feat: py_detect L3 数据流引擎 (部分路径未定义就用 E004)
- _dataflow_check: 对每个函数收集局部变量(参数+赋值目标, 排除 global/nonlocal), 按路径合并扫描 - if/elif: 合并点 defined = 两分支交集 (只有都赋值才确定) - for/while: 循环体赋值可能 0 次执行 → 不贡献确定; 循环体内 target 确定有值; orelse 用进入前集合 - try: 主路径赋值算确定, except 分支独立不合并; with/delete/return/raise 处理 - 保守原则: 宁漏勿误, 只报确定会炸的路径 - E004 动态修复建议: 建议使用前赋初值 x = None 或确保所有分支赋值 - 版本 1.3.0; 新增 12 项数据流测试; 相关套件 275 passed 零回归 - 至此 L1 规则 + L2 jedi 语义 + L3 数据流 三代引擎齐备
This commit is contained in:
1 parent
4fb9392db4
commit
413f66deb8
2 files changed
+193
-1
No files matched your search
+137
-1
@@ -32,7 +32,7 @@ import os
|
||||
import tokenize
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
__version__ = "1.2.0"
|
||||
__version__ = "1.3.0"
|
||||
|
||||
# ── 内置名集合 (判定「覆盖内置」与「未定义」用) ──
|
||||
if isinstance(__builtins__, dict):
|
||||
@@ -45,6 +45,7 @@ RULES: Dict[str, Tuple[str, str, str]] = {
|
||||
"E001": ("错误", "语法错误", "修复: 检查该行附近——常见原因: 缺冒号、括号不匹配、引号未闭合、缩进错误。"),
|
||||
"E002": ("错误", "使用了未定义的变量", "修复: 先给变量赋值或导入它;若是闭包/动态注入(globals()/exec)可忽略。"),
|
||||
"E003": ("错误", "函数/类重复定义", "修复: 删掉其中一个定义,或改名避免覆盖。"),
|
||||
"E004": ("错误", "变量可能未定义就使用(部分执行路径未赋值)", "修复: 使用前先赋初值(如 x = None),或确保所有分支都赋值。"),
|
||||
"W001": ("警告", "导入后未使用", "修复: 删除该 import,或补上使用处。"),
|
||||
"W002": ("警告", "裸 except", "修复: 写成 except Exception: 或指定具体异常类型,避免吞掉所有错误。"),
|
||||
"W003": ("警告", "捕获范围过宽 (except Exception)", "修复: 尽量捕获具体异常 (如 ValueError/KeyError),避免隐藏 bug。"),
|
||||
@@ -134,6 +135,13 @@ def _smart_explain(code: str, ctx: dict) -> Tuple[str, str]:
|
||||
fix = f"修复: 第 {prev_line} 行 {prev} 之后的代码永远不会执行——删除它,或把逻辑放到 {prev} 之前。"
|
||||
return title, fix
|
||||
|
||||
elif code == "E004":
|
||||
name = ctx.get("name", "")
|
||||
if name:
|
||||
fix = (f"修复: {name} 在某条执行路径上可能没赋值就被使用(运行时 UnboundLocalError)。"
|
||||
f"建议在使用前先赋初值: {name} = None(或按逻辑给默认值),或确保所有分支都赋值。")
|
||||
return title, fix
|
||||
|
||||
return title, base_fix
|
||||
|
||||
|
||||
@@ -490,6 +498,132 @@ def _check_syntax(source: str) -> Tuple[List[Dict], Optional[ast.Module]]:
|
||||
return issues, tree
|
||||
|
||||
|
||||
def _flow_collect_stores(target, defined: set):
|
||||
"""登记赋值目标到确定集合"""
|
||||
if isinstance(target, ast.Name):
|
||||
defined.add(target.id)
|
||||
elif isinstance(target, (ast.Tuple, ast.List)):
|
||||
for elt in target.elts:
|
||||
_flow_collect_stores(elt, defined)
|
||||
|
||||
|
||||
def _flow_maybe_report(node, locals_set, defined, analyzer):
|
||||
"""单个 Name 使用点: 若为函数局部变量且当前路径上不确定 → E004"""
|
||||
if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load):
|
||||
nid = node.id
|
||||
if nid in locals_set and nid not in defined:
|
||||
analyzer._add("E004", node.lineno, node.col_offset,
|
||||
extra=f"可能未定义: {nid}", name=nid)
|
||||
|
||||
|
||||
def _flow_check_loads(node, locals_set, defined, analyzer):
|
||||
"""检查表达式树里的所有 Name Load"""
|
||||
if node is None:
|
||||
return
|
||||
for sub in ast.walk(node):
|
||||
_flow_maybe_report(sub, locals_set, defined, analyzer)
|
||||
|
||||
|
||||
def _flow_statements(stmts, locals_set, defined, analyzer):
|
||||
"""按路径合并逻辑扫描语句列表, 更新确定定义集合 defined。
|
||||
保守原则: 拿不准就不报(宁漏勿误), 只报「某条路径确定未赋值就被用」。"""
|
||||
for stmt in stmts:
|
||||
if isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
||||
continue # 嵌套定义不参与外层数据流
|
||||
if isinstance(stmt, ast.Assign):
|
||||
_flow_check_loads(stmt.value, locals_set, defined, analyzer)
|
||||
for t in stmt.targets:
|
||||
_flow_collect_stores(t, defined)
|
||||
elif isinstance(stmt, ast.AnnAssign):
|
||||
_flow_check_loads(stmt.value, locals_set, defined, analyzer)
|
||||
if isinstance(stmt.target, ast.Name):
|
||||
defined.add(stmt.target.id)
|
||||
elif isinstance(stmt, ast.AugAssign):
|
||||
_flow_maybe_report(stmt.target, locals_set, defined, analyzer)
|
||||
if isinstance(stmt.target, ast.Name):
|
||||
defined.add(stmt.target.id)
|
||||
_flow_check_loads(stmt.value, locals_set, defined, analyzer)
|
||||
elif isinstance(stmt, ast.For):
|
||||
_flow_check_loads(stmt.iter, locals_set, defined, analyzer)
|
||||
d0 = set(defined)
|
||||
d_inner = set(defined)
|
||||
_flow_collect_stores(stmt.target, d_inner) # 循环体内 target 确定有值
|
||||
_flow_statements(stmt.body, locals_set, d_inner, analyzer)
|
||||
_flow_statements(stmt.orelse, locals_set, set(d0), analyzer) # orelse 在 0 次循环也执行
|
||||
# 循环体/orelse 的赋值可能 0 次执行 → 不贡献确定
|
||||
defined.clear(); defined.update(d0)
|
||||
elif isinstance(stmt, ast.While):
|
||||
_flow_check_loads(stmt.test, locals_set, defined, analyzer)
|
||||
d0 = set(defined)
|
||||
d_inner = set(defined)
|
||||
_flow_statements(stmt.body, locals_set, d_inner, analyzer)
|
||||
_flow_statements(stmt.orelse, locals_set, d_inner, analyzer)
|
||||
defined.clear(); defined.update(d0)
|
||||
elif isinstance(stmt, ast.If):
|
||||
_flow_check_loads(stmt.test, locals_set, defined, analyzer)
|
||||
d1 = set(defined)
|
||||
d2 = set(defined)
|
||||
_flow_statements(stmt.body, locals_set, d1, analyzer)
|
||||
_flow_statements(stmt.orelse, locals_set, d2, analyzer)
|
||||
defined.clear()
|
||||
defined.update(d1 & d2) # 合并点: 两分支都确定才算确定
|
||||
elif isinstance(stmt, ast.Try):
|
||||
d_main = set(defined)
|
||||
_flow_statements(stmt.body, locals_set, d_main, analyzer)
|
||||
for h in stmt.handlers:
|
||||
d_h = set(defined) # 异常路径独立, 不贡献确定
|
||||
_flow_statements(h.body, locals_set, d_h, analyzer)
|
||||
_flow_statements(stmt.orelse, locals_set, d_main, analyzer)
|
||||
_flow_statements(stmt.finalbody, locals_set, d_main, analyzer)
|
||||
defined.clear(); defined.update(d_main)
|
||||
elif isinstance(stmt, ast.With):
|
||||
for item in stmt.items:
|
||||
_flow_check_loads(item.context_expr, locals_set, defined, analyzer)
|
||||
_flow_statements(stmt.body, locals_set, defined, analyzer)
|
||||
elif isinstance(stmt, ast.Return):
|
||||
_flow_check_loads(stmt.value, locals_set, defined, analyzer)
|
||||
continue # 之后不可达
|
||||
elif isinstance(stmt, ast.Raise):
|
||||
_flow_check_loads(stmt.exc, locals_set, defined, analyzer)
|
||||
continue
|
||||
elif isinstance(stmt, ast.Delete):
|
||||
for t in stmt.targets:
|
||||
if isinstance(t, ast.Name):
|
||||
defined.discard(t.id)
|
||||
elif isinstance(stmt, ast.Expr):
|
||||
_flow_check_loads(stmt.value, locals_set, defined, analyzer)
|
||||
|
||||
|
||||
def _dataflow_check(tree, analyzer):
|
||||
"""L3 数据流 (第一版): 检测「部分路径未定义就用」→ E004。
|
||||
对每个函数: 收集局部变量(参数+赋值目标, 排除 global/nonlocal),
|
||||
按 if/for/try 路径合并逻辑扫描, 使用点不在确定集合 → 报 E004。"""
|
||||
for fn in ast.walk(tree):
|
||||
if not isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
continue
|
||||
locals_set = set()
|
||||
for sub in ast.walk(fn):
|
||||
if isinstance(sub, ast.Name) and isinstance(sub.ctx, (ast.Store, ast.Del)):
|
||||
locals_set.add(sub.id)
|
||||
for a in fn.args.args + fn.args.kwonlyargs:
|
||||
locals_set.add(a.arg)
|
||||
if fn.args.vararg:
|
||||
locals_set.add(fn.args.vararg.arg)
|
||||
if fn.args.kwarg:
|
||||
locals_set.add(fn.args.kwarg.arg)
|
||||
for sub in ast.walk(fn):
|
||||
if isinstance(sub, (ast.Global, ast.Nonlocal)):
|
||||
for n in sub.names:
|
||||
locals_set.discard(n)
|
||||
|
||||
defined = {a.arg for a in fn.args.args + fn.args.kwonlyargs}
|
||||
if fn.args.vararg:
|
||||
defined.add(fn.args.vararg.arg)
|
||||
if fn.args.kwarg:
|
||||
defined.add(fn.args.kwarg.arg)
|
||||
_flow_statements(fn.body, locals_set, defined, analyzer)
|
||||
|
||||
|
||||
def _jedi_validate(source: str, path: str, issues: List[Dict]) -> List[Dict]:
|
||||
"""L2 语义验证后端 (可选增强): 用 jedi 对规则引擎的 E002 做二次确认。
|
||||
|
||||
@@ -552,6 +686,8 @@ def analyze(source: str, filename: str = "<string>") -> List[Dict]:
|
||||
extra=f"重复定义: {name}",
|
||||
name=name, first_line=nodes[0].lineno)
|
||||
|
||||
issues.extend(analyzer.issues)
|
||||
_dataflow_check(tree, analyzer) # L3: 数据流 (部分路径未定义)
|
||||
issues.extend(analyzer.issues)
|
||||
issues = _jedi_validate(source, filename, issues) # L2: 语义二次确认 (可选)
|
||||
return issues
|
||||
|
||||
@@ -311,6 +311,62 @@ class TestJediBackend:
|
||||
assert "E002" not in {d["code"] for d in issues}
|
||||
|
||||
|
||||
# ── L3 数据流: 部分路径未定义就用 (E004) ──
|
||||
class TestDataFlow:
|
||||
def test_if_partial_path(self, pd):
|
||||
src = "def f(flag):\n if flag:\n result = 1\n return result\n"
|
||||
assert 4 in code_lines(pd.analyze(src, "t.py"), "E004")
|
||||
|
||||
def test_if_else_both_defined_ok(self, pd):
|
||||
src = "def f(flag):\n if flag:\n r = 1\n else:\n r = 2\n return r\n"
|
||||
assert "E004" not in {d["code"] for d in pd.analyze(src, "t.py")}
|
||||
|
||||
def test_for_zero_iteration(self, pd):
|
||||
src = "def f(items):\n for x in items:\n total = x\n return total\n"
|
||||
assert 4 in code_lines(pd.analyze(src, "t.py"), "E004")
|
||||
|
||||
def test_for_loop_var_defined_inside(self, pd):
|
||||
src = "def f(items):\n total = 0\n for x in items:\n total += x\n return total\n"
|
||||
assert "E004" not in {d["code"] for d in pd.analyze(src, "t.py")}
|
||||
|
||||
def test_sequential_ok(self, pd):
|
||||
src = "def f():\n result = 1\n return result\n"
|
||||
assert "E004" not in {d["code"] for d in pd.analyze(src, "t.py")}
|
||||
|
||||
def test_param_ok(self, pd):
|
||||
src = "def f(x):\n return x\n"
|
||||
assert "E004" not in {d["code"] for d in pd.analyze(src, "t.py")}
|
||||
|
||||
def test_global_in_function_ok(self, pd):
|
||||
src = "g = 1\ndef f():\n return g\n"
|
||||
assert "E004" not in {d["code"] for d in pd.analyze(src, "t.py")}
|
||||
|
||||
def test_augassign_before_def(self, pd):
|
||||
src = "def f():\n x = x + 1\n return x\n"
|
||||
assert 2 in code_lines(pd.analyze(src, "t.py"), "E004")
|
||||
|
||||
def test_elif_missing_branch(self, pd):
|
||||
src = "def f(a):\n if a > 0:\n v = 1\n elif a < 0:\n v = 2\n return v\n"
|
||||
assert 6 in code_lines(pd.analyze(src, "t.py"), "E004")
|
||||
|
||||
def test_del_then_use(self, pd):
|
||||
src = "def f():\n x = 1\n del x\n return x\n"
|
||||
assert 4 in code_lines(pd.analyze(src, "t.py"), "E004")
|
||||
|
||||
def test_class_method_path(self, pd):
|
||||
src = "class A:\n def m(self, flag):\n if flag:\n r = 1\n return r\n"
|
||||
assert 5 in code_lines(pd.analyze(src, "t.py"), "E004")
|
||||
|
||||
def test_e004_dynamic_fix(self, pd):
|
||||
src = "def f(flag):\n if flag:\n result = 1\n return result\n"
|
||||
issues = pd.analyze(src, "t.py")
|
||||
fix = ""
|
||||
for d in issues:
|
||||
if d["code"] == "E004":
|
||||
fix = d.get("fix", "")
|
||||
assert "result = None" in fix, fix
|
||||
|
||||
|
||||
# ── code_editor 写文件后自动检测集成 ──
|
||||
class TestAutoDetectIntegration:
|
||||
"""AI 用 code_editor 改完 .py 文件后,系统自动跑 py_detect"""
|
||||
|
||||
Reference in new issue
Block a user