mirror of
https://github.com/zernie/vigiles.git
synced 2026-09-14 20:53:57 +08:00
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:
@@ -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)
|
||||
|
||||
Vendored
+3
-1
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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,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
@@ -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";
|
||||
|
||||
@@ -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
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user