@topy-ai/maggie 0.7.47 → 0.7.49
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-zh-TW.md +2 -2
- package/README.md +76 -15
- package/bin/maggie.js +23 -6
- package/bundled-contracts/google-integrations/capability-report-v2.schema.json +2 -1
- package/bundled-contracts/maggie-content/email-localization-v1.schema.json +89 -0
- package/bundled-contracts/maggie-content/lifecycle-email-v1.schema.json +76 -0
- package/bundled-contracts/maggie-deployment/readiness-v1.schema.json +6 -1
- package/bundled-contracts/maggie-design/palette-roles-v1.schema.json +47 -0
- package/bundled-contracts/maggie-marketplace/template-content-v1.schema.json +103 -0
- package/bundled-contracts/maggie-ops/marketing-run-v1.schema.json +89 -0
- package/bundled-contracts/maggie-ops/notification-catalogue-example-v1.json +941 -0
- package/bundled-contracts/maggie-ops/notification-catalogue-v1.schema.json +273 -0
- package/bundled-contracts/maggie-service-booking/fact-provenance-v1.schema.json +128 -0
- package/bundled-contracts/maggie-service-booking/import-stages-v1.schema.json +191 -0
- package/bundled-contracts/maggiedash/booking-browser-evidence-v1.json +21 -0
- package/bundled-contracts/maggiedash/booking-browser-evidence-v2.json +191 -0
- package/bundled-contracts/maggiedash/booking-email-templates-v1.json +3 -1
- package/bundled-contracts/maggiedash/booking-voucher-lifecycle-v1.json +85 -0
- package/bundled-contracts/maggiedash/execution-board.json +4629 -1553
- package/bundled-contracts/maggiedash/stripe-booking-capabilities-v1.json +1 -0
- package/bundled-marketplace/README.md +17 -0
- package/bundled-marketplace/catalog.json +56 -7
- package/bundled-references/browser-inspection.md +25 -0
- package/bundled-references/google-integrations-runbook.md +54 -0
- package/bundled-references/maggiedash-booking/CURRENT-STATE.md +128 -10
- package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +4629 -1553
- package/bundled-references/maggiedash-booking/HOST-ADAPTER.md +5 -0
- package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +64 -21
- package/bundled-references/maggiedash-booking/PRD.md +9 -1
- package/bundled-references/maggiedash-booking/PROGRESS.md +244 -2399
- package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +37 -6
- package/bundled-references/maggiedash-booking/README.md +23 -6
- package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +64 -8
- package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +11 -2
- package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +66 -2
- package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +1 -1
- package/bundled-references/maggiedash-booking/TASKS.md +44 -3
- package/bundled-references/maggiedash-booking/VOUCHERS.md +47 -0
- package/bundled-references/provider-adapter-contract.md +33 -0
- package/bundled-skills/maggie-booking/SKILL.md +8 -0
- package/bundled-skills/maggie-clone-to-template/SKILL.md +26 -0
- package/bundled-skills/maggie-content-localization/SKILL.md +17 -1
- package/bundled-skills/maggie-content-localization/references/email-and-lifecycle.md +83 -0
- package/bundled-skills/maggie-deployment/SKILL.md +141 -7
- package/bundled-skills/maggie-deployment/references/vps.md +74 -1
- package/bundled-skills/maggie-design/SKILL.md +154 -0
- package/bundled-skills/maggie-feedback/SKILL.md +67 -1
- package/bundled-skills/maggie-marketplace/SKILL.md +91 -0
- package/bundled-skills/maggie-ops/SKILL.md +37 -0
- package/bundled-skills/maggie-ops/references/notifications-operations.md +154 -0
- package/bundled-skills/maggie-qa-workflow/SKILL.md +41 -1
- package/bundled-skills/maggie-service-booking/SKILL.md +82 -0
- package/bundled-skills/maggie-social-share/SKILL.md +56 -7
- package/bundled-skills/maggie-social-share/references/marketing-run.md +57 -0
- package/bundled-skills/maggie-social-share/references/queue-and-connections.md +152 -0
- package/bundled-skills/maggie-template/SKILL.md +57 -3
- package/bundled-skills/maggie-template/references/template-content-v1.example.json +34 -0
- package/bundled-tools/clis/maggie_booking.py +199 -26
- package/bundled-tools/clis/maggie_booking_vouchers.py +185 -0
- package/bundled-tools/clis/maggie_clone_to_template.py +14 -0
- package/bundled-tools/clis/maggie_deployment.py +307 -18
- package/bundled-tools/clis/maggie_deployment_auth_canary.py +216 -0
- package/bundled-tools/clis/maggie_deployment_collect.py +225 -0
- package/bundled-tools/clis/maggie_deployment_readiness.py +53 -3
- package/bundled-tools/clis/maggie_design.py +17 -0
- package/bundled-tools/clis/maggie_design_lint.py +277 -0
- package/bundled-tools/clis/maggie_design_verify.py +359 -0
- package/bundled-tools/clis/maggie_docs.py +82 -4
- package/bundled-tools/clis/maggie_email_localization.py +382 -0
- package/bundled-tools/clis/maggie_feedback.py +115 -5
- package/bundled-tools/clis/maggie_icon_glyph_check.py +382 -0
- package/bundled-tools/clis/maggie_icon_release_gate.py +28 -1
- package/bundled-tools/clis/maggie_localization.py +5 -0
- package/bundled-tools/clis/maggie_marketing_run.py +272 -0
- package/bundled-tools/clis/maggie_marketplace.py +199 -5
- package/bundled-tools/clis/maggie_notifications.py +528 -0
- package/bundled-tools/clis/maggie_ops.py +87 -0
- package/bundled-tools/clis/maggie_palette.py +280 -0
- package/bundled-tools/clis/maggie_qa_workflow.py +156 -1
- package/bundled-tools/clis/maggie_schedule.py +12 -2
- package/bundled-tools/clis/maggie_service_import.py +520 -0
- package/bundled-tools/clis/maggie_social_queue.py +442 -0
- package/bundled-tools/clis/maggie_social_share.py +46 -9
- package/bundled-tools/clis/maggie_template_package.py +1141 -0
- package/bundled-tools/runtime/google_ads.py +306 -0
- package/bundled-tools/runtime/google_capabilities.py +18 -2
- package/bundled-tools/runtime/google_remediation.py +152 -0
- package/package.json +1 -1
- package/references/browser-inspection.md +25 -0
- package/references/google-integrations-runbook.md +54 -0
- package/references/provider-adapter-contract.md +33 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Static design checks that catch defects before a browser run.
|
|
3
|
+
|
|
4
|
+
``pressable-check`` flags a pressable or button rendered inside another
|
|
5
|
+
pressable in JSX/TSX. React Native Web renders ``Pressable`` and the
|
|
6
|
+
``Touchable*`` family as ``<button>``-like elements, so a chip with a nested
|
|
7
|
+
remove control becomes ``<button>`` inside ``<button>``: invalid DOM, and taps
|
|
8
|
+
land on the wrong control.
|
|
9
|
+
|
|
10
|
+
``layer-check`` flags unlayered CSS that competes with Tailwind v4 utilities.
|
|
11
|
+
Tailwind v4 emits utilities inside ``@layer utilities``; any unlayered rule
|
|
12
|
+
wins over every layer regardless of specificity, so an unlayered
|
|
13
|
+
``h1 { font-family }`` beats ``font-*`` and ``.ph { display: inline-block }``
|
|
14
|
+
beats ``hidden`` and ``group-open:inline``.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import json
|
|
21
|
+
import re
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
SKIP_DIRS = {"node_modules", "dist", "build", ".git", ".expo", ".next", ".astro", ".output", "coverage", ".maggie", ".turbo", ".vercel", ".wrangler"}
|
|
26
|
+
PRESSABLES = ("Pressable", "TouchableOpacity", "TouchableHighlight", "TouchableWithoutFeedback", "TouchableNativeFeedback", "button", "a")
|
|
27
|
+
ALLOW_MARKER = "maggie-allow-nested-pressable"
|
|
28
|
+
JSX_KEYWORDS = {"return", "yield", "await", "default", "case", "in", "of", "else", "do"}
|
|
29
|
+
|
|
30
|
+
TAILWIND_V4_IMPORT = re.compile(r"""@import\s+(?:url\()?["']tailwindcss["']""")
|
|
31
|
+
LAYERED_IMPORT = re.compile(r"""@import\s+(?:url\()?["']([^"']+)["']\)?\s+layer\(""")
|
|
32
|
+
# At-rules whose bodies are Tailwind theme/utility definitions or descriptors, not competing rules.
|
|
33
|
+
SKIPPED_AT_RULES = {"theme", "utility", "variant", "custom-variant", "keyframes", "font-face", "property", "plugin", "source", "page", "counter-style", "font-feature-values", "starting-style"}
|
|
34
|
+
NESTING_AT_RULES = {"media", "supports", "container", "scope", "document"}
|
|
35
|
+
COMPETING_PROPERTIES = re.compile(
|
|
36
|
+
r"^(display|visibility|font-family|font-size|font-weight|font-style|line-height|letter-spacing|text-transform|"
|
|
37
|
+
r"text-align|color|background(-color)?|margin(-[a-z]+)?|padding(-[a-z]+)?|width|height|min-width|max-width|"
|
|
38
|
+
r"min-height|max-height|gap|border(-[a-z]+)*|position|inset|top|right|bottom|left|z-index|opacity)$"
|
|
39
|
+
)
|
|
40
|
+
ICON_CLASS = re.compile(r"\.(ph|ph-[\w-]+|fa|fa[srb]|fa-[\w-]+|bi|bi-[\w-]+|material-icons[\w-]*|material-symbols[\w-]*|icon|icon-[\w-]+|lucide|lucide-[\w-]+|iconify)(?![\w-])")
|
|
41
|
+
ICON_ATTRIBUTE = re.compile(r"""\[class[\^*|]?=\s*["']?(ph|fa|bi|icon)""")
|
|
42
|
+
TYPE_SELECTOR = re.compile(r"^(\*|[a-zA-Z][a-zA-Z0-9-]*)")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def iter_files(roots: list[Path], suffixes: tuple[str, ...]) -> list[Path]:
|
|
46
|
+
found: list[Path] = []
|
|
47
|
+
for root in roots:
|
|
48
|
+
if root.is_file():
|
|
49
|
+
if root.suffix in suffixes:
|
|
50
|
+
found.append(root)
|
|
51
|
+
continue
|
|
52
|
+
for path in sorted(root.rglob("*")):
|
|
53
|
+
if path.suffix in suffixes and path.is_file() and not any(part in SKIP_DIRS for part in path.relative_to(root).parts):
|
|
54
|
+
found.append(path)
|
|
55
|
+
return found
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _display(path: Path, project: Path) -> str:
|
|
59
|
+
try:
|
|
60
|
+
return path.resolve().relative_to(project.resolve()).as_posix()
|
|
61
|
+
except ValueError:
|
|
62
|
+
return path.name
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ---------------------------------------------------------------- pressables
|
|
66
|
+
|
|
67
|
+
def _strip_jsx_comments(source: str) -> str:
|
|
68
|
+
"""Blank comments while keeping offsets, so line numbers stay correct."""
|
|
69
|
+
def blank(match: re.Match[str]) -> str:
|
|
70
|
+
return re.sub(r"[^\n]", " ", match.group(0))
|
|
71
|
+
source = re.sub(r"/\*.*?\*/", blank, source, flags=re.S)
|
|
72
|
+
return re.sub(r"(?m)^[ \t]*//[^\n]*", blank, source)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _scan_tag(source: str, start: int) -> tuple[int, bool, str] | None:
|
|
76
|
+
"""Return (end offset, self-closing, attribute text) for a tag at ``start``."""
|
|
77
|
+
depth = 0
|
|
78
|
+
quote = ""
|
|
79
|
+
index = start
|
|
80
|
+
while index < len(source):
|
|
81
|
+
char = source[index]
|
|
82
|
+
if quote:
|
|
83
|
+
if char == quote and source[index - 1] != "\\":
|
|
84
|
+
quote = ""
|
|
85
|
+
elif char in "\"'`":
|
|
86
|
+
quote = char
|
|
87
|
+
elif char == "{":
|
|
88
|
+
depth += 1
|
|
89
|
+
elif char == "}":
|
|
90
|
+
depth -= 1
|
|
91
|
+
elif char == ">" and depth == 0:
|
|
92
|
+
return index + 1, source[index - 1] == "/", source[start:index]
|
|
93
|
+
index += 1
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def pressable_findings(path: Path, names: set[str], display: str) -> list[dict[str, object]]:
|
|
98
|
+
raw = path.read_text(encoding="utf-8", errors="replace")
|
|
99
|
+
source = _strip_jsx_comments(raw)
|
|
100
|
+
raw_lines = raw.splitlines()
|
|
101
|
+
findings: list[dict[str, object]] = []
|
|
102
|
+
stack: list[tuple[str, bool, int]] = [] # (tag, is_pressable_boundary, line)
|
|
103
|
+
for match in re.finditer(r"<(/?)([A-Za-z][\w.]*)", source):
|
|
104
|
+
before = source[:match.start()].rstrip()
|
|
105
|
+
closing = match.group(1) == "/"
|
|
106
|
+
name = match.group(2)
|
|
107
|
+
# A preceding identifier character means a TypeScript generic, not JSX.
|
|
108
|
+
if not closing and before and (before[-1].isalnum() or before[-1] in "_$."):
|
|
109
|
+
word = re.search(r"[\w$]+$", before)
|
|
110
|
+
if not word or word.group(0) not in JSX_KEYWORDS:
|
|
111
|
+
continue
|
|
112
|
+
line = source.count("\n", 0, match.start()) + 1
|
|
113
|
+
if closing:
|
|
114
|
+
for position in range(len(stack) - 1, -1, -1):
|
|
115
|
+
if stack[position][0] == name:
|
|
116
|
+
del stack[position:]
|
|
117
|
+
break
|
|
118
|
+
continue
|
|
119
|
+
scanned = _scan_tag(source, match.end())
|
|
120
|
+
if scanned is None:
|
|
121
|
+
continue
|
|
122
|
+
_, self_closing, attributes = scanned
|
|
123
|
+
is_pressable = name in names
|
|
124
|
+
# `asChild` delegates rendering to the child (expo-router Link), so it is not a second control.
|
|
125
|
+
boundary = is_pressable and not re.search(r"\basChild\b", attributes)
|
|
126
|
+
if is_pressable:
|
|
127
|
+
outer = next((entry for entry in reversed(stack) if entry[1]), None)
|
|
128
|
+
allowed = ALLOW_MARKER in (raw_lines[line - 1] if line - 1 < len(raw_lines) else "")
|
|
129
|
+
if outer and not allowed:
|
|
130
|
+
findings.append({
|
|
131
|
+
"file": display, "line": line, "inner": name, "outer": outer[0], "outerLine": outer[2],
|
|
132
|
+
"message": f"<{name}> is nested inside <{outer[0]}> (line {outer[2]}); render them as sibling pressables",
|
|
133
|
+
})
|
|
134
|
+
if not self_closing:
|
|
135
|
+
stack.append((name, boundary, line))
|
|
136
|
+
return findings
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def pressable_check(project: Path, paths: list[Path], extra: list[str]) -> dict[str, object]:
|
|
140
|
+
names = set(PRESSABLES) | set(extra)
|
|
141
|
+
files = iter_files(paths or [project], (".tsx", ".jsx"))
|
|
142
|
+
findings: list[dict[str, object]] = []
|
|
143
|
+
for path in files:
|
|
144
|
+
findings.extend(pressable_findings(path, names, _display(path, project)))
|
|
145
|
+
return {"schemaVersion": "maggie-design-pressable-check.v1", "passed": not findings, "checkedFiles": len(files), "pressables": sorted(names), "findings": findings}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# ---------------------------------------------------------------- CSS layers
|
|
149
|
+
|
|
150
|
+
def _strip_css_comments(source: str) -> str:
|
|
151
|
+
return re.sub(r"/\*.*?\*/", lambda match: re.sub(r"[^\n]", " ", match.group(0)), source, flags=re.S)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _block_end(source: str, open_index: int) -> int:
|
|
155
|
+
depth = 0
|
|
156
|
+
quote = ""
|
|
157
|
+
for index in range(open_index, len(source)):
|
|
158
|
+
char = source[index]
|
|
159
|
+
if quote:
|
|
160
|
+
if char == quote and source[index - 1] != "\\":
|
|
161
|
+
quote = ""
|
|
162
|
+
elif char in "\"'":
|
|
163
|
+
quote = char
|
|
164
|
+
elif char == "{":
|
|
165
|
+
depth += 1
|
|
166
|
+
elif char == "}":
|
|
167
|
+
depth -= 1
|
|
168
|
+
if depth == 0:
|
|
169
|
+
return index
|
|
170
|
+
return len(source) - 1
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _selector_kind(selector: str) -> str | None:
|
|
174
|
+
if ICON_CLASS.search(selector) or ICON_ATTRIBUTE.search(selector):
|
|
175
|
+
return "icon"
|
|
176
|
+
kind = None
|
|
177
|
+
for part in selector.split(","):
|
|
178
|
+
compounds = [item for item in re.split(r"\s*[>+~]\s*|\s+", part.strip()) if item]
|
|
179
|
+
if not compounds:
|
|
180
|
+
continue
|
|
181
|
+
last = re.sub(r"::?[\w-]+(\([^)]*\))?", "", compounds[-1])
|
|
182
|
+
match = TYPE_SELECTOR.match(last)
|
|
183
|
+
if not match:
|
|
184
|
+
continue
|
|
185
|
+
if match.group(1).lower() in {"html", "body"}:
|
|
186
|
+
kind = kind or "root"
|
|
187
|
+
else:
|
|
188
|
+
return "element"
|
|
189
|
+
return kind
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _rule_findings(source: str, start: int, end: int, layered: bool, display: str, findings: list[dict[str, object]]) -> None:
|
|
193
|
+
index = start
|
|
194
|
+
while index < end:
|
|
195
|
+
brace = source.find("{", index, end)
|
|
196
|
+
semicolon = source.find(";", index, end)
|
|
197
|
+
if brace == -1:
|
|
198
|
+
return
|
|
199
|
+
if semicolon != -1 and semicolon < brace:
|
|
200
|
+
index = semicolon + 1 # statement at-rule such as @import or @layer a, b;
|
|
201
|
+
continue
|
|
202
|
+
prelude = source[index:brace].strip()
|
|
203
|
+
close = _block_end(source, brace)
|
|
204
|
+
if prelude.startswith("@"):
|
|
205
|
+
at_name = re.match(r"@([\w-]+)", prelude)
|
|
206
|
+
name = at_name.group(1).lower() if at_name else ""
|
|
207
|
+
if name == "layer":
|
|
208
|
+
_rule_findings(source, brace + 1, close, True, display, findings)
|
|
209
|
+
elif name in NESTING_AT_RULES:
|
|
210
|
+
_rule_findings(source, brace + 1, close, layered, display, findings)
|
|
211
|
+
elif not layered and prelude:
|
|
212
|
+
kind = _selector_kind(prelude)
|
|
213
|
+
body = source[brace + 1:close]
|
|
214
|
+
properties = sorted({item.split(":", 1)[0].strip().lower() for item in body.split(";") if ":" in item and "{" not in item.split(":", 1)[0]})
|
|
215
|
+
competing = [item for item in properties if COMPETING_PROPERTIES.match(item)]
|
|
216
|
+
line = source.count("\n", 0, index + (len(source[index:brace]) - len(source[index:brace].lstrip()))) + 1
|
|
217
|
+
if kind == "root" and competing:
|
|
218
|
+
findings.append({
|
|
219
|
+
"file": display, "line": line, "selector": " ".join(prelude.split()), "kind": kind, "properties": competing, "severity": "warning",
|
|
220
|
+
"message": "unlayered html/body rule overrides utilities placed on that element; prefer @layer base",
|
|
221
|
+
})
|
|
222
|
+
elif kind and competing:
|
|
223
|
+
findings.append({
|
|
224
|
+
"file": display, "line": line, "selector": " ".join(prelude.split()), "kind": kind, "properties": competing, "severity": "error",
|
|
225
|
+
"message": f"unlayered {kind} rule sets {', '.join(competing)} and overrides Tailwind v4 utilities; move it into @layer {'components' if kind == 'icon' else 'base'}",
|
|
226
|
+
})
|
|
227
|
+
elif not kind and "display" in competing:
|
|
228
|
+
findings.append({
|
|
229
|
+
"file": display, "line": line, "selector": " ".join(prelude.split()), "kind": "class", "properties": ["display"], "severity": "warning",
|
|
230
|
+
"message": "unlayered rule sets display, so utilities such as hidden cannot toggle this element; prefer @layer components",
|
|
231
|
+
})
|
|
232
|
+
index = close + 1
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def layer_check(project: Path, paths: list[Path], force_v4: bool) -> dict[str, object]:
|
|
236
|
+
files = iter_files(paths or [project], (".css", ".pcss", ".postcss"))
|
|
237
|
+
sources = {path.resolve(): _strip_css_comments(path.read_text(encoding="utf-8", errors="replace")) for path in files}
|
|
238
|
+
tailwind_v4 = force_v4 or any(TAILWIND_V4_IMPORT.search(source) for source in sources.values())
|
|
239
|
+
if not tailwind_v4:
|
|
240
|
+
return {"schemaVersion": "maggie-design-layer-check.v1", "passed": True, "status": "skipped", "reason": "no Tailwind v4 entry (@import \"tailwindcss\") found", "checkedFiles": len(files), "findings": []}
|
|
241
|
+
layered_files: set[Path] = set()
|
|
242
|
+
for path, source in sources.items():
|
|
243
|
+
for target in LAYERED_IMPORT.findall(source):
|
|
244
|
+
layered_files.add((path.parent / target).resolve())
|
|
245
|
+
findings: list[dict[str, object]] = []
|
|
246
|
+
for path, source in sources.items():
|
|
247
|
+
_rule_findings(source, 0, len(source), path in layered_files, _display(path, project), findings)
|
|
248
|
+
errors = [item for item in findings if item["severity"] == "error"]
|
|
249
|
+
return {"schemaVersion": "maggie-design-layer-check.v1", "passed": not errors, "status": "checked", "checkedFiles": len(files), "errors": len(errors), "warnings": len(findings) - len(errors), "findings": findings}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def main(argv: list[str] | None = None) -> int:
|
|
253
|
+
parser = argparse.ArgumentParser(prog="maggie design", description="Static design checks that run before browser QA.")
|
|
254
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
255
|
+
pressable = sub.add_parser("pressable-check", help="flag pressables or buttons nested inside another pressable (.tsx/.jsx)")
|
|
256
|
+
pressable.add_argument("--project", type=Path, default=Path.cwd())
|
|
257
|
+
pressable.add_argument("--path", type=Path, action="append", default=[], help="file or directory to scan; defaults to the project")
|
|
258
|
+
pressable.add_argument("--pressable", action="append", default=[], help="extra component name that renders a pressable, for example Chip")
|
|
259
|
+
layers = sub.add_parser("layer-check", help="flag unlayered CSS that overrides Tailwind v4 utilities")
|
|
260
|
+
layers.add_argument("--project", type=Path, default=Path.cwd())
|
|
261
|
+
layers.add_argument("--css", type=Path, action="append", default=[], help="CSS file or directory to scan; defaults to the project")
|
|
262
|
+
layers.add_argument("--tailwind-v4", action="store_true", help="check even when no @import \"tailwindcss\" entry is found")
|
|
263
|
+
args = parser.parse_args(argv)
|
|
264
|
+
try:
|
|
265
|
+
if args.command == "pressable-check":
|
|
266
|
+
report = pressable_check(args.project, args.path, args.pressable)
|
|
267
|
+
else:
|
|
268
|
+
report = layer_check(args.project, args.css, args.tailwind_v4)
|
|
269
|
+
except OSError as error:
|
|
270
|
+
print(f"BLOCKED: maggie-design {args.command}: {error}", file=sys.stderr)
|
|
271
|
+
return 1
|
|
272
|
+
print(json.dumps(report, indent=2, ensure_ascii=False))
|
|
273
|
+
return 0 if report["passed"] else 1
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
if __name__ == "__main__":
|
|
277
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Orchestrate evidence for a Maggie design job and record completed steps.
|
|
3
|
+
|
|
4
|
+
``maggie design verify JOB_ID`` runs every *automatable* step of an in-place,
|
|
5
|
+
section-scoped, or author design job against a host adapter — a running
|
|
6
|
+
server (``--base-url``) or a static build (``--dist``) — writes durable
|
|
7
|
+
evidence under ``.maggie/design-evidence/<job>/``, and records a step as done
|
|
8
|
+
only when its check passed. Steps that need an implementer or a person
|
|
9
|
+
(``apply-design-contract``, ``implement-*``, ``visual-review``, ``approval``)
|
|
10
|
+
are never completed here; record them with ``maggie design step`` and real
|
|
11
|
+
evidence. A job therefore cannot reach ``done`` without evidence.
|
|
12
|
+
|
|
13
|
+
Section jobs are two-stage: run ``--stage before`` before editing (captures the
|
|
14
|
+
section snapshot) and the default ``--stage after`` once implemented.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import hashlib
|
|
21
|
+
import json
|
|
22
|
+
import re
|
|
23
|
+
import subprocess
|
|
24
|
+
import sys
|
|
25
|
+
from datetime import datetime, timezone
|
|
26
|
+
from html.parser import HTMLParser
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from urllib.parse import urlsplit
|
|
29
|
+
|
|
30
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
31
|
+
|
|
32
|
+
from maggie_design import REVIEW_VIEWPORTS, find_design_job_path, validate_section_evidence # noqa: E402
|
|
33
|
+
from maggie_icon_glyph_check import _ProbeServer, find_chrome # noqa: E402
|
|
34
|
+
|
|
35
|
+
SCHEMA = "maggie-design-verify.v1"
|
|
36
|
+
MANUAL_STEPS = {"apply-design-contract", "implement-section", "implement-page", "visual-review", "approval"}
|
|
37
|
+
INSPECT_STEPS = {"inspect-route", "inspect-shell", "inspect-section"}
|
|
38
|
+
PLACEHOLDERS = re.compile(r"\{\{[^}]*\}\}|\[object Object\]|\bundefined\b|lorem ipsum", re.I)
|
|
39
|
+
VOID_TAGS = {"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def now() -> str:
|
|
43
|
+
return datetime.now(timezone.utc).isoformat()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class PageFacts(HTMLParser):
|
|
47
|
+
"""Collect bounded structural facts and per-section hashes from HTML."""
|
|
48
|
+
|
|
49
|
+
def __init__(self) -> None:
|
|
50
|
+
super().__init__(convert_charrefs=True)
|
|
51
|
+
self.title = ""; self._in_title = False; self.lang = ""
|
|
52
|
+
self.h1 = 0; self.landmarks: set[str] = set(); self.ids: set[str] = set()
|
|
53
|
+
self.images_without_alt = 0; self.unnamed_controls = 0; self.unlabelled_inputs = 0
|
|
54
|
+
self.robots_noindex = False; self.text_parts: list[str] = []
|
|
55
|
+
self._labels_for: set[str] = set(); self._input_ids: list[tuple[str, bool]] = []
|
|
56
|
+
self._control_stack: list[dict] = []; self._label_depth = 0
|
|
57
|
+
self.sections: dict[str, list[str]] = {}; self._open_sections: list[tuple[str, int]] = []; self._depth = 0
|
|
58
|
+
|
|
59
|
+
def _section_id(self, tag: str, attrs: dict[str, str]) -> str | None:
|
|
60
|
+
explicit = attrs.get("data-section-id") or attrs.get("data-maggie-section")
|
|
61
|
+
if explicit:
|
|
62
|
+
return explicit
|
|
63
|
+
return attrs.get("id") if tag == "section" and attrs.get("id") else None
|
|
64
|
+
|
|
65
|
+
def _record(self, token: str) -> None:
|
|
66
|
+
for section_id, _depth in self._open_sections:
|
|
67
|
+
self.sections[section_id].append(token)
|
|
68
|
+
|
|
69
|
+
def handle_starttag(self, tag: str, attr_list: list[tuple[str, str | None]]) -> None:
|
|
70
|
+
attrs = {name: value or "" for name, value in attr_list}
|
|
71
|
+
void = tag in VOID_TAGS
|
|
72
|
+
if not void:
|
|
73
|
+
self._depth += 1
|
|
74
|
+
section_id = self._section_id(tag, attrs)
|
|
75
|
+
if section_id and not void:
|
|
76
|
+
self.sections.setdefault(section_id, [])
|
|
77
|
+
self._open_sections.append((section_id, self._depth))
|
|
78
|
+
self._record("<" + tag + "".join(f" {k}={v}" for k, v in sorted(attrs.items()) if k not in {"nonce"}) + ">")
|
|
79
|
+
if attrs.get("id"):
|
|
80
|
+
self.ids.add(attrs["id"])
|
|
81
|
+
if tag == "html":
|
|
82
|
+
self.lang = attrs.get("lang", "")
|
|
83
|
+
elif tag == "title":
|
|
84
|
+
self._in_title = True
|
|
85
|
+
elif tag == "h1":
|
|
86
|
+
self.h1 += 1
|
|
87
|
+
elif tag in {"header", "main", "footer", "nav"}:
|
|
88
|
+
self.landmarks.add(tag)
|
|
89
|
+
elif tag == "meta" and attrs.get("name", "").lower() == "robots" and "noindex" in attrs.get("content", "").lower():
|
|
90
|
+
self.robots_noindex = True
|
|
91
|
+
elif tag == "img" and "alt" not in attrs:
|
|
92
|
+
self.images_without_alt += 1
|
|
93
|
+
elif tag == "label":
|
|
94
|
+
self._label_depth += 1
|
|
95
|
+
if attrs.get("for"):
|
|
96
|
+
self._labels_for.add(attrs["for"])
|
|
97
|
+
elif tag in {"input", "select", "textarea"} and attrs.get("type", "").lower() not in {"hidden", "submit", "button", "reset", "image"}:
|
|
98
|
+
named = bool(attrs.get("aria-label") or attrs.get("aria-labelledby") or attrs.get("title") or self._label_depth)
|
|
99
|
+
self._input_ids.append((attrs.get("id", ""), named))
|
|
100
|
+
if tag in {"a", "button"} and not void:
|
|
101
|
+
named = bool(attrs.get("aria-label") or attrs.get("aria-labelledby") or attrs.get("title"))
|
|
102
|
+
self._control_stack.append({"tag": tag, "named": named, "text": ""})
|
|
103
|
+
if tag == "img" and self._control_stack and attrs.get("alt"):
|
|
104
|
+
self._control_stack[-1]["named"] = True
|
|
105
|
+
|
|
106
|
+
def handle_endtag(self, tag: str) -> None:
|
|
107
|
+
self._record("</" + tag + ">")
|
|
108
|
+
if tag == "title":
|
|
109
|
+
self._in_title = False
|
|
110
|
+
elif tag == "label" and self._label_depth:
|
|
111
|
+
self._label_depth -= 1
|
|
112
|
+
if tag in {"a", "button"} and self._control_stack and self._control_stack[-1]["tag"] == tag:
|
|
113
|
+
control = self._control_stack.pop()
|
|
114
|
+
if not control["named"] and not control["text"].strip():
|
|
115
|
+
self.unnamed_controls += 1
|
|
116
|
+
if tag not in VOID_TAGS:
|
|
117
|
+
while self._open_sections and self._open_sections[-1][1] >= self._depth:
|
|
118
|
+
self._open_sections.pop()
|
|
119
|
+
self._depth = max(0, self._depth - 1)
|
|
120
|
+
|
|
121
|
+
def handle_data(self, data: str) -> None:
|
|
122
|
+
if self._in_title:
|
|
123
|
+
self.title += data
|
|
124
|
+
text = " ".join(data.split())
|
|
125
|
+
if text:
|
|
126
|
+
self._record(text)
|
|
127
|
+
self.text_parts.append(text)
|
|
128
|
+
for control in self._control_stack:
|
|
129
|
+
control["text"] += text
|
|
130
|
+
|
|
131
|
+
def finish(self) -> dict:
|
|
132
|
+
unlabelled = sum(1 for input_id, named in self._input_ids if not named and not (input_id and input_id in self._labels_for))
|
|
133
|
+
return {
|
|
134
|
+
"title": self.title.strip()[:200], "lang": self.lang, "h1Count": self.h1, "landmarks": sorted(self.landmarks),
|
|
135
|
+
"imagesWithoutAlt": self.images_without_alt, "unnamedControls": self.unnamed_controls, "unlabelledInputs": unlabelled,
|
|
136
|
+
"robotsNoindex": self.robots_noindex,
|
|
137
|
+
"placeholders": sorted({match.group(0)[:40] for match in PLACEHOLDERS.finditer(" ".join(self.text_parts))}),
|
|
138
|
+
"sections": [{"id": section_id, "hash": hashlib.sha256("\n".join(tokens).encode("utf-8")).hexdigest(), "tokens": len(tokens)} for section_id, tokens in sorted(self.sections.items())],
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def page_facts(document: str) -> dict:
|
|
143
|
+
parser = PageFacts()
|
|
144
|
+
parser.feed(document)
|
|
145
|
+
parser.close()
|
|
146
|
+
return parser.finish()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def job_routes(job: dict) -> list[str]:
|
|
150
|
+
if isinstance(job.get("routes"), list) and job["routes"]:
|
|
151
|
+
return [item["route"] if isinstance(item, dict) else str(item) for item in job["routes"]]
|
|
152
|
+
if job.get("route"):
|
|
153
|
+
return [str(job["route"])]
|
|
154
|
+
if job.get("brief") and Path(job["brief"]).is_file():
|
|
155
|
+
return [json.loads(Path(job["brief"]).read_text(encoding="utf-8"))["route"]]
|
|
156
|
+
raise ValueError("design job has no routes to verify")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class Verifier:
|
|
160
|
+
def __init__(self, project: Path, job_id: str, server: _ProbeServer, chrome: str | None, stage: str, build_command: str | None, budget_ms: int) -> None:
|
|
161
|
+
self.project = project; self.job_id = job_id; self.server = server; self.chrome = chrome
|
|
162
|
+
self.stage = stage; self.build_command = build_command; self.budget_ms = budget_ms
|
|
163
|
+
self.job_path = find_design_job_path(project, job_id)
|
|
164
|
+
self.job = json.loads(self.job_path.read_text(encoding="utf-8"))
|
|
165
|
+
self.routes = job_routes(self.job)
|
|
166
|
+
self.evidence_dir = project / ".maggie" / "design-evidence" / job_id
|
|
167
|
+
self.evidence_dir.mkdir(parents=True, exist_ok=True)
|
|
168
|
+
self._pages: dict[str, tuple[int, str, str]] = {}
|
|
169
|
+
|
|
170
|
+
def page(self, route: str) -> tuple[int, str, str]:
|
|
171
|
+
if route not in self._pages:
|
|
172
|
+
status, content_type, body = self.server.fetch(route)
|
|
173
|
+
self._pages[route] = (status, content_type, body.decode("utf-8", errors="replace"))
|
|
174
|
+
return self._pages[route]
|
|
175
|
+
|
|
176
|
+
def rendered(self, route: str) -> str:
|
|
177
|
+
if not self.chrome:
|
|
178
|
+
return self.page(route)[2]
|
|
179
|
+
proc = subprocess.run([self.chrome, "--headless=new", "--no-sandbox", "--disable-gpu", f"--virtual-time-budget={self.budget_ms}", "--dump-dom", self.server.origin + route], capture_output=True, text=True, timeout=180, check=False)
|
|
180
|
+
return proc.stdout if proc.returncode == 0 and proc.stdout.strip() else self.page(route)[2]
|
|
181
|
+
|
|
182
|
+
def write(self, name: str, payload: dict) -> Path:
|
|
183
|
+
path = self.evidence_dir / f"{name}.json"
|
|
184
|
+
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
185
|
+
return path
|
|
186
|
+
|
|
187
|
+
# -- step checks: return (passed, evidence payload) ----------------------
|
|
188
|
+
|
|
189
|
+
def inspect(self, step: str) -> tuple[bool, dict]:
|
|
190
|
+
results, errors = [], []
|
|
191
|
+
for route in self.routes:
|
|
192
|
+
status, content_type, document = self.page(route)
|
|
193
|
+
facts = page_facts(document) if "html" in content_type else {}
|
|
194
|
+
results.append({"route": route, "status": status, "contentType": content_type.split(";")[0], "title": facts.get("title"), "landmarks": facts.get("landmarks"), "sectionIds": [item["id"] for item in facts.get("sections", [])], "sha256": hashlib.sha256(document.encode("utf-8")).hexdigest()})
|
|
195
|
+
if not 200 <= status <= 299 or "html" not in content_type:
|
|
196
|
+
errors.append(f"{route}: HTTP {status} {content_type.split(';')[0]}")
|
|
197
|
+
if step == "inspect-shell" and not {"header", "footer"} <= set(facts.get("landmarks", [])):
|
|
198
|
+
errors.append(f"{route}: canonical shell needs header and footer landmarks")
|
|
199
|
+
if step == "inspect-section" and self.job.get("sectionId") not in {item["id"] for item in facts.get("sections", [])}:
|
|
200
|
+
errors.append(f"{route}: section {self.job.get('sectionId')!r} not found (use <section id> or data-section-id)")
|
|
201
|
+
return not errors, {"routes": results, "errors": errors}
|
|
202
|
+
|
|
203
|
+
def declare_content_source(self) -> tuple[bool, dict]:
|
|
204
|
+
routes = [item for item in self.job.get("routes", []) if isinstance(item, dict)]
|
|
205
|
+
unproven = [item["route"] for item in routes if item.get("resolution") == "unproven"]
|
|
206
|
+
return not unproven and bool(routes), {"routes": [{key: item.get(key) for key in ("route", "resolution", "contentSource")} for item in routes], "errors": [f"{route}: catch-all content source not declared; recreate the job with --content-source" for route in unproven]}
|
|
207
|
+
|
|
208
|
+
def snapshot(self, label: str) -> tuple[bool, dict]:
|
|
209
|
+
route = self.routes[0]
|
|
210
|
+
status, _content_type, _document = self.page(route)
|
|
211
|
+
facts = page_facts(self.rendered(route))
|
|
212
|
+
payload = {"schemaVersion": "maggie-section-snapshot.v1", "route": route, "status": status, "label": label, "sections": [{"id": item["id"], "hash": item["hash"]} for item in facts["sections"]]}
|
|
213
|
+
errors = [] if 200 <= status <= 299 and payload["sections"] else [f"{route}: no sections captured (HTTP {status})"]
|
|
214
|
+
payload["errors"] = errors
|
|
215
|
+
return not errors, payload
|
|
216
|
+
|
|
217
|
+
def unchanged_rest(self) -> tuple[bool, dict]:
|
|
218
|
+
before = self.evidence_dir / "capture-before.json"
|
|
219
|
+
after = self.evidence_dir / "capture-after.json"
|
|
220
|
+
if not before.is_file() or not after.is_file():
|
|
221
|
+
return False, {"errors": ["capture-before and capture-after evidence are both required; run --stage before first"]}
|
|
222
|
+
result = validate_section_evidence(before, after, str(self.job.get("sectionId")))
|
|
223
|
+
return bool(result["passed"]), result
|
|
224
|
+
|
|
225
|
+
def screenshots(self) -> tuple[bool, dict]:
|
|
226
|
+
if not self.chrome:
|
|
227
|
+
return False, {"errors": ["google-chrome or chromium is required for screenshots"]}
|
|
228
|
+
shots, errors = [], []
|
|
229
|
+
for route in self.routes:
|
|
230
|
+
slug = re.sub(r"[^a-z0-9]+", "-", route.lower()).strip("-") or "home"
|
|
231
|
+
for viewport, (width, height) in REVIEW_VIEWPORTS.items():
|
|
232
|
+
target = self.evidence_dir / "screenshots" / f"{slug}-{viewport}.png"
|
|
233
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
234
|
+
proc = subprocess.run([self.chrome, "--headless=new", "--no-sandbox", "--disable-gpu", "--hide-scrollbars", f"--window-size={width},{height}", f"--virtual-time-budget={self.budget_ms}", f"--screenshot={target}", self.server.origin + route], capture_output=True, text=True, timeout=180, check=False)
|
|
235
|
+
if proc.returncode != 0 or not target.is_file() or target.stat().st_size == 0:
|
|
236
|
+
errors.append(f"{route} @ {viewport}: screenshot failed")
|
|
237
|
+
continue
|
|
238
|
+
shots.append({"route": route, "viewport": viewport, "size": [width, height], "path": str(target), "sha256": hashlib.sha256(target.read_bytes()).hexdigest()})
|
|
239
|
+
return not errors and bool(shots), {"screenshots": shots, "errors": errors}
|
|
240
|
+
|
|
241
|
+
def accessibility(self) -> tuple[bool, dict]:
|
|
242
|
+
results, errors = [], []
|
|
243
|
+
for route in self.routes:
|
|
244
|
+
facts = page_facts(self.rendered(route))
|
|
245
|
+
item = {key: facts[key] for key in ("lang", "h1Count", "imagesWithoutAlt", "unnamedControls", "unlabelledInputs", "landmarks")}
|
|
246
|
+
item["route"] = route
|
|
247
|
+
results.append(item)
|
|
248
|
+
if not facts["lang"]: errors.append(f"{route}: <html> has no lang")
|
|
249
|
+
if facts["h1Count"] != 1: errors.append(f"{route}: expected exactly one h1, found {facts['h1Count']}")
|
|
250
|
+
if facts["imagesWithoutAlt"]: errors.append(f"{route}: {facts['imagesWithoutAlt']} image(s) without alt")
|
|
251
|
+
if facts["unnamedControls"]: errors.append(f"{route}: {facts['unnamedControls']} link/button(s) without an accessible name")
|
|
252
|
+
if facts["unlabelledInputs"]: errors.append(f"{route}: {facts['unlabelledInputs']} form field(s) without a label")
|
|
253
|
+
if "main" not in facts["landmarks"]: errors.append(f"{route}: no <main> landmark")
|
|
254
|
+
return not errors, {"routes": results, "errors": errors, "scope": "static rendered-DOM checks; not a substitute for assistive-technology testing"}
|
|
255
|
+
|
|
256
|
+
def route_validation(self) -> tuple[bool, dict]:
|
|
257
|
+
results, errors = [], []
|
|
258
|
+
for route in self.routes:
|
|
259
|
+
status, content_type, _document = self.page(route)
|
|
260
|
+
facts = page_facts(self.rendered(route)) if "html" in content_type else {"title": "", "h1Count": 0, "placeholders": [], "robotsNoindex": False}
|
|
261
|
+
results.append({"route": route, "status": status, "title": facts["title"], "h1Count": facts["h1Count"], "placeholders": facts["placeholders"], "robotsNoindex": facts["robotsNoindex"]})
|
|
262
|
+
if not 200 <= status <= 299: errors.append(f"{route}: HTTP {status}")
|
|
263
|
+
if not facts["title"]: errors.append(f"{route}: empty <title>")
|
|
264
|
+
if facts["h1Count"] < 1: errors.append(f"{route}: no h1")
|
|
265
|
+
if facts["placeholders"]: errors.append(f"{route}: unresolved placeholder text {facts['placeholders']}")
|
|
266
|
+
if facts["robotsNoindex"]: errors.append(f"{route}: robots noindex present")
|
|
267
|
+
return not errors, {"routes": results, "errors": errors}
|
|
268
|
+
|
|
269
|
+
def build(self) -> tuple[bool, dict] | None:
|
|
270
|
+
if not self.build_command:
|
|
271
|
+
return None
|
|
272
|
+
proc = subprocess.run(self.build_command, shell=True, cwd=self.project, capture_output=True, text=True, timeout=1800, check=False)
|
|
273
|
+
tail = (proc.stdout + proc.stderr)[-2000:]
|
|
274
|
+
return proc.returncode == 0, {"command": self.build_command, "exitCode": proc.returncode, "outputTail": tail, "errors": [] if proc.returncode == 0 else [f"build exited {proc.returncode}"]}
|
|
275
|
+
|
|
276
|
+
def check(self, step: str) -> tuple[bool, dict] | None:
|
|
277
|
+
if step in INSPECT_STEPS: return self.inspect(step)
|
|
278
|
+
if step == "declare-content-source": return self.declare_content_source()
|
|
279
|
+
if step == "capture-before": return self.snapshot("before") if self.stage == "before" else None
|
|
280
|
+
if step == "capture-after": return self.snapshot("after") if self.stage == "after" else None
|
|
281
|
+
if step == "assert-unchanged-rest": return self.unchanged_rest() if self.stage == "after" else None
|
|
282
|
+
if step == "capture-responsive-screenshots": return self.screenshots()
|
|
283
|
+
if step == "accessibility-check": return self.accessibility()
|
|
284
|
+
if step == "route-validation": return self.route_validation()
|
|
285
|
+
if step == "build-check": return self.build()
|
|
286
|
+
return None
|
|
287
|
+
|
|
288
|
+
def run(self, recheck: bool) -> dict:
|
|
289
|
+
steps = self.job.get("steps") or []
|
|
290
|
+
report_steps = []
|
|
291
|
+
for step in steps:
|
|
292
|
+
name = str(step.get("name"))
|
|
293
|
+
if step.get("status") == "done" and not recheck:
|
|
294
|
+
report_steps.append({"step": name, "status": "done", "evidence": step.get("evidence"), "action": "kept"})
|
|
295
|
+
continue
|
|
296
|
+
if name in MANUAL_STEPS:
|
|
297
|
+
report_steps.append({"step": name, "status": step.get("status", "pending"), "action": "requires implementer or reviewer evidence: maggie design step " + self.job_id + f" --step {name} --evidence FILE"})
|
|
298
|
+
continue
|
|
299
|
+
outcome = self.check(name)
|
|
300
|
+
if outcome is None:
|
|
301
|
+
reason = "not part of this stage" if name in {"capture-before", "capture-after", "assert-unchanged-rest"} else ("pass --build-command to run the host build" if name == "build-check" else "no automated check for this step")
|
|
302
|
+
report_steps.append({"step": name, "status": step.get("status", "pending"), "action": reason})
|
|
303
|
+
continue
|
|
304
|
+
passed, payload = outcome
|
|
305
|
+
payload = {"schemaVersion": "maggie-design-step-evidence.v1", "jobId": self.job_id, "step": name, "passed": passed, "checkedAt": now(), **payload}
|
|
306
|
+
path = self.write(name, payload)
|
|
307
|
+
if passed:
|
|
308
|
+
step.update({"status": "done", "evidence": str(path), "completedAt": now(), "verifiedBy": "maggie design verify"})
|
|
309
|
+
self.job.setdefault("history", []).append({"event": "step-completed", "step": name, "evidence": str(path), "at": now(), "verifiedBy": "maggie design verify"})
|
|
310
|
+
else:
|
|
311
|
+
step["lastCheck"] = {"passed": False, "evidence": str(path), "at": now()}
|
|
312
|
+
report_steps.append({"step": name, "status": step.get("status", "pending"), "action": "verified" if passed else "failed", "evidence": str(path), "errors": payload.get("errors", [])[:10]})
|
|
313
|
+
self.job["phase"] = "done" if steps and all(item.get("status") == "done" for item in steps) else "ready"
|
|
314
|
+
self.job["updated_at"] = now()
|
|
315
|
+
self.job_path.write_text(json.dumps(self.job, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
316
|
+
failed = [item["step"] for item in report_steps if item.get("action") == "failed"]
|
|
317
|
+
pending = [item["step"] for item in report_steps if item.get("status") != "done"]
|
|
318
|
+
return {"schemaVersion": SCHEMA, "jobId": self.job_id, "mode": self.job.get("mode"), "stage": self.stage, "routes": self.routes, "phase": self.job["phase"], "passed": not failed, "failedSteps": failed, "pendingSteps": pending, "steps": report_steps, "evidenceDir": str(self.evidence_dir), "mutation": "job step records only"}
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def verify(project: Path, job_id: str, base_url: str | None, dist: Path | None, stage: str, build_command: str | None, recheck: bool, budget_ms: int, require_browser: bool) -> dict:
|
|
322
|
+
if bool(base_url) == bool(dist):
|
|
323
|
+
raise ValueError("exactly one of --base-url or --dist is required")
|
|
324
|
+
if base_url and urlsplit(base_url).scheme not in {"http", "https"}:
|
|
325
|
+
raise ValueError("--base-url must be http(s)")
|
|
326
|
+
try:
|
|
327
|
+
chrome: str | None = find_chrome()
|
|
328
|
+
except RuntimeError:
|
|
329
|
+
if require_browser:
|
|
330
|
+
raise
|
|
331
|
+
chrome = None
|
|
332
|
+
with _ProbeServer("", base_url=base_url, dist=dist) as server:
|
|
333
|
+
verifier = Verifier(project.resolve(), job_id, server, chrome, stage, build_command, budget_ms)
|
|
334
|
+
return verifier.run(recheck)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def main(argv: list[str] | None = None) -> int:
|
|
338
|
+
parser = argparse.ArgumentParser(prog="maggie design verify", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
339
|
+
parser.add_argument("job_id")
|
|
340
|
+
parser.add_argument("--project", type=Path, default=Path.cwd())
|
|
341
|
+
parser.add_argument("--base-url", help="running host origin (GET-only)")
|
|
342
|
+
parser.add_argument("--dist", type=Path, help="static build directory")
|
|
343
|
+
parser.add_argument("--stage", choices=("before", "after"), default="after", help="section jobs: capture before editing, then verify after")
|
|
344
|
+
parser.add_argument("--build-command", help="explicit host build command for the build-check step")
|
|
345
|
+
parser.add_argument("--recheck", action="store_true", help="re-run steps that are already done")
|
|
346
|
+
parser.add_argument("--budget-ms", type=int, default=6000)
|
|
347
|
+
parser.add_argument("--require-browser", action="store_true", help="fail instead of falling back to static HTML when Chrome is unavailable")
|
|
348
|
+
args = parser.parse_args(argv)
|
|
349
|
+
try:
|
|
350
|
+
report = verify(args.project, args.job_id, args.base_url, args.dist, args.stage, args.build_command, args.recheck, args.budget_ms, args.require_browser)
|
|
351
|
+
except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as error:
|
|
352
|
+
print(f"BLOCKED: maggie-design verify: {error}", file=sys.stderr)
|
|
353
|
+
return 2
|
|
354
|
+
print(json.dumps(report, indent=2, ensure_ascii=False))
|
|
355
|
+
return 0 if report["passed"] else 1
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
if __name__ == "__main__":
|
|
359
|
+
raise SystemExit(main())
|