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.
- package/INSTALL_TOOLKIT.md +163 -0
- package/README.md +269 -2
- package/adl_drift/__init__.py +6 -0
- package/adl_drift/cli/__init__.py +1 -0
- package/adl_drift/cli/commands.py +993 -0
- package/adl_drift/cli/main.py +744 -0
- package/adl_drift/cortex/__init__.py +1 -0
- package/adl_drift/cortex/embeddings.py +96 -0
- package/adl_drift/cortex/memory.py +1749 -0
- package/adl_drift/cortex/patterns.py +89 -0
- package/adl_drift/cortex/telemetry.py +192 -0
- package/adl_drift/detectors/__init__.py +1 -0
- package/adl_drift/detectors/async_patterns.py +115 -0
- package/adl_drift/detectors/base.py +121 -0
- package/adl_drift/detectors/config.py +122 -0
- package/adl_drift/detectors/data_access.py +128 -0
- package/adl_drift/detectors/decorators.py +122 -0
- package/adl_drift/detectors/dynamic_patterns.py +260 -0
- package/adl_drift/detectors/errors.py +134 -0
- package/adl_drift/detectors/imports.py +132 -0
- package/adl_drift/detectors/logging_patterns.py +108 -0
- package/adl_drift/detectors/routes.py +257 -0
- package/adl_drift/detectors/security.py +236 -0
- package/adl_drift/detectors/taint.py +406 -0
- package/adl_drift/detectors/tests.py +95 -0
- package/adl_drift/detectors/type_conventions.py +81 -0
- package/adl_drift/graph/__init__.py +1 -0
- package/adl_drift/graph/builder.py +2670 -0
- package/adl_drift/graph/cfg.py +461 -0
- package/adl_drift/graph/cluster.py +40 -0
- package/adl_drift/graph/edges.py +86 -0
- package/adl_drift/graph/node.py +90 -0
- package/adl_drift/harness/__init__.py +10 -0
- package/adl_drift/harness/setup.py +832 -0
- package/adl_drift/harness/skills/drift-antigravity.md +19 -0
- package/adl_drift/harness/skills/drift-generic.md +19 -0
- package/adl_drift/harness/skills/drift.md +25 -0
- package/adl_drift/harness/watch.py +84 -0
- package/adl_drift/lang/__init__.py +8 -0
- package/adl_drift/lang/base.py +464 -0
- package/adl_drift/lang/java/__init__.py +6 -0
- package/adl_drift/lang/java/backend.py +97 -0
- package/adl_drift/lang/java/cfg.py +1424 -0
- package/adl_drift/lang/java/config.py +707 -0
- package/adl_drift/lang/java/detectors/__init__.py +29 -0
- package/adl_drift/lang/java/detectors/additional.py +445 -0
- package/adl_drift/lang/java/detectors/base.py +154 -0
- package/adl_drift/lang/java/detectors/data_access.py +148 -0
- package/adl_drift/lang/java/detectors/imports.py +53 -0
- package/adl_drift/lang/java/detectors/routes.py +159 -0
- package/adl_drift/lang/java/detectors/security.py +386 -0
- package/adl_drift/lang/java/detectors/tests.py +74 -0
- package/adl_drift/lang/java/extractor.py +582 -0
- package/adl_drift/lang/java/ids.py +99 -0
- package/adl_drift/lang/java/parser.py +49 -0
- package/adl_drift/lang/java/resolution/__init__.py +1 -0
- package/adl_drift/lang/java/resolution/constants.py +1619 -0
- package/adl_drift/lang/java/resolution/constructors.py +278 -0
- package/adl_drift/lang/java/resolution/context.py +305 -0
- package/adl_drift/lang/java/resolution/dispatch.py +540 -0
- package/adl_drift/lang/java/resolution/functional.py +1318 -0
- package/adl_drift/lang/java/resolution/generated.py +95 -0
- package/adl_drift/lang/java/resolution/model.py +153 -0
- package/adl_drift/lang/java/resolution/orchestration.py +866 -0
- package/adl_drift/lang/java/resolution/overloads.py +1275 -0
- package/adl_drift/lang/java/resolution/scopes.py +1111 -0
- package/adl_drift/lang/java/resolution/types.py +2947 -0
- package/adl_drift/lang/java/resolver.py +1401 -0
- package/adl_drift/lang/java/scopes.py +69 -0
- package/adl_drift/lang/java/taint.py +500 -0
- package/adl_drift/lang/java/type_provenance.py +342 -0
- package/adl_drift/lang/python/__init__.py +8 -0
- package/adl_drift/lang/python/backend.py +94 -0
- package/adl_drift/lang/python/cfg.py +537 -0
- package/adl_drift/lang/python/extractor.py +454 -0
- package/adl_drift/lang/python/parser.py +41 -0
- package/adl_drift/lang/python/resolver.py +1789 -0
- package/adl_drift/lang/python/taint.py +365 -0
- package/adl_drift/lang/registry.py +117 -0
- package/adl_drift/parser/__init__.py +1 -0
- package/adl_drift/parser/cache.py +38 -0
- package/adl_drift/parser/core.py +33 -0
- package/adl_drift/parser/extractor.py +17 -0
- package/adl_drift/parser/literal_eval.py +326 -0
- package/adl_drift/scanner/__init__.py +1 -0
- package/adl_drift/scanner/discovery.py +338 -0
- package/adl_drift/scanner/incremental.py +261 -0
- package/adl_drift/scanner/parallel.py +224 -0
- package/adl_drift/server/__init__.py +1 -0
- package/adl_drift/server/handlers.py +2849 -0
- package/adl_drift/server/mcp_server.py +127 -0
- package/adl_drift/server/mcp_stdio.py +335 -0
- package/adl_drift/server/protocol.py +71 -0
- package/adl_drift/server/static/index.html +696 -0
- package/adl_drift/server/web.py +350 -0
- package/adl_drift/utils/__init__.py +1 -0
- package/adl_drift/utils/config.py +77 -0
- package/adl_drift/utils/fswalk.py +52 -0
- package/adl_drift/utils/types.py +345 -0
- package/bin/adl-drift.mjs +35 -0
- package/bin/adl-toolkit.mjs +488 -0
- package/package.json +29 -4
- package/pyproject.toml +49 -0
- package/scripts/install_toolkit.py +3584 -0
|
@@ -0,0 +1,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"))
|