chore: address Copilot review round 20

All three findings are the same shape: a check that accepts a command which
exists but cannot gate.

- Splitting on `|` and `&` conflated `||`, so both failure-masking forms counted:
  `npm run X || true` swallows a rejection and `true || npm run X` never runs
  the validator. Both `runsCommand` and the chain walk now split on `\n`, `;`
  and `&&` only, which refuses each without special-casing either.
- A CI step (or job) carrying `continue-on-error` asserts nothing, since a
  rejected skill leaves the workflow green. Both are now skipped, treating a
  string `"true"` and an unevaluable `${{ }}` expression as masking on the same
  conservative principle as the `if:` checks; an explicit `false` still counts.
- `rootReachesScript` was substring-based, so a `validate` reading `echo npm run
  verify:skills` satisfied the sibling vouch while the guard never executed —
  the cycle protecting nothing. It now uses the exact chain walk, which is
  therefore shared in `lib/npm-scripts.mjs` and fixes the same hole in all four
  vouches, not just this PR's. `reachableScripts` keeps its substring behavior
  for the harvesting callers, where over-inclusion is safe.

The chain walk also follows npm's implicit pre/post hooks, with a test — a guard
invoked from a `prevalidate` is genuinely run, and reporting it missing would be
a false failure introduced by the fix.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-29 05:32:12 -04:00
commit a2ddece7a5

View file

@ -85,5 +85,49 @@ export function rootRunsClientValidate(rootScripts, clientDir) {
* `validate` is caught by its sibling. Only deleting both slips through.
*/
export function rootReachesScript(rootScripts, scriptName) {
return reachableScripts(rootScripts, "validate").has(scriptName);
return scriptChainRuns(rootScripts, "validate", scriptName);
}
/**
* Whether `npm run <entry>` reaches a real invocation of `npm run <target>`.
*
* `reachableScripts` matches every `npm run …` **substring**, which is right for
* harvesting (over-inclusion is safe there) and wrong for a vouch: `echo npm run
* verify:skills` would satisfy it while the guard never executes (Copilot).
*
* Splitting is on `\n`, `;` and `&&` only — deliberately NOT on `|` or a bare
* `&`, because `||` is the failure-masking form and both of its shapes must be
* rejected: `npm run X || true` swallows the guard's own failure, and `true ||
* npm run X` never runs it. Leaving them inside a segment means the segment does
* not equal the invocation, so both are refused without special-casing either.
*
* A segment naming some OTHER script is followed, with or without trailing
* flags, as are npm's implicit `pre`/`post` hooks. Only the target is matched
* exactly, which suits the argument-less scripts a vouch asks about.
*
* @param {Record<string, string>} scripts
* @param {string} entry
* @param {string} target
*/
export function scriptChainRuns(scripts, entry, target) {
const seen = new Set();
const queue = [entry];
while (queue.length > 0) {
const name = queue.shift();
if (seen.has(name)) continue;
seen.add(name);
// npm runs pre<name>/post<name> implicitly around <name>.
for (const hook of [`pre${name}`, `post${name}`])
if (typeof scripts?.[hook] === "string") queue.push(hook);
const body = scripts?.[name];
if (typeof body !== "string") continue;
for (const segment of body.split(/\n|;|&&/).map((part) => part.trim())) {
if (segment === `npm run ${target}`) return true;
const tokens = segment.split(/\s+/);
if (tokens[0] === "npm" && tokens[1] === "run" && tokens[2]) {
queue.push(tokens[2]);
}
}
}
return false;
}