diff --git a/changelog.d/gst246-dotfile-literals.md b/changelog.d/gst246-dotfile-literals.md new file mode 100644 index 000000000..13400cb55 --- /dev/null +++ b/changelog.d/gst246-dotfile-literals.md @@ -0,0 +1 @@ +- **Pregate now maps tests to dotfiles they name.** A test that reads `../.gitleaks.toml`, `.gitleaks.toml` or `join('.github', 'workflows', 'ci.yml')` used to get no edge to that file, so a branch touching only the dotfile ended in `PREGATE_NO_LANE`. Quoted literals may now start with a dot, and a dot-led literal that isn't `./` or `../` resolves from the repository root. A literal still forms an edge only when it names a file in the repo, so strings like `'.ts'` add nothing. On gstack this adds 179 edges, all to real dotfiles such as `.github/workflows/*.yml` and `.gstack/*.json`. (GST-246, reported from garrytan/truther#956.) diff --git a/lib/pregate/import-graph.ts b/lib/pregate/import-graph.ts index 6f6dc13b7..f251397cd 100644 --- a/lib/pregate/import-graph.ts +++ b/lib/pregate/import-graph.ts @@ -55,7 +55,7 @@ const scanner = new Bun.Transpiler({ loader: 'tsx' }); // `new URL(…, import.meta.url)` or `readFileSync`) resolves from the file that // names it, like a relative import; without it a test reading a template by // relative path had no edge to the template (GSTA-121's test/ship-publication-gates.test.ts). -const QUOTED = /['"`]((?:\.\.?\/)*[A-Za-z0-9_@][A-Za-z0-9_./@()[\]+-]*)['"`]/g; +const QUOTED = /['"`]((?:\.\.?\/)*\.?[A-Za-z0-9_@][A-Za-z0-9_./@()[\]+-]*)['"`]/g; const JOIN = /\bjoin\(([^()]*)\)/g; const RESOLVE_EXT = ['', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mts', '.cts', '/index.ts', '/index.tsx', '/index.js', '/index.jsx', '/index.mjs']; // A statement starts a line or follows `;` or a compound statement's `:`. The module spec is @@ -291,7 +291,7 @@ function resolveCandidate(files: Set, dirs: Set, c: string, lite */ function resolveRefs(files: Set, dirs: Set, fromFile: string, ref: string, literal = false, config: PathAliases = NO_ALIASES): string[] { if (!ref || ref.startsWith('node:') || ref.startsWith('bun:')) return []; - if (ref.startsWith('.')) { const t = resolveCandidate(files, dirs, normalize(path.posix.join(path.posix.dirname(fromFile), ref)), literal); return t ? [t] : []; } + if (/^\.\.?(?:\/|$)/.test(ref)) { const t = resolveCandidate(files, dirs, normalize(path.posix.join(path.posix.dirname(fromFile), ref)), literal); return t ? [t] : []; } const { chosen, others } = matchAliases(config.aliases, ref); for (const c of chosen ?? []) { const t = resolveCandidate(files, dirs, c, literal); if (t) return [t]; } if (!chosen && others.length === 0 && ref.startsWith('@') && !ref.includes('/')) return []; diff --git a/test/pregate-import-graph-paths.test.ts b/test/pregate-import-graph-paths.test.ts index 4631a3662..be7f932e4 100644 --- a/test/pregate-import-graph-paths.test.ts +++ b/test/pregate-import-graph-paths.test.ts @@ -48,6 +48,31 @@ describe('import graph: route-group / dynamic-segment paths and tsconfig path al expect(testsFor(buildGraph(repo.work), 'ship/sections/pr-body.md.tmpl').tests).toEqual(['test/publication.test.ts']); }); + test('a dotfile named by a quoted literal or a join() maps, relative or from the repo root', () => { + const repo = fixture(); + commit(repo, { + '.gitleaks.toml': 'title = "x"\n', + '.github/workflows/ci.yml': 'on: push\n', + 'test/relative.test.ts': "import { readFileSync } from 'node:fs';\nreadFileSync(new URL('../.gitleaks.toml', import.meta.url), 'utf8');\n", + 'test/rooted.test.ts': "import { readFileSync } from 'node:fs';\nreadFileSync('.gitleaks.toml', 'utf8');\n", + 'test/joined.test.ts': "import { join } from 'node:path';\nimport { readFileSync } from 'node:fs';\nreadFileSync(join('.github', 'workflows', 'ci.yml'), 'utf8');\n", + }); + const g = buildGraph(repo.work); + expect(testsFor(g, '.gitleaks.toml').tests).toEqual(['test/relative.test.ts', 'test/rooted.test.ts']); + expect(testsFor(g, '.github/workflows/ci.yml').tests).toEqual(['test/joined.test.ts']); + }); + + test('a dot-led literal that names no repo file adds no edge (extensions, missing dotfiles)', () => { + const repo = fixture(); + commit(repo, { + 'lib/plan.ts': 'export const plan = 1;\n', + 'test/ext.test.ts': "const exts = ['.ts', '.md', '.env', '../.missing.toml', '.github'];\nexport { exts };\n", + }); + const g = buildGraph(repo.work); + expect(testsFor(g, 'lib/plan.ts').tests).toEqual([]); + expect([...g.reverse.values()].flat().filter(e => e.from === 'test/ext.test.ts')).toEqual([]); + }); + test('a relative import of a bracketed path already maps (control: the import scanner is not the bug)', () => { const repo = fixture(); commit(repo, {