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/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,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
|
cgir/report/__init__.py
ADDED
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)
|