@topy-ai/maggie 0.6.6 → 0.6.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -2
- package/README.zh-TW.md +1 -1
- package/bundled-skills/maggie-content-localization/SKILL.md +16 -5
- package/bundled-tools/clis/maggie_analytics.py +83 -0
- package/bundled-tools/clis/maggie_localization.py +159 -5
- package/bundled-tools/clis/maggie_release.py +21 -0
- package/bundled-tools/integrations/analytics.md +26 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -154,11 +154,33 @@ The canary report requires a screenshot and zero console errors, missing
|
|
|
154
154
|
assets, and visual placeholders for every route. It records safe cache headers
|
|
155
155
|
only and never stores response bodies, cookies, or credentials.
|
|
156
156
|
|
|
157
|
+
### Localization and analytics release gates
|
|
158
|
+
|
|
159
|
+
Extract real page strings before creating a localization job, then require a
|
|
160
|
+
clean rendered report before approval:
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
maggie localization extract --project . --source-dir src \
|
|
164
|
+
--output .maggie/localization/source.json
|
|
165
|
+
maggie localization plan --project . --content content.json \
|
|
166
|
+
--source .maggie/localization/source.json --source-lang en \
|
|
167
|
+
--target-lang zh-Hant --locale zh-Hant-TW --market global --operation translate
|
|
168
|
+
maggie localization validate .maggie/localization/<job>.json \
|
|
169
|
+
--source .maggie/localization/source.json \
|
|
170
|
+
--render-report .maggie/localization/rendered.json
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
For analytics, the opt-in end-to-end gate combines the event contract,
|
|
174
|
+
consent/network evidence, provider readback, browser rendering, referral
|
|
175
|
+
redaction, and production smoke report. It fails closed on missing or unsafe
|
|
176
|
+
evidence; see the repository analytics integration guide for the versioned
|
|
177
|
+
artifact schemas.
|
|
178
|
+
|
|
157
179
|
Recommended upgrade sequence for the current release:
|
|
158
180
|
|
|
159
181
|
```bash
|
|
160
|
-
npx @topy-ai/maggie@0.6.
|
|
161
|
-
npx @topy-ai/maggie@0.6.
|
|
182
|
+
npx @topy-ai/maggie@0.6.7 update --project . --force
|
|
183
|
+
npx @topy-ai/maggie@0.6.7 cleanup --project .
|
|
162
184
|
```
|
|
163
185
|
|
|
164
186
|
## MaggieDash lifecycle
|
package/README.zh-TW.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: maggie-content-localization
|
|
3
3
|
description: Manage translation, polish, rewrite, market localization, review, stale detection, and publishing for pages, guides, posts, services, products, and categories.
|
|
4
4
|
metadata:
|
|
5
|
-
version: 1.
|
|
5
|
+
version: 1.2.0
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Maggie Content Localization
|
|
@@ -40,8 +40,16 @@ advisory and never bypasses validation or owner/Admin approval.
|
|
|
40
40
|
python3 tools/clis/maggie_localization.py plan \
|
|
41
41
|
--project . --content content.json --source-lang en --target-lang zh-Hans \
|
|
42
42
|
--market uk --operation translate --locale zh-Hans-GB
|
|
43
|
+
python3 tools/clis/maggie_localization.py extract --project . --source-dir src \
|
|
44
|
+
--routes-file docs/routes.tsv --output .maggie/localization/source.json
|
|
45
|
+
python3 tools/clis/maggie_localization.py plan --project . --content content.json \
|
|
46
|
+
--source .maggie/localization/source.json --source-lang en --target-lang zh-Hans \
|
|
47
|
+
--market uk --operation translate --locale zh-Hans-GB
|
|
43
48
|
python3 tools/clis/maggie_localization.py preview .maggie/localization/<job>.json
|
|
44
49
|
python3 tools/clis/maggie_localization.py validate .maggie/localization/<job>.json
|
|
50
|
+
python3 tools/clis/maggie_localization.py validate .maggie/localization/<job>.json \
|
|
51
|
+
--source .maggie/localization/source.json \
|
|
52
|
+
--render-report .maggie/localization/rendered.json
|
|
45
53
|
python3 tools/clis/maggie_localization.py review .maggie/localization/<job>.json \
|
|
46
54
|
--reviewer owner@example.com --decision approve
|
|
47
55
|
python3 tools/clis/maggie_localization.py publish .maggie/localization/<job>.json --confirm
|
|
@@ -60,8 +68,11 @@ health claims, slug, canonical ownership, or content identity during a copy
|
|
|
60
68
|
operation without structured approval. Incomplete or fallback output must be
|
|
61
69
|
`isIndexable: false`. High-risk content requires Admin approval before publish.
|
|
62
70
|
|
|
63
|
-
The
|
|
64
|
-
|
|
65
|
-
|
|
71
|
+
The extractor is read-only and uses a conservative markup adapter for Astro,
|
|
72
|
+
HTML, JSX/TSX, Vue, and Svelte. It records stable string IDs, source hashes,
|
|
73
|
+
line numbers, routes, and attribute/text-node kinds while skipping scripts,
|
|
74
|
+
styles, SVG, and dynamic expressions. Supplying source and render reports to
|
|
75
|
+
`validate` makes the checks fail closed; the prepared `content.json` flow
|
|
76
|
+
remains backward-compatible without those artifacts. See
|
|
66
77
|
[`docs/localization-extraction-render-prd.md`](../../docs/localization-extraction-render-prd.md)
|
|
67
|
-
|
|
78
|
+
for the artifact contract and limitations.
|
|
@@ -7,6 +7,7 @@ import argparse
|
|
|
7
7
|
import json
|
|
8
8
|
import os
|
|
9
9
|
import re
|
|
10
|
+
import subprocess
|
|
10
11
|
from pathlib import Path
|
|
11
12
|
from urllib.parse import urlsplit
|
|
12
13
|
|
|
@@ -14,6 +15,7 @@ from urllib.parse import urlsplit
|
|
|
14
15
|
GA4_ID = re.compile(r"^G-[A-Z0-9]+$", re.I)
|
|
15
16
|
ALLOWED_EVENTS = {"page_view", "blog_post_view", "booking_click"}
|
|
16
17
|
FORBIDDEN_EVENTS = re.compile(r"(?:purchase|revenue|confirmed[_-]?booking|completed[_-]?treatment|transaction_id)", re.I)
|
|
18
|
+
SENSITIVE_FIELDS = re.compile(r"(?:email|phone|name|address|ip|user[_-]?id|client[_-]?id|query|referrer)", re.I)
|
|
17
19
|
|
|
18
20
|
|
|
19
21
|
def parse_env(path: Path) -> dict[str, str]:
|
|
@@ -33,13 +35,94 @@ def check(name: str, passed: bool, evidence: str, checks: dict) -> None:
|
|
|
33
35
|
checks[name] = {"passed": bool(passed), "evidence": evidence}
|
|
34
36
|
|
|
35
37
|
|
|
38
|
+
def load_json(path: Path, label: str) -> tuple[dict | None, list[str]]:
|
|
39
|
+
try:
|
|
40
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
41
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
42
|
+
return None, [f"{label}: {exc}"]
|
|
43
|
+
if not isinstance(value, dict):
|
|
44
|
+
return None, [f"{label}: JSON root must be an object"]
|
|
45
|
+
return value, []
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def release_gate(args: argparse.Namespace) -> int:
|
|
49
|
+
project = Path(args.project).resolve()
|
|
50
|
+
checks: dict[str, dict] = {}
|
|
51
|
+
errors: list[str] = []
|
|
52
|
+
contract, contract_errors = load_json(Path(args.contract).resolve(), "contract")
|
|
53
|
+
errors.extend(contract_errors)
|
|
54
|
+
if contract:
|
|
55
|
+
check("contract-schema", contract.get("schemaVersion") == "maggie-analytics-contract.v1", "versioned analytics contract", checks)
|
|
56
|
+
events = contract.get("events")
|
|
57
|
+
valid_events = isinstance(events, list) and bool(events) and all(isinstance(event, str) and re.fullmatch(r"[a-z][a-z0-9_]{1,63}", event) and not FORBIDDEN_EVENTS.search(event) for event in events)
|
|
58
|
+
check("event-contract", valid_events, "event names are lowercase, bounded, and do not claim revenue", checks)
|
|
59
|
+
destinations = contract.get("destinations")
|
|
60
|
+
check("destination-contract", isinstance(destinations, list) and bool(destinations) and all(isinstance(item, str) and item in {"ga4", "gtm", "gsc"} for item in destinations), "destinations are from the supported provider set", checks)
|
|
61
|
+
fields = contract.get("allowedParameters", [])
|
|
62
|
+
check("parameter-privacy", isinstance(fields, list) and all(isinstance(field, str) and not SENSITIVE_FIELDS.search(field) for field in fields), "allowed event parameters exclude identity and raw acquisition signals", checks)
|
|
63
|
+
if isinstance(contract.get("referralSources"), list):
|
|
64
|
+
check("referral-contract", set(contract["referralSources"]).issubset({"search", "partner", "social", "email", "direct", "unknown"}), "referral categories use the redacted taxonomy", checks)
|
|
65
|
+
else:
|
|
66
|
+
check("referral-contract", False, "contract must declare referralSources", checks)
|
|
67
|
+
browser, browser_errors = load_json(Path(args.render_report).resolve(), "render-report")
|
|
68
|
+
errors.extend(browser_errors)
|
|
69
|
+
network, network_errors = load_json(Path(args.network_report).resolve(), "network-report")
|
|
70
|
+
errors.extend(network_errors)
|
|
71
|
+
provider, provider_errors = load_json(Path(args.provider_report).resolve(), "provider-report")
|
|
72
|
+
errors.extend(provider_errors)
|
|
73
|
+
smoke, smoke_errors = load_json(Path(args.smoke_report).resolve(), "smoke-report")
|
|
74
|
+
errors.extend(smoke_errors)
|
|
75
|
+
if browser:
|
|
76
|
+
check("browser-schema", browser.get("schemaVersion") == "maggie-analytics-browser.v1", "versioned browser evidence", checks)
|
|
77
|
+
check("browser-render", browser.get("passed") is True and isinstance(browser.get("routes"), list) and bool(browser["routes"]), "routes rendered without a browser failure", checks)
|
|
78
|
+
check("browser-console", isinstance(browser.get("consoleErrors"), list) and not browser["consoleErrors"], "no browser console errors", checks)
|
|
79
|
+
if network:
|
|
80
|
+
pre = network.get("preConsentRequests")
|
|
81
|
+
check("pre-consent-network", isinstance(pre, list) and not pre, "no analytics requests before consent", checks)
|
|
82
|
+
check("network-redaction", network.get("redacted") is True, "network evidence is explicitly redacted", checks)
|
|
83
|
+
check("consented-network", isinstance(network.get("consentedRequests"), list), "consented network evidence is present", checks)
|
|
84
|
+
if provider:
|
|
85
|
+
check("provider-read-only", provider.get("schemaVersion") == "maggie-analytics-provider.v1" and provider.get("readOnly") is True and provider.get("authorized") is True and provider.get("passed") is True, "provider inspection is authorized, read-only, and passed", checks)
|
|
86
|
+
if smoke:
|
|
87
|
+
check("production-smoke", smoke.get("schemaVersion") == "maggie-analytics-smoke.v1" and smoke.get("passed") is True and bool(smoke.get("route")) and bool(smoke.get("release")) and bool(smoke.get("cache")), "public route, release, and cache smoke evidence", checks)
|
|
88
|
+
if contract and isinstance(contract.get("events"), list) and browser and isinstance(browser.get("consentedEvents"), list):
|
|
89
|
+
check("event-runtime-coverage", set(contract["events"]).issubset(set(browser["consentedEvents"])), "every contract event is present in consented browser evidence", checks)
|
|
90
|
+
failed = [name for name, item in checks.items() if not item["passed"]]
|
|
91
|
+
errors.extend(failed)
|
|
92
|
+
report = {
|
|
93
|
+
"schemaVersion": "maggie-analytics-release-gate.v1",
|
|
94
|
+
"environment": args.environment,
|
|
95
|
+
"project": str(project),
|
|
96
|
+
"checks": checks,
|
|
97
|
+
"passed": not errors,
|
|
98
|
+
"publish": False,
|
|
99
|
+
"mutation": "not executed",
|
|
100
|
+
}
|
|
101
|
+
output = project / ".maggie" / "analytics-release-gate.json"
|
|
102
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
output.write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
104
|
+
print(json.dumps({"status": "passed" if report["passed"] else "failed", "environment": args.environment, "report": str(output), "failedChecks": failed, "publish": False}, indent=2))
|
|
105
|
+
return 0 if report["passed"] else 1
|
|
106
|
+
|
|
107
|
+
|
|
36
108
|
def main() -> int:
|
|
37
109
|
parser = argparse.ArgumentParser()
|
|
110
|
+
parser.add_argument("command", nargs="?", choices=("release-gate",))
|
|
38
111
|
parser.add_argument("--project", default=".")
|
|
39
112
|
parser.add_argument("--environment", choices=("development", "staging", "production"), default="staging")
|
|
40
113
|
parser.add_argument("--env-file", help="optional env file; values are never printed")
|
|
41
114
|
parser.add_argument("--html", help="optional rendered HTML snapshot to inspect")
|
|
115
|
+
parser.add_argument("--contract", help="versioned analytics contract for release-gate")
|
|
116
|
+
parser.add_argument("--render-report", help="redacted browser/render evidence for release-gate")
|
|
117
|
+
parser.add_argument("--network-report", help="redacted network evidence for release-gate")
|
|
118
|
+
parser.add_argument("--provider-report", help="read-only provider evidence for release-gate")
|
|
119
|
+
parser.add_argument("--smoke-report", help="production smoke evidence for release-gate")
|
|
42
120
|
args = parser.parse_args()
|
|
121
|
+
if args.command == "release-gate":
|
|
122
|
+
required = ("contract", "render_report", "network_report", "provider_report", "smoke_report")
|
|
123
|
+
if any(not getattr(args, name) for name in required):
|
|
124
|
+
parser.error("release-gate requires --contract, --render-report, --network-report, --provider-report, and --smoke-report")
|
|
125
|
+
return release_gate(args)
|
|
43
126
|
project = Path(args.project).resolve()
|
|
44
127
|
env = parse_env(Path(args.env_file).resolve()) if args.env_file else parse_env(project / ".env.example")
|
|
45
128
|
env.update({key: value for key, value in os.environ.items() if key in {"PUBLIC_GA4_MEASUREMENT_ID", "PUBLIC_ANALYTICS_ENABLED", "PUBLIC_ANALYTICS_CONSENT_REQUIRED", "GSC_SITE_URL", "GSC_VERIFICATION_TOKEN"}})
|
|
@@ -26,6 +26,133 @@ def load(path: Path) -> dict:
|
|
|
26
26
|
return value
|
|
27
27
|
|
|
28
28
|
|
|
29
|
+
MARKUP_EXTENSIONS = {".astro", ".html", ".htm", ".jsx", ".tsx", ".vue", ".svelte"}
|
|
30
|
+
SKIP_TAGS = re.compile(r"<(?:script|style|noscript|svg)\b[^>]*>.*?</(?:script|style|noscript|svg)\s*>", re.I | re.S)
|
|
31
|
+
TAG_PATTERN = re.compile(r"<[^>]*>")
|
|
32
|
+
ATTR_PATTERN = re.compile(r"\b(?:alt|title|aria-label|placeholder)\s*=\s*([\"'])(.*?)\1", re.I | re.S)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def source_revision(files: list[Path], root: Path) -> str:
|
|
36
|
+
digest = hashlib.sha256()
|
|
37
|
+
for path in sorted(files):
|
|
38
|
+
digest.update(str(path.relative_to(root)).encode("utf-8"))
|
|
39
|
+
digest.update(path.read_bytes())
|
|
40
|
+
return digest.hexdigest()[:16]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def extract_file(path: Path, root: Path, route: str) -> list[dict]:
|
|
44
|
+
raw = path.read_text(encoding="utf-8", errors="replace")
|
|
45
|
+
cleaned = SKIP_TAGS.sub("", raw)
|
|
46
|
+
strings: list[dict] = []
|
|
47
|
+
occupied: list[tuple[int, int]] = []
|
|
48
|
+
|
|
49
|
+
def add(value: str, start: int, kind: str) -> None:
|
|
50
|
+
value = re.sub(r"\s+", " ", value).strip()
|
|
51
|
+
if len(value) < 2 or "{" in value or "}" in value or re.fullmatch(r"[\W_]+", value or ""):
|
|
52
|
+
return
|
|
53
|
+
line = raw.count("\n", 0, start) + 1
|
|
54
|
+
relative = str(path.relative_to(root))
|
|
55
|
+
identity = f"{relative}:{line}:{kind}:{value}"
|
|
56
|
+
strings.append({
|
|
57
|
+
"id": "src-" + hashlib.sha256(identity.encode("utf-8")).hexdigest()[:16],
|
|
58
|
+
"file": relative,
|
|
59
|
+
"line": line,
|
|
60
|
+
"route": route,
|
|
61
|
+
"kind": kind,
|
|
62
|
+
"text": value,
|
|
63
|
+
"sourceHash": hashlib.sha256(value.encode("utf-8")).hexdigest()[:16],
|
|
64
|
+
"translatable": True,
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
for match in TAG_PATTERN.finditer(cleaned):
|
|
68
|
+
tag = match.group(0)
|
|
69
|
+
for attr in ATTR_PATTERN.finditer(tag):
|
|
70
|
+
offset = match.start() + attr.start(2)
|
|
71
|
+
add(attr.group(2), offset, "attribute")
|
|
72
|
+
occupied.append((match.start(), match.end()))
|
|
73
|
+
last = 0
|
|
74
|
+
for start, end in occupied:
|
|
75
|
+
if start > last:
|
|
76
|
+
add(cleaned[last:start], last, "text")
|
|
77
|
+
last = end
|
|
78
|
+
if last < len(cleaned):
|
|
79
|
+
add(cleaned[last:], last, "text")
|
|
80
|
+
return strings
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def extract_source(args: argparse.Namespace) -> int:
|
|
84
|
+
project = args.project.resolve()
|
|
85
|
+
source_dir = (project / args.source_dir).resolve()
|
|
86
|
+
if not source_dir.is_dir() or project not in source_dir.parents and source_dir != project:
|
|
87
|
+
raise ValueError("source-dir must be an existing directory inside project")
|
|
88
|
+
routes: dict[str, str] = {}
|
|
89
|
+
if args.routes_file:
|
|
90
|
+
route_path = (project / args.routes_file).resolve()
|
|
91
|
+
if route_path.exists():
|
|
92
|
+
for line in route_path.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
93
|
+
parts = line.split("\t", 1)
|
|
94
|
+
if len(parts) == 2 and parts[0].strip() and parts[1].strip():
|
|
95
|
+
routes[parts[0].strip()] = parts[1].strip()
|
|
96
|
+
files = [path for path in source_dir.rglob("*") if path.is_file() and path.suffix.lower() in MARKUP_EXTENSIONS]
|
|
97
|
+
strings: list[dict] = []
|
|
98
|
+
for path in sorted(files):
|
|
99
|
+
relative = str(path.relative_to(project))
|
|
100
|
+
strings.extend(extract_file(path, project, routes.get(relative, "unknown")))
|
|
101
|
+
revision = source_revision(files, project)
|
|
102
|
+
output = (project / args.output).resolve()
|
|
103
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
artifact = {
|
|
105
|
+
"schemaVersion": "maggie-localization-source.v1",
|
|
106
|
+
"sourceRevision": revision,
|
|
107
|
+
"sourceRoot": str(source_dir.relative_to(project)),
|
|
108
|
+
"files": [str(path.relative_to(project)) for path in files],
|
|
109
|
+
"strings": strings,
|
|
110
|
+
"extraction": {"adapter": "conservative-markup", "readOnly": True, "supportedExtensions": sorted(MARKUP_EXTENSIONS)},
|
|
111
|
+
}
|
|
112
|
+
output.write_text(json.dumps(artifact, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
113
|
+
print(json.dumps({"status": "passed", "output": str(output), "files": len(files), "strings": len(strings), "sourceRevision": revision}, indent=2))
|
|
114
|
+
return 0
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def validate_source(path: Path, expected_revision: str | None) -> list[str]:
|
|
118
|
+
source = load(path)
|
|
119
|
+
errors: list[str] = []
|
|
120
|
+
if source.get("schemaVersion") != "maggie-localization-source.v1":
|
|
121
|
+
errors.append("source artifact schemaVersion is unsupported")
|
|
122
|
+
strings = source.get("strings")
|
|
123
|
+
if not isinstance(strings, list) or not strings:
|
|
124
|
+
errors.append("source artifact has no extracted strings")
|
|
125
|
+
seen: set[str] = set()
|
|
126
|
+
for item in strings if isinstance(strings, list) else []:
|
|
127
|
+
if not isinstance(item, dict) or not item.get("id") or item.get("id") in seen:
|
|
128
|
+
errors.append("source strings must have unique IDs")
|
|
129
|
+
continue
|
|
130
|
+
seen.add(item["id"])
|
|
131
|
+
if any(marker in str(item.get("text", "")) for marker in ("{", "}")):
|
|
132
|
+
errors.append(f"source string contains an expression: {item.get('id')}")
|
|
133
|
+
if not item.get("file") or not item.get("line") or not item.get("sourceHash"):
|
|
134
|
+
errors.append(f"source string metadata is incomplete: {item.get('id')}")
|
|
135
|
+
if expected_revision and source.get("sourceRevision") != expected_revision:
|
|
136
|
+
errors.append("source revision does not match localization job")
|
|
137
|
+
return errors
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def validate_render_report(path: Path) -> list[str]:
|
|
141
|
+
report = load(path)
|
|
142
|
+
errors: list[str] = []
|
|
143
|
+
if report.get("schemaVersion") != "maggie-localization-render.v1":
|
|
144
|
+
errors.append("render report schemaVersion is unsupported")
|
|
145
|
+
if report.get("passed") is not True:
|
|
146
|
+
errors.append("render report is not passed")
|
|
147
|
+
for field in ("routes", "consoleErrors", "failedAssets", "untranslatedRequired"):
|
|
148
|
+
if field not in report:
|
|
149
|
+
errors.append(f"render report is missing {field}")
|
|
150
|
+
for field in ("consoleErrors", "failedAssets", "untranslatedRequired"):
|
|
151
|
+
if isinstance(report.get(field), list) and report[field]:
|
|
152
|
+
errors.append(f"render report has {field}")
|
|
153
|
+
return errors
|
|
154
|
+
|
|
155
|
+
|
|
29
156
|
def safe_job_content_token(content_id: str) -> str:
|
|
30
157
|
"""Make a content identity safe for a single filename component."""
|
|
31
158
|
token = re.sub(r"[^A-Za-z0-9._-]+", "-", str(content_id)).strip(".-_") or "content"
|
|
@@ -36,6 +163,7 @@ def safe_job_content_token(content_id: str) -> str:
|
|
|
36
163
|
|
|
37
164
|
def plan_job(args: argparse.Namespace) -> int:
|
|
38
165
|
content = load(args.content)
|
|
166
|
+
source_artifact = load(args.source) if getattr(args, "source", None) else None
|
|
39
167
|
if args.source_lang not in LANGUAGES or args.target_lang not in LANGUAGES:
|
|
40
168
|
raise ValueError("source-lang and target-lang must be in the supported language registry")
|
|
41
169
|
if args.market not in MARKETS:
|
|
@@ -59,11 +187,18 @@ def plan_job(args: argparse.Namespace) -> int:
|
|
|
59
187
|
"targetLocale": args.locale,
|
|
60
188
|
"operation": args.operation,
|
|
61
189
|
"translationGroupId": identity.get("translationGroupId", f"tg-{content_id}"),
|
|
62
|
-
"sourceRevision": args.source_revision or content.get("sourceRevision", "unknown"),
|
|
190
|
+
"sourceRevision": args.source_revision or (source_artifact or {}).get("sourceRevision") or content.get("sourceRevision", "unknown"),
|
|
63
191
|
"status": "draft",
|
|
64
192
|
"translation": {"translationStatus": "draft", "isIndexable": False},
|
|
65
193
|
"createdAt": now(),
|
|
66
194
|
}
|
|
195
|
+
if source_artifact:
|
|
196
|
+
if source_artifact.get("schemaVersion") != "maggie-localization-source.v1":
|
|
197
|
+
raise ValueError("source artifact schemaVersion is unsupported")
|
|
198
|
+
source_strings = source_artifact.get("strings", [])
|
|
199
|
+
if not isinstance(source_strings, list) or not source_strings:
|
|
200
|
+
raise ValueError("source artifact has no extracted strings")
|
|
201
|
+
job["translation"]["sourceStringIds"] = [item.get("id") for item in source_strings if isinstance(item, dict) and item.get("id")]
|
|
67
202
|
output = Path(args.project) / ".maggie" / "localization" / f"{job['jobId']}.json"
|
|
68
203
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
69
204
|
output.write_text(json.dumps(job, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
@@ -71,7 +206,7 @@ def plan_job(args: argparse.Namespace) -> int:
|
|
|
71
206
|
return 0
|
|
72
207
|
|
|
73
208
|
|
|
74
|
-
def validate_job(path: Path) -> dict:
|
|
209
|
+
def validate_job(path: Path, source_path: Path | None = None, render_path: Path | None = None) -> dict:
|
|
75
210
|
job = load(path)
|
|
76
211
|
errors = []
|
|
77
212
|
if job.get("operation") not in OPERATIONS:
|
|
@@ -93,6 +228,15 @@ def validate_job(path: Path) -> dict:
|
|
|
93
228
|
})
|
|
94
229
|
}, expected_lang=job.get("targetLanguage")))
|
|
95
230
|
errors.extend(f"protected field changed without approval: {field}" for field in protected_field_changes(job.get("content", {}), translation))
|
|
231
|
+
if source_path:
|
|
232
|
+
errors.extend(validate_source(source_path, job.get("sourceRevision")))
|
|
233
|
+
source = load(source_path)
|
|
234
|
+
source_ids = {item.get("id") for item in source.get("strings", []) if isinstance(item, dict)}
|
|
235
|
+
linked_ids = translation.get("sourceStringIds")
|
|
236
|
+
if not isinstance(linked_ids, list) or set(linked_ids) != source_ids:
|
|
237
|
+
errors.append("translation sourceStringIds must link every extracted source string")
|
|
238
|
+
if render_path:
|
|
239
|
+
errors.extend(validate_render_report(render_path))
|
|
96
240
|
return {"passed": not errors, "errors": errors, "jobId": job.get("jobId"), "status": job.get("status")}
|
|
97
241
|
|
|
98
242
|
|
|
@@ -172,8 +316,17 @@ def main() -> int:
|
|
|
172
316
|
plan_parser.add_argument("--market", required=True)
|
|
173
317
|
plan_parser.add_argument("--operation", required=True)
|
|
174
318
|
plan_parser.add_argument("--source-revision")
|
|
175
|
-
|
|
176
|
-
|
|
319
|
+
plan_parser.add_argument("--source", type=Path, help="optional extracted source artifact")
|
|
320
|
+
sub.add_parser("preview").add_argument("job", type=Path)
|
|
321
|
+
validate_parser = sub.add_parser("validate")
|
|
322
|
+
validate_parser.add_argument("job", type=Path)
|
|
323
|
+
validate_parser.add_argument("--source", type=Path)
|
|
324
|
+
validate_parser.add_argument("--render-report", type=Path)
|
|
325
|
+
extract_parser = sub.add_parser("extract")
|
|
326
|
+
extract_parser.add_argument("--project", type=Path, default=Path("."))
|
|
327
|
+
extract_parser.add_argument("--source-dir", default="src")
|
|
328
|
+
extract_parser.add_argument("--routes-file")
|
|
329
|
+
extract_parser.add_argument("--output", default=".maggie/localization/source.json")
|
|
177
330
|
review_parser = sub.add_parser("review")
|
|
178
331
|
review_parser.add_argument("job", type=Path); review_parser.add_argument("--reviewer", required=True); review_parser.add_argument("--decision", required=True)
|
|
179
332
|
publish_parser = sub.add_parser("publish")
|
|
@@ -185,7 +338,8 @@ def main() -> int:
|
|
|
185
338
|
if args.command == "plan": return plan_job(args)
|
|
186
339
|
if args.command == "preview": return preview(args.job)
|
|
187
340
|
if args.command == "validate":
|
|
188
|
-
result = validate_job(args.job); print(json.dumps(result, indent=2)); return 0 if result["passed"] else 1
|
|
341
|
+
result = validate_job(args.job, args.source, args.render_report); print(json.dumps(result, indent=2)); return 0 if result["passed"] else 1
|
|
342
|
+
if args.command == "extract": return extract_source(args)
|
|
189
343
|
if args.command == "review": return review(args.job, args.reviewer, args.decision)
|
|
190
344
|
if args.command == "publish": return publish(args.job, args.confirm)
|
|
191
345
|
if args.command == "stale": return stale_jobs(args.project)
|
|
@@ -212,6 +212,12 @@ def main() -> int:
|
|
|
212
212
|
parser.add_argument("--output", help="release evidence JSON path")
|
|
213
213
|
parser.add_argument("--skip-compatibility", action="store_true", help="skip the optional schema rehearsal; not recommended for release")
|
|
214
214
|
parser.add_argument("--base-url", help="optional live/staging URL for homepage, admin redirect and 404 smoke")
|
|
215
|
+
parser.add_argument("--analytics-release-gate", action="store_true", help="require the end-to-end analytics evidence gate")
|
|
216
|
+
parser.add_argument("--analytics-contract")
|
|
217
|
+
parser.add_argument("--analytics-render-report")
|
|
218
|
+
parser.add_argument("--analytics-network-report")
|
|
219
|
+
parser.add_argument("--analytics-provider-report")
|
|
220
|
+
parser.add_argument("--analytics-smoke-report")
|
|
215
221
|
args = parser.parse_args()
|
|
216
222
|
project = Path(args.project).resolve()
|
|
217
223
|
if not project.is_dir():
|
|
@@ -221,6 +227,21 @@ def main() -> int:
|
|
|
221
227
|
gates.append(build_gate(project))
|
|
222
228
|
for name, command in build_gates(project, args.environment, args.target, not args.skip_compatibility):
|
|
223
229
|
gates.append(run_gate(name, command, project))
|
|
230
|
+
if args.analytics_release_gate:
|
|
231
|
+
evidence = {
|
|
232
|
+
"--contract": args.analytics_contract,
|
|
233
|
+
"--render-report": args.analytics_render_report,
|
|
234
|
+
"--network-report": args.analytics_network_report,
|
|
235
|
+
"--provider-report": args.analytics_provider_report,
|
|
236
|
+
"--smoke-report": args.analytics_smoke_report,
|
|
237
|
+
}
|
|
238
|
+
missing = [name for name, value in evidence.items() if not value]
|
|
239
|
+
if missing:
|
|
240
|
+
parser.error("--analytics-release-gate requires " + ", ".join(missing))
|
|
241
|
+
analytics_command = [sys.executable, str(ROOT / "maggie_analytics.py"), "release-gate", "--project", str(project), "--environment", args.environment]
|
|
242
|
+
for name, value in evidence.items():
|
|
243
|
+
analytics_command.extend([name, value])
|
|
244
|
+
gates.append(run_gate("analytics-release-gate", analytics_command, project))
|
|
224
245
|
gates.append(editorial_gate(project))
|
|
225
246
|
gates.append(evidence_gate(project, args.environment))
|
|
226
247
|
if args.base_url:
|
|
@@ -46,3 +46,29 @@ Use `--environment production` during release preflight. It fails closed until
|
|
|
46
46
|
GA4 is explicitly enabled with a valid measurement ID and `GSC_SITE_URL` is
|
|
47
47
|
configured. The CLI never prints secret values and does not claim confirmed
|
|
48
48
|
bookings or revenue from outbound booking clicks.
|
|
49
|
+
|
|
50
|
+
## End-to-end release gate
|
|
51
|
+
|
|
52
|
+
For a release that has browser and provider evidence, use the opt-in gate:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
maggie analytics release-gate --project . --environment staging \
|
|
56
|
+
--contract .maggie/analytics-contract.json \
|
|
57
|
+
--render-report .maggie/analytics-browser.json \
|
|
58
|
+
--network-report .maggie/analytics-network.json \
|
|
59
|
+
--provider-report .maggie/analytics-provider.json \
|
|
60
|
+
--smoke-report .maggie/analytics-smoke.json
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The contract must use `maggie-analytics-contract.v1` and declare event names,
|
|
64
|
+
supported destinations, privacy-safe parameters, and the redacted referral
|
|
65
|
+
taxonomy. Evidence uses versioned schemas: browser
|
|
66
|
+
`maggie-analytics-browser.v1`, network `maggie-analytics-network.v1`, provider
|
|
67
|
+
`maggie-analytics-provider.v1`, and smoke `maggie-analytics-smoke.v1`.
|
|
68
|
+
Pre-consent requests must be empty; provider evidence must be read-only and
|
|
69
|
+
authorized; rendered routes, cache/release smoke, and all contract events must
|
|
70
|
+
pass. Reports contain metadata only and never raw URLs, query strings, request
|
|
71
|
+
bodies, credentials, or identity fields.
|
|
72
|
+
|
|
73
|
+
To include it in the aggregate release preflight, pass the same five files to
|
|
74
|
+
`maggie release` with `--analytics-release-gate`.
|