arcgraph 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 (165) hide show
  1. arcgraph/__init__.py +41 -0
  2. arcgraph/_build_provenance.json +10 -0
  3. arcgraph/adapters/__init__.py +6 -0
  4. arcgraph/adapters/arq_adapter.py +369 -0
  5. arcgraph/adapters/celery_adapter.py +440 -0
  6. arcgraph/adapters/common.py +436 -0
  7. arcgraph/adapters/django_adapter.py +765 -0
  8. arcgraph/adapters/fastapi_adapter.py +415 -0
  9. arcgraph/adapters/logging_adapter.py +335 -0
  10. arcgraph/adapters/mcp_adapter.py +100 -0
  11. arcgraph/adapters/pydantic_adapter.py +290 -0
  12. arcgraph/adapters/pytest_adapter.py +221 -0
  13. arcgraph/adapters/pytest_collection.py +96 -0
  14. arcgraph/adapters/pytest_fixtures.py +371 -0
  15. arcgraph/adapters/pytest_types.py +251 -0
  16. arcgraph/adapters/registry.py +34 -0
  17. arcgraph/adapters/service_container_adapter.py +548 -0
  18. arcgraph/adapters/sqlalchemy_adapter.py +446 -0
  19. arcgraph/adapters/typer_click_adapter.py +292 -0
  20. arcgraph/analyzers/__init__.py +1 -0
  21. arcgraph/analyzers/bindings.py +782 -0
  22. arcgraph/analyzers/calls/__init__.py +6 -0
  23. arcgraph/analyzers/calls/analyzer.py +2921 -0
  24. arcgraph/analyzers/calls/constants.py +702 -0
  25. arcgraph/analyzers/calls/context.py +298 -0
  26. arcgraph/analyzers/calls/lexical.py +237 -0
  27. arcgraph/analyzers/coverage.py +826 -0
  28. arcgraph/analyzers/exports.py +46 -0
  29. arcgraph/analyzers/har_trace.py +323 -0
  30. arcgraph/analyzers/imports.py +190 -0
  31. arcgraph/analyzers/otel_trace.py +352 -0
  32. arcgraph/analyzers/precise_references.py +1199 -0
  33. arcgraph/analyzers/python_scopes.py +56 -0
  34. arcgraph/analyzers/runtime_trace.py +516 -0
  35. arcgraph/analyzers/similarity.py +430 -0
  36. arcgraph/analyzers/symbols.py +363 -0
  37. arcgraph/analyzers/type_unions.py +89 -0
  38. arcgraph/analyzers/types.py +2293 -0
  39. arcgraph/assets/__init__.py +1 -0
  40. arcgraph/assets/workbench/__init__.py +1 -0
  41. arcgraph/assets/workbench/index.html +2398 -0
  42. arcgraph/assets/workbench/vendor/LICENSE.d3.txt +15 -0
  43. arcgraph/assets/workbench/vendor/d3.v7.min.js +2 -0
  44. arcgraph/change/__init__.py +10 -0
  45. arcgraph/change/baseline.py +145 -0
  46. arcgraph/change/changed_regions.py +728 -0
  47. arcgraph/change/contracts.py +927 -0
  48. arcgraph/change/errors.py +88 -0
  49. arcgraph/change/evidence.py +289 -0
  50. arcgraph/change/graph_delta.py +1422 -0
  51. arcgraph/change/identities.py +1277 -0
  52. arcgraph/change/paths.py +234 -0
  53. arcgraph/change/pins.py +426 -0
  54. arcgraph/change/planner.py +504 -0
  55. arcgraph/change/protected_surfaces.py +328 -0
  56. arcgraph/change/provider.py +46 -0
  57. arcgraph/change/service.py +1030 -0
  58. arcgraph/change/source_provider.py +311 -0
  59. arcgraph/change/store.py +1138 -0
  60. arcgraph/change/trusted_runners.py +154 -0
  61. arcgraph/change/verdicts.py +141 -0
  62. arcgraph/change/verification_plan.py +113 -0
  63. arcgraph/change/verifier.py +579 -0
  64. arcgraph/core/__init__.py +1 -0
  65. arcgraph/core/assurance.py +343 -0
  66. arcgraph/core/binding_metrics.py +75 -0
  67. arcgraph/core/cleanup.py +701 -0
  68. arcgraph/core/confidence_profiles.py +108 -0
  69. arcgraph/core/evidence_manifest.py +862 -0
  70. arcgraph/core/expression_kinds.py +11 -0
  71. arcgraph/core/force_graph_export.py +1125 -0
  72. arcgraph/core/freshness.py +94 -0
  73. arcgraph/core/graph_store.py +1099 -0
  74. arcgraph/core/ids.py +206 -0
  75. arcgraph/core/language_tiers.py +150 -0
  76. arcgraph/core/merge.py +649 -0
  77. arcgraph/core/metadata.py +30 -0
  78. arcgraph/core/operation_lock.py +218 -0
  79. arcgraph/core/passes.py +142 -0
  80. arcgraph/core/payload_policy.py +708 -0
  81. arcgraph/core/precise_references.py +124 -0
  82. arcgraph/core/query_engine.py +3078 -0
  83. arcgraph/core/recovery.py +18 -0
  84. arcgraph/core/scanner.py +1454 -0
  85. arcgraph/core/schemas.py +700 -0
  86. arcgraph/core/semantic.py +263 -0
  87. arcgraph/core/semantic_metrics.py +619 -0
  88. arcgraph/core/sharing_retry.py +84 -0
  89. arcgraph/core/structural.py +260 -0
  90. arcgraph/core/target_resolver.py +486 -0
  91. arcgraph/core/type_metrics.py +67 -0
  92. arcgraph/core/unresolved_classification.py +471 -0
  93. arcgraph/core/utils.py +100 -0
  94. arcgraph/core/visual_contract.py +240 -0
  95. arcgraph/core/visual_slice_engine.py +933 -0
  96. arcgraph/interfaces/__init__.py +1 -0
  97. arcgraph/interfaces/agent_capabilities.py +493 -0
  98. arcgraph/interfaces/agent_help.py +313 -0
  99. arcgraph/interfaces/benchmark.py +548 -0
  100. arcgraph/interfaces/ci.py +1992 -0
  101. arcgraph/interfaces/cli.py +2799 -0
  102. arcgraph/interfaces/cli_change.py +690 -0
  103. arcgraph/interfaces/cli_feedback.py +176 -0
  104. arcgraph/interfaces/cli_ops.py +69 -0
  105. arcgraph/interfaces/cli_support.py +113 -0
  106. arcgraph/interfaces/cli_trial.py +42 -0
  107. arcgraph/interfaces/cli_visual.py +601 -0
  108. arcgraph/interfaces/cli_workspace.py +300 -0
  109. arcgraph/interfaces/client_setup.py +481 -0
  110. arcgraph/interfaces/docs.py +1255 -0
  111. arcgraph/interfaces/human_output.py +409 -0
  112. arcgraph/interfaces/local_state.py +577 -0
  113. arcgraph/interfaces/mcp_server.py +457 -0
  114. arcgraph/interfaces/mcp_tools.py +1209 -0
  115. arcgraph/interfaces/metrics.py +571 -0
  116. arcgraph/interfaces/ops.py +330 -0
  117. arcgraph/interfaces/precision.py +1670 -0
  118. arcgraph/interfaces/project_probe.py +75 -0
  119. arcgraph/interfaces/reports.py +1121 -0
  120. arcgraph/interfaces/stdio_encoding.py +29 -0
  121. arcgraph/interfaces/trace.py +592 -0
  122. arcgraph/interfaces/trial_feedback.py +519 -0
  123. arcgraph/interfaces/trial_setup.py +535 -0
  124. arcgraph/interfaces/visual_server.py +354 -0
  125. arcgraph/interfaces/visual_slice.py +168 -0
  126. arcgraph/interfaces/visual_smoke.py +666 -0
  127. arcgraph/interfaces/workbench.py +576 -0
  128. arcgraph/interfaces/workspace.py +1513 -0
  129. arcgraph/pipeline/__init__.py +1 -0
  130. arcgraph/pipeline/contracts.py +122 -0
  131. arcgraph/pipeline/external_frontend.py +451 -0
  132. arcgraph/pipeline/external_l3_frontends.py +368 -0
  133. arcgraph/pipeline/external_protocol.py +358 -0
  134. arcgraph/pipeline/frontends.py +437 -0
  135. arcgraph/pipeline/indexer.py +661 -0
  136. arcgraph/pipeline/openapi_frontend.py +708 -0
  137. arcgraph/pipeline/python_frontend.py +298 -0
  138. arcgraph/pipeline/reindexer.py +1333 -0
  139. arcgraph/pipeline/scip_frontend.py +726 -0
  140. arcgraph/pipeline/typescript_extractor/declarations.mjs +183 -0
  141. arcgraph/pipeline/typescript_extractor/edges.mjs +2794 -0
  142. arcgraph/pipeline/typescript_extractor/graph_primitives.mjs +189 -0
  143. arcgraph/pipeline/typescript_extractor/module_resolution.mjs +877 -0
  144. arcgraph/pipeline/typescript_extractor/runtime.mjs +56 -0
  145. arcgraph/pipeline/typescript_extractor/similarity.mjs +619 -0
  146. arcgraph/pipeline/typescript_extractor/similarity_runner.mjs +19 -0
  147. arcgraph/pipeline/typescript_extractor/sources.mjs +23 -0
  148. arcgraph/pipeline/typescript_extractor.mjs +177 -0
  149. arcgraph/pipeline/typescript_frameworks.py +947 -0
  150. arcgraph/pipeline/typescript_frontend.py +656 -0
  151. arcgraph/pipeline/typescript_references.mjs +167 -0
  152. arcgraph/providers/__init__.py +27 -0
  153. arcgraph/providers/context_provider.py +1258 -0
  154. arcgraph/providers/flow_payload.py +117 -0
  155. arcgraph/providers/memory_connector.py +216 -0
  156. arcgraph/providers/orchestrator.py +475 -0
  157. arcgraph/providers/relationship_payload.py +102 -0
  158. arcgraph/providers/risk_provider.py +843 -0
  159. arcgraph/providers/why_provider.py +349 -0
  160. arcgraph/version_info.py +246 -0
  161. arcgraph-0.1.0.dist-info/METADATA +676 -0
  162. arcgraph-0.1.0.dist-info/RECORD +165 -0
  163. arcgraph-0.1.0.dist-info/WHEEL +4 -0
  164. arcgraph-0.1.0.dist-info/entry_points.txt +2 -0
  165. arcgraph-0.1.0.dist-info/licenses/LICENSE +203 -0
