@guanlin4924/devflow-pi 0.1.96 → 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 (38) hide show
  1. package/README.md +11 -1
  2. package/package.json +1 -1
  3. package/plugins/devflow-core/extensions/devflow-core.ts +678 -91
  4. package/plugins/devflow-core/skills/wireframe-render/SKILL.md +1 -1
  5. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_runner.py +18 -3
  6. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_suite.rb +6 -1
  7. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +64 -8
  8. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_coverage.py +3 -0
  9. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_v1.py +52 -2
  10. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +2 -0
  11. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/explore_preflight.py +58 -0
  12. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/init_workflow.rb +1 -0
  13. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/locator_map.py +156 -0
  14. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +1 -0
  15. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/playwright_adapter.py +1 -1
  16. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +50 -15
  17. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/session_rollover.rb +60 -0
  18. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/startup_preflight.py +167 -0
  19. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +5 -0
  20. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_render.py +262 -56
  21. package/plugins/devflow-core/skills/workflow-orchestrator/templates/regression-acceptance.yaml +126 -0
  22. package/plugins/devflow-core/skills/workflow-pipeline/SKILL.md +2 -0
  23. package/plugins/devflow-quality/runtime/playwright/devflow-test-dsl-v1.schema.json +103 -88
  24. package/plugins/devflow-quality/runtime/playwright/explore-host.mjs +879 -0
  25. package/plugins/devflow-quality/runtime/playwright/runner.spec.mjs +297 -15
  26. package/plugins/devflow-quality/runtime/playwright/runtime-manifest.json +3 -2
  27. package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +4 -0
  28. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/SKILL.md +10 -2
  29. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/intermediate-format.md +6 -2
  30. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/project-dsl-contract.md +3 -1
  31. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/ui-mainflow-rules.md +4 -2
  32. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/compile_dsl.py +98 -15
  33. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/locator_report.py +73 -0
  34. package/plugins/devflow-quality/skills/workflow-regression-test-design/SKILL.md +69 -0
  35. package/plugins/devflow-quality/skills/workflow-release-closeout/SKILL.md +1 -1
  36. package/plugins/devflow-quality/skills/workflow-run-acceptance-tests/SKILL.md +1 -1
  37. package/src/constants.ts +1 -1
  38. package/src/startup-briefing.ts +109 -0
@@ -5,4 +5,4 @@ description: Render validated canonical wireframe JSON into deterministic Markdo
5
5
 
6
6
  # Wireframe Render
7
7
 
8
- Call the workflow-orchestrator wireframe_render.py script only with the current Core-built canonical JSON and the validation metadata. Generate preview Markdown, flow SVG, page SVGs under `wireframes/screens/<screen-id>/<platform>/default.svg`, and the optional static gallery under the wireframe output directory. The `wireframes/screens` directory is a required render output and is covered by the execution contract. The `canonical.meta.json` file is created later by the Core approval path and is not an input to this step. Never read proposal YAML as a substitute, mutate canonical JSON, write approval, or modify workflow state.
8
+ Call the workflow-orchestrator wireframe_render.py script only with the current Core-built canonical JSON and the validation metadata. Generate preview Markdown, flow SVG, one SVG per declared state under `wireframes/screens/<screen-id>/<platform>/<state>.svg` (`default.svg` is always present), and the optional static gallery under the wireframe output directory. The `wireframes/screens` directory is a required render output and is covered by the execution contract. The `canonical.meta.json` file is created later by the Core approval path and is not an input to this step. Never read proposal YAML as a substitute, mutate canonical JSON, write approval, or modify workflow state.
@@ -480,7 +480,12 @@ def _run_locked(root: Path, feature: str, write: bool) -> dict[str, Any]:
480
480
  source = cases[tc_id]
481
481
  strategy = source.get("evidence_strategy")
482
482
  surface, legacy = derive_surface(source)
