@typecad/framework-zephyr 1.0.0-alpha.15 → 1.0.0-alpha.18

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 (132) 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/boardgen.d.ts +1 -9
  6. package/dist/boardgen.js +228 -45
  7. package/dist/chips/types.d.ts +1 -1
  8. package/dist/debug-codegen.js +1 -1
  9. package/dist/display/touch-adapter.js +1 -1
  10. package/dist/display/ui-adapter.js +549 -549
  11. package/dist/doctor.js +4 -4
  12. package/dist/dt-config/custom-board.js +2 -2
  13. package/dist/dt-config/kconfig.js +18 -12
  14. package/dist/dt-config/overlay.d.ts +2 -2
  15. package/dist/dt-config/overlay.js +2 -2
  16. package/dist/framework.manifest.d.ts +3 -3
  17. package/dist/framework.manifest.js +10 -7
  18. package/dist/index.js +5 -5
  19. package/dist/licenses.d.ts +2 -2
  20. package/dist/licenses.js +8 -8
  21. package/dist/lowering/fs.js +1 -1
  22. package/dist/lowering/gpio.js +0 -32
  23. package/dist/lowering/http.js +264 -32
  24. package/dist/lowering/i2c.js +0 -8
  25. package/dist/lowering/interrupts.js +6 -0
  26. package/dist/lowering/mqtt.js +110 -8
  27. package/dist/lowering/usb.js +11 -11
  28. package/dist/lowering/wdt.js +2 -29
  29. package/dist/sdk/board-catalog-sync.d.ts +1 -3
  30. package/dist/sdk/board-catalog-sync.js +4 -10
  31. package/dist/strategy.d.ts +22 -44
  32. package/dist/strategy.js +152 -155
  33. package/dist/tmp-probe.d.ts +2 -0
  34. package/dist/tmp-probe.js +9 -0
  35. package/dist/toolchain/debug-config.d.ts +50 -90
  36. package/dist/toolchain/debug-config.js +241 -502
  37. package/dist/toolchain/env-check.d.ts +1 -3
  38. package/dist/toolchain/env-check.js +2 -7
  39. package/dist/toolchain/index.d.ts +65 -2
  40. package/dist/toolchain/index.js +432 -55
  41. package/dist/toolchain/runners.d.ts +16 -0
  42. package/dist/toolchain/runners.js +75 -0
  43. package/dist/toolchain/scaffold.d.ts +1 -1
  44. package/dist/toolchain/scaffold.js +12 -12
  45. package/dist/toolchain/west-discover.d.ts +6 -0
  46. package/dist/toolchain/west-discover.js +36 -13
  47. package/dist/toolchain/west-spawn.js +8 -2
  48. package/installer/README.md +328 -328
  49. package/installer/install.sh +2 -2
  50. package/installer/templates/project/.typecad/activate-zephyr.ps1 +1 -1
  51. package/installer/templates/project/.typecad/activate-zephyr.sh +1 -1
  52. package/installer/templates/project/.vscode/settings.json +1 -1
  53. package/installer/templates/project/README.md +2 -2
  54. package/package.json +5 -5
  55. package/src/as-built.ts +206 -206
  56. package/src/boardgen.ts +214 -48
  57. package/src/chips/types.ts +567 -567
  58. package/src/display/touch-adapter.ts +204 -204
  59. package/src/display/ui-adapter.ts +781 -781
  60. package/src/doctor.ts +4 -4
  61. package/src/dt-config/custom-board.ts +2 -2
  62. package/src/dt-config/kconfig.ts +18 -12
  63. package/src/dt-config/overlay.ts +1058 -1058
  64. package/src/framework.manifest.ts +538 -535
  65. package/src/index.ts +5 -5
  66. package/src/licenses.ts +425 -425
  67. package/src/lowering/fs.ts +135 -135
  68. package/src/lowering/gpio.ts +0 -33
  69. package/src/lowering/http.ts +264 -32
  70. package/src/lowering/i2c.ts +0 -9
  71. package/src/lowering/interrupts.ts +6 -0
  72. package/src/lowering/mqtt.ts +109 -8
  73. package/src/lowering/usb.ts +221 -221
  74. package/src/lowering/wdt.ts +2 -25
  75. package/src/sdk/board-catalog-sync.ts +4 -25
  76. package/src/strategy.ts +2265 -2309
  77. package/src/toolchain/debug-config.ts +264 -514
  78. package/src/toolchain/env-check.ts +279 -285
  79. package/src/toolchain/index.ts +1703 -1286
  80. package/src/toolchain/runners.ts +80 -0
  81. package/src/toolchain/scaffold.ts +296 -296
  82. package/src/toolchain/west-discover.ts +35 -13
  83. package/src/toolchain/west-spawn.ts +174 -168
  84. package/dist/async/timer-polyfill.d.ts +0 -10
  85. package/dist/async/timer-polyfill.js +0 -95
  86. package/dist/chips/board-overrides.d.ts +0 -7
  87. package/dist/chips/board-overrides.js +0 -11
  88. package/dist/chips/esp32.d.ts +0 -2
  89. package/dist/chips/esp32.js +0 -71
  90. package/dist/chips/esp32s3.d.ts +0 -2
  91. package/dist/chips/esp32s3.js +0 -103
  92. package/dist/chips/soc/.d.ts +0 -2
  93. package/dist/chips/soc/.js +0 -129
  94. package/dist/chips/soc/esp32.d.ts +0 -2
  95. package/dist/chips/soc/esp32.js +0 -120
  96. package/dist/chips/soc/esp32c3.d.ts +0 -2
  97. package/dist/chips/soc/esp32c3.js +0 -90
  98. package/dist/chips/soc/esp32c6.d.ts +0 -2
  99. package/dist/chips/soc/esp32c6.js +0 -109
  100. package/dist/chips/soc/esp32s3.d.ts +0 -2
  101. package/dist/chips/soc/esp32s3.js +0 -189
  102. package/dist/chips/soc/index.d.ts +0 -2
  103. package/dist/chips/soc/index.js +0 -23
  104. package/dist/chips/soc/nrf52840.d.ts +0 -2
  105. package/dist/chips/soc/nrf52840.js +0 -130
  106. package/dist/chips/soc/rp2040.d.ts +0 -2
  107. package/dist/chips/soc/rp2040.js +0 -141
  108. package/dist/chips/soc/rp2350a.d.ts +0 -2
  109. package/dist/chips/soc/rp2350a.js +0 -145
  110. package/dist/chips/soc/samd21g18a.d.ts +0 -2
  111. package/dist/chips/soc/samd21g18a.js +0 -143
  112. package/dist/chips/soc/stm32f411xe.d.ts +0 -2
  113. package/dist/chips/soc/stm32f411xe.js +0 -251
  114. package/dist/chips/xiao-ble.d.ts +0 -2
  115. package/dist/chips/xiao-ble.js +0 -100
  116. package/dist/lowering/power.d.ts +0 -9
  117. package/dist/lowering/power.js +0 -60
  118. package/dist/lowering/pulse.d.ts +0 -7
  119. package/dist/lowering/pulse.js +0 -51
  120. package/dist/lowering/tone.d.ts +0 -10
  121. package/dist/lowering/tone.js +0 -63
  122. package/dist/lowering/worker-backing.d.ts +0 -14
  123. package/dist/lowering/worker-backing.js +0 -79
  124. package/dist/lowering/worker.d.ts +0 -6
  125. package/dist/lowering/worker.js +0 -14
  126. package/dist/sdk/board-data.generated.d.ts +0 -2
  127. package/dist/sdk/board-data.generated.js +0 -4
  128. package/dist/sdk/catalog-walker.d.ts +0 -90
  129. package/dist/sdk/catalog-walker.js +0 -682
  130. package/dist/sdk/dts-reader.d.ts +0 -83
  131. package/dist/sdk/dts-reader.js +0 -596
  132. package/src/debug-codegen.ts +0 -207
