diff --git a/.github/workflows/skills-check.yml b/.github/workflows/skills-check.yml index 5257bf1..bf4bc0a 100644 --- a/.github/workflows/skills-check.yml +++ b/.github/workflows/skills-check.yml @@ -8,17 +8,22 @@ on: jobs: vendor-check: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12", "3.13"] + name: vendor-check (py${{ matrix.python-version }}) steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: - python-version: "3.12" + python-version: ${{ matrix.python-version }} - name: Install requirements (mcp SDK so MCP server tests run, not skip) run: python3 -m pip install --disable-pip-version-check -r requirements.txt - - name: Install test checker (deterministic real-process fixtures) - run: python3 -m pip install --disable-pip-version-check ruff==0.16.8 + - name: Install dev baseline (ruff as python checker + test precondition) + run: python3 -m pip install --disable-pip-version-check -r requirements-dev.txt - name: Check executable code quality run: ruff check hooks scripts tests - name: Verify core dependency boundaries and import cycles diff --git a/README.md b/README.md index ac4adf7..9cd126e 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,8 @@ CLI exit priority: FAIL → 2; otherwise UNVERIFIED/PLANNED → 1; verified succ ## Java project awareness +The legacy import facades under `scripts/` (`verdict.py`, `user_config.py`, `run_per_language.py`) are Deprecated compatibility shims: new code must import from the `codeguard` package. They are scheduled for removal in the next major version. + ### Read-only planning ```bash diff --git a/README.zh-CN.md b/README.zh-CN.md index 6693275..f7a00b7 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -38,6 +38,8 @@ CLI 优先级:FAIL → 2;否则有 UNVERIFIED/PLANNED → 1;验证成功 ## Java 项目感知 +`scripts/` 下的旧导入门面(`verdict.py`、`user_config.py`、`run_per_language.py`)为 Deprecated 兼容 shim:新代码必须从 `codeguard` 包导入,计划在下一个 major 版本移除。 + ### 只读规划 ```bash diff --git a/bin/codeguard b/bin/codeguard index 7477266..2c3a399 100755 --- a/bin/codeguard +++ b/bin/codeguard @@ -47,8 +47,9 @@ case "$cmd" in 3. 从 linters/pre-commit/.pre-commit-config.template.yaml 拷贝并裁剪 4. pip install pre-commit && pre-commit install 或直接对 AI 说 /init,由 AI 按步骤完成。 +以上仅为引导清单,本命令不执行任何变更。 EOF - exit 1 + exit 0 ;; *) echo "codeguard: 未知子命令 '$cmd'" >&2 diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..2b88a57 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,4 @@ +# 开发/测试可复现基线(CI 同源安装)。 +# ruff 同时是 python 适配器的真实检查器与若干回归用例的前置依赖 +#(用例在 ruff 缺失时 skipIf 跳过,判定基线钉在 CI 版本防漂移)。 +ruff==0.16.8 diff --git a/scripts/bump-plugin.mjs b/scripts/bump-plugin.mjs index 04f122a..a33b9d6 100644 --- a/scripts/bump-plugin.mjs +++ b/scripts/bump-plugin.mjs @@ -49,14 +49,23 @@ function resolvePluginsRoot() { } while (dir !== path.parse(dir).root) { if (fs.existsSync(path.join(dir, "plugins", "catalog.json"))) return path.join(dir, "plugins"); + const candidates = []; try { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { if (entry.isDirectory() && /-plugins$/.test(entry.name) && fs.existsSync(path.join(dir, entry.name, "catalog.json"))) { - return path.join(dir, entry.name); + candidates.push(path.join(dir, entry.name)); } } } catch { /* 不可读目录跳过 */ } + if (candidates.length === 1) return candidates[0]; + if (candidates.length > 1) { + // 歧义目录(如工作区根同时含多个 *-plugins 市场仓)拒绝 first-match: + // 静默选错市场会把版本写进错误的 catalog。 + throw new Error( + `发现多个插件市场仓候选,拒绝猜测,请设 PARTME_PLUGINS_ROOT 指定:\n ${candidates.join("\n ")}`, + ); + } dir = path.dirname(dir); } throw new Error("找不到插件市场仓。可设 PARTME_PLUGINS_ROOT 指定。"); diff --git a/scripts/check_architecture.py b/scripts/check_architecture.py index 60d69f4..1bf0a48 100644 --- a/scripts/check_architecture.py +++ b/scripts/check_architecture.py @@ -1,4 +1,4 @@ -"""静态架构门禁:内核依赖白名单与第一方 Python 导入环。 +"""静态架构门禁:内核依赖白名单、顶层脚本角色与第一方 Python 导入环。 只解析 AST,不导入被检查代码。函数内、条件分支中的静态 import 同样检查; 运行时拼接的动态加载不在静态保证范围内,本工具不是安全沙箱或 CodeGraph 替代。 @@ -11,6 +11,34 @@ from pathlib import Path # 明确的层依赖;stdlib 也受约束,纯模型不能悄悄导入 subprocess/宿主。 +# 顶层 scripts/*.py 的显式角色登记:新脚本必须归类,不许"就是多了一个脚本"。 +# entry=命令入口 / compat-shim=旧导入面兼容外观(docstring 必须含 Deprecated)/ +# adapter=物化适配层 / support=共享工具层。内核包 codeguard.* 走 CORE_DEPENDENCIES, +# 本表只管包外顶层脚本;两者共同构成可检查的分层边界。 +SCRIPT_ROLES = { + "check_architecture.py": "entry", + "cve_check.py": "entry", + "detect_lang.py": "entry", + "dockerfile_security.py": "entry", + "fix.py": "entry", + "gen_language_docs.py": "entry", + "java_project.py": "entry", + "run_check.py": "entry", + "validate_languages_json.py": "entry", + "verdict.py": "compat-shim", + "user_config.py": "compat-shim", + "run_per_language.py": "compat-shim", + "scope.py": "adapter", + "git_snapshot.py": "adapter", + "paths.py": "support", +} + +# 适配层白名单:包外实现模块的用途声明(谁在依赖它由 CORE_DEPENDENCIES 约束)。 +ADAPTER_LAYERS = { + "scope.py": "linter 命令作用域物化 + git 改动集(delta 门禁共享层)", + "git_snapshot.py": "只读 index/HEAD 物化一次性检查目录", +} + CORE_DEPENDENCIES = { "codeguard.cve_policy": {"__future__", "dataclasses"}, "codeguard.cve_reports": {"__future__", "json", "math", "codeguard.cve_policy"}, @@ -191,9 +219,42 @@ def check(root: Path) -> list[str]: graph[name].add(local) if allowed is not None and target not in allowed and target.split(".")[0] not in allowed: errors.append(f"{relative}:{line}: forbidden dependency: {name} -> {target}") + errors.extend(_script_roles(root, modules)) return errors + _cycles(graph) +def _script_roles(root: Path, modules: dict) -> list[str]: + """顶层 scripts/*.py 必须显式归类;compat-shim 必须带 Deprecated 通告; + adapter 必须在 ADAPTER_LAYERS 声明用途。""" + errors: list[str] = [] + top_level = { + path.name + for path in (root / "scripts").glob("*.py") + } + for name in sorted(top_level - set(SCRIPT_ROLES)): + errors.append(f"scripts/{name}:1: unclassified top-level script (add to SCRIPT_ROLES)") + # 完备性校验只对真实插件仓布局生效:架构用例会在合成临时树上跑本检查, + # 临时树不需要携带全部顶层脚本,不应误报 missing。 + is_full_repo = (root / "scripts" / "check_architecture.py").is_file() + if is_full_repo: + for name in sorted(set(SCRIPT_ROLES) - top_level): + errors.append(f"scripts/{name}:1: declared in SCRIPT_ROLES but missing on disk") + for name, role in sorted(SCRIPT_ROLES.items()): + path = root / "scripts" / name + if not path.is_file(): + continue + if is_full_repo and role == "compat-shim": + head = path.read_text(encoding="utf-8")[:400] + if "Deprecated" not in head: + errors.append(f"scripts/{name}:1: compat-shim must carry a Deprecated notice in its docstring") + if role == "adapter" and is_full_repo and name not in ADAPTER_LAYERS: + errors.append(f"scripts/{name}:1: adapter must declare its purpose in ADAPTER_LAYERS") + if is_full_repo: + for name in sorted(set(ADAPTER_LAYERS) - top_level): + errors.append(f"scripts/{name}:1: declared in ADAPTER_LAYERS but missing on disk") + return errors + + def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1]) diff --git a/scripts/run_per_language.py b/scripts/run_per_language.py index deb6325..fc7795e 100644 --- a/scripts/run_per_language.py +++ b/scripts/run_per_language.py @@ -1,4 +1,8 @@ -"""旧脚本导入兼容入口;按语言检查的唯一实现位于 codeguard.language_check。""" +"""旧脚本导入兼容入口;按语言检查的唯一实现位于 codeguard.language_check。 + +Deprecated:此模块是旧导入面的兼容外观,新代码必须从 `codeguard.language_check` 导入; +计划在下一个 major 版本移除。 +""" from __future__ import annotations from codeguard.language_check import LANG_COMMANDS, _run, run_check, run_fix diff --git a/scripts/user_config.py b/scripts/user_config.py index 22bc154..a901182 100644 --- a/scripts/user_config.py +++ b/scripts/user_config.py @@ -1,4 +1,8 @@ -"""用户配置旧导入面的兼容导出;实现仅在 codeguard.config。""" +"""用户配置旧导入面的兼容导出;实现仅在 codeguard.config。 + +Deprecated:此模块是旧导入面的兼容外观,新代码必须从 `codeguard.config` 导入; +计划在下一个 major 版本移除。 +""" from codeguard.config import ( ConfigurationError, get_overrides, diff --git a/scripts/verdict.py b/scripts/verdict.py index c582483..d65661f 100644 --- a/scripts/verdict.py +++ b/scripts/verdict.py @@ -1,4 +1,8 @@ -"""旧导入路径的兼容层;判定实现仅存在于 codeguard.verdict。""" +"""旧导入路径的兼容层;判定实现仅存在于 codeguard.verdict。 + +Deprecated:此模块是旧导入面的兼容外观,新代码必须从 `codeguard.verdict` 导入; +计划在下一个 major 版本移除。 +""" from codeguard.verdict import ( FAIL, PASS, diff --git a/tests/run_all.py b/tests/run_all.py index 0afac6c..16013a9 100755 --- a/tests/run_all.py +++ b/tests/run_all.py @@ -8,6 +8,8 @@ python3 tests/run_all.py unit # 纯函数单测(glob/requiresConfig、{file} 兜底、多 cd 边界、综述≠细节) python3 tests/run_all.py cve # CVE 生态标识、别名归一化与参数校验退出码 +与 `unittest discover tests/test_*.py` 的分工:纯函数/单元语义单测归 test_*.py;本套件只放宿主协议级模拟、结构审计与跨进程边界回归。CI 两轨都跑(coverage 版 unittest discover + 本套件),新增测试按此归属。 + 钩子模拟的原理 = 完全复刻宿主行为:把 ZCode/Claude 会发给钩子的 JSON payload 通过 stdin 喂给真实钩子脚本,断言退出码与输出协议(exit 0 JSON / exit 2 stderr)。 diff --git a/tests/test_architecture.py b/tests/test_architecture.py index 6de99af..eafec11 100644 --- a/tests/test_architecture.py +++ b/tests/test_architecture.py @@ -28,6 +28,15 @@ def check(self, root=None): return subprocess.run([sys.executable, str(CHECKER), "--root", str(root or self.root)], capture_output=True, text=True, timeout=10, check=False) + def test_top_level_scripts_carry_declared_roles(self): + """顶层脚本角色登记完整:compat-shim 带 Deprecated 通告,adapter 有用途声明。""" + proc = self.check(ROOT) + role_errors = [ + line for line in proc.stdout.splitlines() + if any(tag in line for tag in ("SCRIPT_ROLES", "compat-shim", "ADAPTER_LAYERS", "unclassified top-level")) + ] + self.assertEqual([], role_errors, proc.stdout) + def test_current_repository_obeys_declared_dependencies(self): result = self.check(ROOT) self.assertEqual(0, result.returncode, result.stdout + result.stderr) diff --git a/tests/test_cli_contracts.py b/tests/test_cli_contracts.py index 4dd4415..2052295 100644 --- a/tests/test_cli_contracts.py +++ b/tests/test_cli_contracts.py @@ -34,7 +34,7 @@ def git(self, *args: str) -> None: def test_init_only_prints_guidance_without_writing_project(self): completed = self.cli("init") - self.assertEqual(1, completed.returncode) + self.assertEqual(0, completed.returncode) self.assertIn("项目初始化引导", completed.stderr) self.assertEqual([], list(self.root.iterdir())) diff --git a/tests/test_gate_application.py b/tests/test_gate_application.py index fb1378b..205ae48 100644 --- a/tests/test_gate_application.py +++ b/tests/test_gate_application.py @@ -3,6 +3,7 @@ import json import os +import shutil import subprocess import sys import tempfile @@ -32,6 +33,8 @@ def git(self, *args): return subprocess.run(["git", *args], cwd=self.repo, check=True, text=True, capture_output=True).stdout + @unittest.skipIf(shutil.which("ruff") is None, + "ruff 不在 PATH:本用例依赖真实 python lint 判定(见 requirements-dev.txt)") def test_application_runs_without_hook_directory_or_host_modules(self): script = ("import sys,json;from pathlib import Path;sys.path.insert(0,sys.argv[1]);" "from codeguard.gate import run_gate;" @@ -48,6 +51,8 @@ def test_application_runs_without_hook_directory_or_host_modules(self): self.assertFalse(legacy_imported) self.assertFalse(host_imported) + @unittest.skipIf(shutil.which("ruff") is None, + "ruff 不在 PATH:本用例依赖真实 python lint 判定(见 requirements-dev.txt)") def test_snapshot_audit_has_original_worktree_session_and_actual_argv(self): (self.repo / "a.py").write_text("undefined_name()\n") self.git("add", "a.py") diff --git a/tests/test_readme_parity.py b/tests/test_readme_parity.py index c71762f..fd828f5 100755 --- a/tests/test_readme_parity.py +++ b/tests/test_readme_parity.py @@ -79,6 +79,25 @@ def test_local_link_targets_exist(self) -> None: if path: self.assertTrue((ROOT / path).exists(), f"broken README link: {target}") + def test_registry_counts_match_both_readmes(self) -> None: + """注册表计数与 README 双语同步锁定:languages.json 数量变化必须在同一提交里更新两份 README。""" + import json as _json + import re as _re + + langs = _json.loads( + (ROOT / "scripts" / "languages.json").read_text(encoding="utf-8") + )["languages"] + stable = sum(1 for lang in langs if lang.get("status") == "stable") + planned = sum(1 for lang in langs if lang.get("status") == "planned") + en = EN.read_text(encoding="utf-8") + zh = ZH.read_text(encoding="utf-8") + en_m = _re.search(r"(\d+) Stable adapters and (\d+) Planned", en) + zh_m = _re.search(r"(\d+) 个 Stable 适配器和 (\d+) 个 Planned", zh) + self.assertIsNotNone(en_m, "README.md 缺少注册表计数句") + self.assertIsNotNone(zh_m, "README.zh-CN.md 缺少注册表计数句") + self.assertEqual((stable, planned), (int(en_m.group(1)), int(en_m.group(2)))) + self.assertEqual((stable, planned), (int(zh_m.group(1)), int(zh_m.group(2)))) + def test_version_strings_match(self) -> None: en_v, zh_v = _versions(self.en), _versions(self.zh) self.assertEqual(en_v, zh_v, diff --git a/tests/test_state_storage.py b/tests/test_state_storage.py index 216060a..5678735 100644 --- a/tests/test_state_storage.py +++ b/tests/test_state_storage.py @@ -5,6 +5,7 @@ import io import json import os +import shutil import subprocess import sys import tempfile @@ -78,6 +79,8 @@ def repo(self, name): (repo / "a.py").write_text("print(1)\n") return repo + @unittest.skipIf(shutil.which("ruff") is None, + "ruff 不在 PATH:本用例依赖真实 python lint 判定(见 requirements-dev.txt)") def test_stop_does_not_consume_another_sessions_statistics(self): repo = self.repo("repo") for session in ("first", "second"): @@ -89,6 +92,8 @@ def test_stop_does_not_consume_another_sessions_statistics(self): self.assertIn("共 1 次检查", first) self.assertIn("共 1 次检查", second) + @unittest.skipIf(shutil.which("ruff") is None, + "ruff 不在 PATH:本用例依赖真实 python lint 判定(见 requirements-dev.txt)") def test_same_session_in_two_worktrees_has_separate_statistics(self): left, right = self.repo("left"), self.root / "right" for args in (("config", "user.name", "fixture"), ("config", "user.email", "test@example.invalid"), @@ -124,6 +129,8 @@ def test_unknown_result_does_not_suppress_immediate_retry(self): self.assertIn("UNVERIFIED", out.getvalue()) self.assertIn("passed", out.getvalue()) + @unittest.skipIf(shutil.which("ruff") is None, + "ruff 不在 PATH:本用例依赖真实 python lint 判定(见 requirements-dev.txt)") def test_duplicate_hard_gate_event_cannot_turn_a_rejection_into_permission(self): repo = self.repo("repo") (repo / "a.py").write_text("undefined_variable()\n") @@ -153,6 +160,8 @@ def failing_change(state): self.assertEqual({"count": 3}, json.loads(path.read_text())) self.assertEqual([], list(self.root.glob("*.tmp"))) + @unittest.skipIf(shutil.which("ruff") is None, + "ruff 不在 PATH:本用例依赖真实 python lint 判定(见 requirements-dev.txt)") def test_same_hard_event_rechecks_changed_content_after_permission(self): repo = self.repo("repo") payload = {"session_id": "one", "tool_use_id": "same-event",