motionloom 2.2.0 → 2.4.0
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/CHANGELOG.md +48 -1
- package/README.md +123 -17
- package/ROADMAP.md +8 -3
- package/SKILL.md +34 -3
- package/agent-card.json +48 -5
- package/agent-surfaces.json +8 -1
- package/artifact-adapter-registry.json +53 -0
- package/bin/motionloom.mjs +41 -4
- package/docs/AGENT-INTEGRATION.md +15 -2
- package/docs/CHECKLIST.md +21 -0
- package/docs/STATUS.md +2 -2
- package/docs/audits/data/deep-stress-latest.json +65 -65
- package/docs/releases/2.3.0.md +33 -0
- package/docs/releases/2.4.0.md +31 -0
- package/docs/releases/npm-publish-from-workstation.md +19 -8
- package/docs/research/agent-skill-ecosystem-notes.md +47 -0
- package/docs/research/ai-animation-tools-2026-notes.md +81 -0
- package/docs/research/ai-animation-tools-2026-report.md +168 -0
- package/docs/research/ai-pilot-ingest-notes.md +29 -0
- package/examples/agent-consumer/ai-generated-pilot/hero-male.json +10 -0
- package/examples/agent-consumer/ai-generated-pilot-provenance.json +55 -0
- package/examples/agent-consumer/ai-pilot-scout/CHATGPT-HANDOFF.md +70 -0
- package/examples/agent-consumer/ai-pilot-scout/CODEX-HANDOFF.md +80 -0
- package/examples/agent-consumer/ai-pilot-scout/README.md +39 -0
- package/examples/agent-consumer/ai-pilot-scout/chatgpt-geometry-correction-prompt.md +55 -0
- package/examples/agent-consumer/ai-pilot-scout/chatgpt-pose-generation-prompt.md +78 -0
- package/examples/agent-consumer/ai-pilot-scout/partial-handoff.json +73 -0
- package/examples/agent-consumer/artifact-intake/hero-motion-controls.json +15 -0
- package/examples/agent-consumer/artifact-intake/hero-motion-export.json +14 -0
- package/examples/agent-consumer/artifact-intake/hero-motion-provenance.json +20 -0
- package/examples/agent-consumer/artifact-intake/hero-motion-receipt.json +16 -0
- package/examples/agent-consumer/asset-consistency/assets/forest-back.png +0 -0
- package/examples/agent-consumer/asset-consistency/assets/forest-front.png +0 -0
- package/examples/agent-consumer/asset-consistency/assets/forest-mid.png +0 -0
- package/examples/agent-consumer/asset-consistency/assets/hero-atlas.png +0 -0
- package/examples/agent-consumer/asset-consistency/assets/hero-frame-00.png +0 -0
- package/examples/agent-consumer/asset-consistency/assets/hero-frame-01.png +0 -0
- package/examples/agent-consumer/asset-consistency/assets/hero-frame-02.png +0 -0
- package/examples/agent-consumer/asset-consistency/assets/hero-frame-03.png +0 -0
- package/examples/agent-consumer/asset-consistency/forest-layered-map.json +53 -0
- package/examples/agent-consumer/asset-consistency/hero-atlas-contract.json +24 -0
- package/examples/agent-consumer/asset-consistency/hero-identity.json +51 -0
- package/examples/agent-consumer/asset-consistency/hero-walk-action-set.json +27 -0
- package/examples/agent-consumer/asset-consistency/hero-walk-frame-geometry.json +63 -0
- package/examples/agent-consumer/rig-compatibility/hero-walk-fixture-rig.json +13 -0
- package/examples/agent-consumer/rive-package-gate/README.md +20 -0
- package/examples/agent-consumer/runtime-candidate/hero-walk-candidate.json +18 -0
- package/examples/agent-consumer/runtime-pilot/action-set.json +19 -0
- package/examples/agent-consumer/runtime-pilot/asset-identity.json +44 -0
- package/examples/agent-consumer/runtime-pilot/candidate.json +21 -0
- package/examples/agent-consumer/runtime-pilot/controls.json +35 -0
- package/examples/agent-consumer/runtime-pilot/export.json +17 -0
- package/examples/agent-consumer/runtime-pilot/provenance.json +38 -0
- package/examples/agent-consumer/runtime-pilot/receipt.json +30 -0
- package/package.json +65 -4
- package/references/agent-interoperability.md +11 -0
- package/references/browser-review-contract.md +7 -1
- package/references/intelligence-core.md +29 -2
- package/rig-adapter-registry.json +39 -0
- package/schemas/action-set.schema.json +42 -0
- package/schemas/agent-surfaces.schema.json +1 -1
- package/schemas/artifact-adapter-registry.schema.json +16 -0
- package/schemas/asset-identity.schema.json +117 -0
- package/schemas/asset-provenance.schema.json +183 -0
- package/schemas/atlas-contract.schema.json +48 -0
- package/schemas/control-track.schema.json +18 -0
- package/schemas/export-manifest.schema.json +19 -0
- package/schemas/frame-geometry.schema.json +79 -0
- package/schemas/generation-receipt.schema.json +20 -0
- package/schemas/layered-map.schema.json +75 -0
- package/schemas/rig-adapter-registry.schema.json +14 -0
- package/schemas/rig-compatibility.schema.json +53 -0
- package/schemas/rive-package-manifest.schema.json +43 -0
- package/schemas/runtime-candidate.schema.json +48 -0
- package/schemas/scene-manifest.schema.json +19 -0
- package/scripts/artifact-intake.py +408 -0
- package/scripts/asset-consistency.py +517 -0
- package/scripts/asset-provenance.py +395 -0
- package/scripts/build-ai-pilot.py +504 -0
- package/scripts/docs-audit.py +14 -2
- package/scripts/isolate-alpha-background.py +147 -0
- package/scripts/pr.py +2 -0
- package/scripts/quality-gate.py +127 -3
- package/scripts/report.py +135 -0
- package/scripts/resolve-task-bundle.py +84 -0
- package/scripts/review-hook.py +26 -4
- package/scripts/rig-compatibility.py +251 -0
- package/scripts/rive-package-gate.py +245 -0
- package/scripts/runtime-candidate.py +301 -0
- package/scripts/setup.mjs +500 -0
- package/scripts/skill-doctor.py +2 -1
- package/src/output/browser-review-smoke/asset-provenance.json +77 -0
- package/src/output/browser-review-smoke/manifest.json +1 -0
- package/src/output/browser-review-smoke/visual-truth.json +3 -3
- package/src/output/runtime-pilot-framer/asset-provenance.json +38 -0
- package/src/output/runtime-pilot-framer/browser-review.json +20 -0
- package/src/output/runtime-pilot-framer/framer-motion/runtime-telemetry.json +103 -0
- package/src/output/runtime-pilot-framer/manifest.json +31 -0
- package/src/output/runtime-pilot-framer/motion-spec.json +25 -0
- package/src/output/runtime-pilot-framer/runtime-evidence.json +96 -0
- package/src/output/runtime-pilot-framer/runtime-telemetry.json +100 -0
- package/src/output/runtime-pilot-framer/scene.jsx +52 -0
- package/src/output/runtime-pilot-framer/snapshot/.render-meta.json +9 -0
- package/src/output/runtime-pilot-framer/snapshot/frame-00.png +0 -0
- package/src/output/runtime-pilot-framer/snapshot/frame-100.png +0 -0
- package/src/output/runtime-pilot-framer/snapshot/frame-50.png +0 -0
- package/tests/scripts/run_tests.py +131 -0
- package/tests/scripts/test_ai_pilot_builder.py +128 -0
- package/tests/scripts/test_alpha_isolation.py +60 -0
- package/tests/scripts/test_artifact_intake.py +146 -0
- package/tests/scripts/test_asset_consistency.py +199 -0
- package/tests/scripts/test_rig_compatibility.py +105 -0
- package/tests/scripts/test_rive_package_gate.py +101 -0
- package/tests/scripts/test_runtime_candidate.py +95 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/lenhonbp/MotionLoom/blob/main/schemas/rig-compatibility.schema.json",
|
|
4
|
+
"title": "MotionLoom rig compatibility evidence",
|
|
5
|
+
"description": "Describes a specific rig/export and checks its sockets, actions, events and runtime adapter compatibility. This is evidence only and never proves artist authority or production approval.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": ["schema_version", "rig_id", "asset_id", "authority", "source", "candidate", "bones", "sockets", "actions", "runtime"],
|
|
9
|
+
"properties": {
|
|
10
|
+
"schema_version": {"const": "0.1"},
|
|
11
|
+
"rig_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{2,127}$"},
|
|
12
|
+
"asset_id": {"type": "string", "minLength": 1},
|
|
13
|
+
"authority": {"type": "string", "enum": ["ai_generated", "ai_assisted", "ai_assisted_human_reviewed", "artist_authored", "unknown"]},
|
|
14
|
+
"source": {
|
|
15
|
+
"type": "object",
|
|
16
|
+
"additionalProperties": false,
|
|
17
|
+
"required": ["kind", "path", "sha256"],
|
|
18
|
+
"properties": {
|
|
19
|
+
"kind": {"type": "string", "enum": ["fixture_contract", "procedural_definition", "spine_json", "spine_skel", "rive_file", "lottie_json", "three_asset", "other"]},
|
|
20
|
+
"path": {"type": "string", "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+"},
|
|
21
|
+
"sha256": {"type": "string", "pattern": "^[a-f0-9]{64}$"}
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"candidate": {
|
|
25
|
+
"type": "object",
|
|
26
|
+
"additionalProperties": false,
|
|
27
|
+
"required": ["path", "sha256"],
|
|
28
|
+
"properties": {
|
|
29
|
+
"path": {"type": "string", "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+"},
|
|
30
|
+
"sha256": {"type": "string", "pattern": "^[a-f0-9]{64}$"}
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"bones": {
|
|
34
|
+
"type": "array",
|
|
35
|
+
"minItems": 1,
|
|
36
|
+
"items": {"type": "object", "required": ["id"], "properties": {"id": {"type": "string", "minLength": 1}, "parent": {"type": "string", "minLength": 1}}, "additionalProperties": true}
|
|
37
|
+
},
|
|
38
|
+
"sockets": {"type": "array", "items": {"type": "object", "required": ["id", "bone"], "properties": {"id": {"type": "string", "minLength": 1}, "bone": {"type": "string", "minLength": 1}}, "additionalProperties": true}},
|
|
39
|
+
"actions": {"type": "array", "minItems": 1, "items": {"type": "object", "required": ["id"], "properties": {"id": {"type": "string", "minLength": 1}, "events": {"type": "array", "items": {"type": "string"}}}, "additionalProperties": true}},
|
|
40
|
+
"requirements": {"type": "object", "properties": {"sockets": {"type": "array", "items": {"type": "string"}}, "actions": {"type": "array", "items": {"type": "string"}}, "events": {"type": "array", "items": {"type": "string"}}}, "additionalProperties": false},
|
|
41
|
+
"runtime": {
|
|
42
|
+
"type": "object",
|
|
43
|
+
"additionalProperties": false,
|
|
44
|
+
"required": ["adapter_id", "target", "review_required"],
|
|
45
|
+
"properties": {
|
|
46
|
+
"adapter_id": {"type": "string", "minLength": 1},
|
|
47
|
+
"target": {"type": "string", "minLength": 1},
|
|
48
|
+
"runtime_evidence_ref": {"type": "string", "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+"},
|
|
49
|
+
"review_required": {"const": true}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/lenhonbp/MotionLoom/blob/main/schemas/rive-package-manifest.schema.json",
|
|
4
|
+
"title": "MotionLoom Rive runtime package gate",
|
|
5
|
+
"description": "A hash-bound manifest for a concrete .riv package. Passing this contract is runtime test readiness only; it never grants artist authority, production eligibility, production approval, or PR permission.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": ["schema_version", "package_id", "package_class", "asset", "provenance_ref", "runtime", "actions", "review_required"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"schema_version": {"const": "0.1"},
|
|
10
|
+
"package_id": {"type": "string", "pattern": "^[A-Za-z0-9._-]+$"},
|
|
11
|
+
"package_class": {"enum": ["ai_generated_pilot", "production_candidate"]},
|
|
12
|
+
"asset": {
|
|
13
|
+
"type": "object",
|
|
14
|
+
"required": ["id", "path", "sha256", "bytes"],
|
|
15
|
+
"properties": {
|
|
16
|
+
"id": {"type": "string", "minLength": 1},
|
|
17
|
+
"path": {"type": "string", "pattern": "^(?!/)(?!.*\\.\\.).+\\.riv$"},
|
|
18
|
+
"sha256": {"type": "string", "pattern": "^[a-f0-9]{64}$"},
|
|
19
|
+
"bytes": {"type": "integer", "minimum": 5}
|
|
20
|
+
},
|
|
21
|
+
"additionalProperties": false
|
|
22
|
+
},
|
|
23
|
+
"provenance_ref": {"type": "string", "pattern": "^(?!/)(?!.*\\.\\.).+\\.json$"},
|
|
24
|
+
"runtime": {
|
|
25
|
+
"type": "object",
|
|
26
|
+
"required": ["adapter_id", "target", "required_state_machines", "required_inputs", "required_events", "review_required"],
|
|
27
|
+
"properties": {
|
|
28
|
+
"adapter_id": {"const": "motionloom.rive-runtime"},
|
|
29
|
+
"target": {"const": "rive"},
|
|
30
|
+
"required_state_machines": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}},
|
|
31
|
+
"required_inputs": {"type": "array", "items": {"type": "string", "minLength": 1}},
|
|
32
|
+
"required_events": {"type": "array", "items": {"type": "string", "minLength": 1}},
|
|
33
|
+
"runtime_evidence_ref": {"type": "string", "pattern": "^(?!/)(?!.*\\.\\.).+\\.json$"},
|
|
34
|
+
"runtime_evidence_sha256": {"type": "string", "pattern": "^[a-f0-9]{64}$"},
|
|
35
|
+
"review_required": {"const": true}
|
|
36
|
+
},
|
|
37
|
+
"additionalProperties": false
|
|
38
|
+
},
|
|
39
|
+
"actions": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}, "uniqueItems": true},
|
|
40
|
+
"review_required": {"const": true}
|
|
41
|
+
},
|
|
42
|
+
"additionalProperties": false
|
|
43
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/lenhonbp/MotionLoom/blob/main/schemas/runtime-candidate.schema.json",
|
|
4
|
+
"title": "MotionLoom runtime candidate bridge",
|
|
5
|
+
"description": "Binds artifact intake evidence to deterministic consistency contracts before an asset may enter runtime testing. It never grants production eligibility, production approval or a pull request.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": ["schema_version", "candidate_id", "asset_id", "created_at", "artifact_intake", "consistency", "runtime"],
|
|
9
|
+
"properties": {
|
|
10
|
+
"schema_version": {"const": "0.1"},
|
|
11
|
+
"candidate_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{2,127}$"},
|
|
12
|
+
"asset_id": {"type": "string", "minLength": 1},
|
|
13
|
+
"created_at": {"type": "string", "format": "date-time"},
|
|
14
|
+
"artifact_intake": {
|
|
15
|
+
"type": "object",
|
|
16
|
+
"additionalProperties": false,
|
|
17
|
+
"required": ["registry", "receipt", "controls", "export_manifest"],
|
|
18
|
+
"properties": {
|
|
19
|
+
"registry": {"type": "string", "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+"},
|
|
20
|
+
"receipt": {"type": "string", "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+"},
|
|
21
|
+
"controls": {"type": "string", "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+"},
|
|
22
|
+
"export_manifest": {"type": "string", "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+"}
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"consistency": {
|
|
26
|
+
"type": "object",
|
|
27
|
+
"additionalProperties": false,
|
|
28
|
+
"required": ["asset_identity", "action_set"],
|
|
29
|
+
"properties": {
|
|
30
|
+
"asset_identity": {"type": "string", "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+"},
|
|
31
|
+
"action_set": {"type": "string", "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+"},
|
|
32
|
+
"frame_geometry": {"type": "string", "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+"},
|
|
33
|
+
"atlas": {"type": "string", "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+"},
|
|
34
|
+
"layered_map": {"type": "string", "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+"}
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"runtime": {
|
|
38
|
+
"type": "object",
|
|
39
|
+
"additionalProperties": false,
|
|
40
|
+
"required": ["target", "review_required"],
|
|
41
|
+
"properties": {
|
|
42
|
+
"target": {"type": "string", "enum": ["sprite", "canvas", "lottie", "dotlottie", "rive", "gsap", "framer-motion", "spine", "three"]},
|
|
43
|
+
"runtime_evidence_ref": {"type": "string", "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+"},
|
|
44
|
+
"review_required": {"const": true}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -12,6 +12,25 @@
|
|
|
12
12
|
"file": {"type": "string", "minLength": 1, "pattern": "^(?!/)(?!.*\\.\\.).+$"},
|
|
13
13
|
"runtime_evidence": {"type": "string", "minLength": 1, "pattern": "^(?!/)(?!.*\\.\\.).+$"},
|
|
14
14
|
"visual_truth": {"type": "string", "minLength": 1, "pattern": "^(?!/)(?!.*\\.\\.).+$"},
|
|
15
|
+
"asset_provenance": {"type": "string", "minLength": 1, "pattern": "^(?!/)(?!.*\\.\\.).+$"},
|
|
16
|
+
"asset_identity": {"type": "string", "minLength": 1, "pattern": "^(?!/)(?!.*\\.\\.).+$"},
|
|
17
|
+
"action_set": {"type": "string", "minLength": 1, "pattern": "^(?!/)(?!.*\\.\\.).+$"},
|
|
18
|
+
"frame_geometry": {"type": "string", "minLength": 1, "pattern": "^(?!/)(?!.*\\.\\.).+$"},
|
|
19
|
+
"atlas_contract": {"type": "string", "minLength": 1, "pattern": "^(?!/)(?!.*\\.\\.).+$"},
|
|
20
|
+
"layered_map": {"type": "string", "minLength": 1, "pattern": "^(?!/)(?!.*\\.\\.).+$"},
|
|
21
|
+
"consistency_ref": {"type": "string", "minLength": 1, "pattern": "^(?!/)(?!.*\\.\\.).+$"},
|
|
22
|
+
"consistency_kind": {"type": "string", "enum": ["identity", "action-set", "frame-geometry", "atlas", "layered-map"]},
|
|
23
|
+
"artifact_intake": {
|
|
24
|
+
"type": "object",
|
|
25
|
+
"required": ["registry", "receipt", "controls", "export_manifest"],
|
|
26
|
+
"properties": {
|
|
27
|
+
"registry": {"type": "string", "minLength": 1, "pattern": "^(?!/)(?!.*\\.\\.).+$"},
|
|
28
|
+
"receipt": {"type": "string", "minLength": 1, "pattern": "^(?!/)(?!.*\\.\\.).+$"},
|
|
29
|
+
"controls": {"type": "string", "minLength": 1, "pattern": "^(?!/)(?!.*\\.\\.).+$"},
|
|
30
|
+
"export_manifest": {"type": "string", "minLength": 1, "pattern": "^(?!/)(?!.*\\.\\.).+$"}
|
|
31
|
+
},
|
|
32
|
+
"additionalProperties": false
|
|
33
|
+
},
|
|
15
34
|
"source_binding": {
|
|
16
35
|
"type": "object",
|
|
17
36
|
"required": ["kind", "source_path", "authority", "license", "sha256"],
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""MotionLoom Artifact Intake.
|
|
3
|
+
|
|
4
|
+
Style: Timeline Desk — bind generation controls, provider receipt, provenance
|
|
5
|
+
and exported bytes before a runtime candidate reaches Dev Lab. This tool uses
|
|
6
|
+
only the standard library, does not invoke a provider or skill, and never
|
|
7
|
+
creates artist authority, production eligibility, production approval or a PR.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
from dataclasses import asdict, dataclass
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
24
|
+
SHA256 = re.compile(r"^[a-f0-9]{64}$")
|
|
25
|
+
SAFE = re.compile(r"^(?!/)(?!.*(?:^|/)\.\.(?:/|$)).+")
|
|
26
|
+
AUTHORITY = {"ai_generated", "ai_assisted", "ai_assisted_human_reviewed", "artist_authored", "code_authored", "unknown"}
|
|
27
|
+
ADAPTER_STATUS = {"verified", "scaffold_only", "static_validated", "project_integrated", "disabled"}
|
|
28
|
+
SENSITIVE_KEYS = {"api_key", "apikey", "token", "secret", "password", "authorization", "credential"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class Issue:
|
|
33
|
+
severity: str
|
|
34
|
+
code: str
|
|
35
|
+
message: str
|
|
36
|
+
path: str = ""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _issue(issues: list[Issue], code: str, message: str, path: str = "", severity: str = "error") -> None:
|
|
40
|
+
issues.append(Issue(severity, code, message, path))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _read_json(path: Path, issues: list[Issue], label: str) -> dict[str, Any] | None:
|
|
44
|
+
try:
|
|
45
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
46
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
47
|
+
_issue(issues, "invalid_json", f"{label}: {exc}", str(path))
|
|
48
|
+
return None
|
|
49
|
+
if not isinstance(value, dict):
|
|
50
|
+
_issue(issues, "invalid_document", f"{label} root must be an object", str(path))
|
|
51
|
+
return None
|
|
52
|
+
return value
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _datetime(value: Any) -> bool:
|
|
56
|
+
if not isinstance(value, str) or not value.strip():
|
|
57
|
+
return False
|
|
58
|
+
try:
|
|
59
|
+
datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
60
|
+
return True
|
|
61
|
+
except ValueError:
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _hash(value: Any) -> bool:
|
|
66
|
+
return isinstance(value, str) and bool(SHA256.fullmatch(value))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _safe(value: Any) -> bool:
|
|
70
|
+
return isinstance(value, str) and "\\" not in value and bool(SAFE.fullmatch(value))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _resolve(root: Path, value: Any) -> Path | None:
|
|
74
|
+
if not _safe(value):
|
|
75
|
+
return None
|
|
76
|
+
raw = root / str(value)
|
|
77
|
+
try:
|
|
78
|
+
resolved = raw.resolve()
|
|
79
|
+
resolved.relative_to(root.resolve())
|
|
80
|
+
except ValueError:
|
|
81
|
+
return None
|
|
82
|
+
if raw.is_symlink() or not resolved.is_file():
|
|
83
|
+
return None
|
|
84
|
+
return resolved
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _digest(path: Path) -> str:
|
|
88
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _required(doc: dict[str, Any], fields: tuple[str, ...], prefix: str, issues: list[Issue]) -> None:
|
|
92
|
+
for field in fields:
|
|
93
|
+
if field not in doc:
|
|
94
|
+
_issue(issues, "missing_field", f"{prefix}.{field} is required", f"{prefix}.{field}")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _text(value: Any) -> bool:
|
|
98
|
+
return isinstance(value, str) and bool(value.strip())
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _forbidden_secrets(value: Any, issues: list[Issue], path: str = "") -> None:
|
|
102
|
+
if isinstance(value, dict):
|
|
103
|
+
for key, item in value.items():
|
|
104
|
+
current = f"{path}.{key}" if path else str(key)
|
|
105
|
+
if str(key).lower() in SENSITIVE_KEYS:
|
|
106
|
+
_issue(issues, "secret_forbidden", "artifact intake must not store credentials or tokens", current)
|
|
107
|
+
_forbidden_secrets(item, issues, current)
|
|
108
|
+
elif isinstance(value, list):
|
|
109
|
+
for index, item in enumerate(value):
|
|
110
|
+
_forbidden_secrets(item, issues, f"{path}[{index}]")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _validate_file_entries(entries: Any, root: Path, prefix: str, issues: list[Issue]) -> list[dict[str, Any]]:
|
|
114
|
+
if not isinstance(entries, list) or not entries:
|
|
115
|
+
_issue(issues, "missing_outputs", f"{prefix} must contain at least one file", prefix)
|
|
116
|
+
return []
|
|
117
|
+
seen: set[str] = set()
|
|
118
|
+
valid: list[dict[str, Any]] = []
|
|
119
|
+
for index, entry in enumerate(entries):
|
|
120
|
+
path = f"{prefix}[{index}]"
|
|
121
|
+
if not isinstance(entry, dict):
|
|
122
|
+
_issue(issues, "invalid_file_entry", "file entry must be an object", path)
|
|
123
|
+
continue
|
|
124
|
+
_required(entry, ("path", "sha256"), path, issues)
|
|
125
|
+
file_ref = entry.get("path")
|
|
126
|
+
if not _safe(file_ref):
|
|
127
|
+
_issue(issues, "unsafe_path", "path must be a safe relative path", f"{path}.path")
|
|
128
|
+
continue
|
|
129
|
+
if file_ref in seen:
|
|
130
|
+
_issue(issues, "duplicate_output", f"duplicate artifact path: {file_ref}", f"{path}.path")
|
|
131
|
+
seen.add(str(file_ref))
|
|
132
|
+
if not _hash(entry.get("sha256")):
|
|
133
|
+
_issue(issues, "invalid_sha256", "sha256 must be lowercase SHA-256", f"{path}.sha256")
|
|
134
|
+
target = _resolve(root, file_ref)
|
|
135
|
+
if target is None:
|
|
136
|
+
_issue(issues, "artifact_missing", f"artifact missing or unsafe: {file_ref}", f"{path}.path")
|
|
137
|
+
continue
|
|
138
|
+
if _hash(entry.get("sha256")) and _digest(target) != entry["sha256"]:
|
|
139
|
+
_issue(issues, "sha256_mismatch", f"artifact SHA-256 mismatch: {file_ref}", f"{path}.sha256")
|
|
140
|
+
if "bytes" in entry and (not isinstance(entry["bytes"], int) or entry["bytes"] < 0 or target.stat().st_size != entry["bytes"]):
|
|
141
|
+
_issue(issues, "byte_count_mismatch", f"artifact byte count mismatch: {file_ref}", f"{path}.bytes")
|
|
142
|
+
valid.append(entry)
|
|
143
|
+
return valid
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def validate_registry(registry: dict[str, Any], root: Path) -> tuple[list[Issue], dict[str, dict[str, Any]]]:
|
|
147
|
+
issues: list[Issue] = []
|
|
148
|
+
_required(registry, ("schema_version", "registry_id", "generated_at", "selection_policy", "adapters"), "registry", issues)
|
|
149
|
+
if registry.get("schema_version") != "0.1":
|
|
150
|
+
_issue(issues, "schema_version", "registry.schema_version must be 0.1", "registry.schema_version")
|
|
151
|
+
if not _datetime(registry.get("generated_at")):
|
|
152
|
+
_issue(issues, "invalid_datetime", "registry.generated_at must be ISO-8601", "registry.generated_at")
|
|
153
|
+
policy = registry.get("selection_policy") if isinstance(registry.get("selection_policy"), dict) else {}
|
|
154
|
+
_required(policy, ("require_verified", "allow_scaffold_only"), "registry.selection_policy", issues)
|
|
155
|
+
adapters = registry.get("adapters")
|
|
156
|
+
if not isinstance(adapters, list) or not adapters:
|
|
157
|
+
_issue(issues, "missing_adapters", "registry.adapters must contain at least one adapter", "registry.adapters")
|
|
158
|
+
return issues, {}
|
|
159
|
+
indexed: dict[str, dict[str, Any]] = {}
|
|
160
|
+
for index, adapter in enumerate(adapters):
|
|
161
|
+
prefix = f"registry.adapters[{index}]"
|
|
162
|
+
if not isinstance(adapter, dict):
|
|
163
|
+
_issue(issues, "invalid_adapter", "adapter must be an object", prefix)
|
|
164
|
+
continue
|
|
165
|
+
_required(adapter, ("adapter_id", "kind", "status", "adapter_version", "invocation_mode", "cost_class", "inputs", "outputs", "compatibility", "evidence", "limitations", "risk_level", "side_effect_level"), prefix, issues)
|
|
166
|
+
adapter_id = adapter.get("adapter_id")
|
|
167
|
+
if not _text(adapter_id) or adapter_id in indexed:
|
|
168
|
+
_issue(issues, "duplicate_adapter", f"adapter_id must be unique and non-empty: {adapter_id}", f"{prefix}.adapter_id")
|
|
169
|
+
continue
|
|
170
|
+
indexed[adapter_id] = adapter
|
|
171
|
+
if adapter.get("status") not in ADAPTER_STATUS:
|
|
172
|
+
_issue(issues, "invalid_adapter_status", "adapter status is unsupported", f"{prefix}.status")
|
|
173
|
+
if adapter.get("kind") not in {"fixture", "internal_skill", "external_provider", "manual_import"}:
|
|
174
|
+
_issue(issues, "invalid_adapter_kind", "adapter kind is unsupported", f"{prefix}.kind")
|
|
175
|
+
if adapter.get("invocation_mode") not in {"none", "manual", "agent-mediated", "api"}:
|
|
176
|
+
_issue(issues, "invalid_invocation_mode", "invocation_mode is unsupported", f"{prefix}.invocation_mode")
|
|
177
|
+
if adapter.get("cost_class") not in {"included", "metered", "external", "unknown"}:
|
|
178
|
+
_issue(issues, "invalid_cost_class", "cost_class is unsupported", f"{prefix}.cost_class")
|
|
179
|
+
if adapter.get("side_effect_level") in {"remote_write", "user_review_required"}:
|
|
180
|
+
_issue(issues, "adapter_side_effect", "adapter registry cannot be used as authority to run a side-effecting provider", f"{prefix}.side_effect_level", "warning")
|
|
181
|
+
_validate_file_entries(adapter.get("evidence"), root, f"{prefix}.evidence", issues)
|
|
182
|
+
_forbidden_secrets(registry, issues)
|
|
183
|
+
return issues, indexed
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def validate_controls(controls: dict[str, Any], root: Path) -> list[Issue]:
|
|
187
|
+
issues: list[Issue] = []
|
|
188
|
+
_required(controls, ("schema_version", "control_id", "asset_id", "references", "tracks", "output_profile", "created_at"), "controls", issues)
|
|
189
|
+
if controls.get("schema_version") != "0.1":
|
|
190
|
+
_issue(issues, "schema_version", "controls.schema_version must be 0.1", "controls.schema_version")
|
|
191
|
+
if not _datetime(controls.get("created_at")):
|
|
192
|
+
_issue(issues, "invalid_datetime", "controls.created_at must be ISO-8601", "controls.created_at")
|
|
193
|
+
refs = controls.get("references")
|
|
194
|
+
if not isinstance(refs, list) or not refs:
|
|
195
|
+
_issue(issues, "missing_references", "controls.references must contain at least one hash-bound input", "controls.references")
|
|
196
|
+
else:
|
|
197
|
+
seen: set[str] = set()
|
|
198
|
+
for index, ref in enumerate(refs):
|
|
199
|
+
prefix = f"controls.references[{index}]"
|
|
200
|
+
if not isinstance(ref, dict):
|
|
201
|
+
_issue(issues, "invalid_reference", "reference must be an object", prefix)
|
|
202
|
+
continue
|
|
203
|
+
_required(ref, ("id", "path", "sha256", "role"), prefix, issues)
|
|
204
|
+
if not _text(ref.get("id")) or ref.get("id") in seen:
|
|
205
|
+
_issue(issues, "duplicate_reference", "reference id must be unique and non-empty", f"{prefix}.id")
|
|
206
|
+
seen.add(str(ref.get("id")))
|
|
207
|
+
_validate_file_entries([ref], root, prefix, issues)
|
|
208
|
+
tracks = controls.get("tracks")
|
|
209
|
+
kinds: set[str] = set()
|
|
210
|
+
if not isinstance(tracks, list) or not tracks:
|
|
211
|
+
_issue(issues, "missing_tracks", "controls.tracks must contain at least one bound control", "controls.tracks")
|
|
212
|
+
else:
|
|
213
|
+
ids: set[str] = set()
|
|
214
|
+
for index, track in enumerate(tracks):
|
|
215
|
+
prefix = f"controls.tracks[{index}]"
|
|
216
|
+
if not isinstance(track, dict):
|
|
217
|
+
_issue(issues, "invalid_track", "control track must be an object", prefix)
|
|
218
|
+
continue
|
|
219
|
+
_required(track, ("id", "kind", "binding", "value_hash"), prefix, issues)
|
|
220
|
+
if not _text(track.get("id")) or track.get("id") in ids:
|
|
221
|
+
_issue(issues, "duplicate_track", "track id must be unique and non-empty", f"{prefix}.id")
|
|
222
|
+
ids.add(str(track.get("id")))
|
|
223
|
+
kind = track.get("kind")
|
|
224
|
+
kinds.add(str(kind))
|
|
225
|
+
if kind not in {"identity", "style", "camera", "motion", "pose", "seed", "negative_prompt", "lighting", "source"}:
|
|
226
|
+
_issue(issues, "invalid_track_kind", "track kind is unsupported", f"{prefix}.kind")
|
|
227
|
+
if track.get("binding") not in {"required", "advisory", "not_applicable"}:
|
|
228
|
+
_issue(issues, "invalid_track_binding", "track binding is unsupported", f"{prefix}.binding")
|
|
229
|
+
if not _hash(track.get("value_hash")):
|
|
230
|
+
_issue(issues, "invalid_sha256", "track value_hash must be lowercase SHA-256", f"{prefix}.value_hash")
|
|
231
|
+
profile = controls.get("output_profile") if isinstance(controls.get("output_profile"), dict) else {}
|
|
232
|
+
if profile.get("kind") not in {"image", "frame_sequence", "sprite_atlas", "layered_map", "video", "rigged_2d", "rigged_3d", "runtime_scene"}:
|
|
233
|
+
_issue(issues, "invalid_output_kind", "controls.output_profile.kind is unsupported", "controls.output_profile.kind")
|
|
234
|
+
animated = profile.get("kind") in {"frame_sequence", "sprite_atlas", "video", "rigged_2d", "rigged_3d", "runtime_scene"}
|
|
235
|
+
if animated and "motion" not in kinds and "pose" not in kinds:
|
|
236
|
+
_issue(issues, "missing_motion_control", "animated output requires a motion or pose control track", "controls.tracks")
|
|
237
|
+
if animated and (not isinstance(profile.get("fps"), (int, float)) or profile.get("fps", 0) <= 0 or not isinstance(profile.get("expected_frame_count"), int) or profile.get("expected_frame_count", 0) < 1):
|
|
238
|
+
_issue(issues, "missing_timeline_profile", "animated output requires positive fps and expected_frame_count", "controls.output_profile")
|
|
239
|
+
_forbidden_secrets(controls, issues)
|
|
240
|
+
return issues
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def validate_receipt(receipt: dict[str, Any], controls: dict[str, Any] | None, provenance: dict[str, Any] | None, root: Path, adapters: dict[str, dict[str, Any]]) -> list[Issue]:
|
|
244
|
+
issues: list[Issue] = []
|
|
245
|
+
_required(receipt, ("schema_version", "receipt_id", "asset", "authority", "provider", "control_track_ref", "provenance_ref", "outputs", "created_at"), "receipt", issues)
|
|
246
|
+
if receipt.get("schema_version") != "0.1":
|
|
247
|
+
_issue(issues, "schema_version", "receipt.schema_version must be 0.1", "receipt.schema_version")
|
|
248
|
+
if not _datetime(receipt.get("created_at")):
|
|
249
|
+
_issue(issues, "invalid_datetime", "receipt.created_at must be ISO-8601", "receipt.created_at")
|
|
250
|
+
asset = receipt.get("asset") if isinstance(receipt.get("asset"), dict) else {}
|
|
251
|
+
_required(asset, ("id", "kind", "intended_use"), "receipt.asset", issues)
|
|
252
|
+
if asset.get("kind") not in {"image", "frame_sequence", "sprite_atlas", "layered_map", "video", "rigged_2d", "rigged_3d", "runtime_scene"}:
|
|
253
|
+
_issue(issues, "invalid_asset_kind", "receipt.asset.kind is unsupported", "receipt.asset.kind")
|
|
254
|
+
if asset.get("intended_use") not in {"pilot", "runtime_candidate", "reference_only"}:
|
|
255
|
+
_issue(issues, "invalid_intended_use", "receipt.asset.intended_use is unsupported", "receipt.asset.intended_use")
|
|
256
|
+
if receipt.get("authority") not in AUTHORITY:
|
|
257
|
+
_issue(issues, "invalid_authority", "receipt.authority is unsupported", "receipt.authority")
|
|
258
|
+
provider = receipt.get("provider") if isinstance(receipt.get("provider"), dict) else {}
|
|
259
|
+
_required(provider, ("adapter_id", "kind", "invocation_mode", "task_id", "model", "generated_at", "cost_class"), "receipt.provider", issues)
|
|
260
|
+
adapter = adapters.get(str(provider.get("adapter_id", "")))
|
|
261
|
+
if adapter is None:
|
|
262
|
+
_issue(issues, "unknown_adapter", f"adapter not found: {provider.get('adapter_id')}", "receipt.provider.adapter_id")
|
|
263
|
+
elif adapter.get("status") == "disabled":
|
|
264
|
+
_issue(issues, "adapter_disabled", f"adapter is disabled: {provider.get('adapter_id')}", "receipt.provider.adapter_id")
|
|
265
|
+
else:
|
|
266
|
+
for key in ("kind", "invocation_mode", "cost_class"):
|
|
267
|
+
if provider.get(key) != adapter.get(key):
|
|
268
|
+
_issue(issues, "adapter_mismatch", f"receipt provider {key} must match adapter registry", f"receipt.provider.{key}")
|
|
269
|
+
if not _datetime(provider.get("generated_at")):
|
|
270
|
+
_issue(issues, "invalid_datetime", "receipt.provider.generated_at must be ISO-8601", "receipt.provider.generated_at")
|
|
271
|
+
if "prompt_hash" in provider and not _hash(provider.get("prompt_hash")):
|
|
272
|
+
_issue(issues, "invalid_sha256", "receipt.provider.prompt_hash must be lowercase SHA-256", "receipt.provider.prompt_hash")
|
|
273
|
+
_validate_file_entries(receipt.get("outputs"), root, "receipt.outputs", issues)
|
|
274
|
+
if controls is not None and asset.get("id") != controls.get("asset_id"):
|
|
275
|
+
_issue(issues, "asset_id_mismatch", "receipt asset id must match control track asset id", "receipt.asset.id")
|
|
276
|
+
if provenance is not None:
|
|
277
|
+
if receipt.get("authority") != provenance.get("authority"):
|
|
278
|
+
_issue(issues, "authority_mismatch", "receipt authority must match provenance authority", "receipt.authority")
|
|
279
|
+
provenance_paths = {(entry.get("path"), entry.get("sha256")) for entry in provenance.get("files", []) if isinstance(entry, dict)}
|
|
280
|
+
for entry in receipt.get("outputs", []):
|
|
281
|
+
if isinstance(entry, dict) and (entry.get("path"), entry.get("sha256")) not in provenance_paths:
|
|
282
|
+
_issue(issues, "provenance_output_missing", "every receipt output must be hash-bound in provenance", "receipt.outputs")
|
|
283
|
+
_forbidden_secrets(receipt, issues)
|
|
284
|
+
for forbidden in ("human_approval", "production_approved", "production_eligible", "open_pr"):
|
|
285
|
+
if forbidden in receipt:
|
|
286
|
+
_issue(issues, "approval_forbidden", "receipt cannot contain approval or PR authority", f"receipt.{forbidden}")
|
|
287
|
+
return issues
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def validate_export(export: dict[str, Any], receipt: dict[str, Any] | None, controls: dict[str, Any] | None, root: Path) -> list[Issue]:
|
|
291
|
+
issues: list[Issue] = []
|
|
292
|
+
_required(export, ("schema_version", "manifest_id", "asset_id", "receipt_ref", "control_track_ref", "outputs", "created_at"), "export", issues)
|
|
293
|
+
if export.get("schema_version") != "0.1":
|
|
294
|
+
_issue(issues, "schema_version", "export.schema_version must be 0.1", "export.schema_version")
|
|
295
|
+
if not _datetime(export.get("created_at")):
|
|
296
|
+
_issue(issues, "invalid_datetime", "export.created_at must be ISO-8601", "export.created_at")
|
|
297
|
+
outputs = _validate_file_entries(export.get("outputs"), root, "export.outputs", issues)
|
|
298
|
+
for index, entry in enumerate(outputs):
|
|
299
|
+
if entry.get("target") not in {"runtime_candidate", "preview", "archive"}:
|
|
300
|
+
_issue(issues, "invalid_export_target", "export target is unsupported", f"export.outputs[{index}].target")
|
|
301
|
+
if receipt is not None:
|
|
302
|
+
if export.get("asset_id") != (receipt.get("asset") or {}).get("id"):
|
|
303
|
+
_issue(issues, "asset_id_mismatch", "export asset id must match receipt asset id", "export.asset_id")
|
|
304
|
+
receipt_outputs = {(item.get("path"), item.get("sha256")) for item in receipt.get("outputs", []) if isinstance(item, dict)}
|
|
305
|
+
for entry in outputs:
|
|
306
|
+
if (entry.get("path"), entry.get("sha256")) not in receipt_outputs:
|
|
307
|
+
_issue(issues, "receipt_output_missing", "every export output must appear in receipt with same hash", "export.outputs")
|
|
308
|
+
if controls is not None and export.get("asset_id") != controls.get("asset_id"):
|
|
309
|
+
_issue(issues, "asset_id_mismatch", "export asset id must match control track asset id", "export.asset_id")
|
|
310
|
+
_forbidden_secrets(export, issues)
|
|
311
|
+
return issues
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def evaluate_bundle(args: argparse.Namespace) -> dict[str, Any]:
|
|
315
|
+
root = args.root.resolve()
|
|
316
|
+
issues: list[Issue] = []
|
|
317
|
+
registry_path = args.registry.resolve()
|
|
318
|
+
receipt_path = args.receipt.resolve()
|
|
319
|
+
controls_path = args.controls.resolve()
|
|
320
|
+
export_path = args.export_manifest.resolve()
|
|
321
|
+
receipt = _read_json(receipt_path, issues, "receipt")
|
|
322
|
+
provenance_path = _resolve(root, receipt.get("provenance_ref", "") if receipt else "")
|
|
323
|
+
registry = _read_json(registry_path, issues, "registry")
|
|
324
|
+
controls = _read_json(controls_path, issues, "controls")
|
|
325
|
+
export = _read_json(export_path, issues, "export")
|
|
326
|
+
provenance = _read_json(provenance_path, issues, "provenance") if provenance_path else None
|
|
327
|
+
adapters: dict[str, dict[str, Any]] = {}
|
|
328
|
+
if registry is not None:
|
|
329
|
+
more, adapters = validate_registry(registry, registry_path.parent)
|
|
330
|
+
issues.extend(more)
|
|
331
|
+
if controls is not None:
|
|
332
|
+
issues.extend(validate_controls(controls, root))
|
|
333
|
+
if receipt is not None:
|
|
334
|
+
control_ref = _resolve(root, receipt.get("control_track_ref"))
|
|
335
|
+
if control_ref is None or control_ref.resolve() != controls_path:
|
|
336
|
+
_issue(issues, "control_ref_mismatch", "--controls must match receipt.control_track_ref and resolve inside root", "receipt.control_track_ref")
|
|
337
|
+
if provenance_path is None:
|
|
338
|
+
_issue(issues, "provenance_missing", "receipt.provenance_ref is missing or unsafe", "receipt.provenance_ref")
|
|
339
|
+
issues.extend(validate_receipt(receipt, controls, provenance, root, adapters))
|
|
340
|
+
if export is not None:
|
|
341
|
+
if receipt is not None and export.get("receipt_ref") != receipt_path.relative_to(root).as_posix():
|
|
342
|
+
_issue(issues, "receipt_ref_mismatch", "export.receipt_ref must match --receipt relative to root", "export.receipt_ref")
|
|
343
|
+
if controls is not None and export.get("control_track_ref") != controls_path.relative_to(root).as_posix():
|
|
344
|
+
_issue(issues, "control_ref_mismatch", "export.control_track_ref must match --controls relative to root", "export.control_track_ref")
|
|
345
|
+
issues.extend(validate_export(export, receipt, controls, root))
|
|
346
|
+
adapter = adapters.get(str(((receipt or {}).get("provider") or {}).get("adapter_id", "")))
|
|
347
|
+
warnings: list[Issue] = []
|
|
348
|
+
if adapter and adapter.get("status") != "verified":
|
|
349
|
+
warnings.append(Issue("warning", "adapter_not_runtime_verified", f"adapter status is {adapter.get('status')}; retain human review and runtime evidence", "receipt.provider.adapter_id"))
|
|
350
|
+
if args.strict and warnings:
|
|
351
|
+
issues.extend(Issue("error", item.code, item.message, item.path) for item in warnings)
|
|
352
|
+
warnings = []
|
|
353
|
+
errors = [asdict(item) for item in issues if item.severity == "error"]
|
|
354
|
+
warnings_json = [asdict(item) for item in issues if item.severity == "warning"] + [asdict(item) for item in warnings]
|
|
355
|
+
return {"contract": "artifact-intake", "status": "blocked" if errors else ("review_required" if warnings_json else "pass"), "ready": not errors, "runtime_candidate": bool(not errors and ((receipt or {}).get("asset") or {}).get("intended_use") == "runtime_candidate"), "production_eligible": False, "production_approved": False, "adapter": {"adapter_id": adapter.get("adapter_id"), "status": adapter.get("status"), "invocation_mode": adapter.get("invocation_mode"), "cost_class": adapter.get("cost_class")} if adapter else None, "errors": errors, "warnings": warnings_json, "evidence": {"receipt": str(receipt_path), "controls": str(controls_path), "export_manifest": str(export_path), "registry": str(registry_path), "provenance": str(provenance_path) if provenance_path else None}}
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def evaluate_single(args: argparse.Namespace) -> dict[str, Any]:
|
|
359
|
+
issues: list[Issue] = []
|
|
360
|
+
document = _read_json(args.input.resolve(), issues, args.kind)
|
|
361
|
+
if document is not None:
|
|
362
|
+
if args.kind == "registry":
|
|
363
|
+
more, _ = validate_registry(document, args.input.resolve().parent)
|
|
364
|
+
elif args.kind == "controls":
|
|
365
|
+
more = validate_controls(document, args.root.resolve())
|
|
366
|
+
else:
|
|
367
|
+
more = [Issue("error", "single_kind_unsupported", "receipt/export require the complete bundle command", "kind")]
|
|
368
|
+
issues.extend(more)
|
|
369
|
+
errors = [asdict(item) for item in issues if item.severity == "error"]
|
|
370
|
+
warnings = [asdict(item) for item in issues if item.severity == "warning"]
|
|
371
|
+
return {"contract": f"artifact-intake-{args.kind}", "status": "blocked" if errors else ("review_required" if warnings else "pass"), "ready": not errors, "production_eligible": False, "production_approved": False, "errors": errors, "warnings": warnings}
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def main(argv: list[str] | None = None) -> int:
|
|
375
|
+
parser = argparse.ArgumentParser(description="Validate provider-neutral MotionLoom artifact intake evidence")
|
|
376
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
377
|
+
for command in ("intake", "report"):
|
|
378
|
+
item = sub.add_parser(command)
|
|
379
|
+
item.add_argument("--root", type=Path, default=Path("."))
|
|
380
|
+
item.add_argument("--registry", type=Path, required=True)
|
|
381
|
+
item.add_argument("--receipt", type=Path, required=True)
|
|
382
|
+
item.add_argument("--controls", type=Path, required=True)
|
|
383
|
+
item.add_argument("--export-manifest", type=Path, required=True)
|
|
384
|
+
item.add_argument("--strict", action="store_true")
|
|
385
|
+
item.add_argument("--output", type=Path)
|
|
386
|
+
item.add_argument("--json", action="store_true")
|
|
387
|
+
check = sub.add_parser("validate")
|
|
388
|
+
check.add_argument("--kind", choices=("registry", "controls"), required=True)
|
|
389
|
+
check.add_argument("--input", type=Path, required=True)
|
|
390
|
+
check.add_argument("--root", type=Path, default=Path("."))
|
|
391
|
+
check.add_argument("--output", type=Path)
|
|
392
|
+
check.add_argument("--json", action="store_true")
|
|
393
|
+
args = parser.parse_args(argv)
|
|
394
|
+
result = evaluate_bundle(args) if args.command in {"intake", "report"} else evaluate_single(args)
|
|
395
|
+
if getattr(args, "output", None):
|
|
396
|
+
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
397
|
+
args.output.write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
398
|
+
if args.command != "report" or args.json:
|
|
399
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
400
|
+
else:
|
|
401
|
+
print(f"{result['contract']}: {result['status']} ({len(result['errors'])} errors, {len(result['warnings'])} warnings)")
|
|
402
|
+
for issue in result["errors"] + result["warnings"]:
|
|
403
|
+
print(f"- {issue['severity']}: {issue['code']}: {issue['message']}")
|
|
404
|
+
return 0 if result["ready"] else 1
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
if __name__ == "__main__":
|
|
408
|
+
raise SystemExit(main())
|