package/src/licenses.ts CHANGED
@@ -1,425 +1,425 @@
1
- // ---------------------------------------------------------------------------
2
- // @typecad/framework-zephyr — Zephyr license scanner
3
- //
4
- // Zephyr-specific enumeration + presenter for the `cuttlefish licenses`
5
- // subcommand. The framework-agnostic SPDX detection engine lives in the shared
6
- // cuttlefish core (`@typecad/cuttlefish/api/shared`); this module owns only the
7
- // Zephyr pieces: discovering the west workspace, enumerating the Zephyr kernel
8
- // + west manifest projects (`west list`), and resolving each one's LICENSE.
9
- //
10
- // Scoping mirrors framework-arduino's `--all` distinction:
11
- // - default (project scope): only the dependencies the firmware ACTUALLY
12
- // links, derived from the build's `compile_commands.json` (a module is
13
- // listed iff its sources were compiled). A west manifest carries every
14
- // vendor HAL/library; almost none are linked by a single project, so the
15
- // default filters them out. Requires a prior `cuttlefish build` — without
16
- // one, only the kernel is reported with a hint to build first.
17
- // - `--all`: every west manifest project (the whole workspace).
18
- //
19
- // Mirrors framework-arduino's presenter shape (runLicensesPresenter(strict,
20
- // all); never calls process.exit(); sets process.exitCode under --strict).
21
- // ---------------------------------------------------------------------------
22
-
23
- import { spawnSync } from 'node:child_process';
24
- import { readFileSync, readdirSync } from 'node:fs';
25
- import * as path from 'node:path';
26
- import * as ui from '@typecad/cuttlefish/utils/ui';
27
- import { loadCuttlefishConfig } from '@typecad/cuttlefish/config-loader';
28
- import {
29
- resolveLibraryLicense,
30
- RISK_RANK,
31
- riskBracket,
32
- statusMark,
33
- countByRisk,
34
- type LibraryLicenseEntry,
35
- type ReadFile,
36
- type ReadDir,
37
- } from '@typecad/cuttlefish/api/shared';
38
- import { discoverWest } from './toolchain/west-discover.js';
39
- import { westSpawn } from './toolchain/west-spawn.js';
40
-
41
- // ---------------------------------------------------------------------------
42
- // Outcome + runner types
43
- // ---------------------------------------------------------------------------
44
-
45
- export type ZephyrLicensesOutcome =
46
- | { ok: true; entries: LibraryLicenseEntry[]; needsBuild?: boolean }
47
- | {
48
- ok: false;
49
- reason: 'west-not-found' | 'no-workspace' | 'no-dependencies';
50
- message: string;
51
- };
52
-
53
- /**
54
- * Injected enumeration seams so scanZephyrLicenses is unit-testable without
55
- * spawning west or touching disk. The production runner (defaultRunner) wires
56
- * these to discoverWest + westSpawn + fs.
57
- */
58
- export interface ZephyrLicensesRunner {
59
- /** Absolute workspace topdir, or null if cwd is not inside a west workspace. */
60
- topdir: () => string | null;
61
- /** west manifest projects `[{ name, abspath }]`, or null if `west list` failed. */
62
- listModules: () => { name: string; abspath: string }[] | null;
63
- /** Discovered $ZEPHYR_BASE (absolute), or undefined. */
64
- zephyrBase?: string;
65
- /**
66
- * The build's compile_commands.json text, or null when no build exists. The
67
- * project scope uses it to determine which modules the firmware actually
68
- * links (a module is "linked" iff one of its sources was compiled).
69
- */
70
- compileCommands?: () => string | null;
71
- readFile: ReadFile;
72
- readdir: ReadDir;
73
- }
74
-
75
- // ---------------------------------------------------------------------------
76
- // Linked-module filtering (project scope)
77
- // ---------------------------------------------------------------------------
78
-
79
- /** Normalize a path for case-insensitive, separator-agnostic comparison. */
80
- function norm(p: string): string {
81
- return p.split('\\').join('/').toLowerCase();
82
- }
83
-
84
- /**
85
- * Filter the west manifest to the modules whose sources were compiled in the
86
- * last build. A module is "linked" iff some compiled translation unit lives
87
- * under its abspath. If the build data is unparseable, no filtering is applied
88
- * (degrade to listing all modules rather than reporting nothing).
89
- */
90
- function filterLinkedModules(
91
- ccText: string,
92
- modules: { name: string; abspath: string }[],
93
- ): { name: string; abspath: string }[] {
94
- let arr: unknown;
95
- try {
96
- arr = JSON.parse(ccText);
97
- } catch {
98
- return modules;
99
- }
100
- if (!Array.isArray(arr)) return modules;
101
- // One normalized blob of every compiled source path; membership is then a
102
- // substring check per module (O(modules) after an O(TUs) join).
103
- const blob = arr
104
- .map((e) => norm(typeof (e as { file?: unknown })?.file === 'string' ? (e as { file: string }).file : ''))
105
- .join('\n');
106
- return modules.filter((m) => blob.includes(norm(m.abspath) + '/'));
107
- }
108
-
109
- // ---------------------------------------------------------------------------
110
- // scanZephyrLicenses — enumerate + resolve the workspace dependency set
111
- // ---------------------------------------------------------------------------
112
-
113
- /**
114
- * Resolve the Zephyr kernel + west manifest projects to license entries.
115
- *
116
- * The Zephyr kernel is always included (when ZEPHYR_BASE is known) — a
117
- * cuttlefish app always links it via `find_package(Zephyr)`.
118
- *
119
- * `all === false` (default, project scope) filters the west modules to those
120
- * whose sources were compiled in the last build (via `compileCommands`). When
121
- * no build is available, only the kernel is returned and `needsBuild` is set so
122
- * the presenter can hint the user to build first. Never throws.
123
- */
124
- export function scanZephyrLicenses(
125
- runner: ZephyrLicensesRunner,
126
- all = false,
127
- ): ZephyrLicensesOutcome {
128
- const { readFile, readdir } = runner;
129
- const entries: LibraryLicenseEntry[] = [];
130
-
131
- // 1. The Zephyr kernel ($ZEPHYR_BASE). Apache-2.0; resolved from its LICENSE.
132
- if (runner.zephyrBase) {
133
- entries.push(
134
- resolveLibraryLicense(
135
- { name: 'zephyr (kernel)', installDir: runner.zephyrBase },
136
- readFile,
137
- readdir,
138
- // Zephyr's top-level LICENSE is the authoritative source; no manifest
139
- // license field to consult, and scanning its source headers is noise.
140
- { subdirs: [] },
141
- ),
142
- );
143
- }
144
-
145
- // 2. west manifest projects.
146
- const modules = runner.listModules();
147
- if (modules === null) {
148
- if (entries.length > 0) return { ok: true, entries };
149
- return {
150
- ok: false,
151
- reason: 'no-workspace',
152
- message: '`west list` did not return a project list (not inside a west workspace?).',
153
- };
154
- }
155
-
156
- // Project scope: keep only the modules the build actually linked.
157
- let projectModules = modules;
158
- let needsBuild = false;
159
- if (!all) {
160
- const cc = runner.compileCommands ? runner.compileCommands() : null;
161
- if (cc === null) {
162
- // No build — can't determine the linked set. Report the kernel only and
163
- // flag it so the presenter prints a "build first" hint.
164
- projectModules = [];
165
- needsBuild = true;
166
- } else {
167
- projectModules = filterLinkedModules(cc, modules);
168
- }
169
- }
170
-
171
- for (const m of projectModules) {
172
- // Skip the Zephyr kernel itself (already added above by name) to avoid a
173
- // duplicate row when it is also a manifest project.
174
- if (runner.zephyrBase && path.resolve(m.abspath) === path.resolve(runner.zephyrBase)) {
175
- continue;
176
- }
177
- entries.push(
178
- resolveLibraryLicense({ name: m.name, installDir: m.abspath }, readFile, readdir, {
179
- // Zephyr HAL modules commonly keep their LICENSE under a `zephyr/` or
180
- // `src/` subdir (e.g. hal_nordic ships zephyr/LICENSE.txt). Check both
181
- // alongside the root. (Source-header scanning is bounded — it only runs
182
- // when no LICENSE file is found, capped at 6 files / 120 lines.)
183
- subdirs: ['zephyr', 'src'],
184
- }),
185
- );
186
- }
187
-
188
- if (entries.length === 0) {
189
- return {
190
- ok: false,
191
- reason: 'no-dependencies',
192
- message: 'No Zephyr dependencies found to scan.',
193
- };
194
- }
195
-
196
- entries.sort((a, b) => {
197
- const r = RISK_RANK[a.risk] - RISK_RANK[b.risk];
198
- if (r !== 0) return r;
199
- return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
200
- });
201
- return { ok: true, entries, needsBuild };
202
- }
203
-
204
- // ---------------------------------------------------------------------------
205
- // Default runner — wires seams to discoverWest + westSpawn + fs
206
- // ---------------------------------------------------------------------------
207
-
208
- /**
209
- * Find the build's compile_commands.json under cwd. Cuttlefish's Zephyr project
210
- * root is the transpile output dir (e.g. `<cwd>/src/out`), so the build dir is
211
- * `<root>/build` — not necessarily `<cwd>/build`. Check the common layouts,
212
- * then fall back to a bounded search (skipping node_modules/.git/dist).
213
- */
214
- function findCompileCommandsText(readFile: ReadFile, readdir: ReadDir): string | null {
215
- const direct = [
216
- path.join(process.cwd(), 'build', 'compile_commands.json'),
217
- path.join(process.cwd(), 'out', 'build', 'compile_commands.json'),
218
- path.join(process.cwd(), 'src', 'out', 'build', 'compile_commands.json'),
219
- ];
220
- for (const c of direct) {
221
- const text = readFile(c);
222
- if (text) return text;
223
- }
224
- // Bounded recursive search for a build/compile_commands.json.
225
- const isCc = (p: string) => {
226
- const n = norm(p);
227
- return n.endsWith('/build/compile_commands.json') || n.endsWith('\\build\\compile_commands.json');
228
- };
229
- let found: string | undefined;
230
- const seen = new Set<string>();
231
- const walk = (dir: string, depth: number): void => {
232
- if (found || depth > 4 || seen.has(dir)) return;
233
- seen.add(dir);
234
- let entries: string[];
235
- try {
236
- entries = readdir(dir);
237
- } catch {
238
- return;
239
- }
240
- for (const e of entries) {
241
- const full = path.join(dir, e);
242
- if (e === 'compile_commands.json' && isCc(full)) {
243
- found = full;
244
- return;
245
- }
246
- }
247
- for (const e of entries) {
248
- if (found) return;
249
- if (e === 'node_modules' || e === '.git' || e === 'dist' || e.startsWith('.')) continue;
250
- walk(path.join(dir, e), depth + 1);
251
- }
252
- };
253
- walk(process.cwd(), 0);
254
- return found ? (readFile(found) ?? null) : null;
255
- }
256
-
257
- function defaultRunner(): ZephyrLicensesRunner | null {
258
- const install = discoverWest();
259
- if (!install) return null;
260
- const zephyrBase = install.zephyrBase;
261
-
262
- // `west topdir` prints the workspace root (one path line). Returns null when
263
- // cwd is not inside a west workspace (west exits non-zero).
264
- const topdir = (): string | null => {
265
- try {
266
- const inv = westSpawn(['topdir'], { encoding: 'utf8', timeout: 15_000 });
267
- const r = spawnSync(inv.command, inv.args, inv.options);
268
- const out = typeof r.stdout === 'string' ? r.stdout.trim() : '';
269
- return r.status === 0 && out ? out : null;
270
- } catch {
271
- return null;
272
- }
273
- };
274
-
275
- // `west list --format '{name}\t{abspath}'` → one project per line. Returns
276
- // null on any spawn failure.
277
- const listModules = (): { name: string; abspath: string }[] | null => {
278
- try {
279
- const inv = westSpawn(['list', '--format', '{name}\t{abspath}'], {
280
- encoding: 'utf8',
281
- timeout: 30_000,
282
- });
283
- const r = spawnSync(inv.command, inv.args, inv.options);
284
- if (r.status !== 0) return null;
285
- const out = typeof r.stdout === 'string' ? r.stdout : '';
286
- const mods: { name: string; abspath: string }[] = [];
287
- for (const line of out.split(/\r?\n/)) {
288
- const trimmed = line.trim();
289
- if (!trimmed) continue;
290
- const [name, abspath] = trimmed.split('\t');
291
- if (name && abspath) mods.push({ name, abspath });
292
- }
293
- return mods;
294
- } catch {
295
- return null;
296
- }
297
- };
298
-
299
- const readFile: ReadFile = (p) => {
300
- try {
301
- return readFileSync(p, 'utf8');
302
- } catch {
303
- return undefined;
304
- }
305
- };
306
- const readdir: ReadDir = (d) => {
307
- try {
308
- return readdirSync(d);
309
- } catch {
310
- return [];
311
- }
312
- };
313
-
314
- return {
315
- topdir,
316
- listModules,
317
- zephyrBase,
318
- compileCommands: () => findCompileCommandsText(readFile, readdir),
319
- readFile,
320
- readdir,
321
- };
322
- }
323
-
324
- // ---------------------------------------------------------------------------
325
- // CLI presenter
326
- // ---------------------------------------------------------------------------
327
-
328
- let testRunner: ZephyrLicensesRunner | null | undefined;
329
-
330
- /** @internal Test-only override of the default runner. Pass null to simulate west-not-found. */
331
- export function __setLicensesRunnerForTest(runner: ZephyrLicensesRunner | null | undefined): void {
332
- testRunner = runner;
333
- }
334
-
335
- /**
336
- * `cuttlefish licenses` (Zephyr) presenter. Enumerates the Zephyr kernel +
337
- * west manifest projects, resolves each one's license, classifies copyleft
338
- * risk, and renders a sorted table. Warns on unknown licenses; sets
339
- * process.exitCode under --strict when any strong-copyleft dependency is
340
- * present. Never calls process.exit().
341
- *
342
- * Default scope = only the modules the firmware actually links (from the last
343
- * `cuttlefish build`); `--all` lists every west manifest module.
344
- */
345
- export function runLicensesPresenter(strict: boolean, all: boolean): void {
346
- ui.printHeader();
347
- ui.printStep(
348
- all
349
- ? 'Checking licenses for every west manifest module'
350
- : 'Checking licenses for this Zephyr project (linked dependencies only)',
351
- );
352
-
353
- // Surface the config (informational). Best-effort: a missing/malformed config
354
- // never blocks the license scan.
355
- let buildTarget: string | undefined;
356
- try {
357
- buildTarget = loadCuttlefishConfig(process.cwd())?.buildTarget;
358
- } catch {
359
- /* best-effort */
360
- }
361
- if (buildTarget) {
362
- ui.printInfo(`Board target ... ${buildTarget}`);
363
- }
364
-
365
- const runner = testRunner !== undefined ? testRunner : defaultRunner();
366
- if (!runner) {
367
- ui.printError('west ............. NOT FOUND');
368
- ui.printInfo(" → install west (pip install west) or run the typeCAD Zephyr installer.");
369
- process.exitCode = 1;
370
- return;
371
- }
372
-
373
- const result = scanZephyrLicenses(runner, all);
374
- if (!result.ok) {
375
- if (result.reason === 'no-workspace' || result.reason === 'no-dependencies') {
376
- ui.printInfo(`(${result.message})`);
377
- }
378
- return;
379
- }
380
-
381
- // Project scope with no build: only the kernel is reported. Hint the user to
382
- // build (which records which modules actually link) or use --all.
383
- if (result.needsBuild) {
384
- ui.printInfo(
385
- '(no build found — only the Zephyr kernel is shown. Run `cuttlefish build` to scope ' +
386
- 'this report to the modules your firmware actually links, or use `cuttlefish licenses --all` ' +
387
- 'for every west module.)',
388
- );
389
- }
390
-
391
- const counts = countByRisk(result.entries);
392
- for (const e of result.entries) {
393
- if (e.risk === 'unknown') {
394
- ui.printWarning(`${e.name} .................. UNKNOWN`);
395
- } else {
396
- ui.printInfo(`${e.name} .................. ${e.spdx ?? 'UNKNOWN'}${riskBracket(e.risk)}${statusMark(e.risk)}`);
397
- }
398
- }
399
- ui.printSuccess(
400
- `${counts.permissive} permissive, ${counts['weak-copyleft']} weak copyleft, ` +
401
- `${counts['strong-copyleft']} strong copyleft, ${counts.unknown} unknown`,
402
- );
403
-
404
- const unknowns = result.entries.filter((e) => e.risk === 'unknown');
405
- if (unknowns.length > 0) {
406
- ui.printWarning(
407
- `License could not be determined for ${unknowns.length} ${unknowns.length === 1 ? 'dependency' : 'dependencies'}:`,
408
- );
409
- for (const u of unknowns) {
410
- ui.printInfo(` ${u.name} (check LICENSE in ${u.path})`);
411
- }
412
- }
413
-
414
- const strongCopyleft = result.entries.filter((e) => e.risk === 'strong-copyleft');
415
- if (strongCopyleft.length > 0) {
416
- ui.printWarning(
417
- `${strongCopyleft.length} ${strongCopyleft.length === 1 ? 'dependency carries' : 'dependencies carry'} strong-copyleft terms — review before shipping.`,
418
- );
419
- }
420
-
421
- // --strict fails the build on any unknown OR strong-copyleft dependency.
422
- if ((unknowns.length > 0 || strongCopyleft.length > 0) && strict) {
423
- process.exitCode = 1;
424
- }
425
- }
1
+ // ---------------------------------------------------------------------------
2
+ // @typecad/framework-zephyr — Zephyr license scanner
3
+ //
4
+ // Zephyr-specific enumeration + presenter for the `typecad-hal licenses`
5
+ // subcommand. The framework-agnostic SPDX detection engine lives in the shared
6
+ // cuttlefish core (`@typecad/cuttlefish/api/shared`); this module owns only the
7
+ // Zephyr pieces: discovering the west workspace, enumerating the Zephyr kernel
8
+ // + west manifest projects (`west list`), and resolving each one's LICENSE.
9
+ //
10
+ // Scoping mirrors framework-arduino's `--all` distinction:
11
+ // - default (project scope): only the dependencies the firmware ACTUALLY
12
+ // links, derived from the build's `compile_commands.json` (a module is
13
+ // listed iff its sources were compiled). A west manifest carries every
14
+ // vendor HAL/library; almost none are linked by a single project, so the
15
+ // default filters them out. Requires a prior `typecad-hal build` — without
16
+ // one, only the kernel is reported with a hint to build first.
17
+ // - `--all`: every west manifest project (the whole workspace).
18
+ //
19
+ // Mirrors framework-arduino's presenter shape (runLicensesPresenter(strict,
20
+ // all); never calls process.exit(); sets process.exitCode under --strict).
21
+ // ---------------------------------------------------------------------------
22
+
23
+ import { spawnSync } from 'node:child_process';
24
+ import { readFileSync, readdirSync } from 'node:fs';
25
+ import * as path from 'node:path';
26
+ import * as ui from '@typecad/cuttlefish/utils/ui';
27
+ import { loadTypecadConfig } from '@typecad/cuttlefish/config-loader';
28
+ import {
29
+ resolveLibraryLicense,
30
+ RISK_RANK,
31
+ riskBracket,
32
+ statusMark,
33
+ countByRisk,
34
+ type LibraryLicenseEntry,
35
+ type ReadFile,
36
+ type ReadDir,
37
+ } from '@typecad/cuttlefish/api/shared';
38
+ import { discoverWest } from './toolchain/west-discover.js';
39
+ import { westSpawn } from './toolchain/west-spawn.js';
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Outcome + runner types
43
+ // ---------------------------------------------------------------------------
44
+
45
+ export type ZephyrLicensesOutcome =
46
+ | { ok: true; entries: LibraryLicenseEntry[]; needsBuild?: boolean }
47
+ | {
48
+ ok: false;
49
+ reason: 'west-not-found' | 'no-workspace' | 'no-dependencies';
50
+ message: string;
51
+ };
52
+
53
+ /**
54
+ * Injected enumeration seams so scanZephyrLicenses is unit-testable without
55
+ * spawning west or touching disk. The production runner (defaultRunner) wires
56
+ * these to discoverWest + westSpawn + fs.
57
+ */
58
+ export interface ZephyrLicensesRunner {
59
+ /** Absolute workspace topdir, or null if cwd is not inside a west workspace. */
60
+ topdir: () => string | null;
61
+ /** west manifest projects `[{ name, abspath }]`, or null if `west list` failed. */
62
+ listModules: () => { name: string; abspath: string }[] | null;
63
+ /** Discovered $ZEPHYR_BASE (absolute), or undefined. */
64
+ zephyrBase?: string;
65
+ /**
66
+ * The build's compile_commands.json text, or null when no build exists. The
67
+ * project scope uses it to determine which modules the firmware actually
68
+ * links (a module is "linked" iff one of its sources was compiled).
69
+ */
70
+ compileCommands?: () => string | null;
71
+ readFile: ReadFile;
72
+ readdir: ReadDir;
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Linked-module filtering (project scope)
77
+ // ---------------------------------------------------------------------------
78
+
79
+ /** Normalize a path for case-insensitive, separator-agnostic comparison. */
80
+ function norm(p: string): string {
81
+ return p.split('\\').join('/').toLowerCase();
82
+ }
83
+
84
+ /**
85
+ * Filter the west manifest to the modules whose sources were compiled in the
86
+ * last build. A module is "linked" iff some compiled translation unit lives
87
+ * under its abspath. If the build data is unparseable, no filtering is applied
88
+ * (degrade to listing all modules rather than reporting nothing).
89
+ */
90
+ function filterLinkedModules(
91
+ ccText: string,
92
+ modules: { name: string; abspath: string }[],
93
+ ): { name: string; abspath: string }[] {
94
+ let arr: unknown;
95
+ try {
96
+ arr = JSON.parse(ccText);
97
+ } catch {
98
+ return modules;
99
+ }
100
+ if (!Array.isArray(arr)) return modules;
101
+ // One normalized blob of every compiled source path; membership is then a
102
+ // substring check per module (O(modules) after an O(TUs) join).
103
+ const blob = arr
104
+ .map((e) => norm(typeof (e as { file?: unknown })?.file === 'string' ? (e as { file: string }).file : ''))
105
+ .join('\n');
106
+ return modules.filter((m) => blob.includes(norm(m.abspath) + '/'));
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // scanZephyrLicenses — enumerate + resolve the workspace dependency set
111
+ // ---------------------------------------------------------------------------
112
+
113
+ /**
114
+ * Resolve the Zephyr kernel + west manifest projects to license entries.
115
+ *
116
+ * The Zephyr kernel is always included (when ZEPHYR_BASE is known) — a
117
+ * cuttlefish app always links it via `find_package(Zephyr)`.
118
+ *
119
+ * `all === false` (default, project scope) filters the west modules to those
120
+ * whose sources were compiled in the last build (via `compileCommands`). When
121
+ * no build is available, only the kernel is returned and `needsBuild` is set so
122
+ * the presenter can hint the user to build first. Never throws.
123
+ */
124
+ export function scanZephyrLicenses(
125
+ runner: ZephyrLicensesRunner,
126
+ all = false,
127
+ ): ZephyrLicensesOutcome {
128
+ const { readFile, readdir } = runner;
129
+ const entries: LibraryLicenseEntry[] = [];
130
+
131
+ // 1. The Zephyr kernel ($ZEPHYR_BASE). Apache-2.0; resolved from its LICENSE.
132
+ if (runner.zephyrBase) {
133
+ entries.push(
134
+ resolveLibraryLicense(
135
+ { name: 'zephyr (kernel)', installDir: runner.zephyrBase },
136
+ readFile,
137
+ readdir,
138
+ // Zephyr's top-level LICENSE is the authoritative source; no manifest
139
+ // license field to consult, and scanning its source headers is noise.
140
+ { subdirs: [] },
141
+ ),
142
+ );
143
+ }
144
+
145
+ // 2. west manifest projects.
146
+ const modules = runner.listModules();
147
+ if (modules === null) {
148
+ if (entries.length > 0) return { ok: true, entries };
149
+ return {
150
+ ok: false,
151
+ reason: 'no-workspace',
152
+ message: '`west list` did not return a project list (not inside a west workspace?).',
153
+ };
154
+ }
155
+
156
+ // Project scope: keep only the modules the build actually linked.
157
+ let projectModules = modules;
158
+ let needsBuild = false;
159
+ if (!all) {
160
+ const cc = runner.compileCommands ? runner.compileCommands() : null;
161
+ if (cc === null) {
162
+ // No build — can't determine the linked set. Report the kernel only and
163
+ // flag it so the presenter prints a "build first" hint.
164
+ projectModules = [];
165
+ needsBuild = true;
166
+ } else {
167
+ projectModules = filterLinkedModules(cc, modules);
168
+ }
169
+ }
170
+
171
+ for (const m of projectModules) {
172
+ // Skip the Zephyr kernel itself (already added above by name) to avoid a
173
+ // duplicate row when it is also a manifest project.
174
+ if (runner.zephyrBase && path.resolve(m.abspath) === path.resolve(runner.zephyrBase)) {
175
+ continue;
176
+ }
177
+ entries.push(
178
+ resolveLibraryLicense({ name: m.name, installDir: m.abspath }, readFile, readdir, {
179
+ // Zephyr HAL modules commonly keep their LICENSE under a `zephyr/` or
180
+ // `src/` subdir (e.g. hal_nordic ships zephyr/LICENSE.txt). Check both
181
+ // alongside the root. (Source-header scanning is bounded — it only runs
182
+ // when no LICENSE file is found, capped at 6 files / 120 lines.)
183
+ subdirs: ['zephyr', 'src'],
184
+ }),
185
+ );
186
+ }
187
+
188
+ if (entries.length === 0) {
189
+ return {
190
+ ok: false,
191
+ reason: 'no-dependencies',
192
+ message: 'No Zephyr dependencies found to scan.',
193
+ };
194
+ }
195
+
196
+ entries.sort((a, b) => {
197
+ const r = RISK_RANK[a.risk] - RISK_RANK[b.risk];
198
+ if (r !== 0) return r;
199
+ return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
200
+ });
201
+ return { ok: true, entries, needsBuild };
202
+ }
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // Default runner — wires seams to discoverWest + westSpawn + fs
206
+ // ---------------------------------------------------------------------------
207
+
208
+ /**
209
+ * Find the build's compile_commands.json under cwd. Cuttlefish's Zephyr project
210
+ * root is the transpile output dir (e.g. `<cwd>/src/out`), so the build dir is
211
+ * `<root>/build` — not necessarily `<cwd>/build`. Check the common layouts,
212
+ * then fall back to a bounded search (skipping node_modules/.git/dist).
213
+ */
214
+ function findCompileCommandsText(readFile: ReadFile, readdir: ReadDir): string | null {
215
+ const direct = [
216
+ path.join(process.cwd(), 'build', 'compile_commands.json'),
217
+ path.join(process.cwd(), 'out', 'build', 'compile_commands.json'),
218
+ path.join(process.cwd(), 'src', 'out', 'build', 'compile_commands.json'),
219
+ ];
220
+ for (const c of direct) {
221
+ const text = readFile(c);
222
+ if (text) return text;
223
+ }
224
+ // Bounded recursive search for a build/compile_commands.json.
225
+ const isCc = (p: string) => {
226
+ const n = norm(p);
227
+ return n.endsWith('/build/compile_commands.json') || n.endsWith('\\build\\compile_commands.json');
228
+ };
229
+ let found: string | undefined;
230
+ const seen = new Set<string>();
231
+ const walk = (dir: string, depth: number): void => {
232
+ if (found || depth > 4 || seen.has(dir)) return;
233
+ seen.add(dir);
234
+ let entries: string[];
235
+ try {
236
+ entries = readdir(dir);
237
+ } catch {
238
+ return;
239
+ }
240
+ for (const e of entries) {
241
+ const full = path.join(dir, e);
242
+ if (e === 'compile_commands.json' && isCc(full)) {
243
+ found = full;
244
+ return;
245
+ }
246
+ }
247
+ for (const e of entries) {
248
+ if (found) return;
249
+ if (e === 'node_modules' || e === '.git' || e === 'dist' || e.startsWith('.')) continue;
250
+ walk(path.join(dir, e), depth + 1);
251
+ }
252
+ };
253
+ walk(process.cwd(), 0);
254
+ return found ? (readFile(found) ?? null) : null;
255
+ }
256
+
257
+ function defaultRunner(): ZephyrLicensesRunner | null {
258
+ const install = discoverWest();
259
+ if (!install) return null;
260
+ const zephyrBase = install.zephyrBase;
261
+
262
+ // `west topdir` prints the workspace root (one path line). Returns null when
263
+ // cwd is not inside a west workspace (west exits non-zero).
264
+ const topdir = (): string | null => {
265
+ try {
266
+ const inv = westSpawn(['topdir'], { encoding: 'utf8', timeout: 15_000 });
267
+ const r = spawnSync(inv.command, inv.args, inv.options);
268
+ const out = typeof r.stdout === 'string' ? r.stdout.trim() : '';
269
+ return r.status === 0 && out ? out : null;
270
+ } catch {
271
+ return null;
272
+ }
273
+ };
274
+
275
+ // `west list --format '{name}\t{abspath}'` → one project per line. Returns
276
+ // null on any spawn failure.
277
+ const listModules = (): { name: string; abspath: string }[] | null => {
278
+ try {
279
+ const inv = westSpawn(['list', '--format', '{name}\t{abspath}'], {
280
+ encoding: 'utf8',
281
+ timeout: 30_000,
282
+ });
283
+ const r = spawnSync(inv.command, inv.args, inv.options);
284
+ if (r.status !== 0) return null;
285
+ const out = typeof r.stdout === 'string' ? r.stdout : '';
286
+ const mods: { name: string; abspath: string }[] = [];
287
+ for (const line of out.split(/\r?\n/)) {
288
+ const trimmed = line.trim();
289
+ if (!trimmed) continue;
290
+ const [name, abspath] = trimmed.split('\t');
291
+ if (name && abspath) mods.push({ name, abspath });
292
+ }
293
+ return mods;
294
+ } catch {
295
+ return null;
296
+ }
297
+ };
298
+
299
+ const readFile: ReadFile = (p) => {
300
+ try {
301
+ return readFileSync(p, 'utf8');
302
+ } catch {
303
+ return undefined;
304
+ }
305
+ };
306
+ const readdir: ReadDir = (d) => {
307
+ try {
308
+ return readdirSync(d);
309
+ } catch {
310
+ return [];
311
+ }
312
+ };
313
+
314
+ return {
315
+ topdir,
316
+ listModules,
317
+ zephyrBase,
318
+ compileCommands: () => findCompileCommandsText(readFile, readdir),
319
+ readFile,
320
+ readdir,
321
+ };
322
+ }
323
+
324
+ // ---------------------------------------------------------------------------
325
+ // CLI presenter
326
+ // ---------------------------------------------------------------------------
327
+
328
+ let testRunner: ZephyrLicensesRunner | null | undefined;
329
+
330
+ /** @internal Test-only override of the default runner. Pass null to simulate west-not-found. */
331
+ export function __setLicensesRunnerForTest(runner: ZephyrLicensesRunner | null | undefined): void {
332
+ testRunner = runner;
333
+ }
334
+
335
+ /**
336
+ * `typecad-hal licenses` (Zephyr) presenter. Enumerates the Zephyr kernel +
337
+ * west manifest projects, resolves each one's license, classifies copyleft
338
+ * risk, and renders a sorted table. Warns on unknown licenses; sets
339
+ * process.exitCode under --strict when any strong-copyleft dependency is
340
+ * present. Never calls process.exit().
341
+ *
342
+ * Default scope = only the modules the firmware actually links (from the last
343
+ * `typecad-hal build`); `--all` lists every west manifest module.
344
+ */
345
+ export function runLicensesPresenter(strict: boolean, all: boolean): void {
346
+ ui.printHeader();
347
+ ui.printStep(
348
+ all
349
+ ? 'Checking licenses for every west manifest module'
350
+ : 'Checking licenses for this Zephyr project (linked dependencies only)',
351
+ );
352
+
353
+ // Surface the config (informational). Best-effort: a missing/malformed config
354
+ // never blocks the license scan.
355
+ let buildTarget: string | undefined;
356
+ try {
357
+ buildTarget = loadTypecadConfig(process.cwd())?.buildTarget;
358
+ } catch {
359
+ /* best-effort */
360
+ }
361
+ if (buildTarget) {
362
+ ui.printInfo(`Board target ... ${buildTarget}`);
363
+ }
364
+
365
+ const runner = testRunner !== undefined ? testRunner : defaultRunner();
366
+ if (!runner) {
367
+ ui.printError('west ............. NOT FOUND');
368
+ ui.printInfo(" → install west (pip install west) or run the typeCAD Zephyr installer.");
369
+ process.exitCode = 1;
370
+ return;
371
+ }
372
+
373
+ const result = scanZephyrLicenses(runner, all);
374
+ if (!result.ok) {
375
+ if (result.reason === 'no-workspace' || result.reason === 'no-dependencies') {
376
+ ui.printInfo(`(${result.message})`);
377
+ }
378
+ return;
379
+ }
380
+
381
+ // Project scope with no build: only the kernel is reported. Hint the user to
382
+ // build (which records which modules actually link) or use --all.
383
+ if (result.needsBuild) {
384
+ ui.printInfo(
385
+ '(no build found — only the Zephyr kernel is shown. Run `typecad-hal build` to scope ' +
386
+ 'this report to the modules your firmware actually links, or use `typecad-hal licenses --all` ' +
387
+ 'for every west module.)',
388
+ );
389
+ }
390
+
391
+ const counts = countByRisk(result.entries);
392
+ for (const e of result.entries) {
393
+ if (e.risk === 'unknown') {
394
+ ui.printWarning(`${e.name} .................. UNKNOWN`);
395
+ } else {
396
+ ui.printInfo(`${e.name} .................. ${e.spdx ?? 'UNKNOWN'}${riskBracket(e.risk)}${statusMark(e.risk)}`);
397
+ }
398
+ }
399
+ ui.printSuccess(
400
+ `${counts.permissive} permissive, ${counts['weak-copyleft']} weak copyleft, ` +
401
+ `${counts['strong-copyleft']} strong copyleft, ${counts.unknown} unknown`,
402
+ );
403
+
404
+ const unknowns = result.entries.filter((e) => e.risk === 'unknown');
405
+ if (unknowns.length > 0) {
406
+ ui.printWarning(
407
+ `License could not be determined for ${unknowns.length} ${unknowns.length === 1 ? 'dependency' : 'dependencies'}:`,
408
+ );
409
+ for (const u of unknowns) {
410
+ ui.printInfo(` ${u.name} (check LICENSE in ${u.path})`);
411
+ }
412
+ }
413
+
414
+ const strongCopyleft = result.entries.filter((e) => e.risk === 'strong-copyleft');
415
+ if (strongCopyleft.length > 0) {
416
+ ui.printWarning(
417
+ `${strongCopyleft.length} ${strongCopyleft.length === 1 ? 'dependency carries' : 'dependencies carry'} strong-copyleft terms — review before shipping.`,
418
+ );
419
+ }
420
+
421
+ // --strict fails the build on any unknown OR strong-copyleft dependency.
422
+ if ((unknowns.length > 0 || strongCopyleft.length > 0) && strict) {
423
+ process.exitCode = 1;
424
+ }
425
+ }