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.
Files changed (131) hide show
  1. vigil_codeintel-0.1.0.dist-info/METADATA +780 -0
  2. vigil_codeintel-0.1.0.dist-info/RECORD +131 -0
  3. vigil_codeintel-0.1.0.dist-info/WHEEL +5 -0
  4. vigil_codeintel-0.1.0.dist-info/entry_points.txt +3 -0
  5. vigil_codeintel-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. vigil_codeintel-0.1.0.dist-info/top_level.txt +3 -0
  7. vigil_forensic/__init__.py +224 -0
  8. vigil_forensic/_git_utils.py +178 -0
  9. vigil_forensic/_shared.py +510 -0
  10. vigil_forensic/_stubs.py +156 -0
  11. vigil_forensic/gate_checks/__init__.py +1 -0
  12. vigil_forensic/gate_checks/_ast_helpers.py +629 -0
  13. vigil_forensic/gate_checks/_deployment_detector.py +573 -0
  14. vigil_forensic/gate_checks/atomic_write_checks.py +1143 -0
  15. vigil_forensic/gate_checks/authority_checks.py +95 -0
  16. vigil_forensic/gate_checks/boundary_breach_checks.py +202 -0
  17. vigil_forensic/gate_checks/broad_except_checks.py +301 -0
  18. vigil_forensic/gate_checks/broad_except_hidden_sentinel_checks.py +365 -0
  19. vigil_forensic/gate_checks/common.py +253 -0
  20. vigil_forensic/gate_checks/config_safety_checks.py +704 -0
  21. vigil_forensic/gate_checks/config_ssot_checks.py +78 -0
  22. vigil_forensic/gate_checks/conflict_checks.py +193 -0
  23. vigil_forensic/gate_checks/context_fallback_checks.py +697 -0
  24. vigil_forensic/gate_checks/context_health_checks.py +289 -0
  25. vigil_forensic/gate_checks/contract_shape_drift_checks.py +459 -0
  26. vigil_forensic/gate_checks/dirty_baseline_check.py +274 -0
  27. vigil_forensic/gate_checks/duplication_checks.py +387 -0
  28. vigil_forensic/gate_checks/embedded_string_checks.py +123 -0
  29. vigil_forensic/gate_checks/empty_output_checks.py +87 -0
  30. vigil_forensic/gate_checks/encoding_checks.py +847 -0
  31. vigil_forensic/gate_checks/export_completeness_checks.py +156 -0
  32. vigil_forensic/gate_checks/fallback_checks.py +41 -0
  33. vigil_forensic/gate_checks/file_proliferation_checks.py +171 -0
  34. vigil_forensic/gate_checks/fix_without_test_checks.py +69 -0
  35. vigil_forensic/gate_checks/forensic_cluster_runners/__init__.py +9 -0
  36. vigil_forensic/gate_checks/forensic_cluster_runners/_helpers.py +71 -0
  37. vigil_forensic/gate_checks/forensic_cluster_runners/advanced_checks.py +322 -0
  38. vigil_forensic/gate_checks/forensic_cluster_runners/core.py +273 -0
  39. vigil_forensic/gate_checks/forensic_cluster_runners/integrity_checks.py +203 -0
  40. vigil_forensic/gate_checks/forensic_cluster_runners/quality_checks.py +666 -0
  41. vigil_forensic/gate_checks/forensic_clusters/__init__.py +193 -0
  42. vigil_forensic/gate_checks/forensic_clusters/allowlist.py +426 -0
  43. vigil_forensic/gate_checks/forensic_clusters/allowlist_writer.py +302 -0
  44. vigil_forensic/gate_checks/forensic_clusters/api_protocol.py +231 -0
  45. vigil_forensic/gate_checks/forensic_clusters/async_quality.py +1156 -0
  46. vigil_forensic/gate_checks/forensic_clusters/code_style.py +808 -0
  47. vigil_forensic/gate_checks/forensic_clusters/core.py +319 -0
  48. vigil_forensic/gate_checks/forensic_clusters/data_quality.py +763 -0
  49. vigil_forensic/gate_checks/forensic_clusters/dead_code.py +480 -0
  50. vigil_forensic/gate_checks/forensic_clusters/edit_mutation.py +842 -0
  51. vigil_forensic/gate_checks/forensic_clusters/exception_boundary.py +240 -0
  52. vigil_forensic/gate_checks/forensic_clusters/legacy_debt.py +556 -0
  53. vigil_forensic/gate_checks/forensic_clusters/static_analysis.py +834 -0
  54. vigil_forensic/gate_checks/forensic_clusters/structural_quality.py +298 -0
  55. vigil_forensic/gate_checks/god_object_zones_checks.py +173 -0
  56. vigil_forensic/gate_checks/hallucination_checks.py +566 -0
  57. vigil_forensic/gate_checks/hunter_artifact_completeness_check.py +139 -0
  58. vigil_forensic/gate_checks/implementation_overfit_checks.py +380 -0
  59. vigil_forensic/gate_checks/import_integrity_checks.py +233 -0
  60. vigil_forensic/gate_checks/imports_in_function_checks.py +283 -0
  61. vigil_forensic/gate_checks/ml_checks.py +318 -0
  62. vigil_forensic/gate_checks/performance_checks.py +106 -0
  63. vigil_forensic/gate_checks/project_specific_runner.py +691 -0
  64. vigil_forensic/gate_checks/provider_capability_checks.py +73 -0
  65. vigil_forensic/gate_checks/refactor_completeness_checks.py +274 -0
  66. vigil_forensic/gate_checks/reliability_checks.py +389 -0
  67. vigil_forensic/gate_checks/reporting_checks.py +55 -0
  68. vigil_forensic/gate_checks/runtime_behavior_checks.py +220 -0
  69. vigil_forensic/gate_checks/security_injection_checks.py +332 -0
  70. vigil_forensic/gate_checks/semantic_intent_checks.py +139 -0
  71. vigil_forensic/gate_checks/size_complexity_checks.py +336 -0
  72. vigil_forensic/gate_checks/stuck_feature_flag_checks.py +354 -0
  73. vigil_forensic/gate_checks/syntax_validity_checks.py +217 -0
  74. vigil_forensic/gate_checks/temporal_freshness_checks.py +79 -0
  75. vigil_forensic/gate_checks/test_quality_checks.py +946 -0
  76. vigil_forensic/gate_checks/testing_checks.py +149 -0
  77. vigil_forensic/gate_checks/toctou_checks.py +367 -0
  78. vigil_forensic/gate_checks/type_checking_checks.py +316 -0
  79. vigil_forensic/gate_models.py +392 -0
  80. vigil_forensic/gate_packs/__init__.py +1 -0
  81. vigil_forensic/gate_packs/universal.py +179 -0
  82. vigil_forensic/gate_profile.json +31 -0
  83. vigil_forensic/gate_registry.py +21 -0
  84. vigil_forensic/language_profiles.py +219 -0
  85. vigil_forensic/meta_findings.py +207 -0
  86. vigil_forensic/self_audit.py +725 -0
  87. vigil_forensic/source_analysis.py +175 -0
  88. vigil_mapper/__init__.py +103 -0
  89. vigil_mapper/_ast_helpers_minimal.py +229 -0
  90. vigil_mapper/_extract_imports_impl.py +123 -0
  91. vigil_mapper/_file_count_guard.py +129 -0
  92. vigil_mapper/_git_utils.py +178 -0
  93. vigil_mapper/_runtime_ast.py +438 -0
  94. vigil_mapper/_runtime_dispatch.py +137 -0
  95. vigil_mapper/_seed_helpers.py +82 -0
  96. vigil_mapper/authority_builder.py +1102 -0
  97. vigil_mapper/cli_entry.py +731 -0
  98. vigil_mapper/conflict_builder.py +818 -0
  99. vigil_mapper/data_contract_builder.py +446 -0
  100. vigil_mapper/findings_builder.py +716 -0
  101. vigil_mapper/fingerprint.py +53 -0
  102. vigil_mapper/hotspot_builder.py +539 -0
  103. vigil_mapper/map_common.py +449 -0
  104. vigil_mapper/map_errors.py +55 -0
  105. vigil_mapper/map_models.py +431 -0
  106. vigil_mapper/map_models_ext.py +206 -0
  107. vigil_mapper/map_models_findings.py +130 -0
  108. vigil_mapper/map_storage.py +455 -0
  109. vigil_mapper/parse_cache.py +795 -0
  110. vigil_mapper/refactor_boundary_builder.py +266 -0
  111. vigil_mapper/runtime_builder.py +527 -0
  112. vigil_mapper/runtime_tracer.py +243 -0
  113. vigil_mapper/runtime_tracer_entry.py +199 -0
  114. vigil_mapper/semantic_diff.py +71 -0
  115. vigil_mapper/source_adapters/__init__.py +109 -0
  116. vigil_mapper/source_adapters/_base.py +264 -0
  117. vigil_mapper/source_adapters/_ir.py +156 -0
  118. vigil_mapper/source_adapters/_lexer.py +309 -0
  119. vigil_mapper/source_adapters/_patterns.py +212 -0
  120. vigil_mapper/source_adapters/_treesitter.py +182 -0
  121. vigil_mapper/source_adapters/go.py +553 -0
  122. vigil_mapper/source_adapters/java.py +541 -0
  123. vigil_mapper/source_adapters/javascript.py +626 -0
  124. vigil_mapper/source_adapters/python.py +325 -0
  125. vigil_mapper/source_adapters/typescript.py +749 -0
  126. vigil_mapper/structural_builder.py +586 -0
  127. vigil_mcp/__init__.py +1 -0
  128. vigil_mcp/_jobs.py +587 -0
  129. vigil_mcp/_paths.py +93 -0
  130. vigil_mcp/forensic_server.py +419 -0
  131. 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