@remnic/coding-graph 9.6.23 → 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.
@@ -56,15 +56,25 @@ const JS_FAMILY_DEFINITIONS = `
56
56
  const JS_IMPORTS = `
57
57
  (import_statement
58
58
  source: (string (string_fragment) @import.module)) @__import.stmt
59
+ ; Default and namespace imports bind LOCAL aliases whose exported symbol
60
+ ; is unknowable to Phase A (issue #1894 round 12): they appear in
61
+ ; importedNames (informational) but never in bindings (call-bindable).
59
62
  (import_statement
60
- (import_clause (identifier) @import.name)
63
+ (import_clause (identifier) @import.unboundName)
61
64
  source: (string (string_fragment) @import.module)) @__import.stmt
62
65
  (import_statement
63
- (import_clause (namespace_import (identifier) @import.name))
66
+ (import_clause (namespace_import (identifier) @import.unboundName))
64
67
  source: (string (string_fragment) @import.module)) @__import.stmt
65
68
  (import_statement
66
69
  (import_clause (named_imports (import_specifier name: (identifier) @import.name)))
67
70
  source: (string (string_fragment) @import.module)) @__import.stmt
71
+ ; Aliased named import (issue #1894 review): import { foo as bar } — the
72
+ ; local binding is the alias; the exported name stays the binding target.
73
+ (import_statement
74
+ (import_clause (named_imports (import_specifier
75
+ name: (identifier) @import.aliasExported
76
+ alias: (identifier) @import.aliasLocal)))
77
+ source: (string (string_fragment) @import.module)) @__import.stmt
68
78
 
69
79
  ; CommonJS require("...") — capture the module specifier so dependency
70
80
  ; edges exist for Node/CommonJS codebases, not just ES-module imports.
@@ -130,7 +140,10 @@ const JS_EXPORTS = `
130
140
 
131
141
  const JS_CALLS = `
132
142
  (call_expression function: (identifier) @call.callee)
133
- (call_expression function: (member_expression property: (property_identifier) @call.callee))
143
+ ; Member calls are captured separately (issue #1894 review): obj.save()'s
144
+ ; \`save\` must never bind a bare visible symbol — method dispatch is
145
+ ; Phase B (LSP) territory. The emit layer marks these memberAccess: true.
146
+ (call_expression function: (member_expression property: (property_identifier) @call.member))
134
147
  `.trim();
135
148
 
136
149
  const JS_ROUTES = `
@@ -187,6 +200,17 @@ const PYTHON_EXTRACTOR: LanguageExtractor = {
187
200
  importsQuery: `
188
201
  (import_statement (dotted_name) @import.module) @__import.stmt
189
202
  (import_from_statement module_name: (dotted_name) @import.module) @__import.stmt
203
+ ; Imported names (issue #1894 review round 7): without these captures,
204
+ ; extractImports emits importedNames: [] and the heuristic resolver's
205
+ ; import bindings never fire for real Python parses.
206
+ (import_from_statement
207
+ module_name: (dotted_name) @import.module
208
+ name: (dotted_name) @import.name) @__import.stmt
209
+ (import_from_statement
210
+ module_name: (dotted_name) @import.module
211
+ name: (aliased_import
212
+ name: (dotted_name) @import.aliasExported
213
+ alias: (identifier) @import.aliasLocal)) @__import.stmt
190
214
  ; Python relative imports: from .models import User / from ..parent import X
191
215
  ; tree-sitter-python wraps the module inside a relative_import node, so the
192
216
  ; module_name field is NOT set. Capture the relative_import node itself so
@@ -194,11 +218,19 @@ const PYTHON_EXTRACTOR: LanguageExtractor = {
194
218
  ; relative levels must not collapse to the same module name
195
219
  ; (chatgpt-codex-connector #1688 P2: 'Preserve dots in Python relative imports').
196
220
  (import_from_statement (relative_import) @import.module) @__import.stmt
221
+ (import_from_statement
222
+ (relative_import) @import.module
223
+ name: (dotted_name) @import.name) @__import.stmt
224
+ (import_from_statement
225
+ (relative_import) @import.module
226
+ name: (aliased_import
227
+ name: (dotted_name) @import.aliasExported
228
+ alias: (identifier) @import.aliasLocal)) @__import.stmt
197
229
  `.trim(),
198
230
  exportsQuery: ``,
199
231
  callSitesQuery: `
200
232
  (call function: (identifier) @call.callee)
201
- (call function: (attribute attribute: (identifier) @call.callee))
233
+ (call function: (attribute attribute: (identifier) @call.member))
202
234
  `.trim(),
203
235
  routesQuery: `
204
236
  (decorated_definition
@@ -235,7 +267,7 @@ const GO_EXTRACTOR: LanguageExtractor = {
235
267
  exportsQuery: ``,
236
268
  callSitesQuery: `
237
269
  (call_expression function: (identifier) @call.callee)
238
- (call_expression function: (selector_expression field: (field_identifier) @call.callee))
270
+ (call_expression function: (selector_expression field: (field_identifier) @call.member))
239
271
  `.trim(),
240
272
  routesQuery: ``,
241
273
  };
@@ -271,7 +303,7 @@ const RUST_EXTRACTOR: LanguageExtractor = {
271
303
  exportsQuery: ``,
272
304
  callSitesQuery: `
273
305
  (call_expression function: (identifier) @call.callee)
274
- (call_expression function: (field_expression field: (field_identifier) @call.callee))
306
+ (call_expression function: (field_expression field: (field_identifier) @call.member))
275
307
  (call_expression function: (scoped_identifier) @call.callee)
276
308
  `.trim(),
277
309
  routesQuery: ``,
@@ -291,7 +323,8 @@ const JAVA_EXTRACTOR: LanguageExtractor = {
291
323
  `.trim(),
292
324
  exportsQuery: ``,
293
325
  callSitesQuery: `
294
- (method_invocation name: (identifier) @call.callee)
326
+ (method_invocation !object name: (identifier) @call.callee)
327
+ (method_invocation object: (_) name: (identifier) @call.member)
295
328
  `.trim(),
296
329
  routesQuery: ``,
297
330
  };
@@ -331,7 +364,7 @@ const CPP_EXTRACTOR: LanguageExtractor = {
331
364
  exportsQuery: ``,
332
365
  callSitesQuery: `
333
366
  (call_expression function: (identifier) @call.callee)
334
- (call_expression function: (field_expression field: (field_identifier) @call.callee))
367
+ (call_expression function: (field_expression field: (field_identifier) @call.member))
335
368
  `.trim(),
336
369
  routesQuery: ``,
337
370
  };
@@ -351,7 +384,7 @@ const CSHARP_EXTRACTOR: LanguageExtractor = {
351
384
  exportsQuery: ``,
352
385
  callSitesQuery: `
353
386
  (invocation_expression function: (identifier) @call.callee)
354
- (invocation_expression function: (member_access_expression name: (identifier) @call.callee))
387
+ (invocation_expression function: (member_access_expression name: (identifier) @call.member))
355
388
  `.trim(),
356
389
  routesQuery: ``,
357
390
  };
@@ -371,7 +404,8 @@ const RUBY_EXTRACTOR: LanguageExtractor = {
371
404
  `.trim(),
372
405
  exportsQuery: ``,
373
406
  callSitesQuery: `
374
- (call method: (identifier) @call.callee)
407
+ (call !receiver method: (identifier) @call.callee)
408
+ (call receiver: (_) method: (identifier) @call.member)
375
409
  `.trim(),
376
410
  routesQuery: ``,
377
411
  };
@@ -390,7 +424,7 @@ const PHP_EXTRACTOR: LanguageExtractor = {
390
424
  exportsQuery: ``,
391
425
  callSitesQuery: `
392
426
  (function_call_expression function: (name) @call.callee)
393
- (member_call_expression (name) @call.callee)
427
+ (member_call_expression (name) @call.member)
394
428
  `.trim(),
395
429
  routesQuery: ``,
396
430
  };
@@ -134,6 +134,23 @@ export interface EdgeIR {
134
134
  readonly srcNodeId?: string;
135
135
  /** Optional content-derived destination node id — see {@link EdgeIR.srcNodeId}. */
136
136
  readonly dstNodeId?: string;
137
+ /**
138
+ * Repo-relative, extension-stripped path the dst must live in (issue
139
+ * #1894 review): derived from a relative import's module specifier. A
140
+ * hinted edge resolves ONLY among nodes whose file path matches the
141
+ * hint (`<hint>`, `<hint>.<ext>`, `<hint>/index.<ext>`, or
142
+ * `<hint>/__init__.<ext>`) — never via
143
+ * the global bare-name fallback — so `import { foo } from "./missing"`
144
+ * can never bind an unrelated same-named symbol elsewhere in the repo.
145
+ */
146
+ readonly dstPathHint?: string;
147
+ /**
148
+ * Language of the importing file (issue #1894 round 13): constrains the
149
+ * hinted dst's file extension to that language's module-resolution set
150
+ * so a polyglot repo cannot cross-bind a JS import to a same-named .py
151
+ * file.
152
+ */
153
+ readonly dstImporterLanguage?: string;
137
154
  }
138
155
 
139
156
  /**
@@ -159,6 +176,16 @@ export interface StoreFileIR {
159
176
  readonly symbols: readonly SymbolIR[];
160
177
  /** Store-specific edges derived from the IR by the caller. */
161
178
  readonly edges?: readonly EdgeIR[];
179
+ /**
180
+ * When present, the stale-edge delete in `upsertFileEdges` is scoped to
181
+ * edges whose provenance is in this list: prior src-owned edges of OTHER
182
+ * provenances survive un-asserted (issue #1891). The reindex pipeline
183
+ * asserts `["heuristic"]` because a fresh parse says nothing about
184
+ * `trace`/`lsp` edges; deleting them on every re-ingest would destroy
185
+ * state the parse never contradicted (rule 25). Absent = legacy
186
+ * behavior: every stale src-owned edge is deleted.
187
+ */
188
+ readonly assertedEdgeProvenances?: readonly EdgeProvenance[];
162
189
  /**
163
190
  * Per-file export list (mirrors core FileIR.exports). When present,
164
191
  * the write pipeline marks every node in this file whose `name`
@@ -898,6 +925,105 @@ export class GraphStore {
898
925
  return this.queue.schedule(() => this.runUpsertEdges(edges));
899
926
  }
900
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
+
901
1027
  /** Wait for pending writes to drain — test seam. */
902
1028
  async drain(): Promise<void> {
903
1029
  await this.queue.drain();
@@ -1752,12 +1878,18 @@ export class GraphStore {
1752
1878
  // re-ingest (chatgpt-codex-connector P2).
1753
1879
  const srcId = qualifiedNameToId.get(edge.srcQualifiedName);
1754
1880
  if (!srcId) continue;
1755
- // DST may be cross-file use the full-DB fallback.
1756
- const dstId = resolveNodeId(
1757
- edge.dstQualifiedName,
1758
- qualifiedNameToId,
1759
- this.db,
1760
- );
1881
+ // DST may be cross-file. A path-hinted edge (relative import,
1882
+ // issue #1894 review) resolves ONLY within its declared target
1883
+ // file — never via the global bare-name fallback; unhinted edges
1884
+ // keep the batch-map + full-DB fallback.
1885
+ const dstId = edge.dstPathHint
1886
+ ? resolveNodeIdWithPathHint(
1887
+ edge.dstQualifiedName,
1888
+ edge.dstPathHint,
1889
+ this.db,
1890
+ edge.dstImporterLanguage,
1891
+ )
1892
+ : resolveNodeId(edge.dstQualifiedName, qualifiedNameToId, this.db);
1761
1893
  if (!dstId) continue;
1762
1894
  const key = `${srcId}\u0000${dstId}\u0000${edge.type}`;
1763
1895
  assertedKeys.add(key);
@@ -1788,12 +1920,24 @@ export class GraphStore {
1788
1920
  );
1789
1921
  const priorByKey = new Map<string, { confidence: number; provenance: string }>();
1790
1922
  const staleSrcDstTypes: Array<{ src: string; dst: string; type: string }> = [];
1923
+ // Provenance scoping (issue #1891): when the IR declares which
1924
+ // provenances it asserts, stale edges of OTHER provenances are not
1925
+ // this ingest's to delete — a fresh parse contradicts only its own
1926
+ // derivation class (rule 25). Absent = legacy delete-all-stale.
1927
+ // An EMPTY scope array is treated as absent (cursor review round 9):
1928
+ // "[]" would otherwise be truthy, protect every prior edge from
1929
+ // deletion AND from updates, and silently disable cleanup — an
1930
+ // assertion that scopes nothing scopes nothing.
1931
+ const scoped =
1932
+ ir.assertedEdgeProvenances && ir.assertedEdgeProvenances.length > 0
1933
+ ? ir.assertedEdgeProvenances
1934
+ : undefined;
1791
1935
  for (const p of priorEdges) {
1792
1936
  const key = `${p.src}\u0000${p.dst}\u0000${p.type}`;
1793
1937
  priorByKey.set(key, { confidence: p.confidence, provenance: p.provenance });
1794
- if (!assertedKeys.has(key)) {
1795
- staleSrcDstTypes.push({ src: p.src, dst: p.dst, type: p.type });
1796
- }
1938
+ if (assertedKeys.has(key)) continue;
1939
+ if (scoped && !(scoped as readonly string[]).includes(p.provenance)) continue;
1940
+ staleSrcDstTypes.push({ src: p.src, dst: p.dst, type: p.type });
1797
1941
  }
1798
1942
 
1799
1943
  // Delete only the stale src-owned edges (prior-but-not-asserted).
@@ -1852,6 +1996,27 @@ export class GraphStore {
1852
1996
  // stale keys.
1853
1997
  continue;
1854
1998
  }
1999
+ // Two update-path protections under provenance scoping (issue
2000
+ // #1891 + #1894 review rounds):
2001
+ // 1. a prior row whose provenance is OUTSIDE the asserted scope
2002
+ // is not this assertion's to modify (defense in depth — in
2003
+ // practice cross-provenance key collisions are heuristic/lsp
2004
+ // only, handled by 2);
2005
+ // 2. an lsp row is a strictly stronger derivation of the SAME
2006
+ // source-derived edge: re-asserting the heuristic key keeps
2007
+ // the row alive (it is not stale) but must never downgrade it.
2008
+ // Retirement of lsp rows happens through the stale-delete when
2009
+ // the call disappears from the parse — lsp IS in the reindex
2010
+ // assertion scope precisely so vanished calls retire their
2011
+ // upgraded rows too.
2012
+ if (
2013
+ prior &&
2014
+ scoped &&
2015
+ (!(scoped as readonly string[]).includes(prior.provenance) ||
2016
+ (prior.provenance === "lsp" && edge.provenance === "heuristic"))
2017
+ ) {
2018
+ continue;
2019
+ }
1855
2020
  const r = insertEdge.run(srcId, dstId, edge.type, edge.confidence, edge.provenance);
1856
2021
  edgeCount += r.changes;
1857
2022
  }
@@ -3289,6 +3454,97 @@ function resolveNodeId(
3289
3454
  return rows[0]?.id;
3290
3455
  }
3291
3456
 
3457
+ /**
3458
+ * Resolve a dst node constrained to a path hint (issue #1894 review): the
3459
+ * node's file path must be the hint verbatim, `<hint>.<ext>`,
3460
+ * `<hint>/index.<ext>`, or `<hint>/__init__.<ext>` (Python packages) —
3461
+ * where `<ext>` is a SINGLE dot-free extension segment, so `main.test.ts`
3462
+ * and `main.d.ts` never satisfy a `main` hint (they are not what a module
3463
+ * resolver would load for `./main`). Candidate rows are fetched by
3464
+ * qualified name and the path shape is checked in JS: no LIKE/GLOB, so
3465
+ * hint characters are never pattern metacharacters. Zero or multiple
3466
+ * matches return `undefined` — the edge is dropped rather than guessed
3467
+ * (same conservative policy as {@link resolveNodeId}).
3468
+ */
3469
+ /**
3470
+ * Language families and the file extensions a module resolver in that
3471
+ * family accepts (issue #1894 round 13). A polyglot repo cannot let a
3472
+ * JS import bind a same-named .py file: constrain by importer language.
3473
+ */
3474
+ const LANG_FAMILY_EXTENSIONS: Record<string, ReadonlySet<string>> = {
3475
+ js: new Set(["ts", "tsx", "js", "jsx", "mjs", "cjs", "mts", "cts"]),
3476
+ python: new Set(["py", "pyi"]),
3477
+ ruby: new Set(["rb"]),
3478
+ go: new Set(["go"]),
3479
+ rust: new Set(["rs"]),
3480
+ php: new Set(["php"]),
3481
+ java: new Set(["java"]),
3482
+ csharp: new Set(["cs"]),
3483
+ cpp: new Set(["cc", "cpp", "cxx", "c", "hh", "hpp", "hxx", "h"]),
3484
+ kotlin: new Set(["kt"]),
3485
+ swift: new Set(["swift"]),
3486
+ bash: new Set(["sh", "bash"]),
3487
+ };
3488
+
3489
+ const IMPORTER_LANG_FAMILY: Record<string, keyof typeof LANG_FAMILY_EXTENSIONS> = {
3490
+ typescript: "js",
3491
+ tsx: "js",
3492
+ javascript: "js",
3493
+ python: "python",
3494
+ ruby: "ruby",
3495
+ go: "go",
3496
+ rust: "rust",
3497
+ php: "php",
3498
+ java: "java",
3499
+ csharp: "csharp",
3500
+ c: "cpp",
3501
+ cpp: "cpp",
3502
+ kotlin: "kotlin",
3503
+ swift: "swift",
3504
+ bash: "bash",
3505
+ };
3506
+
3507
+ function resolveNodeIdWithPathHint(
3508
+ qualifiedName: string,
3509
+ pathHint: string,
3510
+ db: BetterSqlite3Database,
3511
+ importerLanguage?: string,
3512
+ ): string | undefined {
3513
+ const family = importerLanguage ? IMPORTER_LANG_FAMILY[importerLanguage] : undefined;
3514
+ const allowedExts = family ? LANG_FAMILY_EXTENSIONS[family] : undefined;
3515
+ const rows = expectRows<{ id: string; path: string }>(
3516
+ db
3517
+ .prepare(
3518
+ `SELECT n.id, f.path FROM nodes n JOIN files f ON n.file_id = f.id
3519
+ WHERE n.qualified_name = ?
3520
+ ORDER BY f.path, n.id`,
3521
+ )
3522
+ .all(qualifiedName),
3523
+ ["id", "path"],
3524
+ );
3525
+ const matches = rows.filter((row) => {
3526
+ if (row.path === pathHint) {
3527
+ // Language filter still applies on exact match: a TS import whose
3528
+ // normalized hint happens to equal a bare directory name must not
3529
+ // bind a same-named .py file (codex review round 15).
3530
+ if (allowedExts) {
3531
+ return false; // bare hint with no extension cannot match a family
3532
+ }
3533
+ return true;
3534
+ }
3535
+ if (!row.path.startsWith(pathHint)) return false;
3536
+ const rest = row.path.slice(pathHint.length);
3537
+ const m = /^(?:\.([^./]+)|\/(?:index|__init__)\.([^./]+))$/.exec(rest);
3538
+ if (!m) return false;
3539
+ const ext = m[1] ?? m[2];
3540
+ // When the importer's language family is known, only that family's
3541
+ // extensions may satisfy the hint; otherwise any single extension.
3542
+ return !allowedExts || allowedExts.has(ext);
3543
+ });
3544
+ if (matches.length !== 1) return undefined;
3545
+ return matches[0]?.id;
3546
+ }
3547
+
3292
3548
  /**
3293
3549
  * Resolve a standalone-edge endpoint by content-derived node id (issue #1677).
3294
3550
  *