Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions .github/workflows/skills-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion bin/codeguard
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# 开发/测试可复现基线(CI 同源安装)。
# ruff 同时是 python 适配器的真实检查器与若干回归用例的前置依赖
#(用例在 ruff 缺失时 skipIf 跳过,判定基线钉在 CI 版本防漂移)。
ruff==0.16.8
11 changes: 10 additions & 1 deletion scripts/bump-plugin.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 指定。");
Expand Down
63 changes: 62 additions & 1 deletion scripts/check_architecture.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""静态架构门禁:内核依赖白名单与第一方 Python 导入环。
"""静态架构门禁:内核依赖白名单、顶层脚本角色与第一方 Python 导入环。

只解析 AST,不导入被检查代码。函数内、条件分支中的静态 import 同样检查;
运行时拼接的动态加载不在静态保证范围内,本工具不是安全沙箱或 CodeGraph 替代。
Expand All @@ -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"},
Expand Down Expand Up @@ -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])
Expand Down
6 changes: 5 additions & 1 deletion scripts/run_per_language.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
6 changes: 5 additions & 1 deletion scripts/user_config.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
"""用户配置旧导入面的兼容导出;实现仅在 codeguard.config。"""
"""用户配置旧导入面的兼容导出;实现仅在 codeguard.config。

Deprecated:此模块是旧导入面的兼容外观,新代码必须从 `codeguard.config` 导入;
计划在下一个 major 版本移除。
"""
from codeguard.config import (
ConfigurationError,
get_overrides,
Expand Down
6 changes: 5 additions & 1 deletion scripts/verdict.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
"""旧导入路径的兼容层;判定实现仅存在于 codeguard.verdict。"""
"""旧导入路径的兼容层;判定实现仅存在于 codeguard.verdict。

Deprecated:此模块是旧导入面的兼容外观,新代码必须从 `codeguard.verdict` 导入;
计划在下一个 major 版本移除。
"""
from codeguard.verdict import (
FAIL,
PASS,
Expand Down
2 changes: 2 additions & 0 deletions tests/run_all.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)。

Expand Down
9 changes: 9 additions & 0 deletions tests/test_architecture.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion tests/test_cli_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()))

Expand Down
5 changes: 5 additions & 0 deletions tests/test_gate_application.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import json
import os
import shutil
import subprocess
import sys
import tempfile
Expand Down Expand Up @@ -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;"
Expand All @@ -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")
Expand Down
19 changes: 19 additions & 0 deletions tests/test_readme_parity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions tests/test_state_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import io
import json
import os
import shutil
import subprocess
import sys
import tempfile
Expand Down Expand Up @@ -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"):
Expand All @@ -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"),
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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",
Expand Down
Loading