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:
shuiyu67 committed 2026-08-07 02:41:33 +08:00
1 parent 4fb9392db4
commit 413f66deb8
2 files changed
+193 -1

No files matched your search

+137 -1
View File
@@ -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
+56
View File
@@ -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"""