@remnic/coding-graph 9.6.24 → 9.6.25
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 +16 -19
- package/dist/{chunk-ZLCF3XQK.js → chunk-ABDBWCXU.js} +75 -1
- package/dist/chunk-ABDBWCXU.js.map +1 -0
- package/dist/{chunk-7DI5P62Q.js → chunk-I4R6GAAA.js} +2 -2
- package/dist/cypher/query-parser.js +2 -2
- package/dist/graph-store.d.ts +20 -0
- package/dist/graph-store.js +1 -1
- package/dist/index.d.ts +12 -0
- package/dist/index.js +18 -6
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/graph-store.ts +99 -0
- package/src/lsp/resolution.ts +52 -11
- package/src/lsp-reconciliation.test.ts +145 -0
- package/dist/chunk-ZLCF3XQK.js.map +0 -1
- /package/dist/{chunk-7DI5P62Q.js.map → chunk-I4R6GAAA.js.map} +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remnic/coding-graph",
|
|
3
|
-
"version": "9.6.
|
|
3
|
+
"version": "9.6.25",
|
|
4
4
|
"description": "Web-tree-sitter powered symbol-extraction engine + SQLite knowledge-graph store for codebase memory (Tier 1: TypeScript, TSX, JavaScript, Python, Go, Rust, Java, C, C++, C#, Ruby, PHP, Kotlin, Swift, Bash). Optional companion of @remnic/core — install only when coding-graph features are needed.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -41,14 +41,14 @@
|
|
|
41
41
|
"web-tree-sitter": "^0.25.0"
|
|
42
42
|
},
|
|
43
43
|
"peerDependencies": {
|
|
44
|
-
"@remnic/core": "^9.6.
|
|
44
|
+
"@remnic/core": "^9.6.25"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
47
|
"@types/node": "^22.10.0",
|
|
48
48
|
"tsup": "^8.5.1",
|
|
49
49
|
"tsx": "^4.19.0",
|
|
50
50
|
"typescript": "^5.7.0",
|
|
51
|
-
"@remnic/core": "9.6.
|
|
51
|
+
"@remnic/core": "9.6.25"
|
|
52
52
|
},
|
|
53
53
|
"license": "MIT",
|
|
54
54
|
"repository": {
|
package/src/graph-store.ts
CHANGED
|
@@ -925,6 +925,105 @@ export class GraphStore {
|
|
|
925
925
|
return this.queue.schedule(() => this.runUpsertEdges(edges));
|
|
926
926
|
}
|
|
927
927
|
|
|
928
|
+
/**
|
|
929
|
+
* Retire stale LSP-provenance edges for a file (issue #1895).
|
|
930
|
+
*
|
|
931
|
+
* The LSP resolution pass re-derives edges from the CURRENT source on each
|
|
932
|
+
* run. After writing the new `lsp` edges for a file, this method deletes
|
|
933
|
+
* prior `lsp`-provenance edges owned by that file's nodes whose
|
|
934
|
+
* `(src, dst, type)` key is NOT in the asserted set. This is the LSP
|
|
935
|
+
* layer's side of the provenance-lifecycle contract: each layer owns its
|
|
936
|
+
* own stale-edge retirement (#1894 established that reindex's heuristic
|
|
937
|
+
* scope never touches `lsp` rows).
|
|
938
|
+
*
|
|
939
|
+
* Heuristic, trace, and semantic edges are never touched.
|
|
940
|
+
*
|
|
941
|
+
* @returns the number of retired edges.
|
|
942
|
+
*/
|
|
943
|
+
reconcileLspEdges(
|
|
944
|
+
filePath: string,
|
|
945
|
+
assertedEdges: ReadonlyArray<{
|
|
946
|
+
srcQualifiedName: string;
|
|
947
|
+
dstQualifiedName: string;
|
|
948
|
+
type: string;
|
|
949
|
+
}>,
|
|
950
|
+
): number {
|
|
951
|
+
if (this.closed) return 0;
|
|
952
|
+
try {
|
|
953
|
+
// Resolve the file and its nodes.
|
|
954
|
+
const fileRow = expectRow<{ id: number }>(
|
|
955
|
+
this.db.prepare("SELECT id FROM files WHERE path = ?").get(filePath),
|
|
956
|
+
["id"],
|
|
957
|
+
);
|
|
958
|
+
if (!fileRow) return 0;
|
|
959
|
+
const nodes = expectRows<{ id: string; qualified_name: string }>(
|
|
960
|
+
this.db
|
|
961
|
+
.prepare("SELECT id, qualified_name FROM nodes WHERE file_id = ?")
|
|
962
|
+
.all(fileRow.id),
|
|
963
|
+
["id", "qualified_name"],
|
|
964
|
+
);
|
|
965
|
+
if (nodes.length === 0) return 0;
|
|
966
|
+
|
|
967
|
+
// Build srcQualifiedName → nodeId for this file's nodes. Only
|
|
968
|
+
// include names that appear exactly once (same conservative
|
|
969
|
+
// ambiguity policy as upsertFileEdges — cursor review on #1914).
|
|
970
|
+
const nameCount = new Map<string, number>();
|
|
971
|
+
for (const n of nodes) nameCount.set(n.qualified_name, (nameCount.get(n.qualified_name) ?? 0) + 1);
|
|
972
|
+
const srcMap = new Map<string, string>();
|
|
973
|
+
for (const n of nodes) {
|
|
974
|
+
if (nameCount.get(n.qualified_name) === 1) srcMap.set(n.qualified_name, n.id);
|
|
975
|
+
}
|
|
976
|
+
const nodeIds = nodes.map((n) => n.id);
|
|
977
|
+
|
|
978
|
+
// Build the asserted key set (resolved to node IDs).
|
|
979
|
+
const assertedKeys = new Set<string>();
|
|
980
|
+
for (const e of assertedEdges) {
|
|
981
|
+
const srcId = srcMap.get(e.srcQualifiedName);
|
|
982
|
+
if (!srcId) continue;
|
|
983
|
+
const dstId = resolveNodeId(e.dstQualifiedName, new Map(), this.db);
|
|
984
|
+
if (!dstId) continue;
|
|
985
|
+
assertedKeys.add(`${srcId}\u0000${dstId}\u0000${e.type}`);
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// Find prior lsp edges owned by this file's nodes.
|
|
989
|
+
const placeholders = nodeIds.map(() => "?").join(", ");
|
|
990
|
+
const priorEdges = expectRows<{ src: string; dst: string; type: string }>(
|
|
991
|
+
this.db
|
|
992
|
+
.prepare(
|
|
993
|
+
`SELECT src, dst, type FROM edges
|
|
994
|
+
WHERE src IN (${placeholders}) AND provenance = 'lsp'`,
|
|
995
|
+
)
|
|
996
|
+
.all(...nodeIds),
|
|
997
|
+
["src", "dst", "type"],
|
|
998
|
+
);
|
|
999
|
+
|
|
1000
|
+
// Delete those NOT in the asserted set.
|
|
1001
|
+
let deleted = 0;
|
|
1002
|
+
const toDelete: Array<[string, string, string]> = [];
|
|
1003
|
+
for (const e of priorEdges) {
|
|
1004
|
+
const key = `${e.src}\u0000${e.dst}\u0000${e.type}`;
|
|
1005
|
+
if (!assertedKeys.has(key)) toDelete.push([e.src, e.dst, e.type]);
|
|
1006
|
+
}
|
|
1007
|
+
if (toDelete.length > 0) {
|
|
1008
|
+
const SQLITE_VARIABLE_LIMIT = 32_766;
|
|
1009
|
+
const PARAMS_PER_TUPLE = 3;
|
|
1010
|
+
const MAX_TUPLES = Math.floor(SQLITE_VARIABLE_LIMIT / PARAMS_PER_TUPLE);
|
|
1011
|
+
for (let i = 0; i < toDelete.length; i += MAX_TUPLES) {
|
|
1012
|
+
const chunk = toDelete.slice(i, i + MAX_TUPLES);
|
|
1013
|
+
const ph = chunk.map(() => "(?, ?, ?)").join(", ");
|
|
1014
|
+
const r = this.db
|
|
1015
|
+
.prepare(`DELETE FROM edges WHERE (src, dst, type) IN (${ph})`)
|
|
1016
|
+
.run(...chunk.flat());
|
|
1017
|
+
deleted += r.changes;
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
return deleted;
|
|
1021
|
+
} catch (error) {
|
|
1022
|
+
logWriteFailure(error);
|
|
1023
|
+
return 0;
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
928
1027
|
/** Wait for pending writes to drain — test seam. */
|
|
929
1028
|
async drain(): Promise<void> {
|
|
930
1029
|
await this.queue.drain();
|
package/src/lsp/resolution.ts
CHANGED
|
@@ -218,6 +218,21 @@ export interface ResolveOptions {
|
|
|
218
218
|
readonly applyUpgrades: (
|
|
219
219
|
upgrades: readonly EdgeUpgrade[],
|
|
220
220
|
) => Promise<void>;
|
|
221
|
+
/**
|
|
222
|
+
* Optional stale-edge reconciliation (issue #1895): after applying a
|
|
223
|
+
* file batch's upgrades, the caller retires prior `lsp`-provenance
|
|
224
|
+
* edges owned by that file whose `(src, dst, type)` keys the current
|
|
225
|
+
* batch does NOT assert. When absent, stale lsp edges persist until
|
|
226
|
+
* node pruning — the soft-fail path documented in #1894.
|
|
227
|
+
*/
|
|
228
|
+
readonly reconcileLspEdges?: (
|
|
229
|
+
filePath: string,
|
|
230
|
+
assertedEdges: ReadonlyArray<{
|
|
231
|
+
srcQualifiedName: string;
|
|
232
|
+
dstQualifiedName: string;
|
|
233
|
+
type: string;
|
|
234
|
+
}>,
|
|
235
|
+
) => void;
|
|
221
236
|
/**
|
|
222
237
|
* Workspace root for resolving repo-relative file paths to absolute LSP
|
|
223
238
|
* URIs and normalizing returned URIs back to repo-relative paths.
|
|
@@ -286,6 +301,13 @@ export async function executeLspResolution(
|
|
|
286
301
|
// Send all definition requests for this file, collecting upgrades.
|
|
287
302
|
const upgrades: EdgeUpgrade[] = [];
|
|
288
303
|
let batchFailed = false;
|
|
304
|
+
// Track whether EVERY request in this file batch was processed to a
|
|
305
|
+
// definitive result (resolved or definitively-not-found). A timeout,
|
|
306
|
+
// request_error, or server-crash mid-batch makes the batch
|
|
307
|
+
// non-exhaustive: reconciliation must NOT run because the asserted
|
|
308
|
+
// set would be incomplete, deleting valid lsp edges for call sites
|
|
309
|
+
// that were never queried (cursor High + codex P1 on #1914).
|
|
310
|
+
let batchExhaustive = true;
|
|
289
311
|
|
|
290
312
|
// Open the document with full text before querying definitions (LSP 3.17).
|
|
291
313
|
// The server needs the content to answer definition requests accurately.
|
|
@@ -328,6 +350,10 @@ export async function executeLspResolution(
|
|
|
328
350
|
break;
|
|
329
351
|
}
|
|
330
352
|
// request_timeout / request_error — count as unresolved, continue.
|
|
353
|
+
// request_timeout / request_error — count as unresolved.
|
|
354
|
+
// The batch is non-exhaustive: this call site's LSP result is
|
|
355
|
+
// indeterminate, so reconciliation for this file is suppressed.
|
|
356
|
+
batchExhaustive = false;
|
|
331
357
|
unresolved++;
|
|
332
358
|
continue;
|
|
333
359
|
}
|
|
@@ -362,21 +388,36 @@ export async function executeLspResolution(
|
|
|
362
388
|
break;
|
|
363
389
|
}
|
|
364
390
|
|
|
365
|
-
// Apply upgrades transactionally per file batch
|
|
366
|
-
//
|
|
391
|
+
// Apply upgrades transactionally per file batch, then reconcile stale
|
|
392
|
+
// lsp edges for this file (issue #1895). If the apply throws, zero
|
|
393
|
+
// upgrades from this batch persist (rule 25 — the applyUpgrades
|
|
367
394
|
// callback MUST be transactional).
|
|
368
|
-
|
|
369
|
-
|
|
395
|
+
try {
|
|
396
|
+
if (upgrades.length > 0) {
|
|
370
397
|
await applyUpgrades(upgrades);
|
|
371
398
|
upgraded += upgrades.length;
|
|
372
|
-
} catch {
|
|
373
|
-
// The apply failed — degrade but don't crash. Upgrades from this
|
|
374
|
-
// batch are lost (the callback's transaction rolled back). Edges
|
|
375
|
-
// from already-applied batches survive (they were in separate
|
|
376
|
-
// transactions — this is the documented per-batch isolation).
|
|
377
|
-
// Count the lost upgrades as unresolved for reporting.
|
|
378
|
-
unresolved += upgrades.length;
|
|
379
399
|
}
|
|
400
|
+
// Reconcile ONLY when the batch was exhaustive (every call site
|
|
401
|
+
// processed to a definitive result). A partial batch's asserted
|
|
402
|
+
// set would be incomplete and retire valid edges for unprocessed
|
|
403
|
+
// call sites (cursor High + codex P1 on #1914).
|
|
404
|
+
if (batchExhaustive && options.reconcileLspEdges) {
|
|
405
|
+
options.reconcileLspEdges(
|
|
406
|
+
filePath,
|
|
407
|
+
upgrades.map((u) => ({
|
|
408
|
+
srcQualifiedName: u.srcQualifiedName,
|
|
409
|
+
dstQualifiedName: u.dstQualifiedName,
|
|
410
|
+
type: u.type,
|
|
411
|
+
})),
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
} catch {
|
|
415
|
+
// The apply failed — degrade but don't crash. Upgrades from this
|
|
416
|
+
// batch are lost (the callback's transaction rolled back). Edges
|
|
417
|
+
// from already-applied batches survive (they were in separate
|
|
418
|
+
// transactions — this is the documented per-batch isolation).
|
|
419
|
+
// Count the lost upgrades as unresolved for reporting.
|
|
420
|
+
unresolved += upgrades.length;
|
|
380
421
|
}
|
|
381
422
|
}
|
|
382
423
|
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LSP edge reconciliation tests (issue #1895).
|
|
3
|
+
*
|
|
4
|
+
* When the LSP resolution pass re-derives edges from the CURRENT source,
|
|
5
|
+
* it must retire prior lsp-provenance edges whose (src, dst, type) key it
|
|
6
|
+
* no longer derives. The store's reconcileLspEdges method does this;
|
|
7
|
+
* the LSP executor wires it after each file batch's upgrades are applied.
|
|
8
|
+
*/
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import test from "node:test";
|
|
14
|
+
|
|
15
|
+
import { GraphStore, type FileIR } from "./graph-store.js";
|
|
16
|
+
|
|
17
|
+
const span = (startByte: number, endByte: number) => ({ startByte, endByte });
|
|
18
|
+
|
|
19
|
+
function fileIR(overrides: Partial<FileIR> & { path: string }): FileIR {
|
|
20
|
+
return {
|
|
21
|
+
language: "typescript",
|
|
22
|
+
contentHash: `h-${overrides.path}`,
|
|
23
|
+
symbols: [],
|
|
24
|
+
imports: [],
|
|
25
|
+
exports: [],
|
|
26
|
+
callSites: [],
|
|
27
|
+
routes: [],
|
|
28
|
+
...overrides,
|
|
29
|
+
} as FileIR;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function openTempStore(): Promise<{ store: GraphStore; dir: string }> {
|
|
33
|
+
const dir = await mkdtemp(path.join(tmpdir(), "cg-lsp-recon-"));
|
|
34
|
+
const store = await GraphStore.open({ dbPath: path.join(dir, "graph.sqlite") });
|
|
35
|
+
return { store, dir };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const HEUR = { confidence: 0.9, provenance: "heuristic" as const };
|
|
39
|
+
const LSP = { confidence: 1, provenance: "lsp" as const };
|
|
40
|
+
|
|
41
|
+
test("reconcile retires lsp edges no longer derived; keeps re-derived ones", async () => {
|
|
42
|
+
const { store, dir } = await openTempStore();
|
|
43
|
+
try {
|
|
44
|
+
// Seed: greet calls format (heuristic) + helper (lsp-upgraded).
|
|
45
|
+
await store.upsertFileBatch([
|
|
46
|
+
{
|
|
47
|
+
...fileIR({
|
|
48
|
+
path: "main.ts",
|
|
49
|
+
symbols: [
|
|
50
|
+
{ kind: "function", name: "greet", qualifiedName: "greet", span: span(0, 70) },
|
|
51
|
+
{ kind: "function", name: "format", qualifiedName: "format", span: span(71, 132) },
|
|
52
|
+
{ kind: "function", name: "helper", qualifiedName: "helper", span: span(133, 190) },
|
|
53
|
+
],
|
|
54
|
+
}),
|
|
55
|
+
edges: [
|
|
56
|
+
{ srcQualifiedName: "greet", dstQualifiedName: "format", type: "CALLS", ...HEUR },
|
|
57
|
+
{ srcQualifiedName: "greet", dstQualifiedName: "helper", type: "CALLS", ...HEUR },
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
]);
|
|
61
|
+
// LSP upgraded both.
|
|
62
|
+
await store.upsertEdges([
|
|
63
|
+
{ srcQualifiedName: "greet", dstQualifiedName: "format", type: "CALLS", ...LSP },
|
|
64
|
+
{ srcQualifiedName: "greet", dstQualifiedName: "helper", type: "CALLS", ...LSP },
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
let stats = await store.schemaStats();
|
|
68
|
+
assert.ok(stats.ok);
|
|
69
|
+
assert.equal(stats.stats.edges, 2, "seed: 2 lsp-upgraded CALLS edges");
|
|
70
|
+
|
|
71
|
+
// LSP re-run derives ONLY greet->format (helper call was removed).
|
|
72
|
+
// Reconcile: retire lsp edges for main.ts NOT in the new set.
|
|
73
|
+
const deleted = store.reconcileLspEdges("main.ts", [
|
|
74
|
+
{ srcQualifiedName: "greet", dstQualifiedName: "format", type: "CALLS" },
|
|
75
|
+
]);
|
|
76
|
+
assert.equal(deleted, 1, "retired the stale greet->helper lsp edge");
|
|
77
|
+
|
|
78
|
+
stats = await store.schemaStats();
|
|
79
|
+
assert.ok(stats.ok);
|
|
80
|
+
assert.equal(stats.stats.edges, 1, "only the re-derived edge remains");
|
|
81
|
+
assert.deepEqual(stats.stats.edgesByType, { CALLS: 1 });
|
|
82
|
+
} finally {
|
|
83
|
+
await store.close();
|
|
84
|
+
await rm(dir, { recursive: true, force: true });
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("reconcile preserves heuristic edges and trace edges untouched", async () => {
|
|
89
|
+
const { store, dir } = await openTempStore();
|
|
90
|
+
try {
|
|
91
|
+
await store.upsertFileBatch([
|
|
92
|
+
{
|
|
93
|
+
...fileIR({
|
|
94
|
+
path: "main.ts",
|
|
95
|
+
symbols: [
|
|
96
|
+
{ kind: "function", name: "greet", qualifiedName: "greet", span: span(0, 70) },
|
|
97
|
+
{ kind: "function", name: "format", qualifiedName: "format", span: span(71, 132) },
|
|
98
|
+
],
|
|
99
|
+
}),
|
|
100
|
+
edges: [
|
|
101
|
+
{ srcQualifiedName: "greet", dstQualifiedName: "format", type: "CALLS", ...HEUR },
|
|
102
|
+
],
|
|
103
|
+
},
|
|
104
|
+
]);
|
|
105
|
+
await store.upsertEdges([
|
|
106
|
+
{ srcQualifiedName: "greet", dstQualifiedName: "format", type: "CALLS", ...LSP },
|
|
107
|
+
{ srcQualifiedName: "greet", dstQualifiedName: "format", type: "HTTP_CALLS", confidence: 1, provenance: "trace" },
|
|
108
|
+
]);
|
|
109
|
+
|
|
110
|
+
// LSP re-run found nothing for main.ts → reconcile with empty asserted set.
|
|
111
|
+
const deleted = store.reconcileLspEdges("main.ts", []);
|
|
112
|
+
assert.equal(deleted, 1, "retired the lsp CALLS edge");
|
|
113
|
+
|
|
114
|
+
const stats = await store.schemaStats();
|
|
115
|
+
assert.ok(stats.ok);
|
|
116
|
+
assert.deepEqual(stats.stats.edgesByType, { HTTP_CALLS: 1 }, "trace edge untouched");
|
|
117
|
+
} finally {
|
|
118
|
+
await store.close();
|
|
119
|
+
await rm(dir, { recursive: true, force: true });
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("reconcile with no prior lsp edges is a no-op", async () => {
|
|
124
|
+
const { store, dir } = await openTempStore();
|
|
125
|
+
try {
|
|
126
|
+
await store.upsertFileBatch([
|
|
127
|
+
{
|
|
128
|
+
...fileIR({
|
|
129
|
+
path: "main.ts",
|
|
130
|
+
symbols: [
|
|
131
|
+
{ kind: "function", name: "greet", qualifiedName: "greet", span: span(0, 70) },
|
|
132
|
+
],
|
|
133
|
+
}),
|
|
134
|
+
edges: [],
|
|
135
|
+
},
|
|
136
|
+
]);
|
|
137
|
+
const deleted = store.reconcileLspEdges("main.ts", [
|
|
138
|
+
{ srcQualifiedName: "greet", dstQualifiedName: "missing", type: "CALLS" },
|
|
139
|
+
]);
|
|
140
|
+
assert.equal(deleted, 0);
|
|
141
|
+
} finally {
|
|
142
|
+
await store.close();
|
|
143
|
+
await rm(dir, { recursive: true, force: true });
|
|
144
|
+
}
|
|
145
|
+
});
|