pregate: dotfile literals as their own pass; dot-led literals also resolve from the naming file's folder

Review fixes for #3661: the optional dot in QUOTED let a closing quote followed
by .method( consume the next literal's opening quote, dropping an edge main
found; and join(__dirname, '.github', ...) lost its fixture edge. Regression
test fails at acd2274.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-31 01:33:09 +00:00
1 parent acd22749b1
commit ac506f70ec
3 files changed
+22 -3

No files matched your search

+1 -1
View File
@@ -1 +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.) - **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 both the repository root and the folder of the file that names it, so a fixture's own `.github/` keeps its edge. 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 (`buildGraph` over this repository: 16,934 on main 2127729, 17,113 with this change, none removed), all to real dotfiles such as `.github/workflows/*.yml` and `.gstack/*.json`. (GST-246, reported from garrytan/truther#956.)
+6 -2
View File
@@ -55,7 +55,10 @@ const scanner = new Bun.Transpiler({ loader: 'tsx' });
// `new URL(…, import.meta.url)` or `readFileSync`) resolves from the file that // `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 // 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). // 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;
// A dot-led literal (`'.gitleaks.toml'`, `'../.github/x.yml'`) runs as its own pass and leaves its
// closing quote unconsumed, so a closing quote followed by `.method(` never eats the next literal's quote.
const DOTFILE = /['"`]((?:\.\.?\/)*\.[A-Za-z0-9_@][A-Za-z0-9_./@()[\]+-]*)(?=['"`])/g;
const JOIN = /\bjoin\(([^()]*)\)/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']; 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 // A statement starts a line or follows `;` or a compound statement's `:`. The module spec is
@@ -295,7 +298,7 @@ function resolveRefs(files: Set<string>, dirs: Set<string>, fromFile: string, re
const { chosen, others } = matchAliases(config.aliases, ref); const { chosen, others } = matchAliases(config.aliases, ref);
for (const c of chosen ?? []) { const t = resolveCandidate(files, dirs, c, literal); if (t) return [t]; } 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 []; if (!chosen && others.length === 0 && ref.startsWith('@') && !ref.includes('/')) return [];
const plausible = [...others, ...(config.baseUrl ? [normalize(path.posix.join(config.baseUrl, ref))] : []), normalize(ref)]; const plausible = [...others, ...(config.baseUrl ? [normalize(path.posix.join(config.baseUrl, ref))] : []), normalize(ref), ...(literal && ref.startsWith('.') ? [normalize(path.posix.join(path.posix.dirname(fromFile), ref))] : [])];
return [...new Set(plausible.map(c => resolveCandidate(files, dirs, c, literal)).filter((t): t is string => t !== null))]; return [...new Set(plausible.map(c => resolveCandidate(files, dirs, c, literal)).filter((t): t is string => t !== null))];
} }
@@ -429,6 +432,7 @@ export function referencesIn(files: Set<string>, dirs: Set<string>, rel: string,
// sources (a test spawns a bin; a bash bin calls a sibling). Prose and // sources (a test spawns a bin; a bash bin calls a sibling). Prose and
// manifests name everything and would make every file a hub. // manifests name everything and would make every file a hub.
if (!scansLiterals(rel)) return out; if (!scansLiterals(rel)) return out;
for (const m of source.matchAll(DOTFILE)) for (const target of resolveRefs(files, dirs, rel, m[1]!, true, config)) out.push({ target, via: 'literal' });
for (const m of source.matchAll(QUOTED)) { for (const m of source.matchAll(QUOTED)) {
const targets = resolveRefs(files, dirs, rel, m[1]!, true, config); const targets = resolveRefs(files, dirs, rel, m[1]!, true, config);
for (const target of targets) out.push({ target, via: 'literal' }); for (const target of targets) out.push({ target, via: 'literal' });
+15
View File
@@ -62,6 +62,21 @@ describe('import graph: route-group / dynamic-segment paths and tsconfig path al
expect(testsFor(g, '.github/workflows/ci.yml').tests).toEqual(['test/joined.test.ts']); expect(testsFor(g, '.github/workflows/ci.yml').tests).toEqual(['test/joined.test.ts']);
}); });
test('a dot-led literal keeps the edges the base found: no swallowed quote, fixture dotfiles still map', () => {
const repo = fixture();
commit(repo, {
'test/fixtures/x.md': 'x\n',
'.github/workflows/ci.yml': 'on: push\n',
'test/.github/workflows/ci.yml': 'on: push\n',
'test/method.test.ts': "const d = 'a';\nexport const hit = `${d}`.includes('test/fixtures/x.md');\n",
'test/config.test.ts': "import { join } from 'node:path';\nimport { readFileSync } from 'node:fs';\nreadFileSync(join(__dirname, '.github', 'workflows', 'ci.yml'), 'utf8');\n",
});
const g = buildGraph(repo.work);
expect(testsFor(g, 'test/fixtures/x.md').tests).toEqual(['test/method.test.ts']);
expect(testsFor(g, 'test/.github/workflows/ci.yml').tests).toEqual(['test/config.test.ts']);
expect(testsFor(g, '.github/workflows/ci.yml').tests).toEqual(['test/config.test.ts']);
});
test('a dot-led literal that names no repo file adds no edge (extensions, missing dotfiles)', () => { test('a dot-led literal that names no repo file adds no edge (extensions, missing dotfiles)', () => {
const repo = fixture(); const repo = fixture();
commit(repo, { commit(repo, {