diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 1fc417da2..4afbc7151 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -435,9 +435,32 @@ jobs: - name: A port inventory does not read as a capability claim run: node .harness/scripts/ci/45-validate-port-inventory-honesty.mjs --verbose + # GT-624: the 2026-07-23 security wave sat unpublished until 2026-07-27 + # while SECURITY.md declared the 1.1.x line "actively patched". Nothing + # detected it; an audit did. This asks the registry what is published, finds + # the commit where each package's published version was SET, and fails on any + # commit after it whose type or scope marks it as security. It needs the + # network — a registry answer is the only honest source for "published", as + # the newest v* tag here is v1.1.0 while npm serves 1.2.2. + - name: No security fix is committed but unpublished + run: node .harness/scripts/ci/48-validate-security-publish-lag.mjs --verbose + + # GT-638: a gap id is chosen by reading the highest GT-* on whichever branch + # you are on, so two parallel sessions allocate the same number and find out + # at merge time. `8449af3d` had to renumber GT-634 -> GT-637 for exactly this. + # The comparison needs the BASE branch, which a shallow checkout does not + # have — fetch it explicitly rather than letting the guard fail for the wrong + # reason. + - name: One gap id, one gap + run: | + git fetch --no-tags --depth=1 origin "${{ github.base_ref || github.event.repository.default_branch }}" + node .harness/scripts/ci/49-validate-gap-id-allocation.mjs --verbose + - name: Self-tests for the governance guards run: | node --test .harness/scripts/ci/42-validate-guard-denominators.test.mjs + node --test .harness/scripts/ci/48-validate-security-publish-lag.test.mjs + node --test .harness/scripts/ci/49-validate-gap-id-allocation.test.mjs node --test .harness/scripts/ci/41-validate-evidence-commands.test.mjs node --test .harness/scripts/ci/43-validate-guard-negative-fixtures.test.mjs node --test .harness/scripts/ci/44-validate-adr-implementation-status.test.mjs @@ -459,7 +482,7 @@ jobs: - name: Execute the board's recorded validationCommands (report mode) run: node .harness/scripts/ci/41-validate-evidence-commands.mjs --execute --verbose - # Ratchet, not a cliff. The budget is 21, which is what THIS RUNNER counts. + # Ratchet, not a cliff. The budget is 19, which is what THIS RUNNER counts. # # It was briefly lowered to 303 on the strength of the guard's computed # "ratchet basis" — 288 dead locally plus 15 that resolve only through @@ -490,7 +513,7 @@ jobs: # Lower it as records are repaired, from a number this job printed; at 0, # delete the flag and this becomes the strict gate. - name: Dead-reference ratchet (must not grow) - run: node .harness/scripts/ci/41-validate-evidence-commands.mjs --strict --max-dead 21 + run: node .harness/scripts/ci/41-validate-evidence-commands.mjs --strict --max-dead 19 # RETIRED 2026-07-27 (GT-570): the `publish-npm` job lived here and published # ONLY `src/sdk/cli`, with `--tag beta` and no provenance, writing the token diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 79d647a33..4d49b6cdf 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -99,13 +99,17 @@ jobs: - name: Self-tests for the ADR ruleset generator run: node --test .harness/scripts/generate-adr-rulesets.test.mjs - # GT-598 / GT-633: `native-evaluability-snapshot.json` says of itself that it - # is a capture, and for a long time nothing captured it — it was written by - # hand and drifted, while its guard compared the file against six numbers - # typed into the test: the same six the file contained. Drift is not - # contained either, because `build-iso-5055-mapping.mjs` stamps - # nativeEvaluability onto all 388 mapping rows FROM this file. The order of - # these two steps is the order of the chain. + # GT-598: `native-evaluability-snapshot.json` says of itself that it is a + # capture, and until now nothing captured it — it was written by hand, and + # it drifted. It still declared `documentation-only: 129` after Core moved + # to 136 (the same seven ADR rulesets the step above exists for), and its + # guard could not see that, because it compared the snapshot against six + # numbers hardcoded in the test: the same six the snapshot contained. + # + # Drift here is not contained. `build-iso-5055-mapping.mjs` stamps + # nativeEvaluability onto all 388 rows of the mapping FROM this file, so a + # stale class is laundered into the larger artifact and overstates the + # handler backlog. The order of these two steps is the order of the chain. - name: Native evaluability snapshot is a faithful capture of Core's triage run: node src/rulesets/standards/capture-native-evaluability-snapshot.mjs --check diff --git a/.gitignore b/.gitignore index 453187c08..2030016fb 100644 --- a/.gitignore +++ b/.gitignore @@ -38,7 +38,12 @@ bower_components build/Release # Dependency directories -node_modules/ +# No trailing slash on purpose: `node_modules/` matches directories ONLY, and a +# fresh worktree has no node_modules of its own — the documented workaround is to +# symlink the main checkout's. A symlink is not a directory, so the slashed form +# left it untracked-but-visible, and `git add -A` staged a link to an absolute +# path on one machine. +node_modules .harness/bin/ jspm_packages/ diff --git a/.harness/scripts/ci/41-validate-evidence-commands.mjs b/.harness/scripts/ci/41-validate-evidence-commands.mjs index 882c8a047..513765254 100644 --- a/.harness/scripts/ci/41-validate-evidence-commands.mjs +++ b/.harness/scripts/ci/41-validate-evidence-commands.mjs @@ -194,6 +194,56 @@ const SAFE_GIT_SUBS = new Set([ /** `npm ` classification. */ const NPM_MUTATING = new Set(['publish', 'ci', 'install', 'i', 'link', 'version', 'pack', 'audit']); +/** + * Flags whose VALUE is the next token, so the value is not an operand. + * + * Without this, `npm run --workspace src/sdk/cli test` reports that the script + * `"src/sdk/cli"` is not declared: the first non-dash token after `run` is the + * flag's value, not the script name. That form is valid npm, so the recorded + * command was right and the parser was wrong — and the failure LOOKED like stale + * evidence, which is the worst way for a guard to be wrong. + */ +const VALUE_TAKING_FLAGS = new Set([ + '--workspace', '-w', '--prefix', '-C', '--filter', '--registry', '--loglevel', '--tag', +]); + +/** + * Built-in subcommands of npm / pnpm / yarn that are NOT package scripts. + * + * `pnpm info ` and `yarn info ` query the registry. Reading `info` as a + * script name and then failing because no `"info"` script is declared blames the + * evidence for a command that runs perfectly well. + */ +const PM_BUILTIN_SUBCOMMANDS = new Set([ + 'info', 'view', 'ls', 'list', 'exec', 'why', 'outdated', 'dlx', 'create', 'init', + 'ping', 'search', 'whoami', 'config', 'licenses', 'dedupe', 'store', 'root', 'bin', + 'prefix', 'explain', 'doctor', 'help', 'add', 'remove', 'update', 'up', 'dist-tag', + 'deprecate', 'owner', 'access', 'team', 'star', 'unpublish', 'login', 'logout', +]); + +/** + * Index of the first true operand at or after `from`, skipping flags AND the + * values they consume. -1 when the token list is nothing but flags. + */ +function firstOperandIndex(tokens, from) { + for (let i = from; i < tokens.length; i += 1) { + const tok = tokens[i]; + if (tok === '--') return -1; // everything after `--` belongs to the script + if (tok.startsWith('-')) { + if (VALUE_TAKING_FLAGS.has(tok)) i += 1; // its value is not an operand + continue; + } + return i; + } + return -1; +} + +/** The operand itself, or null. */ +function firstOperand(tokens, from) { + const i = firstOperandIndex(tokens, from); + return i === -1 ? null : tokens[i]; +} + /** * Scripts this runner refuses to spawn even though they are `node` and * resolvable. Each entry needs a reason: a bare denylist rots into a no-op. @@ -512,17 +562,6 @@ function pathOperands(cmd) { * workspace's own VALUE token (which does not start with `-`) is mistaken for * the script name. */ -function npmScriptOperand(tokens) { - for (let i = 0; i < tokens.length; i++) { - const tok = tokens[i]; - if (tok === '--workspace' || tok === '-w') { i += 1; continue; } - if (tok.startsWith('--workspace=')) continue; - if (tok.startsWith('-')) continue; - return tok; - } - return undefined; -} - /** * Resolve a command's referent. * @returns {{ status: 'resolved'|'dead'|'unchecked', detail: string }} @@ -558,11 +597,14 @@ export function resolveCommand(cmd, ctx) { if (!ws) return { status: 'dead', detail: `npm workspace does not exist: ${wsRef}` }; } } - const sub = t[1]; + // The subcommand is the first OPERAND, not `t[1]`: `npm --workspace w run test` + // puts a flag there. Same skipping rule as the script name below. + const subIdx = firstOperandIndex(t, 1); + const sub = subIdx === -1 ? null : t[subIdx]; let scriptName = null; - if (sub === 'run' || sub === 'run-script') scriptName = npmScriptOperand(t.slice(2)); + if (sub === 'run' || sub === 'run-script') scriptName = firstOperand(t, subIdx + 1); else if (sub === 'test') scriptName = 'test'; - else if (sub && !sub.startsWith('-') && !NPM_MUTATING.has(sub) && sub !== 'exec' && sub !== 'ls' && sub !== 'view') scriptName = sub; + else if (sub && !NPM_MUTATING.has(sub) && !PM_BUILTIN_SUBCOMMANDS.has(sub)) scriptName = sub; const pkg = ws ? ws.pkg : readJson(join(root, cmd.cwd || '.', 'package.json')); if (scriptName && pkg?.scripts && !Object.prototype.hasOwnProperty.call(pkg.scripts, scriptName)) { diff --git a/.harness/scripts/ci/41-validate-evidence-commands.test.mjs b/.harness/scripts/ci/41-validate-evidence-commands.test.mjs index 83afd2f4e..8cfd9e778 100644 --- a/.harness/scripts/ci/41-validate-evidence-commands.test.mjs +++ b/.harness/scripts/ci/41-validate-evidence-commands.test.mjs @@ -45,8 +45,13 @@ before(() => { mkdirSync(join(fixtureRoot, 'scripts'), { recursive: true }); mkdirSync(join(fixtureRoot, 'pkgs', 'alpha'), { recursive: true }); + // `scripts` must be PRESENT and must not contain the names under test. The + // undeclared-script branch is guarded by `pkg?.scripts`, so a root with no + // scripts block skips it entirely — and every assertion about an undeclared + // root script would pass without exercising anything. writeFileSync(join(fixtureRoot, 'package.json'), JSON.stringify({ name: 'fixture-root', private: true, workspaces: ['pkgs/*'], + scripts: { lint: 'echo lint' }, })); writeFileSync(join(fixtureRoot, 'pkgs', 'alpha', 'package.json'), JSON.stringify({ name: '@fixture/alpha', version: '1.0.0', scripts: { test: 'echo ok' }, @@ -167,6 +172,53 @@ describe('resolveCommand', () => { assert.equal(r.status, 'resolved'); }); + // --- GT-598 follow-on: two parser defects that BLAMED THE EVIDENCE --------- + // + // Both produced a `dead` verdict for a command that runs perfectly well, which + // is the worst way for this guard to be wrong: it reads as stale evidence and + // invites someone to "repair" a record that was already correct. + + test('the script name is not the value of --workspace (flag order must not matter)', () => { + // `npm run --workspace