superlocalmemory 4.0.5 → 4.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +108 -0
- package/README.md +8 -9
- package/package.json +3 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-loop/SKILL.md +1 -1
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +1 -1
- package/plugin/skills/slm-recall/SKILL.md +1 -1
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +1 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/access/rbac.py +106 -0
- package/src/superlocalmemory/brain/truth.py +80 -10
- package/src/superlocalmemory/cli/__main__.py +17 -0
- package/src/superlocalmemory/cli/commands.py +28 -3
- package/src/superlocalmemory/cli/gdpr_cmd.py +779 -0
- package/src/superlocalmemory/cli/gdpr_io.py +109 -0
- package/src/superlocalmemory/cli/main.py +85 -0
- package/src/superlocalmemory/cli/summary_cmd.py +195 -0
- package/src/superlocalmemory/code_graph/bridge/entity_resolver.py +26 -0
- package/src/superlocalmemory/code_graph/bridge/event_listeners.py +14 -3
- package/src/superlocalmemory/code_graph/bridge/maintenance.py +206 -0
- package/src/superlocalmemory/code_graph/config.py +65 -1
- package/src/superlocalmemory/code_graph/extractors/__init__.py +17 -0
- package/src/superlocalmemory/code_graph/graph_store.py +180 -3
- package/src/superlocalmemory/code_graph/parser.py +280 -100
- package/src/superlocalmemory/compliance/gdpr.py +358 -0
- package/src/superlocalmemory/core/config.py +44 -1
- package/src/superlocalmemory/core/engine_wiring.py +5 -1
- package/src/superlocalmemory/core/fact_consolidator.py +24 -1
- package/src/superlocalmemory/core/maintenance.py +93 -1
- package/src/superlocalmemory/core/recall_worker.py +33 -12
- package/src/superlocalmemory/infra/backup.py +138 -0
- package/src/superlocalmemory/infra/backup_obligations.py +423 -0
- package/src/superlocalmemory/learning/engagement.py +165 -0
- package/src/superlocalmemory/mcp/tools_code_graph.py +78 -7
- package/src/superlocalmemory/mcp/tools_v3.py +20 -6
- package/src/superlocalmemory/retrieval/engine.py +21 -0
- package/src/superlocalmemory/retrieval/remote_reranker.py +108 -11
- package/src/superlocalmemory/server/routes/brain.py +283 -15
- package/src/superlocalmemory/server/routes/learning.py +13 -25
- package/src/superlocalmemory/server/routes/memories.py +61 -0
- package/src/superlocalmemory/server/routes/v3_api.py +171 -60
- package/src/superlocalmemory/storage/database.py +36 -0
- package/src/superlocalmemory/storage/models.py +12 -4
- package/src/superlocalmemory/storage/schema_code_graph.py +44 -1
- package/src/superlocalmemory/summaries/__init__.py +37 -0
- package/src/superlocalmemory/summaries/base.py +108 -0
- package/src/superlocalmemory/summaries/daily_reflection.py +293 -0
- package/src/superlocalmemory/summaries/project_work_log.py +424 -0
- package/src/superlocalmemory/summaries/session_summary.py +307 -0
- package/src/superlocalmemory/ui/css/design-system.css +76 -1
- package/src/superlocalmemory/ui/index.html +29 -12
- package/src/superlocalmemory/ui/js/fact-detail.js +61 -0
- package/src/superlocalmemory/ui/js/od-agents.js +49 -5
- package/src/superlocalmemory/ui/js/od-brain.js +257 -77
- package/src/superlocalmemory/ui/js/od-graph.js +147 -6
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory | https://qualixar.com
|
|
4
|
+
|
|
5
|
+
"""Bridge pass — runs the code↔memory bridge as background maintenance.
|
|
6
|
+
|
|
7
|
+
WHY THIS MODULE EXISTS
|
|
8
|
+
----------------------
|
|
9
|
+
The bridge was authored to run from ``BridgeEventListeners.on_memory_stored``,
|
|
10
|
+
i.e. once per ``memory.stored`` event. ``EventBus._notify_listeners`` calls
|
|
11
|
+
listeners **synchronously on the emitting thread**, so that design puts entity
|
|
12
|
+
resolution, enrichment and Hebbian linking inside every single ``remember``.
|
|
13
|
+
The owner's constraint for this release is explicit: remember and recall timing
|
|
14
|
+
must not move. So the memory-stored listener is gone and the work happens here,
|
|
15
|
+
in the same background pass that already runs ``consolidate_facts``.
|
|
16
|
+
|
|
17
|
+
WHAT THIS PASS TOUCHES
|
|
18
|
+
----------------------
|
|
19
|
+
Writes to ``code_graph.db`` only:
|
|
20
|
+
* ``code_memory_links`` — EntityResolver output
|
|
21
|
+
* ``code_memory_links.enriched_description`` — FactEnricher output
|
|
22
|
+
|
|
23
|
+
Recall never opens ``code_graph.db`` — verified: nothing under ``retrieval/`` or
|
|
24
|
+
``core/`` references ``code_memory_links`` or ``CodeGraphDatabase``. That makes
|
|
25
|
+
this half of the bridge recall-neutral by construction rather than by
|
|
26
|
+
measurement.
|
|
27
|
+
|
|
28
|
+
DELIBERATELY NOT HERE: Hebbian association edges. ``HebbianLinker`` produces
|
|
29
|
+
edges for ``association_edges`` in **memory.db**, which
|
|
30
|
+
``retrieval/spreading_activation.py`` reads via a UNION with ``graph_edges``.
|
|
31
|
+
Every such edge is an extra neighbour recall must traverse and changes which
|
|
32
|
+
memories come back — not just how fast. That cannot be made safe by moving it
|
|
33
|
+
into this pass, so it is deferred to its own release, where the edge volume at
|
|
34
|
+
production scale can be measured against the recall baseline first. No writer for
|
|
35
|
+
it ships here; ``bridge/hebbian_linker.py`` remains unwired on purpose.
|
|
36
|
+
|
|
37
|
+
IDEMPOTENCE
|
|
38
|
+
-----------
|
|
39
|
+
A watermark in ``graph_metadata`` records the ``created_at`` of the newest fact
|
|
40
|
+
processed. Re-running the pass processes only facts newer than that, so a
|
|
41
|
+
maintenance cycle every few minutes does not rescan the whole store. Links use
|
|
42
|
+
``INSERT OR REPLACE`` on a deterministic key, so reprocessing a fact cannot
|
|
43
|
+
duplicate its links.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
import logging
|
|
49
|
+
from typing import TYPE_CHECKING, Any
|
|
50
|
+
|
|
51
|
+
if TYPE_CHECKING: # pragma: no cover - typing only
|
|
52
|
+
from superlocalmemory.code_graph.database import CodeGraphDatabase
|
|
53
|
+
|
|
54
|
+
logger = logging.getLogger(__name__)
|
|
55
|
+
|
|
56
|
+
#: Watermark key in graph_metadata.
|
|
57
|
+
_WATERMARK_KEY = "bridge.last_fact_created_at"
|
|
58
|
+
|
|
59
|
+
#: Facts examined in a single pass. Bounds the pass so a first run on a large
|
|
60
|
+
#: store cannot occupy the maintenance thread indefinitely; the watermark means
|
|
61
|
+
#: the next cycle resumes where this one stopped.
|
|
62
|
+
MAX_FACTS_PER_PASS = 500
|
|
63
|
+
|
|
64
|
+
#: Links kept for one fact, highest confidence first. A single file-path mention
|
|
65
|
+
#: matches every node in that file — "the parser in code_graph/parser.py was
|
|
66
|
+
#: dropping edges" produced 17 links at confidence 0.6-0.8, against one link at
|
|
67
|
+
#: 0.95 for a backticked function name. Without a bound the broad matches bury
|
|
68
|
+
#: the precise ones in the UI and hand a large node set to the Hebbian pass.
|
|
69
|
+
MAX_LINKS_PER_FACT = 10
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _fact_rows(
|
|
73
|
+
memory_db: Any,
|
|
74
|
+
profile_id: str,
|
|
75
|
+
since: str | None,
|
|
76
|
+
limit: int,
|
|
77
|
+
) -> list[tuple[str, str, str]]:
|
|
78
|
+
"""Return (fact_id, content, created_at) for facts newer than *since*.
|
|
79
|
+
|
|
80
|
+
Ordered by ``created_at`` so the watermark advances monotonically even when
|
|
81
|
+
the pass stops at ``limit``.
|
|
82
|
+
"""
|
|
83
|
+
sql = (
|
|
84
|
+
"SELECT fact_id, content, created_at FROM atomic_facts "
|
|
85
|
+
"WHERE profile_id = ? AND content IS NOT NULL AND content != '' "
|
|
86
|
+
)
|
|
87
|
+
# M011 (archive_status) is a DEFERRED migration, so the column is absent on
|
|
88
|
+
# a database where it has not run yet. DatabaseManager._has_archive_status
|
|
89
|
+
# exists for exactly this and its docstring is explicit: "callers must not
|
|
90
|
+
# filter on a column that may not exist." Filtering unconditionally made
|
|
91
|
+
# this query raise "no such column" on any fresh install, which the caller's
|
|
92
|
+
# except swallowed into a warning and zero links — the bridge would simply
|
|
93
|
+
# never have run for a new user.
|
|
94
|
+
try:
|
|
95
|
+
has_archive = memory_db._has_archive_status()
|
|
96
|
+
except Exception: # pragma: no cover - helper absent on an unusual manager
|
|
97
|
+
has_archive = False
|
|
98
|
+
if has_archive:
|
|
99
|
+
sql += "AND (archive_status IS NULL OR archive_status = '') "
|
|
100
|
+
|
|
101
|
+
params: list[Any] = [profile_id]
|
|
102
|
+
if since:
|
|
103
|
+
sql += "AND created_at > ? "
|
|
104
|
+
params.append(since)
|
|
105
|
+
sql += "ORDER BY created_at ASC LIMIT ?"
|
|
106
|
+
params.append(limit)
|
|
107
|
+
|
|
108
|
+
# DatabaseManager.execute serves both reads and writes (see
|
|
109
|
+
# core/maintenance.py, which uses it for each). It returns sqlite3.Row;
|
|
110
|
+
# index by position so a plain-tuple factory also works.
|
|
111
|
+
rows = memory_db.execute(sql, tuple(params))
|
|
112
|
+
return [(r[0], r[1], r[2]) for r in rows]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def run_bridge_pass(
|
|
116
|
+
memory_db: Any,
|
|
117
|
+
code_graph_db: CodeGraphDatabase,
|
|
118
|
+
profile_id: str,
|
|
119
|
+
*,
|
|
120
|
+
max_facts: int = MAX_FACTS_PER_PASS,
|
|
121
|
+
) -> dict[str, int]:
|
|
122
|
+
"""Resolve code mentions in new facts and enrich the resulting links.
|
|
123
|
+
|
|
124
|
+
Returns counts. Never raises — the caller is background maintenance and a
|
|
125
|
+
bridge failure must not abort the rest of the cycle.
|
|
126
|
+
"""
|
|
127
|
+
counts = {"facts_scanned": 0, "links_created": 0, "enriched": 0}
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
from superlocalmemory.code_graph.bridge.entity_resolver import EntityResolver
|
|
131
|
+
from superlocalmemory.code_graph.bridge.fact_enricher import FactEnricher
|
|
132
|
+
except Exception as exc: # pragma: no cover - import guard
|
|
133
|
+
logger.debug("bridge pass unavailable: %s", exc)
|
|
134
|
+
return counts
|
|
135
|
+
|
|
136
|
+
# Nothing to match against — skip before touching memory.db at all.
|
|
137
|
+
stats = code_graph_db.get_stats()
|
|
138
|
+
if not stats.get("nodes"):
|
|
139
|
+
logger.debug("bridge pass: code graph is empty, nothing to resolve against")
|
|
140
|
+
return counts
|
|
141
|
+
|
|
142
|
+
watermark = code_graph_db.get_metadata(_WATERMARK_KEY)
|
|
143
|
+
try:
|
|
144
|
+
rows = _fact_rows(memory_db, profile_id, watermark, max_facts)
|
|
145
|
+
except Exception as exc:
|
|
146
|
+
logger.warning("bridge pass could not read facts: %s", exc)
|
|
147
|
+
return counts
|
|
148
|
+
|
|
149
|
+
if not rows:
|
|
150
|
+
return counts
|
|
151
|
+
|
|
152
|
+
resolver = EntityResolver(code_graph_db)
|
|
153
|
+
enricher = FactEnricher(code_graph_db)
|
|
154
|
+
newest = watermark
|
|
155
|
+
|
|
156
|
+
for fact_id, content, created_at in rows:
|
|
157
|
+
counts["facts_scanned"] += 1
|
|
158
|
+
newest = created_at if newest is None or created_at > newest else newest
|
|
159
|
+
try:
|
|
160
|
+
links = resolver.resolve(content, fact_id, max_links=MAX_LINKS_PER_FACT)
|
|
161
|
+
except Exception as exc:
|
|
162
|
+
logger.debug("bridge resolve failed for %s: %s", fact_id, exc)
|
|
163
|
+
continue
|
|
164
|
+
if not links:
|
|
165
|
+
continue
|
|
166
|
+
counts["links_created"] += len(links)
|
|
167
|
+
|
|
168
|
+
# Enrichment is derived from (fact text, matched nodes) and is stored
|
|
169
|
+
# beside the link in code_graph.db. The user's own fact wording in
|
|
170
|
+
# memory.db is never rewritten: doing that would invalidate the fact's
|
|
171
|
+
# embedding, and would compound a suffix on every maintenance cycle.
|
|
172
|
+
try:
|
|
173
|
+
matched = resolver.get_matched_nodes(content)
|
|
174
|
+
if not matched:
|
|
175
|
+
continue
|
|
176
|
+
enriched = enricher.enrich(fact_id, matched, content)
|
|
177
|
+
if enriched and enriched != content:
|
|
178
|
+
_store_enrichment(code_graph_db, fact_id, enriched)
|
|
179
|
+
counts["enriched"] += 1
|
|
180
|
+
except Exception as exc:
|
|
181
|
+
logger.debug("bridge enrichment failed for %s: %s", fact_id, exc)
|
|
182
|
+
|
|
183
|
+
if newest and newest != watermark:
|
|
184
|
+
try:
|
|
185
|
+
code_graph_db.set_metadata(_WATERMARK_KEY, newest)
|
|
186
|
+
except Exception as exc:
|
|
187
|
+
logger.warning("bridge watermark not advanced: %s", exc)
|
|
188
|
+
|
|
189
|
+
# One summary line per pass, never one per fact. A 3,527-fact store must not
|
|
190
|
+
# produce 3,527 log lines; per-fact detail stays at debug.
|
|
191
|
+
if counts["links_created"]:
|
|
192
|
+
logger.info(
|
|
193
|
+
"Code bridge: %d facts scanned, %d links, %d enriched",
|
|
194
|
+
counts["facts_scanned"], counts["links_created"], counts["enriched"],
|
|
195
|
+
)
|
|
196
|
+
return counts
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _store_enrichment(
|
|
200
|
+
code_graph_db: CodeGraphDatabase, fact_id: str, enriched: str
|
|
201
|
+
) -> None:
|
|
202
|
+
"""Persist enrichment text onto every link for *fact_id*."""
|
|
203
|
+
code_graph_db.execute_write(
|
|
204
|
+
"UPDATE code_memory_links SET enriched_description = ? WHERE slm_fact_id = ?",
|
|
205
|
+
(enriched, fact_id),
|
|
206
|
+
)
|
|
@@ -9,7 +9,7 @@ Frozen dataclass with all tunables. Sensible defaults for typical repos.
|
|
|
9
9
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
|
-
from dataclasses import dataclass, field
|
|
12
|
+
from dataclasses import dataclass, field, fields
|
|
13
13
|
from pathlib import Path
|
|
14
14
|
|
|
15
15
|
from superlocalmemory.infra.data_root import state_path
|
|
@@ -88,3 +88,67 @@ class CodeGraphConfig:
|
|
|
88
88
|
if slm_base_dir is not None:
|
|
89
89
|
return slm_base_dir / "code_graph.db"
|
|
90
90
|
return state_path("code_graph.db")
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def load(cls, **overrides: object) -> CodeGraphConfig:
|
|
94
|
+
"""Build a config from ``code_graph_config.json``, then apply overrides.
|
|
95
|
+
|
|
96
|
+
WHY THIS EXISTS (4.0.7). ``cli/setup_wizard.py`` has always written
|
|
97
|
+
``code_graph_config.json`` with ``enabled`` and ``bridge_enabled``, and
|
|
98
|
+
until now **nothing read it**. There was no loader on this class at all,
|
|
99
|
+
and every call site constructed ``CodeGraphConfig(enabled=True)`` with
|
|
100
|
+
hardcoded defaults. So a user could answer "yes, enable the code graph"
|
|
101
|
+
in setup, get ``bridge_enabled: true`` written to disk, and have it
|
|
102
|
+
affect nothing — silently, with no way to tell from the outside.
|
|
103
|
+
|
|
104
|
+
Unknown keys in the file are ignored rather than raising: the file is
|
|
105
|
+
user-editable, and a stray key should not stop the code graph from
|
|
106
|
+
loading. Malformed JSON falls back to defaults with a warning, because
|
|
107
|
+
failing closed here would disable a working code graph over a typo.
|
|
108
|
+
"""
|
|
109
|
+
import json
|
|
110
|
+
import logging
|
|
111
|
+
|
|
112
|
+
data: dict[str, object] = {}
|
|
113
|
+
path = state_path("code_graph_config.json")
|
|
114
|
+
try:
|
|
115
|
+
if path.exists():
|
|
116
|
+
loaded = json.loads(path.read_text(encoding="utf-8"))
|
|
117
|
+
if isinstance(loaded, dict):
|
|
118
|
+
data = loaded
|
|
119
|
+
else:
|
|
120
|
+
logging.getLogger(__name__).warning(
|
|
121
|
+
"%s does not contain a JSON object; using defaults", path,
|
|
122
|
+
)
|
|
123
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
124
|
+
logging.getLogger(__name__).warning(
|
|
125
|
+
"could not read %s (%s); using defaults", path, exc,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
data.update(overrides)
|
|
129
|
+
|
|
130
|
+
valid = {f.name for f in fields(cls)}
|
|
131
|
+
unknown = sorted(set(data) - valid)
|
|
132
|
+
if unknown:
|
|
133
|
+
logging.getLogger(__name__).debug(
|
|
134
|
+
"ignoring unknown code_graph config keys: %s", ", ".join(unknown),
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
kwargs = {k: v for k, v in data.items() if k in valid}
|
|
138
|
+
|
|
139
|
+
# JSON has no frozenset/Path; coerce the fields that need it.
|
|
140
|
+
if isinstance(kwargs.get("languages"), list):
|
|
141
|
+
kwargs["languages"] = frozenset(kwargs["languages"])
|
|
142
|
+
if isinstance(kwargs.get("exclude_dirs"), list):
|
|
143
|
+
kwargs["exclude_dirs"] = frozenset(kwargs["exclude_dirs"])
|
|
144
|
+
for key in ("repo_root", "db_path"):
|
|
145
|
+
if isinstance(kwargs.get(key), str):
|
|
146
|
+
kwargs[key] = Path(kwargs[key])
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
return cls(**kwargs) # type: ignore[arg-type]
|
|
150
|
+
except TypeError as exc:
|
|
151
|
+
logging.getLogger(__name__).warning(
|
|
152
|
+
"code_graph config rejected (%s); using defaults", exc,
|
|
153
|
+
)
|
|
154
|
+
return cls()
|
|
@@ -76,3 +76,20 @@ class BaseExtractor(ABC):
|
|
|
76
76
|
import_edges, import_map = self.extract_imports()
|
|
77
77
|
call_edges = self.extract_calls(import_map)
|
|
78
78
|
return (classes + functions, import_edges + call_edges)
|
|
79
|
+
|
|
80
|
+
def extract_with_import_map(
|
|
81
|
+
self,
|
|
82
|
+
) -> tuple[list[GraphNode], list[GraphEdge], dict[str, tuple[str, str]]]:
|
|
83
|
+
"""Like extract() but also returns the per-file import map.
|
|
84
|
+
|
|
85
|
+
The import map has the form {local_name: (module_path, imported_name)}
|
|
86
|
+
and is needed by ImportResolver.resolve_call_targets (Strategy 1).
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
(nodes, edges, import_map)
|
|
90
|
+
"""
|
|
91
|
+
classes = self.extract_classes()
|
|
92
|
+
functions = self.extract_functions()
|
|
93
|
+
import_edges, import_map = self.extract_imports()
|
|
94
|
+
call_edges = self.extract_calls(import_map)
|
|
95
|
+
return (classes + functions, import_edges + call_edges, import_map)
|
|
@@ -70,19 +70,196 @@ class GraphStore:
|
|
|
70
70
|
4. Insert new edges
|
|
71
71
|
5. Upsert file record
|
|
72
72
|
|
|
73
|
-
|
|
73
|
+
Fix B — Defensive pre-filter:
|
|
74
|
+
Before delegating to the DB, drop any edge whose source_node_id or
|
|
75
|
+
target_node_id is absent from *both* the local node set AND the
|
|
76
|
+
database. This guards the incremental update path (update_code_graph
|
|
77
|
+
→ parse_file → here) which does not run the parse_all resolver
|
|
78
|
+
pipeline. After a full build, parse_all's resolution pass should make
|
|
79
|
+
this a no-op; the filter is a belt-and-braces against partial runs.
|
|
80
|
+
|
|
81
|
+
DO NOT fix by disabling the FK — the FK is correct and protects graph
|
|
82
|
+
integrity.
|
|
74
83
|
"""
|
|
84
|
+
# ── build valid-id set: local nodes ──────────────────────────────
|
|
85
|
+
#
|
|
86
|
+
# REPLACE semantics: `store_file_parse_results` uses INSERT OR REPLACE.
|
|
87
|
+
# graph_nodes has a UNIQUE constraint on `qualified_name`. When two
|
|
88
|
+
# nodes share a qualified_name (e.g. a @property getter AND setter, or
|
|
89
|
+
# a function defined twice in source), the LATER insert wins — the
|
|
90
|
+
# EARLIER node is deleted via REPLACE, and ON DELETE CASCADE removes
|
|
91
|
+
# any edges that already referenced it. We simulate this here: only
|
|
92
|
+
# the LAST node per qualified_name survives; edges referencing the
|
|
93
|
+
# earlier "losers" must be dropped before we reach the DB.
|
|
94
|
+
qn_last: dict[str, GraphNode] = {}
|
|
95
|
+
for n in nodes:
|
|
96
|
+
qn_last[n.qualified_name] = n # last occurrence wins
|
|
97
|
+
surviving_ids = {n.node_id for n in qn_last.values()}
|
|
98
|
+
|
|
99
|
+
# Collect foreign endpoints (not satisfied locally) for a single
|
|
100
|
+
# batch DB check — avoids N+1 queries.
|
|
101
|
+
foreign_ids: set[str] = set()
|
|
102
|
+
for edge in edges:
|
|
103
|
+
if edge.source_node_id not in surviving_ids:
|
|
104
|
+
foreign_ids.add(edge.source_node_id)
|
|
105
|
+
if edge.target_node_id not in surviving_ids:
|
|
106
|
+
foreign_ids.add(edge.target_node_id)
|
|
107
|
+
|
|
108
|
+
db_ids: set[str] = set()
|
|
109
|
+
if foreign_ids:
|
|
110
|
+
placeholders = ",".join("?" * len(foreign_ids))
|
|
111
|
+
rows = self._db.execute(
|
|
112
|
+
f"SELECT node_id FROM graph_nodes WHERE node_id IN ({placeholders})",
|
|
113
|
+
tuple(foreign_ids),
|
|
114
|
+
)
|
|
115
|
+
db_ids = {row["node_id"] for row in rows}
|
|
116
|
+
|
|
117
|
+
valid_ids = surviving_ids | db_ids
|
|
118
|
+
|
|
119
|
+
safe_edges: list[GraphEdge] = []
|
|
120
|
+
dropped = 0
|
|
121
|
+
for edge in edges:
|
|
122
|
+
if edge.source_node_id in valid_ids and edge.target_node_id in valid_ids:
|
|
123
|
+
safe_edges.append(edge)
|
|
124
|
+
else:
|
|
125
|
+
dropped += 1
|
|
126
|
+
logger.debug(
|
|
127
|
+
"store_file_nodes_edges: dropped dangling edge %s→%s "
|
|
128
|
+
"for file %s (resolver may not have run on this path)",
|
|
129
|
+
edge.source_node_id, edge.target_node_id, file_path,
|
|
130
|
+
)
|
|
131
|
+
if dropped:
|
|
132
|
+
logger.debug(
|
|
133
|
+
"store_file_nodes_edges: total %d dangling edge(s) dropped for %s",
|
|
134
|
+
dropped, file_path,
|
|
135
|
+
)
|
|
136
|
+
|
|
75
137
|
self._db.store_file_parse_results(
|
|
76
138
|
file_path,
|
|
77
139
|
list(nodes),
|
|
78
|
-
|
|
140
|
+
safe_edges,
|
|
79
141
|
file_record,
|
|
80
142
|
)
|
|
81
143
|
logger.debug(
|
|
82
144
|
"Stored %d nodes, %d edges for %s",
|
|
83
|
-
len(nodes), len(
|
|
145
|
+
len(nodes), len(safe_edges), file_path,
|
|
84
146
|
)
|
|
85
147
|
|
|
148
|
+
def commit_build_batch(
|
|
149
|
+
self,
|
|
150
|
+
batch: list[tuple[str, list[GraphNode], list[GraphEdge], FileRecord]],
|
|
151
|
+
) -> None:
|
|
152
|
+
"""Two-phase bulk commit — order-independent storage for full builds.
|
|
153
|
+
|
|
154
|
+
The single-file ``store_file_nodes_edges`` is insertion-order-
|
|
155
|
+
dependent: when a caller file (a.py, has CALLS foo→bar) is stored
|
|
156
|
+
before the callee file (b.py, defines bar), the cross-file CALLS
|
|
157
|
+
edge is dropped because Fix-B's DB existence check for bar.node_id
|
|
158
|
+
fails (b.py has not been stored yet).
|
|
159
|
+
|
|
160
|
+
This method fixes the root cause by separating commits into two
|
|
161
|
+
phases, both executed in a single atomic transaction:
|
|
162
|
+
|
|
163
|
+
**Phase 1 — all nodes**: for every file in the batch, delete old
|
|
164
|
+
data and insert new nodes. After Phase 1, every node_id from every
|
|
165
|
+
file in the batch is present in ``graph_nodes``.
|
|
166
|
+
|
|
167
|
+
**Phase 2 — all edges**: for every file, validate endpoints (the DB
|
|
168
|
+
existence check now finds callee nodes regardless of file order) and
|
|
169
|
+
insert qualifying edges.
|
|
170
|
+
|
|
171
|
+
Design pattern: *Separated Phases*. Interleaved per-file commits
|
|
172
|
+
(the existing loop) are O(1) transaction boundaries but
|
|
173
|
+
insertion-order-dependent. Separated phases add one extra pass but
|
|
174
|
+
are fully order-independent.
|
|
175
|
+
|
|
176
|
+
Use this for full builds (``build_code_graph``). Single-file
|
|
177
|
+
updates (``update_code_graph``) continue to use
|
|
178
|
+
``store_file_nodes_edges`` — the callee nodes from other files are
|
|
179
|
+
already in the DB from the previous build, so no ordering issue.
|
|
180
|
+
"""
|
|
181
|
+
if not batch:
|
|
182
|
+
return
|
|
183
|
+
|
|
184
|
+
with self._db.transaction():
|
|
185
|
+
# ── Phase 1: delete old data + insert all new nodes ───────────
|
|
186
|
+
#
|
|
187
|
+
# Edges must be deleted before nodes (FK constraint prevents
|
|
188
|
+
# deleting a node that an edge still references). We delete ALL
|
|
189
|
+
# file edges across the whole batch first, then delete nodes.
|
|
190
|
+
# This avoids cascade surprises when file A's nodes are deleted
|
|
191
|
+
# before file B's edges that target those nodes are cleaned up.
|
|
192
|
+
for fp, _, _, _ in batch:
|
|
193
|
+
self._db.delete_edges_by_file(fp)
|
|
194
|
+
for fp, nodes, _, fr in batch:
|
|
195
|
+
self._db.delete_nodes_by_file(fp)
|
|
196
|
+
for node in nodes:
|
|
197
|
+
self._db.upsert_node(node)
|
|
198
|
+
self._db.upsert_file_record(fr)
|
|
199
|
+
|
|
200
|
+
# ── Phase 2: validate + insert all edges ──────────────────────
|
|
201
|
+
#
|
|
202
|
+
# All node_ids from Phase 1 are now in graph_nodes, so the
|
|
203
|
+
# batch-DB check for cross-file foreign endpoints succeeds
|
|
204
|
+
# regardless of which file was stored first.
|
|
205
|
+
total_dropped = 0
|
|
206
|
+
for fp, nodes, edges, _ in batch:
|
|
207
|
+
# Simulate INSERT OR REPLACE dedup: only the last node per
|
|
208
|
+
# qualified_name survives; edges referencing earlier losers
|
|
209
|
+
# must be pre-dropped (mirrors store_file_nodes_edges logic).
|
|
210
|
+
qn_last: dict[str, GraphNode] = {}
|
|
211
|
+
for n in nodes:
|
|
212
|
+
qn_last[n.qualified_name] = n
|
|
213
|
+
surviving_ids = {n.node_id for n in qn_last.values()}
|
|
214
|
+
|
|
215
|
+
# Batch-check foreign endpoints against DB (avoids N+1).
|
|
216
|
+
foreign_ids: set[str] = set()
|
|
217
|
+
for edge in edges:
|
|
218
|
+
if edge.source_node_id not in surviving_ids:
|
|
219
|
+
foreign_ids.add(edge.source_node_id)
|
|
220
|
+
if edge.target_node_id not in surviving_ids:
|
|
221
|
+
foreign_ids.add(edge.target_node_id)
|
|
222
|
+
|
|
223
|
+
db_ids: set[str] = set()
|
|
224
|
+
if foreign_ids:
|
|
225
|
+
placeholders = ",".join("?" * len(foreign_ids))
|
|
226
|
+
rows = self._db.execute(
|
|
227
|
+
f"SELECT node_id FROM graph_nodes "
|
|
228
|
+
f"WHERE node_id IN ({placeholders})",
|
|
229
|
+
tuple(foreign_ids),
|
|
230
|
+
)
|
|
231
|
+
db_ids = {row["node_id"] for row in rows}
|
|
232
|
+
|
|
233
|
+
valid_ids = surviving_ids | db_ids
|
|
234
|
+
dropped = 0
|
|
235
|
+
for edge in edges:
|
|
236
|
+
if (
|
|
237
|
+
edge.source_node_id in valid_ids
|
|
238
|
+
and edge.target_node_id in valid_ids
|
|
239
|
+
):
|
|
240
|
+
self._db.upsert_edge(edge)
|
|
241
|
+
else:
|
|
242
|
+
dropped += 1
|
|
243
|
+
logger.debug(
|
|
244
|
+
"commit_build_batch: dropped dangling edge "
|
|
245
|
+
"%s→%s for %s",
|
|
246
|
+
edge.source_node_id, edge.target_node_id, fp,
|
|
247
|
+
)
|
|
248
|
+
if dropped:
|
|
249
|
+
total_dropped += dropped
|
|
250
|
+
logger.debug(
|
|
251
|
+
"commit_build_batch: %d dangling edge(s) dropped for %s",
|
|
252
|
+
dropped, fp,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
if total_dropped:
|
|
256
|
+
logger.debug(
|
|
257
|
+
"commit_build_batch: total %d dangling edges dropped across "
|
|
258
|
+
"batch of %d files",
|
|
259
|
+
total_dropped, len(batch),
|
|
260
|
+
)
|
|
261
|
+
logger.debug("commit_build_batch: committed %d files", len(batch))
|
|
262
|
+
|
|
86
263
|
def remove_file(self, file_path: str) -> None:
|
|
87
264
|
"""Remove all graph data for *file_path*.
|
|
88
265
|
|