superlocalmemory 4.0.4 → 4.0.6
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 +90 -0
- package/README.md +23 -14
- package/ide/configs/codex-mcp.toml +2 -2
- package/package.json +3 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.mcp.json +1 -0
- 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 +3 -2
- 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 +2 -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 +6 -5
- package/plugin-src/skills/slm-graph/SKILL.md +2 -1
- package/plugin-src/skills/slm-profile/SKILL.md +1 -0
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/access/rbac.py +106 -0
- package/src/superlocalmemory/brain/__init__.py +5 -0
- package/src/superlocalmemory/brain/truth.py +418 -0
- package/src/superlocalmemory/cli/__main__.py +17 -0
- package/src/superlocalmemory/cli/commands.py +96 -28
- package/src/superlocalmemory/cli/gdpr_cmd.py +779 -0
- package/src/superlocalmemory/cli/gdpr_io.py +109 -0
- package/src/superlocalmemory/cli/main.py +88 -0
- 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/context_cache.py +58 -1
- package/src/superlocalmemory/core/engine_wiring.py +5 -1
- package/src/superlocalmemory/core/maintenance.py +43 -1
- package/src/superlocalmemory/core/mutations.py +155 -25
- package/src/superlocalmemory/core/recall_pipeline.py +6 -10
- package/src/superlocalmemory/core/recall_worker.py +33 -12
- package/src/superlocalmemory/core/remember_runtime.py +271 -2
- package/src/superlocalmemory/core/store_pipeline.py +100 -38
- package/src/superlocalmemory/encoding/consolidator.py +17 -47
- package/src/superlocalmemory/encoding/temporal_validator.py +14 -18
- package/src/superlocalmemory/hooks/user_prompt_hook.py +1 -1
- package/src/superlocalmemory/infra/backup.py +138 -0
- package/src/superlocalmemory/infra/backup_obligations.py +423 -0
- package/src/superlocalmemory/integrations/bounded_loops_mcp.py +4 -3
- package/src/superlocalmemory/learning/engagement.py +165 -0
- package/src/superlocalmemory/mcp/profiles.py +19 -7
- package/src/superlocalmemory/mcp/server.py +4 -2
- package/src/superlocalmemory/mcp/tools_brain.py +54 -10
- package/src/superlocalmemory/mcp/tools_code_graph.py +31 -4
- package/src/superlocalmemory/mcp/tools_core.py +88 -3
- package/src/superlocalmemory/mcp/tools_v3.py +20 -6
- package/src/superlocalmemory/retrieval/engine.py +28 -10
- package/src/superlocalmemory/retrieval/remote_reranker.py +108 -11
- package/src/superlocalmemory/retrieval/temporal_validity_filter.py +119 -19
- package/src/superlocalmemory/server/routes/brain.py +297 -14
- package/src/superlocalmemory/server/routes/learning.py +13 -25
- package/src/superlocalmemory/server/routes/memories.py +129 -3
- package/src/superlocalmemory/server/routes/v3_api.py +171 -60
- package/src/superlocalmemory/storage/_migration_internals.py +4 -0
- package/src/superlocalmemory/storage/_schema_version.py +2 -2
- package/src/superlocalmemory/storage/correction_cases.py +670 -0
- package/src/superlocalmemory/storage/database.py +230 -24
- package/src/superlocalmemory/storage/migration_runner.py +7 -0
- package/src/superlocalmemory/storage/migrations/M042_correction_case_ledger.py +245 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +2 -0
- package/src/superlocalmemory/storage/models.py +12 -4
- package/src/superlocalmemory/storage/write_coordinator.py +4 -0
- 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 +28 -11
- package/src/superlocalmemory/ui/js/brain.js +43 -7
- package/src/superlocalmemory/ui/js/od-agents.js +49 -5
- package/src/superlocalmemory/ui/js/od-brain.js +280 -84
- package/src/superlocalmemory/ui/js/od-graph.js +147 -6
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V4 | https://qualixar.com | https://varunpratap.com
|
|
4
|
+
|
|
5
|
+
"""Shared I/O helpers and compliance constants for ``slm gdpr`` subcommands.
|
|
6
|
+
|
|
7
|
+
Separated from gdpr_cmd.py to honour the 800-line file cap (coding-style.md).
|
|
8
|
+
All symbols here are internal (prefixed ``_``) or ALL_CAPS constants.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json as _json
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
20
|
+
# Output helpers
|
|
21
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
def _die(message: str, code: int = 1) -> None:
|
|
24
|
+
"""Print an actionable error to stderr and exit non-zero."""
|
|
25
|
+
print(f"error: {message}", file=sys.stderr)
|
|
26
|
+
sys.exit(code)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _print_json(data: Any) -> None:
|
|
30
|
+
"""Emit pretty-printed JSON to stdout (evidence-pipeline format)."""
|
|
31
|
+
print(_json.dumps(data, indent=2, default=str))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _json_envelope(
|
|
35
|
+
command: str, *, data: dict | None = None, error: dict | None = None
|
|
36
|
+
) -> dict:
|
|
37
|
+
"""Build the standard agent-native JSON envelope used across all SLM CLIs."""
|
|
38
|
+
from superlocalmemory.cli.json_output import _get_version
|
|
39
|
+
|
|
40
|
+
env: dict = {
|
|
41
|
+
"success": error is None,
|
|
42
|
+
"command": command,
|
|
43
|
+
"version": _get_version(),
|
|
44
|
+
}
|
|
45
|
+
if error is not None:
|
|
46
|
+
env["error"] = error
|
|
47
|
+
else:
|
|
48
|
+
env["data"] = data if data is not None else {}
|
|
49
|
+
return env
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
53
|
+
# Path helpers
|
|
54
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
def _data_root() -> Path:
|
|
57
|
+
"""Resolve the canonical SLM data root (honours SLM_DATA_DIR env var)."""
|
|
58
|
+
from superlocalmemory.infra.data_root import canonical_data_root
|
|
59
|
+
|
|
60
|
+
return canonical_data_root()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _db_path() -> Path:
|
|
64
|
+
return _data_root() / "memory.db"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _audit_chain_path() -> Path:
|
|
68
|
+
return _data_root() / "audit_chain.db"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
72
|
+
# GDPR compliance constants (DPO-facing, surfaced in ``status`` output)
|
|
73
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
# Known gaps in this release — reported honestly so a DPO can plan remediation.
|
|
76
|
+
# Gaps C1 and C2 are tracked as open items in the Wave 3 backlog.
|
|
77
|
+
KNOWN_GAPS = [
|
|
78
|
+
{
|
|
79
|
+
"ref": "C1",
|
|
80
|
+
"summary": "backups/ directory is outside erasure scope",
|
|
81
|
+
"detail": (
|
|
82
|
+
"Up to 10 rotating snapshots of memory.db, learning.db and "
|
|
83
|
+
"related DB files survive an Art.17 erasure. The erasure receipt "
|
|
84
|
+
"records a 'backup_obligation' so the obligation is tracked, but "
|
|
85
|
+
"the snapshots are not automatically purged. Snapshots are "
|
|
86
|
+
"re-erased on restore."
|
|
87
|
+
),
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
"ref": "C2",
|
|
91
|
+
"summary": "code_graph.db is not in erasure scope",
|
|
92
|
+
"detail": (
|
|
93
|
+
"Repository paths, file names and symbol names (identifying data "
|
|
94
|
+
"in a work context) are stored in code_graph.db. This file is "
|
|
95
|
+
"covered by compliance/gdpr.py but the per-table discovery "
|
|
96
|
+
"operates on the main memory.db only."
|
|
97
|
+
),
|
|
98
|
+
},
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
# Art. coverage flags that compliance/gdpr.py actively implements.
|
|
102
|
+
ART_COVERAGE = {
|
|
103
|
+
"art15_right_to_access": True,
|
|
104
|
+
"art17_right_to_erasure": True,
|
|
105
|
+
"art20_right_to_portability": True,
|
|
106
|
+
"backup_scope": "outstanding_obligation_recorded_on_receipt",
|
|
107
|
+
"audit_chain": "HMAC-chained tamper-evident",
|
|
108
|
+
"erasure_fails_closed": True,
|
|
109
|
+
}
|
|
@@ -88,6 +88,8 @@ _NO_DAEMON_COMMANDS = {
|
|
|
88
88
|
"help",
|
|
89
89
|
# Lifecycle orchestration must run before any global auto-start hook.
|
|
90
90
|
"serve", "restart",
|
|
91
|
+
# V4.0.6: GDPR CLI accesses the DB directly; no daemon required.
|
|
92
|
+
"gdpr",
|
|
91
93
|
}
|
|
92
94
|
|
|
93
95
|
|
|
@@ -484,6 +486,18 @@ def main() -> None:
|
|
|
484
486
|
update_p.add_argument("content", help="New content for the memory")
|
|
485
487
|
update_p.add_argument("--json", action="store_true", help="Output structured JSON (agent-native)")
|
|
486
488
|
|
|
489
|
+
correction_p = sub.add_parser(
|
|
490
|
+
"review-correction", help="Apply, reject, or roll back a reviewed correction case"
|
|
491
|
+
)
|
|
492
|
+
correction_p.add_argument("case_id", help="Correction case ID returned by slm update")
|
|
493
|
+
correction_p.add_argument("action", choices=("apply", "reject", "rollback"))
|
|
494
|
+
correction_p.add_argument("expected_version", type=int, help="Current case version (CAS guard)")
|
|
495
|
+
correction_p.add_argument(
|
|
496
|
+
"--event-valid-until",
|
|
497
|
+
help="Optional reviewer-approved RFC3339 event-time boundary (apply only)",
|
|
498
|
+
)
|
|
499
|
+
correction_p.add_argument("--json", action="store_true", help="Output structured JSON (agent-native)")
|
|
500
|
+
|
|
487
501
|
list_p = sub.add_parser("list", help="List recent memories chronologically (shows IDs for delete/update)")
|
|
488
502
|
list_p.add_argument(
|
|
489
503
|
"--limit", "-n", type=int, default=20, help="Number of entries (default 20)",
|
|
@@ -954,6 +968,80 @@ def main() -> None:
|
|
|
954
968
|
_sp.add_argument("--json", action="store_true",
|
|
955
969
|
help="Output structured JSON (agent-native)")
|
|
956
970
|
|
|
971
|
+
# Wave-3 / V4.0.6: GDPR subject-rights CLI (Art.15/17/20)
|
|
972
|
+
gdpr_p = sub.add_parser(
|
|
973
|
+
"gdpr",
|
|
974
|
+
help="GDPR subject rights: status, export (Art.15/20), erase (Art.17), verify",
|
|
975
|
+
)
|
|
976
|
+
gdpr_sub = gdpr_p.add_subparsers(dest="gdpr_command", title="gdpr subcommands")
|
|
977
|
+
|
|
978
|
+
gdpr_status_p = gdpr_sub.add_parser(
|
|
979
|
+
"status",
|
|
980
|
+
help="Compliance posture: receipts, audit events, known gaps (read-only)",
|
|
981
|
+
)
|
|
982
|
+
gdpr_status_p.add_argument(
|
|
983
|
+
"--profile", default=None, metavar="PROFILE",
|
|
984
|
+
help="Scope to a specific profile (default: report all)",
|
|
985
|
+
)
|
|
986
|
+
gdpr_status_p.add_argument(
|
|
987
|
+
"--json", action="store_true", help="Output structured JSON (agent-native)",
|
|
988
|
+
)
|
|
989
|
+
|
|
990
|
+
gdpr_export_p = gdpr_sub.add_parser(
|
|
991
|
+
"export",
|
|
992
|
+
help="Art.15/20 subject access / data portability export",
|
|
993
|
+
)
|
|
994
|
+
gdpr_export_p.add_argument(
|
|
995
|
+
"--profile", required=True, metavar="PROFILE",
|
|
996
|
+
help="Profile to export (required)",
|
|
997
|
+
)
|
|
998
|
+
gdpr_export_p.add_argument(
|
|
999
|
+
"--output", default=None, metavar="FILE",
|
|
1000
|
+
help="Write export JSON to FILE (default: stdout)",
|
|
1001
|
+
)
|
|
1002
|
+
gdpr_export_p.add_argument(
|
|
1003
|
+
"--json", action="store_true", help="Output structured JSON envelope",
|
|
1004
|
+
)
|
|
1005
|
+
|
|
1006
|
+
gdpr_erase_p = gdpr_sub.add_parser(
|
|
1007
|
+
"erase",
|
|
1008
|
+
help=(
|
|
1009
|
+
"Art.17 right to erasure — IRREVERSIBLE. "
|
|
1010
|
+
"Requires --profile PROFILE AND --yes."
|
|
1011
|
+
),
|
|
1012
|
+
)
|
|
1013
|
+
gdpr_erase_p.add_argument(
|
|
1014
|
+
"--profile", default=None, metavar="PROFILE",
|
|
1015
|
+
help="Profile to erase (required for live erasure)",
|
|
1016
|
+
)
|
|
1017
|
+
gdpr_erase_p.add_argument(
|
|
1018
|
+
"--yes", action="store_true",
|
|
1019
|
+
help="Confirm irreversible erasure (required together with --profile)",
|
|
1020
|
+
)
|
|
1021
|
+
gdpr_erase_p.add_argument(
|
|
1022
|
+
"--dry-run", action="store_true", dest="dry_run",
|
|
1023
|
+
help="Preview what would be erased without deleting anything",
|
|
1024
|
+
)
|
|
1025
|
+
gdpr_erase_p.add_argument(
|
|
1026
|
+
"--json", action="store_true", help="Output structured JSON",
|
|
1027
|
+
)
|
|
1028
|
+
|
|
1029
|
+
gdpr_verify_p = gdpr_sub.add_parser(
|
|
1030
|
+
"verify",
|
|
1031
|
+
help="Verify HMAC integrity of an erasure receipt (exit 0=ok, 1=tampered, 2=not-found)",
|
|
1032
|
+
)
|
|
1033
|
+
gdpr_verify_p.add_argument(
|
|
1034
|
+
"--receipt-id", dest="receipt_id", default=None, metavar="ID",
|
|
1035
|
+
help="Erasure receipt ID to verify",
|
|
1036
|
+
)
|
|
1037
|
+
gdpr_verify_p.add_argument(
|
|
1038
|
+
"--profile", default=None, metavar="PROFILE",
|
|
1039
|
+
help="Scope receipt lookup to a specific profile",
|
|
1040
|
+
)
|
|
1041
|
+
gdpr_verify_p.add_argument(
|
|
1042
|
+
"--json", action="store_true", help="Output structured JSON",
|
|
1043
|
+
)
|
|
1044
|
+
|
|
957
1045
|
# Wave-3: operational recovery & admin remediation
|
|
958
1046
|
ops_p = sub.add_parser(
|
|
959
1047
|
"ops",
|
|
@@ -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
|
|