eval-quality 2.0.0 → 3.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.
Files changed (45) hide show
  1. package/README.md +4 -1
  2. package/dist/application/index.d.ts +4 -0
  3. package/dist/application/index.js +2 -0
  4. package/dist/core/compile/compile.js +6 -1
  5. package/dist/core/compile/schema-version.d.ts +15 -2
  6. package/dist/core/compile/schema-version.js +11 -3
  7. package/dist/core/emit/emit.js +4 -2
  8. package/dist/core/preflight/plan.js +15 -0
  9. package/dist/core/preflight/reduce.d.ts +1 -1
  10. package/dist/core/preflight/reduce.js +5 -2
  11. package/dist/core/schemas/evaluator-configuration.d.ts +9 -0
  12. package/dist/core/schemas/evaluator-configuration.js +9 -0
  13. package/dist/core/schemas/evidence-artifact.d.ts +9 -0
  14. package/dist/core/schemas/evidence-artifact.js +9 -0
  15. package/dist/core/schemas/isolation-manifest.d.ts +18 -0
  16. package/dist/core/schemas/isolation-manifest.js +18 -0
  17. package/dist/core/schemas/preflight-verdict.d.ts +9 -0
  18. package/dist/core/schemas/preflight-verdict.js +9 -0
  19. package/dist/core/schemas/private-artifact-manifest.d.ts +10 -0
  20. package/dist/core/schemas/private-artifact-manifest.js +10 -0
  21. package/dist/core/schemas/probe.d.ts +41 -0
  22. package/dist/core/schemas/probe.js +43 -0
  23. package/dist/core/schemas/scoring-policy.d.ts +11 -0
  24. package/dist/core/schemas/scoring-policy.js +11 -0
  25. package/dist/core/schemas/sealed-evaluator-brief.d.ts +12 -0
  26. package/dist/core/schemas/sealed-evaluator-brief.js +12 -0
  27. package/dist/core/schemas/sealed-run-record.d.ts +11 -0
  28. package/dist/core/schemas/sealed-run-record.js +11 -0
  29. package/dist/core/score/score.d.ts +1 -1
  30. package/dist/core/score/score.js +23 -0
  31. package/dist/core/seal/seal.js +4 -5
  32. package/dist/gates/audit-lockfile-age.mjs +295 -0
  33. package/dist/gates/check-dependency-direction.js +303 -0
  34. package/dist/gates/check-licenses.mjs +305 -0
  35. package/dist/gates/dependency-direction.js +555 -0
  36. package/dist/gates/discover-source-files.js +44 -0
  37. package/dist/gates/gate-config.js +251 -0
  38. package/dist/gates/gates-cli.js +410 -0
  39. package/dist/gates/lineage-ownership.js +364 -0
  40. package/dist/gates/package-boundary.js +388 -0
  41. package/dist/gates/token-scan.js +203 -0
  42. package/dist/index.d.ts +11 -1
  43. package/dist/index.js +20 -1
  44. package/dist/testing/probe-conformance.d.ts +23 -18
  45. package/package.json +20 -8
