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 ae8f3f43..62089ada 100644 --- a/src/index.js +++ b/src/index.js @@ -106,6 +106,7 @@ export { iterWhere, kindIcon, moduleMapData, + normalizeSymbol, pathData, queryNameData, rolesData, diff --git a/src/queries.js b/src/queries.js index c490744f..8a0ef0f1 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,10 +231,7 @@ export function queryNameData(name, customDbPath, opts = {}) { } return { - name: node.name, - kind: node.kind, - file: node.file, - line: node.line, + ...normalizeSymbol(node, db, hc), callees: callees.map((c) => ({ name: c.name, kind: c.kind, @@ -402,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) { @@ -493,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, @@ -523,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) { @@ -559,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, }; @@ -1194,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 }); } @@ -1234,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(); @@ -1252,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); @@ -1271,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(); @@ -2457,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; @@ -2514,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, @@ -2732,6 +2738,40 @@ export function explain(target, customDbPath, opts = {}) { // ─── whereData ────────────────────────────────────────────────────────── +function getFileHash(db, file) { + const row = db.prepare('SELECT hash FROM file_hashes WHERE file = ?').get(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 @@ -2741,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( @@ -2759,11 +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, - role: node.role || null, + ...normalizeSymbol(node, db, hc), exported, uses: uses.map((u) => ({ name: u.name, file: u.file, line: u.line })), }; @@ -2908,7 +2945,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); @@ -2919,8 +2956,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 69cf916b..1bbbdfa6 100644 --- a/tests/integration/queries.test.js +++ b/tests/integration/queries.test.js @@ -32,6 +32,7 @@ import { fnDepsData, fnImpactData, impactAnalysisData, + listFunctionsData, moduleMapData, pathData, queryNameData, @@ -101,6 +102,16 @@ beforeAll(() => { // Low-confidence call edge for quality tests insertEdge(db, formatResponse, validateToken, 'calls', 0.3); + // File hashes (for fileHash exposure) + for (const f of ['auth.js', 'middleware.js', 'routes.js', 'utils.js', 'auth.test.js']) { + db.prepare('INSERT INTO file_hashes (file, hash, mtime, size) VALUES (?, ?, ?, ?)').run( + f, + `hash_${f.replace('.', '_')}`, + Date.now(), + 100, + ); + } + db.close(); }); @@ -645,3 +656,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(); + }); +});