codegraph-ir 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. cgir/__init__.py +6 -0
  2. cgir/analyses/__init__.py +7 -0
  3. cgir/analyses/call_graph.py +114 -0
  4. cgir/analyses/cfg.py +320 -0
  5. cgir/analyses/effects.py +95 -0
  6. cgir/analyses/entrypoints.py +55 -0
  7. cgir/analyses/param_flow.py +75 -0
  8. cgir/analyses/pdg.py +75 -0
  9. cgir/analyses/purity.py +37 -0
  10. cgir/analyses/reaching_defs.py +115 -0
  11. cgir/analyses/symbols.py +106 -0
  12. cgir/api/__init__.py +1 -0
  13. cgir/api/mcp_server.py +172 -0
  14. cgir/api/server.py +107 -0
  15. cgir/cli.py +688 -0
  16. cgir/config.py +22 -0
  17. cgir/export/__init__.py +5 -0
  18. cgir/export/graphml.py +52 -0
  19. cgir/export/html_viz.py +1087 -0
  20. cgir/export/json_export.py +37 -0
  21. cgir/export/mermaid.py +57 -0
  22. cgir/export/neo4j.py +11 -0
  23. cgir/hooks.py +189 -0
  24. cgir/ir/__init__.py +16 -0
  25. cgir/ir/component_spec.py +100 -0
  26. cgir/ir/edges.py +31 -0
  27. cgir/ir/graph.py +132 -0
  28. cgir/ir/nodes.py +38 -0
  29. cgir/languages/__init__.py +25 -0
  30. cgir/languages/base.py +221 -0
  31. cgir/languages/cache.py +73 -0
  32. cgir/languages/python.py +1145 -0
  33. cgir/languages/registry.py +24 -0
  34. cgir/languages/typescript.py +853 -0
  35. cgir/manifest.py +77 -0
  36. cgir/pipeline.py +54 -0
  37. cgir/py.typed +0 -0
  38. cgir/regenerate/__init__.py +6 -0
  39. cgir/regenerate/prompt_pack.py +16 -0
  40. cgir/regenerate/regenerator.py +108 -0
  41. cgir/report/__init__.py +5 -0
  42. cgir/report/diff.py +226 -0
  43. cgir/report/flow.py +84 -0
  44. cgir/report/impact.py +234 -0
  45. cgir/report/lint.py +84 -0
  46. cgir/report/pack.py +271 -0
  47. cgir/report/stats.py +121 -0
  48. cgir/slicing/__init__.py +5 -0
  49. cgir/slicing/slicer.py +174 -0
  50. cgir/sources/__init__.py +6 -0
  51. cgir/sources/base.py +14 -0
  52. cgir/sources/codeql_source.py +13 -0
  53. cgir/sources/joern_source.py +13 -0
  54. cgir/sources/tree_sitter_source.py +270 -0
  55. cgir/trace/__init__.py +5 -0
  56. cgir/trace/trace_map.py +83 -0
  57. cgir/verify.py +173 -0
  58. cgir/watch.py +171 -0
  59. codegraph_ir-0.1.0.dist-info/METADATA +143 -0
  60. codegraph_ir-0.1.0.dist-info/RECORD +63 -0
  61. codegraph_ir-0.1.0.dist-info/WHEEL +4 -0
  62. codegraph_ir-0.1.0.dist-info/entry_points.txt +2 -0
  63. codegraph_ir-0.1.0.dist-info/licenses/LICENSE +21 -0
cgir/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """CodeGraph IR — a semantic IR layer over repo graphs.
2
+
3
+ See ``Code-IR.md`` at the repo root for the product specification.
4
+ """
5
+
6
+ __version__ = "0.1.0"
@@ -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]
@@ -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