@topy-ai/maggie 0.7.40 → 0.7.41
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 +29 -4
- package/README.md +35 -1
- package/bin/maggie.js +24 -5
- package/bundled-contracts/maggie-clone/interaction-state-v1.schema.json +26 -0
- package/bundled-contracts/maggie-content/provenance-v1.schema.json +20 -0
- package/bundled-contracts/maggie-design/brand-kit-v1.schema.json +18 -0
- package/bundled-contracts/maggie-design/browser-interactions-v1.schema.json +27 -0
- package/bundled-contracts/maggie-design/style-editing-v1.schema.json +35 -0
- package/bundled-contracts/maggie-media/image-generation-policy-v1.json +28 -0
- package/bundled-contracts/maggie-media/video-generation-policy-v1.json +40 -0
- package/bundled-contracts/maggie-media/video-job-v1.schema.json +20 -0
- package/bundled-contracts/maggie-media/video-playback-evidence-v1.schema.json +15 -0
- package/bundled-contracts/maggie-ops/npm11-preflight-v1.schema.json +17 -0
- package/bundled-contracts/maggie-scaffold/host-scaffold-v1.schema.json +25 -0
- package/bundled-contracts/maggie-seo/gsc-readiness-v1.schema.json +19 -0
- package/bundled-contracts/maggie-service-booking/delivery-provider-default-v1.json +8 -0
- package/bundled-contracts/maggie-service-booking/delivery-provider-v1.schema.json +16 -0
- package/bundled-contracts/maggiedash/browser-session-v1.schema.json +18 -0
- package/bundled-contracts/maggiedash/content-overrides-v1.schema.json +19 -0
- package/bundled-contracts/maggiedash/public-session-cache-v1.schema.json +17 -0
- package/bundled-references/browser-inspection.md +21 -0
- package/bundled-skills/maggie-blog/SKILL.md +12 -0
- package/bundled-skills/maggie-blog-bootstrap/SKILL.md +23 -0
- package/bundled-skills/maggie-booking/SKILL.md +15 -0
- package/bundled-skills/maggie-clone/SKILL.md +13 -0
- package/bundled-skills/maggie-deployment/SKILL.md +6 -0
- package/bundled-skills/maggie-design/SKILL.md +29 -3
- package/bundled-skills/maggie-ops/SKILL.md +12 -0
- package/bundled-skills/maggie-seo-geo/SKILL.md +33 -0
- package/bundled-tools/clis/maggie_analytics.py +43 -1
- package/bundled-tools/clis/maggie_browser_audit.py +99 -3
- package/bundled-tools/clis/maggie_clone.py +46 -1
- package/bundled-tools/clis/maggie_contracts.py +111 -0
- package/bundled-tools/clis/maggie_design.py +55 -0
- package/bundled-tools/clis/maggie_workflows.py +387 -0
- package/bundled-tools/clis/site_audit.py +28 -1
- package/bundled-tools/integrations/analytics.md +14 -0
- package/bundled-tools/runtime/site_baseline.py +3 -0
- package/package.json +1 -1
- package/references/browser-inspection.md +21 -0
|
@@ -10,6 +10,7 @@ from __future__ import annotations
|
|
|
10
10
|
|
|
11
11
|
import argparse
|
|
12
12
|
import hashlib
|
|
13
|
+
import html as html_lib
|
|
13
14
|
import json
|
|
14
15
|
import re
|
|
15
16
|
import shutil
|
|
@@ -82,6 +83,50 @@ def rendered_asset_preflight(template: Path) -> list[str]:
|
|
|
82
83
|
return errors
|
|
83
84
|
|
|
84
85
|
|
|
86
|
+
def source_anchor_start(source: str, tag: str, reported_offset: int) -> int:
|
|
87
|
+
"""Resolve an Astro compiler offset to the opening bracket of an element."""
|
|
88
|
+
if not re.fullmatch(r"[A-Za-z][A-Za-z0-9:-]*", tag):
|
|
89
|
+
raise ValueError("tag name is invalid")
|
|
90
|
+
bound = max(0, min(len(source), int(reported_offset) + 1))
|
|
91
|
+
start = source.rfind("<" + tag, 0, bound)
|
|
92
|
+
if start < 0:
|
|
93
|
+
raise ValueError(f"opening tag not found near compiler offset: {tag}")
|
|
94
|
+
return start
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def decode_content_text(value: str) -> str:
|
|
98
|
+
"""Decode markup entities once and refuse unknown/double-escaped entities."""
|
|
99
|
+
decoded = html_lib.unescape(value)
|
|
100
|
+
if re.search(r"&(?:[A-Za-z][A-Za-z0-9]+|#\d+|#x[0-9A-Fa-f]+);", decoded):
|
|
101
|
+
raise ValueError("text contains an unknown or double-escaped HTML entity")
|
|
102
|
+
return decoded
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def markup_check(html_path: Path, require_keys: bool) -> int:
|
|
106
|
+
source = html_path.read_text(encoding="utf-8")
|
|
107
|
+
errors: list[str] = []
|
|
108
|
+
if re.search(r"\bstyle\s*=\s*([\"'])\s*\1", source, re.I):
|
|
109
|
+
errors.append("empty style attributes must be omitted")
|
|
110
|
+
keys = re.findall(r"\bdata-maggie-content-key\s*=\s*[\"']([^\"']+)[\"']", source, re.I)
|
|
111
|
+
duplicates = sorted({key for key in keys if keys.count(key) > 1})
|
|
112
|
+
if duplicates:
|
|
113
|
+
errors.append("duplicate content keys: " + ", ".join(duplicates))
|
|
114
|
+
if require_keys:
|
|
115
|
+
for match in re.finditer(r"<(p|h[1-6]|li|button|a|span)\b([^>]*)>([^<>]+)</\1>", source, re.I | re.S):
|
|
116
|
+
attrs, text = match.group(2), " ".join(match.group(3).split())
|
|
117
|
+
if text and not re.search(r"\bdata-maggie-content-key\s*=", attrs, re.I):
|
|
118
|
+
errors.append(f"editable text node lacks data-maggie-content-key: {match.group(1)}")
|
|
119
|
+
try:
|
|
120
|
+
decode_content_text(match.group(3))
|
|
121
|
+
except ValueError as error:
|
|
122
|
+
errors.append(str(error))
|
|
123
|
+
result = {"schemaVersion": "maggie-design-markup.v1", "passed": not errors,
|
|
124
|
+
"errors": errors, "contentKeyCount": len(keys), "requireKeys": require_keys,
|
|
125
|
+
"source": str(html_path.resolve())}
|
|
126
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
127
|
+
return 0 if not errors else 1
|
|
128
|
+
|
|
129
|
+
|
|
85
130
|
def _surface_list(value: str) -> list[str]:
|
|
86
131
|
surfaces = [item.strip().lower() for item in value.split(",") if item.strip()]
|
|
87
132
|
invalid = sorted(set(surfaces) - set(REFERENCE_SURFACES))
|
|
@@ -808,6 +853,7 @@ commands:
|
|
|
808
853
|
status show a design job and its per-step progress
|
|
809
854
|
resume restart a failed design workflow
|
|
810
855
|
author plan an original first-party page
|
|
856
|
+
markup-check validate optional style attributes, content keys, and entities
|
|
811
857
|
reference-ui, init, validate-ui, app-init, app-validate, rebrand, review
|
|
812
858
|
""")
|
|
813
859
|
return 0
|
|
@@ -823,6 +869,15 @@ commands:
|
|
|
823
869
|
return reference_ui(args.project, args.reference, _surface_list(args.surface), args.screenshots_dir, args.confirm)
|
|
824
870
|
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
825
871
|
print(f"BLOCKED: maggie-design reference-ui: {error}", file=sys.stderr); return 1
|
|
872
|
+
if len(sys.argv) > 1 and sys.argv[1] == "markup-check":
|
|
873
|
+
command = argparse.ArgumentParser(description="Validate generated markup before it is made editable.")
|
|
874
|
+
command.add_argument("--html", type=Path, required=True)
|
|
875
|
+
command.add_argument("--require-content-keys", action="store_true")
|
|
876
|
+
args = command.parse_args(sys.argv[2:])
|
|
877
|
+
try:
|
|
878
|
+
return markup_check(args.html, args.require_content_keys)
|
|
879
|
+
except (OSError, ValueError) as error:
|
|
880
|
+
print(f"BLOCKED: maggie-design markup-check: {error}", file=sys.stderr); return 1
|
|
826
881
|
if len(sys.argv) > 1 and sys.argv[1] == "init":
|
|
827
882
|
command = argparse.ArgumentParser(description="Initialize a native blog or service UI plan from a sanitized reference manifest.")
|
|
828
883
|
command.add_argument("--project", type=Path, default=Path.cwd())
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Stable, read-only policy and evidence gates for deferred Maggie workflows."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import hashlib
|
|
8
|
+
import html
|
|
9
|
+
import json
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import tempfile
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
ROOT = Path(__file__).resolve().parents[2]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def contract_root() -> Path:
|
|
21
|
+
tool_path = Path(__file__).resolve()
|
|
22
|
+
bundled_tools = next((path for path in tool_path.parents if path.name == "bundled-tools"), None)
|
|
23
|
+
if bundled_tools:
|
|
24
|
+
return bundled_tools.parent / "bundled-contracts"
|
|
25
|
+
return ROOT / "contracts"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def now() -> str:
|
|
29
|
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def load(path: Path) -> dict:
|
|
33
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
34
|
+
if not isinstance(value, dict):
|
|
35
|
+
raise ValueError(f"{path} must contain a JSON object")
|
|
36
|
+
return value
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def emit(value: dict, output: str | None = None) -> int:
|
|
40
|
+
if output:
|
|
41
|
+
target = Path(output).expanduser().resolve()
|
|
42
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
target.write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
44
|
+
print(json.dumps(value, indent=2, ensure_ascii=False))
|
|
45
|
+
return 0 if value.get("passed") is True else 1
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def validate_media(args: argparse.Namespace) -> int:
|
|
49
|
+
policy = load(Path(args.policy).expanduser().resolve())
|
|
50
|
+
kind = args.kind
|
|
51
|
+
expected = f"maggie-{kind}-generation-policy.v1"
|
|
52
|
+
errors: list[str] = []
|
|
53
|
+
if policy.get("schemaVersion") != expected:
|
|
54
|
+
errors.append(f"schemaVersion must be {expected}")
|
|
55
|
+
if policy.get("provider") != "google-gemini":
|
|
56
|
+
errors.append("provider must be google-gemini")
|
|
57
|
+
if not str(policy.get("model", "")).startswith("gemini-"):
|
|
58
|
+
errors.append("model must be a Gemini model id")
|
|
59
|
+
if not str(policy.get("fallbackModel", "")).startswith(("gemini-", "veo-")):
|
|
60
|
+
errors.append("fallbackModel must be a Gemini or Veo model id")
|
|
61
|
+
if policy.get("endpointEnv") != "GEMINI_API_ENDPOINT" or policy.get("apiKeyEnv") != "GEMINI_API_KEY":
|
|
62
|
+
errors.append("Gemini endpoint and key must remain host environment names")
|
|
63
|
+
rights = policy.get("rights") if isinstance(policy.get("rights"), dict) else {}
|
|
64
|
+
moderation = policy.get("moderation") if isinstance(policy.get("moderation"), dict) else {}
|
|
65
|
+
for key in ("inputRightsRequired", "outputReviewRequired"):
|
|
66
|
+
if rights.get(key) is not True:
|
|
67
|
+
errors.append(f"rights.{key} must be true")
|
|
68
|
+
for key in ("preflightRequired", "postGenerationReviewRequired"):
|
|
69
|
+
if moderation.get(key) is not True:
|
|
70
|
+
errors.append(f"moderation.{key} must be true")
|
|
71
|
+
if kind == "image":
|
|
72
|
+
outputs = policy.get("outputs") if isinstance(policy.get("outputs"), dict) else {}
|
|
73
|
+
if not outputs.get("allowedMimeTypes") or not outputs.get("allowedSizes"):
|
|
74
|
+
errors.append("image outputs must declare mime types and sizes")
|
|
75
|
+
else:
|
|
76
|
+
cost = policy.get("costPolicy") if isinstance(policy.get("costPolicy"), dict) else {}
|
|
77
|
+
storage = policy.get("storage") if isinstance(policy.get("storage"), dict) else {}
|
|
78
|
+
if cost.get("budgetRequired") is not True or cost.get("duplicateChargeProtection") != "idempotency-key":
|
|
79
|
+
errors.append("video cost policy must require a budget and idempotency protection")
|
|
80
|
+
if storage.get("adapter") != "host-owned-object-storage":
|
|
81
|
+
errors.append("video storage must remain host-owned-object-storage")
|
|
82
|
+
provenance = policy.get("provenance")
|
|
83
|
+
if not isinstance(provenance, list) or not {"model", "sourceRevision", "createdAt"}.issubset(provenance):
|
|
84
|
+
errors.append("policy provenance must include model, sourceRevision, and createdAt")
|
|
85
|
+
return emit({"schemaVersion": expected, "passed": not errors, "kind": kind, "provider": policy.get("provider"), "model": policy.get("model"), "fallbackModel": policy.get("fallbackModel"), "errors": errors, "credentialsPrinted": False}, args.output)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def validate_video_job(args: argparse.Namespace) -> int:
|
|
89
|
+
job = load(Path(args.job).expanduser().resolve())
|
|
90
|
+
errors: list[str] = []
|
|
91
|
+
if job.get("schemaVersion") != "maggie-video-job.v1":
|
|
92
|
+
errors.append("schemaVersion must be maggie-video-job.v1")
|
|
93
|
+
for key in ("jobId", "idempotencyKey", "sourceRevision", "provenance"):
|
|
94
|
+
if not job.get(key):
|
|
95
|
+
errors.append(f"{key} is required")
|
|
96
|
+
state = job.get("state")
|
|
97
|
+
attempt = job.get("attempt")
|
|
98
|
+
if state == "retrying" and (not isinstance(attempt, int) or attempt < 1 or attempt > 3):
|
|
99
|
+
errors.append("retrying jobs need attempt between 1 and 3")
|
|
100
|
+
if state == "dead-letter" and job.get("errorClass") not in {"provider-permanent", "moderation", "rights", "validation", "cost-limit", "unknown"}:
|
|
101
|
+
errors.append("dead-letter jobs need a terminal errorClass")
|
|
102
|
+
if state == "completed" and not isinstance(job.get("storage"), dict):
|
|
103
|
+
errors.append("completed jobs need host storage evidence")
|
|
104
|
+
return emit({"schemaVersion": "maggie-video-job.v1", "passed": not errors, "jobId": job.get("jobId"), "state": state, "errors": errors, "rawProviderPayloadIncluded": False}, args.output)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def git_value(project: Path, *args: str) -> str:
|
|
108
|
+
result = subprocess.run(["git", "-C", str(project), *args], capture_output=True, text=True, check=False)
|
|
109
|
+
if result.returncode:
|
|
110
|
+
raise ValueError(result.stderr.strip() or "not a git worktree")
|
|
111
|
+
return result.stdout.strip()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def capture_provenance(args: argparse.Namespace) -> int:
|
|
115
|
+
project = Path(args.project).expanduser().resolve()
|
|
116
|
+
source = Path(args.source).expanduser().resolve() if args.source else project
|
|
117
|
+
try:
|
|
118
|
+
relative = source.relative_to(project).as_posix() or "."
|
|
119
|
+
except ValueError as error:
|
|
120
|
+
raise ValueError("--source must be inside --project") from error
|
|
121
|
+
if any(part in {".env", ".git", "node_modules"} for part in source.parts):
|
|
122
|
+
raise ValueError("source cannot be a secret, git metadata, or dependency path")
|
|
123
|
+
commit = git_value(project, "rev-parse", "HEAD")
|
|
124
|
+
branch = git_value(project, "branch", "--show-current") or "detached"
|
|
125
|
+
dirty = bool(git_value(project, "status", "--porcelain", "--", relative))
|
|
126
|
+
tracked = subprocess.run(["git", "-C", str(project), "ls-files", "--error-unmatch", relative], capture_output=True, text=True, check=False).returncode == 0
|
|
127
|
+
value = {"schemaVersion": "maggie-content-provenance.v1", "commit": commit, "branch": branch, "dirty": dirty, "sourceRevision": commit, "sourcePath": relative, "authoringBoundary": args.boundary, "tracked": tracked, "staleWritePolicy": "reject-unless-source-revision-matches", "capturedAt": now()}
|
|
128
|
+
# capturedAt is useful in a report but not part of the contract artifact.
|
|
129
|
+
return emit({**value, "passed": True}, args.output)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
FRAMEWORK_PACKAGES = {
|
|
133
|
+
"astro": ["astro"],
|
|
134
|
+
"nextjs": ["next", "react", "react-dom"],
|
|
135
|
+
"sveltekit": ["@sveltejs/kit"],
|
|
136
|
+
"nuxt": ["nuxt"],
|
|
137
|
+
"vite-react": ["vite", "react", "react-dom"],
|
|
138
|
+
"vite-vue": ["vite", "vue"],
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
NPM_SUPPORT_MATRIX = [
|
|
142
|
+
{"packageManager": "npm", "supportedVersions": ["11.x"], "installCommand": "npm install", "lockfile": "package-lock-v3", "lifecyclePolicy": "ignore-scripts-smoke-first"},
|
|
143
|
+
{"packageManager": "pnpm", "supportedVersions": ["9.x", "10.x"], "installCommand": "pnpm install", "lockfile": "pnpm-lock.yaml", "lifecyclePolicy": "ignore-scripts-smoke-first"},
|
|
144
|
+
{"packageManager": "yarn", "supportedVersions": ["1.x", "4.x"], "installCommand": "yarn install", "lockfile": "yarn.lock", "lifecyclePolicy": "ignore-scripts-smoke-first"},
|
|
145
|
+
{"packageManager": "bun", "supportedVersions": ["1.x"], "installCommand": "bun install", "lockfile": "bun.lock", "lifecyclePolicy": "ignore-scripts-smoke-first"},
|
|
146
|
+
]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def scaffold_files(framework: str, language: str) -> dict[str, str]:
|
|
150
|
+
typed = language == "typescript"
|
|
151
|
+
if framework == "astro":
|
|
152
|
+
return {
|
|
153
|
+
"astro.config.mjs": "import { defineConfig } from 'astro/config';\n\nexport default defineConfig();\n",
|
|
154
|
+
f"src/pages/index.{'astro'}": "---\nconst title = 'Maggie project';\n---\n<html lang=\"en\">\n <head><meta charset=\"utf-8\" /><meta name=\"viewport\" content=\"width=device-width\" /><title>{title}</title></head>\n <body><main><h1>{title}</h1><p>Generated by Maggie.</p></main></body>\n</html>\n",
|
|
155
|
+
}
|
|
156
|
+
if framework == "nextjs":
|
|
157
|
+
extension = "tsx" if typed else "jsx"
|
|
158
|
+
return {
|
|
159
|
+
f"app/layout.{extension}": "export default function RootLayout({ children }) { return <html lang=\"en\"><body>{children}</body></html>; }\n",
|
|
160
|
+
f"app/page.{extension}": "export default function Home() { return <main><h1>Maggie project</h1><p>Generated by Maggie.</p></main>; }\n",
|
|
161
|
+
"next.config.mjs": "/** @type {import('next').NextConfig} */\nconst nextConfig = {};\nexport default nextConfig;\n",
|
|
162
|
+
}
|
|
163
|
+
if framework == "sveltekit":
|
|
164
|
+
return {
|
|
165
|
+
"src/routes/+page.svelte": "<svelte:head><title>Maggie project</title></svelte:head>\n<main><h1>Maggie project</h1><p>Generated by Maggie.</p></main>\n",
|
|
166
|
+
"svelte.config.js": "import adapter from '@sveltejs/adapter-auto';\nexport default { kit: { adapter: adapter() } };\n",
|
|
167
|
+
"vite.config.js": "import { sveltekit } from '@sveltejs/kit/vite';\nimport { defineConfig } from 'vite';\nexport default defineConfig({ plugins: [sveltekit()] });\n",
|
|
168
|
+
}
|
|
169
|
+
if framework == "nuxt":
|
|
170
|
+
extension = "ts" if typed else "js"
|
|
171
|
+
return {
|
|
172
|
+
f"pages/index.vue": "<template><main><h1>Maggie project</h1><p>Generated by Maggie.</p></main></template>\n",
|
|
173
|
+
f"nuxt.config.{extension}": "export default defineNuxtConfig({});\n",
|
|
174
|
+
}
|
|
175
|
+
if framework == "vite-react":
|
|
176
|
+
extension = "tsx" if typed else "jsx"
|
|
177
|
+
return {
|
|
178
|
+
"index.html": "<div id=\"root\"></div><script type=\"module\" src=\"/src/main.%s\"></script>\n" % extension,
|
|
179
|
+
f"src/main.{extension}": "import React from 'react';\nimport { createRoot } from 'react-dom/client';\nimport App from './App';\nimport './style.css';\ncreateRoot(document.getElementById('root')).render(<React.StrictMode><App /></React.StrictMode>);\n",
|
|
180
|
+
f"src/App.{extension}": "export default function App() { return <main><h1>Maggie project</h1><p>Generated by Maggie.</p></main>; }\n",
|
|
181
|
+
"src/style.css": "body { margin: 0; font-family: system-ui, sans-serif; } main { padding: 3rem; }\n",
|
|
182
|
+
}
|
|
183
|
+
if framework == "vite-vue":
|
|
184
|
+
return {
|
|
185
|
+
"index.html": "<div id=\"app\"></div><script type=\"module\" src=\"/src/main.js\"></script>\n",
|
|
186
|
+
"src/main.js": "import { createApp } from 'vue';\nimport App from './App.vue';\nimport './style.css';\ncreateApp(App).mount('#app');\n",
|
|
187
|
+
"src/App.vue": "<template><main><h1>Maggie project</h1><p>Generated by Maggie.</p></main></template>\n",
|
|
188
|
+
"src/style.css": "body { margin: 0; font-family: system-ui, sans-serif; } main { padding: 3rem; }\n",
|
|
189
|
+
}
|
|
190
|
+
raise ValueError(f"unsupported scaffold framework: {framework}")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def package_manifest(project: Path, framework: str, language: str, package_manager: str, packages: list[str]) -> dict:
|
|
194
|
+
extension = "ts" if language == "typescript" else "js"
|
|
195
|
+
scripts = {"dev": "astro dev", "build": "astro build", "preview": "astro preview"} if framework == "astro" else {
|
|
196
|
+
"dev": "next dev", "build": "next build", "start": "next start"} if framework == "nextjs" else {
|
|
197
|
+
"dev": "vite dev", "build": "vite build", "preview": "vite preview"}
|
|
198
|
+
if framework == "sveltekit": scripts = {"dev": "vite dev", "build": "vite build", "preview": "vite preview"}
|
|
199
|
+
if framework == "nuxt": scripts = {"dev": "nuxt dev", "build": "nuxt build", "preview": "nuxt preview"}
|
|
200
|
+
dev_dependencies = {package: "latest" for package in packages}
|
|
201
|
+
return {"name": project.name.lower().replace("_", "-") or "maggie-project", "private": True, "type": "module", "scripts": scripts, "packageManager": package_manager, "maggie": {"framework": framework, "language": language, "entryExtension": extension}, "devDependencies": dev_dependencies}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def scaffold(args: argparse.Namespace) -> int:
|
|
205
|
+
project = Path(args.project).expanduser().resolve()
|
|
206
|
+
packages = FRAMEWORK_PACKAGES[args.framework]
|
|
207
|
+
generated = scaffold_files(args.framework, args.language)
|
|
208
|
+
manifest = {"schemaVersion": "maggie-host-scaffold.v1", "framework": args.framework, "language": args.language, "packageManager": args.package_manager, "installPolicy": {"requiresConfirm": True, "secretsByDefault": "never", "migrationsByDefault": "never", "installDependencies": bool(args.install_dependencies)}, "hostHandoff": {"generatedFiles": [".maggie/scaffold/manifest.json", *sorted(generated), "package.json"], "rollbackPlan": "remove generated files only and revert only the explicit dependency diff", "nextCommands": [f"maggie doctor --project {project}", "maggie ops preflight --project ."]}, "packages": packages, "createdAt": now()}
|
|
209
|
+
errors: list[str] = []
|
|
210
|
+
if args.install_dependencies and not args.confirm:
|
|
211
|
+
errors.append("--install-dependencies requires --confirm")
|
|
212
|
+
if args.write:
|
|
213
|
+
if errors:
|
|
214
|
+
return emit({"schemaVersion": "maggie-host-scaffold.v1", "passed": False, "errors": errors, "mutation": "not executed"}, args.output)
|
|
215
|
+
existing = [str(project / relative) for relative in [*generated, "package.json"] if (project / relative).exists()]
|
|
216
|
+
if existing and not args.force:
|
|
217
|
+
errors.append(f"refusing to overwrite existing scaffold files; use --force: {', '.join(existing[:5])}")
|
|
218
|
+
return emit({**manifest, "passed": False, "mutation": "not executed", "errors": errors}, args.output)
|
|
219
|
+
project.mkdir(parents=True, exist_ok=True)
|
|
220
|
+
package_path = project / "package.json"
|
|
221
|
+
package_path.write_text(json.dumps(package_manifest(project, args.framework, args.language, args.package_manager, packages), indent=2) + "\n", encoding="utf-8")
|
|
222
|
+
for relative, content in generated.items():
|
|
223
|
+
target = project / relative
|
|
224
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
225
|
+
target.write_text(content, encoding="utf-8")
|
|
226
|
+
target = project / ".maggie" / "scaffold" / "manifest.json"
|
|
227
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
228
|
+
target.write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
229
|
+
if args.install_dependencies:
|
|
230
|
+
install_cmd = [args.package_manager, "add" if args.package_manager in {"pnpm", "yarn", "bun"} else "install", *packages]
|
|
231
|
+
if args.package_manager == "npm":
|
|
232
|
+
install_cmd = ["npm", "install", *packages]
|
|
233
|
+
subprocess.run(install_cmd, cwd=project, check=True)
|
|
234
|
+
return emit({**manifest, "passed": not errors, "mutation": "executed" if args.write else "plan-only", "errors": errors}, args.output)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def validate_brand(args: argparse.Namespace) -> int:
|
|
238
|
+
kit = load(Path(args.manifest).expanduser().resolve())
|
|
239
|
+
errors: list[str] = []
|
|
240
|
+
required = {"schemaVersion": "maggie-brand-kit.v1", "designBoundary": "DESIGN.md-and-host-tokens-are-source-of-truth"}
|
|
241
|
+
for key, expected in required.items():
|
|
242
|
+
if kit.get(key) != expected:
|
|
243
|
+
errors.append(f"{key} must be {expected}")
|
|
244
|
+
for key in ("designTokens", "logo", "typography", "icons", "assetManifest", "preview"):
|
|
245
|
+
if not kit.get(key):
|
|
246
|
+
errors.append(f"{key} is required")
|
|
247
|
+
if isinstance(kit.get("icons"), dict) and kit["icons"].get("policy") not in {"library-first", "host-defined"}:
|
|
248
|
+
errors.append("icons.policy must declare library-first or host-defined")
|
|
249
|
+
return emit({"schemaVersion": "maggie-brand-kit.v1", "passed": not errors, "errors": errors, "designTokensMutated": False}, args.output)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def token_values(design_path: Path, token_path: Path | None) -> dict:
|
|
253
|
+
if token_path:
|
|
254
|
+
return load(token_path)
|
|
255
|
+
content = design_path.read_text(encoding="utf-8")
|
|
256
|
+
found = {name: value.strip() for name, value in __import__("re").findall(r"(--[A-Za-z0-9_-]+)\s*:\s*([^;\n]+)", content)}
|
|
257
|
+
return found or {"source": str(design_path), "contentHash": hashlib.sha256(content.encode()).hexdigest()}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def brand_kit(args: argparse.Namespace) -> int:
|
|
261
|
+
project = Path(args.project).expanduser().resolve()
|
|
262
|
+
design_path = Path(args.design).expanduser().resolve() if args.design else project / "DESIGN.md"
|
|
263
|
+
token_path = Path(args.tokens).expanduser().resolve() if args.tokens else None
|
|
264
|
+
errors: list[str] = []
|
|
265
|
+
if not design_path.is_file(): errors.append(f"DESIGN.md is missing: {design_path}")
|
|
266
|
+
if token_path and not token_path.is_file(): errors.append(f"tokens file is missing: {token_path}")
|
|
267
|
+
if errors: return emit({"schemaVersion": "maggie-brand-kit.v1", "passed": False, "errors": errors, "designTokensMutated": False}, args.output)
|
|
268
|
+
design_tokens = token_values(design_path, token_path)
|
|
269
|
+
assets: list[str] = []
|
|
270
|
+
if args.assets:
|
|
271
|
+
asset_root = Path(args.assets).expanduser().resolve()
|
|
272
|
+
if not asset_root.is_dir(): errors.append(f"asset directory is missing: {asset_root}")
|
|
273
|
+
else: assets = [str(path.relative_to(project)) if path.is_relative_to(project) else str(path) for path in sorted(asset_root.rglob("*")) if path.is_file()]
|
|
274
|
+
kit = {"schemaVersion": "maggie-brand-kit.v1", "designTokens": design_tokens, "logo": {"primary": args.logo or "host-defined", "variants": ["primary", "monochrome"]}, "typography": {"families": [args.font or "host-defined"], "weights": [400, 500, 600, 700]}, "icons": {"library": args.icon_library, "policy": "library-first"}, "assetManifest": ".maggie/brand-kit/assets.json", "preview": ".maggie/brand-kit/preview.html", "designBoundary": "DESIGN.md-and-host-tokens-are-source-of-truth", "sources": {"design": str(design_path), "tokens": str(token_path) if token_path else None}}
|
|
275
|
+
if args.write:
|
|
276
|
+
if not args.confirm: errors.append("--write requires --confirm")
|
|
277
|
+
if not errors:
|
|
278
|
+
target = project / ".maggie" / "brand-kit"
|
|
279
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
280
|
+
(target / "brand-kit.json").write_text(json.dumps(kit, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
281
|
+
(target / "assets.json").write_text(json.dumps({"schemaVersion": "maggie-brand-assets.v1", "assets": assets, "redacted": True}, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
282
|
+
swatches = "".join(f"<li><code>{html.escape(str(key))}</code><span style=\"background:{html.escape(str(value))}\"></span><small>{html.escape(str(value))}</small></li>" for key, value in design_tokens.items() if isinstance(value, (str, int, float)))
|
|
283
|
+
preview = "<!doctype html><meta charset=\"utf-8\"><title>Maggie brand kit preview</title><style>body{font:16px system-ui,sans-serif;max-width:60rem;margin:2rem auto;padding:0 1rem;color:#172033}ul{list-style:none;padding:0}li{display:flex;gap:.75rem;align-items:center;padding:.5rem 0;border-bottom:1px solid #ddd}li span{width:2rem;height:2rem;border:1px solid #aaa;border-radius:.35rem}small{color:#5b6472}</style><main><h1>Maggie brand kit</h1><p>Read-only preview generated from the approved DESIGN source.</p><h2>Design tokens</h2><ul>" + swatches + f"</ul><p>Logo: <code>{html.escape(str(kit['logo']['primary']))}</code></p><p>Icon library: <code>{html.escape(args.icon_library)}</code></p><p>Assets listed: {len(assets)}</p></main>\n"
|
|
284
|
+
(target / "preview.html").write_text(preview, encoding="utf-8")
|
|
285
|
+
return emit({**kit, "passed": not errors, "mutation": "executed" if args.write and not errors else "plan-only", "errors": errors, "designTokensMutated": False}, args.output)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def npm11(args: argparse.Namespace) -> int:
|
|
289
|
+
project = Path(args.project).expanduser().resolve()
|
|
290
|
+
package_path = project / "package.json"
|
|
291
|
+
errors: list[str] = []
|
|
292
|
+
package: dict = {}
|
|
293
|
+
if not package_path.is_file():
|
|
294
|
+
errors.append("package.json is missing")
|
|
295
|
+
else:
|
|
296
|
+
package = load(package_path)
|
|
297
|
+
engines = package.get("engines") if isinstance(package.get("engines"), dict) else {}
|
|
298
|
+
scripts = package.get("scripts") if isinstance(package.get("scripts"), dict) else {}
|
|
299
|
+
node_version = subprocess.run(["node", "--version"], capture_output=True, text=True, check=False).stdout.strip()
|
|
300
|
+
npm_version = subprocess.run(["npm", "--version"], capture_output=True, text=True, check=False).stdout.strip()
|
|
301
|
+
try:
|
|
302
|
+
npm_major = int(npm_version.split(".", 1)[0])
|
|
303
|
+
except (ValueError, IndexError):
|
|
304
|
+
npm_major = None
|
|
305
|
+
required_node = str(engines.get("node") or ">=18")
|
|
306
|
+
lifecycle_scripts = sorted(set(scripts).intersection({"install", "preinstall", "postinstall"}))
|
|
307
|
+
if lifecycle_scripts and not (args.smoke and args.confirm):
|
|
308
|
+
errors.append("install lifecycle scripts require an explicit audited release decision")
|
|
309
|
+
if npm_major is not None and npm_major < 11:
|
|
310
|
+
errors.append("npm 11 is required for this preflight")
|
|
311
|
+
smoke = {"requested": bool(args.smoke), "ignoreScripts": True, "passed": not errors, "command": None, "stderr": None}
|
|
312
|
+
if args.smoke:
|
|
313
|
+
if not args.confirm: errors.append("--smoke requires --confirm because it performs an isolated dependency install")
|
|
314
|
+
elif not package_path.is_file(): errors.append("--smoke requires package.json")
|
|
315
|
+
else:
|
|
316
|
+
with tempfile.TemporaryDirectory(prefix="maggie-npm11-") as temporary:
|
|
317
|
+
sandbox = Path(temporary)
|
|
318
|
+
shutil.copy2(package_path, sandbox / "package.json")
|
|
319
|
+
if (project / "package-lock.json").is_file(): shutil.copy2(project / "package-lock.json", sandbox / "package-lock.json")
|
|
320
|
+
command = ["npm", "install", "--ignore-scripts", "--no-audit", "--no-fund", "--package-lock=false"]
|
|
321
|
+
proc = subprocess.run(command, cwd=sandbox, capture_output=True, text=True, check=False, timeout=300)
|
|
322
|
+
smoke.update({"command": "npm install --ignore-scripts --no-audit --no-fund --package-lock=false", "exitCode": proc.returncode, "stderr": proc.stderr[-500:] if proc.stderr else None, "passed": proc.returncode == 0})
|
|
323
|
+
if proc.returncode: errors.append("isolated npm install smoke failed")
|
|
324
|
+
result = {"schemaVersion": "maggie-npm11-preflight.v1", "supportMatrix": NPM_SUPPORT_MATRIX, "node": {"supportedMajor": required_node, "observedMajor": node_version}, "npm": {"supportedMajor": 11, "observedMajor": npm_version}, "installScriptPolicy": "ignore-scripts-smoke-first", "lockfilePolicy": "package-lock-v3" if (project / "package-lock.json").exists() else "host-package-manager", "smoke": smoke, "scripts": sorted(scripts), "lifecycleScripts": lifecycle_scripts, "errors": errors, "passed": not errors}
|
|
325
|
+
return emit(result, args.output)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def validate_booking_delivery(args: argparse.Namespace) -> int:
|
|
329
|
+
policy = load(Path(args.policy).expanduser().resolve())
|
|
330
|
+
errors: list[str] = []
|
|
331
|
+
if policy.get("schemaVersion") != "maggie-booking-delivery-provider.v1":
|
|
332
|
+
errors.append("schemaVersion must be maggie-booking-delivery-provider.v1")
|
|
333
|
+
if policy.get("confirmationRule") != "provider-acknowledged-only":
|
|
334
|
+
errors.append("confirmationRule must be provider-acknowledged-only")
|
|
335
|
+
email = policy.get("email") if isinstance(policy.get("email"), dict) else {}
|
|
336
|
+
sms = policy.get("sms") if isinstance(policy.get("sms"), dict) else {}
|
|
337
|
+
if email.get("provider") not in {"resend", "provider-neutral"} or email.get("channel") != "email":
|
|
338
|
+
errors.append("email must use Resend or provider-neutral email")
|
|
339
|
+
if sms.get("provider") not in {"twilio", "provider-neutral"} or sms.get("channel") != "sms":
|
|
340
|
+
errors.append("sms must use Twilio or provider-neutral SMS")
|
|
341
|
+
retry = policy.get("retryPolicy") if isinstance(policy.get("retryPolicy"), dict) else {}
|
|
342
|
+
if retry.get("idempotency") != "booking-event-and-channel" or not isinstance(retry.get("maxAttempts"), int):
|
|
343
|
+
errors.append("retry policy must be idempotent and bounded")
|
|
344
|
+
dead = policy.get("deadLetterPolicy") if isinstance(policy.get("deadLetterPolicy"), dict) else {}
|
|
345
|
+
if dead.get("afterMaxAttempts") != "dead-letter" or dead.get("replayRequiresExplicitAction") is not True:
|
|
346
|
+
errors.append("dead-letter policy must require explicit replay")
|
|
347
|
+
return emit({"schemaVersion": "maggie-booking-delivery-provider.v1", "passed": not errors, "emailProvider": email.get("provider"), "smsProvider": sms.get("provider"), "errors": errors, "credentialsPrinted": False}, args.output)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def validate_playback(args: argparse.Namespace) -> int:
|
|
351
|
+
evidence = load(Path(args.evidence).expanduser().resolve())
|
|
352
|
+
errors: list[str] = []
|
|
353
|
+
if evidence.get("schemaVersion") != "maggie-video-playback-evidence.v1": errors.append("schemaVersion must be maggie-video-playback-evidence.v1")
|
|
354
|
+
if not evidence.get("sourceRevision") or not evidence.get("jobId"): errors.append("jobId and sourceRevision are required")
|
|
355
|
+
viewports = evidence.get("viewports") if isinstance(evidence.get("viewports"), list) else []
|
|
356
|
+
required = {"desktop", "tablet", "mobile"}
|
|
357
|
+
if not required.issubset({item.get("viewport") for item in viewports if isinstance(item, dict)}): errors.append("desktop, tablet, and mobile playback evidence are required")
|
|
358
|
+
for item in viewports:
|
|
359
|
+
if not isinstance(item, dict) or item.get("status") != "passed": errors.append("every viewport playback check must pass")
|
|
360
|
+
if isinstance(item, dict) and not item.get("posterVisible"): errors.append("posterVisible is required for each viewport")
|
|
361
|
+
if evidence.get("rawProviderPayloadIncluded") is not False: errors.append("raw provider payloads must not be included")
|
|
362
|
+
return emit({"schemaVersion": "maggie-video-playback-evidence.v1", "passed": not errors, "jobId": evidence.get("jobId"), "viewports": [item.get("viewport") for item in viewports if isinstance(item, dict)], "errors": errors, "rawProviderPayloadIncluded": False}, args.output)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def main() -> int:
|
|
366
|
+
parser = argparse.ArgumentParser(prog="maggie workflows")
|
|
367
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
368
|
+
media = sub.add_parser("media"); media_sub = media.add_subparsers(dest="media_command", required=True)
|
|
369
|
+
for kind, relative in (("image", "maggie-media/image-generation-policy-v1.json"), ("video", "maggie-media/video-generation-policy-v1.json")):
|
|
370
|
+
command = media_sub.add_parser(f"{kind}-policy"); command.add_argument("--policy", default=str(contract_root() / relative)); command.add_argument("--output"); command.set_defaults(func=validate_media, kind=kind)
|
|
371
|
+
job = media_sub.add_parser("video-job"); job.add_argument("--job", required=True); job.add_argument("--output"); job.set_defaults(func=validate_video_job)
|
|
372
|
+
provenance = sub.add_parser("provenance"); provenance.add_argument("--project", default="."); provenance.add_argument("--source"); provenance.add_argument("--boundary", choices=["git-source", "host-runtime", "generated-artifact"], default="git-source"); provenance.add_argument("--output"); provenance.set_defaults(func=capture_provenance)
|
|
373
|
+
scaffold_parser = sub.add_parser("scaffold"); scaffold_parser.add_argument("--project", default="."); scaffold_parser.add_argument("--framework", choices=sorted(FRAMEWORK_PACKAGES), required=True); scaffold_parser.add_argument("--language", choices=["typescript", "javascript"], default="typescript"); scaffold_parser.add_argument("--package-manager", choices=["npm", "pnpm", "yarn", "bun"], default="npm"); scaffold_parser.add_argument("--install-dependencies", action="store_true"); scaffold_parser.add_argument("--write", action="store_true"); scaffold_parser.add_argument("--confirm", action="store_true"); scaffold_parser.add_argument("--force", action="store_true"); scaffold_parser.add_argument("--output"); scaffold_parser.set_defaults(func=scaffold)
|
|
374
|
+
brand = sub.add_parser("brand-kit"); brand.add_argument("--manifest"); brand.add_argument("--project", default="."); brand.add_argument("--design"); brand.add_argument("--tokens"); brand.add_argument("--logo"); brand.add_argument("--font"); brand.add_argument("--icon-library", default="host-icon-library"); brand.add_argument("--assets"); brand.add_argument("--write", action="store_true"); brand.add_argument("--confirm", action="store_true"); brand.add_argument("--output"); brand.set_defaults(func=lambda args: validate_brand(args) if args.manifest else brand_kit(args))
|
|
375
|
+
npm = sub.add_parser("npm11"); npm.add_argument("--project", default="."); npm.add_argument("--smoke", action="store_true"); npm.add_argument("--confirm", action="store_true"); npm.add_argument("--output"); npm.set_defaults(func=npm11)
|
|
376
|
+
booking = sub.add_parser("booking-delivery"); booking.add_argument("--policy", default=str(contract_root() / "maggie-service-booking/delivery-provider-default-v1.json")); booking.add_argument("--output"); booking.set_defaults(func=validate_booking_delivery)
|
|
377
|
+
playback = sub.add_parser("video-playback"); playback.add_argument("--evidence", required=True); playback.add_argument("--output"); playback.set_defaults(func=validate_playback)
|
|
378
|
+
args = parser.parse_args()
|
|
379
|
+
try:
|
|
380
|
+
return args.func(args)
|
|
381
|
+
except (OSError, ValueError, json.JSONDecodeError, subprocess.CalledProcessError) as error:
|
|
382
|
+
print(f"BLOCKED: maggie workflow: {error}", file=__import__("sys").stderr)
|
|
383
|
+
return 1
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
if __name__ == "__main__":
|
|
387
|
+
raise SystemExit(main())
|
|
@@ -8,6 +8,7 @@ import hashlib
|
|
|
8
8
|
import json
|
|
9
9
|
import re
|
|
10
10
|
import sys
|
|
11
|
+
from datetime import datetime, timedelta, timezone
|
|
11
12
|
from pathlib import Path
|
|
12
13
|
from urllib.parse import urljoin, urlparse, urlunparse
|
|
13
14
|
from html.parser import HTMLParser
|
|
@@ -176,6 +177,20 @@ def fetch(url: str, evidence: dict | None = None) -> tuple[int, str, str]:
|
|
|
176
177
|
return response.status, response.headers.get_content_type(), body
|
|
177
178
|
|
|
178
179
|
|
|
180
|
+
def indexability_if_indexed(page: PageParser, robots: list[str]) -> dict:
|
|
181
|
+
"""Explain what a noindex page would fail if it became indexable."""
|
|
182
|
+
tokens = [token.strip().rsplit(":", 1)[-1] for directive in robots for token in directive.split(",")]
|
|
183
|
+
blocked = sorted(set(tokens) & {"noindex", "none"})
|
|
184
|
+
checks = {
|
|
185
|
+
"title": bool(page.title),
|
|
186
|
+
"description": bool(page.meta.get("description")),
|
|
187
|
+
"canonical": bool(page.canonical) and urlparse(page.canonical).fragment == "",
|
|
188
|
+
"headings": bool(page.headings) and page.h1 == 1,
|
|
189
|
+
"jsonld": page.jsonld > 0 and all(item is not None for item in page.jsonld_values),
|
|
190
|
+
}
|
|
191
|
+
return {"applicable": bool(blocked), "blockedBy": blocked, "wouldPass": all(checks.values()) if blocked else None, "checks": checks if blocked else {}}
|
|
192
|
+
|
|
193
|
+
|
|
179
194
|
def audit_page(url: str, html: str, status: int, content_type: str, expected_languages: set[str] | None = None, response_evidence: dict | None = None) -> dict:
|
|
180
195
|
page = PageParser()
|
|
181
196
|
page.feed(html)
|
|
@@ -184,6 +199,7 @@ def audit_page(url: str, html: str, status: int, content_type: str, expected_lan
|
|
|
184
199
|
robots = page.robots_directives + [value.lower() for value in response_evidence["xRobotsTag"]]
|
|
185
200
|
robots_tokens = [token.strip() for directive in robots for token in directive.split(",")]
|
|
186
201
|
robots_tokens = [token.rsplit(":", 1)[-1].strip() for token in robots_tokens]
|
|
202
|
+
hypothetical = indexability_if_indexed(page, robots)
|
|
187
203
|
return {
|
|
188
204
|
"url": url,
|
|
189
205
|
"status": status,
|
|
@@ -236,6 +252,7 @@ def audit_page(url: str, html: str, status: int, content_type: str, expected_lan
|
|
|
236
252
|
"robots_directives": robots,
|
|
237
253
|
"robots_conflict": len({token for token in robots_tokens if token in {"index", "noindex", "follow", "nofollow", "none"}} & {"index", "noindex"}) > 1 or len({token for token in robots_tokens if token in {"follow", "nofollow", "none"}} & {"follow", "nofollow"}) > 1,
|
|
238
254
|
"primary_navigation_links": page.primary_navigation_links,
|
|
255
|
+
"indexabilityIfIndexed": hypothetical,
|
|
239
256
|
}
|
|
240
257
|
|
|
241
258
|
|
|
@@ -448,6 +465,12 @@ def parse_baseline_reasons(values: list[str], drift_urls: set[str], reason_all:
|
|
|
448
465
|
return reasons
|
|
449
466
|
|
|
450
467
|
|
|
468
|
+
def baseline_ack_expiry(days: int) -> str:
|
|
469
|
+
if not 1 <= days <= 3650:
|
|
470
|
+
raise ValueError("baseline acknowledgement TTL must be between 1 and 3650 days")
|
|
471
|
+
return (datetime.now(timezone.utc) + timedelta(days=days)).isoformat().replace("+00:00", "Z")
|
|
472
|
+
|
|
473
|
+
|
|
451
474
|
def main() -> int:
|
|
452
475
|
parser = argparse.ArgumentParser()
|
|
453
476
|
parser.add_argument("url")
|
|
@@ -471,6 +494,7 @@ def main() -> int:
|
|
|
471
494
|
parser.add_argument("--baseline-id", help="required stable ID for --recapture-baseline")
|
|
472
495
|
parser.add_argument("--reason", action="append", default=[], metavar="URL=REASON", help="reviewer-approved reason for one drifting URL; repeat for every drift")
|
|
473
496
|
parser.add_argument("--reason-all", help="reviewer-approved reason applied to every drifting URL during baseline recapture")
|
|
497
|
+
parser.add_argument("--reason-ttl-days", type=int, default=30, help="expiry for a reviewed baseline drift acknowledgement (default: 30)")
|
|
474
498
|
parser.add_argument("--reviewer", help="required for --save-baseline")
|
|
475
499
|
args = parser.parse_args()
|
|
476
500
|
if args.max_pages < 1:
|
|
@@ -503,7 +527,8 @@ def main() -> int:
|
|
|
503
527
|
base = args.url.rstrip("/")
|
|
504
528
|
checks = {}
|
|
505
529
|
try:
|
|
506
|
-
|
|
530
|
+
homepage_response = {}
|
|
531
|
+
status, content_type, html = fetch(base, homepage_response)
|
|
507
532
|
page = PageParser()
|
|
508
533
|
page.feed(html)
|
|
509
534
|
checks["homepage"] = {"ok": status == 200 and content_type == "text/html", "status": status, "content_type": content_type}
|
|
@@ -522,6 +547,7 @@ def main() -> int:
|
|
|
522
547
|
robots_tokens = [token.strip() for directive in page.robots_directives for token in directive.split(",")]
|
|
523
548
|
checks["robots_directive"] = {"ok": not page.robots_directives or not any(token in {"noindex", "none", "nofollow"} for token in robots_tokens), "directives": page.robots_directives}
|
|
524
549
|
checks["robots_conflict"] = {"ok": not (len({token for token in robots_tokens if token in {"index", "noindex"}}) > 1 or len({token for token in robots_tokens if token in {"follow", "nofollow"}}) > 1), "directives": page.robots_directives}
|
|
550
|
+
checks["indexability_if_indexed"] = {"ok": True, **indexability_if_indexed(page, page.robots_directives + [value.lower() for value in homepage_response.get("xRobotsTag", [])])}
|
|
525
551
|
if args.check_hreflang or args.check_translation_completeness:
|
|
526
552
|
checks["hreflang"] = hreflang_check(base, page, expected_languages) if args.check_hreflang else {"ok": True, "links": page.hreflang}
|
|
527
553
|
if args.check_translation_completeness and expected_languages:
|
|
@@ -614,6 +640,7 @@ def main() -> int:
|
|
|
614
640
|
baseline_id=args.baseline_id,
|
|
615
641
|
supersedes=previous_baseline.get("baselineId") or site_baseline.baseline_fingerprint(previous_baseline),
|
|
616
642
|
change_reasons=reasons,
|
|
643
|
+
approval_expires_at=baseline_ack_expiry(args.reason_ttl_days),
|
|
617
644
|
)
|
|
618
645
|
site_baseline.save(args.recapture_baseline, recaptured)
|
|
619
646
|
result["baselineRecapture"] = {
|
|
@@ -60,6 +60,20 @@ GSC data is read-only in this starter. Any query, page, or indexing report must
|
|
|
60
60
|
show its date window and property so an agent does not confuse an empty result
|
|
61
61
|
with a failed connection.
|
|
62
62
|
|
|
63
|
+
The GSC readiness evidence contract is explicit and can be checked before the
|
|
64
|
+
combined release gate:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
maggie analytics gsc-readiness \
|
|
68
|
+
--gsc-evidence .maggie/gsc-readiness.json
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The evidence must cover property/verification, canonical origin, robots,
|
|
72
|
+
sitemap, read-only authorization, query/readback, and production smoke. Use
|
|
73
|
+
`--gsc-evidence ... --require-gsc` with `maggie analytics release-gate` to make
|
|
74
|
+
those checks blocking. The command does not authorize a property or mutate
|
|
75
|
+
Search Console.
|
|
76
|
+
|
|
63
77
|
Run the deterministic configuration gate before enabling production tracking:
|
|
64
78
|
|
|
65
79
|
```bash
|
|
@@ -21,6 +21,7 @@ def snapshot(
|
|
|
21
21
|
baseline_id: str | None = None,
|
|
22
22
|
supersedes: str | None = None,
|
|
23
23
|
change_reasons: dict[str, str] | None = None,
|
|
24
|
+
approval_expires_at: str | None = None,
|
|
24
25
|
) -> dict:
|
|
25
26
|
if not reviewer.strip():
|
|
26
27
|
raise ValueError("baseline requires a reviewer")
|
|
@@ -58,6 +59,8 @@ def snapshot(
|
|
|
58
59
|
result["supersedes"] = supersedes
|
|
59
60
|
if change_reasons:
|
|
60
61
|
result["approvedChangeReasons"] = {key: change_reasons[key] for key in sorted(change_reasons)}
|
|
62
|
+
if approval_expires_at:
|
|
63
|
+
result["approval"] = {"type": "reviewed-drift-acknowledgement", "expiresAt": approval_expires_at}
|
|
61
64
|
return result
|
|
62
65
|
|
|
63
66
|
|