@@ -0,0 +1,305 @@
1
+ // Holds every entry in a resolved lockfile against an allowlist of SPDX
2
+ // identifiers, and fails closed on an entry whose `resolved` is not the registry
3
+ // tarball for that entry's own name and version.
4
+ //
5
+ // Reads `package-lock.json` directly rather than walking `node_modules`: the
6
+ // lockfile records a `license` field for every entry, including optional
7
+ // platform binaries never installed on this runner's OS/CPU (Biome and friends
8
+ // ship one lock entry per platform). Walking node_modules would silently miss
9
+ // every foreign-platform entry. Needs no install.
10
+ //
11
+ // A pure scanner over a parsed lockfile and the data its caller supplies. It
12
+ // carries no allowlist, no policy and no exception of its own, so this
13
+ // repository's licence policy is data in `eval-quality.config.json` exactly as a
14
+ // consumer's is. `scripts/gates-cli.ts` is the entry point that reads that file,
15
+ // resolves the policy for each lockfile, and decides which exceptions still
16
+ // hold.
17
+ const REGISTRY_PREFIX = 'https://registry.npmjs.org/';
18
+ /**
19
+ * `error.code` on the refusal a caller repairs by pointing the gate at a real
20
+ * lockfile. `audit-lockfile-age.mjs` exports the same string and
21
+ * `tests/architecture/published-gates.test.ts` holds the two equal: neither gate
22
+ * may import from `node_modules` and there is no module between them, so the
23
+ * constant is declared twice for the reason `WINDOW_DAYS_DEFAULT` is.
24
+ */
25
+ export const LOCKFILE_SHAPE_ERROR = 'EVAL_QUALITY_LOCKFILE_SHAPE';
26
+ function refuseLockfileShape(message) {
27
+ const error = new Error(message);
28
+ error.code = LOCKFILE_SHAPE_ERROR;
29
+ return error;
30
+ }
31
+ // The one URL the public registry serves `name@version` from. A scoped name's
32
+ // tarball basename is the segment after the slash, so `@scope/pkg` at 1.0.0 is
33
+ // `https://registry.npmjs.org/@scope/pkg/-/pkg-1.0.0.tgz`.
34
+ function registryTarballUrl(name, version) {
35
+ const basename = name.startsWith('@')
36
+ ? name.slice(name.indexOf('/') + 1)
37
+ : name;
38
+ return `${REGISTRY_PREFIX}${name}/-/${basename}-${version}.tgz`;
39
+ }
40
+ // Strips a single layer of balanced outer parentheses at a time, e.g. "(MIT OR Apache-2.0)" ->
41
+ // "MIT OR Apache-2.0". Only strips when the opening paren's match is the expression's final
42
+ // character (a true outer wrap), not when parens merely appear inside, e.g. "(MIT) OR (ISC)".
43
+ function stripOuterParens(expr) {
44
+ let s = expr.trim();
45
+ while (s.startsWith('(') && s.endsWith(')')) {
46
+ let depth = 0;
47
+ let wrapsWhole = true;
48
+ for (let i = 0; i < s.length; i++) {
49
+ if (s[i] === '(')
50
+ depth++;
51
+ else if (s[i] === ')') {
52
+ depth--;
53
+ if (depth === 0 && i !== s.length - 1) {
54
+ wrapsWhole = false;
55
+ break;
56
+ }
57
+ }
58
+ }
59
+ if (!wrapsWhole)
60
+ break;
61
+ s = s.slice(1, -1).trim();
62
+ }
63
+ return s;
64
+ }
65
+ // Splits `expr` on top-level occurrences of `token` (e.g. " OR ", " AND "), ignoring occurrences
66
+ // nested inside parentheses.
67
+ function splitTopLevel(expr, token) {
68
+ const parts = [];
69
+ let depth = 0;
70
+ let start = 0;
71
+ for (let i = 0; i < expr.length; i++) {
72
+ const ch = expr[i];
73
+ if (ch === '(')
74
+ depth++;
75
+ else if (ch === ')')
76
+ depth--;
77
+ else if (depth === 0 && expr.slice(i, i + token.length) === token) {
78
+ parts.push(expr.slice(start, i));
79
+ i += token.length - 1;
80
+ start = i + 1;
81
+ }
82
+ }
83
+ parts.push(expr.slice(start));
84
+ return parts;
85
+ }
86
+ // Recursively evaluates an SPDX-ish licence expression: OR passes if any operand is allowlisted,
87
+ // AND passes only if every operand is. A bare identifier (including a "X WITH exception" compound,
88
+ // UNLICENSED, or anything unparseable) passes only via exact allowlist membership, so those all fail
89
+ // closed without special-casing.
90
+ function isAllowed(licenseExpr, allowlist) {
91
+ if (!licenseExpr || typeof licenseExpr !== 'string')
92
+ return false;
93
+ const stripped = stripOuterParens(licenseExpr.trim());
94
+ if (stripped === '')
95
+ return false;
96
+ const orParts = splitTopLevel(stripped, ' OR ');
97
+ if (orParts.length > 1)
98
+ return orParts.some((part) => isAllowed(part, allowlist));
99
+ const andParts = splitTopLevel(stripped, ' AND ');
100
+ if (andParts.length > 1)
101
+ return andParts.every((part) => isAllowed(part, allowlist));
102
+ return allowlist.has(stripped);
103
+ }
104
+ function licenseStringOf(meta) {
105
+ const license = meta.license;
106
+ if (typeof license === 'string')
107
+ return license;
108
+ if (license &&
109
+ typeof license === 'object' &&
110
+ typeof license.type === 'string')
111
+ return license.type;
112
+ return null;
113
+ }
114
+ // One scoped exception, applied to one entry. `prefix` names a family of
115
+ // packages and `license` is the single identifier the exception adds to the
116
+ // allowlist for that family; the expression is then read by the rule every other
117
+ // entry is read by. So an AND still needs every operand covered and a WITH
118
+ // compound still has to sit in the list exactly: `Apache-2.0 AND
119
+ // LGPL-3.0-or-later` is tolerated where the exception names the LGPL term and
120
+ // the allowlist carries Apache-2.0, and `LGPL-3.0-or-later AND AGPL-3.0-only` is
121
+ // not. A substring test here kept tolerating a family the day its expression
122
+ // widened to carry a term nobody agreed to. The caller has already decided this
123
+ // exception applies to this lockfile and that its marker still holds.
124
+ function isTolerated(tolerance, meta, name, license, allowlist) {
125
+ if (!name.startsWith(tolerance.prefix))
126
+ return false;
127
+ if (tolerance.optional !== false && meta.optional !== true)
128
+ return false;
129
+ return isAllowed(license, new Set([...allowlist, tolerance.license]));
130
+ }
131
+ // Resolves how npm's hoisting algorithm would look up dependency `name` starting from the package
132
+ // at `fromPath`: its own node_modules first, then each ancestor's, ending at the root.
133
+ //
134
+ // Must split on "/node_modules/" boundaries, not bare "/": a scoped package's path
135
+ // (node_modules/@scope/name) has three segments per nesting level, not two, so striding by two
136
+ // undershoots every scoped ancestor and never reaches the root scope "". That silently drops the
137
+ // dependency edge for anything nested under a scoped package - including every @biomejs/cli-*
138
+ // platform binary - and findDependencyPath falls back to the raw lockfile key instead of the real
139
+ // require-chain.
140
+ function ancestorScopesOf(pkgPath) {
141
+ if (pkgPath === '')
142
+ return [''];
143
+ const scopes = [];
144
+ let scope = pkgPath;
145
+ for (;;) {
146
+ scopes.push(scope);
147
+ const boundary = scope.lastIndexOf('/node_modules/');
148
+ if (boundary === -1)
149
+ break;
150
+ scope = scope.slice(0, boundary);
151
+ }
152
+ scopes.push('');
153
+ return scopes;
154
+ }
155
+ function resolveDependency(packages, fromPath, name) {
156
+ for (const scope of ancestorScopesOf(fromPath)) {
157
+ const candidate = scope
158
+ ? `${scope}/node_modules/${name}`
159
+ : `node_modules/${name}`;
160
+ if (packages[candidate])
161
+ return candidate;
162
+ }
163
+ return null;
164
+ }
165
+ function buildEdges(packages) {
166
+ const edges = new Map();
167
+ for (const [path, meta] of Object.entries(packages)) {
168
+ const wantedNames = new Set([
169
+ ...Object.keys(meta.dependencies ?? {}),
170
+ ...Object.keys(meta.optionalDependencies ?? {}),
171
+ ...Object.keys(meta.peerDependencies ?? {}),
172
+ ...(path === '' ? Object.keys(meta.devDependencies ?? {}) : []),
173
+ ]);
174
+ const list = [];
175
+ for (const name of wantedNames) {
176
+ const resolved = resolveDependency(packages, path, name);
177
+ if (resolved)
178
+ list.push({ name, childPath: resolved });
179
+ }
180
+ edges.set(path, list);
181
+ }
182
+ return edges;
183
+ }
184
+ // Breadth-first search from the root over the lockfile's dependency edges (walking edges, not
185
+ // `npm ls`, since this script needs no install) to find one shortest chain of require-names that
186
+ // reaches `targetPath`.
187
+ function findDependencyPath(packages, edges, targetPath) {
188
+ const rootName = packages['']?.name ?? '(root)';
189
+ if (targetPath === '')
190
+ return rootName;
191
+ const visited = new Set(['']);
192
+ const queue = [''];
193
+ const parent = new Map(); // childPath -> { parentPath, name }
194
+ while (queue.length > 0) {
195
+ const current = queue.shift();
196
+ for (const { name, childPath } of edges.get(current) ?? []) {
197
+ if (visited.has(childPath))
198
+ continue;
199
+ visited.add(childPath);
200
+ parent.set(childPath, { parentPath: current, name });
201
+ if (childPath === targetPath) {
202
+ const chain = [];
203
+ let cursor = targetPath;
204
+ while (cursor !== '') {
205
+ const step = parent.get(cursor);
206
+ chain.unshift(step.name);
207
+ cursor = step.parentPath;
208
+ }
209
+ return [rootName, ...chain].join(' > ');
210
+ }
211
+ queue.push(childPath);
212
+ }
213
+ }
214
+ return targetPath; // unreachable via declared edges; fall back to the raw lockfile key
215
+ }
216
+ /**
217
+ * `options.allowlist` is required and has no default. An absent allowlist either
218
+ * fails every entry or silently permits every entry, and a gate that picks one
219
+ * of those on the caller's behalf is the fallback this package does not have.
220
+ *
221
+ * `options.source` is the path this lockfile was read from, named in the refusal
222
+ * a document without a `packages` object earns.
223
+ */
224
+ export function checkLicenses(lockfile, options = {}) {
225
+ const allowed = options.allowlist;
226
+ if (!Array.isArray(allowed) || allowed.length === 0) {
227
+ throw new Error('check-licenses: no allowlist was supplied; the licences gate carries none of its own');
228
+ }
229
+ const allowlist = new Set(allowed);
230
+ const label = typeof options.label === 'string' ? options.label : 'allowlist';
231
+ const tolerances = options.tolerances ?? [];
232
+ const source = typeof options.source === 'string' ? options.source : 'the lockfile';
233
+ // Every entry is read from `packages`, which npm writes from lockfileVersion 2
234
+ // onward. Defaulting it to {} turned an npm 6 lockfile, or a path naming
235
+ // something that is not a lockfile at all, into a run that reported success
236
+ // over zero entries: a gate that scanned nothing, in the words of a gate that
237
+ // passed.
238
+ const packages = lockfile?.packages;
239
+ if (packages === null ||
240
+ typeof packages !== 'object' ||
241
+ Array.isArray(packages)) {
242
+ throw refuseLockfileShape(`check-licenses: ${source} carries no "packages" object (lockfileVersion ${JSON.stringify(lockfile?.lockfileVersion ?? null)}); every entry is read from there, so this run would have passed over nothing. npm writes "packages" from lockfileVersion 2 onward.`);
243
+ }
244
+ // A `link: true` entry is a workspace symlink, not an installed artifact with its own licence -
245
+ // audit-lockfile-age.mjs already excludes these; this script should agree instead of flagging a
246
+ // symlink for a `license` field it was never going to have.
247
+ const entries = Object.entries(packages).filter(([pkgPath, meta]) => pkgPath !== '' && !meta.link);
248
+ const violations = [];
249
+ const tolerated = [];
250
+ const reasons = new Set();
251
+ for (const [pkgPath, meta] of entries) {
252
+ const name = meta.name ?? pkgPath.split('node_modules/').pop();
253
+ const version = meta.version ?? '(unknown)';
254
+ // `resolved` is the URL `npm ci` fetches, and `integrity` is checked against
255
+ // whatever that URL returns, so an entry can declare one package and install
256
+ // another. Pinning the host alone left that open: an entry naming
257
+ // `lodash@4.17.21` and resolving to `attacker-pkg-9.9.9.tgz` on
258
+ // registry.npmjs.org passed, and the licence being read was never the licence
259
+ // of the artifact being installed. Requiring `resolved` to be the one tarball
260
+ // URL the registry has for this entry's own name and version is what ties the
261
+ // two together.
262
+ const expected = registryTarballUrl(name, version);
263
+ if (meta.resolved !== expected) {
264
+ violations.push({
265
+ path: pkgPath,
266
+ name,
267
+ version,
268
+ license: meta.license ?? null,
269
+ reason: `resolved=${JSON.stringify(meta.resolved ?? null)} is not ${name}@${version}'s registry tarball ${expected}`,
270
+ });
271
+ continue;
272
+ }
273
+ const license = licenseStringOf(meta);
274
+ if (isAllowed(license, allowlist))
275
+ continue;
276
+ const tolerance = tolerances.find((candidate) => isTolerated(candidate, meta, name, license, allowlist));
277
+ if (tolerance !== undefined) {
278
+ tolerated.push(`${name}@${version}`);
279
+ reasons.add(tolerance.reason);
280
+ continue;
281
+ }
282
+ violations.push({ path: pkgPath, name, version, license });
283
+ }
284
+ tolerated.sort();
285
+ const toleranceReasons = [...reasons].sort();
286
+ if (violations.length === 0)
287
+ return {
288
+ violations: [],
289
+ entryCount: entries.length,
290
+ tolerated,
291
+ toleranceReasons,
292
+ policy: label,
293
+ };
294
+ const edges = buildEdges(packages);
295
+ for (const violation of violations) {
296
+ violation.dependencyPath = findDependencyPath(packages, edges, violation.path);
297
+ }
298
+ return {
299
+ violations,
300
+ entryCount: entries.length,
301
+ tolerated,
302
+ toleranceReasons,
303
+ policy: label,
304
+ };
305
+ }