@setzkasten/cli 0.1.0-rc.4 → 0.1.0-rc.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 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,23 @@ 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 font-adjacent 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
+
41
+ Dependency directories such as `node_modules` and `vendor` are ignored during scans by default.
42
+
29
43
  ## Data written locally
30
44
  - `LICENSE_MANIFEST.json`
31
45
  - `.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.6",
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 files and font-adjacent 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,10 +1,12 @@
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",
7
7
  "node_modules",
8
+ "vendor",
9
+ "bower_components",
8
10
  ".setzkasten",
9
11
  "dist",
10
12
  "coverage",
@@ -31,6 +33,10 @@ const TEXT_FILE_EXTENSIONS = new Set([
31
33
  ]);
32
34
 
33
35
  const FONT_FILE_EXTENSIONS = new Set([".woff2", ".woff", ".ttf", ".otf", ".otc"]);
36
+ const LICENSE_FILE_EXTENSIONS = new Set(["", ".txt", ".md", ".pdf", ".rtf", ".html", ".htm"]);
37
+ const LICENSE_FILE_NAME_PATTERN = /(license|licence|eula|ofl|fontlog|copying|copyright)/i;
38
+ const FONT_PATH_SEGMENT_PATTERN = /(^|[\\/])(fonts?|typefaces?)([\\/]|$)/i;
39
+ const FONT_LICENSE_ANCESTOR_DEPTH = 4;
34
40
 
35
41
  const STYLE_TOKENS = new Set([
36
42
  "regular",
@@ -57,6 +63,10 @@ function deepClone(value) {
57
63
  return JSON.parse(JSON.stringify(value));
58
64
  }
59
65
 
66
+ function escapeRegex(input) {
67
+ return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
68
+ }
69
+
60
70
  function normalizeFonts(manifest) {
61
71
  return Array.isArray(manifest.fonts) ? manifest.fonts : [];
62
72
  }
@@ -71,9 +81,26 @@ function shouldDiscoverFontFile(filePath) {
71
81
  return FONT_FILE_EXTENSIONS.has(extension);
72
82
  }
73
83
 
84
+ function shouldDiscoverLicenseFile(filePath) {
85
+ const extension = path.extname(filePath).toLowerCase();
86
+ const fileName = path.basename(filePath).toLowerCase();
87
+
88
+ if (!LICENSE_FILE_EXTENSIONS.has(extension)) {
89
+ return false;
90
+ }
91
+
92
+ return LICENSE_FILE_NAME_PATTERN.test(fileName);
93
+ }
94
+
95
+ function isLikelyTextLicenseFile(filePath) {
96
+ const extension = path.extname(filePath).toLowerCase();
97
+ return extension === "" || extension === ".txt" || extension === ".md" || extension === ".html" || extension === ".htm";
98
+ }
99
+
74
100
  async function collectProjectFiles(rootPath) {
75
101
  const textFiles = [];
76
102
  const fontFiles = [];
103
+ const licenseFiles = [];
77
104
 
78
105
  async function walk(dirPath) {
79
106
  const entries = await readdir(dirPath, { withFileTypes: true });
@@ -109,6 +136,10 @@ async function collectProjectFiles(rootPath) {
109
136
  if (shouldDiscoverFontFile(fullPath)) {
110
137
  fontFiles.push(fullPath);
111
138
  }
139
+
140
+ if (shouldDiscoverLicenseFile(fullPath)) {
141
+ licenseFiles.push(fullPath);
142
+ }
112
143
  }
113
144
  }
114
145
 
@@ -117,6 +148,7 @@ async function collectProjectFiles(rootPath) {
117
148
  return {
118
149
  textFiles,
119
150
  fontFiles,
151
+ licenseFiles,
120
152
  };
121
153
  }
122
154
 
@@ -155,6 +187,33 @@ function guessFamilyNameFromFile(filePath) {
155
187
  return tokens.map((token) => toTitleCaseToken(token)).join(" ");
156
188
  }
157
189
 
190
+ function createFamilyNamePattern(familyName) {
191
+ const normalized = familyName.trim().toLowerCase().replace(/\s+/g, " ");
192
+ if (normalized.length === 0) {
193
+ return null;
194
+ }
195
+
196
+ const tokens = normalized.split(" ").map((token) => token.trim()).filter(Boolean);
197
+ if (tokens.length === 0) {
198
+ return null;
199
+ }
200
+
201
+ const tokenPattern = tokens.map((token) => escapeRegex(token)).join("[\\s_-]+");
202
+ return new RegExp(`(^|[^a-z0-9])${tokenPattern}([^a-z0-9]|$)`, "i");
203
+ }
204
+
205
+ function containsFamilyName(contentLower, font) {
206
+ if (!contentLower.includes(font.family_name_lower)) {
207
+ return false;
208
+ }
209
+
210
+ if (!font.family_name_pattern) {
211
+ return true;
212
+ }
213
+
214
+ return font.family_name_pattern.test(contentLower);
215
+ }
216
+
158
217
  function discoverFontFiles(rootPath, fontFiles, maxDiscoveredFiles) {
159
218
  return fontFiles
160
219
  .map((filePath) => {
@@ -174,20 +233,160 @@ function discoverFontFiles(rootPath, fontFiles, maxDiscoveredFiles) {
174
233
  .slice(0, maxDiscoveredFiles);
175
234
  }
176
235
 
236
+ function detectLicenseKind(fileName, contentLower) {
237
+ if (contentLower) {
238
+ if (
239
+ contentLower.includes("sil open font license") ||
240
+ contentLower.includes("ofl version 1.1") ||
241
+ contentLower.includes("scripts.sil.org/ofl")
242
+ ) {
243
+ return "sil_ofl_1_1";
244
+ }
245
+
246
+ if (contentLower.includes("apache license") && contentLower.includes("version 2.0")) {
247
+ return "apache_2_0";
248
+ }
249
+
250
+ if (contentLower.includes("mit license")) {
251
+ return "mit";
252
+ }
253
+
254
+ if (contentLower.includes("gnu general public license")) {
255
+ return "gpl";
256
+ }
257
+
258
+ if (contentLower.includes("mozilla public license")) {
259
+ return "mpl";
260
+ }
261
+
262
+ if (contentLower.includes("eula") || contentLower.includes("end user license agreement")) {
263
+ return "eula";
264
+ }
265
+ }
266
+
267
+ if (fileName.toLowerCase().includes("ofl")) {
268
+ return "sil_ofl_1_1";
269
+ }
270
+
271
+ if (fileName.toLowerCase().includes("eula")) {
272
+ return "eula";
273
+ }
274
+
275
+ return null;
276
+ }
277
+
278
+ function matchFontIdsFromLicenseContent(fonts, contentLower) {
279
+ if (!contentLower) {
280
+ return [];
281
+ }
282
+
283
+ return fonts
284
+ .filter((font) => containsFamilyName(contentLower, font))
285
+ .map((font) => font.font_id)
286
+ .sort((a, b) => a.localeCompare(b));
287
+ }
288
+
289
+ function collectFontAdjacentDirs(rootPath, fontFiles) {
290
+ const root = path.resolve(rootPath);
291
+ const dirs = new Set();
292
+
293
+ for (const filePath of fontFiles) {
294
+ let currentDir = path.resolve(path.dirname(filePath));
295
+
296
+ for (let depth = 0; depth <= FONT_LICENSE_ANCESTOR_DEPTH; depth += 1) {
297
+ dirs.add(currentDir);
298
+
299
+ if (currentDir === root) {
300
+ break;
301
+ }
302
+
303
+ const parentDir = path.dirname(currentDir);
304
+ if (parentDir === currentDir || parentDir === root) {
305
+ break;
306
+ }
307
+
308
+ currentDir = parentDir;
309
+ }
310
+ }
311
+
312
+ return dirs;
313
+ }
314
+
315
+ function filterLicenseFilesForFontContext(rootPath, licenseFiles, fontFiles) {
316
+ if (licenseFiles.length === 0 || fontFiles.length === 0) {
317
+ return [];
318
+ }
319
+
320
+ const adjacentDirs = collectFontAdjacentDirs(rootPath, fontFiles);
321
+ return licenseFiles.filter((filePath) => {
322
+ const fileDir = path.resolve(path.dirname(filePath));
323
+ if (adjacentDirs.has(fileDir)) {
324
+ return true;
325
+ }
326
+
327
+ return FONT_PATH_SEGMENT_PATTERN.test(relativeTo(rootPath, filePath));
328
+ });
329
+ }
330
+
331
+ async function discoverLicenseFiles(rootPath, licenseFiles, fonts, maxDiscoveredLicenseFiles) {
332
+ const discovered = [];
333
+
334
+ for (const filePath of licenseFiles) {
335
+ let fileBuffer;
336
+ let fileStat;
337
+
338
+ try {
339
+ fileBuffer = await readFile(filePath);
340
+ fileStat = await stat(filePath);
341
+ } catch {
342
+ continue;
343
+ }
344
+
345
+ const extension = path.extname(filePath).toLowerCase();
346
+ const fileName = path.basename(filePath);
347
+ const relativePath = relativeTo(rootPath, filePath);
348
+ const contentLower =
349
+ isLikelyTextLicenseFile(filePath) && fileBuffer.length <= 512 * 1024
350
+ ? fileBuffer.toString("utf8").toLowerCase()
351
+ : null;
352
+
353
+ discovered.push({
354
+ path: relativePath,
355
+ extension,
356
+ file_name: fileName,
357
+ size_bytes: fileStat.size,
358
+ document_hash: sha256Hex(fileBuffer),
359
+ detected_license: detectLicenseKind(fileName, contentLower),
360
+ matched_font_ids: matchFontIdsFromLicenseContent(fonts, contentLower),
361
+ });
362
+ }
363
+
364
+ return discovered
365
+ .sort((a, b) => a.path.localeCompare(b.path))
366
+ .slice(0, maxDiscoveredLicenseFiles);
367
+ }
368
+
177
369
  export async function scanProject(input) {
178
370
  const rootPath = path.resolve(input.rootPath);
179
371
  const maxMatchedPathsPerFont = input.maxMatchedPathsPerFont ?? 30;
180
372
  const discover = input.discover === true;
181
373
  const maxDiscoveredFiles = input.maxDiscoveredFiles ?? 200;
374
+ const maxDiscoveredLicenseFiles = input.maxDiscoveredLicenseFiles ?? 200;
182
375
 
183
376
  const fonts = normalizeFonts(input.manifest)
184
377
  .map((font) => ({
185
378
  font_id: typeof font.font_id === "string" ? font.font_id : "",
186
379
  family_name: typeof font.family_name === "string" ? font.family_name : "",
380
+ family_name_lower: typeof font.family_name === "string" ? font.family_name.toLowerCase() : "",
381
+ family_name_pattern:
382
+ typeof font.family_name === "string" ? createFamilyNamePattern(font.family_name) : null,
187
383
  }))
188
384
  .filter((font) => font.font_id.length > 0 && font.family_name.length > 0);
189
385
 
190
- const { textFiles, fontFiles } = await collectProjectFiles(rootPath);
386
+ const { textFiles, fontFiles, licenseFiles } = await collectProjectFiles(rootPath);
387
+ const candidateLicenseFiles = discover
388
+ ? filterLicenseFilesForFontContext(rootPath, licenseFiles, fontFiles)
389
+ : [];
191
390
  const matches = new Map();
192
391
 
193
392
  for (const font of fonts) {
@@ -211,7 +410,7 @@ export async function scanProject(input) {
211
410
  const lowerContent = content.toLowerCase();
212
411
 
213
412
  for (const font of fonts) {
214
- if (!lowerContent.includes(font.family_name.toLowerCase())) {
413
+ if (!containsFamilyName(lowerContent, font)) {
215
414
  continue;
216
415
  }
217
416
 
@@ -227,18 +426,38 @@ export async function scanProject(input) {
227
426
  }
228
427
  }
229
428
 
429
+ const discoveredLicenseFiles = discover
430
+ ? await discoverLicenseFiles(rootPath, candidateLicenseFiles, fonts, maxDiscoveredLicenseFiles)
431
+ : [];
432
+
230
433
  return {
231
434
  scanned_at: nowIso(),
232
435
  root_path: rootPath,
233
436
  scanned_files_count: textFiles.length,
234
437
  font_matches: Object.fromEntries(Array.from(matches.entries())),
235
438
  discovered_font_files: discover ? discoverFontFiles(rootPath, fontFiles, maxDiscoveredFiles) : [],
439
+ discovered_license_files: discoveredLicenseFiles,
236
440
  };
237
441
  }
238
442
 
239
443
  export function applyScanResultToManifest(manifest, scanResult) {
240
444
  const draft = deepClone(manifest);
241
445
  const fonts = Array.isArray(draft.fonts) ? draft.fonts : [];
446
+ const licenseMatchesByFont = new Map();
447
+
448
+ const discoveredLicenseFiles = Array.isArray(scanResult.discovered_license_files)
449
+ ? scanResult.discovered_license_files
450
+ : [];
451
+
452
+ for (const licenseFile of discoveredLicenseFiles) {
453
+ const matchedFontIds = Array.isArray(licenseFile.matched_font_ids) ? licenseFile.matched_font_ids : [];
454
+ for (const fontId of matchedFontIds) {
455
+ if (!licenseMatchesByFont.has(fontId)) {
456
+ licenseMatchesByFont.set(fontId, []);
457
+ }
458
+ licenseMatchesByFont.get(fontId).push(licenseFile.path);
459
+ }
460
+ }
242
461
 
243
462
  for (const font of fonts) {
244
463
  const fontId = typeof font.font_id === "string" ? font.font_id : "";
@@ -250,12 +469,16 @@ export function applyScanResultToManifest(manifest, scanResult) {
250
469
  const currentUsage =
251
470
  font.usage && typeof font.usage === "object" && !Array.isArray(font.usage) ? font.usage : {};
252
471
 
472
+ const matchedLicensePaths = licenseMatchesByFont.get(fontId) ?? [];
473
+
253
474
  font.usage = {
254
475
  ...currentUsage,
255
476
  scan: {
256
477
  scanned_at: scanResult.scanned_at,
257
478
  match_count: match?.match_count ?? 0,
258
479
  matched_paths: match?.matched_paths ?? [],
480
+ license_match_count: matchedLicensePaths.length,
481
+ license_matched_paths: matchedLicensePaths.slice(0, 30),
259
482
  },
260
483
  };
261
484
  }