skill-family-engineering-kit 0.1.0
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/CODE_OF_CONDUCT.md +131 -0
- package/CONTRIBUTING.md +69 -0
- package/LICENSE +201 -0
- package/README.md +74 -0
- package/SECURITY.md +34 -0
- package/data/licensing/registry.json +211 -0
- package/data/licensing/schema.json +207 -0
- package/docs/.nojekyll +0 -0
- package/docs/404.html +613 -0
- package/docs/architecture/index.html +904 -0
- package/docs/assets/images/favicon.png +0 -0
- package/docs/assets/javascripts/bundle.d7400e89.min.js +16 -0
- package/docs/assets/javascripts/lunr/min/lunr.ar.min.js +1 -0
- package/docs/assets/javascripts/lunr/min/lunr.da.min.js +18 -0
- package/docs/assets/javascripts/lunr/min/lunr.de.min.js +18 -0
- package/docs/assets/javascripts/lunr/min/lunr.du.min.js +18 -0
- package/docs/assets/javascripts/lunr/min/lunr.el.min.js +1 -0
- package/docs/assets/javascripts/lunr/min/lunr.es.min.js +18 -0
- package/docs/assets/javascripts/lunr/min/lunr.fi.min.js +18 -0
- package/docs/assets/javascripts/lunr/min/lunr.fr.min.js +18 -0
- package/docs/assets/javascripts/lunr/min/lunr.he.min.js +1 -0
- package/docs/assets/javascripts/lunr/min/lunr.hi.min.js +1 -0
- package/docs/assets/javascripts/lunr/min/lunr.hu.min.js +18 -0
- package/docs/assets/javascripts/lunr/min/lunr.hy.min.js +1 -0
- package/docs/assets/javascripts/lunr/min/lunr.it.min.js +18 -0
- package/docs/assets/javascripts/lunr/min/lunr.ja.min.js +1 -0
- package/docs/assets/javascripts/lunr/min/lunr.jp.min.js +1 -0
- package/docs/assets/javascripts/lunr/min/lunr.kn.min.js +1 -0
- package/docs/assets/javascripts/lunr/min/lunr.ko.min.js +1 -0
- package/docs/assets/javascripts/lunr/min/lunr.multi.min.js +1 -0
- package/docs/assets/javascripts/lunr/min/lunr.nl.min.js +18 -0
- package/docs/assets/javascripts/lunr/min/lunr.no.min.js +18 -0
- package/docs/assets/javascripts/lunr/min/lunr.pt.min.js +18 -0
- package/docs/assets/javascripts/lunr/min/lunr.ro.min.js +18 -0
- package/docs/assets/javascripts/lunr/min/lunr.ru.min.js +18 -0
- package/docs/assets/javascripts/lunr/min/lunr.sa.min.js +1 -0
- package/docs/assets/javascripts/lunr/min/lunr.stemmer.support.min.js +1 -0
- package/docs/assets/javascripts/lunr/min/lunr.sv.min.js +18 -0
- package/docs/assets/javascripts/lunr/min/lunr.ta.min.js +1 -0
- package/docs/assets/javascripts/lunr/min/lunr.te.min.js +1 -0
- package/docs/assets/javascripts/lunr/min/lunr.th.min.js +1 -0
- package/docs/assets/javascripts/lunr/min/lunr.tr.min.js +18 -0
- package/docs/assets/javascripts/lunr/min/lunr.vi.min.js +1 -0
- package/docs/assets/javascripts/lunr/min/lunr.zh.min.js +1 -0
- package/docs/assets/javascripts/lunr/tinyseg.js +206 -0
- package/docs/assets/javascripts/lunr/wordcut.js +6708 -0
- package/docs/assets/javascripts/workers/search.2c215733.min.js +42 -0
- package/docs/assets/stylesheets/main.ec1eaa64.min.css +1 -0
- package/docs/assets/stylesheets/palette.ab4e12ef.min.css +1 -0
- package/docs/git-lifecycle/index.html +976 -0
- package/docs/help/index.html +907 -0
- package/docs/index.html +837 -0
- package/docs/integration/audit/baseline/audit-codes.json +62 -0
- package/docs/integration/audit/failure-evidence/index.html +872 -0
- package/docs/integration/audit/independence/index.html +841 -0
- package/docs/integration/audit/index.html +826 -0
- package/docs/integration/audit/mutation-taxonomy/index.html +1022 -0
- package/docs/integration/audit/schemas/audit-evidence.schema.json +182 -0
- package/docs/integration/audit/version-compatibility/index.html +845 -0
- package/docs/migration/index.html +1213 -0
- package/docs/quickstart/index.html +842 -0
- package/docs/search/search_index.json +1 -0
- package/docs/setup/index.html +941 -0
- package/docs/sitemap.xml +51 -0
- package/package.json +42 -0
- package/src/adopt-plan.mjs +594 -0
- package/src/check.mjs +415 -0
- package/src/cli.mjs +230 -0
- package/src/errors.mjs +144 -0
- package/src/gitprobe.mjs +230 -0
- package/src/identity-check.mjs +563 -0
- package/src/index.mjs +233 -0
- package/src/license-texts/Apache-2.0.txt +201 -0
- package/src/license-texts/MIT.txt +21 -0
- package/src/licensing.mjs +299 -0
- package/src/migration.mjs +427 -0
- package/src/projection.mjs +397 -0
- package/src/scaffold.mjs +178 -0
- package/src/skeleton.mjs +886 -0
- package/src/workspace.mjs +236 -0
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { readFileContained } from "skill-family-harness-node";
|
|
4
|
+
import { loadLicensingProfile } from "./licensing.mjs";
|
|
5
|
+
|
|
6
|
+
const CODE = "SFC2004";
|
|
7
|
+
const PROJECTION_KINDS = new Set(["plugin-manifest", "marketplace", "public-snapshot"]);
|
|
8
|
+
const COMMERCIAL_PAYLOAD_NAMES = new Set([
|
|
9
|
+
"ee",
|
|
10
|
+
"EULA",
|
|
11
|
+
"EULA.md",
|
|
12
|
+
"ENTERPRISE-LICENSE",
|
|
13
|
+
"ENTERPRISE-LICENSE.md",
|
|
14
|
+
"LICENSE.enterprise",
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
const IDENTITY_SOURCE_PRIORITY = Object.freeze([
|
|
18
|
+
"identity-record",
|
|
19
|
+
"package.json",
|
|
20
|
+
"license",
|
|
21
|
+
"manifest",
|
|
22
|
+
"marketplace",
|
|
23
|
+
"snapshot",
|
|
24
|
+
"readme",
|
|
25
|
+
"notice",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
function finding(kind, message, source, details = {}) {
|
|
29
|
+
return { kind, code: CODE, message, source, ...details };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function readJson(rootAbs, relPath) {
|
|
33
|
+
try {
|
|
34
|
+
const raw = await readFileContained(rootAbs, relPath, { encoding: "utf8" });
|
|
35
|
+
return { state: "ok", value: JSON.parse(raw) };
|
|
36
|
+
} catch (cause) {
|
|
37
|
+
if (cause instanceof SyntaxError) return { state: "invalid", cause: cause.message };
|
|
38
|
+
return { state: "missing", cause: cause?.message };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function loadIdentityRecord(rootAbs) {
|
|
43
|
+
for (const relPath of ["skill-family.identity-record.json", "identity-record.json"]) {
|
|
44
|
+
const loaded = await readJson(rootAbs, relPath);
|
|
45
|
+
if (loaded.state === "ok" && loaded.value?.kind === "skill-family.identity-record") {
|
|
46
|
+
return loaded.value;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function loadPackageJson(rootAbs) {
|
|
53
|
+
const loaded = await readJson(rootAbs, "package.json");
|
|
54
|
+
return loaded.state === "ok" ? loaded.value : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function loadManifest(rootAbs, manifestName) {
|
|
58
|
+
const loaded = await readJson(rootAbs, manifestName);
|
|
59
|
+
return loaded.state === "ok" ? loaded.value : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function loadReadme(rootAbs) {
|
|
63
|
+
try {
|
|
64
|
+
return await readFileContained(rootAbs, "README.md", { encoding: "utf8" });
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function authorValue(value) {
|
|
71
|
+
if (typeof value === "string") return value;
|
|
72
|
+
if (value && typeof value === "object" && typeof value.name === "string") return value.name;
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function checkIdentityFields(record, document, findings, source, { requireLicense = true, requireAuthor = true } = {}) {
|
|
77
|
+
const expectedLicense = record.licensing.spdx;
|
|
78
|
+
const expectedAuthor = record.displayValue;
|
|
79
|
+
const actualLicense = document?.license ?? document?.metadata?.license ?? null;
|
|
80
|
+
const actualAuthor = authorValue(document?.author ?? document?.metadata?.author);
|
|
81
|
+
const developerName = document?.developerName
|
|
82
|
+
?? document?.interface?.developerName
|
|
83
|
+
?? document?.metadata?.developerName
|
|
84
|
+
?? null;
|
|
85
|
+
|
|
86
|
+
if (requireLicense && actualLicense === null) {
|
|
87
|
+
findings.push(finding("license-missing", `${source} does not declare license`, source, {
|
|
88
|
+
expected: expectedLicense,
|
|
89
|
+
}));
|
|
90
|
+
} else if (actualLicense !== null && actualLicense !== expectedLicense) {
|
|
91
|
+
findings.push(finding("license-drift", `${source} license does not match identity record`, source, {
|
|
92
|
+
expected: expectedLicense,
|
|
93
|
+
actual: actualLicense,
|
|
94
|
+
}));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (requireAuthor && actualAuthor === null) {
|
|
98
|
+
findings.push(finding("author-missing", `${source} does not declare author`, source, {
|
|
99
|
+
expected: expectedAuthor,
|
|
100
|
+
}));
|
|
101
|
+
} else if (actualAuthor !== null && actualAuthor !== expectedAuthor) {
|
|
102
|
+
findings.push(finding("author-drift", `${source} author does not match identity record`, source, {
|
|
103
|
+
expected: expectedAuthor,
|
|
104
|
+
actual: actualAuthor,
|
|
105
|
+
}));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (developerName !== null && developerName !== expectedAuthor) {
|
|
109
|
+
findings.push(finding("developer-name-drift", `${source} developerName does not match identity record`, source, {
|
|
110
|
+
expected: expectedAuthor,
|
|
111
|
+
actual: developerName,
|
|
112
|
+
}));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function selectProjectionDocument(record, projection, rootDocument, findings) {
|
|
117
|
+
if (projection.kind === "plugin-manifest") return rootDocument;
|
|
118
|
+
const candidates = Array.isArray(rootDocument)
|
|
119
|
+
? rootDocument
|
|
120
|
+
: Array.isArray(rootDocument?.plugins)
|
|
121
|
+
? rootDocument.plugins
|
|
122
|
+
: Array.isArray(rootDocument?.marketplace?.plugins)
|
|
123
|
+
? rootDocument.marketplace.plugins
|
|
124
|
+
: null;
|
|
125
|
+
if (candidates) {
|
|
126
|
+
const matches = candidates.filter((entry) => entry?.name === record.project.id);
|
|
127
|
+
if (matches.length !== 1) {
|
|
128
|
+
findings.push(finding(
|
|
129
|
+
"projection-target-mismatch",
|
|
130
|
+
`${projection.path} must contain exactly one entry named ${record.project.id}`,
|
|
131
|
+
projection.path,
|
|
132
|
+
{ matchCount: matches.length },
|
|
133
|
+
));
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
return matches[0];
|
|
137
|
+
}
|
|
138
|
+
if (rootDocument?.name === record.project.id) return rootDocument;
|
|
139
|
+
findings.push(finding(
|
|
140
|
+
"projection-target-mismatch",
|
|
141
|
+
`${projection.path} is not a direct ${record.project.id} document and has no plugins array`,
|
|
142
|
+
projection.path,
|
|
143
|
+
));
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function validateProjectionDeclaration(projection, index, findings) {
|
|
148
|
+
const source = `identity-record.projections[${index}]`;
|
|
149
|
+
if (!projection || typeof projection !== "object" || Array.isArray(projection)) {
|
|
150
|
+
findings.push(finding("projection-declaration-invalid", `${source} must be an object`, source));
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
if (Object.keys(projection).sort().join(",") !== "kind,path") {
|
|
154
|
+
findings.push(finding("projection-declaration-invalid", `${source} fields must be exactly kind and path`, source));
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
if (typeof projection.path !== "string"
|
|
158
|
+
|| path.posix.isAbsolute(projection.path)
|
|
159
|
+
|| projection.path.includes("\\")
|
|
160
|
+
|| projection.path.split("/").includes("..")
|
|
161
|
+
|| !projection.path.endsWith(".json")) {
|
|
162
|
+
findings.push(finding(
|
|
163
|
+
"projection-path-invalid",
|
|
164
|
+
`${source} must declare a contained JSON path; JavaScript sources such as plugins.mjs are forbidden`,
|
|
165
|
+
source,
|
|
166
|
+
{ path: projection.path },
|
|
167
|
+
));
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
if (!PROJECTION_KINDS.has(projection.kind)) {
|
|
171
|
+
findings.push(finding("projection-kind-invalid", `${source} has an unknown kind`, source, {
|
|
172
|
+
kind: projection.kind,
|
|
173
|
+
}));
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function checkDeclaredProjections(rootAbs, record, findings, sources) {
|
|
180
|
+
// Projections are opt-in: records without a projections array simply declare
|
|
181
|
+
// none. Generated records always carry the array (possibly empty).
|
|
182
|
+
if (record.projections === undefined) return;
|
|
183
|
+
if (!Array.isArray(record.projections)) {
|
|
184
|
+
findings.push(finding(
|
|
185
|
+
"projection-declarations-missing",
|
|
186
|
+
"identity record must declare projections as an array",
|
|
187
|
+
"identity-record",
|
|
188
|
+
));
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
for (const [index, projection] of record.projections.entries()) {
|
|
192
|
+
if (!validateProjectionDeclaration(projection, index, findings)) continue;
|
|
193
|
+
const loaded = await readJson(rootAbs, projection.path);
|
|
194
|
+
sources.projections.push({ path: projection.path, kind: projection.kind, state: loaded.state });
|
|
195
|
+
if (loaded.state !== "ok") {
|
|
196
|
+
findings.push(finding(
|
|
197
|
+
loaded.state === "invalid" ? "projection-json-invalid" : "projection-missing",
|
|
198
|
+
`declared identity projection cannot be read as JSON: ${projection.path}`,
|
|
199
|
+
projection.path,
|
|
200
|
+
));
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
const document = selectProjectionDocument(record, projection, loaded.value, findings);
|
|
204
|
+
if (document) checkIdentityFields(record, document, findings, projection.path);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function checkReadmeDrift(record, readme, findings) {
|
|
209
|
+
const expectedLicenseMarker = `Licensed under ${record.licensing.spdx}`;
|
|
210
|
+
if (!readme.includes(expectedLicenseMarker)) {
|
|
211
|
+
findings.push(finding(
|
|
212
|
+
"readme-license-drift",
|
|
213
|
+
`README must contain the exact marker "${expectedLicenseMarker}"`,
|
|
214
|
+
"README.md",
|
|
215
|
+
{ expected: expectedLicenseMarker },
|
|
216
|
+
));
|
|
217
|
+
}
|
|
218
|
+
if (!readme.includes(record.displayValue)) {
|
|
219
|
+
findings.push(finding(
|
|
220
|
+
"readme-author-drift",
|
|
221
|
+
`README does not mention author "${record.displayValue}"`,
|
|
222
|
+
"README.md",
|
|
223
|
+
{ expected: record.displayValue },
|
|
224
|
+
));
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function checkLicense(rootAbs, record, findings) {
|
|
229
|
+
let text;
|
|
230
|
+
try {
|
|
231
|
+
text = await readFileContained(rootAbs, "LICENSE", { encoding: "utf8" });
|
|
232
|
+
} catch {
|
|
233
|
+
findings.push(finding("license-file-missing", "LICENSE is missing", "LICENSE"));
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const authorNames = Array.isArray(record.authors)
|
|
237
|
+
? record.authors.map((author) => author?.displayName).filter((name) => typeof name === "string" && name)
|
|
238
|
+
: [];
|
|
239
|
+
if (record.licensing.spdx === "Apache-2.0") {
|
|
240
|
+
const required = [
|
|
241
|
+
"Apache License",
|
|
242
|
+
"Version 2.0, January 2004",
|
|
243
|
+
"TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION",
|
|
244
|
+
"1. Definitions.",
|
|
245
|
+
"9. Accepting Warranty or Additional Liability.",
|
|
246
|
+
"APPENDIX: How to apply the Apache License to your work.",
|
|
247
|
+
];
|
|
248
|
+
if (required.some((marker) => !text.includes(marker))) {
|
|
249
|
+
findings.push(finding(
|
|
250
|
+
"license-file-drift",
|
|
251
|
+
"LICENSE is not the complete Apache License 2.0 text",
|
|
252
|
+
"LICENSE",
|
|
253
|
+
{ expected: "Apache-2.0 complete legal text" },
|
|
254
|
+
));
|
|
255
|
+
}
|
|
256
|
+
} else {
|
|
257
|
+
const required = [
|
|
258
|
+
"MIT License",
|
|
259
|
+
"Permission is hereby granted, free of charge",
|
|
260
|
+
"THE SOFTWARE IS PROVIDED \"AS IS\"",
|
|
261
|
+
];
|
|
262
|
+
if (record.licensing.copyrightYear !== undefined) {
|
|
263
|
+
required.push(`Copyright (c) ${record.licensing.copyrightYear} ${record.displayValue}`);
|
|
264
|
+
}
|
|
265
|
+
if (required.some((marker) => !text.includes(marker))
|
|
266
|
+
|| authorNames.some((name) => !text.includes(name))) {
|
|
267
|
+
findings.push(finding(
|
|
268
|
+
"license-file-drift",
|
|
269
|
+
"LICENSE does not match the MIT identity record",
|
|
270
|
+
"LICENSE",
|
|
271
|
+
{ expected: "MIT text with the identity record copyright line" },
|
|
272
|
+
));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function checkNotice(rootAbs, record, findings) {
|
|
278
|
+
if (record.licensing.spdx !== "Apache-2.0") return;
|
|
279
|
+
try {
|
|
280
|
+
const notice = await readFileContained(rootAbs, "NOTICE", { encoding: "utf8" });
|
|
281
|
+
const yearDrift = record.licensing.copyrightYear !== undefined
|
|
282
|
+
&& !notice.includes(String(record.licensing.copyrightYear));
|
|
283
|
+
if (!notice.includes(record.displayValue) || yearDrift) {
|
|
284
|
+
findings.push(finding(
|
|
285
|
+
"notice-drift",
|
|
286
|
+
"NOTICE does not retain the identity record author and copyright year",
|
|
287
|
+
"NOTICE",
|
|
288
|
+
));
|
|
289
|
+
}
|
|
290
|
+
} catch {
|
|
291
|
+
findings.push(finding("notice-missing", "NOTICE is required for the Apache-2.0 profile", "NOTICE"));
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const COPYRIGHT_LINE_PATTERN = /^Copyright(?:\s+\([cC]\))?\s+(?:\d{4}\s+)?(.+)$/;
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Detects attribution that does not belong to this project's
|
|
299
|
+
* authors (foreign project attribution smuggled into NOTICE). Findings are
|
|
300
|
+
* report-only: historical NOTICE entries and third-party licenses are
|
|
301
|
+
* preserved and must be resolved by human adjudication, never deleted or
|
|
302
|
+
* reassigned automatically.
|
|
303
|
+
*/
|
|
304
|
+
async function checkExternalAttribution(rootAbs, record, findings) {
|
|
305
|
+
let notice;
|
|
306
|
+
try {
|
|
307
|
+
notice = await readFileContained(rootAbs, "NOTICE", { encoding: "utf8" });
|
|
308
|
+
} catch {
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
const ours = [
|
|
312
|
+
...(Array.isArray(record.authors)
|
|
313
|
+
? record.authors.map((author) => author?.displayName).filter((name) => typeof name === "string" && name)
|
|
314
|
+
: []),
|
|
315
|
+
];
|
|
316
|
+
if (typeof record.displayValue === "string" && record.displayValue) ours.push(record.displayValue);
|
|
317
|
+
for (const rawLine of notice.split("\n")) {
|
|
318
|
+
const line = rawLine.trim();
|
|
319
|
+
const match = COPYRIGHT_LINE_PATTERN.exec(line);
|
|
320
|
+
if (!match) continue;
|
|
321
|
+
const holder = match[1].trim();
|
|
322
|
+
if (ours.some((name) => holder.includes(name))) continue;
|
|
323
|
+
findings.push(finding(
|
|
324
|
+
"external-attribution-present",
|
|
325
|
+
"NOTICE contains a copyright statement outside the identity record authors; reported for human review and never deleted automatically",
|
|
326
|
+
"NOTICE",
|
|
327
|
+
{ line },
|
|
328
|
+
));
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function ownerSeparationEnforced(ownerSeparation) {
|
|
333
|
+
return stableJson(ownerSeparation) === stableJson({
|
|
334
|
+
required: true,
|
|
335
|
+
deriveOwnerFromAuthor: false,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function checkOwnerSeparation(record, findings) {
|
|
340
|
+
if (!ownerSeparationEnforced(record.ownerSeparation)) {
|
|
341
|
+
findings.push(finding(
|
|
342
|
+
"owner-separation-drift",
|
|
343
|
+
"owner must remain an independent repository coordinate and must not be derived from author",
|
|
344
|
+
"identity-record",
|
|
345
|
+
{ expected: { required: true, deriveOwnerFromAuthor: false }, actual: record.ownerSeparation },
|
|
346
|
+
));
|
|
347
|
+
}
|
|
348
|
+
if (!Array.isArray(record.authors) || record.authors.length === 0) {
|
|
349
|
+
findings.push(finding("missing-authors", "identity record has no authors", "identity-record"));
|
|
350
|
+
} else if (record.authors.some((author) => author?.role === "owner")) {
|
|
351
|
+
findings.push(finding(
|
|
352
|
+
"owner-as-author",
|
|
353
|
+
"owner roles are forbidden in the author list",
|
|
354
|
+
"identity-record",
|
|
355
|
+
));
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async function checkCommercialBoundary(rootAbs, record, findings) {
|
|
360
|
+
if (record.commercial?.enabled !== false) {
|
|
361
|
+
findings.push(finding(
|
|
362
|
+
"commercial-payload-not-authorized",
|
|
363
|
+
"commercial payload is disabled until a future explicit product decision",
|
|
364
|
+
"identity-record",
|
|
365
|
+
));
|
|
366
|
+
}
|
|
367
|
+
let entries = [];
|
|
368
|
+
try {
|
|
369
|
+
entries = await readdir(rootAbs);
|
|
370
|
+
} catch {
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
for (const entry of entries) {
|
|
374
|
+
if (COMMERCIAL_PAYLOAD_NAMES.has(entry)) {
|
|
375
|
+
findings.push(finding(
|
|
376
|
+
"commercial-payload-present",
|
|
377
|
+
`commercial artifact "${entry}" exists while the profile is disabled`,
|
|
378
|
+
entry,
|
|
379
|
+
));
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export async function checkIdentityDrift({ rootAbs, identityRecord } = {}) {
|
|
385
|
+
const findings = [];
|
|
386
|
+
const record = identityRecord ?? await loadIdentityRecord(rootAbs);
|
|
387
|
+
if (!record) {
|
|
388
|
+
return {
|
|
389
|
+
ok: false,
|
|
390
|
+
findings: [finding(
|
|
391
|
+
"identity-record-missing",
|
|
392
|
+
"identity record not found; cannot perform identity drift check",
|
|
393
|
+
"identity-record",
|
|
394
|
+
)],
|
|
395
|
+
record: null,
|
|
396
|
+
sources: {},
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const sources = { packageJson: false, readme: false, license: false, notice: false, projections: [] };
|
|
401
|
+
const packageLoaded = await readJson(rootAbs, "package.json");
|
|
402
|
+
if (packageLoaded.state === "ok") {
|
|
403
|
+
sources.packageJson = true;
|
|
404
|
+
checkIdentityFields(record, packageLoaded.value, findings, "package.json");
|
|
405
|
+
} else {
|
|
406
|
+
findings.push(finding("package-json-missing", "package.json is missing or invalid", "package.json"));
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const readme = await loadReadme(rootAbs);
|
|
410
|
+
if (readme === null) {
|
|
411
|
+
findings.push(finding("readme-missing", "README.md is missing", "README.md"));
|
|
412
|
+
} else {
|
|
413
|
+
sources.readme = true;
|
|
414
|
+
checkReadmeDrift(record, readme, findings);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
await checkLicense(rootAbs, record, findings);
|
|
418
|
+
sources.license = !findings.some((item) => item.source === "LICENSE" && item.kind === "license-file-missing");
|
|
419
|
+
await checkNotice(rootAbs, record, findings);
|
|
420
|
+
sources.notice = record.licensing.spdx !== "Apache-2.0"
|
|
421
|
+
|| !findings.some((item) => item.source === "NOTICE" && item.kind === "notice-missing");
|
|
422
|
+
await checkExternalAttribution(rootAbs, record, findings);
|
|
423
|
+
checkOwnerSeparation(record, findings);
|
|
424
|
+
await checkCommercialBoundary(rootAbs, record, findings);
|
|
425
|
+
await checkDeclaredProjections(rootAbs, record, findings, sources);
|
|
426
|
+
|
|
427
|
+
return { ok: findings.length === 0, findings, record, sources };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function stableJson(value) {
|
|
431
|
+
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
|
|
432
|
+
if (value && typeof value === "object") {
|
|
433
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`;
|
|
434
|
+
}
|
|
435
|
+
return JSON.stringify(value);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function compareExact(findings, source, label, actual, expected, kind) {
|
|
439
|
+
if (stableJson(actual) !== stableJson(expected)) {
|
|
440
|
+
findings.push(finding(kind, `${label} does not match the selected licensing profile`, source, {
|
|
441
|
+
expected,
|
|
442
|
+
actual,
|
|
443
|
+
}));
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function checkRecordAuthors(record, profile, findings) {
|
|
448
|
+
const authors = Array.isArray(record.authors) ? record.authors : [];
|
|
449
|
+
if (authors.some((author) => author?.role === "owner")) {
|
|
450
|
+
findings.push(finding(
|
|
451
|
+
"owner-as-author",
|
|
452
|
+
"owner roles are forbidden in the author list; owner is an independent coordinate",
|
|
453
|
+
"identity-record",
|
|
454
|
+
));
|
|
455
|
+
}
|
|
456
|
+
const expectedKeys = new Set(profile.identity.authors.map((author) => stableJson(author)));
|
|
457
|
+
const actualKeys = new Set(authors.map((author) => stableJson(author)));
|
|
458
|
+
const lost = [...expectedKeys].filter((key) => !actualKeys.has(key));
|
|
459
|
+
if (lost.length > 0) {
|
|
460
|
+
findings.push(finding(
|
|
461
|
+
"second-author-lost",
|
|
462
|
+
"author set drops authors declared by the selected licensing profile",
|
|
463
|
+
"identity-record",
|
|
464
|
+
{ expected: profile.identity.authors, actual: authors },
|
|
465
|
+
));
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
const extra = [...actualKeys].filter((key) => !expectedKeys.has(key));
|
|
469
|
+
if (extra.length > 0 || stableJson(authors) !== stableJson(profile.identity.authors)) {
|
|
470
|
+
findings.push(finding(
|
|
471
|
+
"authors-profile-drift",
|
|
472
|
+
"authors do not match the selected licensing profile",
|
|
473
|
+
"identity-record",
|
|
474
|
+
{ expected: profile.identity.authors, actual: authors },
|
|
475
|
+
));
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export async function validateIdentityAgainstProfile(record, profilesRoot) {
|
|
480
|
+
const findings = [];
|
|
481
|
+
if (!record || typeof record !== "object" || !record.licensing) {
|
|
482
|
+
return {
|
|
483
|
+
ok: false,
|
|
484
|
+
findings: [finding("identity-record-invalid", "identity record is invalid", "identity-record")],
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
let profile;
|
|
489
|
+
try {
|
|
490
|
+
profile = await loadLicensingProfile({
|
|
491
|
+
profilesRoot,
|
|
492
|
+
profileId: record.licensing.profile,
|
|
493
|
+
variant: record.licensing.variant,
|
|
494
|
+
});
|
|
495
|
+
} catch (cause) {
|
|
496
|
+
return {
|
|
497
|
+
ok: false,
|
|
498
|
+
findings: [finding(
|
|
499
|
+
"unknown-profile",
|
|
500
|
+
`identity record does not resolve to a valid licensing profile: ${cause.message}`,
|
|
501
|
+
"identity-record",
|
|
502
|
+
)],
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const spdx = record.licensing.spdx;
|
|
507
|
+
if (typeof spdx !== "string" || spdx !== profile.licensing.spdx) {
|
|
508
|
+
findings.push(finding("invalid-spdx", "licensing spdx does not match the selected licensing profile", "identity-record", {
|
|
509
|
+
expected: profile.licensing.spdx,
|
|
510
|
+
actual: spdx,
|
|
511
|
+
}));
|
|
512
|
+
}
|
|
513
|
+
if (record.licensing.profile !== profile.profile.id || record.licensing.variant !== profile.profile.variant) {
|
|
514
|
+
findings.push(finding("licensing-profile-drift", "licensing profile coordinate does not match the selected licensing profile", "identity-record", {
|
|
515
|
+
expected: { profile: profile.profile.id, variant: profile.profile.variant },
|
|
516
|
+
actual: { profile: record.licensing.profile, variant: record.licensing.variant },
|
|
517
|
+
}));
|
|
518
|
+
}
|
|
519
|
+
if (record.licensing.copyrightYear !== profile.licensing.copyrightYear) {
|
|
520
|
+
findings.push(finding("licensing-profile-drift", "licensing copyrightYear does not match the selected licensing profile", "identity-record", {
|
|
521
|
+
expected: profile.licensing.copyrightYear,
|
|
522
|
+
actual: record.licensing.copyrightYear,
|
|
523
|
+
}));
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
checkRecordAuthors(record, profile, findings);
|
|
527
|
+
|
|
528
|
+
compareExact(findings, "identity-record", "displayValue", record.displayValue, profile.identity.displayValue, "display-value-drift");
|
|
529
|
+
compareExact(
|
|
530
|
+
findings,
|
|
531
|
+
"identity-record",
|
|
532
|
+
"copyrightStrategy",
|
|
533
|
+
record.copyrightStrategy,
|
|
534
|
+
profile.licensing.copyrightStrategy,
|
|
535
|
+
"copyright-strategy-drift",
|
|
536
|
+
);
|
|
537
|
+
compareExact(
|
|
538
|
+
findings,
|
|
539
|
+
"identity-record",
|
|
540
|
+
"ownerSeparation",
|
|
541
|
+
record.ownerSeparation,
|
|
542
|
+
profile.identity.ownerSeparation,
|
|
543
|
+
"owner-separation-drift",
|
|
544
|
+
);
|
|
545
|
+
compareExact(findings, "identity-record", "preservation", record.preservation, profile.preservation, "preservation-drift");
|
|
546
|
+
compareExact(findings, "identity-record", "commercial", record.commercial, profile.commercial, "commercial-policy-drift");
|
|
547
|
+
|
|
548
|
+
if (!Array.isArray(record.projections)) {
|
|
549
|
+
findings.push(finding(
|
|
550
|
+
"projection-declarations-missing",
|
|
551
|
+
"identity record must declare projections as an array",
|
|
552
|
+
"identity-record",
|
|
553
|
+
));
|
|
554
|
+
} else {
|
|
555
|
+
for (const [index, projection] of record.projections.entries()) {
|
|
556
|
+
validateProjectionDeclaration(projection, index, findings);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
return { ok: findings.length === 0, findings, profile };
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
export { IDENTITY_SOURCE_PRIORITY };
|