feat: py_detect 升级算法智能解释 (零 AI 模型, 纯算法动态生成)

- 拼写纠错: 未定义变量用 difflib 相似度推荐最近名字 (totl -> 你是不是想写「total」)
- 动态修复示例: W004 生成 def f(items=None) 改写; W005 生成 x is None; W006 生成 lst[:] 复制
- 动态注入上下文: 函数名/参数名/列表名/比较对象/首次定义行号/return 行号
- W005 扩展支持 != None; I001 从 type(x) 参数正确提取对象与类型
- 定位: 连 1B 模型都跑不动的设备也秒级运行, 永不联网, 不依赖任何 AI
- 新增 9 项智能解释测试; 相关套件 259 passed 零回归
This commit is contained in:
shuiyu67 committed 2026-08-07 02:13:43 +08:00
1 parent 56d5ed27e0
commit 917ba25902
2 files changed
+200 -22

No files matched your search

+143 -22
View File
@@ -1,16 +1,19 @@
"""
本地 Python 智能检测插件 (py_detect)
===================================
纯本地、零第三方依赖的代码检测引擎——为 xiaoli-cli「纯本地」而生。
纯本地、零第三方依赖、零 AI 模型的代码检测引擎——为 xiaoli-cli「纯本地」而生。
设计目标:
1. 只依赖标准库 (ast / tokenize),低配电脑无压力,永不联网。
1. 只依赖标准库 (ast / tokenize / difflib),低配电脑(连 1B 模型都跑不动)
也秒级运行,永不联网、永不卡死。
2. 三层检测算法:
① 语法层 — tokenize + ast.parse 精确定位语法错误 (行列 + 原因)
② 语义层 — 作用域感知: 未定义变量 / 重复定义 / 未使用导入
③ 反模式层 — 可变默认参数 / 裸 except / ==None / 迭代中修改列表 /
覆盖内置名 / import * / 除零 / 方法缺 self / 死代码
3. 每条诊断带「中文解释 + 修复建议」(规则模板, 零 AI)。
3. 算法智能解释 (不靠 AI 模型): 每条诊断按代码上下文动态生成——
拼写纠错建议 (difflib 相似名)、带函数名/参数名的修复示例、
改后代码片段。纯算法推理,任何设备跑得动。
4. 结果纯文本结构化 (code:line:col 前缀), 方便回喂给 AI 与用户。
操作:
@@ -21,11 +24,12 @@
"""
import ast
import difflib
import os
import tokenize
from typing import Dict, List, Optional, Tuple
__version__ = "1.0.0"
__version__ = "1.1.0"
# ── 内置名集合 (判定「覆盖内置」与「未定义」用) ──
if isinstance(__builtins__, dict):
@@ -53,6 +57,83 @@ RULES: Dict[str, Tuple[str, str, str]] = {
}
def _smart_explain(code: str, ctx: dict) -> Tuple[str, str]:
"""算法智能解释:按代码上下文动态生成「针对性标题 + 修复建议」。
纯算法 (模板渲染 + 名字注入 + 相似度), 不调用任何 AI 模型。
ctx 常见键: name / func / arg / lst / op / typ / suggestion / first_line / prev / prev_line
"""
sev, title, base_fix = RULES[code]
if code == "E002":
name = ctx.get("name", "")
sugg = ctx.get("suggestion", "")
if name:
title = f"使用了未定义的变量 {name}"
if sugg:
fix = (f"修复: 变量 {name} 未定义——你是不是想写「{sugg}」?"
f"先给它赋值/导入;若是闭包或动态注入(globals()/exec)可忽略。")
else:
fix = (f"修复: 变量 {name} 未定义。先给它赋值或导入;"
f"若是闭包或动态注入(globals()/exec)可忽略。")
return title, fix
elif code == "W004":
func = ctx.get("func", "")
arg = ctx.get("arg", "")
if func and arg:
fix = (f"修复: 改写成 def {func}({arg}=None): {arg} = {arg} if {arg} is not None else 默认值"
f"——避免所有调用共享同一个可变对象。")
return title, fix
elif code == "W005":
obj = ctx.get("name", "x")
op = ctx.get("op", "is")
fix = f"修复: 应写成 if {obj} {op} None: ——用身份比较,语义更准确。"
return title, fix
elif code == "W006":
lst = ctx.get("lst", "lst")
fix = f"修复: 先复制再迭代: for x in {lst}[:]: ——或在循环外用新列表收集,避免跳过/漏项。"
return title, fix
elif code == "W007":
func = ctx.get("func", "")
if func:
fix = (f"修复: 方法 {func} 首参数应为 self(实例方法)或 cls(@classmethod);"
f"若是独立函数请移出类。")
return title, fix
elif code == "E003":
name = ctx.get("name", "")
first = ctx.get("first_line")
if name and first:
fix = f"修复: {name} 已在第 {first} 行定义过——删掉其中一个,或改名避免覆盖。"
return title, fix
elif code == "W008":
name = ctx.get("name", "")
if name:
fix = (f"修复: 别用内置名 {name} 当变量,换个名字(如 {name}_val / data / items),"
f"避免覆盖内置行为导致诡异 bug。")
return title, fix
elif code == "I001":
obj = ctx.get("name", "x")
typ = ctx.get("typ", "int")
fix = f"修复: 用 isinstance({obj}, {typ}) 更健壮 (支持子类,且是 Python 官方推荐写法)。"
return title, fix
elif code == "W009":
prev = ctx.get("prev", "return")
prev_line = ctx.get("prev_line")
if prev_line:
fix = f"修复: 第 {prev_line} 行 {prev} 之后的代码永远不会执行——删除它,或把逻辑放到 {prev} 之前。"
return title, fix
return title, base_fix
class _ModuleCollector(ast.NodeVisitor):
"""收集模块级定义 (name -> node),供作用域分析与重复定义/未使用导入检测"""
@@ -144,13 +225,16 @@ class _ScopeAnalyzer(ast.NodeVisitor):
self.module_scope = module_scope
self.scope_stack: List[Dict[str, str]] = [] # 函数/类作用域栈
self.additional_defs: set = set() # 顶层推导式/walrus/循环变量等
self.defined_names: set = set(module_scope) # 全部已定义名 (拼写纠错用)
self.issues: List[Dict] = []
# ── 工具 ──
def _add(self, code: str, lineno: int, col: int, extra: str = ""):
sev, title, fix = RULES[code]
def _add(self, code: str, lineno: int, col: int, extra: str = "", **ctx):
title, fix = _smart_explain(code, ctx)
self.issues.append({
"code": code, "severity": sev, "title": title,
"code": code,
"severity": RULES[code][0],
"title": title,
"line": lineno, "col": col + 1,
"text": self._line_text(lineno), "fix": fix, "extra": extra,
})
@@ -158,6 +242,15 @@ class _ScopeAnalyzer(ast.NodeVisitor):
def _declare(self, name: str):
"""无条件登记一个已定义名字 (顶层也生效, 避免误报)"""
self.additional_defs.add(name)
self.defined_names.add(name)
def _suggest(self, name: str) -> str:
"""拼写纠错: 在已定义名字里找最接近的 (difflib, 纯算法)"""
try:
matches = difflib.get_close_matches(name, self.defined_names, n=1, cutoff=0.6)
return matches[0] if matches else ""
except Exception:
return ""
def _line_text(self, lineno: int) -> str:
if 0 < lineno <= len(self.lines):
@@ -196,7 +289,8 @@ class _ScopeAnalyzer(ast.NodeVisitor):
is_static = "staticmethod" in decos or "classmethod" in decos
args0 = node.args.args[0].arg if node.args.args else None
if not is_static and args0 not in ("self", "cls"):
self._add("W007", node.lineno, node.col_offset, f"方法 {node.name} 缺 self/cls")
self._add("W007", node.lineno, node.col_offset,
extra=f"方法 {node.name} 缺 self/cls", func=node.name)
scope: Dict[str, str] = {}
for arg in node.args.args + node.args.kwonlyargs:
scope[arg.arg] = "参数"
@@ -204,13 +298,19 @@ class _ScopeAnalyzer(ast.NodeVisitor):
scope[node.args.vararg.arg] = "参数"
if node.args.kwarg:
scope[node.args.kwarg.arg] = "参数"
# 可变默认参数 (经典坑: 默认值跨调用共享)
for d in node.args.defaults:
# 可变默认参数 (经典坑: 默认值跨调用共享) —— 动态注入函数名/参数名
n_defaults = len(node.args.defaults)
if n_defaults:
for arg, d in zip(node.args.args[-n_defaults:], node.args.defaults):
if isinstance(d, (ast.List, ast.Dict, ast.Set)):
self._add("W004", node.lineno, node.col_offset, f"可变默认参数: {node.name}")
self._add("W004", node.lineno, node.col_offset,
extra=f"可变默认参数: {node.name}.{arg.arg}",
func=node.name, arg=arg.arg)
for d in node.args.kw_defaults:
if d is not None and isinstance(d, (ast.List, ast.Dict, ast.Set)):
self._add("W004", node.lineno, node.col_offset, f"可变默认参数: {node.name}")
self._add("W004", node.lineno, node.col_offset,
extra=f"可变默认参数: {node.name}",
func=node.name, arg="kw")
self.scope_stack.append(scope)
self.generic_visit(node)
self.scope_stack.pop()
@@ -252,7 +352,8 @@ class _ScopeAnalyzer(ast.NodeVisitor):
if self.scope_stack:
self.scope_stack[-1][t.id] = "变量"
if t.id in _BUILTINS:
self._add("W008", node.lineno, node.col_offset, f"覆盖内置名: {t.id}")
self._add("W008", node.lineno, node.col_offset,
extra=f"覆盖内置名: {t.id}", name=t.id)
self.generic_visit(node)
def visit_AnnAssign(self, node: ast.AnnAssign):
@@ -260,7 +361,8 @@ class _ScopeAnalyzer(ast.NodeVisitor):
if self.scope_stack:
self.scope_stack[-1][node.target.id] = "变量"
if node.target.id in _BUILTINS:
self._add("W008", node.lineno, node.col_offset, f"覆盖内置名: {node.target.id}")
self._add("W008", node.lineno, node.col_offset,
extra=f"覆盖内置名: {node.target.id}", name=node.target.id)
self.generic_visit(node)
def visit_AugAssign(self, node: ast.AugAssign):
@@ -282,7 +384,8 @@ class _ScopeAnalyzer(ast.NodeVisitor):
if sub.func.attr in ("append", "remove", "pop", "clear", "insert", "extend", "sort", "reverse"):
if isinstance(sub.func.value, ast.Name) and sub.func.value.id in iter_names:
self._add("W006", sub.lineno, sub.col_offset,
f"迭代中修改列表: {sub.func.value.id}")
extra=f"迭代中修改列表: {sub.func.value.id}",
lst=sub.func.value.id)
self.generic_visit(node)
def visit_With(self, node: ast.With):
@@ -312,16 +415,30 @@ class _ScopeAnalyzer(ast.NodeVisitor):
def visit_Name(self, node: ast.Name):
if isinstance(node.ctx, ast.Load):
if node.id not in _BUILTINS and not self._in_scope(node.id):
self._add("E002", node.lineno, node.col_offset, f"未定义变量: {node.id}")
self._add("E002", node.lineno, node.col_offset,
extra=f"未定义变量: {node.id}",
name=node.id, suggestion=self._suggest(node.id))
self.generic_visit(node)
def visit_Compare(self, node: ast.Compare):
if len(node.ops) == 1 and isinstance(node.ops[0], ast.Eq):
for side in (node.left, *node.comparators):
if len(node.ops) == 1 and isinstance(node.ops[0], (ast.Eq, ast.NotEq)):
sides = [node.left, *node.comparators]
is_eq = isinstance(node.ops[0], ast.Eq)
op_word = "is" if is_eq else "is not"
for side in sides:
if isinstance(side, ast.Constant) and isinstance(side.value, (type(None), bool)):
self._add("W005", node.lineno, node.col_offset, f"用 == 比较 {side.value!r}")
others = [s for s in sides if s is not side]
obj = others[0].id if others and isinstance(others[0], ast.Name) else "x"
self._add("W005", node.lineno, node.col_offset,
extra=f"用 == 比较 {side.value!r}",
name=obj, op=op_word)
if isinstance(side, ast.Call) and isinstance(side.func, ast.Name) and side.func.id == "type":
self._add("I001", node.lineno, node.col_offset, "type() 比较")
others = [s for s in sides if s is not side]
obj = (side.args[0].id
if side.args and isinstance(side.args[0], ast.Name) else "x")
typ = others[0].id if others and isinstance(others[0], ast.Name) else "int"
self._add("I001", node.lineno, node.col_offset,
extra="type() 比较", name=obj, typ=typ)
self.generic_visit(node)
def visit_BinOp(self, node: ast.BinOp):
@@ -337,8 +454,10 @@ class _ScopeAnalyzer(ast.NodeVisitor):
for i, stmt in enumerate(fn.body[:-1]):
if isinstance(stmt, (ast.Return, ast.Raise)):
nxt = fn.body[i + 1]
prev = "return" if isinstance(stmt, ast.Return) else "raise"
self._add("W009", nxt.lineno, nxt.col_offset,
f"{type(stmt).__name__} 后不可达")
extra=f"{prev} 后不可达",
prev=prev, prev_line=stmt.lineno)
break
@@ -390,7 +509,9 @@ def analyze(source: str, filename: str = "<string>") -> List[Dict]:
for name, nodes in collector.def_nodes.items():
if len(nodes) > 1:
for node in nodes[1:]:
analyzer._add("E003", node.lineno, node.col_offset, f"重复定义: {name}")
analyzer._add("E003", node.lineno, node.col_offset,
extra=f"重复定义: {name}",
name=name, first_line=nodes[0].lineno)
issues.extend(analyzer.issues)
return issues
+57
View File
@@ -221,6 +221,63 @@ class TestHandle:
assert "check" in mcp["inputSchema"]["properties"]["operation"]["enum"]
# ── 算法智能解释 (零 AI 模型, 纯算法动态生成) ──
class TestSmartExplain:
def fix_of(self, issues, code):
for d in issues:
if d["code"] == code:
return d.get("fix", "")
return ""
def test_spell_suggestion(self, pd):
# 拼写纠错: 未定义变量 totl → 建议 total
src = "total = 0\ndef f():\n return totl\n"
issues = pd.analyze(src, "t.py")
fix = self.fix_of(issues, "E002")
assert "total" in fix, fix
assert "totl" in fix, fix
def test_mutable_default_dynamic_fix(self, pd):
src = "def f(items=[]):\n pass\n"
fix = self.fix_of(pd.analyze(src, "t.py"), "W004")
assert "def f(items=None)" in fix, fix
def test_eq_none_dynamic_fix(self, pd):
src = "if x == None:\n pass\n"
fix = self.fix_of(pd.analyze(src, "t.py"), "W005")
assert "x is None" in fix, fix
def test_ne_none_dynamic_fix(self, pd):
src = "if x != None:\n pass\n"
fix = self.fix_of(pd.analyze(src, "t.py"), "W005")
assert "x is not None" in fix, fix
def test_mutate_iter_dynamic_fix(self, pd):
src = "for x in items:\n items.remove(x)\n"
fix = self.fix_of(pd.analyze(src, "t.py"), "W006")
assert "items[:]" in fix, fix
def test_dup_def_dynamic_line(self, pd):
src = "def f():\n pass\ndef f():\n pass\n"
fix = self.fix_of(pd.analyze(src, "t.py"), "E003")
assert "第 1 行" in fix, fix
def test_dead_code_dynamic_line(self, pd):
src = "def f():\n return 1\n x = 2\n"
fix = self.fix_of(pd.analyze(src, "t.py"), "W009")
assert "第 2 行 return" in fix, fix
def test_method_self_dynamic(self, pd):
src = "class A:\n def f():\n pass\n"
fix = self.fix_of(pd.analyze(src, "t.py"), "W007")
assert "self" in fix, fix
def test_type_compare_dynamic(self, pd):
src = "if type(x) == int:\n pass\n"
fix = self.fix_of(pd.analyze(src, "t.py"), "I001")
assert "isinstance(x, int)" in fix, fix
# ── code_editor 写文件后自动检测集成 ──
class TestAutoDetectIntegration:
"""AI 用 code_editor 改完 .py 文件后,系统自动跑 py_detect"""