483
- common = {"tc_id": tc_id, "mode": mode, "execution_surface": surface, "legacy_unclassified": legacy, "tc_sha256": tc_sha256(source)}
483
+ # Feature-development TCs historically omitted this field; the shared
484
+ # contract defines that legacy omission as requirement-origin. A
485
+ # regression suite is frozen only after Core has required it explicitly.
486
+ oracle_source = source.get("oracle_source") or "requirement"
487
+ common = {"tc_id": tc_id, "mode": mode, "execution_surface": surface, "legacy_unclassified": legacy,
488
+ "oracle_source": oracle_source, "tc_sha256": tc_sha256(source)}
484
489
  frozen_mode = source.get("automation_mode")
485
490
  if mode != frozen_mode:
486
491
  common["downgrade"] = {"from": frozen_mode, "to": mode}
@@ -542,6 +547,8 @@ def _run_locked(root: Path, feature: str, write: bool) -> dict[str, Any]:
542
547
  passed = sum(1 for item in results if item.get("status") == "passed")
543
548
  failed = sum(1 for item in results if item.get("status") == "failed")
544
549
  blocked = sum(1 for item in results if item.get("status") == "blocked")
550
+ oracle_source_counts = {source: sum(1 for item in results if (item.get("oracle_source") or "requirement") == source)
551
+ for source in ("requirement", "contract", "invariant", "characterization")}
545
552
  write_json(meta_path, {
546
553
  "schema_version": 1,
547
554
  "feature": feature,
@@ -551,6 +558,7 @@ def _run_locked(root: Path, feature: str, write: bool) -> dict[str, Any]:
551
558
  "acceptance_tc_legacy_unclassified": sum(1 for item in results if item.get("legacy_unclassified")),
552
559
  "acceptance_tc_downgraded": sum(1 for item in results if item.get("downgrade")),
553
560
  "acceptance_tc_dsl_unapproved": sum(1 for item in results if item.get("dsl_approval") == "none"),
561
+ "oracle_source_counts": oracle_source_counts,
554
562
  "acceptance_suite_graph_revision": payload["test_suite"].get("graph_revision"),
555
563
  "acceptance_results_fingerprint": file_fingerprint(result_path),
556
564
  "built_by": "core_acceptance_test_runner",
@@ -576,7 +584,7 @@ def _result_summary(entry: dict[str, Any]) -> dict[str, Any]:
576
584
  elif approval not in {"approved", "none"}:
577
585
  approval = None
578
586
  return {key: entry.get(key) for key in (
579
- "tc_id", "status", "mode", "adapter_kind", "execution_surface", "error_code"
587
+ "tc_id", "status", "mode", "adapter_kind", "execution_surface", "oracle_source", "error_code"
580
588
  )} | {"dsl_approval": approval, "legacy_unclassified": bool(entry.get("legacy_unclassified")),
581
589
  "downgrade": entry.get("downgrade")}
582
590
 
@@ -594,9 +602,12 @@ def report(root: Path, feature: str) -> dict[str, Any]:
594
602
  entries = [entry for entry in payload["results"] if isinstance(entry, dict)]
595
603
  statuses = {status: sum(1 for entry in entries if entry.get("status") == status)
596
604
  for status in ("passed", "failed", "blocked")}
605
+ oracle_source_counts = {source: sum(1 for entry in entries if (entry.get("oracle_source") or "requirement") == source)
606
+ for source in ("requirement", "contract", "invariant", "characterization")}
597
607
  counts = {"passed": statuses["passed"], "failed": statuses["failed"], "blocked": statuses["blocked"],
598
608
  "legacy_unclassified": sum(bool(entry.get("legacy_unclassified")) for entry in entries),
599
- "downgraded": sum(bool(entry.get("downgrade")) for entry in entries)}
609
+ "downgraded": sum(bool(entry.get("downgrade")) for entry in entries),
610
+ "oracle_source": oracle_source_counts}
600
611
  gate = validate_results(root, feature)
601
612
  raw_errors = gate.get("errors", []) if isinstance(gate, dict) else []
602
613
  return {"ok": True, "feature": feature,
@@ -689,6 +700,10 @@ def result_entry_errors(root: Path, feature: str, tc_id: str, source: dict[str,
689
700
  """Re-verify one recorded result against the CURRENT binding, frozen TC, approvals and evidence files."""
690
701
  errors: list[str] = []
691
702
  surface, _legacy = derive_surface(source)
703
+ frozen_oracle_source = source.get("oracle_source") or "requirement"
704
+ recorded_oracle_source = entry.get("oracle_source")
705
+ if recorded_oracle_source != frozen_oracle_source:
706
+ errors.append(f"recorded oracle_source {recorded_oracle_source!r} does not match the frozen TC oracle_source {frozen_oracle_source!r}")
692
707
  mode = binding.get("mode")
693
708
  if entry.get("mode") != mode:
694
709
  errors.append(f"recorded mode {entry.get('mode')!r} does not match the current binding mode {mode!r}")
@@ -7,6 +7,7 @@
7
7
  require "json"
8
8
  require "open3"
9
9
  require "optparse"
10
+ require "yaml"
10
11
  require_relative "control_io"
11
12
  require_relative "requirement_graph"
12
13
 
@@ -46,6 +47,10 @@ begin
46
47
  lock = DevflowControlIO.acquire_project_lock(root) if command == "freeze"
47
48
  graph, path, relative = RequirementGraph.load_graph(root, options[:feature])
48
49
  cases = test_nodes(graph)
50
+ state_path = File.join(root, ".devflow/planning/STATE.md")
51
+ state_front = File.file?(state_path) ? File.read(state_path).match(/\A---\n(.*?)\n---\n/m)&.[](1) : nil
52
+ state = state_front ? YAML.safe_load(state_front, permitted_classes: [], aliases: false) : {}
53
+ regression = state.is_a?(Hash) && state["current_flow"].to_s == "regression-acceptance"
49
54
 
50
55
  if command == "inspect"
51
56
  view = RequirementGraph.view_payload(graph, "tests")
@@ -82,7 +87,7 @@ begin
82
87
  else
83
88
  matrix = api_test_matrix_check(root, options[:feature], graph)
84
89
  result = if matrix["ok"]
85
- RequirementGraph.freeze_test_cases!(root, graph, options[:cp_record], confirm_prerequisites: options[:confirm_prerequisites])
90
+ RequirementGraph.freeze_test_cases!(root, graph, options[:cp_record], confirm_prerequisites: options[:confirm_prerequisites], regression: regression)
86
91
  else
87
92
  { "ok" => false, "reason" => "api_test_matrix_incomplete", "message" => Array(matrix["errors"]).join("; "), "matrix" => matrix }
88
93
  end
@@ -565,6 +565,11 @@ HARD_FAILURE_REASONS = %w[
565
565
  dsl_not_approved
566
566
  environment_profile_not_approved
567
567
  runtime_integrity_error
568
+ acceptance_test_suite_freeze_failed
569
+ acceptance_test_suite_freeze_attestation_invalid
570
+ acceptance_test_suite_freeze_attestation_failed
571
+ acceptance_test_suite_candidate_binding_failed
572
+ acceptance_test_suite_freeze_recovery_failed
568
573
  ].freeze
569
574
  $devflow_advisories = []
570
575
 
@@ -724,7 +729,9 @@ def write_test_suite_freeze_attestation(root, feature, state_revision, candidate
724
729
  raise DevflowControlIO::InvalidControlPath, "candidate test suite pin missing from CP-3 binding check" unless candidate_suite.is_a?(Hash)
725
730
  raise DevflowControlIO::InvalidControlPath, "frozen test suite pin missing from CP-3 freeze" unless frozen_suite.is_a?(Hash)
726
731
  binding_hash = candidate_check["binding_sha256"]
727
- raise DevflowControlIO::InvalidControlPath, "test binding hash missing from CP-3 binding check" unless binding_hash.is_a?(String) && !binding_hash.empty?
732
+ unless candidate_check["regression"] == true || (binding_hash.is_a?(String) && !binding_hash.empty?)
733
+ raise DevflowControlIO::InvalidControlPath, "test binding hash missing from CP-3 binding check"
734
+ end
728
735
 
729
736
  path, relative = test_suite_freeze_attestation_path(root, feature)
730
737
  payload = {
@@ -735,9 +742,9 @@ def write_test_suite_freeze_attestation(root, feature, state_revision, candidate
735
742
  "state_revision" => state_revision,
736
743
  "candidate_test_suite" => candidate_suite,
737
744
  "frozen_test_suite" => frozen_suite,
738
- "test_binding_sha256" => binding_hash,
739
745
  "frozen_case_ids" => Array(freeze_result["frozen_case_ids"]).sort
740
746
  }
747
+ payload["test_binding_sha256"] = binding_hash unless candidate_check["regression"] == true
741
748
  DevflowControlIO.atomic_create(path, JSON.pretty_generate(payload) + "\n")
742
749
  DevflowControlIO.register_control_artifact(root, {
743
750
  "path" => relative, "type" => "test-suite-freeze-attestation", "feature" => feature,
@@ -746,6 +753,26 @@ def write_test_suite_freeze_attestation(root, feature, state_revision, candidate
746
753
  payload
747
754
  end
748
755
 
756
+ def regression_freeze_attestation_check(graph, feature, attestation_path)
757
+ begin
758
+ attestation = JSON.parse(File.read(attestation_path))
759
+ rescue JSON::ParserError, Errno::ENOENT => error
760
+ return { "ok" => false, "errors" => ["invalid regression freeze attestation: #{error.message}"] }
761
+ end
762
+ current_view = graph.dig("views", "tests")
763
+ cases = Array(graph["nodes"]).select { |node| node.is_a?(Hash) && node["kind"] == "test_case" }
764
+ errors = []
765
+ errors << "attestation schema_version is invalid" unless attestation["schema_version"] == 1
766
+ errors << "attestation origin is invalid" unless attestation["origin"] == "core_acceptance_test_suite_freeze"
767
+ errors << "attestation feature does not match" unless attestation["feature"] == feature
768
+ errors << "attestation checkpoint is invalid" unless attestation["checkpoint"] == "CP-3"
769
+ errors << "attestation frozen suite does not match the graph" unless attestation["frozen_test_suite"] == frozen_test_suite_pin("tests_view" => current_view)
770
+ frozen_ids = attestation["frozen_case_ids"]
771
+ expected_ids = cases.map { |node| node["id"] }.sort
772
+ errors << "attestation does not cover exactly the frozen TC cases" unless frozen_ids.is_a?(Array) && frozen_ids.map(&:to_s).sort == expected_ids
773
+ { "ok" => errors.empty?, "errors" => errors }
774
+ end
775
+
749
776
  def cp3_freeze_provenance?(graph, state_revision)
750
777
  expected = "CP-3: state revision #{state_revision} approved"
751
778
  cases = Array(graph["nodes"]).select { |node| node.is_a?(Hash) && node["kind"] == "test_case" }
@@ -754,6 +781,10 @@ def cp3_freeze_provenance?(graph, state_revision)
754
781
  end
755
782
  end
756
783
 
784
+ def regression_workflow?(state, steps)
785
+ state["current_flow"].to_s == "regression-acceptance" || steps.any? { |step| step["id"] == "regression-test-design" }
786
+ end
787
+
757
788
  # Return from a completed failure branch using its declared return_to contract.
758
789
  # The caller must still explicitly confirm the returned checkpoint before any
759
790
  # subsequent source-flow step can proceed.
@@ -1111,13 +1142,18 @@ result =
1111
1142
  # inside the checkpoint transition so the user does not have to
1112
1143
  # approve an extra model-issued dialog and a Skill cannot freeze its
1113
1144
  # own candidates early.
1114
- if result.nil? && state["checkpoint"] == "CP-3" && steps.any? { |step| step["id"] == "acceptance-test-design" }
1145
+ if result.nil? && state["checkpoint"] == "CP-3" && steps.any? { |step| %w[acceptance-test-design regression-test-design].include?(step["id"]) }
1115
1146
  feature = state.fetch("current_feature")
1116
1147
  graph, = RequirementGraph.load_graph(root, feature)
1148
+ regression = regression_workflow?(state, steps)
1117
1149
  attestation_path, = test_suite_freeze_attestation_path(root, feature)
1118
1150
 
1119
1151
  if File.exist?(attestation_path)
1120
- frozen_check = test_task_binding_check(root, feature, "frozen")
1152
+ frozen_check = if regression
1153
+ regression_freeze_attestation_check(graph, feature, attestation_path)
1154
+ else
1155
+ test_task_binding_check(root, feature, "frozen")
1156
+ end
1121
1157
  unless frozen_check["ok"]
1122
1158
  result = fail_result("acceptance_test_suite_freeze_attestation_invalid", "test_suite" => frozen_check)
1123
1159
  else
@@ -1128,7 +1164,11 @@ result =
1128
1164
  # attestation is published. Recover only if each TC proves that
1129
1165
  # this exact CP-3 already froze it; never use this path to bless
1130
1166
  # a generic confirmed suite.
1131
- recovery_check = test_task_binding_check(root, feature, "recovery")
1167
+ recovery_check = if regression
1168
+ { "ok" => true, "regression" => true, "candidate_test_suite" => graph.dig("views", "tests") }
1169
+ else
1170
+ test_task_binding_check(root, feature, "recovery")
1171
+ end
1132
1172
  unless recovery_check["ok"]
1133
1173
  result = fail_result("acceptance_test_suite_freeze_recovery_failed", "test_suite" => recovery_check)
1134
1174
  else
@@ -1139,7 +1179,11 @@ result =
1139
1179
  }
1140
1180
  begin
1141
1181
  write_test_suite_freeze_attestation(root, feature, current_revision, recovery_check, recovered)
1142
- frozen_check = test_task_binding_check(root, feature, "frozen")
1182
+ frozen_check = if regression
1183
+ regression_freeze_attestation_check(graph, feature, attestation_path)
1184
+ else
1185
+ test_task_binding_check(root, feature, "frozen")
1186
+ end
1143
1187
  if frozen_check["ok"]
1144
1188
  actions << "acceptance_test_suite_freeze_attestation_recovered"
1145
1189
  else
@@ -1160,7 +1204,11 @@ result =
1160
1204
  result = fail_result("execution_surface_mismatch", "execution_surface" => surface_check) unless surface_check["ok"]
1161
1205
  end
1162
1206
  if matrix_check["ok"] && surface_check["ok"]
1163
- candidate_check = test_task_binding_check(root, feature, "candidate")
1207
+ candidate_check = if regression
1208
+ { "ok" => true, "regression" => true, "candidate_test_suite" => graph.dig("views", "tests") }
1209
+ else
1210
+ test_task_binding_check(root, feature, "candidate")
1211
+ end
1164
1212
  unless candidate_check["ok"]
1165
1213
  result = fail_result("acceptance_test_suite_candidate_binding_failed", "test_suite" => candidate_check)
1166
1214
  else
@@ -1169,6 +1217,7 @@ result =
1169
1217
  graph,
1170
1218
  "CP-3: state revision #{current_revision} approved",
1171
1219
  confirm_prerequisites: options[:confirm_test_prerequisites] && options[:runtime_confirmed],
1220
+ regression: regression,
1172
1221
  persist: false
1173
1222
  )
1174
1223
  unless frozen["ok"]
@@ -1313,7 +1362,14 @@ result =
1313
1362
  pending_freeze.fetch("candidate_check"),
1314
1363
  pending_freeze.fetch("frozen")
1315
1364
  )
1316
- frozen_check = test_task_binding_check(root, pending_freeze.fetch("feature"), "frozen")
1365
+ frozen_check = if pending_freeze.dig("candidate_check", "regression") == true
1366
+ regression_freeze_attestation_check(
1367
+ pending_freeze.fetch("graph"), pending_freeze.fetch("feature"),
1368
+ test_suite_freeze_attestation_path(root, pending_freeze.fetch("feature")).first
1369
+ )
1370
+ else
1371
+ test_task_binding_check(root, pending_freeze.fetch("feature"), "frozen")
1372
+ end
1317
1373
  unless frozen_check["ok"]
1318
1374
  result = fail_result("acceptance_test_suite_freeze_attestation_invalid", "test_suite" => frozen_check)
1319
1375
  end
@@ -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")
@@ -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: