@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.
Files changed (49) hide show
  1. package/README.md +42 -2
  2. package/package.json +2 -1
  3. package/plugins/devflow-core/extensions/devflow-core.ts +679 -91
  4. package/plugins/devflow-core/runtime/hooks/hook_common.py +245 -0
  5. package/plugins/devflow-core/runtime/hooks/index_gate.py +18 -0
  6. package/plugins/devflow-core/skills/wireframe-render/SKILL.md +1 -1
  7. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_runner.py +98 -2
  8. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_suite.rb +6 -1
  9. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +64 -8
  10. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_coverage.py +3 -0
  11. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_draft.py +53 -0
  12. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_v1.py +52 -2
  13. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +2 -0
  14. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/explore_preflight.py +58 -0
  15. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/init_workflow.rb +1 -0
  16. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/locator_map.py +156 -0
  17. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +1 -0
  18. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/playwright_adapter.py +1 -1
  19. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +50 -15
  20. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/session_rollover.rb +60 -0
  21. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/startup_preflight.py +167 -0
  22. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +5 -0
  23. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_render.py +262 -56
  24. package/plugins/devflow-core/skills/workflow-orchestrator/templates/regression-acceptance.yaml +126 -0
  25. package/plugins/devflow-core/skills/workflow-pipeline/SKILL.md +2 -0
  26. package/plugins/devflow-intel/extensions/devflow-intel.ts +1 -1
  27. package/plugins/devflow-quality/runtime/playwright/devflow-test-dsl-v1.schema.json +103 -88
  28. package/plugins/devflow-quality/runtime/playwright/explore-host.mjs +879 -0
  29. package/plugins/devflow-quality/runtime/playwright/runner.spec.mjs +297 -15
  30. package/plugins/devflow-quality/runtime/playwright/runtime-manifest.json +3 -2
  31. package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +4 -0
  32. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/SKILL.md +10 -2
  33. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/intermediate-format.md +6 -2
  34. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/project-dsl-contract.md +3 -1
  35. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/ui-mainflow-rules.md +4 -2
  36. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/compile_dsl.py +98 -15
  37. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/locator_report.py +73 -0
  38. package/plugins/devflow-quality/skills/workflow-human-acceptance/SKILL.md +3 -1
  39. package/plugins/devflow-quality/skills/workflow-regression-test-design/SKILL.md +69 -0
  40. package/plugins/devflow-quality/skills/workflow-release-closeout/SKILL.md +1 -1
  41. package/plugins/devflow-quality/skills/workflow-run-acceptance-tests/SKILL.md +4 -2
  42. package/src/cli.ts +10 -0
  43. package/src/constants.ts +1 -1
  44. package/src/doctor.ts +9 -0
  45. package/src/mcp/auth.ts +103 -0
  46. package/src/mcp/core.ts +102 -0
  47. package/src/mcp/server.ts +193 -0
  48. package/src/mcp/types.ts +5 -0
  49. 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
- UI_ASSERTIONS = frozenset({"assert_visible", "assert_text", "assert_url"})
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
- return self.one_of(value, schema["oneOf"], path)
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[acceptance_refs requirement_refs test_type target preconditions steps oracle automation_mode evidence_strategy].each do |field|
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
- errors << "#{node["id"]}: acceptance_refs must be a non-empty array" unless data["acceptance_refs"].is_a?(Array) && !data["acceptance_refs"].empty?
317
- errors << "#{node["id"]}: requirement_refs must be a non-empty array" unless data["requirement_refs"].is_a?(Array) && !data["requirement_refs"].empty?
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
- applied = RequirementGraph.proposal(candidate, delta, allowed_kinds)
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]