adl-toolkit 0.0.0-stage → 0.1.3
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/INSTALL_TOOLKIT.md +163 -0
- package/README.md +269 -2
- package/adl_drift/__init__.py +6 -0
- package/adl_drift/cli/__init__.py +1 -0
- package/adl_drift/cli/commands.py +993 -0
- package/adl_drift/cli/main.py +744 -0
- package/adl_drift/cortex/__init__.py +1 -0
- package/adl_drift/cortex/embeddings.py +96 -0
- package/adl_drift/cortex/memory.py +1749 -0
- package/adl_drift/cortex/patterns.py +89 -0
- package/adl_drift/cortex/telemetry.py +192 -0
- package/adl_drift/detectors/__init__.py +1 -0
- package/adl_drift/detectors/async_patterns.py +115 -0
- package/adl_drift/detectors/base.py +121 -0
- package/adl_drift/detectors/config.py +122 -0
- package/adl_drift/detectors/data_access.py +128 -0
- package/adl_drift/detectors/decorators.py +122 -0
- package/adl_drift/detectors/dynamic_patterns.py +260 -0
- package/adl_drift/detectors/errors.py +134 -0
- package/adl_drift/detectors/imports.py +132 -0
- package/adl_drift/detectors/logging_patterns.py +108 -0
- package/adl_drift/detectors/routes.py +257 -0
- package/adl_drift/detectors/security.py +236 -0
- package/adl_drift/detectors/taint.py +406 -0
- package/adl_drift/detectors/tests.py +95 -0
- package/adl_drift/detectors/type_conventions.py +81 -0
- package/adl_drift/graph/__init__.py +1 -0
- package/adl_drift/graph/builder.py +2670 -0
- package/adl_drift/graph/cfg.py +461 -0
- package/adl_drift/graph/cluster.py +40 -0
- package/adl_drift/graph/edges.py +86 -0
- package/adl_drift/graph/node.py +90 -0
- package/adl_drift/harness/__init__.py +10 -0
- package/adl_drift/harness/setup.py +832 -0
- package/adl_drift/harness/skills/adl-drift.md +25 -0
- package/adl_drift/harness/skills/drift-antigravity.md +19 -0
- package/adl_drift/harness/skills/drift-generic.md +19 -0
- package/adl_drift/harness/watch.py +84 -0
- package/adl_drift/lang/__init__.py +8 -0
- package/adl_drift/lang/base.py +464 -0
- package/adl_drift/lang/java/__init__.py +6 -0
- package/adl_drift/lang/java/backend.py +97 -0
- package/adl_drift/lang/java/cfg.py +1424 -0
- package/adl_drift/lang/java/config.py +707 -0
- package/adl_drift/lang/java/detectors/__init__.py +29 -0
- package/adl_drift/lang/java/detectors/additional.py +445 -0
- package/adl_drift/lang/java/detectors/base.py +154 -0
- package/adl_drift/lang/java/detectors/data_access.py +148 -0
- package/adl_drift/lang/java/detectors/imports.py +53 -0
- package/adl_drift/lang/java/detectors/routes.py +159 -0
- package/adl_drift/lang/java/detectors/security.py +386 -0
- package/adl_drift/lang/java/detectors/tests.py +74 -0
- package/adl_drift/lang/java/extractor.py +582 -0
- package/adl_drift/lang/java/ids.py +99 -0
- package/adl_drift/lang/java/parser.py +49 -0
- package/adl_drift/lang/java/resolution/__init__.py +1 -0
- package/adl_drift/lang/java/resolution/constants.py +1619 -0
- package/adl_drift/lang/java/resolution/constructors.py +278 -0
- package/adl_drift/lang/java/resolution/context.py +305 -0
- package/adl_drift/lang/java/resolution/dispatch.py +540 -0
- package/adl_drift/lang/java/resolution/functional.py +1318 -0
- package/adl_drift/lang/java/resolution/generated.py +95 -0
- package/adl_drift/lang/java/resolution/model.py +153 -0
- package/adl_drift/lang/java/resolution/orchestration.py +866 -0
- package/adl_drift/lang/java/resolution/overloads.py +1275 -0
- package/adl_drift/lang/java/resolution/scopes.py +1111 -0
- package/adl_drift/lang/java/resolution/types.py +2947 -0
- package/adl_drift/lang/java/resolver.py +1401 -0
- package/adl_drift/lang/java/scopes.py +69 -0
- package/adl_drift/lang/java/taint.py +500 -0
- package/adl_drift/lang/java/type_provenance.py +342 -0
- package/adl_drift/lang/python/__init__.py +8 -0
- package/adl_drift/lang/python/backend.py +94 -0
- package/adl_drift/lang/python/cfg.py +537 -0
- package/adl_drift/lang/python/extractor.py +454 -0
- package/adl_drift/lang/python/parser.py +41 -0
- package/adl_drift/lang/python/resolver.py +1789 -0
- package/adl_drift/lang/python/taint.py +365 -0
- package/adl_drift/lang/registry.py +117 -0
- package/adl_drift/parser/__init__.py +1 -0
- package/adl_drift/parser/cache.py +38 -0
- package/adl_drift/parser/core.py +33 -0
- package/adl_drift/parser/extractor.py +17 -0
- package/adl_drift/parser/literal_eval.py +326 -0
- package/adl_drift/scanner/__init__.py +1 -0
- package/adl_drift/scanner/discovery.py +338 -0
- package/adl_drift/scanner/incremental.py +261 -0
- package/adl_drift/scanner/parallel.py +224 -0
- package/adl_drift/server/__init__.py +1 -0
- package/adl_drift/server/handlers.py +2849 -0
- package/adl_drift/server/mcp_server.py +127 -0
- package/adl_drift/server/mcp_stdio.py +335 -0
- package/adl_drift/server/protocol.py +71 -0
- package/adl_drift/server/static/index.html +696 -0
- package/adl_drift/server/web.py +350 -0
- package/adl_drift/utils/__init__.py +1 -0
- package/adl_drift/utils/config.py +77 -0
- package/adl_drift/utils/fswalk.py +52 -0
- package/adl_drift/utils/types.py +345 -0
- package/bin/adl-drift.mjs +35 -0
- package/bin/adl-toolkit.mjs +488 -0
- package/package.json +29 -4
- package/pyproject.toml +49 -0
- package/scripts/install_toolkit.py +3584 -0
|
@@ -0,0 +1,1749 @@
|
|
|
1
|
+
"""Cortex V2 memory layer.
|
|
2
|
+
|
|
3
|
+
Persistent storage for approved patterns, tribal knowledge, and scan history.
|
|
4
|
+
Uses SQLite for structured data and vector embeddings for semantic search.
|
|
5
|
+
|
|
6
|
+
Equivalent to drift_core/src/cortex.rs.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import contextlib
|
|
12
|
+
import functools
|
|
13
|
+
import gc
|
|
14
|
+
import importlib.util
|
|
15
|
+
import os
|
|
16
|
+
import pickle
|
|
17
|
+
import sqlite3
|
|
18
|
+
import sys
|
|
19
|
+
import json
|
|
20
|
+
import hashlib
|
|
21
|
+
import time
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Optional, List, Dict, Any, Iterable, Tuple
|
|
24
|
+
from dataclasses import asdict
|
|
25
|
+
from datetime import datetime
|
|
26
|
+
|
|
27
|
+
from adl_drift.utils.types import Pattern, ProjectContext
|
|
28
|
+
from adl_drift.graph.node import GraphNode, NodeType
|
|
29
|
+
from adl_drift.graph.edges import GraphEdge, EdgeType
|
|
30
|
+
from adl_drift.graph.builder import CallGraphBuilder, GRAPH_SCHEMA_VERSION
|
|
31
|
+
|
|
32
|
+
# numpy is only needed by the optional embedding paths, but importing it at
|
|
33
|
+
# module load costs ~0.2s on every CLI invocation; import it on first use.
|
|
34
|
+
HAS_NUMPY = importlib.util.find_spec("numpy") is not None
|
|
35
|
+
|
|
36
|
+
@contextlib.contextmanager
|
|
37
|
+
def _gc_paused():
|
|
38
|
+
was_enabled = gc.isenabled()
|
|
39
|
+
gc.disable()
|
|
40
|
+
try:
|
|
41
|
+
yield
|
|
42
|
+
finally:
|
|
43
|
+
if was_enabled:
|
|
44
|
+
gc.enable()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@functools.lru_cache(maxsize=None)
|
|
48
|
+
def _snapshot_code_key() -> str:
|
|
49
|
+
"""Identity of the code a pickled graph snapshot depends on.
|
|
50
|
+
|
|
51
|
+
A pickle silently mirrors the class layout that wrote it, so a snapshot
|
|
52
|
+
is only trusted while the graph package's own source, the interpreter,
|
|
53
|
+
networkx and the schema version are the ones that produced it — any
|
|
54
|
+
edit to those files (not just a GRAPH_SCHEMA_VERSION bump) falls back to
|
|
55
|
+
the row-by-row load, which then re-persists a fresh snapshot.
|
|
56
|
+
"""
|
|
57
|
+
import networkx
|
|
58
|
+
|
|
59
|
+
digest = hashlib.sha256()
|
|
60
|
+
digest.update(
|
|
61
|
+
f"{GRAPH_SCHEMA_VERSION}|{pickle.HIGHEST_PROTOCOL}|{sys.version_info[:2]}|"
|
|
62
|
+
f"{networkx.__version__}".encode()
|
|
63
|
+
)
|
|
64
|
+
for module in ("adl_drift.graph.builder", "adl_drift.graph.node", "adl_drift.graph.edges"):
|
|
65
|
+
digest.update(Path(sys.modules[module].__file__).read_bytes())
|
|
66
|
+
return digest.hexdigest()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# MCP_GRAPH_CACHE_PLAN.md Lớp 1: a source whose mtime lies within this many
|
|
70
|
+
# seconds of the fingerprint's stat time may have been rewritten with the
|
|
71
|
+
# same size/mtime, so a stat-based fingerprint match must be confirmed by a
|
|
72
|
+
# content-hash classification for such files.
|
|
73
|
+
GRAPH_CACHE_RACY_WINDOW_SECONDS = 2.0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _loads_metadata(raw: Optional[str]) -> Dict[str, Any]:
|
|
77
|
+
"""Decode persisted metadata; most rows carry none, so skip the parser."""
|
|
78
|
+
if not raw or raw == "{}":
|
|
79
|
+
return {}
|
|
80
|
+
return json.loads(raw)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class CortexMemory:
|
|
84
|
+
"""Persistent memory for the analysis engine.
|
|
85
|
+
|
|
86
|
+
Tables:
|
|
87
|
+
- patterns: Approved/detected patterns with metadata
|
|
88
|
+
- tribal_knowledge: Human-written rules and explanations
|
|
89
|
+
- scan_history: Past analysis results
|
|
90
|
+
- embeddings: Vector representations for semantic search
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
def __init__(
|
|
94
|
+
self,
|
|
95
|
+
db_path: Optional[Path] = None,
|
|
96
|
+
embedder: Optional[Any] = None,
|
|
97
|
+
):
|
|
98
|
+
if db_path is None:
|
|
99
|
+
# Default to platform-specific cache dir
|
|
100
|
+
import platformdirs
|
|
101
|
+
cache_dir = Path(platformdirs.user_cache_dir("adl-drift", "adl"))
|
|
102
|
+
db_path = cache_dir / "cortex.db"
|
|
103
|
+
|
|
104
|
+
self.db_path = Path(db_path)
|
|
105
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
# P2.3: anything with embed_single(text) -> 1-D float array works.
|
|
107
|
+
# None → lazily create an EmbeddingEngine iff sentence-transformers
|
|
108
|
+
# is installed (and DRIFT_EMBEDDINGS != 0); otherwise vector search
|
|
109
|
+
# stays off and search_similar uses the LIKE fallback.
|
|
110
|
+
self._embedder = embedder
|
|
111
|
+
self._embedder_resolved = embedder is not None
|
|
112
|
+
self._init_db()
|
|
113
|
+
|
|
114
|
+
@classmethod
|
|
115
|
+
def for_project(cls, project_root: Path) -> "CortexMemory":
|
|
116
|
+
project_root = Path(project_root)
|
|
117
|
+
return cls(project_root / ".adl-drift" / "cortex.db")
|
|
118
|
+
|
|
119
|
+
def _get_embedder(self) -> Optional[Any]:
|
|
120
|
+
"""P2.3: resolve the embedding engine once (lazy, optional)."""
|
|
121
|
+
if self._embedder_resolved:
|
|
122
|
+
return self._embedder
|
|
123
|
+
self._embedder_resolved = True
|
|
124
|
+
if not HAS_NUMPY or os.environ.get("DRIFT_EMBEDDINGS", "1") == "0":
|
|
125
|
+
return None
|
|
126
|
+
from adl_drift.cortex.embeddings import (
|
|
127
|
+
EmbeddingEngine, HAS_SENTENCE_TRANSFORMERS,
|
|
128
|
+
)
|
|
129
|
+
# Without the real model EmbeddingEngine returns *random* vectors —
|
|
130
|
+
# storing those would poison search. Only wire it when genuine.
|
|
131
|
+
# Model load is best-effort: first use downloads from HuggingFace,
|
|
132
|
+
# so an offline machine without a cached model must degrade to the
|
|
133
|
+
# LIKE fallback, not crash every store_pattern.
|
|
134
|
+
if HAS_SENTENCE_TRANSFORMERS:
|
|
135
|
+
try:
|
|
136
|
+
self._embedder = EmbeddingEngine()
|
|
137
|
+
except Exception:
|
|
138
|
+
self._embedder = None
|
|
139
|
+
return self._embedder
|
|
140
|
+
|
|
141
|
+
def _connect(self) -> sqlite3.Connection:
|
|
142
|
+
"""Open a connection with the per-connection pragma ``_init_db`` intends.
|
|
143
|
+
|
|
144
|
+
``journal_mode=WAL`` persists in the database file, but ``synchronous``
|
|
145
|
+
is per connection: without setting it here every other connection ran
|
|
146
|
+
at FULL and fsync()ed on each commit (~150ms per telemetry row on a
|
|
147
|
+
slow disk). NORMAL under WAL still survives an application crash; only
|
|
148
|
+
a power loss can lose the last committed transactions, never corrupt.
|
|
149
|
+
"""
|
|
150
|
+
conn = sqlite3.connect(self.db_path)
|
|
151
|
+
conn.execute("PRAGMA synchronous=NORMAL")
|
|
152
|
+
return conn
|
|
153
|
+
|
|
154
|
+
def _init_db(self) -> None:
|
|
155
|
+
"""Initialize SQLite schema."""
|
|
156
|
+
with self._connect() as conn:
|
|
157
|
+
conn.executescript("""
|
|
158
|
+
PRAGMA journal_mode=WAL;
|
|
159
|
+
PRAGMA synchronous=NORMAL;
|
|
160
|
+
|
|
161
|
+
CREATE TABLE IF NOT EXISTS patterns (
|
|
162
|
+
id TEXT PRIMARY KEY,
|
|
163
|
+
category TEXT NOT NULL,
|
|
164
|
+
name TEXT NOT NULL,
|
|
165
|
+
description TEXT,
|
|
166
|
+
file_path TEXT,
|
|
167
|
+
line INTEGER,
|
|
168
|
+
confidence REAL,
|
|
169
|
+
is_approved INTEGER DEFAULT 0,
|
|
170
|
+
metadata TEXT, -- JSON
|
|
171
|
+
created_at TEXT,
|
|
172
|
+
updated_at TEXT
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
CREATE TABLE IF NOT EXISTS tribal_knowledge (
|
|
176
|
+
id TEXT PRIMARY KEY,
|
|
177
|
+
topic TEXT NOT NULL,
|
|
178
|
+
content TEXT NOT NULL,
|
|
179
|
+
tags TEXT, -- JSON array
|
|
180
|
+
created_at TEXT,
|
|
181
|
+
updated_at TEXT
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
CREATE TABLE IF NOT EXISTS scan_history (
|
|
185
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
186
|
+
project_path TEXT,
|
|
187
|
+
files_analyzed INTEGER,
|
|
188
|
+
patterns_found INTEGER,
|
|
189
|
+
scan_duration_ms REAL,
|
|
190
|
+
results_json TEXT,
|
|
191
|
+
scanned_at TEXT,
|
|
192
|
+
lines_of_code INTEGER DEFAULT 0,
|
|
193
|
+
non_blank_lines INTEGER DEFAULT 0,
|
|
194
|
+
total_symbols INTEGER DEFAULT 0,
|
|
195
|
+
total_functions INTEGER DEFAULT 0,
|
|
196
|
+
total_classes INTEGER DEFAULT 0,
|
|
197
|
+
total_variables INTEGER DEFAULT 0,
|
|
198
|
+
total_modules INTEGER DEFAULT 0,
|
|
199
|
+
entry_points INTEGER DEFAULT 0,
|
|
200
|
+
calls_edges INTEGER DEFAULT 0,
|
|
201
|
+
frameworks_json TEXT,
|
|
202
|
+
patterns_by_category_json TEXT
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
CREATE TABLE IF NOT EXISTS embeddings (
|
|
206
|
+
id TEXT PRIMARY KEY,
|
|
207
|
+
source_type TEXT, -- 'pattern', 'tribal', 'symbol'
|
|
208
|
+
source_id TEXT,
|
|
209
|
+
vector BLOB, -- Serialized numpy array
|
|
210
|
+
text TEXT,
|
|
211
|
+
created_at TEXT
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
CREATE INDEX IF NOT EXISTS idx_patterns_category ON patterns(category);
|
|
215
|
+
CREATE INDEX IF NOT EXISTS idx_patterns_approved ON patterns(is_approved);
|
|
216
|
+
CREATE INDEX IF NOT EXISTS idx_tribal_topic ON tribal_knowledge(topic);
|
|
217
|
+
|
|
218
|
+
CREATE TABLE IF NOT EXISTS graph_nodes (
|
|
219
|
+
project_path TEXT NOT NULL,
|
|
220
|
+
id TEXT NOT NULL,
|
|
221
|
+
name TEXT NOT NULL,
|
|
222
|
+
node_type TEXT NOT NULL,
|
|
223
|
+
module TEXT NOT NULL,
|
|
224
|
+
file_path TEXT NOT NULL,
|
|
225
|
+
line INTEGER,
|
|
226
|
+
column INTEGER,
|
|
227
|
+
contained_in TEXT,
|
|
228
|
+
is_async INTEGER DEFAULT 0,
|
|
229
|
+
is_entry_point INTEGER DEFAULT 0,
|
|
230
|
+
is_test INTEGER DEFAULT 0,
|
|
231
|
+
decorators TEXT,
|
|
232
|
+
docstring TEXT,
|
|
233
|
+
signature TEXT,
|
|
234
|
+
qualified_name TEXT,
|
|
235
|
+
metadata TEXT,
|
|
236
|
+
language TEXT NOT NULL DEFAULT 'python',
|
|
237
|
+
metadata_json TEXT,
|
|
238
|
+
PRIMARY KEY (project_path, id)
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
CREATE TABLE IF NOT EXISTS graph_edges (
|
|
242
|
+
project_path TEXT NOT NULL,
|
|
243
|
+
source TEXT NOT NULL,
|
|
244
|
+
target TEXT NOT NULL,
|
|
245
|
+
edge_type TEXT NOT NULL,
|
|
246
|
+
file_path TEXT,
|
|
247
|
+
line INTEGER,
|
|
248
|
+
col INTEGER,
|
|
249
|
+
confidence REAL DEFAULT 1.0,
|
|
250
|
+
metadata TEXT,
|
|
251
|
+
strategy TEXT,
|
|
252
|
+
PRIMARY KEY (project_path, source, target, edge_type)
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
CREATE TABLE IF NOT EXISTS graph_edge_occurrences (
|
|
256
|
+
project_path TEXT NOT NULL,
|
|
257
|
+
source TEXT NOT NULL,
|
|
258
|
+
target TEXT NOT NULL,
|
|
259
|
+
edge_type TEXT NOT NULL,
|
|
260
|
+
file_path TEXT NOT NULL,
|
|
261
|
+
line INTEGER,
|
|
262
|
+
col INTEGER,
|
|
263
|
+
start_byte INTEGER NOT NULL,
|
|
264
|
+
end_byte INTEGER NOT NULL,
|
|
265
|
+
confidence REAL DEFAULT 1.0,
|
|
266
|
+
metadata TEXT,
|
|
267
|
+
strategy TEXT,
|
|
268
|
+
PRIMARY KEY (
|
|
269
|
+
project_path, source, target, edge_type,
|
|
270
|
+
file_path, start_byte, end_byte
|
|
271
|
+
)
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
CREATE INDEX IF NOT EXISTS idx_graph_nodes_project ON graph_nodes(project_path);
|
|
275
|
+
CREATE INDEX IF NOT EXISTS idx_graph_edges_project ON graph_edges(project_path);
|
|
276
|
+
CREATE INDEX IF NOT EXISTS idx_graph_edge_occurrences_project
|
|
277
|
+
ON graph_edge_occurrences(project_path);
|
|
278
|
+
""")
|
|
279
|
+
# Migration: add confidence column if missing (older DBs).
|
|
280
|
+
cols = {r[1] for r in conn.execute("PRAGMA table_info(graph_edges)").fetchall()}
|
|
281
|
+
if "confidence" not in cols:
|
|
282
|
+
conn.execute("ALTER TABLE graph_edges ADD COLUMN confidence REAL DEFAULT 1.0")
|
|
283
|
+
# Migration (P4.12): per-edge provenance metadata (JSON). NULL on
|
|
284
|
+
# old rows -> an empty dict on load; every non-Java edge stores it
|
|
285
|
+
# empty, so this is purely additive.
|
|
286
|
+
if "metadata" not in cols:
|
|
287
|
+
conn.execute("ALTER TABLE graph_edges ADD COLUMN metadata TEXT")
|
|
288
|
+
# Migration (P4.6 F-03): call-site column, disambiguates two
|
|
289
|
+
# same-named calls resolving to different targets on one line —
|
|
290
|
+
# see `GraphEdge.column`. Named `col` (not `column`) since the
|
|
291
|
+
# latter is a reserved SQL keyword. NULL on old rows is fine,
|
|
292
|
+
# same line-only fallback `TaintAnalyzer._resolve_callees`
|
|
293
|
+
# already had.
|
|
294
|
+
if "col" not in cols:
|
|
295
|
+
conn.execute("ALTER TABLE graph_edges ADD COLUMN col INTEGER")
|
|
296
|
+
if "strategy" not in cols:
|
|
297
|
+
conn.execute("ALTER TABLE graph_edges ADD COLUMN strategy TEXT")
|
|
298
|
+
# Migration (P1.10 step 3): qualified_name column. NULL on old
|
|
299
|
+
# rows is fine — GraphNode.__post_init__ re-derives it on load.
|
|
300
|
+
cols = {r[1] for r in conn.execute("PRAGMA table_info(graph_nodes)").fetchall()}
|
|
301
|
+
if "qualified_name" not in cols:
|
|
302
|
+
conn.execute("ALTER TABLE graph_nodes ADD COLUMN qualified_name TEXT")
|
|
303
|
+
# Migration (F-01): GraphNode.metadata (JSON) — was silently
|
|
304
|
+
# dropped on save/load, breaking P4.12 abstract-dispatch fan-out
|
|
305
|
+
# for nodes reloaded from a prior scan. NULL on old rows -> {}.
|
|
306
|
+
if "metadata" not in cols:
|
|
307
|
+
conn.execute("ALTER TABLE graph_nodes ADD COLUMN metadata TEXT")
|
|
308
|
+
if "language" not in cols:
|
|
309
|
+
conn.execute("ALTER TABLE graph_nodes ADD COLUMN language TEXT NOT NULL DEFAULT 'python'")
|
|
310
|
+
if "metadata_json" not in cols:
|
|
311
|
+
conn.execute("ALTER TABLE graph_nodes ADD COLUMN metadata_json TEXT")
|
|
312
|
+
if "is_test" not in cols:
|
|
313
|
+
conn.execute("ALTER TABLE graph_nodes ADD COLUMN is_test INTEGER DEFAULT 0")
|
|
314
|
+
# Migration (P7 continuation, GRAPH_SCHEMA_VERSION 28): symbol body
|
|
315
|
+
# end line, for snippet-sizing. NULL on old rows -> GraphNode's
|
|
316
|
+
# own end_line=0 default (adaptive sizing falls back to flat).
|
|
317
|
+
if "end_line" not in cols:
|
|
318
|
+
conn.execute("ALTER TABLE graph_nodes ADD COLUMN end_line INTEGER")
|
|
319
|
+
# P1.10 step 1: collision side table (auto-created, no migration
|
|
320
|
+
# risk). One row per displaced node, keyed by source location.
|
|
321
|
+
conn.executescript("""
|
|
322
|
+
CREATE TABLE IF NOT EXISTS node_collisions (
|
|
323
|
+
project_path TEXT NOT NULL,
|
|
324
|
+
file_path TEXT NOT NULL,
|
|
325
|
+
line INTEGER NOT NULL,
|
|
326
|
+
node_id TEXT NOT NULL,
|
|
327
|
+
name TEXT,
|
|
328
|
+
node_type TEXT,
|
|
329
|
+
PRIMARY KEY (project_path, file_path, line)
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
-- Pickled CallGraphBuilder written alongside the row tables by
|
|
333
|
+
-- save_graph(); load_graph() prefers it (one blob read instead
|
|
334
|
+
-- of rebuilding ~20k objects from rows) whenever its code_key
|
|
335
|
+
-- still matches _snapshot_code_key(), else falls back to rows.
|
|
336
|
+
CREATE TABLE IF NOT EXISTS graph_snapshots (
|
|
337
|
+
project_path TEXT PRIMARY KEY,
|
|
338
|
+
code_key TEXT NOT NULL,
|
|
339
|
+
snapshot BLOB NOT NULL
|
|
340
|
+
);
|
|
341
|
+
""")
|
|
342
|
+
conn.executescript("""
|
|
343
|
+
|
|
344
|
+
CREATE TABLE IF NOT EXISTS graph_metadata (
|
|
345
|
+
project_path TEXT PRIMARY KEY,
|
|
346
|
+
saved_at REAL
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
-- MCP_TOKEN_BUDGET_PLAN.md Lớp 2: drift_plan_change snapshots
|
|
350
|
+
-- (full caller / file sets) referenced by id from the compact
|
|
351
|
+
-- baseline the agent carries to drift_verify_change.
|
|
352
|
+
CREATE TABLE IF NOT EXISTS impact_baselines (
|
|
353
|
+
id TEXT PRIMARY KEY,
|
|
354
|
+
symbol TEXT NOT NULL,
|
|
355
|
+
created_at TEXT NOT NULL,
|
|
356
|
+
payload_json TEXT NOT NULL
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
CREATE TABLE IF NOT EXISTS file_metadata (
|
|
360
|
+
project_path TEXT NOT NULL,
|
|
361
|
+
file_path TEXT NOT NULL,
|
|
362
|
+
content_hash TEXT NOT NULL,
|
|
363
|
+
last_modified REAL,
|
|
364
|
+
last_scanned TEXT,
|
|
365
|
+
analysis_json TEXT,
|
|
366
|
+
PRIMARY KEY (project_path, file_path)
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
CREATE INDEX IF NOT EXISTS idx_file_metadata_project ON file_metadata(project_path);
|
|
370
|
+
""")
|
|
371
|
+
# Migration: add graph_version column if missing (older DBs).
|
|
372
|
+
# Old rows get NULL → version mismatch → cached graph invalidated
|
|
373
|
+
# on next load, forcing a rebuild with the current resolver.
|
|
374
|
+
cols = {r[1] for r in conn.execute("PRAGMA table_info(graph_metadata)").fetchall()}
|
|
375
|
+
if "graph_version" not in cols:
|
|
376
|
+
conn.execute("ALTER TABLE graph_metadata ADD COLUMN graph_version INTEGER")
|
|
377
|
+
if "module_owners" not in cols:
|
|
378
|
+
conn.execute("ALTER TABLE graph_metadata ADD COLUMN module_owners TEXT")
|
|
379
|
+
if "source_fingerprint" not in cols:
|
|
380
|
+
conn.execute("ALTER TABLE graph_metadata ADD COLUMN source_fingerprint TEXT")
|
|
381
|
+
if "backend_name" not in cols:
|
|
382
|
+
conn.execute("ALTER TABLE graph_metadata ADD COLUMN backend_name TEXT")
|
|
383
|
+
if "backend_version" not in cols:
|
|
384
|
+
conn.execute("ALTER TABLE graph_metadata ADD COLUMN backend_version INTEGER")
|
|
385
|
+
if "indexed_extensions" not in cols:
|
|
386
|
+
conn.execute("ALTER TABLE graph_metadata ADD COLUMN indexed_extensions TEXT")
|
|
387
|
+
|
|
388
|
+
# Migration: baseline metric columns on scan_history (additive defaults).
|
|
389
|
+
scan_cols = {r[1] for r in conn.execute("PRAGMA table_info(scan_history)").fetchall()}
|
|
390
|
+
for col, dtype in (
|
|
391
|
+
("lines_of_code", "INTEGER DEFAULT 0"),
|
|
392
|
+
("non_blank_lines", "INTEGER DEFAULT 0"),
|
|
393
|
+
("total_symbols", "INTEGER DEFAULT 0"),
|
|
394
|
+
("total_functions", "INTEGER DEFAULT 0"),
|
|
395
|
+
("total_classes", "INTEGER DEFAULT 0"),
|
|
396
|
+
("total_variables", "INTEGER DEFAULT 0"),
|
|
397
|
+
("total_modules", "INTEGER DEFAULT 0"),
|
|
398
|
+
("entry_points", "INTEGER DEFAULT 0"),
|
|
399
|
+
("calls_edges", "INTEGER DEFAULT 0"),
|
|
400
|
+
("frameworks_json", "TEXT"),
|
|
401
|
+
("patterns_by_category_json", "TEXT"),
|
|
402
|
+
):
|
|
403
|
+
if col not in scan_cols:
|
|
404
|
+
conn.execute(f"ALTER TABLE scan_history ADD COLUMN {col} {dtype}")
|
|
405
|
+
|
|
406
|
+
# P2.18: def-use facts table (auto-created, no migration needed for fresh DBs)
|
|
407
|
+
conn.executescript("""
|
|
408
|
+
CREATE TABLE IF NOT EXISTS def_use_facts (
|
|
409
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
410
|
+
project_path TEXT NOT NULL,
|
|
411
|
+
function_id TEXT NOT NULL,
|
|
412
|
+
binding_name TEXT NOT NULL,
|
|
413
|
+
def_line INTEGER,
|
|
414
|
+
def_col INTEGER,
|
|
415
|
+
use_line INTEGER,
|
|
416
|
+
use_col INTEGER,
|
|
417
|
+
is_literal INTEGER DEFAULT 0,
|
|
418
|
+
taint_source TEXT,
|
|
419
|
+
created_at TEXT
|
|
420
|
+
);
|
|
421
|
+
CREATE INDEX IF NOT EXISTS idx_def_use_function
|
|
422
|
+
ON def_use_facts(project_path, function_id);
|
|
423
|
+
CREATE INDEX IF NOT EXISTS idx_def_use_binding
|
|
424
|
+
ON def_use_facts(project_path, binding_name);
|
|
425
|
+
""")
|
|
426
|
+
# P2.19: taint paths table (auto-created, no migration for fresh DBs)
|
|
427
|
+
conn.executescript("""
|
|
428
|
+
CREATE TABLE IF NOT EXISTS taint_paths (
|
|
429
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
430
|
+
project_path TEXT NOT NULL,
|
|
431
|
+
function_id TEXT NOT NULL,
|
|
432
|
+
file_path TEXT,
|
|
433
|
+
sink TEXT,
|
|
434
|
+
sink_category TEXT,
|
|
435
|
+
source_label TEXT,
|
|
436
|
+
binding_name TEXT,
|
|
437
|
+
sink_line INTEGER,
|
|
438
|
+
sink_col INTEGER,
|
|
439
|
+
chain TEXT,
|
|
440
|
+
confidence REAL DEFAULT 0.9,
|
|
441
|
+
cross_function INTEGER DEFAULT 0,
|
|
442
|
+
created_at TEXT
|
|
443
|
+
);
|
|
444
|
+
CREATE INDEX IF NOT EXISTS idx_taint_function
|
|
445
|
+
ON taint_paths(project_path, function_id);
|
|
446
|
+
""")
|
|
447
|
+
|
|
448
|
+
# MONITOR: per-tool-call telemetry for the project monitor UI.
|
|
449
|
+
# One row per MCP tool invocation, recording the tokens drift
|
|
450
|
+
# returned vs. an estimated read+grep baseline, plus the confidence
|
|
451
|
+
# profile of the answer. Powers monitor/ "tokens saved" + "confidence".
|
|
452
|
+
conn.executescript("""
|
|
453
|
+
CREATE TABLE IF NOT EXISTS tool_invocations (
|
|
454
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
455
|
+
project_path TEXT,
|
|
456
|
+
tool TEXT NOT NULL,
|
|
457
|
+
target TEXT,
|
|
458
|
+
args TEXT, -- JSON of selected params
|
|
459
|
+
response_tokens INTEGER, -- tokens drift returned
|
|
460
|
+
baseline_tokens INTEGER, -- est. read+grep tokens for same answer
|
|
461
|
+
saved_tokens INTEGER, -- baseline - response (>= 0)
|
|
462
|
+
files_referenced INTEGER,
|
|
463
|
+
result_count INTEGER,
|
|
464
|
+
confidence_min REAL,
|
|
465
|
+
confidence_avg REAL,
|
|
466
|
+
confidence_max REAL,
|
|
467
|
+
high_conf INTEGER DEFAULT 0, -- >= 0.85
|
|
468
|
+
med_conf INTEGER DEFAULT 0, -- 0.6 .. 0.85
|
|
469
|
+
low_conf INTEGER DEFAULT 0, -- < 0.6
|
|
470
|
+
duration_ms REAL,
|
|
471
|
+
is_error INTEGER DEFAULT 0,
|
|
472
|
+
created_at TEXT
|
|
473
|
+
);
|
|
474
|
+
CREATE INDEX IF NOT EXISTS idx_tool_invocations_project
|
|
475
|
+
ON tool_invocations(project_path);
|
|
476
|
+
CREATE INDEX IF NOT EXISTS idx_tool_invocations_created
|
|
477
|
+
ON tool_invocations(created_at);
|
|
478
|
+
""")
|
|
479
|
+
|
|
480
|
+
def store_invocation(self, record: Dict[str, Any]) -> None:
|
|
481
|
+
"""MONITOR: append one tool-call telemetry row (best-effort).
|
|
482
|
+
|
|
483
|
+
``record`` keys mirror the ``tool_invocations`` columns; missing keys
|
|
484
|
+
default sensibly. Never raises — telemetry must not break a tool call.
|
|
485
|
+
"""
|
|
486
|
+
try:
|
|
487
|
+
with self._connect() as conn:
|
|
488
|
+
conn.execute("""
|
|
489
|
+
INSERT INTO tool_invocations
|
|
490
|
+
(project_path, tool, target, args, response_tokens,
|
|
491
|
+
baseline_tokens, saved_tokens, files_referenced,
|
|
492
|
+
result_count, confidence_min, confidence_avg, confidence_max,
|
|
493
|
+
high_conf, med_conf, low_conf, duration_ms, is_error, created_at)
|
|
494
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
495
|
+
""", (
|
|
496
|
+
record.get("project_path"),
|
|
497
|
+
record.get("tool"),
|
|
498
|
+
record.get("target"),
|
|
499
|
+
json.dumps(record.get("args") or {}, default=str),
|
|
500
|
+
int(record.get("response_tokens", 0)),
|
|
501
|
+
int(record.get("baseline_tokens", 0)),
|
|
502
|
+
int(record.get("saved_tokens", 0)),
|
|
503
|
+
int(record.get("files_referenced", 0)),
|
|
504
|
+
int(record.get("result_count", 0)),
|
|
505
|
+
record.get("confidence_min"),
|
|
506
|
+
record.get("confidence_avg"),
|
|
507
|
+
record.get("confidence_max"),
|
|
508
|
+
int(record.get("high_conf", 0)),
|
|
509
|
+
int(record.get("med_conf", 0)),
|
|
510
|
+
int(record.get("low_conf", 0)),
|
|
511
|
+
record.get("duration_ms"),
|
|
512
|
+
int(bool(record.get("is_error", False))),
|
|
513
|
+
datetime.now().isoformat(),
|
|
514
|
+
))
|
|
515
|
+
except Exception:
|
|
516
|
+
pass
|
|
517
|
+
|
|
518
|
+
def get_invocations(
|
|
519
|
+
self, project_path: Optional[str] = None, limit: int = 200
|
|
520
|
+
) -> List[Dict[str, Any]]:
|
|
521
|
+
"""MONITOR: most-recent telemetry rows, newest first."""
|
|
522
|
+
with self._connect() as conn:
|
|
523
|
+
conn.row_factory = sqlite3.Row
|
|
524
|
+
sql = "SELECT * FROM tool_invocations"
|
|
525
|
+
args: List[Any] = []
|
|
526
|
+
if project_path:
|
|
527
|
+
sql += " WHERE project_path = ?"
|
|
528
|
+
args.append(project_path)
|
|
529
|
+
sql += " ORDER BY id DESC LIMIT ?"
|
|
530
|
+
args.append(limit)
|
|
531
|
+
return [dict(r) for r in conn.execute(sql, args).fetchall()]
|
|
532
|
+
|
|
533
|
+
def get_invocation_stats(
|
|
534
|
+
self, project_path: Optional[str] = None
|
|
535
|
+
) -> Dict[str, Any]:
|
|
536
|
+
"""MONITOR: cumulative aggregates across all telemetry rows."""
|
|
537
|
+
with self._connect() as conn:
|
|
538
|
+
where = ""
|
|
539
|
+
args: List[Any] = []
|
|
540
|
+
if project_path:
|
|
541
|
+
where = " WHERE project_path = ?"
|
|
542
|
+
args.append(project_path)
|
|
543
|
+
row = conn.execute(f"""
|
|
544
|
+
SELECT COUNT(*) AS calls,
|
|
545
|
+
COALESCE(SUM(response_tokens), 0) AS response_tokens,
|
|
546
|
+
COALESCE(SUM(baseline_tokens), 0) AS baseline_tokens,
|
|
547
|
+
COALESCE(SUM(saved_tokens), 0) AS saved_tokens,
|
|
548
|
+
COALESCE(SUM(high_conf), 0) AS high_conf,
|
|
549
|
+
COALESCE(SUM(med_conf), 0) AS med_conf,
|
|
550
|
+
COALESCE(SUM(low_conf), 0) AS low_conf
|
|
551
|
+
FROM tool_invocations{where}
|
|
552
|
+
""", args).fetchone()
|
|
553
|
+
return {
|
|
554
|
+
"calls": row[0], "response_tokens": row[1],
|
|
555
|
+
"baseline_tokens": row[2], "saved_tokens": row[3],
|
|
556
|
+
"high_conf": row[4], "med_conf": row[5], "low_conf": row[6],
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
def store_pattern(self, pattern: Pattern) -> str:
|
|
560
|
+
"""Store a detected pattern. Returns the pattern ID."""
|
|
561
|
+
return self.store_patterns([pattern])[0]
|
|
562
|
+
|
|
563
|
+
def store_patterns(self, patterns: List[Pattern]) -> List[str]:
|
|
564
|
+
"""Store multiple patterns in one connection/transaction.
|
|
565
|
+
|
|
566
|
+
P8: `store_pattern` previously opened its own `sqlite3.connect()` +
|
|
567
|
+
commit per call; scanning a corpus with tens of thousands of patterns
|
|
568
|
+
(e.g. one IMPORTS pattern per import statement) paid that connect+
|
|
569
|
+
commit overhead once per pattern. Batches share a single connection
|
|
570
|
+
and one `executemany` + commit instead.
|
|
571
|
+
|
|
572
|
+
Commit granularity note: callers must call this once per file (or a
|
|
573
|
+
small bounded group of files), not once for an entire multi-file
|
|
574
|
+
scan — `IncrementalScanner.store_result`/`store_file_metadata` commits
|
|
575
|
+
immediately after a file's patterns are persisted, and incremental
|
|
576
|
+
rescans use `file_metadata` (not the `patterns` table) to decide a
|
|
577
|
+
file is "already scanned". Batching all of a scan's patterns into one
|
|
578
|
+
end-of-scan transaction would invert that ordering: a crash after
|
|
579
|
+
`file_metadata` commits for N files but before one large `patterns`
|
|
580
|
+
transaction commits would permanently lose those N files' patterns
|
|
581
|
+
(they'd never be rescanned since their content hash didn't change).
|
|
582
|
+
Per-file batching preserves the existing crash-recovery invariant.
|
|
583
|
+
"""
|
|
584
|
+
if not patterns:
|
|
585
|
+
return []
|
|
586
|
+
now = datetime.now().isoformat()
|
|
587
|
+
pattern_ids = [p.compute_hash() for p in patterns]
|
|
588
|
+
rows = [
|
|
589
|
+
(
|
|
590
|
+
pattern_id,
|
|
591
|
+
pattern.category.name,
|
|
592
|
+
pattern.name,
|
|
593
|
+
pattern.description,
|
|
594
|
+
str(pattern.file_path),
|
|
595
|
+
pattern.position.line,
|
|
596
|
+
pattern.confidence,
|
|
597
|
+
int(pattern.is_approved),
|
|
598
|
+
json.dumps(pattern.metadata),
|
|
599
|
+
now,
|
|
600
|
+
now,
|
|
601
|
+
)
|
|
602
|
+
for pattern_id, pattern in zip(pattern_ids, patterns)
|
|
603
|
+
]
|
|
604
|
+
with self._connect() as conn:
|
|
605
|
+
conn.executemany("""
|
|
606
|
+
INSERT OR REPLACE INTO patterns
|
|
607
|
+
(id, category, name, description, file_path, line,
|
|
608
|
+
confidence, is_approved, metadata, created_at, updated_at)
|
|
609
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
610
|
+
""", rows)
|
|
611
|
+
|
|
612
|
+
# P2.3: embed name+description for vector search (no-op when no
|
|
613
|
+
# embedder; skipped when the same text is already embedded, so
|
|
614
|
+
# re-scans don't pay the model cost again).
|
|
615
|
+
for pattern_id, pattern in zip(pattern_ids, patterns):
|
|
616
|
+
self._store_embedding(
|
|
617
|
+
"pattern", pattern_id, f"{pattern.name}: {pattern.description or ''}"
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
return pattern_ids
|
|
621
|
+
|
|
622
|
+
def _store_embedding(self, source_type: str, source_id: str, text: str) -> None:
|
|
623
|
+
"""P2.3: persist an embedding row for `text` (best-effort, optional)."""
|
|
624
|
+
text = (text or "").strip()
|
|
625
|
+
if not text:
|
|
626
|
+
return
|
|
627
|
+
embedder = self._get_embedder()
|
|
628
|
+
if embedder is None:
|
|
629
|
+
return
|
|
630
|
+
emb_id = f"{source_type}:{source_id}"
|
|
631
|
+
with self._connect() as conn:
|
|
632
|
+
row = conn.execute(
|
|
633
|
+
"SELECT text FROM embeddings WHERE id = ?", (emb_id,)
|
|
634
|
+
).fetchone()
|
|
635
|
+
if row is not None and row[0] == text:
|
|
636
|
+
return
|
|
637
|
+
import numpy as np
|
|
638
|
+
vector = np.asarray(embedder.embed_single(text), dtype=np.float32)
|
|
639
|
+
conn.execute("""
|
|
640
|
+
INSERT OR REPLACE INTO embeddings
|
|
641
|
+
(id, source_type, source_id, vector, text, created_at)
|
|
642
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
643
|
+
""", (
|
|
644
|
+
emb_id, source_type, source_id, vector.tobytes(), text,
|
|
645
|
+
datetime.now().isoformat(),
|
|
646
|
+
))
|
|
647
|
+
|
|
648
|
+
def get_pattern(self, pattern_id: str) -> Optional[Dict[str, Any]]:
|
|
649
|
+
"""Fetch one pattern row by id (P2.11: drift_approve validation)."""
|
|
650
|
+
with self._connect() as conn:
|
|
651
|
+
conn.row_factory = sqlite3.Row
|
|
652
|
+
row = conn.execute(
|
|
653
|
+
"SELECT * FROM patterns WHERE id = ?", (pattern_id,)
|
|
654
|
+
).fetchone()
|
|
655
|
+
return dict(row) if row else None
|
|
656
|
+
|
|
657
|
+
def approve_pattern(self, pattern_id: str) -> None:
|
|
658
|
+
"""Mark a pattern as approved (intentional design choice)."""
|
|
659
|
+
with self._connect() as conn:
|
|
660
|
+
conn.execute(
|
|
661
|
+
"UPDATE patterns SET is_approved = 1, updated_at = ? WHERE id = ?",
|
|
662
|
+
(datetime.now().isoformat(), pattern_id)
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
def get_patterns(self, category: Optional[str] = None, approved_only: bool = False) -> List[Dict[str, Any]]:
|
|
666
|
+
"""Retrieve patterns from memory."""
|
|
667
|
+
query = "SELECT * FROM patterns WHERE 1=1"
|
|
668
|
+
params = []
|
|
669
|
+
|
|
670
|
+
if category:
|
|
671
|
+
query += " AND category = ?"
|
|
672
|
+
params.append(category)
|
|
673
|
+
if approved_only:
|
|
674
|
+
query += " AND is_approved = 1"
|
|
675
|
+
|
|
676
|
+
query += " ORDER BY confidence DESC"
|
|
677
|
+
|
|
678
|
+
with self._connect() as conn:
|
|
679
|
+
conn.row_factory = sqlite3.Row
|
|
680
|
+
rows = conn.execute(query, params).fetchall()
|
|
681
|
+
return [dict(row) for row in rows]
|
|
682
|
+
|
|
683
|
+
def save_impact_baseline(self, payload: Dict[str, Any]) -> str:
|
|
684
|
+
"""Persist a drift_plan_change snapshot; returns its id (content hash)."""
|
|
685
|
+
body = json.dumps(payload, sort_keys=True, default=str)
|
|
686
|
+
baseline_id = hashlib.sha256(body.encode()).hexdigest()[:16]
|
|
687
|
+
with self._connect() as conn:
|
|
688
|
+
conn.execute(
|
|
689
|
+
"INSERT OR REPLACE INTO impact_baselines (id, symbol, created_at, payload_json) VALUES (?, ?, ?, ?)",
|
|
690
|
+
(baseline_id, str(payload.get("symbol", "")), datetime.now().isoformat(), body),
|
|
691
|
+
)
|
|
692
|
+
return baseline_id
|
|
693
|
+
|
|
694
|
+
def load_impact_baseline(self, baseline_id: str) -> Optional[Dict[str, Any]]:
|
|
695
|
+
with self._connect() as conn:
|
|
696
|
+
row = conn.execute(
|
|
697
|
+
"SELECT payload_json FROM impact_baselines WHERE id = ?", (baseline_id,)
|
|
698
|
+
).fetchone()
|
|
699
|
+
return json.loads(row[0]) if row else None
|
|
700
|
+
|
|
701
|
+
def add_tribal_knowledge(self, topic: str, content: str, tags: Optional[List[str]] = None) -> str:
|
|
702
|
+
"""Store institutional knowledge (human-written rules)."""
|
|
703
|
+
knowledge_id = hashlib.sha256(f"{topic}:{content}".encode()).hexdigest()[:16]
|
|
704
|
+
|
|
705
|
+
with self._connect() as conn:
|
|
706
|
+
conn.execute("""
|
|
707
|
+
INSERT OR REPLACE INTO tribal_knowledge
|
|
708
|
+
(id, topic, content, tags, created_at, updated_at)
|
|
709
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
710
|
+
""", (
|
|
711
|
+
knowledge_id,
|
|
712
|
+
topic,
|
|
713
|
+
content,
|
|
714
|
+
json.dumps(tags or []),
|
|
715
|
+
datetime.now().isoformat(),
|
|
716
|
+
datetime.now().isoformat(),
|
|
717
|
+
))
|
|
718
|
+
|
|
719
|
+
self._store_embedding("tribal", knowledge_id, f"{topic}: {content}")
|
|
720
|
+
|
|
721
|
+
return knowledge_id
|
|
722
|
+
|
|
723
|
+
def get_tribal_knowledge(self, topic: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
724
|
+
"""Retrieve tribal knowledge."""
|
|
725
|
+
query = "SELECT * FROM tribal_knowledge"
|
|
726
|
+
params = []
|
|
727
|
+
if topic:
|
|
728
|
+
query += " WHERE topic = ?"
|
|
729
|
+
params.append(topic)
|
|
730
|
+
query += " ORDER BY updated_at DESC"
|
|
731
|
+
|
|
732
|
+
with self._connect() as conn:
|
|
733
|
+
conn.row_factory = sqlite3.Row
|
|
734
|
+
rows = conn.execute(query, params).fetchall()
|
|
735
|
+
return [dict(row) for row in rows]
|
|
736
|
+
|
|
737
|
+
# ── P2.18: def-use facts ─────────────────────────────────────────────────
|
|
738
|
+
|
|
739
|
+
def store_def_use_facts(
|
|
740
|
+
self,
|
|
741
|
+
project_path: Path,
|
|
742
|
+
facts: "List[Any]", # List[DefUseFact] — imported lazily to avoid circular
|
|
743
|
+
) -> int:
|
|
744
|
+
"""Persist a batch of DefUseFact objects. Returns rows inserted."""
|
|
745
|
+
if not facts:
|
|
746
|
+
return 0
|
|
747
|
+
ppath = str(project_path)
|
|
748
|
+
now = datetime.now().isoformat()
|
|
749
|
+
rows = []
|
|
750
|
+
for f in facts:
|
|
751
|
+
rows.append((
|
|
752
|
+
ppath,
|
|
753
|
+
f.function_id,
|
|
754
|
+
f.binding_name,
|
|
755
|
+
f.def_site.line,
|
|
756
|
+
f.def_site.column,
|
|
757
|
+
f.use_site.line,
|
|
758
|
+
f.use_site.column,
|
|
759
|
+
int(f.is_literal),
|
|
760
|
+
f.taint_source,
|
|
761
|
+
now,
|
|
762
|
+
))
|
|
763
|
+
with self._connect() as conn:
|
|
764
|
+
# Replace existing facts for this function (full recompute on re-scan)
|
|
765
|
+
if rows:
|
|
766
|
+
fids = list({r[1] for r in rows})
|
|
767
|
+
conn.executemany(
|
|
768
|
+
"DELETE FROM def_use_facts WHERE project_path=? AND function_id=?",
|
|
769
|
+
[(ppath, fid) for fid in fids],
|
|
770
|
+
)
|
|
771
|
+
conn.executemany(
|
|
772
|
+
"""INSERT INTO def_use_facts
|
|
773
|
+
(project_path, function_id, binding_name,
|
|
774
|
+
def_line, def_col, use_line, use_col,
|
|
775
|
+
is_literal, taint_source, created_at)
|
|
776
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
777
|
+
rows,
|
|
778
|
+
)
|
|
779
|
+
return len(rows)
|
|
780
|
+
|
|
781
|
+
def get_def_use_facts(
|
|
782
|
+
self,
|
|
783
|
+
project_path: Path,
|
|
784
|
+
function_id: str,
|
|
785
|
+
binding_name: Optional[str] = None,
|
|
786
|
+
) -> "List[Any]":
|
|
787
|
+
"""Return DefUseFact objects for a function (optionally filtered by name)."""
|
|
788
|
+
from adl_drift.graph.cfg import DefUseFact
|
|
789
|
+
from adl_drift.utils.types import SourcePosition
|
|
790
|
+
|
|
791
|
+
ppath = str(project_path)
|
|
792
|
+
with self._connect() as conn:
|
|
793
|
+
conn.row_factory = sqlite3.Row
|
|
794
|
+
if binding_name:
|
|
795
|
+
rows = conn.execute(
|
|
796
|
+
"""SELECT * FROM def_use_facts
|
|
797
|
+
WHERE project_path=? AND function_id=? AND binding_name=?""",
|
|
798
|
+
(ppath, function_id, binding_name),
|
|
799
|
+
).fetchall()
|
|
800
|
+
else:
|
|
801
|
+
rows = conn.execute(
|
|
802
|
+
"SELECT * FROM def_use_facts WHERE project_path=? AND function_id=?",
|
|
803
|
+
(ppath, function_id),
|
|
804
|
+
).fetchall()
|
|
805
|
+
out = []
|
|
806
|
+
for r in rows:
|
|
807
|
+
out.append(DefUseFact(
|
|
808
|
+
function_id=r["function_id"],
|
|
809
|
+
binding_name=r["binding_name"],
|
|
810
|
+
def_site=SourcePosition(r["def_line"] or 0, r["def_col"] or 0),
|
|
811
|
+
use_site=SourcePosition(r["use_line"] or 0, r["use_col"] or 0),
|
|
812
|
+
is_literal=bool(r["is_literal"]),
|
|
813
|
+
taint_source=r["taint_source"],
|
|
814
|
+
))
|
|
815
|
+
return out
|
|
816
|
+
|
|
817
|
+
def delete_def_use_facts(self, project_path: Path, function_ids: List[str]) -> None:
|
|
818
|
+
"""Remove def-use facts for re-scanned functions (called during incremental update)."""
|
|
819
|
+
if not function_ids:
|
|
820
|
+
return
|
|
821
|
+
ppath = str(project_path)
|
|
822
|
+
with self._connect() as conn:
|
|
823
|
+
conn.executemany(
|
|
824
|
+
"DELETE FROM def_use_facts WHERE project_path=? AND function_id=?",
|
|
825
|
+
[(ppath, fid) for fid in function_ids],
|
|
826
|
+
)
|
|
827
|
+
|
|
828
|
+
# ── P2.19: taint paths ───────────────────────────────────────────────────
|
|
829
|
+
|
|
830
|
+
def store_taint_paths(
|
|
831
|
+
self,
|
|
832
|
+
project_path: Path,
|
|
833
|
+
paths: "List[Any]", # List[TaintPath] — imported lazily (avoid circular)
|
|
834
|
+
replace_function_ids: Optional[List[str]] = None,
|
|
835
|
+
) -> int:
|
|
836
|
+
"""Persist taint paths. Replaces paths for *replace_function_ids* first
|
|
837
|
+
(the set of functions just re-analyzed); when ``None``, replaces every
|
|
838
|
+
path for the project (full-scan recompute). Returns rows inserted."""
|
|
839
|
+
ppath = str(project_path)
|
|
840
|
+
now = datetime.now().isoformat()
|
|
841
|
+
with self._connect() as conn:
|
|
842
|
+
if replace_function_ids is None:
|
|
843
|
+
conn.execute("DELETE FROM taint_paths WHERE project_path=?", (ppath,))
|
|
844
|
+
elif replace_function_ids:
|
|
845
|
+
conn.executemany(
|
|
846
|
+
"DELETE FROM taint_paths WHERE project_path=? AND function_id=?",
|
|
847
|
+
[(ppath, fid) for fid in set(replace_function_ids)],
|
|
848
|
+
)
|
|
849
|
+
rows = [
|
|
850
|
+
(
|
|
851
|
+
ppath, p.function_id, p.file_path, p.sink, p.sink_category,
|
|
852
|
+
p.source_label, p.binding_name, p.sink_site.line, p.sink_site.column,
|
|
853
|
+
json.dumps(p.chain), p.confidence, int(p.cross_function), now,
|
|
854
|
+
)
|
|
855
|
+
for p in paths
|
|
856
|
+
]
|
|
857
|
+
if rows:
|
|
858
|
+
conn.executemany(
|
|
859
|
+
"""INSERT INTO taint_paths
|
|
860
|
+
(project_path, function_id, file_path, sink, sink_category,
|
|
861
|
+
source_label, binding_name, sink_line, sink_col,
|
|
862
|
+
chain, confidence, cross_function, created_at)
|
|
863
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
864
|
+
rows,
|
|
865
|
+
)
|
|
866
|
+
return len(paths)
|
|
867
|
+
|
|
868
|
+
def get_taint_paths(
|
|
869
|
+
self, project_path: Path, function_id: Optional[str] = None
|
|
870
|
+
) -> "List[Any]":
|
|
871
|
+
"""Return TaintPath objects for a project (optionally one function)."""
|
|
872
|
+
from adl_drift.detectors.taint import TaintPath
|
|
873
|
+
from adl_drift.utils.types import SourcePosition
|
|
874
|
+
|
|
875
|
+
ppath = str(project_path)
|
|
876
|
+
with self._connect() as conn:
|
|
877
|
+
conn.row_factory = sqlite3.Row
|
|
878
|
+
if function_id:
|
|
879
|
+
rows = conn.execute(
|
|
880
|
+
"SELECT * FROM taint_paths WHERE project_path=? AND function_id=?",
|
|
881
|
+
(ppath, function_id),
|
|
882
|
+
).fetchall()
|
|
883
|
+
else:
|
|
884
|
+
rows = conn.execute(
|
|
885
|
+
"SELECT * FROM taint_paths WHERE project_path=?", (ppath,)
|
|
886
|
+
).fetchall()
|
|
887
|
+
out = []
|
|
888
|
+
for r in rows:
|
|
889
|
+
try:
|
|
890
|
+
chain = json.loads(r["chain"]) if r["chain"] else []
|
|
891
|
+
except Exception:
|
|
892
|
+
chain = []
|
|
893
|
+
out.append(TaintPath(
|
|
894
|
+
function_id=r["function_id"],
|
|
895
|
+
file_path=r["file_path"] or "",
|
|
896
|
+
sink=r["sink"] or "",
|
|
897
|
+
sink_category=r["sink_category"] or "",
|
|
898
|
+
source_label=r["source_label"] or "",
|
|
899
|
+
binding_name=r["binding_name"],
|
|
900
|
+
sink_site=SourcePosition(r["sink_line"] or 0, r["sink_col"] or 0),
|
|
901
|
+
chain=chain,
|
|
902
|
+
confidence=r["confidence"] if r["confidence"] is not None else 0.9,
|
|
903
|
+
cross_function=bool(r["cross_function"]),
|
|
904
|
+
))
|
|
905
|
+
return out
|
|
906
|
+
|
|
907
|
+
def store_scan(self, context: ProjectContext, duration_ms: float, results_json: str) -> int:
|
|
908
|
+
"""Store scan history with project-size baseline metrics."""
|
|
909
|
+
with self._connect() as conn:
|
|
910
|
+
cursor = conn.execute("""
|
|
911
|
+
INSERT INTO scan_history
|
|
912
|
+
(project_path, files_analyzed, patterns_found, scan_duration_ms, results_json, scanned_at,
|
|
913
|
+
lines_of_code, non_blank_lines, total_symbols, total_functions, total_classes,
|
|
914
|
+
total_variables, total_modules, entry_points, calls_edges,
|
|
915
|
+
frameworks_json, patterns_by_category_json)
|
|
916
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
917
|
+
""", (
|
|
918
|
+
str(context.root_path),
|
|
919
|
+
context.files_analyzed,
|
|
920
|
+
context.total_patterns,
|
|
921
|
+
duration_ms,
|
|
922
|
+
results_json,
|
|
923
|
+
datetime.now().isoformat(),
|
|
924
|
+
context.lines_of_code,
|
|
925
|
+
context.non_blank_lines,
|
|
926
|
+
context.total_symbols,
|
|
927
|
+
context.total_functions,
|
|
928
|
+
context.total_classes,
|
|
929
|
+
context.total_variables,
|
|
930
|
+
context.total_modules,
|
|
931
|
+
context.entry_points,
|
|
932
|
+
context.calls_edges,
|
|
933
|
+
json.dumps(context.frameworks),
|
|
934
|
+
json.dumps(context.patterns_by_category),
|
|
935
|
+
))
|
|
936
|
+
return cursor.lastrowid or 0
|
|
937
|
+
|
|
938
|
+
def get_scan_baseline(self, project_path: Path) -> Optional[Dict[str, Any]]:
|
|
939
|
+
"""Return the most recent scan-history baseline for a project.
|
|
940
|
+
|
|
941
|
+
Returns None when no scan has been recorded. This is the cheap,
|
|
942
|
+
read-only source for the monitor's project-size card and for
|
|
943
|
+
``drift_status`` baseline reporting.
|
|
944
|
+
"""
|
|
945
|
+
proj = str(project_path.resolve())
|
|
946
|
+
with self._connect() as conn:
|
|
947
|
+
conn.row_factory = sqlite3.Row
|
|
948
|
+
row = conn.execute(
|
|
949
|
+
"""SELECT files_analyzed, patterns_found, lines_of_code, non_blank_lines,
|
|
950
|
+
total_symbols, total_functions, total_classes, total_variables,
|
|
951
|
+
total_modules, entry_points, calls_edges, frameworks_json,
|
|
952
|
+
patterns_by_category_json, scan_duration_ms, scanned_at
|
|
953
|
+
FROM scan_history
|
|
954
|
+
WHERE project_path = ?
|
|
955
|
+
ORDER BY id DESC
|
|
956
|
+
LIMIT 1""",
|
|
957
|
+
(proj,),
|
|
958
|
+
).fetchone()
|
|
959
|
+
if row is None:
|
|
960
|
+
return None
|
|
961
|
+
return {
|
|
962
|
+
"files_analyzed": row["files_analyzed"] or 0,
|
|
963
|
+
"patterns_found": row["patterns_found"] or 0,
|
|
964
|
+
"lines_of_code": row["lines_of_code"] or 0,
|
|
965
|
+
"non_blank_lines": row["non_blank_lines"] or 0,
|
|
966
|
+
"total_symbols": row["total_symbols"] or 0,
|
|
967
|
+
"total_functions": row["total_functions"] or 0,
|
|
968
|
+
"total_classes": row["total_classes"] or 0,
|
|
969
|
+
"total_variables": row["total_variables"] or 0,
|
|
970
|
+
"total_modules": row["total_modules"] or 0,
|
|
971
|
+
"entry_points": row["entry_points"] or 0,
|
|
972
|
+
"calls_edges": row["calls_edges"] or 0,
|
|
973
|
+
"frameworks": json.loads(row["frameworks_json"] or "[]"),
|
|
974
|
+
"patterns_by_category": json.loads(row["patterns_by_category_json"] or "{}"),
|
|
975
|
+
"scan_duration_ms": row["scan_duration_ms"] or 0,
|
|
976
|
+
"scanned_at": row["scanned_at"],
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
# P2.20: every table that carries a project_path. Dead-project reap
|
|
980
|
+
# deletes a project's rows from all of them when the project root is gone.
|
|
981
|
+
_PROJECT_TABLES = (
|
|
982
|
+
"graph_nodes", "graph_edges", "graph_edge_occurrences",
|
|
983
|
+
"graph_metadata", "node_collisions",
|
|
984
|
+
"file_metadata", "def_use_facts", "taint_paths", "scan_history",
|
|
985
|
+
)
|
|
986
|
+
# Tables carrying a per-row file_path that the dead-file reap can check.
|
|
987
|
+
# `patterns` has no project_path, so it is reaped by file existence only.
|
|
988
|
+
_FILE_TABLES = ("patterns", "file_metadata")
|
|
989
|
+
|
|
990
|
+
def prune_database(
|
|
991
|
+
self,
|
|
992
|
+
*,
|
|
993
|
+
stale_before: Optional[str] = None,
|
|
994
|
+
include_approved: bool = False,
|
|
995
|
+
dry_run: bool = False,
|
|
996
|
+
) -> Dict[str, Any]:
|
|
997
|
+
"""P2.20: reclaim space from stale rows. Three passes + VACUUM.
|
|
998
|
+
|
|
999
|
+
1. Dead-project reap (always): any `project_path` whose root no longer
|
|
1000
|
+
exists on disk → delete that project's rows from every project table.
|
|
1001
|
+
2. Dead-file reap (always): in the surviving rows, delete `patterns` /
|
|
1002
|
+
`file_metadata` rows whose `file_path` is gone from disk.
|
|
1003
|
+
3. Stale-pattern sweep (opt-in): when `stale_before` (an ISO timestamp
|
|
1004
|
+
captured just before a fresh *full* scan) is given, delete
|
|
1005
|
+
`patterns` rows whose file still exists but whose `updated_at`
|
|
1006
|
+
predates the scan — i.e. the detector no longer emits them, so the
|
|
1007
|
+
full scan never refreshed their row. Approved rows are skipped
|
|
1008
|
+
unless `include_approved`.
|
|
1009
|
+
|
|
1010
|
+
Orphaned `embeddings` rows are removed after the pattern deletions.
|
|
1011
|
+
Returns a per-table deleted-row report plus `bytes_freed`.
|
|
1012
|
+
`dry_run` counts what would be deleted without writing.
|
|
1013
|
+
"""
|
|
1014
|
+
report: Dict[str, Any] = {
|
|
1015
|
+
"deleted": {},
|
|
1016
|
+
"dead_projects": 0,
|
|
1017
|
+
"dead_files": 0,
|
|
1018
|
+
"stale_patterns": 0,
|
|
1019
|
+
"bytes_freed": 0,
|
|
1020
|
+
"dry_run": dry_run,
|
|
1021
|
+
}
|
|
1022
|
+
size_before = self.db_path.stat().st_size if self.db_path.exists() else 0
|
|
1023
|
+
|
|
1024
|
+
def _bump(table: str, n: int) -> None:
|
|
1025
|
+
if n:
|
|
1026
|
+
report["deleted"][table] = report["deleted"].get(table, 0) + n
|
|
1027
|
+
|
|
1028
|
+
with self._connect() as conn:
|
|
1029
|
+
tables = {
|
|
1030
|
+
r[0] for r in conn.execute(
|
|
1031
|
+
"SELECT name FROM sqlite_master WHERE type='table'"
|
|
1032
|
+
)
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
# ---- Pass 1: dead-project reap ----
|
|
1036
|
+
dead_projects: set = set()
|
|
1037
|
+
for table in self._PROJECT_TABLES:
|
|
1038
|
+
if table not in tables:
|
|
1039
|
+
continue
|
|
1040
|
+
for (proj,) in conn.execute(
|
|
1041
|
+
f"SELECT DISTINCT project_path FROM {table}"
|
|
1042
|
+
).fetchall():
|
|
1043
|
+
if proj and not Path(proj).exists():
|
|
1044
|
+
dead_projects.add(proj)
|
|
1045
|
+
for table in self._PROJECT_TABLES:
|
|
1046
|
+
if table not in tables or not dead_projects:
|
|
1047
|
+
continue
|
|
1048
|
+
for proj in dead_projects:
|
|
1049
|
+
if dry_run:
|
|
1050
|
+
n = conn.execute(
|
|
1051
|
+
f"SELECT COUNT(*) FROM {table} WHERE project_path = ?",
|
|
1052
|
+
(proj,),
|
|
1053
|
+
).fetchone()[0]
|
|
1054
|
+
else:
|
|
1055
|
+
n = conn.execute(
|
|
1056
|
+
f"DELETE FROM {table} WHERE project_path = ?", (proj,)
|
|
1057
|
+
).rowcount
|
|
1058
|
+
_bump(table, n)
|
|
1059
|
+
report["dead_projects"] = len(dead_projects)
|
|
1060
|
+
|
|
1061
|
+
# ---- Pass 2: dead-file reap ----
|
|
1062
|
+
for table in self._FILE_TABLES:
|
|
1063
|
+
if table not in tables:
|
|
1064
|
+
continue
|
|
1065
|
+
rows = conn.execute(
|
|
1066
|
+
f"SELECT rowid, file_path FROM {table}"
|
|
1067
|
+
).fetchall()
|
|
1068
|
+
dead_ids = [
|
|
1069
|
+
rid for rid, fp in rows
|
|
1070
|
+
if fp and not Path(fp).exists()
|
|
1071
|
+
]
|
|
1072
|
+
report["dead_files"] += len(dead_ids)
|
|
1073
|
+
if dead_ids and not dry_run:
|
|
1074
|
+
conn.executemany(
|
|
1075
|
+
f"DELETE FROM {table} WHERE rowid = ?",
|
|
1076
|
+
[(rid,) for rid in dead_ids],
|
|
1077
|
+
)
|
|
1078
|
+
_bump(table, len(dead_ids))
|
|
1079
|
+
|
|
1080
|
+
# ---- Pass 3: stale-pattern sweep (opt-in) ----
|
|
1081
|
+
if stale_before is not None and "patterns" in tables:
|
|
1082
|
+
rows = conn.execute(
|
|
1083
|
+
"SELECT id, file_path, is_approved, updated_at FROM patterns"
|
|
1084
|
+
).fetchall()
|
|
1085
|
+
stale = [
|
|
1086
|
+
pid for pid, fp, approved, updated in rows
|
|
1087
|
+
if (updated or "") < stale_before
|
|
1088
|
+
and fp and Path(fp).exists()
|
|
1089
|
+
and (include_approved or not approved)
|
|
1090
|
+
]
|
|
1091
|
+
report["stale_patterns"] = len(stale)
|
|
1092
|
+
if stale and not dry_run:
|
|
1093
|
+
conn.executemany(
|
|
1094
|
+
"DELETE FROM patterns WHERE id = ?",
|
|
1095
|
+
[(pid,) for pid in stale],
|
|
1096
|
+
)
|
|
1097
|
+
_bump("patterns", len(stale))
|
|
1098
|
+
|
|
1099
|
+
# ---- Orphaned embeddings ----
|
|
1100
|
+
if "embeddings" in tables and not dry_run:
|
|
1101
|
+
n = conn.execute("""
|
|
1102
|
+
DELETE FROM embeddings
|
|
1103
|
+
WHERE source_type = 'pattern'
|
|
1104
|
+
AND source_id NOT IN (SELECT id FROM patterns)
|
|
1105
|
+
""").rowcount
|
|
1106
|
+
n += conn.execute("""
|
|
1107
|
+
DELETE FROM embeddings
|
|
1108
|
+
WHERE source_type = 'tribal'
|
|
1109
|
+
AND source_id NOT IN (SELECT id FROM tribal_knowledge)
|
|
1110
|
+
""").rowcount
|
|
1111
|
+
_bump("embeddings", n)
|
|
1112
|
+
|
|
1113
|
+
# ---- VACUUM (must run outside a transaction) ----
|
|
1114
|
+
if not dry_run:
|
|
1115
|
+
with self._connect() as conn:
|
|
1116
|
+
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
|
1117
|
+
conn.isolation_level = None
|
|
1118
|
+
conn.execute("VACUUM")
|
|
1119
|
+
size_after = self.db_path.stat().st_size if self.db_path.exists() else 0
|
|
1120
|
+
report["bytes_freed"] = max(0, size_before - size_after)
|
|
1121
|
+
|
|
1122
|
+
return report
|
|
1123
|
+
|
|
1124
|
+
@classmethod
|
|
1125
|
+
def _source_files(cls, project_path: Path):
|
|
1126
|
+
from adl_drift.scanner.discovery import discover_file_groups
|
|
1127
|
+
|
|
1128
|
+
for files in discover_file_groups(project_path).values():
|
|
1129
|
+
yield from files
|
|
1130
|
+
|
|
1131
|
+
@classmethod
|
|
1132
|
+
def _source_state(
|
|
1133
|
+
cls, project_path: Path, files: Optional[Iterable[Path]] = None,
|
|
1134
|
+
) -> tuple[float, str]:
|
|
1135
|
+
"""``(max_mtime, fingerprint)`` over the project's discovered sources.
|
|
1136
|
+
|
|
1137
|
+
``files`` lets a caller that has already run discovery for the same
|
|
1138
|
+
project (``MCPHandlers._build_full_graph``) reuse it instead of
|
|
1139
|
+
walking the tree again (MCP_GRAPH_CACHE_PLAN.md Lớp 2). It must be
|
|
1140
|
+
the ``discover_file_groups`` result for ``project_path``; the digest
|
|
1141
|
+
is order-independent because the files are sorted here either way.
|
|
1142
|
+
"""
|
|
1143
|
+
project_path = project_path.resolve()
|
|
1144
|
+
fingerprint = hashlib.sha256()
|
|
1145
|
+
max_mtime = 0.0
|
|
1146
|
+
if files is None:
|
|
1147
|
+
files = cls._source_files(project_path)
|
|
1148
|
+
for file_path in sorted(files):
|
|
1149
|
+
try:
|
|
1150
|
+
stat = file_path.stat()
|
|
1151
|
+
except OSError:
|
|
1152
|
+
continue
|
|
1153
|
+
max_mtime = max(max_mtime, stat.st_mtime)
|
|
1154
|
+
relative = file_path.relative_to(project_path).as_posix()
|
|
1155
|
+
fingerprint.update(
|
|
1156
|
+
f"{relative}\0{stat.st_size}\0{stat.st_mtime_ns}\n".encode()
|
|
1157
|
+
)
|
|
1158
|
+
return max_mtime, fingerprint.hexdigest()
|
|
1159
|
+
|
|
1160
|
+
def save_graph(
|
|
1161
|
+
self, project_path: Path, builder: CallGraphBuilder,
|
|
1162
|
+
files: Optional[Iterable[Path]] = None,
|
|
1163
|
+
) -> None:
|
|
1164
|
+
"""Persist a CallGraphBuilder to SQLite for fast reload.
|
|
1165
|
+
|
|
1166
|
+
``files`` is forwarded to ``_source_state`` so a caller that already
|
|
1167
|
+
discovered the project's sources does not walk the tree again.
|
|
1168
|
+
"""
|
|
1169
|
+
proj = str(project_path.resolve())
|
|
1170
|
+
with self._connect() as conn:
|
|
1171
|
+
conn.execute("DELETE FROM graph_edges WHERE project_path = ?", (proj,))
|
|
1172
|
+
conn.execute("DELETE FROM graph_edge_occurrences WHERE project_path = ?", (proj,))
|
|
1173
|
+
conn.execute("DELETE FROM graph_nodes WHERE project_path = ?", (proj,))
|
|
1174
|
+
conn.execute("DELETE FROM node_collisions WHERE project_path = ?", (proj,))
|
|
1175
|
+
conn.execute("DELETE FROM graph_snapshots WHERE project_path = ?", (proj,))
|
|
1176
|
+
|
|
1177
|
+
# Batched inserts: a persisted Java corpus holds hundreds of
|
|
1178
|
+
# thousands of rows, and one execute() per row dominated the
|
|
1179
|
+
# dependency-scoped incremental update (P7 profile).
|
|
1180
|
+
def _node_rows():
|
|
1181
|
+
for node in builder._nodes.values():
|
|
1182
|
+
metadata_json = json.dumps(node.metadata)
|
|
1183
|
+
yield (
|
|
1184
|
+
proj, node.id, node.name, node.node_type.name, node.module,
|
|
1185
|
+
node.file_path, node.line, node.column, node.contained_in,
|
|
1186
|
+
int(node.is_async), int(node.is_entry_point), int(node.is_test),
|
|
1187
|
+
json.dumps(node.decorators), node.docstring, node.signature,
|
|
1188
|
+
node.qualified_name,
|
|
1189
|
+
metadata_json if node.metadata else None,
|
|
1190
|
+
node.language, metadata_json, node.end_line,
|
|
1191
|
+
)
|
|
1192
|
+
|
|
1193
|
+
conn.executemany("""
|
|
1194
|
+
INSERT INTO graph_nodes
|
|
1195
|
+
(project_path, id, name, node_type, module, file_path, line, column,
|
|
1196
|
+
contained_in, is_async, is_entry_point, is_test, decorators, docstring, signature,
|
|
1197
|
+
qualified_name, metadata, language, metadata_json, end_line)
|
|
1198
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1199
|
+
""", _node_rows())
|
|
1200
|
+
|
|
1201
|
+
# P1.10 step 1: persist the collision tripwire's side table so the
|
|
1202
|
+
# signal survives across sessions (drift_status surfaces the count).
|
|
1203
|
+
for (file_path, line), displaced in builder.node_collisions.items():
|
|
1204
|
+
conn.execute("""
|
|
1205
|
+
INSERT OR REPLACE INTO node_collisions
|
|
1206
|
+
(project_path, file_path, line, node_id, name, node_type)
|
|
1207
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
1208
|
+
""", (
|
|
1209
|
+
proj, file_path, line, displaced.id, displaced.name,
|
|
1210
|
+
displaced.node_type.name,
|
|
1211
|
+
))
|
|
1212
|
+
|
|
1213
|
+
conn.executemany("""
|
|
1214
|
+
INSERT INTO graph_edges
|
|
1215
|
+
(project_path, source, target, edge_type, file_path, line, col, confidence, metadata, strategy)
|
|
1216
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1217
|
+
""", (
|
|
1218
|
+
(
|
|
1219
|
+
proj, edge.source, edge.target, edge.edge_type.name,
|
|
1220
|
+
edge.file_path, edge.line, edge.column, float(edge.confidence),
|
|
1221
|
+
json.dumps(edge.metadata) if edge.metadata else None,
|
|
1222
|
+
edge.strategy,
|
|
1223
|
+
)
|
|
1224
|
+
for edge in builder._edges
|
|
1225
|
+
))
|
|
1226
|
+
|
|
1227
|
+
conn.executemany("""
|
|
1228
|
+
INSERT INTO graph_edge_occurrences
|
|
1229
|
+
(project_path, source, target, edge_type, file_path, line, col,
|
|
1230
|
+
start_byte, end_byte, confidence, metadata, strategy)
|
|
1231
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1232
|
+
""", (
|
|
1233
|
+
(
|
|
1234
|
+
proj, edge.source, edge.target, edge.edge_type.name,
|
|
1235
|
+
edge.file_path or "", edge.line, edge.column,
|
|
1236
|
+
edge.start_byte if edge.start_byte is not None else -1,
|
|
1237
|
+
edge.end_byte if edge.end_byte is not None else -1,
|
|
1238
|
+
float(edge.confidence),
|
|
1239
|
+
json.dumps(edge.metadata) if edge.metadata else None,
|
|
1240
|
+
edge.strategy,
|
|
1241
|
+
)
|
|
1242
|
+
for edge in builder._edge_occurrences
|
|
1243
|
+
))
|
|
1244
|
+
|
|
1245
|
+
max_mtime, source_fingerprint = self._source_state(project_path, files)
|
|
1246
|
+
conn.execute(
|
|
1247
|
+
"INSERT OR REPLACE INTO graph_metadata "
|
|
1248
|
+
"(project_path, saved_at, graph_version, module_owners, source_fingerprint, "
|
|
1249
|
+
"backend_name, backend_version, indexed_extensions) "
|
|
1250
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
1251
|
+
(
|
|
1252
|
+
proj,
|
|
1253
|
+
max_mtime,
|
|
1254
|
+
GRAPH_SCHEMA_VERSION,
|
|
1255
|
+
json.dumps({k: sorted(v) for k, v in builder._module_owners.items()}),
|
|
1256
|
+
source_fingerprint,
|
|
1257
|
+
",".join(sorted(
|
|
1258
|
+
builder.backend_names
|
|
1259
|
+
or {node.language for node in builder._nodes.values()}
|
|
1260
|
+
)),
|
|
1261
|
+
GRAPH_SCHEMA_VERSION,
|
|
1262
|
+
json.dumps(sorted(
|
|
1263
|
+
builder.indexed_extensions
|
|
1264
|
+
or {
|
|
1265
|
+
Path(node.file_path).suffix
|
|
1266
|
+
for node in builder._nodes.values()
|
|
1267
|
+
if node.file_path
|
|
1268
|
+
}
|
|
1269
|
+
)),
|
|
1270
|
+
),
|
|
1271
|
+
)
|
|
1272
|
+
conn.execute(
|
|
1273
|
+
"INSERT OR REPLACE INTO graph_snapshots (project_path, code_key, snapshot) "
|
|
1274
|
+
"VALUES (?, ?, ?)",
|
|
1275
|
+
(
|
|
1276
|
+
proj, _snapshot_code_key(),
|
|
1277
|
+
sqlite3.Binary(pickle.dumps(builder, protocol=pickle.HIGHEST_PROTOCOL)),
|
|
1278
|
+
),
|
|
1279
|
+
)
|
|
1280
|
+
|
|
1281
|
+
def get_graph_metadata(self, project_path: Path) -> Optional[Dict[str, Any]]:
|
|
1282
|
+
"""P2.11 (drift_status): persisted-graph facts without loading or
|
|
1283
|
+
invalidating anything. Returns None when no graph is saved.
|
|
1284
|
+
"""
|
|
1285
|
+
proj = str(project_path.resolve())
|
|
1286
|
+
with self._connect() as conn:
|
|
1287
|
+
conn.row_factory = sqlite3.Row
|
|
1288
|
+
meta = conn.execute(
|
|
1289
|
+
"SELECT saved_at, graph_version, backend_name, backend_version, indexed_extensions "
|
|
1290
|
+
"FROM graph_metadata WHERE project_path = ?",
|
|
1291
|
+
(proj,)
|
|
1292
|
+
).fetchone()
|
|
1293
|
+
if meta is None:
|
|
1294
|
+
return None
|
|
1295
|
+
node_types = dict(conn.execute(
|
|
1296
|
+
"SELECT node_type, COUNT(*) FROM graph_nodes WHERE project_path = ? GROUP BY node_type",
|
|
1297
|
+
(proj,)
|
|
1298
|
+
).fetchall())
|
|
1299
|
+
edge_types = dict(conn.execute(
|
|
1300
|
+
"SELECT edge_type, COUNT(*) FROM graph_edges WHERE project_path = ? GROUP BY edge_type",
|
|
1301
|
+
(proj,)
|
|
1302
|
+
).fetchall())
|
|
1303
|
+
occurrence_count = conn.execute(
|
|
1304
|
+
"SELECT COUNT(*) FROM graph_edge_occurrences WHERE project_path = ?",
|
|
1305
|
+
(proj,),
|
|
1306
|
+
).fetchone()
|
|
1307
|
+
files = conn.execute(
|
|
1308
|
+
"SELECT COUNT(*), MAX(last_scanned) FROM file_metadata WHERE project_path = ?",
|
|
1309
|
+
(proj,)
|
|
1310
|
+
).fetchone()
|
|
1311
|
+
collisions = conn.execute(
|
|
1312
|
+
"SELECT COUNT(*) FROM node_collisions WHERE project_path = ?",
|
|
1313
|
+
(proj,)
|
|
1314
|
+
).fetchone()
|
|
1315
|
+
result = {
|
|
1316
|
+
"saved_at": meta["saved_at"],
|
|
1317
|
+
"graph_version": meta["graph_version"],
|
|
1318
|
+
"backend_name": meta["backend_name"],
|
|
1319
|
+
"backend_version": meta["backend_version"],
|
|
1320
|
+
"indexed_extensions": json.loads(meta["indexed_extensions"]) if meta["indexed_extensions"] else [],
|
|
1321
|
+
"total_nodes": sum(node_types.values()),
|
|
1322
|
+
"total_edges": sum(edge_types.values()),
|
|
1323
|
+
"total_edge_occurrences": occurrence_count[0] if occurrence_count else 0,
|
|
1324
|
+
"node_types": node_types,
|
|
1325
|
+
"edge_types": edge_types,
|
|
1326
|
+
"files_indexed": files[0] or 0,
|
|
1327
|
+
"last_scanned": files[1],
|
|
1328
|
+
"node_collisions": collisions[0] or 0,
|
|
1329
|
+
}
|
|
1330
|
+
baseline = self.get_scan_baseline(project_path)
|
|
1331
|
+
if baseline:
|
|
1332
|
+
result["baseline"] = baseline
|
|
1333
|
+
return result
|
|
1334
|
+
|
|
1335
|
+
def get_source_fingerprint(self, project_path: Path) -> Optional[str]:
|
|
1336
|
+
"""The persisted graph's source fingerprint (``_source_state``), or
|
|
1337
|
+
``None`` when no graph is saved. Reads one row and invalidates nothing.
|
|
1338
|
+
"""
|
|
1339
|
+
proj = str(project_path.resolve())
|
|
1340
|
+
with self._connect() as conn:
|
|
1341
|
+
row = conn.execute(
|
|
1342
|
+
"SELECT source_fingerprint FROM graph_metadata WHERE project_path = ?",
|
|
1343
|
+
(proj,),
|
|
1344
|
+
).fetchone()
|
|
1345
|
+
return row[0] if row else None
|
|
1346
|
+
|
|
1347
|
+
def load_graph(
|
|
1348
|
+
self, project_path: Path, *, allow_stale: bool = False,
|
|
1349
|
+
files: Optional[Iterable[Path]] = None,
|
|
1350
|
+
fingerprint: Optional[str] = None,
|
|
1351
|
+
) -> Optional[CallGraphBuilder]:
|
|
1352
|
+
"""Load a persisted graph.
|
|
1353
|
+
|
|
1354
|
+
``allow_stale`` is for the incremental builder only. It first
|
|
1355
|
+
classifies source changes and then removes/rebuilds the invalidated
|
|
1356
|
+
scope itself; discarding the snapshot here would make every edit a
|
|
1357
|
+
full rebuild. Ordinary readers retain the strict fingerprint check.
|
|
1358
|
+
|
|
1359
|
+
``files`` is the caller's already-discovered source list for the
|
|
1360
|
+
fingerprint check (see ``_source_state``); without it the tree is
|
|
1361
|
+
walked a second time on every no-change query. ``fingerprint`` is a
|
|
1362
|
+
``_source_state`` digest the caller already computed for the same
|
|
1363
|
+
sources, so the files are not stat()ed a second time either.
|
|
1364
|
+
|
|
1365
|
+
Runs with the cyclic GC paused: materialising a large graph (JUnit5:
|
|
1366
|
+
~500k objects) otherwise triggers full collections every few
|
|
1367
|
+
thousand allocations, which more than doubled the load time.
|
|
1368
|
+
"""
|
|
1369
|
+
with _gc_paused():
|
|
1370
|
+
return self._load_graph(
|
|
1371
|
+
project_path, allow_stale=allow_stale, files=files, fingerprint=fingerprint,
|
|
1372
|
+
)
|
|
1373
|
+
|
|
1374
|
+
def _load_graph(
|
|
1375
|
+
self, project_path: Path, *, allow_stale: bool,
|
|
1376
|
+
files: Optional[Iterable[Path]], fingerprint: Optional[str],
|
|
1377
|
+
) -> Optional[CallGraphBuilder]:
|
|
1378
|
+
proj = str(project_path.resolve())
|
|
1379
|
+
with self._connect() as conn:
|
|
1380
|
+
conn.row_factory = sqlite3.Row
|
|
1381
|
+
row = conn.execute(
|
|
1382
|
+
"SELECT COUNT(*) as cnt FROM graph_nodes WHERE project_path = ?",
|
|
1383
|
+
(proj,)
|
|
1384
|
+
).fetchone()
|
|
1385
|
+
if row is None or row["cnt"] == 0:
|
|
1386
|
+
return None
|
|
1387
|
+
|
|
1388
|
+
# Staleness check: invalidate when any supported source changed.
|
|
1389
|
+
meta = conn.execute(
|
|
1390
|
+
"SELECT saved_at, graph_version, module_owners, source_fingerprint, "
|
|
1391
|
+
"backend_name, backend_version, indexed_extensions "
|
|
1392
|
+
"FROM graph_metadata WHERE project_path = ?",
|
|
1393
|
+
(proj,)
|
|
1394
|
+
).fetchone()
|
|
1395
|
+
if meta is None:
|
|
1396
|
+
return None
|
|
1397
|
+
|
|
1398
|
+
# Version check: graph persisted by an older resolver (or a DB
|
|
1399
|
+
# predating version stamps → NULL) may contain edges the current
|
|
1400
|
+
# resolver would not emit. Invalidate so the graph self-heals.
|
|
1401
|
+
if (
|
|
1402
|
+
meta["graph_version"] != GRAPH_SCHEMA_VERSION
|
|
1403
|
+
or meta["module_owners"] is None
|
|
1404
|
+
or meta["source_fingerprint"] is None
|
|
1405
|
+
or meta["backend_name"] is None
|
|
1406
|
+
or meta["backend_version"] != GRAPH_SCHEMA_VERSION
|
|
1407
|
+
or meta["indexed_extensions"] is None
|
|
1408
|
+
):
|
|
1409
|
+
conn.execute("DELETE FROM graph_edges WHERE project_path = ?", (proj,))
|
|
1410
|
+
conn.execute("DELETE FROM graph_edge_occurrences WHERE project_path = ?", (proj,))
|
|
1411
|
+
conn.execute("DELETE FROM graph_nodes WHERE project_path = ?", (proj,))
|
|
1412
|
+
conn.execute("DELETE FROM graph_metadata WHERE project_path = ?", (proj,))
|
|
1413
|
+
conn.execute("DELETE FROM node_collisions WHERE project_path = ?", (proj,))
|
|
1414
|
+
conn.execute("DELETE FROM graph_snapshots WHERE project_path = ?", (proj,))
|
|
1415
|
+
return None
|
|
1416
|
+
|
|
1417
|
+
if not allow_stale and fingerprint is None:
|
|
1418
|
+
fingerprint = self._source_state(project_path, files)[1]
|
|
1419
|
+
if not allow_stale and fingerprint != meta["source_fingerprint"]:
|
|
1420
|
+
conn.execute("DELETE FROM graph_edges WHERE project_path = ?", (proj,))
|
|
1421
|
+
conn.execute("DELETE FROM graph_edge_occurrences WHERE project_path = ?", (proj,))
|
|
1422
|
+
conn.execute("DELETE FROM graph_nodes WHERE project_path = ?", (proj,))
|
|
1423
|
+
conn.execute("DELETE FROM graph_metadata WHERE project_path = ?", (proj,))
|
|
1424
|
+
conn.execute("DELETE FROM node_collisions WHERE project_path = ?", (proj,))
|
|
1425
|
+
conn.execute("DELETE FROM graph_snapshots WHERE project_path = ?", (proj,))
|
|
1426
|
+
return None
|
|
1427
|
+
|
|
1428
|
+
snapshot = conn.execute(
|
|
1429
|
+
"SELECT code_key, snapshot FROM graph_snapshots WHERE project_path = ?",
|
|
1430
|
+
(proj,),
|
|
1431
|
+
).fetchone()
|
|
1432
|
+
if snapshot is not None and snapshot["code_key"] == _snapshot_code_key():
|
|
1433
|
+
builder = self._restore_snapshot(snapshot["snapshot"], project_path)
|
|
1434
|
+
if builder is not None:
|
|
1435
|
+
return builder
|
|
1436
|
+
|
|
1437
|
+
# Incremental updates add newly parsed files to this restored
|
|
1438
|
+
# builder. Preserve the root used by a fresh build so module
|
|
1439
|
+
# namespace selection (notably mixed-language Java projects) is
|
|
1440
|
+
# identical to a full rebuild.
|
|
1441
|
+
builder = CallGraphBuilder(project_root=project_path)
|
|
1442
|
+
builder.backend_names = set(meta["backend_name"].split(","))
|
|
1443
|
+
builder.indexed_extensions = set(json.loads(meta["indexed_extensions"]))
|
|
1444
|
+
builder._module_owners = {
|
|
1445
|
+
module_id: set(owners)
|
|
1446
|
+
for module_id, owners in json.loads(meta["module_owners"]).items()
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
rows = conn.execute(
|
|
1450
|
+
"SELECT * FROM graph_nodes WHERE project_path = ?",
|
|
1451
|
+
(proj,)
|
|
1452
|
+
).fetchall()
|
|
1453
|
+
keys = rows[0].keys() if rows else []
|
|
1454
|
+
for row in rows:
|
|
1455
|
+
node = GraphNode(
|
|
1456
|
+
id=row["id"],
|
|
1457
|
+
name=row["name"],
|
|
1458
|
+
node_type=NodeType[row["node_type"]],
|
|
1459
|
+
module=row["module"],
|
|
1460
|
+
file_path=row["file_path"] or "",
|
|
1461
|
+
line=row["line"] or 0,
|
|
1462
|
+
column=row["column"] or 0,
|
|
1463
|
+
end_line=(row["end_line"] or 0) if "end_line" in keys else 0,
|
|
1464
|
+
qualified_name=(row["qualified_name"] or "") if "qualified_name" in keys else "",
|
|
1465
|
+
contained_in=row["contained_in"],
|
|
1466
|
+
is_async=bool(row["is_async"]),
|
|
1467
|
+
is_entry_point=bool(row["is_entry_point"]),
|
|
1468
|
+
is_test=bool(row["is_test"]),
|
|
1469
|
+
decorators=json.loads(row["decorators"]) if row["decorators"] else [],
|
|
1470
|
+
docstring=row["docstring"],
|
|
1471
|
+
signature=row["signature"],
|
|
1472
|
+
language=row["language"] or "python",
|
|
1473
|
+
metadata=_loads_metadata(row["metadata"] or row["metadata_json"]),
|
|
1474
|
+
)
|
|
1475
|
+
builder._add_node(node)
|
|
1476
|
+
|
|
1477
|
+
# P1.10: restore the collision side table alongside the nodes.
|
|
1478
|
+
for row in conn.execute(
|
|
1479
|
+
"SELECT * FROM node_collisions WHERE project_path = ?", (proj,)
|
|
1480
|
+
).fetchall():
|
|
1481
|
+
builder.node_collisions[(row["file_path"], row["line"])] = GraphNode(
|
|
1482
|
+
id=row["node_id"],
|
|
1483
|
+
name=row["name"] or "",
|
|
1484
|
+
node_type=NodeType[row["node_type"]] if row["node_type"] else NodeType.FUNCTION,
|
|
1485
|
+
module="",
|
|
1486
|
+
file_path=row["file_path"],
|
|
1487
|
+
line=row["line"],
|
|
1488
|
+
column=0,
|
|
1489
|
+
)
|
|
1490
|
+
|
|
1491
|
+
rows = conn.execute(
|
|
1492
|
+
"SELECT * FROM graph_edges WHERE project_path = ?",
|
|
1493
|
+
(proj,)
|
|
1494
|
+
).fetchall()
|
|
1495
|
+
for row in rows:
|
|
1496
|
+
keys = row.keys()
|
|
1497
|
+
edge = GraphEdge(
|
|
1498
|
+
source=row["source"],
|
|
1499
|
+
target=row["target"],
|
|
1500
|
+
edge_type=EdgeType[row["edge_type"]],
|
|
1501
|
+
file_path=row["file_path"],
|
|
1502
|
+
line=row["line"],
|
|
1503
|
+
column=row["col"] if "col" in keys else None,
|
|
1504
|
+
confidence=float(row["confidence"]) if "confidence" in keys and row["confidence"] is not None else 1.0,
|
|
1505
|
+
metadata=_loads_metadata(row["metadata"] if "metadata" in keys else None),
|
|
1506
|
+
strategy=row["strategy"] if "strategy" in keys else None,
|
|
1507
|
+
)
|
|
1508
|
+
builder._add_edge(edge)
|
|
1509
|
+
if edge.edge_type == EdgeType.CALLS:
|
|
1510
|
+
if edge.source in builder._nodes:
|
|
1511
|
+
builder._nodes[edge.source].calls.add(edge.target)
|
|
1512
|
+
if edge.target in builder._nodes:
|
|
1513
|
+
builder._nodes[edge.target].called_by.add(edge.source)
|
|
1514
|
+
|
|
1515
|
+
for row in conn.execute(
|
|
1516
|
+
"SELECT * FROM graph_edge_occurrences WHERE project_path = ?",
|
|
1517
|
+
(proj,),
|
|
1518
|
+
).fetchall():
|
|
1519
|
+
edge = GraphEdge(
|
|
1520
|
+
source=row["source"],
|
|
1521
|
+
target=row["target"],
|
|
1522
|
+
edge_type=EdgeType[row["edge_type"]],
|
|
1523
|
+
file_path=row["file_path"] or None,
|
|
1524
|
+
line=row["line"],
|
|
1525
|
+
column=row["col"],
|
|
1526
|
+
start_byte=None if row["start_byte"] == -1 else row["start_byte"],
|
|
1527
|
+
end_byte=None if row["end_byte"] == -1 else row["end_byte"],
|
|
1528
|
+
confidence=float(row["confidence"]),
|
|
1529
|
+
metadata=_loads_metadata(row["metadata"]),
|
|
1530
|
+
strategy=row["strategy"],
|
|
1531
|
+
)
|
|
1532
|
+
builder._record_edge_occurrence(edge)
|
|
1533
|
+
|
|
1534
|
+
if self._looks_like_java_graph(builder):
|
|
1535
|
+
from adl_drift.lang.java.resolver import default_java_resolver_config
|
|
1536
|
+
builder.resolver_config = default_java_resolver_config()
|
|
1537
|
+
|
|
1538
|
+
return builder
|
|
1539
|
+
|
|
1540
|
+
def _restore_snapshot(
|
|
1541
|
+
self, blob: bytes, project_path: Path,
|
|
1542
|
+
) -> Optional[CallGraphBuilder]:
|
|
1543
|
+
"""Unpickle a ``graph_snapshots`` row into the state ``load_graph``'s
|
|
1544
|
+
row-by-row path produces; ``None`` (→ row fallback) on any failure."""
|
|
1545
|
+
try:
|
|
1546
|
+
builder = pickle.loads(blob)
|
|
1547
|
+
except Exception:
|
|
1548
|
+
return None
|
|
1549
|
+
if not isinstance(builder, CallGraphBuilder):
|
|
1550
|
+
return None
|
|
1551
|
+
builder.project_root = Path(project_path).resolve()
|
|
1552
|
+
if self._looks_like_java_graph(builder):
|
|
1553
|
+
from adl_drift.lang.java.resolver import default_java_resolver_config
|
|
1554
|
+
builder.resolver_config = default_java_resolver_config()
|
|
1555
|
+
return builder
|
|
1556
|
+
|
|
1557
|
+
@staticmethod
|
|
1558
|
+
def _looks_like_java_graph(builder: CallGraphBuilder) -> bool:
|
|
1559
|
+
"""Best-effort resolver restoration for persisted Java graphs."""
|
|
1560
|
+
for node in builder._nodes.values():
|
|
1561
|
+
if Path(node.file_path).suffix == ".java":
|
|
1562
|
+
return True
|
|
1563
|
+
if any(str(key).startswith("java_") for key in node.metadata):
|
|
1564
|
+
return True
|
|
1565
|
+
return False
|
|
1566
|
+
|
|
1567
|
+
def store_file_metadata(
|
|
1568
|
+
self,
|
|
1569
|
+
project_path: Path,
|
|
1570
|
+
file_path: Path,
|
|
1571
|
+
content_hash: str,
|
|
1572
|
+
last_modified: float,
|
|
1573
|
+
analysis_json: str,
|
|
1574
|
+
) -> None:
|
|
1575
|
+
"""Store or update metadata for a single file."""
|
|
1576
|
+
self.store_files_metadata(project_path, [(file_path, content_hash, last_modified, analysis_json)])
|
|
1577
|
+
|
|
1578
|
+
def store_files_metadata(
|
|
1579
|
+
self,
|
|
1580
|
+
project_path: Path,
|
|
1581
|
+
entries: List[Tuple[Path, str, float, str]],
|
|
1582
|
+
) -> None:
|
|
1583
|
+
"""Store or update metadata for multiple files in one connection/transaction.
|
|
1584
|
+
|
|
1585
|
+
P8 Phase 3 follow-up: `store_file_metadata` previously opened its own
|
|
1586
|
+
`sqlite3.connect()` + commit per file — every scanned file pays that
|
|
1587
|
+
overhead once, on top of the one `store_patterns` already batches.
|
|
1588
|
+
Same commit-granularity contract as `store_patterns`: a file's
|
|
1589
|
+
`patterns` rows must already be committed before that file's entry is
|
|
1590
|
+
included here (incremental rescans decide "already scanned" from
|
|
1591
|
+
`file_metadata`, not `patterns`; committing metadata ahead of a file's
|
|
1592
|
+
patterns would let a crash between the two permanently orphan that
|
|
1593
|
+
file's patterns — see `store_patterns` docstring). Callers only ever
|
|
1594
|
+
move this call *later* relative to the existing per-file
|
|
1595
|
+
`store_patterns` call, never earlier, so that invariant still holds
|
|
1596
|
+
batched across many files: the worst a crash mid-batch can do is
|
|
1597
|
+
leave those files' `file_metadata` uncommitted, which just means they
|
|
1598
|
+
get rescanned (idempotent — `store_patterns` keys patterns by content
|
|
1599
|
+
hash) rather than silently losing data.
|
|
1600
|
+
"""
|
|
1601
|
+
if not entries:
|
|
1602
|
+
return
|
|
1603
|
+
proj = str(project_path.resolve())
|
|
1604
|
+
now = datetime.now().isoformat()
|
|
1605
|
+
rows = [
|
|
1606
|
+
(proj, str(file_path.resolve()), content_hash, last_modified, now, analysis_json)
|
|
1607
|
+
for file_path, content_hash, last_modified, analysis_json in entries
|
|
1608
|
+
]
|
|
1609
|
+
with self._connect() as conn:
|
|
1610
|
+
conn.executemany("""
|
|
1611
|
+
INSERT OR REPLACE INTO file_metadata
|
|
1612
|
+
(project_path, file_path, content_hash, last_modified, last_scanned, analysis_json)
|
|
1613
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
1614
|
+
""", rows)
|
|
1615
|
+
|
|
1616
|
+
def get_file_metadata(self, project_path: Path, file_path: Path) -> Optional[Dict[str, Any]]:
|
|
1617
|
+
"""Retrieve metadata for a single file."""
|
|
1618
|
+
proj = str(project_path.resolve())
|
|
1619
|
+
fpath = str(file_path.resolve())
|
|
1620
|
+
with self._connect() as conn:
|
|
1621
|
+
conn.row_factory = sqlite3.Row
|
|
1622
|
+
row = conn.execute(
|
|
1623
|
+
"SELECT * FROM file_metadata WHERE project_path = ? AND file_path = ?",
|
|
1624
|
+
(proj, fpath),
|
|
1625
|
+
).fetchone()
|
|
1626
|
+
return dict(row) if row else None
|
|
1627
|
+
|
|
1628
|
+
def get_all_file_metadata(self, project_path: Path) -> List[Dict[str, Any]]:
|
|
1629
|
+
"""Retrieve metadata for all files in a project."""
|
|
1630
|
+
proj = str(project_path.resolve())
|
|
1631
|
+
with self._connect() as conn:
|
|
1632
|
+
conn.row_factory = sqlite3.Row
|
|
1633
|
+
rows = conn.execute(
|
|
1634
|
+
"SELECT * FROM file_metadata WHERE project_path = ?",
|
|
1635
|
+
(proj,),
|
|
1636
|
+
).fetchall()
|
|
1637
|
+
return [dict(row) for row in rows]
|
|
1638
|
+
|
|
1639
|
+
def prune_file_metadata(self, project_path: Path, current_files: set) -> None:
|
|
1640
|
+
"""Remove metadata for files that no longer exist in the project."""
|
|
1641
|
+
proj = str(project_path.resolve())
|
|
1642
|
+
with self._connect() as conn:
|
|
1643
|
+
# SQLite doesn't directly support set operations, so we delete
|
|
1644
|
+
# rows whose file_path is NOT IN the current set.
|
|
1645
|
+
if not current_files:
|
|
1646
|
+
conn.execute(
|
|
1647
|
+
"DELETE FROM file_metadata WHERE project_path = ?",
|
|
1648
|
+
(proj,),
|
|
1649
|
+
)
|
|
1650
|
+
return
|
|
1651
|
+
placeholders = ",".join("?" * len(current_files))
|
|
1652
|
+
conn.execute(
|
|
1653
|
+
f"DELETE FROM file_metadata WHERE project_path = ? AND file_path NOT IN ({placeholders})",
|
|
1654
|
+
(proj, *current_files),
|
|
1655
|
+
)
|
|
1656
|
+
|
|
1657
|
+
def search_similar(self, query: str, limit: int = 5) -> List[Dict[str, Any]]:
|
|
1658
|
+
"""Semantic search over patterns and knowledge (P2.3).
|
|
1659
|
+
|
|
1660
|
+
When an embedding engine is available and vectors have been stored,
|
|
1661
|
+
embeds the query and ranks stored embeddings by cosine similarity
|
|
1662
|
+
(numpy brute force — the table holds at most a few thousand rows,
|
|
1663
|
+
so a vector index would be overkill). Each hit carries a
|
|
1664
|
+
``similarity`` score. Falls back to SQLite LIKE text search when
|
|
1665
|
+
embeddings are unavailable or the table is empty.
|
|
1666
|
+
"""
|
|
1667
|
+
vector_hits = self._search_similar_vectors(query, limit)
|
|
1668
|
+
if vector_hits is not None:
|
|
1669
|
+
return vector_hits
|
|
1670
|
+
return self._search_similar_like(query, limit)
|
|
1671
|
+
|
|
1672
|
+
def _search_similar_vectors(
|
|
1673
|
+
self, query: str, limit: int
|
|
1674
|
+
) -> Optional[List[Dict[str, Any]]]:
|
|
1675
|
+
"""Cosine-similarity search; None → caller should use LIKE fallback."""
|
|
1676
|
+
embedder = self._get_embedder()
|
|
1677
|
+
if embedder is None:
|
|
1678
|
+
return None
|
|
1679
|
+
with self._connect() as conn:
|
|
1680
|
+
conn.row_factory = sqlite3.Row
|
|
1681
|
+
rows = conn.execute(
|
|
1682
|
+
"SELECT id, source_type, source_id, vector FROM embeddings "
|
|
1683
|
+
"WHERE source_type IN ('pattern', 'tribal')"
|
|
1684
|
+
).fetchall()
|
|
1685
|
+
if not rows:
|
|
1686
|
+
return None
|
|
1687
|
+
import numpy as np
|
|
1688
|
+
qvec = np.asarray(embedder.embed_single(query), dtype=np.float32)
|
|
1689
|
+
qnorm = float(np.linalg.norm(qvec))
|
|
1690
|
+
if qnorm == 0.0:
|
|
1691
|
+
return None
|
|
1692
|
+
scored: List[tuple] = []
|
|
1693
|
+
for row in rows:
|
|
1694
|
+
vec = np.frombuffer(row["vector"], dtype=np.float32)
|
|
1695
|
+
if vec.shape != qvec.shape:
|
|
1696
|
+
continue # stored by a different model — ignore
|
|
1697
|
+
denom = qnorm * float(np.linalg.norm(vec))
|
|
1698
|
+
if denom == 0.0:
|
|
1699
|
+
continue
|
|
1700
|
+
scored.append((
|
|
1701
|
+
float(np.dot(qvec, vec)) / denom,
|
|
1702
|
+
row["source_type"], row["source_id"],
|
|
1703
|
+
))
|
|
1704
|
+
scored.sort(key=lambda t: t[0], reverse=True)
|
|
1705
|
+
|
|
1706
|
+
out: List[Dict[str, Any]] = []
|
|
1707
|
+
for sim, source_type, source_id in scored[:limit]:
|
|
1708
|
+
if source_type == "pattern":
|
|
1709
|
+
hit = conn.execute(
|
|
1710
|
+
"SELECT * FROM patterns WHERE id = ?", (source_id,)
|
|
1711
|
+
).fetchone()
|
|
1712
|
+
if hit is None:
|
|
1713
|
+
continue
|
|
1714
|
+
d = dict(hit)
|
|
1715
|
+
else: # tribal — map to the pattern-shaped dict the LIKE path emits
|
|
1716
|
+
hit = conn.execute(
|
|
1717
|
+
"SELECT * FROM tribal_knowledge WHERE id = ?", (source_id,)
|
|
1718
|
+
).fetchone()
|
|
1719
|
+
if hit is None:
|
|
1720
|
+
continue
|
|
1721
|
+
d = {
|
|
1722
|
+
"id": hit["id"], "category": hit["topic"],
|
|
1723
|
+
"name": hit["topic"], "description": hit["content"],
|
|
1724
|
+
"file_path": None, "line": None, "confidence": 1.0,
|
|
1725
|
+
"is_approved": 1, "metadata": None,
|
|
1726
|
+
"created_at": hit["created_at"],
|
|
1727
|
+
"updated_at": hit["updated_at"],
|
|
1728
|
+
}
|
|
1729
|
+
d["similarity"] = sim
|
|
1730
|
+
out.append(d)
|
|
1731
|
+
return out
|
|
1732
|
+
|
|
1733
|
+
def _search_similar_like(self, query: str, limit: int) -> List[Dict[str, Any]]:
|
|
1734
|
+
"""Plain text LIKE search (pre-P2.3 behavior, kept as fallback)."""
|
|
1735
|
+
with self._connect() as conn:
|
|
1736
|
+
conn.row_factory = sqlite3.Row
|
|
1737
|
+
# Simple text search fallback
|
|
1738
|
+
rows = conn.execute("""
|
|
1739
|
+
SELECT * FROM patterns
|
|
1740
|
+
WHERE name LIKE ? OR description LIKE ?
|
|
1741
|
+
UNION ALL
|
|
1742
|
+
SELECT id, topic as category, topic as name, content as description,
|
|
1743
|
+
NULL as file_path, NULL as line, 1.0 as confidence,
|
|
1744
|
+
1 as is_approved, NULL as metadata, created_at, updated_at
|
|
1745
|
+
FROM tribal_knowledge
|
|
1746
|
+
WHERE topic LIKE ? OR content LIKE ?
|
|
1747
|
+
LIMIT ?
|
|
1748
|
+
""", (f"%{query}%", f"%{query}%", f"%{query}%", f"%{query}%", limit)).fetchall()
|
|
1749
|
+
return [dict(row) for row in rows]
|