@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
|
@@ -711,6 +711,251 @@ def tool_touches_control_store(event: dict[str, Any], root: Path) -> tuple[bool,
|
|
|
711
711
|
return False, []
|
|
712
712
|
|
|
713
713
|
|
|
714
|
+
# MCP authorization and audit data are user-local security state. This guard
|
|
715
|
+
# is deliberately narrower than DEVFLOW_HOME: only mcp.json and mcp-audit/
|
|
716
|
+
# are protected, and the decision is independent of soft-gate policy.
|
|
717
|
+
def path_is_mcp_protected(raw: str, cwd: Path) -> bool:
|
|
718
|
+
candidate = Path(_expand_shell_path(str(raw)))
|
|
719
|
+
if not candidate.is_absolute():
|
|
720
|
+
candidate = cwd / candidate
|
|
721
|
+
lexical = Path(os.path.normpath(str(candidate)))
|
|
722
|
+
resolved = Path(os.path.realpath(str(candidate)))
|
|
723
|
+
home = devflow_home()
|
|
724
|
+
config = home / "mcp.json"
|
|
725
|
+
audit = home / "mcp-audit"
|
|
726
|
+
for item in (lexical, resolved):
|
|
727
|
+
if item == config or _under(item, audit):
|
|
728
|
+
return True
|
|
729
|
+
return False
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def _analyze_mcp_command(command: str, cwd: Path, depth: int = 0) -> bool:
|
|
733
|
+
tokens = _shell_tokens(command)
|
|
734
|
+
for segment in _segments(tokens):
|
|
735
|
+
index = 0
|
|
736
|
+
while index < len(segment) and (ASSIGNMENT_RE.match(segment[index]) or segment[index] in WRAPPER_PROGRAMS):
|
|
737
|
+
index += 1
|
|
738
|
+
if index >= len(segment):
|
|
739
|
+
continue
|
|
740
|
+
program = os.path.basename(segment[index]).lower()
|
|
741
|
+
operands = segment[index + 1:]
|
|
742
|
+
if program in SHELL_PROGRAMS and depth < 3:
|
|
743
|
+
for pos, operand in enumerate(operands):
|
|
744
|
+
if operand.startswith("-") and "c" in operand[1:] and pos + 1 < len(operands):
|
|
745
|
+
if _analyze_mcp_command(operands[pos + 1], cwd, depth + 1):
|
|
746
|
+
return True
|
|
747
|
+
ignored: set[int] = set()
|
|
748
|
+
if program in {"echo", "printf"}:
|
|
749
|
+
ignored.update(range(len(operands)))
|
|
750
|
+
elif program in {"rg", "grep", "ag", "ack"}:
|
|
751
|
+
seen_pattern = False
|
|
752
|
+
pos = 0
|
|
753
|
+
while pos < len(operands):
|
|
754
|
+
operand = operands[pos]
|
|
755
|
+
if operand in {"-e", "--regexp"} and pos + 1 < len(operands):
|
|
756
|
+
ignored.add(pos + 1)
|
|
757
|
+
pos += 2
|
|
758
|
+
continue
|
|
759
|
+
if operand.startswith("--regexp=") or (not operand.startswith("-") and not seen_pattern):
|
|
760
|
+
ignored.add(pos)
|
|
761
|
+
seen_pattern = True
|
|
762
|
+
pos += 1
|
|
763
|
+
elif program == "git":
|
|
764
|
+
for pos, operand in enumerate(operands):
|
|
765
|
+
if operand in {"-m", "--message"} and pos + 1 < len(operands):
|
|
766
|
+
ignored.add(pos + 1)
|
|
767
|
+
previous = ""
|
|
768
|
+
for pos, operand in enumerate(operands):
|
|
769
|
+
if previous in REDIRECT_OPERATORS and path_is_mcp_protected(operand, cwd):
|
|
770
|
+
return True
|
|
771
|
+
if pos in ignored:
|
|
772
|
+
previous = operand
|
|
773
|
+
continue
|
|
774
|
+
if operand in REDIRECT_OPERATORS or operand.startswith("-"):
|
|
775
|
+
previous = operand
|
|
776
|
+
continue
|
|
777
|
+
if path_is_mcp_protected(operand, cwd):
|
|
778
|
+
return True
|
|
779
|
+
previous = operand
|
|
780
|
+
return False
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def tool_touches_mcp_config(event: dict[str, Any], root: Path) -> tuple[bool, list[str]]:
|
|
784
|
+
cwd = Path(str(event.get("cwd") or root)).expanduser()
|
|
785
|
+
blocked = [raw for raw in _raw_tool_paths(event) if path_is_mcp_protected(raw, cwd)]
|
|
786
|
+
if blocked:
|
|
787
|
+
return True, sorted(dict.fromkeys(blocked))
|
|
788
|
+
command = command_text(event)
|
|
789
|
+
if command and _analyze_mcp_command(command, cwd):
|
|
790
|
+
return True, ["<shell command>"]
|
|
791
|
+
return False, []
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
# Run artifacts are user-local evidence, not agent-readable source. Keep this
|
|
795
|
+
# guard separate from the control-store guard: it is always on and its only
|
|
796
|
+
# read exception is the Core-produced result.json summary.
|
|
797
|
+
def _run_root(cwd: Path) -> Path:
|
|
798
|
+
# macOS commonly exposes /var as a symlink to /private/var; use the
|
|
799
|
+
# canonical cwd so lexical and realpath checks compare in one namespace.
|
|
800
|
+
return Path(os.path.realpath(str(cwd))) / ".devflow" / "runs"
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def _run_relative(raw: str, cwd: Path, anchor: Path | None = None) -> tuple[Path, Path, Path]:
|
|
804
|
+
# The protected root is anchored at the project root, never at the current
|
|
805
|
+
# shell cwd, so `cd .devflow && cat runs/...` still resolves into it.
|
|
806
|
+
expanded = _expand_shell_path(raw)
|
|
807
|
+
cwd_text = str(cwd)
|
|
808
|
+
expanded = re.sub(r"\$\{PWD\}|\$PWD\b", cwd_text, expanded)
|
|
809
|
+
candidate = Path(expanded.replace("\\", "/"))
|
|
810
|
+
if not candidate.is_absolute():
|
|
811
|
+
candidate = cwd / candidate
|
|
812
|
+
lexical = Path(os.path.normpath(str(candidate)))
|
|
813
|
+
resolved = Path(os.path.realpath(str(candidate)))
|
|
814
|
+
return lexical, resolved, _run_root(anchor or cwd)
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def _under_run(path: Path, run_root: Path) -> bool:
|
|
818
|
+
return _under(path, run_root)
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def path_is_run_artifact(raw: str, cwd: Path, tool: dict[str, Any] | str) -> bool:
|
|
822
|
+
"""Return whether a tool path enters .devflow/runs.
|
|
823
|
+
|
|
824
|
+
``result.json`` is readable only by pure-read tools. Glob may list the
|
|
825
|
+
runs directory itself; every other path, including result.json writes and
|
|
826
|
+
symlink aliases, remains protected.
|
|
827
|
+
"""
|
|
828
|
+
lexical, resolved, run_root = _run_relative(raw, cwd)
|
|
829
|
+
paths = (lexical, resolved)
|
|
830
|
+
tool_event = tool if isinstance(tool, dict) else {"tool_name": tool}
|
|
831
|
+
pure_read = is_pure_read_tool(tool_event)
|
|
832
|
+
name = tool_name(tool_event)
|
|
833
|
+
for item in paths:
|
|
834
|
+
if not _under_run(item, run_root):
|
|
835
|
+
continue
|
|
836
|
+
relative = item.relative_to(run_root).parts
|
|
837
|
+
if name == "glob" and len(relative) == 0:
|
|
838
|
+
continue
|
|
839
|
+
if pure_read and len(relative) == 3 and relative[-1] == "result.json":
|
|
840
|
+
continue
|
|
841
|
+
return True
|
|
842
|
+
return False
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
RUN_FILE_PROGRAMS = {
|
|
846
|
+
"cat", "cp", "mv", "rm", "tee", "touch", "truncate", "install", "rsync", "scp",
|
|
847
|
+
"tar", "unzip", "zip", "base64", "xxd", "strings", "od", "hexdump", "file", "stat",
|
|
848
|
+
"head", "tail", "less", "more", "sed", "awk", "nl", "bat", "rg", "grep", "find", "fd",
|
|
849
|
+
"ls", "tree", "du", "open", "sha256sum", "shasum", "md5sum", "md5", "cksum", "chmod",
|
|
850
|
+
"chown", "unlink", "shred", "dd", "gzip", "gunzip", "bzip2", "xz", "python", "python3",
|
|
851
|
+
"node", "bun", "deno", "ruby", "perl", "php", "lua",
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def _token_is_run_target(token: str, cwd: Path, anchor: Path | None = None) -> bool:
|
|
856
|
+
if not token or token in REDIRECT_OPERATORS or token.startswith("-"):
|
|
857
|
+
return False
|
|
858
|
+
lexical, resolved, run_root = _run_relative(token, cwd, anchor)
|
|
859
|
+
return _under_run(lexical, run_root) or _under_run(resolved, run_root)
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def _analyze_run_command(command: str, cwd: Path, depth: int = 0, anchor: Path | None = None) -> bool:
|
|
863
|
+
anchor = anchor or cwd
|
|
864
|
+
tokens = _shell_tokens(command)
|
|
865
|
+
current_cwd = cwd
|
|
866
|
+
for segment in _segments(tokens):
|
|
867
|
+
index = 0
|
|
868
|
+
while index < len(segment) and (ASSIGNMENT_RE.match(segment[index]) or segment[index] in WRAPPER_PROGRAMS):
|
|
869
|
+
index += 1
|
|
870
|
+
if index >= len(segment):
|
|
871
|
+
continue
|
|
872
|
+
program = os.path.basename(segment[index]).lower()
|
|
873
|
+
operands = segment[index + 1:]
|
|
874
|
+
if program in SHELL_PROGRAMS and depth < 3:
|
|
875
|
+
for pos, operand in enumerate(operands):
|
|
876
|
+
if operand.startswith("-") and "c" in operand[1:] and pos + 1 < len(operands):
|
|
877
|
+
if _analyze_run_command(operands[pos + 1], current_cwd, depth + 1, anchor):
|
|
878
|
+
return True
|
|
879
|
+
if program == "cd" and operands:
|
|
880
|
+
if _token_is_run_target(operands[-1], current_cwd, anchor):
|
|
881
|
+
return True
|
|
882
|
+
candidate = Path(_expand_shell_path(operands[-1]))
|
|
883
|
+
current_cwd = candidate if candidate.is_absolute() else current_cwd / candidate
|
|
884
|
+
current_cwd = Path(os.path.normpath(str(current_cwd)))
|
|
885
|
+
continue
|
|
886
|
+
# Any shell redirection is a real filesystem access, even when the
|
|
887
|
+
# command producing the bytes is otherwise harmless (e.g. echo).
|
|
888
|
+
previous = ""
|
|
889
|
+
for operand in operands:
|
|
890
|
+
if previous in REDIRECT_OPERATORS and _token_is_run_target(operand, current_cwd, anchor):
|
|
891
|
+
return True
|
|
892
|
+
previous = operand
|
|
893
|
+
# Inline interpreters can open arbitrary files. Reject the source
|
|
894
|
+
# text conservatively whenever it names the protected tree.
|
|
895
|
+
if program in {"python", "python3", "node", "bun", "deno", "ruby", "perl", "php"}:
|
|
896
|
+
for pos, operand in enumerate(operands):
|
|
897
|
+
if operand in {"-c", "-e", "--eval"} and pos + 1 < len(operands):
|
|
898
|
+
if ".devflow/runs" in operands[pos + 1]:
|
|
899
|
+
return True
|
|
900
|
+
if any(operand.startswith(prefix) for prefix in ("--eval=", "-e=")) and ".devflow/runs" in operand.split("=", 1)[1]:
|
|
901
|
+
return True
|
|
902
|
+
|
|
903
|
+
# Every non-option operand is a potential filesystem target, regardless
|
|
904
|
+
# of the executable. Only documented text positions are exempt.
|
|
905
|
+
ignored: set[int] = set()
|
|
906
|
+
if program in {"echo", "printf"}:
|
|
907
|
+
ignored.update(range(len(operands)))
|
|
908
|
+
elif program in {"rg", "grep", "ag", "ack"}:
|
|
909
|
+
seen_pattern = False
|
|
910
|
+
pos = 0
|
|
911
|
+
while pos < len(operands):
|
|
912
|
+
operand = operands[pos]
|
|
913
|
+
if operand in {"-e", "--regexp"} and pos + 1 < len(operands):
|
|
914
|
+
ignored.add(pos + 1)
|
|
915
|
+
pos += 2
|
|
916
|
+
continue
|
|
917
|
+
if operand.startswith("--regexp="):
|
|
918
|
+
ignored.add(pos)
|
|
919
|
+
elif not operand.startswith("-") and not seen_pattern:
|
|
920
|
+
ignored.add(pos)
|
|
921
|
+
seen_pattern = True
|
|
922
|
+
pos += 1
|
|
923
|
+
elif program == "git":
|
|
924
|
+
for pos, operand in enumerate(operands):
|
|
925
|
+
if operand in {"-m", "--message"} and pos + 1 < len(operands):
|
|
926
|
+
ignored.add(pos + 1)
|
|
927
|
+
elif operand.startswith("--message=") or operand.startswith("-m="):
|
|
928
|
+
ignored.add(pos)
|
|
929
|
+
|
|
930
|
+
for pos, operand in enumerate(operands):
|
|
931
|
+
if pos in ignored or operand in REDIRECT_OPERATORS:
|
|
932
|
+
continue
|
|
933
|
+
target = operand
|
|
934
|
+
if operand.startswith("--") and "=" in operand:
|
|
935
|
+
target = operand.split("=", 1)[1]
|
|
936
|
+
if not target or target.startswith("-"):
|
|
937
|
+
continue
|
|
938
|
+
if _token_is_run_target(target, current_cwd, anchor):
|
|
939
|
+
return True
|
|
940
|
+
return False
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
def command_touches_run_artifacts(command: str, cwd: Path | None = None) -> bool:
|
|
944
|
+
"""Deny shell access to real run-artifact paths, not textual mentions."""
|
|
945
|
+
return _analyze_run_command(command, cwd or Path.cwd())
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
def tool_touches_run_artifacts(event: dict[str, Any], root: Path) -> tuple[bool, list[str]]:
|
|
949
|
+
cwd = Path(str(event.get("cwd") or root)).expanduser()
|
|
950
|
+
blocked = [raw for raw in _raw_tool_paths(event) if path_is_run_artifact(raw, cwd, event)]
|
|
951
|
+
if blocked:
|
|
952
|
+
return True, sorted(dict.fromkeys(blocked))
|
|
953
|
+
command = command_text(event)
|
|
954
|
+
if command and command_touches_run_artifacts(command, cwd):
|
|
955
|
+
return True, ["<shell command>"]
|
|
956
|
+
return False, []
|
|
957
|
+
|
|
958
|
+
|
|
714
959
|
def evidence_observed(event: dict[str, Any], root: Path, artifacts: list[str]) -> list[str]:
|
|
715
960
|
name = tool_name(event)
|
|
716
961
|
if not (any(marker in name for marker in READ_TOOL_MARKERS) or is_command_tool(event)):
|
|
@@ -15,8 +15,10 @@ from hook_common import (
|
|
|
15
15
|
read_event,
|
|
16
16
|
record_prechange_fingerprints,
|
|
17
17
|
required_evidence,
|
|
18
|
+
tool_touches_mcp_config,
|
|
18
19
|
tool_requests_protected_source,
|
|
19
20
|
tool_touches_control_store,
|
|
21
|
+
tool_touches_run_artifacts,
|
|
20
22
|
)
|
|
21
23
|
|
|
22
24
|
|
|
@@ -40,6 +42,13 @@ def main() -> int:
|
|
|
40
42
|
event = read_event()
|
|
41
43
|
root = project_root(event)
|
|
42
44
|
# Hard protection, evaluated before any soft/strict policy handling.
|
|
45
|
+
mcp_blocked, mcp_paths = tool_touches_mcp_config(event, root)
|
|
46
|
+
if mcp_blocked:
|
|
47
|
+
return deny(
|
|
48
|
+
"MCP_CONFIG_PROTECTED: $DEVFLOW_HOME/mcp.json and $DEVFLOW_HOME/mcp-audit/ are reserved for "
|
|
49
|
+
"Devflow MCP authorization and audit records; ordinary agent file and shell access is denied "
|
|
50
|
+
"regardless of DEVFLOW_SOFT_GATES. Blocked target: " + ", ".join(mcp_paths) + "."
|
|
51
|
+
)
|
|
43
52
|
control_blocked, control_paths = tool_touches_control_store(event, root)
|
|
44
53
|
if control_blocked:
|
|
45
54
|
return deny(
|
|
@@ -49,6 +58,15 @@ def main() -> int:
|
|
|
49
58
|
"Canonical acceptance-results*.json may be read with the Read/Grep file tools. "
|
|
50
59
|
f"Use devflow_approval(action=\"propose\"|\"list\") for approvals. Blocked target: {', '.join(control_paths)}."
|
|
51
60
|
)
|
|
61
|
+
run_blocked, run_paths = tool_touches_run_artifacts(event, root)
|
|
62
|
+
if run_blocked:
|
|
63
|
+
return deny(
|
|
64
|
+
"RUN_ARTIFACTS_PROTECTED: .devflow/runs/ contains trace, screenshots, runtime logs and other "
|
|
65
|
+
"user-local evidence that Agent tools may not read, write or access through bash. Trace and screenshots "
|
|
66
|
+
"are for the user to inspect locally with Playwright in a terminal; Agent failure analysis must read "
|
|
67
|
+
"the Core-produced result.json summary instead. Only pure-read access to a feature execution's "
|
|
68
|
+
"result.json is allowed. Blocked target: " + ", ".join(run_paths) + "."
|
|
69
|
+
)
|
|
52
70
|
policy = load_policy(root)
|
|
53
71
|
session_id = ensure_session(root, event)
|
|
54
72
|
required = required_evidence(root, policy)
|
|
@@ -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,
|
|
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.
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import argparse
|
|
6
|
+
import contextlib
|
|
7
|
+
import fcntl
|
|
6
8
|
import hashlib
|
|
7
9
|
import json
|
|
8
10
|
import os
|
|
@@ -35,6 +37,8 @@ ALLOWED_BINDING_MODES = {"automated": ("automated", "manual", "blocked"), "manua
|
|
|
35
37
|
RESULT_ORIGINS = {"command": "project_test_command", "http": "core_http_adapter", playwright_adapter.ADAPTER_KIND: playwright_adapter.RESULT_ORIGIN}
|
|
36
38
|
DEFAULT_RUN_RETENTION_DAYS = 30
|
|
37
39
|
DEFAULT_UNREFERENCED_RUN_TTL_DAYS = 7
|
|
40
|
+
MAX_REPORT_ERRORS = 50
|
|
41
|
+
MAX_REPORT_ERROR_CHARS = 500
|
|
38
42
|
|
|
39
43
|
|
|
40
44
|
def utc_now() -> str:
|
|
@@ -427,7 +431,36 @@ def run_command_case(root: Path, argv: list[str]) -> tuple[int, str]:
|
|
|
427
431
|
return 127, f"command could not start: {error}"
|
|
428
432
|
|
|
429
433
|
|
|
434
|
+
@contextlib.contextmanager
|
|
435
|
+
def acceptance_run_lock(root: Path, feature: str):
|
|
436
|
+
"""Hold the feature write lock for the complete run, including result writes."""
|
|
437
|
+
lock_path = root / ".devflow/planning/test-runs" / feature / ".run.lock"
|
|
438
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
439
|
+
handle = lock_path.open("a+")
|
|
440
|
+
try:
|
|
441
|
+
try:
|
|
442
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
443
|
+
except BlockingIOError:
|
|
444
|
+
yield False
|
|
445
|
+
return
|
|
446
|
+
yield True
|
|
447
|
+
finally:
|
|
448
|
+
try:
|
|
449
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
450
|
+
finally:
|
|
451
|
+
handle.close()
|
|
452
|
+
|
|
453
|
+
|
|
430
454
|
def run(root: Path, feature: str, write: bool) -> dict[str, Any]:
|
|
455
|
+
if write:
|
|
456
|
+
with acceptance_run_lock(root, feature) as acquired:
|
|
457
|
+
if not acquired:
|
|
458
|
+
return {"ok": False, "feature": feature, "reason": "acceptance_run_in_progress", "errors": []}
|
|
459
|
+
return _run_locked(root, feature, write)
|
|
460
|
+
return _run_locked(root, feature, write)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _run_locked(root: Path, feature: str, write: bool) -> dict[str, Any]:
|
|
431
464
|
validation, suite, bindings = validate(root, feature)
|
|
432
465
|
if not validation["ok"]:
|
|
433
466
|
return validation
|
|
@@ -447,7 +480,12 @@ def run(root: Path, feature: str, write: bool) -> dict[str, Any]:
|
|
|
447
480
|
source = cases[tc_id]
|
|
448
481
|
strategy = source.get("evidence_strategy")
|
|
449
482
|
surface, legacy = derive_surface(source)
|
|
450
|
-
|
|
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)}
|
|
451
489
|
frozen_mode = source.get("automation_mode")
|
|
452
490
|
if mode != frozen_mode:
|
|
453
491
|
common["downgrade"] = {"from": frozen_mode, "to": mode}
|
|
@@ -509,6 +547,8 @@ def run(root: Path, feature: str, write: bool) -> dict[str, Any]:
|
|
|
509
547
|
passed = sum(1 for item in results if item.get("status") == "passed")
|
|
510
548
|
failed = sum(1 for item in results if item.get("status") == "failed")
|
|
511
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")}
|
|
512
552
|
write_json(meta_path, {
|
|
513
553
|
"schema_version": 1,
|
|
514
554
|
"feature": feature,
|
|
@@ -518,6 +558,7 @@ def run(root: Path, feature: str, write: bool) -> dict[str, Any]:
|
|
|
518
558
|
"acceptance_tc_legacy_unclassified": sum(1 for item in results if item.get("legacy_unclassified")),
|
|
519
559
|
"acceptance_tc_downgraded": sum(1 for item in results if item.get("downgrade")),
|
|
520
560
|
"acceptance_tc_dsl_unapproved": sum(1 for item in results if item.get("dsl_approval") == "none"),
|
|
561
|
+
"oracle_source_counts": oracle_source_counts,
|
|
521
562
|
"acceptance_suite_graph_revision": payload["test_suite"].get("graph_revision"),
|
|
522
563
|
"acceptance_results_fingerprint": file_fingerprint(result_path),
|
|
523
564
|
"built_by": "core_acceptance_test_runner",
|
|
@@ -527,6 +568,55 @@ def run(root: Path, feature: str, write: bool) -> dict[str, Any]:
|
|
|
527
568
|
"errors": [] if all_passed else ["one or more frozen TC cases failed or are blocked"]}
|
|
528
569
|
|
|
529
570
|
|
|
571
|
+
def _report_error(value: Any) -> dict[str, Any]:
|
|
572
|
+
text = str(value)
|
|
573
|
+
# Reports are safe summaries: do not expose project or run locations from gate diagnostics.
|
|
574
|
+
text = re.sub(r"(?:[A-Za-z]:)?[^\s,;()]+(?:/|\\)[^\s,;()]+", "<path>", text)
|
|
575
|
+
text = re.sub(r"(?:^|\s)(?:/|~/?|\.\.?/)[^\s,;()]+", " <path>", text)
|
|
576
|
+
truncated = len(text) > MAX_REPORT_ERROR_CHARS
|
|
577
|
+
return {"message": text[:MAX_REPORT_ERROR_CHARS], "truncated": True} if truncated else {"message": text, "truncated": False}
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _result_summary(entry: dict[str, Any]) -> dict[str, Any]:
|
|
581
|
+
approval = entry.get("dsl_approval")
|
|
582
|
+
if isinstance(approval, dict):
|
|
583
|
+
approval = "approved" if approval.get("status") == "approved" else "none"
|
|
584
|
+
elif approval not in {"approved", "none"}:
|
|
585
|
+
approval = None
|
|
586
|
+
return {key: entry.get(key) for key in (
|
|
587
|
+
"tc_id", "status", "mode", "adapter_kind", "execution_surface", "oracle_source", "error_code"
|
|
588
|
+
)} | {"dsl_approval": approval, "legacy_unclassified": bool(entry.get("legacy_unclassified")),
|
|
589
|
+
"downgrade": entry.get("downgrade")}
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def report(root: Path, feature: str) -> dict[str, Any]:
|
|
593
|
+
_, _, result_path = paths(root, feature)
|
|
594
|
+
if not result_path.is_file():
|
|
595
|
+
return {"ok": False, "feature": feature, "reason": "acceptance_results_missing"}
|
|
596
|
+
try:
|
|
597
|
+
payload = json.loads(result_path.read_text(encoding="utf-8"))
|
|
598
|
+
except (OSError, json.JSONDecodeError):
|
|
599
|
+
return {"ok": False, "feature": feature, "reason": "acceptance_results_missing"}
|
|
600
|
+
if not isinstance(payload, dict) or not isinstance(payload.get("results"), list):
|
|
601
|
+
return {"ok": False, "feature": feature, "reason": "acceptance_results_missing"}
|
|
602
|
+
entries = [entry for entry in payload["results"] if isinstance(entry, dict)]
|
|
603
|
+
statuses = {status: sum(1 for entry in entries if entry.get("status") == status)
|
|
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")}
|
|
607
|
+
counts = {"passed": statuses["passed"], "failed": statuses["failed"], "blocked": statuses["blocked"],
|
|
608
|
+
"legacy_unclassified": sum(bool(entry.get("legacy_unclassified")) for entry in entries),
|
|
609
|
+
"downgraded": sum(bool(entry.get("downgrade")) for entry in entries),
|
|
610
|
+
"oracle_source": oracle_source_counts}
|
|
611
|
+
gate = validate_results(root, feature)
|
|
612
|
+
raw_errors = gate.get("errors", []) if isinstance(gate, dict) else []
|
|
613
|
+
return {"ok": True, "feature": feature,
|
|
614
|
+
"gate": {"ok": bool(gate.get("ok")), "reason": gate.get("reason"),
|
|
615
|
+
"errors": [_report_error(error) for error in raw_errors[:MAX_REPORT_ERRORS]]},
|
|
616
|
+
"executed_at": payload.get("executed_at"), "test_suite": payload.get("test_suite"),
|
|
617
|
+
"counts": counts, "results": [_result_summary(entry) for entry in entries]}
|
|
618
|
+
|
|
619
|
+
|
|
530
620
|
def run_evidence_expired(root: Path, feature: str, today: date | None = None) -> bool:
|
|
531
621
|
"""True only once release-closeout completed and the post-closeout retention window has elapsed.
|
|
532
622
|
|
|
@@ -610,6 +700,10 @@ def result_entry_errors(root: Path, feature: str, tc_id: str, source: dict[str,
|
|
|
610
700
|
"""Re-verify one recorded result against the CURRENT binding, frozen TC, approvals and evidence files."""
|
|
611
701
|
errors: list[str] = []
|
|
612
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}")
|
|
613
707
|
mode = binding.get("mode")
|
|
614
708
|
if entry.get("mode") != mode:
|
|
615
709
|
errors.append(f"recorded mode {entry.get('mode')!r} does not match the current binding mode {mode!r}")
|
|
@@ -764,7 +858,7 @@ def gc_runs(root: Path, feature: str, write: bool, retention_days: int = DEFAULT
|
|
|
764
858
|
|
|
765
859
|
def main() -> int:
|
|
766
860
|
parser = argparse.ArgumentParser()
|
|
767
|
-
parser.add_argument("action", choices=("validate", "validate-human", "run", "validate-results", "gc-runs"))
|
|
861
|
+
parser.add_argument("action", choices=("validate", "validate-human", "run", "validate-results", "report", "gc-runs"))
|
|
768
862
|
parser.add_argument("--feature", required=True)
|
|
769
863
|
parser.add_argument("--write", action="store_true")
|
|
770
864
|
parser.add_argument("--retention-days", type=int, default=DEFAULT_RUN_RETENTION_DAYS)
|
|
@@ -785,6 +879,8 @@ def main() -> int:
|
|
|
785
879
|
result = run(root, args.feature, args.write) if args.write else {"ok": False, "errors": ["--write is required for run"]}
|
|
786
880
|
elif args.action == "gc-runs":
|
|
787
881
|
result = gc_runs(root, args.feature, args.write, max(args.retention_days, 0), max(args.unreferenced_ttl_days, 0))
|
|
882
|
+
elif args.action == "report":
|
|
883
|
+
result = report(root, args.feature)
|
|
788
884
|
else:
|
|
789
885
|
result = validate_results(root, args.feature)
|
|
790
886
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
@@ -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
|
-
|
|
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|
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|