tirtc-device-builder 0.5.0 → 0.6.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/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +7 -0
- package/README.md +19 -9
- package/package.json +1 -1
- package/skills/tirtc-esp32-builder/SKILL.md +4 -4
- package/skills/tirtc-esp32-builder/assets/board-audio-contract.example.json +66 -0
- package/skills/tirtc-esp32-builder/assets/board-video-contract.example.json +94 -0
- package/skills/tirtc-esp32-builder/assets/developer-intake-prompt.md +7 -4
- package/skills/tirtc-esp32-builder/assets/hardware-ir-v2.example.json +5 -0
- package/skills/tirtc-esp32-builder/assets/lckfb-szpi-esp32s3-portable-prompt.md +51 -0
- package/skills/tirtc-esp32-builder/assets/report-template.md +11 -0
- package/skills/tirtc-esp32-builder/references/audio-contract.md +51 -0
- package/skills/tirtc-esp32-builder/references/capability-rules.md +1 -1
- package/skills/tirtc-esp32-builder/references/environment.md +8 -0
- package/skills/tirtc-esp32-builder/references/hardware-ir.md +6 -2
- package/skills/tirtc-esp32-builder/references/reporting.md +4 -2
- package/skills/tirtc-esp32-builder/references/video-contract.md +30 -0
- package/skills/tirtc-esp32-builder/references/workflow.md +1 -1
- package/skills/tirtc-esp32-builder/scripts/audio_contract.py +320 -0
- package/skills/tirtc-esp32-builder/scripts/hardware_ir.py +223 -4
- package/skills/tirtc-esp32-builder/scripts/install_audio_gate.py +66 -0
- package/skills/tirtc-esp32-builder/scripts/install_video_gate.py +66 -0
- package/skills/tirtc-esp32-builder/scripts/project_portability.py +86 -0
- package/skills/tirtc-esp32-builder/scripts/video_contract.py +333 -0
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Verify an ESP32 board audio contract against locked source and adapter files."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
PAIR_PATTERN = re.compile(r"\{\s*(\d+)\s*,\s*(\d+)\s*,")
|
|
16
|
+
AUDIO_MODES = {"standard", "tdm", "dsp", "pcm"}
|
|
17
|
+
HANDOFF_POLICIES = {"none", "release_before_claim", "delete_recreate"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def sha256_file(path: Path) -> str:
|
|
21
|
+
digest = hashlib.sha256()
|
|
22
|
+
with path.open("rb") as stream:
|
|
23
|
+
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
|
24
|
+
digest.update(chunk)
|
|
25
|
+
return digest.hexdigest()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def project_file(project: Path, value: Any, label: str) -> Path:
|
|
29
|
+
if not isinstance(value, str) or not value.strip():
|
|
30
|
+
raise ValueError(f"{label} must be a non-empty project-relative path")
|
|
31
|
+
relative = Path(value)
|
|
32
|
+
if relative.is_absolute():
|
|
33
|
+
raise ValueError(f"{label} must be project-relative, got {value!r}")
|
|
34
|
+
resolved = (project / relative).resolve()
|
|
35
|
+
if project != resolved and project not in resolved.parents:
|
|
36
|
+
raise ValueError(f"{label} escapes project root: {value!r}")
|
|
37
|
+
return resolved
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def require_mapping(value: Any, label: str, errors: list[str]) -> dict[str, Any]:
|
|
41
|
+
if not isinstance(value, dict):
|
|
42
|
+
errors.append(f"{label} must be an object")
|
|
43
|
+
return {}
|
|
44
|
+
return value
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def require_positive_int(value: Any, label: str, errors: list[str]) -> int | None:
|
|
48
|
+
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
|
49
|
+
errors.append(f"{label} must be a positive integer")
|
|
50
|
+
return None
|
|
51
|
+
return value
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def require_nonempty_string(value: Any, label: str, errors: list[str]) -> str | None:
|
|
55
|
+
if not isinstance(value, str) or not value.strip():
|
|
56
|
+
errors.append(f"{label} must be a non-empty string")
|
|
57
|
+
return None
|
|
58
|
+
return value
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def check_clock(
|
|
62
|
+
project: Path,
|
|
63
|
+
contract: dict[str, Any],
|
|
64
|
+
errors: list[str],
|
|
65
|
+
inputs: dict[str, str],
|
|
66
|
+
) -> None:
|
|
67
|
+
clock = require_mapping(contract.get("clock"), "clock", errors)
|
|
68
|
+
sample_rate = require_positive_int(
|
|
69
|
+
clock.get("sample_rate_hz"), "clock.sample_rate_hz", errors
|
|
70
|
+
)
|
|
71
|
+
ratio = require_positive_int(clock.get("mclk_ratio"), "clock.mclk_ratio", errors)
|
|
72
|
+
mclk = require_positive_int(clock.get("mclk_hz"), "clock.mclk_hz", errors)
|
|
73
|
+
if None not in (sample_rate, ratio, mclk) and sample_rate * ratio != mclk:
|
|
74
|
+
errors.append(
|
|
75
|
+
"clock tuple is inconsistent: "
|
|
76
|
+
f"{sample_rate}Hz x {ratio} != {mclk}Hz"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
tables = clock.get("codec_tables")
|
|
80
|
+
if not isinstance(tables, list) or not tables:
|
|
81
|
+
errors.append("clock.codec_tables must list every clocked codec driver table")
|
|
82
|
+
return
|
|
83
|
+
if sample_rate is None or mclk is None:
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
for index, item in enumerate(tables):
|
|
87
|
+
label = f"clock.codec_tables[{index}]"
|
|
88
|
+
table = require_mapping(item, label, errors)
|
|
89
|
+
codec = require_nonempty_string(table.get("codec"), f"{label}.codec", errors)
|
|
90
|
+
try:
|
|
91
|
+
source = project_file(project, table.get("source"), f"{label}.source")
|
|
92
|
+
except ValueError as exc:
|
|
93
|
+
errors.append(str(exc))
|
|
94
|
+
continue
|
|
95
|
+
if not source.is_file():
|
|
96
|
+
errors.append(f"{label}.source does not exist: {source}")
|
|
97
|
+
continue
|
|
98
|
+
text = source.read_text(encoding="utf-8", errors="replace")
|
|
99
|
+
pairs = {(int(first), int(second)) for first, second in PAIR_PATTERN.findall(text)}
|
|
100
|
+
inputs[str(source.relative_to(project))] = sha256_file(source)
|
|
101
|
+
if not pairs:
|
|
102
|
+
errors.append(f"{label} contains no C initializer clock pairs")
|
|
103
|
+
elif (mclk, sample_rate) not in pairs:
|
|
104
|
+
errors.append(
|
|
105
|
+
f"{codec or label} rejects selected clock tuple: "
|
|
106
|
+
f"sample_rate={sample_rate}Hz mclk={mclk}Hz"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def check_endpoint(
|
|
111
|
+
value: Any,
|
|
112
|
+
label: str,
|
|
113
|
+
errors: list[str],
|
|
114
|
+
) -> dict[str, Any]:
|
|
115
|
+
endpoint = require_mapping(value, label, errors)
|
|
116
|
+
controller = endpoint.get("controller")
|
|
117
|
+
if isinstance(controller, bool) or not isinstance(controller, int) or controller < 0:
|
|
118
|
+
errors.append(f"{label}.controller must be an integer >= 0")
|
|
119
|
+
mode = endpoint.get("mode")
|
|
120
|
+
if mode not in AUDIO_MODES:
|
|
121
|
+
errors.append(f"{label}.mode must be one of {', '.join(sorted(AUDIO_MODES))}")
|
|
122
|
+
role = endpoint.get("role")
|
|
123
|
+
if role not in {"master", "slave"}:
|
|
124
|
+
errors.append(f"{label}.role must be master or slave")
|
|
125
|
+
return endpoint
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def check_topology(contract: dict[str, Any], errors: list[str]) -> None:
|
|
129
|
+
capture = check_endpoint(contract.get("capture"), "capture", errors)
|
|
130
|
+
playback = check_endpoint(contract.get("playback"), "playback", errors)
|
|
131
|
+
|
|
132
|
+
capture_mode = capture.get("mode")
|
|
133
|
+
tdm_enabled = capture.get("tdm_enabled")
|
|
134
|
+
if not isinstance(tdm_enabled, bool):
|
|
135
|
+
errors.append("capture.tdm_enabled must be true or false")
|
|
136
|
+
elif (capture_mode == "tdm") != tdm_enabled:
|
|
137
|
+
errors.append("capture.mode and capture.tdm_enabled disagree")
|
|
138
|
+
|
|
139
|
+
if capture_mode == "tdm":
|
|
140
|
+
slot_count = require_positive_int(
|
|
141
|
+
capture.get("slot_count"), "capture.slot_count", errors
|
|
142
|
+
)
|
|
143
|
+
slot_order = capture.get("slot_order")
|
|
144
|
+
slot_signals = capture.get("slot_signals")
|
|
145
|
+
selected_slot = capture.get("selected_slot")
|
|
146
|
+
selected_signal = capture.get("selected_signal")
|
|
147
|
+
if not isinstance(slot_order, list) or not slot_order:
|
|
148
|
+
errors.append("capture.slot_order must be a non-empty array in TDM mode")
|
|
149
|
+
if not isinstance(slot_signals, list) or not slot_signals:
|
|
150
|
+
errors.append("capture.slot_signals must be a non-empty array in TDM mode")
|
|
151
|
+
if slot_count is not None:
|
|
152
|
+
if isinstance(slot_order, list) and len(slot_order) != slot_count:
|
|
153
|
+
errors.append("capture.slot_order length must equal capture.slot_count")
|
|
154
|
+
if isinstance(slot_signals, list) and len(slot_signals) != slot_count:
|
|
155
|
+
errors.append("capture.slot_signals length must equal capture.slot_count")
|
|
156
|
+
if (
|
|
157
|
+
isinstance(selected_slot, bool)
|
|
158
|
+
or not isinstance(selected_slot, int)
|
|
159
|
+
or selected_slot < 0
|
|
160
|
+
or slot_count is None
|
|
161
|
+
or selected_slot >= slot_count
|
|
162
|
+
):
|
|
163
|
+
errors.append("capture.selected_slot must select an available TDM slot")
|
|
164
|
+
elif isinstance(slot_signals, list) and selected_slot < len(slot_signals):
|
|
165
|
+
if selected_signal != slot_signals[selected_slot]:
|
|
166
|
+
errors.append(
|
|
167
|
+
"capture.selected_signal does not match capture.slot_signals at "
|
|
168
|
+
"capture.selected_slot"
|
|
169
|
+
)
|
|
170
|
+
require_nonempty_string(
|
|
171
|
+
capture.get("mapping_evidence"), "capture.mapping_evidence", errors
|
|
172
|
+
)
|
|
173
|
+
else:
|
|
174
|
+
require_nonempty_string(
|
|
175
|
+
capture.get("mapping_evidence"), "capture.mapping_evidence", errors
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
shared = require_mapping(contract.get("shared_clock"), "shared_clock", errors)
|
|
179
|
+
gpios = shared.get("gpios")
|
|
180
|
+
if not isinstance(gpios, list) or not gpios or any(
|
|
181
|
+
isinstance(gpio, bool) or not isinstance(gpio, int) or gpio < 0 for gpio in gpios
|
|
182
|
+
):
|
|
183
|
+
errors.append("shared_clock.gpios must be a non-empty array of GPIO numbers")
|
|
184
|
+
simultaneous = shared.get("directions_simultaneous")
|
|
185
|
+
if not isinstance(simultaneous, bool):
|
|
186
|
+
errors.append("shared_clock.directions_simultaneous must be true or false")
|
|
187
|
+
handoff = shared.get("handoff")
|
|
188
|
+
if handoff not in HANDOFF_POLICIES:
|
|
189
|
+
errors.append(
|
|
190
|
+
"shared_clock.handoff must be one of "
|
|
191
|
+
+ ", ".join(sorted(HANDOFF_POLICIES))
|
|
192
|
+
)
|
|
193
|
+
if simultaneous is False and handoff == "none":
|
|
194
|
+
errors.append("half-duplex shared clocks require an explicit handoff")
|
|
195
|
+
if simultaneous is True and capture.get("mode") != playback.get("mode"):
|
|
196
|
+
errors.append("simultaneous directions cannot use different I2S modes")
|
|
197
|
+
if (
|
|
198
|
+
capture.get("controller") == playback.get("controller")
|
|
199
|
+
and capture.get("mode") != playback.get("mode")
|
|
200
|
+
and handoff != "delete_recreate"
|
|
201
|
+
):
|
|
202
|
+
errors.append(
|
|
203
|
+
"one I2S controller cannot retain different capture/playback modes; "
|
|
204
|
+
"use distinct controllers or delete_recreate handoff"
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def check_assertions(
|
|
209
|
+
project: Path,
|
|
210
|
+
contract: dict[str, Any],
|
|
211
|
+
errors: list[str],
|
|
212
|
+
inputs: dict[str, str],
|
|
213
|
+
) -> None:
|
|
214
|
+
assertions = contract.get("implementation_assertions")
|
|
215
|
+
if not isinstance(assertions, list) or not assertions:
|
|
216
|
+
errors.append("implementation_assertions must be a non-empty array")
|
|
217
|
+
return
|
|
218
|
+
for index, item in enumerate(assertions):
|
|
219
|
+
label = f"implementation_assertions[{index}]"
|
|
220
|
+
assertion = require_mapping(item, label, errors)
|
|
221
|
+
try:
|
|
222
|
+
source = project_file(project, assertion.get("file"), f"{label}.file")
|
|
223
|
+
except ValueError as exc:
|
|
224
|
+
errors.append(str(exc))
|
|
225
|
+
continue
|
|
226
|
+
if not source.is_file():
|
|
227
|
+
errors.append(f"{label}.file does not exist: {source}")
|
|
228
|
+
continue
|
|
229
|
+
text = source.read_text(encoding="utf-8", errors="replace")
|
|
230
|
+
compact = re.sub(r"\s+", "", text)
|
|
231
|
+
inputs[str(source.relative_to(project))] = sha256_file(source)
|
|
232
|
+
for field, haystack, present in (
|
|
233
|
+
("contains", text, True),
|
|
234
|
+
("contains_compact", compact, True),
|
|
235
|
+
("absent", text, False),
|
|
236
|
+
("absent_compact", compact, False),
|
|
237
|
+
):
|
|
238
|
+
needles = assertion.get(field, [])
|
|
239
|
+
if not isinstance(needles, list) or any(
|
|
240
|
+
not isinstance(needle, str) or not needle for needle in needles
|
|
241
|
+
):
|
|
242
|
+
errors.append(f"{label}.{field} must be an array of non-empty strings")
|
|
243
|
+
continue
|
|
244
|
+
for needle in needles:
|
|
245
|
+
found = needle in haystack
|
|
246
|
+
if found != present:
|
|
247
|
+
verb = "missing" if present else "contains forbidden"
|
|
248
|
+
errors.append(f"{label} {verb} {field} token: {needle}")
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def verify_contract(contract_path: Path, project_path: Path) -> dict[str, Any]:
|
|
252
|
+
project = project_path.expanduser().resolve()
|
|
253
|
+
contract_file = contract_path.expanduser().resolve()
|
|
254
|
+
errors: list[str] = []
|
|
255
|
+
inputs: dict[str, str] = {}
|
|
256
|
+
if not project.is_dir():
|
|
257
|
+
return {"ok": False, "errors": [f"project directory does not exist: {project}"]}
|
|
258
|
+
if not contract_file.is_file():
|
|
259
|
+
return {"ok": False, "errors": [f"audio contract does not exist: {contract_file}"]}
|
|
260
|
+
if project != contract_file and project not in contract_file.parents:
|
|
261
|
+
return {"ok": False, "errors": ["audio contract must be inside the project"]}
|
|
262
|
+
try:
|
|
263
|
+
contract = json.loads(contract_file.read_text(encoding="utf-8"))
|
|
264
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
265
|
+
return {"ok": False, "errors": [f"invalid audio contract: {exc}"]}
|
|
266
|
+
if not isinstance(contract, dict):
|
|
267
|
+
return {"ok": False, "errors": ["audio contract root must be an object"]}
|
|
268
|
+
if contract.get("schema_version") != 1:
|
|
269
|
+
errors.append("schema_version must be 1")
|
|
270
|
+
evidence = contract.get("evidence")
|
|
271
|
+
if not isinstance(evidence, list) or len(evidence) < 2 or any(
|
|
272
|
+
not isinstance(item, str) or not item for item in evidence
|
|
273
|
+
):
|
|
274
|
+
errors.append("evidence must contain at least two non-empty source IDs")
|
|
275
|
+
inputs[str(contract_file.relative_to(project))] = sha256_file(contract_file)
|
|
276
|
+
check_clock(project, contract, errors, inputs)
|
|
277
|
+
check_topology(contract, errors)
|
|
278
|
+
check_assertions(project, contract, errors, inputs)
|
|
279
|
+
clock = contract.get("clock", {})
|
|
280
|
+
return {
|
|
281
|
+
"ok": not errors,
|
|
282
|
+
"summary": (
|
|
283
|
+
f"sample_rate={clock.get('sample_rate_hz')}Hz "
|
|
284
|
+
f"mclk={clock.get('mclk_hz')}Hz ratio={clock.get('mclk_ratio')}"
|
|
285
|
+
),
|
|
286
|
+
"inputs": inputs,
|
|
287
|
+
"errors": errors,
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def parse_args() -> argparse.Namespace:
|
|
292
|
+
parser = argparse.ArgumentParser(
|
|
293
|
+
description="Verify codec clocks, I2S topology, channel mapping, and adapter assertions."
|
|
294
|
+
)
|
|
295
|
+
parser.add_argument("contract", type=Path)
|
|
296
|
+
parser.add_argument("--project", type=Path, required=True)
|
|
297
|
+
parser.add_argument("--json", action="store_true")
|
|
298
|
+
parser.add_argument("--evidence-out", type=Path)
|
|
299
|
+
return parser.parse_args()
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def main() -> int:
|
|
303
|
+
args = parse_args()
|
|
304
|
+
result = verify_contract(args.contract, args.project)
|
|
305
|
+
if args.evidence_out is not None:
|
|
306
|
+
output = args.evidence_out.expanduser().resolve()
|
|
307
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
308
|
+
output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
309
|
+
if args.json:
|
|
310
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
311
|
+
elif result["ok"]:
|
|
312
|
+
print(f"PASS: audio semantic contract: {result['summary']}")
|
|
313
|
+
else:
|
|
314
|
+
for error in result["errors"]:
|
|
315
|
+
print(f"FAIL: {error}", file=sys.stderr)
|
|
316
|
+
return 0 if result["ok"] else 3
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
if __name__ == "__main__":
|
|
320
|
+
raise SystemExit(main())
|
|
@@ -11,6 +11,9 @@ import sys
|
|
|
11
11
|
from pathlib import Path
|
|
12
12
|
from typing import Any
|
|
13
13
|
|
|
14
|
+
from audio_contract import verify_contract as verify_audio_contract
|
|
15
|
+
from video_contract import verify_contract as verify_video_contract
|
|
16
|
+
|
|
14
17
|
|
|
15
18
|
FEATURES = {
|
|
16
19
|
"h5_live_audio",
|
|
@@ -18,6 +21,8 @@ FEATURES = {
|
|
|
18
21
|
"h5_talkback",
|
|
19
22
|
"ai_talk",
|
|
20
23
|
}
|
|
24
|
+
AUDIO_FEATURES = {"h5_live_audio", "h5_talkback", "ai_talk"}
|
|
25
|
+
VIDEO_FEATURES = {"h5_live_video"}
|
|
21
26
|
VERIFICATION_LEVELS = {
|
|
22
27
|
"extracted": 1,
|
|
23
28
|
"corroborated": 2,
|
|
@@ -220,6 +225,26 @@ def validate_hardware_resources(
|
|
|
220
225
|
validate_verification(
|
|
221
226
|
i2s.get("verification"), "hardware_resources.i2s.verification", errors
|
|
222
227
|
)
|
|
228
|
+
audio_contract = resources.get("audio_semantic_contract")
|
|
229
|
+
nullable_string(
|
|
230
|
+
audio_contract,
|
|
231
|
+
"hardware_resources.audio_semantic_contract",
|
|
232
|
+
errors,
|
|
233
|
+
)
|
|
234
|
+
if isinstance(audio_contract, str) and Path(audio_contract).is_absolute():
|
|
235
|
+
errors.append(
|
|
236
|
+
"hardware_resources.audio_semantic_contract must be project-relative"
|
|
237
|
+
)
|
|
238
|
+
video_contract = resources.get("video_semantic_contract")
|
|
239
|
+
nullable_string(
|
|
240
|
+
video_contract,
|
|
241
|
+
"hardware_resources.video_semantic_contract",
|
|
242
|
+
errors,
|
|
243
|
+
)
|
|
244
|
+
if isinstance(video_contract, str) and Path(video_contract).is_absolute():
|
|
245
|
+
errors.append(
|
|
246
|
+
"hardware_resources.video_semantic_contract must be project-relative"
|
|
247
|
+
)
|
|
223
248
|
|
|
224
249
|
mapping_section = mapping(
|
|
225
250
|
resources.get("audio_channel_mapping"),
|
|
@@ -396,6 +421,27 @@ def validate_runtime_evidence(
|
|
|
396
421
|
validate_source_refs(record, prefix, source_ids, errors)
|
|
397
422
|
|
|
398
423
|
|
|
424
|
+
def validate_build_evidence(data: dict[str, Any], errors: list[str]) -> None:
|
|
425
|
+
if "build_evidence" not in data:
|
|
426
|
+
return
|
|
427
|
+
evidence = mapping(data.get("build_evidence"), "build_evidence", errors)
|
|
428
|
+
artifacts = evidence.get("artifacts")
|
|
429
|
+
if not isinstance(artifacts, list):
|
|
430
|
+
errors.append("build_evidence.artifacts must be an array")
|
|
431
|
+
return
|
|
432
|
+
for index, item in enumerate(artifacts):
|
|
433
|
+
prefix = f"build_evidence.artifacts[{index}]"
|
|
434
|
+
record = mapping(item, prefix, errors)
|
|
435
|
+
path_value = record.get("path")
|
|
436
|
+
nonempty_string(path_value, f"{prefix}.path", errors)
|
|
437
|
+
if isinstance(path_value, str) and Path(path_value).is_absolute():
|
|
438
|
+
errors.append(f"{prefix}.path must be project-relative")
|
|
439
|
+
positive_int(record.get("size_bytes"), f"{prefix}.size_bytes", errors)
|
|
440
|
+
sha = record.get("sha256")
|
|
441
|
+
if not isinstance(sha, str) or not SHA256_RE.fullmatch(sha):
|
|
442
|
+
errors.append(f"{prefix}.sha256 must be a 64-character SHA-256")
|
|
443
|
+
|
|
444
|
+
|
|
399
445
|
def validate_ir(data: dict[str, Any]) -> list[str]:
|
|
400
446
|
errors: list[str] = []
|
|
401
447
|
schema_version = data.get("schema_version")
|
|
@@ -483,6 +529,7 @@ def validate_ir(data: dict[str, Any]) -> list[str]:
|
|
|
483
529
|
if schema_version == 2:
|
|
484
530
|
validate_hardware_resources(data, source_ids, errors)
|
|
485
531
|
validate_onboarding(data, source_ids, errors)
|
|
532
|
+
validate_build_evidence(data, errors)
|
|
486
533
|
validate_runtime_evidence(data, source_ids, errors)
|
|
487
534
|
return errors
|
|
488
535
|
|
|
@@ -818,6 +865,24 @@ def combine_requirements(
|
|
|
818
865
|
return {"status": status, "reasons": reasons}
|
|
819
866
|
|
|
820
867
|
|
|
868
|
+
def constrain_project_gate(
|
|
869
|
+
project_gate: dict[str, Any], feature_assessments: dict[str, dict[str, Any]]
|
|
870
|
+
) -> None:
|
|
871
|
+
"""Prevent a project-level pass while a requested feature is unresolved."""
|
|
872
|
+
feature_states = {item["status"] for item in feature_assessments.values()}
|
|
873
|
+
if "BLOCKED" in feature_states:
|
|
874
|
+
project_gate["status"] = "BLOCKED"
|
|
875
|
+
project_gate["reasons"].append("at least one requested feature is blocked")
|
|
876
|
+
elif (
|
|
877
|
+
"NEEDS_CONFIRMATION" in feature_states
|
|
878
|
+
and project_gate["status"] != "BLOCKED"
|
|
879
|
+
):
|
|
880
|
+
project_gate["status"] = "NEEDS_CONFIRMATION"
|
|
881
|
+
project_gate["reasons"].append(
|
|
882
|
+
"at least one requested feature still needs confirmation"
|
|
883
|
+
)
|
|
884
|
+
|
|
885
|
+
|
|
821
886
|
def project_requirements(data: dict[str, Any]) -> list[Requirement]:
|
|
822
887
|
requirements: list[Requirement] = []
|
|
823
888
|
revision = data["board"]["hardware_revision"].strip().lower()
|
|
@@ -887,11 +952,40 @@ def matching_runtime_evidence(
|
|
|
887
952
|
return None
|
|
888
953
|
|
|
889
954
|
|
|
890
|
-
def
|
|
955
|
+
def matching_build_artifact(
|
|
956
|
+
data: dict[str, Any], artifact_sha256: str | None
|
|
957
|
+
) -> dict[str, Any] | None:
|
|
958
|
+
if artifact_sha256 is None:
|
|
959
|
+
return None
|
|
960
|
+
normalized = artifact_sha256.lower()
|
|
961
|
+
build_evidence = data.get("build_evidence", {})
|
|
962
|
+
if not isinstance(build_evidence, dict):
|
|
963
|
+
return None
|
|
964
|
+
artifacts = build_evidence.get("artifacts", [])
|
|
965
|
+
if not isinstance(artifacts, list):
|
|
966
|
+
return None
|
|
967
|
+
for record in artifacts:
|
|
968
|
+
if (
|
|
969
|
+
isinstance(record, dict)
|
|
970
|
+
and str(record.get("sha256", "")).lower() == normalized
|
|
971
|
+
):
|
|
972
|
+
return record
|
|
973
|
+
return None
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
def artifact_requirement(
|
|
977
|
+
data: dict[str, Any], artifact_sha256: str | None
|
|
978
|
+
) -> Requirement:
|
|
891
979
|
if artifact_sha256 is None:
|
|
892
980
|
return "NEEDS_CONFIRMATION", "exact build artifact SHA-256 is missing", 0
|
|
893
981
|
if not SHA256_RE.fullmatch(artifact_sha256):
|
|
894
982
|
return "BLOCKED", "build artifact SHA-256 is invalid", 0
|
|
983
|
+
if matching_build_artifact(data, artifact_sha256) is None:
|
|
984
|
+
return (
|
|
985
|
+
"BLOCKED",
|
|
986
|
+
f"artifact {artifact_sha256} is not present in build_evidence.artifacts",
|
|
987
|
+
0,
|
|
988
|
+
)
|
|
895
989
|
return (
|
|
896
990
|
"SATISFIED",
|
|
897
991
|
f"build evidence is bound to artifact {artifact_sha256}",
|
|
@@ -903,6 +997,8 @@ def assess_ir(
|
|
|
903
997
|
data: dict[str, Any],
|
|
904
998
|
artifact_sha256: str | None = None,
|
|
905
999
|
phase: str | None = None,
|
|
1000
|
+
audio_gate: Requirement | None = None,
|
|
1001
|
+
video_gate: Requirement | None = None,
|
|
906
1002
|
) -> dict[str, Any]:
|
|
907
1003
|
selected_phase = phase or ("hil" if artifact_sha256 else "intake")
|
|
908
1004
|
if selected_phase not in ASSESSMENT_PHASES:
|
|
@@ -947,7 +1043,7 @@ def assess_ir(
|
|
|
947
1043
|
resources = {}
|
|
948
1044
|
|
|
949
1045
|
if selected_phase in {"build", "hil"}:
|
|
950
|
-
project.append(artifact_requirement(artifact_sha256))
|
|
1046
|
+
project.append(artifact_requirement(data, artifact_sha256))
|
|
951
1047
|
|
|
952
1048
|
for feature in requested:
|
|
953
1049
|
if schema_version == 1:
|
|
@@ -1010,6 +1106,24 @@ def assess_ir(
|
|
|
1010
1106
|
audio_mapping_requirement(resources, minimum_verification),
|
|
1011
1107
|
memory_requirement(resources, minimum_verification),
|
|
1012
1108
|
]
|
|
1109
|
+
if feature in AUDIO_FEATURES and selected_phase in {"build", "hil"}:
|
|
1110
|
+
requirements.append(
|
|
1111
|
+
audio_gate
|
|
1112
|
+
or (
|
|
1113
|
+
"NEEDS_CONFIRMATION",
|
|
1114
|
+
"audio semantic gate was not executed for this project",
|
|
1115
|
+
0,
|
|
1116
|
+
)
|
|
1117
|
+
)
|
|
1118
|
+
if feature in VIDEO_FEATURES and selected_phase in {"build", "hil"}:
|
|
1119
|
+
requirements.append(
|
|
1120
|
+
video_gate
|
|
1121
|
+
or (
|
|
1122
|
+
"NEEDS_CONFIRMATION",
|
|
1123
|
+
"video semantic gate was not executed for this project",
|
|
1124
|
+
0,
|
|
1125
|
+
)
|
|
1126
|
+
)
|
|
1013
1127
|
result[feature] = combine_requirements(
|
|
1014
1128
|
requirements, success_status=success_status
|
|
1015
1129
|
)
|
|
@@ -1019,6 +1133,7 @@ def assess_ir(
|
|
|
1019
1133
|
success_status=success_status,
|
|
1020
1134
|
legacy_hil_from_levels=(schema_version == 1 and selected_phase == "intake"),
|
|
1021
1135
|
)
|
|
1136
|
+
build_artifact = matching_build_artifact(data, artifact_sha256)
|
|
1022
1137
|
evidence = matching_runtime_evidence(data, artifact_sha256)
|
|
1023
1138
|
if schema_version == 2 and selected_phase == "hil":
|
|
1024
1139
|
evidence_features = set(evidence.get("features", [])) if evidence else set()
|
|
@@ -1046,6 +1161,8 @@ def assess_ir(
|
|
|
1046
1161
|
f"all requested features have matching artifact evidence for {artifact_sha256}"
|
|
1047
1162
|
)
|
|
1048
1163
|
|
|
1164
|
+
constrain_project_gate(project_gate, result)
|
|
1165
|
+
|
|
1049
1166
|
assessment: dict[str, Any] = {
|
|
1050
1167
|
"schema_version": schema_version,
|
|
1051
1168
|
"phase": selected_phase,
|
|
@@ -1059,7 +1176,8 @@ def assess_ir(
|
|
|
1059
1176
|
assessment["selected_wifi_method"] = selected_wifi
|
|
1060
1177
|
assessment["selected_binding_method"] = selected_binding
|
|
1061
1178
|
assessment["artifact_sha256"] = artifact_sha256
|
|
1062
|
-
assessment["
|
|
1179
|
+
assessment["build_artifact_evidence_matched"] = build_artifact is not None
|
|
1180
|
+
assessment["runtime_artifact_evidence_matched"] = evidence is not None
|
|
1063
1181
|
return assessment
|
|
1064
1182
|
|
|
1065
1183
|
|
|
@@ -1110,15 +1228,111 @@ def command_assess(args: argparse.Namespace) -> int:
|
|
|
1110
1228
|
):
|
|
1111
1229
|
print("error: --artifact-sha256 must be a 64-character SHA-256", file=sys.stderr)
|
|
1112
1230
|
return 2
|
|
1231
|
+
selected_phase = args.phase or (
|
|
1232
|
+
"hil" if args.artifact_sha256 is not None else "intake"
|
|
1233
|
+
)
|
|
1234
|
+
project = (args.project or args.path.parent).expanduser().resolve()
|
|
1235
|
+
audio_gate: Requirement | None = None
|
|
1236
|
+
audio_gate_result: dict[str, Any] | None = None
|
|
1237
|
+
if (
|
|
1238
|
+
data.get("schema_version") == 2
|
|
1239
|
+
and selected_phase in {"build", "hil"}
|
|
1240
|
+
and AUDIO_FEATURES.intersection(data["features"]["requested"])
|
|
1241
|
+
):
|
|
1242
|
+
relative_contract = data["hardware_resources"].get(
|
|
1243
|
+
"audio_semantic_contract"
|
|
1244
|
+
)
|
|
1245
|
+
if not isinstance(relative_contract, str) or not relative_contract:
|
|
1246
|
+
audio_gate = (
|
|
1247
|
+
"NEEDS_CONFIRMATION",
|
|
1248
|
+
"hardware_resources.audio_semantic_contract is missing",
|
|
1249
|
+
0,
|
|
1250
|
+
)
|
|
1251
|
+
audio_gate_result = {
|
|
1252
|
+
"ok": False,
|
|
1253
|
+
"errors": ["audio semantic contract is missing"],
|
|
1254
|
+
}
|
|
1255
|
+
else:
|
|
1256
|
+
contract = (project / relative_contract).resolve()
|
|
1257
|
+
if project != contract and project not in contract.parents:
|
|
1258
|
+
audio_gate_result = {
|
|
1259
|
+
"ok": False,
|
|
1260
|
+
"errors": ["audio semantic contract escapes project root"],
|
|
1261
|
+
}
|
|
1262
|
+
else:
|
|
1263
|
+
audio_gate_result = verify_audio_contract(contract, project)
|
|
1264
|
+
if audio_gate_result["ok"]:
|
|
1265
|
+
audio_gate = (
|
|
1266
|
+
"SATISFIED",
|
|
1267
|
+
"audio semantic gate passed: "
|
|
1268
|
+
+ str(audio_gate_result.get("summary", "verified")),
|
|
1269
|
+
VERIFICATION_LEVELS["build_verified"],
|
|
1270
|
+
)
|
|
1271
|
+
else:
|
|
1272
|
+
audio_gate = (
|
|
1273
|
+
"BLOCKED",
|
|
1274
|
+
"audio semantic gate failed: "
|
|
1275
|
+
+ "; ".join(audio_gate_result.get("errors", [])),
|
|
1276
|
+
0,
|
|
1277
|
+
)
|
|
1278
|
+
video_gate: Requirement | None = None
|
|
1279
|
+
video_gate_result: dict[str, Any] | None = None
|
|
1280
|
+
if (
|
|
1281
|
+
data.get("schema_version") == 2
|
|
1282
|
+
and selected_phase in {"build", "hil"}
|
|
1283
|
+
and VIDEO_FEATURES.intersection(data["features"]["requested"])
|
|
1284
|
+
):
|
|
1285
|
+
relative_contract = data["hardware_resources"].get(
|
|
1286
|
+
"video_semantic_contract"
|
|
1287
|
+
)
|
|
1288
|
+
if not isinstance(relative_contract, str) or not relative_contract:
|
|
1289
|
+
video_gate = (
|
|
1290
|
+
"NEEDS_CONFIRMATION",
|
|
1291
|
+
"hardware_resources.video_semantic_contract is missing",
|
|
1292
|
+
0,
|
|
1293
|
+
)
|
|
1294
|
+
video_gate_result = {
|
|
1295
|
+
"ok": False,
|
|
1296
|
+
"errors": ["video semantic contract is missing"],
|
|
1297
|
+
}
|
|
1298
|
+
else:
|
|
1299
|
+
contract = (project / relative_contract).resolve()
|
|
1300
|
+
if project != contract and project not in contract.parents:
|
|
1301
|
+
video_gate_result = {
|
|
1302
|
+
"ok": False,
|
|
1303
|
+
"errors": ["video semantic contract escapes project root"],
|
|
1304
|
+
}
|
|
1305
|
+
else:
|
|
1306
|
+
video_gate_result = verify_video_contract(contract, project)
|
|
1307
|
+
if video_gate_result["ok"]:
|
|
1308
|
+
video_gate = (
|
|
1309
|
+
"SATISFIED",
|
|
1310
|
+
"video semantic gate passed: "
|
|
1311
|
+
+ str(video_gate_result.get("summary", "verified")),
|
|
1312
|
+
VERIFICATION_LEVELS["build_verified"],
|
|
1313
|
+
)
|
|
1314
|
+
else:
|
|
1315
|
+
video_gate = (
|
|
1316
|
+
"BLOCKED",
|
|
1317
|
+
"video semantic gate failed: "
|
|
1318
|
+
+ "; ".join(video_gate_result.get("errors", [])),
|
|
1319
|
+
0,
|
|
1320
|
+
)
|
|
1113
1321
|
try:
|
|
1114
1322
|
assessment = assess_ir(
|
|
1115
1323
|
data,
|
|
1116
1324
|
artifact_sha256=args.artifact_sha256,
|
|
1117
|
-
phase=
|
|
1325
|
+
phase=selected_phase,
|
|
1326
|
+
audio_gate=audio_gate,
|
|
1327
|
+
video_gate=video_gate,
|
|
1118
1328
|
)
|
|
1119
1329
|
except ValueError as exc:
|
|
1120
1330
|
print(f"error: {exc}", file=sys.stderr)
|
|
1121
1331
|
return 2
|
|
1332
|
+
if audio_gate_result is not None:
|
|
1333
|
+
assessment["audio_semantic_gate"] = audio_gate_result
|
|
1334
|
+
if video_gate_result is not None:
|
|
1335
|
+
assessment["video_semantic_gate"] = video_gate_result
|
|
1122
1336
|
print(json.dumps(assessment, ensure_ascii=False, indent=2))
|
|
1123
1337
|
if args.strict:
|
|
1124
1338
|
statuses = {item["status"] for item in assessment["features"].values()}
|
|
@@ -1161,6 +1375,11 @@ def parse_args() -> argparse.Namespace:
|
|
|
1161
1375
|
"--artifact-sha256",
|
|
1162
1376
|
help="bind build or HIL assessment to this exact firmware artifact",
|
|
1163
1377
|
)
|
|
1378
|
+
assess_parser.add_argument(
|
|
1379
|
+
"--project",
|
|
1380
|
+
type=Path,
|
|
1381
|
+
help="generated project root; defaults to the Hardware IR directory",
|
|
1382
|
+
)
|
|
1164
1383
|
assess_parser.set_defaults(handler=command_assess)
|
|
1165
1384
|
return parser.parse_args()
|
|
1166
1385
|
|