diff --git a/docs/pregate.md b/docs/pregate.md index 975e20f5f..dc84ed95c 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -195,6 +195,19 @@ 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 path (a deleted file, or a rename's old name) that no remaining + test selects owes no lane: it prints `removed (no lane owed): — ` + and the `lanes` detail adds `N removed path(s)`. A rename's old name defers to + the new path (`old name of `); a deleted test runs its owning suite, the + remaining tests of 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 (`coverage removed: has no remaining test`, and + `M subject(s) lost their last test` in the detail); any other deleted file + has nothing left to test (`no remaining test references it`). A deleted test + whose merge-base text cannot be read (a symlink, invalid UTF-8, over 1 MiB) + fails closed with `PREGATE_NO_LANE`, and a modified file whose only test the + branch deleted still has no lane. /review lists the deletion 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 03a377edb..5d2ad46e7 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -3211,6 +3211,12 @@ 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 path the branch removed is not this error either: a deleted file, or a +rename's old name, that no remaining test selects prints `removed (no lane +owed): — ` instead (a deleted test runs its owning suite or says +`coverage removed: has no remaining test`). A modified file whose only +test the branch deleted is still this error, and so is a deleted test whose +merge-base text cannot be read (a symlink, invalid UTF-8, over 1 MiB). **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..c2ad6ec77 100644 --- a/lib/pregate/checks/lanes.ts +++ b/lib/pregate/checks/lanes.ts @@ -13,6 +13,10 @@ * 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 path (deleted, or a rename's old name) no remaining test selects + * owes no lane and is named on its own `removed (no lane owed)` line; a deleted + * test runs its owning suite or names the files that lost their last test + * (issue #3529). * 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 +348,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 +507,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..164ecf0c8 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,21 +444,39 @@ 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); } } const config = readPathAliases(repoRoot); @@ -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..6c0d68ca2 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: each owes no lane and is named on its own line (issue #3529). */ + 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,47 @@ 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)); } +/** + * Why a removed path no remaining test selects owes no lane, and the owning-suite tests it selects: a rename's old name defers + * to the new path; a deleted test selects the remaining tests of the files it exercised and names those that lost their last + * test; any other removed file has nothing left to test. Null when a deleted test's merge-base text could not be read: it fails closed. + */ +function removedPath(ctx: PregateContext, graph: Graph, file: string): { removed: Removed; suite: string[] } | null { + const renamedTo = ctx.touched.find(t => t.renamedFrom === file)?.path; + if (renamedTo) return { removed: { file, note: `old name of ${renamedTo}`, lostCoverage: [] }, suite: [] }; + if (!isTestFile(file)) return { removed: { file, note: 'no remaining test references it', lostCoverage: [] }, suite: [] }; + const subjects = graph.goneSubjects?.get(file); + if (!subjects) return null; + if (!subjects.length) return { removed: { file, note: 'it exercised no file still in the tree', lostCoverage: [] }, suite: [] }; + const suite = new Set(); + const lostCoverage: string[] = []; + for (const subject of subjects) { + const m = testsFor(graph, subject, ctx.config.dependencies); + for (const t of m.tests) suite.add(t); + if (!m.tests.length && !m.declaredNone) lostCoverage.push(subject); + } + const notes = [ + suite.size ? `owning suite runs: ${[...suite].sort().join(' ')}` : '', + lostCoverage.length ? `coverage removed: ${lostCoverage.join(' ')} ${lostCoverage.length === 1 ? 'has' : 'have'} no remaining test` : '', + ].filter(Boolean); + return { removed: { file, note: notes.join('; ') || 'it exercised only files declared no lane needed', lostCoverage }, suite: [...suite] }; +} + 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) { + if (t.status !== 'D' || !isTestFile(t.path)) continue; + const text = readAtMergeBase(ctx, t.path, ['100644', '100755']); + if (text !== null) goneSources.set(t.path, text); + } + const graph: Graph = buildGraph(ctx.repoRoot, { declarations: ctx.config.dependencies, extraFiles: gone, goneSources }); 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 +189,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 +197,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, file); + if (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}: removed test whose merge-base text could not be read (not a regular file there, invalid UTF-8 or over 1 MiB); no lane until declared`); + } noLane.push(file); } const laneSets: Record = {}; @@ -182,7 +230,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/test/pregate-removed-paths.test.ts b/test/pregate-removed-paths.test.ts new file mode 100644 index 000000000..5d583c542 --- /dev/null +++ b/test/pregate-removed-paths.test.ts @@ -0,0 +1,174 @@ +/** + * Paths a branch removes (deleted files and the old name of a rename) never fail + * `lanes` with PREGATE_NO_LANE on their own (issue #3529, gbrain#7829). Each one + * prints a visible `removed (no lane owed): — ` line: 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, write, 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 notes both and loses no coverage of a file that still exists', () => { + const repo = fixture(); + commit(repo, { 'test/util.test.js': null, 'lib/util.js': null }); + const r = lanes(repo); + expect(r.check.status).toBe('pass'); + expect(r.removed).toEqual([ + 'removed (no lane owed): lib/util.js — no remaining test references it', + '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('a renamed source keeps the lane of its new name; its old name is noted', () => { + 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.status).toBe('pass'); + expect(r.removed).toEqual(['removed (no lane owed): lib/util.js — old name of lib/util2.js']); + expect(r.ran).toContain('test/util.test.js'); + }); + + test('a deleted file no test covered is noted, not failed', () => { + const repo = fixture(); + commit(repo, { 'lib/orphan.js': null }); + const r = lanes(repo); + expect(r.check.status).toBe('pass'); + expect(r.removed).toEqual(['removed (no lane owed): lib/orphan.js — no remaining test references it']); + }); + + 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'); + }); + + describe('negative controls', () => { + 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.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'); + }); + }); +});