codeblast 0.2.0

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,246 @@
1
+ """
2
+ codeblast Python 提取器 — 方案 B(高置信边 only)。
3
+
4
+ 只产出静态确定的东西:
5
+ - 节点: file / class / function / method(test 文件的可调用体记 kind=test)
6
+ - 边: imports(file→file,含 from-import)/ contains / extends(类继承)
7
+ calls —— 仅两类高置信:
8
+ 1) 同文件直接名字调用 foo() → 本文件的 def foo
9
+ 2) self.method() → 本类或基类(仓内可解析时)的 method
10
+ - 盲区: 属性链调用 obj.m()(obj 非 self)、getattr/eval/exec、动态 import、
11
+ 星号 import —— 全部显式记录。Python 无函数级零漏报承诺(intent.md 方案 B)。
12
+
13
+ stdout: JSON {files:[{path, hash, nodes, edges, blind_spots}]}
14
+ 用法: python3 py_extract.py <repo_root>
15
+ """
16
+ import ast
17
+ import hashlib
18
+ import json
19
+ import os
20
+ import sys
21
+
22
+ SKIP_DIRS = {"node_modules", ".git", "__pycache__", ".venv", "venv", "dist",
23
+ "build", ".idea", ".vscode", ".pytest_cache", "site-packages", "egg-info"}
24
+ TEST_MARKERS = ("test_", "_test.py", "conftest.py")
25
+
26
+
27
+ def is_test_file(rel_path):
28
+ base = os.path.basename(rel_path)
29
+ return base.startswith("test") or base.endswith("_test.py") or "/tests/" in rel_path.replace(os.sep, "/")
30
+
31
+
32
+ def module_to_path(module, level, cur_rel_dir, repo_root, py_files):
33
+ """from .. import / import a.b.c → 仓内文件相对路径(找不到返回 None = 外部包)。"""
34
+ if level > 0: # 相对导入
35
+ parts = cur_rel_dir.split(os.sep) if cur_rel_dir else []
36
+ if level - 1 > 0:
37
+ parts = parts[: -(level - 1)] if level - 1 <= len(parts) else []
38
+ base = parts + (module.split(".") if module else [])
39
+ else:
40
+ base = module.split(".") if module else []
41
+ if not base:
42
+ return None
43
+ for suffix in (os.path.join(*base) + ".py", os.path.join(*base, "__init__.py")):
44
+ if suffix in py_files:
45
+ return suffix
46
+ return None
47
+
48
+
49
+ class FileExtractor(ast.NodeVisitor):
50
+ def __init__(self, rel_path, repo_root, py_files, source):
51
+ self.rel = rel_path
52
+ self.repo_root = repo_root
53
+ self.py_files = py_files
54
+ self.is_test = is_test_file(rel_path)
55
+ self.nodes = []
56
+ self.edges = []
57
+ self.blind = []
58
+ self.scope = [] # 类/函数名栈
59
+ self.local_defs = {} # 名字 → 节点 id(本文件顶层 def/class)
60
+ self.class_methods = {} # 类名 → {方法名 → 节点 id}
61
+ self.imported_names = {} # 本地名 → (目标文件, 原名):from x import f [as g] 的绑定
62
+ self.local_types = {} # 变量名 → 类名(本文件/具名导入的类构造):b = Builder()
63
+ end = len(source.splitlines()) or 1
64
+ self.nodes.append(dict(id=rel_path, kind="file", name=os.path.basename(rel_path),
65
+ file=rel_path, line=1, end_line=end, exported=0, signature="", src_file=rel_path))
66
+
67
+ # ---------- 定义 ----------
68
+ def qualname(self, name):
69
+ return self.rel + "#" + ".".join(self.scope + [name]) if self.scope else f"{self.rel}#{name}"
70
+
71
+ def visit_ClassDef(self, node):
72
+ cid = self.qualname(node.name)
73
+ self.nodes.append(dict(id=cid, kind="class", name=node.name, file=self.rel,
74
+ line=node.lineno, end_line=node.end_lineno or node.lineno,
75
+ exported=int(not node.name.startswith("_")), signature="", src_file=self.rel))
76
+ self.edges.append(dict(src=self.rel, dst=cid, kind="contains", file=self.rel,
77
+ line=node.lineno, confidence="exact", src_file=self.rel))
78
+ if not self.scope:
79
+ self.local_defs[node.name] = cid
80
+ self.class_methods.setdefault(node.name, {})
81
+ for base in node.bases: # extends 边:仅可解析为本文件顶层类名的
82
+ if isinstance(base, ast.Name) and base.id in self.local_defs:
83
+ self.edges.append(dict(src=cid, dst=self.local_defs[base.id], kind="extends",
84
+ file=self.rel, line=node.lineno, confidence="exact", src_file=self.rel))
85
+ self.scope.append(node.name)
86
+ self.generic_visit(node)
87
+ self.scope.pop()
88
+
89
+ def _visit_func(self, node):
90
+ fid = self.qualname(node.name)
91
+ in_class = bool(self.scope) and self.scope[-1] in self.class_methods
92
+ kind = "test" if self.is_test else ("method" if in_class else "function")
93
+ try:
94
+ sig = ast.unparse(node.args)[:200] if hasattr(ast, "unparse") else ""
95
+ except Exception:
96
+ sig = ""
97
+ self.nodes.append(dict(id=fid, kind=kind, name=node.name, file=self.rel,
98
+ line=node.lineno, end_line=node.end_lineno or node.lineno,
99
+ exported=int(not node.name.startswith("_")), signature=sig, src_file=self.rel))
100
+ self.edges.append(dict(src=self.rel, dst=fid, kind="contains", file=self.rel,
101
+ line=node.lineno, confidence="exact", src_file=self.rel))
102
+ if not self.scope:
103
+ self.local_defs[node.name] = fid
104
+ elif in_class:
105
+ self.class_methods[self.scope[-1]][node.name] = fid
106
+ # 参数注解 → 类型环境: def f(b: Builder) 使函数体内 b.method() 可解析
107
+ saved_types = dict(self.local_types)
108
+ for arg in list(node.args.args) + list(node.args.kwonlyargs):
109
+ if arg.annotation and isinstance(arg.annotation, ast.Name):
110
+ ann = arg.annotation.id
111
+ if ann in self.class_methods:
112
+ self.local_types[arg.arg] = (self.rel, ann)
113
+ elif ann in self.imported_names:
114
+ dst_file, orig = self.imported_names[ann]
115
+ self.local_types[arg.arg] = (dst_file, orig)
116
+ self.scope.append(node.name)
117
+ self.generic_visit(node)
118
+ self.scope.pop()
119
+ self.local_types = saved_types
120
+
121
+ visit_FunctionDef = _visit_func
122
+ visit_AsyncFunctionDef = _visit_func
123
+
124
+ # ---------- import ----------
125
+ def visit_Import(self, node):
126
+ for alias in node.names:
127
+ dst = module_to_path(alias.name, 0, os.path.dirname(self.rel), self.repo_root, self.py_files)
128
+ if dst:
129
+ self.edges.append(dict(src=self.rel, dst=dst, kind="imports", file=self.rel,
130
+ line=node.lineno, confidence="exact", src_file=self.rel))
131
+
132
+ def visit_ImportFrom(self, node):
133
+ if any(a.name == "*" for a in node.names):
134
+ self.blind.append(dict(file=self.rel, line=node.lineno,
135
+ reason=f"star import: from {node.module or '.'} import *", src_file=self.rel))
136
+ dst = module_to_path(node.module or "", node.level, os.path.dirname(self.rel), self.repo_root, self.py_files)
137
+ if dst:
138
+ self.edges.append(dict(src=self.rel, dst=dst, kind="imports", file=self.rel,
139
+ line=node.lineno, confidence="exact", src_file=self.rel))
140
+ for a in node.names:
141
+ if a.name != "*":
142
+ self.imported_names[a.asname or a.name] = (dst, a.name)
143
+
144
+ # ---------- 调用 ----------
145
+ def current_caller(self):
146
+ if not self.scope:
147
+ return self.rel
148
+ # 栈顶回溯出完整限定 id
149
+ return f"{self.rel}#{'.'.join(self.scope)}"
150
+
151
+ def visit_Call(self, node):
152
+ caller = self.current_caller()
153
+ f = node.func
154
+ if isinstance(f, ast.Name):
155
+ if f.id in ("eval", "exec", "getattr", "__import__"):
156
+ self.blind.append(dict(file=self.rel, line=node.lineno,
157
+ reason=f"dynamic call: {f.id}()", src_file=self.rel))
158
+ elif f.id in self.local_defs: # 高置信 1:本文件顶层名字
159
+ self.edges.append(dict(src=caller, dst=self.local_defs[f.id], kind="calls",
160
+ file=self.rel, line=node.lineno, confidence="exact", src_file=self.rel))
161
+ elif f.id in self.imported_names: # 高置信 3:具名导入的跨文件调用 from x import f
162
+ dst_file, orig = self.imported_names[f.id]
163
+ self.edges.append(dict(src=caller, dst=f"{dst_file}#{orig}", kind="calls",
164
+ file=self.rel, line=node.lineno, confidence="exact", src_file=self.rel))
165
+ # 其余名字调用(内建等):文件级由 imports 边兜底
166
+ elif isinstance(f, ast.Attribute):
167
+ if isinstance(f.value, ast.Name) and f.value.id == "self" and len(self.scope) >= 2:
168
+ cls = self.scope[0]
169
+ mid = self.class_methods.get(cls, {}).get(f.attr)
170
+ if mid: # 高置信 2:self.method → 本类方法
171
+ self.edges.append(dict(src=caller, dst=mid, kind="calls", file=self.rel,
172
+ line=node.lineno, confidence="exact", src_file=self.rel))
173
+ else: # 基类方法或动态 → 盲区
174
+ self.blind.append(dict(file=self.rel, line=node.lineno,
175
+ reason=f"unresolved self call: self.{f.attr}()", src_file=self.rel))
176
+ elif isinstance(f.value, ast.Name) and f.value.id in self.local_types:
177
+ # 高置信 4:类型可知的方法调用 b = Builder(); b.method() / 注解 b: Builder
178
+ cls_file, cls_name = self.local_types[f.value.id]
179
+ self.edges.append(dict(src=caller, dst=f"{cls_file}#{cls_name}.{f.attr}", kind="calls",
180
+ file=self.rel, line=node.lineno, confidence="conservative", src_file=self.rel))
181
+ elif isinstance(f.value, ast.Name) and f.value.id in self.imported_names:
182
+ # 高置信 5:模块别名成员调用 from pkg import mod; mod.func()
183
+ dst_file, orig = self.imported_names[f.value.id]
184
+ self.edges.append(dict(src=caller, dst=f"{dst_file}#{f.attr}", kind="calls",
185
+ file=self.rel, line=node.lineno, confidence="conservative", src_file=self.rel))
186
+ else: # obj.method() —— Python 无类型信息,原理性盲区
187
+ chain = ast.unparse(f)[:80] if hasattr(ast, "unparse") else f.attr
188
+ self.blind.append(dict(file=self.rel, line=node.lineno,
189
+ reason=f"attribute call: {chain}()", src_file=self.rel))
190
+ self.generic_visit(node)
191
+ def visit_Assign(self, node):
192
+ # 类型推断: b = Builder() —— Builder 是本文件类或具名导入的类
193
+ if isinstance(node.value, ast.Call) and isinstance(node.value.func, ast.Name):
194
+ ctor = node.value.func.id
195
+ target_cls = None
196
+ if ctor in self.class_methods:
197
+ target_cls = (self.rel, ctor)
198
+ elif ctor in self.imported_names and ctor[:1].isupper():
199
+ dst_file, orig = self.imported_names[ctor]
200
+ target_cls = (dst_file, orig)
201
+ if target_cls:
202
+ for t in node.targets:
203
+ if isinstance(t, ast.Name):
204
+ self.local_types[t.id] = target_cls
205
+ self.generic_visit(node)
206
+
207
+ def visit_AnnAssign(self, node):
208
+ # 类型注解: b: Builder = ... / 参数级注解在 _visit_func 处理
209
+ if isinstance(node.target, ast.Name) and isinstance(node.annotation, ast.Name):
210
+ ann = node.annotation.id
211
+ if ann in self.class_methods:
212
+ self.local_types[node.target.id] = (self.rel, ann)
213
+ elif ann in self.imported_names:
214
+ dst_file, orig = self.imported_names[ann]
215
+ self.local_types[node.target.id] = (dst_file, orig)
216
+ self.generic_visit(node)
217
+
218
+
219
+ def main():
220
+ repo_root = os.path.abspath(sys.argv[1])
221
+ py_files = set()
222
+ for dirpath, dirnames, filenames in os.walk(repo_root):
223
+ dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS and not d.endswith(".egg-info")]
224
+ for fn in filenames:
225
+ if fn.endswith(".py"):
226
+ py_files.add(os.path.relpath(os.path.join(dirpath, fn), repo_root))
227
+
228
+ out = []
229
+ for rel in sorted(py_files):
230
+ abs_path = os.path.join(repo_root, rel)
231
+ try:
232
+ source = open(abs_path, encoding="utf-8", errors="replace").read()
233
+ tree = ast.parse(source)
234
+ except SyntaxError as e:
235
+ out.append(dict(path=rel, hash="", parse_error=str(e), nodes=[], edges=[], blind_spots=[
236
+ dict(file=rel, line=e.lineno or 1, reason=f"syntax error: {e.msg}", src_file=rel)]))
237
+ continue
238
+ ex = FileExtractor(rel, repo_root, py_files, source)
239
+ ex.visit(tree)
240
+ out.append(dict(path=rel, hash=hashlib.sha1(source.encode()).hexdigest(),
241
+ nodes=ex.nodes, edges=ex.edges, blind_spots=ex.blind))
242
+ json.dump({"files": out}, sys.stdout)
243
+
244
+
245
+ if __name__ == "__main__":
246
+ main()
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "codeblast",
3
+ "version": "0.2.0",
4
+ "description": "Know what breaks before you merge — mutation-tested code graph with architecture, change & impact maps. Evidence on every edge. For humans and AI agents.",
5
+ "keywords": [
6
+ "impact-analysis",
7
+ "blast-radius",
8
+ "call-graph",
9
+ "code-graph",
10
+ "architecture",
11
+ "code-visualization",
12
+ "static-analysis",
13
+ "typescript",
14
+ "monorepo",
15
+ "pr-review",
16
+ "agent-skill"
17
+ ],
18
+ "license": "MIT",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/alloevil/codeblast.git"
22
+ },
23
+ "homepage": "https://alloevil.github.io/codeblast/",
24
+ "bugs": {
25
+ "url": "https://github.com/alloevil/codeblast/issues"
26
+ },
27
+ "type": "module",
28
+ "bin": {
29
+ "codeblast": "./dist/bin.js"
30
+ },
31
+ "files": [
32
+ "dist",
33
+ "SKILL.md",
34
+ "README.md",
35
+ "LICENSE"
36
+ ],
37
+ "scripts": {
38
+ "build": "bun build src/bin.ts --target=node --outdir=dist --entry-naming=bin.js --external typescript --external @dagrejs/dagre && cp src/archmap-client.js src/py_extract.py dist/",
39
+ "prepack": "bun run build",
40
+ "typecheck": "tsc -p .",
41
+ "test": "bun test",
42
+ "demo": "bun src/bin.ts demo",
43
+ "verify": "python3 eval/mutation_check.py"
44
+ },
45
+ "engines": {
46
+ "node": ">=22.13.0"
47
+ },
48
+ "dependencies": {
49
+ "@dagrejs/dagre": "^3.1.1",
50
+ "typescript": "^5.9.3"
51
+ },
52
+ "devDependencies": {
53
+ "@types/node": "^26.4.0",
54
+ "bun-types": "^1.4.0"
55
+ }
56
+ }