@topy-ai/maggie 0.7.48 → 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 +31 -4
- package/bin/maggie.js +17 -4
- 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-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-references/google-integrations-runbook.md +54 -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 +89 -7
- package/bundled-skills/maggie-deployment/references/vps.md +38 -6
- package/bundled-skills/maggie-design/SKILL.md +55 -0
- package/bundled-skills/maggie-feedback/SKILL.md +29 -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-service-booking/SKILL.md +59 -0
- package/bundled-skills/maggie-social-share/SKILL.md +19 -2
- package/bundled-skills/maggie-social-share/references/marketing-run.md +57 -0
- package/bundled-skills/maggie-social-share/references/queue-and-connections.md +51 -0
- package/bundled-tools/clis/maggie_deployment.py +58 -17
- 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 +8 -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 +9 -2
- 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_notifications.py +528 -0
- package/bundled-tools/clis/maggie_ops.py +87 -0
- 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 +104 -0
- package/bundled-tools/clis/maggie_social_share.py +6 -2
- 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/google-integrations-runbook.md +54 -0
|
@@ -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())
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Audit project documentation for stale trackers and
|
|
2
|
+
"""Audit project documentation for stale trackers, unreferenced files, and conflicting rollout status."""
|
|
3
3
|
|
|
4
4
|
from __future__ import annotations
|
|
5
5
|
|
|
@@ -126,16 +126,94 @@ def audit(project: Path, docs_dir: str = "docs") -> dict:
|
|
|
126
126
|
}
|
|
127
127
|
|
|
128
128
|
|
|
129
|
+
HISTORICAL_RE = re.compile(r"documentation-status:\s*historical-snapshot", re.I)
|
|
130
|
+
ROLLOUT_STATES = ("planned", "in_progress", "blocked", "complete")
|
|
131
|
+
CLAIM_PATTERNS = (
|
|
132
|
+
("complete", re.compile(r"(?i)^\s*[-*]\s*\[x\]|\b(?:complete|completed|done|shipped|live|resolved)\b")),
|
|
133
|
+
("open", re.compile(r"(?i)^\s*[-*]\s*\[ \]|\b(?:in[ -]progress|pending|ongoing|planned|not started|blocked|todo|unchecked)\b")),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _claim(line: str) -> str | None:
|
|
138
|
+
"""Return complete/open for a status line, or None when absent or ambiguous."""
|
|
139
|
+
found = {state for state, pattern in CLAIM_PATTERNS if pattern.search(line)}
|
|
140
|
+
return found.pop() if len(found) == 1 else None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def rollout_status(project: Path, docs_dir: str = "docs", status_file: str = "docs/rollout-status.json", id_pattern: str = r"\b[A-Z][A-Z0-9]+-\d+\b") -> dict:
|
|
144
|
+
"""Find documents whose rollout claims disagree with each other or with the canonical status file."""
|
|
145
|
+
root = project.resolve()
|
|
146
|
+
docs = (root / docs_dir).resolve()
|
|
147
|
+
errors: list[str] = []
|
|
148
|
+
canonical: dict[str, dict] = {}
|
|
149
|
+
canonical_path = root / status_file
|
|
150
|
+
if canonical_path.is_file():
|
|
151
|
+
data = json.loads(canonical_path.read_text(encoding="utf-8"))
|
|
152
|
+
if data.get("schemaVersion") != "maggie-rollout-status.v1" or not isinstance(data.get("items"), list):
|
|
153
|
+
errors.append(f"{status_file} must use schemaVersion maggie-rollout-status.v1 with an items list")
|
|
154
|
+
for item in data.get("items") or []:
|
|
155
|
+
item_id, state = str(item.get("id") or ""), item.get("status")
|
|
156
|
+
if not item_id or state not in ROLLOUT_STATES:
|
|
157
|
+
errors.append(f"{status_file}: item {item_id or '?'} needs an id and a status in {', '.join(ROLLOUT_STATES)}")
|
|
158
|
+
continue
|
|
159
|
+
if state == "complete" and not item.get("evidence"):
|
|
160
|
+
errors.append(f"{status_file}: {item_id} is complete without evidence")
|
|
161
|
+
canonical[item_id] = item
|
|
162
|
+
pattern = re.compile(id_pattern)
|
|
163
|
+
claims: dict[str, list[dict]] = {}
|
|
164
|
+
historical: list[str] = []
|
|
165
|
+
for path in _files(docs) if docs.is_dir() else []:
|
|
166
|
+
if path.suffix.casefold() not in {".md", ".mdx"}:
|
|
167
|
+
continue
|
|
168
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
169
|
+
relative = str(path.relative_to(root))
|
|
170
|
+
if HISTORICAL_RE.search(text):
|
|
171
|
+
historical.append(relative)
|
|
172
|
+
continue
|
|
173
|
+
for number, line in enumerate(text.splitlines(), 1):
|
|
174
|
+
ids = set(pattern.findall(line))
|
|
175
|
+
claim = _claim(line) if len(ids) == 1 else None
|
|
176
|
+
if claim:
|
|
177
|
+
claims.setdefault(ids.pop(), []).append({"path": relative, "line": number, "claim": claim})
|
|
178
|
+
conflicts = []
|
|
179
|
+
for item_id, found in sorted(claims.items()):
|
|
180
|
+
if item_id in canonical:
|
|
181
|
+
expected = "complete" if canonical[item_id]["status"] == "complete" else "open"
|
|
182
|
+
wrong = [claim for claim in found if claim["claim"] != expected]
|
|
183
|
+
if wrong:
|
|
184
|
+
conflicts.append({"id": item_id, "canonical": canonical[item_id]["status"], "claims": wrong})
|
|
185
|
+
elif len({claim["claim"] for claim in found}) > 1:
|
|
186
|
+
conflicts.append({"id": item_id, "canonical": None, "claims": found})
|
|
187
|
+
errors.extend(f"{item['id']}: conflicting rollout status in {', '.join(sorted({c['path'] for c in item['claims']}))}" for item in conflicts)
|
|
188
|
+
return {
|
|
189
|
+
"schemaVersion": "maggie-rollout-status-audit.v1",
|
|
190
|
+
"passed": not errors,
|
|
191
|
+
"canonical": str(canonical_path.relative_to(root)) if canonical_path.is_file() else None,
|
|
192
|
+
"itemCount": len(claims),
|
|
193
|
+
"historicalSnapshots": historical,
|
|
194
|
+
"conflicts": conflicts,
|
|
195
|
+
"errors": errors,
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
|
|
129
199
|
def main() -> int:
|
|
130
200
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
131
|
-
parser.add_argument("
|
|
201
|
+
parser.add_argument("command", nargs="?", choices=("audit", "rollout-status"), default="audit")
|
|
132
202
|
parser.add_argument("--project", default=".")
|
|
133
203
|
parser.add_argument("--docs-dir", default="docs")
|
|
134
204
|
parser.add_argument("--fail-on-findings", action="store_true")
|
|
205
|
+
parser.add_argument("--status-file", default="docs/rollout-status.json", help="canonical maggie-rollout-status.v1 file (rollout-status)")
|
|
206
|
+
parser.add_argument("--id-pattern", default=r"\b[A-Z][A-Z0-9]+-\d+\b", help="regex for rollout item IDs such as MSG-14 (rollout-status)")
|
|
135
207
|
parser.add_argument("--output")
|
|
136
208
|
args = parser.parse_args()
|
|
137
|
-
|
|
138
|
-
|
|
209
|
+
if args.command == "rollout-status":
|
|
210
|
+
try:
|
|
211
|
+
result = rollout_status(Path(args.project), args.docs_dir, args.status_file, args.id_pattern)
|
|
212
|
+
except (OSError, ValueError, json.JSONDecodeError, re.error) as error:
|
|
213
|
+
result = {"schemaVersion": "maggie-rollout-status-audit.v1", "passed": False, "errors": [str(error)]}
|
|
214
|
+
else:
|
|
215
|
+
result = audit(Path(args.project), args.docs_dir)
|
|
216
|
+
if args.command == "audit" and args.fail_on_findings and result.get("findingCount"):
|
|
139
217
|
result["passed"] = False
|
|
140
218
|
result.setdefault("errors", []).append("documentation findings require review")
|
|
141
219
|
if args.output:
|