@guanlin4924/devflow-pi 0.1.96 → 0.1.98

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 +697 -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 +1015 -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 +12 -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 +72 -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
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import argparse
6
6
  import base64
7
+ from collections import deque
7
8
  import hashlib
8
9
  import html
9
10
  import json
@@ -41,80 +42,144 @@ def mermaid_label(value: Any) -> str:
41
42
  .replace("\r", " ").replace("\n", " "))
42
43
 
43
44
 
44
- def render_screen(screen: dict[str, Any], platform: str) -> str:
45
+ def render_screen(screen: dict[str, Any], platform: str, state: str = "default") -> str:
45
46
  mobile = platform == "mobile"
46
- width, height = (390, 760) if mobile else (960, 600)
47
+ width, default_height = (390, 760) if mobile else (960, 600)
47
48
  pad = 20 if mobile else 48
48
- pieces = [f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}" role="img" aria-label="{text(screen.get("title"))} {platform} 页面线框">', f'<rect width="{width}" height="{height}" fill="#f8fafc"/>']
49
+ pieces: list[str] = []
49
50
  y = 45 if mobile else 58
50
- if screen.get("presentation") in {"modal", "drawer"}:
51
+ presentation = screen.get("presentation")
52
+ overlay = presentation in {"modal", "drawer"}
53
+ if overlay:
51
54
  if mobile:
52
- panel_x, panel_y, panel_w, panel_h = 0, 220, width, height - 220
55
+ panel_x, panel_y, panel_w, original_panel_h = 0, 220, width, default_height - 220
53
56
  pad, y = 24, 265
54
- elif screen.get("presentation") == "drawer":
55
- panel_x, panel_y, panel_w, panel_h = 540, 0, 420, height
57
+ elif presentation == "drawer":
58
+ panel_x, panel_y, panel_w, original_panel_h = 540, 0, 420, default_height
56
59
  pad, y = 570, 48
57
60
  else:
58
- panel_x, panel_y, panel_w, panel_h = 270, 70, 420, 460
61
+ panel_x, panel_y, panel_w, original_panel_h = 270, 70, 420, 460
59
62
  pad, y = 300, 115
60
- pieces.append(f'<rect width="{width}" height="{height}" fill="#0f172a33"/>')
61
- pieces.append(f'<rect x="{panel_x}" y="{panel_y}" width="{panel_w}" height="{panel_h}" rx="12" fill="white" stroke="#7c3aed" stroke-width="2"/>')
62
63
  content_right = panel_x + panel_w - (24 if mobile else 30)
63
64
  else:
64
65
  content_right = width - pad
65
66
  content_width = content_right - pad
66
- for element in screen.get("elements", []):
67
+ elements = screen.get("elements", [])
68
+ if overlay and not any(element.get("type") == "page_header" for element in elements):
69
+ pieces.append(svg_text(pad, y, screen.get("title"), 18, weight="700"))
70
+ y += 40
71
+
72
+ data_types = {"table", "card_list"}
73
+ has_data_region = any(element.get("type") in data_types for element in elements)
74
+ error_drawn = False
75
+
76
+ def error_bar() -> None:
77
+ nonlocal y, error_drawn
78
+ pieces.append(f'<rect x="{pad}" y="{y}" width="{content_width}" height="40" fill="#fef2f2" stroke="#fca5a5"/>')
79
+ pieces.append(svg_text(pad + 16, y + 26, "加载失败,请重试", 14, "#b91c1c"))
80
+ y += 62
81
+ error_drawn = True
82
+
83
+ if state == "error" and not has_data_region and not elements:
84
+ error_bar()
85
+
86
+ bottom_navigation: dict[str, Any] | None = None
87
+ for element_index, element in enumerate(elements):
67
88
  typ = element.get("type")
89
+ if state == "error" and has_data_region and typ in data_types and not error_drawn:
90
+ error_bar()
68
91
  if typ == "page_header":
69
92
  pieces.append(svg_text(pad, y, element.get("title", screen.get("title")), 21 if mobile else 25, weight="700"))
70
93
  y += 48
71
94
  elif typ == "filter_bar":
72
- box_h = 92 if mobile else 76
73
- pieces.append(f'<rect x="{pad}" y="{y}" width="{content_width}" height="{box_h}" rx="8" fill="white" stroke="#cbd5e1"/>')
74
95
  fields = element.get("fields", [])
