tirtc-device-builder 0.6.0 → 0.7.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 +8 -0
- package/README.md +21 -31
- package/bin/esp32-kit-metadata.js +7 -6
- package/bin/install-esp32-kit.js +2 -0
- package/package.json +1 -1
- package/skills/tirtc-esp32-builder/SKILL.md +4 -4
- package/skills/tirtc-esp32-builder/USAGE.md +1 -1
- package/skills/tirtc-esp32-builder/assets/board-video-contract.example.json +2 -1
- package/skills/tirtc-esp32-builder/assets/developer-intake-prompt.md +7 -5
- package/skills/tirtc-esp32-builder/assets/hardware-ir-v2.example.json +1 -0
- package/skills/tirtc-esp32-builder/assets/lckfb-szpi-esp32s3-portable-prompt.md +67 -38
- package/skills/tirtc-esp32-builder/assets/report-template.md +5 -1
- package/skills/tirtc-esp32-builder/assets/tirtc-runtime-contract.example.json +10 -0
- package/skills/tirtc-esp32-builder/references/capability-rules.md +3 -3
- package/skills/tirtc-esp32-builder/references/hardware-ir.md +3 -1
- package/skills/tirtc-esp32-builder/references/reporting.md +2 -2
- package/skills/tirtc-esp32-builder/references/runtime-contract.md +34 -0
- package/skills/tirtc-esp32-builder/references/video-contract.md +4 -3
- package/skills/tirtc-esp32-builder/references/workflow.md +3 -1
- package/skills/tirtc-esp32-builder/scripts/doctor.py +7 -1
- package/skills/tirtc-esp32-builder/scripts/hardware_ir.py +177 -3
- package/skills/tirtc-esp32-builder/scripts/install_runtime_gate.py +66 -0
- package/skills/tirtc-esp32-builder/scripts/project_portability.py +2 -2
- package/skills/tirtc-esp32-builder/scripts/runtime_contract.py +314 -0
- package/skills/tirtc-esp32-builder/scripts/video_contract.py +74 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Verify the generated project's ThingConnect/TiRTC runtime protocol contract."""
|
|
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
|
+
CALLBACKS = (
|
|
16
|
+
"on_event",
|
|
17
|
+
"on_conn_accepted",
|
|
18
|
+
"on_conn_error",
|
|
19
|
+
"on_disconnected",
|
|
20
|
+
"on_ai_connect",
|
|
21
|
+
"on_audio",
|
|
22
|
+
"on_video",
|
|
23
|
+
"on_command",
|
|
24
|
+
"on_request_key_frame",
|
|
25
|
+
"on_subscribe_audio",
|
|
26
|
+
"on_subscribe_video",
|
|
27
|
+
"on_unsubscribe_audio",
|
|
28
|
+
"on_unsubscribe_video",
|
|
29
|
+
)
|
|
30
|
+
FORBIDDEN_CALLBACK_CALLS = (
|
|
31
|
+
"TiRtcDisconnect(",
|
|
32
|
+
"TiRtcStop(",
|
|
33
|
+
"TiRtcUninit(",
|
|
34
|
+
)
|
|
35
|
+
VIDEO_PROFILES = {
|
|
36
|
+
"mjpeg": ("TIRTC_VIDEO_JPEG", "complete_jpeg"),
|
|
37
|
+
"h264": ("TIRTC_VIDEO_H264", "annex_b_access_unit"),
|
|
38
|
+
"h265": ("TIRTC_VIDEO_H265", "annex_b_access_unit"),
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def sha256_file(path: Path) -> str:
|
|
43
|
+
digest = hashlib.sha256()
|
|
44
|
+
with path.open("rb") as stream:
|
|
45
|
+
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
|
46
|
+
digest.update(chunk)
|
|
47
|
+
return digest.hexdigest()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def project_file(project: Path, value: Any, label: str) -> Path:
|
|
51
|
+
if not isinstance(value, str) or not value.strip():
|
|
52
|
+
raise ValueError(f"{label} must be a non-empty project-relative path")
|
|
53
|
+
relative = Path(value)
|
|
54
|
+
if relative.is_absolute():
|
|
55
|
+
raise ValueError(f"{label} must be project-relative, got {value!r}")
|
|
56
|
+
resolved = (project / relative).resolve()
|
|
57
|
+
if project != resolved and project not in resolved.parents:
|
|
58
|
+
raise ValueError(f"{label} escapes project root: {value!r}")
|
|
59
|
+
return resolved
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def function_body(text: str, name: str) -> str | None:
|
|
63
|
+
pattern = re.compile(rf"\b{re.escape(name)}\s*\([^;]*?\)\s*\{{", re.DOTALL)
|
|
64
|
+
match = pattern.search(text)
|
|
65
|
+
if match is None:
|
|
66
|
+
return None
|
|
67
|
+
brace = text.find("{", match.start())
|
|
68
|
+
depth = 0
|
|
69
|
+
for index in range(brace, len(text)):
|
|
70
|
+
if text[index] == "{":
|
|
71
|
+
depth += 1
|
|
72
|
+
elif text[index] == "}":
|
|
73
|
+
depth -= 1
|
|
74
|
+
if depth == 0:
|
|
75
|
+
return text[brace : index + 1]
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def require_tokens(
|
|
80
|
+
text: str, label: str, tokens: tuple[str, ...], errors: list[str]
|
|
81
|
+
) -> None:
|
|
82
|
+
compact = re.sub(r"\s+", "", text)
|
|
83
|
+
for token in tokens:
|
|
84
|
+
if re.sub(r"\s+", "", token) not in compact:
|
|
85
|
+
errors.append(f"{label} is missing protocol token: {token}")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def check_platform_contract(platform: Any, errors: list[str]) -> None:
|
|
89
|
+
if not isinstance(platform, dict) or platform.get("schema_version") != 1:
|
|
90
|
+
errors.append("platform media contract schema_version must be 1")
|
|
91
|
+
return
|
|
92
|
+
expected_audio = {
|
|
93
|
+
("h5", "audio_up"): (10, "alaw", 8000, 1),
|
|
94
|
+
("h5", "audio_down"): (14, "alaw", 8000, 1),
|
|
95
|
+
("ai", "audio_up"): (1, "alaw", 8000, 1),
|
|
96
|
+
("ai", "audio_down"): (1, "alaw", 8000, 1),
|
|
97
|
+
}
|
|
98
|
+
for (section, direction), expected in expected_audio.items():
|
|
99
|
+
parent = platform.get(section)
|
|
100
|
+
actual = parent.get(direction) if isinstance(parent, dict) else None
|
|
101
|
+
if not isinstance(actual, dict):
|
|
102
|
+
errors.append(f"platform contract is missing {section}.{direction}")
|
|
103
|
+
continue
|
|
104
|
+
values = (
|
|
105
|
+
actual.get("stream_id"),
|
|
106
|
+
actual.get("codec"),
|
|
107
|
+
actual.get("sample_rate_hz"),
|
|
108
|
+
actual.get("channels"),
|
|
109
|
+
)
|
|
110
|
+
if values != expected or actual.get("media") != "TIRTC_AUDIO_ALAW" or actual.get(
|
|
111
|
+
"flags"
|
|
112
|
+
) != "TIRTC_AUDIOSAMPLE_8K16B1C":
|
|
113
|
+
errors.append(
|
|
114
|
+
f"platform {section}.{direction} must be stream/codec/rate/channels "
|
|
115
|
+
f"{expected} with TiRTC A-law 8k flags"
|
|
116
|
+
)
|
|
117
|
+
h5 = platform.get("h5")
|
|
118
|
+
video = h5.get("video_up") if isinstance(h5, dict) else None
|
|
119
|
+
profiles = video.get("supported_profiles") if isinstance(video, dict) else None
|
|
120
|
+
if not isinstance(video, dict) or video.get("stream_id") != 11 or not isinstance(
|
|
121
|
+
profiles, list
|
|
122
|
+
):
|
|
123
|
+
errors.append("platform contract must define H5 video stream 11 profiles")
|
|
124
|
+
else:
|
|
125
|
+
actual_profiles = {
|
|
126
|
+
item.get("codec"): (item.get("media"), item.get("send_boundary"))
|
|
127
|
+
for item in profiles
|
|
128
|
+
if isinstance(item, dict) and isinstance(item.get("codec"), str)
|
|
129
|
+
}
|
|
130
|
+
if actual_profiles != VIDEO_PROFILES:
|
|
131
|
+
errors.append(
|
|
132
|
+
"platform H5 video profiles must declare MJPEG, H.264 and H.265 "
|
|
133
|
+
"with their exact TiRTC media and send boundaries"
|
|
134
|
+
)
|
|
135
|
+
ai = platform.get("ai")
|
|
136
|
+
response = ai.get("start_session_response") if isinstance(ai, dict) else None
|
|
137
|
+
required = response.get("required_fields") if isinstance(response, dict) else None
|
|
138
|
+
if (
|
|
139
|
+
not isinstance(required, list)
|
|
140
|
+
or not {
|
|
141
|
+
"id",
|
|
142
|
+
"result.session_id",
|
|
143
|
+
"result.input_audio",
|
|
144
|
+
"result.output_audio",
|
|
145
|
+
}.issubset(set(required))
|
|
146
|
+
or response.get("response_formats_authoritative") is not True
|
|
147
|
+
):
|
|
148
|
+
errors.append(
|
|
149
|
+
"platform AI contract must make session_id and response audio formats authoritative"
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def verify_contract(contract_path: Path, project_path: Path) -> dict[str, Any]:
|
|
154
|
+
project = project_path.expanduser().resolve()
|
|
155
|
+
contract_file = contract_path.expanduser().resolve()
|
|
156
|
+
errors: list[str] = []
|
|
157
|
+
inputs: dict[str, str] = {}
|
|
158
|
+
if not project.is_dir():
|
|
159
|
+
return {"ok": False, "errors": [f"project directory does not exist: {project}"]}
|
|
160
|
+
if not contract_file.is_file():
|
|
161
|
+
return {"ok": False, "errors": [f"runtime contract does not exist: {contract_file}"]}
|
|
162
|
+
if project != contract_file and project not in contract_file.parents:
|
|
163
|
+
return {"ok": False, "errors": ["runtime contract must be inside the project"]}
|
|
164
|
+
try:
|
|
165
|
+
contract = json.loads(contract_file.read_text(encoding="utf-8"))
|
|
166
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
167
|
+
return {"ok": False, "errors": [f"invalid runtime contract: {exc}"]}
|
|
168
|
+
if not isinstance(contract, dict) or contract.get("schema_version") != 1:
|
|
169
|
+
return {"ok": False, "errors": ["runtime contract schema_version must be 1"]}
|
|
170
|
+
inputs[str(contract_file.relative_to(project))] = sha256_file(contract_file)
|
|
171
|
+
|
|
172
|
+
files = contract.get("files")
|
|
173
|
+
if not isinstance(files, dict):
|
|
174
|
+
return {"ok": False, "errors": ["runtime contract files must be an object"]}
|
|
175
|
+
contents: dict[str, str] = {}
|
|
176
|
+
for name in ("platform_client", "app_main", "starter_tirtc", "starter_runtime"):
|
|
177
|
+
try:
|
|
178
|
+
path = project_file(project, files.get(name), f"files.{name}")
|
|
179
|
+
except ValueError as exc:
|
|
180
|
+
errors.append(str(exc))
|
|
181
|
+
continue
|
|
182
|
+
if not path.is_file():
|
|
183
|
+
errors.append(f"files.{name} does not exist: {path}")
|
|
184
|
+
continue
|
|
185
|
+
contents[name] = path.read_text(encoding="utf-8", errors="replace")
|
|
186
|
+
inputs[str(path.relative_to(project))] = sha256_file(path)
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
platform_path = project_file(
|
|
190
|
+
project, contract.get("platform_contract"), "platform_contract"
|
|
191
|
+
)
|
|
192
|
+
except ValueError as exc:
|
|
193
|
+
errors.append(str(exc))
|
|
194
|
+
platform_path = None
|
|
195
|
+
if platform_path is not None:
|
|
196
|
+
if not platform_path.is_file():
|
|
197
|
+
errors.append(f"platform media contract does not exist: {platform_path}")
|
|
198
|
+
else:
|
|
199
|
+
inputs[str(platform_path.relative_to(project))] = sha256_file(platform_path)
|
|
200
|
+
try:
|
|
201
|
+
check_platform_contract(
|
|
202
|
+
json.loads(platform_path.read_text(encoding="utf-8")), errors
|
|
203
|
+
)
|
|
204
|
+
except json.JSONDecodeError as exc:
|
|
205
|
+
errors.append(f"invalid platform media contract: {exc}")
|
|
206
|
+
|
|
207
|
+
platform_source = contents.get("platform_client", "")
|
|
208
|
+
app_main = contents.get("app_main", "")
|
|
209
|
+
tirtc = contents.get("starter_tirtc", "")
|
|
210
|
+
runtime = contents.get("starter_runtime", "")
|
|
211
|
+
require_tokens(
|
|
212
|
+
platform_source,
|
|
213
|
+
"platform_client",
|
|
214
|
+
('"tirtc-srv"', "platform_client_tirtc_endpoint", "s_services.tirtc"),
|
|
215
|
+
errors,
|
|
216
|
+
)
|
|
217
|
+
require_tokens(
|
|
218
|
+
app_main,
|
|
219
|
+
"app_main",
|
|
220
|
+
("platform_client_tirtc_endpoint()", ".service_endpoint = tirtc_endpoint"),
|
|
221
|
+
errors,
|
|
222
|
+
)
|
|
223
|
+
require_tokens(
|
|
224
|
+
tirtc,
|
|
225
|
+
"starter_tirtc",
|
|
226
|
+
(
|
|
227
|
+
"TIRTC_OPT_SERVICE_ENDPOINT",
|
|
228
|
+
"config->service_endpoint",
|
|
229
|
+
".stream_id = mode == STARTER_TIRTC_H5 ? H5_AUDIO_STREAM : AI_AUDIO_STREAM",
|
|
230
|
+
".media = TIRTC_AUDIO_ALAW",
|
|
231
|
+
".flags = TIRTC_AUDIOSAMPLE_8K16B1C",
|
|
232
|
+
"frame->media != TIRTC_AUDIO_ALAW",
|
|
233
|
+
"frame->flags != TIRTC_AUDIOSAMPLE_8K16B1C",
|
|
234
|
+
"TIRTC_VIDEO_JPEG",
|
|
235
|
+
"TIRTC_VIDEO_H264",
|
|
236
|
+
"TIRTC_VIDEO_H265",
|
|
237
|
+
"defer_disconnect(connection)",
|
|
238
|
+
),
|
|
239
|
+
errors,
|
|
240
|
+
)
|
|
241
|
+
for callback in CALLBACKS:
|
|
242
|
+
body = function_body(tirtc, callback)
|
|
243
|
+
if body is None:
|
|
244
|
+
errors.append(f"starter_tirtc callback {callback} is missing")
|
|
245
|
+
continue
|
|
246
|
+
for forbidden in FORBIDDEN_CALLBACK_CALLS:
|
|
247
|
+
if forbidden in body:
|
|
248
|
+
errors.append(
|
|
249
|
+
f"starter_tirtc callback {callback} calls forbidden lifecycle API {forbidden[:-1]}"
|
|
250
|
+
)
|
|
251
|
+
worker = function_body(tirtc, "deferred_disconnect_task")
|
|
252
|
+
if worker is None or "TiRtcDisconnect(" not in worker:
|
|
253
|
+
errors.append("deferred disconnect worker must own TiRtcDisconnect")
|
|
254
|
+
|
|
255
|
+
require_tokens(
|
|
256
|
+
runtime,
|
|
257
|
+
"starter_runtime",
|
|
258
|
+
(
|
|
259
|
+
'"input_audio"',
|
|
260
|
+
'"output_audio"',
|
|
261
|
+
'"codec", "alaw"',
|
|
262
|
+
'"sample_rate", 8000',
|
|
263
|
+
'"channels", 1',
|
|
264
|
+
'"session_id"',
|
|
265
|
+
"ai_audio_format_is_alaw_8k_mono(input_audio)",
|
|
266
|
+
"ai_audio_format_is_alaw_8k_mono(output_audio)",
|
|
267
|
+
"has_result && !accepted",
|
|
268
|
+
'strcmp(method->valuestring, "end_session")',
|
|
269
|
+
),
|
|
270
|
+
errors,
|
|
271
|
+
)
|
|
272
|
+
ai_handler = function_body(runtime, "handle_ai_command")
|
|
273
|
+
if ai_handler is None or "starter_media_start(STARTER_TIRTC_AI" not in re.sub(
|
|
274
|
+
r"\s+", "", ai_handler
|
|
275
|
+
):
|
|
276
|
+
errors.append("AI media must start only from the validated command handler")
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
"ok": not errors,
|
|
280
|
+
"summary": "endpoint + callbacks + H5/AI stream and negotiation contract",
|
|
281
|
+
"inputs": inputs,
|
|
282
|
+
"errors": errors,
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def main() -> int:
|
|
287
|
+
parser = argparse.ArgumentParser(
|
|
288
|
+
description="Verify TiRTC endpoint, callbacks, stream metadata, and AI negotiation."
|
|
289
|
+
)
|
|
290
|
+
parser.add_argument("contract", type=Path)
|
|
291
|
+
parser.add_argument("--project", type=Path, required=True)
|
|
292
|
+
parser.add_argument("--json", action="store_true")
|
|
293
|
+
parser.add_argument("--evidence-out", type=Path)
|
|
294
|
+
args = parser.parse_args()
|
|
295
|
+
result = verify_contract(args.contract, args.project)
|
|
296
|
+
if args.evidence_out is not None:
|
|
297
|
+
output = args.evidence_out.expanduser().resolve()
|
|
298
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
299
|
+
output.write_text(
|
|
300
|
+
json.dumps(result, ensure_ascii=False, indent=2) + "\n",
|
|
301
|
+
encoding="utf-8",
|
|
302
|
+
)
|
|
303
|
+
if args.json:
|
|
304
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
305
|
+
elif result["ok"]:
|
|
306
|
+
print(f"PASS: TiRTC runtime contract: {result['summary']}")
|
|
307
|
+
else:
|
|
308
|
+
for error in result["errors"]:
|
|
309
|
+
print(f"FAIL: {error}", file=sys.stderr)
|
|
310
|
+
return 0 if result["ok"] else 3
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
if __name__ == "__main__":
|
|
314
|
+
raise SystemExit(main())
|
|
@@ -17,6 +17,11 @@ VIDEO_MEDIA = {
|
|
|
17
17
|
"h264": "TIRTC_VIDEO_H264",
|
|
18
18
|
"h265": "TIRTC_VIDEO_H265",
|
|
19
19
|
}
|
|
20
|
+
VIDEO_BOUNDARY = {
|
|
21
|
+
"mjpeg": "complete_jpeg",
|
|
22
|
+
"h264": "annex_b_access_unit",
|
|
23
|
+
"h265": "annex_b_access_unit",
|
|
24
|
+
}
|
|
20
25
|
|
|
21
26
|
|
|
22
27
|
def sha256_file(path: Path) -> str:
|
|
@@ -96,6 +101,74 @@ def check_dependencies(
|
|
|
96
101
|
)
|
|
97
102
|
|
|
98
103
|
|
|
104
|
+
def check_platform_contract(
|
|
105
|
+
project: Path,
|
|
106
|
+
contract: dict[str, Any],
|
|
107
|
+
errors: list[str],
|
|
108
|
+
inputs: dict[str, str],
|
|
109
|
+
) -> None:
|
|
110
|
+
try:
|
|
111
|
+
path = project_file(
|
|
112
|
+
project, contract.get("platform_contract"), "platform_contract"
|
|
113
|
+
)
|
|
114
|
+
except ValueError as exc:
|
|
115
|
+
errors.append(str(exc))
|
|
116
|
+
return
|
|
117
|
+
if not path.is_file():
|
|
118
|
+
errors.append(f"platform media contract does not exist: {path}")
|
|
119
|
+
return
|
|
120
|
+
try:
|
|
121
|
+
platform = json.loads(path.read_text(encoding="utf-8"))
|
|
122
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
123
|
+
errors.append(f"invalid platform media contract: {exc}")
|
|
124
|
+
return
|
|
125
|
+
inputs[str(path.relative_to(project))] = sha256_file(path)
|
|
126
|
+
if not isinstance(platform, dict) or platform.get("schema_version") != 1:
|
|
127
|
+
errors.append("platform media contract schema_version must be 1")
|
|
128
|
+
return
|
|
129
|
+
h5 = platform.get("h5")
|
|
130
|
+
video = h5.get("video_up") if isinstance(h5, dict) else None
|
|
131
|
+
if not isinstance(video, dict) or video.get("stream_id") != 11:
|
|
132
|
+
errors.append("platform media contract must define H5 video_up stream 11")
|
|
133
|
+
return
|
|
134
|
+
profiles = video.get("supported_profiles")
|
|
135
|
+
if not isinstance(profiles, list):
|
|
136
|
+
errors.append("platform H5 video supported_profiles must be an array")
|
|
137
|
+
return
|
|
138
|
+
supported: dict[str, dict[str, Any]] = {}
|
|
139
|
+
for index, item in enumerate(profiles):
|
|
140
|
+
if not isinstance(item, dict):
|
|
141
|
+
errors.append(
|
|
142
|
+
f"platform supported_profiles[{index}] must be an object"
|
|
143
|
+
)
|
|
144
|
+
continue
|
|
145
|
+
codec = item.get("codec")
|
|
146
|
+
if codec not in VIDEO_MEDIA:
|
|
147
|
+
errors.append(
|
|
148
|
+
f"platform supported_profiles[{index}].codec is unsupported"
|
|
149
|
+
)
|
|
150
|
+
continue
|
|
151
|
+
if codec in supported:
|
|
152
|
+
errors.append(f"platform video profile {codec} is duplicated")
|
|
153
|
+
continue
|
|
154
|
+
if item.get("media") != VIDEO_MEDIA[codec]:
|
|
155
|
+
errors.append(
|
|
156
|
+
f"platform video profile {codec} media must be {VIDEO_MEDIA[codec]}"
|
|
157
|
+
)
|
|
158
|
+
if item.get("send_boundary") != VIDEO_BOUNDARY[codec]:
|
|
159
|
+
errors.append(
|
|
160
|
+
f"platform video profile {codec} boundary must be "
|
|
161
|
+
f"{VIDEO_BOUNDARY[codec]}"
|
|
162
|
+
)
|
|
163
|
+
supported[codec] = item
|
|
164
|
+
camera = contract.get("camera")
|
|
165
|
+
selected = camera.get("codec") if isinstance(camera, dict) else None
|
|
166
|
+
if selected in VIDEO_MEDIA and selected not in supported:
|
|
167
|
+
errors.append(
|
|
168
|
+
f"board-selected codec {selected} is not supported by platform contract"
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
99
172
|
def check_scheduler(
|
|
100
173
|
project: Path,
|
|
101
174
|
contract: dict[str, Any],
|
|
@@ -288,6 +361,7 @@ def verify_contract(contract_path: Path, project_path: Path) -> dict[str, Any]:
|
|
|
288
361
|
):
|
|
289
362
|
errors.append("evidence must contain at least two non-empty source IDs")
|
|
290
363
|
check_dependencies(project, contract, errors, inputs)
|
|
364
|
+
check_platform_contract(project, contract, errors, inputs)
|
|
291
365
|
check_scheduler(project, contract, errors, inputs)
|
|
292
366
|
check_pipeline(contract, errors)
|
|
293
367
|
check_assertions(project, contract, errors, inputs)
|