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.
@@ -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
- violations.push({ path: pkgPath, name, version, license });
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
- 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 {
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
- * Three of the five keep their schema in the gate module rather than here, so
36
- * that a module reachable only after `typescript` has been probed still declares
37
- * its own section. Importing those schemas is safe on any load path: each of the
38
- * three reaches `typescript` through a dynamic import and nothing else.
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 names the lockfiles
135
- // it applies to, both by the same string `lockfiles` names them by. A value
136
- // matching no declared lockfile loads clean and applies to nothing, so a typo
137
- // like "pacakge-lock.json" reads as a policy that was written and never runs.
138
- // Keeping those three lists in step by hand is the class of setting this format
139
- // does not have, so a name matching nothing is refused here.
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 `check-doc-claims.ts`,
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);