@guanlin4924/devflow-pi 0.1.19 → 0.1.21

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 (36) hide show
  1. package/README.md +19 -0
  2. package/package.json +3 -3
  3. package/plugins/devflow-core/extensions/devflow-core.ts +152 -3
  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-core/skills/workflow-project-context/SKILL.md +17 -0
  19. package/plugins/devflow-design/skills/workflow-api-contract/SKILL.md +38 -0
  20. package/plugins/devflow-implementation/agents/implementation-worker.md +3 -0
  21. package/plugins/devflow-implementation/skills/workflow-change-plan/SKILL.md +7 -0
  22. package/plugins/devflow-implementation/skills/workflow-change-plan/references/output-template.md +2 -0
  23. package/plugins/devflow-implementation/skills/workflow-execute-phase/SKILL.md +3 -3
  24. package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +63 -0
  25. package/plugins/devflow-quality/skills/workflow-acceptance-test-design/agents/openai.yaml +4 -0
  26. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/SKILL.md +7 -6
  27. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/intermediate-format.md +3 -0
  28. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/compile_dsl.py +49 -3
  29. package/plugins/devflow-quality/skills/workflow-run-acceptance-tests/SKILL.md +48 -0
  30. package/plugins/devflow-quality/skills/workflow-run-acceptance-tests/agents/openai.yaml +4 -0
  31. package/plugins/devflow-quality/skills/workflow-verify-work/SKILL.md +9 -3
  32. package/src/constants.ts +1 -1
  33. package/src/doctor.ts +6 -2
  34. package/src/project-context.ts +11 -1
  35. package/src/runtime.ts +7 -0
  36. package/src/usage-stats.ts +3 -3
