@topy-ai/maggie 0.7.53 → 0.7.55
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 +28 -50
- package/README.md +80 -64
- package/bin/maggie.js +61 -9
- package/bundled-contracts/maggie-assistant-tools/booking-receptionist-example-v1.json +18 -2
- package/bundled-contracts/maggie-assistant-tools/report-v1.schema.json +43 -0
- package/bundled-contracts/maggie-assistant-tools/tool-registry-example-v1.json +249 -11
- package/bundled-contracts/maggie-assistant-tools/tool-registry-v1.schema.json +175 -0
- package/bundled-contracts/maggie-ops/agent-routines-v1.schema.json +48 -0
- package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +1 -1
- package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +2 -2
- package/bundled-skills/catalog.json +1 -1
- package/bundled-skills/maggie-assistant-tools/SKILL.md +34 -1
- package/bundled-skills/maggie-assistant-tools/references/report-inbox.md +45 -0
- package/bundled-skills/maggie-assistant-tools/references/unattended-agents.md +70 -0
- package/bundled-skills/maggie-blog/SKILL.md +15 -0
- package/bundled-skills/maggie-booking/SKILL.md +20 -12
- package/bundled-skills/maggie-content-localization/SKILL.md +26 -0
- package/bundled-skills/maggie-dash/SKILL.md +37 -2
- package/bundled-skills/maggie-deployment/SKILL.md +104 -1
- package/bundled-skills/maggie-deployment/references/vps.md +5 -0
- package/bundled-skills/maggie-design/SKILL.md +81 -0
- package/bundled-skills/maggie-feedback/SKILL.md +10 -2
- package/bundled-skills/maggie-marketplace/SKILL.md +22 -2
- package/bundled-skills/maggie-marketplace/references/directory-search.md +1 -1
- package/bundled-skills/maggie-marketplace/references/directory.mjs +2 -2
- package/bundled-skills/maggie-mobile-app/SKILL.md +29 -0
- package/bundled-skills/maggie-ops/SKILL.md +28 -0
- package/bundled-skills/maggie-ops/references/agents-repository.md +108 -0
- package/bundled-skills/maggie-ops/references/ops-autopilot.md +65 -0
- package/bundled-skills/maggie-ops/references/routine-mode.md +92 -0
- package/bundled-skills/maggie-seo-geo/SKILL.md +9 -1
- package/bundled-skills/maggie-social-share/SKILL.md +50 -0
- package/bundled-tools/clis/maggie.py +63 -3
- package/bundled-tools/clis/maggie_assistant_tools.py +225 -1
- package/bundled-tools/clis/maggie_booking.py +9 -8
- package/bundled-tools/clis/maggie_dash.py +173 -3
- package/bundled-tools/clis/maggie_deployment_ops.py +64 -1
- package/bundled-tools/clis/maggie_design.py +7 -1
- package/bundled-tools/clis/maggie_design_bulk.py +290 -0
- package/bundled-tools/clis/maggie_design_lint.py +131 -1
- package/bundled-tools/clis/maggie_feedback.py +41 -13
- package/bundled-tools/clis/maggie_host_runtime.py +458 -0
- package/bundled-tools/clis/maggie_live_urls.py +147 -0
- package/bundled-tools/clis/maggie_localization.py +3 -0
- package/bundled-tools/clis/maggie_localization_keys.py +151 -0
- package/bundled-tools/clis/maggie_media_generate.py +174 -0
- package/bundled-tools/clis/maggie_mobile.py +84 -1
- package/bundled-tools/clis/maggie_ops.py +2 -0
- package/bundled-tools/clis/maggie_ops_routines.py +352 -0
- package/bundled-tools/clis/maggie_product_release.py +49 -1
- package/bundled-tools/clis/maggie_query_ideas.py +154 -0
- package/bundled-tools/clis/maggie_skill_catalog.py +16 -2
- package/bundled-tools/clis/maggie_social_share.py +223 -2
- package/bundled-tools/clis/maggie_version_sources.py +93 -0
- package/bundled-tools/integrations/maggie-api-pull.md +49 -0
- package/package.json +1 -1
- package/bundled-contracts/maggiedash/execution-board.json +0 -5463
- package/bundled-references/maggiedash-booking/CURRENT-STATE.md +0 -210
- package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +0 -5463
- package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +0 -270
- package/bundled-references/maggiedash-booking/PROGRESS.md +0 -279
- package/bundled-references/maggiedash-booking/README.md +0 -288
- package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +0 -551
- package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +0 -107
- package/bundled-references/maggiedash-booking/TASKS.md +0 -178
|
@@ -20,6 +20,11 @@
|
|
|
20
20
|
Building with ``sudo`` leaves root-owned ``dist``/cache files that the
|
|
21
21
|
unprivileged prune cannot remove; the release refuses that and names the
|
|
22
22
|
one-time fix.
|
|
23
|
+
- ``preview-auth-check``: before (or after) putting basic auth on a preview
|
|
24
|
+
host, find every workflow, script or webhook config that still sends
|
|
25
|
+
requests to it. Each must be repointed to production or carry credentials;
|
|
26
|
+
otherwise it fails on every push (a skills sync kept posting to a protected
|
|
27
|
+
preview for days and left a public page two versions behind).
|
|
23
28
|
|
|
24
29
|
Values of secrets are never printed; only variable names and digests.
|
|
25
30
|
"""
|
|
@@ -385,6 +390,56 @@ def vps_release(args: argparse.Namespace, ssh_args: list[str]) -> dict:
|
|
|
385
390
|
return plan
|
|
386
391
|
|
|
387
392
|
|
|
393
|
+
SCAN_SUFFIXES = {".yml", ".yaml", ".json", ".jsonc", ".toml", ".sh", ".mjs", ".js", ".cjs", ".ts", ".py", ".env", ".example"}
|
|
394
|
+
SCAN_SKIP = {"node_modules", ".git", "dist", "build", ".next", ".astro", ".wrangler", ".turbo", ".maggie", "coverage"}
|
|
395
|
+
SENDS = re.compile(r"\b(?:curl|wget|fetch|axios|requests\.(?:post|put|patch|get)|httpx|Invoke-WebRequest|http\.(?:post|request))\b|-X\s*(?:POST|PUT|PATCH)|\bmethod\s*[:=]\s*[\"']?(?:POST|PUT|PATCH)\b|\b(?:webhook|callback|notify)_?url\b", re.I)
|
|
396
|
+
# Only basic-auth credentials get through a basic-auth gate; a bearer token does not.
|
|
397
|
+
BASIC_CREDENTIALS = re.compile(r"(?:\s-u\s|--user\b|authorization\s*[:=]\s*[\"']?basic\b|\bbasic\s+\$|PREVIEW_(?:USER|PASS|PASSWORD|AUTH|BASIC)|BASIC_AUTH|https?://\$\{?\w+\}?:\$)", re.I)
|
|
398
|
+
ASSIGN = re.compile(r"^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*[:=]")
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def preview_auth_check(repo: Path, host: str, live: bool) -> dict:
|
|
402
|
+
"""Workflows and scripts that send requests to a basic-auth protected preview host."""
|
|
403
|
+
host = host.lower().removeprefix("https://").removeprefix("http://").strip("/")
|
|
404
|
+
pattern = re.compile(rf"https?://{re.escape(host)}(?:[/:\s\"'`]|$)", re.I)
|
|
405
|
+
findings = []
|
|
406
|
+
for path in sorted(repo.rglob("*")):
|
|
407
|
+
if not path.is_file() or any(part in SCAN_SKIP for part in path.relative_to(repo).parts) or (path.suffix not in SCAN_SUFFIXES and path.name != "Dockerfile"):
|
|
408
|
+
continue
|
|
409
|
+
try:
|
|
410
|
+
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
411
|
+
except OSError:
|
|
412
|
+
continue
|
|
413
|
+
for number, line in enumerate(lines, 1):
|
|
414
|
+
if not pattern.search(line):
|
|
415
|
+
continue
|
|
416
|
+
window = "\n".join(lines[max(0, number - 6):number + 5])
|
|
417
|
+
uses = [number]
|
|
418
|
+
assigned = ASSIGN.match(line)
|
|
419
|
+
if assigned:
|
|
420
|
+
# The host is put in a variable (SITE: https://preview...); follow the variable.
|
|
421
|
+
name = re.escape(assigned.group(1))
|
|
422
|
+
uses = [index for index, other in enumerate(lines, 1) if index != number and re.search(rf"\$\{{?{name}\b|\benv\.{name}\b|process\.env\.{name}\b|os\.environ\S*{name}", other)]
|
|
423
|
+
window = "\n".join("\n".join(lines[max(0, index - 4):index + 3]) for index in uses)
|
|
424
|
+
if not SENDS.search(window):
|
|
425
|
+
continue
|
|
426
|
+
authed = bool(BASIC_CREDENTIALS.search(window))
|
|
427
|
+
findings.append({"file": str(path.relative_to(repo)), "line": number, "requestLines": uses[:5], "credentials": authed, "severity": "ok" if authed else "error",
|
|
428
|
+
"fix": None if authed else "repoint this to production, or send the preview's basic-auth credentials from a secret (a bearer token does not pass basic auth)"})
|
|
429
|
+
probe = None
|
|
430
|
+
if live:
|
|
431
|
+
try:
|
|
432
|
+
with urlopen(Request(f"https://{host}/", method="GET", headers={"User-Agent": "MaggieDeployment/1.0"}), timeout=15) as response:
|
|
433
|
+
probe = {"status": response.status, "basicAuth": False}
|
|
434
|
+
except HTTPError as error:
|
|
435
|
+
probe = {"status": error.code, "basicAuth": error.code == 401 and "basic" in (error.headers.get("WWW-Authenticate") or "").lower()}
|
|
436
|
+
except (URLError, TimeoutError, OSError) as error:
|
|
437
|
+
probe = {"status": None, "error": type(error).__name__}
|
|
438
|
+
errors = [item for item in findings if item["severity"] == "error"]
|
|
439
|
+
return {"schemaVersion": "maggie-deployment-preview-auth.v1", "host": host, "passed": not errors, "senders": findings, "unauthenticated": len(errors), "live": probe,
|
|
440
|
+
"note": "Every sender above must keep working once the preview asks for a password. Check the workflow runs after the change, and the public surfaces they feed."}
|
|
441
|
+
|
|
442
|
+
|
|
388
443
|
def main(argv: list[str] | None = None) -> int:
|
|
389
444
|
parser = argparse.ArgumentParser(prog="maggie deployment", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
390
445
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
@@ -425,7 +480,11 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
425
480
|
vr.add_argument("--keep", type=int, default=2)
|
|
426
481
|
vr.add_argument("--timeout", type=int, default=1800)
|
|
427
482
|
vr.add_argument("--confirm", action="store_true")
|
|
428
|
-
|
|
483
|
+
pa = sub.add_parser("preview-auth-check", help="find workflows/webhooks that send requests to a basic-auth protected preview host")
|
|
484
|
+
pa.add_argument("--host", required=True, help="the preview hostname, e.g. preview.example.com")
|
|
485
|
+
pa.add_argument("--repo", type=Path, action="append", default=[], help="repository to scan (repeatable; default .)")
|
|
486
|
+
pa.add_argument("--live", action="store_true", help="also request the host and report whether it asks for basic auth")
|
|
487
|
+
for command in (cf, fp, wc, vr, pa):
|
|
429
488
|
command.add_argument("--output", type=Path)
|
|
430
489
|
args = parser.parse_args(argv)
|
|
431
490
|
ssh_args = []
|
|
@@ -440,6 +499,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
440
499
|
elif args.command == "file-parity":
|
|
441
500
|
report = file_parity(args.host, args.root, args.path, Path(args.repo).resolve(), args.ref, args.subdir, ssh_args)
|
|
442
501
|
failed = not report["passed"]
|
|
502
|
+
elif args.command == "preview-auth-check":
|
|
503
|
+
reports = [preview_auth_check(repo.resolve(), args.host, args.live) for repo in (args.repo or [Path(".")])]
|
|
504
|
+
report = reports[0] if len(reports) == 1 else {"passed": all(item["passed"] for item in reports), "repositories": reports}
|
|
505
|
+
failed = not report["passed"]
|
|
443
506
|
elif args.command == "warm-check":
|
|
444
507
|
report = warm_check(args.url, max(0, args.warm), max(1, args.samples), args.budget_ms)
|
|
445
508
|
failed = not report["passed"]
|
|
@@ -1007,11 +1007,13 @@ commands:
|
|
|
1007
1007
|
status show a design job and its per-step progress
|
|
1008
1008
|
resume restart a failed design workflow
|
|
1009
1009
|
author plan an original first-party page
|
|
1010
|
+
bulk many similar pages from one brief, facts file and validator
|
|
1010
1011
|
markup-check validate optional style attributes, content keys, and entities
|
|
1011
1012
|
pressable-check flag pressables/buttons nested in another pressable (JSX/TSX)
|
|
1012
1013
|
layer-check flag unlayered CSS that overrides Tailwind v4 utilities
|
|
1013
1014
|
safe-area-check flag SafeAreaView inside iOS modal routes / RN Modal
|
|
1014
1015
|
textinput-check flag multiline TextInput without a height / grow-only auto-grow (RN Web)
|
|
1016
|
+
sheet-check flag a sheet close followed in the same tick by a toast/refresh/navigation (iOS freeze)
|
|
1015
1017
|
palette generate or validate a semantic palette role contract
|
|
1016
1018
|
verify run automatable design-job steps and record evidence
|
|
1017
1019
|
icon-glyph-check render icons and prove font load and glyph paint
|
|
@@ -1019,7 +1021,7 @@ commands:
|
|
|
1019
1021
|
reference-ui, init, validate-ui, app-init, app-validate, rebrand, review
|
|
1020
1022
|
""")
|
|
1021
1023
|
return 0
|
|
1022
|
-
if len(sys.argv) > 1 and sys.argv[1] in {"pressable-check", "layer-check", "safe-area-check", "textinput-check"}:
|
|
1024
|
+
if len(sys.argv) > 1 and sys.argv[1] in {"pressable-check", "layer-check", "safe-area-check", "textinput-check", "sheet-check"}:
|
|
1023
1025
|
from maggie_design_lint import main as lint_main
|
|
1024
1026
|
return lint_main(sys.argv[1:])
|
|
1025
1027
|
if len(sys.argv) > 1 and sys.argv[1] == "verify":
|
|
@@ -1183,6 +1185,10 @@ commands:
|
|
|
1183
1185
|
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
1184
1186
|
print(f"BLOCKED: maggie-design section-validate: {error}", file=sys.stderr)
|
|
1185
1187
|
return 1
|
|
1188
|
+
if len(sys.argv) > 1 and sys.argv[1] == "bulk":
|
|
1189
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
1190
|
+
from maggie_design_bulk import main as bulk_main
|
|
1191
|
+
return bulk_main(sys.argv[2:])
|
|
1186
1192
|
if len(sys.argv) > 1 and sys.argv[1] == "author":
|
|
1187
1193
|
author = argparse.ArgumentParser(description="Create an original first-party page brief and route plan.")
|
|
1188
1194
|
author.add_argument("--project", type=Path, default=Path.cwd())
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""Bulk author mode: many similar pages from one brief, facts file and validator.
|
|
2
|
+
|
|
3
|
+
One job tracks every target page through write -> validate -> picture -> seed.
|
|
4
|
+
The rules come from a run of 255 industry pages:
|
|
5
|
+
|
|
6
|
+
* a page gets its picture only after it validated and its file has not
|
|
7
|
+
changed since (a picture worker that rewrites a file while a writer still
|
|
8
|
+
edits it loses work);
|
|
9
|
+
* a writer receives the file's current hash and must re-read the page if it
|
|
10
|
+
changed before writing (``record --stage written --expect-hash``);
|
|
11
|
+
* changing the brief, facts or validator mid-run sends every page that passed
|
|
12
|
+
under the old rules back to ``revalidate``;
|
|
13
|
+
* pages are seeded only when every page in the job passed all checks.
|
|
14
|
+
|
|
15
|
+
The validator is the project's own command (JSON argv; the page path is
|
|
16
|
+
appended). Two built-in checks need no command: internal links must point at a
|
|
17
|
+
route of the job or ``--known-routes``, and every price must appear in the
|
|
18
|
+
facts file.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import hashlib
|
|
24
|
+
import json
|
|
25
|
+
import re
|
|
26
|
+
import subprocess
|
|
27
|
+
import sys
|
|
28
|
+
from datetime import datetime, timezone
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
STAGES = ("planned", "written", "validated", "pictured", "seeded")
|
|
32
|
+
LINK = re.compile(r"""(?:href=["']|\]\(|"(?:href|url|link)"\s*:\s*")(/[^"')\s#?]*)""")
|
|
33
|
+
PRICE = re.compile(r"(?:[£$€¥]\s?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?|(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?\s?(?:GBP|USD|EUR)\b)")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def now() -> str:
|
|
37
|
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def digest(path: Path) -> str | None:
|
|
41
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()[:16] if path.is_file() else None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def rules_digest(job: dict, project: Path) -> str:
|
|
45
|
+
parts = [json.dumps(job["validator"]), json.dumps(job["checks"]), json.dumps(job["knownRoutes"])]
|
|
46
|
+
for key in ("brief", "facts"):
|
|
47
|
+
parts.append(digest(project / job[key]) or "missing" if job.get(key) else "")
|
|
48
|
+
return hashlib.sha256("\n".join(parts).encode()).hexdigest()[:16]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def job_path(project: Path, job_id: str) -> Path:
|
|
52
|
+
return project / ".maggie" / "design" / f"{job_id}.json"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load(project: Path, job_id: str) -> dict:
|
|
56
|
+
return json.loads(job_path(project, job_id).read_text(encoding="utf-8"))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def save(project: Path, job: dict) -> None:
|
|
60
|
+
path = job_path(project, job["id"])
|
|
61
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
path.write_text(json.dumps(job, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def refresh_rules(job: dict, project: Path) -> list[str]:
|
|
66
|
+
"""When the rules changed, earlier passes no longer count."""
|
|
67
|
+
current = rules_digest(job, project)
|
|
68
|
+
if current == job.get("rulesDigest"):
|
|
69
|
+
return []
|
|
70
|
+
moved = []
|
|
71
|
+
for target in job["targets"]:
|
|
72
|
+
if STAGES.index(target["stage"]) >= STAGES.index("validated") and target["stage"] != "seeded":
|
|
73
|
+
target["stage"] = "written"
|
|
74
|
+
target["revalidate"] = True
|
|
75
|
+
moved.append(target["id"])
|
|
76
|
+
job["rulesDigest"] = current
|
|
77
|
+
job["history"].append({"at": now(), "event": "rules-changed", "revalidate": len(moved)})
|
|
78
|
+
return moved
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def builtin_checks(job: dict, project: Path, text: str) -> list[str]:
|
|
82
|
+
errors = []
|
|
83
|
+
if "links" in job["checks"]:
|
|
84
|
+
routes = {target["route"].rstrip("/") or "/" for target in job["targets"]} | {route.rstrip("/") or "/" for route in job["knownRoutes"]}
|
|
85
|
+
for link in sorted(set(LINK.findall(text))):
|
|
86
|
+
if (link.rstrip("/") or "/") not in routes and not link.startswith(("/_", "/api/", "/images/", "/media/", "/fonts/")) and "." not in link.rsplit("/", 1)[-1]:
|
|
87
|
+
errors.append(f"link to {link}, which is not a page of this job or a known route")
|
|
88
|
+
if "prices" in job["checks"] and job.get("facts"):
|
|
89
|
+
facts = (project / job["facts"]).read_text(encoding="utf-8") if (project / job["facts"]).is_file() else ""
|
|
90
|
+
allowed = {re.sub(r"\s", "", value) for value in PRICE.findall(facts)}
|
|
91
|
+
for price in sorted({re.sub(r"\s", "", value) for value in PRICE.findall(text)}):
|
|
92
|
+
if price not in allowed:
|
|
93
|
+
errors.append(f"price {price} is not in the facts file")
|
|
94
|
+
return errors
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def page_text(path: Path) -> str:
|
|
98
|
+
"""The page's text; for a JSON page, its string values (escapes decoded)."""
|
|
99
|
+
raw = path.read_text(encoding="utf-8", errors="replace")
|
|
100
|
+
if path.suffix != ".json":
|
|
101
|
+
return raw
|
|
102
|
+
strings: list[str] = []
|
|
103
|
+
|
|
104
|
+
def walk(value: object) -> None:
|
|
105
|
+
if isinstance(value, str):
|
|
106
|
+
strings.append(value)
|
|
107
|
+
elif isinstance(value, dict):
|
|
108
|
+
for key, item in value.items():
|
|
109
|
+
# Keep "href": "/x" shapes visible to the link check.
|
|
110
|
+
if isinstance(item, str) and key in {"href", "url", "link"}:
|
|
111
|
+
strings.append(f'"{key}": "{item}"')
|
|
112
|
+
else:
|
|
113
|
+
walk(item)
|
|
114
|
+
elif isinstance(value, list):
|
|
115
|
+
for item in value:
|
|
116
|
+
walk(item)
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
walk(json.loads(raw))
|
|
120
|
+
except json.JSONDecodeError:
|
|
121
|
+
return raw
|
|
122
|
+
return "\n".join(strings)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def validate_target(job: dict, project: Path, target: dict, timeout: int) -> dict:
|
|
126
|
+
path = project / target["file"]
|
|
127
|
+
if not path.is_file():
|
|
128
|
+
return {"passed": False, "errors": ["page file does not exist yet"]}
|
|
129
|
+
errors = builtin_checks(job, project, page_text(path))
|
|
130
|
+
if job["validator"]:
|
|
131
|
+
try:
|
|
132
|
+
completed = subprocess.run([*job["validator"], str(path)], cwd=project, capture_output=True, text=True, timeout=timeout, check=False)
|
|
133
|
+
except (OSError, subprocess.TimeoutExpired) as error:
|
|
134
|
+
return {"passed": False, "errors": errors + [f"validator could not run: {type(error).__name__}"]}
|
|
135
|
+
if completed.returncode:
|
|
136
|
+
detail = (completed.stdout.strip() or completed.stderr.strip()).splitlines()[-5:]
|
|
137
|
+
errors.extend(["validator: " + line[:300] for line in detail] or ["validator failed"])
|
|
138
|
+
return {"passed": not errors, "errors": errors}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def eligible(job: dict, project: Path, stage: str) -> list[dict]:
|
|
142
|
+
batch = []
|
|
143
|
+
for target in job["targets"]:
|
|
144
|
+
current = digest(project / target["file"])
|
|
145
|
+
if stage == "write" and target["stage"] in {"planned", "written"} and (target["stage"] == "planned" or target.get("revalidate") or target.get("lastErrors")):
|
|
146
|
+
batch.append({"id": target["id"], "route": target["route"], "file": target["file"], "currentHash": target.get("knownHash") or "none", "errors": target.get("lastErrors", [])})
|
|
147
|
+
elif stage == "picture" and target["stage"] == "validated" and current == target.get("validatedHash"):
|
|
148
|
+
# Stable since validation: nobody is editing it.
|
|
149
|
+
batch.append({"id": target["id"], "route": target["route"], "file": target["file"], "validatedHash": current})
|
|
150
|
+
if len(batch) >= job["batchSize"]:
|
|
151
|
+
break
|
|
152
|
+
return batch
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def command(args: argparse.Namespace) -> int:
|
|
156
|
+
project = args.project.resolve()
|
|
157
|
+
if args.bulk_command == "plan":
|
|
158
|
+
if not args.confirm:
|
|
159
|
+
print("CONFIRMATION_REQUIRED: rerun with --confirm", file=sys.stderr)
|
|
160
|
+
return 2
|
|
161
|
+
raw = json.loads(args.targets.read_text(encoding="utf-8"))
|
|
162
|
+
items = raw.get("targets", raw) if isinstance(raw, dict) else raw
|
|
163
|
+
targets, seen = [], set()
|
|
164
|
+
for item in items:
|
|
165
|
+
route = str(item.get("route") or "")
|
|
166
|
+
if not route.startswith("/") or route == "/":
|
|
167
|
+
raise ValueError(f"target route must be a non-homepage path: {route!r}")
|
|
168
|
+
target_id = str(item.get("id") or route.strip("/").replace("/", "-"))
|
|
169
|
+
if target_id in seen:
|
|
170
|
+
raise ValueError(f"duplicate target id {target_id}")
|
|
171
|
+
seen.add(target_id)
|
|
172
|
+
targets.append({"id": target_id, "route": route, "file": str(item.get("file") or f"src/content/pages/{target_id}.json"), "stage": "planned"})
|
|
173
|
+
job_id = "bulk-" + hashlib.sha256((str(project) + "\n" + "\n".join(t["route"] for t in targets)).encode()).hexdigest()[:12]
|
|
174
|
+
relative = lambda path: str(path.resolve().relative_to(project)) if path else None # noqa: E731
|
|
175
|
+
job = {"schemaVersion": "maggie-design-bulk.v1", "id": job_id, "mode": "bulk-author", "brief": relative(args.brief), "facts": relative(args.facts),
|
|
176
|
+
"validator": json.loads(args.validator) if args.validator else [], "checks": sorted(set(args.check)), "knownRoutes": args.known_routes,
|
|
177
|
+
"batchSize": args.batch_size, "targets": targets, "history": [{"at": now(), "event": "planned", "targets": len(targets)}]}
|
|
178
|
+
if job["validator"] and not (isinstance(job["validator"], list) and all(isinstance(part, str) for part in job["validator"])):
|
|
179
|
+
raise ValueError("--validator must be a JSON argv array")
|
|
180
|
+
job["rulesDigest"] = rules_digest(job, project)
|
|
181
|
+
save(project, job)
|
|
182
|
+
print(json.dumps({"job": job_id, "targets": len(targets), "batchSize": job["batchSize"], "plan": str(job_path(project, job_id))}, indent=2))
|
|
183
|
+
return 0
|
|
184
|
+
job = load(project, args.job)
|
|
185
|
+
revalidate = refresh_rules(job, project)
|
|
186
|
+
if args.bulk_command == "next":
|
|
187
|
+
batch = eligible(job, project, args.stage)
|
|
188
|
+
save(project, job)
|
|
189
|
+
print(json.dumps({"job": job["id"], "stage": args.stage, "revalidate": revalidate, "batch": batch, "rule": "re-read each file and compare currentHash before writing" if args.stage == "write" else "draw only these pages; skip any whose hash changed"}, indent=2, ensure_ascii=False))
|
|
190
|
+
return 0
|
|
191
|
+
if args.bulk_command == "record":
|
|
192
|
+
target = next((item for item in job["targets"] if item["id"] == args.target), None)
|
|
193
|
+
if not target:
|
|
194
|
+
raise ValueError(f"unknown target {args.target}")
|
|
195
|
+
current = digest(project / target["file"])
|
|
196
|
+
if args.stage == "written":
|
|
197
|
+
# knownHash is the file as the job last saw it (written, validated or
|
|
198
|
+
# pictured). A writer who read another version must re-read first.
|
|
199
|
+
if args.expect_hash is None:
|
|
200
|
+
raise ValueError("--expect-hash is required: pass the currentHash from `next --stage write` ('none' for a new page)")
|
|
201
|
+
if args.expect_hash != (target.get("knownHash") or "none"):
|
|
202
|
+
raise ValueError(f"{target['id']} changed since you read it (now {target.get('knownHash')}); re-read the page, apply your edit again and record with the new hash")
|
|
203
|
+
target.update({"stage": "written", "knownHash": current, "revalidate": False})
|
|
204
|
+
elif args.stage == "pictured":
|
|
205
|
+
if target["stage"] != "validated":
|
|
206
|
+
raise ValueError(f"{target['id']} is not validated; validate it before drawing")
|
|
207
|
+
target.update({"stage": "pictured", "pictureHash": current, "knownHash": current})
|
|
208
|
+
target["updatedAt"] = now()
|
|
209
|
+
save(project, job)
|
|
210
|
+
print(json.dumps({"target": target["id"], "stage": target["stage"], "hash": current}, indent=2))
|
|
211
|
+
return 0
|
|
212
|
+
if args.bulk_command == "validate":
|
|
213
|
+
results = []
|
|
214
|
+
for target in job["targets"]:
|
|
215
|
+
if args.only and target["id"] not in args.only:
|
|
216
|
+
continue
|
|
217
|
+
if target["stage"] == "planned" or (target["stage"] in {"validated", "pictured", "seeded"} and digest(project / target["file"]) == target.get("validatedHash") and not target.get("revalidate")):
|
|
218
|
+
continue
|
|
219
|
+
outcome = validate_target(job, project, target, args.timeout)
|
|
220
|
+
current = digest(project / target["file"])
|
|
221
|
+
if outcome["passed"]:
|
|
222
|
+
# A page that changed after its picture needs the picture again.
|
|
223
|
+
target.update({"stage": "validated" if target.get("pictureHash") != current else "pictured", "validatedHash": current, "knownHash": current, "revalidate": False, "lastErrors": []})
|
|
224
|
+
else:
|
|
225
|
+
target.update({"stage": "written", "lastErrors": outcome["errors"][:20]})
|
|
226
|
+
results.append({"id": target["id"], **outcome})
|
|
227
|
+
save(project, job)
|
|
228
|
+
failed = [item for item in results if not item["passed"]]
|
|
229
|
+
print(json.dumps({"job": job["id"], "validated": len(results) - len(failed), "failed": failed[:50], "revalidate": revalidate}, indent=2, ensure_ascii=False))
|
|
230
|
+
return 0 if not failed else 1
|
|
231
|
+
# status / seed
|
|
232
|
+
counts = {stage: sum(1 for target in job["targets"] if target["stage"] == stage) for stage in STAGES}
|
|
233
|
+
stale = [target["id"] for target in job["targets"] if target["stage"] in {"validated", "pictured"} and digest(project / target["file"]) != target.get("validatedHash")]
|
|
234
|
+
ready = counts["pictured"] + counts["seeded"] == len(job["targets"]) and not stale
|
|
235
|
+
if args.bulk_command == "seed":
|
|
236
|
+
if not ready:
|
|
237
|
+
print(json.dumps({"job": job["id"], "seed": "blocked", "counts": counts, "changedAfterValidation": stale, "reason": "every page must be validated, pictured and unchanged since validation before any page is seeded"}, indent=2))
|
|
238
|
+
return 1
|
|
239
|
+
if not args.confirm:
|
|
240
|
+
print("CONFIRMATION_REQUIRED: rerun with --confirm", file=sys.stderr)
|
|
241
|
+
return 2
|
|
242
|
+
for target in job["targets"]:
|
|
243
|
+
target["stage"] = "seeded"
|
|
244
|
+
job["history"].append({"at": now(), "event": "seeded", "targets": len(job["targets"])})
|
|
245
|
+
save(project, job)
|
|
246
|
+
print(json.dumps({"job": job["id"], "seed": "ready", "targets": [{"route": t["route"], "file": t["file"]} for t in job["targets"]]}, indent=2))
|
|
247
|
+
return 0
|
|
248
|
+
save(project, job)
|
|
249
|
+
print(json.dumps({"job": job["id"], "counts": counts, "changedAfterValidation": stale, "revalidate": revalidate, "seedReady": ready}, indent=2))
|
|
250
|
+
return 0
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def add_parser(argv: list[str]) -> argparse.Namespace:
|
|
254
|
+
parser = argparse.ArgumentParser(prog="maggie design bulk", description=__doc__.splitlines()[0])
|
|
255
|
+
sub = parser.add_subparsers(dest="bulk_command", required=True)
|
|
256
|
+
plan = sub.add_parser("plan", help="create a bulk author job from a target list")
|
|
257
|
+
plan.add_argument("--targets", type=Path, required=True, help='JSON [{"id"?, "route", "file"?}] or {"targets": [...]}')
|
|
258
|
+
plan.add_argument("--brief", type=Path)
|
|
259
|
+
plan.add_argument("--facts", type=Path)
|
|
260
|
+
plan.add_argument("--validator", help="JSON argv; the page file path is appended")
|
|
261
|
+
plan.add_argument("--check", action="append", choices=["links", "prices"], default=[])
|
|
262
|
+
plan.add_argument("--known-routes", nargs="*", default=[])
|
|
263
|
+
plan.add_argument("--batch-size", type=int, default=8)
|
|
264
|
+
plan.add_argument("--confirm", action="store_true")
|
|
265
|
+
for name in ("next", "record", "validate", "status", "seed"):
|
|
266
|
+
command_parser = sub.add_parser(name)
|
|
267
|
+
command_parser.add_argument("job")
|
|
268
|
+
if name == "next":
|
|
269
|
+
command_parser.add_argument("--stage", choices=["write", "picture"], required=True)
|
|
270
|
+
if name == "record":
|
|
271
|
+
command_parser.add_argument("--target", required=True)
|
|
272
|
+
command_parser.add_argument("--stage", choices=["written", "pictured"], required=True)
|
|
273
|
+
command_parser.add_argument("--expect-hash", help="the currentHash you read before writing ('none' for a new page)")
|
|
274
|
+
if name == "validate":
|
|
275
|
+
command_parser.add_argument("--only", nargs="*")
|
|
276
|
+
command_parser.add_argument("--timeout", type=int, default=120)
|
|
277
|
+
if name == "seed":
|
|
278
|
+
command_parser.add_argument("--confirm", action="store_true")
|
|
279
|
+
for action in sub.choices.values():
|
|
280
|
+
action.add_argument("--project", type=Path, default=Path.cwd())
|
|
281
|
+
return parser.parse_args(argv)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def main(argv: list[str]) -> int:
|
|
285
|
+
args = add_parser(argv)
|
|
286
|
+
try:
|
|
287
|
+
return command(args)
|
|
288
|
+
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
289
|
+
print(f"BLOCKED: maggie design bulk: {error}", file=sys.stderr)
|
|
290
|
+
return 1
|
|
@@ -22,6 +22,13 @@ Expo Router modal route (``presentation: "transparentModal"``, ``"modal"``,
|
|
|
22
22
|
surfaces can measure a 0 top inset, so the header and Close button sit under
|
|
23
23
|
the notch or Dynamic Island; web screenshots cannot show it. Read insets with
|
|
24
24
|
``useSafeAreaInsets()`` from the root ``SafeAreaProvider`` instead.
|
|
25
|
+
|
|
26
|
+
``sheet-check`` flags closing a modal sheet and, in the same tick, showing a
|
|
27
|
+
toast, refreshing or writing query data, navigating, or opening another sheet.
|
|
28
|
+
On iOS the closing sheet's transparent window can stay on top of the app and
|
|
29
|
+
swallow every touch until the app is killed. Close first, then do the rest
|
|
30
|
+
once the sheet has slid away (``afterSheetCloses(fn)``: ``setTimeout`` of the
|
|
31
|
+
slide duration on iOS/Android, immediate on web).
|
|
25
32
|
"""
|
|
26
33
|
|
|
27
34
|
from __future__ import annotations
|
|
@@ -378,6 +385,124 @@ def textinput_check(project: Path, paths: list[Path]) -> dict[str, object]:
|
|
|
378
385
|
return {"schemaVersion": "maggie-design-textinput-check.v1", "passed": not errors, "checkedFiles": len(files), "findings": findings}
|
|
379
386
|
|
|
380
387
|
|
|
388
|
+
SHEET_ALLOW = "maggie-allow-sheet-close"
|
|
389
|
+
SHEET_CLOSE = re.compile(r"\bset(?:\w*(?:Sheet|Modal|Drawer|Picker|Dialog))\s*\(\s*(?:null|undefined|false)\s*\)|\bset\w*(?:Open|Visible|Shown|Show)\s*\(\s*false\s*\)|\b(?:onClose|closeSheet|closeModal|dismiss|onDismiss)\s*\(\s*\)")
|
|
390
|
+
SHEET_DEFER_NAMES = ("afterSheetCloses", "setTimeout", "requestAnimationFrame", "queueMicrotask", "InteractionManager.runAfterInteractions", "runAfterInteractions")
|
|
391
|
+
SHEET_DEFER = re.compile(r"\b(?:" + "|".join(map(re.escape, SHEET_DEFER_NAMES)) + r")\s*\(")
|
|
392
|
+
SHEET_TRIGGERS = (
|
|
393
|
+
("toast", re.compile(r"\b(?:toast|showToast|Toast\.show)\s*\(")),
|
|
394
|
+
("query-refresh", re.compile(r"\b(?:refetch|invalidateQueries|refetchQueries|resetQueries|mutate|revalidate|reload|refresh)\s*\(")),
|
|
395
|
+
("set-query-data", re.compile(r"\bsetQueryData\s*\(")),
|
|
396
|
+
("navigation", re.compile(r"\b(?:router|navigation|navigator)\.(?:push|replace|navigate|back|dismiss\w*)\s*\(|\bLinking\.openURL\s*\(")),
|
|
397
|
+
("another-sheet", re.compile(r"\bset\w*(?:Sheet|Modal|Drawer|Picker|Dialog)\s*\(\s*(?!null|undefined|false)[^)\s]")),
|
|
398
|
+
("another-sheet", re.compile(r"\bset\w*(?:Open|Visible|Shown|Show)\s*\(\s*true\s*\)")),
|
|
399
|
+
("navigation", re.compile(r"\b\w+\.open\s*\(|\bopenSheet\s*\(")),
|
|
400
|
+
)
|
|
401
|
+
# A parent's callback often toasts or refreshes; worth a look, not an error.
|
|
402
|
+
SHEET_CALLBACK = re.compile(r"(?<![.\w])on[A-Z]\w*\s*\(")
|
|
403
|
+
SHEET_PROP = re.compile(r"<(?:\w+\.)?\w*(?:Sheet|Modal|Drawer|Dialog)\b[^>]*?\b(?:open|visible|isOpen|isVisible)\s*=\s*\{([^{}]*)\}", re.S)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
SHEET_PAUSE = re.compile(r"\bawait\s+(?:new\s+Promise\s*\([^;]*?\bsetTimeout\b|(?:sleep|delay|wait|pause)\s*\()")
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _defer_pattern(source: str) -> re.Pattern[str]:
|
|
410
|
+
"""Built-in deferrals plus local helpers whose body defers (e.g. `const later = (fn) => { ... setTimeout ... }`)."""
|
|
411
|
+
names = []
|
|
412
|
+
for match in re.finditer(r"(?:const|let)\s+(\w+)\s*=\s*(?:async\s*)?\([^\n]*?\)\s*(?::[^={\n]+)?=>\s*\{|function\s+(\w+)\s*\([^\n]*?\)[^{\n]*\{", source):
|
|
413
|
+
body = source[match.end():match.end() + 600]
|
|
414
|
+
if re.search(r"\b(?:setTimeout|afterSheetCloses|requestAnimationFrame|runAfterInteractions)\b", body.split("\n}", 1)[0]):
|
|
415
|
+
names.append(match.group(1) or match.group(2))
|
|
416
|
+
if not names:
|
|
417
|
+
return SHEET_DEFER
|
|
418
|
+
return re.compile(r"\b(?:" + "|".join(map(re.escape, (*SHEET_DEFER_NAMES, *names))) + r")\s*\(")
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _call_end(source: str, open_paren: int) -> int:
|
|
422
|
+
level, cursor = 0, open_paren
|
|
423
|
+
while cursor < len(source):
|
|
424
|
+
if source[cursor] == "(":
|
|
425
|
+
level += 1
|
|
426
|
+
elif source[cursor] == ")":
|
|
427
|
+
level -= 1
|
|
428
|
+
if level == 0:
|
|
429
|
+
return cursor
|
|
430
|
+
cursor += 1
|
|
431
|
+
return len(source)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _same_tick(source: str, start: int, defer: re.Pattern[str]) -> str:
|
|
435
|
+
"""Text that runs synchronously after `start` in the same block, with deferred callbacks blanked out."""
|
|
436
|
+
out: list[str] = []
|
|
437
|
+
depth = 0
|
|
438
|
+
index = start
|
|
439
|
+
while index < len(source):
|
|
440
|
+
deferred = defer.match(source, index)
|
|
441
|
+
if deferred:
|
|
442
|
+
index = _call_end(source, deferred.end() - 1) + 1
|
|
443
|
+
continue
|
|
444
|
+
if SHEET_PAUSE.match(source, index):
|
|
445
|
+
break # awaiting a timer longer than nothing: the rest runs later
|
|
446
|
+
char = source[index]
|
|
447
|
+
if char in "({[":
|
|
448
|
+
depth += 1
|
|
449
|
+
elif char in ")}]":
|
|
450
|
+
depth -= 1
|
|
451
|
+
if depth < 0:
|
|
452
|
+
break
|
|
453
|
+
out.append(char)
|
|
454
|
+
index += 1
|
|
455
|
+
return "".join(out)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def sheet_check(project: Path, paths: list[Path]) -> dict[str, object]:
|
|
459
|
+
project = project.resolve()
|
|
460
|
+
files = iter_files(paths or [project], (".tsx", ".jsx", ".ts", ".js"))
|
|
461
|
+
findings: list[dict[str, object]] = []
|
|
462
|
+
for path in files:
|
|
463
|
+
raw = path.read_text(encoding="utf-8", errors="replace")
|
|
464
|
+
if SHEET_ALLOW in raw or not re.search(r"\bSheet\b|<Modal\b|BottomSheet|presentation", raw):
|
|
465
|
+
continue
|
|
466
|
+
source = _strip_jsx_comments(raw)
|
|
467
|
+
display = _display(path, project)
|
|
468
|
+
# The state behind each sheet's open/visible prop: its setter closes the sheet.
|
|
469
|
+
names = {name for expression in SHEET_PROP.findall(source) for name in re.findall(r"(?<![.\w])([a-z]\w*)\b", re.sub(r"(['\"`])(?:\\.|(?!\1).)*\1", "", expression)) if name not in {"true", "false", "null", "undefined"}}
|
|
470
|
+
setters = [f"set{name[0].upper()}{name[1:]}" for name in names]
|
|
471
|
+
close_pattern = SHEET_CLOSE if not setters else re.compile(SHEET_CLOSE.pattern + "|\\b(?:" + "|".join(map(re.escape, setters)) + ")\\s*\\(\\s*(?:null|undefined|false|\"\"|'')\\s*\\)")
|
|
472
|
+
defer = _defer_pattern(source)
|
|
473
|
+
deferred_spans = [(match.end(), _call_end(source, match.end() - 1)) for match in defer.finditer(source)]
|
|
474
|
+
for close in close_pattern.finditer(source):
|
|
475
|
+
# A close inside a deferred callback already runs after the slide.
|
|
476
|
+
if any(begin <= close.start() <= end for begin, end in deferred_spans):
|
|
477
|
+
continue
|
|
478
|
+
# `const close = () => setOpen(false)`: the function only closes.
|
|
479
|
+
if re.search(r"=>\s*$", source[max(0, close.start() - 40):close.start()]):
|
|
480
|
+
continue
|
|
481
|
+
after = _same_tick(source, close.end(), defer)
|
|
482
|
+
seen: set[str] = set()
|
|
483
|
+
for kind, pattern in SHEET_TRIGGERS:
|
|
484
|
+
match = pattern.search(after)
|
|
485
|
+
if match and kind not in seen:
|
|
486
|
+
seen.add(kind)
|
|
487
|
+
findings.append({"file": display, "line": _line(source, close.start()), "kind": kind, "severity": "error", "close": close.group(0), "then": match.group(0).strip(),
|
|
488
|
+
"message": f"`{close.group(0)}` is followed in the same tick by {kind.replace('-', ' ')} (`{match.group(0).strip()}`); on iOS the closing sheet can stay on screen and take every touch. Close first, then run the rest in afterSheetCloses(() => ...)."})
|
|
489
|
+
callback = SHEET_CALLBACK.search(after)
|
|
490
|
+
if callback and not seen:
|
|
491
|
+
findings.append({"file": display, "line": _line(source, close.start()), "kind": "parent-callback", "severity": "warning", "close": close.group(0), "then": callback.group(0).strip(),
|
|
492
|
+
"message": f"`{close.group(0)}` is followed by `{callback.group(0).strip()}`; if that callback toasts, refreshes, navigates or closes another sheet, defer it with afterSheetCloses."})
|
|
493
|
+
# Content tied to the open state: closing (x = null) also empties the sheet while it slides away.
|
|
494
|
+
for tag in re.finditer(r"<(?:\w+\.)?\w*(?:Sheet|Modal|Drawer|Dialog)\b[^>]*?\b(?:open|visible|isOpen|isVisible)\s*=\s*\{\s*(?:Boolean\(\s*(\w+)\s*\)|!!\s*(\w+)|(\w+)\s*!==?\s*(?:null|undefined))\s*\}", source, re.S):
|
|
495
|
+
name = next(group for group in tag.groups() if group)
|
|
496
|
+
rest = source[tag.end():tag.end() + 1500]
|
|
497
|
+
body = rest[:rest.find("</")] if "</" in rest else rest
|
|
498
|
+
if re.search(rf"(?<![\w.]){re.escape(name)}\s*(?:\?\.|\.|\[)", source[tag.start():tag.end()] + body):
|
|
499
|
+
findings.append({"file": display, "line": _line(source, tag.start()), "kind": "content-tied-to-open", "severity": "warning", "close": f"set{name[0].upper()}{name[1:]}(null)", "then": f"{name}.… in the sheet",
|
|
500
|
+
"message": f"the sheet opens on `{name}` and shows `{name}`'s fields, so closing it (clearing `{name}`) changes its content while it slides away; keep a separate open flag and clear `{name}` in afterSheetCloses (or leave it until the next open)."})
|
|
501
|
+
errors = [item for item in findings if item["severity"] == "error"]
|
|
502
|
+
return {"schemaVersion": "maggie-design-sheet-check.v1", "passed": not errors, "checkedFiles": len(files), "findings": findings,
|
|
503
|
+
"fix": "export function afterSheetCloses(fn: () => void) { if (Platform.OS === \"web\") fn(); else setTimeout(fn, SHEET_CLOSE_MS); } // SHEET_CLOSE_MS ~ the slide-out duration (about 450 ms)"}
|
|
504
|
+
|
|
505
|
+
|
|
381
506
|
def main(argv: list[str] | None = None) -> int:
|
|
382
507
|
parser = argparse.ArgumentParser(prog="maggie design", description="Static design checks that run before browser QA.")
|
|
383
508
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
@@ -395,9 +520,14 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
395
520
|
textinput = sub.add_parser("textinput-check", help="flag multiline TextInput without a height and grow-only auto-grow on React Native Web (.tsx/.jsx)")
|
|
396
521
|
textinput.add_argument("--project", type=Path, default=Path.cwd())
|
|
397
522
|
textinput.add_argument("--path", type=Path, action="append", default=[], help="file or directory to scan; defaults to the project")
|
|
523
|
+
sheet = sub.add_parser("sheet-check", help="flag closing a modal sheet and, in the same tick, a toast, refresh, query write, navigation or another sheet (iOS freeze)")
|
|
524
|
+
sheet.add_argument("--project", type=Path, default=Path.cwd())
|
|
525
|
+
sheet.add_argument("--path", type=Path, action="append", default=[], help="file or directory to scan; defaults to the project")
|
|
398
526
|
args = parser.parse_args(argv)
|
|
399
527
|
try:
|
|
400
|
-
if args.command == "
|
|
528
|
+
if args.command == "sheet-check":
|
|
529
|
+
report = sheet_check(args.project, args.path)
|
|
530
|
+
elif args.command == "textinput-check":
|
|
401
531
|
report = textinput_check(args.project, args.path)
|
|
402
532
|
elif args.command == "safe-area-check":
|
|
403
533
|
report = safe_area_check(args.project, args.path)
|