@remnic/coding-graph 9.6.22 → 9.6.24

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`
@@ -1752,12 +1779,18 @@ export class GraphStore {
1752
1779
  // re-ingest (chatgpt-codex-connector P2).
1753
1780
  const srcId = qualifiedNameToId.get(edge.srcQualifiedName);
1754
1781
  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
- );
1782
+ // DST may be cross-file. A path-hinted edge (relative import,
1783
+ // issue #1894 review) resolves ONLY within its declared target
1784
+ // file — never via the global bare-name fallback; unhinted edges
1785
+ // keep the batch-map + full-DB fallback.
1786
+ const dstId = edge.dstPathHint
1787
+ ? resolveNodeIdWithPathHint(
1788
+ edge.dstQualifiedName,
1789
+ edge.dstPathHint,
1790
+ this.db,
1791
+ edge.dstImporterLanguage,
1792
+ )
1793
+ : resolveNodeId(edge.dstQualifiedName, qualifiedNameToId, this.db);
1761
1794
  if (!dstId) continue;
1762
1795
  const key = `${srcId}\u0000${dstId}\u0000${edge.type}`;
1763
1796
  assertedKeys.add(key);
@@ -1788,12 +1821,24 @@ export class GraphStore {
1788
1821
  );
1789
1822
  const priorByKey = new Map<string, { confidence: number; provenance: string }>();
1790
1823
  const staleSrcDstTypes: Array<{ src: string; dst: string; type: string }> = [];
1824
+ // Provenance scoping (issue #1891): when the IR declares which
1825
+ // provenances it asserts, stale edges of OTHER provenances are not
1826
+ // this ingest's to delete — a fresh parse contradicts only its own
1827
+ // derivation class (rule 25). Absent = legacy delete-all-stale.
1828
+ // An EMPTY scope array is treated as absent (cursor review round 9):
1829
+ // "[]" would otherwise be truthy, protect every prior edge from
1830
+ // deletion AND from updates, and silently disable cleanup — an
1831
+ // assertion that scopes nothing scopes nothing.
1832
+ const scoped =
1833
+ ir.assertedEdgeProvenances && ir.assertedEdgeProvenances.length > 0
1834
+ ? ir.assertedEdgeProvenances
1835
+ : undefined;
1791
1836
  for (const p of priorEdges) {
1792
1837
  const key = `${p.src}\u0000${p.dst}\u0000${p.type}`;
1793
1838
  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
- }
1839
+ if (assertedKeys.has(key)) continue;
1840
+ if (scoped && !(scoped as readonly string[]).includes(p.provenance)) continue;
1841
+ staleSrcDstTypes.push({ src: p.src, dst: p.dst, type: p.type });
1797
1842
  }
1798
1843
 
1799
1844
  // Delete only the stale src-owned edges (prior-but-not-asserted).
@@ -1852,6 +1897,27 @@ export class GraphStore {
1852
1897
  // stale keys.
1853
1898
  continue;
1854
1899
  }
1900
+ // Two update-path protections under provenance scoping (issue
1901
+ // #1891 + #1894 review rounds):
1902
+ // 1. a prior row whose provenance is OUTSIDE the asserted scope
1903
+ // is not this assertion's to modify (defense in depth — in
1904
+ // practice cross-provenance key collisions are heuristic/lsp
1905
+ // only, handled by 2);
1906
+ // 2. an lsp row is a strictly stronger derivation of the SAME
1907
+ // source-derived edge: re-asserting the heuristic key keeps
1908
+ // the row alive (it is not stale) but must never downgrade it.
1909
+ // Retirement of lsp rows happens through the stale-delete when
1910
+ // the call disappears from the parse — lsp IS in the reindex
1911
+ // assertion scope precisely so vanished calls retire their
1912
+ // upgraded rows too.
1913
+ if (
1914
+ prior &&
1915
+ scoped &&
1916
+ (!(scoped as readonly string[]).includes(prior.provenance) ||
1917
+ (prior.provenance === "lsp" && edge.provenance === "heuristic"))
1918
+ ) {
1919
+ continue;
1920
+ }
1855
1921
  const r = insertEdge.run(srcId, dstId, edge.type, edge.confidence, edge.provenance);
1856
1922
  edgeCount += r.changes;
1857
1923
  }
@@ -3289,6 +3355,97 @@ function resolveNodeId(
3289
3355
  return rows[0]?.id;
3290
3356
  }
3291
3357
 
3358
+ /**
3359
+ * Resolve a dst node constrained to a path hint (issue #1894 review): the
3360
+ * node's file path must be the hint verbatim, `<hint>.<ext>`,
3361
+ * `<hint>/index.<ext>`, or `<hint>/__init__.<ext>` (Python packages) —
3362
+ * where `<ext>` is a SINGLE dot-free extension segment, so `main.test.ts`
3363
+ * and `main.d.ts` never satisfy a `main` hint (they are not what a module
3364
+ * resolver would load for `./main`). Candidate rows are fetched by
3365
+ * qualified name and the path shape is checked in JS: no LIKE/GLOB, so
3366
+ * hint characters are never pattern metacharacters. Zero or multiple
3367
+ * matches return `undefined` — the edge is dropped rather than guessed
3368
+ * (same conservative policy as {@link resolveNodeId}).
3369
+ */
3370
+ /**
3371
+ * Language families and the file extensions a module resolver in that
3372
+ * family accepts (issue #1894 round 13). A polyglot repo cannot let a
3373
+ * JS import bind a same-named .py file: constrain by importer language.
3374
+ */
3375
+ const LANG_FAMILY_EXTENSIONS: Record<string, ReadonlySet<string>> = {
3376
+ js: new Set(["ts", "tsx", "js", "jsx", "mjs", "cjs", "mts", "cts"]),
3377
+ python: new Set(["py", "pyi"]),
3378
+ ruby: new Set(["rb"]),
3379
+ go: new Set(["go"]),
3380
+ rust: new Set(["rs"]),
3381
+ php: new Set(["php"]),
3382
+ java: new Set(["java"]),
3383
+ csharp: new Set(["cs"]),
3384
+ cpp: new Set(["cc", "cpp", "cxx", "c", "hh", "hpp", "hxx", "h"]),
3385
+ kotlin: new Set(["kt"]),
3386
+ swift: new Set(["swift"]),
3387
+ bash: new Set(["sh", "bash"]),
3388
+ };
3389
+
3390
+ const IMPORTER_LANG_FAMILY: Record<string, keyof typeof LANG_FAMILY_EXTENSIONS> = {
3391
+ typescript: "js",
3392
+ tsx: "js",
3393
+ javascript: "js",
3394
+ python: "python",
3395
+ ruby: "ruby",
3396
+ go: "go",
3397
+ rust: "rust",
3398
+ php: "php",
3399
+ java: "java",
3400
+ csharp: "csharp",
3401
+ c: "cpp",
3402
+ cpp: "cpp",
3403
+ kotlin: "kotlin",
3404
+ swift: "swift",
3405
+ bash: "bash",
3406
+ };
3407
+
3408
+ function resolveNodeIdWithPathHint(
3409
+ qualifiedName: string,
3410
+ pathHint: string,
3411
+ db: BetterSqlite3Database,
3412
+ importerLanguage?: string,
3413
+ ): string | undefined {
3414
+ const family = importerLanguage ? IMPORTER_LANG_FAMILY[importerLanguage] : undefined;
3415
+ const allowedExts = family ? LANG_FAMILY_EXTENSIONS[family] : undefined;
3416
+ const rows = expectRows<{ id: string; path: string }>(
3417
+ db
3418
+ .prepare(
3419
+ `SELECT n.id, f.path FROM nodes n JOIN files f ON n.file_id = f.id
3420
+ WHERE n.qualified_name = ?
3421
+ ORDER BY f.path, n.id`,
3422
+ )
3423
+ .all(qualifiedName),
3424
+ ["id", "path"],
3425
+ );
3426
+ const matches = rows.filter((row) => {
3427
+ if (row.path === pathHint) {
3428
+ // Language filter still applies on exact match: a TS import whose
3429
+ // normalized hint happens to equal a bare directory name must not
3430
+ // bind a same-named .py file (codex review round 15).
3431
+ if (allowedExts) {
3432
+ return false; // bare hint with no extension cannot match a family
3433
+ }
3434
+ return true;
3435
+ }
3436
+ if (!row.path.startsWith(pathHint)) return false;
3437
+ const rest = row.path.slice(pathHint.length);
3438
+ const m = /^(?:\.([^./]+)|\/(?:index|__init__)\.([^./]+))$/.exec(rest);
3439
+ if (!m) return false;
3440
+ const ext = m[1] ?? m[2];
3441
+ // When the importer's language family is known, only that family's
3442
+ // extensions may satisfy the hint; otherwise any single extension.
3443
+ return !allowedExts || allowedExts.has(ext);
3444
+ });
3445
+ if (matches.length !== 1) return undefined;
3446
+ return matches[0]?.id;
3447
+ }
3448
+
3292
3449
  /**
3293
3450
  * Resolve a standalone-edge endpoint by content-derived node id (issue #1677).
3294
3451
  *