@topy-ai/maggie 0.7.16 → 0.7.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/README.md +52 -9
  2. package/README.zh-TW.md +10 -2
  3. package/bin/maggie.js +5 -4
  4. package/bundled-references/universal-booking-adapter.md +36 -1
  5. package/bundled-skills/maggie-blog/SKILL.md +11 -1
  6. package/bundled-skills/maggie-dash/SKILL.md +5 -0
  7. package/bundled-skills/maggie-deployment/SKILL.md +9 -1
  8. package/bundled-skills/maggie-feedback/SKILL.md +5 -0
  9. package/bundled-skills/maggie-ops/SKILL.md +8 -0
  10. package/bundled-skills/maggie-qa-workflow/SKILL.md +11 -0
  11. package/bundled-skills/maggie-seo-geo/SKILL.md +29 -1
  12. package/bundled-skills/maggie-service-booking/SKILL.md +19 -1
  13. package/bundled-tools/clis/maggie.py +1 -1
  14. package/bundled-tools/clis/maggie_blog.py +12 -2
  15. package/bundled-tools/clis/maggie_feedback.py +17 -5
  16. package/bundled-tools/clis/maggie_head_tags.py +35 -0
  17. package/bundled-tools/clis/maggie_indexnow.py +6 -3
  18. package/bundled-tools/clis/maggie_ops.py +12 -0
  19. package/bundled-tools/clis/maggie_qa_workflow.py +2 -0
  20. package/bundled-tools/clis/maggie_release.py +88 -2
  21. package/bundled-tools/clis/maggie_service_booking.py +42 -3
  22. package/bundled-tools/clis/maggie_social_cards.py +45 -0
  23. package/bundled-tools/clis/site_audit.py +2 -2
  24. package/bundled-tools/runtime/booking_capabilities.py +137 -0
  25. package/bundled-tools/runtime/maggie_blog.py +85 -0
  26. package/bundled-tools/runtime/maggie_favicon.py +86 -0
  27. package/bundled-tools/runtime/maggie_head_tags.py +101 -0
  28. package/bundled-tools/runtime/maggie_indexnow.py +50 -0
  29. package/bundled-tools/runtime/maggie_social_cards.py +165 -0
  30. package/bundled-tools/runtime/service_variants.py +48 -7
  31. package/package.json +1 -1
  32. package/references/universal-booking-adapter.md +36 -1
@@ -224,6 +224,8 @@ def record(args: argparse.Namespace) -> int:
224
224
  raise ValueError("a failed scenario must have a recorded fix before retest")
225
225
  if phase == "test" and args.status == "fail" and not (args.expected and args.actual and args.error_fingerprint):
226
226
  raise ValueError("a failed test requires --expected, --actual and --error-fingerprint")
