@guanlin4924/devflow-pi 0.1.19 → 0.1.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/README.md +19 -0
  2. package/package.json +3 -3
  3. package/plugins/devflow-core/extensions/devflow-core.ts +142 -2
  4. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_runner.py +394 -0
  5. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_suite.rb +86 -0
  6. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +192 -1
  7. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/api_test_matrix.py +829 -0
  8. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +7 -5
  9. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/init_state.rb +11 -1
  10. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/inspect_state.rb +5 -1
  11. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +39 -3
  12. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +309 -23
  13. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/tdd_evidence.py +311 -0
  14. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/test_command_policy.py +53 -0
  15. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/test_task_binding_check.py +254 -0
  16. package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +109 -4
  17. package/plugins/devflow-core/skills/workflow-orchestrator/templates/uat-remediation.yaml +21 -1
  18. package/plugins/devflow-design/skills/workflow-api-contract/SKILL.md +38 -0
  19. package/plugins/devflow-implementation/agents/implementation-worker.md +3 -0
  20. package/plugins/devflow-implementation/skills/workflow-change-plan/SKILL.md +7 -0
  21. package/plugins/devflow-implementation/skills/workflow-change-plan/references/output-template.md +2 -0
  22. package/plugins/devflow-implementation/skills/workflow-execute-phase/SKILL.md +3 -3
  23. package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +63 -0
  24. package/plugins/devflow-quality/skills/workflow-acceptance-test-design/agents/openai.yaml +4 -0
  25. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/SKILL.md +7 -6
  26. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/intermediate-format.md +3 -0
  27. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/compile_dsl.py +49 -3
  28. package/plugins/devflow-quality/skills/workflow-run-acceptance-tests/SKILL.md +48 -0
  29. package/plugins/devflow-quality/skills/workflow-run-acceptance-tests/agents/openai.yaml +4 -0
  30. package/plugins/devflow-quality/skills/workflow-verify-work/SKILL.md +9 -3
