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.
- package/LICENSE +21 -0
- package/README.md +117 -0
- package/README.zh-CN.md +89 -0
- package/SKILL.md +155 -0
- package/dist/archmap-client.js +361 -0
- package/dist/bin.js +2299 -0
- package/dist/py_extract.py +246 -0
- package/package.json +56 -0
|
@@ -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
|
+
}
|