@topy-ai/maggie 0.7.17 → 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.
package/README.md CHANGED
@@ -88,6 +88,23 @@ hours. `maggie seo social-cards` checks per-page OG image dimensions and format;
88
88
  Dash inventory separates renderer kind from public page kind while reporting
89
89
  source coverage for code-rendered routes.
90
90
 
91
+ Service booking providers can be checked with a machine-readable,
92
+ fixture-backed capability matrix:
93
+
94
+ ```bash
95
+ maggie service capability-audit --project . \
96
+ --catalogue .maggie/booking/services.json \
97
+ --capabilities-file .maggie/booking/provider-capabilities.json \
98
+ --fixture .maggie/booking/fixtures/provider.json
99
+ ```
100
+
101
+ The declaration records variant mode (`native`, `derived-offers`,
102
+ `single-fallback`, or `blocked`), price semantics, and stable-ID confidence.
103
+ The audit also records observed counts and fixture evidence. Localized service
104
+ variants use market/locale-aware canonical routes and reciprocal hreflang.
105
+ Release preflight consumes the latest matching scenario QA run when one is
106
+ configured and blocks incomplete evidence.
107
+
91
108
  For Google integrations, validate a redacted provider matrix before reporting
92
109
  access. The command fails closed on unknown scopes, missing Ads prerequisites,
93
110
  duplicate provider resources, and unverified edit/publish claims:
@@ -145,7 +162,7 @@ maggie memory ... # confirmed preferences and lessons
145
162
  maggie feedback ... # redact, preview, submit, list
146
163
  maggie qa ... # scenario browser QA, fix/retest, release gate
147
164
  maggie localization ... # plan, validate, review, publish, stale
148
- maggie service ... # import, sync, generate, validate
165
+ maggie service ... # import, sync, capability audit, validate
149
166
  maggie seo performance ... # sampled PageSpeed/CWV report and baseline
150
167
  maggie seo images ... # inventory, variants, confirmation, validate
151
168
  maggie seo sitemap ... # typed/semantic plan, agent-files, apply, rollback
@@ -267,8 +284,8 @@ artifact schemas.
267
284
  Recommended upgrade sequence for the current release:
268
285
 
269
286
  ```bash
270
- npx @topy-ai/maggie@0.7.17 update --project . --force
271
- npx @topy-ai/maggie@0.7.17 cleanup --project .
287
+ npx @topy-ai/maggie@0.7.18 update --project . --force
288
+ npx @topy-ai/maggie@0.7.18 cleanup --project .
272
289
  ```
273
290
 
274
291
  Maintainers should pass npm credentials through the repository helper, never
@@ -278,7 +295,10 @@ as a command-line argument:
278
295
  node scripts/publish-npm.mjs --maggie-env-file ../.env
279
296
  ```
280
297
 
281
- The 0.7.17 workflow adds sanitized database-backed blog gate adapters, deployed
298
+ The 0.7.18 workflow adds staged/untracked changed-surface detection, scenario
299
+ QA release integration, market/locale-aware service variant routes and
300
+ reciprocal hreflang, provider capability matrices with fixture evidence, and
301
+ feedback fixed-proof/path-privacy gates. The 0.7.17 workflow adds sanitized database-backed blog gate adapters, deployed
282
302
  IndexNow key verification, social-card and cross-shell head-tag audits, required
283
303
  Open Graph image coverage, and behavioural favicon verification. The 0.7.16
284
304
  workflow tightens the review-gate exit code and adds retry-path regression
package/README.zh-TW.md CHANGED
@@ -8,7 +8,7 @@ Codex、Claude Code 與相容的 coding agents。
8
8
  ## 安裝
9
9
 
10
10
  ```bash
11
- npx @topy-ai/maggie@0.7.16 init --agent all
11
+ npx @topy-ai/maggie@0.7.18 init --agent all
12
12
  npx @topy-ai/maggie doctor --project .
