@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.
Files changed (91) hide show
  1. package/README-zh-TW.md +2 -2
  2. package/README.md +76 -15
  3. package/bin/maggie.js +23 -6
  4. package/bundled-contracts/google-integrations/capability-report-v2.schema.json +2 -1
  5. package/bundled-contracts/maggie-content/email-localization-v1.schema.json +89 -0
  6. package/bundled-contracts/maggie-content/lifecycle-email-v1.schema.json +76 -0
  7. package/bundled-contracts/maggie-deployment/readiness-v1.schema.json +6 -1
  8. package/bundled-contracts/maggie-design/palette-roles-v1.schema.json +47 -0
  9. package/bundled-contracts/maggie-marketplace/template-content-v1.schema.json +103 -0
  10. package/bundled-contracts/maggie-ops/marketing-run-v1.schema.json +89 -0
  11. package/bundled-contracts/maggie-ops/notification-catalogue-example-v1.json +941 -0
  12. package/bundled-contracts/maggie-ops/notification-catalogue-v1.schema.json +273 -0
  13. package/bundled-contracts/maggie-service-booking/fact-provenance-v1.schema.json +128 -0
  14. package/bundled-contracts/maggie-service-booking/import-stages-v1.schema.json +191 -0
  15. package/bundled-contracts/maggiedash/booking-browser-evidence-v1.json +21 -0
  16. package/bundled-contracts/maggiedash/booking-browser-evidence-v2.json +191 -0
  17. package/bundled-contracts/maggiedash/booking-email-templates-v1.json +3 -1
  18. package/bundled-contracts/maggiedash/booking-voucher-lifecycle-v1.json +85 -0
  19. package/bundled-contracts/maggiedash/execution-board.json +4629 -1553
  20. package/bundled-contracts/maggiedash/stripe-booking-capabilities-v1.json +1 -0
  21. package/bundled-marketplace/README.md +17 -0
  22. package/bundled-marketplace/catalog.json +56 -7
  23. package/bundled-references/browser-inspection.md +25 -0
  24. package/bundled-references/google-integrations-runbook.md +54 -0
  25. package/bundled-references/maggiedash-booking/CURRENT-STATE.md +128 -10
  26. package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +4629 -1553
  27. package/bundled-references/maggiedash-booking/HOST-ADAPTER.md +5 -0
  28. package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +64 -21
  29. package/bundled-references/maggiedash-booking/PRD.md +9 -1
  30. package/bundled-references/maggiedash-booking/PROGRESS.md +244 -2399
  31. package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +37 -6
  32. package/bundled-references/maggiedash-booking/README.md +23 -6
  33. package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +64 -8
  34. package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +11 -2
  35. package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +66 -2
  36. package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +1 -1
  37. package/bundled-references/maggiedash-booking/TASKS.md +44 -3
  38. package/bundled-references/maggiedash-booking/VOUCHERS.md +47 -0
  39. package/bundled-references/provider-adapter-contract.md +33 -0
  40. package/bundled-skills/maggie-booking/SKILL.md +8 -0
  41. package/bundled-skills/maggie-clone-to-template/SKILL.md +26 -0
  42. package/bundled-skills/maggie-content-localization/SKILL.md +17 -1
  43. package/bundled-skills/maggie-content-localization/references/email-and-lifecycle.md +83 -0
  44. package/bundled-skills/maggie-deployment/SKILL.md +141 -7
  45. package/bundled-skills/maggie-deployment/references/vps.md +74 -1
  46. package/bundled-skills/maggie-design/SKILL.md +154 -0
  47. package/bundled-skills/maggie-feedback/SKILL.md +67 -1
  48. package/bundled-skills/maggie-marketplace/SKILL.md +91 -0
  49. package/bundled-skills/maggie-ops/SKILL.md +37 -0
  50. package/bundled-skills/maggie-ops/references/notifications-operations.md +154 -0
  51. package/bundled-skills/maggie-qa-workflow/SKILL.md +41 -1
  52. package/bundled-skills/maggie-service-booking/SKILL.md +82 -0
  53. package/bundled-skills/maggie-social-share/SKILL.md +56 -7
  54. package/bundled-skills/maggie-social-share/references/marketing-run.md +57 -0
  55. package/bundled-skills/maggie-social-share/references/queue-and-connections.md +152 -0
  56. package/bundled-skills/maggie-template/SKILL.md +57 -3
  57. package/bundled-skills/maggie-template/references/template-content-v1.example.json +34 -0
  58. package/bundled-tools/clis/maggie_booking.py +199 -26
  59. package/bundled-tools/clis/maggie_booking_vouchers.py +185 -0
  60. package/bundled-tools/clis/maggie_clone_to_template.py +14 -0
  61. package/bundled-tools/clis/maggie_deployment.py +307 -18
  62. package/bundled-tools/clis/maggie_deployment_auth_canary.py +216 -0
  63. package/bundled-tools/clis/maggie_deployment_collect.py +225 -0
  64. package/bundled-tools/clis/maggie_deployment_readiness.py +53 -3
  65. package/bundled-tools/clis/maggie_design.py +17 -0
  66. package/bundled-tools/clis/maggie_design_lint.py +277 -0
  67. package/bundled-tools/clis/maggie_design_verify.py +359 -0
  68. package/bundled-tools/clis/maggie_docs.py +82 -4
  69. package/bundled-tools/clis/maggie_email_localization.py +382 -0
  70. package/bundled-tools/clis/maggie_feedback.py +115 -5
  71. package/bundled-tools/clis/maggie_icon_glyph_check.py +382 -0
  72. package/bundled-tools/clis/maggie_icon_release_gate.py +28 -1
  73. package/bundled-tools/clis/maggie_localization.py +5 -0
  74. package/bundled-tools/clis/maggie_marketing_run.py +272 -0
  75. package/bundled-tools/clis/maggie_marketplace.py +199 -5
  76. package/bundled-tools/clis/maggie_notifications.py +528 -0
  77. package/bundled-tools/clis/maggie_ops.py +87 -0
  78. package/bundled-tools/clis/maggie_palette.py +280 -0
  79. package/bundled-tools/clis/maggie_qa_workflow.py +156 -1
  80. package/bundled-tools/clis/maggie_schedule.py +12 -2
  81. package/bundled-tools/clis/maggie_service_import.py +520 -0
  82. package/bundled-tools/clis/maggie_social_queue.py +442 -0
  83. package/bundled-tools/clis/maggie_social_share.py +46 -9
  84. package/bundled-tools/clis/maggie_template_package.py +1141 -0
  85. package/bundled-tools/runtime/google_ads.py +306 -0
  86. package/bundled-tools/runtime/google_capabilities.py +18 -2
  87. package/bundled-tools/runtime/google_remediation.py +152 -0
  88. package/package.json +1 -1
  89. package/references/browser-inspection.md +25 -0
  90. package/references/google-integrations-runbook.md +54 -0
  91. 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())