diff --git a/skills/ship/SKILL.md b/skills/ship/SKILL.md index a21e24d..37df368 100644 --- a/skills/ship/SKILL.md +++ b/skills/ship/SKILL.md @@ -34,6 +34,9 @@ description: >- 新的 ship。 - tester / builder / deployer 的 SSOT 仍在各自目录。ship 只追加指针。本轮跑过的 测试、obtain、部署若没写入 `current.yaml` 的 `evidence`,该阶段不算完成。 +- 新建 ship 的任务带 `testPlan`,开发 worker 把新增/修改测试写入 + `2-develop/test-manifest.yaml`;verify 按 manifest 的 selector 执行,regress 再叠加 + 所有 tester catalog 根和项目原生完整测试。没有测试时必须有明确批准的 `testGap`。 - 项目构建/部署契约仍在 `.pouch/ack/delivery.yaml` 和 builder/deployer 配置里,不要 复制进每个 ship。 - 旧的 `.pouch/specifier/features//` 在继续该需求时迁到 diff --git a/skills/ship/references/record.md b/skills/ship/references/record.md index ce70350..fba9e95 100644 --- a/skills/ship/references/record.md +++ b/skills/ship/references/record.md @@ -16,6 +16,7 @@ current.yaml # 总账:kind、状态、stopAt、指针 spec.md # 短需求说明(short 和 long 都有) tasks.yaml # 本 ship 的任务 DAG + 2-develop/test-manifest.yaml # 开发者新增/修改测试的机器清单 status.md # 给人看的一页摘要 1-spec/ # 规格、评审、根因和 handoff 2-develop/ # 开发任务和变更 @@ -131,6 +132,12 @@ evidence: `tasks.yaml` 只描述本 ship 的工作项,不是 ACK 缺陷板。 +新建 ship 的任务默认带 `testPlan`。开发 worker 在 manifest 中记录测试的稳定 id、 +runner、target、selector、覆盖的验收项和 `added|changed` 状态。`verify` 必须逐条执行 +manifest;`regress` 在完整 tester catalog 和项目原生测试之外再次确认这些条目通过。 +仅文档或明确无行为变化的任务可使用 `policy: not_applicable`,但必须填写被主会话接受的 +`testGap.reason` 与 `acceptedBy`。 + ```yaml shipId: add-export-button tasks: @@ -141,6 +148,9 @@ tasks: - 登录用户在列表页看到「导出」 verification: - 浏览器打开列表页,按钮可见且可点 + testPlan: + policy: required + manifest: 2-develop/test-manifest.yaml status: open stopAt: review_ready ``` diff --git a/skills/ship/references/worker-prompt.md b/skills/ship/references/worker-prompt.md index f771fd6..e3db115 100644 --- a/skills/ship/references/worker-prompt.md +++ b/skills/ship/references/worker-prompt.md @@ -21,9 +21,18 @@ scripts/append_evidence.py 追加后跑 scripts/validate_ship.py。 验收:见 spec.md 与 tasks.yaml 里该任务的 expected / verification。 授权:<本步允许的 git/PR/部署动作;未写的不要做> +测试交接:`implement` / `implement_task` 必须在 ship 的 `2-develop/test-manifest.yaml` +登记本次新增或修改的测试。每条记录至少包含稳定 `id`、`kind`、`runner`、`target`、 +`selector`、`covers` 和 `change: added|changed`。若确实没有行为变化或无法补测,必须 +填写 `testGap.reason`,并由主会话明确写入 `acceptedBy`;不能留空后声称已验证。 + 测试边界:`verify` / `verify_task` 只负责开发阶段验证,执行任务相关的单元测试、 -新增功能集成测试和静态检查,不执行合并后的完整 tester 回归;`regress` 只在合并后 -负责完整回归,必须覆盖发现的每个 tester catalog 根和项目声明的原生测试套件。 +新增功能集成测试和静态检查,不执行合并后的完整 tester 回归。验证前先使用 ship skill +的 `scripts/validate_test_manifest.py /2-develop/test-manifest.yaml` +带上当前 ship id、任务的 testPlan.policy 执行 `--mode verify`;逐条执行 manifest 中的 +测试 selector,并在证据中记录测试 id。 +`regress` 只在合并后负责完整回归,除 manifest 中的新增/修改测试外,必须覆盖发现的每个 +tester catalog 根和项目声明的原生测试套件。 若步骤是 `verify_task` 或 `regress` 且 skill 是 `tester`:先执行 `python3 /scripts/catalog.py roots --root `。`verify_task` 按 diff --git a/skills/ship/scripts/validate_ship.py b/skills/ship/scripts/validate_ship.py index fdd0d42..153cf53 100644 --- a/skills/ship/scripts/validate_ship.py +++ b/skills/ship/scripts/validate_ship.py @@ -19,6 +19,7 @@ from ship_lib import ( check_ship_id, load_yaml, ) +from validate_test_manifest import validate_manifest def _issue(issues: list[str], msg: str) -> None: @@ -144,6 +145,7 @@ def validate_ship_dir(ship_dir: Path) -> list[str]: _issue(issues, f"缺少 {tasks_rel}") else: issues.extend(_validate_tasks(tasks_path, ship_id, kind)) + issues.extend(_validate_manifest_pointers(tasks_path, ship_dir, ship_id)) delivery = data.get("delivery") if delivery is not None: @@ -247,6 +249,43 @@ def _validate_tasks(path: Path, ship_id: Any, kind: Any) -> list[str]: return issues +def _validate_manifest_pointers(path: Path, ship_dir: Path, ship_id: str) -> list[str]: + issues: list[str] = [] + try: + data = load_yaml(path) + except Exception: + return issues + tasks = data.get("tasks") if isinstance(data, dict) else None + if not isinstance(tasks, list): + return issues + for index, item in enumerate(tasks): + if not isinstance(item, dict): + continue + plan = item.get("testPlan") + if not isinstance(plan, dict): + continue + manifest = plan.get("manifest") + if not isinstance(manifest, str) or not manifest.strip(): + continue + target = (ship_dir / manifest).resolve() + try: + target.relative_to(ship_dir.resolve()) + except ValueError: + _issue(issues, f"tasks[{index}].testPlan.manifest 必须位于 ship 目录内: {manifest}") + continue + if not target.is_file(): + _issue(issues, f"tasks[{index}].testPlan.manifest 不存在: {manifest}") + continue + manifest_issues, _ = validate_manifest( + target, + expected_ship_id=ship_id, + policy=str(plan.get("policy") or "required"), + mode="schema", + ) + issues.extend(f"tasks[{index}].{issue}" for issue in manifest_issues) + return issues + + def _validate_evidence(evidence: dict[str, Any], project_root: Path) -> list[str]: issues: list[str] = [] for key in ("testRuns", "artifacts", "deploys", "prs", "commits"): diff --git a/tests/test_ship_test_manifest.py b/tests/test_ship_test_manifest.py new file mode 100644 index 0000000..6babf03 --- /dev/null +++ b/tests/test_ship_test_manifest.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +from pouch.yaml_io import safe_dump, safe_load + +ROOT = Path(__file__).resolve().parents[1] +CREATE = ROOT / "skills/ship/scripts/create_ship.py" +VALIDATE = ROOT / "skills/ship/scripts/validate_ship.py" +MANIFEST = ROOT / "skills/ship/scripts/validate_test_manifest.py" + + +def run(script: Path, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(script), *args], + cwd=ROOT, + text=True, + capture_output=True, + check=False, + ) + + +class TestManifestTests(unittest.TestCase): + def _create(self, root: Path) -> Path: + result = run( + CREATE, + "--project-root", str(root), + "--ship-id", "manifest-demo", + "--kind", "short", + "--title", "测试 manifest", + "--stop-at", "develop_verified", + ) + self.assertEqual(result.returncode, 0, result.stderr) + return root / ".pouch/ship/manifest-demo" + + def test_new_ship_creates_manifest_and_schema_validates(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + ship = self._create(Path(tmp)) + manifest = ship / "2-develop/test-manifest.yaml" + self.assertTrue(manifest.is_file()) + checked = run( + MANIFEST, str(manifest), "--ship-id", "manifest-demo", "--mode", "schema" + ) + self.assertEqual(checked.returncode, 0, checked.stderr) + record = run(VALIDATE, str(ship)) + self.assertEqual(record.returncode, 0, record.stderr) + + def test_required_empty_manifest_blocks_verify(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + ship = self._create(Path(tmp)) + checked = run( + MANIFEST, + str(ship / "2-develop/test-manifest.yaml"), + "--ship-id", "manifest-demo", + "--policy", "required", + "--mode", "verify", + ) + self.assertNotEqual(checked.returncode, 0) + self.assertIn("登记至少一个测试", checked.stderr) + + def test_manifest_lists_exact_test_selector(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + ship = self._create(Path(tmp)) + path = ship / "2-develop/test-manifest.yaml" + data = safe_load(path.read_text(encoding="utf-8")) + data["tests"] = [{ + "id": "go:internal/store/drafts_test.go#TestDraft", + "kind": "unit", + "runner": "go-test", + "target": "./internal/store", + "selector": "^TestDraft$", + "covers": ["T-01"], + "change": "added", + }] + path.write_text(safe_dump(data), encoding="utf-8") + checked = run( + MANIFEST, + str(path), + "--ship-id", "manifest-demo", + "--policy", "required", + "--mode", "verify", + ) + self.assertEqual(checked.returncode, 0, checked.stderr) + self.assertIn("go:internal/store/drafts_test.go#TestDraft", checked.stdout) + + def test_ship_validation_rejects_missing_manifest(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + ship = self._create(Path(tmp)) + (ship / "2-develop/test-manifest.yaml").unlink() + checked = run(VALIDATE, str(ship)) + self.assertNotEqual(checked.returncode, 0) + self.assertIn("manifest 不存在", checked.stderr) + + +if __name__ == "__main__": + unittest.main()