diff --git a/plugins/code_editor.py b/plugins/code_editor.py index 016b4fd..7614d15 100644 --- a/plugins/code_editor.py +++ b/plugins/code_editor.py @@ -627,6 +627,60 @@ else: first = diag_text.splitlines()[0] if diag_text else "" return f"\n LSP 诊断: {first}" + def _load_py_detect(self): + """延迟加载 py_detect 核心(plugins/py_detect.py),失败返回 None 并缓存""" + cached = getattr(self, "_py_detect_mod", "unset") + if cached != "unset": + return cached + try: + import importlib.util + plugin_path = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "py_detect.py") + if not os.path.exists(plugin_path): + self._py_detect_mod = None + return None + spec = importlib.util.spec_from_file_location("py_detect_core", plugin_path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + self._py_detect_mod = mod + return mod + except Exception: + self._py_detect_mod = None + return None + + def _auto_py_detect(self, filepath: str) -> str: + """写/改 .py 文件后自动智能检测(py_detect,纯本地零依赖)。 + + 仅在 .py 文件且检测到问题时输出(截断为前 6 条,避免刷屏), + 干净或非 py 文件静默返回空串——与 _auto_lsp_check 同风格。 + """ + if not filepath.lower().endswith(".py"): + return "" + mod = self._load_py_detect() + if mod is None: + return "" + try: + with open(filepath, "r", encoding="utf-8", errors="replace") as f: + source = f.read() + issues = mod.analyze(source, filepath) + if not issues: + return "" + order = {"错误": 0, "警告": 1, "提示": 2} + issues = sorted(issues, key=lambda d: (order.get(d["severity"], 9), d["line"], d["col"])) + n = len(issues) + n_e = sum(1 for d in issues if d["severity"] == "错误") + n_w = sum(1 for d in issues if d["severity"] == "警告") + n_i = sum(1 for d in issues if d["severity"] == "提示") + lines = [f"\n [py_detect] 检测到 {n} 个问题 (错误 {n_e} · 警告 {n_w} · 提示 {n_i}):"] + for d in issues[:6]: + lines.append(f" [{d['code']}] {d['severity']} @{d['line']}:{d['col']} {d['title']}" + + (f" ({d['extra']})" if d.get("extra") else "")) + if n > 6: + lines.append(f" … 还有 {n - 6} 条,py_detect check {filepath} 查看全部") + return "\n".join(lines) + except Exception: + return "" + # ══════════════════════════════════════ # Diff 弹窗系统 # ══════════════════════════════════════ @@ -1003,7 +1057,7 @@ else: self._spawn_diff_popup(fp, before_content, new_content) result = f" 已编辑: {fp}\n\n{self._make_diff(old_text, new_text)}" - result += self._auto_syntax_check(fp) + self._auto_lsp_check(fp) + result += self._auto_syntax_check(fp) + self._auto_lsp_check(fp) + self._auto_py_detect(fp) return result def _op_multi_edit(self, path: str, rest: str) -> str: @@ -1041,7 +1095,7 @@ else: f.write(content) self._spawn_diff_popup(fp, before_content, content) result = f" 批量编辑: {fp}\n" + "\n".join(results) - result += self._auto_syntax_check(fp) + self._auto_lsp_check(fp) + result += self._auto_syntax_check(fp) + self._auto_lsp_check(fp) + self._auto_py_detect(fp) return result def _op_insert(self, path: str, rest: str) -> str: @@ -1074,7 +1128,7 @@ else: after_content = ''.join(lines) self._spawn_diff_popup(fp, before_content, after_content) result = f" 已在第 {line_num} 行插入 {len(insert_lines)} 行" - result += self._auto_syntax_check(fp) + self._auto_lsp_check(fp) + result += self._auto_syntax_check(fp) + self._auto_lsp_check(fp) + self._auto_py_detect(fp) return result def _op_delete_lines(self, path: str, rest: str) -> str: @@ -1107,7 +1161,7 @@ else: after_content = ''.join(lines) self._spawn_diff_popup(fp, before_content, after_content) result = f" 已删除第 {start}-{end} 行 ({len(deleted)} 行)" - result += self._auto_syntax_check(fp) + self._auto_lsp_check(fp) + result += self._auto_syntax_check(fp) + self._auto_lsp_check(fp) + self._auto_py_detect(fp) return result def _op_create(self, path: str, rest: str) -> str: @@ -1123,7 +1177,7 @@ else: f.write(rest or "") self._spawn_diff_popup(fp, "", rest or "") result = f" 已创建: {fp} ({len(rest or '')} 字符)" - result += self._auto_syntax_check(fp) + self._auto_lsp_check(fp) + result += self._auto_syntax_check(fp) + self._auto_lsp_check(fp) + self._auto_py_detect(fp) return result def _op_write(self, path: str, rest: str) -> str: @@ -1138,7 +1192,7 @@ else: f.write(rest) self._spawn_diff_popup(fp, before_content, rest) result = f" 已写入: {fp} ({len(rest)} 字符)" - result += self._auto_syntax_check(fp) + self._auto_lsp_check(fp) + result += self._auto_syntax_check(fp) + self._auto_lsp_check(fp) + self._auto_py_detect(fp) return result def _op_append(self, path: str, rest: str) -> str: @@ -1153,7 +1207,7 @@ else: after_content = self._read_file_safe(fp) self._spawn_diff_popup(fp, before_content, after_content) result = f" 已追加: {fp} ({len(rest)} 字符)" - result += self._auto_syntax_check(fp) + self._auto_lsp_check(fp) + result += self._auto_syntax_check(fp) + self._auto_lsp_check(fp) + self._auto_py_detect(fp) return result # ══════════════════════════════════════ diff --git a/plugins/py_detect.py b/plugins/py_detect.py new file mode 100644 index 0000000..2b885f5 --- /dev/null +++ b/plugins/py_detect.py @@ -0,0 +1,558 @@ +""" +本地 Python 智能检测插件 (py_detect) +=================================== +纯本地、零第三方依赖的代码检测引擎——为 xiaoli-cli「纯本地」而生。 + +设计目标: + 1. 只依赖标准库 (ast / tokenize),低配电脑无压力,永不联网。 + 2. 三层检测算法: + ① 语法层 — tokenize + ast.parse 精确定位语法错误 (行列 + 原因) + ② 语义层 — 作用域感知: 未定义变量 / 重复定义 / 未使用导入 + ③ 反模式层 — 可变默认参数 / 裸 except / ==None / 迭代中修改列表 / + 覆盖内置名 / import * / 除零 / 方法缺 self / 死代码 + 3. 每条诊断带「中文解释 + 修复建议」(规则模板, 零 AI)。 + 4. 结果纯文本结构化 (code:line:col 前缀), 方便回喂给 AI 与用户。 + +操作: + check <文件> [行号] - 检测整个文件 / 只检测某一行 + check <文件> <起>-<止> - 只检测某一段范围 + rules [规则码] - 列出全部规则 / 查看单条规则 + version - 版本信息 +""" + +import ast +import os +import tokenize +from typing import Dict, List, Optional, Tuple + +__version__ = "1.0.0" + +# ── 内置名集合 (判定「覆盖内置」与「未定义」用) ── +if isinstance(__builtins__, dict): + _BUILTINS = set(__builtins__) +else: + _BUILTINS = set(dir(__builtins__)) + +# ── 规则元数据: code -> (severity, 标题, 修复建议) ── +RULES: Dict[str, Tuple[str, str, str]] = { + "E001": ("错误", "语法错误", "修复: 检查该行附近——常见原因: 缺冒号、括号不匹配、引号未闭合、缩进错误。"), + "E002": ("错误", "使用了未定义的变量", "修复: 先给变量赋值或导入它;若是闭包/动态注入(globals()/exec)可忽略。"), + "E003": ("错误", "函数/类重复定义", "修复: 删掉其中一个定义,或改名避免覆盖。"), + "W001": ("警告", "导入后未使用", "修复: 删除该 import,或补上使用处。"), + "W002": ("警告", "裸 except", "修复: 写成 except Exception: 或指定具体异常类型,避免吞掉所有错误。"), + "W003": ("警告", "捕获范围过宽 (except Exception)", "修复: 尽量捕获具体异常 (如 ValueError/KeyError),避免隐藏 bug。"), + "W004": ("警告", "可变默认参数", "修复: 改成 def f(x=None): x = x if x is not None else [],避免跨调用共享同一对象。"), + "W005": ("警告", "用 == 比较 None/True/False", "修复: 应使用 is / is not,如 if x is None。"), + "W006": ("警告", "迭代过程中修改了列表", "修复: 先复制: for x in lst[:]: 或用列表推导生成新列表。"), + "W007": ("警告", "方法缺少 self/cls 参数", "修复: 方法首参数应为 self(实例方法)或 cls(@classmethod);若是独立函数请移出类。"), + "W008": ("警告", "赋值覆盖了内置名", "修复: 换一个名字(如 data/items/input_text),避免后续行为诡异。"), + "W009": ("警告", "return/raise 之后有不可达代码", "修复: 删除无效语句,或把 return 放到函数最后。"), + "W010": ("警告", "使用了 import *", "修复: 显式导入用到的名字,避免污染命名空间。"), + "W011": ("警告", "除零风险 (字面量 0 作除数)", "修复: 除之前判断除数是否为 0,或改用安全除法。"), + "I001": ("提示", "建议用 isinstance 替代 type 比较", "修复: 用 isinstance(x, int) 更健壮 (支持继承)。"), +} + + +class _ModuleCollector(ast.NodeVisitor): + """收集模块级定义 (name -> node),供作用域分析与重复定义/未使用导入检测""" + + def __init__(self): + self.module_scope: Dict[str, Optional[ast.AST]] = {} + self.def_nodes: Dict[str, List[ast.AST]] = {} # def/class 定义节点 (重复定义检测) + self.imports: Dict[str, int] = {} # 导入的名字 -> 行号 + self.used_names: set = set() # 被 load 使用的名字 + self.import_star_lines: List[int] = [] + + def visit_Import(self, node: ast.Import): + for alias in node.names: + name = (alias.asname or alias.name).split(".")[0] + self.module_scope[name] = node + self.imports[name] = node.lineno + self.generic_visit(node) + + def visit_ImportFrom(self, node: ast.ImportFrom): + if any(alias.name == "*" for alias in node.names): + self.import_star_lines.append(node.lineno) + return + for alias in node.names: + name = alias.asname or alias.name + self.module_scope[name] = node + self.imports[name] = node.lineno + self.generic_visit(node) + + def visit_FunctionDef(self, node: ast.FunctionDef): + self.module_scope[node.name] = node + self.def_nodes.setdefault(node.name, []).append(node) + self._collect_uses(node) # 函数体内的使用也计入 (判定未使用导入) + + visit_AsyncFunctionDef = visit_FunctionDef + + def visit_ClassDef(self, node: ast.ClassDef): + self.module_scope[node.name] = node + self.def_nodes.setdefault(node.name, []).append(node) + for child in node.body: + if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)): + self._collect_uses(child) # 方法体内的使用也计入 + + def visit_Assign(self, node: ast.Assign): + for t in node.targets: + if isinstance(t, ast.Name): + self.module_scope.setdefault(t.id, None) + self._collect_uses(node.value) + + def visit_AnnAssign(self, node: ast.AnnAssign): + if isinstance(node.target, ast.Name): + self.module_scope.setdefault(node.target.id, None) + self.generic_visit(node) + + def visit_For(self, node: ast.For): + self.module_scope.setdefault(self._first_name(node.target), None) + self._collect_uses(node) + + def visit_With(self, node: ast.With): + for item in node.items: + if item.optional_vars: + self.module_scope.setdefault(self._first_name(item.optional_vars), None) + self.generic_visit(node) + + def visit_ExceptHandler(self, node: ast.ExceptHandler): + if node.name: + self.module_scope.setdefault(node.name, None) + self.generic_visit(node) + + def _first_name(self, target) -> Optional[str]: + if isinstance(target, ast.Name): + return target.id + if isinstance(target, (ast.Tuple, ast.List)) and target.elts: + return self._first_name(target.elts[0]) + return None + + def _collect_uses(self, node: ast.AST): + for n in ast.walk(node): + if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Load): + self.used_names.add(n.id) + elif isinstance(n, ast.Attribute) and isinstance(n.value, ast.Name): + self.used_names.add(n.value.id) + + +class _ScopeAnalyzer(ast.NodeVisitor): + """作用域感知检测器: 未定义变量 / 方法缺 self / 反模式扫描等""" + + def __init__(self, source: str, module_scope: Dict[str, Optional[ast.AST]]): + self.source = source + self.lines = source.splitlines() + self.module_scope = module_scope + self.scope_stack: List[Dict[str, str]] = [] # 函数/类作用域栈 + self.additional_defs: set = set() # 顶层推导式/walrus/循环变量等 + self.issues: List[Dict] = [] + + # ── 工具 ── + def _add(self, code: str, lineno: int, col: int, extra: str = ""): + sev, title, fix = RULES[code] + self.issues.append({ + "code": code, "severity": sev, "title": title, + "line": lineno, "col": col + 1, + "text": self._line_text(lineno), "fix": fix, "extra": extra, + }) + + def _declare(self, name: str): + """无条件登记一个已定义名字 (顶层也生效, 避免误报)""" + self.additional_defs.add(name) + + def _line_text(self, lineno: int) -> str: + if 0 < lineno <= len(self.lines): + return self.lines[lineno - 1].strip()[:120] + return "" + + def _in_scope(self, name: str) -> bool: + for scope in reversed(self.scope_stack): + if name in scope: + return True + return name in self.module_scope or name in self.additional_defs + + def _collect_target(self, target: ast.AST, scope: Dict[str, str]): + if isinstance(target, ast.Name): + scope[target.id] = "变量" + elif isinstance(target, (ast.Tuple, ast.List)): + for elt in target.elts: + self._collect_target(elt, scope) + + def _target_names(self, target: ast.AST) -> set: + names = set() + if isinstance(target, ast.Name): + names.add(target.id) + elif isinstance(target, (ast.Tuple, ast.List)): + for elt in target.elts: + names |= self._target_names(elt) + return names + + # ── 函数/类/作用域 ── + def visit_FunctionDef(self, node: ast.FunctionDef): + if self.scope_stack: + self.scope_stack[-1][node.name] = "函数" # 局部函数名可见 + parent = self.scope_stack[-1] if self.scope_stack else None + if parent is not None and "" in parent: + decos = {getattr(d, "id", "") for d in node.decorator_list} + 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") + scope: Dict[str, str] = {} + for arg in node.args.args + node.args.kwonlyargs: + scope[arg.arg] = "参数" + if node.args.vararg: + scope[node.args.vararg.arg] = "参数" + if node.args.kwarg: + scope[node.args.kwarg.arg] = "参数" + # 可变默认参数 (经典坑: 默认值跨调用共享) + for d in node.args.defaults: + if isinstance(d, (ast.List, ast.Dict, ast.Set)): + self._add("W004", node.lineno, node.col_offset, f"可变默认参数: {node.name}") + 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.scope_stack.append(scope) + self.generic_visit(node) + self.scope_stack.pop() + + visit_AsyncFunctionDef = visit_FunctionDef + + def visit_ClassDef(self, node: ast.ClassDef): + scope: Dict[str, str] = {"": "类"} + self.scope_stack.append(scope) + self.generic_visit(node) + self.scope_stack.pop() + + def visit_Lambda(self, node: ast.Lambda): + for a in node.args.args: + self._declare(a.arg) + self.generic_visit(node) + + def _visit_comprehension(self, node): + for gen in node.generators: + for n in ast.walk(gen.target): + if isinstance(n, ast.Name): + self._declare(n.id) + self.generic_visit(node) + + def visit_ListComp(self, node): self._visit_comprehension(node) + def visit_SetComp(self, node): self._visit_comprehension(node) + def visit_GeneratorExp(self, node): self._visit_comprehension(node) + def visit_DictComp(self, node): self._visit_comprehension(node) + + def visit_NamedExpr(self, node: ast.NamedExpr): + if isinstance(node.target, ast.Name): + self._declare(node.target.id) + self.generic_visit(node) + + # ── 赋值/循环/上下文 ── + def visit_Assign(self, node: ast.Assign): + for t in node.targets: + if isinstance(t, ast.Name): + 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.generic_visit(node) + + def visit_AnnAssign(self, node: ast.AnnAssign): + if isinstance(node.target, ast.Name): + 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.generic_visit(node) + + def visit_AugAssign(self, node: ast.AugAssign): + if isinstance(node.target, ast.Name) and self.scope_stack: + self.scope_stack[-1][node.target.id] = "变量" + self.generic_visit(node) + + def visit_For(self, node: ast.For): + if self.scope_stack: + self._collect_target(node.target, self.scope_stack[-1]) + for n in ast.walk(node.target): + if isinstance(n, ast.Name): + self._declare(n.id) # 顶层 for 变量也登记, 避免误报 + # 迭代中修改列表检测: 检查被迭代对象 (iter) 在循环体里被增删改 + iter_names = self._target_names(node.iter) + for stmt in node.body: + for sub in ast.walk(stmt): + if isinstance(sub, ast.Call) and isinstance(sub.func, ast.Attribute): + 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}") + self.generic_visit(node) + + def visit_With(self, node: ast.With): + if self.scope_stack: + for item in node.items: + if item.optional_vars: + self._collect_target(item.optional_vars, self.scope_stack[-1]) + for item in node.items: + if item.optional_vars: + for n in ast.walk(item.optional_vars): + if isinstance(n, ast.Name): + self._declare(n.id) + self.generic_visit(node) + + def visit_ExceptHandler(self, node: ast.ExceptHandler): + if node.name: + if self.scope_stack: + self.scope_stack[-1][node.name] = "异常变量" + self._declare(node.name) + if node.type is None: + self._add("W002", node.lineno, 0) + elif isinstance(node.type, ast.Name) and node.type.id == "Exception": + self._add("W003", node.lineno, 0) + self.generic_visit(node) + + # ── 表达式检测 ── + 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.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 isinstance(side, ast.Constant) and isinstance(side.value, (type(None), bool)): + self._add("W005", node.lineno, node.col_offset, f"用 == 比较 {side.value!r}") + 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() 比较") + self.generic_visit(node) + + def visit_BinOp(self, node: ast.BinOp): + if isinstance(node.op, (ast.Div, ast.FloorDiv, ast.Mod)): + if isinstance(node.right, ast.Constant) and node.right.value in (0, 0.0): + self._add("W011", node.lineno, node.col_offset, "字面量 0 作除数") + self.generic_visit(node) + + # ── 死代码 (函数体 return/raise 后) ── + def _check_dead_code(self, tree: ast.AST): + for fn in ast.walk(tree): + if isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)): + for i, stmt in enumerate(fn.body[:-1]): + if isinstance(stmt, (ast.Return, ast.Raise)): + nxt = fn.body[i + 1] + self._add("W009", nxt.lineno, nxt.col_offset, + f"{type(stmt).__name__} 后不可达") + break + + +def _check_syntax(source: str) -> Tuple[List[Dict], Optional[ast.Module]]: + issues: List[Dict] = [] + tree = None + try: + tree = ast.parse(source) + except SyntaxError as e: + issues.append({ + "code": "E001", "severity": "错误", "title": "语法错误", + "line": e.lineno or 1, "col": (e.offset or 1), + "text": (e.text or "").strip()[:120], + "fix": RULES["E001"][2], "extra": f"ast: {e.msg}", + }) + if not issues: + try: + for _tok in tokenize.generate_tokens(iter(source.splitlines(True)).__next__): + pass + except tokenize.TokenError as e: + issues.append({ + "code": "E001", "severity": "错误", "title": "语法错误", + "line": e.args[1][0] + 1, "col": e.args[1][1] + 1, + "text": "", "fix": RULES["E001"][2], + "extra": f"tokenize: 未闭合括号/引号 {e.args[0]}", + }) + return issues, tree + + +def analyze(source: str, filename: str = "") -> List[Dict]: + """三层检测主入口: 返回诊断列表""" + issues, tree = _check_syntax(source) + if tree is None: + return issues + + collector = _ModuleCollector() + collector.visit(tree) + + analyzer = _ScopeAnalyzer(source, collector.module_scope) + analyzer.visit(tree) + analyzer._check_dead_code(tree) + + for name, lineno in collector.imports.items(): + if name not in collector.used_names: + analyzer._add("W001", lineno, 0, f"导入未使用: {name}") + for lineno in collector.import_star_lines: + analyzer._add("W010", lineno, 0, "import *") + + 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}") + + issues.extend(analyzer.issues) + return issues + + +def format_report(path: str, issues: List[Dict], scope: Optional[Tuple[int, int]] = None) -> str: + if not issues: + return f"[OK] {path}: 未发现可疑问题。" + + order = {"错误": 0, "警告": 1, "提示": 2} + issues = sorted(issues, key=lambda d: (order.get(d["severity"], 9), d["line"], d["col"])) + if scope: + lo, hi = scope + issues = [d for d in issues if lo <= d["line"] <= hi] + if not issues: + return f"[OK] {path}: 该范围未发现可疑问题。" + + n_e = sum(1 for d in issues if d["severity"] == "错误") + n_w = sum(1 for d in issues if d["severity"] == "警告") + n_i = sum(1 for d in issues if d["severity"] == "提示") + head = f"[DETECT] {path} — {len(issues)} 个问题 (错误 {n_e} · 警告 {n_w} · 提示 {n_i})\n" + + body = [] + for d in issues: + loc = f"{d['line']}:{d['col']}" + line = f"[{d['code']}] {d['severity']} @{loc} {d['title']}" + if d.get("extra"): + line += f" ({d['extra']})" + body.append(line) + if d.get("text"): + body.append(f" 代码: {d['text']}") + if d.get("fix"): + body.append(f" {d['fix']}") + return head + "\n".join(body) + + +class Liugin: + """本地 Python 智能检测 — 纯本地、零依赖、低配友好""" + + def __init__(self): + self.cli = None + self.usage = """本地 Python 智能检测 (py_detect) + +纯本地、零第三方依赖的代码检测引擎 (仅标准库 ast/tokenize)。 +三层算法: 语法错误精确定位 → 作用域语义检测 → 常见反模式扫描, +每条诊断自带中文解释与修复建议 (规则模板, 零 AI)。 + +操作: + check <文件> [行号] - 检测整个文件 / 只检测某一行 + check <文件> <起>-<止> - 只检测某段范围 (如 10-30) + rules [规则码] - 列出全部规则 / 查看单条规则 (如 rules E002) + version - 版本信息 + +示例: + py_detect check main.py + py_detect check main.py 12 + py_detect check main.py 10-30 + py_detect rules W004 +""" + + def set_cli(self, cli): + self.cli = cli + + def get_tool_info(self): + return { + "name": "py_detect", + "description": "本地 Python 智能检测 - 语法错误精确定位 + 未定义变量/未使用导入/可变默认参数/裸except等反模式扫描,纯本地零依赖,每条诊断带中文修复建议", + "keywords": ["检测", "检查", "lint", "静态分析", "语法", "错误", "诊断", "代码质量", "审查"], + "usage": self.usage, + } + + def get_mcp_definition(self): + return { + "name": "py_detect", + "description": "本地 Python 代码智能检测引擎: 语法错误、未定义变量、未使用导入、可变默认参数、裸 except、==None、迭代中修改列表、覆盖内置名、import*、除零、方法缺 self、死代码等", + "inputSchema": { + "type": "object", + "properties": { + "operation": { + "type": "string", + "enum": ["check", "rules", "version"], + "description": "操作: check 检测文件 / rules 列出规则 / version 版本", + }, + "file": {"type": "string", "description": "要检测的 Python 文件路径"}, + "scope": {"type": "string", "description": "检测范围, 如 '12' 或 '10-30' (可选)"}, + "code": {"type": "string", "description": "查看单条规则 (rules 操作用, 可选)"}, + }, + "required": ["operation"], + }, + } + + def handle(self, args: str) -> str: + args = (args or "").strip() + if not args: + return self.usage + + parts = args.split() + op = parts[0].lower() + + if op == "version": + return f"py_detect v{__version__} - 纯本地智能检测 (零第三方依赖, 规则 {len(RULES)} 条)" + + if op == "rules": + code = parts[1].upper() if len(parts) > 1 else "" + return self._rules(code) + + if op == "check": + return self._check(parts[1:]) + + return f"未知操作: {op}\n\n{self.usage}" + + def _rules(self, code: str) -> str: + if code: + if code not in RULES: + return f"没有规则 {code}。可用: {', '.join(sorted(RULES))}" + sev, title, fix = RULES[code] + return f"[{code}] {sev} {title}\n {fix}" + lines = [f"py_detect 规则表 (共 {len(RULES)} 条):", ""] + for c in sorted(RULES): + sev, title, _ = RULES[c] + lines.append(f" [{c}] {sev} - {title}") + lines.append("") + lines.append("查看单条: py_detect rules <规则码>") + return "\n".join(lines) + + def _check(self, args: List[str]) -> str: + if not args: + return "用法: py_detect check <文件> [行号] 或 [起-止]" + + path = args[0] + if not os.path.exists(path): + return f"[X] 文件不存在: {path}" + if os.path.isdir(path): + return f"[X] {path} 是目录,请指定 .py 文件" + + try: + with open(path, "r", encoding="utf-8", errors="replace") as f: + source = f.read() + except OSError as e: + return f"[X] 读取失败: {e}" + + scope = None + if len(args) > 1: + scope = self._parse_scope(args[1]) + if scope is None: + return "[X] 范围格式错误: 用 行号 (如 12) 或 起-止 (如 10-30)" + + try: + issues = analyze(source, path) + except Exception as e: + return f"[!] 检测器内部错误: {e!r} (请反馈)" + + return format_report(path, issues, scope) + + def _parse_scope(self, raw: str) -> Optional[Tuple[int, int]]: + raw = raw.strip() + if raw.isdigit(): + n = int(raw) + return (n, n) + if "-" in raw: + a, b = raw.split("-", 1) + if a.isdigit() and b.isdigit(): + lo, hi = int(a), int(b) + return (min(lo, hi), max(lo, hi)) + return None diff --git a/tests/test_py_detect.py b/tests/test_py_detect.py new file mode 100644 index 0000000..e2f54f7 --- /dev/null +++ b/tests/test_py_detect.py @@ -0,0 +1,260 @@ +"""py_detect 本地智能检测插件测试""" +import importlib.util +import os +import sys + +import pytest + +# 项目根目录 +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +PLUGIN_PATH = os.path.join(PROJECT_ROOT, "plugins", "py_detect.py") + + +def load_module(name, path): + spec = importlib.util.spec_from_file_location(name, path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +@pytest.fixture(scope="module") +def pd(): + mod = load_module("py_detect", PLUGIN_PATH) + return mod + + +def codes(issues): + """提取诊断规则码集合""" + return {d["code"] for d in issues} + + +def code_lines(issues, code): + """按规则码提取行号集合""" + return {d["line"] for d in issues if d["code"] == code} + + +# ── 语法层 ── +class TestSyntax: + def test_syntax_error(self, pd): + issues = pd.analyze("def f(:\n pass\n", "bad.py") + assert "E001" in codes(issues) + e = [d for d in issues if d["code"] == "E001"][0] + assert e["line"] >= 1 + + def test_unclosed_paren(self, pd): + issues = pd.analyze("x = (1 + 2\n", "bad.py") + assert "E001" in codes(issues) + + +# ── 语义层 ── +class TestSemantic: + def test_undefined_variable(self, pd): + src = "def f():\n return y\n" + assert 2 in code_lines(pd.analyze(src, "t.py"), "E002") + + def test_no_undefined_for_local_and_global(self, pd): + src = "x = 1\ndef f(a):\n b = a + x\n return b\nprint(f(1))\n" + assert "E002" not in codes(pd.analyze(src, "t.py")) + + def test_undefined_recursion_ok(self, pd): + src = "def f(n):\n if n <= 1:\n return 1\n return n * f(n - 1)\n" + assert "E002" not in codes(pd.analyze(src, "t.py")) + + def test_comprehension_and_walrus_ok(self, pd): + src = "nums = [1, 2, 3]\nsq = [x * x for x in nums]\nif (n := len(nums)) > 2:\n print(n)\n" + assert "E002" not in codes(pd.analyze(src, "t.py")) + + def test_closure_reads_global_ok(self, pd): + src = "counter = 0\ndef inc():\n return counter + 1\n" + assert "E002" not in codes(pd.analyze(src, "t.py")) + + def test_dup_function(self, pd): + src = "def f():\n pass\ndef f():\n pass\n" + assert 3 in code_lines(pd.analyze(src, "t.py"), "E003") + + def test_unused_import(self, pd): + src = "import os\nimport json\nprint(json.dumps(1))\n" + assert 1 in code_lines(pd.analyze(src, "t.py"), "W001") + + def test_import_used_inside_function(self, pd): + src = "import os\ndef f():\n return os.path.join('a', 'b')\n" + assert "W001" not in codes(pd.analyze(src, "t.py")) + + +# ── 反模式层 ── +class TestAntiPatterns: + def test_mutable_default(self, pd): + src = "def f(items=[]):\n pass\n" + assert 1 in code_lines(pd.analyze(src, "t.py"), "W004") + + def test_bare_except(self, pd): + src = "try:\n x = 1\nexcept:\n pass\n" + assert 3 in code_lines(pd.analyze(src, "t.py"), "W002") + + def test_wide_except(self, pd): + src = "try:\n x = 1\nexcept Exception:\n pass\n" + assert 3 in code_lines(pd.analyze(src, "t.py"), "W003") + + def test_eq_none(self, pd): + src = "if x == None:\n pass\n" + assert 1 in code_lines(pd.analyze(src, "t.py"), "W005") + + def test_mutate_during_iter(self, pd): + src = "for x in items:\n items.remove(x)\n" + assert 2 in code_lines(pd.analyze(src, "t.py"), "W006") + + def test_method_missing_self(self, pd): + src = "class A:\n def f():\n pass\n" + assert 2 in code_lines(pd.analyze(src, "t.py"), "W007") + + def test_staticmethod_ok(self, pd): + src = "class A:\n @staticmethod\n def f():\n pass\n" + assert "W007" not in codes(pd.analyze(src, "t.py")) + + def test_builtin_shadow(self, pd): + src = "list = [1, 2]\n" + assert 1 in code_lines(pd.analyze(src, "t.py"), "W008") + + def test_dead_code(self, pd): + src = "def f():\n return 1\n x = 2\n" + assert 3 in code_lines(pd.analyze(src, "t.py"), "W009") + + def test_import_star(self, pd): + src = "from math import *\n" + assert 1 in code_lines(pd.analyze(src, "t.py"), "W010") + + def test_div_zero(self, pd): + src = "x = 1 / 0\n" + assert 1 in code_lines(pd.analyze(src, "t.py"), "W011") + + def test_type_compare(self, pd): + src = "if type(x) == int:\n pass\n" + assert 1 in code_lines(pd.analyze(src, "t.py"), "I001") + + def test_clean_code_no_issues(self, pd): + src = ( + "import os\n" + "def add(a, b):\n" + " return a + b\n" + "def main():\n" + " path = os.path.join('x', 'y')\n" + " items = [1, 2, 3]\n" + " total = sum(items)\n" + " return path, add(total, 1)\n" + "if __name__ == '__main__':\n" + " main()\n" + ) + assert pd.analyze(src, "clean.py") == [] + + +# ── 插件接口 / handle ── +class TestHandle: + def test_version(self, pd): + plugin = pd.Liugin() + out = plugin.handle("version") + assert "py_detect" in out and "v1" in out + + def test_rules_list(self, pd): + plugin = pd.Liugin() + out = plugin.handle("rules") + assert "E002" in out and "W004" in out and "I001" in out + + def test_rules_single(self, pd): + plugin = pd.Liugin() + out = plugin.handle("rules W004") + assert "可变默认参数" in out + + def test_rules_unknown(self, pd): + plugin = pd.Liugin() + out = plugin.handle("rules ZZZ") + assert "没有规则" in out + + def test_check_missing_file(self, pd): + plugin = pd.Liugin() + out = plugin.handle("check /no/such/file.py") + assert "文件不存在" in out + + def test_check_dir(self, pd, tmp_path): + plugin = pd.Liugin() + out = plugin.handle(f"check {tmp_path}") + assert "是目录" in out + + def test_check_clean_file(self, pd, tmp_path): + plugin = pd.Liugin() + f = tmp_path / "ok.py" + f.write_text("x = 1\nprint(x)\n", encoding="utf-8") + out = plugin.handle(f"check {f}") + assert "未发现可疑问题" in out + + def test_check_with_issues(self, pd, tmp_path): + plugin = pd.Liugin() + f = tmp_path / "bad.py" + f.write_text("def f(items=[]):\n return items\n", encoding="utf-8") + out = plugin.handle(f"check {f}") + assert "[W004]" in out and "可变默认参数" in out + + def test_check_scope_filter(self, pd, tmp_path): + plugin = pd.Liugin() + f = tmp_path / "scoped.py" + f.write_text("y = None\nif y == None:\n pass\nz = 2\n", encoding="utf-8") + out = plugin.handle(f"check {f} 2") + assert "[W005]" in out and "未定义变量" not in out + + def test_check_bad_scope(self, pd, tmp_path): + plugin = pd.Liugin() + f = tmp_path / "any.py" + f.write_text("x = 1\n", encoding="utf-8") + out = plugin.handle(f"check {f} abc") + assert "范围格式错误" in out + + def test_tool_info(self, pd): + plugin = pd.Liugin() + info = plugin.get_tool_info() + assert info["name"] == "py_detect" + assert "智能检测" in info["description"] + assert "check" in info["usage"] + + def test_mcp_definition(self, pd): + plugin = pd.Liugin() + mcp = plugin.get_mcp_definition() + assert mcp["name"] == "py_detect" + assert "check" in mcp["inputSchema"]["properties"]["operation"]["enum"] + + +# ── code_editor 写文件后自动检测集成 ── +class TestAutoDetectIntegration: + """AI 用 code_editor 改完 .py 文件后,系统自动跑 py_detect""" + + @pytest.fixture + def editor(self): + mod = load_module("code_editor", os.path.join(PROJECT_ROOT, "plugins", "code_editor.py")) + cls = getattr(mod, "Liugin", None) or getattr(mod, "Plugin", None) + return cls() + + def test_write_dirty_py_auto_detects(self, editor, tmp_path): + f = tmp_path / "bad.py" + out = editor.handle(f"write {f} def f(items=[]):\n return missing_var\n") + assert "[py_detect]" in out, out + assert "[W004]" in out and "[E002]" in out, out + + def test_write_clean_py_silent(self, editor, tmp_path): + f = tmp_path / "ok.py" + out = editor.handle(f"write {f} x = 1\nprint(x)\n") + assert "[py_detect]" not in out, out + + def test_edit_py_auto_detects(self, editor, tmp_path): + f = tmp_path / "edit.py" + f.write_text("y = 1\nif y == 1:\n print('a')\n", encoding="utf-8") + # 编辑引入 == None 反模式 + out = editor.handle(f"edit {f} y == 1 <<<>>> y == None") + assert "[py_detect]" in out and "[W005]" in out, out + + def test_create_py_auto_detects(self, editor, tmp_path): + f = tmp_path / "new.py" + out = editor.handle(f"create {f} import os") + assert "[py_detect]" in out and "[W001]" in out, out # import os 未使用 + + def test_non_py_no_detect(self, editor, tmp_path): + f = tmp_path / "data.txt" + out = editor.handle(f"write {f} hello world") + assert "[py_detect]" not in out, out