mirror of
https://github.com/zernie/vigiles.git
synced 2026-09-14 20:53:57 +08:00
45a7690ec2
* fix(site): replace unmeasured claims about other tools with a measurement The hero opened with "Nothing checks that the tools, events, files and rules your skills and hooks name actually exist." Nobody had ever run the other tool. Two things were wrong with the sentence: it is a universal negative over every tool that exists, which is unmeasurable, and on its own terms it is false for one of its four nouns. Measured instead, and the probe is committed as tools/measure-validate-overlap.mjs so the next reader reruns it rather than trusting prose. Against Claude Code 2.1.263, seven planted defects, both harness shapes: repo-local .claude/ 0 of 7 flagged, default AND --strict packaged plugin 1 of 7 — "hooks.PreToolUze: unknown hook event" The silence is real silence: the same binary errors on invalid manifest JSON and on a hooks.json missing its root key, and warns on an agent with no description, so it does read these files. That measurement moved claims in BOTH directions, which is the point: - Hero drops the universal negative and describes only what vigiles does. - Wedge credits `claude plugin validate` with the hook-event check it really makes, scoped to plugin form, and stops listing that check among the ones it misses. A draft of this paragraph had it in the wrong column. - docs/rules/hook-script-exists.md said the rule "matches Anthropic's own `claude plugin validate`, making vigiles a superset on the hook surface". Measured false — validate does not check hook-script existence at all. We were under-crediting our own rule. Corrected in place rather than deleted, so it does not read as a wording change and get added back. - Same false credit removed from the subagent-frontmatter keyFile: validate flags neither `model: sonnnet` nor `color: purpel`, and cclint was never run. Every claim of exclusivity, scope or comparison now carries a comment above it with the claim, the command that backs it and the version, what the earlier version said and why it was wrong, and what would invalidate it. The record of the mistake is the part that prevents the repeat. Two live falsehoods found on the page while verifying the above: - The first failure card and the `audit` example both used `event: "Setup"` as a hook event that does not exist. `Setup` IS a real Claude Code event, with its own docs sections; this repo's own catalog learned that on 2026-08-17 and the site never followed. The page's opening example of "config that parses and does the wrong thing" was config that works. Now `PreToolUze`, verified wrong in both directions against the real binary. - The `eval` row quoted the debunked claim as 65%. Three places in this repo and the linked post all say 65%. A debunk that misquotes its own target is the one number a reader checks in ten seconds. Also: the hero's language chip said "Any language · 11 linter catalogs" — an overclaim wrapped around a term a cold visitor cannot gloss, which never told a Python developer that Ruff and Pylint are covered. It now names the languages, still derived from BUILTIN_LINTERS, with a test that fails if a shipped linter has no language decision or a covered language is missing from the display order. Both directions proved by mutation. The landing-site skill is committed in this public repo and carried a "backend (roadmap)" design for an unbuilt capability plus a growth-loop framing, which no-product-strategy-here forbids here. The technical guidance stays; the roadmap and the growth narrative are gone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: replace the unmeasured exclusivity claim with the mechanism "No other tool resolves rules across 11 catalog APIs" stood in five places. It rested on a competitor matrix whose cells were checked against DOCUMENTATION rather than against a run — the same matrix that put a false claim on the landing page, corrected in the previous commit after measuring the real binary. Two independent reasons to drop it, not one: 1. It was never measured. Verifying it means enumerating 11 catalog APIs across every comparable tool, which nobody did. 2. This repository is public, and its own `no-product-strategy-here` rule forbids competitive positioning here outright. "The core moat" and "no other tool" are positioning, so those lines were a rule violation independent of whether they happened to be true. Replaced with what the engine actually does, which a reader can check by running it: the rule name is resolved against the linter's own catalog rather than matched as a string, and the enabled state is read from the project's config. The removal is recorded in the source rather than performed silently. A silent deletion reads as a wording change and invites the claim back; the comment in linters.ts says what stood there and why it went. Sites: docs/linter-support.md, docs/what-vigiles-catches.md, src/core/linters.ts, CLAUDE.md.spec.ts, examples/CLAUDE.md.spec.ts (the two CLAUDE.md artifacts recompiled). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(compile): the section size guard measured the wrong unit `DEFAULT_MAX_SECTION_LINES = 200` reads as "a section may not sprawl" and never fired on the section that actually sprawled. Two reasons, both measured on this repo 2026-09-08: 1. The cap counts LINES. A `keyFiles` entry is ONE line of any length, so `## Key Files` reached 173 KB — 59% of the compiled CLAUDE.md — while staying far under 200 lines. Longest single description: 4782 chars. 2. `keyFiles` never reached the check at all. `compileKeyFilesSection` is a separate path that calls `validateFileRef` and nothing else, so the one section with no length control was the one that grew. Both closed: a character budget beside the line budget (whichever runs out first reports), and a per-entry cap on a key-file description, since an entry is a pointer and the reasoning belongs in that file's own header where a reader meets it on opening the file. RATCHETED, not bare. Introduced bare these caps open with 60 findings and get switched off the same day. `.vigiles/keyfile-debt.json` records the existing oversize once; the caps report only what is NEW or has GROWN, and a repo without the file gets every finding, which is the right default for everyone else. Lowering a number is the fix; raising one is the edit the file exists to make visible in review. Verified in three directions, not one: silent on the recorded debt, fires on an entry whose recorded size was lowered, fires on an entry removed from the debt entirely. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(docs): withdraw the unmeasured #30519 matcher claim, and record the probe `docs/compiled-hooks.md` and CLAUDE.md both said the native `if:` matcher misses a compound `cd repo && git push -f`, citing #30519, and offered that as a reason to prefer the AST-backed `runs()`. Measured against Claude Code 2.1.263: the native matcher CAUGHT the compound command, in both spellings. Either the issue was fixed the way #34692 was, or the claim was wrong when written. Withdrawn rather than repeated; a focused re-measure decides. What the same run DID establish is a real gap, and a sharper one: form input hook spawned prefix git push --force origin main YES prefix /usr/bin/git push --force origin main NO glob git push --force origin main YES glob /usr/bin/git push --force origin main NO An ABSOLUTE program head is matched by neither spelling. That holds for `Bash(git push:*)`, the form Anthropic ships in its own security-guidance plugin, as much as for the glob form — so a guard written either way does not run on `/usr/bin/git push --force`, which is the whole guard when the hook body is unconditional. The probe is committed as tools/measure-if-matcher-forms.mjs so the next person re-runs it after a Claude Code minor instead of trusting this note. It measures someone else's matcher, and prose cannot notice when that moves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(claude-md): key files are pointers again — 73k tokens to 43k `## Key Files` was 59% of the compiled CLAUDE.md. Its 266 entries had grown from index lines into paragraphs, median 371 characters, longest 4782 — and every one of them is loaded on every turn of every session in this repo. Measured before cutting anything: 220 of the 266 files ALREADY carry the same reasoning in their own header comment, usually in a fuller form. So this is mostly not a move, it is deletion of a duplicate that had drifted from the copy people actually read when they open the file. For the 17 entries whose file had no header, the prose was moved into one rather than dropped; 29 short entries were left alone. compiled CLAUDE.md 291579 -> 173682 chars ~72900 -> ~43400 tokens AND THE RATCHET IS GONE. The cap landed an hour earlier with a `.vigiles/keyfile-debt.json` recording 60 existing violations so it would not open loud enough to be switched off. That was the wrong order of work: a mechanism to tolerate the problem, invented instead of spending one pass fixing it. With the entries pruned the debt file is empty, so both it and the code reading it are deleted and the cap stands bare — 0 findings on a clean tree, fires on a lengthened entry. The section CHARACTER cap goes with it, for the same reason it should never have shipped ratcheted: `positioning` is 24877 characters and would fail it today, and a guard that cannot fire is the defect this product is about. That section is a real and separate piece of work, named here rather than recorded as debt. Verified in both directions on a clean tree: silent at 0 findings, and a mutation that lengthens one description fails on its own assertion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(tools): record the WIDENED if: forms, which are the ones that are sound The probe measured the two `if:` spellings found in the wild and stopped there, leaving the useful half unmeasured: what a native `if:` would have to look like for a compiler to be allowed to emit one. Measured on Claude Code 2.1.263: emitted if: plain absolute unrelated Bash(git push *--force*) fires NO no Bash(git push:*) fires NO no Bash(*git*) fires fires no Bash(*) fires fires fires The natural translation of a typed predicate is strictly NARROWER than the body it guards — it misses an absolute program head — so emitting it turns a guard off on exactly the input a careful author wrote it for. Widening to the PROGRAM NAME alone keeps every case the body would catch and still filters unrelated commands, which is the property an emission needs: it may only ever add spawns, never remove one. Flags and subcommand refinement are dropped on purpose. Including them is what makes the natural translation too narrow, and the body re-checks them anyway. Left explicitly unmeasured so nobody assumes it: the widening for `touches()` and `pipesToShell()`. Either may have none, and the honest answer for such a primitive is to emit nothing rather than something that looks like a filter. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(claude-md): the two "layer" paragraphs point at their guides again `## Positioning` was 24891 characters, and two of its twelve paragraphs were 64% of it: Second layer — testing the harness 7859 chars Third layer — COMPILED HOOKS 8108 chars Both are summaries of a document that already exists, is far richer, and was already linked from the same paragraph: docs/harness-testing.md 37114 chars 4.8x the summary docs/compiled-hooks.md 62167 chars 7.8x the summary Same shape as the key-files entries in the previous commit: a copy that duplicates the text people actually read, drifts from it, and is loaded into every session's context whether or not the turn is about hooks. What was KEPT in each is the part that constrains what gets built or claimed, because that has to be in context and cannot live behind a link: the tier discipline (push a question down a tier; only firing and behaviour-movement touch a real model), that the mock-model tier is table stakes and must not be sold as distinctive, the `experimental_*` marking, that compile and verify fix authoring and logic and never delivery, and that a gate is a strong default and never an unbypassable wall. What went to the guides is the mechanics: the tier APIs, the role family, the vocabulary, the stamp. ## Positioning 24891 -> 11361 chars CLAUDE.md 43420 -> 40038 tokens (72894 before this branch) The section CHARACTER cap is still not restored, and this says why rather than leaving it looking forgotten: `## Rules` is 82586 characters and would fail any cap that means anything. Rules are a different problem — measured across 53 of them, median 1449 characters, and unlike a key-files entry or a layer summary there is no richer copy elsewhere to point at, because a rule's text IS the instruction. Trimming it loses information rather than removing a duplicate, so it is judgement, not a mechanical pass, and a 500-char cap would open on 40 of 53 and be switched off the same day. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(hook)!: a bash gate is Bash by construction — delete its match field `HookProgram.match: { tool: string }` was a representable illegal state, and the same false-confidence class the module header says this subsystem exists to eliminate: if (rawEvent.tool_name !== program.match.tool) return allow(); `match: tool("Edit")` type-checked, compiled, wired a PreToolUse matcher `Edit`, fired on edits, built `commandView("")` from a missing `tool_input.command`, found every predicate false and returned allow() — a silently dead guard. The comparison was `!==` while every sibling role routes through `matchesTool`; that runtime/emit disagreement is the one MEASURED and fixed for reacts on 2026-08-12 and documented in this very file's header. It survived on the flagship role. The field carried no information either: `BashToolEvent.tool` is the literal "Bash", and all 34 call sites across both repos wrote `match: tool("Bash")`. So the fix is deletion rather than unification: the matcher moves to `hookRouting`, the runtime keeps a defensive `!== "Bash"` on a literal nobody can mis-set, and the author's file loses a line it could only get wrong. BREAKING CHANGE: `tool` is no longer exported from `vigiles/hook`, and `HookProgram` no longer accepts `match`. Both are part of the compiled-hook vocabulary, whose entry points are `experimental_*`; taken now precisely because the vocabulary is still young. `tools()` — the plural, for file gates and reacts — is untouched, and so is the `tool()` CHECK in `vigiles` (a different function that happens to share the name). Found by an architecture review that was asked to break a different proposal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(hook): restore the CHECK `tool()` a careless sweep took with it The previous commit removed `tool` from the compiled-hook vocabulary, and the regex that cleared its call sites also stripped `tool,` from `src/test.ts` — the `vigiles` ROOT barrel, where a DIFFERENT `tool()` lives: the check from `src/check.ts`, used as `assertChecks(run, [tool("Bash")])`. Two unrelated functions that happen to share a name, and the sweep could not tell them apart. That silently removed a stable public export. Nothing in the change was about it, the commit message did not mention it, and the api-surface diff that would have shown it was regenerated only afterwards. Caught by `docs-imports`, which type-checks the code blocks in `docs/` against the real surface and failed on `docs/testing-api.md:210` importing `tool` from `"vigiles"`. Worth noting which gate did it: the unit tests stayed green, because no test imports that symbol from the root barrel. A doc example was the only consumer standing in for every external one. Also completes the docs half of the previous commit, which shipped without it: `docs/compiled-hooks.md` still imported `tool` from `vigiles/hook` in three places, wrote `match: tool("Bash")` in four, and listed `tool(name)` in the vocabulary — all naming a function that no longer exists. And `api-surface/vigiles-hook.api.md` is regenerated, so the removal is visible as a reviewed diff rather than a surprise. Gates: `npm run check` — the only failure is `cli-lint`, whose 480 errors are all inside other sessions' gitignored worktrees (0 outside; CI never sees them). `docs-imports` 0 findings. `src/hook.test.ts` + `src/core/hook-program.test.ts` 120/120. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(examples): drop the `tool` import the sweep left behind Two example hooks kept importing `tool` from the hook vocabulary after the sweep removed their `match: tool("Bash")` line — and `tool` no longer exists, so the module failed to load: HookCompileError: Cannot load hook "examples/harness/protect-main.hook.mjs": Named export 'tool' not found. `compiled-hook-inprocess.harness.mjs` loads that file BY PATH and asserts over its decisions, so it went red. The runtime behaved correctly — a hook that cannot load must not pass traffic — which is why the failure surfaced as a refusal rather than a silent skip. HOW IT REACHED CI, which is the part worth recording: `npm run check` prints the CI jobs it does not cover, and `harness` was on that list. The change touched hooks throughout. I read the list, decided the harness tier was the known-environment-red one (`claude` 2.1.187 pinned vs 2.1.263 installed) and skipped it — while the note that records that redness says in as many words that redness IN CI is no longer environmental. The printed list did its job; I did not do mine with it. The unit tiers could not have caught this either: `src/hook.test.ts` writes its fixtures as source text and was updated with the sweep, so 120/120 stayed green while a shipped example was unloadable. Verified: `node examples/harness/compiled-hook-inprocess.harness.mjs` → 6/6. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(test): the `tool` import inside a fixture STRING, which no sweep reached `hook-state-store.test.ts:387` expected a throttled gate to stay quiet on a ten-minute-old fact and got `blocked: true`. The gate was not misbehaving — it could not load: const THROTTLED_GATE = `import { experimental_defineHook, tool, state, … }` `tool` was removed two commits ago, so the module threw on import and the runtime failed closed, which is correct behaviour for a gate that cannot run. Same in `hook-load-wedge.test.ts` and `core/hook-program.test.ts`. WHY THIS TOOK THREE ROUNDS TO FINISH, since that is the reusable part. The reference lived in four forms and a regex over source reaches one: real import in an example .mjs caught by the CI `harness` job re-export in a barrel caught by `docs-imports` import inside a template STRING caught by the CI `test` job code block in a doc caught by `docs-imports` That is verbatim the class already written down in this project's own notes after `.claude/pipeline/` was relocated — "four forms of a reference, of which a path search sees ONE" — including its conclusion that the only check which covers all four is one that RESOLVES the reference, not one that greps for it. I could have quoted the rule and still edited by regex and verified by a subset, three times running. Verified this time by the whole suite rather than the files I expected to break. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(test): a FIFTH spelling of the same import — built by concatenation `cli-coverage-record.test.ts` builds its fixture's import line with string concatenation: `import { experimental_defineHook, tool, deny, allow } from ` + JSON.stringify(HOOK_ENTRY) + … so no search for `from "…"` reaches it. Two tests failed because the fixture hook could not load and the runtime failed closed — correct behaviour, stale fixture. That is the fifth form this one removed symbol lived in. The running tally, because the tally is the finding: real import in an example .mjs caught by the CI `harness` job re-export in a barrel caught by `docs-imports` import inside a fixture template caught by the CI `test` job code block in a doc caught by `docs-imports` import assembled by concatenation caught by the full local suite Five spellings, four different gates, and every one of them found by a gate rather than by the sweep that created the problem. The lesson already written down in this project — that a reference search sees one form and only RESOLVING the reference sees all of them — held on every round. Verified: `npx vitest run src/cli-coverage-record.test.ts` 16/16. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: say why the emitted hook block has a matcher and no native `if:` Claude Code lets a hook action carry an `if:` precondition, checked before the hook process spawns. `vigiles compile` does not emit one, and the guide never said why — the first question a reader who knows Claude Code will ask, with no public home. That fails `document-the-why`. The rule the decision rests on: a prefilter may only ever be a proven SUPERSET of the typed predicate. Narrower and the process never starts, nothing is logged, and the guard reads as passing — the silent-guard bug this whole page exists to make unwritable. The safe superset is not a useful filter: `command.runs("git push", {force})` is AST-backed (compound, subshell, pipeline, absolute program head), and the narrowest string condition provably covering that is about `Bash(*git*)`. Same measurement as the "Matcher bypass" row (tools/measure-if-matcher-forms.mjs, 2.1.263). Two smaller reasons: `if:` is Claude Code only (Codex has no equivalent field, so emitting it would make one typed source mean different scopes per harness), and it would not buy the latency it appears to — same-event hooks run concurrently, so a filtered-out hook does not shorten the turn by its own duration. Per-event cost is the runtime's startup, which is where it belongs. Also cross-referenced from the "Runtime cost" limitation, which previously left `if:` looking like the obvious escape hatch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: `tool` is no longer in the hook vocabulary — the Status list still named it `tool("Bash")` was removed from `vigiles/hook` earlier today: a bash gate is Bash BY CONSTRUCTION, the matcher is emitted for the author, and every call site in both repos wrote the same literal. The removal swept the imports, the `match: tool("Bash")` lines and the vocabulary bullet — and missed this one. The Status/pending section lists the vocabulary as prose to explain why only the entry points carry the `experimental_` prefix. `tool` sat in that list, naming a symbol that no longer exists, in a section about a different subject. Measured limit this exposes: nothing catches it. self-command-refs resolves `vigiles <cmd>` invocations; doc-refs resolves marks inside fenced ts blocks. A vocabulary word named in PROSE is a form neither resolves, and a grep for the symbol also matches the CHECK `tool()` in docs/testing-api.md, which is a different, live function — so the sweep that would have found it also had to tell two same-named symbols apart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: reading an `if:` and emitting one are different acts — say so The new "why no native `if:`" section explained the refusal to EMIT a condition and left a reader free to conclude vigiles ignores the field entirely. That inference is wrong in a way that costs trust in a different feature: the test tiers already honour a hook's own `if:` when measuring it (`decideHookCondition`, src/core/hook-condition.ts, on main since #211), precisely so a guard is scored as the harness would really run it rather than as an unconditional stand-in that flatters it. One paragraph, stated as the asymmetry it is: we read yours, we do not generate one for you, because a generated filter can be narrower than the program behind it and a hand-written one is the author's own declared intent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: cut a paragraph that took five lines to carry one fact "None of this means vigiles ignores `if:`. Reading one and emitting one are different acts." — an aphorism standing in for a point, opening on a negation of an objection nobody raised, then five lines to say one thing. It broke this repo's own prose-clarity rule at the first sentence: lead with the point. Two lines now, the fact first: an `if:` you wrote is honoured, `runHook` reads it, only generating one is refused. Also dropped "a bad trade at any price" from the paragraph above — the preceding sentence already establishes that the failure is silent, so the flourish added emphasis to a point that was already made. Correction to9898a76, which this supersedes in content: that commit's message describes only my paragraph, but the commit also carried two edits another agent made to the same file (the 650→80 ms measurement and the rewritten Runtime cost bullet). `git commit <pathspec>` separates files, not authors; a shared file cannot be split that way. Left in history rather than rewritten — the content is right, and force-pushing a branch with live writers on it is the worse trade. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(skills): hold the contributor skills to the trigger-rate bar Of the 14 skills under .claude/skills/, 9 were model-invocable and only 2 (ship-a-feature, generate-logo) had a trigger-rate eval; 7 could auto-fire with no measurement. The first question per skill was not "write an eval" but "should this auto-fire at all" — a skill a human always reaches for by name costs nothing to disable and leaves the trigger surface entirely. Disabled (disable-model-invocation: true): - dogfood-cli — a deliberate multi-agent find-and-fix session a human starts on purpose; its own body says "NOT for a single known bug". - verify-docs-findable — a scheduled, cost-bearing docs audit with an argument-hint; already shaped as a command. Disabling it also dissolves its overlap with review-docs without an eval. - deep-research — both of its save targets (research/, the startup/ vault) no longer exist in this repo; the skill is stale here and its description invites competitive/market research this repo does not hold. (Despite the CLAUDE.md label, neither it nor review-docs is vendored: both were authored in-repo,39b729aand a0dddb7.) Kept model-invocable, each with a trigger eval on the ship-a-feature template (>=10 recall + >=10 precision prompts, stubSkillBodies, drawn from this repo's real work): - code-quality — "clean this up" never carries the skill's name. - landing-site — a design bar that must arrive BEFORE site/ is touched. - screenshot — a miss makes the model reach for the blocked `playwright install` or capture scroll-reveal sections blank. - review-docs — authored as auto-triggering ("review the readme"). Two genuine prompt-space overlaps are named in the eval headers and deliberately left out of every precision bank rather than picked around: code-quality vs ship-a-feature on "new public export", and landing-site vs screenshot on "screenshot the landing page". Those are measureSelectionMatrix questions over the whole .claude/skills/. The evals were authored, not run (keyless env; a real-model eval spends money). The thresholds are the template's defaults, uncalibrated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(skills): deep-research routed strategy findings into a public repo Its "Where to save" section named two directories, and NEITHER exists — both were removed in #68. So the routing has not worked for some time, and the way it fails is the problem: - `research/<topic>.md` — gone. - "Sensitive: competitive / VC / strategy / fundraising → the encrypted `startup/` vault (see `startup/CLAUDE.md`)" — gone. Two defects, and the second is not cosmetic. The line DISCLOSES that a private strategy vault exists, in a public repository. And because the vault it names is deleted, an agent following the instruction today has nowhere to put competitive or fundraising material except this repo — the skill's own safety valve had become a funnel toward the exact content `no-product-strategy-here` forbids. Rewritten to say what is true now: technical findings go to a `docs/` page or a contributor note cited from keyFiles (the tier decided by public-vs-internal-docs and doc-tiers); strategy, market, pricing, GTM and roadmap research goes into no directory here at all, because the rule is unconditional and a deleted file survives in history and on other branches. Findings come back in the answer instead, for a human to place privately. Found while verifying a subagent's report rather than accepting it — the skill was in scope for a frontmatter change and this is a body defect it flagged and correctly declined to patch under its brief. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * perf(hook): a decision no longer loads the CLI to decide allow/deny A compiled hook runs on every matching tool call, and `dist/cli.js` is the bin — so `vigiles hook-runtime run-program` resolved ~85 top-level imports (compile, lint, audit, eval, init, the report template loader) before argv was parsed, to answer a question that touches none of them. CommonJS gives no way to defer that from inside the file; the entrypoint had to split. MEASURED, Node 22.22.2, median of 20 spawns, `tools/measure-hook-startup.mjs`, before =04f2c2a: layer before -> after bare `node -e ''` 42 -> 41 ms require dist/core/hook-program.js 170 -> 64 ms require dist/hook.js 171 -> 64 ms end-to-end run-program before -> after bash gate (safe-bash-guard.mjs) 661 -> 199 ms 3.3x file gate 628 -> 80 ms 7.9x inject 610 -> 83 ms 7.3x For scale: a shell hook doing the same job is ~12 ms and a python one ~35 ms. Roughly 40 ms of what remains is Node itself starting. Four lazy boundaries, each with its measurement at the call site: src/cli.ts reduced to a dispatcher shim with NO top-level imports; the verbs moved verbatim to src/cli-main.ts src/hook-runtime.ts the run-program runtime, lifted out of the verb barrel; the adapter registry (279 ms) is required only by react core/hook-program.ts @iarna/toml (56 ms) at its one serialize site hook-install.ts the same, and it is the edge that put toml back into every decision through hook-state-store's normalizeHookRef core/bash-effects.ts mvdan-sh (~100 ms) on first parse, not at module load The parser deferral is clean rather than forced: `decideProgram` returns allow() before building a commandView when the tool is not Bash, and every entry point in bash-effects begins by parsing — so no path reaches the parser without needing it. A bash gate still pays, and that is the work it asked for. THE EMITTED COMMAND IS BYTE-IDENTICAL, which outranks the win: `npx vigiles hook-runtime run-program <file>` is baked into every already-emitted settings block and covered by the SHA stamp beside each hook. Verified by compiling the same gate and inject with both trees and diffing the artifacts — `.claude/ settings.json`, `.codex/config.toml` and both stamp sidecars are identical. The invariant is asserted as a MODULE GRAPH, not a duration (#216 asks for this: a timing threshold on a shared runner is flaky and gets quarantined first). src/hook-runtime-graph.test.ts dumps require.cache from the real spawned CLI, so it sees the lazy requires a static scan would miss, and it asserts both directions — a bash gate DOES load mvdan-sh, `--help` DOES load the barrel. Proven to fire: hoisting each of the three imports back turns it red on its own assertion, and the tree is green again with them restored. Docs updated where the old claim was now wrong: the public runtime-cost bullet said "tens to hundreds of ms" and now states the measured per-role figures. Closes #216. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(config)!: rename the `testGlobs` rule option to `include` One config file, two words for "the set to consider". `orphans` took `include` (`options.include ?? DEFAULT_INCLUDE`, its own doc calling them "tsconfig-style glob arrays") while the `untested-*` rules took `testGlobs` for the same job with the same replace semantics. Both already had `exclude`. Now both read `include`/`exclude` and mean the same thing by them. Semantics are UNCHANGED — `include` replaces the defaults, as `orphans.include` does, as tsconfig and vitest do. No alias and no fallback: a stale `testGlobs` key is simply not read. WHAT THIS EXPOSED, which outlasts the rename: `.vigilesrc.json` is an UNGUARDED public surface. `testGlobs` ships in v26.0.0 through v26.1.1 and is documented with a copy-paste example, so a user can have it committed today — yet every one of the eighteen checks passes on this diff. `api-surface/*.api.md` guards TS exports and `TestCoverageOptions` is not one; nothing at all watches config keys. The property that makes a rename consequential is precisely the one no gate sees. Renamed across 14 files: the option in core/types.ts, its consumers (test-coverage, coverage-evidence, audit-score, cli-main), three test suites, and five docs plus HANDOFF.md. The eight `.claude/worktrees/agent-*/` copies are other sessions' gitignored trees and were deliberately not touched. Gates: format RC=0 (`prettier --check .`, clean) · targeted vitest RC=0 (test-coverage, cli-untested-options, cli-nudge-config — 80 tests) · `npm run check` RC=1 on `cli-lint` ONLY, whose 480 errors are 480-of-480 inside `.claude/worktrees/`, zero in tracked files — the same pre-existing condition recorded on this PR when it was 18 errors. `api-surface` passed (14.5s). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(lint): "A `include` entry" — the rename left bad grammar in a user-facing message The `testGlobs` → `include` rename produced "A `include` entry WITHOUT {surface}…" in the untested-surface advisory. It shipped to a place I could see it: the `vigiles lint` output posted as this PR's sticky comment. Two passes had already fixed the same a/an break in docs/rules/untested-skill.md, src/test-coverage.test.ts and src/coverage-evidence.ts — and both missed this one, because the string is assembled from template literals with ESCAPED backticks (A \`include\` entry). A grep for "A `include`" cannot match it. That form is on this repo's own list of reference spellings a search does not reach, alongside a path built by concatenation and an import inside a fixture string. Found by reading the tool's actual output rather than the source, which is the only view where the assembled sentence exists at all. Gates: prettier --check on the file RC=0 · vitest src/test-coverage.test.ts RC=0 (69 passed). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(harness): nudge when product code moves and no doc follows `doc-consistency` says: ship, rename or remove a capability and update its docs in the SAME change, across BOTH tiers — public `docs/` + README, and the contributor record in `CLAUDE.md`. That rule is prose, and this evening measured what prose is worth: · `tool()` was deleted from the hook vocabulary while docs/compiled-hooks.md kept listing it as vocabulary — through TWO sweeps of that same file. · An exclusivity claim outlived the measurement that withdrew it. · deep-research routed findings to a `startup/` vault deleted in #68. No check caught any of them. self-command-refs resolves `vigiles <cmd>`; doc-refs resolves marks in fenced ts blocks; orphan-docs finds unreferenced files. Prose describing a capability whose BEHAVIOUR changed is invisible to all three — nothing about it is broken, it is merely no longer true. WHY A HOOK, NOT A LINT RULE. `lint` reads a checkout; "the code changed and the docs did not" is a property of a DIFF. A tree-level rule would have to guess which prose belongs to which symbol, which is undecidable, and by lint-rule-calibration a heuristic can never gate. A hook sees edits accumulate across a session, which is the only place the fact exists. WHY NOT ship-a-feature. That skill covers this for a NEW export, with executable DOCUMENTED/FINDABLE checks. Its own trigger eval asserts it stays QUIET on "Rename the internal helper posixly to toPosix" and "Tighten the wording in docs/compiled-hooks.md" — firing there is a false positive against maxFalsePositive: 0.2. Widening it would break its own precision assertion, so this covers what that skill is measured NOT to. TWO FACTS, NOT ONE, and the test asserts them apart. `docs.followed` is recorded on any doc edit, so a session already maintaining its docs stays silent; that silence is SUCCESS. `docs.nudged` is the rate limit; that silence is a CAP. One fact could not tell them apart, and the hook would then go quiet for two hours after a single doc edit. Tests are excluded from the trigger deliberately: a test states what the code already does, so a test-only edit changes no documented behaviour. Including them would make it fire through ordinary red-green work, which is how a nudge gets muted. Gates: vitest src/docs-drift-nudge.test.ts + src/test-tier-nudge.test.ts RC=0 (34 passed) · prettier --check . RC=0 · `vigiles compile` wired it into .claude/settings.json and wrote its SHA stamp. MUTATION, patch proven landed before the run: deleting the `docs.followed` branch (collapsing the two silences) fails exactly one test — "QUIET because docs are being edited (success)" — and restoring it returns 11/11. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(coverage): credit the tests this repo already had — 25 untested surfaces to 14 vigiles ships `include` with a `{surface}` placeholder for exactly the case where a test lives somewhere other than beside its surface, and this repo had never configured it. So its own audit called eleven tested surfaces untested, including four trigger evals written today and both compiled hooks. MEASURED, each step run rather than reasoned: 25 baseline 16 `examples/harness/dogfood/{surface}.trigger.eval.mjs` on untested-skill — credits fable's four new evals plus generate-logo, strengthen, test-harness, edit-spec, debug-my-harness 25 adding a SECOND, narrower list to untested-hook — the count went BACK UP 16 one identical list on both rules 15 renaming src/docs-drift-nudge.test.ts → .hook.test.ts 14 the same for test-tier-nudge TWO THINGS THAT SURPRISED ME, both worth knowing before touching this config: 1. THE OPTIONS ARE SHARED ACROSS THE `untested-*` RULES. Setting `include` on untested-hook REPLACED what untested-skill had, and nine credited surfaces silently went back to untested. docs/rules/untested-subagent.md does say the options are shared; the failure mode it produces — a second config UNDOING the first, with the count moving in the alarming direction — is not obvious from that sentence. The lists are identical on both rules deliberately. 2. A HOOK'S SURFACE NAME CARRIES `.hook`. `{surface}` for `.vigiles/hooks/docs-drift-nudge.hook.mjs` is `docs-drift-nudge.hook`, not `docs-drift-nudge` — so `src/docs-drift-nudge.test.ts` did not bind and `src/docs-drift-nudge.hook.test.ts` does. Established by renaming and watching the count, not inferred from the suggestion string. The first two globs restate DEFAULT_TEST_GLOBS on purpose: `include` REPLACES the defaults rather than extending them, so omitting them would strip credit from every `*.harness.*` and `*.eval.*` in the repo while the number looked better. The 14 that remain are honest: five shell hooks under `hooks/` and nine skills with no test of any kind. Gates: vitest both renamed suites RC=0 (34 passed) · prettier --check . RC=0 · `node dist/cli.js lint` re-run after every step above. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: two committed artifacts drifted from the files they index CI went red twice, both mine, both from the same shape — an artifact that is GENERATED but COMMITTED, so adding or renaming a source file silently invalidates it until it is regenerated in the same change. 1. `generated-types:committed` oned9eaf1. `.vigiles/generated.d.ts` enumerates every project file; `src/docs-drift-nudge.hook.test.ts` was not in it. The gate is `git diff --exit-code .vigiles/generated.d.ts`, so the artifact must be regenerated AND committed — regenerating alone leaves it red. 2. The SPEC REFERENCE check on6ce464a: ✗ CLAUDE.md: File not found: "src/test-tier-nudge.test.ts" Renaming that test to `.hook.test.ts` (so its `{surface}` name binds) left a `file()` keyFile in CLAUDE.md.spec.ts pointing at a path that no longer exists. This one is worth naming plainly: it is the cross-referencing engine — the thing this product IS — catching its own author's stale reference, minutes after the rename, exactly as designed. Also indexes what the rename left undocumented: `.vigiles/hooks/docs-drift-nudge.hook.mjs` and its test now have keyFiles, and the tier-nudge entry says WHY the file is named `<surface>.hook.test.ts` — a hook's `{surface}` name carries `.hook`, which is what binds it under the `include` globs in .vigilesrc.json. Without that sentence the next rename repeats this. HOW BOTH GOT THROUGH, since the cause is procedural rather than technical: after adding the hook I ran targeted vitest and prettier instead of `npm run check`, and the re-run after that was taken from a tree I was still editing — a gate on a moving tree measures a state that never existed. Both are the `run-the-gates` rule, failed in two different ways within twenty minutes. Gates on a settled tree: build · types:root · types:test · types:site · jest · api-surface · prettier — all RC=0. `cli-lint` red with every finding inside `.claude/worktrees/` (gitignored, CI never sees it). `generated-types:committed` red only because the regenerated artifact was uncommitted at the time — this commit is what makes it green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(hook): reject a tool matcher that names no real tool `compileHookProgram` cross-references the `on:` event against the harness catalog, so a typo'd EVENT fails the build. It did not do the same for the tool matcher, and the existing check there validates REGEX SYNTAX only — so `tools("Edt")` parses fine, compiles, wires a matcher `Edt`, and the hook never fires. Same defect the event check exists to kill, on the axis it did not cover. WHY THIS IS NOT A UNION TYPE, which is the obvious answer and the wrong one: a tool matcher IS a regex — that is why `tools("Edit|Write")` works, and `tools("mcp__github__.*")` is documented-correct. A literal union would reject both. So the check reads only entries with NO regex metacharacter as literal NAMES, which is exactly the spelling a typo produces. A fully-spelled MCP tool is skipped inside verifyToolContract itself (dialect.mcpToolPattern), so it passes on both counts. Reuses verifyToolContract + authoringIssues — the same detector the subagent tool-contract rule runs, so the hook matcher and the subagent contract cannot drift into two vocabularies (one-detector-no-drift). Both directions tested, and MUTATION-PROVEN with the patch grepped as landed: disabling the throw fails exactly one test ("a typo'd tool NAME in match does NOT compile"); restoring returns 98/98. The second test pins what must KEEP compiling — a real name beside `mcp__github__.*` and a spelled-out MCP tool. 🔴 AND THE GATE CAUGHT A REGRESSION THE TESTS COULD NOT. `api-surface` failed with one line — `// @public` → `// @public (undocumented)`. Inserting the REGEX_META const put it BETWEEN `invalidToolPatterns`'s JSDoc and its declaration, so the comment documented my constant and the real function went bare. That is verbatim the failure `ship-a-feature` was written after ("inserted between verifyGuardrail's JSDoc and its declaration; the diff said so, nobody read it"). Fixed by moving the comment back, NOT by regenerating — `api:report` would have committed the degraded surface and called it green. api:check now passes with the surface UNCHANGED, which is the correct outcome for adding an internal const. Gates: build RC=0 · tsc --noEmit RC=0 · api:check RC=0 (surface unchanged) · vitest hook-program 98 passed · prettier RC=0 · `npm run check` red only on `cli-lint`, entirely inside `.claude/worktrees/`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(site): the landing page showed a hook that no longer compiles The Guard section's headline sample — the first compiled hook a visitor ever sees — opened with a symbol deleted from the vocabulary earlier today: import { experimental_defineHook, tool, deny, allow } from "vigiles/hook"; export default experimental_defineHook({ on: "PreToolUse", match: tool("Bash"), `tool()` is gone. A bash gate is Bash BY CONSTRUCTION — `BashToolEvent.tool` is the literal "Bash", the matcher is emitted for the author, and the field existed only to be got wrong (`match: tool("Edit")` type-checked, compiled, wired an Edit matcher, and never fired). Removing it is the fix; the site kept advertising it. So a reader copying the landing page's own example got a build error on line 1, and the file's header claims the snippet is REAL — "node dist/cli.js hook-runtime run-program <this file> # via verifyGuardrail" — which makes it a claim about executed code rather than an illustration. 🔴 WHAT THIS SAYS ABOUT THE HOOK SHIPPED TWO HOURS AGO. `docs-drift-nudge` exists to catch exactly this: product code changed, the prose describing it did not. It would NOT have fired here. It watches `src/**` for the trigger and treats `docs/**`, README and CLAUDE.md.spec.ts as the docs — a `.tsx` under `site/` is neither, so the site is invisible to it in both directions. The landing page is a front door under `cohesive-feature-delivery`, and the nudge does not cover it. Recorded here rather than widened in the same commit: the trigger set is a calibration question (a `site/` edit is often styling, not a claim), and widening a nudge on a hunch is how a nudge gets muted. Gates: prettier --check RC=0 · `npm --prefix site run test:browser` RC=0 — 7 files, 49 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1842 lines
73 KiB
TypeScript
1842 lines
73 KiB
TypeScript
/**
|
|
* Untested-surface detector test suite.
|
|
*
|
|
* Pure, filesystem-driven: build a tiny fake plugin in a tmp dir and assert the
|
|
* ONE coverage detector (colocation), the user-invoked exemption, the
|
|
* ignore-marker opt-out, hook-script discovery, and the report formatting. No
|
|
* model, no network.
|
|
*
|
|
* Several tests here are INVERSIONS of ones that used to assert the opposite —
|
|
* a name-mention and a `vigiles:covers` declaration both used to confer
|
|
* coverage. They are kept as inversions rather than deleted so the removal is
|
|
* pinned: a future re-introduction of either tier fails these.
|
|
*/
|
|
import { test } from "vitest";
|
|
import assert from "node:assert/strict";
|
|
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
import { isAbsolute, join } from "node:path";
|
|
|
|
import {
|
|
coverageEvidenceCounts,
|
|
findUntestedSurfaces,
|
|
coverageCaveats,
|
|
formatUntestedReport,
|
|
skillTestNudge,
|
|
evalTierQuestion,
|
|
suggestedTestPath,
|
|
} from "./test-coverage.js";
|
|
import { findUntestedSurfacesInFiles } from "./test-coverage-files.js";
|
|
import { isEvalScript } from "./coverage-evidence.js";
|
|
import { SCRIPT_EXTS } from "./adapters/claude-code/run-scripts.js";
|
|
import { surfaceSha } from "./coverage-artifact.js";
|
|
import { makeTmpDir, cleanupTmpDir } from "./core/test-utils.js";
|
|
import { claudeCodeLayout } from "./adapters/claude-code/layout.js";
|
|
import { codexLayout } from "./adapters/codex/layout.js";
|
|
import { testFileExt } from "./core/test-file-ext.js";
|
|
import { canRunTypeScript, detectNodeCaps } from "./ts-runner-caps.js";
|
|
import { interpreterArgs } from "./adapters/claude-code/run-scripts.js";
|
|
|
|
function write(dir: string, rel: string, content: string): void {
|
|
const abs = join(dir, rel);
|
|
mkdirSync(join(abs, ".."), { recursive: true });
|
|
writeFileSync(abs, content);
|
|
}
|
|
|
|
function skill(name: string, extra = ""): string {
|
|
return `---\nname: ${name}\ndescription: A skill that does ${name} things for tests\n${extra}---\n\n# ${name}\n`;
|
|
}
|
|
|
|
test("surface discovery + hook-token are layout-driven (non-CC harness)", () => {
|
|
// A harness with its OWN surface dirs and plugin-root token — proves the
|
|
// de-CC-ing: skills/agents are found at the layout's dirs and a hook script is
|
|
// resolved through the layout's token, NOT hard-coded `skills/`/`agents/`/
|
|
// `${CLAUDE_PLUGIN_ROOT}`. (Regression guard for the scan.ts/test-coverage.ts
|
|
// hard-codings.)
|
|
const dir = makeTmpDir("tc-layout");
|
|
write(dir, "mysurf/skill/foo/SKILL.md", skill("foo"));
|
|
write(dir, "mysurf/agent/bar.md", skill("bar"));
|
|
write(dir, "hooks/present.sh", "#!/bin/sh\n");
|
|
write(
|
|
dir,
|
|
"my-manifest.json",
|
|
JSON.stringify({
|
|
hooks: {
|
|
PostToolUse: [
|
|
{ hooks: [{ command: "$MY_PLUGIN_ROOT/hooks/present.sh" }] },
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
|
|
const layout = {
|
|
...claudeCodeLayout,
|
|
skillDir: "mysurf/skill",
|
|
agentDir: "mysurf/agent",
|
|
commandDir: "mysurf/command",
|
|
materializeRoot: "",
|
|
manifestPath: "my-manifest.json",
|
|
pluginRootToken: "${MY_PLUGIN_ROOT}",
|
|
};
|
|
|
|
const r = findUntestedSurfaces({ basePath: dir, layout });
|
|
const byKind = (k: string) =>
|
|
r.untested.filter((s) => s.kind === k).map((s) => s.name);
|
|
assert.deepEqual(byKind("skill"), ["foo"], "skill found at layout.skillDir");
|
|
assert.deepEqual(byKind("agent"), ["bar"], "agent found at layout.agentDir");
|
|
assert.deepEqual(
|
|
byKind("hook"),
|
|
["present"],
|
|
"hook resolved via layout.pluginRootToken (unbraced $MY_PLUGIN_ROOT)",
|
|
);
|
|
|
|
// The default Claude Code layout sees none of it (different dirs/token).
|
|
assert.equal(findUntestedSurfaces({ basePath: dir }).total, 0);
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
test("skill is covered by a colocated eval", () => {
|
|
const dir = makeTmpDir("tc-coloc");
|
|
write(dir, "skills/foo/SKILL.md", skill("foo"));
|
|
write(dir, "skills/foo/foo.eval.mjs", "// trigger eval\n");
|
|
const r = findUntestedSurfaces({ basePath: dir });
|
|
assert.equal(r.untested.length, 0);
|
|
assert.deepEqual(
|
|
r.covered.map((s) => s.name),
|
|
["foo"],
|
|
);
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
test("loose skill under .claude/skills is covered by its colocated eval", () => {
|
|
// Regression: the suggested eval lives under a DOT dir, so a globstar test
|
|
// glob without `dot:true` never found it and the surface looked untested
|
|
// even after the user added exactly the file the warning recommended.
|
|
const dir = makeTmpDir("tc-dotdir");
|
|
write(dir, ".claude/skills/foo/SKILL.md", skill("foo"));
|
|
const before = findUntestedSurfaces({ basePath: dir });
|
|
assert.equal(before.untested.length, 1, "skill is found and flagged first");
|
|
assert.equal(
|
|
suggestedTestPath(before.untested[0]),
|
|
".claude/skills/foo/foo.eval.mjs",
|
|
);
|
|
|
|
// Add exactly the suggested colocated eval — it must now count as covered.
|
|
write(dir, ".claude/skills/foo/foo.eval.mjs", "// trigger eval\n");
|
|
const after = findUntestedSurfaces({ basePath: dir });
|
|
assert.equal(after.untested.length, 0);
|
|
assert.deepEqual(
|
|
after.covered.map((s) => s.name),
|
|
["foo"],
|
|
);
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
test("a test that only NAMES the skill does NOT cover it", () => {
|
|
// Was "content-reference covers it". Inverted 2026-08-11: naming a surface is
|
|
// not testing it, and the tier that believed otherwise supplied 9 of vigiles's
|
|
// own 10 covered surfaces with at least three of them false.
|
|
const dir = makeTmpDir("tc-ref");
|
|
write(dir, "skills/foo/SKILL.md", skill("foo"));
|
|
write(dir, "test/foo.eval.mjs", 'skillResolved(t, "myplugin:foo");\n');
|
|
const r = findUntestedSurfaces({ basePath: dir });
|
|
assert.deepEqual(
|
|
r.untested.map((x) => x.name),
|
|
["foo"],
|
|
);
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
test("skill with no test is flagged", () => {
|
|
const dir = makeTmpDir("tc-untested");
|
|
write(dir, "skills/foo/SKILL.md", skill("foo"));
|
|
const r = findUntestedSurfaces({ basePath: dir });
|
|
assert.equal(r.untested.length, 1);
|
|
assert.equal(r.untested[0].name, "foo");
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
test("a command-only (disable-model-invocation) skill is NOT exempt — it still needs a test", () => {
|
|
const dir = makeTmpDir("tc-userinvoked");
|
|
write(
|
|
dir,
|
|
"skills/cmd/SKILL.md",
|
|
skill("cmd", "disable-model-invocation: true\n"),
|
|
);
|
|
const r = findUntestedSurfaces({ basePath: dir });
|
|
// Invocation mode no longer exempts anything: the command-only skill is held
|
|
// to the requirement like any other and flagged when it has no test.
|
|
assert.equal(r.total, 1);
|
|
assert.equal(r.exempt, 0);
|
|
assert.equal(r.untested.length, 1);
|
|
assert.equal(r.untested[0].name, "cmd");
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
test("vigiles:ignore-test marker is the only exemption (and is counted)", () => {
|
|
const dir = makeTmpDir("tc-ignore");
|
|
write(
|
|
dir,
|
|
"skills/foo/SKILL.md",
|
|
skill("foo") + "\n<!-- vigiles:ignore-test -->\n",
|
|
);
|
|
const r = findUntestedSurfaces({ basePath: dir });
|
|
assert.equal(r.total, 0); // nothing held to the requirement
|
|
assert.equal(r.exempt, 1); // the explicit opt-out is visible, not silent
|
|
assert.equal(r.untested.length, 0);
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
test("agent is covered by a name-prefixed sibling, flagged otherwise", () => {
|
|
const dir = makeTmpDir("tc-agent");
|
|
write(dir, "agents/planner.md", "---\nname: planner\n---\nbody\n");
|
|
write(dir, "agents/reviewer.md", "---\nname: reviewer\n---\nbody\n");
|
|
write(dir, "agents/planner.harness.mjs", "// planner test\n");
|
|
const r = findUntestedSurfaces({ basePath: dir });
|
|
assert.deepEqual(
|
|
r.covered.map((s) => s.name),
|
|
["planner"],
|
|
);
|
|
assert.deepEqual(
|
|
r.untested.map((s) => s.name),
|
|
["reviewer"],
|
|
);
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
test("a hook rooted at the PROJECT var is discovered — not just the PLUGIN var", () => {
|
|
// 🔴 A WHOLE SURFACE KIND WAS INVISIBLE. Only `${CLAUDE_PLUGIN_ROOT}` was
|
|
// stripped, so `"$CLAUDE_PROJECT_DIR/.claude/hooks/x.sh"` — Claude Code's
|
|
// DOCUMENTED spelling for a project hook, used because hooks do not run with a
|
|
// stable cwd — kept its literal prefix, failed `existsSync`, and was dropped
|
|
// without a word.
|
|
//
|
|
// MEASURED on a real consumer repo, four numbers for the same thing:
|
|
// 19 hook ENTRIES in settings · 16 inventoried by `audit` (every one printed
|
|
// "unresolved var — can't check") · 17 FILES in `.claude/hooks/` (two are not
|
|
// hooks) · 0 hook SURFACES. After the fix: 14, which is exactly the
|
|
// registered scripts under `.claude/hooks/`.
|
|
const dir = makeTmpDir("cov-hooks-projvar");
|
|
const hook = (n: string): void => {
|
|
write(dir, `.claude/hooks/${n}`, "#!/bin/sh\n");
|
|
};
|
|
for (const n of ["a.sh", "b.mjs", "c.hook.ts", "d.sh"]) hook(n);
|
|
write(dir, "node_modules/vigiles/dist/cli.js", "// the runtime\n");
|
|
const cmd = (c: string) => ({
|
|
matcher: "Bash",
|
|
hooks: [{ type: "command", command: c }],
|
|
});
|
|
write(
|
|
dir,
|
|
".claude/settings.json",
|
|
JSON.stringify({
|
|
hooks: {
|
|
PreToolUse: [
|
|
cmd('"$CLAUDE_PROJECT_DIR/.claude/hooks/a.sh"'),
|
|
cmd('node "${CLAUDE_PROJECT_DIR}/.claude/hooks/b.mjs" post'),
|
|
// The compiled-hook shape: the RUNTIME is a dependency, the operand is
|
|
// the surface. Both are extracted from this one command.
|
|
cmd(
|
|
'node "$CLAUDE_PROJECT_DIR/node_modules/vigiles/dist/cli.js" ' +
|
|
'hook-runtime run-program "$CLAUDE_PROJECT_DIR/.claude/hooks/c.hook.ts"',
|
|
),
|
|
// No variable at all still works, and so does the plugin root.
|
|
cmd(".claude/hooks/d.sh"),
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
const names = findUntestedSurfaces({ basePath: dir })
|
|
.untested.filter((x) => x.kind === "hook")
|
|
.map((x) => x.path)
|
|
.sort();
|
|
assert.deepEqual(names, [
|
|
".claude/hooks/a.sh",
|
|
".claude/hooks/b.mjs",
|
|
".claude/hooks/c.hook.ts",
|
|
".claude/hooks/d.sh",
|
|
]);
|
|
// ⚠️ A DEPENDENCY IS NOT A SURFACE. The same settings name the runtime that
|
|
// runs the compiled hooks; holding `node_modules/vigiles/dist/cli.js` to
|
|
// `untested-hook` would ask the author to test their package manager.
|
|
assert.ok(
|
|
!names.some((n) => n.includes("node_modules")),
|
|
"a node_modules path must never be a surface",
|
|
);
|
|
|
|
// QUIET: a path that does not exist is still not a surface — stripping a token
|
|
// must not turn a typo into a discovery.
|
|
const dir2 = makeTmpDir("cov-hooks-projvar-missing");
|
|
write(
|
|
dir2,
|
|
".claude/settings.json",
|
|
JSON.stringify({
|
|
hooks: {
|
|
PreToolUse: [cmd('"$CLAUDE_PROJECT_DIR/.claude/hooks/nope.sh"')],
|
|
},
|
|
}),
|
|
);
|
|
assert.deepEqual(
|
|
findUntestedSurfaces({ basePath: dir2 }).untested.filter(
|
|
(x) => x.kind === "hook",
|
|
),
|
|
[],
|
|
);
|
|
cleanupTmpDir(dir);
|
|
cleanupTmpDir(dir2);
|
|
});
|
|
|
|
test("the BROWSER twin reads the PROJECT var too — one settings file, one reader", () => {
|
|
// 🔴 THE TWIN WAS LEFT BEHIND BY THE FIX ABOVE. The disk detector learned the
|
|
// project token; `findUntestedSurfacesInFiles` — the engine `scanFiles` runs
|
|
// against a GitHub file map, with no disk — still stripped only the PLUGIN
|
|
// token, so the very same settings.json that yields four hook surfaces on disk
|
|
// yielded ONE in the browser. Same defect, same file, second reader: the audit
|
|
// under-reported the untested-hook count and therefore the score.
|
|
//
|
|
// This is the parity assertion, not a restatement: the two engines are handed
|
|
// the SAME repo and must name the SAME hooks. It is written as an equality
|
|
// against the disk result so it cannot pass by agreeing on the wrong answer —
|
|
// the disk expectation is pinned by the test directly above.
|
|
const cmd = (c: string) => ({
|
|
matcher: "Bash",
|
|
hooks: [{ type: "command", command: c }],
|
|
});
|
|
const settings = JSON.stringify({
|
|
hooks: {
|
|
PreToolUse: [
|
|
cmd('"$CLAUDE_PROJECT_DIR/.claude/hooks/a.sh"'),
|
|
cmd('node "${CLAUDE_PROJECT_DIR}/.claude/hooks/b.mjs" post'),
|
|
cmd(
|
|
'node "$CLAUDE_PROJECT_DIR/node_modules/vigiles/dist/cli.js" ' +
|
|
'hook-runtime run-program "$CLAUDE_PROJECT_DIR/.claude/hooks/c.hook.ts"',
|
|
),
|
|
cmd(".claude/hooks/d.sh"),
|
|
],
|
|
},
|
|
});
|
|
const files: Record<string, string> = {
|
|
".claude/settings.json": settings,
|
|
".claude/hooks/a.sh": "#!/bin/sh\n",
|
|
".claude/hooks/b.mjs": "#!/bin/sh\n",
|
|
".claude/hooks/c.hook.ts": "#!/bin/sh\n",
|
|
".claude/hooks/d.sh": "#!/bin/sh\n",
|
|
"node_modules/vigiles/dist/cli.js": "// the runtime\n",
|
|
};
|
|
const names = findUntestedSurfacesInFiles(files, claudeCodeLayout, "repo")
|
|
.untested.filter((x) => x.kind === "hook")
|
|
.map((x) => x.path)
|
|
.sort();
|
|
assert.deepEqual(names, [
|
|
".claude/hooks/a.sh",
|
|
".claude/hooks/b.mjs",
|
|
".claude/hooks/c.hook.ts",
|
|
".claude/hooks/d.sh",
|
|
]);
|
|
// ⚠️ A DEPENDENCY IS NOT A SURFACE — the disk detector skips `node_modules/`,
|
|
// and the twin did not even have that branch. A file map fetched from a repo
|
|
// that vendors its runtime would have handed the author their package manager
|
|
// to test.
|
|
assert.ok(
|
|
!names.some((n) => n.includes("node_modules")),
|
|
"a node_modules path must never be a surface",
|
|
);
|
|
|
|
// QUIET: stripping a token must not turn a typo into a discovery. The map has
|
|
// no such file, so nothing is found — same as the disk twin's quiet half.
|
|
assert.deepEqual(
|
|
findUntestedSurfacesInFiles(
|
|
{
|
|
".claude/settings.json": JSON.stringify({
|
|
hooks: {
|
|
PreToolUse: [cmd('"$CLAUDE_PROJECT_DIR/.claude/hooks/nope.sh"')],
|
|
},
|
|
}),
|
|
},
|
|
claudeCodeLayout,
|
|
"repo",
|
|
).untested.filter((x) => x.kind === "hook"),
|
|
[],
|
|
);
|
|
});
|
|
|
|
test("`.eval.` in the MIDDLE of a name is not the paid tier — the runner would never run it", () => {
|
|
// 🔴 THE TIER SPLIT CLAIMED A SURFACE WAS EVAL-COVERED BY A FILE NO RUNNER
|
|
// RUNS. The splitter tested `.eval.` as an INFIX, so a colocated deterministic
|
|
// test named `parser.eval.test.ts` — picked up by an `include` of
|
|
// `**/*.test.ts` — was pushed into the paid EVAL tier and REMOVED from the
|
|
// free one. But `vigiles eval` discovers `**/*.eval.{mjs,cjs,js,mts,cts,ts}`
|
|
// (scriptGlob, SCRIPT_EXTS in adapters/claude-code/run-scripts.ts): the name
|
|
// ends `.test.ts`, so the eval runner never sees it. The surface was reported
|
|
// as covered by the tier that cannot run it and uncovered by the tier that
|
|
// does — exactly backwards.
|
|
const dir = makeTmpDir("cov-eval-infix");
|
|
write(
|
|
dir,
|
|
".claude/skills/parser/SKILL.md",
|
|
"---\nname: parser\n---\nbody\n",
|
|
);
|
|
write(dir, ".claude/skills/parser/parser.eval.test.ts", "// deterministic\n");
|
|
const r = findUntestedSurfaces({
|
|
basePath: dir,
|
|
include: ["**/*.test.ts"],
|
|
});
|
|
assert.deepEqual(
|
|
r.harness.covered.map((s) => s.name),
|
|
["parser"],
|
|
"a *.test.ts file is deterministic — it belongs to the FREE tier",
|
|
);
|
|
assert.deepEqual(
|
|
r.evals.covered.map((s) => s.name),
|
|
[],
|
|
"nothing the paid runner can discover covers this surface",
|
|
);
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
test("…and the money hazard the infix was guarding stays closed", () => {
|
|
// The infix was not arbitrary: it replaced the full suffix `.eval.mjs` because
|
|
// `foo.eval.ts` would then have fallen into the FREE branch and been run on
|
|
// every push, spending real model calls in CI. Narrowing to a SUFFIX must not
|
|
// reopen that — every extension the eval runner accepts still lands paid.
|
|
// The list is SCRIPT_EXTS, read from the runner, not from memory.
|
|
for (const ext of SCRIPT_EXTS) {
|
|
const dir = makeTmpDir(`cov-eval-paid-${ext}`);
|
|
write(dir, ".claude/skills/p/SKILL.md", "---\nname: p\n---\nbody\n");
|
|
write(dir, `.claude/skills/p/p.eval.${ext}`, "// paid\n");
|
|
const r = findUntestedSurfaces({ basePath: dir });
|
|
assert.deepEqual(
|
|
r.evals.covered.map((s) => s.name),
|
|
["p"],
|
|
`.eval.${ext} must be the PAID tier`,
|
|
);
|
|
assert.deepEqual(
|
|
r.harness.covered.map((s) => s.name),
|
|
[],
|
|
`.eval.${ext} must NOT be run by the free per-push tier`,
|
|
);
|
|
cleanupTmpDir(dir);
|
|
}
|
|
});
|
|
|
|
test("the eval-suffix rule is pinned to the extensions the runner really globs", () => {
|
|
// The predicate lives in coverage-evidence.ts (browser-safe, both twins route
|
|
// through it) and therefore re-declares the extension list rather than
|
|
// importing the node-only runner module. This is the assertion that keeps the
|
|
// copy honest: if SCRIPT_EXTS gains an extension, the splitter must gain it
|
|
// too, or a paid file starts running on every push again.
|
|
for (const ext of SCRIPT_EXTS) {
|
|
assert.ok(
|
|
isEvalScript(`x.eval.${ext}`),
|
|
`SCRIPT_EXTS has .${ext} but the tier splitter does not know it`,
|
|
);
|
|
}
|
|
assert.ok(!isEvalScript("parser.eval.test.ts"));
|
|
assert.ok(!isEvalScript("evaluate.ts"));
|
|
assert.ok(!isEvalScript("x.eval.mjs.bak"));
|
|
assert.ok(!isEvalScript("x.harness.ts"));
|
|
});
|
|
|
|
test("hook scripts are discovered from plugin.json and matched by path", () => {
|
|
const dir = makeTmpDir("tc-hooks");
|
|
write(dir, "hooks/pre-edit.sh", "#!/usr/bin/env bash\n");
|
|
write(dir, "hooks/post-edit.sh", "#!/usr/bin/env bash\n");
|
|
write(
|
|
dir,
|
|
".claude-plugin/plugin.json",
|
|
JSON.stringify({
|
|
name: "p",
|
|
hooks: {
|
|
PreToolUse: [
|
|
{
|
|
matcher: "Edit",
|
|
hooks: [
|
|
{
|
|
type: "command",
|
|
command: "bash ${CLAUDE_PLUGIN_ROOT}/hooks/pre-edit.sh",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
PostToolUse: [
|
|
{
|
|
matcher: "Edit",
|
|
hooks: [
|
|
{
|
|
type: "command",
|
|
command: "bash ${CLAUDE_PLUGIN_ROOT}/hooks/post-edit.sh",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
// A unit test elsewhere that drives pre-edit BY PATH now covers nothing —
|
|
// naming a hook is not testing it. This is the exact shape by which the
|
|
// detector's own suite used to grant coverage to these two hooks.
|
|
write(dir, "src/hooks.test.ts", 'runHook("hooks/pre-edit.sh", {});\n');
|
|
let r = findUntestedSurfaces({ basePath: dir });
|
|
assert.equal(r.total, 2);
|
|
assert.deepEqual(r.untested.map((s) => s.name).sort(), [
|
|
"post-edit",
|
|
"pre-edit",
|
|
]);
|
|
|
|
// A hook is covered by a name-prefixed SIBLING — placement, not mention.
|
|
write(dir, "hooks/pre-edit.harness.mjs", "assert.ok(true);\n");
|
|
r = findUntestedSurfaces({ basePath: dir });
|
|
assert.deepEqual(
|
|
r.covered.map((s) => s.name),
|
|
["pre-edit"],
|
|
);
|
|
assert.deepEqual(
|
|
r.untested.map((s) => s.name),
|
|
["post-edit"],
|
|
);
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
test("per-surface kind toggles disable scanning", () => {
|
|
const dir = makeTmpDir("tc-toggle");
|
|
write(dir, "skills/foo/SKILL.md", skill("foo"));
|
|
const r = findUntestedSurfaces({ basePath: dir, skills: false });
|
|
assert.equal(r.total, 0);
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
test("formatUntestedReport: clean vs flagged, suggestedTestPath", () => {
|
|
const dir = makeTmpDir("tc-fmt");
|
|
write(dir, "skills/foo/SKILL.md", skill("foo"));
|
|
const flagged = findUntestedSurfaces({ basePath: dir });
|
|
const text = formatUntestedReport(flagged);
|
|
assert.ok(text.includes("1 surface(s) with no test"));
|
|
assert.ok(text.includes("skills/foo/foo.eval.mjs"));
|
|
// The footer points at include for an external test loop (issue #113).
|
|
assert.ok(text.includes("include"));
|
|
assert.equal(
|
|
suggestedTestPath(flagged.untested[0]),
|
|
"skills/foo/foo.eval.mjs",
|
|
);
|
|
|
|
write(dir, "skills/foo/foo.eval.mjs", "// covered\n");
|
|
const clean = findUntestedSurfaces({ basePath: dir });
|
|
assert.ok(formatUntestedReport(clean).startsWith("✓"));
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
test("discovers a bare SKILL.md AT the base (single-skill-dir target)", () => {
|
|
// When lint/audit is pointed at one skill dir, the SKILL.md is at the base
|
|
// itself, not under `skills/*/`. Without this the untested-skill check would
|
|
// silently vanish for exactly that target.
|
|
const dir = makeTmpDir("cov-solo");
|
|
write(dir, "SKILL.md", "---\nname: solo\ndescription: x\n---\nbody\n");
|
|
const report = findUntestedSurfaces({ basePath: dir });
|
|
const found = report.untested.find((s) => s.kind === "skill");
|
|
assert.ok(found, "the root SKILL.md is reported as an untested skill");
|
|
assert.equal(found.path, "SKILL.md");
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
test("a root SKILL.md is COVERED by a colocated eval (single-skill-dir target)", () => {
|
|
// A colocated `solo.eval.mjs` beside a root SKILL.md must count as coverage —
|
|
// globSync returns it without a "./" prefix, which the colocation check now
|
|
// handles for a "." dir. Else the documented single-skill target false-fails CI.
|
|
const dir = makeTmpDir("cov-solo-eval");
|
|
write(dir, "SKILL.md", "---\nname: solo\ndescription: x\n---\nbody\n");
|
|
write(dir, "solo.eval.mjs", "// colocated eval\n");
|
|
const report = findUntestedSurfaces({ basePath: dir });
|
|
assert.equal(
|
|
report.untested.filter((s) => s.kind === "skill").length,
|
|
0,
|
|
"the colocated solo.eval.mjs covers the root SKILL.md",
|
|
);
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
// ── Two tiers, split at DISCOVERY ────────────────────────────────────────────
|
|
// A harness (`*.harness.mjs`, `*.test.*`) is free and runs on every push; an eval
|
|
// (`*.eval.mjs`) spends real model calls on a schedule and is the ONLY thing that
|
|
// answers "does this skill fire?". Erasing that at discovery makes it
|
|
// unrecoverable downstream — hence a per-tier split, not a display tweak.
|
|
|
|
test("tiers split at discovery: a harness-only skill is NOT evaluated, and vice versa", () => {
|
|
const dir = makeTmpDir("cov-tiers");
|
|
write(dir, "skills/harnessed/SKILL.md", skill("harnessed"));
|
|
write(dir, "skills/harnessed/harnessed.harness.mjs", "// deterministic\n");
|
|
write(dir, "skills/evaled/SKILL.md", skill("evaled"));
|
|
write(dir, "skills/evaled/evaled.eval.mjs", "// real model\n");
|
|
write(dir, "skills/bare/SKILL.md", skill("bare"));
|
|
|
|
const r = findUntestedSurfaces({ basePath: dir });
|
|
const names = (ss: readonly { name: string }[]) =>
|
|
ss.map((s) => s.name).sort();
|
|
|
|
// The UNION is unchanged: a test anywhere counts.
|
|
assert.deepEqual(names(r.untested), ["bare"]);
|
|
assert.deepEqual(names(r.covered), ["evaled", "harnessed"]);
|
|
|
|
// The tiers disagree, which is the whole point — "has deterministic coverage,
|
|
// no evals" is a different position from "has neither".
|
|
assert.deepEqual(names(r.harness.covered), ["harnessed"]);
|
|
assert.deepEqual(names(r.harness.untested), ["bare", "evaled"]);
|
|
assert.deepEqual(names(r.evals.covered), ["evaled"]);
|
|
assert.deepEqual(names(r.evals.untested), ["bare", "harnessed"]);
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
test("a `*.test.ts` is NOT a vigiles test — it is the one name a foreign runner claims", () => {
|
|
// INVERSION, 2026-08-11. This used to assert the opposite: `*.test.*` counted as
|
|
// the deterministic tier. It was removed, and the reason is money.
|
|
//
|
|
// vigiles never RAN those files, but crediting them made the name reasonable —
|
|
// so an author writes `skills/foo/foo.test.mjs`, and then `npx vitest` runs it,
|
|
// because that name matches vitest's and jest's DEFAULT patterns exactly (read
|
|
// out of the installed packages). A skill test calls runHarnessTest /
|
|
// measureTriggerRate: it spawns a model. The accident is a silent bill, on every
|
|
// push, in CI. Measured in a spike: a bare project with that file, plain
|
|
// `npx vitest run`, and it executed — a dot-directory does not hide it.
|
|
const dir = makeTmpDir("cov-tier-ts");
|
|
write(dir, "skills/foo/SKILL.md", skill("foo"));
|
|
write(dir, "skills/foo/foo.test.ts", 'import "./SKILL.md";\n');
|
|
const r = findUntestedSurfaces({ basePath: dir });
|
|
assert.deepEqual(
|
|
r.untested.map((s) => s.name),
|
|
["foo"],
|
|
);
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
test("…and the same file renamed to `.harness.ts` DOES count — TypeScript is first-class", () => {
|
|
// The quiet half, and a feature in its own right: `.harness.ts` matched NOTHING
|
|
// before, so a TypeScript project got no credit for a test it had written. Node
|
|
// 22 runs `.ts`/`.mts`/`.cts` directly (type stripping, no toolchain) — measured.
|
|
const dir = makeTmpDir("cov-tier-ts-ok");
|
|
write(dir, "skills/foo/SKILL.md", skill("foo"));
|
|
write(dir, "skills/foo/foo.harness.ts", "const n: number = 1;\n");
|
|
const r = findUntestedSurfaces({ basePath: dir });
|
|
assert.deepEqual(
|
|
r.untested.map((s) => s.name),
|
|
[],
|
|
);
|
|
assert.deepEqual(
|
|
r.harness.untested.map((s) => s.name),
|
|
[],
|
|
);
|
|
// …and it is the FREE tier: nothing has measured firing.
|
|
assert.deepEqual(
|
|
r.evals.untested.map((s) => s.name),
|
|
["foo"],
|
|
);
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
test("🔴 `foo.eval.ts` is the PAID tier — the split is by infix, not by `.eval.mjs`", () => {
|
|
// The hazard that arrives WITH TypeScript support if the split is left alone:
|
|
// the old test was `path.endsWith(".eval.mjs")`, so `foo.eval.ts` would have
|
|
// landed in the harness branch — a file that spends real model calls, classified
|
|
// as the free tier and run on every push. Accepting TS without this is worse than
|
|
// not accepting it.
|
|
const dir = makeTmpDir("cov-eval-ts");
|
|
write(dir, "skills/foo/SKILL.md", skill("foo"));
|
|
write(dir, "skills/foo/foo.eval.ts", "const n: number = 1;\n");
|
|
const r = findUntestedSurfaces({ basePath: dir });
|
|
assert.deepEqual(
|
|
r.evals.untested.map((s) => s.name),
|
|
[],
|
|
"counted as the PAID tier",
|
|
);
|
|
assert.deepEqual(
|
|
r.harness.untested.map((s) => s.name),
|
|
["foo"],
|
|
"and NOT as the free one — an eval must never be mistaken for a per-push test",
|
|
);
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
test("a clean UNION still says when nothing has measured firing", () => {
|
|
// Every surface has a deterministic harness, so the gate passes — but no
|
|
// `*.eval.mjs` exists, so nothing has measured that the skill fires. A bare ✓
|
|
// there would read as "firing verified" when the question was never asked.
|
|
const dir = makeTmpDir("cov-clean-noeval");
|
|
write(dir, "skills/a/SKILL.md", skill("a"));
|
|
write(dir, "skills/a/a.harness.mjs", "// deterministic only\n");
|
|
const harnessOnly = formatUntestedReport(
|
|
findUntestedSurfaces({ basePath: dir }),
|
|
);
|
|
assert.ok(harnessOnly.startsWith("✓"), harnessOnly);
|
|
assert.ok(harnessOnly.includes("no `*.eval.mjs`"), harnessOnly);
|
|
assert.ok(harnessOnly.includes("actually fire"), harnessOnly);
|
|
|
|
// Add the eval and the caveat disappears — a plain ✓, nothing left unasked.
|
|
write(dir, "skills/a/a.eval.mjs", "// real model\n");
|
|
const both = formatUntestedReport(findUntestedSurfaces({ basePath: dir }));
|
|
assert.ok(both.startsWith("✓"), both);
|
|
assert.ok(!both.includes("eval.mjs`"), both);
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
test("formatUntestedReport names the two gaps SEPARATELY (no test/eval slash)", () => {
|
|
const dir = makeTmpDir("cov-tier-fmt");
|
|
write(dir, "skills/a/SKILL.md", skill("a"));
|
|
write(dir, "skills/a/a.harness.mjs", "// deterministic only\n");
|
|
write(dir, "skills/b/SKILL.md", skill("b"));
|
|
const text = formatUntestedReport(findUntestedSurfaces({ basePath: dir }));
|
|
// Only `b` is in the union list…
|
|
assert.ok(text.includes("1 surface(s) with no test"));
|
|
// …but the breakdown says 1 needs a harness and 2 need firing measured, with
|
|
// the cost of each named. One number could not have said that.
|
|
assert.ok(
|
|
text.includes(
|
|
"Two gaps, two costs: 1 with no deterministic harness (free, every push) · 2 whose firing was never measured",
|
|
),
|
|
text,
|
|
);
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
// ── Evidence: a STRING is not a test ─────────────────────────────────────────
|
|
// The detector used to count any occurrence of a surface's path/namespace ANYWHERE
|
|
// in a discovered test file. Measured on a real repo (37 skills, 14 hooks),
|
|
// appending one COMMENT line — `// probe: skills/argument-arc` — moved the
|
|
// untested count 33 → 32. Meanwhile a harness that genuinely asserts over 21
|
|
// skills but builds its paths at runtime contained no literal `skills/<name>` and
|
|
// covered nothing: generality was penalised, gaming was free.
|
|
|
|
test("a surface named ONLY in a comment is NOT covered (the one-line probe)", () => {
|
|
const dir = makeTmpDir("cov-comment-probe");
|
|
write(dir, "skills/argument-arc/SKILL.md", skill("argument-arc"));
|
|
// Verbatim shape of the reproduction: a real harness file, plus a comment that
|
|
// happens to spell the surface's path. Nothing asserts anything about it.
|
|
write(
|
|
dir,
|
|
"test/pipeline.harness.mjs",
|
|
[
|
|
"import { readFileSync } from 'node:fs';",
|
|
"// probe: skills/argument-arc",
|
|
"export default () => readFileSync('unrelated.txt');",
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
const r = findUntestedSurfaces({ basePath: dir });
|
|
assert.deepEqual(
|
|
r.untested.map((s) => s.name),
|
|
["argument-arc"],
|
|
"a comment is prose about a test, not a test",
|
|
);
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
test("a declaration cannot confer coverage on a file that asserts nothing", () => {
|
|
// The lie this removal exists to stop: a file whose entire content is a
|
|
// `vigiles:covers` comment used to report BOTH surfaces as tested.
|
|
const dir = makeTmpDir("cov-liar");
|
|
write(dir, "skills/alpha/SKILL.md", skill("alpha"));
|
|
write(dir, "skills/beta/SKILL.md", skill("beta"));
|
|
write(
|
|
dir,
|
|
"test/liar.harness.mjs",
|
|
"// vigiles:covers skills/alpha, skills/beta\n",
|
|
);
|
|
const r = findUntestedSurfaces({ basePath: dir });
|
|
assert.deepEqual(r.untested.map((x) => x.name).sort(), ["alpha", "beta"]);
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
test("coverage cannot be changed by editing text INSIDE a test file", () => {
|
|
// Placement decides coverage now, so the detector never reads a test's
|
|
// contents — which is what made the old metric editable by one comment line.
|
|
const dir = makeTmpDir("cov-content-blind");
|
|
write(dir, "skills/foo/SKILL.md", skill("foo"));
|
|
write(
|
|
dir,
|
|
"test/foo.test.ts",
|
|
'loadSkill("skills/foo"); // vigiles:covers skills/foo\n',
|
|
);
|
|
assert.equal(findUntestedSurfaces({ basePath: dir }).untested.length, 1);
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
test("a runtime-path harness covers nothing until its tests are colocated", () => {
|
|
// The honest cost of colocation-only, pinned rather than hidden: a harness
|
|
// that really does assert over both skills but assembles paths at runtime
|
|
// counts for neither. The fix is a test inside each skill's directory, not a
|
|
// marker claiming credit from afar.
|
|
const dir = makeTmpDir("cov-runtime");
|
|
write(dir, "skills/alpha/SKILL.md", skill("alpha"));
|
|
write(dir, "skills/beta/SKILL.md", skill("beta"));
|
|
write(
|
|
dir,
|
|
"test/pipeline.harness.mjs",
|
|
[
|
|
"// vigiles:covers skills/alpha, skills/beta",
|
|
"for (const name of ['alpha', 'beta']) {",
|
|
" assertFrontmatter(join(root, 'skills', name, 'SKILL.md'));",
|
|
"}",
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
assert.equal(findUntestedSurfaces({ basePath: dir }).untested.length, 2);
|
|
|
|
// Colocate one, and exactly one flips.
|
|
write(dir, "skills/alpha/alpha.harness.mjs", "assert.ok(true);\n");
|
|
const after = findUntestedSurfaces({ basePath: dir });
|
|
assert.deepEqual(
|
|
after.untested.map((x) => x.name),
|
|
["beta"],
|
|
);
|
|
assert.deepEqual(coverageEvidenceCounts(after), {
|
|
executed: 0,
|
|
colocated: 1,
|
|
configured: 0,
|
|
});
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
test("provenance is reported, and says placement is not proof of a run", () => {
|
|
const dir = makeTmpDir("cov-provenance");
|
|
write(dir, "skills/covered/SKILL.md", skill("covered"));
|
|
write(dir, "skills/covered/covered.eval.mjs", "assert.ok(true);\n");
|
|
const report = formatUntestedReport(findUntestedSurfaces({ basePath: dir }));
|
|
assert.match(report, /1 colocated/);
|
|
// The remaining hole is NAMED in the output rather than left for the reader
|
|
// to discover: an empty colocated file still counts.
|
|
assert.match(report, /EXISTS, not/);
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
test("an EMPTY colocated file still counts — the known, reported hole", () => {
|
|
// Pinned deliberately, and still true of the FALLBACK tier: colocation proves
|
|
// placement, not execution. What changed on 2026-08-11 is that a recorded run
|
|
// OUTRANKS it (the `executed` block at the end of this file), so a surface
|
|
// answered by colocation is one that nothing has ever been run against. The
|
|
// report says so out loud, so the number is not read as more than it is.
|
|
const dir = makeTmpDir("cov-empty");
|
|
write(dir, "skills/foo/SKILL.md", skill("foo"));
|
|
write(dir, "skills/foo/foo.eval.mjs", "");
|
|
const r = findUntestedSurfaces({ basePath: dir });
|
|
assert.equal(r.untested.length, 0);
|
|
assert.deepEqual(coverageEvidenceCounts(r), {
|
|
executed: 0,
|
|
colocated: 1,
|
|
configured: 0,
|
|
});
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// skillTestNudge — the edit-time delivery of `untested-skill`.
|
|
//
|
|
// The rule was already stated correctly; it only ever ran inside `vigiles lint`,
|
|
// which someone has to type. These pin the four behaviours that make the nudge
|
|
// worth wiring: it fires on an untested surface, it distinguishes "never
|
|
// evaluated" from "no test at all", it stays silent when covered, and it always
|
|
// hands off to the `test-harness` skill rather than restating "write a test".
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test("skillTestNudge: an untested skill gets a nudge that names the test-harness skill", () => {
|
|
const dir = makeTmpDir("nudge-untested");
|
|
write(dir, "skills/foo/SKILL.md", skill("foo"));
|
|
const msg = skillTestNudge("skills/foo/SKILL.md", { basePath: dir });
|
|
assert.ok(msg, "an untested skill must produce a nudge");
|
|
// The load-bearing property: it points at the vocabulary, not at the duty.
|
|
assert.match(msg, /test-harness/);
|
|
assert.match(msg, /measureTriggerRate/);
|
|
// …and it tells the agent where to put the result.
|
|
assert.match(msg, /skills\/foo\/foo\.eval\.mjs/);
|
|
// A nudge must never read as a gate.
|
|
assert.match(msg, /not a block/);
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
test("skillTestNudge: a harness-covered skill is nudged about FIRING, not about tests", () => {
|
|
// The case an edit to a SKILL.md usually IS: the description (= the trigger
|
|
// surface) changed, and a deterministic harness structurally cannot tell you
|
|
// whether it still fires.
|
|
const dir = makeTmpDir("nudge-uneval");
|
|
write(dir, "skills/foo/SKILL.md", skill("foo"));
|
|
write(dir, "skills/foo/foo.harness.mjs", "// vigiles:covers skills/foo\n");
|
|
const msg = skillTestNudge("skills/foo/SKILL.md", { basePath: dir });
|
|
assert.ok(msg, "covered-but-never-evaluated must still nudge");
|
|
assert.match(msg, /FIRES/);
|
|
assert.match(msg, /measureTriggerRate/);
|
|
// It must NOT claim the surface is untested — it isn't.
|
|
assert.doesNotMatch(msg, /no test or eval covers it/);
|
|
// 🔴 …and it must name the DRIVER argument. The nudge is layout-aware, so it
|
|
// reaches repos targeting a harness other than the eval tier's default, and a
|
|
// bare `measureTriggerRate(spec)` falls back to that default driver — it does
|
|
// not fail, it measures a DIFFERENT harness and reports a number. A remedy that
|
|
// silently answers about someone else's harness is worse than no remedy.
|
|
assert.match(msg, /evalDriver/);
|
|
assert.match(msg, /DEFAULT harness/);
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
test("skillTestNudge: the firing remedy is runnable on a NON-default harness too", () => {
|
|
// The half that pins the reason. Same repo shape, Codex layout: the surfaces
|
|
// live under `.codex/`, the nudge finds them (that is what layout-awareness
|
|
// bought), and the remedy it prints must be one this repo can actually run.
|
|
const dir = makeTmpDir("nudge-uneval-codex");
|
|
write(dir, ".codex/skills/foo/SKILL.md", skill("foo"));
|
|
write(dir, ".codex/skills/foo/foo.harness.mjs", "// deterministic only\n");
|
|
const msg = skillTestNudge(".codex/skills/foo/SKILL.md", {
|
|
basePath: dir,
|
|
layout: codexLayout,
|
|
});
|
|
assert.ok(msg, "a Codex repo's skill must still be nudged about firing");
|
|
assert.match(msg, /FIRES/);
|
|
// Runnable: `measureTriggerRate(spec, { evalDriver: codexEvalDriver })` is
|
|
// exported from `vigiles/codex`, so the tier IS reachable here — what the old
|
|
// wording omitted was the argument that points it at THIS harness.
|
|
assert.match(msg, /evalDriver/);
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
test("skillTestNudge: silent when the surface is covered on both tiers", () => {
|
|
const dir = makeTmpDir("nudge-covered");
|
|
write(dir, "skills/foo/SKILL.md", skill("foo"));
|
|
write(dir, "skills/foo/foo.harness.mjs", "// vigiles:covers skills/foo\n");
|
|
write(dir, "skills/foo/foo.eval.mjs", "// vigiles:covers skills/foo\n");
|
|
assert.equal(skillTestNudge("skills/foo/SKILL.md", { basePath: dir }), null);
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
test("skillTestNudge: silent for a file that is not a surface, and for another skill's edit", () => {
|
|
const dir = makeTmpDir("nudge-other");
|
|
write(dir, "skills/foo/SKILL.md", skill("foo"));
|
|
write(dir, "README.md", "# not a surface\n");
|
|
assert.equal(skillTestNudge("README.md", { basePath: dir }), null);
|
|
// An absolute-ish path ending in the surface path still matches (the hook
|
|
// passes a repo-relative path, but a caller may not).
|
|
assert.ok(skillTestNudge("/abs/repo/skills/foo/SKILL.md", { basePath: dir }));
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
test("skillTestNudge: a harness-covered AGENT is nudged about its CONTRACT, not about firing", () => {
|
|
// 🔴 The reported defect. `skillTestNudge` disables only hooks, so an agent
|
|
// reaches the never-evaluated branch exactly as a skill does — and it used to
|
|
// be handed the SKILL's sentence: that its description must "FIRE", remedied
|
|
// by `measureTriggerRate`. Both halves of that are wrong for an agent. It is
|
|
// dispatched BY NAME through `Task`; firing is not its question. And the tool
|
|
// named cannot address it at all — measured 2026-08-12, no model spent:
|
|
//
|
|
// packageSkillsDir(<agents dir>) → THREW "No <name>/SKILL.md
|
|
// skills found under …"
|
|
// measureTriggerRateWith({ pluginDir: <agents- → rate = 0 | competitors = 0
|
|
// only plugin> }, fakeRunner)
|
|
//
|
|
// The plugin form does not fail: it reports a NUMBER for a surface it never
|
|
// installed, which is the same "worse than an error" the sibling driver note
|
|
// exists for.
|
|
const dir = makeTmpDir("nudge-agent-uneval");
|
|
write(dir, "agents/bar.md", skill("bar"));
|
|
write(dir, "agents/bar.harness.mjs", "// deterministic only\n");
|
|
const msg = skillTestNudge("agents/bar.md", { basePath: dir });
|
|
assert.ok(msg, "covered-but-never-evaluated must still nudge an agent");
|
|
// What it must NOT say — the skill's question, and the tool that cannot run.
|
|
assert.doesNotMatch(msg, /actually FIRES/);
|
|
assert.doesNotMatch(msg, /irrelevantPrompts/);
|
|
assert.doesNotMatch(msg, /measureTriggerRate\(spec/);
|
|
// …and what it must say instead: the real-model tier, with the assertions the
|
|
// repo's own subagent guidance names (docs/rules/untested-subagent.md).
|
|
assert.match(msg, /runEval/);
|
|
assert.match(msg, /subagent\(name/);
|
|
assert.match(msg, /notTool/);
|
|
assert.match(msg, /assertAgentOk/);
|
|
// ⚠️ It must not invent an argument the function does not take: `runEval` /
|
|
// `measure` / `measureArms` hard-wire `spawnAgent`, and `runEvalWith` is not
|
|
// exported from `vigiles/eval` — only `paid_measureTriggerRate` has that seam.
|
|
// Suggesting `runEval(spec, { evalDriver })` would be this finding's own class
|
|
// of defect (a remedy that does not apply), so the honest limit is stated.
|
|
assert.doesNotMatch(msg, /runEval\(spec, \{ evalDriver/);
|
|
assert.match(msg, /no public dispatch/);
|
|
assert.match(msg, /not a block/);
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
test("skillTestNudge: the SKILL sentence is untouched — the fix is a branch, not a rewrite", () => {
|
|
// The quiet half of the branch. A skill in the same position keeps every word
|
|
// the two sibling tests pin, so the per-kind split cannot be mistaken for a
|
|
// regression of the skill remedy.
|
|
const dir = makeTmpDir("nudge-skill-uneval-still");
|
|
write(dir, "skills/foo/SKILL.md", skill("foo"));
|
|
write(dir, "skills/foo/foo.harness.mjs", "// deterministic only\n");
|
|
const msg = skillTestNudge("skills/foo/SKILL.md", { basePath: dir });
|
|
assert.ok(msg);
|
|
assert.match(msg, /actually FIRES/);
|
|
assert.match(msg, /measureTriggerRate\(spec, \{ evalDriver \}\)/);
|
|
// …and it does NOT acquire the agent's vocabulary.
|
|
assert.doesNotMatch(msg, /assertAgentOk/);
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
test("evalTierQuestion: total over SurfaceKind — a hook has no eval-tier question", () => {
|
|
// The exhaustiveness guarantee, asserted at the only place it is observable.
|
|
// `skillTestNudge` scans with `hooks: false`, so this arm is unreachable
|
|
// through it; without a direct test a hook would inherit whatever the last
|
|
// branch happened to return, which is how the agent got the skill's sentence.
|
|
assert.equal(evalTierQuestion("hook"), null);
|
|
// The two that DO have one are distinct texts, not the same string twice.
|
|
const forSkill = evalTierQuestion("skill");
|
|
const forAgent = evalTierQuestion("agent");
|
|
assert.ok(forSkill && forAgent);
|
|
assert.notEqual(forSkill, forAgent);
|
|
assert.match(forSkill, /FIRES/);
|
|
assert.match(forAgent, /tool contract/);
|
|
});
|
|
|
|
test("skillTestNudge: an agent surface is covered too, and a broken scan is silent", () => {
|
|
const dir = makeTmpDir("nudge-agent");
|
|
write(dir, "agents/bar.md", skill("bar"));
|
|
const msg = skillTestNudge("agents/bar.md", { basePath: dir });
|
|
assert.ok(msg);
|
|
assert.match(msg, /bar\.harness\.mjs/); // agents suggest a harness, not an eval
|
|
// A nonexistent base must not throw out of a PostToolUse hook.
|
|
assert.equal(
|
|
skillTestNudge("skills/foo/SKILL.md", { basePath: join(dir, "nope") }),
|
|
null,
|
|
);
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
// ── colocation must be NAMED, not merely nearby (defect found by dogfooding) ──
|
|
// Placement says where a file sits; only the name says what it is about. The rule
|
|
// read "any file under the skill's directory", which is the substitution the
|
|
// removed `mention` tier made, reached by a different route. Observed live: a
|
|
// consumer repo's `paper-pipeline/` held SIX evals, exactly one about that skill,
|
|
// the rest sitting there for a historical reason — and the orchestrator scored as
|
|
// covered with no test of its own.
|
|
|
|
test("a test named after ANOTHER skill does not cover the one it sits with", () => {
|
|
const dir = makeTmpDir("cov-foreign");
|
|
write(dir, ".claude/skills/orchestrator/SKILL.md", skill("orchestrator"));
|
|
write(dir, ".claude/skills/grader/SKILL.md", skill("grader"));
|
|
// The historical accident: a test ABOUT `grader`, living in `orchestrator/`.
|
|
write(
|
|
dir,
|
|
".claude/skills/orchestrator/grader-ablation.eval.mjs",
|
|
"// about grader\n",
|
|
);
|
|
const report = findUntestedSurfaces({ basePath: dir });
|
|
const untested = report.untested.map((s) => s.name).sort();
|
|
assert.deepEqual(untested, ["grader", "orchestrator"]);
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
test("…and its own, correctly named test still covers it", () => {
|
|
// The quiet half. Without it the assertion above passes on a detector that
|
|
// credits nothing at all, which is the failure mode this suite exists to catch.
|
|
const dir = makeTmpDir("cov-own-name");
|
|
write(dir, ".claude/skills/orchestrator/SKILL.md", skill("orchestrator"));
|
|
write(
|
|
dir,
|
|
".claude/skills/orchestrator/orchestrator.eval.mjs",
|
|
"// about orchestrator\n",
|
|
);
|
|
const report = findUntestedSurfaces({ basePath: dir });
|
|
assert.deepEqual(
|
|
report.untested.map((s) => s.name),
|
|
[],
|
|
);
|
|
assert.equal(coverageEvidenceCounts(report).colocated, 1);
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
test("a test in a SUBDIRECTORY is not colocated, however well named", () => {
|
|
// Colocation is worth having for one property: `ls` answers "is this tested?".
|
|
// Permit a subdirectory and it takes `find` instead — and two permitted shapes
|
|
// is a choice at write time and a lookup at read time.
|
|
//
|
|
// Measured before removing the allowance: across two real repos exactly ONE
|
|
// nested test exists, `verify-citations/scripts/verify-cites.test.mjs` — a unit
|
|
// test for a script the skill BUNDLES, not a test of the skill. It credited
|
|
// nothing anyone wanted.
|
|
const dir = makeTmpDir("cov-nested");
|
|
write(dir, ".claude/skills/foo/SKILL.md", skill("foo"));
|
|
write(dir, ".claude/skills/foo/tests/foo.harness.mjs", "// about foo\n");
|
|
const report = findUntestedSurfaces({ basePath: dir });
|
|
assert.deepEqual(
|
|
report.untested.map((s) => s.name),
|
|
["foo"],
|
|
);
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
test("…and a bundled script's own unit test does not credit the skill either", () => {
|
|
// The real case above, reproduced: `scripts/<script>.test.mjs` pins a pure
|
|
// reducer the skill ships. A good test of a script is not a test of a skill.
|
|
const dir = makeTmpDir("cov-script-test");
|
|
write(
|
|
dir,
|
|
".claude/skills/verify-citations/SKILL.md",
|
|
skill("verify-citations"),
|
|
);
|
|
write(
|
|
dir,
|
|
".claude/skills/verify-citations/scripts/verify-cites.test.mjs",
|
|
"// pure reducer\n",
|
|
);
|
|
const report = findUntestedSurfaces({ basePath: dir });
|
|
assert.deepEqual(
|
|
report.untested.map((s) => s.name),
|
|
["verify-citations"],
|
|
);
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
test("a single-skill target takes its identity from `name:`, not the checkout dir", () => {
|
|
// The base of a single-skill target is wherever the thing happens to be checked
|
|
// out — a temp dir, a CI workspace. Deriving the identity from the path would
|
|
// ask the author to name their test after their checkout directory.
|
|
const dir = makeTmpDir("cov-root-identity");
|
|
write(dir, "SKILL.md", skill("solo"));
|
|
write(dir, "solo.eval.mjs", "// about solo\n");
|
|
const report = findUntestedSurfaces({ basePath: dir });
|
|
assert.deepEqual(
|
|
report.untested.map((s) => s.name),
|
|
[],
|
|
);
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
// ── the retired `vigiles:covers` marker explains its own removal ──────────────
|
|
// Up to 15.0.2 the untested finding TOLD people to write this marker. Removing
|
|
// the tier without a word means anyone who complied loses coverage on upgrade
|
|
// with no error — the marker quietly becomes a comment. A migration nobody is
|
|
// told about is indistinguishable from a bug they caused.
|
|
|
|
test("a test still carrying `vigiles:covers` is named, with what changed", () => {
|
|
const dir = makeTmpDir("cov-legacy-marker");
|
|
write(dir, ".claude/skills/foo/SKILL.md", skill("foo"));
|
|
write(
|
|
dir,
|
|
".claude/skills/foo/foo.harness.mjs",
|
|
"// vigiles:covers skills/foo\n",
|
|
);
|
|
const report = findUntestedSurfaces({ basePath: dir });
|
|
assert.deepEqual(report.legacyCoversFiles, [
|
|
".claude/skills/foo/foo.harness.mjs",
|
|
]);
|
|
const text = formatUntestedReport(report);
|
|
assert.match(text, /retired/);
|
|
// Printed even though this repo is at ZERO untested — a repo can lose the tier
|
|
// and still be clean, and would then never hear about it.
|
|
assert.equal(report.untested.length, 0);
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
test("…and a repo that never used the marker hears nothing about it", () => {
|
|
const dir = makeTmpDir("cov-no-legacy");
|
|
write(dir, ".claude/skills/foo/SKILL.md", skill("foo"));
|
|
write(dir, ".claude/skills/foo/foo.harness.mjs", "// an ordinary test\n");
|
|
const report = findUntestedSurfaces({ basePath: dir });
|
|
assert.deepEqual(report.legacyCoversFiles, []);
|
|
assert.doesNotMatch(formatUntestedReport(report), /retired/);
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
// ── the EXECUTION tier: `.vigiles/coverage.json` ─────────────────────────────
|
|
//
|
|
// The order of answers is execution → name → nothing, and the report must say
|
|
// which one it used. Each behaviour below has both halves: it fires with an
|
|
// artifact present, and the corpus is untouched when there is none — a fresh
|
|
// clone and somebody else's repo must not get one extra nudge from this tier.
|
|
|
|
/**
|
|
* Write a run artifact naming `surfacePath` as exercised by `by` — AND put that
|
|
* script on disk, because a run that produced the record necessarily executed a
|
|
* file that was there. A record counts only while BOTH files it names are still
|
|
* present, so a fixture that named a script it never created was describing a
|
|
* checkout that cannot happen (and, before that rule, was the fixture shape in
|
|
* which a deleted harness kept granting coverage forever).
|
|
*/
|
|
function artifact(
|
|
dir: string,
|
|
entries: readonly {
|
|
path: string;
|
|
name: string;
|
|
sha: string;
|
|
tier?: "harness" | "eval";
|
|
by?: string;
|
|
at?: string;
|
|
kind?: "skill" | "agent" | "hook";
|
|
}[],
|
|
): void {
|
|
const runs = entries.map((e) => ({
|
|
kind: e.kind ?? "skill",
|
|
path: e.path,
|
|
name: e.name,
|
|
tier: e.tier ?? "harness",
|
|
how: "fired",
|
|
by: e.by ?? "runner.harness.mjs",
|
|
at: e.at ?? "2026-08-11T10:00:00.000Z",
|
|
sha: e.sha,
|
|
}));
|
|
for (const by of new Set(runs.map((r) => r.by))) {
|
|
// `by` reaches the artifact exactly as it was typed, absolute spellings
|
|
// included — so resolve it the way the detector does before creating it.
|
|
const abs = isAbsolute(by) ? by : join(dir, by);
|
|
if (!existsSync(abs)) {
|
|
mkdirSync(join(abs, ".."), { recursive: true });
|
|
writeFileSync(abs, "// the script that ran\n");
|
|
}
|
|
}
|
|
write(
|
|
dir,
|
|
".vigiles/coverage.json",
|
|
JSON.stringify({ v: 1, generated: "2026-08-11T10:00:00.000Z", runs }),
|
|
);
|
|
}
|
|
|
|
test("a recorded run covers a surface that has NO file named after it", () => {
|
|
// The whole point: `skills.harness.mjs` builds its paths at runtime and
|
|
// contains no literal `skills/<name>`, so colocation credits it with nothing.
|
|
// A run says otherwise, and the run is the thing that happened.
|
|
const dir = makeTmpDir("cov-exec");
|
|
const body = skill("alpha");
|
|
write(dir, "skills/alpha/SKILL.md", body);
|
|
write(dir, "test/pipeline.harness.mjs", "// builds paths at runtime\n");
|
|
assert.equal(findUntestedSurfaces({ basePath: dir }).untested.length, 1);
|
|
|
|
artifact(dir, [
|
|
{ path: "skills/alpha/SKILL.md", name: "alpha", sha: surfaceSha(body) },
|
|
]);
|
|
const after = findUntestedSurfaces({ basePath: dir });
|
|
assert.equal(after.untested.length, 0);
|
|
assert.deepEqual(coverageEvidenceCounts(after), {
|
|
executed: 1,
|
|
colocated: 0,
|
|
configured: 0,
|
|
});
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
test("execution OUTRANKS colocation, and the report words them differently", () => {
|
|
const dir = makeTmpDir("cov-exec-rank");
|
|
const alpha = skill("alpha");
|
|
const beta = skill("beta");
|
|
write(dir, "skills/alpha/SKILL.md", alpha);
|
|
write(dir, "skills/alpha/alpha.harness.mjs", "assert.ok(true);\n");
|
|
write(dir, "skills/beta/SKILL.md", beta);
|
|
write(dir, "skills/beta/beta.harness.mjs", ""); // an EMPTY file still colocates
|
|
artifact(dir, [
|
|
{ path: "skills/alpha/SKILL.md", name: "alpha", sha: surfaceSha(alpha) },
|
|
]);
|
|
const r = findUntestedSurfaces({ basePath: dir });
|
|
assert.deepEqual(coverageEvidenceCounts(r), {
|
|
executed: 1,
|
|
colocated: 1,
|
|
configured: 0,
|
|
});
|
|
const text = formatUntestedReport(r);
|
|
// "measured by a run" and "there is a file with a matching name" are two
|
|
// different facts. A reader who cannot tell them apart has the old number.
|
|
assert.match(text, /1 MEASURED BY A RUN/);
|
|
assert.match(text, /1 colocated/);
|
|
assert.match(text, /EXISTS, not that it ran/);
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
test("a run against OLDER text grants nothing, and says so out loud", () => {
|
|
const dir = makeTmpDir("cov-stale");
|
|
write(dir, "skills/alpha/SKILL.md", skill("alpha"));
|
|
artifact(dir, [
|
|
{
|
|
path: "skills/alpha/SKILL.md",
|
|
name: "alpha",
|
|
sha: surfaceSha("the text it had LAST week"),
|
|
},
|
|
]);
|
|
const r = findUntestedSurfaces({ basePath: dir });
|
|
assert.equal(r.untested.length, 1, "a stale run is not coverage");
|
|
assert.deepEqual(
|
|
(r.staleRuns ?? []).map((s) => s.path),
|
|
["skills/alpha/SKILL.md"],
|
|
);
|
|
// Silently counting it is the PIPELINE-STATUS disease — a tick against a
|
|
// document somebody rewrote afterwards.
|
|
assert.match(formatUntestedReport(r), /BEFORE their current/);
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
test("a record whose HARNESS was deleted grants nothing — and that is the permanent case", () => {
|
|
// 🔴 The staleness contract pinned the SURFACE and not the thing that did the
|
|
// executing. Delete the passing harness and the record stays fresh forever:
|
|
// freshness is keyed to the surface's text, which removing the test does not
|
|
// touch. And unlike an emptied harness — which the next `vigiles test` retracts
|
|
// because a `vacuous` run is in the retraction set — a DELETED file can never
|
|
// appear in `discoverScripts` output again, so no future run can withdraw it.
|
|
// Permanent, unfalsifiable execution coverage.
|
|
const dir = makeTmpDir("cov-deleted-harness");
|
|
const body = skill("alpha");
|
|
write(dir, "skills/alpha/SKILL.md", body);
|
|
artifact(dir, [
|
|
{
|
|
path: "skills/alpha/SKILL.md",
|
|
name: "alpha",
|
|
sha: surfaceSha(body),
|
|
by: "test/pipeline.harness.mjs",
|
|
},
|
|
]);
|
|
// The precondition, so the assertion below cannot pass by measuring nothing.
|
|
const before = findUntestedSurfaces({ basePath: dir });
|
|
assert.equal(before.untested.length, 0);
|
|
assert.deepEqual(coverageEvidenceCounts(before), {
|
|
executed: 1,
|
|
colocated: 0,
|
|
configured: 0,
|
|
});
|
|
|
|
// FIRES: exactly one change — the harness is gone.
|
|
rmSync(join(dir, "test/pipeline.harness.mjs"));
|
|
const after = findUntestedSurfaces({ basePath: dir });
|
|
assert.equal(after.untested.length, 1, "nothing left in this tree ran it");
|
|
assert.deepEqual(coverageEvidenceCounts(after), {
|
|
executed: 0,
|
|
colocated: 0,
|
|
configured: 0,
|
|
});
|
|
// Not "measured, but not this version" either: the surface was never edited,
|
|
// so calling it stale would name the wrong file.
|
|
assert.deepEqual(after.staleRuns ?? [], []);
|
|
assert.doesNotMatch(formatUntestedReport(after), /MEASURED BY A RUN/);
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
test("…and a RENAMED harness is the same case, because the old name never returns", () => {
|
|
// The rename is why presence is checked at read time rather than left to
|
|
// retraction: the new name writes a new record, and the OLD record — which the
|
|
// retraction set can never name again — would otherwise sit there green.
|
|
const dir = makeTmpDir("cov-renamed-harness");
|
|
const body = skill("alpha");
|
|
write(dir, "skills/alpha/SKILL.md", body);
|
|
artifact(dir, [
|
|
{
|
|
path: "skills/alpha/SKILL.md",
|
|
name: "alpha",
|
|
sha: surfaceSha(body),
|
|
by: "test/old-name.harness.mjs",
|
|
},
|
|
]);
|
|
rmSync(join(dir, "test/old-name.harness.mjs"));
|
|
write(dir, "test/new-name.harness.mjs", "// same file, new name\n");
|
|
const r = findUntestedSurfaces({ basePath: dir });
|
|
assert.deepEqual(coverageEvidenceCounts(r), {
|
|
executed: 0,
|
|
colocated: 0,
|
|
configured: 0,
|
|
});
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
test("…but a spelling of the SAME file still counts — presence, not string equality", () => {
|
|
// The QUIET half, and the one a naive `existsSync(by)` gets wrong.
|
|
// `discoverScripts` passes an argument naming an existing file through
|
|
// VERBATIM, so `by` can be `./t.harness.mjs` or an absolute path, and reading
|
|
// either as "not on disk" would delete real coverage on every scan.
|
|
const dir = makeTmpDir("cov-by-spellings");
|
|
const body = skill("alpha");
|
|
write(dir, "skills/alpha/SKILL.md", body);
|
|
write(dir, "test/t.harness.mjs", "// the script that ran\n");
|
|
for (const by of [
|
|
"test/t.harness.mjs",
|
|
"./test/t.harness.mjs",
|
|
"test/fixtures/../t.harness.mjs",
|
|
join(dir, "test/t.harness.mjs"),
|
|
]) {
|
|
artifact(dir, [
|
|
{
|
|
path: "skills/alpha/SKILL.md",
|
|
name: "alpha",
|
|
sha: surfaceSha(body),
|
|
by,
|
|
},
|
|
]);
|
|
assert.deepEqual(
|
|
coverageEvidenceCounts(findUntestedSurfaces({ basePath: dir })),
|
|
{ executed: 1, colocated: 0, configured: 0 },
|
|
by,
|
|
);
|
|
}
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
test("a stale run is reported even when the repo is at ZERO untested", () => {
|
|
// Same reason `legacyCoversNote` prints in both branches: a repo can be clean
|
|
// and still be resting on a measurement of text that no longer exists.
|
|
const dir = makeTmpDir("cov-stale-clean");
|
|
write(dir, "skills/alpha/SKILL.md", skill("alpha"));
|
|
write(dir, "skills/alpha/alpha.harness.mjs", "assert.ok(true);\n");
|
|
artifact(dir, [
|
|
{ path: "skills/alpha/SKILL.md", name: "alpha", sha: surfaceSha("older") },
|
|
]);
|
|
const r = findUntestedSurfaces({ basePath: dir });
|
|
assert.equal(r.untested.length, 0);
|
|
assert.match(formatUntestedReport(r), /BEFORE their current/);
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
test("a re-run refreshes the surface and the stale notice goes away", () => {
|
|
const dir = makeTmpDir("cov-stale-refresh");
|
|
const body = skill("alpha");
|
|
write(dir, "skills/alpha/SKILL.md", body);
|
|
// The artifact keeps the old record AND a fresh one, as merging leaves it.
|
|
artifact(dir, [
|
|
{
|
|
path: "skills/alpha/SKILL.md",
|
|
name: "alpha",
|
|
sha: surfaceSha("older"),
|
|
by: "old.harness.mjs",
|
|
at: "2026-08-01T00:00:00.000Z",
|
|
},
|
|
{
|
|
path: "skills/alpha/SKILL.md",
|
|
name: "alpha",
|
|
sha: surfaceSha(body),
|
|
by: "new.harness.mjs",
|
|
},
|
|
]);
|
|
const r = findUntestedSurfaces({ basePath: dir });
|
|
assert.equal(r.untested.length, 0);
|
|
assert.deepEqual(r.staleRuns, [], "a permanent notice is an ignored notice");
|
|
assert.deepEqual(coverageEvidenceCounts(r), {
|
|
executed: 1,
|
|
colocated: 0,
|
|
configured: 0,
|
|
});
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
test("an executed HARNESS does not silence `firing was never measured`", () => {
|
|
// The tiers cost three orders of magnitude apart. A free deterministic run
|
|
// must not answer the question only a real model can.
|
|
const dir = makeTmpDir("cov-exec-tier");
|
|
const body = skill("alpha");
|
|
write(dir, "skills/alpha/SKILL.md", body);
|
|
artifact(dir, [
|
|
{
|
|
path: "skills/alpha/SKILL.md",
|
|
name: "alpha",
|
|
sha: surfaceSha(body),
|
|
tier: "harness",
|
|
},
|
|
]);
|
|
const r = findUntestedSurfaces({ basePath: dir });
|
|
assert.equal(r.harness.untested.length, 0);
|
|
assert.equal(r.evals.untested.length, 1);
|
|
assert.match(formatUntestedReport(r), /no `\*\.eval\.mjs`|never measured/);
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
test("NO artifact ⇒ byte-for-byte the old behaviour, including the report text", () => {
|
|
// The conservative direction, pinned. A fresh clone and anyone else's repo
|
|
// must not get one extra nudge, or one fewer, from this tier existing.
|
|
const dir = makeTmpDir("cov-no-artifact");
|
|
write(dir, "skills/alpha/SKILL.md", skill("alpha"));
|
|
write(dir, "skills/beta/SKILL.md", skill("beta"));
|
|
write(dir, "skills/beta/beta.harness.mjs", "assert.ok(true);\n");
|
|
const r = findUntestedSurfaces({ basePath: dir });
|
|
assert.deepEqual(
|
|
r.untested.map((s) => s.name),
|
|
["alpha"],
|
|
);
|
|
assert.deepEqual(coverageEvidenceCounts(r), {
|
|
executed: 0,
|
|
colocated: 1,
|
|
configured: 0,
|
|
});
|
|
const text = formatUntestedReport(r);
|
|
assert.doesNotMatch(text, /MEASURED BY A RUN/);
|
|
assert.doesNotMatch(text, /BEFORE their current/);
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
test("a run record for a surface nobody discovers changes nothing", () => {
|
|
const dir = makeTmpDir("cov-ghost");
|
|
write(dir, "skills/alpha/SKILL.md", skill("alpha"));
|
|
artifact(dir, [
|
|
{ path: "skills/deleted/SKILL.md", name: "deleted", sha: "whatever" },
|
|
]);
|
|
const r = findUntestedSurfaces({ basePath: dir });
|
|
assert.equal(r.untested.length, 1);
|
|
assert.deepEqual(r.staleRuns, []);
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
// ─── the SUGGESTED path must be one `vigiles test` will actually run ────────────
|
|
//
|
|
// 🔴 Measured defect, not a hypothetical: on Node 20 — which this repo's own CI
|
|
// runs — a project holding a `tsconfig.json` but no local `tsx` was told to add
|
|
// `foo.harness.ts`, and `interpreterArgs` then threw `Cannot run TypeScript test
|
|
// script`. The author does the work and the tool says no.
|
|
|
|
test("fires: no tsx and no native type stripping ⇒ suggest `.mjs`, and the runner accepts it", () => {
|
|
const dir = makeTmpDir("tc-ts-caps");
|
|
write(dir, "tsconfig.json", "{}");
|
|
write(dir, "skills/foo/SKILL.md", skill("foo"));
|
|
// The capability the runner branches on, forced to the Node-20-no-tsx state.
|
|
const caps = { tsx: false, stripTypes: false };
|
|
assert.equal(canRunTypeScript(caps), false);
|
|
assert.equal(
|
|
testFileExt({ hasTsconfig: true, canRunTypeScript: false }),
|
|
"mjs",
|
|
);
|
|
// The property that matters end to end: the runner does not refuse the file the
|
|
// finding just asked for.
|
|
assert.doesNotThrow(() =>
|
|
interpreterArgs(
|
|
suggestedTestPath(
|
|
{
|
|
kind: "skill",
|
|
name: "foo",
|
|
path: "skills/foo/SKILL.md",
|
|
tokens: [],
|
|
ignored: false,
|
|
},
|
|
testFileExt({ hasTsconfig: true, canRunTypeScript: false }),
|
|
),
|
|
caps,
|
|
),
|
|
);
|
|
// …and the SAME repo with tsx present keeps its `.ts` suggestion — the fix is a
|
|
// capability check, not a blanket downgrade of TypeScript projects.
|
|
assert.equal(
|
|
testFileExt({ hasTsconfig: true, canRunTypeScript: true }),
|
|
"ts",
|
|
);
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
/**
|
|
* Run `fn` with the Node-20 capability profile: no `--experimental-strip-types`.
|
|
* `process.allowedNodeEnvironmentFlags` is a configurable accessor, so this is the
|
|
* one way to exercise `findUntestedSurfaces`'s REAL wiring on any Node — otherwise
|
|
* the whole gate is untestable on a dev box (Node 22 strips types natively, so the
|
|
* answer is `.ts` with or without the fix, and dropping the wiring breaks nothing
|
|
* until CI's Node 20 sees it).
|
|
*/
|
|
/**
|
|
* The MIRROR of {@link asNode20} — pretend the host CAN strip types.
|
|
*
|
|
* 🔴 WITHOUT THIS, THE "CAPABILITY RESTORED" HALF IS HOST-DEPENDENT, and that is
|
|
* exactly how this suite went red: it asserted `.ts` on whatever node happened to
|
|
* run it, passed on a Node 22 dev box, and failed on CI's Node 20 — where the
|
|
* capability is genuinely absent and `.mjs` is the CORRECT answer. The assertion
|
|
* was testing the runner, not the code.
|
|
*
|
|
* Simulating BOTH directions makes each half deterministic everywhere, which is
|
|
* the same lesson the `--experimental-test-isolation=none` probe taught two hours
|
|
* earlier in `eval-node-test-guard.test.ts`: never assert a behaviour that depends
|
|
* on which node picked up the job.
|
|
*/
|
|
function asNodeWithStripTypes(fn: () => void): void {
|
|
const original = Object.getOwnPropertyDescriptor(
|
|
process,
|
|
"allowedNodeEnvironmentFlags",
|
|
);
|
|
assert.ok(original?.configurable, "cannot simulate strip-types support");
|
|
Object.defineProperty(process, "allowedNodeEnvironmentFlags", {
|
|
value: new Set<string>(["--experimental-strip-types"]),
|
|
configurable: true,
|
|
});
|
|
try {
|
|
fn();
|
|
} finally {
|
|
Object.defineProperty(process, "allowedNodeEnvironmentFlags", original);
|
|
}
|
|
}
|
|
|
|
function asNode20(fn: () => void): void {
|
|
const original = Object.getOwnPropertyDescriptor(
|
|
process,
|
|
"allowedNodeEnvironmentFlags",
|
|
);
|
|
assert.ok(original?.configurable, "cannot simulate Node 20");
|
|
Object.defineProperty(process, "allowedNodeEnvironmentFlags", {
|
|
value: new Set<string>(),
|
|
configurable: true,
|
|
});
|
|
try {
|
|
fn();
|
|
} finally {
|
|
Object.defineProperty(process, "allowedNodeEnvironmentFlags", original);
|
|
}
|
|
}
|
|
|
|
test("fires end-to-end: on Node 20 with no tsx, the REPORT itself suggests `.mjs`", () => {
|
|
// Pins the WIRING, not just the pure decision — without the `canRunTypeScript`
|
|
// argument in `findUntestedSurfaces`, this repo's own CI (Node 20.20.2) hands
|
|
// out a path `vigiles test` refuses to run.
|
|
const dir = makeTmpDir("tc-ts-caps-wired");
|
|
write(dir, "tsconfig.json", "{}");
|
|
write(dir, "skills/foo/SKILL.md", skill("foo"));
|
|
asNode20(() => {
|
|
assert.equal(canRunTypeScript(detectNodeCaps(dir)), false);
|
|
const report = findUntestedSurfaces({ basePath: dir });
|
|
assert.equal(report.testExt, "mjs");
|
|
const [surface] = report.untested;
|
|
assert.ok(surface, "the skill must be reported untested");
|
|
assert.doesNotThrow(() =>
|
|
interpreterArgs(
|
|
suggestedTestPath(surface, report.testExt),
|
|
detectNodeCaps(dir),
|
|
),
|
|
);
|
|
});
|
|
// Same fixture, capability SIMULATED back on → the TypeScript suggestion returns.
|
|
// Simulated, not ambient: on CI's Node 20 the real capability is absent and
|
|
// `.mjs` is correct there, so asserting `.ts` against the host tested the
|
|
// runner rather than the gate.
|
|
asNodeWithStripTypes(() => {
|
|
assert.equal(findUntestedSurfaces({ basePath: dir }).testExt, "ts");
|
|
});
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
test("silent: THIS repo — a real TypeScript project that CAN run TS — still gets `.ts`", () => {
|
|
// Real input, not a fixture: the vigiles checkout has a `tsconfig.json` AND a
|
|
// local `tsx`, so the capability gate must be invisible here. If this ever flips
|
|
// to `.mjs`, the gate has started punishing TypeScript repos instead of
|
|
// protecting the ones with no runner.
|
|
const repoRoot = join(__dirname, "..");
|
|
assert.equal(canRunTypeScript(detectNodeCaps(repoRoot)), true);
|
|
assert.equal(findUntestedSurfaces({ basePath: repoRoot }).testExt, "ts");
|
|
});
|
|
|
|
test("an EXPLICIT `testExtension: ts` survives with no runner — the field exists to disagree", () => {
|
|
// Withdrawing a configured value would be the tool overruling a decision the
|
|
// author typed on purpose (they may be adding tsx in the same commit).
|
|
assert.equal(
|
|
testFileExt({
|
|
hasTsconfig: false,
|
|
configured: "ts",
|
|
canRunTypeScript: false,
|
|
}),
|
|
"ts",
|
|
);
|
|
});
|
|
|
|
// ── one list of caveats, so a renderer cannot carry half of them ─────────────
|
|
// `audit` builds its own fact block from this report and used to print neither
|
|
// qualifier, so the note explaining a silent migration was itself silent for
|
|
// anyone who runs `audit` rather than `lint`. Both renderers now read this one
|
|
// list; a third caveat added here reaches both or neither.
|
|
|
|
test("coverageCaveats collects the qualifiers, and is empty when there are none", () => {
|
|
const dirty = makeTmpDir("cov-caveats-dirty");
|
|
write(dirty, ".claude/skills/foo/SKILL.md", skill("foo"));
|
|
write(
|
|
dirty,
|
|
".claude/skills/foo/foo.harness.mjs",
|
|
"// vigiles:covers skills/foo\n",
|
|
);
|
|
const withMarker = coverageCaveats(findUntestedSurfaces({ basePath: dirty }));
|
|
assert.equal(withMarker.length, 1);
|
|
assert.match(withMarker[0] ?? "", /retired `vigiles:covers` marker/);
|
|
// Everything the lint renderer shows about caveats comes from here.
|
|
for (const line of withMarker)
|
|
assert.ok(
|
|
formatUntestedReport(findUntestedSurfaces({ basePath: dirty })).includes(
|
|
line,
|
|
),
|
|
);
|
|
cleanupTmpDir(dirty);
|
|
|
|
const clean = makeTmpDir("cov-caveats-clean");
|
|
write(clean, ".claude/skills/foo/SKILL.md", skill("foo"));
|
|
write(clean, ".claude/skills/foo/foo.harness.mjs", "// an ordinary test\n");
|
|
assert.deepEqual(
|
|
coverageCaveats(findUntestedSurfaces({ basePath: clean })),
|
|
[],
|
|
);
|
|
cleanupTmpDir(clean);
|
|
});
|
|
|
|
// ── the other half of the 15.x migration: the suffix that stopped counting ────
|
|
// `*.test.*` left DEFAULT_TEST_GLOBS because a default vitest/jest run collects
|
|
// it and a skill test spends money. The rename was announced to nobody: the
|
|
// finding prints the SURFACE path and suggests adding a file, so an author with
|
|
// `foo.test.mjs` already on disk is told to write the test they wrote.
|
|
|
|
test("a `<surface>.test.*` beside an UNTESTED surface is named, with the reason", () => {
|
|
const dir = makeTmpDir("cov-retired-suffix");
|
|
write(dir, ".claude/skills/foo/SKILL.md", skill("foo"));
|
|
write(dir, ".claude/skills/foo/foo.test.mjs", "// a skill test, misnamed\n");
|
|
const report = findUntestedSurfaces({ basePath: dir });
|
|
// Unchanged: the name grants no coverage. This is about SAYING so.
|
|
assert.equal(report.untested.length, 1);
|
|
assert.deepEqual(report.retiredTestNames, [
|
|
{
|
|
path: ".claude/skills/foo/foo.test.mjs",
|
|
surface: ".claude/skills/foo/SKILL.md",
|
|
},
|
|
]);
|
|
const text = formatUntestedReport(report);
|
|
assert.match(text, /foo\.test\.mjs/);
|
|
assert.match(text, /vitest\/jest/);
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
test("…and says nothing when the surface IS covered — a stray unit test is not our business", () => {
|
|
const dir = makeTmpDir("cov-retired-suffix-covered");
|
|
write(dir, ".claude/skills/foo/SKILL.md", skill("foo"));
|
|
write(dir, ".claude/skills/foo/foo.harness.mjs", "// the real one\n");
|
|
write(dir, ".claude/skills/foo/foo.test.mjs", "// somebody's unit test\n");
|
|
const report = findUntestedSurfaces({ basePath: dir });
|
|
assert.equal(report.untested.length, 0);
|
|
assert.deepEqual(report.retiredTestNames, []);
|
|
assert.deepEqual(coverageCaveats(report), []);
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
test("…and nothing at all in a repo with no such file", () => {
|
|
const dir = makeTmpDir("cov-retired-suffix-none");
|
|
write(dir, ".claude/skills/foo/SKILL.md", skill("foo"));
|
|
// Named after the surface, sitting beside it, and NOT a runnable test name —
|
|
// the suffix is the whole reason this finding exists, so it has to be load-bearing.
|
|
write(dir, ".claude/skills/foo/foo.notes.md", "not a test\n");
|
|
write(dir, ".claude/skills/foo/foo.test.fixture.json", "{}\n");
|
|
const report = findUntestedSurfaces({ basePath: dir });
|
|
assert.equal(report.untested.length, 1);
|
|
assert.deepEqual(report.retiredTestNames, []);
|
|
assert.doesNotMatch(formatUntestedReport(report), /vitest\/jest/);
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// `{surface}` in include — a centralized layout, without weakening what
|
|
// coverage MEANS (#175.2)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test("a {surface} testGlob credits a centralized test", () => {
|
|
// The reported layout: suites live in tests/<skill>/evals/, not beside the
|
|
// skill. `include` alone did not move the number (35 before, 35 after),
|
|
// because it widens what counts as a TEST FILE and says nothing about which
|
|
// SURFACE the file is for.
|
|
const dir = makeTmpDir();
|
|
try {
|
|
mkdirSync(join(dir, "skills", "mysql-designer"), { recursive: true });
|
|
writeFileSync(
|
|
join(dir, "skills", "mysql-designer", "SKILL.md"),
|
|
"---\nname: mysql-designer\ndescription: Designs schemas.\n---\n\nBody.\n",
|
|
);
|
|
mkdirSync(join(dir, "tests", "mysql-designer", "evals"), {
|
|
recursive: true,
|
|
});
|
|
writeFileSync(
|
|
join(dir, "tests", "mysql-designer", "evals", "promptfooconfig.yaml"),
|
|
"prompts: []\n",
|
|
);
|
|
|
|
const before = findUntestedSurfaces({ basePath: dir });
|
|
assert.equal(before.untested.length, 1, "uncovered without the glob");
|
|
|
|
const after = findUntestedSurfaces({
|
|
basePath: dir,
|
|
include: ["tests/{surface}/evals/promptfooconfig*.yaml"],
|
|
});
|
|
assert.equal(after.untested.length, 0);
|
|
assert.equal(after.decisions[0]?.evidence, "configured");
|
|
} finally {
|
|
cleanupTmpDir(dir);
|
|
}
|
|
});
|
|
|
|
test("a {surface} glob credits ONLY the surface it names", () => {
|
|
// The load-bearing half. The retired `name-mentioned` tier died because a
|
|
// substring credited surfaces nothing had touched; the placeholder must not
|
|
// reintroduce that. One skill has a suite, its neighbour does not.
|
|
const dir = makeTmpDir();
|
|
try {
|
|
for (const name of ["alpha", "beta"]) {
|
|
mkdirSync(join(dir, "skills", name), { recursive: true });
|
|
writeFileSync(
|
|
join(dir, "skills", name, "SKILL.md"),
|
|
`---\nname: ${name}\ndescription: Does ${name}.\n---\n\nBody.\n`,
|
|
);
|
|
}
|
|
mkdirSync(join(dir, "tests", "alpha", "evals"), { recursive: true });
|
|
writeFileSync(
|
|
join(dir, "tests", "alpha", "evals", "promptfooconfig.yaml"),
|
|
"prompts: []\n",
|
|
);
|
|
|
|
const r = findUntestedSurfaces({
|
|
basePath: dir,
|
|
include: ["tests/{surface}/evals/promptfooconfig*.yaml"],
|
|
});
|
|
assert.deepEqual(
|
|
r.untested.map((s) => s.name),
|
|
["beta"],
|
|
"alpha's suite must not cover beta",
|
|
);
|
|
} finally {
|
|
cleanupTmpDir(dir);
|
|
}
|
|
});
|
|
|
|
test("a plain custom glob still credits NOTHING by itself", () => {
|
|
// Unchanged behaviour, pinned: without the placeholder there is no name
|
|
// binding, and inferring one from a substring is the tier that was retired.
|
|
const dir = makeTmpDir();
|
|
try {
|
|
mkdirSync(join(dir, "skills", "gamma"), { recursive: true });
|
|
writeFileSync(
|
|
join(dir, "skills", "gamma", "SKILL.md"),
|
|
"---\nname: gamma\ndescription: Does gamma.\n---\n\nBody.\n",
|
|
);
|
|
mkdirSync(join(dir, "tests", "gamma", "evals"), { recursive: true });
|
|
writeFileSync(
|
|
join(dir, "tests", "gamma", "evals", "promptfooconfig.yaml"),
|
|
"prompts: []\n",
|
|
);
|
|
|
|
const r = findUntestedSurfaces({
|
|
basePath: dir,
|
|
include: ["tests/*/evals/promptfooconfig*.yaml"],
|
|
});
|
|
assert.equal(r.untested.length, 1, "no placeholder ⇒ no surface binding");
|
|
} finally {
|
|
cleanupTmpDir(dir);
|
|
}
|
|
});
|
|
|
|
test("colocation still wins, and is reported as colocation", () => {
|
|
// Precedence: a repo doing both must not be relabelled by the new tier.
|
|
const dir = makeTmpDir();
|
|
try {
|
|
mkdirSync(join(dir, "skills", "delta"), { recursive: true });
|
|
writeFileSync(
|
|
join(dir, "skills", "delta", "SKILL.md"),
|
|
"---\nname: delta\ndescription: Does delta.\n---\n\nBody.\n",
|
|
);
|
|
writeFileSync(
|
|
join(dir, "skills", "delta", "delta.harness.mjs"),
|
|
"// a colocated harness\n",
|
|
);
|
|
mkdirSync(join(dir, "tests", "delta", "evals"), { recursive: true });
|
|
writeFileSync(
|
|
join(dir, "tests", "delta", "evals", "promptfooconfig.yaml"),
|
|
"prompts: []\n",
|
|
);
|
|
|
|
// BOTH glob orders, because the point is that the answer does NOT depend on
|
|
// the order. A first-found implementation passes one of these and fails the
|
|
// other — which is exactly how the stale "strongest, not first-found" doc
|
|
// comment was caught: it described a ranking the code did not do.
|
|
for (const include of [
|
|
["**/*.harness.mjs", "tests/{surface}/evals/promptfooconfig*.yaml"],
|
|
["tests/{surface}/evals/promptfooconfig*.yaml", "**/*.harness.mjs"],
|
|
]) {
|
|
const r = findUntestedSurfaces({ basePath: dir, include });
|
|
assert.equal(r.untested.length, 0);
|
|
assert.equal(
|
|
r.decisions[0]?.evidence,
|
|
"colocated",
|
|
`colocation must win with globs ordered ${JSON.stringify(include)}`,
|
|
);
|
|
}
|
|
} finally {
|
|
cleanupTmpDir(dir);
|
|
}
|
|
});
|