eval-quality 3.0.0 → 3.2.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/README.md +4 -1
- package/dist/application/index.d.ts +4 -0
- package/dist/application/index.js +2 -0
- package/dist/core/emit/emit.js +4 -2
- package/dist/core/preflight/reduce.d.ts +1 -1
- package/dist/core/preflight/reduce.js +5 -2
- package/dist/core/schemas/evaluator-configuration.d.ts +9 -0
- package/dist/core/schemas/evaluator-configuration.js +9 -0
- package/dist/core/schemas/evidence-artifact.d.ts +9 -0
- package/dist/core/schemas/evidence-artifact.js +9 -0
- package/dist/core/schemas/isolation-manifest.d.ts +18 -0
- package/dist/core/schemas/isolation-manifest.js +18 -0
- package/dist/core/schemas/preflight-verdict.d.ts +9 -0
- package/dist/core/schemas/preflight-verdict.js +9 -0
- package/dist/core/schemas/private-artifact-manifest.d.ts +10 -0
- package/dist/core/schemas/private-artifact-manifest.js +10 -0
- package/dist/core/schemas/scoring-policy.d.ts +11 -0
- package/dist/core/schemas/scoring-policy.js +11 -0
- package/dist/core/schemas/sealed-evaluator-brief.d.ts +12 -0
- package/dist/core/schemas/sealed-evaluator-brief.js +12 -0
- package/dist/core/schemas/sealed-run-record.d.ts +11 -0
- package/dist/core/schemas/sealed-run-record.js +11 -0
- package/dist/core/seal/seal.js +4 -5
- package/dist/gates/audit-lockfile-age.mjs +392 -0
- package/dist/gates/check-dependency-direction.js +303 -0
- package/dist/gates/check-doc-claims.js +1012 -0
- package/dist/gates/check-doc-counts.js +408 -0
- package/dist/gates/check-doc-invocations.mjs +618 -0
- package/dist/gates/check-licenses.mjs +378 -0
- package/dist/gates/consumer-pattern.js +104 -0
- package/dist/gates/dependency-direction.js +555 -0
- package/dist/gates/discover-source-files.js +44 -0
- package/dist/gates/gate-config.js +415 -0
- package/dist/gates/gates-cli.js +607 -0
- package/dist/gates/lineage-ownership.js +364 -0
- package/dist/gates/module-value.js +187 -0
- package/dist/gates/package-boundary.js +277 -0
- package/dist/gates/scanned-paths.js +110 -0
- package/dist/gates/token-scan.js +203 -0
- package/dist/index.d.ts +11 -1
- package/dist/index.js +20 -1
- package/dist/testing/probe-conformance.d.ts +23 -18
- package/package.json +24 -11
|
@@ -0,0 +1,378 @@
|
|
|
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.undeclared` is the rows for entries whose manifest declares no
|
|
222
|
+
* licence, each a `prefix`, the one identifier the entry is read as under
|
|
223
|
+
* `readAs`, the `evidence` for that reading and a `reason`. The caller has
|
|
224
|
+
* already decided which rows apply to this lockfile. A row that reached no
|
|
225
|
+
* undeclared entry comes back by prefix in `unusedUndeclared`, so the caller
|
|
226
|
+
* can refuse a reading nothing is holding.
|
|
227
|
+
*
|
|
228
|
+
* `options.source` is the path this lockfile was read from, named in the refusal
|
|
229
|
+
* a document without a `packages` object earns.
|
|
230
|
+
*/
|
|
231
|
+
export function checkLicenses(lockfile, options = {}) {
|
|
232
|
+
const allowed = options.allowlist;
|
|
233
|
+
if (!Array.isArray(allowed) || allowed.length === 0) {
|
|
234
|
+
throw new Error('check-licenses: no allowlist was supplied; the licences gate carries none of its own');
|
|
235
|
+
}
|
|
236
|
+
const allowlist = new Set(allowed);
|
|
237
|
+
const label = typeof options.label === 'string' ? options.label : 'allowlist';
|
|
238
|
+
const tolerances = options.tolerances ?? [];
|
|
239
|
+
const undeclared = options.undeclared ?? [];
|
|
240
|
+
const source = typeof options.source === 'string' ? options.source : 'the lockfile';
|
|
241
|
+
// Every entry is read from `packages`, which npm writes from lockfileVersion 2
|
|
242
|
+
// onward. Defaulting it to {} turned an npm 6 lockfile, or a path naming
|
|
243
|
+
// something that is not a lockfile at all, into a run that reported success
|
|
244
|
+
// over zero entries: a gate that scanned nothing, in the words of a gate that
|
|
245
|
+
// passed.
|
|
246
|
+
const packages = lockfile?.packages;
|
|
247
|
+
if (packages === null ||
|
|
248
|
+
typeof packages !== 'object' ||
|
|
249
|
+
Array.isArray(packages)) {
|
|
250
|
+
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.`);
|
|
251
|
+
}
|
|
252
|
+
// A `link: true` entry is a workspace symlink, not an installed artifact with its own licence -
|
|
253
|
+
// audit-lockfile-age.mjs already excludes these; this script should agree instead of flagging a
|
|
254
|
+
// symlink for a `license` field it was never going to have.
|
|
255
|
+
const entries = Object.entries(packages).filter(([pkgPath, meta]) => pkgPath !== '' && !meta.link);
|
|
256
|
+
const violations = [];
|
|
257
|
+
const tolerated = [];
|
|
258
|
+
const readByEvidence = [];
|
|
259
|
+
const usedPrefixes = new Set();
|
|
260
|
+
const reasons = new Set();
|
|
261
|
+
for (const [pkgPath, meta] of entries) {
|
|
262
|
+
const name = meta.name ?? pkgPath.split('node_modules/').pop();
|
|
263
|
+
const version = meta.version ?? '(unknown)';
|
|
264
|
+
// `resolved` is the URL `npm ci` fetches, and `integrity` is checked against
|
|
265
|
+
// whatever that URL returns, so an entry can declare one package and install
|
|
266
|
+
// another. Pinning the host alone left that open: an entry naming
|
|
267
|
+
// `lodash@4.17.21` and resolving to `attacker-pkg-9.9.9.tgz` on
|
|
268
|
+
// registry.npmjs.org passed, and the licence being read was never the licence
|
|
269
|
+
// of the artifact being installed. Requiring `resolved` to be the one tarball
|
|
270
|
+
// URL the registry has for this entry's own name and version is what ties the
|
|
271
|
+
// two together.
|
|
272
|
+
// "Declares nothing" is an absent, null or blank field. A field present in
|
|
273
|
+
// a shape `licenseStringOf` does not read, an array or an object with no
|
|
274
|
+
// `type`, declares something and fails below as it always has, rather than
|
|
275
|
+
// reading as nothing and taking a row's evidence.
|
|
276
|
+
const declaresNothing = meta.license === undefined ||
|
|
277
|
+
meta.license === null ||
|
|
278
|
+
(typeof meta.license === 'string' && meta.license.trim() === '');
|
|
279
|
+
const matching = declaresNothing
|
|
280
|
+
? undeclared.filter((candidate) => name.startsWith(candidate.prefix))
|
|
281
|
+
: [];
|
|
282
|
+
// A row is held to have reached an entry before the resolved-URL check
|
|
283
|
+
// below, so a tampered entry the row documents reports the tampering and
|
|
284
|
+
// never a row that reaches nothing.
|
|
285
|
+
for (const row of matching)
|
|
286
|
+
usedPrefixes.add(row.prefix);
|
|
287
|
+
const expected = registryTarballUrl(name, version);
|
|
288
|
+
if (meta.resolved !== expected) {
|
|
289
|
+
violations.push({
|
|
290
|
+
path: pkgPath,
|
|
291
|
+
name,
|
|
292
|
+
version,
|
|
293
|
+
license: meta.license ?? null,
|
|
294
|
+
reason: `resolved=${JSON.stringify(meta.resolved ?? null)} is not ${name}@${version}'s registry tarball ${expected}`,
|
|
295
|
+
});
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
const license = licenseStringOf(meta);
|
|
299
|
+
// A manifest with no licence field declares nothing, so there is no
|
|
300
|
+
// expression to widen and no tolerance is consulted. A row under
|
|
301
|
+
// `undeclared` supplies the reading and its evidence, and the identifier is
|
|
302
|
+
// then held by `isAllowed` like a declared one, so a row cannot admit what
|
|
303
|
+
// the allowlist refuses.
|
|
304
|
+
if (declaresNothing) {
|
|
305
|
+
const blank = typeof meta.license === 'string';
|
|
306
|
+
const undeclaredAs = blank
|
|
307
|
+
? 'declares no licence, the field is blank'
|
|
308
|
+
: 'declares no licence';
|
|
309
|
+
if (matching.length === 0) {
|
|
310
|
+
violations.push({
|
|
311
|
+
path: pkgPath,
|
|
312
|
+
name,
|
|
313
|
+
version,
|
|
314
|
+
license: meta.license ?? null,
|
|
315
|
+
reason: undeclaredAs,
|
|
316
|
+
});
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
// The first row whose reading the allowlist admits, as a tolerance is
|
|
320
|
+
// the first that holds; the failure names every reading that was tried.
|
|
321
|
+
const row = matching.find((candidate) => isAllowed(candidate.readAs, allowlist));
|
|
322
|
+
if (row === undefined) {
|
|
323
|
+
const tried = [...new Set(matching.map((c) => c.readAs))].join(' or ');
|
|
324
|
+
violations.push({
|
|
325
|
+
path: pkgPath,
|
|
326
|
+
name,
|
|
327
|
+
version,
|
|
328
|
+
license: meta.license ?? null,
|
|
329
|
+
reason: `read by evidence as ${tried}, which is outside ${label}`,
|
|
330
|
+
});
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
readByEvidence.push({
|
|
334
|
+
entry: `${name}@${version}`,
|
|
335
|
+
readAs: row.readAs,
|
|
336
|
+
evidence: row.evidence,
|
|
337
|
+
reason: row.reason,
|
|
338
|
+
});
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
if (isAllowed(license, allowlist))
|
|
342
|
+
continue;
|
|
343
|
+
const tolerance = tolerances.find((candidate) => isTolerated(candidate, meta, name, license, allowlist));
|
|
344
|
+
if (tolerance !== undefined) {
|
|
345
|
+
tolerated.push(`${name}@${version}`);
|
|
346
|
+
reasons.add(tolerance.reason);
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
// The raw field when the reader made nothing of it, so `["MIT"]` prints as
|
|
350
|
+
// what it is and never as the null that means "declares nothing".
|
|
351
|
+
violations.push({
|
|
352
|
+
path: pkgPath,
|
|
353
|
+
name,
|
|
354
|
+
version,
|
|
355
|
+
license: license ?? meta.license,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
tolerated.sort();
|
|
359
|
+
readByEvidence.sort((a, b) => a.entry < b.entry ? -1 : a.entry > b.entry ? 1 : 0);
|
|
360
|
+
const report = {
|
|
361
|
+
violations,
|
|
362
|
+
entryCount: entries.length,
|
|
363
|
+
tolerated,
|
|
364
|
+
toleranceReasons: [...reasons].sort(),
|
|
365
|
+
readByEvidence,
|
|
366
|
+
unusedUndeclared: undeclared
|
|
367
|
+
.map((row) => row.prefix)
|
|
368
|
+
.filter((prefix) => !usedPrefixes.has(prefix)),
|
|
369
|
+
policy: label,
|
|
370
|
+
};
|
|
371
|
+
if (violations.length === 0)
|
|
372
|
+
return report;
|
|
373
|
+
const edges = buildEdges(packages);
|
|
374
|
+
for (const violation of violations) {
|
|
375
|
+
violation.dependencyPath = findDependencyPath(packages, edges, violation.path);
|
|
376
|
+
}
|
|
377
|
+
return report;
|
|
378
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// The bound on a regular expression a consumer writes, shared by every gate
|
|
2
|
+
// that takes one.
|
|
3
|
+
//
|
|
4
|
+
// Four gates now accept a pattern out of `eval-quality.config.json`, and the
|
|
5
|
+
// bound has to be the same in all four: a length cap on the source, a flag set
|
|
6
|
+
// that excludes the two flags carrying a match position between calls, a
|
|
7
|
+
// refusal of backreferences, and a compile check so a malformed pattern is a
|
|
8
|
+
// configuration error rather than a crash at the first line it reads.
|
|
9
|
+
//
|
|
10
|
+
// `package-boundary.ts` states why these are the bounds and what they leave
|
|
11
|
+
// uncovered. That note stays there, beside `MAX_SCANNED_LINE`, because the
|
|
12
|
+
// input bound is the scanner's own and only the pattern half is shared.
|
|
13
|
+
//
|
|
14
|
+
// Run by `node` directly: Node's type stripping erases types only, so no
|
|
15
|
+
// TypeScript enum, namespace, parameter property, or non-type re-export may
|
|
16
|
+
// appear in this file or anything it imports.
|
|
17
|
+
import { z } from 'zod';
|
|
18
|
+
export const MAX_PATTERN_LENGTH = 200;
|
|
19
|
+
/**
|
|
20
|
+
* The same bound for a pattern that describes a sentence.
|
|
21
|
+
*
|
|
22
|
+
* A boundary pattern names a construct, and 200 characters is more than any of
|
|
23
|
+
* them needs. A documentation pattern quotes prose: it carries the words either
|
|
24
|
+
* side of the number or the list it captures, because those words are what stop
|
|
25
|
+
* it matching a different sentence on the same page. Holding it to the shorter
|
|
26
|
+
* bound would push consumers towards loose patterns, which is the failure mode
|
|
27
|
+
* these gates exist to close.
|
|
28
|
+
*
|
|
29
|
+
* Length is the weaker half of the bound in both cases. What the work actually
|
|
30
|
+
* rests on is the backreference refusal and the size of the subject, and both
|
|
31
|
+
* are unchanged here.
|
|
32
|
+
*/
|
|
33
|
+
export const MAX_PROSE_PATTERN_LENGTH = 800;
|
|
34
|
+
/**
|
|
35
|
+
* `\1` through `\9` and `\k<name>`. It over-refuses an escaped backslash
|
|
36
|
+
* followed by a digit, which is a literal backslash and not a backreference,
|
|
37
|
+
* and that spelling has no place in a configured pattern anyway.
|
|
38
|
+
*/
|
|
39
|
+
const BACKREFERENCE = /\\[1-9]|\\k</;
|
|
40
|
+
const FLAG_MESSAGE = 'admits only i, m, s, u and v. A g or a y carries a match position between calls, so a pattern holding either would match every second thing it should have matched';
|
|
41
|
+
export const PatternSource = z.string().min(1).max(MAX_PATTERN_LENGTH);
|
|
42
|
+
export const ProsePatternSource = z
|
|
43
|
+
.string()
|
|
44
|
+
.min(1)
|
|
45
|
+
.max(MAX_PROSE_PATTERN_LENGTH);
|
|
46
|
+
export const PatternFlags = z
|
|
47
|
+
.string()
|
|
48
|
+
.regex(/^[imsuv]*$/, FLAG_MESSAGE)
|
|
49
|
+
.default('');
|
|
50
|
+
/**
|
|
51
|
+
* The two refusals a length bound and a flag set do not cover. Exported as a
|
|
52
|
+
* function so a gate composing its own object around a pattern reports them
|
|
53
|
+
* against its own key path.
|
|
54
|
+
*/
|
|
55
|
+
export function refinePattern(match, flags, ctx, path) {
|
|
56
|
+
if (BACKREFERENCE.test(match)) {
|
|
57
|
+
ctx.addIssue({
|
|
58
|
+
code: 'custom',
|
|
59
|
+
path,
|
|
60
|
+
message: 'carries a backreference, which is the construct that turns a linear scan into an exponential one; write the pattern without one',
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
new RegExp(match, flags);
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
ctx.addIssue({
|
|
68
|
+
code: 'custom',
|
|
69
|
+
path,
|
|
70
|
+
message: `is not a regular expression: ${error instanceof Error ? error.message : String(error)}`,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* A pattern on its own, for a gate with nothing to say about it beyond where it
|
|
76
|
+
* is matched. A gate that reports under a name or carries a reason composes
|
|
77
|
+
* `PatternSource`, `PatternFlags` and `refinePattern` into its own object
|
|
78
|
+
* instead.
|
|
79
|
+
*/
|
|
80
|
+
export const ConsumerPattern = z
|
|
81
|
+
.strictObject({
|
|
82
|
+
match: PatternSource.describe('The regular expression, as source text.'),
|
|
83
|
+
flags: PatternFlags.describe('Regular-expression flags. Empty by default.'),
|
|
84
|
+
})
|
|
85
|
+
.superRefine((pattern, ctx) => {
|
|
86
|
+
refinePattern(pattern.match, pattern.flags, ctx, ['match']);
|
|
87
|
+
});
|
|
88
|
+
/** The same object at the prose bound, for the documentation gates. */
|
|
89
|
+
export const ProsePattern = z
|
|
90
|
+
.strictObject({
|
|
91
|
+
match: ProsePatternSource.describe('The regular expression, as source text.'),
|
|
92
|
+
flags: PatternFlags.describe('Regular-expression flags. Empty by default.'),
|
|
93
|
+
})
|
|
94
|
+
.superRefine((pattern, ctx) => {
|
|
95
|
+
refinePattern(pattern.match, pattern.flags, ctx, ['match']);
|
|
96
|
+
});
|
|
97
|
+
export const compilePattern = (pattern) => new RegExp(pattern.match, pattern.flags);
|
|
98
|
+
/**
|
|
99
|
+
* The same pattern with `g` added, for a gate that counts every occurrence.
|
|
100
|
+
* `g` is outside the configured flag set because a consumer cannot be given a
|
|
101
|
+
* stateful `lastIndex`; a gate that needs it adds it at the point of use, where
|
|
102
|
+
* the regular expression is built fresh for each subject.
|
|
103
|
+
*/
|
|
104
|
+
export const compileGlobalPattern = (pattern) => new RegExp(pattern.match, `${pattern.flags}g`);
|