adl-toolkit 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/INSTALL_TOOLKIT.md +163 -0
  2. package/README.md +269 -2
  3. package/adl_drift/__init__.py +6 -0
  4. package/adl_drift/cli/__init__.py +1 -0
  5. package/adl_drift/cli/commands.py +993 -0
  6. package/adl_drift/cli/main.py +744 -0
  7. package/adl_drift/cortex/__init__.py +1 -0
  8. package/adl_drift/cortex/embeddings.py +96 -0
  9. package/adl_drift/cortex/memory.py +1749 -0
  10. package/adl_drift/cortex/patterns.py +89 -0
  11. package/adl_drift/cortex/telemetry.py +192 -0
  12. package/adl_drift/detectors/__init__.py +1 -0
  13. package/adl_drift/detectors/async_patterns.py +115 -0
  14. package/adl_drift/detectors/base.py +121 -0
  15. package/adl_drift/detectors/config.py +122 -0
  16. package/adl_drift/detectors/data_access.py +128 -0
  17. package/adl_drift/detectors/decorators.py +122 -0
  18. package/adl_drift/detectors/dynamic_patterns.py +260 -0
  19. package/adl_drift/detectors/errors.py +134 -0
  20. package/adl_drift/detectors/imports.py +132 -0
  21. package/adl_drift/detectors/logging_patterns.py +108 -0
  22. package/adl_drift/detectors/routes.py +257 -0
  23. package/adl_drift/detectors/security.py +236 -0
  24. package/adl_drift/detectors/taint.py +406 -0
  25. package/adl_drift/detectors/tests.py +95 -0
  26. package/adl_drift/detectors/type_conventions.py +81 -0
  27. package/adl_drift/graph/__init__.py +1 -0
  28. package/adl_drift/graph/builder.py +2670 -0
  29. package/adl_drift/graph/cfg.py +461 -0
  30. package/adl_drift/graph/cluster.py +40 -0
  31. package/adl_drift/graph/edges.py +86 -0
  32. package/adl_drift/graph/node.py +90 -0
  33. package/adl_drift/harness/__init__.py +10 -0
  34. package/adl_drift/harness/setup.py +832 -0
  35. package/adl_drift/harness/skills/drift-antigravity.md +19 -0
  36. package/adl_drift/harness/skills/drift-generic.md +19 -0
  37. package/adl_drift/harness/skills/drift.md +25 -0
  38. package/adl_drift/harness/watch.py +84 -0
  39. package/adl_drift/lang/__init__.py +8 -0
  40. package/adl_drift/lang/base.py +464 -0
  41. package/adl_drift/lang/java/__init__.py +6 -0
  42. package/adl_drift/lang/java/backend.py +97 -0
  43. package/adl_drift/lang/java/cfg.py +1424 -0
  44. package/adl_drift/lang/java/config.py +707 -0
  45. package/adl_drift/lang/java/detectors/__init__.py +29 -0
  46. package/adl_drift/lang/java/detectors/additional.py +445 -0
  47. package/adl_drift/lang/java/detectors/base.py +154 -0
  48. package/adl_drift/lang/java/detectors/data_access.py +148 -0
  49. package/adl_drift/lang/java/detectors/imports.py +53 -0
  50. package/adl_drift/lang/java/detectors/routes.py +159 -0
  51. package/adl_drift/lang/java/detectors/security.py +386 -0
  52. package/adl_drift/lang/java/detectors/tests.py +74 -0
  53. package/adl_drift/lang/java/extractor.py +582 -0
  54. package/adl_drift/lang/java/ids.py +99 -0
  55. package/adl_drift/lang/java/parser.py +49 -0
  56. package/adl_drift/lang/java/resolution/__init__.py +1 -0
  57. package/adl_drift/lang/java/resolution/constants.py +1619 -0
  58. package/adl_drift/lang/java/resolution/constructors.py +278 -0
  59. package/adl_drift/lang/java/resolution/context.py +305 -0
  60. package/adl_drift/lang/java/resolution/dispatch.py +540 -0
  61. package/adl_drift/lang/java/resolution/functional.py +1318 -0
  62. package/adl_drift/lang/java/resolution/generated.py +95 -0
  63. package/adl_drift/lang/java/resolution/model.py +153 -0
  64. package/adl_drift/lang/java/resolution/orchestration.py +866 -0
  65. package/adl_drift/lang/java/resolution/overloads.py +1275 -0
  66. package/adl_drift/lang/java/resolution/scopes.py +1111 -0
  67. package/adl_drift/lang/java/resolution/types.py +2947 -0
  68. package/adl_drift/lang/java/resolver.py +1401 -0
  69. package/adl_drift/lang/java/scopes.py +69 -0
  70. package/adl_drift/lang/java/taint.py +500 -0
  71. package/adl_drift/lang/java/type_provenance.py +342 -0
  72. package/adl_drift/lang/python/__init__.py +8 -0
  73. package/adl_drift/lang/python/backend.py +94 -0
  74. package/adl_drift/lang/python/cfg.py +537 -0
  75. package/adl_drift/lang/python/extractor.py +454 -0
  76. package/adl_drift/lang/python/parser.py +41 -0
  77. package/adl_drift/lang/python/resolver.py +1789 -0
  78. package/adl_drift/lang/python/taint.py +365 -0
  79. package/adl_drift/lang/registry.py +117 -0
  80. package/adl_drift/parser/__init__.py +1 -0
  81. package/adl_drift/parser/cache.py +38 -0
  82. package/adl_drift/parser/core.py +33 -0
  83. package/adl_drift/parser/extractor.py +17 -0
  84. package/adl_drift/parser/literal_eval.py +326 -0
  85. package/adl_drift/scanner/__init__.py +1 -0
  86. package/adl_drift/scanner/discovery.py +338 -0
  87. package/adl_drift/scanner/incremental.py +261 -0
  88. package/adl_drift/scanner/parallel.py +224 -0
  89. package/adl_drift/server/__init__.py +1 -0
  90. package/adl_drift/server/handlers.py +2849 -0
  91. package/adl_drift/server/mcp_server.py +127 -0
  92. package/adl_drift/server/mcp_stdio.py +335 -0
  93. package/adl_drift/server/protocol.py +71 -0
  94. package/adl_drift/server/static/index.html +696 -0
  95. package/adl_drift/server/web.py +350 -0
  96. package/adl_drift/utils/__init__.py +1 -0
  97. package/adl_drift/utils/config.py +77 -0
  98. package/adl_drift/utils/fswalk.py +52 -0
  99. package/adl_drift/utils/types.py +345 -0
  100. package/bin/adl-drift.mjs +35 -0
  101. package/bin/adl-toolkit.mjs +488 -0
  102. package/package.json +29 -4
  103. package/pyproject.toml +49 -0
  104. 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]