227
+ if phase in {"test", "retest"} and args.status == "pass" and not args.evidence:
228
+ raise ValueError("a passing test requires --evidence from the browser adapter")
227
229
  event = {
228
230
  "at": now(),
229
231
  "phase": phase,
@@ -16,7 +16,7 @@ from urllib.error import HTTPError, URLError
16
16
  from urllib.request import Request, urlopen
17
17
  from datetime import datetime, timezone
18
18
  from pathlib import Path
19
- from urllib.parse import urljoin
19
+ from urllib.parse import urljoin, urlsplit
20
20
 
21
21
 
22
22
  ROOT = Path(__file__).resolve().parent
@@ -130,14 +130,23 @@ def evidence_gate(project: Path, environment: str) -> dict:
130
130
  def changed_surface_gate(project: Path) -> dict:
131
131
  """Require visual/runtime evidence when a visitor-facing surface changed."""
132
132
  try:
133
- changed = subprocess.run(
133
+ changed_output = subprocess.run(
134
134
  ["git", "-C", str(project), "diff", "--name-only"],
135
135
  capture_output=True, text=True, check=True,
136
136
  ).stdout.splitlines()
137
+ staged_output = subprocess.run(
138
+ ["git", "-C", str(project), "diff", "--cached", "--name-only"],
139
+ capture_output=True, text=True, check=True,
140
+ ).stdout.splitlines()
141
+ untracked_output = subprocess.run(
142
+ ["git", "-C", str(project), "ls-files", "--others", "--exclude-standard", "-z"],
143
+ capture_output=True, text=True, check=True,
144
+ ).stdout.split("\0")
137
145
  except (OSError, subprocess.CalledProcessError):
138
146
  return {"name": "changed-surface-evidence", "passed": True, "exitCode": 0,
139
147
  "result": {"passed": True, "changed": False, "reason": "project is not a git worktree"}, "stderr": ""}
140
148
  visitor_suffixes = {".astro", ".css", ".scss", ".html", ".jsx", ".tsx", ".js", ".ts", ".svg", ".png", ".jpg", ".jpeg", ".webp"}
149
+ changed = set(changed_output) | set(staged_output) | {path for path in untracked_output if path}
141
150
  surfaces = sorted(path for path in changed if Path(path).suffix.lower() in visitor_suffixes)
142
151
  if not surfaces:
143
152
  return {"name": "changed-surface-evidence", "passed": True, "exitCode": 0,
@@ -181,6 +190,82 @@ def changed_surface_gate(project: Path) -> dict:
181
190
  "result": {"passed": not errors, "changed": True, "surfaces": surfaces, "evidence": evidence, "errors": errors}, "stderr": ""}
182
191
 
183
192
 
193
+ def _origin(value: object) -> str:
194
+ try:
195
+ parsed = urlsplit(str(value or ""))
196
+ if parsed.scheme and parsed.netloc:
197
+ return f"{parsed.scheme.lower()}://{parsed.netloc.lower()}"
198
+ except ValueError:
199
+ pass
200
+ return ""
201
+
202
+
203
+ def _qa_run_matches(run: dict, manifest_label: str | None, environment: str, base_url: str | None) -> bool:
204
+ if run.get("schemaVersion") != "maggie.qa-run.v1":
205
+ return False
206
+ if run.get("environment") != environment:
207
+ return False
208
+ if manifest_label and run.get("scenarioManifest") != manifest_label:
209
+ return False
210
+ if base_url and _origin(run.get("baseUrl")) != _origin(base_url):
211
+ return False
212
+ return isinstance(run.get("scenarios"), dict) and bool(run.get("scenarios"))
213
+
214
+
215
+ def qa_gate(project: Path, environment: str, base_url: str | None = None) -> dict:
216
+ """Consume the latest matching project QA run without running a browser."""
217
+ manifest = project / ".maggie" / "scenario-manifest.json"
218
+ run_dir = project / ".maggie" / "qa-runs"
219
+ run_paths = sorted(run_dir.glob("*.json")) if run_dir.is_dir() else []
220
+ configured = manifest.is_file() or bool(run_paths)
221
+ if not configured:
222
+ return {"name": "scenario-qa", "passed": True, "exitCode": 0,
223
+ "result": {"passed": True, "state": "not-configured", "configured": False,
224
+ "reason": "no scenario manifest or QA run directory"}, "stderr": ""}
225
+
226
+ try:
227
+ manifest_label = str(manifest.relative_to(project)) if manifest.is_file() else None
228
+ except ValueError:
229
+ manifest_label = manifest.name if manifest.is_file() else None
230
+ candidates: list[tuple[Path, dict]] = []
231
+ invalid_count = 0
232
+ for path in run_paths:
233
+ try:
234
+ value = json.loads(path.read_text(encoding="utf-8"))
235
+ if isinstance(value, dict) and _qa_run_matches(value, manifest_label, environment, base_url):
236
+ candidates.append((path, value))
237
+ except (OSError, json.JSONDecodeError):
238
+ invalid_count += 1
239
+ if not candidates:
240
+ return {"name": "scenario-qa", "passed": False, "exitCode": 1,
241
+ "result": {"passed": False, "state": "inconclusive", "configured": True,
242
+ "errors": ["no matching scenario QA run", f"invalidRuns={invalid_count}"]}, "stderr": ""}
243
+
244
+ path, run = max(candidates, key=lambda item: str(item[1].get("updatedAt") or item[1].get("createdAt") or ""))
245
+ errors: list[str] = []
246
+ summary = run.get("summary") if isinstance(run.get("summary"), dict) else {}
247
+ if summary.get("gate") != "pass":
248
+ errors.append("latest matching QA run is not passed")
249
+ for scenario in run.get("scenarios", {}).values():
250
+ if not isinstance(scenario, dict) or scenario.get("status") != "pass":
251
+ errors.append("latest matching QA run contains a non-passing scenario")
252
+ continue
253
+ history = scenario.get("history") if isinstance(scenario.get("history"), list) else []
254
+ last = history[-1] if history else {}
255
+ if last.get("phase") not in {"test", "retest"} or last.get("status") != "pass":
256
+ errors.append("latest matching QA run has a scenario without a passing final test")
257
+ if not isinstance(last.get("evidence"), list) or not last.get("evidence"):
258
+ errors.append("latest matching QA run has a scenario without browser evidence")
259
+ passed = not errors
260
+ try:
261
+ report_path = str(path.relative_to(project))
262
+ except ValueError:
263
+ report_path = path.name
264
+ return {"name": "scenario-qa", "passed": passed, "exitCode": 0 if passed else 1,
265
+ "result": {"passed": passed, "state": "passed" if passed else "failed", "configured": True,
266
+ "run": report_path, "updatedAt": run.get("updatedAt"), "errors": errors}, "stderr": ""}
267
+
268
+
184
269
  def editorial_gate(project: Path) -> dict:
185
270
  """Require explicit editorial approval for every launch category.
186
271
 
@@ -279,6 +364,7 @@ def main() -> int:
279
364
 
280
365
  gates = []
281
366
  gates.append(changed_surface_gate(project))
367
+ gates.append(qa_gate(project, args.environment, args.base_url))
282
368
  gates.append(build_gate(project))
283
369
  for name, command in build_gates(project, args.environment, args.target, not args.skip_compatibility):
284
370
  gates.append(run_gate(name, command, project))
@@ -12,6 +12,7 @@ from urllib.request import Request, urlopen
12
12
 
13
13
  sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
14
14
  from route_imports import imported_components # noqa: E402
15
+ from booking_capabilities import audit_matrix # noqa: E402
15
16
 
16
17
  NOW = lambda: datetime.now(timezone.utc).isoformat()
17
18
  MONEY = re.compile(r"(?:£|GBP\s*)\s*([0-9]+(?:[.,][0-9]{1,2})?)", re.I)
@@ -76,7 +77,8 @@ def parse(source, provider):
76
77
  currency=(offer.get("priceCurrency") or ("GBP" if price and ("£" in text or provider=="fresha") else None))
77
78
  if price is None and dm is None: continue
78
79
  amount=int(round(float(str(price).replace(",",""))*100)) if price is not None else None
79
- variants.append({"id":f"{slug(name)}:{dm.group(1) if dm else 'default'}","title":offer.get("name") or (f"{dm.group(1)} minutes" if dm else "Standard"),"durationMinutes":int(dm.group(1)) if dm else None,"price":{"amountMinor":amount,"currency":currency,"display":f"£{amount/100:.2f}" if amount is not None and currency=="GBP" else None}})
80
+ provider_variant_id = offer.get("sku") or offer.get("@id") or offer.get("id")
81
+ variants.append({"id":str(provider_variant_id or f"{slug(name)}:{dm.group(1) if dm else 'default'}"),"idSource":"provider" if provider_variant_id else "derived","variantSource":"native" if provider_variant_id else "derived-offers","title":offer.get("name") or (f"{dm.group(1)} minutes" if dm else "Standard"),"durationMinutes":int(dm.group(1)) if dm else None,"price":{"amountMinor":amount,"currency":currency,"display":f"£{amount/100:.2f}" if amount is not None and currency=="GBP" else None}})
80
82
  url=item.get("url") or item.get("sameAs")
81
83
  if not variants and not url: continue
82
84
  pid=str(item.get("providerServiceId") or item.get("productID") or item.get("sku") or slug(name))
@@ -113,7 +115,8 @@ def parse_fresha_embedded(raw, source):
113
115
  parsed_amount, parsed_currency=money_value(display or "")
114
116
  caption=variant.get("caption") or ""
115
117
  duration=duration_minutes(caption) or round(float(variant.get("maxInSeconds") or variant.get("minInSeconds") or item.get("maxInSeconds") or item.get("minInSeconds") or 0)/60) or None
116
- variants.append({"id":str(variant.get("id") or f'{item["serviceId"]}:default'),"title":variant.get("name") or caption or "Standard","durationMinutes":duration,"price":{"amountMinor":parsed_amount if parsed_amount is not None else amount,"currency":parsed_currency or currency,"display":display}})
118
+ provider_variant_id = variant.get("id")
119
+ variants.append({"id":str(provider_variant_id or f'{item["serviceId"]}:default'),"idSource":"provider" if provider_variant_id else "derived","variantSource":"native" if provider_variant_id else "single-fallback","title":variant.get("name") or caption or "Standard","durationMinutes":duration,"price":{"amountMinor":parsed_amount if parsed_amount is not None else amount,"currency":parsed_currency or currency,"display":display}})
117
120
  if not variants: continue
118
121
  booking=item.get("bookingUrl") or f'{source.split("?")[0]}/booking?offerItemId={quote(str(item.get("id") or ""))}'
119
122
  record=make_record("fresha",source,str(item["serviceId"]),str(item["name"]),str(item.get("description") or ""),variants,booking,[])
@@ -157,7 +160,7 @@ def parse_fresha_embedded(raw, source):
157
160
  for caption, display, vid, vname in variant_pattern.findall(match.group("variants")):
158
161
  duration=duration_minutes(caption)
159
162
  amount, currency=money_value(display)
160
- variants.append({"id":vid,"title":vname or caption,"durationMinutes":duration,"price":{"amountMinor":amount,"currency":currency,"display":display}})
163
+ variants.append({"id":vid,"idSource":"provider","variantSource":"native","title":vname or caption,"durationMinutes":duration,"price":{"amountMinor":amount,"currency":currency,"display":display}})
161
164
  if not variants: continue
162
165
  name=variants[0]["title"]
163
166
  records.append(make_record("fresha",source,match.group("pid"),name,description,variants,None,[]))
@@ -436,6 +439,40 @@ def cmd_category_hash(args):
436
439
  print(json.dumps(report, indent=2, ensure_ascii=False))
437
440
  return 0 if not errors else 1
438
441
 
442
+
443
+ def cmd_capability_audit(args):
444
+ """Validate provider declarations and emit a fixture-backed capability matrix."""
445
+ project = root(args)
446
+ def read(path_value):
447
+ path_value = Path(path_value)
448
+ path_value = path_value if path_value.is_absolute() else project / path_value
449
+ return path_value.resolve(), json.loads(path_value.resolve().read_text(encoding="utf-8"))
450
+ try:
451
+ catalogue_path, catalogue = read(args.catalogue)
452
+ declaration_path, declarations = read(args.capabilities_file)
453
+ fixture = None
454
+ fixture_path = None
455
+ fixture_digest = None
456
+ if args.fixture:
457
+ fixture_path, fixture = read(args.fixture)
458
+ fixture_digest = hashlib.sha256(fixture_path.read_bytes()).hexdigest()
459
+ result = audit_matrix(declarations, catalogue, provider=args.provider, fixture=fixture,
460
+ fixture_name=fixture_path.name if fixture_path else None,
461
+ fixture_sha256=fixture_digest)
462
+ except (OSError, ValueError, json.JSONDecodeError) as error:
463
+ print(json.dumps({"schemaVersion": "maggie-provider-variant-capabilities.v1", "passed": False, "errors": [str(error)]}, indent=2))
464
+ return 1
465
+ output = Path(args.output) if args.output else project / "docs" / "provider-variant-capabilities.json"
466
+ if not output.is_absolute():
467
+ output = project / output
468
+ output = output.resolve()
469
+ output.parent.mkdir(parents=True, exist_ok=True)
470
+ result["catalogue"] = catalogue_path.name
471
+ result["declarations"] = declaration_path.name
472
+ output.write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
473
+ print(json.dumps({"status": "passed" if result["passed"] else "failed", "report": str(output), "providers": len(result["providers"]), "errors": result["errors"]}, indent=2, ensure_ascii=False))
474
+ return 0 if result["passed"] else 1
475
+
439
476
  def category_audit_report(project, pages_dir, copy_data, rendered_dir=None, environment="staging"):
440
477
  errors = []
441
478
  copy_path = (project / copy_data).resolve()
@@ -976,6 +1013,7 @@ def main():
976
1013
  q=sub.add_parser("category-editorial-review"); q.add_argument("--project",default="."); q.add_argument("--copy-data",default="docs/category-page-copy.json"); q.add_argument("--category",action="append",help="category to review; defaults to all seven launch categories"); q.add_argument("--reviewer"); q.add_argument("--evidence",action="append",default=[],help="review evidence path or URL; repeatable"); q.add_argument("--apply",action="store_true",help="write explicit editorial approval metadata"); q.add_argument("--confirm",action="store_true",help="confirm that the selected records were actually reviewed by a human")
977
1014
  q=sub.add_parser("category-hash"); q.add_argument("--project",default="."); q.add_argument("--copy-data",required=True); q.add_argument("--apply",action="store_true",help="backfill deterministic provenance hashes in this generated artifact")
978
1015
  q=sub.add_parser("fact-audit"); q.add_argument("--project",default="."); q.add_argument("--backfill-source",action="store_true",help="copy the catalogue sourceUrl into records that have no sourceUrl"); q.add_argument("--overrides",help="JSON of human-approved, evidenced descriptions for provider gaps"); q.add_argument("--apply-overrides",action="store_true",help="apply only approved fact overrides to the draft catalogue")
1016
+ q=sub.add_parser("capability-audit", help="validate provider variant declarations and fixture evidence"); q.add_argument("--project",default="."); q.add_argument("--provider"); q.add_argument("--catalogue",default=".maggie/booking/services.json"); q.add_argument("--capabilities-file",default=".maggie/booking/provider-capabilities.json"); q.add_argument("--fixture",help="sanitized provider fixture JSON"); q.add_argument("--output")
979
1017
  q=sub.add_parser("category-context"); q.add_argument("--project",default="."); q.add_argument("--output")
980
1018
  q=sub.add_parser("validate-copy"); q.add_argument("--project",default="."); q.add_argument("--service-id",required=True); q.add_argument("--copy-data",required=True,help="AI-authored service copy JSON")
981
1019
  q=sub.add_parser("sitemap-audit"); q.add_argument("--project",default="."); q.add_argument("--index",required=True,help="rendered sitemap index XML"); q.add_argument("--sitemap-dir",required=True,help="directory containing rendered child sitemap XML files"); q.add_argument("--base-url",help="reserved for URL evidence and report context"); q.add_argument("--environment",choices=("development","staging","production"),default="staging")
@@ -997,6 +1035,7 @@ def main():
997
1035
  if a.command=="category-editorial-review": return cmd_category_editorial_review(a)
998
1036
  if a.command=="category-hash": return cmd_category_hash(a)
999
1037
  if a.command=="fact-audit": return cmd_fact_audit(a)
1038
+ if a.command=="capability-audit": return cmd_capability_audit(a)
1000
1039
  if a.command=="category-context": return cmd_category_context(a)
1001
1040
  if a.command=="validate-copy": return cmd_validate_copy(a)
1002
1041
  if a.command=="sitemap-audit": return cmd_sitemap_audit(a)
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env python3
2
+ """Audit per-page Open Graph social-card assets."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
12
+ from maggie_social_cards import audit_cards, pages_from_urls, read_pages # noqa: E402
13
+
14
+
15
+ def main() -> int:
16
+ parser = argparse.ArgumentParser(prog="maggie seo social-cards")
17
+ sub = parser.add_subparsers(dest="command", required=True)
18
+ audit = sub.add_parser("audit", help="resolve each page og:image and inspect its image contract")
19
+ sources = audit.add_mutually_exclusive_group(required=True)
20
+ sources.add_argument("--urls-file", type=Path, help="JSON array or {\"urls\": []} of HTML URLs")
21
+ sources.add_argument("--pages-file", type=Path, help="JSON page manifest with url and ogImage fields")
22
+ audit.add_argument("--timeout", type=int, default=15)
23
+ audit.add_argument("--output")
24
+ args = parser.parse_args()
25
+ try:
26
+ pages = read_pages(args.pages_file) if args.pages_file else None
27
+ if args.urls_file:
28
+ value = json.loads(args.urls_file.read_text(encoding="utf-8"))
29
+ urls = value.get("urls") if isinstance(value, dict) else value
30
+ if not isinstance(urls, list) or any(not isinstance(url, str) or not url.strip() for url in urls):
31
+ raise ValueError("urls file must be a JSON array or {\"urls\": []} of nonempty strings")
32
+ pages = pages_from_urls(urls, args.timeout)
33
+ result = audit_cards(pages or [], timeout=args.timeout)
34
+ if args.output:
35
+ output = Path(args.output).resolve(); output.parent.mkdir(parents=True, exist_ok=True)
36
+ output.write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
37
+ print(json.dumps(result, indent=2, ensure_ascii=False))
38
+ return 0 if result["passed"] else 1
39
+ except (OSError, ValueError, json.JSONDecodeError) as error:
40
+ print(f"maggie-social-cards: {error}", file=sys.stderr)
41
+ return 1
42
+
43
+
44
+ if __name__ == "__main__":
45
+ raise SystemExit(main())
@@ -144,7 +144,7 @@ def audit_page(url: str, html: str, status: int, content_type: str, expected_lan
144
144
  "canonical": bool(canonical) and urlparse(canonical).fragment == "",
145
145
  "description": bool(page.meta.get("description")),
146
146
  "locale": valid_locale(page.lang) and (not expected_languages or parse_locale(page.lang)[0] in expected_languages or page.lang in expected_languages),
147
- "open_graph": all(page.meta.get(key) for key in ("og:title", "og:description", "og:url")),
147
+ "open_graph": all(page.meta.get(key) for key in ("og:title", "og:description", "og:url", "og:image")),
148
148
  "twitter_card": page.meta.get("twitter:card") in {"summary", "summary_large_image"},
149
149
  "jsonld": page.jsonld > 0 and all(item is not None for item in page.jsonld_values),
150
150
  "entity_jsonld": any(isinstance(item, dict) and item.get("@type") and (item.get("url") or item.get("@id")) for item in page.jsonld_values),
@@ -312,7 +312,7 @@ def main() -> int:
312
312
  checks["canonical"] = {"ok": bool(canonical) and urlparse(canonical).fragment == "", "value": canonical}
313
313
  checks["description"] = {"ok": bool(page.meta.get("description")), "value": page.meta.get("description", "")}
314
314
  checks["locale"] = {"ok": valid_locale(page.lang) and (not expected_languages or parse_locale(page.lang)[0] in expected_languages or page.lang in expected_languages), "value": page.lang, "language": parse_locale(page.lang)[0], "markets": sorted(markets)}
315
- checks["open_graph"] = {"ok": all(page.meta.get(key) for key in ("og:title", "og:description", "og:url")), "fields": {key: bool(page.meta.get(key)) for key in ("og:title", "og:description", "og:url")}}
315
+ checks["open_graph"] = {"ok": all(page.meta.get(key) for key in ("og:title", "og:description", "og:url", "og:image")), "fields": {key: bool(page.meta.get(key)) for key in ("og:title", "og:description", "og:url", "og:image")}}
316
316
  checks["twitter_card"] = {"ok": page.meta.get("twitter:card") in {"summary", "summary_large_image"}, "value": page.meta.get("twitter:card", "")}
317
317
  checks["jsonld"] = {"ok": page.jsonld > 0 and all(item is not None for item in page.jsonld_values), "count": page.jsonld}
318
318
  checks["entity_jsonld"] = {"ok": any(isinstance(item, dict) and item.get("@type") and (item.get("url") or item.get("@id")) for item in page.jsonld_values), "count": page.jsonld}
@@ -0,0 +1,137 @@
1
+ """Provider-neutral service variant capability matrix validation."""
2
+ from __future__ import annotations
3
+
4
+ from collections import Counter
5
+ from typing import Any
6
+
7
+ SCHEMA_VERSION = "maggie-provider-variant-capabilities.v1"
8
+ VARIANT_MODES = {"native", "derived-offers", "single-fallback", "blocked"}
9
+ PRICE_SEMANTICS = {"minor-unit", "minor-unit-and-display", "display-only", "unavailable", "unknown"}
10
+ STABLE_ID_CONFIDENCE = {"high", "medium", "low", "unknown", "blocked"}
11
+
12
+
13
+ def _services(value: object) -> list[dict[str, Any]]:
14
+ if isinstance(value, dict) and isinstance(value.get("services"), list):
15
+ return [item for item in value["services"] if isinstance(item, dict)]
16
+ if isinstance(value, list):
17
+ return [item for item in value if isinstance(item, dict)]
18
+ return []
19
+
20
+
21
+ def _fixture_evidence(fixture: object | None, name: str | None, digest: str | None) -> dict[str, Any]:
22
+ services = _services(fixture)
23
+ variants = [variant for service in services for variant in _services({"services": service.get("variants", [])})]
24
+ return {
25
+ "provided": fixture is not None,
26
+ "name": name or None,
27
+ "sha256": digest or None,
28
+ "serviceCount": len(services),
29
+ "variantCounts": [len(service.get("variants") or []) for service in services],
30
+ "allVariantIdsPresent": bool(variants) and all(str(item.get("id") or "").strip() for item in variants),
31
+ "allVariantSourcesPresent": bool(variants) and all(str(item.get("variantSource") or "").strip() for item in variants),
32
+ }
33
+
34
+
35
+ def audit_provider(declaration: object, catalogue: object, *, fixture: object | None = None,
36
+ fixture_name: str | None = None, fixture_sha256: str | None = None) -> dict[str, Any]:
37
+ """Validate one provider declaration against catalogue and fixture evidence."""
38
+ item = declaration if isinstance(declaration, dict) else {}
39
+ provider = str(item.get("provider") or "").strip()
40
+ mode = str(item.get("variantMode") or "").strip()
41
+ price_semantics = str(item.get("priceSemantics") or "").strip()
42
+ stable_id_confidence = str(item.get("stableIdConfidence") or "").strip()
43
+ errors: list[str] = []
44
+ if not provider:
45
+ errors.append("provider is required")
46
+ if mode not in VARIANT_MODES:
47
+ errors.append("variantMode must be native, derived-offers, single-fallback, or blocked")
48
+ if price_semantics not in PRICE_SEMANTICS:
49
+ errors.append("priceSemantics is unsupported")
50
+ if stable_id_confidence not in STABLE_ID_CONFIDENCE:
51
+ errors.append("stableIdConfidence is unsupported")
52
+
53
+ services = _services(catalogue)
54
+ active_services = [service for service in services if service.get("status", "active") != "archived"]
55
+ variants = [variant for service in active_services for variant in (service.get("variants") or []) if isinstance(variant, dict)]
56
+ observed_modes = Counter(str(variant.get("variantSource") or "unknown") for variant in variants)
57
+ all_ids = bool(variants) and all(str(variant.get("id") or "").strip() for variant in variants)
58
+ native_id_sources = all(str(variant.get("idSource") or "") in {"provider", "native"} for variant in variants) if variants else False
59
+ structured_prices = bool(variants) and all(
60
+ isinstance(variant.get("price"), dict)
61
+ and isinstance(variant["price"].get("amountMinor"), int)
62
+ and str(variant["price"].get("currency") or "").isalpha()
63
+ for variant in variants
64
+ )
65
+ if not services:
66
+ errors.append("catalogue has no services")
67
+ if mode != "blocked" and not variants:
68
+ errors.append("declared non-blocked provider has no active variants")
69
+ if mode == "native" and variants and not native_id_sources:
70
+ errors.append("native variants require provider/native idSource evidence")
71
+ if mode == "single-fallback" and any(len(service.get("variants") or []) > 1 for service in active_services):
72
+ errors.append("single-fallback provider has a service with multiple variants")
73
+ expected_source = {"native": "native", "derived-offers": "derived-offers", "single-fallback": "single-fallback"}.get(mode)
74
+ if expected_source and variants and any(source != expected_source for source in observed_modes):
75
+ errors.append(f"catalogue variantSource does not match declared {mode} mode")
76
+ if stable_id_confidence == "high" and not native_id_sources:
77
+ errors.append("high stable-ID confidence requires provider/native idSource evidence")
78
+ if fixture is None:
79
+ errors.append("fixture evidence is required")
80
+
81
+ evidence = _fixture_evidence(fixture, fixture_name, fixture_sha256)
82
+ if fixture is not None and evidence["serviceCount"] == 0:
83
+ errors.append("fixture evidence has no services")
84
+ if mode != "blocked" and fixture is not None and not evidence["allVariantIdsPresent"]:
85
+ errors.append("fixture evidence is missing variant IDs")
86
+ if mode != "blocked" and fixture is not None and not evidence["allVariantSourcesPresent"]:
87
+ errors.append("fixture evidence is missing variant sources")
88
+ if price_semantics in {"minor-unit", "minor-unit-and-display"} and variants and not structured_prices:
89
+ errors.append("declared minor-unit price semantics require amountMinor and currency")
90
+ if price_semantics == "display-only" and variants and any(
91
+ isinstance(variant.get("price"), dict) and variant["price"].get("amountMinor") is not None
92
+ for variant in variants
93
+ ):
94
+ errors.append("display-only price semantics cannot contain amountMinor")
95
+ if price_semantics == "unavailable" and variants and any(variant.get("price") for variant in variants):
96
+ errors.append("unavailable price semantics cannot contain price data")
97
+ if provider and isinstance(catalogue, dict) and catalogue.get("provider") and catalogue.get("provider") != provider:
98
+ errors.append("catalogue provider does not match declaration")
99
+
100
+ passed = not errors
101
+ return {
102
+ "provider": provider or None,
103
+ "variantMode": mode or None,
104
+ "priceSemantics": price_semantics or None,
105
+ "stableIdConfidence": stable_id_confidence or None,
106
+ "observed": {
107
+ "serviceCount": len(services),
108
+ "activeServiceCount": len(active_services),
109
+ "variantCount": len(variants),
110
+ "variantSources": dict(sorted(observed_modes.items())),
111
+ "allVariantIdsPresent": all_ids,
112
+ "allStructuredPrices": structured_prices,
113
+ },
114
+ "fixtureEvidence": evidence,
115
+ "passed": passed,
116
+ "errors": errors,
117
+ }
118
+
119
+
120
+ def audit_matrix(declarations: object, catalogue: object, *, provider: str | None = None,
121
+ fixture: object | None = None, fixture_name: str | None = None,
122
+ fixture_sha256: str | None = None) -> dict[str, Any]:
123
+ """Validate a machine-readable provider declaration file."""
124
+ if isinstance(declarations, dict) and declarations.get("providers"):
125
+ entries = declarations["providers"]
126
+ elif isinstance(declarations, list):
127
+ entries = declarations
128
+ else:
129
+ entries = []
130
+ if not isinstance(entries, list):
131
+ entries = []
132
+ selected = [entry for entry in entries if isinstance(entry, dict) and (not provider or entry.get("provider") == provider)]
133
+ if not selected:
134
+ return {"schemaVersion": SCHEMA_VERSION, "passed": False, "providers": [], "errors": ["no provider declaration selected"]}
135
+ results = [audit_provider(entry, catalogue, fixture=fixture, fixture_name=fixture_name, fixture_sha256=fixture_sha256) for entry in selected]
136
+ return {"schemaVersion": SCHEMA_VERSION, "passed": all(result["passed"] for result in results), "providers": results,
137
+ "errors": [f"{result.get('provider') or 'unknown'}: {error}" for result in results for error in result["errors"]]}
@@ -6,6 +6,7 @@ import hashlib
6
6
  import json
7
7
  import re
8
8
  import shutil
9
+ import subprocess
9
10
  from datetime import datetime, timezone
10
11
  from pathlib import Path
11
12
  from localization_runner import checkpoint, generate
@@ -44,6 +45,90 @@ def checksum(value: object) -> str:
44
45
  return "sha256:" + hashlib.sha256(json.dumps(value, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
45
46
 
46
47
 
48
+ def normalise_review_settings(value: object) -> dict:
49
+ """Map a host settings record to the small review-gate contract.
50
+
51
+ Hosts may store settings in PostgreSQL or another provider. The adapter
52
+ boundary deliberately accepts only these policy fields; credentials and
53
+ unrelated provider data never enter the report.
54
+ """
55
+ if not isinstance(value, dict):
56
+ raise ValueError("blog settings adapter must return a JSON object")
57
+ source = value.get("settings") if isinstance(value.get("settings"), dict) else value
58
+
59
+ def first(*keys: str, default: object = None) -> object:
60
+ for key in keys:
61
+ if key in source:
62
+ return source[key]
63
+ return default
64
+
65
+ def as_bool(value: object, default: bool) -> bool:
66
+ if value is None:
67
+ return default
68
+ if isinstance(value, bool):
69
+ return value
70
+ if isinstance(value, str):
71
+ lowered = value.strip().lower()
72
+ if lowered in {"true", "1", "yes", "on"}:
73
+ return True
74
+ if lowered in {"false", "0", "no", "off", ""}:
75
+ return False
76
+ return bool(value)
77
+
78
+ default_status = str(first("defaultPostStatus", "default_post_status", default="draft") or "draft")
79
+ return {
80
+ "schemaVersion": "maggie-blog-settings.v1",
81
+ "defaultPostStatus": default_status,
82
+ "requireReview": as_bool(first("requireReview", "require_review", default=True), True),
83
+ "autoPublishEnabled": as_bool(first("autoPublishEnabled", "auto_publish", "autoPublish", default=False), False),
84
+ }
85
+
86
+
87
+ def review_settings_from_adapter(project: Path, settings_file: Path | None = None,
88
+ adapter_command: list[str] | None = None,
89
+ timeout: int = 120) -> tuple[dict, str]:
90
+ """Read sanitized settings from a file or trusted argv adapter.
91
+
92
+ ``adapter_command`` is executed without a shell and receives a small
93
+ request on stdin. Its stdout must be the normalized settings object. Error
94
+ output is intentionally suppressed so provider details cannot leak into a
95
+ Maggie report.
96
+ """
97
+ if settings_file and adapter_command:
98
+ raise ValueError("use either --settings-file or --adapter-command")
99
+ if timeout < 1:
100
+ raise ValueError("adapter timeout must be positive")
101
+ if settings_file:
102
+ try:
103
+ value = json.loads(settings_file.read_text(encoding="utf-8"))
104
+ except json.JSONDecodeError:
105
+ raise ValueError("settings file must contain a JSON object") from None
106
+ return normalise_review_settings(value), "settings-file"
107
+ if adapter_command is not None:
108
+ if not adapter_command or any(not isinstance(part, str) or not part for part in adapter_command):
109
+ raise ValueError("adapter-command must be a nonempty JSON argv array")
110
+ try:
111
+ result = subprocess.run(
112
+ adapter_command,
113
+ input=json.dumps({"schemaVersion": "maggie-blog-settings-request.v1"}),
114
+ text=True,
115
+ capture_output=True,
116
+ timeout=timeout,
117
+ check=False,
118
+ cwd=project.resolve(),
119
+ )
120
+ except (OSError, subprocess.TimeoutExpired):
121
+ raise OSError("blog settings adapter could not execute or timed out") from None
122
+ if result.returncode:
123
+ raise OSError("blog settings adapter failed; provider output suppressed")
124
+ try:
125
+ value = json.loads(result.stdout)
126
+ except json.JSONDecodeError:
127
+ raise ValueError("blog settings adapter must return a JSON object") from None
128
+ return normalise_review_settings(value), "adapter-command"
129
+ raise ValueError("blog is not initialized; use --settings-file or --adapter-command for a provider-backed blog")
130
+
131
+
47
132
  class BlogStore:
48
133
  def __init__(self, project: Path) -> None:
49
134
  self.root = project / ".maggie" / "blog"
@@ -0,0 +1,86 @@
1
+ """Behavioural favicon verification for a deployed origin."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from html.parser import HTMLParser
6
+ from typing import Any
7
+ from urllib.error import HTTPError, URLError
8
+ from urllib.parse import urljoin, urlparse
9
+ from urllib.request import Request, urlopen
10
+
11
+ from maggie_social_cards import SUPPORTED_FORMATS, _dimensions
12
+
13
+
14
+ SCHEMA = "maggie-favicon.v1"
15
+
16
+
17
+ class IconLinkParser(HTMLParser):
18
+ def __init__(self) -> None:
19
+ super().__init__()
20
+ self.href = ""
21
+ self.type = ""
22
+
23
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
24
+ if tag != "link":
25
+ return
26
+ data = dict(attrs)
27
+ rel = set(str(data.get("rel") or "").lower().split())
28
+ if rel & {"icon", "shortcut"} and data.get("href") and not self.href:
29
+ self.href = str(data["href"])
30
+ self.type = str(data.get("type") or "")
31
+
32
+
33
+ def _fetch(url: str, timeout: int) -> tuple[int | None, str, bytes, str | None]:
34
+ try:
35
+ request = Request(url, headers={"User-Agent": "Maggie-Favicon-Check/1"})
36
+ with urlopen(request, timeout=timeout) as response:
37
+ raw = response.read(8_000_001)
38
+ if len(raw) > 8_000_000:
39
+ return int(response.status), response.headers.get_content_type(), b"", "response exceeds size limit"
40
+ return int(response.status), response.headers.get_content_type(), raw, None
41
+ except HTTPError as error:
42
+ return int(error.code), "", b"", "HTTP error"
43
+ except (URLError, TimeoutError, OSError):
44
+ return None, "", b"", "request failed"
45
+
46
+
47
+ def _resource(url: str, timeout: int) -> dict[str, Any]:
48
+ status, header_type, raw, fetch_error = _fetch(url, timeout)
49
+ image_type, width, height = _dimensions(raw, header_type)
50
+ errors: list[str] = []
51
+ if fetch_error or status != 200:
52
+ errors.append(fetch_error or "icon request did not return HTTP 200")
53
+ if image_type not in SUPPORTED_FORMATS:
54
+ errors.append("unsupported favicon format")
55
+ if width is None or height is None:
56
+ errors.append("favicon dimensions could not be read")
57
+ elif width != height:
58
+ errors.append("favicon must be square")
59
+ return {"url": url, "statusCode": status, "format": image_type, "width": width, "height": height, "errors": errors, "passed": not errors}
60
+
61
+
62
+ def check_favicon(origin: str, declared_url: str | None = None, timeout: int = 15) -> dict[str, Any]:
63
+ parsed = urlparse(origin.rstrip("/"))
64
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc or parsed.fragment:
65
+ raise ValueError("origin must be an absolute HTTP(S) URL without a fragment")
66
+ if timeout < 1:
67
+ raise ValueError("timeout must be positive")
68
+ base = origin.rstrip("/") + "/"
69
+ favicon_url = urljoin(base, "favicon.ico")
70
+ detected: str | None = None
71
+ if not declared_url:
72
+ status, content_type, raw, _ = _fetch(base, timeout)
73
+ if status == 200 and content_type == "text/html":
74
+ parser = IconLinkParser(); parser.feed(raw.decode("utf-8", "replace"))
75
+ if parser.href:
76
+ detected = urljoin(base, parser.href)
77
+ declared = declared_url or detected
78
+ if declared:
79
+ declared = urljoin(base, declared)
80
+ declared_parsed = urlparse(declared)
81
+ if declared_parsed.scheme not in {"http", "https"} or declared_parsed.fragment:
82
+ raise ValueError("declared-url must resolve to an absolute HTTP(S) URL without a fragment")
83
+ ico = _resource(favicon_url, timeout)
84
+ declared_check = _resource(declared, timeout) if declared and declared != favicon_url else None
85
+ passed = ico["passed"] and (declared_check is None or declared_check["passed"])
86
+ return {"schemaVersion": SCHEMA, "origin": origin.rstrip("/"), "favicon": ico, "declaredUrl": declared, "declared": declared_check, "passed": passed, "mutation": False}