@typecad/framework-zephyr 1.0.0-alpha.16 → 1.0.0-alpha.19

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.
Files changed (210) hide show
  1. package/LICENSE +202 -21
  2. package/README.md +49 -87
  3. package/dist/as-built.d.ts +2 -2
  4. package/dist/as-built.js +2 -2
  5. package/dist/audit.d.ts +111 -0
  6. package/dist/audit.js +416 -0
  7. package/dist/boardgen.d.ts +1 -9
  8. package/dist/boardgen.js +288 -47
  9. package/dist/chips/resolve.js +20 -0
  10. package/dist/chips/types.d.ts +22 -1
  11. package/dist/debug-codegen.js +1 -1
  12. package/dist/display/bindings.d.ts +55 -0
  13. package/dist/display/bindings.js +316 -0
  14. package/dist/display/gfx.d.ts +2 -3
  15. package/dist/display/gfx.js +166 -154
  16. package/dist/display/index.js +20 -1
  17. package/dist/display/mipi-dbi-host.d.ts +9 -0
  18. package/dist/display/mipi-dbi-host.js +174 -0
  19. package/dist/display/profiles.d.ts +109 -4
  20. package/dist/display/profiles.js +270 -7
  21. package/dist/display/touch-adapter.js +119 -49
  22. package/dist/display/ui-adapter-eink.d.ts +2 -0
  23. package/dist/display/ui-adapter-eink.js +4 -0
  24. package/dist/display/ui-adapter-gray.d.ts +8 -0
  25. package/dist/display/ui-adapter-gray.js +170 -0
  26. package/dist/display/ui-adapter-mono.d.ts +13 -0
  27. package/dist/display/ui-adapter-mono.js +230 -0
  28. package/dist/display/ui-adapter-native.d.ts +10 -0
  29. package/dist/display/ui-adapter-native.js +295 -0
  30. package/dist/display/ui-adapter-shared.d.ts +11 -0
  31. package/dist/display/ui-adapter-shared.js +122 -0
  32. package/dist/display/ui-adapter.js +510 -558
  33. package/dist/doctor.js +4 -4
  34. package/dist/dt-config/custom-board.js +2 -2
  35. package/dist/dt-config/kconfig.d.ts +62 -1
  36. package/dist/dt-config/kconfig.js +141 -38
  37. package/dist/dt-config/overlay.d.ts +15 -2
  38. package/dist/dt-config/overlay.js +433 -18
  39. package/dist/framework.manifest.d.ts +10 -4
  40. package/dist/framework.manifest.js +133 -17
  41. package/dist/index.d.ts +2 -0
  42. package/dist/index.js +14 -6
  43. package/dist/licenses.d.ts +2 -2
  44. package/dist/licenses.js +13 -92
  45. package/dist/lowering/can.d.ts +25 -0
  46. package/dist/lowering/can.js +97 -0
  47. package/dist/lowering/clock.d.ts +17 -0
  48. package/dist/lowering/clock.js +58 -0
  49. package/dist/lowering/fs.js +1 -1
  50. package/dist/lowering/gpio.js +0 -32
  51. package/dist/lowering/hid.d.ts +27 -0
  52. package/dist/lowering/hid.js +244 -0
  53. package/dist/lowering/http.js +264 -32
  54. package/dist/lowering/i2c.d.ts +8 -0
  55. package/dist/lowering/i2c.js +137 -5
  56. package/dist/lowering/i2s.d.ts +27 -0
  57. package/dist/lowering/i2s.js +98 -0
  58. package/dist/lowering/index.d.ts +9 -1
  59. package/dist/lowering/index.js +25 -1
  60. package/dist/lowering/interrupts.js +6 -0
  61. package/dist/lowering/matrix.d.ts +15 -0
  62. package/dist/lowering/matrix.js +63 -0
  63. package/dist/lowering/mqtt.js +110 -8
  64. package/dist/lowering/power.d.ts +3 -2
  65. package/dist/lowering/power.js +20 -45
  66. package/dist/lowering/pwm.js +25 -0
  67. package/dist/lowering/sensor.d.ts +2 -2
  68. package/dist/lowering/sensor.js +8 -4
  69. package/dist/lowering/strip.d.ts +16 -0
  70. package/dist/lowering/strip.js +70 -0
  71. package/dist/lowering/thread.js +5 -1
  72. package/dist/lowering/trace.d.ts +44 -0
  73. package/dist/lowering/trace.js +239 -0
  74. package/dist/lowering/uart.js +6 -1
  75. package/dist/lowering/usb.d.ts +3 -1
  76. package/dist/lowering/usb.js +16 -13
  77. package/dist/lowering/wdt.js +2 -29
  78. package/dist/sbom.d.ts +181 -0
  79. package/dist/sbom.js +901 -0
  80. package/dist/sdk/board-catalog-sync.d.ts +1 -3
  81. package/dist/sdk/board-catalog-sync.js +4 -10
  82. package/dist/strategy.d.ts +82 -46
  83. package/dist/strategy.js +639 -182
  84. package/dist/tmp-probe.d.ts +2 -0
  85. package/dist/tmp-probe.js +9 -0
  86. package/dist/toolchain/debug-config.d.ts +50 -90
  87. package/dist/toolchain/debug-config.js +239 -510
  88. package/dist/toolchain/env-check.d.ts +1 -3
  89. package/dist/toolchain/env-check.js +2 -7
  90. package/dist/toolchain/index.d.ts +28 -3
  91. package/dist/toolchain/index.js +498 -62
  92. package/dist/toolchain/runners.d.ts +16 -0
  93. package/dist/toolchain/runners.js +75 -0
  94. package/dist/toolchain/scaffold.d.ts +5 -2
  95. package/dist/toolchain/scaffold.js +79 -15
  96. package/dist/toolchain/west-discover.d.ts +6 -0
  97. package/dist/toolchain/west-discover.js +36 -13
  98. package/dist/toolchain/west-spawn.js +8 -2
  99. package/dist/west-inventory.d.ts +25 -0
  100. package/dist/west-inventory.js +97 -0
  101. package/installer/README.md +328 -328
  102. package/installer/install.sh +2 -2
  103. package/installer/templates/project/.typecad/activate-zephyr.ps1 +1 -1
  104. package/installer/templates/project/.typecad/activate-zephyr.sh +1 -1
  105. package/installer/templates/project/.vscode/settings.json +1 -1
  106. package/installer/templates/project/README.md +2 -2
  107. package/package.json +5 -5
  108. package/src/as-built.ts +206 -206
  109. package/src/audit.ts +529 -0
  110. package/src/boardgen.ts +265 -50
  111. package/src/chips/resolve.ts +21 -0
  112. package/src/chips/types.ts +10 -1
  113. package/src/display/bindings.ts +347 -0
  114. package/src/display/gfx.ts +318 -306
  115. package/src/display/index.ts +87 -70
  116. package/src/display/mipi-dbi-host.ts +183 -0
  117. package/src/display/profiles.ts +458 -139
  118. package/src/display/touch-adapter.ts +119 -49
  119. package/src/display/ui-adapter-eink.ts +13 -0
  120. package/src/display/ui-adapter-gray.ts +178 -0
  121. package/src/display/ui-adapter-mono.ts +238 -0
  122. package/src/display/ui-adapter-native.ts +304 -0
  123. package/src/display/ui-adapter-shared.ts +125 -0
  124. package/src/display/ui-adapter.ts +732 -781
  125. package/src/doctor.ts +4 -4
  126. package/src/dt-config/custom-board.ts +2 -2
  127. package/src/dt-config/kconfig.ts +647 -505
  128. package/src/dt-config/overlay.ts +1475 -1058
  129. package/src/framework.manifest.ts +659 -535
  130. package/src/index.ts +16 -6
  131. package/src/licenses.ts +346 -425
  132. package/src/lowering/can.ts +140 -0
  133. package/src/lowering/clock.ts +91 -0
  134. package/src/lowering/fs.ts +135 -135
  135. package/src/lowering/gpio.ts +0 -33
  136. package/src/lowering/hid.ts +261 -0
  137. package/src/lowering/http.ts +264 -32
  138. package/src/lowering/i2c.ts +142 -5
  139. package/src/lowering/i2s.ts +143 -0
  140. package/src/lowering/index.ts +18 -1
  141. package/src/lowering/interrupts.ts +6 -0
  142. package/src/lowering/matrix.ts +70 -0
  143. package/src/lowering/mqtt.ts +109 -8
  144. package/src/lowering/power.ts +41 -0
  145. package/src/lowering/pwm.ts +192 -167
  146. package/src/lowering/sensor.ts +159 -155
  147. package/src/lowering/strip.ts +81 -0
  148. package/src/lowering/thread.ts +5 -1
  149. package/src/lowering/trace.ts +270 -0
  150. package/src/lowering/uart.ts +6 -1
  151. package/src/lowering/usb.ts +16 -13
  152. package/src/lowering/wdt.ts +2 -25
  153. package/src/sbom.ts +1117 -0
  154. package/src/sdk/board-catalog-sync.ts +4 -25
  155. package/src/strategy.ts +2680 -2309
  156. package/src/toolchain/debug-config.ts +262 -522
  157. package/src/toolchain/env-check.ts +279 -285
  158. package/src/toolchain/index.ts +1792 -1359
  159. package/src/toolchain/runners.ts +80 -0
  160. package/src/toolchain/scaffold.ts +355 -296
  161. package/src/toolchain/west-discover.ts +35 -13
  162. package/src/toolchain/west-spawn.ts +174 -168
  163. package/src/west-inventory.ts +102 -0
  164. package/dist/async/timer-polyfill.d.ts +0 -10
  165. package/dist/async/timer-polyfill.js +0 -95
  166. package/dist/chips/board-overrides.d.ts +0 -7
  167. package/dist/chips/board-overrides.js +0 -11
  168. package/dist/chips/esp32.d.ts +0 -2
  169. package/dist/chips/esp32.js +0 -71
  170. package/dist/chips/esp32s3.d.ts +0 -2
  171. package/dist/chips/esp32s3.js +0 -103
  172. package/dist/chips/soc/.d.ts +0 -2
  173. package/dist/chips/soc/.js +0 -129
  174. package/dist/chips/soc/esp32.d.ts +0 -2
  175. package/dist/chips/soc/esp32.js +0 -120
  176. package/dist/chips/soc/esp32c3.d.ts +0 -2
  177. package/dist/chips/soc/esp32c3.js +0 -90
  178. package/dist/chips/soc/esp32c6.d.ts +0 -2
  179. package/dist/chips/soc/esp32c6.js +0 -109
  180. package/dist/chips/soc/esp32s3.d.ts +0 -2
  181. package/dist/chips/soc/esp32s3.js +0 -189
  182. package/dist/chips/soc/index.d.ts +0 -2
  183. package/dist/chips/soc/index.js +0 -23
  184. package/dist/chips/soc/nrf52840.d.ts +0 -2
  185. package/dist/chips/soc/nrf52840.js +0 -130
  186. package/dist/chips/soc/rp2040.d.ts +0 -2
  187. package/dist/chips/soc/rp2040.js +0 -141
  188. package/dist/chips/soc/rp2350a.d.ts +0 -2
  189. package/dist/chips/soc/rp2350a.js +0 -145
  190. package/dist/chips/soc/samd21g18a.d.ts +0 -2
  191. package/dist/chips/soc/samd21g18a.js +0 -143
  192. package/dist/chips/soc/stm32f411xe.d.ts +0 -2
  193. package/dist/chips/soc/stm32f411xe.js +0 -251
  194. package/dist/chips/xiao-ble.d.ts +0 -2
  195. package/dist/chips/xiao-ble.js +0 -100
  196. package/dist/lowering/pulse.d.ts +0 -7
  197. package/dist/lowering/pulse.js +0 -51
  198. package/dist/lowering/tone.d.ts +0 -10
  199. package/dist/lowering/tone.js +0 -63
  200. package/dist/lowering/worker-backing.d.ts +0 -14
  201. package/dist/lowering/worker-backing.js +0 -79
  202. package/dist/lowering/worker.d.ts +0 -6
  203. package/dist/lowering/worker.js +0 -14
  204. package/dist/sdk/board-data.generated.d.ts +0 -2
  205. package/dist/sdk/board-data.generated.js +0 -4
  206. package/dist/sdk/catalog-walker.d.ts +0 -90
  207. package/dist/sdk/catalog-walker.js +0 -682
  208. package/dist/sdk/dts-reader.d.ts +0 -83
  209. package/dist/sdk/dts-reader.js +0 -596
  210. package/src/debug-codegen.ts +0 -207
