工程(索引): 索引只维护会提交内容;修复独占锁内收集自锁

- 维护索引按 git 视角枚举(已跟踪 + 未跟踪且未忽略):被 .gitignore 忽略的运行痕迹与
  过程性检查报告不再进入目录派生或影响索引门禁;非 git 场景退回原磁盘枚举
- conftest 验证闸门在 `验证锁 --执行` 启动的会话内不再取共享锁,修复 `make 索引生成`
  在独占锁内收集用例时的自锁(原 180s 超时失败 → 现 6s 通过)
- .gitignore 登记过程性检查报告与工具会话缓存(.zcode/):可复跑,不入历史
- 新增两条测试:忽略内容不参与目录派生、独占锁内收集不自锁
This commit is contained in:
zizi 2026-09-18 15:07:32 +08:00
parent 9712589bfe
commit 8a44364539
7 changed files with 222 additions and 18 deletions

4
.gitignore vendored
View File

@ -49,3 +49,7 @@ web/playwright-report/
# TypeScript增量编译缓存(已有跟踪项需另获索引操作授权后取消跟踪) # TypeScript增量编译缓存(已有跟踪项需另获索引操作授权后取消跟踪)
web/*.tsbuildinfo web/*.tsbuildinfo
# 工具会话缓存与过程性检查报告:可复跑,不入历史
docs/实现回顾/专项检查-报告-*.md
.zcode/

View File

@ -39,7 +39,7 @@ def 准备验证闸门(session: pytest.Session) -> None:
"""采集前持锁;外部环境层再核对源码身份,不一致立即失败,不等到几百个用例报假错。""" """采集前持锁;外部环境层再核对源码身份,不一致立即失败,不等到几百个用例报假错。"""
from contextlib import ExitStack from contextlib import ExitStack
from 验证锁 import 持有, 源码身份核对, 登记说明 from 验证锁 import 已在独占锁内, 持有, 源码身份核对, 登记说明
# 离线层按用例显式注入源码资源,不依赖已安装资源身份;只有外部环境层才需要同身份。 # 离线层按用例显式注入源码资源,不依赖已安装资源身份;只有外部环境层才需要同身份。
if session.config.getoption("--外部环境"): if session.config.getoption("--外部环境"):
@ -54,6 +54,9 @@ def 准备验证闸门(session: pytest.Session) -> None:
说明 = f"pytest {' '.join(session.config.args) or '全部'}(pid {os.getpid()})" 说明 = f"pytest {' '.join(session.config.args) or '全部'}(pid {os.getpid()})"
登记说明(说明) 登记说明(说明)
# 由 `验证锁 --执行` 启动时父进程已持独占写锁,再取共享锁会自锁;
# `make 索引生成` 就在锁内收集用例,走的正是这条路径。
if not 已在独占锁内():
_验证闸门["栈"].enter_context(持有(说明)) _验证闸门["栈"].enter_context(持有(说明))

View File

@ -8,6 +8,7 @@
from __future__ import annotations from __future__ import annotations
import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
@ -15,7 +16,7 @@ import pytest
仓库根 = Path(__file__).resolve().parents[2] 仓库根 = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(仓库根 / "工具")) sys.path.insert(0, str(仓库根 / "工具"))
from 维护索引 import 检查技能目录 # noqa: E402 from 维护索引 import 写入目录, 检查技能目录, 检查派生目录 # noqa: E402
def _写能力样本(根: Path, 相对: str, 身份: str, 名称: str) -> Path: def _写能力样本(根: Path, 相对: str, 身份: str, 名称: str) -> Path:
@ -155,6 +156,35 @@ def test_skill_directory_without_skill_file_fails__e8450b(tmp_path: Path) -> Non
assert any("缺少 SKILL.md" in p for p in 问题), 问题 assert any("缺少 SKILL.md" in p for p in 问题), 问题
@pytest.mark.case_id(
"TC-7b1e4d9c35a2",
environment="离线,临时 git 工作树",
when="读取或生成登记资源及导航。",
contract="docs/系统架构/新版设计/编排与能力资源.md",
)
def test_索引只覆盖会提交内容而忽略运行痕迹__7b1e4d(tmp_path: Path) -> None:
"""被 .gitignore 忽略的运行痕迹不进入目录派生,也不影响索引一致性。"""
根 = tmp_path
subprocess.run(["git", "init", "-q"], cwd=根, check=True)
(根 / ".gitignore").write_text("docs/噪音/\n", encoding="utf-8")
(根 / "docs").mkdir()
(根 / "docs" / "目录.md").write_text(
"| 名称 | 相对地址 | 内容描述 | 使用场景 | 使用要求 |\n"
"|------|----------|----------|----------|----------|\n",
encoding="utf-8",
)
(根 / "docs" / "真实.md").write_text("# 真实\n\n正文。\n", encoding="utf-8")
(根 / "docs" / "噪音").mkdir()
(根 / "docs" / "噪音" / "跑痕.json").write_text("{}\n", encoding="utf-8")
写入目录(根)
文本 = (根 / "docs" / "目录.md").read_text(encoding="utf-8")
assert (根 / "docs" / "噪音" / "跑痕.json").is_file() # 痕迹在磁盘上,只是不参与派生
assert "真实.md" in 文本
assert "噪音" not in 文本
assert 检查派生目录(根) == []
def _角色字节(身份: str, 名称: str, 额外: str = "") -> bytes: def _角色字节(身份: str, 名称: str, 额外: str = "") -> bytes:
return ( return (
f"---\nid: {身份}\nname: {名称}\ncategory: role\n" f"---\nid: {身份}\nname: {名称}\ncategory: role\n"

View File

@ -255,3 +255,40 @@ def test_zero_exit_without_execution_evidence_fails_closed__50f80b(项目: 临
码2, 结果2 = 项目.运行("test_跳过.py") 码2, 结果2 = 项目.运行("test_跳过.py")
assert 码2 == 5, (码2, 结果2["stdout"]) assert 码2 == 5, (码2, 结果2["stdout"])
assert "零实际执行" in 结果2["stderr"] assert "零实际执行" in 结果2["stderr"]
@pytest.mark.case_id(
"TC-3c9a17d84e60",
environment="离线,受限子进程和临时测试项目",
when="收集并执行选定用例,模拟超时、缺依赖或零执行。",
contract=".agent/rules/测试隔离.md",
)
def test_独占锁内启动的收集会话不自锁__3c9a17(项目: 临时项目, tmp_path: Path) -> None:
"""`验证锁 --执行` 已持独占锁时,子 pytest 会话不再取共享锁(否则自锁)。"""
项目.写("tests/单元/test_样本.py", "def test_样本__c10001():\n assert True\n")
环境变量 = {**_清理环境(), "MUSE_验证锁": str(tmp_path / "验证锁.lock")}
工具目录 = str(项目.根 / "工具")
环境变量["PYTHONPATH"] = 工具目录 + (
(":" + 环境变量["PYTHONPATH"]) if 环境变量.get("PYTHONPATH") else ""
)
进程 = subprocess.run(
[
sys.executable,
str(项目.根 / "工具/验证锁.py"),
"--执行",
sys.executable,
"-m",
"pytest",
"--collect-only",
"-q",
"-p",
"no:cacheprovider",
],
cwd=项目.根,
capture_output=True,
text=True,
timeout=120,
env=环境变量,
)
assert 进程.returncode == 0, (进程.stdout, 进程.stderr)
assert "test_样本" in 进程.stdout

View File

@ -30238,6 +30238,28 @@
], ],
"markers": [] "markers": []
}, },
{
"case_id": "TC-3c9a17d84e60",
"environment": "离线,受限子进程和临时测试项目",
"when": "收集并执行选定用例,模拟超时、缺依赖或零执行。",
"contract": ".agent/rules/测试隔离.md",
"file": "tests/架构/test_测试依赖与执行证据.py",
"symbol": "test_独占锁内启动的收集会话不自锁__3c9a17",
"parameter_ids": [],
"node_ids": [
"tests/架构/test_测试依赖与执行证据.py::test_独占锁内启动的收集会话不自锁__3c9a17"
],
"fixtures": [
"request",
"tmp_path",
"tmp_path_factory",
"测试资源接缝",
"源码资源",
"离线防护",
"项目"
],
"markers": []
},
{ {
"case_id": "TC-3d92e0528232", "case_id": "TC-3d92e0528232",
"environment": "默认离线", "environment": "默认离线",
@ -32415,6 +32437,27 @@
], ],
"markers": [] "markers": []
}, },
{
"case_id": "TC-7b1e4d9c35a2",
"environment": "离线,临时 git 工作树",
"when": "读取或生成登记资源及导航。",
"contract": "docs/系统架构/新版设计/编排与能力资源.md",
"file": "tests/架构/test_技能与文档索引.py",
"symbol": "test_索引只覆盖会提交内容而忽略运行痕迹__7b1e4d",
"parameter_ids": [],
"node_ids": [
"tests/架构/test_技能与文档索引.py::test_索引只覆盖会提交内容而忽略运行痕迹__7b1e4d"
],
"fixtures": [
"request",
"tmp_path",
"tmp_path_factory",
"测试资源接缝",
"源码资源",
"离线防护"
],
"markers": []
},
{ {
"case_id": "TC-7c28d3e5268e", "case_id": "TC-7c28d3e5268e",
"environment": "默认离线", "environment": "默认离线",

View File

@ -37,6 +37,8 @@ _忽略目录片段 = frozenset(
# 新版用例目录:旧测试树(tests/skills、tests/adapters、tests/architecture 英文目录等) # 新版用例目录:旧测试树(tests/skills、tests/adapters、tests/architecture 英文目录等)
# 由旧环境运行,不进入新版用例身份检查 # 由旧环境运行,不进入新版用例身份检查
_新版用例目录 = ("单元", "契约", "集成", "架构", "迁移", "端到端", "真实调用") _新版用例目录 = ("单元", "契约", "集成", "架构", "迁移", "端到端", "真实调用")
# 按 git 工作树顶层缓存「会提交内容」,同一轮多次遍历不重复询问 git。
_仓库缓存: dict[Path, frozenset[Path] | None] = {}
def _在忽略目录(路径: Path, 根: Path) -> bool: def _在忽略目录(路径: Path, 根: Path) -> bool:
@ -47,8 +49,90 @@ def _在忽略目录(路径: Path, 根: Path) -> bool:
return any(片段 in 相对.parts for 片段 in _忽略目录片段) return any(片段 in 相对.parts for 片段 in _忽略目录片段)
def _仓库顶层(根: Path) -> Path | None:
"""根所在 git 工作树的顶层目录;不在工作树内时返回 None。"""
try:
输出 = subprocess.run(
["git", "-C", str(根), "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
timeout=30,
check=True,
).stdout.strip()
except (OSError, subprocess.SubprocessError):
return None
return Path(输出).resolve() if 输出 else None
def _会提交内容(根: Path) -> frozenset[Path] | None:
"""根下会提交的路径(文件与其祖先目录);非 git 场景返回 None,调用方退回磁盘枚举。
索引只维护会提交的内容:被 .gitignore 忽略的运行痕迹、检查报告与缓存既不
进入派生,也不承担扫描成本。
"""
顶层 = _仓库顶层(根)
if 顶层 is None:
return None
if 顶层 not in _仓库缓存:
try:
输出 = subprocess.run(
[
"git",
"-C",
str(顶层),
"ls-files",
"-z",
"--cached",
"--others",
"--exclude-standard",
],
capture_output=True,
timeout=60,
check=True,
).stdout
except (OSError, subprocess.SubprocessError):
_仓库缓存[顶层] = None
return None
文件集: set[Path] = set()
for 名 in 输出.decode("utf-8", "surrogateescape").split("\0"):
路径 = 顶层 / 名
if 名 and 路径.is_file():
文件集.add(路径)
目录集 = {
祖先 for p in 文件集 for 祖先 in p.parents if 祖先 == 顶层 or 顶层 in 祖先.parents
}
_仓库缓存[顶层] = frozenset(文件集 | 目录集)
内容 = _仓库缓存[顶层]
if 内容 is None:
return None
本根 = 根.resolve()
return frozenset(p for p in 内容 if p == 本根 or 本根 in p.parents)
def _会提交条目(根: Path, 目录: Path) -> list[Path] | None:
"""目录下会提交的一级条目(文件与子目录);非 git 场景返回 None。"""
内容 = _会提交内容(根)
if 内容 is None:
return None
return sorted(p for p in 内容 if p.parent == 目录)
def _会提交子目录(根: Path, 目录: Path) -> list[Path]:
"""目录下会提交的一级子目录;非 git 场景退回磁盘枚举。"""
条目 = _会提交条目(根, 目录)
if 条目 is None:
return sorted(p for p in 目录.iterdir() if p.is_dir())
return [p for p in 条目 if p.is_dir()]
def 遍历文件(根: Path, 模式: str): def 遍历文件(根: Path, 模式: str):
"""在进入目录前剪枝,忽略缓存和历史树不承担扫描成本。""" """只遍历会提交的内容;忽略的运行痕迹不参与派生,也不承担扫描成本。"""
内容 = _会提交内容(根)
if 内容 is not None:
for 文件 in sorted(内容):
if 文件.is_file() and fnmatch(文件.name, 模式) and not _在忽略目录(文件, 根):
yield 文件
return
for 当前, 目录名, 文件名 in os.walk(根, followlinks=False): for 当前, 目录名, 文件名 in os.walk(根, followlinks=False):
目录名[:] = sorted(名 for 名 in 目录名 if 名 not in _忽略目录片段) 目录名[:] = sorted(名 for 名 in 目录名 if 名 not in _忽略目录片段)
for 名 in sorted(文件名): for 名 in sorted(文件名):
@ -102,8 +186,8 @@ def 检查技能目录(根: Path) -> list[str]:
return ["技能目录不存在:.agent/skills(缺失即失败,不静默跳过)"] return ["技能目录不存在:.agent/skills(缺失即失败,不静默跳过)"]
问题: list[str] = [] 问题: list[str] = []
已见: dict[str, str] = {} 已见: dict[str, str] = {}
for 分组 in sorted(p for p in 技能根.iterdir() if p.is_dir()): for 分组 in _会提交子目录(根, 技能根):
for 技能目录 in sorted(p for p in 分组.iterdir() if p.is_dir()): for 技能目录 in _会提交子目录(根, 分组):
名称 = 技能目录.name 名称 = 技能目录.name
相对 = str(技能目录.relative_to(根)) 相对 = str(技能目录.relative_to(根))
技能文件 = 技能目录 / "SKILL.md" 技能文件 = 技能目录 / "SKILL.md"
@ -189,7 +273,7 @@ def _目录文件(根: Path) -> list[Path]:
} }
技能根 = 根 / ".agent/skills" 技能根 = 根 / ".agent/skills"
if 技能根.is_dir(): if 技能根.is_dir():
文件组.update(p / "目录.md" for p in 技能根.iterdir() if p.is_dir()) 文件组.update(p / "目录.md" for p in _会提交子目录(根, 技能根))
return sorted(文件组) return sorted(文件组)
@ -251,16 +335,15 @@ def 派生目录(根: Path) -> dict[Path, str]:
原文 = 索引.read_text(encoding="utf-8") if 索引.is_file() else "" 原文 = 索引.read_text(encoding="utf-8") if 索引.is_file() else ""
旧行 = _表格行(原文) 旧行 = _表格行(原文)
分组 = 索引.parent.parent == 技能根 分组 = 索引.parent.parent == 技能根
条目 = _会提交条目(根, 索引.parent)
if 条目 is None:
条目 = sorted(索引.parent.iterdir())
if 分组: if 分组:
文件组 = [ 文件组 = [p / "SKILL.md" for p in 条目 if p.is_dir() and (p / "SKILL.md").is_file()]
p / "SKILL.md"
for p in sorted(索引.parent.iterdir())
if p.is_dir() and (p / "SKILL.md").is_file()
]
else: else:
文件组 = [ 文件组 = [
(p / "目录.md" if (p / "目录.md").is_file() else p) (p / "目录.md" if (p / "目录.md").is_file() else p)
for p in sorted(索引.parent.iterdir()) for p in 条目
if p != 索引 and not p.name.startswith(".") and p.name != "__pycache__" if p != 索引 and not p.name.startswith(".") and p.name != "__pycache__"
] ]
新行 = {} 新行 = {}
@ -352,7 +435,7 @@ def 检查技能索引(根: Path) -> list[str]:
if not 技能根.is_dir(): if not 技能根.is_dir():
return ["技能目录不存在:.agent/skills"] return ["技能目录不存在:.agent/skills"]
问题 = [] 问题 = []
for 分组 in sorted(p for p in 技能根.iterdir() if p.is_dir()): for 分组 in _会提交子目录(根, 技能根):
索引 = 分组 / "目录.md" 索引 = 分组 / "目录.md"
if not 索引.is_file(): if not 索引.is_file():
问题.append(f"分组缺目录:{分组.name}") 问题.append(f"分组缺目录:{分组.name}")
@ -363,9 +446,7 @@ def 检查技能索引(根: Path) -> list[str]:
if 地址.endswith("/SKILL.md") if 地址.endswith("/SKILL.md")
] ]
磁盘名 = { 磁盘名 = {
f"{p.name}/SKILL.md" f"{p.name}/SKILL.md" for p in _会提交子目录(根, 分组) if (p / "SKILL.md").is_file()
for p in 分组.iterdir()
if p.is_dir() and (p / "SKILL.md").is_file()
} }
if set(索引名) != 磁盘名: if set(索引名) != 磁盘名:
问题.append( 问题.append(

View File

@ -25,6 +25,12 @@ from typing import Any
_持锁标记 = "MUSE_写入锁已持有" _持锁标记 = "MUSE_写入锁已持有"
def 已在独占锁内() -> bool:
"""本进程是否由 `--执行` 启动:父进程已持独占写锁,不能再取共享锁。"""
return os.environ.get(_持锁标记) == "1"
def 正在验证() -> str | None: def 正在验证() -> str | None:
"""返回当前验证说明;没有验证在跑时返回 None。 """返回当前验证说明;没有验证在跑时返回 None。
@ -32,7 +38,7 @@ def 正在验证() -> str | None:
不能把自己判成「验证正在运行」;用环境变量把这一事实传给子进程。 不能把自己判成「验证正在运行」;用环境变量把这一事实传给子进程。
""" """
if os.environ.get(_持锁标记) == "1": if 已在独占锁内():
return None return None
if not 锁路径.exists(): if not 锁路径.exists():
return None return None