@@ -0,0 +1,311 @@
1
+ #!/usr/bin/env python3
2
+ """Core-owned RED/GREEN/REGRESSION command evidence for a frozen task binding."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import hashlib
7
+ import json
8
+ import subprocess
9
+ import sys
10
+ from datetime import datetime, timezone
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from test_task_binding_check import FEATURE_RE, validate as validate_bindings
15
+ from test_command_policy import validate_test_command
16
+
17
+
18
+ STAGES = ("red", "green", "regression")
19
+
20
+
21
+ def sha256_bytes(value: bytes) -> str:
22
+ return hashlib.sha256(value).hexdigest()
23
+
24
+
25
+ def utc_now() -> str:
26
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
27
+
28
+
29
+ def read_json(path: Path) -> dict[str, Any] | None:
30
+ try:
31
+ value = json.loads(path.read_text(encoding="utf-8"))
32
+ except (FileNotFoundError, json.JSONDecodeError):
33
+ return None
34
+ return value if isinstance(value, dict) else None
35
+
36
+
37
+ def write_json(path: Path, value: dict[str, Any]) -> None:
38
+ path.parent.mkdir(parents=True, exist_ok=True)
39
+ temporary = path.with_name(f".{path.name}.tmp")
40
+ temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
41
+ temporary.replace(path)
42
+
43
+
44
+ def safe_project_path(root: Path, raw: str) -> Path | None:
45
+ candidate = Path(raw)
46
+ if candidate.is_absolute() or ".." in candidate.parts or not raw or "\x00" in raw:
47
+ return None
48
+ resolved = (root / candidate).resolve()
49
+ try:
50
+ resolved.relative_to(root.resolve())
51
+ except ValueError:
52
+ return None
53
+ return resolved
54
+
55
+
56
+ def source_snapshot(root: Path, paths: list[str]) -> tuple[dict[str, str], list[str]]:
57
+ values: dict[str, str] = {}
58
+ errors: list[str] = []
59
+ for raw in sorted(set(paths)):
60
+ absolute = safe_project_path(root, raw)
61
+ if absolute is None:
62
+ errors.append(f"invalid production path: {raw}")
63
+ continue
64
+ if absolute.exists() and not absolute.is_file():
65
+ errors.append(f"production path must be a file or missing: {raw}")
66
+ continue
67
+ values[raw] = sha256_bytes(absolute.read_bytes()) if absolute.exists() else "<missing>"
68
+ return values, errors
69
+
70
+
71
+ def test_paths(task: dict[str, Any]) -> list[str]:
72
+ return [item["path"] for item in task.get("implementation_tests", []) if isinstance(item, dict) and isinstance(item.get("path"), str)]
73
+
74
+
75
+ def dirty_declared_production_paths(root: Path, paths: list[str]) -> list[str]:
76
+ """Reject evidence that starts after a declared source file was already edited.
77
+
78
+ A non-Git temporary project is acceptable for unit tests, but a real Git
79
+ worktree supplies independent evidence that the RED checkpoint preceded
80
+ production edits.
81
+ """
82
+ try:
83
+ completed = subprocess.run(["git", "status", "--porcelain=v1", "--", *paths], cwd=root, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)
84
+ except OSError:
85
+ return []
86
+ if completed.returncode != 0:
87
+ return []
88
+ return [line[3:] for line in completed.stdout.splitlines() if len(line) > 3]
89
+
90
+
91
+ def task_binding(root: Path, feature: str, task_id: str) -> tuple[dict[str, Any] | None, list[str]]:
92
+ validation = validate_bindings(root, feature)
93
+ if not validation.get("ok"):
94
+ return None, [str(item) for item in validation.get("errors", ["invalid test bindings"])]
95
+ bindings = read_json(root / ".planning" / "features" / feature / "test-bindings.json")
96
+ if bindings is None:
97
+ return None, ["missing test bindings"]
98
+ matches = [item for item in bindings.get("tasks", []) if isinstance(item, dict) and item.get("id") == task_id]
99
+ if len(matches) != 1:
100
+ return None, [f"task binding not found: {task_id}"]
101
+ task = matches[0]
102
+ if task.get("test_mode") != "tdd_required":
103
+ return None, [f"task {task_id} is not tdd_required"]
104
+ return task, []
105
+
106
+
107
+ def evidence_path(root: Path, feature: str, task_id: str) -> Path:
108
+ return root / ".planning" / "test-runs" / feature / "tdd" / f"{task_id}.json"
109
+
110
+
111
+ def command_hash(argv: list[str]) -> str:
112
+ return sha256_bytes(json.dumps(argv, ensure_ascii=False, separators=(",", ":")).encode("utf-8"))
113
+
114
+
115
+ def response(ok: bool, **fields: Any) -> dict[str, Any]:
116
+ return {"ok": ok, **fields}
117
+
118
+
119
+ def prepare(root: Path, feature: str, task_id: str, write: bool) -> dict[str, Any]:
120
+ task, errors = task_binding(root, feature, task_id)
121
+ if errors:
122
+ return response(False, feature=feature, task_id=task_id, errors=errors)
123
+ assert task is not None
124
+ snapshot, snapshot_errors = source_snapshot(root, list(task["production_paths"]))
125
+ tests_snapshot, test_snapshot_errors = source_snapshot(root, test_paths(task))
126
+ if snapshot_errors:
127
+ return response(False, feature=feature, task_id=task_id, errors=snapshot_errors)
128
+ if test_snapshot_errors:
129
+ return response(False, feature=feature, task_id=task_id, errors=test_snapshot_errors)
130
+ dirty = dirty_declared_production_paths(root, list(task["production_paths"]))
131
+ if dirty:
132
+ return response(False, feature=feature, task_id=task_id, errors=[f"declared production paths already changed before TDD preparation: {', '.join(dirty)}"])
133
+ destination = evidence_path(root, feature, task_id)
134
+ existing = read_json(destination)
135
+ if existing is not None:
136
+ return response(False, feature=feature, task_id=task_id, errors=["TDD evidence is already prepared; use a new task binding to restart"])
137
+ record = {
138
+ "schema_version": 1,
139
+ "origin": "core_tdd_evidence",
140
+ "feature": feature,
141
+ "task_id": task_id,
142
+ "tc_refs": task["tc_refs"],
143
+ "prepared_at": utc_now(),
144
+ "prepared_source_snapshot": snapshot,
145
+ "prepared_test_snapshot": tests_snapshot,
146
+ "stages": {},
147
+ }
148
+ if write:
149
+ write_json(destination, record)
150
+ return response(True, feature=feature, task_id=task_id, action="prepare", path=str(destination.relative_to(root)), record=record)
151
+
152
+
153
+ def record_stage(root: Path, feature: str, task_id: str, stage: str, write: bool) -> dict[str, Any]:
154
+ task, errors = task_binding(root, feature, task_id)
155
+ if errors:
156
+ return response(False, feature=feature, task_id=task_id, stage=stage, errors=errors)
157
+ assert task is not None
158
+ destination = evidence_path(root, feature, task_id)
159
+ record = read_json(destination)
160
+ if record is None:
161
+ return response(False, feature=feature, task_id=task_id, stage=stage, errors=["TDD evidence must be prepared before RED"])
162
+ if record.get("feature") != feature or record.get("task_id") != task_id or not isinstance(record.get("stages"), dict):
163
+ return response(False, feature=feature, task_id=task_id, stage=stage, errors=["invalid existing TDD evidence record"])
164
+ stages = record["stages"]
165
+ if stage in stages:
166
+ return response(False, feature=feature, task_id=task_id, stage=stage, errors=[f"stage already recorded: {stage}"])
167
+ if stage == "green" and stages.get("red", {}).get("status") != "passed":
168
+ return response(False, feature=feature, task_id=task_id, stage=stage, errors=["GREEN requires a passed RED record"])
169
+ if stage == "regression" and stages.get("green", {}).get("status") != "passed":
170
+ return response(False, feature=feature, task_id=task_id, stage=stage, errors=["REGRESSION requires a passed GREEN record"])
171
+
172
+ snapshot, snapshot_errors = source_snapshot(root, list(task["production_paths"]))
173
+ tests_snapshot, test_snapshot_errors = source_snapshot(root, test_paths(task))
174
+ if snapshot_errors:
175
+ return response(False, feature=feature, task_id=task_id, stage=stage, errors=snapshot_errors)
176
+ if test_snapshot_errors:
177
+ return response(False, feature=feature, task_id=task_id, stage=stage, errors=test_snapshot_errors)
178
+ prepared_snapshot = record.get("prepared_source_snapshot")
179
+ if not isinstance(prepared_snapshot, dict):
180
+ return response(False, feature=feature, task_id=task_id, stage=stage, errors=["prepared source snapshot is missing"])
181
+ if stage == "red" and snapshot != prepared_snapshot:
182
+ return response(False, feature=feature, task_id=task_id, stage=stage, errors=["production paths changed before RED; task is no longer eligible for TDD evidence"])
183
+ if stage == "red" and tests_snapshot == record.get("prepared_test_snapshot"):
184
+ return response(False, feature=feature, task_id=task_id, stage=stage, errors=["test paths did not change before RED"])
185
+ if stage == "green" and snapshot == prepared_snapshot:
186
+ return response(False, feature=feature, task_id=task_id, stage=stage, errors=["production paths did not change after RED"])
187
+
188
+ tdd = task["tdd"]
189
+ stage_spec = tdd[stage]
190
+ argv = stage_spec["argv"]
191
+ command_error = validate_test_command(root, argv)
192
+ if command_error:
193
+ return response(False, feature=feature, task_id=task_id, stage=stage, errors=[command_error])
194
+ if stage == "green" and command_hash(argv) != stages["red"].get("command_hash"):
195
+ return response(False, feature=feature, task_id=task_id, stage=stage, errors=["GREEN command does not match the recorded RED test target"])
196
+ started_at = utc_now()
197
+ try:
198
+ completed = subprocess.run(argv, cwd=root, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120, check=False)
199
+ exit_code = completed.returncode
200
+ output = f"{completed.stdout}\n{completed.stderr}"
201
+ except subprocess.TimeoutExpired as error:
202
+ exit_code = 124
203
+ output = f"command timed out: {error}"
204
+ except OSError as error:
205
+ exit_code = 127
206
+ output = f"command could not start: {error}"
207
+ expected_failure = stage_spec.get("expected_failure") if isinstance(stage_spec, dict) else None
208
+ passed = (exit_code != 0 and isinstance(expected_failure, str) and expected_failure in output) if stage == "red" else exit_code == 0
209
+ stage_record = {
210
+ "status": "passed" if passed else "failed",
211
+ "stage": stage,
212
+ "argv": argv,
213
+ "command_hash": command_hash(argv),
214
+ "started_at": started_at,
215
+ "finished_at": utc_now(),
216
+ "exit_code": exit_code,
217
+ "output_sha256": sha256_bytes(output.encode("utf-8", errors="replace")),
218
+ "source_snapshot": snapshot,
219
+ "test_snapshot": tests_snapshot,
220
+ "tc_refs": task["tc_refs"],
221
+ }
222
+ if stage == "red":
223
+ stage_record["expected_failure"] = expected_failure
224
+ stages[stage] = stage_record
225
+ if write:
226
+ write_json(destination, record)
227
+ failures = [] if passed else (["RED did not fail with its declared expected_failure"] if stage == "red" else [f"{stage.upper()} command failed with exit code {exit_code}"])
228
+ return response(passed, feature=feature, task_id=task_id, stage=stage, path=str(destination.relative_to(root)), stage_record=stage_record, errors=failures)
229
+
230
+
231
+ def validate_feature(root: Path, feature: str) -> dict[str, Any]:
232
+ binding_result = validate_bindings(root, feature)
233
+ errors = [str(item) for item in binding_result.get("errors", [])]
234
+ if errors:
235
+ return response(False, feature=feature, errors=errors)
236
+ bindings = read_json(root / ".planning" / "features" / feature / "test-bindings.json")
237
+ assert bindings is not None
238
+ checked: list[str] = []
239
+ for task in bindings.get("tasks", []):
240
+ if not isinstance(task, dict) or task.get("test_mode") != "tdd_required":
241
+ continue
242
+ task_id = task.get("id")
243
+ if not isinstance(task_id, str):
244
+ errors.append("invalid TDD task id in test bindings")
245
+ continue
246
+ checked.append(task_id)
247
+ record = read_json(evidence_path(root, feature, task_id))
248
+ if record is None:
249
+ errors.append(f"missing TDD evidence: {task_id}")
250
+ continue
251
+ if record.get("origin") != "core_tdd_evidence" or record.get("feature") != feature or record.get("task_id") != task_id:
252
+ errors.append(f"invalid TDD evidence identity: {task_id}")
253
+ continue
254
+ if not isinstance(record.get("prepared_source_snapshot"), dict):
255
+ errors.append(f"missing prepared source snapshot: {task_id}")
256
+ if not isinstance(record.get("prepared_test_snapshot"), dict):
257
+ errors.append(f"missing prepared test snapshot: {task_id}")
258
+ stages = record.get("stages") if isinstance(record.get("stages"), dict) else {}
259
+ for stage in STAGES:
260
+ evidence = stages.get(stage)
261
+ if not isinstance(evidence, dict) or evidence.get("status") != "passed":
262
+ errors.append(f"missing passed {stage.upper()} evidence: {task_id}")
263
+ continue
264
+ expected_hash = command_hash(task["tdd"][stage]["argv"])
265
+ if evidence.get("command_hash") != expected_hash:
266
+ errors.append(f"{stage.upper()} command does not match frozen binding: {task_id}")
267
+ red = stages.get("red") if isinstance(stages.get("red"), dict) else {}
268
+ green = stages.get("green") if isinstance(stages.get("green"), dict) else {}
269
+ if red and green and red.get("command_hash") != green.get("command_hash"):
270
+ errors.append(f"RED/GREEN command mismatch in evidence: {task_id}")
271
+ if red and record.get("prepared_source_snapshot") != red.get("source_snapshot"):
272
+ errors.append(f"RED source snapshot differs from prepared snapshot: {task_id}")
273
+ if red and record.get("prepared_test_snapshot") == red.get("test_snapshot"):
274
+ errors.append(f"RED test snapshot did not change after preparation: {task_id}")
275
+ if green and record.get("prepared_source_snapshot") == green.get("source_snapshot"):
276
+ errors.append(f"GREEN source snapshot did not change after RED: {task_id}")
277
+ return response(not errors, feature=feature, task_ids=checked, errors=errors)
278
+
279
+
280
+ def main() -> int:
281
+ parser = argparse.ArgumentParser()
282
+ parser.add_argument("action", choices=("prepare", "record", "inspect", "validate"))
283
+ parser.add_argument("--feature", required=True)
284
+ parser.add_argument("--task-id")
285
+ parser.add_argument("--stage", choices=STAGES)
286
+ parser.add_argument("--write", action="store_true")
287
+ parser.add_argument("--json", action="store_true")
288
+ args = parser.parse_args()
289
+ if not FEATURE_RE.fullmatch(args.feature):
290
+ result = response(False, errors=["invalid feature"])
291
+ elif args.action == "validate":
292
+ result = validate_feature(Path.cwd().resolve(), args.feature)
293
+ elif not args.task_id or "/" in args.task_id or "\\" in args.task_id:
294
+ result = response(False, errors=["invalid task_id"])
295
+ elif args.action == "inspect":
296
+ record = read_json(evidence_path(Path.cwd().resolve(), args.feature, args.task_id))
297
+ result = response(record is not None, record=record, errors=[] if record else ["TDD evidence not found"])
298
+ elif not args.write:
299
+ result = response(False, errors=["--write is required for prepare and record"])
300
+ elif args.action == "prepare":
301
+ result = prepare(Path.cwd().resolve(), args.feature, args.task_id, True)
302
+ elif not args.stage:
303
+ result = response(False, errors=["--stage is required for record"])
304
+ else:
305
+ result = record_stage(Path.cwd().resolve(), args.feature, args.task_id, args.stage, True)
306
+ print(json.dumps(result, ensure_ascii=False, indent=2))
307
+ return 0
308
+
309
+
310
+ if __name__ == "__main__":
311
+ raise SystemExit(main())
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env python3
2
+ """Allow only initialized, non-shell test runner argv commands."""
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+
9
+ def runner_marker(argv: list[str]) -> str | None:
10
+ if not argv:
11
+ return None
12
+ executable = Path(argv[0]).name
13
+ if executable in {"pytest"}:
14
+ return "pytest"
15
+ if executable.startswith("python") and len(argv) >= 3 and argv[1:3] == ["-m", "pytest"]:
16
+ return "pytest"
17
+ if executable.startswith("python") and len(argv) >= 3 and argv[1:3] == ["-m", "unittest"]:
18
+ return "unittest"
19
+ if executable == "bun" and len(argv) >= 2 and argv[1] == "test":
20
+ return "bun test"
21
+ if executable == "npm" and len(argv) >= 2 and argv[1] in {"test", "run"}:
22
+ return "npm"
23
+ if executable == "pnpm" and len(argv) >= 2 and argv[1] in {"test", "run"}:
24
+ return "pnpm"
25
+ if executable == "yarn" and len(argv) >= 2 and argv[1] in {"test", "run"}:
26
+ return "yarn"
27
+ if executable == "go" and len(argv) >= 2 and argv[1] == "test":
28
+ return "go test"
29
+ if executable == "newman" and len(argv) >= 2 and argv[1] == "run":
30
+ return "newman"
31
+ if executable == "playwright" and len(argv) >= 2 and argv[1] == "test":
32
+ return "playwright"
33
+ if executable == "npx" and len(argv) >= 3 and argv[1:3] == ["playwright", "test"]:
34
+ return "playwright"
35
+ if executable == "vitest" and len(argv) >= 2 and argv[1] in {"run", "related"}:
36
+ return "vitest"
37
+ return None
38
+
39
+
40
+ def validate_test_command(root: Path, argv: Any) -> str | None:
41
+ if not isinstance(argv, list) or not argv or not all(isinstance(token, str) and token and "\x00" not in token for token in argv):
42
+ return "test command must be a non-empty argv array"
43
+ marker = runner_marker(argv)
44
+ if marker is None:
45
+ return "test command runner is not in the Core allowlist"
46
+ toolchain = root / ".planning" / "toolchain.md"
47
+ if not toolchain.is_file():
48
+ return "initialized .planning/toolchain.md is required before Core can execute a test command"
49
+ content = toolchain.read_text(encoding="utf-8").lower()
50
+ aliases = {"pytest": ("pytest",), "unittest": ("unittest",), "bun test": ("bun test",), "npm": ("npm",), "pnpm": ("pnpm",), "yarn": ("yarn",), "go test": ("go test",), "newman": ("newman",), "playwright": ("playwright",), "vitest": ("vitest",)}
51
+ if not any(alias in content for alias in aliases[marker]):
52
+ return f"test runner '{marker}' was not discovered in .planning/toolchain.md"
53
+ return None
@@ -0,0 +1,254 @@
1
+ #!/usr/bin/env python3
2
+ """Fail-closed validation of implementation-task to acceptance-TC bindings.
3
+
4
+ The change plan is drafted *before* CP-3 freezes its TC candidates. Treating
5
+ that draft as if it already pointed at a frozen view creates a circular gate:
6
+ the plan cannot reach the checkpoint which is responsible for freezing it.
7
+
8
+ This checker therefore has two explicit stages:
9
+
10
+ * ``candidate`` validates the complete draft binding against the current,
11
+ unfrozen suite. It is the gate for the change-plan step.
12
+ * ``frozen`` validates the same binding against the Core-owned CP-3 freeze
13
+ attestation and the resulting frozen suite. It is the gate before TDD or
14
+ acceptance execution may claim the binding is usable.
15
+
16
+ The binding keeps its candidate-view pin; the freeze attestation joins that
17
+ immutable draft to the post-freeze view without asking a Skill to rewrite a
18
+ file after the user's approval.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import hashlib
24
+ import json
25
+ import re
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+
30
+ FEATURE_RE = re.compile(r"^[^/\\\x00-\x1f\x7f]+$")
31
+ MODES = {"tdd_required", "contract_first", "integration_only", "manual_justified"}
32
+
33
+
34
+ def read_json(path: Path, errors: list[str], label: str) -> dict[str, Any] | None:
35
+ try:
36
+ value = json.loads(path.read_text(encoding="utf-8"))
37
+ except FileNotFoundError:
38
+ errors.append(f"missing {label}: {path.as_posix()}")
39
+ return None
40
+ except json.JSONDecodeError as error:
41
+ errors.append(f"invalid {label}: {error.msg}")
42
+ return None
43
+ if not isinstance(value, dict):
44
+ errors.append(f"invalid {label}: root must be an object")
45
+ return None
46
+ return value
47
+
48
+
49
+ def file_fingerprint(path: Path) -> str | None:
50
+ try:
51
+ return hashlib.sha256(path.read_bytes()).hexdigest()
52
+ except OSError:
53
+ return None
54
+
55
+
56
+ def valid_argv(value: Any) -> bool:
57
+ return isinstance(value, list) and bool(value) and all(isinstance(token, str) and token and "\x00" not in token for token in value)
58
+
59
+
60
+ def test_case_ids(suite: dict[str, Any]) -> set[str]:
61
+ nodes = suite.get("nodes")
62
+ if not isinstance(nodes, list):
63
+ return set()
64
+ return {
65
+ node["id"]
66
+ for node in nodes
67
+ if isinstance(node, dict) and node.get("kind") == "test_case" and isinstance(node.get("id"), str)
68
+ }
69
+
70
+
71
+ def view_pin(suite: dict[str, Any]) -> dict[str, Any]:
72
+ view_meta = suite.get("view_meta") if isinstance(suite.get("view_meta"), dict) else {}
73
+ return {"graph_revision": suite.get("graph_revision"), "fingerprint": view_meta.get("fingerprint")}
74
+
75
+
76
+ def freeze_attestation_path(root: Path, feature: str) -> Path:
77
+ return root / ".planning" / "features" / feature / "test-suite-freeze.json"
78
+
79
+
80
+ def validated_attestation(
81
+ root: Path,
82
+ feature: str,
83
+ suite: dict[str, Any],
84
+ binding_path: Path,
85
+ binding_pin: dict[str, Any],
86
+ tc_ids: set[str],
87
+ errors: list[str],
88
+ ) -> None:
89
+ attestation = read_json(freeze_attestation_path(root, feature), errors, "TC suite freeze attestation")
90
+ if attestation is None:
91
+ return
92
+ if attestation.get("schema_version") != 1:
93
+ errors.append("TC suite freeze attestation schema_version must be 1")
94
+ if attestation.get("origin") != "core_acceptance_test_suite_freeze":
95
+ errors.append("TC suite freeze attestation origin is invalid")
96
+ if attestation.get("feature") != feature:
97
+ errors.append("TC suite freeze attestation feature does not match requested feature")
98
+ if attestation.get("candidate_test_suite") != binding_pin:
99
+ errors.append("TC suite freeze attestation does not match the binding candidate suite")
100
+ if attestation.get("frozen_test_suite") != view_pin(suite):
101
+ errors.append("TC suite freeze attestation does not match the current frozen TC suite")
102
+ if attestation.get("test_binding_sha256") != file_fingerprint(binding_path):
103
+ errors.append("test bindings changed after CP-3 freeze")
104
+ frozen_ids = attestation.get("frozen_case_ids")
105
+ if not isinstance(frozen_ids, list) or set(frozen_ids) != tc_ids or not all(isinstance(value, str) for value in frozen_ids):
106
+ errors.append("TC suite freeze attestation does not cover exactly the frozen TC cases")
107
+
108
+
109
+ def validate(root: Path, feature: str, stage: str = "frozen") -> dict[str, Any]:
110
+ errors: list[str] = []
111
+ suite_path = root / ".planning" / "requirements" / feature / "tests.json"
112
+ binding_path = root / ".planning" / "features" / feature / "test-bindings.json"
113
+ suite = read_json(suite_path, errors, "TC suite")
114
+ bindings = read_json(binding_path, errors, "test bindings")
115
+ if suite is None or bindings is None:
116
+ return {"ok": False, "errors": errors}
117
+
118
+ tc_ids = test_case_ids(suite)
119
+ if not tc_ids:
120
+ errors.append("TC suite contains no test_case nodes")
121
+ else:
122
+ non_confirmed = sorted(
123
+ node.get("id", "<unknown>")
124
+ for node in suite.get("nodes", [])
125
+ if isinstance(node, dict) and node.get("kind") == "test_case" and node.get("status") != "confirmed"
126
+ )
127
+ confirmed = sorted(tc_ids - set(non_confirmed))
128
+ if stage == "candidate":
129
+ if confirmed:
130
+ errors.append(f"TC candidate suite already contains frozen cases: {', '.join(confirmed)}")
131
+ if suite.get("frozen") is True:
132
+ errors.append("TC candidate suite is marked frozen")
133
+ else:
134
+ if non_confirmed:
135
+ errors.append(f"TC suite is not frozen: {', '.join(non_confirmed)}")
136
+ if suite.get("frozen") is not True:
137
+ errors.append("TC suite is not marked frozen and fresh")
138
+ if bindings.get("schema_version") != 2:
139
+ errors.append("test bindings schema_version must be 2")
140
+ if bindings.get("origin") != "devflow_change_plan":
141
+ errors.append("test bindings origin must be devflow_change_plan")
142
+ if bindings.get("feature") != feature:
143
+ errors.append("test bindings feature does not match requested feature")
144
+
145
+ pin = bindings.get("candidate_test_suite")
146
+ # schema_version=1 shipped only in the unfinished local implementation.
147
+ # Reject it deliberately rather than treating an ambiguous pre-freeze pin
148
+ # as frozen evidence.
149
+ if not isinstance(pin, dict):
150
+ errors.append("candidate_test_suite pin is required")
151
+ else:
152
+ if stage == "candidate" and pin != view_pin(suite):
153
+ errors.append("candidate_test_suite does not match current TC candidate suite")
154
+
155
+ tasks = bindings.get("tasks")
156
+ if not isinstance(tasks, list) or not tasks:
157
+ errors.append("tasks must be a non-empty array")
158
+ tasks = []
159
+ task_ids: set[str] = set()
160
+ covered: set[str] = set()
161
+ for index, task in enumerate(tasks):
162
+ label = f"tasks[{index}]"
163
+ if not isinstance(task, dict):
164
+ errors.append(f"{label} must be an object")
165
+ continue
166
+ task_id = task.get("id")
167
+ if not isinstance(task_id, str) or not task_id:
168
+ errors.append(f"{label}.id is required")
169
+ elif task_id in task_ids:
170
+ errors.append(f"duplicate task id: {task_id}")
171
+ else:
172
+ task_ids.add(task_id)
173
+ mode = task.get("test_mode")
174
+ if mode not in MODES:
175
+ errors.append(f"{label}: invalid test_mode")
176
+ refs = task.get("tc_refs")
177
+ if not isinstance(refs, list) or not refs or not all(isinstance(ref, str) and ref for ref in refs):
178
+ errors.append(f"{label}: tc_refs must be a non-empty string array")
179
+ refs = []
180
+ for ref in refs:
181
+ if ref not in tc_ids:
182
+ errors.append(f"{label}: unknown TC reference {ref}")
183
+ else:
184
+ covered.add(ref)
185
+ tests = task.get("implementation_tests")
186
+ if not isinstance(tests, list) or not tests:
187
+ errors.append(f"{label}: implementation_tests must be a non-empty array")
188
+ elif not all(isinstance(item, dict) and isinstance(item.get("path"), str) and item["path"] for item in tests):
189
+ errors.append(f"{label}: implementation_tests entries require path")
190
+
191
+ if mode == "tdd_required":
192
+ tdd = task.get("tdd")
193
+ if not isinstance(tdd, dict):
194
+ errors.append(f"{label}: tdd is required")
195
+ continue
196
+ for tdd_stage in ("red", "green", "regression"):
197
+ evidence = tdd.get(tdd_stage)
198
+ if not isinstance(evidence, dict):
199
+ errors.append(f"{label}: missing tdd.{tdd_stage}")
200
+ continue
201
+ if not valid_argv(evidence.get("argv")):
202
+ errors.append(f"{label}: tdd.{tdd_stage}.argv must be a non-empty argv array")
203
+ red = tdd.get("red")
204
+ if not isinstance(red, dict) or not isinstance(red.get("expected_failure"), str) or not red["expected_failure"].strip():
205
+ errors.append(f"{label}: tdd.red.expected_failure is required")
206
+ green = tdd.get("green")
207
+ if isinstance(red, dict) and isinstance(green, dict) and valid_argv(red.get("argv")) and valid_argv(green.get("argv")) and red["argv"] != green["argv"]:
208
+ errors.append(f"{label}: tdd.red and tdd.green must use the same test target")
209
+ production_paths = task.get("production_paths")
210
+ if not isinstance(production_paths, list) or not production_paths or not all(isinstance(item, str) and item and not item.startswith(("/", "\\")) and ".." not in item.split("/") for item in production_paths):
211
+ errors.append(f"{label}: tdd_required production_paths must be a non-empty project-relative path array")
212
+ elif mode in MODES:
213
+ reason = task.get("non_tdd_reason")
214
+ if not isinstance(reason, str) or not reason.strip():
215
+ errors.append(f"{label}: non_tdd_reason is required for {mode}")
216
+ if mode == "manual_justified" and (not isinstance(task.get("owner"), str) or not task["owner"].strip()):
217
+ errors.append(f"{label}: manual_justified requires owner")
218
+
219
+ suite_label = "candidate" if stage == "candidate" else "frozen"
220
+ for tc_id in sorted(tc_ids - covered):
221
+ errors.append(f"uncovered {suite_label} TC: {tc_id}")
222
+
223
+ if stage == "frozen" and isinstance(pin, dict):
224
+ validated_attestation(root, feature, suite, binding_path, pin, tc_ids, errors)
225
+ return {
226
+ "ok": not errors,
227
+ "feature": feature,
228
+ "stage": stage,
229
+ "covered_tc_ids": sorted(covered),
230
+ "tc_ids": sorted(tc_ids),
231
+ "candidate_test_suite": pin if isinstance(pin, dict) else None,
232
+ "binding_sha256": file_fingerprint(binding_path),
233
+ "errors": errors,
234
+ }
235
+
236
+
237
+ def main() -> int:
238
+ parser = argparse.ArgumentParser()
239
+ parser.add_argument("--feature", required=True)
240
+ # ``recovery`` is Core-only: it lets advance_state repair an interrupted
241
+ # CP-3 after it can independently prove every TC was frozen by that exact
242
+ # checkpoint. It deliberately cannot be selected by plugin tools.
243
+ parser.add_argument("--stage", choices=("candidate", "frozen", "recovery"), default="frozen")
244
+ parser.add_argument("--json", action="store_true")
245
+ args = parser.parse_args()
246
+ if not FEATURE_RE.fullmatch(args.feature):
247
+ print(json.dumps({"ok": False, "errors": ["invalid feature"]}, ensure_ascii=False))
248
+ return 0
249
+ print(json.dumps(validate(Path.cwd().resolve(), args.feature, args.stage), ensure_ascii=False, indent=2))
250
+ return 0
251
+
252
+
253
+ if __name__ == "__main__":
254
+ raise SystemExit(main())