@setzkasten/cli 0.1.0-rc.4 → 0.1.0-rc.5

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 CHANGED
@@ -7,6 +7,8 @@ CLI-first tool for font license governance, audit logging, and deterministic pol
7
7
  - Writes an append-only event log (`.setzkasten/events.log`)
8
8
  - Adds/removes font entries
9
9
  - Scans controlled local assets for usage signals
10
+ - Discovers likely license files and computes deterministic `document_hash` values
11
+ - Links license evidence files to existing license instances (`evidence add`)
10
12
  - Evaluates policy decisions (`allow`, `warn`, `escalate`)
11
13
  - Generates deterministic quote output
12
14
  - Provides a migration stub command
@@ -21,11 +23,21 @@ npm install -g @setzkasten/cli
21
23
  setzkasten init --name "My Project"
22
24
  setzkasten add --font-id inter --family "Inter" --source oss
23
25
  setzkasten scan --path . --discover
26
+ setzkasten evidence add --license-id lic_inter_001 --file ./licenses/OFL.txt
24
27
  setzkasten policy
25
28
  setzkasten quote
26
29
  setzkasten migrate
27
30
  ```
28
31
 
32
+ ## License Evidence Workflow
33
+ 1. Run `setzkasten scan --path . --discover` to list discovered fonts and likely license files.
34
+ 2. Review `result.discovered_license_files` in JSON output (`path`, `document_hash`, `detected_license`, `matched_font_ids`).
35
+ 3. Link the local license file to a license instance:
36
+ ```bash
37
+ setzkasten evidence add --license-id <license_id> --file <path-to-license-file>
38
+ ```
39
+ 4. Run `setzkasten policy` to verify BYO evidence state.
40
+
29
41
  ## Data written locally
30
42
  - `LICENSE_MANIFEST.json`
31
43
  - `.setzkasten/events.log`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@setzkasten/cli",
3
- "version": "0.1.0-rc.4",
3
+ "version": "0.1.0-rc.5",
4
4
  "description": "Setzkasten CLI for font license governance and audit trails.",
5
5
  "private": false,
6
6
  "type": "module",
package/src/index.js CHANGED
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { realpathSync } from "node:fs";
3
- import { access } from "node:fs/promises";
3
+ import { access, readFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import process from "node:process";
6
6
  import { fileURLToPath } from "node:url";
7
- import { MANIFEST_FILENAME, parseListFlag, slugifyId } from "./lib/core.js";
7
+ import { MANIFEST_FILENAME, parseListFlag, sha256Hex, slugifyId } from "./lib/core.js";
8
8
  import { appendProjectEvent } from "./lib/events.js";
9
9
  import {
10
10
  addFontToManifest,
@@ -13,6 +13,7 @@ import {
13
13
  loadManifest,
14
14
  removeFontFromManifest,
15
15
  saveManifest,
16
+ upsertLicenseEvidence,
16
17
  } from "./lib/manifest-lib.js";
17
18
  import { evaluatePolicy } from "./lib/policy.js";
18
19
  import { generateQuote } from "./lib/quote.js";
@@ -28,7 +29,8 @@ Commands:
28
29
  init Create ${MANIFEST_FILENAME} and .setzkasten/events.log
29
30
  add Add font entry to manifest
30
31
  remove Remove font entry from manifest
31
- scan Scan local repository usage and optionally discover font files
32
+ scan Scan local repository usage and optionally discover font/license files
33
+ evidence Attach/update license evidence from local files
32
34
  policy Evaluate policy decision (allow|warn|escalate)
33
35
  quote Generate deterministic quote from license schema data
34
36
  migrate Generate migration stub plan
@@ -37,13 +39,20 @@ Common options:
37
39
  --manifest <path> Explicit path to ${MANIFEST_FILENAME}
38
40
  Scan options:
39
41
  --path <dir> Directory to scan (default: project root)
40
- --discover Discover existing font files (woff2/woff/ttf/otf/otc)
41
- --max-discovered-files <n> Max discovered files in output (default: 200)
42
+ --discover Discover existing font and license files
43
+ --max-discovered-files <n> Max discovered font files in output (default: 200)
44
+ --max-discovered-license-files <n> Max discovered license files in output (default: 200)
45
+ Evidence options:
46
+ setzkasten evidence add --license-id <id> --file <path>
47
+ [--type <type>] [--evidence-id <id>] [--document-name <name>]
48
+ [--document-url <uri>] [--reference <id>] [--issuer <name>]
49
+ [--purchased-at <iso-date-time>] [--notes <text>]
42
50
 
43
51
  Examples:
44
52
  setzkasten init --name "Acme Project"
45
53
  setzkasten add --font-id inter --family "Inter" --source oss
46
54
  setzkasten scan --path . --discover
55
+ setzkasten evidence add --license-id lic_web_001 --file ./licenses/OFL.txt
47
56
  setzkasten policy --fail-on escalate
48
57
  `;
