diff --git a/scripts/pr-dependencies.ts b/scripts/pr-dependencies.ts index fbf1ef43d..a6dc94e3a 100644 --- a/scripts/pr-dependencies.ts +++ b/scripts/pr-dependencies.ts @@ -49,6 +49,12 @@ export interface DerivedDependencies { tracked: Set; /** The reference chain from an E2E case's declared inputs to `file` (for fix text and audits), else null. */ trace(caseId: string, file: string): string[] | null; + /** + * Files whose import scan failed for a reason other than a leading `#!` + * line (their imports are unknown). PR selection restores the full gate + * while this is non-empty; it fills as the derivation reads files. + */ + scanErrors: Map; /** * Tracked files whose reference text still names `missing`, a path absent * from the tree (deleted in the diff): its path or extensionless import @@ -60,8 +66,10 @@ export interface DerivedDependencies { } const CODE = /\.(?:[cm]?[jt]s|tsx|jsx)$/; +/** An extensionless script Bun or Node runs: its imports are code, not shell. */ +const CODE_SHEBANG = /^#![^\n]*\b(?:bun|node)\b/; const TEXT = /\.(?:[cm]?[jt]s|tsx|jsx|sh|md|tmpl|json|ya?ml|toml|txt|py)$/; -const REPO_PATH = /(?:^|[^\w.@-])((?:bin|lib|scripts|browse|design|make-pdf|hosts|extension|model-overlays|agents|test\/helpers|test\/fixtures)\/[\w.@-]+(?:\/[\w.@-]+)*)/g; +const REPO_PATH = /(?:^|[^\w.@-])((?:bin|lib|scripts|browse|design|make-pdf|hosts|extension|model-overlays|agents|capy|test\/helpers|test\/fixtures)\/[\w.@-]+(?:\/[\w.@-]+)*)/g; /** `path.join(ROOT, 'lib', 'x.ts')`: a top-level directory and its entry as separate string literals. */ const JOINED_PATH = /['"`](bin|lib|scripts|browse|design|make-pdf|hosts|extension|model-overlays|agents)['"`]\s*,\s*['"`]([\w.@-]+)['"`]/g; const BIN_NAME = /\bgstack-[a-z0-9][a-z0-9-]*[a-z0-9]\b/g; @@ -85,7 +93,26 @@ export const SELECTION_DATA_MODULES: readonly string[] = [ ]; const DEFAULT_JUDGE_OWNER = 'test/skill-llm-eval.test.ts'; -const transpiler = new Bun.Transpiler({ loader: 'tsx' }); +const transpilers = { + ts: new Bun.Transpiler({ loader: 'ts' }), tsx: new Bun.Transpiler({ loader: 'tsx' }), + js: new Bun.Transpiler({ loader: 'js' }), jsx: new Bun.Transpiler({ loader: 'jsx' }), +}; + +/** Code by extension, or an extensionless file whose shebang runs Bun or Node. */ +export function isCodeFile(file: string, text: string): boolean { + return CODE.test(file) || (!/\.[^/]+$/.test(path.posix.basename(file)) && CODE_SHEBANG.test(text)); +} + +/** + * Relative import specifiers of a code file. A leading `#!` line is blanked + * first (`scanImports` rejects it), and each extension gets its own loader + * (the TSX loader misreads `.ts` generics and casts). Any other parse error + * throws, so callers fail closed. + */ +export function scanCodeImports(file: string, text: string): string[] { + const loader = /\.tsx$/.test(file) ? 'tsx' : /\.jsx$/.test(file) ? 'jsx' : /\.[cm]?js$/.test(file) ? 'js' : 'ts'; + return transpilers[loader].scanImports(text.replace(/^#![^\n]*/, '')).map(item => item.path); +} /** * Text a reference scan reads: whole-line comments are prose about other @@ -168,15 +195,17 @@ export function deriveDependencies(maps: DependencyMaps, root: string): DerivedD }; const dirs = new Set(tracked.flatMap(file => file.split('/').slice(0, -1).map((_, i, parts) => parts.slice(0, i + 1).join('/')))); + const scanErrors = new Map(); + const importsOf = (file: string): string[] => { + try { return scanCodeImports(file, read(file)); } catch (error) { scanErrors.set(file, String(error)); return []; } + }; const edges = new Map(); const refs = (file: string): string[] => { if (edges.has(file)) return edges.get(file)!; const raw = read(file); const text = (SELECTION_DATA_MODULES as readonly string[]).includes(file) ? '' : referenceText(file, raw); const out = new Set(); - if (CODE.test(file)) { - try { for (const item of transpiler.scanImports(raw)) { const hit = resolveSpecifier(root, item.path, file); if (hit) out.add(hit); } } catch { /* literal scan below still applies */ } - } + if (isCodeFile(file, raw)) for (const specifier of importsOf(file)) { const hit = resolveSpecifier(root, specifier, file); if (hit) out.add(hit); } for (const match of text.matchAll(REPO_PATH)) { const ref = match[1]!.replace(/[.]+$/, ''); if (trackedSet.has(ref)) out.add(ref); @@ -238,19 +267,17 @@ export function deriveDependencies(maps: DependencyMaps, root: string): DerivedD const file = pending.pop()!; if (global.has(file)) continue; global.add(file); - if (!CODE.test(file)) continue; - try { for (const item of transpiler.scanImports(read(file))) { const hit = resolveSpecifier(root, item.path, file); if (hit && !global.has(hit)) pending.push(hit); } } catch { /* unparseable: its own entry stays global */ } + if (!isCodeFile(file, read(file))) continue; + for (const specifier of importsOf(file)) { const hit = resolveSpecifier(root, specifier, file); if (hit && !global.has(hit)) pending.push(hit); } } const importTargets = new Map(); const targetsOf = (file: string): string[] => { if (!importTargets.has(file)) { const targets: string[] = []; - if (CODE.test(file) && !(SELECTION_DATA_MODULES as readonly string[]).includes(file)) { - try { - for (const item of transpiler.scanImports(read(file))) { - if (item.path.startsWith('.')) targets.push(path.posix.normalize(path.posix.join(path.posix.dirname(file), item.path))); - } - } catch { /* literal mentions below still apply */ } + if (isCodeFile(file, read(file)) && !(SELECTION_DATA_MODULES as readonly string[]).includes(file)) { + for (const specifier of importsOf(file)) { + if (specifier.startsWith('.')) targets.push(path.posix.normalize(path.posix.join(path.posix.dirname(file), specifier))); + } } importTargets.set(file, targets); } @@ -287,7 +314,7 @@ export function deriveDependencies(maps: DependencyMaps, root: string): DerivedD while (parents.has(chain[0]!)) chain.unshift(parents.get(chain[0]!)!); return chain; }; - return { e2e, judges: consumers(maps.judgeTouchfiles, DEFAULT_JUDGE_OWNER), everyCase, global, tracked: trackedSet, trace, referencers }; + return { e2e, judges: consumers(maps.judgeTouchfiles, DEFAULT_JUDGE_OWNER), everyCase, global, tracked: trackedSet, trace, referencers, scanErrors }; } const cache = new Map(); diff --git a/scripts/replay-pr-selection.ts b/scripts/replay-pr-selection.ts index 71e79d5de..3e6ed6903 100644 --- a/scripts/replay-pr-selection.ts +++ b/scripts/replay-pr-selection.ts @@ -26,7 +26,7 @@ import { packageChangeOnlyVersion } from './test-pr-profile'; import { E2E_TIERS, E2E_TOUCHFILES, GLOBAL_TOUCHFILES } from '../test/helpers/touchfiles-data'; import { matchGlob } from '../test/helpers/test-selection'; import { derivedDependencies } from './pr-dependencies'; -import { PR_PROFILE_MAPS } from './test-pr-profile'; +import { knownNonBehaviorFile, PR_PROFILE_MAPS } from './test-pr-profile'; export interface ReplayInput { runId: number; @@ -59,7 +59,9 @@ export interface ReplayRow { /** * Whether a case depends on any changed file under today's dependency graph: * its touchfiles, the globals, and the derived reference closure - * (scripts/pr-dependencies.ts, its global runner closure included). + * (scripts/pr-dependencies.ts, its global runner closure included). Like + * the selector, a touchfile match wins over the audited free-only exemptions, + * and an exempt file reaches no case through the derived closure. */ export function caseTouchesDiff(id: string, changedFiles: readonly string[]): boolean { const patterns = [...(E2E_TOUCHFILES[id] ?? []), ...GLOBAL_TOUCHFILES]; @@ -67,7 +69,7 @@ export function caseTouchesDiff(id: string, changedFiles: readonly string[]): bo // A path absent from today's tree reaches a case through the live files that still reference it. const via = (file: string) => derived.tracked.has(file) ? [file] : derived.referencers(file); return changedFiles.some(file => patterns.some(pattern => matchGlob(file, pattern)) - || via(file).some(source => derived.global.has(source) || !!derived.e2e.get(source)?.has(id))); + || (!knownNonBehaviorFile(file) && via(file).some(source => derived.global.has(source) || !!derived.e2e.get(source)?.has(id)))); } export function replayRun(input: ReplayInput, oldSelection: readonly string[]): ReplayRow { diff --git a/scripts/test-pr-profile.ts b/scripts/test-pr-profile.ts index ddc1e5991..52c325e18 100644 --- a/scripts/test-pr-profile.ts +++ b/scripts/test-pr-profile.ts @@ -216,6 +216,12 @@ export const FREE_ONLY_PR_FILES = [ '.gstack/generated.json', // gstack's own regen registry: gstack-regen/gstack-pregate read it from origin/ of the repo they run in; paid cases run in fixture repos. '.gstack/pregate.json', // gstack's own pre-gate policy: same trust boundary, same reason. '.gstack/ship-policy.json', // gstack's own ship policy: gstack-ship-policy/gstack-restamp read it from origin/ of the repo they run in; same reason. + '.gstack/ship-policy.example.json', // The example beside it: only .gitignore and docs/ship-policy.md name it. + '.github/dependabot.yml', // Dependabot's own config: read by GitHub, and by free test/compare-pr-version.test.ts; dependabot runs skip the paid lane. + '.osv-scanner.toml', // Read by .github/workflows/osv-scanner.yml and the free OSV tests (test/osv-*.test.ts). + 'setup.ps1', // The Windows installer: run only by .github/workflows/windows-setup-e2e.yml; the paid lane is Linux. + '.gitlab-ci.yml', // gstack's own GitLab config: skills probe the fixture repo's .gitlab-ci.yml, never this one. + '.github/ISSUE_TEMPLATE/**', // Issue templates: read by GitHub and free test/gstack-doctor.test.ts. 'scripts/retired-command.ts', // One-release stubs for retired package scripts; no paid case imports it. 'careful/bin/check-careful.sh', // /careful PreToolUse hook: covered by test/hook-scripts.test.ts; no paid fixture copies or runs it. // Reporting and launch tools: they read CI history or start a lane, never run inside a paid case. @@ -273,9 +279,9 @@ export const PR_FULL_GATE_FILES = [ export const PERIODIC_ONLY_PR_FILES = ['.github/workflows/evals-periodic.yml', '.github/workflows/evals-marathon.yml'] as const; /** Directories the reference derivation covers: an unmapped file there with no consumer is consumed by no paid case. */ -export const DERIVABLE_PREFIXES = ['bin/', 'lib/', 'scripts/', 'browse/', 'design/', 'make-pdf/', 'hosts/', 'extension/', 'model-overlays/', 'agents/', 'test/helpers/'] as const; +export const DERIVABLE_PREFIXES = ['bin/', 'lib/', 'scripts/', 'browse/', 'design/', 'make-pdf/', 'hosts/', 'extension/', 'model-overlays/', 'agents/', 'test/helpers/', 'capy/'] as const; -function knownNonBehaviorFile(file: string): boolean { +export function knownNonBehaviorFile(file: string): boolean { // A mapped dependency still wins over these exemptions. New helper, // runtime, dependency, or paid-workflow files are deliberately not exempted. return /^(?:docs\/|(?:README|CONTRIBUTING|ARCHITECTURE|CHANGELOG|TODOS)\.md$|VERSION$)/.test(file) @@ -293,6 +299,9 @@ export const FALLBACK_FIX_ANCHOR = 'docs/TESTING_INTERNALS.md#pr-paid-lane-fallb /** Why one changed file restored the full gate, and the edit that narrows it next time (DX-6). */ export function unknownFileLabel(file: string): { label: string; fix: string } { + if (matches(file, PR_FULL_GATE_FILES)) { + return { label: 'full-gate input', fix: `${file} is in PR_FULL_GATE_FILES (scripts/test-pr-profile.ts): every paid case consumes it, so the full gate is intended and no mapping narrows it` }; + } if (isPaidTestFile(file) || file.startsWith('test/helpers/')) { return { label: 'needs touchfile entry', fix: `register ${file} under the cases that consume it in test/helpers/touchfiles-data.ts` }; } @@ -380,6 +389,12 @@ export function selectPrProfile(options: { if (found.e2e.size + found.judges.size > 0) consumers.set(file, found); else noConsumerFiles.push(file); } + // A failed import scan leaves some closure unknown: every unmapped file restores the full gate (fail closed). + const scanFailed = derived && unmapped.length ? [...derived.scanErrors.keys()].sort() : []; + if (scanFailed.length) { + consumers.clear(); derivedGlobal.length = 0; noConsumerFiles.length = 0; + for (const file of unmapped) deletedLabels.set(file, { label: 'import scan failed', fix: `scripts/pr-dependencies.ts could not scan the imports of ${scanFailed.slice(0, 3).join(', ')}${scanFailed.length > 3 ? ` and ${scanFailed.length - 3} more` : ''}; fix the parse error so ${file}'s consumers can be derived` }); + } const derivedFiles = [...consumers.keys()]; const unknownFiles = unmapped.filter(file => !derivedGlobal.includes(file) && !consumers.has(file) && !noConsumerFiles.includes(file)); const sharedInputs = files.filter(file => depends(file, PR_FULL_GATE_FILES)); diff --git a/test/free-fixtures.test.ts b/test/free-fixtures.test.ts index c9cb1d933..4feeb8c37 100644 --- a/test/free-fixtures.test.ts +++ b/test/free-fixtures.test.ts @@ -85,7 +85,7 @@ describe('workflow inventory (CEO-20)', () => { test('the inventory rule also reads .yaml files', () => { expect(unknownFileLabel('.github/workflows/new-check.yaml').label).toBe('add to FREE_ONLY_PR_FILES'); - expect(unknownFileLabel('.github/workflows/evals.yml').label).toBe('real unknown dependency'); + expect(unknownFileLabel('.github/workflows/evals.yml').label).toBe('full-gate input'); }); }); @@ -111,7 +111,7 @@ describe('PR selection over the W1a single-file fallbacks', () => { const result = select(['.github/workflows/evals.yml']); expect(result.mode).toBe('full-fallback'); expect(result.unknownFileLabels).toEqual([{ file: '.github/workflows/evals.yml', ...unknownFileLabel('.github/workflows/evals.yml') }]); - expect(result.reasons[0]).toContain('.github/workflows/evals.yml (real unknown dependency)'); + expect(result.reasons[0]).toContain('.github/workflows/evals.yml (full-gate input)'); }); test('a mapped free fixture is exempt; a paid-registered fixture selects its cases; an unmapped one falls back', () => { diff --git a/test/paid-free-boundary.test.ts b/test/paid-free-boundary.test.ts index 402ac6917..dd4ea3eb9 100644 --- a/test/paid-free-boundary.test.ts +++ b/test/paid-free-boundary.test.ts @@ -114,7 +114,7 @@ describe('paid/free dependency boundary', () => { expect(result.selection).toEqual({ e2e: [], judges: [] }); } // Tracked files outside every mapping and outside the derivable directories restore the full gate. - for (const file of ['ETHOS.md', 'SECURITY.md', 'conductor.json', '.osv-scanner.toml']) { + for (const file of ['ETHOS.md', 'SECURITY.md', 'conductor.json', 'NOTICE.md']) { const result = computePaidCaseSelection({ profile: 'pr', env: {}, changedFiles: [FREE_ONLY_PR_FILES[0], file] }); expect(result.coverage?.mode, file).toBe('full-fallback'); expect(result.coverage?.unknownFiles).toContain(file); diff --git a/test/pr-dependencies.test.ts b/test/pr-dependencies.test.ts index 2470405e9..a3ddcf73d 100644 --- a/test/pr-dependencies.test.ts +++ b/test/pr-dependencies.test.ts @@ -96,6 +96,93 @@ describe('reference derivation on a fixture tree', () => { }); }); +describe('shebang files and import scan failures (A1-1)', () => { + const tree = (extra: Record = {}) => fixtureRepo({ + 'test/skill-e2e-alpha.test.ts': "import '../lib/entry';\n", 'test/skill-e2e-beta.test.ts': '', + 'lib/entry.ts': '#!/usr/bin/env bun\nimport { y } from \'./typed\';\nexport const x = y;\n', + 'lib/typed.ts': 'export const y = (v: T) => v as unknown as T;\nimport \'./typed-dep\';\n', 'lib/typed-dep.ts': '', + 'alpha/SKILL.md.tmpl': 'Run `gstack-sb`.\n', 'bin/gstack-sb': '#!/usr/bin/env -S bun run\nimport { z } from \'../lib/sb-dep\';\n', 'lib/sb-dep.ts': '', + 'bin/gstack-sh': '#!/bin/sh\nimport ../lib/sh-only.ts\n', 'lib/sh-only.ts': '', + 'scripts/build.sh': '', 'scripts/resolvers/index.ts': '', 'package.json': '{}', ...extra, + }); + const maps = { e2eTouchfiles: { alpha: ['test/skill-e2e-alpha.test.ts', 'alpha/**'], beta: ['test/skill-e2e-beta.test.ts'] }, judgeTouchfiles: {}, globalTouchfiles: [] as string[] }; + const profileMaps = { ...maps, tiers: { alpha: 'gate', beta: 'gate' } as const }; + const pick = (derived: ReturnType, changedFiles: string[]) => + selectPrProfile({ maps: profileMaps, profile: [], selectedE2E: [], selectedJudges: [], changedFiles, derived }); + + test('a shebang .ts file, a bun-shebang extensionless bin and a .ts generic arrow are all import-scanned', () => { + const derived = deriveDependencies(maps, tree()); + expect([...derived.e2e.get('lib/typed.ts') ?? []]).toEqual(['alpha']); + expect([...derived.e2e.get('lib/typed-dep.ts') ?? []]).toEqual(['alpha']); + expect([...derived.e2e.get('lib/sb-dep.ts') ?? []]).toEqual(['alpha']); + expect(derived.e2e.get('lib/sh-only.ts')).toBeUndefined(); + expect(derived.scanErrors.size).toBe(0); + expect(pick(derived, ['lib/sb-dep.ts'])).toMatchObject({ mode: 'dependents', e2e: ['alpha'] }); + }); + + test('lib/fs-utils.ts selects the cases of bin/gstack-redact-prepush, which imports it', () => { + const bin = gate.filter(id => real.e2e.get('bin/gstack-redact-prepush')?.has(id)); + expect(bin.length).toBeGreaterThan(0); + const result = computePaidCaseSelection({ profile: 'pr', env: {}, changedFiles: ['lib/fs-utils.ts'], packageVersionOnly: true }); + expect(result.coverage?.mode).toBe('dependents'); + for (const id of bin) expect(result.selection.e2e, id).toContain(id); + expect(real.scanErrors.size).toBe(0); + expect(real.global.has('test/helpers/bootstrap-retention.ts')).toBe(true); + }); + + test('any other scan error fails closed at every site: case closures, the global walk and deleted-file referencers', () => { + const broken = "import { from './nowhere';\n"; + const inClosure = deriveDependencies(maps, tree({ 'lib/typed-dep.ts': broken })); + expect([...inClosure.scanErrors.keys()]).toEqual(['lib/typed-dep.ts']); + const closed = pick(inClosure, ['lib/sb-dep.ts']); + expect(closed).toMatchObject({ mode: 'full-fallback', e2e: ['alpha', 'beta'], unknownFiles: ['lib/sb-dep.ts'] }); + expect(closed.unknownFileLabels[0]).toMatchObject({ label: 'import scan failed' }); + expect(closed.unknownFileLabels[0]!.fix).toContain('lib/typed-dep.ts'); + + const inGlobal = deriveDependencies({ ...maps, globalTouchfiles: ['lib/runner.ts'] }, tree({ 'lib/runner.ts': broken })); + expect([...inGlobal.scanErrors.keys()]).toEqual(['lib/runner.ts']); + expect(pick(inGlobal, ['lib/sb-dep.ts']).mode).toBe('full-fallback'); + + const unreached = deriveDependencies(maps, tree({ 'lib/orphan.ts': broken })); + expect(unreached.scanErrors.size).toBe(0); + expect(pick(unreached, ['lib/gone.ts'])).toMatchObject({ mode: 'full-fallback', unknownFiles: ['lib/gone.ts'] }); + expect([...unreached.scanErrors.keys()]).toEqual(['lib/orphan.ts']); + }); +}); + +describe('safe classification (A1-2)', () => { + test('capy/support.env derives to the bins that read it instead of restoring the full gate', () => { + for (const bin of ['bin/gstack-install-verdict.sh', 'bin/gstack-doctor-check.sh']) { + for (const id of real.e2e.get(bin) ?? []) expect(real.e2e.get('capy/support.env')?.has(id), `${bin} ${id}`).toBe(true); + } + const result = computePaidCaseSelection({ profile: 'pr', env: {}, changedFiles: ['capy/support.env'], packageVersionOnly: true }); + expect(result.coverage?.unknownFiles).toEqual([]); + expect(result.coverage?.mode).toBe('dependents'); + expect(DERIVABLE_PREFIXES).toContain('capy/'); + }); + + test('repository config read only by free lanes or GitHub stays in the PR profile', () => { + const files = ['.github/dependabot.yml', '.osv-scanner.toml', 'setup.ps1', '.gitlab-ci.yml', '.github/ISSUE_TEMPLATE/bug_report.md', '.gstack/ship-policy.example.json']; + for (const file of files) expect(real.tracked.has(file), file).toBe(true); + const result = computePaidCaseSelection({ profile: 'pr', env: {}, changedFiles: files, packageVersionOnly: true }); + expect(result.coverage?.mode).toBe('pr'); + expect(result.selection).toEqual({ e2e: [], judges: [] }); + }); + + test('a full-gate file is never labelled a real unknown dependency', () => { + for (const file of ['.github/workflows/evals.yml', 'test-setup.ts', '.gitattributes']) { + const result = computePaidCaseSelection({ profile: 'pr', env: {}, changedFiles: [file], packageVersionOnly: true }); + expect(result.coverage?.mode, file).toBe('full-fallback'); + for (const label of result.coverage!.unknownFileLabels) { + expect(label.label, file).toBe('full-gate input'); + expect(label.fix).toContain('PR_FULL_GATE_FILES'); + } + expect(unknownFileLabel(file).label).toBe('full-gate input'); + } + expect(unknownFileLabel('ETHOS.md').label).toBe('real unknown dependency'); + }); +}); + describe('derived dependencies of this checkout', () => { test('a bin script a skill template invokes selects that skill\'s cases', () => { const consumers = real.e2e.get('bin/gstack-next-version') ?? new Set();