@guanlin4924/devflow-pi 0.1.95 → 0.1.97
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 +42 -2
- package/package.json +2 -1
- package/plugins/devflow-core/extensions/devflow-core.ts +679 -91
- package/plugins/devflow-core/runtime/hooks/hook_common.py +245 -0
- package/plugins/devflow-core/runtime/hooks/index_gate.py +18 -0
- package/plugins/devflow-core/skills/wireframe-render/SKILL.md +1 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_runner.py +98 -2
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_suite.rb +6 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +64 -8
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_coverage.py +3 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_draft.py +53 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_v1.py +52 -2
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +2 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/explore_preflight.py +58 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/init_workflow.rb +1 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/locator_map.py +156 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +1 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/playwright_adapter.py +1 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +50 -15
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/session_rollover.rb +60 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/startup_preflight.py +167 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +5 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_render.py +262 -56
- package/plugins/devflow-core/skills/workflow-orchestrator/templates/regression-acceptance.yaml +126 -0
- package/plugins/devflow-core/skills/workflow-pipeline/SKILL.md +2 -0
- package/plugins/devflow-intel/extensions/devflow-intel.ts +1 -1
- package/plugins/devflow-quality/runtime/playwright/devflow-test-dsl-v1.schema.json +103 -88
- package/plugins/devflow-quality/runtime/playwright/explore-host.mjs +879 -0
- package/plugins/devflow-quality/runtime/playwright/runner.spec.mjs +297 -15
- package/plugins/devflow-quality/runtime/playwright/runtime-manifest.json +3 -2
- package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +4 -0
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/SKILL.md +10 -2
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/intermediate-format.md +6 -2
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/project-dsl-contract.md +3 -1
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/ui-mainflow-rules.md +4 -2
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/compile_dsl.py +98 -15
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/locator_report.py +73 -0
- package/plugins/devflow-quality/skills/workflow-human-acceptance/SKILL.md +3 -1
- package/plugins/devflow-quality/skills/workflow-regression-test-design/SKILL.md +69 -0
- package/plugins/devflow-quality/skills/workflow-release-closeout/SKILL.md +1 -1
- package/plugins/devflow-quality/skills/workflow-run-acceptance-tests/SKILL.md +4 -2
- package/src/cli.ts +10 -0
- package/src/constants.ts +1 -1
- package/src/doctor.ts +9 -0
- package/src/mcp/auth.ts +103 -0
- package/src/mcp/core.ts +102 -0
- package/src/mcp/server.ts +193 -0
- package/src/mcp/types.ts +5 -0
- package/src/startup-briefing.ts +109 -0
|
@@ -46,6 +46,9 @@ def _count(data: dict[str, Any], key: str) -> int:
|
|
|
46
46
|
|
|
47
47
|
def check(tc_data: dict[str, Any], dsl: dict[str, Any]) -> list[str]:
|
|
48
48
|
errors: list[str] = []
|
|
49
|
+
for _phase, path, step in all_steps(dsl):
|
|
50
|
+
if step.get("action") == "assert_each_text" and step.get("min_count", 1) < 1:
|
|
51
|
+
errors.append(f"{path}.min_count must be >= 1")
|
|
49
52
|
counts = {"preconditions": _count(tc_data, "preconditions"), "steps": _count(tc_data, "steps")}
|
|
50
53
|
covered: dict[str, set[int]] = {"preconditions": set(), "steps": set()}
|
|
51
54
|
oracle = tc_data.get("oracle")
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Validate an unapproved DSL draft without making it executable."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from dsl_coverage import check as dsl_coverage_check
|
|
10
|
+
from dsl_v1 import validate as validate_dsl
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
MAX_INPUT_BYTES = 2 * 1024 * 1024
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main() -> int:
|
|
17
|
+
raw = sys.stdin.buffer.read(MAX_INPUT_BYTES + 1)
|
|
18
|
+
if len(raw) > MAX_INPUT_BYTES:
|
|
19
|
+
result = {"ok": False, "errors": ["input exceeds 2 MiB"], "warnings": [],
|
|
20
|
+
"eligible_for_execution": False,
|
|
21
|
+
"note": "Draft validation never grants execution eligibility."}
|
|
22
|
+
else:
|
|
23
|
+
parse_error: str | None = None
|
|
24
|
+
try:
|
|
25
|
+
payload = json.loads(raw.decode("utf-8"))
|
|
26
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
27
|
+
payload = None
|
|
28
|
+
parse_error = f"input must be UTF-8 JSON: {error}"
|
|
29
|
+
errors: list[str] = []
|
|
30
|
+
if payload is None:
|
|
31
|
+
errors.append(parse_error or "input must be a JSON object")
|
|
32
|
+
elif not isinstance(payload, dict):
|
|
33
|
+
errors.append("input must be an object")
|
|
34
|
+
else:
|
|
35
|
+
dsl = payload.get("dsl")
|
|
36
|
+
tc_data = payload.get("tc_data")
|
|
37
|
+
if not isinstance(dsl, dict):
|
|
38
|
+
errors.append("dsl must be an object")
|
|
39
|
+
else:
|
|
40
|
+
errors.extend(f"DSL is invalid: {error}" for error in validate_dsl(dsl))
|
|
41
|
+
if not isinstance(tc_data, dict):
|
|
42
|
+
errors.append("tc_data must be an object")
|
|
43
|
+
elif isinstance(dsl, dict) and not validate_dsl(dsl):
|
|
44
|
+
errors.extend(f"DSL coverage: {error}" for error in dsl_coverage_check(tc_data, dsl))
|
|
45
|
+
result = {"ok": not errors, "errors": errors, "warnings": [],
|
|
46
|
+
"eligible_for_execution": False,
|
|
47
|
+
"note": "Draft validation never grants execution eligibility."}
|
|
48
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
49
|
+
return 0 if result["ok"] else 1
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
if __name__ == "__main__":
|
|
53
|
+
raise SystemExit(main())
|
|
@@ -28,7 +28,14 @@ BUILTIN_VARIABLES = frozenset({"execution_id", "namespace"})
|
|
|
28
28
|
VARIABLE_REF_RE = re.compile(r"\$\{([^}]*)\}")
|
|
29
29
|
VARIABLE_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,63}$")
|
|
30
30
|
SECRET_HEADERS = frozenset({"authorization", "proxy-authorization", "cookie", "x-api-key", "x-auth-token"})
|
|
31
|
-
|
|
31
|
+
FRAGILE_CSS_PATTERNS = (
|
|
32
|
+
re.compile(r"el-table_\d+(?:_column_\d+)?"),
|
|
33
|
+
re.compile(r":nth-(?:last-)?(?:child|of-type)\("),
|
|
34
|
+
re.compile(r"\[data-v-[0-9a-fA-F]{8}\]"),
|
|
35
|
+
re.compile(r"[A-Za-z]+[-_][0-9a-fA-F]{6,}"),
|
|
36
|
+
re.compile(r"_[A-Za-z0-9]{5,}_"),
|
|
37
|
+
)
|
|
38
|
+
UI_ASSERTIONS = frozenset({"assert_visible", "assert_text", "assert_each_text", "assert_value", "assert_url"})
|
|
32
39
|
UI_INTERACTIONS = frozenset({"click", "fill", "select", "check", "uncheck"})
|
|
33
40
|
_IGNORED_KEYWORDS = {"$schema", "$id", "title", "description", "$defs", "default", "examples"}
|
|
34
41
|
|
|
@@ -111,7 +118,9 @@ class _Validator:
|
|
|
111
118
|
if unknown:
|
|
112
119
|
raise ValueError(f"unsupported schema keywords: {sorted(unknown)}")
|
|
113
120
|
if "oneOf" in schema:
|
|
114
|
-
|
|
121
|
+
one_of_errors = self.one_of(value, schema["oneOf"], path)
|
|
122
|
+
if one_of_errors:
|
|
123
|
+
return one_of_errors
|
|
115
124
|
found: list[str] = []
|
|
116
125
|
if "type" in schema:
|
|
117
126
|
types = schema["type"] if isinstance(schema["type"], list) else [schema["type"]]
|
|
@@ -259,6 +268,8 @@ def semantic_errors(dsl: dict[str, Any]) -> list[str]:
|
|
|
259
268
|
seen_ids.setdefault(step_id, path)
|
|
260
269
|
payload = {key: value for key, value in step.items() if key not in {"id", "driver", "action", "covers", "extract", "assertions", "retry"}}
|
|
261
270
|
_references(payload, path, known, errors)
|
|
271
|
+
if step.get("driver") == "ui" and isinstance(step.get("target"), dict):
|
|
272
|
+
_target_errors(step["target"], f"{path}.target", errors, action=step.get("action"))
|
|
262
273
|
if step["driver"] != "api":
|
|
263
274
|
continue
|
|
264
275
|
for index, assertion in enumerate(step.get("assertions", [])):
|
|
@@ -281,9 +292,48 @@ def semantic_errors(dsl: dict[str, Any]) -> list[str]:
|
|
|
281
292
|
known.add(name)
|
|
282
293
|
if spec.get("sensitive") is True:
|
|
283
294
|
sensitive.add(name)
|
|
295
|
+
if "header" in spec and spec.get("sensitive", True):
|
|
296
|
+
sensitive.add(name)
|
|
284
297
|
return errors
|
|
285
298
|
|
|
286
299
|
|
|
300
|
+
def _target_errors(target: dict[str, Any], path: str, errors: list[str], depth: int = 0, action: Any = None) -> None:
|
|
301
|
+
"""Semantic target rules shared with the Runtime's independent gate."""
|
|
302
|
+
locator_keys = {"testid", "role", "label", "placeholder", "text", "css"}
|
|
303
|
+
locators = locator_keys & target.keys()
|
|
304
|
+
if "role" in target and "name" not in target:
|
|
305
|
+
errors.append(f"{path} role target requires name")
|
|
306
|
+
if "name" in target and "role" not in target:
|
|
307
|
+
errors.append(f"{path} name is only valid with role")
|
|
308
|
+
if len(locators) > 1:
|
|
309
|
+
errors.append(f"{path} must contain exactly one locator form")
|
|
310
|
+
if "css" in target and any(pattern.search(str(target["css"])) for pattern in FRAGILE_CSS_PATTERNS):
|
|
311
|
+
errors.append(f"{path}.css uses a fragile generated/structural selector; use table/column, within, row, or a mapping anchor")
|
|
312
|
+
if "table" in target:
|
|
313
|
+
table = target["table"]
|
|
314
|
+
if not isinstance(table, dict) or sum(key in table for key in ("near_heading", "testid", "only")) != 1:
|
|
315
|
+
errors.append(f"{path}.table must select by exactly one of near_heading, testid, or only")
|
|
316
|
+
if "column" not in target and "row" not in target:
|
|
317
|
+
errors.append(f"{path}.table requires column or row")
|
|
318
|
+
if "table_kind" in target and target["table_kind"] not in {"html", "element-plus"}:
|
|
319
|
+
errors.append(f"{path}.table_kind must be html or element-plus")
|
|
320
|
+
if "column" in target and "table" not in target:
|
|
321
|
+
errors.append(f"{path}.column requires table")
|
|
322
|
+
if "row" in target:
|
|
323
|
+
row = target["row"]
|
|
324
|
+
if not isinstance(row, dict) or ("equals" in row) == ("contains" in row):
|
|
325
|
+
errors.append(f"{path}.row requires exactly one of equals or contains")
|
|
326
|
+
if isinstance(row, dict) and "column" not in row:
|
|
327
|
+
errors.append(f"{path}.row requires column")
|
|
328
|
+
if "table" in target and "row" not in target and action != "assert_each_text":
|
|
329
|
+
errors.append(f"{path} table/column targets require assert_each_text or a row restriction")
|
|
330
|
+
if "within" in target:
|
|
331
|
+
if depth >= 2:
|
|
332
|
+
errors.append(f"{path}.within exceeds maximum nesting depth 2")
|
|
333
|
+
elif isinstance(target["within"], dict):
|
|
334
|
+
_target_errors(target["within"], f"{path}.within", errors, depth + 1, action)
|
|
335
|
+
|
|
336
|
+
|
|
287
337
|
def validate(dsl: Any) -> list[str]:
|
|
288
338
|
try:
|
|
289
339
|
errors = schema_errors(dsl)
|
|
@@ -24,6 +24,7 @@ POLICY = {
|
|
|
24
24
|
"api-supplement" => { "checkers" => %w[requirement-graph-check contract-coverage-check], "agents" => ["implementation-worker"], "budget" => 1, "allowed_graph_delta" => %w[api decision] },
|
|
25
25
|
"integration-verify" => { "checkers" => %w[requirement-graph-check slice-independence-check contract-coverage-check change-impact-check cross-track-consistency-check business-claim-reference-check business-cross-model-check], "agents" => ["verification-runner"], "budget" => 1, "allowed_graph_delta" => %w[requirement risk decision] },
|
|
26
26
|
"acceptance-test-design" => { "checkers" => %w[requirement-graph-check contract-coverage-check acceptance-coverage-check api-test-matrix-check test-coverage-check test-obligation-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[test_case] },
|
|
27
|
+
"regression-test-design" => { "checkers" => %w[test-coverage-check test-obligation-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[test_case] },
|
|
27
28
|
"change-plan" => { "checkers" => %w[requirement-graph-check slice-independence-check contract-coverage-check api-test-matrix-check test-coverage-check test-obligation-check test-task-binding-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[acceptance] },
|
|
28
29
|
"execute" => { "checkers" => %w[tdd-evidence-check change-impact-check], "agents" => ["implementation-worker"], "budget" => 1, "allowed_graph_delta" => %w[requirement rule boundary behavior] },
|
|
29
30
|
"run-acceptance-tests" => { "checkers" => %w[api-test-matrix-check test-coverage-check acceptance-test-results-check change-impact-check], "agents" => ["verification-runner"], "budget" => 1, "allowed_graph_delta" => [] },
|
|
@@ -129,6 +130,7 @@ begin
|
|
|
129
130
|
read grep find glob lsp search write edit apply_patch bash shell exec_command task todo
|
|
130
131
|
devflow_api_test_matrix devflow_tdd_evidence devflow_run_acceptance_tests devflow_mainflow
|
|
131
132
|
devflow_requirement_graph devflow_static_runtime_context
|
|
133
|
+
devflow_explore
|
|
132
134
|
devflow_architecture_constraint devflow_architecture_feasibility devflow_architecture_brief
|
|
133
135
|
devflow_approval
|
|
134
136
|
],
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Read-only gate for the devflow_explore host.
|
|
3
|
+
|
|
4
|
+
This deliberately shares the profile loader and approval-store reader used by
|
|
5
|
+
the Playwright acceptance adapter. It never reads secret values and never
|
|
6
|
+
writes the control plane.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from approval_store import REQUIRES_DATABASE, valid_approval
|
|
15
|
+
from environment_profile import load as load_profile, profile_path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def preflight(root: Path, feature: str, name: str) -> dict[str, object]:
|
|
19
|
+
profile, sha256, errors = load_profile(root, name)
|
|
20
|
+
result: dict[str, object] = {
|
|
21
|
+
"ok": False,
|
|
22
|
+
"profile": name,
|
|
23
|
+
"profile_path": str(profile_path(root, name).resolve()),
|
|
24
|
+
"sha256": sha256,
|
|
25
|
+
"approved": False,
|
|
26
|
+
"secret_names": [],
|
|
27
|
+
"errors": list(errors),
|
|
28
|
+
}
|
|
29
|
+
if profile is None or sha256 is None:
|
|
30
|
+
return result
|
|
31
|
+
approval = valid_approval(root, feature, "environment_profile", name, {"profile_sha256": sha256})
|
|
32
|
+
if not approval.get("ok"):
|
|
33
|
+
reason = approval.get("reason")
|
|
34
|
+
message = str(approval.get("message") or "environment profile approval is missing or stale")
|
|
35
|
+
result["errors"] = [f"{reason}: {message}" if reason else message]
|
|
36
|
+
result["reason"] = reason
|
|
37
|
+
return result
|
|
38
|
+
secret_env = profile.get("secret_env", {})
|
|
39
|
+
if isinstance(secret_env, dict):
|
|
40
|
+
result["secret_names"] = sorted(str(value) for value in secret_env.values() if isinstance(value, str) and value)
|
|
41
|
+
result["approved"] = True
|
|
42
|
+
result["ok"] = True
|
|
43
|
+
return result
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def main() -> int:
|
|
47
|
+
parser = argparse.ArgumentParser()
|
|
48
|
+
parser.add_argument("--root", type=Path, default=Path.cwd())
|
|
49
|
+
parser.add_argument("--feature", required=True)
|
|
50
|
+
parser.add_argument("--environment-profile", required=True)
|
|
51
|
+
args = parser.parse_args()
|
|
52
|
+
result = preflight(args.root.resolve(), args.feature, args.environment_profile)
|
|
53
|
+
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
|
|
54
|
+
return 0 if result["ok"] else (3 if result.get("reason") == REQUIRES_DATABASE else 2)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
if __name__ == "__main__":
|
|
58
|
+
raise SystemExit(main())
|
|
@@ -59,6 +59,7 @@ end
|
|
|
59
59
|
targets = {
|
|
60
60
|
"feature-development.yaml" => ".devflow/planning/workflows/feature-development.yaml",
|
|
61
61
|
"acceptance-remediation.yaml" => ".devflow/planning/workflows/acceptance-remediation.yaml",
|
|
62
|
+
"regression-acceptance.yaml" => ".devflow/planning/workflows/regression-acceptance.yaml",
|
|
62
63
|
"workflows-README.md" => ".devflow/planning/workflows/README.md"
|
|
63
64
|
}
|
|
64
65
|
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Load and strictly expand Devflow locator mapping files.
|
|
3
|
+
|
|
4
|
+
The mapping is a planning artefact, never a runtime input. Keeping this
|
|
5
|
+
module independent of the compiler makes it useful to other generators while
|
|
6
|
+
ensuring that an anchor can only expand to one of the DSL target forms.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import copy
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class LocatorMapError(ValueError):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_PAGE_RE = re.compile(r"^[^/\\\x00-\x1f\x7f]+$")
|
|
22
|
+
_ANCHOR_RE = re.compile(r"^anchor:([^/\\\x00-\x1f\x7f]+)/([^/\\\x00-\x1f\x7f]+)$")
|
|
23
|
+
_TARGET_KEYS = {"testid", "role", "name", "label", "placeholder", "text", "css", "exact",
|
|
24
|
+
"within", "table", "column", "table_kind", "row"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _error(path: str, message: str) -> LocatorMapError:
|
|
28
|
+
return LocatorMapError(f"{path}: {message}")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _target(value: Any, path: str) -> None:
|
|
32
|
+
if not isinstance(value, dict) or not value:
|
|
33
|
+
raise _error(path, "target must be a non-empty object")
|
|
34
|
+
unknown = set(value) - _TARGET_KEYS
|
|
35
|
+
if unknown:
|
|
36
|
+
raise _error(path, f"unsupported target keys {sorted(unknown)}")
|
|
37
|
+
forms = [key for key in ("testid", "role", "label", "placeholder", "text", "css", "table", "row") if key in value]
|
|
38
|
+
if "within" in value:
|
|
39
|
+
if len(forms) != 1:
|
|
40
|
+
raise _error(path, "within must wrap exactly one locator form")
|
|
41
|
+
_target(value["within"], f"{path}.within")
|
|
42
|
+
elif len(forms) != 1:
|
|
43
|
+
raise _error(path, "target must contain exactly one locator form")
|
|
44
|
+
if "role" in value and (not isinstance(value["role"], str) or not isinstance(value.get("name"), str)):
|
|
45
|
+
raise _error(path, "role target requires string role and name")
|
|
46
|
+
if "role" not in value and "name" in value:
|
|
47
|
+
raise _error(path, "name is only valid with role")
|
|
48
|
+
for key in ("testid", "label", "placeholder", "text", "css"):
|
|
49
|
+
if key in value and (not isinstance(value[key], str) or not value[key].strip()):
|
|
50
|
+
raise _error(path, f"{key} must be a non-empty string")
|
|
51
|
+
if "exact" in value and not isinstance(value["exact"], bool):
|
|
52
|
+
raise _error(path, "exact must be boolean")
|
|
53
|
+
if "table" in value:
|
|
54
|
+
table = value["table"]
|
|
55
|
+
if not isinstance(table, dict) or set(table) not in ({"near_heading"}, {"testid"}, {"only"}, {"ref"}):
|
|
56
|
+
raise _error(f"{path}.table", "must be near_heading, testid, only, or ref")
|
|
57
|
+
key = next(iter(table))
|
|
58
|
+
if key == "only" and table[key] is not True:
|
|
59
|
+
raise _error(f"{path}.table.only", "must be true")
|
|
60
|
+
if key != "only" and (not isinstance(table[key], str) or not table[key].strip()):
|
|
61
|
+
raise _error(f"{path}.table.{key}", "must be a non-empty string")
|
|
62
|
+
if "column" not in value or not isinstance(value["column"], str) or not value["column"].strip():
|
|
63
|
+
raise _error(path, "table target requires a non-empty column")
|
|
64
|
+
if value.get("table_kind", "html") not in {"html", "element-plus"}:
|
|
65
|
+
raise _error(path, "table_kind must be html or element-plus")
|
|
66
|
+
if "row" in value:
|
|
67
|
+
row = value["row"]
|
|
68
|
+
if not isinstance(row, dict) or set(row) != {"column", "equals"} and set(row) != {"column", "contains"}:
|
|
69
|
+
raise _error(f"{path}.row", "must be {column, equals} or {column, contains}")
|
|
70
|
+
if not isinstance(row.get("column"), str) or not row["column"].strip() or not isinstance(row[next(k for k in row if k != "column")], str):
|
|
71
|
+
raise _error(f"{path}.row", "column and match value must be strings")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def validate_mapping(document: Any, source: str = "mapping") -> dict[str, Any]:
|
|
75
|
+
if not isinstance(document, dict):
|
|
76
|
+
raise _error(source, "must be an object")
|
|
77
|
+
unknown = set(document) - {"schema_version", "page", "anchors"}
|
|
78
|
+
if unknown:
|
|
79
|
+
raise _error(source, f"unsupported keys {sorted(unknown)}")
|
|
80
|
+
if document.get("schema_version") != 1:
|
|
81
|
+
raise _error(source, "schema_version must be 1")
|
|
82
|
+
page = document.get("page")
|
|
83
|
+
if not isinstance(page, str) or not _PAGE_RE.fullmatch(page):
|
|
84
|
+
raise _error(f"{source}.page", "must be a single non-empty path-safe string")
|
|
85
|
+
anchors = document.get("anchors")
|
|
86
|
+
if not isinstance(anchors, dict) or not anchors:
|
|
87
|
+
raise _error(f"{source}.anchors", "must be a non-empty object")
|
|
88
|
+
for name, target in anchors.items():
|
|
89
|
+
if not isinstance(name, str) or not name.strip() or "/" in name:
|
|
90
|
+
raise _error(f"{source}.anchors", "anchor names must be non-empty and must not contain '/'")
|
|
91
|
+
if isinstance(target, dict) and set(target) == {"ref"}:
|
|
92
|
+
if not isinstance(target["ref"], str) or not target["ref"].strip():
|
|
93
|
+
raise _error(f"{source}.anchors.{name}.ref", "must be a non-empty string")
|
|
94
|
+
else:
|
|
95
|
+
_target(target, f"{source}.anchors.{name}")
|
|
96
|
+
return copy.deepcopy(document)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def load_locator_map(path: Path) -> dict[str, Any]:
|
|
100
|
+
try:
|
|
101
|
+
document = json.loads(path.read_text(encoding="utf-8"))
|
|
102
|
+
except FileNotFoundError as error:
|
|
103
|
+
raise LocatorMapError(f"locator map not found: {path}") from error
|
|
104
|
+
except json.JSONDecodeError as error:
|
|
105
|
+
raise LocatorMapError(f"locator map is not valid JSON: {path}: {error}") from error
|
|
106
|
+
return validate_mapping(document, str(path))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def expand_mapping(document: dict[str, Any], anchor: str) -> dict[str, Any]:
|
|
110
|
+
mapping = validate_mapping(document)
|
|
111
|
+
anchors = mapping["anchors"]
|
|
112
|
+
visiting: list[str] = []
|
|
113
|
+
|
|
114
|
+
def resolve(name: str) -> dict[str, Any]:
|
|
115
|
+
if name in visiting:
|
|
116
|
+
cycle = " -> ".join(visiting + [name])
|
|
117
|
+
raise LocatorMapError(f"anchor reference cycle: {cycle}")
|
|
118
|
+
if name not in anchors:
|
|
119
|
+
raise LocatorMapError(f"unknown anchor '{name}' (known: {', '.join(sorted(anchors))})")
|
|
120
|
+
visiting.append(name)
|
|
121
|
+
value = anchors[name]
|
|
122
|
+
if isinstance(value, dict) and set(value) == {"ref"}:
|
|
123
|
+
result = resolve(value["ref"])
|
|
124
|
+
else:
|
|
125
|
+
result = copy.deepcopy(value)
|
|
126
|
+
table = result.get("table") if isinstance(result, dict) else None
|
|
127
|
+
if isinstance(table, dict) and set(table) == {"ref"}:
|
|
128
|
+
referenced = resolve(table["ref"])
|
|
129
|
+
if "table" not in referenced:
|
|
130
|
+
raise LocatorMapError(f"anchor '{table['ref']}' is not a table target")
|
|
131
|
+
result["table"] = copy.deepcopy(referenced["table"])
|
|
132
|
+
if "table_kind" not in result and "table_kind" in referenced:
|
|
133
|
+
result["table_kind"] = referenced["table_kind"]
|
|
134
|
+
visiting.pop()
|
|
135
|
+
return result
|
|
136
|
+
|
|
137
|
+
result = resolve(anchor)
|
|
138
|
+
_target(result, f"anchor:{mapping['page']}/{anchor}")
|
|
139
|
+
return result
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def expand_anchor_reference(reference: str, locator_dir: Path) -> tuple[dict[str, Any], str]:
|
|
143
|
+
match = _ANCHOR_RE.fullmatch(reference.strip()) if isinstance(reference, str) else None
|
|
144
|
+
if not match:
|
|
145
|
+
raise LocatorMapError("anchor locator must be 'anchor:<page-key>/<anchor-name>'")
|
|
146
|
+
page, name = match.groups()
|
|
147
|
+
if not _PAGE_RE.fullmatch(page):
|
|
148
|
+
raise LocatorMapError("anchor page key is not path-safe")
|
|
149
|
+
path = locator_dir / f"{page}.json"
|
|
150
|
+
mapping = load_locator_map(path)
|
|
151
|
+
if mapping["page"] != page:
|
|
152
|
+
raise LocatorMapError(f"{path}: page must equal filename key '{page}'")
|
|
153
|
+
return expand_mapping(mapping, name), reference.strip()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
__all__ = ["LocatorMapError", "expand_anchor_reference", "expand_mapping", "load_locator_map", "validate_mapping"]
|
|
@@ -127,6 +127,7 @@ def configured_checkers(workflow, step_id)
|
|
|
127
127
|
when "integration-verify" then %w[requirement-graph-check slice-independence-check contract-coverage-check change-impact-check]
|
|
128
128
|
when "change-plan" then %w[requirement-graph-check slice-independence-check contract-coverage-check api-test-matrix-check test-coverage-check test-obligation-check test-task-binding-check change-impact-check]
|
|
129
129
|
when "acceptance-test-design" then %w[requirement-graph-check contract-coverage-check acceptance-coverage-check api-test-matrix-check test-coverage-check test-obligation-check change-impact-check]
|
|
130
|
+
when "regression-test-design" then %w[test-coverage-check test-obligation-check change-impact-check]
|
|
130
131
|
when "run-acceptance-tests" then %w[api-test-matrix-check test-coverage-check acceptance-test-results-check change-impact-check]
|
|
131
132
|
when "verify" then %w[acceptance-coverage-check acceptance-test-results-check change-impact-check]
|
|
132
133
|
when "execute" then %w[tdd-evidence-check change-impact-check]
|
|
@@ -43,7 +43,7 @@ ARTIFACT_KINDS = frozenset({"trace", "screenshot", "network"})
|
|
|
43
43
|
EXECUTION_ID_RE = re.compile(r"^[0-9]{8}T[0-9]{6}Z-[0-9a-f]{8}$")
|
|
44
44
|
SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
|
45
45
|
KILL_GRACE_SECONDS = 5
|
|
46
|
-
STEP_SUMMARY_KEYS = ("id", "phase", "driver", "action", "status", "duration_ms", "locator", "match_count", "http_status", "assertion", "error")
|
|
46
|
+
STEP_SUMMARY_KEYS = ("id", "phase", "driver", "action", "status", "duration_ms", "locator", "locator_strategy", "locator_details", "match_count", "http_status", "assertion", "error")
|
|
47
47
|
|
|
48
48
|
|
|
49
49
|
def utc_now() -> str:
|
|
@@ -65,6 +65,16 @@ module RequirementGraph
|
|
|
65
65
|
nil
|
|
66
66
|
end
|
|
67
67
|
|
|
68
|
+
def active_flow(root)
|
|
69
|
+
state_path = File.join(root, ".devflow/planning/STATE.md")
|
|
70
|
+
return nil unless File.file?(state_path)
|
|
71
|
+
front = File.read(state_path).match(/\A---\n(.*?)\n---/m)&.[](1)
|
|
72
|
+
return nil unless front
|
|
73
|
+
YAML.safe_load(front, permitted_classes: [], aliases: false)["current_flow"]
|
|
74
|
+
rescue Psych::Exception
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
|
|
68
78
|
def assert_active_feature!(root, feature)
|
|
69
79
|
current = active_feature(root)
|
|
70
80
|
return if current.nil? || current == feature
|
|
@@ -188,7 +198,7 @@ module RequirementGraph
|
|
|
188
198
|
graph["stale"].reject! { |entry| entry.is_a?(Hash) && entry["node_id"] == node_id }
|
|
189
199
|
end
|
|
190
200
|
|
|
191
|
-
def proposal(graph, delta, allowed_kinds = nil)
|
|
201
|
+
def proposal(graph, delta, allowed_kinds = nil, regression: false)
|
|
192
202
|
raise ArgumentError, "delta must be an object" unless delta.is_a?(Hash)
|
|
193
203
|
case delta["op"]
|
|
194
204
|
when "add"
|
|
@@ -198,7 +208,7 @@ module RequirementGraph
|
|
|
198
208
|
raise ArgumentError, "node kind #{kind} is not allowed by the execution contract" if allowed_kinds && !allowed_kinds.include?(kind)
|
|
199
209
|
data = normalize_data(delta["data"])
|
|
200
210
|
node = { "id" => next_id(graph, prefix), "kind" => kind, "status" => "candidate", "data" => data }
|
|
201
|
-
candidate_errors = workload_node_errors(graph, node) + metric_node_errors(graph, node) + read_model_node_errors(graph, node) + architecture_decision_errors(graph, node) + architecture_risk_errors(graph, node) + test_case_errors(graph, node)
|
|
211
|
+
candidate_errors = workload_node_errors(graph, node) + metric_node_errors(graph, node) + read_model_node_errors(graph, node) + architecture_decision_errors(graph, node) + architecture_risk_errors(graph, node) + test_case_errors(graph, node, regression: regression)
|
|
202
212
|
raise ArgumentError, candidate_errors.join("; ") if candidate_errors.any?
|
|
203
213
|
set_node_fingerprint!(node)
|
|
204
214
|
graph["nodes"] << node
|
|
@@ -220,7 +230,7 @@ module RequirementGraph
|
|
|
220
230
|
end
|
|
221
231
|
data = normalize_data(delta["data"])
|
|
222
232
|
node["data"] = node.fetch("data", {}).merge(data)
|
|
223
|
-
candidate_errors = workload_node_errors(graph, node) + metric_node_errors(graph, node) + read_model_node_errors(graph, node) + architecture_decision_errors(graph, node) + architecture_risk_errors(graph, node) + test_case_errors(graph, node)
|
|
233
|
+
candidate_errors = workload_node_errors(graph, node) + metric_node_errors(graph, node) + read_model_node_errors(graph, node) + architecture_decision_errors(graph, node) + architecture_risk_errors(graph, node) + test_case_errors(graph, node, regression: regression)
|
|
224
234
|
raise ArgumentError, candidate_errors.join("; ") if candidate_errors.any?
|
|
225
235
|
set_node_fingerprint!(node)
|
|
226
236
|
clear_stale_for!(graph, id)
|
|
@@ -301,25 +311,50 @@ module RequirementGraph
|
|
|
301
311
|
# causally proven without a deterministic replay.
|
|
302
312
|
TEST_EVIDENCE_STRATEGIES = %w[replay causal observation].freeze
|
|
303
313
|
REPLAY_SCOPES = %w[component page api integration].freeze
|
|
314
|
+
ORACLE_SOURCES = %w[requirement contract invariant characterization].freeze
|
|
315
|
+
|
|
316
|
+
def oracle_source_errors(node, data, regression: false)
|
|
317
|
+
errors = []
|
|
318
|
+
source = data["oracle_source"]
|
|
319
|
+
if source.nil? && !regression
|
|
320
|
+
source = "requirement"
|
|
321
|
+
elsif !ORACLE_SOURCES.include?(source)
|
|
322
|
+
errors << "#{node["id"]}: oracle_source must be requirement, contract, invariant, or characterization"
|
|
323
|
+
return errors
|
|
324
|
+
end
|
|
325
|
+
if regression && !data.key?("oracle_source")
|
|
326
|
+
errors << "#{node["id"]}: regression TC requires explicit oracle_source"
|
|
327
|
+
end
|
|
328
|
+
if source == "requirement"
|
|
329
|
+
errors << "#{node["id"]}: requirement oracle_source requires non-empty acceptance_refs" unless data["acceptance_refs"].is_a?(Array) && !data["acceptance_refs"].empty?
|
|
330
|
+
elsif !data["oracle_evidence"].is_a?(String) || data["oracle_evidence"].strip.empty?
|
|
331
|
+
errors << "#{node["id"]}: #{source} oracle_source requires oracle_evidence"
|
|
332
|
+
end
|
|
333
|
+
errors
|
|
334
|
+
end
|
|
304
335
|
|
|
305
336
|
# TC nodes are derived verification intent. They deliberately store only
|
|
306
337
|
# externally observable setup, operation and oracle; implementation paths,
|
|
307
338
|
# selectors and agent summaries belong to later execution bindings.
|
|
308
|
-
def test_case_errors(graph, node, require_edges: false, require_confirmed_inputs: false)
|
|
339
|
+
def test_case_errors(graph, node, require_edges: false, require_confirmed_inputs: false, regression: false)
|
|
309
340
|
return [] unless node["kind"] == "test_case"
|
|
310
341
|
|
|
311
342
|
data = node["data"].is_a?(Hash) ? node["data"] : {}
|
|
312
343
|
errors = []
|
|
313
|
-
%w[
|
|
344
|
+
%w[test_type target preconditions steps oracle automation_mode evidence_strategy].each do |field|
|
|
314
345
|
errors << "#{node["id"]}: missing #{field}" unless present?(data[field])
|
|
315
346
|
end
|
|
316
|
-
|
|
317
|
-
errors << "#{node["id"]}:
|
|
347
|
+
allow_empty_refs = regression && data["oracle_source"] && data["oracle_source"] != "requirement"
|
|
348
|
+
errors << "#{node["id"]}: acceptance_refs must be present" unless data.key?("acceptance_refs")
|
|
349
|
+
errors << "#{node["id"]}: acceptance_refs must be a non-empty array" unless data["acceptance_refs"].is_a?(Array) && (!data["acceptance_refs"].empty? || allow_empty_refs)
|
|
350
|
+
errors << "#{node["id"]}: requirement_refs must be present" unless data.key?("requirement_refs")
|
|
351
|
+
errors << "#{node["id"]}: requirement_refs must be a non-empty array" unless data["requirement_refs"].is_a?(Array) && (!data["requirement_refs"].empty? || allow_empty_refs)
|
|
318
352
|
errors << "#{node["id"]}: preconditions must be a non-empty array" unless data["preconditions"].is_a?(Array) && !data["preconditions"].empty?
|
|
319
353
|
errors << "#{node["id"]}: steps must be a non-empty array" unless data["steps"].is_a?(Array) && !data["steps"].empty?
|
|
320
354
|
errors << "#{node["id"]}: invalid test_type" unless TEST_CASE_TYPES.include?(data["test_type"])
|
|
321
355
|
errors << "#{node["id"]}: invalid automation_mode" unless TEST_AUTOMATION_MODES.include?(data["automation_mode"])
|
|
322
356
|
errors << "#{node["id"]}: invalid evidence_strategy" unless TEST_EVIDENCE_STRATEGIES.include?(data["evidence_strategy"])
|
|
357
|
+
errors.concat(oracle_source_errors(node, data, regression: regression))
|
|
323
358
|
case data["evidence_strategy"]
|
|
324
359
|
when "replay"
|
|
325
360
|
errors << "#{node["id"]}: replay tests require replay_scope" unless REPLAY_SCOPES.include?(data["replay_scope"])
|
|
@@ -447,7 +482,7 @@ module RequirementGraph
|
|
|
447
482
|
# CP-3 is the single freeze point. Keep it here instead of in a Skill so the
|
|
448
483
|
# graph revision, TC statuses, views and audit entry commit atomically under
|
|
449
484
|
# the caller's control-plane lock.
|
|
450
|
-
def freeze_test_cases!(root, graph, cp_record, confirm_prerequisites: false, persist: true)
|
|
485
|
+
def freeze_test_cases!(root, graph, cp_record, confirm_prerequisites: false, persist: true, regression: false)
|
|
451
486
|
cases = Array(graph["nodes"]).select { |node| node.is_a?(Hash) && node["kind"] == "test_case" }
|
|
452
487
|
return { "ok" => false, "reason" => "test_suite_empty", "message" => "cannot freeze an empty TC suite" } if cases.empty?
|
|
453
488
|
return { "ok" => false, "reason" => "test_suite_already_frozen", "message" => "confirmed test cases require a new candidate suite" } if cases.any? { |node| node["status"] == "confirmed" }
|
|
@@ -460,10 +495,10 @@ module RequirementGraph
|
|
|
460
495
|
end
|
|
461
496
|
return prerequisite_confirmation unless prerequisite_confirmation["ok"]
|
|
462
497
|
|
|
463
|
-
checkers = checker_results(graph)
|
|
498
|
+
checkers = checker_results(graph, regression: regression)
|
|
464
499
|
errors = checkers.fetch("test-coverage-check", []).dup
|
|
465
500
|
errors.concat(checkers.fetch("test-obligation-check", []))
|
|
466
|
-
cases.each { |node| errors.concat(test_case_errors(graph, node, require_edges: true, require_confirmed_inputs: true)) }
|
|
501
|
+
cases.each { |node| errors.concat(test_case_errors(graph, node, require_edges: true, require_confirmed_inputs: true, regression: regression)) }
|
|
467
502
|
return { "ok" => false, "reason" => "test_suite_incomplete", "message" => errors.uniq.join("; "), "errors" => errors.uniq } if errors.any?
|
|
468
503
|
|
|
469
504
|
cases.each do |node|
|
|
@@ -528,14 +563,14 @@ module RequirementGraph
|
|
|
528
563
|
comparable = node.dig("data", "execution") == candidate_data["execution"] && node.dig("data", "test_obligation_refs") == candidate_data["test_obligation_refs"]
|
|
529
564
|
raise ArgumentError, "#{item["key"]}: existing generated TC does not match matrix" unless comparable
|
|
530
565
|
else
|
|
531
|
-
applied = proposal(graph, { "op" => "add", "kind" => "test_case", "data" => candidate_data }, ["test_case"])
|
|
566
|
+
applied = proposal(graph, { "op" => "add", "kind" => "test_case", "data" => candidate_data }, ["test_case"], regression: active_flow(root).to_s == "regression-acceptance")
|
|
532
567
|
node = applied.fetch("node")
|
|
533
568
|
refs = Array(candidate_data["acceptance_refs"]) + Array(candidate_data["requirement_refs"]) + Array(candidate_data["api_refs"])
|
|
534
569
|
refs.each do |ref|
|
|
535
570
|
edge = { "from" => ref, "to" => node["id"], "relation" => "verified_by" }
|
|
536
571
|
graph["edges"] << edge unless graph["edges"].include?(edge)
|
|
537
572
|
end
|
|
538
|
-
errors = test_case_errors(graph, node, require_edges: true, require_confirmed_inputs: true)
|
|
573
|
+
errors = test_case_errors(graph, node, require_edges: true, require_confirmed_inputs: true, regression: active_flow(root).to_s == "regression-acceptance")
|
|
539
574
|
raise ArgumentError, errors.join("; ") if errors.any?
|
|
540
575
|
existing[item["key"]] = node
|
|
541
576
|
imported << node["id"]
|
|
@@ -827,7 +862,7 @@ module RequirementGraph
|
|
|
827
862
|
!node.nil? && node["status"] == "confirmed"
|
|
828
863
|
end
|
|
829
864
|
|
|
830
|
-
def checker_results(graph)
|
|
865
|
+
def checker_results(graph, regression: false)
|
|
831
866
|
results = {
|
|
832
867
|
"requirement-graph-check" => [], "slice-independence-check" => [], "contract-coverage-check" => [],
|
|
833
868
|
"change-impact-check" => [], "acceptance-coverage-check" => [], "test-coverage-check" => [], "test-obligation-check" => [], "risk-derivation-check" => []
|
|
@@ -886,7 +921,7 @@ module RequirementGraph
|
|
|
886
921
|
end
|
|
887
922
|
when "test_case"
|
|
888
923
|
results["test-coverage-check"].concat(
|
|
889
|
-
test_case_errors(graph, node, require_edges: true, require_confirmed_inputs: node["status"] == "confirmed")
|
|
924
|
+
test_case_errors(graph, node, require_edges: true, require_confirmed_inputs: node["status"] == "confirmed", regression: regression)
|
|
890
925
|
)
|
|
891
926
|
end
|
|
892
927
|
end
|
|
@@ -1137,7 +1172,7 @@ if __FILE__ == $PROGRAM_NAME
|
|
|
1137
1172
|
delta = JSON.parse(options[:delta_json].to_s)
|
|
1138
1173
|
allowed_kinds = options[:allowed_kinds]&.split(",")&.map(&:strip)
|
|
1139
1174
|
candidate = RequirementGraph.deep_clone(graph)
|
|
1140
|
-
|
|
1175
|
+
applied = RequirementGraph.proposal(candidate, delta, allowed_kinds, regression: RequirementGraph.active_flow(root).to_s == "regression-acceptance")
|
|
1141
1176
|
candidate["revision"] += 1
|
|
1142
1177
|
stale = Array(candidate["stale"]).any?
|
|
1143
1178
|
if options[:write]
|