* test(#2724): delete golden-install-parity fixtures, test, and generator Removes the 19 committed path->hash manifests, the two per-file size baselines, tests/golden-install-parity.test.cjs, and scripts/gen-golden-install-parity-zcode.cjs. These were pure functions of the source tree (ADR-2719); the differential attribution check (tests/emitted-attribution.test.cjs + tests/emitted-provenance.test.cjs) is now the sole gate for emitted-artifact propagation. tests/fixtures/install-tree/*.json and tests/golden-install-tree.test.cjs are unchanged (ADR-2719 section 7 exception). Follow-up commits fix the resulting bookkeeping: scripts/ci-test-scope.cjs's existence guard, .gitattributes, package.json scripts, the emitted-provenance totality guard's IO, the differential check's baseline acquisition, CI wiring to publish/restore the baseline artifact, and docs. * refactor(#2724): make the differential attribution check self-sufficient Three fixes required to delete the golden fixtures without breaking CI: - scripts/ci-test-scope.cjs: remove tests/golden-install-parity.test.cjs from the three rules that named it. #2759's missingRuleTestFiles guard hard-throws at module load if a rule names a test file absent from disk, which would break the changes job on every PR the moment the fixture-deletion commit landed. - tests/helpers/emitted-provenance.cjs: loadManifests() read the committed golden fixture directory. With that directory deleted at every future ref, this would throw at module load forever, taking the Phase 2 totality guard down with it. Rebuilt from real installer spawns (MANIFEST_FAMILIES + runMinimalInstall + buildParityManifest), the same shape emitted-runtime.cjs's currentManifests() already uses. - tests/emitted-attribution.test.cjs / tests/helpers/emitted-runtime.cjs: the real-tree test's baseline acquisition swaps from baselineManifestsAtRef(base) (git show at a ref that no longer carries fixtures) to resolveBaseline()'s documented precedence: env, then the on-disk cache, then an in-job build. The build fallback (buildBaselineAtRef, new) checks out base into a throwaway git worktree and runs the new scripts/gen-emitted-baseline.cjs there -- no npm ci needed, since bin/install.js and the test helper shells are Node-builtins-only. That script also publishes the baseline artifact from CI's push-to-next job (wired in a follow-up commit). * refactor(#2724): retire the merge-driver bridge and per-file size baselines The Phase 1 bridge (#2721) is retired now that the artifacts it guarded are deleted: scripts/git-merge-regen-driver.cjs, its test, and the 'setup:merge-driver' npm script are removed, and the .gitattributes merge=gsd-regen/linguist-generated block for the three deleted-path globs is dropped. tests/fixtures/install-tree/*.json keeps its normal merge behavior, unchanged (ADR-2719 section 7). scripts/update-size-baseline.cjs and its test are removed: their sole purpose was regenerating tests/workflow-size-baseline.json and tests/agent-size-baseline.json, both deleted. The 'size:baseline' npm script and its step in 'regen:derived' go with it. The per-file baseline describe blocks in tests/workflow-size-budget.test.cjs and tests/agent-size-budget.test.cjs are removed for the same reason; the independent loose-tier hard caps are untouched. The differential attribution check's size ratchet (tests/emitted-diff.cjs, already shipped in #2723) is the replacement anti-creep mechanism. 'npm run gen:golden' is replaced by 'npm run gen:install-tree', which keeps regenerating tests/fixtures/install-tree/*.json (the one artifact family ADR-2719 section 7 keeps committed); tests/golden-install-tree.test.cjs's error messages point at the new command name. tests/golden-parity-single-source.test.cjs's anti-divergence guard (#2266) is retargeted from the two deleted golden-parity consumers to their two replacements (tests/helpers/emitted-runtime.cjs and tests/helpers/emitted-provenance.cjs), which import buildParityManifest the same way — the divergence risk the guard exists for is unchanged. Also wires CI: a new publish-emitted-baseline job runs scripts/gen-emitted-baseline.cjs after a push to next and caches the result keyed on the sha; the test and test-full jobs restore that cache on pull_request events, keyed on the PR's base sha, and export GSD_EMITTED_BASELINE for tests/emitted-attribution.test.cjs's real-tree test to pick up. * docs(#2724): flip ADR-2719 to Accepted and update contributor docs Status: Proposed -> Accepted. Regenerated docs/adr/README.md index. CONTRIBUTING.md, docs/TESTING-SUITES.md, and CONTEXT.md (RULESET. EMITTED_ATTRIBUTION, RULESET.WORKFLOW_SIZE_BUDGET, RULESET. AGENT_SIZE_BUDGET, and the Emitted Artifact Provenance glossary entry) no longer point at the deleted golden-install-parity fixtures, size baselines, gen:golden, UPDATE_GOLDEN, or the setup:merge-driver / git-merge-regen-driver.cjs bridge. Editing shipped content now requires zero manual fixture regeneration, documented against the differential attribution check instead of the deleted commands. * docs(#2724): add changeset for removed golden-parity commands * fix(#2724): drop stale scripts/update-size-baseline.cjs glossary ref check-glossary-refs.cjs verifies every backtick-wrapped scripts/*.cjs token in CONTEXT.md resolves to a real file. The RULESET. EMITTED_ATTRIBUTION rewrite named the deleted script inside backticks, which the checker reads as a live reference, not historical prose. * test(#2724): retarget ci-test-scope tests off the deleted golden test tests/ci-test-scope.test.cjs asserted specific RULES entries select tests/golden-install-parity.test.cjs, and that every rule selecting it also selects both emitted gates. Both premises broke when the golden test was deleted (#2724): the deleted filename never re-appears in targeted_tests, and there was no longer a third file for the gates to travel alongside. Retargeted the two selection describe blocks to assert tests/emitted-provenance.test.cjs directly (the drift guard the golden gate's rules were retargeted to), and simplified the third block to assert the two emitted gates always travel together, without reference to the golden filename. * docs(#2724): repoint two contributor how-to guides at the differential check Both guides told contributors to regenerate a baseline against tests/golden-install-parity.test.cjs, which #2724 deletes. Repointed at the differential attribution check (tests/emitted-attribution.test.cjs, ADR-2719), which needs no manual regeneration step. * fix(#2724): repair phase6-capstone-conformance's deleted-baseline read An independent orthogonal review caught a real regression this branch introduced into a test file the branch's diff never touched: tests/phase6-capstone-conformance.test.cjs read tests/workflow-size-baseline.json (deleted earlier in this branch) with no fallback, so the whole suite would throw ENOENT the moment this branch landed. The test's actual intent — prove the host-loop workflow files are real, tracked, non-empty docs — is preserved by asserting the live byte count via the same shared counter (scripts/workflow-size.cjs) the size guards already use, instead of a committed snapshot. Also, from the same review: a stale doc comment in scripts/workflow-size.cjs still named the deleted scripts/update-size-baseline.cjs as a consumer, and buildBaselineAtRef's cleanup in tests/helpers/emitted-runtime.cjs left two fs.rmSync calls unguarded against masking the primary result/error, inconsistent with the try/catch already wrapping the git cleanup beside them. Both fixed. A doc comment was added to baselineFamilyNamesAtRef explaining why it (and its siblings) are kept despite having no production caller post-cutover — they still answer real questions about refs that predate the cutover. * fix(#2724): repair three real regressions found by remote verification 1. tests/emitted-provenance.test.cjs's two hostile-input tests (non-object manifest, unreadable fixture) drove loadManifests(tmp) and monkeypatched fs.readFileSync, both premised on the deleted fixture-directory read this branch already replaced with real installer spawns -- the negative assertions silently stopped firing. loadManifests() now accepts injected {families, install, build, clean} (defaulting to production values), giving the tests a real seam to drive a bad build result and a build failure through the ACTUAL loader instead of a reimplementation, and added coverage that clean() still runs on both paths. 2. .github/workflows/test.yml's two 'Export GSD_EMITTED_BASELINE' steps hardcoded shell: bash, which is wrong on windows-latest (native pwsh) and on test-full's macos-latest legs (native zsh per that job's own matrix) -- the repo's H1 shell policy (tests/policy-shell-pinning .test.cjs) caught it. Replaced the inline bash script with scripts/ci-export-emitted-baseline-env.cjs, a plain Node script: a bare 'node <path>' command line has no shell-specific syntax, so it runs correctly under bash, zsh, and pwsh without a shell override. tests/phase6-capstone-conformance.test.cjs's deleted-baseline read (caught by the same remote run, at a commit prior to this one) was already fixed in d0c3b1242 and is not touched here; verified still passing after these changes. * fix(#2724): revive ADR-1610's new-file size cap inside the differential An isolated review caught a real regression: deleting tests/workflow-size-baseline.json silently dropped NEW_FILE_CAP (ADR-1610 Decision point 3, the Codex project_doc_max_bytes anchor) with no successor. tests/helpers/emitted-diff.cjs's size ratchet already 'continue's past any file absent from sizeBaseline -- exactly the files this cap exists to bound -- so a brand-new workflow file sized 32,769-40,960 bytes passed CI clean and shipped, then risked silent truncation at the Codex anchor at runtime. ADR-1610 is Accepted and never referenced anywhere in this branch. Fix: NEW_FILE_CAP=32768 revived inside emitted-diff.cjs's own size-ratchet loop, keyed off the SAME hasOwnProperty(sizeBaseline, name) signal the growth check already computes -- 'new' is exactly 'present in sizeCurrent, absent from sizeBaseline'. Not ack-able, matching the tier hard caps it sits beside: the fix is extraction, not an acknowledgment entry. Documented, disclosed narrowing: the pure differential module cannot see XL_WORKFLOWS/LARGE_WORKFLOWS tiering (tests/workflow-size-budget.test.cjs's classification), so a legitimately large new file must extract rather than tier in, one release earlier than an existing file would need to. ADR-1610 itself is left unamended -- this restores its decision rather than re-litigating it. Also fixes a stale comment plus a redundant real 19-installer-spawn assertion left over from the pre-injection-seam version of tests/emitted-provenance.test.cjs's build-failure test, and annotates 3 of 4 stale golden-fixture citations in docs/reference/host-integration-capability-matrix.md as superseded (the 4th is an accurate historical PR narrative, left alone). * fix(#2724): repair three red CI defects on the golden-fixture cutover Windows-only provenance false attribution (defect A): the `hooks-built` provenance rule attributed `hooks/<name>.cmd` to itself. Those shims are Windows-only installer output (ensureCodexHooksJsonSessionStart / ensureCodexHooksJsonEvent, both in src/runtime-hooks-surface.cts) wrapping the same-named `.js` hook — no `.cmd` file is ever tracked in the repo, so the self-attribution resolved to a path that exists on no platform. Only windows-latest ever emits the key, so this only failed there. Fixed by special-casing `.cmd` inside the SAME `hooks-built` rule (not a dedicated rule) — a dedicated rule would match zero paths, and therefore report as a dead rule, on every non-Windows lane of the same totality guard. `sources` already supported per-match functions; `transforms` is extended to support the same shape so the attribution can vary by match within one rule. Baseline bootstrap was structurally impossible (defect B): `buildBaselineAtRef` ran `scripts/gen-emitted-baseline.cjs` from INSIDE the base-ref worktree, but that script is new in this PR and therefore absent at any base ref that predates it — every call failed closed with "Cannot find module". Fixed by running the PR checkout's own generator against the worktree via a new `--dir` parameter, decoupling "which copy of the script runs" from "which tree it measures" (`currentManifests`/`currentSizes` gained a `repoRoot` override, threaded down to `runMinimalInstall`'s new `installScript` override). This is not just a bootstrap fix: a differential needs ONE measurement schema applied to both sides, or the two stop being comparable the moment that schema evolves — running each side's own copy would silently reintroduce that risk. Verified locally end-to-end against real origin/next: resolves a valid {version, sha, manifests, sizes} artifact with the correct sha and no leaked worktree. Changeset placeholder (defect C): `pr: 0` -> `pr: 2767`, which is what let docs-lint evaluate the fragment for the first time; it already passes (docs/TESTING-SUITES.md and friends already document the removed scripts). Also fixed while in this file: an eslint no-unused-vars warning surfaced by the changed lint run (unused `cleanup` import in tests/emitted-provenance.test.cjs). Added regression coverage for both A and B: a cross-platform spot-check that drives the real hooks-built rule against `.cmd` keys directly (not through a real Windows install), and a real-tree test that drives buildBaselineAtRef against a base ref verified (via git cat-file) to lack the generator, both skipping honestly rather than false-passing when their precondition does not hold. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W5kQs6ZufZDySC6zDJfYP6 * fix(#2724): repair false .cmd byte-provenance and a permanently-skipping regression test Two isolated-review findings on PR #2767: - `hooks-built`'s `.cmd` branch attributed the Windows shim's bytes to the wrapped `hooks/<name>.js` script, asserting a byte-provenance link that does not exist — traced against buildCodexHookWindowsShimIR (src/runtime-hooks-surface.cts), only the script's NAME (a literal in that same file) flows into the .cmd bytes, never its content. Point `sources` at HOOKS_WINDOWS_SHIM_SRC instead, matching the code-derived convention used elsewhere in the table. Since `sources` is checked before `transforms` in the differential, the wrong mapping silently excused any .cmd byte movement caused by editing the wrapped .js file. - The `buildBaselineAtRef` regression test skipped unless a resolvable base ref still lacked scripts/gen-emitted-baseline.cjs — true only until this PR merges, after which every base ref carries the file and the test skips forever with zero ongoing coverage. Rebuilt hermetically: synthesize the missing-generator condition in-place via git plumbing (a throwaway commit, child of HEAD, with just that one file removed from a scratch index), never touching the real working tree, HEAD, or index, and never depending on ambient history or remotes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W5kQs6ZufZDySC6zDJfYP6 * fix(#2724): tolerate the remote runner's dubious-ownership git mount in the emitted baseline path The runner container mounts the repo at a path owned by a different uid than the process running the suite, so git's dubious-ownership protection refuses every git operation there. GitHub Actions never hits this because actions/checkout registers the workspace as safe automatically; this runner's container does not. buildBaselineAtRef is the production build-fallback the sole remaining emitted gate depends on (resolveBaseline's in-job-build leg), not just a test helper, so the fix is in the shared git() wrapper (emitted-runtime.cjs) that every caller — resolveChangedPaths, resolveBase, buildBaselineAtRef's worktree add/remove/prune, and the hermetic regression test added in the prior commit — funnels through, plus gen-emitted-baseline.cjs's own rev-parse (now reusing that same wrapper instead of a second execFileSync, so the fix has one source of truth). Each call declares -c safe.directory=<the exact directory it already operates on>, never the * wildcard. Audited every other helper on this surface (emitted-diff.cjs, emitted-baseline.cjs, install-shared.cjs) for the same gap: none of them shell out to git at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W5kQs6ZufZDySC6zDJfYP6 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
577 lines
24 KiB
JavaScript
577 lines
24 KiB
JavaScript
#!/usr/bin/env node
|
||
'use strict';
|
||
|
||
const path = require('path');
|
||
const { execFileSync } = require('child_process');
|
||
const { existsSync, readdirSync, appendFileSync } = require('fs');
|
||
|
||
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
|
||
|
||
// Workflow files that are purely administrative / policy bots. Changes to these
|
||
// files do NOT require the cross-platform test matrix — only a lightweight
|
||
// ubuntu lane running workflow-lint tests is needed.
|
||
// FAIL-SAFE: any .github/workflows/*.yml NOT listed here is treated as a
|
||
// pipeline workflow and gets the full matrix. New workflow files default to full.
|
||
const INERT_WORKFLOWS = new Set([
|
||
'stale.yml',
|
||
'branch-cleanup.yml',
|
||
'branch-naming.yml',
|
||
'auto-label-issues.yml',
|
||
'auto-branch.yml',
|
||
'auto-backmerge.yml',
|
||
'close-draft-prs.yml',
|
||
'dismiss-unauthorized-pr-approvals.yml',
|
||
'pr-target-validator.yml',
|
||
'pr-template-format.yml',
|
||
'require-issue-link.yml',
|
||
'changeset-required.yml',
|
||
'docs-required.yml',
|
||
'discord-changelog.yml',
|
||
]);
|
||
|
||
// Workflows that gate merges, ship the product, or run security/cross-platform
|
||
// suites — these must ALWAYS get the full pipeline treatment and can never be
|
||
// added to INERT_WORKFLOWS. A module-load assertion enforces this so a mistaken
|
||
// or malicious addition fails CI loudly in the `changes` job on every PR.
|
||
const PROTECTED_WORKFLOWS = new Set([
|
||
'test.yml',
|
||
'install-smoke.yml',
|
||
'mutation.yml',
|
||
'security-scan.yml',
|
||
'release.yml',
|
||
]);
|
||
for (const wf of PROTECTED_WORKFLOWS) {
|
||
if (INERT_WORKFLOWS.has(wf)) {
|
||
throw new Error(`ci-test-scope: protected workflow "${wf}" must not be in INERT_WORKFLOWS (it requires the full test matrix).`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Returns true if the path is an inert (non-pipeline) workflow file.
|
||
* Only `.github/workflows/<name>` where <name> is in INERT_WORKFLOWS qualifies.
|
||
*/
|
||
function isInertCi(filePath) {
|
||
if (!filePath.startsWith('.github/workflows/')) return false;
|
||
const name = filePath.slice('.github/workflows/'.length);
|
||
// Must be a direct child (no further slashes) and in the allowlist.
|
||
return !name.includes('/') && INERT_WORKFLOWS.has(name);
|
||
}
|
||
|
||
// Tests shared by both the 'workflow automation' and 'inert CI' rules.
|
||
const WORKFLOW_LINT_TESTS = [
|
||
'tests/workflow-shell-pinning.test.cjs',
|
||
'tests/pr-template-policy.test.cjs',
|
||
'tests/lint-pr-check-project-dir.test.cjs',
|
||
];
|
||
|
||
const RULES = [
|
||
{
|
||
name: 'workflow automation',
|
||
// Only NON-inert .github/workflows/* and all .github/rulesets/* trigger full matrix.
|
||
// FAIL-SAFE: any .github/workflows/*.yml not in INERT_WORKFLOWS is treated as pipeline.
|
||
match: filePath => (filePath.startsWith('.github/workflows/') && !isInertCi(filePath)) ||
|
||
filePath.startsWith('.github/rulesets/'),
|
||
fullMatrix: true,
|
||
tests: [
|
||
...WORKFLOW_LINT_TESTS,
|
||
'tests/release-tarball-smoke-workflow.test.cjs',
|
||
],
|
||
},
|
||
{
|
||
name: 'inert CI',
|
||
match: filePath => isInertCi(filePath),
|
||
fullMatrix: false,
|
||
tests: [
|
||
...WORKFLOW_LINT_TESTS,
|
||
'tests/policy-lint-shallow-checkout.test.cjs',
|
||
],
|
||
},
|
||
{
|
||
name: 'test harness',
|
||
match: path => path === 'scripts/run-tests.cjs',
|
||
fullMatrix: true,
|
||
tests: [
|
||
'tests/run-tests-harness.test.cjs',
|
||
'tests/workflow-shell-pinning.test.cjs',
|
||
],
|
||
},
|
||
{
|
||
name: 'environment and dependency gates',
|
||
match: path => [
|
||
'scripts/check-env.cjs',
|
||
'scripts/check-npm-integrity.cjs',
|
||
'package.json',
|
||
'package-lock.json',
|
||
].includes(path),
|
||
fullMatrix: true,
|
||
tests: [
|
||
'tests/check-env.test.cjs',
|
||
'tests/npm-integrity-gate.test.cjs', // #2758: absorbs the former tests/bug-3588-npm-audit-clean.test.cjs (folded into it by consolidation epic #1969 B6 #1975; the stale filename here was a silent coverage hole this rule never actually re-selected)
|
||
'tests/package-manifest.test.cjs',
|
||
],
|
||
},
|
||
{
|
||
name: 'TS runtime sources (ADR-457 build-at-publish)',
|
||
// src/*.cts compiles into gsd-core/bin/lib/*.cjs; a source-only edit must
|
||
// still trigger the migrated module's tests (otherwise CI silently skips them).
|
||
match: path => path.startsWith('src/') || path === 'tsconfig.build.json',
|
||
tests: [
|
||
'tests/semver-compare.test.cjs', // #2758: absorbs the former tests/bug-10-semver-policy-consolidation.test.cjs (folded into it by consolidation epic #1969 B3 #1972; the stale filename here was a silent coverage hole this rule never actually re-selected)
|
||
'tests/emitted-provenance.test.cjs', // any src/installer change can alter emitted install artifacts → re-verify provenance totality (#2724: golden-install-parity retired, this is the sole gate)
|
||
'tests/emitted-attribution.test.cjs',
|
||
],
|
||
},
|
||
{
|
||
name: 'installer and package layout',
|
||
match: path => path.startsWith('bin/') ||
|
||
path.startsWith('gsd-core/bin/') ||
|
||
path.includes('install') ||
|
||
path.includes('release-tarball-smoke'),
|
||
fullMatrix: true,
|
||
tests: [
|
||
'tests/install.test.cjs',
|
||
'tests/install-regressions.test.cjs',
|
||
'tests/install-runtime-artifacts.test.cjs',
|
||
'tests/install-path-detection.test.cjs',
|
||
// NOTE: release-tarball-smoke.install.test.cjs is intentionally NOT here.
|
||
// It is a 3–6 min `npm pack` + `npm install -g` integration test with its
|
||
// OWN dedicated workflow (.github/workflows/install-smoke.yml, triggered on
|
||
// the production install paths). Running it in the scoped/targeted lane too
|
||
// is redundant and blows the per-chunk Windows timeout when a broad PR
|
||
// bundles it with many other changed test files (epic #1969). See the
|
||
// SCOPED_LANE_EXCLUDE guard below, which also drops it when it is itself a
|
||
// changed test file.
|
||
'tests/runtime-artifact-layout.test.cjs',
|
||
'tests/emitted-provenance.test.cjs', // any src/installer change can alter emitted install artifacts → re-verify provenance totality (#2724: golden-install-parity retired, this is the sole gate)
|
||
'tests/emitted-attribution.test.cjs',
|
||
],
|
||
},
|
||
{
|
||
name: 'shipped install content (emitted-attribution drift guard, #2267/#2724)',
|
||
// Every source file the installer EMITS into a runtime layout is captured by
|
||
// the emitted-attribution differential + the install-tree snapshot. A source
|
||
// edit here that changes emitted output MUST re-verify (#2266: a
|
||
// hooks/gsd-statusline.js edit changed installed output but no rule selected
|
||
// the drift guard, so a stale emitted state shipped to next undetected).
|
||
// Union semantics: this ADDS the drift guard on top of each path's existing
|
||
// content-specific tests. Targeted lane only (the real-tree test skips win32
|
||
// by design), no fullMatrix.
|
||
// #2724: golden-install-parity.test.cjs is retired (ADR-2719 Phase 4); the
|
||
// emitted differential (ADR-2719 Phase 2/3) is now the sole gate for a PR
|
||
// editing only shipped content, the archetypal emitted-ripple case.
|
||
// NOTE: intentionally NOT a blanket 'gsd-core/' prefix, for two reasons:
|
||
// (1) gsd-core/bin/** is tsc-compiled runtime output — EXCLUDED_PREFIXES-
|
||
// excluded from both manifests, and already covered by the 'installer and
|
||
// package layout' rule (path.startsWith('gsd-core/bin/')) — so matching it
|
||
// here would be pure noise; and
|
||
// (2) enumerating only the installer-shipped content subtrees preserves the
|
||
// bug-408 unit-fallback contract: a gsd-core/ path that is NOT shipped
|
||
// verbatim (the bug-408 test uses gsd-core/src/some-util.js) must still
|
||
// fall back to ['unit'] when no rule matches.
|
||
// Listed: the four gsd-core content subtrees the installer ships verbatim
|
||
// (contexts, references, templates, workflows) + bin/shared/*.json data files.
|
||
// Verify against Object.keys(golden fixture) grouped by gsd-core/<subdir>.
|
||
match: path =>
|
||
['hooks/', 'commands/', 'agents/', 'skills/', 'gsd-core/workflows/', 'gsd-core/templates/', 'gsd-core/references/', 'gsd-core/contexts/', 'scripts/changeset/', 'scripts/lib/'].some(p => path.startsWith(p)) ||
|
||
(path.startsWith('gsd-core/bin/shared/') && path.endsWith('.json')) ||
|
||
['scripts/fix-slash-commands.cjs', 'scripts/gen-capability-registry.cjs', 'scripts/gen-loop-host-contract.cjs'].includes(path),
|
||
tests: [
|
||
'tests/golden-install-tree.test.cjs',
|
||
'tests/emitted-provenance.test.cjs',
|
||
'tests/emitted-attribution.test.cjs',
|
||
],
|
||
},
|
||
{
|
||
name: 'hooks',
|
||
match: path => path.startsWith('hooks/'),
|
||
fullMatrix: true,
|
||
tests: [
|
||
'tests/hook-validation.test.cjs',
|
||
'tests/managed-hooks.test.cjs',
|
||
'tests/hooks-opt-in.test.cjs',
|
||
'tests/sh-hook-paths.test.cjs',
|
||
'tests/precommit-alias-drift-hook.test.cjs',
|
||
'tests/prepush-enterprise-email-hook.test.cjs',
|
||
],
|
||
},
|
||
{
|
||
name: 'changeset tooling',
|
||
match: path => path.startsWith('scripts/changeset/') || path.startsWith('.changeset/'),
|
||
tests: [
|
||
'tests/changeset-cli.test.cjs',
|
||
'tests/changeset-lint.test.cjs',
|
||
'tests/changeset-new.test.cjs',
|
||
'tests/changeset-parse.test.cjs',
|
||
'tests/changeset-render.test.cjs',
|
||
'tests/changeset-serialize.test.cjs',
|
||
'tests/changeset-github-release-notes.test.cjs',
|
||
],
|
||
},
|
||
{
|
||
name: 'security scanners',
|
||
match: path => path.includes('secret-scan') ||
|
||
path.includes('base64-scan') ||
|
||
path.includes('prompt-injection-scan') ||
|
||
path.startsWith('tests/fixtures/adversarial/security/'),
|
||
tests: [
|
||
'tests/secret-scan-lint.security.test.cjs',
|
||
'tests/prompt-injection-scan.security.test.cjs',
|
||
'tests/security-prompt-injection.security.test.cjs',
|
||
'tests/read-injection-scanner.security.test.cjs',
|
||
'tests/security-scan.security.test.cjs',
|
||
],
|
||
},
|
||
{
|
||
name: 'command definitions',
|
||
match: path => path.startsWith('commands/'),
|
||
tests: [
|
||
'tests/command-contract.test.cjs',
|
||
'tests/command-routing-hub.test.cjs',
|
||
'tests/commands.test.cjs',
|
||
'tests/docs-parity-live-registry.test.cjs',
|
||
'tests/phase-command-router.test.cjs',
|
||
'tests/roadmap-command-router.test.cjs',
|
||
],
|
||
},
|
||
{
|
||
name: 'workflow prompts',
|
||
match: path => path.startsWith('gsd-core/workflows/'),
|
||
tests: [
|
||
'tests/workflow-compat.test.cjs',
|
||
'tests/workflow-size-budget.test.cjs',
|
||
'tests/workflow-guard-registration.test.cjs',
|
||
'tests/commands.test.cjs',
|
||
// #2758: was 'tests/bug-3683-workflow-colon-namespace-leak.test.cjs', deleted by
|
||
// consolidation epic #1969 (B6 #1975) and folded into slash-command-namespace.test.cjs
|
||
// ("folded:bug-3683-workflow-colon-namespace-leak" describe block). The stale filename
|
||
// here was itself an instance of this issue's defect class — silently dropped by
|
||
// existingTests() below, so gsd-core/workflows/ changes stopped re-running this
|
||
// regression's coverage with nothing signaling it.
|
||
'tests/slash-command-namespace.test.cjs',
|
||
],
|
||
},
|
||
{
|
||
name: 'agent prompts',
|
||
match: path => path.startsWith('agents/'),
|
||
tests: [
|
||
'tests/agent-frontmatter.test.cjs',
|
||
'tests/agent-size-budget.test.cjs',
|
||
'tests/agent-skills.test.cjs',
|
||
'tests/agent-skills-awareness.test.cjs',
|
||
'tests/agent-required-reading-consistency.test.cjs',
|
||
'tests/docs-parity-live-registry.test.cjs',
|
||
],
|
||
},
|
||
{
|
||
name: 'configuration',
|
||
match: path => ['config', 'configuration', 'model-catalog', 'model-profile'].some(k => path.includes(k)),
|
||
tests: [
|
||
'tests/config.test.cjs',
|
||
'tests/config-get-default.test.cjs',
|
||
'tests/configuration-migrate-config.test.cjs',
|
||
'tests/model-catalog-runtime-defaults.test.cjs',
|
||
'tests/model-profiles.test.cjs',
|
||
],
|
||
},
|
||
{
|
||
// ADR-1703 portability lint surface. Editing a rule, the shared vocab/guard
|
||
// helpers, or the eslint config that wires them must re-run the rule suites
|
||
// + the disable-ban. The disable-ban also scans bin/install.js and
|
||
// scripts/build-hooks.js (the Phase 6 glob-expansion surface), so changes
|
||
// to those files re-run it too.
|
||
name: 'portability lint rules (ADR-1703)',
|
||
match: path => path.startsWith('eslint-rules/') ||
|
||
path === 'eslint.config.mjs' ||
|
||
path === 'bin/install.js' ||
|
||
path === 'scripts/build-hooks.js',
|
||
tests: [
|
||
'tests/portability-rule-disable-ban.test.cjs',
|
||
'tests/portability-vocab-drift.test.cjs',
|
||
// All nine RuleTester suites (P1–P6) — editing any rule / the shared
|
||
// vocab+guard helpers / the eslint config re-runs the full rule family.
|
||
'tests/no-path-literal-in-assert.rule.test.cjs',
|
||
'tests/no-posix-mode-bit-assert.rule.test.cjs',
|
||
'tests/no-unguarded-nonportable-exec.rule.test.cjs',
|
||
'tests/no-crlf-fragile-split.rule.test.cjs',
|
||
'tests/no-hardcoded-tmp.rule.test.cjs',
|
||
'tests/no-bare-npm-exec.rule.test.cjs',
|
||
'tests/require-userprofile-with-home.rule.test.cjs',
|
||
'tests/normalize-path-in-content.rule.test.cjs',
|
||
'tests/require-fs-op-fallback.rule.test.cjs',
|
||
],
|
||
},
|
||
];
|
||
|
||
/**
|
||
* Every RULES[].tests entry (deduped, across every rule) that does NOT exist on
|
||
* disk. #2758: a rule naming a test file that no longer exists is not merely
|
||
* inert — existingTests() below silently drops it out of targeted_tests, with
|
||
* nothing in the CI output signaling why. Phase 4 (#2724) deletes
|
||
* tests/golden-install-parity.test.cjs; without this check, any rule still
|
||
* naming it would stop selecting the guard entirely and CI would stay green
|
||
* throughout. Pure and independent of which rule / which file: it catches ANY
|
||
* phantom entry, not only the two names this issue is about.
|
||
* Paths resolve relative to the repo root (this file's parent directory), not
|
||
* the caller's cwd, so the check behaves identically whether invoked as the CLI
|
||
* (`node scripts/ci-test-scope.cjs ...`, cwd == repo root by convention) or
|
||
* required directly by a test.
|
||
*/
|
||
function missingRuleTestFiles(rules) {
|
||
const referenced = new Set();
|
||
for (const rule of rules) {
|
||
for (const f of rule.tests) referenced.add(f);
|
||
}
|
||
return [...referenced].filter(f => !existsSync(path.join(__dirname, '..', f))).sort();
|
||
}
|
||
|
||
// Fail loudly at module load, mirroring the PROTECTED_WORKFLOWS check above —
|
||
// this fires on EVERY invocation of the CLI (including the real `changes` job
|
||
// in .github/workflows/test.yml), not only when a test suite happens to run.
|
||
{
|
||
const missing = missingRuleTestFiles(RULES);
|
||
if (missing.length > 0) {
|
||
throw new Error(
|
||
`ci-test-scope: RULES reference test file(s) that do not exist on disk ` +
|
||
`(silent coverage hole — see #2758):\n ${missing.join('\n ')}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
function usage() {
|
||
return [
|
||
'Usage:',
|
||
' node scripts/ci-test-scope.cjs --base <sha> --head <sha>',
|
||
' node scripts/ci-test-scope.cjs --files <path-list>',
|
||
'',
|
||
'Prints JSON by default. With GITHUB_OUTPUT set, also writes workflow outputs.',
|
||
].join('\n');
|
||
}
|
||
|
||
function parseArgs(argv) {
|
||
const out = { base: null, head: null, files: null };
|
||
for (let i = 0; i < argv.length; i++) {
|
||
const arg = argv[i];
|
||
if (arg === '--base') {
|
||
out.base = argv[++i];
|
||
if (!out.base || out.base.startsWith('--')) throw new Error('--base requires a value');
|
||
} else if (arg.startsWith('--base=')) {
|
||
out.base = arg.slice('--base='.length);
|
||
if (!out.base) throw new Error('--base requires a value');
|
||
} else if (arg === '--head') {
|
||
out.head = argv[++i];
|
||
if (!out.head || out.head.startsWith('--')) throw new Error('--head requires a value');
|
||
} else if (arg.startsWith('--head=')) {
|
||
out.head = arg.slice('--head='.length);
|
||
if (!out.head) throw new Error('--head requires a value');
|
||
} else if (arg === '--files') {
|
||
out.files = argv[++i];
|
||
if (!out.files || out.files.startsWith('--')) throw new Error('--files requires a value');
|
||
} else if (arg.startsWith('--files=')) {
|
||
out.files = arg.slice('--files='.length);
|
||
if (!out.files) throw new Error('--files requires a value');
|
||
} else if (arg === '--help' || arg === '-h') {
|
||
console.log(usage());
|
||
throw new ExitError(0);
|
||
} else {
|
||
throw new Error(`unknown argument: ${arg}`);
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function splitFiles(value) {
|
||
if (!value) return [];
|
||
const SEPARATORS = new Set([',', ' ', '\t', '\n', '\r', '\f', '\v']);
|
||
const tokens = [];
|
||
let current = '';
|
||
for (const ch of value) {
|
||
if (SEPARATORS.has(ch)) {
|
||
if (current) tokens.push(current);
|
||
current = '';
|
||
} else {
|
||
current += ch;
|
||
}
|
||
}
|
||
if (current) tokens.push(current);
|
||
return tokens.map(v => v.trim()).filter(Boolean);
|
||
}
|
||
|
||
function changedFiles(args) {
|
||
if (args.files) return splitFiles(args.files);
|
||
if (!args.base || !args.head) {
|
||
throw new Error('--base/--head or --files is required');
|
||
}
|
||
// Three-dot diff (merge-base...head) matches GitHub's PR "Files changed" semantics.
|
||
// A two-dot `git diff base head` would surface every file `next` gained after this
|
||
// branch's merge-base, mis-flagging product_changed/full_matrix on docs-only PRs cut
|
||
// from a slightly stale base (#837). The `changes` job checks out with fetch-depth: 0,
|
||
// so the merge-base is always available.
|
||
const stdout = execFileSync('git', ['diff', '--name-only', `${args.base}...${args.head}`], {
|
||
encoding: 'utf8',
|
||
});
|
||
return splitFiles(stdout);
|
||
}
|
||
|
||
function existingTests(files) {
|
||
const all = new Set(readdirSync('tests').filter(f => f.endsWith('.test.cjs')).map(f => `tests/${f}`));
|
||
return files.filter(file => all.has(file) && existsSync(file));
|
||
}
|
||
|
||
function addAll(set, values) {
|
||
for (const value of values) set.add(value);
|
||
}
|
||
|
||
// Windows-sensitive filename hints — deliberately narrow. 'workflow',
|
||
// 'install', and 'hook' were dropped from this list: workflow-lint tests are
|
||
// platform-independent YAML/policy checks, and the installer/hooks RULES set
|
||
// fullMatrix=true, so the full Windows lane already runs when those paths
|
||
// change. The old six-hint list pulled 102 of ~633 test files into the scoped
|
||
// windows lane, turning it into a ~10-minute job on every PR.
|
||
const WINDOWS_HINTS = ['windows', 'win32', 'shell', 'path'];
|
||
const isWindowsHint = s => WINDOWS_HINTS.some(k => s.toLowerCase().includes(k));
|
||
|
||
function classify(files) {
|
||
const targeted = new Set();
|
||
const windows = new Set();
|
||
const reasons = [];
|
||
let productOrPipelineChanged = false; // product/pipeline code (excludes docs)
|
||
let inertCiChanged = false; // inert workflow files
|
||
let fullMatrix = false;
|
||
|
||
for (const file of files) {
|
||
// Determine if this file is product/pipeline code.
|
||
// docs/ and root-level .md files are intentionally excluded.
|
||
// 'skills/' is shipped agent-skill content installed into every runtime by
|
||
// the installer (see the 'shipped install content' RULES entry below) — it
|
||
// must be product code, or a skills/-only change silently gets
|
||
// code_changed=false and skips the ENTIRE CI matrix, not merely golden-parity
|
||
// (found while verifying the #2267 golden-parity rule against skills/**: the
|
||
// rule fired in `reasons` but classify()'s codeChanged gate zeroed out every
|
||
// targeted test because 'skills/' was absent from this list).
|
||
if (
|
||
['bin/', 'src/', 'gsd-core/', 'agents/', 'commands/', 'hooks/', 'skills/', 'tests/', 'scripts/', 'eslint-rules/'].some(p => file.startsWith(p)) ||
|
||
file === 'package.json' || file === 'package-lock.json' ||
|
||
(file.startsWith('tsconfig') && file.endsWith('.json')) ||
|
||
file.startsWith('.github/rulesets/')
|
||
) {
|
||
productOrPipelineChanged = true;
|
||
}
|
||
|
||
// Non-inert .github/workflows/* are pipeline code → full matrix.
|
||
if (file.startsWith('.github/workflows/') && !isInertCi(file)) {
|
||
productOrPipelineChanged = true;
|
||
}
|
||
|
||
// Inert workflow files set a lightweight signal.
|
||
if (isInertCi(file)) {
|
||
inertCiChanged = true;
|
||
}
|
||
|
||
if (file.startsWith('tests/') && file.endsWith('.test.cjs')) {
|
||
targeted.add(file);
|
||
// #494 invariant, narrowed: a changed test must still be exercised on
|
||
// the divergent OS before merge, but at per-file cost — it ALWAYS joins
|
||
// the scoped windows lane instead of triggering the three full parity
|
||
// lanes. (full_matrix fired on 15/15 sampled PRs because test-driven
|
||
// PRs always touch tests/, costing ~25 runner-minutes each.) Changed
|
||
// tests already run on ubuntu-22 and ubuntu-24 via targeted_tests; the
|
||
// residual macOS / windows-node-22 cross-product is covered by the full
|
||
// matrix on every push to next.
|
||
windows.add(file);
|
||
}
|
||
|
||
for (const rule of RULES) {
|
||
if (rule.match(file)) {
|
||
addAll(targeted, rule.tests);
|
||
reasons.push(`${file}: ${rule.name}`);
|
||
if (rule.fullMatrix) fullMatrix = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Heavy integration tests that own a dedicated workflow must never run in the
|
||
// scoped/targeted lane — they carry a multi-minute cost that overruns the
|
||
// per-chunk timeout (worst on Windows) when a broad PR bundles them with many
|
||
// other changed test files, and their production paths already trigger their
|
||
// own workflow. Drop them however they entered (matched rule OR changed-file).
|
||
const SCOPED_LANE_EXCLUDE = new Set([
|
||
// covered by .github/workflows/install-smoke.yml
|
||
'tests/release-tarball-smoke.install.test.cjs',
|
||
]);
|
||
for (const f of SCOPED_LANE_EXCLUDE) { targeted.delete(f); windows.delete(f); }
|
||
|
||
// code_changed: true when product/pipeline OR inert CI changed.
|
||
// Docs-only PRs (neither flag set) get code_changed=false → full matrix skip.
|
||
const codeChanged = productOrPipelineChanged || inertCiChanged;
|
||
|
||
const targetedTests = existingTests([...targeted].sort());
|
||
|
||
// When code changed but no rule matched any changed file, fall back to the
|
||
// unit suite so the targeted lane always runs something meaningful (#408).
|
||
if (codeChanged && targetedTests.length === 0) {
|
||
targetedTests.push('unit');
|
||
}
|
||
|
||
const windowsTests = existingTests([...new Set([...windows, ...targetedTests.filter(isWindowsHint)])].sort());
|
||
|
||
// Inert-CI-only: full_matrix must be false (override any RULES that fired).
|
||
if (inertCiChanged && !productOrPipelineChanged) {
|
||
fullMatrix = false;
|
||
}
|
||
|
||
// Normalize: when code_changed is false, the output must be self-consistent.
|
||
// A docs file can coincidentally match a coarse content RULE (e.g. docs/installer-migrations.md
|
||
// matches the installer rule via path.includes('install')), leaving full_matrix=true and
|
||
// non-empty targeted_tests/windows_tests. The workflow skips correctly (gated on code_changed)
|
||
// but the output object would be self-contradictory. Force a clean "nothing to run" result.
|
||
if (!codeChanged) {
|
||
fullMatrix = false;
|
||
targetedTests.length = 0;
|
||
windowsTests.length = 0;
|
||
}
|
||
|
||
return {
|
||
code_changed: codeChanged,
|
||
product_changed: productOrPipelineChanged,
|
||
full_matrix: fullMatrix,
|
||
targeted_tests: targetedTests,
|
||
windows_tests: windowsTests,
|
||
reasons: [...new Set(reasons)].sort(),
|
||
};
|
||
}
|
||
|
||
function writeOutputs(result) {
|
||
if (!process.env.GITHUB_OUTPUT) return;
|
||
const lines = [
|
||
`code_changed=${result.code_changed}`,
|
||
`product_changed=${result.product_changed}`,
|
||
`full_matrix=${result.full_matrix}`,
|
||
`targeted_tests=${result.targeted_tests.join(' ')}`,
|
||
`windows_tests=${result.windows_tests.join(' ')}`,
|
||
];
|
||
appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join('\n')}\n`);
|
||
}
|
||
|
||
function main() {
|
||
try {
|
||
const args = parseArgs(process.argv.slice(2));
|
||
|
||
const files = changedFiles(args);
|
||
const result = classify(files);
|
||
result.changed_files = files;
|
||
writeOutputs(result);
|
||
console.log(JSON.stringify(result, null, 2));
|
||
} catch (error) {
|
||
if (error instanceof ExitError) throw error;
|
||
console.error(`ci-test-scope: ${error.message}`);
|
||
console.error(usage());
|
||
throw new ExitError(2);
|
||
}
|
||
}
|
||
|
||
if (require.main === module) {
|
||
runMain(main);
|
||
}
|
||
|
||
module.exports = { RULES, missingRuleTestFiles };
|