@typecad/cuttlefish 0.1.0-alpha.2 → 1.0.0-alpha.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -10
- package/dist/api/board-types.d.ts +1 -1
- package/dist/api/shared/hal-op-ir.d.ts +10 -1
- package/dist/api/shared/platform-strategy.d.ts +6 -0
- package/dist/api/shared/types.d.ts +8 -0
- package/dist/cli-utils.d.ts +1 -0
- package/dist/cli-utils.js +3 -1
- package/dist/cli.js +75 -0
- package/dist/create/index.d.ts +1 -1
- package/dist/create/index.js +1 -1
- package/dist/create/init-scaffold.js +48 -2
- package/dist/create/init-templates.d.ts +2 -0
- package/dist/create/init-templates.js +189 -9
- package/dist/emit/emitters/setup.js +122 -1
- package/dist/ir/adc-range-validation.js +26 -25
- package/dist/ir/expression-to-ir.js +43 -6
- package/dist/ir/hal/hal-plugins.js +10 -0
- package/dist/ir/interrupt-analysis.js +8 -3
- package/dist/ir/memory-budget-validation.js +1 -0
- package/dist/ir/ownership-analysis.js +19 -0
- package/dist/ir/peripheral-ownership.js +5 -0
- package/dist/ir/peripheral-validation.d.ts +1 -1
- package/dist/ir/peripheral-validation.js +6 -3
- package/dist/ir/pin-alias-conflict.d.ts +1 -1
- package/dist/ir/pin-alias-conflict.js +2 -1
- package/dist/ir/pin-capability-validation.js +34 -32
- package/dist/ir/pin-mode-validation.js +5 -0
- package/dist/ir/pin-safety.d.ts +1 -1
- package/dist/ir/pin-safety.js +2 -1
- package/dist/ir/program-analysis.d.ts +16 -0
- package/dist/ir/program-analysis.js +113 -0
- package/dist/ir/pulldown-validation.d.ts +1 -1
- package/dist/ir/pulldown-validation.js +2 -1
- package/dist/ir/pwm-timer-sharing.d.ts +1 -1
- package/dist/ir/pwm-timer-sharing.js +2 -1
- package/dist/ir/resource-analysis.js +2 -0
- package/dist/ir/timer0-pwm-timing-conflict.d.ts +1 -1
- package/dist/ir/timer0-pwm-timing-conflict.js +2 -1
- package/dist/ir/timing-validation.js +1 -0
- package/dist/ir/transformers/variables.js +46 -17
- package/dist/ir/try-catch-validation.js +2 -0
- package/dist/ir/type-resolution.js +2 -2
- package/dist/ir/unit-suspicion-validation.js +9 -7
- package/dist/ir/validation-orchestrator.js +6 -6
- package/dist/licenses.d.ts +185 -0
- package/dist/licenses.js +963 -0
- package/dist/testing.d.ts +1 -1
- package/dist/testing.js +1 -1
- package/dist/transpile.js +17 -9
- package/dist/types.d.ts +7 -1
- package/dist/utils/cli.js +44 -0
- package/package.json +5 -4
- package/dist/ir/heap-array-validation.d.ts +0 -24
- package/dist/ir/heap-array-validation.js +0 -29
package/dist/licenses.js
ADDED
|
@@ -0,0 +1,963 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// @typecad/cuttlefish — Arduino library license scanner
|
|
3
|
+
//
|
|
4
|
+
// Pure detection core for the `cuttlefish licenses` subcommand. Enumerates
|
|
5
|
+
// installed Arduino libraries, resolves each library's SPDX license from
|
|
6
|
+
// library.properties and/or the LICENSE file, classifies copyleft risk, and
|
|
7
|
+
// returns a sorted list. Never throws. The CLI presenter (runLicenses in
|
|
8
|
+
// cli.ts) renders the result and sets process.exitCode.
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
/**
|
|
11
|
+
* #include capture for both angle-bracket and quote forms. Returns the bare
|
|
12
|
+
* header name, e.g. '#include <Adafruit_GFX.h>' or '#include "Servo.h'" -> the
|
|
13
|
+
* captured header. Quote includes with a path separator (e.g. "./foo.h",
|
|
14
|
+
* "../util/bar.h") are project-relative and excluded by the second regex.
|
|
15
|
+
*/
|
|
16
|
+
const INCLUDE_RE = /^\s*#include\s*[<"]([^>"]+)[>"]\s*$/;
|
|
17
|
+
/** System/stdlib headers that are never Arduino libraries. Matched verbatim. */
|
|
18
|
+
const SYSTEM_HEADERS = new Set([
|
|
19
|
+
"Arduino.h",
|
|
20
|
+
"stdio.h",
|
|
21
|
+
"stdlib.h",
|
|
22
|
+
"string.h",
|
|
23
|
+
"stdint.h",
|
|
24
|
+
"Esp.h",
|
|
25
|
+
"math.h",
|
|
26
|
+
"avr/pgmspace.h",
|
|
27
|
+
]);
|
|
28
|
+
/**
|
|
29
|
+
* Header prefixes that come from the compiler toolchain's C library
|
|
30
|
+
* (avr-libc, newlib), not from any installable Arduino library. Such headers
|
|
31
|
+
* are present on the system (via the toolchain) and should be reported as
|
|
32
|
+
* CORE/TOOLCHAIN rather than NOT INSTALLED. Deliberately conservative — only
|
|
33
|
+
* confident patterns; anything else still surfaces as not-installed.
|
|
34
|
+
*/
|
|
35
|
+
const TOOLCHAIN_HEADER_PREFIXES = ["avr/", "util/"];
|
|
36
|
+
/** Classify a header as a compiler-toolchain C-library header. */
|
|
37
|
+
export function isToolchainHeader(header) {
|
|
38
|
+
return TOOLCHAIN_HEADER_PREFIXES.some((p) => header.toLowerCase().startsWith(p));
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Compare two version strings as semver (major.minor.patch). Falls back to
|
|
42
|
+
* lexical comparison when either isn't a clean semver.
|
|
43
|
+
*/
|
|
44
|
+
function compareVersion(a, b) {
|
|
45
|
+
const pa = a.split(".").map((n) => Number(n));
|
|
46
|
+
const pb = b.split(".").map((n) => Number(n));
|
|
47
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
48
|
+
const va = pa[i] ?? 0;
|
|
49
|
+
const vb = pb[i] ?? 0;
|
|
50
|
+
if (va !== vb)
|
|
51
|
+
return va - vb;
|
|
52
|
+
}
|
|
53
|
+
return a.localeCompare(b);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Resolve the project's board-core directory from its FQBN, via
|
|
57
|
+
* `arduino-cli config dump` (for the packages data dir) and the on-disk
|
|
58
|
+
* `<data>/packages/<packager>/hardware/<arch>/<version>/` layout. Returns the
|
|
59
|
+
* highest-versioned core dir. Never throws.
|
|
60
|
+
*
|
|
61
|
+
* `runConfigDump` is an injected seam (returns the `config dump` stdout, or ""
|
|
62
|
+
* on failure) so the function is unit-testable without spawning.
|
|
63
|
+
*/
|
|
64
|
+
export function resolveProjectCore(fqbn, runConfigDump, readdir) {
|
|
65
|
+
const coreId = deriveRequiredCore(fqbn);
|
|
66
|
+
if (!coreId) {
|
|
67
|
+
return { ok: false, reason: "no-fqbn", message: "No buildTarget (FQBN) in config." };
|
|
68
|
+
}
|
|
69
|
+
const [packager, arch] = coreId.split(":");
|
|
70
|
+
let dataDir;
|
|
71
|
+
try {
|
|
72
|
+
const stdout = runConfigDump();
|
|
73
|
+
if (stdout) {
|
|
74
|
+
const parsed = JSON.parse(stdout);
|
|
75
|
+
dataDir = parsed?.config?.directories?.data;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// fall through to no-core
|
|
80
|
+
}
|
|
81
|
+
if (!dataDir) {
|
|
82
|
+
return { ok: false, reason: "no-core", message: "Could not read arduino-cli data directory." };
|
|
83
|
+
}
|
|
84
|
+
const hardwareDir = path.join(dataDir, "packages", packager, "hardware", arch);
|
|
85
|
+
let versions;
|
|
86
|
+
try {
|
|
87
|
+
versions = readdir(hardwareDir);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return { ok: false, reason: "no-core", message: `No core versions at ${hardwareDir}.` };
|
|
91
|
+
}
|
|
92
|
+
if (versions.length === 0) {
|
|
93
|
+
return { ok: false, reason: "no-core", message: `No core versions at ${hardwareDir}.` };
|
|
94
|
+
}
|
|
95
|
+
versions.sort((a, b) => compareVersion(b, a)); // descending
|
|
96
|
+
return { ok: true, coreDir: path.join(hardwareDir, versions[0]) };
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Static driver -> library-header mapping for the config fallback. Mirrors the
|
|
100
|
+
* `includes` each display adapter emits (those strings are static per driver —
|
|
101
|
+
* verified against the adapters in api/shared/display-adapter*.ts). Kept here
|
|
102
|
+
* rather than calling generateDisplayAdapter() so the fallback doesn't need to
|
|
103
|
+
* construct a full ResolvedDisplay (which requires resolved mount pins).
|
|
104
|
+
*/
|
|
105
|
+
const DRIVER_HEADERS = {
|
|
106
|
+
ili9341: ["Adafruit_GFX.h", "Adafruit_ILI9341.h"],
|
|
107
|
+
st7796: ["Adafruit_GFX.h", "Adafruit_ST7796S.h"],
|
|
108
|
+
ssd1309: ["Adafruit_GFX.h", "Adafruit_SSD1306.h"],
|
|
109
|
+
ssd1680: ["Adafruit_EPD.h"],
|
|
110
|
+
sdl: [],
|
|
111
|
+
};
|
|
112
|
+
/**
|
|
113
|
+
* Built-in touch library -> header, mirroring generateTouchAdapter()'s includes.
|
|
114
|
+
*/
|
|
115
|
+
const TOUCH_HEADERS = {
|
|
116
|
+
XPT2046_Touchscreen: ["XPT2046_Touchscreen.h"],
|
|
117
|
+
Adafruit_TouchScreen: ["TouchScreen.h"],
|
|
118
|
+
Adafruit_STMPE610: ["Adafruit_STMPE610.h"],
|
|
119
|
+
FT6336U: ["Wire.h", "RAK14014_FT6336U.h"],
|
|
120
|
+
sdl: ["SDL2/SDL.h"],
|
|
121
|
+
};
|
|
122
|
+
/** Pull library header names out of an .ino's text. */
|
|
123
|
+
function parseInoHeaders(inoText) {
|
|
124
|
+
const headers = [];
|
|
125
|
+
for (const line of inoText.split(/\r?\n/)) {
|
|
126
|
+
const m = line.match(INCLUDE_RE);
|
|
127
|
+
if (m && !SYSTEM_HEADERS.has(m[1]))
|
|
128
|
+
headers.push(m[1]);
|
|
129
|
+
}
|
|
130
|
+
return headers;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Resolve the project's library headers. Prefers the generated .ino
|
|
134
|
+
* (authoritative); falls back to display/touch headers derived from config
|
|
135
|
+
* (partial picture, no transpile required).
|
|
136
|
+
*/
|
|
137
|
+
export function resolveProjectHeaders(config, readFile) {
|
|
138
|
+
if (!config) {
|
|
139
|
+
return { ok: false, reason: "no-config", message: "No cuttlefish.config.ts found." };
|
|
140
|
+
}
|
|
141
|
+
const configDir = path.dirname(config.configPath);
|
|
142
|
+
const entryBase = config.entry ? path.basename(config.entry).replace(/\.[tj]s$/, "") : "";
|
|
143
|
+
// 1. Prefer the generated .ino (authoritative). The outDir is resolved
|
|
144
|
+
// relative to the entry's directory, matching the transpile path
|
|
145
|
+
// (transpile.ts:280 -> outBaseDir defaults to the entry dir, and cli.ts
|
|
146
|
+
// resolves config.outputOutDir against inputDir = entry dir).
|
|
147
|
+
if (entryBase) {
|
|
148
|
+
const entryDir = path.dirname(path.resolve(configDir, config.entry));
|
|
149
|
+
const outDir = path.resolve(entryDir, config.outputOutDir ?? "./out");
|
|
150
|
+
const inoPath = path.join(outDir, entryBase, `${entryBase}.ino`);
|
|
151
|
+
const inoText = readFile(inoPath);
|
|
152
|
+
if (inoText) {
|
|
153
|
+
const parsed = parseInoHeaders(inoText);
|
|
154
|
+
// Drop project-local headers: a header co-located with the .ino (e.g. a
|
|
155
|
+
// cuttlefish-emitted polyfill like sht30.h) is project code, not a
|
|
156
|
+
// missing library — it must not be reported as NOT INSTALLED.
|
|
157
|
+
const inoDir = path.dirname(inoPath);
|
|
158
|
+
const headers = parsed.filter((h) => readFile(path.join(inoDir, h)) === undefined);
|
|
159
|
+
return { ok: true, headers, source: "ino", inoPath };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// 2. Fallback: derive display + touch headers from config (partial picture).
|
|
163
|
+
const headers = [];
|
|
164
|
+
const driver = config.display?.driver;
|
|
165
|
+
if (driver && DRIVER_HEADERS[driver]) {
|
|
166
|
+
headers.push(...DRIVER_HEADERS[driver]);
|
|
167
|
+
}
|
|
168
|
+
const touchLib = config.display?.touch?.library;
|
|
169
|
+
if (touchLib && TOUCH_HEADERS[touchLib]) {
|
|
170
|
+
headers.push(...TOUCH_HEADERS[touchLib]);
|
|
171
|
+
}
|
|
172
|
+
if (headers.length > 0) {
|
|
173
|
+
return { ok: true, headers, source: "config" };
|
|
174
|
+
}
|
|
175
|
+
// 3. Nothing to go on.
|
|
176
|
+
return {
|
|
177
|
+
ok: false,
|
|
178
|
+
reason: "no-entry",
|
|
179
|
+
message: entryBase
|
|
180
|
+
? "No generated .ino and no display config. Run 'cuttlefish build'."
|
|
181
|
+
: "No entry point in config.",
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Build a header-basename -> owning library map from the installed libraries,
|
|
186
|
+
* scanning each install_dir root and its `src/` subdir for .h/.hpp files.
|
|
187
|
+
*/
|
|
188
|
+
function buildHeaderIndex(libs, readdir) {
|
|
189
|
+
const index = new Map();
|
|
190
|
+
const dirs = (installDir) => [installDir, ...LICENSE_SUBDIRS.map((s) => path.join(installDir, s))];
|
|
191
|
+
for (const lib of libs) {
|
|
192
|
+
if (!lib.install_dir)
|
|
193
|
+
continue;
|
|
194
|
+
for (const dir of dirs(lib.install_dir)) {
|
|
195
|
+
let entries;
|
|
196
|
+
try {
|
|
197
|
+
entries = readdir(dir);
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
for (const entry of entries) {
|
|
203
|
+
const lower = entry.toLowerCase();
|
|
204
|
+
if (lower.endsWith(".h") || lower.endsWith(".hpp")) {
|
|
205
|
+
if (!index.has(entry))
|
|
206
|
+
index.set(entry, lib); // first-wins
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return index;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Build a header-basename -> bundled-library map from a board core's bundled
|
|
215
|
+
* libraries. Scans `<coreDir>/libraries/<Lib>/src/` for .h files (e.g. Wire.h,
|
|
216
|
+
* SPI.h). Each header maps to a synthetic RawArduinoLibrary so the existing
|
|
217
|
+
* resolveLibraryLicense can read its license from the bundled lib's header
|
|
218
|
+
* notice or library.properties.
|
|
219
|
+
*/
|
|
220
|
+
export function buildCoreHeaderIndex(coreDir, readdir) {
|
|
221
|
+
const index = new Map();
|
|
222
|
+
const libsDir = path.join(coreDir, "libraries");
|
|
223
|
+
let libNames;
|
|
224
|
+
try {
|
|
225
|
+
libNames = readdir(libsDir);
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
return index;
|
|
229
|
+
}
|
|
230
|
+
for (const libName of libNames) {
|
|
231
|
+
const srcDir = path.join(libsDir, libName, "src");
|
|
232
|
+
let headers;
|
|
233
|
+
try {
|
|
234
|
+
headers = readdir(srcDir);
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
const synthetic = {
|
|
240
|
+
name: libName,
|
|
241
|
+
install_dir: path.join(libsDir, libName),
|
|
242
|
+
};
|
|
243
|
+
for (const entry of headers) {
|
|
244
|
+
const lower = entry.toLowerCase();
|
|
245
|
+
if ((lower.endsWith(".h") || lower.endsWith(".hpp")) && !index.has(entry)) {
|
|
246
|
+
index.set(entry, synthetic);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return index;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Join each project header to its owning library or classify it. Pipeline:
|
|
254
|
+
* 1. user library (arduino-cli lib list) -> resolved (license)
|
|
255
|
+
* 2. core library (project's own core) -> resolved (license, e.g. LGPL-2.1)
|
|
256
|
+
* 3. toolchain header (avr/*, util/*) -> core (gray, no license)
|
|
257
|
+
* 4. else -> not-installed
|
|
258
|
+
*
|
|
259
|
+
* `coreDir` is optional; when absent, step 2 is skipped.
|
|
260
|
+
*/
|
|
261
|
+
export function joinHeadersToLibraries(headers, libs, readdir, readFile, coreDir) {
|
|
262
|
+
const userIndex = buildHeaderIndex(libs, readdir);
|
|
263
|
+
const coreIndex = coreDir ? buildCoreHeaderIndex(coreDir, readdir) : new Map();
|
|
264
|
+
return headers.map((header) => {
|
|
265
|
+
// 1. user library
|
|
266
|
+
const userOwner = userIndex.get(header);
|
|
267
|
+
if (userOwner) {
|
|
268
|
+
return { kind: "resolved", lib: resolveLibraryLicense(userOwner, readFile, readdir) };
|
|
269
|
+
}
|
|
270
|
+
// 2. core library
|
|
271
|
+
const coreOwner = coreIndex.get(header);
|
|
272
|
+
if (coreOwner) {
|
|
273
|
+
return { kind: "resolved", lib: resolveLibraryLicense(coreOwner, readFile, readdir) };
|
|
274
|
+
}
|
|
275
|
+
// 3. toolchain header
|
|
276
|
+
if (isToolchainHeader(header)) {
|
|
277
|
+
return { kind: "core", header };
|
|
278
|
+
}
|
|
279
|
+
// 4. not installed
|
|
280
|
+
return { kind: "not-installed", header };
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
const SPDX_TABLE = [
|
|
284
|
+
{
|
|
285
|
+
id: "MIT",
|
|
286
|
+
risk: "permissive",
|
|
287
|
+
aliases: ["MIT", "MIT-0", "Expat"],
|
|
288
|
+
markers: ["permission is hereby granted, free of charge"],
|
|
289
|
+
shortMarkers: ["mit licence", "mit license"],
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
id: "BSD-3-Clause",
|
|
293
|
+
risk: "permissive",
|
|
294
|
+
aliases: ["BSD-3", "BSD-3-Clause", "BSD", "New BSD"],
|
|
295
|
+
markers: [
|
|
296
|
+
"redistribution and use in source and binary forms",
|
|
297
|
+
"neither the name",
|
|
298
|
+
],
|
|
299
|
+
// Adafruit's header convention: "BSD license, all text here/above must be
|
|
300
|
+
// included in any redistribution." Adafruit declares these as BSD-3.
|
|
301
|
+
shortMarkers: ["bsd license, all text", "bsd license. all text"],
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
id: "BSD-2-Clause",
|
|
305
|
+
risk: "permissive",
|
|
306
|
+
aliases: ["BSD-2", "BSD-2-Clause", "FreeBSD"],
|
|
307
|
+
markers: [
|
|
308
|
+
"redistribution and use in source and binary forms",
|
|
309
|
+
"redistributions of source code must retain the above copyright notice",
|
|
310
|
+
],
|
|
311
|
+
shortMarkers: [],
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
id: "Apache-2.0",
|
|
315
|
+
risk: "permissive",
|
|
316
|
+
aliases: ["Apache-2.0", "Apache 2.0", "Apache-2", "ASL-2.0"],
|
|
317
|
+
markers: ["apache license", "version 2.0"],
|
|
318
|
+
// ArduinoHttpClient header: "Released under Apache License, version 2.0"
|
|
319
|
+
shortMarkers: ["apache license, version 2.0", "under apache license"],
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
id: "LGPL-2.1",
|
|
323
|
+
risk: "weak-copyleft",
|
|
324
|
+
aliases: ["LGPL-2.1", "LGPL-2.1-only", "LGPL-2.1-or-later", "Lesser GPL 2.1"],
|
|
325
|
+
markers: ["gnu lesser general public license", "version 2.1"],
|
|
326
|
+
// ESP32Servo header: "GNU Lesser General Public ... version 2.1"
|
|
327
|
+
shortMarkers: ["gnu lesser general public", "version 2.1"],
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
id: "LGPL-3.0",
|
|
331
|
+
risk: "weak-copyleft",
|
|
332
|
+
aliases: ["LGPL-3.0", "LGPL-3", "LGPL-3.0-only", "LGPL-3.0-or-later"],
|
|
333
|
+
markers: ["gnu lesser general public license", "version 3"],
|
|
334
|
+
shortMarkers: [],
|
|
335
|
+
},
|
|
336
|
+
{
|
|
337
|
+
id: "GPL-2.0",
|
|
338
|
+
risk: "strong-copyleft",
|
|
339
|
+
aliases: ["GPL-2.0", "GPL-2", "GPLv2", "GPL-2.0-only", "GPL-2.0-or-later"],
|
|
340
|
+
markers: ["gnu general public license", "version 2"],
|
|
341
|
+
shortMarkers: [],
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
id: "GPL-3.0",
|
|
345
|
+
risk: "strong-copyleft",
|
|
346
|
+
aliases: ["GPL-3.0", "GPL-3", "GPLv3", "GPL-3.0-only", "GPL-3.0-or-later"],
|
|
347
|
+
markers: ["gnu general public license", "version 3"],
|
|
348
|
+
shortMarkers: [],
|
|
349
|
+
},
|
|
350
|
+
{
|
|
351
|
+
id: "AGPL-3.0",
|
|
352
|
+
risk: "strong-copyleft",
|
|
353
|
+
aliases: ["AGPL-3.0", "AGPL-3", "Affero GPL 3", "AGPL-3.0-only", "AGPL-3.0-or-later"],
|
|
354
|
+
markers: ["gnu affero general public license"],
|
|
355
|
+
shortMarkers: [],
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
id: "Unlicense",
|
|
359
|
+
risk: "permissive",
|
|
360
|
+
aliases: ["Unlicense", "The Unlicense"],
|
|
361
|
+
markers: [
|
|
362
|
+
"this is free and unencumbered software released into the public domain",
|
|
363
|
+
],
|
|
364
|
+
shortMarkers: [],
|
|
365
|
+
},
|
|
366
|
+
{
|
|
367
|
+
id: "CC-BY-4.0",
|
|
368
|
+
risk: "permissive",
|
|
369
|
+
aliases: ["CC-BY-4.0", "Creative Commons Attribution 4.0", "cc by 4.0"],
|
|
370
|
+
markers: ["creative commons attribution 4.0"],
|
|
371
|
+
shortMarkers: [],
|
|
372
|
+
},
|
|
373
|
+
{
|
|
374
|
+
id: "CC-BY-SA-4.0",
|
|
375
|
+
risk: "strong-copyleft",
|
|
376
|
+
aliases: [
|
|
377
|
+
"CC-BY-SA-4.0",
|
|
378
|
+
"Creative Commons Attribution-ShareAlike 4.0",
|
|
379
|
+
"cc by-sa 4.0",
|
|
380
|
+
],
|
|
381
|
+
markers: ["creative commons attribution-sharealike 4.0"],
|
|
382
|
+
shortMarkers: [],
|
|
383
|
+
},
|
|
384
|
+
{
|
|
385
|
+
id: "CC-BY-NC-4.0",
|
|
386
|
+
risk: "strong-copyleft",
|
|
387
|
+
aliases: [
|
|
388
|
+
"CC-BY-NC-4.0",
|
|
389
|
+
"Creative Commons Attribution-NonCommercial 4.0",
|
|
390
|
+
"cc by-nc 4.0",
|
|
391
|
+
],
|
|
392
|
+
markers: ["creative commons attribution-noncommercial 4.0"],
|
|
393
|
+
shortMarkers: [],
|
|
394
|
+
},
|
|
395
|
+
];
|
|
396
|
+
// ---------------------------------------------------------------------------
|
|
397
|
+
// Pure SPDX detection
|
|
398
|
+
// ---------------------------------------------------------------------------
|
|
399
|
+
/**
|
|
400
|
+
* Normalize license input text for matching: trim, lowercase, strip surrounding
|
|
401
|
+
* quotes/whitespace.
|
|
402
|
+
*/
|
|
403
|
+
function normalize(input) {
|
|
404
|
+
return input.trim().toLowerCase().replace(/^["']|["']$/g, "");
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Resolve a raw license input (either the short `library.properties` `license=`
|
|
408
|
+
* value, the full text of a LICENSE file, or a source-file header comment) to a
|
|
409
|
+
* canonical SPDX ID.
|
|
410
|
+
*
|
|
411
|
+
* Matching priority:
|
|
412
|
+
* 1. SPDX-License-Identifier: <id> marker (authoritative when present)
|
|
413
|
+
* 2. exact alias match (suits the short properties value)
|
|
414
|
+
* 3. substring markers match, ALL markers required (suits full LICENSE text)
|
|
415
|
+
* 4. shortMarkers match, ANY one sufficient (suits sparse header comments
|
|
416
|
+
* like Adafruit's "BSD license, all text here must be included")
|
|
417
|
+
*
|
|
418
|
+
* Returns the SPDX ID string, or undefined if nothing matched.
|
|
419
|
+
*/
|
|
420
|
+
export function identifySpdx(input) {
|
|
421
|
+
const norm = normalize(input);
|
|
422
|
+
// 1. SPDX-License-Identifier marker — extract the id and alias-match it.
|
|
423
|
+
// Aliases are stored in their canonical case; compare lowercased.
|
|
424
|
+
const marker = norm.match(/spdx-license-identifier:\s*([^\s\n]+)/);
|
|
425
|
+
if (marker) {
|
|
426
|
+
const id = marker[1].toLowerCase();
|
|
427
|
+
for (const entry of SPDX_TABLE) {
|
|
428
|
+
if (entry.aliases.some((a) => a.toLowerCase() === id)) {
|
|
429
|
+
return entry.id;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
// 2. exact alias match (case-insensitive; `norm` is already lowercased).
|
|
434
|
+
for (const entry of SPDX_TABLE) {
|
|
435
|
+
if (entry.aliases.some((a) => a.toLowerCase() === norm)) {
|
|
436
|
+
return entry.id;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
// 3. substring markers — every marker phrase must appear.
|
|
440
|
+
// BSD-3 is listed before BSD-2 so its superset clauses (which contain
|
|
441
|
+
// "neither the name") win over BSD-2's subset.
|
|
442
|
+
for (const entry of SPDX_TABLE) {
|
|
443
|
+
if (entry.markers.every((m) => norm.includes(m))) {
|
|
444
|
+
return entry.id;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
// 4. shortMarkers — ANY one match is sufficient. Used for sparse header
|
|
448
|
+
// comments where the full license text is absent.
|
|
449
|
+
for (const entry of SPDX_TABLE) {
|
|
450
|
+
if (entry.shortMarkers.some((m) => norm.includes(m))) {
|
|
451
|
+
return entry.id;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return undefined;
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Classify the copyleft risk of a known SPDX ID. Returns "unknown" for
|
|
458
|
+
* unrecognized ids.
|
|
459
|
+
*/
|
|
460
|
+
export function classifyRisk(spdx) {
|
|
461
|
+
const entry = SPDX_TABLE.find((e) => e.id === spdx || e.aliases.includes(spdx));
|
|
462
|
+
return entry ? entry.risk : "unknown";
|
|
463
|
+
}
|
|
464
|
+
// ---------------------------------------------------------------------------
|
|
465
|
+
// Arduino library enumeration (via arduino-cli)
|
|
466
|
+
// ---------------------------------------------------------------------------
|
|
467
|
+
import { spawnSync } from "node:child_process";
|
|
468
|
+
import fs from "node:fs";
|
|
469
|
+
import path from "node:path";
|
|
470
|
+
/**
|
|
471
|
+
* Candidate LICENSE filenames checked case-insensitively. Includes the British
|
|
472
|
+
* "LICENCE" spelling (e.g. lvgl ships LICENCE.txt).
|
|
473
|
+
*/
|
|
474
|
+
const LICENSE_FILENAMES = [
|
|
475
|
+
"LICENSE",
|
|
476
|
+
"LICENSE.md",
|
|
477
|
+
"LICENSE.txt",
|
|
478
|
+
"LICENSE.markdown",
|
|
479
|
+
"LICENCE",
|
|
480
|
+
"LICENCE.md",
|
|
481
|
+
"LICENCE.txt",
|
|
482
|
+
"COPYING",
|
|
483
|
+
"COPYING.txt",
|
|
484
|
+
];
|
|
485
|
+
/**
|
|
486
|
+
* Subdirectories that commonly hold an Arduino library's license file or source
|
|
487
|
+
* headers when the root has neither. Arduino's own libraries (Ethernet,
|
|
488
|
+
* ArduinoHttpClient, ESP32Servo) keep sources under `src/`.
|
|
489
|
+
*/
|
|
490
|
+
const LICENSE_SUBDIRS = ["src"];
|
|
491
|
+
/**
|
|
492
|
+
* Read library.properties from a directory and return its `license=` value
|
|
493
|
+
* (raw, untrimmed) if present.
|
|
494
|
+
*/
|
|
495
|
+
function readPropertiesLicense(installDir, readFile) {
|
|
496
|
+
const text = readFile(path.join(installDir, "library.properties"));
|
|
497
|
+
if (!text)
|
|
498
|
+
return undefined;
|
|
499
|
+
for (const line of text.split(/\r?\n/)) {
|
|
500
|
+
const m = line.match(/^\s*license\s*=\s*(.*)$/);
|
|
501
|
+
if (m && m[1].trim())
|
|
502
|
+
return m[1];
|
|
503
|
+
}
|
|
504
|
+
return undefined;
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* Read the first LICENSE/COPYING file found in a directory and return its text.
|
|
508
|
+
* Checks the directory itself only (no recursion).
|
|
509
|
+
*/
|
|
510
|
+
function readLicenseFileIn(dir, readFile, readdir) {
|
|
511
|
+
const entries = new Set(readdir(dir).map((e) => e.toLowerCase()));
|
|
512
|
+
for (const candidate of LICENSE_FILENAMES) {
|
|
513
|
+
if (entries.has(candidate.toLowerCase())) {
|
|
514
|
+
return readFile(path.join(dir, candidate));
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
return undefined;
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Read the first LICENSE/COPYING file found in installDir or one of its common
|
|
521
|
+
* subdirectories (e.g. `src/`, where Arduino's own libraries keep it).
|
|
522
|
+
*/
|
|
523
|
+
function readLicenseFile(installDir, readFile, readdir) {
|
|
524
|
+
return (readLicenseFileIn(installDir, readFile, readdir) ??
|
|
525
|
+
(() => {
|
|
526
|
+
for (const sub of LICENSE_SUBDIRS) {
|
|
527
|
+
const text = readLicenseFileIn(path.join(installDir, sub), readFile, readdir);
|
|
528
|
+
if (text)
|
|
529
|
+
return text;
|
|
530
|
+
}
|
|
531
|
+
return undefined;
|
|
532
|
+
})());
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Extensions whose header comments may carry a license notice (Adafruit and
|
|
536
|
+
* many Arduino libs embed the license at the top of the primary source file
|
|
537
|
+
* rather than in a standalone LICENSE file).
|
|
538
|
+
*/
|
|
539
|
+
const HEADER_EXTENSIONS = [".h", ".hpp", ".cpp", ".c"];
|
|
540
|
+
/**
|
|
541
|
+
* Read the leading header comment of each candidate source file in installDir
|
|
542
|
+
* (and `src/`) and concatenate them, so the SPDX matcher can look for license
|
|
543
|
+
* phrases. The license notice usually lives in the file named after the
|
|
544
|
+
* library itself, so such files are scanned first; then a cap of further
|
|
545
|
+
* headers/sources is scanned to keep this cheap.
|
|
546
|
+
*/
|
|
547
|
+
function readSourceHeaders(libName, installDir, readFile, readdir) {
|
|
548
|
+
const dirs = [installDir, ...LICENSE_SUBDIRS.map((s) => path.join(installDir, s))];
|
|
549
|
+
// Normalize the library name into the stem its source files likely use:
|
|
550
|
+
// "Adafruit seesaw Library" -> "adafruit_seesaw".
|
|
551
|
+
const stem = libName.toLowerCase().replace(/\s+library$/, "").replace(/\s+/g, "_");
|
|
552
|
+
const chunks = [];
|
|
553
|
+
for (const dir of dirs) {
|
|
554
|
+
let entries;
|
|
555
|
+
try {
|
|
556
|
+
entries = readdir(dir);
|
|
557
|
+
}
|
|
558
|
+
catch {
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
const sources = entries
|
|
562
|
+
.filter((e) => HEADER_EXTENSIONS.some((ext) => e.toLowerCase().endsWith(ext)))
|
|
563
|
+
// Files whose basename starts with the library stem go first — that is
|
|
564
|
+
// where the license header conventionally lives.
|
|
565
|
+
.sort((a, b) => {
|
|
566
|
+
const aMatch = Number(a.toLowerCase().startsWith(stem));
|
|
567
|
+
const bMatch = Number(b.toLowerCase().startsWith(stem));
|
|
568
|
+
return bMatch - aMatch;
|
|
569
|
+
})
|
|
570
|
+
.slice(0, 6);
|
|
571
|
+
for (const src of sources) {
|
|
572
|
+
const text = readFile(path.join(dir, src));
|
|
573
|
+
if (text) {
|
|
574
|
+
// Take a generous leading window. Most licenses sit in the first few
|
|
575
|
+
// lines, but some .cpp files place the notice after a long copyright
|
|
576
|
+
// preamble (e.g. OneWire.cpp at ~line 99), so 120 lines covers it
|
|
577
|
+
// without reading whole large files.
|
|
578
|
+
chunks.push(text.split(/\r?\n/).slice(0, 120).join("\n"));
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
return chunks.length > 0 ? chunks.join("\n") : undefined;
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* Resolve a single library's license. Priority: library.properties → LICENSE
|
|
586
|
+
* file → none.
|
|
587
|
+
*/
|
|
588
|
+
function resolveLibraryLicense(lib, readFile, readdir) {
|
|
589
|
+
const installDir = lib.install_dir ?? "";
|
|
590
|
+
// 1. library.properties
|
|
591
|
+
const propsLicense = readPropertiesLicense(installDir, readFile);
|
|
592
|
+
if (propsLicense) {
|
|
593
|
+
const spdx = identifySpdx(propsLicense);
|
|
594
|
+
if (spdx) {
|
|
595
|
+
return {
|
|
596
|
+
name: lib.name,
|
|
597
|
+
version: lib.version,
|
|
598
|
+
path: installDir,
|
|
599
|
+
spdx,
|
|
600
|
+
risk: classifyRisk(spdx),
|
|
601
|
+
source: "library.properties",
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
// 2. LICENSE file (root or src/)
|
|
606
|
+
const fileText = readLicenseFile(installDir, readFile, readdir);
|
|
607
|
+
if (fileText) {
|
|
608
|
+
const spdx = identifySpdx(fileText);
|
|
609
|
+
if (spdx) {
|
|
610
|
+
return {
|
|
611
|
+
name: lib.name,
|
|
612
|
+
version: lib.version,
|
|
613
|
+
path: installDir,
|
|
614
|
+
spdx,
|
|
615
|
+
risk: classifyRisk(spdx),
|
|
616
|
+
source: "license-file",
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
// 3. source-file header comments (Adafruit/Arduino pattern: license notice
|
|
621
|
+
// embedded at the top of the primary .h/.cpp, no standalone LICENSE file).
|
|
622
|
+
const headerText = readSourceHeaders(lib.name, installDir, readFile, readdir);
|
|
623
|
+
if (headerText) {
|
|
624
|
+
const spdx = identifySpdx(headerText);
|
|
625
|
+
if (spdx) {
|
|
626
|
+
return {
|
|
627
|
+
name: lib.name,
|
|
628
|
+
version: lib.version,
|
|
629
|
+
path: installDir,
|
|
630
|
+
spdx,
|
|
631
|
+
risk: classifyRisk(spdx),
|
|
632
|
+
source: "source-header",
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
// 4. unknown
|
|
637
|
+
return {
|
|
638
|
+
name: lib.name,
|
|
639
|
+
version: lib.version,
|
|
640
|
+
path: installDir,
|
|
641
|
+
spdx: undefined,
|
|
642
|
+
risk: "unknown",
|
|
643
|
+
source: "none",
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
const RISK_RANK = {
|
|
647
|
+
"strong-copyleft": 0,
|
|
648
|
+
"weak-copyleft": 1,
|
|
649
|
+
permissive: 2,
|
|
650
|
+
unknown: 3,
|
|
651
|
+
};
|
|
652
|
+
/**
|
|
653
|
+
* Run `arduino-cli lib list --format json` and parse both known shapes into a
|
|
654
|
+
* flat list. Returns null on spawn failure or unparseable output (mirrors the
|
|
655
|
+
* null-on-error convention from framework-arduino/src/lib-discovery.ts).
|
|
656
|
+
*/
|
|
657
|
+
function listLibraries() {
|
|
658
|
+
try {
|
|
659
|
+
const result = spawnSync("arduino-cli", ["lib", "list", "--format", "json"], {
|
|
660
|
+
encoding: "utf8",
|
|
661
|
+
timeout: 30000,
|
|
662
|
+
});
|
|
663
|
+
if (result.error || result.status !== 0)
|
|
664
|
+
return null;
|
|
665
|
+
const output = result.stdout?.trim();
|
|
666
|
+
if (!output)
|
|
667
|
+
return null;
|
|
668
|
+
const parsed = JSON.parse(output);
|
|
669
|
+
return coerceLibList(parsed);
|
|
670
|
+
}
|
|
671
|
+
catch {
|
|
672
|
+
return null;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Normalize either JSON shape (wrapped or bare array) to a flat library list.
|
|
677
|
+
* Exported for direct unit testing of the dual-shape parsing.
|
|
678
|
+
*/
|
|
679
|
+
export function coerceLibList(parsed) {
|
|
680
|
+
if (Array.isArray(parsed)) {
|
|
681
|
+
return parsed;
|
|
682
|
+
}
|
|
683
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
684
|
+
const wrapped = parsed;
|
|
685
|
+
if (wrapped.installed_libraries && Array.isArray(wrapped.installed_libraries)) {
|
|
686
|
+
return wrapped.installed_libraries.map((item) => item.library);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
return [];
|
|
690
|
+
}
|
|
691
|
+
let testRunner;
|
|
692
|
+
/** @internal Test-only override of the default runner. */
|
|
693
|
+
export function __setLicensesRunnerForTest(runner) {
|
|
694
|
+
testRunner = runner;
|
|
695
|
+
}
|
|
696
|
+
/**
|
|
697
|
+
* Scan installed Arduino libraries and resolve each one's license. Never throws.
|
|
698
|
+
*/
|
|
699
|
+
export function scanLicenses(options) {
|
|
700
|
+
const listRunner = options?.fakeLibList ?? testRunner?.listLibraries ?? listLibraries;
|
|
701
|
+
const readFile = options?.fakeReadFile ??
|
|
702
|
+
testRunner?.readFile ??
|
|
703
|
+
((p) => {
|
|
704
|
+
try {
|
|
705
|
+
return fs.readFileSync(p, "utf8");
|
|
706
|
+
}
|
|
707
|
+
catch {
|
|
708
|
+
return undefined;
|
|
709
|
+
}
|
|
710
|
+
});
|
|
711
|
+
const readdir = options?.fakeReaddir ??
|
|
712
|
+
testRunner?.readdir ??
|
|
713
|
+
((d) => {
|
|
714
|
+
try {
|
|
715
|
+
return fs.readdirSync(d);
|
|
716
|
+
}
|
|
717
|
+
catch {
|
|
718
|
+
return [];
|
|
719
|
+
}
|
|
720
|
+
});
|
|
721
|
+
const libs = listRunner();
|
|
722
|
+
if (libs === null) {
|
|
723
|
+
return {
|
|
724
|
+
ok: false,
|
|
725
|
+
reason: "arduino-cli-unresponsive",
|
|
726
|
+
message: "arduino-cli did not return a library list.",
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
if (libs.length === 0) {
|
|
730
|
+
return {
|
|
731
|
+
ok: false,
|
|
732
|
+
reason: "no-libraries",
|
|
733
|
+
message: "No Arduino libraries are installed.",
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
const entries = libs
|
|
737
|
+
.filter((lib) => lib.name && lib.install_dir)
|
|
738
|
+
.map((lib) => resolveLibraryLicense(lib, readFile, readdir));
|
|
739
|
+
entries.sort((a, b) => {
|
|
740
|
+
const r = RISK_RANK[a.risk] - RISK_RANK[b.risk];
|
|
741
|
+
if (r !== 0)
|
|
742
|
+
return r;
|
|
743
|
+
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
|
744
|
+
});
|
|
745
|
+
return { ok: true, libraries: entries };
|
|
746
|
+
}
|
|
747
|
+
// ---------------------------------------------------------------------------
|
|
748
|
+
// CLI presenter
|
|
749
|
+
// ---------------------------------------------------------------------------
|
|
750
|
+
// Imported here (not in cli.ts) so the presenter is unit-testable without
|
|
751
|
+
// importing the binary entry module cli.ts, which has a shebang and runs
|
|
752
|
+
// main() at import time.
|
|
753
|
+
import * as ui from "./utils/ui.js";
|
|
754
|
+
import { loadCuttlefishConfig } from "./config-loader.js";
|
|
755
|
+
import { deriveRequiredCore } from "@typecad/arduino-cli";
|
|
756
|
+
let testProjectConfig;
|
|
757
|
+
/** @internal Test-only override of the project config (normally loaded via loadCuttlefishConfig). */
|
|
758
|
+
export function __setProjectConfigForTest(config) {
|
|
759
|
+
testProjectConfig = config;
|
|
760
|
+
}
|
|
761
|
+
let testConfigDump;
|
|
762
|
+
/** @internal Test-only override of `arduino-cli config dump` stdout. */
|
|
763
|
+
export function __setConfigDumpForTest(fn) {
|
|
764
|
+
testConfigDump = fn;
|
|
765
|
+
}
|
|
766
|
+
/**
|
|
767
|
+
* `cuttlefish licenses` presenter. `all === false` (default, project scope)
|
|
768
|
+
* resolves this project's libraries from the generated .ino (or config
|
|
769
|
+
* fallback), joins each to an installed library, and reports only those.
|
|
770
|
+
* `all === true` reports every installed library (the original behavior).
|
|
771
|
+
* Warns on unknown licenses; flags NOT INSTALLED headers in project scope; sets
|
|
772
|
+
* process.exitCode under --strict. Never calls process.exit().
|
|
773
|
+
*/
|
|
774
|
+
export function runLicensesPresenter(strict, all) {
|
|
775
|
+
ui.printHeader();
|
|
776
|
+
if (all) {
|
|
777
|
+
runAllScope(strict);
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
runProjectScope(strict);
|
|
781
|
+
}
|
|
782
|
+
/** Original system-wide behavior: scan every installed library. */
|
|
783
|
+
function runAllScope(strict) {
|
|
784
|
+
ui.printStep("Checking licenses for all installed Arduino libraries");
|
|
785
|
+
renderAllLicenses(scanLicenses(), strict);
|
|
786
|
+
}
|
|
787
|
+
/** Project scope: resolve this project's headers, join, render. */
|
|
788
|
+
function runProjectScope(strict) {
|
|
789
|
+
const config = testProjectConfig ?? loadProjectConfig();
|
|
790
|
+
const readFile = testRunner?.readFile ?? makeDefaultReadFile();
|
|
791
|
+
const headers = resolveProjectHeaders(config, readFile);
|
|
792
|
+
if (!headers.ok) {
|
|
793
|
+
ui.printStep("Checking licenses for this project");
|
|
794
|
+
if (headers.reason === "no-config") {
|
|
795
|
+
ui.printInfo(`(no cuttlefish.config.ts found — run from a project dir, or use 'cuttlefish licenses --all')`);
|
|
796
|
+
}
|
|
797
|
+
else {
|
|
798
|
+
ui.printInfo(`(${headers.message})`);
|
|
799
|
+
}
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
const label = headers.source === "ino" && headers.inoPath
|
|
803
|
+
? `Checking licenses for this project (from ${relFromCwd(headers.inoPath)})`
|
|
804
|
+
: "Checking licenses for this project (from cuttlefish.config.ts — run 'cuttlefish build' for the full set)";
|
|
805
|
+
ui.printStep(label);
|
|
806
|
+
const listRunner = testRunner?.listLibraries ?? listLibraries;
|
|
807
|
+
const libs = listRunner();
|
|
808
|
+
if (libs === null) {
|
|
809
|
+
ui.printError(`arduino-cli .... NOT FOUND or unresponsive`);
|
|
810
|
+
process.exitCode = 1;
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
const readdir = testRunner?.readdir ?? makeDefaultReaddir();
|
|
814
|
+
// Resolve the project's board core (for core-bundled libs like Wire). Best-effort:
|
|
815
|
+
// on any failure, skip the core-index step.
|
|
816
|
+
const configDump = testConfigDump ?? makeDefaultConfigDump();
|
|
817
|
+
const core = resolveProjectCore(config?.buildTarget, configDump, readdir);
|
|
818
|
+
const coreDir = core.ok ? core.coreDir : undefined;
|
|
819
|
+
const project = joinHeadersToLibraries(headers.headers, libs, readdir, readFile, coreDir);
|
|
820
|
+
const resolved = project.filter((p) => p.kind === "resolved");
|
|
821
|
+
const notInstalled = project.filter((p) => p.kind === "not-installed");
|
|
822
|
+
// Sort resolved by risk, then name.
|
|
823
|
+
resolved.sort((a, b) => {
|
|
824
|
+
const r = RISK_RANK[a.lib.risk] - RISK_RANK[b.lib.risk];
|
|
825
|
+
if (r !== 0)
|
|
826
|
+
return r;
|
|
827
|
+
return a.lib.name.toLowerCase().localeCompare(b.lib.name.toLowerCase());
|
|
828
|
+
});
|
|
829
|
+
for (const r of resolved) {
|
|
830
|
+
const lib = r.lib;
|
|
831
|
+
if (lib.risk === "unknown") {
|
|
832
|
+
ui.printWarning(`${lib.name} .................. UNKNOWN`);
|
|
833
|
+
}
|
|
834
|
+
else {
|
|
835
|
+
ui.printInfo(`${lib.name} .................. ${lib.spdx ?? "UNKNOWN"}${riskBracket(lib.risk)}${statusMark(lib.risk)}`);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
for (const ni of notInstalled) {
|
|
839
|
+
ui.printError(`${ni.header} .................. NOT INSTALLED`);
|
|
840
|
+
}
|
|
841
|
+
const coreHeaders = project.filter((p) => p.kind === "core");
|
|
842
|
+
for (const c of coreHeaders) {
|
|
843
|
+
ui.printInfo(`${c.header} ... CORE/TOOLCHAIN`);
|
|
844
|
+
}
|
|
845
|
+
const counts = countByRisk(resolved.map((r) => r.lib));
|
|
846
|
+
ui.printSuccess(`${counts.permissive} permissive, ${counts["weak-copyleft"]} weak copyleft, ` +
|
|
847
|
+
`${counts["strong-copyleft"]} strong copyleft, ${counts.unknown} unknown` +
|
|
848
|
+
(notInstalled.length > 0 ? `; ${notInstalled.length} not installed` : ""));
|
|
849
|
+
const unknowns = resolved.filter((r) => r.lib.risk === "unknown").map((r) => r.lib);
|
|
850
|
+
if (unknowns.length > 0) {
|
|
851
|
+
ui.printWarning(`License could not be determined for ${unknowns.length} ${unknowns.length === 1 ? "library" : "libraries"}:`);
|
|
852
|
+
for (const u of unknowns) {
|
|
853
|
+
ui.printInfo(` ${u.name} (check library.properties or LICENSE in ${u.path})`);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
if (notInstalled.length > 0) {
|
|
857
|
+
ui.printError(`${notInstalled.length} project ${notInstalled.length === 1 ? "dependency is" : "dependencies are"} not installed:`);
|
|
858
|
+
for (const ni of notInstalled) {
|
|
859
|
+
ui.printInfo(` ${ni.header} (no installed Arduino library provides this header)`);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
if ((unknowns.length > 0 || notInstalled.length > 0) && strict) {
|
|
863
|
+
process.exitCode = 1;
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
/** Render the --all scope's scanLicenses outcome. */
|
|
867
|
+
function renderAllLicenses(result, strict) {
|
|
868
|
+
if (!result.ok) {
|
|
869
|
+
if (result.reason === "arduino-cli-unresponsive") {
|
|
870
|
+
ui.printError(`arduino-cli .... NOT FOUND or unresponsive`);
|
|
871
|
+
process.exitCode = 1;
|
|
872
|
+
}
|
|
873
|
+
else {
|
|
874
|
+
ui.printInfo(`(no libraries installed — nothing to scan)`);
|
|
875
|
+
}
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
const counts = countByRisk(result.libraries);
|
|
879
|
+
for (const lib of result.libraries) {
|
|
880
|
+
if (lib.risk === "unknown") {
|
|
881
|
+
ui.printWarning(`${lib.name} .................. UNKNOWN`);
|
|
882
|
+
}
|
|
883
|
+
else {
|
|
884
|
+
ui.printInfo(`${lib.name} .................. ${lib.spdx ?? "UNKNOWN"}${riskBracket(lib.risk)}${statusMark(lib.risk)}`);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
ui.printSuccess(`${counts.permissive} permissive, ${counts["weak-copyleft"]} weak copyleft, ` +
|
|
888
|
+
`${counts["strong-copyleft"]} strong copyleft, ${counts.unknown} unknown`);
|
|
889
|
+
const unknowns = result.libraries.filter((l) => l.risk === "unknown");
|
|
890
|
+
if (unknowns.length > 0) {
|
|
891
|
+
ui.printWarning(`License could not be determined for ${unknowns.length} ${unknowns.length === 1 ? "library" : "libraries"}:`);
|
|
892
|
+
for (const u of unknowns) {
|
|
893
|
+
ui.printInfo(` ${u.name} (check library.properties or LICENSE in ${u.path})`);
|
|
894
|
+
}
|
|
895
|
+
if (strict)
|
|
896
|
+
process.exitCode = 1;
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
// Small helpers used by both scopes.
|
|
900
|
+
function riskBracket(risk) {
|
|
901
|
+
if (risk === "strong-copyleft")
|
|
902
|
+
return " [COPYLEFT]";
|
|
903
|
+
if (risk === "weak-copyleft")
|
|
904
|
+
return " [weak copyleft]";
|
|
905
|
+
return "";
|
|
906
|
+
}
|
|
907
|
+
function statusMark(risk) {
|
|
908
|
+
return risk === "permissive" ? " ✓" : "";
|
|
909
|
+
}
|
|
910
|
+
function countByRisk(libs) {
|
|
911
|
+
const counts = {
|
|
912
|
+
permissive: 0,
|
|
913
|
+
"weak-copyleft": 0,
|
|
914
|
+
"strong-copyleft": 0,
|
|
915
|
+
unknown: 0,
|
|
916
|
+
};
|
|
917
|
+
for (const l of libs)
|
|
918
|
+
counts[l.risk] += 1;
|
|
919
|
+
return counts;
|
|
920
|
+
}
|
|
921
|
+
function relFromCwd(p) {
|
|
922
|
+
return path.relative(process.cwd(), p) || p;
|
|
923
|
+
}
|
|
924
|
+
function loadProjectConfig() {
|
|
925
|
+
// loadCuttlefishConfig walks up from cwd for cuttlefish.config.ts.
|
|
926
|
+
return loadCuttlefishConfig(process.cwd());
|
|
927
|
+
}
|
|
928
|
+
function makeDefaultReadFile() {
|
|
929
|
+
return (p) => {
|
|
930
|
+
try {
|
|
931
|
+
return fs.readFileSync(p, "utf8");
|
|
932
|
+
}
|
|
933
|
+
catch {
|
|
934
|
+
return undefined;
|
|
935
|
+
}
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
function makeDefaultReaddir() {
|
|
939
|
+
return (d) => {
|
|
940
|
+
try {
|
|
941
|
+
return fs.readdirSync(d);
|
|
942
|
+
}
|
|
943
|
+
catch {
|
|
944
|
+
return [];
|
|
945
|
+
}
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
function makeDefaultConfigDump() {
|
|
949
|
+
return () => {
|
|
950
|
+
try {
|
|
951
|
+
const result = spawnSync("arduino-cli", ["config", "dump", "--format", "json"], {
|
|
952
|
+
encoding: "utf8",
|
|
953
|
+
timeout: 30000,
|
|
954
|
+
});
|
|
955
|
+
if (result.error || result.status !== 0)
|
|
956
|
+
return "";
|
|
957
|
+
return result.stdout ?? "";
|
|
958
|
+
}
|
|
959
|
+
catch {
|
|
960
|
+
return "";
|
|
961
|
+
}
|
|
962
|
+
};
|
|
963
|
+
}
|