Files
msd-core/scripts/ci-test-scope.cjs
Tom Boucher 1fe85cd43e chore(#4244): ESLint rules for the #4220 Windows dirname-walk / TMPDIR-triad bug class (#4246)
* fix(#4244): repoint TEMP/TMP alongside TMPDIR and fix the sweepProtectSet fixed-point walk

Repo-wide sweep (ahead of adding lint rules for these exact bug classes)
found both incident patterns still live and unfixed on `next`:

- scripts/run-tests.cjs's sweepProtectSet walk stopped on
  `cur !== runTempRoot && cur.length > 1` — a POSIX-only sentinel.
  win32 dirname('D:\') is a fixed point (length 3, never satisfies
  `> 1`... wait, it does satisfy length>1), so a selected file living
  outside runTempRoot (the common case) spins the walk forever on
  Windows. Extracted a pure, exported computeSweepProtectSet helper
  that terminates on dirname(cur) === cur instead, with in-process
  RuleTester-style coverage for both win32 and posix paths.

- tests/run-tests-temp-root.test.cjs's own #4020 regression test set
  only TMPDIR on its runNode(...) child env. Node's os.tmpdir() never
  reads TMPDIR on Windows (only TEMP, then TMP), so the redirect
  silently no-oped there — masked because Windows CI died in the
  dirname-walk hang above before ever reaching this test.

- tests/config-schema.property.test.cjs's fallow config-set test had
  the same TMPDIR-only pattern, direct process.env assignment this
  time, restored in its own finally block.

Origin: #4220 and its shared root cause #4020.

* feat(#4244): require-full-tmpdir-triad and no-unbounded-dirname-walk ESLint rules

Two custom local ESLint rules catch the #4220 / #4020 Windows CI hang bug
class at author time, joining the ADR-1703 DEFECT.WINDOWS-TEST-PORTABILITY
catalog. Neither eslint-plugin-unicorn nor eslint-plugin-n has a rule for
either shape.

- local/require-full-tmpdir-triad: flags a TMPDIR environment override
  (direct process.env.TMPDIR assignment, or a TMPDIR property in a
  spawn-like call's env: object literal) not accompanied by TEMP and TMP
  in the same scope. Node's os.tmpdir() never reads TMPDIR on Windows.
  Registered on tests/**/*.cjs, matching the require-userprofile-with-home
  precedent.

- local/no-unbounded-dirname-walk: flags a while/do-while loop reassigning
  from dirname() with no fixed-point termination guard
  (dirname(cur) !== cur, or path.parse(cur).root). path.dirname() is a
  no-op at the platform root, but the value differs by platform
  (win32 'D:\' is length 3, posix '/' is length 1), so a POSIX-shaped
  length/equality bound never fires on Windows. Registered on BOTH
  tests/**/*.cjs and scripts/**/*.cjs — the real #4020 bug lived in
  scripts/run-tests.cjs, not tests/.

Both rules join the zero-escape-hatch discipline already established for
this catalog (no bespoke comment marker; PROTECTED_RULES in
tests/portability-rule-disable-ban.test.cjs independently bans
eslint-disable of either). ADR-1703 and its two companion contributing
docs get an amendment documenting the mechanism, code examples, and the
repo-wide sweep (three live instances found and fixed in the prior
commit; no others found). CI test-scope selection updated so an edit to
either rule or to scripts/run-tests.cjs re-runs the right suites.

* fix(#4244): no-unbounded-dirname-walk must analyze a single-condition loop test too

checkWhile bailed out early unless node.test was a LogicalExpression,
so a single-condition loop -- while (cur !== root) { cur = dirname(cur); } --
was silently skipped and never reported. That is the EXACT minimal
shape of the original #4020/#4220 bug, and it is literally the shape
used by this rule's own shipped RuleTester fixtures (the "equality-only
bound" invalid cases), which were failing (0 errors reported, 1
expected) until this fix -- confirmed by running RuleTester directly
against both fixtures, not just via a passing test-runner exit code.

The conjunct-collection helper already handled a non-LogicalExpression
test correctly (it pushes a single node as the sole conjunct); only the
early-return gate needed to stop requiring a compound && / || test.

Verified: RuleTester run directly against both previously-broken
fixtures plus two new sanity cases (a guarded single-condition loop
stays valid; an unrelated single-condition loop stays silent), and a
fresh `npx eslint .` across the whole repo remains clean (no other
single-condition dirname-walk shape exists in the tree).

* fix(#4244): require-full-tmpdir-triad must recognize a destructured child_process call

isSpawnLikeCallee only recognized a MemberExpression callee
(child_process.spawnSync(...)) or a bare identifier in
ENV_LOCAL_HELPER_NAMES (runNode). A destructured import called bare --
const { spawnSync } = require('child_process'); spawnSync(...) -- has an
Identifier callee named "spawnSync", which matched neither branch, so
the whole env-literal check was skipped. gsd-test caught this: both
"invalid: child_process.spawnSync with TMPDIR-only env" cases in
tests/require-full-tmpdir-triad.rule.test.cjs were failing (0 errors
reported, 1 expected).

Widened the bare-identifier branch to also match any of the known
ENV_CHILD_PROCESS_METHODS names, matched by name only -- the same
lightweight convention this repo's other eslint-rules/*.cjs use (e.g.
no-hardcoded-tmp.cjs's isFsMethodCall), not full import data-flow
tracing.

Verified: RuleTester run directly against all 11 cases in
tests/require-full-tmpdir-triad.rule.test.cjs (not just the two that
were failing), all pass; a fresh npx eslint . and npm run lint:ci
across the whole repo remain clean.

* fix(#4244): correct a stale escape-hatch reference in a test comment

The comment on the "length comparison against another expression's
length" case referenced a "// allow-dirname-walk marker" that doesn't
exist -- the rule has zero comment-based escape hatches by design
(ADR-1703), and an earlier draft's marker mechanism was removed before
this branch's first commit. Spec-axis review caught the stale
reference. No behavior change; comment-only.

* chore(#4244): backfill changeset PR number (pr:0 -> pr:4246)

---------

Co-authored-by: sim <sim@local>
2026-09-03 14:14:09 -04:00

634 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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',
// #3833: the reusable gate every `pull_request` compute lane depends on —
// marking it inert would let a change to the gate itself ship without ever
// running the full matrix it is responsible for enforcing.
'pr-mergeable-preflight.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',
// #4220: the run-scoped temp root + computeSweepProtectSet ancestor-walk
// termination coverage — was previously not re-selected by an edit to
// scripts/run-tests.cjs, the exact file that shipped the #4020 hang.
'tests/run-tests-temp-root.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',
// #4244 (origin #4020/#4220 Windows CI hang) — see ADR-1703 amendment.
'tests/require-full-tmpdir-triad.rule.test.cjs',
'tests/no-unbounded-dirname-walk.rule.test.cjs',
],
},
{
// ADR-3212 Phase 4 (#3415): no-unbounded-quantifier and the shared
// readfilesync-trace helper it uses (also now imported by
// no-crlf-fragile-split). NOT part of the ADR-1703 portability family above
// — kept as its own bucket so this rule's tests re-run without pulling in
// the ADR-1703 disable-ban / vocab-drift suites it is not governed by.
name: 'no-unbounded-quantifier + readfilesync-trace (ADR-3212 Phase 4)',
match: path => [
'eslint-rules/no-unbounded-quantifier.cjs',
'eslint-rules/lib/readfilesync-trace.cjs',
'eslint-rules/no-crlf-fragile-split.cjs',
].includes(path),
tests: [
'tests/no-unbounded-quantifier.rule.test.cjs',
'tests/readfilesync-trace-parity.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();
}
/**
* Every PROTECTED_WORKFLOWS member that does not exist on disk.
* The name list and the real filenames are two surfaces over one fact
* (#3833): without this, renaming or deleting a gating workflow silently
* un-protects it and CI stays green while the protection is gone. Same
* shape and rationale as missingRuleTestFiles() above.
*/
function missingProtectedWorkflows(names = PROTECTED_WORKFLOWS) {
return [...names]
.filter(name => !existsSync(path.join(__dirname, '..', '.github', 'workflows', name)))
.sort();
}
/**
* Shared module-load assertion for the two "this list names something that
* does not exist on disk" guards above. Both are silent-coverage-hole guards:
* a name that no longer resolves stops selecting/protecting anything while CI
* stays green, so both must fail loudly at load rather than at test time.
*/
function assertNoneMissing(missing, summary, issueRef) {
if (missing.length === 0) return;
throw new Error(`ci-test-scope: ${summary} (${issueRef}):\n ${missing.join('\n ')}`);
}
// Fail loudly at module load — 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.
assertNoneMissing(
missingRuleTestFiles(RULES),
'RULES reference test file(s) that do not exist on disk (silent coverage hole)',
'see #2758',
);
// A PROTECTED_WORKFLOWS entry naming a file that does not exist means the
// workflow was renamed or deleted without updating this list, silently
// un-protecting it while CI stays green.
assertNoneMissing(
missingProtectedWorkflows(PROTECTED_WORKFLOWS),
'PROTECTED_WORKFLOWS reference workflow file(s) that do not exist on disk (silent protection hole)',
'see #3833',
);
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 the two ubuntu-24 lanes via targeted_tests; the
// residual macOS / windows 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, PROTECTED_WORKFLOWS, INERT_WORKFLOWS, missingProtectedWorkflows };