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/stats.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Codebase structure report over ComponentSpecs.
|
|
2
|
+
|
|
3
|
+
``compute_stats`` is a pure function from ``list[ComponentSpec]`` to a
|
|
4
|
+
JSON-able dict — it drives both the ``cgir stats`` text output and its
|
|
5
|
+
``--json`` mode. Purity buckets follow the scoring rubric in
|
|
6
|
+
:mod:`cgir.analyses.purity`: 1.0 pure, 0.7 effect-tainted (calls into
|
|
7
|
+
effectful code only), everything below direct-impure.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections import Counter
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from cgir.analyses.effects import IMPURE_EFFECT_TAGS
|
|
16
|
+
from cgir.ir.component_spec import ComponentSpec
|
|
17
|
+
|
|
18
|
+
TOP_N = 10
|
|
19
|
+
_IMPURE = set(IMPURE_EFFECT_TAGS)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def compute_stats(specs: list[ComponentSpec], top_n: int = TOP_N) -> dict[str, Any]:
|
|
23
|
+
known_ids = {s.id for s in specs}
|
|
24
|
+
files = {s.trace[0].rsplit(":", 1)[0] for s in specs if s.trace}
|
|
25
|
+
|
|
26
|
+
kinds = Counter(s.kind.value for s in specs)
|
|
27
|
+
effects = Counter(tag for s in specs for tag in s.effects)
|
|
28
|
+
|
|
29
|
+
internal_callers: Counter[str] = Counter()
|
|
30
|
+
external_callers: Counter[str] = Counter()
|
|
31
|
+
constructed: Counter[str] = Counter()
|
|
32
|
+
for s in specs:
|
|
33
|
+
for callee in set(s.calls):
|
|
34
|
+
if callee in known_ids:
|
|
35
|
+
internal_callers[callee] += 1
|
|
36
|
+
else:
|
|
37
|
+
external_callers[callee] += 1
|
|
38
|
+
for type_name in set(s.constructs):
|
|
39
|
+
constructed[type_name] += 1
|
|
40
|
+
|
|
41
|
+
pure = sum(1 for s in specs if s.purity == 1.0)
|
|
42
|
+
tainted = sum(1 for s in specs if s.purity == 0.7)
|
|
43
|
+
impure = len(specs) - pure - tainted
|
|
44
|
+
mean = sum(s.purity or 0.0 for s in specs) / len(specs) if specs else 0.0
|
|
45
|
+
|
|
46
|
+
fan_out = sorted(specs, key=lambda s: (-len(s.calls), s.id))
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
"total": len(specs),
|
|
50
|
+
"files": len(files),
|
|
51
|
+
"kinds": dict(kinds),
|
|
52
|
+
"purity": {"mean": mean, "pure": pure, "tainted": tainted, "impure": impure},
|
|
53
|
+
"effects": dict(effects),
|
|
54
|
+
"most_called": [
|
|
55
|
+
{"id": callee, "callers": n} for callee, n in internal_callers.most_common(top_n)
|
|
56
|
+
],
|
|
57
|
+
"top_fan_out": [{"id": s.id, "calls": len(s.calls)} for s in fan_out[:top_n] if s.calls],
|
|
58
|
+
"external_calls": [
|
|
59
|
+
{"id": callee, "callers": n} for callee, n in external_callers.most_common(top_n)
|
|
60
|
+
],
|
|
61
|
+
"top_constructed": [
|
|
62
|
+
{"id": type_name, "constructors": n} for type_name, n in constructed.most_common(top_n)
|
|
63
|
+
],
|
|
64
|
+
"entrypoints": [
|
|
65
|
+
{"id": s.id, "entrypoint": s.entrypoint}
|
|
66
|
+
for s in sorted(specs, key=lambda s: (s.entrypoint or "", s.id))
|
|
67
|
+
if s.entrypoint
|
|
68
|
+
],
|
|
69
|
+
"untested_effectful": [
|
|
70
|
+
{"id": s.id, "effects": s.effects}
|
|
71
|
+
for s in sorted(specs, key=lambda s: s.id)
|
|
72
|
+
if _IMPURE & set(s.effects) and not s.covered_by
|
|
73
|
+
],
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def render_text(stats: dict[str, Any]) -> str:
|
|
78
|
+
"""Terminal-friendly rendering of :func:`compute_stats` output."""
|
|
79
|
+
lines: list[str] = []
|
|
80
|
+
lines.append(f"Components: {stats['total']} (files: {stats['files']})")
|
|
81
|
+
|
|
82
|
+
if stats["kinds"]:
|
|
83
|
+
kinds = " · ".join(f"{k} {n}" for k, n in sorted(stats["kinds"].items()))
|
|
84
|
+
lines.append(f"Kinds: {kinds}")
|
|
85
|
+
|
|
86
|
+
purity = stats["purity"]
|
|
87
|
+
lines.append(
|
|
88
|
+
f"Purity: mean {purity['mean']:.2f} · pure {purity['pure']}"
|
|
89
|
+
f" · tainted {purity['tainted']} · impure {purity['impure']}"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
if stats["effects"]:
|
|
93
|
+
effects = " · ".join(f"{k} {n}" for k, n in sorted(stats["effects"].items()))
|
|
94
|
+
lines.append(f"Effects: {effects}")
|
|
95
|
+
|
|
96
|
+
if stats["entrypoints"]:
|
|
97
|
+
lines.append("Entrypoints:")
|
|
98
|
+
width = max(len(e["entrypoint"]) for e in stats["entrypoints"])
|
|
99
|
+
for entry in stats["entrypoints"]:
|
|
100
|
+
lines.append(f" {entry['entrypoint']:<{width}} {entry['id']}")
|
|
101
|
+
|
|
102
|
+
if stats["untested_effectful"]:
|
|
103
|
+
lines.append(f"Untested effectful ({len(stats['untested_effectful'])}):")
|
|
104
|
+
for entry in stats["untested_effectful"][:TOP_N]:
|
|
105
|
+
lines.append(f" [{','.join(entry['effects'])}] {entry['id']}")
|
|
106
|
+
|
|
107
|
+
for title, key, count_key in (
|
|
108
|
+
("Most called", "most_called", "callers"),
|
|
109
|
+
("Top fan-out", "top_fan_out", "calls"),
|
|
110
|
+
("Constructed types", "top_constructed", "constructors"),
|
|
111
|
+
("External calls", "external_calls", "callers"),
|
|
112
|
+
):
|
|
113
|
+
entries = stats[key]
|
|
114
|
+
if not entries:
|
|
115
|
+
continue
|
|
116
|
+
lines.append(f"{title}:")
|
|
117
|
+
width = len(str(entries[0][count_key]))
|
|
118
|
+
for entry in entries:
|
|
119
|
+
lines.append(f" {entry[count_key]:>{width}}x {entry['id']}")
|
|
120
|
+
|
|
121
|
+
return "\n".join(lines) + "\n"
|
cgir/slicing/__init__.py
ADDED
cgir/slicing/slicer.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Slice each Function/Method node into a ComponentSpec."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from cgir.analyses.effects import IMPURE_EFFECT_TAGS, TRANSITIVE_TAG
|
|
8
|
+
from cgir.analyses.entrypoints import detect as detect_entrypoint
|
|
9
|
+
from cgir.analyses.purity import PLACEHOLDER_SCORE
|
|
10
|
+
from cgir.ir.component_spec import ComponentKind, ComponentSpec
|
|
11
|
+
from cgir.ir.edges import EdgeKind
|
|
12
|
+
from cgir.ir.graph import RepoGraph
|
|
13
|
+
from cgir.ir.nodes import Node, NodeKind
|
|
14
|
+
from cgir.languages import adapter_for_extension
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _node_language(node: Node, fallback: str) -> str:
|
|
18
|
+
"""The component's language, from its file's adapter (multi-language repos)."""
|
|
19
|
+
if node.path:
|
|
20
|
+
adapter = adapter_for_extension(Path(node.path).suffix)
|
|
21
|
+
if adapter is not None:
|
|
22
|
+
return adapter.name
|
|
23
|
+
return fallback
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def slice_components(
|
|
27
|
+
graph: RepoGraph,
|
|
28
|
+
effects: dict[str, list[str]] | None = None,
|
|
29
|
+
purity_scores: dict[str, float] | None = None,
|
|
30
|
+
language: str = "python",
|
|
31
|
+
) -> list[ComponentSpec]:
|
|
32
|
+
effects = effects or {}
|
|
33
|
+
purity_scores = purity_scores or {}
|
|
34
|
+
specs: list[ComponentSpec] = []
|
|
35
|
+
|
|
36
|
+
for node in graph.nodes():
|
|
37
|
+
if node.kind not in {NodeKind.Function, NodeKind.Method}:
|
|
38
|
+
continue
|
|
39
|
+
|
|
40
|
+
qual = str(node.attrs.get("qualname") or node.name)
|
|
41
|
+
inputs = [param.name for param in graph.children(node.id, NodeKind.Parameter)]
|
|
42
|
+
calls, constructs = _split_callees(graph, node.id)
|
|
43
|
+
node_effects = effects.get(node.id, [])
|
|
44
|
+
purity_score = purity_scores.get(node.id, PLACEHOLDER_SCORE)
|
|
45
|
+
mutates_state = _has_mutations(graph, node.id)
|
|
46
|
+
kind = _classify(node_effects, purity_score, mutates_state)
|
|
47
|
+
|
|
48
|
+
trace = []
|
|
49
|
+
if node.path is not None and node.start_line is not None:
|
|
50
|
+
trace.append(f"{node.path}:{node.start_line}")
|
|
51
|
+
|
|
52
|
+
attrs = node.attrs or {}
|
|
53
|
+
signature = attrs.get("signature")
|
|
54
|
+
returns = attrs.get("returns")
|
|
55
|
+
decorators = attrs.get("decorators")
|
|
56
|
+
doc = attrs.get("doc")
|
|
57
|
+
raises = attrs.get("raises")
|
|
58
|
+
covered_by = _covering_tests(graph, node.id)
|
|
59
|
+
|
|
60
|
+
specs.append(
|
|
61
|
+
ComponentSpec(
|
|
62
|
+
id=qual,
|
|
63
|
+
kind=kind,
|
|
64
|
+
inputs=inputs,
|
|
65
|
+
outputs=[returns] if isinstance(returns, str) and returns else [],
|
|
66
|
+
effects=list(node_effects),
|
|
67
|
+
calls=calls,
|
|
68
|
+
constructs=constructs,
|
|
69
|
+
trace=trace,
|
|
70
|
+
language=_node_language(node, language),
|
|
71
|
+
signature=signature if isinstance(signature, str) else None,
|
|
72
|
+
entrypoint=detect_entrypoint(
|
|
73
|
+
decorators if isinstance(decorators, list) else [], node.name
|
|
74
|
+
),
|
|
75
|
+
doc=doc if isinstance(doc, str) and doc else None,
|
|
76
|
+
raises=list(raises) if isinstance(raises, list) else [],
|
|
77
|
+
covered_by=covered_by,
|
|
78
|
+
purity=purity_score,
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
specs.sort(key=lambda s: s.id)
|
|
83
|
+
return specs
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _covering_tests(graph: RepoGraph, func_id: str) -> list[str]:
|
|
87
|
+
"""Test components that call this one (via resolved CALLS edges)."""
|
|
88
|
+
tests: set[str] = set()
|
|
89
|
+
for edge in graph.in_edges(func_id, EdgeKind.CALLS):
|
|
90
|
+
caller = graph.get_node(edge.src)
|
|
91
|
+
if _is_test_node(caller):
|
|
92
|
+
tests.add(_callee_label(graph, caller.id))
|
|
93
|
+
return sorted(tests)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _is_test_node(node: Node) -> bool:
|
|
97
|
+
"""Heuristic: pytest-style test functions in test files."""
|
|
98
|
+
if node.name.startswith("test_"):
|
|
99
|
+
return True
|
|
100
|
+
if node.path:
|
|
101
|
+
parts = node.path.replace("\\", "/").split("/")
|
|
102
|
+
stem = parts[-1].removesuffix(".py")
|
|
103
|
+
return "tests" in parts[:-1] or stem.startswith("test_") or stem.endswith("_test")
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _split_callees(graph: RepoGraph, func_id: str) -> tuple[list[str], list[str]]:
|
|
108
|
+
"""Partition CALLS edges into (calls, constructs).
|
|
109
|
+
|
|
110
|
+
A call whose target is a Class node is a construction: it resolves to
|
|
111
|
+
the class's ``__init__`` component when one is defined, else the class
|
|
112
|
+
qualname lands in ``constructs`` (dataclass / ORM style).
|
|
113
|
+
"""
|
|
114
|
+
calls: set[str] = set()
|
|
115
|
+
constructs: set[str] = set()
|
|
116
|
+
for edge in graph.out_edges(func_id, EdgeKind.CALLS):
|
|
117
|
+
callee = graph.get_node(edge.dst)
|
|
118
|
+
if callee.kind == NodeKind.Class:
|
|
119
|
+
init = next(
|
|
120
|
+
(m for m in graph.children(callee.id, NodeKind.Method) if m.name == "__init__"),
|
|
121
|
+
None,
|
|
122
|
+
)
|
|
123
|
+
if init is not None:
|
|
124
|
+
calls.add(_callee_label(graph, init.id))
|
|
125
|
+
else:
|
|
126
|
+
constructs.add(_callee_label(graph, callee.id))
|
|
127
|
+
else:
|
|
128
|
+
calls.add(_callee_label(graph, edge.dst))
|
|
129
|
+
return sorted(calls), sorted(constructs)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _callee_label(graph: RepoGraph, node_id: str) -> str:
|
|
133
|
+
node = graph.get_node(node_id)
|
|
134
|
+
qual = node.attrs.get("qualname") if node.attrs else None
|
|
135
|
+
return str(qual) if isinstance(qual, str) else node.name
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _has_mutations(graph: RepoGraph, func_id: str) -> bool:
|
|
139
|
+
"""True if any CFG child mutates state *observable by the caller*.
|
|
140
|
+
|
|
141
|
+
Covers attribute/subscript assignment LHS (``self.x = v``, ``xs[0] = v``,
|
|
142
|
+
``self.total += n``) on ``Assignment`` nodes and bare mutator method
|
|
143
|
+
calls (``xs.append(x)``) on ``Statement`` nodes — both populated by
|
|
144
|
+
:mod:`cgir.analyses.cfg` via ``attrs["mutates"]``.
|
|
145
|
+
|
|
146
|
+
Mutating an object the function *itself created* (a local) is invisible
|
|
147
|
+
to callers and stays pure: a mutated base name only counts if it is a
|
|
148
|
+
parameter, ``self``, or a name never written locally (a global).
|
|
149
|
+
"""
|
|
150
|
+
params = {p.name for p in graph.children(func_id, NodeKind.Parameter)}
|
|
151
|
+
local_writes: set[str] = set()
|
|
152
|
+
mutated: list[str] = []
|
|
153
|
+
for child in graph.children(func_id):
|
|
154
|
+
attrs = child.attrs or {}
|
|
155
|
+
writes = attrs.get("writes")
|
|
156
|
+
if isinstance(writes, list):
|
|
157
|
+
local_writes.update(writes)
|
|
158
|
+
mutates = attrs.get("mutates")
|
|
159
|
+
if isinstance(mutates, list):
|
|
160
|
+
mutated.extend(mutates)
|
|
161
|
+
return any(base in params or base not in local_writes for base in mutated)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _classify(effects: list[str], purity_score: float, mutates_state: bool) -> ComponentKind:
|
|
165
|
+
tags = set(effects)
|
|
166
|
+
if tags & IMPURE_EFFECT_TAGS:
|
|
167
|
+
return ComponentKind.effect_adapter
|
|
168
|
+
if TRANSITIVE_TAG in tags:
|
|
169
|
+
return ComponentKind.orchestrator
|
|
170
|
+
if mutates_state:
|
|
171
|
+
return ComponentKind.state_transformer
|
|
172
|
+
if purity_score == 1.0:
|
|
173
|
+
return ComponentKind.pure_function
|
|
174
|
+
return ComponentKind.unknown
|
cgir/sources/__init__.py
ADDED
cgir/sources/base.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Abstract GraphSource — every backend normalizes into the same RepoGraph."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from cgir.ir.graph import RepoGraph
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class GraphSource(ABC):
|
|
12
|
+
@abstractmethod
|
|
13
|
+
def ingest(self, repo_path: Path) -> RepoGraph:
|
|
14
|
+
"""Walk ``repo_path`` and produce a normalized RepoGraph."""
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""CodeQL adapter — milestone: P2-codeql-bridge."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from cgir.ir.graph import RepoGraph
|
|
8
|
+
from cgir.sources.base import GraphSource
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CodeQLSource(GraphSource):
|
|
12
|
+
def ingest(self, repo_path: Path) -> RepoGraph:
|
|
13
|
+
raise NotImplementedError("milestone: P2-codeql-bridge")
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Joern adapter — milestone: P2-joern-bridge."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from cgir.ir.graph import RepoGraph
|
|
8
|
+
from cgir.sources.base import GraphSource
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class JoernSource(GraphSource):
|
|
12
|
+
def ingest(self, repo_path: Path) -> RepoGraph:
|
|
13
|
+
raise NotImplementedError("milestone: P2-joern-bridge")
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Generic tree-sitter ingester — language-neutral over a LanguageAdapter.
|
|
2
|
+
|
|
3
|
+
Walks a repo, parses each source file with the active adapter, and emits
|
|
4
|
+
the ``Repository → File → Module → (Class → Method | Function)``
|
|
5
|
+
containment spine plus ``Parameter`` children, per-module ``Import``
|
|
6
|
+
siblings, and module-level ``Variable`` nodes.
|
|
7
|
+
|
|
8
|
+
All grammar-specific extraction (what a function/class/import looks like,
|
|
9
|
+
signatures, docstrings, raised names, free names, relative-import
|
|
10
|
+
resolution) comes from :meth:`LanguageAdapter.module_declarations` as
|
|
11
|
+
normalized declarations; this module only builds graph nodes and edges.
|
|
12
|
+
Symbol resolution and ``CALLS`` edges live in :mod:`cgir.analyses`.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import Iterable
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from tree_sitter import Node as TSNode
|
|
21
|
+
|
|
22
|
+
from cgir.ir.edges import Edge, EdgeKind
|
|
23
|
+
from cgir.ir.graph import RepoGraph
|
|
24
|
+
from cgir.ir.nodes import Node, NodeKind
|
|
25
|
+
from cgir.languages import ADAPTERS, LanguageAdapter, adapter_for_extension
|
|
26
|
+
from cgir.languages.base import ClassDecl, FunctionDecl, ImportDecl, VariableDecl
|
|
27
|
+
from cgir.languages.cache import parse_cached
|
|
28
|
+
from cgir.sources.base import GraphSource
|
|
29
|
+
|
|
30
|
+
DEFAULT_IGNORE_DIRS: frozenset[str] = frozenset(
|
|
31
|
+
{
|
|
32
|
+
# Virtual environments
|
|
33
|
+
"venv",
|
|
34
|
+
"env",
|
|
35
|
+
# Build / dist artefacts
|
|
36
|
+
"build",
|
|
37
|
+
"dist",
|
|
38
|
+
"target",
|
|
39
|
+
"out",
|
|
40
|
+
# Python caches
|
|
41
|
+
"__pycache__",
|
|
42
|
+
"site-packages",
|
|
43
|
+
# Tool caches (dot-prefixed names are also covered by the dot-prefix
|
|
44
|
+
# filter below; listed here for completeness when authors rename them)
|
|
45
|
+
".tox",
|
|
46
|
+
".pytest_cache",
|
|
47
|
+
".mypy_cache",
|
|
48
|
+
".ruff_cache",
|
|
49
|
+
# Other ecosystems sometimes vendored into repos
|
|
50
|
+
"node_modules",
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class TreeSitterSource(GraphSource):
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
ignore_dirs: Iterable[str] | None = None,
|
|
59
|
+
adapter: LanguageAdapter | None = None,
|
|
60
|
+
) -> None:
|
|
61
|
+
# adapter=None → dispatch per file extension across all registered
|
|
62
|
+
# languages; a forced adapter restricts ingest to its extensions.
|
|
63
|
+
self._forced = adapter
|
|
64
|
+
self._ignore: frozenset[str] = DEFAULT_IGNORE_DIRS | frozenset(ignore_dirs or ())
|
|
65
|
+
|
|
66
|
+
def _extensions(self) -> tuple[str, ...]:
|
|
67
|
+
if self._forced is not None:
|
|
68
|
+
return self._forced.file_extensions
|
|
69
|
+
return tuple(ext for a in ADAPTERS.values() for ext in a.file_extensions)
|
|
70
|
+
|
|
71
|
+
def ingest(self, repo_path: Path) -> RepoGraph:
|
|
72
|
+
repo_path = repo_path.resolve()
|
|
73
|
+
graph = RepoGraph()
|
|
74
|
+
repo_id = f"repo:{repo_path.name}"
|
|
75
|
+
graph.add_node(
|
|
76
|
+
Node(id=repo_id, kind=NodeKind.Repository, name=repo_path.name, path=str(repo_path))
|
|
77
|
+
)
|
|
78
|
+
files: list[Path] = []
|
|
79
|
+
for ext in self._extensions():
|
|
80
|
+
files.extend(repo_path.rglob(f"*{ext}"))
|
|
81
|
+
for source_file in sorted(set(files)):
|
|
82
|
+
if self._should_skip(source_file.relative_to(repo_path)):
|
|
83
|
+
continue
|
|
84
|
+
adapter = self._forced or adapter_for_extension(source_file.suffix)
|
|
85
|
+
if adapter is not None:
|
|
86
|
+
self._ingest_file(graph, repo_path, repo_id, source_file, adapter)
|
|
87
|
+
return graph
|
|
88
|
+
|
|
89
|
+
def _should_skip(self, rel: Path) -> bool:
|
|
90
|
+
for part in rel.parts:
|
|
91
|
+
if part.startswith("."):
|
|
92
|
+
return True
|
|
93
|
+
if part in self._ignore:
|
|
94
|
+
return True
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
def _ingest_file(
|
|
98
|
+
self,
|
|
99
|
+
graph: RepoGraph,
|
|
100
|
+
repo_path: Path,
|
|
101
|
+
repo_id: str,
|
|
102
|
+
file_path: Path,
|
|
103
|
+
adapter: LanguageAdapter,
|
|
104
|
+
) -> None:
|
|
105
|
+
rel = file_path.relative_to(repo_path)
|
|
106
|
+
rel_str = str(rel)
|
|
107
|
+
module_name = ".".join(rel.with_suffix("").parts)
|
|
108
|
+
file_id = f"file:{rel_str}"
|
|
109
|
+
module_id = f"module:{module_name}"
|
|
110
|
+
|
|
111
|
+
source = file_path.read_bytes()
|
|
112
|
+
root = parse_cached(adapter, source)
|
|
113
|
+
|
|
114
|
+
graph.add_node(
|
|
115
|
+
Node(
|
|
116
|
+
id=file_id,
|
|
117
|
+
kind=NodeKind.File,
|
|
118
|
+
name=rel_str,
|
|
119
|
+
path=rel_str,
|
|
120
|
+
start_line=1,
|
|
121
|
+
end_line=root.end_point[0] + 1,
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
graph.add_edge(Edge(src=repo_id, dst=file_id, kind=EdgeKind.CONTAINS))
|
|
125
|
+
|
|
126
|
+
graph.add_node(
|
|
127
|
+
Node(
|
|
128
|
+
id=module_id,
|
|
129
|
+
kind=NodeKind.Module,
|
|
130
|
+
name=module_name,
|
|
131
|
+
path=rel_str,
|
|
132
|
+
start_line=1,
|
|
133
|
+
end_line=root.end_point[0] + 1,
|
|
134
|
+
attrs={"language": adapter.name},
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
graph.add_edge(Edge(src=file_id, dst=module_id, kind=EdgeKind.CONTAINS))
|
|
138
|
+
|
|
139
|
+
for decl in adapter.module_declarations(root, source, module_name, rel_str):
|
|
140
|
+
if isinstance(decl, FunctionDecl):
|
|
141
|
+
self._add_function(graph, module_id, module_name, rel_str, decl, is_method=False)
|
|
142
|
+
elif isinstance(decl, ClassDecl):
|
|
143
|
+
self._add_class(graph, module_id, module_name, rel_str, decl)
|
|
144
|
+
elif isinstance(decl, ImportDecl):
|
|
145
|
+
self._add_import(graph, module_id, rel_str, decl)
|
|
146
|
+
elif isinstance(decl, VariableDecl):
|
|
147
|
+
self._add_variable(graph, module_id, module_name, rel_str, decl)
|
|
148
|
+
|
|
149
|
+
def _add_function(
|
|
150
|
+
self,
|
|
151
|
+
graph: RepoGraph,
|
|
152
|
+
parent_id: str,
|
|
153
|
+
parent_qual: str,
|
|
154
|
+
rel_path: str,
|
|
155
|
+
decl: FunctionDecl,
|
|
156
|
+
is_method: bool,
|
|
157
|
+
) -> None:
|
|
158
|
+
qual = f"{parent_qual}.{decl.name}"
|
|
159
|
+
kind = NodeKind.Method if is_method else NodeKind.Function
|
|
160
|
+
prefix = "method" if is_method else "func"
|
|
161
|
+
node_id = f"{prefix}:{qual}"
|
|
162
|
+
graph.add_node(
|
|
163
|
+
Node(
|
|
164
|
+
id=node_id,
|
|
165
|
+
kind=kind,
|
|
166
|
+
name=decl.name,
|
|
167
|
+
path=rel_path,
|
|
168
|
+
start_line=decl.node.start_point[0] + 1,
|
|
169
|
+
end_line=decl.node.end_point[0] + 1,
|
|
170
|
+
attrs={
|
|
171
|
+
"qualname": qual,
|
|
172
|
+
"signature": decl.signature,
|
|
173
|
+
"returns": decl.returns,
|
|
174
|
+
"decorators": list(decl.decorators),
|
|
175
|
+
"doc": decl.doc,
|
|
176
|
+
"raises": list(decl.raises),
|
|
177
|
+
"free_names": list(decl.free_names),
|
|
178
|
+
},
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
graph.add_edge(Edge(src=parent_id, dst=node_id, kind=EdgeKind.CONTAINS))
|
|
182
|
+
for param in decl.params:
|
|
183
|
+
self._add_parameter(graph, node_id, qual, rel_path, param.name, param.node)
|
|
184
|
+
|
|
185
|
+
def _add_parameter(
|
|
186
|
+
self,
|
|
187
|
+
graph: RepoGraph,
|
|
188
|
+
func_id: str,
|
|
189
|
+
func_qual: str,
|
|
190
|
+
rel_path: str,
|
|
191
|
+
name: str,
|
|
192
|
+
ts_node: TSNode,
|
|
193
|
+
) -> None:
|
|
194
|
+
param_id = f"param:{func_qual}.{name}"
|
|
195
|
+
graph.add_node(
|
|
196
|
+
Node(
|
|
197
|
+
id=param_id,
|
|
198
|
+
kind=NodeKind.Parameter,
|
|
199
|
+
name=name,
|
|
200
|
+
path=rel_path,
|
|
201
|
+
start_line=ts_node.start_point[0] + 1,
|
|
202
|
+
end_line=ts_node.end_point[0] + 1,
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
graph.add_edge(Edge(src=func_id, dst=param_id, kind=EdgeKind.CONTAINS))
|
|
206
|
+
|
|
207
|
+
def _add_class(
|
|
208
|
+
self,
|
|
209
|
+
graph: RepoGraph,
|
|
210
|
+
parent_id: str,
|
|
211
|
+
parent_qual: str,
|
|
212
|
+
rel_path: str,
|
|
213
|
+
decl: ClassDecl,
|
|
214
|
+
) -> None:
|
|
215
|
+
qual = f"{parent_qual}.{decl.name}"
|
|
216
|
+
node_id = f"class:{qual}"
|
|
217
|
+
graph.add_node(
|
|
218
|
+
Node(
|
|
219
|
+
id=node_id,
|
|
220
|
+
kind=NodeKind.Class,
|
|
221
|
+
name=decl.name,
|
|
222
|
+
path=rel_path,
|
|
223
|
+
start_line=decl.node.start_point[0] + 1,
|
|
224
|
+
end_line=decl.node.end_point[0] + 1,
|
|
225
|
+
attrs={"qualname": qual, "fields": dict(decl.fields)},
|
|
226
|
+
)
|
|
227
|
+
)
|
|
228
|
+
graph.add_edge(Edge(src=parent_id, dst=node_id, kind=EdgeKind.CONTAINS))
|
|
229
|
+
for method in decl.methods:
|
|
230
|
+
self._add_function(graph, node_id, qual, rel_path, method, is_method=True)
|
|
231
|
+
|
|
232
|
+
def _add_import(
|
|
233
|
+
self, graph: RepoGraph, module_id: str, rel_path: str, decl: ImportDecl
|
|
234
|
+
) -> None:
|
|
235
|
+
import_id = f"import:{module_id}::{decl.target}"
|
|
236
|
+
graph.add_node(
|
|
237
|
+
Node(
|
|
238
|
+
id=import_id,
|
|
239
|
+
kind=NodeKind.Import,
|
|
240
|
+
name=decl.target,
|
|
241
|
+
path=rel_path,
|
|
242
|
+
start_line=decl.node.start_point[0] + 1,
|
|
243
|
+
end_line=decl.node.end_point[0] + 1,
|
|
244
|
+
attrs={"target": decl.target, "alias": decl.alias},
|
|
245
|
+
)
|
|
246
|
+
)
|
|
247
|
+
graph.add_edge(Edge(src=module_id, dst=import_id, kind=EdgeKind.CONTAINS))
|
|
248
|
+
graph.add_edge(
|
|
249
|
+
Edge(src=module_id, dst=import_id, kind=EdgeKind.IMPORTS, attrs={"target": decl.target})
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
def _add_variable(
|
|
253
|
+
self, graph: RepoGraph, module_id: str, module_name: str, rel_path: str, decl: VariableDecl
|
|
254
|
+
) -> None:
|
|
255
|
+
var_id = f"var:{module_name}.{decl.name}"
|
|
256
|
+
graph.add_node(
|
|
257
|
+
Node(
|
|
258
|
+
id=var_id,
|
|
259
|
+
kind=NodeKind.Variable,
|
|
260
|
+
name=decl.name,
|
|
261
|
+
path=rel_path,
|
|
262
|
+
start_line=decl.node.start_point[0] + 1,
|
|
263
|
+
end_line=decl.node.end_point[0] + 1,
|
|
264
|
+
attrs={"qualname": f"{module_name}.{decl.name}"},
|
|
265
|
+
)
|
|
266
|
+
)
|
|
267
|
+
graph.add_edge(Edge(src=module_id, dst=var_id, kind=EdgeKind.CONTAINS))
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
__all__ = ["DEFAULT_IGNORE_DIRS", "TreeSitterSource"]
|