From 8a44364539bcb1b6c6e56a6af4e627df34178528 Mon Sep 17 00:00:00 2001 From: zizi Date: Fri, 18 Sep 2026 15:07:32 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B7=A5=E7=A8=8B(=E7=B4=A2=E5=BC=95):=20?= =?UTF-8?q?=E7=B4=A2=E5=BC=95=E5=8F=AA=E7=BB=B4=E6=8A=A4=E4=BC=9A=E6=8F=90?= =?UTF-8?q?=E4=BA=A4=E5=86=85=E5=AE=B9=EF=BC=9B=E4=BF=AE=E5=A4=8D=E7=8B=AC?= =?UTF-8?q?=E5=8D=A0=E9=94=81=E5=86=85=E6=94=B6=E9=9B=86=E8=87=AA=E9=94=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 维护索引按 git 视角枚举(已跟踪 + 未跟踪且未忽略):被 .gitignore 忽略的运行痕迹与 过程性检查报告不再进入目录派生或影响索引门禁;非 git 场景退回原磁盘枚举 - conftest 验证闸门在 `验证锁 --执行` 启动的会话内不再取共享锁,修复 `make 索引生成` 在独占锁内收集用例时的自锁(原 180s 超时失败 → 现 6s 通过) - .gitignore 登记过程性检查报告与工具会话缓存(.zcode/):可复跑,不入历史 - 新增两条测试:忽略内容不参与目录派生、独占锁内收集不自锁 --- .gitignore | 4 + tests/conftest.py | 7 +- tests/架构/test_技能与文档索引.py | 32 +++++++- tests/架构/test_测试依赖与执行证据.py | 37 +++++++++ tests/用例清单.json | 43 ++++++++++ 工具/维护索引.py | 109 ++++++++++++++++++++++---- 工具/验证锁.py | 8 +- 7 files changed, 222 insertions(+), 18 deletions(-) diff --git a/.gitignore b/.gitignore index 6971cf8..6c46d1f 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,7 @@ web/playwright-report/ # TypeScript增量编译缓存(已有跟踪项需另获索引操作授权后取消跟踪) web/*.tsbuildinfo + +# 工具会话缓存与过程性检查报告:可复跑,不入历史 +docs/实现回顾/专项检查-报告-*.md +.zcode/ diff --git a/tests/conftest.py b/tests/conftest.py index eb40e21..a01e7bd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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: diff --git a/tests/架构/test_技能与文档索引.py b/tests/架构/test_技能与文档索引.py index 9e75e56..c456413 100644 --- a/tests/架构/test_技能与文档索引.py +++ b/tests/架构/test_技能与文档索引.py @@ -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" diff --git a/tests/架构/test_测试依赖与执行证据.py b/tests/架构/test_测试依赖与执行证据.py index f73cd9c..3a870f5 100644 --- a/tests/架构/test_测试依赖与执行证据.py +++ b/tests/架构/test_测试依赖与执行证据.py @@ -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 diff --git a/tests/用例清单.json b/tests/用例清单.json index b8cac9c..d7db7f3 100644 --- a/tests/用例清单.json +++ b/tests/用例清单.json @@ -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": "默认离线", diff --git a/工具/维护索引.py b/工具/维护索引.py index fe02373..7ce594e 100644 --- a/工具/维护索引.py +++ b/工具/维护索引.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( diff --git a/工具/验证锁.py b/工具/验证锁.py index f2f9a3b..3008459 100644 --- a/工具/验证锁.py +++ b/工具/验证锁.py @@ -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