13
13
  ```
14
14
 
@@ -25,7 +25,9 @@ maggie doctor --project . --require-bootstrap --strict
25
25
  deployment、memory、feedback 和 MaggieDash。內容先 draft/review,外部寫入、
26
26
  publish 與 production deployment 需要明確確認。
27
27
 
28
- 0.7.16 補強 review gate 的 CI exit code 與 IndexNow retry regression;
28
+ 0.7.18 加入 staged/untracked changed-surface release gate、QA run 與 release
29
+ 整合、market/locale-aware service variant route、provider capability matrix
30
+ fixture evidence,以及 feedback fixed-proof/path privacy gate。0.7.16 補強 review gate 的 CI exit code 與 IndexNow retry regression;
29
31
  0.7.15 也加入 API schema contract、blog review gate、sitemap freshness
30
32
  warning、change-driven IndexNow 與 page-kind/source-coverage inventory。
31
33
 
@@ -59,6 +61,12 @@ maggie migration identity --identity-file .maggie/db-identity.json \
59
61
  maggie qa start --project . --scenario-file .maggie/scenario-manifest.json \
60
62
  --environment local --base-url http://localhost:4321
61
63
  maggie qa summary --project . --run <run-id>
64
+
65
+ # Provider variant capability matrix
66
+ maggie service capability-audit --project . \
67
+ --catalogue .maggie/booking/services.json \
68
+ --capabilities-file .maggie/booking/provider-capabilities.json \
69
+ --fixture .maggie/booking/fixtures/provider.json
62
70
  ```
63
71
 
64
72
  完整中文說明、19 個 skills 清單和 roadmap:
package/bin/maggie.js CHANGED
@@ -98,6 +98,7 @@ Usage:
98
98
  maggie service import <provider-url> --project PATH
99
99
  maggie service sync <provider-url> --project PATH
100
100
  maggie service generate --project PATH
101
+ maggie service capability-audit --project PATH --catalogue FILE --capabilities-file FILE --fixture FILE
101
102
  maggie service validate --project PATH
102
103
  maggie service inspect --project PATH
103
104
  maggie service status --project PATH
@@ -25,8 +25,10 @@ when a title changes.
25
25
  "title": "Signature Scalp Ritual",
26
26
  "description": "Provider-supplied factual description.",
27
27
  "category": {"level1": "Head Spa", "level2": "Scalp Treatments"},
