eval-quality 3.1.0 → 3.3.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 +1 -1
- package/dist/application/index.d.ts +6 -0
- package/dist/application/index.js +8 -0
- package/dist/gates/audit-lockfile-age.mjs +105 -8
- package/dist/gates/check-dependency-direction.js +10 -10
- package/dist/gates/check-doc-claims.js +1026 -0
- package/dist/gates/check-doc-counts.js +408 -0
- package/dist/gates/check-doc-invocations.mjs +677 -0
- package/dist/gates/check-licenses.mjs +89 -16
- package/dist/gates/consumer-pattern.js +104 -0
- package/dist/gates/dependency-direction.js +130 -0
- package/dist/gates/gate-config.js +179 -12
- package/dist/gates/gates-cli.js +216 -19
- package/dist/gates/lineage-ownership.js +20 -23
- package/dist/gates/module-value.js +187 -0
- package/dist/gates/package-boundary.js +33 -144
- package/dist/gates/scanned-paths.js +110 -0
- package/dist/gates/typescript-scanner.js +181 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.js +1 -1
- package/dist/ports/environment-probe-port.d.ts +12 -12
- package/package.json +8 -5
|
@@ -218,6 +218,13 @@ function findDependencyPath(packages, edges, targetPath) {
|
|
|
218
218
|
* fails every entry or silently permits every entry, and a gate that picks one
|
|
219
219
|
* of those on the caller's behalf is the fallback this package does not have.
|
|
220
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
|
+
*
|
|
221
228
|
* `options.source` is the path this lockfile was read from, named in the refusal
|
|
222
229
|
* a document without a `packages` object earns.
|
|
223
230
|
*/
|
|
@@ -229,6 +236,7 @@ export function checkLicenses(lockfile, options = {}) {
|
|
|
229
236
|
const allowlist = new Set(allowed);
|
|
230
237
|
const label = typeof options.label === 'string' ? options.label : 'allowlist';
|
|
231
238
|
const tolerances = options.tolerances ?? [];
|
|
239
|
+
const undeclared = options.undeclared ?? [];
|
|
232
240
|
const source = typeof options.source === 'string' ? options.source : 'the lockfile';
|
|
233
241
|
// Every entry is read from `packages`, which npm writes from lockfileVersion 2
|
|
234
242
|
// onward. Defaulting it to {} turned an npm 6 lockfile, or a path naming
|
|
@@ -247,6 +255,8 @@ export function checkLicenses(lockfile, options = {}) {
|
|
|
247
255
|
const entries = Object.entries(packages).filter(([pkgPath, meta]) => pkgPath !== '' && !meta.link);
|
|
248
256
|
const violations = [];
|
|
249
257
|
const tolerated = [];
|
|
258
|
+
const readByEvidence = [];
|
|
259
|
+
const usedPrefixes = new Set();
|
|
250
260
|
const reasons = new Set();
|
|
251
261
|
for (const [pkgPath, meta] of entries) {
|
|
252
262
|
const name = meta.name ?? pkgPath.split('node_modules/').pop();
|
|
@@ -259,6 +269,21 @@ export function checkLicenses(lockfile, options = {}) {
|
|
|
259
269
|
// of the artifact being installed. Requiring `resolved` to be the one tarball
|
|
260
270
|
// URL the registry has for this entry's own name and version is what ties the
|
|
261
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);
|
|
262
287
|
const expected = registryTarballUrl(name, version);
|
|
263
288
|
if (meta.resolved !== expected) {
|
|
264
289
|
violations.push({
|
|
@@ -271,6 +296,48 @@ export function checkLicenses(lockfile, options = {}) {
|
|
|
271
296
|
continue;
|
|
272
297
|
}
|
|
273
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
|
+
}
|
|
274
341
|
if (isAllowed(license, allowlist))
|
|
275
342
|
continue;
|
|
276
343
|
const tolerance = tolerances.find((candidate) => isTolerated(candidate, meta, name, license, allowlist));
|
|
@@ -279,27 +346,33 @@ export function checkLicenses(lockfile, options = {}) {
|
|
|
279
346
|
reasons.add(tolerance.reason);
|
|
280
347
|
continue;
|
|
281
348
|
}
|
|
282
|
-
|
|
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
|
+
});
|
|
283
357
|
}
|
|
284
358
|
tolerated.sort();
|
|
285
|
-
|
|
286
|
-
|
|
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 {
|
|
359
|
+
readByEvidence.sort((a, b) => a.entry < b.entry ? -1 : a.entry > b.entry ? 1 : 0);
|
|
360
|
+
const report = {
|
|
299
361
|
violations,
|
|
300
362
|
entryCount: entries.length,
|
|
301
363
|
tolerated,
|
|
302
|
-
toleranceReasons,
|
|
364
|
+
toleranceReasons: [...reasons].sort(),
|
|
365
|
+
readByEvidence,
|
|
366
|
+
unusedUndeclared: undeclared
|
|
367
|
+
.map((row) => row.prefix)
|
|
368
|
+
.filter((prefix) => !usedPrefixes.has(prefix)),
|
|
303
369
|
policy: label,
|
|
304
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;
|
|
305
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`);
|
|
@@ -473,6 +473,20 @@ function scanFile(file, source, files, graph, violations) {
|
|
|
473
473
|
const openIndex = tokens[i + 1]?.kind === SyntaxKind.QuestionDotToken ? i + 2 : i + 1;
|
|
474
474
|
if (tokens[openIndex]?.kind !== SyntaxKind.OpenParenToken)
|
|
475
475
|
continue;
|
|
476
|
+
// The site this arm exists for is the free identifier `require` called
|
|
477
|
+
// with a specifier. Two shapes share its tokens and are ordinary
|
|
478
|
+
// JavaScript: a member call, `sandbox.require('fs')`, where the token
|
|
479
|
+
// before is `.` or `?.`, and a method named require, `require(name) {`
|
|
480
|
+
// in an object literal or a class, where the token after the matching
|
|
481
|
+
// `)` is `{`. A call's own `)` is never followed by `{`, so
|
|
482
|
+
// `if (require('x')) {` still reads as the call it is. A consumer
|
|
483
|
+
// renaming a mock to get past this arm is a gate teaching the wrong lesson.
|
|
484
|
+
const before = tokens[i - 1]?.kind;
|
|
485
|
+
if (before === SyntaxKind.DotToken ||
|
|
486
|
+
before === SyntaxKind.QuestionDotToken ||
|
|
487
|
+
isMethodDefinition(tokens, i, openIndex)) {
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
476
490
|
if (graph.commonjs === 'forbid') {
|
|
477
491
|
violations.push({
|
|
478
492
|
file,
|
|
@@ -545,6 +559,122 @@ function scanFile(file, source, files, graph, violations) {
|
|
|
545
559
|
* against `graph` and returns every violation found, in no particular cross-file
|
|
546
560
|
* order.
|
|
547
561
|
*/
|
|
562
|
+
/**
|
|
563
|
+
* A modifier or generator marker that can sit between a declaration boundary
|
|
564
|
+
* and the member name it modifies: `async`, `static`, `get`, `set`,
|
|
565
|
+
* `readonly`, an access modifier, or `*`. Skipped when walking backward from
|
|
566
|
+
* `require` to find what actually introduces it.
|
|
567
|
+
*/
|
|
568
|
+
const MEMBER_MODIFIERS = new Set([
|
|
569
|
+
SyntaxKind.AsyncKeyword,
|
|
570
|
+
SyntaxKind.StaticKeyword,
|
|
571
|
+
SyntaxKind.GetKeyword,
|
|
572
|
+
SyntaxKind.SetKeyword,
|
|
573
|
+
SyntaxKind.ReadonlyKeyword,
|
|
574
|
+
SyntaxKind.PrivateKeyword,
|
|
575
|
+
SyntaxKind.PublicKeyword,
|
|
576
|
+
SyntaxKind.ProtectedKeyword,
|
|
577
|
+
SyntaxKind.AsteriskToken,
|
|
578
|
+
]);
|
|
579
|
+
/**
|
|
580
|
+
* Whether `require`, immediately followed by a parameter list and then `{`,
|
|
581
|
+
* actually sits where a name is declared rather than where a call's result is
|
|
582
|
+
* followed by an unrelated block statement: `const mod = require('x')` and a
|
|
583
|
+
* stray `{` on the next line tokenize exactly like a method body, and only
|
|
584
|
+
* what precedes `require` tells them apart. Skips the modifiers above, then
|
|
585
|
+
* requires the next token to be the opening brace of the object, class or
|
|
586
|
+
* interface `require` is the first member of, a `,` or `;` separating it from
|
|
587
|
+
* a prior member, a `case`/`default` label, `function` for a function
|
|
588
|
+
* declaration, or the start of the file. Anything else -- `=`, `return`, or
|
|
589
|
+
* any other token an expression puts before a call -- means this is a call.
|
|
590
|
+
*
|
|
591
|
+
* A `;` or `}` immediately before `require` stays undecidable this way: both
|
|
592
|
+
* a prior class member and a prior unrelated statement end on one, and
|
|
593
|
+
* telling them apart needs knowing what kind of block `require` sits in,
|
|
594
|
+
* which a token stream does not carry. Rare enough, and specific enough to
|
|
595
|
+
* write on purpose, that it is left as the one shape this arm still misses.
|
|
596
|
+
*/
|
|
597
|
+
function isDeclarationPosition(tokens, requireIndex) {
|
|
598
|
+
let j = requireIndex - 1;
|
|
599
|
+
while (j >= 0 && MEMBER_MODIFIERS.has(tokens[j]?.kind))
|
|
600
|
+
j -= 1;
|
|
601
|
+
if (j < 0)
|
|
602
|
+
return true;
|
|
603
|
+
const kind = tokens[j]?.kind;
|
|
604
|
+
return (kind === SyntaxKind.OpenBraceToken ||
|
|
605
|
+
kind === SyntaxKind.CommaToken ||
|
|
606
|
+
kind === SyntaxKind.SemicolonToken ||
|
|
607
|
+
kind === SyntaxKind.CaseKeyword ||
|
|
608
|
+
kind === SyntaxKind.DefaultKeyword ||
|
|
609
|
+
kind === SyntaxKind.FunctionKeyword);
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* Whether the parenthesised list opening at `openIndex` is a parameter list,
|
|
613
|
+
* which is to say `require` here is a method or a signature and never a call.
|
|
614
|
+
* A `{` straight after the matching `)` defers to `isDeclarationPosition`. A
|
|
615
|
+
* `:` after it is either a return-type annotation, a ternary's else, or a
|
|
616
|
+
* `case`/`default` label, and the three are told apart by looking back from
|
|
617
|
+
* `require` at bracket depth zero: a ternary has its `?` before the call and
|
|
618
|
+
* inside the same expression, a `case`/`default` label has the keyword
|
|
619
|
+
* immediately before the call and nothing between them, and a member
|
|
620
|
+
* declaration has none of those before its own `{`, `,` or `;` -- or before
|
|
621
|
+
* reaching an enclosing `(`, `[` or `{` with nothing still open inside it,
|
|
622
|
+
* which is the same boundary one level up. An unclosed list reads as a call,
|
|
623
|
+
* which is what this arm already did with a stream it could not place.
|
|
624
|
+
*/
|
|
625
|
+
function isMethodDefinition(tokens, requireIndex, openIndex) {
|
|
626
|
+
let depth = 0;
|
|
627
|
+
let closeIndex = -1;
|
|
628
|
+
for (let j = openIndex; j < tokens.length; j++) {
|
|
629
|
+
const kind = tokens[j]?.kind;
|
|
630
|
+
if (kind === SyntaxKind.OpenParenToken)
|
|
631
|
+
depth += 1;
|
|
632
|
+
else if (kind === SyntaxKind.CloseParenToken) {
|
|
633
|
+
depth -= 1;
|
|
634
|
+
if (depth === 0) {
|
|
635
|
+
closeIndex = j;
|
|
636
|
+
break;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
if (closeIndex === -1)
|
|
641
|
+
return false;
|
|
642
|
+
const after = tokens[closeIndex + 1]?.kind;
|
|
643
|
+
if (after === SyntaxKind.OpenBraceToken) {
|
|
644
|
+
return isDeclarationPosition(tokens, requireIndex);
|
|
645
|
+
}
|
|
646
|
+
if (after !== SyntaxKind.ColonToken)
|
|
647
|
+
return false;
|
|
648
|
+
depth = 0;
|
|
649
|
+
for (let j = requireIndex - 1; j >= 0; j--) {
|
|
650
|
+
const kind = tokens[j]?.kind;
|
|
651
|
+
if (kind === SyntaxKind.CloseParenToken ||
|
|
652
|
+
kind === SyntaxKind.CloseBracketToken ||
|
|
653
|
+
kind === SyntaxKind.CloseBraceToken) {
|
|
654
|
+
depth += 1;
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
if (kind === SyntaxKind.OpenParenToken ||
|
|
658
|
+
kind === SyntaxKind.OpenBracketToken ||
|
|
659
|
+
kind === SyntaxKind.OpenBraceToken) {
|
|
660
|
+
if (depth === 0)
|
|
661
|
+
return true;
|
|
662
|
+
depth -= 1;
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
if (depth > 0)
|
|
666
|
+
continue;
|
|
667
|
+
if (kind === SyntaxKind.QuestionToken)
|
|
668
|
+
return false;
|
|
669
|
+
if (kind === SyntaxKind.CaseKeyword || kind === SyntaxKind.DefaultKeyword) {
|
|
670
|
+
return false;
|
|
671
|
+
}
|
|
672
|
+
if (kind === SyntaxKind.SemicolonToken || kind === SyntaxKind.CommaToken) {
|
|
673
|
+
return true;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
return true;
|
|
677
|
+
}
|
|
548
678
|
export function scanSources(files, graph) {
|
|
549
679
|
const fileSet = new Set(files.keys());
|
|
550
680
|
const violations = [];
|
|
@@ -25,17 +25,26 @@ import { readFile } from 'node:fs/promises';
|
|
|
25
25
|
import { isAbsolute, resolve } from 'node:path';
|
|
26
26
|
import { z } from 'zod';
|
|
27
27
|
import { DependencyDirectionSection } from './check-dependency-direction.js';
|
|
28
|
+
import { DocClaimsSection } from './check-doc-claims.js';
|
|
29
|
+
import { DocCountsSection } from './check-doc-counts.js';
|
|
28
30
|
import { FieldOwnershipSection } from './lineage-ownership.js';
|
|
29
31
|
import { PackageBoundarySection } from './package-boundary.js';
|
|
32
|
+
import { RelativePath } from './scanned-paths.js';
|
|
30
33
|
/** The file a consumer writes, resolved against the directory the gate runs in. */
|
|
31
34
|
export const DEFAULT_CONFIG_FILE = 'eval-quality.config.json';
|
|
32
35
|
/**
|
|
33
36
|
* The gates this build publishes, in the order the usage text lists them.
|
|
34
37
|
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
38
|
+
* Five of the eight keep their schema in the gate module rather than here. Three
|
|
39
|
+
* do so because they are reachable only after `typescript` has been probed and
|
|
40
|
+
* still have to declare their own section, and two because their schemas are
|
|
41
|
+
* large enough that carrying them here would make this file the place every gate
|
|
42
|
+
* is described. Importing any of those schemas is safe on any load path: each
|
|
43
|
+
* reaches `typescript` through a dynamic import and nothing else.
|
|
44
|
+
*
|
|
45
|
+
* The two `.mjs` gates and the invocation gate keep their schemas here, because
|
|
46
|
+
* a `.mjs` module cannot import `zod` without breaking the pre-install path PR 1
|
|
47
|
+
* records.
|
|
39
48
|
*/
|
|
40
49
|
export const GATE_NAMES = [
|
|
41
50
|
'lockfile-age',
|
|
@@ -43,6 +52,9 @@ export const GATE_NAMES = [
|
|
|
43
52
|
'dependency-direction',
|
|
44
53
|
'package-boundary',
|
|
45
54
|
'field-ownership',
|
|
55
|
+
'doc-invocations',
|
|
56
|
+
'doc-counts',
|
|
57
|
+
'doc-claims',
|
|
46
58
|
];
|
|
47
59
|
/**
|
|
48
60
|
* The audit window when a configuration names none, in days.
|
|
@@ -63,6 +75,31 @@ const NonEmpty = z.string().min(1);
|
|
|
63
75
|
* a hand maintains in step with the dependency graph.
|
|
64
76
|
*/
|
|
65
77
|
const SpdxIdentifier = NonEmpty.regex(/^[A-Za-z0-9][A-Za-z0-9.+-]*$/, 'is not an SPDX short identifier: this setting takes identifiers such as MIT or Apache-2.0, and a package-and-version pin is not one');
|
|
78
|
+
/**
|
|
79
|
+
* An npm package name, for an age exclusion: an optional `@scope/` and then the
|
|
80
|
+
* name, in the URL-safe charset npm admits. A second `@` is refused anywhere, so
|
|
81
|
+
* `left-pad@1.3.0` cannot be written here. Uppercase is admitted because a
|
|
82
|
+
* lockfile can carry a legacy name that has it, and this setting names what the
|
|
83
|
+
* lockfile has. The setting mirrors `.npmrc`'s `min-release-age-exclude`, which
|
|
84
|
+
* takes names for the reason the allowlist takes identifiers: a version here
|
|
85
|
+
* would be a value a hand maintains in step with the dependency graph.
|
|
86
|
+
*/
|
|
87
|
+
const PackageName = NonEmpty.regex(/^(?:@[A-Za-z0-9._~!*'()-]+\/)?[A-Za-z0-9._~!*'()-]+$/, 'is not a package name: this setting takes names such as left-pad or @scope/name, and a package-and-version pin is not one');
|
|
88
|
+
/**
|
|
89
|
+
* One exemption from the age window. It names the package, the lockfiles it
|
|
90
|
+
* holds in, and why, so the run prints the reason beside the entry the way a
|
|
91
|
+
* policy and a tolerance do. A row that reaches no entry in any lockfile it
|
|
92
|
+
* names is refused at run time: the package left the lockfile, or the name is
|
|
93
|
+
* mistyped, and either way the row is a value nobody is holding.
|
|
94
|
+
*/
|
|
95
|
+
const AgeExclusion = z.strictObject({
|
|
96
|
+
name: PackageName.describe('The package the exemption covers, as the lockfile names the installed package, and every entry under that name in the lockfile, a nested duplicate at another version included. For an npm: alias that is the aliased package, so an entry installed at node_modules/foo from npm:bar@x is excluded by writing bar.'),
|
|
97
|
+
reason: NonEmpty.describe('Why this package may be adopted inside the window. It is printed on every run that uses it.'),
|
|
98
|
+
lockfiles: z
|
|
99
|
+
.array(NonEmpty)
|
|
100
|
+
.min(1)
|
|
101
|
+
.describe('The lockfiles this exemption applies to, and no others.'),
|
|
102
|
+
});
|
|
66
103
|
const LockfileAgeSection = z
|
|
67
104
|
.strictObject({
|
|
68
105
|
lockfiles: z
|
|
@@ -74,6 +111,41 @@ const LockfileAgeSection = z
|
|
|
74
111
|
.min(1)
|
|
75
112
|
.default(LOCKFILE_WINDOW_DAYS_DEFAULT)
|
|
76
113
|
.describe('How old an entry has to be, in days, and at least 1. A duration, so nothing here goes stale as time passes. Zero puts the cutoff at the instant of the run, admits a package published that same instant, and still reports that every entry was published before the cutoff.'),
|
|
114
|
+
cache: RelativePath.optional().describe('A JSON file mapping "name@version" to a publication timestamp. A publication time is fixed the moment it happens, so a reading taken once is correct forever and this cache carries no staleness bound. An entry it holds is used with no request; an entry it does not is fetched, and a fetch that fails still fails the gate. The gate only reads it.'),
|
|
115
|
+
exclude: z
|
|
116
|
+
.array(AgeExclusion)
|
|
117
|
+
.optional()
|
|
118
|
+
.describe("Packages exempt from the window and from the registry fetch, and never from the resolved-URL check, each row carrying its reason. The counterpart of .npmrc's min-release-age-exclude: names, so no version is pinned here. Every excluded entry is printed on every run, passing or failing."),
|
|
119
|
+
})
|
|
120
|
+
// A row naming a lockfile the section does not declare would load clean and
|
|
121
|
+
// apply to nothing, for the reason the licences section refuses the same
|
|
122
|
+
// under `tolerances` and `undeclared`. Two rows exempting one name in one
|
|
123
|
+
// lockfile are one exemption written twice with two reasons, so the second
|
|
124
|
+
// is refused.
|
|
125
|
+
.superRefine((section, ctx) => {
|
|
126
|
+
const declared = new Set(section.lockfiles);
|
|
127
|
+
const seen = new Set();
|
|
128
|
+
section.exclude?.forEach((row, index) => {
|
|
129
|
+
row.lockfiles.forEach((named, position) => {
|
|
130
|
+
if (!declared.has(named)) {
|
|
131
|
+
ctx.addIssue({
|
|
132
|
+
code: 'custom',
|
|
133
|
+
path: ['exclude', index, 'lockfiles', position],
|
|
134
|
+
message: `names "${named}", which is not one of the lockfiles this section declares: ${section.lockfiles.join(', ')}`,
|
|
135
|
+
});
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const key = `${row.name}\u0000${named}`;
|
|
139
|
+
if (seen.has(key)) {
|
|
140
|
+
ctx.addIssue({
|
|
141
|
+
code: 'custom',
|
|
142
|
+
path: ['exclude', index],
|
|
143
|
+
message: `excludes "${row.name}" in ${named}, which an earlier row already excludes; one exemption carries one reason`,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
seen.add(key);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
77
149
|
})
|
|
78
150
|
.describe("Fails on a locked entry published inside the window, on metadata that could not be fetched, and on an entry whose resolved URL is not that entry's own tarball on the npm registry.");
|
|
79
151
|
/**
|
|
@@ -112,6 +184,27 @@ const LicenceTolerance = z.strictObject({
|
|
|
112
184
|
.optional()
|
|
113
185
|
.describe('The exception holds only while this file carries this text. An absent or unreadable file withdraws it.'),
|
|
114
186
|
});
|
|
187
|
+
/**
|
|
188
|
+
* A reading for an entry whose manifest declares no licence. A tolerance widens
|
|
189
|
+
* the allowlist for a family that declares one, under a condition the gate can
|
|
190
|
+
* re-read; an undeclared entry declares nothing, so there is no expression to
|
|
191
|
+
* widen. The row supplies what the manifest would have said and the evidence
|
|
192
|
+
* for it, and the identifier is then held by the rule every other entry is held
|
|
193
|
+
* by, so a row cannot admit what the allowlist refuses. "Declares no licence"
|
|
194
|
+
* is an absent, null or blank `license` field: a field present in a shape the
|
|
195
|
+
* gate does not read, an array or an object with no `type`, declares something
|
|
196
|
+
* and fails as it always has.
|
|
197
|
+
*/
|
|
198
|
+
const UndeclaredLicence = z.strictObject({
|
|
199
|
+
reason: NonEmpty.describe('Why the manifest carries no licence field. It is printed on every run that uses it.'),
|
|
200
|
+
lockfiles: z
|
|
201
|
+
.array(NonEmpty)
|
|
202
|
+
.min(1)
|
|
203
|
+
.describe('The lockfiles this reading applies to, and no others.'),
|
|
204
|
+
prefix: NonEmpty.describe('The package-name prefix the reading covers, matched as a plain string prefix with no boundary, so a whole name is the tightest prefix and zod-to-ts also reaches zod-to-ts-plugin the day one appears undeclared. A prefix, so no version is pinned here. It reaches only an entry that declares no licence; an entry under it that declares one is held to its declaration, and a row that reaches no such entry in any lockfile it names is refused at run time.'),
|
|
205
|
+
readAs: SpdxIdentifier.describe('The one identifier the entry is read as, held against the allowlist like any declared identifier. One identifier only: an expression, a marker or an optional flag has no meaning for an entry that declares nothing.'),
|
|
206
|
+
evidence: NonEmpty.describe('Where the reading comes from, such as the registry packument or a LICENSE file in the repository. It is printed on every run that uses it.'),
|
|
207
|
+
});
|
|
115
208
|
const LicencesSection = z
|
|
116
209
|
.strictObject({
|
|
117
210
|
lockfiles: z
|
|
@@ -130,13 +223,18 @@ const LicencesSection = z
|
|
|
130
223
|
.array(LicenceTolerance)
|
|
131
224
|
.optional()
|
|
132
225
|
.describe('Scoped exceptions, each carrying its own reason.'),
|
|
226
|
+
undeclared: z
|
|
227
|
+
.array(UndeclaredLicence)
|
|
228
|
+
.optional()
|
|
229
|
+
.describe('Readings for entries whose manifest declares no licence, each carrying its evidence and its reason.'),
|
|
133
230
|
})
|
|
134
|
-
// `policies` is keyed by lockfile path and every tolerance
|
|
135
|
-
// it applies to,
|
|
136
|
-
// matching no declared lockfile loads clean
|
|
137
|
-
// like "pacakge-lock.json" reads as a policy
|
|
138
|
-
// Keeping those
|
|
139
|
-
// does not have, so a name matching nothing is
|
|
231
|
+
// `policies` is keyed by lockfile path, and every tolerance and every
|
|
232
|
+
// undeclared row names the lockfiles it applies to, all by the same string
|
|
233
|
+
// `lockfiles` names them by. A value matching no declared lockfile loads clean
|
|
234
|
+
// and applies to nothing, so a typo like "pacakge-lock.json" reads as a policy
|
|
235
|
+
// that was written and never runs. Keeping those lists in step by hand is the
|
|
236
|
+
// class of setting this format does not have, so a name matching nothing is
|
|
237
|
+
// refused here.
|
|
140
238
|
.superRefine((section, ctx) => {
|
|
141
239
|
const declared = new Set(section.lockfiles);
|
|
142
240
|
const requireDeclared = (named, path) => {
|
|
@@ -156,13 +254,76 @@ const LicencesSection = z
|
|
|
156
254
|
requireDeclared(named, ['tolerances', index, 'lockfiles', position]);
|
|
157
255
|
});
|
|
158
256
|
});
|
|
257
|
+
// Two rows reading one prefix in one lockfile would read one package as
|
|
258
|
+
// two licences, so the second is refused.
|
|
259
|
+
const seen = new Set();
|
|
260
|
+
section.undeclared?.forEach((row, index) => {
|
|
261
|
+
row.lockfiles.forEach((named, position) => {
|
|
262
|
+
requireDeclared(named, ['undeclared', index, 'lockfiles', position]);
|
|
263
|
+
const key = `${row.prefix}\u0000${named}`;
|
|
264
|
+
if (declared.has(named) && seen.has(key)) {
|
|
265
|
+
ctx.addIssue({
|
|
266
|
+
code: 'custom',
|
|
267
|
+
path: ['undeclared', index],
|
|
268
|
+
message: `reads "${row.prefix}" in ${named}, which an earlier row already reads; one package is read as one licence`,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
seen.add(key);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
159
274
|
})
|
|
160
275
|
.describe("Holds every locked entry's licence expression against an allowlist of SPDX identifiers, and fails on an entry whose resolved URL is not that entry's own tarball on the npm registry.");
|
|
276
|
+
/**
|
|
277
|
+
* The invocation gate's section. It sits here rather than in its gate module,
|
|
278
|
+
* because that gate is `.mjs` and stays free of every import from
|
|
279
|
+
* `node_modules`, which is what keeps the pre-install path open for the two
|
|
280
|
+
* gates that run before `npm ci`.
|
|
281
|
+
*/
|
|
282
|
+
const DocumentedBinary = z
|
|
283
|
+
.strictObject({
|
|
284
|
+
entry: RelativePath.describe('The built entry point every documented command is run against. It is a precondition: a gate that skipped when it was absent would exit 0 having executed nothing.'),
|
|
285
|
+
spellings: z
|
|
286
|
+
.array(NonEmpty)
|
|
287
|
+
.min(1)
|
|
288
|
+
.describe('How your documentation writes the command, as the literal text a reader types. Each is matched at the start of a fenced line, with whitespace or end of line after it, so a sample of your own diagnostic output is left alone.'),
|
|
289
|
+
installedPrefix: NonEmpty.optional().describe('The path prefix a page uses for a file inside the installed package, such as "node_modules/your-package/". Mapping it away is what lets those examples be checked against real bytes.'),
|
|
290
|
+
})
|
|
291
|
+
.describe('One binary a documented command line runs.');
|
|
292
|
+
const DocInvocationsSection = z
|
|
293
|
+
.strictObject({
|
|
294
|
+
pages: z
|
|
295
|
+
.array(RelativePath)
|
|
296
|
+
.min(1)
|
|
297
|
+
.describe("The pages whose fenced commands are run, as files or directories to walk. Naming the configuration's own directory is refused: every fenced command in a whole repository is more than this gate should run."),
|
|
298
|
+
binary: z
|
|
299
|
+
.union([DocumentedBinary, z.array(DocumentedBinary).min(1)])
|
|
300
|
+
.describe("What a documented command line runs. One object for a package that publishes one binary, or an array for a package that publishes several, each carrying its own entry, spellings and installedPrefix. Every spelling across every binary is matched against the same page and the longest one wins, so a short spelling belonging to one binary never claims a line that opens with a longer spelling belonging to another. Each binary's entry is its own precondition and the refusal names which one is missing. An empty array is refused, since a gate with no spelling to match extracts nothing and reports a pass over nothing."),
|
|
301
|
+
sampleInput: RelativePath.describe('A file that stands in for an input only a reader has, such as `<path>`. A run that needed one is judged for usage errors and crashes and no more.'),
|
|
302
|
+
usageExit: z
|
|
303
|
+
.int()
|
|
304
|
+
.min(1)
|
|
305
|
+
.max(255)
|
|
306
|
+
.default(64)
|
|
307
|
+
.describe('The code your command line returns when a command or a flag does not exist. Every documented invocation exiting with it fails, whatever inputs it named. 64 is sysexits.h EX_USAGE.'),
|
|
308
|
+
timeoutMs: z
|
|
309
|
+
.int()
|
|
310
|
+
.min(1000)
|
|
311
|
+
.default(30_000)
|
|
312
|
+
.describe('How long one documented invocation may run.'),
|
|
313
|
+
elisionLimit: z
|
|
314
|
+
.int()
|
|
315
|
+
.min(0)
|
|
316
|
+
.default(3)
|
|
317
|
+
.describe('How many "..." elisions one transcribed output line may carry. Matching them is polynomial in the count, so a line carrying many over a long repetitive diagnostic can run for minutes.'),
|
|
318
|
+
})
|
|
319
|
+
.describe('Runs every fenced command in your documentation against your built binary and compares the exit code with what the page claims. A page that declares its exit may transcribe the diagnostic beside it, and that block is compared line for line.');
|
|
161
320
|
/**
|
|
162
321
|
* The whole document, as one schema. It is where the format states its own
|
|
163
|
-
* incremental-adoption property, and its one consumer is `
|
|
322
|
+
* incremental-adoption property, and its one consumer is the `doc-claims` gate,
|
|
164
323
|
* which parses the documented example through it so the page a consumer copies
|
|
165
|
-
* is held to the format it describes.
|
|
324
|
+
* is held to the format it describes. The relation runs through the
|
|
325
|
+
* configuration rather than through an import: `eval-quality.config.json` names
|
|
326
|
+
* this export as the schema for that fence, and the gate imports it at run time.
|
|
166
327
|
*
|
|
167
328
|
* The loader never uses it. Validating the document whole would block a gate
|
|
168
329
|
* the caller is running on a gate it is not, which is the opposite of the
|
|
@@ -175,6 +336,9 @@ export const GateConfiguration = z
|
|
|
175
336
|
'dependency-direction': DependencyDirectionSection.optional(),
|
|
176
337
|
'package-boundary': PackageBoundarySection.optional(),
|
|
177
338
|
'field-ownership': FieldOwnershipSection.optional(),
|
|
339
|
+
'doc-invocations': DocInvocationsSection.optional(),
|
|
340
|
+
'doc-counts': DocCountsSection.optional(),
|
|
341
|
+
'doc-claims': DocClaimsSection.optional(),
|
|
178
342
|
})
|
|
179
343
|
.describe("The gates this repository has chosen to run, keyed by gate name. Incremental adoption is structural: the file carries only the gates you have adopted, and configuring a gate is what opts into it. A gate you invoke with no section here refuses by name; it falls back to nobody else's values.");
|
|
180
344
|
const refuse = (message) => ({
|
|
@@ -249,3 +413,6 @@ export const loadLicencesConfig = (options = {}) => loadSection('licences', Lice
|
|
|
249
413
|
export const loadDependencyDirectionConfig = (options = {}) => loadSection('dependency-direction', DependencyDirectionSection, options);
|
|
250
414
|
export const loadPackageBoundaryConfig = (options = {}) => loadSection('package-boundary', PackageBoundarySection, options);
|
|
251
415
|
export const loadFieldOwnershipConfig = (options = {}) => loadSection('field-ownership', FieldOwnershipSection, options);
|
|
416
|
+
export const loadDocInvocationsConfig = (options = {}) => loadSection('doc-invocations', DocInvocationsSection, options);
|
|
417
|
+
export const loadDocCountsConfig = (options = {}) => loadSection('doc-counts', DocCountsSection, options);
|
|
418
|
+
export const loadDocClaimsConfig = (options = {}) => loadSection('doc-claims', DocClaimsSection, options);
|