@guanlin4924/devflow-pi 0.1.39 → 0.1.47
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/package.json +1 -1
- package/plugins/devflow-core/extensions/devflow-core.ts +728 -39
- package/plugins/devflow-core/skills/wireframe-proposal/SKILL.md +13 -0
- package/plugins/devflow-core/skills/wireframe-proposal/agents/openai.yaml +4 -0
- package/plugins/devflow-core/skills/wireframe-render/SKILL.md +8 -0
- package/plugins/devflow-core/skills/wireframe-review/SKILL.md +8 -0
- package/plugins/devflow-core/skills/wireframe-validate/SKILL.md +8 -0
- package/plugins/devflow-core/skills/wireframe-validate/agents/openai.yaml +4 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/SKILL.md +16 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-candidate-delta.schema.yaml +41 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-claim.schema.yaml +10 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-model-policy.yaml +51 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-model.schema.yaml +241 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-source-registry.schema.yaml +80 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/proposal.schema.yaml +44 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/questions.schema.yaml +14 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_suite.rb +13 -3
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +334 -30
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_claim_validate.py +77 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_closure_e2e_seed.json +516 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_cross_model_check.py +76 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_impact.py +51 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_invalidation.rb +108 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_commit.rb +570 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_gate.rb +89 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_render.py +131 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_validate.py +1063 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_source_resolver.rb +365 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/checker_registry.rb +2 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/cross_track_consistency_check.rb +94 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +3 -3
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_contract.rb +3 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_runtime.rb +84 -6
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/inspect_state.rb +3 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +45 -12
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_recover.rb +386 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_run.rb +248 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +139 -14
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph_backfill.rb +143 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/session_rollover.rb +15 -4
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/sync_artifacts.rb +45 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +86 -2
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_commit.rb +222 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_render.py +231 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_validate.py +457 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/workflow_gate_contract.rb +94 -2
- package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +111 -1
- package/plugins/devflow-implementation/agents/implementation-worker.md +6 -0
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/code_index.py +107 -37
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/core/scanner.py +20 -1
- package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +1 -1
- package/src/constants.ts +1 -1
- package/src/runtime.ts +61 -24
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Fail-closed validator and canonical builder for wireframe proposals."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import unicodedata
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
DEFAULT_SCHEMA = Path(__file__).resolve().parents[1] / "contracts" / "proposal.schema.yaml"
|
|
16
|
+
DEFAULT_QUESTIONS_SCHEMA = Path(__file__).resolve().parents[1] / "contracts" / "questions.schema.yaml"
|
|
17
|
+
ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*$")
|
|
18
|
+
STATES = {"default", "empty", "loading", "error"}
|
|
19
|
+
PRESENTATIONS = {"page", "modal", "drawer"}
|
|
20
|
+
PLATFORMS = {"web", "mobile"}
|
|
21
|
+
ACTION_TYPES = {"open", "close", "back"}
|
|
22
|
+
ELEMENT_TYPES = set()
|
|
23
|
+
ELEMENT_ALLOWED: dict[str, set[str]] = {}
|
|
24
|
+
ATOMIC = {"input", "select", "button", "icon_button"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_yaml(path: Path) -> Any:
|
|
28
|
+
try:
|
|
29
|
+
import yaml # type: ignore
|
|
30
|
+
return yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
31
|
+
except ImportError:
|
|
32
|
+
result = subprocess.run(
|
|
33
|
+
["ruby", "-ryaml", "-rjson", "-e", "puts JSON.generate(YAML.safe_load(File.read(ARGV.fetch(0))))", str(path)],
|
|
34
|
+
text=True, capture_output=True, check=False,
|
|
35
|
+
)
|
|
36
|
+
if result.returncode != 0:
|
|
37
|
+
raise ValueError(result.stderr.strip() or "YAML parse failed")
|
|
38
|
+
return json.loads(result.stdout)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def schema_values(schema: dict[str, Any]) -> tuple[re.Pattern[str], set[str], set[str], set[str], set[str], dict[str, set[str]]]:
|
|
42
|
+
pattern = str(schema.get("id_pattern", r"^[A-Za-z0-9][A-Za-z0-9_-]*$"))
|
|
43
|
+
screens = schema.get("screen", {})
|
|
44
|
+
screen_props = screens.get("properties", {}) if isinstance(screens, dict) else {}
|
|
45
|
+
states = set(screen_props.get("states", {}).get("values", ["default", "empty", "loading", "error"]))
|
|
46
|
+
presentations = set(screen_props.get("presentation", {}).get("values", ["page", "modal", "drawer"]))
|
|
47
|
+
platforms = set(screen_props.get("platforms", {}).get("values", ["web", "mobile"]))
|
|
48
|
+
actions = schema.get("action", {}).get("properties", {}).get("type", {}).get("values", ["open", "close", "back"])
|
|
49
|
+
element_specs = schema.get("elements", {})
|
|
50
|
+
element_allowed = {typ: {"id", "type"} | set(spec) for typ, spec in element_specs.items() if isinstance(spec, dict)}
|
|
51
|
+
return re.compile(pattern), states, presentations, platforms, set(actions), element_allowed
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def issue(errors: list[dict[str, str]], code: str, path: str, message: str) -> None:
|
|
55
|
+
errors.append({"code": code, "path": path, "message": message})
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def unknown_keys(value: Any, allowed: set[str], path: str, errors: list[dict[str, str]]) -> None:
|
|
59
|
+
if not isinstance(value, dict):
|
|
60
|
+
return
|
|
61
|
+
for key in value:
|
|
62
|
+
if key not in allowed:
|
|
63
|
+
issue(errors, "WIREFRAME_UNKNOWN_FIELD", f"{path}.{key}", f"unknown field {key}")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def require_id(value: Any, code: str, path: str, errors: list[dict[str, str]], seen: set[str]) -> None:
|
|
67
|
+
if not isinstance(value, str) or not value:
|
|
68
|
+
issue(errors, code, path, "id is required")
|
|
69
|
+
elif not ID_RE.fullmatch(value):
|
|
70
|
+
issue(errors, code, path, "id must match ^[A-Za-z0-9][A-Za-z0-9_-]*$")
|
|
71
|
+
elif value in seen:
|
|
72
|
+
issue(errors, "WIREFRAME_DUPLICATE_ELEMENT_ID" if code == "WIREFRAME_ELEMENT_ID_MISSING" else "WIREFRAME_DUPLICATE_SCREEN_ID", path, f"duplicate id {value}")
|
|
73
|
+
else:
|
|
74
|
+
seen.add(value)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def non_empty(value: Any, code: str, path: str, errors: list[dict[str, str]]) -> None:
|
|
78
|
+
if not isinstance(value, str) or not value.strip():
|
|
79
|
+
issue(errors, code, path, "non-empty string required")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def validate_questions(value: Any, feature: str, schema: dict[str, Any]) -> tuple[list[dict[str, str]], list[str], dict[str, Any]]:
|
|
83
|
+
errors: list[dict[str, str]] = []
|
|
84
|
+
blocking_open: list[str] = []
|
|
85
|
+
metadata = {"status": "present", "unresolved": []}
|
|
86
|
+
if not isinstance(value, dict):
|
|
87
|
+
issue(errors, "WIREFRAME_QUESTION_SCHEMA_INVALID", "$", "questions root must be an object")
|
|
88
|
+
return errors, blocking_open, {"status": "invalid", "unresolved": []}
|
|
89
|
+
unknown_keys(value, {"schema_version", "feature", "questions"}, "$", errors)
|
|
90
|
+
if value.get("schema_version") != schema.get("schema_version", 1):
|
|
91
|
+
issue(errors, "WIREFRAME_QUESTION_SCHEMA_INVALID", "schema_version", "must match questions schema")
|
|
92
|
+
if value.get("feature") != feature:
|
|
93
|
+
issue(errors, "WIREFRAME_QUESTION_SCHEMA_INVALID", "feature", "must match proposal feature")
|
|
94
|
+
questions = value.get("questions")
|
|
95
|
+
if not isinstance(questions, list):
|
|
96
|
+
issue(errors, "WIREFRAME_QUESTION_SCHEMA_INVALID", "questions", "must be an array")
|
|
97
|
+
return errors, blocking_open, metadata
|
|
98
|
+
ids: set[str] = set()
|
|
99
|
+
for i, question in enumerate(questions):
|
|
100
|
+
path = f"questions[{i}]"
|
|
101
|
+
if not isinstance(question, dict):
|
|
102
|
+
issue(errors, "WIREFRAME_QUESTION_SCHEMA_INVALID", path, "question must be an object")
|
|
103
|
+
continue
|
|
104
|
+
unknown_keys(question, {"id", "text", "kind", "status", "target", "answer"}, path, errors)
|
|
105
|
+
qid = question.get("id")
|
|
106
|
+
if not isinstance(qid, str) or not ID_RE.fullmatch(qid):
|
|
107
|
+
issue(errors, "WIREFRAME_QUESTION_ID_MISSING", f"{path}.id", "id must match the proposal id pattern")
|
|
108
|
+
elif qid in ids:
|
|
109
|
+
issue(errors, "WIREFRAME_DUPLICATE_QUESTION_ID", f"{path}.id", f"duplicate id {qid}")
|
|
110
|
+
else:
|
|
111
|
+
ids.add(qid)
|
|
112
|
+
non_empty(question.get("text"), "WIREFRAME_QUESTION_TEXT_MISSING", f"{path}.text", errors)
|
|
113
|
+
if question.get("kind") not in {"blocking", "informational"}:
|
|
114
|
+
issue(errors, "WIREFRAME_QUESTION_SCHEMA_INVALID", f"{path}.kind", "must be blocking or informational")
|
|
115
|
+
if question.get("status") not in {"open", "resolved"}:
|
|
116
|
+
issue(errors, "WIREFRAME_QUESTION_SCHEMA_INVALID", f"{path}.status", "must be open or resolved")
|
|
117
|
+
target = question.get("target")
|
|
118
|
+
if target is not None:
|
|
119
|
+
if not isinstance(target, dict):
|
|
120
|
+
issue(errors, "WIREFRAME_QUESTION_SCHEMA_INVALID", f"{path}.target", "must be an object")
|
|
121
|
+
else:
|
|
122
|
+
unknown_keys(target, {"screen", "element"}, f"{path}.target", errors)
|
|
123
|
+
if any(not isinstance(target.get(k), str) or not ID_RE.fullmatch(target[k]) for k in target):
|
|
124
|
+
issue(errors, "WIREFRAME_QUESTION_SCHEMA_INVALID", f"{path}.target", "target IDs must match the proposal id pattern")
|
|
125
|
+
if "answer" in question and question["answer"] is not None and not isinstance(question["answer"], str):
|
|
126
|
+
issue(errors, "WIREFRAME_QUESTION_SCHEMA_INVALID", f"{path}.answer", "answer must be null or a string")
|
|
127
|
+
if question.get("kind") == "blocking" and question.get("status") == "open" and isinstance(qid, str):
|
|
128
|
+
blocking_open.append(qid)
|
|
129
|
+
metadata["unresolved"] = blocking_open
|
|
130
|
+
return errors, blocking_open, metadata
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def validate_inline(element: dict[str, Any], index: int, errors: list[dict[str, str]]) -> None:
|
|
134
|
+
typ = element.get("type")
|
|
135
|
+
path = f"screens[].elements[{index}]"
|
|
136
|
+
if typ == "page_header":
|
|
137
|
+
non_empty(element.get("title"), "WIREFRAME_MISSING_FIELD", f"{path}.title", errors)
|
|
138
|
+
refs = element.get("action_refs", [])
|
|
139
|
+
if not isinstance(refs, list) or not all(isinstance(x, str) for x in refs):
|
|
140
|
+
issue(errors, "WIREFRAME_INVALID_FIELD_VALUE", f"{path}.action_refs", "must be an array of IDs")
|
|
141
|
+
elif typ == "filter_bar":
|
|
142
|
+
fields = element.get("fields")
|
|
143
|
+
if not isinstance(fields, list) or not fields:
|
|
144
|
+
issue(errors, "WIREFRAME_MISSING_FIELD", f"{path}.fields", "fields must be a non-empty array")
|
|
145
|
+
else:
|
|
146
|
+
ids: set[str] = set()
|
|
147
|
+
for j, field in enumerate(fields):
|
|
148
|
+
if not isinstance(field, dict):
|
|
149
|
+
issue(errors, "WIREFRAME_INVALID_FIELD_VALUE", f"{path}.fields[{j}]", "field must be an object")
|
|
150
|
+
continue
|
|
151
|
+
unknown_keys(field, {"id", "label", "type"}, f"{path}.fields[{j}]", errors)
|
|
152
|
+
require_id(field.get("id"), "WIREFRAME_ELEMENT_ID_MISSING", f"{path}.fields[{j}].id", errors, ids)
|
|
153
|
+
non_empty(field.get("label"), "WIREFRAME_INVALID_FIELD_VALUE", f"{path}.fields[{j}].label", errors)
|
|
154
|
+
if field.get("type") not in {"input", "select"}:
|
|
155
|
+
issue(errors, "WIREFRAME_INVALID_FIELD_VALUE", f"{path}.fields[{j}].type", "must be input or select")
|
|
156
|
+
elif typ == "table":
|
|
157
|
+
columns = element.get("columns")
|
|
158
|
+
if not isinstance(columns, list) or not columns:
|
|
159
|
+
issue(errors, "WIREFRAME_MISSING_FIELD", f"{path}.columns", "columns must be a non-empty array")
|
|
160
|
+
else:
|
|
161
|
+
ids: set[str] = set()
|
|
162
|
+
for j, col in enumerate(columns):
|
|
163
|
+
if not isinstance(col, dict):
|
|
164
|
+
issue(errors, "WIREFRAME_INVALID_FIELD_VALUE", f"{path}.columns[{j}]", "column must be an object")
|
|
165
|
+
continue
|
|
166
|
+
unknown_keys(col, {"id", "label"}, f"{path}.columns[{j}]", errors)
|
|
167
|
+
require_id(col.get("id"), "WIREFRAME_ELEMENT_ID_MISSING", f"{path}.columns[{j}].id", errors, ids)
|
|
168
|
+
non_empty(col.get("label"), "WIREFRAME_INVALID_FIELD_VALUE", f"{path}.columns[{j}].label", errors)
|
|
169
|
+
elif typ == "card_list":
|
|
170
|
+
items = element.get("items")
|
|
171
|
+
if not isinstance(items, list) or not items:
|
|
172
|
+
issue(errors, "WIREFRAME_MISSING_FIELD", f"{path}.items", "items must be a non-empty array")
|
|
173
|
+
else:
|
|
174
|
+
ids: set[str] = set()
|
|
175
|
+
for j, item in enumerate(items):
|
|
176
|
+
if not isinstance(item, dict):
|
|
177
|
+
issue(errors, "WIREFRAME_INVALID_FIELD_VALUE", f"{path}.items[{j}]", "item must be an object")
|
|
178
|
+
continue
|
|
179
|
+
unknown_keys(item, {"id", "title", "meta"}, f"{path}.items[{j}]", errors)
|
|
180
|
+
require_id(item.get("id"), "WIREFRAME_ELEMENT_ID_MISSING", f"{path}.items[{j}].id", errors, ids)
|
|
181
|
+
non_empty(item.get("title"), "WIREFRAME_INVALID_FIELD_VALUE", f"{path}.items[{j}].title", errors)
|
|
182
|
+
meta = item.get("meta", [])
|
|
183
|
+
if not isinstance(meta, list) or any(not isinstance(x, dict) or set(x) != {"label", "value"} for x in meta):
|
|
184
|
+
issue(errors, "WIREFRAME_INVALID_FIELD_VALUE", f"{path}.items[{j}].meta", "must contain label/value objects")
|
|
185
|
+
elif typ == "bottom_navigation":
|
|
186
|
+
items = element.get("items")
|
|
187
|
+
if not isinstance(items, list) or not items:
|
|
188
|
+
issue(errors, "WIREFRAME_MISSING_FIELD", f"{path}.items", "items must be a non-empty array")
|
|
189
|
+
else:
|
|
190
|
+
for j, item in enumerate(items):
|
|
191
|
+
if not isinstance(item, dict):
|
|
192
|
+
issue(errors, "WIREFRAME_INVALID_FIELD_VALUE", f"{path}.items[{j}]", "item must be an object")
|
|
193
|
+
continue
|
|
194
|
+
unknown_keys(item, {"id", "label", "action_ref"}, f"{path}.items[{j}]", errors)
|
|
195
|
+
non_empty(item.get("id"), "WIREFRAME_INVALID_FIELD_VALUE", f"{path}.items[{j}].id", errors)
|
|
196
|
+
non_empty(item.get("label"), "WIREFRAME_INVALID_FIELD_VALUE", f"{path}.items[{j}].label", errors)
|
|
197
|
+
if "action_ref" in item:
|
|
198
|
+
non_empty(item.get("action_ref"), "WIREFRAME_INVALID_FIELD_VALUE", f"{path}.items[{j}].action_ref", errors)
|
|
199
|
+
elif typ == "tabs":
|
|
200
|
+
tabs = element.get("tabs")
|
|
201
|
+
if not isinstance(tabs, list) or not tabs:
|
|
202
|
+
issue(errors, "WIREFRAME_MISSING_FIELD", f"{path}.tabs", "tabs must be a non-empty array")
|
|
203
|
+
else:
|
|
204
|
+
for j, tab in enumerate(tabs):
|
|
205
|
+
if not isinstance(tab, dict):
|
|
206
|
+
issue(errors, "WIREFRAME_INVALID_FIELD_VALUE", f"{path}.tabs[{j}]", "tab must be an object")
|
|
207
|
+
continue
|
|
208
|
+
unknown_keys(tab, {"id", "label"}, f"{path}.tabs[{j}]", errors)
|
|
209
|
+
non_empty(tab.get("id"), "WIREFRAME_INVALID_FIELD_VALUE", f"{path}.tabs[{j}].id", errors)
|
|
210
|
+
non_empty(tab.get("label"), "WIREFRAME_INVALID_FIELD_VALUE", f"{path}.tabs[{j}].label", errors)
|
|
211
|
+
elif typ in ATOMIC:
|
|
212
|
+
non_empty(element.get("label"), "WIREFRAME_MISSING_FIELD", f"{path}.label", errors)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def validate(data: Any, traceability: Any, schema: dict[str, Any], business_model: Any = None) -> tuple[list[dict[str, str]], dict[str, Any] | None]:
|
|
216
|
+
global ID_RE, STATES, PRESENTATIONS, PLATFORMS, ACTION_TYPES, ELEMENT_TYPES, ELEMENT_ALLOWED, ATOMIC
|
|
217
|
+
ID_RE, STATES, PRESENTATIONS, PLATFORMS, ACTION_TYPES, ELEMENT_ALLOWED = schema_values(schema)
|
|
218
|
+
ELEMENT_TYPES = set(ELEMENT_ALLOWED)
|
|
219
|
+
ATOMIC = ELEMENT_TYPES - {"page_header", "filter_bar", "table", "card_list", "bottom_navigation", "tabs"}
|
|
220
|
+
errors: list[dict[str, str]] = []
|
|
221
|
+
business_actions = set()
|
|
222
|
+
if business_model is not None and isinstance(business_model, dict):
|
|
223
|
+
business_actions = {item.get("id") for item in business_model.get("actions", []) if isinstance(item, dict)}
|
|
224
|
+
if not business_actions:
|
|
225
|
+
issue(errors, "WIREFRAME_BUSINESS_MODEL_INVALID", "business_model", "business model has no actions")
|
|
226
|
+
if not isinstance(data, dict):
|
|
227
|
+
issue(errors, "WIREFRAME_SCHEMA_VERSION_INVALID", "$", "root must be an object")
|
|
228
|
+
return errors, None
|
|
229
|
+
unknown_keys(data, {"schema_version", "feature", "screens"}, "$", errors)
|
|
230
|
+
if data.get("schema_version") != 1:
|
|
231
|
+
issue(errors, "WIREFRAME_SCHEMA_VERSION_INVALID", "schema_version", "must be 1")
|
|
232
|
+
non_empty(data.get("feature"), "WIREFRAME_FEATURE_MISSING", "feature", errors)
|
|
233
|
+
screens = data.get("screens")
|
|
234
|
+
if not isinstance(screens, list) or not screens:
|
|
235
|
+
issue(errors, "WIREFRAME_SCREENS_MISSING", "screens", "must be a non-empty array")
|
|
236
|
+
return errors, None
|
|
237
|
+
reqs = set()
|
|
238
|
+
if isinstance(traceability, dict):
|
|
239
|
+
reqs = {r.get("id") for r in traceability.get("requirements", []) if isinstance(r, dict) and r.get("status") in {"confirmed", "approved"} and r.get("scope", "in") == "in"}
|
|
240
|
+
screen_ids: set[str] = set()
|
|
241
|
+
canonical_screens: list[dict[str, Any]] = []
|
|
242
|
+
for i, screen in enumerate(screens):
|
|
243
|
+
path = f"screens[{i}]"
|
|
244
|
+
if not isinstance(screen, dict):
|
|
245
|
+
issue(errors, "WIREFRAME_SCREENS_MISSING", path, "screen must be an object")
|
|
246
|
+
continue
|
|
247
|
+
unknown_keys(screen, {"id", "title", "presentation", "platforms", "requirement_refs", "elements", "actions", "states"}, path, errors)
|
|
248
|
+
sid = screen.get("id")
|
|
249
|
+
if not isinstance(sid, str) or not sid:
|
|
250
|
+
issue(errors, "WIREFRAME_SCREEN_ID_MISSING", f"{path}.id", "id is required")
|
|
251
|
+
elif not ID_RE.fullmatch(sid):
|
|
252
|
+
issue(errors, "WIREFRAME_SCREEN_ID_MISSING", f"{path}.id", "id format is invalid")
|
|
253
|
+
elif sid in screen_ids:
|
|
254
|
+
issue(errors, "WIREFRAME_DUPLICATE_SCREEN_ID", f"{path}.id", f"duplicate id {sid}")
|
|
255
|
+
else:
|
|
256
|
+
screen_ids.add(sid)
|
|
257
|
+
non_empty(screen.get("title"), "WIREFRAME_SCREEN_TITLE_MISSING", f"{path}.title", errors)
|
|
258
|
+
presentation = screen.get("presentation", "page")
|
|
259
|
+
if presentation not in PRESENTATIONS:
|
|
260
|
+
issue(errors, "WIREFRAME_INVALID_PRESENTATION", f"{path}.presentation", "invalid presentation")
|
|
261
|
+
platforms = screen.get("platforms", ["web", "mobile"])
|
|
262
|
+
if not isinstance(platforms, list) or not platforms or any(x not in PLATFORMS for x in platforms) or len(set(platforms)) != len(platforms):
|
|
263
|
+
issue(errors, "WIREFRAME_INVALID_PLATFORM", f"{path}.platforms", "must be unique web/mobile values")
|
|
264
|
+
refs = screen.get("requirement_refs")
|
|
265
|
+
if not isinstance(refs, list) or not refs:
|
|
266
|
+
issue(errors, "WIREFRAME_SCREEN_REQUIREMENT_REFS_EMPTY", f"{path}.requirement_refs", "at least one requirement ref is required")
|
|
267
|
+
else:
|
|
268
|
+
for j, ref in enumerate(refs):
|
|
269
|
+
if ref not in reqs:
|
|
270
|
+
issue(errors, "WIREFRAME_UNKNOWN_REQUIREMENT_REF", f"{path}.requirement_refs[{j}]", f"unknown confirmed requirement {ref}")
|
|
271
|
+
elements = screen.get("elements")
|
|
272
|
+
if not isinstance(elements, list):
|
|
273
|
+
issue(errors, "WIREFRAME_MISSING_FIELD", f"{path}.elements", "elements must be an array")
|
|
274
|
+
elements = []
|
|
275
|
+
actions = screen.get("actions", [])
|
|
276
|
+
if not isinstance(actions, list):
|
|
277
|
+
issue(errors, "WIREFRAME_INVALID_FIELD_VALUE", f"{path}.actions", "actions must be an array")
|
|
278
|
+
actions = []
|
|
279
|
+
states = screen.get("states")
|
|
280
|
+
if not isinstance(states, list):
|
|
281
|
+
issue(errors, "WIREFRAME_STATES_MISSING", f"{path}.states", "states must be an array")
|
|
282
|
+
states = []
|
|
283
|
+
if "default" not in states:
|
|
284
|
+
issue(errors, "WIREFRAME_STATE_DEFAULT_MISSING", f"{path}.states", "default state is required")
|
|
285
|
+
if len(states) != len(set(states)):
|
|
286
|
+
issue(errors, "WIREFRAME_INVALID_STATE", f"{path}.states", "states must not contain duplicates")
|
|
287
|
+
for j, state in enumerate(states):
|
|
288
|
+
if state not in STATES:
|
|
289
|
+
issue(errors, "WIREFRAME_INVALID_STATE", f"{path}.states[{j}]", "invalid state")
|
|
290
|
+
element_ids: set[str] = set()
|
|
291
|
+
for j, element in enumerate(elements):
|
|
292
|
+
if not isinstance(element, dict):
|
|
293
|
+
issue(errors, "WIREFRAME_INVALID_FIELD_VALUE", f"{path}.elements[{j}]", "element must be an object")
|
|
294
|
+
continue
|
|
295
|
+
typ = element.get("type")
|
|
296
|
+
allowed = ELEMENT_ALLOWED.get(typ, {"id", "type"})
|
|
297
|
+
unknown_keys(element, allowed, f"{path}.elements[{j}]", errors)
|
|
298
|
+
require_id(element.get("id"), "WIREFRAME_ELEMENT_ID_MISSING", f"{path}.elements[{j}].id", errors, element_ids)
|
|
299
|
+
if typ not in ELEMENT_TYPES:
|
|
300
|
+
issue(errors, "WIREFRAME_UNKNOWN_ELEMENT_TYPE", f"{path}.elements[{j}].type", f"unknown element type {typ}")
|
|
301
|
+
else:
|
|
302
|
+
validate_inline(element, j, errors)
|
|
303
|
+
action_ids: set[str] = set()
|
|
304
|
+
for j, action in enumerate(actions):
|
|
305
|
+
apath = f"{path}.actions[{j}]"
|
|
306
|
+
if not isinstance(action, dict):
|
|
307
|
+
issue(errors, "WIREFRAME_INVALID_FIELD_VALUE", apath, "action must be an object")
|
|
308
|
+
continue
|
|
309
|
+
unknown_keys(action, {"id", "type", "target", "business_action_ref"}, apath, errors)
|
|
310
|
+
aid = action.get("id")
|
|
311
|
+
if not isinstance(aid, str) or not aid:
|
|
312
|
+
issue(errors, "WIREFRAME_ACTION_ID_MISSING", f"{apath}.id", "id is required")
|
|
313
|
+
elif not ID_RE.fullmatch(aid):
|
|
314
|
+
issue(errors, "WIREFRAME_ACTION_ID_MISSING", f"{apath}.id", "id format is invalid")
|
|
315
|
+
elif aid in action_ids:
|
|
316
|
+
issue(errors, "WIREFRAME_DUPLICATE_ACTION_ID", f"{apath}.id", f"duplicate id {aid}")
|
|
317
|
+
else:
|
|
318
|
+
action_ids.add(aid)
|
|
319
|
+
if action.get("type") not in ACTION_TYPES:
|
|
320
|
+
issue(errors, "WIREFRAME_INVALID_ACTION_TYPE", f"{apath}.type", "must be open, close or back")
|
|
321
|
+
elif action.get("type") == "open" and not isinstance(action.get("target"), str):
|
|
322
|
+
issue(errors, "WIREFRAME_ACTION_TARGET_SHAPE", f"{apath}.target", "open target must be a screen id")
|
|
323
|
+
elif action.get("type") in {"close", "back"} and action.get("target") is not None:
|
|
324
|
+
issue(errors, "WIREFRAME_ACTION_TARGET_SHAPE", f"{apath}.target", "close/back target must be null")
|
|
325
|
+
if business_model is not None and action.get("type") == "open":
|
|
326
|
+
ref = action.get("business_action_ref")
|
|
327
|
+
if not isinstance(ref, str) or not ref:
|
|
328
|
+
issue(errors, "WIREFRAME_BUSINESS_ACTION_REF_MISSING", f"{apath}.business_action_ref", "open action must reference a business action")
|
|
329
|
+
elif ref not in business_actions:
|
|
330
|
+
issue(errors, "WIREFRAME_BUSINESS_ACTION_REF_UNRESOLVED", f"{apath}.business_action_ref", f"business action {ref} does not exist")
|
|
331
|
+
refs_used: list[str] = []
|
|
332
|
+
for element in elements:
|
|
333
|
+
if isinstance(element, dict):
|
|
334
|
+
refs_used += element.get("action_refs", []) if isinstance(element.get("action_refs"), list) else []
|
|
335
|
+
if isinstance(element.get("action_ref"), str): refs_used.append(element["action_ref"])
|
|
336
|
+
if element.get("type") == "bottom_navigation":
|
|
337
|
+
refs_used += [x.get("action_ref") for x in element.get("items", []) if isinstance(x, dict) and isinstance(x.get("action_ref"), str)]
|
|
338
|
+
for ref in refs_used:
|
|
339
|
+
if ref not in action_ids:
|
|
340
|
+
issue(errors, "WIREFRAME_ACTION_REF_UNRESOLVED", f"{path}.elements", f"action ref {ref} is not defined on this screen")
|
|
341
|
+
canonical = dict(screen)
|
|
342
|
+
canonical.setdefault("presentation", "page")
|
|
343
|
+
canonical.setdefault("platforms", ["web", "mobile"])
|
|
344
|
+
canonical.setdefault("actions", [])
|
|
345
|
+
canonical_screens.append(canonical)
|
|
346
|
+
# Validate targets after the full screen namespace is known; forward screen
|
|
347
|
+
# references are valid and must not depend on YAML traversal order.
|
|
348
|
+
for i, screen in enumerate(screens):
|
|
349
|
+
if not isinstance(screen, dict) or not isinstance(screen.get("actions", []), list):
|
|
350
|
+
continue
|
|
351
|
+
for j, action in enumerate(screen.get("actions", [])):
|
|
352
|
+
if isinstance(action, dict) and action.get("type") == "open" and action.get("target") not in screen_ids:
|
|
353
|
+
issue(errors, "WIREFRAME_INVALID_ACTION_TARGET", f"screens[{i}].actions[{j}].target", "target screen does not exist")
|
|
354
|
+
canonical = {"schema_version": 1, "feature": data.get("feature"), "screens": canonical_screens}
|
|
355
|
+
return errors, canonical
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def normalize(value: Any) -> Any:
|
|
359
|
+
if isinstance(value, str):
|
|
360
|
+
return unicodedata.normalize("NFKC", value).rstrip()
|
|
361
|
+
if isinstance(value, list):
|
|
362
|
+
return [normalize(x) for x in value]
|
|
363
|
+
if isinstance(value, dict):
|
|
364
|
+
return {k: normalize(v) for k, v in value.items()}
|
|
365
|
+
return value
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
ROOT_ORDER = ["schema_version", "feature", "screens"]
|
|
369
|
+
SCREEN_ORDER = ["id", "title", "presentation", "platforms", "requirement_refs", "elements", "actions", "states"]
|
|
370
|
+
ELEMENT_ORDER = ["id", "type", "title", "label", "action_refs", "action_ref", "fields", "columns", "items", "tabs"]
|
|
371
|
+
ACTION_ORDER = ["id", "type", "target", "business_action_ref"]
|
|
372
|
+
INLINE_ORDER = ["id", "label", "type", "title", "meta", "value", "action_ref"]
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def reorder(value: Any, order: list[str]) -> Any:
|
|
376
|
+
if isinstance(value, list):
|
|
377
|
+
return [reorder(x, INLINE_ORDER) for x in value]
|
|
378
|
+
if not isinstance(value, dict):
|
|
379
|
+
return value
|
|
380
|
+
keys = [key for key in order if key in value]
|
|
381
|
+
keys += sorted(key for key in value if key not in keys)
|
|
382
|
+
return {key: reorder(value[key], INLINE_ORDER) for key in keys}
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def canonical_order(value: dict[str, Any]) -> dict[str, Any]:
|
|
386
|
+
result = {"schema_version": value["schema_version"], "feature": value["feature"], "screens": []}
|
|
387
|
+
for screen in value["screens"]:
|
|
388
|
+
ordered_screen = {key: screen[key] for key in SCREEN_ORDER if key in screen}
|
|
389
|
+
ordered_screen["elements"] = []
|
|
390
|
+
for element in screen.get("elements", []):
|
|
391
|
+
ordered_element = {key: element[key] for key in ELEMENT_ORDER if key in element}
|
|
392
|
+
ordered_element = reorder(ordered_element, ELEMENT_ORDER)
|
|
393
|
+
ordered_screen["elements"].append(ordered_element)
|
|
394
|
+
ordered_screen["actions"] = [reorder(action, ACTION_ORDER) for action in screen.get("actions", [])]
|
|
395
|
+
ordered_screen["states"] = list(screen.get("states", []))
|
|
396
|
+
result["screens"].append(ordered_screen)
|
|
397
|
+
return result
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def main() -> int:
|
|
401
|
+
parser = argparse.ArgumentParser()
|
|
402
|
+
parser.add_argument("--proposal", required=True, type=Path)
|
|
403
|
+
parser.add_argument("--traceability", required=True, type=Path)
|
|
404
|
+
parser.add_argument("--schema", type=Path, default=DEFAULT_SCHEMA)
|
|
405
|
+
parser.add_argument("--questions", type=Path)
|
|
406
|
+
parser.add_argument("--questions-schema", type=Path, default=DEFAULT_QUESTIONS_SCHEMA)
|
|
407
|
+
parser.add_argument("--canonical-out", type=Path)
|
|
408
|
+
parser.add_argument("--business-canonical", type=Path)
|
|
409
|
+
args = parser.parse_args()
|
|
410
|
+
try:
|
|
411
|
+
proposal = load_yaml(args.proposal)
|
|
412
|
+
schema = load_yaml(args.schema)
|
|
413
|
+
questions_schema = load_yaml(args.questions_schema)
|
|
414
|
+
traceability = json.loads(args.traceability.read_text(encoding="utf-8"))
|
|
415
|
+
business_model = json.loads(args.business_canonical.read_text(encoding="utf-8")) if args.business_canonical else None
|
|
416
|
+
except Exception as error:
|
|
417
|
+
print(json.dumps({"valid": False, "errors": [{"code": "WIREFRAME_PARSE_ERROR", "path": "$", "message": str(error)}]}, ensure_ascii=False))
|
|
418
|
+
return 1
|
|
419
|
+
if not isinstance(schema, dict):
|
|
420
|
+
print(json.dumps({"valid": False, "errors": [{"code": "WIREFRAME_PARSE_ERROR", "path": "schema", "message": "schema root must be an object"}]}, ensure_ascii=False))
|
|
421
|
+
return 1
|
|
422
|
+
errors, canonical = validate(proposal, traceability, schema, business_model)
|
|
423
|
+
questions_path = args.questions
|
|
424
|
+
if questions_path is None and isinstance(proposal, dict) and proposal.get("feature"):
|
|
425
|
+
questions_path = Path(".planning") / "features" / str(proposal["feature"]) / "questions.yaml"
|
|
426
|
+
question_meta: dict[str, Any]
|
|
427
|
+
blocking_open: list[str] = []
|
|
428
|
+
if questions_path is None or not questions_path.exists():
|
|
429
|
+
question_meta = {"status": "unavailable", "unresolved": []}
|
|
430
|
+
else:
|
|
431
|
+
try:
|
|
432
|
+
questions = load_yaml(questions_path)
|
|
433
|
+
if not isinstance(questions_schema, dict):
|
|
434
|
+
raise ValueError("questions schema root must be an object")
|
|
435
|
+
question_errors, blocking_open, question_meta = validate_questions(questions, proposal.get("feature", "") if isinstance(proposal, dict) else "", questions_schema)
|
|
436
|
+
errors.extend(question_errors)
|
|
437
|
+
except Exception as error:
|
|
438
|
+
issue(errors, "WIREFRAME_QUESTION_SCHEMA_INVALID", str(questions_path), str(error))
|
|
439
|
+
question_meta = {"status": "invalid", "unresolved": []}
|
|
440
|
+
if errors or canonical is None:
|
|
441
|
+
print(json.dumps({"valid": False, "verdict": "block_unsafe", "questions": question_meta, "errors": errors}, ensure_ascii=False, indent=2))
|
|
442
|
+
return 1
|
|
443
|
+
if blocking_open:
|
|
444
|
+
print(json.dumps({"valid": False, "verdict": "await_user", "questions": question_meta, "errors": [{"code": "WIREFRAME_QUESTIONS_UNRESOLVED", "path": "questions", "message": "blocking questions remain open: " + ", ".join(blocking_open), "question_ids": blocking_open}]}, ensure_ascii=False, indent=2))
|
|
445
|
+
return 1
|
|
446
|
+
canonical = canonical_order(normalize(canonical))
|
|
447
|
+
encoded = json.dumps(canonical, ensure_ascii=False, separators=(",", ":"), sort_keys=False) + "\n"
|
|
448
|
+
fingerprint = "sha256:" + hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
|
449
|
+
if args.canonical_out:
|
|
450
|
+
args.canonical_out.parent.mkdir(parents=True, exist_ok=True)
|
|
451
|
+
args.canonical_out.write_text(encoded, encoding="utf-8")
|
|
452
|
+
print(json.dumps({"valid": True, "verdict": "allow", "questions": question_meta, "canonical_fingerprint": fingerprint, "canonical": canonical}, ensure_ascii=False, indent=2))
|
|
453
|
+
return 0
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
if __name__ == "__main__":
|
|
457
|
+
raise SystemExit(main())
|
|
@@ -4,6 +4,11 @@
|
|
|
4
4
|
# Validates the existing workflow topology as a GateContract source. This is
|
|
5
5
|
# intentionally declarative and read-only: it does not advance STATE or alter
|
|
6
6
|
# failure-branch behavior.
|
|
7
|
+
require "json"
|
|
8
|
+
require "optparse"
|
|
9
|
+
require "yaml"
|
|
10
|
+
require_relative "checker_registry"
|
|
11
|
+
|
|
7
12
|
module WorkflowGateContract
|
|
8
13
|
CHECKPOINT_FIELDS = %w[gate_after producing_step required_outputs required_meta_output required_meta_fields prompt confirm_options skip_policy resume_behavior].freeze
|
|
9
14
|
|
|
@@ -11,6 +16,11 @@ module WorkflowGateContract
|
|
|
11
16
|
|
|
12
17
|
def validate(workflow)
|
|
13
18
|
errors = []
|
|
19
|
+
unless workflow.is_a?(Hash)
|
|
20
|
+
return [error("WORKFLOW_SCHEMA_INVALID", "workflow must be a mapping")]
|
|
21
|
+
end
|
|
22
|
+
steps = Array(workflow["steps"])
|
|
23
|
+
step_by_id = steps.each_with_object({}) { |step, index| index[step["id"].to_s] = step if step.is_a?(Hash) && step["id"] }
|
|
14
24
|
checkpoints = workflow["checkpoints"]
|
|
15
25
|
unless checkpoints.is_a?(Hash)
|
|
16
26
|
return [error("CHECKPOINTS_SCHEMA_INVALID", "workflow checkpoints must be a mapping")]
|
|
@@ -33,13 +43,79 @@ module WorkflowGateContract
|
|
|
33
43
|
errors << error("CHECKPOINT_REF_NOT_FOUND", "enforced checkpoint is not defined", checkpoint: id) unless checkpoints.key?(id)
|
|
34
44
|
end
|
|
35
45
|
|
|
46
|
+
step_checkers = workflow.dig("governance", "step_checkers")
|
|
47
|
+
if step_checkers.is_a?(Hash)
|
|
48
|
+
step_checkers.each do |step_id, checker_ids|
|
|
49
|
+
errors << error("CHECKER_STEP_REF_NOT_FOUND", "checker declaration references an unknown step", step: step_id) unless step_by_id.key?(step_id.to_s)
|
|
50
|
+
Array(checker_ids).each do |checker_id|
|
|
51
|
+
errors << error("UNKNOWN_CHECKER_ID", "workflow references an unknown checker", step: step_id, checker: checker_id) unless CheckerRegistry.known?(checker_id)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
36
56
|
Array(workflow["parallel_windows"]).each do |window|
|
|
37
|
-
|
|
38
|
-
|
|
57
|
+
unless window.is_a?(Hash) && window["join_step"] && window["branches"].is_a?(Hash)
|
|
58
|
+
errors << error("PARALLEL_WINDOW_SCHEMA_INVALID", "parallel window must define join_step and branches", window: window.is_a?(Hash) && window["id"])
|
|
59
|
+
next
|
|
60
|
+
end
|
|
61
|
+
window_id = window["id"]
|
|
62
|
+
errors << error("PARALLEL_AFTER_STEP_NOT_FOUND", "parallel window after_step is not a workflow step", window: window_id, step: window["after_step"]) if window["after_step"] && !step_by_id.key?(window["after_step"].to_s)
|
|
63
|
+
errors << error("PARALLEL_JOIN_STEP_NOT_FOUND", "parallel join_step is not a workflow step", window: window_id, step: window["join_step"]) unless step_by_id.key?(window["join_step"].to_s)
|
|
39
64
|
errors << error("PARALLEL_WINDOW_CHECKPOINT_INVALID", "parallel join checkpoint is not defined", window: window["id"], checkpoint: window["checkpoint"]) if window["checkpoint"] && !checkpoints.key?(window["checkpoint"])
|
|
65
|
+
policy = window["execution_policy"]
|
|
66
|
+
scheduler = policy.is_a?(Hash) && policy["scheduler"]
|
|
67
|
+
unless scheduler.is_a?(Hash)
|
|
68
|
+
errors << error("PARALLEL_SCHEDULER_SCHEMA_INVALID", "parallel window must define execution_policy.scheduler", window: window_id)
|
|
69
|
+
else
|
|
70
|
+
errors << error("PARALLEL_SCHEDULER_FIELD_INVALID", "one_agent_per_step must be boolean", window: window_id, field: "one_agent_per_step") unless [true, false].include?(scheduler["one_agent_per_step"])
|
|
71
|
+
errors << error("PARALLEL_SCHEDULER_FIELD_INVALID", "max_concurrency must be a positive integer", window: window_id, field: "max_concurrency") unless scheduler["max_concurrency"].is_a?(Integer) && scheduler["max_concurrency"] > 0
|
|
72
|
+
errors << error("PARALLEL_SCHEDULER_FIELD_INVALID", "track_order must be declared", window: window_id, field: "track_order") unless scheduler["track_order"] == "declared"
|
|
73
|
+
end
|
|
74
|
+
errors << error("PARALLEL_BRANCH_STEPS_INVALID", "branch_steps must be an array", window: window_id) if window.key?("branch_steps") && !window["branch_steps"].is_a?(Array)
|
|
75
|
+
Array(window["branch_steps"]).each do |step_id|
|
|
76
|
+
errors << error("PARALLEL_BRANCH_STEP_NOT_FOUND", "branch_steps references an unknown step", window: window_id, step: step_id) unless step_by_id.key?(step_id.to_s)
|
|
77
|
+
end
|
|
78
|
+
branch_ids = []
|
|
79
|
+
step_tracks = {}
|
|
80
|
+
window["branches"].each do |track, branch_steps|
|
|
81
|
+
unless branch_steps.is_a?(Array) && !branch_steps.empty?
|
|
82
|
+
errors << error("PARALLEL_TRACK_STEPS_INVALID", "parallel track must contain an ordered step array", window: window_id, track: track)
|
|
83
|
+
next
|
|
84
|
+
end
|
|
85
|
+
positions = branch_steps.each_with_index.to_h { |step_id, position| [step_id.to_s, position] }
|
|
86
|
+
branch_steps.each_with_index do |step_id, position|
|
|
87
|
+
if positions.count { |candidate, _| candidate == step_id.to_s } > 1
|
|
88
|
+
errors << error("PARALLEL_TRACK_DUPLICATE_STEP", "parallel track repeats a step", window: window_id, track: track, step: step_id)
|
|
89
|
+
end
|
|
90
|
+
branch_ids << step_id.to_s
|
|
91
|
+
step_tracks[step_id.to_s] = track.to_s
|
|
92
|
+
errors << error("PARALLEL_BRANCH_STEP_NOT_FOUND", "parallel track references an unknown step", window: window_id, track: track, step: step_id) unless step_by_id.key?(step_id.to_s)
|
|
93
|
+
Array(step_by_id.dig(step_id.to_s, "depends_on")).each do |dependency|
|
|
94
|
+
dependency_position = positions[dependency.to_s]
|
|
95
|
+
next unless dependency_position
|
|
96
|
+
errors << error("PARALLEL_TRACK_ORDER_INVALID", "track dependency must precede dependent step", window: window_id, track: track, step: step_id, dependency: dependency) if dependency_position >= position
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
window["branches"].each do |track, branch_steps|
|
|
101
|
+
Array(branch_steps).each do |step_id|
|
|
102
|
+
Array(step_by_id.dig(step_id.to_s, "depends_on")).each do |dependency|
|
|
103
|
+
dependency_track = step_tracks[dependency.to_s]
|
|
104
|
+
next unless dependency_track && dependency_track != track.to_s
|
|
105
|
+
|
|
106
|
+
errors << error("PARALLEL_CROSS_TRACK_DEPENDENCY", "parallel branch depends on a step in another track", window: window_id, track: track, step: step_id, dependency: dependency, dependency_track: dependency_track)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
counts = branch_ids.each_with_object(Hash.new(0)) { |step_id, result| result[step_id] += 1 }
|
|
111
|
+
duplicate_steps = counts.select { |_, count| count > 1 }.keys
|
|
112
|
+
duplicate_steps.each { |step_id| errors << error("PARALLEL_STEP_DUPLICATED", "step appears in multiple parallel branches", window: window_id, step: step_id) }
|
|
113
|
+
errors << error("PARALLEL_JOIN_IN_BRANCH", "join_step cannot also be a branch step", window: window_id, step: window["join_step"]) if branch_ids.include?(window["join_step"].to_s)
|
|
40
114
|
end
|
|
41
115
|
|
|
42
116
|
(workflow["failure_branches"] || {}).each do |source_step, branch|
|
|
117
|
+
errors << error("FAILURE_SOURCE_STEP_NOT_FOUND", "failure branch source step is not defined", source_step: source_step) unless step_by_id.key?(source_step.to_s)
|
|
118
|
+
errors << error("FAILURE_BRANCH_SCHEMA_INVALID", "failure branch must be a mapping", source_step: source_step) unless branch.is_a?(Hash)
|
|
43
119
|
resume = branch.is_a?(Hash) && branch["resume_after_success"]
|
|
44
120
|
unless resume.is_a?(Hash) && %w[flow step status checkpoint].all? { |field| resume.key?(field) }
|
|
45
121
|
errors << error("FAILURE_RETURN_TO_INVALID", "failure branch must declare flow, step, status and checkpoint", source_step: source_step)
|
|
@@ -54,3 +130,19 @@ module WorkflowGateContract
|
|
|
54
130
|
{ "code" => code, "message" => message }.merge(extra)
|
|
55
131
|
end
|
|
56
132
|
end
|
|
133
|
+
|
|
134
|
+
if $PROGRAM_NAME == __FILE__
|
|
135
|
+
begin
|
|
136
|
+
options = {}
|
|
137
|
+
OptionParser.new do |opts|
|
|
138
|
+
opts.on("--workflow PATH") { |value| options[:workflow] = value }
|
|
139
|
+
opts.on("--json") { options[:json] = true }
|
|
140
|
+
end.parse!
|
|
141
|
+
raise ArgumentError, "--workflow is required" unless options[:workflow]
|
|
142
|
+
result = WorkflowGateContract.validate(YAML.safe_load(File.read(options[:workflow]), permitted_classes: [], aliases: false))
|
|
143
|
+
puts(options[:json] ? JSON.generate(result) : YAML.dump(result))
|
|
144
|
+
rescue StandardError => e
|
|
145
|
+
warn e.message
|
|
146
|
+
exit 1
|
|
147
|
+
end
|
|
148
|
+
end
|