bmad-module-skill-forge 1.0.0 → 1.2.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/.claude-plugin/marketplace.json +1 -1
- package/README.md +9 -7
- package/docs/_data/pinned.yaml +5 -2
- package/docs/{RELEASING.md → _internal/RELEASING.md} +87 -61
- package/docs/{STABILITY.md → _internal/STABILITY.md} +2 -2
- package/docs/bmad-synergy.md +2 -2
- package/docs/getting-started.md +1 -1
- package/docs/troubleshooting.md +1 -1
- package/docs/why-skf.md +5 -4
- package/package.json +2 -7
- package/release-audits/v1.0.0-launch-audit.md +313 -2
- package/src/knowledge/ccc-bridge.md +1 -1
- package/src/shared/scripts/schemas/skf-setup-result-envelope.v1.json +134 -0
- package/src/shared/scripts/skf-detect-tools.py +359 -0
- package/src/shared/scripts/skf-emit-result-envelope.py +419 -0
- package/src/shared/scripts/skf-forge-tier-rw.py +413 -0
- package/src/shared/scripts/skf-merge-ccc-exclusions.py +324 -0
- package/src/shared/scripts/skf-preflight.py +14 -4
- package/src/shared/scripts/skf-qmd-classify-collections.py +212 -0
- package/src/shared/scripts/skf-validate-frontmatter.py +9 -3
- package/src/skf-audit-skill/steps-c/step-01-init.md +60 -0
- package/src/skf-audit-skill/steps-c/step-02-re-index.md +7 -1
- package/src/skf-audit-skill/steps-c/step-03-structural-diff.md +19 -1
- package/src/skf-audit-skill/steps-c/step-04-semantic-diff.md +10 -1
- package/src/skf-audit-skill/steps-c/step-05-severity-classify.md +8 -0
- package/src/skf-audit-skill/steps-c/step-06-report.md +8 -0
- package/src/skf-brief-skill/assets/skill-brief-schema.md +36 -18
- package/src/skf-create-skill/steps-c/step-06-validate.md +1 -1
- package/src/skf-drop-skill/steps-c/step-02-execute.md +1 -1
- package/src/skf-export-skill/assets/managed-section-format.md +1 -1
- package/src/skf-export-skill/steps-c/step-04-update-context.md +4 -4
- package/src/skf-rename-skill/steps-c/step-02-execute.md +1 -1
- package/src/skf-setup/SKILL.md +25 -10
- package/src/skf-setup/references/tier-rules.md +2 -2
- package/src/skf-setup/steps-c/step-01-detect-and-tier.md +58 -71
- package/src/skf-setup/steps-c/step-01b-ccc-index.md +49 -66
- package/src/skf-setup/steps-c/step-02-write-config.md +59 -86
- package/src/skf-setup/steps-c/step-03-auto-index.md +73 -91
- package/src/skf-setup/steps-c/step-04-report.md +135 -11
- package/src/skf-setup/steps-c/step-05-health-check.md +4 -2
- package/src/skf-test-skill/steps-c/step-01-init.md +4 -4
- package/src/skf-test-skill/steps-c/step-03-coverage-check.md +1 -1
- package/src/skf-update-skill/steps-c/step-02-detect-changes.md +123 -0
- package/tools/validate-docs-drift.js +42 -2
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.10"
|
|
3
|
+
# dependencies = []
|
|
4
|
+
# ///
|
|
5
|
+
"""SKF Emit Result Envelope — Schema-locked headless output for skf-setup.
|
|
6
|
+
|
|
7
|
+
Replaces the prose-driven envelope assembly in `src/skf-setup/steps-c/
|
|
8
|
+
step-04-report.md` §4 with one Python invocation. The envelope contract
|
|
9
|
+
(SKF_SETUP_RESULT_JSON) was added to step-04 in PR #247, extended in
|
|
10
|
+
PR #248 (header / outputs / failure modes), and extended again in this
|
|
11
|
+
PR (previous_tier, tier_changed, tools_added, tools_removed, error).
|
|
12
|
+
|
|
13
|
+
LLM-rendered envelopes risk silent schema drift on every invocation —
|
|
14
|
+
a pipeline that grep's `SKF_SETUP_RESULT_JSON: {…}` out of the workflow
|
|
15
|
+
log can break if the model decides to rename a key or rearrange a
|
|
16
|
+
nested structure. This script is the single source of truth: it takes
|
|
17
|
+
a context payload as JSON on stdin, computes derived fields
|
|
18
|
+
deterministically, and emits the envelope in a fixed shape that
|
|
19
|
+
matches the JSON Schema at
|
|
20
|
+
`src/shared/scripts/schemas/skf-setup-result-envelope.v1.json`.
|
|
21
|
+
|
|
22
|
+
Subcommands:
|
|
23
|
+
|
|
24
|
+
emit Read context payload as JSON on stdin, derive missing
|
|
25
|
+
fields (tools_added/removed, tier_changed), assemble the
|
|
26
|
+
envelope, emit `SKF_SETUP_RESULT_JSON: {one-line JSON}`
|
|
27
|
+
on stdout. Default subcommand.
|
|
28
|
+
|
|
29
|
+
validate Read an envelope (without the prefix) as JSON on stdin
|
|
30
|
+
and verify it against the documented schema. No stdout
|
|
31
|
+
on success; non-zero exit + stderr error on failure.
|
|
32
|
+
Useful for paranoid pipelines that want to validate a
|
|
33
|
+
received envelope before consuming it.
|
|
34
|
+
|
|
35
|
+
Context payload shape (consumed by `emit`):
|
|
36
|
+
|
|
37
|
+
{
|
|
38
|
+
"tier": "Quick|Forge|Forge+|Deep",
|
|
39
|
+
"previous_tier": "Quick|Forge|Forge+|Deep|null",
|
|
40
|
+
"tools": {"ast_grep": bool, "gh_cli": bool, "qmd": bool, "ccc": bool},
|
|
41
|
+
"previous_tools": {…same shape…|null},
|
|
42
|
+
"config_path": "/abs/path/to/forge-tier.yaml",
|
|
43
|
+
"ccc_index": {"status": "...", "indexed_path": "...|null", "file_count": int|null},
|
|
44
|
+
"files_written": ["forge-tier.yaml", ...],
|
|
45
|
+
"tier_override_active": bool,
|
|
46
|
+
"tier_override_invalid": bool,
|
|
47
|
+
"tier_override_invalid_value": "string|null",
|
|
48
|
+
"tier_override_invalid_suggestion": "string|null",
|
|
49
|
+
"tier_override_unsafe": bool,
|
|
50
|
+
"tier_override_unsafe_missing": ["gh", "qmd", ...],
|
|
51
|
+
"require_tier_satisfied": bool|null,
|
|
52
|
+
"require_tier_failure_missing": ["ccc", ...],
|
|
53
|
+
"qmd_status": "absent|daemon_stopped|healthy",
|
|
54
|
+
"ccc_exclusion_warnings": ["string", ...],
|
|
55
|
+
"ccc_registry_stale_removed": ["/path", ...],
|
|
56
|
+
"ccc_indexing_failed_reason": "string|null",
|
|
57
|
+
"error": null|{"phase","path","reason"}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
Caller does NOT need to compute warnings, tools_added/removed, or
|
|
61
|
+
tier_changed — the script derives them from the inputs above.
|
|
62
|
+
|
|
63
|
+
CLI — invoke via `uv run` for invocation consistency with sibling
|
|
64
|
+
scripts (PEP 723 inline metadata is honored automatically; this script
|
|
65
|
+
declares dependencies = [] so technically `python3` works too, but
|
|
66
|
+
prefer `uv run` so all 5 cutover scripts share one canonical invocation
|
|
67
|
+
pattern documented in docs/getting-started.md):
|
|
68
|
+
|
|
69
|
+
echo '{...context payload...}' | uv run skf-emit-result-envelope.py emit
|
|
70
|
+
echo '{"skf_setup":{...}}' | uv run skf-emit-result-envelope.py validate
|
|
71
|
+
|
|
72
|
+
Exit codes:
|
|
73
|
+
0 success
|
|
74
|
+
1 user error (bad args, malformed JSON, validation failure)
|
|
75
|
+
2 internal error
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
from __future__ import annotations
|
|
79
|
+
|
|
80
|
+
import argparse
|
|
81
|
+
import json
|
|
82
|
+
import os
|
|
83
|
+
import sys
|
|
84
|
+
from pathlib import Path
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
ENVELOPE_PREFIX = "SKF_SETUP_RESULT_JSON: "
|
|
88
|
+
SCHEMA_FILE = Path(__file__).parent / "schemas" / "skf-setup-result-envelope.v1.json"
|
|
89
|
+
TOOL_KEYS = ("ast_grep", "gh_cli", "qmd", "ccc")
|
|
90
|
+
VALID_TIERS = ("Quick", "Forge", "Forge+", "Deep")
|
|
91
|
+
VALID_FILES = ("forge-tier.yaml", "preferences.yaml", "settings.yml", "ccc_index")
|
|
92
|
+
VALID_CCC_STATUS = ("fresh", "created", "failed", "none")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _die(code: int, message: str) -> None:
|
|
96
|
+
print(json.dumps({"status": "error", "message": message}), file=sys.stderr)
|
|
97
|
+
sys.exit(code)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _read_stdin_json(label: str) -> dict:
|
|
101
|
+
raw = sys.stdin.read()
|
|
102
|
+
if not raw.strip():
|
|
103
|
+
_die(1, f"{label}: empty stdin (expected JSON payload)")
|
|
104
|
+
try:
|
|
105
|
+
return json.loads(raw)
|
|
106
|
+
except json.JSONDecodeError as e:
|
|
107
|
+
_die(1, f"{label}: invalid JSON on stdin: {e}")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ─── derive helpers ─────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _normalize_tools(maybe_tools) -> dict:
|
|
114
|
+
"""Coerce either {key: bool} or {key: {available: bool}} into {key: bool}.
|
|
115
|
+
|
|
116
|
+
skf-detect-tools.py emits the second shape; some callers will pass the
|
|
117
|
+
first. Tolerate both for robustness; missing keys default to False.
|
|
118
|
+
"""
|
|
119
|
+
if maybe_tools is None:
|
|
120
|
+
return {k: False for k in TOOL_KEYS}
|
|
121
|
+
out = {}
|
|
122
|
+
for k in TOOL_KEYS:
|
|
123
|
+
v = maybe_tools.get(k)
|
|
124
|
+
if isinstance(v, dict):
|
|
125
|
+
out[k] = bool(v.get("available", False))
|
|
126
|
+
else:
|
|
127
|
+
out[k] = bool(v)
|
|
128
|
+
return out
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _compute_tool_deltas(current: dict, previous) -> tuple[list[str], list[str]]:
|
|
132
|
+
"""Return (added, removed) tool-key lists, sorted for determinism.
|
|
133
|
+
|
|
134
|
+
First-run convention (previous is None or empty): added = currently
|
|
135
|
+
available tools; removed = []. Matches the documented step-04 §4 rule.
|
|
136
|
+
"""
|
|
137
|
+
cur = _normalize_tools(current)
|
|
138
|
+
if not previous:
|
|
139
|
+
added = sorted(k for k, v in cur.items() if v)
|
|
140
|
+
return added, []
|
|
141
|
+
prev = _normalize_tools(previous)
|
|
142
|
+
added = sorted(k for k in TOOL_KEYS if cur[k] and not prev[k])
|
|
143
|
+
removed = sorted(k for k in TOOL_KEYS if prev[k] and not cur[k])
|
|
144
|
+
return added, removed
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _assemble_warnings(payload: dict) -> list[str]:
|
|
148
|
+
"""Fold every documented warning source into the envelope's warnings array.
|
|
149
|
+
|
|
150
|
+
Matches the field-rules block in step-04 §4 — pipelines should only need
|
|
151
|
+
to consult `warnings` to surface non-fatal issues.
|
|
152
|
+
"""
|
|
153
|
+
warnings: list[str] = []
|
|
154
|
+
if payload.get("tier_override_invalid"):
|
|
155
|
+
bad = payload.get("tier_override_invalid_value")
|
|
156
|
+
suggestion = payload.get("tier_override_invalid_suggestion")
|
|
157
|
+
bad_text = bad if bad is not None else "<unknown>"
|
|
158
|
+
if suggestion:
|
|
159
|
+
warnings.append(f"tier_override_invalid: {bad_text} (did you mean {suggestion}?)")
|
|
160
|
+
else:
|
|
161
|
+
warnings.append(f"tier_override_invalid: {bad_text}")
|
|
162
|
+
if payload.get("tier_override_unsafe"):
|
|
163
|
+
missing = payload.get("tier_override_unsafe_missing", []) or []
|
|
164
|
+
warnings.append(f"tier_override_unsafe: missing {', '.join(missing) if missing else '<none>'}")
|
|
165
|
+
for w in payload.get("ccc_exclusion_warnings", []) or []:
|
|
166
|
+
warnings.append(str(w))
|
|
167
|
+
for p in payload.get("ccc_registry_stale_removed", []) or []:
|
|
168
|
+
warnings.append(f"ccc_registry_stale_removed: {p}")
|
|
169
|
+
if payload.get("qmd_status") == "daemon_stopped":
|
|
170
|
+
warnings.append("qmd_daemon_stopped")
|
|
171
|
+
failure_reason = payload.get("ccc_indexing_failed_reason")
|
|
172
|
+
if failure_reason:
|
|
173
|
+
warnings.append(f"ccc_indexing_failed: {failure_reason}")
|
|
174
|
+
if payload.get("require_tier_satisfied") is False:
|
|
175
|
+
missing = payload.get("require_tier_failure_missing", []) or []
|
|
176
|
+
warnings.append(
|
|
177
|
+
f"require_tier_failed: missing {', '.join(missing) if missing else '<none>'}"
|
|
178
|
+
)
|
|
179
|
+
return warnings
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _normalize_files_written(maybe_files) -> list[str]:
|
|
183
|
+
"""Accept either a list of names or a dict {name: bool}; emit sorted-canonical list."""
|
|
184
|
+
if maybe_files is None:
|
|
185
|
+
return []
|
|
186
|
+
if isinstance(maybe_files, dict):
|
|
187
|
+
names = [k for k, v in maybe_files.items() if v]
|
|
188
|
+
elif isinstance(maybe_files, list):
|
|
189
|
+
names = [str(x) for x in maybe_files]
|
|
190
|
+
else:
|
|
191
|
+
_die(1, f"files_written must be list or dict, got {type(maybe_files).__name__}")
|
|
192
|
+
# Filter to documented names only; preserve canonical order rather than
|
|
193
|
+
# caller-provided order so two callers with the same files get byte-identical
|
|
194
|
+
# output.
|
|
195
|
+
return [name for name in VALID_FILES if name in names]
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _normalize_ccc_index(maybe_idx) -> dict:
|
|
199
|
+
"""Coerce caller's ccc_index dict to the envelope shape (drops extras)."""
|
|
200
|
+
if maybe_idx is None:
|
|
201
|
+
return {"status": "none", "indexed_path": None, "file_count": None}
|
|
202
|
+
return {
|
|
203
|
+
"status": maybe_idx.get("status", "none"),
|
|
204
|
+
"indexed_path": maybe_idx.get("indexed_path"),
|
|
205
|
+
"file_count": maybe_idx.get("file_count"),
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _normalize_error(maybe_error) -> dict | None:
|
|
210
|
+
if maybe_error is None:
|
|
211
|
+
return None
|
|
212
|
+
if not isinstance(maybe_error, dict):
|
|
213
|
+
_die(1, f"error must be null or object, got {type(maybe_error).__name__}")
|
|
214
|
+
required = {"phase", "path", "reason"}
|
|
215
|
+
missing = required - set(maybe_error.keys())
|
|
216
|
+
if missing:
|
|
217
|
+
_die(1, f"error object missing required keys: {sorted(missing)}")
|
|
218
|
+
return {
|
|
219
|
+
"phase": str(maybe_error["phase"]),
|
|
220
|
+
"path": str(maybe_error["path"]),
|
|
221
|
+
"reason": str(maybe_error["reason"]),
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# ─── envelope assembly ──────────────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def assemble_envelope(payload: dict) -> dict:
|
|
229
|
+
"""Build the canonical envelope from a context payload. Pure function."""
|
|
230
|
+
tier = payload.get("tier")
|
|
231
|
+
if tier not in VALID_TIERS:
|
|
232
|
+
_die(1, f"tier must be one of {VALID_TIERS}, got {tier!r}")
|
|
233
|
+
|
|
234
|
+
previous_tier = payload.get("previous_tier")
|
|
235
|
+
if previous_tier is not None and previous_tier not in VALID_TIERS:
|
|
236
|
+
_die(1, f"previous_tier must be one of {VALID_TIERS} or null, got {previous_tier!r}")
|
|
237
|
+
tier_changed = previous_tier is not None and previous_tier != tier
|
|
238
|
+
|
|
239
|
+
tools = _normalize_tools(payload.get("tools"))
|
|
240
|
+
tools_added, tools_removed = _compute_tool_deltas(
|
|
241
|
+
payload.get("tools"), payload.get("previous_tools")
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
config_path = payload.get("config_path")
|
|
245
|
+
if not isinstance(config_path, str) or not config_path:
|
|
246
|
+
_die(1, "config_path must be a non-empty string")
|
|
247
|
+
|
|
248
|
+
ccc_index = _normalize_ccc_index(payload.get("ccc_index"))
|
|
249
|
+
if ccc_index["status"] not in VALID_CCC_STATUS:
|
|
250
|
+
_die(1, f"ccc_index.status must be one of {VALID_CCC_STATUS}, got {ccc_index['status']!r}")
|
|
251
|
+
|
|
252
|
+
require_tier_satisfied = payload.get("require_tier_satisfied")
|
|
253
|
+
if require_tier_satisfied is not None and not isinstance(require_tier_satisfied, bool):
|
|
254
|
+
_die(1, f"require_tier_satisfied must be bool or null, got {type(require_tier_satisfied).__name__}")
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
"skf_setup": {
|
|
258
|
+
"tier": tier,
|
|
259
|
+
"previous_tier": previous_tier,
|
|
260
|
+
"tier_changed": tier_changed,
|
|
261
|
+
"tools": tools,
|
|
262
|
+
"tools_added": tools_added,
|
|
263
|
+
"tools_removed": tools_removed,
|
|
264
|
+
"config_path": config_path,
|
|
265
|
+
"ccc_index": ccc_index,
|
|
266
|
+
"files_written": _normalize_files_written(payload.get("files_written")),
|
|
267
|
+
"tier_override_active": bool(payload.get("tier_override_active", False)),
|
|
268
|
+
"tier_override_invalid": bool(payload.get("tier_override_invalid", False)),
|
|
269
|
+
"require_tier_satisfied": require_tier_satisfied,
|
|
270
|
+
"warnings": _assemble_warnings(payload),
|
|
271
|
+
"error": _normalize_error(payload.get("error")),
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def emit_envelope_line(envelope: dict) -> str:
|
|
277
|
+
"""Serialize the envelope as one prefixed line. No embedded newlines, sort_keys=True for determinism."""
|
|
278
|
+
body = json.dumps(envelope, separators=(",", ":"), sort_keys=True, ensure_ascii=False)
|
|
279
|
+
if "\n" in body:
|
|
280
|
+
_die(2, "envelope serialization produced embedded newline (should be impossible)")
|
|
281
|
+
return ENVELOPE_PREFIX + body
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# ─── minimal stdlib JSON Schema validator ───────────────────────────────────
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _load_schema() -> dict:
|
|
288
|
+
if not SCHEMA_FILE.exists():
|
|
289
|
+
_die(2, f"schema file missing: {SCHEMA_FILE}")
|
|
290
|
+
return json.loads(SCHEMA_FILE.read_text(encoding="utf-8"))
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _validate_against_schema(value, schema: dict, path: str = "$") -> list[str]:
|
|
294
|
+
"""Return a list of error strings (empty = valid).
|
|
295
|
+
|
|
296
|
+
Implements only the subset of Draft 2020-12 features used by our envelope
|
|
297
|
+
schema: type, enum, oneOf, properties, additionalProperties, required,
|
|
298
|
+
items, uniqueItems, minLength, minimum. Sufficient to catch every
|
|
299
|
+
violation our schema can express; not a general-purpose validator.
|
|
300
|
+
"""
|
|
301
|
+
errors: list[str] = []
|
|
302
|
+
|
|
303
|
+
if "oneOf" in schema:
|
|
304
|
+
matches = sum(1 for sub in schema["oneOf"] if not _validate_against_schema(value, sub, path))
|
|
305
|
+
if matches != 1:
|
|
306
|
+
errors.append(f"{path}: matched {matches} of {len(schema['oneOf'])} oneOf branches (expected exactly 1)")
|
|
307
|
+
return errors
|
|
308
|
+
|
|
309
|
+
expected_type = schema.get("type")
|
|
310
|
+
if expected_type is not None:
|
|
311
|
+
if not _matches_type(value, expected_type):
|
|
312
|
+
errors.append(f"{path}: expected type {expected_type}, got {type(value).__name__}")
|
|
313
|
+
return errors
|
|
314
|
+
|
|
315
|
+
if "enum" in schema:
|
|
316
|
+
if value not in schema["enum"]:
|
|
317
|
+
errors.append(f"{path}: value {value!r} not in enum {schema['enum']}")
|
|
318
|
+
|
|
319
|
+
if isinstance(value, dict):
|
|
320
|
+
props = schema.get("properties", {})
|
|
321
|
+
required = set(schema.get("required", []))
|
|
322
|
+
present = set(value.keys())
|
|
323
|
+
missing = required - present
|
|
324
|
+
for k in sorted(missing):
|
|
325
|
+
errors.append(f"{path}: missing required property {k!r}")
|
|
326
|
+
if schema.get("additionalProperties") is False:
|
|
327
|
+
extra = present - set(props.keys())
|
|
328
|
+
for k in sorted(extra):
|
|
329
|
+
errors.append(f"{path}: unexpected property {k!r}")
|
|
330
|
+
for k, sub in props.items():
|
|
331
|
+
if k in value:
|
|
332
|
+
errors.extend(_validate_against_schema(value[k], sub, f"{path}.{k}"))
|
|
333
|
+
|
|
334
|
+
if isinstance(value, list):
|
|
335
|
+
if "items" in schema:
|
|
336
|
+
for i, item in enumerate(value):
|
|
337
|
+
errors.extend(_validate_against_schema(item, schema["items"], f"{path}[{i}]"))
|
|
338
|
+
if schema.get("uniqueItems") and len(value) != len(set(map(_freeze, value))):
|
|
339
|
+
errors.append(f"{path}: items not unique")
|
|
340
|
+
|
|
341
|
+
if isinstance(value, str) and "minLength" in schema:
|
|
342
|
+
if len(value) < schema["minLength"]:
|
|
343
|
+
errors.append(f"{path}: string shorter than minLength {schema['minLength']}")
|
|
344
|
+
|
|
345
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool) and "minimum" in schema:
|
|
346
|
+
if value < schema["minimum"]:
|
|
347
|
+
errors.append(f"{path}: value {value} below minimum {schema['minimum']}")
|
|
348
|
+
|
|
349
|
+
return errors
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _matches_type(value, expected) -> bool:
|
|
353
|
+
"""Implement JSON Schema 'type' for a single string OR a list of allowed types."""
|
|
354
|
+
if isinstance(expected, list):
|
|
355
|
+
return any(_matches_type(value, t) for t in expected)
|
|
356
|
+
if expected == "object":
|
|
357
|
+
return isinstance(value, dict)
|
|
358
|
+
if expected == "array":
|
|
359
|
+
return isinstance(value, list)
|
|
360
|
+
if expected == "string":
|
|
361
|
+
return isinstance(value, str)
|
|
362
|
+
if expected == "boolean":
|
|
363
|
+
return isinstance(value, bool)
|
|
364
|
+
if expected == "integer":
|
|
365
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
366
|
+
if expected == "null":
|
|
367
|
+
return value is None
|
|
368
|
+
return False
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _freeze(v):
|
|
372
|
+
"""Make a value hashable for uniqueItems checks."""
|
|
373
|
+
if isinstance(v, dict):
|
|
374
|
+
return tuple(sorted((k, _freeze(val)) for k, val in v.items()))
|
|
375
|
+
if isinstance(v, list):
|
|
376
|
+
return tuple(_freeze(x) for x in v)
|
|
377
|
+
return v
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
# ─── subcommands ────────────────────────────────────────────────────────────
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def cmd_emit() -> None:
|
|
384
|
+
payload = _read_stdin_json("emit")
|
|
385
|
+
envelope = assemble_envelope(payload)
|
|
386
|
+
schema = _load_schema()
|
|
387
|
+
errors = _validate_against_schema(envelope, schema)
|
|
388
|
+
if errors:
|
|
389
|
+
_die(2, f"assembled envelope failed schema validation (this is a bug): {errors}")
|
|
390
|
+
print(emit_envelope_line(envelope))
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def cmd_validate() -> None:
|
|
394
|
+
envelope = _read_stdin_json("validate")
|
|
395
|
+
schema = _load_schema()
|
|
396
|
+
errors = _validate_against_schema(envelope, schema)
|
|
397
|
+
if errors:
|
|
398
|
+
_die(1, "; ".join(errors))
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def main() -> None:
|
|
402
|
+
parser = argparse.ArgumentParser(
|
|
403
|
+
description=__doc__,
|
|
404
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
405
|
+
)
|
|
406
|
+
sub = parser.add_subparsers(dest="cmd")
|
|
407
|
+
sub.add_parser("emit", help="Build envelope from context payload (default).")
|
|
408
|
+
sub.add_parser("validate", help="Validate an envelope payload against the schema.")
|
|
409
|
+
args = parser.parse_args()
|
|
410
|
+
|
|
411
|
+
cmd = args.cmd or "emit"
|
|
412
|
+
if cmd == "emit":
|
|
413
|
+
cmd_emit()
|
|
414
|
+
elif cmd == "validate":
|
|
415
|
+
cmd_validate()
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
if __name__ == "__main__":
|
|
419
|
+
main()
|