@@ -0,0 +1,394 @@
1
+ #!/usr/bin/env python3
2
+ """Execute only adapters bound to a frozen TC suite; never derive scope from delivery prose."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import hashlib
7
+ import json
8
+ import os
9
+ import re
10
+ import subprocess
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+ from typing import Any
14
+ from urllib.error import HTTPError, URLError
15
+ from urllib.parse import urlencode, urljoin
16
+ from urllib.request import Request, urlopen
17
+
18
+ from test_command_policy import validate_test_command
19
+
20
+
21
+ FEATURE_RE = re.compile(r"^[^/\\\x00-\x1f\x7f]+$")
22
+ AUTOMATION_MODES = {"automated", "manual", "blocked"}
23
+
24
+
25
+ def utc_now() -> str:
26
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
27
+
28
+
29
+ def digest(value: str | bytes) -> str:
30
+ if isinstance(value, str):
31
+ value = value.encode("utf-8")
32
+ return hashlib.sha256(value).hexdigest()
33
+
34
+
35
+ def read_json(path: Path, errors: list[str], label: str) -> dict[str, Any] | None:
36
+ try:
37
+ value = json.loads(path.read_text(encoding="utf-8"))
38
+ except FileNotFoundError:
39
+ errors.append(f"missing {label}: {path.as_posix()}")
40
+ return None
41
+ except json.JSONDecodeError as error:
42
+ errors.append(f"invalid {label}: {error.msg}")
43
+ return None
44
+ if not isinstance(value, dict):
45
+ errors.append(f"invalid {label}: root must be an object")
46
+ return None
47
+ return value
48
+
49
+
50
+ def write_json(path: Path, value: dict[str, Any]) -> None:
51
+ path.parent.mkdir(parents=True, exist_ok=True)
52
+ temporary = path.with_name(f".{path.name}.tmp")
53
+ temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
54
+ temporary.replace(path)
55
+
56
+
57
+ def valid_argv(value: Any) -> bool:
58
+ return isinstance(value, list) and bool(value) and all(isinstance(token, str) and token and "\x00" not in token for token in value)
59
+
60
+
61
+ ENV_NAME_RE = re.compile(r"^[A-Z_][A-Z0-9_]*$")
62
+
63
+
64
+ def valid_http_adapter(value: Any) -> bool:
65
+ if not isinstance(value, dict) or value.get("kind") != "http" or not isinstance(value.get("base_url_env"), str) or not ENV_NAME_RE.fullmatch(value["base_url_env"]):
66
+ return False
67
+ headers_env = value.get("headers_env", {})
68
+ if not isinstance(headers_env, dict) or not all(isinstance(header, str) and header and isinstance(env, str) and ENV_NAME_RE.fullmatch(env) for header, env in headers_env.items()):
69
+ return False
70
+ setup = value.get("setup")
71
+ return setup is None or (isinstance(setup, dict) and valid_argv(setup.get("argv")))
72
+
73
+
74
+ def pointer_value(document: Any, pointer: str) -> Any:
75
+ if pointer == "":
76
+ return document
77
+ if not isinstance(pointer, str) or not pointer.startswith("/"):
78
+ raise ValueError("error_code_pointer must be an RFC 6901 JSON pointer")
79
+ current = document
80
+ for token in pointer[1:].split("/"):
81
+ token = token.replace("~1", "/").replace("~0", "~")
82
+ if isinstance(current, dict) and token in current:
83
+ current = current[token]
84
+ elif isinstance(current, list) and token.isdigit() and int(token) < len(current):
85
+ current = current[int(token)]
86
+ else:
87
+ raise KeyError(pointer)
88
+ return current
89
+
90
+
91
+ def http_execution_error(source: dict[str, Any]) -> str | None:
92
+ execution = source.get("execution")
93
+ if not isinstance(execution, dict) or execution.get("protocol") != "http":
94
+ return "frozen TC has no HTTP execution definition"
95
+ request = execution.get("request")
96
+ expect = execution.get("expect")
97
+ if not isinstance(request, dict) or not isinstance(expect, dict):
98
+ return "frozen TC HTTP execution requires request and expect"
99
+ if not isinstance(request.get("method"), str) or not re.fullmatch(r"[A-Z]+", request["method"]):
100
+ return "frozen TC HTTP request method is invalid"
101
+ if not isinstance(request.get("path"), str) or not request["path"].startswith("/"):
102
+ return "frozen TC HTTP request path is invalid"
103
+ if not isinstance(expect.get("http_status"), int) or not 100 <= expect["http_status"] <= 599:
104
+ return "frozen TC HTTP expectation http_status is invalid"
105
+ for key in ("path_params", "query", "headers", "body"):
106
+ if key in request and not isinstance(request[key], dict):
107
+ return f"frozen TC HTTP request {key} must be an object"
108
+ if "error_code" in expect and (not isinstance(expect["error_code"], str) or not expect["error_code"].strip()):
109
+ return "frozen TC HTTP expectation error_code is invalid"
110
+ if "error_code_pointer" in expect and (not isinstance(expect["error_code_pointer"], str) or not expect["error_code_pointer"].startswith("/")):
111
+ return "frozen TC HTTP expectation error_code_pointer is invalid"
112
+ return None
113
+
114
+
115
+ def frozen_cases(suite: dict[str, Any], errors: list[str]) -> dict[str, dict[str, Any]]:
116
+ cases: dict[str, dict[str, Any]] = {}
117
+ for node in suite.get("nodes", []):
118
+ if not isinstance(node, dict) or node.get("kind") != "test_case":
119
+ continue
120
+ tc_id = node.get("id")
121
+ data = node.get("data")
122
+ if not isinstance(tc_id, str) or not isinstance(data, dict):
123
+ errors.append("invalid test_case node in TC suite")
124
+ continue
125
+ if node.get("status") != "confirmed" or node.get("stale") is True:
126
+ errors.append(f"TC is not frozen and fresh: {tc_id}")
127
+ cases[tc_id] = data
128
+ if not cases:
129
+ errors.append("TC suite contains no test_case nodes")
130
+ return cases
131
+
132
+
133
+ def paths(root: Path, feature: str) -> tuple[Path, Path, Path]:
134
+ return (
135
+ root / ".planning" / "requirements" / feature / "tests.json",
136
+ root / ".planning" / "features" / feature / "acceptance-test-bindings.json",
137
+ root / ".planning" / "test-runs" / feature / "acceptance-results.json",
138
+ )
139
+
140
+
141
+ def file_fingerprint(path: Path) -> str | None:
142
+ return digest(path.read_bytes()) if path.is_file() else None
143
+
144
+
145
+ def run_http_case(source: dict[str, Any], adapter: dict[str, Any]) -> tuple[bool, int, str]:
146
+ execution = source["execution"]
147
+ request_spec = execution["request"]
148
+ expect = execution["expect"]
149
+ base_url = os.environ.get(adapter["base_url_env"])
150
+ if not base_url:
151
+ return False, 127, f"required test base URL environment variable is unset: {adapter['base_url_env']}"
152
+ setup_output = ""
153
+ setup = adapter.get("setup")
154
+ if isinstance(setup, dict):
155
+ try:
156
+ completed = subprocess.run(setup["argv"], text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180, check=False)
157
+ setup_output = f"setup exit={completed.returncode}: {completed.stdout}\n{completed.stderr}"
158
+ if completed.returncode != 0:
159
+ return False, completed.returncode, setup_output
160
+ except subprocess.TimeoutExpired as error:
161
+ return False, 124, f"setup command timed out: {error}"
162
+ except OSError as error:
163
+ return False, 127, f"setup command could not start: {error}"
164
+ path = request_spec["path"]
165
+ for key, value in request_spec.get("path_params", {}).items():
166
+ path = path.replace("{" + str(key) + "}", str(value)).replace(":" + str(key), str(value))
167
+ query = request_spec.get("query", {})
168
+ url = urljoin(base_url.rstrip("/") + "/", path.lstrip("/"))
169
+ if query:
170
+ url += ("&" if "?" in url else "?") + urlencode(query, doseq=True)
171
+ headers = {str(key): str(value) for key, value in request_spec.get("headers", {}).items()}
172
+ for header, env_name in adapter.get("headers_env", {}).items():
173
+ value = os.environ.get(env_name)
174
+ if not value:
175
+ return False, 127, f"required test header environment variable is unset: {env_name}"
176
+ headers[header] = value
177
+ body = request_spec.get("body")
178
+ payload: bytes | None = None
179
+ if body is not None:
180
+ payload = json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
181
+ headers.setdefault("Content-Type", "application/json")
182
+ request = Request(url, data=payload, headers=headers, method=request_spec["method"])
183
+ try:
184
+ with urlopen(request, timeout=20) as response:
185
+ http_status = response.status
186
+ output = response.read().decode("utf-8", errors="replace")
187
+ except HTTPError as error:
188
+ http_status = error.code
189
+ output = error.read().decode("utf-8", errors="replace")
190
+ except (URLError, OSError) as error:
191
+ return False, 127, f"{setup_output}\nHTTP adapter could not complete request: {error}"
192
+ expected_status = expect["http_status"]
193
+ if http_status != expected_status:
194
+ return False, 1, f"{setup_output}\nexpected HTTP {expected_status}, got {http_status}; body={output[:1000]}"
195
+ if "error_code" in expect:
196
+ try:
197
+ body_json = json.loads(output)
198
+ actual_code = pointer_value(body_json, expect.get("error_code_pointer", "/error/code"))
199
+ except (json.JSONDecodeError, KeyError, ValueError) as error:
200
+ return False, 1, f"{setup_output}\ncould not read expected error code from response: {error}; body={output[:1000]}"
201
+ if actual_code != expect["error_code"]:
202
+ return False, 1, f"{setup_output}\nexpected error code {expect['error_code']}, got {actual_code!r}; body={output[:1000]}"
203
+ return True, 0, f"{setup_output}\nHTTP {http_status} matched expectation"
204
+
205
+
206
+ def validate(root: Path, feature: str) -> tuple[dict[str, Any], dict[str, Any] | None, dict[str, Any] | None]:
207
+ errors: list[str] = []
208
+ suite_path, binding_path, _ = paths(root, feature)
209
+ suite = read_json(suite_path, errors, "TC suite")
210
+ bindings = read_json(binding_path, errors, "acceptance-test bindings")
211
+ if suite is None or bindings is None:
212
+ return {"ok": False, "feature": feature, "errors": errors}, suite, bindings
213
+ cases = frozen_cases(suite, errors)
214
+ if bindings.get("schema_version") != 1:
215
+ errors.append("acceptance-test bindings schema_version must be 1")
216
+ if bindings.get("origin") != "devflow_acceptance_test_execution":
217
+ errors.append("acceptance-test bindings origin must be devflow_acceptance_test_execution")
218
+ if bindings.get("feature") != feature:
219
+ errors.append("acceptance-test bindings feature does not match requested feature")
220
+ pin = bindings.get("test_suite")
221
+ meta = suite.get("view_meta") if isinstance(suite.get("view_meta"), dict) else {}
222
+ if not isinstance(pin, dict):
223
+ errors.append("test_suite pin is required")
224
+ else:
225
+ if pin.get("graph_revision") != suite.get("graph_revision"):
226
+ errors.append("test_suite.graph_revision does not match current TC suite")
227
+ if pin.get("fingerprint") != meta.get("fingerprint"):
228
+ errors.append("test_suite.fingerprint does not match current TC suite")
229
+
230
+ entries = bindings.get("cases")
231
+ if not isinstance(entries, list) or not entries:
232
+ errors.append("cases must be a non-empty array")
233
+ entries = []
234
+ seen: set[str] = set()
235
+ for index, entry in enumerate(entries):
236
+ label = f"cases[{index}]"
237
+ if not isinstance(entry, dict):
238
+ errors.append(f"{label} must be an object")
239
+ continue
240
+ tc_id = entry.get("tc_id")
241
+ if not isinstance(tc_id, str) or not tc_id:
242
+ errors.append(f"{label}.tc_id is required")
243
+ continue
244
+ if tc_id in seen:
245
+ errors.append(f"duplicate acceptance-test binding: {tc_id}")
246
+ seen.add(tc_id)
247
+ source = cases.get(tc_id)
248
+ if source is None:
249
+ errors.append(f"unknown TC reference: {tc_id}")
250
+ continue
251
+ mode = entry.get("mode")
252
+ if mode not in AUTOMATION_MODES:
253
+ errors.append(f"{label}.mode is invalid")
254
+ continue
255
+ if mode != source.get("automation_mode"):
256
+ errors.append(f"{label}.mode must match frozen TC automation_mode")
257
+ if mode == "automated":
258
+ adapter = entry.get("adapter")
259
+ if not isinstance(adapter, dict):
260
+ errors.append(f"{label}.adapter is required")
261
+ elif adapter.get("kind") == "command":
262
+ if not valid_argv(adapter.get("argv")):
263
+ errors.append(f"{label}.adapter command requires non-empty argv")
264
+ continue
265
+ command_error = validate_test_command(root, adapter["argv"])
266
+ if command_error:
267
+ errors.append(f"{label}.adapter: {command_error}")
268
+ elif adapter.get("kind") == "http":
269
+ if not valid_http_adapter(adapter):
270
+ errors.append(f"{label}.adapter HTTP requires base_url_env, valid optional headers_env, and optional setup.argv")
271
+ else:
272
+ execution_error = http_execution_error(source)
273
+ if execution_error:
274
+ errors.append(f"{label}.adapter: {execution_error}")
275
+ if isinstance(adapter.get("setup"), dict):
276
+ setup_error = validate_test_command(root, adapter["setup"]["argv"])
277
+ if setup_error:
278
+ errors.append(f"{label}.adapter.setup: {setup_error}")
279
+ else:
280
+ errors.append(f"{label}.adapter kind must be command or http")
281
+ elif mode == "manual":
282
+ if not isinstance(entry.get("owner"), str) or not entry["owner"].strip() or not isinstance(entry.get("reason"), str) or not entry["reason"].strip():
283
+ errors.append(f"{label}.manual requires owner and reason")
284
+ elif mode == "blocked":
285
+ if not isinstance(entry.get("blocked_by"), str) or not entry["blocked_by"].strip() or not isinstance(entry.get("owner"), str) or not entry["owner"].strip():
286
+ errors.append(f"{label}.blocked requires blocked_by and owner")
287
+ for tc_id in sorted(set(cases) - seen):
288
+ errors.append(f"unbound frozen TC: {tc_id}")
289
+ return {"ok": not errors, "feature": feature, "tc_ids": sorted(cases), "errors": errors}, suite, bindings
290
+
291
+
292
+ def run(root: Path, feature: str, write: bool) -> dict[str, Any]:
293
+ validation, suite, bindings = validate(root, feature)
294
+ if not validation["ok"]:
295
+ return validation
296
+ assert suite is not None and bindings is not None
297
+ cases = frozen_cases(suite, [])
298
+ _, _, result_path = paths(root, feature)
299
+ previous = read_json(result_path, [], "previous acceptance-test results")
300
+ task_bindings_path = root / ".planning" / "features" / feature / "test-bindings.json"
301
+ execution_bindings_path = root / ".planning" / "features" / feature / "acceptance-test-bindings.json"
302
+ results: list[dict[str, Any]] = []
303
+ for entry in bindings["cases"]:
304
+ tc_id = entry["tc_id"]
305
+ mode = entry["mode"]
306
+ if mode == "automated":
307
+ started_at = utc_now()
308
+ adapter = entry["adapter"]
309
+ if adapter["kind"] == "command":
310
+ argv = adapter["argv"]
311
+ try:
312
+ completed = subprocess.run(argv, cwd=root, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180, check=False)
313
+ exit_code = completed.returncode
314
+ output = f"{completed.stdout}\n{completed.stderr}"
315
+ except subprocess.TimeoutExpired as error:
316
+ exit_code = 124
317
+ output = f"command timed out: {error}"
318
+ except OSError as error:
319
+ exit_code = 127
320
+ output = f"command could not start: {error}"
321
+ result = {"tc_id": tc_id, "status": "passed" if exit_code == 0 else "failed", "mode": mode, "adapter_kind": "command", "argv": argv,
322
+ "command_hash": digest(json.dumps(argv, ensure_ascii=False, separators=(",", ":"))), "started_at": started_at,
323
+ "finished_at": utc_now(), "exit_code": exit_code, "output_sha256": digest(output)}
324
+ else:
325
+ passed, exit_code, output = run_http_case(cases[tc_id], adapter)
326
+ result = {"tc_id": tc_id, "status": "passed" if passed else "failed", "mode": mode, "adapter_kind": "http",
327
+ "adapter_fingerprint": digest(json.dumps(adapter, ensure_ascii=False, separators=(",", ":"))), "started_at": started_at,
328
+ "finished_at": utc_now(), "exit_code": exit_code, "output_sha256": digest(output)}
329
+ results.append(result)
330
+ elif mode == "manual":
331
+ results.append({"tc_id": tc_id, "status": "blocked", "mode": mode, "owner": entry["owner"], "reason": entry["reason"],
332
+ "blocked_by": "manual execution has no Core-observed result"})
333
+ else:
334
+ results.append({"tc_id": tc_id, "status": "blocked", "mode": mode, "owner": entry["owner"], "blocked_by": entry["blocked_by"]})
335
+ all_passed = all(item["status"] == "passed" for item in results)
336
+ history = previous.get("history", []) if isinstance(previous, dict) and isinstance(previous.get("history"), list) else []
337
+ if isinstance(previous, dict) and isinstance(previous.get("results"), list):
338
+ history = [*history, {"executed_at": previous.get("executed_at"), "passed": previous.get("passed"), "test_suite": previous.get("test_suite"),
339
+ "implementation_task_bindings": previous.get("implementation_task_bindings"), "execution_binding_fingerprint": previous.get("execution_binding_fingerprint"),
340
+ "results": previous["results"]}]
341
+ payload = {"schema_version": 1, "origin": "core_acceptance_test_runner", "feature": feature, "executed_at": utc_now(),
342
+ "test_suite": bindings["test_suite"], "implementation_task_bindings": {"path": str(task_bindings_path.relative_to(root)), "fingerprint": file_fingerprint(task_bindings_path)},
343
+ "execution_binding_fingerprint": file_fingerprint(execution_bindings_path), "results": results, "passed": all_passed, "history": history[-20:]}
344
+ if write:
345
+ write_json(result_path, payload)
346
+ return {"ok": all_passed, "feature": feature, "path": str(result_path.relative_to(root)), "results": results,
347
+ "errors": [] if all_passed else ["one or more frozen TC cases failed or are blocked"]}
348
+
349
+
350
+ def validate_results(root: Path, feature: str) -> dict[str, Any]:
351
+ validation, suite, _ = validate(root, feature)
352
+ errors = list(validation["errors"])
353
+ _, _, result_path = paths(root, feature)
354
+ results = read_json(result_path, errors, "acceptance-test results")
355
+ if suite is None or results is None:
356
+ return {"ok": False, "feature": feature, "errors": errors}
357
+ if results.get("origin") != "core_acceptance_test_runner" or results.get("feature") != feature:
358
+ errors.append("acceptance-test results identity is invalid")
359
+ meta = suite.get("view_meta") if isinstance(suite.get("view_meta"), dict) else {}
360
+ if results.get("test_suite") != {"graph_revision": suite.get("graph_revision"), "fingerprint": meta.get("fingerprint")}:
361
+ errors.append("acceptance-test results are pinned to a stale TC suite")
362
+ expected = set(validation.get("tc_ids", []))
363
+ result_entries = results.get("results") if isinstance(results.get("results"), list) else []
364
+ actual = {item.get("tc_id") for item in result_entries if isinstance(item, dict) and isinstance(item.get("tc_id"), str)}
365
+ if actual != expected:
366
+ errors.append("acceptance-test results do not cover exactly the frozen TC suite")
367
+ failed = sorted(item.get("tc_id", "<unknown>") for item in result_entries if not isinstance(item, dict) or item.get("status") != "passed")
368
+ if failed:
369
+ errors.append(f"frozen TC cases not passed: {', '.join(failed)}")
370
+ return {"ok": not errors, "feature": feature, "errors": errors, "result_path": str(result_path.relative_to(root))}
371
+
372
+
373
+ def main() -> int:
374
+ parser = argparse.ArgumentParser()
375
+ parser.add_argument("action", choices=("validate", "run", "validate-results"))
376
+ parser.add_argument("--feature", required=True)
377
+ parser.add_argument("--write", action="store_true")
378
+ parser.add_argument("--json", action="store_true")
379
+ args = parser.parse_args()
380
+ root = Path.cwd().resolve()
381
+ if not FEATURE_RE.fullmatch(args.feature):
382
+ result = {"ok": False, "errors": ["invalid feature"]}
383
+ elif args.action == "validate":
384
+ result, _, _ = validate(root, args.feature)
385
+ elif args.action == "run":
386
+ result = run(root, args.feature, args.write) if args.write else {"ok": False, "errors": ["--write is required for run"]}
387
+ else:
388
+ result = validate_results(root, args.feature)
389
+ print(json.dumps(result, ensure_ascii=False, indent=2))
390
+ return 0
391
+
392
+
393
+ if __name__ == "__main__":
394
+ raise SystemExit(main())
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Core-owned freeze point for a feature's derived TC suite. A skill may propose
5
+ # individual candidates, but it cannot turn them into evidence by confirming
6
+ # them one by one: this command snapshots the complete, fresh suite under CP-3.
7
+ require "json"
8
+ require "open3"
9
+ require "optparse"
10
+ require_relative "control_io"
11
+ require_relative "requirement_graph"
12
+
13
+ options = { feature: nil, expected_graph_revision: nil, cp_record: nil, write: false, json: false }
14
+ command = ARGV.shift
15
+ OptionParser.new do |parser|
16
+ parser.on("--feature NAME") { |value| options[:feature] = value }
17
+ parser.on("--expected-graph-revision REVISION", Integer) { |value| options[:expected_graph_revision] = value }
18
+ parser.on("--cp-record RECORD") { |value| options[:cp_record] = value }
19
+ parser.on("--write") { options[:write] = true }
20
+ parser.on("--json") { options[:json] = true }
21
+ end.parse!
22
+
23
+ def test_nodes(graph)
24
+ Array(graph["nodes"]).select { |node| node.is_a?(Hash) && node["kind"] == "test_case" }
25
+ end
26
+
27
+ def api_test_matrix_check(root, feature, graph)
28
+ return { "ok" => true, "skipped" => "no_api_nodes" } unless Array(graph["nodes"]).any? { |node| node.is_a?(Hash) && node["kind"] == "api" }
29
+
30
+ script = File.join(__dir__, "api_test_matrix.py")
31
+ stdout, stderr, status = Open3.capture3("python3", script, "validate", "--feature", feature, "--json", chdir: root)
32
+ payload = JSON.parse(stdout)
33
+ return payload if status.success? && payload.is_a?(Hash)
34
+
35
+ { "ok" => false, "errors" => [stderr.strip.empty? ? "API test matrix checker returned invalid output" : stderr.strip] }
36
+ rescue JSON::ParserError
37
+ { "ok" => false, "errors" => ["API test matrix checker returned invalid JSON"] }
38
+ end
39
+
40
+ root = Dir.pwd
41
+ lock = nil
42
+ begin
43
+ raise ArgumentError, "command must be inspect or freeze" unless %w[inspect freeze].include?(command)
44
+ raise ArgumentError, "--feature is required" unless RequirementGraph.valid_feature?(options[:feature])
45
+ lock = DevflowControlIO.acquire_project_lock(root) if command == "freeze"
46
+ graph, path, relative = RequirementGraph.load_graph(root, options[:feature])
47
+ cases = test_nodes(graph)
48
+
49
+ if command == "inspect"
50
+ view = RequirementGraph.view_payload(graph, "tests")
51
+ result = {
52
+ "ok" => true,
53
+ "path" => relative,
54
+ "graph_revision" => graph["revision"],
55
+ "test_case_ids" => cases.map { |node| node["id"] },
56
+ "frozen" => view["frozen"],
57
+ "frozen_graph_revision" => view["frozen_graph_revision"],
58
+ "stale" => Array(graph["stale"]).select { |entry| cases.any? { |node| node["id"] == entry["node_id"] } }
59
+ }
60
+ else
61
+ raise ArgumentError, "--write is required" unless options[:write]
62
+ raise ArgumentError, "--expected-graph-revision is required" if options[:expected_graph_revision].nil?
63
+ raise ArgumentError, "--cp-record is required" if options[:cp_record].to_s.strip.empty?
64
+ if graph["revision"] != options[:expected_graph_revision]
65
+ result = { "ok" => false, "reason" => "stale_graph_revision", "expected_revision" => options[:expected_graph_revision], "actual_revision" => graph["revision"] }
66
+ elsif cases.empty?
67
+ result = { "ok" => false, "reason" => "test_suite_empty", "message" => "cannot freeze an empty TC suite" }
68
+ elsif cases.any? { |node| node["status"] == "confirmed" }
69
+ result = { "ok" => false, "reason" => "test_suite_already_frozen", "message" => "confirmed test cases require a new candidate suite" }
70
+ elsif Array(graph["stale"]).any?
71
+ result = { "ok" => false, "reason" => "test_suite_graph_stale", "message" => "cannot freeze TC suite while the requirement graph has stale downstream nodes" }
72
+ else
73
+ matrix = api_test_matrix_check(root, options[:feature], graph)
74
+ result = if matrix["ok"]
75
+ RequirementGraph.freeze_test_cases!(root, graph, options[:cp_record])
76
+ else
77
+ { "ok" => false, "reason" => "api_test_matrix_incomplete", "message" => Array(matrix["errors"]).join("; "), "matrix" => matrix }
78
+ end
79
+ end
80
+ end
81
+ rescue ArgumentError, JSON::ParserError => error
82
+ result = { "ok" => false, "reason" => "acceptance_test_suite_invalid", "message" => error.message }
83
+ ensure
84
+ DevflowControlIO.release_lock(lock)
85
+ end
86
+ puts JSON.pretty_generate(result)