28
- "variants": [{
28
+ "variants": [{
29
29
  "id": "service-123:60",
30
+ "idSource": "provider",
31
+ "variantSource": "native",
30
32
  "title": "60 minutes",
31
33
  "durationMinutes": 60,
32
34
  "price": {"amountMinor": 9500, "currency": "GBP", "display": "£95.00"}
@@ -147,3 +149,36 @@ Provider adapters may add `raw` evidence in a private local snapshot, but
147
149
  public page generation consumes only the canonical fields above. A sync must
148
150
  preserve removed records as `archived` with `removedAt` and must output a
149
151
  change report.
152
+
153
+ ## Provider variant capability matrix
154
+
155
+ Before service pages are published, each provider declares its variant
156
+ behavior in a project-owned JSON file. The declaration is validated against a
157
+ sanitized fixture and emits `maggie-provider-variant-capabilities.v1`:
158
+
159
+ ```json
160
+ {
161
+ "schemaVersion": "maggie-provider-capabilities.v1",
162
+ "providers": [{
163
+ "provider": "fresha",
164
+ "variantMode": "native",
165
+ "priceSemantics": "minor-unit-and-display",
166
+ "stableIdConfidence": "high"
167
+ }]
168
+ }
169
+ ```
170
+
171
+ `variantMode` is one of `native`, `derived-offers`, `single-fallback`, or
172
+ `blocked`. `priceSemantics` records whether structured minor units and display
173
+ prices are available. `stableIdConfidence` must be justified by provider or
174
+ derived ID evidence. The audit records service/variant counts, observed source
175
+ types, fixture name and hash, and validation errors without copying fixture
176
+ rows or provider credentials. Run it with:
177
+
178
+ ```bash
179
+ maggie service capability-audit \
180
+ --project . \
181
+ --catalogue .maggie/booking/services.json \
182
+ --capabilities-file .maggie/booking/provider-capabilities.json \
183
+ --fixture .maggie/booking/fixtures/provider.json
184
+ ```
@@ -32,7 +32,8 @@ response bodies in it.
32
32
 
33
33
  When a release changes a visitor-facing HTML, CSS, component, or asset surface,
34
34
  the release preflight automatically requires both a passing icon inventory and
35
- rendered canary evidence. The canary must include screenshots, zero console or
35
+ rendered canary evidence. It considers unstaged, staged, and untracked
36
+ visitor-facing files. The canary must include screenshots, zero console or
36
37
  network errors, and zero placeholder matches. Query-driven routes belong in
37
38
  behavior/API checks, not static byte baselines.
38
39
 
@@ -161,6 +162,13 @@ python3 tools/clis/maggie_release.py /path/to/project \
161
162
  --base-url https://staging.example.com
162
163
  ```
163
164
 
165
+ If `.maggie/scenario-manifest.json` or `.maggie/qa-runs/` exists, the same
166
+ preflight consumes the latest matching `maggie qa` run for the requested
167
+ environment (and `--base-url`, when supplied). It blocks when a scenario is
168
+ not passed, has no browser-adapter evidence, or the matching run is missing.
169
+ Projects without scenario QA report `not-configured`; Maggie does not run the
170
+ browser itself.
171
+
164
172
  This aggregates deployment, migration, provider-health, schedule, analytics,
165
173
  service-fact, editorial approval, durable SEO/route/category evidence,
166
174
  MaggieDash schema compatibility, and live runtime security-header checks. It writes
@@ -34,6 +34,11 @@ The draft is written to `.maggie/feedback/`. It contains the Maggie version,
34
34
  skill, run ID, phase, error fingerprint, expected/actual result, reproduction
35
35
  steps, resolution, validation, and metadata-only screenshot references.
36
36
 
37
+ If a draft is marked fixed with `--fixed` (or the supplied run report says
38
+ `fixed`), both `--resolution` and `--validation` are required. Feedback text
39
+ also scrubs common POSIX/home/temp and Windows absolute paths by default while
40
+ preserving public URLs and route paths.
41
+
37
42
  ## Submit explicitly
38
43
 
39
44
  The CLI never submits automatically. After reviewing the draft:
@@ -69,6 +69,11 @@ maggie qa record --project . --run local-qa-001 \
69
69
  the test failed, a fix. After a fix, retest the original scenario and at least
70
70
  one adjacent scenario affected by the same surface.
71
71
 
72
+ A passing `test` or `retest` requires at least one `--evidence` reference from
73
+ the host browser adapter. The adapter owns console, network, authentication,
74
+ URL, viewport, and screenshot capture; this skill stores only a relative path,
75
+ URL reference, and hash when a local file exists.
76
+
72
77
  ## Gate and release evidence
73
78
 
74
79
  The run gate is `fail` when any scenario fails, `blocked` when there is no
@@ -88,6 +93,12 @@ Before calling a release Pass, also run the relevant build, accessibility,
88
93
  SEO, media, API, and deployment-canary checks. A green build, HTTP 200, source
89
94
  class, or one guest smoke is not browser QA evidence.
90
95
 
96
+ `maggie release` consumes the latest matching run from `.maggie/qa-runs/` when
97
+ the project has a scenario manifest or QA run directory. A matching run must
98
+ have a passed summary, passing scenarios, a passing final test/retest event,
99
+ and browser evidence for every scenario. No manifest or run reports an
100
+ explicit `not-configured` state.
101
+
91
102
  ## Privacy and feedback
92
103
 
93
104
  Do not put passwords, tokens, cookies, full private URLs, personal data,
@@ -21,7 +21,8 @@ supporting relations for all suggested candidates. Inspect each service's
21
21
  `pages` after selection and validate the rendered links. The current selection
22
22
  path writes `canonical` for selected pages; multiple selections can violate
23
23
  the one-canonical invariant. Resolve roles and run validation before publishing.
24
- Variant role assignment and layout/URL conventions remain tracked in issue #27.
24
+ Variant role assignment and layout/URL conventions are enforced by the shared
25
+ market/locale route and reciprocal hreflang contract.
25
26
 
26
27
  ## Automatic memory hook
27
28
 
@@ -107,6 +108,14 @@ python3 tools/clis/maggie_service_booking.py category-audit \
107
108
  --project . --rendered-dir /tmp/category-rendered
108
109
 
109
110
  python3 tools/clis/maggie_service_booking.py category-context --project .
111
+
112
+ # Declare provider behavior, then validate it against the imported catalogue
113
+ # and a sanitized fixture before publishing service pages.
114
+ python3 tools/clis/maggie_service_booking.py capability-audit \
115
+ --project . \
116
+ --catalogue .maggie/booking/services.json \
117
+ --capabilities-file .maggie/booking/provider-capabilities.json \
118
+ --fixture .maggie/booking/fixtures/provider.json
110
119
  ```
111
120
 
112
121
  `import` creates the first catalogue. `sync` compares the newly imported
@@ -265,6 +274,15 @@ amounts silently. Include `Service` and `Offer` JSON-LD only from validated
265
274
  catalogue fields. Do not emit `Review`, `AggregateRating`, or medical claims
266
275
  unless verified source data is present.
267
276
 
277
+ Localized and market-specific variants use
278
+ `/services/<market>/<locale>/<variant-slug>/` as their canonical route. A
279
+ translation never reuses the source route. Published variants expose
280
+ reciprocal hreflang links only to published counterparts. Before generation,
281
+ each provider must declare `variantMode` (`native`, `derived-offers`,
282
+ `single-fallback`, or `blocked`), `priceSemantics`, and `stableIdConfidence`
283
+ in a machine-readable capability file. The capability audit requires a
284
+ sanitized fixture and fails closed on missing or contradictory evidence.
285
+
268
286
  ## AI category/page copy contract
269
287
 
270
288
  Read [`references/ai-service-copy-contract.md`](../../references/ai-service-copy-contract.md)
@@ -20,6 +20,12 @@ from urllib.request import Request, urlopen
20
20
 
21
21
  DEFAULT_ENDPOINT = "https://feedback.noblox.app/api/feedback"
22
22
  SECRET_RE = re.compile(r"(?i)(bearer\s+|(?:api[_-]?key|token|secret|password|npm_config[^=]*|authorization)\s*[=:]\s*)[^\s,;]+")
23
+ LOCAL_PATH_RE = re.compile(
24
+ r"(?<![A-Za-z0-9])(?:~/(?:[^\s,;:)\]}]+/)*[^\s,;:)\]}]+|"
25
+ r"/(?:home|Users|private|tmp|var|mnt|opt|root|srv|workspace|workspaces)(?:/[^\s,;:)\]}]+)+|"
26
+ r"[A-Za-z]:[\\/]+(?:[^\\/\s,;:)\]}]+[\\/]+)*[^\\/\s,;:)\]}]+|"
27
+ r"\\\\[^\\/\s,;:)\]}]+(?:[\\/]+[^\\/\s,;:)\]}]+)+)"
28
+ )
23
29
  VERSION_RE = re.compile(r"\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?")
24
30
  ACKNOWLEDGEMENT_KEYS = ("status", "feedbackId", "requestId")
25
31
  REPO_ROOT = Path(__file__).resolve().parents[2]
@@ -30,7 +36,8 @@ def now() -> str:
30
36
 
31
37
 
32
38
  def safe_text(value: object) -> str:
33
- return SECRET_RE.sub(r"\1[REDACTED]", str(value or "")).strip()
39
+ value = SECRET_RE.sub(r"\1[REDACTED]", str(value or ""))
40
+ return LOCAL_PATH_RE.sub("[LOCAL_PATH_REDACTED]", value).strip()
34
41
 
35
42
 
36
43
  def project_fingerprint(project: Path) -> str:
@@ -90,6 +97,11 @@ def collect(args: argparse.Namespace) -> int:
90
97
  skill = args.skill or report.get("skill") or report.get("workflow") or "unknown"
91
98
  run_id = args.run_id or report.get("runId") or report.get("jobId") or ""
92
99
  actual = args.actual or report.get("actual") or report.get("error") or report.get("message") or ""
100
+ fixed = bool(args.fixed or report.get("fixed") is True or report.get("status") == "fixed")
101
+ resolution = safe_text(args.resolution or report.get("resolution"))
102
+ validation = safe_text(args.validation or report.get("validation"))
103
+ if fixed and (not resolution or not validation):
104
+ raise ValueError("fixed feedback requires non-empty --resolution and --validation")
93
105
  context = {"projectFingerprint": project_fingerprint(project)}
94
106
  if args.allow_project_context and args.context_note:
95
107
  context["note"] = safe_text(args.context_note)
@@ -103,15 +115,15 @@ def collect(args: argparse.Namespace) -> int:
103
115
  "skill": skill,
104
116
  "runId": run_id,
105
117
  "phase": args.phase or report.get("phase") or "",
106
- "status": report.get("status") or ("fixed" if args.fixed else "reported"),
118
+ "status": report.get("status") or ("fixed" if fixed else "reported"),
107
119
  "summary": safe_text(args.summary),
108
120
  "expected": safe_text(args.expected),
109
121
  "actual": safe_text(actual),
110
122
  "errorFingerprint": safe_text(args.error_fingerprint or report.get("errorFingerprint") or ""),
111
123
  "stepsToReproduce": [safe_text(step) for step in args.reproduce],
112
- "fixed": bool(args.fixed),
113
- "resolution": safe_text(args.resolution),
114
- "validation": safe_text(args.validation),
124
+ "fixed": fixed,
125
+ "resolution": resolution,
126
+ "validation": validation,
115
127
  "attachments": [attachment(value) for value in args.screenshot],
116
128
  "environment": {"os": platform.system().lower(), "python": platform.python_version()},
117
129
  "privacy": {"secretsRedacted": True, "projectContextAllowed": bool(args.allow_project_context)},
@@ -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,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"]]}
@@ -35,6 +35,16 @@ def variant_slug(kind: str, *, location: str = "", event: str = "", holiday: str
35
35
  return f"{kind}/{slug_part(value)}"
36
36
 
37
37
 
38
+ def locale_segment(value: str) -> str:
39
+ """Return the stable lowercase URL segment for a BCP 47-like locale."""
40
+ return slug_part(value.replace("_", "-"))
41
+
42
+
43
+ def variant_canonical_url(slug: str, locale: str, market: str) -> str:
44
+ """Build a collision-safe, market/language-aware service URL."""
45
+ return f"/services/{slug_part(market)}/{locale_segment(locale)}/{slug.strip('/')}/"
46
+
47
+
38
48
  def similarity(source: str, variant: str) -> float:
39
49
  left, right = set(re.findall(r"[a-z0-9]+", source.lower())), set(re.findall(r"[a-z0-9]+", variant.lower()))
40
50
  return 1.0 if not left and not right else len(left & right) / max(1, len(left | right))
@@ -62,6 +72,20 @@ class ServiceVariantStore:
62
72
  raise ValueError("service variant not found")
63
73
  return found
64
74
 
75
+ def _cluster(self, canonical_variant_id: str) -> list[dict]:
76
+ return [
77
+ item for item in self.data["variants"]
78
+ if item.get("id") == canonical_variant_id or item.get("canonicalVariantId") == canonical_variant_id
79
+ ]
80
+
81
+ def _refresh_hreflang(self, canonical_variant_id: str) -> None:
82
+ """Keep published locale links reciprocal and limited to published peers."""
83
+ cluster = self._cluster(canonical_variant_id)
84
+ published = [item for item in cluster if item.get("status") == "published"]
85
+ published_links = {item["locale"]: item["canonicalUrl"] for item in published}
86
+ for item in cluster:
87
+ item["hreflang"] = published_links if item in published else {item["locale"]: item["canonicalUrl"]}
88
+
65
89
  def create(self, *, service_id: str, variant_id: str, variant_type: str, locale: str, market: str,
66
90
  slug: str, title: str, facts: list[dict], source_revision: str, canonical_variant_id: str | None = None,
67
91
  cluster_links: list[str] | None = None, layout_family: str = "service-default", actor: str = "cli") -> dict:
@@ -73,15 +97,19 @@ class ServiceVariantStore:
73
97
  raise ValueError("variant requires meaningful keyed facts")
74
98
  if any(item["id"] == variant_id for item in self.data["variants"]):
75
99
  raise ValueError("variant ID already exists")
76
- if any(item["slug"] == slug and item["locale"] == locale for item in self.data["variants"]):
77
- raise ValueError("variant slug and locale collision")
78
100
  if canonical_variant_id and not any(item["id"] == canonical_variant_id for item in self.data["variants"]):
79
101
  raise ValueError("canonical variant does not exist")
102
+ canonical_url = variant_canonical_url(slug, locale, market)
103
+ if any(item.get("canonicalUrl") == canonical_url for item in self.data["variants"]):
104
+ raise ValueError("variant market, locale and slug route collision")
105
+ family = canonical_variant_id or variant_id
106
+ if any(item.get("canonicalVariantId") == family and item.get("locale") == locale for item in self.data["variants"]):
107
+ raise ValueError("variant locale collision in translation cluster")
80
108
  item = {"id": variant_id, "serviceId": service_id, "variantType": variant_type, "locale": locale,
81
109
  "market": market, "slug": slug, "title": title, "facts": facts,
82
110
  "clusterLinks": sorted(set(cluster_links or [])), "layoutFamily": layout_family,
83
- "canonicalVariantId": canonical_variant_id or variant_id, "canonicalUrl": "/services/" + slug + "/",
84
- "hreflang": {locale: "/services/" + slug + "/"}, "sourceRevision": source_revision,
111
+ "canonicalVariantId": family, "canonicalUrl": canonical_url,
112
+ "hreflang": {locale: canonical_url}, "sourceRevision": source_revision,
85
113
  "provenance": {"operation": "create", "actor": actor, "sourceRevision": source_revision},
86
114
  "status": "draft", "createdAt": now(), "updatedAt": now()}
87
115
  self.data["variants"].append(item); self._event("variant.created", variant_id, actor, "draft created"); self._save()
@@ -120,6 +148,7 @@ class ServiceVariantStore:
120
148
  raise ValueError("canonical source must be published before a translated variant")
121
149
  old = item["status"]; item["status"] = target; item["updatedAt"] = now()
122
150
  item.setdefault("approval", []).append({"from": old, "to": target, "actor": actor, "reason": reason, "at": now()})
151
+ self._refresh_hreflang(item["canonicalVariantId"])
123
152
  self._event("variant." + target, variant_id, actor, reason); self._save(); return item
124
153
 
125
154
  def plan_slug_change(self, variant_id: str, new_slug: str, actor: str) -> dict:
@@ -128,17 +157,29 @@ class ServiceVariantStore:
128
157
  raise ValueError("published URL changes require owner approval")
129
158
  if not new_slug.startswith(item["variantType"] + "/") or not SLUG_WORD.fullmatch(new_slug.split("/", 1)[-1]):
130
159
  raise ValueError("new slug violates variant grammar")
131
- if any(v["slug"] == new_slug and v["locale"] == item["locale"] for v in self.data["variants"] if v["id"] != variant_id):
132
- raise ValueError("new slug collides with another variant")
133
- redirect = {"from": item["canonicalUrl"], "to": "/services/" + new_slug + "/", "ownerApproval": actor, "status": "planned"}
160
+ new_url = variant_canonical_url(new_slug, item["locale"], item["market"])
161
+ if any(v.get("canonicalUrl") == new_url for v in self.data["variants"] if v["id"] != variant_id):
162
+ raise ValueError("new market, locale and slug route collides with another variant")
163
+ redirect = {"from": item["canonicalUrl"], "to": new_url, "ownerApproval": actor, "status": "planned"}
134
164
  self.data["redirects"].append(redirect); self._save(); return redirect
135
165
 
136
166
  def validate(self, variant_id: str, source_text: str | None = None, render_report: dict | None = None) -> dict:
137
167
  item = self._find(variant_id); errors = []
168
+ expected_url = variant_canonical_url(item["slug"], item["locale"], item["market"])
169
+ if item.get("canonicalUrl") != expected_url:
170
+ errors.append("canonical URL must include market, locale and variant slug")
138
171
  if not item["clusterLinks"] and item["variantType"] != "location": errors.append("cluster links required")
139
172
  if source_text is not None and similarity(source_text, item["title"] + " " + " ".join(str(f["value"]) for f in item["facts"])) < 0.1:
140
173
  errors.append("variant content has no meaningful similarity to source")
141
174
  if item["canonicalVariantId"] != item["id"] and item["canonicalVariantId"] not in {v["id"] for v in self.data["variants"]}: errors.append("canonical relation missing")
175
+ if item.get("status") == "published":
176
+ expected_hreflang = {
177
+ peer["locale"]: peer["canonicalUrl"]
178
+ for peer in self._cluster(item["canonicalVariantId"])
179
+ if peer.get("status") == "published"
180
+ }
181
+ if item.get("hreflang") != expected_hreflang:
182
+ errors.append("published hreflang links must be reciprocal published counterparts")
142
183
  if render_report is not None:
143
184
  if render_report.get("schemaVersion") != "maggie-service-variant-render.v1" or render_report.get("passed") is not True:
144
185
  errors.append("render report is not passed")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topy-ai/maggie",
3
- "version": "0.7.17",
3
+ "version": "0.7.18",
4
4
  "description": "Install and manage Maggie Skills for AI coding agents",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -25,8 +25,10 @@ when a title changes.
25
25
  "title": "Signature Scalp Ritual",
26
26
  "description": "Provider-supplied factual description.",
27
27
  "category": {"level1": "Head Spa", "level2": "Scalp Treatments"},
28
- "variants": [{
28
+ "variants": [{
29
29
  "id": "service-123:60",
30
+ "idSource": "provider",
31
+ "variantSource": "native",
30
32
  "title": "60 minutes",
31
33
  "durationMinutes": 60,
32
34
  "price": {"amountMinor": 9500, "currency": "GBP", "display": "£95.00"}
@@ -147,3 +149,36 @@ Provider adapters may add `raw` evidence in a private local snapshot, but
147
149
  public page generation consumes only the canonical fields above. A sync must
148
150
  preserve removed records as `archived` with `removedAt` and must output a
149
151
  change report.
152
+
153
+ ## Provider variant capability matrix
154
+
155
+ Before service pages are published, each provider declares its variant
156
+ behavior in a project-owned JSON file. The declaration is validated against a
157
+ sanitized fixture and emits `maggie-provider-variant-capabilities.v1`:
158
+
159
+ ```json
160
+ {
161
+ "schemaVersion": "maggie-provider-capabilities.v1",
162
+ "providers": [{
163
+ "provider": "fresha",
164
+ "variantMode": "native",
165
+ "priceSemantics": "minor-unit-and-display",
166
+ "stableIdConfidence": "high"
167
+ }]
168
+ }
169
+ ```
170
+
171
+ `variantMode` is one of `native`, `derived-offers`, `single-fallback`, or
172
+ `blocked`. `priceSemantics` records whether structured minor units and display
173
+ prices are available. `stableIdConfidence` must be justified by provider or
174
+ derived ID evidence. The audit records service/variant counts, observed source
175
+ types, fixture name and hash, and validation errors without copying fixture
176
+ rows or provider credentials. Run it with:
177
+
178
+ ```bash
179
+ maggie service capability-audit \
180
+ --project . \
181
+ --catalogue .maggie/booking/services.json \
182
+ --capabilities-file .maggie/booking/provider-capabilities.json \
183
+ --fixture .maggie/booking/fixtures/provider.json
184
+ ```