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
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Write the repo graph + ComponentSpec index to disk."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from cgir.ir.component_spec import ComponentSpec
|
|
9
|
+
from cgir.ir.graph import RepoGraph
|
|
10
|
+
from cgir.manifest import write_manifest
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def read_specs(index_dir: Path) -> list[ComponentSpec]:
|
|
14
|
+
"""Load every ComponentSpec from an existing index directory."""
|
|
15
|
+
components_dir = index_dir / "components"
|
|
16
|
+
return [
|
|
17
|
+
ComponentSpec.from_dict(json.loads(p.read_text()))
|
|
18
|
+
for p in sorted(components_dir.glob("*.json"))
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def write_index(out_dir: Path, graph: RepoGraph, specs: list[ComponentSpec]) -> None:
|
|
23
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
(out_dir / "repo_graph.json").write_text(
|
|
25
|
+
json.dumps(graph.to_jsonable(), indent=2, sort_keys=True)
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
components_dir = out_dir / "components"
|
|
29
|
+
components_dir.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
for spec in specs:
|
|
31
|
+
spec.validate()
|
|
32
|
+
(components_dir / f"{spec.id}.json").write_text(spec.to_json())
|
|
33
|
+
|
|
34
|
+
index = [{"id": spec.id, "kind": spec.kind.value, "trace": spec.trace} for spec in specs]
|
|
35
|
+
(out_dir / "components_index.json").write_text(json.dumps(index, indent=2, sort_keys=True))
|
|
36
|
+
|
|
37
|
+
write_manifest(out_dir, component_count=len(specs))
|
cgir/export/mermaid.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Mermaid call-graph rendering from ComponentSpecs.
|
|
2
|
+
|
|
3
|
+
Produces ``flowchart LR`` text suitable for embedding in Markdown (GitHub,
|
|
4
|
+
Obsidian, mermaid.live). One node per component, one ``subgraph`` per
|
|
5
|
+
source file, one edge per resolved intra-repo call; nodes are colored by
|
|
6
|
+
:class:`~cgir.ir.component_spec.ComponentKind`.
|
|
7
|
+
|
|
8
|
+
Calls to components outside the spec set (stdlib, third-party) are skipped
|
|
9
|
+
rather than rendered as dangling nodes — the diagram shows *this* repo.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
|
|
16
|
+
from cgir.ir.component_spec import ComponentSpec
|
|
17
|
+
|
|
18
|
+
_KIND_STYLES: dict[str, str] = {
|
|
19
|
+
"pure_function": "fill:#c6f6d5,stroke:#2f855a,color:#1a202c",
|
|
20
|
+
"orchestrator": "fill:#bee3f8,stroke:#2b6cb0,color:#1a202c",
|
|
21
|
+
"state_transformer": "fill:#feebc8,stroke:#c05621,color:#1a202c",
|
|
22
|
+
"effect_adapter": "fill:#fed7d7,stroke:#c53030,color:#1a202c",
|
|
23
|
+
"unknown": "fill:#e2e8f0,stroke:#4a5568,color:#1a202c",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def render_call_graph(specs: list[ComponentSpec]) -> str:
|
|
28
|
+
"""Render the component call graph as Mermaid flowchart text."""
|
|
29
|
+
lines = ["flowchart LR"]
|
|
30
|
+
known_ids = {s.id for s in specs}
|
|
31
|
+
|
|
32
|
+
by_file: dict[str, list[ComponentSpec]] = {}
|
|
33
|
+
for spec in specs:
|
|
34
|
+
file = spec.trace[0].rsplit(":", 1)[0] if spec.trace else "(unknown)"
|
|
35
|
+
by_file.setdefault(file, []).append(spec)
|
|
36
|
+
|
|
37
|
+
for file, group in sorted(by_file.items()):
|
|
38
|
+
lines.append(f' subgraph {_mermaid_id(file)}["{file}"]')
|
|
39
|
+
for spec in group:
|
|
40
|
+
lines.append(f' {_mermaid_id(spec.id)}["{spec.id}"]')
|
|
41
|
+
lines.append(" end")
|
|
42
|
+
|
|
43
|
+
for spec in specs:
|
|
44
|
+
for callee in spec.calls:
|
|
45
|
+
if callee in known_ids:
|
|
46
|
+
lines.append(f" {_mermaid_id(spec.id)} --> {_mermaid_id(callee)}")
|
|
47
|
+
|
|
48
|
+
for kind in sorted({s.kind.value for s in specs}):
|
|
49
|
+
lines.append(f" classDef {kind} {_KIND_STYLES[kind]}")
|
|
50
|
+
for spec in specs:
|
|
51
|
+
lines.append(f" class {_mermaid_id(spec.id)} {spec.kind.value}")
|
|
52
|
+
|
|
53
|
+
return "\n".join(lines) + "\n"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _mermaid_id(raw: str) -> str:
|
|
57
|
+
return re.sub(r"[^0-9A-Za-z_]", "_", raw)
|
cgir/export/neo4j.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Neo4j export — milestone: P2-neo4j."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from cgir.ir.graph import RepoGraph
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def write(out_dir: Path, graph: RepoGraph) -> None:
|
|
11
|
+
raise NotImplementedError("milestone: P2-neo4j")
|
cgir/hooks.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""The git pre-commit seatbelt.
|
|
2
|
+
|
|
3
|
+
A local, deterministic gate on your own — and your agent's — commits.
|
|
4
|
+
On commit, ``run_check`` scans the last committed tree (``HEAD``) against
|
|
5
|
+
the *staged* tree (``git write-tree``), diffs the contracts, and reports:
|
|
6
|
+
|
|
7
|
+
* **violations** — the ``--fail-on`` drift rules that trip (defaults to the
|
|
8
|
+
evidence-based low-noise set from ``docs/gate-noise.md``);
|
|
9
|
+
* **tests to run** — the union of :func:`compute_typed_impact` over every
|
|
10
|
+
changed component, narrowed by *its* contract delta, so a body-only
|
|
11
|
+
refactor asks for nothing downstream.
|
|
12
|
+
|
|
13
|
+
Design choices that matter for a hook:
|
|
14
|
+
|
|
15
|
+
* **Scans the staged tree, not the working tree** — it checks exactly what
|
|
16
|
+
will be committed (``git write-tree`` materialises the index as a tree).
|
|
17
|
+
* **Fail-open on internal error** — a bug in CGIR must never block your
|
|
18
|
+
commit; only a real contract violation does.
|
|
19
|
+
* **Skips non-source commits** — no scan when nothing with a supported
|
|
20
|
+
extension is staged.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import subprocess
|
|
26
|
+
import tempfile
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from cgir.ir.component_spec import ComponentSpec
|
|
31
|
+
from cgir.languages import ADAPTERS
|
|
32
|
+
from cgir.pipeline import scan_repo
|
|
33
|
+
from cgir.report.diff import compute_diff, render_diff, violations
|
|
34
|
+
from cgir.report.impact import compute_typed_impact
|
|
35
|
+
|
|
36
|
+
# The low-noise default from the real-history noise measurement: fail only
|
|
37
|
+
# when something starts — or silently stops — reaching the outside world.
|
|
38
|
+
DEFAULT_FAIL_ON: tuple[str, ...] = (
|
|
39
|
+
"effect-gain:net",
|
|
40
|
+
"effect-gain:fs",
|
|
41
|
+
"effect-gain:db",
|
|
42
|
+
"effect-loss:net",
|
|
43
|
+
"effect-loss:fs",
|
|
44
|
+
"effect-loss:db",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
_SUPPORTED_EXTS: frozenset[str] = frozenset(
|
|
48
|
+
ext for adapter in ADAPTERS.values() for ext in adapter.file_extensions
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
_HOOK_TEMPLATE = """\
|
|
52
|
+
#!/bin/sh
|
|
53
|
+
# CGIR contract seatbelt — installed by `cgir hook install`. Delete to remove.
|
|
54
|
+
if ! command -v cgir >/dev/null 2>&1; then
|
|
55
|
+
echo "cgir not on PATH; skipping contract check" >&2
|
|
56
|
+
exit 0
|
|
57
|
+
fi
|
|
58
|
+
exec cgir hook run {flags}
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class HookResult:
|
|
64
|
+
checked: bool
|
|
65
|
+
violations: list[str] = field(default_factory=list)
|
|
66
|
+
changed: list[str] = field(default_factory=list)
|
|
67
|
+
tests: list[str] = field(default_factory=list)
|
|
68
|
+
diff_text: str = ""
|
|
69
|
+
error: str | None = None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _git(repo: Path, *args: str) -> str:
|
|
73
|
+
return subprocess.check_output(["git", "-C", str(repo), *args], text=True).strip()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _staged_supported(repo: Path) -> bool:
|
|
77
|
+
names = _git(repo, "diff", "--cached", "--name-only").splitlines()
|
|
78
|
+
return any(Path(n).suffix in _SUPPORTED_EXTS for n in names)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _specs_of_tree(repo: Path, tree: str) -> list[ComponentSpec]:
|
|
82
|
+
"""Scan the content of a git tree-ish out-of-tree."""
|
|
83
|
+
with tempfile.TemporaryDirectory() as td:
|
|
84
|
+
src = Path(td) / "src"
|
|
85
|
+
src.mkdir()
|
|
86
|
+
archive = subprocess.check_output(["git", "-C", str(repo), "archive", tree])
|
|
87
|
+
subprocess.run(["tar", "-x", "-C", str(src)], input=archive, check=True)
|
|
88
|
+
with tempfile.TemporaryDirectory() as od:
|
|
89
|
+
return scan_repo(src, out=Path(od)).specs
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def run_check(repo: Path, fail_on: list[str] | None = None) -> HookResult:
|
|
93
|
+
"""Contract-check the staged tree against HEAD. Fail-open on any error."""
|
|
94
|
+
rules = list(fail_on) if fail_on is not None else list(DEFAULT_FAIL_ON)
|
|
95
|
+
try:
|
|
96
|
+
if not _staged_supported(repo):
|
|
97
|
+
return HookResult(checked=False)
|
|
98
|
+
staged_tree = _git(repo, "write-tree")
|
|
99
|
+
try:
|
|
100
|
+
head_tree = _git(repo, "rev-parse", "HEAD^{tree}")
|
|
101
|
+
except subprocess.CalledProcessError:
|
|
102
|
+
head_tree = None # unborn branch — the initial commit
|
|
103
|
+
if head_tree == staged_tree:
|
|
104
|
+
return HookResult(checked=False)
|
|
105
|
+
|
|
106
|
+
base_specs = _specs_of_tree(repo, head_tree) if head_tree else []
|
|
107
|
+
head_specs = _specs_of_tree(repo, staged_tree)
|
|
108
|
+
except Exception as exc: # never block a commit because of our own bug
|
|
109
|
+
return HookResult(checked=False, error=f"{type(exc).__name__}: {exc}")
|
|
110
|
+
|
|
111
|
+
diff = compute_diff(base_specs, head_specs)
|
|
112
|
+
found = violations(diff, rules)
|
|
113
|
+
changed = [c["id"] for c in diff["changed"]]
|
|
114
|
+
|
|
115
|
+
tests: set[str] = set()
|
|
116
|
+
for change in diff["changed"]:
|
|
117
|
+
typed = compute_typed_impact(head_specs, change["id"], list(change["fields"].keys()))
|
|
118
|
+
tests |= set(typed["tests"])
|
|
119
|
+
|
|
120
|
+
return HookResult(
|
|
121
|
+
checked=True,
|
|
122
|
+
violations=found,
|
|
123
|
+
changed=changed,
|
|
124
|
+
tests=sorted(tests),
|
|
125
|
+
diff_text=render_diff(diff),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def render_hook(result: HookResult) -> str:
|
|
130
|
+
if result.error:
|
|
131
|
+
return f"cgir: skipped contract check ({result.error})\n"
|
|
132
|
+
if not result.checked:
|
|
133
|
+
return ""
|
|
134
|
+
lines = ["cgir contract seatbelt"]
|
|
135
|
+
if result.changed:
|
|
136
|
+
lines.append(f" contract changes in {len(result.changed)} component(s)")
|
|
137
|
+
if result.tests:
|
|
138
|
+
lines.append(f" tests to run ({len(result.tests)}):")
|
|
139
|
+
lines.extend(f" • {t}" for t in result.tests)
|
|
140
|
+
if result.violations:
|
|
141
|
+
lines.append("")
|
|
142
|
+
lines.append(f" ❌ blocked — {len(result.violations)} drift violation(s):")
|
|
143
|
+
lines.extend(f" ! {v}" for v in result.violations)
|
|
144
|
+
lines.append(" (bypass once with `git commit --no-verify`)")
|
|
145
|
+
else:
|
|
146
|
+
lines.append(" ✅ no blocking contract drift")
|
|
147
|
+
return "\n".join(lines) + "\n"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _hooks_dir(repo: Path) -> Path:
|
|
151
|
+
return Path(_git(repo, "rev-parse", "--git-path", "hooks"))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def install(repo: Path, fail_on: list[str] | None = None, force: bool = False) -> Path:
|
|
155
|
+
"""Write ``.git/hooks/pre-commit`` invoking ``cgir hook run``."""
|
|
156
|
+
rules = list(fail_on) if fail_on is not None else list(DEFAULT_FAIL_ON)
|
|
157
|
+
hooks = _hooks_dir(repo)
|
|
158
|
+
if not hooks.is_absolute():
|
|
159
|
+
hooks = repo / hooks
|
|
160
|
+
hooks.mkdir(parents=True, exist_ok=True)
|
|
161
|
+
path = hooks / "pre-commit"
|
|
162
|
+
if path.exists() and not force:
|
|
163
|
+
raise FileExistsError(f"{path} exists; pass force=True to overwrite")
|
|
164
|
+
flags = " ".join(f"--fail-on {r}" for r in rules)
|
|
165
|
+
path.write_text(_HOOK_TEMPLATE.format(flags=flags))
|
|
166
|
+
path.chmod(0o755)
|
|
167
|
+
return path
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def uninstall(repo: Path) -> bool:
|
|
171
|
+
"""Remove the hook if it is ours. Returns whether anything was removed."""
|
|
172
|
+
hooks = _hooks_dir(repo)
|
|
173
|
+
if not hooks.is_absolute():
|
|
174
|
+
hooks = repo / hooks
|
|
175
|
+
path = hooks / "pre-commit"
|
|
176
|
+
if path.exists() and "cgir hook run" in path.read_text():
|
|
177
|
+
path.unlink()
|
|
178
|
+
return True
|
|
179
|
+
return False
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
__all__ = [
|
|
183
|
+
"DEFAULT_FAIL_ON",
|
|
184
|
+
"HookResult",
|
|
185
|
+
"install",
|
|
186
|
+
"render_hook",
|
|
187
|
+
"run_check",
|
|
188
|
+
"uninstall",
|
|
189
|
+
]
|
cgir/ir/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Internal IR: nodes, edges, RepoGraph, and ComponentSpec."""
|
|
2
|
+
|
|
3
|
+
from cgir.ir.component_spec import ComponentKind, ComponentSpec
|
|
4
|
+
from cgir.ir.edges import Edge, EdgeKind
|
|
5
|
+
from cgir.ir.graph import RepoGraph
|
|
6
|
+
from cgir.ir.nodes import Node, NodeKind
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"ComponentKind",
|
|
10
|
+
"ComponentSpec",
|
|
11
|
+
"Edge",
|
|
12
|
+
"EdgeKind",
|
|
13
|
+
"Node",
|
|
14
|
+
"NodeKind",
|
|
15
|
+
"RepoGraph",
|
|
16
|
+
]
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""ComponentSpec — the agent-facing contract (Code-IR.md §Data model)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import asdict, dataclass, field
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
from functools import lru_cache
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from jsonschema import Draft202012Validator
|
|
12
|
+
|
|
13
|
+
COMPONENT_SPEC_SCHEMA: dict[str, Any] = {
|
|
14
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
15
|
+
"$id": "https://cgir.dev/schemas/component_spec.schema.json",
|
|
16
|
+
"title": "ComponentSpec",
|
|
17
|
+
"type": "object",
|
|
18
|
+
"required": ["id", "kind", "inputs", "outputs", "effects", "calls", "trace"],
|
|
19
|
+
"properties": {
|
|
20
|
+
"id": {"type": "string"},
|
|
21
|
+
"kind": {
|
|
22
|
+
"enum": [
|
|
23
|
+
"pure_function",
|
|
24
|
+
"state_transformer",
|
|
25
|
+
"effect_adapter",
|
|
26
|
+
"orchestrator",
|
|
27
|
+
"unknown",
|
|
28
|
+
]
|
|
29
|
+
},
|
|
30
|
+
"language": {"type": "string"},
|
|
31
|
+
"signature": {"type": "string"},
|
|
32
|
+
"entrypoint": {"type": "string"},
|
|
33
|
+
"doc": {"type": "string"},
|
|
34
|
+
"raises": {"type": "array", "items": {"type": "string"}},
|
|
35
|
+
"covered_by": {"type": "array", "items": {"type": "string"}},
|
|
36
|
+
"inputs": {"type": "array", "items": {"type": "string"}},
|
|
37
|
+
"outputs": {"type": "array", "items": {"type": "string"}},
|
|
38
|
+
"effects": {"type": "array", "items": {"type": "string"}},
|
|
39
|
+
"calls": {"type": "array", "items": {"type": "string"}},
|
|
40
|
+
"constructs": {"type": "array", "items": {"type": "string"}},
|
|
41
|
+
"reads": {"type": "array", "items": {"type": "string"}},
|
|
42
|
+
"writes": {"type": "array", "items": {"type": "string"}},
|
|
43
|
+
"purity": {"type": "number", "minimum": 0, "maximum": 1},
|
|
44
|
+
"algorithm": {"type": "array", "items": {"type": "string"}},
|
|
45
|
+
"trace": {"type": "array", "items": {"type": "string"}},
|
|
46
|
+
},
|
|
47
|
+
"additionalProperties": False,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ComponentKind(StrEnum):
|
|
52
|
+
pure_function = "pure_function"
|
|
53
|
+
state_transformer = "state_transformer"
|
|
54
|
+
effect_adapter = "effect_adapter"
|
|
55
|
+
orchestrator = "orchestrator"
|
|
56
|
+
unknown = "unknown"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(slots=True)
|
|
60
|
+
class ComponentSpec:
|
|
61
|
+
id: str
|
|
62
|
+
kind: ComponentKind
|
|
63
|
+
inputs: list[str] = field(default_factory=list)
|
|
64
|
+
outputs: list[str] = field(default_factory=list)
|
|
65
|
+
effects: list[str] = field(default_factory=list)
|
|
66
|
+
calls: list[str] = field(default_factory=list)
|
|
67
|
+
constructs: list[str] = field(default_factory=list)
|
|
68
|
+
trace: list[str] = field(default_factory=list)
|
|
69
|
+
language: str | None = None
|
|
70
|
+
signature: str | None = None
|
|
71
|
+
entrypoint: str | None = None
|
|
72
|
+
doc: str | None = None
|
|
73
|
+
raises: list[str] = field(default_factory=list)
|
|
74
|
+
covered_by: list[str] = field(default_factory=list)
|
|
75
|
+
reads: list[str] = field(default_factory=list)
|
|
76
|
+
writes: list[str] = field(default_factory=list)
|
|
77
|
+
purity: float | None = None
|
|
78
|
+
algorithm: list[str] = field(default_factory=list)
|
|
79
|
+
|
|
80
|
+
def to_dict(self) -> dict[str, Any]:
|
|
81
|
+
data = asdict(self)
|
|
82
|
+
data["kind"] = self.kind.value
|
|
83
|
+
return {k: v for k, v in data.items() if v is not None}
|
|
84
|
+
|
|
85
|
+
def to_json(self, indent: int | None = 2) -> str:
|
|
86
|
+
return json.dumps(self.to_dict(), indent=indent, sort_keys=True)
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def from_dict(cls, data: dict[str, Any]) -> ComponentSpec:
|
|
90
|
+
payload = dict(data)
|
|
91
|
+
payload["kind"] = ComponentKind(payload["kind"])
|
|
92
|
+
return cls(**payload)
|
|
93
|
+
|
|
94
|
+
def validate(self) -> None:
|
|
95
|
+
_validator().validate(self.to_dict())
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@lru_cache(maxsize=1)
|
|
99
|
+
def _validator() -> Draft202012Validator:
|
|
100
|
+
return Draft202012Validator(COMPONENT_SPEC_SCHEMA)
|
cgir/ir/edges.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Edge vocabulary for the CGIR graph (Code-IR.md §Data model)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class EdgeKind(StrEnum):
|
|
11
|
+
CONTAINS = "CONTAINS"
|
|
12
|
+
IMPORTS = "IMPORTS"
|
|
13
|
+
CALLS = "CALLS"
|
|
14
|
+
READS = "READS"
|
|
15
|
+
WRITES = "WRITES"
|
|
16
|
+
MUTATES = "MUTATES"
|
|
17
|
+
RETURNS = "RETURNS"
|
|
18
|
+
THROWS = "THROWS"
|
|
19
|
+
FLOWS_TO = "FLOWS_TO"
|
|
20
|
+
CONTROLS = "CONTROLS"
|
|
21
|
+
DEPENDS_ON = "DEPENDS_ON"
|
|
22
|
+
TRACE_OF = "TRACE_OF"
|
|
23
|
+
REGENERATED_AS = "REGENERATED_AS"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class Edge:
|
|
28
|
+
src: str
|
|
29
|
+
dst: str
|
|
30
|
+
kind: EdgeKind
|
|
31
|
+
attrs: dict[str, Any] = field(default_factory=dict)
|
cgir/ir/graph.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""RepoGraph: a thin wrapper over networkx.MultiDiGraph with CGIR semantics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import networkx as nx
|
|
9
|
+
|
|
10
|
+
from cgir.ir.edges import Edge, EdgeKind
|
|
11
|
+
from cgir.ir.nodes import Node, NodeKind
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RepoGraph:
|
|
15
|
+
def __init__(self) -> None:
|
|
16
|
+
self._g: nx.MultiDiGraph = nx.MultiDiGraph()
|
|
17
|
+
|
|
18
|
+
def add_node(self, node: Node) -> None:
|
|
19
|
+
self._g.add_node(
|
|
20
|
+
node.id,
|
|
21
|
+
kind=node.kind,
|
|
22
|
+
name=node.name,
|
|
23
|
+
path=node.path,
|
|
24
|
+
start_line=node.start_line,
|
|
25
|
+
end_line=node.end_line,
|
|
26
|
+
attrs=dict(node.attrs),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
def add_edge(self, edge: Edge) -> None:
|
|
30
|
+
self._g.add_edge(
|
|
31
|
+
edge.src, edge.dst, key=edge.kind.value, kind=edge.kind, attrs=dict(edge.attrs)
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def has_node(self, node_id: str) -> bool:
|
|
35
|
+
return node_id in self._g
|
|
36
|
+
|
|
37
|
+
def get_node(self, node_id: str) -> Node:
|
|
38
|
+
data = self._g.nodes[node_id]
|
|
39
|
+
return Node(
|
|
40
|
+
id=node_id,
|
|
41
|
+
kind=data["kind"],
|
|
42
|
+
name=data["name"],
|
|
43
|
+
path=data.get("path"),
|
|
44
|
+
start_line=data.get("start_line"),
|
|
45
|
+
end_line=data.get("end_line"),
|
|
46
|
+
attrs=dict(data.get("attrs") or {}),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def nodes(self, kind: NodeKind | None = None) -> Iterator[Node]:
|
|
50
|
+
for node_id, data in self._g.nodes(data=True):
|
|
51
|
+
if kind is None or data["kind"] == kind:
|
|
52
|
+
yield Node(
|
|
53
|
+
id=node_id,
|
|
54
|
+
kind=data["kind"],
|
|
55
|
+
name=data["name"],
|
|
56
|
+
path=data.get("path"),
|
|
57
|
+
start_line=data.get("start_line"),
|
|
58
|
+
end_line=data.get("end_line"),
|
|
59
|
+
attrs=dict(data.get("attrs") or {}),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def out_edges(self, node_id: str, kind: EdgeKind | None = None) -> Iterator[Edge]:
|
|
63
|
+
for src, dst, data in self._g.out_edges(node_id, data=True):
|
|
64
|
+
if kind is None or data["kind"] == kind:
|
|
65
|
+
yield Edge(src=src, dst=dst, kind=data["kind"], attrs=dict(data.get("attrs") or {}))
|
|
66
|
+
|
|
67
|
+
def in_edges(self, node_id: str, kind: EdgeKind | None = None) -> Iterator[Edge]:
|
|
68
|
+
for src, dst, data in self._g.in_edges(node_id, data=True):
|
|
69
|
+
if kind is None or data["kind"] == kind:
|
|
70
|
+
yield Edge(src=src, dst=dst, kind=data["kind"], attrs=dict(data.get("attrs") or {}))
|
|
71
|
+
|
|
72
|
+
def children(self, node_id: str, kind: NodeKind | None = None) -> Iterator[Node]:
|
|
73
|
+
for edge in self.out_edges(node_id, EdgeKind.CONTAINS):
|
|
74
|
+
child = self.get_node(edge.dst)
|
|
75
|
+
if kind is None or child.kind == kind:
|
|
76
|
+
yield child
|
|
77
|
+
|
|
78
|
+
def __len__(self) -> int:
|
|
79
|
+
return int(self._g.number_of_nodes())
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def from_jsonable(cls, data: dict[str, Any]) -> RepoGraph:
|
|
83
|
+
"""Inverse of :meth:`to_jsonable` — rebuild a graph from its JSON dump."""
|
|
84
|
+
graph = cls()
|
|
85
|
+
for n in data.get("nodes", []):
|
|
86
|
+
graph.add_node(
|
|
87
|
+
Node(
|
|
88
|
+
id=n["id"],
|
|
89
|
+
kind=NodeKind(n["kind"]),
|
|
90
|
+
name=n["name"],
|
|
91
|
+
path=n.get("path"),
|
|
92
|
+
start_line=n.get("start_line"),
|
|
93
|
+
end_line=n.get("end_line"),
|
|
94
|
+
attrs=dict(n.get("attrs") or {}),
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
for e in data.get("edges", []):
|
|
98
|
+
graph.add_edge(
|
|
99
|
+
Edge(
|
|
100
|
+
src=e["src"],
|
|
101
|
+
dst=e["dst"],
|
|
102
|
+
kind=EdgeKind(e["kind"]),
|
|
103
|
+
attrs=dict(e.get("attrs") or {}),
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
return graph
|
|
107
|
+
|
|
108
|
+
def to_jsonable(self) -> dict[str, Any]:
|
|
109
|
+
nodes = []
|
|
110
|
+
for node in self.nodes():
|
|
111
|
+
nodes.append(
|
|
112
|
+
{
|
|
113
|
+
"id": node.id,
|
|
114
|
+
"kind": node.kind.value,
|
|
115
|
+
"name": node.name,
|
|
116
|
+
"path": node.path,
|
|
117
|
+
"start_line": node.start_line,
|
|
118
|
+
"end_line": node.end_line,
|
|
119
|
+
"attrs": node.attrs,
|
|
120
|
+
}
|
|
121
|
+
)
|
|
122
|
+
edges = []
|
|
123
|
+
for src, dst, data in self._g.edges(data=True):
|
|
124
|
+
edges.append(
|
|
125
|
+
{
|
|
126
|
+
"src": src,
|
|
127
|
+
"dst": dst,
|
|
128
|
+
"kind": data["kind"].value,
|
|
129
|
+
"attrs": data.get("attrs") or {},
|
|
130
|
+
}
|
|
131
|
+
)
|
|
132
|
+
return {"nodes": nodes, "edges": edges}
|
cgir/ir/nodes.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Node vocabulary for the CGIR graph (Code-IR.md §Data model)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class NodeKind(StrEnum):
|
|
11
|
+
Repository = "Repository"
|
|
12
|
+
File = "File"
|
|
13
|
+
Module = "Module"
|
|
14
|
+
Class = "Class"
|
|
15
|
+
Function = "Function"
|
|
16
|
+
Method = "Method"
|
|
17
|
+
Parameter = "Parameter"
|
|
18
|
+
Variable = "Variable"
|
|
19
|
+
Assignment = "Assignment"
|
|
20
|
+
Expr = "Expr"
|
|
21
|
+
Statement = "Statement"
|
|
22
|
+
Branch = "Branch"
|
|
23
|
+
Loop = "Loop"
|
|
24
|
+
Return = "Return"
|
|
25
|
+
Import = "Import"
|
|
26
|
+
Effect = "Effect"
|
|
27
|
+
Test = "Test"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class Node:
|
|
32
|
+
id: str
|
|
33
|
+
kind: NodeKind
|
|
34
|
+
name: str
|
|
35
|
+
path: str | None = None
|
|
36
|
+
start_line: int | None = None
|
|
37
|
+
end_line: int | None = None
|
|
38
|
+
attrs: dict[str, Any] = field(default_factory=dict)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Language adapters — the per-language seam over a shared analysis pipeline.
|
|
2
|
+
|
|
3
|
+
Add a language by implementing :class:`LanguageAdapter`; register it in
|
|
4
|
+
``registry.ADAPTERS`` and everything downstream (stats, viz, flow, diff,
|
|
5
|
+
pack, lint, verify) works on it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from cgir.languages.base import CallSite, LanguageAdapter
|
|
11
|
+
from cgir.languages.cache import SourceCache
|
|
12
|
+
from cgir.languages.python import PythonAdapter
|
|
13
|
+
from cgir.languages.registry import ADAPTERS, DEFAULT_ADAPTER, adapter_for_extension
|
|
14
|
+
from cgir.languages.typescript import TypeScriptAdapter
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"ADAPTERS",
|
|
18
|
+
"DEFAULT_ADAPTER",
|
|
19
|
+
"CallSite",
|
|
20
|
+
"LanguageAdapter",
|
|
21
|
+
"PythonAdapter",
|
|
22
|
+
"SourceCache",
|
|
23
|
+
"TypeScriptAdapter",
|
|
24
|
+
"adapter_for_extension",
|
|
25
|
+
]
|