package/dist/sbom.js ADDED
@@ -0,0 +1,901 @@
1
+ // ---------------------------------------------------------------------------
2
+ // @typecad/framework-zephyr — SBOM (software bill of materials)
3
+ //
4
+ // `typecad-hal sbom` — emit a build-true software bill of materials for this
5
+ // project, in CycloneDX 1.6 JSON (default) or SPDX 2.3 JSON. The inventory is
6
+ // the same as-built ground truth the licenses presenter reports from: the
7
+ // Zephyr kernel + the west modules the last build ACTUALLY compiled
8
+ // (compile_commands.json), each recorded immutably by its checked-out commit
9
+ // SHA, plus the firmware artifact itself (SHA-256 hashed) and the target
10
+ // board. The toolchain (cuttlefish / framework / hal / SDK pin) is recorded
11
+ // in the CycloneDX `formulation` block — the build environment, deliberately
12
+ // NOT part of the product's runtime components, which is the classic embedded
13
+ // SBOM mistake (generic scanners pollute the runtime BOM with build tools;
14
+ // the firmware links none of them — their contribution is code the engine
15
+ // GENERATED into the app, which the hashed firmware component already
16
+ // captures).
17
+ //
18
+ // EU Cyber Resilience Act alignment: the CRA's essential requirements ask
19
+ // placed-on-market products for an SBOM in a commonly used format (CycloneDX
20
+ // and SPDX both qualify) covering at minimum the top-level dependencies. The
21
+ // default scope here is stronger — only what linked into the binary — and
22
+ // `--check` gates a release on the recorded BOM still matching the build,
23
+ // while `--diff` reports exactly what changed between two builds (the input
24
+ // to update notifications and vulnerability reassessment).
25
+ //
26
+ // Presenter mirrors licenses.ts: injected seams for unit testing, never calls
27
+ // process.exit(), sets process.exitCode under --strict / --check failures.
28
+ // ---------------------------------------------------------------------------
29
+ import { spawnSync } from 'node:child_process';
30
+ import { createHash } from 'node:crypto';
31
+ import { createRequire } from 'node:module';
32
+ import { readFileSync, readdirSync, statSync, writeFileSync, mkdirSync } from 'node:fs';
33
+ import * as path from 'node:path';
34
+ import * as ui from '@typecad/cuttlefish/utils/ui';
35
+ import { loadTypecadConfig } from '@typecad/cuttlefish/config-loader';
36
+ import { PINNED_ZEPHYR_SDK_VERSION } from '@typecad/cuttlefish/board-catalog';
37
+ import { resolveLibraryLicense, } from '@typecad/cuttlefish/api/shared';
38
+ import { discoverWest } from './toolchain/west-discover.js';
39
+ import { westSpawn } from './toolchain/west-spawn.js';
40
+ import { filterLinkedModules, findCompileCommandsPath } from './west-inventory.js';
41
+ // ---------------------------------------------------------------------------
42
+ // Inventory collection
43
+ // ---------------------------------------------------------------------------
44
+ /** Parse a Zephyr VERSION file ("VERSION_MAJOR = 4\n...") into "4.3.0". */
45
+ export function parseZephyrVersion(text) {
46
+ if (!text)
47
+ return undefined;
48
+ const grab = (key) => {
49
+ const m = text.match(new RegExp(`^${key}\\s*=\\s*(\\d+)`, 'm'));
50
+ return m ? m[1] : undefined;
51
+ };
52
+ const major = grab('VERSION_MAJOR');
53
+ const minor = grab('VERSION_MINOR');
54
+ const patch = grab('VERSION_PATCH');
55
+ if (major === undefined)
56
+ return undefined;
57
+ return [major, minor ?? '0', patch ?? '0'].join('.');
58
+ }
59
+ function boardTargetFromConfig() {
60
+ try {
61
+ return loadTypecadConfig(process.cwd())?.buildTarget;
62
+ }
63
+ catch {
64
+ return undefined;
65
+ }
66
+ }
67
+ /**
68
+ * Collect the as-built inventory: kernel + west modules (linked-only by
69
+ * default), each one's immutable identity + resolved SPDX license, the hashed
70
+ * firmware artifact, the project identity, and the toolchain versions.
71
+ * Never throws.
72
+ */
73
+ export function collectSbomInventory(runner, allModules = false, meta = {}) {
74
+ const { readFile, readdir } = runner;
75
+ const warnings = [];
76
+ const rows = runner.listModules();
77
+ if (rows === null && !runner.zephyrBase) {
78
+ return {
79
+ ok: false,
80
+ reason: 'no-workspace',
81
+ message: '`west list` did not return a project list and $ZEPHYR_BASE is unknown (not inside a west workspace?).',
82
+ };
83
+ }
84
+ if (rows === null) {
85
+ warnings.push({
86
+ kind: 'west-list-limited',
87
+ component: '(workspace)',
88
+ detail: '`west list` failed — the SBOM records only the Zephyr kernel.',
89
+ });
90
+ }
91
+ const allRows = rows ?? [];
92
+ // Kernel identity: the west row for $ZEPHYR_BASE when present (carries
93
+ // sha/revision/url), else the row named "zephyr", else $ZEPHYR_BASE itself.
94
+ let kernelBase = runner.zephyrBase;
95
+ let kernelRow;
96
+ if (runner.zephyrBase) {
97
+ kernelRow =
98
+ allRows.find((r) => path.resolve(r.abspath) === path.resolve(runner.zephyrBase)) ??
99
+ allRows.find((r) => r.name === 'zephyr');
100
+ if (kernelRow)
101
+ kernelBase = kernelRow.abspath;
102
+ }
103
+ else {
104
+ kernelRow = allRows.find((r) => r.name === 'zephyr');
105
+ if (kernelRow)
106
+ kernelBase = kernelRow.abspath;
107
+ }
108
+ // Build discovery (both scopes want the artifact hash; the linked scope
109
+ // additionally filters the manifest by what was compiled).
110
+ const ccPath = runner.compileCommandsPath ? runner.compileCommandsPath() : undefined;
111
+ const buildDir = ccPath ? path.dirname(ccPath) : undefined;
112
+ let scoped = allRows;
113
+ let needsBuild = false;
114
+ if (!allModules) {
115
+ if (!ccPath) {
116
+ needsBuild = true;
117
+ scoped = [];
118
+ warnings.push({
119
+ kind: 'no-build',
120
+ component: '(project)',
121
+ detail: 'no build found — the SBOM cannot scope to linked modules or hash the firmware artifact. Run typecad-hal build first.',
122
+ });
123
+ }
124
+ else {
125
+ const ccText = readFile(ccPath);
126
+ // A found-but-degenerate compile_commands.json (0-byte, unreadable,
127
+ // unparseable, or a zero-entry array from a configure-only build)
128
+ // must not silently scope the BOM: empty text or a parse failure
129
+ // would fall through to allRows (every module recorded as linked),
130
+ // an empty array to kernel-only — both stamped as authoritative.
131
+ // Treat it like a missing build: warn, mark needsBuild, block stamping.
132
+ let ccEntries;
133
+ try {
134
+ ccEntries = ccText ? JSON.parse(ccText) : undefined;
135
+ }
136
+ catch {
137
+ ccEntries = undefined;
138
+ }
139
+ if (Array.isArray(ccEntries) && ccEntries.length > 0) {
140
+ scoped = filterLinkedModules(ccText, allRows);
141
+ }
142
+ else {
143
+ needsBuild = true;
144
+ scoped = [];
145
+ warnings.push({
146
+ kind: 'no-build',
147
+ component: '(project)',
148
+ detail: `compile_commands.json at ${ccPath} is empty or unreadable — the SBOM cannot scope to linked modules. Rebuild with typecad-hal build.`,
149
+ });
150
+ }
151
+ }
152
+ }
153
+ // Firmware artifact hash (zephyr.bin, falling back to zephyr.elf).
154
+ let artifactInfo;
155
+ if (buildDir) {
156
+ for (const name of ['zephyr.bin', 'zephyr.elf']) {
157
+ const p = path.join(buildDir, 'zephyr', name);
158
+ const h = runner.sha256 ? runner.sha256(p) : null;
159
+ if (h) {
160
+ const size = runner.fileSize ? runner.fileSize(p) : null;
161
+ artifactInfo = { path: p, fileName: `zephyr/${name}`, sha256: h, sizeBytes: size ?? 0 };
162
+ break;
163
+ }
164
+ }
165
+ }
166
+ // Module components — kernel first (always present when known), then the
167
+ // scoped manifest rows, deduped against the kernel.
168
+ const modules = [];
169
+ const pushModule = (row, isKernel) => {
170
+ const lic = resolveLibraryLicense({ name: isKernel ? 'zephyr' : row.name, installDir: row.abspath }, readFile, readdir,
171
+ // Kernel: top-level LICENSE is authoritative (mirrors licenses.ts).
172
+ // Modules: many HALs keep theirs under zephyr/ or src/.
173
+ isKernel ? { subdirs: [] } : { subdirs: ['zephyr', 'src'] });
174
+ const sha = row.sha && row.sha.trim() ? row.sha.trim() : undefined;
175
+ const revision = row.revision && row.revision.trim() ? row.revision.trim() : undefined;
176
+ if (!sha) {
177
+ warnings.push({
178
+ kind: 'missing-sha',
179
+ component: row.name,
180
+ detail: 'checked-out commit unknown (module not cloned, or west could not resolve it)',
181
+ });
182
+ }
183
+ if (revision && /^(main|master|develop|head)$/i.test(revision)) {
184
+ warnings.push({
185
+ kind: 'floating-revision',
186
+ component: row.name,
187
+ detail: `manifest revision '${revision}' moves between west updates — the recorded SHA is the immutable identity; pin by SHA or tag for a reproducible BOM`,
188
+ });
189
+ }
190
+ let kernelVersion;
191
+ if (isKernel) {
192
+ kernelVersion = parseZephyrVersion(runner.zephyrVersionText ? runner.zephyrVersionText() : undefined);
193
+ }
194
+ modules.push({
195
+ name: isKernel ? 'zephyr' : row.name,
196
+ isKernel,
197
+ version: isKernel
198
+ ? kernelVersion ?? sha ?? revision ?? 'unknown'
199
+ : sha ?? revision ?? 'unknown',
200
+ sha,
201
+ revision,
202
+ url: row.url && row.url.trim() ? row.url.trim() : undefined,
203
+ spdx: lic.spdx,
204
+ kernelVersion,
205
+ });
206
+ if (!lic.spdx)
207
+ warnings.push({ kind: 'unknown-license', component: row.name });
208
+ };
209
+ if (kernelBase) {
210
+ pushModule(kernelRow ?? { name: 'zephyr', abspath: kernelBase }, true);
211
+ }
212
+ for (const row of scoped) {
213
+ if (kernelBase && kernelRow && row === kernelRow)
214
+ continue;
215
+ if (kernelBase && path.resolve(row.abspath) === path.resolve(kernelBase))
216
+ continue;
217
+ pushModule(row, false);
218
+ }
219
+ modules.sort((a, b) => {
220
+ if (a.isKernel !== b.isKernel)
221
+ return a.isKernel ? -1 : 1;
222
+ return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
223
+ });
224
+ // Project identity: package.json name/version, else the directory name.
225
+ let projectName;
226
+ let projectVersion;
227
+ try {
228
+ const pkg = JSON.parse(runner.projectPackageJson ? runner.projectPackageJson() ?? '' : '');
229
+ if (typeof pkg?.name === 'string' && pkg.name)
230
+ projectName = pkg.name;
231
+ if (typeof pkg?.version === 'string' && pkg.version)
232
+ projectVersion = pkg.version;
233
+ }
234
+ catch {
235
+ /* best-effort */
236
+ }
237
+ const cwdBase = path.basename(runner.cwd ?? process.cwd()) || 'firmware';
238
+ return {
239
+ ok: true,
240
+ scope: allModules ? 'all' : 'linked',
241
+ projectName: meta.projectName ?? projectName ?? cwdBase,
242
+ projectVersion: meta.projectVersion ?? projectVersion ?? '0.0.0',
243
+ boardTarget: meta.boardTarget,
244
+ buildDir,
245
+ artifact: artifactInfo,
246
+ modules,
247
+ tools: runner.toolVersions ? runner.toolVersions() : {},
248
+ warnings,
249
+ needsBuild,
250
+ };
251
+ }
252
+ // ---------------------------------------------------------------------------
253
+ // Deterministic identity — UUID v5 serial number
254
+ // ---------------------------------------------------------------------------
255
+ /** Fixed typeCAD SBOM namespace (v5 namespacing; any stable UUID works). */
256
+ const SBOM_NS_HEX = 'b3d19c557e9a4d0e9f6a2c8f4d1a7b52';
257
+ function uuidV5(name) {
258
+ const ns = Buffer.from(SBOM_NS_HEX, 'hex');
259
+ const h = createHash('sha1').update(ns).update(name, 'utf8').digest();
260
+ h[6] = (h[6] & 0x0f) | 0x50; // version 5
261
+ h[8] = (h[8] & 0x3f) | 0x80; // RFC 4122 variant
262
+ const hex = h.toString('hex', 0, 16);
263
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
264
+ }
265
+ /**
266
+ * Deterministic serial number: the same build (same component identities +
267
+ * artifact hash) always produces the same urn:uuid, so `sbom --check` can
268
+ * regenerate and compare without false drift from random UUIDs.
269
+ */
270
+ export function sbomSerialNumber(inv) {
271
+ const ident = [
272
+ inv.projectName,
273
+ inv.projectVersion,
274
+ inv.scope,
275
+ ...inv.modules.map((m) => `${m.name}:${m.version}:${m.sha ?? ''}`),
276
+ inv.artifact?.sha256 ?? 'no-artifact',
277
+ ].join('\n');
278
+ return `urn:uuid:${uuidV5(ident)}`;
279
+ }
280
+ const appBomRef = (inv) => `typecad:app/${inv.projectName}@${inv.projectVersion}`;
281
+ const moduleBomRef = (m) => `west:${m.name}@${m.version}`;
282
+ export function buildCycloneDXBom(inv, opts) {
283
+ const moduleComponents = inv.modules.map((m) => {
284
+ const c = {
285
+ type: 'library',
286
+ 'bom-ref': moduleBomRef(m),
287
+ name: m.name,
288
+ version: m.version,
289
+ };
290
+ if (m.spdx)
291
+ c.licenses = [{ license: { id: m.spdx } }];
292
+ const props = [];
293
+ if (m.sha)
294
+ props.push({ name: 'typecad-hal:commit', value: m.sha });
295
+ if (m.revision)
296
+ props.push({ name: 'typecad-hal:manifest-revision', value: m.revision });
297
+ if (m.isKernel && m.kernelVersion) {
298
+ props.push({ name: 'typecad-hal:zephyr-version', value: m.kernelVersion });
299
+ }
300
+ if (props.length > 0)
301
+ c.properties = props;
302
+ if (m.url)
303
+ c.externalReferences = [{ type: 'vcs', url: m.url }];
304
+ return c;
305
+ });
306
+ const app = {
307
+ type: 'firmware',
308
+ 'bom-ref': appBomRef(inv),
309
+ name: inv.projectName,
310
+ version: inv.projectVersion,
311
+ };
312
+ if (inv.artifact) {
313
+ app.hashes = [{ alg: 'SHA-256', content: inv.artifact.sha256 }];
314
+ app.properties = [{ name: 'typecad-hal:artifact', value: inv.artifact.fileName }];
315
+ }
316
+ const components = [...moduleComponents];
317
+ if (inv.boardTarget) {
318
+ // CycloneDX models physical hardware as component type "device" — the
319
+ // board the firmware was built for, identified by its Zephyr target.
320
+ components.push({
321
+ type: 'device',
322
+ 'bom-ref': `hardware:${inv.boardTarget}`,
323
+ name: inv.boardTarget,
324
+ });
325
+ }
326
+ // Build environment — formulation, never a runtime component.
327
+ const tools = [];
328
+ if (inv.tools.cuttlefish) {
329
+ tools.push({
330
+ type: 'application',
331
+ 'bom-ref': `tool:cuttlefish@${inv.tools.cuttlefish}`,
332
+ name: '@typecad/cuttlefish',
333
+ version: inv.tools.cuttlefish,
334
+ });
335
+ }
336
+ if (inv.tools.frameworkZephyr) {
337
+ tools.push({
338
+ type: 'application',
339
+ 'bom-ref': `tool:framework-zephyr@${inv.tools.frameworkZephyr}`,
340
+ name: '@typecad/framework-zephyr',
341
+ version: inv.tools.frameworkZephyr,
342
+ });
343
+ }
344
+ if (inv.tools.hal) {
345
+ tools.push({
346
+ type: 'application',
347
+ 'bom-ref': `tool:hal@${inv.tools.hal}`,
348
+ name: '@typecad/hal',
349
+ version: inv.tools.hal,
350
+ });
351
+ }
352
+ if (inv.tools.sdk) {
353
+ tools.push({
354
+ type: 'application',
355
+ 'bom-ref': `tool:typecad-zephyr-sdk@${inv.tools.sdk}`,
356
+ name: 'typeCAD Zephyr SDK',
357
+ version: inv.tools.sdk,
358
+ });
359
+ }
360
+ return {
361
+ $schema: 'http://cyclonedx.org/schema/bom-1.6.schema.json',
362
+ bomFormat: 'CycloneDX',
363
+ specVersion: '1.6',
364
+ serialNumber: sbomSerialNumber(inv),
365
+ version: 1,
366
+ metadata: {
367
+ timestamp: opts.timestamp,
368
+ tools: {
369
+ components: [{ type: 'application', name: 'typecad-hal', version: opts.toolVersion }],
370
+ },
371
+ component: app,
372
+ properties: [{ name: 'typecad-hal:sbom-scope', value: inv.scope }],
373
+ },
374
+ components,
375
+ ...(tools.length > 0
376
+ ? { formulation: [{ 'bom-ref': 'formulation:typecad-hal', components: tools }] }
377
+ : {}),
378
+ dependencies: [
379
+ { ref: appBomRef(inv), dependsOn: moduleComponents.map((c) => c['bom-ref']) },
380
+ ...moduleComponents.map((c) => ({ ref: c['bom-ref'], dependsOn: [] })),
381
+ ],
382
+ };
383
+ }
384
+ // ---------------------------------------------------------------------------
385
+ // SPDX 2.3 writer
386
+ // ---------------------------------------------------------------------------
387
+ const spdxSlug = (s) => s.replace(/[^a-zA-Z0-9.-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '') || 'pkg';
388
+ export function buildSpdxDocument(inv, opts) {
389
+ const appPkgId = `SPDXRef-Package-${spdxSlug(inv.projectName)}`;
390
+ const appPkg = {
391
+ name: inv.projectName,
392
+ SPDXID: appPkgId,
393
+ versionInfo: inv.projectVersion,
394
+ downloadLocation: 'NOASSERTION',
395
+ filesAnalyzed: false,
396
+ licenseConcluded: 'NOASSERTION',
397
+ licenseDeclared: 'NOASSERTION',
398
+ copyrightText: 'NOASSERTION',
399
+ description: 'Firmware application built by typecad-hal (Zephyr).',
400
+ ...(inv.artifact
401
+ ? { checksums: [{ algorithm: 'SHA256', checksumValue: inv.artifact.sha256 }] }
402
+ : {}),
403
+ };
404
+ const modulePkgs = inv.modules.map((m) => ({
405
+ name: m.name,
406
+ SPDXID: `SPDXRef-Package-west-${spdxSlug(m.name)}`,
407
+ versionInfo: m.version,
408
+ downloadLocation: m.url ? `git+${m.url}@${m.sha ?? m.version}` : 'NOASSERTION',
409
+ filesAnalyzed: false,
410
+ licenseConcluded: 'NOASSERTION',
411
+ licenseDeclared: m.spdx ?? 'NOASSERTION',
412
+ copyrightText: 'NOASSERTION',
413
+ comment: m.isKernel && m.kernelVersion ? `Zephyr ${m.kernelVersion}` : undefined,
414
+ }));
415
+ return {
416
+ spdxVersion: 'SPDX-2.3',
417
+ dataLicense: 'CC0-1.0',
418
+ SPDXID: 'SPDXRef-DOCUMENT',
419
+ name: `${inv.projectName}-sbom`,
420
+ documentNamespace: `https://typecad.dev/spdx/${sbomSerialNumber(inv).slice('urn:uuid:'.length)}`,
421
+ creationInfo: {
422
+ created: opts.timestamp,
423
+ creators: [`Tool: typecad-hal-${opts.toolVersion}`],
424
+ },
425
+ packages: [appPkg, ...modulePkgs],
426
+ relationships: [
427
+ {
428
+ spdxElementId: 'SPDXRef-DOCUMENT',
429
+ relationshipType: 'DESCRIBES',
430
+ relatedSpdxElement: appPkgId,
431
+ },
432
+ ...inv.modules.map((m) => ({
433
+ spdxElementId: appPkgId,
434
+ relationshipType: 'DEPENDS_ON',
435
+ relatedSpdxElement: `SPDXRef-Package-west-${spdxSlug(m.name)}`,
436
+ })),
437
+ ],
438
+ };
439
+ }
440
+ // ---------------------------------------------------------------------------
441
+ // Canonical JSON + diff (for --check and --diff)
442
+ // ---------------------------------------------------------------------------
443
+ /** Stable stringify: object keys sorted, so regenerated docs compare equal. */
444
+ export function canonicalJson(value) {
445
+ const walk = (v) => {
446
+ if (Array.isArray(v))
447
+ return v.map(walk);
448
+ if (v && typeof v === 'object') {
449
+ const out = {};
450
+ for (const k of Object.keys(v).sort()) {
451
+ out[k] = walk(v[k]);
452
+ }
453
+ return out;
454
+ }
455
+ return v;
456
+ };
457
+ return JSON.stringify(walk(value));
458
+ }
459
+ function flattenComponents(doc) {
460
+ const map = new Map();
461
+ const add = (c) => {
462
+ if (!c || typeof c !== 'object')
463
+ return;
464
+ const o = c;
465
+ const type = typeof o.type === 'string' ? o.type : 'component';
466
+ const name = typeof o.name === 'string' ? o.name : '';
467
+ if (!name)
468
+ return;
469
+ const lic = Array.isArray(o.licenses)
470
+ ? o.licenses[0]?.license
471
+ : undefined;
472
+ const hashes = Array.isArray(o.hashes)
473
+ ? o.hashes[0]
474
+ : undefined;
475
+ map.set(`${type}:${name}`, {
476
+ key: `${type}:${name}`,
477
+ version: typeof o.version === 'string' ? o.version : undefined,
478
+ license: typeof lic?.id === 'string' ? lic.id : undefined,
479
+ hash: typeof hashes?.content === 'string' ? hashes.content : undefined,
480
+ });
481
+ };
482
+ const md = doc.metadata;
483
+ if (md && md.component)
484
+ add(md.component);
485
+ for (const c of Array.isArray(doc.components) ? doc.components : [])
486
+ add(c);
487
+ return map;
488
+ }
489
+ /** Compare two CycloneDX documents' component sets (added/removed/changed),
490
+ * keyed by type:name so a version bump reads as a change, not churn. */
491
+ export function diffCycloneDxDocs(a, b) {
492
+ const fa = flattenComponents(a);
493
+ const fb = flattenComponents(b);
494
+ const added = [];
495
+ const removed = [];
496
+ const changed = [];
497
+ for (const [key, c] of fa) {
498
+ const other = fb.get(key);
499
+ if (!other) {
500
+ removed.push(key);
501
+ continue;
502
+ }
503
+ if (c.version !== other.version) {
504
+ changed.push({ component: key, field: 'version', from: c.version ?? '-', to: other.version ?? '-' });
505
+ }
506
+ if (c.license !== other.license) {
507
+ changed.push({ component: key, field: 'license', from: c.license ?? '-', to: other.license ?? '-' });
508
+ }
509
+ if (c.hash !== other.hash) {
510
+ changed.push({ component: key, field: 'hash', from: c.hash ?? '-', to: other.hash ?? '-' });
511
+ }
512
+ }
513
+ for (const key of fb.keys()) {
514
+ if (!fa.has(key))
515
+ added.push(key);
516
+ }
517
+ return { added, removed, changed };
518
+ }
519
+ // ---------------------------------------------------------------------------
520
+ // Default runner — discoverWest + westSpawn + fs + crypto
521
+ // ---------------------------------------------------------------------------
522
+ function readPackageJsonVersion(spec, fromDir) {
523
+ try {
524
+ const req = createRequire(import.meta.url);
525
+ const p = fromDir
526
+ ? req.resolve(`${spec}/package.json`, { paths: [fromDir] })
527
+ : req.resolve(`${spec}/package.json`);
528
+ const v = JSON.parse(readFileSync(p, 'utf8')).version;
529
+ return typeof v === 'string' ? v : undefined;
530
+ }
531
+ catch {
532
+ return undefined;
533
+ }
534
+ }
535
+ /**
536
+ * Parse `west list --format` TSV output into module refs. Empty columns and
537
+ * west's "N/A" placeholder (e.g. the sha/url of the manifest-imported zephyr
538
+ * project) become undefined; lines without a name+abspath pair are skipped.
539
+ * Exported for unit tests.
540
+ */
541
+ export function parseWestListOutput(out) {
542
+ const column = (raw) => {
543
+ const v = raw?.trim();
544
+ return v && v !== 'N/A' ? v : undefined;
545
+ };
546
+ const mods = [];
547
+ for (const line of out.split(/\r?\n/)) {
548
+ const trimmed = line.trim();
549
+ if (!trimmed)
550
+ continue;
551
+ const parts = trimmed.split('\t');
552
+ const [name, abspath] = parts;
553
+ if (!name || !abspath)
554
+ continue;
555
+ mods.push({
556
+ name,
557
+ abspath,
558
+ revision: column(parts[2]),
559
+ sha: column(parts[3]),
560
+ url: column(parts[4]),
561
+ });
562
+ }
563
+ return mods;
564
+ }
565
+ function defaultSbomRunner(cwd = process.cwd()) {
566
+ const install = discoverWest();
567
+ if (!install)
568
+ return null;
569
+ const zephyrBase = install.zephyrBase;
570
+ // `west list` with identity columns. Older west builds reject unknown
571
+ // format keys ({sha}), so degrade: detailed → plain name/abspath → null.
572
+ const listModules = () => {
573
+ const tryFormat = (fmt) => {
574
+ try {
575
+ const inv = westSpawn(['list', '--format', fmt], { encoding: 'utf8', timeout: 30_000 });
576
+ const r = spawnSync(inv.command, inv.args, inv.options);
577
+ if (r.status !== 0)
578
+ return null;
579
+ const out = typeof r.stdout === 'string' ? r.stdout : '';
580
+ return parseWestListOutput(out);
581
+ }
582
+ catch {
583
+ return null;
584
+ }
585
+ };
586
+ const detailed = tryFormat('{name}\t{abspath}\t{revision}\t{sha}\t{url}');
587
+ if (detailed !== null)
588
+ return detailed;
589
+ return tryFormat('{name}\t{abspath}');
590
+ };
591
+ const readFile = (p) => {
592
+ try {
593
+ return readFileSync(p, 'utf8');
594
+ }
595
+ catch {
596
+ return undefined;
597
+ }
598
+ };
599
+ const readdir = (d) => {
600
+ try {
601
+ return readdirSync(d);
602
+ }
603
+ catch {
604
+ return [];
605
+ }
606
+ };
607
+ return {
608
+ cwd,
609
+ zephyrBase,
610
+ listModules,
611
+ compileCommandsPath: () => findCompileCommandsPath(readFile, readdir, cwd),
612
+ readFile,
613
+ readdir,
614
+ statMtimeMs: (p) => {
615
+ try {
616
+ return statSync(p).mtimeMs;
617
+ }
618
+ catch {
619
+ return null;
620
+ }
621
+ },
622
+ sha256: (p) => {
623
+ try {
624
+ return createHash('sha256').update(readFileSync(p)).digest('hex');
625
+ }
626
+ catch {
627
+ return null;
628
+ }
629
+ },
630
+ fileSize: (p) => {
631
+ try {
632
+ return statSync(p).size;
633
+ }
634
+ catch {
635
+ return null;
636
+ }
637
+ },
638
+ zephyrVersionText: () => (zephyrBase ? readFile(path.join(zephyrBase, 'VERSION')) : undefined),
639
+ projectPackageJson: () => readFile(path.join(cwd, 'package.json')),
640
+ toolVersions: () => ({
641
+ // Engine + framework resolve from this module's dependency closure; hal
642
+ // resolves from the PROJECT's node_modules — the installed copy whose
643
+ // API surface the firmware source compiled against.
644
+ cuttlefish: readPackageJsonVersion('@typecad/cuttlefish'),
645
+ frameworkZephyr: readPackageJsonVersion('@typecad/framework-zephyr'),
646
+ hal: readPackageJsonVersion('@typecad/hal', cwd),
647
+ sdk: PINNED_ZEPHYR_SDK_VERSION,
648
+ }),
649
+ };
650
+ }
651
+ // ---------------------------------------------------------------------------
652
+ // Build stamping (post-build, best-effort — never fails a build)
653
+ // ---------------------------------------------------------------------------
654
+ /**
655
+ * Write `<buildDir>/sbom.cdx.json` after a successful west build. Best-effort:
656
+ * any failure returns undefined silently — a missing SBOM never blocks the
657
+ * build. `runner` is a test seam; production leaves it unset.
658
+ */
659
+ export function stampBuildSbom(args, runner) {
660
+ try {
661
+ const base = runner !== undefined ? runner : defaultSbomRunner(args.projectRoot ?? process.cwd());
662
+ if (!base)
663
+ return undefined;
664
+ // The toolchain knows the build dir — prefer it over cwd-relative discovery.
665
+ const r = {
666
+ ...base,
667
+ compileCommandsPath: () => {
668
+ const cc = path.join(args.buildDir, 'compile_commands.json');
669
+ if (base.readFile(cc))
670
+ return cc;
671
+ return base.compileCommandsPath ? base.compileCommandsPath() : undefined;
672
+ },
673
+ };
674
+ const inv = collectSbomInventory(r, false, { boardTarget: args.board });
675
+ if (!inv.ok)
676
+ return undefined;
677
+ // Only stamp COMPLETE records. The presenter may generate a partial BOM
678
+ // interactively (with visible warnings), but a silently stamped kernel-only
679
+ // or artifact-less record would read as authoritative — and --check would
680
+ // then verify a lie.
681
+ const incomplete = inv.warnings.some((w) => w.kind === 'west-list-limited' || w.kind === 'no-build');
682
+ if (incomplete)
683
+ return undefined;
684
+ const doc = buildCycloneDXBom(inv, {
685
+ toolVersion: inv.tools.cuttlefish ?? '0.0.0',
686
+ timestamp: new Date().toISOString(),
687
+ });
688
+ const out = path.join(args.buildDir, 'sbom.cdx.json');
689
+ writeFileSync(out, JSON.stringify(doc, null, 2) + '\n');
690
+ return out;
691
+ }
692
+ catch {
693
+ return undefined;
694
+ }
695
+ }
696
+ // ---------------------------------------------------------------------------
697
+ // CLI presenter
698
+ // ---------------------------------------------------------------------------
699
+ let testRunner;
700
+ /** @internal Test-only override of the default runner. Pass null to simulate west-not-found. */
701
+ export function __setSbomRunnerForTest(runner) {
702
+ testRunner = runner;
703
+ }
704
+ function runnerOrReport() {
705
+ const runner = testRunner !== undefined ? testRunner : defaultSbomRunner();
706
+ if (!runner) {
707
+ ui.printError('west ............. NOT FOUND');
708
+ ui.printInfo(' → install west (pip install west) or run the typeCAD Zephyr installer.');
709
+ process.exitCode = 1;
710
+ return null;
711
+ }
712
+ return runner;
713
+ }
714
+ function inventoryOrReport(all, meta) {
715
+ const runner = runnerOrReport();
716
+ if (!runner)
717
+ return null;
718
+ const inv = collectSbomInventory(runner, all, meta);
719
+ if (!inv.ok) {
720
+ ui.printInfo(`(${inv.message})`);
721
+ process.exitCode = 1;
722
+ return null;
723
+ }
724
+ return inv;
725
+ }
726
+ function printWarnings(inv) {
727
+ for (const w of inv.warnings) {
728
+ const label = w.kind === 'no-build'
729
+ ? w.detail ?? 'no build found'
730
+ : `${w.component}: ${w.kind}${w.detail ? ` — ${w.detail}` : ''}`;
731
+ ui.printWarning(label);
732
+ }
733
+ }
734
+ function defaultOutputPath(inv, format) {
735
+ const base = inv.buildDir ?? process.cwd();
736
+ return path.join(base, format === 'spdx' ? 'sbom.spdx.json' : 'sbom.cdx.json');
737
+ }
738
+ function runGenerate(options, cleanStdout) {
739
+ const inv = inventoryOrReport(options.all ?? false, { boardTarget: boardTargetFromConfig() });
740
+ if (!inv)
741
+ return;
742
+ const format = options.format ?? 'cyclonedx';
743
+ const toolVersion = inv.tools.cuttlefish ?? '0.0.0';
744
+ const doc = format === 'spdx'
745
+ ? buildSpdxDocument(inv, { toolVersion, timestamp: new Date().toISOString() })
746
+ : buildCycloneDXBom(inv, { toolVersion, timestamp: new Date().toISOString() });
747
+ // --strict fails on identity holes: a BOM whose components cannot be pinned
748
+ // to immutable commits is not a compliance record. Evaluated before the
749
+ // stdout early-return so a clean pipe still gates CI.
750
+ const integrityHoles = inv.warnings.filter((w) => w.kind === 'floating-revision' || w.kind === 'missing-sha');
751
+ if (integrityHoles.length > 0 && options.strict) {
752
+ process.exitCode = 1;
753
+ }
754
+ if (options.stdout) {
755
+ console.log(JSON.stringify(doc, null, 2));
756
+ return;
757
+ }
758
+ const out = options.output ?? defaultOutputPath(inv, format);
759
+ try {
760
+ mkdirSync(path.dirname(out), { recursive: true });
761
+ writeFileSync(out, JSON.stringify(doc, null, 2) + '\n');
762
+ }
763
+ catch (e) {
764
+ ui.printError(`Could not write ${out}: ${e.message}`);
765
+ process.exitCode = 1;
766
+ return;
767
+ }
768
+ ui.printSuccess(`SBOM written to ${out}`);
769
+ const licensed = inv.modules.filter((m) => m.spdx).length;
770
+ ui.printInfo(`components ........ ${inv.modules.length} module(s), ${licensed} with a resolved license ` +
771
+ `(${inv.modules.length - licensed} unknown)`);
772
+ if (inv.boardTarget)
773
+ ui.printInfo(`board ............. ${inv.boardTarget}`);
774
+ if (inv.artifact) {
775
+ ui.printInfo(`firmware .......... ${inv.artifact.fileName} sha256 ${inv.artifact.sha256.slice(0, 16)}…`);
776
+ }
777
+ if (inv.tools.cuttlefish || inv.tools.sdk) {
778
+ ui.printInfo(`toolchain ......... formulation: cuttlefish ${inv.tools.cuttlefish ?? '?'} / ` +
779
+ `hal ${inv.tools.hal ?? '?'} / SDK pin ${inv.tools.sdk ?? '?'}`);
780
+ }
781
+ printWarnings(inv);
782
+ }
783
+ function runCheck(options) {
784
+ if ((options.format ?? 'cyclonedx') === 'spdx') {
785
+ ui.printError('--check verifies the build-stamped CycloneDX record; SPDX output is generate-only.');
786
+ process.exitCode = 1;
787
+ return;
788
+ }
789
+ const inv = inventoryOrReport(options.all ?? false, { boardTarget: boardTargetFromConfig() });
790
+ if (!inv)
791
+ return;
792
+ const runner = testRunner !== undefined ? testRunner : defaultSbomRunner();
793
+ const file = options.output ?? (inv.buildDir ? path.join(inv.buildDir, 'sbom.cdx.json') : defaultOutputPath(inv, 'cyclonedx'));
794
+ let recorded;
795
+ try {
796
+ recorded = JSON.parse(readFileSync(file, 'utf8'));
797
+ }
798
+ catch {
799
+ ui.printError(`No readable CycloneDX SBOM at ${file} — run 'typecad-hal build' (which stamps one) or 'typecad-hal sbom'.`);
800
+ process.exitCode = 1;
801
+ return;
802
+ }
803
+ // Regenerate with the recorded timestamp so only real drift differs.
804
+ const ts = recorded.metadata?.timestamp ??
805
+ new Date().toISOString();
806
+ const expected = buildCycloneDXBom(inv, {
807
+ toolVersion: inv.tools.cuttlefish ?? '0.0.0',
808
+ timestamp: typeof ts === 'string' ? ts : new Date().toISOString(),
809
+ });
810
+ const matches = canonicalJson(expected) === canonicalJson(recorded);
811
+ // Freshness: the binary newer than the SBOM means the record is stale.
812
+ if (matches && inv.artifact && runner?.statMtimeMs) {
813
+ const binM = runner.statMtimeMs(inv.artifact.path);
814
+ const sbomM = runner.statMtimeMs(file);
815
+ if (binM !== null && sbomM !== null && binM > sbomM) {
816
+ ui.printError(`The firmware binary (${inv.artifact.path}) is newer than the recorded SBOM — rebuild or re-run 'typecad-hal sbom'.`);
817
+ process.exitCode = 1;
818
+ return;
819
+ }
820
+ }
821
+ if (matches) {
822
+ ui.printSuccess(`SBOM matches the recorded build — ${inv.modules.length} module(s), serial ${sbomSerialNumber(inv)}.`);
823
+ return;
824
+ }
825
+ const d = diffCycloneDxDocs(recorded, expected);
826
+ for (const r of d.removed)
827
+ ui.printInfo(` removed from build: ${r}`);
828
+ for (const a of d.added)
829
+ ui.printInfo(` new in build: ${a}`);
830
+ for (const c of d.changed) {
831
+ ui.printInfo(` changed: ${c.component} ${c.field} ${c.from} → ${c.to}`);
832
+ }
833
+ ui.printError('The recorded SBOM does not match the current build (drift above).');
834
+ process.exitCode = 1;
835
+ }
836
+ function runDiff(paths) {
837
+ const parse = (p) => {
838
+ try {
839
+ const doc = JSON.parse(readFileSync(p, 'utf8'));
840
+ if (doc.bomFormat !== 'CycloneDX') {
841
+ ui.printError(`${p} is not a CycloneDX document (bomFormat missing).`);
842
+ return null;
843
+ }
844
+ return doc;
845
+ }
846
+ catch (e) {
847
+ ui.printError(`Could not read ${p}: ${e.message}`);
848
+ return null;
849
+ }
850
+ };
851
+ const a = parse(paths[0]);
852
+ const b = parse(paths[1]);
853
+ if (!a || !b) {
854
+ process.exitCode = 1;
855
+ return;
856
+ }
857
+ const serial = (d) => typeof d.serialNumber === 'string' ? d.serialNumber : '(no serial)';
858
+ ui.printInfo(`A: ${paths[0]} — ${serial(a)}`);
859
+ ui.printInfo(`B: ${paths[1]} — ${serial(b)}`);
860
+ const d = diffCycloneDxDocs(a, b);
861
+ for (const r of d.removed)
862
+ ui.printInfo(` only in A: ${r}`);
863
+ for (const add of d.added)
864
+ ui.printInfo(` only in B: ${add}`);
865
+ for (const c of d.changed) {
866
+ ui.printInfo(` changed: ${c.component} ${c.field} ${c.from} → ${c.to}`);
867
+ }
868
+ if (d.added.length === 0 && d.removed.length === 0 && d.changed.length === 0) {
869
+ ui.printSuccess('Component sets are identical (timestamps and serials may still differ).');
870
+ }
871
+ else {
872
+ ui.printWarning(`${d.removed.length} removed, ${d.added.length} added, ${d.changed.length} changed — ` +
873
+ 're-assess vulnerabilities for the changed set.');
874
+ }
875
+ }
876
+ /** `typecad-hal sbom` presenter: generate (default), --check, or --diff.
877
+ * `--stdout` (generate mode) prints ONLY the JSON document — the banner,
878
+ * summary, and warnings are suppressed so it is a clean CI pipe. */
879
+ export function runSbomPresenter(options) {
880
+ const cleanStdout = (options.stdout ?? false) && !options.check && !options.diff;
881
+ if (!cleanStdout)
882
+ ui.printHeader();
883
+ if (options.diff) {
884
+ if (!cleanStdout)
885
+ ui.printStep('Comparing two SBOMs');
886
+ runDiff(options.diff);
887
+ return;
888
+ }
889
+ if (options.check) {
890
+ if (!cleanStdout)
891
+ ui.printStep('Verifying the recorded SBOM against the current build');
892
+ runCheck(options);
893
+ return;
894
+ }
895
+ if (!cleanStdout) {
896
+ ui.printStep((options.all ?? false)
897
+ ? 'Generating SBOM (every west manifest module)'
898
+ : 'Generating SBOM (linked dependencies only)');
899
+ }
900
+ runGenerate(options, cleanStdout);
901
+ }