codegraph-ir 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 (63) hide show
  1. cgir/__init__.py +6 -0
  2. cgir/analyses/__init__.py +7 -0
  3. cgir/analyses/call_graph.py +114 -0
  4. cgir/analyses/cfg.py +320 -0
  5. cgir/analyses/effects.py +95 -0
  6. cgir/analyses/entrypoints.py +55 -0
  7. cgir/analyses/param_flow.py +75 -0
  8. cgir/analyses/pdg.py +75 -0
  9. cgir/analyses/purity.py +37 -0
  10. cgir/analyses/reaching_defs.py +115 -0
  11. cgir/analyses/symbols.py +106 -0
  12. cgir/api/__init__.py +1 -0
  13. cgir/api/mcp_server.py +172 -0
  14. cgir/api/server.py +107 -0
  15. cgir/cli.py +688 -0
  16. cgir/config.py +22 -0
  17. cgir/export/__init__.py +5 -0
  18. cgir/export/graphml.py +52 -0
  19. cgir/export/html_viz.py +1087 -0
  20. cgir/export/json_export.py +37 -0
  21. cgir/export/mermaid.py +57 -0
  22. cgir/export/neo4j.py +11 -0
  23. cgir/hooks.py +189 -0
  24. cgir/ir/__init__.py +16 -0
  25. cgir/ir/component_spec.py +100 -0
  26. cgir/ir/edges.py +31 -0
  27. cgir/ir/graph.py +132 -0
  28. cgir/ir/nodes.py +38 -0
  29. cgir/languages/__init__.py +25 -0
  30. cgir/languages/base.py +221 -0
  31. cgir/languages/cache.py +73 -0
  32. cgir/languages/python.py +1145 -0
  33. cgir/languages/registry.py +24 -0
  34. cgir/languages/typescript.py +853 -0
  35. cgir/manifest.py +77 -0
  36. cgir/pipeline.py +54 -0
  37. cgir/py.typed +0 -0
  38. cgir/regenerate/__init__.py +6 -0
  39. cgir/regenerate/prompt_pack.py +16 -0
  40. cgir/regenerate/regenerator.py +108 -0
  41. cgir/report/__init__.py +5 -0
  42. cgir/report/diff.py +226 -0
  43. cgir/report/flow.py +84 -0
  44. cgir/report/impact.py +234 -0
  45. cgir/report/lint.py +84 -0
  46. cgir/report/pack.py +271 -0
  47. cgir/report/stats.py +121 -0
  48. cgir/slicing/__init__.py +5 -0
  49. cgir/slicing/slicer.py +174 -0
  50. cgir/sources/__init__.py +6 -0
  51. cgir/sources/base.py +14 -0
  52. cgir/sources/codeql_source.py +13 -0
  53. cgir/sources/joern_source.py +13 -0
  54. cgir/sources/tree_sitter_source.py +270 -0
  55. cgir/trace/__init__.py +5 -0
  56. cgir/trace/trace_map.py +83 -0
  57. cgir/verify.py +173 -0
  58. cgir/watch.py +171 -0
  59. codegraph_ir-0.1.0.dist-info/METADATA +143 -0
  60. codegraph_ir-0.1.0.dist-info/RECORD +63 -0
  61. codegraph_ir-0.1.0.dist-info/WHEEL +4 -0
  62. codegraph_ir-0.1.0.dist-info/entry_points.txt +2 -0
  63. codegraph_ir-0.1.0.dist-info/licenses/LICENSE +21 -0
