muse-agent-example/harness/skill_harness.py
zizi c9f69d9d6d 治理: Skill 测试治理第一阶段——harness 控制平面 + 实现测试迁出运行时目录
范围(不含 design-story-foundation、docs/、humanization/README.md 等进行中改动):

1. 新增 harness/ 控制平面
   - skill_harness.py 静态审计:32 个运行时 Skill 的 frontmatter/manifest/文档污染,当前 0 问题
   - run_selected.py 选择性执行器:manifest 与磁盘一一对账、依赖阻断、
     空跑与 skip-only 失败关闭、AST 测试形状门
   - manifests/skills.json:32 个 Skill 的合同责任方与协作领域登记
   - manifests/test-inventory.json:81 个测试资产登记
   - specs/skill-testing.md 与 README.md:测试分层、证据边界与 harness 职责

2. 实现测试从 .claude/skills/*/scripts/ 迁至 tests/skills/<skill>/
   - 71 个测试文件迁移并修复项目根与临时目录运行导入
   - 数据库触发器测试宽泛异常收窄为 psycopg.errors.RaiseException
   - 抽取离线大测试拆出真实 PG smoke(默认阻断,不计入离线通过)
   - 抽取 presence 去重边界拆出独立测试:493 + 78 = 571 项检查不变

3. 运行时文档清理
   - 13 个 SKILL.md 移除自测/离线验证段落、测试命令与测试文件事实源表述,
     只保留运行时合同;业务运行合同、额度、授权与离线模式均保留

4. SoT 同步
   - AGENTS.md:新增 Skill 领域索引(7 个合同责任方分组,覆盖 32 个运行时 Skill)
   - 领域 07:测试入口改由 harness/manifests/ 登记,SKILL.md 不承载测试命令
   - humanization 覆盖矩阵:活动测试路径同步迁移

验证证据: harness 自测 15 项 + runner 自测 13 项通过;静态审计 32 Skill / 0 问题;
73 个非数据库测试通过;8 个集成条目中 6 个 PostgreSQL 项被依赖门明确阻断;
py_compile 与 git diff --check 通过。未连接 PostgreSQL、网络、真实模型或额度。

已知边界: 真正 skill_behavior_eval 仍为 0,尚未验证任何 Skill 自然语言行为;
evaluate-frozen-replay 的 raw 存储边界冲突留待单独治理。
2026-08-19 01:50:20 +08:00

871 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""对项目运行时 Skill 文档执行只读静态审计。
本模块只读取 `.claude/skills/*/SKILL.md`,不连接数据库、网络或模型,也不修改
工作树。机器调用使用 :func:`audit_skills`,命令行入口同时支持人读摘要和 JSON。
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from collections import Counter
from pathlib import Path
from typing import Any, Iterable, Optional, Sequence
SCHEMA_VERSION = 1
SKILLS_ROOT = Path(".claude") / "skills"
DEFAULT_MANIFEST_PATH = Path("harness") / "manifests" / "skills.json"
_FRONTMATTER_NAME = re.compile(r"^\s*name\s*:\s*(.*?)\s*$")
_COVERAGE_TOOL_PATTERN = re.compile(
r"(?:\bcoverage\.py\b|\bpytest-cov\b|"
r"(?<![A-Za-z0-9_])--cov(?:[-A-Za-z0-9_]+)?(?:\s|=|:|$)|"
r"\bcoverage\s+report\b)",
re.IGNORECASE,
)
_EXPLICIT_TEST_COVERAGE_PATTERN = re.compile(
r"(?:"
r"(?:测试(?:用例|代码)?|单元(?:测试)?|集成(?:测试)?|回归测试|开发测试|开发验证|"
r"代码|分支|语句|行|函数|方法)\s*覆盖率"
r"|(?:unit(?:\s+test)?|integration(?:\s+test)?|regression\s+test|"
r"test|code|branch|statement|line|function|method)\s+coverage"
r")",
re.IGNORECASE,
)
_COVERAGE_PERCENT_PATTERN = re.compile(
r"覆盖率\s*(?:达到|为|至少|不低于|不少于|应达到|>=|>|:|:)?\s*"
r"\d+(?:\.\d+)?\s*[%%]",
re.IGNORECASE,
)
_BUSINESS_COVERAGE_CONTEXT_PATTERN = re.compile(
r"(?:细纲|事件|硬约束|软约束|伏笔|实体|设定|情节|场景|知识|事实|正文|"
r"作品|业务|候选|规划|大纲|章)[^。\n]{0,24}覆盖率"
r"|覆盖率[^。\n]{0,24}(?:细纲|事件|硬约束|软约束|伏笔|实体|设定|情节|"
r"场景|知识|事实|正文|作品|业务|候选|规划|大纲|章)",
re.IGNORECASE,
)
# 这些规则只针对运行时 Skill 文档中的明显开发验证残留;业务正文中的普通“检查”
# 等词不在扫描范围内。规则保持确定性,方便报告中的 code 作为机械门禁输入。
_DEVELOPMENT_HEADING_PATTERN = re.compile(r"^\s*(#{1,6})\s*(.*?)\s*$")
_DEVELOPMENT_SECTION_TITLE_PATTERN = re.compile(
r"(?:自测|测试|开发验证|离线验证|开发测试|离线测试)",
re.IGNORECASE,
)
_DEVELOPMENT_COMMAND_PATTERN = re.compile(
r"(?<![A-Za-z0-9_])check_contract\.py(?![A-Za-z0-9_])",
re.IGNORECASE,
)
_POLLUTION_RULES = (
(
"pytest_reference",
re.compile(r"(?<![A-Za-z0-9_])pytest(?![A-Za-z0-9_])", re.IGNORECASE),
"发现 pytest 测试入口",
),
(
"unittest_reference",
re.compile(r"(?<![A-Za-z0-9_])unittest(?![A-Za-z0-9_])", re.IGNORECASE),
"发现 unittest 测试入口",
),
(
"test_file_reference",
re.compile(
r"(?:\btest_[A-Za-z0-9][A-Za-z0-9_.-]*\.py\b"
r"|\b[A-Za-z0-9][A-Za-z0-9_.-]*_test\.py\b"
r"|test_\*\.py|\*_test\.py)",
re.IGNORECASE,
),
"发现测试文件路径或文件名模式",
),
(
"test_pass_declaration",
re.compile(
r"(?:"
r"(?:所有|全部)?\s*测试\s*(?:已|已经|均|都|全部)?\s*(?:通过|成功)"
r"|(?:通过|成功)\s*测试"
r"|(?:all|the)\s+tests?\s+(?:all\s+)?(?:pass(?:ed|es|ing)?|succeed(?:ed|s|ing)?|successful)"
r"|tests?\s+(?:all\s+)?(?:pass(?:ed|es|ing)?|succeed(?:ed|s|ing)?|successful)"
r")",
re.IGNORECASE,
),
"发现测试通过声明",
),
)
def _resolve_root(root: str | Path) -> Path:
"""把审计根目录解析为绝对路径,但不要求它已经存在。"""
path = Path(root).expanduser()
if not path.is_absolute():
path = Path.cwd() / path
return path.resolve()
def _relative_path(path: Path, root: Path) -> str:
"""返回报告中的稳定 POSIX 相对路径。"""
try:
return path.resolve().relative_to(root).as_posix()
except ValueError:
return path.resolve().as_posix()
def _issue(
code: str,
message: str,
*,
path: Optional[str] = None,
line: Optional[int] = None,
**details: Any,
) -> dict[str, Any]:
result: dict[str, Any] = {
"code": code,
"severity": "error",
"message": message,
}
if path is not None:
result["path"] = path
if line is not None:
result["line"] = line
result.update(details)
return result
def _unquote_scalar(value: str) -> str:
"""解析 Skill frontmatter 中足够支持 name 的简单标量。"""
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] == '"':
try:
parsed = json.loads(value)
except json.JSONDecodeError:
return value[1:-1]
return parsed if isinstance(parsed, str) else value
if len(value) >= 2 and value[0] == value[-1] == "'":
return value[1:-1].replace("''", "'")
# 允许常见的 YAML 行尾注释,不引入 YAML 依赖。
return re.sub(r"\s+#.*$", "", value).strip()
def _parse_frontmatter(text: str) -> tuple[Optional[dict[str, str]], Optional[int], Optional[str]]:
"""读取 frontmatter 的简单键值视图。
返回值为 ``(fields, closing_line, error_code)``。该解析器只需要识别 name,
不试图替代完整 YAML 解析器;不合法或缺失的边界会明确进入失败报告。
"""
lines = text.splitlines()
if not lines or lines[0].lstrip("\ufeff").strip() != "---":
return None, None, "frontmatter_missing"
closing_line: Optional[int] = None
for index in range(1, len(lines)):
if lines[index].strip() in {"---", "..."}:
closing_line = index + 1
break
if closing_line is None:
return None, None, "frontmatter_unclosed"
fields: dict[str, str] = {}
for line in lines[1 : closing_line - 1]:
match = _FRONTMATTER_NAME.match(line)
if match:
fields["name"] = _unquote_scalar(match.group(1))
return fields, closing_line, None
def _snippet(line: str) -> str:
compact = line.strip()
return compact if len(compact) <= 200 else compact[:197] + "..."
def _is_development_coverage_reference(line: str) -> bool:
"""只把明确落在开发测试语境中的 coverage 文字判为污染。"""
if _COVERAGE_TOOL_PATTERN.search(line) or _EXPLICIT_TEST_COVERAGE_PATTERN.search(line):
return True
if not _COVERAGE_PERCENT_PATTERN.search(line):
return False
return not _BUSINESS_COVERAGE_CONTEXT_PATTERN.search(line)
def _inspect_skill_file(path: Path, root: Path) -> tuple[dict[str, Any], list[dict[str, Any]]]:
relative = _relative_path(path, root)
entry: dict[str, Any] = {
"directory": path.parent.name,
"skill_path": relative,
"name": None,
"frontmatter_present": False,
}
issues: list[dict[str, Any]] = []
try:
text = path.read_text(encoding="utf-8")
except (OSError, UnicodeError) as exc:
issues.append(
_issue(
"skill_file_unreadable",
"无法读取 SKILL.md",
path=relative,
error=str(exc),
)
)
return entry, issues
if not text.strip():
issues.append(
_issue(
"empty_skill_file",
"SKILL.md 为空",
path=relative,
line=1,
)
)
return entry, issues
fields, closing_line, frontmatter_error = _parse_frontmatter(text)
if frontmatter_error is not None:
line = 1 if frontmatter_error == "frontmatter_missing" else max(1, len(text.splitlines()))
messages = {
"frontmatter_missing": "缺少以 --- 开始的 frontmatter",
"frontmatter_unclosed": "frontmatter 未闭合",
}
issues.append(
_issue(
frontmatter_error,
messages[frontmatter_error],
path=relative,
line=line,
)
)
else:
entry["frontmatter_present"] = True
name = fields.get("name") if fields is not None else None
entry["name"] = name or None
if not name:
issues.append(
_issue(
"frontmatter_name_missing",
"frontmatter 缺少非空 name",
path=relative,
line=1,
)
)
elif name != path.parent.name:
issues.append(
_issue(
"name_directory_mismatch",
"frontmatter name 与 Skill 目录名不一致",
path=relative,
line=2,
expected=path.parent.name,
actual=name,
)
)
development_section_level: Optional[int] = None
for line_number, line in enumerate(text.splitlines(), start=1):
heading_match = _DEVELOPMENT_HEADING_PATTERN.match(line)
if heading_match:
heading_level = len(heading_match.group(1))
heading_title = re.sub(r"\s+#+\s*$", "", heading_match.group(2)).strip()
if (
development_section_level is not None
and heading_level <= development_section_level
):
development_section_level = None
if (
2 <= heading_level <= 6
and _DEVELOPMENT_SECTION_TITLE_PATTERN.search(heading_title)
):
development_section_level = heading_level
issues.append(
_issue(
"development_test_heading",
"发现开发测试章节标题",
path=relative,
line=line_number,
snippet=_snippet(line),
)
)
for code, pattern, message in _POLLUTION_RULES:
if pattern.search(line):
issues.append(
_issue(
code,
message,
path=relative,
line=line_number,
snippet=_snippet(line),
)
)
if (
development_section_level is not None
and _DEVELOPMENT_COMMAND_PATTERN.search(line)
):
issues.append(
_issue(
"development_test_command_reference",
"发现开发测试章节中的合同测试命令",
path=relative,
line=line_number,
snippet=_snippet(line),
)
)
if _is_development_coverage_reference(line):
issues.append(
_issue(
"coverage_reference",
"发现开发测试语境中的覆盖率声明或工具名",
path=relative,
line=line_number,
snippet=_snippet(line),
)
)
return entry, issues
def _scan_skill_directories(root: Path) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
skills_root = root / SKILLS_ROOT
relative_skills_root = _relative_path(skills_root, root)
entries: list[dict[str, Any]] = []
issues: list[dict[str, Any]] = []
if not skills_root.exists():
issues.append(
_issue(
"skills_directory_missing",
"运行时 Skill 目录不存在",
path=relative_skills_root,
)
)
return entries, issues
if not skills_root.is_dir():
issues.append(
_issue(
"skills_directory_not_directory",
"运行时 Skill 路径不是目录",
path=relative_skills_root,
)
)
return entries, issues
try:
children = sorted(skills_root.iterdir(), key=lambda item: item.name)
except OSError as exc:
issues.append(
_issue(
"skills_directory_unreadable",
"无法列出运行时 Skill 目录",
path=relative_skills_root,
error=str(exc),
)
)
return entries, issues
directories = [child for child in children if child.is_dir()]
if not directories:
issues.append(
_issue(
"no_skills_found",
"运行时 Skill 目录中未发现 Skill 子目录",
path=relative_skills_root,
)
)
return entries, issues
for directory in directories:
skill_file = directory / "SKILL.md"
relative_skill_file = _relative_path(skill_file, root)
disk_entry: dict[str, Any] = {
"directory": directory.name,
"skill_path": relative_skill_file,
"name": None,
"frontmatter_present": False,
}
if not skill_file.exists():
entries.append(disk_entry)
issues.append(
_issue(
"missing_skill_file",
"Skill 目录缺少 SKILL.md",
path=relative_skill_file,
)
)
continue
if not skill_file.is_file():
entries.append(disk_entry)
issues.append(
_issue(
"skill_file_not_file",
"SKILL.md 路径不是普通文件",
path=relative_skill_file,
)
)
continue
entry, file_issues = _inspect_skill_file(skill_file, root)
entries.append(entry)
issues.extend(file_issues)
return entries, issues
def _resolve_manifest_path(root: Path, manifest: str | Path | None) -> Path:
path = DEFAULT_MANIFEST_PATH if manifest is None else Path(manifest).expanduser()
if not path.is_absolute():
path = root / path
return path.resolve()
def _normalise_manifest_skill_path(
root: Path, value: str
) -> tuple[Optional[str], Optional[Path]]:
declared = Path(value)
if declared.is_absolute():
return None, None
candidate = (root / declared).resolve()
try:
relative = candidate.relative_to(root).as_posix()
except ValueError:
return None, None
return relative, candidate
def _validate_manifest(
root: Path,
entries: Sequence[dict[str, Any]],
manifest_path: Path,
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
"""验证 Skill 登记表,并要求它与磁盘目录一一对应。"""
relative_manifest = _relative_path(manifest_path, root)
metadata: dict[str, Any] = {
"path": relative_manifest,
"loaded": False,
"skills_declared": 0,
}
issues: list[dict[str, Any]] = []
if not manifest_path.exists():
issues.append(
_issue(
"manifest_missing",
"Skill manifest 不存在",
path=relative_manifest,
)
)
return metadata, issues
if not manifest_path.is_file():
issues.append(
_issue(
"manifest_not_file",
"Skill manifest 路径不是普通文件",
path=relative_manifest,
)
)
return metadata, issues
try:
manifest_text = manifest_path.read_text(encoding="utf-8")
manifest = json.loads(manifest_text)
except UnicodeError as exc:
issues.append(
_issue(
"manifest_unreadable",
"无法按 UTF-8 读取 Skill manifest",
path=relative_manifest,
error=str(exc),
)
)
return metadata, issues
except OSError as exc:
issues.append(
_issue(
"manifest_unreadable",
"无法读取 Skill manifest",
path=relative_manifest,
error=str(exc),
)
)
return metadata, issues
except json.JSONDecodeError as exc:
issues.append(
_issue(
"manifest_invalid_json",
"Skill manifest 不是合法 JSON",
path=relative_manifest,
line=exc.lineno,
column=exc.colno,
error=exc.msg,
)
)
return metadata, issues
metadata["loaded"] = True
if not isinstance(manifest, dict):
issues.append(
_issue(
"manifest_invalid_structure",
"Skill manifest 顶层必须是对象",
path=relative_manifest,
)
)
return metadata, issues
raw_skills = manifest.get("skills")
if not isinstance(raw_skills, list):
issues.append(
_issue(
"manifest_skills_invalid",
"Skill manifest.skills 必须是数组",
path=relative_manifest,
)
)
return metadata, issues
metadata["skills_declared"] = len(raw_skills)
disk_paths = {
str(entry["skill_path"])
for entry in entries
if isinstance(entry.get("skill_path"), str)
}
declared_paths: set[str] = set()
path_occurrences: dict[str, list[int]] = {}
name_occurrences: dict[str, list[int]] = {}
skills_root = (root / SKILLS_ROOT).resolve()
for index, item in enumerate(raw_skills):
if not isinstance(item, dict):
issues.append(
_issue(
"manifest_entry_invalid",
"Skill manifest 条目必须是对象",
path=relative_manifest,
entry_index=index,
)
)
continue
name = item.get("name")
if (
not isinstance(name, str)
or not name.strip()
or "/" in name
or "\\" in name
or name in {".", ".."}
):
issues.append(
_issue(
"manifest_name_invalid",
"manifest 条目的 name 必须是非空目录名",
path=relative_manifest,
entry_index=index,
actual=name,
)
)
else:
name_occurrences.setdefault(name, []).append(index)
contract_owner = item.get("contract_owner")
if not isinstance(contract_owner, str) or not contract_owner.strip():
issues.append(
_issue(
"manifest_contract_owner_invalid",
"manifest 条目的 contract_owner 必须非空",
path=relative_manifest,
entry_index=index,
)
)
collaborations = item.get("collaborates_with")
if (
not isinstance(collaborations, list)
or any(not isinstance(value, str) or not value.strip() for value in collaborations)
):
issues.append(
_issue(
"manifest_collaborates_with_invalid",
"manifest 条目的 collaborates_with 必须是非空字符串数组",
path=relative_manifest,
entry_index=index,
)
)
declared_skill_path = item.get("skill_path")
if not isinstance(declared_skill_path, str) or not declared_skill_path.strip():
issues.append(
_issue(
"manifest_skill_path_invalid",
"manifest 条目的 skill_path 必须是非空相对路径",
path=relative_manifest,
entry_index=index,
actual=declared_skill_path,
)
)
continue
normalised_path, candidate = _normalise_manifest_skill_path(
root, declared_skill_path
)
if normalised_path is None or candidate is None:
issues.append(
_issue(
"manifest_skill_path_invalid",
"manifest 条目的 skill_path 必须位于项目根目录内",
path=relative_manifest,
entry_index=index,
actual=declared_skill_path,
)
)
continue
declared_paths.add(normalised_path)
path_occurrences.setdefault(normalised_path, []).append(index)
if candidate != candidate.parent / "SKILL.md" or not candidate.is_relative_to(skills_root):
issues.append(
_issue(
"manifest_skill_path_invalid",
"manifest 条目的 skill_path 必须指向 .claude/skills/*/SKILL.md",
path=normalised_path,
entry_index=index,
)
)
if not candidate.exists():
issues.append(
_issue(
"manifest_skill_path_missing",
"manifest 登记的 Skill 路径不存在",
path=normalised_path,
entry_index=index,
)
)
elif not candidate.is_file():
issues.append(
_issue(
"manifest_skill_path_not_file",
"manifest 登记的 Skill 路径不是普通文件",
path=normalised_path,
entry_index=index,
)
)
directory_name = candidate.parent.name
if isinstance(name, str) and name.strip() and name != directory_name:
issues.append(
_issue(
"manifest_name_directory_mismatch",
"manifest name 与 skill_path 的目录名不一致",
path=normalised_path,
entry_index=index,
expected=directory_name,
actual=name,
)
)
if isinstance(name, str) and name.strip():
expected_path = (root / SKILLS_ROOT / name / "SKILL.md").resolve()
expected_relative = _relative_path(expected_path, root)
if normalised_path != expected_relative:
issues.append(
_issue(
"manifest_skill_path_mismatch",
"manifest skill_path 与 name 对应的 Skill 路径不一致",
path=normalised_path,
entry_index=index,
expected=expected_relative,
actual=normalised_path,
)
)
for path, indexes in sorted(path_occurrences.items()):
if len(indexes) > 1:
issues.append(
_issue(
"manifest_duplicate_skill_path",
"manifest 重复登记同一个 Skill 路径",
path=path,
entry_indexes=indexes,
)
)
for name, indexes in sorted(name_occurrences.items()):
if len(indexes) > 1:
issues.append(
_issue(
"manifest_duplicate_name",
"manifest 重复登记同一个 Skill name",
path=relative_manifest,
name=name,
entry_indexes=indexes,
)
)
for path in sorted(disk_paths - declared_paths):
issues.append(
_issue(
"manifest_skill_missing",
"磁盘 Skill 未在 manifest 中登记",
path=path,
)
)
for path in sorted(declared_paths - disk_paths):
issues.append(
_issue(
"manifest_skill_extra",
"manifest 登记了磁盘中不存在的额外 Skill",
path=path,
)
)
return metadata, issues
def _duplicate_name_issues(entries: Iterable[dict[str, Any]]) -> list[dict[str, Any]]:
by_name: dict[str, list[str]] = {}
for entry in entries:
name = entry.get("name")
if isinstance(name, str) and name:
by_name.setdefault(name, []).append(str(entry["skill_path"]))
issues: list[dict[str, Any]] = []
for name in sorted(by_name):
paths = sorted(by_name[name])
if len(paths) > 1:
issues.append(
_issue(
"duplicate_name",
"多个 Skill 使用相同的 frontmatter name",
path=paths[0],
name=name,
paths=paths,
)
)
return issues
def _summarize(issues: Sequence[dict[str, Any]], skill_count: int) -> dict[str, Any]:
counts = Counter(str(issue["code"]) for issue in issues)
return {
"status": "passed" if not issues else "failed",
"skills_scanned": skill_count,
"issue_count": len(issues),
"issue_codes": {code: counts[code] for code in sorted(counts)},
}
def audit_skills(
root: str | Path = ".", manifest: str | Path | None = None
) -> dict[str, Any]:
"""审计 Skill 文档与 manifest,并返回 JSON 可序列化报告。"""
root_path = _resolve_root(root)
manifest_path = _resolve_manifest_path(root_path, manifest)
report: dict[str, Any] = {
"schema_version": SCHEMA_VERSION,
"root": str(root_path),
"skills_root": SKILLS_ROOT.as_posix(),
"manifest": {
"path": _relative_path(manifest_path, root_path),
"loaded": False,
},
"skills_scanned": 0,
"skills": [],
"issues": [],
}
if not root_path.exists():
report["issues"] = [
_issue("root_missing", "审计根目录不存在", path=".")
]
elif not root_path.is_dir():
report["issues"] = [
_issue("root_not_directory", "审计根路径不是目录", path=".")
]
else:
entries, issues = _scan_skill_directories(root_path)
issues.extend(_duplicate_name_issues(entries))
manifest_info, manifest_issues = _validate_manifest(
root_path, entries, manifest_path
)
issues.extend(manifest_issues)
report["manifest"] = manifest_info
report["skills"] = entries
report["skills_scanned"] = len(entries)
report["issues"] = issues
report["summary"] = _summarize(report["issues"], report["skills_scanned"])
report["issue_count"] = len(report["issues"])
report["ok"] = not report["issues"]
report["status"] = "passed" if report["ok"] else "failed"
return report
def human_summary(report: dict[str, Any]) -> str:
"""把结构化报告渲染为简洁的人读摘要。"""
status = "通过" if report["ok"] else "失败"
lines = [
f"Skill 静态审计:{status}",
f"扫描 Skill:{report['skills_scanned']} 个;问题:{report['issue_count']} 个",
]
issues = report["issues"]
if not issues:
lines.append("未发现 frontmatter、目录命名、空文件或开发测试污染问题。")
return "\n".join(lines)
lines.append("问题明细:")
for issue in issues:
location = str(issue.get("path", "<root>"))
if issue.get("line") is not None:
location += f":{issue['line']}"
lines.append(f"- {location} [{issue['code']}] {issue['message']}")
return "\n".join(lines)
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="只读扫描项目运行时 .claude/skills/*/SKILL.md 的静态卫生审计器。"
)
parser.add_argument(
"--root",
default=".",
help="项目根目录,默认为当前目录",
)
parser.add_argument(
"--manifest",
default=None,
help="Skill manifest 路径;相对路径按项目根目录解析,默认 harness/manifests/skills.json",
)
parser.add_argument(
"--json",
action="store_true",
help="输出结构化 JSON 报告",
)
parser.add_argument(
"--quiet",
action="store_true",
help="不输出人读摘要;与 --json 同用时仍输出 JSON",
)
return parser
def main(argv: Optional[Sequence[str]] = None) -> int:
args = _build_parser().parse_args(argv)
report = audit_skills(args.root, args.manifest)
if args.json:
print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
elif not args.quiet:
print(human_summary(report))
return 0 if report["ok"] else 1
if __name__ == "__main__":
sys.exit(main())