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/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))
@@ -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}
@@ -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