flurryx-code-memory 0.4.0__py3-none-any.whl

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.
Files changed (53) hide show
  1. code_memory/__init__.py +1 -0
  2. code_memory/claims/__init__.py +32 -0
  3. code_memory/claims/extractor.py +325 -0
  4. code_memory/claims/indexer.py +258 -0
  5. code_memory/claims/resolver.py +186 -0
  6. code_memory/claims/store.py +424 -0
  7. code_memory/cli.py +1192 -0
  8. code_memory/config.py +268 -0
  9. code_memory/embed/__init__.py +224 -0
  10. code_memory/embed/cache.py +204 -0
  11. code_memory/embed/m3.py +174 -0
  12. code_memory/embed/ollama.py +92 -0
  13. code_memory/embed/tei.py +106 -0
  14. code_memory/episodic/__init__.py +3 -0
  15. code_memory/episodic/sqlite_store.py +278 -0
  16. code_memory/extractor/__init__.py +3 -0
  17. code_memory/extractor/csproj.py +166 -0
  18. code_memory/extractor/dll.py +385 -0
  19. code_memory/extractor/gitignore.py +162 -0
  20. code_memory/extractor/nuget.py +275 -0
  21. code_memory/extractor/sanity.py +124 -0
  22. code_memory/extractor/sln.py +108 -0
  23. code_memory/extractor/treesitter.py +1172 -0
  24. code_memory/graph/__init__.py +3 -0
  25. code_memory/graph/falkor_store.py +740 -0
  26. code_memory/mcp_server.py +1816 -0
  27. code_memory/metrics.py +260 -0
  28. code_memory/orchestrator/__init__.py +13 -0
  29. code_memory/orchestrator/git_delta.py +211 -0
  30. code_memory/orchestrator/ingest_state.py +71 -0
  31. code_memory/orchestrator/pipeline.py +1478 -0
  32. code_memory/orchestrator/reset.py +130 -0
  33. code_memory/orchestrator/resolver.py +825 -0
  34. code_memory/orchestrator/retrieve.py +505 -0
  35. code_memory/resilience.py +73 -0
  36. code_memory/sync/__init__.py +20 -0
  37. code_memory/sync/autostart/__init__.py +42 -0
  38. code_memory/sync/autostart/base.py +106 -0
  39. code_memory/sync/autostart/launchd.py +115 -0
  40. code_memory/sync/autostart/schtasks.py +155 -0
  41. code_memory/sync/autostart/systemd.py +113 -0
  42. code_memory/sync/hooks.py +164 -0
  43. code_memory/sync/safety.py +65 -0
  44. code_memory/sync/snapshot.py +461 -0
  45. code_memory/sync/store.py +399 -0
  46. code_memory/sync/sync.py +405 -0
  47. code_memory/sync/watcher.py +320 -0
  48. code_memory/vector/__init__.py +3 -0
  49. code_memory/vector/qdrant_store.py +302 -0
  50. flurryx_code_memory-0.4.0.dist-info/METADATA +26 -0
  51. flurryx_code_memory-0.4.0.dist-info/RECORD +53 -0
  52. flurryx_code_memory-0.4.0.dist-info/WHEEL +4 -0
  53. flurryx_code_memory-0.4.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,825 @@
