工程(索引): 索引只维护会提交内容;修复独占锁内收集自锁
- 维护索引按 git 视角枚举(已跟踪 + 未跟踪且未忽略):被 .gitignore 忽略的运行痕迹与 过程性检查报告不再进入目录派生或影响索引门禁;非 git 场景退回原磁盘枚举 - conftest 验证闸门在 `验证锁 --执行` 启动的会话内不再取共享锁,修复 `make 索引生成` 在独占锁内收集用例时的自锁(原 180s 超时失败 → 现 6s 通过) - .gitignore 登记过程性检查报告与工具会话缓存(.zcode/):可复跑,不入历史 - 新增两条测试:忽略内容不参与目录派生、独占锁内收集不自锁
This commit is contained in:
parent
9712589bfe
commit
8a44364539
4
.gitignore
vendored
4
.gitignore
vendored
@ -49,3 +49,7 @@ web/playwright-report/
|
||||
|
||||
# TypeScript增量编译缓存(已有跟踪项需另获索引操作授权后取消跟踪)
|
||||
web/*.tsbuildinfo
|
||||
|
||||
# 工具会话缓存与过程性检查报告:可复跑,不入历史
|
||||
docs/实现回顾/专项检查-报告-*.md
|
||||
.zcode/
|
||||
|
||||
@ -39,7 +39,7 @@ def 准备验证闸门(session: pytest.Session) -> None:
|
||||
"""采集前持锁;外部环境层再核对源码身份,不一致立即失败,不等到几百个用例报假错。"""
|
||||
from contextlib import ExitStack
|
||||
|
||||
from 验证锁 import 持有, 源码身份核对, 登记说明
|
||||
from 验证锁 import 已在独占锁内, 持有, 源码身份核对, 登记说明
|
||||
|
||||
# 离线层按用例显式注入源码资源,不依赖已安装资源身份;只有外部环境层才需要同身份。
|
||||
if session.config.getoption("--外部环境"):
|
||||
@ -54,7 +54,10 @@ def 准备验证闸门(session: pytest.Session) -> None:
|
||||
|
||||
说明 = f"pytest {' '.join(session.config.args) or '全部'}(pid {os.getpid()})"
|
||||
登记说明(说明)
|
||||
_验证闸门["栈"].enter_context(持有(说明))
|
||||
# 由 `验证锁 --执行` 启动时父进程已持独占写锁,再取共享锁会自锁;
|
||||
# `make 索引生成` 就在锁内收集用例,走的正是这条路径。
|
||||
if not 已在独占锁内():
|
||||
_验证闸门["栈"].enter_context(持有(说明))
|
||||
|
||||
|
||||
def pytest_addoption(parser: pytest.Parser) -> None:
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@ -15,7 +16,7 @@ import pytest
|
||||
|
||||
仓库根 = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(仓库根 / "工具"))
|
||||
from 维护索引 import 检查技能目录 # noqa: E402
|
||||
from 维护索引 import 写入目录, 检查技能目录, 检查派生目录 # noqa: E402
|
||||
|
||||
|
||||
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 问题), 问题
|
||||
|
||||
|
||||
@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:
|
||||
return (
|
||||
f"---\nid: {身份}\nname: {名称}\ncategory: role\n"
|
||||
|
||||
@ -255,3 +255,40 @@ def test_zero_exit_without_execution_evidence_fails_closed__50f80b(项目: 临
|
||||
码2, 结果2 = 项目.运行("test_跳过.py")
|
||||
assert 码2 == 5, (码2, 结果2["stdout"])
|
||||
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
|
||||
|
||||
@ -30238,6 +30238,28 @@
|
||||
],
|
||||
"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",
|
||||
"environment": "默认离线",
|
||||
@ -32415,6 +32437,27 @@
|
||||
],
|
||||
"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",
|
||||
"environment": "默认离线",
|
||||
|
||||
109
工具/维护索引.py
109
工具/维护索引.py
@ -37,6 +37,8 @@ _忽略目录片段 = frozenset(
|
||||
# 新版用例目录:旧测试树(tests/skills、tests/adapters、tests/architecture 英文目录等)
|
||||
# 由旧环境运行,不进入新版用例身份检查
|
||||
_新版用例目录 = ("单元", "契约", "集成", "架构", "迁移", "端到端", "真实调用")
|
||||
# 按 git 工作树顶层缓存「会提交内容」,同一轮多次遍历不重复询问 git。
|
||||
_仓库缓存: dict[Path, frozenset[Path] | None] = {}
|
||||
|
||||
|
||||
def _在忽略目录(路径: Path, 根: Path) -> bool:
|
||||
@ -47,8 +49,90 @@ def _在忽略目录(路径: Path, 根: Path) -> bool:
|
||||
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):
|
||||
"""在进入目录前剪枝,忽略缓存和历史树不承担扫描成本。"""
|
||||
"""只遍历会提交的内容;忽略的运行痕迹不参与派生,也不承担扫描成本。"""
|
||||
内容 = _会提交内容(根)
|
||||
if 内容 is not None:
|
||||
for 文件 in sorted(内容):
|
||||
if 文件.is_file() and fnmatch(文件.name, 模式) and not _在忽略目录(文件, 根):
|
||||
yield 文件
|
||||
return
|
||||
for 当前, 目录名, 文件名 in os.walk(根, followlinks=False):
|
||||
目录名[:] = sorted(名 for 名 in 目录名 if 名 not in _忽略目录片段)
|
||||
for 名 in sorted(文件名):
|
||||
@ -102,8 +186,8 @@ def 检查技能目录(根: Path) -> list[str]:
|
||||
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()):
|
||||
for 分组 in _会提交子目录(根, 技能根):
|
||||
for 技能目录 in _会提交子目录(根, 分组):
|
||||
名称 = 技能目录.name
|
||||
相对 = str(技能目录.relative_to(根))
|
||||
技能文件 = 技能目录 / "SKILL.md"
|
||||
@ -189,7 +273,7 @@ def _目录文件(根: Path) -> list[Path]:
|
||||
}
|
||||
技能根 = 根 / ".agent/skills"
|
||||
if 技能根.is_dir():
|
||||
文件组.update(p / "目录.md" for p in 技能根.iterdir() if p.is_dir())
|
||||
文件组.update(p / "目录.md" for p in _会提交子目录(根, 技能根))
|
||||
return sorted(文件组)
|
||||
|
||||
|
||||
@ -251,16 +335,15 @@ def 派生目录(根: Path) -> dict[Path, str]:
|
||||
原文 = 索引.read_text(encoding="utf-8") if 索引.is_file() else ""
|
||||
旧行 = _表格行(原文)
|
||||
分组 = 索引.parent.parent == 技能根
|
||||
条目 = _会提交条目(根, 索引.parent)
|
||||
if 条目 is None:
|
||||
条目 = sorted(索引.parent.iterdir())
|
||||
if 分组:
|
||||
文件组 = [
|
||||
p / "SKILL.md"
|
||||
for p in sorted(索引.parent.iterdir())
|
||||
if p.is_dir() and (p / "SKILL.md").is_file()
|
||||
]
|
||||
文件组 = [p / "SKILL.md" for p in 条目 if p.is_dir() and (p / "SKILL.md").is_file()]
|
||||
else:
|
||||
文件组 = [
|
||||
(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__"
|
||||
]
|
||||
新行 = {}
|
||||
@ -352,7 +435,7 @@ def 检查技能索引(根: Path) -> list[str]:
|
||||
if not 技能根.is_dir():
|
||||
return ["技能目录不存在:.agent/skills"]
|
||||
问题 = []
|
||||
for 分组 in sorted(p for p in 技能根.iterdir() if p.is_dir()):
|
||||
for 分组 in _会提交子目录(根, 技能根):
|
||||
索引 = 分组 / "目录.md"
|
||||
if not 索引.is_file():
|
||||
问题.append(f"分组缺目录:{分组.name}")
|
||||
@ -363,9 +446,7 @@ def 检查技能索引(根: Path) -> list[str]:
|
||||
if 地址.endswith("/SKILL.md")
|
||||
]
|
||||
磁盘名 = {
|
||||
f"{p.name}/SKILL.md"
|
||||
for p in 分组.iterdir()
|
||||
if p.is_dir() and (p / "SKILL.md").is_file()
|
||||
f"{p.name}/SKILL.md" for p in _会提交子目录(根, 分组) if (p / "SKILL.md").is_file()
|
||||
}
|
||||
if set(索引名) != 磁盘名:
|
||||
问题.append(
|
||||
|
||||
@ -25,6 +25,12 @@ from typing import Any
|
||||
_持锁标记 = "MUSE_写入锁已持有"
|
||||
|
||||
|
||||
def 已在独占锁内() -> bool:
|
||||
"""本进程是否由 `--执行` 启动:父进程已持独占写锁,不能再取共享锁。"""
|
||||
|
||||
return os.environ.get(_持锁标记) == "1"
|
||||
|
||||
|
||||
def 正在验证() -> str | None:
|
||||
"""返回当前验证说明;没有验证在跑时返回 None。
|
||||
|
||||
@ -32,7 +38,7 @@ def 正在验证() -> str | None:
|
||||
不能把自己判成「验证正在运行」;用环境变量把这一事实传给子进程。
|
||||
"""
|
||||
|
||||
if os.environ.get(_持锁标记) == "1":
|
||||
if 已在独占锁内():
|
||||
return None
|
||||
if not 锁路径.exists():
|
||||
return None
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user