@remnic/coding-graph 9.6.37 → 9.6.39

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remnic/coding-graph",
3
- "version": "9.6.37",
3
+ "version": "9.6.39",
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.37"
44
+ "@remnic/core": "^9.6.39"
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.37"
51
+ "@remnic/core": "9.6.39"
52
52
  },
53
53
  "license": "MIT",
54
54
  "repository": {
@@ -2909,6 +2909,128 @@ export class GraphStore {
2909
2909
  }
2910
2910
  }
2911
2911
 
2912
+ /**
2913
+ * Find the innermost node whose span contains a byte offset in a file
2914
+ * (issue #1917). Used by the LSP resolution pass's NodeLocator to map
2915
+ * definition locations back to indexed nodes. Returns the node's
2916
+ * qualified name, or null when no node contains the offset.
2917
+ */
2918
+ findNodeBySpan(filePath: string, byteOffset: number): string | null {
2919
+ if (this.closed) return null;
2920
+ try {
2921
+ // Fetch the two smallest containing spans: nested containment is
2922
+ // normal (a method inside a class both contain the offset) and the
2923
+ // INNERMOST (smallest) wins — but a TIE at the smallest size means
2924
+ // the offset cannot be attributed to exactly one node, and the
2925
+ // NodeLocator contract requires null over an arbitrary pick
2926
+ // (review thread: a wrong winner persists incorrect CALLS edges).
2927
+ const rows = expectRows<{ qualified_name: string; size: number }>(
2928
+ this.db
2929
+ .prepare(
2930
+ `SELECT n.qualified_name, (n.span_end - n.span_start) AS size FROM nodes n
2931
+ JOIN files f ON n.file_id = f.id
2932
+ WHERE f.path = ? AND n.span_start <= ? AND n.span_end > ?
2933
+ ORDER BY size ASC
2934
+ LIMIT 2`,
2935
+ )
2936
+ .all(filePath, byteOffset, byteOffset),
2937
+ ["qualified_name", "size"],
2938
+ );
2939
+ if (rows.length === 0) return null;
2940
+ if (rows.length > 1 && rows[1] !== undefined && rows[0]!.size === rows[1].size) return null;
2941
+ const qualifiedName = rows[0]!.qualified_name;
2942
+ // The span lookup disambiguated by file, but the returned QUALIFIED
2943
+ // NAME is the edge key downstream (upsertEdges resolves names, not
2944
+ // ids). If another file defines the same qualified name (common for
2945
+ // top-level `main`/`handler`/`init`), an edge written against the
2946
+ // name could bind to the wrong node — return null and skip the
2947
+ // upgrade instead (conservative; id-carrying upgrades are the
2948
+ // follow-up, review thread on #1923).
2949
+ const dupRow = expectRow<{ n: number }>(
2950
+ this.db
2951
+ .prepare(`SELECT COUNT(*) AS n FROM nodes WHERE qualified_name = ?`)
2952
+ .get(qualifiedName),
2953
+ ["n"],
2954
+ );
2955
+ if (dupRow && dupRow.n > 1) return null;
2956
+ return qualifiedName;
2957
+ } catch {
2958
+ return null;
2959
+ }
2960
+ }
2961
+
2962
+ /**
2963
+ * Callee NAMES already linked from a caller symbol via CALLS edges of a
2964
+ * given provenance (issue #1917 wiring). The LSP pass uses this to skip
2965
+ * call sites Phase A (provenance "heuristic") already resolved WITHOUT
2966
+ * also skipping sites whose only edge is a prior "lsp" edge — those must
2967
+ * be re-asserted each run or reconciliation would retire them.
2968
+ */
2969
+ resolvedCalleeNames(srcQualifiedName: string, provenance: string, filePath?: string): string[] {
2970
+ if (this.closed) return [];
2971
+ try {
2972
+ // Scoped to the caller FILE when provided: two files defining the
2973
+ // same top-level qualified name (main/handler/init) must not leak
2974
+ // each other's resolutions (#1923 review thread).
2975
+ const fileClause = filePath !== undefined ? " AND f.path = ?" : "";
2976
+ const bind = filePath !== undefined ? [srcQualifiedName, provenance, filePath] : [srcQualifiedName, provenance];
2977
+ const rows = expectRows<{ name: string }>(
2978
+ this.db
2979
+ .prepare(
2980
+ `SELECT DISTINCT dn.name AS name FROM edges e
2981
+ JOIN nodes sn ON e.src = sn.id
2982
+ JOIN files f ON sn.file_id = f.id
2983
+ JOIN nodes dn ON e.dst = dn.id
2984
+ WHERE sn.qualified_name = ? AND e.type = 'CALLS' AND e.provenance = ?${fileClause}`,
2985
+ )
2986
+ .all(...bind),
2987
+ ["name"],
2988
+ );
2989
+ return rows.map((r) => r.name);
2990
+ } catch {
2991
+ return [];
2992
+ }
2993
+ }
2994
+
2995
+ /**
2996
+ * Current CALLS edges of a given provenance owned by a caller symbol in
2997
+ * a file (#1923 review threads). The LSP pass uses this two ways:
2998
+ * provenance "lsp" lists a filtered caller's existing lsp edges, and
2999
+ * provenance "heuristic" lists its current Phase-A resolutions — an lsp
3000
+ * edge is preserved at reconcile time only when it duplicates a current
3001
+ * heuristic resolution (same dst), so removed member calls' edges retire
3002
+ * while a filtered bare call's covering edge survives.
3003
+ */
3004
+ callEdgesForCaller(
3005
+ srcQualifiedName: string,
3006
+ filePath: string,
3007
+ provenance: string,
3008
+ ): Array<{ srcQualifiedName: string; dstQualifiedName: string; dstName: string; type: string }> {
3009
+ if (this.closed) return [];
3010
+ try {
3011
+ const rows = expectRows<{ src_q: string; dst_q: string; dst_n: string; type: string }>(
3012
+ this.db
3013
+ .prepare(
3014
+ `SELECT sn.qualified_name AS src_q, dn.qualified_name AS dst_q, dn.name AS dst_n, e.type AS type FROM edges e
3015
+ JOIN nodes sn ON e.src = sn.id
3016
+ JOIN files f ON sn.file_id = f.id
3017
+ JOIN nodes dn ON e.dst = dn.id
3018
+ WHERE sn.qualified_name = ? AND f.path = ? AND e.provenance = ? AND e.type = 'CALLS'`,
3019
+ )
3020
+ .all(srcQualifiedName, filePath, provenance),
3021
+ ["src_q", "dst_q", "dst_n", "type"],
3022
+ );
3023
+ return rows.map((r) => ({
3024
+ srcQualifiedName: r.src_q,
3025
+ dstQualifiedName: r.dst_q,
3026
+ dstName: r.dst_n,
3027
+ type: r.type,
3028
+ }));
3029
+ } catch {
3030
+ return [];
3031
+ }
3032
+ }
3033
+
2912
3034
  /**
2913
3035
  * Read a symbol's source span from disk. The store NEVER persists
2914
3036
  * file contents (privacy + DB size — issue #1552 design); this
@@ -295,12 +295,135 @@ test("executor: definition returns empty → unresolved", async () => {
295
295
  client: mockClient,
296
296
  nodeLocator: locator,
297
297
  applyUpgrades: async () => {},
298
+ // Warm-up retry (issue #1933) is exercised by its own tests below;
299
+ // this test asserts the definitive-empty path.
300
+ warmupRetryDelayMs: 0,
298
301
  });
299
302
 
300
303
  assert.equal(result.upgraded, 0);
301
304
  assert.equal(result.unresolved, 1);
302
305
  });
303
306
 
307
+ // ──────────────────────────────────────────────────────────────────────────
308
+ // Warm-up retry (issue #1933): tsserver answers pre-project-load definition
309
+ // requests with [] instead of an error. An empty result before the server
310
+ // has proven warm is retried once; the retry's answer is final.
311
+ // ──────────────────────────────────────────────────────────────────────────
312
+
313
+ test("executor: warm-up retry — empty first answer retried once, retry result upgrades", async () => {
314
+ const requests = planLspUpgrades(
315
+ [makeCallSite("src/a.ts", "target", 0, "a.caller")],
316
+ { maxRequests: 100 },
317
+ ).requests;
318
+
319
+ // Stateful mock: first definition call returns [], second returns the
320
+ // real location (project finished loading between the two).
321
+ let calls = 0;
322
+ const client = {
323
+ definition: async () => {
324
+ calls += 1;
325
+ if (calls === 1) return { ok: true as const, locations: [] };
326
+ return {
327
+ ok: true as const,
328
+ locations: [{
329
+ uri: "file:///src/target.ts",
330
+ range: { start: { line: 0, character: 0 }, end: { line: 0, character: 6 } },
331
+ }],
332
+ };
333
+ },
334
+ didOpen: () => {},
335
+ dispose: async () => {},
336
+ } as unknown as LspClient;
337
+
338
+ const applied: EdgeUpgrade[] = [];
339
+ const result = await executeLspResolution(requests, {
340
+ client,
341
+ nodeLocator: () => "mod.target",
342
+ applyUpgrades: async (upgrades) => {
343
+ applied.push(...upgrades);
344
+ },
345
+ warmupRetryDelayMs: 5,
346
+ });
347
+
348
+ assert.equal(calls, 2, "empty pre-warm answer must be retried exactly once");
349
+ assert.equal(result.upgraded, 1, "the retry's location produces the upgrade");
350
+ assert.equal(result.unresolved, 0);
351
+ assert.equal(applied.length, 1);
352
+ });
353
+
354
+ test("executor: warm-up retry — only ONE retry is paid; later empties are definitive", async () => {
355
+ const requests = planLspUpgrades(
356
+ [
357
+ makeCallSite("src/a.ts", "missing1", 0, "a.caller"),
358
+ makeCallSite("src/a.ts", "missing2", 10, "a.caller"),
359
+ ],
360
+ { maxRequests: 100 },
361
+ ).requests;
362
+
363
+ let calls = 0;
364
+ const client = {
365
+ definition: async () => {
366
+ calls += 1;
367
+ return { ok: true as const, locations: [] };
368
+ },
369
+ didOpen: () => {},
370
+ dispose: async () => {},
371
+ } as unknown as LspClient;
372
+
373
+ const result = await executeLspResolution(requests, {
374
+ client,
375
+ nodeLocator: () => null,
376
+ applyUpgrades: async () => {},
377
+ warmupRetryDelayMs: 5,
378
+ });
379
+
380
+ // Site 1: initial + one warm-up retry = 2 calls; the server is then
381
+ // considered warm. Site 2: exactly 1 call, its empty answer is final.
382
+ assert.equal(calls, 3, "one warm-up retry total, not one per site");
383
+ assert.equal(result.upgraded, 0);
384
+ assert.equal(result.unresolved, 2);
385
+ });
386
+
387
+ test("executor: warm-up retry — a non-empty FIRST answer marks the server warm (no retry paid)", async () => {
388
+ const requests = planLspUpgrades(
389
+ [
390
+ makeCallSite("src/a.ts", "target", 0, "a.caller"),
391
+ makeCallSite("src/a.ts", "missing", 10, "a.caller"),
392
+ ],
393
+ { maxRequests: 100 },
394
+ ).requests;
395
+
396
+ let calls = 0;
397
+ const client = {
398
+ definition: async () => {
399
+ calls += 1;
400
+ if (calls === 1) {
401
+ return {
402
+ ok: true as const,
403
+ locations: [{
404
+ uri: "file:///src/target.ts",
405
+ range: { start: { line: 0, character: 0 }, end: { line: 0, character: 6 } },
406
+ }],
407
+ };
408
+ }
409
+ return { ok: true as const, locations: [] };
410
+ },
411
+ didOpen: () => {},
412
+ dispose: async () => {},
413
+ } as unknown as LspClient;
414
+
415
+ const result = await executeLspResolution(requests, {
416
+ client,
417
+ nodeLocator: () => "mod.target",
418
+ applyUpgrades: async () => {},
419
+ warmupRetryDelayMs: 5,
420
+ });
421
+
422
+ assert.equal(calls, 2, "warm server: the second site's empty answer is definitive, no retry");
423
+ assert.equal(result.upgraded, 1);
424
+ assert.equal(result.unresolved, 1);
425
+ });
426
+
304
427
  test("executor: mid-batch applyUpgrades failure → caught, counted as unresolved", async () => {
305
428
  const requests = planLspUpgrades(
306
429
  [makeCallSite("src/a.ts", "target", 0, "a.caller")],
@@ -488,6 +611,8 @@ test("executor: workspaceRoot resolves repo-relative paths to absolute URIs", as
488
611
  nodeLocator: () => null,
489
612
  applyUpgrades: async () => {},
490
613
  workspaceRoot: "/workspace",
614
+ // This test asserts URI shapes, not warm-up behavior (issue #1933).
615
+ warmupRetryDelayMs: 0,
491
616
  });
492
617
 
493
618
  assert.equal(didOpenUris.length, 1);
@@ -209,6 +209,15 @@ export function planLspUpgrades(
209
209
  export interface ResolveOptions {
210
210
  readonly client: LspClient;
211
211
  readonly nodeLocator: NodeLocator;
212
+ /**
213
+ * Warm-up retry delay in ms (issue #1933). Language servers (tsserver
214
+ * in particular) load projects ASYNCHRONOUSLY after `didOpen`; a
215
+ * definition request that arrives too early returns an EMPTY location
216
+ * array — indistinguishable from "definitely no definition". Until the
217
+ * server has proven warm (any non-empty response), an empty result is
218
+ * retried ONCE after this delay. Default 2500. Set 0 to disable.
219
+ */
220
+ readonly warmupRetryDelayMs?: number;
212
221
  /**
213
222
  * Apply a batch of edge upgrades atomically. Called once per file batch.
214
223
  * MUST be transactional — if it throws, zero upgrades from this batch
@@ -297,6 +306,14 @@ export async function executeLspResolution(
297
306
  let unresolved = 0;
298
307
  let degradation: LspDegradation | undefined;
299
308
 
309
+ // Warm-up tracking (issue #1933): false until the server returns its
310
+ // first NON-EMPTY definition, or until one warm-up retry has been paid.
311
+ // tsserver answers pre-project-load requests with [] instead of an
312
+ // error, so without the retry every first-run resolution silently
313
+ // reports unresolved and Phase B never upgrades anything.
314
+ let serverWarm = false;
315
+ const warmupRetryDelayMs = options.warmupRetryDelayMs ?? 2_500;
316
+
300
317
  for (const [filePath, batchReqs] of byFile) {
301
318
  // Send all definition requests for this file, collecting upgrades.
302
319
  const upgrades: EdgeUpgrade[] = [];
@@ -327,11 +344,28 @@ export async function executeLspResolution(
327
344
  break;
328
345
  }
329
346
 
330
- const defResult = await client.definition({
347
+ let defResult = await client.definition({
331
348
  textDocument: { uri: filePathToUri(req.filePath, options.workspaceRoot) },
332
349
  position: req.position,
333
350
  });
334
351
 
352
+ // Warm-up retry (issue #1933): an empty result from a not-yet-warm
353
+ // server is indeterminate, not definitive. Pay ONE bounded delay,
354
+ // re-ask, and treat the server as warm from then on — whatever the
355
+ // retry returns is the real answer.
356
+ if (defResult.ok && defResult.locations.length === 0 && !serverWarm && warmupRetryDelayMs > 0) {
357
+ await new Promise<void>((resolve) => setTimeout(resolve, warmupRetryDelayMs));
358
+ serverWarm = true;
359
+ const retryResult = await client.definition({
360
+ textDocument: { uri: filePathToUri(req.filePath, options.workspaceRoot) },
361
+ position: req.position,
362
+ });
363
+ defResult = retryResult;
364
+ }
365
+ if (defResult.ok && defResult.locations.length > 0) {
366
+ serverWarm = true;
367
+ }
368
+
335
369
  if (!defResult.ok) {
336
370
  // Distinguish "server problem" (stop the whole pass) from
337
371
  // "this particular definition returned nothing" (continue).