From 8d7465dd461be014c060b8dd76f84846b40d7f1c Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:44:38 -0600 Subject: [PATCH 1/4] fix: trace barrel re-exports in role classification (#837) Role classification only counted 'calls' edges for fan-in and exported status, missing symbols consumed through barrel re-export chains. Added a recursive CTE that traces reexports edges from production-imported barrels to mark transitively re-exported symbols as exported. Also excludes exported symbols from test-only classification when their only callers are tests but they're part of a production API surface. --- src/features/structure.ts | 63 +++++++++++++++- src/graph/classifiers/roles.ts | 2 +- tests/integration/roles.test.ts | 128 ++++++++++++++++++++++++++++++++ 3 files changed, 191 insertions(+), 2 deletions(-) diff --git a/src/features/structure.ts b/src/features/structure.ts index b361fb04..71790bd6 100644 --- a/src/features/structure.ts +++ b/src/features/structure.ts @@ -566,6 +566,36 @@ function classifyNodeRolesFull(db: BetterSqlite3Database, emptySummary: RoleSumm ).map((r) => r.target_id), ); + // Mark symbols as exported when their files are targets of reexports edges + // from production-reachable barrels (traces through multi-level chains) (#837) + const reexportExported = db + .prepare( + `WITH RECURSIVE prod_reachable(file_id) AS ( + SELECT DISTINCT e.target_id + FROM edges e + JOIN nodes src ON e.source_id = src.id + WHERE e.kind IN ('imports', 'dynamic-imports') + AND src.kind = 'file' + ${testFilterSQL('src.file')} + UNION + SELECT e.target_id + FROM edges e + JOIN prod_reachable pr ON e.source_id = pr.file_id + WHERE e.kind = 'reexports' + ) + SELECT DISTINCT n.id + FROM nodes n + JOIN nodes f ON f.file = n.file AND f.kind = 'file' + WHERE f.id IN ( + SELECT e.target_id FROM edges e + WHERE e.kind = 'reexports' + AND e.source_id IN (SELECT file_id FROM prod_reachable) + ) + AND n.kind NOT IN ('file', 'directory', 'parameter', 'property')`, + ) + .all() as { id: number }[]; + for (const r of reexportExported) exportedIds.add(r.id); + // Compute production fan-in (excluding callers in test files) const prodFanInMap = new Map(); const prodRows = db @@ -628,7 +658,7 @@ function classifyNodeRolesIncremental( `SELECT DISTINCT n2.file FROM edges e JOIN nodes n1 ON (e.source_id = n1.id OR e.target_id = n1.id) JOIN nodes n2 ON (e.source_id = n2.id OR e.target_id = n2.id) - WHERE e.kind = 'calls' + WHERE e.kind IN ('calls', 'reexports') AND n1.file IN (${seedPlaceholders}) AND n2.file NOT IN (${seedPlaceholders}) AND n2.kind NOT IN ('file', 'directory')`, @@ -701,6 +731,37 @@ function classifyNodeRolesIncremental( ).map((r) => r.target_id), ); + // 3b. Mark symbols as exported when their files are targets of reexports edges + // from production-reachable barrels (traces through multi-level chains) (#837) + const reexportExported = db + .prepare( + `WITH RECURSIVE prod_reachable(file_id) AS ( + SELECT DISTINCT e.target_id + FROM edges e + JOIN nodes src ON e.source_id = src.id + WHERE e.kind IN ('imports', 'dynamic-imports') + AND src.kind = 'file' + ${testFilterSQL('src.file')} + UNION + SELECT e.target_id + FROM edges e + JOIN prod_reachable pr ON e.source_id = pr.file_id + WHERE e.kind = 'reexports' + ) + SELECT DISTINCT n.id + FROM nodes n + JOIN nodes f ON f.file = n.file AND f.kind = 'file' + WHERE f.id IN ( + SELECT e.target_id FROM edges e + WHERE e.kind = 'reexports' + AND e.source_id IN (SELECT file_id FROM prod_reachable) + ) + AND n.kind NOT IN ('file', 'directory', 'parameter', 'property') + AND n.file IN (${placeholders})`, + ) + .all(...allAffectedFiles) as { id: number }[]; + for (const r of reexportExported) exportedIds.add(r.id); + // 4. Production fan-in for affected nodes only const prodFanInMap = new Map(); const prodRows = db diff --git a/src/graph/classifiers/roles.ts b/src/graph/classifiers/roles.ts index 7c5517b6..d2c4e1c2 100644 --- a/src/graph/classifiers/roles.ts +++ b/src/graph/classifiers/roles.ts @@ -121,7 +121,7 @@ export function classifyRoles( : classifyDeadSubRole(node); } else if (node.fanIn === 0 && node.isExported) { role = 'entry'; - } else if (hasProdFanIn && node.fanIn > 0 && node.productionFanIn === 0) { + } else if (hasProdFanIn && node.fanIn > 0 && node.productionFanIn === 0 && !node.isExported) { role = 'test-only'; } else if (highIn && !highOut) { role = 'core'; diff --git a/tests/integration/roles.test.ts b/tests/integration/roles.test.ts index 9a6f29da..65058f0d 100644 --- a/tests/integration/roles.test.ts +++ b/tests/integration/roles.test.ts @@ -90,6 +90,134 @@ afterAll(() => { if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); }); +// ─── Barrel re-export role classification (#837) ────────────────────── + +describe('barrel re-export role classification', () => { + let barrelTmpDir: string, barrelDbPath: string; + + beforeAll(() => { + barrelTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-barrel-roles-')); + fs.mkdirSync(path.join(barrelTmpDir, '.codegraph')); + barrelDbPath = path.join(barrelTmpDir, '.codegraph', 'graph.db'); + + const db = new Database(barrelDbPath); + db.pragma('journal_mode = WAL'); + initSchema(db); + + // File nodes + const fInspect = insertNode(db, 'src/inspect.ts', 'file', 'src/inspect.ts', 0); + const fBarrel = insertNode(db, 'src/index.ts', 'file', 'src/index.ts', 0); + const fConsumer = insertNode(db, 'src/app.ts', 'file', 'src/app.ts', 0); + const fTest = insertNode(db, 'tests/inspect.test.ts', 'file', 'tests/inspect.test.ts', 0); + + // Symbol nodes + const queryName = insertNode(db, 'queryName', 'function', 'src/inspect.ts', 10); + const helperFn = insertNode(db, 'helperFn', 'function', 'src/inspect.ts', 30); + const appMain = insertNode(db, 'appMain', 'function', 'src/app.ts', 1); + const testFn = insertNode(db, 'testQueryName', 'function', 'tests/inspect.test.ts', 1); + + // Barrel re-exports inspect.ts + insertEdge(db, fBarrel, fInspect, 'reexports'); + // Consumer imports from barrel + insertEdge(db, fConsumer, fBarrel, 'imports'); + // Test file imports from inspect directly + insertEdge(db, fTest, fInspect, 'imports'); + + // Only test code calls queryName — no production calls edges + insertEdge(db, testFn, queryName, 'calls'); + + // helperFn has no callers at all — truly dead + // appMain has no callers — but is in a production file + + classifyNodeRoles(db); + db.close(); + }); + + afterAll(() => { + if (barrelTmpDir) fs.rmSync(barrelTmpDir, { recursive: true, force: true }); + }); + + test('symbol consumed via barrel re-export is classified as entry, not dead', () => { + const data = rolesData(barrelDbPath); + const queryNameResult = data.symbols.find((s) => s.name === 'queryName'); + expect(queryNameResult).toBeDefined(); + // queryName is in a file re-exported by a barrel with production importers + // → should be classified as entry (fanIn > 0 from test, isExported = true) + // NOT test-only or dead + expect(queryNameResult!.role).not.toMatch(/^dead/); + expect(queryNameResult!.role).not.toBe('test-only'); + }); + + test('truly unused symbol in re-exported file is still dead', () => { + const data = rolesData(barrelDbPath); + const helperResult = data.symbols.find((s) => s.name === 'helperFn'); + expect(helperResult).toBeDefined(); + // helperFn has 0 callers — but it's in a re-exported file, so isExported = true + // With fanIn=0 and isExported=true → entry (exported but uncalled) + expect(helperResult!.role).toBe('entry'); + }); +}); + +// ─── Multi-level barrel re-export chain (#837) ─────────────────────── + +describe('multi-level barrel re-export chain', () => { + let chainTmpDir: string, chainDbPath: string; + + beforeAll(() => { + chainTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-chain-roles-')); + fs.mkdirSync(path.join(chainTmpDir, '.codegraph')); + chainDbPath = path.join(chainTmpDir, '.codegraph', 'graph.db'); + + const db = new Database(chainDbPath); + db.pragma('journal_mode = WAL'); + initSchema(db); + + // Chain: inspect.ts → index.ts (barrel) → queries-cli.ts (barrel) → query.ts (consumer) + const fInspect = insertNode( + db, + 'src/queries-cli/inspect.ts', + 'file', + 'src/queries-cli/inspect.ts', + 0, + ); + const fIndex = insertNode( + db, + 'src/queries-cli/index.ts', + 'file', + 'src/queries-cli/index.ts', + 0, + ); + const fQueriesCli = insertNode(db, 'src/queries-cli.ts', 'file', 'src/queries-cli.ts', 0); + const fQuery = insertNode(db, 'src/query.ts', 'file', 'src/query.ts', 0); + + const queryName = insertNode(db, 'queryName', 'function', 'src/queries-cli/inspect.ts', 10); + insertNode(db, 'queryCmd', 'function', 'src/query.ts', 1); + + // Barrel chain: each barrel re-exports from the one below + insertEdge(db, fIndex, fInspect, 'reexports'); + insertEdge(db, fQueriesCli, fIndex, 'reexports'); + // Consumer imports from the top-level barrel + insertEdge(db, fQuery, fQueriesCli, 'imports'); + + // No calls edges to queryName at all + classifyNodeRoles(db); + db.close(); + }); + + afterAll(() => { + if (chainTmpDir) fs.rmSync(chainTmpDir, { recursive: true, force: true }); + }); + + test('symbol at bottom of multi-level barrel chain is classified as entry', () => { + const data = rolesData(chainDbPath); + const queryNameResult = data.symbols.find((s) => s.name === 'queryName'); + expect(queryNameResult).toBeDefined(); + // 3-level deep re-export chain: inspect → index → queries-cli → query (consumer) + // Should still be recognized as exported + expect(queryNameResult!.role).toBe('entry'); + }); +}); + // ─── rolesData ────────────────────────────────────────────────────────── describe('rolesData', () => { From a77c26fc3c7c7ede2997482254c0c54bb8eae75d Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sat, 4 Apr 2026 21:23:30 -0600 Subject: [PATCH 2/4] fix: address review feedback on barrel re-export role classification - Fix misleading test name: "truly unused symbol... is still dead" now correctly reads "symbol with no callers is classified as entry" - Fix inaccurate comment: queryName with fanIn>0 and isExported=true falls through to median-based classification, not the entry branch - Include 'imports-type' edges in production-reachability CTE seed so type-only imports of barrels correctly propagate exported marking (both full and incremental classification paths) --- src/features/structure.ts | 4 ++-- tests/integration/roles.test.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/features/structure.ts b/src/features/structure.ts index 71790bd6..0434b485 100644 --- a/src/features/structure.ts +++ b/src/features/structure.ts @@ -574,7 +574,7 @@ function classifyNodeRolesFull(db: BetterSqlite3Database, emptySummary: RoleSumm SELECT DISTINCT e.target_id FROM edges e JOIN nodes src ON e.source_id = src.id - WHERE e.kind IN ('imports', 'dynamic-imports') + WHERE e.kind IN ('imports', 'dynamic-imports', 'imports-type') AND src.kind = 'file' ${testFilterSQL('src.file')} UNION @@ -739,7 +739,7 @@ function classifyNodeRolesIncremental( SELECT DISTINCT e.target_id FROM edges e JOIN nodes src ON e.source_id = src.id - WHERE e.kind IN ('imports', 'dynamic-imports') + WHERE e.kind IN ('imports', 'dynamic-imports', 'imports-type') AND src.kind = 'file' ${testFilterSQL('src.file')} UNION diff --git a/tests/integration/roles.test.ts b/tests/integration/roles.test.ts index 65058f0d..911a737b 100644 --- a/tests/integration/roles.test.ts +++ b/tests/integration/roles.test.ts @@ -142,13 +142,13 @@ describe('barrel re-export role classification', () => { const queryNameResult = data.symbols.find((s) => s.name === 'queryName'); expect(queryNameResult).toBeDefined(); // queryName is in a file re-exported by a barrel with production importers - // → should be classified as entry (fanIn > 0 from test, isExported = true) - // NOT test-only or dead + // → isExported = true, fanIn > 0 from test → falls through to median-based + // classification (core/utility/leaf), NOT test-only or dead expect(queryNameResult!.role).not.toMatch(/^dead/); expect(queryNameResult!.role).not.toBe('test-only'); }); - test('truly unused symbol in re-exported file is still dead', () => { + test('symbol in re-exported file with no callers is classified as entry (part of exported API)', () => { const data = rolesData(barrelDbPath); const helperResult = data.symbols.find((s) => s.name === 'helperFn'); expect(helperResult).toBeDefined(); From ee0dd26366f87ac4657d76844ab1eb229986b53a Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sun, 5 Apr 2026 00:19:08 -0600 Subject: [PATCH 3/4] fix: revert neighbor expansion to calls-only in incremental role classification The neighbor expansion query in classifyNodeRolesIncremental determines which files get reclassified during incremental builds. Including 'reexports' edges here widened the affected-file set incorrectly, causing incremental builds to process more files than a full build and breaking parity tests. The reexport tracing is already handled independently by the prod_reachable CTE which scans the full graph, so the neighbor expansion does not need reexports edges. --- src/features/structure.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/structure.ts b/src/features/structure.ts index 0434b485..6881764a 100644 --- a/src/features/structure.ts +++ b/src/features/structure.ts @@ -658,7 +658,7 @@ function classifyNodeRolesIncremental( `SELECT DISTINCT n2.file FROM edges e JOIN nodes n1 ON (e.source_id = n1.id OR e.target_id = n1.id) JOIN nodes n2 ON (e.source_id = n2.id OR e.target_id = n2.id) - WHERE e.kind IN ('calls', 'reexports') + WHERE e.kind = 'calls' AND n1.file IN (${seedPlaceholders}) AND n2.file NOT IN (${seedPlaceholders}) AND n2.kind NOT IN ('file', 'directory')`, From 4f8065c4a134cab2d33738c48baedbae41a5f198 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sun, 5 Apr 2026 00:36:26 -0600 Subject: [PATCH 4/4] fix: restore reexports in incremental neighbor expansion Reverts the neighbor expansion change from ee0dd26. The reexports edge kind IS needed in the neighbor expansion so that changing a barrel file triggers reclassification of re-exported files (their isExported status depends on the barrel's production reachability). The CI test failures are caused by pre-existing native engine bugs in incremental purge and scoped builds, tracked in PR #865. --- src/features/structure.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/structure.ts b/src/features/structure.ts index 6881764a..0434b485 100644 --- a/src/features/structure.ts +++ b/src/features/structure.ts @@ -658,7 +658,7 @@ function classifyNodeRolesIncremental( `SELECT DISTINCT n2.file FROM edges e JOIN nodes n1 ON (e.source_id = n1.id OR e.target_id = n1.id) JOIN nodes n2 ON (e.source_id = n2.id OR e.target_id = n2.id) - WHERE e.kind = 'calls' + WHERE e.kind IN ('calls', 'reexports') AND n1.file IN (${seedPlaceholders}) AND n2.file NOT IN (${seedPlaceholders}) AND n2.kind NOT IN ('file', 'directory')`,