@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 +24 -4
- package/README.zh-TW.md +10 -2
- package/bin/maggie.js +1 -0
- package/bundled-references/universal-booking-adapter.md +36 -1
- package/bundled-skills/maggie-deployment/SKILL.md +9 -1
- package/bundled-skills/maggie-feedback/SKILL.md +5 -0
- package/bundled-skills/maggie-qa-workflow/SKILL.md +11 -0
- package/bundled-skills/maggie-service-booking/SKILL.md +19 -1
- package/bundled-tools/clis/maggie_feedback.py +17 -5
- package/bundled-tools/clis/maggie_qa_workflow.py +2 -0
- package/bundled-tools/clis/maggie_release.py +88 -2
- package/bundled-tools/clis/maggie_service_booking.py +42 -3
- package/bundled-tools/runtime/booking_capabilities.py +137 -0
- package/bundled-tools/runtime/service_variants.py +48 -7
- package/package.json +1 -1
- package/references/universal-booking-adapter.md +36 -1
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,
|
|
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.
|
|
271
|
-
npx @topy-ai/maggie@0.7.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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":
|
|
113
|
-
"resolution":
|
|
114
|
-
"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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
84
|
-
"hreflang": {locale:
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
@@ -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
|
-
|
|
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
|
+
```
|