Skip to content
53 changes: 53 additions & 0 deletions crates/codegraph-core/src/extractors/javascript.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1700,6 +1700,10 @@ fn handle_export_declaration(node: &Node, decl: &Node, source: &[u8], symbols: &
"class_declaration" | "abstract_class_declaration" => ("class", "name"),
"interface_declaration" => ("interface", "name"),
"type_alias_declaration" => ("type", "name"),
"lexical_declaration" | "variable_declaration" => {
collect_exported_var_declarations(node, decl, source, symbols);
return;
}
_ => return,
};
if let Some(n) = decl.child_by_field_name(field) {
Expand All @@ -1711,6 +1715,55 @@ fn handle_export_declaration(node: &Node, decl: &Node, source: &[u8], symbols: &
}
}

/// Push `ExportInfo` entries for `export const/let/var …`, one per declarator.
///
/// Named function/class/interface/type declarations carry their own `name`
/// field (handled above); a lexical/variable declaration doesn't, so each
/// declarator's value is classified the same way `handle_var_decl` classifies
/// it when creating the matching `Definition`: function-valued declarators
/// become kind "function", `const` declarators with a literal/array/object/
/// new-expression value (per `is_js_literal`) become kind "constant".
/// Mirrors the WASM/TS extractor's `collectExportedDeclarations`.
///
/// This predicate must stay identical to `handle_var_decl`'s: `insert_nodes.rs`
/// marks `exported = 1` by matching (name, kind, file, line) against
/// already-inserted definition rows, so a mismatched kind here silently
/// no-ops the UPDATE instead of marking the symbol exported (#1728).
fn collect_exported_var_declarations(
node: &Node,
decl: &Node,
source: &[u8],
symbols: &mut FileSymbols,
) {
let is_const = decl
.child(0)
.map(|c| node_text(&c, source) == "const")
.unwrap_or(false);
let line = start_line(node);
for i in 0..decl.child_count() {
let Some(declarator) = decl.child(i) else { continue };
if declarator.kind() != "variable_declarator" { continue; }
let name_n = declarator.child_by_field_name("name");
let value_n = declarator.child_by_field_name("value");
let (Some(name_n), Some(value_n)) = (name_n, value_n) else { continue };
if name_n.kind() != "identifier" { continue; }
let vt = value_n.kind();
if vt == "arrow_function" || vt == "function_expression" || vt == "function" || vt == "generator_function" {
symbols.exports.push(ExportInfo {
name: node_text(&name_n, source).to_string(),
kind: "function".to_string(),
line,
});
} else if is_const && is_js_literal(&value_n) {
symbols.exports.push(ExportInfo {
name: node_text(&name_n, source).to_string(),
kind: "constant".to_string(),
line,
});
}
}
}