@@ -0,0 +1,2799 @@
1
+ """Command line interface for ArcGraph."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import subprocess
9
+ import sys
10
+ import time
11
+ import traceback
12
+ from pathlib import Path
13
+ from typing import Any, cast
14
+
15
+ from arcgraph import __version__
16
+ from arcgraph.change.errors import ChangeSafetyError
17
+ from arcgraph.pipeline.indexer import ArcGraphIndexer
18
+ from arcgraph.pipeline.external_protocol import summarize_toolchain_status
19
+ from arcgraph.core.evidence_manifest import (
20
+ evidence_health_check_status,
21
+ evidence_plan,
22
+ evidence_status,
23
+ load_live_evidence_manifest,
24
+ write_evidence_sidecar,
25
+ )
26
+ from arcgraph.core.metadata import current_commit
27
+ from arcgraph.core.ids import route_id
28
+ from arcgraph.core.query_engine import (
29
+ QueryEngine,
30
+ SchemaVersionError,
31
+ )
32
+ from arcgraph.core.schemas import ContextRequest
33
+ from arcgraph.core.target_resolver import is_entrypoint_target, parse_route_target
34
+ from arcgraph.pipeline.reindexer import ArcGraphReindexer
35
+ from arcgraph.core.scanner import (
36
+ DEFAULT_IGNORE_RULES,
37
+ LEGACY_SOURCE_ROOTS,
38
+ ResolvedSourceRoots,
39
+ SourceRoot,
40
+ SourceRootDetection,
41
+ detect_source_roots,
42
+ infer_source_roots,
43
+ )
44
+ from arcgraph.core.unresolved_classification import UNRESOLVED_CATEGORIES
45
+ from arcgraph.interfaces.ci import evidence_requirement_summary, run_ci_checks
46
+ from arcgraph.interfaces.agent_help import (
47
+ HELP_SURFACES,
48
+ HELP_TOPICS,
49
+ HelpTopic,
50
+ build_agent_help,
51
+ format_agent_help_human,
52
+ )
53
+ from arcgraph.interfaces.docs import DOC_TOPICS, render_docs
54
+ from arcgraph.interfaces.cli_feedback import (
55
+ add_feedback_parser,
56
+ feedback_metrics_path_conflict_payload,
57
+ )
58
+ from arcgraph.interfaces.human_output import (
59
+ format_build_human,
60
+ format_doctor_human,
61
+ format_generic_human,
62
+ format_init_human,
63
+ )
64
+ from arcgraph.interfaces.metrics import (
65
+ CLI_METRICS_WRITE_WARNING,
66
+ METRICS_LOG_INVALID_ENCODING_ERROR_CODE,
67
+ METRICS_LOG_UNREADABLE_ERROR_CODE,
68
+ MetricsLogEncodingError,
69
+ MetricsRecorder,
70
+ metrics_unavailable_payload,
71
+ summarize_metrics,
72
+ summarize_trial_metrics,
73
+ )
74
+ from arcgraph.interfaces.local_state import (
75
+ PrivateLocalStateError,
76
+ local_state_paths_alias,
77
+ )
78
+ from arcgraph.interfaces.mcp_server import add_mcp_server_args, serve as serve_mcp
79
+ from arcgraph.interfaces.ops import (
80
+ prune_output,
81
+ rebuild_index,
82
+ stale_alert,
83
+ )
84
+ from arcgraph.interfaces.precision import (
85
+ convert_scip_index_to_json,
86
+ generate_pyright_json,
87
+ generate_scip_python_json,
88
+ )
89
+ from arcgraph.interfaces.project_probe import (
90
+ detect_project_languages,
91
+ source_file_counts,
92
+ )
93
+ from arcgraph.interfaces.stdio_encoding import use_utf8_stdio
94
+ from arcgraph.interfaces.reports import (
95
+ classify_unresolved_records,
96
+ render_ci_markdown,
97
+ render_impact_markdown,
98
+ render_metrics_dashboard_html,
99
+ render_pr_markdown,
100
+ render_static_html_report,
101
+ render_unresolved_classification_markdown,
102
+ )
103
+ from arcgraph.interfaces.trace import (
104
+ import_har_network,
105
+ import_otel_spans,
106
+ import_runtime_trace,
107
+ run_runtime_trace,
108
+ )
109
+ from arcgraph.version_info import version_info
110
+ from arcgraph.interfaces.trial_feedback import (
111
+ DISTINCT_METRICS_FEEDBACK_LOGS_MESSAGE,
112
+ )
113
+
114
+ # Re-exported so `from arcgraph.interfaces.cli import handle_change_*` and
115
+ # the parser wiring below keep working unchanged after the split.
116
+ from arcgraph.interfaces.cli_workspace import ( # noqa: F401
117
+ add_benchmark_parser,
118
+ add_workspace_parser,
119
+ handle_workspace_status,
120
+ handle_workspace_resolve,
121
+ handle_workspace_context,
122
+ handle_benchmark_agent_startup,
123
+ handle_benchmark_suite,
124
+ )
125
+ from arcgraph.interfaces.cli_visual import ( # noqa: F401
126
+ add_visual_parser,
127
+ handle_visual_force,
128
+ handle_visual_workbench,
129
+ handle_visual_serve,
130
+ handle_visual_smoke,
131
+ _visual_graph_options,
132
+ )
133
+ from arcgraph.interfaces.cli_support import ( # noqa: F401
134
+ output_location,
135
+ print_json,
136
+ query_engine,
137
+ write_json_output,
138
+ _print_cli_error_payload,
139
+ )
140
+ from arcgraph.interfaces.client_setup import add_setup_parser
141
+ from arcgraph.interfaces.cli_trial import ( # noqa: F401
142
+ add_trial_parser,
143
+ handle_trial_setup,
144
+ )
145
+ from arcgraph.interfaces.cli_ops import handle_sync, handle_watch # noqa: F401
146
+ from arcgraph.interfaces.cli_change import ( # noqa: F401
147
+ add_change_parser,
148
+ _change_error_payload,
149
+ _change_json_object,
150
+ _change_output_dir,
151
+ _change_payload,
152
+ _change_repo_id,
153
+ _change_repo_id_from_argv,
154
+ _change_repo_root,
155
+ _change_service,
156
+ _change_target,
157
+ _evidence_summary_payload,
158
+ handle_change_abandon,
159
+ handle_change_approve,
160
+ handle_change_archive,
161
+ handle_change_audit_export,
162
+ handle_change_diff,
163
+ handle_change_evidence_add,
164
+ handle_change_evidence_list,
165
+ handle_change_evidence_purge,
166
+ handle_change_evidence_show,
167
+ handle_change_list,
168
+ handle_change_plan,
169
+ handle_change_reconcile_pins,
170
+ handle_change_reject,
171
+ handle_change_report_show,
172
+ handle_change_show,
173
+ handle_change_verify,
174
+ )
175
+ from arcgraph.core.payload_policy import (
176
+ TARGET_SCOPED_CLI_COMMANDS,
177
+ apply_target_payload_contract,
178
+ )
179
+ from arcgraph.providers.context_provider import ContextProvider
180
+
181
+
182
+ def _scope_payload_capabilities(payload: dict[str, Any]) -> dict[str, Any]:
183
+ """Apply the target-scoped read contract at the CLI boundary.
184
+
185
+ On a minimal missing-symbol response, the complete capability table can
186
+ dominate the serialized payload while placing no caveat on the answer. The
187
+ shared policy keeps degraded entries, reports what was omitted, and
188
+ independently versions the bounded read contract. It is idempotent, so
189
+ provider-built payloads and direct QueryEngine payloads converge here.
190
+ """
191
+ return apply_target_payload_contract(payload)
192
+
193
+
194
+ class _ArgumentParserError(ValueError):
195
+ def __init__(self, parser: argparse.ArgumentParser, message: str) -> None:
196
+ self.parser = parser
197
+ self.message = message
198
+ super().__init__(message)
199
+
200
+
201
+ class _ArcGraphArgumentParser(argparse.ArgumentParser):
202
+ """Let ``main`` preserve the structured Change CLI error contract."""
203
+
204
+ def error(self, message: str) -> None:
205
+ raise _ArgumentParserError(self, message)
206
+
207
+
208
+ def main(argv: list[str] | None = None) -> int:
209
+ use_utf8_stdio()
210
+ parser = build_parser()
211
+ raw_argv = list(argv) if argv is not None else sys.argv[1:]
212
+ try:
213
+ args = parser.parse_args(raw_argv)
214
+ except _ArgumentParserError as exc:
215
+ parse_error_payload = getattr(
216
+ exc.parser,
217
+ "_arcgraph_parse_error_payload",
218
+ None,
219
+ )
220
+ if isinstance(parse_error_payload, dict):
221
+ print_json(dict(parse_error_payload))
222
+ return int(
223
+ getattr(
224
+ exc.parser,
225
+ "_arcgraph_parse_error_exit_code",
226
+ 2,
227
+ )
228
+ )
229
+ if _top_level_command(raw_argv) == "change":
230
+ error_args = argparse.Namespace(
231
+ command="change",
232
+ change_repo_id=_change_repo_id_from_argv(raw_argv),
233
+ )
234
+ print_json(_change_error_payload(error_args, ValueError(exc.message)))
235
+ return 1
236
+ exc.parser.print_usage(sys.stderr)
237
+ exc.parser.exit(2, f"{exc.parser.prog}: error: {exc.message}\n")
238
+
239
+ if not hasattr(args, "handler"):
240
+ parser.print_help()
241
+ return 0
242
+
243
+ feedback_path_error = feedback_metrics_path_conflict_payload(args)
244
+ if feedback_path_error is not None:
245
+ exit_code = int(feedback_path_error.pop("_exit_code", 2))
246
+ print_json(feedback_path_error)
247
+ return exit_code
248
+ if (
249
+ getattr(args, "command", None) == "mcp"
250
+ and getattr(args, "mcp_command", None) == "serve"
251
+ and getattr(args, "metrics_log", None)
252
+ and getattr(args, "mcp_feedback_log", None)
253
+ and local_state_paths_alias(
254
+ Path(args.metrics_log),
255
+ Path(args.mcp_feedback_log),
256
+ )
257
+ ):
258
+ print(
259
+ f"ArcGraph: {DISTINCT_METRICS_FEEDBACK_LOGS_MESSAGE}",
260
+ file=sys.stderr,
261
+ )
262
+ # Rejected before the handler runs, but it is still a failure a caller
263
+ # parses, so it gets the same envelope the handler failures get.
264
+ _print_cli_error_payload(
265
+ args, ValueError(DISTINCT_METRICS_FEEDBACK_LOGS_MESSAGE)
266
+ )
267
+ return 2
268
+
269
+ started = time.perf_counter()
270
+ payload: dict[str, Any] | str | None = None
271
+ streaming_output = False
272
+ exit_code = 0
273
+ status = "success"
274
+ error: str | None = None
275
+ try:
276
+ payload = args.handler(args)
277
+ except BrokenPipeError:
278
+ # Every CLI command writes to stdout, so a consumer that closes the
279
+ # pipe early (`arcgraph ... | head`) is a normal termination for all
280
+ # of them, not just the streaming ones. Detach stdout so interpreter
281
+ # shutdown cannot re-raise on flush, and report the conventional
282
+ # SIGPIPE status.
283
+ return _exit_on_broken_pipe()
284
+ except ChangeSafetyError as exc:
285
+ status = "error"
286
+ error = str(exc)
287
+ exit_code = 1 if getattr(args, "command", None) == "change" else 2
288
+ if getattr(args, "command", None) == "change":
289
+ payload = _change_error_payload(args, exc)
290
+ print_json(payload)
291
+ else:
292
+ print(f"ArcGraph: {exc}", file=sys.stderr)
293
+ _print_cli_error_payload(args, exc)
294
+ except (FileNotFoundError, RuntimeError, SchemaVersionError) as exc:
295
+ status = "error"
296
+ error = str(exc)
297
+ exit_code = 1 if getattr(args, "command", None) == "change" else 2
298
+ if getattr(args, "command", None) == "change":
299
+ payload = _change_error_payload(args, exc)
300
+ print_json(payload)
301
+ else:
302
+ print(f"ArcGraph: {exc}", file=sys.stderr)
303
+ _print_cli_error_payload(args, exc)
304
+ except (TypeError, ValueError) as exc:
305
+ if getattr(args, "command", None) != "change":
306
+ # An internal defect keeps its traceback -- swallowing it would
307
+ # hide a bug -- but stdout still gets the envelope, so an agent is
308
+ # told that the command failed and why rather than reading silence.
309
+ traceback.print_exc()
310
+ _print_cli_error_payload(args, exc)
311
+ return 1
312
+ status = "error"
313
+ error = str(exc)
314
+ exit_code = 1
315
+ payload = _change_error_payload(args, exc)
316
+ print_json(payload)
317
+ else:
318
+ if isinstance(payload, dict):
319
+ exit_code = int(payload.pop("_exit_code", 0))
320
+ streaming_output = bool(payload.pop("_streaming_output", False))
321
+ if getattr(args, "command", None) == "change":
322
+ status = str(payload.get("status", "success"))
323
+ else:
324
+ status = "success" if exit_code == 0 else "failed"
325
+ # --raw promises the QueryEngine payload unchanged, so it must not
326
+ # be shaped here either. Scoping it made `callers --raw` differ
327
+ # from `QueryEngine.callers()`, which is the one thing --raw exists
328
+ # to reproduce.
329
+ if getattr(
330
+ args, "command", None
331
+ ) in TARGET_SCOPED_CLI_COMMANDS and not getattr(args, "raw", False):
332
+ payload = _scope_payload_capabilities(payload)
333
+
334
+ if payload is not None:
335
+ try:
336
+ _emit_cli_payload(args, payload, streaming_output, started)
337
+ except BrokenPipeError:
338
+ exit_code = _exit_on_broken_pipe()
339
+ finally:
340
+ metrics_log = getattr(args, "metrics_log", None)
341
+ if metrics_log:
342
+ try:
343
+ MetricsRecorder(Path(metrics_log)).record_cli_command(
344
+ command=getattr(args, "command", None),
345
+ status=status,
346
+ duration_ms=(time.perf_counter() - started) * 1000,
347
+ exit_code=exit_code,
348
+ payload=payload if isinstance(payload, dict) else None,
349
+ error=error,
350
+ )
351
+ except PrivateLocalStateError:
352
+ sys.stderr.write(CLI_METRICS_WRITE_WARNING)
353
+ return exit_code
354
+
355
+
356
+ def _exit_on_broken_pipe() -> int:
357
+ """Detach stdout and report the conventional SIGPIPE exit status."""
358
+
359
+ try:
360
+ devnull = os.open(os.devnull, os.O_WRONLY)
361
+ os.dup2(devnull, sys.stdout.fileno())
362
+ except (OSError, ValueError):
363
+ pass
364
+ return 141
365
+
366
+
367
+ def _emit_cli_payload(
368
+ args: argparse.Namespace,
369
+ payload: dict[str, Any] | str,
370
+ streaming_output: bool,
371
+ started: float,
372
+ ) -> None:
373
+ if isinstance(payload, str):
374
+ print(payload, end="")
375
+ elif getattr(args, "human", False):
376
+ duration_s = time.perf_counter() - started
377
+ command = getattr(args, "command", None)
378
+ if command == "build":
379
+ print(format_build_human(payload, duration_s))
380
+ elif command == "init":
381
+ print(format_init_human(payload))
382
+ elif command == "doctor":
383
+ print(format_doctor_human(payload))
384
+ elif command == "help":
385
+ print(format_agent_help_human(payload), end="")
386
+ else:
387
+ print(format_generic_human(payload, command))
388
+ elif streaming_output:
389
+ print(json.dumps(payload, ensure_ascii=False, sort_keys=True))
390
+ else:
391
+ print_json(payload)
392
+
393
+
394
+ def build_parser() -> argparse.ArgumentParser:
395
+ parser = _ArcGraphArgumentParser(
396
+ prog="arcgraph", description="Build and query a local ArcGraph index."
397
+ )
398
+ parser.add_argument(
399
+ "--repo-root",
400
+ default=".",
401
+ help="Repository root. Defaults to current directory.",
402
+ )
403
+ parser.add_argument(
404
+ "--output-dir", default="output/arcgraph", help="Index output directory."
405
+ )
406
+ parser.add_argument(
407
+ "--metrics-log",
408
+ help="Optional JSONL path for CLI latency/status metrics.",
409
+ )
410
+ parser.add_argument(
411
+ "--version",
412
+ action="version",
413
+ version=f"%(prog)s {__version__}",
414
+ )
415
+ parser.add_argument(
416
+ "--human",
417
+ action="store_true",
418
+ help="Print human-readable output instead of JSON.",
419
+ )
420
+ subparsers = parser.add_subparsers(dest="command")
421
+
422
+ def _add_legacy_roots_arg(sub: argparse.ArgumentParser) -> None:
423
+ sub.add_argument(
424
+ "--legacy-roots",
425
+ action="store_true",
426
+ help=(
427
+ "Use the hard-coded legacy source roots instead of "
428
+ "auto-detection. Mutually exclusive with --root."
429
+ ),
430
+ )
431
+
432
+ def _add_agent_compact_args(sub: argparse.ArgumentParser) -> None:
433
+ shape = sub.add_mutually_exclusive_group()
434
+ shape.add_argument(
435
+ "--compact",
436
+ action="store_true",
437
+ help=(
438
+ "Accepted for compatibility and ignored: the bounded agent "
439
+ "payload is the default."
440
+ ),
441
+ )
442
+ shape.add_argument(
443
+ "--raw",
444
+ action="store_true",
445
+ help=(
446
+ "Return the unbounded QueryEngine debug payload instead of the "
447
+ "bounded agent payload. It carries every node property and is "
448
+ "not size-bounded; on this repository the raw impact payload is "
449
+ "over 10 MB. Payload-shaping options are rejected with --raw."
450
+ ),
451
+ )
452
+ # ``None`` defaults let the handler tell "user asked for it" apart from
453
+ # "argparse filled it in", so --raw can reject shaping it cannot honor
454
+ # instead of silently discarding it.
455
+ sub.add_argument("--max-results", type=int, default=None)
456
+ sub.add_argument(
457
+ "--detail-level",
458
+ default=None,
459
+ choices=["summary", "standard", "detailed"],
460
+ )
461
+ sub.add_argument(
462
+ "--include-source",
463
+ action="store_true",
464
+ default=None,
465
+ help="Include source snippets when the provider can safely expose them.",
466
+ )
467
+
468
+ def _add_relation_profile_arg(
469
+ sub: argparse.ArgumentParser, *, direction: str
470
+ ) -> None:
471
+ sub.add_argument(
472
+ "--profile",
473
+ default="review_default",
474
+ help=f"Confidence profile to use for direct {direction} evidence.",
475
+ )
476
+
477
+ init = subparsers.add_parser(
478
+ "init", help="Detect source roots and add a [tool.arcgraph] config."
479
+ )
480
+ init.add_argument(
481
+ "--dry-run",
482
+ action="store_true",
483
+ help="Show the config that would be written without modifying pyproject.toml.",
484
+ )
485
+ init.set_defaults(handler=handle_init)
486
+
487
+ doctor = subparsers.add_parser(
488
+ "doctor", help="Run environment and index health checks."
489
+ )
490
+ doctor.set_defaults(handler=handle_doctor)
491
+
492
+ version = subparsers.add_parser(
493
+ "version",
494
+ help="Report the exact runtime, artifact, and source identity.",
495
+ )
496
+ version.add_argument(
497
+ "--json",
498
+ action="store_true",
499
+ help="Emit the machine-readable provenance payload (the default format).",
500
+ )
501
+ # The identity command must observe live working-tree state, never a
502
+ # reused capture.
503
+ version.set_defaults(handler=lambda _args: version_info(refresh=True))
504
+
505
+ build = subparsers.add_parser("build", help="Build a new ArcGraph index.")
506
+ build.add_argument(
507
+ "--root",
508
+ action="append",
509
+ default=[],
510
+ help="Source root to scan. Can be repeated.",
511
+ )
512
+ build.add_argument(
513
+ "--scip-index",
514
+ help="Optional SCIP-derived JSON occurrence file for confirmed references.",
515
+ )
516
+ build.add_argument(
517
+ "--scip-graph-index",
518
+ help=(
519
+ "Optional scip print --json payload to ingest as a language-neutral "
520
+ "protocol graph fragment. Distinct from --scip-index precision evidence."
521
+ ),
522
+ )
523
+ build.add_argument(
524
+ "--openapi-spec",
525
+ help=(
526
+ "Optional OpenAPI 3.x JSON/YAML artifact to ingest as an explicit "
527
+ "protocol graph fragment."
528
+ ),
529
+ )
530
+ build.add_argument(
531
+ "--pyright-export",
532
+ help="Optional Pyright-derived JSON export path for the precision input contract.",
533
+ )
534
+ build.add_argument(
535
+ "--runtime-trace",
536
+ help="Optional ArcGraph runtime trace JSON file to import as runtime-only evidence.",
537
+ )
538
+ build.add_argument(
539
+ "--coverage",
540
+ help="Optional coverage JSON or Cobertura XML file for runtime test evidence.",
541
+ )
542
+ build.add_argument(
543
+ "--semantic-call-resolution",
544
+ action="store_true",
545
+ help=(
546
+ "Compatibility flag retained for older scripts; V2 receiver-aware "
547
+ "call resolution is the default at schema 1.0."
548
+ ),
549
+ )
550
+ build.add_argument(
551
+ "--legacy-call-resolution",
552
+ action="store_true",
553
+ help=(
554
+ "Use the legacy AST call resolver. This escape hatch is rejected by "
555
+ "ci --python-full."
556
+ ),
557
+ )
558
+ _add_legacy_roots_arg(build)
559
+ build.set_defaults(handler=handle_build)
560
+
561
+ reindex = subparsers.add_parser(
562
+ "reindex", help="Incrementally reindex changed files."
563
+ )
564
+ reindex.add_argument(
565
+ "--changed",
566
+ action="store_true",
567
+ help="Reindex changed files using the current index as a baseline.",
568
+ )
569
+ reindex.set_defaults(handler=handle_reindex)
570
+
571
+ sync = subparsers.add_parser(
572
+ "sync", help="Explicitly publish an incremental index when needed."
573
+ )
574
+ sync.add_argument(
575
+ "--if-stale",
576
+ action="store_true",
577
+ help="Skip synchronization when the current index is fresh.",
578
+ )
579
+ sync.set_defaults(handler=handle_sync)
580
+
581
+ watch = subparsers.add_parser(
582
+ "watch", help="Watch for stale changes and debounce incremental sync."
583
+ )
584
+ watch.add_argument("--poll-interval", type=float, default=0.25)
585
+ watch.add_argument("--debounce", type=float, default=0.5)
586
+ watch.add_argument("--max-cycles", type=int, help=argparse.SUPPRESS)
587
+ watch.set_defaults(handler=handle_watch)
588
+
589
+ stats = subparsers.add_parser("stats", help="Show current index statistics.")
590
+ stats.set_defaults(handler=lambda args: query_engine(args).stats())
591
+
592
+ semantic_stats = subparsers.add_parser(
593
+ "semantic-stats", help="Show Python semantic callsite metrics."
594
+ )
595
+ semantic_stats.add_argument(
596
+ "--output",
597
+ help="Path to write semantic metrics JSON. Defaults to <output-dir>/metrics/semantic-stats.json.",
598
+ )
599
+ semantic_stats.set_defaults(handler=handle_semantic_stats)
600
+
601
+ unresolved = subparsers.add_parser(
602
+ "unresolved", help="Show unresolved semantic callsites."
603
+ )
604
+ unresolved.add_argument(
605
+ "target",
606
+ nargs="?",
607
+ help="Optional file path, symbol, entrypoint, or stable id filter.",
608
+ )
609
+ unresolved.add_argument(
610
+ "--limit", type=int, default=100, help="Maximum unresolved callsites to return."
611
+ )
612
+ unresolved.add_argument(
613
+ "--category",
614
+ choices=UNRESOLVED_CATEGORIES,
615
+ help="Only return unresolved callsites in this triage category.",
616
+ )
617
+ unresolved.add_argument(
618
+ "--release-blocking-only",
619
+ action="store_true",
620
+ help="Only return unresolved callsites that block the python_full gate.",
621
+ )
622
+ unresolved.set_defaults(
623
+ handler=lambda args: query_engine(args).unresolved(
624
+ args.target,
625
+ limit=args.limit,
626
+ category=args.category,
627
+ release_blocking_only=args.release_blocking_only,
628
+ )
629
+ )
630
+
631
+ bindings = subparsers.add_parser(
632
+ "bindings", help="Show bindings attached to an indexed scope."
633
+ )
634
+ bindings.add_argument("target", help="Dotted qualname, path, or stable symbol id.")
635
+ bindings.add_argument(
636
+ "--limit", type=int, default=200, help="Maximum binding records to return."
637
+ )
638
+ bindings.set_defaults(
639
+ handler=lambda args: query_engine(args).bindings(args.target, limit=args.limit)
640
+ )
641
+
642
+ types = subparsers.add_parser(
643
+ "types", help="Show TypeRef facts attached to an indexed scope."
644
+ )
645
+ types.add_argument("target", help="Dotted qualname, path, or stable symbol id.")
646
+ types.add_argument(
647
+ "--limit", type=int, default=200, help="Maximum TypeRef records to return."
648
+ )
649
+ types.set_defaults(
650
+ handler=lambda args: query_engine(args).types(args.target, limit=args.limit)
651
+ )
652
+
653
+ callsites = subparsers.add_parser(
654
+ "callsites", help="Show extracted callsites for an indexed scope."
655
+ )
656
+ callsites.add_argument("target", help="Dotted qualname, path, or stable symbol id.")
657
+ callsites.add_argument(
658
+ "--limit", type=int, default=200, help="Maximum callsite records to return."
659
+ )
660
+ callsites.set_defaults(
661
+ handler=lambda args: query_engine(args).callsites(args.target, limit=args.limit)
662
+ )
663
+
664
+ imports = subparsers.add_parser(
665
+ "imports", help="Show import in/out edges for a module."
666
+ )
667
+ imports.add_argument("module", help="Dotted module name or mod:<name> id.")
668
+ imports.set_defaults(handler=lambda args: query_engine(args).imports(args.module))
669
+
670
+ callers = subparsers.add_parser(
671
+ "callers", help="Show direct inferred callers for a symbol."
672
+ )
673
+ callers.add_argument("target", help="Dotted qualname, path, or stable symbol id.")
674
+ _add_agent_compact_args(callers)
675
+ _add_relation_profile_arg(callers, direction="caller")
676
+ callers.set_defaults(handler=handle_callers)
677
+
678
+ callees = subparsers.add_parser(
679
+ "callees", help="Show direct inferred callees for a symbol."
680
+ )
681
+ callees.add_argument("target", help="Dotted qualname, path, or stable symbol id.")
682
+ _add_agent_compact_args(callees)
683
+ _add_relation_profile_arg(callees, direction="callee")
684
+ callees.set_defaults(handler=handle_callees)
685
+
686
+ impact = subparsers.add_parser(
687
+ "impact", help="Show a call/import/entrypoint/resource impact report."
688
+ )
689
+ impact.add_argument("target", help="Dotted qualname, path, or stable symbol id.")
690
+ impact.add_argument(
691
+ "--max-depth",
692
+ type=int,
693
+ default=None,
694
+ help="Reverse call traversal depth. Defaults to the profile's default_max_depth.",
695
+ )
696
+ impact.add_argument(
697
+ "--profile",
698
+ default="review_default",
699
+ help="Impact confidence profile label to include in the response.",
700
+ )
701
+ impact.add_argument(
702
+ "--include-edge-kind",
703
+ action="append",
704
+ default=[],
705
+ help="Limit impact edges to this kind. Can be repeated.",
706
+ )
707
+ impact.add_argument(
708
+ "--exclude-edge-kind",
709
+ action="append",
710
+ default=[],
711
+ help="Exclude this edge kind from impact output. Can be repeated.",
712
+ )
713
+ _add_agent_compact_args(impact)
714
+ impact.set_defaults(handler=handle_impact)
715
+
716
+ report = subparsers.add_parser("report", help="Render ArcGraph reports.")
717
+ report_subparsers = report.add_subparsers(dest="report_command")
718
+ report_impact = report_subparsers.add_parser(
719
+ "impact", help="Render a Markdown impact report."
720
+ )
721
+ report_impact.add_argument(
722
+ "target", help="Dotted qualname, path, or stable symbol id."
723
+ )
724
+ report_impact.add_argument(
725
+ "--max-depth",
726
+ type=int,
727
+ default=None,
728
+ help="Reverse call traversal depth. Defaults to the profile's default_max_depth.",
729
+ )
730
+ report_impact.add_argument(
731
+ "--profile",
732
+ default="review_default",
733
+ help="Impact confidence profile label to include in the report.",
734
+ )
735
+ report_impact.add_argument(
736
+ "--include-edge-kind",
737
+ action="append",
738
+ default=[],
739
+ help="Limit impact edges to this kind. Can be repeated.",
740
+ )
741
+ report_impact.add_argument(
742
+ "--exclude-edge-kind",
743
+ action="append",
744
+ default=[],
745
+ help="Exclude this edge kind from impact output. Can be repeated.",
746
+ )
747
+ report_impact.set_defaults(
748
+ handler=lambda args: render_impact_markdown(
749
+ query_engine(args).impact(
750
+ args.target,
751
+ max_depth=args.max_depth,
752
+ profile=args.profile,
753
+ include_edge_kinds=args.include_edge_kind,
754
+ exclude_edge_kinds=args.exclude_edge_kind,
755
+ )
756
+ )
757
+ )
758
+ report_ci = report_subparsers.add_parser("ci", help="Render a Markdown CI summary.")
759
+ report_ci.add_argument("--target", action="append", default=[])
760
+ report_ci.add_argument("--changed-file", action="append", default=[])
761
+ report_ci.add_argument("--max-results", type=int, default=30)
762
+ report_ci.add_argument(
763
+ "--semantic-baseline",
764
+ help="Optional semantic-stats or CI JSON baseline for resolution trend checks.",
765
+ )
766
+ report_ci.add_argument(
767
+ "--performance-budget-ms",
768
+ action="append",
769
+ default=[],
770
+ metavar="OPERATION=MS",
771
+ help="Override a CI performance budget in milliseconds. Can be repeated.",
772
+ )
773
+ _add_ci_runtime_trace_args(report_ci)
774
+ report_ci.set_defaults(
775
+ handler=lambda args: render_ci_markdown(handle_ci_payload(args))
776
+ )
777
+ report_pr = report_subparsers.add_parser(
778
+ "pr", help="Render a Markdown PR impact summary."
779
+ )
780
+ report_pr.add_argument("--target", action="append", default=[])
781
+ report_pr.add_argument("--changed-file", action="append", default=[])
782
+ report_pr.add_argument("--max-results", type=int, default=30)
783
+ report_pr.add_argument(
784
+ "--semantic-baseline",
785
+ help="Optional semantic-stats or CI JSON baseline for resolution trend checks.",
786
+ )
787
+ report_pr.add_argument(
788
+ "--performance-budget-ms",
789
+ action="append",
790
+ default=[],
791
+ metavar="OPERATION=MS",
792
+ help="Override a CI performance budget in milliseconds. Can be repeated.",
793
+ )
794
+ _add_ci_runtime_trace_args(report_pr)
795
+ report_pr.add_argument("--output", help="Path to write the Markdown report.")
796
+ report_pr.set_defaults(handler=handle_report_pr)
797
+ report_html = report_subparsers.add_parser(
798
+ "html", help="Render a static HTML ArcGraph report."
799
+ )
800
+ report_html.add_argument("target", help="Dotted qualname, path, or symbol id.")
801
+ report_html.add_argument("--output", help="Path to write the HTML report.")
802
+ report_html.add_argument("--max-results", type=int, default=30)
803
+ report_html.add_argument(
804
+ "--profile",
805
+ default="review_default",
806
+ help="Impact confidence profile label to include in the report.",
807
+ )
808
+ report_html.add_argument(
809
+ "--include-edge-kind",
810
+ action="append",
811
+ default=[],
812
+ help="Limit impact edges to this kind. Can be repeated.",
813
+ )
814
+ report_html.add_argument(
815
+ "--exclude-edge-kind",
816
+ action="append",
817
+ default=[],
818
+ help="Exclude this edge kind from impact output. Can be repeated.",
819
+ )
820
+ report_html.set_defaults(handler=handle_report_html)
821
+ report_metrics = report_subparsers.add_parser(
822
+ "metrics-html", help="Render a static HTML metrics dashboard."
823
+ )
824
+ report_metrics.add_argument("path", help="Metrics JSONL path.")
825
+ report_metrics.add_argument("--limit", type=int, default=1000)
826
+ report_metrics.add_argument("--output", help="Path to write the HTML dashboard.")
827
+ report_metrics.set_defaults(handler=handle_report_metrics_html)
828
+ report_unresolved = report_subparsers.add_parser(
829
+ "unresolved", help="Render a Markdown unresolved classification report."
830
+ )
831
+ report_unresolved.add_argument(
832
+ "target",
833
+ nargs="?",
834
+ help="Optional file path, symbol, entrypoint, or stable id filter.",
835
+ )
836
+ report_unresolved.add_argument(
837
+ "--limit",
838
+ type=int,
839
+ default=10000,
840
+ help="Maximum unresolved diagnostics to classify.",
841
+ )
842
+ report_unresolved.add_argument(
843
+ "--output",
844
+ default="output/arcgraph/reports/unresolved-classification.md",
845
+ help="Path to write the Markdown report.",
846
+ )
847
+ report_unresolved.add_argument(
848
+ "--json-output",
849
+ help="Optional path to write machine-readable unresolved classification JSON.",
850
+ )
851
+ report_unresolved.set_defaults(handler=handle_report_unresolved)
852
+
853
+ tests = subparsers.add_parser(
854
+ "tests", help="Recommend related tests and coverage gaps."
855
+ )
856
+ tests.add_argument("target", help="Dotted qualname, path, or stable symbol id.")
857
+ tests.set_defaults(handler=lambda args: query_engine(args).tests(args.target))
858
+
859
+ references = subparsers.add_parser(
860
+ "references", help="Verify references with SCIP or TypeScript Language Service."
861
+ )
862
+ references.add_argument("target", help="Exact qualname or stable symbol id.")
863
+ references.add_argument(
864
+ "--backend",
865
+ choices=("auto", "scip", "typescript_language_service"),
866
+ default="auto",
867
+ )
868
+ references.add_argument("--max-results", type=int, default=200)
869
+ references.set_defaults(
870
+ handler=lambda args: query_engine(args).references(
871
+ args.target,
872
+ backend=args.backend,
873
+ max_results=args.max_results,
874
+ )
875
+ )
876
+
877
+ context = subparsers.add_parser(
878
+ "context", help="Return canonical agent context for one or more targets."
879
+ )
880
+ context.add_argument(
881
+ "target",
882
+ nargs="+",
883
+ help="Dotted qualname, path, stable symbol id, or entrypoint target.",
884
+ )
885
+ context.add_argument(
886
+ "--task", help="Optional task description for context ranking."
887
+ )
888
+ context.add_argument(
889
+ "--profile",
890
+ default="review_default",
891
+ help="Confidence profile to use for impact and risk grouping.",
892
+ )
893
+ context.add_argument("--max-results", type=int, default=30)
894
+ context.add_argument(
895
+ "--detail-level",
896
+ default="summary",
897
+ choices=["summary", "standard", "detailed"],
898
+ )
899
+ context.add_argument(
900
+ "--include-source",
901
+ action="store_true",
902
+ help="Include source snippets when the provider can safely expose them.",
903
+ )
904
+ context.set_defaults(handler=handle_context)
905
+
906
+ explain = subparsers.add_parser(
907
+ "explain", help="Explain target resolution, direct edges, and evidence."
908
+ )
909
+ explain.add_argument(
910
+ "target",
911
+ nargs="+",
912
+ help="Dotted qualname, path, stable symbol id, or entrypoint target.",
913
+ )
914
+ explain.add_argument(
915
+ "--task", help="Optional task description for explanation ranking."
916
+ )
917
+ explain.add_argument(
918
+ "--profile",
919
+ default="review_default",
920
+ help="Confidence profile to use for direct call evidence.",
921
+ )
922
+ explain.add_argument("--max-results", type=int, default=30)
923
+ explain.add_argument(
924
+ "--detail-level",
925
+ default="summary",
926
+ choices=["summary", "standard", "detailed"],
927
+ )
928
+ explain.add_argument(
929
+ "--include-source",
930
+ action="store_true",
931
+ help="Include source snippets when the provider can safely expose them.",
932
+ )
933
+ explain.set_defaults(handler=handle_explain)
934
+
935
+ similar = subparsers.add_parser(
936
+ "similar", help="Find indexed implementations similar to a symbol."
937
+ )
938
+ similar.add_argument("target", help="Dotted qualname or stable symbol id.")
939
+ similar.add_argument("--max-results", type=int, default=10)
940
+ similar.add_argument(
941
+ "--detail-level",
942
+ default="summary",
943
+ choices=["summary", "standard", "detailed"],
944
+ )
945
+ similar.set_defaults(
946
+ handler=lambda args: query_engine(args).similar(
947
+ args.target,
948
+ max_results=args.max_results,
949
+ detail_level=args.detail_level,
950
+ )
951
+ )
952
+
953
+ route = subparsers.add_parser(
954
+ "route", help="Trace an indexed route to handlers and middleware."
955
+ )
956
+ route.add_argument("method", help="HTTP method, for example GET or POST.")
957
+ route.add_argument("path", help="Route path, for example /api/v1/memories.")
958
+ route.set_defaults(
959
+ handler=lambda args: query_engine(args).route(args.method, args.path)
960
+ )
961
+
962
+ worker = subparsers.add_parser("worker", help="Trace an ARQ worker task or queue.")
963
+ worker.add_argument(
964
+ "target", help="worker:<id>, queue:<name>, task name, or queue name."
965
+ )
966
+ worker.set_defaults(handler=lambda args: query_engine(args).worker(args.target))
967
+
968
+ architecture = subparsers.add_parser(
969
+ "architecture", help="Show a basic architecture report."
970
+ )
971
+ architecture.set_defaults(handler=lambda args: query_engine(args).architecture())
972
+
973
+ add_visual_parser(subparsers)
974
+ trace = subparsers.add_parser("trace", help="Import runtime trace evidence.")
975
+ trace_subparsers = trace.add_subparsers(dest="trace_command")
976
+ trace_import = trace_subparsers.add_parser(
977
+ "import", help="Import a ArcGraph runtime trace JSON file."
978
+ )
979
+ trace_import.add_argument("path", help="Runtime trace JSON file to import.")
980
+ trace_import.add_argument(
981
+ "--max-events",
982
+ type=int,
983
+ default=50_000,
984
+ help="Maximum trace events to import from the file.",
985
+ )
986
+ trace_import.add_argument(
987
+ "--max-file-bytes",
988
+ type=int,
989
+ default=10 * 1024 * 1024,
990
+ help="Maximum runtime trace JSON file size to import.",
991
+ )
992
+ trace_import.add_argument(
993
+ "--max-seconds",
994
+ type=float,
995
+ default=120.0,
996
+ help="Maximum seconds to spend importing runtime trace events.",
997
+ )
998
+ trace_import.set_defaults(handler=handle_trace_import)
999
+ trace_import_otel = trace_subparsers.add_parser(
1000
+ "import-otel", help="Import offline OpenTelemetry span JSON/JSONL."
1001
+ )
1002
+ trace_import_otel.add_argument("path", help="OpenTelemetry span JSON/JSONL file.")
1003
+ trace_import_otel.add_argument(
1004
+ "--max-spans",
1005
+ type=int,
1006
+ default=50_000,
1007
+ help="Maximum OpenTelemetry spans to inspect from the file.",
1008
+ )
1009
+ trace_import_otel.add_argument(
1010
+ "--max-file-bytes",
1011
+ type=int,
1012
+ default=10 * 1024 * 1024,
1013
+ help="Maximum OpenTelemetry span file size to import.",
1014
+ )
1015
+ trace_import_otel.add_argument(
1016
+ "--max-seconds",
1017
+ type=float,
1018
+ default=120.0,
1019
+ help="Maximum seconds to spend importing OpenTelemetry spans.",
1020
+ )
1021
+ trace_import_otel.set_defaults(handler=handle_trace_import_otel)
1022
+ trace_import_har = trace_subparsers.add_parser(
1023
+ "import-har", help="Import offline HAR browser network JSON."
1024
+ )
1025
+ trace_import_har.add_argument("path", help="HAR 1.2 JSON file to import.")
1026
+ trace_import_har.add_argument(
1027
+ "--max-entries",
1028
+ type=int,
1029
+ default=50_000,
1030
+ help="Maximum HAR entries to inspect from the file.",
1031
+ )
1032
+ trace_import_har.add_argument(
1033
+ "--max-file-bytes",
1034
+ type=int,
1035
+ default=10 * 1024 * 1024,
1036
+ help="Maximum HAR file size to import.",
1037
+ )
1038
+ trace_import_har.add_argument(
1039
+ "--max-seconds",
1040
+ type=float,
1041
+ default=120.0,
1042
+ help="Maximum seconds to spend importing HAR entries.",
1043
+ )
1044
+ trace_import_har.set_defaults(handler=handle_trace_import_har)
1045
+ trace_run = trace_subparsers.add_parser(
1046
+ "run", help="Run pytest under a lightweight ArcGraph runtime tracer."
1047
+ )
1048
+ trace_run.add_argument(
1049
+ "--output",
1050
+ required=True,
1051
+ help="Path to write the runtime trace JSON file.",
1052
+ )
1053
+ trace_run.add_argument(
1054
+ "--root",
1055
+ action="append",
1056
+ default=[],
1057
+ help="Source root to map runtime frames. Can be repeated.",
1058
+ )
1059
+ trace_run.add_argument(
1060
+ "--max-events",
1061
+ type=int,
1062
+ default=50_000,
1063
+ help="Maximum runtime call events to record.",
1064
+ )
1065
+ trace_run.add_argument(
1066
+ "--max-seconds",
1067
+ type=float,
1068
+ default=120.0,
1069
+ help="Maximum seconds to trace each pytest test call.",
1070
+ )
1071
+ trace_run.add_argument(
1072
+ "pytest_args",
1073
+ nargs=argparse.REMAINDER,
1074
+ help="Arguments passed to pytest after an optional -- separator.",
1075
+ )
1076
+ _add_legacy_roots_arg(trace_run)
1077
+ trace_run.set_defaults(handler=handle_trace_run)
1078
+
1079
+ precision = subparsers.add_parser(
1080
+ "precision", help="Generate or convert precision input artifacts."
1081
+ )
1082
+ precision_subparsers = precision.add_subparsers(dest="precision_command")
1083
+ precision_scip_json = precision_subparsers.add_parser(
1084
+ "scip-json", help="Convert a binary index.scip file to JSON."
1085
+ )
1086
+ precision_scip_json.add_argument(
1087
+ "--input",
1088
+ required=True,
1089
+ help="Path to the binary SCIP index, usually index.scip.",
1090
+ )
1091
+ precision_scip_json.add_argument(
1092
+ "--output",
1093
+ required=True,
1094
+ help="Path to write ArcGraph-readable SCIP JSON.",
1095
+ )
1096
+ precision_scip_json.add_argument(
1097
+ "--scip-command",
1098
+ default="scip",
1099
+ help="SCIP CLI command to run. Defaults to scip.",
1100
+ )
1101
+ precision_scip_json.add_argument(
1102
+ "--root",
1103
+ action="append",
1104
+ default=[],
1105
+ help="Source root represented by the SCIP index. Defaults to ArcGraph roots.",
1106
+ )
1107
+ _add_legacy_roots_arg(precision_scip_json)
1108
+ precision_scip_json.set_defaults(handler=handle_precision_scip_json)
1109
+
1110
+ precision_scip_python = precision_subparsers.add_parser(
1111
+ "scip-python", help="Run scip-python and convert its index to JSON."
1112
+ )
1113
+ precision_scip_python.add_argument(
1114
+ "--output",
1115
+ required=True,
1116
+ help="Path to write ArcGraph-readable SCIP JSON.",
1117
+ )
1118
+ precision_scip_python.add_argument(
1119
+ "--index-file",
1120
+ help="Path to keep the generated binary SCIP index. Defaults beside --output.",
1121
+ )
1122
+ precision_scip_python.add_argument(
1123
+ "--project-name",
1124
+ help="Project name passed to scip-python. Defaults to the repository name.",
1125
+ )
1126
+ precision_scip_python.add_argument(
1127
+ "--project-version",
1128
+ help="Optional project version passed to scip-python.",
1129
+ )
1130
+ precision_scip_python.add_argument(
1131
+ "--target-only",
1132
+ action="append",
1133
+ default=[],
1134
+ help="Restrict scip-python to a target path. Can be repeated.",
1135
+ )
1136
+ precision_scip_python.add_argument(
1137
+ "--environment",
1138
+ help="Optional scip-python environment JSON file.",
1139
+ )
1140
+ precision_scip_python.add_argument(
1141
+ "--scip-python-command",
1142
+ default="scip-python",
1143
+ help="scip-python command to run. Defaults to scip-python.",
1144
+ )
1145
+ precision_scip_python.add_argument(
1146
+ "--scip-command",
1147
+ default="scip",
1148
+ help="SCIP CLI command to run for JSON conversion. Defaults to scip.",
1149
+ )
1150
+ precision_scip_python.add_argument(
1151
+ "--timeout-seconds",
1152
+ type=float,
1153
+ default=600.0,
1154
+ help="Maximum seconds to wait for scip-python.",
1155
+ )
1156
+ precision_scip_python.add_argument(
1157
+ "--root",
1158
+ action="append",
1159
+ default=[],
1160
+ help="Source root represented by the generated SCIP index. Defaults to ArcGraph roots.",
1161
+ )
1162
+ _add_legacy_roots_arg(precision_scip_python)
1163
+ precision_scip_python.set_defaults(handler=handle_precision_scip_python)
1164
+
1165
+ precision_pyright = precision_subparsers.add_parser(
1166
+ "pyright", help="Generate ArcGraph-readable Pyright precision JSON."
1167
+ )
1168
+ precision_pyright.add_argument(
1169
+ "--output",
1170
+ required=True,
1171
+ help="Path to write ArcGraph-readable Pyright JSON.",
1172
+ )
1173
+ precision_pyright.add_argument(
1174
+ "--target-only",
1175
+ action="append",
1176
+ default=[],
1177
+ help="Restrict Pyright probes to a target path. Can be repeated.",
1178
+ )
1179
+ precision_pyright.add_argument(
1180
+ "--root",
1181
+ action="append",
1182
+ default=[],
1183
+ help="Source root to scan for Pyright probes. Defaults to ArcGraph roots.",
1184
+ )
1185
+ precision_pyright.add_argument(
1186
+ "--python-version",
1187
+ default="3.11",
1188
+ help="Python version passed in the Pyright LSP initialization options.",
1189
+ )
1190
+ precision_pyright.add_argument(
1191
+ "--extra-path",
1192
+ action="append",
1193
+ default=[],
1194
+ help="Extra import path passed in the Pyright LSP initialization options.",
1195
+ )
1196
+ precision_pyright.add_argument(
1197
+ "--pyright-command",
1198
+ default="pyright-langserver",
1199
+ help="Pyright language server command. Defaults to pyright-langserver.",
1200
+ )
1201
+ precision_pyright.add_argument(
1202
+ "--timeout-seconds",
1203
+ type=float,
1204
+ default=300.0,
1205
+ help="Maximum seconds to wait for the Pyright LSP probe.",
1206
+ )
1207
+ _add_legacy_roots_arg(precision_pyright)
1208
+ precision_pyright.set_defaults(handler=handle_precision_pyright)
1209
+
1210
+ evidence = subparsers.add_parser(
1211
+ "evidence", help="Inspect and plan external evidence artifacts."
1212
+ )
1213
+ evidence_subparsers = evidence.add_subparsers(dest="evidence_command")
1214
+ evidence_status_cmd = evidence_subparsers.add_parser(
1215
+ "status", help="Report live evidence, index snapshot, and capability status."
1216
+ )
1217
+ evidence_status_cmd.set_defaults(handler=handle_evidence_status)
1218
+ evidence_plan_cmd = evidence_subparsers.add_parser(
1219
+ "plan", help="Report live evidence health and suggested generation commands."
1220
+ )
1221
+ evidence_plan_cmd.add_argument(
1222
+ "--profile",
1223
+ choices=["default", "python_full"],
1224
+ default="default",
1225
+ help="Evidence planning profile. python_full marks blockers for ci --python-full.",
1226
+ )
1227
+ evidence_plan_cmd.set_defaults(handler=handle_evidence_plan)
1228
+ evidence_stamp_cmd = evidence_subparsers.add_parser(
1229
+ "stamp", help="Write ArcGraph provenance metadata beside an evidence artifact."
1230
+ )
1231
+ evidence_stamp_cmd.add_argument(
1232
+ "--kind",
1233
+ required=True,
1234
+ choices=[
1235
+ "precision_scip",
1236
+ "precision_pyright",
1237
+ "coverage",
1238
+ "runtime_trace",
1239
+ ],
1240
+ )
1241
+ evidence_stamp_cmd.add_argument(
1242
+ "--path",
1243
+ required=True,
1244
+ help="Evidence artifact path to stamp, such as output/arcgraph/coverage.xml.",
1245
+ )
1246
+ evidence_stamp_cmd.add_argument(
1247
+ "--tool-name",
1248
+ required=True,
1249
+ help="Tool that generated the artifact, such as coverage or pytest-cov.",
1250
+ )
1251
+ evidence_stamp_cmd.add_argument("--tool-version")
1252
+ evidence_stamp_cmd.set_defaults(handler=handle_evidence_stamp)
1253
+
1254
+ ci = subparsers.add_parser("ci", help="Run ArcGraph CI checks.")
1255
+ ci.add_argument("--target", action="append", default=[])
1256
+ ci.add_argument("--changed-file", action="append", default=[])
1257
+ ci.add_argument("--max-results", type=int, default=30)
1258
+ ci.add_argument(
1259
+ "--semantic-baseline",
1260
+ help="Optional semantic-stats or CI JSON baseline for resolution trend checks.",
1261
+ )
1262
+ ci.add_argument(
1263
+ "--performance-budget-ms",
1264
+ action="append",
1265
+ default=[],
1266
+ metavar="OPERATION=MS",
1267
+ help="Override a CI performance budget in milliseconds. Can be repeated.",
1268
+ )
1269
+ ci.add_argument(
1270
+ "--require-precision",
1271
+ action="store_true",
1272
+ help="Fail when SCIP/Pyright precision inputs are unavailable.",
1273
+ )
1274
+ ci.add_argument(
1275
+ "--require-coverage",
1276
+ action="store_true",
1277
+ help="Fail when coverage input is unavailable or partial.",
1278
+ )
1279
+ ci.add_argument(
1280
+ "--require-runtime-trace",
1281
+ action="store_true",
1282
+ help="Fail when runtime trace input is unavailable or partial.",
1283
+ )
1284
+ ci.add_argument(
1285
+ "--python-full",
1286
+ action="store_true",
1287
+ help=(
1288
+ "Require the full Python evidence profile: precision, coverage, "
1289
+ "and runtime trace inputs must all be available."
1290
+ ),
1291
+ )
1292
+ _add_ci_runtime_trace_args(ci)
1293
+ ci.add_argument(
1294
+ "--fail-on-warnings",
1295
+ action="store_true",
1296
+ help="Return a non-zero exit code when any CI check warns.",
1297
+ )
1298
+ ci.set_defaults(handler=handle_ci)
1299
+
1300
+ ops = subparsers.add_parser("ops", help="Run ArcGraph operational tasks.")
1301
+ ops_subparsers = ops.add_subparsers(dest="ops_command")
1302
+ ops_rebuild = ops_subparsers.add_parser(
1303
+ "rebuild", help="Build or refresh the current ArcGraph index."
1304
+ )
1305
+ ops_rebuild.add_argument(
1306
+ "--if-stale",
1307
+ action="store_true",
1308
+ help="Skip rebuild when the current index is fresh.",
1309
+ )
1310
+ ops_rebuild.add_argument(
1311
+ "--root",
1312
+ action="append",
1313
+ default=[],
1314
+ help="Source root to scan. Can be repeated.",
1315
+ )
1316
+ ops_rebuild.add_argument(
1317
+ "--scip-index",
1318
+ help="Optional SCIP-derived JSON occurrence file for confirmed references.",
1319
+ )
1320
+ ops_rebuild.add_argument(
1321
+ "--pyright-export",
1322
+ help="Optional Pyright-derived JSON export path for the precision input contract.",
1323
+ )
1324
+ ops_rebuild.add_argument(
1325
+ "--runtime-trace",
1326
+ help="Optional ArcGraph runtime trace JSON file to import as runtime-only evidence.",
1327
+ )
1328
+ ops_rebuild.add_argument(
1329
+ "--coverage",
1330
+ help="Optional coverage JSON or Cobertura XML file for runtime test evidence.",
1331
+ )
1332
+ ops_rebuild.add_argument(
1333
+ "--semantic-call-resolution",
1334
+ action="store_true",
1335
+ help=(
1336
+ "Compatibility flag retained for older scripts; V2 receiver-aware "
1337
+ "call resolution is the default at schema 1.0."
1338
+ ),
1339
+ )
1340
+ ops_rebuild.add_argument(
1341
+ "--legacy-call-resolution",
1342
+ action="store_true",
1343
+ help=(
1344
+ "Use the legacy AST call resolver. This escape hatch is rejected by "
1345
+ "ci --python-full."
1346
+ ),
1347
+ )
1348
+ _add_legacy_roots_arg(ops_rebuild)
1349
+ ops_rebuild.set_defaults(handler=handle_ops_rebuild)
1350
+ ops_stale = ops_subparsers.add_parser(
1351
+ "stale-alert", help="Report whether the current index is stale."
1352
+ )
1353
+ ops_stale.add_argument(
1354
+ "--fail-on-stale",
1355
+ action="store_true",
1356
+ help="Return a non-zero exit code when the index is stale.",
1357
+ )
1358
+ ops_stale.set_defaults(handler=handle_ops_stale_alert)
1359
+ ops_prune = ops_subparsers.add_parser(
1360
+ "prune",
1361
+ help="Safely prune stale generated ArcGraph output.",
1362
+ )
1363
+ ops_prune.add_argument(
1364
+ "--keep-builds",
1365
+ type=int,
1366
+ default=3,
1367
+ help="Number of build directories to retain, always including current.",
1368
+ )
1369
+ ops_prune.add_argument(
1370
+ "--include-input-artifacts",
1371
+ action="store_true",
1372
+ help=(
1373
+ "Also delete generated root-level precision, coverage, and runtime "
1374
+ "input artifacts. Build history is the only default cleanup scope."
1375
+ ),
1376
+ )
1377
+ ops_prune.add_argument(
1378
+ "--apply",
1379
+ action="store_true",
1380
+ help="Delete planned candidates. Without this flag, prune is a dry run.",
1381
+ )
1382
+ ops_prune.set_defaults(handler=handle_ops_prune)
1383
+
1384
+ metrics = subparsers.add_parser("metrics", help="Summarize ArcGraph metrics JSONL.")
1385
+ metrics.add_argument("path", help="Metrics JSONL path.")
1386
+ metrics.add_argument("--limit", type=int, default=1000)
1387
+ metrics.add_argument(
1388
+ "--trial-summary",
1389
+ action="store_true",
1390
+ help=(
1391
+ "Return a privacy-bounded trial aggregate without paths, timestamps, "
1392
+ "or raw events."
1393
+ ),
1394
+ )
1395
+ metrics.set_defaults(handler=handle_metrics_summary)
1396
+
1397
+ add_change_parser(subparsers)
1398
+ mcp = subparsers.add_parser("mcp", help="Run local MCP server commands.")
1399
+ mcp_subparsers = mcp.add_subparsers(dest="mcp_command")
1400
+ mcp.set_defaults(handler=lambda args, parser=mcp: parser.print_help())
1401
+ mcp_serve = mcp_subparsers.add_parser(
1402
+ "serve",
1403
+ help=(
1404
+ "Run the local stdio MCP server. Default tools are read-only; an "
1405
+ "explicit feedback log enables one local append. Requires the "
1406
+ "optional MCP runtime extra."
1407
+ ),
1408
+ )
1409
+ add_mcp_server_args(mcp_serve)
1410
+ mcp_serve.set_defaults(handler=handle_mcp_serve)
1411
+
1412
+ add_trial_parser(subparsers)
1413
+ add_setup_parser(subparsers)
1414
+
1415
+ symbol = subparsers.add_parser(
1416
+ "symbol", help="Find a module, class, function, or method definition."
1417
+ )
1418
+ symbol.add_argument("qualname", help="Dotted qualname or stable symbol id.")
1419
+ symbol.set_defaults(handler=lambda args: query_engine(args).symbol(args.qualname))
1420
+
1421
+ current = subparsers.add_parser("current", help="Show current index metadata.")
1422
+ current.set_defaults(handler=lambda args: query_engine(args).current())
1423
+ status = subparsers.add_parser("status", help="Alias for current index metadata.")
1424
+ status.set_defaults(handler=lambda args: query_engine(args).current())
1425
+
1426
+ add_workspace_parser(subparsers)
1427
+ add_benchmark_parser(subparsers)
1428
+ add_feedback_parser(subparsers)
1429
+ agent_help = subparsers.add_parser(
1430
+ "help",
1431
+ help="Show structured Agent guidance for ArcGraph capabilities.",
1432
+ )
1433
+ agent_help.add_argument(
1434
+ "--topic",
1435
+ default="overview",
1436
+ choices=HELP_TOPICS,
1437
+ help="Agent guidance topic to render.",
1438
+ )
1439
+ agent_help.add_argument(
1440
+ "--tool",
1441
+ help="Exact registered MCP tool name to explain.",
1442
+ )
1443
+ agent_help.add_argument(
1444
+ "--surface",
1445
+ default="all",
1446
+ choices=HELP_SURFACES,
1447
+ help="Capability surface to include.",
1448
+ )
1449
+ agent_help.set_defaults(
1450
+ handler=lambda args, choices=subparsers.choices: handle_agent_help(
1451
+ args,
1452
+ cli_commands=tuple(choices),
1453
+ )
1454
+ )
1455
+ docs = subparsers.add_parser(
1456
+ "docs", help="Show built-in ArcGraph user reference docs."
1457
+ )
1458
+ docs.add_argument(
1459
+ "topic",
1460
+ nargs="?",
1461
+ default="cli-reference",
1462
+ choices=DOC_TOPICS,
1463
+ help="Documentation topic to render.",
1464
+ )
1465
+ docs.add_argument(
1466
+ "--json",
1467
+ action="store_true",
1468
+ help="Render the docs topic as structured JSON.",
1469
+ )
1470
+ docs.set_defaults(handler=lambda args: render_docs(args.topic, as_json=args.json))
1471
+
1472
+ return parser
1473
+
1474
+
1475
+ def handle_agent_help(
1476
+ args: argparse.Namespace,
1477
+ *,
1478
+ cli_commands: tuple[str, ...],
1479
+ ) -> dict[str, Any]:
1480
+ payload = build_agent_help(
1481
+ topic=cast(HelpTopic, args.topic),
1482
+ tool_name=args.tool,
1483
+ surface=args.surface,
1484
+ cli_commands=cli_commands,
1485
+ )
1486
+ if payload["status"] == "unavailable":
1487
+ payload["_exit_code"] = 2
1488
+ return payload
1489
+
1490
+
1491
+ def _resolve_source_roots(
1492
+ args: argparse.Namespace,
1493
+ repo_root: Path,
1494
+ ) -> ResolvedSourceRoots:
1495
+ """Unified source root resolution for all CLI commands.
1496
+
1497
+ Priority: --root (explicit) > --legacy-roots > auto-detect.
1498
+ """
1499
+ has_root = bool(getattr(args, "root", None))
1500
+ has_legacy = getattr(args, "legacy_roots", False)
1501
+
1502
+ if has_root and has_legacy:
1503
+ raise SystemExit("--root and --legacy-roots are mutually exclusive")
1504
+
1505
+ if has_root:
1506
+ roots = infer_source_roots(args.root)
1507
+ return ResolvedSourceRoots(
1508
+ roots=tuple(roots),
1509
+ detection=SourceRootDetection(
1510
+ strategy="cli_explicit",
1511
+ roots=tuple(r.path for r in roots),
1512
+ ),
1513
+ )
1514
+
1515
+ if has_legacy:
1516
+ return ResolvedSourceRoots(
1517
+ roots=LEGACY_SOURCE_ROOTS,
1518
+ detection=SourceRootDetection(
1519
+ strategy="legacy_roots",
1520
+ roots=tuple(r.path for r in LEGACY_SOURCE_ROOTS),
1521
+ ),
1522
+ )
1523
+
1524
+ return detect_source_roots(repo_root)
1525
+
1526
+
1527
+ def handle_init(args: argparse.Namespace) -> dict[str, Any]:
1528
+ repo_root = Path(args.repo_root).resolve()
1529
+ pyproject_path = repo_root / "pyproject.toml"
1530
+ resolved = detect_source_roots(repo_root)
1531
+ snippet = _arcgraph_config_snippet(resolved.roots)
1532
+ base_payload: dict[str, Any] = {
1533
+ "status": "available",
1534
+ "repo_root": str(repo_root),
1535
+ "pyproject_path": str(pyproject_path),
1536
+ "pyproject_exists": pyproject_path.exists(),
1537
+ "source_root_detection": {
1538
+ "strategy": resolved.detection.strategy,
1539
+ "roots": list(resolved.detection.roots),
1540
+ "non_package_dirs": list(resolved.detection.non_package_dirs),
1541
+ },
1542
+ "source_roots": [
1543
+ {"path": root.path, "module_prefix": root.module_prefix}
1544
+ for root in resolved.roots
1545
+ ],
1546
+ "config_snippet": snippet,
1547
+ "wrote": False,
1548
+ }
1549
+
1550
+ if not pyproject_path.exists():
1551
+ base_payload.update(
1552
+ {
1553
+ "action": "manual_config",
1554
+ "message": (
1555
+ "No pyproject.toml found. Add the config snippet manually, "
1556
+ "or create pyproject.toml first."
1557
+ ),
1558
+ "next_steps": [
1559
+ "Add the shown [tool.arcgraph] section to pyproject.toml.",
1560
+ "Run arcgraph build.",
1561
+ ],
1562
+ }
1563
+ )
1564
+ return base_payload
1565
+
1566
+ pyproject_text = pyproject_path.read_text(encoding="utf-8")
1567
+ if _pyproject_has_arcgraph_config(pyproject_text):
1568
+ base_payload.update(
1569
+ {
1570
+ "action": "already_configured",
1571
+ "message": "pyproject.toml already contains [tool.arcgraph] source_roots.",
1572
+ "next_steps": ["Run arcgraph build."],
1573
+ }
1574
+ )
1575
+ return base_payload
1576
+
1577
+ if args.dry_run:
1578
+ base_payload.update(
1579
+ {
1580
+ "action": "dry_run",
1581
+ "message": "Dry run only; pyproject.toml was not modified.",
1582
+ "next_steps": [
1583
+ "Rerun without --dry-run to write the config.",
1584
+ "Run arcgraph build.",
1585
+ ],
1586
+ }
1587
+ )
1588
+ return base_payload
1589
+
1590
+ separator = "" if pyproject_text.endswith("\n") else "\n"
1591
+ pyproject_path.write_text(
1592
+ f"{pyproject_text}{separator}\n{snippet}\n",
1593
+ encoding="utf-8",
1594
+ )
1595
+ base_payload.update(
1596
+ {
1597
+ "action": "written",
1598
+ "message": "Added [tool.arcgraph] to pyproject.toml.",
1599
+ "wrote": True,
1600
+ "next_steps": ["Run arcgraph build."],
1601
+ }
1602
+ )
1603
+ return base_payload
1604
+
1605
+
1606
+ def handle_doctor(args: argparse.Namespace) -> dict[str, Any]:
1607
+ """Run environment and index health checks."""
1608
+ import shutil
1609
+
1610
+ repo_root = Path(args.repo_root).resolve()
1611
+ output_dir = (repo_root / args.output_dir).resolve()
1612
+ checks: list[dict[str, Any]] = []
1613
+ detected_source_roots: list[str] = []
1614
+ project_languages = detect_project_languages(repo_root)
1615
+
1616
+ # 1. Project config -------------------------------------------------------
1617
+ pyproject_path = repo_root / "pyproject.toml"
1618
+ if not pyproject_path.exists() and project_languages == {"node"}:
1619
+ checks.append(
1620
+ {
1621
+ "name": "project_config",
1622
+ "status": "pass",
1623
+ "message": (
1624
+ "Node/TypeScript project detected from package.json or tsconfig; "
1625
+ "pyproject.toml is not required."
1626
+ ),
1627
+ }
1628
+ )
1629
+ elif not pyproject_path.exists():
1630
+ checks.append(
1631
+ {
1632
+ "name": "project_config",
1633
+ "status": "warn",
1634
+ "message": "No pyproject.toml found.",
1635
+ "fix": "Run arcgraph init or create pyproject.toml manually.",
1636
+ }
1637
+ )
1638
+ else:
1639
+ text = pyproject_path.read_text(encoding="utf-8")
1640
+ if _pyproject_has_arcgraph_config(text):
1641
+ checks.append(
1642
+ {
1643
+ "name": "project_config",
1644
+ "status": "pass",
1645
+ "message": "pyproject.toml has [tool.arcgraph] source_roots config.",
1646
+ }
1647
+ )
1648
+ else:
1649
+ checks.append(
1650
+ {
1651
+ "name": "project_config",
1652
+ "status": "warn",
1653
+ "message": "pyproject.toml exists but has no [tool.arcgraph] source_roots config.",
1654
+ "fix": "Run arcgraph init to add it.",
1655
+ }
1656
+ )
1657
+
1658
+ # 2. Source roots ----------------------------------------------------------
1659
+ external_toolchains: dict[str, Any] | None = None
1660
+ try:
1661
+ resolved = detect_source_roots(repo_root)
1662
+ detected_source_roots = [root.path for root in resolved.roots]
1663
+ root_paths = [repo_root / r.path for r in resolved.roots]
1664
+ missing = [r.path for r in resolved.roots if not (repo_root / r.path).is_dir()]
1665
+ if missing:
1666
+ checks.append(
1667
+ {
1668
+ "name": "source_roots",
1669
+ "status": "fail",
1670
+ "message": f"{len(missing)} source root(s) missing on disk: {', '.join(missing)}.",
1671
+ "fix": "Check your [tool.arcgraph] source_roots or project layout.",
1672
+ }
1673
+ )
1674
+ else:
1675
+ source_counts = source_file_counts(root_paths)
1676
+ source_count = sum(source_counts.values())
1677
+ checks.append(
1678
+ {
1679
+ "name": "source_roots",
1680
+ "status": "pass" if source_count > 0 else "warn",
1681
+ "message": (
1682
+ f"{len(resolved.roots)} root(s) detected ({resolved.detection.strategy}), "
1683
+ f"{source_count} supported source file(s) "
1684
+ f"({', '.join(f'{name}={count}' for name, count in source_counts.items() if count)})."
1685
+ ),
1686
+ "fix": "Check source_roots config." if source_count == 0 else None,
1687
+ }
1688
+ )
1689
+ except Exception as exc:
1690
+ checks.append(
1691
+ {
1692
+ "name": "source_roots",
1693
+ "status": "fail",
1694
+ "message": f"Source root detection failed: {exc}",
1695
+ }
1696
+ )
1697
+
1698
+ # 3. Index -----------------------------------------------------------------
1699
+ current_path = output_dir / "current.json"
1700
+ if not current_path.exists():
1701
+ checks.append(
1702
+ {
1703
+ "name": "index",
1704
+ "status": "warn",
1705
+ "message": f"No index found at {output_dir}.",
1706
+ "fix": "Run arcgraph build.",
1707
+ }
1708
+ )
1709
+ else:
1710
+ try:
1711
+ engine = QueryEngine(output_dir)
1712
+ cur = engine.current()
1713
+ raw_toolchains = cur.get("toolchain_status", {})
1714
+ external_toolchains = (
1715
+ raw_toolchains if isinstance(raw_toolchains, dict) else {}
1716
+ )
1717
+ freshness = cur.get("freshness", {})
1718
+ fresh_status = freshness.get("status", "unknown")
1719
+ schema = cur.get("schema_version", "?")
1720
+ nodes = cur.get("node_count", 0)
1721
+ edges = cur.get("edge_count", 0)
1722
+ if fresh_status == "fresh":
1723
+ checks.append(
1724
+ {
1725
+ "name": "index",
1726
+ "status": "pass",
1727
+ "message": f"Index is fresh (schema {schema}, {nodes:,} nodes, {edges:,} edges).",
1728
+ }
1729
+ )
1730
+ else:
1731
+ stale_files = freshness.get("stale_files", [])
1732
+ checks.append(
1733
+ {
1734
+ "name": "index",
1735
+ "status": "warn",
1736
+ "message": (
1737
+ f"Index is {fresh_status} (schema {schema}, "
1738
+ f"{len(stale_files)} file(s) changed)."
1739
+ ),
1740
+ "fix": (
1741
+ "Run arcgraph sync --if-stale; use arcgraph build "
1742
+ "when the index is missing or incompatible."
1743
+ ),
1744
+ }
1745
+ )
1746
+ except SchemaVersionError as exc:
1747
+ checks.append(
1748
+ {
1749
+ "name": "index",
1750
+ "status": "fail",
1751
+ "message": str(exc),
1752
+ "fix": "Run arcgraph build to rebuild with the current schema.",
1753
+ }
1754
+ )
1755
+ except FileNotFoundError as exc:
1756
+ checks.append(
1757
+ {
1758
+ "name": "index",
1759
+ "status": "fail",
1760
+ "message": str(exc),
1761
+ "fix": "Run arcgraph build.",
1762
+ }
1763
+ )
1764
+
1765
+ # 4. External semantic extractor toolchains -------------------------------
1766
+ if external_toolchains:
1767
+ toolchain_summary = summarize_toolchain_status(external_toolchains)
1768
+ required_missing = toolchain_summary["required_unavailable"]
1769
+ checks.append(
1770
+ {
1771
+ "name": "external_toolchains",
1772
+ "status": "warn" if required_missing else "pass",
1773
+ "message": (
1774
+ "External semantic extractor toolchains are available or optional."
1775
+ if not required_missing
1776
+ else "Required external semantic extractor toolchains are unavailable."
1777
+ ),
1778
+ "details": toolchain_summary,
1779
+ }
1780
+ )
1781
+ else:
1782
+ checks.append(
1783
+ {
1784
+ "name": "external_toolchains",
1785
+ "status": "pass",
1786
+ "message": "No external semantic extractor toolchains recorded.",
1787
+ }
1788
+ )
1789
+
1790
+ # 5. Evidence health ------------------------------------------------------
1791
+ try:
1792
+ manifest = load_live_evidence_manifest(
1793
+ repo_root=repo_root,
1794
+ output_dir=output_dir,
1795
+ commit_sha=current_commit(repo_root),
1796
+ source_roots=detected_source_roots or _detect_source_root_paths(repo_root),
1797
+ )
1798
+ evidence_status = evidence_health_check_status(manifest)
1799
+ checks.append(
1800
+ {
1801
+ "name": "evidence_health",
1802
+ "status": "pass" if evidence_status == "available" else "warn",
1803
+ "message": (
1804
+ "All external evidence artifacts are available."
1805
+ if evidence_status == "available"
1806
+ else f"External evidence health is {evidence_status}."
1807
+ ),
1808
+ "fix": (
1809
+ None
1810
+ if evidence_status == "available"
1811
+ else "Run arcgraph evidence plan for generation commands."
1812
+ ),
1813
+ "details": manifest.get("summary", {}),
1814
+ }
1815
+ )
1816
+ except Exception as exc:
1817
+ checks.append(
1818
+ {
1819
+ "name": "evidence_health",
1820
+ "status": "warn",
1821
+ "message": f"Evidence health could not be evaluated: {exc}",
1822
+ "fix": "Run arcgraph build to regenerate evidence manifest metadata.",
1823
+ }
1824
+ )
1825
+
1826
+ # 6. Precision tools -------------------------------------------------------
1827
+ tool_status: list[tuple[str, str | None]] = [
1828
+ ("scip-python", shutil.which("scip-python")),
1829
+ ("scip", shutil.which("scip")),
1830
+ ("pyright-langserver", shutil.which("pyright-langserver")),
1831
+ ]
1832
+ found = [(name, path) for name, path in tool_status if path]
1833
+ missing_tools = [name for name, path in tool_status if not path]
1834
+ if not missing_tools:
1835
+ checks.append(
1836
+ {
1837
+ "name": "precision_tools",
1838
+ "status": "pass",
1839
+ "message": f"All precision tools available ({', '.join(n for n, _ in found)}).",
1840
+ }
1841
+ )
1842
+ else:
1843
+ checks.append(
1844
+ {
1845
+ "name": "precision_tools",
1846
+ "status": "warn",
1847
+ "message": f"Missing: {', '.join(missing_tools)}.",
1848
+ "fix": (
1849
+ "Run scripts/install-arcgraph-precision-tools.ps1 (Windows) or "
1850
+ "install scip-python, scip, pyright manually. "
1851
+ "Precision tools are optional; builds work without them."
1852
+ ),
1853
+ }
1854
+ )
1855
+
1856
+ import platform
1857
+
1858
+ py_ver = platform.python_version()
1859
+ py_tuple = tuple(int(x) for x in py_ver.split(".")[:2])
1860
+ if (3, 11) <= py_tuple < (3, 13):
1861
+ checks.append(
1862
+ {
1863
+ "name": "python_version",
1864
+ "status": "pass",
1865
+ "message": f"Python {py_ver}.",
1866
+ }
1867
+ )
1868
+ else:
1869
+ checks.append(
1870
+ {
1871
+ "name": "python_version",
1872
+ "status": "fail",
1873
+ "message": f"Python {py_ver} (requires >=3.11,<3.13).",
1874
+ "fix": "Recreate the tool environment with Python 3.11 or 3.12.",
1875
+ }
1876
+ )
1877
+
1878
+ if "node" in project_languages:
1879
+ node_command = shutil.which("node")
1880
+ if node_command is None:
1881
+ checks.append(
1882
+ {
1883
+ "name": "node_runtime",
1884
+ "status": "fail",
1885
+ "message": "Node/TypeScript project detected but Node.js is unavailable.",
1886
+ "fix": "Install a supported Node.js runtime (20, 22, or 24).",
1887
+ }
1888
+ )
1889
+ else:
1890
+ try:
1891
+ completed = subprocess.run(
1892
+ [node_command, "--version"],
1893
+ cwd=repo_root,
1894
+ check=False,
1895
+ capture_output=True,
1896
+ text=True,
1897
+ encoding="utf-8",
1898
+ errors="replace",
1899
+ timeout=10,
1900
+ )
1901
+ except subprocess.TimeoutExpired:
1902
+ completed = None
1903
+ checks.append(
1904
+ {
1905
+ "name": "node_runtime",
1906
+ "status": (
1907
+ "pass"
1908
+ if completed is not None and completed.returncode == 0
1909
+ else "fail"
1910
+ ),
1911
+ "message": (
1912
+ f"Node.js {completed.stdout.strip()}."
1913
+ if completed is not None and completed.returncode == 0
1914
+ else (
1915
+ "Node.js version probe timed out."
1916
+ if completed is None
1917
+ else "Node.js version probe failed."
1918
+ )
1919
+ ),
1920
+ }
1921
+ )
1922
+ try:
1923
+ compiler = subprocess.run(
1924
+ [node_command, "-e", "require.resolve('typescript')"],
1925
+ cwd=repo_root,
1926
+ check=False,
1927
+ capture_output=True,
1928
+ text=True,
1929
+ encoding="utf-8",
1930
+ errors="replace",
1931
+ timeout=10,
1932
+ )
1933
+ except subprocess.TimeoutExpired:
1934
+ compiler = None
1935
+ checks.append(
1936
+ {
1937
+ "name": "typescript_compiler",
1938
+ "status": (
1939
+ "pass"
1940
+ if compiler is not None and compiler.returncode == 0
1941
+ else "warn"
1942
+ ),
1943
+ "message": (
1944
+ "TypeScript compiler API is resolvable from the project."
1945
+ if compiler is not None and compiler.returncode == 0
1946
+ else (
1947
+ "TypeScript compiler probe timed out."
1948
+ if compiler is None
1949
+ else "TypeScript compiler API is not resolvable from the project."
1950
+ )
1951
+ ),
1952
+ "fix": (
1953
+ None
1954
+ if compiler is not None and compiler.returncode == 0
1955
+ else "Install the project's locked TypeScript dependency."
1956
+ ),
1957
+ }
1958
+ )
1959
+ fail_count = sum(1 for c in checks if c["status"] == "fail")
1960
+ warn_count = sum(1 for c in checks if c["status"] == "warn")
1961
+ overall = "fail" if fail_count > 0 else "warn" if warn_count > 0 else "pass"
1962
+
1963
+ return {
1964
+ "status": overall,
1965
+ "project_languages": sorted(project_languages),
1966
+ "checks": checks,
1967
+ "summary": {
1968
+ "pass": sum(1 for c in checks if c["status"] == "pass"),
1969
+ "warn": warn_count,
1970
+ "fail": fail_count,
1971
+ },
1972
+ }
1973
+
1974
+
1975
+ def handle_build(args: argparse.Namespace) -> dict[str, Any]:
1976
+ repo_root = Path(args.repo_root).resolve()
1977
+ output_dir = (repo_root / args.output_dir).resolve()
1978
+ resolved = _resolve_source_roots(args, repo_root)
1979
+ # Merge [tool.arcgraph] exclude patterns with default ignore_rules
1980
+ exclude = resolved.detection.exclude
1981
+ ignore_rules: list[str] | None = (
1982
+ list(DEFAULT_IGNORE_RULES) + list(exclude) if exclude else None
1983
+ )
1984
+ indexer = ArcGraphIndexer(
1985
+ repo_root,
1986
+ output_dir,
1987
+ resolved.roots,
1988
+ ignore_rules=ignore_rules,
1989
+ scip_index_path=args.scip_index,
1990
+ scip_graph_index_path=args.scip_graph_index,
1991
+ openapi_path=args.openapi_spec,
1992
+ pyright_export_path=args.pyright_export,
1993
+ runtime_trace_path=args.runtime_trace,
1994
+ coverage_path=args.coverage,
1995
+ enable_v2_call_resolution=not args.legacy_call_resolution,
1996
+ source_root_detection=resolved.detection,
1997
+ )
1998
+ metadata, build_dir = indexer.build()
1999
+ payload = metadata.model_dump(mode="json")
2000
+ payload["build_dir"] = str(build_dir)
2001
+ if indexer.last_cleanup_result is not None:
2002
+ payload["cleanup"] = indexer.last_cleanup_result
2003
+ return payload
2004
+
2005
+
2006
+ def handle_reindex(args: argparse.Namespace) -> dict[str, Any]:
2007
+ if not args.changed:
2008
+ raise RuntimeError("Phase 3 only supports ArcGraph reindex --changed")
2009
+ repo_root = Path(args.repo_root).resolve()
2010
+ output_dir = (repo_root / args.output_dir).resolve()
2011
+ return ArcGraphReindexer(repo_root, output_dir).reindex_changed()
2012
+
2013
+
2014
+ def handle_semantic_stats(args: argparse.Namespace) -> dict[str, Any]:
2015
+ repo_root = Path(args.repo_root).resolve()
2016
+ output_dir = (repo_root / args.output_dir).resolve()
2017
+ payload = query_engine(args).semantic_stats()
2018
+ output_path = (
2019
+ Path(args.output)
2020
+ if args.output
2021
+ else output_dir / "metrics" / "semantic-stats.json"
2022
+ )
2023
+ named = bool(args.output)
2024
+ payload["metrics_path"] = str(output_location(output_path, named_by_user=named))
2025
+ write_json_output(output_path, payload, named_by_user=named)
2026
+ return payload
2027
+
2028
+
2029
+ def handle_ci(args: argparse.Namespace) -> dict[str, Any]:
2030
+ payload = handle_ci_payload(args)
2031
+ payload["_exit_code"] = 1 if payload["status"] == "fail" else 0
2032
+ if args.fail_on_warnings and payload["status"] == "warn":
2033
+ payload["_exit_code"] = 1
2034
+ return payload
2035
+
2036
+
2037
+ def handle_ci_payload(args: argparse.Namespace) -> dict[str, Any]:
2038
+ _import_runtime_trace_for_ci(args)
2039
+ return run_ci_checks(
2040
+ query_engine(args),
2041
+ targets=args.target,
2042
+ changed_files=args.changed_file,
2043
+ max_results=args.max_results,
2044
+ semantic_baseline=_load_json_arg(args.semantic_baseline),
2045
+ performance_budgets_ms=_load_performance_budgets_arg(
2046
+ args.performance_budget_ms
2047
+ ),
2048
+ require_precision=args.require_precision,
2049
+ require_coverage=args.require_coverage,
2050
+ require_runtime_trace=args.require_runtime_trace,
2051
+ require_python_full=args.python_full,
2052
+ )
2053
+
2054
+
2055
+ _SHAPING_OPTIONS = (
2056
+ ("max_results", "--max-results"),
2057
+ ("detail_level", "--detail-level"),
2058
+ ("include_source", "--include-source"),
2059
+ )
2060
+
2061
+
2062
+ def _agent_payload_request(args: argparse.Namespace) -> ContextRequest:
2063
+ """Build the bounded-payload request, applying only explicit options.
2064
+
2065
+ Options left at their ``None`` default fall through to the ``ContextRequest``
2066
+ defaults rather than pinning them here, so the schema stays the single
2067
+ source of truth for payload bounds.
2068
+ """
2069
+ fields: dict[str, Any] = {"targets": [args.target], "profile": args.profile}
2070
+ for attr, _flag in _SHAPING_OPTIONS:
2071
+ value = getattr(args, attr)
2072
+ if value is not None:
2073
+ fields[attr] = value
2074
+ return ContextRequest(**fields)
2075
+
2076
+
2077
+ def _reject_raw_shaping(args: argparse.Namespace) -> None:
2078
+ """Fail loudly when ``--raw`` is combined with options it cannot apply.
2079
+
2080
+ ``--raw`` returns the QueryEngine payload unchanged, so it can honour none
2081
+ of the shaping options. Accepting them and dropping them silently is what
2082
+ let ``callers TARGET --max-results 5`` return all 132 callers.
2083
+ """
2084
+ supplied = [
2085
+ flag for attr, flag in _SHAPING_OPTIONS if getattr(args, attr) is not None
2086
+ ]
2087
+ if supplied:
2088
+ # RuntimeError, not ValueError: main() re-raises ValueError for
2089
+ # non-change commands, which would surface this as a traceback.
2090
+ raise RuntimeError(
2091
+ f"--raw returns the unbounded debug payload and cannot apply "
2092
+ f"{', '.join(supplied)}. Drop --raw to use them."
2093
+ )
2094
+
2095
+
2096
+ def handle_callers(args: argparse.Namespace) -> dict[str, Any]:
2097
+ if args.raw:
2098
+ _reject_raw_shaping(args)
2099
+ return query_engine(args).callers(args.target, profile=args.profile)
2100
+ request = _agent_payload_request(args)
2101
+ return ContextProvider(query_engine(args)).compact_callers(args.target, request)
2102
+
2103
+
2104
+ def handle_callees(args: argparse.Namespace) -> dict[str, Any]:
2105
+ if args.raw:
2106
+ _reject_raw_shaping(args)
2107
+ return query_engine(args).callees(args.target, profile=args.profile)
2108
+ request = _agent_payload_request(args)
2109
+ return ContextProvider(query_engine(args)).compact_callees(args.target, request)
2110
+
2111
+
2112
+ def handle_impact(args: argparse.Namespace) -> dict[str, Any]:
2113
+ if args.raw:
2114
+ _reject_raw_shaping(args)
2115
+ return query_engine(args).impact(
2116
+ args.target,
2117
+ max_depth=args.max_depth,
2118
+ profile=args.profile,
2119
+ include_edge_kinds=args.include_edge_kind,
2120
+ exclude_edge_kinds=args.exclude_edge_kind,
2121
+ )
2122
+ request = _agent_payload_request(args)
2123
+ return ContextProvider(query_engine(args)).compact_impact(
2124
+ args.target,
2125
+ request,
2126
+ max_depth=args.max_depth,
2127
+ include_edge_kinds=args.include_edge_kind,
2128
+ exclude_edge_kinds=args.exclude_edge_kind,
2129
+ )
2130
+
2131
+
2132
+ def handle_report_pr(args: argparse.Namespace) -> dict[str, Any] | str:
2133
+ repo_root = Path(args.repo_root).resolve()
2134
+ changed_files = args.changed_file or _infer_changed_files(repo_root)
2135
+ _import_runtime_trace_for_ci(args)
2136
+ ci_result = run_ci_checks(
2137
+ query_engine(args),
2138
+ targets=args.target,
2139
+ changed_files=changed_files,
2140
+ max_results=args.max_results,
2141
+ semantic_baseline=_load_json_arg(args.semantic_baseline),
2142
+ performance_budgets_ms=_load_performance_budgets_arg(
2143
+ args.performance_budget_ms
2144
+ ),
2145
+ )
2146
+ markdown = render_pr_markdown(ci_result)
2147
+ if not args.output:
2148
+ return markdown
2149
+ output_path = Path(args.output)
2150
+ output_path.parent.mkdir(parents=True, exist_ok=True)
2151
+ output_path.write_text(markdown, encoding="utf-8")
2152
+ return {
2153
+ "schema_version": ci_result["schema_version"],
2154
+ "index_version": ci_result.get("index_version"),
2155
+ "status": ci_result.get("status", "unknown"),
2156
+ "path": str(output_path.resolve()),
2157
+ "changed_files": changed_files,
2158
+ }
2159
+
2160
+
2161
+ def handle_report_html(args: argparse.Namespace) -> dict[str, Any] | str:
2162
+ engine = query_engine(args)
2163
+ query_target = _normalized_report_target(args.target)
2164
+ impact = engine.impact(
2165
+ query_target,
2166
+ profile=args.profile,
2167
+ include_edge_kinds=args.include_edge_kind,
2168
+ exclude_edge_kinds=args.exclude_edge_kind,
2169
+ )
2170
+ similar = engine.similar(query_target, max_results=args.max_results)
2171
+ architecture = engine.architecture()
2172
+ entrypoint_flow = (
2173
+ engine.entrypoint_flow(args.target, max_results=args.max_results)
2174
+ if _is_entrypoint_target(args.target)
2175
+ else None
2176
+ )
2177
+ ci_result = run_ci_checks(
2178
+ engine,
2179
+ targets=[query_target],
2180
+ max_results=args.max_results,
2181
+ )
2182
+ html = render_static_html_report(
2183
+ target=args.target,
2184
+ impact=impact,
2185
+ similar=similar,
2186
+ architecture=architecture,
2187
+ entrypoint_flow=entrypoint_flow,
2188
+ ci_result=ci_result,
2189
+ )
2190
+ if not args.output:
2191
+ return html
2192
+ output_path = Path(args.output)
2193
+ output_path.parent.mkdir(parents=True, exist_ok=True)
2194
+ output_path.write_text(html, encoding="utf-8")
2195
+ return {
2196
+ "schema_version": impact["schema_version"],
2197
+ "index_version": impact.get("index_version"),
2198
+ "status": "available",
2199
+ "path": str(output_path.resolve()),
2200
+ }
2201
+
2202
+
2203
+ def handle_report_metrics_html(args: argparse.Namespace) -> dict[str, Any] | str:
2204
+ metrics_path = Path(args.path)
2205
+ try:
2206
+ metrics = summarize_metrics(metrics_path, limit=args.limit)
2207
+ except MetricsLogEncodingError:
2208
+ return _metrics_report_error_payload(metrics_path, invalid_encoding=True)
2209
+ except PrivateLocalStateError:
2210
+ return _metrics_report_error_payload(metrics_path)
2211
+ html = render_metrics_dashboard_html(metrics)
2212
+ if not args.output:
2213
+ return html
2214
+ output_path = Path(args.output)
2215
+ output_path.parent.mkdir(parents=True, exist_ok=True)
2216
+ output_path.write_text(html, encoding="utf-8")
2217
+ return {
2218
+ "status": metrics.get("status", "unknown"),
2219
+ "path": str(output_path.resolve()),
2220
+ "event_count": metrics.get("event_count", 0),
2221
+ }
2222
+
2223
+
2224
+ def handle_report_unresolved(args: argparse.Namespace) -> dict[str, Any] | str:
2225
+ payload = query_engine(args).unresolved(args.target, limit=args.limit)
2226
+ classification = classify_unresolved_records(payload)
2227
+ markdown = render_unresolved_classification_markdown(payload)
2228
+ if not args.output:
2229
+ return markdown
2230
+ output_path = Path(args.output)
2231
+ output_path.parent.mkdir(parents=True, exist_ok=True)
2232
+ output_path.write_text(markdown, encoding="utf-8")
2233
+ json_output = Path(args.json_output) if args.json_output else None
2234
+ if json_output is not None:
2235
+ json_output.parent.mkdir(parents=True, exist_ok=True)
2236
+ json_output.write_text(
2237
+ json.dumps(classification, indent=2, sort_keys=True, ensure_ascii=False),
2238
+ encoding="utf-8",
2239
+ )
2240
+ return {
2241
+ "schema_version": payload["schema_version"],
2242
+ "index_version": payload.get("index_version"),
2243
+ "status": payload.get("status", "unknown"),
2244
+ "path": str(output_path.resolve()),
2245
+ "json_path": str(json_output.resolve()) if json_output else None,
2246
+ "total": payload.get("summary", {}).get("total", 0),
2247
+ "classified": payload.get("summary", {}).get("returned", 0),
2248
+ "category_counts": classification["category_counts"],
2249
+ }
2250
+
2251
+
2252
+ def handle_context(args: argparse.Namespace) -> dict[str, Any]:
2253
+ request = ContextRequest(
2254
+ task=args.task,
2255
+ targets=args.target,
2256
+ max_results=args.max_results,
2257
+ detail_level=args.detail_level,
2258
+ profile=args.profile,
2259
+ include_source=args.include_source,
2260
+ )
2261
+ return ContextProvider(query_engine(args)).get_context(request)
2262
+
2263
+
2264
+ def handle_explain(args: argparse.Namespace) -> dict[str, Any]:
2265
+ request = ContextRequest(
2266
+ task=args.task,
2267
+ targets=args.target,
2268
+ max_results=args.max_results,
2269
+ detail_level=args.detail_level,
2270
+ profile=args.profile,
2271
+ include_source=args.include_source,
2272
+ )
2273
+ return ContextProvider(query_engine(args)).explain(request)
2274
+
2275
+
2276
+ def handle_ops_rebuild(args: argparse.Namespace) -> dict[str, Any]:
2277
+ repo_root = Path(args.repo_root).resolve()
2278
+ output_dir = (repo_root / args.output_dir).resolve()
2279
+ resolved = _resolve_source_roots(args, repo_root)
2280
+ return rebuild_index(
2281
+ repo_root=repo_root,
2282
+ output_dir=output_dir,
2283
+ source_roots=list(resolved.roots),
2284
+ if_stale=args.if_stale,
2285
+ scip_index_path=args.scip_index,
2286
+ pyright_export_path=args.pyright_export,
2287
+ runtime_trace_path=args.runtime_trace,
2288
+ coverage_path=args.coverage,
2289
+ enable_v2_call_resolution=not args.legacy_call_resolution,
2290
+ source_root_detection=resolved.detection,
2291
+ )
2292
+
2293
+
2294
+ def handle_ops_stale_alert(args: argparse.Namespace) -> dict[str, Any]:
2295
+ return stale_alert(query_engine(args), fail_on_stale=args.fail_on_stale)
2296
+
2297
+
2298
+ def handle_ops_prune(args: argparse.Namespace) -> dict[str, Any]:
2299
+ repo_root = Path(args.repo_root).resolve()
2300
+ output_dir = (repo_root / args.output_dir).resolve()
2301
+ return prune_output(
2302
+ output_dir=output_dir,
2303
+ keep_builds=args.keep_builds,
2304
+ include_input_artifacts=args.include_input_artifacts,
2305
+ apply=args.apply,
2306
+ )
2307
+
2308
+
2309
+ def handle_trace_import(args: argparse.Namespace) -> dict[str, Any]:
2310
+ repo_root = Path(args.repo_root).resolve()
2311
+ output_dir = (repo_root / args.output_dir).resolve()
2312
+ return import_runtime_trace(
2313
+ repo_root=repo_root,
2314
+ output_dir=output_dir,
2315
+ trace_path=args.path,
2316
+ max_events=args.max_events,
2317
+ max_file_bytes=args.max_file_bytes,
2318
+ max_seconds=args.max_seconds,
2319
+ )
2320
+
2321
+
2322
+ def handle_trace_import_otel(args: argparse.Namespace) -> dict[str, Any]:
2323
+ repo_root = Path(args.repo_root).resolve()
2324
+ output_dir = (repo_root / args.output_dir).resolve()
2325
+ return import_otel_spans(
2326
+ repo_root=repo_root,
2327
+ output_dir=output_dir,
2328
+ span_path=args.path,
2329
+ max_spans=args.max_spans,
2330
+ max_file_bytes=args.max_file_bytes,
2331
+ max_seconds=args.max_seconds,
2332
+ )
2333
+
2334
+
2335
+ def handle_trace_import_har(args: argparse.Namespace) -> dict[str, Any]:
2336
+ repo_root = Path(args.repo_root).resolve()
2337
+ output_dir = (repo_root / args.output_dir).resolve()
2338
+ return import_har_network(
2339
+ repo_root=repo_root,
2340
+ output_dir=output_dir,
2341
+ har_path=args.path,
2342
+ max_entries=args.max_entries,
2343
+ max_file_bytes=args.max_file_bytes,
2344
+ max_seconds=args.max_seconds,
2345
+ )
2346
+
2347
+
2348
+ def _add_ci_runtime_trace_args(parser: argparse.ArgumentParser) -> None:
2349
+ parser.add_argument(
2350
+ "--runtime-trace",
2351
+ help=(
2352
+ "Explicit runtime trace JSON to import before CI checks. "
2353
+ "CI never records or imports runtime evidence unless this is set."
2354
+ ),
2355
+ )
2356
+ parser.add_argument(
2357
+ "--runtime-trace-max-events",
2358
+ type=int,
2359
+ default=50_000,
2360
+ help="Maximum runtime trace events to import for CI.",
2361
+ )
2362
+ parser.add_argument(
2363
+ "--runtime-trace-max-file-bytes",
2364
+ type=int,
2365
+ default=10 * 1024 * 1024,
2366
+ help="Maximum runtime trace JSON file size to import for CI.",
2367
+ )
2368
+ parser.add_argument(
2369
+ "--runtime-trace-max-seconds",
2370
+ type=float,
2371
+ default=120.0,
2372
+ help="Maximum seconds to spend importing runtime trace events for CI.",
2373
+ )
2374
+
2375
+
2376
+ def _import_runtime_trace_for_ci(args: argparse.Namespace) -> None:
2377
+ trace_path = getattr(args, "runtime_trace", None)
2378
+ if not trace_path:
2379
+ return
2380
+ repo_root = Path(args.repo_root).resolve()
2381
+ output_dir = (repo_root / args.output_dir).resolve()
2382
+ import_runtime_trace(
2383
+ repo_root=repo_root,
2384
+ output_dir=output_dir,
2385
+ trace_path=trace_path,
2386
+ max_events=args.runtime_trace_max_events,
2387
+ max_file_bytes=args.runtime_trace_max_file_bytes,
2388
+ max_seconds=args.runtime_trace_max_seconds,
2389
+ )
2390
+
2391
+
2392
+ def handle_trace_run(args: argparse.Namespace) -> dict[str, Any]:
2393
+ repo_root = Path(args.repo_root).resolve()
2394
+ resolved = _resolve_source_roots(args, repo_root)
2395
+ pytest_args = list(args.pytest_args or [])
2396
+ if pytest_args and pytest_args[0] == "--":
2397
+ pytest_args = pytest_args[1:]
2398
+ return run_runtime_trace(
2399
+ repo_root=repo_root,
2400
+ output_path=args.output,
2401
+ pytest_args=pytest_args,
2402
+ source_roots=list(resolved.roots),
2403
+ max_events=args.max_events,
2404
+ max_seconds=args.max_seconds,
2405
+ )
2406
+
2407
+
2408
+ def handle_precision_scip_json(args: argparse.Namespace) -> dict[str, Any]:
2409
+ repo_root = Path(args.repo_root).resolve()
2410
+ resolved = _resolve_source_roots(args, repo_root)
2411
+ return convert_scip_index_to_json(
2412
+ repo_root=repo_root,
2413
+ scip_index_path=args.input,
2414
+ output_path=args.output,
2415
+ scip_command=args.scip_command,
2416
+ source_roots=list(resolved.roots),
2417
+ commit_sha=current_commit(repo_root),
2418
+ )
2419
+
2420
+
2421
+ def handle_precision_scip_python(args: argparse.Namespace) -> dict[str, Any]:
2422
+ repo_root = Path(args.repo_root).resolve()
2423
+ resolved = _resolve_source_roots(args, repo_root)
2424
+ return generate_scip_python_json(
2425
+ repo_root=repo_root,
2426
+ output_path=args.output,
2427
+ index_file=args.index_file,
2428
+ project_name=args.project_name,
2429
+ project_version=args.project_version,
2430
+ target_only=args.target_only,
2431
+ environment_path=args.environment,
2432
+ scip_python_command=args.scip_python_command,
2433
+ scip_command=args.scip_command,
2434
+ timeout_seconds=args.timeout_seconds,
2435
+ source_roots=list(resolved.roots),
2436
+ commit_sha=current_commit(repo_root),
2437
+ )
2438
+
2439
+
2440
+ def handle_precision_pyright(args: argparse.Namespace) -> dict[str, Any]:
2441
+ repo_root = Path(args.repo_root).resolve()
2442
+ resolved = _resolve_source_roots(args, repo_root)
2443
+ return generate_pyright_json(
2444
+ repo_root=repo_root,
2445
+ output_path=args.output,
2446
+ target_only=args.target_only,
2447
+ source_roots=list(resolved.roots),
2448
+ python_version=args.python_version,
2449
+ extra_path=args.extra_path,
2450
+ pyright_command=args.pyright_command,
2451
+ timeout_seconds=args.timeout_seconds,
2452
+ commit_sha=current_commit(repo_root),
2453
+ )
2454
+
2455
+
2456
+ def handle_evidence_status(args: argparse.Namespace) -> dict[str, Any]:
2457
+ repo_root = Path(args.repo_root).resolve()
2458
+ output_dir = (repo_root / args.output_dir).resolve()
2459
+ current = _current_index_or_empty(args)
2460
+ return evidence_status(
2461
+ repo_root=repo_root,
2462
+ output_dir=output_dir,
2463
+ commit_sha=current_commit(repo_root),
2464
+ source_roots=_detect_source_root_paths(repo_root),
2465
+ snapshot_manifest=(
2466
+ current.get("evidence_manifest") if isinstance(current, dict) else None
2467
+ ),
2468
+ capabilities=(
2469
+ current.get("capabilities", {}) if isinstance(current, dict) else {}
2470
+ ),
2471
+ )
2472
+
2473
+
2474
+ def handle_evidence_plan(args: argparse.Namespace) -> dict[str, Any]:
2475
+ repo_root = Path(args.repo_root).resolve()
2476
+ output_dir = (repo_root / args.output_dir).resolve()
2477
+ current = _current_index_or_empty(args)
2478
+ requirements = (
2479
+ evidence_requirement_summary(current, require_python_full=True)["requirements"]
2480
+ if args.profile == "python_full" and current
2481
+ else None
2482
+ )
2483
+ return evidence_plan(
2484
+ repo_root=repo_root,
2485
+ output_dir=output_dir,
2486
+ commit_sha=current_commit(repo_root),
2487
+ source_roots=_detect_source_root_paths(repo_root),
2488
+ profile=args.profile,
2489
+ project_name=_project_name_for_evidence(repo_root, current),
2490
+ python_full_requirements=requirements,
2491
+ )
2492
+
2493
+
2494
+ def handle_evidence_stamp(args: argparse.Namespace) -> dict[str, Any]:
2495
+ repo_root = Path(args.repo_root).resolve()
2496
+ return write_evidence_sidecar(
2497
+ repo_root=repo_root,
2498
+ artifact_path=args.path,
2499
+ kind=args.kind,
2500
+ commit_sha=current_commit(repo_root),
2501
+ source_roots=_detect_source_root_paths(repo_root),
2502
+ tool_name=args.tool_name,
2503
+ tool_version=args.tool_version,
2504
+ )
2505
+
2506
+
2507
+ def handle_metrics_summary(args: argparse.Namespace) -> dict[str, Any]:
2508
+ path = Path(args.path)
2509
+ try:
2510
+ if args.trial_summary:
2511
+ return summarize_trial_metrics(path, limit=args.limit)
2512
+ return summarize_metrics(path, limit=args.limit)
2513
+ except MetricsLogEncodingError:
2514
+ return _metrics_read_error_payload(
2515
+ path,
2516
+ trial_summary=args.trial_summary,
2517
+ invalid_encoding=True,
2518
+ )
2519
+ except PrivateLocalStateError:
2520
+ return _metrics_read_error_payload(
2521
+ path,
2522
+ trial_summary=args.trial_summary,
2523
+ )
2524
+
2525
+
2526
+ def _metrics_read_error_warning(*, invalid_encoding: bool) -> str:
2527
+ if invalid_encoding:
2528
+ return "Metrics log is not valid UTF-8. Start a new private metrics log."
2529
+ return "Metrics log could not be read safely. Use a new canonical private path."
2530
+
2531
+
2532
+ def _metrics_read_error_payload(
2533
+ path: Path,
2534
+ *,
2535
+ trial_summary: bool,
2536
+ invalid_encoding: bool = False,
2537
+ ) -> dict[str, Any]:
2538
+ payload = metrics_unavailable_payload(
2539
+ path,
2540
+ trial_summary=trial_summary,
2541
+ warning=_metrics_read_error_warning(invalid_encoding=invalid_encoding),
2542
+ )
2543
+ payload["_exit_code"] = 2
2544
+ return payload
2545
+
2546
+
2547
+ def _metrics_report_error_payload(
2548
+ metrics_path: Path,
2549
+ *,
2550
+ invalid_encoding: bool = False,
2551
+ ) -> dict[str, Any]:
2552
+ """Report a failed metrics read without borrowing the summary result shape.
2553
+
2554
+ ``report metrics-html`` uses ``path`` for the report it wrote, and its
2555
+ success result can itself carry ``status: "unavailable"`` for an empty log.
2556
+ Reusing the summary payload here would make an unwritten report
2557
+ indistinguishable from a written one on every field a caller inspects.
2558
+ """
2559
+
2560
+ return {
2561
+ "status": "error",
2562
+ "error_code": (
2563
+ METRICS_LOG_INVALID_ENCODING_ERROR_CODE
2564
+ if invalid_encoding
2565
+ else METRICS_LOG_UNREADABLE_ERROR_CODE
2566
+ ),
2567
+ "metrics_path": str(metrics_path),
2568
+ "event_count": 0,
2569
+ "warnings": [_metrics_read_error_warning(invalid_encoding=invalid_encoding)],
2570
+ "_exit_code": 2,
2571
+ }
2572
+
2573
+
2574
+ def _top_level_command(argv: list[str]) -> str | None:
2575
+ options_with_values = {"--repo-root", "--output-dir", "--metrics-log"}
2576
+ index = 0
2577
+ while index < len(argv):
2578
+ token = argv[index]
2579
+ if token in options_with_values:
2580
+ index += 2
2581
+ continue
2582
+ if any(token.startswith(f"{option}=") for option in options_with_values):
2583
+ index += 1
2584
+ continue
2585
+ if token.startswith("-"):
2586
+ index += 1
2587
+ continue
2588
+ return token
2589
+ return None
2590
+
2591
+
2592
+ def handle_mcp_serve(args: argparse.Namespace) -> None:
2593
+ serve_mcp(args)
2594
+ return None
2595
+
2596
+
2597
+ def _detect_source_root_paths(repo_root: Path) -> list[str]:
2598
+ return [root.path for root in detect_source_roots(repo_root).roots]
2599
+
2600
+
2601
+ def _current_index_or_empty(args: argparse.Namespace) -> dict[str, Any]:
2602
+ try:
2603
+ return query_engine(args).current()
2604
+ except (FileNotFoundError, SchemaVersionError, RuntimeError, ValueError):
2605
+ return {}
2606
+
2607
+
2608
+ def _project_name_for_evidence(repo_root: Path, current: dict[str, Any] | None) -> str:
2609
+ if isinstance(current, dict):
2610
+ current_name = current.get("project_name")
2611
+ if isinstance(current_name, str) and current_name:
2612
+ return current_name
2613
+ pyproject_path = repo_root / "pyproject.toml"
2614
+ if pyproject_path.exists():
2615
+ try:
2616
+ import tomllib
2617
+
2618
+ data = tomllib.loads(pyproject_path.read_text(encoding="utf-8"))
2619
+ project = data.get("project")
2620
+ if isinstance(project, dict):
2621
+ name = project.get("name")
2622
+ if isinstance(name, str) and name:
2623
+ return name
2624
+ except Exception:
2625
+ pass
2626
+ return repo_root.name
2627
+
2628
+
2629
+ def _pyproject_has_arcgraph_config(text: str) -> bool:
2630
+ try:
2631
+ import tomllib
2632
+
2633
+ data = tomllib.loads(text)
2634
+ except Exception as exc:
2635
+ raise RuntimeError(f"Unable to parse pyproject.toml: {exc}") from exc
2636
+
2637
+ tool = data["tool"] if "tool" in data else None
2638
+ if not isinstance(tool, dict):
2639
+ return False
2640
+ arcgraph = tool["arcgraph"] if "arcgraph" in tool else None
2641
+ if not isinstance(arcgraph, dict):
2642
+ return False
2643
+ source_roots = arcgraph["source_roots"] if "source_roots" in arcgraph else None
2644
+ if not isinstance(source_roots, list):
2645
+ return False
2646
+
2647
+ def path_value(entry: dict[str, Any]) -> Any:
2648
+ return entry["path"] if "path" in entry else None
2649
+
2650
+ return any(
2651
+ (isinstance(entry, str) and bool(entry))
2652
+ or (
2653
+ isinstance(entry, dict)
2654
+ and isinstance(path_value(entry), str)
2655
+ and bool(path_value(entry))
2656
+ )
2657
+ for entry in source_roots
2658
+ )
2659
+
2660
+
2661
+ def _arcgraph_config_snippet(roots: tuple[SourceRoot, ...]) -> str:
2662
+ lines = ["[tool.arcgraph]"]
2663
+ if all(not root.module_prefix for root in roots):
2664
+ values = ", ".join(_toml_string(root.path) for root in roots)
2665
+ if len(values) <= 72:
2666
+ lines.append(f"source_roots = [{values}]")
2667
+ return "\n".join(lines)
2668
+
2669
+ lines.append("source_roots = [")
2670
+ for root in roots:
2671
+ if root.module_prefix:
2672
+ lines.append(
2673
+ " { path = "
2674
+ f"{_toml_string(root.path)}, "
2675
+ f"module_prefix = {_toml_string(root.module_prefix)}"
2676
+ " },"
2677
+ )
2678
+ else:
2679
+ lines.append(f" {_toml_string(root.path)},")
2680
+ lines.append("]")
2681
+ return "\n".join(lines)
2682
+
2683
+
2684
+ def _toml_string(value: str) -> str:
2685
+ return json.dumps(value, ensure_ascii=False)
2686
+
2687
+
2688
+ # The classifier lives beside parse_route_target so every surface shares one
2689
+ # definition. A real def rather than an alias assignment: ArcGraph's own
2690
+ # resolver cannot follow calls through an alias binding, and the unresolved
2691
+ # callsite would trip the release gate.
2692
+ def _is_entrypoint_target(target: str) -> bool:
2693
+ return is_entrypoint_target(target)
2694
+
2695
+
2696
+ def _normalized_report_target(target: str) -> str:
2697
+ if target.startswith("mcp:"):
2698
+ return f"mcp_tool:{target.split(':', 1)[1]}"
2699
+ parsed = parse_route_target(target)
2700
+ if parsed is not None:
2701
+ method, path = parsed
2702
+ return route_id(method, path)
2703
+ return target
2704
+
2705
+
2706
+ def _infer_changed_files(repo_root: Path) -> list[str]:
2707
+ status_files = _git_status_files(repo_root)
2708
+ if status_files:
2709
+ return status_files
2710
+
2711
+ candidates: list[list[str]] = []
2712
+ base_ref = os.environ.get("GITHUB_BASE_REF")
2713
+ if base_ref:
2714
+ candidates.append(["diff", "--name-only", f"origin/{base_ref}...HEAD"])
2715
+ candidates.extend(
2716
+ [
2717
+ ["diff", "--name-only", "HEAD~1", "HEAD"],
2718
+ ["diff", "--name-only", "HEAD"],
2719
+ ]
2720
+ )
2721
+ for args in candidates:
2722
+ files = _git_changed_files(repo_root, args)
2723
+ if files:
2724
+ return files
2725
+ return []
2726
+
2727
+
2728
+ def _load_json_arg(path: str | None) -> dict[str, Any] | None:
2729
+ if not path:
2730
+ return None
2731
+ return json.loads(Path(path).read_text(encoding="utf-8"))
2732
+
2733
+
2734
+ def _load_performance_budgets_arg(items: list[str]) -> dict[str, float] | None:
2735
+ if not items:
2736
+ return None
2737
+ budgets: dict[str, float] = {}
2738
+ for item in items:
2739
+ if "=" not in item:
2740
+ raise RuntimeError(
2741
+ "Performance budget must use OPERATION=MS, " f"got {item!r}."
2742
+ )
2743
+ operation, raw_value = item.split("=", 1)
2744
+ operation = operation.strip()
2745
+ if not operation:
2746
+ raise RuntimeError("Performance budget operation name must not be empty.")
2747
+ try:
2748
+ budgets[operation] = float(raw_value)
2749
+ except ValueError as exc:
2750
+ raise RuntimeError(
2751
+ f"Performance budget for {operation!r} must be a number."
2752
+ ) from exc
2753
+ return budgets
2754
+
2755
+
2756
+ def _git_changed_files(repo_root: Path, args: list[str]) -> list[str]:
2757
+ completed = subprocess.run(
2758
+ ["git", "-C", str(repo_root), *args],
2759
+ check=False,
2760
+ capture_output=True,
2761
+ encoding="utf-8",
2762
+ errors="replace",
2763
+ )
2764
+ if completed.returncode != 0:
2765
+ return []
2766
+ return _python_files(completed.stdout.splitlines())
2767
+
2768
+
2769
+ def _git_status_files(repo_root: Path) -> list[str]:
2770
+ completed = subprocess.run(
2771
+ ["git", "-C", str(repo_root), "status", "--porcelain", "--untracked-files=all"],
2772
+ check=False,
2773
+ capture_output=True,
2774
+ encoding="utf-8",
2775
+ errors="replace",
2776
+ )
2777
+ if completed.returncode != 0:
2778
+ return []
2779
+ paths: list[str] = []
2780
+ for row in completed.stdout.splitlines():
2781
+ path = row[3:] if len(row) > 3 else ""
2782
+ if " -> " in path:
2783
+ path = path.rsplit(" -> ", 1)[-1]
2784
+ paths.append(path)
2785
+ return _python_files(paths)
2786
+
2787
+
2788
+ def _python_files(paths: list[str]) -> list[str]:
2789
+ return sorted(
2790
+ dict.fromkeys(
2791
+ path.replace("\\", "/")
2792
+ for path in paths
2793
+ if path.replace("\\", "/").endswith(".py")
2794
+ )
2795
+ )
2796
+
2797
+
2798
+ if __name__ == "__main__":
2799
+ raise SystemExit(main())