@topy-ai/maggie 0.7.13 → 0.7.15
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 +43 -7
- package/README.zh-TW.md +11 -3
- package/bin/maggie.js +8 -5
- package/bundled-skills/README.md +1 -0
- package/bundled-skills/catalog.json +4 -0
- package/bundled-skills/maggie-blog/SKILL.md +13 -9
- package/bundled-skills/maggie-dash/SKILL.md +17 -2
- package/bundled-skills/maggie-qa-workflow/SKILL.md +102 -0
- package/bundled-skills/maggie-seo-geo/SKILL.md +21 -0
- package/bundled-tools/clis/maggie_blog.py +5 -1
- package/bundled-tools/clis/maggie_dash.py +14 -1
- package/bundled-tools/clis/maggie_indexnow.py +60 -0
- package/bundled-tools/clis/maggie_qa_workflow.py +367 -0
- package/bundled-tools/runtime/maggie_api_contract.py +97 -0
- package/bundled-tools/runtime/maggie_blog.py +42 -1
- package/bundled-tools/runtime/maggie_indexnow.py +128 -0
- package/bundled-tools/runtime/maggie_quality.py +21 -4
- package/bundled-tools/runtime/maggie_sitemap.py +52 -1
- package/package.json +1 -1
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Track project scenario-based browser QA from test through retest.
|
|
3
|
+
|
|
4
|
+
The command stores secret-free run state in ``.maggie/qa-runs``. Browser
|
|
5
|
+
interaction remains with the selected browser skill; this CLI records the
|
|
6
|
+
scenario, evidence references, fix event and final release decision.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
DEFAULT_SCENARIOS = Path(".maggie") / "scenario-manifest.json"
|
|
23
|
+
VALID_STATUSES = {"pending", "pass", "fail", "blocked", "inconclusive"}
|
|
24
|
+
VALID_PHASES = {"test", "fix", "retest"}
|
|
25
|
+
SECRET_RE = re.compile(
|
|
26
|
+
r"(?i)(bearer\s+|(?:api[_-]?key|token|secret|password|authorization|cookie)\s*[=:]\s*)[^\s,;]+"
|
|
27
|
+
)
|
|
28
|
+
EMAIL_RE = re.compile(r"\b[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}\b")
|
|
29
|
+
QUERY_SECRET_RE = re.compile(r"(?i)([?&](?:token|key|secret|password|code|id_token|access_token)=)[^&\s]+")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def now() -> str:
|
|
33
|
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def safe_text(value: object, limit: int = 1200) -> str:
|
|
37
|
+
text = str(value or "").strip()
|
|
38
|
+
text = SECRET_RE.sub(r"\1[REDACTED]", text)
|
|
39
|
+
text = QUERY_SECRET_RE.sub(r"\1[REDACTED]", text)
|
|
40
|
+
text = EMAIL_RE.sub("[REDACTED_EMAIL]", text)
|
|
41
|
+
return text[:limit]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def safe_base_url(value: object) -> str:
|
|
45
|
+
"""Keep the origin only so reports do not retain private paths or queries."""
|
|
46
|
+
text = safe_text(value, 500)
|
|
47
|
+
try:
|
|
48
|
+
parsed = urlsplit(text)
|
|
49
|
+
if parsed.scheme and parsed.hostname:
|
|
50
|
+
netloc = parsed.hostname
|
|
51
|
+
if parsed.port:
|
|
52
|
+
netloc = f"{netloc}:{parsed.port}"
|
|
53
|
+
return urlunsplit((parsed.scheme, netloc, "", "", ""))
|
|
54
|
+
except ValueError:
|
|
55
|
+
pass
|
|
56
|
+
return text
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def project_root(value: str | os.PathLike[str]) -> Path:
|
|
60
|
+
return Path(value).expanduser().resolve()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def load_json(path: Path) -> dict:
|
|
64
|
+
try:
|
|
65
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
66
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
67
|
+
raise ValueError(f"could not read JSON {path}: {error}") from error
|
|
68
|
+
if not isinstance(value, dict):
|
|
69
|
+
raise ValueError(f"JSON root must be an object: {path}")
|
|
70
|
+
return value
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def scenario_records(path: Path) -> list[dict]:
|
|
74
|
+
manifest = load_json(path)
|
|
75
|
+
scenarios = manifest.get("scenarios")
|
|
76
|
+
if not isinstance(scenarios, list) or not scenarios:
|
|
77
|
+
raise ValueError("scenario manifest must contain a non-empty scenarios array")
|
|
78
|
+
result = []
|
|
79
|
+
seen: set[str] = set()
|
|
80
|
+
for item in scenarios:
|
|
81
|
+
if not isinstance(item, dict) or not isinstance(item.get("id"), str):
|
|
82
|
+
raise ValueError("each scenario must be an object with an id")
|
|
83
|
+
scenario_id = item["id"]
|
|
84
|
+
if scenario_id in seen:
|
|
85
|
+
raise ValueError(f"duplicate scenario id: {scenario_id}")
|
|
86
|
+
seen.add(scenario_id)
|
|
87
|
+
result.append(item)
|
|
88
|
+
return result
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def run_directory(project: Path) -> Path:
|
|
92
|
+
directory = project / ".maggie" / "qa-runs"
|
|
93
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
return directory
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def normal_run_id(value: str | None) -> str:
|
|
98
|
+
if value:
|
|
99
|
+
value = re.sub(r"[^A-Za-z0-9._-]+", "-", value).strip("-")
|
|
100
|
+
if not value:
|
|
101
|
+
raise ValueError("run id cannot be empty")
|
|
102
|
+
return value
|
|
103
|
+
return "qa-" + datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def run_path(project: Path, value: str) -> Path:
|
|
107
|
+
candidate = Path(value).expanduser()
|
|
108
|
+
if candidate.suffix == ".json" or candidate.parent != Path("."):
|
|
109
|
+
path = candidate if candidate.is_absolute() else project / candidate
|
|
110
|
+
else:
|
|
111
|
+
path = run_directory(project) / f"{normal_run_id(value)}.json"
|
|
112
|
+
path = path.resolve()
|
|
113
|
+
if not path.is_file():
|
|
114
|
+
raise ValueError(f"QA run does not exist: {path}")
|
|
115
|
+
return path
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def write_json(path: Path, value: dict) -> None:
|
|
119
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
120
|
+
temporary = path.with_suffix(path.suffix + ".tmp")
|
|
121
|
+
temporary.write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
122
|
+
temporary.replace(path)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def calculate(run: dict) -> dict:
|
|
126
|
+
counts = {status: 0 for status in VALID_STATUSES}
|
|
127
|
+
lifecycle_counts = {"ready": 0, "fix_pending": 0}
|
|
128
|
+
for item in run.get("scenarios", {}).values():
|
|
129
|
+
status = item.get("status", "pending")
|
|
130
|
+
counts[status] = counts.get(status, 0) + 1
|
|
131
|
+
lifecycle_counts["fix_pending" if item.get("lifecycle") == "fix_pending" else "ready"] += 1
|
|
132
|
+
if counts["fail"]:
|
|
133
|
+
gate = "fail"
|
|
134
|
+
elif counts["pending"] or counts["blocked"] or counts["inconclusive"]:
|
|
135
|
+
gate = "blocked"
|
|
136
|
+
else:
|
|
137
|
+
gate = "pass"
|
|
138
|
+
return {"gate": gate, "counts": counts, "lifecycle": lifecycle_counts, "total": sum(counts.values())}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def start(args: argparse.Namespace) -> int:
|
|
142
|
+
project = project_root(args.project)
|
|
143
|
+
manifest_path = project_root(args.scenario_file) if Path(args.scenario_file).is_absolute() else project / args.scenario_file
|
|
144
|
+
records = scenario_records(manifest_path)
|
|
145
|
+
run_id = normal_run_id(args.run_id)
|
|
146
|
+
path = run_directory(project) / f"{run_id}.json"
|
|
147
|
+
if path.exists():
|
|
148
|
+
raise ValueError(f"QA run already exists: {path}")
|
|
149
|
+
scenarios = {
|
|
150
|
+
item["id"]: {
|
|
151
|
+
"id": item["id"],
|
|
152
|
+
"group": item.get("group", ""),
|
|
153
|
+
"title": item.get("title", ""),
|
|
154
|
+
"priority": item.get("priority", "P2"),
|
|
155
|
+
"routes": item.get("routes", []),
|
|
156
|
+
"auth": item.get("auth", ""),
|
|
157
|
+
"browser": bool(item.get("browser", True)),
|
|
158
|
+
"status": "pending",
|
|
159
|
+
"lifecycle": "ready",
|
|
160
|
+
"history": [],
|
|
161
|
+
}
|
|
162
|
+
for item in records
|
|
163
|
+
}
|
|
164
|
+
try:
|
|
165
|
+
manifest_label = str(manifest_path.relative_to(project))
|
|
166
|
+
except ValueError:
|
|
167
|
+
# Do not persist an absolute path outside the project. The basename is
|
|
168
|
+
# enough to identify the source without leaking a local filesystem
|
|
169
|
+
# layout into a shareable run artifact.
|
|
170
|
+
manifest_label = manifest_path.name
|
|
171
|
+
run = {
|
|
172
|
+
"schemaVersion": "maggie.qa-run.v1",
|
|
173
|
+
"runId": run_id,
|
|
174
|
+
"createdAt": now(),
|
|
175
|
+
"updatedAt": now(),
|
|
176
|
+
"project": hashlib.sha256(str(project).encode()).hexdigest()[:16],
|
|
177
|
+
"environment": safe_text(args.environment),
|
|
178
|
+
"baseUrl": safe_base_url(args.base_url),
|
|
179
|
+
"browser": safe_text(args.browser),
|
|
180
|
+
"commit": safe_text(args.commit),
|
|
181
|
+
"release": safe_text(args.release),
|
|
182
|
+
"scenarioManifest": manifest_label,
|
|
183
|
+
"scenarios": scenarios,
|
|
184
|
+
}
|
|
185
|
+
run["summary"] = calculate(run)
|
|
186
|
+
write_json(path, run)
|
|
187
|
+
print(json.dumps({"runId": run_id, "path": str(path), "summary": run["summary"]}, ensure_ascii=False, indent=2))
|
|
188
|
+
return 0
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def evidence(value: str, project: Path) -> dict:
|
|
192
|
+
clean = safe_text(value, 500)
|
|
193
|
+
path = Path(value).expanduser()
|
|
194
|
+
if path.is_file():
|
|
195
|
+
resolved = path.resolve()
|
|
196
|
+
try:
|
|
197
|
+
display = str(resolved.relative_to(project))
|
|
198
|
+
except ValueError:
|
|
199
|
+
display = resolved.name
|
|
200
|
+
return {"path": display, "sha256": hashlib.sha256(resolved.read_bytes()).hexdigest()}
|
|
201
|
+
return {"reference": clean}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def record(args: argparse.Namespace) -> int:
|
|
205
|
+
project = project_root(args.project)
|
|
206
|
+
path = run_path(project, args.run)
|
|
207
|
+
run = load_json(path)
|
|
208
|
+
scenarios = run.get("scenarios", {})
|
|
209
|
+
item = scenarios.get(args.scenario)
|
|
210
|
+
if not isinstance(item, dict):
|
|
211
|
+
raise ValueError(f"scenario is not in this run: {args.scenario}")
|
|
212
|
+
phase = args.phase
|
|
213
|
+
if phase not in VALID_PHASES:
|
|
214
|
+
raise ValueError(f"phase must be one of: {', '.join(sorted(VALID_PHASES))}")
|
|
215
|
+
if phase in {"test", "retest"} and args.status not in VALID_STATUSES - {"pending"}:
|
|
216
|
+
raise ValueError("test and retest require status pass, fail, blocked or inconclusive")
|
|
217
|
+
if phase == "fix" and not args.resolution:
|
|
218
|
+
raise ValueError("fix requires --resolution")
|
|
219
|
+
if phase == "fix" and item.get("status") != "fail":
|
|
220
|
+
raise ValueError("fix can only follow a recorded fail; use retest after a blocked or inconclusive run")
|
|
221
|
+
if phase == "retest" and not any(event.get("phase") == "test" for event in item.get("history", [])):
|
|
222
|
+
raise ValueError("retest requires an earlier test event")
|
|
223
|
+
if phase == "retest" and item.get("status") == "fail" and not any(event.get("phase") == "fix" for event in item.get("history", [])):
|
|
224
|
+
raise ValueError("a failed scenario must have a recorded fix before retest")
|
|
225
|
+
if phase == "test" and args.status == "fail" and not (args.expected and args.actual and args.error_fingerprint):
|
|
226
|
+
raise ValueError("a failed test requires --expected, --actual and --error-fingerprint")
|
|
227
|
+
event = {
|
|
228
|
+
"at": now(),
|
|
229
|
+
"phase": phase,
|
|
230
|
+
"status": args.status if phase != "fix" else "fix_pending",
|
|
231
|
+
"summary": safe_text(args.summary),
|
|
232
|
+
"expected": safe_text(args.expected),
|
|
233
|
+
"actual": safe_text(args.actual),
|
|
234
|
+
"errorFingerprint": safe_text(args.error_fingerprint, 300),
|
|
235
|
+
"issue": safe_text(args.issue, 500),
|
|
236
|
+
"resolution": safe_text(args.resolution),
|
|
237
|
+
"validation": safe_text(args.validation),
|
|
238
|
+
"evidence": [evidence(value, project) for value in args.evidence],
|
|
239
|
+
}
|
|
240
|
+
item.setdefault("history", []).append(event)
|
|
241
|
+
if phase == "fix":
|
|
242
|
+
item["lifecycle"] = "fix_pending"
|
|
243
|
+
else:
|
|
244
|
+
item["status"] = args.status
|
|
245
|
+
item["lifecycle"] = "ready"
|
|
246
|
+
run["updatedAt"] = now()
|
|
247
|
+
run["summary"] = calculate(run)
|
|
248
|
+
write_json(path, run)
|
|
249
|
+
print(json.dumps({"runId": run.get("runId"), "scenario": args.scenario, "event": event, "summary": run["summary"]}, ensure_ascii=False, indent=2))
|
|
250
|
+
return 0
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def markdown(run: dict) -> str:
|
|
254
|
+
summary = run.get("summary") or calculate(run)
|
|
255
|
+
lines = [
|
|
256
|
+
f"# Maggie QA run `{run.get('runId', '')}`",
|
|
257
|
+
"",
|
|
258
|
+
"<!-- Generated by maggie_qa_workflow.py. Evidence is metadata-only. -->",
|
|
259
|
+
"",
|
|
260
|
+
f"- Environment: `{run.get('environment') or 'not provided'}`",
|
|
261
|
+
f"- Base URL: `{run.get('baseUrl') or 'not provided'}`",
|
|
262
|
+
f"- Browser: `{run.get('browser') or 'not provided'}`",
|
|
263
|
+
f"- Commit/release: `{run.get('commit') or 'not provided'}` / `{run.get('release') or 'not provided'}`",
|
|
264
|
+
f"- Gate: **{summary.get('gate', 'blocked').upper()}**",
|
|
265
|
+
"",
|
|
266
|
+
"## Scenario summary",
|
|
267
|
+
"",
|
|
268
|
+
"| ID | Priority | Scenario | Status | Lifecycle | Last result |",
|
|
269
|
+
"|---|---|---|---|---|---|",
|
|
270
|
+
]
|
|
271
|
+
for scenario in run.get("scenarios", {}).values():
|
|
272
|
+
history = scenario.get("history", [])
|
|
273
|
+
last = history[-1] if history else {}
|
|
274
|
+
result = safe_text(last.get("summary") or last.get("actual") or "Not tested", 180).replace("|", "\\|")
|
|
275
|
+
lines.append(f"| {scenario.get('id')} | {scenario.get('priority')} | {scenario.get('title')} | **{scenario.get('status')}** | {scenario.get('lifecycle')} | {result} |")
|
|
276
|
+
lines += ["", "## Fix and retest history", ""]
|
|
277
|
+
for scenario in run.get("scenarios", {}).values():
|
|
278
|
+
for event in scenario.get("history", []):
|
|
279
|
+
lines.append(f"### {scenario.get('id')} — {event.get('phase')} ({event.get('at')})")
|
|
280
|
+
lines.append("")
|
|
281
|
+
if event.get("summary"): lines.append(f"- Summary: {event['summary']}")
|
|
282
|
+
if event.get("expected"): lines.append(f"- Expected: {event['expected']}")
|
|
283
|
+
if event.get("actual"): lines.append(f"- Actual: {event['actual']}")
|
|
284
|
+
if event.get("errorFingerprint"): lines.append(f"- Error fingerprint: `{event['errorFingerprint']}`")
|
|
285
|
+
if event.get("resolution"): lines.append(f"- Resolution: {event['resolution']}")
|
|
286
|
+
if event.get("validation"): lines.append(f"- Validation: {event['validation']}")
|
|
287
|
+
references = event.get("evidence", [])
|
|
288
|
+
if references: lines.append(f"- Evidence: {', '.join(str(value.get('path') or value.get('reference')) for value in references)}")
|
|
289
|
+
lines.append("")
|
|
290
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def summary(args: argparse.Namespace) -> int:
|
|
294
|
+
project = project_root(args.project)
|
|
295
|
+
run = load_json(run_path(project, args.run))
|
|
296
|
+
result = run.get("summary") or calculate(run)
|
|
297
|
+
if args.format == "markdown":
|
|
298
|
+
print(markdown(run))
|
|
299
|
+
else:
|
|
300
|
+
print(json.dumps({"runId": run.get("runId"), **result}, ensure_ascii=False, indent=2))
|
|
301
|
+
return 0 if result.get("gate") == "pass" else 1
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def export_run(args: argparse.Namespace) -> int:
|
|
305
|
+
project = project_root(args.project)
|
|
306
|
+
run = load_json(run_path(project, args.run))
|
|
307
|
+
output = Path(args.output).expanduser()
|
|
308
|
+
if not output.is_absolute(): output = project / output
|
|
309
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
310
|
+
output.write_text(markdown(run), encoding="utf-8")
|
|
311
|
+
print(json.dumps({"runId": run.get("runId"), "output": str(output.resolve()), "gate": run.get("summary", {}).get("gate")}, ensure_ascii=False, indent=2))
|
|
312
|
+
return 0
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def parser() -> argparse.ArgumentParser:
|
|
316
|
+
root = argparse.ArgumentParser(description=__doc__)
|
|
317
|
+
root.add_argument("--project", default=".")
|
|
318
|
+
commands = root.add_subparsers(dest="command", required=True)
|
|
319
|
+
|
|
320
|
+
start_parser = commands.add_parser("start")
|
|
321
|
+
start_parser.add_argument("--project", default=argparse.SUPPRESS)
|
|
322
|
+
start_parser.add_argument("--scenario-file", default=str(DEFAULT_SCENARIOS))
|
|
323
|
+
start_parser.add_argument("--run-id")
|
|
324
|
+
start_parser.add_argument("--environment", required=True)
|
|
325
|
+
start_parser.add_argument("--base-url", required=True)
|
|
326
|
+
start_parser.add_argument("--browser", default="chrome")
|
|
327
|
+
start_parser.add_argument("--commit", default="")
|
|
328
|
+
start_parser.add_argument("--release", default="")
|
|
329
|
+
|
|
330
|
+
record_parser = commands.add_parser("record")
|
|
331
|
+
record_parser.add_argument("--project", default=argparse.SUPPRESS)
|
|
332
|
+
record_parser.add_argument("--run", required=True)
|
|
333
|
+
record_parser.add_argument("--scenario", required=True)
|
|
334
|
+
record_parser.add_argument("--phase", choices=sorted(VALID_PHASES), required=True)
|
|
335
|
+
record_parser.add_argument("--status", choices=sorted(VALID_STATUSES - {"pending"}))
|
|
336
|
+
record_parser.add_argument("--summary", default="")
|
|
337
|
+
record_parser.add_argument("--expected", default="")
|
|
338
|
+
record_parser.add_argument("--actual", default="")
|
|
339
|
+
record_parser.add_argument("--error-fingerprint", default="")
|
|
340
|
+
record_parser.add_argument("--issue", default="")
|
|
341
|
+
record_parser.add_argument("--resolution", default="")
|
|
342
|
+
record_parser.add_argument("--validation", default="")
|
|
343
|
+
record_parser.add_argument("--evidence", action="append", default=[])
|
|
344
|
+
|
|
345
|
+
for name in ("summary", "export"):
|
|
346
|
+
command = commands.add_parser(name)
|
|
347
|
+
command.add_argument("--project", default=argparse.SUPPRESS)
|
|
348
|
+
command.add_argument("--run", required=True)
|
|
349
|
+
if name == "summary": command.add_argument("--format", choices=("json", "markdown"), default="json")
|
|
350
|
+
else: command.add_argument("--output", required=True)
|
|
351
|
+
return root
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def main() -> int:
|
|
355
|
+
args = parser().parse_args()
|
|
356
|
+
try:
|
|
357
|
+
if args.command == "start": return start(args)
|
|
358
|
+
if args.command == "record": return record(args)
|
|
359
|
+
if args.command == "summary": return summary(args)
|
|
360
|
+
return export_run(args)
|
|
361
|
+
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
362
|
+
print(f"QA workflow error: {safe_text(error)}", file=sys.stderr)
|
|
363
|
+
return 2
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
if __name__ == "__main__":
|
|
367
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Provider-neutral request/response contract checks for HTTP APIs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
SCHEMA = "maggie-api-contract.v1"
|
|
10
|
+
METHODS = {"get", "post", "put", "patch", "delete", "head", "options", "trace"}
|
|
11
|
+
BODY_METHODS = {"post", "put", "patch"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _has_schema(value: object) -> bool:
|
|
15
|
+
if not isinstance(value, Mapping):
|
|
16
|
+
return False
|
|
17
|
+
if "$ref" in value:
|
|
18
|
+
return bool(str(value["$ref"]).strip())
|
|
19
|
+
schema = value.get("schema")
|
|
20
|
+
if isinstance(schema, Mapping):
|
|
21
|
+
return _has_schema(schema) or bool(schema)
|
|
22
|
+
content = value.get("content")
|
|
23
|
+
if isinstance(content, Mapping):
|
|
24
|
+
return any(_has_schema(media) for media in content.values())
|
|
25
|
+
return False
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _success_responses(responses: Mapping[str, Any]) -> list[tuple[str, Mapping[str, Any]]]:
|
|
29
|
+
result = []
|
|
30
|
+
for code, response in responses.items():
|
|
31
|
+
label = str(code).upper()
|
|
32
|
+
if label.startswith("2") or label == "2XX":
|
|
33
|
+
result.append((str(code), response if isinstance(response, Mapping) else {}))
|
|
34
|
+
return result
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def validate_api_contract(spec: object) -> dict[str, Any]:
|
|
38
|
+
"""Validate declared body and success response shapes without guessing fields.
|
|
39
|
+
|
|
40
|
+
Empty request/response bodies are valid only when they are explicit (or a
|
|
41
|
+
204 response). This keeps a consumer from inventing fields for an
|
|
42
|
+
under-specified endpoint while allowing intentional command-style APIs.
|
|
43
|
+
"""
|
|
44
|
+
errors: list[dict[str, str]] = []
|
|
45
|
+
operations: list[dict[str, Any]] = []
|
|
46
|
+
paths = spec.get("paths") if isinstance(spec, Mapping) else None
|
|
47
|
+
if not isinstance(paths, Mapping):
|
|
48
|
+
return {"schemaVersion": SCHEMA, "passed": False, "operations": [],
|
|
49
|
+
"errors": [{"code": "paths-missing", "message": "spec.paths must be an object"}]}
|
|
50
|
+
for path, path_item in sorted(paths.items(), key=lambda item: str(item[0])):
|
|
51
|
+
if not isinstance(path_item, Mapping):
|
|
52
|
+
errors.append({"path": str(path), "code": "path-invalid", "message": "path item must be an object"})
|
|
53
|
+
continue
|
|
54
|
+
for method, operation in sorted(path_item.items(), key=lambda item: str(item[0])):
|
|
55
|
+
verb = str(method).casefold()
|
|
56
|
+
if verb not in METHODS:
|
|
57
|
+
continue
|
|
58
|
+
operation_id = str((operation or {}).get("operationId") or f"{verb} {path}") if isinstance(operation, Mapping) else f"{verb} {path}"
|
|
59
|
+
record: dict[str, Any] = {"path": str(path), "method": verb.upper(), "operationId": operation_id}
|
|
60
|
+
if not isinstance(operation, Mapping):
|
|
61
|
+
errors.append({"path": str(path), "method": verb.upper(), "code": "operation-invalid", "message": "operation must be an object"})
|
|
62
|
+
continue
|
|
63
|
+
if verb in BODY_METHODS:
|
|
64
|
+
body = operation.get("requestBody")
|
|
65
|
+
if body is None and not operation.get("x-maggie-empty-request-body"):
|
|
66
|
+
errors.append({"path": str(path), "method": verb.upper(), "code": "request-body-undeclared", "message": "declare requestBody or x-maggie-empty-request-body"})
|
|
67
|
+
record["requestBody"] = "missing"
|
|
68
|
+
elif operation.get("x-maggie-empty-request-body"):
|
|
69
|
+
record["requestBody"] = "explicit-empty"
|
|
70
|
+
elif not _has_schema(body):
|
|
71
|
+
errors.append({"path": str(path), "method": verb.upper(), "code": "request-body-schema-missing", "message": "requestBody must declare content.schema or $ref"})
|
|
72
|
+
record["requestBody"] = "schema-missing"
|
|
73
|
+
else:
|
|
74
|
+
record["requestBody"] = "declared"
|
|
75
|
+
else:
|
|
76
|
+
record["requestBody"] = "not-applicable"
|
|
77
|
+
responses = operation.get("responses")
|
|
78
|
+
if not isinstance(responses, Mapping):
|
|
79
|
+
errors.append({"path": str(path), "method": verb.upper(), "code": "responses-missing", "message": "declare responses with a 2xx success response"})
|
|
80
|
+
record["successResponse"] = "missing"
|
|
81
|
+
else:
|
|
82
|
+
successes = _success_responses(responses)
|
|
83
|
+
if not successes:
|
|
84
|
+
errors.append({"path": str(path), "method": verb.upper(), "code": "success-response-missing", "message": "declare at least one 2xx response"})
|
|
85
|
+
record["successResponse"] = "missing"
|
|
86
|
+
else:
|
|
87
|
+
code, response = successes[0]
|
|
88
|
+
if code == "204" or response.get("x-maggie-empty-response"):
|
|
89
|
+
record["successResponse"] = f"{code}:explicit-empty"
|
|
90
|
+
elif not _has_schema(response):
|
|
91
|
+
errors.append({"path": str(path), "method": verb.upper(), "code": "success-response-schema-missing", "message": f"response {code} must declare content.schema, $ref, or x-maggie-empty-response"})
|
|
92
|
+
record["successResponse"] = f"{code}:schema-missing"
|
|
93
|
+
else:
|
|
94
|
+
record["successResponse"] = f"{code}:declared"
|
|
95
|
+
operations.append(record)
|
|
96
|
+
return {"schemaVersion": SCHEMA, "passed": not errors, "operations": operations, "errors": errors,
|
|
97
|
+
"operationCount": len(operations), "errorCount": len(errors)}
|
|
@@ -18,6 +18,8 @@ DEFAULTS = {
|
|
|
18
18
|
"postsPerPage": 9,
|
|
19
19
|
"topicLimit": 12,
|
|
20
20
|
"defaultPostStatus": "draft",
|
|
21
|
+
"requireReview": True,
|
|
22
|
+
"autoPublishEnabled": False,
|
|
21
23
|
"autoPullEnabled": False,
|
|
22
24
|
"pullIntervalMinutes": 120,
|
|
23
25
|
"maxPerRun": 1,
|
|
@@ -142,6 +144,12 @@ class BlogStore:
|
|
|
142
144
|
if not content_id or not title: raise ValueError("each post requires contentId/id and title")
|
|
143
145
|
old = by_content.get(content_id)
|
|
144
146
|
raw_slug = str(item.get("slug") or title)
|
|
147
|
+
initial_status = settings.get("defaultPostStatus", "draft")
|
|
148
|
+
# A provider must not turn an import into an implicit publication.
|
|
149
|
+
# Older projects may still carry defaultPostStatus=published; the
|
|
150
|
+
# review gate makes that configuration visible and safe here.
|
|
151
|
+
if settings.get("requireReview", True) and initial_status != "draft":
|
|
152
|
+
initial_status = "draft"
|
|
145
153
|
post = {
|
|
146
154
|
"schemaVersion": "maggie-blog-post.v1",
|
|
147
155
|
"id": old["id"] if old else f"post:{content_id}",
|
|
@@ -156,7 +164,7 @@ class BlogStore:
|
|
|
156
164
|
"topics": [{"slug": slugify(str(topic)), "label": str(topic)} for topic in item.get("topics", item.get("keywords", []))],
|
|
157
165
|
# Provider input can suggest a status, but cannot bypass the
|
|
158
166
|
# local approval gate. Existing published state is preserved.
|
|
159
|
-
"status": old.get("status",
|
|
167
|
+
"status": old.get("status", initial_status) if old else initial_status,
|
|
160
168
|
"canonicalUrl": item.get("canonicalUrl"),
|
|
161
169
|
"source": {"provider": provider, "revision": str(item.get("revision") or checksum(item))},
|
|
162
170
|
"publishedAt": old.get("publishedAt") if old else item.get("publishedAt"),
|
|
@@ -183,6 +191,8 @@ class BlogStore:
|
|
|
183
191
|
|
|
184
192
|
def validate(self) -> dict:
|
|
185
193
|
settings = self.settings(); posts = self.posts(); errors = []
|
|
194
|
+
gate = self.review_gate(settings)
|
|
195
|
+
errors.extend(f"review gate: {error}" for error in gate["errors"])
|
|
186
196
|
ids = [p.get("contentId") for p in posts]; slugs = [p.get("slug") for p in posts]
|
|
187
197
|
if len(ids) != len(set(ids)): errors.append("duplicate contentId")
|
|
188
198
|
if len(slugs) != len(set(slugs)): errors.append("duplicate slug")
|
|
@@ -192,11 +202,42 @@ class BlogStore:
|
|
|
192
202
|
if post.get("status") == "published" and not post.get("publishedAt"): errors.append(f"{post.get('id')}: publishedAt required")
|
|
193
203
|
return {"valid": not errors, "posts": len(posts), "published": sum(p.get("status") == "published" for p in posts), "settings": settings, "errors": errors}
|
|
194
204
|
|
|
205
|
+
@staticmethod
|
|
206
|
+
def review_gate(settings: dict) -> dict:
|
|
207
|
+
"""Report whether generated content is forced through human review."""
|
|
208
|
+
errors: list[str] = []
|
|
209
|
+
require_review = bool(settings.get("requireReview", True))
|
|
210
|
+
auto_publish = bool(settings.get("autoPublishEnabled", False))
|
|
211
|
+
default_status = str(settings.get("defaultPostStatus", "draft"))
|
|
212
|
+
if auto_publish:
|
|
213
|
+
errors.append("autoPublishEnabled must be false for provider-neutral draft-first publishing")
|
|
214
|
+
if not require_review:
|
|
215
|
+
errors.append("requireReview must be true")
|
|
216
|
+
if default_status == "published":
|
|
217
|
+
errors.append("defaultPostStatus cannot be published")
|
|
218
|
+
return {"schemaVersion": "maggie-blog-review-gate.v1", "passed": not errors,
|
|
219
|
+
"requireReview": require_review, "autoPublishEnabled": auto_publish,
|
|
220
|
+
"defaultPostStatus": default_status, "errors": errors,
|
|
221
|
+
"mutation": False}
|
|
222
|
+
|
|
223
|
+
def approve(self, slug: str, actor: str, reason: str) -> dict:
|
|
224
|
+
if not actor or not reason:
|
|
225
|
+
raise ValueError("actor and reason are required")
|
|
226
|
+
posts = self.posts(); found = next((p for p in posts if p.get("slug") == slug), None)
|
|
227
|
+
if not found: raise ValueError(f"post not found: {slug}")
|
|
228
|
+
if found["status"] not in {"draft", "review"}:
|
|
229
|
+
raise ValueError(f"post cannot approve from {found['status']}")
|
|
230
|
+
found["status"] = "approved"; found["updatedAt"] = now()
|
|
231
|
+
found["lastTransition"] = {"actor": actor, "reason": reason, "at": now()}
|
|
232
|
+
self._backup(); self._write(self.posts_path, posts); return found
|
|
233
|
+
|
|
195
234
|
def publish(self, slug: str, actor: str, reason: str) -> dict:
|
|
196
235
|
if not actor or not reason: raise ValueError("actor and reason are required")
|
|
197
236
|
posts = self.posts(); found = next((p for p in posts if p.get("slug") == slug), None)
|
|
198
237
|
if not found: raise ValueError(f"post not found: {slug}")
|
|
199
238
|
if found["status"] not in {"draft", "review", "approved"}: raise ValueError(f"post cannot publish from {found['status']}")
|
|
239
|
+
if self.settings().get("requireReview", True) and found["status"] != "approved":
|
|
240
|
+
raise ValueError("post requires explicit approval before publish")
|
|
200
241
|
found["status"] = "published"; found["publishedAt"] = found.get("publishedAt") or now(); found["updatedAt"] = now(); found["lastTransition"] = {"actor": actor, "reason": reason, "at": now()}
|
|
201
242
|
self._backup(); self._write(self.posts_path, posts); return found
|
|
202
243
|
|