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.
- cgir/__init__.py +6 -0
- cgir/analyses/__init__.py +7 -0
- cgir/analyses/call_graph.py +114 -0
- cgir/analyses/cfg.py +320 -0
- cgir/analyses/effects.py +95 -0
- cgir/analyses/entrypoints.py +55 -0
- cgir/analyses/param_flow.py +75 -0
- cgir/analyses/pdg.py +75 -0
- cgir/analyses/purity.py +37 -0
- cgir/analyses/reaching_defs.py +115 -0
- cgir/analyses/symbols.py +106 -0
- cgir/api/__init__.py +1 -0
- cgir/api/mcp_server.py +172 -0
- cgir/api/server.py +107 -0
- cgir/cli.py +688 -0
- cgir/config.py +22 -0
- cgir/export/__init__.py +5 -0
- cgir/export/graphml.py +52 -0
- cgir/export/html_viz.py +1087 -0
- cgir/export/json_export.py +37 -0
- cgir/export/mermaid.py +57 -0
- cgir/export/neo4j.py +11 -0
- cgir/hooks.py +189 -0
- cgir/ir/__init__.py +16 -0
- cgir/ir/component_spec.py +100 -0
- cgir/ir/edges.py +31 -0
- cgir/ir/graph.py +132 -0
- cgir/ir/nodes.py +38 -0
- cgir/languages/__init__.py +25 -0
- cgir/languages/base.py +221 -0
- cgir/languages/cache.py +73 -0
- cgir/languages/python.py +1145 -0
- cgir/languages/registry.py +24 -0
- cgir/languages/typescript.py +853 -0
- cgir/manifest.py +77 -0
- cgir/pipeline.py +54 -0
- cgir/py.typed +0 -0
- cgir/regenerate/__init__.py +6 -0
- cgir/regenerate/prompt_pack.py +16 -0
- cgir/regenerate/regenerator.py +108 -0
- cgir/report/__init__.py +5 -0
- cgir/report/diff.py +226 -0
- cgir/report/flow.py +84 -0
- cgir/report/impact.py +234 -0
- cgir/report/lint.py +84 -0
- cgir/report/pack.py +271 -0
- cgir/report/stats.py +121 -0
- cgir/slicing/__init__.py +5 -0
- cgir/slicing/slicer.py +174 -0
- cgir/sources/__init__.py +6 -0
- cgir/sources/base.py +14 -0
- cgir/sources/codeql_source.py +13 -0
- cgir/sources/joern_source.py +13 -0
- cgir/sources/tree_sitter_source.py +270 -0
- cgir/trace/__init__.py +5 -0
- cgir/trace/trace_map.py +83 -0
- cgir/verify.py +173 -0
- cgir/watch.py +171 -0
- codegraph_ir-0.1.0.dist-info/METADATA +143 -0
- codegraph_ir-0.1.0.dist-info/RECORD +63 -0
- codegraph_ir-0.1.0.dist-info/WHEEL +4 -0
- codegraph_ir-0.1.0.dist-info/entry_points.txt +2 -0
- 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()
|