diff --git a/CHANGELOG.md b/CHANGELOG.md index 170c8c36c..2f0e187cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ - Only removed tests are waived. These still fail PREGATE_NO_LANE until a test or a `dependencies` entry covers them: 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` (42 cases) covers the waived tests and the fail-closed negative controls; no existing test, threshold or allowlist changed. +- New `test/pregate-removed-paths.test.ts` (45 cases) covers the waived tests and the fail-closed negative controls; no existing test, threshold or allowlist changed. ## [1.91.291.0] - 2026-10-28 diff --git a/docs/pregate.md b/docs/pregate.md index 42bacc999..b035e40e5 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -200,8 +200,9 @@ Two stages with separate clocks, both printed: ` (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 basename, - such as a curation list (`files naming it run: …`), and fails closed when + the tests of each remaining non-Markdown file that mentions its name + without the final extension as a whole word (`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 diff --git a/lib/pregate/lane-selection.ts b/lib/pregate/lane-selection.ts index 26c9e0069..3ab6989c8 100644 --- a/lib/pregate/lane-selection.ts +++ b/lib/pregate/lane-selection.ts @@ -105,9 +105,15 @@ 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 basename, where the import graph cannot see: a curation list, a durations manifest, a script or a workflow. */ +/** + * Tracked files outside Markdown that mention `file`'s name without its final extension as a whole word, where the import graph + * cannot see: a curation list, a durations manifest, a script, a workflow, an extensionless path (`node test/alpha.test`) or a + * dotted module (`tests.test_alpha`). + */ function namedBy(ctx: PregateContext, file: string): string[] { - const r = spawnSync('git', ['grep', '--untracked', '-l', '-F', '-e', path.posix.basename(file), '--', '.', ':(exclude)*.md', ':(exclude)*.mdx'], { cwd: ctx.repoRoot, encoding: 'utf8', timeout: 60_000, maxBuffer: 16 * 1024 * 1024, env: ctx.env }); + const base = path.posix.basename(file); + const stem = base.includes('.') ? base.slice(0, base.lastIndexOf('.')) : base; + const r = spawnSync('git', ['grep', '--untracked', '-l', '-w', '-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)']; } diff --git a/test/pregate-removed-paths.test.ts b/test/pregate-removed-paths.test.ts index 0ad4bbc73..24b473813 100644 --- a/test/pregate-removed-paths.test.ts +++ b/test/pregate-removed-paths.test.ts @@ -229,6 +229,20 @@ describe('removed paths in lanes (#3529)', () => { 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 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 });