vigil-codeintel 0.1.0__py3-none-any.whl
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.
- vigil_codeintel-0.1.0.dist-info/METADATA +780 -0
- vigil_codeintel-0.1.0.dist-info/RECORD +131 -0
- vigil_codeintel-0.1.0.dist-info/WHEEL +5 -0
- vigil_codeintel-0.1.0.dist-info/entry_points.txt +3 -0
- vigil_codeintel-0.1.0.dist-info/licenses/LICENSE +21 -0
- vigil_codeintel-0.1.0.dist-info/top_level.txt +3 -0
- vigil_forensic/__init__.py +224 -0
- vigil_forensic/_git_utils.py +178 -0
- vigil_forensic/_shared.py +510 -0
- vigil_forensic/_stubs.py +156 -0
- vigil_forensic/gate_checks/__init__.py +1 -0
- vigil_forensic/gate_checks/_ast_helpers.py +629 -0
- vigil_forensic/gate_checks/_deployment_detector.py +573 -0
- vigil_forensic/gate_checks/atomic_write_checks.py +1143 -0
- vigil_forensic/gate_checks/authority_checks.py +95 -0
- vigil_forensic/gate_checks/boundary_breach_checks.py +202 -0
- vigil_forensic/gate_checks/broad_except_checks.py +301 -0
- vigil_forensic/gate_checks/broad_except_hidden_sentinel_checks.py +365 -0
- vigil_forensic/gate_checks/common.py +253 -0
- vigil_forensic/gate_checks/config_safety_checks.py +704 -0
- vigil_forensic/gate_checks/config_ssot_checks.py +78 -0
- vigil_forensic/gate_checks/conflict_checks.py +193 -0
- vigil_forensic/gate_checks/context_fallback_checks.py +697 -0
- vigil_forensic/gate_checks/context_health_checks.py +289 -0
- vigil_forensic/gate_checks/contract_shape_drift_checks.py +459 -0
- vigil_forensic/gate_checks/dirty_baseline_check.py +274 -0
- vigil_forensic/gate_checks/duplication_checks.py +387 -0
- vigil_forensic/gate_checks/embedded_string_checks.py +123 -0
- vigil_forensic/gate_checks/empty_output_checks.py +87 -0
- vigil_forensic/gate_checks/encoding_checks.py +847 -0
- vigil_forensic/gate_checks/export_completeness_checks.py +156 -0
- vigil_forensic/gate_checks/fallback_checks.py +41 -0
- vigil_forensic/gate_checks/file_proliferation_checks.py +171 -0
- vigil_forensic/gate_checks/fix_without_test_checks.py +69 -0
- vigil_forensic/gate_checks/forensic_cluster_runners/__init__.py +9 -0
- vigil_forensic/gate_checks/forensic_cluster_runners/_helpers.py +71 -0
- vigil_forensic/gate_checks/forensic_cluster_runners/advanced_checks.py +322 -0
- vigil_forensic/gate_checks/forensic_cluster_runners/core.py +273 -0
- vigil_forensic/gate_checks/forensic_cluster_runners/integrity_checks.py +203 -0
- vigil_forensic/gate_checks/forensic_cluster_runners/quality_checks.py +666 -0
- vigil_forensic/gate_checks/forensic_clusters/__init__.py +193 -0
- vigil_forensic/gate_checks/forensic_clusters/allowlist.py +426 -0
- vigil_forensic/gate_checks/forensic_clusters/allowlist_writer.py +302 -0
- vigil_forensic/gate_checks/forensic_clusters/api_protocol.py +231 -0
- vigil_forensic/gate_checks/forensic_clusters/async_quality.py +1156 -0
- vigil_forensic/gate_checks/forensic_clusters/code_style.py +808 -0
- vigil_forensic/gate_checks/forensic_clusters/core.py +319 -0
- vigil_forensic/gate_checks/forensic_clusters/data_quality.py +763 -0
- vigil_forensic/gate_checks/forensic_clusters/dead_code.py +480 -0
- vigil_forensic/gate_checks/forensic_clusters/edit_mutation.py +842 -0
- vigil_forensic/gate_checks/forensic_clusters/exception_boundary.py +240 -0
- vigil_forensic/gate_checks/forensic_clusters/legacy_debt.py +556 -0
- vigil_forensic/gate_checks/forensic_clusters/static_analysis.py +834 -0
- vigil_forensic/gate_checks/forensic_clusters/structural_quality.py +298 -0
- vigil_forensic/gate_checks/god_object_zones_checks.py +173 -0
- vigil_forensic/gate_checks/hallucination_checks.py +566 -0
- vigil_forensic/gate_checks/hunter_artifact_completeness_check.py +139 -0
- vigil_forensic/gate_checks/implementation_overfit_checks.py +380 -0
- vigil_forensic/gate_checks/import_integrity_checks.py +233 -0
- vigil_forensic/gate_checks/imports_in_function_checks.py +283 -0
- vigil_forensic/gate_checks/ml_checks.py +318 -0
- vigil_forensic/gate_checks/performance_checks.py +106 -0
- vigil_forensic/gate_checks/project_specific_runner.py +691 -0
- vigil_forensic/gate_checks/provider_capability_checks.py +73 -0
- vigil_forensic/gate_checks/refactor_completeness_checks.py +274 -0
- vigil_forensic/gate_checks/reliability_checks.py +389 -0
- vigil_forensic/gate_checks/reporting_checks.py +55 -0
- vigil_forensic/gate_checks/runtime_behavior_checks.py +220 -0
- vigil_forensic/gate_checks/security_injection_checks.py +332 -0
- vigil_forensic/gate_checks/semantic_intent_checks.py +139 -0
- vigil_forensic/gate_checks/size_complexity_checks.py +336 -0
- vigil_forensic/gate_checks/stuck_feature_flag_checks.py +354 -0
- vigil_forensic/gate_checks/syntax_validity_checks.py +217 -0
- vigil_forensic/gate_checks/temporal_freshness_checks.py +79 -0
- vigil_forensic/gate_checks/test_quality_checks.py +946 -0
- vigil_forensic/gate_checks/testing_checks.py +149 -0
- vigil_forensic/gate_checks/toctou_checks.py +367 -0
- vigil_forensic/gate_checks/type_checking_checks.py +316 -0
- vigil_forensic/gate_models.py +392 -0
- vigil_forensic/gate_packs/__init__.py +1 -0
- vigil_forensic/gate_packs/universal.py +179 -0
- vigil_forensic/gate_profile.json +31 -0
- vigil_forensic/gate_registry.py +21 -0
- vigil_forensic/language_profiles.py +219 -0
- vigil_forensic/meta_findings.py +207 -0
- vigil_forensic/self_audit.py +725 -0
- vigil_forensic/source_analysis.py +175 -0
- vigil_mapper/__init__.py +103 -0
- vigil_mapper/_ast_helpers_minimal.py +229 -0
- vigil_mapper/_extract_imports_impl.py +123 -0
- vigil_mapper/_file_count_guard.py +129 -0
- vigil_mapper/_git_utils.py +178 -0
- vigil_mapper/_runtime_ast.py +438 -0
- vigil_mapper/_runtime_dispatch.py +137 -0
- vigil_mapper/_seed_helpers.py +82 -0
- vigil_mapper/authority_builder.py +1102 -0
- vigil_mapper/cli_entry.py +731 -0
- vigil_mapper/conflict_builder.py +818 -0
- vigil_mapper/data_contract_builder.py +446 -0
- vigil_mapper/findings_builder.py +716 -0
- vigil_mapper/fingerprint.py +53 -0
- vigil_mapper/hotspot_builder.py +539 -0
- vigil_mapper/map_common.py +449 -0
- vigil_mapper/map_errors.py +55 -0
- vigil_mapper/map_models.py +431 -0
- vigil_mapper/map_models_ext.py +206 -0
- vigil_mapper/map_models_findings.py +130 -0
- vigil_mapper/map_storage.py +455 -0
- vigil_mapper/parse_cache.py +795 -0
- vigil_mapper/refactor_boundary_builder.py +266 -0
- vigil_mapper/runtime_builder.py +527 -0
- vigil_mapper/runtime_tracer.py +243 -0
- vigil_mapper/runtime_tracer_entry.py +199 -0
- vigil_mapper/semantic_diff.py +71 -0
- vigil_mapper/source_adapters/__init__.py +109 -0
- vigil_mapper/source_adapters/_base.py +264 -0
- vigil_mapper/source_adapters/_ir.py +156 -0
- vigil_mapper/source_adapters/_lexer.py +309 -0
- vigil_mapper/source_adapters/_patterns.py +212 -0
- vigil_mapper/source_adapters/_treesitter.py +182 -0
- vigil_mapper/source_adapters/go.py +553 -0
- vigil_mapper/source_adapters/java.py +541 -0
- vigil_mapper/source_adapters/javascript.py +626 -0
- vigil_mapper/source_adapters/python.py +325 -0
- vigil_mapper/source_adapters/typescript.py +749 -0
- vigil_mapper/structural_builder.py +586 -0
- vigil_mcp/__init__.py +1 -0
- vigil_mcp/_jobs.py +587 -0
- vigil_mcp/_paths.py +93 -0
- vigil_mcp/forensic_server.py +419 -0
- vigil_mcp/map_server.py +452 -0
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
"""Generic runtime map builder -- Map 2.
|
|
2
|
+
|
|
3
|
+
Static AST scanner for runtime-relevant patterns in any target project.
|
|
4
|
+
|
|
5
|
+
Detects (via _runtime_ast.py):
|
|
6
|
+
- Module-level call statements (import_time_side_effects)
|
|
7
|
+
- Route/dispatch decorators (@app.route, @bp.route, @router.get, etc.)
|
|
8
|
+
- Background task spawns in __init__ / bootstrap / setup functions
|
|
9
|
+
(threading.Thread, asyncio.create_task, subprocess.Popen)
|
|
10
|
+
- Environment variable reads (os.environ.get, os.getenv, os.environ[])
|
|
11
|
+
|
|
12
|
+
Optionally merges with a seed from <project>/.cortex/map_seeds/runtime_seed.json.
|
|
13
|
+
Seed nodes are marked status="canonical" and win on name conflicts.
|
|
14
|
+
Auto-discovered nodes are marked status="inferred".
|
|
15
|
+
|
|
16
|
+
Generic design: no hardcoded application-specific node names.
|
|
17
|
+
Self-diagnosis: pass project_dir=Path(".") to run against Vigil itself.
|
|
18
|
+
|
|
19
|
+
Public API:
|
|
20
|
+
build_runtime_map_static(project_dir, include_roots) -> list[RuntimeNode]
|
|
21
|
+
build_runtime_map_full(project_dir, target_module, target_argv, timeout_s,
|
|
22
|
+
include_roots) -> tuple[list[RuntimeNode], dict]
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import ast
|
|
27
|
+
import json
|
|
28
|
+
import logging
|
|
29
|
+
import time
|
|
30
|
+
from datetime import datetime, timezone
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any, Sequence
|
|
33
|
+
|
|
34
|
+
from .map_common import iter_py_files
|
|
35
|
+
from .map_errors import MapIntegrityError
|
|
36
|
+
from .map_models import RuntimeNode
|
|
37
|
+
from .map_storage import seeds_dir
|
|
38
|
+
from ._runtime_ast import _RuntimeVisitor
|
|
39
|
+
|
|
40
|
+
__all__ = ["build_runtime_map_static", "build_runtime_map_full"]
|
|
41
|
+
|
|
42
|
+
_log = logging.getLogger(__name__)
|
|
43
|
+
|
|
44
|
+
# Seed schema version this builder supports.
|
|
45
|
+
_SUPPORTED_SEED_SCHEMA = "1.0.0"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# Helpers
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
def _freshness_now() -> str:
|
|
53
|
+
return (
|
|
54
|
+
datetime.now(timezone.utc)
|
|
55
|
+
.isoformat()
|
|
56
|
+
.replace("+00:00", "Z")
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _rel_posix(path: Path, project_dir: Path) -> str:
|
|
61
|
+
try:
|
|
62
|
+
return path.relative_to(project_dir).as_posix()
|
|
63
|
+
except ValueError:
|
|
64
|
+
return path.as_posix()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
# Seed loading
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
def _load_seed(project_dir: Path) -> list[RuntimeNode]:
|
|
72
|
+
"""Load optional runtime seed from <project_dir>/.cortex/map_seeds/runtime_seed.json.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
List of RuntimeNode with status="canonical" on success.
|
|
76
|
+
Empty list if seed file is absent (info-logged, not an error).
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
MapIntegrityError: If the seed file exists but is corrupt or missing
|
|
80
|
+
schema_version.
|
|
81
|
+
"""
|
|
82
|
+
seed_path = seeds_dir(project_dir) / "runtime_seed.json"
|
|
83
|
+
|
|
84
|
+
if not seed_path.exists():
|
|
85
|
+
_log.info("build_runtime_map_static: no runtime seed, using auto-discovery only")
|
|
86
|
+
return []
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
raw = seed_path.read_text(encoding="utf-8")
|
|
90
|
+
payload = json.loads(raw)
|
|
91
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
92
|
+
raise MapIntegrityError(
|
|
93
|
+
"runtime_seed.json is unreadable or not valid JSON: %s" % exc
|
|
94
|
+
) from exc
|
|
95
|
+
|
|
96
|
+
if not isinstance(payload, dict):
|
|
97
|
+
raise MapIntegrityError(
|
|
98
|
+
"runtime_seed.json: expected a JSON object at top level"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
schema_version = payload.get("schema_version")
|
|
102
|
+
if not schema_version:
|
|
103
|
+
raise MapIntegrityError(
|
|
104
|
+
"runtime_seed.json: missing required field 'schema_version'"
|
|
105
|
+
)
|
|
106
|
+
if schema_version != _SUPPORTED_SEED_SCHEMA:
|
|
107
|
+
raise MapIntegrityError(
|
|
108
|
+
"runtime_seed.json: unsupported schema_version %r (expected %r)"
|
|
109
|
+
% (schema_version, _SUPPORTED_SEED_SCHEMA)
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
raw_nodes = payload.get("nodes", [])
|
|
113
|
+
if not isinstance(raw_nodes, list):
|
|
114
|
+
raise MapIntegrityError("runtime_seed.json: 'nodes' must be a list")
|
|
115
|
+
|
|
116
|
+
freshness = _freshness_now()
|
|
117
|
+
nodes: list[RuntimeNode] = []
|
|
118
|
+
for i, raw_node in enumerate(raw_nodes):
|
|
119
|
+
if not isinstance(raw_node, dict):
|
|
120
|
+
raise MapIntegrityError("runtime_seed.json: node[%d] is not a dict" % i)
|
|
121
|
+
node_name = raw_node.get("node")
|
|
122
|
+
if not node_name:
|
|
123
|
+
raise MapIntegrityError("runtime_seed.json: node[%d] missing 'node' field" % i)
|
|
124
|
+
nodes.append(RuntimeNode(
|
|
125
|
+
node=str(node_name),
|
|
126
|
+
defined_in=str(raw_node.get("defined_in", "")),
|
|
127
|
+
kind=str(raw_node.get("kind", "unknown")),
|
|
128
|
+
calls=tuple(raw_node.get("calls", [])),
|
|
129
|
+
side_effects=tuple(raw_node.get("side_effects", [])),
|
|
130
|
+
depends_on_env=tuple(raw_node.get("depends_on_env", [])),
|
|
131
|
+
order_constraints=tuple(raw_node.get("order_constraints", [])),
|
|
132
|
+
hidden_runtime_dependencies=tuple(raw_node.get("hidden_runtime_dependencies", [])),
|
|
133
|
+
tags=tuple(raw_node.get("tags", [])),
|
|
134
|
+
source="seed",
|
|
135
|
+
evidence=(str(seed_path),),
|
|
136
|
+
confidence=1.0,
|
|
137
|
+
freshness=freshness,
|
|
138
|
+
status="canonical",
|
|
139
|
+
))
|
|
140
|
+
|
|
141
|
+
_log.info(
|
|
142
|
+
"build_runtime_map_static: loaded %d canonical nodes from runtime seed",
|
|
143
|
+
len(nodes),
|
|
144
|
+
)
|
|
145
|
+
return nodes
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
# Main builder
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
def build_runtime_map_static(
|
|
153
|
+
project_dir: Path,
|
|
154
|
+
include_roots: Sequence[str] | None = None,
|
|
155
|
+
parse_cache: "Any | None" = None,
|
|
156
|
+
) -> list[RuntimeNode]:
|
|
157
|
+
"""Build Map 2 (runtime) via static AST analysis.
|
|
158
|
+
|
|
159
|
+
Loads an optional seed from <project_dir>/.cortex/map_seeds/runtime_seed.json.
|
|
160
|
+
Seed nodes are marked canonical and win on node-name conflicts.
|
|
161
|
+
Auto-discovered nodes are marked inferred.
|
|
162
|
+
|
|
163
|
+
Map metadata note: trace_status="static_only" should be set by the caller
|
|
164
|
+
when writing the map payload (e.g. via write_map metadata arg).
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
project_dir: Root of the target project to scan.
|
|
168
|
+
include_roots: Optional list of subdirectory names to restrict scan.
|
|
169
|
+
None = whole project (minus excluded dirs).
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
Merged list[RuntimeNode], canonical seed nodes first, then inferred.
|
|
173
|
+
|
|
174
|
+
Raises:
|
|
175
|
+
MapIntegrityError: If runtime_seed.json exists but is corrupt.
|
|
176
|
+
"""
|
|
177
|
+
project_dir = project_dir.resolve()
|
|
178
|
+
_log.info(
|
|
179
|
+
"build_runtime_map_static: start project_dir=%s include_roots=%s",
|
|
180
|
+
project_dir,
|
|
181
|
+
include_roots,
|
|
182
|
+
)
|
|
183
|
+
t_start = time.monotonic()
|
|
184
|
+
|
|
185
|
+
# 1. Load optional seed
|
|
186
|
+
seed_nodes = _load_seed(project_dir)
|
|
187
|
+
seed_index: dict[str, RuntimeNode] = {n.node: n for n in seed_nodes}
|
|
188
|
+
|
|
189
|
+
# 2. Auto-discover via AST
|
|
190
|
+
try:
|
|
191
|
+
py_files = list(iter_py_files(project_dir, include_roots))
|
|
192
|
+
except Exception as exc:
|
|
193
|
+
from .map_errors import MapBuilderError
|
|
194
|
+
raise MapBuilderError(
|
|
195
|
+
"build_runtime_map_static: iter_py_files failed: %s" % exc
|
|
196
|
+
) from exc
|
|
197
|
+
|
|
198
|
+
_log.debug("build_runtime_map_static: scanning %d files", len(py_files))
|
|
199
|
+
|
|
200
|
+
# auto_raw: node_name → merged accumulator dict
|
|
201
|
+
auto_raw: dict[str, dict] = {}
|
|
202
|
+
freshness = _freshness_now()
|
|
203
|
+
|
|
204
|
+
for abs_path in py_files:
|
|
205
|
+
rel = _rel_posix(abs_path, project_dir)
|
|
206
|
+
|
|
207
|
+
# Use parse_cache if available to avoid re-reading + re-parsing.
|
|
208
|
+
# Cache gives us is_parseable; we still need a live ast.parse for
|
|
209
|
+
# _RuntimeVisitor which requires traversing the AST. The cache check
|
|
210
|
+
# lets us skip unparseable files cheaply without reading/parsing.
|
|
211
|
+
if parse_cache is not None:
|
|
212
|
+
cached = parse_cache.get_or_parse(abs_path, project_dir)
|
|
213
|
+
if not cached.is_parseable:
|
|
214
|
+
_log.debug("build_runtime_map_static: skipping unparseable (cache): %s", rel)
|
|
215
|
+
continue
|
|
216
|
+
# Reuse cached source if available (avoid re-reading disk)
|
|
217
|
+
source = parse_cache.get_cached_source(abs_path)
|
|
218
|
+
if source is None:
|
|
219
|
+
# Fallback: cache miss on source (should be rare)
|
|
220
|
+
try:
|
|
221
|
+
source = abs_path.read_text(encoding="utf-8", errors="replace")
|
|
222
|
+
except OSError as exc:
|
|
223
|
+
from .map_errors import MapBuilderError # noqa: PLC0415
|
|
224
|
+
raise MapBuilderError(
|
|
225
|
+
"build_runtime_map_static: cannot read %s: %s" % (abs_path, exc)
|
|
226
|
+
) from exc
|
|
227
|
+
try:
|
|
228
|
+
tree = ast.parse(source, filename=rel)
|
|
229
|
+
except SyntaxError:
|
|
230
|
+
_log.debug("build_runtime_map_static: skipping unparseable file: %s", rel)
|
|
231
|
+
continue
|
|
232
|
+
else:
|
|
233
|
+
# Backward-compat: no cache
|
|
234
|
+
try:
|
|
235
|
+
source = abs_path.read_text(encoding="utf-8", errors="replace")
|
|
236
|
+
except OSError as exc:
|
|
237
|
+
from .map_errors import MapBuilderError # noqa: PLC0415
|
|
238
|
+
raise MapBuilderError(
|
|
239
|
+
"build_runtime_map_static: cannot read %s: %s" % (abs_path, exc)
|
|
240
|
+
) from exc
|
|
241
|
+
|
|
242
|
+
try:
|
|
243
|
+
tree = ast.parse(source, filename=rel)
|
|
244
|
+
except SyntaxError:
|
|
245
|
+
_log.debug("build_runtime_map_static: skipping unparseable file: %s", rel)
|
|
246
|
+
continue
|
|
247
|
+
|
|
248
|
+
visitor = _RuntimeVisitor(rel)
|
|
249
|
+
visitor.visit(tree)
|
|
250
|
+
|
|
251
|
+
for raw in visitor.results:
|
|
252
|
+
name = raw["node"]
|
|
253
|
+
if name in auto_raw:
|
|
254
|
+
existing = auto_raw[name]
|
|
255
|
+
existing["tags"] = list(set(existing["tags"]) | set(raw["tags"]))
|
|
256
|
+
existing["env_vars"] = list(set(existing["env_vars"]) | set(raw["env_vars"]))
|
|
257
|
+
existing["side_effects"] = list(
|
|
258
|
+
set(existing["side_effects"]) | set(raw["side_effects"])
|
|
259
|
+
)
|
|
260
|
+
# Preserve entry-function call targets (order-stable union).
|
|
261
|
+
for c in raw.get("calls", ()):
|
|
262
|
+
if c not in existing["calls"]:
|
|
263
|
+
existing["calls"].append(c)
|
|
264
|
+
else:
|
|
265
|
+
auto_raw[name] = {
|
|
266
|
+
"node": name,
|
|
267
|
+
"kind": raw["kind"],
|
|
268
|
+
"tags": list(raw["tags"]),
|
|
269
|
+
"env_vars": list(raw["env_vars"]),
|
|
270
|
+
"side_effects": list(raw["side_effects"]),
|
|
271
|
+
"calls": list(raw.get("calls", ())),
|
|
272
|
+
"evidence": raw["evidence"],
|
|
273
|
+
"defined_in": name.split(":")[0] if ":" in name else name,
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
_log.debug("build_runtime_map_static: auto-discovered %d raw nodes", len(auto_raw))
|
|
277
|
+
|
|
278
|
+
# 3. Build inferred RuntimeNode objects, skipping seed conflicts
|
|
279
|
+
auto_nodes: list[RuntimeNode] = []
|
|
280
|
+
for name, raw in sorted(auto_raw.items()):
|
|
281
|
+
if name in seed_index:
|
|
282
|
+
_log.debug(
|
|
283
|
+
"build_runtime_map_static: seed canonical wins for node %r", name
|
|
284
|
+
)
|
|
285
|
+
continue
|
|
286
|
+
auto_nodes.append(RuntimeNode(
|
|
287
|
+
node=name,
|
|
288
|
+
defined_in=raw["defined_in"],
|
|
289
|
+
kind=raw["kind"],
|
|
290
|
+
calls=tuple(raw.get("calls", ())),
|
|
291
|
+
side_effects=tuple(sorted(set(raw["side_effects"]))),
|
|
292
|
+
depends_on_env=tuple(sorted(set(raw["env_vars"]))),
|
|
293
|
+
order_constraints=(),
|
|
294
|
+
hidden_runtime_dependencies=(),
|
|
295
|
+
tags=tuple(sorted(set(raw["tags"]))),
|
|
296
|
+
source="static_scan",
|
|
297
|
+
evidence=raw["evidence"],
|
|
298
|
+
confidence=0.75,
|
|
299
|
+
freshness=freshness,
|
|
300
|
+
status="inferred",
|
|
301
|
+
))
|
|
302
|
+
|
|
303
|
+
# 4. Collect TS/JS adapter runtime nodes and append
|
|
304
|
+
try:
|
|
305
|
+
from ._runtime_dispatch import collect_adapter_runtime_nodes # noqa: PLC0415
|
|
306
|
+
adapter_nodes = collect_adapter_runtime_nodes(project_dir, _freshness_now)
|
|
307
|
+
auto_nodes.extend(adapter_nodes)
|
|
308
|
+
_log.debug(
|
|
309
|
+
"build_runtime_map_static: adapter dispatch added %d nodes", len(adapter_nodes)
|
|
310
|
+
)
|
|
311
|
+
except Exception as exc: # noqa: BLE001
|
|
312
|
+
_log.error(
|
|
313
|
+
"build_runtime_map_static: adapter runtime dispatch failed: %s -- continuing",
|
|
314
|
+
exc,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
# 5. Merge: canonical seed first, inferred auto second
|
|
318
|
+
merged: list[RuntimeNode] = list(seed_nodes) + auto_nodes
|
|
319
|
+
|
|
320
|
+
elapsed = time.monotonic() - t_start
|
|
321
|
+
_SLA_SECONDS = 20.0
|
|
322
|
+
if elapsed > _SLA_SECONDS:
|
|
323
|
+
_log.warning(
|
|
324
|
+
"build_runtime_map_static: SLA exceeded -- %.2fs > %.1fs (%d files, %d nodes)",
|
|
325
|
+
elapsed, _SLA_SECONDS, len(py_files), len(merged),
|
|
326
|
+
)
|
|
327
|
+
else:
|
|
328
|
+
_log.info(
|
|
329
|
+
"build_runtime_map_static: done in %.2fs -- seed=%d auto=%d total=%d",
|
|
330
|
+
elapsed, len(seed_nodes), len(auto_nodes), len(merged),
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
return merged
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
# ---------------------------------------------------------------------------
|
|
337
|
+
# Full builder: static + subprocess trace merge
|
|
338
|
+
# ---------------------------------------------------------------------------
|
|
339
|
+
|
|
340
|
+
def build_runtime_map_full(
|
|
341
|
+
project_dir: Path,
|
|
342
|
+
target_module: str | None = None,
|
|
343
|
+
target_argv: Sequence[str] = (),
|
|
344
|
+
timeout_s: float = 30.0,
|
|
345
|
+
include_roots: Sequence[str] | None = None,
|
|
346
|
+
) -> tuple[list[RuntimeNode], dict]:
|
|
347
|
+
"""Build Map 2 (runtime) combining static analysis with live startup tracing.
|
|
348
|
+
|
|
349
|
+
If target_module is None, degrades gracefully to static-only (same as
|
|
350
|
+
calling build_runtime_map_static directly).
|
|
351
|
+
|
|
352
|
+
Merge rule (per plan sec.4b):
|
|
353
|
+
canonical (seed) > observed (trace-confirmed) > inferred (static auto)
|
|
354
|
+
|
|
355
|
+
A static node's status is upgraded from "inferred" to "observed" when a
|
|
356
|
+
matching trace call event is found. "canonical" nodes are never downgraded.
|
|
357
|
+
|
|
358
|
+
Args:
|
|
359
|
+
project_dir: Root of the target project to scan.
|
|
360
|
+
target_module: Dotted Python module name to trace (e.g. "json").
|
|
361
|
+
None -> static only, no subprocess spawned.
|
|
362
|
+
target_argv: Forwarded to the subprocess as target's sys.argv.
|
|
363
|
+
timeout_s: Subprocess time budget in seconds.
|
|
364
|
+
include_roots: Optional subdirectory restrict list for static scan.
|
|
365
|
+
|
|
366
|
+
Returns:
|
|
367
|
+
Tuple of (nodes, metadata):
|
|
368
|
+
nodes - merged list[RuntimeNode]
|
|
369
|
+
metadata - dict with trace_status and trace metrics
|
|
370
|
+
|
|
371
|
+
Raises:
|
|
372
|
+
RuntimeTracerTimeoutError: If subprocess times out.
|
|
373
|
+
(MapBuilderError subtypes from static scan propagate unchanged.)
|
|
374
|
+
"""
|
|
375
|
+
from .map_errors import RuntimeTracerError, RuntimeTracerTimeoutError # noqa: PLC0415
|
|
376
|
+
|
|
377
|
+
project_dir = project_dir.resolve()
|
|
378
|
+
|
|
379
|
+
# Step 1: always build static map first.
|
|
380
|
+
static_nodes = build_runtime_map_static(project_dir, include_roots)
|
|
381
|
+
|
|
382
|
+
# Step 2: if no target specified, return static-only.
|
|
383
|
+
if target_module is None:
|
|
384
|
+
_log.info("build_runtime_map_full: no target_module -- returning static_only map")
|
|
385
|
+
return static_nodes, {"trace_status": "static_only"}
|
|
386
|
+
|
|
387
|
+
# Step 3: attempt subprocess trace.
|
|
388
|
+
from .runtime_tracer import capture_startup_trace # noqa: PLC0415
|
|
389
|
+
|
|
390
|
+
trace_result: dict | None = None
|
|
391
|
+
trace_exit_code: int = -1
|
|
392
|
+
trace_duration: float = 0.0
|
|
393
|
+
|
|
394
|
+
try:
|
|
395
|
+
trace_result = capture_startup_trace(
|
|
396
|
+
target_module=target_module,
|
|
397
|
+
target_argv=target_argv,
|
|
398
|
+
project_dir=project_dir,
|
|
399
|
+
timeout_s=timeout_s,
|
|
400
|
+
)
|
|
401
|
+
trace_exit_code = trace_result.get("exit_code", -1)
|
|
402
|
+
trace_duration = trace_result.get("duration_s", 0.0)
|
|
403
|
+
except RuntimeTracerTimeoutError:
|
|
404
|
+
_log.warning(
|
|
405
|
+
"build_runtime_map_full: trace timed out for target=%r -- degrading to static",
|
|
406
|
+
target_module,
|
|
407
|
+
)
|
|
408
|
+
return static_nodes, {
|
|
409
|
+
"trace_status": "degraded",
|
|
410
|
+
"trace_exit_code": -1,
|
|
411
|
+
"trace_events_captured": 0,
|
|
412
|
+
"trace_duration_s": 0.0,
|
|
413
|
+
"trace_error": "timeout",
|
|
414
|
+
}
|
|
415
|
+
except RuntimeTracerError as exc:
|
|
416
|
+
_log.warning(
|
|
417
|
+
"build_runtime_map_full: trace failed for target=%r: %s -- degrading to static",
|
|
418
|
+
target_module,
|
|
419
|
+
exc,
|
|
420
|
+
)
|
|
421
|
+
return static_nodes, {
|
|
422
|
+
"trace_status": "degraded",
|
|
423
|
+
"trace_exit_code": -1,
|
|
424
|
+
"trace_events_captured": 0,
|
|
425
|
+
"trace_duration_s": 0.0,
|
|
426
|
+
"trace_error": str(exc),
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
# Step 4: if subprocess returned non-zero, degrade.
|
|
430
|
+
if trace_exit_code != 0:
|
|
431
|
+
_log.warning(
|
|
432
|
+
"build_runtime_map_full: trace exited with code %d for target=%r -- degrading",
|
|
433
|
+
trace_exit_code,
|
|
434
|
+
target_module,
|
|
435
|
+
)
|
|
436
|
+
return static_nodes, {
|
|
437
|
+
"trace_status": "degraded",
|
|
438
|
+
"trace_exit_code": trace_exit_code,
|
|
439
|
+
"trace_events_captured": len(trace_result.get("events", [])) if trace_result else 0,
|
|
440
|
+
"trace_duration_s": trace_duration,
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
# Step 5: merge — upgrade inferred nodes to observed where trace confirms them.
|
|
444
|
+
events: list[dict] = trace_result.get("events", []) if trace_result else []
|
|
445
|
+
import_events: list[dict] = trace_result.get("import_events", []) if trace_result else []
|
|
446
|
+
|
|
447
|
+
# Build a set of observed qualnames from trace events (call events only).
|
|
448
|
+
observed_qualnames: set[str] = {
|
|
449
|
+
ev["qualname"]
|
|
450
|
+
for ev in events
|
|
451
|
+
if ev.get("event") == "call" and ev.get("qualname")
|
|
452
|
+
}
|
|
453
|
+
# Also collect imported module names.
|
|
454
|
+
observed_imports: set[str] = {
|
|
455
|
+
ev["module"]
|
|
456
|
+
for ev in import_events
|
|
457
|
+
if ev.get("module")
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
freshness = _freshness_now()
|
|
461
|
+
merged_nodes: list[RuntimeNode] = []
|
|
462
|
+
|
|
463
|
+
for node in static_nodes:
|
|
464
|
+
# canonical nodes are never downgraded.
|
|
465
|
+
if node.status == "canonical":
|
|
466
|
+
merged_nodes.append(node)
|
|
467
|
+
continue
|
|
468
|
+
|
|
469
|
+
# Check if this node's qualname or node name appears in trace events.
|
|
470
|
+
node_name = node.node
|
|
471
|
+
# Match by qualname suffix (node may be "module:Class.method" format).
|
|
472
|
+
short_name = node_name.split(":")[-1] if ":" in node_name else node_name
|
|
473
|
+
module_part = node_name.split(":")[0] if ":" in node_name else ""
|
|
474
|
+
|
|
475
|
+
matched = (
|
|
476
|
+
short_name in observed_qualnames
|
|
477
|
+
or node_name in observed_qualnames
|
|
478
|
+
or module_part in observed_imports
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
if matched and node.status == "inferred":
|
|
482
|
+
# Upgrade: inferred → observed.
|
|
483
|
+
# RuntimeNode is frozen — create a new instance with updated fields.
|
|
484
|
+
existing_evidence = list(node.evidence)
|
|
485
|
+
new_evidence = tuple(existing_evidence + ["trace:call"])
|
|
486
|
+
upgraded = RuntimeNode(
|
|
487
|
+
node=node.node,
|
|
488
|
+
defined_in=node.defined_in,
|
|
489
|
+
kind=node.kind,
|
|
490
|
+
calls=node.calls,
|
|
491
|
+
side_effects=node.side_effects,
|
|
492
|
+
depends_on_env=node.depends_on_env,
|
|
493
|
+
order_constraints=node.order_constraints,
|
|
494
|
+
hidden_runtime_dependencies=node.hidden_runtime_dependencies,
|
|
495
|
+
tags=node.tags,
|
|
496
|
+
source=node.source,
|
|
497
|
+
evidence=new_evidence,
|
|
498
|
+
confidence=min(node.confidence + 0.1, 1.0),
|
|
499
|
+
freshness=freshness,
|
|
500
|
+
status="observed",
|
|
501
|
+
)
|
|
502
|
+
merged_nodes.append(upgraded)
|
|
503
|
+
_log.debug(
|
|
504
|
+
"build_runtime_map_full: upgraded node %r inferred->observed",
|
|
505
|
+
node.node,
|
|
506
|
+
)
|
|
507
|
+
else:
|
|
508
|
+
merged_nodes.append(node)
|
|
509
|
+
|
|
510
|
+
events_captured = len(events)
|
|
511
|
+
_log.info(
|
|
512
|
+
"build_runtime_map_full: full trace done -- events=%d imports=%d "
|
|
513
|
+
"upgraded=%d total=%d duration=%.2fs",
|
|
514
|
+
events_captured,
|
|
515
|
+
len(import_events),
|
|
516
|
+
sum(1 for n in merged_nodes if n.status == "observed"),
|
|
517
|
+
len(merged_nodes),
|
|
518
|
+
trace_duration,
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
metadata = {
|
|
522
|
+
"trace_status": "full",
|
|
523
|
+
"trace_events_captured": events_captured,
|
|
524
|
+
"trace_duration_s": trace_duration,
|
|
525
|
+
"trace_exit_code": trace_exit_code,
|
|
526
|
+
}
|
|
527
|
+
return merged_nodes, metadata
|