@remnic/coding-graph 9.3.759
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +130 -0
- package/dist/chunk-5I2DBHOQ.js +1042 -0
- package/dist/chunk-5I2DBHOQ.js.map +1 -0
- package/dist/chunk-CPYJACC5.js +1838 -0
- package/dist/chunk-CPYJACC5.js.map +1 -0
- package/dist/chunk-ZVCMIM4T.js +216 -0
- package/dist/chunk-ZVCMIM4T.js.map +1 -0
- package/dist/cypher/query-parser.d.ts +253 -0
- package/dist/cypher/query-parser.js +17 -0
- package/dist/cypher/query-parser.js.map +1 -0
- package/dist/graph-schema.d.ts +84 -0
- package/dist/graph-schema.js +17 -0
- package/dist/graph-schema.js.map +1 -0
- package/dist/graph-store.d.ts +938 -0
- package/dist/graph-store.js +16 -0
- package/dist/graph-store.js.map +1 -0
- package/dist/index.d.ts +1953 -0
- package/dist/index.js +3509 -0
- package/dist/index.js.map +1 -0
- package/grammars/tree-sitter-bash.wasm +0 -0
- package/grammars/tree-sitter-c.wasm +0 -0
- package/grammars/tree-sitter-c_sharp.wasm +0 -0
- package/grammars/tree-sitter-cpp.wasm +0 -0
- package/grammars/tree-sitter-go.wasm +0 -0
- package/grammars/tree-sitter-java.wasm +0 -0
- package/grammars/tree-sitter-javascript.wasm +0 -0
- package/grammars/tree-sitter-kotlin.wasm +0 -0
- package/grammars/tree-sitter-php.wasm +0 -0
- package/grammars/tree-sitter-python.wasm +0 -0
- package/grammars/tree-sitter-ruby.wasm +0 -0
- package/grammars/tree-sitter-rust.wasm +0 -0
- package/grammars/tree-sitter-swift.wasm +0 -0
- package/grammars/tree-sitter-tsx.wasm +0 -0
- package/grammars/tree-sitter-typescript.wasm +0 -0
- package/package.json +79 -0
- package/src/co-change.test.ts +175 -0
- package/src/co-change.ts +167 -0
- package/src/cypher/query-parser.test.ts +1107 -0
- package/src/cypher/query-parser.ts +1692 -0
- package/src/detect-changes.test.ts +533 -0
- package/src/detect-changes.ts +367 -0
- package/src/engine/emit.ts +556 -0
- package/src/engine/engine.test.ts +1417 -0
- package/src/engine/engine.ts +182 -0
- package/src/engine/extractors.ts +486 -0
- package/src/engine/fixtures.ts +364 -0
- package/src/engine/language-sniff.ts +56 -0
- package/src/engine/parser-backend.ts +206 -0
- package/src/engine/utf16-offsets.ts +68 -0
- package/src/git-invoker.test.ts +116 -0
- package/src/git-invoker.ts +426 -0
- package/src/graph-schema.test.ts +541 -0
- package/src/graph-schema.ts +383 -0
- package/src/graph-store-pr2.test.ts +1879 -0
- package/src/graph-store.test.ts +1420 -0
- package/src/graph-store.ts +3489 -0
- package/src/index-status.test.ts +303 -0
- package/src/index-status.ts +135 -0
- package/src/index.ts +384 -0
- package/src/lsp/byte-position.ts +173 -0
- package/src/lsp/characterization.test.ts +174 -0
- package/src/lsp/client.test.ts +275 -0
- package/src/lsp/client.ts +484 -0
- package/src/lsp/config.ts +219 -0
- package/src/lsp/degradation.ts +86 -0
- package/src/lsp/fixtures/fake-server.mjs +198 -0
- package/src/lsp/framing.test.ts +180 -0
- package/src/lsp/framing.ts +177 -0
- package/src/lsp/resolution.test.ts +497 -0
- package/src/lsp/resolution.ts +483 -0
- package/src/lsp/status.ts +140 -0
- package/src/lsp/types.ts +167 -0
- package/src/reindex.test.ts +1038 -0
- package/src/reindex.ts +908 -0
- package/src/row-types.ts +45 -0
- package/src/semantic/canonical-text.test.ts +150 -0
- package/src/semantic/canonical-text.ts +219 -0
- package/src/semantic/config.ts +235 -0
- package/src/semantic/index.ts +78 -0
- package/src/semantic/minhash.test.ts +197 -0
- package/src/semantic/minhash.ts +261 -0
- package/src/semantic/semantic-query.ts +173 -0
- package/src/semantic/semantic.test.ts +1315 -0
- package/src/semantic/similarity.ts +268 -0
- package/src/semantic/types.ts +145 -0
- package/src/semantic/vectors.ts +235 -0
|
@@ -0,0 +1,1838 @@
|
|
|
1
|
+
import {
|
|
2
|
+
applyCodingGraphSchema,
|
|
3
|
+
expectRow,
|
|
4
|
+
expectRows,
|
|
5
|
+
ftsRowidForNodeId,
|
|
6
|
+
isEdgeProvenance,
|
|
7
|
+
readSchemaVersion
|
|
8
|
+
} from "./chunk-ZVCMIM4T.js";
|
|
9
|
+
|
|
10
|
+
// src/graph-store.ts
|
|
11
|
+
import { createHash } from "crypto";
|
|
12
|
+
import { mkdir, readFile } from "fs/promises";
|
|
13
|
+
import path from "path";
|
|
14
|
+
import {
|
|
15
|
+
openBetterSqlite3
|
|
16
|
+
} from "@remnic/core/runtime/better-sqlite";
|
|
17
|
+
var DEFAULT_TRAVERSE_PATHS_MAX = 1e4;
|
|
18
|
+
var MAX_TRAVERSE_PATHS_HOPS = 1e3;
|
|
19
|
+
var DEAD_CODE_EXCLUSION = {
|
|
20
|
+
/**
|
|
21
|
+
* Edge types that — when pointing INTO a node — count as "this node
|
|
22
|
+
* is used". Mirrors the issue's `CALLS/USAGE` wording plus the four
|
|
23
|
+
* call-flavored edge types in the wider coding-graph vocabulary.
|
|
24
|
+
*/
|
|
25
|
+
INBOUND_USAGE_EDGE_TYPES: [
|
|
26
|
+
"CALLS",
|
|
27
|
+
"USES_TYPE",
|
|
28
|
+
"ASYNC_CALLS",
|
|
29
|
+
"HTTP_CALLS",
|
|
30
|
+
"DATA_FLOWS"
|
|
31
|
+
],
|
|
32
|
+
/**
|
|
33
|
+
* File-path regexes identifying test files. Matched against
|
|
34
|
+
* `files.path` (repo-relative, forward slashes).
|
|
35
|
+
*/
|
|
36
|
+
TEST_PATH_PATTERNS: [
|
|
37
|
+
/\.test\.[cm]?[tj]sx?$/,
|
|
38
|
+
/\.spec\.[cm]?[tj]sx?$/,
|
|
39
|
+
/(^|\/)__tests__\//,
|
|
40
|
+
/(^|\/)__mocks__\//,
|
|
41
|
+
/(^|\/)tests?\//,
|
|
42
|
+
/(^|\/)test\//
|
|
43
|
+
],
|
|
44
|
+
/**
|
|
45
|
+
* File-path regexes identifying entry points (reachable from
|
|
46
|
+
* outside the indexed code). Matched against `files.path`. Kept
|
|
47
|
+
* deliberately narrow — `server.ts` / `app.ts` are intentionally
|
|
48
|
+
* NOT treated as entry points because they are common module
|
|
49
|
+
* names that may also contain dead helpers. The conservative
|
|
50
|
+
* direction is to report a symbol as dead rather than hide it.
|
|
51
|
+
*/
|
|
52
|
+
ENTRY_POINT_PATH_PATTERNS: [
|
|
53
|
+
/(^|\/)index\.[cm]?[tj]sx?$/,
|
|
54
|
+
/(^|\/)main\.[cm]?[tj]sx?$/,
|
|
55
|
+
/(^|\/)cli\.[cm]?[tj]sx?$/,
|
|
56
|
+
/(^|\/)bin\//,
|
|
57
|
+
/(^|\/)src\/bin\//
|
|
58
|
+
],
|
|
59
|
+
/**
|
|
60
|
+
* Columns on `node_attributes` whose value being `1` excludes the
|
|
61
|
+
* node. Names mirror the schema so a future column add is a one-line
|
|
62
|
+
* constant extension + a query clause (no scattered edits).
|
|
63
|
+
*/
|
|
64
|
+
EXCLUDED_ATTRIBUTE_FLAGS: ["is_exported", "is_route_handler"]
|
|
65
|
+
};
|
|
66
|
+
function isExcludedByPath(filePath) {
|
|
67
|
+
for (const re of DEAD_CODE_EXCLUSION.TEST_PATH_PATTERNS) {
|
|
68
|
+
if (re.test(filePath)) return true;
|
|
69
|
+
}
|
|
70
|
+
for (const re of DEAD_CODE_EXCLUSION.ENTRY_POINT_PATH_PATTERNS) {
|
|
71
|
+
if (re.test(filePath)) return true;
|
|
72
|
+
}
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
var SQLITE_VARIABLE_LIMIT = 32766;
|
|
76
|
+
var WriteQueue = class {
|
|
77
|
+
tail = Promise.resolve();
|
|
78
|
+
schedule(run) {
|
|
79
|
+
const next = this.tail.then(run, run);
|
|
80
|
+
this.tail = next.catch(() => void 0);
|
|
81
|
+
return next;
|
|
82
|
+
}
|
|
83
|
+
/** Test seam: wait until the queue has drained. */
|
|
84
|
+
async drain() {
|
|
85
|
+
await this.tail;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
var GraphStore = class _GraphStore {
|
|
89
|
+
db;
|
|
90
|
+
queue = new WriteQueue();
|
|
91
|
+
repoRoot;
|
|
92
|
+
closed = false;
|
|
93
|
+
closing = false;
|
|
94
|
+
/**
|
|
95
|
+
* True once close() has begun (closing) or completed (closed). Public so
|
|
96
|
+
* callers that hold a GraphStore reference can return the documented
|
|
97
|
+
* 'store_closed' degradation code instead of treating a closed store as
|
|
98
|
+
* an empty graph (cursor Bugbot: 'Closed store reports success'). The
|
|
99
|
+
* read primitives already short-circuit on this internally; this getter
|
|
100
|
+
* lets the semantic entry points do the same BEFORE calling a read that
|
|
101
|
+
* would return [].
|
|
102
|
+
*/
|
|
103
|
+
get isClosed() {
|
|
104
|
+
return this.closed || this.closing;
|
|
105
|
+
}
|
|
106
|
+
// Shared drain-and-close promise so a second close() called while the
|
|
107
|
+
// first is still draining awaits the same completion instead of
|
|
108
|
+
// resolving early (chatgpt-codex-connector P2: 'Wait for an
|
|
109
|
+
// in-progress close').
|
|
110
|
+
closePromise;
|
|
111
|
+
constructor(db, repoRoot) {
|
|
112
|
+
this.db = db;
|
|
113
|
+
this.repoRoot = repoRoot;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Open a store at the given dbPath. Creates parent directories and
|
|
117
|
+
* applies the schema (idempotent — also handles upgrade). The dbPath
|
|
118
|
+
* does no namespace resolution.
|
|
119
|
+
*/
|
|
120
|
+
static async open(options) {
|
|
121
|
+
const { dbPath, repoRoot } = options;
|
|
122
|
+
if (!path.isAbsolute(dbPath)) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`graph-store: dbPath must be absolute; received ${JSON.stringify(dbPath)}`
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
if (repoRoot !== void 0 && !path.isAbsolute(repoRoot)) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`graph-store: repoRoot must be absolute when provided; received ${JSON.stringify(repoRoot)}`
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
await mkdir(path.dirname(dbPath), { recursive: true });
|
|
133
|
+
const db = openBetterSqlite3(dbPath);
|
|
134
|
+
db.pragma("journal_mode = WAL");
|
|
135
|
+
db.pragma("busy_timeout = 5000");
|
|
136
|
+
db.pragma("synchronous = NORMAL");
|
|
137
|
+
db.pragma("foreign_keys = ON");
|
|
138
|
+
applyCodingGraphSchema(db);
|
|
139
|
+
return new _GraphStore(db, repoRoot);
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* The current schema_version row. Test seam — never expires, never
|
|
143
|
+
* cached so migrations land without a restart.
|
|
144
|
+
*/
|
|
145
|
+
schemaVersion() {
|
|
146
|
+
return readSchemaVersion(this.db);
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Ingest a batch of IR files atomically. One transaction wraps every
|
|
150
|
+
* file's delete + insert; if any file throws, the whole batch rolls
|
|
151
|
+
* back (rule 34 — never partial-write a coding graph).
|
|
152
|
+
*
|
|
153
|
+
* Re-ingesting the same IR is a no-op once the rows are written
|
|
154
|
+
* (idempotency — node ids are deterministic so the second pass collides
|
|
155
|
+
* on PRIMARY KEY).
|
|
156
|
+
*
|
|
157
|
+
* Two-pass ordering: pass 1 upserts every file's nodes (so FTS stays
|
|
158
|
+
* in sync and cross-file edge targets exist by the time pass 2 runs),
|
|
159
|
+
* pass 2 resolves edges against the full batch's node map and deletes
|
|
160
|
+
* prior edges owned by these files so changed confidence/provenance
|
|
161
|
+
* values overwrite (chatgpt-codex-connector P1 + cursor medium + PR1
|
|
162
|
+
* design anchor in graph-schema).
|
|
163
|
+
*
|
|
164
|
+
* Tagging:
|
|
165
|
+
* - `{ok:true, results}` — every file's counts.
|
|
166
|
+
* - `{ok:false, code:"db_locked"}` — busy_timeout elapsed; caller may
|
|
167
|
+
* retry. NOT a thrown error so the agent can degrade gracefully.
|
|
168
|
+
* - `{ok:false, code:"db_corrupt"}` — SQLite reported
|
|
169
|
+
* `database disk image is malformed`; the caller must surface and
|
|
170
|
+
* stop trusting this DB.
|
|
171
|
+
*/
|
|
172
|
+
async upsertFileBatch(files, deletePaths = []) {
|
|
173
|
+
if (this.closed || this.closing) {
|
|
174
|
+
return {
|
|
175
|
+
ok: false,
|
|
176
|
+
code: "store_closed"
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
return this.queue.schedule(() => this.runUpsert(files, deletePaths));
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Upsert standalone edges whose endpoints are resolved from the FULL
|
|
183
|
+
* database (not just a per-file batch). Used by the codegraph
|
|
184
|
+
* ingest_traces surface (issue #1554) to persist runtime HTTP_CALLS
|
|
185
|
+
* observations as edges with `provenance: "trace"` — upgrading
|
|
186
|
+
* confidence on existing edges and inserting new ones.
|
|
187
|
+
*
|
|
188
|
+
* Endpoint resolution: when an edge carries `srcNodeId` / `dstNodeId`
|
|
189
|
+
* (issue #1677 — the SIMILAR_TO pipeline populates them from
|
|
190
|
+
* content-derived node ids), the endpoint is resolved by `nodes.id`
|
|
191
|
+
* (unique primary key), so an edge between two symbols that share a
|
|
192
|
+
* qualified name across files is persisted rather than dropped as
|
|
193
|
+
* ambiguous. Edges WITHOUT node ids fall back to qualified_name
|
|
194
|
+
* resolution via the global `resolveNodeId` (unambiguous single-match
|
|
195
|
+
* policy). Edges whose endpoints do not resolve (missing node id row OR
|
|
196
|
+
* an ambiguous/dangling qualified name) are skipped (and counted in
|
|
197
|
+
* `skipped`) rather than attached to the wrong node — the dangling-edge
|
|
198
|
+
* policy from `upsertFileBatch` applies.
|
|
199
|
+
*
|
|
200
|
+
* Serialized on the store's write queue like `upsertFileBatch` so a
|
|
201
|
+
* concurrent file-batch upsert and a trace upsert cannot interleave
|
|
202
|
+
* (rule 40).
|
|
203
|
+
*/
|
|
204
|
+
async upsertEdges(edges) {
|
|
205
|
+
if (this.closed || this.closing) {
|
|
206
|
+
return { ok: false, code: "store_closed" };
|
|
207
|
+
}
|
|
208
|
+
return this.queue.schedule(() => this.runUpsertEdges(edges));
|
|
209
|
+
}
|
|
210
|
+
/** Wait for pending writes to drain — test seam. */
|
|
211
|
+
async drain() {
|
|
212
|
+
await this.queue.drain();
|
|
213
|
+
}
|
|
214
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
215
|
+
// PR3 (issue #1553): meta-table + file-management methods for the
|
|
216
|
+
// incremental reindex pipeline.
|
|
217
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
218
|
+
/**
|
|
219
|
+
* Read a value from the `meta` table. Returns `null` when the key is
|
|
220
|
+
* absent. Synchronous (like the other read primitives) so the reindex
|
|
221
|
+
* planner can read `last_indexed_head` without an await.
|
|
222
|
+
*/
|
|
223
|
+
readMeta(key) {
|
|
224
|
+
if (this.closed) return { ok: false, code: "store_closed" };
|
|
225
|
+
try {
|
|
226
|
+
const row = expectRow(
|
|
227
|
+
this.db.prepare("SELECT value FROM meta WHERE key = ?").get(key),
|
|
228
|
+
["value"]
|
|
229
|
+
);
|
|
230
|
+
return { ok: true, value: row ? row.value : null };
|
|
231
|
+
} catch (error) {
|
|
232
|
+
logWriteFailure(error);
|
|
233
|
+
return classifyReadError(error);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Write a key/value pair to the `meta` table. Synchronous — runs in its
|
|
238
|
+
* own implicit transaction. The reindex executor calls this AFTER
|
|
239
|
+
* `upsertFileBatch` resolves (rule 25: head/state updates only after
|
|
240
|
+
* the data transaction commits). A crash between the two leaves the old
|
|
241
|
+
* head, and the next run re-ingests idempotently (deterministic node ids).
|
|
242
|
+
*/
|
|
243
|
+
writeMeta(key, value) {
|
|
244
|
+
if (this.closed) return;
|
|
245
|
+
this.db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)").run(key, value);
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Read every file row's path → content_hash. Used by hash_scan mode
|
|
249
|
+
* to detect content drift without a reachable base commit (issue #1553).
|
|
250
|
+
*/
|
|
251
|
+
readFileHashes() {
|
|
252
|
+
if (this.closed) return { ok: false, code: "store_closed" };
|
|
253
|
+
try {
|
|
254
|
+
const rows = expectRows(
|
|
255
|
+
this.db.prepare("SELECT path, content_hash FROM files").all(),
|
|
256
|
+
["path", "content_hash"]
|
|
257
|
+
);
|
|
258
|
+
const out = /* @__PURE__ */ new Map();
|
|
259
|
+
for (const r of rows) out.set(r.path, r.content_hash);
|
|
260
|
+
return { ok: true, hashes: out };
|
|
261
|
+
} catch (error) {
|
|
262
|
+
logWriteFailure(error);
|
|
263
|
+
return classifyReadError(error);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Drop file rows by path, cascading to their nodes + edges +
|
|
268
|
+
* node_attributes (the schema's `ON DELETE CASCADE` from `files(id)`
|
|
269
|
+
* handles the cascade — `foreign_keys = ON` is set in `open()`).
|
|
270
|
+
* Used by the reindex executor to prune deleted files.
|
|
271
|
+
*
|
|
272
|
+
* Paths are chunked under the SQLite variable limit (rule 23 pattern).
|
|
273
|
+
*/
|
|
274
|
+
async dropFiles(paths) {
|
|
275
|
+
if (this.closed || this.closing || paths.length === 0) return;
|
|
276
|
+
await this.queue.schedule(async () => {
|
|
277
|
+
this.runChunkedDelete(
|
|
278
|
+
"DELETE FROM files WHERE path IN (%PH%)",
|
|
279
|
+
paths
|
|
280
|
+
);
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Chunk a parameterized DELETE-with-IN-list under SQLite's variable
|
|
285
|
+
* bind limit. Mirrors the chunking pattern used by `runChunkedUpdate`
|
|
286
|
+
* and the stale-edge deletes.
|
|
287
|
+
*/
|
|
288
|
+
runChunkedDelete(sqlTemplate, params) {
|
|
289
|
+
if (params.length === 0) return;
|
|
290
|
+
for (let i = 0; i < params.length; i += SQLITE_VARIABLE_LIMIT) {
|
|
291
|
+
const chunk = params.slice(i, i + SQLITE_VARIABLE_LIMIT);
|
|
292
|
+
const placeholders = chunk.map(() => "?").join(", ");
|
|
293
|
+
this.db.prepare(sqlTemplate.replace("%PH%", placeholders)).run(...chunk);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* PR3 (issue #1553): upsert co-change edges into the `co_changes`
|
|
298
|
+
* table. Clears existing edges then inserts the new set in one
|
|
299
|
+
* transaction (idempotent — re-running on unchanged history produces
|
|
300
|
+
* the same table). Serialized through the write queue.
|
|
301
|
+
*/
|
|
302
|
+
/**
|
|
303
|
+
* PR3 (issue #1553): upsert co-change edges into the `co_changes`
|
|
304
|
+
* table. Clears existing edges then inserts the new set in one
|
|
305
|
+
* transaction (idempotent — re-running on unchanged history produces
|
|
306
|
+
* the same table). Serialized through the write queue.
|
|
307
|
+
*
|
|
308
|
+
* Returns `{ ok: false, code: "store_closed" }` when the store is
|
|
309
|
+
* closed/closing so the caller does NOT believe mining succeeded
|
|
310
|
+
* while nothing was persisted (cursor Bugbot: 'Co-change store
|
|
311
|
+
* reports false success').
|
|
312
|
+
*/
|
|
313
|
+
async upsertCoChanges(edges) {
|
|
314
|
+
if (this.closed || this.closing) {
|
|
315
|
+
return { ok: false, code: "store_closed" };
|
|
316
|
+
}
|
|
317
|
+
try {
|
|
318
|
+
await this.queue.schedule(async () => {
|
|
319
|
+
const tx = this.db.transaction(() => {
|
|
320
|
+
this.db.exec("DELETE FROM co_changes");
|
|
321
|
+
const insert = this.db.prepare(
|
|
322
|
+
`INSERT INTO co_changes (file_a, file_b, support, confidence)
|
|
323
|
+
VALUES (?, ?, ?, ?)
|
|
324
|
+
ON CONFLICT(file_a, file_b) DO UPDATE SET
|
|
325
|
+
support = excluded.support,
|
|
326
|
+
confidence = excluded.confidence`
|
|
327
|
+
);
|
|
328
|
+
for (const e of edges) {
|
|
329
|
+
insert.run(e.fileA, e.fileB, e.support, e.confidence);
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
tx();
|
|
333
|
+
});
|
|
334
|
+
return { ok: true };
|
|
335
|
+
} catch (error) {
|
|
336
|
+
logWriteFailure(error);
|
|
337
|
+
return { ok: false, code: "db_error" };
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* PR3 (issue #1553): read co-change edges for a file. Returns edges
|
|
342
|
+
* where the file is either `file_a` or `file_b`. Synchronous read.
|
|
343
|
+
*/
|
|
344
|
+
readCoChanges(filePath) {
|
|
345
|
+
if (this.closed) return { ok: false, code: "store_closed" };
|
|
346
|
+
try {
|
|
347
|
+
const rows = expectRows(
|
|
348
|
+
this.db.prepare(
|
|
349
|
+
`SELECT file_a, file_b, support, confidence
|
|
350
|
+
FROM co_changes
|
|
351
|
+
WHERE file_a = ? OR file_b = ?
|
|
352
|
+
ORDER BY confidence DESC, file_a ASC, file_b ASC`
|
|
353
|
+
).all(filePath, filePath),
|
|
354
|
+
["file_a", "file_b", "support", "confidence"]
|
|
355
|
+
);
|
|
356
|
+
return {
|
|
357
|
+
ok: true,
|
|
358
|
+
edges: rows.map((r) => ({
|
|
359
|
+
fileA: r.file_a,
|
|
360
|
+
fileB: r.file_b,
|
|
361
|
+
support: r.support,
|
|
362
|
+
confidence: r.confidence
|
|
363
|
+
}))
|
|
364
|
+
};
|
|
365
|
+
} catch (error) {
|
|
366
|
+
logWriteFailure(error);
|
|
367
|
+
return classifyReadError(error);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Close the SQLite handle after draining the write queue. A batch
|
|
372
|
+
* that has already been scheduled on the queue would otherwise run
|
|
373
|
+
* against a closed DB and surface as `db_corrupt` — the caller
|
|
374
|
+
* would stop trusting the store for unrelated reasons. Drain first,
|
|
375
|
+
* then close (cursor Bugbot #09be5784).
|
|
376
|
+
*/
|
|
377
|
+
async close() {
|
|
378
|
+
if (this.closed) return;
|
|
379
|
+
if (this.closing) return this.closePromise;
|
|
380
|
+
this.closing = true;
|
|
381
|
+
this.closePromise = this.finishClose();
|
|
382
|
+
return this.closePromise;
|
|
383
|
+
}
|
|
384
|
+
/** Drain queued writes then close the SQLite handle exactly once. */
|
|
385
|
+
async finishClose() {
|
|
386
|
+
await this.queue.drain();
|
|
387
|
+
this.closed = true;
|
|
388
|
+
this.db.close();
|
|
389
|
+
}
|
|
390
|
+
// ────────────── private ──────────────
|
|
391
|
+
async runUpsert(files, deletePaths = []) {
|
|
392
|
+
const seenPaths = /* @__PURE__ */ new Set();
|
|
393
|
+
for (const ir of files) {
|
|
394
|
+
assertCanonicalFilePath(ir.path);
|
|
395
|
+
if (seenPaths.has(ir.path)) {
|
|
396
|
+
throw new Error(
|
|
397
|
+
`graph-store: duplicate path '${ir.path}' in batch \u2014 each FileIR must have a unique path`
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
seenPaths.add(ir.path);
|
|
401
|
+
const symbolsField = ir.symbols;
|
|
402
|
+
if (!Array.isArray(symbolsField)) {
|
|
403
|
+
throw new Error(
|
|
404
|
+
`graph-store: file '${ir.path}' symbols must be an array (FileIR contract requires it); received ${symbolsField === null ? "null" : typeof symbolsField} \u2014 refusing to ingest to avoid wiping existing nodes`
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
for (const sym of symbolsField) {
|
|
408
|
+
assertValidSymbolSpan(sym, ir.path);
|
|
409
|
+
}
|
|
410
|
+
if (ir.exports != null) {
|
|
411
|
+
if (!Array.isArray(ir.exports)) {
|
|
412
|
+
throw new Error(
|
|
413
|
+
`graph-store: file '${ir.path}' exports must be an array when present; received ${ir.exports === null ? "null" : typeof ir.exports} \u2014 refusing to ingest to avoid wiping existing flags`
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
for (const ex of ir.exports) {
|
|
417
|
+
if (!ex || typeof ex.name !== "string" || ex.name.length === 0) {
|
|
418
|
+
throw new Error(
|
|
419
|
+
`graph-store: file '${ir.path}' has a malformed export entry \u2014 expected { name: string (non-empty) }; refusing to ingest to avoid wiping existing flags`
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
if (ir.routes != null) {
|
|
425
|
+
if (!Array.isArray(ir.routes)) {
|
|
426
|
+
throw new Error(
|
|
427
|
+
`graph-store: file '${ir.path}' routes must be an array when present; received ${ir.routes === null ? "null" : typeof ir.routes} \u2014 refusing to ingest to avoid wiping existing flags`
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
for (const r of ir.routes) {
|
|
431
|
+
if (!r || typeof r.handlerQualifiedName !== "string" || r.handlerQualifiedName.length === 0) {
|
|
432
|
+
throw new Error(
|
|
433
|
+
`graph-store: file '${ir.path}' has a malformed route entry \u2014 expected { handlerQualifiedName: string (non-empty) }; refusing to ingest to avoid wiping existing flags`
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
try {
|
|
440
|
+
const results = [];
|
|
441
|
+
const tx = this.db.transaction((irs) => {
|
|
442
|
+
if (deletePaths.length > 0) {
|
|
443
|
+
for (let i = 0; i < deletePaths.length; i += SQLITE_VARIABLE_LIMIT) {
|
|
444
|
+
const chunk = deletePaths.slice(i, i + SQLITE_VARIABLE_LIMIT);
|
|
445
|
+
const placeholders = chunk.map(() => "?").join(", ");
|
|
446
|
+
this.db.prepare("DELETE FROM files WHERE path IN (%PH%)".replace("%PH%", placeholders)).run(...chunk);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
const pending = [];
|
|
450
|
+
for (const ir of irs) {
|
|
451
|
+
const { result, prunedNodeIds } = this.upsertFileNodes(ir);
|
|
452
|
+
pending.push({ result, prunedNodeIds });
|
|
453
|
+
results.push(result);
|
|
454
|
+
}
|
|
455
|
+
const batchPrunedIds = [];
|
|
456
|
+
for (const { prunedNodeIds } of pending) {
|
|
457
|
+
for (const id of prunedNodeIds) batchPrunedIds.push(id);
|
|
458
|
+
}
|
|
459
|
+
for (const { result, prunedNodeIds } of pending) {
|
|
460
|
+
this.pruneFileNodes(result, prunedNodeIds, batchPrunedIds);
|
|
461
|
+
}
|
|
462
|
+
for (let i = 0; i < irs.length; i += 1) {
|
|
463
|
+
const ir = irs[i];
|
|
464
|
+
const result = results[i];
|
|
465
|
+
this.upsertFileEdges(ir, result);
|
|
466
|
+
}
|
|
467
|
+
for (let i = 0; i < irs.length; i += 1) {
|
|
468
|
+
const ir = irs[i];
|
|
469
|
+
const result = results[i];
|
|
470
|
+
this.upsertFileAttributes(ir, result);
|
|
471
|
+
}
|
|
472
|
+
});
|
|
473
|
+
tx(files);
|
|
474
|
+
return { ok: true, results };
|
|
475
|
+
} catch (error) {
|
|
476
|
+
logWriteFailure(error);
|
|
477
|
+
return classifyError(error);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Standalone-edge upsert body (runs under the write queue). Resolves
|
|
482
|
+
* both endpoints from the full DB via the unambiguous single-match
|
|
483
|
+
* `resolveNodeId` fallback, then upserts each edge with the same
|
|
484
|
+
* ON CONFLICT(src,dst,type) policy as the file-batch path. Edges whose
|
|
485
|
+
* src or dst do not resolve to exactly one node are skipped (counted
|
|
486
|
+
* in `skipped`) per the dangling-edge policy.
|
|
487
|
+
*/
|
|
488
|
+
async runUpsertEdges(edges) {
|
|
489
|
+
const emptyBatch = /* @__PURE__ */ new Map();
|
|
490
|
+
const insertEdge = this.db.prepare(
|
|
491
|
+
`INSERT INTO edges (src, dst, type, confidence, provenance)
|
|
492
|
+
VALUES (?, ?, ?, ?, ?)
|
|
493
|
+
ON CONFLICT(src, dst, type) DO UPDATE SET
|
|
494
|
+
confidence = excluded.confidence,
|
|
495
|
+
provenance = excluded.provenance`
|
|
496
|
+
);
|
|
497
|
+
const nodeById = this.db.prepare(
|
|
498
|
+
"SELECT qualified_name FROM nodes WHERE id = ? LIMIT 1"
|
|
499
|
+
);
|
|
500
|
+
try {
|
|
501
|
+
let persisted = 0;
|
|
502
|
+
let skipped = 0;
|
|
503
|
+
this.db.transaction(() => {
|
|
504
|
+
for (const edge of edges) {
|
|
505
|
+
if (!isEdgeProvenance(edge.provenance)) {
|
|
506
|
+
throw new Error(
|
|
507
|
+
`graph-store: edge has invalid provenance ${JSON.stringify(edge.provenance)}`
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
if (!Number.isFinite(edge.confidence) || edge.confidence < 0 || edge.confidence > 1) {
|
|
511
|
+
throw new Error(
|
|
512
|
+
`graph-store: edge confidence ${edge.confidence} is out of range [0, 1] for edge ${edge.srcQualifiedName} \u2192 ${edge.dstQualifiedName}`
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
const srcId = edge.srcNodeId ? resolveByNodeId(nodeById, edge.srcNodeId, edge.srcQualifiedName) : resolveNodeId(edge.srcQualifiedName, emptyBatch, this.db);
|
|
516
|
+
const dstId = edge.dstNodeId ? resolveByNodeId(nodeById, edge.dstNodeId, edge.dstQualifiedName) : resolveNodeId(edge.dstQualifiedName, emptyBatch, this.db);
|
|
517
|
+
if (!srcId || !dstId) {
|
|
518
|
+
skipped += 1;
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
const r = insertEdge.run(srcId, dstId, edge.type, edge.confidence, edge.provenance);
|
|
522
|
+
persisted += r.changes;
|
|
523
|
+
}
|
|
524
|
+
})();
|
|
525
|
+
return { ok: true, persisted, skipped };
|
|
526
|
+
} catch (error) {
|
|
527
|
+
logWriteFailure(error);
|
|
528
|
+
return classifyError(error);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Pass 1a: upsert the file row and every symbol node, refreshing the
|
|
533
|
+
* contentless `nodes_fts` index in lockstep, and compute the set of
|
|
534
|
+
* stale node ids this file wants to prune (deterministic id, NOT
|
|
535
|
+
* qualified_name, so a kind change gets a new id and the OLD row is
|
|
536
|
+
* deleted). The prune itself — and the dangling-edge count that
|
|
537
|
+
* gates it — is deferred to {@link pruneFileNodes} so the whole batch
|
|
538
|
+
* shares one batch-wide view of what is being pruned before any
|
|
539
|
+
* cascade runs.
|
|
540
|
+
*/
|
|
541
|
+
upsertFileNodes(ir) {
|
|
542
|
+
const upsertFile = this.db.prepare(
|
|
543
|
+
`INSERT INTO files (path, lang, content_hash)
|
|
544
|
+
VALUES (?, ?, ?)
|
|
545
|
+
ON CONFLICT(path) DO UPDATE SET
|
|
546
|
+
lang = excluded.lang,
|
|
547
|
+
content_hash = excluded.content_hash
|
|
548
|
+
RETURNING id`
|
|
549
|
+
);
|
|
550
|
+
const fileRow = expectRow(
|
|
551
|
+
upsertFile.get(ir.path, ir.language, ir.contentHash),
|
|
552
|
+
["id"]
|
|
553
|
+
);
|
|
554
|
+
if (!fileRow) {
|
|
555
|
+
throw new Error(
|
|
556
|
+
`graph-store: INSERT INTO files RETURNING id returned no row for path=${ir.path}`
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
const fileId = fileRow.id;
|
|
560
|
+
const seenNodeIds = /* @__PURE__ */ new Set();
|
|
561
|
+
const symbolByNodeId = /* @__PURE__ */ new Map();
|
|
562
|
+
for (const sym of ir.symbols) {
|
|
563
|
+
const id = nodeIdFor({
|
|
564
|
+
qualifiedName: sym.qualifiedName,
|
|
565
|
+
filePath: ir.path,
|
|
566
|
+
label: sym.kind
|
|
567
|
+
});
|
|
568
|
+
seenNodeIds.add(id);
|
|
569
|
+
symbolByNodeId.set(id, sym);
|
|
570
|
+
}
|
|
571
|
+
const existingNodes = expectRows(
|
|
572
|
+
this.db.prepare(
|
|
573
|
+
`SELECT id, label, name, qualified_name, file_id,
|
|
574
|
+
span_start, span_end, lang
|
|
575
|
+
FROM nodes WHERE file_id = ?`
|
|
576
|
+
).all(fileId),
|
|
577
|
+
["id", "label", "name", "qualified_name", "file_id", "span_start", "span_end", "lang"]
|
|
578
|
+
);
|
|
579
|
+
const existingById = new Map(existingNodes.map((n) => [n.id, n]));
|
|
580
|
+
const insertNode = this.db.prepare(
|
|
581
|
+
`INSERT INTO nodes (
|
|
582
|
+
id, label, name, qualified_name,
|
|
583
|
+
file_id, span_start, span_end, lang
|
|
584
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
585
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
586
|
+
label = excluded.label,
|
|
587
|
+
name = excluded.name,
|
|
588
|
+
qualified_name = excluded.qualified_name,
|
|
589
|
+
file_id = excluded.file_id,
|
|
590
|
+
span_start = excluded.span_start,
|
|
591
|
+
span_end = excluded.span_end,
|
|
592
|
+
lang = excluded.lang`
|
|
593
|
+
);
|
|
594
|
+
const insertFts = this.db.prepare(
|
|
595
|
+
`INSERT INTO nodes_fts (rowid, name, qualified_name) VALUES (?, ?, ?)`
|
|
596
|
+
);
|
|
597
|
+
const deleteFtsByRowid = this.db.prepare(
|
|
598
|
+
`DELETE FROM nodes_fts WHERE rowid = ?`
|
|
599
|
+
);
|
|
600
|
+
const upsertFtsIndex = this.db.prepare(
|
|
601
|
+
`INSERT INTO fts_index (fts_rowid, node_id) VALUES (?, ?)
|
|
602
|
+
ON CONFLICT(node_id) DO UPDATE SET
|
|
603
|
+
fts_rowid = excluded.fts_rowid`
|
|
604
|
+
);
|
|
605
|
+
const deleteFtsIndexByRowid = this.db.prepare(
|
|
606
|
+
`DELETE FROM fts_index WHERE fts_rowid = ?`
|
|
607
|
+
);
|
|
608
|
+
let nodeCount = 0;
|
|
609
|
+
for (const [id, sym] of symbolByNodeId) {
|
|
610
|
+
const prior = existingById.get(id);
|
|
611
|
+
if (prior && prior.label === sym.kind && prior.name === sym.name && prior.qualified_name === sym.qualifiedName && prior.span_start === sym.span.startByte && prior.span_end === sym.span.endByte && prior.lang === ir.language) {
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
const ftsRowid = ftsRowidForNodeId(id);
|
|
615
|
+
deleteFtsByRowid.run(ftsRowid);
|
|
616
|
+
deleteFtsIndexByRowid.run(ftsRowid);
|
|
617
|
+
insertNode.run(
|
|
618
|
+
id,
|
|
619
|
+
sym.kind,
|
|
620
|
+
sym.name,
|
|
621
|
+
sym.qualifiedName,
|
|
622
|
+
fileId,
|
|
623
|
+
sym.span.startByte,
|
|
624
|
+
sym.span.endByte,
|
|
625
|
+
ir.language
|
|
626
|
+
);
|
|
627
|
+
insertFts.run(ftsRowid, sym.name, sym.qualifiedName);
|
|
628
|
+
upsertFtsIndex.run(ftsRowid, id);
|
|
629
|
+
nodeCount += 1;
|
|
630
|
+
}
|
|
631
|
+
const prunedNodeIds = existingNodes.map((n) => n.id).filter((id) => !seenNodeIds.has(id));
|
|
632
|
+
return {
|
|
633
|
+
result: {
|
|
634
|
+
path: ir.path,
|
|
635
|
+
fileId,
|
|
636
|
+
nodeCount,
|
|
637
|
+
edgeCount: 0,
|
|
638
|
+
droppedDanglingEdges: 0
|
|
639
|
+
},
|
|
640
|
+
prunedNodeIds
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Pass 1b: count the dangling edges this file's prune will drop and
|
|
645
|
+
* perform the cascade delete + FTS cleanup. A dangling edge is one
|
|
646
|
+
* whose dst is pruned by THIS file but whose src survives — and
|
|
647
|
+
* "survives" is judged against the BATCH-WIDE pruned set, so an edge
|
|
648
|
+
* whose both ends are pruned (possibly in different files) is
|
|
649
|
+
* cascade-deleted and never reported as dangling. This makes the
|
|
650
|
+
* reported loss independent of the order files are visited in
|
|
651
|
+
* (chatgpt-codex-connector P2: 'Count dangling edges against the
|
|
652
|
+
* whole batch').
|
|
653
|
+
*/
|
|
654
|
+
pruneFileNodes(result, prunedNodeIds, batchPrunedIds) {
|
|
655
|
+
if (prunedNodeIds.length === 0) {
|
|
656
|
+
result.droppedDanglingEdges = 0;
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
this.db.exec(
|
|
660
|
+
"CREATE TEMP TABLE IF NOT EXISTS _pruned_ids (id TEXT NOT NULL PRIMARY KEY)"
|
|
661
|
+
);
|
|
662
|
+
this.db.exec(
|
|
663
|
+
"CREATE TEMP TABLE IF NOT EXISTS _batch_pruned_ids (id TEXT NOT NULL PRIMARY KEY)"
|
|
664
|
+
);
|
|
665
|
+
const clearPruned = this.db.prepare("DELETE FROM _pruned_ids");
|
|
666
|
+
const clearBatch = this.db.prepare("DELETE FROM _batch_pruned_ids");
|
|
667
|
+
const insertPruned = this.db.prepare(
|
|
668
|
+
"INSERT OR IGNORE INTO _pruned_ids (id) VALUES (?)"
|
|
669
|
+
);
|
|
670
|
+
const insertBatch = this.db.prepare(
|
|
671
|
+
"INSERT OR IGNORE INTO _batch_pruned_ids (id) VALUES (?)"
|
|
672
|
+
);
|
|
673
|
+
clearPruned.run();
|
|
674
|
+
clearBatch.run();
|
|
675
|
+
const fillTemp = this.db.transaction(
|
|
676
|
+
(rows) => {
|
|
677
|
+
for (const { table, ids } of rows) {
|
|
678
|
+
const stmt = table === "_pruned_ids" ? insertPruned : insertBatch;
|
|
679
|
+
for (const id of ids) stmt.run(id);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
);
|
|
683
|
+
fillTemp([
|
|
684
|
+
{ table: "_pruned_ids", ids: prunedNodeIds },
|
|
685
|
+
{ table: "_batch_pruned_ids", ids: batchPrunedIds }
|
|
686
|
+
]);
|
|
687
|
+
const dangling = expectRow(
|
|
688
|
+
this.db.prepare(
|
|
689
|
+
`SELECT COUNT(*) AS c FROM edges
|
|
690
|
+
WHERE dst IN (SELECT id FROM _pruned_ids)
|
|
691
|
+
AND src NOT IN (SELECT id FROM _batch_pruned_ids)`
|
|
692
|
+
).get(),
|
|
693
|
+
["c"]
|
|
694
|
+
);
|
|
695
|
+
result.droppedDanglingEdges = dangling?.c ?? 0;
|
|
696
|
+
this.db.exec("DELETE FROM nodes WHERE id IN (SELECT id FROM _pruned_ids)");
|
|
697
|
+
clearPruned.run();
|
|
698
|
+
clearBatch.run();
|
|
699
|
+
const SQLITE_VAR_LIMIT = 32766;
|
|
700
|
+
const ftsRowids = prunedNodeIds.map(ftsRowidForNodeId);
|
|
701
|
+
for (let i = 0; i < ftsRowids.length; i += SQLITE_VAR_LIMIT) {
|
|
702
|
+
const chunk = ftsRowids.slice(i, i + SQLITE_VAR_LIMIT);
|
|
703
|
+
const ph = chunk.map(() => "?").join(", ");
|
|
704
|
+
this.db.prepare(`DELETE FROM nodes_fts WHERE rowid IN (${ph})`).run(...chunk);
|
|
705
|
+
this.db.prepare(`DELETE FROM fts_index WHERE fts_rowid IN (${ph})`).run(...chunk);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* Pass 2: re-insert edges for one file. Runs AFTER every file's
|
|
710
|
+
* nodes are in place (the full batch is committed to nodes) so
|
|
711
|
+
* cross-file edges resolve regardless of input order. Stale edges
|
|
712
|
+
* for nodes owned by this file are deleted first so a changed
|
|
713
|
+
* `confidence` or `provenance` actually overwrites the prior row
|
|
714
|
+
* (chatgpt-codex-connector P1: ON CONFLICT DO NOTHING silently
|
|
715
|
+
* kept stale edges across re-ingests).
|
|
716
|
+
*/
|
|
717
|
+
upsertFileEdges(ir, result) {
|
|
718
|
+
if (ir.edges == null) {
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
const qualifiedNameToId = /* @__PURE__ */ new Map();
|
|
722
|
+
const ownSymbols = expectRows(
|
|
723
|
+
this.db.prepare("SELECT id, qualified_name FROM nodes WHERE file_id = ?").all(result.fileId),
|
|
724
|
+
["id", "qualified_name"]
|
|
725
|
+
);
|
|
726
|
+
const qnameCounts = /* @__PURE__ */ new Map();
|
|
727
|
+
for (const row of ownSymbols) {
|
|
728
|
+
qnameCounts.set(row.qualified_name, (qnameCounts.get(row.qualified_name) ?? 0) + 1);
|
|
729
|
+
}
|
|
730
|
+
for (const row of ownSymbols) {
|
|
731
|
+
if ((qnameCounts.get(row.qualified_name) ?? 0) === 1) {
|
|
732
|
+
qualifiedNameToId.set(row.qualified_name, row.id);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
const assertedKeys = /* @__PURE__ */ new Set();
|
|
736
|
+
const seenKeys = [];
|
|
737
|
+
const keyToEdge = /* @__PURE__ */ new Map();
|
|
738
|
+
for (const edge of ir.edges ?? []) {
|
|
739
|
+
if (!isEdgeProvenance(edge.provenance)) {
|
|
740
|
+
throw new Error(
|
|
741
|
+
`graph-store: edge has invalid provenance ${JSON.stringify(edge.provenance)}`
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
if (!Number.isFinite(edge.confidence) || edge.confidence < 0 || edge.confidence > 1) {
|
|
745
|
+
throw new Error(
|
|
746
|
+
`graph-store: edge confidence ${edge.confidence} is out of range [0, 1] for edge ${edge.srcQualifiedName} \u2192 ${edge.dstQualifiedName}`
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
const srcId = qualifiedNameToId.get(edge.srcQualifiedName);
|
|
750
|
+
if (!srcId) continue;
|
|
751
|
+
const dstId = resolveNodeId(
|
|
752
|
+
edge.dstQualifiedName,
|
|
753
|
+
qualifiedNameToId,
|
|
754
|
+
this.db
|
|
755
|
+
);
|
|
756
|
+
if (!dstId) continue;
|
|
757
|
+
const key = `${srcId}\0${dstId}\0${edge.type}`;
|
|
758
|
+
assertedKeys.add(key);
|
|
759
|
+
seenKeys.push(key);
|
|
760
|
+
if (!keyToEdge.has(key)) {
|
|
761
|
+
keyToEdge.set(key, edge);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
const priorEdges = expectRows(
|
|
765
|
+
this.db.prepare(
|
|
766
|
+
"SELECT src, dst, type, confidence, provenance FROM edges WHERE src IN (SELECT id FROM nodes WHERE file_id = ?)"
|
|
767
|
+
).all(result.fileId),
|
|
768
|
+
["src", "dst", "type", "confidence", "provenance"]
|
|
769
|
+
);
|
|
770
|
+
const priorByKey = /* @__PURE__ */ new Map();
|
|
771
|
+
const staleSrcDstTypes = [];
|
|
772
|
+
for (const p of priorEdges) {
|
|
773
|
+
const key = `${p.src}\0${p.dst}\0${p.type}`;
|
|
774
|
+
priorByKey.set(key, { confidence: p.confidence, provenance: p.provenance });
|
|
775
|
+
if (!assertedKeys.has(key)) {
|
|
776
|
+
staleSrcDstTypes.push({ src: p.src, dst: p.dst, type: p.type });
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
const SQLITE_VARIABLE_LIMIT2 = 32766;
|
|
780
|
+
const PARAMS_PER_TUPLE = 3;
|
|
781
|
+
const MAX_TUPLES_PER_CHUNK = Math.floor(SQLITE_VARIABLE_LIMIT2 / PARAMS_PER_TUPLE);
|
|
782
|
+
for (let i = 0; i < staleSrcDstTypes.length; i += MAX_TUPLES_PER_CHUNK) {
|
|
783
|
+
const chunk = staleSrcDstTypes.slice(i, i + MAX_TUPLES_PER_CHUNK);
|
|
784
|
+
const placeholders = chunk.map(() => "(?, ?, ?)").join(", ");
|
|
785
|
+
this.db.prepare(`DELETE FROM edges WHERE (src, dst, type) IN (${placeholders})`).run(...chunk.flatMap((e) => [e.src, e.dst, e.type]));
|
|
786
|
+
}
|
|
787
|
+
const insertEdge = this.db.prepare(
|
|
788
|
+
`INSERT INTO edges (src, dst, type, confidence, provenance)
|
|
789
|
+
VALUES (?, ?, ?, ?, ?)
|
|
790
|
+
ON CONFLICT(src, dst, type) DO UPDATE SET
|
|
791
|
+
confidence = excluded.confidence,
|
|
792
|
+
provenance = excluded.provenance`
|
|
793
|
+
);
|
|
794
|
+
let edgeCount = 0;
|
|
795
|
+
const processedKeys = /* @__PURE__ */ new Set();
|
|
796
|
+
for (const key of seenKeys) {
|
|
797
|
+
if (processedKeys.has(key)) continue;
|
|
798
|
+
processedKeys.add(key);
|
|
799
|
+
const edge = keyToEdge.get(key);
|
|
800
|
+
if (!edge) continue;
|
|
801
|
+
const parts = key.split("\0");
|
|
802
|
+
const srcId = parts[0];
|
|
803
|
+
const dstId = parts[1];
|
|
804
|
+
const prior = priorByKey.get(key);
|
|
805
|
+
if (prior && prior.confidence === edge.confidence && prior.provenance === edge.provenance) {
|
|
806
|
+
continue;
|
|
807
|
+
}
|
|
808
|
+
const r = insertEdge.run(srcId, dstId, edge.type, edge.confidence, edge.provenance);
|
|
809
|
+
edgeCount += r.changes;
|
|
810
|
+
}
|
|
811
|
+
result.edgeCount = edgeCount;
|
|
812
|
+
}
|
|
813
|
+
/**
|
|
814
|
+
* Pass 3 (PR2): upsert `node_attributes` rows for this file's
|
|
815
|
+
* surviving nodes, derived from the IR's optional `exports` and
|
|
816
|
+
* `routes` arrays. Per-field preservation semantics (mirrors the
|
|
817
|
+
* edges pass, generalized to two independent flags):
|
|
818
|
+
* - `exports == null` (omitted) → preserve existing `is_exported`
|
|
819
|
+
* flags untouched (PR1-era IR has no exports field). The
|
|
820
|
+
* `is_route_handler` flag is rebuilt independently from
|
|
821
|
+
* `routes` — the two columns do NOT interact.
|
|
822
|
+
* - `exports === []` (explicit empty) → wipe the file's
|
|
823
|
+
* `is_exported` flags (the caller is asserting "this file
|
|
824
|
+
* exports nothing").
|
|
825
|
+
* - same rule for `routes` / `is_route_handler`.
|
|
826
|
+
*
|
|
827
|
+
* A symbol is `is_exported=1` when its `name` matches an entry in
|
|
828
|
+
* `ir.exports` (multiple symbols with the same name in one file all
|
|
829
|
+
* get the flag — the dead-code query treats this conservatively,
|
|
830
|
+
* never silently picking one). A symbol is `is_route_handler=1`
|
|
831
|
+
* when its `qualifiedName` equals a route's `handlerQualifiedName`.
|
|
832
|
+
*
|
|
833
|
+
* Implementation: per-flag UPDATE, not a delete-then-insert (the
|
|
834
|
+
* original PR2 implementation wiped both flags whenever either field
|
|
835
|
+
* was present, so a re-ingest with only `exports` silently dropped
|
|
836
|
+
* `is_route_handler` — cursor Bugbot + chatgpt-codex-connector P2).
|
|
837
|
+
* The two flags live in the same row keyed by node_id; INSERT OR
|
|
838
|
+
* IGNORE ensures a row exists, then UPDATE-per-flag changes only
|
|
839
|
+
* the column the IR is asserting.
|
|
840
|
+
*/
|
|
841
|
+
upsertFileAttributes(ir, result) {
|
|
842
|
+
if (ir.exports == null && ir.routes == null) {
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
const ownNodes = expectRows(
|
|
846
|
+
this.db.prepare("SELECT id, name, qualified_name FROM nodes WHERE file_id = ?").all(result.fileId),
|
|
847
|
+
["id", "name", "qualified_name"]
|
|
848
|
+
);
|
|
849
|
+
const ownNodeIds = ownNodes.map((n) => n.id);
|
|
850
|
+
if (ownNodeIds.length === 0) {
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
const ensureRow = this.db.prepare(
|
|
854
|
+
`INSERT OR IGNORE INTO node_attributes (node_id, is_exported, is_route_handler)
|
|
855
|
+
VALUES (?, 0, 0)`
|
|
856
|
+
);
|
|
857
|
+
for (const id of ownNodeIds) ensureRow.run(id);
|
|
858
|
+
if (ir.exports != null) {
|
|
859
|
+
const exportNames = /* @__PURE__ */ new Set();
|
|
860
|
+
for (const ex of ir.exports) {
|
|
861
|
+
if (ex && typeof ex.name === "string" && ex.name.length > 0) {
|
|
862
|
+
exportNames.add(ex.name);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
const newExportedIds = /* @__PURE__ */ new Set();
|
|
866
|
+
for (const n of ownNodes) {
|
|
867
|
+
if (exportNames.has(n.name)) newExportedIds.add(n.id);
|
|
868
|
+
}
|
|
869
|
+
this.runChunkedUpdate(
|
|
870
|
+
`UPDATE node_attributes SET is_exported = 0 WHERE node_id IN (%PH%)`,
|
|
871
|
+
ownNodeIds
|
|
872
|
+
);
|
|
873
|
+
const setExported = this.db.prepare(
|
|
874
|
+
`UPDATE node_attributes SET is_exported = 1 WHERE node_id = ?`
|
|
875
|
+
);
|
|
876
|
+
for (const id of newExportedIds) setExported.run(id);
|
|
877
|
+
}
|
|
878
|
+
if (ir.routes != null) {
|
|
879
|
+
const handlerQNames = /* @__PURE__ */ new Set();
|
|
880
|
+
for (const r of ir.routes) {
|
|
881
|
+
if (r && typeof r.handlerQualifiedName === "string" && r.handlerQualifiedName.length > 0) {
|
|
882
|
+
handlerQNames.add(r.handlerQualifiedName);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
const newRouteIds = /* @__PURE__ */ new Set();
|
|
886
|
+
for (const n of ownNodes) {
|
|
887
|
+
if (handlerQNames.has(n.qualified_name)) newRouteIds.add(n.id);
|
|
888
|
+
}
|
|
889
|
+
this.runChunkedUpdate(
|
|
890
|
+
`UPDATE node_attributes SET is_route_handler = 0 WHERE node_id IN (%PH%)`,
|
|
891
|
+
ownNodeIds
|
|
892
|
+
);
|
|
893
|
+
const setRoute = this.db.prepare(
|
|
894
|
+
`UPDATE node_attributes SET is_route_handler = 1 WHERE node_id = ?`
|
|
895
|
+
);
|
|
896
|
+
for (const id of newRouteIds) setRoute.run(id);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* Chunk a parameterized UPDATE-with-IN-list under SQLite's variable
|
|
901
|
+
* bind limit. The SQL template uses `%PH%` as a placeholder for the
|
|
902
|
+
* `?,?,…` list. Mirrors the chunking pattern PR1 uses for deletes.
|
|
903
|
+
*/
|
|
904
|
+
runChunkedUpdate(sqlTemplate, params) {
|
|
905
|
+
if (params.length === 0) return;
|
|
906
|
+
for (let i = 0; i < params.length; i += SQLITE_VARIABLE_LIMIT) {
|
|
907
|
+
const chunk = params.slice(i, i + SQLITE_VARIABLE_LIMIT);
|
|
908
|
+
const placeholders = chunk.map(() => "?").join(", ");
|
|
909
|
+
this.db.prepare(sqlTemplate.replace("%PH%", placeholders)).run(...chunk);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
913
|
+
// PR2 read primitives (issue #1552 steps 4–5).
|
|
914
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
915
|
+
/**
|
|
916
|
+
* Iterative frontier BFS over the edges table. Cycle-safe via a JS
|
|
917
|
+
* visited set keyed by node id; depth-capped by {@link TraverseQuery.maxDepth}
|
|
918
|
+
* (half-open — depth==maxDepth is INCLUDED, maxDepth+1 is NOT — rule 35).
|
|
919
|
+
* The start node is always included at depth 0 when it exists.
|
|
920
|
+
*
|
|
921
|
+
* Reads the edges table via a single prepared statement per
|
|
922
|
+
* direction; the frontier expands level-by-level so memory is
|
|
923
|
+
* bounded by the visited set's size, not the recursion depth.
|
|
924
|
+
*/
|
|
925
|
+
traverse(query) {
|
|
926
|
+
if (this.closed) return { ok: false, code: "store_closed" };
|
|
927
|
+
if (query == null || typeof query !== "object") {
|
|
928
|
+
return { ok: false, code: "invalid_query" };
|
|
929
|
+
}
|
|
930
|
+
if (typeof query.maxDepth !== "number" || !Number.isInteger(query.maxDepth) || query.maxDepth < 0) {
|
|
931
|
+
return {
|
|
932
|
+
ok: false,
|
|
933
|
+
code: "invalid_query"
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
const direction = query.direction === void 0 ? "outgoing" : query.direction;
|
|
937
|
+
if (direction !== "outgoing" && direction !== "incoming" && direction !== "both") {
|
|
938
|
+
return { ok: false, code: "invalid_query" };
|
|
939
|
+
}
|
|
940
|
+
if (query.edgeTypes !== void 0 && (!Array.isArray(query.edgeTypes) || !query.edgeTypes.every((e) => typeof e === "string"))) {
|
|
941
|
+
return { ok: false, code: "invalid_query" };
|
|
942
|
+
}
|
|
943
|
+
if (typeof query.start !== "string" || query.start.length === 0) {
|
|
944
|
+
return { ok: false, code: "invalid_query" };
|
|
945
|
+
}
|
|
946
|
+
try {
|
|
947
|
+
let startId;
|
|
948
|
+
const isNodeId = /^[0-9a-f]{64}$/.test(query.start);
|
|
949
|
+
const rows = expectRows(
|
|
950
|
+
this.db.prepare(
|
|
951
|
+
isNodeId ? "SELECT id FROM nodes WHERE id = ?" : "SELECT id FROM nodes WHERE qualified_name = ?"
|
|
952
|
+
).all(query.start),
|
|
953
|
+
["id"]
|
|
954
|
+
);
|
|
955
|
+
if (rows.length === 0) {
|
|
956
|
+
return { ok: false, code: "unknown_start" };
|
|
957
|
+
}
|
|
958
|
+
if (rows.length > 1) {
|
|
959
|
+
return { ok: false, code: "ambiguous_start" };
|
|
960
|
+
}
|
|
961
|
+
startId = rows[0].id;
|
|
962
|
+
const edgeTypes = query.edgeTypes ?? [];
|
|
963
|
+
const typeClause = edgeTypes.length > 0 ? `AND type IN (${edgeTypes.map(() => "?").join(", ")})` : "";
|
|
964
|
+
const outgoingStmt = this.db.prepare(
|
|
965
|
+
`SELECT dst AS neighbor, src AS via_id FROM edges WHERE src = ? ${typeClause}`
|
|
966
|
+
);
|
|
967
|
+
const incomingStmt = this.db.prepare(
|
|
968
|
+
`SELECT src AS neighbor, dst AS via_id FROM edges WHERE dst = ? ${typeClause}`
|
|
969
|
+
);
|
|
970
|
+
const visited = /* @__PURE__ */ new Set([startId]);
|
|
971
|
+
const hits = [];
|
|
972
|
+
const startRow = expectRow(
|
|
973
|
+
this.db.prepare(
|
|
974
|
+
"SELECT n.id, n.qualified_name, n.name, n.label, f.path AS file_path FROM nodes n JOIN files f ON n.file_id = f.id WHERE n.id = ?"
|
|
975
|
+
).get(startId),
|
|
976
|
+
["id", "qualified_name", "name", "label", "file_path"]
|
|
977
|
+
);
|
|
978
|
+
if (!startRow) {
|
|
979
|
+
return { ok: false, code: "unknown_start" };
|
|
980
|
+
}
|
|
981
|
+
hits.push({
|
|
982
|
+
nodeId: startRow.id,
|
|
983
|
+
qualifiedName: startRow.qualified_name,
|
|
984
|
+
name: startRow.name,
|
|
985
|
+
label: startRow.label,
|
|
986
|
+
filePath: startRow.file_path,
|
|
987
|
+
depth: 0
|
|
988
|
+
});
|
|
989
|
+
if (query.maxDepth === 0) {
|
|
990
|
+
return { ok: true, hits };
|
|
991
|
+
}
|
|
992
|
+
let frontier = [startId];
|
|
993
|
+
for (let depth = 1; depth <= query.maxDepth; depth += 1) {
|
|
994
|
+
const nextFrontier = [];
|
|
995
|
+
for (const nodeId of frontier) {
|
|
996
|
+
const params = [nodeId, ...edgeTypes];
|
|
997
|
+
const outRows = direction === "outgoing" || direction === "both" ? expectRows(
|
|
998
|
+
outgoingStmt.all(...params),
|
|
999
|
+
["neighbor"]
|
|
1000
|
+
) : [];
|
|
1001
|
+
const inRows = direction === "incoming" || direction === "both" ? expectRows(
|
|
1002
|
+
incomingStmt.all(...params),
|
|
1003
|
+
["neighbor"]
|
|
1004
|
+
) : [];
|
|
1005
|
+
for (const r of [...outRows, ...inRows]) {
|
|
1006
|
+
const neighbor = r.neighbor;
|
|
1007
|
+
if (visited.has(neighbor)) continue;
|
|
1008
|
+
visited.add(neighbor);
|
|
1009
|
+
nextFrontier.push(neighbor);
|
|
1010
|
+
const hitRow = expectRow(
|
|
1011
|
+
this.db.prepare(
|
|
1012
|
+
"SELECT n.id, n.qualified_name, n.name, n.label, f.path AS file_path FROM nodes n JOIN files f ON n.file_id = f.id WHERE n.id = ?"
|
|
1013
|
+
).get(neighbor),
|
|
1014
|
+
["id", "qualified_name", "name", "label", "file_path"]
|
|
1015
|
+
);
|
|
1016
|
+
if (hitRow) {
|
|
1017
|
+
hits.push({
|
|
1018
|
+
nodeId: hitRow.id,
|
|
1019
|
+
qualifiedName: hitRow.qualified_name,
|
|
1020
|
+
name: hitRow.name,
|
|
1021
|
+
label: hitRow.label,
|
|
1022
|
+
filePath: hitRow.file_path,
|
|
1023
|
+
depth
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
if (nextFrontier.length === 0) break;
|
|
1029
|
+
frontier = nextFrontier;
|
|
1030
|
+
}
|
|
1031
|
+
return { ok: true, hits };
|
|
1032
|
+
} catch (error) {
|
|
1033
|
+
logWriteFailure(error);
|
|
1034
|
+
return classifyReadError(error);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
/**
|
|
1038
|
+
* Path-enumerating traversal (issue #1650). Unlike {@link traverse}'s BFS —
|
|
1039
|
+
* which visits each node ONCE at its shortest-path depth and so cannot honor
|
|
1040
|
+
* an exact `*N` (N > 1) hop count for nodes reachable at both a shorter and a
|
|
1041
|
+
* length-N path — this primitive enumerates concrete relationship-simple
|
|
1042
|
+
* paths from the start, yielding one hit per distinct (path, endpoint) pair
|
|
1043
|
+
* up to {@link TraversePathsQuery.maxHops}.
|
|
1044
|
+
*
|
|
1045
|
+
* Cycle safety uses RELATIONSHIP UNIQUENESS (the real Cypher rule): a single
|
|
1046
|
+
* path never traverses the same edge twice, keyed by the edge's canonical
|
|
1047
|
+
* `(src, dst, type)` identity. A node MAY recur in a path via distinct edges
|
|
1048
|
+
* (e.g. A->B->A over two different edges) — that is correct Cypher behavior.
|
|
1049
|
+
* The {@link TraversePathsQuery.maxHops} cap bounds each path's length;
|
|
1050
|
+
* {@link TraversePathsQuery.maxPaths} bounds the total enumerated count so a
|
|
1051
|
+
* dense subgraph cannot blow enumeration up exponentially without notice
|
|
1052
|
+
* (when hit, enumeration stops and the result carries `truncated: true`).
|
|
1053
|
+
*
|
|
1054
|
+
* Every yielded path has length >= 1 (at least one edge). A length-0 "path"
|
|
1055
|
+
* (the trivial start->start) is NOT enumerated; callers that need the start
|
|
1056
|
+
* node for a `*0..N` bound add it themselves.
|
|
1057
|
+
*/
|
|
1058
|
+
traversePaths(query) {
|
|
1059
|
+
if (this.closed) return { ok: false, code: "store_closed" };
|
|
1060
|
+
if (query == null || typeof query !== "object") {
|
|
1061
|
+
return { ok: false, code: "invalid_query" };
|
|
1062
|
+
}
|
|
1063
|
+
if (typeof query.maxHops !== "number" || !Number.isInteger(query.maxHops) || query.maxHops < 0) {
|
|
1064
|
+
return { ok: false, code: "invalid_query" };
|
|
1065
|
+
}
|
|
1066
|
+
if (query.maxHops > MAX_TRAVERSE_PATHS_HOPS) {
|
|
1067
|
+
return { ok: false, code: "invalid_query" };
|
|
1068
|
+
}
|
|
1069
|
+
const direction = query.direction === void 0 ? "outgoing" : query.direction;
|
|
1070
|
+
if (direction !== "outgoing" && direction !== "incoming" && direction !== "both") {
|
|
1071
|
+
return { ok: false, code: "invalid_query" };
|
|
1072
|
+
}
|
|
1073
|
+
if (query.edgeTypes !== void 0 && (!Array.isArray(query.edgeTypes) || !query.edgeTypes.every((e) => typeof e === "string"))) {
|
|
1074
|
+
return { ok: false, code: "invalid_query" };
|
|
1075
|
+
}
|
|
1076
|
+
if (typeof query.start !== "string" || query.start.length === 0) {
|
|
1077
|
+
return { ok: false, code: "invalid_query" };
|
|
1078
|
+
}
|
|
1079
|
+
const minHops = query.minHops === void 0 ? 1 : query.minHops;
|
|
1080
|
+
if (typeof minHops !== "number" || !Number.isInteger(minHops) || minHops < 1) {
|
|
1081
|
+
return { ok: false, code: "invalid_query" };
|
|
1082
|
+
}
|
|
1083
|
+
let maxPaths;
|
|
1084
|
+
if (query.maxPaths === void 0) {
|
|
1085
|
+
maxPaths = DEFAULT_TRAVERSE_PATHS_MAX;
|
|
1086
|
+
} else if (typeof query.maxPaths !== "number" || !Number.isInteger(query.maxPaths) || query.maxPaths < 0) {
|
|
1087
|
+
return { ok: false, code: "invalid_query" };
|
|
1088
|
+
} else {
|
|
1089
|
+
maxPaths = query.maxPaths;
|
|
1090
|
+
}
|
|
1091
|
+
try {
|
|
1092
|
+
const isNodeId = /^[0-9a-f]{64}$/.test(query.start);
|
|
1093
|
+
const rows = expectRows(
|
|
1094
|
+
this.db.prepare(
|
|
1095
|
+
isNodeId ? "SELECT id FROM nodes WHERE id = ?" : "SELECT id FROM nodes WHERE qualified_name = ?"
|
|
1096
|
+
).all(query.start),
|
|
1097
|
+
["id"]
|
|
1098
|
+
);
|
|
1099
|
+
if (rows.length === 0) return { ok: false, code: "unknown_start" };
|
|
1100
|
+
if (rows.length > 1) return { ok: false, code: "ambiguous_start" };
|
|
1101
|
+
const startId = rows[0].id;
|
|
1102
|
+
if (query.maxHops === 0) {
|
|
1103
|
+
return { ok: true, hits: [], truncated: false };
|
|
1104
|
+
}
|
|
1105
|
+
const edgeTypes = query.edgeTypes ?? [];
|
|
1106
|
+
const typeClause = edgeTypes.length > 0 ? `AND type IN (${edgeTypes.map(() => "?").join(", ")})` : "";
|
|
1107
|
+
const outgoingStmt = this.db.prepare(
|
|
1108
|
+
`SELECT dst AS neighbor, src, dst, type FROM edges WHERE src = ? ${typeClause}`
|
|
1109
|
+
);
|
|
1110
|
+
const incomingStmt = this.db.prepare(
|
|
1111
|
+
`SELECT src AS neighbor, src, dst, type FROM edges WHERE dst = ? ${typeClause}`
|
|
1112
|
+
);
|
|
1113
|
+
const nodeStmt = this.db.prepare(
|
|
1114
|
+
"SELECT n.id, n.qualified_name, n.name, n.label, f.path AS file_path FROM nodes n JOIN files f ON n.file_id = f.id WHERE n.id = ?"
|
|
1115
|
+
);
|
|
1116
|
+
const nodeCache = /* @__PURE__ */ new Map();
|
|
1117
|
+
const getNode = (id) => {
|
|
1118
|
+
const cached = nodeCache.get(id);
|
|
1119
|
+
if (cached) return cached;
|
|
1120
|
+
const row = expectRow(nodeStmt.get(id), [
|
|
1121
|
+
"id",
|
|
1122
|
+
"qualified_name",
|
|
1123
|
+
"name",
|
|
1124
|
+
"label",
|
|
1125
|
+
"file_path"
|
|
1126
|
+
]);
|
|
1127
|
+
if (row) nodeCache.set(id, row);
|
|
1128
|
+
return row;
|
|
1129
|
+
};
|
|
1130
|
+
const neighborsOf = (id) => {
|
|
1131
|
+
const params = [id, ...edgeTypes];
|
|
1132
|
+
const out = direction === "outgoing" || direction === "both" ? expectRows(outgoingStmt.all(...params), [
|
|
1133
|
+
"neighbor",
|
|
1134
|
+
"src",
|
|
1135
|
+
"dst",
|
|
1136
|
+
"type"
|
|
1137
|
+
]) : [];
|
|
1138
|
+
const inn = direction === "incoming" || direction === "both" ? expectRows(incomingStmt.all(...params), [
|
|
1139
|
+
"neighbor",
|
|
1140
|
+
"src",
|
|
1141
|
+
"dst",
|
|
1142
|
+
"type"
|
|
1143
|
+
]) : [];
|
|
1144
|
+
const seenEdge = /* @__PURE__ */ new Set();
|
|
1145
|
+
const deduped = [];
|
|
1146
|
+
for (const e of [...out, ...inn]) {
|
|
1147
|
+
const k = e.src + "\0" + e.dst + "\0" + e.type;
|
|
1148
|
+
if (seenEdge.has(k)) continue;
|
|
1149
|
+
seenEdge.add(k);
|
|
1150
|
+
deduped.push(e);
|
|
1151
|
+
}
|
|
1152
|
+
return deduped;
|
|
1153
|
+
};
|
|
1154
|
+
const hits = [];
|
|
1155
|
+
let truncated = false;
|
|
1156
|
+
const usedEdges = /* @__PURE__ */ new Set();
|
|
1157
|
+
const pathNodes = [startId];
|
|
1158
|
+
const pathEdgeTypes = [];
|
|
1159
|
+
const pathEndpoints = [];
|
|
1160
|
+
const dfs = (currentId, length) => {
|
|
1161
|
+
if (truncated) return;
|
|
1162
|
+
if (length >= query.maxHops) return;
|
|
1163
|
+
for (const e of neighborsOf(currentId)) {
|
|
1164
|
+
if (truncated) return;
|
|
1165
|
+
const key = `${e.src}\0${e.dst}\0${e.type}`;
|
|
1166
|
+
if (usedEdges.has(key)) continue;
|
|
1167
|
+
usedEdges.add(key);
|
|
1168
|
+
pathNodes.push(e.neighbor);
|
|
1169
|
+
pathEdgeTypes.push(e.type);
|
|
1170
|
+
pathEndpoints.push({ src: e.src, dst: e.dst });
|
|
1171
|
+
const newLength = length + 1;
|
|
1172
|
+
if (newLength >= minHops) {
|
|
1173
|
+
if (hits.length >= maxPaths) {
|
|
1174
|
+
truncated = true;
|
|
1175
|
+
} else {
|
|
1176
|
+
const info = getNode(e.neighbor);
|
|
1177
|
+
if (info) {
|
|
1178
|
+
hits.push({
|
|
1179
|
+
nodeId: info.id,
|
|
1180
|
+
qualifiedName: info.qualified_name,
|
|
1181
|
+
name: info.name,
|
|
1182
|
+
label: info.label,
|
|
1183
|
+
filePath: info.file_path,
|
|
1184
|
+
length: newLength,
|
|
1185
|
+
nodeIds: pathNodes.slice(),
|
|
1186
|
+
edgeTypes: pathEdgeTypes.slice(),
|
|
1187
|
+
edgeEndpoints: pathEndpoints.slice()
|
|
1188
|
+
});
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
if (!truncated) dfs(e.neighbor, newLength);
|
|
1193
|
+
pathEndpoints.pop();
|
|
1194
|
+
pathEdgeTypes.pop();
|
|
1195
|
+
pathNodes.pop();
|
|
1196
|
+
usedEdges.delete(key);
|
|
1197
|
+
}
|
|
1198
|
+
};
|
|
1199
|
+
dfs(startId, 0);
|
|
1200
|
+
return { ok: true, hits, truncated };
|
|
1201
|
+
} catch (error) {
|
|
1202
|
+
logWriteFailure(error);
|
|
1203
|
+
return classifyReadError(error);
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
/**
|
|
1207
|
+
* Structured node search. All filters are AND-combined; patterns use
|
|
1208
|
+
* SQLite LIKE (case-insensitive via COLLATE NOCASE). Patterns and
|
|
1209
|
+
* limits are parameter-bound, never string-interpolated, so user
|
|
1210
|
+
* input cannot inject SQL.
|
|
1211
|
+
*/
|
|
1212
|
+
searchGraph(query) {
|
|
1213
|
+
if (this.closed) return { ok: false, code: "store_closed" };
|
|
1214
|
+
if (query == null || typeof query !== "object") {
|
|
1215
|
+
return { ok: false, code: "invalid_query" };
|
|
1216
|
+
}
|
|
1217
|
+
if (query.degreeMin !== void 0 && (typeof query.degreeMin !== "number" || !Number.isInteger(query.degreeMin) || query.degreeMin < 0) || query.degreeMax !== void 0 && (typeof query.degreeMax !== "number" || !Number.isInteger(query.degreeMax) || query.degreeMax < 0)) {
|
|
1218
|
+
return { ok: false, code: "invalid_query" };
|
|
1219
|
+
}
|
|
1220
|
+
if (query.degreeMin !== void 0 && query.degreeMax !== void 0 && query.degreeMin > query.degreeMax) {
|
|
1221
|
+
return { ok: false, code: "invalid_query" };
|
|
1222
|
+
}
|
|
1223
|
+
const rawLimit = query.limit ?? 100;
|
|
1224
|
+
if (typeof rawLimit !== "number" || !Number.isInteger(rawLimit) || rawLimit < 0) {
|
|
1225
|
+
return { ok: false, code: "invalid_query" };
|
|
1226
|
+
}
|
|
1227
|
+
const MAX_SEARCH_LIMIT = 1e3;
|
|
1228
|
+
const limit = Math.min(rawLimit, MAX_SEARCH_LIMIT);
|
|
1229
|
+
if (query.label !== void 0 && typeof query.label !== "string" || query.namePattern !== void 0 && typeof query.namePattern !== "string" || query.filePattern !== void 0 && typeof query.filePattern !== "string") {
|
|
1230
|
+
return { ok: false, code: "invalid_query" };
|
|
1231
|
+
}
|
|
1232
|
+
try {
|
|
1233
|
+
const params = [];
|
|
1234
|
+
const where = [];
|
|
1235
|
+
if (query.label !== void 0 && query.label.length > 0) {
|
|
1236
|
+
where.push("n.label = ?");
|
|
1237
|
+
params.push(query.label);
|
|
1238
|
+
}
|
|
1239
|
+
if (query.namePattern !== void 0 && query.namePattern.length > 0) {
|
|
1240
|
+
where.push("n.name LIKE ? COLLATE NOCASE");
|
|
1241
|
+
params.push(query.namePattern);
|
|
1242
|
+
}
|
|
1243
|
+
if (query.filePattern !== void 0 && query.filePattern.length > 0) {
|
|
1244
|
+
where.push("f.path LIKE ? COLLATE NOCASE");
|
|
1245
|
+
params.push(query.filePattern);
|
|
1246
|
+
}
|
|
1247
|
+
if (query.degreeMin !== void 0) {
|
|
1248
|
+
where.push(
|
|
1249
|
+
"(SELECT COUNT(*) FROM edges e WHERE e.src = n.id OR e.dst = n.id) >= ?"
|
|
1250
|
+
);
|
|
1251
|
+
params.push(query.degreeMin);
|
|
1252
|
+
}
|
|
1253
|
+
if (query.degreeMax !== void 0) {
|
|
1254
|
+
where.push(
|
|
1255
|
+
"(SELECT COUNT(*) FROM edges e WHERE e.src = n.id OR e.dst = n.id) <= ?"
|
|
1256
|
+
);
|
|
1257
|
+
params.push(query.degreeMax);
|
|
1258
|
+
}
|
|
1259
|
+
params.push(limit);
|
|
1260
|
+
const sql = `SELECT n.id AS node_id, n.qualified_name, n.name, n.label,
|
|
1261
|
+
f.path AS file_path,
|
|
1262
|
+
(SELECT COUNT(*) FROM edges e WHERE e.src = n.id OR e.dst = n.id) AS degree
|
|
1263
|
+
FROM nodes n
|
|
1264
|
+
JOIN files f ON n.file_id = f.id
|
|
1265
|
+
${where.length > 0 ? "WHERE " + where.join(" AND ") : ""}
|
|
1266
|
+
ORDER BY degree DESC, n.qualified_name ASC
|
|
1267
|
+
LIMIT ?`;
|
|
1268
|
+
const rows = expectRows(this.db.prepare(sql).all(...params), [
|
|
1269
|
+
"node_id",
|
|
1270
|
+
"qualified_name",
|
|
1271
|
+
"name",
|
|
1272
|
+
"label",
|
|
1273
|
+
"file_path",
|
|
1274
|
+
"degree"
|
|
1275
|
+
]);
|
|
1276
|
+
const hits = rows.map((r) => ({
|
|
1277
|
+
nodeId: r.node_id,
|
|
1278
|
+
qualifiedName: r.qualified_name,
|
|
1279
|
+
name: r.name,
|
|
1280
|
+
label: r.label,
|
|
1281
|
+
filePath: r.file_path,
|
|
1282
|
+
degree: r.degree
|
|
1283
|
+
}));
|
|
1284
|
+
return { ok: true, hits };
|
|
1285
|
+
} catch (error) {
|
|
1286
|
+
logWriteFailure(error);
|
|
1287
|
+
return classifyReadError(error);
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
/**
|
|
1291
|
+
* Aggregate counts over the whole graph. Single round-trip: one
|
|
1292
|
+
* scalar per metric, two GROUP BY queries for the by-label /
|
|
1293
|
+
* by-type histograms.
|
|
1294
|
+
*/
|
|
1295
|
+
schemaStats() {
|
|
1296
|
+
if (this.closed) return { ok: false, code: "store_closed" };
|
|
1297
|
+
try {
|
|
1298
|
+
const fileCount = expectRow(
|
|
1299
|
+
this.db.prepare("SELECT COUNT(*) AS c FROM files").get(),
|
|
1300
|
+
["c"]
|
|
1301
|
+
);
|
|
1302
|
+
const nodeCount = expectRow(
|
|
1303
|
+
this.db.prepare("SELECT COUNT(*) AS c FROM nodes").get(),
|
|
1304
|
+
["c"]
|
|
1305
|
+
);
|
|
1306
|
+
const edgeCount = expectRow(
|
|
1307
|
+
this.db.prepare("SELECT COUNT(*) AS c FROM edges").get(),
|
|
1308
|
+
["c"]
|
|
1309
|
+
);
|
|
1310
|
+
const labelRows = expectRows(
|
|
1311
|
+
this.db.prepare(
|
|
1312
|
+
"SELECT label, COUNT(*) AS c FROM nodes GROUP BY label ORDER BY label"
|
|
1313
|
+
).all(),
|
|
1314
|
+
["label", "c"]
|
|
1315
|
+
);
|
|
1316
|
+
const typeRows = expectRows(
|
|
1317
|
+
this.db.prepare(
|
|
1318
|
+
"SELECT type, COUNT(*) AS c FROM edges GROUP BY type ORDER BY type"
|
|
1319
|
+
).all(),
|
|
1320
|
+
["type", "c"]
|
|
1321
|
+
);
|
|
1322
|
+
const nodesByLabel = {};
|
|
1323
|
+
for (const r of labelRows) nodesByLabel[r.label] = r.c;
|
|
1324
|
+
const edgesByType = {};
|
|
1325
|
+
for (const r of typeRows) edgesByType[r.type] = r.c;
|
|
1326
|
+
return {
|
|
1327
|
+
ok: true,
|
|
1328
|
+
stats: {
|
|
1329
|
+
files: fileCount?.c ?? 0,
|
|
1330
|
+
nodes: nodeCount?.c ?? 0,
|
|
1331
|
+
edges: edgeCount?.c ?? 0,
|
|
1332
|
+
nodesByLabel,
|
|
1333
|
+
edgesByType
|
|
1334
|
+
}
|
|
1335
|
+
};
|
|
1336
|
+
} catch (error) {
|
|
1337
|
+
logWriteFailure(error);
|
|
1338
|
+
const failure = classifyReadError(error);
|
|
1339
|
+
return failure;
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
/**
|
|
1343
|
+
* Dead-code candidates: nodes with zero inbound
|
|
1344
|
+
* {@link DEAD_CODE_EXCLUSION.INBOUND_USAGE_EDGE_TYPES} edges, excluding
|
|
1345
|
+
* nodes whose `node_attributes` row marks them exported / route-handler
|
|
1346
|
+
* AND nodes whose file path matches the test / entry-point patterns
|
|
1347
|
+
* in {@link DEAD_CODE_EXCLUSION}.
|
|
1348
|
+
*
|
|
1349
|
+
* The exclusion criteria live in the named constant — not in
|
|
1350
|
+
* ad-hoc WHERE clauses (rule 53 analog). The stored flags come from
|
|
1351
|
+
* the write pipeline's `upsertFileAttributes` pass, which the IR's
|
|
1352
|
+
* `exports` and `routes` arrays feed.
|
|
1353
|
+
*/
|
|
1354
|
+
deadCode() {
|
|
1355
|
+
if (this.closed) return { ok: false, code: "store_closed" };
|
|
1356
|
+
try {
|
|
1357
|
+
const usageTypes = DEAD_CODE_EXCLUSION.INBOUND_USAGE_EDGE_TYPES;
|
|
1358
|
+
const typePlaceholders = usageTypes.map(() => "?").join(", ");
|
|
1359
|
+
const sql = `SELECT n.id AS node_id, n.qualified_name, n.name, n.label,
|
|
1360
|
+
f.path AS file_path
|
|
1361
|
+
FROM nodes n
|
|
1362
|
+
JOIN files f ON n.file_id = f.id
|
|
1363
|
+
LEFT JOIN node_attributes a ON a.node_id = n.id
|
|
1364
|
+
WHERE NOT EXISTS (
|
|
1365
|
+
SELECT 1 FROM edges e
|
|
1366
|
+
WHERE e.dst = n.id
|
|
1367
|
+
AND e.src <> n.id
|
|
1368
|
+
AND e.type IN (${typePlaceholders})
|
|
1369
|
+
)
|
|
1370
|
+
AND COALESCE(a.is_exported, 0) = 0
|
|
1371
|
+
AND COALESCE(a.is_route_handler, 0) = 0
|
|
1372
|
+
ORDER BY n.qualified_name ASC`;
|
|
1373
|
+
const rows = expectRows(this.db.prepare(sql).all(...usageTypes), [
|
|
1374
|
+
"node_id",
|
|
1375
|
+
"qualified_name",
|
|
1376
|
+
"name",
|
|
1377
|
+
"label",
|
|
1378
|
+
"file_path"
|
|
1379
|
+
]);
|
|
1380
|
+
const hits = [];
|
|
1381
|
+
for (const r of rows) {
|
|
1382
|
+
if (isExcludedByPath(r.file_path)) continue;
|
|
1383
|
+
hits.push({
|
|
1384
|
+
nodeId: r.node_id,
|
|
1385
|
+
qualifiedName: r.qualified_name,
|
|
1386
|
+
name: r.name,
|
|
1387
|
+
label: r.label,
|
|
1388
|
+
filePath: r.file_path
|
|
1389
|
+
});
|
|
1390
|
+
}
|
|
1391
|
+
return { ok: true, hits };
|
|
1392
|
+
} catch (error) {
|
|
1393
|
+
logWriteFailure(error);
|
|
1394
|
+
const failure = classifyReadError(error);
|
|
1395
|
+
return failure;
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
/**
|
|
1399
|
+
* Read a symbol's source span from disk. The store NEVER persists
|
|
1400
|
+
* file contents (privacy + DB size — issue #1552 design); this
|
|
1401
|
+
* method resolves `files.path` against {@link GraphStoreOptions.repoRoot}
|
|
1402
|
+
* and slices the half-open `[startByte, endByte)` span from the
|
|
1403
|
+
* on-disk bytes.
|
|
1404
|
+
*/
|
|
1405
|
+
async snippetFor(query) {
|
|
1406
|
+
if (this.closed) return { ok: false, code: "store_closed" };
|
|
1407
|
+
if (query == null || typeof query !== "object") {
|
|
1408
|
+
return { ok: false, code: "invalid_query" };
|
|
1409
|
+
}
|
|
1410
|
+
const hasNodeId = typeof query.nodeId === "string" && query.nodeId.length > 0;
|
|
1411
|
+
if (!hasNodeId && (typeof query.qualifiedName !== "string" || query.qualifiedName.length === 0)) {
|
|
1412
|
+
return { ok: false, code: "invalid_query" };
|
|
1413
|
+
}
|
|
1414
|
+
if (query.contextLines !== void 0 && (typeof query.contextLines !== "number" || !Number.isInteger(query.contextLines) || query.contextLines < 0)) {
|
|
1415
|
+
return { ok: false, code: "invalid_query" };
|
|
1416
|
+
}
|
|
1417
|
+
const root = typeof query.repoRoot === "string" && query.repoRoot.length > 0 ? query.repoRoot : this.repoRoot;
|
|
1418
|
+
if (root === void 0) {
|
|
1419
|
+
return { ok: false, code: "repo_root_unset" };
|
|
1420
|
+
}
|
|
1421
|
+
let rows;
|
|
1422
|
+
try {
|
|
1423
|
+
rows = expectRows(
|
|
1424
|
+
this.db.prepare(
|
|
1425
|
+
`SELECT n.id, n.qualified_name, n.span_start, n.span_end, n.lang,
|
|
1426
|
+
f.path AS file_path
|
|
1427
|
+
FROM nodes n JOIN files f ON n.file_id = f.id
|
|
1428
|
+
WHERE ${hasNodeId ? "n.id = ?" : "n.qualified_name = ?"}`
|
|
1429
|
+
).all(hasNodeId ? query.nodeId : query.qualifiedName),
|
|
1430
|
+
["id", "qualified_name", "file_path", "span_start", "span_end", "lang"]
|
|
1431
|
+
);
|
|
1432
|
+
} catch (error) {
|
|
1433
|
+
logWriteFailure(error);
|
|
1434
|
+
return classifyReadError(error);
|
|
1435
|
+
}
|
|
1436
|
+
if (rows.length === 0) return { ok: false, code: "not_found" };
|
|
1437
|
+
if (rows.length > 1) return { ok: false, code: "ambiguous_name" };
|
|
1438
|
+
const node = rows[0];
|
|
1439
|
+
const absolutePath = path.resolve(root, node.file_path);
|
|
1440
|
+
let bytes;
|
|
1441
|
+
try {
|
|
1442
|
+
bytes = await readFile(absolutePath);
|
|
1443
|
+
} catch (error) {
|
|
1444
|
+
logWriteFailure(error);
|
|
1445
|
+
return { ok: false, code: "read_failed" };
|
|
1446
|
+
}
|
|
1447
|
+
const start = Math.max(0, node.span_start);
|
|
1448
|
+
const end = Math.min(bytes.length, node.span_end);
|
|
1449
|
+
if (start > end) {
|
|
1450
|
+
return {
|
|
1451
|
+
ok: true,
|
|
1452
|
+
qualifiedName: node.qualified_name,
|
|
1453
|
+
filePath: node.file_path,
|
|
1454
|
+
absolutePath,
|
|
1455
|
+
startByte: node.span_start,
|
|
1456
|
+
endByte: node.span_end,
|
|
1457
|
+
text: "",
|
|
1458
|
+
lang: node.lang
|
|
1459
|
+
};
|
|
1460
|
+
}
|
|
1461
|
+
let text = bytes.subarray(start, end).toString("utf8");
|
|
1462
|
+
const ctx = query.contextLines ?? 0;
|
|
1463
|
+
if (ctx > 0) {
|
|
1464
|
+
const MAX_CTX = 200;
|
|
1465
|
+
const contextLines = Math.min(Math.max(0, Math.floor(ctx)), MAX_CTX);
|
|
1466
|
+
if (contextLines > 0) {
|
|
1467
|
+
let lineStart = start;
|
|
1468
|
+
while (lineStart > 0 && bytes[lineStart - 1] !== 10) {
|
|
1469
|
+
lineStart -= 1;
|
|
1470
|
+
}
|
|
1471
|
+
for (let i = 0; i < contextLines && lineStart > 0; i += 1) {
|
|
1472
|
+
lineStart -= 1;
|
|
1473
|
+
while (lineStart > 0 && bytes[lineStart - 1] !== 10) {
|
|
1474
|
+
lineStart -= 1;
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
let lineEnd = end;
|
|
1478
|
+
while (lineEnd < bytes.length && bytes[lineEnd] !== 10) {
|
|
1479
|
+
lineEnd += 1;
|
|
1480
|
+
}
|
|
1481
|
+
if (lineEnd < bytes.length && bytes[lineEnd] === 10) {
|
|
1482
|
+
lineEnd += 1;
|
|
1483
|
+
}
|
|
1484
|
+
for (let i = 0; i < contextLines && lineEnd < bytes.length; i += 1) {
|
|
1485
|
+
while (lineEnd < bytes.length && bytes[lineEnd] !== 10) {
|
|
1486
|
+
lineEnd += 1;
|
|
1487
|
+
}
|
|
1488
|
+
if (lineEnd < bytes.length && bytes[lineEnd] === 10) {
|
|
1489
|
+
lineEnd += 1;
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
text = bytes.subarray(lineStart, lineEnd).toString("utf8");
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
return {
|
|
1496
|
+
ok: true,
|
|
1497
|
+
qualifiedName: node.qualified_name,
|
|
1498
|
+
filePath: node.file_path,
|
|
1499
|
+
absolutePath,
|
|
1500
|
+
startByte: node.span_start,
|
|
1501
|
+
endByte: node.span_end,
|
|
1502
|
+
text,
|
|
1503
|
+
lang: node.lang
|
|
1504
|
+
};
|
|
1505
|
+
}
|
|
1506
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
1507
|
+
// Semantic layer (issue #1556): symbol_vectors table read/write.
|
|
1508
|
+
// The db is private; these methods are the ONLY surface the semantic
|
|
1509
|
+
// indexer/query path uses. Vectors are float32 BLOBs; content_hash is
|
|
1510
|
+
// the canonical-text hash (rule 37 — the cache invalidation key).
|
|
1511
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
1512
|
+
/**
|
|
1513
|
+
* Upsert one symbol vector. Idempotent on (node_id, model_id). The
|
|
1514
|
+
* caller (the semantic indexer) has ALREADY decided to re-embed (the
|
|
1515
|
+
* content_hash differs from the cached row); this method just persists.
|
|
1516
|
+
*/
|
|
1517
|
+
async writeSymbolVector(input) {
|
|
1518
|
+
if (this.closed || this.closing) return false;
|
|
1519
|
+
const buf = Buffer.from(input.vector.buffer, input.vector.byteOffset, input.vector.byteLength);
|
|
1520
|
+
await this.queue.schedule(async () => {
|
|
1521
|
+
this.db.prepare(
|
|
1522
|
+
`INSERT INTO symbol_vectors (node_id, model_id, content_hash, dims, vector)
|
|
1523
|
+
VALUES (?, ?, ?, ?, ?)
|
|
1524
|
+
ON CONFLICT(node_id, model_id) DO UPDATE SET
|
|
1525
|
+
content_hash = excluded.content_hash,
|
|
1526
|
+
dims = excluded.dims,
|
|
1527
|
+
vector = excluded.vector`
|
|
1528
|
+
).run(input.nodeId, input.modelId, input.contentHash, input.dims, buf);
|
|
1529
|
+
});
|
|
1530
|
+
return true;
|
|
1531
|
+
}
|
|
1532
|
+
/**
|
|
1533
|
+
* Read one vector row by (node_id, model_id). Returns null when absent.
|
|
1534
|
+
* Used by the indexer's cache-check path (skip re-embed when content_hash
|
|
1535
|
+
* matches) and by the cache-hit test.
|
|
1536
|
+
*/
|
|
1537
|
+
readSymbolVector(nodeId, modelId) {
|
|
1538
|
+
if (this.closed) return null;
|
|
1539
|
+
const row = expectRow(
|
|
1540
|
+
this.db.prepare(
|
|
1541
|
+
`SELECT content_hash, dims, vector FROM symbol_vectors
|
|
1542
|
+
WHERE node_id = ? AND model_id = ?`
|
|
1543
|
+
).get(nodeId, modelId),
|
|
1544
|
+
["content_hash", "dims", "vector"]
|
|
1545
|
+
);
|
|
1546
|
+
if (!row) return null;
|
|
1547
|
+
return {
|
|
1548
|
+
contentHash: row.content_hash,
|
|
1549
|
+
dims: row.dims,
|
|
1550
|
+
vector: new Float32Array(row.vector.buffer, row.vector.byteOffset, row.vector.byteLength / 4)
|
|
1551
|
+
};
|
|
1552
|
+
}
|
|
1553
|
+
/**
|
|
1554
|
+
* Read every vector row for a given model. Used by brute-force cosine
|
|
1555
|
+
* retrieval (SIMILAR_TO confirmation + semantic_query). Returns node
|
|
1556
|
+
* metadata alongside the vector so callers can hydrate hits without a
|
|
1557
|
+
* second round-trip.
|
|
1558
|
+
*/
|
|
1559
|
+
readAllSymbolVectors(modelId) {
|
|
1560
|
+
if (this.closed) return [];
|
|
1561
|
+
const rows = expectRows(
|
|
1562
|
+
this.db.prepare(
|
|
1563
|
+
`SELECT sv.node_id, sv.dims, sv.vector, sv.content_hash,
|
|
1564
|
+
n.qualified_name, n.label, f.path AS file_path
|
|
1565
|
+
FROM symbol_vectors sv
|
|
1566
|
+
JOIN nodes n ON sv.node_id = n.id
|
|
1567
|
+
JOIN files f ON n.file_id = f.id
|
|
1568
|
+
WHERE sv.model_id = ?`
|
|
1569
|
+
).all(modelId),
|
|
1570
|
+
["node_id", "qualified_name", "label", "file_path", "dims", "vector", "content_hash"]
|
|
1571
|
+
);
|
|
1572
|
+
return rows.map((r) => ({
|
|
1573
|
+
nodeId: r.node_id,
|
|
1574
|
+
qualifiedName: r.qualified_name,
|
|
1575
|
+
filePath: r.file_path,
|
|
1576
|
+
kind: r.label,
|
|
1577
|
+
dims: r.dims,
|
|
1578
|
+
contentHash: r.content_hash,
|
|
1579
|
+
vector: new Float32Array(r.vector.buffer, r.vector.byteOffset, r.vector.byteLength / 4)
|
|
1580
|
+
}));
|
|
1581
|
+
}
|
|
1582
|
+
/**
|
|
1583
|
+
* Delete vector rows for a set of node ids (all models). Used by the
|
|
1584
|
+
* cache-invalidation path when a symbol's canonical text changed AND
|
|
1585
|
+
* it could not be re-embedded (provider gone) — the stale vector must
|
|
1586
|
+
* not survive to pollute cosine retrieval. Cascades via the schema's
|
|
1587
|
+
* ON DELETE CASCADE on nodes(id) when a node is pruned, so this method
|
|
1588
|
+
* is only for the targeted-invalidation path.
|
|
1589
|
+
*/
|
|
1590
|
+
async deleteSymbolVectors(nodeIds) {
|
|
1591
|
+
if (this.closed || this.closing || nodeIds.length === 0) return;
|
|
1592
|
+
await this.queue.schedule(async () => {
|
|
1593
|
+
this.runChunkedDelete(
|
|
1594
|
+
"DELETE FROM symbol_vectors WHERE node_id IN (%PH%)",
|
|
1595
|
+
nodeIds
|
|
1596
|
+
);
|
|
1597
|
+
});
|
|
1598
|
+
}
|
|
1599
|
+
/**
|
|
1600
|
+
* Remove every SIMILAR_TO edge written by the semantic similarity
|
|
1601
|
+
* pipeline (type 'SIMILAR_TO', provenance 'semantic'). The pipeline
|
|
1602
|
+
* recomputes the FULL near-clone edge set on each run, so callers MUST
|
|
1603
|
+
* clear the prior set before upserting the new one — otherwise an edge
|
|
1604
|
+
* between two symbols that stopped being similar survives indefinitely
|
|
1605
|
+
* and graph traversal keeps reporting a stale clone relationship
|
|
1606
|
+
* (chatgpt-codex-connector P2: 'Replace old SIMILAR_TO edges on
|
|
1607
|
+
* recompute'). Scoped to provenance 'semantic' so non-semantic edges
|
|
1608
|
+
* are untouched. Serialized via the write queue so it cannot interleave
|
|
1609
|
+
* a concurrent file-batch edge upsert.
|
|
1610
|
+
*/
|
|
1611
|
+
async clearSemanticSimilarToEdges() {
|
|
1612
|
+
if (this.closed || this.closing) return;
|
|
1613
|
+
await this.queue.schedule(async () => {
|
|
1614
|
+
this.db.prepare("DELETE FROM edges WHERE type = ? AND provenance = ?").run("SIMILAR_TO", "semantic");
|
|
1615
|
+
});
|
|
1616
|
+
}
|
|
1617
|
+
/**
|
|
1618
|
+
* Read every node with its file path + span, for the semantic indexer.
|
|
1619
|
+
* The indexer reads source text from disk (via repoRoot) and builds
|
|
1620
|
+
* canonical text per node. Returns kind + qualified_name + span so the
|
|
1621
|
+
* indexer can reconstruct the SymbolIR-equivalent without a second
|
|
1622
|
+
* join. Ordered by qualified_name for deterministic processing order.
|
|
1623
|
+
*/
|
|
1624
|
+
readNodesForSemantic() {
|
|
1625
|
+
if (this.closed) return [];
|
|
1626
|
+
const rows = expectRows(
|
|
1627
|
+
this.db.prepare(
|
|
1628
|
+
`SELECT n.id, n.qualified_name, n.label, n.span_start, n.span_end, n.lang,
|
|
1629
|
+
f.path AS file_path
|
|
1630
|
+
FROM nodes n JOIN files f ON n.file_id = f.id
|
|
1631
|
+
ORDER BY n.qualified_name ASC`
|
|
1632
|
+
).all(),
|
|
1633
|
+
["id", "qualified_name", "label", "file_path", "span_start", "span_end", "lang"]
|
|
1634
|
+
);
|
|
1635
|
+
return rows.map((r) => ({
|
|
1636
|
+
nodeId: r.id,
|
|
1637
|
+
qualifiedName: r.qualified_name,
|
|
1638
|
+
kind: r.label,
|
|
1639
|
+
filePath: r.file_path,
|
|
1640
|
+
startByte: r.span_start,
|
|
1641
|
+
endByte: r.span_end,
|
|
1642
|
+
lang: r.lang
|
|
1643
|
+
}));
|
|
1644
|
+
}
|
|
1645
|
+
/**
|
|
1646
|
+
* Read the callers and callees of a node by qualified name, for
|
|
1647
|
+
* semantic_query hydration (the issue: hydrate each hit with graph
|
|
1648
|
+
* context — defining file, direct callers/callees).
|
|
1649
|
+
*/
|
|
1650
|
+
readNeighbors(qualifiedName) {
|
|
1651
|
+
if (this.closed) return { callers: [], callees: [] };
|
|
1652
|
+
const nodeRow = expectRow(
|
|
1653
|
+
this.db.prepare("SELECT id FROM nodes WHERE qualified_name = ?").get(qualifiedName),
|
|
1654
|
+
["id"]
|
|
1655
|
+
);
|
|
1656
|
+
if (!nodeRow) return { callers: [], callees: [] };
|
|
1657
|
+
const id = nodeRow.id;
|
|
1658
|
+
const callerRows = expectRows(
|
|
1659
|
+
this.db.prepare(
|
|
1660
|
+
`SELECT n.qualified_name FROM edges e
|
|
1661
|
+
JOIN nodes n ON e.src = n.id
|
|
1662
|
+
WHERE e.dst = ? AND e.type = 'CALLS'`
|
|
1663
|
+
).all(id),
|
|
1664
|
+
["qualified_name"]
|
|
1665
|
+
);
|
|
1666
|
+
const calleeRows = expectRows(
|
|
1667
|
+
this.db.prepare(
|
|
1668
|
+
`SELECT n.qualified_name FROM edges e
|
|
1669
|
+
JOIN nodes n ON e.dst = n.id
|
|
1670
|
+
WHERE e.src = ? AND e.type = 'CALLS'`
|
|
1671
|
+
).all(id),
|
|
1672
|
+
["qualified_name"]
|
|
1673
|
+
);
|
|
1674
|
+
return {
|
|
1675
|
+
callers: callerRows.map((r) => r.qualified_name),
|
|
1676
|
+
callees: calleeRows.map((r) => r.qualified_name)
|
|
1677
|
+
};
|
|
1678
|
+
}
|
|
1679
|
+
/**
|
|
1680
|
+
* Read callers/callees by node id directly (avoids the qualified-name
|
|
1681
|
+
* ambiguity when duplicate names exist across files). Used by
|
|
1682
|
+
* semantic_query hydration (chatgpt-codex-connector: 'Use the hit node
|
|
1683
|
+
* id when hydrating neighbors').
|
|
1684
|
+
*/
|
|
1685
|
+
readNeighborsByNodeId(nodeId) {
|
|
1686
|
+
if (this.closed) return { callers: [], callees: [] };
|
|
1687
|
+
const callerRows = expectRows(
|
|
1688
|
+
this.db.prepare(
|
|
1689
|
+
`SELECT n.qualified_name FROM edges e
|
|
1690
|
+
JOIN nodes n ON e.src = n.id
|
|
1691
|
+
WHERE e.dst = ? AND e.type = 'CALLS'`
|
|
1692
|
+
).all(nodeId),
|
|
1693
|
+
["qualified_name"]
|
|
1694
|
+
);
|
|
1695
|
+
const calleeRows = expectRows(
|
|
1696
|
+
this.db.prepare(
|
|
1697
|
+
`SELECT n.qualified_name FROM edges e
|
|
1698
|
+
JOIN nodes n ON e.dst = n.id
|
|
1699
|
+
WHERE e.src = ? AND e.type = 'CALLS'`
|
|
1700
|
+
).all(nodeId),
|
|
1701
|
+
["qualified_name"]
|
|
1702
|
+
);
|
|
1703
|
+
return {
|
|
1704
|
+
callers: callerRows.map((r) => r.qualified_name),
|
|
1705
|
+
callees: calleeRows.map((r) => r.qualified_name)
|
|
1706
|
+
};
|
|
1707
|
+
}
|
|
1708
|
+
};
|
|
1709
|
+
function nodeIdFor(input) {
|
|
1710
|
+
const fields = [
|
|
1711
|
+
["qualifiedName", input.qualifiedName],
|
|
1712
|
+
["filePath", input.filePath],
|
|
1713
|
+
["label", input.label]
|
|
1714
|
+
].map(([k, v]) => [k, String(v)]).sort(([a], [b]) => a.localeCompare(b));
|
|
1715
|
+
const hash = createHash("sha256");
|
|
1716
|
+
for (const [k, v] of fields) {
|
|
1717
|
+
hash.update(`${k.length}:${k}:`);
|
|
1718
|
+
hash.update(`${v.length}:${v}:`);
|
|
1719
|
+
}
|
|
1720
|
+
return hash.digest("hex");
|
|
1721
|
+
}
|
|
1722
|
+
function resolveNodeId(qualifiedName, inBatch, db) {
|
|
1723
|
+
const local = inBatch.get(qualifiedName);
|
|
1724
|
+
if (local) return local;
|
|
1725
|
+
const rows = expectRows(
|
|
1726
|
+
db.prepare(
|
|
1727
|
+
"SELECT id, file_id FROM nodes WHERE qualified_name = ? ORDER BY file_id, id"
|
|
1728
|
+
).all(qualifiedName),
|
|
1729
|
+
["id", "file_id"]
|
|
1730
|
+
);
|
|
1731
|
+
if (rows.length === 0) return void 0;
|
|
1732
|
+
if (rows.length > 1) {
|
|
1733
|
+
return void 0;
|
|
1734
|
+
}
|
|
1735
|
+
return rows[0]?.id;
|
|
1736
|
+
}
|
|
1737
|
+
function resolveByNodeId(stmt, nodeId, qualifiedName) {
|
|
1738
|
+
const row = expectRow(stmt.get(nodeId), ["qualified_name"]);
|
|
1739
|
+
if (!row) return void 0;
|
|
1740
|
+
if (row.qualified_name !== qualifiedName) return void 0;
|
|
1741
|
+
return nodeId;
|
|
1742
|
+
}
|
|
1743
|
+
function classifyError(error) {
|
|
1744
|
+
const code = hasErrorCode(error) ? error.code : "";
|
|
1745
|
+
if (code === "SQLITE_BUSY" || code === "SQLITE_LOCKED") {
|
|
1746
|
+
return { ok: false, code: "db_locked" };
|
|
1747
|
+
}
|
|
1748
|
+
const msg = error instanceof Error ? error.message : String(error ?? "");
|
|
1749
|
+
if (code === "SQLITE_CORRUPT" || code === "SQLITE_NOTADB" || msg.includes("database disk image is malformed")) {
|
|
1750
|
+
return { ok: false, code: "db_corrupt" };
|
|
1751
|
+
}
|
|
1752
|
+
throw error instanceof Error ? error : new Error(String(error ?? ""));
|
|
1753
|
+
}
|
|
1754
|
+
function classifyReadError(error) {
|
|
1755
|
+
try {
|
|
1756
|
+
return classifyError(error);
|
|
1757
|
+
} catch {
|
|
1758
|
+
return { ok: false, code: "db_error" };
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
function hasErrorCode(value) {
|
|
1762
|
+
if (typeof value !== "object" || value === null) return false;
|
|
1763
|
+
if (!("code" in value)) return false;
|
|
1764
|
+
const codeValue = value["code"];
|
|
1765
|
+
return typeof codeValue === "string";
|
|
1766
|
+
}
|
|
1767
|
+
function logWriteFailure(error) {
|
|
1768
|
+
console.error(
|
|
1769
|
+
"[coding-graph] write failure:",
|
|
1770
|
+
error instanceof Error ? error.message : String(error ?? "")
|
|
1771
|
+
);
|
|
1772
|
+
}
|
|
1773
|
+
function assertCanonicalFilePath(filePath) {
|
|
1774
|
+
if (typeof filePath !== "string" || filePath.length === 0) {
|
|
1775
|
+
throw new Error(
|
|
1776
|
+
`graph-store: file path must be a non-empty string; received ${filePath === null ? "null" : typeof filePath}`
|
|
1777
|
+
);
|
|
1778
|
+
}
|
|
1779
|
+
if (filePath.includes("\\")) {
|
|
1780
|
+
throw new Error(
|
|
1781
|
+
`graph-store: file path '${filePath}' must use forward slashes (backslash rejected \u2014 FileIR contract requires repo-relative POSIX paths)`
|
|
1782
|
+
);
|
|
1783
|
+
}
|
|
1784
|
+
if (filePath.startsWith("/") || /^[A-Za-z]:[\\/]/.test(filePath)) {
|
|
1785
|
+
throw new Error(
|
|
1786
|
+
`graph-store: file path '${filePath}' must be repo-relative (absolute path rejected \u2014 FileIR contract requires repo-relative forward-slash paths)`
|
|
1787
|
+
);
|
|
1788
|
+
}
|
|
1789
|
+
if (filePath.split("/").some((segment) => segment === "." || segment === "..")) {
|
|
1790
|
+
throw new Error(
|
|
1791
|
+
`graph-store: file path '${filePath}' must be canonical (no '.' or '..' segments \u2014 FileIR contract requires repo-relative forward-slash paths)`
|
|
1792
|
+
);
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
function assertValidSymbolSpan(sym, filePath) {
|
|
1796
|
+
if (typeof sym !== "object" || sym === null) {
|
|
1797
|
+
throw new Error(
|
|
1798
|
+
`graph-store: file '${filePath}' has a non-object symbol; received ${sym === null ? "null" : typeof sym}`
|
|
1799
|
+
);
|
|
1800
|
+
}
|
|
1801
|
+
if (!("span" in sym)) {
|
|
1802
|
+
throw new Error(
|
|
1803
|
+
`graph-store: file '${filePath}' has a symbol with no span (FileIR contract requires startByte/endByte)`
|
|
1804
|
+
);
|
|
1805
|
+
}
|
|
1806
|
+
const span = sym.span;
|
|
1807
|
+
if (typeof span !== "object" || span === null || !("startByte" in span) || !("endByte" in span)) {
|
|
1808
|
+
throw new Error(
|
|
1809
|
+
`graph-store: file '${filePath}' has a symbol with a malformed span \u2014 expected { startByte, endByte }; received ${JSON.stringify(span)}`
|
|
1810
|
+
);
|
|
1811
|
+
}
|
|
1812
|
+
const startByte = span.startByte;
|
|
1813
|
+
const endByte = span.endByte;
|
|
1814
|
+
if (typeof startByte !== "number" || typeof endByte !== "number" || !Number.isInteger(startByte) || !Number.isInteger(endByte)) {
|
|
1815
|
+
throw new Error(
|
|
1816
|
+
`graph-store: file '${filePath}' has a symbol with a non-integer span [${JSON.stringify(startByte)}, ${JSON.stringify(endByte)}) \u2014 startByte and endByte must be finite integers`
|
|
1817
|
+
);
|
|
1818
|
+
}
|
|
1819
|
+
if (startByte < 0 || endByte < 0) {
|
|
1820
|
+
throw new Error(
|
|
1821
|
+
`graph-store: file '${filePath}' has a symbol with a negative span [${startByte}, ${endByte}) \u2014 byte offsets must be non-negative`
|
|
1822
|
+
);
|
|
1823
|
+
}
|
|
1824
|
+
if (startByte > endByte) {
|
|
1825
|
+
throw new Error(
|
|
1826
|
+
`graph-store: file '${filePath}' has a symbol with startByte > endByte [${startByte}, ${endByte}) \u2014 half-open spans require startByte <= endByte`
|
|
1827
|
+
);
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
export {
|
|
1832
|
+
DEFAULT_TRAVERSE_PATHS_MAX,
|
|
1833
|
+
MAX_TRAVERSE_PATHS_HOPS,
|
|
1834
|
+
DEAD_CODE_EXCLUSION,
|
|
1835
|
+
GraphStore,
|
|
1836
|
+
nodeIdFor
|
|
1837
|
+
};
|
|
1838
|
+
//# sourceMappingURL=chunk-CPYJACC5.js.map
|