From 874aa08aadf600f73fb9f150f0770433cb78587b Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Tue, 18 Aug 2026 19:00:12 -0600 Subject: [PATCH 1/6] fix(cli): distinguish zero exports from an unbuilt graph in exports exportsData conflated "no file-kind node matched at all" (unbuilt/not-found) with "the file is in the graph but legitimately has zero exports" (entry script, side-effect-only module) -- both produced results.length === 0, so fileExports told the user to rebuild even on a correctly built graph. exportsData now also returns fileFound, true whenever a file-kind node matched regardless of its export count. The CLI uses it to print "No exported symbols found for X." without the rebuild suggestion whenever the file was actually found, mirroring the fix applied to roles --role in #2390. docs check acknowledged Closes #2530 Impact: 3 functions changed, 2 affected --- src/domain/analysis/exports.ts | 2 ++ src/presentation/queries-cli/exports.ts | 6 ++++++ tests/integration/exports.test.ts | 12 ++++++++++++ tests/presentation/queries-cli.test.ts | 20 +++++++++++++++++++- 4 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/domain/analysis/exports.ts b/src/domain/analysis/exports.ts index a56aca267..484c0ebca 100644 --- a/src/domain/analysis/exports.ts +++ b/src/domain/analysis/exports.ts @@ -56,6 +56,7 @@ export function exportsData( return paginateResult( { file, + fileFound: false, results: [], reexports: [], reexportedSymbols: [], @@ -74,6 +75,7 @@ export function exportsData( const first = fileResults[0]!; const base = { file: first.file, + fileFound: true, results: first.results, reexports: first.reexports, reexportedSymbols: first.reexportedSymbols, diff --git a/src/presentation/queries-cli/exports.ts b/src/presentation/queries-cli/exports.ts index de915a734..baf2bfdde 100644 --- a/src/presentation/queries-cli/exports.ts +++ b/src/presentation/queries-cli/exports.ts @@ -43,6 +43,8 @@ interface ReexportedSymbol extends ExportSymbol { interface ExportsDataResult { file: string; + /** Whether a file-kind node for this file exists in the graph at all (#2530). */ + fileFound: boolean; totalExported: number; totalInternal: number; totalUnused: number; @@ -151,6 +153,10 @@ export function fileExports(file: string, customDbPath: string, opts: ExportsOpt if (data.results.length === 0 && !hasReexported) { if (opts.unused) { console.log(`No unused exports found for "${file}".`); + } else if (data.fileFound) { + // The file is in the graph — it simply has no exports (an entry + // script, a side-effect-only module, etc.), not an unbuilt graph (#2530). + console.log(`No exported symbols found for "${file}".`); } else { console.log(`No exported symbols found for "${file}". Run "codegraph build" first.`); } diff --git a/tests/integration/exports.test.ts b/tests/integration/exports.test.ts index 64f22bb0b..13c1461e6 100644 --- a/tests/integration/exports.test.ts +++ b/tests/integration/exports.test.ts @@ -63,6 +63,9 @@ beforeAll(() => { const fApp = insertNode(db, 'app.js', 'file', 'app.js', 0); const fBarrel = insertNode(db, 'barrel.js', 'file', 'barrel.js', 0); const fTest = insertNode(db, 'lib.test.js', 'file', 'lib.test.js', 0); + // Entry script with a file-kind node but no exported symbols at all (#2530). + insertNode(db, 'entry.js', 'file', 'entry.js', 0); + insertNode(db, 'runApp', 'function', 'entry.js', 1); // never marked exported // Function nodes in lib.js const add = insertNode(db, 'add', 'function', 'lib.js', 1); @@ -164,6 +167,15 @@ describe('exportsData', () => { expect(data.totalExported).toBe(0); expect(data.totalInternal).toBe(0); expect(data.totalUnused).toBe(0); + // No file-kind node matched at all — genuinely unbuilt/not-found (#2530). + expect(data.fileFound).toBe(false); + }); + + test('fileFound is true for a file in the graph with legitimately zero exports (#2530)', () => { + const data = exportsData('entry.js', dbPath); + expect(data.results).toEqual([]); + expect(data.totalExported).toBe(0); + expect(data.fileFound).toBe(true); }); test('pagination works', () => { diff --git a/tests/presentation/queries-cli.test.ts b/tests/presentation/queries-cli.test.ts index 667920397..64dc65d6d 100644 --- a/tests/presentation/queries-cli.test.ts +++ b/tests/presentation/queries-cli.test.ts @@ -591,9 +591,10 @@ describe('fileExports', () => { expect(out).toContain('from math.js'); }); - it('prints message when no exports found', () => { + it('recommends a rebuild when the file was never found in the graph', () => { mocks.exportsData.mockReturnValue({ file: 'empty.js', + fileFound: false, totalExported: 0, totalInternal: 0, totalUnused: 0, @@ -603,6 +604,23 @@ describe('fileExports', () => { }); fileExports('empty.js', '/db'); expect(output()).toContain('No exported symbols found'); + expect(output()).toContain('Run "codegraph build" first'); + }); + + it('does not recommend a rebuild when the file is in the graph with legitimately zero exports (#2530)', () => { + mocks.exportsData.mockReturnValue({ + file: 'entry.js', + fileFound: true, + totalExported: 0, + totalInternal: 0, + totalUnused: 0, + results: [], + reexportedSymbols: [], + reexports: [], + }); + fileExports('entry.js', '/db'); + expect(output()).toContain('No exported symbols found'); + expect(output()).not.toContain('Run "codegraph build" first'); }); it('prints unused header when opts.unused', () => { From f40a19614237ab2ae2ebdd0a31a0f7a13089166b Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Tue, 18 Aug 2026 19:31:48 -0600 Subject: [PATCH 2/6] fix: exports fileFound requires a plausible match, not any LIKE substring hit findFileNodes uses LIKE '%target%', so a nonexistent target could mid-string-collide with an unrelated file's path (e.g. "add.js" matching "badd.jsx", or "utils.js" matching "my-utils.js") and set fileFound: true for a file the caller never actually asked about, suppressing the rebuild suggestion when it was still warranted. fileFound now requires an exact match or a "/"-bounded path suffix via isPlausibleFileMatch. docs check acknowledged Impact: 2 functions changed, 3 affected --- src/domain/analysis/exports.ts | 15 ++++++++++++++- src/presentation/queries-cli/exports.ts | 6 +++++- tests/integration/exports.test.ts | 15 +++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/domain/analysis/exports.ts b/src/domain/analysis/exports.ts index 484c0ebca..3d5cb69d4 100644 --- a/src/domain/analysis/exports.ts +++ b/src/domain/analysis/exports.ts @@ -27,6 +27,19 @@ const _reexportsToStmtCache: StmtCache<{ file: string }> = new WeakMap(); const _reexportSymbolsStmtCache: StmtCache = new WeakMap(); const _wildcardReexportTargetsStmtCache: StmtCache<{ file: string }> = new WeakMap(); +/** + * Whether `matchedFile` (a result of the `LIKE '%target%'` fuzzy lookup used + * throughout this module) plausibly *is* the file the caller asked for, + * rather than an unrelated file whose path merely contains `target` as a + * substring somewhere in the middle (e.g. target `add.js` mid-string-matching + * `badd.jsx`, or `utils.js` matching `my-utils.js` with no path separator + * before it). Requires an exact match or a `/`-bounded path suffix — the + * only two cases where the match reflects genuine user intent (#2530 review). + */ +function isPlausibleFileMatch(matchedFile: string, target: string): boolean { + return matchedFile === target || matchedFile.endsWith(`/${target}`); +} + export function exportsData( file: string, customDbPath: string, @@ -75,7 +88,7 @@ export function exportsData( const first = fileResults[0]!; const base = { file: first.file, - fileFound: true, + fileFound: isPlausibleFileMatch(first.file, file), results: first.results, reexports: first.reexports, reexportedSymbols: first.reexportedSymbols, diff --git a/src/presentation/queries-cli/exports.ts b/src/presentation/queries-cli/exports.ts index baf2bfdde..db1aa7578 100644 --- a/src/presentation/queries-cli/exports.ts +++ b/src/presentation/queries-cli/exports.ts @@ -43,7 +43,11 @@ interface ReexportedSymbol extends ExportSymbol { interface ExportsDataResult { file: string; - /** Whether a file-kind node for this file exists in the graph at all (#2530). */ + /** + * Whether `file`'s matched node is plausibly the requested target itself, + * not just an unrelated file whose path happens to contain it as a + * substring (#2530). + */ fileFound: boolean; totalExported: number; totalInternal: number; diff --git a/tests/integration/exports.test.ts b/tests/integration/exports.test.ts index 13c1461e6..1edd95560 100644 --- a/tests/integration/exports.test.ts +++ b/tests/integration/exports.test.ts @@ -66,6 +66,11 @@ beforeAll(() => { // Entry script with a file-kind node but no exported symbols at all (#2530). insertNode(db, 'entry.js', 'file', 'entry.js', 0); insertNode(db, 'runApp', 'function', 'entry.js', 1); // never marked exported + // Unrelated file whose path contains "utils.js" as a mid-string substring + // (not a `/`-bounded suffix) and also has zero exports — a false LIKE-query + // collision for a query of "utils.js" that must NOT report fileFound: true + // (#2530 Greptile review). + insertNode(db, 'src/my-utils.js', 'file', 'src/my-utils.js', 0); // Function nodes in lib.js const add = insertNode(db, 'add', 'function', 'lib.js', 1); @@ -178,6 +183,16 @@ describe('exportsData', () => { expect(data.fileFound).toBe(true); }); + test('fileFound is false for a target that only mid-string-collides with an unrelated file (#2530 Greptile review)', () => { + // "utils.js" is not a real file, but "src/my-utils.js" contains it as a + // substring (not a `/`-bounded suffix) and the LIKE '%utils.js%' lookup + // matches it. The rebuild suggestion must still fire here, since this is + // not genuinely the file the caller asked about. + const data = exportsData('utils.js', dbPath); + expect(data.results).toEqual([]); + expect(data.fileFound).toBe(false); + }); + test('pagination works', () => { const data = exportsData('lib.js', dbPath, { limit: 1 }); expect(data.results.length).toBe(1); From f0d1b3331396c89601e0921411bb337612554359 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Tue, 18 Aug 2026 19:57:20 -0600 Subject: [PATCH 3/6] fix: exports prefers a plausible file match over an arbitrary first result findFileNodes has no ORDER BY, so when a target ambiguously LIKE-matches multiple files, fileResults[0] was whichever row SQLite's unordered scan happened to visit first. If an unrelated mid-string collision was inserted before the genuine exact/suffix match, it would win, showing the wrong file's (possibly empty) data and reporting fileFound: false even though a real match existed elsewhere in the result set. exportsData now prefers the first fileResults entry that isPlausibleFileMatch accepts, falling back to the first result only when no candidate is plausible. docs check acknowledged Impact: 1 functions changed, 2 affected --- src/domain/analysis/exports.ts | 8 +++++-- tests/integration/exports.test.ts | 39 ++++++++++++++++++++++++------- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/src/domain/analysis/exports.ts b/src/domain/analysis/exports.ts index 3d5cb69d4..dd29df752 100644 --- a/src/domain/analysis/exports.ts +++ b/src/domain/analysis/exports.ts @@ -84,8 +84,12 @@ export function exportsData( ); } - // For single-file match return flat; for multi-match return first (like explainData) - const first = fileResults[0]!; + // For single-file match return flat; for multi-match prefer a plausible + // match over an arbitrary (unordered LIKE query) first result, so an + // unrelated substring collision returned before the genuine target can't + // shadow it (#2530 Greptile review) — otherwise falls back to the first + // result, like explainData. + const first = fileResults.find((r) => isPlausibleFileMatch(r.file, file)) ?? fileResults[0]!; const base = { file: first.file, fileFound: isPlausibleFileMatch(first.file, file), diff --git a/tests/integration/exports.test.ts b/tests/integration/exports.test.ts index 1edd95560..89fddb7a9 100644 --- a/tests/integration/exports.test.ts +++ b/tests/integration/exports.test.ts @@ -66,11 +66,19 @@ beforeAll(() => { // Entry script with a file-kind node but no exported symbols at all (#2530). insertNode(db, 'entry.js', 'file', 'entry.js', 0); insertNode(db, 'runApp', 'function', 'entry.js', 1); // never marked exported - // Unrelated file whose path contains "utils.js" as a mid-string substring + // Unrelated file whose path contains "widget.js" as a mid-string substring // (not a `/`-bounded suffix) and also has zero exports — a false LIKE-query - // collision for a query of "utils.js" that must NOT report fileFound: true - // (#2530 Greptile review). + // collision for a query of "widget.js" that must NOT report fileFound: + // true, with no genuine match present anywhere in the graph (#2530 + // Greptile review). + insertNode(db, 'src/my-widget.js', 'file', 'src/my-widget.js', 0); + // Same shape, but paired with a genuine match: "src/my-utils.js" (a + // collision for "utils.js", inserted first) and "src/utils.js" (the + // genuine target, inserted after). findFileNodes has no ORDER BY, so an + // unordered LIKE scan would return the collision first unless a plausible + // match is explicitly preferred (#2530 Greptile round 2). insertNode(db, 'src/my-utils.js', 'file', 'src/my-utils.js', 0); + insertNode(db, 'src/utils.js', 'file', 'src/utils.js', 0); // Function nodes in lib.js const add = insertNode(db, 'add', 'function', 'lib.js', 1); @@ -183,16 +191,29 @@ describe('exportsData', () => { expect(data.fileFound).toBe(true); }); - test('fileFound is false for a target that only mid-string-collides with an unrelated file (#2530 Greptile review)', () => { - // "utils.js" is not a real file, but "src/my-utils.js" contains it as a - // substring (not a `/`-bounded suffix) and the LIKE '%utils.js%' lookup - // matches it. The rebuild suggestion must still fire here, since this is - // not genuinely the file the caller asked about. - const data = exportsData('utils.js', dbPath); + test('fileFound is false for a target that only mid-string-collides with an unrelated file, no genuine match present (#2530 Greptile review)', () => { + // "widget.js" is not a real file anywhere in the fixture, but + // "src/my-widget.js" (added below) contains it as a substring (not a + // `/`-bounded suffix). The rebuild suggestion must still fire, since this + // is not genuinely the file the caller asked about. + const data = exportsData('widget.js', dbPath); expect(data.results).toEqual([]); expect(data.fileFound).toBe(false); }); + test('prefers a genuine exact/suffix match over an unordered mid-string collision returned first (#2530 Greptile round 2)', () => { + // findFileNodes has no ORDER BY, so a plain LIKE '%utils.js%' scan + // returns whichever row SQLite visits first. "src/my-utils.js" (a + // collision, no `/`-bounded suffix match) was inserted BEFORE the + // genuine "src/utils.js" — without preferring a plausible match, the + // first (arbitrary) result would win and wrongly report fileFound: false + // plus the wrong file's (empty) data, even though "src/utils.js" is a + // real, matching file in the graph. + const data = exportsData('utils.js', dbPath); + expect(data.file).toBe('src/utils.js'); + expect(data.fileFound).toBe(true); + }); + test('pagination works', () => { const data = exportsData('lib.js', dbPath, { limit: 1 }); expect(data.results.length).toBe(1); From a761eb74c78d40b1fbdaa4cc486f37e967f2e312 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Tue, 18 Aug 2026 20:23:42 -0600 Subject: [PATCH 4/6] fix: isPlausibleFileMatch is case-insensitive, matching SQLite LIKE's default SQLite's LIKE is case-insensitive for ASCII by default, so a case-mismatched target (e.g. "Entry.js" against a stored "entry.js") already passes the LIKE '%target%' lookup used throughout this module. isPlausibleFileMatch's own exact/suffix comparison was case-sensitive, making it stricter than the lookup that decided the match was a hit in the first place -- reintroducing a false "file not found" for a spelling variant SQLite itself already accepted. Both sides are now lowercased before comparing. docs check acknowledged Impact: 1 functions changed, 3 affected --- src/domain/analysis/exports.ts | 10 +++++++++- tests/integration/exports.test.ts | 10 ++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/domain/analysis/exports.ts b/src/domain/analysis/exports.ts index dd29df752..1eab047da 100644 --- a/src/domain/analysis/exports.ts +++ b/src/domain/analysis/exports.ts @@ -35,9 +35,17 @@ const _wildcardReexportTargetsStmtCache: StmtCache<{ file: string }> = new WeakM * `badd.jsx`, or `utils.js` matching `my-utils.js` with no path separator * before it). Requires an exact match or a `/`-bounded path suffix — the * only two cases where the match reflects genuine user intent (#2530 review). + * + * Case-insensitive to match SQLite's own default `LIKE` behavior (ASCII + * case-insensitive) — otherwise a target that only differs from the graph's + * stored casing would pass the LIKE lookup but fail this stricter check, + * reintroducing a false "not found" for a match SQLite itself already deemed + * a hit (#2530 review round 3). */ function isPlausibleFileMatch(matchedFile: string, target: string): boolean { - return matchedFile === target || matchedFile.endsWith(`/${target}`); + const a = matchedFile.toLowerCase(); + const b = target.toLowerCase(); + return a === b || a.endsWith(`/${b}`); } export function exportsData( diff --git a/tests/integration/exports.test.ts b/tests/integration/exports.test.ts index 89fddb7a9..41885db90 100644 --- a/tests/integration/exports.test.ts +++ b/tests/integration/exports.test.ts @@ -214,6 +214,16 @@ describe('exportsData', () => { expect(data.fileFound).toBe(true); }); + test("fileFound is case-insensitive, matching SQLite LIKE's own default case-insensitivity (#2530 Greptile round 3)", () => { + // SQLite's LIKE is case-insensitive for ASCII by default, so a + // case-mismatched target ("Entry.js") still LIKE-matches the stored + // "entry.js" -- isPlausibleFileMatch must not be stricter than the LIKE + // lookup that already decided this is a hit. + const data = exportsData('Entry.js', dbPath); + expect(data.file).toBe('entry.js'); + expect(data.fileFound).toBe(true); + }); + test('pagination works', () => { const data = exportsData('lib.js', dbPath, { limit: 1 }); expect(data.results.length).toBe(1); From e7f0e0f6c9269645d4782b0a6296b0f31a6d13b5 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Tue, 18 Aug 2026 21:13:05 -0600 Subject: [PATCH 5/6] fix: exports prefers an exact-case match and stops contradicting fileFound Two related gaps in the fuzzy file-match selection: - fileFound could be false while first.results was non-empty (a real collision file with real exports, but no plausible candidate at all) -- a self-contradictory signal for structured/JSON consumers even though the CLI's own text messaging never reads fileFound once results are non-empty. fileFound is now also true whenever first has real results or reexports. - isPlausibleFileMatch's case-insensitivity (needed to match SQLite LIKE's own default behavior) meant two distinct real files differing only by case could both look plausible for the same target, and the unordered LIKE scan could return the wrong one. exportsData now prefers an exact (case-sensitive) match via isExactFileMatch before falling back to the case-insensitive check. docs check acknowledged Impact: 2 functions changed, 3 affected --- src/domain/analysis/exports.ts | 41 ++++++++++++++++++++++++------ tests/integration/exports.test.ts | 42 +++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 8 deletions(-) diff --git a/src/domain/analysis/exports.ts b/src/domain/analysis/exports.ts index 1eab047da..0f13701c2 100644 --- a/src/domain/analysis/exports.ts +++ b/src/domain/analysis/exports.ts @@ -27,6 +27,18 @@ const _reexportsToStmtCache: StmtCache<{ file: string }> = new WeakMap(); const _reexportSymbolsStmtCache: StmtCache = new WeakMap(); const _wildcardReexportTargetsStmtCache: StmtCache<{ file: string }> = new WeakMap(); +/** + * Whether `matchedFile` is exactly (case-sensitively) the file the caller + * asked for, or a `/`-bounded path suffix of it. Checked before the + * case-insensitive variant below so that a genuine exact-case match always + * wins over a merely case-insensitive one when both exist among the + * candidates (e.g. distinct real files `src/utils.js` and `src/UTILS.js` on + * a case-sensitive filesystem — #2530 review round 5). + */ +function isExactFileMatch(matchedFile: string, target: string): boolean { + return matchedFile === target || matchedFile.endsWith(`/${target}`); +} + /** * Whether `matchedFile` (a result of the `LIKE '%target%'` fuzzy lookup used * throughout this module) plausibly *is* the file the caller asked for, @@ -40,7 +52,8 @@ const _wildcardReexportTargetsStmtCache: StmtCache<{ file: string }> = new WeakM * case-insensitive) — otherwise a target that only differs from the graph's * stored casing would pass the LIKE lookup but fail this stricter check, * reintroducing a false "not found" for a match SQLite itself already deemed - * a hit (#2530 review round 3). + * a hit (#2530 review round 3). Callers selecting among multiple candidates + * should prefer `isExactFileMatch` first (round 5). */ function isPlausibleFileMatch(matchedFile: string, target: string): boolean { const a = matchedFile.toLowerCase(); @@ -92,15 +105,27 @@ export function exportsData( ); } - // For single-file match return flat; for multi-match prefer a plausible - // match over an arbitrary (unordered LIKE query) first result, so an - // unrelated substring collision returned before the genuine target can't - // shadow it (#2530 Greptile review) — otherwise falls back to the first - // result, like explainData. - const first = fileResults.find((r) => isPlausibleFileMatch(r.file, file)) ?? fileResults[0]!; + // For single-file match return flat; for multi-match prefer, in order: an + // exact-case match, then a case-insensitive/suffix match, then an + // arbitrary (unordered LIKE query) first result — so neither an unrelated + // substring collision nor a case-variant of a different real file can + // shadow the genuine target (#2530 Greptile review) — otherwise falls + // back to the first result, like explainData. + const first = + fileResults.find((r) => isExactFileMatch(r.file, file)) ?? + fileResults.find((r) => isPlausibleFileMatch(r.file, file)) ?? + fileResults[0]!; + // fileFound must stay true whenever we're actually returning real + // content (results or reexports) for `first`, even if that content came + // from the unordered-fallback branch above with no plausible candidate — + // otherwise structured (JSON/MCP) consumers see the self-contradictory + // combination of non-empty results alongside fileFound: false (#2530 + // review round 5). The CLI's own messaging only ever reads fileFound + // inside the `results.length === 0` branch, so this has no effect there. + const hasContent = first.results.length > 0 || first.reexportedSymbols.length > 0; const base = { file: first.file, - fileFound: isPlausibleFileMatch(first.file, file), + fileFound: hasContent || isPlausibleFileMatch(first.file, file), results: first.results, reexports: first.reexports, reexportedSymbols: first.reexportedSymbols, diff --git a/tests/integration/exports.test.ts b/tests/integration/exports.test.ts index 41885db90..352bcdf38 100644 --- a/tests/integration/exports.test.ts +++ b/tests/integration/exports.test.ts @@ -79,6 +79,22 @@ beforeAll(() => { // match is explicitly preferred (#2530 Greptile round 2). insertNode(db, 'src/my-utils.js', 'file', 'src/my-utils.js', 0); insertNode(db, 'src/utils.js', 'file', 'src/utils.js', 0); + // A collision file WITH real exports, for a target with no plausible + // candidate at all ("gadget.js" -> "src/my-gadget.js"). fileFound must + // still be true here, since real (non-empty) results are being returned — + // fileFound: false alongside non-empty results is a self-contradictory + // signal for structured/JSON consumers (#2530 Greptile round 5). + insertNode(db, 'src/my-gadget.js', 'file', 'src/my-gadget.js', 0); + const gadgetExport = insertNode(db, 'buildGadget', 'function', 'src/my-gadget.js', 1); + // Two distinct real files differing only by case, to test that an + // exact-case match always wins over a same-name case-insensitive one + // (#2530 Greptile round 5). The case-different file is inserted FIRST so + // an unordered LIKE scan visits it before the exact-case match, forcing a + // naive case-insensitive-only `.find()` to pick the wrong file. + insertNode(db, 'src/CaseDemo.js', 'file', 'src/CaseDemo.js', 0); + const upperCaseExport = insertNode(db, 'upperCaseFn', 'function', 'src/CaseDemo.js', 1); + insertNode(db, 'src/casedemo.js', 'file', 'src/casedemo.js', 0); + const lowerCaseExport = insertNode(db, 'lowerCaseFn', 'function', 'src/casedemo.js', 1); // Function nodes in lib.js const add = insertNode(db, 'add', 'function', 'lib.js', 1); @@ -97,6 +113,9 @@ beforeAll(() => { markExported.run(add); markExported.run(multiply); markExported.run(unusedFn); + markExported.run(gadgetExport); + markExported.run(lowerCaseExport); + markExported.run(upperCaseExport); // Import edges insertEdge(db, fApp, fLib, 'imports'); @@ -224,6 +243,29 @@ describe('exportsData', () => { expect(data.fileFound).toBe(true); }); + test('fileFound is true when the unordered fallback still returns real, non-empty results (#2530 Greptile round 5)', () => { + // "gadget.js" has no plausible candidate at all -- only the collision + // "src/my-gadget.js" LIKE-matches it, and that collision has a REAL + // export. fileFound must not be false here: false alongside non-empty + // results is a self-contradictory signal for structured/JSON consumers, + // even though the CLI's own text messaging never reads fileFound when + // results are non-empty (the results.length === 0 branch is never hit). + const data = exportsData('gadget.js', dbPath); + expect(data.file).toBe('src/my-gadget.js'); + expect(data.results.length).toBeGreaterThan(0); + expect(data.fileFound).toBe(true); + }); + + test('prefers an exact-case match over a same-name case-insensitive match (#2530 Greptile round 5)', () => { + // "src/casedemo.js" and "src/CaseDemo.js" are two DISTINCT real files. + // A query for the exact-case "casedemo.js" must resolve to + // "src/casedemo.js", never to its differently-cased sibling, even though + // isPlausibleFileMatch's case-insensitivity (round 3) would accept both. + const data = exportsData('casedemo.js', dbPath); + expect(data.file).toBe('src/casedemo.js'); + expect(data.results.map((r) => r.name)).toEqual(['lowerCaseFn']); + }); + test('pagination works', () => { const data = exportsData('lib.js', dbPath, { limit: 1 }); expect(data.results.length).toBe(1); From b7e99ed194beb83d5540d797d57c323be5630b89 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Tue, 18 Aug 2026 21:39:22 -0600 Subject: [PATCH 6/6] fix: exports treats no-plausible-match as not-found, not an arbitrary fallback The previous fix (hasContent) resolved the fileFound/non-empty-results contradiction by making fileFound true whenever an unordered fallback still had real content -- but that fallback could be an entirely unrelated substring collision, so a missing target could still surface a different file's real exports under fileFound: true. Requiring a plausible candidate (isExactFileMatch or isPlausibleFileMatch) to exist at all, and treating its absence exactly like fileResults.length === 0, removes the arbitrary fallback entirely: nothing plausible now means nothing is returned, full stop, matching a genuinely missing file's behavior. fileFound is then trivially always true once `first` exists, since it can only exist when one of those checks already accepted it. docs check acknowledged Impact: 1 functions changed, 2 affected --- src/domain/analysis/exports.ts | 36 ++++++++++++++----------------- tests/integration/exports.test.ts | 17 ++++++++------- 2 files changed, 25 insertions(+), 28 deletions(-) diff --git a/src/domain/analysis/exports.ts b/src/domain/analysis/exports.ts index 0f13701c2..3ecb7905c 100644 --- a/src/domain/analysis/exports.ts +++ b/src/domain/analysis/exports.ts @@ -86,7 +86,18 @@ export function exportsData( const unused = opts.unused || false; const fileResults = exportsFileImpl(db, file, noTests, getFileLines, unused, displayOpts); - if (fileResults.length === 0) { + // Prefer, in order: an exact-case match, then a case-insensitive/suffix + // match. If NEITHER exists among the LIKE lookup's results, treat this + // exactly like "no file found at all" rather than falling back to an + // arbitrary unrelated substring collision's real data — returning that + // collision's export payload under a mismatched query is misleading + // regardless of what `fileFound` says about it (#2530 Greptile round 6), + // and it can never be a plausible answer to what was actually asked. + const first = + fileResults.find((r) => isExactFileMatch(r.file, file)) ?? + fileResults.find((r) => isPlausibleFileMatch(r.file, file)); + + if (!first) { return paginateResult( { file, @@ -105,27 +116,12 @@ export function exportsData( ); } - // For single-file match return flat; for multi-match prefer, in order: an - // exact-case match, then a case-insensitive/suffix match, then an - // arbitrary (unordered LIKE query) first result — so neither an unrelated - // substring collision nor a case-variant of a different real file can - // shadow the genuine target (#2530 Greptile review) — otherwise falls - // back to the first result, like explainData. - const first = - fileResults.find((r) => isExactFileMatch(r.file, file)) ?? - fileResults.find((r) => isPlausibleFileMatch(r.file, file)) ?? - fileResults[0]!; - // fileFound must stay true whenever we're actually returning real - // content (results or reexports) for `first`, even if that content came - // from the unordered-fallback branch above with no plausible candidate — - // otherwise structured (JSON/MCP) consumers see the self-contradictory - // combination of non-empty results alongside fileFound: false (#2530 - // review round 5). The CLI's own messaging only ever reads fileFound - // inside the `results.length === 0` branch, so this has no effect there. - const hasContent = first.results.length > 0 || first.reexportedSymbols.length > 0; const base = { file: first.file, - fileFound: hasContent || isPlausibleFileMatch(first.file, file), + // Always true here: `first` only exists when isExactFileMatch or + // isPlausibleFileMatch accepted it, so this can never contradict + // non-empty results the way an unconditional plausibility check could. + fileFound: true, results: first.results, reexports: first.reexports, reexportedSymbols: first.reexportedSymbols, diff --git a/tests/integration/exports.test.ts b/tests/integration/exports.test.ts index 352bcdf38..fadf4cf60 100644 --- a/tests/integration/exports.test.ts +++ b/tests/integration/exports.test.ts @@ -243,17 +243,18 @@ describe('exportsData', () => { expect(data.fileFound).toBe(true); }); - test('fileFound is true when the unordered fallback still returns real, non-empty results (#2530 Greptile round 5)', () => { + test('treats a collision with no plausible candidate as not-found, even when the collision itself has real exports (#2530 Greptile round 6)', () => { // "gadget.js" has no plausible candidate at all -- only the collision // "src/my-gadget.js" LIKE-matches it, and that collision has a REAL - // export. fileFound must not be false here: false alongside non-empty - // results is a self-contradictory signal for structured/JSON consumers, - // even though the CLI's own text messaging never reads fileFound when - // results are non-empty (the results.length === 0 branch is never hit). + // export. Earlier this fell back to returning the collision's real data + // with fileFound: true, which is misleading regardless of what fileFound + // says (round 6) -- and fileFound: false alongside non-empty results was + // self-contradictory before that (round 5). Requiring a plausible + // candidate to exist at all resolves both: nothing plausible means + // nothing is returned, full stop, exactly like a genuinely missing file. const data = exportsData('gadget.js', dbPath); - expect(data.file).toBe('src/my-gadget.js'); - expect(data.results.length).toBeGreaterThan(0); - expect(data.fileFound).toBe(true); + expect(data.results).toEqual([]); + expect(data.fileFound).toBe(false); }); test('prefers an exact-case match over a same-name case-insensitive match (#2530 Greptile round 5)', () => {