@topy-ai/maggie 0.7.35 → 0.7.36
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 +12 -4
- package/bin/maggie.js +74 -19
- package/bundled-contracts/maggie-seo/model-policy-v2.schema.json +29 -0
- package/bundled-contracts/maggie-service-booking/notification-lifecycle-v1.schema.json +29 -0
- package/bundled-skills/maggie-blog-bootstrap/SKILL.md +6 -0
- package/bundled-skills/maggie-design/SKILL.md +15 -0
- package/bundled-skills/maggie-seo-geo/SKILL.md +24 -2
- package/bundled-skills/maggie-service-booking/SKILL.md +19 -0
- package/bundled-templates/astro-blog/starter/README.md +4 -0
- package/bundled-templates/astro-blog/starter/src/styles/global.css +6 -0
- package/bundled-tools/clis/maggie.py +26 -2
- package/bundled-tools/clis/maggie_release.py +24 -0
- package/bundled-tools/clis/maggie_service_booking.py +38 -2
- package/bundled-tools/clis/site_audit.py +114 -15
- package/bundled-tools/runtime/site_baseline.py +11 -1
- package/package.json +1 -1
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.36 init --agent all
|
|
12
12
|
npx @topy-ai/maggie doctor --project .
|
|
13
13
|
```
|
|
14
14
|
|
|
@@ -25,7 +25,7 @@ maggie doctor --project . --require-bootstrap --strict
|
|
|
25
25
|
deployment、memory、feedback 和 MaggieDash。內容先 draft/review,外部寫入、
|
|
26
26
|
publish 與 production deployment 需要明確確認。
|
|
27
27
|
|
|
28
|
-
目前 release 是 `0.7.
|
|
28
|
+
目前 release 是 `0.7.36`,完成下一批已驗證 feedback:修正 doctor placeholder scope、Tailwind renderable source scope、internal link/fragment audit、unstable baseline classification、site-wide baseline reason、base64url asset hash、installed-file hash update safety,並加入 provider-routed Gemini v2、venue timezone/DST booking parsing、social-card release path、notification lifecycle contract 和 mobile hero video playback contract。provider-bound video generation 與 transactional delivery 已記錄在 issue #39;前一批延後功能仍在 issue #38,不會直接成為 active memory。0.7.33 記錄並審查一筆更正回饋;確認 0.7.32 已正確處理 batch index,沒有修改已驗證的 shared behavior。0.7.32 加入 feedback 一基索引支援、MaggieDash panel 的 source/freshness/error evidence、Consent Mode 與實際 write/no-op reconciliation、SEO baseline recapture、sitemap origin rebasing、content-diff scope、migration ledger reconciliation、deployment credential preflight、release provenance/runtime preflight、icon release gate、opt-in blog auto-publish gate,以及 least-privilege VPS deployer。也修復 top-level `maggie feedback` dispatcher parity。0.7.31 加入 update 差異摘要、atomic installer copy、subset install manifest merge,以及 inventory drift 時 doctor non-zero。0.7.30 補上 feedback tracking issue closeout gate 與 batch 14/15 audit ledger。0.7.29 修正 npm package landing page 使用英文 README;0.7.28 加入 versioned Gemini model policy、明確 fallback 與 provenance、bundled-first CLI
|
|
29
29
|
dispatch、`maggie --version`、feedback batch review 聚合與重複偵測、marketplace enrichment evidence、booking
|
|
30
30
|
worker/resolver evidence,以及 read-only migration preflight。它也包含 host-owned mobile app surface contract、signed-in
|
|
31
31
|
camera-state QA、直接 Astro route resolution、correlated feedback batch,以及
|
package/README.md
CHANGED
|
@@ -60,14 +60,22 @@ Maggie keeps the existing project foundation and asks for decisions before
|
|
|
60
60
|
shared routes, analytics, or publishing boundaries change. The current
|
|
61
61
|
package ships 19 installable skills and a local-first MaggieDash foundation.
|
|
62
62
|
|
|
63
|
-
The current release is `0.7.
|
|
63
|
+
The current release is `0.7.36`. It closes the next validated feedback batch:
|
|
64
64
|
production demo fixtures are development-only, Dash infers the persisted
|
|
65
65
|
project id, clone asset extraction includes responsive/background assets, SEO
|
|
66
66
|
structure hashes ignore content-hashed asset names, decorative empty alt text
|
|
67
67
|
is accepted, and missing starter routes return a real 404. It also adds a
|
|
68
68
|
Tailwind v4 starter entry point, explicit `PUBLIC_SITE_URL` validation, a
|
|
69
69
|
portable installed-CLI script path, and a bundled Astro starter without local
|
|
70
|
-
dependencies or build caches.
|
|
70
|
+
dependencies or build caches. Batch 20 also adds rendered-output placeholder
|
|
71
|
+
scoping, Tailwind renderable source boundaries, same-origin link/fragment
|
|
72
|
+
checks, unstable baseline classification, site-wide baseline reasons,
|
|
73
|
+
base64url asset-hash normalization, installed-file update hashes, provider-
|
|
74
|
+
routed Gemini v2, venue-timezone/DST booking parsing, social-card release-path
|
|
75
|
+
discovery, a booking notification lifecycle contract, and mobile hero-video
|
|
76
|
+
playback guidance. Provider-bound video generation and transactional delivery
|
|
77
|
+
are tracked in [issue #39](https://github.com/TOPY-AI-LTD/ai-cmo-skills/issues/39).
|
|
78
|
+
Deferred feature requests from the prior batch remain in
|
|
71
79
|
[issue #38](https://github.com/TOPY-AI-LTD/ai-cmo-skills/issues/38), not active
|
|
72
80
|
memory. The 0.7.33 release records the reviewed correction for the
|
|
73
81
|
batch-index feedback workflow while keeping the verified 0.7.32 behavior
|
|
@@ -509,8 +517,8 @@ artifact schemas.
|
|
|
509
517
|
Recommended upgrade sequence for the current release:
|
|
510
518
|
|
|
511
519
|
```bash
|
|
512
|
-
npx @topy-ai/maggie@0.7.
|
|
513
|
-
npx @topy-ai/maggie@0.7.
|
|
520
|
+
npx @topy-ai/maggie@0.7.36 update --project . --force
|
|
521
|
+
npx @topy-ai/maggie@0.7.36 cleanup --project .
|
|
514
522
|
```
|
|
515
523
|
|
|
516
524
|
Maintainers should pass npm credentials through the repository helper, never
|
package/bin/maggie.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
5
5
|
import { spawnSync } from "node:child_process";
|
|
6
|
-
import { basename, dirname, join, resolve } from "node:path";
|
|
6
|
+
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
|
|
9
9
|
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
@@ -132,9 +132,9 @@ Usage:
|
|
|
132
132
|
maggie seo performance|images|sitemap|indexnow|social-cards|head-tags [options] (sitemap supports strict validate and agent-files)
|
|
133
133
|
maggie feedback <collect|preview|batch|batch-review|submit|list> [options]
|
|
134
134
|
maggie qa <start|record|summary|export|assertion-audit> [options]
|
|
135
|
-
maggie site-audit URL [--crawl] [--access-log FILE] [--require-sitemap-request] [--languages en-GB,es-MX,ja-JP] [--check-hreflang]
|
|
135
|
+
maggie site-audit URL [--crawl] [--access-log FILE] [--require-sitemap-request] [--languages en-GB,es-MX,ja-JP] [--check-hreflang] [--check-internal-links]
|
|
136
136
|
maggie site-audit URL --crawl --save-baseline FILE --reviewer NAME
|
|
137
|
-
maggie site-audit URL --crawl --baseline FILE
|
|
137
|
+
maggie site-audit URL --crawl --baseline FILE [--reason-all REASON]
|
|
138
138
|
maggie browser-audit URL --browse PATH --output DIR --required SELECTOR [--sticky SELECTOR]
|
|
139
139
|
maggie verification coverage --contract FILE --evidence FILE
|
|
140
140
|
maggie ops audit|preflight|verify|lockfiles|seed-manifest|google-capabilities|favicon-check --project PATH
|
|
@@ -282,21 +282,25 @@ function diffSummary(source, target) {
|
|
|
282
282
|
return `${added} local additions, ${removed} package lines removed across ${blocks} changed blocks (${localLines.length} local lines vs ${packageLines.length} package lines)`;
|
|
283
283
|
}
|
|
284
284
|
|
|
285
|
-
function syncTree(source, target, force) {
|
|
285
|
+
function syncTree(source, target, force, root = null, managedFiles = {}) {
|
|
286
286
|
let changed = 0;
|
|
287
287
|
for (const entry of readdirSync(source, { withFileTypes: true })) {
|
|
288
288
|
const sourcePath = join(source, entry.name);
|
|
289
289
|
const targetPath = join(target, entry.name);
|
|
290
290
|
if (entry.isDirectory()) {
|
|
291
|
-
changed += syncTree(sourcePath, targetPath, force);
|
|
291
|
+
changed += syncTree(sourcePath, targetPath, force, root, managedFiles);
|
|
292
292
|
continue;
|
|
293
293
|
}
|
|
294
|
+
const managedPath = root ? relative(root, targetPath) : targetPath;
|
|
295
|
+
const localDigest = existsSync(targetPath) ? digest(targetPath) : null;
|
|
296
|
+
const packageDigest = digest(sourcePath);
|
|
297
|
+
const wasManaged = managedFiles && managedFiles[managedPath] === localDigest;
|
|
294
298
|
if (!existsSync(targetPath)) {
|
|
295
299
|
mkdirSync(dirname(targetPath), { recursive: true });
|
|
296
300
|
writeFileSync(targetPath, readFileSync(sourcePath));
|
|
297
301
|
console.log(`installed ${targetPath}`);
|
|
298
302
|
changed++;
|
|
299
|
-
} else if (force ||
|
|
303
|
+
} else if (force || packageDigest === localDigest || wasManaged) {
|
|
300
304
|
writeFileSync(targetPath, readFileSync(sourcePath));
|
|
301
305
|
console.log(`updated ${targetPath}`);
|
|
302
306
|
changed++;
|
|
@@ -307,6 +311,59 @@ function syncTree(source, target, force) {
|
|
|
307
311
|
return changed;
|
|
308
312
|
}
|
|
309
313
|
|
|
314
|
+
function collectManagedFiles(source, target, root, managedFiles, previousManagedFiles = {}) {
|
|
315
|
+
if (!existsSync(source) || !existsSync(target)) return;
|
|
316
|
+
const sourceStat = readdirSync;
|
|
317
|
+
let entries;
|
|
318
|
+
try {
|
|
319
|
+
entries = sourceStat(source, { withFileTypes: true });
|
|
320
|
+
} catch {
|
|
321
|
+
const targetDigest = digest(target);
|
|
322
|
+
const relativePath = relative(root, target);
|
|
323
|
+
if (targetDigest === digest(source) || previousManagedFiles[relativePath] === targetDigest) managedFiles[relativePath] = targetDigest;
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
for (const entry of entries) {
|
|
327
|
+
collectManagedFiles(join(source, entry.name), join(target, entry.name), root, managedFiles, previousManagedFiles);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function managedPairs(root, roots, skills) {
|
|
332
|
+
const pairs = [];
|
|
333
|
+
const add = (source, target) => { if (existsSync(source) && existsSync(target)) pairs.push([source, target]); };
|
|
334
|
+
for (const agentRoot of roots) {
|
|
335
|
+
for (const skill of skills) add(join(SKILLS_ROOT, skill), join(agentRoot, "skills", skill));
|
|
336
|
+
add(REFERENCES_ROOT, join(agentRoot, "references"));
|
|
337
|
+
}
|
|
338
|
+
for (const group of ["clis", "runtime", "integrations"]) add(join(TOOLS_ROOT, group), join(root, "tools", group));
|
|
339
|
+
add(DESIGN_ROOT, join(root, ".maggie", "design-reference"));
|
|
340
|
+
add(MARKETPLACE_ROOT, join(root, "marketplace"));
|
|
341
|
+
add(CONTRACTS_ROOT, join(root, "contracts"));
|
|
342
|
+
add(join(TEMPLATES_ROOT, "maggiedash"), join(root, "templates", "maggiedash"));
|
|
343
|
+
return pairs;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function managedFileDigests(root, roots, skills, previousManagedFiles = {}) {
|
|
347
|
+
const managedFiles = {};
|
|
348
|
+
for (const [source, target] of managedPairs(root, roots, skills)) collectManagedFiles(source, target, root, managedFiles, previousManagedFiles);
|
|
349
|
+
return Object.fromEntries(Object.entries(managedFiles).sort(([a], [b]) => a.localeCompare(b)));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function readInstallManifest(root) {
|
|
353
|
+
try {
|
|
354
|
+
const path = join(root, STATE_DIR, "install.json");
|
|
355
|
+
return existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : {};
|
|
356
|
+
} catch {
|
|
357
|
+
return {};
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function writeInstallManifest(root, data) {
|
|
362
|
+
const stateDir = join(root, STATE_DIR);
|
|
363
|
+
mkdirSync(stateDir, { recursive: true });
|
|
364
|
+
writeFileSync(join(stateDir, "install.json"), JSON.stringify(data, null, 2) + "\n");
|
|
365
|
+
}
|
|
366
|
+
|
|
310
367
|
function install(args) {
|
|
311
368
|
const root = projectRoot(args);
|
|
312
369
|
const skills = selectedSkills(args);
|
|
@@ -336,9 +393,7 @@ function install(args) {
|
|
|
336
393
|
if (existsSync(MARKETPLACE_ROOT)) copyIfMissing(MARKETPLACE_ROOT, join(root, "marketplace"));
|
|
337
394
|
if (existsSync(CONTRACTS_ROOT)) copyIfMissing(CONTRACTS_ROOT, join(root, "contracts"));
|
|
338
395
|
if (existsSync(join(TEMPLATES_ROOT, "maggiedash"))) copyIfMissing(join(TEMPLATES_ROOT, "maggiedash"), join(root, "templates", "maggiedash"));
|
|
339
|
-
|
|
340
|
-
mkdirSync(stateDir, { recursive: true });
|
|
341
|
-
writeFileSync(join(stateDir, "install.json"), JSON.stringify({ version: PACKAGE_VERSION, agents: roots.map((item) => item.slice(root.length + 1)), skills: mergedSkillNames(root, skills), installed_at: new Date().toISOString() }, null, 2) + "\n");
|
|
396
|
+
writeInstallManifest(root, { version: PACKAGE_VERSION, agents: roots.map((item) => item.slice(root.length + 1)), skills: mergedSkillNames(root, skills), managedFiles: managedFileDigests(root, roots, skills), installed_at: new Date().toISOString() });
|
|
342
397
|
console.log(`Maggie installed in ${root}`);
|
|
343
398
|
console.log("Run `maggie doctor --project .` before using mutating workflows.");
|
|
344
399
|
}
|
|
@@ -348,6 +403,8 @@ function update(args) {
|
|
|
348
403
|
const skills = selectedSkills(args);
|
|
349
404
|
const force = args.includes("--force");
|
|
350
405
|
const roots = agentRoots(args, root);
|
|
406
|
+
const previousManifest = readInstallManifest(root);
|
|
407
|
+
const previousManagedFiles = previousManifest.managedFiles && typeof previousManifest.managedFiles === "object" ? previousManifest.managedFiles : {};
|
|
351
408
|
if (!existsSync(SKILLS_ROOT)) throw new Error("bundled skills are missing; run npm pack from the package source");
|
|
352
409
|
let updated = 0;
|
|
353
410
|
for (const agentRoot of roots) {
|
|
@@ -358,24 +415,22 @@ function update(args) {
|
|
|
358
415
|
console.log(`absent ${target} (run install to add it)`);
|
|
359
416
|
continue;
|
|
360
417
|
}
|
|
361
|
-
updated += syncTree(source, target, force);
|
|
418
|
+
updated += syncTree(source, target, force, root, previousManagedFiles);
|
|
362
419
|
}
|
|
363
|
-
if (existsSync(REFERENCES_ROOT) && existsSync(join(agentRoot, "references"))) updated += syncTree(REFERENCES_ROOT, join(agentRoot, "references"), force);
|
|
420
|
+
if (existsSync(REFERENCES_ROOT) && existsSync(join(agentRoot, "references"))) updated += syncTree(REFERENCES_ROOT, join(agentRoot, "references"), force, root, previousManagedFiles);
|
|
364
421
|
}
|
|
365
422
|
const tools = join(root, "tools");
|
|
366
423
|
if (existsSync(TOOLS_ROOT)) for (const group of ["clis", "runtime", "integrations"]) if (existsSync(join(TOOLS_ROOT, group))) {
|
|
367
424
|
const target = join(tools, group);
|
|
368
|
-
if (existsSync(target)) updated += syncTree(join(TOOLS_ROOT, group), target, force);
|
|
425
|
+
if (existsSync(target)) updated += syncTree(join(TOOLS_ROOT, group), target, force, root, previousManagedFiles);
|
|
369
426
|
}
|
|
370
427
|
if (existsSync(join(DESIGN_ROOT, "SPA-DESIGN.md")) && existsSync(join(root, ".maggie", "design-reference"))) {
|
|
371
|
-
updated += syncTree(DESIGN_ROOT, join(root, ".maggie", "design-reference"), force);
|
|
428
|
+
updated += syncTree(DESIGN_ROOT, join(root, ".maggie", "design-reference"), force, root, previousManagedFiles);
|
|
372
429
|
}
|
|
373
|
-
if (existsSync(MARKETPLACE_ROOT) && existsSync(join(root, "marketplace"))) updated += syncTree(MARKETPLACE_ROOT, join(root, "marketplace"), force);
|
|
374
|
-
if (existsSync(CONTRACTS_ROOT) && existsSync(join(root, "contracts"))) updated += syncTree(CONTRACTS_ROOT, join(root, "contracts"), force);
|
|
375
|
-
if (existsSync(join(TEMPLATES_ROOT, "maggiedash")) && existsSync(join(root, "templates", "maggiedash"))) updated += syncTree(join(TEMPLATES_ROOT, "maggiedash"), join(root, "templates", "maggiedash"), force);
|
|
376
|
-
|
|
377
|
-
mkdirSync(stateDir, { recursive: true });
|
|
378
|
-
writeFileSync(join(stateDir, "install.json"), JSON.stringify({ version: PACKAGE_VERSION, agents: roots.map((item) => item.slice(root.length + 1)), skills: mergedSkillNames(root, skills), updated_at: new Date().toISOString() }, null, 2) + "\n");
|
|
430
|
+
if (existsSync(MARKETPLACE_ROOT) && existsSync(join(root, "marketplace"))) updated += syncTree(MARKETPLACE_ROOT, join(root, "marketplace"), force, root, previousManagedFiles);
|
|
431
|
+
if (existsSync(CONTRACTS_ROOT) && existsSync(join(root, "contracts"))) updated += syncTree(CONTRACTS_ROOT, join(root, "contracts"), force, root, previousManagedFiles);
|
|
432
|
+
if (existsSync(join(TEMPLATES_ROOT, "maggiedash")) && existsSync(join(root, "templates", "maggiedash"))) updated += syncTree(join(TEMPLATES_ROOT, "maggiedash"), join(root, "templates", "maggiedash"), force, root, previousManagedFiles);
|
|
433
|
+
writeInstallManifest(root, { version: PACKAGE_VERSION, agents: roots.map((item) => item.slice(root.length + 1)), skills: mergedSkillNames(root, skills), managedFiles: managedFileDigests(root, roots, skills, previousManagedFiles), updated_at: new Date().toISOString() });
|
|
379
434
|
console.log(`Maggie update complete: ${updated} files changed`);
|
|
380
435
|
if (!force) console.log("Local files with changes were preserved. Review the output and rerun with --force only when replacement is intended.");
|
|
381
436
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://maggie.noblox.app/contracts/maggie-seo/model-policy-v2.schema.json",
|
|
4
|
+
"title": "Maggie provider-routed Gemini model policy v2",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["policyVersion", "model", "fallbackModel", "promptVersion", "schemaVersion", "locale", "sourceRevision", "providerRoute"],
|
|
7
|
+
"properties": {
|
|
8
|
+
"policyVersion": {"const": "maggie-gemini-model-policy.v2"},
|
|
9
|
+
"model": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{1,99}$"},
|
|
10
|
+
"fallbackModel": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{1,99}$"},
|
|
11
|
+
"promptVersion": {"type": "string", "minLength": 1},
|
|
12
|
+
"schemaVersion": {"type": "string", "minLength": 1},
|
|
13
|
+
"locale": {"type": ["string", "null"]},
|
|
14
|
+
"sourceRevision": {"type": ["string", "null"]},
|
|
15
|
+
"providerRoute": {
|
|
16
|
+
"type": "object",
|
|
17
|
+
"required": ["provider", "endpointEnv", "apiKeyEnv", "billingMode", "maxTokens"],
|
|
18
|
+
"properties": {
|
|
19
|
+
"provider": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{1,63}$"},
|
|
20
|
+
"endpointEnv": {"type": "string", "pattern": "^[A-Z][A-Z0-9_]{2,63}$"},
|
|
21
|
+
"apiKeyEnv": {"type": "string", "pattern": "^[A-Z][A-Z0-9_]{2,63}$"},
|
|
22
|
+
"billingMode": {"enum": ["byok", "managed"]},
|
|
23
|
+
"maxTokens": {"type": "integer", "minimum": 1, "maximum": 32768}
|
|
24
|
+
},
|
|
25
|
+
"additionalProperties": false
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"additionalProperties": false
|
|
29
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://maggie.noblox.app/contracts/maggie-service-booking/notification-lifecycle-v1.schema.json",
|
|
4
|
+
"title": "Maggie booking notification lifecycle v1",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["schemaVersion", "bookingId", "state", "events"],
|
|
7
|
+
"properties": {
|
|
8
|
+
"schemaVersion": {"const": "maggie-booking-notification-lifecycle.v1"},
|
|
9
|
+
"bookingId": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$"},
|
|
10
|
+
"state": {"enum": ["received", "pending-confirmation", "confirmed", "alternative-proposed", "cancelled", "failed"]},
|
|
11
|
+
"events": {
|
|
12
|
+
"type": "array",
|
|
13
|
+
"items": {
|
|
14
|
+
"type": "object",
|
|
15
|
+
"required": ["eventId", "type", "occurredAt", "idempotencyKey", "status", "recipientRole"],
|
|
16
|
+
"properties": {
|
|
17
|
+
"eventId": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$"},
|
|
18
|
+
"type": {"enum": ["acknowledgement", "studio-notification", "confirmation", "alternative", "cancellation"]},
|
|
19
|
+
"occurredAt": {"type": "string", "format": "date-time"},
|
|
20
|
+
"idempotencyKey": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$"},
|
|
21
|
+
"status": {"enum": ["pending", "sent", "failed", "suppressed"]},
|
|
22
|
+
"recipientRole": {"enum": ["customer", "studio"]}
|
|
23
|
+
},
|
|
24
|
+
"additionalProperties": false
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"additionalProperties": false
|
|
29
|
+
}
|
|
@@ -51,6 +51,12 @@ project-specific design, service, SEO, operations, and deployment adapters.
|
|
|
51
51
|
9. Run build, route, SEO, accessibility, content, and deployment checks; hand
|
|
52
52
|
private content operations to `maggie-ops`.
|
|
53
53
|
|
|
54
|
+
After a build, run `maggie doctor PROJECT --rendered-dir dist` (or point it at
|
|
55
|
+
the host's equivalent output directory). The placeholder gate prefers shipped
|
|
56
|
+
HTML/assets and excludes server fixtures, API handlers, and CLI examples; this
|
|
57
|
+
prevents a sample URL used in private preview code from failing public-site
|
|
58
|
+
metadata validation.
|
|
59
|
+
|
|
54
60
|
For an installed dashboard, create sanitized runtime observations for every
|
|
55
61
|
endpoint in `maggiedash/contracts/host-adapter-v2.json` and run:
|
|
56
62
|
|
|
@@ -84,6 +84,21 @@ a successful `assetStatus` (2xx), and a non-empty accessible name. This
|
|
|
84
84
|
gate is independent from typecheck/build and is read-only apart from its
|
|
85
85
|
explicit output.
|
|
86
86
|
|
|
87
|
+
## Hero video playback contract
|
|
88
|
+
|
|
89
|
+
Hero video is an enhancement, not the only way to see the first-band message.
|
|
90
|
+
For every video hero, capture desktop, tablet, and mobile evidence with the
|
|
91
|
+
actual browser adapter. The contract must show a visible poster/fallback before
|
|
92
|
+
`canplaythrough`, a usable play control when autoplay is unavailable, a
|
|
93
|
+
reduced-motion path that does not autoplay, and a `Save-Data`/low-bandwidth
|
|
94
|
+
fallback. Mobile evidence must not assume that `preload="none"` will emit
|
|
95
|
+
`canplaythrough` without user interaction. Record poster visibility, playback
|
|
96
|
+
state, console errors, network errors, and a screenshot per viewport.
|
|
97
|
+
|
|
98
|
+
This contract is provider-neutral. It does not claim that Maggie can generate
|
|
99
|
+
video assets; generated-video planning, continuation, safety review, retries,
|
|
100
|
+
and cost approval remain separate product/provider work.
|
|
101
|
+
|
|
87
102
|
## Automatic memory hook
|
|
88
103
|
|
|
89
104
|
Follow [Maggie Memory Hook](../../references/memory-hook.md) at invocation and completion.
|
|
@@ -10,7 +10,9 @@ metadata:
|
|
|
10
10
|
## Versioned Gemini policy
|
|
11
11
|
|
|
12
12
|
AI-assisted SEO, localization, and content generation must resolve models
|
|
13
|
-
through one explicit policy: `maggie-gemini-model-policy.v1
|
|
13
|
+
through one explicit policy: `maggie-gemini-model-policy.v1` for legacy local
|
|
14
|
+
routes or `contracts/maggie-seo/model-policy-v2.schema.json` for provider-routed
|
|
15
|
+
work. Pin the primary
|
|
14
16
|
model, declare a separate fallback, and record the policy version, exact
|
|
15
17
|
prompt/schema versions, locale, and source revision with generated evidence.
|
|
16
18
|
Only model availability failures (retired model, quota, timeout, or provider
|
|
@@ -18,6 +20,13 @@ Only model availability failures (retired model, quota, timeout, or provider
|
|
|
18
20
|
invalid output remain errors. Do not scatter retired model defaults across
|
|
19
21
|
routes.
|
|
20
22
|
|
|
23
|
+
Provider-routed work must use policy v2 and declare the provider, endpoint
|
|
24
|
+
environment variable, API-key environment variable, billing mode (`byok` or
|
|
25
|
+
`managed`), and a finite `maxTokens` limit. A route is not considered BYOK
|
|
26
|
+
merely because a key exists in the process; the selected endpoint and billing
|
|
27
|
+
path must be recorded in the policy evidence. Never print key values or send a
|
|
28
|
+
provider secret in a report.
|
|
29
|
+
|
|
21
30
|
## Static audit and sitemap evidence
|
|
22
31
|
|
|
23
32
|
Run `maggie site-audit https://example.com --crawl --json` for sitemap-listed
|
|
@@ -129,6 +138,13 @@ maggie site-audit https://example.com --crawl --json \
|
|
|
129
138
|
--reason 'https://example.com/pricing=approved pricing refresh'
|
|
130
139
|
```
|
|
131
140
|
|
|
141
|
+
When the same approved reason applies to every drift, use `--reason-all` or
|
|
142
|
+
`--reason '*=approved site-wide stylesheet rebuild'`. A URL-specific reason can
|
|
143
|
+
still override the site-wide reason. If a page changes between its first and
|
|
144
|
+
second fetch, the comparison reports it as `unstable` rather than silently
|
|
145
|
+
calling it a content or markup change; review that page and supply a reason
|
|
146
|
+
before recapture.
|
|
147
|
+
|
|
132
148
|
The command fails closed when the current crawl is incomplete, a drift has no
|
|
133
149
|
reason, a reason names a URL without a reported drift, or the destination file
|
|
134
150
|
already exists. The new contract records its `baselineId`, `supersedes` value,
|
|
@@ -249,7 +265,9 @@ accepts JPEG, PNG, GIF, and ICO responses. It reports missing, unreachable,
|
|
|
249
265
|
undersized, unreadable, or unsupported images and warns when one source is
|
|
250
266
|
used for at least 75% of a sample of three or more pages. Page-specific
|
|
251
267
|
first-band images remain an editorial choice and should be declared by the
|
|
252
|
-
host manifest.
|
|
268
|
+
host manifest. When `docs/social-cards-pages.json` or
|
|
269
|
+
`docs/public-urls.json` exists, `maggie release` includes the social-card audit
|
|
270
|
+
in its read-only release path.
|
|
253
271
|
|
|
254
272
|
Head-tag auditing consumes a host-produced JSON manifest with `url`, `shell`,
|
|
255
273
|
and normalized `tags`. It compares structural declarations for icons,
|
|
@@ -281,6 +299,10 @@ is intended; the report then says `"declared-origin"`.
|
|
|
281
299
|
Use `--output <project>/docs/site-audit.json` to persist the evidence used by
|
|
282
300
|
the release review.
|
|
283
301
|
|
|
302
|
+
For a full crawl, add `--check-internal-links` to follow same-origin HTML links
|
|
303
|
+
and check HTTP 200 targets plus `#fragment` IDs. External, mailto, and other
|
|
304
|
+
non-site links are intentionally out of scope.
|
|
305
|
+
|
|
284
306
|
- **Plan:** combine Project Context, GSC/search signals, competitor gaps, and
|
|
285
307
|
AI visibility prompts into topic clusters and briefs.
|
|
286
308
|
- **Audit:** inspect robots, sitemap, canonical URLs, metadata, JSON-LD,
|
|
@@ -186,6 +186,25 @@ evidence artifact and validates the selected ending (`pending`, `redirect`,
|
|
|
186
186
|
signals, and sitemap exclusion. It never stores response bodies or decides a
|
|
187
187
|
redirect destination for the host.
|
|
188
188
|
|
|
189
|
+
## Appointment time contract
|
|
190
|
+
|
|
191
|
+
Any booking or seasonal variant that receives a timezone-naive wall-clock
|
|
192
|
+
timestamp must carry an IANA `venueTimezone` such as `Europe/London`. The
|
|
193
|
+
shared parser in `tools/clis/maggie_service_booking.py` applies venue DST rules
|
|
194
|
+
instead of the server timezone. An ambiguous DST time must also declare
|
|
195
|
+
`venueTimeFold: 0` or `1`; nonexistent local times are rejected. Prefer an
|
|
196
|
+
offset-bearing timestamp when the provider already supplies one. This is a
|
|
197
|
+
validation contract only: it does not claim that a provider supports booking
|
|
198
|
+
creation or confirmation.
|
|
199
|
+
|
|
200
|
+
When a host implements its own notification adapter, use
|
|
201
|
+
[`notification-lifecycle-v1.schema.json`](../../bundled-contracts/maggie-service-booking/notification-lifecycle-v1.schema.json)
|
|
202
|
+
to represent acknowledgement, studio notification, confirmation, alternative,
|
|
203
|
+
and cancellation events. Every event needs an idempotency key and explicit
|
|
204
|
+
status. A request must remain `pending-confirmation` until the host/provider
|
|
205
|
+
actually confirms it; an email or UI receipt must never imply an appointment
|
|
206
|
+
was booked. The contract is provider-neutral and does not send mail by itself.
|
|
207
|
+
|
|
189
208
|
## Asynchronous onboarding contract
|
|
190
209
|
|
|
191
210
|
Provider onboarding is three independent state machines: `import` owns the
|
|
@@ -88,6 +88,10 @@ with `WORDPRESS_API_TOKEN` or a server-only application-password pair.
|
|
|
88
88
|
The starter includes a Tailwind v4 Vite entry point for projects that choose
|
|
89
89
|
Tailwind during bootstrap. It remains compatible with the existing contract
|
|
90
90
|
surface and does not require replacing the host's design system.
|
|
91
|
+
The entry point explicitly scopes Tailwind v4 scanning to renderable Astro
|
|
92
|
+
pages, layouts, and components. API/lib fixtures and prose in server-only files
|
|
93
|
+
are not treated as utility-class sources; keep that scope when adding a host
|
|
94
|
+
component.
|
|
91
95
|
|
|
92
96
|
`npm run maggie:*` uses the installed `maggie` binary. It is portable after the
|
|
93
97
|
starter is copied out of this monorepo; it no longer depends on repository-
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
@import "tailwindcss";
|
|
2
2
|
|
|
3
|
+
/* Scan renderable Astro surfaces only. Server fixtures and prose in API/lib
|
|
4
|
+
files must not become accidental utility classes in the shipped bundle. */
|
|
5
|
+
@source "../pages/**/*.astro";
|
|
6
|
+
@source "../layouts/**/*.astro";
|
|
7
|
+
@source "../components/**/*.{astro,jsx,tsx}";
|
|
8
|
+
|
|
3
9
|
/* The starter keeps its contract UI framework-neutral while providing a
|
|
4
10
|
working Tailwind v4 entry point for projects that select Tailwind. */
|
|
@@ -23,6 +23,7 @@ STATE_FILE = "bootstrap-state.json"
|
|
|
23
23
|
IGNORED_DIRS = {".git", ".next", "node_modules", "dist", "build", "coverage", "generated"}
|
|
24
24
|
TEXT_SUFFIXES = {".css", ".html", ".js", ".jsx", ".md", ".mdx", ".svelte", ".ts", ".tsx", ".vue", ".astro", ".njk", ".liquid", ".json", ".yaml", ".yml"}
|
|
25
25
|
DOCTOR_NON_SITE_DIRS = {".agents", ".claude", "__tests__", "contracts", "docs", "examples", "fixtures", "marketplace", "references", "scripts", "test", "tests", "tools"}
|
|
26
|
+
DOCTOR_SERVER_DIRS = {"api", "data", "lib", "server", "services"}
|
|
26
27
|
WORKFLOW_PHASES = (
|
|
27
28
|
"maggiedash-foundation",
|
|
28
29
|
"project-research",
|
|
@@ -81,6 +82,26 @@ def doctor_source_files(root: Path, files: list[Path]) -> list[Path]:
|
|
|
81
82
|
return result
|
|
82
83
|
|
|
83
84
|
|
|
85
|
+
def doctor_placeholder_files(root: Path, source_files: list[Path], rendered_dir: str | None = None) -> tuple[list[Path], str]:
|
|
86
|
+
"""Choose rendered output first, with a narrow public-source fallback.
|
|
87
|
+
|
|
88
|
+
Server fixtures and CLI examples are not shipped page metadata and must not
|
|
89
|
+
fail a public-site placeholder check. A build output directory is the
|
|
90
|
+
strongest evidence; ``--rendered-dir`` makes that choice explicit for CI.
|
|
91
|
+
"""
|
|
92
|
+
candidates = [Path(rendered_dir).expanduser().resolve()] if rendered_dir else [root / name for name in ("dist", "build", "out", "public")]
|
|
93
|
+
for candidate in candidates:
|
|
94
|
+
if not candidate.is_dir():
|
|
95
|
+
continue
|
|
96
|
+
files = sorted(path for path in candidate.rglob("*") if path.is_file() and path.suffix.lower() in TEXT_SUFFIXES)
|
|
97
|
+
return files, "rendered-output"
|
|
98
|
+
fallback = [
|
|
99
|
+
path for path in source_files
|
|
100
|
+
if not any(part.lower() in DOCTOR_SERVER_DIRS for part in path.relative_to(root).parts[:-1])
|
|
101
|
+
]
|
|
102
|
+
return fallback, "public-site-source"
|
|
103
|
+
|
|
104
|
+
|
|
84
105
|
def package_info(root: Path) -> tuple[dict, Path | None]:
|
|
85
106
|
candidates = [root / "package.json"] + sorted(path for path in root.glob("*/package.json") if path.parent.name not in IGNORED_DIRS)
|
|
86
107
|
parsed: list[tuple[int, dict, Path]] = []
|
|
@@ -819,6 +840,8 @@ def command_doctor(args: argparse.Namespace) -> int:
|
|
|
819
840
|
files = list(files_for(root))
|
|
820
841
|
source_files = doctor_source_files(root, files)
|
|
821
842
|
text = "\n".join(read_text(path) for path in source_files)
|
|
843
|
+
placeholder_files, placeholder_scope = doctor_placeholder_files(root, source_files, getattr(args, "rendered_dir", None))
|
|
844
|
+
placeholder_text = "\n".join(read_text(path) for path in placeholder_files)
|
|
822
845
|
paths = [evidence(path, root).lower() for path in source_files]
|
|
823
846
|
|
|
824
847
|
checks = {
|
|
@@ -838,7 +861,7 @@ def command_doctor(args: argparse.Namespace) -> int:
|
|
|
838
861
|
"jsonld_dates": "datePublished" in text and ("dateModified" in text or "updatedAt" in text),
|
|
839
862
|
"published_only_guard": "published" in text.lower() and any(token in text.lower() for token in {"sitemap", "post"}),
|
|
840
863
|
"no_public_api_key": not bool(re.search(r"(?:PUBLIC|NEXT_PUBLIC)[A-Z0-9_]*(?:API|KEY|TOKEN)", text, re.IGNORECASE)),
|
|
841
|
-
"no_placeholder_metadata": not any(token in
|
|
864
|
+
"no_placeholder_metadata": not any(token in placeholder_text.lower() for token in {"your-domain.com", "example.com", "lorem ipsum", "replace-me"}),
|
|
842
865
|
}
|
|
843
866
|
if args.strict:
|
|
844
867
|
checks.update({
|
|
@@ -886,7 +909,7 @@ def command_doctor(args: argparse.Namespace) -> int:
|
|
|
886
909
|
safe_skips.append("robots route requires framework confirmation")
|
|
887
910
|
for check, reason in (("canonical_metadata", "canonical metadata needs a site-specific URL"), ("meta_description", "metadata content needs editorial input"), ("article_jsonld", "JSON-LD shape needs content-specific fields")):
|
|
888
911
|
if not checks[check]: safe_skips.append(reason)
|
|
889
|
-
result = {"project_root": str(root), "placeholder_scan": {"scope":
|
|
912
|
+
result = {"project_root": str(root), "placeholder_scan": {"scope": placeholder_scope, "files": len(placeholder_files)}, "checks": {key: {"ok": value} for key, value in checks.items()}, "passed": all(checks.values())}
|
|
890
913
|
if getattr(args, "fix_safe", False): result["safe_fixes"] = {"applied": safe_fixes, "skipped_requires_confirmation": safe_skips}
|
|
891
914
|
print(json.dumps(result, indent=2))
|
|
892
915
|
return 0 if result["passed"] else 1
|
|
@@ -1000,6 +1023,7 @@ def parser() -> argparse.ArgumentParser:
|
|
|
1000
1023
|
doctor.add_argument("--strict", action="store_true", help="also require the complete AI-native blog and Ops surface")
|
|
1001
1024
|
doctor.add_argument("--ci", action="store_true", help="CI alias for strict checks")
|
|
1002
1025
|
doctor.add_argument("--fix-safe", action="store_true", help="apply only low-risk route fixes; never changes schema, slugs, canonical URLs or publishing state")
|
|
1026
|
+
doctor.add_argument("--rendered-dir", help="scan this build output for placeholder metadata instead of source fixtures")
|
|
1003
1027
|
doctor.set_defaults(func=command_doctor)
|
|
1004
1028
|
for name in ("routes", "schema", "seo", "migration", "env"):
|
|
1005
1029
|
check = sub.add_parser(name, help=f"run Maggie {name} contract checks")
|
|
@@ -320,6 +320,29 @@ def editorial_gate(project: Path) -> dict:
|
|
|
320
320
|
}
|
|
321
321
|
|
|
322
322
|
|
|
323
|
+
def social_card_gate(project: Path) -> dict:
|
|
324
|
+
"""Include the per-page social-card audit whenever a manifest is present."""
|
|
325
|
+
candidates = (
|
|
326
|
+
(project / "docs" / "social-cards-pages.json", "--pages-file"),
|
|
327
|
+
(project / ".maggie" / "social-cards-pages.json", "--pages-file"),
|
|
328
|
+
(project / "docs" / "public-urls.json", "--urls-file"),
|
|
329
|
+
(project / ".maggie" / "public-urls.json", "--urls-file"),
|
|
330
|
+
)
|
|
331
|
+
candidate = next(((path, flag) for path, flag in candidates if path.is_file()), None)
|
|
332
|
+
if candidate is None:
|
|
333
|
+
return {
|
|
334
|
+
"name": "social-card-audit",
|
|
335
|
+
"passed": True,
|
|
336
|
+
"exitCode": 0,
|
|
337
|
+
"result": {"passed": True, "state": "not-configured", "reason": "add docs/social-cards-pages.json or docs/public-urls.json to enable the release-path audit"},
|
|
338
|
+
"stderr": "",
|
|
339
|
+
}
|
|
340
|
+
path, flag = candidate
|
|
341
|
+
result = run_gate("social-card-audit", [sys.executable, str(ROOT / "maggie_social_cards.py"), "audit", flag, str(path)], project)
|
|
342
|
+
result["result"]["manifest"] = str(path.relative_to(project))
|
|
343
|
+
return result
|
|
344
|
+
|
|
345
|
+
|
|
323
346
|
def runtime_smoke(base_url: str) -> dict:
|
|
324
347
|
"""Check basic public, admin redirect, and unknown-route behaviour."""
|
|
325
348
|
base = base_url.rstrip("/") + "/"
|
|
@@ -366,6 +389,7 @@ def main() -> int:
|
|
|
366
389
|
gates.append(changed_surface_gate(project))
|
|
367
390
|
gates.append(qa_gate(project, args.environment, args.base_url))
|
|
368
391
|
gates.append(build_gate(project))
|
|
392
|
+
gates.append(social_card_gate(project))
|
|
369
393
|
for name, command in build_gates(project, args.environment, args.target, not args.skip_compatibility):
|
|
370
394
|
gates.append(run_gate(name, command, project))
|
|
371
395
|
if args.analytics_release_gate:
|
|
@@ -7,6 +7,7 @@ import xml.etree.ElementTree as ET
|
|
|
7
7
|
from datetime import datetime, timezone
|
|
8
8
|
from html.parser import HTMLParser
|
|
9
9
|
from pathlib import Path
|
|
10
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
10
11
|
from urllib.parse import quote, urljoin, urlsplit, urlparse
|
|
11
12
|
from urllib.request import Request, urlopen
|
|
12
13
|
|
|
@@ -20,6 +21,39 @@ DURATION = re.compile(r"(\d{2,3})\s*(?:min|mins|minutes?)", re.I)
|
|
|
20
21
|
SUPPLY_STATES = {"live", "withdrawn"}
|
|
21
22
|
DISPLAY_STATES = {"published", "hidden", "retired"}
|
|
22
23
|
|
|
24
|
+
|
|
25
|
+
def parse_booking_time(value, venue_timezone=None, venue_time_fold=None):
|
|
26
|
+
"""Parse an appointment time using venue wall-clock rules, not server TZ."""
|
|
27
|
+
raw = str(value or "").strip()
|
|
28
|
+
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
|
29
|
+
if parsed.tzinfo is None:
|
|
30
|
+
if not venue_timezone:
|
|
31
|
+
raise ValueError("venueTimezone is required for a timezone-naive booking time")
|
|
32
|
+
try:
|
|
33
|
+
zone = ZoneInfo(str(venue_timezone))
|
|
34
|
+
except ZoneInfoNotFoundError as exc:
|
|
35
|
+
raise ValueError("venueTimezone is not an installed IANA timezone") from exc
|
|
36
|
+
candidates = []
|
|
37
|
+
for fold in (0, 1):
|
|
38
|
+
candidate = parsed.replace(tzinfo=zone, fold=fold)
|
|
39
|
+
round_trip = candidate.astimezone(timezone.utc).astimezone(zone).replace(tzinfo=None)
|
|
40
|
+
if round_trip == parsed:
|
|
41
|
+
candidates.append(candidate)
|
|
42
|
+
if not candidates:
|
|
43
|
+
raise ValueError("venue wall-clock time does not exist because of a DST transition")
|
|
44
|
+
if len({candidate.utcoffset() for candidate in candidates}) > 1:
|
|
45
|
+
if venue_time_fold not in (0, 1):
|
|
46
|
+
raise ValueError("venueTimeFold 0 or 1 is required for an ambiguous DST time")
|
|
47
|
+
parsed = parsed.replace(tzinfo=zone, fold=venue_time_fold)
|
|
48
|
+
else:
|
|
49
|
+
parsed = candidates[0]
|
|
50
|
+
elif venue_timezone:
|
|
51
|
+
try:
|
|
52
|
+
parsed = parsed.astimezone(ZoneInfo(str(venue_timezone)))
|
|
53
|
+
except ZoneInfoNotFoundError as exc:
|
|
54
|
+
raise ValueError("venueTimezone is not an installed IANA timezone") from exc
|
|
55
|
+
return parsed
|
|
56
|
+
|
|
23
57
|
def slug(value):
|
|
24
58
|
value = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
|
|
25
59
|
return value or "service"
|
|
@@ -1081,8 +1115,10 @@ def cmd_variant_audit(args):
|
|
|
1081
1115
|
if typ=="event" and not v.get("eventId"): errors.append(f"{prefix}: eventId is required")
|
|
1082
1116
|
if typ=="promotion" and not v.get("promotionId"): errors.append(f"{prefix}: promotionId is required")
|
|
1083
1117
|
if typ in {"holiday","seasonal","event","promotion"}:
|
|
1084
|
-
|
|
1085
|
-
|
|
1118
|
+
venue_timezone = v.get("venueTimezone") or (payload.get("venueTimezone") if isinstance(payload, dict) else None)
|
|
1119
|
+
fold = v.get("venueTimeFold") if "venueTimeFold" in v else (payload.get("venueTimeFold") if isinstance(payload, dict) else None)
|
|
1120
|
+
try: start=parse_booking_time(v.get("startsAt"), venue_timezone, fold); end=parse_booking_time(v.get("endsAt"), venue_timezone, fold)
|
|
1121
|
+
except ValueError as exc: errors.append(f"{prefix}: startsAt and endsAt require venue wall-clock timezone ({exc})"); start=end=None
|
|
1086
1122
|
if start and end and end<=start: errors.append(f"{prefix}: endsAt must be after startsAt")
|
|
1087
1123
|
if end and end<=as_of and v.get("indexable") is True: errors.append(f"{prefix}: expired variant cannot be indexable")
|
|
1088
1124
|
indexable=v.get("indexable") is True; noindex=v.get("noIndex") is True
|
|
@@ -26,7 +26,10 @@ def stable_structure_reference(key: str, value: str) -> str:
|
|
|
26
26
|
path = parsed.path
|
|
27
27
|
if not re.search(r"\.(?:css|js|mjs|png|jpe?g|gif|webp|avif|svg|ico|woff2?|ttf|otf)$", path, re.I):
|
|
28
28
|
return value
|
|
29
|
-
|
|
29
|
+
# Vite/Astro and similar bundlers also emit base64url-safe digests such as
|
|
30
|
+
# ``SiteLayout.Bdp0itNn.css``. Restrict this to a digest-looking token
|
|
31
|
+
# immediately before a known asset extension.
|
|
32
|
+
path = re.sub(r"(?:[-_.])[A-Za-z0-9_-]{8,64}(?=\.[^.]+$)", "-asset", path)
|
|
30
33
|
return urlunparse((parsed.scheme, parsed.netloc, path, parsed.params, "", ""))
|
|
31
34
|
|
|
32
35
|
|
|
@@ -65,9 +68,14 @@ class PageParser(HTMLParser):
|
|
|
65
68
|
self.headings = []
|
|
66
69
|
self.paragraphs = []
|
|
67
70
|
self.links = []
|
|
71
|
+
self.fragment_ids = set()
|
|
68
72
|
|
|
69
73
|
def handle_starttag(self, tag, attrs):
|
|
70
74
|
data = dict(attrs)
|
|
75
|
+
if data.get("id"):
|
|
76
|
+
self.fragment_ids.add(data["id"])
|
|
77
|
+
if tag == "a" and data.get("name"):
|
|
78
|
+
self.fragment_ids.add(data["name"])
|
|
71
79
|
if data.get("data-template"):
|
|
72
80
|
self.templates.add(data["data-template"])
|
|
73
81
|
if tag in {"script", "style", "noscript"}:
|
|
@@ -184,6 +192,7 @@ def audit_page(url: str, html: str, status: int, content_type: str, expected_lan
|
|
|
184
192
|
"title": page.title, "meta": page.meta, "canonical": canonical,
|
|
185
193
|
"lang": page.lang, "hreflang": page.hreflang, "robots": robots,
|
|
186
194
|
"jsonld": page.jsonld_values, "images": page.images,
|
|
195
|
+
"fragmentIds": sorted(page.fragment_ids),
|
|
187
196
|
"structureHash": hashlib.sha256(json.dumps(stable_structure(page.structure), sort_keys=True).encode()).hexdigest(),
|
|
188
197
|
"textHash": hashlib.sha256(" ".join(page.visible_text).encode()).hexdigest(),
|
|
189
198
|
},
|
|
@@ -298,6 +307,99 @@ def access_log_sitemap_check(path: Path, sitemap_path: str = "/sitemap.xml") ->
|
|
|
298
307
|
return {"provided": True, "path": str(path), "requestCount": len(requests), "googlebotRequestCount": len(googlebot), "status": "requested" if requests else "not-requested", "diagnosis": "googlebot-request-observed" if googlebot else ("other-client-request-only" if requests else "no-matching-request")}
|
|
299
308
|
|
|
300
309
|
|
|
310
|
+
def internal_link_check(base: str, pages: list[dict], fetcher=fetch) -> dict[str, object]:
|
|
311
|
+
"""Validate same-origin links and fragment targets discovered during a crawl."""
|
|
312
|
+
origin = urlparse(base)
|
|
313
|
+
page_by_url = {page.get("url"): page for page in pages if page.get("url")}
|
|
314
|
+
errors: list[dict[str, str]] = []
|
|
315
|
+
checked = 0
|
|
316
|
+
seen: set[tuple[str, str]] = set()
|
|
317
|
+
for page in pages:
|
|
318
|
+
page_url = str(page.get("url") or "")
|
|
319
|
+
for link in (page.get("contentContract") or {}).get("links", []):
|
|
320
|
+
target = urljoin(page_url, str(link.get("href") or ""))
|
|
321
|
+
parsed = urlparse(target)
|
|
322
|
+
if parsed.scheme not in {"http", "https"} or (parsed.scheme, parsed.netloc.lower()) != (origin.scheme, origin.netloc.lower()):
|
|
323
|
+
continue
|
|
324
|
+
fragment = parsed.fragment
|
|
325
|
+
target_url = urlunparse((parsed.scheme, parsed.netloc, parsed.path or "/", parsed.params, parsed.query, ""))
|
|
326
|
+
key = (target_url, fragment)
|
|
327
|
+
if key in seen:
|
|
328
|
+
continue
|
|
329
|
+
seen.add(key)
|
|
330
|
+
checked += 1
|
|
331
|
+
target_page = page_by_url.get(target_url)
|
|
332
|
+
if target_page and isinstance(target_page.get("contract"), dict):
|
|
333
|
+
status = target_page.get("status")
|
|
334
|
+
content_type = target_page.get("content_type")
|
|
335
|
+
fragment_ids = set(target_page["contract"].get("fragmentIds", []))
|
|
336
|
+
else:
|
|
337
|
+
try:
|
|
338
|
+
status, content_type, body = fetcher(target_url)
|
|
339
|
+
parsed_target = PageParser()
|
|
340
|
+
parsed_target.feed(body)
|
|
341
|
+
fragment_ids = parsed_target.fragment_ids
|
|
342
|
+
except Exception as exc:
|
|
343
|
+
errors.append({"source": page_url, "url": target_url, "reason": type(exc).__name__})
|
|
344
|
+
continue
|
|
345
|
+
if status != 200 or content_type != "text/html":
|
|
346
|
+
errors.append({"source": page_url, "url": target_url, "reason": "target did not return HTTP 200 HTML"})
|
|
347
|
+
elif fragment and fragment not in fragment_ids:
|
|
348
|
+
errors.append({"source": page_url, "url": target, "reason": "fragment target not found"})
|
|
349
|
+
return {"ok": not errors, "linksChecked": checked, "errors": errors}
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def mark_unstable_pages(pages: list[dict]) -> None:
|
|
353
|
+
"""Refetch baseline candidates once and label per-response markup drift."""
|
|
354
|
+
for page in pages:
|
|
355
|
+
if not isinstance(page.get("contract"), dict):
|
|
356
|
+
continue
|
|
357
|
+
try:
|
|
358
|
+
status, content_type, body = fetch(page["url"])
|
|
359
|
+
second = audit_page(page["url"], body, status, content_type)
|
|
360
|
+
first_contract = page["contract"]
|
|
361
|
+
second_contract = second["contract"]
|
|
362
|
+
first_content = page.get("contentContract", {})
|
|
363
|
+
second_content = second.get("contentContract", {})
|
|
364
|
+
changed = any(first_contract.get(key) != second_contract.get(key) for key in ("structureHash", "textHash")) or first_content.get("contentHash") != second_content.get("contentHash")
|
|
365
|
+
page["stability"] = {
|
|
366
|
+
"status": "unstable" if changed else "stable",
|
|
367
|
+
"changedBetweenFetches": changed,
|
|
368
|
+
"secondStructureHash": second_contract.get("structureHash"),
|
|
369
|
+
"secondTextHash": second_contract.get("textHash"),
|
|
370
|
+
}
|
|
371
|
+
except Exception as exc:
|
|
372
|
+
page["stability"] = {"status": "recapture-failed", "changedBetweenFetches": False, "error": type(exc).__name__}
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def parse_baseline_reasons(values: list[str], drift_urls: set[str], reason_all: str | None = None) -> dict[str, str]:
|
|
376
|
+
"""Parse per-URL and site-wide reviewer reasons with actionable errors."""
|
|
377
|
+
reasons: dict[str, str] = {}
|
|
378
|
+
default = reason_all.strip() if reason_all and reason_all.strip() else ""
|
|
379
|
+
for value in values:
|
|
380
|
+
url, separator, reason = value.partition("=")
|
|
381
|
+
if not separator or not url.strip() or not reason.strip():
|
|
382
|
+
raise ValueError("each --reason must use URL=REASON")
|
|
383
|
+
url, reason = url.strip(), reason.strip()
|
|
384
|
+
if url == "*":
|
|
385
|
+
if default:
|
|
386
|
+
raise ValueError("duplicate site-wide baseline change reason")
|
|
387
|
+
default = reason
|
|
388
|
+
continue
|
|
389
|
+
if url in reasons:
|
|
390
|
+
raise ValueError("duplicate baseline change reason: " + url)
|
|
391
|
+
if url not in drift_urls:
|
|
392
|
+
raise ValueError("baseline change reason references a URL without a detected drift: " + url)
|
|
393
|
+
reasons[url] = reason
|
|
394
|
+
if default:
|
|
395
|
+
for url in drift_urls:
|
|
396
|
+
reasons.setdefault(url, default)
|
|
397
|
+
missing_reasons = sorted(drift_urls - reasons.keys())
|
|
398
|
+
if missing_reasons:
|
|
399
|
+
raise ValueError("every drifting URL requires a reviewer reason: " + ", ".join(missing_reasons))
|
|
400
|
+
return reasons
|
|
401
|
+
|
|
402
|
+
|
|
301
403
|
def main() -> int:
|
|
302
404
|
parser = argparse.ArgumentParser()
|
|
303
405
|
parser.add_argument("url")
|
|
@@ -309,6 +411,7 @@ def main() -> int:
|
|
|
309
411
|
parser.add_argument("--languages", help="comma-separated expected languages/locales, e.g. en-GB,es-MX,ja-JP")
|
|
310
412
|
parser.add_argument("--markets", help="comma-separated markets: global,uk,us")
|
|
311
413
|
parser.add_argument("--check-hreflang", action="store_true")
|
|
414
|
+
parser.add_argument("--check-internal-links", action="store_true", help="validate same-origin links and fragment targets during a crawl")
|
|
312
415
|
parser.add_argument("--check-translation-completeness", action="store_true")
|
|
313
416
|
parser.add_argument("--access-log", type=Path, help="optional local access log for crawler-request diagnosis")
|
|
314
417
|
parser.add_argument("--require-sitemap-request", action="store_true", help="fail unless the supplied access log contains a sitemap request")
|
|
@@ -319,12 +422,15 @@ def main() -> int:
|
|
|
319
422
|
parser.add_argument("--recapture-baseline", type=Path, help="write a reviewed versioned baseline after reviewing every drift")
|
|
320
423
|
parser.add_argument("--baseline-id", help="required stable ID for --recapture-baseline")
|
|
321
424
|
parser.add_argument("--reason", action="append", default=[], metavar="URL=REASON", help="reviewer-approved reason for one drifting URL; repeat for every drift")
|
|
425
|
+
parser.add_argument("--reason-all", help="reviewer-approved reason applied to every drifting URL during baseline recapture")
|
|
322
426
|
parser.add_argument("--reviewer", help="required for --save-baseline")
|
|
323
427
|
args = parser.parse_args()
|
|
324
428
|
if args.max_pages < 1:
|
|
325
429
|
parser.error("max-pages must be positive")
|
|
326
430
|
if (args.save_baseline or args.baseline) and not args.crawl:
|
|
327
431
|
parser.error("baseline operations require --crawl")
|
|
432
|
+
if args.check_internal_links and not args.crawl:
|
|
433
|
+
parser.error("internal link checks require --crawl")
|
|
328
434
|
if args.save_baseline and not (args.reviewer or "").strip():
|
|
329
435
|
parser.error("save-baseline requires --reviewer")
|
|
330
436
|
if args.recapture_baseline:
|
|
@@ -422,6 +528,9 @@ def main() -> int:
|
|
|
422
528
|
crawl["sitemapOriginPolicy"] = "declared-origin" if args.preserve_sitemap_origin else "requested-origin"
|
|
423
529
|
crawl["passed"] = crawl["complete"] and bool(urls) and len(crawl["pages"]) == len(urls) and all(item["passed"] for item in crawl["pages"])
|
|
424
530
|
crawl["url_count"] = len(urls)
|
|
531
|
+
if args.check_internal_links:
|
|
532
|
+
crawl["internal_links"] = internal_link_check(base, crawl["pages"])
|
|
533
|
+
crawl["passed"] = crawl["passed"] and crawl["internal_links"]["ok"]
|
|
425
534
|
except Exception as exc:
|
|
426
535
|
crawl = {"enabled": True, "passed": False, "error": type(exc).__name__, "pages": []}
|
|
427
536
|
|
|
@@ -431,6 +540,8 @@ def main() -> int:
|
|
|
431
540
|
try:
|
|
432
541
|
current_passed = result["passed"]
|
|
433
542
|
if args.baseline:
|
|
543
|
+
if args.crawl:
|
|
544
|
+
mark_unstable_pages(crawl["pages"])
|
|
434
545
|
previous_baseline = json.loads(args.baseline.read_text())
|
|
435
546
|
result["baseline"] = site_baseline.compare(previous_baseline, result)
|
|
436
547
|
result["passed"] = result["passed"] and result["baseline"]["passed"]
|
|
@@ -439,20 +550,8 @@ def main() -> int:
|
|
|
439
550
|
drift_urls = set(comparison.get("added", [])) | set(comparison.get("removed", []))
|
|
440
551
|
drift_urls |= {item["url"] for item in comparison.get("changed", []) if isinstance(item, dict) and item.get("url")}
|
|
441
552
|
drift_urls |= {item["url"] for item in comparison.get("contentChanged", []) if isinstance(item, dict) and item.get("url")}
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
url, separator, reason = value.partition("=")
|
|
445
|
-
if not separator or not url.strip() or not reason.strip():
|
|
446
|
-
raise ValueError("each --reason must use URL=REASON")
|
|
447
|
-
url = url.strip()
|
|
448
|
-
if url in reasons:
|
|
449
|
-
raise ValueError("duplicate baseline change reason")
|
|
450
|
-
if url not in drift_urls:
|
|
451
|
-
raise ValueError("baseline change reason references a URL without a detected drift")
|
|
452
|
-
reasons[url] = reason.strip()
|
|
453
|
-
missing_reasons = sorted(drift_urls - reasons.keys())
|
|
454
|
-
if missing_reasons:
|
|
455
|
-
raise ValueError("every drifting URL requires a reviewer reason")
|
|
553
|
+
drift_urls |= set(comparison.get("unstable", []))
|
|
554
|
+
reasons = parse_baseline_reasons(args.reason, drift_urls, args.reason_all)
|
|
456
555
|
if not current_passed:
|
|
457
556
|
raise ValueError("baseline recapture requires a passing current crawl")
|
|
458
557
|
recapture_report = dict(result)
|
|
@@ -36,6 +36,8 @@ def snapshot(
|
|
|
36
36
|
for page in pages:
|
|
37
37
|
if not page.get("passed") or not isinstance(page.get("contract"), dict):
|
|
38
38
|
raise ValueError("baseline requires successful page contracts")
|
|
39
|
+
if (page.get("stability") or {}).get("status") == "unstable":
|
|
40
|
+
raise ValueError("baseline requires stable page contracts")
|
|
39
41
|
if page["url"] in contracts:
|
|
40
42
|
raise ValueError("duplicate page URL")
|
|
41
43
|
if urlparse(page["url"]).query:
|
|
@@ -75,11 +77,16 @@ def compare(baseline: dict, report: dict) -> dict:
|
|
|
75
77
|
expected = baseline["pages"]
|
|
76
78
|
added, removed = sorted(current.keys() - expected.keys()), sorted(expected.keys() - current.keys())
|
|
77
79
|
changes = []
|
|
80
|
+
unstable = []
|
|
78
81
|
for url in sorted(expected.keys() & current.keys()):
|
|
79
82
|
actual = current[url]
|
|
80
83
|
if not isinstance(actual, dict):
|
|
81
84
|
errors.append("missing page contract: " + url)
|
|
82
85
|
continue
|
|
86
|
+
page = next((item for item in crawl_pages if item.get("url") == url), {})
|
|
87
|
+
if (page.get("stability") or {}).get("status") == "unstable":
|
|
88
|
+
unstable.append(url)
|
|
89
|
+
continue
|
|
83
90
|
fields = sorted(key for key in expected[url].keys() | actual.keys() if expected[url].get(key) != actual.get(key))
|
|
84
91
|
if fields:
|
|
85
92
|
changes.append({"url": url, "fields": fields})
|
|
@@ -88,6 +95,8 @@ def compare(baseline: dict, report: dict) -> dict:
|
|
|
88
95
|
content_not_compared = []
|
|
89
96
|
if expected_content:
|
|
90
97
|
for url in sorted(expected_content.keys() & current_content.keys()):
|
|
98
|
+
if url in unstable:
|
|
99
|
+
continue
|
|
91
100
|
expected_contract = expected_content[url]
|
|
92
101
|
current_contract = current_content[url]
|
|
93
102
|
ignored_legacy_fields = {"images", "imageAlts"} if "images" not in expected_contract else set()
|
|
@@ -99,9 +108,10 @@ def compare(baseline: dict, report: dict) -> dict:
|
|
|
99
108
|
missing_content = sorted(expected_content.keys() - current_content.keys())
|
|
100
109
|
if missing_content:
|
|
101
110
|
errors.append("missing content contract: " + ", ".join(missing_content))
|
|
102
|
-
return {"passed": not errors and not added and not removed and not changes and not content_changes,
|
|
111
|
+
return {"passed": not errors and not added and not removed and not changes and not content_changes and not unstable,
|
|
103
112
|
"errors": errors, "added": added, "removed": removed, "changed": changes,
|
|
104
113
|
"contentChanged": content_changes,
|
|
114
|
+
"unstable": unstable,
|
|
105
115
|
"contentNotCompared": content_not_compared,
|
|
106
116
|
"excludedQueryUrls": query_urls}
|
|
107
117
|
|