tirtc-device-builder 0.7.2 → 0.7.3

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tirtc-device-builder",
3
- "version": "0.7.2",
3
+ "version": "0.7.3",
4
4
  "description": "Codex workflows for building and validating TiRTC device firmware across supported chip platforms.",
5
5
  "author": {
6
6
  "name": "TangeAI",
package/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  This project follows Semantic Versioning.
4
4
 
5
+ ## 0.7.3
6
+
7
+ - Make source export reject missing or Git-ignored Hardware IR, requested-feature
8
+ contracts, dependency locks, custom partition tables, and retained build
9
+ artifacts.
10
+ - Support frame-compatible paired standard/TDM full-duplex audio and validate
11
+ hardware-reference AEC microphone/reference slot mapping.
12
+ - Document ESP32-S3 FPU task affinity, watchdog fairness, software-encoder task
13
+ isolation, and PSRAM DMA staging as distinct bring-up risks.
14
+
5
15
  ## 0.7.2
6
16
 
7
17
  - Require the selected managed ESP32 Device Kit manifest to match the pinned Kit version; ignore stale managed references instead of reporting an older structurally complete Kit as ready.
package/README.md CHANGED
@@ -881,8 +881,8 @@ metadata 中的版本、标签、上游 commit 和期望 SHA-256 必须与本地
881
881
 
882
882
  ```bash
883
883
  npm test
884
- git tag -a v0.7.2 -m "v0.7.2"
885
- git push origin v0.7.2
884
+ git tag -a v0.7.3 -m "v0.7.3"
885
+ git push origin v0.7.3
886
886
  ```
887
887
 
888
888
  不要重复发布已经存在的 npm 版本。版本变化同步更新 `package.json`、`.codex-plugin/plugin.json` 和发布说明。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tirtc-device-builder",
3
- "version": "0.7.2",
3
+ "version": "0.7.3",
4
4
  "description": "Install and run TiRTC device-development skills for Codex.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -40,4 +40,4 @@ For HIL assessment, run `hardware_ir.py assess --phase hil --artifact-sha256 <sh
40
40
 
41
41
  ## Finish
42
42
 
43
- After the final assessment, remove the machine-bound `build/` tree without rebuilding, then run `project_portability.py <project> --export` against the source plus the project-relative `artifacts/` copies. Return the generated project path, Hardware IR, capability assessment, build artifacts, flash record when applicable, and `TIRTC_PORTING_REPORT.md`. The task is complete only when every requested feature is either verified at the requested level or named as a blocker with the smallest next action that can resolve it.
43
+ After the final assessment, remove the machine-bound `build/` tree without rebuilding, then run `project_portability.py <project> --export` against the source plus the project-relative `artifacts/` copies. Resolve every missing or Git-ignored required input reported by that check; local intermediate artifacts may remain ignored, but retained build evidence must survive export. Return the generated project path, Hardware IR, capability assessment, build artifacts, flash record when applicable, and `TIRTC_PORTING_REPORT.md`. The task is complete only when every requested feature is either verified at the requested level or named as a blocker with the smallest next action that can resolve it.
@@ -1 +1 @@
1
- 0.7.2
1
+ 0.7.3
@@ -40,6 +40,7 @@
40
40
  "shared_clock": {
41
41
  "gpios": [38, 14, 13],
42
42
  "directions_simultaneous": false,
43
+ "paired_channels": false,
43
44
  "handoff": "release_before_claim"
44
45
  },
45
46
  "implementation_assertions": [
@@ -7,7 +7,7 @@ $tirtc-esp32-builder
7
7
  前置条件(必须由开发者在启动本次 Codex 会话前完成,不属于本提示词内的操作):
8
8
 
9
9
  ```bash
10
- npx --yes tirtc-device-builder@0.7.2 setup esp32 --install --force-skill
10
+ npx --yes tirtc-device-builder@0.7.3 setup esp32 --install --force-skill
11
11
  ```
12
12
 
13
13
  该命令只允许在当前用户目录安装固定版本的 Skill、managed ESP32 Device Kit、ESP-IDF 和工具链;禁止 sudo、系统级包变更和修改 shell profile。安装完成后,开发者必须关闭原 Codex 会话,再从本 clean-room 工作区启动一个新会话,然后粘贴本提示词。
@@ -15,11 +15,11 @@ npx --yes tirtc-device-builder@0.7.2 setup esp32 --install --force-skill
15
15
  本轮第一步先只读运行:
16
16
 
17
17
  ```bash
18
- npx --yes tirtc-device-builder@0.7.2 --version
19
- npx --yes tirtc-device-builder@0.7.2 setup esp32
18
+ npx --yes tirtc-device-builder@0.7.3 --version
19
+ npx --yes tirtc-device-builder@0.7.3 setup esp32
20
20
  ```
21
21
 
22
- 必须根据命令的实际输出和本机文件确认:npm 包为 0.7.2、已安装 Skill 的 `VERSION` 为 0.7.2、所选 Device Kit 的 `manifest.json` 中 `kit_version` 为 1.1.1,并且 Doctor 对 `--expected-kit 1.1.1` 输出 `OVERALL: PASS`。Plugin manifest 不属于这种 npm 安装方式的运行时前置条件,不得把不可访问的 Plugin 版本当作阻塞项。如果版本不一致、Skill 是在当前会话启动后才安装,或环境检查未通过,停止并报告前置条件不成立;不要在当前会话中替换 Skill 后继续生成工程。
22
+ 必须根据命令的实际输出和本机文件确认:npm 包为 0.7.3、已安装 Skill 的 `VERSION` 为 0.7.3、所选 Device Kit 的 `manifest.json` 中 `kit_version` 为 1.1.1,并且 Doctor 对 `--expected-kit 1.1.1` 输出 `OVERALL: PASS`。Plugin manifest 不属于这种 npm 安装方式的运行时前置条件,不得把不可访问的 Plugin 版本当作阻塞项。如果版本不一致、Skill 是在当前会话启动后才安装,或环境检查未通过,停止并报告前置条件不成立;不要在当前会话中替换 Skill 后继续生成工程。
23
23
 
24
24
  工作区与 clean-room 边界:
25
25
  - 将启动 Codex 时的当前目录定义为 `WORKSPACE_ROOT`。
@@ -10,12 +10,32 @@ The contract must describe:
10
10
 
11
11
  - the PCM sample rate, MCLK ratio, resulting MCLK, and every clocked codec driver table;
12
12
  - capture/playback controller, role, and standard/TDM/DSP/PCM mode;
13
+ - per-direction slot count and slot bit width when simultaneous directions use
14
+ different framing modes;
13
15
  - TDM enable, slot count/order, physical signal at each slot, selected slot, and mapping evidence when TDM is used;
14
16
  - shared clock GPIOs, whether directions are simultaneous, and the release/recreate handoff;
17
+ - paired-channel ownership and equal BCLKs per frame when one controller runs
18
+ simultaneous standard TX and TDM RX;
19
+ - when AEC is selected, the hardware sample rate plus distinct microphone and
20
+ playback-reference slots/signals;
15
21
  - source assertions tying the normalized contract to the actual adapter implementation.
16
22
 
17
23
  A generic header comment such as “typically 256” is not coefficient evidence. After `idf.py reconfigure` resolves managed components, the selected `(MCLK, sample rate)` pair must exist in every locked codec table named by the contract.
18
24
 
25
+ Simultaneous directions do not have to use the same ESP-IDF mode name. A valid
26
+ mixed-mode topology must set `shared_clock.paired_channels=true`, use the same
27
+ controller, and provide `slot_count` plus `slot_bit_width` for both directions;
28
+ their products must match. For example, four 16-bit TDM RX slots and two 32-bit
29
+ standard TX slots both consume 64 BCLKs per frame. This exception does not allow
30
+ two independent masters to drive shared clock GPIOs.
31
+
32
+ For hardware-reference AEC, add `echo_cancellation` with `enabled`,
33
+ `sample_rate_hz`, `microphone_slot`/`microphone_signal`, and
34
+ `reference_slot`/`reference_signal`. The gate verifies that AEC runs while both
35
+ directions are active, uses the hardware clock rate, and maps two distinct TDM
36
+ signals. Implementation assertions must still bind the contract to the paired
37
+ channel setup, AEC library configuration, and exact slot extraction code.
38
+
19
39
  ## Mandatory gate
20
40
 
21
41
  Run the gate before claiming an audio-capable build:
@@ -44,8 +64,10 @@ Audio reaches `BUILD_VERIFIED` only when all of these are true:
44
64
  1. the contract has at least two authoritative source IDs;
45
65
  2. its arithmetic and codec table lookups pass;
46
66
  3. its topology, TDM mapping, and handoff are internally consistent;
47
- 4. its adapter assertions pass;
48
- 5. the gate is part of the ordinary build;
49
- 6. the exact BIN/ELF is hashed after that build.
67
+ 4. simultaneous mixed framing has equal BCLKs per frame and any AEC reference is
68
+ mapped to a distinct evidenced slot;
69
+ 5. its adapter assertions pass;
70
+ 6. the gate is part of the ordinary build;
71
+ 7. the exact BIN/ELF is hashed after that build.
50
72
 
51
73
  Compilation without this gate is `COMPILE_PASS`, not audio `BUILD_VERIFIED`.
@@ -58,6 +58,13 @@ python3 <skill-dir>/scripts/project_portability.py <generated-project> --export
58
58
 
59
59
  Copy source inputs only. Never export `build/`: CMake caches absolute source, toolchain, and Python paths from the originating machine. `managed_components/` may be regenerated from the committed `dependencies.lock`; the bundled `third_party/tirtc` SDK and its build contract must remain in the source package. CMake must invoke shell gates through `bash <script>` so the build does not depend on archive- or filesystem-specific executable bits.
60
60
 
61
+ The export check also requires Hardware IR, every requested-feature semantic
62
+ contract, `sdkconfig.defaults`, a referenced custom partition table, and each
63
+ artifact retained in `build_evidence.artifacts[]`. Inside a Git worktree these
64
+ inputs must not be untracked files hidden by `.gitignore`. Ignore local build
65
+ trees and intermediate firmware snapshots, then explicitly include the exact
66
+ validated release bundle used by retained evidence.
67
+
61
68
  ## Required checks
62
69
 
63
70
  - `python3`, `git`, `idf.py`, and the target compiler are available in the active shell;
@@ -10,6 +10,11 @@ python3 <skill-dir>/scripts/hardware_ir.py init <output>/hardware-ir.json
10
10
 
11
11
  The initializer creates schema v2. The validator still accepts schema v1 for existing H.264-only projects; update new or materially changed boards to v2.
12
12
 
13
+ Schema v1 cannot represent an MJPEG/H.265 selection, semantic-contract paths,
14
+ or artifact-bound v2 HIL. If an existing v1 project requests H5 video without
15
+ an available H.264 path, migrate it to v2 rather than interpreting the legacy
16
+ H.264 assessment failure as a board or platform codec failure.
17
+
13
18
  ## Evidence rules
14
19
 
15
20
  - Give every source a stable `id`, `kind`, `location`, and revision when available.
@@ -13,6 +13,17 @@ Record the selected video profile, audio formats and stream IDs, duplex/AEC poli
13
13
  - I2S/audio: record controller, GPIO, master/slave mode, clocks, DMA, channel/TDM slot to physical-signal mapping, and shared-signal handoff. A codec name or I2C address does not prove the audio path.
14
14
  - Realtime camera path: record DMA/event queues, task core/priority, and competing Wi-Fi work. Treat queue overflows as scheduling or throughput evidence, then change one variable per HIL comparison.
15
15
 
16
+ Distinguish the camera driver's event task from an adapter-owned software
17
+ JPEG/H.26x task. Core isolation of the driver does not prove that a CPU-bound
18
+ encoder is isolated. On ESP32-S3, code using floating point can cause an
19
+ unpinned task to become pinned to the first core where it uses the FPU; AEC and
20
+ other DSP tasks therefore need intentional affinity when they would otherwise
21
+ land on the Wi-Fi core. A DMA-fed loop or software encoder that can remain
22
+ continuously runnable must block on a queue/semaphore or yield at a bounded
23
+ frame boundary. Keep the idle-task watchdog enabled, and expose maximum
24
+ processing time plus deadline misses instead of hiding starvation by widening
25
+ or disabling the watchdog.
26
+
16
27
  Turn every discovered invariant that can regress into a focused test or post-link gate. Generate board-specific values with the project; keep the Skill generic.
17
28
 
18
29
  ## Wi-Fi credentials and device binding
@@ -38,6 +49,13 @@ Platform service discovery and the TiRTC SDK service endpoint are different sett
38
49
 
39
50
  Define a conservative static budget before implementation using the locked SDK contract, framebuffer geometry, DMA/queue bounds, task stacks, and an internal-memory reserve. Before claiming runtime margin or tuning TiRTC buffers, measure internal free/largest blocks, PSRAM, frame size distribution, queue watermarks, and send/drop rates on the exact artifact. A larger queue can prevent drops, exhaust startup memory, or add buffer latency. If an authorized HTTP baseline exists, stage transport changes separately from media changes and retain the HTTPS requirements as a pending acceptance item.
40
51
 
52
+ Treat PSRAM framebuffer placement, direct peripheral DMA into PSRAM, and an
53
+ internal-DMA staging buffer as three separate facts. PSRAM capacity does not
54
+ prove DMA compatibility or frame integrity for a sensor/pixel format. Promote
55
+ direct PSRAM DMA only after artifact-bound HIL checks image boundaries and line
56
+ integrity; otherwise retain the evidenced staging path and budget its internal
57
+ DMA reserve explicitly.
58
+
41
59
  ## Network and media evidence
42
60
 
43
61
  Disable Wi-Fi power saving for the realtime baseline unless the product contract says otherwise. Record BSSID, channel, RSSI, reconnect/roaming events, media counters, queue watermarks, camera overflows, internal heap, and largest block. Establish a strong, stable AP baseline before controlled weak-network testing.
@@ -47,3 +65,10 @@ Confirm SDK send return semantics and callback payload lifetimes from the select
47
65
  ## Artifact discipline
48
66
 
49
67
  Record BIN/ELF SHA-256 for every build used in HIL. Runtime evidence applies only to the exact artifact. Diagnose one failing invariant, make the smallest correction, rerun that layer, and preserve the comparison in the report.
68
+
69
+ Local intermediate snapshots may be ignored, but the Hardware IR, semantic
70
+ contracts, `dependencies.lock`, custom partition input, and the artifact named
71
+ by retained build evidence must survive the chosen export mechanism. Broad
72
+ `*.json`, `*.csv`, or `artifacts/` ignore rules are invalid when they hide an
73
+ untracked required input; `project_portability.py --export` checks this when the
74
+ project is inside a Git worktree.
@@ -192,18 +192,114 @@ def check_topology(contract: dict[str, Any], errors: list[str]) -> None:
192
192
  )
193
193
  if simultaneous is False and handoff == "none":
194
194
  errors.append("half-duplex shared clocks require an explicit handoff")
195
+ if simultaneous is True and handoff != "none":
196
+ errors.append("simultaneous directions must not use a clock handoff")
197
+ paired_channels = shared.get("paired_channels", False)
198
+ if not isinstance(paired_channels, bool):
199
+ errors.append("shared_clock.paired_channels must be true or false")
200
+ paired_channels = False
201
+
202
+ mixed_paired_ok = False
195
203
  if simultaneous is True and capture.get("mode") != playback.get("mode"):
196
- errors.append("simultaneous directions cannot use different I2S modes")
204
+ if paired_channels is not True:
205
+ errors.append(
206
+ "simultaneous mixed I2S modes require shared_clock.paired_channels=true"
207
+ )
208
+ if capture.get("controller") != playback.get("controller"):
209
+ errors.append(
210
+ "simultaneous mixed I2S modes require paired directions on one controller"
211
+ )
212
+ frame_bits: dict[str, int] = {}
213
+ for endpoint, label in ((capture, "capture"), (playback, "playback")):
214
+ slots = require_positive_int(
215
+ endpoint.get("slot_count"), f"{label}.slot_count", errors
216
+ )
217
+ width = require_positive_int(
218
+ endpoint.get("slot_bit_width"),
219
+ f"{label}.slot_bit_width",
220
+ errors,
221
+ )
222
+ if slots is not None and width is not None:
223
+ frame_bits[label] = slots * width
224
+ if len(frame_bits) == 2 and frame_bits["capture"] != frame_bits["playback"]:
225
+ errors.append(
226
+ "simultaneous mixed I2S modes require equal BCLKs per frame: "
227
+ f"capture={frame_bits['capture']} playback={frame_bits['playback']}"
228
+ )
229
+ mixed_paired_ok = (
230
+ paired_channels is True
231
+ and capture.get("controller") == playback.get("controller")
232
+ and len(frame_bits) == 2
233
+ and frame_bits["capture"] == frame_bits["playback"]
234
+ )
197
235
  if (
198
236
  capture.get("controller") == playback.get("controller")
199
237
  and capture.get("mode") != playback.get("mode")
238
+ and not mixed_paired_ok
200
239
  and handoff != "delete_recreate"
201
240
  ):
202
241
  errors.append(
203
242
  "one I2S controller cannot retain different capture/playback modes; "
204
- "use distinct controllers or delete_recreate handoff"
243
+ "use paired frame-compatible directions, distinct controllers, or "
244
+ "delete_recreate handoff"
205
245
  )
206
246
 
247
+ aec_value = contract.get("echo_cancellation")
248
+ if aec_value is None:
249
+ return
250
+ aec = require_mapping(aec_value, "echo_cancellation", errors)
251
+ enabled = aec.get("enabled")
252
+ if not isinstance(enabled, bool):
253
+ errors.append("echo_cancellation.enabled must be true or false")
254
+ return
255
+ if not enabled:
256
+ return
257
+ if simultaneous is not True:
258
+ errors.append("echo cancellation requires simultaneous capture and playback")
259
+ if capture_mode != "tdm":
260
+ errors.append("echo cancellation reference mapping requires TDM capture")
261
+ sample_rate = require_positive_int(
262
+ aec.get("sample_rate_hz"), "echo_cancellation.sample_rate_hz", errors
263
+ )
264
+ clock = contract.get("clock", {})
265
+ if sample_rate is not None and sample_rate != clock.get("sample_rate_hz"):
266
+ errors.append("echo cancellation sample rate must match the hardware clock")
267
+
268
+ microphone_slot = aec.get("microphone_slot")
269
+ reference_slot = aec.get("reference_slot")
270
+ microphone_signal = require_nonempty_string(
271
+ aec.get("microphone_signal"), "echo_cancellation.microphone_signal", errors
272
+ )
273
+ reference_signal = require_nonempty_string(
274
+ aec.get("reference_signal"), "echo_cancellation.reference_signal", errors
275
+ )
276
+ slot_signals = capture.get("slot_signals")
277
+ slot_count = capture.get("slot_count")
278
+ for slot, signal, label in (
279
+ (microphone_slot, microphone_signal, "microphone"),
280
+ (reference_slot, reference_signal, "reference"),
281
+ ):
282
+ if (
283
+ isinstance(slot, bool)
284
+ or not isinstance(slot, int)
285
+ or slot < 0
286
+ or not isinstance(slot_count, int)
287
+ or slot >= slot_count
288
+ ):
289
+ errors.append(
290
+ f"echo_cancellation.{label}_slot must select an available TDM slot"
291
+ )
292
+ elif isinstance(slot_signals, list) and slot < len(slot_signals):
293
+ if signal != slot_signals[slot]:
294
+ errors.append(
295
+ f"echo_cancellation.{label}_signal does not match "
296
+ f"capture.slot_signals at slot {slot}"
297
+ )
298
+ if microphone_slot == reference_slot:
299
+ errors.append("echo cancellation microphone and reference slots must differ")
300
+ if microphone_signal is not None and microphone_signal == reference_signal:
301
+ errors.append("echo cancellation microphone and reference signals must differ")
302
+
207
303
 
208
304
  def check_assertions(
209
305
  project: Path,
@@ -277,11 +373,15 @@ def verify_contract(contract_path: Path, project_path: Path) -> dict[str, Any]:
277
373
  check_topology(contract, errors)
278
374
  check_assertions(project, contract, errors, inputs)
279
375
  clock = contract.get("clock", {})
376
+ shared = contract.get("shared_clock", {})
377
+ aec = contract.get("echo_cancellation", {})
280
378
  return {
281
379
  "ok": not errors,
282
380
  "summary": (
283
381
  f"sample_rate={clock.get('sample_rate_hz')}Hz "
284
- f"mclk={clock.get('mclk_hz')}Hz ratio={clock.get('mclk_ratio')}"
382
+ f"mclk={clock.get('mclk_hz')}Hz ratio={clock.get('mclk_ratio')} "
383
+ f"simultaneous={shared.get('directions_simultaneous')} "
384
+ f"aec={aec.get('enabled', False)}"
285
385
  ),
286
386
  "inputs": inputs,
287
387
  "errors": errors,
@@ -6,6 +6,7 @@ from __future__ import annotations
6
6
  import argparse
7
7
  import json
8
8
  import re
9
+ import subprocess
9
10
  import sys
10
11
  from pathlib import Path
11
12
  from typing import Any
@@ -13,8 +14,30 @@ from typing import Any
13
14
 
14
15
  ABSOLUTE_PATH = re.compile(r"(?:^|[\s\"'])(?:/home/|/root/|[A-Za-z]:[\\/])")
15
16
  BUILD_INPUT_NAMES = {"CMakeLists.txt", "idf_component.yml", "idf_component.yaml"}
16
- BUILD_INPUT_SUFFIXES = {".c", ".h", ".cmake", ".py", ".sh"}
17
+ BUILD_INPUT_SUFFIXES = {
18
+ ".c",
19
+ ".cmake",
20
+ ".csv",
21
+ ".defaults",
22
+ ".env",
23
+ ".h",
24
+ ".json",
25
+ ".lock",
26
+ ".py",
27
+ ".sh",
28
+ ".txt",
29
+ ".yaml",
30
+ ".yml",
31
+ }
17
32
  SKIP_PARTS = {"build", "managed_components", ".git"}
33
+ REQUIRED_PROJECT_INPUTS = {
34
+ "dependencies.lock",
35
+ "hardware-ir.json",
36
+ "sdkconfig.defaults",
37
+ }
38
+ PARTITION_FILE_RE = re.compile(
39
+ r'^CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="([^"]+)"$', re.MULTILINE
40
+ )
18
41
 
19
42
 
20
43
  def source_files(project: Path):
@@ -26,13 +49,156 @@ def source_files(project: Path):
26
49
  yield path
27
50
 
28
51
 
52
+ def project_file(
53
+ project: Path, value: Any, label: str, errors: list[str]
54
+ ) -> Path | None:
55
+ if not isinstance(value, str) or not value.strip():
56
+ errors.append(f"{label} must be a non-empty project-relative path")
57
+ return None
58
+ relative = Path(value)
59
+ if relative.is_absolute():
60
+ errors.append(f"{label} must be project-relative, got {value!r}")
61
+ return None
62
+ resolved = (project / relative).resolve()
63
+ if project != resolved and project not in resolved.parents:
64
+ errors.append(f"{label} escapes project root: {value!r}")
65
+ return None
66
+ return resolved
67
+
68
+
69
+ def load_hardware_ir(project: Path, errors: list[str]) -> dict[str, Any] | None:
70
+ path = project / "hardware-ir.json"
71
+ if not path.is_file():
72
+ return None
73
+ try:
74
+ data = json.loads(path.read_text(encoding="utf-8"))
75
+ except (OSError, json.JSONDecodeError) as exc:
76
+ errors.append(f"hardware-ir.json is invalid: {exc}")
77
+ return None
78
+ if not isinstance(data, dict):
79
+ errors.append("hardware-ir.json root must be an object")
80
+ return None
81
+ return data
82
+
83
+
84
+ def required_project_files(
85
+ project: Path, export: bool, errors: list[str]
86
+ ) -> set[Path]:
87
+ required = {(project / relative).resolve() for relative in REQUIRED_PROJECT_INPUTS}
88
+ ir = load_hardware_ir(project, errors)
89
+ if ir is not None:
90
+ schema_version = ir.get("schema_version")
91
+ requested = ir.get("features", {}).get("requested", [])
92
+ if schema_version == 2:
93
+ resources = ir.get("hardware_resources")
94
+ if not isinstance(resources, dict):
95
+ errors.append("hardware-ir.json hardware_resources must be an object")
96
+ else:
97
+ contract_fields = ["runtime_semantic_contract"]
98
+ if {"h5_live_audio", "h5_talkback", "ai_talk"}.intersection(
99
+ requested
100
+ ):
101
+ contract_fields.append("audio_semantic_contract")
102
+ if "h5_live_video" in requested:
103
+ contract_fields.append("video_semantic_contract")
104
+ for field in contract_fields:
105
+ path = project_file(
106
+ project,
107
+ resources.get(field),
108
+ f"hardware_resources.{field}",
109
+ errors,
110
+ )
111
+ if path is not None:
112
+ required.add(path)
113
+ elif (
114
+ schema_version == 1
115
+ and "h5_live_video" in requested
116
+ and ir.get("camera", {}).get("h264", {}).get("available") is not True
117
+ ):
118
+ errors.append(
119
+ "schema v1 can only export the legacy H.264 video contract; "
120
+ "migrate Hardware IR to schema v2 for MJPEG or H.265"
121
+ )
122
+
123
+ if export:
124
+ artifacts = ir.get("build_evidence", {}).get("artifacts")
125
+ if not isinstance(artifacts, list) or not artifacts:
126
+ errors.append(
127
+ "hardware-ir.json build_evidence.artifacts must retain at least "
128
+ "one verified deliverable for export"
129
+ )
130
+ else:
131
+ for index, record in enumerate(artifacts):
132
+ if not isinstance(record, dict):
133
+ errors.append(
134
+ f"build_evidence.artifacts[{index}] must be an object"
135
+ )
136
+ continue
137
+ path = project_file(
138
+ project,
139
+ record.get("path"),
140
+ f"build_evidence.artifacts[{index}].path",
141
+ errors,
142
+ )
143
+ if path is not None:
144
+ required.add(path)
145
+
146
+ for config_name in ("sdkconfig.defaults", "sdkconfig"):
147
+ config = project / config_name
148
+ if not config.is_file():
149
+ continue
150
+ text = config.read_text(encoding="utf-8", errors="replace")
151
+ match = PARTITION_FILE_RE.search(text)
152
+ if match:
153
+ path = project_file(
154
+ project,
155
+ match.group(1),
156
+ f"{config_name} custom partition table",
157
+ errors,
158
+ )
159
+ if path is not None:
160
+ required.add(path)
161
+ return required
162
+
163
+
164
+ def git_root(project: Path) -> Path | None:
165
+ result = subprocess.run(
166
+ ["git", "-C", str(project), "rev-parse", "--show-toplevel"],
167
+ stdout=subprocess.PIPE,
168
+ stderr=subprocess.DEVNULL,
169
+ text=True,
170
+ check=False,
171
+ )
172
+ if result.returncode != 0:
173
+ return None
174
+ return Path(result.stdout.strip()).resolve()
175
+
176
+
177
+ def git_ignored(root: Path, path: Path) -> bool:
178
+ try:
179
+ relative = path.relative_to(root)
180
+ except ValueError:
181
+ return False
182
+ result = subprocess.run(
183
+ ["git", "-C", str(root), "check-ignore", "--quiet", "--", relative.as_posix()],
184
+ stdout=subprocess.DEVNULL,
185
+ stderr=subprocess.DEVNULL,
186
+ check=False,
187
+ )
188
+ return result.returncode == 0
189
+
190
+
29
191
  def check_project(project_path: Path, export: bool = False) -> dict[str, Any]:
30
192
  project = project_path.expanduser().resolve()
31
193
  errors: list[str] = []
32
194
  if not (project / "CMakeLists.txt").is_file():
33
195
  return {"ok": False, "errors": ["CMakeLists.txt is missing"]}
34
- if not (project / "dependencies.lock").is_file():
35
- errors.append("dependencies.lock is missing")
196
+ required = required_project_files(project, export, errors)
197
+ for path in sorted(required):
198
+ if not path.is_file():
199
+ errors.append(
200
+ f"required portable input is missing: {path.relative_to(project)}"
201
+ )
36
202
  sdk_required = (
37
203
  project / "third_party" / "tirtc" / "include" / "tirtc" / "tiRTC.h",
38
204
  project / "third_party" / "tirtc" / "lib" / "libTiRTC.a",
@@ -43,6 +209,15 @@ def check_project(project_path: Path, export: bool = False) -> dict[str, Any]:
43
209
  errors.append("bundled TiRTC SDK is incomplete: " + ", ".join(missing_sdk))
44
210
  if export and (project / "build").exists():
45
211
  errors.append("export source contains a machine-bound build/ directory")
212
+ if export:
213
+ root = git_root(project)
214
+ if root is not None:
215
+ for path in sorted(required):
216
+ if path.is_file() and git_ignored(root, path):
217
+ errors.append(
218
+ "required portable input is ignored by Git: "
219
+ f"{path.relative_to(project)}"
220
+ )
46
221
 
47
222
  for path in source_files(project):
48
223
  relative = path.relative_to(project)