From 6739c8a1b92608ff2277ba8f92c186b6c6b189b9 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Wed, 8 Apr 2026 16:29:51 +0300 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20richer=20symbol=20metadata=20?= =?UTF-8?q?=E2=80=94=20generics,=20return=20types,=20enum=20members?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signatures now include generic type parameters (``), return type annotations (`: Promise`), and class/interface heritage (`extends`, `implements`). Enum members are extracted into a new `members TEXT` column as JSON. Benchmarked on a 1,653-file React/TS codebase: 973 functions gain return types, 873 symbols gain generics, 291 enum member values captured across 50 enums. Index time +7% (negligible), DB size unchanged. --- .agents/skills/codemap/SKILL.md | 4 + docs/architecture.md | 26 +-- src/db.ts | 9 +- src/parser.test.ts | 125 +++++++++++++++ src/parser.ts | 192 ++++++++++++++++++++++- templates/agents/skills/codemap/SKILL.md | 4 + 6 files changed, 337 insertions(+), 23 deletions(-) diff --git a/.agents/skills/codemap/SKILL.md b/.agents/skills/codemap/SKILL.md index 41a5c503..fa0fb23e 100644 --- a/.agents/skills/codemap/SKILL.md +++ b/.agents/skills/codemap/SKILL.md @@ -190,6 +190,10 @@ FROM symbols WHERE name LIKE '%Config%' ORDER BY name; SELECT name, kind, signature FROM symbols WHERE file_path LIKE '%settings-provider%' AND is_exported = 1; +-- Enum values (what are the valid members of an enum?) +SELECT name, members FROM symbols +WHERE kind = 'enum' AND name = 'TransactionStatus'; + -- File overview (imports + exports) SELECT 'import' as dir, source as name, specifiers as detail FROM imports WHERE file_path LIKE '%OrderRow%' diff --git a/docs/architecture.md b/docs/architecture.md index e8c4a3eb..524ba223 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -173,17 +173,18 @@ All tables use `STRICT` mode. Tables marked with `WITHOUT ROWID` store data dire ### `symbols` — Functions, constants, classes, interfaces, types, enums (`STRICT`) -| Column | Type | Description | -| ----------------- | ---------- | --------------------------------------------------------------------- | -| id | INTEGER PK | Auto-increment row id | -| file_path | TEXT FK | References `files(path)` ON DELETE CASCADE | -| name | TEXT | Symbol name | -| kind | TEXT | `function`, `const`, `class`, `interface`, `type`, `enum` | -| line_start | INTEGER | Start line (1-based) | -| line_end | INTEGER | End line | -| signature | TEXT | Reconstructed signature (e.g. `usePermissions(): PermissionsContext`) | -| is_exported | INTEGER | 1 if exported | -| is_default_export | INTEGER | 1 if default export | +| Column | Type | Description | +| ----------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| id | INTEGER PK | Auto-increment row id | +| file_path | TEXT FK | References `files(path)` ON DELETE CASCADE | +| name | TEXT | Symbol name | +| kind | TEXT | `function`, `const`, `class`, `interface`, `type`, `enum` | +| line_start | INTEGER | Start line (1-based) | +| line_end | INTEGER | End line | +| signature | TEXT | Reconstructed signature with generics and return types (e.g. `identity(val): T`, `interface Repo extends Iterable`, `class Store extends Base implements IStore`) | +| is_exported | INTEGER | 1 if exported | +| is_default_export | INTEGER | 1 if default export | +| members | TEXT | JSON array of enum members (NULL for non-enums). Each entry: `{"name":"…","value":"…"}` (value omitted for implicit-value enums) | ### `imports` — Import statements (`STRICT`) @@ -283,7 +284,8 @@ All tables have covering indexes tuned for AI agent query patterns. See [Coverin Uses the Rust-based `oxc-parser` via NAPI bindings to parse TypeScript/TSX/JS/JSX files into an AST. Extracts: -- **Symbols**: Functions, arrow functions, classes, interfaces, type aliases, enums — with reconstructed signatures +- **Symbols**: Functions, arrow functions, classes, interfaces, type aliases, enums — with reconstructed signatures including generic type parameters (e.g. ``), return type annotations (e.g. `: Promise`), class/interface heritage (`extends`, `implements`) +- **Enum members**: String and numeric values for each member, stored as JSON (e.g. `[{"name":"Active","value":"active"}]`) - **Imports**: All `import` statements with specifiers, source paths, and type-only flags - **Exports**: Named exports, default exports, re-exports - **Components**: React components detected via PascalCase name + (JSX return **or** hook usage). A PascalCase function in `.tsx`/`.jsx` that neither returns JSX nor calls hooks is indexed only as a symbol, not a component. Extracts props type and hooks used diff --git a/src/db.ts b/src/db.ts index c4ec7277..cbe2f4ce 100644 --- a/src/db.ts +++ b/src/db.ts @@ -49,7 +49,8 @@ export function createTables(db: CodemapDatabase) { line_end INTEGER, signature TEXT, is_exported INTEGER DEFAULT 0, - is_default_export INTEGER DEFAULT 0 + is_default_export INTEGER DEFAULT 0, + members TEXT ) STRICT; CREATE TABLE IF NOT EXISTS imports ( @@ -254,6 +255,7 @@ export interface SymbolRow { signature: string; is_exported: number; is_default_export: number; + members: string | null; } const BATCH_SIZE = 100; @@ -286,8 +288,8 @@ export function insertSymbols(db: CodemapDatabase, symbols: SymbolRow[]) { batchInsert( db, symbols, - "INSERT INTO symbols (file_path, name, kind, line_start, line_end, signature, is_exported, is_default_export)", - "(?,?,?,?,?,?,?,?)", + "INSERT INTO symbols (file_path, name, kind, line_start, line_end, signature, is_exported, is_default_export, members)", + "(?,?,?,?,?,?,?,?,?)", (s, v) => v.push( s.file_path, @@ -298,6 +300,7 @@ export function insertSymbols(db: CodemapDatabase, symbols: SymbolRow[]) { s.signature, s.is_exported, s.is_default_export, + s.members, ), ); } diff --git a/src/parser.test.ts b/src/parser.test.ts index 49626f3a..4758d261 100644 --- a/src/parser.test.ts +++ b/src/parser.test.ts @@ -41,6 +41,131 @@ describe("extractFileData", () => { }); }); + describe("signatures — generics and return types", () => { + it("includes return type annotation on functions", () => { + const src = `export function greet(name: string): string { return name; }\n`; + const d = extractFileData("/proj/x.ts", src, "x.ts"); + const sig = d.symbols.find((s) => s.name === "greet")?.signature; + expect(sig).toBe("greet(name): string"); + }); + + it("includes generic type params on functions", () => { + const src = `export function identity(val: T): T { return val; }\n`; + const d = extractFileData("/proj/x.ts", src, "x.ts"); + const sig = d.symbols.find((s) => s.name === "identity")?.signature; + expect(sig).toBe("identity(val): T"); + }); + + it("includes constrained generics", () => { + const src = `export function first(items: T[]): T { return items[0]; }\n`; + const d = extractFileData("/proj/x.ts", src, "x.ts"); + const sig = d.symbols.find((s) => s.name === "first")?.signature; + expect(sig).toBe("first(items): T"); + }); + + it("includes return type on arrow functions", () => { + const src = `export const add = (a: number, b: number): number => a + b;\n`; + const d = extractFileData("/proj/x.ts", src, "x.ts"); + const sig = d.symbols.find((s) => s.name === "add")?.signature; + expect(sig).toBe("add(a, b): number"); + }); + + it("includes generics on arrow functions", () => { + const src = `export const wrap = (val: T): T[] => [val];\n`; + const d = extractFileData("/proj/x.ts", src, "x.ts"); + const sig = d.symbols.find((s) => s.name === "wrap")?.signature; + expect(sig).toBe("wrap(val): T[]"); + }); + + it("includes generics on type aliases", () => { + const src = `export type Result = { ok: T } | { err: E };\n`; + const d = extractFileData("/proj/x.ts", src, "x.ts"); + const sig = d.symbols.find((s) => s.name === "Result")?.signature; + expect(sig).toBe("type Result"); + }); + + it("includes generics and extends on interfaces", () => { + const src = `export interface Repo extends Iterable { get(id: string): T; }\n`; + const d = extractFileData("/proj/x.ts", src, "x.ts"); + const sig = d.symbols.find((s) => s.name === "Repo")?.signature; + expect(sig).toBe("interface Repo extends Iterable"); + }); + + it("includes generics, extends, and implements on classes", () => { + const src = `export class Store extends Base implements IStore {}\n`; + const d = extractFileData("/proj/x.ts", src, "x.ts"); + const sig = d.symbols.find((s) => s.name === "Store")?.signature; + expect(sig).toBe("class Store extends Base implements IStore"); + }); + + it("handles union return types", () => { + const src = `export function parse(s: string): number | null { return null; }\n`; + const d = extractFileData("/proj/x.ts", src, "x.ts"); + const sig = d.symbols.find((s) => s.name === "parse")?.signature; + expect(sig).toBe("parse(s): number | null"); + }); + + it("handles Promise return type", () => { + const src = `export async function load(): Promise {}\n`; + const d = extractFileData("/proj/x.ts", src, "x.ts"); + const sig = d.symbols.find((s) => s.name === "load")?.signature; + expect(sig).toBe("load(): Promise"); + }); + + it("omits return type when unannotated", () => { + const src = `export function run() { return 1; }\n`; + const d = extractFileData("/proj/x.ts", src, "x.ts"); + const sig = d.symbols.find((s) => s.name === "run")?.signature; + expect(sig).toBe("run()"); + }); + }); + + describe("enum members extraction", () => { + it("extracts string enum members with values", () => { + const src = `export enum Status { Active = "active", Inactive = "inactive" }\n`; + const d = extractFileData("/proj/x.ts", src, "x.ts"); + const sym = d.symbols.find((s) => s.name === "Status"); + expect(sym?.kind).toBe("enum"); + const members = JSON.parse(sym!.members!); + expect(members).toEqual([ + { name: "Active", value: "active" }, + { name: "Inactive", value: "inactive" }, + ]); + }); + + it("extracts numeric enum members", () => { + const src = `export enum Dir { Up = 0, Down = 1, Left = 2 }\n`; + const d = extractFileData("/proj/x.ts", src, "x.ts"); + const members = JSON.parse( + d.symbols.find((s) => s.name === "Dir")!.members!, + ); + expect(members).toEqual([ + { name: "Up", value: 0 }, + { name: "Down", value: 1 }, + { name: "Left", value: 2 }, + ]); + }); + + it("extracts implicit-value enum members (no initializer)", () => { + const src = `export enum Color { Red, Green, Blue }\n`; + const d = extractFileData("/proj/x.ts", src, "x.ts"); + const members = JSON.parse( + d.symbols.find((s) => s.name === "Color")!.members!, + ); + expect(members).toEqual([ + { name: "Red" }, + { name: "Green" }, + { name: "Blue" }, + ]); + }); + + it("returns null members for non-enum symbols", () => { + const src = `export function foo(): void {}\n`; + const d = extractFileData("/proj/x.ts", src, "x.ts"); + expect(d.symbols.find((s) => s.name === "foo")?.members).toBeNull(); + }); + }); + describe("component detection heuristic", () => { it("detects components that return JSX", () => { const src = `export function Card() { return
card
; }\n`; diff --git a/src/parser.ts b/src/parser.ts index 15f5d329..ddcb4d95 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -125,6 +125,7 @@ export function extractFileData( signature: buildFunctionSignature(name, node), is_exported: isExported ? 1 : 0, is_default_export: isDefault ? 1 : 0, + members: null, }); if (isTsx && /^[A-Z]/.test(name)) { @@ -166,6 +167,7 @@ export function extractFileData( : `const ${name}`, is_exported: isExported ? 1 : 0, is_default_export: isDefault ? 1 : 0, + members: null, }); if (isTsx && /^[A-Z]/.test(name) && isArrowOrFn) { @@ -188,15 +190,17 @@ export function extractFileData( const name = node.id?.name; if (!name) return; const isExported = exportedNames.has(name); + const tp = stringifyTypeParams(node.typeParameters); symbols.push({ file_path: relPath, name, kind: "type", line_start: offsetToLine(lineMap, node.start), line_end: offsetToLine(lineMap, node.end), - signature: `type ${name}`, + signature: `type ${name}${tp}`, is_exported: isExported ? 1 : 0, is_default_export: 0, + members: null, }); }, @@ -204,15 +208,33 @@ export function extractFileData( const name = node.id?.name; if (!name) return; const isExported = exportedNames.has(name); + const tp = stringifyTypeParams(node.typeParameters); + let sig = `interface ${name}${tp}`; + if (node.extends?.length) { + const bases = node.extends + .map((e: any) => { + const base = e.expression?.name ?? e.typeName?.name ?? ""; + if (!base) return null; + const ta = e.typeArguments ?? e.typeParameters; + if (ta?.params?.length) { + const args = ta.params.map(stringifyTypeNode).filter(Boolean); + if (args.length) return `${base}<${args.join(", ")}>`; + } + return base; + }) + .filter(Boolean); + if (bases.length) sig += ` extends ${bases.join(", ")}`; + } symbols.push({ file_path: relPath, name, kind: "interface", line_start: offsetToLine(lineMap, node.start), line_end: offsetToLine(lineMap, node.end), - signature: `interface ${name}`, + signature: sig, is_exported: isExported ? 1 : 0, is_default_export: 0, + members: null, }); }, @@ -220,6 +242,23 @@ export function extractFileData( const name = node.id?.name; if (!name) return; const isExported = exportedNames.has(name); + const enumMembers = node.body?.members; + let members: string | null = null; + if (enumMembers?.length) { + const extracted = enumMembers.map((m: any) => { + const mName = m.id?.name ?? m.id?.value; + if (!mName) return null; + const init = m.initializer; + let mValue: string | number | null = null; + if (init?.type === "Literal" || init?.type === "StringLiteral") + mValue = init.value; + else if (init?.type === "NumericLiteral") mValue = init.value; + return mValue !== null && mValue !== undefined + ? { name: mName, value: mValue } + : { name: mName }; + }); + members = JSON.stringify(extracted.filter(Boolean)); + } symbols.push({ file_path: relPath, name, @@ -229,6 +268,7 @@ export function extractFileData( signature: `enum ${name}`, is_exported: isExported ? 1 : 0, is_default_export: 0, + members, }); }, @@ -237,15 +277,41 @@ export function extractFileData( if (!name) return; const isExported = exportedNames.has(name) || defaultExportedNames.has(name); + const tp = stringifyTypeParams(node.typeParameters); + let sig = `class ${name}${tp}`; + if (node.superClass?.name) { + sig += ` extends ${node.superClass.name}`; + const sta = node.superTypeArguments ?? node.superTypeParameters; + if (sta?.params?.length) { + const args = sta.params.map(stringifyTypeNode).filter(Boolean); + if (args.length) sig += `<${args.join(", ")}>`; + } + } + if (node.implements?.length) { + const impls = node.implements + .map((i: any) => { + const n = i.expression?.name ?? ""; + if (!n) return null; + const ta = i.typeArguments ?? i.typeParameters; + if (ta?.params?.length) { + const args = ta.params.map(stringifyTypeNode).filter(Boolean); + if (args.length) return `${n}<${args.join(", ")}>`; + } + return n; + }) + .filter(Boolean); + if (impls.length) sig += ` implements ${impls.join(", ")}`; + } symbols.push({ file_path: relPath, name, kind: "class", line_start: offsetToLine(lineMap, node.start), line_end: offsetToLine(lineMap, node.end), - signature: `class ${name}`, + signature: sig, is_exported: isExported ? 1 : 0, is_default_export: defaultExportedNames.has(name) ? 1 : 0, + members: null, }); }, @@ -361,11 +427,121 @@ function exportEntryToRow( }; } +function stringifyTypeNode(node: any): string | null { + if (!node) return null; + switch (node.type) { + case "TSTypeReference": { + let name: string | null = null; + const tn = node.typeName; + if (tn?.type === "Identifier") name = tn.name; + else if (typeof tn?.name === "string") name = tn.name; + else if (tn?.type === "TSQualifiedName") + name = `${tn.left?.name ?? ""}.${tn.right?.name ?? ""}`; + if (!name) return null; + const ta = node.typeArguments ?? node.typeParameters; + if (ta?.params?.length) { + const args = ta.params.map(stringifyTypeNode).filter(Boolean); + if (args.length) return `${name}<${args.join(", ")}>`; + } + return name; + } + case "TSStringKeyword": + return "string"; + case "TSNumberKeyword": + return "number"; + case "TSBooleanKeyword": + return "boolean"; + case "TSVoidKeyword": + return "void"; + case "TSNullKeyword": + return "null"; + case "TSUndefinedKeyword": + return "undefined"; + case "TSAnyKeyword": + return "any"; + case "TSNeverKeyword": + return "never"; + case "TSUnknownKeyword": + return "unknown"; + case "TSObjectKeyword": + return "object"; + case "TSBigIntKeyword": + return "bigint"; + case "TSSymbolKeyword": + return "symbol"; + case "TSArrayType": { + const elem = stringifyTypeNode(node.elementType); + return elem ? `${elem}[]` : null; + } + case "TSUnionType": { + const types = node.types?.map(stringifyTypeNode).filter(Boolean); + return types?.length ? types.join(" | ") : null; + } + case "TSIntersectionType": { + const types = node.types?.map(stringifyTypeNode).filter(Boolean); + return types?.length ? types.join(" & ") : null; + } + case "TSTupleType": { + const elems = node.elementTypes?.map(stringifyTypeNode).filter(Boolean); + return `[${elems?.join(", ") ?? ""}]`; + } + case "TSLiteralType": { + const lit = node.literal; + if (lit?.type === "StringLiteral") return `"${lit.value}"`; + if (lit?.type === "NumericLiteral") return String(lit.value); + if (lit?.type === "BooleanLiteral") return String(lit.value); + return null; + } + case "TSTypeQuery": { + const exprName = node.exprName; + const n = + typeof exprName?.name === "string" ? exprName.name : exprName?.name; + return n ? `typeof ${n}` : null; + } + case "TSTypeOperator": { + const inner = stringifyTypeNode(node.typeAnnotation); + return inner ? `${node.operator} ${inner}` : null; + } + case "TSThisType": + return "this"; + default: + return null; + } +} + +function stringifyTypeParams(typeParameters: any): string { + const params = typeParameters?.params; + if (!params?.length) return ""; + const parts = params.map((p: any) => { + const name = typeof p.name === "string" ? p.name : (p.name?.name ?? "?"); + let s = name; + if (p.constraint) { + const c = stringifyTypeNode(p.constraint); + if (c) s += ` extends ${c}`; + } + if (p.default) { + const d = stringifyTypeNode(p.default); + if (d) s += ` = ${d}`; + } + return s; + }); + return `<${parts.join(", ")}>`; +} + function buildFunctionSignature(name: string, node: any): string { + const typeParams = stringifyTypeParams(node?.typeParameters); const params = node?.params; - if (!params || params.length === 0) return `${name}()`; - const paramNames = params - .map((p: any) => p.name ?? p.left?.name ?? p.argument?.name ?? "...") - .join(", "); - return `${name}(${paramNames})`; + let paramStr = ""; + if (params?.length) { + paramStr = params + .map((p: any) => p.name ?? p.left?.name ?? p.argument?.name ?? "...") + .join(", "); + } + let sig = `${name}${typeParams}(${paramStr})`; + const returnType = node?.returnType?.typeAnnotation; + if (returnType) { + const rt = stringifyTypeNode(returnType); + if (rt) sig += `: ${rt}`; + } + return sig; } diff --git a/templates/agents/skills/codemap/SKILL.md b/templates/agents/skills/codemap/SKILL.md index 0ac8ae18..cfb2e69f 100644 --- a/templates/agents/skills/codemap/SKILL.md +++ b/templates/agents/skills/codemap/SKILL.md @@ -190,6 +190,10 @@ FROM symbols WHERE name LIKE '%Config%' ORDER BY name; SELECT name, kind, signature FROM symbols WHERE file_path LIKE '%settings-provider%' AND is_exported = 1; +-- Enum values (what are the valid members of an enum?) +SELECT name, members FROM symbols +WHERE kind = 'enum' AND name = 'TransactionStatus'; + -- File overview (imports + exports) SELECT 'import' as dir, source as name, specifiers as detail FROM imports WHERE file_path LIKE '%OrderRow%' From 1ad06d1ad9076ea4e628fea4033751c0ce31e44d Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Wed, 8 Apr 2026 16:41:12 +0300 Subject: [PATCH 2/9] feat: JSDoc extraction and type_members table Add doc_comment column to symbols (3,084 documented symbols on benchmark repo) and new type_members table for interface/type-alias properties (12,052 members extracted). Agents can now query type shapes and symbol documentation without reading files. SCHEMA_VERSION bumped to 2. --- .agents/rules/codemap.mdc | 5 ++ .agents/skills/codemap/SKILL.md | 28 +++++- docs/architecture.md | 15 +++- src/adapters/builtin.ts | 1 + src/adapters/types.ts | 1 + src/application/index-engine.ts | 7 ++ src/application/run-index.ts | 1 + src/application/types.ts | 1 + src/db.ts | 55 +++++++++++- src/parsed-types.ts | 2 + src/parser.test.ts | 99 +++++++++++++++++++++ src/parser.ts | 107 ++++++++++++++++++++++- templates/agents/skills/codemap/SKILL.md | 16 +++- 13 files changed, 330 insertions(+), 8 deletions(-) diff --git a/.agents/rules/codemap.mdc b/.agents/rules/codemap.mdc index 87a6dabe..5a838193 100644 --- a/.agents/rules/codemap.mdc +++ b/.agents/rules/codemap.mdc @@ -56,6 +56,8 @@ If the question looks like any of these → use the index: | "How many files/symbols/components are there?" | any table with `COUNT(*)` | | "What are the CSS classes in X?" | `css_classes` | | "What keyframe animations exist?" | `css_keyframes` | +| "What fields does interface/type X have?" | `type_members` | +| "Is symbol X deprecated?" / "What does X do?" | `symbols` (`doc_comment`) | ## When Grep / Read IS appropriate @@ -93,6 +95,9 @@ bun src/index.ts query --json "" | CSS design tokens | `SELECT name, value, scope FROM css_variables WHERE name LIKE '--%...'` | | CSS module classes | `SELECT name, file_path FROM css_classes WHERE is_module = 1` | | CSS keyframes | `SELECT name, file_path FROM css_keyframes` | +| Type/interface shape | `SELECT name, type, is_optional, is_readonly FROM type_members WHERE symbol_name = '...'` | +| Deprecated symbols | `SELECT name, kind, file_path, doc_comment FROM symbols WHERE doc_comment LIKE '%@deprecated%'` | +| Symbol docs | `SELECT name, signature, doc_comment FROM symbols WHERE name = '...' AND doc_comment IS NOT NULL` | **Use `DISTINCT`** on dependency and import queries — a file importing multiple specifiers from the same module produces duplicate rows. diff --git a/.agents/skills/codemap/SKILL.md b/.agents/skills/codemap/SKILL.md index fa0fb23e..6e9cdb26 100644 --- a/.agents/skills/codemap/SKILL.md +++ b/.agents/skills/codemap/SKILL.md @@ -85,9 +85,23 @@ LIMIT 10 | kind | TEXT | `function`, `class`, `type`, `interface`, `enum`, `const` | | line_start | INTEGER | Start line (1-based) | | line_end | INTEGER | End line (1-based) | -| signature | TEXT | e.g. `createHandler()`, `type UserProps` | +| signature | TEXT | Reconstructed signature with generics and return types | | is_exported | INTEGER | 1 if exported | | is_default_export | INTEGER | 1 if default export | +| members | TEXT | JSON enum members (NULL for non-enums) | +| doc_comment | TEXT | Leading JSDoc text (cleaned), NULL when absent | + +### `type_members` — Properties of interfaces and object-literal type aliases + +| Column | Type | Description | +| ----------- | ---------- | -------------------------------------------------- | +| id | INTEGER PK | Auto-increment ID | +| file_path | TEXT FK | References `files(path)` | +| symbol_name | TEXT | Parent interface / type alias name | +| name | TEXT | Property or method name | +| type | TEXT | Type annotation (e.g. `string`, `(key) => number`) | +| is_optional | INTEGER | 1 if `?` modifier | +| is_readonly | INTEGER | 1 if `readonly` modifier | ### `imports` — Import statements @@ -194,6 +208,18 @@ FROM symbols WHERE file_path LIKE '%settings-provider%' AND is_exported = 1; SELECT name, members FROM symbols WHERE kind = 'enum' AND name = 'TransactionStatus'; +-- Interface / type shape (what fields does a type have?) +SELECT name, type, is_optional, is_readonly FROM type_members +WHERE symbol_name = 'UserSession'; + +-- Deprecated symbols (find @deprecated via JSDoc) +SELECT name, kind, file_path, doc_comment FROM symbols +WHERE doc_comment LIKE '%@deprecated%'; + +-- Symbol documentation +SELECT name, signature, doc_comment FROM symbols +WHERE name = 'formatCurrency' AND doc_comment IS NOT NULL; + -- File overview (imports + exports) SELECT 'import' as dir, source as name, specifiers as detail FROM imports WHERE file_path LIKE '%OrderRow%' diff --git a/docs/architecture.md b/docs/architecture.md index 524ba223..34134570 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -100,7 +100,7 @@ A local SQLite database (`.codemap.db`) indexes the project tree and stores stru | `application/` | Indexing use cases and engine (`run-index`, `index-engine`, types) | | `worker-pool.ts` | Parallel parse workers (Bun / Node) | | `db.ts` | SQLite adapter — schema DDL, typed CRUD, connection management | -| `parser.ts` | TS/TSX/JS/JSX extraction via `oxc-parser` — symbols, imports, exports, components, markers | +| `parser.ts` | TS/TSX/JS/JSX extraction via `oxc-parser` — symbols (with JSDoc + generics + return types), type members, imports, exports, components, markers | | `css-parser.ts` | CSS extraction via `lightningcss` — custom properties, classes, keyframes, `@theme` blocks | | `resolver.ts` | Import path resolution via `oxc-resolver` — respects `tsconfig` aliases, builds dependency graph | | `constants.ts` | Shared constants — e.g. `LANG_MAP` | @@ -185,6 +185,19 @@ All tables use `STRICT` mode. Tables marked with `WITHOUT ROWID` store data dire | is_exported | INTEGER | 1 if exported | | is_default_export | INTEGER | 1 if default export | | members | TEXT | JSON array of enum members (NULL for non-enums). Each entry: `{"name":"…","value":"…"}` (value omitted for implicit-value enums) | +| doc_comment | TEXT | Leading JSDoc comment text (cleaned: `*` prefixes stripped, trimmed). NULL when absent. Preserves `@deprecated`, `@param`, etc. tags | + +### `type_members` — Properties and methods of interfaces and object-literal types (`STRICT`) + +| Column | Type | Description | +| ----------- | ---------- | --------------------------------------------------------- | +| id | INTEGER PK | Auto-increment row id | +| file_path | TEXT FK | References `files(path)` ON DELETE CASCADE | +| symbol_name | TEXT | Name of the parent interface or type alias | +| name | TEXT | Property or method name | +| type | TEXT | Type annotation string (e.g. `string`, `(key) => number`) | +| is_optional | INTEGER | 1 if `?` modifier present | +| is_readonly | INTEGER | 1 if `readonly` modifier present | ### `imports` — Import statements (`STRICT`) diff --git a/src/adapters/builtin.ts b/src/adapters/builtin.ts index 4aa94383..828c0502 100644 --- a/src/adapters/builtin.ts +++ b/src/adapters/builtin.ts @@ -23,6 +23,7 @@ function parseTsJs(ctx: ParseContext): ParsedFilePayload { exports: data.exports, components: data.components, markers: data.markers, + typeMembers: data.typeMembers, }; } diff --git a/src/adapters/types.ts b/src/adapters/types.ts index e0f7af38..fcf90d1b 100644 --- a/src/adapters/types.ts +++ b/src/adapters/types.ts @@ -27,6 +27,7 @@ export type ParsedFilePayload = Pick< | "exports" | "components" | "markers" + | "typeMembers" | "cssVariables" | "cssClasses" | "cssKeyframes" diff --git a/src/application/index-engine.ts b/src/application/index-engine.ts index a883edbc..35950d38 100644 --- a/src/application/index-engine.ts +++ b/src/application/index-engine.ts @@ -24,6 +24,7 @@ import { insertCssVariables, insertCssClasses, insertCssKeyframes, + insertTypeMembers, getAllFileHashes, SCHEMA_VERSION, type CodemapDatabase, @@ -220,6 +221,9 @@ function insertParsedResults( insertComponents(db, parsed.components); } if (parsed.markers?.length) insertMarkers(db, parsed.markers); + if (parsed.typeMembers?.length) { + insertTypeMembers(db, parsed.typeMembers); + } } } catch (err) { console.error( @@ -246,6 +250,7 @@ export function fetchTableStats(db: CodemapDatabase): IndexTableStats { (SELECT COUNT(*) FROM components) as components, (SELECT COUNT(*) FROM dependencies) as dependencies, (SELECT COUNT(*) FROM markers) as markers, + (SELECT COUNT(*) FROM type_members) as type_members, (SELECT COUNT(*) FROM css_variables) as css_vars, (SELECT COUNT(*) FROM css_classes) as css_classes, (SELECT COUNT(*) FROM css_keyframes) as css_keyframes`, @@ -360,6 +365,8 @@ export async function indexFiles( if (data.exports.length) insertExports(db, data.exports); if (data.components.length) insertComponents(db, data.components); if (data.markers.length) insertMarkers(db, data.markers); + if (data.typeMembers.length) + insertTypeMembers(db, data.typeMembers); } } catch (err) { console.error( diff --git a/src/application/run-index.ts b/src/application/run-index.ts index 7291b85d..358c93f0 100644 --- a/src/application/run-index.ts +++ b/src/application/run-index.ts @@ -20,6 +20,7 @@ function emptyStats(): IndexTableStats { components: 0, dependencies: 0, markers: 0, + type_members: 0, css_vars: 0, css_classes: 0, css_keyframes: 0, diff --git a/src/application/types.ts b/src/application/types.ts index f415ea38..fe253970 100644 --- a/src/application/types.ts +++ b/src/application/types.ts @@ -9,6 +9,7 @@ export interface IndexTableStats extends Record { components: number; dependencies: number; markers: number; + type_members: number; css_vars: number; css_classes: number; css_keyframes: number; diff --git a/src/db.ts b/src/db.ts index cbe2f4ce..8af94504 100644 --- a/src/db.ts +++ b/src/db.ts @@ -9,7 +9,7 @@ import { * tweaks; run `--full` locally after pulling. After v1.0, bump in lockstep with * `createTables` / `createIndexes` when the on-disk schema changes. */ -export const SCHEMA_VERSION = 1; +export const SCHEMA_VERSION = 2; export type { CodemapDatabase }; @@ -50,7 +50,8 @@ export function createTables(db: CodemapDatabase) { signature TEXT, is_exported INTEGER DEFAULT 0, is_default_export INTEGER DEFAULT 0, - members TEXT + members TEXT, + doc_comment TEXT ) STRICT; CREATE TABLE IF NOT EXISTS imports ( @@ -119,6 +120,16 @@ export function createTables(db: CodemapDatabase) { line_number INTEGER ) STRICT; + CREATE TABLE IF NOT EXISTS type_members ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_path TEXT NOT NULL REFERENCES files(path) ON DELETE CASCADE, + symbol_name TEXT NOT NULL, + name TEXT NOT NULL, + type TEXT, + is_optional INTEGER DEFAULT 0, + is_readonly INTEGER DEFAULT 0 + ) STRICT; + CREATE TABLE IF NOT EXISTS meta ( key TEXT PRIMARY KEY, value TEXT @@ -160,6 +171,9 @@ export function createIndexes(db: CodemapDatabase) { CREATE INDEX IF NOT EXISTS idx_css_classes_name ON css_classes(name, file_path, is_module); CREATE INDEX IF NOT EXISTS idx_css_classes_file ON css_classes(file_path); CREATE INDEX IF NOT EXISTS idx_css_keyframes_name ON css_keyframes(name, file_path); + + CREATE INDEX IF NOT EXISTS idx_type_members_symbol ON type_members(symbol_name, file_path, name, type, is_optional, is_readonly); + CREATE INDEX IF NOT EXISTS idx_type_members_file ON type_members(file_path); `); } @@ -188,6 +202,7 @@ export function createSchema(db: CodemapDatabase) { export function dropAll(db: CodemapDatabase) { db.run(` + DROP TABLE IF EXISTS type_members; DROP TABLE IF EXISTS dependencies; DROP TABLE IF EXISTS markers; DROP TABLE IF EXISTS components; @@ -256,6 +271,7 @@ export interface SymbolRow { is_exported: number; is_default_export: number; members: string | null; + doc_comment: string | null; } const BATCH_SIZE = 100; @@ -288,8 +304,8 @@ export function insertSymbols(db: CodemapDatabase, symbols: SymbolRow[]) { batchInsert( db, symbols, - "INSERT INTO symbols (file_path, name, kind, line_start, line_end, signature, is_exported, is_default_export, members)", - "(?,?,?,?,?,?,?,?,?)", + "INSERT INTO symbols (file_path, name, kind, line_start, line_end, signature, is_exported, is_default_export, members, doc_comment)", + "(?,?,?,?,?,?,?,?,?,?)", (s, v) => v.push( s.file_path, @@ -301,6 +317,7 @@ export function insertSymbols(db: CodemapDatabase, symbols: SymbolRow[]) { s.is_exported, s.is_default_export, s.members, + s.doc_comment, ), ); } @@ -469,6 +486,36 @@ export function insertCssKeyframes( ); } +export interface TypeMemberRow { + file_path: string; + symbol_name: string; + name: string; + type: string | null; + is_optional: number; + is_readonly: number; +} + +export function insertTypeMembers( + db: CodemapDatabase, + members: TypeMemberRow[], +) { + batchInsert( + db, + members, + "INSERT INTO type_members (file_path, symbol_name, name, type, is_optional, is_readonly)", + "(?,?,?,?,?,?)", + (m, v) => + v.push( + m.file_path, + m.symbol_name, + m.name, + m.type, + m.is_optional, + m.is_readonly, + ), + ); +} + export function getAllFileHashes(db: CodemapDatabase): Map { const rows = db .query<{ path: string; content_hash: string }>( diff --git a/src/parsed-types.ts b/src/parsed-types.ts index 84870d2d..decc475f 100644 --- a/src/parsed-types.ts +++ b/src/parsed-types.ts @@ -8,6 +8,7 @@ import type { CssVariableRow, CssClassRow, CssKeyframeRow, + TypeMemberRow, } from "./db"; /** @@ -24,6 +25,7 @@ export interface ParsedFile { exports?: ExportRow[]; components?: ComponentRow[]; markers?: MarkerRow[]; + typeMembers?: TypeMemberRow[]; cssVariables?: CssVariableRow[]; cssClasses?: CssClassRow[]; cssKeyframes?: CssKeyframeRow[]; diff --git a/src/parser.test.ts b/src/parser.test.ts index 4758d261..79a9005d 100644 --- a/src/parser.test.ts +++ b/src/parser.test.ts @@ -166,6 +166,105 @@ describe("extractFileData", () => { }); }); + describe("JSDoc extraction", () => { + it("attaches single-line JSDoc to function", () => { + const src = `/** Formats a value. */\nexport function fmt(v: string): string { return v; }\n`; + const d = extractFileData("/proj/x.ts", src, "x.ts"); + expect(d.symbols.find((s) => s.name === "fmt")?.doc_comment).toBe( + "Formats a value.", + ); + }); + + it("attaches multi-line JSDoc with @deprecated", () => { + const src = `/**\n * Old helper.\n * @deprecated Use newHelper instead.\n */\nexport function oldHelper(): void {}\n`; + const d = extractFileData("/proj/x.ts", src, "x.ts"); + const doc = d.symbols.find((s) => s.name === "oldHelper")?.doc_comment; + expect(doc).toContain("Old helper."); + expect(doc).toContain("@deprecated"); + }); + + it("does not attach orphan comment across code boundary", () => { + const src = `/** Orphan */\nconst x = 1;\nexport function foo(): void {}\n`; + const d = extractFileData("/proj/x.ts", src, "x.ts"); + expect(d.symbols.find((s) => s.name === "foo")?.doc_comment).toBeNull(); + }); + + it("attaches JSDoc to interface", () => { + const src = `/** Session data. */\nexport interface Session { id: string; }\n`; + const d = extractFileData("/proj/x.ts", src, "x.ts"); + expect(d.symbols.find((s) => s.name === "Session")?.doc_comment).toBe( + "Session data.", + ); + }); + + it("returns null when no JSDoc present", () => { + const src = `export const x = 42;\n`; + const d = extractFileData("/proj/x.ts", src, "x.ts"); + expect(d.symbols.find((s) => s.name === "x")?.doc_comment).toBeNull(); + }); + }); + + describe("type members extraction", () => { + it("extracts interface property members", () => { + const src = `export interface User { id: string; name: string; age?: number; }\n`; + const d = extractFileData("/proj/x.ts", src, "x.ts"); + expect(d.typeMembers).toHaveLength(3); + expect(d.typeMembers[0]).toMatchObject({ + symbol_name: "User", + name: "id", + type: "string", + is_optional: 0, + }); + expect(d.typeMembers[2]).toMatchObject({ + name: "age", + type: "number", + is_optional: 1, + }); + }); + + it("extracts interface method signatures", () => { + const src = `export interface Store { get(key: string): number; set(key: string, val: number): void; }\n`; + const d = extractFileData("/proj/x.ts", src, "x.ts"); + expect(d.typeMembers).toHaveLength(2); + expect(d.typeMembers[0]).toMatchObject({ + symbol_name: "Store", + name: "get", + type: "(key) => number", + }); + expect(d.typeMembers[1]).toMatchObject({ + name: "set", + type: "(key, val) => void", + }); + }); + + it("extracts type alias object literal members", () => { + const src = `export type Config = { host: string; port?: number; };\n`; + const d = extractFileData("/proj/x.ts", src, "x.ts"); + expect(d.typeMembers).toHaveLength(2); + expect(d.typeMembers[0]).toMatchObject({ + symbol_name: "Config", + name: "host", + type: "string", + }); + expect(d.typeMembers[1]).toMatchObject({ + name: "port", + is_optional: 1, + }); + }); + + it("does not extract type members from union types", () => { + const src = `export type Status = "ok" | "error";\n`; + const d = extractFileData("/proj/x.ts", src, "x.ts"); + expect(d.typeMembers).toHaveLength(0); + }); + + it("does not extract type members from functions", () => { + const src = `export function foo(): void {}\n`; + const d = extractFileData("/proj/x.ts", src, "x.ts"); + expect(d.typeMembers).toHaveLength(0); + }); + }); + describe("component detection heuristic", () => { it("detects components that return JSX", () => { const src = `export function Card() { return
card
; }\n`; diff --git a/src/parser.ts b/src/parser.ts index ddcb4d95..866701d1 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -14,6 +14,7 @@ import type { ExportRow, ComponentRow, MarkerRow, + TypeMemberRow, } from "./db"; import { extractMarkers } from "./markers"; @@ -23,6 +24,7 @@ interface ExtractedData { exports: ExportRow[]; components: ComponentRow[]; markers: MarkerRow[]; + typeMembers: TypeMemberRow[]; } /** @@ -71,11 +73,14 @@ export function extractFileData( const lineMap = buildLineMap(source); const mod = result.module; + const jsDocComments = buildJsDocIndex(result.comments); + const symbols: SymbolRow[] = []; const imports: ImportRow[] = []; const exports: ExportRow[] = []; const components: ComponentRow[] = []; const markers: MarkerRow[] = []; + const typeMembers: TypeMemberRow[] = []; const exportedNames = new Set(); const defaultExportedNames = new Set(); @@ -126,6 +131,7 @@ export function extractFileData( is_exported: isExported ? 1 : 0, is_default_export: isDefault ? 1 : 0, members: null, + doc_comment: findJsDoc(jsDocComments, node.start, source), }); if (isTsx && /^[A-Z]/.test(name)) { @@ -168,6 +174,7 @@ export function extractFileData( is_exported: isExported ? 1 : 0, is_default_export: isDefault ? 1 : 0, members: null, + doc_comment: findJsDoc(jsDocComments, node.start, source), }); if (isTsx && /^[A-Z]/.test(name) && isArrowOrFn) { @@ -201,7 +208,16 @@ export function extractFileData( is_exported: isExported ? 1 : 0, is_default_export: 0, members: null, + doc_comment: findJsDoc(jsDocComments, node.start, source), }); + if (node.typeAnnotation?.type === "TSTypeLiteral") { + extractObjectMembers( + node.typeAnnotation.members, + relPath, + name, + typeMembers, + ); + } }, TSInterfaceDeclaration(node: any) { @@ -235,7 +251,9 @@ export function extractFileData( is_exported: isExported ? 1 : 0, is_default_export: 0, members: null, + doc_comment: findJsDoc(jsDocComments, node.start, source), }); + extractObjectMembers(node.body?.body, relPath, name, typeMembers); }, TSEnumDeclaration(node: any) { @@ -269,6 +287,7 @@ export function extractFileData( is_exported: isExported ? 1 : 0, is_default_export: 0, members, + doc_comment: findJsDoc(jsDocComments, node.start, source), }); }, @@ -312,6 +331,7 @@ export function extractFileData( is_exported: isExported ? 1 : 0, is_default_export: defaultExportedNames.has(name) ? 1 : 0, members: null, + doc_comment: findJsDoc(jsDocComments, node.start, source), }); }, @@ -363,7 +383,7 @@ export function extractFileData( }); } - return { symbols, imports, exports, components, markers }; + return { symbols, imports, exports, components, markers, typeMembers }; } function staticImportToRow( @@ -545,3 +565,88 @@ function buildFunctionSignature(name: string, node: any): string { } return sig; } + +interface JsDocEntry { + end: number; + text: string; +} + +function buildJsDocIndex(comments: any[]): JsDocEntry[] { + if (!comments?.length) return []; + const docs: JsDocEntry[] = []; + for (const c of comments) { + if (c.type !== "Block" || !c.value.startsWith("*")) continue; + docs.push({ end: c.end, text: cleanJsDoc(c.value) }); + } + return docs; +} + +function cleanJsDoc(raw: string): string { + return raw + .split("\n") + .map((line) => line.replace(/^\s*\*\s?/, "")) + .join("\n") + .trim(); +} + +function findJsDoc( + docs: JsDocEntry[], + nodeStart: number, + source: string, +): string | null { + if (!docs.length) return null; + let lo = 0; + let hi = docs.length - 1; + let best = -1; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + if (docs[mid].end <= nodeStart) { + best = mid; + lo = mid + 1; + } else { + hi = mid - 1; + } + } + if (best < 0) return null; + const doc = docs[best]; + const gap = source.slice(doc.end, nodeStart); + if (/[;{}]/.test(gap)) return null; + return doc.text || null; +} + +function extractObjectMembers( + members: any[] | undefined, + filePath: string, + symbolName: string, + out: TypeMemberRow[], +) { + if (!members?.length) return; + for (const m of members) { + const name = m.key?.name ?? m.key?.value; + if (!name) continue; + let type: string | null = null; + if (m.type === "TSMethodSignature") { + const rt = m.returnType?.typeAnnotation; + const rtStr = rt ? stringifyTypeNode(rt) : null; + const params = m.params; + let paramStr = ""; + if (params?.length) { + paramStr = params + .map((p: any) => p.name ?? p.left?.name ?? "...") + .join(", "); + } + type = `(${paramStr})${rtStr ? ` => ${rtStr}` : ""}`; + } else { + const ta = m.typeAnnotation?.typeAnnotation; + if (ta) type = stringifyTypeNode(ta); + } + out.push({ + file_path: filePath, + symbol_name: symbolName, + name, + type, + is_optional: m.optional ? 1 : 0, + is_readonly: m.readonly ? 1 : 0, + }); + } +} diff --git a/templates/agents/skills/codemap/SKILL.md b/templates/agents/skills/codemap/SKILL.md index cfb2e69f..b214ed77 100644 --- a/templates/agents/skills/codemap/SKILL.md +++ b/templates/agents/skills/codemap/SKILL.md @@ -85,9 +85,23 @@ LIMIT 10 | kind | TEXT | `function`, `class`, `type`, `interface`, `enum`, `const` | | line_start | INTEGER | Start line (1-based) | | line_end | INTEGER | End line (1-based) | -| signature | TEXT | e.g. `createHandler()`, `type UserProps` | +| signature | TEXT | Reconstructed signature with generics and return types | | is_exported | INTEGER | 1 if exported | | is_default_export | INTEGER | 1 if default export | +| members | TEXT | JSON enum members (NULL for non-enums) | +| doc_comment | TEXT | Leading JSDoc text (cleaned), NULL when absent | + +### `type_members` — Properties of interfaces and object-literal type aliases + +| Column | Type | Description | +| ----------- | ---------- | -------------------------------------------------- | +| id | INTEGER PK | Auto-increment ID | +| file_path | TEXT FK | References `files(path)` | +| symbol_name | TEXT | Parent interface / type alias name | +| name | TEXT | Property or method name | +| type | TEXT | Type annotation (e.g. `string`, `(key) => number`) | +| is_optional | INTEGER | 1 if `?` modifier | +| is_readonly | INTEGER | 1 if `readonly` modifier | ### `imports` — Import statements From e9a7ecba64c7fd25ef6b977e4fc7d71e6a679636 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Wed, 8 Apr 2026 16:45:49 +0300 Subject: [PATCH 3/9] feat: extract const literal values into symbols.value Captures string, number, boolean, null, negative numbers, `as const`, and simple template literals. 615 values extracted on benchmark repo. --- .agents/rules/codemap.mdc | 1 + .agents/skills/codemap/SKILL.md | 5 +++ docs/architecture.md | 1 + src/db.ts | 9 ++-- src/parser.test.ts | 57 ++++++++++++++++++++++++ src/parser.ts | 34 ++++++++++++++ templates/agents/skills/codemap/SKILL.md | 1 + 7 files changed, 105 insertions(+), 3 deletions(-) diff --git a/.agents/rules/codemap.mdc b/.agents/rules/codemap.mdc index 5a838193..667dd033 100644 --- a/.agents/rules/codemap.mdc +++ b/.agents/rules/codemap.mdc @@ -98,6 +98,7 @@ bun src/index.ts query --json "" | Type/interface shape | `SELECT name, type, is_optional, is_readonly FROM type_members WHERE symbol_name = '...'` | | Deprecated symbols | `SELECT name, kind, file_path, doc_comment FROM symbols WHERE doc_comment LIKE '%@deprecated%'` | | Symbol docs | `SELECT name, signature, doc_comment FROM symbols WHERE name = '...' AND doc_comment IS NOT NULL` | +| Const values | `SELECT name, value, file_path FROM symbols WHERE kind = 'const' AND value IS NOT NULL AND name LIKE '%...'` | **Use `DISTINCT`** on dependency and import queries — a file importing multiple specifiers from the same module produces duplicate rows. diff --git a/.agents/skills/codemap/SKILL.md b/.agents/skills/codemap/SKILL.md index 6e9cdb26..276b81fb 100644 --- a/.agents/skills/codemap/SKILL.md +++ b/.agents/skills/codemap/SKILL.md @@ -90,6 +90,7 @@ LIMIT 10 | is_default_export | INTEGER | 1 if default export | | members | TEXT | JSON enum members (NULL for non-enums) | | doc_comment | TEXT | Leading JSDoc text (cleaned), NULL when absent | +| value | TEXT | Literal value for consts (`"ok"`, `42`, `true`, `null`) | ### `type_members` — Properties of interfaces and object-literal type aliases @@ -220,6 +221,10 @@ WHERE doc_comment LIKE '%@deprecated%'; SELECT name, signature, doc_comment FROM symbols WHERE name = 'formatCurrency' AND doc_comment IS NOT NULL; +-- Const values (config flags, magic strings) +SELECT name, value, file_path FROM symbols +WHERE kind = 'const' AND value IS NOT NULL AND name LIKE '%URL%'; + -- File overview (imports + exports) SELECT 'import' as dir, source as name, specifiers as detail FROM imports WHERE file_path LIKE '%OrderRow%' diff --git a/docs/architecture.md b/docs/architecture.md index 34134570..1af3f173 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -186,6 +186,7 @@ All tables use `STRICT` mode. Tables marked with `WITHOUT ROWID` store data dire | is_default_export | INTEGER | 1 if default export | | members | TEXT | JSON array of enum members (NULL for non-enums). Each entry: `{"name":"…","value":"…"}` (value omitted for implicit-value enums) | | doc_comment | TEXT | Leading JSDoc comment text (cleaned: `*` prefixes stripped, trimmed). NULL when absent. Preserves `@deprecated`, `@param`, etc. tags | +| value | TEXT | Literal value for `const` declarations (strings, numbers, booleans, `null`). NULL for non-literal or non-const symbols. Handles `as const` and simple template literals | ### `type_members` — Properties and methods of interfaces and object-literal types (`STRICT`) diff --git a/src/db.ts b/src/db.ts index 8af94504..c9950952 100644 --- a/src/db.ts +++ b/src/db.ts @@ -51,7 +51,8 @@ export function createTables(db: CodemapDatabase) { is_exported INTEGER DEFAULT 0, is_default_export INTEGER DEFAULT 0, members TEXT, - doc_comment TEXT + doc_comment TEXT, + value TEXT ) STRICT; CREATE TABLE IF NOT EXISTS imports ( @@ -272,6 +273,7 @@ export interface SymbolRow { is_default_export: number; members: string | null; doc_comment: string | null; + value: string | null; } const BATCH_SIZE = 100; @@ -304,8 +306,8 @@ export function insertSymbols(db: CodemapDatabase, symbols: SymbolRow[]) { batchInsert( db, symbols, - "INSERT INTO symbols (file_path, name, kind, line_start, line_end, signature, is_exported, is_default_export, members, doc_comment)", - "(?,?,?,?,?,?,?,?,?,?)", + "INSERT INTO symbols (file_path, name, kind, line_start, line_end, signature, is_exported, is_default_export, members, doc_comment, value)", + "(?,?,?,?,?,?,?,?,?,?,?)", (s, v) => v.push( s.file_path, @@ -318,6 +320,7 @@ export function insertSymbols(db: CodemapDatabase, symbols: SymbolRow[]) { s.is_default_export, s.members, s.doc_comment, + s.value, ), ); } diff --git a/src/parser.test.ts b/src/parser.test.ts index 79a9005d..18cfd4b9 100644 --- a/src/parser.test.ts +++ b/src/parser.test.ts @@ -265,6 +265,63 @@ describe("extractFileData", () => { }); }); + describe("const literal value extraction", () => { + it("extracts string literal", () => { + const src = `export const URL = "https://api.example.com";\n`; + const d = extractFileData("/proj/x.ts", src, "x.ts"); + expect(d.symbols.find((s) => s.name === "URL")?.value).toBe( + "https://api.example.com", + ); + }); + + it("extracts number literal", () => { + const src = `export const MAX = 42;\n`; + const d = extractFileData("/proj/x.ts", src, "x.ts"); + expect(d.symbols.find((s) => s.name === "MAX")?.value).toBe("42"); + }); + + it("extracts boolean literal", () => { + const src = `export const DEBUG = true;\n`; + const d = extractFileData("/proj/x.ts", src, "x.ts"); + expect(d.symbols.find((s) => s.name === "DEBUG")?.value).toBe("true"); + }); + + it("extracts negative number", () => { + const src = `export const OFFSET = -1;\n`; + const d = extractFileData("/proj/x.ts", src, "x.ts"); + expect(d.symbols.find((s) => s.name === "OFFSET")?.value).toBe("-1"); + }); + + it("extracts null literal", () => { + const src = `export const EMPTY = null;\n`; + const d = extractFileData("/proj/x.ts", src, "x.ts"); + expect(d.symbols.find((s) => s.name === "EMPTY")?.value).toBe("null"); + }); + + it("extracts value through as const", () => { + const src = `export const MODE = "production" as const;\n`; + const d = extractFileData("/proj/x.ts", src, "x.ts"); + expect(d.symbols.find((s) => s.name === "MODE")?.value).toBe( + "production", + ); + }); + + it("extracts simple template literal without expressions", () => { + const src = `export const GREETING = \`hello world\`;\n`; + const d = extractFileData("/proj/x.ts", src, "x.ts"); + expect(d.symbols.find((s) => s.name === "GREETING")?.value).toBe( + "hello world", + ); + }); + + it("returns null for non-literal values", () => { + const src = `export const arr = [1, 2];\nexport const fn = () => {};\n`; + const d = extractFileData("/proj/x.ts", src, "x.ts"); + expect(d.symbols.find((s) => s.name === "arr")?.value).toBeNull(); + expect(d.symbols.find((s) => s.name === "fn")?.value).toBeNull(); + }); + }); + describe("component detection heuristic", () => { it("detects components that return JSX", () => { const src = `export function Card() { return
card
; }\n`; diff --git a/src/parser.ts b/src/parser.ts index 866701d1..347d0854 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -132,6 +132,7 @@ export function extractFileData( is_default_export: isDefault ? 1 : 0, members: null, doc_comment: findJsDoc(jsDocComments, node.start, source), + value: null, }); if (isTsx && /^[A-Z]/.test(name)) { @@ -175,6 +176,7 @@ export function extractFileData( is_default_export: isDefault ? 1 : 0, members: null, doc_comment: findJsDoc(jsDocComments, node.start, source), + value: isArrowOrFn ? null : extractLiteralValue(init), }); if (isTsx && /^[A-Z]/.test(name) && isArrowOrFn) { @@ -209,6 +211,7 @@ export function extractFileData( is_default_export: 0, members: null, doc_comment: findJsDoc(jsDocComments, node.start, source), + value: null, }); if (node.typeAnnotation?.type === "TSTypeLiteral") { extractObjectMembers( @@ -252,6 +255,7 @@ export function extractFileData( is_default_export: 0, members: null, doc_comment: findJsDoc(jsDocComments, node.start, source), + value: null, }); extractObjectMembers(node.body?.body, relPath, name, typeMembers); }, @@ -288,6 +292,7 @@ export function extractFileData( is_default_export: 0, members, doc_comment: findJsDoc(jsDocComments, node.start, source), + value: null, }); }, @@ -332,6 +337,7 @@ export function extractFileData( is_default_export: defaultExportedNames.has(name) ? 1 : 0, members: null, doc_comment: findJsDoc(jsDocComments, node.start, source), + value: null, }); }, @@ -614,6 +620,34 @@ function findJsDoc( return doc.text || null; } +function extractLiteralValue(init: any): string | null { + if (!init) return null; + let node = init; + if (node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression") { + node = node.expression; + } + if (node.type === "Literal") { + return node.value === null ? "null" : String(node.value); + } + if ( + node.type === "UnaryExpression" && + node.prefix && + node.operator === "-" && + node.argument?.type === "Literal" && + typeof node.argument.value === "number" + ) { + return String(-node.argument.value); + } + if ( + node.type === "TemplateLiteral" && + node.expressions?.length === 0 && + node.quasis?.length === 1 + ) { + return node.quasis[0].value?.cooked ?? null; + } + return null; +} + function extractObjectMembers( members: any[] | undefined, filePath: string, diff --git a/templates/agents/skills/codemap/SKILL.md b/templates/agents/skills/codemap/SKILL.md index b214ed77..223472ef 100644 --- a/templates/agents/skills/codemap/SKILL.md +++ b/templates/agents/skills/codemap/SKILL.md @@ -90,6 +90,7 @@ LIMIT 10 | is_default_export | INTEGER | 1 if default export | | members | TEXT | JSON enum members (NULL for non-enums) | | doc_comment | TEXT | Leading JSDoc text (cleaned), NULL when absent | +| value | TEXT | Literal value for consts (`"ok"`, `42`, `true`, `null`) | ### `type_members` — Properties of interfaces and object-literal type aliases From a7f879c76e54772464b0c93679c0bcf0c2771d10 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Wed, 8 Apr 2026 16:56:50 +0300 Subject: [PATCH 4/9] feat: symbol nesting via parent_name + class member extraction Adds parent_name column to symbols for scope tracking. A visitor stack assigns parent context to nested functions/consts (10,202 nested symbols on benchmark). Class methods, properties, and getters/setters are now extracted as individual symbols with parent_name pointing to the class. --- .agents/rules/codemap.mdc | 2 + .agents/skills/codemap/SKILL.md | 9 ++ docs/architecture.md | 1 + src/db.ts | 9 +- src/parser.test.ts | 57 ++++++++++++ src/parser.ts | 109 ++++++++++++++++++++++- templates/agents/skills/codemap/SKILL.md | 1 + 7 files changed, 184 insertions(+), 4 deletions(-) diff --git a/.agents/rules/codemap.mdc b/.agents/rules/codemap.mdc index 667dd033..749ef355 100644 --- a/.agents/rules/codemap.mdc +++ b/.agents/rules/codemap.mdc @@ -99,6 +99,8 @@ bun src/index.ts query --json "" | Deprecated symbols | `SELECT name, kind, file_path, doc_comment FROM symbols WHERE doc_comment LIKE '%@deprecated%'` | | Symbol docs | `SELECT name, signature, doc_comment FROM symbols WHERE name = '...' AND doc_comment IS NOT NULL` | | Const values | `SELECT name, value, file_path FROM symbols WHERE kind = 'const' AND value IS NOT NULL AND name LIKE '%...'` | +| Class members | `SELECT name, kind, signature FROM symbols WHERE parent_name = '...'` | +| Top-level only | `SELECT name, kind, signature FROM symbols WHERE parent_name IS NULL AND file_path LIKE '%...'` | **Use `DISTINCT`** on dependency and import queries — a file importing multiple specifiers from the same module produces duplicate rows. diff --git a/.agents/skills/codemap/SKILL.md b/.agents/skills/codemap/SKILL.md index 276b81fb..d7d1e96f 100644 --- a/.agents/skills/codemap/SKILL.md +++ b/.agents/skills/codemap/SKILL.md @@ -91,6 +91,7 @@ LIMIT 10 | members | TEXT | JSON enum members (NULL for non-enums) | | doc_comment | TEXT | Leading JSDoc text (cleaned), NULL when absent | | value | TEXT | Literal value for consts (`"ok"`, `42`, `true`, `null`) | +| parent_name | TEXT | Enclosing symbol name (class/function), NULL = top-level | ### `type_members` — Properties of interfaces and object-literal type aliases @@ -225,6 +226,14 @@ WHERE name = 'formatCurrency' AND doc_comment IS NOT NULL; SELECT name, value, file_path FROM symbols WHERE kind = 'const' AND value IS NOT NULL AND name LIKE '%URL%'; +-- Class methods (what does class X expose?) +SELECT name, kind, signature FROM symbols +WHERE parent_name = 'UserService' ORDER BY name; + +-- Top-level symbols only (skip nested helpers) +SELECT name, kind, signature FROM symbols +WHERE parent_name IS NULL AND file_path LIKE '%utils%'; + -- File overview (imports + exports) SELECT 'import' as dir, source as name, specifiers as detail FROM imports WHERE file_path LIKE '%OrderRow%' diff --git a/docs/architecture.md b/docs/architecture.md index 1af3f173..d571c1fe 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -187,6 +187,7 @@ All tables use `STRICT` mode. Tables marked with `WITHOUT ROWID` store data dire | members | TEXT | JSON array of enum members (NULL for non-enums). Each entry: `{"name":"…","value":"…"}` (value omitted for implicit-value enums) | | doc_comment | TEXT | Leading JSDoc comment text (cleaned: `*` prefixes stripped, trimmed). NULL when absent. Preserves `@deprecated`, `@param`, etc. tags | | value | TEXT | Literal value for `const` declarations (strings, numbers, booleans, `null`). NULL for non-literal or non-const symbols. Handles `as const` and simple template literals | +| parent_name | TEXT | Name of the enclosing symbol (class, function) for nested symbols. NULL for top-level (module scope). Class methods/properties point to their class | ### `type_members` — Properties and methods of interfaces and object-literal types (`STRICT`) diff --git a/src/db.ts b/src/db.ts index c9950952..5dc5879c 100644 --- a/src/db.ts +++ b/src/db.ts @@ -52,7 +52,8 @@ export function createTables(db: CodemapDatabase) { is_default_export INTEGER DEFAULT 0, members TEXT, doc_comment TEXT, - value TEXT + value TEXT, + parent_name TEXT ) STRICT; CREATE TABLE IF NOT EXISTS imports ( @@ -274,6 +275,7 @@ export interface SymbolRow { members: string | null; doc_comment: string | null; value: string | null; + parent_name: string | null; } const BATCH_SIZE = 100; @@ -306,8 +308,8 @@ export function insertSymbols(db: CodemapDatabase, symbols: SymbolRow[]) { batchInsert( db, symbols, - "INSERT INTO symbols (file_path, name, kind, line_start, line_end, signature, is_exported, is_default_export, members, doc_comment, value)", - "(?,?,?,?,?,?,?,?,?,?,?)", + "INSERT INTO symbols (file_path, name, kind, line_start, line_end, signature, is_exported, is_default_export, members, doc_comment, value, parent_name)", + "(?,?,?,?,?,?,?,?,?,?,?,?)", (s, v) => v.push( s.file_path, @@ -321,6 +323,7 @@ export function insertSymbols(db: CodemapDatabase, symbols: SymbolRow[]) { s.members, s.doc_comment, s.value, + s.parent_name, ), ); } diff --git a/src/parser.test.ts b/src/parser.test.ts index 18cfd4b9..23724f91 100644 --- a/src/parser.test.ts +++ b/src/parser.test.ts @@ -322,6 +322,63 @@ describe("extractFileData", () => { }); }); + describe("symbol nesting and scope", () => { + it("top-level symbols have null parent_name", () => { + const src = `export function foo(): void {}\nexport const bar = 42;\n`; + const d = extractFileData("/proj/x.ts", src, "x.ts"); + expect(d.symbols.find((s) => s.name === "foo")?.parent_name).toBeNull(); + expect(d.symbols.find((s) => s.name === "bar")?.parent_name).toBeNull(); + }); + + it("nested function declarations get parent_name", () => { + const src = `export function outer(): void {\n function inner(): void {}\n}\n`; + const d = extractFileData("/proj/x.ts", src, "x.ts"); + expect(d.symbols.find((s) => s.name === "inner")?.parent_name).toBe( + "outer", + ); + }); + + it("nested arrow functions get parent_name", () => { + const src = `export function Component(): void {\n const handler = (): void => {};\n}\n`; + const d = extractFileData("/proj/x.ts", src, "x.ts"); + expect(d.symbols.find((s) => s.name === "handler")?.parent_name).toBe( + "Component", + ); + }); + + it("class methods get parent_name", () => { + const src = `export class Svc {\n async run(id: string): Promise {}\n static create(): Svc { return new Svc(); }\n}\n`; + const d = extractFileData("/proj/x.ts", src, "x.ts"); + const run = d.symbols.find((s) => s.name === "run"); + expect(run?.parent_name).toBe("Svc"); + expect(run?.kind).toBe("method"); + expect(run?.signature).toContain("async"); + const create = d.symbols.find((s) => s.name === "create"); + expect(create?.parent_name).toBe("Svc"); + expect(create?.signature).toContain("static"); + }); + + it("class properties get parent_name", () => { + const src = `export class Config {\n private host: string;\n readonly port = 3000;\n}\n`; + const d = extractFileData("/proj/x.ts", src, "x.ts"); + const host = d.symbols.find((s) => s.name === "host"); + expect(host?.parent_name).toBe("Config"); + expect(host?.kind).toBe("property"); + expect(host?.signature).toContain("private"); + const port = d.symbols.find((s) => s.name === "port"); + expect(port?.signature).toContain("readonly"); + expect(port?.value).toBe("3000"); + }); + + it("class getters get kind getter", () => { + const src = `export class Store {\n get count(): number { return 0; }\n}\n`; + const d = extractFileData("/proj/x.ts", src, "x.ts"); + const count = d.symbols.find((s) => s.name === "count"); + expect(count?.kind).toBe("getter"); + expect(count?.parent_name).toBe("Store"); + }); + }); + describe("component detection heuristic", () => { it("detects components that return JSX", () => { const src = `export function Card() { return
card
; }\n`; diff --git a/src/parser.ts b/src/parser.ts index 347d0854..d7628de1 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -110,6 +110,9 @@ export function extractFileData( const hookCalls = new Map>(); // function scope name -> hook names const jsxScopes = new Set(); // function scopes that contain JSX let currentFunctionScope: string | null = null; + const scopeStack: string[] = []; + const currentParent = () => + scopeStack.length ? scopeStack[scopeStack.length - 1] : null; const visitor = new Visitor({ FunctionDeclaration(node: any) { @@ -133,8 +136,10 @@ export function extractFileData( members: null, doc_comment: findJsDoc(jsDocComments, node.start, source), value: null, + parent_name: currentParent(), }); + scopeStack.push(name); if (isTsx && /^[A-Z]/.test(name)) { currentFunctionScope = name; hookCalls.set(name, new Set()); @@ -142,6 +147,9 @@ export function extractFileData( }, "FunctionDeclaration:exit"(node: any) { const name = node.id?.name; + if (name && scopeStack[scopeStack.length - 1] === name) { + scopeStack.pop(); + } if (name && currentFunctionScope === name) { maybeAddComponent(name, node, false); currentFunctionScope = null; @@ -177,8 +185,12 @@ export function extractFileData( members: null, doc_comment: findJsDoc(jsDocComments, node.start, source), value: isArrowOrFn ? null : extractLiteralValue(init), + parent_name: currentParent(), }); + if (isArrowOrFn) { + scopeStack.push(name); + } if (isTsx && /^[A-Z]/.test(name) && isArrowOrFn) { currentFunctionScope = name; hookCalls.set(name, new Set()); @@ -188,8 +200,16 @@ export function extractFileData( "VariableDeclaration:exit"(node: any) { for (const decl of node.declarations) { const name = decl.id?.name; + if (!name) continue; + const init = decl.init; + const isArrowOrFn = + init?.type === "ArrowFunctionExpression" || + init?.type === "FunctionExpression"; + if (isArrowOrFn && scopeStack[scopeStack.length - 1] === name) { + scopeStack.pop(); + } if (name && currentFunctionScope === name) { - maybeAddComponent(name, decl.init, true); + maybeAddComponent(name, init, true); currentFunctionScope = null; } } @@ -212,6 +232,7 @@ export function extractFileData( members: null, doc_comment: findJsDoc(jsDocComments, node.start, source), value: null, + parent_name: currentParent(), }); if (node.typeAnnotation?.type === "TSTypeLiteral") { extractObjectMembers( @@ -256,6 +277,7 @@ export function extractFileData( members: null, doc_comment: findJsDoc(jsDocComments, node.start, source), value: null, + parent_name: currentParent(), }); extractObjectMembers(node.body?.body, relPath, name, typeMembers); }, @@ -293,6 +315,7 @@ export function extractFileData( members, doc_comment: findJsDoc(jsDocComments, node.start, source), value: null, + parent_name: currentParent(), }); }, @@ -338,7 +361,24 @@ export function extractFileData( members: null, doc_comment: findJsDoc(jsDocComments, node.start, source), value: null, + parent_name: currentParent(), }); + scopeStack.push(name); + extractClassMembers( + node.body?.body, + relPath, + name, + lineMap, + symbols, + jsDocComments, + source, + ); + }, + "ClassDeclaration:exit"(node: any) { + const name = node.id?.name; + if (name && scopeStack[scopeStack.length - 1] === name) { + scopeStack.pop(); + } }, CallExpression(node: any) { @@ -620,6 +660,73 @@ function findJsDoc( return doc.text || null; } +function extractClassMembers( + members: any[] | undefined, + filePath: string, + className: string, + lineMap: number[], + out: SymbolRow[], + jsDocComments: JsDocEntry[], + source: string, +) { + if (!members?.length) return; + for (const m of members) { + const name = m.key?.name; + if (!name) continue; + + if (m.type === "MethodDefinition") { + const fn = m.value; + const kind = + m.kind === "get" ? "getter" : m.kind === "set" ? "setter" : "method"; + let prefix = ""; + if (m.accessibility && m.accessibility !== "public") { + prefix += `${m.accessibility} `; + } + if (m.static) prefix += "static "; + if (fn?.async) prefix += "async "; + const sig = `${prefix}${buildFunctionSignature(name, fn)}`; + out.push({ + file_path: filePath, + name, + kind, + line_start: offsetToLine(lineMap, m.start), + line_end: offsetToLine(lineMap, m.end), + signature: sig, + is_exported: 0, + is_default_export: 0, + members: null, + doc_comment: findJsDoc(jsDocComments, m.start, source), + value: null, + parent_name: className, + }); + } else if (m.type === "PropertyDefinition") { + let prefix = ""; + if (m.accessibility && m.accessibility !== "public") { + prefix += `${m.accessibility} `; + } + if (m.static) prefix += "static "; + if (m.readonly) prefix += "readonly "; + const ta = m.typeAnnotation?.typeAnnotation; + const typeStr = ta ? stringifyTypeNode(ta) : null; + const sig = typeStr ? `${prefix}${name}: ${typeStr}` : `${prefix}${name}`; + out.push({ + file_path: filePath, + name, + kind: "property", + line_start: offsetToLine(lineMap, m.start), + line_end: offsetToLine(lineMap, m.end), + signature: sig, + is_exported: 0, + is_default_export: 0, + members: null, + doc_comment: findJsDoc(jsDocComments, m.start, source), + value: extractLiteralValue(m.value), + parent_name: className, + }); + } + } +} + function extractLiteralValue(init: any): string | null { if (!init) return null; let node = init; diff --git a/templates/agents/skills/codemap/SKILL.md b/templates/agents/skills/codemap/SKILL.md index 223472ef..64bb202a 100644 --- a/templates/agents/skills/codemap/SKILL.md +++ b/templates/agents/skills/codemap/SKILL.md @@ -91,6 +91,7 @@ LIMIT 10 | members | TEXT | JSON enum members (NULL for non-enums) | | doc_comment | TEXT | Leading JSDoc text (cleaned), NULL when absent | | value | TEXT | Literal value for consts (`"ok"`, `42`, `true`, `null`) | +| parent_name | TEXT | Enclosing symbol name (class/function), NULL = top-level | ### `type_members` — Properties of interfaces and object-literal type aliases From 932bcf5c9d89bca56bba13c57c8932f76b6182c7 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Wed, 8 Apr 2026 17:12:28 +0300 Subject: [PATCH 5/9] =?UTF-8?q?feat:=20call=20graph=20extraction=20?= =?UTF-8?q?=E2=80=94=20function-scoped,=20deduped=20per=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `calls` table tracks which functions call which. Edges are deduped per file and limited to function-scoped calls (module-level excluded). Benchmark (merchant-dashboard-v2): 13,804 edges, 2,700 callers, 5,310 callees. Index time unchanged, DB +5MB (23→28MB). --- .agents/rules/codemap.mdc | 4 ++ .agents/skills/codemap/SKILL.md | 21 +++++++++ docs/architecture.md | 11 +++++ src/adapters/builtin.ts | 1 + src/adapters/types.ts | 1 + src/application/index-engine.ts | 4 ++ src/application/run-index.ts | 1 + src/application/types.ts | 1 + src/db.ts | 28 +++++++++++ src/parsed-types.ts | 2 + src/parser.test.ts | 59 ++++++++++++++++++++++++ src/parser.ts | 47 +++++++++++++++++-- templates/agents/skills/codemap/SKILL.md | 9 ++++ 13 files changed, 185 insertions(+), 4 deletions(-) diff --git a/.agents/rules/codemap.mdc b/.agents/rules/codemap.mdc index 749ef355..43c74925 100644 --- a/.agents/rules/codemap.mdc +++ b/.agents/rules/codemap.mdc @@ -58,6 +58,7 @@ If the question looks like any of these → use the index: | "What keyframe animations exist?" | `css_keyframes` | | "What fields does interface/type X have?" | `type_members` | | "Is symbol X deprecated?" / "What does X do?" | `symbols` (`doc_comment`) | +| "Who calls X?" / "What does X call?" | `calls` | ## When Grep / Read IS appropriate @@ -101,6 +102,9 @@ bun src/index.ts query --json "" | Const values | `SELECT name, value, file_path FROM symbols WHERE kind = 'const' AND value IS NOT NULL AND name LIKE '%...'` | | Class members | `SELECT name, kind, signature FROM symbols WHERE parent_name = '...'` | | Top-level only | `SELECT name, kind, signature FROM symbols WHERE parent_name IS NULL AND file_path LIKE '%...'` | +| Who calls X? | `SELECT DISTINCT caller_name, file_path FROM calls WHERE callee_name = '...'` | +| What does X call? | `SELECT DISTINCT callee_name FROM calls WHERE caller_name = '...'` | +| Call hotspots | `SELECT callee_name, COUNT(*) as fan_in FROM calls GROUP BY callee_name ORDER BY fan_in DESC LIMIT 10` | **Use `DISTINCT`** on dependency and import queries — a file importing multiple specifiers from the same module produces duplicate rows. diff --git a/.agents/skills/codemap/SKILL.md b/.agents/skills/codemap/SKILL.md index d7d1e96f..e5ebeaa1 100644 --- a/.agents/skills/codemap/SKILL.md +++ b/.agents/skills/codemap/SKILL.md @@ -93,6 +93,15 @@ LIMIT 10 | value | TEXT | Literal value for consts (`"ok"`, `42`, `true`, `null`) | | parent_name | TEXT | Enclosing symbol name (class/function), NULL = top-level | +### `calls` — Function-scoped call edges (deduped per file) + +| Column | Type | Description | +| ----------- | ---------- | ------------------------------- | +| id | INTEGER PK | Auto-increment ID | +| file_path | TEXT FK | References `files(path)` | +| caller_name | TEXT | Calling function/method name | +| callee_name | TEXT | Called function or `obj.method` | + ### `type_members` — Properties of interfaces and object-literal type aliases | Column | Type | Description | @@ -234,6 +243,18 @@ WHERE parent_name = 'UserService' ORDER BY name; SELECT name, kind, signature FROM symbols WHERE parent_name IS NULL AND file_path LIKE '%utils%'; +-- Who calls function X? (fan-in) +SELECT DISTINCT caller_name, file_path FROM calls +WHERE callee_name = 'fetchUser'; + +-- What does function X call? (fan-out) +SELECT DISTINCT callee_name FROM calls +WHERE caller_name = 'processUser'; + +-- Most-called functions (hotspots) +SELECT callee_name, COUNT(*) as fan_in FROM calls +GROUP BY callee_name ORDER BY fan_in DESC LIMIT 10; + -- File overview (imports + exports) SELECT 'import' as dir, source as name, specifiers as detail FROM imports WHERE file_path LIKE '%OrderRow%' diff --git a/docs/architecture.md b/docs/architecture.md index d571c1fe..73832c38 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -189,6 +189,17 @@ All tables use `STRICT` mode. Tables marked with `WITHOUT ROWID` store data dire | value | TEXT | Literal value for `const` declarations (strings, numbers, booleans, `null`). NULL for non-literal or non-const symbols. Handles `as const` and simple template literals | | parent_name | TEXT | Name of the enclosing symbol (class, function) for nested symbols. NULL for top-level (module scope). Class methods/properties point to their class | +### `calls` — Function-scoped call edges, deduped per file (`STRICT`) + +| Column | Type | Description | +| ----------- | ---------- | ------------------------------------------------------------ | +| id | INTEGER PK | Auto-increment row id | +| file_path | TEXT FK | References `files(path)` ON DELETE CASCADE | +| caller_name | TEXT | Name of the calling function/method | +| callee_name | TEXT | Name of the called function or `obj.method` for member calls | + +Edges are deduped per file: if `foo` calls `bar` three times in the same file, only one row is stored. Module-level calls (outside any function) are excluded — only function-scoped calls are tracked. + ### `type_members` — Properties and methods of interfaces and object-literal types (`STRICT`) | Column | Type | Description | diff --git a/src/adapters/builtin.ts b/src/adapters/builtin.ts index 828c0502..12863745 100644 --- a/src/adapters/builtin.ts +++ b/src/adapters/builtin.ts @@ -24,6 +24,7 @@ function parseTsJs(ctx: ParseContext): ParsedFilePayload { components: data.components, markers: data.markers, typeMembers: data.typeMembers, + calls: data.calls, }; } diff --git a/src/adapters/types.ts b/src/adapters/types.ts index fcf90d1b..da888b00 100644 --- a/src/adapters/types.ts +++ b/src/adapters/types.ts @@ -28,6 +28,7 @@ export type ParsedFilePayload = Pick< | "components" | "markers" | "typeMembers" + | "calls" | "cssVariables" | "cssClasses" | "cssKeyframes" diff --git a/src/application/index-engine.ts b/src/application/index-engine.ts index 35950d38..e726df85 100644 --- a/src/application/index-engine.ts +++ b/src/application/index-engine.ts @@ -25,6 +25,7 @@ import { insertCssClasses, insertCssKeyframes, insertTypeMembers, + insertCalls, getAllFileHashes, SCHEMA_VERSION, type CodemapDatabase, @@ -224,6 +225,7 @@ function insertParsedResults( if (parsed.typeMembers?.length) { insertTypeMembers(db, parsed.typeMembers); } + if (parsed.calls?.length) insertCalls(db, parsed.calls); } } catch (err) { console.error( @@ -251,6 +253,7 @@ export function fetchTableStats(db: CodemapDatabase): IndexTableStats { (SELECT COUNT(*) FROM dependencies) as dependencies, (SELECT COUNT(*) FROM markers) as markers, (SELECT COUNT(*) FROM type_members) as type_members, + (SELECT COUNT(*) FROM calls) as calls, (SELECT COUNT(*) FROM css_variables) as css_vars, (SELECT COUNT(*) FROM css_classes) as css_classes, (SELECT COUNT(*) FROM css_keyframes) as css_keyframes`, @@ -367,6 +370,7 @@ export async function indexFiles( if (data.markers.length) insertMarkers(db, data.markers); if (data.typeMembers.length) insertTypeMembers(db, data.typeMembers); + if (data.calls.length) insertCalls(db, data.calls); } } catch (err) { console.error( diff --git a/src/application/run-index.ts b/src/application/run-index.ts index 358c93f0..df355f91 100644 --- a/src/application/run-index.ts +++ b/src/application/run-index.ts @@ -21,6 +21,7 @@ function emptyStats(): IndexTableStats { dependencies: 0, markers: 0, type_members: 0, + calls: 0, css_vars: 0, css_classes: 0, css_keyframes: 0, diff --git a/src/application/types.ts b/src/application/types.ts index fe253970..32a386fa 100644 --- a/src/application/types.ts +++ b/src/application/types.ts @@ -10,6 +10,7 @@ export interface IndexTableStats extends Record { dependencies: number; markers: number; type_members: number; + calls: number; css_vars: number; css_classes: number; css_keyframes: number; diff --git a/src/db.ts b/src/db.ts index 5dc5879c..c6079eee 100644 --- a/src/db.ts +++ b/src/db.ts @@ -122,6 +122,13 @@ export function createTables(db: CodemapDatabase) { line_number INTEGER ) STRICT; + CREATE TABLE IF NOT EXISTS calls ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_path TEXT NOT NULL REFERENCES files(path) ON DELETE CASCADE, + caller_name TEXT NOT NULL, + callee_name TEXT NOT NULL + ) STRICT; + CREATE TABLE IF NOT EXISTS type_members ( id INTEGER PRIMARY KEY AUTOINCREMENT, file_path TEXT NOT NULL REFERENCES files(path) ON DELETE CASCADE, @@ -176,6 +183,10 @@ export function createIndexes(db: CodemapDatabase) { CREATE INDEX IF NOT EXISTS idx_type_members_symbol ON type_members(symbol_name, file_path, name, type, is_optional, is_readonly); CREATE INDEX IF NOT EXISTS idx_type_members_file ON type_members(file_path); + + CREATE INDEX IF NOT EXISTS idx_calls_caller ON calls(caller_name, file_path); + CREATE INDEX IF NOT EXISTS idx_calls_callee ON calls(callee_name, file_path); + CREATE INDEX IF NOT EXISTS idx_calls_file ON calls(file_path); `); } @@ -204,6 +215,7 @@ export function createSchema(db: CodemapDatabase) { export function dropAll(db: CodemapDatabase) { db.run(` + DROP TABLE IF EXISTS calls; DROP TABLE IF EXISTS type_members; DROP TABLE IF EXISTS dependencies; DROP TABLE IF EXISTS markers; @@ -492,6 +504,22 @@ export function insertCssKeyframes( ); } +export interface CallRow { + file_path: string; + caller_name: string; + callee_name: string; +} + +export function insertCalls(db: CodemapDatabase, calls: CallRow[]) { + batchInsert( + db, + calls, + "INSERT INTO calls (file_path, caller_name, callee_name)", + "(?,?,?)", + (c, v) => v.push(c.file_path, c.caller_name, c.callee_name), + ); +} + export interface TypeMemberRow { file_path: string; symbol_name: string; diff --git a/src/parsed-types.ts b/src/parsed-types.ts index decc475f..3aaa4577 100644 --- a/src/parsed-types.ts +++ b/src/parsed-types.ts @@ -9,6 +9,7 @@ import type { CssClassRow, CssKeyframeRow, TypeMemberRow, + CallRow, } from "./db"; /** @@ -26,6 +27,7 @@ export interface ParsedFile { components?: ComponentRow[]; markers?: MarkerRow[]; typeMembers?: TypeMemberRow[]; + calls?: CallRow[]; cssVariables?: CssVariableRow[]; cssClasses?: CssClassRow[]; cssKeyframes?: CssKeyframeRow[]; diff --git a/src/parser.test.ts b/src/parser.test.ts index 23724f91..660a744d 100644 --- a/src/parser.test.ts +++ b/src/parser.test.ts @@ -379,6 +379,65 @@ describe("extractFileData", () => { }); }); + describe("call graph extraction", () => { + it("extracts function-to-function calls", () => { + const src = `function foo() { bar(); baz(); }\nfunction bar() {}\nfunction baz() {}\n`; + const d = extractFileData("/proj/x.ts", src, "x.ts"); + expect(d.calls).toHaveLength(2); + expect(d.calls[0]).toMatchObject({ + caller_name: "foo", + callee_name: "bar", + }); + expect(d.calls[1]).toMatchObject({ + caller_name: "foo", + callee_name: "baz", + }); + }); + + it("extracts member expression calls", () => { + const src = `function init() { console.log("hi"); arr.push(1); }\n`; + const d = extractFileData("/proj/x.ts", src, "x.ts"); + const names = d.calls.map((c) => c.callee_name); + expect(names).toContain("console.log"); + expect(names).toContain("arr.push"); + }); + + it("deduplicates calls within same caller", () => { + const src = `function process() { save(); save(); save(); }\n`; + const d = extractFileData("/proj/x.ts", src, "x.ts"); + expect(d.calls).toHaveLength(1); + expect(d.calls[0]).toMatchObject({ + caller_name: "process", + callee_name: "save", + }); + }); + + it("tracks calls from arrow functions", () => { + const src = `const handler = () => { validate(); submit(); };\n`; + const d = extractFileData("/proj/x.ts", src, "x.ts"); + expect(d.calls).toHaveLength(2); + expect(d.calls[0].caller_name).toBe("handler"); + }); + + it("skips module-level calls (no caller scope)", () => { + const src = `configure();\nfunction foo() { bar(); }\n`; + const d = extractFileData("/proj/x.ts", src, "x.ts"); + expect(d.calls).toHaveLength(1); + expect(d.calls[0]).toMatchObject({ + caller_name: "foo", + callee_name: "bar", + }); + }); + + it("tracks calls from class methods", () => { + const src = `class Svc {\n run() { this.validate(); fetch(); }\n}\n`; + const d = extractFileData("/proj/x.ts", src, "x.ts"); + const fromRun = d.calls.filter((c) => c.caller_name === "run"); + expect(fromRun.length).toBeGreaterThanOrEqual(1); + expect(fromRun.map((c) => c.callee_name)).toContain("fetch"); + }); + }); + describe("component detection heuristic", () => { it("detects components that return JSX", () => { const src = `export function Card() { return
card
; }\n`; diff --git a/src/parser.ts b/src/parser.ts index d7628de1..c85b2b9d 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -15,6 +15,7 @@ import type { ComponentRow, MarkerRow, TypeMemberRow, + CallRow, } from "./db"; import { extractMarkers } from "./markers"; @@ -25,6 +26,7 @@ interface ExtractedData { components: ComponentRow[]; markers: MarkerRow[]; typeMembers: TypeMemberRow[]; + calls: CallRow[]; } /** @@ -81,6 +83,8 @@ export function extractFileData( const components: ComponentRow[] = []; const markers: MarkerRow[] = []; const typeMembers: TypeMemberRow[] = []; + const calls: CallRow[] = []; + const seenCalls = new Set(); const exportedNames = new Set(); const defaultExportedNames = new Set(); @@ -381,11 +385,46 @@ export function extractFileData( } }, + MethodDefinition(node: any) { + const name = node.key?.name; + if (name) scopeStack.push(name); + }, + "MethodDefinition:exit"(node: any) { + const name = node.key?.name; + if (name && scopeStack[scopeStack.length - 1] === name) { + scopeStack.pop(); + } + }, + CallExpression(node: any) { - if (!currentFunctionScope) return; + if (currentFunctionScope) { + const callee = node.callee; + if (callee?.type === "Identifier" && /^use[A-Z]/.test(callee.name)) { + hookCalls.get(currentFunctionScope)?.add(callee.name); + } + } + const caller = currentParent(); + if (!caller) return; const callee = node.callee; - if (callee?.type === "Identifier" && /^use[A-Z]/.test(callee.name)) { - hookCalls.get(currentFunctionScope)?.add(callee.name); + let calleeName: string | null = null; + if (callee?.type === "Identifier") { + calleeName = callee.name; + } else if ( + callee?.type === "MemberExpression" && + callee.object?.type === "Identifier" + ) { + calleeName = `${callee.object.name}.${callee.property?.name}`; + } + if (calleeName) { + const key = `${caller}>>${calleeName}`; + if (!seenCalls.has(key)) { + seenCalls.add(key); + calls.push({ + file_path: relPath, + caller_name: caller, + callee_name: calleeName, + }); + } } }, @@ -429,7 +468,7 @@ export function extractFileData( }); } - return { symbols, imports, exports, components, markers, typeMembers }; + return { symbols, imports, exports, components, markers, typeMembers, calls }; } function staticImportToRow( diff --git a/templates/agents/skills/codemap/SKILL.md b/templates/agents/skills/codemap/SKILL.md index 64bb202a..80e0548d 100644 --- a/templates/agents/skills/codemap/SKILL.md +++ b/templates/agents/skills/codemap/SKILL.md @@ -93,6 +93,15 @@ LIMIT 10 | value | TEXT | Literal value for consts (`"ok"`, `42`, `true`, `null`) | | parent_name | TEXT | Enclosing symbol name (class/function), NULL = top-level | +### `calls` — Function-scoped call edges (deduped per file) + +| Column | Type | Description | +| ----------- | ---------- | ------------------------------- | +| id | INTEGER PK | Auto-increment ID | +| file_path | TEXT FK | References `files(path)` | +| caller_name | TEXT | Calling function/method name | +| callee_name | TEXT | Called function or `obj.method` | + ### `type_members` — Properties of interfaces and object-literal type aliases | Column | Type | Description | From a3470d05f7b50c03cb688923aec7870b7e59b56d Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Wed, 8 Apr 2026 17:16:53 +0300 Subject: [PATCH 6/9] chore: add changeset for richer symbol metadata --- .changeset/richer-symbol-metadata.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .changeset/richer-symbol-metadata.md diff --git a/.changeset/richer-symbol-metadata.md b/.changeset/richer-symbol-metadata.md new file mode 100644 index 00000000..a88ca05a --- /dev/null +++ b/.changeset/richer-symbol-metadata.md @@ -0,0 +1,14 @@ +--- +"@stainless-code/codemap": minor +--- + +Richer symbol metadata: generics, return types, JSDoc, type members, const values, symbol nesting, call graph + +- Signatures now include generic type parameters, return type annotations, and heritage clauses (extends/implements) +- New `doc_comment` column on symbols extracts leading JSDoc comments +- New `type_members` table indexes properties and methods of interfaces and object-literal types +- New `value` column on symbols captures const literal values (strings, numbers, booleans, null) +- New `parent_name` column on symbols tracks scope nesting; class methods/properties/getters extracted as individual symbols +- New `calls` table tracks function-scoped call edges (deduped per file) for fan-in/fan-out and impact analysis +- Enum members extracted into `members` column as JSON +- SCHEMA_VERSION bumped to 2 From e525e06b4513c8602837d0b6e1b6e898c4495aec Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Wed, 8 Apr 2026 17:46:56 +0300 Subject: [PATCH 7/9] =?UTF-8?q?fix:=20call=20graph=20improvements=20?= =?UTF-8?q?=E2=80=94=20qualified=20scope,=20this.method(),=20multi-declara?= =?UTF-8?q?tor=20scope=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add caller_scope column to calls table (dot-joined scope path, e.g. UserService.run) so same-named methods across classes are no longer conflated. Dedup key now uses full scope path. - Handle this.foo() calls in CallExpression (emitted as this.methodName). - Fix multi-declarator scope corruption: VariableDeclaration:exit now iterates in reverse so const a = () => {}, b = () => {} correctly pops both scopes. - Document new class-member kind values (method, property, getter, setter) in architecture.md. - Sync template SKILL.md with authoring skill query examples. --- .agents/skills/codemap/SKILL.md | 13 ++++--- docs/architecture.md | 17 ++++---- src/db.ts | 8 ++-- src/parser.test.ts | 18 ++++++++- src/parser.ts | 20 ++++++---- templates/agents/skills/codemap/SKILL.md | 49 +++++++++++++++++++++--- 6 files changed, 93 insertions(+), 32 deletions(-) diff --git a/.agents/skills/codemap/SKILL.md b/.agents/skills/codemap/SKILL.md index e5ebeaa1..c6a0d333 100644 --- a/.agents/skills/codemap/SKILL.md +++ b/.agents/skills/codemap/SKILL.md @@ -95,12 +95,13 @@ LIMIT 10 ### `calls` — Function-scoped call edges (deduped per file) -| Column | Type | Description | -| ----------- | ---------- | ------------------------------- | -| id | INTEGER PK | Auto-increment ID | -| file_path | TEXT FK | References `files(path)` | -| caller_name | TEXT | Calling function/method name | -| callee_name | TEXT | Called function or `obj.method` | +| Column | Type | Description | +| ------------ | ---------- | ----------------------------------------------- | +| id | INTEGER PK | Auto-increment ID | +| file_path | TEXT FK | References `files(path)` | +| caller_name | TEXT | Calling function/method name | +| caller_scope | TEXT | Dot-joined scope path (e.g. `MyClass.run`) | +| callee_name | TEXT | Called function, `obj.method`, or `this.method` | ### `type_members` — Properties of interfaces and object-literal type aliases diff --git a/docs/architecture.md b/docs/architecture.md index 73832c38..8cb55e7b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -178,7 +178,7 @@ All tables use `STRICT` mode. Tables marked with `WITHOUT ROWID` store data dire | id | INTEGER PK | Auto-increment row id | | file_path | TEXT FK | References `files(path)` ON DELETE CASCADE | | name | TEXT | Symbol name | -| kind | TEXT | `function`, `const`, `class`, `interface`, `type`, `enum` | +| kind | TEXT | `function`, `const`, `class`, `interface`, `type`, `enum`, `method`, `property`, `getter`, `setter` (last four are class members) | | line_start | INTEGER | Start line (1-based) | | line_end | INTEGER | End line | | signature | TEXT | Reconstructed signature with generics and return types (e.g. `identity(val): T`, `interface Repo extends Iterable`, `class Store extends Base implements IStore`) | @@ -191,14 +191,15 @@ All tables use `STRICT` mode. Tables marked with `WITHOUT ROWID` store data dire ### `calls` — Function-scoped call edges, deduped per file (`STRICT`) -| Column | Type | Description | -| ----------- | ---------- | ------------------------------------------------------------ | -| id | INTEGER PK | Auto-increment row id | -| file_path | TEXT FK | References `files(path)` ON DELETE CASCADE | -| caller_name | TEXT | Name of the calling function/method | -| callee_name | TEXT | Name of the called function or `obj.method` for member calls | +| Column | Type | Description | +| ------------ | ---------- | ---------------------------------------------------------------------------------- | +| id | INTEGER PK | Auto-increment row id | +| file_path | TEXT FK | References `files(path)` ON DELETE CASCADE | +| caller_name | TEXT | Name of the calling function/method | +| caller_scope | TEXT | Dot-joined scope path (e.g. `UserService.run`). Disambiguates same-named methods | +| callee_name | TEXT | Name of the called function, `obj.method` for member calls, `this.method` for self | -Edges are deduped per file: if `foo` calls `bar` three times in the same file, only one row is stored. Module-level calls (outside any function) are excluded — only function-scoped calls are tracked. +Edges are deduped per (caller_scope, callee) per file: if `foo` calls `bar` three times in the same file, only one row is stored. Same-named methods in different classes get distinct `caller_scope` values. Module-level calls (outside any function) are excluded — only function-scoped calls are tracked. ### `type_members` — Properties and methods of interfaces and object-literal types (`STRICT`) diff --git a/src/db.ts b/src/db.ts index c6079eee..928aa2f3 100644 --- a/src/db.ts +++ b/src/db.ts @@ -126,6 +126,7 @@ export function createTables(db: CodemapDatabase) { id INTEGER PRIMARY KEY AUTOINCREMENT, file_path TEXT NOT NULL REFERENCES files(path) ON DELETE CASCADE, caller_name TEXT NOT NULL, + caller_scope TEXT NOT NULL, callee_name TEXT NOT NULL ) STRICT; @@ -507,6 +508,7 @@ export function insertCssKeyframes( export interface CallRow { file_path: string; caller_name: string; + caller_scope: string; callee_name: string; } @@ -514,9 +516,9 @@ export function insertCalls(db: CodemapDatabase, calls: CallRow[]) { batchInsert( db, calls, - "INSERT INTO calls (file_path, caller_name, callee_name)", - "(?,?,?)", - (c, v) => v.push(c.file_path, c.caller_name, c.callee_name), + "INSERT INTO calls (file_path, caller_name, caller_scope, callee_name)", + "(?,?,?,?)", + (c, v) => v.push(c.file_path, c.caller_name, c.caller_scope, c.callee_name), ); } diff --git a/src/parser.test.ts b/src/parser.test.ts index 660a744d..2b424cf0 100644 --- a/src/parser.test.ts +++ b/src/parser.test.ts @@ -386,10 +386,12 @@ describe("extractFileData", () => { expect(d.calls).toHaveLength(2); expect(d.calls[0]).toMatchObject({ caller_name: "foo", + caller_scope: "foo", callee_name: "bar", }); expect(d.calls[1]).toMatchObject({ caller_name: "foo", + caller_scope: "foo", callee_name: "baz", }); }); @@ -417,6 +419,7 @@ describe("extractFileData", () => { const d = extractFileData("/proj/x.ts", src, "x.ts"); expect(d.calls).toHaveLength(2); expect(d.calls[0].caller_name).toBe("handler"); + expect(d.calls[0].caller_scope).toBe("handler"); }); it("skips module-level calls (no caller scope)", () => { @@ -429,12 +432,23 @@ describe("extractFileData", () => { }); }); - it("tracks calls from class methods", () => { + it("tracks calls from class methods with qualified scope", () => { const src = `class Svc {\n run() { this.validate(); fetch(); }\n}\n`; const d = extractFileData("/proj/x.ts", src, "x.ts"); const fromRun = d.calls.filter((c) => c.caller_name === "run"); - expect(fromRun.length).toBeGreaterThanOrEqual(1); + expect(fromRun.length).toBe(2); expect(fromRun.map((c) => c.callee_name)).toContain("fetch"); + expect(fromRun.map((c) => c.callee_name)).toContain("this.validate"); + expect(fromRun[0].caller_scope).toBe("Svc.run"); + }); + + it("distinguishes same-named methods across classes", () => { + const src = `class A { run() { foo(); } }\nclass B { run() { bar(); } }\n`; + const d = extractFileData("/proj/x.ts", src, "x.ts"); + expect(d.calls).toHaveLength(2); + const scopes = d.calls.map((c) => c.caller_scope); + expect(scopes).toContain("A.run"); + expect(scopes).toContain("B.run"); }); }); diff --git a/src/parser.ts b/src/parser.ts index c85b2b9d..5cc97d75 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -117,6 +117,7 @@ export function extractFileData( const scopeStack: string[] = []; const currentParent = () => scopeStack.length ? scopeStack[scopeStack.length - 1] : null; + const currentScope = () => scopeStack.join("."); const visitor = new Visitor({ FunctionDeclaration(node: any) { @@ -202,7 +203,9 @@ export function extractFileData( } }, "VariableDeclaration:exit"(node: any) { - for (const decl of node.declarations) { + const decls = node.declarations; + for (let i = decls.length - 1; i >= 0; i--) { + const decl = decls[i]; const name = decl.id?.name; if (!name) continue; const init = decl.init; @@ -409,19 +412,22 @@ export function extractFileData( let calleeName: string | null = null; if (callee?.type === "Identifier") { calleeName = callee.name; - } else if ( - callee?.type === "MemberExpression" && - callee.object?.type === "Identifier" - ) { - calleeName = `${callee.object.name}.${callee.property?.name}`; + } else if (callee?.type === "MemberExpression" && callee.property?.name) { + if (callee.object?.type === "Identifier") { + calleeName = `${callee.object.name}.${callee.property.name}`; + } else if (callee.object?.type === "ThisExpression") { + calleeName = `this.${callee.property.name}`; + } } if (calleeName) { - const key = `${caller}>>${calleeName}`; + const scope = currentScope(); + const key = `${scope}>>${calleeName}`; if (!seenCalls.has(key)) { seenCalls.add(key); calls.push({ file_path: relPath, caller_name: caller, + caller_scope: scope, callee_name: calleeName, }); } diff --git a/templates/agents/skills/codemap/SKILL.md b/templates/agents/skills/codemap/SKILL.md index 80e0548d..dac6e13b 100644 --- a/templates/agents/skills/codemap/SKILL.md +++ b/templates/agents/skills/codemap/SKILL.md @@ -95,12 +95,13 @@ LIMIT 10 ### `calls` — Function-scoped call edges (deduped per file) -| Column | Type | Description | -| ----------- | ---------- | ------------------------------- | -| id | INTEGER PK | Auto-increment ID | -| file_path | TEXT FK | References `files(path)` | -| caller_name | TEXT | Calling function/method name | -| callee_name | TEXT | Called function or `obj.method` | +| Column | Type | Description | +| ------------ | ---------- | ----------------------------------------------- | +| id | INTEGER PK | Auto-increment ID | +| file_path | TEXT FK | References `files(path)` | +| caller_name | TEXT | Calling function/method name | +| caller_scope | TEXT | Dot-joined scope path (e.g. `MyClass.run`) | +| callee_name | TEXT | Called function, `obj.method`, or `this.method` | ### `type_members` — Properties of interfaces and object-literal type aliases @@ -219,6 +220,42 @@ FROM symbols WHERE file_path LIKE '%settings-provider%' AND is_exported = 1; SELECT name, members FROM symbols WHERE kind = 'enum' AND name = 'TransactionStatus'; +-- Interface / type shape (what fields does a type have?) +SELECT name, type, is_optional, is_readonly FROM type_members +WHERE symbol_name = 'UserSession'; + +-- Deprecated symbols (find @deprecated via JSDoc) +SELECT name, kind, file_path, doc_comment FROM symbols +WHERE doc_comment LIKE '%@deprecated%'; + +-- Symbol documentation +SELECT name, signature, doc_comment FROM symbols +WHERE name = 'formatCurrency' AND doc_comment IS NOT NULL; + +-- Const values (config flags, magic strings) +SELECT name, value, file_path FROM symbols +WHERE kind = 'const' AND value IS NOT NULL AND name LIKE '%URL%'; + +-- Class methods (what does class X expose?) +SELECT name, kind, signature FROM symbols +WHERE parent_name = 'UserService' ORDER BY name; + +-- Top-level symbols only (skip nested helpers) +SELECT name, kind, signature FROM symbols +WHERE parent_name IS NULL AND file_path LIKE '%utils%'; + +-- Who calls function X? (fan-in) +SELECT DISTINCT caller_name, file_path FROM calls +WHERE callee_name = 'fetchUser'; + +-- What does function X call? (fan-out) +SELECT DISTINCT callee_name FROM calls +WHERE caller_name = 'processUser'; + +-- Most-called functions (hotspots) +SELECT callee_name, COUNT(*) as fan_in FROM calls +GROUP BY callee_name ORDER BY fan_in DESC LIMIT 10; + -- File overview (imports + exports) SELECT 'import' as dir, source as name, specifiers as detail FROM imports WHERE file_path LIKE '%OrderRow%' From a3c5dde44ca3b18a12dccbbb7034ee113e9c4dd2 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Wed, 8 Apr 2026 18:10:34 +0300 Subject: [PATCH 8/9] perf: reduce allocations and redundant I/O in parser and index engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cache scopeStack.join(".") via scopePush/scopePop helpers - Hoist hot-path regex literals (RE_COMPONENT, RE_HOOK) to module scope - Replace findJsDoc gap regex with charCodeAt loop (no .slice()) - Cache getProjectRoot() in getChangedFiles, insertParsedResults, indexFiles - Eliminate redundant getAllFileHashes call in incremental path - Batch DELETE for deleted files (single IN() vs N separate DELETEs) - Hoist fileURLToPath and getProjectRoot() in worker-pool spawn loop - Increase BATCH_SIZE 100→500 for fewer INSERT round-trips - Zero-alloc isPathExcluded via charCodeAt segment scan - Update docs: schema version 1→2, batch size references --- .changeset/richer-symbol-metadata.md | 3 +- docs/architecture.md | 8 ++--- src/application/index-engine.ts | 25 ++++++++++------ src/application/run-index.ts | 4 +-- src/db.ts | 2 +- src/parser.ts | 44 ++++++++++++++++++---------- src/runtime.ts | 11 +++++-- src/worker-pool.ts | 11 ++++--- 8 files changed, 70 insertions(+), 38 deletions(-) diff --git a/.changeset/richer-symbol-metadata.md b/.changeset/richer-symbol-metadata.md index a88ca05a..dbd0ff29 100644 --- a/.changeset/richer-symbol-metadata.md +++ b/.changeset/richer-symbol-metadata.md @@ -9,6 +9,7 @@ Richer symbol metadata: generics, return types, JSDoc, type members, const value - New `type_members` table indexes properties and methods of interfaces and object-literal types - New `value` column on symbols captures const literal values (strings, numbers, booleans, null) - New `parent_name` column on symbols tracks scope nesting; class methods/properties/getters extracted as individual symbols -- New `calls` table tracks function-scoped call edges (deduped per file) for fan-in/fan-out and impact analysis +- New `calls` table tracks function-scoped call edges with `caller_scope` for qualified disambiguation (deduped per file) - Enum members extracted into `members` column as JSON +- Performance: cached scope strings, hoisted hot-path regex, batch deletes, reduced redundant I/O, BATCH_SIZE 100→500 - SCHEMA_VERSION bumped to 2 diff --git a/docs/architecture.md b/docs/architecture.md index 8cb55e7b..80ba9ed6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -155,7 +155,7 @@ Optional **`codemap.config.ts`** (default export: object or async factory) or ** **Fresh database:** the default CLI **`codemap`** (incremental) calls **`createSchema()`** in **`runCodemapIndex`** before **`getChangedFiles()`**, so the **`meta`** table exists before **`getMeta(..., "last_indexed_commit")`** runs on an empty **`.codemap.db`**. -Current schema version: **1** — see [Schema Versioning](#schema-versioning) for details. +Current schema version: **2** — see [Schema Versioning](#schema-versioning) for details. All tables use `STRICT` mode. Tables marked with `WITHOUT ROWID` store data directly in the primary key B-tree. PRAGMAs and index design: [SQLite Performance Configuration](#sqlite-performance-configuration). @@ -426,7 +426,7 @@ All bulk insert functions use a shared `batchInsert()` helper that: - **Eliminates `.slice()` allocations** — iterates with index bounds (`i` to `end`) instead of copying array segments per batch - **Uses indexed `for (let j)` loops** — avoids per-batch iterator protocol overhead -Batches of 100 rows per `INSERT ... VALUES (...),(...),(...)` statement reduce per-statement overhead (parse, plan, execute cycle) by ~100×. +Batches of 500 rows per `INSERT ... VALUES (...),(...),(...)` statement reduce per-statement overhead (parse, plan, execute cycle) significantly. ### Sorted inserts @@ -460,13 +460,13 @@ When `SCHEMA_VERSION` is bumped (after the first release, when DDL changes requi - `createSchema()` detects the mismatch automatically and calls `dropAll()` before recreating - No manual intervention needed — run the indexer and it auto-rebuilds on version change -Until the first release, Codemap keeps **`SCHEMA_VERSION` at 1**; pull `--full` or delete `.codemap.db` when the DDL in `db.ts` changes without a version bump. +When `SCHEMA_VERSION` changes, the indexer auto-detects the mismatch and triggers a full rebuild — no manual intervention needed. ## SQLite Performance Configuration ### `bun:sqlite` API -All DDL and PRAGMA statements use `Database.run()`. The `sqlite-db.ts` wrapper abstracts both Bun (`bun:sqlite`) and Node (`better-sqlite3`). On Bun, `Database.query()` caches compiled statements internally. On Node, the wrapper maintains a `Map` cache so repeated `run()` and `query()` calls with the same SQL reuse a single prepared statement. Read queries use the wrapper's `.query().all()` or `.get()`. Bulk inserts use the generic `batchInsert()` helper with multi-row `INSERT ... VALUES (...),(...),(...)` in batches of 100, pre-computed placeholders, and zero-copy index-bounds iteration. +All DDL and PRAGMA statements use `Database.run()`. The `sqlite-db.ts` wrapper abstracts both Bun (`bun:sqlite`) and Node (`better-sqlite3`). On Bun, `Database.query()` caches compiled statements internally. On Node, the wrapper maintains a `Map` cache so repeated `run()` and `query()` calls with the same SQL reuse a single prepared statement. Read queries use the wrapper's `.query().all()` or `.get()`. Bulk inserts use the generic `batchInsert()` helper with multi-row `INSERT ... VALUES (...),(...),(...)` in batches of 500, pre-computed placeholders, and zero-copy index-bounds iteration. ### PRAGMAs (set on every `openDb()`) diff --git a/src/application/index-engine.ts b/src/application/index-engine.ts index e726df85..999f9591 100644 --- a/src/application/index-engine.ts +++ b/src/application/index-engine.ts @@ -86,16 +86,18 @@ export function collectFiles(): string[] { export function getChangedFiles(db: CodemapDatabase): { changed: string[]; deleted: string[]; + existingPaths: Set; } | null { const lastCommit = getMeta(db, "last_indexed_commit"); if (!lastCommit) return null; try { + const root = getProjectRoot(); const isAncestor = spawnSync( "git", ["merge-base", "--is-ancestor", lastCommit, "HEAD"], { - cwd: getProjectRoot(), + cwd: root, }, ); if (isAncestor.status !== 0) return null; @@ -104,14 +106,14 @@ export function getChangedFiles(db: CodemapDatabase): { "git", ["diff", "--name-only", `${lastCommit}..HEAD`], { - cwd: getProjectRoot(), + cwd: root, }, ); const statusResult = spawnSync( "git", ["status", "--porcelain", "--no-renames"], { - cwd: getProjectRoot(), + cwd: root, }, ); @@ -140,7 +142,7 @@ export function getChangedFiles(db: CodemapDatabase): { const deleted: string[] = []; for (const f of allCandidates) { - const absPath = join(getProjectRoot(), f); + const absPath = join(root, f); let source: string; try { source = readFileSync(absPath, "utf-8"); @@ -153,7 +155,7 @@ export function getChangedFiles(db: CodemapDatabase): { } } - return { changed, deleted }; + return { changed, deleted, existingPaths: new Set(existingHashes.keys()) }; } catch { return null; } @@ -172,6 +174,7 @@ function insertParsedResults( indexedPaths: Set, ) { let indexed = 0; + const root = getProjectRoot(); const transaction = db.transaction(() => { for (const parsed of results) { @@ -211,7 +214,7 @@ function insertParsedResults( if (parsed.symbols?.length) insertSymbols(db, parsed.symbols); if (parsed.imports?.length) { - const absPath = join(getProjectRoot(), parsed.relPath); + const absPath = join(root, parsed.relPath); const deps = resolveImports(absPath, parsed.imports, indexedPaths); insertImports(db, parsed.imports); if (deps.length) insertDependencies(db, deps); @@ -292,10 +295,11 @@ export async function indexFiles( indexed = insertParsedResults(db, results, indexedPaths); } else { const existingHashes = getAllFileHashes(db); + const root = getProjectRoot(); const transaction = db.transaction(() => { for (const relPath of filePaths) { - const absPath = join(getProjectRoot(), relPath); + const absPath = join(root, relPath); let source: string; try { source = readFileSync(absPath, "utf-8"); @@ -432,8 +436,11 @@ export function deleteFilesFromIndex( quiet?: boolean, ) { if (deleted.length === 0) return; - for (const f of deleted) { - deleteFileData(db, f); + const CHUNK = 500; + for (let i = 0; i < deleted.length; i += CHUNK) { + const batch = deleted.slice(i, i + CHUNK); + const placeholders = batch.map(() => "?").join(","); + db.run(`DELETE FROM files WHERE path IN (${placeholders})`, batch); } if (!quiet) { console.log(` Removed ${deleted.length} deleted files from index`); diff --git a/src/application/run-index.ts b/src/application/run-index.ts index df355f91..83959b94 100644 --- a/src/application/run-index.ts +++ b/src/application/run-index.ts @@ -1,4 +1,4 @@ -import { createSchema, getAllFileHashes, setMeta } from "../db"; +import { createSchema, setMeta } from "../db"; import type { CodemapDatabase } from "../db"; import { collectFiles, @@ -115,7 +115,7 @@ export async function runCodemapIndex( } deleteFilesFromIndex(db, diff.deleted, quiet); if (diff.changed.length > 0) { - const indexedPaths = new Set(getAllFileHashes(db).keys()); + const indexedPaths = diff.existingPaths; for (const f of diff.changed) indexedPaths.add(f); const run = await indexFiles(db, diff.changed, false, indexedPaths, { quiet, diff --git a/src/db.ts b/src/db.ts index 928aa2f3..b1df57ff 100644 --- a/src/db.ts +++ b/src/db.ts @@ -291,7 +291,7 @@ export interface SymbolRow { parent_name: string | null; } -const BATCH_SIZE = 100; +const BATCH_SIZE = 500; function batchInsert( db: CodemapDatabase, diff --git a/src/parser.ts b/src/parser.ts index 5cc97d75..1ba5701b 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -19,6 +19,9 @@ import type { } from "./db"; import { extractMarkers } from "./markers"; +const RE_COMPONENT = /^[A-Z]/; +const RE_HOOK = /^use[A-Z]/; + interface ExtractedData { symbols: SymbolRow[]; imports: ImportRow[]; @@ -115,9 +118,18 @@ export function extractFileData( const jsxScopes = new Set(); // function scopes that contain JSX let currentFunctionScope: string | null = null; const scopeStack: string[] = []; + let _scopeStr = ""; const currentParent = () => scopeStack.length ? scopeStack[scopeStack.length - 1] : null; - const currentScope = () => scopeStack.join("."); + const currentScope = () => _scopeStr; + const scopePush = (name: string) => { + scopeStack.push(name); + _scopeStr = _scopeStr ? `${_scopeStr}.${name}` : name; + }; + const scopePop = () => { + scopeStack.pop(); + _scopeStr = scopeStack.join("."); + }; const visitor = new Visitor({ FunctionDeclaration(node: any) { @@ -144,8 +156,8 @@ export function extractFileData( parent_name: currentParent(), }); - scopeStack.push(name); - if (isTsx && /^[A-Z]/.test(name)) { + scopePush(name); + if (isTsx && RE_COMPONENT.test(name)) { currentFunctionScope = name; hookCalls.set(name, new Set()); } @@ -153,7 +165,7 @@ export function extractFileData( "FunctionDeclaration:exit"(node: any) { const name = node.id?.name; if (name && scopeStack[scopeStack.length - 1] === name) { - scopeStack.pop(); + scopePop(); } if (name && currentFunctionScope === name) { maybeAddComponent(name, node, false); @@ -194,9 +206,9 @@ export function extractFileData( }); if (isArrowOrFn) { - scopeStack.push(name); + scopePush(name); } - if (isTsx && /^[A-Z]/.test(name) && isArrowOrFn) { + if (isTsx && RE_COMPONENT.test(name) && isArrowOrFn) { currentFunctionScope = name; hookCalls.set(name, new Set()); } @@ -213,7 +225,7 @@ export function extractFileData( init?.type === "ArrowFunctionExpression" || init?.type === "FunctionExpression"; if (isArrowOrFn && scopeStack[scopeStack.length - 1] === name) { - scopeStack.pop(); + scopePop(); } if (name && currentFunctionScope === name) { maybeAddComponent(name, init, true); @@ -370,7 +382,7 @@ export function extractFileData( value: null, parent_name: currentParent(), }); - scopeStack.push(name); + scopePush(name); extractClassMembers( node.body?.body, relPath, @@ -384,25 +396,25 @@ export function extractFileData( "ClassDeclaration:exit"(node: any) { const name = node.id?.name; if (name && scopeStack[scopeStack.length - 1] === name) { - scopeStack.pop(); + scopePop(); } }, MethodDefinition(node: any) { const name = node.key?.name; - if (name) scopeStack.push(name); + if (name) scopePush(name); }, "MethodDefinition:exit"(node: any) { const name = node.key?.name; if (name && scopeStack[scopeStack.length - 1] === name) { - scopeStack.pop(); + scopePop(); } }, CallExpression(node: any) { if (currentFunctionScope) { const callee = node.callee; - if (callee?.type === "Identifier" && /^use[A-Z]/.test(callee.name)) { + if (callee?.type === "Identifier" && RE_HOOK.test(callee.name)) { hookCalls.get(currentFunctionScope)?.add(callee.name); } } @@ -447,7 +459,7 @@ export function extractFileData( markers.push(...extractMarkers(source, relPath)); function maybeAddComponent(name: string, node: any, _isArrow: boolean) { - if (!isTsx || !/^[A-Z]/.test(name)) return; + if (!isTsx || !RE_COMPONENT.test(name)) return; const hooks = hookCalls.get(name); const hasJsx = jsxScopes.has(name); if (!hasJsx && !(hooks && hooks.size > 0)) return; @@ -700,8 +712,10 @@ function findJsDoc( } if (best < 0) return null; const doc = docs[best]; - const gap = source.slice(doc.end, nodeStart); - if (/[;{}]/.test(gap)) return null; + for (let i = doc.end; i < nodeStart; i++) { + const ch = source.charCodeAt(i); + if (ch === 59 || ch === 123 || ch === 125) return null; // ; { } + } return doc.text || null; } diff --git a/src/runtime.ts b/src/runtime.ts index 8a5f6703..523f5745 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -41,7 +41,14 @@ export function getTsconfigPath(): string | null { /** True if any path segment matches an excluded directory name (e.g. `node_modules`). */ export function isPathExcluded(relPath: string): boolean { - const parts = relPath.split(/[/\\]/).filter(Boolean); const set = getExcludeDirNames(); - return parts.some((p) => set.has(p)); + let start = 0; + for (let i = 0; i <= relPath.length; i++) { + const ch = i < relPath.length ? relPath.charCodeAt(i) : 0; + if (ch === 47 || ch === 92 || i === relPath.length) { + if (i > start && set.has(relPath.slice(start, i))) return true; + start = i + 1; + } + } + return false; } diff --git a/src/worker-pool.ts b/src/worker-pool.ts index c51f3d67..20c1d180 100644 --- a/src/worker-pool.ts +++ b/src/worker-pool.ts @@ -21,6 +21,8 @@ const WORKER_URL_NODE = new URL( ); const WORKER_COUNT = Math.max(2, Math.min(cpus().length || 4, 6)); +const IS_BUN = typeof Bun !== "undefined"; +const NODE_WORKER_PATH = IS_BUN ? "" : fileURLToPath(WORKER_URL_NODE); export function parseFilesParallel(filePaths: string[]): Promise { const chunkSize = Math.ceil(filePaths.length / WORKER_COUNT); @@ -29,16 +31,18 @@ export function parseFilesParallel(filePaths: string[]): Promise { chunks.push(filePaths.slice(i, i + chunkSize)); } + const projectRoot = getProjectRoot(); + return Promise.all( chunks.map( (chunk) => new Promise((resolve, reject) => { const input: WorkerInput = { files: chunk, - projectRoot: getProjectRoot(), + projectRoot, }; - if (typeof Bun !== "undefined") { + if (IS_BUN) { const worker = new Worker(WORKER_URL_BUN); worker.onmessage = (event: MessageEvent) => { resolve(event.data.results); @@ -52,8 +56,7 @@ export function parseFilesParallel(filePaths: string[]): Promise { return; } - const workerPath = fileURLToPath(WORKER_URL_NODE); - const worker = new NodeWorker(workerPath, { + const worker = new NodeWorker(NODE_WORKER_PATH, { type: "module", } as import("node:worker_threads").WorkerOptions); worker.on("message", (data: WorkerOutput) => { From 5d5d72505beebf31f02c6bc63b2766097c39361d Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Wed, 8 Apr 2026 18:17:08 +0300 Subject: [PATCH 9/9] docs: fix stale schema comment, add caller_scope index, expand parser checklist - Update SCHEMA_VERSION comment to reflect current bump-on-change policy - Add idx_calls_scope covering index for caller_scope queries - Expand parser extraction checklist in architecture.md with JSDoc, const values, type members, call graph, and symbol nesting --- docs/architecture.md | 9 +++++++-- src/db.ts | 6 +++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 80ba9ed6..b9da137b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -311,8 +311,13 @@ All tables have covering indexes tuned for AI agent query patterns. See [Coverin Uses the Rust-based `oxc-parser` via NAPI bindings to parse TypeScript/TSX/JS/JSX files into an AST. Extracts: -- **Symbols**: Functions, arrow functions, classes, interfaces, type aliases, enums — with reconstructed signatures including generic type parameters (e.g. ``), return type annotations (e.g. `: Promise`), class/interface heritage (`extends`, `implements`) -- **Enum members**: String and numeric values for each member, stored as JSON (e.g. `[{"name":"Active","value":"active"}]`) +- **Symbols**: Functions, arrow functions, classes, interfaces, type aliases, enums — with reconstructed signatures including generic type parameters (e.g. ``), return type annotations (e.g. `: Promise`), class/interface heritage (`extends`, `implements`). Class methods, properties, getters, and setters are extracted as individual symbols with `parent_name` pointing to their class +- **JSDoc**: Leading `/** … */` comments attached to symbols via `doc_comment` column (cleaned: `*` prefixes stripped, tags preserved) +- **Enum members**: String and numeric values for each member, stored as JSON in the `members` column (e.g. `[{"name":"Active","value":"active"}]`) +- **Const values**: Literal values (`string`, `number`, `boolean`, `null`, `as const`, simple template literals) stored in the `value` column +- **Type members**: Properties and method signatures of interfaces and object-literal type aliases, stored in the `type_members` table +- **Call graph**: Function-scoped call edges stored in the `calls` table — deduped per (caller_scope, callee) per file. Captures `obj.method()` and `this.method()` patterns +- **Symbol nesting**: `parent_name` column tracks scope (nested functions → parent function, class members → class name) - **Imports**: All `import` statements with specifiers, source paths, and type-only flags - **Exports**: Named exports, default exports, re-exports - **Components**: React components detected via PascalCase name + (JSX return **or** hook usage). A PascalCase function in `.tsx`/`.jsx` that neither returns JSX nor calls hooks is indexed only as a symbol, not a component. Extracts props type and hooks used diff --git a/src/db.ts b/src/db.ts index b1df57ff..dd7e897f 100644 --- a/src/db.ts +++ b/src/db.ts @@ -5,9 +5,8 @@ import { } from "./sqlite-db"; /** - * Pre-release: keep at **1** until the first npm release — do not bump for DDL - * tweaks; run `--full` locally after pulling. After v1.0, bump in lockstep with - * `createTables` / `createIndexes` when the on-disk schema changes. + * Bump in lockstep with `createTables` / `createIndexes` whenever on-disk schema + * changes. `createSchema()` rebuilds automatically on version mismatch. */ export const SCHEMA_VERSION = 2; @@ -186,6 +185,7 @@ export function createIndexes(db: CodemapDatabase) { CREATE INDEX IF NOT EXISTS idx_type_members_file ON type_members(file_path); CREATE INDEX IF NOT EXISTS idx_calls_caller ON calls(caller_name, file_path); + CREATE INDEX IF NOT EXISTS idx_calls_scope ON calls(caller_scope, file_path, callee_name); CREATE INDEX IF NOT EXISTS idx_calls_callee ON calls(callee_name, file_path); CREATE INDEX IF NOT EXISTS idx_calls_file ON calls(file_path); `);