Compare commits
2 Commits
d7ebb6ee85
...
fbf0ca8cd8
| Author | SHA1 | Date | |
|---|---|---|---|
| fbf0ca8cd8 | |||
| 8d48d2d335 |
@@ -34,6 +34,9 @@ description: >-
|
|||||||
新的 ship。
|
新的 ship。
|
||||||
- tester / builder / deployer 的 SSOT 仍在各自目录。ship 只追加指针。本轮跑过的
|
- tester / builder / deployer 的 SSOT 仍在各自目录。ship 只追加指针。本轮跑过的
|
||||||
测试、obtain、部署若没写入 `current.yaml` 的 `evidence`,该阶段不算完成。
|
测试、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 配置里,不要
|
- 项目构建/部署契约仍在 `.pouch/ack/delivery.yaml` 和 builder/deployer 配置里,不要
|
||||||
复制进每个 ship。
|
复制进每个 ship。
|
||||||
- 旧的 `.pouch/specifier/features/<id>/` 在继续该需求时迁到
|
- 旧的 `.pouch/specifier/features/<id>/` 在继续该需求时迁到
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
current.yaml # 总账:kind、状态、stopAt、指针
|
current.yaml # 总账:kind、状态、stopAt、指针
|
||||||
spec.md # 短需求说明(short 和 long 都有)
|
spec.md # 短需求说明(short 和 long 都有)
|
||||||
tasks.yaml # 本 ship 的任务 DAG
|
tasks.yaml # 本 ship 的任务 DAG
|
||||||
|
2-develop/test-manifest.yaml # 开发者新增/修改测试的机器清单
|
||||||
status.md # 给人看的一页摘要
|
status.md # 给人看的一页摘要
|
||||||
1-spec/ # 规格、评审、根因和 handoff
|
1-spec/ # 规格、评审、根因和 handoff
|
||||||
2-develop/ # 开发任务和变更
|
2-develop/ # 开发任务和变更
|
||||||
@@ -131,6 +132,12 @@ evidence:
|
|||||||
|
|
||||||
`tasks.yaml` 只描述本 ship 的工作项,不是 ACK 缺陷板。
|
`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
|
```yaml
|
||||||
shipId: add-export-button
|
shipId: add-export-button
|
||||||
tasks:
|
tasks:
|
||||||
@@ -141,6 +148,9 @@ tasks:
|
|||||||
- 登录用户在列表页看到「导出」
|
- 登录用户在列表页看到「导出」
|
||||||
verification:
|
verification:
|
||||||
- 浏览器打开列表页,按钮可见且可点
|
- 浏览器打开列表页,按钮可见且可点
|
||||||
|
testPlan:
|
||||||
|
policy: required
|
||||||
|
manifest: 2-develop/test-manifest.yaml
|
||||||
status: open
|
status: open
|
||||||
stopAt: review_ready
|
stopAt: review_ready
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -21,9 +21,18 @@ scripts/append_evidence.py 追加后跑 scripts/validate_ship.py。
|
|||||||
验收:见 spec.md 与 tasks.yaml 里该任务的 expected / verification。
|
验收:见 spec.md 与 tasks.yaml 里该任务的 expected / verification。
|
||||||
授权:<本步允许的 git/PR/部署动作;未写的不要做>
|
授权:<本步允许的 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` 只负责开发阶段验证,执行任务相关的单元测试、
|
测试边界:`verify` / `verify_task` 只负责开发阶段验证,执行任务相关的单元测试、
|
||||||
新增功能集成测试和静态检查,不执行合并后的完整 tester 回归;`regress` 只在合并后
|
新增功能集成测试和静态检查,不执行合并后的完整 tester 回归。验证前先使用 ship skill
|
||||||
负责完整回归,必须覆盖发现的每个 tester catalog 根和项目声明的原生测试套件。
|
的 `scripts/validate_test_manifest.py <ship-dir>/2-develop/test-manifest.yaml`
|
||||||
|
带上当前 ship id、任务的 testPlan.policy 执行 `--mode verify`;逐条执行 manifest 中的
|
||||||
|
测试 selector,并在证据中记录测试 id。
|
||||||
|
`regress` 只在合并后负责完整回归,除 manifest 中的新增/修改测试外,必须覆盖发现的每个
|
||||||
|
tester catalog 根和项目声明的原生测试套件。
|
||||||
|
|
||||||
若步骤是 `verify_task` 或 `regress` 且 skill 是 `tester`:先执行
|
若步骤是 `verify_task` 或 `regress` 且 skill 是 `tester`:先执行
|
||||||
`python3 <tester-skill>/scripts/catalog.py roots --root <project-root>`。`verify_task` 按
|
`python3 <tester-skill>/scripts/catalog.py roots --root <project-root>`。`verify_task` 按
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from ship_lib import (
|
|||||||
check_ship_id,
|
check_ship_id,
|
||||||
load_yaml,
|
load_yaml,
|
||||||
)
|
)
|
||||||
|
from validate_test_manifest import validate_manifest
|
||||||
|
|
||||||
|
|
||||||
def _issue(issues: list[str], msg: str) -> None:
|
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}")
|
_issue(issues, f"缺少 {tasks_rel}")
|
||||||
else:
|
else:
|
||||||
issues.extend(_validate_tasks(tasks_path, ship_id, kind))
|
issues.extend(_validate_tasks(tasks_path, ship_id, kind))
|
||||||
|
issues.extend(_validate_manifest_pointers(tasks_path, ship_dir, ship_id))
|
||||||
|
|
||||||
delivery = data.get("delivery")
|
delivery = data.get("delivery")
|
||||||
if delivery is not None:
|
if delivery is not None:
|
||||||
@@ -247,6 +249,43 @@ def _validate_tasks(path: Path, ship_id: Any, kind: Any) -> list[str]:
|
|||||||
return issues
|
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]:
|
def _validate_evidence(evidence: dict[str, Any], project_root: Path) -> list[str]:
|
||||||
issues: list[str] = []
|
issues: list[str] = []
|
||||||
for key in ("testRuns", "artifacts", "deploys", "prs", "commits"):
|
for key in ("testRuns", "artifacts", "deploys", "prs", "commits"):
|
||||||
|
|||||||
@@ -67,8 +67,11 @@ class DeployerCheckTests(unittest.TestCase):
|
|||||||
self.assertIn("不要读 compose.md", compose_section)
|
self.assertIn("不要读 compose.md", compose_section)
|
||||||
self.assertIn("remote.py <svc> recreate", compose_section)
|
self.assertIn("remote.py <svc> recreate", compose_section)
|
||||||
self.assertIn("一句问", compose_section)
|
self.assertIn("一句问", compose_section)
|
||||||
self.assertIn("没有 `test` 就用非 `prod` 的那个", compose_section)
|
self.assertIn(
|
||||||
self.assertIn("不要** `sync.py` / `remote.py`", compose_section)
|
"没有 `test` 时只能选择明确为本机的非 `prod` 环境",
|
||||||
|
compose_section,
|
||||||
|
)
|
||||||
|
self.assertIn("不要 `sync.py`、`remote.py`、SSH", compose_section)
|
||||||
self.assertIn("docker compose up -d --force-recreate", compose_section)
|
self.assertIn("docker compose up -d --force-recreate", compose_section)
|
||||||
self.assertNotIn(
|
self.assertNotIn(
|
||||||
"读 [compose.md](references/compose.md)。用 `list.py` 摸底",
|
"读 [compose.md](references/compose.md)。用 `list.py` 摸底",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ sync_spec.loader.exec_module(deployer_sync)
|
|||||||
def _info(service_dir: str, **overrides) -> dict:
|
def _info(service_dir: str, **overrides) -> dict:
|
||||||
info = {
|
info = {
|
||||||
"service_dir": service_dir,
|
"service_dir": service_dir,
|
||||||
|
"name": "demo-service",
|
||||||
"node": "edge",
|
"node": "edge",
|
||||||
"remote_dir": "/opt/app/svc",
|
"remote_dir": "/opt/app/svc",
|
||||||
"sync_exclude": ["data", "_data"],
|
"sync_exclude": ["data", "_data"],
|
||||||
@@ -93,11 +94,15 @@ class DeployerSyncTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(rc, 0)
|
self.assertEqual(rc, 0)
|
||||||
self.assertTrue(commands)
|
self.assertTrue(commands)
|
||||||
mkdir_cmd = commands[0]
|
mkdir_cmd = next(
|
||||||
|
command
|
||||||
|
for command in commands
|
||||||
|
if command and command[0] == "ssh" and command[-1].startswith("mkdir -p ")
|
||||||
|
)
|
||||||
self.assertEqual(mkdir_cmd[0], "ssh")
|
self.assertEqual(mkdir_cmd[0], "ssh")
|
||||||
self.assertIn("-p", mkdir_cmd)
|
self.assertIn("-p", mkdir_cmd)
|
||||||
self.assertIn("2222", mkdir_cmd)
|
self.assertIn("2222", mkdir_cmd)
|
||||||
self.assertTrue(any("/opt/app/svc/cfg" in part for part in mkdir_cmd))
|
self.assertIn("/opt/app/svc/cfg", " ".join(mkdir_cmd))
|
||||||
|
|
||||||
scp_cmds = [c for c in commands if c and c[0] == "scp"]
|
scp_cmds = [c for c in commands if c and c[0] == "scp"]
|
||||||
self.assertEqual(len(scp_cmds), 2)
|
self.assertEqual(len(scp_cmds), 2)
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user