mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
v1.91.295.0 fix(pregate): deleting or renaming a test no longer fails lanes (#3534)
* fix(pregate): removed paths owe no lane instead of failing PREGATE_NO_LANE A deleted file or a rename's old name that no remaining test selects now prints 'removed (no lane owed): <path> — <why>' in lanes: a deleted test runs its owning suite or names the files that lost their last test; a modified file whose only test was deleted still fails, and an unreadable deleted test fails closed. Fixes: #3529 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(pregate): pre-landing review fixes for removed paths A removal owes what an edit owes: manifests, lockfiles, config, fixtures, goldens, workflows and files a config still names keep PREGATE_NO_LANE. A test reading a fully deleted directory is selected again, a deleted Rust module selects its crate's tests, a test renamed to a non-test path is accounted as deleted, and the owning suite is the tests that directly exercise the same files. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(pregate): a removed file remaining code still imports fails closed Pass-2 red-team: namedBy matched only full repo paths, so a deleted module imported by a relative, extensionless or dotted specifier from untested code was waived. removedPath now refuses when the import graph shows a remaining dependent. Stale headers updated to say other removals owe what an edit owes. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(pregate): removals the graph cannot see stay fail-closed Step 11 adversarial and outside-voice findings: the waiver trusted SOURCE_EXT, so Go, Ruby, shell, Rust and bin removals, files loaded by name and files a nested config names relative to itself passed with no test; a rename's old name skipped every check. Now every removed path is refused while a remaining file imports it (directory references included); only JS, TS, Python and Dart code is waived, never a file loaded by name; namedBy also matches paths relative to each ancestor directory; Go and Ruby tests are refused; a rename's old name goes through the same checks unless it is a test renamed to a test. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(pregate): basename mentions and Go/Ruby test renames fail closed Fix-check 2 (cycle 4, rule 23 grant from GStack Minder): a deleted file a tested loader reached by path.join(__dirname, 'lib', 'value.js') was waived, and a Go helper test renamed into another directory got the test-rename exemption. namedBy now greps the basename repo-wide (non-Markdown); the Go and Ruby test refusal runs before the rename exemption. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(pregate): align removed-path prose with the waiver rules Ship docs audit (gsta137-docs-1): name the snapshot, baseline and .gstack/ trees the waiver excludes, add usercustomize.py to the loaded-by-name list, and give the unreadable-deleted-test explain line. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * chore: bump version and changelog (v1.91.292.0) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: a removed test runs the tests of files that still name it (pregate lanes) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pregate waives only removed tests; other removals owe what an edit owes (D3 narrowed by the controller) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: a removed test is named by any whole-word mention of its name without the extension (pregate lanes) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: a removed test is named by any mention of its name without the extension (pregate lanes) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * chore: unstamp (VERSION back to 1.91.291.0, CHANGELOG entry under [Unreleased]); the slot call stamps Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: CHANGELOG [Unreleased] entry passes the prose-lint gate Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * v1.91.295.0 fix(pregate): deleting or renaming a test no longer fails lanes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
4b8c03bea9
commit
61e201ec08
10 files changed
+543
-24
No files matched your search
@@ -1,5 +1,19 @@
|
||||
# Changelog
|
||||
|
||||
## [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): <path> — <why>`, 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: <file> 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.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# gstack digest v1.91.294.0 — regenerate/re-copy after upgrading gstack
|
||||
# gstack digest v1.91.295.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,
|
||||
|
||||
@@ -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): <path> —
|
||||
<why>` (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
|
||||
<new>`). 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: <file> 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`
|
||||
|
||||
@@ -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 (<reason>)`
|
||||
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): <path> — <why>` instead. It runs the tests of files
|
||||
that still name it, and a deleted test runs its owning suite or says
|
||||
`coverage removed: <file> 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 `<path>:
|
||||
removed, but …` (`<path>: 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).
|
||||
|
||||
@@ -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<ReturnType<typeof runLocal>>
|
||||
|
||||
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<CheckResult> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
+32
-13
@@ -43,6 +43,8 @@ export interface Graph {
|
||||
unresolved?: Map<string, string[]>;
|
||||
/** 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<string, string[]>;
|
||||
}
|
||||
|
||||
const scanner = new Bun.Transpiler({ loader: 'tsx' });
|
||||
@@ -442,23 +444,41 @@ export function referencesIn(files: Set<string>, dirs: Set<string>, 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/<name>.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>): 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<string, string[]>; maxBytes?: number;
|
||||
/** Paths that no longer exist (deleted or renamed-from) but that tests may still name; they resolve as dependencies so their consumers are found. */
|
||||
/** 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<string, string>;
|
||||
}
|
||||
|
||||
/** The whole-repo reverse graph. Files over maxBytes (default 2 MiB) are skipped: generated bundles, not tests. */
|
||||
export function buildGraph(repoRoot: string, opts: BuildOptions = {}): Graph {
|
||||
const all = listRepoFiles(repoRoot);
|
||||
const files = new Set([...all, ...(opts.extraFiles ?? [])]);
|
||||
const gone = new Set(opts.extraFiles ?? []);
|
||||
const all = listRepoFiles(repoRoot).filter(f => !gone.has(f));
|
||||
const files = new Set([...all, ...gone]);
|
||||
const dirs = new Set<string>();
|
||||
for (const f of all) { let d = path.posix.dirname(f); while (d && d !== '.') { dirs.add(d); d = path.posix.dirname(d); } }
|
||||
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 ?? {})) {
|
||||
|
||||
@@ -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<string>, 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<string>();
|
||||
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<string>();
|
||||
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<string, string>();
|
||||
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<string>();
|
||||
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<string, string[] | '?'> = {};
|
||||
@@ -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); }
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.91.294",
|
||||
"version": "1.91.295",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -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): <path> — <why>`
|
||||
* 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<typeof makeFixtureRepo>[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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user