diff --git a/CHANGELOG.md b/CHANGELOG.md index 187e8b796..3d8357e7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [1.91.297.0] - 2026-10-28 **The terminal agent on Windows no longer waits on PowerShell to learn its own start time, so it boots in about a second and a slow PowerShell can no longer leave it without an identity.** Every agent boot ran `powershell.exe Get-CimInstance` with a 2 s timeout, twice when started standalone. On a loaded `windows-latest` runner one launch took 0.94 s at the median and up to 10.8 s, so agent boot took 2.4 to 2.8 s at the median and 5.1 s on a runner's first boot, past the owner-watchdog test's 5 s window. Measured again after the change under the same load: 0.86 to 0.91 s at the median, 1.18 s at most, 120 of 120 boots inside the window. @@ -15,6 +15,34 @@ ### Gate integrity - Test 5 in `process-liveness-windows.test.ts` expected the PowerShell mock's `fixture-owner-start` as the owner start time on every platform. On Windows that expectation followed the old implementation, so it moved with the product. It now requires the in-process value, equality with a real read, and no PowerShell query for the owner. The Linux and macOS assertion is unchanged. +## [1.91.296.0] - 2026-10-28 + +**The docsync gate stops failing ship-docsync-current and ship-docsync-store when the agent asks the docs helper for its usage in the middle of a line of reads.** Five red paid trials since 2026-10-27 each failed on a single refused line. In four, the agent ended a line of reads with `gstack-docs-candidate --help` (screen 40558235014, gate 40600731931, and screen 40627127462 twice). In the fifth, gate 40643690306, the line included `git tag`. v1.91.185.0 tried to stop the first slip by moving the "own Bash call" rule into the sentence that names `--help`, and the slip came back four times. The helper's `--help` prints its usage and returns before it touches Git or any file, so refusing it protected nothing. The 2026-10-27 backstop red (40515158924, `toContain`) ran on a tree from before #3516, which had already fixed it. + +### Fixed +- **`test/helpers/docsync-observer.ts`**: a line of reads may now include the installed candidate's `--help`, either alone or after `snapshot` or `compare`, optionally with `2>&1` or `2>/dev/null`. It counts as a neutral read, the same way a joined Git read does, but never when it is fed by a pipe and never in a line that also runs `cd`. `git tag`, `git tag -l` and `git tag --list` are now accepted as Git reads, alongside the branch listings #3516 added. A tag operand or any other option is still refused. +- **`test/helpers/docsync-interface.ts`**: the fixture interface declares both changes. It names the candidate's `--help` as the one exception to the own-call rule, and it adds tag listings to the Git read list. + +### Tests +- `test/docsync-command-grammar.test.ts` replays the five captured lines, which were refused before this change, and checks that each is now accepted, while the same line with `; git add app.ts` appended is still refused. Negative controls that stay refused: pipe-fed, `| sh`, `>` redirect and `cd` forms; `snapshot` and `compare` joined to reads; `-h`, `--version` and `--help snapshot`; the `~` and foreign helper paths; the lifecycle helpers' `--help` joined to reads; and 13 tag writes or filtered listings. + +### Gate integrity +- This is a gate edit, because the docs detector now accepts more lines. The test that pinned the joined `--help` as refused now expects it to be accepted. Focused paid trials at e967634: ship-docsync-current PASS ($0.84; its agent did join `--help` onto a line of reads) and ship-docsync-store PASS ($0.84). + +## [1.91.295.0] - 2026-10-28 + +**Deleting or renaming a test no longer fails pregate with PREGATE_NO_LANE.** A PR that removed a test file, or renamed one, failed `lanes` because the removed path had no test lane of its own, so cleanup PRs needed a hand-written `dependencies` entry to pass (#3529). Pregate now accounts for a removed test the way it accounts for an edit. A renamed test runs under its new name, a deleted test runs the remaining tests of the files it exercised, and either one runs the tests of any file that still names it, such as a curation list. Every waived test is printed on its own line, so the reason is visible in the gate output. Removing any other file still needs a lane, as before. + +### Fixed +- **Deleted and renamed tests pass `lanes`**: each removed JS, TS, Python, Dart or bats test that nothing remaining imports prints `removed (no lane owed): — `, and the `lanes` detail counts `N removed path(s)`. A deleted test (or a test renamed to a non-test path) runs its owning suite, the remaining tests that directly exercise the files its merge-base text exercised (`owning suite runs: …`); test helpers and fixtures are not subjects. Any removed test also runs the tests of each remaining non-Markdown file that still names it (`files naming it run: …`), and fails closed when that file has no test. +- **Lost coverage is named, not hidden**: when a deleted test was the last test of a file, the line says `coverage removed: has no remaining test` and the detail adds `M subject(s) lost their last test`. A modified file whose only test the branch deleted still fails PREGATE_NO_LANE. + +### Changed +- Only removed tests are waived. These still fail PREGATE_NO_LANE until a test or a `dependencies` entry covers them. They are any removed non-test path (a renamed source file's old name included), a removed test that remaining code imports, a deleted or renamed Go or Ruby test (it may define helpers its package still uses), and a deleted test whose merge-base text cannot be read (a symlink, invalid UTF-8, over 1 MiB). A remaining test that reads a deleted file or directory is still selected. + +### Gate integrity +- New `test/pregate-removed-paths.test.ts` (47 cases) covers the waived tests and the fail-closed negative controls; no existing test, threshold or allowlist changed. + ## [1.91.294.0] - 2026-10-28 **`gstack-doctor` and setup's hook check start far fewer processes, so the doctor tests fit the Windows CI lane again.** On Windows Git Bash each process start costs 12 to 77 ms, and the doctor spent most of its time starting them: about 3.3 s per `--check` and 6 s per dashboard run. `windows-free-shard (1)` was red on main because `test/gstack-doctor.test.ts` took 553 to 647 s there. Every doctor row, fix line and JSON field prints the same as before. diff --git a/VERSION b/VERSION index 3b2d0fdc4..2512a1601 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.294.0 +1.91.297.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 25babf755..185a0902b 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.294.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.297.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/docs/pregate.md b/docs/pregate.md index 975e20f5f..a42b37831 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -195,6 +195,30 @@ Two stages with separate clocks, both printed: no lane, and a workspace-only Cargo.toml or Cargo.lock needs a `dependencies` entry), transitively through non-test files; deleted and renamed files select the tests that named them. + A removed test (a deleted one, or a rename's old name) that no remaining + test selects owes no lane, and prints `removed (no lane owed): — + ` (the `lanes` detail adds `N removed path(s)`), when it is a JS, TS, + Python, Dart or bats test and no remaining file imports it (directly or + through a directory it reads; otherwise `still imported by …`). It runs + the tests of each remaining non-Markdown file that mentions its name + without the final extension anywhere (`alpha.test`, `test_alpha`: + a path with or without the extension, or a dotted module), such as a curation list (`files naming it run: …`), and fails closed when + such a file has no test (`still named by …, which no test covers`). A test + renamed to another test path then defers to its new name (`old name of + `). A deleted test, or one renamed to a non-test path, also runs its + owning suite, the remaining tests that directly exercise the files its + merge-base text exercised (`owning suite runs: …`; test helpers and + fixtures are not subjects), and names each file left with no test at all + (`coverage removed: has no remaining test`, and `M subject(s) lost + their last test` in the detail). Every other removal owes what an edit + owes, as before: a deleted or renamed Go or Ruby test (it may define + helpers its package still uses), a deleted test whose merge-base text + cannot be read (a symlink, invalid UTF-8, over 1 MiB), and any removed + non-test path, a renamed source file's old name included, are still + `PREGATE_NO_LANE` until a test or a `dependencies` entry covers them. A + modified file whose only test the branch deleted still has no lane, and a + remaining test that reads a deleted file or directory is selected. /review + lists a deleted test as a gate edit (RH-1) either way. An extensionless JS/TS import (relative, a tsconfig/jsconfig `paths` alias or `baseUrl`) tries the exact path, then `.ts`, `.tsx`, `.js`, `.jsx`, `.mjs`, `.cjs`, `.mts`, `.cts`, then `index.ts`, `index.tsx`, `index.js`, `index.jsx` diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 0eb279be3..1ffa23542 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -3211,6 +3211,18 @@ versions, line endings or any non-version byte changed, one under a test, fixture, golden, `.gstack/` or workflow tree, or one added, deleted, renamed or re-moded, is still this error; `--explain` prints `not version-stamp-only ()` for it. +A test the branch removed is usually not this error either: a deleted or +renamed JS, TS, Python, Dart or bats test that nothing remaining imports prints +`removed (no lane owed): — ` instead. It runs the tests of files +that still name it, and a deleted test runs its owning suite or says +`coverage removed: has no remaining test`. These are still this error: +a removed test that remaining code imports or that a file with no test still +names, a deleted or renamed Go or Ruby test, a deleted test whose merge-base +text cannot be read (a symlink, invalid UTF-8, over 1 MiB), any other removed +path (a renamed source file's old name included), and a modified file whose +only test the branch deleted. `--explain` prints the reason as `: +removed, but …` (`: removed test whose merge-base text could not be +read …` for an unreadable deleted test). **Fix.** Add a test that names the file, or declare its lane (an explicit `[]` for files with no behaviour) under `dependencies` in `.gstack/pregate.json` (or the user-owned file the message names). diff --git a/lib/pregate/checks/lanes.ts b/lib/pregate/checks/lanes.ts index b0a83f381..47df77d37 100644 --- a/lib/pregate/checks/lanes.ts +++ b/lib/pregate/checks/lanes.ts @@ -13,6 +13,11 @@ * A release manifest whose diff against the merge base changes only version * values is waived as version-stamp-only (lib/pregate/stamp-only.ts) and named * on its own line; any other change to it still needs a lane. + * A removed test (deleted, or a rename's old name) no remaining test selects + * owes no lane when nothing remaining imports it, and is named on its own + * `removed (no lane owed)` line; it runs the tests of files that still name it, + * and a deleted test runs its owning suite or names the files that lost their + * last test (issue #3529). Any other removal owes what an edit owes. * A test that reads the file still selects that test. * Incidents: p0-4-pipe-hidden-failure (`false | tail` read as green), * p0-4-windows-lane-unrun (a Windows-only change merged with its lane unrun). @@ -344,6 +349,7 @@ function unfinished(ctx: PregateContext, r: Awaited> function describe(sel: Selection, noPolicy = false): string[] { const lines = noPolicy ? [`no policy: ${sel.noLane.join(' ')} have no test lane (a warning under no policy; the starter below maps them)`] : sel.noLane.map(f => `FAIL lanes — ${f} has no test lane`); + for (const r of sel.removed) lines.push(`removed (no lane owed): ${r.file} — ${r.note}`); if (sel.emptyLane.length) lines.push(`docs/no-tests (empty lane): ${sel.emptyLane.join(' ')}`); if (sel.inert.length) lines.push(`no-tests (assets and tool config): ${sel.inert.join(' ')}`); if (sel.stampOnly.length) lines.push(`version-stamp-only (no lane needed): ${sel.stampOnly.join(' ')} — only version values changed against the merge base; a manifest whose other content changed still needs a lane`); @@ -502,10 +508,13 @@ async function run(ctx: PregateContext): Promise { const inputs = baseInputs(ctx, { selection: sha256(sel.tests.join('\n')), localTests: sha256(sel.localTests.join('\n')), remote: sha256(sel.remote.map(r => `${r.lane}:${r.tests.join(',')}`).join('\n')) }); const noPolicy = sel.noLane.length > 0 && ctx.configKind === 'none'; const base: CheckResult = { id: 'lanes', stage: 'tests', status: 'pass', inputs, lines: describe(sel, noPolicy), remote: sel.remote }; + const lost = new Set(sel.removed.flatMap(r => r.lostCoverage)).size; + const removedNote = sel.removed.length ? `${sel.removed.length} removed path(s)${lost ? `; ${lost} subject(s) lost their last test` : ''}` : ''; + const withRemoved = (r: CheckResult): CheckResult => (removedNote ? { ...r, detail: r.detail ? `${r.detail}; ${removedNote}` : removedNote } : r); if (sel.noLane.length && !noPolicy) { - return { ...base, status: 'fail', cause: 'diff', code: 'PREGATE_NO_LANE', detail: `${sel.noLane.length} touched file(s) with no test lane: ${sel.noLane.join(' ')}`, fix: noLaneFix(ctx, sel.noLane) }; + return withRemoved({ ...base, status: 'fail', cause: 'diff', code: 'PREGATE_NO_LANE', detail: `${sel.noLane.length} touched file(s) with no test lane: ${sel.noLane.join(' ')}`, fix: noLaneFix(ctx, sel.noLane) }); } - const result = await runSelected(ctx, sel, base); + const result = withRemoved(await runSelected(ctx, sel, base)); return noPolicy ? withStarter(ctx, sel, result) : result; } diff --git a/lib/pregate/import-graph.ts b/lib/pregate/import-graph.ts index 19176e94b..6f6dc13b7 100644 --- a/lib/pregate/import-graph.ts +++ b/lib/pregate/import-graph.ts @@ -43,6 +43,8 @@ export interface Graph { unresolved?: Map; /** tsconfig/jsconfig `extends` hops that were not followed (for the no-lane explanation). */ aliasNotes?: string[]; + /** Removed test → the files still in the tree its merge-base text exercised (never tests, helpers or fixtures); only for `goneSources`. */ + goneSubjects?: Map; } const scanner = new Bun.Transpiler({ loader: 'tsx' }); @@ -442,23 +444,41 @@ export function referencesIn(files: Set, dirs: Set, rel: string, return out; } +const TEST_TREE = /(?:^|\/)(?:test|tests|spec|__tests__)\//; + /** 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); + return TEST_TREE.test(rel) || isTestFile(rel) || rel.startsWith('bin/') || /\.(?:sh|bash)$/.test(rel); +} + +/** The bin/ entrypoints a `test/.test.*` file names by convention: test/gstack-ship-receipt-census.test.ts names bin/gstack-ship-receipt-census, then bin/gstack-ship-receipt, then bin/gstack-ship, … */ +function conventionBins(test: string, files: Set): string[] { + const m = /^(?:[\w.-]+\/)*?test\/([\w-]+)\.(?:test|spec)\./.exec(test); + if (!m) return []; + const parts = m[1]!.split('-'); + const out: string[] = []; + 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)) out.push(bin); + } + return out; } export interface BuildOptions { declarations?: Record; 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. */ + /** Paths that no longer exist (deleted or renamed-from) but that tests may still name; they resolve as dependencies so their consumers are found, and are never tests themselves (`git ls-files` still lists an unstaged deletion). */ extraFiles?: string[]; + /** Merge-base text of removed test files; their forward edges to files still in the tree become `goneSubjects`. */ + goneSources?: Map; } /** 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 gone = new Set(opts.extraFiles ?? []); + const all = listRepoFiles(repoRoot).filter(f => !gone.has(f)); + const files = new Set([...all, ...gone]); const dirs = new Set(); - for (const f of all) { let d = path.posix.dirname(f); while (d && d !== '.') { dirs.add(d); d = path.posix.dirname(d); } } + for (const f of files) { 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; @@ -478,14 +498,13 @@ export function buildGraph(repoRoot: string, opts: BuildOptions = {}): Graph { for (const e of rust.edges) addEdge(graph, e.dependency, e.dependent, e.via); const django = djangoEdges({ files: [...files], tests: graph.tests, read: rel => pySources.get(rel) ?? readRepoFile(repoRoot, rel, maxBytes), code: pythonCode, resolveDotted: dotted => pythonPatchTarget(files, dotted) }); for (const e of django) addEdge(graph, e.dependency, e.dependent, 'framework'); - 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 test of graph.tests) for (const bin of conventionBins(test, files)) addEdge(graph, bin, test, 'convention'); + if (opts.goneSources) { + const present = new Set(all); + graph.goneSubjects = new Map(); + for (const [rel, source] of opts.goneSources) { + const targets = [...referencesIn(files, dirs, rel, source, config, undefined, packages).map(r => r.target), ...conventionBins(rel, files)]; + graph.goneSubjects.set(rel, [...new Set(targets)].filter(t => present.has(t) && !isTestFile(t) && !TEST_TREE.test(t)).sort()); } } for (const [glob, tests] of Object.entries(opts.declarations ?? {})) { diff --git a/lib/pregate/lane-selection.ts b/lib/pregate/lane-selection.ts index e3c41ff1a..ae0c68126 100644 --- a/lib/pregate/lane-selection.ts +++ b/lib/pregate/lane-selection.ts @@ -20,9 +20,14 @@ import { stampFileKind, versionStampOnly } from './stamp-only'; import type { PregateContext, RemoteObligation } from './types'; import type { Lane } from './workflows'; +/** A path the branch removed (deleted, or a rename's old name) that owes no lane, with why (`note`) and the files that lost their last test. */ +export interface Removed { file: string; note: string; lostCoverage: string[] } + export interface Selection { /** Touched files with no lane and no `[]` declaration. */ noLane: string[]; + /** Removed paths no remaining test selects that owe no lane, each named on its own line (issue #3529); other removals stay in noLane. */ + removed: Removed[]; /** Markdown (and `empty_lanes` paths) with no import-graph test: the docs/no-tests empty lane. */ emptyLane: string[]; /** Assets and dotfile tool config with no import-graph test: files no runner loads, also an empty lane. */ @@ -82,10 +87,10 @@ function strictText(bytes: Buffer | null | undefined): string | null { if (!bytes || bytes.length > STAMP_FILE_CAP) return null; try { return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes); } catch { return null; } } -/** The file's text at the merge base when it is a regular blob there of mode `mode` (null when absent, a symlink or submodule, another mode, unreadable, invalid UTF-8 or over 1 MiB). */ -function readAtMergeBase(ctx: PregateContext, rel: string, mode: string): string | null { +/** The file's text at the merge base when it is a regular blob there of one of `modes` (null when absent, a symlink or submodule, another mode, unreadable, invalid UTF-8 or over 1 MiB). */ +function readAtMergeBase(ctx: PregateContext, rel: string, modes: readonly string[]): string | null { const ls = spawnSync('git', ['ls-tree', ctx.mergeBase, '--', rel], { cwd: ctx.repoRoot, encoding: 'utf8', timeout: 60_000, env: ctx.env }); - if (ls.status !== 0 || !(ls.stdout ?? '').startsWith(`${mode} blob `)) return null; + if (ls.status !== 0 || !modes.some(mode => (ls.stdout ?? '').startsWith(`${mode} blob `))) return null; const r = spawnSync('git', ['show', `${ctx.mergeBase}:${rel}`], { cwd: ctx.repoRoot, timeout: 60_000, maxBuffer: STAMP_FILE_CAP + 1, env: ctx.env }); return r.status === 0 ? strictText(r.stdout as Buffer) : null; } @@ -100,14 +105,84 @@ function readWorkingTree(repoRoot: string, rel: string): { text: string | null; /** Tracked files matching a registry entry's output globs. */ function outputsOf(all: readonly string[], globs: readonly string[]): string[] { return all.filter(f => matchesAny(globs, f)); } +/** + * Tracked files outside Markdown that mention `file`'s name without its final extension anywhere, where the import graph cannot + * see: a curation list, a durations manifest, a script, a workflow, an extensionless path (`node test/alpha.test`), a dotted + * module (`tests.test_alpha`), or an escaped or encoded path (`\talpha.test.js`, `test%2Falpha.test.js`). + */ +function namedBy(ctx: PregateContext, file: string): string[] { + const base = path.posix.basename(file); + const stem = base.includes('.') ? base.slice(0, base.lastIndexOf('.')) : base; + const r = spawnSync('git', ['grep', '--untracked', '-l', '-F', '-e', stem, '--', '.', ':(exclude)*.md', ':(exclude)*.mdx'], { cwd: ctx.repoRoot, encoding: 'utf8', timeout: 60_000, maxBuffer: 16 * 1024 * 1024, env: ctx.env }); + if (r.status === 1) return []; + return r.status === 0 ? (r.stdout ?? '').split('\n').filter(Boolean) : ['(git grep failed)']; +} + +/** Test languages whose importers the graph resolves file by file; a Go or Ruby test may define helpers its package still uses. */ +const RESOLVED_IMPORTS = /\.(?:[cm]?[jt]sx?|py|dart)$/; + +const shown = (files: readonly string[]) => `${files.slice(0, 8).join(' ')}${files.length > 8 ? ` … +${files.length - 8} more` : ''}`; + +/** + * Why a removed test no remaining test selects owes no lane, and the tests it selects, or `refused` when it must fail closed as + * no lane. Only a removed JS, TS, Python, Dart or bats test is waived; any other removed path, a renamed source's old name + * included, owes what an edit owes. Nothing is waived while a remaining file imports it, directly or through a directory + * reference. A removed test runs the tests of every remaining non-Markdown file that names its basename (a curation list, a + * durations manifest), and is refused when such a file has no test. A test renamed to a test then defers to its new name. A + * deleted test, or one renamed to a non-test path, also runs the tests that directly exercise the files it exercised and names + * those left with no test. + */ +function removedPath(ctx: PregateContext, graph: Graph, gone: ReadonlySet, file: string): { removed: Removed; suite: string[] } | { refused: string } { + if (!isTestFile(file)) return { refused: 'removed, but only a removed test is waived; any other removal owes what an edit owes' }; + if (!RESOLVED_IMPORTS.test(file) && !file.endsWith('.bats')) return { refused: 'removed, but a Go or Ruby test may define helpers its package still uses, which the graph cannot see' }; + const edges = [...graph.reverse].filter(([k]) => k === file || (k.endsWith('/**') && file.startsWith(k.slice(0, -2)))).flatMap(([, list]) => list); + const importers = [...new Set(edges.map(e => e.from))].filter(f => !gone.has(f)).sort(); + if (importers.length) return { refused: `removed, but still imported by ${shown(importers)}` }; + const namerSuite = new Set(); + const uncovered = namedBy(ctx, file).filter(f => !gone.has(f)).filter(n => { + const m = testsFor(graph, n, ctx.config.dependencies); + for (const t of m.tests) namerSuite.add(t); + return !m.tests.length; + }); + if (uncovered.length) return { refused: `removed, but still named by ${shown(uncovered)}, which no test covers` }; + const namerNote = namerSuite.size ? `files naming it run: ${shown([...namerSuite].sort())}` : ''; + const renamedTo = ctx.touched.find(t => t.renamedFrom === file)?.path; + if (renamedTo && isTestFile(renamedTo)) return { removed: { file, note: [`old name of ${renamedTo}`, namerNote].filter(Boolean).join('; '), lostCoverage: [] }, suite: [...namerSuite] }; + const subjects = graph.goneSubjects?.get(file); + if (!subjects) return { refused: 'removed test whose merge-base text could not be read (not a regular file there, invalid UTF-8 or over 1 MiB)' }; + if (!subjects.length) return { removed: { file, note: ['it exercised no file still in the tree', namerNote].filter(Boolean).join('; '), lostCoverage: [] }, suite: [...namerSuite] }; + const suite = new Set(); + const lostCoverage: string[] = []; + for (const subject of subjects) { + const m = testsFor(graph, subject, ctx.config.dependencies); + for (const e of graph.reverse.get(subject) ?? []) if (graph.tests.has(e.from)) suite.add(e.from); + if (!m.tests.length && !m.declaredNone) lostCoverage.push(subject); + } + const notes = [ + suite.size ? `owning suite runs: ${shown([...suite].sort())}` : '', + lostCoverage.length ? `coverage removed: ${lostCoverage.join(' ')} ${lostCoverage.length === 1 ? 'has' : 'have'} no remaining test` : '', + namerNote, + ].filter(Boolean); + return { removed: { file, note: notes.join('; ') || 'its files keep indirect or declared coverage', lostCoverage }, suite: [...new Set([...suite, ...namerSuite])] }; +} + export function selectLanes(ctx: PregateContext): Selection { const touched = ctx.touched; const gone = touched.flatMap(t => [t.status === 'D' ? t.path : null, t.renamedFrom ?? null]).filter((p): p is string => !!p); - const graph: Graph = buildGraph(ctx.repoRoot, { declarations: ctx.config.dependencies, extraFiles: gone }); + const goneSources = new Map(); + for (const t of touched) { + const removedTest = t.status === 'D' && isTestFile(t.path) ? t.path : t.renamedFrom && isTestFile(t.renamedFrom) && !isTestFile(t.path) ? t.renamedFrom : null; + if (!removedTest) continue; + const text = readAtMergeBase(ctx, removedTest, ['100644', '100755']); + if (text !== null) goneSources.set(removedTest, text); + } + const graph: Graph = buildGraph(ctx.repoRoot, { declarations: ctx.config.dependencies, extraFiles: gone, goneSources }); + const goneSet = new Set(gone); const all = listRepoFiles(ctx.repoRoot); const provenance: string[] = []; const tests = new Set(); const noLane: string[] = []; + const removed: Removed[] = []; const emptyLane: string[] = []; const inert: string[] = []; const stampOnly: string[] = []; @@ -151,7 +226,7 @@ export function selectLanes(ctx: PregateContext): Selection { const entry = touchedByPath.get(file); if (entry && entry.status === 'M' && !entry.renamedFrom && stampFileKind(file) && !isProtectedTree(file)) { const after = readWorkingTree(ctx.repoRoot, file); - const verdict = versionStampOnly(file, after.text === null ? null : readAtMergeBase(ctx, file, after.mode), after.text); + const verdict = versionStampOnly(file, after.text === null ? null : readAtMergeBase(ctx, file, [after.mode]), after.text); if (verdict.stampOnly) { stampOnly.push(file); provenance.push(`${file}: version-stamp-only against ${ctx.mergeBase.slice(0, 12)} (${verdict.changed.join('; ')}); no lane needed`); @@ -159,6 +234,16 @@ export function selectLanes(ctx: PregateContext): Selection { } provenance.push(`${file}: not version-stamp-only (${verdict.reason})`); } + if (gone.includes(file)) { + const r = removedPath(ctx, graph, goneSet, file); + if ('removed' in r) { + removed.push(r.removed); + for (const t of r.suite) tests.add(t); + provenance.push(`${file}: removed (no lane owed) — ${r.removed.note}`); + continue; + } + provenance.push(`${file}: ${r.refused}; no lane until declared`); + } noLane.push(file); } const laneSets: Record = {}; @@ -182,7 +267,7 @@ export function selectLanes(ctx: PregateContext): Selection { if (anyLocalAll || ctx.lanes.filter(l => l.runsTests && !l.manual && l.tier !== 'platform').length === 0) for (const t of selected) local.add(t); for (const t of selected) if (!local.has(t) && !remote.some(r => r.tests.includes(t))) local.add(t); if (local.size && tiers.size === 0) tiers.add('unit'); - return { noLane: noLane.sort(), emptyLane: emptyLane.sort(), inert: inert.sort(), stampOnly: stampOnly.sort(), tests: selected, localTests: [...local].sort(), remote, tiersRan: [...tiers].sort(), laneSets, provenance }; + return { noLane: noLane.sort(), removed: removed.sort((a, b) => a.file.localeCompare(b.file)), emptyLane: emptyLane.sort(), inert: inert.sort(), stampOnly: stampOnly.sort(), tests: selected, localTests: [...local].sort(), remote, tiersRan: [...tiers].sort(), laneSets, provenance }; } export function isTest(file: string): boolean { return isTestFile(file); } diff --git a/package.json b/package.json index 455ca838f..2b0cc41da 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.294", + "version": "1.91.297", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/docsync-command-grammar.test.ts b/test/docsync-command-grammar.test.ts index 8b24c1e11..c11cc4823 100644 --- a/test/docsync-command-grammar.test.ts +++ b/test/docsync-command-grammar.test.ts @@ -234,16 +234,65 @@ test('the installed docs-candidate helper is admitted only with a private .json expect(docsNativeInterface(fixture)).toContain('compare and --help commands'); }); -// Train gate 39904966815 (ship-docsync-completion, attempt 1) and GSTA-63's focused run joined the candidate's -// --help onto a line of reads. The join stays refused; the own-call rule now sits in the sentence naming --help. -test('a candidate --help joined to reads stays refused, and the interface says so where it names the command', () => { +// Train gate 39904966815 and GSTA-63's focused run joined the candidate's --help onto a line of reads; v1.91.185.0 moved the +// own-call rule into the sentence naming --help and the slip came back four times (JOINED_READ_LINES below). The --help only +// prints usage, so it now joins a line of reads like a Git read; every other own-call command stays alone. +test('a candidate --help joined to reads is a read, and the interface names it as the one exception to the own-call rule', () => { const skills = fixture.skills.split(path.sep).join('/'); - const joined = `git diff; git diff --cached; cat options.ts; ls -la handbook handbook/reference handbook/reference/commands; ${skills}/bin/gstack-docs-candidate --help`; - expect(docsCommandAllowed(joined, fixture)).toBe(false); - expect(docsCommandAllowed(`${skills}/bin/gstack-docs-candidate --help`, fixture)).toBe(true); + const candidate = `${skills}/bin/gstack-docs-candidate`; + const joined = `git diff; git diff --cached; cat options.ts; ls -la handbook handbook/reference handbook/reference/commands; ${candidate} --help`; + expect(docsCommandAllowed(joined, fixture)).toBe(true); + for (const command of [`${candidate} --help`, `${candidate} --help 2>&1`, `git status; ${candidate} snapshot --help`, `${candidate} compare --help 2>/dev/null; git log --oneline -3`, + `${candidate} --help | head -20`]) expect(docsCommandAllowed(command, fixture), command).toBe(true); + const record = path.join(fixture.home, 'c.json'); + for (const command of [ + `git status; ${candidate} snapshot --out ${record} --audit-id a --mode edit --base main`, `git status; ${candidate} compare ${record}`, + `${candidate} --help; git add app.ts`, `${candidate} --help > ${path.join(fixture.home, 'usage.md')}`, `${candidate} --help | sh`, + `echo x | ${candidate} --help`, `git status | ${candidate} --help`, `cd ${fixture.repo} && ${candidate} --help`, `git status; ${candidate} --help snapshot`, + `git status; ${candidate} --version`, `git status; ${candidate} -h`, `git status; ${candidate} --help --exec rm`, + `git status; ~/.claude/skills/gstack/bin/gstack-docs-candidate --help`, `git status; ${path.join(path.dirname(fixture.home), 'bin/gstack-docs-candidate')} --help`, + `git status; ${skills}/bin/gstack-skill-start --help`, `git status; ${skills}/bin/gstack-skill-end --help`, + ]) expect(docsCommandAllowed(command, fixture), command).toBe(false); const sentence = docsNativeInterface(fixture).split(/(?<=\.)\s+(?=[A-Z])/).find(part => part.includes('gstack-docs-candidate snapshot')); expect(sentence).toContain('as its own Bash call, with nothing joined before or after it'); - expect(sentence).toContain('a --help is no exception'); + expect(sentence).not.toContain('a --help is no exception'); + expect(docsNativeInterface(fixture)).toContain(`So may the installed ${candidate} --help, alone or after snapshot or compare, because it only prints usage (the one exception)`); +}); + +// Five red ship-docsync parents, each red only on 'command outside declared docs observation interface', and each with exactly +// one refused line (every other Bash call of the five captures replays clean): four joined the candidate's --help onto a line of +// reads, and gate 40643690306 put `git tag` in one. {home} is the fixture home; the sha is the capture file's sha256 prefix. +const JOINED_READ_LINES: Array<[source: string, scenario: 'current' | 'store', line: string]> = [ + ['screen 40558235014 slice 5 @ 70cedc3d, capture c73fb9d0b1b0', 'current', + 'git diff main HEAD; cat personal-note.txt; ls -la; find handbook; {home}/.claude/skills/gstack/bin/gstack-docs-candidate --help'], + ['gate 40600731931 slice 4 @ 5fef38a5, capture 606503d8b78a', 'store', + 'cat handbook/reference/commands/widget.md.tmpl; echo ---; cat CHANGELOG.md TODOS.md VERSION package.json AGENTS.md; echo ---; cat personal-note.txt; echo ---; find handbook; {home}/.claude/skills/gstack/bin/gstack-docs-candidate --help'], + ['screen 40627127462 slice 5 @ 76a155d4, capture 67fa4dea377a', 'current', + 'git diff main...HEAD; cat handbook/reference/commands/widget.md.tmpl; ls -la handbook handbook/reference handbook/reference/commands; cat personal-note.txt | head -3; {home}/.claude/skills/gstack/bin/gstack-docs-candidate --help'], + ['screen 40627127462 rerun 1 @ 76a155d4, capture 53d06fea1a89', 'current', + 'git diff main HEAD; git rev-parse origin/main; {home}/.claude/skills/gstack/bin/gstack-docs-candidate --help 2>&1'], + ['gate 40643690306 slice 4 @ c188a78c, capture 6ac1902bd83f', 'store', + 'git rev-parse HEAD origin/main; git merge-base main HEAD; git tag; cat {home}/fixture-interface.md | head -5; ls {home}'], +]; + +test.each(JOINED_READ_LINES)('captured joined read line from %s is a declared read', (_source, scenario, line) => { + const owned = fixtureDocs(scenario); + try { + const command = line.replaceAll('{home}', owned.home); + const publish = path.join(owned.home, 'fixture-publish.ts'); + expect(docsCommandAllowed(command, owned, [publish])).toBe(true); + expect(docsToolFailures(nativeResult(command, 'usage: gstack-docs-candidate snapshot ...'), owned, [publish], true)).toEqual([]); + expect(docsCommandAllowed(`${command}; git add app.ts`, owned, [publish])).toBe(false); + } finally { owned.clean(); } +}); + +test('operand-free tag listings are reads, and every tag write stays refused', () => { + for (const listing of ['git tag', 'git tag -l', 'git tag --list', 'git status; git tag; git log --oneline -3']) + expect(docsCommandAllowed(listing, fixture), listing).toBe(true); + for (const write of ['git tag v1', 'git tag -a v1 -m release', 'git tag -d v1', 'git tag -f v1 HEAD~1', 'git tag -s v1', 'git tag -v v1', 'git tag -l "v*"', + 'git tag --contains HEAD', 'git tag -n', 'git tag --sort=-v:refname', 'git tag -l v1', 'git tag; git tag v2', 'git tag | sh']) + expect(docsCommandAllowed(write, fixture), write).toBe(false); + expect(docsNativeInterface(fixture)).toContain('tag listings with no operand and only -l/--list'); }); test('the installed lifecycle helpers are admitted for their own --help, which starts nothing', () => { diff --git a/test/helpers/docsync-interface.ts b/test/helpers/docsync-interface.ts index 41370692d..607dd68ae 100644 --- a/test/helpers/docsync-interface.ts +++ b/test/helpers/docsync-interface.ts @@ -15,13 +15,13 @@ export const docsCapsCommand = (skills: string) => `${skills}/bin/gstack-ship-re export function docsNativeInterface(fixture: Pick, 'home' | 'repo' | 'skills'> & Partial, 'env'>>, scripts: string[] = [], transport = false, insert = false, file?: string): string { const skills = fixture.skills.split(path.sep).join('/'); const state = fixture.env?.GSTACK_HOME ?? path.join(fixture.home, 'state'); - return `Fixture observation interface (applies to parent and every child; ${file ? `saved at ${file}: each child prompt tells the child to Read that file first instead of copying this text` : 'include this interface in child prompts'}): Bash may execute only separate literal pwd, ls, cat, stat, sha256sum and Git read commands (status, diff, show, log, ls-files, ls-tree, check-ignore, rev-parse, merge-base, hash-object without -w, branch listings with no operand and only --show-current, -a/--all, -r/--remotes, -v/--verbose, -l/--list, --no-color or --no-abbrev (no --merged, --sort, --format or --contains), describe with only --tags, --always, --abbrev= or --exact-match and at most one revision). Bash may also run each of the following as its own Bash call, with nothing joined before or after it by ;, &&, || or a pipe (a --help is no exception, so never add one to a line of reads): the exact generated Preamble block with its spawned prefix, literal installed gstack-skill-start/gstack-skill-end commands for document-release (start requires GSTACK_SESSION_KIND=spawned) or their own --help, or the literal installed ${skills}/bin/gstack-docs-candidate snapshot (its --out a private .json under ${fixture.home}), compare and --help commands, and /ship Step 14.5's three own-tree housekeeping commands exactly as written, each on its own line: the shared .gstack/tmp exclude line (the one &&-joined housekeeping line this interface admits) + return `Fixture observation interface (applies to parent and every child; ${file ? `saved at ${file}: each child prompt tells the child to Read that file first instead of copying this text` : 'include this interface in child prompts'}): Bash may execute only separate literal pwd, ls, cat, stat, sha256sum and Git read commands (status, diff, show, log, ls-files, ls-tree, check-ignore, rev-parse, merge-base, hash-object without -w, branch listings with no operand and only --show-current, -a/--all, -r/--remotes, -v/--verbose, -l/--list, --no-color or --no-abbrev (no --merged, --sort, --format or --contains), tag listings with no operand and only -l/--list, describe with only --tags, --always, --abbrev= or --exact-match and at most one revision). Bash may also run each of the following as its own Bash call, with nothing joined before or after it by ;, &&, || or a pipe (one exception follows the read grammar below): the exact generated Preamble block with its spawned prefix, literal installed gstack-skill-start/gstack-skill-end commands for document-release (start requires GSTACK_SESSION_KIND=spawned) or their own --help, or the literal installed ${skills}/bin/gstack-docs-candidate snapshot (its --out a private .json under ${fixture.home}), compare and --help commands, and /ship Step 14.5's three own-tree housekeeping commands exactly as written, each on its own line: the shared .gstack/tmp exclude line (the one &&-joined housekeeping line this interface admits) ${DOCS_TMP_EXCLUDE} then ${DOCS_AUDIT_RESET} and the cap gate, which reads origin/main's policy and whose DOCS_CAP lines and exit status decide it ${docsCapsCommand(skills)} -(no --json, no other base, no other gstack-ship-receipt subcommand). ${readOnlyInterfaceText(fixture.home)} The Git reads above may join such a line too, but not in a line with cd. A for loop over literal words (a lowercase loop variable; words without quotes, globs or $) whose body uses only such reads counts as those reads, one pass per word, wherever it sits in the line. Every read stays off actor-state.json and off ${docsConfigDir(fixture.home)} except its settings.json: that directory holds Claude's credentials and every agent's session transcript, so judge a child only by the output it returns (its echoed SESSION_KIND line included) and by the repository. The additional scripts below also run each as its own Bash call; no own-call command ever joins such a line. Otherwise no shell composition, custom interpreters, arbitrary scripts, inline eval or memory-mapped writes. The only additional scripts are ${scripts.length ? scripts.join(', ') : 'none'}.${insert ? ` To insert one saved private Markdown artifact into another (a saved section file into a report), Bash may also run the single literal command cat SOURCE.md >> TARGET.md with absolute paths under ${fixture.home}.` : ''} Read/Glob/Grep remain available; Read skill and section files with Read (offset/limit for ranges), because Bash output over 30KB becomes a truncated preview. A private JSON/Markdown artifact (the snapshot --out file, saved child output, a section or report file) lives directly under ${fixture.home} or in a new directory you create there, never inside the repository, ${skills}, ${docsConfigDir(fixture.home)}, ${state} (gstack's own state directory, not a place for your files) or ${fixture.home}/remote.git. Use Write/Edit for permitted docs, those private artifacts and Step 14.5's receipt record ${fixture.repo}/${DOCS_AUDIT_RECORD} (the only permitted write under the repository besides the document, excluded from git by the line above); do not rewrite installed skills, config, actor state or scripts. No effects outside the owned fixture. The owner preserves evidence and cleans up. Missing observer coverage blocks acceptance; the Linux kernel monitor covers syscall writes in the product tree, not hostile processes or arbitrary external destinations. +(no --json, no other base, no other gstack-ship-receipt subcommand). ${readOnlyInterfaceText(fixture.home)} The Git reads above may join such a line too, but not in a line with cd. So may the installed ${skills}/bin/gstack-docs-candidate --help, alone or after snapshot or compare, because it only prints usage (the one exception); neither a joined Git read nor that --help may be fed by a pipe. A for loop over literal words (a lowercase loop variable; words without quotes, globs or $) whose body uses only such reads counts as those reads, one pass per word, wherever it sits in the line. Every read stays off actor-state.json and off ${docsConfigDir(fixture.home)} except its settings.json: that directory holds Claude's credentials and every agent's session transcript, so judge a child only by the output it returns (its echoed SESSION_KIND line included) and by the repository. The additional scripts below also run each as its own Bash call; no other own-call command ever joins such a line. Otherwise no shell composition, custom interpreters, arbitrary scripts, inline eval or memory-mapped writes. The only additional scripts are ${scripts.length ? scripts.join(', ') : 'none'}.${insert ? ` To insert one saved private Markdown artifact into another (a saved section file into a report), Bash may also run the single literal command cat SOURCE.md >> TARGET.md with absolute paths under ${fixture.home}.` : ''} Read/Glob/Grep remain available; Read skill and section files with Read (offset/limit for ranges), because Bash output over 30KB becomes a truncated preview. A private JSON/Markdown artifact (the snapshot --out file, saved child output, a section or report file) lives directly under ${fixture.home} or in a new directory you create there, never inside the repository, ${skills}, ${docsConfigDir(fixture.home)}, ${state} (gstack's own state directory, not a place for your files) or ${fixture.home}/remote.git. Use Write/Edit for permitted docs, those private artifacts and Step 14.5's receipt record ${fixture.repo}/${DOCS_AUDIT_RECORD} (the only permitted write under the repository besides the document, excluded from git by the line above); do not rewrite installed skills, config, actor state or scripts. No effects outside the owned fixture. The owner preserves evidence and cleans up. Missing observer coverage blocks acceptance; the Linux kernel monitor covers syscall writes in the product tree, not hostile processes or arbitrary external destinations. The working directory for parent and child Bash calls is already ${fixture.repo}. Run Git reads directly, for example: git status, git diff --cached, git merge-base main HEAD, git rev-parse HEAD. Do not use Git global options such as -C, -c, --git-dir or --work-tree, and do not prepend cd or another shell wrapper. The literal git subcommand must immediately follow git; an absolute owned repository path does not make git -C an allowed command. diff --git a/test/helpers/docsync-observer.ts b/test/helpers/docsync-observer.ts index b97312e2b..8125fbe1e 100644 --- a/test/helpers/docsync-observer.ts +++ b/test/helpers/docsync-observer.ts @@ -322,9 +322,12 @@ function docsUnrollLoops(text: string): string | null { /** * A read-only line the shared grammar cannot see whole: loops are checked as - * their unrolled passes, and each declared Git read joined to grammar reads - * (never after a cd, never fed by a pipe) is checked literally and then stands - * in the line as a neutral read for the grammar. + * their unrolled passes, and each declared Git read or installed candidate + * --help joined to grammar reads (never after a cd, never fed by a pipe) is + * checked literally and then stands in the line as a neutral read for the grammar. + * The candidate's --help prints its usage and returns before it touches Git or a + * file (bin/gstack-docs-candidate main), so joining it reads nothing more (gates + * 40600731931 and 40627127462, screen 40558235014: the parent ended a line of reads with it). */ function docsComposedReadAllowed(text: string, fixture: ReturnType): boolean { const line = docsUnrollLoops(text); @@ -332,13 +335,16 @@ function docsComposedReadAllowed(text: string, fixture: ReturnType line.slice(segment.start, segment.end).trim()); - const git = commands.flatMap((command, index) => /^git(?:\s|$)/.test(command) ? [index] : []); - if (!git.length || commands.some(command => /^cd(?:\s|$)/.test(command))) return false; + const declared = commands.flatMap((command, index) => /^git(?:\s|$)/.test(command) || command.startsWith(`${candidate} `) ? [index] : []); + if (!declared.length || commands.some(command => /^cd(?:\s|$)/.test(command))) return false; let neutral = line; - for (const index of git.reverse()) { + for (const index of declared.reverse()) { const read = unwrapHarmlessRead(commands[index]!, fixture); - if (segments[index - 1]?.op === '|' || read === null || !/^git\s/.test(read) || !literalDocsCommandAllowed(read, fixture, [])) return false; + const help = read !== null && /^(?:(?:snapshot|compare) )?--help$/.test(read.slice(candidate.length + 1)); + if (segments[index - 1]?.op === '|' || read === null || !(/^git\s/.test(read) || (read.startsWith(`${candidate} `) && help)) + || !literalDocsCommandAllowed(read, fixture, [])) return false; neutral = `${neutral.slice(0, segments[index]!.start)} pwd ${neutral.slice(segments[index]!.end)}`; } return readOnlyCommandAllowed(neutral, docsReadOnlyScope(fixture)); @@ -384,6 +390,9 @@ function literalDocsCommandAllowed(text: string, fixture: ReturnType DOCS_BRANCH_LIST_FLAGS.test(arg)); + // A tag listing reads refs the same way (gate 40643690306: the parent ended a line of reads with `git tag`); + // an operand or any other option creates, deletes, signs or verifies a tag. + if (rest[0] === 'tag') return rest.slice(1).every(arg => arg === '-l' || arg === '--list'); if (rest[0] === 'describe') { const operands = rest.slice(1).filter(arg => !DOCS_DESCRIBE_FLAGS.test(arg)); return operands.length <= 1 && operands.every(arg => !arg.startsWith('-')); diff --git a/test/pregate-removed-paths.test.ts b/test/pregate-removed-paths.test.ts new file mode 100644 index 000000000..55fcc4c9f --- /dev/null +++ b/test/pregate-removed-paths.test.ts @@ -0,0 +1,356 @@ +/** + * A test a branch removes (deleted, or the old name of a rename) owes no lane + * when nothing remaining imports it (issue #3529, gbrain#7829); any other + * removal, a renamed source's old name included, owes what an edit owes, as on + * main. Each waived test prints a visible `removed (no lane owed): — ` + * line: it runs the tests of files that still name it, and a deleted test runs + * its owning suite (the remaining tests of the files it exercised) or says which + * subjects lost their last test; a modified file whose only test was deleted + * still fails, and an unreadable deleted test fails closed. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { BASE_PREGATE, cleanup, commit, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/pregate-fixture'; + +const repos: FixtureRepo[] = []; +const fixture = (opts?: Parameters[0]) => { const r = makeFixtureRepo(opts); repos.push(r); return r; }; +afterEach(() => { while (repos.length) cleanup(repos.pop()!); }); + +const UTIL_V2 = "const test = require('node:test');\nconst assert = require('node:assert');\nconst { add } = require('../lib/util.js');\ntest('adds again', () => assert.strictEqual(add(2, 2), 4));\n"; + +function lanes(repo: FixtureRepo) { + const r = runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands', '--stage', 'tests']); + const out = JSON.parse(fs.readFileSync(path.join(repo.work, '.gstack/tmp/pregate.json'), 'utf8')); + const check = out.checks.find((c: { id: string }) => c.id === 'lanes'); + const lines: string[] = check.lines ?? []; + return { status: r.status, check, lines, removed: lines.filter(l => l.startsWith('removed (no lane owed): ')), ran: lines.find(l => l.startsWith('ran: ')) ?? '' }; +} + +function move(repo: FixtureRepo, from: string, to: string, edit?: [string, (text: string) => string]) { + repo.git('mv', from, to); + if (edit) { const file = path.join(repo.work, edit[0]); fs.writeFileSync(file, edit[1](fs.readFileSync(file, 'utf8'))); } + repo.git('add', '-A'); + repo.git('commit', '-q', '-m', 'move'); +} + +describe('removed paths in lanes (#3529)', () => { + test('a deleted test runs its owning suite: the remaining tests of the files it exercised', () => { + const repo = fixture({ baseFiles: { 'test/util-v2.test.js': UTIL_V2 } }); + commit(repo, { 'test/util.test.js': null }); + const r = lanes(repo); + expect(r.check.code).not.toBe('PREGATE_NO_LANE'); + expect(r.check.status).toBe('pass'); + expect(r.removed).toEqual(['removed (no lane owed): test/util.test.js — owning suite runs: test/util-v2.test.js']); + expect(r.ran).toContain('test/util-v2.test.js'); + expect(r.ran).not.toContain("'test/util.test.js'"); + expect(r.check.detail).toContain('1 removed path(s)'); + }); + + test('deleting the only test of a file says coverage removed, in the line and in the detail', () => { + const repo = fixture(); + commit(repo, { 'test/util.test.js': null }); + const r = lanes(repo); + expect(r.check.status).toBe('pass'); + expect(r.removed).toEqual(['removed (no lane owed): test/util.test.js — coverage removed: lib/util.js has no remaining test']); + expect(r.check.detail).toContain('1 removed path(s); 1 subject(s) lost their last test'); + }); + + test('deleting a file and its test together waives the test; the deleted file still owes a lane', () => { + const repo = fixture(); + commit(repo, { 'test/util.test.js': null, 'lib/util.js': null }); + const r = lanes(repo); + expect(r.check.code).toBe('PREGATE_NO_LANE'); + expect(r.check.detail).toContain('1 touched file(s) with no test lane: lib/util.js'); + expect(r.removed).toEqual(['removed (no lane owed): test/util.test.js — it exercised no file still in the tree']); + expect(r.check.detail).not.toContain('lost their last test'); + }); + + test('a renamed test runs under its new name; its old name is noted', () => { + const repo = fixture(); + move(repo, 'test/util.test.js', 'test/util2.test.js'); + const r = lanes(repo); + expect(r.check.status).toBe('pass'); + expect(r.removed).toEqual(['removed (no lane owed): test/util.test.js — old name of test/util2.test.js']); + expect(r.ran).toContain('test/util2.test.js'); + }); + + test('an unstaged deletion behaves like a committed one: the missing test is never selected', () => { + const repo = fixture({ baseFiles: { 'test/util-v2.test.js': UTIL_V2 } }); + fs.rmSync(path.join(repo.work, 'test/util.test.js')); + const r = lanes(repo); + expect(r.check.status).toBe('pass'); + expect(r.removed).toEqual(['removed (no lane owed): test/util.test.js — owning suite runs: test/util-v2.test.js']); + expect(r.ran).not.toContain("'test/util.test.js'"); + }); + + test('a deleted executable test is read at its merge-base mode', () => { + const repo = fixture({ baseFiles: { 'test/util-v2.test.js': UTIL_V2 } }); + fs.chmodSync(path.join(repo.work, 'test/util.test.js'), 0o755); + repo.git('add', '-A'); repo.git('commit', '-q', '-m', 'exec'); repo.git('push', '-q', 'origin', 'HEAD:main'); + commit(repo, { 'test/util.test.js': null }); + const r = lanes(repo); + expect(r.check.status).toBe('pass'); + expect(r.removed).toEqual(['removed (no lane owed): test/util.test.js — owning suite runs: test/util-v2.test.js']); + }); + + test('a shared test helper the deleted test imported selects no suite and never reads as lost coverage', () => { + const helper = "exports.check = (a, b) => a === b;\n"; + const usesHelper = "const test = require('node:test');\nconst { check } = require('./helpers/h.js');\ntest('helper', () => check(1, 1));\n"; + const util = "const test = require('node:test');\nconst assert = require('node:assert');\nconst { check } = require('./helpers/h.js');\nconst { add } = require('../lib/util.js');\ntest('adds', () => assert.ok(check(add(1, 2), 3)));\n"; + const repo = fixture({ baseFiles: { 'test/helpers/h.js': helper, 'test/helper-user.test.js': usesHelper, 'test/util.test.js': util, 'test/util-v2.test.js': UTIL_V2 } }); + commit(repo, { 'test/util.test.js': null }); + const r = lanes(repo); + expect(r.removed).toEqual(['removed (no lane owed): test/util.test.js — owning suite runs: test/util-v2.test.js']); + expect(r.ran).not.toContain('helper-user'); + }); + + test('a deleted test named after a bin/ entrypoint reports that bin when it loses its last test', () => { + const repo = fixture({ baseFiles: { 'bin/gstack-foo': '#!/bin/sh\necho foo\n', 'test/gstack-foo.test.js': "const test = require('node:test');\ntest('foo', () => {});\n" } }); + commit(repo, { 'test/gstack-foo.test.js': null }); + expect(lanes(repo).removed).toEqual(['removed (no lane owed): test/gstack-foo.test.js — coverage removed: bin/gstack-foo has no remaining test']); + }); + + test('a file declared no lane needed never reads as lost coverage', () => { + const repo = fixture({ pregate: { ...BASE_PREGATE, dependencies: { ...BASE_PREGATE.dependencies, 'lib/util.js': [] } } }); + commit(repo, { 'test/util.test.js': null }); + const r = lanes(repo); + expect(r.check.status).toBe('pass'); + expect(r.removed).toEqual(['removed (no lane owed): test/util.test.js — its files keep indirect or declared coverage']); + expect(r.check.detail).not.toContain('lost their last test'); + }); + + test('the owning suite is the tests that exercise the same files directly, not every test that reaches them', () => { + const wrap = "exports.addTwice = (a) => require('./util.js').add(a, a);\n"; + const wrapTest = "const test = require('node:test');\nconst assert = require('node:assert');\nconst { addTwice } = require('../lib/wrap.js');\ntest('twice', () => assert.strictEqual(addTwice(2), 4));\n"; + const repo = fixture({ baseFiles: { 'lib/wrap.js': wrap, 'test/wrap.test.js': wrapTest, 'test/util-v2.test.js': UTIL_V2 } }); + commit(repo, { 'test/util.test.js': null }); + const r = lanes(repo); + expect(r.removed).toEqual(['removed (no lane owed): test/util.test.js — owning suite runs: test/util-v2.test.js']); + expect(r.ran).not.toContain('test/wrap.test.js'); + }); + + test('a test renamed to a non-test path is accounted for like a deleted test', () => { + const repo = fixture(); + move(repo, 'test/util.test.js', 'docs/util-notes.md'); + expect(lanes(repo).removed).toEqual(['removed (no lane owed): test/util.test.js — coverage removed: lib/util.js has no remaining test']); + }); + + describe('negative controls', () => { + test('deleting every file of a fixture directory a remaining test reads selects that test', () => { + const reader = "const test = require('node:test');\nconst fs = require('node:fs');\ntest('reads the fixture dir', () => { fs.readdirSync('test/fixtures/data'); });\n"; + const repo = fixture({ baseFiles: { 'test/fixtures/data/a.json': '{}\n', 'test/fixtures/data/b.json': '{}\n', 'test/fixture-dir.test.js': reader } }); + commit(repo, { 'test/fixtures/data/a.json': null, 'test/fixtures/data/b.json': null }); + const r = lanes(repo); + expect(r.removed).toEqual([]); + expect(r.ran).toContain('test/fixture-dir.test.js'); + }); + + test.each(['package.json', '.github/workflows/windows.yml', 'test/golden/digest.txt'])('deleting %s owes what editing it owes: PREGATE_NO_LANE', file => { + const repo = fixture(); + commit(repo, { [file]: null }); + const r = lanes(repo); + expect(r.check.code).toBe('PREGATE_NO_LANE'); + expect(r.check.detail).toContain(file); + expect(r.removed).toEqual([]); + }); + + test('a deleted source file a remaining config still names fails closed', () => { + const repo = fixture({ baseFiles: { 'scripts/setup.js': "console.log('setup');\n", 'bunfig.toml': '[test]\npreload = ["./scripts/setup.js"]\n' } }); + commit(repo, { 'scripts/setup.js': null }); + const r = lanes(repo); + expect(r.check.code).toBe('PREGATE_NO_LANE'); + expect(r.check.detail).toContain('scripts/setup.js'); + }); + + test('a deleted test that remaining code still imports fails closed', () => { + const repo = fixture({ baseFiles: { 'lib/uses-test.js': "exports.cases = require('../test/util.test.js');\n" } }); + commit(repo, { 'test/util.test.js': null }); + const r = lanes(repo); + expect(r.check.code).toBe('PREGATE_NO_LANE'); + expect(r.removed).toEqual([]); + const why = runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands', '--stage', 'tests', '--explain']); + expect(`${why.out}${why.err}`).toContain('test/util.test.js: removed, but still imported by lib/uses-test.js'); + }); + + const GO_MOD = 'module example.com/demo\n\ngo 1.22\n'; + const NOT_A_TEST = 'removed, but only a removed test is waived; any other removal owes what an edit owes'; + test.each([ + ['a file nothing references', { 'lib/orphan.js': 'exports.o = 1;\n' }, 'lib/orphan.js'], + ['a file untested code imports without an extension', { 'src/d.ts': 'export const d = 1;\n', 'src/u.ts': "import { d } from './d';\nexport const u = d + 1;\n" }, 'src/d.ts'], + ['a module untested code imports by its dotted name', { 'pkg/__init__.py': '', 'pkg/m.py': 'X = 1\n', 'app.py': 'from pkg import m\nprint(m.X)\n' }, 'pkg/m.py'], + ['a module run by its dotted name', { 'tools/__init__.py': '', 'tools/cli.py': 'print(1)\n', 'Makefile': 'run:\n\tpython3 -m tools.cli\n' }, 'tools/cli.py'], + ['a Go file its package still uses', { 'go.mod': GO_MOD, 'pkg/value.go': 'package pkg\n\nfunc value() int { return 1 }\n', 'pkg/main.go': 'package pkg\n\nfunc Main() int { return value() }\n' }, 'pkg/value.go'], + ['a Ruby file loaded by require_relative', { 'lib/value.rb': 'VALUE = 1\n', 'lib/main.rb': "require_relative 'value'\n" }, 'lib/value.rb'], + ['a shell helper a bin sources', { 'bin/helper.sh': 'greet() { echo hi; }\n', 'bin/tool': '#!/bin/sh\nDIR=$(dirname "$0")\n. "$DIR/helper.sh"\ngreet\n' }, 'bin/helper.sh'], + ['a config its runner loads by name', { 'vitest.config.ts': 'export default {};\n' }, 'vitest.config.ts'], + ['a Python startup file', { 'sitecustomize.py': 'import builtins\nbuiltins.FEATURE = True\n' }, 'sitecustomize.py'], + ['a setup file a nested config names relative to itself', { 'packages/app/setup.ts': 'export {};\n', 'packages/app/bunfig.toml': '[test]\npreload = ["./setup.ts"]\n' }, 'packages/app/setup.ts'], + ])('deleting %s still owes a lane, as on main', (_what, files, deleted) => { + const repo = fixture({ baseFiles: files }); + commit(repo, { [deleted]: null }); + const r = lanes(repo); + expect(r.check.code).toBe('PREGATE_NO_LANE'); + expect(r.removed).toEqual([]); + const explain = runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands', '--stage', 'tests', '--explain']); + expect(explain.out + explain.err).toContain(`${deleted}: ${NOT_A_TEST}`); + }); + + test('deleting a Go test whose package may share its helpers fails closed', () => { + const repo = fixture({ baseFiles: { 'go.mod': GO_MOD, 'pkg/a.go': 'package pkg\n', 'pkg/a_test.go': 'package pkg\n\nfunc helper() {}\n', 'pkg/b_test.go': 'package pkg\n' } }); + commit(repo, { 'pkg/a_test.go': null }); + const r = lanes(repo); + expect(r.check.code).toBe('PREGATE_NO_LANE'); + expect(r.removed).toEqual([]); + const explain = runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands', '--stage', 'tests', '--explain']); + expect(explain.out + explain.err).toContain('pkg/a_test.go: removed, but a Go or Ruby test may define helpers'); + }); + + test('a renamed source\'s old name still owes a lane, as on main', () => { + const repo = fixture(); + move(repo, 'lib/util.js', 'lib/util2.js', ['test/util.test.js', t => t.replace('../lib/util.js', '../lib/util2.js')]); + const r = lanes(repo); + expect(r.check.code).toBe('PREGATE_NO_LANE'); + expect(r.check.detail).toContain('lib/util.js'); + expect(r.removed).toEqual([]); + }); + + const CURATION = "exports.SKIP = ['test/util.test.js'];\n"; + const CURATION_TEST = "const test = require('node:test');\nconst assert = require('node:assert');\nconst fs = require('node:fs');\nconst path = require('node:path');\nconst { SKIP } = require('../scripts/curation.js');\ntest('every entry names a real test', () => assert.deepStrictEqual(SKIP.filter(f => !fs.existsSync(path.join(__dirname, '..', f))), []));\n"; + test.each([ + ['deleted test', (repo: FixtureRepo) => commit(repo, { 'test/util.test.js': null })], + ['test renamed to another test', (repo: FixtureRepo) => move(repo, 'test/util.test.js', 'test/util2.test.js')], + ])('a %s that a tested curation list still names runs that list\'s test, which fails loudly', (_how, change) => { + const repo = fixture({ baseFiles: { 'scripts/curation.js': CURATION, 'test/curation.test.js': CURATION_TEST } }); + change(repo); + const r = lanes(repo); + expect(r.ran).toContain('test/curation.test.js'); + expect(r.check.status).toBe('fail'); + expect(r.removed[0]).toContain('files naming it run: test/curation.test.js'); + }); + + test.each([ + ['an extensionless path in package.json', 'test/util.test.js', { 'package.json': '{ "name": "fixture", "scripts": { "smoke": "node --test test/util.test" } }\n' }], + ['a dotted module name in a Makefile', 'tests/test_alpha.py', { 'tests/__init__.py': '', 'tests/test_alpha.py': 'import unittest\nclass T(unittest.TestCase):\n def test_a(self):\n pass\n', 'Makefile': 'smoke:\n\tpython3 -m unittest tests.test_alpha\n' }], + ['a dotted module name untested code imports at run time', 'app/test_handlers.py', { 'app/__init__.py': '', 'app/test_handlers.py': 'X = 1\n', 'app/registry.py': 'import importlib\nH = importlib.import_module("app.test_handlers")\n' }], + ['a JSON-escaped tab before the name', 'alpha.test.js', { 'alpha.test.js': "const test = require('node:test');\ntest('a', () => {});\n", 'package.json': '{ "name": "fixture", "scripts": { "smoke": "node --test\\talpha.test.js" } }\n' }], + ['a URL-encoded path', 'test/alpha.test.js', { 'test/alpha.test.js': "const test = require('node:test');\ntest('a', () => {});\n", 'ci/links.txt': 'https://example.test/run?file=test%2Falpha.test.js\n' }], + ])('a removed test named only by %s that no test covers fails closed', (_how, removed, files) => { + const repo = fixture({ baseFiles: files }); + commit(repo, { [removed]: null }); + const r = lanes(repo); + expect(r.check.code).toBe('PREGATE_NO_LANE'); + expect(r.removed).toEqual([]); + const explain = runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands', '--stage', 'tests', '--explain']); + expect(explain.out + explain.err).toContain(`${removed}: removed, but still named by`); + }); + + test('a removed test a file no test covers still names fails closed', () => { + const repo = fixture({ baseFiles: { 'scripts/skip-list.txt': 'test/util.test.js\n' } }); + commit(repo, { 'test/util.test.js': null }); + const r = lanes(repo); + expect(r.check.code).toBe('PREGATE_NO_LANE'); + expect(r.removed).toEqual([]); + const explain = runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands', '--stage', 'tests', '--explain']); + expect(explain.out + explain.err).toContain('test/util.test.js: removed, but still named by scripts/skip-list.txt, which no test covers'); + }); + + test('a rename into a waived path does not waive the old name: package.json renamed to Markdown fails closed', () => { + const repo = fixture(); + move(repo, 'package.json', 'docs/package.md'); + const r = lanes(repo); + expect(r.check.code).toBe('PREGATE_NO_LANE'); + expect(r.check.detail).toContain('package.json'); + expect(r.removed).toEqual([]); + }); + + test('a renamed source remaining untested code still imports by its old name fails closed', () => { + const repo = fixture({ baseFiles: { 'lib/a.js': 'exports.a = 1;\n', 'lib/u.js': "exports.u = require('./a.js').a;\n", 'notes/plan.md': '# plan\n' } }); + move(repo, 'lib/a.js', 'notes/a.md'); + const r = lanes(repo); + expect(r.check.code).toBe('PREGATE_NO_LANE'); + expect(r.check.detail).toContain('lib/a.js'); + expect(r.removed).toEqual([]); + }); + + test('a deleted file a remaining bin reads through its directory fails closed', () => { + const repo = fixture({ baseFiles: { 'lib/plugins/a.js': 'exports.a = 1;\n', 'lib/plugins/b.js': 'exports.b = 2;\n', 'bin/load.js': "const fs = require('node:fs');\nfor (const f of fs.readdirSync('lib/plugins')) require('../lib/plugins/' + f);\n" } }); + commit(repo, { 'lib/plugins/a.js': null }); + const r = lanes(repo); + expect(r.check.code).toBe('PREGATE_NO_LANE'); + expect(r.removed).toEqual([]); + const explain = runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands', '--stage', 'tests', '--explain']); + expect(explain.out + explain.err).toContain(`lib/plugins/a.js: ${NOT_A_TEST}`); + }); + + test('a deleted file a tested loader reaches by a computed path fails closed', () => { + const loader = "const path = require('node:path');\nexports.value = () => require(path.join(__dirname, 'lib', 'value.js')).value;\n"; + const loaderTest = "const test = require('node:test');\nconst { value } = require('../loader.js');\ntest('loads', () => value());\n"; + const repo = fixture({ baseFiles: { 'lib/value.js': 'exports.value = 1;\n', 'loader.js': loader, 'test/loader.test.js': loaderTest } }); + commit(repo, { 'lib/value.js': null }); + const r = lanes(repo); + expect(r.check.code).toBe('PREGATE_NO_LANE'); + expect(r.removed).toEqual([]); + const explain = runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands', '--stage', 'tests', '--explain']); + expect(explain.out + explain.err).toContain(`lib/value.js: ${NOT_A_TEST}`); + }); + + test('a Go test renamed into another directory is refused: its old package may still call its helpers', () => { + const repo = fixture({ baseFiles: { 'go.mod': 'module example.com/demo\n\ngo 1.22\n', 'pkg/a.go': 'package pkg\n', 'pkg/a_test.go': 'package pkg\n\nfunc helper() {}\n', 'pkg/b_test.go': 'package pkg\n\nfunc TestB(t *testing.T) { helper() }\n', 'isolated/keep.go': 'package isolated\n' } }); + move(repo, 'pkg/a_test.go', 'isolated/a_test.go'); + const r = lanes(repo); + expect(r.check.code).toBe('PREGATE_NO_LANE'); + expect(r.check.detail).toContain('pkg/a_test.go'); + expect(r.removed).toEqual([]); + }); + + test('a modified file whose only test was deleted still fails PREGATE_NO_LANE', () => { + const repo = fixture(); + commit(repo, { 'test/util.test.js': null, 'lib/util.js': "exports.add = (a, b) => b + a;\n" }); + const r = lanes(repo); + expect(r.status).toBe(1); + expect(r.check.code).toBe('PREGATE_NO_LANE'); + expect(r.check.detail).toContain('lib/util.js'); + expect(r.check.detail).not.toContain('test/util.test.js'); + expect(r.check.detail).toContain('1 removed path(s); 1 subject(s) lost their last test'); + expect(r.removed).toEqual(['removed (no lane owed): test/util.test.js — coverage removed: lib/util.js has no remaining test']); + }); + + test('a deleted file a remaining test still imports selects that test, which fails loudly', () => { + const repo = fixture(); + commit(repo, { 'lib/util.js': null }); + const r = lanes(repo); + expect(r.removed).toEqual([]); + expect(r.ran).toContain('test/util.test.js'); + expect(r.check.code).toBe('PREGATE_LANE_FAILED'); + }); + + test('an explicit dependencies mapping for a deleted path still selects its mapped test', () => { + const repo = fixture({ pregate: { ...BASE_PREGATE, dependencies: { ...BASE_PREGATE.dependencies, 'test/util.test.js': ['test/retry.test.js'] } } }); + commit(repo, { 'test/util.test.js': null }); + const r = lanes(repo); + expect(r.check.status).toBe('pass'); + expect(r.removed).toEqual([]); + expect(r.ran).toContain('test/retry.test.js'); + }); + + test('a deleted markdown file keeps its docs/no-tests label', () => { + const repo = fixture({ baseFiles: { 'notes/plan.md': '# plan\n' } }); + commit(repo, { 'notes/plan.md': null }); + const r = lanes(repo); + expect(r.check.status).toBe('pass'); + expect(r.removed).toEqual([]); + expect(r.lines).toContain('docs/no-tests (empty lane): notes/plan.md'); + }); + + test('a deleted test whose merge-base text cannot be read (over 1 MiB) fails closed', () => { + const big = "const test = require('node:test');\nrequire('../lib/util.js');\n// " + 'x'.repeat(1024 * 1024) + '\n'; + const repo = fixture({ baseFiles: { 'test/big.test.js': big } }); + commit(repo, { 'test/big.test.js': null }); + const r = lanes(repo); + expect(r.status).toBe(1); + expect(r.check.code).toBe('PREGATE_NO_LANE'); + expect(r.check.detail).toContain('test/big.test.js'); + }); + }); +});