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

@ -28,8 +28,8 @@ import {
checkWiring,
ciRunsUnconditionally,
runsCommand,
scriptChainRuns,
} from "./verify-skills.mjs";
import { scriptChainRuns } from "./lib/npm-scripts.mjs";
const SCRIPT = path.join(
path.dirname(fileURLToPath(import.meta.url)),
@ -483,3 +483,73 @@ test("checkWiring rejects a local:gate that only mentions the validator", () =>
/local:gate` no longer runs `verify:skills:cli`/,
);
});
test("runsCommand rejects failure-masking and conditional shell forms", () => {
// `npm run X || true` swallows a rejection so CI stays green; `true || npm
// run X` never runs the validator. Both are `run:` steps containing the
// command, so a separator-agnostic split counted them (Copilot).
const C = "npm run verify:skills:cli";
for (const script of [`${C} || true`, `true || ${C}`, `${C} | tee log`]) {
assert.equal(runsCommand(script, C), false, script);
}
});
test("ciRunsUnconditionally rejects a step whose failure cannot fail the job", () => {
const C = "npm run verify:skills:cli";
const head = "on:\n push:\njobs:\n build:\n";
// `continue-on-error` means a rejected skill leaves the workflow green, so
// the step asserts nothing — on the step or on the whole job.
assert.equal(
ciRunsUnconditionally(
`${head} steps:\n - continue-on-error: true\n run: ${C}\n`,
C,
),
false,
);
assert.equal(
ciRunsUnconditionally(
`${head} steps:\n - continue-on-error: "true"\n run: ${C}\n`,
C,
),
false,
);
assert.equal(
ciRunsUnconditionally(
`on:\n push:\njobs:\n build:\n continue-on-error: true\n steps:\n - run: ${C}\n`,
C,
),
false,
);
// An explicit `false` is the normal case and must still count.
assert.equal(
ciRunsUnconditionally(
`${head} steps:\n - continue-on-error: false\n run: ${C}\n`,
C,
),
true,
);
});
test("scriptChainRuns rejects failure-masking chains", () => {
const T = "verify:skills:cli";
assert.equal(
scriptChainRuns({ "local:gate": `npm run ${T} || true` }, "local:gate", T),
false,
);
assert.equal(
scriptChainRuns({ "local:gate": `true || npm run ${T}` }, "local:gate", T),
false,
);
});
test("scriptChainRuns follows npm's implicit pre/post hooks", () => {
const T = "verify:skills:cli";
assert.equal(
scriptChainRuns(
{ "local:gate": "npm run coverage", precoverage: `npm run ${T}` },
"local:gate",
T,
),
true,
);
});