code2schema 0.1.1__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.
@@ -0,0 +1,117 @@
1
+ """
2
+ code2schema.codegen
3
+ ~~~~~~~~~~~~~~~~~~~
4
+ Generatory wyjściowe:
5
+ - JSON (domyślny)
6
+ - Proto (gRPC .proto)
7
+ - Markdown summary
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from pathlib import Path
13
+ from typing import Literal
14
+
15
+ from code2schema.core.models import CQRSRole, SchemaIR
16
+
17
+
18
+ # ── JSON ──────────────────────────────────────────────────────────────────────
19
+
20
+ def to_json(schema: SchemaIR, indent: int = 2) -> str:
21
+ """Serializuje SchemaIR do JSON."""
22
+ return json.dumps(schema.model_dump(), indent=indent, default=str)
23
+
24
+
25
+ def write_json(schema: SchemaIR, path: Path) -> None:
26
+ path.write_text(to_json(schema), encoding="utf-8")
27
+
28
+
29
+ # ── Proto ─────────────────────────────────────────────────────────────────────
30
+
31
+ def to_proto(schema: SchemaIR) -> str:
32
+ """Generuje .proto z modelu CQRS."""
33
+ lines: list[str] = [
34
+ 'syntax = "proto3";',
35
+ "",
36
+ f"// Generated by code2schema v0.1",
37
+ f"// Modules: {len(schema.modules)}, "
38
+ f"Commands: {len(schema.commands())}, "
39
+ f"Queries: {len(schema.queries())}",
40
+ "",
41
+ "package code2schema;",
42
+ "",
43
+ "message Request { string payload = 1; }",
44
+ "message Response { string result = 1; bool success = 2; }",
45
+ "",
46
+ "service Code2Schema {",
47
+ ]
48
+
49
+ for func in schema.all_functions():
50
+ if func.role in (CQRSRole.QUERY, CQRSRole.COMMAND, CQRSRole.ORCHESTRATOR):
51
+ suffix = func.role.value.capitalize()
52
+ safe_name = _safe_proto_name(func.name)
53
+ lines.append(
54
+ f" rpc {safe_name}{suffix} (Request) returns (Response);"
55
+ )
56
+
57
+ lines += ["}", ""]
58
+ return "\n".join(lines)
59
+
60
+
61
+ def write_proto(schema: SchemaIR, path: Path) -> None:
62
+ path.write_text(to_proto(schema), encoding="utf-8")
63
+
64
+
65
+ # ── Markdown summary ──────────────────────────────────────────────────────────
66
+
67
+ def to_markdown(schema: SchemaIR) -> str:
68
+ """Zwraca czytelne Markdown podsumowanie."""
69
+ funcs = schema.all_functions()
70
+ total = len(funcs)
71
+ commands = len(schema.commands())
72
+ queries = len(schema.queries())
73
+ orchestrators = len(schema.orchestrators())
74
+
75
+ lines: list[str] = [
76
+ "# Code2Schema Report",
77
+ "",
78
+ f"**Modules:** {len(schema.modules)} "
79
+ f"**Functions:** {total} "
80
+ f"**Workflows:** {len(schema.workflows)}",
81
+ "",
82
+ "## CQRS Distribution",
83
+ "",
84
+ f"| Role | Count |",
85
+ f"|------|-------|",
86
+ f"| Query | {queries} |",
87
+ f"| Command | {commands} |",
88
+ f"| Orchestrator | {orchestrators} |",
89
+ "",
90
+ ]
91
+
92
+ if schema.rules:
93
+ lines += ["## Quality Rules", ""]
94
+ for rule in schema.rules:
95
+ icon = "🔴" if rule.severity == "error" else "🟡"
96
+ lines.append(f"- {icon} **{rule.id}** `{rule.target}` — {rule.condition}")
97
+ lines.append("")
98
+
99
+ if schema.workflows:
100
+ lines += ["## Workflows (Orchestrators)", ""]
101
+ for wf in schema.workflows[:20]: # max 20
102
+ steps = " → ".join(s.callee for s in wf.steps[:8])
103
+ lines.append(f"- **{wf.name}**: `{wf.entry}` → {steps}")
104
+ lines.append("")
105
+
106
+ return "\n".join(lines)
107
+
108
+
109
+ def write_markdown(schema: SchemaIR, path: Path) -> None:
110
+ path.write_text(to_markdown(schema), encoding="utf-8")
111
+
112
+
113
+ # ── helpers ───────────────────────────────────────────────────────────────────
114
+
115
+ def _safe_proto_name(name: str) -> str:
116
+ """CamelCase dla nazw proto RPC."""
117
+ return "".join(part.capitalize() for part in name.split("_"))
@@ -0,0 +1,317 @@
1
+ """
2
+ code2schema.codegen.visualizer
3
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
4
+ Generuje interaktywny HTML z grafem CQRS (D3.js force layout).
5
+ Bez zewnętrznych zależności poza stdlib — D3 ładowany z CDN.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from code2schema.core.models import CQRSRole, SchemaIR
14
+
15
+ # ── Kolory ról ────────────────────────────────────────────────────────────────
16
+ ROLE_COLOR: dict[str, str] = {
17
+ CQRSRole.QUERY: "#4ade80", # zielony
18
+ CQRSRole.COMMAND: "#fb923c", # pomarańczowy
19
+ CQRSRole.ORCHESTRATOR: "#a78bfa", # fioletowy
20
+ CQRSRole.UNKNOWN: "#94a3b8", # szary
21
+ }
22
+
23
+ ROLE_EMOJI: dict[str, str] = {
24
+ CQRSRole.QUERY: "🔍",
25
+ CQRSRole.COMMAND: "✏️",
26
+ CQRSRole.ORCHESTRATOR: "🔀",
27
+ CQRSRole.UNKNOWN: "❓",
28
+ }
29
+
30
+
31
+ def _build_graph_data(schema: SchemaIR) -> dict[str, Any]:
32
+ """Buduje nodes/links dla D3 force graph."""
33
+ nodes: list[dict] = []
34
+ links: list[dict] = []
35
+ node_ids: dict[str, int] = {}
36
+
37
+ for func in schema.all_functions():
38
+ idx = len(nodes)
39
+ node_ids[func.name] = idx
40
+ nodes.append({
41
+ "id": idx,
42
+ "name": func.name,
43
+ "module": func.module,
44
+ "role": func.role.value,
45
+ "color": ROLE_COLOR.get(func.role, "#94a3b8"),
46
+ "emoji": ROLE_EMOJI.get(func.role, "❓"),
47
+ "fan_out": func.fan_out,
48
+ "lines": func.lines,
49
+ "side_effects": [s.value for s in func.side_effects],
50
+ "is_async": func.is_async,
51
+ })
52
+
53
+ for func in schema.all_functions():
54
+ src = node_ids.get(func.name)
55
+ for callee in func.calls:
56
+ dst = node_ids.get(callee)
57
+ if src is not None and dst is not None and src != dst:
58
+ links.append({"source": src, "target": dst})
59
+
60
+ rules_by_target = {}
61
+ for r in schema.rules:
62
+ rules_by_target.setdefault(r.target.split(".")[-1], []).append(r.id)
63
+
64
+ return {
65
+ "nodes": nodes,
66
+ "links": links,
67
+ "rules": rules_by_target,
68
+ "stats": {
69
+ "modules": len(schema.modules),
70
+ "functions": len(nodes),
71
+ "commands": len(schema.commands()),
72
+ "queries": len(schema.queries()),
73
+ "orchestrators": len(schema.orchestrators()),
74
+ "workflows": len(schema.workflows),
75
+ "rules": len(schema.rules),
76
+ },
77
+ }
78
+
79
+
80
+ _HTML_TEMPLATE = r"""<!DOCTYPE html>
81
+ <html lang="pl">
82
+ <head>
83
+ <meta charset="UTF-8">
84
+ <meta name="viewport" content="width=device-width,initial-scale=1">
85
+ <title>Code2Schema — CQRS Visualizer</title>
86
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.9.0/d3.min.js"></script>
87
+ <style>
88
+ *{box-sizing:border-box;margin:0;padding:0}
89
+ body{font-family:'Segoe UI',system-ui,sans-serif;background:#0f172a;color:#e2e8f0;height:100vh;display:flex;flex-direction:column}
90
+ header{padding:12px 20px;background:#1e293b;border-bottom:1px solid #334155;display:flex;align-items:center;gap:16px;flex-shrink:0}
91
+ header h1{font-size:18px;font-weight:700;color:#f8fafc}
92
+ header h1 span{color:#818cf8}
93
+ .stats{display:flex;gap:10px;margin-left:auto;flex-wrap:wrap}
94
+ .stat{background:#0f172a;border:1px solid #334155;border-radius:8px;padding:4px 12px;font-size:12px;display:flex;gap:6px;align-items:center}
95
+ .stat b{color:#f8fafc}
96
+ .main{display:flex;flex:1;overflow:hidden}
97
+ #graph{flex:1;overflow:hidden;cursor:grab}
98
+ #graph:active{cursor:grabbing}
99
+ #sidebar{width:280px;background:#1e293b;border-left:1px solid #334155;overflow-y:auto;flex-shrink:0;padding:16px;display:flex;flex-direction:column;gap:12px}
100
+ .legend h3,.panel h3{font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:#94a3b8;margin-bottom:8px}
101
+ .legend-row{display:flex;align-items:center;gap:8px;margin-bottom:6px;cursor:pointer;padding:4px 6px;border-radius:6px;transition:background .15s}
102
+ .legend-row:hover{background:#0f172a}
103
+ .legend-row input{cursor:pointer}
104
+ .dot{width:14px;height:14px;border-radius:50%;flex-shrink:0}
105
+ .legend-row span{font-size:13px}
106
+ .panel{background:#0f172a;border-radius:8px;padding:12px}
107
+ .panel p{font-size:12px;color:#94a3b8;margin-bottom:8px}
108
+ #detail{min-height:120px}
109
+ #detail .fname{font-size:15px;font-weight:600;color:#f8fafc;margin-bottom:4px}
110
+ #detail .fmodule{font-size:11px;color:#64748b;margin-bottom:10px;word-break:break-all}
111
+ #detail .badge{display:inline-block;border-radius:9999px;font-size:11px;padding:2px 10px;font-weight:600;margin-bottom:8px}
112
+ .tag{display:inline-block;background:#1e293b;border:1px solid #334155;border-radius:4px;font-size:10px;padding:1px 6px;margin:2px}
113
+ .rule-item{font-size:11px;color:#fbbf24;margin:2px 0}
114
+ #search{width:100%;background:#0f172a;border:1px solid #334155;border-radius:6px;padding:6px 10px;color:#e2e8f0;font-size:13px;outline:none}
115
+ #search:focus{border-color:#818cf8}
116
+ #search::placeholder{color:#475569}
117
+ .node circle{transition:r .2s,opacity .15s}
118
+ .node text{pointer-events:none;user-select:none}
119
+ .link{stroke:#334155;stroke-opacity:.7}
120
+ .node.dimmed circle{opacity:.2}
121
+ .node.dimmed text{opacity:.2}
122
+ .link.dimmed{stroke-opacity:.08}
123
+ </style>
124
+ </head>
125
+ <body>
126
+ <header>
127
+ <h1>Code2<span>Schema</span> — CQRS Visualizer</h1>
128
+ <div class="stats" id="stats"></div>
129
+ </header>
130
+ <div class="main">
131
+ <svg id="graph"></svg>
132
+ <div id="sidebar">
133
+ <div>
134
+ <input id="search" type="text" placeholder="🔍 Szukaj funkcji...">
135
+ </div>
136
+ <div class="legend">
137
+ <h3>Role CQRS</h3>
138
+ <label class="legend-row"><input type="checkbox" checked data-role="query"><div class="dot" style="background:#4ade80"></div><span>🔍 Query</span></label>
139
+ <label class="legend-row"><input type="checkbox" checked data-role="command"><div class="dot" style="background:#fb923c"></div><span>✏️ Command</span></label>
140
+ <label class="legend-row"><input type="checkbox" checked data-role="orchestrator"><div class="dot" style="background:#a78bfa"></div><span>🔀 Orchestrator</span></label>
141
+ <label class="legend-row"><input type="checkbox" checked data-role="unknown"><div class="dot" style="background:#94a3b8"></div><span>❓ Unknown</span></label>
142
+ </div>
143
+ <div class="panel">
144
+ <h3>Szczegóły węzła</h3>
145
+ <div id="detail"><p>Kliknij węzeł aby zobaczyć szczegóły</p></div>
146
+ </div>
147
+ <div class="panel">
148
+ <h3>Reguły jakości</h3>
149
+ <div id="rules-panel"><p>Brak naruszeń ✅</p></div>
150
+ </div>
151
+ </div>
152
+ </div>
153
+ <script>
154
+ const DATA = __GRAPH_DATA__;
155
+
156
+ // ── Stats bar ─────────────────────────────────────────────────────────────────
157
+ const s = DATA.stats;
158
+ document.getElementById('stats').innerHTML = [
159
+ ['📦 Modules', s.modules],
160
+ ['⚡ Functions', s.functions],
161
+ ['🔍 Queries', s.queries],
162
+ ['✏️ Commands', s.commands],
163
+ ['🔀 Orchestrators', s.orchestrators],
164
+ ['🔁 Workflows', s.workflows],
165
+ ['⚠️ Rules', s.rules],
166
+ ].map(([l,v])=>`<div class="stat">${l} <b>${v}</b></div>`).join('');
167
+
168
+ // ── Rules panel ───────────────────────────────────────────────────────────────
169
+ const rp = document.getElementById('rules-panel');
170
+ const rEntries = Object.entries(DATA.rules);
171
+ if(rEntries.length){
172
+ rp.innerHTML = rEntries.map(([fn,ids])=>
173
+ `<div class="rule-item">⚠️ <b>${fn}</b>: ${ids.join(', ')}</div>`
174
+ ).join('');
175
+ }
176
+
177
+ // ── D3 Force Graph ────────────────────────────────────────────────────────────
178
+ const svg = d3.select('#graph');
179
+ const container = document.getElementById('graph');
180
+
181
+ let W = container.clientWidth, H = container.clientHeight;
182
+ svg.attr('width', W).attr('height', H);
183
+
184
+ const g = svg.append('g');
185
+
186
+ // Zoom
187
+ svg.call(d3.zoom().scaleExtent([0.1, 4]).on('zoom', e => g.attr('transform', e.transform)));
188
+
189
+ // Arrow marker
190
+ svg.append('defs').append('marker')
191
+ .attr('id','arrow').attr('viewBox','0 -4 8 8').attr('refX',16).attr('refY',0)
192
+ .attr('markerWidth',6).attr('markerHeight',6).attr('orient','auto')
193
+ .append('path').attr('d','M0,-4L8,0L0,4').attr('fill','#475569');
194
+
195
+ const simulation = d3.forceSimulation(DATA.nodes)
196
+ .force('link', d3.forceLink(DATA.links).id(d=>d.id).distance(80).strength(0.4))
197
+ .force('charge', d3.forceManyBody().strength(-220))
198
+ .force('center', d3.forceCenter(W/2, H/2))
199
+ .force('collision', d3.forceCollide(d => nodeRadius(d) + 6));
200
+
201
+ function nodeRadius(d){
202
+ if(d.role==='orchestrator') return 16 + Math.min(d.fan_out, 20);
203
+ if(d.role==='command') return 10;
204
+ return 8;
205
+ }
206
+
207
+ const link = g.append('g').selectAll('line')
208
+ .data(DATA.links).join('line')
209
+ .attr('class','link')
210
+ .attr('stroke-width', 1.2)
211
+ .attr('marker-end','url(#arrow)');
212
+
213
+ const node = g.append('g').selectAll('.node')
214
+ .data(DATA.nodes).join('g')
215
+ .attr('class','node')
216
+ .call(d3.drag()
217
+ .on('start',(e,d)=>{ if(!e.active) simulation.alphaTarget(0.3).restart(); d.fx=d.x; d.fy=d.y; })
218
+ .on('drag', (e,d)=>{ d.fx=e.x; d.fy=e.y; })
219
+ .on('end', (e,d)=>{ if(!e.active) simulation.alphaTarget(0); d.fx=null; d.fy=null; })
220
+ )
221
+ .on('click', onNodeClick)
222
+ .on('mouseover', onNodeHover)
223
+ .on('mouseout', onNodeOut);
224
+
225
+ node.append('circle')
226
+ .attr('r', nodeRadius)
227
+ .attr('fill', d=>d.color)
228
+ .attr('stroke', '#0f172a')
229
+ .attr('stroke-width', 2);
230
+
231
+ node.append('text')
232
+ .attr('dy', d => nodeRadius(d) + 12)
233
+ .attr('text-anchor','middle')
234
+ .attr('font-size', 10)
235
+ .attr('fill','#94a3b8')
236
+ .text(d => d.name.length > 18 ? d.name.slice(0,17)+'…' : d.name);
237
+
238
+ simulation.on('tick', ()=>{
239
+ link
240
+ .attr('x1',d=>d.source.x).attr('y1',d=>d.source.y)
241
+ .attr('x2',d=>d.target.x).attr('y2',d=>d.target.y);
242
+ node.attr('transform',d=>`translate(${d.x},${d.y})`);
243
+ });
244
+
245
+ // ── Hover ─────────────────────────────────────────────────────────────────────
246
+ function onNodeHover(e, d){
247
+ const connected = new Set([d.id]);
248
+ DATA.links.forEach(l=>{
249
+ if(l.source.id===d.id||l.target.id===d.id){
250
+ connected.add(l.source.id); connected.add(l.target.id);
251
+ }
252
+ });
253
+ node.classed('dimmed', n => !connected.has(n.id));
254
+ link.classed('dimmed', l => l.source.id!==d.id && l.target.id!==d.id);
255
+ }
256
+
257
+ function onNodeOut(){
258
+ node.classed('dimmed', false);
259
+ link.classed('dimmed', false);
260
+ }
261
+
262
+ // ── Click detail ──────────────────────────────────────────────────────────────
263
+ function onNodeClick(e, d){
264
+ const roleColors={'query':'#4ade80','command':'#fb923c','orchestrator':'#a78bfa','unknown':'#94a3b8'};
265
+ const rules = DATA.rules[d.name] || [];
266
+ const sideEff = d.side_effects.filter(s=>s!=='none');
267
+ document.getElementById('detail').innerHTML = `
268
+ <div class="fname">${d.emoji} ${d.name}</div>
269
+ <div class="fmodule">${d.module}</div>
270
+ <span class="badge" style="background:${roleColors[d.role]}20;color:${roleColors[d.role]};border:1px solid ${roleColors[d.role]}40">${d.role.toUpperCase()}</span>
271
+ ${d.is_async ? '<span class="badge" style="background:#0ea5e920;color:#38bdf8;border:1px solid #0ea5e940">async</span>' : ''}
272
+ <div style="margin-top:8px;font-size:12px;color:#64748b">
273
+ <div>Fan-out: <b style="color:#e2e8f0">${d.fan_out}</b></div>
274
+ <div>Lines: <b style="color:#e2e8f0">${d.lines}</b></div>
275
+ ${sideEff.length ? `<div style="margin-top:4px">Side effects:<br>${sideEff.map(s=>`<span class="tag">⚡${s}</span>`).join('')}</div>` : ''}
276
+ ${rules.length ? `<div style="margin-top:6px;color:#fbbf24">${rules.map(r=>`⚠️ ${r}`).join('<br>')}</div>` : ''}
277
+ </div>
278
+ `;
279
+ }
280
+
281
+ // ── Search ────────────────────────────────────────────────────────────────────
282
+ const hidden = new Set();
283
+ document.getElementById('search').addEventListener('input', e=>{
284
+ const q = e.target.value.trim().toLowerCase();
285
+ if(!q){ node.classed('dimmed',false); link.classed('dimmed',false); return; }
286
+ node.classed('dimmed', d => !d.name.toLowerCase().includes(q));
287
+ link.classed('dimmed', l => !l.source.name.toLowerCase().includes(q) && !l.target.name.toLowerCase().includes(q));
288
+ });
289
+
290
+ // ── Legend filters ────────────────────────────────────────────────────────────
291
+ document.querySelectorAll('[data-role]').forEach(cb=>{
292
+ cb.addEventListener('change', ()=>{
293
+ const active = new Set([...document.querySelectorAll('[data-role]:checked')].map(c=>c.dataset.role));
294
+ node.style('display', d => active.has(d.role) ? null : 'none');
295
+ link.style('display', l => active.has(l.source.role) && active.has(l.target.role) ? null : 'none');
296
+ });
297
+ });
298
+
299
+ // ── Resize ────────────────────────────────────────────────────────────────────
300
+ window.addEventListener('resize', ()=>{
301
+ W = container.clientWidth; H = container.clientHeight;
302
+ svg.attr('width',W).attr('height',H);
303
+ simulation.force('center', d3.forceCenter(W/2,H/2)).alpha(0.3).restart();
304
+ });
305
+ </script>
306
+ </body>
307
+ </html>"""
308
+
309
+
310
+ def to_html(schema: SchemaIR) -> str:
311
+ """Generuje interaktywny HTML z grafem CQRS."""
312
+ data = _build_graph_data(schema)
313
+ return _HTML_TEMPLATE.replace("__GRAPH_DATA__", json.dumps(data))
314
+
315
+
316
+ def write_html(schema: SchemaIR, path: Path) -> None:
317
+ path.write_text(to_html(schema), encoding="utf-8")
@@ -0,0 +1 @@
1
+ # code2schema.core
@@ -0,0 +1,160 @@
1
+ """
2
+ code2schema.core.extractor
3
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~
4
+ Ekstrakcja funkcji i importów z plików .py przy użyciu wbudowanego modułu `ast`.
5
+ Bez zewnętrznych zależności — czyste stdlib.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import ast
10
+ import os
11
+ from pathlib import Path
12
+ from typing import List
13
+
14
+ from code2schema.core.models import FunctionIR, ModuleIR
15
+
16
+
17
+ # ── Wzorce side-effectów ─────────────────────────────────────────────────────
18
+
19
+ _FILESYSTEM_CALLS: set[str] = {"open", "write", "read", "unlink", "mkdir", "rmdir", "rename"}
20
+ _NETWORK_CALLS: set[str] = {"get", "post", "put", "delete", "patch", "request", "fetch", "connect"}
21
+ _SYSTEM_CALLS: set[str] = {"system", "popen", "subprocess", "Popen", "run", "call", "check_output"}
22
+ _DB_CALLS: set[str] = {"execute", "commit", "rollback", "query", "insert", "update", "delete"}
23
+
24
+ _NETWORK_MODULES: set[str] = {"requests", "httpx", "aiohttp", "urllib", "http"}
25
+ _SYSTEM_MODULES: set[str] = {"os", "subprocess", "shutil", "sys"}
26
+
27
+
28
+ class _FunctionVisitor(ast.NodeVisitor):
29
+ """Odwiedza węzły AST i buduje FunctionIR."""
30
+
31
+ def __init__(self, module_name: str) -> None:
32
+ self.module_name = module_name
33
+ self.functions: list[FunctionIR] = []
34
+ self._imports: set[str] = set()
35
+
36
+ # ── imports ───────────────────────────────────────────────────────────────
37
+
38
+ def visit_Import(self, node: ast.Import) -> None:
39
+ for alias in node.names:
40
+ self._imports.add(alias.name.split(".")[0])
41
+ self.generic_visit(node)
42
+
43
+ def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
44
+ if node.module:
45
+ self._imports.add(node.module.split(".")[0])
46
+ self.generic_visit(node)
47
+
48
+ # ── functions ─────────────────────────────────────────────────────────────
49
+
50
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
51
+ self._process_func(node, is_async=False)
52
+ self.generic_visit(node)
53
+
54
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
55
+ self._process_func(node, is_async=True)
56
+ self.generic_visit(node)
57
+
58
+ def _process_func(self, node: ast.FunctionDef | ast.AsyncFunctionDef, is_async: bool) -> None:
59
+ calls = self._collect_calls(node)
60
+ side_effects = self._detect_side_effects(node, calls)
61
+ docstring = ast.get_docstring(node)
62
+
63
+ func = FunctionIR(
64
+ name=node.name,
65
+ module=self.module_name,
66
+ calls=list(dict.fromkeys(calls)), # deduplicate, preserve order
67
+ fan_out=len(set(calls)),
68
+ side_effects=side_effects,
69
+ lines=node.end_lineno - node.lineno + 1 if hasattr(node, "end_lineno") else 0,
70
+ is_async=is_async,
71
+ docstring=docstring,
72
+ )
73
+ self.functions.append(func)
74
+
75
+ def _collect_calls(self, node: ast.AST) -> list[str]:
76
+ """Zbiera nazwy wszystkich wywołań funkcji wewnątrz węzła."""
77
+ calls: list[str] = []
78
+ for child in ast.walk(node):
79
+ if isinstance(child, ast.Call):
80
+ name = self._resolve_call_name(child.func)
81
+ if name:
82
+ calls.append(name)
83
+ return calls
84
+
85
+ @staticmethod
86
+ def _resolve_call_name(func_node: ast.expr) -> str | None:
87
+ if isinstance(func_node, ast.Name):
88
+ return func_node.id
89
+ if isinstance(func_node, ast.Attribute):
90
+ return func_node.attr
91
+ return None
92
+
93
+ def _detect_side_effects(
94
+ self, node: ast.AST, calls: list[str]
95
+ ) -> list[str]:
96
+ from code2schema.core.models import SideEffect
97
+
98
+ effects: list[SideEffect] = []
99
+ call_set = set(calls)
100
+
101
+ if call_set & _FILESYSTEM_CALLS:
102
+ effects.append(SideEffect.FILESYSTEM)
103
+ if call_set & _NETWORK_CALLS:
104
+ effects.append(SideEffect.NETWORK)
105
+ if call_set & _SYSTEM_CALLS:
106
+ effects.append(SideEffect.SYSTEM)
107
+ if call_set & _DB_CALLS:
108
+ effects.append(SideEffect.DATABASE)
109
+
110
+ return effects or [SideEffect.NONE]
111
+
112
+
113
+ # ── Public API ────────────────────────────────────────────────────────────────
114
+
115
+ def extract_module(path: Path) -> ModuleIR | None:
116
+ """Parsuje jeden plik .py i zwraca ModuleIR."""
117
+ try:
118
+ source = path.read_text(encoding="utf-8", errors="replace")
119
+ tree = ast.parse(source, filename=str(path))
120
+ except SyntaxError:
121
+ return None
122
+
123
+ module_name = _path_to_module(path)
124
+ visitor = _FunctionVisitor(module_name)
125
+ visitor.visit(tree)
126
+
127
+ imports = [
128
+ alias.name
129
+ for node in ast.walk(tree)
130
+ for alias in (node.names if isinstance(node, (ast.Import, ast.ImportFrom)) else [])
131
+ ]
132
+
133
+ return ModuleIR(
134
+ name=module_name,
135
+ path=str(path),
136
+ functions=visitor.functions,
137
+ imports=list(dict.fromkeys(imports)),
138
+ lines=len(source.splitlines()),
139
+ )
140
+
141
+
142
+ def extract_project(root: Path, exclude: list[str] | None = None) -> list[ModuleIR]:
143
+ """Rekurencyjnie przetwarza katalog i zwraca listę ModuleIR."""
144
+ exclude = set(exclude or ["__pycache__", ".venv", "venv", "node_modules", ".git"])
145
+ modules: list[ModuleIR] = []
146
+
147
+ for py_file in root.rglob("*.py"):
148
+ if any(part in exclude for part in py_file.parts):
149
+ continue
150
+ mod = extract_module(py_file)
151
+ if mod is not None:
152
+ modules.append(mod)
153
+
154
+ return modules
155
+
156
+
157
+ def _path_to_module(path: Path) -> str:
158
+ """Konwertuje ścieżkę pliku na notację modułu (dots)."""
159
+ parts = list(path.with_suffix("").parts)
160
+ return ".".join(parts)