fn handle_reexport(node: &Node, source_node: &Node, source: &[u8], symbols: &mut FileSymbols) {
let mod_path = node_text(source_node, source)
.replace(&['\'', '"'][..], "");
Expand Down
1 change: 1 addition & 0 deletions src/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export {
findCallers,
findCrossFileCallTargets,
findDistinctCallers,
findExportedNodesByFile,
findFileNodes,
findImplementors,
findImportDependents,
Expand Down
1 change: 1 addition & 0 deletions src/db/repository/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export {
countEdges,
countFiles,
countNodes,
findExportedNodesByFile,
findFileNodes,
findNodeById,
findNodeByQualifiedName,
Expand Down
61 changes: 61 additions & 0 deletions src/db/repository/nodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {
} from '../../types.js';
import { buildFileConditionSQL, NodeQuery } from '../query-builder.js';
import { cachedStmt } from './cached-stmt.js';
import { findCrossFileCallTargets } from './edges.js';

// ─── Query-builder based lookups (moved from src/db/repository.js) ─────

Expand Down Expand Up @@ -179,6 +180,66 @@ export function findNodesByFile(db: BetterSqlite3Database, file: string): NodeRo
).all(file);
}

/** Cache the schema probe for the `exported` column per db handle. */
const _hasExportedColCache: WeakMap<BetterSqlite3Database, boolean> = new WeakMap();

const _findExportedNodesByFileStmt: StmtCache<NodeRow> = new WeakMap();

/**
* Detect whether this DB's `nodes` table has the `exported` column populated
* (older DBs built before it existed fall back to a cross-file-calls
* heuristic). Cached per db handle to avoid re-probing on every call.
*/
function hasExportedColumn(db: BetterSqlite3Database): boolean {
if (_hasExportedColCache.has(db)) return _hasExportedColCache.get(db)!;
let has = false;
try {
db.prepare('SELECT exported FROM nodes LIMIT 0').raw(true);
has = true;
} catch {
/* older DB predates the exported column */
}
_hasExportedColCache.set(db, has);
return has;
}

/**
* Find nodes in `file` that are part of its exported surface.
*
* Prefers the `exported = 1` column — set by the extractor at build time from
* the file's actual `export` statements, the authoritative "was this
* declared as exported" signal — and only falls back to
* `findCrossFileCallTargets` (symbols targeted by a cross-file `calls` edge)
* for DBs built before the column existed.
*
* Shared by `codegraph exports` and `codegraph where --file` so both commands
* agree on what "exported" means; before this was extracted, `where --file`
* used the cross-file-calls heuristic unconditionally, which misses any
* exported symbol that's only ever read as a value (not called) from another
* file — e.g. an exported constant used in a comparison (#1728).
*
* `knownSymbols`, if passed, is used in place of a fresh `findNodesByFile`
* call for the legacy-DB fallback path — callers that already fetched the
* file's symbols (e.g. to build their own result shape) can pass them in to
* avoid running the same query twice.
*/
export function findExportedNodesByFile(
db: BetterSqlite3Database,
file: string,
knownSymbols?: NodeRow[],
): NodeRow[] {
if (hasExportedColumn(db)) {
return cachedStmt(
_findExportedNodesByFileStmt,
db,
"SELECT * FROM nodes WHERE file = ? AND kind != 'file' AND exported = 1 ORDER BY line",
).all(file);
}
const symbols = knownSymbols ?? findNodesByFile(db, file);
const exportedIds = findCrossFileCallTargets(db, file);
return symbols.filter((s) => exportedIds.has(s.id));
}

/**
* Find file-kind nodes matching a LIKE pattern.
*/
Expand Down
52 changes: 3 additions & 49 deletions src/domain/analysis/exports.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import path from 'node:path';
import {
findCrossFileCallTargets,
findDbPath,
findExportedNodesByFile,
findFileNodes,
findNodesByFile,
} from '../../db/index.js';
import { cachedStmt } from '../../db/repository/cached-stmt.js';
import { debug } from '../../infrastructure/logger.js';
import { isTestFile } from '../../infrastructure/test-filter.js';
import {
createFileLinesReader,
Expand All @@ -17,10 +16,6 @@ import { paginateResult } from '../../shared/paginate.js';
import type { BetterSqlite3Database, NodeRow, StmtCache } from '../../types.js';
import { resolveAnalysisOpts, withReadonlyDb } from './query-helpers.js';

/** Cache the schema probe for the `exported` column per db handle. */
const _hasExportedColCache: WeakMap<BetterSqlite3Database, boolean> = new WeakMap();

const _exportedNodesStmtCache: StmtCache<NodeRow> = new WeakMap();
const _consumersStmtCache: StmtCache<{ name: string; file: string; line: number }> = new WeakMap();
const _reexportsFromStmtCache: StmtCache<{ file: string }> = new WeakMap();
const _reexportsToStmtCache: StmtCache<{ file: string }> = new WeakMap();
Expand Down Expand Up @@ -104,23 +99,14 @@ function collectReexportedSymbols(
db: BetterSqlite3Database,
fileNodeId: number,
reexportsToStmt: ReturnType<BetterSqlite3Database['prepare']>,
exportedNodesStmt: ReturnType<BetterSqlite3Database['prepare']> | null,
hasExportedCol: boolean,
getFileLines: (file: string) => string[] | null,
buildSymbolResult: (s: NodeRow, fileLines: string[] | null) => any,
) {
const reexportTargets = reexportsToStmt.all(fileNodeId) as Array<{ file: string }>;
const reexportedSymbols: Array<ReturnType<typeof buildSymbolResult> & { originFile: string }> =
[];
for (const reexTarget of reexportTargets) {
let targetExported: NodeRow[];
if (hasExportedCol) {
targetExported = exportedNodesStmt!.all(reexTarget.file) as NodeRow[];
} else {
const targetSymbols = findNodesByFile(db, reexTarget.file) as NodeRow[];
const exportedIds = findCrossFileCallTargets(db, reexTarget.file) as Set<number>;
targetExported = targetSymbols.filter((s) => exportedIds.has(s.id));
}
const targetExported = findExportedNodesByFile(db, reexTarget.file);
for (const s of targetExported) {
reexportedSymbols.push({
...buildSymbolResult(s, getFileLines(reexTarget.file)),
Expand All @@ -142,28 +128,6 @@ function exportsFileImpl(
const fileNodes = findFileNodes(db, `%${target}%`) as NodeRow[];
if (fileNodes.length === 0) return [];

// Detect whether exported column exists (cached per db handle)
let hasExportedCol: boolean;
if (_hasExportedColCache.has(db)) {
hasExportedCol = _hasExportedColCache.get(db)!;
} else {
hasExportedCol = false;
try {
db.prepare('SELECT exported FROM nodes LIMIT 0').raw(true);
hasExportedCol = true;
} catch (e: unknown) {
debug(`exported column not available, using fallback: ${(e as Error).message}`);
}
_hasExportedColCache.set(db, hasExportedCol);
}

const exportedNodesStmt = hasExportedCol
? cachedStmt(
_exportedNodesStmtCache,
db,
"SELECT * FROM nodes WHERE file = ? AND kind != 'file' AND exported = 1 ORDER BY line",
)
: null;
// Consumers include real call/construct edges plus `imports-type` edges —
// the symbol-level edge emitted for `import type { X }` statements (source
// is the importing *file* node, since the import statement references the
Expand Down Expand Up @@ -199,15 +163,7 @@ function exportsFileImpl(
return fileNodes.map((fn) => {
const symbols = findNodesByFile(db, fn.file) as NodeRow[];

let exported: NodeRow[];
if (hasExportedCol) {
// Use the exported column populated during build
exported = exportedNodesStmt!.all(fn.file) as NodeRow[];
} else {
// Fallback: symbols that have incoming calls from other files
const exportedIds = findCrossFileCallTargets(db, fn.file) as Set<number>;
exported = symbols.filter((s) => exportedIds.has(s.id));
}
const exported = findExportedNodesByFile(db, fn.file, symbols);
const internalCount = symbols.length - exported.length;

const buildSymbolResult = (s: NodeRow, fileLines: string[] | null) => {
Expand Down Expand Up @@ -244,8 +200,6 @@ function exportsFileImpl(
db,
fn.id,
reexportsToStmt,
exportedNodesStmt,
hasExportedCol,
getFileLines,
buildSymbolResult,
);
Expand Down
6 changes: 2 additions & 4 deletions src/domain/analysis/symbol-lookup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
findAllIncomingEdges,
findAllOutgoingEdges,
findCallers,
findCrossFileCallTargets,
findExportedNodesByFile,
findFileNodes,
findImportSources,
findImportTargets,
Expand Down Expand Up @@ -177,9 +177,7 @@ function whereFileImpl(db: BetterSqlite3Database, target: string) {

const importedBy = (findImportSources(db, fn.id) as ImportEdgeRow[]).map((r) => r.file);

const exportedIds = findCrossFileCallTargets(db, fn.file) as Set<number>;

const exported = symbols.filter((s) => exportedIds.has(s.id)).map((s) => s.name);
const exported = findExportedNodesByFile(db, fn.file, symbols).map((s) => s.name);

return {
file: fn.file,
Expand Down
92 changes: 60 additions & 32 deletions src/extractors/javascript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,64 @@ function handleMethodCapture(c: Record<string, TreeSitterNode>, definitions: Def
});
}

/** Node types whose own `name` field is the exported symbol's name. */
const EXPORT_DECL_KIND: Record<string, string> = {
function_declaration: 'function',
generator_function_declaration: 'function',
class_declaration: 'class',
abstract_class_declaration: 'class',
interface_declaration: 'interface',
type_alias_declaration: 'type',
};

/**
* Push Export entries for the declaration wrapped by an `export` statement.
* Shared by both extraction paths (query-based `handleExportCapture` and
* walk-based `handleExportStmt`) so they can't drift apart on what counts as
* an export — see the "two code paths" gotcha for this extractor.
*
* Named function/class/interface/type declarations carry their own `name`
* field. `export const/let/var …` has no such field — each declarator's value
* is classified the same way `handleVariableDeclarator` classifies it when
* building the matching Definition (function-valued → kind 'function',
* literal/array/object/new-expression-valued `const` → kind 'constant').
* This predicate must stay identical to the Definition-building one: the
* exported=1 UPDATE it feeds matches DB rows by (name, kind, file, line), so
* a mismatched kind silently no-ops instead of marking the symbol exported (#1728).
*/
function collectExportedDeclarations(
decl: TreeSitterNode,
exportLine: number,
exps: Export[],
): void {
const kind = EXPORT_DECL_KIND[decl.type];
if (kind) {
const n = decl.childForFieldName('name');
if (n) exps.push({ name: n.text, kind: kind as Export['kind'], line: exportLine });
return;
}
if (decl.type !== 'lexical_declaration' && decl.type !== 'variable_declaration') return;
const isConst = decl.text.startsWith('const ');
for (let i = 0; i < decl.childCount; i++) {
const declarator = decl.child(i);
if (declarator?.type !== 'variable_declarator') continue;
const nameN = declarator.childForFieldName('name');
const valueN = declarator.childForFieldName('value');
if (nameN?.type !== 'identifier' || !valueN) continue;
const valType = valueN.type;
if (
valType === 'arrow_function' ||
valType === 'function_expression' ||
valType === 'function' ||
valType === 'generator_function'
) {
exps.push({ name: nameN.text, kind: 'function', line: exportLine });
} else if (isConst && isConstantValue(valueN)) {
exps.push({ name: nameN.text, kind: 'constant', line: exportLine });
}
}
}

/** Handle export_statement capture. */
function handleExportCapture(
c: Record<string, TreeSitterNode>,
Expand All @@ -203,22 +261,7 @@ function handleExportCapture(
): void {
const exportLine = nodeStartLine(c.exp_node!);
const decl = c.exp_node!.childForFieldName('declaration');
if (decl) {
const declType = decl.type;
const kindMap: Record<string, string> = {
function_declaration: 'function',
generator_function_declaration: 'function',
class_declaration: 'class',
abstract_class_declaration: 'class',
interface_declaration: 'interface',
type_alias_declaration: 'type',
};
const kind = kindMap[declType];
if (kind) {
const n = decl.childForFieldName('name');
if (n) exps.push({ name: n.text, kind: kind as Export['kind'], line: exportLine });
}
}
if (decl) collectExportedDeclarations(decl, exportLine, exps);
const source = c.exp_node!.childForFieldName('source') || findChild(c.exp_node!, 'string');
if (source && !decl) {
const modPath = source.text.replace(/['"]/g, '');
Expand Down Expand Up @@ -1448,22 +1491,7 @@ function handleImportStmt(node: TreeSitterNode, ctx: ExtractorOutput): void {
function handleExportStmt(node: TreeSitterNode, ctx: ExtractorOutput): void {
const exportLine = nodeStartLine(node);
const decl = node.childForFieldName('declaration');
if (decl) {
const declType = decl.type;
const kindMap: Record<string, string> = {
function_declaration: 'function',
generator_function_declaration: 'function',
class_declaration: 'class',
abstract_class_declaration: 'class',
interface_declaration: 'interface',
type_alias_declaration: 'type',
};
const kind = kindMap[declType];
if (kind) {
const n = decl.childForFieldName('name');
if (n) ctx.exports.push({ name: n.text, kind: kind as Export['kind'], line: exportLine });
}
}
if (decl) collectExportedDeclarations(decl, exportLine, ctx.exports);
const source = node.childForFieldName('source') || findChild(node, 'string');
if (source && !decl) {
const modPath = source.text.replace(/['"]/g, '');
Expand Down
Loading
Loading