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,993 @@
1
+ """Command implementations.
2
+
3
+ Business logic backing the CLI commands.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import time
10
+ from pathlib import Path
11
+ from typing import List, Dict, Set, Tuple, Any, Optional
12
+
13
+ from adl_drift.detectors.base import DetectorRegistry
14
+ from adl_drift.lang.registry import backend_for_file, detect_backends, get_backend
15
+ from adl_drift.cortex.memory import CortexMemory, GRAPH_CACHE_RACY_WINDOW_SECONDS
16
+ from adl_drift.cortex.patterns import PatternManager
17
+ from adl_drift.cortex.telemetry import TelemetryRecorder
18
+ from adl_drift.graph.builder import CallGraphBuilder
19
+ from adl_drift.scanner.incremental import (
20
+ IncrementalScanner,
21
+ java_declaration_fingerprint,
22
+ java_incremental_scope,
23
+ )
24
+ from adl_drift.scanner.parallel import parallel_analyze, should_parallelize
25
+ from adl_drift.scanner.discovery import discover_file_groups, discover_files, select_backend
26
+
27
+
28
+ from adl_drift.utils.types import (
29
+ LOCALS_MARKER, SCAN_FILE_ERRORS, Pattern, PatternCategory, ProjectContext, SourcePosition,
30
+ )
31
+ from typing import Callable
32
+
33
+
34
+ class DriftCommands:
35
+ """Implements all CLI commands."""
36
+
37
+ def __init__(self, project_path: Path):
38
+ self.project_path = project_path
39
+ # P2.5: per-file errors from the most recent scan() call —
40
+ # [{"file": str, "error": str}]. Read by the CLI for the summary line.
41
+ self.last_scan_errors: List[Dict[str, str]] = []
42
+ # Baseline context from the most recent scan() call (files, LOC, symbols, ...).
43
+ self.last_scan_context: Optional[ProjectContext] = None
44
+ # Explains whether the latest graph update was dependency-scoped or
45
+ # deliberately fell back to a full rebuild. Consumed by P6 metrics and
46
+ # useful to callers that must not mistake a safe fallback for a fast
47
+ # incremental update.
48
+ self.last_graph_update: Dict[str, Any] = {}
49
+ self.project_root = CallGraphBuilder.find_project_root(project_path) or project_path
50
+ self.memory = CortexMemory.for_project(self.project_root)
51
+ self.pattern_manager = PatternManager(self.memory)
52
+ # PM.2: CLI queries record the same telemetry the MCP path does, tagged
53
+ # source="cli" so the monitor can tell agent calls from shell calls.
54
+ self._telemetry = TelemetryRecorder(self.memory, self.project_root)
55
+
56
+ self.backend = None
57
+ # Language detection and the build-file config for a root are reused
58
+ # by `_file_groups` for that same root: a CLI invocation otherwise
59
+ # walked the whole tree and parsed every build file twice per query.
60
+ self._detected_root: Optional[Path] = None
61
+ self._detected_backends: Optional[List[Any]] = None
62
+ self._config_root: Optional[Path] = None
63
+ self._configure_backend(project_path)
64
+
65
+ def _configure_backend(self, path: Path, backend_name: Optional[str] = None) -> None:
66
+ if backend_name:
67
+ backend = get_backend(backend_name)
68
+ else:
69
+ self._detected_backends = detect_backends(path)
70
+ self._detected_root = path
71
+ backend = select_backend(path, candidates=self._detected_backends)
72
+ if self.backend is not None and self.backend.name == backend.name:
73
+ return
74
+ self.backend = backend
75
+ self.config = backend.project_config(path)
76
+ self._config_root = path
77
+ self.parser = backend.parser()
78
+ self.extractor = backend.extractor()
79
+ self.registry = DetectorRegistry(backend.detectors())
80
+ security = self.registry.get_for_category(PatternCategory.SECURITY)
81
+ self._security_detector = security[0] if security else None
82
+
83
+ def _resolver_config(self):
84
+ return self.backend.resolver_config()
85
+
86
+ def _route_patterns(self, parse_result: Any, file_path: Path) -> List[Pattern]:
87
+ """ROUTES-only detection for the structural-only graph-build path.
88
+
89
+ `build_graph()` (unlike `scan()`) never runs the full detector set,
90
+ but callers (impact/caller/callee/path queries) still need route
91
+ handlers marked as entry points (`graph/builder.py` reads
92
+ `result.patterns` for this). Mirrors
93
+ `MCPHandlers._route_patterns` (server/handlers.py).
94
+ """
95
+ patterns: List[Pattern] = []
96
+ for detector in self.registry.get_for_category(PatternCategory.ROUTES):
97
+ try:
98
+ patterns.extend(detector.detect(parse_result.tree, parse_result.source_bytes, file_path))
99
+ except Exception:
100
+ continue
101
+ return patterns
102
+
103
+ @staticmethod
104
+ def _backend_name(file_path: Path) -> str:
105
+ backend = backend_for_file(file_path)
106
+ return backend.name if backend else "python"
107
+
108
+ def _file_groups(self, path: Path, language: Optional[str] = None) -> Dict[str, List[Path]]:
109
+ configs = (
110
+ {self.backend.name: self.config}
111
+ if self.backend is not None and path == self._config_root else {}
112
+ )
113
+ if language:
114
+ files = discover_files(path, get_backend(language), config=configs.get(language))
115
+ return {language: files} if files else {}
116
+ return discover_file_groups(
117
+ path,
118
+ backends=self._detected_backends if path == self._detected_root else None,
119
+ configs=configs,
120
+ )
121
+
122
+ @classmethod
123
+ def _group_paths(cls, paths: List[Path]) -> Dict[str, List[Path]]:
124
+ groups: Dict[str, List[Path]] = {}
125
+ for path in paths:
126
+ groups.setdefault(cls._backend_name(path), []).append(path)
127
+ return groups
128
+
129
+ def _parse_file(self, file_path: Path):
130
+ self._configure_backend(file_path, self._backend_name(file_path))
131
+ return self.parser.parse_file(file_path)
132
+
133
+ def _resolve_graph(
134
+ self,
135
+ builder: "CallGraphBuilder",
136
+ parses: Dict[Path, Any],
137
+ results: Dict[Path, Any],
138
+ ) -> None:
139
+ if not parses:
140
+ return
141
+ builder.resolve_relationships(results)
142
+ for backend_name in sorted({self._backend_name(path) for path in parses}):
143
+ self._configure_backend(self.project_path, backend_name)
144
+ builder.resolver_config = self._resolver_config()
145
+ builder._resolver = None
146
+ group = {
147
+ path: parse for path, parse in parses.items()
148
+ if self._backend_name(path) == backend_name
149
+ }
150
+ builder.resolve_decorator_types(group, results)
151
+ builder.resolve_calls(group, results)
152
+ builder.harvest_literal_candidates(group, results)
153
+
154
+ @staticmethod
155
+ def _java_declaration_fingerprint(result: Any) -> Tuple[Any, ...]:
156
+ """Thin wrapper: the logic lives in ``scanner.incremental`` so the MCP
157
+ graph-build path uses the identical definition (MCP_INCREMENTAL_REBUILD_PLAN.md)."""
158
+ return java_declaration_fingerprint(result)
159
+
160
+ def _java_incremental_scope(
161
+ self,
162
+ root: Path,
163
+ scanner: IncrementalScanner,
164
+ changed: List[Path],
165
+ deleted: List[Path],
166
+ ) -> Tuple[Optional[Set[Path]], str]:
167
+ """Thin wrapper over ``scanner.incremental.java_incremental_scope``."""
168
+ return java_incremental_scope(root, scanner, changed, deleted)
169
+
170
+ def scan(
171
+ self,
172
+ path: Path,
173
+ category: Optional[str] = None,
174
+ incremental: bool = False,
175
+ progress_callback: Optional[Callable[[int, int, str], None]] = None,
176
+ language: Optional[str] = None,
177
+ ) -> List[Dict[str, Any]]:
178
+ """Scan path and return patterns.
179
+
180
+ When *incremental* is ``True``, only files whose content hash has
181
+ changed since the last scan are re-parsed. Patterns for unchanged
182
+ files are loaded from the cache.
183
+
184
+ *progress_callback(done, total, detail)* is called after each file is
185
+ processed. ``detail`` is either the file path string or a phase
186
+ marker of the form ``"PHASE: <name>"``. Pass ``None`` (default) to
187
+ suppress all progress reporting (MCP, tests).
188
+ """
189
+ t0 = time.perf_counter()
190
+ if language:
191
+ self._configure_backend(path, language)
192
+ groups = self._file_groups(path, language)
193
+ scope_extensions = set(get_backend(language).file_extensions) if language else None
194
+ files = sorted((file for group in groups.values() for file in group), key=str)
195
+ total = len(files)
196
+ total_loc, non_blank_loc = self._count_lines(files)
197
+ done_count = 0
198
+ scanner = IncrementalScanner(self.memory, self.parser, self.extractor, self.registry)
199
+ self.last_scan_errors = []
200
+
201
+ def _cb(detail: str, *, advance: bool = True) -> None:
202
+ nonlocal done_count
203
+ if progress_callback is None:
204
+ return
205
+ if advance:
206
+ done_count += 1
207
+ progress_callback(done_count, total, detail)
208
+
209
+ if incremental:
210
+ changed, unchanged, deleted = scanner.classify_files(
211
+ path, files, scope_extensions
212
+ )
213
+ if not changed and not deleted:
214
+ # Fast path – nothing changed, serve from cache
215
+ return self._patterns_from_cached_results(path, unchanged, category)
216
+ else:
217
+ changed = files
218
+ unchanged = []
219
+ deleted = []
220
+
221
+ results: List[Dict[str, Any]] = []
222
+ all_patterns: List[Pattern] = []
223
+ parses: Dict[Path, Any] = {}
224
+ analysis_results: Dict[Path, Any] = {}
225
+ pending_metadata: List[Any] = []
226
+
227
+ def _append_patterns(patterns: List[Pattern]) -> None:
228
+ """Store all patterns for indexing, but only emit filtered ones
229
+ to the CLI/MCP result list when a category filter is active."""
230
+ display = patterns
231
+ if category:
232
+ display = [p for p in patterns if p.category.name.lower() == category.lower()]
233
+ self.memory.store_patterns(display)
234
+ for p in display:
235
+ results.append({
236
+ "name": p.name,
237
+ "category": p.category.name,
238
+ "description": p.description,
239
+ "file": str(p.file_path),
240
+ "line": p.position.line,
241
+ "confidence": p.confidence,
242
+ "metadata": p.metadata,
243
+ })
244
+
245
+ need_graph = path.is_dir()
246
+ use_parallel = should_parallelize(len(changed))
247
+
248
+ if use_parallel:
249
+ # Pass category=None so we can build the full baseline category
250
+ # breakdown and cache all patterns; filtering happens in _append_patterns.
251
+ for backend_name, backend_files in self._group_paths(changed).items():
252
+ self._configure_backend(path, backend_name)
253
+ for fp, ar, patterns, err in parallel_analyze(
254
+ backend_files, backend_name=backend_name, category_filter=None
255
+ ):
256
+ if err is not None or ar is None:
257
+ self.last_scan_errors.append({
258
+ "file": str(fp), "error": err or "unknown",
259
+ })
260
+ _cb(str(fp))
261
+ continue
262
+ all_patterns.extend(patterns)
263
+ _append_patterns(patterns)
264
+ ar.patterns = patterns
265
+ analysis_results[fp] = ar
266
+ pending_metadata.append((fp, ar))
267
+ _cb(str(fp))
268
+ scanner.store_results(path, pending_metadata)
269
+ pending_metadata = []
270
+ if need_graph:
271
+ _cb("PHASE: re-parsing for graph", advance=False)
272
+ for fp in list(analysis_results):
273
+ try:
274
+ parses[fp] = self._parse_file(fp)
275
+ except SCAN_FILE_ERRORS as e:
276
+ analysis_results.pop(fp, None)
277
+ self.last_scan_errors.append({
278
+ "file": str(fp), "error": f"{type(e).__name__}: {e}",
279
+ })
280
+ else:
281
+ for backend_name, backend_files in self._group_paths(changed).items():
282
+ self._configure_backend(path, backend_name)
283
+ for file_path in backend_files:
284
+ try:
285
+ parse_result = self.parser.parse_file(file_path)
286
+ # P2.18: pre-compute def-use facts for Python security checks.
287
+ if backend_name == "python":
288
+ self._inject_def_use_facts(parse_result, file_path)
289
+ patterns = self.registry.run_all(parse_result.tree, parse_result.source_bytes, file_path)
290
+ if self._security_detector is not None and hasattr(self._security_detector, "def_use_facts"):
291
+ self._security_detector.def_use_facts = {}
292
+ ar = self.extractor._build(parse_result, file_path)
293
+
294
+ all_patterns.extend(patterns)
295
+ _append_patterns(patterns)
296
+
297
+ # P1.11: carry patterns on the result (see parallel branch).
298
+ ar.patterns = patterns
299
+ parses[file_path] = parse_result
300
+ analysis_results[file_path] = ar
301
+ pending_metadata.append((file_path, ar))
302
+ except SCAN_FILE_ERRORS as e:
303
+ self.last_scan_errors.append({
304
+ "file": str(file_path),
305
+ "error": f"{type(e).__name__}: {e}",
306
+ })
307
+ _cb(str(file_path))
308
+ scanner.store_results(path, pending_metadata)
309
+
310
+ # Load unchanged results so the graph stays complete
311
+ for file_path in unchanged:
312
+ ar = scanner.load_cached_result(path, file_path)
313
+ if ar is not None:
314
+ analysis_results[file_path] = ar
315
+ if not incremental:
316
+ # Full scan mode: also emit patterns from cache
317
+ patterns = ar.patterns or []
318
+ all_patterns.extend(patterns)
319
+ _append_patterns(patterns)
320
+
321
+ graph_unchanged = list(unchanged)
322
+ if scope_extensions is not None:
323
+ for row in self.memory.get_all_file_metadata(path):
324
+ file_path = Path(row["file_path"])
325
+ if file_path.suffix in scope_extensions:
326
+ continue
327
+ ar = scanner.load_cached_result(path, file_path)
328
+ if ar is not None:
329
+ analysis_results[file_path] = ar
330
+ graph_unchanged.append(file_path)
331
+
332
+ # P2.18: CFG + reaching-definitions pass on changed files
333
+ python_parses = {
334
+ file_path: parse for file_path, parse in parses.items()
335
+ if self._backend_name(file_path) == "python"
336
+ }
337
+ if python_parses:
338
+ _cb("PHASE: def-use analysis", advance=False)
339
+ self._run_def_use_pass(path, python_parses, analysis_results)
340
+
341
+ # Build / update graph
342
+ builder = None
343
+ if path.is_dir() and analysis_results:
344
+ _cb("PHASE: building graph", advance=False)
345
+ builder = self._update_graph_incrementally(
346
+ path, scanner, changed, graph_unchanged, deleted, parses,
347
+ analysis_results, scope_extensions,
348
+ )
349
+ _cb("PHASE: saving index", advance=False)
350
+ # P2.19: taint analysis (source→sink) over the freshly built graph.
351
+ _cb("PHASE: taint analysis", advance=False)
352
+ self._run_taint_pass(path, builder, python_parses, results, category)
353
+
354
+ duration_ms = (time.perf_counter() - t0) * 1000.0
355
+ context = self._build_project_context(
356
+ path, files, total_loc, non_blank_loc,
357
+ analysis_results, all_patterns, builder,
358
+ )
359
+ self.last_scan_context = context
360
+ try:
361
+ self.memory.store_scan(context, duration_ms, json.dumps({
362
+ "categories": context.patterns_by_category,
363
+ "errors": len(self.last_scan_errors),
364
+ }))
365
+ except Exception:
366
+ pass
367
+
368
+ return results
369
+
370
+ @staticmethod
371
+ def _count_lines(files: List[Path]) -> Tuple[int, int]:
372
+ """Return (total_lines, non_blank_lines) for a list of files."""
373
+ total_lines = 0
374
+ non_blank = 0
375
+ for fp in files:
376
+ try:
377
+ text = fp.read_text(encoding="utf-8", errors="ignore")
378
+ lines = text.splitlines()
379
+ total_lines += len(lines)
380
+ non_blank += sum(1 for ln in lines if ln.strip())
381
+ except OSError:
382
+ pass
383
+ return total_lines, non_blank
384
+
385
+ def _build_project_context(
386
+ self,
387
+ path: Path,
388
+ files: List[Path],
389
+ total_loc: int,
390
+ non_blank_loc: int,
391
+ analysis_results: Dict[Path, Any],
392
+ all_patterns: List[Pattern],
393
+ builder: Optional["CallGraphBuilder"],
394
+ ) -> ProjectContext:
395
+ """Aggregate project-size baseline metrics from a completed scan."""
396
+ total_symbols = 0
397
+ total_functions = 0
398
+ total_classes = 0
399
+ total_variables = 0
400
+ for ar in analysis_results.values():
401
+ total_symbols += len(ar.symbols)
402
+ for sym in ar.symbols:
403
+ if sym.kind == "function":
404
+ total_functions += 1
405
+ elif sym.kind == "class":
406
+ total_classes += 1
407
+ elif sym.kind in ("variable", "attribute"):
408
+ total_variables += 1
409
+
410
+ entry_points = 0
411
+ calls_edges = 0
412
+ if builder is not None:
413
+ stats = builder.stats()
414
+ entry_points = stats.get("entry_points", 0)
415
+ calls_edges = stats.get("edge_types", {}).get("CALLS", 0)
416
+
417
+ patterns_by_category: Dict[str, int] = {}
418
+ for p in all_patterns:
419
+ cat = p.category.name
420
+ patterns_by_category[cat] = patterns_by_category.get(cat, 0) + 1
421
+
422
+ return ProjectContext(
423
+ root_path=path if path.is_dir() else path.parent,
424
+ files_analyzed=len(files),
425
+ total_symbols=total_symbols,
426
+ total_patterns=len(all_patterns),
427
+ frameworks=self.config.frameworks,
428
+ lines_of_code=total_loc,
429
+ non_blank_lines=non_blank_loc,
430
+ total_functions=total_functions,
431
+ total_classes=total_classes,
432
+ total_variables=total_variables,
433
+ total_modules=len(analysis_results),
434
+ entry_points=entry_points,
435
+ calls_edges=calls_edges,
436
+ patterns_by_category=patterns_by_category,
437
+ excluded_files=self._excluded_source_files(),
438
+ )
439
+
440
+ def _excluded_source_files(self) -> List[Dict[str, Any]]:
441
+ """P1 diagnostics: files on disk that ``self.config`` correctly
442
+ left out of scope, with a reason. Duck-typed (not a Java import
443
+ here) since `self.config` is whichever backend's ProjectConfig was
444
+ last configured; only `JavaProjectConfig` currently implements it."""
445
+ method = getattr(self.config, "excluded_source_files", None)
446
+ if method is None:
447
+ return []
448
+ try:
449
+ return method()
450
+ except OSError:
451
+ return []
452
+
453
+ def _inject_def_use_facts(self, parse_result: Any, file_path: Path) -> None:
454
+ """P2.18: run CFG analysis on one file and load facts into SecurityDetector."""
455
+ try:
456
+ from adl_drift.graph.cfg import analyze_file
457
+ from adl_drift.graph.builder import CallGraphBuilder
458
+ tmp = CallGraphBuilder(project_root=self.project_root)
459
+ module_name = tmp._path_to_module(file_path)
460
+ facts = analyze_file(
461
+ parse_result.tree.root_node,
462
+ parse_result.source_bytes,
463
+ module_name,
464
+ {},
465
+ )
466
+ # Build function_id → {binding_name → [facts]} index
467
+ index: Dict[str, Dict[str, List[Any]]] = {}
468
+ for f in facts:
469
+ fmap = index.setdefault(f.function_id, {})
470
+ fmap.setdefault(f.binding_name, []).append(f)
471
+ # Also index by bare function name (last segment) for robustness
472
+ short_index: Dict[str, Dict[str, List[Any]]] = {}
473
+ for fid, fmap in index.items():
474
+ short = fid.split(".")[-1]
475
+ for bname, bfacts in fmap.items():
476
+ short_index.setdefault(short, {}).setdefault(bname, []).extend(bfacts)
477
+ merged = {**index, **short_index}
478
+ self._security_detector.def_use_facts = merged
479
+ except Exception:
480
+ self._security_detector.def_use_facts = {}
481
+
482
+ def _run_def_use_pass(
483
+ self,
484
+ project_path: Path,
485
+ parses: Dict[Path, Any],
486
+ analysis_results: Dict[Path, Any],
487
+ ) -> None:
488
+ """P2.18: run CFG + reaching-definitions on every function in *parses*.
489
+
490
+ Stores DefUseFact rows in SQLite for later query by SecurityDetector
491
+ and DynamicPatternDetector. Per-file failures are silently skipped
492
+ (the pass is best-effort; security findings remain even without facts).
493
+ """
494
+ from adl_drift.graph.cfg import analyze_file
495
+ from adl_drift.graph.builder import CallGraphBuilder
496
+
497
+ _tmp_builder = CallGraphBuilder(project_root=self.project_root)
498
+
499
+ for file_path, parse_result in parses.items():
500
+ try:
501
+ ar = analysis_results.get(file_path)
502
+ module_name = _tmp_builder._path_to_module(file_path)
503
+ # Build a name→id map so facts carry the correct graph node id
504
+ # cfg's _walk_for_functions keys facts by module + the chain
505
+ # of enclosing *function* names (no classes, no <locals>
506
+ # markers) — map that key to the real graph node id so facts
507
+ # carry ids that match the graph (P1.10).
508
+ node_id_map: Dict[str, str] = {}
509
+ if ar:
510
+ for sym in ar.symbols:
511
+ if sym.kind in ("function", "method"):
512
+ fn_chain = [
513
+ seg for i, seg in enumerate(sym.scope_chain)
514
+ if i + 1 < len(sym.scope_chain)
515
+ and sym.scope_chain[i + 1] == LOCALS_MARKER
516
+ ]
517
+ cfg_key = ".".join([module_name] + fn_chain + [sym.name])
518
+ graph_id = CallGraphBuilder._symbol_to_id(sym, module_name)
519
+ node_id_map[cfg_key] = graph_id
520
+
521
+ facts = analyze_file(
522
+ parse_result.tree.root_node,
523
+ parse_result.source_bytes,
524
+ module_name,
525
+ node_id_map,
526
+ )
527
+ if facts:
528
+ self.memory.store_def_use_facts(project_path, facts)
529
+ except Exception:
530
+ pass # best-effort
531
+
532
+ def _run_taint_pass(
533
+ self,
534
+ project_path: Path,
535
+ builder: "CallGraphBuilder",
536
+ parses: Dict[Path, Any],
537
+ results: List[Dict[str, Any]],
538
+ category: Optional[str],
539
+ ) -> None:
540
+ """P2.19: run intra + cross-function taint analysis, persist the paths,
541
+ and surface each confirmed source→sink path as a CRITICAL TAINT pattern.
542
+
543
+ Best-effort: per-pass failures never abort a scan. Cross-function taint
544
+ only follows calls between functions whose CSTs are in *parses* (every
545
+ file on a full scan; the changed set on an incremental scan)."""
546
+ if not parses:
547
+ return
548
+ try:
549
+ from adl_drift.detectors.taint import TaintAnalyzer
550
+ except Exception:
551
+ return
552
+ try:
553
+ paths = TaintAnalyzer().analyze(builder, parses)
554
+ except Exception:
555
+ return
556
+
557
+ # Persist: replace paths only for the functions we just re-analyzed so a
558
+ # partial (incremental) scan doesn't wipe paths in untouched functions.
559
+ analyzed_fids = list({nid for _, nid in self._iter_parsed_func_ids(builder, parses)})
560
+ try:
561
+ self.memory.store_taint_paths(project_path, paths, replace_function_ids=analyzed_fids)
562
+ except Exception:
563
+ pass
564
+
565
+ if category and category.lower() != "taint":
566
+ return # filtered scan that doesn't ask for taint — don't inject rows
567
+
568
+ for p in paths:
569
+ sev = "CRITICAL"
570
+ chain_str = " → ".join(p.chain)
571
+ pat = Pattern(
572
+ category=PatternCategory.TAINT,
573
+ name=f"taint_{p.sink_category}",
574
+ description=f"{p.sink_category}: {chain_str}",
575
+ position=SourcePosition(
576
+ line=p.sink_site.line, column=p.sink_site.column,
577
+ end_line=p.sink_site.end_line, end_column=p.sink_site.end_column,
578
+ ),
579
+ file_path=Path(p.file_path),
580
+ confidence=p.confidence,
581
+ metadata={
582
+ "severity": sev,
583
+ "sink": p.sink,
584
+ "sink_category": p.sink_category,
585
+ "source": p.source_label,
586
+ "binding": p.binding_name or "",
587
+ "chain": chain_str,
588
+ "cross_function": p.cross_function,
589
+ "function": p.function_id,
590
+ },
591
+ )
592
+ try:
593
+ self.memory.store_pattern(pat)
594
+ except Exception:
595
+ pass
596
+ results.append({
597
+ "name": pat.name,
598
+ "category": pat.category.name,
599
+ "description": pat.description,
600
+ "file": str(pat.file_path),
601
+ "line": pat.position.line,
602
+ "confidence": pat.confidence,
603
+ "metadata": pat.metadata,
604
+ })
605
+
606
+ @staticmethod
607
+ def _iter_parsed_func_ids(builder: "CallGraphBuilder", parses: Dict[Path, Any]):
608
+ """Yield (file_path, function_node_id) for every function in *parses*."""
609
+ for file_path, parse_result in parses.items():
610
+ try:
611
+ module = builder._path_to_module(file_path)
612
+ for _, nid in builder._iter_func_cst(
613
+ parse_result.tree.root_node, parse_result.source_bytes, module
614
+ ):
615
+ yield file_path, nid
616
+ except Exception:
617
+ continue
618
+
619
+ def _patterns_from_cached_results(
620
+ self,
621
+ project_path: Path,
622
+ files: List[Path],
623
+ category: Optional[str],
624
+ ) -> List[Dict[str, Any]]:
625
+ """Return patterns from cached AnalysisResults without re-parsing."""
626
+ scanner = IncrementalScanner(self.memory, self.parser, self.extractor, self.registry)
627
+ results: List[Dict[str, Any]] = []
628
+ for file_path in files:
629
+ ar = scanner.load_cached_result(project_path, file_path)
630
+ if ar is None:
631
+ continue
632
+ patterns = ar.patterns
633
+ if category:
634
+ patterns = [p for p in patterns if p.category.name.lower() == category.lower()]
635
+ for p in patterns:
636
+ results.append({
637
+ "name": p.name,
638
+ "category": p.category.name,
639
+ "description": p.description,
640
+ "file": str(p.file_path),
641
+ "line": p.position.line,
642
+ "confidence": p.confidence,
643
+ "metadata": p.metadata,
644
+ })
645
+ return results
646
+
647
+ def _update_graph_incrementally(
648
+ self,
649
+ path: Path,
650
+ scanner: IncrementalScanner,
651
+ changed: List[Path],
652
+ unchanged: List[Path],
653
+ deleted: List[Path],
654
+ parses: Dict[Path, Any],
655
+ analysis_results: Dict[Path, Any],
656
+ scope_extensions: Optional[Set[str]] = None,
657
+ ) -> "CallGraphBuilder":
658
+ """Update the persisted graph using cached + fresh results."""
659
+ builder = self.memory.load_graph(path)
660
+ if builder is None:
661
+ # No prior graph – do a full build. ``parses`` only covers changed
662
+ # files in incremental mode; reparse the rest so CALLS edges from
663
+ # unchanged callers into changed-file symbols still resolve.
664
+ builder = CallGraphBuilder(project_root=self.project_root)
665
+ for ar in analysis_results.values():
666
+ builder.add_file_result(ar)
667
+ for fp in analysis_results.keys():
668
+ if fp not in parses:
669
+ try:
670
+ parses[fp] = self._parse_file(fp)
671
+ except Exception:
672
+ continue
673
+ self._resolve_graph(builder, parses, analysis_results)
674
+ else:
675
+ # Remove stale nodes
676
+ for fp in deleted + changed:
677
+ builder.remove_file(str(fp))
678
+ # Add fresh results for changed files
679
+ for fp in changed:
680
+ if fp in analysis_results:
681
+ builder.add_file_result(analysis_results[fp])
682
+ # Resolve calls originating from changed files
683
+ if parses:
684
+ self._resolve_graph(builder, parses, analysis_results)
685
+ # Drop stale cross-file edges (target deleted/renamed) and re-sync
686
+ # the NetworkX graph with surviving inbound edges.
687
+ builder.reconcile_edges()
688
+
689
+ scanner.prune_deleted_files(
690
+ path,
691
+ {str(f.resolve()) for f in (changed + unchanged)},
692
+ scope_extensions,
693
+ )
694
+ self.memory.save_graph(path, builder)
695
+ return builder
696
+
697
+ def approve(self, pattern_id: str) -> None:
698
+ """Approve a pattern."""
699
+ self.pattern_manager.approve(pattern_id)
700
+
701
+
702
+ def list_patterns(self) -> List[Dict[str, Any]]:
703
+ """List all patterns from memory."""
704
+ return self.memory.get_patterns()
705
+
706
+ def prune(
707
+ self,
708
+ *,
709
+ aggressive: bool = False,
710
+ include_approved: bool = False,
711
+ dry_run: bool = False,
712
+ ) -> Dict[str, Any]:
713
+ """P2.20: reclaim space from the project's cortex.db.
714
+
715
+ Always reaps dead projects (root gone) and dead files (file gone).
716
+ With *aggressive*, first runs a full (non-incremental) scan of this
717
+ project so every still-detected pattern's ``updated_at`` is refreshed,
718
+ then sweeps `patterns` rows for live files that the scan did not
719
+ re-emit (the detector no longer fires there).
720
+ """
721
+ stale_before: Optional[str] = None
722
+ if aggressive:
723
+ from datetime import datetime as _dt
724
+ stale_before = _dt.now().isoformat()
725
+ # Full scan refreshes updated_at on every currently-detected pattern
726
+ # for this project's files; un-refreshed live-file rows are stale.
727
+ self.scan(self.project_path, incremental=False)
728
+ return self.memory.prune_database(
729
+ stale_before=stale_before,
730
+ include_approved=include_approved,
731
+ dry_run=dry_run,
732
+ )
733
+
734
+ def add_knowledge(self, topic: str, content: str) -> str:
735
+ """Add tribal knowledge."""
736
+ return self.memory.add_tribal_knowledge(topic, content)
737
+
738
+ def get_knowledge(self, topic: str) -> List[Dict[str, Any]]:
739
+ """Get tribal knowledge."""
740
+ return self.memory.get_tribal_knowledge(topic=topic)
741
+
742
+ def build_graph(self, path: Optional[Path] = None, incremental: bool = True) -> CallGraphBuilder:
743
+ """Parse every file under `path`, build full call graph (CONTAINS + CALLS).
744
+
745
+ When *incremental* is ``True`` (the default), only changed files are
746
+ re-parsed. The existing graph is loaded from SQLite, stale nodes are
747
+ removed, and call edges for changed files are re-resolved.
748
+ """
749
+ root = path or self.project_path
750
+ files = self._find_files(root)
751
+ scanner = IncrementalScanner(self.memory, self.parser, self.extractor, self.registry)
752
+
753
+ if incremental:
754
+ cached_update = {
755
+ "mode": "cached", "invalidated_files": [],
756
+ "reason": "no source changes",
757
+ }
758
+ # MCP_GRAPH_CACHE_PLAN.md Lớp 1's stat-based fast path, for the
759
+ # CLI: when the sources' size/mtime fingerprint still matches the
760
+ # persisted graph's — and the newest mtime is outside the racy
761
+ # window, where a same-size rewrite could hide — load it without
762
+ # hashing every file. Otherwise classify by content hash as before.
763
+ max_mtime, fingerprint = CortexMemory._source_state(root, files)
764
+ if (
765
+ fingerprint == self.memory.get_source_fingerprint(root)
766
+ and max_mtime < time.time() - GRAPH_CACHE_RACY_WINDOW_SECONDS
767
+ ):
768
+ cached = self.memory.load_graph(root, files=files, fingerprint=fingerprint)
769
+ if cached is not None:
770
+ self.last_graph_update = cached_update
771
+ return cached
772
+ changed, unchanged, deleted = scanner.classify_files(root, files)
773
+ if not changed and not deleted:
774
+ cached = self.memory.load_graph(root, files=files, fingerprint=fingerprint)
775
+ if cached is not None:
776
+ self.last_graph_update = cached_update
777
+ return cached
778
+ if any(fp.suffix == ".java" for fp in (*changed, *deleted)):
779
+ scope, reason = self._java_incremental_scope(root, scanner, changed, deleted)
780
+ if scope is None:
781
+ fallback = {
782
+ "mode": "full_rebuild", "invalidated_files": [str(fp) for fp in changed + deleted],
783
+ "reason": reason,
784
+ }
785
+ graph = self.build_graph(root, incremental=False)
786
+ self.last_graph_update = fallback
787
+ return graph
788
+ # Only body-only Java units are in scope. Keep the complete
789
+ # cached graph and re-resolve their outgoing relationships.
790
+ changed = [fp for fp in changed if fp.suffix != ".java" or fp in scope]
791
+ self.last_graph_update = {
792
+ "mode": "dependency_scoped", "invalidated_files": [str(fp) for fp in sorted(scope)],
793
+ "reason": reason,
794
+ }
795
+ else:
796
+ changed = files
797
+ unchanged = []
798
+ deleted = []
799
+ self.last_graph_update = {"mode": "full_rebuild", "invalidated_files": [str(fp) for fp in files], "reason": "requested full build"}
800
+
801
+ # A requested full rebuild must start with no persisted graph state.
802
+ # Reusing the deserialized builder retained backend/module identity
803
+ # caches, so an alleged full rebuild could disagree with a fresh
804
+ # incremental graph even when their source was identical (P3.2).
805
+ # Changes are classified above, so preserve the pre-edit snapshot for
806
+ # dependency-scoped removal/re-resolution instead of having the
807
+ # storage layer discard it solely because its source fingerprint moved.
808
+ builder = None if not incremental else self.memory.load_graph(root, allow_stale=True)
809
+ if builder is not None:
810
+ # The persisted graph is keyed by the scan root, while this
811
+ # command may have found a broader project root (for example a
812
+ # fixture without a Maven marker inside a Git worktree). Reapply
813
+ # that root before adding changed Java symbols so their module
814
+ # namespace matches a full rebuild.
815
+ builder.project_root = self.project_root.resolve()
816
+ builder._project_languages = None
817
+ builder._java_build_module_roots = None
818
+ builder._java_namespace_visibility = None
819
+ full_rebuild = builder is None
820
+ if full_rebuild:
821
+ builder = CallGraphBuilder(project_root=self.project_root)
822
+ # Cached file analyses without their matching graph snapshot are
823
+ # insufficient for a scoped update: inbound edges would be absent.
824
+ # Start fresh and expose that fact to telemetry/callers.
825
+ if incremental:
826
+ self.last_graph_update = {
827
+ "mode": "full_rebuild",
828
+ "invalidated_files": [str(fp) for fp in changed + deleted],
829
+ "reason": "persisted graph unavailable",
830
+ }
831
+
832
+ # Remove stale nodes
833
+ for fp in deleted + changed:
834
+ builder.remove_file(str(fp))
835
+
836
+ parses: Dict[Path, Any] = {}
837
+ results: Dict[Path, Any] = {}
838
+
839
+ # Re-parse changed files
840
+ for fp in changed:
841
+ try:
842
+ self._configure_backend(root, self._backend_name(fp))
843
+ pr = self.parser.parse_file(fp)
844
+ ar = self.extractor._build(pr, fp)
845
+ # P1: this path skips the full detector set, but the graph
846
+ # still needs ROUTES patterns to mark route handlers as entry
847
+ # points (see `_route_patterns`).
848
+ ar.patterns = self._route_patterns(pr, fp)
849
+ parses[fp] = pr
850
+ results[fp] = ar
851
+ except Exception:
852
+ continue
853
+ scanner.store_results(root, list(results.items()))
854
+
855
+ # Add all results to graph (changed + unchanged cached)
856
+ results.update(scanner.load_cached_results(root, unchanged))
857
+
858
+ # A persisted builder already owns every unchanged node/edge. Adding
859
+ # those results again repopulates pending indexes and defeats the
860
+ # dependency-scoped update. Full builds still add the complete set.
861
+ cached_incremental_builder = incremental and not full_rebuild
862
+ for fp, ar in results.items():
863
+ if not cached_incremental_builder or fp in changed:
864
+ builder.add_file_result(ar)
865
+
866
+ # Full rebuild (no prior graph / cache invalidated): ``parses`` only
867
+ # covers changed files, but CALLS edges originating from *unchanged*
868
+ # files exist nowhere else — re-parse them so resolve_calls() walks
869
+ # every body. Same fix as _update_graph_incrementally (P0.5).
870
+ if full_rebuild:
871
+ for fp in results.keys():
872
+ if fp not in parses:
873
+ try:
874
+ parses[fp] = self._parse_file(fp)
875
+ except Exception:
876
+ continue
877
+
878
+ if parses:
879
+ self._resolve_graph(builder, parses, results)
880
+ # Drop edges whose endpoints vanished and re-sync surviving inbound
881
+ # edges into the NetworkX graph (remove_file keeps them in _edges
882
+ # but remove_node drops them from the graph).
883
+ builder.reconcile_edges()
884
+
885
+ scanner.prune_deleted_files(root, {str(f.resolve()) for f in files})
886
+ self.memory.save_graph(root, builder, files=files)
887
+ return builder
888
+
889
+ def graph_stats(self, path: Optional[Path] = None) -> Dict[str, Any]:
890
+ t0 = time.perf_counter()
891
+ result = self.build_graph(path).stats()
892
+ self._telemetry.record(
893
+ "drift_graph", {}, result,
894
+ duration_ms=(time.perf_counter() - t0) * 1000.0, source="cli",
895
+ )
896
+ return result
897
+
898
+ def god_nodes(self, top_n: int = 10, path: Optional[Path] = None) -> List[Dict]:
899
+ return self.build_graph(path).god_nodes(top_n)
900
+
901
+ def surprising_connections(self, path: Optional[Path] = None) -> List[Dict]:
902
+ return self.build_graph(path).surprising_connections()
903
+
904
+ def impact(
905
+ self,
906
+ symbol: str,
907
+ path: Optional[Path] = None,
908
+ *,
909
+ include_initializers: bool = False,
910
+ ) -> Dict[str, Any]:
911
+ t0 = time.perf_counter()
912
+ result = self.build_graph(path).get_impact_analysis(
913
+ symbol, include_initializers=include_initializers,
914
+ )
915
+ self._telemetry.record(
916
+ "drift_impact", {
917
+ "symbol": symbol,
918
+ "include_initializers": include_initializers,
919
+ }, result,
920
+ is_error=bool(result.get("error")),
921
+ duration_ms=(time.perf_counter() - t0) * 1000.0, source="cli",
922
+ )
923
+ return result
924
+
925
+ def check_gate(self) -> List[Dict[str, Any]]:
926
+ """Check for unapproved patterns (CI gate)."""
927
+ # Scan entire project
928
+ all_patterns = []
929
+ for backend_name, files in self._file_groups(self.project_path).items():
930
+ self._configure_backend(self.project_path, backend_name)
931
+ for file_path in files:
932
+ try:
933
+ parse_result = self.parser.parse_file(file_path)
934
+ patterns = self.registry.run_all(parse_result.tree, parse_result.source_bytes, file_path)
935
+ all_patterns.extend(patterns)
936
+ except Exception:
937
+ continue
938
+
939
+ return self.pattern_manager.check_conformance(all_patterns)
940
+
941
+ def gate_report(self) -> Dict[str, Any]:
942
+ """`adl-drift gate --json` contract (HARNESS.md Part 4):
943
+ {violations, new_patterns, scan_errors, high_security, failed}.
944
+
945
+ ``failed`` (→ CLI exit 1) means HIGH-severity security findings or
946
+ scan errors — conformance violations are reported but advisory.
947
+ Unlike check_gate(), per-file failures are surfaced, not swallowed.
948
+ """
949
+ all_patterns = []
950
+ scan_errors: List[Dict[str, str]] = []
951
+ for backend_name, files in self._file_groups(self.project_path).items():
952
+ self._configure_backend(self.project_path, backend_name)
953
+ for file_path in files:
954
+ try:
955
+ parse_result = self.parser.parse_file(file_path)
956
+ patterns = self.registry.run_all(parse_result.tree, parse_result.source_bytes, file_path)
957
+ all_patterns.extend(patterns)
958
+ except SCAN_FILE_ERRORS as e:
959
+ scan_errors.append({"file": str(file_path), "error": f"{type(e).__name__}: {e}"})
960
+
961
+ known_ids = {p["id"] for p in self.memory.get_patterns()}
962
+ new_patterns = [
963
+ {"name": p.name, "category": p.category.name,
964
+ "file": str(p.file_path), "line": p.position.line}
965
+ for p in all_patterns if p.compute_hash() not in known_ids
966
+ ]
967
+ high_security = [
968
+ {"name": p.name, "description": p.description,
969
+ "file": str(p.file_path), "line": p.position.line}
970
+ for p in all_patterns
971
+ if p.category.name == "SECURITY"
972
+ and str((p.metadata or {}).get("severity", "")).upper() == "HIGH"
973
+ ]
974
+ return {
975
+ "violations": self.pattern_manager.check_conformance(all_patterns),
976
+ "new_patterns": new_patterns,
977
+ "scan_errors": scan_errors,
978
+ "high_security": high_security,
979
+ "failed": bool(high_security or scan_errors),
980
+ }
981
+
982
+ def _find_files(self, path: Path, language: Optional[str] = None) -> List[Path]:
983
+ return sorted(
984
+ (file for files in self._file_groups(path, language).values() for file in files),
985
+ key=str,
986
+ )
987
+
988
+ @classmethod
989
+ def _find_python_files(cls, path: Path) -> List[Path]:
990
+ """Compatibility wrapper for downstream Python-only callers."""
991
+ from adl_drift.lang.registry import get_backend
992
+
993
+ return discover_files(path, get_backend("python"))