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/languages/base.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""LanguageAdapter — the per-language seam.
|
|
2
|
+
|
|
3
|
+
Every downstream analysis (symbols, cfg, effects, pdg, param-flow, purity,
|
|
4
|
+
slicing) is written *once* against the RepoGraph and a shared node-attr
|
|
5
|
+
contract. What differs between languages is only grammar-specific
|
|
6
|
+
extraction: what node type is a call, which builtins are effectful, how a
|
|
7
|
+
branch's condition is reached. A ``LanguageAdapter`` answers exactly those
|
|
8
|
+
questions, so a new language is one adapter, not a re-implemented pipeline.
|
|
9
|
+
|
|
10
|
+
Tree-sitter is the shared substrate (Python, TypeScript, Go, Rust all have
|
|
11
|
+
grammars), so adapters trade in :class:`tree_sitter.Node`. The adapter
|
|
12
|
+
abstracts the *grammar*, not the parser technology.
|
|
13
|
+
|
|
14
|
+
The surface grows by phase as passes are migrated behind it:
|
|
15
|
+
* phase 1 — parse / locate / effects / calls
|
|
16
|
+
* phase 2 — CFG statement classification + field extraction
|
|
17
|
+
* phase 3 — ingest structural dispatch + attr extraction
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from abc import ABC, abstractmethod
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
|
|
25
|
+
from tree_sitter import Node as TSNode
|
|
26
|
+
|
|
27
|
+
# (dotted_callee, arg_identifier_names, 1-based_line)
|
|
28
|
+
CallSite = tuple[str, list[str], int]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# --- normalized statement descriptors (phase 2: CFG) ---------------------------
|
|
32
|
+
#
|
|
33
|
+
# The CFG *topology* (how branches wire, loop back-edges, try/finally joins)
|
|
34
|
+
# is language-universal and lives in cgir/analyses/cfg.py. The adapter's job
|
|
35
|
+
# is to classify each statement and hand back its parts in one of these
|
|
36
|
+
# shapes; the builder never looks at grammar node types.
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(slots=True)
|
|
40
|
+
class SimpleDesc:
|
|
41
|
+
reads: list[str] = field(default_factory=list)
|
|
42
|
+
mutates: list[str] = field(default_factory=list)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(slots=True)
|
|
46
|
+
class AssignDesc:
|
|
47
|
+
writes: list[str] = field(default_factory=list)
|
|
48
|
+
mutates: list[str] = field(default_factory=list)
|
|
49
|
+
reads: list[str] = field(default_factory=list)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(slots=True)
|
|
53
|
+
class ReturnDesc:
|
|
54
|
+
reads: list[str] = field(default_factory=list)
|
|
55
|
+
mutates: list[str] = field(default_factory=list)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(slots=True)
|
|
59
|
+
class BranchDesc:
|
|
60
|
+
"""An if/elif (or `else if`) arm. Chains via ``next_branch``."""
|
|
61
|
+
|
|
62
|
+
reads: list[str] = field(default_factory=list)
|
|
63
|
+
consequence: TSNode | None = None
|
|
64
|
+
else_block: TSNode | None = None
|
|
65
|
+
next_branch: TSNode | None = None # elif_clause / nested `if` — described again
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(slots=True)
|
|
69
|
+
class LoopDesc:
|
|
70
|
+
reads: list[str] = field(default_factory=list)
|
|
71
|
+
writes: list[str] = field(default_factory=list) # for-targets
|
|
72
|
+
body: TSNode | None = None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(slots=True)
|
|
76
|
+
class WithDesc:
|
|
77
|
+
"""Resource-acquisition header (with / using / try-with-resources)."""
|
|
78
|
+
|
|
79
|
+
writes: list[str] = field(default_factory=list) # `as` aliases
|
|
80
|
+
reads: list[str] = field(default_factory=list)
|
|
81
|
+
body: TSNode | None = None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(slots=True)
|
|
85
|
+
class HandlerDesc:
|
|
86
|
+
node: TSNode
|
|
87
|
+
writes: list[str] = field(default_factory=list) # `except ... as e`
|
|
88
|
+
block: TSNode | None = None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(slots=True)
|
|
92
|
+
class TryDesc:
|
|
93
|
+
body: TSNode | None = None
|
|
94
|
+
handlers: list[HandlerDesc] = field(default_factory=list)
|
|
95
|
+
else_block: TSNode | None = None
|
|
96
|
+
finally_block: TSNode | None = None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(slots=True)
|
|
100
|
+
class CaseDesc:
|
|
101
|
+
node: TSNode
|
|
102
|
+
reads: list[str] = field(default_factory=list) # subject + guard
|
|
103
|
+
consequence: TSNode | None = None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(slots=True)
|
|
107
|
+
class MatchDesc:
|
|
108
|
+
cases: list[CaseDesc] = field(default_factory=list)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
StatementDesc = (
|
|
112
|
+
SimpleDesc | AssignDesc | ReturnDesc | BranchDesc | LoopDesc | WithDesc | TryDesc | MatchDesc
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# --- normalized module declarations (phase 3: ingest) ---------------------------
|
|
117
|
+
#
|
|
118
|
+
# The structural spine (Repository → File → Module → members + CONTAINS) and
|
|
119
|
+
# node/edge construction are language-universal and live in
|
|
120
|
+
# sources/tree_sitter_source.py. The adapter walks a module root and yields
|
|
121
|
+
# these; the ingester never looks at grammar node types.
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(slots=True)
|
|
125
|
+
class ParamDecl:
|
|
126
|
+
name: str
|
|
127
|
+
node: TSNode
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass(slots=True)
|
|
131
|
+
class FunctionDecl:
|
|
132
|
+
node: TSNode
|
|
133
|
+
name: str
|
|
134
|
+
params: list[ParamDecl] = field(default_factory=list)
|
|
135
|
+
signature: str = ""
|
|
136
|
+
returns: str | None = None
|
|
137
|
+
doc: str = ""
|
|
138
|
+
raises: list[str] = field(default_factory=list)
|
|
139
|
+
decorators: list[str] = field(default_factory=list)
|
|
140
|
+
free_names: list[str] = field(default_factory=list)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass(slots=True)
|
|
144
|
+
class ClassDecl:
|
|
145
|
+
node: TSNode
|
|
146
|
+
name: str
|
|
147
|
+
methods: list[FunctionDecl] = field(default_factory=list)
|
|
148
|
+
# field name → declared type name, for DI/receiver call resolution
|
|
149
|
+
# (``this.svc.method()`` where ``svc: ChaptersService``).
|
|
150
|
+
fields: dict[str, str] = field(default_factory=dict)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataclass(slots=True)
|
|
154
|
+
class ImportDecl:
|
|
155
|
+
node: TSNode
|
|
156
|
+
target: str
|
|
157
|
+
alias: str | None = None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass(slots=True)
|
|
161
|
+
class VariableDecl:
|
|
162
|
+
node: TSNode
|
|
163
|
+
name: str
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
Declaration = FunctionDecl | ClassDecl | ImportDecl | VariableDecl
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class LanguageAdapter(ABC):
|
|
170
|
+
name: str = "base"
|
|
171
|
+
file_extensions: tuple[str, ...] = ()
|
|
172
|
+
|
|
173
|
+
@abstractmethod
|
|
174
|
+
def parse(self, source: bytes) -> TSNode:
|
|
175
|
+
"""Parse source bytes; return the tree's root node."""
|
|
176
|
+
|
|
177
|
+
@abstractmethod
|
|
178
|
+
def locate_function(self, root: TSNode, name: str, start_row: int) -> TSNode | None:
|
|
179
|
+
"""The function/method node named ``name`` starting on 0-based ``start_row``."""
|
|
180
|
+
|
|
181
|
+
@abstractmethod
|
|
182
|
+
def direct_effects(self, func_node: TSNode, source: bytes, aliases: dict[str, str]) -> set[str]:
|
|
183
|
+
"""Direct effect tags (io/net/fs/db/nondeterm/raise) in the body.
|
|
184
|
+
|
|
185
|
+
``aliases`` maps local import names to their absolute dotted target
|
|
186
|
+
(built language-neutrally from ``Import`` nodes), so an adapter can
|
|
187
|
+
resolve ``r.get`` → ``requests.get`` before matching its tables.
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
@abstractmethod
|
|
191
|
+
def call_sites(self, func_node: TSNode, source: bytes) -> list[CallSite]:
|
|
192
|
+
"""Call sites in the body: dotted callee, arg identifier names, line."""
|
|
193
|
+
|
|
194
|
+
# --- phase 2: CFG extraction --------------------------------------------
|
|
195
|
+
|
|
196
|
+
@abstractmethod
|
|
197
|
+
def function_body(self, func_node: TSNode) -> TSNode | None:
|
|
198
|
+
"""The function's body block node."""
|
|
199
|
+
|
|
200
|
+
@abstractmethod
|
|
201
|
+
def block_statements(self, block: TSNode) -> list[TSNode]:
|
|
202
|
+
"""Statement nodes of a block, comments filtered."""
|
|
203
|
+
|
|
204
|
+
@abstractmethod
|
|
205
|
+
def describe_statement(self, node: TSNode, source: bytes) -> StatementDesc:
|
|
206
|
+
"""Classify one statement and extract its parts (see descriptors)."""
|
|
207
|
+
|
|
208
|
+
# --- phase 3: ingest extraction ------------------------------------------
|
|
209
|
+
|
|
210
|
+
@abstractmethod
|
|
211
|
+
def module_declarations(
|
|
212
|
+
self, root: TSNode, source: bytes, module_name: str, rel_path: str
|
|
213
|
+
) -> list[Declaration]:
|
|
214
|
+
"""Top-level declarations of a module, fully extracted.
|
|
215
|
+
|
|
216
|
+
``module_name`` is the dotted module path (for languages with dotted
|
|
217
|
+
relative imports, e.g. Python ``from ..a import x``); ``rel_path`` is
|
|
218
|
+
the repo-relative file path (for path-specifier imports, e.g. TS
|
|
219
|
+
``from './util'``). Method params exclude implicit receivers
|
|
220
|
+
(``self``/``this``).
|
|
221
|
+
"""
|
cgir/languages/cache.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Parse-once source cache.
|
|
2
|
+
|
|
3
|
+
Two layers of caching, both keyed so they are always correct:
|
|
4
|
+
|
|
5
|
+
* :func:`parse_cached` — a process-wide store keyed by ``(adapter, content
|
|
6
|
+
hash)``. A parse tree is a pure function of the bytes and the grammar, so
|
|
7
|
+
this is safe across analysis passes *and* across repeated scans (watch
|
|
8
|
+
mode): an unchanged file is parsed once, ever. Bounded LRU so a long-lived
|
|
9
|
+
watch process doesn't grow without limit.
|
|
10
|
+
* :class:`SourceCache` — a per-run, path-keyed view used by the analysis
|
|
11
|
+
passes, which walk *per function* and would otherwise re-read/re-parse a
|
|
12
|
+
file once per function. It now resolves parse trees through
|
|
13
|
+
:func:`parse_cached`, so the ingest pass and every analysis share one
|
|
14
|
+
parse per content.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import hashlib
|
|
20
|
+
from collections import OrderedDict
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from tree_sitter import Node as TSNode
|
|
24
|
+
|
|
25
|
+
from cgir.languages.base import LanguageAdapter
|
|
26
|
+
from cgir.languages.registry import adapter_for_extension
|
|
27
|
+
|
|
28
|
+
# (adapter name, sha256(content)) -> parsed root. Bounded LRU.
|
|
29
|
+
_PARSE_STORE: OrderedDict[tuple[str, bytes], TSNode] = OrderedDict()
|
|
30
|
+
_PARSE_STORE_MAX = 8192
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def parse_cached(adapter: LanguageAdapter, source: bytes) -> TSNode:
|
|
34
|
+
"""Parse ``source`` with ``adapter``, reusing a cached tree for identical
|
|
35
|
+
content. Correct across passes and scans — the tree only depends on the
|
|
36
|
+
grammar and the bytes."""
|
|
37
|
+
key = (adapter.name, hashlib.sha256(source).digest())
|
|
38
|
+
cached = _PARSE_STORE.get(key)
|
|
39
|
+
if cached is not None:
|
|
40
|
+
_PARSE_STORE.move_to_end(key)
|
|
41
|
+
return cached
|
|
42
|
+
root = adapter.parse(source)
|
|
43
|
+
_PARSE_STORE[key] = root
|
|
44
|
+
if len(_PARSE_STORE) > _PARSE_STORE_MAX:
|
|
45
|
+
_PARSE_STORE.popitem(last=False)
|
|
46
|
+
return root
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def clear_parse_cache() -> None:
|
|
50
|
+
"""Drop the process-wide parse store (tests / memory reclamation)."""
|
|
51
|
+
_PARSE_STORE.clear()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class SourceCache:
|
|
55
|
+
def __init__(self, repo_path: Path, adapter: LanguageAdapter | None = None) -> None:
|
|
56
|
+
self._repo_path = repo_path
|
|
57
|
+
self._forced = adapter
|
|
58
|
+
self._entries: dict[str, tuple[bytes, TSNode, LanguageAdapter] | None] = {}
|
|
59
|
+
|
|
60
|
+
def get(self, rel_path: str) -> tuple[bytes, TSNode, LanguageAdapter] | None:
|
|
61
|
+
"""Return ``(source_bytes, root_node, adapter)`` for a path, or None."""
|
|
62
|
+
if rel_path not in self._entries:
|
|
63
|
+
adapter = self._forced or adapter_for_extension(Path(rel_path).suffix)
|
|
64
|
+
if adapter is None:
|
|
65
|
+
self._entries[rel_path] = None
|
|
66
|
+
else:
|
|
67
|
+
try:
|
|
68
|
+
source = (self._repo_path / rel_path).read_bytes()
|
|
69
|
+
except OSError:
|
|
70
|
+
self._entries[rel_path] = None
|
|
71
|
+
else:
|
|
72
|
+
self._entries[rel_path] = (source, parse_cached(adapter, source), adapter)
|
|
73
|
+
return self._entries[rel_path]
|