@topy-ai/maggie 0.7.31 → 0.7.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README-zh-TW.md +2 -2
- package/README.md +101 -19
- package/bin/maggie.js +6 -0
- package/bundled-references/google-integrations-runbook.md +26 -0
- package/bundled-skills/maggie-blog/SKILL.md +18 -1
- package/bundled-skills/maggie-dash/SKILL.md +18 -3
- package/bundled-skills/maggie-deployment/SKILL.md +72 -0
- package/bundled-skills/maggie-deployment/references/vps.md +26 -0
- package/bundled-skills/maggie-design/SKILL.md +16 -0
- package/bundled-skills/maggie-feedback/SKILL.md +11 -2
- package/bundled-skills/maggie-seo-geo/SKILL.md +22 -1
- package/bundled-tools/clis/maggie.py +12 -0
- package/bundled-tools/clis/maggie_analytics.py +34 -0
- package/bundled-tools/clis/maggie_blog.py +13 -0
- package/bundled-tools/clis/maggie_dash.py +17 -0
- package/bundled-tools/clis/maggie_deployment.py +91 -7
- package/bundled-tools/clis/maggie_feedback.py +18 -4
- package/bundled-tools/clis/maggie_icon_release_gate.py +79 -0
- package/bundled-tools/clis/maggie_migration.py +71 -0
- package/bundled-tools/clis/maggie_release_manifest.py +81 -0
- package/bundled-tools/clis/maggie_runtime_preflight.py +76 -0
- package/bundled-tools/clis/site_audit.py +78 -5
- package/bundled-tools/integrations/analytics.md +20 -0
- package/bundled-tools/runtime/maggie_blog_publish.py +38 -0
- package/bundled-tools/runtime/maggie_dash_panels.py +111 -0
- package/bundled-tools/runtime/maggie_quality.py +1 -1
- package/bundled-tools/runtime/maggie_sections.py +39 -1
- package/bundled-tools/runtime/site_baseline.py +23 -2
- package/package.json +1 -1
- package/references/google-integrations-runbook.md +26 -0
|
@@ -9,7 +9,7 @@ import json
|
|
|
9
9
|
import re
|
|
10
10
|
import sys
|
|
11
11
|
from pathlib import Path
|
|
12
|
-
from urllib.parse import urljoin, urlparse
|
|
12
|
+
from urllib.parse import urljoin, urlparse, urlunparse
|
|
13
13
|
from html.parser import HTMLParser
|
|
14
14
|
from urllib.request import Request, urlopen
|
|
15
15
|
|
|
@@ -233,7 +233,16 @@ def hreflang_check(url: str, page: PageParser, expected: set[str]) -> dict:
|
|
|
233
233
|
return {"ok": not missing and not reciprocal_errors, "missing": missing, "reciprocal_errors": reciprocal_errors, "links": links}
|
|
234
234
|
|
|
235
235
|
|
|
236
|
-
def
|
|
236
|
+
def sitemap_target(base: str, value: str, *, preserve_origin: bool = False) -> str:
|
|
237
|
+
"""Resolve a sitemap loc while keeping crawl traffic on the requested origin."""
|
|
238
|
+
parsed = urlparse(value)
|
|
239
|
+
if preserve_origin or not parsed.scheme or not parsed.netloc:
|
|
240
|
+
return urljoin(base + "/", value)
|
|
241
|
+
requested = urlparse(base)
|
|
242
|
+
return urlunparse((requested.scheme, requested.netloc, parsed.path or "/", "", parsed.query, ""))
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def sitemap_urls(base: str, *, preserve_origin: bool = False) -> tuple[list[str], list[dict[str, str]]]:
|
|
237
246
|
"""Resolve a sitemap index and return page URLs plus raw loc violations."""
|
|
238
247
|
pending = [urljoin(base + "/", "sitemap.xml")]
|
|
239
248
|
pages = []
|
|
@@ -249,7 +258,7 @@ def sitemap_urls(base: str) -> tuple[list[str], list[dict[str, str]]]:
|
|
|
249
258
|
for value in raw_locs:
|
|
250
259
|
if not re.match(r"^https?://[^\s]+$", value, re.I):
|
|
251
260
|
violations.append({"sitemap": sitemap_url, "loc": value, "reason": "sitemap loc must be an absolute HTTP(S) URL"})
|
|
252
|
-
locs = [
|
|
261
|
+
locs = [sitemap_target(base, value, preserve_origin=preserve_origin) for value in raw_locs]
|
|
253
262
|
if re.search(r"<sitemapindex\b", body, re.I):
|
|
254
263
|
pending.extend(locs)
|
|
255
264
|
else:
|
|
@@ -280,9 +289,13 @@ def main() -> int:
|
|
|
280
289
|
parser.add_argument("--check-translation-completeness", action="store_true")
|
|
281
290
|
parser.add_argument("--access-log", type=Path, help="optional local access log for crawler-request diagnosis")
|
|
282
291
|
parser.add_argument("--require-sitemap-request", action="store_true", help="fail unless the supplied access log contains a sitemap request")
|
|
292
|
+
parser.add_argument("--preserve-sitemap-origin", action="store_true", help="opt in to crawling absolute sitemap URLs on their declared origins")
|
|
283
293
|
baseline_args = parser.add_mutually_exclusive_group()
|
|
284
294
|
baseline_args.add_argument("--save-baseline", type=Path, help="create a new reviewed contract from a passing complete crawl")
|
|
285
295
|
baseline_args.add_argument("--baseline", type=Path, help="fail on differences from a reviewed contract")
|
|
296
|
+
parser.add_argument("--recapture-baseline", type=Path, help="write a reviewed versioned baseline after reviewing every drift")
|
|
297
|
+
parser.add_argument("--baseline-id", help="required stable ID for --recapture-baseline")
|
|
298
|
+
parser.add_argument("--reason", action="append", default=[], metavar="URL=REASON", help="reviewer-approved reason for one drifting URL; repeat for every drift")
|
|
286
299
|
parser.add_argument("--reviewer", help="required for --save-baseline")
|
|
287
300
|
args = parser.parse_args()
|
|
288
301
|
if args.max_pages < 1:
|
|
@@ -291,6 +304,17 @@ def main() -> int:
|
|
|
291
304
|
parser.error("baseline operations require --crawl")
|
|
292
305
|
if args.save_baseline and not (args.reviewer or "").strip():
|
|
293
306
|
parser.error("save-baseline requires --reviewer")
|
|
307
|
+
if args.recapture_baseline:
|
|
308
|
+
if not args.baseline:
|
|
309
|
+
parser.error("--recapture-baseline requires --baseline")
|
|
310
|
+
if not args.crawl:
|
|
311
|
+
parser.error("baseline recapture requires --crawl")
|
|
312
|
+
if not (args.reviewer or "").strip():
|
|
313
|
+
parser.error("baseline recapture requires --reviewer")
|
|
314
|
+
if not args.baseline_id or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,79}", args.baseline_id):
|
|
315
|
+
parser.error("baseline recapture requires a safe --baseline-id")
|
|
316
|
+
if args.save_baseline and args.recapture_baseline:
|
|
317
|
+
parser.error("--save-baseline cannot be combined with --recapture-baseline")
|
|
294
318
|
try:
|
|
295
319
|
expected_languages = language_set(args.languages)
|
|
296
320
|
markets = {item.strip() for item in (args.markets or "").split(",") if item.strip()}
|
|
@@ -349,7 +373,7 @@ def main() -> int:
|
|
|
349
373
|
crawl = {"enabled": args.crawl, "passed": True, "pages": []}
|
|
350
374
|
if args.crawl:
|
|
351
375
|
try:
|
|
352
|
-
urls, sitemap_violations = sitemap_urls(base)
|
|
376
|
+
urls, sitemap_violations = sitemap_urls(base, preserve_origin=args.preserve_sitemap_origin)
|
|
353
377
|
crawl["discovered_url_count"] = len(urls)
|
|
354
378
|
crawl["complete"] = len(urls) <= args.max_pages and not sitemap_violations
|
|
355
379
|
urls = urls[: args.max_pages]
|
|
@@ -371,6 +395,8 @@ def main() -> int:
|
|
|
371
395
|
page_checks = {"url": page_url, "passed": False, "error": type(exc).__name__}
|
|
372
396
|
crawl["pages"].append(page_checks)
|
|
373
397
|
crawl["sitemap_loc_violations"] = sitemap_violations
|
|
398
|
+
crawl["requestedOrigin"] = base
|
|
399
|
+
crawl["sitemapOriginPolicy"] = "declared-origin" if args.preserve_sitemap_origin else "requested-origin"
|
|
374
400
|
crawl["passed"] = crawl["complete"] and bool(urls) and len(crawl["pages"]) == len(urls) and all(item["passed"] for item in crawl["pages"])
|
|
375
401
|
crawl["url_count"] = len(urls)
|
|
376
402
|
except Exception as exc:
|
|
@@ -380,9 +406,56 @@ def main() -> int:
|
|
|
380
406
|
result["evidence"] = {"structural": "captured", "rendered": "not_run", "behavioral": "not_run"}
|
|
381
407
|
crawl["summary"] = summarize_crawl(crawl["pages"])
|
|
382
408
|
try:
|
|
409
|
+
current_passed = result["passed"]
|
|
383
410
|
if args.baseline:
|
|
384
|
-
|
|
411
|
+
previous_baseline = json.loads(args.baseline.read_text())
|
|
412
|
+
result["baseline"] = site_baseline.compare(previous_baseline, result)
|
|
385
413
|
result["passed"] = result["passed"] and result["baseline"]["passed"]
|
|
414
|
+
if args.recapture_baseline:
|
|
415
|
+
comparison = result["baseline"]
|
|
416
|
+
drift_urls = set(comparison.get("added", [])) | set(comparison.get("removed", []))
|
|
417
|
+
drift_urls |= {item["url"] for item in comparison.get("changed", []) if isinstance(item, dict) and item.get("url")}
|
|
418
|
+
drift_urls |= {item["url"] for item in comparison.get("contentChanged", []) if isinstance(item, dict) and item.get("url")}
|
|
419
|
+
reasons: dict[str, str] = {}
|
|
420
|
+
for value in args.reason:
|
|
421
|
+
url, separator, reason = value.partition("=")
|
|
422
|
+
if not separator or not url.strip() or not reason.strip():
|
|
423
|
+
raise ValueError("each --reason must use URL=REASON")
|
|
424
|
+
url = url.strip()
|
|
425
|
+
if url in reasons:
|
|
426
|
+
raise ValueError("duplicate baseline change reason")
|
|
427
|
+
if url not in drift_urls:
|
|
428
|
+
raise ValueError("baseline change reason references a URL without a detected drift")
|
|
429
|
+
reasons[url] = reason.strip()
|
|
430
|
+
missing_reasons = sorted(drift_urls - reasons.keys())
|
|
431
|
+
if missing_reasons:
|
|
432
|
+
raise ValueError("every drifting URL requires a reviewer reason")
|
|
433
|
+
if not current_passed:
|
|
434
|
+
raise ValueError("baseline recapture requires a passing current crawl")
|
|
435
|
+
recapture_report = dict(result)
|
|
436
|
+
# The comparison intentionally makes the aggregate result
|
|
437
|
+
# fail while the current crawl itself is still valid. Snapshot
|
|
438
|
+
# validates the latter, not whether the old baseline drifted.
|
|
439
|
+
recapture_report["passed"] = current_passed
|
|
440
|
+
recaptured = site_baseline.snapshot(
|
|
441
|
+
recapture_report,
|
|
442
|
+
args.reviewer,
|
|
443
|
+
baseline_id=args.baseline_id,
|
|
444
|
+
supersedes=previous_baseline.get("baselineId") or site_baseline.baseline_fingerprint(previous_baseline),
|
|
445
|
+
change_reasons=reasons,
|
|
446
|
+
)
|
|
447
|
+
site_baseline.save(args.recapture_baseline, recaptured)
|
|
448
|
+
result["baselineRecapture"] = {
|
|
449
|
+
"passed": True,
|
|
450
|
+
"saved": str(args.recapture_baseline),
|
|
451
|
+
"baselineId": args.baseline_id,
|
|
452
|
+
"supersedes": recaptured["supersedes"],
|
|
453
|
+
"reasonCount": len(reasons),
|
|
454
|
+
}
|
|
455
|
+
# A reviewed recapture resolves the old-baseline diff; the
|
|
456
|
+
# command's exit status should reflect the current crawl and
|
|
457
|
+
# the completed review gate.
|
|
458
|
+
result["passed"] = current_passed
|
|
386
459
|
if args.save_baseline:
|
|
387
460
|
site_baseline.save(args.save_baseline, site_baseline.snapshot(result, args.reviewer))
|
|
388
461
|
result["baseline"] = {"passed": True, "saved": str(args.save_baseline)}
|
|
@@ -25,6 +25,26 @@ booking click is only an outbound intent signal; it is not a confirmed booking
|
|
|
25
25
|
or revenue event. Add conversion events only when the host project defines a
|
|
26
26
|
verified provider callback or reconciliation source.
|
|
27
27
|
|
|
28
|
+
## Consent ordering and first-party scope
|
|
29
|
+
|
|
30
|
+
Consent Mode is an ordering contract, not only a boolean configuration:
|
|
31
|
+
|
|
32
|
+
1. Push the default consent state inline into the data layer before loading the
|
|
33
|
+
tag manager/container snippet.
|
|
34
|
+
2. Let vendor tags follow Consent Mode. Do not remove the default data-layer
|
|
35
|
+
event merely because analytics is disabled before consent.
|
|
36
|
+
3. Stop first-party analytics storage when consent is denied, or declare and
|
|
37
|
+
evidence an explicit degraded mode. Do not silently keep identifiers.
|
|
38
|
+
4. Treat an intentional first-party form submission as a separate privacy
|
|
39
|
+
decision. If it is recorded without analytics consent, declare
|
|
40
|
+
`intentionalFormMode: "record"` in the contract and document the lawful
|
|
41
|
+
basis and retention policy in the host project; otherwise use `"omit"`.
|
|
42
|
+
|
|
43
|
+
The release contract must declare `defaultBeforeContainer`,
|
|
44
|
+
`firstPartyStorageMode` (`stop` or `degrade`), and `intentionalFormMode`
|
|
45
|
+
(`record` or `omit`). Browser evidence must prove the ordering and the
|
|
46
|
+
first-party behavior; a missing observation is `unknown`, never `false`.
|
|
47
|
+
|
|
28
48
|
## GSC
|
|
29
49
|
|
|
30
50
|
Prefer DNS verification for production. If HTML verification is needed, use a
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Validate an explicit, evidence-backed automatic blog publication opt-in."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
REQUIRED = ("localeVariants", "featureImage", "seo", "sitemap", "indexingNotification", "audit")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def validate_auto_publish(value: object) -> dict[str, Any]:
|
|
12
|
+
errors: list[str] = []
|
|
13
|
+
if not isinstance(value, dict):
|
|
14
|
+
return {"schemaVersion": "maggie-blog-auto-publish-result.v1", "passed": False, "errors": ["evidence must be an object"], "mutation": "not executed"}
|
|
15
|
+
if value.get("schemaVersion") != "maggie-blog-auto-publish.v1":
|
|
16
|
+
errors.append("schemaVersion must be maggie-blog-auto-publish.v1")
|
|
17
|
+
if value.get("optIn") is not True:
|
|
18
|
+
errors.append("optIn must be true for automatic publishing")
|
|
19
|
+
if value.get("requireReview") is not False or value.get("reviewDisabledExplicitly") is not True:
|
|
20
|
+
errors.append("automatic publishing requires explicit review disablement")
|
|
21
|
+
checks: dict[str, bool] = {}
|
|
22
|
+
for name in REQUIRED:
|
|
23
|
+
item = value.get(name)
|
|
24
|
+
passed = isinstance(item, dict) and item.get("passed") is True
|
|
25
|
+
checks[name] = passed
|
|
26
|
+
if not passed:
|
|
27
|
+
errors.append(f"{name}.passed must be true")
|
|
28
|
+
return {
|
|
29
|
+
"schemaVersion": "maggie-blog-auto-publish-result.v1",
|
|
30
|
+
"passed": not errors,
|
|
31
|
+
"errors": sorted(set(errors)),
|
|
32
|
+
"optIn": value.get("optIn") is True,
|
|
33
|
+
"requireReview": value.get("requireReview"),
|
|
34
|
+
"reviewDisabledExplicitly": value.get("reviewDisabledExplicitly") is True,
|
|
35
|
+
"checks": checks,
|
|
36
|
+
"mutation": "not executed",
|
|
37
|
+
"nextAction": "host may call its explicit publish path only after this gate passes" if not errors else "keep content in human review",
|
|
38
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Validate source and freshness evidence for independent dashboard panels."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
SCHEMA = "maggiedash-measurement-panels.v1"
|
|
10
|
+
MODES = {"live", "snapshot"}
|
|
11
|
+
STATUSES = {"pass", "stale", "error"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _timestamp(value: object) -> bool:
|
|
15
|
+
if not isinstance(value, str) or not value.strip():
|
|
16
|
+
return False
|
|
17
|
+
try:
|
|
18
|
+
datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
19
|
+
except ValueError:
|
|
20
|
+
return False
|
|
21
|
+
return True
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def validate_panel_report(value: object) -> dict[str, Any]:
|
|
25
|
+
errors: list[str] = []
|
|
26
|
+
warnings: list[str] = []
|
|
27
|
+
normalized: list[dict[str, Any]] = []
|
|
28
|
+
if not isinstance(value, dict):
|
|
29
|
+
return {"schemaVersion": SCHEMA, "passed": False, "errors": ["report must be an object"], "panels": [], "failedPanels": [], "warnings": []}
|
|
30
|
+
if value.get("schemaVersion") != SCHEMA:
|
|
31
|
+
errors.append(f"schemaVersion must be {SCHEMA}")
|
|
32
|
+
panels = value.get("panels")
|
|
33
|
+
if not isinstance(panels, list) or not panels:
|
|
34
|
+
errors.append("panels must be a non-empty list")
|
|
35
|
+
panels = []
|
|
36
|
+
seen: set[str] = set()
|
|
37
|
+
failed: list[str] = []
|
|
38
|
+
for index, panel in enumerate(panels):
|
|
39
|
+
at = f"panels[{index}]"
|
|
40
|
+
if not isinstance(panel, dict):
|
|
41
|
+
errors.append(f"{at} must be an object")
|
|
42
|
+
continue
|
|
43
|
+
panel_id = panel.get("id")
|
|
44
|
+
if not isinstance(panel_id, str) or not panel_id.strip():
|
|
45
|
+
errors.append(f"{at}.id must be a non-empty string")
|
|
46
|
+
panel_id = f"panel-{index}"
|
|
47
|
+
if panel_id in seen:
|
|
48
|
+
errors.append(f"{at}.id is duplicated")
|
|
49
|
+
seen.add(panel_id)
|
|
50
|
+
source = panel.get("source")
|
|
51
|
+
source_name = ""
|
|
52
|
+
if isinstance(source, dict):
|
|
53
|
+
source_name = str(source.get("name") or source.get("system") or "").strip()
|
|
54
|
+
elif isinstance(source, str):
|
|
55
|
+
source_name = source.strip()
|
|
56
|
+
if not source_name:
|
|
57
|
+
errors.append(f"{at}.source must identify a data source")
|
|
58
|
+
mode = panel.get("mode")
|
|
59
|
+
if mode not in MODES:
|
|
60
|
+
errors.append(f"{at}.mode must be live or snapshot")
|
|
61
|
+
observed_at = panel.get("observedAt")
|
|
62
|
+
if not _timestamp(observed_at):
|
|
63
|
+
errors.append(f"{at}.observedAt must be an ISO-8601 timestamp")
|
|
64
|
+
max_age = panel.get("maxAgeSeconds")
|
|
65
|
+
if not isinstance(max_age, int) or isinstance(max_age, bool) or max_age < 1:
|
|
66
|
+
errors.append(f"{at}.maxAgeSeconds must be a positive integer")
|
|
67
|
+
max_age = 0
|
|
68
|
+
age = panel.get("ageSeconds")
|
|
69
|
+
if not isinstance(age, int) or isinstance(age, bool) or age < 0:
|
|
70
|
+
errors.append(f"{at}.ageSeconds must be a non-negative integer")
|
|
71
|
+
age = 0
|
|
72
|
+
status = panel.get("status")
|
|
73
|
+
if status not in STATUSES:
|
|
74
|
+
errors.append(f"{at}.status must be pass, stale, or error")
|
|
75
|
+
status = "error"
|
|
76
|
+
warning = str(panel.get("warning") or "").strip()
|
|
77
|
+
error = panel.get("error")
|
|
78
|
+
if status == "stale":
|
|
79
|
+
if not warning:
|
|
80
|
+
errors.append(f"{at}.warning is required for stale panels")
|
|
81
|
+
warnings.append(panel_id)
|
|
82
|
+
if status == "error":
|
|
83
|
+
failed.append(panel_id)
|
|
84
|
+
if not isinstance(error, dict) or not str(error.get("code") or "").strip() or not str(error.get("remediation") or "").strip():
|
|
85
|
+
errors.append(f"{at}.error requires a code and remediation")
|
|
86
|
+
if status == "stale" or status == "error":
|
|
87
|
+
failed.append(panel_id)
|
|
88
|
+
if status == "pass" and age > max_age:
|
|
89
|
+
errors.append(f"{at}.status cannot be pass when ageSeconds exceeds maxAgeSeconds")
|
|
90
|
+
failed.append(panel_id)
|
|
91
|
+
normalized.append({
|
|
92
|
+
"id": panel_id,
|
|
93
|
+
"source": source_name,
|
|
94
|
+
"mode": mode,
|
|
95
|
+
"observedAt": observed_at,
|
|
96
|
+
"maxAgeSeconds": max_age,
|
|
97
|
+
"ageSeconds": age,
|
|
98
|
+
"status": status,
|
|
99
|
+
"warning": bool(warning),
|
|
100
|
+
"errorCode": str(error.get("code")) if isinstance(error, dict) and error.get("code") else None,
|
|
101
|
+
})
|
|
102
|
+
failed = sorted(set(failed))
|
|
103
|
+
return {
|
|
104
|
+
"schemaVersion": "maggiedash-measurement-panels-result.v1",
|
|
105
|
+
"passed": not errors and not failed and bool(normalized),
|
|
106
|
+
"errors": sorted(set(errors)),
|
|
107
|
+
"panels": normalized,
|
|
108
|
+
"failedPanels": failed,
|
|
109
|
+
"warnings": sorted(set(warnings)),
|
|
110
|
+
"mutation": "not executed",
|
|
111
|
+
}
|
|
@@ -244,7 +244,7 @@ def validate_reconcile_contract(contract: object) -> dict[str, Any]:
|
|
|
244
244
|
if contract.get("writesOnlyWhenChanged") is not True:
|
|
245
245
|
errors.append("writesOnlyWhenChanged must be true")
|
|
246
246
|
report = contract.get("report") if isinstance(contract.get("report"), dict) else {}
|
|
247
|
-
for key in ("changed", "unchanged"):
|
|
247
|
+
for key in ("changed", "unchanged", "writes"):
|
|
248
248
|
if report.get(key) is not True:
|
|
249
249
|
errors.append(f"report.{key} evidence is required")
|
|
250
250
|
second = contract.get("secondRun") if isinstance(contract.get("secondRun"), dict) else {}
|
|
@@ -430,4 +430,42 @@ def reconcile_fields(current: object, desired: object) -> dict[str, Any]:
|
|
|
430
430
|
else:
|
|
431
431
|
result[key] = copy.deepcopy(value)
|
|
432
432
|
changed.append(str(key))
|
|
433
|
-
return {
|
|
433
|
+
return {
|
|
434
|
+
"schemaVersion": RECONCILE_SCHEMA,
|
|
435
|
+
"passed": True,
|
|
436
|
+
"changed": changed,
|
|
437
|
+
"alreadyCorrect": already_correct,
|
|
438
|
+
"writeCount": len(changed),
|
|
439
|
+
"unchangedCount": len(already_correct),
|
|
440
|
+
"writeOutcome": {"wrote": bool(changed), "reason": "changed-fields" if changed else "already-correct"},
|
|
441
|
+
"result": result,
|
|
442
|
+
"convergent": True,
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def summarize_write_outcomes(outcomes: object) -> dict[str, Any]:
|
|
447
|
+
"""Summarize step-level compare-and-set outcomes by actual writes.
|
|
448
|
+
|
|
449
|
+
A step is not a write. Callers must report a boolean ``wrote`` outcome so
|
|
450
|
+
a successful no-op cannot inflate release or migration write counts.
|
|
451
|
+
"""
|
|
452
|
+
if not isinstance(outcomes, list) or not outcomes:
|
|
453
|
+
return {"schemaVersion": "maggiedash-write-outcomes.v1", "passed": False, "errors": ["outcomes must be a non-empty list"]}
|
|
454
|
+
errors: list[str] = []
|
|
455
|
+
normalized: list[dict[str, Any]] = []
|
|
456
|
+
for index, outcome in enumerate(outcomes):
|
|
457
|
+
if not isinstance(outcome, dict) or not isinstance(outcome.get("wrote"), bool):
|
|
458
|
+
errors.append(f"outcomes[{index}].wrote must be boolean")
|
|
459
|
+
continue
|
|
460
|
+
normalized.append({"step": str(outcome.get("step") or index), "wrote": outcome["wrote"]})
|
|
461
|
+
writes = sum(1 for item in normalized if item["wrote"])
|
|
462
|
+
return {
|
|
463
|
+
"schemaVersion": "maggiedash-write-outcomes.v1",
|
|
464
|
+
"passed": not errors,
|
|
465
|
+
"errors": errors,
|
|
466
|
+
"steps": len(normalized),
|
|
467
|
+
"writes": writes,
|
|
468
|
+
"unchanged": len(normalized) - writes,
|
|
469
|
+
"outcomes": normalized,
|
|
470
|
+
"convergent": not errors,
|
|
471
|
+
}
|
|
@@ -2,12 +2,26 @@
|
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
import json
|
|
5
|
+
import hashlib
|
|
5
6
|
from datetime import datetime, timezone
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
from urllib.parse import urlparse
|
|
8
9
|
|
|
9
10
|
|
|
10
|
-
def
|
|
11
|
+
def baseline_fingerprint(baseline: dict) -> str:
|
|
12
|
+
"""Return a stable identity for legacy baselines without baselineId."""
|
|
13
|
+
encoded = json.dumps(baseline, sort_keys=True, ensure_ascii=False, separators=(",", ":")).encode()
|
|
14
|
+
return "sha256:" + hashlib.sha256(encoded).hexdigest()[:16]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def snapshot(
|
|
18
|
+
report: dict,
|
|
19
|
+
reviewer: str,
|
|
20
|
+
*,
|
|
21
|
+
baseline_id: str | None = None,
|
|
22
|
+
supersedes: str | None = None,
|
|
23
|
+
change_reasons: dict[str, str] | None = None,
|
|
24
|
+
) -> dict:
|
|
11
25
|
if not reviewer.strip():
|
|
12
26
|
raise ValueError("baseline requires a reviewer")
|
|
13
27
|
crawl = report.get("crawl", {})
|
|
@@ -32,10 +46,17 @@ def snapshot(report: dict, reviewer: str) -> dict:
|
|
|
32
46
|
content[page["url"]] = page["contentContract"]
|
|
33
47
|
if not contracts:
|
|
34
48
|
raise ValueError("baseline has no non-query page contracts")
|
|
35
|
-
|
|
49
|
+
result = {"schemaVersion": "maggie-site-baseline.v1", "url": report["url"],
|
|
36
50
|
"reviewer": reviewer.strip(), "createdAt": datetime.now(timezone.utc).isoformat(),
|
|
37
51
|
"pages": contracts, "content": content,
|
|
38
52
|
"excludedQueryUrls": sorted(excluded_query_urls)}
|
|
53
|
+
if baseline_id:
|
|
54
|
+
result["baselineId"] = baseline_id
|
|
55
|
+
if supersedes:
|
|
56
|
+
result["supersedes"] = supersedes
|
|
57
|
+
if change_reasons:
|
|
58
|
+
result["approvedChangeReasons"] = {key: change_reasons[key] for key in sorted(change_reasons)}
|
|
59
|
+
return result
|
|
39
60
|
|
|
40
61
|
|
|
41
62
|
def compare(baseline: dict, report: dict) -> dict:
|
package/package.json
CHANGED
|
@@ -52,6 +52,32 @@ The validator accepts the complete provider allowlist, but a capability is
|
|
|
52
52
|
verified only when its corresponding required scope is present. Scope presence
|
|
53
53
|
alone never proves product access.
|
|
54
54
|
|
|
55
|
+
### GTM operation scope matrix
|
|
56
|
+
|
|
57
|
+
Request the union of the rows needed by one operation; do not use a broad
|
|
58
|
+
scope merely because a later operation might need it:
|
|
59
|
+
|
|
60
|
+
| GTM operation | Minimum OAuth scope |
|
|
61
|
+
|---|---|
|
|
62
|
+
| List/read account, container, tag, trigger, or variable | `https://www.googleapis.com/auth/tagmanager.readonly` |
|
|
63
|
+
| Edit container configuration | `https://www.googleapis.com/auth/tagmanager.edit.containers` |
|
|
64
|
+
| Create or update a container version | `https://www.googleapis.com/auth/tagmanager.edit.containerversions` |
|
|
65
|
+
| Publish a container version | `https://www.googleapis.com/auth/tagmanager.publish` |
|
|
66
|
+
|
|
67
|
+
An operation that edits a container and creates a version must request both
|
|
68
|
+
edit scopes; publishing additionally requires the publish scope. Record the
|
|
69
|
+
operation name and exact scope union in the capability evidence so a successful
|
|
70
|
+
read cannot be mistaken for edit or publish access.
|
|
71
|
+
|
|
72
|
+
### Missing fields are unknown
|
|
73
|
+
|
|
74
|
+
Google APIs can omit fields, especially when a proto3 field is absent from the
|
|
75
|
+
response or was not selected by the request. An omitted boolean is not
|
|
76
|
+
`false`, and an absent schema field is not evidence that the capability is
|
|
77
|
+
disabled. Reports must use `unknown`/`not_observed` in the provider adapter's
|
|
78
|
+
evidence and obtain a corroborating read or time-series observation before
|
|
79
|
+
making a negative claim. Never silently coerce a missing field to `false`.
|
|
80
|
+
|
|
55
81
|
## Provider setup and read-only preflight
|
|
56
82
|
|
|
57
83
|
1. Select one provider and one resource. Enable only that provider API in the
|