@topy-ai/maggie 0.7.18 → 0.7.19
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 +28 -4
- package/README.zh-TW.md +3 -1
- package/bin/maggie.js +9 -0
- package/bundled-contracts/maggiedash/README.md +19 -0
- package/bundled-contracts/maggiedash/activity-log-v1.json +27 -0
- package/bundled-contracts/maggiedash/dashboard-runtime-v1.json +33 -0
- package/bundled-contracts/maggiedash/navigation-v1.json +29 -0
- package/bundled-skills/maggie-dash/SKILL.md +22 -1
- package/bundled-tools/clis/maggie_dash.py +26 -5
- package/bundled-tools/clis/maggie_docs.py +151 -0
- package/bundled-tools/runtime/maggie_api_contract.py +51 -0
- package/bundled-tools/runtime/maggie_dash_runtime.py +58 -0
- package/bundled-tools/runtime/maggie_schema_audit.py +72 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -76,6 +76,24 @@ maggie qa summary --project . --run <run-id>
|
|
|
76
76
|
The QA workflow stores secret-free run state under `.maggie/qa-runs/` and
|
|
77
77
|
keeps project-specific scenarios and evidence outside the npm package.
|
|
78
78
|
|
|
79
|
+
Dashboard and documentation audits are provider-neutral and keep host data
|
|
80
|
+
behind explicit evidence files:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
maggie dash api-contract --spec .maggie/openapi.json
|
|
84
|
+
maggie dash api-contract --spec https://example.test/openapi.json
|
|
85
|
+
maggie dash schema-audit --inventory .maggie/schema-inventory.json --fail-on-unread
|
|
86
|
+
maggie dash ui runtime-validate --evidence .maggie/dashboard-runtime.json
|
|
87
|
+
maggie docs audit --project . --docs-dir docs --output docs/documentation-audit.json
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The API checker accepts a local JSON file or a public HTTPS URL and validates
|
|
91
|
+
the response in memory. Schema audits use host-provided table, row-count, and
|
|
92
|
+
reader evidence; they never guess from a production database. Runtime
|
|
93
|
+
evidence proves that a dashboard screen mounted and executed its checks, so a
|
|
94
|
+
static component scan cannot pass by itself. MaggieDash hosts may also expose
|
|
95
|
+
the provider-neutral activity-log and managed-navigation contracts.
|
|
96
|
+
|
|
79
97
|
The current package also includes reusable safeguards from the latest feedback
|
|
80
98
|
review: `maggie dash api-contract` checks declared request and 2xx response
|
|
81
99
|
shapes; `maggie blog check-gate`/`approve` enforces review before publish;
|
|
@@ -151,7 +169,10 @@ maggie dash transition ... # explicit content approval transition
|
|
|
151
169
|
maggie dash variant ... # service variant create/review/preview/publish
|
|
152
170
|
maggie dash sections ... # field fan-out, locale, binding, media, copy, identity keys
|
|
153
171
|
maggie dash inventory ... # disjoint renderer/page-kind inventory + coverage
|
|
154
|
-
maggie dash api-contract ... # declared API body and 2xx
|
|
172
|
+
maggie dash api-contract ... # local/live declared API body and 2xx contract
|
|
173
|
+
maggie dash schema-audit ... # host schema reader-evidence audit
|
|
174
|
+
maggie dash ui runtime-validate ... # mounted dashboard runtime evidence
|
|
175
|
+
maggie docs audit ... # documentation hygiene audit
|
|
155
176
|
maggie agent-content write ... # host-authorized, origin-bound content bridge
|
|
156
177
|
maggie verification coverage ... # changed surface/locale evidence gate
|
|
157
178
|
maggie clone ... # authorized homepage capture
|
|
@@ -284,8 +305,8 @@ artifact schemas.
|
|
|
284
305
|
Recommended upgrade sequence for the current release:
|
|
285
306
|
|
|
286
307
|
```bash
|
|
287
|
-
npx @topy-ai/maggie@0.7.
|
|
288
|
-
npx @topy-ai/maggie@0.7.
|
|
308
|
+
npx @topy-ai/maggie@0.7.19 update --project . --force
|
|
309
|
+
npx @topy-ai/maggie@0.7.19 cleanup --project .
|
|
289
310
|
```
|
|
290
311
|
|
|
291
312
|
Maintainers should pass npm credentials through the repository helper, never
|
|
@@ -295,7 +316,10 @@ as a command-line argument:
|
|
|
295
316
|
node scripts/publish-npm.mjs --maggie-env-file ../.env
|
|
296
317
|
```
|
|
297
318
|
|
|
298
|
-
The 0.7.
|
|
319
|
+
The 0.7.19 workflow adds documentation hygiene audits, live URL API contract
|
|
320
|
+
validation, host-supplied schema reader audits, dashboard runtime evidence,
|
|
321
|
+
and provider-neutral activity-log and managed-navigation contracts. The 0.7.18
|
|
322
|
+
workflow adds staged/untracked changed-surface detection, scenario
|
|
299
323
|
QA release integration, market/locale-aware service variant routes and
|
|
300
324
|
reciprocal hreflang, provider capability matrices with fixture evidence, and
|
|
301
325
|
feedback fixed-proof/path-privacy gates. The 0.7.17 workflow adds sanitized database-backed blog gate adapters, deployed
|
package/README.zh-TW.md
CHANGED
|
@@ -8,7 +8,7 @@ Codex、Claude Code 與相容的 coding agents。
|
|
|
8
8
|
## 安裝
|
|
9
9
|
|
|
10
10
|
```bash
|
|
11
|
-
npx @topy-ai/maggie@0.7.
|
|
11
|
+
npx @topy-ai/maggie@0.7.19 init --agent all
|
|
12
12
|
npx @topy-ai/maggie doctor --project .
|
|
13
13
|
```
|
|
14
14
|
|
|
@@ -25,6 +25,8 @@ maggie doctor --project . --require-bootstrap --strict
|
|
|
25
25
|
deployment、memory、feedback 和 MaggieDash。內容先 draft/review,外部寫入、
|
|
26
26
|
publish 與 production deployment 需要明確確認。
|
|
27
27
|
|
|
28
|
+
0.7.19 加入文件 hygiene audit、live URL API contract、schema reader audit、
|
|
29
|
+
dashboard runtime evidence,以及 activity-log 和 managed-navigation contracts。
|
|
28
30
|
0.7.18 加入 staged/untracked changed-surface release gate、QA run 與 release
|
|
29
31
|
整合、market/locale-aware service variant route、provider capability matrix
|
|
30
32
|
fixture evidence,以及 feedback fixed-proof/path privacy gate。0.7.16 補強 review gate 的 CI exit code 與 IndexNow retry regression;
|
package/bin/maggie.js
CHANGED
|
@@ -61,6 +61,9 @@ Usage:
|
|
|
61
61
|
maggie dash sections <validate|prompt|keys|remap-translations|fanout-validate|locale-validate|variant-copy-validate|media-validate|bindings-validate|idempotency-validate|reconcile> [options]
|
|
62
62
|
maggie dash inventory --pages-file FILE [--require-page-kinds] [--require-source-coverage]
|
|
63
63
|
maggie dash api-contract --spec FILE
|
|
64
|
+
maggie dash api-contract --spec https://example.test/openapi.json
|
|
65
|
+
maggie dash schema-audit --inventory FILE [--fail-on-unread]
|
|
66
|
+
maggie dash ui runtime-validate --evidence FILE
|
|
64
67
|
maggie dash components-audit --bindings-file FILE --sections-file FILE --pages-file FILE
|
|
65
68
|
maggie dash status --project PATH
|
|
66
69
|
maggie dash migrate --project PATH --confirm
|
|
@@ -301,6 +304,11 @@ function service(args) {
|
|
|
301
304
|
process.exitCode = result.status ?? 1;
|
|
302
305
|
}
|
|
303
306
|
|
|
307
|
+
function docs(args) {
|
|
308
|
+
if (args[0] && args[0] !== "audit") throw new Error("docs command must be audit");
|
|
309
|
+
workflowCli("maggie_docs.py", args);
|
|
310
|
+
}
|
|
311
|
+
|
|
304
312
|
function seo(args) {
|
|
305
313
|
const command = args[0];
|
|
306
314
|
const scripts = { performance: "maggie_performance.py", images: "maggie_images.py", sitemap: "maggie_sitemap.py", indexnow: "maggie_indexnow.py", "social-cards": "maggie_social_cards.py", "head-tags": "maggie_head_tags.py" };
|
|
@@ -396,6 +404,7 @@ try {
|
|
|
396
404
|
else if (command === "auth") workflowCli("maggie_auth.py", args);
|
|
397
405
|
else if (command === "blog") workflowCli("maggie_blog.py", args);
|
|
398
406
|
else if (command === "service") service(args);
|
|
407
|
+
else if (command === "docs") docs(args);
|
|
399
408
|
else if (command === "seo") seo(args);
|
|
400
409
|
else if (command === "ops") workflowCli("maggie_ops.py", args);
|
|
401
410
|
else if (command === "deployment" && args[0] === "canary") workflowCli("maggie_deployment_canary.py", args.slice(1));
|
|
@@ -42,6 +42,25 @@ but they may not change the required identity, status, provenance, or approval
|
|
|
42
42
|
fields. Unknown fields must be preserved or reported as unsupported during
|
|
43
43
|
import.
|
|
44
44
|
|
|
45
|
+
## Operational UI contracts
|
|
46
|
+
|
|
47
|
+
- [`activity-log-v1.json`](activity-log-v1.json) defines append-only activity
|
|
48
|
+
events, actor/action taxonomy, and the metadata boundary;
|
|
49
|
+
- [`navigation-v1.json`](navigation-v1.json) defines locale-aware managed menu
|
|
50
|
+
items with stable ordering, nesting, and safe destinations;
|
|
51
|
+
- [`dashboard-runtime-v1.json`](dashboard-runtime-v1.json) defines evidence
|
|
52
|
+
that an admin screen mounted, ran its checks, and completed its data calls.
|
|
53
|
+
|
|
54
|
+
These are adapter-facing interfaces, not database schemas. A host may use
|
|
55
|
+
PostgreSQL, SQLite, or another first-party store. Activity recording happens
|
|
56
|
+
after a successful caller write, uses the same transaction/client where
|
|
57
|
+
available, redacts credential-like metadata, and must never make the business
|
|
58
|
+
operation fail. Retention/pruning is explicit. Navigation reads fall back to
|
|
59
|
+
site markup when no published menu exists or the adapter is unavailable.
|
|
60
|
+
Dashboard browser checks must emit runtime evidence; a static component scan
|
|
61
|
+
or JSON declaration is not proof that a screen mounted or that its data calls
|
|
62
|
+
ran.
|
|
63
|
+
|
|
45
64
|
## Status model
|
|
46
65
|
|
|
47
66
|
Content transitions are explicit:
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://maggiedash.noblox.app/contracts/activity-log-v1.json",
|
|
4
|
+
"title": "MaggieDash first-party activity log contract v1",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["schemaVersion", "events"],
|
|
7
|
+
"properties": {
|
|
8
|
+
"schemaVersion": {"const": "maggiedash-activity-log.v1"},
|
|
9
|
+
"events": {
|
|
10
|
+
"type": "array",
|
|
11
|
+
"items": {
|
|
12
|
+
"type": "object",
|
|
13
|
+
"required": ["id", "occurredAt", "actor", "action"],
|
|
14
|
+
"properties": {
|
|
15
|
+
"id": {"type": "string", "minLength": 1},
|
|
16
|
+
"occurredAt": {"type": "string", "format": "date-time"},
|
|
17
|
+
"actor": {"type": "string", "minLength": 1},
|
|
18
|
+
"action": {"type": "string", "pattern": "^[a-z_]+\\.[a-z_]+$"},
|
|
19
|
+
"summary": {"type": "string"},
|
|
20
|
+
"metadata": {"type": "object", "additionalProperties": true}
|
|
21
|
+
},
|
|
22
|
+
"additionalProperties": false
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"additionalProperties": false
|
|
27
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://maggiedash.noblox.app/contracts/dashboard-runtime-v1.json",
|
|
4
|
+
"title": "MaggieDash dashboard runtime evidence contract v1",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["schemaVersion", "screen", "route", "mounted", "checks"],
|
|
7
|
+
"properties": {
|
|
8
|
+
"schemaVersion": {"const": "maggiedash-dashboard-runtime.v1"},
|
|
9
|
+
"screen": {"type": "string", "minLength": 1},
|
|
10
|
+
"route": {"type": "string", "minLength": 1},
|
|
11
|
+
"mounted": {"const": true},
|
|
12
|
+
"checks": {
|
|
13
|
+
"type": "array",
|
|
14
|
+
"minItems": 1,
|
|
15
|
+
"items": {
|
|
16
|
+
"type": "object",
|
|
17
|
+
"required": ["name", "passed"],
|
|
18
|
+
"properties": {"name": {"type": "string", "minLength": 1}, "passed": {"type": "boolean"}},
|
|
19
|
+
"additionalProperties": false
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"dataCalls": {
|
|
23
|
+
"type": "array",
|
|
24
|
+
"items": {
|
|
25
|
+
"type": "object",
|
|
26
|
+
"required": ["url", "ok"],
|
|
27
|
+
"properties": {"url": {"type": "string"}, "method": {"type": "string"}, "status": {"type": "integer"}, "ok": {"type": "boolean"}},
|
|
28
|
+
"additionalProperties": false
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"additionalProperties": false
|
|
33
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://maggiedash.noblox.app/contracts/navigation-v1.json",
|
|
4
|
+
"title": "MaggieDash managed navigation contract v1",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["schemaVersion", "location", "locale", "items"],
|
|
7
|
+
"properties": {
|
|
8
|
+
"schemaVersion": {"const": "maggiedash-navigation.v1"},
|
|
9
|
+
"location": {"type": "string", "minLength": 1},
|
|
10
|
+
"locale": {"type": "string", "minLength": 2},
|
|
11
|
+
"items": {
|
|
12
|
+
"type": "array",
|
|
13
|
+
"items": {
|
|
14
|
+
"type": "object",
|
|
15
|
+
"required": ["id", "label", "destination", "order"],
|
|
16
|
+
"properties": {
|
|
17
|
+
"id": {"type": "string", "minLength": 1},
|
|
18
|
+
"label": {"type": "string", "minLength": 1},
|
|
19
|
+
"destination": {"type": "string", "minLength": 1},
|
|
20
|
+
"order": {"type": "integer", "minimum": 0},
|
|
21
|
+
"parentId": {"type": ["string", "null"]},
|
|
22
|
+
"newTab": {"type": "boolean"}
|
|
23
|
+
},
|
|
24
|
+
"additionalProperties": false
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"additionalProperties": false
|
|
29
|
+
}
|
|
@@ -200,8 +200,21 @@ maggie dash sections bindings-validate --sections-file .maggie/sections.json \
|
|
|
200
200
|
--references-file .maggie/reference-inventory.json
|
|
201
201
|
maggie dash sections idempotency-validate --contract-file .maggie/reconcile-contract.json
|
|
202
202
|
|
|
203
|
-
# Validate API body/response shapes before a client writes against an endpoint
|
|
203
|
+
# Validate API body/response shapes before a client writes against an endpoint.
|
|
204
|
+
# The source can be a local JSON file or a public HTTPS URL:
|
|
204
205
|
maggie dash api-contract --spec .maggie/openapi.json
|
|
206
|
+
maggie dash api-contract --spec https://example.test/openapi.json
|
|
207
|
+
|
|
208
|
+
# Find declared tables with no reader evidence; this never connects to a DB:
|
|
209
|
+
maggie dash schema-audit --inventory .maggie/schema-inventory.json \
|
|
210
|
+
--fail-on-unread
|
|
211
|
+
|
|
212
|
+
# Prove a browser check mounted the screen and executed its data calls:
|
|
213
|
+
maggie dash ui runtime-validate --evidence .maggie/dashboard-runtime.json
|
|
214
|
+
|
|
215
|
+
# Classify docs, show inbound references and completed progress trackers:
|
|
216
|
+
maggie docs audit --project . --docs-dir docs \
|
|
217
|
+
--output docs/documentation-audit.json
|
|
205
218
|
|
|
206
219
|
# Classify every published page exactly once. `pageKind` describes the public
|
|
207
220
|
# page family; `kind`/`rendererKind` describes how it is rendered. Keep these
|
|
@@ -219,6 +232,14 @@ ordinary, and blog routes, plus `sourceCoverage`. A host adapter must include
|
|
|
219
232
|
code-rendered routes in the input or declare coverage incomplete; a zero
|
|
220
233
|
database-row count is not evidence that no public routes exist.
|
|
221
234
|
|
|
235
|
+
The host can expose the optional `activity-log-v1` and `navigation-v1`
|
|
236
|
+
contracts through `/api/maggie/*` adapters. Activity events should be
|
|
237
|
+
append-only and non-blocking to the original write. Managed navigation should
|
|
238
|
+
be locale-aware, ordered, and safe to fall back to existing markup. Do not
|
|
239
|
+
claim a dashboard feature is tested from a static source scan alone: record a
|
|
240
|
+
`dashboard-runtime-v1` report with `mounted: true`, named checks, and the
|
|
241
|
+
actual data calls.
|
|
242
|
+
|
|
222
243
|
When a host renders multiple shells, export a normalized head manifest and run
|
|
223
244
|
`maggie seo head-tags audit`. This is the reusable boundary for detecting icon
|
|
224
245
|
MIME drift, OG-image fallback drift, and verification-tag coverage; MaggieDash
|
|
@@ -25,7 +25,9 @@ from service_variants import ServiceVariantStore # noqa: E402
|
|
|
25
25
|
from maggie_dash_ui import load_and_validate # noqa: E402
|
|
26
26
|
from maggie_sections import catalogue, remap_translations, section_id_migration, validate_registry, copy_notes, validate_values, validate_fanout, reconcile_fields, validate_locale_coverage # noqa: E402
|
|
27
27
|
from maggie_quality import validate_variant_copy, validate_media_uniqueness, classify_inventory, validate_bindings, validate_reconcile_contract # noqa: E402
|
|
28
|
-
from maggie_api_contract import validate_api_contract # noqa: E402
|
|
28
|
+
from maggie_api_contract import load_api_contract_source, validate_api_contract # noqa: E402
|
|
29
|
+
from maggie_dash_runtime import validate_runtime_evidence # noqa: E402
|
|
30
|
+
from maggie_schema_audit import audit_schema_inventory # noqa: E402
|
|
29
31
|
from route_imports import classify_bindings # noqa: E402
|
|
30
32
|
|
|
31
33
|
|
|
@@ -316,6 +318,11 @@ def command_cms(args: argparse.Namespace) -> int:
|
|
|
316
318
|
|
|
317
319
|
|
|
318
320
|
def command_ui(args: argparse.Namespace) -> int:
|
|
321
|
+
if args.ui_command == "runtime-validate":
|
|
322
|
+
value = json.loads(Path(args.evidence).resolve().read_text(encoding="utf-8"))
|
|
323
|
+
result = validate_runtime_evidence(value)
|
|
324
|
+
emit(result)
|
|
325
|
+
return 0 if result["passed"] else 1
|
|
319
326
|
result = load_and_validate(Path(args.contract).resolve(), [Path(value).resolve() for value in args.source])
|
|
320
327
|
emit(result)
|
|
321
328
|
return 0 if result["passed"] else 1
|
|
@@ -429,8 +436,15 @@ def command_inventory(args: argparse.Namespace) -> int:
|
|
|
429
436
|
|
|
430
437
|
|
|
431
438
|
def command_api_contract(args: argparse.Namespace) -> int:
|
|
432
|
-
value =
|
|
433
|
-
result = validate_api_contract(value)
|
|
439
|
+
value, source = load_api_contract_source(args.spec)
|
|
440
|
+
result = {**validate_api_contract(value), "source": source}
|
|
441
|
+
emit(result)
|
|
442
|
+
return 0 if result["passed"] else 1
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def command_schema_audit(args: argparse.Namespace) -> int:
|
|
446
|
+
value = json.loads(Path(args.inventory).resolve().read_text(encoding="utf-8"))
|
|
447
|
+
result = audit_schema_inventory(value, fail_on_unread=args.fail_on_unread)
|
|
434
448
|
emit(result)
|
|
435
449
|
return 0 if result["passed"] else 1
|
|
436
450
|
|
|
@@ -537,6 +551,9 @@ def parser() -> argparse.ArgumentParser:
|
|
|
537
551
|
ui_validate.add_argument("--contract", required=True)
|
|
538
552
|
ui_validate.add_argument("--source", action="append", default=[])
|
|
539
553
|
ui_validate.set_defaults(func=command_ui)
|
|
554
|
+
ui_runtime = ui_sub.add_parser("runtime-validate", help="validate evidence that a dashboard screen mounted and ran")
|
|
555
|
+
ui_runtime.add_argument("--evidence", required=True)
|
|
556
|
+
ui_runtime.set_defaults(func=command_ui)
|
|
540
557
|
sections = sub.add_parser("sections", help="validate section registry and stable section identities")
|
|
541
558
|
sections_sub = sections.add_subparsers(dest="sections_command", required=True)
|
|
542
559
|
sections_validate = sections_sub.add_parser("validate")
|
|
@@ -583,9 +600,13 @@ def parser() -> argparse.ArgumentParser:
|
|
|
583
600
|
inventory.add_argument("--require-page-kinds", action="store_true", help="fail when a published page has no pageKind")
|
|
584
601
|
inventory.add_argument("--require-source-coverage", action="store_true", help="fail unless sourceCoverage.complete is true")
|
|
585
602
|
inventory.set_defaults(func=command_inventory)
|
|
586
|
-
api_contract = sub.add_parser("api-contract", help="validate declared request and success-response
|
|
587
|
-
api_contract.add_argument("--spec", required=True, help="
|
|
603
|
+
api_contract = sub.add_parser("api-contract", help="validate a local or live declared request and success-response contract")
|
|
604
|
+
api_contract.add_argument("--spec", required=True, help="local JSON file or HTTPS URL (loopback HTTP is allowed for development)")
|
|
588
605
|
api_contract.set_defaults(func=command_api_contract)
|
|
606
|
+
schema_audit = sub.add_parser("schema-audit", help="find declared tables with missing reader evidence")
|
|
607
|
+
schema_audit.add_argument("--inventory", required=True, help="host-produced schema inventory JSON")
|
|
608
|
+
schema_audit.add_argument("--fail-on-unread", action="store_true")
|
|
609
|
+
schema_audit.set_defaults(func=command_schema_audit)
|
|
589
610
|
variant = sub.add_parser("variant", help="manage service variant lifecycle")
|
|
590
611
|
variant_sub = variant.add_subparsers(dest="variant_command", required=True)
|
|
591
612
|
create = variant_sub.add_parser("create"); create.add_argument("--project", default="."); create.add_argument("--service-id", required=True); create.add_argument("--variant-id", required=True); create.add_argument("--variant-type", required=True); create.add_argument("--locale", required=True); create.add_argument("--market", required=True); create.add_argument("--slug", required=True); create.add_argument("--title", required=True); create.add_argument("--facts", required=True); create.add_argument("--source-revision", required=True); create.add_argument("--canonical-variant-id"); create.add_argument("--cluster-link", action="append", default=[]); create.add_argument("--layout-family", default="service-default"); create.add_argument("--confirm", action="store_true")
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Audit project documentation for stale trackers and unreferenced files."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import subprocess
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
DOCUMENT_EXTENSIONS = {".md", ".mdx", ".json", ".yaml", ".yml"}
|
|
14
|
+
SKIP_PARTS = {".git", "node_modules", "dist", "build", ".next"}
|
|
15
|
+
COMPLETED_RE = re.compile(r"(?i)\b(?:done|complete|completed|shipped|resolved|closed)\b")
|
|
16
|
+
TRACKER_RE = re.compile(r"(?i)(?:plan|task|progress|roadmap|todo)")
|
|
17
|
+
RECORD_RE = re.compile(r"(?i)(?:audit|report|evidence|log|feedback|record)")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _files(root: Path) -> list[Path]:
|
|
21
|
+
return sorted(
|
|
22
|
+
path for path in root.rglob("*")
|
|
23
|
+
if path.is_file() and path.suffix.casefold() in DOCUMENT_EXTENSIONS
|
|
24
|
+
and not any(part in SKIP_PARTS for part in path.relative_to(root).parts)
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _category(path: Path) -> str:
|
|
29
|
+
if path.suffix.casefold() in {".json", ".yaml", ".yml"}:
|
|
30
|
+
return "data"
|
|
31
|
+
name = path.stem
|
|
32
|
+
if TRACKER_RE.search(name):
|
|
33
|
+
return "plan"
|
|
34
|
+
if RECORD_RE.search(name):
|
|
35
|
+
return "record"
|
|
36
|
+
return "reference"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _identity(path: Path, text: str) -> str:
|
|
40
|
+
heading = next((line.strip().lstrip("#").strip() for line in text.splitlines() if line.lstrip().startswith("#")), "")
|
|
41
|
+
value = heading or path.stem
|
|
42
|
+
return re.sub(r"[^a-z0-9]+", "-", value.casefold()).strip("-")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _last_change(project: Path, relative: str) -> str | None:
|
|
46
|
+
try:
|
|
47
|
+
result = subprocess.run(
|
|
48
|
+
["git", "-C", str(project), "log", "-1", "--format=%cI", "--", relative],
|
|
49
|
+
capture_output=True, text=True, check=False,
|
|
50
|
+
)
|
|
51
|
+
except OSError:
|
|
52
|
+
return None
|
|
53
|
+
value = result.stdout.strip()
|
|
54
|
+
return value or None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def audit(project: Path, docs_dir: str = "docs") -> dict:
|
|
58
|
+
root = project.resolve()
|
|
59
|
+
docs = (root / docs_dir).resolve()
|
|
60
|
+
if not docs.is_dir():
|
|
61
|
+
return {"schemaVersion": "maggie-docs-audit.v1", "passed": False, "errors": [f"docs directory does not exist: {docs}"], "files": []}
|
|
62
|
+
files = _files(docs)
|
|
63
|
+
corpus: list[tuple[Path, str]] = []
|
|
64
|
+
for path in _files(root):
|
|
65
|
+
try:
|
|
66
|
+
corpus.append((path, path.read_text(encoding="utf-8", errors="replace")))
|
|
67
|
+
except OSError:
|
|
68
|
+
continue
|
|
69
|
+
entries = []
|
|
70
|
+
for path in files:
|
|
71
|
+
try:
|
|
72
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
73
|
+
except OSError as error:
|
|
74
|
+
entries.append({"path": str(path.relative_to(root)), "status": "unreadable", "error": str(error)})
|
|
75
|
+
continue
|
|
76
|
+
relative = str(path.relative_to(root))
|
|
77
|
+
basename = path.name
|
|
78
|
+
inbound = sum(1 for source, source_text in corpus if source != path and (relative in source_text or basename in source_text))
|
|
79
|
+
category = _category(path)
|
|
80
|
+
completed_tracker = category == "plan" and bool(COMPLETED_RE.search(text))
|
|
81
|
+
findings: list[str] = []
|
|
82
|
+
if completed_tracker:
|
|
83
|
+
findings.append("completed-progress-tracker")
|
|
84
|
+
if inbound == 0 and category in {"reference", "data"}:
|
|
85
|
+
findings.append("unreferenced")
|
|
86
|
+
entries.append({
|
|
87
|
+
"path": relative,
|
|
88
|
+
"category": category,
|
|
89
|
+
"inboundReferences": inbound,
|
|
90
|
+
"lastChanged": _last_change(root, relative),
|
|
91
|
+
"completedTracker": completed_tracker,
|
|
92
|
+
"openTracker": category == "plan" and not completed_tracker,
|
|
93
|
+
"identity": _identity(path, text),
|
|
94
|
+
"findings": findings,
|
|
95
|
+
})
|
|
96
|
+
identities: dict[str, list[dict]] = {}
|
|
97
|
+
for item in entries:
|
|
98
|
+
identities.setdefault(item.get("identity", ""), []).append(item)
|
|
99
|
+
duplicate_groups = [group for key, group in identities.items() if key and len(group) > 1]
|
|
100
|
+
duplicate_paths = {item["path"] for group in duplicate_groups for item in group}
|
|
101
|
+
for item in entries:
|
|
102
|
+
item.pop("identity", None)
|
|
103
|
+
if item["path"] in duplicate_paths:
|
|
104
|
+
item["findings"].append("duplicate-candidate")
|
|
105
|
+
item["staleCandidate"] = bool(item["findings"])
|
|
106
|
+
findings = [item for item in entries if item.get("findings")]
|
|
107
|
+
return {
|
|
108
|
+
"schemaVersion": "maggie-docs-audit.v1",
|
|
109
|
+
"passed": True,
|
|
110
|
+
"project": str(root),
|
|
111
|
+
"docsDirectory": str(docs.relative_to(root)),
|
|
112
|
+
"fileCount": len(entries),
|
|
113
|
+
"findingCount": len(findings),
|
|
114
|
+
"summary": {
|
|
115
|
+
"reference": sum(item.get("category") == "reference" for item in entries),
|
|
116
|
+
"record": sum(item.get("category") == "record" for item in entries),
|
|
117
|
+
"plan": sum(item.get("category") == "plan" for item in entries),
|
|
118
|
+
"data": sum(item.get("category") == "data" for item in entries),
|
|
119
|
+
"completedTrackers": sum(item.get("completedTracker", False) for item in entries),
|
|
120
|
+
"openTrackers": sum(item.get("openTracker", False) for item in entries),
|
|
121
|
+
"unreferenced": sum("unreferenced" in item.get("findings", []) for item in entries),
|
|
122
|
+
"duplicateCandidates": len(duplicate_groups),
|
|
123
|
+
"staleCandidates": sum(item.get("staleCandidate", False) for item in entries),
|
|
124
|
+
},
|
|
125
|
+
"files": entries,
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def main() -> int:
|
|
130
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
131
|
+
parser.add_argument("audit", nargs="?", choices=("audit",), default="audit")
|
|
132
|
+
parser.add_argument("--project", default=".")
|
|
133
|
+
parser.add_argument("--docs-dir", default="docs")
|
|
134
|
+
parser.add_argument("--fail-on-findings", action="store_true")
|
|
135
|
+
parser.add_argument("--output")
|
|
136
|
+
args = parser.parse_args()
|
|
137
|
+
result = audit(Path(args.project), args.docs_dir)
|
|
138
|
+
if args.fail_on_findings and result.get("findingCount"):
|
|
139
|
+
result["passed"] = False
|
|
140
|
+
result.setdefault("errors", []).append("documentation findings require review")
|
|
141
|
+
if args.output:
|
|
142
|
+
output = Path(args.output).expanduser().resolve()
|
|
143
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
144
|
+
output.write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
145
|
+
result["output"] = str(output)
|
|
146
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
147
|
+
return 0 if result.get("passed") else 1
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
if __name__ == "__main__":
|
|
151
|
+
raise SystemExit(main())
|
|
@@ -4,11 +4,17 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from collections.abc import Mapping
|
|
6
6
|
from typing import Any
|
|
7
|
+
from urllib.error import HTTPError, URLError
|
|
8
|
+
from urllib.parse import urlparse
|
|
9
|
+
from urllib.request import Request, urlopen
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import json
|
|
7
12
|
|
|
8
13
|
|
|
9
14
|
SCHEMA = "maggie-api-contract.v1"
|
|
10
15
|
METHODS = {"get", "post", "put", "patch", "delete", "head", "options", "trace"}
|
|
11
16
|
BODY_METHODS = {"post", "put", "patch"}
|
|
17
|
+
MAX_SPEC_BYTES = 2_000_000
|
|
12
18
|
|
|
13
19
|
|
|
14
20
|
def _has_schema(value: object) -> bool:
|
|
@@ -95,3 +101,48 @@ def validate_api_contract(spec: object) -> dict[str, Any]:
|
|
|
95
101
|
operations.append(record)
|
|
96
102
|
return {"schemaVersion": SCHEMA, "passed": not errors, "operations": operations, "errors": errors,
|
|
97
103
|
"operationCount": len(operations), "errorCount": len(errors)}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _safe_url(source: str) -> tuple[str, str]:
|
|
107
|
+
parsed = urlparse(source)
|
|
108
|
+
if parsed.scheme not in {"https", "http"} or not parsed.netloc:
|
|
109
|
+
raise ValueError("API contract source must be a local file or an absolute HTTPS URL")
|
|
110
|
+
if parsed.username or parsed.password or parsed.query or parsed.fragment:
|
|
111
|
+
raise ValueError("API contract URL must not contain credentials, query parameters, or fragments")
|
|
112
|
+
host = (parsed.hostname or "").casefold()
|
|
113
|
+
if parsed.scheme == "http" and host not in {"localhost", "127.0.0.1", "::1"}:
|
|
114
|
+
raise ValueError("plain HTTP is allowed only for loopback development sources")
|
|
115
|
+
display = f"{parsed.scheme}://{parsed.netloc}{parsed.path or '/'}"
|
|
116
|
+
return source, display
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def load_api_contract_source(source: str | Path, *, timeout: float = 20.0) -> tuple[object, dict[str, Any]]:
|
|
120
|
+
"""Load a local JSON spec or fetch a safe URL without writing the response.
|
|
121
|
+
|
|
122
|
+
The caller receives source metadata suitable for an audit report. Response
|
|
123
|
+
bodies are parsed in memory only, so a live contract check cannot silently
|
|
124
|
+
create a project artifact containing a private API document.
|
|
125
|
+
"""
|
|
126
|
+
value = str(source)
|
|
127
|
+
parsed = urlparse(value)
|
|
128
|
+
if parsed.scheme in {"https", "http"}:
|
|
129
|
+
requested, display = _safe_url(value)
|
|
130
|
+
request = Request(requested, headers={"Accept": "application/json", "User-Agent": "MaggieApiContract/1"})
|
|
131
|
+
try:
|
|
132
|
+
with urlopen(request, timeout=timeout) as response:
|
|
133
|
+
raw = response.read(MAX_SPEC_BYTES + 1)
|
|
134
|
+
except (HTTPError, URLError, TimeoutError) as error:
|
|
135
|
+
raise RuntimeError(f"could not fetch API contract URL: {error}") from error
|
|
136
|
+
if len(raw) > MAX_SPEC_BYTES:
|
|
137
|
+
raise ValueError(f"API contract response exceeds {MAX_SPEC_BYTES} bytes")
|
|
138
|
+
try:
|
|
139
|
+
document = json.loads(raw.decode("utf-8"))
|
|
140
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
141
|
+
raise ValueError("API contract URL did not return a UTF-8 JSON document") from error
|
|
142
|
+
return document, {"sourceType": "url", "source": display}
|
|
143
|
+
|
|
144
|
+
path = Path(value).expanduser().resolve()
|
|
145
|
+
raw = path.read_bytes()
|
|
146
|
+
if len(raw) > MAX_SPEC_BYTES:
|
|
147
|
+
raise ValueError(f"API contract file exceeds {MAX_SPEC_BYTES} bytes")
|
|
148
|
+
return json.loads(raw.decode("utf-8")), {"sourceType": "file", "source": str(path)}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Validate runtime evidence that a dashboard screen actually mounted and ran."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Any
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
SCHEMA = "maggiedash-dashboard-runtime.v1"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def validate_runtime_evidence(evidence: object) -> dict[str, Any]:
|
|
14
|
+
errors: list[str] = []
|
|
15
|
+
if not isinstance(evidence, Mapping):
|
|
16
|
+
return {"schemaVersion": SCHEMA, "passed": False, "errors": ["evidence must be an object"]}
|
|
17
|
+
if evidence.get("schemaVersion") != SCHEMA:
|
|
18
|
+
errors.append(f"schemaVersion must be {SCHEMA}")
|
|
19
|
+
for field in ("screen", "route"):
|
|
20
|
+
if not isinstance(evidence.get(field), str) or not evidence[field].strip():
|
|
21
|
+
errors.append(f"{field} is required")
|
|
22
|
+
if evidence.get("mounted") is not True:
|
|
23
|
+
errors.append("mounted must be true")
|
|
24
|
+
checks = evidence.get("checks")
|
|
25
|
+
if not isinstance(checks, list) or not checks:
|
|
26
|
+
errors.append("checks must contain at least one executed check")
|
|
27
|
+
checks = []
|
|
28
|
+
check_results: list[dict[str, Any]] = []
|
|
29
|
+
for index, check in enumerate(checks):
|
|
30
|
+
if not isinstance(check, Mapping) or not isinstance(check.get("name"), str) or not check["name"].strip():
|
|
31
|
+
errors.append(f"checks[{index}] needs a name")
|
|
32
|
+
continue
|
|
33
|
+
if not isinstance(check.get("passed"), bool):
|
|
34
|
+
errors.append(f"checks[{index}].passed must be boolean")
|
|
35
|
+
elif check["passed"] is False:
|
|
36
|
+
errors.append(f"runtime check failed: {check['name']}")
|
|
37
|
+
check_results.append({"name": check["name"], "passed": check.get("passed")})
|
|
38
|
+
calls = evidence.get("dataCalls", [])
|
|
39
|
+
if not isinstance(calls, list):
|
|
40
|
+
errors.append("dataCalls must be a list")
|
|
41
|
+
calls = []
|
|
42
|
+
call_results: list[dict[str, Any]] = []
|
|
43
|
+
for index, call in enumerate(calls):
|
|
44
|
+
if not isinstance(call, Mapping):
|
|
45
|
+
errors.append(f"dataCalls[{index}] must be an object")
|
|
46
|
+
continue
|
|
47
|
+
url = str(call.get("url") or "")
|
|
48
|
+
parsed = urlparse(url)
|
|
49
|
+
if not url or parsed.username or parsed.password or parsed.scheme not in {"", "http", "https"}:
|
|
50
|
+
errors.append(f"dataCalls[{index}].url must be a relative or credential-free HTTP URL")
|
|
51
|
+
if not isinstance(call.get("method", "GET"), str):
|
|
52
|
+
errors.append(f"dataCalls[{index}].method must be a string")
|
|
53
|
+
if not isinstance(call.get("ok"), bool):
|
|
54
|
+
errors.append(f"dataCalls[{index}].ok must be boolean")
|
|
55
|
+
elif call["ok"] is False:
|
|
56
|
+
errors.append(f"data call failed: {url or 'unknown'}")
|
|
57
|
+
call_results.append({"url": url, "method": call.get("method", "GET"), "status": call.get("status"), "ok": call.get("ok")})
|
|
58
|
+
return {"schemaVersion": SCHEMA, "passed": not errors, "errors": errors, "screen": evidence.get("screen"), "route": evidence.get("route"), "mounted": evidence.get("mounted"), "checks": check_results, "dataCalls": call_results}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Validate host-supplied table/reader evidence without connecting to a database."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
SCHEMA = "maggie-schema-inventory.v1"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def audit_schema_inventory(inventory: object, *, fail_on_unread: bool = False) -> dict[str, Any]:
|
|
13
|
+
errors: list[str] = []
|
|
14
|
+
warnings: list[str] = []
|
|
15
|
+
if not isinstance(inventory, Mapping):
|
|
16
|
+
return {"schemaVersion": SCHEMA, "passed": False, "errors": ["inventory must be an object"], "tables": []}
|
|
17
|
+
if inventory.get("schemaVersion") != SCHEMA:
|
|
18
|
+
errors.append(f"schemaVersion must be {SCHEMA}")
|
|
19
|
+
tables = inventory.get("tables")
|
|
20
|
+
if not isinstance(tables, list):
|
|
21
|
+
errors.append("tables must be a list")
|
|
22
|
+
tables = []
|
|
23
|
+
seen: set[str] = set()
|
|
24
|
+
normalized: list[dict[str, Any]] = []
|
|
25
|
+
unread: list[dict[str, Any]] = []
|
|
26
|
+
unknown: list[str] = []
|
|
27
|
+
for index, table in enumerate(tables):
|
|
28
|
+
prefix = f"tables[{index}]"
|
|
29
|
+
if not isinstance(table, Mapping):
|
|
30
|
+
errors.append(f"{prefix} must be an object")
|
|
31
|
+
continue
|
|
32
|
+
name = str(table.get("name") or "").strip()
|
|
33
|
+
if not name:
|
|
34
|
+
errors.append(f"{prefix}.name is required")
|
|
35
|
+
continue
|
|
36
|
+
if name in seen:
|
|
37
|
+
errors.append(f"duplicate table: {name}")
|
|
38
|
+
seen.add(name)
|
|
39
|
+
row_count = table.get("rowCount")
|
|
40
|
+
if not isinstance(row_count, int) or isinstance(row_count, bool) or row_count < 0:
|
|
41
|
+
errors.append(f"{prefix}.rowCount must be a non-negative integer")
|
|
42
|
+
row_count = None
|
|
43
|
+
readers = table.get("readers")
|
|
44
|
+
if readers is None:
|
|
45
|
+
unknown.append(name)
|
|
46
|
+
warnings.append(f"{name} has no reader evidence")
|
|
47
|
+
reader_list: list[str] = []
|
|
48
|
+
reader_status = "unknown"
|
|
49
|
+
elif not isinstance(readers, list) or any(not isinstance(reader, str) or not reader.strip() for reader in readers):
|
|
50
|
+
errors.append(f"{prefix}.readers must be a list of non-empty strings")
|
|
51
|
+
reader_list = []
|
|
52
|
+
reader_status = "invalid"
|
|
53
|
+
else:
|
|
54
|
+
reader_list = sorted({reader.strip() for reader in readers})
|
|
55
|
+
reader_status = "read" if reader_list else "unread"
|
|
56
|
+
record = {"name": name, "rowCount": row_count, "readers": reader_list, "readerStatus": reader_status}
|
|
57
|
+
normalized.append(record)
|
|
58
|
+
if reader_status == "unread":
|
|
59
|
+
unread.append(record)
|
|
60
|
+
if fail_on_unread and unread:
|
|
61
|
+
errors.append(f"{len(unread)} declared table(s) have no reader evidence")
|
|
62
|
+
return {
|
|
63
|
+
"schemaVersion": SCHEMA,
|
|
64
|
+
"passed": not errors,
|
|
65
|
+
"errors": errors,
|
|
66
|
+
"warnings": warnings,
|
|
67
|
+
"tables": normalized,
|
|
68
|
+
"unreadTables": unread,
|
|
69
|
+
"unknownReaderEvidence": unknown,
|
|
70
|
+
"tableCount": len(normalized),
|
|
71
|
+
"unreadCount": len(unread),
|
|
72
|
+
}
|