From 9ef8de469cfc83d2884765ecf06e69294acfa63c Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Mon, 2 Mar 2026 17:29:02 -0700 Subject: [PATCH] feat: add normalizeSymbol utility for stable JSON schema Add normalizeSymbol(row, db, hashCache) that returns a consistent 7-field symbol shape (name, kind, file, line, endLine, role, fileHash) across all query and search commands. Update queryNameData, fnDepsData, fnImpactData, explainFunctionImpl, listFunctionsData, rolesData, whereSymbolImpl in queries.js and searchData, multiSearchData, ftsSearchData, hybridSearchData in embedder.js to use normalizeSymbol. Update SQL in listFunctionsData, rolesData, iterListFunctions, iterRoles, _prepareSearch, and ftsSearchData to include end_line and role columns. Export normalizeSymbol from index.js. Add docs/json-schema.md documenting the stable schema. Add 8 unit tests and 7 integration schema conformance tests. Impact: 13 functions changed, 33 affected Impact: 14 functions changed, 42 affected Impact: 13 functions changed, 21 affected --- docs/json-schema.md | 228 ++++++++++++++++++++++++++++ src/embedder.js | 32 ++-- src/index.js | 1 + src/queries.js | 100 +++++++----- tests/integration/queries.test.js | 74 +++++++++ tests/unit/normalize-symbol.test.js | 114 ++++++++++++++ 6 files changed, 499 insertions(+), 50 deletions(-) create mode 100644 docs/json-schema.md create mode 100644 tests/unit/normalize-symbol.test.js diff --git a/docs/json-schema.md b/docs/json-schema.md new file mode 100644 index 00000000..e45c925f --- /dev/null +++ b/docs/json-schema.md @@ -0,0 +1,228 @@ +# JSON Schema — Stable Symbol Metadata + +Every codegraph command that returns symbol data includes a **stable base shape** of 7 fields. Commands may add extra fields (e.g. `similarity`, `callees`), but these 7 are always present. + +## Base Symbol Shape + +| Field | Type | Description | +|------------|-------------------|-------------| +| `name` | `string` | Symbol identifier (e.g. `"buildGraph"`, `"MyClass.method"`) | +| `kind` | `string` | Symbol kind — see [Valid Kinds](#valid-kinds) | +| `file` | `string` | Repo-relative file path (forward slashes) | +| `line` | `number` | 1-based start line | +| `endLine` | `number \| null` | 1-based end line, or `null` if unavailable | +| `role` | `string \| null` | Architectural role classification, or `null` if unclassified — see [Valid Roles](#valid-roles) | +| `fileHash` | `string \| null` | SHA-256 hash of the file at build time, or `null` if unavailable | + +### Valid Kinds + +``` +function method class interface type struct enum trait record module +``` + +Language-specific types use their native kind (e.g. Go structs use `struct`, Rust traits use `trait`, Ruby modules use `module`). + +### Valid Roles + +``` +entry core utility adapter dead leaf +``` + +Roles are assigned during `codegraph build` based on call-graph topology. Symbols without enough signal remain `null`. + +## Command Envelopes + +### `where` (symbol mode) + +```jsonc +{ + "target": "buildGraph", + "mode": "symbol", + "results": [ + { + "name": "buildGraph", // ← base 7 + "kind": "function", + "file": "src/builder.js", + "line": 42, + "endLine": 180, + "role": "core", + "fileHash": "abc123...", + "exported": true, // ← command-specific + "uses": [ // lightweight refs (4 fields) + { "name": "parseFile", "file": "src/parser.js", "line": 10 } + ] + } + ] +} +``` + +### `query` + +```jsonc +{ + "query": "buildGraph", + "results": [ + { + "name": "buildGraph", // ← base 7 + "kind": "function", + "file": "src/builder.js", + "line": 42, + "endLine": 180, + "role": "core", + "fileHash": "abc123...", + "callees": [ // lightweight refs + { "name": "parseFile", "kind": "function", "file": "src/parser.js", "line": 10, "edgeKind": "calls" } + ], + "callers": [ + { "name": "main", "kind": "function", "file": "src/cli.js", "line": 5, "edgeKind": "calls" } + ] + } + ] +} +``` + +### `fn` (fnDeps) + +```jsonc +{ + "name": "buildGraph", + "results": [ + { + "name": "buildGraph", // ← base 7 + "kind": "function", + "file": "src/builder.js", + "line": 42, + "endLine": 180, + "role": "core", + "fileHash": "abc123...", + "callees": [/* lightweight */], + "callers": [/* lightweight */], + "transitiveCallers": { "2": [/* lightweight */] } + } + ] +} +``` + +### `fn-impact` + +```jsonc +{ + "name": "buildGraph", + "results": [ + { + "name": "buildGraph", // ← base 7 + "kind": "function", + "file": "src/builder.js", + "line": 42, + "endLine": 180, + "role": "core", + "fileHash": "abc123...", + "levels": { "1": [/* lightweight */], "2": [/* lightweight */] }, + "totalDependents": 5 + } + ] +} +``` + +### `explain` (function mode) + +```jsonc +{ + "kind": "function", + "results": [ + { + "name": "buildGraph", // ← base 7 + "kind": "function", + "file": "src/builder.js", + "line": 42, + "endLine": 180, + "role": "core", + "fileHash": "abc123...", + "lineCount": 138, // ← command-specific + "summary": "...", + "signature": "...", + "complexity": { ... }, + "callees": [/* lightweight */], + "callers": [/* lightweight */], + "relatedTests": [/* { file } */] + } + ] +} +``` + +### `search` / `multi-search` / `fts` / `hybrid` + +```jsonc +{ + "results": [ + { + "name": "buildGraph", // ← base 7 + "kind": "function", + "file": "src/builder.js", + "line": 42, + "endLine": 180, + "role": "core", + "fileHash": "abc123...", + "similarity": 0.85 // ← search-specific (varies by mode) + } + ] +} +``` + +### `list-functions` + +```jsonc +{ + "count": 42, + "functions": [ + { + "name": "buildGraph", // ← base 7 + "kind": "function", + "file": "src/builder.js", + "line": 42, + "endLine": 180, + "role": "core", + "fileHash": "abc123..." + } + ] +} +``` + +### `roles` + +```jsonc +{ + "count": 42, + "summary": { "core": 10, "utility": 20, "entry": 5, "leaf": 7 }, + "symbols": [ + { + "name": "buildGraph", // ← base 7 + "kind": "function", + "file": "src/builder.js", + "line": 42, + "endLine": 180, + "role": "core", + "fileHash": "abc123..." + } + ] +} +``` + +## Lightweight Inner References + +Nested/secondary references (callees, callers, transitive hops, path nodes) use a lightweight 4-field shape: + +| Field | Type | +|--------|----------| +| `name` | `string` | +| `kind` | `string` | +| `file` | `string` | +| `line` | `number` | + +Some contexts add extra fields like `edgeKind` or `viaHierarchy`. + +## Notes + +- `variable` is not a tracked kind — codegraph tracks function/type-level symbols only. +- Iterator functions (`iterListFunctions`, `iterRoles`) yield `endLine` and `role` but not `fileHash` (streaming avoids holding DB open for per-row hash lookups). +- The `normalizeSymbol(row, db, hashCache)` utility is exported from both `src/queries.js` and `src/index.js` for programmatic consumers. diff --git a/src/embedder.js b/src/embedder.js index 265f12a6..c715109e 100644 --- a/src/embedder.js +++ b/src/embedder.js @@ -4,6 +4,7 @@ import path from 'node:path'; import { createInterface } from 'node:readline'; import { closeDb, findDbPath, openDb, openReadonlyOrFail } from './db.js'; import { info, warn } from './logger.js'; +import { normalizeSymbol } from './queries.js'; /** * Split an identifier into readable words. @@ -582,7 +583,7 @@ function _prepareSearch(customDbPath, opts = {}) { const noTests = opts.noTests || false; const TEST_PATTERN = /\.(test|spec)\.|__test__|__tests__|\.stories\./; let sql = ` - SELECT e.node_id, e.vector, e.text_preview, n.name, n.kind, n.file, n.line + SELECT e.node_id, e.vector, e.text_preview, n.name, n.kind, n.file, n.line, n.end_line, n.role FROM embeddings e JOIN nodes n ON e.node_id = n.id `; @@ -638,6 +639,7 @@ export async function searchData(query, customDbPath, opts = {}) { return null; } + const hc = new Map(); const results = []; for (const row of rows) { const vec = new Float32Array(new Uint8Array(row.vector).buffer); @@ -645,10 +647,7 @@ export async function searchData(query, customDbPath, opts = {}) { if (sim >= minScore) { results.push({ - name: row.name, - kind: row.kind, - file: row.file, - line: row.line, + ...normalizeSymbol(row, db, hc), similarity: sim, }); } @@ -734,14 +733,12 @@ export async function multiSearchData(queries, customDbPath, opts = {}) { } // Build results sorted by RRF score + const hc = new Map(); const results = []; for (const [rowIndex, entry] of fusionMap) { const row = rows[rowIndex]; results.push({ - name: row.name, - kind: row.kind, - file: row.file, - line: row.line, + ...normalizeSymbol(row, db, hc), rrf: entry.rrfScore, queryScores: entry.queryScores, }); @@ -804,7 +801,7 @@ export function ftsSearchData(query, customDbPath, opts = {}) { let sql = ` SELECT f.rowid AS node_id, rank AS bm25_score, - n.name, n.kind, n.file, n.line + n.name, n.kind, n.file, n.line, n.end_line, n.role FROM fts_index f JOIN nodes n ON f.rowid = n.id WHERE fts_index MATCH ? @@ -841,16 +838,13 @@ export function ftsSearchData(query, customDbPath, opts = {}) { rows = rows.filter((row) => !TEST_PATTERN.test(row.file)); } - db.close(); - + const hc = new Map(); const results = rows.slice(0, limit).map((row) => ({ - name: row.name, - kind: row.kind, - file: row.file, - line: row.line, + ...normalizeSymbol(row, db, hc), bm25Score: -row.bm25_score, // FTS5 rank is negative; negate for display })); + db.close(); return { results }; } @@ -924,6 +918,9 @@ export async function hybridSearchData(query, customDbPath, opts = {}) { kind: item.kind, file: item.file, line: item.line, + endLine: item.endLine ?? null, + role: item.role ?? null, + fileHash: item.fileHash ?? null, rrfScore: 0, bm25Score: null, bm25Rank: null, @@ -955,6 +952,9 @@ export async function hybridSearchData(query, customDbPath, opts = {}) { kind: e.kind, file: e.file, line: e.line, + endLine: e.endLine, + role: e.role, + fileHash: e.fileHash, rrf: e.rrfScore, bm25Score: e.bm25Score, bm25Rank: e.bm25Rank, diff --git a/src/index.js b/src/index.js index 968204bb..03be6853 100644 --- a/src/index.js +++ b/src/index.js @@ -122,6 +122,7 @@ export { iterWhere, kindIcon, moduleMapData, + normalizeSymbol, pathData, queryNameData, rolesData, diff --git a/src/queries.js b/src/queries.js index 9a0204a5..5ee87b0c 100644 --- a/src/queries.js +++ b/src/queries.js @@ -207,6 +207,7 @@ export function queryNameData(name, customDbPath, opts = {}) { return { query: name, results: [] }; } + const hc = new Map(); const results = nodes.map((node) => { let callees = db .prepare(` @@ -230,11 +231,7 @@ export function queryNameData(name, customDbPath, opts = {}) { } return { - name: node.name, - kind: node.kind, - file: node.file, - line: node.line, - fileHash: getFileHash(db, node.file), + ...normalizeSymbol(node, db, hc), callees: callees.map((c) => ({ name: c.name, kind: c.kind, @@ -403,6 +400,7 @@ export function fnDepsData(name, customDbPath, opts = {}) { const db = openReadonlyOrFail(customDbPath); const depth = opts.depth || 3; const noTests = opts.noTests || false; + const hc = new Map(); const nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind }); if (nodes.length === 0) { @@ -494,10 +492,7 @@ export function fnDepsData(name, customDbPath, opts = {}) { } return { - name: node.name, - kind: node.kind, - file: node.file, - line: node.line, + ...normalizeSymbol(node, db, hc), callees: filteredCallees.map((c) => ({ name: c.name, kind: c.kind, @@ -524,6 +519,7 @@ export function fnImpactData(name, customDbPath, opts = {}) { const db = openReadonlyOrFail(customDbPath); const maxDepth = opts.depth || 5; const noTests = opts.noTests || false; + const hc = new Map(); const nodes = findMatchingNodes(db, name, { noTests, file: opts.file, kind: opts.kind }); if (nodes.length === 0) { @@ -560,10 +556,7 @@ export function fnImpactData(name, customDbPath, opts = {}) { } return { - name: node.name, - kind: node.kind, - file: node.file, - line: node.line, + ...normalizeSymbol(node, db, hc), levels, totalDependents: visited.size - 1, }; @@ -1195,14 +1188,16 @@ export function listFunctionsData(customDbPath, opts = {}) { let rows = db .prepare( - `SELECT name, kind, file, line, role FROM nodes WHERE ${conditions.join(' AND ')} ORDER BY file, line`, + `SELECT name, kind, file, line, end_line, role FROM nodes WHERE ${conditions.join(' AND ')} ORDER BY file, line`, ) .all(...params); if (noTests) rows = rows.filter((r) => !isTestFile(r.file)); + const hc = new Map(); + const functions = rows.map((r) => normalizeSymbol(r, db, hc)); db.close(); - const base = { count: rows.length, functions: rows }; + const base = { count: functions.length, functions }; return paginateResult(base, 'functions', { limit: opts.limit, offset: opts.offset }); } @@ -1235,11 +1230,18 @@ export function* iterListFunctions(customDbPath, opts = {}) { } const stmt = db.prepare( - `SELECT name, kind, file, line, role FROM nodes WHERE ${conditions.join(' AND ')} ORDER BY file, line`, + `SELECT name, kind, file, line, end_line, role FROM nodes WHERE ${conditions.join(' AND ')} ORDER BY file, line`, ); for (const row of stmt.iterate(...params)) { if (noTests && isTestFile(row.file)) continue; - yield { name: row.name, kind: row.kind, file: row.file, line: row.line, role: row.role }; + yield { + name: row.name, + kind: row.kind, + file: row.file, + line: row.line, + endLine: row.end_line ?? null, + role: row.role ?? null, + }; } } finally { db.close(); @@ -1253,7 +1255,7 @@ export function* iterListFunctions(customDbPath, opts = {}) { * @param {boolean} [opts.noTests] * @param {string} [opts.role] * @param {string} [opts.file] - * @yields {{ name: string, kind: string, file: string, line: number, role: string }} + * @yields {{ name: string, kind: string, file: string, line: number, endLine: number|null, role: string }} */ export function* iterRoles(customDbPath, opts = {}) { const db = openReadonlyOrFail(customDbPath); @@ -1272,11 +1274,18 @@ export function* iterRoles(customDbPath, opts = {}) { } const stmt = db.prepare( - `SELECT name, kind, file, line, role FROM nodes WHERE ${conditions.join(' AND ')} ORDER BY role, file, line`, + `SELECT name, kind, file, line, end_line, role FROM nodes WHERE ${conditions.join(' AND ')} ORDER BY role, file, line`, ); for (const row of stmt.iterate(...params)) { if (noTests && isTestFile(row.file)) continue; - yield { name: row.name, kind: row.kind, file: row.file, line: row.line, role: row.role }; + yield { + name: row.name, + kind: row.kind, + file: row.file, + line: row.line, + endLine: row.end_line ?? null, + role: row.role ?? null, + }; } } finally { db.close(); @@ -2458,6 +2467,7 @@ function explainFunctionImpl(db, target, noTests, getFileLines) { if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file)); if (nodes.length === 0) return []; + const hc = new Map(); return nodes.slice(0, 10).map((node) => { const fileLines = getFileLines(node.file); const lineCount = node.end_line ? node.end_line - node.line + 1 : null; @@ -2515,12 +2525,7 @@ function explainFunctionImpl(db, target, noTests, getFileLines) { } return { - name: node.name, - kind: node.kind, - file: node.file, - line: node.line, - role: node.role || null, - endLine: node.end_line || null, + ...normalizeSymbol(node, db, hc), lineCount, summary, signature, @@ -2738,6 +2743,35 @@ function getFileHash(db, file) { return row ? row.hash : null; } +/** + * Normalize a raw DB/query row into the stable 7-field symbol shape. + * @param {object} row - Raw row (from SELECT * or explicit columns) + * @param {object} [db] - Open DB handle; when null, fileHash will be null + * @param {Map} [hashCache] - Optional per-file cache to avoid repeated getFileHash calls + * @returns {{ name: string, kind: string, file: string, line: number, endLine: number|null, role: string|null, fileHash: string|null }} + */ +export function normalizeSymbol(row, db, hashCache) { + let fileHash = null; + if (db) { + if (hashCache) { + if (!hashCache.has(row.file)) { + hashCache.set(row.file, getFileHash(db, row.file)); + } + fileHash = hashCache.get(row.file); + } else { + fileHash = getFileHash(db, row.file); + } + } + return { + name: row.name, + kind: row.kind, + file: row.file, + line: row.line, + endLine: row.end_line ?? row.endLine ?? null, + role: row.role ?? null, + fileHash, + }; +} function whereSymbolImpl(db, target, noTests) { const placeholders = ALL_SYMBOL_KINDS.map(() => '?').join(', '); let nodes = db @@ -2747,6 +2781,7 @@ function whereSymbolImpl(db, target, noTests) { .all(`%${target}%`, ...ALL_SYMBOL_KINDS); if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file)); + const hc = new Map(); return nodes.map((node) => { const crossFileCallers = db .prepare( @@ -2765,12 +2800,7 @@ function whereSymbolImpl(db, target, noTests) { if (noTests) uses = uses.filter((u) => !isTestFile(u.file)); return { - name: node.name, - kind: node.kind, - file: node.file, - line: node.line, - fileHash: getFileHash(db, node.file), - role: node.role || null, + ...normalizeSymbol(node, db, hc), exported, uses: uses.map((u) => ({ name: u.name, file: u.file, line: u.line })), }; @@ -2916,7 +2946,7 @@ export function rolesData(customDbPath, opts = {}) { let rows = db .prepare( - `SELECT name, kind, file, line, role FROM nodes WHERE ${conditions.join(' AND ')} ORDER BY role, file, line`, + `SELECT name, kind, file, line, end_line, role FROM nodes WHERE ${conditions.join(' AND ')} ORDER BY role, file, line`, ) .all(...params); @@ -2927,8 +2957,10 @@ export function rolesData(customDbPath, opts = {}) { summary[r.role] = (summary[r.role] || 0) + 1; } + const hc = new Map(); + const symbols = rows.map((r) => normalizeSymbol(r, db, hc)); db.close(); - const base = { count: rows.length, summary, symbols: rows }; + const base = { count: symbols.length, summary, symbols }; return paginateResult(base, 'symbols', { limit: opts.limit, offset: opts.offset }); } diff --git a/tests/integration/queries.test.js b/tests/integration/queries.test.js index 40fab71d..0bb3b7dc 100644 --- a/tests/integration/queries.test.js +++ b/tests/integration/queries.test.js @@ -660,3 +660,77 @@ describe('noTests filtering', () => { expect(filteredFiles).not.toContain('auth.test.js'); }); }); + +// ─── Stable symbol schema conformance ────────────────────────────────── + +const STABLE_FIELDS = ['name', 'kind', 'file', 'line', 'endLine', 'role', 'fileHash']; + +function expectStableSymbol(sym) { + for (const field of STABLE_FIELDS) { + expect(sym).toHaveProperty(field); + } + expect(typeof sym.name).toBe('string'); + expect(typeof sym.kind).toBe('string'); + expect(typeof sym.file).toBe('string'); + expect(typeof sym.line).toBe('number'); + // endLine, role, fileHash may be null + expect(sym.endLine === null || typeof sym.endLine === 'number').toBe(true); + expect(sym.role === null || typeof sym.role === 'string').toBe(true); + expect(sym.fileHash === null || typeof sym.fileHash === 'string').toBe(true); +} + +describe('stable symbol schema', () => { + test('queryNameData results have all 7 stable fields', () => { + const data = queryNameData('authenticate', dbPath); + expect(data.results.length).toBeGreaterThan(0); + for (const r of data.results) { + expectStableSymbol(r); + } + }); + + test('fnDepsData results have all 7 stable fields', () => { + const data = fnDepsData('handleRoute', dbPath); + expect(data.results.length).toBeGreaterThan(0); + for (const r of data.results) { + expectStableSymbol(r); + } + }); + + test('fnImpactData results have all 7 stable fields', () => { + const data = fnImpactData('authenticate', dbPath); + expect(data.results.length).toBeGreaterThan(0); + for (const r of data.results) { + expectStableSymbol(r); + } + }); + + test('whereData (symbol) results have all 7 stable fields', () => { + const data = whereData('authMiddleware', dbPath); + expect(data.results.length).toBeGreaterThan(0); + for (const r of data.results) { + expectStableSymbol(r); + } + }); + + test('explainData (function) results have all 7 stable fields', () => { + const data = explainData('authMiddleware', dbPath); + expect(data.results.length).toBeGreaterThan(0); + for (const r of data.results) { + expectStableSymbol(r); + } + }); + + test('listFunctionsData results have all 7 stable fields', () => { + const data = listFunctionsData(dbPath); + expect(data.functions.length).toBeGreaterThan(0); + for (const r of data.functions) { + expectStableSymbol(r); + } + }); + + test('fileHash values match expected hashes', () => { + const data = queryNameData('authenticate', dbPath); + const fn = data.results.find((r) => r.name === 'authenticate' && r.kind === 'function'); + expect(fn.fileHash).toBe('hash_auth_js'); + }); +}); diff --git a/tests/unit/normalize-symbol.test.js b/tests/unit/normalize-symbol.test.js new file mode 100644 index 00000000..8a27b344 --- /dev/null +++ b/tests/unit/normalize-symbol.test.js @@ -0,0 +1,114 @@ +import { describe, expect, test, vi } from 'vitest'; +import { normalizeSymbol } from '../../src/queries.js'; + +describe('normalizeSymbol', () => { + test('full row with all fields', () => { + const row = { + name: 'foo', + kind: 'function', + file: 'src/bar.js', + line: 10, + end_line: 20, + role: 'core', + }; + const db = { + prepare: vi.fn().mockReturnValue({ + get: vi.fn().mockReturnValue({ hash: 'abc123' }), + }), + }; + const result = normalizeSymbol(row, db); + expect(result).toEqual({ + name: 'foo', + kind: 'function', + file: 'src/bar.js', + line: 10, + endLine: 20, + role: 'core', + fileHash: 'abc123', + }); + }); + + test('minimal row defaults endLine, role, fileHash to null', () => { + const row = { name: 'bar', kind: 'method', file: 'a.js', line: 1 }; + const result = normalizeSymbol(row, null); + expect(result).toEqual({ + name: 'bar', + kind: 'method', + file: 'a.js', + line: 1, + endLine: null, + role: null, + fileHash: null, + }); + }); + + test('prefers end_line over endLine (raw SQLite column)', () => { + const row = { + name: 'baz', + kind: 'class', + file: 'b.js', + line: 5, + end_line: 50, + endLine: 99, + }; + const result = normalizeSymbol(row, null); + expect(result.endLine).toBe(50); + }); + + test('falls back to endLine when end_line is undefined', () => { + const row = { + name: 'baz', + kind: 'class', + file: 'b.js', + line: 5, + endLine: 99, + }; + const result = normalizeSymbol(row, null); + expect(result.endLine).toBe(99); + }); + + test('db = null yields fileHash = null', () => { + const row = { name: 'x', kind: 'function', file: 'c.js', line: 1, end_line: 10, role: 'leaf' }; + const result = normalizeSymbol(row, null); + expect(result.fileHash).toBeNull(); + }); + + test('hashCache reuses result for same file', () => { + const getSpy = vi.fn().mockReturnValue({ hash: 'h1' }); + const db = { prepare: vi.fn().mockReturnValue({ get: getSpy }) }; + const hc = new Map(); + + const row1 = { name: 'a', kind: 'function', file: 'x.js', line: 1 }; + const row2 = { name: 'b', kind: 'function', file: 'x.js', line: 10 }; + + normalizeSymbol(row1, db, hc); + normalizeSymbol(row2, db, hc); + + // DB was queried only once for x.js + expect(getSpy).toHaveBeenCalledTimes(1); + expect(hc.get('x.js')).toBe('h1'); + }); + + test('hashCache queries once per unique file', () => { + const getSpy = vi.fn((file) => (file === 'a.js' ? { hash: 'ha' } : { hash: 'hb' })); + const db = { prepare: vi.fn().mockReturnValue({ get: getSpy }) }; + const hc = new Map(); + + normalizeSymbol({ name: 'x', kind: 'function', file: 'a.js', line: 1 }, db, hc); + normalizeSymbol({ name: 'y', kind: 'function', file: 'b.js', line: 1 }, db, hc); + normalizeSymbol({ name: 'z', kind: 'function', file: 'a.js', line: 5 }, db, hc); + + expect(getSpy).toHaveBeenCalledTimes(2); + }); + + test('file with no hash returns fileHash null', () => { + const db = { + prepare: vi.fn().mockReturnValue({ + get: vi.fn().mockReturnValue(undefined), + }), + }; + const row = { name: 'x', kind: 'function', file: 'missing.js', line: 1 }; + const result = normalizeSymbol(row, db); + expect(result.fileHash).toBeNull(); + }); +});