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/manifest.py ADDED
@@ -0,0 +1,77 @@
1
+ """Index manifest โ€” provenance + compatibility for a written index.
2
+
3
+ Every index carries a ``manifest.json`` recording which ``cgir`` version
4
+ and ComponentSpec schema version produced it. ``cgir diff`` (and any tool
5
+ comparing two indexes, e.g. the CI Action) reads both manifests so a
6
+ base/head comparison produced by *different* cgir versions is a warning,
7
+ not a silent wrong answer.
8
+
9
+ ``SCHEMA_VERSION`` bumps whenever the ComponentSpec contract changes shape
10
+ (a field added/removed/repurposed). It is independent of ``cgir.__version__``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from dataclasses import asdict, dataclass
17
+ from datetime import UTC, datetime
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from cgir import __version__
22
+
23
+ SCHEMA_VERSION = "1.0"
24
+ MANIFEST_NAME = "manifest.json"
25
+
26
+
27
+ @dataclass(slots=True)
28
+ class Manifest:
29
+ cgir_version: str
30
+ schema_version: str
31
+ component_count: int
32
+ created_at: str
33
+
34
+ @classmethod
35
+ def create(cls, component_count: int) -> Manifest:
36
+ return cls(
37
+ cgir_version=__version__,
38
+ schema_version=SCHEMA_VERSION,
39
+ component_count=component_count,
40
+ created_at=datetime.now(UTC).isoformat(),
41
+ )
42
+
43
+ def to_dict(self) -> dict[str, Any]:
44
+ return asdict(self)
45
+
46
+
47
+ def write_manifest(index_dir: Path, component_count: int) -> Manifest:
48
+ index_dir.mkdir(parents=True, exist_ok=True)
49
+ manifest = Manifest.create(component_count)
50
+ (index_dir / MANIFEST_NAME).write_text(json.dumps(manifest.to_dict(), indent=2, sort_keys=True))
51
+ return manifest
52
+
53
+
54
+ def read_manifest(index_dir: Path) -> Manifest | None:
55
+ """Load an index's manifest, or None for a pre-manifest (old) index."""
56
+ path = index_dir / MANIFEST_NAME
57
+ if not path.exists():
58
+ return None
59
+ data = json.loads(path.read_text())
60
+ return Manifest(
61
+ cgir_version=str(data.get("cgir_version", "unknown")),
62
+ schema_version=str(data.get("schema_version", "unknown")),
63
+ component_count=int(data.get("component_count", 0)),
64
+ created_at=str(data.get("created_at", "")),
65
+ )
66
+
67
+
68
+ def compatibility_warning(old: Manifest | None, new: Manifest | None) -> str | None:
69
+ """A human-readable warning if two indexes may not be safely comparable."""
70
+ old_schema = old.schema_version if old else "unknown"
71
+ new_schema = new.schema_version if new else "unknown"
72
+ if old_schema != new_schema:
73
+ return (
74
+ f"warning: schema version mismatch ({old_schema} vs {new_schema}); "
75
+ "diff may be unreliable โ€” rescan both with the same cgir version"
76
+ )
77
+ return None
cgir/pipeline.py ADDED
@@ -0,0 +1,54 @@
1
+ """The scan pipeline โ€” single driver shared by the CLI and the HTTP API.
2
+
3
+ Pipeline order is fixed by the spec:
4
+ ``ingest โ†’ symbols โ†’ call_graph โ†’ cfg โ†’ pdg โ†’ effects โ†’ purity โ†’ slice โ†’
5
+ export``. New analyses wire in here (see ``CLAUDE.md`` working
6
+ conventions); the CLI and API are thin surfaces over :func:`scan_repo`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Iterable
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ from cgir.analyses import effects as effects_pass
16
+ from cgir.analyses import purity as purity_pass
17
+ from cgir.analyses.call_graph import build_call_graph
18
+ from cgir.analyses.cfg import build as build_cfg
19
+ from cgir.analyses.pdg import build as build_pdg
20
+ from cgir.analyses.symbols import build_symbol_tables
21
+ from cgir.config import CGIRConfig
22
+ from cgir.export import json_export
23
+ from cgir.ir.component_spec import ComponentSpec
24
+ from cgir.slicing import slice_components
25
+ from cgir.sources import TreeSitterSource
26
+ from cgir.trace import build_trace_map
27
+
28
+
29
+ @dataclass(slots=True)
30
+ class ScanResult:
31
+ out_dir: Path
32
+ specs: list[ComponentSpec]
33
+
34
+
35
+ def scan_repo(
36
+ repo: Path,
37
+ out: Path | None = None,
38
+ exclude: Iterable[str] | None = None,
39
+ ) -> ScanResult:
40
+ """Run the full pipeline over ``repo`` and write the index to disk."""
41
+ config = CGIRConfig.for_scan(repo, out)
42
+ source = TreeSitterSource(ignore_dirs=set(exclude or ()))
43
+ graph = source.ingest(config.repo_path)
44
+ tables = build_symbol_tables(graph)
45
+ build_call_graph(graph, tables, config.repo_path)
46
+ build_cfg(graph, config.repo_path)
47
+ build_pdg(graph)
48
+ effects = effects_pass.classify(graph, config.repo_path)
49
+ purity_scores = purity_pass.score(graph, effects)
50
+ specs = slice_components(graph, effects=effects, purity_scores=purity_scores)
51
+ trace_map = build_trace_map(graph)
52
+ json_export.write_index(config.out_dir, graph, specs)
53
+ trace_map.write(config.out_dir / "trace_map.json")
54
+ return ScanResult(out_dir=config.out_dir, specs=specs)
cgir/py.typed ADDED
File without changes
@@ -0,0 +1,6 @@
1
+ """Prompt-pack + regeneration. The LLM call is an injectable generator seam."""
2
+
3
+ from cgir.regenerate.prompt_pack import build_prompt
4
+ from cgir.regenerate.regenerator import regenerate
5
+
6
+ __all__ = ["build_prompt", "regenerate"]
@@ -0,0 +1,16 @@
1
+ """Render the prompt-pack template from Code-IR.md ยงAnalysis/workflow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from cgir.ir.component_spec import ComponentSpec
6
+
7
+ PROMPT_TEMPLATE = (
8
+ "Given ComponentSpec + dependent interfaces + tests, recreate this component in "
9
+ "{target_language}. Preserve contracts, effects, and trace IDs. Do not invent "
10
+ "hidden dependencies.\n\n"
11
+ "ComponentSpec:\n{spec_json}\n"
12
+ )
13
+
14
+
15
+ def build_prompt(spec: ComponentSpec, target_language: str) -> str:
16
+ return PROMPT_TEMPLATE.format(target_language=target_language, spec_json=spec.to_json())
@@ -0,0 +1,108 @@
1
+ """LLM-driven regeneration.
2
+
3
+ The generation seam is an injectable ``Callable[[str], str]`` so the
4
+ pipeline stays testable offline โ€” only :func:`anthropic_generator` touches
5
+ the network, and only when explicitly requested (``cgir regenerate
6
+ --live``). Per the spec, the LLM sees the prompt-pack built from the
7
+ ``ComponentSpec``, never raw source.
8
+
9
+ Round-tripping generated code through compile/test verification before
10
+ tagging ``REGENERATED_AS`` edges is future work โ€” the result carries
11
+ ``live`` so callers can tell a real generation from a dry run.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ from collections.abc import Callable
18
+ from dataclasses import dataclass
19
+
20
+ from cgir.ir.component_spec import ComponentSpec
21
+ from cgir.regenerate.prompt_pack import build_prompt
22
+
23
+ DEFAULT_MODEL = "claude-sonnet-4-6"
24
+
25
+ _SYSTEM_PROMPT = (
26
+ "You are a code regeneration engine. You receive a ComponentSpec (a "
27
+ "language-agnostic contract for one component: inputs, effects, calls, "
28
+ "purity, trace IDs) and produce an implementation in the requested "
29
+ "target language. Respond with code only โ€” no prose, no fences."
30
+ )
31
+
32
+ _DRY_RUN_NOTE = (
33
+ "// dry run: no generator supplied.\n"
34
+ "// Pass --live (requires `pip install cgir[llm]` and ANTHROPIC_API_KEY)\n"
35
+ "// or inject a generator callable to produce code.\n"
36
+ )
37
+
38
+
39
+ @dataclass(slots=True)
40
+ class RegenerationResult:
41
+ spec_id: str
42
+ target_language: str
43
+ prompt: str
44
+ code: str
45
+ live: bool = False
46
+
47
+
48
+ def regenerate(
49
+ spec: ComponentSpec,
50
+ target_language: str,
51
+ generator: Callable[[str], str] | None = None,
52
+ ) -> RegenerationResult:
53
+ """Build the prompt-pack for ``spec`` and (optionally) generate code.
54
+
55
+ Without a ``generator`` this is a dry run: the prompt is returned for
56
+ inspection and ``code`` explains how to go live.
57
+ """
58
+ prompt = build_prompt(spec, target_language)
59
+ if generator is None:
60
+ return RegenerationResult(
61
+ spec_id=spec.id,
62
+ target_language=target_language,
63
+ prompt=prompt,
64
+ code=_DRY_RUN_NOTE,
65
+ live=False,
66
+ )
67
+ return RegenerationResult(
68
+ spec_id=spec.id,
69
+ target_language=target_language,
70
+ prompt=prompt,
71
+ code=generator(prompt),
72
+ live=True,
73
+ )
74
+
75
+
76
+ def anthropic_generator(model: str | None = None) -> Callable[[str], str]:
77
+ """A generator backed by the Anthropic SDK (``pip install cgir[llm]``).
78
+
79
+ The system prompt is marked for prompt caching from day one (see
80
+ ``docs/roadmap.md``). Model defaults to :data:`DEFAULT_MODEL`; override
81
+ with the ``CGIR_MODEL`` environment variable or the ``model`` argument.
82
+ """
83
+ try:
84
+ import anthropic
85
+ except ImportError as exc:
86
+ raise RuntimeError(
87
+ "Install cgir[llm] to use live regeneration (adds the anthropic package)"
88
+ ) from exc
89
+
90
+ client = anthropic.Anthropic()
91
+ resolved_model = model or os.environ.get("CGIR_MODEL", DEFAULT_MODEL)
92
+
93
+ def generate(prompt: str) -> str:
94
+ message = client.messages.create(
95
+ model=resolved_model,
96
+ max_tokens=4096,
97
+ system=[
98
+ {
99
+ "type": "text",
100
+ "text": _SYSTEM_PROMPT,
101
+ "cache_control": {"type": "ephemeral"},
102
+ }
103
+ ],
104
+ messages=[{"role": "user", "content": prompt}],
105
+ )
106
+ return "".join(block.text for block in message.content if block.type == "text")
107
+
108
+ return generate
@@ -0,0 +1,5 @@
1
+ """Human-facing reports derived from ComponentSpecs."""
2
+
3
+ from cgir.report.stats import compute_stats
4
+
5
+ __all__ = ["compute_stats"]
cgir/report/diff.py ADDED
@@ -0,0 +1,226 @@
1
+ """Index diff โ€” architecture drift between two scans (Sprint 16).
2
+
3
+ ``compute_diff`` is pure over two spec lists (same pattern as
4
+ :mod:`cgir.report.stats`): which components appeared, disappeared, or
5
+ changed on the *contract* fields โ€” kind, purity, effects, signature,
6
+ outputs. ``violations`` evaluates CI fail rules against a diff, so
7
+ "this PR made a pure function effectful" โ€” or silently dropped a network
8
+ call โ€” can fail a build:
9
+
10
+ cgir diff old-index new-index --fail-on effect-gain:net --fail-on effect-loss:net
11
+
12
+ Rules only fire for components present in *both* scans โ€” new effectful
13
+ code is a choice, drift in existing code is a regression.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Any
19
+
20
+ from cgir.ir.component_spec import ComponentSpec
21
+
22
+ CONTRACT_FIELDS = ("kind", "purity", "effects", "signature", "outputs")
23
+
24
+
25
+ def compute_diff(old_specs: list[ComponentSpec], new_specs: list[ComponentSpec]) -> dict[str, Any]:
26
+ old = {s.id: s for s in old_specs}
27
+ new = {s.id: s for s in new_specs}
28
+
29
+ changed: list[dict[str, Any]] = []
30
+ for spec_id in sorted(old.keys() & new.keys()):
31
+ fields = _field_changes(old[spec_id], new[spec_id])
32
+ if fields:
33
+ changed.append({"id": spec_id, "fields": fields})
34
+
35
+ return {
36
+ "added": sorted(new.keys() - old.keys()),
37
+ "removed": sorted(old.keys() - new.keys()),
38
+ "changed": changed,
39
+ "entrypoints": _entrypoint_surface(old, new),
40
+ }
41
+
42
+
43
+ def _entrypoint_surface(
44
+ old: dict[str, ComponentSpec], new: dict[str, ComponentSpec]
45
+ ) -> dict[str, list[dict[str, Any]]]:
46
+ """The externally-reachable surface that appeared, vanished, or moved."""
47
+ added = [
48
+ {"id": sid, "entrypoint": new[sid].entrypoint}
49
+ for sid in sorted(new.keys() - old.keys())
50
+ if new[sid].entrypoint
51
+ ]
52
+ removed = [
53
+ {"id": sid, "entrypoint": old[sid].entrypoint}
54
+ for sid in sorted(old.keys() - new.keys())
55
+ if old[sid].entrypoint
56
+ ]
57
+ changed = [
58
+ {"id": sid, "old": old[sid].entrypoint, "new": new[sid].entrypoint}
59
+ for sid in sorted(old.keys() & new.keys())
60
+ if old[sid].entrypoint != new[sid].entrypoint
61
+ ]
62
+ return {"added": added, "removed": removed, "changed": changed}
63
+
64
+
65
+ def _field_changes(old: ComponentSpec, new: ComponentSpec) -> dict[str, dict[str, Any]]:
66
+ fields: dict[str, dict[str, Any]] = {}
67
+ for name in CONTRACT_FIELDS:
68
+ old_value = getattr(old, name)
69
+ new_value = getattr(new, name)
70
+ if name == "kind":
71
+ old_value, new_value = old_value.value, new_value.value
72
+ if old_value != new_value:
73
+ fields[name] = {"old": old_value, "new": new_value}
74
+ return fields
75
+
76
+
77
+ def violations(diff: dict[str, Any], rules: list[str]) -> list[str]:
78
+ """Evaluate fail rules against a diff; each hit is a human-readable line."""
79
+ found: list[str] = []
80
+ for change in diff["changed"]:
81
+ fields = change["fields"]
82
+ spec_id = change["id"]
83
+ for rule in rules:
84
+ if rule.startswith("effect-gain"):
85
+ _, _, wanted = rule.partition(":")
86
+ gained = _gained_effects(fields)
87
+ if wanted:
88
+ gained = [t for t in gained if t == wanted]
89
+ if gained:
90
+ found.append(f"{spec_id}: gained effect(s) {', '.join(gained)}")
91
+ elif rule.startswith("effect-loss"):
92
+ _, _, wanted = rule.partition(":")
93
+ lost = _lost_effects(fields)
94
+ if wanted:
95
+ lost = [t for t in lost if t == wanted]
96
+ # Indirection, not removal: if the component *simultaneously*
97
+ # gained calls_effectful, the effect most likely moved behind a
98
+ # call and is still transitively reachable (the one false-alarm
99
+ # class in the real-history noise replay โ€” see gate-noise.md).
100
+ # Trade-off: a true removal paired with a new unrelated
101
+ # effectful call is masked; the loss stays visible in the diff
102
+ # report, it just doesn't fail the build.
103
+ if lost and "calls_effectful" in _gained_effects(fields):
104
+ lost = []
105
+ if lost:
106
+ found.append(f"{spec_id}: lost effect(s) {', '.join(lost)}")
107
+ elif rule == "purity-drop":
108
+ purity = fields.get("purity")
109
+ if purity and _as_float(purity["new"]) < _as_float(purity["old"]):
110
+ found.append(f"{spec_id}: purity dropped {purity['old']} -> {purity['new']}")
111
+ elif rule == "kind-change":
112
+ kind = fields.get("kind")
113
+ if kind:
114
+ found.append(f"{spec_id}: kind changed {kind['old']} -> {kind['new']}")
115
+ surface = diff.get("entrypoints", {})
116
+ for rule in rules:
117
+ if rule == "entrypoint-added":
118
+ for e in surface.get("added", []):
119
+ found.append(f"{e['id']}: new entrypoint {e['entrypoint']}")
120
+ elif rule == "entrypoint-change":
121
+ for e in surface.get("changed", []):
122
+ found.append(f"{e['id']}: entrypoint changed {e['old']} -> {e['new']}")
123
+ return found
124
+
125
+
126
+ def _gained_effects(fields: dict[str, dict[str, Any]]) -> list[str]:
127
+ effects = fields.get("effects")
128
+ if not effects:
129
+ return []
130
+ return sorted(set(effects["new"]) - set(effects["old"]))
131
+
132
+
133
+ def _lost_effects(fields: dict[str, dict[str, Any]]) -> list[str]:
134
+ effects = fields.get("effects")
135
+ if not effects:
136
+ return []
137
+ return sorted(set(effects["old"]) - set(effects["new"]))
138
+
139
+
140
+ def _as_float(value: Any) -> float:
141
+ return float(value) if value is not None else 0.0
142
+
143
+
144
+ def render_diff(diff: dict[str, Any]) -> str:
145
+ surface = diff.get("entrypoints", {"added": [], "removed": [], "changed": []})
146
+ has_surface = surface["added"] or surface["removed"] or surface["changed"]
147
+ if not (diff["added"] or diff["removed"] or diff["changed"] or has_surface):
148
+ return "no changes\n"
149
+ lines: list[str] = []
150
+ if has_surface:
151
+ lines.append("entrypoint surface:")
152
+ lines.extend(f" + {e['entrypoint']} ({e['id']})" for e in surface["added"])
153
+ lines.extend(f" - {e['entrypoint']} ({e['id']})" for e in surface["removed"])
154
+ lines.extend(f" ~ {e['old']} -> {e['new']} ({e['id']})" for e in surface["changed"])
155
+ if diff["added"]:
156
+ lines.append(f"added ({len(diff['added'])}):")
157
+ lines.extend(f" + {spec_id}" for spec_id in diff["added"])
158
+ if diff["removed"]:
159
+ lines.append(f"removed ({len(diff['removed'])}):")
160
+ lines.extend(f" - {spec_id}" for spec_id in diff["removed"])
161
+ if diff["changed"]:
162
+ lines.append(f"changed ({len(diff['changed'])}):")
163
+ for change in diff["changed"]:
164
+ lines.append(f" ~ {change['id']}")
165
+ for name, values in change["fields"].items():
166
+ lines.append(f" {name}: {values['old']} -> {values['new']}")
167
+ return "\n".join(lines) + "\n"
168
+
169
+
170
+ def render_diff_markdown(
171
+ diff: dict[str, Any],
172
+ violations: list[str] | None = None,
173
+ warning: str | None = None,
174
+ ) -> str:
175
+ """A PR-comment-ready markdown report of an index diff."""
176
+ surface = diff.get("entrypoints", {"added": [], "removed": [], "changed": []})
177
+ has_surface = bool(surface["added"] or surface["removed"] or surface["changed"])
178
+ has_any = bool(diff["added"] or diff["removed"] or diff["changed"] or has_surface)
179
+
180
+ out: list[str] = ["## ๐Ÿ” CGIR contract diff", ""]
181
+ if warning:
182
+ out += [f"> โš ๏ธ {warning}", ""]
183
+ if not has_any:
184
+ out.append("โœ… No contract changes.")
185
+ return "\n".join(out) + "\n"
186
+
187
+ if violations:
188
+ out.append(f"> โŒ **{len(violations)} drift violation(s)** โ€” gate failed:")
189
+ out += [f"> - {line}" for line in violations]
190
+ out.append("")
191
+
192
+ if has_surface:
193
+ out += ["### Entrypoint surface", ""]
194
+ out += [f"- ๐ŸŸข `{e['entrypoint']}` โ€” new (`{e['id']}`)" for e in surface["added"]]
195
+ out += [f"- ๐Ÿ”ด `{e['entrypoint']}` โ€” removed (`{e['id']}`)" for e in surface["removed"]]
196
+ out += [f"- ๐ŸŸก `{e['old']}` โ†’ `{e['new']}` (`{e['id']}`)" for e in surface["changed"]]
197
+ out.append("")
198
+
199
+ if diff["changed"]:
200
+ out += [f"### Contract drift ({len(diff['changed'])})", ""]
201
+ for change in diff["changed"]:
202
+ out.append(f"<details><summary><code>{change['id']}</code></summary>")
203
+ out += ["", "| field | before | after |", "|---|---|---|"]
204
+ for name, values in change["fields"].items():
205
+ old_v = _md_cell(values["old"])
206
+ new_v = _md_cell(values["new"])
207
+ out.append(f"| {name} | {old_v} | {new_v} |")
208
+ out += ["", "</details>", ""]
209
+
210
+ counts = []
211
+ if diff["added"]:
212
+ counts.append(f"**{len(diff['added'])}** added")
213
+ if diff["removed"]:
214
+ counts.append(f"**{len(diff['removed'])}** removed")
215
+ if counts:
216
+ out.append(" ยท ".join(counts))
217
+
218
+ return "\n".join(out).rstrip() + "\n"
219
+
220
+
221
+ def _md_cell(value: Any) -> str:
222
+ if value is None or value == [] or value == "":
223
+ return "โ€”"
224
+ if isinstance(value, list):
225
+ return ", ".join(f"`{v}`" for v in value)
226
+ return f"`{value}`"
cgir/report/flow.py ADDED
@@ -0,0 +1,84 @@
1
+ """Component flow tracing โ€” who calls it, what it calls, what it builds.
2
+
3
+ ``render_flow`` is pure over ComponentSpecs (same pattern as
4
+ :mod:`cgir.report.stats`): upstream callers and downstream callees as
5
+ depth-limited trees, each node annotated with kind, effects, and the
6
+ declared return type. Cycles are marked and not re-expanded.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Callable
12
+
13
+ from cgir.ir.component_spec import ComponentSpec
14
+
15
+ DEFAULT_DEPTH = 3
16
+
17
+
18
+ def render_flow(specs: list[ComponentSpec], root_id: str, depth: int = DEFAULT_DEPTH) -> str:
19
+ by_id = {s.id: s for s in specs}
20
+ if root_id not in by_id:
21
+ raise KeyError(root_id)
22
+
23
+ callers: dict[str, list[str]] = {}
24
+ for spec in specs:
25
+ for callee in spec.calls:
26
+ if callee in by_id:
27
+ callers.setdefault(callee, []).append(spec.id)
28
+
29
+ def upstream(node_id: str) -> list[str]:
30
+ return sorted(callers.get(node_id, []))
31
+
32
+ def downstream(node_id: str) -> list[str]:
33
+ return [c for c in by_id[node_id].calls if c in by_id]
34
+
35
+ lines: list[str] = [_describe(by_id[root_id]), ""]
36
+ lines.append("called by (upstream):")
37
+ _walk(root_id, upstream, by_id, lines, "<-", depth, indent=1, seen={root_id})
38
+ lines.append("")
39
+ lines.append("calls (downstream):")
40
+ _walk(root_id, downstream, by_id, lines, "->", depth, indent=1, seen={root_id})
41
+ root_constructs = by_id[root_id].constructs
42
+ if root_constructs:
43
+ lines.append("")
44
+ lines.append("constructs:")
45
+ for type_name in root_constructs:
46
+ lines.append(f" + {type_name}")
47
+ return "\n".join(lines) + "\n"
48
+
49
+
50
+ def _walk(
51
+ node_id: str,
52
+ next_ids: Callable[[str], list[str]],
53
+ by_id: dict[str, ComponentSpec],
54
+ lines: list[str],
55
+ arrow: str,
56
+ depth: int,
57
+ indent: int,
58
+ seen: set[str],
59
+ ) -> None:
60
+ children = next_ids(node_id)
61
+ if not children and indent == 1:
62
+ lines.append(" (none)")
63
+ return
64
+ for child in children:
65
+ pad = " " * indent
66
+ if child in seen:
67
+ lines.append(f"{pad}{arrow} {child} (cycle)")
68
+ continue
69
+ lines.append(f"{pad}{arrow} {_describe(by_id[child])}")
70
+ if indent < depth:
71
+ _walk(child, next_ids, by_id, lines, arrow, depth, indent + 1, seen | {child})
72
+
73
+
74
+ def _describe(spec: ComponentSpec) -> str:
75
+ parts = [spec.id, f"[{spec.kind.value}"]
76
+ effects = [e for e in spec.effects if e != "calls_effectful"]
77
+ if effects:
78
+ parts[-1] += " ยท " + ",".join(effects)
79
+ parts[-1] += "]"
80
+ if spec.outputs:
81
+ parts.append(f"-> {spec.outputs[0]}")
82
+ if spec.entrypoint:
83
+ parts.append(f"({spec.entrypoint})")
84
+ return " ".join(parts)