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/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Analysis passes over RepoGraph.
|
|
2
|
+
|
|
3
|
+
P0 working: :mod:`cgir.analyses.symbols`, :mod:`cgir.analyses.call_graph`.
|
|
4
|
+
P0 stubs: :mod:`cgir.analyses.effects`, :mod:`cgir.analyses.purity`.
|
|
5
|
+
P1 stubs: :mod:`cgir.analyses.cfg`, :mod:`cgir.analyses.reaching_defs`,
|
|
6
|
+
:mod:`cgir.analyses.pdg`.
|
|
7
|
+
"""
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Build CALLS edges from resolved symbol tables.
|
|
2
|
+
|
|
3
|
+
Language-neutral: the active :class:`~cgir.languages.LanguageAdapter`
|
|
4
|
+
supplies each function's call sites (dotted callee, arg names, line); this
|
|
5
|
+
module resolves each callee through the owning module's symbol table and
|
|
6
|
+
emits the edge. Unresolved calls are dropped — third-party effects show up
|
|
7
|
+
via :mod:`cgir.analyses.effects` instead.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from cgir.analyses.symbols import SymbolTable, module_of
|
|
15
|
+
from cgir.ir.edges import Edge, EdgeKind
|
|
16
|
+
from cgir.ir.graph import RepoGraph
|
|
17
|
+
from cgir.ir.nodes import Node, NodeKind
|
|
18
|
+
from cgir.languages import LanguageAdapter, SourceCache
|
|
19
|
+
|
|
20
|
+
_SELF_RECEIVERS: frozenset[str] = frozenset({"this", "self"})
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def build_call_graph(
|
|
24
|
+
graph: RepoGraph,
|
|
25
|
+
tables: dict[str, SymbolTable],
|
|
26
|
+
repo_path: Path,
|
|
27
|
+
adapter: LanguageAdapter | None = None,
|
|
28
|
+
) -> None:
|
|
29
|
+
cache = SourceCache(repo_path, adapter)
|
|
30
|
+
for func in list(graph.nodes()):
|
|
31
|
+
if func.kind not in {NodeKind.Function, NodeKind.Method}:
|
|
32
|
+
continue
|
|
33
|
+
module_id = module_of(graph, func)
|
|
34
|
+
if module_id is None or func.path is None:
|
|
35
|
+
continue
|
|
36
|
+
table = tables.get(module_id)
|
|
37
|
+
if table is None:
|
|
38
|
+
continue
|
|
39
|
+
parsed = cache.get(func.path)
|
|
40
|
+
if parsed is None:
|
|
41
|
+
continue
|
|
42
|
+
source, root, file_adapter = parsed
|
|
43
|
+
func_ts = file_adapter.locate_function(root, func.name, (func.start_line or 1) - 1)
|
|
44
|
+
if func_ts is None:
|
|
45
|
+
continue
|
|
46
|
+
class_fields = _owning_class_fields(graph, func)
|
|
47
|
+
for callee_name, arg_names, line in file_adapter.call_sites(func_ts, source):
|
|
48
|
+
target = _resolve_callee(tables, table, callee_name)
|
|
49
|
+
if target is None and class_fields:
|
|
50
|
+
target = _resolve_field_call(graph, table, class_fields, callee_name)
|
|
51
|
+
if target is None:
|
|
52
|
+
continue
|
|
53
|
+
# Note: multi-edges collapse per (caller, callee) pair, so the
|
|
54
|
+
# recorded args/line describe one representative call site.
|
|
55
|
+
graph.add_edge(
|
|
56
|
+
Edge(
|
|
57
|
+
src=func.id,
|
|
58
|
+
dst=target,
|
|
59
|
+
kind=EdgeKind.CALLS,
|
|
60
|
+
attrs={"args": arg_names, "line": line},
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _owning_class_fields(graph: RepoGraph, func: Node) -> dict[str, str]:
|
|
66
|
+
"""The field→type map of the class owning a method (empty for free functions)."""
|
|
67
|
+
for edge in graph.in_edges(func.id, EdgeKind.CONTAINS):
|
|
68
|
+
parent = graph.get_node(edge.src)
|
|
69
|
+
if parent.kind == NodeKind.Class:
|
|
70
|
+
fields = parent.attrs.get("fields")
|
|
71
|
+
return fields if isinstance(fields, dict) else {}
|
|
72
|
+
return {}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _resolve_field_call(
|
|
76
|
+
graph: RepoGraph, table: SymbolTable, class_fields: dict[str, str], dotted: str
|
|
77
|
+
) -> str | None:
|
|
78
|
+
"""Resolve ``this.<field>.<method>`` via the field's declared type.
|
|
79
|
+
|
|
80
|
+
``this.svc.translate`` where ``svc: ChaptersService`` resolves to the
|
|
81
|
+
method on the class that ``ChaptersService`` binds to (DI / receiver
|
|
82
|
+
calls). Requires the type to resolve to an in-repo Class node.
|
|
83
|
+
"""
|
|
84
|
+
parts = dotted.split(".")
|
|
85
|
+
if len(parts) < 3 or parts[0] not in _SELF_RECEIVERS:
|
|
86
|
+
return None
|
|
87
|
+
type_name = class_fields.get(parts[1])
|
|
88
|
+
if type_name is None:
|
|
89
|
+
return None
|
|
90
|
+
binding = table.bindings.get(type_name)
|
|
91
|
+
if binding is None or not binding.startswith("class:"):
|
|
92
|
+
return None
|
|
93
|
+
method_id = f"method:{binding[len('class:') :]}.{parts[2]}"
|
|
94
|
+
return method_id if graph.has_node(method_id) else None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _resolve_callee(tables: dict[str, SymbolTable], table: SymbolTable, dotted: str) -> str | None:
|
|
98
|
+
"""Resolve a (possibly dotted) callee through the local symbol table.
|
|
99
|
+
|
|
100
|
+
``chapter.get_chapter`` where ``chapter`` binds to a module resolves
|
|
101
|
+
into that module's own table — the edge lands on the function, not the
|
|
102
|
+
module. Non-module attribute bases (``self.repo.get``) stay at the
|
|
103
|
+
binding of the head, which is usually unbound and dropped.
|
|
104
|
+
"""
|
|
105
|
+
head, _, rest = dotted.partition(".")
|
|
106
|
+
target = table.bindings.get(head)
|
|
107
|
+
if target is None:
|
|
108
|
+
return None
|
|
109
|
+
if rest and target.startswith("module:"):
|
|
110
|
+
sub = tables.get(target)
|
|
111
|
+
if sub is None:
|
|
112
|
+
return None
|
|
113
|
+
return sub.bindings.get(rest.split(".", 1)[0])
|
|
114
|
+
return target
|
cgir/analyses/cfg.py
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""Intra-procedural control-flow graph construction — language-neutral.
|
|
2
|
+
|
|
3
|
+
For each Function/Method node we walk the body via the active
|
|
4
|
+
:class:`~cgir.languages.LanguageAdapter` and emit:
|
|
5
|
+
|
|
6
|
+
* ``Assignment`` for binding statements (``x = e``, ``x += e``)
|
|
7
|
+
* ``Return`` for returns (CFG sinks)
|
|
8
|
+
* ``Branch`` for each conditional arm, exception handler, and match case
|
|
9
|
+
* ``Loop`` for loop headers (for-targets recorded in ``writes``)
|
|
10
|
+
* ``Statement`` for everything else — including resource headers (``with``),
|
|
11
|
+
which record their aliases in ``writes``, and mutator calls in ``mutates``
|
|
12
|
+
|
|
13
|
+
Edges:
|
|
14
|
+
|
|
15
|
+
* ``Function -[CONTAINS]-> <cfg-node>`` for every CFG node we emit
|
|
16
|
+
* ``<node> -[CONTROLS]-> <next-node>`` for control-flow successors
|
|
17
|
+
|
|
18
|
+
The *topology* rules here are language-universal (branch fall-through, loop
|
|
19
|
+
back-edges, try/finally joins, case chains); everything grammar-specific —
|
|
20
|
+
what node type is a loop, where its condition lives, which names it binds —
|
|
21
|
+
comes from the adapter as a normalized
|
|
22
|
+
:class:`~cgir.languages.base.StatementDesc`:
|
|
23
|
+
|
|
24
|
+
* The Function node is the CFG entry; ``Return`` is a sink.
|
|
25
|
+
* A branch without an else falls through (the Branch node joins the
|
|
26
|
+
post-branch successor set); else-if arms chain as nested Branch nodes,
|
|
27
|
+
each control-dependent on its parent.
|
|
28
|
+
* A ``Loop`` header has two successors (body, fall-through); body tails
|
|
29
|
+
get a back-edge to the header.
|
|
30
|
+
* Resource headers (``with``) introduce no control dependence — the body
|
|
31
|
+
keeps the outer controller.
|
|
32
|
+
* ``try`` bodies keep the outer controller; each handler is a ``Branch``
|
|
33
|
+
whose predecessors include the try entry *and* the body tails; ``else``
|
|
34
|
+
chains off the no-exception path; ``finally`` joins every path.
|
|
35
|
+
* ``match``/``switch`` cases chain like else-if arms, with the last case's
|
|
36
|
+
Branch left open as the no-match fall-through.
|
|
37
|
+
|
|
38
|
+
``break``/``continue`` jump targets and loop ``else`` clauses are out of
|
|
39
|
+
scope — flag rather than guess.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
from dataclasses import dataclass, field
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
|
|
47
|
+
from tree_sitter import Node as TSNode
|
|
48
|
+
|
|
49
|
+
from cgir.ir.edges import Edge, EdgeKind
|
|
50
|
+
from cgir.ir.graph import RepoGraph
|
|
51
|
+
from cgir.ir.nodes import Node, NodeKind
|
|
52
|
+
from cgir.languages import LanguageAdapter, SourceCache
|
|
53
|
+
from cgir.languages.base import (
|
|
54
|
+
AssignDesc,
|
|
55
|
+
BranchDesc,
|
|
56
|
+
LoopDesc,
|
|
57
|
+
MatchDesc,
|
|
58
|
+
ReturnDesc,
|
|
59
|
+
SimpleDesc,
|
|
60
|
+
TryDesc,
|
|
61
|
+
WithDesc,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def build(graph: RepoGraph, repo_path: Path, adapter: LanguageAdapter | None = None) -> None:
|
|
66
|
+
cache = SourceCache(repo_path, adapter)
|
|
67
|
+
for func in list(graph.nodes()):
|
|
68
|
+
if func.kind not in {NodeKind.Function, NodeKind.Method}:
|
|
69
|
+
continue
|
|
70
|
+
if func.path is None or func.start_line is None:
|
|
71
|
+
continue
|
|
72
|
+
parsed = cache.get(func.path)
|
|
73
|
+
if parsed is None:
|
|
74
|
+
continue
|
|
75
|
+
source, root, file_adapter = parsed
|
|
76
|
+
func_ts = file_adapter.locate_function(root, func.name, func.start_line - 1)
|
|
77
|
+
if func_ts is None:
|
|
78
|
+
continue
|
|
79
|
+
body = file_adapter.function_body(func_ts)
|
|
80
|
+
if body is None:
|
|
81
|
+
continue
|
|
82
|
+
builder = _CFGBuilder(graph=graph, owner=func, source=source, adapter=file_adapter)
|
|
83
|
+
builder.build_block(body, predecessors=[func.id], controller=None)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class _CFGBuilder:
|
|
88
|
+
graph: RepoGraph
|
|
89
|
+
owner: Node
|
|
90
|
+
source: bytes
|
|
91
|
+
adapter: LanguageAdapter
|
|
92
|
+
_counter: int = field(default=0, init=False)
|
|
93
|
+
|
|
94
|
+
def build_block(
|
|
95
|
+
self, block_ts: TSNode, predecessors: list[str], controller: str | None
|
|
96
|
+
) -> list[str]:
|
|
97
|
+
"""Build a straight-line block. Returns the open predecessors at the block's tail."""
|
|
98
|
+
current_preds = list(predecessors)
|
|
99
|
+
for child in self.adapter.block_statements(block_ts):
|
|
100
|
+
current_preds = self.build_stmt(child, current_preds, controller)
|
|
101
|
+
return current_preds
|
|
102
|
+
|
|
103
|
+
def build_stmt(self, ts_node: TSNode, preds: list[str], controller: str | None) -> list[str]:
|
|
104
|
+
desc = self.adapter.describe_statement(ts_node, self.source)
|
|
105
|
+
if isinstance(desc, BranchDesc):
|
|
106
|
+
return self._build_branch(ts_node, desc, preds, controller)
|
|
107
|
+
if isinstance(desc, LoopDesc):
|
|
108
|
+
return self._build_loop(ts_node, desc, preds, controller)
|
|
109
|
+
if isinstance(desc, ReturnDesc):
|
|
110
|
+
return self._build_return(ts_node, desc, preds, controller)
|
|
111
|
+
if isinstance(desc, WithDesc):
|
|
112
|
+
return self._build_with(ts_node, desc, preds, controller)
|
|
113
|
+
if isinstance(desc, TryDesc):
|
|
114
|
+
return self._build_try(desc, preds, controller)
|
|
115
|
+
if isinstance(desc, MatchDesc):
|
|
116
|
+
return self._build_match(desc, preds, controller)
|
|
117
|
+
if isinstance(desc, AssignDesc):
|
|
118
|
+
return self._build_assignment(ts_node, desc, preds, controller)
|
|
119
|
+
return self._emit_simple(ts_node, desc, preds, controller)
|
|
120
|
+
|
|
121
|
+
def _emit_simple(
|
|
122
|
+
self, ts_node: TSNode, desc: SimpleDesc, preds: list[str], controller: str | None
|
|
123
|
+
) -> list[str]:
|
|
124
|
+
node_id = self._new_id("stmt")
|
|
125
|
+
attrs: dict[str, object] = {
|
|
126
|
+
"reads": desc.reads,
|
|
127
|
+
"mutates": desc.mutates,
|
|
128
|
+
"controlled_by": controller,
|
|
129
|
+
}
|
|
130
|
+
self._add_node(node_id, NodeKind.Statement, ts_node, attrs=attrs)
|
|
131
|
+
self._wire(preds, node_id)
|
|
132
|
+
return [node_id]
|
|
133
|
+
|
|
134
|
+
def _build_assignment(
|
|
135
|
+
self, ts_node: TSNode, desc: AssignDesc, preds: list[str], controller: str | None
|
|
136
|
+
) -> list[str]:
|
|
137
|
+
node_id = self._new_id("assign")
|
|
138
|
+
attrs: dict[str, object] = {
|
|
139
|
+
"writes": desc.writes,
|
|
140
|
+
"mutates": desc.mutates,
|
|
141
|
+
"reads": desc.reads,
|
|
142
|
+
"controlled_by": controller,
|
|
143
|
+
}
|
|
144
|
+
self._add_node(node_id, NodeKind.Assignment, ts_node, attrs=attrs)
|
|
145
|
+
self._wire(preds, node_id)
|
|
146
|
+
return [node_id]
|
|
147
|
+
|
|
148
|
+
def _build_return(
|
|
149
|
+
self, ts_node: TSNode, desc: ReturnDesc, preds: list[str], controller: str | None
|
|
150
|
+
) -> list[str]:
|
|
151
|
+
node_id = self._new_id("return")
|
|
152
|
+
attrs: dict[str, object] = {
|
|
153
|
+
"reads": desc.reads,
|
|
154
|
+
"mutates": desc.mutates,
|
|
155
|
+
"controlled_by": controller,
|
|
156
|
+
}
|
|
157
|
+
self._add_node(node_id, NodeKind.Return, ts_node, attrs=attrs)
|
|
158
|
+
self._wire(preds, node_id)
|
|
159
|
+
# Return is a sink — no open successors for the caller to wire.
|
|
160
|
+
return []
|
|
161
|
+
|
|
162
|
+
def _build_branch(
|
|
163
|
+
self, ts_node: TSNode, desc: BranchDesc, preds: list[str], controller: str | None
|
|
164
|
+
) -> list[str]:
|
|
165
|
+
branch_id = self._new_id("branch")
|
|
166
|
+
attrs: dict[str, object] = {"reads": desc.reads, "controlled_by": controller}
|
|
167
|
+
self._add_node(branch_id, NodeKind.Branch, ts_node, attrs=attrs)
|
|
168
|
+
self._wire(preds, branch_id)
|
|
169
|
+
|
|
170
|
+
exits: list[str] = []
|
|
171
|
+
if desc.consequence is not None:
|
|
172
|
+
exits.extend(self.build_block(desc.consequence, [branch_id], controller=branch_id))
|
|
173
|
+
|
|
174
|
+
if desc.next_branch is not None:
|
|
175
|
+
# else-if arm: its own Branch, control-dependent on this one.
|
|
176
|
+
next_desc = self.adapter.describe_statement(desc.next_branch, self.source)
|
|
177
|
+
if isinstance(next_desc, BranchDesc):
|
|
178
|
+
exits.extend(
|
|
179
|
+
self._build_branch(desc.next_branch, next_desc, [branch_id], branch_id)
|
|
180
|
+
)
|
|
181
|
+
else: # defensive: treat unexpected shapes as an else block
|
|
182
|
+
exits.extend(self.build_stmt(desc.next_branch, [branch_id], branch_id))
|
|
183
|
+
elif desc.else_block is not None:
|
|
184
|
+
exits.extend(self.build_block(desc.else_block, [branch_id], controller=branch_id))
|
|
185
|
+
else:
|
|
186
|
+
# No else: the branch itself is an open predecessor for fall-through.
|
|
187
|
+
exits.append(branch_id)
|
|
188
|
+
return exits
|
|
189
|
+
|
|
190
|
+
def _build_loop(
|
|
191
|
+
self, ts_node: TSNode, desc: LoopDesc, preds: list[str], controller: str | None
|
|
192
|
+
) -> list[str]:
|
|
193
|
+
loop_id = self._new_id("loop")
|
|
194
|
+
attrs: dict[str, object] = {
|
|
195
|
+
"writes": desc.writes,
|
|
196
|
+
"reads": desc.reads,
|
|
197
|
+
"controlled_by": controller,
|
|
198
|
+
}
|
|
199
|
+
self._add_node(loop_id, NodeKind.Loop, ts_node, attrs=attrs)
|
|
200
|
+
self._wire(preds, loop_id)
|
|
201
|
+
|
|
202
|
+
if desc.body is not None:
|
|
203
|
+
body_tail = self.build_block(desc.body, [loop_id], controller=loop_id)
|
|
204
|
+
for tail in body_tail:
|
|
205
|
+
self.graph.add_edge(Edge(src=tail, dst=loop_id, kind=EdgeKind.CONTROLS))
|
|
206
|
+
|
|
207
|
+
# Fall-through exit: loop header itself.
|
|
208
|
+
return [loop_id]
|
|
209
|
+
|
|
210
|
+
def _build_with(
|
|
211
|
+
self, ts_node: TSNode, desc: WithDesc, preds: list[str], controller: str | None
|
|
212
|
+
) -> list[str]:
|
|
213
|
+
header_id = self._new_id("with")
|
|
214
|
+
attrs: dict[str, object] = {
|
|
215
|
+
"writes": desc.writes,
|
|
216
|
+
"reads": desc.reads,
|
|
217
|
+
"mutates": [],
|
|
218
|
+
"controlled_by": controller,
|
|
219
|
+
}
|
|
220
|
+
self._add_node(header_id, NodeKind.Statement, ts_node, attrs=attrs)
|
|
221
|
+
self._wire(preds, header_id)
|
|
222
|
+
|
|
223
|
+
if desc.body is None:
|
|
224
|
+
return [header_id]
|
|
225
|
+
# The body always executes: it keeps the *outer* controller.
|
|
226
|
+
return self.build_block(desc.body, [header_id], controller=controller)
|
|
227
|
+
|
|
228
|
+
def _build_try(self, desc: TryDesc, preds: list[str], controller: str | None) -> list[str]:
|
|
229
|
+
body_tails = (
|
|
230
|
+
self.build_block(desc.body, preds, controller) if desc.body is not None else list(preds)
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
no_exception_tails = body_tails
|
|
234
|
+
handler_exits: list[str] = []
|
|
235
|
+
for handler in desc.handlers:
|
|
236
|
+
# An exception may fire before any try-body statement completes,
|
|
237
|
+
# so the handler's predecessors include the try entry.
|
|
238
|
+
handler_preds = _dedupe(list(preds) + body_tails)
|
|
239
|
+
branch_id = self._new_id("except")
|
|
240
|
+
attrs: dict[str, object] = {
|
|
241
|
+
"writes": handler.writes,
|
|
242
|
+
"reads": [],
|
|
243
|
+
"controlled_by": controller,
|
|
244
|
+
}
|
|
245
|
+
self._add_node(branch_id, NodeKind.Branch, handler.node, attrs=attrs)
|
|
246
|
+
self._wire(handler_preds, branch_id)
|
|
247
|
+
if handler.block is not None:
|
|
248
|
+
handler_exits.extend(self.build_block(handler.block, [branch_id], branch_id))
|
|
249
|
+
else:
|
|
250
|
+
handler_exits.append(branch_id)
|
|
251
|
+
|
|
252
|
+
if desc.else_block is not None:
|
|
253
|
+
no_exception_tails = self.build_block(desc.else_block, no_exception_tails, controller)
|
|
254
|
+
|
|
255
|
+
exits = _dedupe(no_exception_tails + handler_exits)
|
|
256
|
+
|
|
257
|
+
if desc.finally_block is not None:
|
|
258
|
+
exits = self.build_block(desc.finally_block, exits, controller)
|
|
259
|
+
return exits
|
|
260
|
+
|
|
261
|
+
def _build_match(self, desc: MatchDesc, preds: list[str], controller: str | None) -> list[str]:
|
|
262
|
+
exits: list[str] = []
|
|
263
|
+
current_preds = list(preds)
|
|
264
|
+
current_controller = controller
|
|
265
|
+
last_branch: str | None = None
|
|
266
|
+
for case in desc.cases:
|
|
267
|
+
branch_id = self._new_id("case")
|
|
268
|
+
attrs: dict[str, object] = {"reads": case.reads, "controlled_by": current_controller}
|
|
269
|
+
self._add_node(branch_id, NodeKind.Branch, case.node, attrs=attrs)
|
|
270
|
+
self._wire(current_preds, branch_id)
|
|
271
|
+
|
|
272
|
+
if case.consequence is not None:
|
|
273
|
+
exits.extend(self.build_block(case.consequence, [branch_id], branch_id))
|
|
274
|
+
|
|
275
|
+
current_preds = [branch_id]
|
|
276
|
+
current_controller = branch_id
|
|
277
|
+
last_branch = branch_id
|
|
278
|
+
|
|
279
|
+
if last_branch is not None:
|
|
280
|
+
# No case matched: fall through past the last case's Branch.
|
|
281
|
+
exits.append(last_branch)
|
|
282
|
+
return _dedupe(exits)
|
|
283
|
+
|
|
284
|
+
def _add_node(
|
|
285
|
+
self,
|
|
286
|
+
node_id: str,
|
|
287
|
+
kind: NodeKind,
|
|
288
|
+
ts_node: TSNode,
|
|
289
|
+
attrs: dict[str, object] | None = None,
|
|
290
|
+
) -> None:
|
|
291
|
+
self.graph.add_node(
|
|
292
|
+
Node(
|
|
293
|
+
id=node_id,
|
|
294
|
+
kind=kind,
|
|
295
|
+
name=_short_name(ts_node, self.source),
|
|
296
|
+
path=self.owner.path,
|
|
297
|
+
start_line=ts_node.start_point[0] + 1,
|
|
298
|
+
end_line=ts_node.end_point[0] + 1,
|
|
299
|
+
attrs=dict(attrs) if attrs else {},
|
|
300
|
+
)
|
|
301
|
+
)
|
|
302
|
+
self.graph.add_edge(Edge(src=self.owner.id, dst=node_id, kind=EdgeKind.CONTAINS))
|
|
303
|
+
|
|
304
|
+
def _wire(self, preds: list[str], dst: str) -> None:
|
|
305
|
+
for pred in preds:
|
|
306
|
+
self.graph.add_edge(Edge(src=pred, dst=dst, kind=EdgeKind.CONTROLS))
|
|
307
|
+
|
|
308
|
+
def _new_id(self, prefix: str) -> str:
|
|
309
|
+
self._counter += 1
|
|
310
|
+
return f"{prefix}:{self.owner.id}#{self._counter}"
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _dedupe(ids: list[str]) -> list[str]:
|
|
314
|
+
return list(dict.fromkeys(ids))
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _short_name(ts_node: TSNode, source: bytes) -> str:
|
|
318
|
+
raw = source[ts_node.start_byte : ts_node.end_byte].decode("utf-8", errors="replace")
|
|
319
|
+
first_line = raw.splitlines()[0] if raw else ""
|
|
320
|
+
return first_line.strip()[:80]
|
cgir/analyses/effects.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Side-effect classification — language-neutral algorithm over a LanguageAdapter.
|
|
2
|
+
|
|
3
|
+
Effect taxonomy (the language-neutral ComponentSpec vocabulary):
|
|
4
|
+
io terminal / device IO
|
|
5
|
+
raise contains a raise (informational — see below)
|
|
6
|
+
net network access
|
|
7
|
+
fs filesystem access
|
|
8
|
+
nondeterm randomness / clock reads
|
|
9
|
+
db database access
|
|
10
|
+
calls_effectful (transitive only) — a callee has an *impure* effect
|
|
11
|
+
|
|
12
|
+
``raise`` is recorded but **not impure** (settled Sprint 13): exceptions
|
|
13
|
+
are control flow / part of the contract, so a raise-only function keeps
|
|
14
|
+
purity 1.0 and does not taint callers. :data:`IMPURE_EFFECT_TAGS` is the
|
|
15
|
+
gate used by purity, classification, and the transitive closure.
|
|
16
|
+
|
|
17
|
+
This module owns only the *algorithm*: build per-module import-alias maps
|
|
18
|
+
(language-neutral, from ``Import`` node attrs), ask the active
|
|
19
|
+
:class:`~cgir.languages.LanguageAdapter` for each function's direct effect
|
|
20
|
+
tags, then propagate ``calls_effectful`` transitively over ``CALLS`` to a
|
|
21
|
+
fixed point. Which calls count as which effect is the adapter's business
|
|
22
|
+
(``PythonAdapter.direct_effects`` and its stdlib tables).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from cgir.analyses.symbols import module_of
|
|
30
|
+
from cgir.ir.edges import EdgeKind
|
|
31
|
+
from cgir.ir.graph import RepoGraph
|
|
32
|
+
from cgir.ir.nodes import Node, NodeKind
|
|
33
|
+
from cgir.languages import LanguageAdapter, SourceCache
|
|
34
|
+
|
|
35
|
+
DIRECT_EFFECT_TAGS: frozenset[str] = frozenset({"io", "raise", "net", "fs", "nondeterm", "db"})
|
|
36
|
+
IMPURE_EFFECT_TAGS: frozenset[str] = DIRECT_EFFECT_TAGS - {"raise"}
|
|
37
|
+
TRANSITIVE_TAG = "calls_effectful"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def classify(
|
|
41
|
+
graph: RepoGraph, repo_path: Path, adapter: LanguageAdapter | None = None
|
|
42
|
+
) -> dict[str, list[str]]:
|
|
43
|
+
"""Return ``{function_id: sorted([effect_tag, ...])}`` for every function/method."""
|
|
44
|
+
cache = SourceCache(repo_path, adapter)
|
|
45
|
+
func_nodes = [n for n in graph.nodes() if n.kind in {NodeKind.Function, NodeKind.Method}]
|
|
46
|
+
alias_maps = _module_alias_maps(graph)
|
|
47
|
+
|
|
48
|
+
effects: dict[str, set[str]] = {}
|
|
49
|
+
for func in func_nodes:
|
|
50
|
+
module_id = module_of(graph, func)
|
|
51
|
+
aliases = alias_maps.get(module_id, {}) if module_id else {}
|
|
52
|
+
effects[func.id] = _direct_effects(cache, func, aliases)
|
|
53
|
+
|
|
54
|
+
# Propagate transitively over CALLS edges until fixed point. Only
|
|
55
|
+
# *impure* effects taint callers — raise-only callees don't.
|
|
56
|
+
changed = True
|
|
57
|
+
while changed:
|
|
58
|
+
changed = False
|
|
59
|
+
for func in func_nodes:
|
|
60
|
+
for edge in graph.out_edges(func.id, EdgeKind.CALLS):
|
|
61
|
+
callee = effects.get(edge.dst, set())
|
|
62
|
+
if (
|
|
63
|
+
callee & IMPURE_EFFECT_TAGS or TRANSITIVE_TAG in callee
|
|
64
|
+
) and TRANSITIVE_TAG not in effects[func.id]:
|
|
65
|
+
effects[func.id].add(TRANSITIVE_TAG)
|
|
66
|
+
changed = True
|
|
67
|
+
|
|
68
|
+
return {nid: sorted(tags) for nid, tags in effects.items()}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _module_alias_maps(graph: RepoGraph) -> dict[str, dict[str, str]]:
|
|
72
|
+
"""Per module: ``{local_name: absolute_dotted_target}`` from Import nodes."""
|
|
73
|
+
maps: dict[str, dict[str, str]] = {}
|
|
74
|
+
for module in graph.nodes(NodeKind.Module):
|
|
75
|
+
table: dict[str, str] = {}
|
|
76
|
+
for child in graph.children(module.id, NodeKind.Import):
|
|
77
|
+
target = str(child.attrs.get("target") or child.name)
|
|
78
|
+
alias = child.attrs.get("alias")
|
|
79
|
+
local = alias if isinstance(alias, str) else target.rsplit(".", 1)[-1]
|
|
80
|
+
table[local] = target
|
|
81
|
+
maps[module.id] = table
|
|
82
|
+
return maps
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _direct_effects(cache: SourceCache, func: Node, aliases: dict[str, str]) -> set[str]:
|
|
86
|
+
if func.path is None or func.start_line is None:
|
|
87
|
+
return set()
|
|
88
|
+
parsed = cache.get(func.path)
|
|
89
|
+
if parsed is None:
|
|
90
|
+
return set()
|
|
91
|
+
source, root, adapter = parsed
|
|
92
|
+
func_ts = adapter.locate_function(root, func.name, func.start_line - 1)
|
|
93
|
+
if func_ts is None:
|
|
94
|
+
return set()
|
|
95
|
+
return adapter.direct_effects(func_ts, source, aliases)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Entrypoint detection from decorator texts (Sprint 17).
|
|
2
|
+
|
|
3
|
+
The ingester records each decorated definition's decorator texts (sans
|
|
4
|
+
``@``); :func:`detect` maps them to a human-readable entrypoint label:
|
|
5
|
+
|
|
6
|
+
* ``router.get("/x")`` / ``app.post(...)`` → ``HTTP GET /x`` (FastAPI style)
|
|
7
|
+
* ``app.route("/x")`` → ``HTTP /x`` (Flask style; method set not parsed)
|
|
8
|
+
* ``app.command()`` / ``click.command()`` → ``CLI <func>`` (typer/click)
|
|
9
|
+
* ``shared_task`` / ``celery.task`` → ``task <func>``
|
|
10
|
+
|
|
11
|
+
Purely lexical — a decorator named like a router that isn't one will
|
|
12
|
+
false-positive; per spec, flag rather than solve. Framework dispatch is
|
|
13
|
+
exactly the dynamic behavior static call graphs can't see, so these
|
|
14
|
+
labels are how CGIR shows "called by the framework".
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
|
|
21
|
+
_HTTP_METHODS: frozenset[str] = frozenset(
|
|
22
|
+
{"get", "post", "put", "delete", "patch", "head", "options", "websocket"}
|
|
23
|
+
)
|
|
24
|
+
_HTTP_RECEIVERS: frozenset[str] = frozenset({"app", "router", "api", "blueprint", "bp"})
|
|
25
|
+
_TASK_MARKERS: frozenset[str] = frozenset({"task", "shared_task"})
|
|
26
|
+
|
|
27
|
+
_FIRST_STR_ARG = re.compile(r"\(\s*[rbf]*[\"']([^\"']*)[\"']")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def detect(decorators: list[str], func_name: str) -> str | None:
|
|
31
|
+
"""Map decorator texts to an entrypoint label, or None."""
|
|
32
|
+
for decorator in decorators:
|
|
33
|
+
head = decorator.split("(", 1)[0].strip()
|
|
34
|
+
parts = head.split(".")
|
|
35
|
+
if len(parts) == 2 and parts[0] in _HTTP_RECEIVERS:
|
|
36
|
+
if parts[1] in _HTTP_METHODS:
|
|
37
|
+
path = _first_str_arg(decorator)
|
|
38
|
+
return (
|
|
39
|
+
f"HTTP {parts[1].upper()} {path}".rstrip()
|
|
40
|
+
if path
|
|
41
|
+
else (f"HTTP {parts[1].upper()}")
|
|
42
|
+
)
|
|
43
|
+
if parts[1] == "route":
|
|
44
|
+
path = _first_str_arg(decorator)
|
|
45
|
+
return f"HTTP {path}" if path else "HTTP"
|
|
46
|
+
if parts[-1] == "command" and len(parts) >= 2:
|
|
47
|
+
return f"CLI {func_name}"
|
|
48
|
+
if parts[-1] in _TASK_MARKERS:
|
|
49
|
+
return f"task {func_name}"
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _first_str_arg(decorator: str) -> str | None:
|
|
54
|
+
match = _FIRST_STR_ARG.search(decorator)
|
|
55
|
+
return match.group(1) if match else None
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Parameter-flow analysis: which caller params reach which callee.
|
|
2
|
+
|
|
3
|
+
Pure-graph may-analysis over the CFG ``reads``/``writes`` attrs populated
|
|
4
|
+
by :mod:`cgir.analyses.cfg` and the call-site ``args`` recorded on
|
|
5
|
+
``CALLS`` edges by :mod:`cgir.analyses.call_graph`:
|
|
6
|
+
|
|
7
|
+
* Each parameter taints its own name.
|
|
8
|
+
* Any CFG node that reads a tainted name taints every name it writes
|
|
9
|
+
(fixed point, order-insensitive — a *may*-flow, per spec: flag rather
|
|
10
|
+
than prove).
|
|
11
|
+
* A parameter flows into a call iff the call's argument identifiers
|
|
12
|
+
intersect the parameter's tainted names.
|
|
13
|
+
|
|
14
|
+
Drives the arg-flow edges in the HTML Flow view (data passed *down* into
|
|
15
|
+
callees, complementing the return-type edges that flow up).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from cgir.ir.edges import EdgeKind
|
|
23
|
+
from cgir.ir.graph import RepoGraph
|
|
24
|
+
from cgir.ir.nodes import Node, NodeKind
|
|
25
|
+
|
|
26
|
+
_CFG_KINDS: frozenset[NodeKind] = frozenset(
|
|
27
|
+
{
|
|
28
|
+
NodeKind.Statement,
|
|
29
|
+
NodeKind.Assignment,
|
|
30
|
+
NodeKind.Branch,
|
|
31
|
+
NodeKind.Loop,
|
|
32
|
+
NodeKind.Return,
|
|
33
|
+
}
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def compute(graph: RepoGraph) -> dict[str, list[dict[str, Any]]]:
|
|
38
|
+
"""Per caller id: ``[{"callee": id, "params": [names...]}, ...]``."""
|
|
39
|
+
result: dict[str, list[dict[str, Any]]] = {}
|
|
40
|
+
for func in graph.nodes():
|
|
41
|
+
if func.kind not in {NodeKind.Function, NodeKind.Method}:
|
|
42
|
+
continue
|
|
43
|
+
entries = _entries_for(graph, func)
|
|
44
|
+
if entries:
|
|
45
|
+
result[func.id] = entries
|
|
46
|
+
return result
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _entries_for(graph: RepoGraph, func: Node) -> list[dict[str, Any]]:
|
|
50
|
+
params = [p.name for p in graph.children(func.id, NodeKind.Parameter)]
|
|
51
|
+
if not params:
|
|
52
|
+
return []
|
|
53
|
+
cfg_nodes = [c for c in graph.children(func.id) if c.kind in _CFG_KINDS]
|
|
54
|
+
|
|
55
|
+
taint: dict[str, set[str]] = {p: {p} for p in params}
|
|
56
|
+
changed = True
|
|
57
|
+
while changed:
|
|
58
|
+
changed = False
|
|
59
|
+
for node in cfg_nodes:
|
|
60
|
+
reads = set(node.attrs.get("reads") or []) if node.attrs else set()
|
|
61
|
+
writes = set(node.attrs.get("writes") or []) if node.attrs else set()
|
|
62
|
+
if not writes:
|
|
63
|
+
continue
|
|
64
|
+
for param in params:
|
|
65
|
+
if reads & taint[param] and not writes <= taint[param]:
|
|
66
|
+
taint[param] |= writes
|
|
67
|
+
changed = True
|
|
68
|
+
|
|
69
|
+
entries: list[dict[str, Any]] = []
|
|
70
|
+
for edge in graph.out_edges(func.id, EdgeKind.CALLS):
|
|
71
|
+
args = set(edge.attrs.get("args") or [])
|
|
72
|
+
flowing = [p for p in params if args & taint[p]]
|
|
73
|
+
if flowing:
|
|
74
|
+
entries.append({"callee": edge.dst, "params": flowing})
|
|
75
|
+
return entries
|