mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
* fix(pregate): recognise bats test files so lanes does not fail in bats repos (#3161) gstack-pregate never treated a .bats file as a test, so in a repository whose tests are bats files the lanes check failed every run with PREGATE_NO_LANE: a .gstack/pregate.json dependency pointing at tests/foo.bats selected nothing, a selection manifest printing .bats paths yielded 0 files, and a bats run that executed no tests could not trip the zero-run rule. Recognise .bats in the test-file pattern and in the manifest-output parser, and read the bats TAP plan and pretty summary (as a last resort, after every existing runner's summary) so a run that reports zero tests is a failure. Fixes #3161. Cherry-picked from #3177 (d355c116b). Conflicts resolved as in the author's own merges (b09476beb,444f4e768): lane-selection.ts keeps main's isTestFile-based manifest parser, and lanes.ts carries the bats summary on main's supervised runner. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(pregate): a nested .bats file is selected and run end to end (#3161) Reviewer test for #3177: touched itself with no declaration, and through a declared dependency; a failing nested bats run reports the bats counts. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(pregate): a wtree failure is WTREE_UNAVAILABLE, not an unresolvable base (#3197) The pregate consumers of the working-tree fingerprint (buildContext, withFingerprint, verifyPregate) move to lib/script-invocation.ts's {tree}|{error} form, and lib/regen.ts drops its string-returning wrapper. - A fingerprint failure was reported as "policy source unavailable: origin/<base> is not a resolvable commit" with git fetch as the fix. It is now WTREE_UNAVAILABLE: a launch failure lists each Windows bash candidate with its rejection reason and names GSTACK_GIT_BASH; a gstack-wtree exit keeps the repair it printed (set TMPDIR to a writable directory). - A fingerprint failing after a check is incomplete with that cause, not "working tree changed"; verify says the tree cannot be fingerprinted. - #3190's gstack-config read (test_backend) goes through runScriptSync, so Windows reaches it through Git Bash. The tripwire learns the bin-constant shape that let it through. - Under --json every context refusal is one envelope on stdout ({ok:false,status:'refused',code,detail,fix,docs}, exit 3); the run object carries ok and status too, and GSTACK_RESULT goes to stderr. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ci(pregate): gstack-pregate joins the Windows pregate job; pregate tests return to the Windows lane (#3197) - windows-pregate-e2e runs gstack-pregate run (preflight: regen, secrets through the gstack-redact bun bin, literals, guards) in both journeys, and asserts a missing GSTACK_GIT_BASH refuses with one WTREE_UNAVAILABLE --json envelope. - test/helpers/pregate-fixture.ts and restamp-fixture.ts runBin launch bins through lib/script-invocation.ts (bun bins as Bun argv, bash bins through Git Bash on Windows), which removes the "shebang via CreateProcess" reason for the pregate tests; they move from KNOWN_WINDOWS_INCOMPATIBLE to KNOWN_WINDOWS_SAFE. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * doctor: list rescue refs and warn about CLAUDE.md skill lists gstack-doctor's plain report gains two rows. 'rescue refs' lists each refs/gstack/rescue/* commit in the install checkout, newest first, with its date, a needs-manual-resolution label when the rescue held unmerged files, and the shell-quoted inspect and delete commands bin/gstack-ff-update's banner prints. 'CLAUDE.md' warns when a gstack section in ~/.claude/CLAUDE.md or the project's CLAUDE.md enumerates skills (#3169) and prints the short block to paste instead; docs/troubleshooting.md#claude-md-skill-list explains it. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * doctor: name the Windows bash gstack resolves and what chose it On Windows (OSTYPE msys/cygwin) gstack-doctor adds a 'windows bash' row from lib/script-invocation.ts resolveWindowsBash, run through the new thin bin/gstack-windows-bash.ts with the bun on PATH: the resolved bash and the override or discovery step that chose it, a warn when GSTACK_GIT_BASH and GSTACK_BASH differ, and a fail carrying the BASH_UNAVAILABLE line (every candidate with its rejection reason) when none qualifies. Tests inject the platform (GSTACK_DOCTOR_PLATFORM) and the discovery inputs (GSTACK_DOCTOR_BASH_PROBE) so the row runs on every OS. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * doctor --check: name the held-install recovery on Windows, not bin/gstack-upgrade bin/gstack-upgrade compares a pwd -P path (/c/...) with git's toplevel (C:/...) and refuses on every Git Bash install, so on Windows the revision row's upgrade fix (REVISION_BELOW_MINIMUM, the Capy-wrapper variant and REVISION_UNMET) names the held-install recovery forms from docs/troubleshooting.md#auto-update-held-install-recovery instead: the Git Bash form and the Windows PowerShell 5.1 form, each labelled by shell with the install path quoted for that shell. Other platforms keep bin/gstack-upgrade. A test pins both printed forms to the documented ones. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(skills): omit the plan-mode section on hosts without plan mode (#3168) generatePlanModeInfo now honours capabilities.planMode. Hosts that declare planMode:false (codex, agy, hermes, openclaw, and define-host's default: factory, kiro, slate, cursor, gbrain) no longer render the '## Plan Mode Safe Operations' and '## Skill Invocation During Plan Mode' preamble sections. Claude, copilot and opencode are unchanged. The codex, factory and agy ship goldens are regenerated. The clarity test that pinned the plan-mode entry-order prose on every host now pins it on planMode hosts and asserts its absence elsewhere (intentional contract change). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(skills): guard generated SKILL.md size at 100,000 chars on every host (#3199) A free test renders every host and fails when a generated SKILL.md exceeds 100,000 characters, naming host, skill, actual size, limit and the fix. The 89 current offenders are pinned at their exact size in test/fixtures/skill-md-size-allowlist.json: growth past a pin fails, and a shrink fails until the pin is lowered. The shared-preamble hoist is deferred (owner Garry, revisit 2026-11-15). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(pregate): keep #3177's bats tests beside the nested-directory test Moves #3177's bats describe block verbatim from test/pregate-selection.test.ts into test/pregate-bats-nested.test.ts, so pregate-selection.test.ts is unchanged on this branch and the in-flight field-list-2 pregate lane (capy/fl2-L6-pregate, which edits that file's imports and adds cases) merges without a conflict. No assertion changes. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * doctor --check: keep bin/gstack-upgrade as the Windows fix (path spelling fixed on main) Reverts37bac07f8. Plan item D named the held-install recovery forms on Windows only until bin/gstack-upgrade stopped refusing Git Bash installs. bin/gstack-upgrade:63-67 now compares with gstack_path_same from bin/gstack-path-spelling.sh, pinned by test/gstack-upgrade-path-spelling.test.ts, so bin/gstack-upgrade is the right fix on every platform again. * test(skills): measure the size-guard pins on v1.91.109.0 #3225 (merged into this branch) grew 53 pinned skills: 3 characters each from the Aside driver resolver, and about 340 more in plan-design-review and plan-devex-review from the #3219 summary sections. The pins are this PR's starting measurement, so they are taken on the merged base; no skill this PR renders grew. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ci(windows): only the pregate tests that pass on windows-latest join the lane windows-free-tests run 38984824731 (this branch at8ed6da402) ran the moved files. gstack-regen and the new pregate-wtree-refusal passed. pregate-guards, -remote, -regate, -invalidation, -selection and -bats-nested launched gstack-pregate fine but fail on Linux-host expectations: their fixture lanes are Linux workflow jobs, which a Windows host records as requires-remote obligations. They go back to KNOWN_WINDOWS_INCOMPATIBLE with that reason instead of "shebang via CreateProcess". Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * windows curation: keep CI run ids out of reason prose (pii.phone false positive) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * skill-md size pins: follow #3231's review-army line (+21 chars on five inlining hosts) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * doctor test: drop the reverted Windows upgrade-fix wording from item D's describe Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * skill-md size pins: follow #3226 (plan-devex-review +3324) and #3234 (autoplan +59) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * WTREE_UNAVAILABLE moves to lib/result-codes-release.ts (#3235 split; result-codes.ts stays at 796 of 800 lines); doctor fixture copies the release table; size pins follow #3235's review +1 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): fix wave PR 5 release notes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * v1.91.117.0 fix: fix wave PR 5 (pregate bats + WTREE_UNAVAILABLE, doctor rows, SKILL.md size guard, planMode hosts) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(qa): an invalid-input probe breaks only the rule it tests The paid gate's qa-functional-cli-report failed again on #3236 (run 39001107574): the agent probed the digits-only amount rule with -5, which the positivity check rejected first, so the planted parseInt defect stayed hidden and it reported no defect. The contract map's invalid-input row now asks for each input to break only the rule it tests, so another check cannot reject it first. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: harjothkhara <48686985+harjothkhara@users.noreply.github.com> Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
315 lines
19 KiB
TypeScript
315 lines
19 KiB
TypeScript
/**
|
|
* pregate/import-graph — which test files depend on which repo files (plan
|
|
* C8 `lanes`). The reverse graph unions four sources, each kept as provenance
|
|
* for `--explain`:
|
|
* 1. static imports and requires (Bun.Transpiler.scanImports);
|
|
* 2. repo-path string literals, including `join('a', 'b')` segment runs
|
|
* (a test that spawns `bin/gstack-doctor` names it in a string, never an
|
|
* import) and literal directories (`test/fixtures/x` → `x/**`);
|
|
* 3. the `bin/<name>` → `test/<name>*.test.*` convention (with and without
|
|
* the `gstack-` prefix);
|
|
* 4. explicit `.gstack/pregate.json` `dependencies` declarations.
|
|
* Uncertainty selects more tests, never fewer: a dependency on a directory
|
|
* selects every test under it; a touched file with no path to a test is the
|
|
* caller's `no lane` failure, not a pass. test/helpers/touchfile-closure.ts
|
|
* (helpers and fixtures only, a lower bound by its own words) is the precedent.
|
|
*/
|
|
import { spawnSync } from 'node:child_process';
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import { matchesAny } from '../ship-policy';
|
|
|
|
export const SOURCE_EXT = /\.(?:[cm]?[jt]sx?|py|rb|sh|bash|go|rs)$/;
|
|
export const TEST_FILE_RE = /(?:^|\/)(?:test|tests|spec|__tests__)\/.*\.(?:test|spec)\.[cm]?[jt]sx?$|(?:^|\/)[^/]*\.(?:test|spec)\.[cm]?[jt]sx?$|(?:^|\/)test_[^/]*\.py$|_test\.(?:py|go|rb)$|_spec\.rb$|(?:^|\/)[^/]*\.bats$/;
|
|
|
|
export interface Edge { from: string; via: 'import' | 'literal' | 'convention' | 'declared' | 'generated' }
|
|
export interface Graph {
|
|
files: Set<string>;
|
|
tests: Set<string>;
|
|
/** dependency → dependents with the kind of edge. */
|
|
reverse: Map<string, Edge[]>;
|
|
/** Test file → import specifiers that looked like repo paths but resolved nowhere (for the no-lane explanation). */
|
|
unresolved?: Map<string, string[]>;
|
|
/** tsconfig/jsconfig `extends` hops that were not followed (for the no-lane explanation). */
|
|
aliasNotes?: string[];
|
|
}
|
|
|
|
const scanner = new Bun.Transpiler({ loader: 'tsx' });
|
|
// `( ) [ ] +` appear in framework route paths: Next.js route groups `(app)`,
|
|
// dynamic segments `[id]` / `[[...slug]]`, SvelteKit `+page.svelte`. A literal
|
|
// that matches more text only gains an edge when it resolves to a repo file.
|
|
const QUOTED = /['"`]([A-Za-z0-9_@][A-Za-z0-9_./@()[\]+-]*)['"`]/g;
|
|
const JOIN = /\bjoin\(([^()]*)\)/g;
|
|
const RESOLVE_EXT = ['', '.ts', '.tsx', '.js', '.mjs', '.cjs', '/index.ts', '/index.js'];
|
|
|
|
export function isTestFile(rel: string): boolean { return TEST_FILE_RE.test(rel); }
|
|
|
|
export function listRepoFiles(repoRoot: string): string[] {
|
|
const r = spawnSync('git', ['ls-files', '-co', '--exclude-standard', '-z'], { cwd: repoRoot, encoding: 'utf8', timeout: 60_000, maxBuffer: 64 * 1024 * 1024 });
|
|
return (r.stdout ?? '').split('\0').filter(p => p && !p.startsWith('node_modules/'));
|
|
}
|
|
|
|
function normalize(p: string): string { return path.posix.normalize(p.replace(/\\/g, '/')).replace(/^\.\//, ''); }
|
|
|
|
/** One tsconfig/jsconfig `compilerOptions.paths` entry: `@/*` → [`./*`], already joined to its base. */
|
|
export interface PathAlias { prefix: string; suffix: string; wildcard: boolean; targets: string[] }
|
|
/** The effective `paths` of the root config, its `baseUrl` (repo-relative, or null) and what could not be read. */
|
|
export interface PathAliases { aliases: PathAlias[]; baseUrl: string | null; notes: string[] }
|
|
|
|
/** How many relative `extends` hops are followed from the root tsconfig/jsconfig. */
|
|
export const MAX_TSCONFIG_EXTENDS = 4;
|
|
|
|
/** JSONC → JSON: drop comments, and a comma whose next token closes an object or array, outside strings. */
|
|
function stripJsonc(text: string): string {
|
|
const skipTrivia = (from: number): number => {
|
|
let j = from;
|
|
while (j < text.length) {
|
|
if (/\s/.test(text[j]!)) { j++; continue; }
|
|
if (text.startsWith('//', j)) { const e = text.indexOf('\n', j); j = e < 0 ? text.length : e; continue; }
|
|
if (text.startsWith('/*', j)) { const e = text.indexOf('*/', j + 2); j = e < 0 ? text.length : e + 2; continue; }
|
|
break;
|
|
}
|
|
return j;
|
|
};
|
|
let out = '';
|
|
for (let i = 0; i < text.length; i++) {
|
|
const c = text[i]!;
|
|
if (c === '"') { const end = /"(?:[^"\\]|\\.)*"/y; end.lastIndex = i; const m = end.exec(text); const s = m ? m[0] : text.slice(i); out += s; i += s.length - 1; continue; }
|
|
if (c === '/' && text[i + 1] === '/') { while (i < text.length && text[i] !== '\n') i++; out += '\n'; continue; }
|
|
if (c === '/' && text[i + 1] === '*') { const e = text.indexOf('*/', i + 2); i = e < 0 ? text.length : e + 1; continue; }
|
|
if (c === ',' && (text[skipTrivia(i + 1)] === '}' || text[skipTrivia(i + 1)] === ']')) continue;
|
|
out += c;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
interface Located<T> { value: T; dir: string }
|
|
interface ConfigOptions { baseUrl?: Located<string>; paths?: Located<Record<string, unknown>> }
|
|
|
|
/** `baseUrl` and `paths` of one config after its relative `extends` chain (later entries and the config itself override). */
|
|
function effectiveOptions(repoRoot: string, rel: string, depth: number, seen: Set<string>, notes: string[]): ConfigOptions | null {
|
|
let json: { extends?: unknown; compilerOptions?: { baseUrl?: unknown; paths?: unknown } };
|
|
try { json = JSON.parse(stripJsonc(fs.readFileSync(path.join(repoRoot, rel), 'utf8'))) ?? {}; } catch { return null; }
|
|
const dir = path.posix.dirname(rel);
|
|
let options: ConfigOptions = {};
|
|
const parents = typeof json.extends === 'string' ? [json.extends] : Array.isArray(json.extends) ? json.extends.filter((e): e is string => typeof e === 'string') : [];
|
|
for (const parent of parents) {
|
|
if (!parent.startsWith('./') && !parent.startsWith('../')) { notes.push(`${rel} extends ${parent}: not a relative path, not followed`); continue; }
|
|
if (depth >= MAX_TSCONFIG_EXTENDS) { notes.push(`${rel} extends ${parent}: not followed (tsconfig extends depth limit ${MAX_TSCONFIG_EXTENDS})`); continue; }
|
|
let target = normalize(path.posix.join(dir, parent));
|
|
if (!target.endsWith('.json') && !fs.existsSync(path.join(repoRoot, target))) target += '.json';
|
|
if (target.startsWith('../') || seen.has(target)) { notes.push(`${rel} extends ${parent}: outside the repo or a cycle, not followed`); continue; }
|
|
const inherited = effectiveOptions(repoRoot, target, depth + 1, new Set([...seen, target]), notes);
|
|
if (inherited) options = { ...options, ...inherited };
|
|
else notes.push(`${rel} extends ${parent}: unreadable`);
|
|
}
|
|
const own = json.compilerOptions ?? {};
|
|
if (typeof own.baseUrl === 'string') options.baseUrl = { value: own.baseUrl, dir };
|
|
if (own.paths && typeof own.paths === 'object') options.paths = { value: own.paths as Record<string, unknown>, dir };
|
|
return options;
|
|
}
|
|
|
|
/**
|
|
* The root tsconfig.json (else jsconfig.json) `paths`, relative to the repo,
|
|
* following relative `extends` up to MAX_TSCONFIG_EXTENDS hops. Targets join
|
|
* `baseUrl` when one is set, else the directory of the config declaring
|
|
* `paths`. What is not followed is recorded in `notes` for the no-lane
|
|
* explanation; a missing alias leaves no edge (so `no lane`), never a wrong one.
|
|
*/
|
|
export function readPathAliases(repoRoot: string): PathAliases {
|
|
for (const name of ['tsconfig.json', 'jsconfig.json']) {
|
|
const notes: string[] = [];
|
|
const options = effectiveOptions(repoRoot, name, 0, new Set([name]), notes);
|
|
if (!options) continue;
|
|
const baseUrl = options.baseUrl ? normalize(path.posix.join(options.baseUrl.dir, options.baseUrl.value)) : null;
|
|
const base = baseUrl ?? options.paths?.dir ?? '.';
|
|
const aliases: PathAlias[] = [];
|
|
for (const [pattern, targets] of Object.entries(options.paths?.value ?? {})) {
|
|
if (!Array.isArray(targets)) continue;
|
|
const star = pattern.indexOf('*');
|
|
const resolved = targets.filter((t): t is string => typeof t === 'string').map(t => normalize(path.posix.join(base, t)));
|
|
aliases.push(star < 0 ? { prefix: pattern, suffix: '', wildcard: false, targets: resolved } : { prefix: pattern.slice(0, star), suffix: pattern.slice(star + 1), wildcard: true, targets: resolved });
|
|
}
|
|
return { aliases, baseUrl, notes };
|
|
}
|
|
return { aliases: [], baseUrl: null, notes: [] };
|
|
}
|
|
|
|
const NO_ALIASES: PathAliases = { aliases: [], baseUrl: null, notes: [] };
|
|
|
|
/**
|
|
* TypeScript's pattern choice: an exact pattern, else the matching wildcard
|
|
* with the longest prefix. `chosen` is that pattern's expanded targets in
|
|
* order; `others` are the other matching patterns' targets.
|
|
*/
|
|
function matchAliases(aliases: readonly PathAlias[], ref: string): { chosen: string[] | null; others: string[] } {
|
|
const expand = (a: PathAlias) => a.wildcard ? a.targets.map(t => normalize(t.replace('*', ref.slice(a.prefix.length, ref.length - a.suffix.length)))) : a.targets;
|
|
const matching = aliases.filter(a => a.wildcard ? ref.startsWith(a.prefix) && ref.endsWith(a.suffix) && ref.length >= a.prefix.length + a.suffix.length : ref === a.prefix);
|
|
if (matching.length === 0) return { chosen: null, others: [] };
|
|
const best = matching.find(a => !a.wildcard) ?? matching.reduce((a, b) => (b.prefix.length > a.prefix.length ? b : a));
|
|
return { chosen: expand(best), others: matching.filter(a => a !== best).flatMap(expand) };
|
|
}
|
|
|
|
/** One candidate repo path → the file it names (trying extensions), `dir/**`, a bare bin, or null. */
|
|
function resolveCandidate(files: Set<string>, dirs: Set<string>, c: string, literal: boolean): string | null {
|
|
if (c.startsWith('../') || c === '..' || c === '.' || c === '') return null;
|
|
for (const ext of RESOLVE_EXT) if (files.has(c + ext)) return c + ext;
|
|
// A directory literal needs two segments (`test/fixtures/x`): a bare word is a name, not a path.
|
|
if (dirs.has(c) && (!literal || c.includes('/'))) return `${c}/**`;
|
|
// A bare bin name in a test (spawned through PATH or a helper's bin dir) names bin/<name>.
|
|
if (literal && !c.includes('/') && files.has(`bin/${c}`)) return `bin/${c}`;
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* A module specifier or literal → the repo files it names. A relative ref
|
|
* and an alias whose chosen pattern has a target that exists name one file
|
|
* (targets tried in order). Otherwise no single target is established, so
|
|
* every plausible reading (other matching patterns, `baseUrl`, the repo
|
|
* root) contributes: uncertainty selects more tests, never fewer.
|
|
*/
|
|
function resolveRefs(files: Set<string>, dirs: Set<string>, fromFile: string, ref: string, literal = false, config: PathAliases = NO_ALIASES): string[] {
|
|
if (!ref || ref.startsWith('node:') || ref.startsWith('bun:')) return [];
|
|
if (ref.startsWith('.')) { const t = resolveCandidate(files, dirs, normalize(path.posix.join(path.posix.dirname(fromFile), ref)), literal); return t ? [t] : []; }
|
|
const { chosen, others } = matchAliases(config.aliases, ref);
|
|
for (const c of chosen ?? []) { const t = resolveCandidate(files, dirs, c, literal); if (t) return [t]; }
|
|
if (!chosen && others.length === 0 && ref.startsWith('@') && !ref.includes('/')) return [];
|
|
const plausible = [...others, ...(config.baseUrl ? [normalize(path.posix.join(config.baseUrl, ref))] : []), normalize(ref)];
|
|
return [...new Set(plausible.map(c => resolveCandidate(files, dirs, c, literal)).filter((t): t is string => t !== null))];
|
|
}
|
|
|
|
/** A specifier worth naming when it resolves nowhere: relative, aliased, or alias-shaped (`@/`, `~`, `#`); never a package. */
|
|
function pathLike(config: PathAliases, ref: string): boolean {
|
|
if (ref.startsWith('.') || /^(?:@\/|~|#)/.test(ref)) return true;
|
|
const { chosen, others } = matchAliases(config.aliases, ref);
|
|
return chosen !== null || others.length > 0;
|
|
}
|
|
|
|
function addEdge(graph: Graph, dependency: string, dependent: string, via: Edge['via']): void {
|
|
if (dependency === dependent) return;
|
|
const list = graph.reverse.get(dependency) ?? [];
|
|
if (!list.some(e => e.from === dependent && e.via === via)) { list.push({ from: dependent, via }); graph.reverse.set(dependency, list); }
|
|
}
|
|
|
|
/**
|
|
* Every dependency reference one source file makes: imports, requires, quoted
|
|
* repo paths and join() segment runs. Import specifiers that resolve nowhere
|
|
* and look like repo paths go to `unresolved` when given.
|
|
*/
|
|
export function referencesIn(files: Set<string>, dirs: Set<string>, rel: string, source: string, config: PathAliases = NO_ALIASES, unresolved?: string[]): Array<{ target: string; via: Edge['via'] }> {
|
|
const out: Array<{ target: string; via: Edge['via'] }> = [];
|
|
const importOf = (spec: string) => {
|
|
const targets = resolveRefs(files, dirs, rel, spec, false, config);
|
|
for (const target of targets) out.push({ target, via: 'import' });
|
|
if (targets.length === 0 && unresolved && pathLike(config, spec) && !unresolved.includes(spec)) unresolved.push(spec);
|
|
};
|
|
if (/\.[cm]?[jt]sx?$/.test(rel)) {
|
|
let imports: Array<{ path: string }> = [];
|
|
try { imports = scanner.scanImports(source); } catch { imports = []; }
|
|
for (const { path: spec } of imports) importOf(spec);
|
|
// The transpiler erases type-only imports; a types module still binds its importers' contracts.
|
|
for (const m of source.matchAll(/^\s*(?:import|export)\s+type\b[^;'"]*?\bfrom\s+['"]([^'"]+)['"]/gm)) importOf(m[1]!);
|
|
}
|
|
// Literals carry dependencies only from the test tree and from bin/ and shell
|
|
// sources (a test spawns a bin; a bash bin calls a sibling). Prose and
|
|
// manifests name everything and would make every file a hub.
|
|
if (!scansLiterals(rel)) return out;
|
|
for (const m of source.matchAll(QUOTED)) for (const target of resolveRefs(files, dirs, rel, m[1]!, true, config)) out.push({ target, via: 'literal' });
|
|
for (const m of source.matchAll(JOIN)) {
|
|
const segments = [...m[1]!.matchAll(/['"`]([^'"`]+)['"`]/g)].map(s => s[1]!);
|
|
if (segments.length < 2) continue;
|
|
const joined = segments.join('/');
|
|
const direct = resolveRefs(files, dirs, rel, joined, true);
|
|
for (const target of direct.length ? direct : resolveRefs(files, dirs, rel, './' + joined, true)) out.push({ target, via: 'literal' });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Test-tree files (tests, helpers, fixtures), bin/ entrypoints and shell sources carry literal dependencies. */
|
|
export function scansLiterals(rel: string): boolean {
|
|
return /(?:^|\/)(?:test|tests|spec|__tests__)\//.test(rel) || isTestFile(rel) || rel.startsWith('bin/') || /\.(?:sh|bash)$/.test(rel);
|
|
}
|
|
|
|
export interface BuildOptions {
|
|
declarations?: Record<string, string[]>; maxBytes?: number;
|
|
/** Paths that no longer exist (deleted or renamed-from) but that tests may still name; they resolve as dependencies so their consumers are found. */
|
|
extraFiles?: string[];
|
|
}
|
|
|
|
/** The whole-repo reverse graph. Files over maxBytes (default 2 MiB) are skipped: generated bundles, not tests. */
|
|
export function buildGraph(repoRoot: string, opts: BuildOptions = {}): Graph {
|
|
const all = listRepoFiles(repoRoot);
|
|
const files = new Set([...all, ...(opts.extraFiles ?? [])]);
|
|
const dirs = new Set<string>();
|
|
for (const f of all) { let d = path.posix.dirname(f); while (d && d !== '.') { dirs.add(d); d = path.posix.dirname(d); } }
|
|
const config = readPathAliases(repoRoot);
|
|
const graph: Graph = { files, tests: new Set(all.filter(isTestFile)), reverse: new Map(), unresolved: new Map(), aliasNotes: config.notes };
|
|
const maxBytes = opts.maxBytes ?? 2 * 1024 * 1024;
|
|
for (const rel of all) {
|
|
if (!SOURCE_EXT.test(rel) && !rel.startsWith('bin/')) continue;
|
|
let source: string;
|
|
try { const st = fs.statSync(path.join(repoRoot, rel)); if (!st.isFile() || st.size > maxBytes) continue; source = fs.readFileSync(path.join(repoRoot, rel), 'utf8'); } catch { continue; }
|
|
const unresolved: string[] = [];
|
|
for (const { target, via } of referencesIn(files, dirs, rel, source, config, graph.tests.has(rel) ? unresolved : undefined)) addEdge(graph, target, rel, via);
|
|
if (unresolved.length) graph.unresolved!.set(rel, unresolved);
|
|
}
|
|
for (const test of graph.tests) {
|
|
const m = /^(?:[\w.-]+\/)*?test\/([\w-]+)\.(?:test|spec)\./.exec(test);
|
|
if (!m) continue;
|
|
// test/gstack-ship-receipt-census.test.ts names bin/gstack-ship-receipt-census, then bin/gstack-ship-receipt, then bin/gstack-ship, …
|
|
const parts = m[1]!.split('-');
|
|
for (let n = parts.length; n >= 1; n--) {
|
|
const name = parts.slice(0, n).join('-');
|
|
for (const bin of [`bin/${name}`, `bin/gstack-${name}`, `bin/${name}.ts`, `bin/gstack-${name}.ts`]) if (files.has(bin)) addEdge(graph, bin, test, 'convention');
|
|
}
|
|
}
|
|
for (const [glob, tests] of Object.entries(opts.declarations ?? {})) {
|
|
for (const f of files) if (matchesAny([glob], f)) for (const t of tests) addEdge(graph, f, t, 'declared');
|
|
}
|
|
return graph;
|
|
}
|
|
|
|
export interface LaneMapping { file: string; tests: string[]; provenance: string[]; declaredNone: boolean }
|
|
|
|
/**
|
|
* Tests that depend on `file`, transitively through non-test files and
|
|
* through `dir/**` edges. `declarations` with an empty list declare "no lane
|
|
* needed" and win when nothing else maps.
|
|
*/
|
|
export function testsFor(graph: Graph, file: string, declarations: Record<string, string[]> = {}, maxDepth = 12): LaneMapping {
|
|
const tests = new Set<string>();
|
|
const provenance: string[] = [];
|
|
const seen = new Set<string>([file]);
|
|
const queue: Array<{ node: string; chain: string[] }> = [{ node: file, chain: [file] }];
|
|
const dirEdges = [...graph.reverse.keys()].filter(k => k.endsWith('/**'));
|
|
while (queue.length) {
|
|
const { node, chain } = queue.shift()!;
|
|
if (chain.length > maxDepth) continue;
|
|
const edges = [...(graph.reverse.get(node) ?? [])];
|
|
for (const dir of dirEdges) if (node.startsWith(dir.slice(0, -3) + '/')) edges.push(...(graph.reverse.get(dir) ?? []).map(e => ({ ...e, via: e.via })));
|
|
for (const e of edges) {
|
|
if (seen.has(e.from)) continue;
|
|
seen.add(e.from);
|
|
if (graph.tests.has(e.from)) { tests.add(e.from); provenance.push(`${e.from} ← ${[...chain].reverse().join(' ← ')} (${e.via})`); }
|
|
else queue.push({ node: e.from, chain: [...chain, e.from] });
|
|
}
|
|
}
|
|
if (graph.tests.has(file)) { tests.add(file); provenance.push(`${file} (itself a test)`); }
|
|
const declaredNone = tests.size === 0 && Object.entries(declarations).some(([glob, list]) => list.length === 0 && matchesAny([glob], file));
|
|
if (declaredNone) provenance.push(`${file}: declared no lane needed (.gstack/pregate.json dependencies)`);
|
|
if (tests.size === 0 && !declaredNone) provenance.push(...noLaneExplanation(graph, file));
|
|
return { file, tests: [...tests].sort(), provenance, declaredNone };
|
|
}
|
|
|
|
/** Why the graph may have missed a file's tests: unresolved repo-path specifiers in test files and unfollowed `extends`. */
|
|
function noLaneExplanation(graph: Graph, file: string): string[] {
|
|
const seen = [...(graph.unresolved ?? new Map<string, string[]>())].sort(([a], [b]) => a.localeCompare(b));
|
|
const notes = graph.aliasNotes ?? [];
|
|
if (seen.length === 0 && notes.length === 0) return [];
|
|
const lines = notes.map(n => `${file}: path aliases may be incomplete: ${n}`);
|
|
const shown = seen.slice(0, 10).map(([test, specs]) => `${test}: ${specs.slice(0, 5).map(s => `'${s}'`).join(' ')}${specs.length > 5 ? ' …' : ''}`);
|
|
if (shown.length) lines.push(`${file}: no lane; specifiers in test files that resolve to no repo file: ${shown.join('; ')}${seen.length > 10 ? `; +${seen.length - 10} more test file(s)` : ''}`);
|
|
lines.push(`${file}: if a test covers it, declare the file under dependencies in .gstack/pregate.json, or report the resolver gap`);
|
|
return lines;
|
|
}
|