75
- for i, field in enumerate(fields[:2]):
76
- x = pad + 16 if mobile else pad + 24 + i * 220
77
- pieces.append(svg_text(x, y + 28, field.get("label"), 12, "#64748b"))
78
- pieces.append(f'<rect x="{x}" y="{y + 38}" width="{150 if mobile else 180}" height="24" fill="#f1f5f9" stroke="#cbd5e1"/>')
96
+ per_row = 1 if mobile else max(1, (content_width - 24) // 220)
97
+ rows = (len(fields) + per_row - 1) // per_row
98
+ box_h = 20 + rows * 56 if fields else 76
99
+ pieces.append(f'<rect x="{pad}" y="{y}" width="{content_width}" height="{box_h}" rx="8" fill="white" stroke="#cbd5e1"/>')
100
+ for i, field in enumerate(fields):
101
+ row, col = divmod(i, per_row)
102
+ x = pad + 16 if mobile else pad + 24 + col * 220
103
+ field_y = y + row * 56
104
+ pieces.append(svg_text(x, field_y + 28, field.get("label"), 12, "#64748b"))
105
+ pieces.append(f'<rect x="{x}" y="{field_y + 38}" width="{150 if mobile else 180}" height="24" fill="#f1f5f9" stroke="#cbd5e1"/>')
79
106
  y += box_h + 22
80
107
  elif typ == "table":
81
108
  box_h = 290 if mobile else 300
82
109
  pieces.append(f'<rect x="{pad}" y="{y}" width="{content_width}" height="{box_h}" rx="8" fill="white" stroke="#cbd5e1"/>')
83
110
  columns = element.get("columns", [])
84
- if mobile:
111
+ if state in {"empty", "error"}:
112
+ if state == "empty":
113
+ pieces.append(f'<text x="{pad + content_width // 2}" y="{y + box_h // 2 + 5}" text-anchor="middle" font-size="14" fill="#64748b">暂无数据</text>')
114
+ elif state == "loading":
115
+ skeleton_width = content_width - 32
116
+ for i, ratio in enumerate((0.9, 0.7, 0.8)):
117
+ pieces.append(f'<rect x="{pad + 16}" y="{y + 34 + i * 28}" width="{int(skeleton_width * ratio)}" height="14" rx="4" fill="#e2e8f0"/>')
118
+ pieces.append(svg_text(pad + 16, y + 132, "加载中…", 14, "#64748b"))
119
+ elif mobile and columns:
85
120
  pieces.append(svg_text(pad + 16, y + 36, "列表", 14, weight="700"))
86
- for row, label in enumerate(["示例任务一", "示例任务二"]):
121
+ first = f'{columns[0].get("label", "")}示例'
122
+ secondary = " · ".join(
123
+ f'{column.get("label", "")}示例'
124
+ for column in columns[1:3]
125
+ if not _is_action_column(column)
126
+ )
127
+ for row in range(2):
87
128
  ry = y + 78 + row * 72
88
- pieces.extend([svg_text(pad + 16, ry, label), svg_text(pad + 16, ry + 24, "运行中 · 2026-09-01", 12, "#64748b"), f'<path d="M{pad + 16} {ry + 38} H{width - pad - 16}" stroke="#e2e8f0"/>'])
89
- else:
90
- for i, col in enumerate(columns):
91
- pieces.append(svg_text(pad + 24 + i * 160, y + 36, col.get("label"), 13, "#475569"))
129
+ pieces.append(svg_text(pad + 16, ry, first))
130
+ if secondary:
131
+ pieces.append(svg_text(pad + 16, ry + 24, secondary, 12, "#64748b"))
132
+ pieces.append(f'<path d="M{pad + 16} {ry + 38} H{content_right - 16}" stroke="#e2e8f0"/>')
133
+ elif not mobile and columns:
134
+ available_width = content_width - 32
135
+ max_cols = max(1, available_width // 110)
136
+ visible_columns = list(columns)
137
+ if len(columns) > max_cols:
138
+ shown = list(columns[:max_cols - 1])
139
+ visible_columns = shown + [{"id": "__overflow__", "label": f"+{len(columns) - len(shown)} 列"}]
140
+ col_w = available_width // len(visible_columns)
141
+ limit = col_w // 14
142
+ for i, column in enumerate(visible_columns):
143
+ pieces.append(svg_text(pad + 16 + i * col_w, y + 36, _truncate(column.get("label", ""), limit), 13, "#475569"))
92
144
  for row in range(4):
93
145
  ry = y + 74 + row * 54
94
146
  pieces.append(f'<path d="M{pad + 16} {ry} H{content_right - 16}" stroke="#e2e8f0"/>')
95
147
  if row < 2:
96
- pieces.extend([svg_text(pad + 24, ry + 32, "示例任务"), svg_text(pad + 184, ry + 32, "运行中"), svg_text(pad + 344, ry + 32, "2026-09-01"), svg_text(pad + 504, ry + 32, "查看", 13, "#2563eb")])
148
+ for i, column in enumerate(visible_columns):
149
+ if column.get("id") == "__overflow__":
150
+ value, fill = "", "#0f172a"
151
+ elif _is_action_column(column):
152
+ value, fill = "查看", "#2563eb"
153
+ else:
154
+ value, fill = f'{column.get("label", "")}示例', "#0f172a"
155
+ pieces.append(svg_text(pad + 16 + i * col_w, ry + 32, _truncate(value, limit), 13, fill))
97
156
  y += box_h + 22
98
157
  elif typ == "card_list":
99
- for item in element.get("items", []):
100
- pieces.append(f'<rect x="{pad}" y="{y}" width="{width - 2 * pad}" height="90" rx="8" fill="white" stroke="#cbd5e1"/>')
101
- pieces.append(svg_text(pad + 20, y + 34, item.get("title"), 14, weight="700"))
102
- meta = item.get("meta", [])
103
- pieces.append(svg_text(pad + 20, y + 62, " · ".join(f'{m.get("label")}: {m.get("value")}' for m in meta), 12, "#64748b"))
104
- y += 108
158
+ if state in {"empty", "loading", "error"}:
159
+ box_h = 160
160
+ pieces.append(f'<rect x="{pad}" y="{y}" width="{content_width}" height="{box_h}" rx="8" fill="white" stroke="#cbd5e1"/>')
161
+ if state == "empty":
162
+ pieces.append(f'<text x="{pad + content_width // 2}" y="{y + box_h // 2 + 5}" text-anchor="middle" font-size="14" fill="#64748b">暂无数据</text>')
163
+ elif state == "loading":
164
+ skeleton_width = content_width - 32
165
+ for i, ratio in enumerate((0.9, 0.7, 0.8)):
166
+ pieces.append(f'<rect x="{pad + 16}" y="{y + 22 + i * 28}" width="{int(skeleton_width * ratio)}" height="14" rx="4" fill="#e2e8f0"/>')
167
+ pieces.append(svg_text(pad + 16, y + 128, "加载中…", 14, "#64748b"))
168
+ y += box_h + 18
169
+ else:
170
+ for item in element.get("items", []):
171
+ pieces.append(f'<rect x="{pad}" y="{y}" width="{content_width}" height="90" rx="8" fill="white" stroke="#cbd5e1"/>')
172
+ pieces.append(svg_text(pad + 20, y + 34, item.get("title"), 14, weight="700"))
173
+ meta = item.get("meta", [])
174
+ pieces.append(svg_text(pad + 20, y + 62, " · ".join(f'{m.get("label")}: {m.get("value")}' for m in meta), 12, "#64748b"))
175
+ y += 108
105
176
  elif typ == "tabs":
106
177
  pieces.append(f'<rect x="{pad}" y="{y}" width="{content_width}" height="48" fill="white" stroke="#cbd5e1"/>')
107
178
  for i, tab in enumerate(element.get("tabs", [])):
108
179
  pieces.append(svg_text(pad + 20 + i * 120, y + 30, tab.get("label"), 14, "#2563eb" if i == 0 else "#64748b"))
109
180
  y += 70
110
181
  elif typ == "bottom_navigation":
111
- bar_h = 58
112
- bar_y = height - bar_h - 18
113
- pieces.append(f'<rect x="{pad}" y="{bar_y}" width="{content_width}" height="{bar_h}" rx="8" fill="white" stroke="#cbd5e1"/>')
114
- items = element.get("items", [])
115
- slot = max(1, (width - 2 * pad) // max(1, len(items)))
116
- for i, item in enumerate(items):
117
- pieces.append(f'<text x="{pad + slot * i + slot // 2}" y="{bar_y + 35}" text-anchor="middle" font-size="12" fill="#475569">{text(item.get("label"))}</text>')
182
+ bottom_navigation = element
118
183
  elif typ in {"input", "select"}:
119
184
  pieces.append(svg_text(pad, y, element.get("label"), 12, "#64748b"))
120
185
  pieces.append(f'<rect x="{pad}" y="{y + 10}" width="{content_width}" height="38" fill="white" stroke="#cbd5e1"/>')
@@ -124,24 +189,152 @@ def render_screen(screen: dict[str, Any], platform: str) -> str:
124
189
  pieces.append(f'<rect x="{pad}" y="{y}" width="{bw}" height="40" rx="5" fill="#334155"/>')
125
190
  pieces.append(f'<text x="{pad + bw // 2}" y="{y + 26}" text-anchor="middle" font-size="13" fill="white">{text(element.get("label"))}</text>')
126
191
  y += 58
127
- pieces.append("</svg>")
128
- return "".join(pieces)
192
+ if state == "error" and not has_data_region and element_index == 0 and not error_drawn:
193
+ error_bar()
194
+
195
+ if not has_data_region and state in {"empty", "loading"}:
196
+ pieces.append(f'<rect x="{pad}" y="{y}" width="{content_width}" height="120" rx="8" fill="white" stroke="#cbd5e1"/>')
197
+ label = "暂无数据" if state == "empty" else "加载中…"
198
+ pieces.append(f'<text x="{pad + content_width // 2}" y="{y + 65}" text-anchor="middle" font-size="14" fill="#64748b">{label}</text>')
199
+ y += 142
200
+
201
+ y_end = y
202
+ if bottom_navigation is not None:
203
+ y_end += 58 + 18 + 12
204
+ height = max(default_height, y_end + 40)
205
+
206
+ if bottom_navigation is not None:
207
+ bar_h = 58
208
+ bar_y = height - bar_h - 18
209
+ pieces.append(f'<rect x="{pad}" y="{bar_y}" width="{content_width}" height="{bar_h}" rx="8" fill="white" stroke="#cbd5e1"/>')
210
+ items = bottom_navigation.get("items", [])
211
+ slot = max(1, content_width // max(1, len(items)))
212
+ for i, item in enumerate(items):
213
+ pieces.append(f'<text x="{pad + slot * i + slot // 2}" y="{bar_y + 35}" text-anchor="middle" font-size="12" fill="#475569">{text(item.get("label"))}</text>')
214
+
215
+ prefix = [f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}" role="img" aria-label="{text(screen.get("title"))} {platform} {state} 页面线框">', f'<rect width="{width}" height="{height}" fill="#f8fafc"/>']
216
+ if overlay:
217
+ if mobile or presentation == "drawer":
218
+ panel_h = height - panel_y
219
+ else:
220
+ panel_h = max(original_panel_h, y_end - panel_y + 30)
221
+ prefix.extend([f'<rect width="{width}" height="{height}" fill="#0f172a33"/>', f'<rect x="{panel_x}" y="{panel_y}" width="{panel_w}" height="{panel_h}" rx="12" fill="white" stroke="#7c3aed" stroke-width="2"/>'])
222
+ return "".join(prefix + pieces + ["</svg>"])
223
+
224
+
225
+ def _is_action_column(column: dict[str, Any]) -> bool:
226
+ return column.get("id") in {"actions", "action", "operation", "operations", "op"} or column.get("label") == "操作"
227
+
228
+
229
+ def _truncate(value: Any, limit: int) -> str:
230
+ value = str(value if value is not None else "")
231
+ if len(value) <= limit:
232
+ return value
233
+ if limit <= 1:
234
+ return "…" if limit == 1 else ""
235
+ return value[:limit - 1] + "…"
129
236
 
130
237
 
131
238
  def render_flow(screens: list[dict[str, Any]]) -> str:
132
- width = max(760, 220 * len(screens) + 80)
133
- parts = [f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="180" viewBox="0 0 {width} 180" role="img" aria-label="页面流转图"><defs><marker id="arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 Z" fill="#64748b"/></marker></defs><rect width="100%" height="100%" fill="#f8fafc"/>']
134
- ids = {s.get("id"): i for i, s in enumerate(screens)}
135
- for i, screen in enumerate(screens):
136
- x = 40 + i * 220
239
+ screen_by_id = {screen.get("id"): screen for screen in screens}
240
+ order = {screen.get("id"): i for i, screen in enumerate(screens)}
241
+ adjacency: dict[str, list[str]] = {screen["id"]: [] for screen in screens}
242
+ indegree = {screen["id"]: 0 for screen in screens}
243
+ first_opener: dict[str, str] = {}
244
+ for screen in screens:
245
+ source = screen["id"]
246
+ for action in screen.get("actions", []):
247
+ target = action.get("target")
248
+ if action.get("type") == "open" and target in screen_by_id:
249
+ adjacency[source].append(target)
250
+ indegree[target] += 1
251
+ first_opener.setdefault(target, source)
252
+
253
+ roots = [screen["id"] for screen in screens if indegree[screen["id"]] == 0]
254
+ if not roots and screens:
255
+ roots = [screens[0]["id"]]
256
+ layers: dict[str, int] = {}
257
+ visit_order: dict[str, int] = {}
258
+ queue: deque[str] = deque()
259
+
260
+ def add_root(root: str) -> None:
261
+ if root not in layers:
262
+ layers[root] = 0
263
+ visit_order[root] = len(visit_order)
264
+ queue.append(root)
265
+
266
+ for root in roots:
267
+ add_root(root)
268
+ while True:
269
+ while queue:
270
+ source = queue.popleft()
271
+ for target in adjacency[source]:
272
+ candidate = layers[source] + 1
273
+ if target not in layers:
274
+ layers[target] = candidate
275
+ visit_order[target] = len(visit_order)
276
+ queue.append(target)
277
+ elif candidate < layers[target]:
278
+ layers[target] = candidate
279
+ queue.append(target)
280
+ unreachable = next((screen["id"] for screen in screens if screen["id"] not in layers), None)
281
+ if unreachable is None:
282
+ break
283
+ add_root(unreachable)
284
+
285
+ by_layer: dict[int, list[str]] = {}
286
+ for screen in screens:
287
+ sid = screen["id"]
288
+ by_layer.setdefault(layers[sid], []).append(sid)
289
+ for layer_ids in by_layer.values():
290
+ layer_ids.sort(key=lambda sid: (visit_order[sid], order[sid]))
291
+ positions: dict[str, tuple[int, int]] = {}
292
+ for layer, layer_ids in by_layer.items():
293
+ for row, sid in enumerate(layer_ids):
294
+ positions[sid] = (40 + layer * 240, 40 + row * 90)
295
+
296
+ layer_count = max(layers.values(), default=-1) + 1
297
+ max_rows = max((len(layer_ids) for layer_ids in by_layer.values()), default=1)
298
+ bottom = 40 + (max_rows - 1) * 90 + 56
299
+ width = max(760, 40 + layer_count * 240 + 40)
300
+ height = max(180, 40 + max_rows * 90 + 40)
301
+ parts = [f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}" role="img" aria-label="页面流转图"><defs><marker id="arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 Z" fill="#64748b"/></marker></defs><rect width="100%" height="100%" fill="#f8fafc"/>']
302
+
303
+ back_edge_count = 0
304
+
305
+ def edge(source: str, target: str, dashed: bool = False) -> str:
306
+ nonlocal back_edge_count
307
+ if source == target:
308
+ return ""
309
+ sx, sy = positions[source]
310
+ tx, ty = positions[target]
311
+ source_y, target_y = sy + 28, ty + 28
312
+ if layers[target] > layers[source]:
313
+ path = f'M{sx + 180} {source_y} L{tx} {target_y}'
314
+ else:
315
+ offset_index = back_edge_count % 3
316
+ off = 4 * offset_index
317
+ bend_y = bottom + 12 + 8 * offset_index
318
+ back_edge_count += 1
319
+ path = f'M{sx + 180} {source_y} H{sx + 180 + 20 + off} V{bend_y} H{tx - 20 - off} V{target_y} H{tx}'
320
+ dash = ' stroke-dasharray="6 4"' if dashed else ""
321
+ return f'<path d="{path}" fill="none" stroke="#64748b" stroke-width="2"{dash} marker-end="url(#arrow)"/>'
322
+
323
+ for screen in screens:
324
+ source = screen["id"]
325
+ for action in screen.get("actions", []):
326
+ target = action.get("target")
327
+ if action.get("type") == "open" and target in screen_by_id:
328
+ parts.append(edge(source, target))
329
+ elif action.get("type") in {"close", "back"} and source in first_opener:
330
+ parts.append(edge(source, first_opener[source], True))
331
+
332
+ for screen in screens:
333
+ x, y = positions[screen["id"]]
137
334
  modal = screen.get("presentation") in {"modal", "drawer"}
138
- shape = f'<path d="M{x + 90} 62 L{x + 180} 90 L{x + 90} 118 L{x} 90 Z"' if modal else f'<rect x="{x}" y="62" width="180" height="56" rx="8"'
335
+ shape = f'<path d="M{x + 90} {y} L{x + 180} {y + 28} L{x + 90} {y + 56} L{x} {y + 28} Z"' if modal else f'<rect x="{x}" y="{y}" width="180" height="56" rx="8"'
139
336
  parts.append(shape + ' fill="#fff" stroke="#334155" stroke-width="2"/>')
140
- parts.append(f'<text x="{x + 90}" y="96" text-anchor="middle" font-family="Arial,sans-serif" font-size="14" fill="#0f172a">{mermaid_label(screen.get("title"))}</text>')
141
- for action in screen.get("actions", []):
142
- if action.get("type") == "open" and action.get("target") in ids:
143
- tx = 40 + ids[action["target"]] * 220
144
- parts.append(f'<path d="M{x + 180} 90 H{tx}" stroke="#64748b" stroke-width="2" marker-end="url(#arrow)"/>')
337
+ parts.append(f'<text x="{x + 90}" y="{y + 34}" text-anchor="middle" font-family="Arial,sans-serif" font-size="14" fill="#0f172a">{mermaid_label(screen.get("title"))}</text>')
145
338
  parts.append("</svg>")
146
339
  return "".join(parts)
147
340
 
@@ -151,10 +344,18 @@ def render_preview(screens: list[dict[str, Any]], feature: str) -> str:
151
344
  for screen in screens:
152
345
  sid = screen["id"].replace("-", "_")
153
346
  lines.append(f' {sid}["{mermaid_label(screen["title"])}"]')
347
+ screen_ids = {screen["id"] for screen in screens}
348
+ first_opener: dict[str, str] = {}
349
+ for screen in screens:
350
+ for action in screen.get("actions", []):
351
+ if action.get("type") == "open" and action.get("target") in screen_ids:
352
+ first_opener.setdefault(action["target"], screen["id"])
154
353
  for screen in screens:
155
354
  for action in screen.get("actions", []):
156
355
  if action.get("type") == "open":
157
356
  lines.append(f' {screen["id"].replace("-", "_")} -->|{mermaid_label(action.get("id", "open"))}| {action["target"].replace("-", "_")}')
357
+ elif action.get("type") in {"close", "back"} and screen["id"] in first_opener:
358
+ lines.append(f' {screen["id"].replace("-", "_")} -.->|{mermaid_label(action.get("id", action["type"]))}| {first_opener[screen["id"]].replace("-", "_")}')
158
359
  lines += ["```", "", "## Screens", ""]
159
360
  for screen in screens:
160
361
  lines += [f'### {screen["title"]} (`{screen["id"]}`)', "", f'- presentation: `{screen.get("presentation", "page")}`', f'- platforms: `{", ".join(screen.get("platforms", ["web", "mobile"]))}`', f'- states: `{", ".join(screen.get("states", []))}`', "", "| 顺序 | 元素 ID | 类型 | 内容 |", "|---:|---|---|---|"]
@@ -187,8 +388,8 @@ def gallery_html(manifest: dict[str, Any], assets: dict[str, str], standalone: b
187
388
  <style>body{{margin:0;background:#eef2f7;color:#0f172a;font:14px system-ui,sans-serif}}header{{padding:18px 24px;background:#fff;border-bottom:1px solid #cbd5e1}}main{{display:flex;min-height:calc(100vh - 70px)}}aside{{width:190px;padding:20px;background:#f8fafc;border-right:1px solid #cbd5e1}}button{{display:block;width:100%;margin:0 0 8px;padding:10px;text-align:left;border:1px solid #cbd5e1;border-radius:6px;background:#fff;cursor:pointer}}button.active{{border-color:#7c3aed;background:#f3e8ff}}section{{flex:1;padding:24px}}.toolbar{{display:flex;gap:8px;margin-bottom:18px}}.toolbar button{{width:auto}}.frame{{padding:20px;background:#dbe3ed;border-radius:10px;min-height:620px}}.frame img{{display:block;max-width:100%;max-height:620px;margin:auto;box-shadow:0 8px 24px #94a3b855;background:#fff}}.meta{{color:#64748b;font-size:12px;margin-top:12px}}</style>
188
389
  <script type="application/json" id="gallery-manifest">{embedded_manifest}</script>
189
390
  <header><strong>低保真原型画廊</strong><div class="meta">prototype: {esc(manifest['prototype_fingerprint'])} · source: {esc(manifest['source_document_fingerprint'])} · renderer: {esc(manifest['renderer_version'])}</div></header>
190
- <main><aside id="nav"></aside><section><div class="toolbar"><button id="web">Web</button><button id="mobile">Mobile</button><button id="flow">流程图</button></div><div class="frame"><img id="preview" alt="页面线框"></div><div class="meta" id="state">state: default</div></section></main>
191
- <script>const manifest=JSON.parse(document.querySelector('#gallery-manifest').textContent);const assets={asset_json};let current=manifest.screens[0].screen_id,platform='web';const nav=document.querySelector('#nav'),img=document.querySelector('#preview'),webBtn=document.querySelector('#web'),mobileBtn=document.querySelector('#mobile');function path(id,p){{const s=manifest.screens.find(x=>x.screen_id===id);return s&&s.platforms[p]&&s.platforms[p].states.default}}manifest.screens.forEach(s=>{{const b=document.createElement('button');b.textContent=s.screen_id;b.dataset.id=s.screen_id;b.onclick=()=>{{current=s.screen_id;render()}};nav.append(b)}});function render(){{document.querySelectorAll('aside button').forEach(b=>b.classList.toggle('active',b.dataset.id===current));const s=manifest.screens.find(x=>x.screen_id===current);webBtn.disabled=!s.platforms.web;mobileBtn.disabled=!s.platforms.mobile;const p=path(current,platform);if(p){{img.src=assets[p]||p;img.alt=current+' '+platform+' default'}}else{{img.removeAttribute('src');img.alt=current+' '+platform+' 未定义'}}}}webBtn.onclick=()=>{{platform='web';render()}};mobileBtn.onclick=()=>{{platform='mobile';render()}};document.querySelector('#flow').onclick=()=>{{img.src=assets['flow.svg']||'flow.svg'}};render()</script>'''
391
+ <main><aside id="nav"></aside><section><div class="toolbar"><button id="web">Web</button><button id="mobile">Mobile</button><button id="flow">流程图</button><div class="toolbar" id="state-buttons"></div></div><div class="frame"><img id="preview" alt="页面线框"></div><div class="meta" id="state">state: default</div></section></main>
392
+ <script>const manifest=JSON.parse(document.querySelector('#gallery-manifest').textContent);const assets={asset_json};let current=manifest.screens[0].screen_id,platform='web',state='default';const nav=document.querySelector('#nav'),img=document.querySelector('#preview'),webBtn=document.querySelector('#web'),mobileBtn=document.querySelector('#mobile'),stateButtons=document.querySelector('#state-buttons'),stateLabel=document.querySelector('#state');function screen(){{return manifest.screens.find(x=>x.screen_id===current)}}function states(){{const s=screen();return s&&s.platforms[platform]?s.platforms[platform].states:{{}}}}function ensureState(){{if(!states()[state])state='default'}}manifest.screens.forEach(s=>{{const b=document.createElement('button');b.textContent=s.screen_id;b.dataset.id=s.screen_id;b.onclick=()=>{{current=s.screen_id;if(!s.platforms[platform])platform=Object.keys(s.platforms)[0];ensureState();render()}};nav.append(b)}});function render(){{document.querySelectorAll('aside button').forEach(b=>b.classList.toggle('active',b.dataset.id===current));const s=screen();webBtn.disabled=!s.platforms.web;mobileBtn.disabled=!s.platforms.mobile;ensureState();stateButtons.replaceChildren();Object.keys(states()).forEach(name=>{{const b=document.createElement('button');b.textContent=name;b.classList.toggle('active',name===state);b.onclick=()=>{{state=name;render()}};stateButtons.append(b)}});const p=states()[state];stateLabel.textContent='state: '+state;if(p){{img.src=assets[p]||p;img.alt=current+' '+platform+' '+state}}else{{img.removeAttribute('src');img.alt=current+' '+platform+' 未定义'}}}}webBtn.onclick=()=>{{platform='web';ensureState();render()}};mobileBtn.onclick=()=>{{platform='mobile';ensureState();render()}};document.querySelector('#flow').onclick=()=>{{img.src=assets['flow.svg']||'flow.svg';img.alt='页面流转图'}};render()</script>'''
192
393
 
193
394
 
194
395
  def main() -> int:
@@ -202,15 +403,20 @@ def main() -> int:
202
403
  screens = canonical.get("screens", [])
203
404
  canonical_bytes = json.dumps(canonical_order(normalize(canonical)), ensure_ascii=False, separators=(",", ":")) + "\n"
204
405
  prototype = "sha256:" + hashlib.sha256(canonical_bytes.encode("utf-8")).hexdigest()
205
- manifest: dict[str, Any] = {"prototype_fingerprint": prototype, "source_document_fingerprint": args.source_fingerprint, "renderer_version": "1.0.0", "generated_at": datetime.now(timezone.utc).isoformat(), "screens": []}
406
+ manifest: dict[str, Any] = {"prototype_fingerprint": prototype, "source_document_fingerprint": args.source_fingerprint, "renderer_version": "1.1.0", "generated_at": datetime.now(timezone.utc).isoformat(), "screens": []}
206
407
  assets: dict[str, str] = {}
207
408
  svg_by_path: dict[str, str] = {"flow.svg": render_flow(screens)}
208
409
  for screen in screens:
209
410
  entry = {"screen_id": screen["id"], "platforms": {}}
210
411
  for platform in screen.get("platforms", ["web", "mobile"]):
211
- path = f"screens/{screen['id']}/{platform}/default.svg"
212
- svg_by_path[path] = render_screen(screen, platform)
213
- entry["platforms"][platform] = {"states": {"default": path}}
412
+ state_paths = {}
413
+ declared_states = list(screen.get("states") or [])
414
+ render_states = declared_states if "default" in declared_states else ["default", *declared_states]
415
+ for state in render_states:
416
+ path = f"screens/{screen['id']}/{platform}/{state}.svg"
417
+ svg_by_path[path] = render_screen(screen, platform, state)
418
+ state_paths[state] = path
419
+ entry["platforms"][platform] = {"states": state_paths}
214
420
  manifest["screens"].append(entry)
215
421
  args.out.mkdir(parents=True, exist_ok=True)
216
422
  (args.out / "preview.md").write_text(render_preview(screens, canonical.get("feature", "wireframe")), encoding="utf-8")
@@ -0,0 +1,126 @@
1
+ schema_version: 1
2
+ id: regression-acceptance
3
+ name: Regression Acceptance Workflow
4
+ description: >
5
+ 从空闲状态为已上线或存量功能建立并执行一套可追溯的回归验收用例。
6
+
7
+ entry:
8
+ from_flow: idle
9
+ initial_step: regression-test-design
10
+
11
+ governance:
12
+ hard_gate_mode: core
13
+ gate_contract_version: 3
14
+ requirement_graph_gate: enforced
15
+ enforced_checkpoints:
16
+ - CP-3
17
+ step_checkers:
18
+ regression-test-design:
19
+ - change-impact-check
20
+ - test-coverage-check
21
+ - test-obligation-check
22
+ regression-dsl:
23
+ - test-coverage-check
24
+ run-acceptance-tests:
25
+ - acceptance-test-results-check
26
+
27
+ artifact_registry:
28
+ source: ".devflow/planning/artifacts/index.yaml"
29
+ path_key: path
30
+ status_key: status
31
+ pending_statuses:
32
+ - planned
33
+ - pending_rerun
34
+ terminal_success_statuses:
35
+ - approved
36
+ - completed
37
+ - passed
38
+
39
+ state_contract:
40
+ source: ".devflow/planning/STATE.md"
41
+ parser_hint: "read YAML front matter first, then fall back to Markdown sections"
42
+ required_fields:
43
+ - current_flow
44
+ - current_feature
45
+ - current_phase
46
+ - current_step
47
+ - status
48
+ - completed_steps
49
+ - pending_steps
50
+
51
+ variables:
52
+ feature:
53
+ source: state.current_feature
54
+ phase:
55
+ source: state.current_phase
56
+
57
+ checkpoints:
58
+ CP-3:
59
+ name: Regression test suite freeze
60
+ gate_after: regression-test-design
61
+ producing_step: regression-test-design
62
+ required_outputs:
63
+ - ".devflow/planning/requirements/{feature}/tests.json"
64
+ required_meta_output: ".devflow/planning/requirements/{feature}/tests.meta.json"
65
+ required_meta_fields:
66
+ - oracle_source_counts
67
+ - test_case_count
68
+ - candidate_test_suite_graph_revision
69
+ - candidate_test_suite_fingerprint
70
+ summary_fields:
71
+ - test_case_count
72
+ - oracle_source_counts
73
+ - characterization_test_case_count
74
+ prompt: "确认每条回归 TC 的判定标准来源、证据和可复现范围;确认后由 Core 冻结测试集。"
75
+ confirm_options:
76
+ - approve
77
+ - needs_fix
78
+ skip_policy: never
79
+ resume_behavior: "user confirms, then continue with regression-dsl"
80
+
81
+ steps:
82
+ - id: regression-test-design
83
+ order: 10
84
+ label: Regression test design
85
+ skill: workflow-regression-test-design
86
+ depends_on: []
87
+ inputs:
88
+ - ".devflow/planning/code-index/"
89
+ - ".devflow/planning/PROJECT.md"
90
+ outputs:
91
+ - path: ".devflow/planning/requirements/{feature}/tests.json"
92
+ type: acceptance-test-design
93
+ required: true
94
+ - path: ".devflow/planning/features/{feature}/回归验收测试设计.md"
95
+ type: regression-test-design
96
+ required: true
97
+ checkpoint: CP-3
98
+
99
+ - id: regression-dsl
100
+ order: 20
101
+ label: Generate regression DSL cases
102
+ skill: workflow-generate-mainflow-cases
103
+ depends_on:
104
+ - regression-test-design
105
+ inputs:
106
+ - ".devflow/planning/requirements/{feature}/tests.json"
107
+ - ".devflow/planning/code-index/"
108
+ outputs:
109
+ - path: ".devflow/planning/generated-cases/{feature}/"
110
+ type: generated-acceptance-cases
111
+ required: true
112
+
113
+ - id: run-acceptance-tests
114
+ order: 30
115
+ label: Run regression acceptance tests
116
+ skill: workflow-run-acceptance-tests
117
+ depends_on:
118
+ - regression-dsl
119
+ inputs:
120
+ - ".devflow/planning/requirements/{feature}/tests.json"
121
+ - ".devflow/planning/features/{feature}/acceptance-test-bindings.json"
122
+ outputs:
123
+ - path: ".devflow/planning/test-runs/{feature}/acceptance-results.json"
124
+ type: acceptance-test-results
125
+ required: true
126
+
@@ -82,6 +82,8 @@ pipeline 本身不包含执行规则——所有门禁、CP 协议、失败分
82
82
 
83
83
  每次启动时,通过 workflow-orchestrator 的程序化 inspector 判断当前状态和下一步动作:
84
84
 
85
+ 如果 Runtime 指令中带有 `<startup-briefing>`,说明预检和合同激活已经完成,直接按简报执行。
86
+
85
87
  ```bash
86
88
  devflow_state(action="inspect")
87
89
  ```