@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.
- package/README.md +19 -0
- package/package.json +3 -3
- package/plugins/devflow-core/extensions/devflow-core.ts +152 -3
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_runner.py +394 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_suite.rb +86 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +192 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/api_test_matrix.py +829 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +7 -5
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/init_state.rb +11 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/inspect_state.rb +5 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +39 -3
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +309 -23
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/tdd_evidence.py +311 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/test_command_policy.py +53 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/test_task_binding_check.py +254 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +109 -4
- package/plugins/devflow-core/skills/workflow-orchestrator/templates/uat-remediation.yaml +21 -1
- package/plugins/devflow-core/skills/workflow-project-context/SKILL.md +17 -0
- package/plugins/devflow-design/skills/workflow-api-contract/SKILL.md +38 -0
- package/plugins/devflow-implementation/agents/implementation-worker.md +3 -0
- package/plugins/devflow-implementation/skills/workflow-change-plan/SKILL.md +7 -0
- package/plugins/devflow-implementation/skills/workflow-change-plan/references/output-template.md +2 -0
- package/plugins/devflow-implementation/skills/workflow-execute-phase/SKILL.md +3 -3
- package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +63 -0
- package/plugins/devflow-quality/skills/workflow-acceptance-test-design/agents/openai.yaml +4 -0
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/SKILL.md +7 -6
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/intermediate-format.md +3 -0
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/compile_dsl.py +49 -3
- package/plugins/devflow-quality/skills/workflow-run-acceptance-tests/SKILL.md +48 -0
- package/plugins/devflow-quality/skills/workflow-run-acceptance-tests/agents/openai.yaml +4 -0
- package/plugins/devflow-quality/skills/workflow-verify-work/SKILL.md +9 -3
- package/src/constants.ts +1 -1
- package/src/doctor.ts +6 -2
- package/src/project-context.ts +11 -1
- package/src/runtime.ts +7 -0
- 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)
|