* chore(#4654): add local/no-unconfined-path-join and drain it to zero Phase 4 of epic #4636 — the ratchet, and the phase that makes the epic hold. THE MEASUREMENT THAT RESHAPED THE PHASE. An AST census (the repo's own parser, not grep) found what the epic never enumerated: ADR-4650 named seven containment implementations; `src/` alone held roughly 24 more hand-rolled gates across ~13 files, several guarding a write or an `fs.rmSync`. Two verified by reading rather than pattern-matching — `research-store.cts` comments its own as "ensure the resolved file path stays inside the store dir" immediately before a write, and `capability-lifecycle.cts` gates `fs.rmSync` with one. So the epic's Done-when "one containment predicate, used at every site" was FALSE when Phase 3 reported it satisfied. It is true now: the rule is clean across src/, scripts/, gsd-core/bin/ and hooks/ with an EMPTY allowlist. WHY NOT THE RULE THE ISSUE PROPOSED. #4654 proposed flagging `path.join` whose first argument is a managed root and whose later arguments derive from argv. That is a taint analysis over 2046 call sites, in ESLint, without type information; "derives from argv" is not locally decidable. Any approximation either floods or is trivially evaded, and a rule that fires on hundreds of correct sites earns an allowlist of hundreds — the opposite of a ratchet. What is actually duplicated is the COMPARISON, not the join, and that has one recognizable shape. Arm 1 X.startsWith(Y + sep) the hand-rolled containment idiom Arm 2 a containment predicate called as a bare statement, answer discarded Arm 2 is the issue's "asserts the result was narrowed, not merely that a helper was called". Its example `validatePath(x, root).resolved` is already structurally impossible — Phase 3 un-exported `validatePath` — so the remaining expressible failure is ignoring the answer, which is the defect that recurred five times in this epic. The census found exactly one live instance (`milestone.cts:1643`); it now returns the proven `ContainedPath` so consumers stop re-deriving the path the comment above it was extracted to stop them re-deriving. The rule deliberately does NOT try to catch validate-one-path-use-another where the answer is used but a different variable flows onward. That needs flow analysis; the branded `ContainedPath` from Phase 3 is the defense there, and the two are complementary. PER-SITE FAMILY CHOICE, NOT A DEFAULT. Phase 3's lesson binds: collapsing a lexical site onto the realpath family broke four tests and was caught only by the matrix. Every migrated site was triaged individually. The six installer-migrations tree-walks and the six capability-lifecycle gates take the LEXICAL family because their operands are already realpath-resolved and they deliberately treat the final component as a link; boundary sites take realpath. TWO SITES WITH AN INVERTED CONTRACT, which a mechanical swap would have broken. `installer-migrations.cts:127` and `runtime-artifact-install-plan.cts:144` REJECT `target === root` by contract, while the canonical comparison ACCEPTS it. Swapped naively, a migration could `rmdir` the user's config root and a third-party descriptor could write at configHome itself. Both keep `=== root` as an explicit additional arm alongside the predicate call — the predicate decides containment, the call site keeps its own extra condition (ADR-4650 decision 6). ONE DUPLICATE DELETED OUTRIGHT: `planning-inspect.cts`'s `isWithinRoot` was byte-identical to `isContainedIn` and said so in its own docstring. `isContainedIn` is now exported for callers that have already resolved both operands and need only the comparison, with a doc note that a caller which has NOT resolved them must use a full predicate instead. THE MARKER, AND WHY IT IS NOT THE ALLOWLIST. Nine sites are justified holdouts and carry `// allow-handrolled-containment: <reason>` with a mandatory, reviewable reason. Two justifications: (a) not a containment decision — an ancestor-walk loop condition, sub-repo grouping, worktree identity matching, declared-path coverage; (b) it IS containment but the canonical predicate is unreachable — `capability-validator.cjs` is a committed pre-build `.cjs` and the compiled `security.cjs` is untracked build output, so requiring it would break a fresh clone. `scripts/lib/drift-scan.cjs` runs under `lint:ci` with the same exposure. The marker was renamed from `allow-lexical-prefix-match` mid-phase because that name asserted only (a) and would have stated something false at the (b) sites. A marker suppresses BEFORE the violation counter increments, so a file whose every occurrence is marked still reports `staleAllowlistEntry` — otherwise a drained entry lingers and silently re-permits the site later. DEMONSTRATED RED, per #4654: a hand-rolled copy reintroduced into a real `src/` file made `npm run lint` fail with the rule's full guidance message; removing it returned the tree to clean. Both halves recorded — red alone proves nothing, since a rule red for an unrelated reason looks identical. DISCLOSED: `defaultRequireFromInstallRoot` (gsd-tools.cjs) previously carried two distinct rejection messages and two manual realpath calls; routing it through `tryWithinRoot` collapses them to one message, and a missing module now surfaces as MODULE_NOT_FOUND rather than ENOENT. No test asserts either message. The security property is preserved and slightly strengthened — the candidate is realpathed and containment re-checked, and the dangling-symlink oracle closure comes along with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(#4654): record the containment ratchet in CONTEXT.md and the security model Both entries previously described the seam without the thing that keeps it a seam. They now state what the rule bans, and — more usefully for whoever reads this next — what it deliberately does NOT attempt: deciding per path.join call whether an argument came from user input. That question is not locally decidable, and an approximation across ~2000 join sites would earn an exemption list of hundreds, which is the opposite of a ratchet. Also records the marker's two legitimate justifications and that its reason is mandatory, so the escape stays reviewable rather than becoming a mute button. Glossary gate 270 refs exit 0; install-tree goldens and CONTEXT-INDEX.json regenerated and confirmed byte-identical rather than assumed — which also confirms eslint-rules/ is not a shipped path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#4654): close review findings and the two matrix failures MATRIX FAILURE 1 — a collapsed message broke a negative-proof test, and my evidence for collapsing it was wrong. I searched tests/ for the literal string "resolves outside its install root", found nothing, and reported that no test asserted it. The test matches a REGEX SUBSTRING, /outside its install root/, so the literal search missed it. What broke was "NEGATIVE PROOF: a symlinked module pointing OUTSIDE the install root is not loaded" — the test guarding the exact property I claimed was preserved. defaultRequireFromInstallRoot now does both checks again with both messages byte-identical, each routed through the canonical predicate, which is better than the original since that hand-rolled both comparisons. MATRIX FAILURE 2 — shipped migrations are checksum-locked, and a marker cannot serve there. migrationChecksum hashes plan.toString(), which INCLUDES comments, so a suppression marker inside a plan body drifts the baseline exactly as an edit does. Measured: with markers in place, two of the four still differed from their committed checksums. The four shipped bodies are now byte-identical to next, and the rule's config excludes those four paths BY NAME rather than by a directory wildcard, so a NEW migration is still covered. Six containment comparisons stay un-ratcheted there; that gap is recorded in the rule's Known gaps, in CONTEXT.md and in the security model rather than left implicit. Justification (c) is removed from the marker's documented reasons, because a marker was proven unable to express it. ADVERSARIAL REVIEW — the sharpest finding was that the rule banned the CORRECT shape while permitting the incorrect one: startsWith(root) with no separator is the genuinely unsafe form, since it accepts a sibling such as root-evil, and my own test blessed it as valid. Flagging every bare startsWith would swamp the rule, so that stays a STATED gap rather than a silent one. Closed for real: the template-literal spelling, which the census never saw because it only inspected plus-concatenation — that surfaced TWELVE more sites, now triaged and migrated. A separator reached through a const alias is now resolved via scope analysis. And isContainedIn, exported in Phase 3, was missing from the discarded-result set, so a bare no-op call went unflagged on the one function the epic funnels through. SECURITY REVIEW — the marker could over-suppress two ways: a block comment worked identically to a line comment, and one marker silently covered every violation sharing its line. It now requires a Line comment positioned after the flagged node ends, so it anchors to the node it trails. Four sites had dropped an unreachable-but-deliberate equality rejection against the root; each is restored as the call site's own arm. eslint.config.mjs still documented the OLD marker token, which my rename missed — it would have sent the next author in circles. A FALSE GREEN, recorded because it nearly stuck: lint:ci reported exit 0 from a stale eslint cache while twelve real violations existed. Every lint check here now clears the cache first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#4654): anchor a suppression marker to the violation it actually trails The matrix caught this; my own test caught it, on its first execution. The case "two violations on one line: trailing marker suppresses only the one it trails" expected 1 error and got 0 — both were suppressed. ROOT CAUSE: the anchoring accepted any Line comment on the node's line whose range started at or after the node's end. A trailing marker at the END of a line sits after EVERY node on that line, so that condition held for all of them. "After the node" does not identify WHICH node the marker trails. The fix reads as correct and is not. FIX: deferred reporting. Violations accumulate during traversal instead of being reported immediately; at Program:exit each marker claims exactly ONE pending violation — the one on its line whose end is nearest before the marker begins — and every unclaimed violation is then counted and reported. One marker, one suppression. An earlier violation sharing the line is still reported, which is the property the security review asked for and the previous attempt only appeared to deliver. The counter now increments at flush time rather than during traversal, so a suppressed occurrence still does not keep an allowlist entry alive. AND A TOOL THAT SHOULD HAVE EXISTED BEFORE THE FIRST MATRIX RUN. `node --test` is hard-blocked here, so this rule's test file could only ever be executed on the remote matrix — which is why a broken anchoring shipped into a run. ESLint's programmatic Linter API is not a test runner, and exercising the rule through it verifies every case locally in seconds. All 24 now pass locally, including the two-on-one-line case that failed remotely. That loop should have been built before the rule was first sent to the matrix rather than after it failed twice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#4654): backfill PR 4674 into the changeset and complete 70-docs.json The phase gate requires enablementSequence and the Diataxis quadrants; 70-docs now carries both, with the how-to quadrant skipped for a stated reason rather than an empty field. The audience for this deliverable is a contributor who trips the rule, and the task-oriented guidance reaches them in the ESLint message itself — which names the correct predicate, says how to choose between the realpath and lexical families, cites the Phase 3 regression caused by choosing wrong, and gives the marker syntax. A docs/how-to page would be a second, driftable copy read by nobody at the moment of failure. enablementSequence is recorded as what it actually is: a VERIFICATION sequence, not an enablement one. The rule is never off, so there is no off-to-on transition to describe. scripts/lint-docs-required.cjs now passes (ok_docs_updated) — it could not evaluate against the mandated pr:0 placeholder. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
555 lines
19 KiB
JavaScript
555 lines
19 KiB
JavaScript
'use strict';
|
|
|
|
const { execFileSync } = require('node:child_process');
|
|
const { readdirSync, readFileSync, existsSync } = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const { ExitError } = require('./lib/cli-exit.cjs');
|
|
const { suiteOf } = require('./run-tests.cjs');
|
|
|
|
const CRITICAL_PATHS = [
|
|
'.github/workflows/',
|
|
'package.json',
|
|
'package-lock.json',
|
|
'scripts/run-tests.cjs',
|
|
'scripts/affected-tests-lib.cjs',
|
|
'scripts/run-affected-tests.cjs',
|
|
];
|
|
|
|
// Suites that are push-only. PRs must never select or run these.
|
|
const PR_EXCLUDED_SUITES = new Set(['install', 'slow']);
|
|
|
|
// Suites run on every PR cell when the critical-path fallback fires.
|
|
const PR_FULL_SUITES = ['unit', 'integration', 'security'];
|
|
|
|
// Source trees to walk when building the forward graph (in addition to tests/).
|
|
// Relative to repoRoot. We walk these to discover SUT-internal requires so that
|
|
// a change to a deep helper propagates through re-export chains to tests.
|
|
const SOURCE_TREES = [
|
|
'gsd-core/bin/lib',
|
|
'bin/lib',
|
|
'bin',
|
|
'scripts',
|
|
'commands',
|
|
'hooks',
|
|
'agents',
|
|
'eslint-rules',
|
|
];
|
|
|
|
function toPosixPath(input) {
|
|
return input.split(path.sep).join('/');
|
|
}
|
|
|
|
function parseRelativeSpecifiers(source) {
|
|
const specifiers = [];
|
|
const requireRe = /require\((['"])(.+?)\1\)/g;
|
|
const importFromRe = /from\s+(['"])(.+?)\1/g;
|
|
let match;
|
|
|
|
while ((match = requireRe.exec(source)) !== null) {
|
|
specifiers.push(match[2]);
|
|
}
|
|
while ((match = importFromRe.exec(source)) !== null) {
|
|
specifiers.push(match[2]);
|
|
}
|
|
|
|
return specifiers.filter(specifier => specifier.startsWith('.'));
|
|
}
|
|
|
|
// Extended candidate list now includes .ts/.cts/.mts/.json as well as the
|
|
// standard .js/.cjs/.mjs and index variants.
|
|
function resolveRelativeDependency(repoRoot, fromAbs, specifier) {
|
|
const base = path.resolve(path.dirname(fromAbs), specifier);
|
|
const candidates = [
|
|
base,
|
|
`${base}.js`,
|
|
`${base}.cjs`,
|
|
`${base}.mjs`,
|
|
`${base}.ts`,
|
|
`${base}.cts`,
|
|
`${base}.mts`,
|
|
`${base}.json`,
|
|
path.join(base, 'index.js'),
|
|
path.join(base, 'index.cjs'),
|
|
path.join(base, 'index.mjs'),
|
|
path.join(base, 'index.ts'),
|
|
];
|
|
|
|
for (const candidate of candidates) {
|
|
if (existsSync(candidate)) {
|
|
return toPosixPath(path.relative(repoRoot, candidate));
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Source-file walker
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Collect all .cjs / .mjs / .js / .ts / .cts / .mts / .json files under a
|
|
* directory tree, returned as repo-relative POSIX paths. Silently skips
|
|
* trees that don't exist.
|
|
*/
|
|
function walkTree(repoRoot, relDir) {
|
|
const absDir = path.join(repoRoot, relDir);
|
|
if (!existsSync(absDir)) return [];
|
|
|
|
const results = [];
|
|
const queue = [absDir];
|
|
|
|
while (queue.length > 0) {
|
|
const cur = queue.shift();
|
|
let entries;
|
|
try {
|
|
entries = readdirSync(cur, { withFileTypes: true });
|
|
} catch {
|
|
continue;
|
|
}
|
|
for (const entry of entries) {
|
|
const abs = path.join(cur, entry.name);
|
|
if (entry.isDirectory()) {
|
|
// Skip node_modules
|
|
if (entry.name === 'node_modules') continue;
|
|
queue.push(abs);
|
|
} else if (entry.isFile()) {
|
|
const ext = path.extname(entry.name);
|
|
if (['.js', '.cjs', '.mjs', '.ts', '.cts', '.mts', '.json'].includes(ext)) {
|
|
results.push(toPosixPath(path.relative(repoRoot, abs)));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Forward graph: Map<fileRel, Set<depRel>>
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Build a forward dependency graph over test files PLUS source trees.
|
|
* For each file: read, parseRelativeSpecifiers, resolve each specifier.
|
|
* Returns Map<fileRel, Set<depRel>>.
|
|
*/
|
|
function buildForwardGraph(repoRoot, testFiles) {
|
|
// Collect all files to index: test files + source files
|
|
const sourceFiles = [];
|
|
for (const tree of SOURCE_TREES) {
|
|
for (const f of walkTree(repoRoot, tree)) {
|
|
sourceFiles.push(f);
|
|
}
|
|
}
|
|
|
|
const allFiles = [...new Set([...testFiles, ...sourceFiles])];
|
|
const forward = new Map();
|
|
|
|
for (const fileRel of allFiles) {
|
|
const absFile = path.join(repoRoot, fileRel);
|
|
let source;
|
|
try {
|
|
source = readFileSync(absFile, 'utf8');
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
const specs = parseRelativeSpecifiers(source);
|
|
const deps = new Set();
|
|
|
|
for (const specifier of specs) {
|
|
const dep = resolveRelativeDependency(repoRoot, absFile, specifier);
|
|
if (dep) deps.add(dep);
|
|
}
|
|
|
|
forward.set(fileRel, deps);
|
|
}
|
|
|
|
return forward;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Reverse-transitive index: Map<depRel, Set<testRel>>
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Build the TRANSITIVE reverse index: Map<depRel, Set<testRel>>.
|
|
*
|
|
* Algorithm:
|
|
* 1. Build forward graph over all test + source files.
|
|
* 2. Invert to direct reverse edges: Map<depRel, Set<dependentRel>>.
|
|
* 3. For each test file, BFS backwards through all direct reverse edges
|
|
* to find every ancestor. Map each ancestor → the test.
|
|
*
|
|
* Cycle safety: visited set per BFS — each node is enqueued at most once.
|
|
*
|
|
* @param {string} repoRoot
|
|
* @param {string[]} testFiles repo-relative posix paths (e.g. ['tests/foo.test.cjs'])
|
|
* @returns {Map<string, Set<string>>}
|
|
*/
|
|
function buildTransitiveReverseIndex(repoRoot, testFiles) {
|
|
const forward = buildForwardGraph(repoRoot, testFiles);
|
|
|
|
// Build direct reverse edges: dep → Set of files that directly require dep
|
|
const directReverse = new Map();
|
|
for (const [fileRel, deps] of forward) {
|
|
for (const dep of deps) {
|
|
if (!directReverse.has(dep)) directReverse.set(dep, new Set());
|
|
directReverse.get(dep).add(fileRel);
|
|
}
|
|
}
|
|
|
|
// For each test file, BFS through direct reverse edges to collect all
|
|
// ancestors, then invert: ancestor → test.
|
|
// We do this test-file-first (not dep-first) so we know which test reached
|
|
// each ancestor.
|
|
const transitiveReverse = new Map();
|
|
|
|
for (const testFile of testFiles) {
|
|
// BFS from testFile following reverse edges (files that point TO testFile,
|
|
// then files that point to THOSE files, etc.).
|
|
// We want: "if X changed, would that eventually pull in testFile?"
|
|
// So we walk the FORWARD graph starting from testFile to find all deps,
|
|
// then any of those deps maps back to testFile.
|
|
|
|
// Actually simpler: for each test we do a forward BFS to find ALL files
|
|
// the test transitively depends on. Then we record testFile as a
|
|
// dependent of each of those files.
|
|
const visited = new Set();
|
|
visited.add(testFile);
|
|
const queue = [testFile];
|
|
|
|
while (queue.length > 0) {
|
|
const current = queue.shift();
|
|
const deps = forward.get(current);
|
|
if (!deps) continue;
|
|
for (const dep of deps) {
|
|
if (visited.has(dep)) continue;
|
|
visited.add(dep);
|
|
queue.push(dep);
|
|
}
|
|
}
|
|
|
|
// Every file in `visited` (except testFile itself) is a transitive dep.
|
|
// Record testFile as a dependent of each.
|
|
for (const dep of visited) {
|
|
if (dep === testFile) continue;
|
|
if (!transitiveReverse.has(dep)) transitiveReverse.set(dep, new Set());
|
|
transitiveReverse.get(dep).add(testFile);
|
|
}
|
|
}
|
|
|
|
return transitiveReverse;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Legacy shim — kept so that runAffectedTests can call buildTransitiveReverseIndex
|
|
// and existing call sites that still call buildReverseIndex still work.
|
|
// ---------------------------------------------------------------------------
|
|
function buildReverseIndex(repoRoot, testFiles) {
|
|
return buildTransitiveReverseIndex(repoRoot, testFiles);
|
|
}
|
|
|
|
function shouldRunFullSuite(changedFiles) {
|
|
return changedFiles.some(file =>
|
|
CRITICAL_PATHS.some(critical => file === critical || file.startsWith(critical)),
|
|
);
|
|
}
|
|
|
|
function listTestFiles(repoRoot) {
|
|
const results = [];
|
|
function walk(dir, relBase) {
|
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
if (entry.isDirectory()) {
|
|
if (entry.name === 'node_modules') continue;
|
|
const nextRel = relBase ? `${relBase}/${entry.name}` : entry.name;
|
|
walk(path.join(dir, entry.name), nextRel);
|
|
} else if (entry.name.endsWith('.test.cjs')) {
|
|
const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
|
|
results.push(`tests/${rel}`);
|
|
}
|
|
}
|
|
}
|
|
walk(path.join(repoRoot, 'tests'), '');
|
|
return results.sort();
|
|
}
|
|
|
|
/**
|
|
* Select the affected tests given a set of changed files and a reverse index.
|
|
*
|
|
* Options:
|
|
* detectWiden {boolean} — when true, attach `._widenRequired = true` to the
|
|
* returned array when a changed source file has zero transitive test
|
|
* dependents. The caller (runAffectedTests) uses this to widen to unit/all.
|
|
*
|
|
* The returned array is sorted and may have `._widenRequired` attached.
|
|
*/
|
|
function pickAffectedTests(changedFiles, allTests, reverseIndex, options = {}) {
|
|
const { detectWiden = false } = options;
|
|
const selected = new Set();
|
|
let widenRequired = false;
|
|
|
|
// Build a fast lookup of currently-existing test files (from readdirSync — deleted files absent).
|
|
const allTestsSet = new Set(allTests);
|
|
|
|
// (a) directly-changed test files + (b) transitive test dependents
|
|
// Deleted test files are filtered out — they no longer exist and cannot be run.
|
|
// A deleted test file also must NOT trigger widen (the test is simply gone).
|
|
for (const file of changedFiles) {
|
|
if (file.startsWith('tests/') && file.endsWith('.test.cjs')) {
|
|
// Only select if the test file still exists (i.e. is present in allTests from readdirSync).
|
|
if (allTestsSet.has(file)) {
|
|
selected.add(file);
|
|
}
|
|
// Deleted test file — do not add to selected; do not look up reverse index.
|
|
} else {
|
|
const dependents = reverseIndex.get(file);
|
|
if (dependents) {
|
|
for (const testFile of dependents) selected.add(testFile);
|
|
}
|
|
}
|
|
}
|
|
|
|
// (c) stem heuristic — kept as secondary mechanism
|
|
for (const file of changedFiles) {
|
|
const stem = path.basename(file).replace(/\.[^.]+$/, '').toLowerCase();
|
|
if (!stem) continue;
|
|
for (const testFile of allTests) {
|
|
if (testFile.toLowerCase().includes(stem)) selected.add(testFile);
|
|
}
|
|
}
|
|
|
|
// Widen backstop: if a changed file is a non-test, non-CRITICAL_PATH source file
|
|
// (recognised extension) under a SOURCE_TREE, AND it is either deleted (no longer
|
|
// on disk — so never in the forward graph and has no static dependents) OR it
|
|
// exists with ZERO transitive test dependents — signal a widen.
|
|
// NOTE: we deliberately do NOT skip deleted files here; a deleted source file's
|
|
// absence from the forward graph means dependents===undefined, which is the same
|
|
// as zero static dependents, and is itself the widen trigger.
|
|
if (detectWiden) {
|
|
for (const file of changedFiles) {
|
|
// Only care about source files, not test files or docs
|
|
if (file.startsWith('tests/')) continue;
|
|
if (shouldRunFullSuite([file])) continue; // critical path already triggers full suite
|
|
// Check: is this a source file (has a recognised extension)?
|
|
const ext = path.extname(file);
|
|
const isSourceFile = ['.js', '.cjs', '.mjs', '.ts', '.cts', '.mts', '.json'].includes(ext);
|
|
if (!isSourceFile) continue;
|
|
// Check: is this file under a recognised source tree?
|
|
const isUnderSourceTree = SOURCE_TREES.some(
|
|
tree => file === tree || file.startsWith(tree + '/'), // allow-handrolled-containment: test-selection path filtering, not a safety decision
|
|
);
|
|
if (!isUnderSourceTree) continue;
|
|
// Does it have any test dependents?
|
|
// A deleted file will have undefined here (not in the graph) — that is
|
|
// treated as zero static dependents and triggers widen conservatively.
|
|
const dependents = reverseIndex.get(file);
|
|
const hasStaticDependents = dependents && dependents.size > 0;
|
|
if (!hasStaticDependents) {
|
|
widenRequired = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Drop any file whose suite is push-only. This is the single chokepoint —
|
|
// it catches direct-change, reverse-index, AND stem-match selections.
|
|
for (const file of selected) {
|
|
const suite = suiteOf(path.basename(file));
|
|
if (PR_EXCLUDED_SUITES.has(suite)) selected.delete(file);
|
|
}
|
|
|
|
// When nothing maps, return an empty array. The caller decides the fallback.
|
|
const result = [...selected].sort();
|
|
if (widenRequired) result._widenRequired = true;
|
|
return result;
|
|
}
|
|
|
|
function changedFilesSinceBase(repoRoot, baseRef) {
|
|
const out = execFileSync(
|
|
'git',
|
|
['diff', '--name-only', '--no-renames', '--diff-filter=ACMRD', `${baseRef}...HEAD`],
|
|
{ cwd: repoRoot, encoding: 'utf8' },
|
|
).trim();
|
|
if (!out) return [];
|
|
return out.split('\n').map(line => line.trim()).filter(Boolean);
|
|
}
|
|
|
|
function runNodeTestFiles(repoRoot, files) {
|
|
const defaultConcurrency = process.platform === 'win32' ? 2 : 4;
|
|
const concurrency = process.env.TEST_CONCURRENCY
|
|
? `--test-concurrency=${process.env.TEST_CONCURRENCY}`
|
|
: `--test-concurrency=${defaultConcurrency}`;
|
|
const absoluteFiles = files.map(file => path.join(repoRoot, file));
|
|
|
|
// Keep chunks bounded for Windows CreateProcess command-length limits.
|
|
const maxChars = process.env.RUN_TESTS_MAX_CMDLINE_CHARS
|
|
? Number(process.env.RUN_TESTS_MAX_CMDLINE_CHARS)
|
|
: 28000;
|
|
const fixed = process.execPath.length + '--test'.length + concurrency.length + 8;
|
|
const chunks = [];
|
|
let current = [];
|
|
let currentLen = fixed;
|
|
|
|
for (const file of absoluteFiles) {
|
|
const add = file.length + 1;
|
|
if (current.length > 0 && currentLen + add > maxChars) {
|
|
chunks.push(current);
|
|
current = [];
|
|
currentLen = fixed;
|
|
}
|
|
current.push(file);
|
|
currentLen += add;
|
|
}
|
|
if (current.length > 0) chunks.push(current);
|
|
|
|
let firstFailure = 0;
|
|
for (let i = 0; i < chunks.length; i++) {
|
|
if (chunks.length > 1) {
|
|
console.error(`affected-tests: chunk ${i + 1}/${chunks.length} (${chunks[i].length} files)`);
|
|
}
|
|
try {
|
|
execFileSync(process.execPath, ['--test', concurrency, ...chunks[i]], {
|
|
cwd: repoRoot,
|
|
stdio: 'inherit',
|
|
env: { ...process.env },
|
|
});
|
|
} catch (error) {
|
|
const code = error.status || 1;
|
|
if (firstFailure === 0) firstFailure = code;
|
|
}
|
|
}
|
|
if (firstFailure !== 0) throw new ExitError(firstFailure);
|
|
}
|
|
|
|
function runSuite(repoRoot, suite) {
|
|
execFileSync(process.execPath, ['scripts/run-tests.cjs', '--suite', suite], {
|
|
cwd: repoRoot,
|
|
stdio: 'inherit',
|
|
env: { ...process.env },
|
|
});
|
|
}
|
|
|
|
function resolveBaseRef() {
|
|
if (process.env.GSD_AFFECTED_BASE) return process.env.GSD_AFFECTED_BASE;
|
|
if (process.env.GITHUB_BASE_REF) return `origin/${process.env.GITHUB_BASE_REF}`;
|
|
return 'origin/main';
|
|
}
|
|
|
|
/**
|
|
* Pure function: given the outputs of the selection phase, return a run plan
|
|
* describing what should be executed. No I/O is performed here.
|
|
*
|
|
* Return shapes:
|
|
* { mode: 'suite', suite: 'unit' } — no changed files
|
|
* { mode: 'suites', suites: PR_FULL_SUITES } — critical path triggered
|
|
* { mode: 'suites', suites: PR_FULL_SUITES } — widen required (orphan src file)
|
|
* { mode: 'suite', suite: 'unit' } — selection empty after widen=false
|
|
* { mode: 'files', files: string[] } — concrete selection, no widen
|
|
*
|
|
* Invariant: when widenRequired is true the executed set is ALWAYS ⊇ selected,
|
|
* because PR_FULL_SUITES covers every PR-eligible suite (unit + integration +
|
|
* security), so every concrete match that pickAffectedTests put into `selected`
|
|
* belongs to one of those suites and will be exercised by running all three.
|
|
*/
|
|
function resolveRunPlan({ changedFiles: _changedFiles, selected, widenRequired, criticalPath, noChanges }) {
|
|
if (noChanges) {
|
|
return { mode: 'suite', suite: 'unit' };
|
|
}
|
|
if (criticalPath) {
|
|
return { mode: 'suites', suites: PR_FULL_SUITES };
|
|
}
|
|
if (widenRequired) {
|
|
return { mode: 'suites', suites: PR_FULL_SUITES };
|
|
}
|
|
if (selected.length === 0) {
|
|
return { mode: 'suite', suite: 'unit' };
|
|
}
|
|
return { mode: 'files', files: selected };
|
|
}
|
|
|
|
function runAffectedTests(options = {}) {
|
|
const repoRoot = options.repoRoot || path.resolve(__dirname, '..');
|
|
const baseRef = options.baseRef || resolveBaseRef();
|
|
const changed = changedFilesSinceBase(repoRoot, baseRef);
|
|
|
|
if (changed.length === 0) {
|
|
console.error(`affected-tests: no changed files against ${baseRef}; running unit suite`);
|
|
runSuite(repoRoot, 'unit');
|
|
return;
|
|
}
|
|
|
|
if (shouldRunFullSuite(changed)) {
|
|
console.error('affected-tests: critical CI/runtime files changed; running PR suites (unit, integration, security)');
|
|
for (const suite of PR_FULL_SUITES) {
|
|
runSuite(repoRoot, suite);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const allTests = listTestFiles(repoRoot);
|
|
const reverseIndex = buildTransitiveReverseIndex(repoRoot, allTests);
|
|
const selected = pickAffectedTests(changed, allTests, reverseIndex, { detectWiden: true });
|
|
|
|
console.error(`affected-tests: base=${baseRef} changed=${changed.length} selected=${selected.length}`);
|
|
console.error(`affected-tests: ${selected.join(' ')}`);
|
|
|
|
const plan = resolveRunPlan({
|
|
changedFiles: changed,
|
|
selected,
|
|
widenRequired: selected._widenRequired === true,
|
|
criticalPath: false,
|
|
noChanges: false,
|
|
});
|
|
|
|
if (plan.mode === 'suites') {
|
|
// Widen backstop: a source file changed that has no static test dependents.
|
|
// Run all PR suites (unit + integration + security) — a strict superset of
|
|
// the concretely-selected tests — so no integration/security match is lost.
|
|
for (const file of changed) {
|
|
const ext = path.extname(file);
|
|
const isSourceFile = ['.js', '.cjs', '.mjs', '.ts', '.cts', '.mts', '.json'].includes(ext);
|
|
if (!isSourceFile || file.startsWith('tests/') || shouldRunFullSuite([file])) continue;
|
|
const dependents = reverseIndex.get(file);
|
|
if (!dependents || dependents.size === 0) {
|
|
console.error(
|
|
`affected-tests: ${file} has no static test dependents; widening to PR suites (unit+integration+security)`,
|
|
);
|
|
}
|
|
}
|
|
for (const suite of plan.suites) {
|
|
runSuite(repoRoot, suite);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (plan.mode === 'suite') {
|
|
console.error('affected-tests: no affected tests found; running unit suite as smoke');
|
|
runSuite(repoRoot, plan.suite);
|
|
return;
|
|
}
|
|
|
|
// plan.mode === 'files'
|
|
runNodeTestFiles(repoRoot, plan.files);
|
|
}
|
|
|
|
module.exports = {
|
|
CRITICAL_PATHS,
|
|
PR_EXCLUDED_SUITES,
|
|
PR_FULL_SUITES,
|
|
buildForwardGraph,
|
|
buildReverseIndex,
|
|
buildTransitiveReverseIndex,
|
|
listTestFiles,
|
|
parseRelativeSpecifiers,
|
|
pickAffectedTests,
|
|
resolveBaseRef,
|
|
resolveRelativeDependency,
|
|
resolveRunPlan,
|
|
shouldRunFullSuite,
|
|
toPosixPath,
|
|
runAffectedTests,
|
|
};
|