1
+ """Post-ingest symbol resolver.
2
+
3
+ The extractor emits ``CALLS`` edges from each File to a placeholder
4
+ ``Symbol {key: "name::X"}`` node — there's no way to know *which* X is
5
+ meant during a single-file parse. This module runs after ingest, loads
6
+ the entire project graph into memory, and re-points each placeholder
7
+ edge at a real (defined) Symbol when possible.
8
+
9
+ Resolution tiers (highest confidence first):
10
+
11
+ 1. **same-file** — F defines X locally → link to F's X.
12
+ 2. **imported** — F imports a file/module that defines X → link to that.
13
+ 3. **project-unique** — exactly one File in the project defines X → link
14
+ with medium confidence.
15
+ 4. **assembly-exposed** — F belongs to a .NET Project whose referenced
16
+ Assemblies expose exactly one Type named X → link to that Type with
17
+ "external" confidence. This is what turns calls like
18
+ ``JsonConvert.SerializeObject(...)`` into resolved edges pointing at
19
+ the Newtonsoft.Json assembly instead of leaving them as orphan
20
+ placeholders.
21
+ 5. **ambiguous / external** — leave the placeholder in place so the
22
+ structure is preserved but downstream graph queries can filter it.
23
+
24
+ Imports are resolved best-effort:
25
+
26
+ - Relative paths (``./bar``, ``../svc/auth``) are probed against project
27
+ files with common extensions (``.ts``, ``.tsx``, ``.js``, ``.jsx``,
28
+ ``.py``, plus ``/index.*``).
29
+ - Bare module names (``@acme-ng/security``, ``rxjs``) are treated as
30
+ external — we can't resolve them without a package map.
31
+
32
+ The resolver is read-mostly; it only writes when something actually
33
+ changes. After resolution, placeholder ``name::X`` nodes that lose all
34
+ incoming CALLS edges are deleted.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ from collections import defaultdict
40
+ from dataclasses import dataclass, field
41
+ from pathlib import Path
42
+
43
+ from ..graph.falkor_store import FalkorStore
44
+
45
+ PLACEHOLDER_PREFIX = "name::"
46
+ RESOLVABLE_SUFFIXES = (".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py")
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class ResolvedEdge:
51
+ file_path: str
52
+ placeholder_key: str # e.g. "name::getBearerToken"
53
+ target_key: str # real Symbol or Type key
54
+ confidence: str # "high" | "medium" | "external"
55
+ target_label: str = "Symbol" # "Symbol" (in-project) | "Type" (assembly)
56
+ via_assembly: str | None = None # set when target_label == "Type"
57
+ edge_type: str = "CALLS" # "CALLS" | "INJECTS"
58
+
59
+
60
+ @dataclass
61
+ class ResolverStats:
62
+ placeholders: int = 0
63
+ edges_total: int = 0
64
+ edges_resolved_same_file: int = 0
65
+ edges_resolved_imported: int = 0
66
+ edges_resolved_unique: int = 0
67
+ edges_resolved_assembly: int = 0
68
+ edges_left_ambiguous: int = 0
69
+ edges_left_external: int = 0
70
+ placeholders_deleted: int = 0
71
+ import_aliases_added: int = 0
72
+ notes: list[str] = field(default_factory=list)
73
+
74
+
75
+ def resolve_graph(graph: FalkorStore) -> ResolverStats:
76
+ """Run the full resolver pass against ``graph``.
77
+
78
+ Loads File / Symbol nodes + DEFINES / IMPORTS / CALLS edges into
79
+ memory, computes resolutions, then writes back the rewritten CALLS
80
+ edges and deletes orphaned placeholders.
81
+ """
82
+ state = _GraphState.load(graph)
83
+ stats = ResolverStats(
84
+ placeholders=len(state.placeholders),
85
+ edges_total=len(state.call_edges) + len(state.inject_edges),
86
+ )
87
+
88
+ resolutions = _resolve_all(state, stats)
89
+ _apply_resolutions(graph, resolutions)
90
+ deleted = _cleanup_orphans(graph, state, resolutions)
91
+ stats.placeholders_deleted = deleted
92
+ stats.import_aliases_added = _emit_import_aliases(graph, state)
93
+ return stats
94
+
95
+
96
+ # ---------------------------------------------------------------- state load
97
+
98
+
99
+ @dataclass
100
+ class _GraphState:
101
+ """In-memory snapshot of the parts of the graph the resolver needs."""
102
+
103
+ # path -> set of symbol names defined in that file
104
+ file_defines: dict[str, set[str]]
105
+ # path -> list of (module_key, kind) where kind in {"relative", "bare"}
106
+ file_imports: dict[str, list[tuple[str, str]]]
107
+ # placeholder_key -> short name (e.g. "name::foo" -> "foo")
108
+ placeholders: dict[str, str]
109
+ # list of (file_path, placeholder_key, arity, receiver_type) edges to
110
+ # resolve. ``arity`` is -1 when the call site arity is unknown.
111
+ # ``receiver_type`` is the inferred type of ``this.<field>`` for
112
+ # ``this.<field>.<method>()`` patterns; ``None`` otherwise.
113
+ call_edges: list[tuple[str, str, int, str | None]]
114
+ # symbol_name -> list of (file_path, full_symbol_key, param_count) defining it.
115
+ # ``param_count`` is ``None`` for non-callable kinds; resolver
116
+ # ignores arity matching when either side is missing.
117
+ name_index: dict[str, list[tuple[str, str, int | None]]]
118
+ # set of project file paths (resolved absolute), for relative import lookup
119
+ project_files: set[str]
120
+ # file_path -> project_key (CONTAINED_IN); empty for non-.NET projects
121
+ file_project: dict[str, str]
122
+ # project_key -> set of assembly_key references (USES_ASSEMBLY)
123
+ project_assemblies: dict[str, set[str]]
124
+ # type_name -> list of (assembly_key, type_key) tuples
125
+ type_index: dict[str, list[tuple[str, str]]]
126
+ # list of (file_path, placeholder_key) INJECTS edges to resolve
127
+ # alongside CALLS — same resolution rules, different edge type.
128
+ inject_edges: list[tuple[str, str]] = field(default_factory=list)
129
+ # list of (file_path, placeholder_key) REFERENCES edges (type-position
130
+ # name refs: base lists, parameter/field/property types, generic args,
131
+ # constraints). Same resolution rules as CALLS / INJECTS; arity is
132
+ # always unknown (type references carry no call-site arity).
133
+ reference_edges: list[tuple[str, str]] = field(default_factory=list)
134
+
135
+ @classmethod
136
+ def load(cls, graph: FalkorStore) -> _GraphState:
137
+ rows = graph.graph.query(
138
+ "MATCH (f:File)-[:DEFINES]->(s:Symbol) "
139
+ "WHERE s.unresolved IS NULL "
140
+ "RETURN f.key, s.name, s.key, s.params"
141
+ ).result_set
142
+ file_defines: dict[str, set[str]] = defaultdict(set)
143
+ name_index: dict[str, list[tuple[str, str, int | None]]] = defaultdict(list)
144
+ for row in rows:
145
+ f_key, s_name, s_key = row[0], row[1], row[2]
146
+ params = row[3] if len(row) > 3 else None
147
+ params_int = int(params) if isinstance(params, (int, float)) else None
148
+ file_defines[f_key].add(s_name)
149
+ name_index[s_name].append((f_key, s_key, params_int))
150
+
151
+ rows = graph.graph.query(
152
+ "MATCH (f:File)-[:IMPORTS]->(m:Module) RETURN f.key, m.key"
153
+ ).result_set
154
+ file_imports: dict[str, list[tuple[str, str]]] = defaultdict(list)
155
+ for f_key, m_key in rows:
156
+ kind = "relative" if (m_key.startswith(".") or m_key.startswith("/")) else "bare"
157
+ file_imports[f_key].append((m_key, kind))
158
+
159
+ rows = graph.graph.query(
160
+ "MATCH (s:Symbol) WHERE s.key STARTS WITH $p RETURN s.key, s.name",
161
+ {"p": PLACEHOLDER_PREFIX},
162
+ ).result_set
163
+ placeholders: dict[str, str] = {}
164
+ for s_key, s_name in rows:
165
+ # fall back to stripping prefix if the name prop is missing
166
+ placeholders[s_key] = s_name or s_key[len(PLACEHOLDER_PREFIX) :]
167
+
168
+ rows = graph.graph.query(
169
+ "MATCH (f:File)-[r:CALLS]->(s:Symbol) "
170
+ "WHERE s.key STARTS WITH $p "
171
+ "RETURN f.key, s.key, r.args, r.receiver_type",
172
+ {"p": PLACEHOLDER_PREFIX},
173
+ ).result_set
174
+ call_edges: list[tuple[str, str, int, str | None]] = []
175
+ for row in rows:
176
+ f = row[0]
177
+ s = row[1]
178
+ arity_raw = row[2] if len(row) > 2 else None
179
+ arity = int(arity_raw) if isinstance(arity_raw, (int, float)) else -1
180
+ recv = row[3] if len(row) > 3 else None
181
+ recv_type = recv if isinstance(recv, str) and recv else None
182
+ call_edges.append((f, s, arity, recv_type))
183
+
184
+ rows = graph.graph.query(
185
+ "MATCH (f:File)-[:INJECTS]->(s:Symbol) "
186
+ "WHERE s.key STARTS WITH $p "
187
+ "RETURN f.key, s.key",
188
+ {"p": PLACEHOLDER_PREFIX},
189
+ ).result_set
190
+ inject_edges: list[tuple[str, str]] = [(f, s) for f, s in rows]
191
+
192
+ rows = graph.graph.query(
193
+ "MATCH (f:File)-[:REFERENCES]->(s:Symbol) "
194
+ "WHERE s.key STARTS WITH $p "
195
+ "RETURN f.key, s.key",
196
+ {"p": PLACEHOLDER_PREFIX},
197
+ ).result_set
198
+ reference_edges: list[tuple[str, str]] = [(f, s) for f, s in rows]
199
+
200
+ # File→Project containment (only emitted for .NET files).
201
+ rows = graph.graph.query(
202
+ "MATCH (f:File)-[:CONTAINED_IN]->(p:Project) RETURN f.key, p.key"
203
+ ).result_set
204
+ file_project: dict[str, str] = {f: p for f, p in rows}
205
+
206
+ # Project→Assembly use edges.
207
+ rows = graph.graph.query(
208
+ "MATCH (p:Project)-[:USES_ASSEMBLY]->(a:Assembly) "
209
+ "RETURN p.key, a.key"
210
+ ).result_set
211
+ project_assemblies: dict[str, set[str]] = defaultdict(set)
212
+ for p_key, a_key in rows:
213
+ project_assemblies[p_key].add(a_key)
214
+
215
+ # Type name index across all indexed assemblies.
216
+ rows = graph.graph.query(
217
+ "MATCH (a:Assembly)-[:EXPOSES_TYPE]->(t:Type) "
218
+ "RETURN t.name, t.key, a.key"
219
+ ).result_set
220
+ type_index: dict[str, list[tuple[str, str]]] = defaultdict(list)
221
+ for t_name, t_key, a_key in rows:
222
+ type_index[t_name].append((a_key, t_key))
223
+
224
+ return cls(
225
+ file_defines=dict(file_defines),
226
+ file_imports=dict(file_imports),
227
+ placeholders=placeholders,
228
+ call_edges=call_edges,
229
+ name_index=dict(name_index),
230
+ project_files=set(file_defines.keys()),
231
+ file_project=file_project,
232
+ project_assemblies=dict(project_assemblies),
233
+ type_index=dict(type_index),
234
+ inject_edges=inject_edges,
235
+ reference_edges=reference_edges,
236
+ )
237
+
238
+
239
+ # ---------------------------------------------------------------- resolution
240
+
241
+
242
+ def _resolve_all(state: _GraphState, stats: ResolverStats) -> list[ResolvedEdge]:
243
+ resolutions: list[ResolvedEdge] = []
244
+ # per-file cache: imported file path -> symbols defined there
245
+ import_cache: dict[str, dict[str, list[tuple[str, str, int | None]]]] = {}
246
+
247
+ # Normalise to (file, placeholder, arity, receiver_type). Only CALLS
248
+ # carries a receiver type; DI is by type and type refs have no call
249
+ # site at all, so both default to ``None``.
250
+ norm_calls: list[tuple[str, str, int, str | None]] = list(state.call_edges)
251
+ norm_injects: list[tuple[str, str, int, str | None]] = [
252
+ (f, p, -1, None) for f, p in state.inject_edges
253
+ ]
254
+ norm_references: list[tuple[str, str, int, str | None]] = [
255
+ (f, p, -1, None) for f, p in state.reference_edges
256
+ ]
257
+ edge_specs: list[tuple[str, list[tuple[str, str, int, str | None]]]] = [
258
+ ("CALLS", norm_calls),
259
+ ("INJECTS", norm_injects),
260
+ ("REFERENCES", norm_references),
261
+ ]
262
+
263
+ # Reverse index: type name -> set of files that define it. Used to
264
+ # narrow ``this.<field>.<method>()`` to the methods of the field's
265
+ # type when ``<method>`` is otherwise ambiguous across the project.
266
+ name_to_files: dict[str, set[str]] = defaultdict(set)
267
+ for file_key, names in state.file_defines.items():
268
+ for nm in names:
269
+ name_to_files[nm].add(file_key)
270
+
271
+ for edge_type, edges in edge_specs:
272
+ for file_path, placeholder_key, arity, receiver_type in edges:
273
+ name = state.placeholders.get(placeholder_key)
274
+ if not name:
275
+ stats.edges_left_external += 1
276
+ continue
277
+
278
+ # (1) same-file
279
+ if name in state.file_defines.get(file_path, ()):
280
+ target = _pick_target(
281
+ state.name_index[name],
282
+ preferred_file=file_path,
283
+ arity=arity,
284
+ )
285
+ if target is not None:
286
+ resolutions.append(
287
+ ResolvedEdge(
288
+ file_path,
289
+ placeholder_key,
290
+ target,
291
+ "high",
292
+ edge_type=edge_type,
293
+ )
294
+ )
295
+ stats.edges_resolved_same_file += 1
296
+ continue
297
+
298
+ # (2) imported
299
+ if file_path not in import_cache:
300
+ import_cache[file_path] = _imported_symbols(state, file_path)
301
+ imported = import_cache[file_path].get(name, [])
302
+ if len(imported) == 1:
303
+ resolutions.append(
304
+ ResolvedEdge(
305
+ file_path,
306
+ placeholder_key,
307
+ imported[0][1],
308
+ "high",
309
+ edge_type=edge_type,
310
+ )
311
+ )
312
+ stats.edges_resolved_imported += 1
313
+ continue
314
+ if len(imported) > 1:
315
+ # Try arity-based disambiguation across imported candidates.
316
+ arity_match = _pick_by_arity(imported, arity)
317
+ if arity_match is not None:
318
+ resolutions.append(
319
+ ResolvedEdge(
320
+ file_path,
321
+ placeholder_key,
322
+ arity_match,
323
+ "high",
324
+ edge_type=edge_type,
325
+ )
326
+ )
327
+ stats.edges_resolved_imported += 1
328
+ continue
329
+ stats.edges_left_ambiguous += 1
330
+ continue
331
+
332
+ # (2.5) receiver-type narrowing. For ``this.<field>.<method>()``
333
+ # patterns, the extractor tagged the call with the inferred
334
+ # type of ``<field>``. Restrict project-wide candidates to
335
+ # symbols defined inside a file that also defines that type
336
+ # — typically the port / interface declaration. Without this
337
+ # narrow, every Angular use case calling ``port.execute()``
338
+ # or ``port.with()`` collapses to an ambiguous name with
339
+ # dozens of cross-codebase definitions.
340
+ if receiver_type:
341
+ type_files = name_to_files.get(receiver_type, set())
342
+ if type_files:
343
+ candidates = state.name_index.get(name, [])
344
+ narrowed = [c for c in candidates if c[0] in type_files]
345
+ if len(narrowed) == 1:
346
+ resolutions.append(
347
+ ResolvedEdge(
348
+ file_path,
349
+ placeholder_key,
350
+ narrowed[0][1],
351
+ "high",
352
+ edge_type=edge_type,
353
+ )
354
+ )
355
+ stats.edges_resolved_unique += 1
356
+ continue
357
+ if len(narrowed) > 1:
358
+ arity_match = _pick_by_arity(narrowed, arity)
359
+ if arity_match is not None:
360
+ resolutions.append(
361
+ ResolvedEdge(
362
+ file_path,
363
+ placeholder_key,
364
+ arity_match,
365
+ "high",
366
+ edge_type=edge_type,
367
+ )
368
+ )
369
+ stats.edges_resolved_unique += 1
370
+ continue
371
+
372
+ # (3) project-unique (with arity tiebreak)
373
+ candidates = state.name_index.get(name, [])
374
+ if len(candidates) == 1:
375
+ resolutions.append(
376
+ ResolvedEdge(
377
+ file_path,
378
+ placeholder_key,
379
+ candidates[0][1],
380
+ "medium",
381
+ edge_type=edge_type,
382
+ )
383
+ )
384
+ stats.edges_resolved_unique += 1
385
+ continue
386
+ if len(candidates) > 1:
387
+ arity_match = _pick_by_arity(candidates, arity)
388
+ if arity_match is not None:
389
+ resolutions.append(
390
+ ResolvedEdge(
391
+ file_path,
392
+ placeholder_key,
393
+ arity_match,
394
+ "medium",
395
+ edge_type=edge_type,
396
+ )
397
+ )
398
+ stats.edges_resolved_unique += 1
399
+ continue
400
+ stats.edges_left_ambiguous += 1
401
+ continue
402
+
403
+ # (4) assembly-exposed — only for .NET files whose project
404
+ # we indexed. Match the name against Type nodes from any
405
+ # assembly the file's project references; require a unique
406
+ # hit so we never coin-flip across overlapping surface
407
+ # (``Path`` in BCL and a 3rd-party lib). Same rules for
408
+ # CALLS and INJECTS — a Razor file injecting
409
+ # ``IUserService`` resolves through this path too.
410
+ asm_target = _resolve_via_assembly(file_path, name, state)
411
+ if asm_target is not None:
412
+ target_key, asm_key = asm_target
413
+ resolutions.append(
414
+ ResolvedEdge(
415
+ file_path=file_path,
416
+ placeholder_key=placeholder_key,
417
+ target_key=target_key,
418
+ confidence="external",
419
+ target_label="Type",
420
+ via_assembly=asm_key,
421
+ edge_type=edge_type,
422
+ )
423
+ )
424
+ stats.edges_resolved_assembly += 1
425
+ continue
426
+
427
+ stats.edges_left_external += 1
428
+
429
+ return resolutions
430
+
431
+
432
+ def _resolve_via_assembly(
433
+ file_path: str, name: str, state: _GraphState
434
+ ) -> tuple[str, str] | None:
435
+ """Pick the unique ``(type_key, assembly_key)`` that resolves ``name``.
436
+
437
+ Returns ``None`` when:
438
+ * the file isn't contained in any project (non-.NET, or owned by a
439
+ project we didn't index),
440
+ * the type name isn't exposed by any indexed assembly,
441
+ * multiple referenced assemblies expose the same type name
442
+ (would be a coin flip; safer to leave it unresolved so the agent
443
+ sees ambiguity).
444
+ """
445
+ proj_key = state.file_project.get(file_path)
446
+ if proj_key is None:
447
+ return None
448
+ asm_set = state.project_assemblies.get(proj_key)
449
+ if not asm_set:
450
+ return None
451
+ candidates = state.type_index.get(name, [])
452
+ matches = [(t_key, a_key) for a_key, t_key in candidates if a_key in asm_set]
453
+ if len(matches) != 1:
454
+ return None
455
+ type_key, asm_key = matches[0]
456
+ return type_key, asm_key
457
+
458
+
459
+ def _pick_target(
460
+ candidates: list[tuple[str, str, int | None]],
461
+ *,
462
+ preferred_file: str | None,
463
+ arity: int = -1,
464
+ ) -> str | None:
465
+ """Pick the best symbol key from same-name candidates.
466
+
467
+ Prefers (in order):
468
+ 1. Definitions in ``preferred_file`` (same-file match).
469
+ 2. The first remaining candidate.
470
+
471
+ When ``arity`` is supplied (>= 0) and matches a candidate's
472
+ ``params``, that candidate wins the same-file tier too — handy
473
+ when a file declares two overloads of the same method.
474
+ """
475
+ if not candidates:
476
+ return None
477
+ if preferred_file is not None:
478
+ same_file = [c for c in candidates if c[0] == preferred_file]
479
+ if same_file:
480
+ if arity >= 0:
481
+ for f, k, p in same_file:
482
+ if p == arity:
483
+ return k
484
+ return same_file[0][1]
485
+ return candidates[0][1]
486
+
487
+
488
+ def _pick_by_arity(
489
+ candidates: list[tuple[str, str, int | None]], arity: int
490
+ ) -> str | None:
491
+ """Return the unique candidate whose param count matches ``arity``.
492
+
493
+ Returns ``None`` when arity is unknown (call_edge arity == -1) or
494
+ when the match isn't unique. The resolver treats any of those as
495
+ "ambiguous" — we never coin-flip across overloads.
496
+ """
497
+ if arity < 0:
498
+ return None
499
+ matches = [c for c in candidates if c[2] == arity]
500
+ if len(matches) == 1:
501
+ return matches[0][1]
502
+ return None
503
+
504
+
505
+ def _imported_symbols(
506
+ state: _GraphState, file_path: str
507
+ ) -> dict[str, list[tuple[str, str, int | None]]]:
508
+ """Return {symbol_name -> [(defining_file, symbol_key, params)]} reachable from ``file_path``.
509
+
510
+ Only relative imports are followed (bare module specifiers like
511
+ ``@scope/lib`` are treated as external).
512
+ """
513
+ out: dict[str, list[tuple[str, str, int | None]]] = defaultdict(list)
514
+ file_dir = Path(file_path).parent
515
+ for mod_key, kind in state.file_imports.get(file_path, []):
516
+ if kind != "relative":
517
+ continue
518
+ target_file = _resolve_relative_import(file_dir, mod_key, state.project_files)
519
+ if target_file is None:
520
+ continue
521
+ for sym_name in state.file_defines.get(target_file, ()):
522
+ for f, k, params in state.name_index.get(sym_name, []):
523
+ if f == target_file:
524
+ out[sym_name].append((f, k, params))
525
+ return out
526
+
527
+
528
+ def _derive_import_aliases(target_file: str) -> list[str]:
529
+ """Compute alternative IMPORTS-target keys for ``target_file``.
530
+
531
+ Used to bridge the gap between *how a file says it imports* (relative:
532
+ ``from ..graph.falkor_store import X``) and *how an agent queries it*
533
+ (canonical: ``importers code_memory.graph.falkor_store``).
534
+
535
+ Emits three forms whenever possible:
536
+
537
+ 1. The absolute file path — for ``importers /abs/path/to/file.py``.
538
+ 2. The bare basename without extension — for ``importers falkor_store``.
539
+ 3. The Python dotted package path, derived by climbing ``__init__.py``
540
+ parents up to the package root. For ``…/src/code_memory/graph/
541
+ falkor_store.py`` that's ``code_memory.graph.falkor_store``. Skipped
542
+ for files that aren't inside a Python package (no ``__init__.py``
543
+ chain), which covers TS/JS/C# where relative-path keys are already
544
+ unambiguous.
545
+ """
546
+ p = Path(target_file)
547
+ aliases: list[str] = [str(p), p.stem]
548
+
549
+ parts: list[str] = [p.stem]
550
+ current = p.parent
551
+ # Climb until we hit a directory without __init__.py. ``current.parent
552
+ # == current`` is the filesystem root sentinel.
553
+ while (current / "__init__.py").exists():
554
+ parts.append(current.name)
555
+ if current.parent == current:
556
+ break
557
+ current = current.parent
558
+ if len(parts) > 1:
559
+ aliases.append(".".join(reversed(parts)))
560
+ return aliases
561
+
562
+
563
+ def _emit_import_aliases(graph: FalkorStore, state: _GraphState) -> int:
564
+ """Add alias IMPORTS edges so canonical-name lookups find every importer.
565
+
566
+ Without this, ``importers code_memory.graph.falkor_store`` only matches
567
+ Python files that wrote the absolute form; relative ``from ..graph.
568
+ falkor_store import X`` callers stay invisible because the graph stores
569
+ a different Module key for that text. Resolves each relative import to
570
+ its project file, derives the canonical alias(es), and emits
571
+ ``File → Module{key: alias}`` IMPORTS edges via ``MERGE`` so reruns
572
+ don't duplicate.
573
+
574
+ Returns the number of new alias edges committed.
575
+ """
576
+ rows: list[dict[str, str]] = []
577
+ seen: set[tuple[str, str]] = set()
578
+ for file_path, imports in state.file_imports.items():
579
+ file_dir = Path(file_path).parent
580
+ for mod_key, kind in imports:
581
+ if kind != "relative":
582
+ continue
583
+ target = _resolve_relative_import(
584
+ file_dir, mod_key, state.project_files
585
+ )
586
+ if target is None:
587
+ continue
588
+ for alias in _derive_import_aliases(target):
589
+ if alias == mod_key:
590
+ continue # original edge already covers this key
591
+ key = (file_path, alias)
592
+ if key in seen:
593
+ continue
594
+ seen.add(key)
595
+ rows.append({"file": file_path, "alias": alias})
596
+ if not rows:
597
+ return 0
598
+ graph.graph.query(
599
+ """
600
+ UNWIND $rows AS row
601
+ MERGE (m:Module {key: row.alias})
602
+ WITH row, m
603
+ MATCH (f:File {key: row.file})
604
+ MERGE (f)-[r:IMPORTS]->(m)
605
+ SET r.derived = true
606
+ """,
607
+ {"rows": rows},
608
+ )
609
+ return len(rows)
610
+
611
+
612
+ def _resolve_relative_import(
613
+ file_dir: Path, mod_key: str, project_files: set[str]
614
+ ) -> str | None:
615
+ """Resolve a relative import specifier to an actual project file path.
616
+
617
+ Handles two grammars:
618
+
619
+ 1. **TS / JS path-style** — ``./bar``, ``../svc/auth``. Joined with
620
+ ``file_dir`` and probed against extensions + ``/index.*``.
621
+ 2. **Python dotted-relative** — ``..graph.falkor_store`` from a file
622
+ in ``code_memory.orchestrator``. Each leading dot strips one
623
+ package level off the import side; the remaining dots split the
624
+ subpath. Probes ``.py`` and ``/__init__.py``.
625
+
626
+ Returns ``None`` if no candidate matches a known project file.
627
+ """
628
+ # Python dotted-relative: ``.foo`` / ``..pkg.sub.leaf`` — dots come
629
+ # in a contiguous prefix, then dotted segments. TS path-style uses
630
+ # ``./`` or ``../`` (a slash directly after the dots).
631
+ if mod_key.startswith(".") and "/" not in mod_key and len(mod_key) > 1:
632
+ # Strip the leading dots; first dot means "current package", each
633
+ # additional dot climbs one level. file_dir IS the current package
634
+ # for `from .foo`, so dot count - 1 = directories to climb.
635
+ i = 0
636
+ while i < len(mod_key) and mod_key[i] == ".":
637
+ i += 1
638
+ dots, tail = i, mod_key[i:]
639
+ base = file_dir
640
+ for _ in range(dots - 1):
641
+ base = base.parent
642
+ sub_segments = tail.split(".") if tail else []
643
+ candidate_dir = base
644
+ for seg in sub_segments[:-1]:
645
+ candidate_dir = candidate_dir / seg
646
+ last = sub_segments[-1] if sub_segments else ""
647
+ candidates = []
648
+ if last:
649
+ candidates.append(candidate_dir / f"{last}.py")
650
+ candidates.append(candidate_dir / last / "__init__.py")
651
+ else:
652
+ # bare ``from .`` / ``from ..`` — points at the package
653
+ candidates.append(candidate_dir / "__init__.py")
654
+ for cand in candidates:
655
+ cand_str = str(cand.resolve())
656
+ if cand_str in project_files:
657
+ return cand_str
658
+ return None
659
+
660
+ base = (file_dir / mod_key).resolve()
661
+ base_str = str(base)
662
+
663
+ # exact match (caller wrote ``./bar.ts``)
664
+ if base_str in project_files:
665
+ return base_str
666
+
667
+ for suf in RESOLVABLE_SUFFIXES:
668
+ cand = base_str + suf
669
+ if cand in project_files:
670
+ return cand
671
+
672
+ # directory index
673
+ for suf in RESOLVABLE_SUFFIXES:
674
+ cand = str(base / f"index{suf}")
675
+ if cand in project_files:
676
+ return cand
677
+
678
+ return None
679
+
680
+
681
+ # ---------------------------------------------------------------- writeback
682
+
683
+
684
+ def _apply_resolutions(
685
+ graph: FalkorStore, resolutions: list[ResolvedEdge]
686
+ ) -> None:
687
+ """Rewrite resolved edges from placeholder to real targets.
688
+
689
+ Four batches: cross-product of (edge_type, target_label).
690
+ FalkorDB's MATCH has no polymorphism, so each combination needs
691
+ its own Cypher pattern.
692
+ """
693
+ if not resolutions:
694
+ return
695
+
696
+ def _bucket(edge_type: str, label: str) -> list[dict[str, object]]:
697
+ return [
698
+ {
699
+ "file": r.file_path,
700
+ "placeholder": r.placeholder_key,
701
+ "target": r.target_key,
702
+ "conf": r.confidence,
703
+ "via": r.via_assembly,
704
+ }
705
+ for r in resolutions
706
+ if r.edge_type == edge_type and r.target_label == label
707
+ ]
708
+
709
+ queries: list[tuple[str, list[dict[str, object]]]] = [
710
+ (
711
+ """
712
+ UNWIND $rows AS row
713
+ MATCH (f:File {key: row.file})-[old:CALLS]->(:Symbol {key: row.placeholder})
714
+ MATCH (t:Symbol {key: row.target})
715
+ DELETE old
716
+ MERGE (f)-[r:CALLS]->(t)
717
+ SET r.confidence = row.conf, r.resolved = true
718
+ """,
719
+ _bucket("CALLS", "Symbol"),
720
+ ),
721
+ (
722
+ """
723
+ UNWIND $rows AS row
724
+ MATCH (f:File {key: row.file})-[old:CALLS]->(:Symbol {key: row.placeholder})
725
+ MATCH (t:Type {key: row.target})
726
+ DELETE old
727
+ MERGE (f)-[r:CALLS]->(t)
728
+ SET r.confidence = row.conf,
729
+ r.resolved = true,
730
+ r.via_assembly = row.via
731
+ """,
732
+ _bucket("CALLS", "Type"),
733
+ ),
734
+ (
735
+ """
736
+ UNWIND $rows AS row
737
+ MATCH (f:File {key: row.file})-[old:INJECTS]->(:Symbol {key: row.placeholder})
738
+ MATCH (t:Symbol {key: row.target})
739
+ DELETE old
740
+ MERGE (f)-[r:INJECTS]->(t)
741
+ SET r.confidence = row.conf, r.resolved = true
742
+ """,
743
+ _bucket("INJECTS", "Symbol"),
744
+ ),
745
+ (
746
+ """
747
+ UNWIND $rows AS row
748
+ MATCH (f:File {key: row.file})-[old:INJECTS]->(:Symbol {key: row.placeholder})
749
+ MATCH (t:Type {key: row.target})
750
+ DELETE old
751
+ MERGE (f)-[r:INJECTS]->(t)
752
+ SET r.confidence = row.conf,
753
+ r.resolved = true,
754
+ r.via_assembly = row.via
755
+ """,
756
+ _bucket("INJECTS", "Type"),
757
+ ),
758
+ (
759
+ """
760
+ UNWIND $rows AS row
761
+ MATCH (f:File {key: row.file})-[old:REFERENCES]->(:Symbol {key: row.placeholder})
762
+ MATCH (t:Symbol {key: row.target})
763
+ DELETE old
764
+ MERGE (f)-[r:REFERENCES]->(t)
765
+ SET r.confidence = row.conf, r.resolved = true
766
+ """,
767
+ _bucket("REFERENCES", "Symbol"),
768
+ ),
769
+ (
770
+ """
771
+ UNWIND $rows AS row
772
+ MATCH (f:File {key: row.file})-[old:REFERENCES]->(:Symbol {key: row.placeholder})
773
+ MATCH (t:Type {key: row.target})
774
+ DELETE old
775
+ MERGE (f)-[r:REFERENCES]->(t)
776
+ SET r.confidence = row.conf,
777
+ r.resolved = true,
778
+ r.via_assembly = row.via
779
+ """,
780
+ _bucket("REFERENCES", "Type"),
781
+ ),
782
+ ]
783
+ for query, rows in queries:
784
+ if rows:
785
+ graph.graph.query(query, {"rows": rows})
786
+
787
+
788
+ def _cleanup_orphans(
789
+ graph: FalkorStore,
790
+ state: _GraphState,
791
+ resolutions: list[ResolvedEdge],
792
+ ) -> int:
793
+ """Delete placeholder nodes whose CALLS, INJECTS and REFERENCES edges are gone.
794
+
795
+ A placeholder is orphan only when nothing points at it via any of
796
+ the three placeholder-producing relations — a Razor file injecting
797
+ an unresolved interface, or any file type-referencing it, keeps
798
+ the placeholder alive even when no source calls it.
799
+ """
800
+ if not state.placeholders:
801
+ return 0
802
+
803
+ res = graph.graph.query(
804
+ """
805
+ MATCH (s:Symbol)
806
+ WHERE s.key STARTS WITH $p
807
+ AND NOT ( ()-[:CALLS]->(s) )
808
+ AND NOT ( ()-[:INJECTS]->(s) )
809
+ AND NOT ( ()-[:REFERENCES]->(s) )
810
+ WITH s, count(s) AS c
811
+ DELETE s
812
+ RETURN c
813
+ """,
814
+ {"p": PLACEHOLDER_PREFIX},
815
+ )
816
+ # FalkorDB returns nodes_deleted in result statistics; fall back to a
817
+ # second count query if not available.
818
+ deleted = getattr(res, "nodes_deleted", None)
819
+ if deleted is None:
820
+ # best-effort estimate from local state
821
+ rewritten = {r.placeholder_key for r in resolutions}
822
+ deleted = sum(
823
+ 1 for k in state.placeholders if k in rewritten
824
+ )
825
+ return int(deleted)