@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 +14 -0
- package/package.json +1 -1
- package/src/index.js +90 -5
- package/src/lib/manifest-lib.js +143 -2
- package/src/lib/scanner.js +226 -3
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
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
|
|
41
|
-
--max-discovered-files <n>
|
|
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":
|
package/src/lib/manifest-lib.js
CHANGED
|
@@ -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:
|
|
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:
|
|
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.");
|
package/src/lib/scanner.js
CHANGED
|
@@ -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
|
|
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
|
}
|