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/analyses/pdg.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Program dependence graph overlay.
|
|
2
|
+
|
|
3
|
+
Adds two edge kinds onto the existing CFG-enriched graph:
|
|
4
|
+
|
|
5
|
+
* ``FLOWS_TO`` — data dependence. For each definition D (a ``Parameter``
|
|
6
|
+
or any CFG node with a non-empty ``writes`` attr — ``Assignment``,
|
|
7
|
+
``for`` header, ``with`` header, ``except`` clause) writing variable v,
|
|
8
|
+
and each CFG node N that *reads* v, emit ``D -[FLOWS_TO]-> N`` iff D
|
|
9
|
+
reaches N (per :mod:`cgir.analyses.reaching_defs`).
|
|
10
|
+
* ``DEPENDS_ON`` — control dependence. For each CFG node N with
|
|
11
|
+
``attrs["controlled_by"]`` set, emit ``N -[DEPENDS_ON]-> controller``.
|
|
12
|
+
The controller is the immediately enclosing ``Branch`` or ``Loop``, as
|
|
13
|
+
recorded by :mod:`cgir.analyses.cfg`.
|
|
14
|
+
|
|
15
|
+
Pure-graph pass: no ``repo_path``, no re-parse. Idempotency is *not*
|
|
16
|
+
guaranteed — calling ``build`` twice will duplicate edges. The CLI calls
|
|
17
|
+
it once.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from cgir.analyses import reaching_defs
|
|
23
|
+
from cgir.ir.edges import Edge, EdgeKind
|
|
24
|
+
from cgir.ir.graph import RepoGraph
|
|
25
|
+
from cgir.ir.nodes import Node, NodeKind
|
|
26
|
+
|
|
27
|
+
_CFG_KINDS: frozenset[NodeKind] = frozenset(
|
|
28
|
+
{
|
|
29
|
+
NodeKind.Statement,
|
|
30
|
+
NodeKind.Assignment,
|
|
31
|
+
NodeKind.Branch,
|
|
32
|
+
NodeKind.Loop,
|
|
33
|
+
NodeKind.Return,
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def build(graph: RepoGraph) -> None:
|
|
39
|
+
rd = reaching_defs.compute(graph)
|
|
40
|
+
for func in list(graph.nodes()):
|
|
41
|
+
if func.kind in {NodeKind.Function, NodeKind.Method}:
|
|
42
|
+
_build_for_function(graph, func, rd)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _build_for_function(graph: RepoGraph, func: Node, rd: dict[str, set[str]]) -> None:
|
|
46
|
+
cfg_nodes = [c for c in graph.children(func.id) if c.kind in _CFG_KINDS]
|
|
47
|
+
if not cfg_nodes:
|
|
48
|
+
return
|
|
49
|
+
params = list(graph.children(func.id, NodeKind.Parameter))
|
|
50
|
+
|
|
51
|
+
# var → {def_id, ...} (Assignments + Parameters)
|
|
52
|
+
var_to_defs: dict[str, set[str]] = {}
|
|
53
|
+
for p in params:
|
|
54
|
+
var_to_defs.setdefault(p.name, set()).add(p.id)
|
|
55
|
+
for n in cfg_nodes:
|
|
56
|
+
writes = n.attrs.get("writes") if n.attrs else None
|
|
57
|
+
if isinstance(writes, list):
|
|
58
|
+
for v in writes:
|
|
59
|
+
var_to_defs.setdefault(v, set()).add(n.id)
|
|
60
|
+
|
|
61
|
+
# FLOWS_TO: def -> use, gated by reaching defs.
|
|
62
|
+
for n in cfg_nodes:
|
|
63
|
+
reads = n.attrs.get("reads") if n.attrs else None
|
|
64
|
+
if not isinstance(reads, list) or not reads:
|
|
65
|
+
continue
|
|
66
|
+
in_defs = rd.get(n.id, set())
|
|
67
|
+
for var in reads:
|
|
68
|
+
for def_id in var_to_defs.get(var, set()) & in_defs:
|
|
69
|
+
graph.add_edge(Edge(src=def_id, dst=n.id, kind=EdgeKind.FLOWS_TO))
|
|
70
|
+
|
|
71
|
+
# DEPENDS_ON: controlled node -> controlling Branch/Loop.
|
|
72
|
+
for n in cfg_nodes:
|
|
73
|
+
controller = n.attrs.get("controlled_by") if n.attrs else None
|
|
74
|
+
if isinstance(controller, str):
|
|
75
|
+
graph.add_edge(Edge(src=n.id, dst=controller, kind=EdgeKind.DEPENDS_ON))
|
cgir/analyses/purity.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Purity scoring from the effects dict produced by :mod:`cgir.analyses.effects`.
|
|
2
|
+
|
|
3
|
+
Rubric:
|
|
4
|
+
1.0 no own impure effects AND only calls into pure components
|
|
5
|
+
0.7 ``calls_effectful`` only (orchestrates effectful callees but does
|
|
6
|
+
no direct IO/state writes itself)
|
|
7
|
+
0.0 any direct impure effect (io, net, fs, nondeterm, db)
|
|
8
|
+
|
|
9
|
+
``raise`` is *not* impure (settled Sprint 13): exceptions are control flow,
|
|
10
|
+
so a raise-only validator scores 1.0 while keeping the ``raise`` tag in its
|
|
11
|
+
effects list.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from cgir.analyses.effects import IMPURE_EFFECT_TAGS, TRANSITIVE_TAG
|
|
17
|
+
from cgir.ir.graph import RepoGraph
|
|
18
|
+
from cgir.ir.nodes import NodeKind
|
|
19
|
+
|
|
20
|
+
# Retained so callers that compute specs without a purity pass still get a
|
|
21
|
+
# defined value; downstream code can treat this as "no information".
|
|
22
|
+
PLACEHOLDER_SCORE = 0.5
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def score(graph: RepoGraph, effects: dict[str, list[str]]) -> dict[str, float]:
|
|
26
|
+
scores: dict[str, float] = {}
|
|
27
|
+
for node in graph.nodes():
|
|
28
|
+
if node.kind not in {NodeKind.Function, NodeKind.Method}:
|
|
29
|
+
continue
|
|
30
|
+
tags = set(effects.get(node.id, []))
|
|
31
|
+
if tags & IMPURE_EFFECT_TAGS:
|
|
32
|
+
scores[node.id] = 0.0
|
|
33
|
+
elif TRANSITIVE_TAG in tags:
|
|
34
|
+
scores[node.id] = 0.7
|
|
35
|
+
else:
|
|
36
|
+
scores[node.id] = 1.0
|
|
37
|
+
return scores
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Reaching-definitions analysis.
|
|
2
|
+
|
|
3
|
+
Classical forward may-analysis over each function's CFG:
|
|
4
|
+
|
|
5
|
+
* Any CFG node A with a non-empty ``writes`` attr is a definition of those
|
|
6
|
+
variables: ``gen[A] = {A.id}`` and ``kill[A]`` contains every other def
|
|
7
|
+
of any variable A writes. This covers ``Assignment`` nodes, ``for``-loop
|
|
8
|
+
headers (loop targets), ``with`` headers (``as`` aliases), and ``except``
|
|
9
|
+
clauses (``as`` aliases).
|
|
10
|
+
* ``Parameter`` nodes count as initial defs at function entry.
|
|
11
|
+
* ``in[n] = union of out[pred]`` over ``CONTROLS`` predecessors within
|
|
12
|
+
the same function; for the entry node(s), ``in`` also contains the
|
|
13
|
+
parameter defs.
|
|
14
|
+
* ``out[n] = gen[n] | (in[n] - kill[n])``.
|
|
15
|
+
|
|
16
|
+
This is the first pure-graph analysis: it reads the ``writes`` attrs
|
|
17
|
+
populated by :mod:`cgir.analyses.cfg` and walks the graph. It does not
|
|
18
|
+
re-parse source. See ``docs/roadmap.md`` "Grammar-agnostic core refactor".
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from collections import deque
|
|
24
|
+
|
|
25
|
+
from cgir.ir.edges import EdgeKind
|
|
26
|
+
from cgir.ir.graph import RepoGraph
|
|
27
|
+
from cgir.ir.nodes import Node, NodeKind
|
|
28
|
+
|
|
29
|
+
_CFG_KINDS: frozenset[NodeKind] = frozenset(
|
|
30
|
+
{
|
|
31
|
+
NodeKind.Statement,
|
|
32
|
+
NodeKind.Assignment,
|
|
33
|
+
NodeKind.Branch,
|
|
34
|
+
NodeKind.Loop,
|
|
35
|
+
NodeKind.Return,
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def compute(graph: RepoGraph) -> dict[str, set[str]]:
|
|
41
|
+
"""Return ``{cfg_node_id: {def_id, ...}}`` across every function/method.
|
|
42
|
+
|
|
43
|
+
``def_id`` is the id of a ``Parameter`` node or any CFG node with a
|
|
44
|
+
non-empty ``writes`` attr.
|
|
45
|
+
"""
|
|
46
|
+
result: dict[str, set[str]] = {}
|
|
47
|
+
for func in list(graph.nodes()):
|
|
48
|
+
if func.kind in {NodeKind.Function, NodeKind.Method}:
|
|
49
|
+
result.update(_compute_for_function(graph, func))
|
|
50
|
+
return result
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _compute_for_function(graph: RepoGraph, func: Node) -> dict[str, set[str]]:
|
|
54
|
+
cfg_nodes = [c for c in graph.children(func.id) if c.kind in _CFG_KINDS]
|
|
55
|
+
if not cfg_nodes:
|
|
56
|
+
return {}
|
|
57
|
+
params = list(graph.children(func.id, NodeKind.Parameter))
|
|
58
|
+
|
|
59
|
+
cfg_ids: set[str] = {n.id for n in cfg_nodes}
|
|
60
|
+
|
|
61
|
+
# var -> {def_id, ...} (Assignment and Parameter)
|
|
62
|
+
defs_by_var: dict[str, set[str]] = {}
|
|
63
|
+
for p in params:
|
|
64
|
+
defs_by_var.setdefault(p.name, set()).add(p.id)
|
|
65
|
+
writes_per_node: dict[str, list[str]] = {}
|
|
66
|
+
for n in cfg_nodes:
|
|
67
|
+
writes_raw = n.attrs.get("writes") if n.attrs else None
|
|
68
|
+
writes = list(writes_raw) if isinstance(writes_raw, list) else []
|
|
69
|
+
if writes:
|
|
70
|
+
writes_per_node[n.id] = writes
|
|
71
|
+
for var in writes:
|
|
72
|
+
defs_by_var.setdefault(var, set()).add(n.id)
|
|
73
|
+
|
|
74
|
+
gen: dict[str, set[str]] = {}
|
|
75
|
+
kill: dict[str, set[str]] = {}
|
|
76
|
+
for n in cfg_nodes:
|
|
77
|
+
if n.id in writes_per_node:
|
|
78
|
+
gen[n.id] = {n.id}
|
|
79
|
+
kill_set: set[str] = set()
|
|
80
|
+
for var in writes_per_node[n.id]:
|
|
81
|
+
kill_set |= defs_by_var.get(var, set()) - {n.id}
|
|
82
|
+
kill[n.id] = kill_set
|
|
83
|
+
else:
|
|
84
|
+
gen[n.id] = set()
|
|
85
|
+
kill[n.id] = set()
|
|
86
|
+
|
|
87
|
+
entry_defs: set[str] = {p.id for p in params}
|
|
88
|
+
|
|
89
|
+
in_set: dict[str, set[str]] = {nid: set() for nid in cfg_ids}
|
|
90
|
+
out_set: dict[str, set[str]] = {nid: set() for nid in cfg_ids}
|
|
91
|
+
|
|
92
|
+
worklist: deque[str] = deque(cfg_ids)
|
|
93
|
+
queued: set[str] = set(cfg_ids)
|
|
94
|
+
|
|
95
|
+
while worklist:
|
|
96
|
+
nid = worklist.popleft()
|
|
97
|
+
queued.discard(nid)
|
|
98
|
+
|
|
99
|
+
new_in: set[str] = set()
|
|
100
|
+
for pred_edge in graph.in_edges(nid, EdgeKind.CONTROLS):
|
|
101
|
+
if pred_edge.src == func.id:
|
|
102
|
+
new_in |= entry_defs
|
|
103
|
+
elif pred_edge.src in cfg_ids:
|
|
104
|
+
new_in |= out_set[pred_edge.src]
|
|
105
|
+
in_set[nid] = new_in
|
|
106
|
+
|
|
107
|
+
new_out = gen[nid] | (new_in - kill[nid])
|
|
108
|
+
if new_out != out_set[nid]:
|
|
109
|
+
out_set[nid] = new_out
|
|
110
|
+
for succ_edge in graph.out_edges(nid, EdgeKind.CONTROLS):
|
|
111
|
+
if succ_edge.dst in cfg_ids and succ_edge.dst not in queued:
|
|
112
|
+
worklist.append(succ_edge.dst)
|
|
113
|
+
queued.add(succ_edge.dst)
|
|
114
|
+
|
|
115
|
+
return {nid: in_set[nid] for nid in cfg_ids}
|
cgir/analyses/symbols.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Per-module symbol tables + cross-file import resolution for Python.
|
|
2
|
+
|
|
3
|
+
Resolution rules:
|
|
4
|
+
* ``import x.y.z`` binds the local name ``x`` to module ``x.y.z`` (Python's
|
|
5
|
+
actual semantics differ, but for call-graph purposes we only care that the
|
|
6
|
+
dotted target resolves).
|
|
7
|
+
* ``from a.b import c`` binds the local name ``c`` to the qualified symbol
|
|
8
|
+
``a.b.c``. If that resolves to a known ``Function``/``Class`` node, we
|
|
9
|
+
record it; otherwise the binding stays opaque (third-party).
|
|
10
|
+
* ``import x as y`` / ``from a import b as c`` bind the *alias* (recorded
|
|
11
|
+
by the ingester on the Import node); the original name is not bound.
|
|
12
|
+
* Top-level ``def``/``class`` in a module bind their names in that module's
|
|
13
|
+
table.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
|
|
20
|
+
from cgir.ir.edges import EdgeKind
|
|
21
|
+
from cgir.ir.graph import RepoGraph
|
|
22
|
+
from cgir.ir.nodes import Node, NodeKind
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class SymbolTable:
|
|
27
|
+
"""Local name → graph node id (or ``None`` for opaque external symbols)."""
|
|
28
|
+
|
|
29
|
+
module_id: str
|
|
30
|
+
bindings: dict[str, str | None] = field(default_factory=dict)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def build_symbol_tables(graph: RepoGraph) -> dict[str, SymbolTable]:
|
|
34
|
+
qualname_index = _qualname_index(graph)
|
|
35
|
+
tables: dict[str, SymbolTable] = {}
|
|
36
|
+
|
|
37
|
+
for module in graph.nodes(NodeKind.Module):
|
|
38
|
+
table = SymbolTable(module_id=module.id)
|
|
39
|
+
for child in graph.children(module.id):
|
|
40
|
+
if child.kind in {NodeKind.Function, NodeKind.Class}:
|
|
41
|
+
table.bindings[child.name] = child.id
|
|
42
|
+
tables[module.id] = table
|
|
43
|
+
|
|
44
|
+
for module in graph.nodes(NodeKind.Module):
|
|
45
|
+
table = tables[module.id]
|
|
46
|
+
for child in graph.children(module.id, NodeKind.Import):
|
|
47
|
+
target = str(child.attrs.get("target") or child.name)
|
|
48
|
+
alias = child.attrs.get("alias")
|
|
49
|
+
local = alias if isinstance(alias, str) else target.rsplit(".", 1)[-1]
|
|
50
|
+
table.bindings[local] = _resolve_target(qualname_index, target)
|
|
51
|
+
|
|
52
|
+
return tables
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _resolve_target(qualname_index: dict[str, str], target: str) -> str | None:
|
|
56
|
+
"""Exact qualname match, else a *unique* suffix match.
|
|
57
|
+
|
|
58
|
+
The suffix fallback bridges source-root prefixes: a repo whose package
|
|
59
|
+
lives in ``backend/`` (or ``src/``) has module qualnames like
|
|
60
|
+
``backend.app.repos.chapter`` while its code imports
|
|
61
|
+
``app.repos.chapter``. Ambiguous suffixes stay unresolved — don't guess.
|
|
62
|
+
"""
|
|
63
|
+
hit = qualname_index.get(target)
|
|
64
|
+
if hit is not None:
|
|
65
|
+
return hit
|
|
66
|
+
suffix = "." + target
|
|
67
|
+
candidates = {nid for qual, nid in qualname_index.items() if qual.endswith(suffix)}
|
|
68
|
+
if len(candidates) == 1:
|
|
69
|
+
return next(iter(candidates))
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _qualname_index(graph: RepoGraph) -> dict[str, str]:
|
|
74
|
+
index: dict[str, str] = {}
|
|
75
|
+
for node in graph.nodes():
|
|
76
|
+
if node.kind not in {NodeKind.Function, NodeKind.Method, NodeKind.Class, NodeKind.Module}:
|
|
77
|
+
continue
|
|
78
|
+
qual = node.attrs.get("qualname") if node.attrs else None
|
|
79
|
+
if isinstance(qual, str):
|
|
80
|
+
index[qual] = node.id
|
|
81
|
+
else:
|
|
82
|
+
index[node.name] = node.id
|
|
83
|
+
return index
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def resolve(tables: dict[str, SymbolTable], module_id: str, name: str) -> str | None:
|
|
87
|
+
table = tables.get(module_id)
|
|
88
|
+
if table is None:
|
|
89
|
+
return None
|
|
90
|
+
return table.bindings.get(name)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def module_of(graph: RepoGraph, func_node: Node) -> str | None:
|
|
94
|
+
"""Walk CONTAINS edges upward to find the owning Module id."""
|
|
95
|
+
current_id = func_node.id
|
|
96
|
+
visited: set[str] = set()
|
|
97
|
+
while current_id not in visited:
|
|
98
|
+
visited.add(current_id)
|
|
99
|
+
parents = list(graph.in_edges(current_id, EdgeKind.CONTAINS))
|
|
100
|
+
if not parents:
|
|
101
|
+
return None
|
|
102
|
+
parent = graph.get_node(parents[0].src)
|
|
103
|
+
if parent.kind == NodeKind.Module:
|
|
104
|
+
return parent.id
|
|
105
|
+
current_id = parent.id
|
|
106
|
+
return None
|
cgir/api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""HTTP API surface — a thin FastAPI layer over :mod:`cgir.pipeline`."""
|
cgir/api/mcp_server.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""MCP server — the semantic index as agent tools.
|
|
2
|
+
|
|
3
|
+
Agents are CGIR's target user, and agents call tools rather than reading
|
|
4
|
+
dashboards. The ``tool_*`` functions below are plain callables over an
|
|
5
|
+
index directory, each returning a string; :func:`create_server` wraps
|
|
6
|
+
them in FastMCP behind a lazy import (``pip install cgir[mcp]``),
|
|
7
|
+
mirroring the anthropic pattern in :mod:`cgir.regenerate.regenerator`.
|
|
8
|
+
|
|
9
|
+
Run it: ``cgir mcp --index .cgir`` (stdio transport), then register the
|
|
10
|
+
command as an MCP server in your agent's config.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from cgir.export.json_export import read_specs
|
|
19
|
+
from cgir.report.flow import render_flow
|
|
20
|
+
from cgir.report.impact import render_impact, render_typed_impact
|
|
21
|
+
from cgir.report.pack import build_pack, render_pack
|
|
22
|
+
from cgir.report.stats import compute_stats, render_text
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def tool_stats(index_dir: Path) -> str:
|
|
26
|
+
"""Structure report: kinds, purity, effects, hotspots, entrypoints."""
|
|
27
|
+
return render_text(compute_stats(read_specs(index_dir)))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def tool_component(index_dir: Path, component_id: str) -> str:
|
|
31
|
+
"""One ComponentSpec as JSON."""
|
|
32
|
+
spec_path = index_dir / "components" / f"{component_id}.json"
|
|
33
|
+
if not spec_path.exists():
|
|
34
|
+
return f"unknown component: {component_id}"
|
|
35
|
+
return spec_path.read_text()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def tool_flow(index_dir: Path, component_id: str, depth: int = 3) -> str:
|
|
39
|
+
"""Upstream callers and downstream callees, annotated."""
|
|
40
|
+
try:
|
|
41
|
+
return render_flow(read_specs(index_dir), component_id, depth)
|
|
42
|
+
except KeyError:
|
|
43
|
+
return f"unknown component: {component_id}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def tool_impact(index_dir: Path, component_id: str) -> str:
|
|
47
|
+
"""Blast radius of changing a component: affected callers, entrypoints, tests."""
|
|
48
|
+
try:
|
|
49
|
+
return render_impact(read_specs(index_dir), component_id)
|
|
50
|
+
except KeyError:
|
|
51
|
+
return f"unknown component: {component_id}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def tool_impact_of_change(index_dir: Path, repo: Path, component_id: str, candidate: str) -> str:
|
|
55
|
+
"""Blast radius of a *specific* proposed edit — narrowed by its real contract delta."""
|
|
56
|
+
from cgir.verify import verify
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
result = verify(index_dir, component_id, candidate, repo)
|
|
60
|
+
except KeyError:
|
|
61
|
+
return f"unknown component: {component_id}"
|
|
62
|
+
delta = list(result.drift.keys())
|
|
63
|
+
return render_typed_impact(read_specs(index_dir), component_id, delta)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def tool_pack(index_dir: Path, component_id: str, budget: int = 4000) -> str:
|
|
67
|
+
"""The minimal context bundle for working on one component."""
|
|
68
|
+
try:
|
|
69
|
+
bundle = build_pack(read_specs(index_dir), component_id, budget=budget)
|
|
70
|
+
except KeyError:
|
|
71
|
+
return f"unknown component: {component_id}"
|
|
72
|
+
return render_pack(bundle)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def tool_search(index_dir: Path, query: str) -> str:
|
|
76
|
+
"""Components whose id, entrypoint, or effects match a substring."""
|
|
77
|
+
needle = query.lower()
|
|
78
|
+
hits: list[str] = []
|
|
79
|
+
for spec in read_specs(index_dir):
|
|
80
|
+
haystack = " ".join([spec.id, spec.entrypoint or "", " ".join(spec.effects)]).lower()
|
|
81
|
+
if needle in haystack:
|
|
82
|
+
line = f"{spec.id} [{spec.kind.value}]"
|
|
83
|
+
if spec.entrypoint:
|
|
84
|
+
line += f" ({spec.entrypoint})"
|
|
85
|
+
hits.append(line)
|
|
86
|
+
if not hits:
|
|
87
|
+
return f"no components match {query!r}"
|
|
88
|
+
return "\n".join(hits) + "\n"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def tool_entrypoints(index_dir: Path) -> str:
|
|
92
|
+
"""The repo's external surface: HTTP routes, CLI commands, tasks."""
|
|
93
|
+
entries = [s for s in read_specs(index_dir) if s.entrypoint]
|
|
94
|
+
if not entries:
|
|
95
|
+
return "no entrypoints detected"
|
|
96
|
+
entries.sort(key=lambda s: (s.entrypoint or "", s.id))
|
|
97
|
+
return "\n".join(f"{s.entrypoint} {s.id}" for s in entries) + "\n"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def tool_verify(index_dir: Path, repo: Path, component_id: str, candidate: str) -> str:
|
|
101
|
+
"""Contract-check a candidate implementation before proposing it."""
|
|
102
|
+
from cgir.verify import verify
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
result = verify(index_dir, component_id, candidate, repo)
|
|
106
|
+
except KeyError:
|
|
107
|
+
return f"unknown component: {component_id}"
|
|
108
|
+
lines = [f"contract: {'ok' if result.contract_ok else 'CHANGED'}"]
|
|
109
|
+
for name, values in result.drift.items():
|
|
110
|
+
lines.append(f" {name}: {values['old']} -> {values['new']}")
|
|
111
|
+
return "\n".join(lines) + "\n"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def create_server(index_dir: Path) -> Any:
|
|
115
|
+
"""Build the FastMCP server (requires the ``cgir[mcp]`` extra)."""
|
|
116
|
+
try:
|
|
117
|
+
from mcp.server.fastmcp import FastMCP
|
|
118
|
+
except ImportError as exc:
|
|
119
|
+
raise RuntimeError(
|
|
120
|
+
"Install cgir[mcp] to serve the index over MCP (adds the mcp package)"
|
|
121
|
+
) from exc
|
|
122
|
+
|
|
123
|
+
server = FastMCP("cgir")
|
|
124
|
+
|
|
125
|
+
@server.tool()
|
|
126
|
+
def stats() -> str:
|
|
127
|
+
"""Codebase structure report: kinds, purity, effects, hotspots."""
|
|
128
|
+
return tool_stats(index_dir)
|
|
129
|
+
|
|
130
|
+
@server.tool()
|
|
131
|
+
def component(component_id: str) -> str:
|
|
132
|
+
"""Get one ComponentSpec (contract: inputs/outputs/effects/calls) as JSON."""
|
|
133
|
+
return tool_component(index_dir, component_id)
|
|
134
|
+
|
|
135
|
+
@server.tool()
|
|
136
|
+
def flow(component_id: str, depth: int = 3) -> str:
|
|
137
|
+
"""Trace a component: upstream callers and downstream callees."""
|
|
138
|
+
return tool_flow(index_dir, component_id, depth)
|
|
139
|
+
|
|
140
|
+
@server.tool()
|
|
141
|
+
def impact(component_id: str) -> str:
|
|
142
|
+
"""Before editing a component, see its blast radius: which callers are affected,
|
|
143
|
+
which entrypoints are at risk, and exactly which tests to run."""
|
|
144
|
+
return tool_impact(index_dir, component_id)
|
|
145
|
+
|
|
146
|
+
@server.tool()
|
|
147
|
+
def impact_of_change(repo: str, component_id: str, candidate: str) -> str:
|
|
148
|
+
"""After drafting an edit, see its *narrowed* blast radius: the contract delta
|
|
149
|
+
your candidate causes and only the callers/entrypoints/tests that delta reaches."""
|
|
150
|
+
return tool_impact_of_change(index_dir, Path(repo), component_id, candidate)
|
|
151
|
+
|
|
152
|
+
@server.tool()
|
|
153
|
+
def pack(component_id: str, budget: int = 4000) -> str:
|
|
154
|
+
"""Minimal context bundle (spec, callee interfaces, callers) for editing a component."""
|
|
155
|
+
return tool_pack(index_dir, component_id, budget)
|
|
156
|
+
|
|
157
|
+
@server.tool()
|
|
158
|
+
def search(query: str) -> str:
|
|
159
|
+
"""Find components by id / entrypoint / effect substring."""
|
|
160
|
+
return tool_search(index_dir, query)
|
|
161
|
+
|
|
162
|
+
@server.tool()
|
|
163
|
+
def entrypoints() -> str:
|
|
164
|
+
"""The repo's external surface: HTTP routes, CLI commands, tasks."""
|
|
165
|
+
return tool_entrypoints(index_dir)
|
|
166
|
+
|
|
167
|
+
@server.tool()
|
|
168
|
+
def verify(repo: str, component_id: str, candidate: str) -> str:
|
|
169
|
+
"""Contract-check a candidate implementation of a component before proposing it."""
|
|
170
|
+
return tool_verify(index_dir, Path(repo), component_id, candidate)
|
|
171
|
+
|
|
172
|
+
return server
|
cgir/api/server.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""FastAPI surface over the scan pipeline.
|
|
2
|
+
|
|
3
|
+
Only available with ``pip install cgir[api]``. Per ``docs/roadmap.md``,
|
|
4
|
+
the API is a thin surface over the same driver the CLI uses
|
|
5
|
+
(:func:`cgir.pipeline.scan_repo`) — no separate pipeline.
|
|
6
|
+
|
|
7
|
+
The app is bound to an index directory (default ``.cgir``); a successful
|
|
8
|
+
``POST /scan`` re-points it at the freshly written index. Reads against a
|
|
9
|
+
missing index answer 409 rather than 404 so "no scan yet" is
|
|
10
|
+
distinguishable from "unknown component".
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from pydantic import BaseModel
|
|
20
|
+
|
|
21
|
+
from cgir.export.json_export import read_specs
|
|
22
|
+
from cgir.pipeline import scan_repo
|
|
23
|
+
from cgir.regenerate import regenerate as run_regenerate
|
|
24
|
+
from cgir.report.stats import compute_stats
|
|
25
|
+
from cgir.trace import TraceMap
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ScanRequest(BaseModel):
|
|
29
|
+
repo: str
|
|
30
|
+
out: str | None = None
|
|
31
|
+
exclude: list[str] = []
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class RegenerateRequest(BaseModel):
|
|
35
|
+
component_id: str
|
|
36
|
+
lang: str = "typescript"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def create_app(index_dir: Path | None = None) -> Any:
|
|
40
|
+
try:
|
|
41
|
+
from fastapi import FastAPI, HTTPException
|
|
42
|
+
except ImportError as exc:
|
|
43
|
+
raise RuntimeError("Install cgir[api] to use the HTTP surface") from exc
|
|
44
|
+
|
|
45
|
+
app = FastAPI(title="CGIR", version="0.1.0")
|
|
46
|
+
state: dict[str, Path] = {"index_dir": index_dir or Path(".cgir")}
|
|
47
|
+
|
|
48
|
+
def _index() -> Path:
|
|
49
|
+
current = state["index_dir"]
|
|
50
|
+
if not (current / "components").is_dir():
|
|
51
|
+
raise HTTPException(status_code=409, detail=f"No index at {current}; POST /scan first")
|
|
52
|
+
return current
|
|
53
|
+
|
|
54
|
+
def _spec_path(component_id: str) -> Path:
|
|
55
|
+
path = _index() / "components" / f"{component_id}.json"
|
|
56
|
+
if not path.exists():
|
|
57
|
+
raise HTTPException(status_code=404, detail=f"Unknown component: {component_id}")
|
|
58
|
+
return path
|
|
59
|
+
|
|
60
|
+
@app.post("/scan")
|
|
61
|
+
def scan(req: ScanRequest) -> dict[str, Any]:
|
|
62
|
+
repo = Path(req.repo)
|
|
63
|
+
if not repo.is_dir():
|
|
64
|
+
raise HTTPException(status_code=400, detail=f"Not a directory: {repo}")
|
|
65
|
+
result = scan_repo(repo, Path(req.out) if req.out else None, req.exclude)
|
|
66
|
+
state["index_dir"] = result.out_dir
|
|
67
|
+
return {"out_dir": str(result.out_dir), "components": len(result.specs)}
|
|
68
|
+
|
|
69
|
+
@app.get("/components")
|
|
70
|
+
def list_components() -> list[dict[str, Any]]:
|
|
71
|
+
index_path = _index() / "components_index.json"
|
|
72
|
+
entries: list[dict[str, Any]] = json.loads(index_path.read_text())
|
|
73
|
+
return entries
|
|
74
|
+
|
|
75
|
+
@app.get("/components/{component_id}")
|
|
76
|
+
def get_component(component_id: str) -> dict[str, Any]:
|
|
77
|
+
payload: dict[str, Any] = json.loads(_spec_path(component_id).read_text())
|
|
78
|
+
return payload
|
|
79
|
+
|
|
80
|
+
@app.get("/trace")
|
|
81
|
+
def trace(path: str, line: int) -> dict[str, str]:
|
|
82
|
+
trace_path = _index() / "trace_map.json"
|
|
83
|
+
if not trace_path.exists():
|
|
84
|
+
raise HTTPException(status_code=409, detail=f"No trace map at {trace_path}")
|
|
85
|
+
hit = TraceMap.read(trace_path).lookup(path, line)
|
|
86
|
+
if hit is None:
|
|
87
|
+
raise HTTPException(status_code=404, detail=f"No component covers {path}:{line}")
|
|
88
|
+
return {"component_id": hit}
|
|
89
|
+
|
|
90
|
+
@app.post("/regenerate")
|
|
91
|
+
def regenerate(req: RegenerateRequest) -> dict[str, str]:
|
|
92
|
+
from cgir.ir.component_spec import ComponentSpec
|
|
93
|
+
|
|
94
|
+
spec = ComponentSpec.from_dict(json.loads(_spec_path(req.component_id).read_text()))
|
|
95
|
+
result = run_regenerate(spec, req.lang)
|
|
96
|
+
return {
|
|
97
|
+
"component_id": result.spec_id,
|
|
98
|
+
"lang": result.target_language,
|
|
99
|
+
"prompt": result.prompt,
|
|
100
|
+
"code": result.code,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
@app.get("/stats")
|
|
104
|
+
def stats() -> dict[str, Any]:
|
|
105
|
+
return compute_stats(read_specs(_index()))
|
|
106
|
+
|
|
107
|
+
return app
|