cgir/cli.py ADDED
@@ -0,0 +1,688 @@
1
+ """CLI entry point — matches the command shape in Code-IR.md §Analysis/workflow.
2
+
3
+ The scan pipeline itself lives in :mod:`cgir.pipeline`; this module (and the
4
+ HTTP API) are thin surfaces over it.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from collections import Counter
11
+ from pathlib import Path
12
+ from typing import Annotated, Any
13
+
14
+ import typer
15
+
16
+ from cgir.analyses import param_flow
17
+ from cgir.export import graphml as graphml_export
18
+ from cgir.export import html_viz
19
+ from cgir.export.json_export import read_specs
20
+ from cgir.export.mermaid import render_call_graph
21
+ from cgir.ir.component_spec import ComponentSpec
22
+ from cgir.ir.graph import RepoGraph
23
+ from cgir.ir.nodes import NodeKind
24
+ from cgir.manifest import compatibility_warning, read_manifest
25
+ from cgir.pipeline import scan_repo
26
+ from cgir.regenerate import regenerate as run_regenerate
27
+ from cgir.report.diff import compute_diff, render_diff, render_diff_markdown, violations
28
+ from cgir.report.flow import render_flow
29
+ from cgir.report.impact import (
30
+ compute_impact,
31
+ compute_typed_impact,
32
+ render_impact,
33
+ render_typed_impact,
34
+ )
35
+ from cgir.report.pack import build_pack, render_pack
36
+ from cgir.report.stats import compute_stats, render_text
37
+ from cgir.trace import TraceMap
38
+
39
+ app = typer.Typer(
40
+ add_completion=False,
41
+ help="CodeGraph IR - semantic IR for repo-scale LLM rewriting.",
42
+ )
43
+
44
+ hook_app = typer.Typer(help="Git pre-commit seatbelt: contract-check staged changes.")
45
+ app.add_typer(hook_app, name="hook")
46
+
47
+
48
+ @hook_app.command("run")
49
+ def hook_run(
50
+ fail_on: Annotated[
51
+ list[str] | None,
52
+ typer.Option("--fail-on", help="Drift rules that block the commit (repeatable)."),
53
+ ] = None,
54
+ ) -> None:
55
+ """Contract-check the staged tree against HEAD (invoked by the installed hook)."""
56
+ from cgir.hooks import render_hook, run_check
57
+
58
+ result = run_check(Path.cwd(), fail_on)
59
+ typer.echo(render_hook(result), nl=False)
60
+ if result.violations:
61
+ raise typer.Exit(code=1)
62
+
63
+
64
+ @hook_app.command("install")
65
+ def hook_install(
66
+ fail_on: Annotated[
67
+ list[str] | None,
68
+ typer.Option("--fail-on", help="Drift rules to bake into the hook (repeatable)."),
69
+ ] = None,
70
+ force: Annotated[
71
+ bool, typer.Option("--force", help="Overwrite an existing pre-commit hook.")
72
+ ] = False,
73
+ ) -> None:
74
+ """Install the pre-commit seatbelt into this repo's .git/hooks."""
75
+ from cgir.hooks import install
76
+
77
+ try:
78
+ path = install(Path.cwd(), fail_on, force)
79
+ except FileExistsError as exc:
80
+ raise typer.BadParameter(str(exc)) from exc
81
+ typer.echo(f"Installed contract seatbelt at {path}")
82
+
83
+
84
+ @hook_app.command("uninstall")
85
+ def hook_uninstall() -> None:
86
+ """Remove the pre-commit seatbelt (only if CGIR installed it)."""
87
+ from cgir.hooks import uninstall
88
+
89
+ typer.echo("Removed contract seatbelt." if uninstall(Path.cwd()) else "No CGIR hook to remove.")
90
+
91
+
92
+ def _version_callback(value: bool) -> None:
93
+ if value:
94
+ from cgir import __version__
95
+
96
+ typer.echo(f"cgir {__version__}")
97
+ raise typer.Exit()
98
+
99
+
100
+ @app.callback()
101
+ def _main(
102
+ version: Annotated[
103
+ bool,
104
+ typer.Option("--version", callback=_version_callback, is_eager=True, help="Show version."),
105
+ ] = False,
106
+ ) -> None:
107
+ """CodeGraph IR — semantic IR for repo-scale LLM rewriting."""
108
+
109
+
110
+ @app.command()
111
+ def scan(
112
+ repo: Annotated[Path, typer.Argument(exists=True, file_okay=False, dir_okay=True)],
113
+ out: Annotated[
114
+ Path | None, typer.Option("--out", help="Output directory (default <repo>/.cgir)")
115
+ ] = None,
116
+ exclude: Annotated[
117
+ list[str] | None,
118
+ typer.Option(
119
+ "--exclude",
120
+ help="Additional directory names to skip during ingest (repeatable).",
121
+ ),
122
+ ] = None,
123
+ ) -> None:
124
+ """Scan a repository and write the RepoGraph + ComponentSpec index."""
125
+ result = scan_repo(repo, out, exclude)
126
+ typer.echo(f"Wrote {len(result.specs)} components to {result.out_dir}")
127
+ _print_kind_histogram(result.specs)
128
+
129
+
130
+ def _print_kind_histogram(specs: list[ComponentSpec]) -> None:
131
+ if not specs:
132
+ return
133
+ counts = Counter(spec.kind.value for spec in specs)
134
+ # Stable display order: pure → orchestrator → state → effect → unknown.
135
+ order = ["pure_function", "orchestrator", "state_transformer", "effect_adapter", "unknown"]
136
+ for kind in order:
137
+ if counts.get(kind):
138
+ typer.echo(f" {kind}: {counts[kind]}")
139
+ for kind, n in counts.items():
140
+ if kind not in order:
141
+ typer.echo(f" {kind}: {n}")
142
+
143
+
144
+ @app.command()
145
+ def export(
146
+ fmt: Annotated[str, typer.Option("--format", help="One of: json | graphml | neo4j")] = "json",
147
+ out: Annotated[Path, typer.Option("--out")] = Path(".cgir"),
148
+ ) -> None:
149
+ """Re-export an existing index."""
150
+ if fmt == "json":
151
+ typer.echo(f"JSON outputs already at {out}; nothing to do.")
152
+ return
153
+ if fmt == "graphml":
154
+ path = graphml_export.write(out, _load_graph(out))
155
+ typer.echo(f"Wrote {path}")
156
+ return
157
+ if fmt == "neo4j":
158
+ raise NotImplementedError("milestone: P2-neo4j")
159
+ raise typer.BadParameter(f"Unknown format: {fmt}")
160
+
161
+
162
+ @app.command()
163
+ def viz(
164
+ index_dir: Annotated[Path, typer.Option("--index")] = Path(".cgir"),
165
+ fmt: Annotated[str, typer.Option("--format", help="One of: html | mermaid")] = "html",
166
+ ) -> None:
167
+ """Render the component graph — a self-contained HTML page or Mermaid text."""
168
+ specs = _load_specs(index_dir)
169
+ if fmt == "html":
170
+ path = html_viz.write(index_dir, specs, arg_flows=_arg_flows(index_dir))
171
+ typer.echo(f"Wrote {path} — open it in a browser.")
172
+ elif fmt == "mermaid":
173
+ typer.echo(render_call_graph(specs), nl=False)
174
+ else:
175
+ raise typer.BadParameter(f"Unknown format: {fmt}")
176
+
177
+
178
+ @app.command()
179
+ def stats(
180
+ index_dir: Annotated[Path, typer.Option("--index")] = Path(".cgir"),
181
+ as_json: Annotated[bool, typer.Option("--json", help="Emit machine-readable JSON.")] = False,
182
+ ) -> None:
183
+ """Summarize the scanned codebase: kinds, purity, effects, hotspots."""
184
+ result = compute_stats(_load_specs(index_dir))
185
+ if as_json:
186
+ typer.echo(json.dumps(result, indent=2, sort_keys=True))
187
+ else:
188
+ typer.echo(render_text(result), nl=False)
189
+
190
+
191
+ @app.command()
192
+ def watch(
193
+ repo: Annotated[Path, typer.Argument(exists=True, file_okay=False)] = Path("."),
194
+ index_dir: Annotated[Path, typer.Option("--index")] = Path(".cgir"),
195
+ interval: Annotated[float, typer.Option("--interval", help="Poll interval (s).")] = 0.5,
196
+ once: Annotated[bool, typer.Option("--once", help="Run a single tick and exit.")] = False,
197
+ ) -> None:
198
+ """Keep the index live: on each save, re-scan and print contract drift."""
199
+ from cgir.watch import run_watch
200
+
201
+ if not once:
202
+ typer.echo(f"watching {repo.resolve()} → {index_dir} (Ctrl-C to stop)")
203
+ try:
204
+ run_watch(repo, index_dir, interval=interval, once=once, emit=typer.echo)
205
+ except KeyboardInterrupt:
206
+ typer.echo("\nstopped.")
207
+
208
+
209
+ @app.command()
210
+ def flow(
211
+ component_id: Annotated[str, typer.Argument(metavar="ID")],
212
+ index_dir: Annotated[Path, typer.Option("--index")] = Path(".cgir"),
213
+ depth: Annotated[int, typer.Option("--depth", help="Max hops in each direction.")] = 3,
214
+ ) -> None:
215
+ """Trace a component: upstream callers, downstream callees, constructed types."""
216
+ specs = _load_specs(index_dir)
217
+ try:
218
+ typer.echo(render_flow(specs, component_id, depth), nl=False)
219
+ except KeyError as exc:
220
+ raise typer.BadParameter(f"Unknown component: {component_id}") from exc
221
+
222
+
223
+ @app.command()
224
+ def impact(
225
+ component_id: Annotated[str, typer.Argument(metavar="ID")],
226
+ index_dir: Annotated[Path, typer.Option("--index")] = Path(".cgir"),
227
+ as_json: Annotated[bool, typer.Option("--json", help="Emit machine-readable JSON.")] = False,
228
+ changed: Annotated[
229
+ str | None,
230
+ typer.Option(
231
+ "--changed",
232
+ help="Comma-separated contract fields that changed "
233
+ "(effects,purity,kind,signature,outputs) — narrows the radius.",
234
+ ),
235
+ ] = None,
236
+ candidate: Annotated[
237
+ Path | None,
238
+ typer.Option(
239
+ "--candidate",
240
+ exists=True,
241
+ help="A proposed new implementation; its contract delta (via verify) "
242
+ "narrows the radius. Requires --repo.",
243
+ ),
244
+ ] = None,
245
+ repo: Annotated[
246
+ Path | None, typer.Option("--repo", help="Repo root (with --candidate).")
247
+ ] = None,
248
+ ) -> None:
249
+ """Blast radius of changing a component: affected callers, entrypoints at risk, tests to run.
250
+
251
+ Worst-case by default; narrowed by --changed or by a --candidate's actual contract delta.
252
+ """
253
+ specs = _load_specs(index_dir)
254
+ try:
255
+ if candidate is not None:
256
+ if repo is None:
257
+ raise typer.BadParameter("--candidate requires --repo")
258
+ from cgir.verify import verify
259
+
260
+ result = verify(index_dir, component_id, candidate.read_text(), repo)
261
+ delta = list(result.drift.keys())
262
+ data = compute_typed_impact(specs, component_id, delta)
263
+ renderer = render_typed_impact(specs, component_id, delta)
264
+ elif changed is not None:
265
+ delta = [f.strip() for f in changed.split(",") if f.strip()]
266
+ data = compute_typed_impact(specs, component_id, delta)
267
+ renderer = render_typed_impact(specs, component_id, delta)
268
+ else:
269
+ data = compute_impact(specs, component_id)
270
+ renderer = render_impact(specs, component_id)
271
+ typer.echo(
272
+ json.dumps(data, indent=2, sort_keys=True) if as_json else renderer, nl=not as_json
273
+ )
274
+ except KeyError as exc:
275
+ raise typer.BadParameter(f"Unknown component: {component_id}") from exc
276
+
277
+
278
+ @app.command()
279
+ def pack(
280
+ component_id: Annotated[str, typer.Argument(metavar="ID")],
281
+ index_dir: Annotated[Path, typer.Option("--index")] = Path(".cgir"),
282
+ repo: Annotated[
283
+ Path | None,
284
+ typer.Option("--repo", help="Repo root, for embedding the target's source."),
285
+ ] = None,
286
+ budget: Annotated[int, typer.Option("--budget", help="Approximate token budget.")] = 4000,
287
+ ) -> None:
288
+ """Emit the minimal context bundle for working on one component."""
289
+ from cgir.report.pack import referenced_type_names
290
+
291
+ specs = _load_specs(index_dir)
292
+ target = next((s for s in specs if s.id == component_id), None)
293
+ if target is None:
294
+ raise typer.BadParameter(f"Unknown component: {component_id}")
295
+
296
+ graph = _load_graph(index_dir) if (index_dir / "repo_graph.json").exists() else None
297
+ source = _component_source(graph, component_id, repo) if repo else None
298
+ types = _type_sources(graph, referenced_type_names(target), repo) if repo else {}
299
+ tests = _test_sources(graph, target.covered_by, repo) if repo else {}
300
+ context = _module_context(graph, component_id, repo) if repo else {}
301
+ receivers = _call_receivers(graph, target)
302
+ bundle = build_pack(
303
+ specs,
304
+ component_id,
305
+ source=source,
306
+ budget=budget,
307
+ types=types,
308
+ tests=tests,
309
+ context=context,
310
+ receivers=receivers,
311
+ )
312
+ typer.echo(render_pack(bundle), nl=False)
313
+
314
+
315
+ _HELPER_MAX_LINES = 25
316
+
317
+
318
+ def _module_context(
319
+ graph: RepoGraph | None, component_id: str, repo: Path | None
320
+ ) -> dict[str, str]:
321
+ """Same-module constants and small helpers the target's body references."""
322
+ if graph is None or repo is None:
323
+ return {}
324
+ target = next(
325
+ (
326
+ n
327
+ for n in graph.nodes()
328
+ if n.kind in {NodeKind.Function, NodeKind.Method}
329
+ and n.attrs.get("qualname") == component_id
330
+ ),
331
+ None,
332
+ )
333
+ if target is None:
334
+ return {}
335
+ free = target.attrs.get("free_names")
336
+ if not isinstance(free, list) or not free:
337
+ return {}
338
+ module = component_id.rsplit(".", 1)[0]
339
+ wanted = {f"{module}.{name}" for name in free}
340
+ out: dict[str, str] = {}
341
+ for node in graph.nodes():
342
+ qual = node.attrs.get("qualname")
343
+ if not isinstance(qual, str) or qual not in wanted or qual == component_id:
344
+ continue
345
+ if node.kind == NodeKind.Variable:
346
+ src = _span_source(node, repo)
347
+ elif node.kind in {NodeKind.Function, NodeKind.Method}:
348
+ span = (node.end_line or 0) - (node.start_line or 0)
349
+ src = _span_source(node, repo) if span <= _HELPER_MAX_LINES else None
350
+ else:
351
+ continue
352
+ if src:
353
+ out[qual.rsplit(".", 1)[-1]] = src
354
+ return out
355
+
356
+
357
+ def _call_receivers(graph: RepoGraph | None, target: ComponentSpec) -> dict[str, str]:
358
+ """Map each DI callee to the field it is reached through.
359
+
360
+ The target's owning class records injected/declared fields as
361
+ ``{field: TypeName}``. A callee whose class matches one of those field
362
+ types is called via that field; surfacing it lets a rewriter reproduce
363
+ the call — and preserve the effect contract — instead of guessing the
364
+ field name. The receiver keyword is language-aware: ``self.<field>`` in
365
+ Python, ``this.<field>`` in TypeScript. Empty for classes without
366
+ fields, so the pack is unchanged there.
367
+ """
368
+ if graph is None:
369
+ return {}
370
+ self_kw = "self" if target.language == "python" else "this"
371
+ class_qual = target.id.rsplit(".", 1)[0]
372
+ cls = next(
373
+ (
374
+ n
375
+ for n in graph.nodes()
376
+ if n.kind == NodeKind.Class and n.attrs.get("qualname") == class_qual
377
+ ),
378
+ None,
379
+ )
380
+ fields = cls.attrs.get("fields") if cls is not None else None
381
+ if not isinstance(fields, dict) or not fields:
382
+ return {}
383
+ type_to_field: dict[str, str] = {}
384
+ for field, type_name in fields.items():
385
+ type_to_field.setdefault(type_name, field)
386
+ out: dict[str, str] = {}
387
+ for callee in target.calls:
388
+ callee_class = callee.rsplit(".", 1)[0].rsplit(".", 1)[-1]
389
+ field = type_to_field.get(callee_class)
390
+ if field:
391
+ out[callee] = f"{self_kw}.{field}"
392
+ return out
393
+
394
+
395
+ def _test_sources(
396
+ graph: RepoGraph | None, test_ids: list[str], repo: Path | None
397
+ ) -> dict[str, str]:
398
+ """Resolve the target's linked test components to their source."""
399
+ if graph is None or repo is None or not test_ids:
400
+ return {}
401
+ wanted = set(test_ids)
402
+ out: dict[str, str] = {}
403
+ for node in graph.nodes():
404
+ if node.kind not in {NodeKind.Function, NodeKind.Method}:
405
+ continue
406
+ if node.attrs.get("qualname") not in wanted:
407
+ continue
408
+ src = _span_source(node, repo)
409
+ if src:
410
+ out[str(node.attrs.get("qualname"))] = src
411
+ return out
412
+
413
+
414
+ def _component_source(graph: RepoGraph | None, component_id: str, repo: Path | None) -> str | None:
415
+ """The target's source lines, via the graph's span (best-effort)."""
416
+ if graph is None or repo is None:
417
+ return None
418
+ for node in graph.nodes():
419
+ if node.kind not in {NodeKind.Function, NodeKind.Method}:
420
+ continue
421
+ if node.attrs.get("qualname") != component_id:
422
+ continue
423
+ return _span_source(node, repo)
424
+ return None
425
+
426
+
427
+ def _type_sources(
428
+ graph: RepoGraph | None, type_names: set[str], repo: Path | None
429
+ ) -> dict[str, str]:
430
+ """Resolve referenced type names to their in-repo Class definitions."""
431
+ if graph is None or repo is None or not type_names:
432
+ return {}
433
+ out: dict[str, str] = {}
434
+ for node in graph.nodes():
435
+ if node.kind not in {NodeKind.Class, NodeKind.Variable}:
436
+ continue
437
+ if node.name not in type_names or node.name in out:
438
+ continue # first match wins; ambiguous names take one
439
+ src = _span_source(node, repo)
440
+ if src:
441
+ out[node.name] = src
442
+ return out
443
+
444
+
445
+ def _span_source(node: Any, repo: Path) -> str | None:
446
+ if node.path is None or node.start_line is None or node.end_line is None:
447
+ return None
448
+ try:
449
+ all_lines = (repo / node.path).read_text().splitlines()
450
+ except OSError:
451
+ return None
452
+ return "\n".join(all_lines[node.start_line - 1 : node.end_line]) + "\n"
453
+
454
+
455
+ @app.command()
456
+ def lint(
457
+ index_dir: Annotated[Path, typer.Option("--index")] = Path(".cgir"),
458
+ config: Annotated[Path, typer.Option("--config", help="Rules file.")] = Path("cgir.toml"),
459
+ as_json: Annotated[bool, typer.Option("--json")] = False,
460
+ ) -> None:
461
+ """Check the index against semantic architecture rules (effects, kind, calls)."""
462
+ from cgir.report.lint import lint as run_lint
463
+ from cgir.report.lint import load_rules, render_lint
464
+
465
+ if not config.exists():
466
+ raise typer.BadParameter(f"No rules file at {config}")
467
+ violations = run_lint(_load_specs(index_dir), load_rules(config))
468
+ if as_json:
469
+ typer.echo(
470
+ json.dumps(
471
+ [
472
+ {"rule": v.rule, "component": v.component, "detail": v.detail}
473
+ for v in violations
474
+ ],
475
+ indent=2,
476
+ )
477
+ )
478
+ else:
479
+ typer.echo(render_lint(violations), nl=False)
480
+ if violations:
481
+ raise typer.Exit(code=1)
482
+
483
+
484
+ @app.command()
485
+ def verify(
486
+ component_id: Annotated[str, typer.Argument(metavar="ID")],
487
+ candidate: Annotated[
488
+ Path, typer.Option("--candidate", help="File with the new implementation.")
489
+ ],
490
+ index_dir: Annotated[Path, typer.Option("--index")] = Path(".cgir"),
491
+ repo: Annotated[Path, typer.Option("--repo", help="Repo root.")] = Path("."),
492
+ fail_on: Annotated[
493
+ list[str] | None,
494
+ typer.Option("--fail-on", help="Drift rules that fail the check (repeatable)."),
495
+ ] = None,
496
+ run_tests: Annotated[
497
+ bool, typer.Option("--tests", help="Also run the component's linked tests.")
498
+ ] = False,
499
+ as_json: Annotated[bool, typer.Option("--json")] = False,
500
+ ) -> None:
501
+ """Contract-check an LLM-written candidate against the indexed component."""
502
+ from cgir.verify import verify as run_verify
503
+
504
+ try:
505
+ result = run_verify(
506
+ index_dir,
507
+ component_id,
508
+ candidate.read_text(),
509
+ repo,
510
+ fail_on=list(fail_on or []),
511
+ run_tests=run_tests,
512
+ )
513
+ except KeyError as exc:
514
+ raise typer.BadParameter(str(exc)) from exc
515
+
516
+ if as_json:
517
+ typer.echo(json.dumps(result.to_dict(), indent=2, sort_keys=True))
518
+ else:
519
+ typer.echo(f"contract: {'ok' if result.contract_ok else 'CHANGED'}")
520
+ for name, values in result.drift.items():
521
+ typer.echo(f" {name}: {values['old']} -> {values['new']}")
522
+ if result.violations:
523
+ typer.echo("violations:")
524
+ for line in result.violations:
525
+ typer.echo(f" ! {line}")
526
+ if result.tests_ok is not None:
527
+ typer.echo(
528
+ f"tests ({len(result.tests_ran)} file(s)): {'pass' if result.tests_ok else 'FAIL'}"
529
+ )
530
+ if result.violations or result.tests_ok is False:
531
+ raise typer.Exit(code=1)
532
+
533
+
534
+ @app.command()
535
+ def mcp(
536
+ index_dir: Annotated[Path, typer.Option("--index")] = Path(".cgir"),
537
+ ) -> None:
538
+ """Serve the index to agents over MCP (stdio; requires cgir[mcp])."""
539
+ from cgir.api.mcp_server import create_server
540
+
541
+ try:
542
+ server = create_server(index_dir)
543
+ except RuntimeError as exc:
544
+ raise typer.BadParameter(str(exc)) from exc
545
+ server.run()
546
+
547
+
548
+ @app.command()
549
+ def diff(
550
+ old_index: Annotated[Path, typer.Argument(exists=True, file_okay=False)],
551
+ new_index: Annotated[Path, typer.Argument(exists=True, file_okay=False)],
552
+ as_json: Annotated[bool, typer.Option("--json", help="Emit machine-readable JSON.")] = False,
553
+ markdown: Annotated[
554
+ bool, typer.Option("--markdown", help="Emit a PR-comment-ready markdown report.")
555
+ ] = False,
556
+ fail_on: Annotated[
557
+ list[str] | None,
558
+ typer.Option(
559
+ "--fail-on",
560
+ help="Exit 1 on drift: effect-gain[:tag] | effect-loss[:tag] | purity-drop | "
561
+ "kind-change | entrypoint-added | entrypoint-change (repeatable).",
562
+ ),
563
+ ] = None,
564
+ ) -> None:
565
+ """Compare two scan indexes: added/removed components and contract drift."""
566
+ warning = compatibility_warning(read_manifest(old_index), read_manifest(new_index))
567
+ result = compute_diff(_load_specs(old_index), _load_specs(new_index))
568
+ found = violations(result, list(fail_on or []))
569
+ if as_json:
570
+ payload = dict(result)
571
+ if warning:
572
+ payload["warning"] = warning
573
+ payload["violations"] = found
574
+ typer.echo(json.dumps(payload, indent=2, sort_keys=True))
575
+ elif markdown:
576
+ typer.echo(render_diff_markdown(result, violations=found, warning=warning), nl=False)
577
+ else:
578
+ if warning:
579
+ typer.echo(warning)
580
+ typer.echo(render_diff(result), nl=False)
581
+ if found:
582
+ typer.echo("")
583
+ typer.echo(f"drift violations ({len(found)}):")
584
+ for line in found:
585
+ typer.echo(f" ! {line}")
586
+ if found:
587
+ raise typer.Exit(code=1)
588
+
589
+
590
+ def _load_graph(index_dir: Path) -> RepoGraph:
591
+ graph_path = index_dir / "repo_graph.json"
592
+ if not graph_path.exists():
593
+ raise typer.BadParameter(f"No graph at {graph_path}; run `cgir scan` first")
594
+ return RepoGraph.from_jsonable(json.loads(graph_path.read_text()))
595
+
596
+
597
+ def _arg_flows(index_dir: Path) -> dict[str, list[dict[str, object]]] | None:
598
+ """PDG-derived param→callee flows, re-keyed by spec id (qualname)."""
599
+ graph_path = index_dir / "repo_graph.json"
600
+ if not graph_path.exists():
601
+ return None
602
+ graph = RepoGraph.from_jsonable(json.loads(graph_path.read_text()))
603
+ flows = param_flow.compute(graph)
604
+
605
+ def qual(node_id: str) -> str:
606
+ node = graph.get_node(node_id)
607
+ q = node.attrs.get("qualname") if node.attrs else None
608
+ return str(q) if isinstance(q, str) else node.name
609
+
610
+ return {
611
+ qual(caller): [
612
+ {"callee": qual(str(entry["callee"])), "params": entry["params"]} for entry in entries
613
+ ]
614
+ for caller, entries in flows.items()
615
+ }
616
+
617
+
618
+ def _load_specs(index_dir: Path) -> list[ComponentSpec]:
619
+ if not (index_dir / "components").is_dir():
620
+ raise typer.BadParameter(f"No components at {index_dir}; run `cgir scan` first")
621
+ return read_specs(index_dir)
622
+
623
+
624
+ @app.command()
625
+ def component(
626
+ component_id: Annotated[str, typer.Argument()],
627
+ index_dir: Annotated[Path, typer.Option("--index")] = Path(".cgir"),
628
+ ) -> None:
629
+ """Pretty-print a ComponentSpec."""
630
+ spec_path = index_dir / "components" / f"{component_id}.json"
631
+ if not spec_path.exists():
632
+ raise typer.BadParameter(f"No spec at {spec_path}")
633
+ typer.echo(spec_path.read_text())
634
+
635
+
636
+ @app.command()
637
+ def trace(
638
+ location: Annotated[str, typer.Argument(help="path:line, e.g. pricing.py:1")],
639
+ index_dir: Annotated[Path, typer.Option("--index")] = Path(".cgir"),
640
+ ) -> None:
641
+ """Look up which ComponentSpec owns a given source location."""
642
+ if ":" not in location:
643
+ raise typer.BadParameter("location must be <path>:<line>")
644
+ path, line_str = location.rsplit(":", 1)
645
+ line = int(line_str)
646
+ trace_path = index_dir / "trace_map.json"
647
+ if not trace_path.exists():
648
+ raise typer.BadParameter(f"No trace map at {trace_path}; run `cgir scan` first")
649
+ trace_map = TraceMap.read(trace_path)
650
+ hit = trace_map.lookup(path, line)
651
+ if hit is None:
652
+ typer.echo("(no component covers that location)")
653
+ raise typer.Exit(code=1)
654
+ typer.echo(hit)
655
+
656
+
657
+ @app.command(name="regenerate")
658
+ def regenerate_cmd(
659
+ component_id: Annotated[str, typer.Argument(metavar="ID")],
660
+ lang: Annotated[str, typer.Option("--lang")] = "typescript",
661
+ index_dir: Annotated[Path, typer.Option("--index")] = Path(".cgir"),
662
+ live: Annotated[
663
+ bool,
664
+ typer.Option("--live", help="Call the Anthropic API (requires cgir[llm] + API key)."),
665
+ ] = False,
666
+ ) -> None:
667
+ """Print the prompt-pack for a component; --live generates real code."""
668
+ spec_path = index_dir / "components" / f"{component_id}.json"
669
+ if not spec_path.exists():
670
+ raise typer.BadParameter(f"No spec at {spec_path}")
671
+ spec = ComponentSpec.from_dict(json.loads(spec_path.read_text()))
672
+ generator = None
673
+ if live:
674
+ from cgir.regenerate.regenerator import anthropic_generator
675
+
676
+ try:
677
+ generator = anthropic_generator()
678
+ except RuntimeError as exc:
679
+ raise typer.BadParameter(str(exc)) from exc
680
+ result = run_regenerate(spec, lang, generator=generator)
681
+ typer.echo("--- PROMPT ---")
682
+ typer.echo(result.prompt)
683
+ typer.echo("--- OUTPUT (live) ---" if result.live else "--- OUTPUT (dry run) ---")
684
+ typer.echo(result.code)
685
+
686
+
687
+ if __name__ == "__main__": # pragma: no cover
688
+ app()