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.
- code_memory/__init__.py +1 -0
- code_memory/claims/__init__.py +32 -0
- code_memory/claims/extractor.py +325 -0
- code_memory/claims/indexer.py +258 -0
- code_memory/claims/resolver.py +186 -0
- code_memory/claims/store.py +424 -0
- code_memory/cli.py +1192 -0
- code_memory/config.py +268 -0
- code_memory/embed/__init__.py +224 -0
- code_memory/embed/cache.py +204 -0
- code_memory/embed/m3.py +174 -0
- code_memory/embed/ollama.py +92 -0
- code_memory/embed/tei.py +106 -0
- code_memory/episodic/__init__.py +3 -0
- code_memory/episodic/sqlite_store.py +278 -0
- code_memory/extractor/__init__.py +3 -0
- code_memory/extractor/csproj.py +166 -0
- code_memory/extractor/dll.py +385 -0
- code_memory/extractor/gitignore.py +162 -0
- code_memory/extractor/nuget.py +275 -0
- code_memory/extractor/sanity.py +124 -0
- code_memory/extractor/sln.py +108 -0
- code_memory/extractor/treesitter.py +1172 -0
- code_memory/graph/__init__.py +3 -0
- code_memory/graph/falkor_store.py +740 -0
- code_memory/mcp_server.py +1816 -0
- code_memory/metrics.py +260 -0
- code_memory/orchestrator/__init__.py +13 -0
- code_memory/orchestrator/git_delta.py +211 -0
- code_memory/orchestrator/ingest_state.py +71 -0
- code_memory/orchestrator/pipeline.py +1478 -0
- code_memory/orchestrator/reset.py +130 -0
- code_memory/orchestrator/resolver.py +825 -0
- code_memory/orchestrator/retrieve.py +505 -0
- code_memory/resilience.py +73 -0
- code_memory/sync/__init__.py +20 -0
- code_memory/sync/autostart/__init__.py +42 -0
- code_memory/sync/autostart/base.py +106 -0
- code_memory/sync/autostart/launchd.py +115 -0
- code_memory/sync/autostart/schtasks.py +155 -0
- code_memory/sync/autostart/systemd.py +113 -0
- code_memory/sync/hooks.py +164 -0
- code_memory/sync/safety.py +65 -0
- code_memory/sync/snapshot.py +461 -0
- code_memory/sync/store.py +399 -0
- code_memory/sync/sync.py +405 -0
- code_memory/sync/watcher.py +320 -0
- code_memory/vector/__init__.py +3 -0
- code_memory/vector/qdrant_store.py +302 -0
- flurryx_code_memory-0.4.0.dist-info/METADATA +26 -0
- flurryx_code_memory-0.4.0.dist-info/RECORD +53 -0
- flurryx_code_memory-0.4.0.dist-info/WHEEL +4 -0
- 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)
|