fix(lint): spec-refs reaches every compiled surface, and compile emits prettier-clean artifacts (#195)

Closes #190.

spec-refs re-derives a compiled file's references from its spec, but
called compileClaude directly and skipped every spec whose _specType was
not "claude". Four of the five compiled surfaces — skills, subagents,
railway orchestrators, generated harness files — got no re-derivation at
lint time, so an artifact committed while its refs were live stayed green
forever after the target was deleted. Measured here: compile reported
`Skill not found` on examples/SKILL.md while lint reported `hash valid`.

Smaller than the issue estimated. #190 expected a per-type ref accessor
because each compiler reaches its refs differently; they do not — all
four already return the same errors: CompileError[], and only the options
differ. One exhaustive dispatcher, so a fifth spec type is a tsc error
rather than a silent skip.

compile also emitted artifacts that prettier --check then rejected, on
all seven examples/ artifacts — which is why they sat un-recompiled
carrying a retired marker position, and which blocked the fix above. Two
independent defects: both frontmatter renderers padded inside the ---
fence, and the frontmatter/body join added a blank line placeIntegrityHeader
then added again. Fixed at the join, not in the stamper: seal hashes the
content it receives, so trimming there hashes one string and writes
another (measured — it broke integrity on three artifacts).

Also: check.mjs now declares and prints the CI jobs it does NOT run, with
a test asserting that list against ci.yml. It covered one job of six
while its name and its "18/18 passed" read like all of them, and that
cost a cycle. The test earned itself immediately — the hand-written list
missed e2e and named two commands that do not exist.

Also: @openai/codex is pinned. latest was 0.153.1, whose tarball 404s
while its metadata still resolves, so the install step failed on every
branch until pinned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
zernie
2026-09-04 02:44:22 +05:00
committed by GitHub
parent d6b1ad06aa
commit f52fa12c4f
19 changed files with 395 additions and 59 deletions
+9 -1
View File
@@ -206,7 +206,15 @@ jobs:
CC=$(grep -oP -m1 'VALIDATED_CC_VERSION = "\K[0-9.]+' src/dialect-drift.ts)
[ -n "$CC" ] || { echo "could not read VALIDATED_CC_VERSION"; exit 1; }
echo "Pinning @anthropic-ai/claude-code@$CC"
npm i -g "@anthropic-ai/claude-code@$CC" @openai/codex
# Codex is pinned for the SAME reason, and the reason stopped being
# hypothetical on 2026-09-03: `latest` was 0.153.1, whose TARBALL
# 404s while its registry metadata still resolves, so `npm i -g
# @openai/codex` failed for every job on every branch — a red CI
# nobody's diff caused. Reproduced: metadata `npm view` returns
# 0.153.1, GET .../codex-0.153.1.tgz is 404; 0.153.0 and older are
# 200. Leaving this unpinned beside a deliberately pinned
# claude-code let one upstream publish take the build down.
npm i -g "@anthropic-ai/claude-code@$CC" "@openai/codex@0.153.0"
sudo apt-get update && sudo apt-get install -y bubblewrap
- name: Allow unprivileged user namespaces (Ubuntu 24.04 AppArmor restriction)
+3 -1
View File
@@ -101,7 +101,7 @@ declare module "vigiles/generated" {
| "internal:check"
| "docs:api";
/** 411 project files. */
/** 412 project files. */
export type ProjectFile =
| "src/CLAUDE.md"
| "src/CLAUDE.md.spec.ts"
@@ -497,6 +497,7 @@ declare module "vigiles/generated" {
| "src/spec-hooks.mts"
| "src/spec-host.mts"
| "src/spec-loader.test.ts"
| "src/spec-refs-surfaces.test.ts"
| "src/stability-verbs.test.ts"
| "src/stats.test.ts"
| "src/stats.ts"
@@ -980,6 +981,7 @@ declare module "vigiles/spec" {
| "src/spec-hooks.mts"
| "src/spec-host.mts"
| "src/spec-loader.test.ts"
| "src/spec-refs-surfaces.test.ts"
| "src/stability-verbs.test.ts"
| "src/stats.test.ts"
| "src/stats.ts"
+3 -5
View File
@@ -1,14 +1,12 @@
<!-- vigiles:sha256:da271d5e41e8e90e compiled from examples/SKILL.md.spec.ts -->
---
name: pr-to-lint-rule
description: Convert a recurring PR review comment into an automated lint rule with tests and CLAUDE.md annotation
disable-model-invocation: true
argument-hint: <description of recurring PR feedback>
---
<!-- vigiles:sha256:49b12ebc0cf20852 compiled from examples/SKILL.md.spec.ts -->
Convert a recurring PR review comment into an automated lint rule.
## Arguments
@@ -43,7 +41,7 @@ For JavaScript/TypeScript, generate an ESLint rule using the AST visitor pattern
### Step 3: Update `CLAUDE.md`
Add the annotation block. See [enforce-rules-format](skills/enforce-rules-format/SKILL.md) for the correct format.
Add the annotation block. See `docs/spec-format.md` for the correct format.
### Step 4: Verify
+2 -2
View File
@@ -4,7 +4,7 @@
* This is the source of truth. SKILL.md is a compiled build artifact.
* Run `vigiles compile` to generate SKILL.md from this spec.
*/
import { experimental_skill, file, cmd, ref, prose } from "../src/core/spec.js";
import { experimental_skill, file, cmd, prose } from "../src/core/spec.js";
export default experimental_skill({
name: "pr-to-lint-rule",
@@ -48,7 +48,7 @@ For JavaScript/TypeScript, generate an ESLint rule using the AST visitor pattern
### Step 3: Update ${file("CLAUDE.md")}
Add the annotation block. See ${ref("skills/enforce-rules-format/SKILL.md")} for the correct format.
Add the annotation block. See ${file("docs/spec-format.md")} for the correct format.
### Step 4: Verify
@@ -1,14 +1,12 @@
<!-- vigiles:sha256:de5543be89358df1 compiled from examples/harness/dogfood/reviewer-ab/spec/agents/code-reviewer.md.spec.ts -->
---
name: code-reviewer
description: Review a file for correctness defects and report them.
model: sonnet
tools: Read, Grep
---
<!-- vigiles:sha256:272d5a98801ff4dc compiled from examples/harness/dogfood/reviewer-ab/spec/agents/code-reviewer.md.spec.ts -->
<!-- vigiles:purity:pure -->
You are a focused code reviewer.
+2 -4
View File
@@ -1,14 +1,12 @@
<!-- vigiles:sha256:4e15c156920f8c31 compiled from examples/railway/fixer.md.spec.ts -->
---
name: fixer
description: Address a failing step's findings, then re-verify. Dispatched by the railway's bounded recovery.
model: sonnet
tools: Read, Edit, Bash, Grep
---
<!-- vigiles:sha256:d0939b7f32bf79ef compiled from examples/railway/fixer.md.spec.ts -->
You receive a failing step's error payload (findings or logs)
and fix the underlying issue. Re-run `npm test` before reporting success.
If you cannot fix it, return a reason so the railway falls to the error track.
+3 -5
View File
@@ -1,14 +1,12 @@
<!-- vigiles:sha256:df16b875ba61702a compiled from examples/railway/implementer.md.spec.ts -->
---
name: implementer
description: Implement an approved plan: make the edits and prove the build passes. Dispatch after the planner.
description: "Implement an approved plan: make the edits and prove the build passes. Dispatch after the planner."
model: sonnet
tools: Read, Edit, Write, Bash, Grep, Glob
---
<!-- vigiles:sha256:f9fb83e2a8bde272 compiled from examples/railway/implementer.md.spec.ts -->
You implement the plan handed to you, one step at a time. After
the edits, run `npm run build` and `npm test`; only report success
once both pass. On failure, report where you stopped so the fixer can recover.
+2 -4
View File
@@ -1,14 +1,12 @@
<!-- vigiles:sha256:29a509e62504dc31 compiled from examples/railway/planner.md.spec.ts -->
---
name: planner
description: Break a change request into an ordered, reviewable plan. Dispatch FIRST in the ship-pr railway.
model: sonnet
tools: Read, Grep, Glob
---
<!-- vigiles:sha256:cc80c8d85ab45d3f compiled from examples/railway/planner.md.spec.ts -->
You turn a change request into a concrete, ordered plan. Read the
relevant code first; do not write any. Verify the build is green with `npm run build`
before planning around it.
+2 -4
View File
@@ -1,14 +1,12 @@
<!-- vigiles:sha256:2dba1e6b88ef08b6 compiled from examples/railway/reporter.md.spec.ts -->
---
name: reporter
description: Summarize a railway failure for a human. Dispatched on the error track when recovery is exhausted.
model: haiku
tools: Read
---
<!-- vigiles:sha256:b787da3b2291b273 compiled from examples/railway/reporter.md.spec.ts -->
You receive the error payload of the step that failed and the
recovery attempts that were exhausted. Write a concise, factual report: what was
attempted, where it failed, and what a human should look at next.
+2 -4
View File
@@ -1,14 +1,12 @@
<!-- vigiles:sha256:a055143eb4a78162 compiled from examples/railway/reviewer.md.spec.ts -->
---
name: reviewer
description: Review the implemented diff for correctness. Dispatch LAST on the success track.
model: opus
tools: Read, Grep, Bash
---
<!-- vigiles:sha256:d644900ac06e752c compiled from examples/railway/reviewer.md.spec.ts -->
You review the diff for correctness and regressions. Re-run
`npm test` yourself — do not trust the report. Approve only when the change
is correct; otherwise return concrete, actionable findings.
+2 -4
View File
@@ -1,13 +1,11 @@
<!-- vigiles:sha256:4c614a2e08f000bb compiled from examples/ship-pr/SKILL.md.spec.ts -->
---
name: ship-pr
description: Run the project checks and open a pull request once they pass
argument-hint: <branch> [<title>]
---
<!-- vigiles:sha256:149214b0d9c16174 compiled from examples/ship-pr/SKILL.md.spec.ts -->
## Arguments
- `$1` **branch** — the branch to open the PR from
+88
View File
@@ -0,0 +1,88 @@
/**
* `scripts/check.mjs` accounts for every job in ci.yml covered, or declared
* as NOT covered with the command that does run it.
*
* WHY THIS EXISTS. check.mjs was written so nobody would run a remembered
* subset of CI (its own header: three PRs went red from a five-command list).
* It then covered ONE of the workflow's jobs while reading, from its name and
* its `18/18 passed`, like all of them and on 2026-09-03 that cost a cycle:
* a green `check` was taken for a green CI and the push broke the `test` job.
*
* A list of what-we-do-not-cover is the same hand-maintained list check.mjs
* exists to abolish, one level up, so it is not trusted either: a NEW job in
* ci.yml fails HERE until someone either covers it or names it, rather than
* becoming a seventh thing that runs only in CI.
*
* Both files are read as TEXT. Importing check.mjs would RUN it the module
* body is the check run which is the same "an import is a run" trap the KB
* records for eval files.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const root = join(import.meta.dirname, "..");
const workflow = readFileSync(join(root, ".github/workflows/ci.yml"), "utf8");
const checkSource = readFileSync(join(root, "scripts/check.mjs"), "utf8");
/** Top-level keys under `jobs:` — NOT every 2-space key (`on:` has `push:`). */
function ciJobs(yml: string): string[] {
const lines = yml.split("\n");
const start = lines.findIndex((l) => /^jobs:\s*$/.test(l));
if (start === -1) throw new Error("ci.yml has no top-level `jobs:` block");
const jobs: string[] = [];
for (const line of lines.slice(start + 1)) {
if (/^\S/.test(line)) break; // dedent out of `jobs:`
const m = /^ {2}([a-z][a-z0-9_-]*):\s*$/.exec(line);
if (m) jobs.push(m[1]);
}
return jobs;
}
/** The `job:` fields of CI_JOBS_NOT_COVERED, read from the source text. */
function declaredNotCovered(src: string): string[] {
const block = /export const CI_JOBS_NOT_COVERED = \[([\s\S]*?)\n\];/.exec(
src,
);
if (!block)
throw new Error("check.mjs no longer declares CI_JOBS_NOT_COVERED");
return [...block[1].matchAll(/\bjob:\s*"([^"]+)"/g)].map((m) => m[1]);
}
describe("check.mjs accounts for every CI job", () => {
const jobs = ciJobs(workflow);
const notCovered = declaredNotCovered(checkSource);
it("finds the jobs and the declaration (neither parse silently empty)", () => {
expect(jobs.length).toBeGreaterThan(1);
expect(notCovered.length).toBeGreaterThan(0);
expect(jobs).toContain("check"); // the one job check.mjs DOES run
});
it("every ci.yml job is either `check` or declared not-covered", () => {
const accounted = new Set(["check", ...notCovered]);
expect(jobs.filter((j) => !accounted.has(j))).toEqual([]);
});
it("declares no job ci.yml does not have", () => {
expect(notCovered.filter((j) => !jobs.includes(j))).toEqual([]);
});
it("every not-covered entry carries a cmd field and a reason", () => {
// Split on OBJECT boundaries, not lines: prettier wraps each entry across
// several lines, and a line-based scan silently checked nothing.
const block = /export const CI_JOBS_NOT_COVERED = \[([\s\S]*?)\n\];/.exec(
checkSource,
)![1];
const entries = block.split("},").filter((e) => e.includes("job:"));
expect(entries).toHaveLength(notCovered.length);
for (const entry of entries) {
expect(entry, `entry needs a reason: ${entry}`).toMatch(/why:\s*"[^"]+"/);
expect(entry, `entry needs a cmd field: ${entry}`).toMatch(/cmd:\s*"/);
}
});
it("prints the gap — a green run cannot read as a green CI", () => {
expect(checkSource).toMatch(/NOT covered here/);
});
});
+54
View File
@@ -36,6 +36,52 @@
import { spawn } from "node:child_process";
import { cpus } from "node:os";
/**
* The CI jobs this file does NOT run, and the command that does.
*
* 🔴 THE NAME OVERPROMISES, AND THAT COST A CYCLE (2026-09-03). This file's own
* header says it exists so nobody runs a remembered subset and it covers ONE
* of the workflow's jobs. Reading `18/18 passed` as "CI would pass" is exactly
* the mistake it was written to prevent, one level up: a green `check` says
* nothing about `test`, which is where a UNC-denylist regression was caught
* after a push made on this file's word alone.
*
* `npm run coverage` is NOT folded in: it is ~200s against this file's ~70s, and
* a pre-push gate people stop running is worse than a fast one that says what it
* skipped. So the gap is declared and PRINTED instead of silently held.
*
* `scripts/check-covers-ci.test.ts` asserts this list plus `check` accounts for
* every job in ci.yml, so a NEW job is a failing test rather than a silent
* seventh thing nobody runs locally.
*/
export const CI_JOBS_NOT_COVERED = [
{
job: "test",
cmd: "npm run coverage",
why: "vitest + the 100% coverage gate — the job a green `check` says nothing about",
},
{
job: "e2e",
cmd: "npm run test:e2e",
why: "the e2e vitest project (real built CLI over fixture repos)",
},
{
job: "harness",
cmd: "npm run test:harness",
why: "the deterministic harness tier — needs the claude binary",
},
{
job: "site",
cmd: "npm --prefix site run test:browser && npm --prefix site run test:e2e",
why: "the site's browser + Playwright tests — needs a Chromium download",
},
{
job: "changes",
cmd: "",
why: "the path-filter classifier — decides which jobs run; nothing to run locally",
},
];
/** Stage 1 — everything downstream reads `dist/`. */
const BUILD = { name: "build", cmd: "npm run build" };
@@ -157,6 +203,14 @@ console.log(
`\n${ALL.length - failures.length}/${ALL.length} passed in ${((Date.now() - t0) / 1000).toFixed(0)}s`,
);
// Say what this run did NOT cover. A green `check` is not a green CI, and the
// difference is invisible unless it is printed.
const runnable = CI_JOBS_NOT_COVERED.filter((j) => j.cmd);
console.log(
`\nNOT covered here — ${runnable.length} more CI job(s):\n` +
runnable.map((j) => ` ${j.job.padEnd(8)} ${j.cmd}`).join("\n"),
);
if (failures.length) {
for (const f of failures) {
console.error(
+66 -10
View File
@@ -1873,22 +1873,24 @@ async function checkSpecRefs(
const sev = ruleSeverity(config?.rules?.["spec-refs"]) ?? "error";
if (!sev) return { issues: 0, errors: 0 };
const found: string[] = [];
let knownAgents: readonly string[] | undefined;
for (const specPath of findSpecs(excludes)) {
const target = specPath.replace(/\.spec\.ts$/, "");
if (!existsSync(target)) continue; // never compiled — `compile` reports it
const spec = await loadSpec(specPath);
if (!spec || spec._specType !== "claude") continue;
if (!spec) continue;
try {
const { errors } = compileClaude(spec, {
basePath: process.cwd(),
specFile: specPath,
// Railway is the one type whose validation needs the sibling agent names;
// collected lazily so a repo with no railway spec never pays for the walk.
if (spec._specType === "railway")
knownAgents ??= await collectAgentNames(excludes);
const errors = specCompileErrors(
spec,
specPath,
dialect,
maxRules: config?.maxRules,
maxTokens: config?.maxTokens,
maxSectionLines: config?.maxSectionLines,
catalogOnly: config?.catalogOnly,
linters: config?.linters,
});
config,
knownAgents ?? [],
);
for (const e of errors)
found.push(`${target}: ${e.message} (from ${specPath})`);
} catch {
@@ -1907,6 +1909,60 @@ async function checkSpecRefs(
return { issues: found.length, errors: sev === "error" ? found.length : 0 };
}
/**
* The ONE spec-type dispatcher, in the only shape both directions can share:
* errors, no writing. `compile` reaches its compilers through the
* `compile*ToFile` wrappers (which write and print); `checkSpecRefs` reaches the
* SAME compilers through this, because a read must never write.
*
* Why it exists (#190): `checkSpecRefs` used to call `compileClaude` directly and
* skip every other spec type, so a SKILL.md / subagent / railway artifact
* committed with a since-deleted reference hashed cleanly against its own header
* forever `lint` green, `compile` red. Measured on this repo:
* `examples/SKILL.md.spec.ts` names `skills/enforce-rules-format/SKILL.md`, which
* does not exist (the skill lives under `.claude/skills/`), and `lint` reported
* `hash valid`.
*
* A switch rather than a per-type "ref accessor": all four compilers already
* return the same `errors: CompileError[]`, so there is nothing to normalize
* only the options differ. Exhaustive over `_specType`, so a FIFTH spec type is
* a tsc error here instead of a silent skip, which is the failure this closes.
*/
function specCompileErrors(
spec: AnySpec,
specPath: string,
dialect: HarnessDialect,
config: VigilesConfig | undefined,
knownAgents: readonly string[],
): readonly CompileError[] {
const basePath = process.cwd();
switch (spec._specType) {
case "claude":
return compileClaude(spec, {
basePath,
specFile: specPath,
dialect,
maxRules: config?.maxRules,
maxTokens: config?.maxTokens,
maxSectionLines: config?.maxSectionLines,
catalogOnly: config?.catalogOnly,
linters: config?.linters,
}).errors;
case "skill":
return compileSkill(spec, { basePath, specFile: specPath, dialect })
.errors;
case "agent":
return compileAgent(spec, { basePath, specFile: specPath, dialect })
.errors;
case "railway":
return compileRailway(spec, { specFile: specPath, knownAgents }).errors;
default:
// A `pipeline` spec compiles through its underlying railway, so it has no
// artifact of its own to re-derive refs for.
return [];
}
}
/**
* Write a compiled artifact. Accepts ONLY a {@link StampedMarkdown}, so a body
* that failed to compile cannot reach the disk there is no stamp to pass.
+4 -2
View File
@@ -4,8 +4,10 @@
* Each case encodes a semantics-preserving obfuscation that a Lit-only matcher
* (built on `leafCommands`) is defeated by, and asserts the normalized form
* exposes the underlying OPERATION (basename head, unwrapped args, canonical
* flags, $HOME~). This is the primitive the hardened guard
* (examples/harness/safe-bash-guard-v2.mjs) matches over.
* flags, $HOME~). This is the primitive the closed vocabulary's `runs()`,
* `touches()` and `pipesToShell()` match over which is why the shipped
* guard (examples/harness/safe-bash-guard.mjs) is robust to these forms
* without naming any of them.
*/
import { test } from "vitest";
import assert from "node:assert/strict";
+20 -6
View File
@@ -906,7 +906,6 @@ function renderSkillFrontmatter(
): string {
const fm = [
"---",
"",
`name: ${yamlScalar(spec.name)}`,
`description: ${yamlScalar(spec.description)}`,
];
@@ -941,7 +940,7 @@ function renderSkillFrontmatter(
fm.push(`disallowed-tools: [${spec.disallowedTools.join(", ")}]`);
}
}
fm.push("", "---");
fm.push("---");
return fm.join("\n");
}
@@ -1134,7 +1133,15 @@ export function compileSkill(
const marker = purityMarker(spec.purity);
const content =
renderSkillFrontmatter(spec, profile) +
"\n\n" +
// ONE newline, not two: `placeIntegrityHeader` puts the stamp AFTER the
// frontmatter and supplies its own blank line on each side, so a second one
// here becomes two blank lines in the artifact — which `prettier --check`
// rejects, and a freshly compiled artifact then cannot pass `npm run check`.
// Fixed HERE rather than in the stamper: the hash is computed over this
// content (compile.ts `seal`), so trimming inside `placeIntegrityHeader`
// would hash one string and write another — measured, it broke integrity on
// all three frontmatter-bearing artifacts.
"\n" +
(marker ? marker + "\n\n" : "") +
sections.trim() +
"\n";
@@ -1183,7 +1190,6 @@ function renderAgentFrontmatter(spec: AgentSpec): string {
// {@link yamlScalar}.
const fm = [
"---",
"",
`name: ${yamlScalar(spec.name)}`,
`description: ${yamlScalar(spec.description)}`,
];
@@ -1195,7 +1201,7 @@ function renderAgentFrontmatter(spec: AgentSpec): string {
if (spec.disallowedTools && spec.disallowedTools.length > 0) {
fm.push(`disallowedTools: ${spec.disallowedTools.join(", ")}`);
}
fm.push("", "---");
fm.push("---");
return fm.join("\n");
}
@@ -1374,7 +1380,15 @@ export function compileAgent(
const marker = purityMarker(spec.purity);
const content =
renderAgentFrontmatter(spec) +
"\n\n" +
// ONE newline, not two: `placeIntegrityHeader` puts the stamp AFTER the
// frontmatter and supplies its own blank line on each side, so a second one
// here becomes two blank lines in the artifact — which `prettier --check`
// rejects, and a freshly compiled artifact then cannot pass `npm run check`.
// Fixed HERE rather than in the stamper: the hash is computed over this
// content (compile.ts `seal`), so trimming inside `placeIntegrityHeader`
// would hash one string and write another — measured, it broke integrity on
// all three frontmatter-bearing artifacts.
"\n" +
(marker ? marker + "\n\n" : "") +
body.trim() +
"\n";
+47 -1
View File
@@ -12,8 +12,9 @@ import {
REQUIRE_INSTRUCTIONS_SPEC_DISABLE,
} from "./integrity.js";
import { sha256short } from "./hash.js";
import { addHash } from "./compile.js";
import { addHash, compileSkill } from "./compile.js";
import { experimental_skill } from "./spec.js";
import { claudeCodeDialect as dialect } from "../adapters/claude-code/dialect.js";
const { input } = experimental_skill;
/** Build a compiled-file string with a VALID header for `body`. */
@@ -212,3 +213,48 @@ describe("input() refuses a non-string call", () => {
});
});
});
/**
* A COMPILED artifact must be prettier-clean, or the tool cannot pass its own
* `npm run check` after a recompile measured 2026-09-03 on all seven
* `examples/` artifacts, which is why the seven sat un-recompiled with an
* out-of-date marker position instead of being regenerated.
*
* Two separate defects produced it, and both are asserted here because each one
* alone is enough to fail `prettier --check`:
*
* 1. the frontmatter renderers padded the INSIDE of the `---` fence
* 2. the frontmatter/body join added a blank line that `placeIntegrityHeader`
* then added again
*
* Fixed at the JOIN, not in the stamper: the hash is computed over the content
* `seal` receives, so trimming inside `placeIntegrityHeader` hashes one string
* and writes another that attempt broke integrity on all three
* frontmatter-bearing artifacts before it was reverted.
*/
describe("a compiled artifact is prettier-clean by construction", () => {
const compiled = compileSkill(
experimental_skill({
name: "fixture",
description: "One line, no surprises",
body: "Body line.",
}),
{ basePath: process.cwd(), specFile: "SKILL.md.spec.ts", dialect },
);
it("compiles", () => {
expect(compiled.errors).toEqual([]);
expect(compiled.artifact).not.toBeNull();
});
it("has no blank line INSIDE the frontmatter fence", () => {
const md = compiled.artifact ?? "";
const fence = /^---\r?\n([\s\S]*?)\r?\n---/.exec(md);
expect(fence, "no frontmatter found").not.toBeNull();
expect(fence?.[1]).not.toMatch(/^\s*$/m);
});
it("never emits two consecutive blank lines", () => {
expect(compiled.artifact ?? "").not.toMatch(/\n[ \t]*\n[ \t]*\n/);
});
});
+14 -2
View File
@@ -184,7 +184,19 @@ export type {
ProviderRegistry,
} from "./core/hook-providers.js";
// Operation-normalized leaf extraction — the robust matching primitive a
// hardened guard is built on (see examples/harness/safe-bash-guard-v2.mjs).
// Operation-normalized leaf extraction — the primitive `runs()`, `touches()`
// and `pipesToShell()` match over, so a guard written against the closed
// vocabulary sees the OPERATION rather than the literal tokens.
//
// It is exported because `examples/harness/safe-bash-guard-v2.mjs` needs it —
// and that need is the finding, not the feature. v2 is an UNRUN experiment (no
// test executes it) covering three things the vocabulary cannot yet express: a
// flag with a value (`--index-url`), an env-assignment prefix, and a
// cross-leaf pipeline fact (`env | curl`). Reaching for this export is how a
// guard author escapes the closed vocabulary — the thing `checkHookImports`
// exists to prevent — so treat a new consumer as a request for vocabulary,
// not as the intended path. Measured 2026-09-03: on the 7 seeds plus every
// generated spelling (143 cases) v1 and v2 both block 143/143, so v2 is no
// longer the "hardened" one; see zernie/vigiles#193.
export { leafCommandsNormalized } from "./core/bash-effects.js";
export type { NormalizedLeaf, LeafRedirect } from "./core/bash-effects.js";
+70
View File
@@ -0,0 +1,70 @@
/**
* `spec-refs` on the surfaces that are NOT `claude` (#190).
*
* The rule re-derives a compiled file's references from its spec and validates
* them the way `compile` does. It used to call `compileClaude` directly and skip
* every other spec type, so a SKILL.md / subagent / railway artifact committed
* while its refs were live stayed green forever after the target was deleted:
* `lint` reported `hash valid` (the artifact DOES match its own header that is
* the wrong question) while `compile` reported the broken ref.
*
* Measured on this repo when the gap was found: `examples/SKILL.md.spec.ts` named
* `skills/enforce-rules-format/SKILL.md`, which does not exist, and `lint` was
* green on it.
*
* The fixture reproduces the ORDER that makes it a lint question rather than a
* compile one compile while the ref resolves, THEN delete the target. A test
* that merely wrote a broken spec would be caught by `compile` and prove nothing
* about `lint`.
*/
import { describe, it, beforeAll, afterAll } from "vitest";
import assert from "node:assert/strict";
import { mkdtempSync, writeFileSync, rmSync, existsSync } from "node:fs";
import { join, resolve } from "node:path";
import { tmpdir } from "node:os";
import { spawnSync } from "node:child_process";
const CLI = resolve(__dirname, "..", "dist", "cli.js");
let dir: string;
const run = (args: string[]) =>
spawnSync("node", [CLI, ...args], { cwd: dir, encoding: "utf8" });
beforeAll(() => {
dir = mkdtempSync(join(tmpdir(), "vigiles-spec-refs-surfaces-"));
writeFileSync(join(dir, "TARGET.md"), "# the file the skill points at\n");
writeFileSync(
join(dir, "SKILL.md.spec.ts"),
[
`import { experimental_skill, file, prose } from ${JSON.stringify(resolve(__dirname, "core", "spec.js"))};`,
"export default experimental_skill({",
' name: "fixture",',
' description: "A fixture skill whose one reference is about to die",',
' body: prose`See ${file("TARGET.md")} for the format.`,',
"});",
"",
].join("\n"),
);
});
afterAll(() => {
rmSync(dir, { recursive: true, force: true });
});
describe("spec-refs reaches non-claude surfaces", () => {
it("compiles clean while the ref resolves", () => {
const r = run(["compile"]);
assert.equal(r.status, 0, r.stdout + r.stderr);
assert.ok(existsSync(join(dir, "SKILL.md")), "SKILL.md was not written");
});
it("lint FLAGS the skill once its target is deleted", () => {
// The artifact still matches its own hash — integrity has nothing to say.
// Only re-deriving the refs from the spec can see this.
rmSync(join(dir, "TARGET.md"));
const r = run(["lint"]);
const out = r.stdout + r.stderr;
assert.match(out, /Spec reference check/, out);
assert.match(out, /TARGET\.md/, out);
});
});