@topy-ai/maggie 0.7.20 → 0.7.22
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 +55 -3
- package/README.zh-TW.md +18 -1
- package/bin/maggie.js +6 -1
- package/bundled-contracts/maggie-deployment/readiness-v1.schema.json +37 -0
- package/bundled-references/content-localization-contract.md +9 -0
- package/bundled-references/universal-booking-adapter.md +62 -1
- package/bundled-skills/maggie-content-localization/SKILL.md +6 -3
- package/bundled-skills/maggie-deployment/SKILL.md +25 -0
- package/bundled-skills/maggie-qa-workflow/SKILL.md +20 -0
- package/bundled-skills/maggie-service-booking/SKILL.md +48 -3
- package/bundled-tools/clis/maggie_deployment_readiness.py +158 -0
- package/bundled-tools/clis/maggie_localization.py +6 -2
- package/bundled-tools/clis/maggie_qa_workflow.py +88 -0
- package/bundled-tools/clis/maggie_service_booking.py +197 -42
- package/bundled-tools/runtime/content_localization.py +137 -0
- package/package.json +1 -1
- package/references/content-localization-contract.md +9 -0
- package/references/universal-booking-adapter.md +62 -1
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Summarize release evidence without confusing tests with runtime readiness."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Callable
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
CHECKS = (
|
|
15
|
+
("unitRegression", "unit_report"),
|
|
16
|
+
("packageSmoke", "package_report"),
|
|
17
|
+
("browserEvidence", "browser_report"),
|
|
18
|
+
("renderedCanary", "rendered_canary"),
|
|
19
|
+
("deploymentPreflight", "deployment_preflight"),
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def display_path(path: Path, project: Path) -> str:
|
|
24
|
+
try:
|
|
25
|
+
return str(path.resolve().relative_to(project.resolve()))
|
|
26
|
+
except ValueError:
|
|
27
|
+
return path.name
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def validate_unit(payload: dict[str, Any]) -> tuple[bool, str]:
|
|
31
|
+
if payload.get("schemaVersion") != "maggie-regression.v1":
|
|
32
|
+
return False, "unit evidence schemaVersion is unsupported"
|
|
33
|
+
if payload.get("status") != "passed" or not isinstance(payload.get("suites"), int) or payload["suites"] < 1:
|
|
34
|
+
return False, "unit evidence must report a passing suite count"
|
|
35
|
+
if payload.get("skipped", 0) != 0:
|
|
36
|
+
return False, "unit evidence contains skipped suites"
|
|
37
|
+
return True, "dependency-free regression suites passed"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def validate_package(payload: dict[str, Any]) -> tuple[bool, str]:
|
|
41
|
+
if payload.get("schemaVersion") != "maggie-package-smoke.v1":
|
|
42
|
+
return False, "package evidence schemaVersion is unsupported"
|
|
43
|
+
checks = payload.get("checks")
|
|
44
|
+
if payload.get("passed") is not True or not isinstance(checks, list) or not checks:
|
|
45
|
+
return False, "package evidence must contain passing smoke checks"
|
|
46
|
+
return True, "package smoke checks passed"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def validate_browser(payload: dict[str, Any]) -> tuple[bool, str]:
|
|
50
|
+
if payload.get("schemaVersion") != "maggie-browser-evidence.v1":
|
|
51
|
+
return False, "browser evidence schemaVersion is unsupported"
|
|
52
|
+
routes = payload.get("routes")
|
|
53
|
+
if payload.get("passed") is not True or not isinstance(routes, list) or not routes:
|
|
54
|
+
return False, "browser evidence must contain a passing non-empty route set"
|
|
55
|
+
for route in routes:
|
|
56
|
+
if not isinstance(route, dict) or not route.get("route") or not route.get("screenshot"):
|
|
57
|
+
return False, "browser evidence routes need a route and screenshot"
|
|
58
|
+
if route.get("consoleErrors") or route.get("networkErrors") or route.get("missingAssets"):
|
|
59
|
+
return False, "browser evidence contains runtime errors"
|
|
60
|
+
return True, "host browser evidence passed"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def validate_canary(payload: dict[str, Any]) -> tuple[bool, str]:
|
|
64
|
+
if payload.get("schemaVersion") != "maggie-deployment-canary.v1":
|
|
65
|
+
return False, "rendered canary schemaVersion is unsupported"
|
|
66
|
+
if payload.get("status") != "pass":
|
|
67
|
+
return False, "rendered canary did not pass"
|
|
68
|
+
rendered = payload.get("rendered")
|
|
69
|
+
if not isinstance(rendered, dict) or not isinstance(rendered.get("report"), dict):
|
|
70
|
+
return False, "rendered canary is missing browser-rendered report evidence"
|
|
71
|
+
return True, "edge and rendered canary passed"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def validate_preflight(payload: dict[str, Any]) -> tuple[bool, str]:
|
|
75
|
+
if payload.get("schemaVersion") not in {"1.0", "maggie-release-preflight.v1"}:
|
|
76
|
+
return False, "deployment preflight schemaVersion is unsupported"
|
|
77
|
+
if payload.get("passed") is not True:
|
|
78
|
+
return False, "deployment preflight contains a failed gate"
|
|
79
|
+
gates = payload.get("gates")
|
|
80
|
+
if not isinstance(gates, list) or not gates:
|
|
81
|
+
return False, "deployment preflight has no gate results"
|
|
82
|
+
return True, "deployment preflight gates passed"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
VALIDATORS: dict[str, Callable[[dict[str, Any]], tuple[bool, str]]] = {
|
|
86
|
+
"unitRegression": validate_unit,
|
|
87
|
+
"packageSmoke": validate_package,
|
|
88
|
+
"browserEvidence": validate_browser,
|
|
89
|
+
"renderedCanary": validate_canary,
|
|
90
|
+
"deploymentPreflight": validate_preflight,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def inspect_check(name: str, path: Path, project: Path) -> dict[str, Any]:
|
|
95
|
+
result: dict[str, Any] = {"state": "inconclusive", "configured": path.is_file(), "path": display_path(path, project)}
|
|
96
|
+
if not path.is_file():
|
|
97
|
+
result["reason"] = "required adapter evidence is unavailable"
|
|
98
|
+
return result
|
|
99
|
+
try:
|
|
100
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
101
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
102
|
+
result.update({"state": "failed", "reason": f"evidence is not valid JSON: {error}"})
|
|
103
|
+
return result
|
|
104
|
+
if not isinstance(payload, dict):
|
|
105
|
+
result.update({"state": "failed", "reason": "evidence root must be an object"})
|
|
106
|
+
return result
|
|
107
|
+
valid, reason = VALIDATORS[name](payload)
|
|
108
|
+
result.update({"state": "passed" if valid else "failed", "reason": reason})
|
|
109
|
+
return result
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def readiness(project: Path, paths: dict[str, Path], output: Path | None = None) -> int:
|
|
113
|
+
checks = {name: inspect_check(name, path, project) for name, path in paths.items()}
|
|
114
|
+
states = {item["state"] for item in checks.values()}
|
|
115
|
+
status = "failed" if "failed" in states else "inconclusive" if "inconclusive" in states else "passed"
|
|
116
|
+
report = {
|
|
117
|
+
"schemaVersion": "maggie-deployment-readiness.v1",
|
|
118
|
+
"generatedAt": datetime.now(timezone.utc).isoformat(),
|
|
119
|
+
"project": display_path(project, project),
|
|
120
|
+
"status": status,
|
|
121
|
+
"checks": checks,
|
|
122
|
+
"nextAction": "resolve failed evidence before release" if status == "failed" else "collect the missing host adapter evidence" if status == "inconclusive" else "all declared readiness evidence passed",
|
|
123
|
+
}
|
|
124
|
+
if output:
|
|
125
|
+
destination = output if output.is_absolute() else project / output
|
|
126
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
destination.write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
128
|
+
report["output"] = display_path(destination, project)
|
|
129
|
+
print(json.dumps(report, indent=2, ensure_ascii=False))
|
|
130
|
+
return 0 if status == "passed" else 1
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def main() -> int:
|
|
134
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
135
|
+
parser.add_argument("--project", type=Path, default=Path.cwd())
|
|
136
|
+
parser.add_argument("--unit-report", type=Path, default=Path(".maggie/verification/unit-regression.json"))
|
|
137
|
+
parser.add_argument("--package-report", type=Path, default=Path(".maggie/verification/package-smoke.json"))
|
|
138
|
+
parser.add_argument("--browser-report", type=Path, default=Path(".maggie/verification/browser-evidence.json"))
|
|
139
|
+
parser.add_argument("--rendered-canary", type=Path, default=Path(".maggie/deployment-canary.json"))
|
|
140
|
+
parser.add_argument("--deployment-preflight", type=Path, default=Path(".maggie/release-preflight.json"))
|
|
141
|
+
parser.add_argument("--output", type=Path)
|
|
142
|
+
args = parser.parse_args()
|
|
143
|
+
project = args.project.resolve()
|
|
144
|
+
if not project.is_dir():
|
|
145
|
+
parser.error(f"project directory does not exist: {project}")
|
|
146
|
+
paths = {
|
|
147
|
+
name: (getattr(args, option).resolve() if getattr(args, option).is_absolute() else project / getattr(args, option))
|
|
148
|
+
for name, option in CHECKS
|
|
149
|
+
}
|
|
150
|
+
try:
|
|
151
|
+
return readiness(project, paths, args.output)
|
|
152
|
+
except (OSError, ValueError) as error:
|
|
153
|
+
print(f"BLOCKED: maggie deployment readiness: {error}", file=sys.stderr)
|
|
154
|
+
return 2
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
if __name__ == "__main__":
|
|
158
|
+
raise SystemExit(main())
|
|
@@ -12,7 +12,7 @@ from datetime import datetime, timezone
|
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
|
|
14
14
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
|
|
15
|
-
from content_localization import LANGUAGES, MARKETS, OPERATIONS, parse_locale, protected_field_changes, stale, valid_locale, validate_translation
|
|
15
|
+
from content_localization import LANGUAGES, MARKETS, OPERATIONS, parse_locale, protected_field_changes, stale, valid_locale, validate_operation_quality, validate_translation
|
|
16
16
|
from localization_runner import generate, process_adapter
|
|
17
17
|
|
|
18
18
|
|
|
@@ -250,6 +250,8 @@ def validate_job(path: Path, source_path: Path | None = None, render_path: Path
|
|
|
250
250
|
errors.append("generationContract mode must match operation")
|
|
251
251
|
if job.get("operation") == "rewrite" and not generation.get("allowStructuralRewrite"):
|
|
252
252
|
errors.append("rewrite requires structural rewrite permission")
|
|
253
|
+
if job.get("operation") == "polish" and job.get("sourceLanguage") != job.get("targetLanguage"):
|
|
254
|
+
errors.append("polish must keep sourceLanguage and targetLanguage identical")
|
|
253
255
|
errors.extend(validate_translation({
|
|
254
256
|
"contentId": job.get("contentId"), "lang": job.get("targetLanguage"),
|
|
255
257
|
"locale": job.get("targetLocale"), "translationGroupId": job.get("translationGroupId", f"tg-{job.get('contentId')}"),
|
|
@@ -260,6 +262,8 @@ def validate_job(path: Path, source_path: Path | None = None, render_path: Path
|
|
|
260
262
|
})
|
|
261
263
|
}, expected_lang=job.get("targetLanguage")))
|
|
262
264
|
errors.extend(f"protected field changed without approval: {field}" for field in protected_field_changes(job.get("content", {}), translation))
|
|
265
|
+
quality_errors, quality = validate_operation_quality(job.get("content", {}), translation, job.get("operation", ""), job.get("review"))
|
|
266
|
+
errors.extend(quality_errors)
|
|
263
267
|
if source_path:
|
|
264
268
|
errors.extend(validate_source(source_path, job.get("sourceRevision")))
|
|
265
269
|
source = load(source_path)
|
|
@@ -269,7 +273,7 @@ def validate_job(path: Path, source_path: Path | None = None, render_path: Path
|
|
|
269
273
|
errors.append("translation sourceStringIds must link every extracted source string")
|
|
270
274
|
if render_path:
|
|
271
275
|
errors.extend(validate_render_report(render_path))
|
|
272
|
-
return {"passed": not errors, "errors": errors, "jobId": job.get("jobId"), "status": job.get("status")}
|
|
276
|
+
return {"passed": not errors, "errors": errors, "jobId": job.get("jobId"), "status": job.get("status"), "quality": quality}
|
|
273
277
|
|
|
274
278
|
|
|
275
279
|
def preview(path: Path) -> int:
|
|
@@ -22,6 +22,9 @@ from urllib.parse import urlsplit, urlunsplit
|
|
|
22
22
|
DEFAULT_SCENARIOS = Path(".maggie") / "scenario-manifest.json"
|
|
23
23
|
VALID_STATUSES = {"pending", "pass", "fail", "blocked", "inconclusive"}
|
|
24
24
|
VALID_PHASES = {"test", "fix", "retest"}
|
|
25
|
+
ASSERTION_SCHEMA = "maggie.qa-assertions.v1"
|
|
26
|
+
RUNTIME_ASSERTION_TYPES = {"http-status", "final-url", "text-present", "text-absent", "meta", "link-absent", "redirect"}
|
|
27
|
+
SOURCE_ASSERTION_TYPES = {"class-present", "class-absent", "markup-present", "markup-absent", "source-text"}
|
|
25
28
|
SECRET_RE = re.compile(
|
|
26
29
|
r"(?i)(bearer\s+|(?:api[_-]?key|token|secret|password|authorization|cookie)\s*[=:]\s*)[^\s,;]+"
|
|
27
30
|
)
|
|
@@ -314,6 +317,86 @@ def export_run(args: argparse.Namespace) -> int:
|
|
|
314
317
|
return 0
|
|
315
318
|
|
|
316
319
|
|
|
320
|
+
def assertion_audit(args: argparse.Namespace) -> int:
|
|
321
|
+
"""Ensure QA assertions test the served requirement, not edited source markup."""
|
|
322
|
+
project = project_root(args.project)
|
|
323
|
+
manifest_path = project_root(args.assertions) if Path(args.assertions).is_absolute() else project / args.assertions
|
|
324
|
+
manifest = load_json(manifest_path)
|
|
325
|
+
errors = []
|
|
326
|
+
records = manifest.get("assertions")
|
|
327
|
+
if manifest.get("schemaVersion") != ASSERTION_SCHEMA:
|
|
328
|
+
errors.append(f"manifest schemaVersion must be {ASSERTION_SCHEMA}")
|
|
329
|
+
if not isinstance(records, list) or not records:
|
|
330
|
+
errors.append("assertions must be a non-empty array")
|
|
331
|
+
records = []
|
|
332
|
+
seen = set()
|
|
333
|
+
audited = []
|
|
334
|
+
for index, item in enumerate(records):
|
|
335
|
+
item_errors = []
|
|
336
|
+
if not isinstance(item, dict):
|
|
337
|
+
audited.append({"id": f"assertion-{index + 1}", "passed": False, "errors": ["assertion must be an object"]})
|
|
338
|
+
continue
|
|
339
|
+
identifier = safe_text(item.get("id"), 120) or f"assertion-{index + 1}"
|
|
340
|
+
if identifier in seen:
|
|
341
|
+
item_errors.append("assertion id is duplicated")
|
|
342
|
+
seen.add(identifier)
|
|
343
|
+
if not safe_text(item.get("requirement"), 1000):
|
|
344
|
+
item_errors.append("requirement is required")
|
|
345
|
+
route = safe_text(item.get("route"), 500)
|
|
346
|
+
if not route or not route.startswith("/"):
|
|
347
|
+
item_errors.append("route must be a path on the tested site")
|
|
348
|
+
if item.get("transport") != "http":
|
|
349
|
+
item_errors.append("transport must be http runtime evidence")
|
|
350
|
+
evidence_data = item.get("evidence")
|
|
351
|
+
if not isinstance(evidence_data, dict):
|
|
352
|
+
item_errors.append("evidence must be an object")
|
|
353
|
+
evidence_data = {}
|
|
354
|
+
if not safe_text(evidence_data.get("screenshot"), 500):
|
|
355
|
+
item_errors.append("evidence.screenshot is required")
|
|
356
|
+
if not isinstance(evidence_data.get("httpStatus"), int) or isinstance(evidence_data.get("httpStatus"), bool):
|
|
357
|
+
item_errors.append("evidence.httpStatus must be an integer")
|
|
358
|
+
checks = item.get("checks")
|
|
359
|
+
if not isinstance(checks, list) or not checks:
|
|
360
|
+
item_errors.append("checks must be a non-empty array")
|
|
361
|
+
checks = []
|
|
362
|
+
if not any(isinstance(check, dict) and check.get("type") == "http-status" for check in checks):
|
|
363
|
+
item_errors.append("at least one http-status check is required")
|
|
364
|
+
for check_index, check in enumerate(checks):
|
|
365
|
+
if not isinstance(check, dict):
|
|
366
|
+
item_errors.append(f"checks[{check_index}] must be an object")
|
|
367
|
+
continue
|
|
368
|
+
check_type = check.get("type")
|
|
369
|
+
surface = check.get("surface")
|
|
370
|
+
if check_type in SOURCE_ASSERTION_TYPES or surface == "source":
|
|
371
|
+
item_errors.append(f"checks[{check_index}] is source/markup-only; assert the served route instead")
|
|
372
|
+
if check_type not in RUNTIME_ASSERTION_TYPES:
|
|
373
|
+
item_errors.append(f"checks[{check_index}] has an unsupported runtime assertion type")
|
|
374
|
+
if surface not in {"http", "browser-runtime"}:
|
|
375
|
+
item_errors.append(f"checks[{check_index}] must identify http or browser-runtime evidence")
|
|
376
|
+
if not isinstance(check.get("passed"), bool):
|
|
377
|
+
item_errors.append(f"checks[{check_index}].passed must be boolean evidence")
|
|
378
|
+
if check_type == "http-status" and isinstance(evidence_data.get("httpStatus"), int) and check.get("actual") != evidence_data["httpStatus"]:
|
|
379
|
+
item_errors.append(f"checks[{check_index}] actual status does not match evidence.httpStatus")
|
|
380
|
+
if any(isinstance(check, dict) and check.get("passed") is False for check in checks):
|
|
381
|
+
item_errors.append("one or more runtime checks failed")
|
|
382
|
+
audited.append({"id": identifier, "passed": not item_errors, "errors": item_errors})
|
|
383
|
+
passed = not errors and all(item.get("passed") for item in audited) and bool(audited)
|
|
384
|
+
report = {
|
|
385
|
+
"schemaVersion": "maggie.qa-assertion-audit.v1",
|
|
386
|
+
"assertionSchema": ASSERTION_SCHEMA,
|
|
387
|
+
"checked": len(audited),
|
|
388
|
+
"assertions": audited,
|
|
389
|
+
"errors": errors,
|
|
390
|
+
"passed": passed,
|
|
391
|
+
}
|
|
392
|
+
output = Path(args.output).expanduser()
|
|
393
|
+
if not output.is_absolute():
|
|
394
|
+
output = project / output
|
|
395
|
+
write_json(output, report)
|
|
396
|
+
print(json.dumps({"status": "passed" if passed else "failed", "report": str(output.resolve()), "checked": len(audited), "errors": errors}, ensure_ascii=False, indent=2))
|
|
397
|
+
return 0 if passed else 1
|
|
398
|
+
|
|
399
|
+
|
|
317
400
|
def parser() -> argparse.ArgumentParser:
|
|
318
401
|
root = argparse.ArgumentParser(description=__doc__)
|
|
319
402
|
root.add_argument("--project", default=".")
|
|
@@ -350,6 +433,10 @@ def parser() -> argparse.ArgumentParser:
|
|
|
350
433
|
command.add_argument("--run", required=True)
|
|
351
434
|
if name == "summary": command.add_argument("--format", choices=("json", "markdown"), default="json")
|
|
352
435
|
else: command.add_argument("--output", required=True)
|
|
436
|
+
assertion_parser = commands.add_parser("assertion-audit")
|
|
437
|
+
assertion_parser.add_argument("--project", default=argparse.SUPPRESS)
|
|
438
|
+
assertion_parser.add_argument("--assertions", required=True, help="runtime assertion manifest JSON")
|
|
439
|
+
assertion_parser.add_argument("--output", default=".maggie/qa-assertion-audit.json")
|
|
353
440
|
return root
|
|
354
441
|
|
|
355
442
|
|
|
@@ -359,6 +446,7 @@ def main() -> int:
|
|
|
359
446
|
if args.command == "start": return start(args)
|
|
360
447
|
if args.command == "record": return record(args)
|
|
361
448
|
if args.command == "summary": return summary(args)
|
|
449
|
+
if args.command == "assertion-audit": return assertion_audit(args)
|
|
362
450
|
return export_run(args)
|
|
363
451
|
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
364
452
|
print(f"QA workflow error: {safe_text(error)}", file=sys.stderr)
|