@topy-ai/maggie 0.7.47 → 0.7.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README-zh-TW.md +2 -2
- package/README.md +76 -15
- package/bin/maggie.js +23 -6
- package/bundled-contracts/google-integrations/capability-report-v2.schema.json +2 -1
- package/bundled-contracts/maggie-content/email-localization-v1.schema.json +89 -0
- package/bundled-contracts/maggie-content/lifecycle-email-v1.schema.json +76 -0
- package/bundled-contracts/maggie-deployment/readiness-v1.schema.json +6 -1
- package/bundled-contracts/maggie-design/palette-roles-v1.schema.json +47 -0
- package/bundled-contracts/maggie-marketplace/template-content-v1.schema.json +103 -0
- package/bundled-contracts/maggie-ops/marketing-run-v1.schema.json +89 -0
- package/bundled-contracts/maggie-ops/notification-catalogue-example-v1.json +941 -0
- package/bundled-contracts/maggie-ops/notification-catalogue-v1.schema.json +273 -0
- package/bundled-contracts/maggie-service-booking/fact-provenance-v1.schema.json +128 -0
- package/bundled-contracts/maggie-service-booking/import-stages-v1.schema.json +191 -0
- package/bundled-contracts/maggiedash/booking-browser-evidence-v1.json +21 -0
- package/bundled-contracts/maggiedash/booking-browser-evidence-v2.json +191 -0
- package/bundled-contracts/maggiedash/booking-email-templates-v1.json +3 -1
- package/bundled-contracts/maggiedash/booking-voucher-lifecycle-v1.json +85 -0
- package/bundled-contracts/maggiedash/execution-board.json +4629 -1553
- package/bundled-contracts/maggiedash/stripe-booking-capabilities-v1.json +1 -0
- package/bundled-marketplace/README.md +17 -0
- package/bundled-marketplace/catalog.json +56 -7
- package/bundled-references/browser-inspection.md +25 -0
- package/bundled-references/google-integrations-runbook.md +54 -0
- package/bundled-references/maggiedash-booking/CURRENT-STATE.md +128 -10
- package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +4629 -1553
- package/bundled-references/maggiedash-booking/HOST-ADAPTER.md +5 -0
- package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +64 -21
- package/bundled-references/maggiedash-booking/PRD.md +9 -1
- package/bundled-references/maggiedash-booking/PROGRESS.md +244 -2399
- package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +37 -6
- package/bundled-references/maggiedash-booking/README.md +23 -6
- package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +64 -8
- package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +11 -2
- package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +66 -2
- package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +1 -1
- package/bundled-references/maggiedash-booking/TASKS.md +44 -3
- package/bundled-references/maggiedash-booking/VOUCHERS.md +47 -0
- package/bundled-references/provider-adapter-contract.md +33 -0
- package/bundled-skills/maggie-booking/SKILL.md +8 -0
- package/bundled-skills/maggie-clone-to-template/SKILL.md +26 -0
- package/bundled-skills/maggie-content-localization/SKILL.md +17 -1
- package/bundled-skills/maggie-content-localization/references/email-and-lifecycle.md +83 -0
- package/bundled-skills/maggie-deployment/SKILL.md +141 -7
- package/bundled-skills/maggie-deployment/references/vps.md +74 -1
- package/bundled-skills/maggie-design/SKILL.md +154 -0
- package/bundled-skills/maggie-feedback/SKILL.md +67 -1
- package/bundled-skills/maggie-marketplace/SKILL.md +91 -0
- package/bundled-skills/maggie-ops/SKILL.md +37 -0
- package/bundled-skills/maggie-ops/references/notifications-operations.md +154 -0
- package/bundled-skills/maggie-qa-workflow/SKILL.md +41 -1
- package/bundled-skills/maggie-service-booking/SKILL.md +82 -0
- package/bundled-skills/maggie-social-share/SKILL.md +56 -7
- package/bundled-skills/maggie-social-share/references/marketing-run.md +57 -0
- package/bundled-skills/maggie-social-share/references/queue-and-connections.md +152 -0
- package/bundled-skills/maggie-template/SKILL.md +57 -3
- package/bundled-skills/maggie-template/references/template-content-v1.example.json +34 -0
- package/bundled-tools/clis/maggie_booking.py +199 -26
- package/bundled-tools/clis/maggie_booking_vouchers.py +185 -0
- package/bundled-tools/clis/maggie_clone_to_template.py +14 -0
- package/bundled-tools/clis/maggie_deployment.py +307 -18
- package/bundled-tools/clis/maggie_deployment_auth_canary.py +216 -0
- package/bundled-tools/clis/maggie_deployment_collect.py +225 -0
- package/bundled-tools/clis/maggie_deployment_readiness.py +53 -3
- package/bundled-tools/clis/maggie_design.py +17 -0
- package/bundled-tools/clis/maggie_design_lint.py +277 -0
- package/bundled-tools/clis/maggie_design_verify.py +359 -0
- package/bundled-tools/clis/maggie_docs.py +82 -4
- package/bundled-tools/clis/maggie_email_localization.py +382 -0
- package/bundled-tools/clis/maggie_feedback.py +115 -5
- package/bundled-tools/clis/maggie_icon_glyph_check.py +382 -0
- package/bundled-tools/clis/maggie_icon_release_gate.py +28 -1
- package/bundled-tools/clis/maggie_localization.py +5 -0
- package/bundled-tools/clis/maggie_marketing_run.py +272 -0
- package/bundled-tools/clis/maggie_marketplace.py +199 -5
- package/bundled-tools/clis/maggie_notifications.py +528 -0
- package/bundled-tools/clis/maggie_ops.py +87 -0
- package/bundled-tools/clis/maggie_palette.py +280 -0
- package/bundled-tools/clis/maggie_qa_workflow.py +156 -1
- package/bundled-tools/clis/maggie_schedule.py +12 -2
- package/bundled-tools/clis/maggie_service_import.py +520 -0
- package/bundled-tools/clis/maggie_social_queue.py +442 -0
- package/bundled-tools/clis/maggie_social_share.py +46 -9
- package/bundled-tools/clis/maggie_template_package.py +1141 -0
- package/bundled-tools/runtime/google_ads.py +306 -0
- package/bundled-tools/runtime/google_capabilities.py +18 -2
- package/bundled-tools/runtime/google_remediation.py +152 -0
- package/package.json +1 -1
- package/references/browser-inspection.md +25 -0
- package/references/google-integrations-runbook.md +54 -0
- package/references/provider-adapter-contract.md +33 -0
|
@@ -10,6 +10,7 @@ import os
|
|
|
10
10
|
import platform
|
|
11
11
|
import re
|
|
12
12
|
import secrets
|
|
13
|
+
import subprocess
|
|
13
14
|
import sys
|
|
14
15
|
from datetime import datetime, timezone
|
|
15
16
|
from pathlib import Path
|
|
@@ -41,6 +42,14 @@ EVIDENCE_KINDS = {"test", "report", "screenshot", "command", "deployment", "issu
|
|
|
41
42
|
RELATION_KINDS = {"related", "same-run", "same-fingerprint", "duplicates", "validates", "remediates", "caused-by"}
|
|
42
43
|
ACKNOWLEDGEMENT_KEYS = ("status", "feedbackId", "requestId")
|
|
43
44
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
45
|
+
FEEDBACK_TYPES = ("bug", "feature", "quality", "improvement", "general")
|
|
46
|
+
# The hosted endpoint accepts these types. Local-only types are submitted as a
|
|
47
|
+
# hosted type with the original kept in `subtype`, so no endpoint change is needed.
|
|
48
|
+
HOSTED_TYPES = {"bug", "feature", "quality", "general"}
|
|
49
|
+
HOSTED_TYPE_FALLBACK = {"improvement": "feature"}
|
|
50
|
+
REVIEW_MARKER = "REVIEW:"
|
|
51
|
+
FIX_SUBJECT_RE = re.compile(r"^(?:fix|bugfix|hotfix)(?:\([^)]*\))?!?:\s*|^(?:fix|fixes|fixed)\b\s*", re.IGNORECASE)
|
|
52
|
+
FEATURE_SUBJECT_RE = re.compile(r"^(?:feat|perf|refactor)(?:\([^)]*\))?!?:\s*", re.IGNORECASE)
|
|
44
53
|
|
|
45
54
|
|
|
46
55
|
def now() -> str:
|
|
@@ -230,6 +239,8 @@ def collect(args: argparse.Namespace) -> int:
|
|
|
230
239
|
# Persist one canonical representation so batch review and the hosted
|
|
231
240
|
# API do not have to guess which indexing convention was used.
|
|
232
241
|
batch_index -= batch_base
|
|
242
|
+
if args.type not in FEEDBACK_TYPES:
|
|
243
|
+
raise ValueError(f"type must be one of: {', '.join(FEEDBACK_TYPES)}")
|
|
233
244
|
priority = safe_text(getattr(args, "priority", "") or "normal").lower()
|
|
234
245
|
if priority not in {"low", "normal", "high", "critical"}:
|
|
235
246
|
raise ValueError("priority must be low, normal, high, or critical")
|
|
@@ -321,6 +332,16 @@ def preview(args: argparse.Namespace) -> int:
|
|
|
321
332
|
return 0
|
|
322
333
|
|
|
323
334
|
|
|
335
|
+
def hosted_payload(data: dict) -> dict:
|
|
336
|
+
"""Map local-only feedback types onto the hosted contract without losing them."""
|
|
337
|
+
payload = dict(data)
|
|
338
|
+
local_type = payload.get("type")
|
|
339
|
+
if local_type not in HOSTED_TYPES:
|
|
340
|
+
payload["type"] = HOSTED_TYPE_FALLBACK.get(local_type, "general")
|
|
341
|
+
payload["subtype"] = local_type
|
|
342
|
+
return payload
|
|
343
|
+
|
|
344
|
+
|
|
324
345
|
def submit(args: argparse.Namespace) -> int:
|
|
325
346
|
if not args.confirm:
|
|
326
347
|
raise ValueError("submit requires --confirm; feedback is never sent implicitly")
|
|
@@ -330,7 +351,7 @@ def submit(args: argparse.Namespace) -> int:
|
|
|
330
351
|
parsed = urlparse(endpoint)
|
|
331
352
|
if parsed.scheme != "https" or not parsed.netloc:
|
|
332
353
|
raise ValueError("endpoint must be an absolute HTTPS URL")
|
|
333
|
-
payload =
|
|
354
|
+
payload = hosted_payload(data)
|
|
334
355
|
payload["projectContext"] = data.get("projectContext") if data.get("privacy", {}).get("projectContextAllowed") else None
|
|
335
356
|
request = Request(endpoint, data=json.dumps(payload, ensure_ascii=False).encode(), headers={"Content-Type": "application/json", "Accept": "application/json", "User-Agent": "MaggieFeedback/0.1"}, method="POST")
|
|
336
357
|
try:
|
|
@@ -352,7 +373,7 @@ def list_feedback(args: argparse.Namespace) -> int:
|
|
|
352
373
|
for path in sorted(directory.glob("*.json")):
|
|
353
374
|
try:
|
|
354
375
|
data = read_feedback(path)
|
|
355
|
-
records.append({"feedbackId": data.get("feedbackId"), "skill": data.get("skill"), "status": data.get("submission", {}).get("status", "draft"), "path": str(path)})
|
|
376
|
+
records.append({"feedbackId": data.get("feedbackId"), "skill": data.get("skill"), "type": data.get("type"), "status": data.get("submission", {}).get("status", "draft"), "path": str(path)})
|
|
356
377
|
except (OSError, ValueError):
|
|
357
378
|
continue
|
|
358
379
|
print(json.dumps(records, indent=2, ensure_ascii=False))
|
|
@@ -361,7 +382,14 @@ def list_feedback(args: argparse.Namespace) -> int:
|
|
|
361
382
|
|
|
362
383
|
def collect_batch(args: argparse.Namespace) -> int:
|
|
363
384
|
"""Create one privacy-safe draft per item while retaining shared batch context."""
|
|
364
|
-
|
|
385
|
+
if args.batch_file == "-":
|
|
386
|
+
# Reading JSON from stdin lets a quoted heredoc carry multiline text and
|
|
387
|
+
# backticks without shell interpolation.
|
|
388
|
+
manifest = json.loads(sys.stdin.read())
|
|
389
|
+
if not isinstance(manifest, dict):
|
|
390
|
+
raise ValueError("batch input must be a JSON object")
|
|
391
|
+
else:
|
|
392
|
+
manifest = load_json(Path(args.batch_file).expanduser().resolve())
|
|
365
393
|
batch_id = safe_text(manifest.get("batchId"))
|
|
366
394
|
if not SAFE_BATCH_ID_RE.fullmatch(batch_id):
|
|
367
395
|
raise ValueError("batch file requires a safe batchId")
|
|
@@ -383,6 +411,9 @@ def collect_batch(args: argparse.Namespace) -> int:
|
|
|
383
411
|
for index, item in enumerate(items):
|
|
384
412
|
if not isinstance(item, dict):
|
|
385
413
|
raise ValueError(f"items[{index}] must be an object")
|
|
414
|
+
unedited = [field for field in ("summary", "expected", "actual", "resolution", "validation") if REVIEW_MARKER in str(item.get(field) or "")]
|
|
415
|
+
if unedited:
|
|
416
|
+
raise ValueError(f"items[{index}] still has {REVIEW_MARKER} placeholders in: {', '.join(unedited)}; edit or remove the item before creating drafts")
|
|
386
417
|
values = {
|
|
387
418
|
"project": args.project,
|
|
388
419
|
"run_report": None,
|
|
@@ -422,6 +453,75 @@ def collect_batch(args: argparse.Namespace) -> int:
|
|
|
422
453
|
return 0
|
|
423
454
|
|
|
424
455
|
|
|
456
|
+
def session_summary(args: argparse.Namespace) -> int:
|
|
457
|
+
"""Draft a reviewable batch file from commit subjects; nothing is collected or submitted."""
|
|
458
|
+
repo = Path(args.repo).expanduser().resolve()
|
|
459
|
+
since = str(args.since).strip()
|
|
460
|
+
if not since or since.startswith("-") or not re.fullmatch(r"[A-Za-z0-9._/@{}~^:-]{1,200}", since):
|
|
461
|
+
raise ValueError("--since must be a git revision such as a tag, branch, or commit")
|
|
462
|
+
batch_id = safe_text(args.batch_id) or f"session-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}"
|
|
463
|
+
if not SAFE_BATCH_ID_RE.fullmatch(batch_id):
|
|
464
|
+
raise ValueError("batch ID is invalid")
|
|
465
|
+
completed = subprocess.run(
|
|
466
|
+
["git", "-C", str(repo), "log", "--no-merges", "--format=%h%x1f%s", f"{since}..HEAD"],
|
|
467
|
+
capture_output=True, text=True, check=False,
|
|
468
|
+
)
|
|
469
|
+
if completed.returncode:
|
|
470
|
+
raise ValueError("git log failed; check --repo and --since")
|
|
471
|
+
items = []
|
|
472
|
+
skipped = 0
|
|
473
|
+
for line in completed.stdout.splitlines():
|
|
474
|
+
sha, _, subject = line.partition("\x1f")
|
|
475
|
+
subject = safe_text(subject)[:200]
|
|
476
|
+
if not sha or not subject:
|
|
477
|
+
continue
|
|
478
|
+
if FIX_SUBJECT_RE.match(subject):
|
|
479
|
+
kind, text = "bug", FIX_SUBJECT_RE.sub("", subject, count=1)
|
|
480
|
+
elif FEATURE_SUBJECT_RE.match(subject):
|
|
481
|
+
kind, text = "improvement", FEATURE_SUBJECT_RE.sub("", subject, count=1)
|
|
482
|
+
else:
|
|
483
|
+
skipped += 1
|
|
484
|
+
continue
|
|
485
|
+
text = text[:1].upper() + text[1:] if text else subject
|
|
486
|
+
items.append({
|
|
487
|
+
"type": kind,
|
|
488
|
+
"summary": text,
|
|
489
|
+
"expected": f"{REVIEW_MARKER} what the skill or CLI should have done",
|
|
490
|
+
"actual": f"{REVIEW_MARKER} what happened before commit {sha}",
|
|
491
|
+
"fixed": True,
|
|
492
|
+
"resolution": text,
|
|
493
|
+
"validation": f"{REVIEW_MARKER} the test or check that verified commit {sha}",
|
|
494
|
+
"evidence": [f"commit:{sha}"],
|
|
495
|
+
})
|
|
496
|
+
if len(items) >= 49:
|
|
497
|
+
break
|
|
498
|
+
items.append({
|
|
499
|
+
"type": "feature",
|
|
500
|
+
"summary": f"{REVIEW_MARKER} describe an open ask from this session, or delete this item",
|
|
501
|
+
"expected": "",
|
|
502
|
+
"actual": "",
|
|
503
|
+
})
|
|
504
|
+
manifest = {
|
|
505
|
+
"batchId": batch_id,
|
|
506
|
+
"skill": safe_text(args.skill),
|
|
507
|
+
"runId": safe_text(args.run_id),
|
|
508
|
+
"phase": safe_text(args.phase) or "session-summary",
|
|
509
|
+
"reviewRequired": True,
|
|
510
|
+
"note": "Draft only. Remove items unrelated to Maggie skills or CLIs, replace every REVIEW: and delete the open-ask placeholder if unused. `maggie feedback batch` rejects unedited REVIEW: fields.",
|
|
511
|
+
"items": items,
|
|
512
|
+
}
|
|
513
|
+
project = Path(args.project).resolve()
|
|
514
|
+
output = Path(args.output) if args.output else project / ".maggie" / "feedback" / "batches" / f"{batch_id}-draft.json"
|
|
515
|
+
if not output.is_absolute():
|
|
516
|
+
output = project / output
|
|
517
|
+
if output.exists():
|
|
518
|
+
raise ValueError("output already exists; choose another --output or --batch-id")
|
|
519
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
520
|
+
output.write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
521
|
+
print(json.dumps({"status": "draft-written", "batchId": batch_id, "items": len(items), "skippedCommits": skipped, "output": str(output), "submitted": False}, ensure_ascii=False))
|
|
522
|
+
return 0
|
|
523
|
+
|
|
524
|
+
|
|
425
525
|
def batch_review(args: argparse.Namespace) -> int:
|
|
426
526
|
"""Aggregate one bounded batch without exposing raw payloads or paths."""
|
|
427
527
|
project = Path(args.project).resolve()
|
|
@@ -534,7 +634,7 @@ def main() -> int:
|
|
|
534
634
|
collect_parser.add_argument("--skill", default="")
|
|
535
635
|
collect_parser.add_argument("--run-id", default="")
|
|
536
636
|
collect_parser.add_argument("--run-report")
|
|
537
|
-
collect_parser.add_argument("--type", choices=
|
|
637
|
+
collect_parser.add_argument("--type", choices=FEEDBACK_TYPES, default="bug", help="improvement: a proposal to improve a skill or CLI (submitted as feature with subtype)")
|
|
538
638
|
collect_parser.add_argument("--phase", default="")
|
|
539
639
|
collect_parser.add_argument("--summary", required=True)
|
|
540
640
|
collect_parser.add_argument("--expected", default="")
|
|
@@ -562,7 +662,16 @@ def main() -> int:
|
|
|
562
662
|
collect_parser.add_argument("--relationship", dest="relationships", action="append", default=[], help="safe relation=target pointer")
|
|
563
663
|
batch_parser = sub.add_parser("batch")
|
|
564
664
|
batch_parser.add_argument("--project", default=argparse.SUPPRESS)
|
|
565
|
-
batch_parser.add_argument("--batch-file", required=True)
|
|
665
|
+
batch_parser.add_argument("--batch-file", required=True, help="batch JSON file, or - to read JSON from stdin")
|
|
666
|
+
summary_parser = sub.add_parser("session-summary", help="draft batch items from git commit subjects for review")
|
|
667
|
+
summary_parser.add_argument("--project", default=argparse.SUPPRESS)
|
|
668
|
+
summary_parser.add_argument("--since", required=True, help="git revision; commits after it are summarized")
|
|
669
|
+
summary_parser.add_argument("--repo", default=".", help="git repository to read (default: current directory)")
|
|
670
|
+
summary_parser.add_argument("--batch-id", default="")
|
|
671
|
+
summary_parser.add_argument("--skill", default="")
|
|
672
|
+
summary_parser.add_argument("--run-id", default="")
|
|
673
|
+
summary_parser.add_argument("--phase", default="")
|
|
674
|
+
summary_parser.add_argument("--output")
|
|
566
675
|
review_parser = sub.add_parser("batch-review")
|
|
567
676
|
review_parser.add_argument("--project", default=argparse.SUPPRESS)
|
|
568
677
|
review_parser.add_argument("--batch-id", required=True)
|
|
@@ -581,6 +690,7 @@ def main() -> int:
|
|
|
581
690
|
if args.command == "collect": return collect(args)
|
|
582
691
|
if args.command == "batch": return collect_batch(args)
|
|
583
692
|
if args.command == "batch-review": return batch_review(args)
|
|
693
|
+
if args.command == "session-summary": return session_summary(args)
|
|
584
694
|
if args.command == "preview": return preview(args)
|
|
585
695
|
if args.command == "submit": return submit(args)
|
|
586
696
|
return list_feedback(args)
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Collect rendered icon-glyph evidence in headless Chrome.
|
|
3
|
+
|
|
4
|
+
Source-to-runtime inventory proves names, not pixels. This collector renders
|
|
5
|
+
representative icons on each route and viewport and records, per icon:
|
|
6
|
+
|
|
7
|
+
- whether its icon font is in ``document.fonts`` with status ``loaded``;
|
|
8
|
+
- whether the glyph paints ink that differs from the browser fallback font
|
|
9
|
+
(a missing glyph falls back to another font or a blank box);
|
|
10
|
+
- whether runtime CSS hides it (``display``, ``visibility``, ``opacity``,
|
|
11
|
+
zero size, or a ``content: none`` pseudo-element);
|
|
12
|
+
- its family, accessible name, and the HTTP status of the icon font assets.
|
|
13
|
+
|
|
14
|
+
The target is either a running host (``--base-url``, proxied through a local
|
|
15
|
+
read-only GET proxy so the probe and fonts share one origin) or a static build
|
|
16
|
+
(``--dist``). Only GET requests are forwarded; the report never stores page
|
|
17
|
+
text beyond icon names and accessible labels.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import html as html_lib
|
|
24
|
+
import json
|
|
25
|
+
import re
|
|
26
|
+
import shutil
|
|
27
|
+
import subprocess
|
|
28
|
+
import sys
|
|
29
|
+
import threading
|
|
30
|
+
from datetime import datetime, timezone
|
|
31
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from urllib.error import HTTPError, URLError
|
|
34
|
+
from urllib.parse import urlsplit
|
|
35
|
+
from urllib.request import Request, urlopen
|
|
36
|
+
|
|
37
|
+
SCHEMA = "maggie-icon-browser-evidence.v1"
|
|
38
|
+
COLLECTOR = "maggie-icon-glyph-check"
|
|
39
|
+
DEFAULT_VIEWPORTS = {"mobile": (390, 844), "tablet": (768, 1024), "desktop": (1440, 900)}
|
|
40
|
+
FONT_EXTENSIONS = (".woff2", ".woff", ".ttf", ".otf", ".eot")
|
|
41
|
+
REPORT_ID = "maggie-icon-probe-report"
|
|
42
|
+
DEFAULT_SELECTOR = (
|
|
43
|
+
"i[class*='ph'], span[class*='ph-'], [class^='ph-'], [class*=' ph-'], i[class*='fa-'], span[class*='fa-'], "
|
|
44
|
+
"[class*='lucide'], [data-icon], [data-glyph], .material-icons, .material-symbols-outlined, "
|
|
45
|
+
".material-symbols-rounded, .bi, [class*='bi-']"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
PROBE = r"""
|
|
49
|
+
(() => {
|
|
50
|
+
const config = __CONFIG__;
|
|
51
|
+
const out = document.createElement('pre');
|
|
52
|
+
out.id = config.reportId; out.hidden = true;
|
|
53
|
+
const norm = (value) => String(value || '').split(',')[0].trim().replace(/^['"]|['"]$/g, '').toLowerCase();
|
|
54
|
+
const nameOf = (el) => {
|
|
55
|
+
const attr = el.getAttribute('data-icon') || el.getAttribute('data-glyph');
|
|
56
|
+
if (attr) return attr;
|
|
57
|
+
for (const token of Array.from(el.classList)) {
|
|
58
|
+
const match = token.match(/^(?:ph|fa|lucide|bi)-([a-z0-9-]+)$/i);
|
|
59
|
+
if (match && !/^(?:thin|light|regular|bold|fill|duotone|solid|brands|fw|lg|xl)$/i.test(match[1])) return match[1];
|
|
60
|
+
}
|
|
61
|
+
const text = (el.textContent || '').trim();
|
|
62
|
+
if (/material/.test(el.className) && text) return text;
|
|
63
|
+
return el.tagName.toLowerCase() === 'svg' ? (el.getAttribute('data-lucide') || 'svg') : (el.className || el.tagName).toString().slice(0, 60);
|
|
64
|
+
};
|
|
65
|
+
const hiddenBy = (el) => {
|
|
66
|
+
for (let node = el; node && node.nodeType === 1; node = node.parentElement) {
|
|
67
|
+
const style = getComputedStyle(node);
|
|
68
|
+
if (style.display === 'none') return node === el ? 'display:none' : 'ancestor display:none';
|
|
69
|
+
if (style.visibility === 'hidden' || style.visibility === 'collapse') return 'visibility:' + style.visibility;
|
|
70
|
+
if (Number(style.opacity) === 0) return node === el ? 'opacity:0' : 'ancestor opacity:0';
|
|
71
|
+
if (node === document.body) break;
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
};
|
|
75
|
+
const accessibleName = (el) => {
|
|
76
|
+
const direct = el.getAttribute('aria-label') || el.getAttribute('title');
|
|
77
|
+
if (direct && direct.trim()) return { name: direct.trim(), decorative: false };
|
|
78
|
+
const labelledby = el.getAttribute('aria-labelledby');
|
|
79
|
+
if (labelledby) {
|
|
80
|
+
const text = labelledby.split(/\s+/).map((id) => document.getElementById(id)?.textContent || '').join(' ').trim();
|
|
81
|
+
if (text) return { name: text, decorative: false };
|
|
82
|
+
}
|
|
83
|
+
const control = el.closest('a,button,[role=button],[role=link],label,summary');
|
|
84
|
+
if (control) {
|
|
85
|
+
const label = control.getAttribute('aria-label') || control.getAttribute('title') || (control.textContent || '').replace(/\s+/g, ' ').trim();
|
|
86
|
+
if (label) return { name: label.slice(0, 120), decorative: el.getAttribute('aria-hidden') === 'true' };
|
|
87
|
+
}
|
|
88
|
+
const parentText = (el.parentElement?.textContent || '').replace(/\s+/g, ' ').trim();
|
|
89
|
+
return { name: parentText.slice(0, 120), decorative: true };
|
|
90
|
+
};
|
|
91
|
+
const pixels = (font, text) => {
|
|
92
|
+
const canvas = document.createElement('canvas'); canvas.width = 64; canvas.height = 64;
|
|
93
|
+
const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, 64, 64); ctx.fillStyle = '#000';
|
|
94
|
+
ctx.textBaseline = 'middle'; ctx.font = font; ctx.fillText(text, 8, 32);
|
|
95
|
+
const data = ctx.getImageData(0, 0, 64, 64).data; let ink = 0; let hash = 0;
|
|
96
|
+
for (let i = 3; i < data.length; i += 4) { if (data[i]) ink += 1; hash = (hash * 31 + data[i]) | 0; }
|
|
97
|
+
return { ink, hash };
|
|
98
|
+
};
|
|
99
|
+
const glyphText = (el) => {
|
|
100
|
+
for (const pseudo of ['::before', '::after']) {
|
|
101
|
+
const style = getComputedStyle(el, pseudo);
|
|
102
|
+
const content = style.content;
|
|
103
|
+
if (content && content !== 'none' && content !== 'normal' && content !== '""') {
|
|
104
|
+
return { text: content.replace(/^["']|["']$/g, ''), style, pseudo };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const text = (el.textContent || '').trim();
|
|
108
|
+
return text ? { text, style: getComputedStyle(el), pseudo: null } : null;
|
|
109
|
+
};
|
|
110
|
+
const run = () => {
|
|
111
|
+
const icons = []; const seen = new Set();
|
|
112
|
+
for (const el of Array.from(document.querySelectorAll(config.selector))) {
|
|
113
|
+
const name = nameOf(el);
|
|
114
|
+
if (seen.has(name) || icons.length >= config.maxIcons) continue;
|
|
115
|
+
seen.add(name);
|
|
116
|
+
const rect = el.getBoundingClientRect();
|
|
117
|
+
const hidden = hiddenBy(el);
|
|
118
|
+
const label = accessibleName(el);
|
|
119
|
+
const record = { name, tag: el.tagName.toLowerCase(), width: Math.round(rect.width * 100) / 100, height: Math.round(rect.height * 100) / 100, hiddenBy: hidden, accessibleName: label.name, decorative: label.decorative };
|
|
120
|
+
if (el.tagName.toLowerCase() === 'svg' || el.querySelector('svg')) {
|
|
121
|
+
const svg = el.tagName.toLowerCase() === 'svg' ? el : el.querySelector('svg');
|
|
122
|
+
const box = svg.getBoundingClientRect();
|
|
123
|
+
record.kind = 'svg';
|
|
124
|
+
record.glyphPainted = box.width > 0 && box.height > 0 && !!svg.querySelector('path,circle,rect,line,polyline,polygon,use,ellipse');
|
|
125
|
+
record.fontLoaded = null; record.fontFamily = null;
|
|
126
|
+
} else if (el.tagName.toLowerCase() === 'img') {
|
|
127
|
+
record.kind = 'img';
|
|
128
|
+
record.glyphPainted = el.complete && el.naturalWidth > 0;
|
|
129
|
+
record.fontLoaded = null; record.fontFamily = null;
|
|
130
|
+
} else {
|
|
131
|
+
const glyph = glyphText(el);
|
|
132
|
+
record.kind = 'font';
|
|
133
|
+
if (!glyph) {
|
|
134
|
+
record.fontFamily = norm(getComputedStyle(el).fontFamily); record.fontLoaded = false; record.glyphPainted = false; record.hiddenBy = hidden || 'no glyph content';
|
|
135
|
+
} else {
|
|
136
|
+
const rawFamily = String(glyph.style.fontFamily || '').split(',')[0].trim().replace(/^['"]|['"]$/g, '');
|
|
137
|
+
const family = norm(rawFamily);
|
|
138
|
+
const weight = glyph.style.fontWeight || '400';
|
|
139
|
+
const faces = Array.from(document.fonts).filter((face) => norm(face.family) === family);
|
|
140
|
+
record.fontFamily = family;
|
|
141
|
+
record.fontLoaded = faces.some((face) => face.status === 'loaded');
|
|
142
|
+
record.fontStatus = faces.map((face) => face.status);
|
|
143
|
+
const icon = pixels(`${weight} 32px "${rawFamily}"`, glyph.text);
|
|
144
|
+
const fallback = pixels(`${weight} 32px "maggie-missing-font-probe"`, glyph.text);
|
|
145
|
+
record.glyphPainted = icon.ink > 0 && (icon.hash !== fallback.hash || icon.ink !== fallback.ink);
|
|
146
|
+
record.ink = icon.ink; record.fallbackInk = fallback.ink; record.glyphText = glyph.text.length <= 4 ? Array.from(glyph.text).map((c) => 'U+' + c.codePointAt(0).toString(16).toUpperCase().padStart(4, '0')).join(' ') : glyph.text.slice(0, 40);
|
|
147
|
+
if (glyph.pseudo && getComputedStyle(el, glyph.pseudo).display === 'none' && !record.hiddenBy) record.hiddenBy = glyph.pseudo + ' display:none';
|
|
148
|
+
}
|
|
149
|
+
const prefix = Object.keys(config.families).find((key) => (el.className || '').toString().split(/\s+/).some((token) => token === key || token.startsWith(key + '-')));
|
|
150
|
+
if (prefix) record.familyMatches = record.fontFamily === config.families[prefix].toLowerCase();
|
|
151
|
+
}
|
|
152
|
+
record.visible = !record.hiddenBy && record.width > 0 && record.height > 0;
|
|
153
|
+
icons.push(record);
|
|
154
|
+
}
|
|
155
|
+
out.textContent = JSON.stringify({ icons, fontsReady: document.fonts.status, faces: Array.from(document.fonts).map((face) => ({ family: norm(face.family), status: face.status })) });
|
|
156
|
+
document.body.appendChild(out);
|
|
157
|
+
};
|
|
158
|
+
const start = () => document.fonts.ready.then(() => setTimeout(run, config.settleMs));
|
|
159
|
+
if (document.readyState === 'complete') start(); else window.addEventListener('load', start);
|
|
160
|
+
})();
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _probe_script(selector: str, max_icons: int, families: dict[str, str], settle_ms: int) -> str:
|
|
165
|
+
config = {"reportId": REPORT_ID, "selector": selector, "maxIcons": max_icons, "families": families, "settleMs": settle_ms}
|
|
166
|
+
return "<script>" + PROBE.replace("__CONFIG__", json.dumps(config)) + "</script>"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def inject(document: str, probe: str) -> str:
|
|
170
|
+
index = document.lower().rfind("</body>")
|
|
171
|
+
return document[:index] + probe + document[index:] if index >= 0 else document + probe
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class _ProbeServer:
|
|
175
|
+
"""Serve a static build or proxy GET requests to a host, injecting the probe."""
|
|
176
|
+
|
|
177
|
+
def __init__(self, probe: str, base_url: str | None = None, dist: Path | None = None) -> None:
|
|
178
|
+
self.probe = probe; self.base_url = base_url.rstrip("/") if base_url else None; self.dist = dist.resolve() if dist else None
|
|
179
|
+
self.assets: list[dict] = []; self.lock = threading.Lock()
|
|
180
|
+
server = self
|
|
181
|
+
|
|
182
|
+
class Handler(BaseHTTPRequestHandler):
|
|
183
|
+
def log_message(self, *_args) -> None: # silence request logging
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
def do_GET(self) -> None: # noqa: N802 - stdlib handler name
|
|
187
|
+
status, content_type, body = server.fetch(self.path)
|
|
188
|
+
if status < 400 and "text/html" in content_type:
|
|
189
|
+
body = inject(body.decode("utf-8", errors="replace"), server.probe).encode("utf-8")
|
|
190
|
+
path = urlsplit(self.path).path
|
|
191
|
+
if path.lower().endswith(FONT_EXTENSIONS) or "font" in content_type:
|
|
192
|
+
with server.lock:
|
|
193
|
+
server.assets.append({"path": path, "status": status, "contentType": content_type.split(";")[0]})
|
|
194
|
+
self.send_response(status)
|
|
195
|
+
self.send_header("Content-Type", content_type)
|
|
196
|
+
self.send_header("Content-Length", str(len(body)))
|
|
197
|
+
self.end_headers()
|
|
198
|
+
self.wfile.write(body)
|
|
199
|
+
|
|
200
|
+
def do_POST(self) -> None: # noqa: N802 - writes are never forwarded
|
|
201
|
+
self.send_response(405); self.send_header("Content-Length", "0"); self.end_headers()
|
|
202
|
+
|
|
203
|
+
do_PUT = do_PATCH = do_DELETE = do_POST
|
|
204
|
+
|
|
205
|
+
self.httpd = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
|
206
|
+
self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
|
|
207
|
+
|
|
208
|
+
def fetch(self, raw_path: str) -> tuple[int, str, bytes]:
|
|
209
|
+
path = urlsplit(raw_path).path or "/"
|
|
210
|
+
if self.dist is not None:
|
|
211
|
+
relative = path.lstrip("/")
|
|
212
|
+
target = (self.dist / relative).resolve()
|
|
213
|
+
if not target.is_relative_to(self.dist):
|
|
214
|
+
return 403, "text/plain", b"forbidden"
|
|
215
|
+
if target.is_dir():
|
|
216
|
+
target = target / "index.html"
|
|
217
|
+
elif not target.exists() and (self.dist / (relative + ".html")).exists():
|
|
218
|
+
target = self.dist / (relative + ".html")
|
|
219
|
+
if not target.is_file():
|
|
220
|
+
return 404, "text/plain", b"not found"
|
|
221
|
+
return 200, _content_type(target.name), target.read_bytes()
|
|
222
|
+
request = Request(self.base_url + raw_path, headers={"Accept-Encoding": "identity", "User-Agent": "maggie-icon-glyph-check/1"})
|
|
223
|
+
try:
|
|
224
|
+
with urlopen(request, timeout=30) as response:
|
|
225
|
+
return response.status, response.headers.get("Content-Type", "application/octet-stream"), response.read()
|
|
226
|
+
except HTTPError as error:
|
|
227
|
+
return error.code, error.headers.get("Content-Type", "text/plain") if error.headers else "text/plain", error.read() or b""
|
|
228
|
+
except URLError as error:
|
|
229
|
+
return 502, "text/plain", f"upstream unavailable: {error.reason}".encode()
|
|
230
|
+
|
|
231
|
+
def __enter__(self) -> "_ProbeServer":
|
|
232
|
+
self.thread.start(); return self
|
|
233
|
+
|
|
234
|
+
def __exit__(self, *_exc) -> None:
|
|
235
|
+
self.httpd.shutdown(); self.httpd.server_close()
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def origin(self) -> str:
|
|
239
|
+
return f"http://127.0.0.1:{self.httpd.server_port}"
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _content_type(name: str) -> str:
|
|
243
|
+
lower = name.lower()
|
|
244
|
+
for suffix, value in ((".html", "text/html; charset=utf-8"), (".css", "text/css"), (".js", "text/javascript"), (".mjs", "text/javascript"), (".svg", "image/svg+xml"), (".png", "image/png"), (".jpg", "image/jpeg"), (".webp", "image/webp"), (".json", "application/json"), (".woff2", "font/woff2"), (".woff", "font/woff"), (".ttf", "font/ttf"), (".otf", "font/otf")):
|
|
245
|
+
if lower.endswith(suffix):
|
|
246
|
+
return value
|
|
247
|
+
return "application/octet-stream"
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def find_chrome() -> str:
|
|
251
|
+
chrome = shutil.which("google-chrome") or shutil.which("chromium") or shutil.which("chromium-browser") or shutil.which("chrome")
|
|
252
|
+
if not chrome:
|
|
253
|
+
raise RuntimeError("google-chrome or chromium is required for icon-glyph-check")
|
|
254
|
+
return chrome
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def render(chrome: str, url: str, width: int, height: int, budget_ms: int) -> dict:
|
|
258
|
+
proc = subprocess.run([chrome, "--headless=new", "--no-sandbox", "--disable-gpu", "--hide-scrollbars", f"--window-size={width},{height}", f"--virtual-time-budget={budget_ms}", "--dump-dom", url], capture_output=True, text=True, timeout=180, check=False)
|
|
259
|
+
if proc.returncode != 0:
|
|
260
|
+
raise RuntimeError(f"headless chrome failed: {(proc.stderr or '')[-300:]}")
|
|
261
|
+
match = re.search(rf'<pre id="{REPORT_ID}"[^>]*>(.*?)</pre>', proc.stdout, re.S)
|
|
262
|
+
if not match:
|
|
263
|
+
raise RuntimeError("icon probe did not report; the page may block inline scripts or never finished loading")
|
|
264
|
+
return json.loads(html_lib.unescape(match.group(1)) or "{}")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _asset_status(icon: dict, assets: list[dict]) -> int:
|
|
268
|
+
if icon.get("kind") != "font":
|
|
269
|
+
return 200 if icon.get("glyphPainted") else 0
|
|
270
|
+
if icon.get("fontLoaded"):
|
|
271
|
+
return 200 # the face reached status "loaded", so its file was served
|
|
272
|
+
failing = [item["status"] for item in assets if item["status"] >= 400]
|
|
273
|
+
return min(failing) if failing else 0
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def collect(routes: list[str], viewports: dict[str, tuple[int, int]], base_url: str | None, dist: Path | None, selector: str, families: dict[str, str], max_icons: int, budget_ms: int, settle_ms: int) -> dict:
|
|
277
|
+
if bool(base_url) == bool(dist):
|
|
278
|
+
raise ValueError("exactly one of --base-url or --dist is required")
|
|
279
|
+
chrome = find_chrome()
|
|
280
|
+
probe = _probe_script(selector, max_icons, families, settle_ms)
|
|
281
|
+
icons: list[dict] = []; errors: list[str] = []; route_results: list[dict] = []
|
|
282
|
+
with _ProbeServer(probe, base_url=base_url, dist=dist) as server:
|
|
283
|
+
for route in routes:
|
|
284
|
+
if not route.startswith("/"):
|
|
285
|
+
raise ValueError(f"route must start with /: {route}")
|
|
286
|
+
for viewport, (width, height) in viewports.items():
|
|
287
|
+
before = len(server.assets)
|
|
288
|
+
try:
|
|
289
|
+
report = render(chrome, server.origin + route, width, height, budget_ms)
|
|
290
|
+
except RuntimeError as error:
|
|
291
|
+
errors.append(f"{route} @ {viewport}: {error}")
|
|
292
|
+
route_results.append({"route": route, "viewport": viewport, "status": "error"})
|
|
293
|
+
continue
|
|
294
|
+
assets = server.assets[before:] or server.assets
|
|
295
|
+
found = report.get("icons") or []
|
|
296
|
+
for icon in found:
|
|
297
|
+
icon.update({"route": route, "viewport": viewport, "assetStatus": _asset_status(icon, assets)})
|
|
298
|
+
icons.append(icon)
|
|
299
|
+
route_results.append({"route": route, "viewport": viewport, "status": "rendered", "iconCount": len(found), "fontsReady": report.get("fontsReady")})
|
|
300
|
+
if not found:
|
|
301
|
+
errors.append(f"{route} @ {viewport}: no icons matched the selector")
|
|
302
|
+
font_assets = sorted({(item["path"], item["status"]) for item in server.assets})
|
|
303
|
+
failures = sorted({f"{icon['name']} @ {icon['route']} {icon['viewport']}" for icon in icons if icon_failure(icon)})
|
|
304
|
+
if failures:
|
|
305
|
+
errors.append("icons failed rendered-glyph checks: " + ", ".join(failures[:20]))
|
|
306
|
+
return {
|
|
307
|
+
"schemaVersion": SCHEMA, "collector": COLLECTOR, "passed": not errors,
|
|
308
|
+
"target": "dist" if dist else "base-url", "routes": routes, "viewports": {name: list(size) for name, size in viewports.items()},
|
|
309
|
+
"renders": route_results, "icons": icons,
|
|
310
|
+
"fontAssets": [{"path": path, "status": status} for path, status in font_assets],
|
|
311
|
+
"errors": errors, "generatedAt": datetime.now(timezone.utc).isoformat(), "mutation": "not executed",
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def icon_failure(icon: dict) -> str | None:
|
|
316
|
+
"""Return why one collected icon fails, or ``None`` when it is proven painted."""
|
|
317
|
+
if icon.get("hiddenBy"):
|
|
318
|
+
return f"hidden by {icon['hiddenBy']}"
|
|
319
|
+
if not (isinstance(icon.get("width"), (int, float)) and icon["width"] > 0 and isinstance(icon.get("height"), (int, float)) and icon["height"] > 0):
|
|
320
|
+
return "zero size"
|
|
321
|
+
if icon.get("kind") == "font" and icon.get("fontLoaded") is not True:
|
|
322
|
+
return "icon font not loaded"
|
|
323
|
+
if icon.get("glyphPainted") is not True:
|
|
324
|
+
return "glyph not painted (fallback font or blank)"
|
|
325
|
+
if icon.get("familyMatches") is False:
|
|
326
|
+
return "unexpected font family"
|
|
327
|
+
if not str(icon.get("accessibleName") or "").strip():
|
|
328
|
+
return "no accessible name"
|
|
329
|
+
return None
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _parse_viewports(values: list[str]) -> dict[str, tuple[int, int]]:
|
|
333
|
+
if not values:
|
|
334
|
+
return dict(DEFAULT_VIEWPORTS)
|
|
335
|
+
result: dict[str, tuple[int, int]] = {}
|
|
336
|
+
for value in values:
|
|
337
|
+
match = re.fullmatch(r"(?:([a-z0-9-]+)=)?(\d{3,4})x(\d{3,4})", value.strip(), re.I)
|
|
338
|
+
if not match:
|
|
339
|
+
raise ValueError(f"viewport must look like mobile=390x844 or 390x844: {value}")
|
|
340
|
+
name = match.group(1) or f"{match.group(2)}w"
|
|
341
|
+
result[name] = (int(match.group(2)), int(match.group(3)))
|
|
342
|
+
return result
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _parse_families(values: list[str]) -> dict[str, str]:
|
|
346
|
+
result = {}
|
|
347
|
+
for value in values:
|
|
348
|
+
prefix, sep, family = value.partition("=")
|
|
349
|
+
if not sep or not prefix.strip() or not family.strip():
|
|
350
|
+
raise ValueError(f"--family must look like ph=Phosphor: {value}")
|
|
351
|
+
result[prefix.strip()] = family.strip()
|
|
352
|
+
return result
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def main(argv: list[str] | None = None) -> int:
|
|
356
|
+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
357
|
+
parser.add_argument("--base-url", help="running host origin, e.g. http://localhost:4321 (GET-only proxy)")
|
|
358
|
+
parser.add_argument("--dist", type=Path, help="static build directory to serve locally")
|
|
359
|
+
parser.add_argument("--route", action="append", default=[], help="route to render; repeatable (default /)")
|
|
360
|
+
parser.add_argument("--viewport", action="append", default=[], help="name=WIDTHxHEIGHT; repeatable (default mobile, tablet, desktop)")
|
|
361
|
+
parser.add_argument("--selector", default=DEFAULT_SELECTOR, help="CSS selector for icon elements")
|
|
362
|
+
parser.add_argument("--family", action="append", default=[], help="expected font family per class prefix, e.g. ph=Phosphor")
|
|
363
|
+
parser.add_argument("--max-icons", type=int, default=60, help="representative icons per render (one per name)")
|
|
364
|
+
parser.add_argument("--budget-ms", type=int, default=8000)
|
|
365
|
+
parser.add_argument("--settle-ms", type=int, default=250)
|
|
366
|
+
parser.add_argument("--output", type=Path, required=True)
|
|
367
|
+
args = parser.parse_args(argv)
|
|
368
|
+
try:
|
|
369
|
+
report = collect(args.route or ["/"], _parse_viewports(args.viewport), args.base_url, args.dist, args.selector, _parse_families(args.family), args.max_icons, args.budget_ms, args.settle_ms)
|
|
370
|
+
except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as error:
|
|
371
|
+
print(f"BLOCKED: maggie-design icon-glyph-check: {error}", file=sys.stderr)
|
|
372
|
+
return 2
|
|
373
|
+
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
374
|
+
args.output.write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
375
|
+
summary = {key: report[key] for key in ("schemaVersion", "collector", "passed", "routes", "errors")}
|
|
376
|
+
summary.update({"iconCount": len(report["icons"]), "output": str(args.output)})
|
|
377
|
+
print(json.dumps(summary, indent=2, ensure_ascii=False))
|
|
378
|
+
return 0 if report["passed"] else 1
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
if __name__ == "__main__":
|
|
382
|
+
raise SystemExit(main())
|
|
@@ -5,9 +5,12 @@ from __future__ import annotations
|
|
|
5
5
|
|
|
6
6
|
import argparse
|
|
7
7
|
import json
|
|
8
|
+
import sys
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
from typing import Any
|
|
10
11
|
|
|
12
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
13
|
+
|
|
11
14
|
|
|
12
15
|
def validate(icon_report: object, browser_report: object) -> dict[str, Any]:
|
|
13
16
|
errors: list[str] = []
|
|
@@ -40,6 +43,27 @@ def validate(icon_report: object, browser_report: object) -> dict[str, Any]:
|
|
|
40
43
|
invalid.append(name)
|
|
41
44
|
if invalid:
|
|
42
45
|
errors.append("browser icon evidence has invisible, zero-size, inaccessible, or unserved glyphs")
|
|
46
|
+
# Evidence from `maggie design icon-glyph-check` also proves the icon font
|
|
47
|
+
# loaded and the glyph differs from the fallback font at every viewport.
|
|
48
|
+
# Hand-written legacy evidence cannot prove that, so it is only warned.
|
|
49
|
+
warnings: list[str] = []
|
|
50
|
+
glyph_proof = browser_report.get("collector") == "maggie-icon-glyph-check" or any(isinstance(icon, dict) and "glyphPainted" in icon for icon in icons)
|
|
51
|
+
glyph_failures: list[str] = []
|
|
52
|
+
if glyph_proof:
|
|
53
|
+
from maggie_icon_glyph_check import icon_failure
|
|
54
|
+
|
|
55
|
+
for icon in icons:
|
|
56
|
+
reason = icon_failure(icon) if isinstance(icon, dict) else "invalid icon record"
|
|
57
|
+
if reason:
|
|
58
|
+
glyph_failures.append(f"{icon.get('name', '?') if isinstance(icon, dict) else '?'} @ {icon.get('viewport', '?') if isinstance(icon, dict) else '?'}: {reason}")
|
|
59
|
+
expected_viewports = set((browser_report.get("viewports") or {}).keys()) if isinstance(browser_report.get("viewports"), dict) else set()
|
|
60
|
+
rendered = {icon.get("viewport") for icon in icons if isinstance(icon, dict)}
|
|
61
|
+
if expected_viewports and expected_viewports - rendered:
|
|
62
|
+
glyph_failures.append("no icon evidence for viewport(s): " + ", ".join(sorted(expected_viewports - rendered)))
|
|
63
|
+
if glyph_failures:
|
|
64
|
+
errors.append("rendered glyph proof failed: font not loaded, fallback glyph, hidden by CSS, or missing viewport")
|
|
65
|
+
else:
|
|
66
|
+
warnings.append("browser evidence does not prove font loading or glyph identity; collect it with `maggie design icon-glyph-check`")
|
|
43
67
|
return {
|
|
44
68
|
"schemaVersion": "maggie-icon-release-gate.v1",
|
|
45
69
|
"passed": not errors,
|
|
@@ -47,9 +71,12 @@ def validate(icon_report: object, browser_report: object) -> dict[str, Any]:
|
|
|
47
71
|
"checks": {
|
|
48
72
|
"sourceRuntime": source_ok,
|
|
49
73
|
"servedAssets": browser_ok and not bool(invalid),
|
|
50
|
-
"paintedGlyphs": bool(icons) and not bool(invalid),
|
|
74
|
+
"paintedGlyphs": bool(icons) and not bool(invalid) and not glyph_failures,
|
|
51
75
|
"accessibility": bool(icons) and not bool(invalid),
|
|
52
76
|
},
|
|
77
|
+
"glyphIdentityProven": glyph_proof and not glyph_failures,
|
|
78
|
+
"glyphFailures": glyph_failures[:50],
|
|
79
|
+
"warnings": warnings,
|
|
53
80
|
"iconCount": len(icons),
|
|
54
81
|
"invalidIcons": sorted(set(invalid)),
|
|
55
82
|
"mutation": "not executed",
|