4"""Gate the offline-checkable ESP32-C6 staging and link contract.
6The pinned ESP-hosted patch can apply cleanly while the eventual image is still
7wrong. A renamed first-party header may make ``build.sh`` stage a nonexistent
8path, the patched explicit component set may omit the directory we stage, or a
9component ABI marker may become private while the post-link assertion retains
10its old name. Those failures otherwise appear only in a full ESP-IDF build.
12This gate checks the committed recipe without ESP-IDF, a network, or hardware:
13every staged source exists, copies retain their basename, component identities
14agree, and the patch/header/source/post-link symbol contracts form one chain.
15A full pinned ESP-IDF build remains the end-to-end proof.
19 check_c6_integration.py
20 check_c6_integration.py --selftest
23from __future__
import annotations
27from pathlib
import Path
29REPO_ROOT = Path(__file__).resolve().parents[2]
30C6_DIR = REPO_ROOT /
"coprocessor" /
"esp32c6"
31BUILD_SCRIPT = C6_DIR /
"build.sh"
32PATCH_FILE = C6_DIR /
"patches" /
"0001-custom-rpc-sync-response-hook.patch"
33SERVICE_HEADER = REPO_ROOT /
"port" /
"esp32_c6" /
"inc" /
"ra8_mdl_service.h"
34SERVICE_SOURCE = REPO_ROOT /
"port" /
"esp32_c6" /
"src" /
"mdl_service.c"
40COMPONENT_ABI =
"ra8_mdl_service_component_abi"
41CUSTOM_RPC_HOOK =
"esp_hosted_custom_rpc_sync_handler"
43_STAGED_COPY_RE = re.compile(
44 r'cp\s+"\$\{SCRIPT_DIR\}/\.\./\.\./(?P<src>[^"]+)"\s*'
45 r'(?:\\\s*)?"\$\{COMPONENT_DIR\}/(?P<dest>[^"]+)"',
48_COMPONENT_DIR_RE = re.compile(
49 r'^[ \t]*COMPONENT_DIR="\$\{PERIPHERAL_DIR\}/components/(?P<name>[^"/]+)"$',
52_PATCH_COMPONENTS_RE = re.compile(
r"^\+set\(COMPONENTS (?P<names>[^)]+)\)$", re.MULTILINE)
56REQUIRED_STAGED_SOURCES: frozenset[str] = frozenset(
58 "port/esp32_c6/CMakeLists.txt",
59 "port/esp32_c6/src/mdl_service.c",
60 "port/esp32_c6/inc/ra8_mdl_service.h",
61 "libs/ra8_c6link/inc/ra8_mdl_protocol.h",
62 "libs/ra8_c6link/inc/ra8_mdl_http.h",
67def parse_staged_copies(build_text: str) -> list[tuple[str, str]]:
68 """Return repository source and component destination for each staged copy."""
69 return [match.group(
"src",
"dest")
for match
in _STAGED_COPY_RE.finditer(build_text)]
72def _check_staged_copies(build_text: str, available_sources: frozenset[str] |
None) -> list[str]:
73 """Return findings for missing, renamed, or nonexistent staged files."""
74 findings: list[str] = []
75 copies = parse_staged_copies(build_text)
77 findings.append(
"staging: build.sh contains no first-party component copies")
79 staged_sources = {source
for source, _destination
in copies}
81 f
"staging: build.sh no longer stages required source {required}"
82 for required
in sorted(REQUIRED_STAGED_SOURCES - staged_sources)
84 for source, destination
in copies:
86 source
in available_sources
87 if available_sources
is not None
88 else (REPO_ROOT / source).is_file()
91 findings.append(f
"staging: source does not exist: {source}")
92 if Path(source).name != Path(destination).name:
94 f
"staging: {source} is renamed to {destination}; copied component "
95 "files must retain their source basename"
100def _check_component(build_text: str, patch_text: str) -> list[str]:
101 """Return findings when staged and explicitly enabled component names differ."""
102 findings: list[str] = []
103 component_match = _COMPONENT_DIR_RE.search(build_text)
104 patch_matches = list(_PATCH_COMPONENTS_RE.finditer(patch_text))
105 if component_match
is None:
106 findings.append(
"component: build.sh does not declare a literal staged component name")
107 if len(patch_matches) != 1:
109 "component: patch must add exactly one explicit set(COMPONENTS ...) declaration"
111 if component_match
is not None and len(patch_matches) == 1:
112 component = component_match.group(
"name")
113 if component
not in patch_matches[0].group(
"names").split():
115 f
"component: build.sh stages {component!r}, but the patch's explicit "
116 "COMPONENTS set does not include it"
122 build_text: str, patch_text: str, header_text: str, source_text: str
124 """Return findings for public, weak, strong, and post-link symbols."""
125 findings: list[str] = []
126 abi_decl = re.compile(rf
"^\s*uint32_t\s+{COMPONENT_ABI}\s*\(\s*void\s*\)\s*;", re.MULTILINE)
127 abi_definition = re.compile(
128 rf
"^\s*(?:\[\[[^\n]+\]\]\s*)?uint32_t\s+{COMPONENT_ABI}\s*\(\s*void\s*\)",
131 if abi_decl.search(header_text)
is None:
132 findings.append(f
"ABI: public header does not declare {COMPONENT_ABI}(void)")
133 if abi_definition.search(source_text)
is None:
134 findings.append(f
"ABI: source does not define externally visible {COMPONENT_ABI}(void)")
135 if f
"T[[:space:]]+{COMPONENT_ABI}$" not in build_text:
136 findings.append(f
"ABI: build.sh does not require strong text symbol {COMPONENT_ABI}")
138 weak_hook = re.compile(
139 rf
"^\+__attribute__\(\(weak\)\)\s+esp_err_t\s+{CUSTOM_RPC_HOOK}\s*\(",
142 strong_hook = re.compile(rf
"^\s*esp_err_t\s+{CUSTOM_RPC_HOOK}\s*\(", re.MULTILINE)
143 if weak_hook.search(patch_text)
is None:
144 findings.append(f
"hook: patch does not provide weak extension point {CUSTOM_RPC_HOOK}")
145 if strong_hook.search(source_text)
is None:
146 findings.append(f
"hook: component source does not define strong {CUSTOM_RPC_HOOK}")
147 if f
"T[[:space:]]+{CUSTOM_RPC_HOOK}$" not in build_text:
148 findings.append(f
"hook: build.sh does not require strong text symbol {CUSTOM_RPC_HOOK}")
157 available_sources: frozenset[str] |
None =
None,
159 """Return all offline-checkable C6 integration-contract findings."""
160 findings = _check_staged_copies(build_text, available_sources)
161 findings.extend(_check_component(build_text, patch_text))
162 findings.extend(_check_symbols(build_text, patch_text, header_text, source_text))
167 COMPONENT_DIR="${PERIPHERAL_DIR}/components/mdl_service"
168cp "${SCRIPT_DIR}/../../port/esp32_c6/CMakeLists.txt" "${COMPONENT_DIR}/CMakeLists.txt"
169cp "${SCRIPT_DIR}/../../port/esp32_c6/src/mdl_service.c" \
170 "${COMPONENT_DIR}/src/mdl_service.c"
171cp "${SCRIPT_DIR}/../../port/esp32_c6/inc/ra8_mdl_service.h" \
172 "${COMPONENT_DIR}/include/ra8_mdl_service.h"
173cp "${SCRIPT_DIR}/../../libs/ra8_c6link/inc/ra8_mdl_protocol.h" \
174 "${COMPONENT_DIR}/include/ra8_mdl_protocol.h"
175cp "${SCRIPT_DIR}/../../libs/ra8_c6link/inc/ra8_mdl_http.h" \
176 "${COMPONENT_DIR}/include/ra8_mdl_http.h"
177grep -Eq 'T[[:space:]]+ra8_mdl_service_component_abi$'
178grep -Eq 'T[[:space:]]+esp_hosted_custom_rpc_sync_handler$'
181+set(COMPONENTS esp_timer main mdl_service)
182+__attribute__((weak)) esp_err_t esp_hosted_custom_rpc_sync_handler(
184_GOOD_HEADER =
"uint32_t ra8_mdl_service_component_abi(void);\n"
186[[gnu::noinline]] uint32_t ra8_mdl_service_component_abi(void) { return 1U; }
187esp_err_t esp_hosted_custom_rpc_sync_handler(uint32_t id) { return ESP_OK; }
190SelftestCase = tuple[str, str, str, str, str, bool]
193def _selftest_staging_cases() -> list[SelftestCase]:
194 """Return the quiet control and staged-file/component drift cases."""
196 (
"contract agrees", _GOOD_BUILD, _GOOD_PATCH, _GOOD_HEADER, _GOOD_SOURCE,
False),
198 "staged source renamed away",
199 _GOOD_BUILD.replace(
"inc/ra8_mdl_http.h",
"inc/mdl_http.h", 1),
206 "destination kept stale basename",
207 _GOOD_BUILD.replace(
"include/ra8_mdl_protocol.h",
"include/mdl_protocol.h"),
214 "required copy removed",
216 'cp "${SCRIPT_DIR}/../../port/esp32_c6/CMakeLists.txt" '
217 '"${COMPONENT_DIR}/CMakeLists.txt"\n',
226 "component identity drifted",
228 _GOOD_PATCH.replace(
"main mdl_service",
"main ra8_mdl_service"),
234 "component assignment became nonliteral",
236 ' COMPONENT_DIR="${PERIPHERAL_DIR}/components/mdl_service"',
237 ' COMPONENT_DIR="${PERIPHERAL_DIR}/components/${COMPONENT_NAME}"',
247def _selftest_symbol_cases() -> list[SelftestCase]:
248 """Return public/private/post-link ABI and hook drift cases."""
251 "public ABI name drifted",
254 _GOOD_HEADER.replace(COMPONENT_ABI,
"mdl_service_component_abi"),
259 "ABI became private",
263 _GOOD_SOURCE.replace(
"[[gnu::noinline]] uint32_t",
"static uint32_t"),
267 "post-link ABI name drifted",
268 _GOOD_BUILD.replace(COMPONENT_ABI,
"mdl_service_component_abi"),
275 "weak hook disappeared",
277 _GOOD_PATCH.replace(CUSTOM_RPC_HOOK,
"custom_rpc_sync_handler"),
285def _selftest_cases() -> list[SelftestCase]:
286 """Return one quiet control and one case for each protected seam."""
287 return _selftest_staging_cases() + _selftest_symbol_cases()
290def selftest() -> int:
291 """Prove the detector fires on drift and stays quiet on agreement."""
292 inventory = frozenset(source
for source, _dest
in parse_staged_copies(_GOOD_BUILD))
293 failures: list[str] = []
294 for label, build, patch, header, source, expect
in _selftest_cases():
295 findings = check_contract(build, patch, header, source, inventory)
296 if bool(findings) != expect:
297 verb =
"reported nothing" if expect
else f
"reported {findings}"
298 failures.append(f
" {label}: {verb}")
300 sys.stderr.write(
"check_c6_integration.py --selftest: FAILED\n")
301 sys.stderr.write(
"\n".join(failures) +
"\n")
303 cases = _selftest_cases()
304 fires = sum(1
for case
in cases
if case[-1])
306 f
"check_c6_integration.py --selftest: OK "
307 f
"({len(cases)} cases: {fires} fire, {len(cases) - fires} stays quiet)."
312def main(argv: list[str]) -> int:
313 """Run the selftest or validate the real committed integration recipe."""
314 if "--selftest" in argv[1:]:
316 required = (BUILD_SCRIPT, PATCH_FILE, SERVICE_HEADER, SERVICE_SOURCE)
317 for path
in required:
318 if not path.is_file():
319 sys.stderr.write(f
"check_c6_integration.py: FATAL -- missing {path}\n")
321 findings = check_contract(*(path.read_text(encoding=
"utf-8")
for path
in required))
323 sys.stderr.write(f
"check_c6_integration.py: {len(findings)} C6 integration drift(s):\n")
324 for finding
in findings:
325 sys.stderr.write(f
" {finding}\n")
327 print(
"check_c6_integration.py: C6 staging/component/ABI contract agrees.")
331if __name__ ==
"__main__":
332 raise SystemExit(
main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.