- 章后处理:事实提案命令身份加入类型、批内去重改为先校验后去重(REPEAT_OBJECT_TYPE 记跳过、不再静默丢重复);查询章后状态回传事实跳过;可按需发布/核对可登记任务与 config_not_declared 语义收紧 - 模型治理:推理字符数计量(ChatCompletions/Messages/执行合同)、探针在声明关闭推理却仍有推理时拒绝、计价支持缓存协议与未知模型兜底 - 运行态:资源包重建(发布身份 21d850f7…),三处配置同步,post-extract v5 代次 5 启用,服务 PID 96675(health/ready 通过) - 门禁:make 检查通过;离线 1008 passed;定点数据库 101 passed;最终完整备份 bkp-1f5591d8… 与空库恢复演练 verified(152 表/158 关系/迁移 45,演练库已 DROP) - 文档与证据:运行手册、功能覆盖、工作包清单 W30 证据、测试用例清单(17 条 execution_evidence 回填 + 历史死指针说明)、目标文件清单 - 旧实现与旧库直连材料退出(含明文凭据文件移除);新配置 配置/本机正式.toml、配置/本机维护.toml、配置/计费/、配置/运行配置/ 入库,均只含受控引用 - R2 执行证据在 .agents.local/改造/R2-20260909/(不入库)
326 lines
14 KiB
Python
326 lines
14 KiB
Python
"""索引生成、查重及检查。
|
||
|
||
工程阶段的生成或预检入口:
|
||
- 核对 Markdown 索引(目录.md)表内相对链接有效;
|
||
- 核对技能目录(.agent/skills/ 下分组)中每个技能有 SKILL.md,名称不重复;
|
||
- 核对测试符号与用例清单的 case 身份一致:仓库内每个 test_ 符号必须带
|
||
``__<case6>`` 后缀且能在清单定位;清单登记的目标符号必须在树上真实存在;
|
||
只有 print 没有 assert/raise 的用例在执行前即被标记(反假绿)。
|
||
|
||
边界:只报告差异与失败,不改变业务数据、不自行批准发布。
|
||
生成物与唯一输入一致;命令可独立重跑。所有检查函数接受显式根目录,
|
||
供测试在临时项目上验证同一语义。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import ast
|
||
import json
|
||
import re
|
||
import sys
|
||
import unicodedata
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
仓库根 = Path(__file__).resolve().parent.parent
|
||
_目录表行 = re.compile(r"^\|\s*([^|]+)\|\s*\[([^\]]+)\]\(([^)]+)\)\s*\|")
|
||
_用例后缀 = re.compile(r"__([0-9a-f]{6})$")
|
||
_忽略目录片段 = frozenset(
|
||
{".venv", ".venv-旧", ".git", "node_modules", "__pycache__", ".agents.local", "研究依据"}
|
||
)
|
||
# 新版用例目录:旧测试树(tests/skills、tests/adapters、tests/architecture 英文目录等)
|
||
# 由旧环境运行,不进入新版用例身份检查
|
||
_新版用例目录 = ("单元", "契约", "集成", "架构", "迁移", "端到端", "真实调用")
|
||
|
||
|
||
def _在忽略目录(路径: Path, 根: Path) -> bool:
|
||
try:
|
||
相对 = 路径.relative_to(根)
|
||
except ValueError:
|
||
return True
|
||
return any(片段 in 相对.parts for 片段 in _忽略目录片段)
|
||
|
||
|
||
def 检查目录链接(根: Path) -> list[str]:
|
||
"""核对仓库内 目录.md 的表格相对链接;返回问题列表。"""
|
||
问题: list[str] = []
|
||
for 目录文件 in 根.rglob("目录.md"):
|
||
if _在忽略目录(目录文件, 根):
|
||
continue
|
||
for 行号, 行 in enumerate(目录文件.read_text(encoding="utf-8").splitlines(), 1):
|
||
匹配 = _目录表行.match(行)
|
||
if not 匹配:
|
||
continue
|
||
地址 = 匹配.group(3).split("#")[0].strip()
|
||
if not 地址 or 地址.startswith(("http://", "https://")):
|
||
continue
|
||
目标 = (目录文件.parent / 地址).resolve()
|
||
if not 目标.exists():
|
||
问题.append(f"{目录文件.relative_to(根)}:{行号} 链接不存在:{地址}")
|
||
return 问题
|
||
|
||
|
||
def _读frontmatter名(文件: Path) -> str | None:
|
||
"""读 SKILL.md frontmatter 的 name 字段;无 frontmatter 返回 None。"""
|
||
文本 = 文件.read_text(encoding="utf-8")
|
||
if not 文本.startswith("---"):
|
||
return None
|
||
结束 = 文本.find("\n---", 3)
|
||
if 结束 < 0:
|
||
return None
|
||
for 行 in 文本[3:结束].splitlines():
|
||
if 行.startswith("name:"):
|
||
return 行[5:].strip()
|
||
return None
|
||
|
||
|
||
def _含中文(名称: str) -> bool:
|
||
return any("\u4e00" <= ch <= "\u9fff" for ch in 名称)
|
||
|
||
|
||
def 检查技能目录(根: Path) -> list[str]:
|
||
"""核对 .agent/skills 分组:每个技能有非空 SKILL.md、名称不重复、
|
||
frontmatter name 与目录一致、目录名全中文且规范化(NFC、无空白)。"""
|
||
技能根 = 根 / ".agent" / "skills"
|
||
if not 技能根.is_dir():
|
||
return ["技能目录不存在:.agent/skills(缺失即失败,不静默跳过)"]
|
||
问题: list[str] = []
|
||
已见: dict[str, str] = {}
|
||
for 分组 in sorted(p for p in 技能根.iterdir() if p.is_dir()):
|
||
for 技能目录 in sorted(p for p in 分组.iterdir() if p.is_dir()):
|
||
名称 = 技能目录.name
|
||
相对 = str(技能目录.relative_to(根))
|
||
技能文件 = 技能目录 / "SKILL.md"
|
||
if not 技能文件.is_file():
|
||
问题.append(f"技能缺少 SKILL.md:{相对}")
|
||
continue
|
||
正文 = 技能文件.read_text(encoding="utf-8").strip()
|
||
if not 正文 or 正文 == "---":
|
||
问题.append(f"SKILL.md 为空:{相对}")
|
||
continue
|
||
front名 = _读frontmatter名(技能文件)
|
||
if front名 is None:
|
||
问题.append(f"SKILL.md 缺少 frontmatter name:{相对}")
|
||
elif front名 != 名称:
|
||
问题.append(f"frontmatter name 与目录不一致:{相对}(name: {front名})")
|
||
if not _含中文(名称):
|
||
问题.append(f"技能名称必须全中文(政策 chinese_only):{相对}")
|
||
if 名称 != unicodedata.normalize("NFC", 名称):
|
||
问题.append(f"技能名称不是 NFC 规范形式:{相对}")
|
||
if any(ch.isspace() for ch in 名称):
|
||
问题.append(f"技能名称含空白:{相对}")
|
||
if 名称 in 已见:
|
||
问题.append(f"技能名称重复:{名称}({已见[名称]} 与 {相对})")
|
||
else:
|
||
已见[名称] = 相对
|
||
return 问题
|
||
|
||
|
||
def 解析测试符号(文件: Path) -> list[ast.FunctionDef]:
|
||
"""用 AST 提取 test_ 开头的函数与方法;不执行任何代码。"""
|
||
树 = ast.parse(文件.read_text(encoding="utf-8"))
|
||
return [
|
||
节点
|
||
for 节点 in ast.walk(树)
|
||
if isinstance(节点, ast.FunctionDef) and 节点.name.startswith("test_")
|
||
]
|
||
|
||
|
||
def _有判定语句(节点: ast.FunctionDef) -> bool:
|
||
"""函数体内是否有 assert、raise 或 pytest.fail/skip/xfail 调用。"""
|
||
for 子 in ast.walk(节点):
|
||
if isinstance(子, ast.Assert):
|
||
return True
|
||
if isinstance(子, ast.Raise):
|
||
return True
|
||
if isinstance(子, ast.Call) and isinstance(子.func, ast.Attribute):
|
||
if 子.func.attr in {"fail", "skip", "xfail", "raises"}:
|
||
return True
|
||
return False
|
||
|
||
|
||
def 检查用例身份(
|
||
根: Path,
|
||
清单路径: Path | None = None,
|
||
*,
|
||
核对缺失目标: bool = False,
|
||
全树扫描: bool = False,
|
||
) -> list[str]:
|
||
"""核对测试符号与用例清单双向一致:
|
||
1. 新版用例目录内符号按完整文件、函数或前端用例名绑定清单;
|
||
2. 用例体必须包含可判定语句(assert/raise/fail/skip/xfail);
|
||
3. 核对缺失目标=True 时,清单登记的 (file, symbol) 必须在树上真实存在
|
||
已实施条目始终反向核对;过渡期只容许未实施目标暂缺。
|
||
"""
|
||
if 清单路径 is None:
|
||
清单路径 = 根 / "docs" / "系统架构" / "新版设计" / "验证设计" / "测试用例清单.json"
|
||
问题: list[str] = []
|
||
if not 清单路径.is_file():
|
||
return [f"用例清单不存在:{清单路径}"]
|
||
清单: dict[str, Any] = json.loads(清单路径.read_text(encoding="utf-8"))
|
||
|
||
分配路径 = 根 / "docs/系统架构/新版设计/改造计划/工作包清单.json"
|
||
if 分配路径.is_file():
|
||
目标表 = {
|
||
t["case_id"]: t
|
||
for t in [t for r in 清单.get("cases", []) for t in r.get("target_cases", [])]
|
||
+ 清单.get("new_cases", [])
|
||
}
|
||
分配表 = json.loads(分配路径.read_text(encoding="utf-8"))
|
||
for a in 分配表.get("case_assignments", []):
|
||
id_ = a.get("case_id")
|
||
if not id_:
|
||
continue
|
||
target = 目标表.get(id_)
|
||
if target is None:
|
||
问题.append(f"分配表引用未登记用例:{id_}")
|
||
elif any(
|
||
a.get(k) != target.get(v, "")
|
||
for k, v in (
|
||
("target_file", "file"),
|
||
("target_symbol", "symbol"),
|
||
("environment", "environment"),
|
||
)
|
||
):
|
||
问题.append(f"分配表用例定位与权威清单不符:{id_}")
|
||
|
||
登记符号: dict[str, str] = {}
|
||
已实施目标: set[str] = set()
|
||
for 用例 in 清单.get("cases", []):
|
||
for 目标 in 用例.get("target_cases") or []:
|
||
case_id = str(目标.get("case_id", ""))
|
||
文件 = str(目标.get("file", ""))
|
||
符号 = str(目标.get("symbol", ""))
|
||
if 文件 and 符号:
|
||
登记符号[f"{文件}::{符号}"] = case_id
|
||
if 目标.get("verification_state") in {"通过", "已实现未执行", "失败"}:
|
||
已实施目标.add(f"{文件}::{符号}")
|
||
for 新例 in 清单.get("new_cases", []):
|
||
case_id = str(新例.get("case_id", ""))
|
||
符号 = str(新例.get("symbol", ""))
|
||
文件 = str(新例.get("file", ""))
|
||
if 文件 and 符号:
|
||
登记符号[f"{文件}::{符号}"] = case_id
|
||
if 新例.get("verification_state") in {"通过", "已实现未执行", "失败"}:
|
||
已实施目标.add(f"{文件}::{符号}")
|
||
|
||
树上符号: set[str] = set()
|
||
候选 = sorted(根.glob("**/test_*.py")) if 全树扫描 else sorted(根.glob("tests/**/test_*.py"))
|
||
for 测试文件 in 候选:
|
||
if _在忽略目录(测试文件, 根):
|
||
continue
|
||
if not 全树扫描:
|
||
# 只检查新版用例目录;旧测试树由旧环境运行
|
||
try:
|
||
相对路径 = 测试文件.relative_to(根 / "tests")
|
||
except ValueError:
|
||
continue
|
||
if not 相对路径.parts or 相对路径.parts[0] not in _新版用例目录:
|
||
continue
|
||
try:
|
||
相对 = 测试文件.relative_to(根).as_posix()
|
||
except ValueError:
|
||
continue
|
||
for 节点 in 解析测试符号(测试文件):
|
||
树上符号.add(f"{相对}::{节点.name}")
|
||
匹配 = _用例后缀.search(节点.name)
|
||
if not 匹配:
|
||
问题.append(f"测试符号缺少用例后缀:{相对}::{节点.name}")
|
||
if f"{相对}::{节点.name}" not in 登记符号:
|
||
问题.append(f"测试文件与符号未在用例清单登记:{相对}::{节点.name}")
|
||
if not _有判定语句(节点):
|
||
问题.append(f"用例无可判定语句(print 不能代替断言):{相对}::{节点.name}")
|
||
|
||
前端测试 = sorted(
|
||
[*(根 / "web/tests").rglob("*.test.ts*"), *(根 / "web/tests").rglob("*.spec.ts*")]
|
||
)
|
||
if 前端测试:
|
||
import subprocess
|
||
|
||
# TypeScript AST 只解析静态用例声明,不执行测试或加载其运行依赖。
|
||
脚本 = """
|
||
import ts from 'typescript';
|
||
import fs from 'node:fs';
|
||
const files = JSON.parse(fs.readFileSync(0, 'utf8'));
|
||
const found = [];
|
||
for (const file of files) {
|
||
const source = ts.createSourceFile(file, fs.readFileSync(file, 'utf8'),
|
||
ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
||
function visit(node) {
|
||
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)
|
||
&& ['it', 'test'].includes(node.expression.text)
|
||
&& node.arguments[0] && ts.isStringLiteral(node.arguments[0]))
|
||
found.push([file, node.arguments[0].text]);
|
||
ts.forEachChild(node, visit);
|
||
}
|
||
visit(source);
|
||
}
|
||
process.stdout.write(JSON.stringify(found));
|
||
"""
|
||
try:
|
||
结果 = subprocess.run(
|
||
["node", "--input-type=module", "-e", 脚本],
|
||
cwd=根 / "web",
|
||
input=json.dumps([str(p) for p in 前端测试]),
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=20,
|
||
check=True,
|
||
)
|
||
for 文件, 符号 in json.loads(结果.stdout):
|
||
键 = f"{Path(文件).relative_to(根).as_posix()}::{符号}"
|
||
树上符号.add(键)
|
||
if 键 not in 登记符号:
|
||
问题.append(f"前端用例未在清单登记:{键}")
|
||
except (OSError, ValueError, subprocess.SubprocessError):
|
||
问题.append("前端用例解析失败;需要已锁定的 Node 与 TypeScript 工具链")
|
||
|
||
for 键, case_id in sorted(登记符号.items()):
|
||
if 核对缺失目标 or 键 in 已实施目标:
|
||
if 键 not in 树上符号:
|
||
问题.append(f"用例清单登记的目标不存在:{键}({case_id})")
|
||
return 问题
|
||
|
||
|
||
def 检查文件定位(根: Path) -> list[str]:
|
||
"""设计清单的文件定位必须恰好命中一个定义,不把目录树文字当作锚点。"""
|
||
设计根 = 根 / "docs/系统架构/新版设计"
|
||
清单 = 设计根 / "目标文件清单.json"
|
||
if not 清单.is_file():
|
||
return []
|
||
问题, 已读 = [], {}
|
||
for 项 in json.loads(清单.read_text(encoding="utf-8"))["files"]:
|
||
文件名, 分隔, 锚点 = 项.get("design_location", "").partition("#")
|
||
文件 = (设计根 / 文件名).resolve()
|
||
if not 分隔 or not 文件.is_relative_to(设计根.resolve()) or not 文件.is_file():
|
||
问题.append(f"文件设计定位不存在:{项['path']}")
|
||
continue
|
||
if 文件 not in 已读:
|
||
已读[文件] = 文件.read_text(encoding="utf-8")
|
||
次数 = len(re.findall(r'<a\s+id=["\x27]' + re.escape(锚点) + r'["\x27]', 已读[文件]))
|
||
if 次数 != 1:
|
||
问题.append(f"文件设计锚点应恰一,实际{次数}:{项['path']}({锚点})")
|
||
return 问题
|
||
|
||
|
||
def 主() -> int:
|
||
解析器 = argparse.ArgumentParser(description="索引生成、查重及检查")
|
||
解析器.add_argument("--写入", action="store_true", help="执行索引写入(当前为幂等检查)")
|
||
解析器.add_argument("--检查", action="store_true", help="只检查并报告差异")
|
||
参数 = 解析器.parse_args()
|
||
if 参数.写入:
|
||
print("索引写入:当前无可写生成物,执行幂等检查")
|
||
|
||
问题 = 检查目录链接(仓库根) + 检查技能目录(仓库根) + 检查用例身份(仓库根) + 检查文件定位(仓库根)
|
||
if 问题:
|
||
for 条 in 问题:
|
||
print(f"索引问题:{条}", file=sys.stderr)
|
||
return 1
|
||
print("索引检查通过:目录链接、技能目录、用例身份及文件定位均一致")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(主())
|