49
58
 
@@ -339,6 +348,7 @@ async function handleScan(cwd, flags) {
339
348
  const scanRoot = path.resolve(cwd, getStringFlag(flags, "path") ?? projectRoot);
340
349
  const maxMatchedPaths = Number(getStringFlag(flags, "max-matched-paths") ?? "30");
341
350
  const maxDiscoveredFiles = Number(getStringFlag(flags, "max-discovered-files") ?? "200");
351
+ const maxDiscoveredLicenseFiles = Number(getStringFlag(flags, "max-discovered-license-files") ?? "200");
342
352
  const discover = getBooleanFlag(flags, "discover");
343
353
 
344
354
  const scanResult = await scanProject({
@@ -346,6 +356,7 @@ async function handleScan(cwd, flags) {
346
356
  manifest,
347
357
  maxMatchedPathsPerFont: Number.isFinite(maxMatchedPaths) ? maxMatchedPaths : 30,
348
358
  maxDiscoveredFiles: Number.isFinite(maxDiscoveredFiles) ? maxDiscoveredFiles : 200,
359
+ maxDiscoveredLicenseFiles: Number.isFinite(maxDiscoveredLicenseFiles) ? maxDiscoveredLicenseFiles : 200,
349
360
  discover,
350
361
  });
351
362
 
@@ -362,6 +373,9 @@ async function handleScan(cwd, flags) {
362
373
  discovered_font_files_count: Array.isArray(scanResult.discovered_font_files)
363
374
  ? scanResult.discovered_font_files.length
364
375
  : 0,
376
+ discovered_license_files_count: Array.isArray(scanResult.discovered_license_files)
377
+ ? scanResult.discovered_license_files.length
378
+ : 0,
365
379
  discover_enabled: discover,
366
380
  root_path: scanResult.root_path,
367
381
  },
@@ -376,6 +390,75 @@ async function handleScan(cwd, flags) {
376
390
  return 0;
377
391
  }
378
392
 
393
+ async function handleEvidenceAdd(cwd, flags) {
394
+ const manifestPath = resolveManifestPathFromFlag(cwd, flags);
395
+ const { manifest, manifestPath: resolvedManifestPath, projectRoot } = await loadManifest({
396
+ cwd,
397
+ manifestPath,
398
+ });
399
+
400
+ const licenseId = requireStringFlag(flags, "license-id");
401
+ const documentPath = path.resolve(cwd, requireStringFlag(flags, "file"));
402
+
403
+ let documentBuffer;
404
+ try {
405
+ documentBuffer = await readFile(documentPath);
406
+ } catch {
407
+ throw new Error(`Could not read evidence file at ${documentPath}.`);
408
+ }
409
+
410
+ const upsertResult = upsertLicenseEvidence(manifest, {
411
+ licenseId,
412
+ evidenceId: getStringFlag(flags, "evidence-id"),
413
+ type: getStringFlag(flags, "type") ?? "other",
414
+ documentHash: sha256Hex(documentBuffer),
415
+ documentName: getStringFlag(flags, "document-name") ?? path.basename(documentPath),
416
+ documentPath: path.relative(projectRoot, documentPath),
417
+ documentUrl: getStringFlag(flags, "document-url"),
418
+ reference: getStringFlag(flags, "reference"),
419
+ issuer: getStringFlag(flags, "issuer"),
420
+ purchasedAt: getStringFlag(flags, "purchased-at"),
421
+ notes: getStringFlag(flags, "notes"),
422
+ });
423
+
424
+ await saveManifest(resolvedManifestPath, upsertResult.manifest);
425
+
426
+ await appendProjectEvent({
427
+ projectRoot,
428
+ projectId: getManifestProjectId(upsertResult.manifest),
429
+ eventType: "manifest.license_ref_added",
430
+ payload: {
431
+ action: upsertResult.action,
432
+ license_id: upsertResult.license_id,
433
+ evidence_id: upsertResult.evidence.evidence_id,
434
+ evidence_type: upsertResult.evidence.type,
435
+ document_hash: upsertResult.evidence.document_hash,
436
+ document_name: upsertResult.evidence.document_name ?? path.basename(documentPath),
437
+ file_path: path.relative(projectRoot, documentPath),
438
+ },
439
+ });
440
+
441
+ printJson({
442
+ ok: true,
443
+ command: "evidence",
444
+ action: "add",
445
+ manifest_path: resolvedManifestPath,
446
+ result: upsertResult,
447
+ });
448
+
449
+ return 0;
450
+ }
451
+
452
+ async function handleEvidence(cwd, flags, positionals) {
453
+ const action = positionals[0] ?? "add";
454
+
455
+ if (action !== "add") {
456
+ throw new Error(`Unknown evidence action '${action}'. Supported: add`);
457
+ }
458
+
459
+ return handleEvidenceAdd(cwd, flags);
460
+ }
461
+
379
462
  async function handlePolicy(cwd, flags) {
380
463
  const manifestPath = resolveManifestPathFromFlag(cwd, flags);
381
464
  const { manifest, projectRoot } = await loadManifest({
@@ -490,6 +573,8 @@ export async function runCli(argv = process.argv.slice(2), cwd = process.cwd())
490
573
  return handleRemove(cwd, parsed.flags);
491
574
  case "scan":
492
575
  return handleScan(cwd, parsed.flags);
576
+ case "evidence":
577
+ return handleEvidence(cwd, parsed.flags, parsed.positionals);
493
578
  case "policy":
494
579
  return handlePolicy(cwd, parsed.flags);
495
580
  case "quote":
@@ -16,6 +16,7 @@ const SOURCE_TYPES = new Set(["oss", "byo"]);
16
16
  const OFFERING_TYPES = new Set(["commercial", "trial"]);
17
17
  const INSTANCE_STATUS = new Set(["active", "expired", "superseded", "revoked"]);
18
18
  const ACQUISITION_SOURCES = new Set(["direct_foundry", "reseller", "marketplace", "legacy"]);
19
+ const SHA256_PATTERN = /^[A-Fa-f0-9]{64}$/;
19
20
 
20
21
  function isObject(value) {
21
22
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
@@ -164,7 +165,7 @@ function validateEvidence(errors, pathName, value) {
164
165
  });
165
166
  validateString(errors, `${pathName}.type`, value.type, { minLength: 1 });
166
167
  validateString(errors, `${pathName}.document_hash`, value.document_hash, {
167
- pattern: /^[A-Fa-f0-9]{64}$/,
168
+ pattern: SHA256_PATTERN,
168
169
  });
169
170
  }
170
171
 
@@ -322,7 +323,7 @@ function validateLicenseInstance(errors, pathName, value) {
322
323
 
323
324
  validateString(errors, `${pathName}.status`, value.status, { enum: INSTANCE_STATUS });
324
325
 
325
- const hasEvidence = validateArray(errors, `${pathName}.evidence`, value.evidence, { minItems: 1 });
326
+ const hasEvidence = validateArray(errors, `${pathName}.evidence`, value.evidence, { minItems: 0 });
326
327
  if (hasEvidence) {
327
328
  for (let index = 0; index < value.evidence.length; index += 1) {
328
329
  validateEvidence(errors, `${pathName}.evidence[${index}]`, value.evidence[index]);
@@ -556,6 +557,15 @@ function normalizeStringArray(value) {
556
557
  return value.filter((entry) => typeof entry === "string");
557
558
  }
558
559
 
560
+ function normalizeOptionalString(value) {
561
+ if (typeof value !== "string") {
562
+ return undefined;
563
+ }
564
+
565
+ const normalized = value.trim();
566
+ return normalized.length > 0 ? normalized : undefined;
567
+ }
568
+
559
569
  export function addFontToManifest(manifest, font) {
560
570
  const draft = deepClone(manifest);
561
571
  const fonts = Array.isArray(draft.fonts) ? draft.fonts : [];
@@ -591,6 +601,137 @@ export function removeFontFromManifest(manifest, fontId) {
591
601
  };
592
602
  }
593
603
 
604
+ export function upsertLicenseEvidence(manifest, input) {
605
+ const licenseId = normalizeOptionalString(input?.licenseId);
606
+ if (!licenseId) {
607
+ throw new Error("licenseId is required.");
608
+ }
609
+
610
+ const documentHash = normalizeOptionalString(input?.documentHash);
611
+ if (!documentHash || !SHA256_PATTERN.test(documentHash)) {
612
+ throw new Error("documentHash must be a 64-char sha256 hex string.");
613
+ }
614
+
615
+ const draft = deepClone(manifest);
616
+ const instances = Array.isArray(draft.license_instances) ? draft.license_instances : [];
617
+ const instanceIndex = instances.findIndex(
618
+ (entry) => isObject(entry) && typeof entry.license_id === "string" && entry.license_id === licenseId,
619
+ );
620
+
621
+ if (instanceIndex < 0) {
622
+ throw new Error(`License instance '${licenseId}' not found in manifest.`);
623
+ }
624
+
625
+ const instance = instances[instanceIndex];
626
+ const currentEvidence = Array.isArray(instance.evidence) ? instance.evidence : [];
627
+
628
+ const evidenceType = normalizeOptionalString(input?.type) ?? "other";
629
+ const documentName =
630
+ normalizeOptionalString(input?.documentName) ??
631
+ normalizeOptionalString(input?.documentPath) ??
632
+ "license-document";
633
+ const requestedEvidenceId = normalizeOptionalString(input?.evidenceId);
634
+
635
+ if (requestedEvidenceId && !ID_PATTERN.test(requestedEvidenceId)) {
636
+ throw new Error("evidenceId has invalid format.");
637
+ }
638
+
639
+ const generatedEvidenceId = slugifyId(`${path.parse(documentName).name}-${documentHash.slice(0, 8)}`, "evidence");
640
+ const evidenceId = requestedEvidenceId ?? generatedEvidenceId;
641
+
642
+ const nextEvidence = {
643
+ evidence_id: evidenceId,
644
+ type: evidenceType,
645
+ document_hash: documentHash,
646
+ };
647
+
648
+ const reference = normalizeOptionalString(input?.reference);
649
+ if (reference) {
650
+ nextEvidence.reference = reference;
651
+ }
652
+
653
+ const issuer = normalizeOptionalString(input?.issuer);
654
+ if (issuer) {
655
+ nextEvidence.issuer = issuer;
656
+ }
657
+
658
+ const documentUrl = normalizeOptionalString(input?.documentUrl);
659
+ if (documentUrl) {
660
+ try {
661
+ new URL(documentUrl);
662
+ } catch {
663
+ throw new Error("documentUrl must be a valid URI.");
664
+ }
665
+ nextEvidence.document_url = documentUrl;
666
+ }
667
+
668
+ const purchasedAt = normalizeOptionalString(input?.purchasedAt);
669
+ if (purchasedAt) {
670
+ nextEvidence.purchased_at = purchasedAt;
671
+ }
672
+
673
+ const notes = normalizeOptionalString(input?.notes);
674
+ if (notes) {
675
+ nextEvidence.notes = notes;
676
+ }
677
+
678
+ const normalizedDocumentName = normalizeOptionalString(input?.documentName);
679
+ if (normalizedDocumentName) {
680
+ nextEvidence.document_name = normalizedDocumentName;
681
+ }
682
+
683
+ let action = "added";
684
+ let appliedEvidence = nextEvidence;
685
+
686
+ const byIdIndex = currentEvidence.findIndex(
687
+ (entry) => isObject(entry) && typeof entry.evidence_id === "string" && entry.evidence_id === evidenceId,
688
+ );
689
+
690
+ if (byIdIndex >= 0) {
691
+ appliedEvidence = {
692
+ ...currentEvidence[byIdIndex],
693
+ ...nextEvidence,
694
+ };
695
+ currentEvidence[byIdIndex] = appliedEvidence;
696
+ action = "updated";
697
+ } else {
698
+ const byHashIndex = currentEvidence.findIndex(
699
+ (entry) =>
700
+ isObject(entry) &&
701
+ typeof entry.document_hash === "string" &&
702
+ entry.document_hash.toLowerCase() === documentHash.toLowerCase(),
703
+ );
704
+
705
+ if (byHashIndex >= 0) {
706
+ const existing = currentEvidence[byHashIndex];
707
+ appliedEvidence = {
708
+ ...existing,
709
+ ...nextEvidence,
710
+ evidence_id:
711
+ typeof existing.evidence_id === "string" && existing.evidence_id.length > 0
712
+ ? existing.evidence_id
713
+ : evidenceId,
714
+ };
715
+ currentEvidence[byHashIndex] = appliedEvidence;
716
+ action = "updated";
717
+ } else {
718
+ currentEvidence.push(nextEvidence);
719
+ action = "added";
720
+ }
721
+ }
722
+
723
+ instance.evidence = currentEvidence;
724
+ instances[instanceIndex] = instance;
725
+ draft.license_instances = instances;
726
+
727
+ return {
728
+ manifest: draft,
729
+ action,
730
+ license_id: licenseId,
731
+ evidence: appliedEvidence,
732
+ };
733
+ }
734
+
594
735
  export function getManifestProjectId(manifest) {
595
736
  if (!isObject(manifest.project)) {
596
737
  throw new Error("manifest.project must be an object.");
@@ -1,6 +1,6 @@
1
1
  import { readdir, readFile, stat } from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { nowIso, slugifyId } from "./core.js";
3
+ import { nowIso, sha256Hex, slugifyId } from "./core.js";
4
4
 
5
5
  const DEFAULT_IGNORED_DIRS = new Set([
6
6
  ".git",
@@ -31,6 +31,8 @@ const TEXT_FILE_EXTENSIONS = new Set([
31
31
  ]);
32
32
 
33
33
  const FONT_FILE_EXTENSIONS = new Set([".woff2", ".woff", ".ttf", ".otf", ".otc"]);
34
+ const LICENSE_FILE_EXTENSIONS = new Set(["", ".txt", ".md", ".pdf", ".rtf", ".html", ".htm"]);
35
+ const LICENSE_FILE_NAME_PATTERN = /(license|licence|eula|ofl|fontlog|copying|copyright)/i;
34
36
 
35
37
  const STYLE_TOKENS = new Set([
36
38
  "regular",
@@ -71,9 +73,26 @@ function shouldDiscoverFontFile(filePath) {
71
73
  return FONT_FILE_EXTENSIONS.has(extension);
72
74
  }
73
75
 
76
+ function shouldDiscoverLicenseFile(filePath) {
77
+ const extension = path.extname(filePath).toLowerCase();
78
+ const fileName = path.basename(filePath).toLowerCase();
79
+
80
+ if (!LICENSE_FILE_EXTENSIONS.has(extension)) {
81
+ return false;
82
+ }
83
+
84
+ return LICENSE_FILE_NAME_PATTERN.test(fileName);
85
+ }
86
+
87
+ function isLikelyTextLicenseFile(filePath) {
88
+ const extension = path.extname(filePath).toLowerCase();
89
+ return extension === "" || extension === ".txt" || extension === ".md" || extension === ".html" || extension === ".htm";
90
+ }
91
+
74
92
  async function collectProjectFiles(rootPath) {
75
93
  const textFiles = [];
76
94
  const fontFiles = [];
95
+ const licenseFiles = [];
77
96
 
78
97
  async function walk(dirPath) {
79
98
  const entries = await readdir(dirPath, { withFileTypes: true });
@@ -109,6 +128,10 @@ async function collectProjectFiles(rootPath) {
109
128
  if (shouldDiscoverFontFile(fullPath)) {
110
129
  fontFiles.push(fullPath);
111
130
  }
131
+
132
+ if (shouldDiscoverLicenseFile(fullPath)) {
133
+ licenseFiles.push(fullPath);
134
+ }
112
135
  }
113
136
  }
114
137
 
@@ -117,6 +140,7 @@ async function collectProjectFiles(rootPath) {
117
140
  return {
118
141
  textFiles,
119
142
  fontFiles,
143
+ licenseFiles,
120
144
  };
121
145
  }
122
146
 
@@ -174,11 +198,103 @@ function discoverFontFiles(rootPath, fontFiles, maxDiscoveredFiles) {
174
198
  .slice(0, maxDiscoveredFiles);
175
199
  }
176
200
 
201
+ function detectLicenseKind(fileName, contentLower) {
202
+ if (contentLower) {
203
+ if (
204
+ contentLower.includes("sil open font license") ||
205
+ contentLower.includes("ofl version 1.1") ||
206
+ contentLower.includes("scripts.sil.org/ofl")
207
+ ) {
208
+ return "sil_ofl_1_1";
209
+ }
210
+
211
+ if (contentLower.includes("apache license") && contentLower.includes("version 2.0")) {
212
+ return "apache_2_0";
213
+ }
214
+
215
+ if (contentLower.includes("mit license")) {
216
+ return "mit";
217
+ }
218
+
219
+ if (contentLower.includes("gnu general public license")) {
220
+ return "gpl";
221
+ }
222
+
223
+ if (contentLower.includes("mozilla public license")) {
224
+ return "mpl";
225
+ }
226
+
227
+ if (contentLower.includes("eula") || contentLower.includes("end user license agreement")) {
228
+ return "eula";
229
+ }
230
+ }
231
+
232
+ if (fileName.toLowerCase().includes("ofl")) {
233
+ return "sil_ofl_1_1";
234
+ }
235
+
236
+ if (fileName.toLowerCase().includes("eula")) {
237
+ return "eula";
238
+ }
239
+
240
+ return null;
241
+ }
242
+
243
+ function matchFontIdsFromLicenseContent(fonts, contentLower) {
244
+ if (!contentLower) {
245
+ return [];
246
+ }
247
+
248
+ return fonts
249
+ .filter((font) => contentLower.includes(font.family_name.toLowerCase()))
250
+ .map((font) => font.font_id)
251
+ .sort((a, b) => a.localeCompare(b));
252
+ }
253
+
254
+ async function discoverLicenseFiles(rootPath, licenseFiles, fonts, maxDiscoveredLicenseFiles) {
255
+ const discovered = [];
256
+
257
+ for (const filePath of licenseFiles) {
258
+ let fileBuffer;
259
+ let fileStat;
260
+
261
+ try {
262
+ fileBuffer = await readFile(filePath);
263
+ fileStat = await stat(filePath);
264
+ } catch {
265
+ continue;
266
+ }
267
+
268
+ const extension = path.extname(filePath).toLowerCase();
269
+ const fileName = path.basename(filePath);
270
+ const relativePath = relativeTo(rootPath, filePath);
271
+ const contentLower =
272
+ isLikelyTextLicenseFile(filePath) && fileBuffer.length <= 512 * 1024
273
+ ? fileBuffer.toString("utf8").toLowerCase()
274
+ : null;
275
+
276
+ discovered.push({
277
+ path: relativePath,
278
+ extension,
279
+ file_name: fileName,
280
+ size_bytes: fileStat.size,
281
+ document_hash: sha256Hex(fileBuffer),
282
+ detected_license: detectLicenseKind(fileName, contentLower),
283
+ matched_font_ids: matchFontIdsFromLicenseContent(fonts, contentLower),
284
+ });
285
+ }
286
+
287
+ return discovered
288
+ .sort((a, b) => a.path.localeCompare(b.path))
289
+ .slice(0, maxDiscoveredLicenseFiles);
290
+ }
291
+
177
292
  export async function scanProject(input) {
178
293
  const rootPath = path.resolve(input.rootPath);
179
294
  const maxMatchedPathsPerFont = input.maxMatchedPathsPerFont ?? 30;
180
295
  const discover = input.discover === true;
181
296
  const maxDiscoveredFiles = input.maxDiscoveredFiles ?? 200;
297
+ const maxDiscoveredLicenseFiles = input.maxDiscoveredLicenseFiles ?? 200;
182
298
 
183
299
  const fonts = normalizeFonts(input.manifest)
184
300
  .map((font) => ({
@@ -187,7 +303,7 @@ export async function scanProject(input) {
187
303
  }))
188
304
  .filter((font) => font.font_id.length > 0 && font.family_name.length > 0);
189
305
 
190
- const { textFiles, fontFiles } = await collectProjectFiles(rootPath);
306
+ const { textFiles, fontFiles, licenseFiles } = await collectProjectFiles(rootPath);
191
307
  const matches = new Map();
192
308
 
193
309
  for (const font of fonts) {
@@ -227,18 +343,38 @@ export async function scanProject(input) {
227
343
  }
228
344
  }
229
345
 
346
+ const discoveredLicenseFiles = discover
347
+ ? await discoverLicenseFiles(rootPath, licenseFiles, fonts, maxDiscoveredLicenseFiles)
348
+ : [];
349
+
230
350
  return {
231
351
  scanned_at: nowIso(),
232
352
  root_path: rootPath,
233
353
  scanned_files_count: textFiles.length,
234
354
  font_matches: Object.fromEntries(Array.from(matches.entries())),
235
355
  discovered_font_files: discover ? discoverFontFiles(rootPath, fontFiles, maxDiscoveredFiles) : [],
356
+ discovered_license_files: discoveredLicenseFiles,
236
357
  };
237
358
  }
238
359
 
239
360
  export function applyScanResultToManifest(manifest, scanResult) {
240
361
  const draft = deepClone(manifest);
241
362
  const fonts = Array.isArray(draft.fonts) ? draft.fonts : [];
363
+ const licenseMatchesByFont = new Map();
364
+
365
+ const discoveredLicenseFiles = Array.isArray(scanResult.discovered_license_files)
366
+ ? scanResult.discovered_license_files
367
+ : [];
368
+
369
+ for (const licenseFile of discoveredLicenseFiles) {
370
+ const matchedFontIds = Array.isArray(licenseFile.matched_font_ids) ? licenseFile.matched_font_ids : [];
371
+ for (const fontId of matchedFontIds) {
372
+ if (!licenseMatchesByFont.has(fontId)) {
373
+ licenseMatchesByFont.set(fontId, []);
374
+ }
375
+ licenseMatchesByFont.get(fontId).push(licenseFile.path);
376
+ }
377
+ }
242
378
 
243
379
  for (const font of fonts) {
244
380
  const fontId = typeof font.font_id === "string" ? font.font_id : "";
@@ -250,12 +386,16 @@ export function applyScanResultToManifest(manifest, scanResult) {
250
386
  const currentUsage =
251
387
  font.usage && typeof font.usage === "object" && !Array.isArray(font.usage) ? font.usage : {};
252
388
 
389
+ const matchedLicensePaths = licenseMatchesByFont.get(fontId) ?? [];
390
+
253
391
  font.usage = {
254
392
  ...currentUsage,
255
393
  scan: {
256
394
  scanned_at: scanResult.scanned_at,
257
395
  match_count: match?.match_count ?? 0,
258
396
  matched_paths: match?.matched_paths ?? [],
397
+ license_match_count: matchedLicensePaths.length,
398
+ license_matched_paths: matchedLicensePaths.slice(0, 30),
259
399
  },
260
400
  };
261
401
  }