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/report/impact.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Change-impact / blast-radius analysis — the forward-looking companion
|
|
2
|
+
to :mod:`cgir.report.diff`.
|
|
3
|
+
|
|
4
|
+
``diff`` is retrospective ("what changed?"). ``impact`` is predictive:
|
|
5
|
+
*before* you (or an agent) touch a component, what does changing it put at
|
|
6
|
+
risk? The answer is the transitive **upstream** (caller) closure — every
|
|
7
|
+
component whose behaviour depends on this one — plus the surface that
|
|
8
|
+
closure reaches: which **entrypoints** sit above it, and which **tests** to
|
|
9
|
+
run (union of ``covered_by`` over the target and everything affected).
|
|
10
|
+
|
|
11
|
+
``compute_impact`` is the worst case: it assumes the change could affect
|
|
12
|
+
anything upstream. ``compute_typed_impact`` narrows that by *what actually
|
|
13
|
+
changed about the contract*, because not every change propagates the same
|
|
14
|
+
way:
|
|
15
|
+
|
|
16
|
+
* **body-only** (no contract field changed) — callers are contract-safe;
|
|
17
|
+
reach ``none``, only the target's own tests matter.
|
|
18
|
+
* **signature / outputs** — an interface break the *direct* call sites must
|
|
19
|
+
adapt to, but which does not inherently ripple past them; reach
|
|
20
|
+
``direct``.
|
|
21
|
+
* **effects / purity / kind** — semantic taint that flows *up* the call
|
|
22
|
+
graph (a caller of something newly effectful is itself newly effectful);
|
|
23
|
+
reach ``transitive`` — the full closure.
|
|
24
|
+
|
|
25
|
+
The reach model is a deliberate, documented heuristic, not a proof: it
|
|
26
|
+
mirrors how the effect/purity analyses actually propagate. Pure over
|
|
27
|
+
ComponentSpecs, so it drives both the CLI (``cgir impact``) and the MCP
|
|
28
|
+
tool an agent calls before — and after — an edit.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from collections.abc import Iterable
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
from cgir.ir.component_spec import ComponentSpec
|
|
37
|
+
|
|
38
|
+
# Contract fields whose change taints callers transitively vs. only directly.
|
|
39
|
+
_TRANSITIVE_FIELDS = frozenset({"effects", "purity", "kind"})
|
|
40
|
+
_DIRECT_FIELDS = frozenset({"signature", "outputs", "inputs"})
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _callers_map(
|
|
44
|
+
specs: list[ComponentSpec], by_id: dict[str, ComponentSpec]
|
|
45
|
+
) -> dict[str, set[str]]:
|
|
46
|
+
"""Reverse the CALLS relation: callee id -> ids that call it."""
|
|
47
|
+
callers: dict[str, set[str]] = {}
|
|
48
|
+
for spec in specs:
|
|
49
|
+
for callee in spec.calls:
|
|
50
|
+
if callee in by_id:
|
|
51
|
+
callers.setdefault(callee, set()).add(spec.id)
|
|
52
|
+
return callers
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _upstream_closure(callers: dict[str, set[str]], target_id: str) -> set[str]:
|
|
56
|
+
"""Transitive caller closure of ``target_id`` (cycle-safe, target excluded)."""
|
|
57
|
+
affected: set[str] = set()
|
|
58
|
+
queue = [target_id]
|
|
59
|
+
while queue:
|
|
60
|
+
node = queue.pop()
|
|
61
|
+
for caller in callers.get(node, ()):
|
|
62
|
+
if caller != target_id and caller not in affected:
|
|
63
|
+
affected.add(caller)
|
|
64
|
+
queue.append(caller)
|
|
65
|
+
return affected
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _is_test_spec(spec: ComponentSpec) -> bool:
|
|
69
|
+
"""Mirror of ``slicer._is_test_node`` at the spec level."""
|
|
70
|
+
if spec.id.rsplit(".", 1)[-1].startswith("test_"):
|
|
71
|
+
return True
|
|
72
|
+
if spec.trace:
|
|
73
|
+
path = spec.trace[0].rsplit(":", 1)[0].replace("\\", "/")
|
|
74
|
+
parts = path.split("/")
|
|
75
|
+
stem = parts[-1].rsplit(".", 1)[0]
|
|
76
|
+
return "tests" in parts[:-1] or stem.startswith("test_") or stem.endswith("_test")
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _surface(
|
|
81
|
+
by_id: dict[str, ComponentSpec], target_id: str, affected: set[str]
|
|
82
|
+
) -> tuple[list[str], list[dict[str, Any]], list[str]]:
|
|
83
|
+
"""Partition + derive: (affected code, entrypoints at risk, tests to run).
|
|
84
|
+
|
|
85
|
+
Test components in the caller closure are not "affected code" — they are
|
|
86
|
+
literally tests to run, so they move into the tests list (this also
|
|
87
|
+
catches transitive test callers never linked via ``covered_by``).
|
|
88
|
+
"""
|
|
89
|
+
code = {sid for sid in affected if not _is_test_spec(by_id[sid])}
|
|
90
|
+
scope = sorted(code | {target_id})
|
|
91
|
+
entrypoints = [
|
|
92
|
+
{"id": sid, "entrypoint": by_id[sid].entrypoint} for sid in scope if by_id[sid].entrypoint
|
|
93
|
+
]
|
|
94
|
+
tests: set[str] = set(by_id[target_id].covered_by)
|
|
95
|
+
tests |= affected - code
|
|
96
|
+
for sid in code:
|
|
97
|
+
tests |= set(by_id[sid].covered_by)
|
|
98
|
+
return sorted(code), entrypoints, sorted(tests)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def compute_impact(specs: list[ComponentSpec], target_id: str) -> dict[str, Any]:
|
|
102
|
+
"""Worst-case blast radius of changing ``target_id`` — pure, JSON-able."""
|
|
103
|
+
by_id = {s.id: s for s in specs}
|
|
104
|
+
if target_id not in by_id:
|
|
105
|
+
raise KeyError(target_id)
|
|
106
|
+
callers = _callers_map(specs, by_id)
|
|
107
|
+
affected = _upstream_closure(callers, target_id)
|
|
108
|
+
code, entrypoints, tests = _surface(by_id, target_id, affected)
|
|
109
|
+
return {
|
|
110
|
+
"target": target_id,
|
|
111
|
+
"direct_callers": sorted(callers.get(target_id, set())),
|
|
112
|
+
"affected": code,
|
|
113
|
+
"entrypoints": entrypoints,
|
|
114
|
+
"tests": tests,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _reach(changed_fields: set[str]) -> str:
|
|
119
|
+
if changed_fields & _TRANSITIVE_FIELDS:
|
|
120
|
+
return "transitive"
|
|
121
|
+
if changed_fields & _DIRECT_FIELDS:
|
|
122
|
+
return "direct"
|
|
123
|
+
return "none"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def compute_typed_impact(
|
|
127
|
+
specs: list[ComponentSpec], target_id: str, changed_fields: Iterable[str]
|
|
128
|
+
) -> dict[str, Any]:
|
|
129
|
+
"""Blast radius narrowed by *which* contract fields changed.
|
|
130
|
+
|
|
131
|
+
``changed_fields`` is the set of drifted contract fields — e.g. the keys
|
|
132
|
+
of a :func:`cgir.report.diff.compute_diff` change entry or a
|
|
133
|
+
``VerifyResult.drift``. An empty set means a body-only edit.
|
|
134
|
+
"""
|
|
135
|
+
by_id = {s.id: s for s in specs}
|
|
136
|
+
if target_id not in by_id:
|
|
137
|
+
raise KeyError(target_id)
|
|
138
|
+
delta = set(changed_fields)
|
|
139
|
+
reach = _reach(delta)
|
|
140
|
+
callers = _callers_map(specs, by_id)
|
|
141
|
+
direct = sorted(callers.get(target_id, set()))
|
|
142
|
+
|
|
143
|
+
if reach == "transitive":
|
|
144
|
+
affected = _upstream_closure(callers, target_id)
|
|
145
|
+
elif reach == "direct":
|
|
146
|
+
affected = set(direct)
|
|
147
|
+
else:
|
|
148
|
+
affected = set()
|
|
149
|
+
|
|
150
|
+
code, entrypoints, tests = _surface(by_id, target_id, affected)
|
|
151
|
+
return {
|
|
152
|
+
"target": target_id,
|
|
153
|
+
"changed_fields": sorted(delta),
|
|
154
|
+
"reach": reach,
|
|
155
|
+
"direct_callers": direct,
|
|
156
|
+
"affected": code,
|
|
157
|
+
"entrypoints": entrypoints,
|
|
158
|
+
"tests": tests,
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
_REACH_NOTE = {
|
|
163
|
+
"none": "body-only change — no contract drift, callers are unaffected",
|
|
164
|
+
"direct": "interface change — direct call sites must adapt",
|
|
165
|
+
"transitive": "effect/purity change — taint flows up the call graph",
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _render(data: dict[str, Any]) -> str:
|
|
170
|
+
target = data["target"]
|
|
171
|
+
affected = data["affected"]
|
|
172
|
+
entrypoints = data["entrypoints"]
|
|
173
|
+
tests = data["tests"]
|
|
174
|
+
reach = data.get("reach")
|
|
175
|
+
|
|
176
|
+
lines: list[str] = [f"# impact of changing {target}", ""]
|
|
177
|
+
if reach is not None:
|
|
178
|
+
fields = ", ".join(data["changed_fields"]) or "(none)"
|
|
179
|
+
lines.append(f"contract delta: {fields} → reach: {reach}")
|
|
180
|
+
lines.append(f" {_REACH_NOTE[reach]}")
|
|
181
|
+
lines.append("")
|
|
182
|
+
lines.append(
|
|
183
|
+
f"{len(affected)} component(s) affected · "
|
|
184
|
+
f"{len(entrypoints)} entrypoint(s) at risk · "
|
|
185
|
+
f"{len(tests)} test(s) to run"
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
lines.append("")
|
|
189
|
+
lines.append(f"affected components ({len(affected)}):")
|
|
190
|
+
if affected:
|
|
191
|
+
direct = set(data["direct_callers"])
|
|
192
|
+
for sid in affected:
|
|
193
|
+
lines.append(f" {'← direct' if sid in direct else ' ⋯ '} {sid}")
|
|
194
|
+
elif reach == "none" and data["direct_callers"]:
|
|
195
|
+
lines.append(f" (none contract-affected; {len(data['direct_callers'])} call it)")
|
|
196
|
+
else:
|
|
197
|
+
lines.append(" (none — nothing calls this)")
|
|
198
|
+
|
|
199
|
+
lines.append("")
|
|
200
|
+
lines.append(f"entrypoints at risk ({len(entrypoints)}):")
|
|
201
|
+
if entrypoints:
|
|
202
|
+
for e in entrypoints:
|
|
203
|
+
lines.append(f" ! {e['entrypoint']} ({e['id']})")
|
|
204
|
+
else:
|
|
205
|
+
lines.append(" (none reachable)")
|
|
206
|
+
|
|
207
|
+
lines.append("")
|
|
208
|
+
lines.append(f"tests to run ({len(tests)}):")
|
|
209
|
+
if tests:
|
|
210
|
+
lines.extend(f" • {t}" for t in tests)
|
|
211
|
+
else:
|
|
212
|
+
lines.append(" (no linked tests — this change is unguarded)")
|
|
213
|
+
|
|
214
|
+
return "\n".join(lines) + "\n"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def render_impact(specs: list[ComponentSpec], target_id: str) -> str:
|
|
218
|
+
"""Human summary of the worst-case blast radius."""
|
|
219
|
+
return _render(compute_impact(specs, target_id))
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def render_typed_impact(
|
|
223
|
+
specs: list[ComponentSpec], target_id: str, changed_fields: Iterable[str]
|
|
224
|
+
) -> str:
|
|
225
|
+
"""Human summary of the blast radius narrowed by the contract delta."""
|
|
226
|
+
return _render(compute_typed_impact(specs, target_id, changed_fields))
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
__all__ = [
|
|
230
|
+
"compute_impact",
|
|
231
|
+
"compute_typed_impact",
|
|
232
|
+
"render_impact",
|
|
233
|
+
"render_typed_impact",
|
|
234
|
+
]
|
cgir/report/lint.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""cgir lint — semantic architecture rules over the ComponentSpec index.
|
|
2
|
+
|
|
3
|
+
Import linters (Tach, import-linter) constrain *imports*. CGIR constrains
|
|
4
|
+
*meaning*: which components may carry which effects, what kind they must be,
|
|
5
|
+
and what they may call — checks an import graph can't express because it
|
|
6
|
+
doesn't know a function touches the network or routes to the DB layer.
|
|
7
|
+
|
|
8
|
+
A rule is scoped by an ``in`` id-glob and carries one predicate:
|
|
9
|
+
|
|
10
|
+
* ``forbid-effect``: matched components must not carry these effect tags
|
|
11
|
+
* ``require-kind``: matched components must be this component kind
|
|
12
|
+
* ``forbid-call``: matched components must not call components whose id
|
|
13
|
+
matches this glob (a semantic layer boundary over resolved CALLS)
|
|
14
|
+
|
|
15
|
+
Rules live in ``cgir.toml`` as a ``[[rule]]`` array; the checker itself is
|
|
16
|
+
pure over specs so it is trivially testable and CI-friendly.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import tomllib
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from fnmatch import fnmatch
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from cgir.ir.component_spec import ComponentSpec
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(slots=True)
|
|
31
|
+
class LintViolation:
|
|
32
|
+
rule: str
|
|
33
|
+
component: str
|
|
34
|
+
detail: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def load_rules(config_path: Path) -> list[dict[str, Any]]:
|
|
38
|
+
"""Read ``[[rule]]`` entries from a ``cgir.toml``-style config."""
|
|
39
|
+
data = tomllib.loads(config_path.read_text())
|
|
40
|
+
rules = data.get("rule", [])
|
|
41
|
+
return list(rules) if isinstance(rules, list) else []
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def lint(specs: list[ComponentSpec], rules: list[dict[str, Any]]) -> list[LintViolation]:
|
|
45
|
+
violations: list[LintViolation] = []
|
|
46
|
+
known = {s.id for s in specs}
|
|
47
|
+
for rule in rules:
|
|
48
|
+
name = str(rule.get("name", "rule"))
|
|
49
|
+
scope = str(rule.get("in", "*"))
|
|
50
|
+
matched = [s for s in specs if fnmatch(s.id, scope)]
|
|
51
|
+
|
|
52
|
+
if "forbid-effect" in rule:
|
|
53
|
+
forbidden = set(rule["forbid-effect"])
|
|
54
|
+
for spec in matched:
|
|
55
|
+
hit = sorted(forbidden & set(spec.effects))
|
|
56
|
+
if hit:
|
|
57
|
+
violations.append(
|
|
58
|
+
LintViolation(name, spec.id, f"has forbidden effect(s): {', '.join(hit)}")
|
|
59
|
+
)
|
|
60
|
+
if "require-kind" in rule:
|
|
61
|
+
want = str(rule["require-kind"])
|
|
62
|
+
for spec in matched:
|
|
63
|
+
if spec.kind.value != want:
|
|
64
|
+
violations.append(
|
|
65
|
+
LintViolation(name, spec.id, f"is {spec.kind.value}, must be {want}")
|
|
66
|
+
)
|
|
67
|
+
if "forbid-call" in rule:
|
|
68
|
+
target_glob = str(rule["forbid-call"])
|
|
69
|
+
for spec in matched:
|
|
70
|
+
for callee in spec.calls:
|
|
71
|
+
if callee in known and fnmatch(callee, target_glob):
|
|
72
|
+
violations.append(
|
|
73
|
+
LintViolation(name, spec.id, f"calls forbidden target: {callee}")
|
|
74
|
+
)
|
|
75
|
+
return violations
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def render_lint(violations: list[LintViolation]) -> str:
|
|
79
|
+
if not violations:
|
|
80
|
+
return "no architecture-rule violations\n"
|
|
81
|
+
lines = [f"{len(violations)} architecture-rule violation(s):"]
|
|
82
|
+
for v in violations:
|
|
83
|
+
lines.append(f" ! [{v.rule}] {v.component} {v.detail}")
|
|
84
|
+
return "\n".join(lines) + "\n"
|
cgir/report/pack.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""Context packer — the minimal bundle for working on one component.
|
|
2
|
+
|
|
3
|
+
This is the product's core loop (Code-IR.md: rewrite/audit "without
|
|
4
|
+
holding the whole repo in context"): given a target component, assemble
|
|
5
|
+
what an LLM needs and nothing else, in priority order:
|
|
6
|
+
|
|
7
|
+
1. the target — spec fields + source text when available
|
|
8
|
+
2. its callees, as *interfaces only* (signature, kind, effects, returns)
|
|
9
|
+
3. its callers — how it's used, with entrypoints ("how the outside
|
|
10
|
+
world reaches this")
|
|
11
|
+
4. the types it constructs
|
|
12
|
+
|
|
13
|
+
``budget`` is an approximate token budget (chars / 4). Lower-priority
|
|
14
|
+
sections are dropped whole to fit, and every drop is recorded under
|
|
15
|
+
``omitted`` — the bundle never silently lies about completeness.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from cgir.ir.component_spec import ComponentSpec
|
|
24
|
+
|
|
25
|
+
DEFAULT_BUDGET = 4000
|
|
26
|
+
|
|
27
|
+
# Dropped in this order to fit budget: types, tests & the target contract
|
|
28
|
+
# are the last to go (they're what makes a context-free rewrite possible).
|
|
29
|
+
_SECTION_PRIORITY = ("constructs", "callers", "callees", "context", "tests", "types")
|
|
30
|
+
|
|
31
|
+
_BUILTIN_TYPES = frozenset(
|
|
32
|
+
{
|
|
33
|
+
"int",
|
|
34
|
+
"float",
|
|
35
|
+
"str",
|
|
36
|
+
"bool",
|
|
37
|
+
"bytes",
|
|
38
|
+
"None",
|
|
39
|
+
"Any",
|
|
40
|
+
"list",
|
|
41
|
+
"dict",
|
|
42
|
+
"set",
|
|
43
|
+
"tuple",
|
|
44
|
+
"frozenset",
|
|
45
|
+
"object",
|
|
46
|
+
"Optional",
|
|
47
|
+
"Union",
|
|
48
|
+
"Iterable",
|
|
49
|
+
"Iterator",
|
|
50
|
+
"Sequence",
|
|
51
|
+
"Mapping",
|
|
52
|
+
"Callable",
|
|
53
|
+
"Type",
|
|
54
|
+
"Awaitable",
|
|
55
|
+
"Coroutine",
|
|
56
|
+
"AsyncIterator",
|
|
57
|
+
}
|
|
58
|
+
)
|
|
59
|
+
_TYPE_TOKEN = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def referenced_type_names(spec: ComponentSpec) -> set[str]:
|
|
63
|
+
"""Non-builtin type identifiers the component's contract names.
|
|
64
|
+
|
|
65
|
+
Pulled from the return type, constructed classes, and parameter
|
|
66
|
+
annotations — the types an implementer must know the shape of.
|
|
67
|
+
"""
|
|
68
|
+
names: set[str] = set()
|
|
69
|
+
for output in spec.outputs:
|
|
70
|
+
names |= _type_tokens(output)
|
|
71
|
+
for construct in spec.constructs:
|
|
72
|
+
names.add(construct.rsplit(".", 1)[-1])
|
|
73
|
+
for _, annotation in _param_items(spec.signature):
|
|
74
|
+
if annotation:
|
|
75
|
+
names |= _type_tokens(annotation)
|
|
76
|
+
return {n for n in names if n not in _BUILTIN_TYPES}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _type_tokens(annotation: str) -> set[str]:
|
|
80
|
+
return set(_TYPE_TOKEN.findall(annotation))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _param_items(signature: str | None) -> list[tuple[str, str | None]]:
|
|
84
|
+
if not signature or "(" not in signature or ")" not in signature:
|
|
85
|
+
return []
|
|
86
|
+
inner = signature[signature.index("(") + 1 : signature.rindex(")")]
|
|
87
|
+
items: list[tuple[str, str | None]] = []
|
|
88
|
+
depth, current = 0, ""
|
|
89
|
+
for ch in inner + ",":
|
|
90
|
+
if ch in "([{":
|
|
91
|
+
depth += 1
|
|
92
|
+
elif ch in ")]}":
|
|
93
|
+
depth -= 1
|
|
94
|
+
if ch == "," and depth == 0:
|
|
95
|
+
name, _, annotation = current.partition(":")
|
|
96
|
+
annotation = annotation.split("=", 1)[0].strip()
|
|
97
|
+
items.append((name.strip().lstrip("*"), annotation or None))
|
|
98
|
+
current = ""
|
|
99
|
+
else:
|
|
100
|
+
current += ch
|
|
101
|
+
return items
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def build_pack(
|
|
105
|
+
specs: list[ComponentSpec],
|
|
106
|
+
target_id: str,
|
|
107
|
+
source: str | None = None,
|
|
108
|
+
budget: int = DEFAULT_BUDGET,
|
|
109
|
+
types: dict[str, str] | None = None,
|
|
110
|
+
tests: dict[str, str] | None = None,
|
|
111
|
+
context: dict[str, str] | None = None,
|
|
112
|
+
receivers: dict[str, str] | None = None,
|
|
113
|
+
) -> dict[str, Any]:
|
|
114
|
+
by_id = {s.id: s for s in specs}
|
|
115
|
+
target = by_id[target_id] # KeyError for unknown targets, by design
|
|
116
|
+
|
|
117
|
+
callees = []
|
|
118
|
+
for callee in target.calls:
|
|
119
|
+
if callee not in by_id:
|
|
120
|
+
continue
|
|
121
|
+
entry = _interface(by_id[callee])
|
|
122
|
+
# A DI callee is reached through an injected field; naming it lets a
|
|
123
|
+
# rewriter reproduce the call (and thus the effect contract) instead
|
|
124
|
+
# of guessing the field name.
|
|
125
|
+
if receivers and callee in receivers:
|
|
126
|
+
entry["receiver"] = receivers[callee]
|
|
127
|
+
callees.append(entry)
|
|
128
|
+
callers = [_interface(s) for s in specs if target_id in s.calls]
|
|
129
|
+
type_defs = [{"name": name, "source": src} for name, src in sorted((types or {}).items())]
|
|
130
|
+
test_defs = [{"id": tid, "source": src} for tid, src in sorted((tests or {}).items())]
|
|
131
|
+
context_defs = [{"name": n, "source": src} for n, src in sorted((context or {}).items())]
|
|
132
|
+
pack: dict[str, Any] = {
|
|
133
|
+
"target": {
|
|
134
|
+
"id": target.id,
|
|
135
|
+
"kind": target.kind.value,
|
|
136
|
+
"signature": target.signature,
|
|
137
|
+
"inputs": target.inputs,
|
|
138
|
+
"outputs": target.outputs,
|
|
139
|
+
"effects": target.effects,
|
|
140
|
+
"purity": target.purity,
|
|
141
|
+
"entrypoint": target.entrypoint,
|
|
142
|
+
"doc": target.doc,
|
|
143
|
+
"raises": target.raises,
|
|
144
|
+
"trace": target.trace,
|
|
145
|
+
"source": source,
|
|
146
|
+
},
|
|
147
|
+
"types": type_defs,
|
|
148
|
+
"context": context_defs,
|
|
149
|
+
"tests": test_defs,
|
|
150
|
+
"callees": callees,
|
|
151
|
+
"callers": callers,
|
|
152
|
+
"constructs": list(target.constructs),
|
|
153
|
+
"omitted": [],
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
for section in _SECTION_PRIORITY:
|
|
157
|
+
if _estimate_tokens(pack) <= budget:
|
|
158
|
+
break
|
|
159
|
+
if pack[section]:
|
|
160
|
+
pack[section] = []
|
|
161
|
+
pack["omitted"].append(section)
|
|
162
|
+
return pack
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _interface(spec: ComponentSpec) -> dict[str, Any]:
|
|
166
|
+
return {
|
|
167
|
+
"id": spec.id,
|
|
168
|
+
"kind": spec.kind.value,
|
|
169
|
+
"signature": spec.signature,
|
|
170
|
+
"outputs": spec.outputs,
|
|
171
|
+
"effects": spec.effects,
|
|
172
|
+
"entrypoint": spec.entrypoint,
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _estimate_tokens(pack: dict[str, Any]) -> int:
|
|
177
|
+
return len(str(pack)) // 4
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def render_pack(pack: dict[str, Any]) -> str:
|
|
181
|
+
target = pack["target"]
|
|
182
|
+
lines: list[str] = [f"# {target['id']}", ""]
|
|
183
|
+
lines.append(
|
|
184
|
+
f"`{target['signature']}` — {target['kind']}"
|
|
185
|
+
+ (f", purity {target['purity']}" if target["purity"] is not None else "")
|
|
186
|
+
)
|
|
187
|
+
if target["entrypoint"]:
|
|
188
|
+
lines.append(f"Entrypoint: **{target['entrypoint']}**")
|
|
189
|
+
if target["effects"]:
|
|
190
|
+
lines.append(f"Effects: {', '.join(target['effects'])}")
|
|
191
|
+
if target["outputs"]:
|
|
192
|
+
lines.append(f"Returns: {', '.join(target['outputs'])}")
|
|
193
|
+
if target.get("raises"):
|
|
194
|
+
lines.append(f"Raises: {', '.join(target['raises'])}")
|
|
195
|
+
if target["trace"]:
|
|
196
|
+
lines.append(f"Source location: {target['trace'][0]}")
|
|
197
|
+
if target.get("doc"):
|
|
198
|
+
lines.append("")
|
|
199
|
+
lines.append(f"> {target['doc'].strip().splitlines()[0]}")
|
|
200
|
+
if target["source"]:
|
|
201
|
+
lines.append("")
|
|
202
|
+
lines.append("```python")
|
|
203
|
+
lines.append(target["source"].rstrip("\n"))
|
|
204
|
+
lines.append("```")
|
|
205
|
+
|
|
206
|
+
if pack.get("types"):
|
|
207
|
+
lines.append("")
|
|
208
|
+
lines.append("## Types")
|
|
209
|
+
lines.append("Shapes the target's contract references — match these exactly.")
|
|
210
|
+
for tdef in pack["types"]:
|
|
211
|
+
lines.append("")
|
|
212
|
+
lines.append("```python")
|
|
213
|
+
lines.append(tdef["source"].rstrip("\n"))
|
|
214
|
+
lines.append("```")
|
|
215
|
+
|
|
216
|
+
if pack.get("context"):
|
|
217
|
+
lines.append("")
|
|
218
|
+
lines.append("## Referenced module definitions")
|
|
219
|
+
lines.append("Constants and helpers the body uses — use them, don't redefine.")
|
|
220
|
+
for cdef in pack["context"]:
|
|
221
|
+
lines.append("")
|
|
222
|
+
lines.append("```python")
|
|
223
|
+
lines.append(cdef["source"].rstrip("\n"))
|
|
224
|
+
lines.append("```")
|
|
225
|
+
|
|
226
|
+
if pack.get("tests"):
|
|
227
|
+
lines.append("")
|
|
228
|
+
lines.append("## Tests (behavior contract)")
|
|
229
|
+
lines.append("The implementation must satisfy these.")
|
|
230
|
+
for tdef in pack["tests"]:
|
|
231
|
+
lines.append("")
|
|
232
|
+
lines.append(f"```python # {tdef['id']}")
|
|
233
|
+
lines.append(tdef["source"].rstrip("\n"))
|
|
234
|
+
lines.append("```")
|
|
235
|
+
|
|
236
|
+
if pack["callees"]:
|
|
237
|
+
lines.append("")
|
|
238
|
+
lines.append("## Callees (interfaces)")
|
|
239
|
+
lines.append("Do not modify these; call them as specified.")
|
|
240
|
+
lines.append("")
|
|
241
|
+
for callee in pack["callees"]:
|
|
242
|
+
lines.append(_interface_line(callee))
|
|
243
|
+
if pack["callers"]:
|
|
244
|
+
lines.append("")
|
|
245
|
+
lines.append("## Callers")
|
|
246
|
+
lines.append("These depend on the target's current contract.")
|
|
247
|
+
lines.append("")
|
|
248
|
+
for caller in pack["callers"]:
|
|
249
|
+
lines.append(_interface_line(caller))
|
|
250
|
+
if pack["constructs"]:
|
|
251
|
+
lines.append("")
|
|
252
|
+
lines.append("## Constructs")
|
|
253
|
+
lines.extend(f"- {type_name}" for type_name in pack["constructs"])
|
|
254
|
+
if pack["omitted"]:
|
|
255
|
+
lines.append("")
|
|
256
|
+
lines.append(f"_Omitted for budget: {', '.join(pack['omitted'])}_")
|
|
257
|
+
return "\n".join(lines) + "\n"
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _interface_line(entry: dict[str, Any]) -> str:
|
|
261
|
+
receiver = entry.get("receiver")
|
|
262
|
+
call = f"{receiver}.{entry['signature']}" if receiver else entry["signature"]
|
|
263
|
+
parts = [f"- `{call}`", f"({entry['id']}, {entry['kind']}"]
|
|
264
|
+
if entry["effects"]:
|
|
265
|
+
parts[-1] += f", effects: {','.join(entry['effects'])}"
|
|
266
|
+
parts[-1] += ")"
|
|
267
|
+
if entry["outputs"]:
|
|
268
|
+
parts.append(f"-> {entry['outputs'][0]}")
|
|
269
|
+
if entry["entrypoint"]:
|
|
270
|
+
parts.append(f"[{entry['entrypoint']}]")
|
|
271
|
+
return " ".join(parts)
|