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.
@@ -24,12 +24,17 @@
24
24
  import { readFile } from 'node:fs/promises';
25
25
  import { dirname, resolve } from 'node:path';
26
26
  import process from 'node:process';
27
- import { auditLockfileAge, LOCKFILE_SHAPE_ERROR, } from './audit-lockfile-age.mjs';
27
+ import { auditLockfileAge, LOCKFILE_SHAPE_ERROR, readPublishCache, } from './audit-lockfile-age.mjs';
28
28
  import { runDependencyDirection } from './check-dependency-direction.js';
29
+ import { DOC_CLAIM_PATH, runDocClaims } from './check-doc-claims.js';
30
+ import { DOC_COUNT_SOURCE, runDocCounts } from './check-doc-counts.js';
31
+ import { DOC_PATH_ERROR, runDocInvocations } from './check-doc-invocations.mjs';
29
32
  import { checkLicenses } from './check-licenses.mjs';
30
- import { DEFAULT_CONFIG_FILE, GATE_NAMES, loadDependencyDirectionConfig, loadFieldOwnershipConfig, loadLicencesConfig, loadLockfileAgeConfig, loadPackageBoundaryConfig, } from './gate-config.js';
33
+ import { DEFAULT_CONFIG_FILE, GATE_NAMES, loadDependencyDirectionConfig, loadDocClaimsConfig, loadDocCountsConfig, loadDocInvocationsConfig, loadFieldOwnershipConfig, loadLicencesConfig, loadLockfileAgeConfig, loadPackageBoundaryConfig, } from './gate-config.js';
31
34
  import { runFieldOwnership, TYPESCRIPT_UNAVAILABLE, } from './lineage-ownership.js';
32
- import { runPackageBoundary, SCAN_PATH_ERROR, SCAN_UNREADABLE, } from './package-boundary.js';
35
+ import { MODULE_VALUE_ERROR } from './module-value.js';
36
+ import { runPackageBoundary } from './package-boundary.js';
37
+ import { SCAN_PATH_ERROR, SCAN_UNREADABLE } from './scanned-paths.js';
33
38
  const EXIT_OK = 0;
34
39
  /** The gate ran and found what it exists to find. */
35
40
  const EXIT_GATE_FAILED = 1;
@@ -51,6 +56,9 @@ const GATE_SUMMARY = {
51
56
  'dependency-direction': 'every import in the trees you name, against a layer graph you declare',
52
57
  'package-boundary': 'every line your package would publish, against the patterns you forbid',
53
58
  'field-ownership': 'every write to a field you own, against the modules you let write it',
59
+ 'doc-invocations': 'every fenced command in your pages, against the exit code the page claims',
60
+ 'doc-counts': 'every hand-written count in your pages, against the thing it counts',
61
+ 'doc-claims': 'every prose claim in your pages, against the tree those pages describe',
54
62
  };
55
63
  /** The widest gate name, plus the two spaces that separate it from its summary. */
56
64
  const GATE_COLUMN = Math.max(...GATE_NAMES.map((gate) => gate.length)) + 2;
@@ -71,6 +79,10 @@ dependency-direction and field-ownership read your source with the TypeScript
71
79
  scanner, so those two need the optional peer dependency "typescript". Install it
72
80
  only if you run one of them; each refuses by name when it is absent.
73
81
 
82
+ doc-counts and doc-claims read values out of modules your configuration names,
83
+ which means importing them, which runs them. doc-invocations runs the commands
84
+ your pages document, each inside a temporary directory it owns.
85
+
74
86
  Exit codes: ${EXIT_OK} the gate passed, ${EXIT_GATE_FAILED} the gate failed, ${EXIT_USAGE} a usage or configuration error.`;
75
87
  const writeOut = (line) => {
76
88
  process.stdout.write(`${line}\n`);
@@ -157,28 +169,120 @@ async function readLockfile(root, relative, configFile, gate) {
157
169
  throw new ConfigurationError(`${path} ${detail}; ${configFile}'s "${gate}" section names it under lockfiles`);
158
170
  }
159
171
  }
172
+ /**
173
+ * The committed publication cache, if the section names one. A path that is not
174
+ * there is a configuration error rather than a silent full-fetch run: a mistyped
175
+ * path would read as a cache answering nothing, which is the shape that turns a
176
+ * gate into one that passes for the wrong reason.
177
+ */
178
+ async function readCache(configFile, root, named) {
179
+ if (named === undefined)
180
+ return {};
181
+ const path = resolve(root, named);
182
+ let text;
183
+ try {
184
+ text = await readFile(path, 'utf8');
185
+ }
186
+ catch (error) {
187
+ const code = error.code;
188
+ const detail = code === 'ENOENT'
189
+ ? 'does not exist'
190
+ : `could not be read: ${error instanceof Error ? error.message : String(error)}`;
191
+ throw new ConfigurationError(`${path} ${detail}; ${configFile}'s "lockfile-age" section names it under cache`);
192
+ }
193
+ try {
194
+ return readPublishCache(JSON.parse(text), path);
195
+ }
196
+ catch (error) {
197
+ throw new ConfigurationError(error instanceof Error ? error.message : String(error));
198
+ }
199
+ }
200
+ /**
201
+ * Which (row, lockfile) pairs a run has not yet seen reach an entry. A row
202
+ * naming two lockfiles is held in each: a scope where it never reaches anything
203
+ * is a value nobody is holding, whatever it reaches in the other.
204
+ *
205
+ * The refusal comes after every lockfile has reported, and it never outranks a
206
+ * gate failure: a run that found a violation exits 1 and prints the stale rows
207
+ * as a diagnostic, because a caller branching on the code must see the finding
208
+ * first. The usage code is for the run that would otherwise have passed.
209
+ */
210
+ class UnreachedRows {
211
+ pending = new Map();
212
+ constructor(rows) {
213
+ for (const row of rows)
214
+ this.pending.set(row, new Set(row.lockfiles));
215
+ }
216
+ reached(row, lockfile) {
217
+ this.pending.get(row)?.delete(lockfile);
218
+ }
219
+ /** Each stale row with the lockfiles it reached nothing in, or nothing. */
220
+ remaining() {
221
+ return [...this.pending]
222
+ .filter(([, lockfiles]) => lockfiles.size > 0)
223
+ .map(([row, lockfiles]) => ({ row, lockfiles: [...lockfiles] }));
224
+ }
225
+ }
226
+ function exitAfter(passed, stale) {
227
+ if (stale !== null)
228
+ writeDiagnostic(`\n${BINARY}: ${stale}`);
229
+ if (!passed)
230
+ return EXIT_GATE_FAILED;
231
+ return stale === null ? EXIT_OK : EXIT_USAGE;
232
+ }
160
233
  async function runLockfileAge(configFile, root, section) {
234
+ const cache = await readCache(configFile, root, section.cache);
161
235
  const now = new Date();
162
236
  // The line `.github/actions/audit-lockfile-age/action.yml` greps for: a
163
237
  // clock-parsing bug that made every entry look permanently old would
164
238
  // otherwise be invisible.
165
239
  writeOut(`Effective clock: ${now.toISOString()}`);
240
+ if (section.cache !== undefined) {
241
+ writeOut(`lockfile-age: ${Object.keys(cache).length} publication time(s) read from ${section.cache}; only an entry absent from it is fetched.`);
242
+ }
166
243
  let passed = true;
244
+ const unreached = new UnreachedRows(section.exclude ?? []);
167
245
  for (const relative of section.lockfiles) {
168
246
  const lockfile = await readLockfile(root, relative, configFile, 'lockfile-age');
247
+ const exclusions = (section.exclude ?? []).filter((row) => row.lockfiles.includes(relative));
169
248
  const report = (await auditLockfileAge({
170
249
  lockfile,
171
250
  now,
172
251
  windowDays: section.windowDays,
252
+ exclude: exclusions.map((row) => row.name),
173
253
  source: relative,
254
+ cache,
174
255
  }));
256
+ // The exclusions are part of what the run did, on a failing run as on a
257
+ // passing one, so they print on both with the scanned total beside them,
258
+ // each with the reason its row gave.
259
+ const excluded = report.excludedEntries;
260
+ const writeExcluded = () => {
261
+ for (const entry of excluded) {
262
+ const row = exclusions.find((candidate) => candidate.name === entry.name);
263
+ if (row !== undefined)
264
+ unreached.reached(row, relative);
265
+ writeOut(` excluded: ${entry.name}@${entry.version} (${entry.path})`);
266
+ if (row !== undefined)
267
+ writeOut(` because: ${row.reason}`);
268
+ }
269
+ };
175
270
  if (report.youngEntries.length === 0 &&
176
271
  report.unfetchableEntries.length === 0 &&
177
272
  report.offRegistryEntries.length === 0) {
178
- writeOut(`lockfile-age ${relative}: passed, ${report.entries.length} entrie(s), all published before ${report.cutoff.toISOString()}.`);
273
+ const cutoff = report.cutoff.toISOString();
274
+ const scanned = report.entries.length;
275
+ writeOut(excluded.length === 0
276
+ ? `lockfile-age ${relative}: passed, ${scanned} entrie(s), all published before ${cutoff}.`
277
+ : `lockfile-age ${relative}: passed, ${scanned} entrie(s), ${scanned - excluded.length} published before ${cutoff} and ${excluded.length} excluded by name.`);
278
+ writeExcluded();
179
279
  continue;
180
280
  }
181
281
  passed = false;
282
+ if (excluded.length > 0) {
283
+ writeOut(`lockfile-age ${relative}: ${report.entries.length} entrie(s), ${excluded.length} excluded by name.`);
284
+ writeExcluded();
285
+ }
182
286
  if (report.offRegistryEntries.length > 0) {
183
287
  writeDiagnostic(`\nlockfile-age ${relative}: failed closed, ${report.offRegistryEntries.length} entrie(s) do not resolve to the npm registry:`);
184
288
  for (const entry of report.offRegistryEntries) {
@@ -198,7 +302,12 @@ async function runLockfileAge(configFile, root, section) {
198
302
  }
199
303
  }
200
304
  }
201
- return passed;
305
+ const stale = unreached.remaining();
306
+ return exitAfter(passed, stale.length === 0
307
+ ? null
308
+ : `${configFile}'s "lockfile-age" section excludes ${stale
309
+ .map(({ row, lockfiles }) => `"${row.name}" in ${lockfiles.join(', ')}`)
310
+ .join('; ')}, and no entry there carries that name; the package left the lockfile or the name is mistyped, so remove the row or narrow its lockfiles`);
202
311
  }
203
312
  /** A tolerance holds only while its marker does, so the file is read on every run. */
204
313
  async function markerHolds(root, marker) {
@@ -214,6 +323,7 @@ async function markerHolds(root, marker) {
214
323
  }
215
324
  async function runLicences(configFile, root, section) {
216
325
  let passed = true;
326
+ const unreached = new UnreachedRows(section.undeclared ?? []);
217
327
  for (const relative of section.lockfiles) {
218
328
  const lockfile = await readLockfile(root, relative, configFile, 'licences');
219
329
  const policy = section.policies?.[relative];
@@ -229,32 +339,57 @@ async function runLicences(configFile, root, section) {
229
339
  continue;
230
340
  applicable.push(tolerance);
231
341
  }
342
+ const undeclared = (section.undeclared ?? []).filter((row) => row.lockfiles.includes(relative));
232
343
  const report = checkLicenses(lockfile, {
233
344
  allowlist,
234
345
  label,
235
346
  tolerances: applicable,
347
+ undeclared,
236
348
  source: relative,
237
349
  });
238
- if (report.violations.length === 0) {
239
- writeOut(`licences ${relative}: passed against ${label}, ${report.entryCount} entrie(s), all allowlisted.`);
240
- if (policy !== undefined)
241
- writeOut(` ${label}: ${policy.reason}`);
350
+ for (const row of undeclared) {
351
+ if (!report.unusedUndeclared.includes(row.prefix)) {
352
+ unreached.reached(row, relative);
353
+ }
354
+ }
355
+ // An entry read by evidence is printed on every run that used the row,
356
+ // and apart from the tolerated: a tolerance widens the allowlist for a
357
+ // licence the entry declares, and this row supplies one the entry does not.
358
+ // Both print on a failing run too, since both are part of what the run did.
359
+ const writeExceptions = () => {
360
+ for (const reading of report.readByEvidence) {
361
+ writeOut(` read by evidence: ${reading.entry} as ${reading.readAs}`);
362
+ writeOut(` evidence: ${reading.evidence}`);
363
+ writeOut(` because: ${reading.reason}`);
364
+ }
242
365
  if (report.tolerated.length > 0) {
243
366
  writeOut(` tolerated: ${report.tolerated.join(', ')}`);
244
367
  for (const reason of report.toleranceReasons) {
245
368
  writeOut(` because: ${reason}`);
246
369
  }
247
370
  }
371
+ };
372
+ if (report.violations.length === 0) {
373
+ writeOut(`licences ${relative}: passed against ${label}, ${report.entryCount} entrie(s), all allowlisted.`);
374
+ if (policy !== undefined)
375
+ writeOut(` ${label}: ${policy.reason}`);
376
+ writeExceptions();
248
377
  continue;
249
378
  }
250
379
  passed = false;
380
+ writeExceptions();
251
381
  writeDiagnostic(`\nlicences ${relative}: ${report.violations.length} entrie(s) outside ${label}:`);
252
382
  for (const violation of report.violations) {
253
383
  writeDiagnostic(` - ${violation.name}@${violation.version}: license=${JSON.stringify(violation.license)}${violation.reason ? ` (${violation.reason})` : ''}`);
254
384
  writeDiagnostic(` dependency path: ${violation.dependencyPath}`);
255
385
  }
256
386
  }
257
- return passed;
387
+ const stale = unreached.remaining();
388
+ return exitAfter(passed, stale.length === 0
389
+ ? null
390
+ : `${configFile}'s "licences" section reads ${stale
391
+ .map(({ row, lockfiles }) => `"${row.prefix}" in ${lockfiles.join(', ')}`)
392
+ .join('; ')} by evidence, and no entry there under that prefix declares no licence; the package now declares one or the prefix is mistyped, so remove the row or narrow its lockfiles`);
258
393
  }
259
394
  /** The order every violation report prints in, so two runs read the same. */
260
395
  const byFileThenLine = (violations) => [...violations].sort((a, b) => a.file === b.file ? a.line - b.line : a.file < b.file ? -1 : 1);
@@ -309,6 +444,47 @@ async function runOwnership(root, section) {
309
444
  }
310
445
  return EXIT_GATE_FAILED;
311
446
  }
447
+ /**
448
+ * The three documentation gates share a report shape: a summary line that is
449
+ * written whatever the outcome, and a list of failures. The summary on a clean
450
+ * run is what stops a gate reading as green because it scanned nothing.
451
+ */
452
+ function reportDocFailures(gate, failures, noun) {
453
+ if (failures.length === 0)
454
+ return EXIT_OK;
455
+ writeDiagnostic(`\n${gate}: ${failures.length} ${noun}:`);
456
+ for (const failure of failures)
457
+ writeDiagnostic(` ${failure}`);
458
+ return EXIT_GATE_FAILED;
459
+ }
460
+ function runInvocations(root, section) {
461
+ const report = runDocInvocations(root, section);
462
+ writeOut(`doc-invocations: ${report.scanned} invocation(s) scanned across ${report.pages} page(s), ` +
463
+ `${report.judged} run faithfully over real inputs, ${report.compared} with their output compared, ` +
464
+ `${report.failures.length} failure(s)`);
465
+ if (report.failures.length === 0)
466
+ return EXIT_OK;
467
+ writeDiagnostic(`\ndoc-invocations: ${report.failures.length} failing invocation(s):`);
468
+ for (const failure of [...report.failures].sort((a, b) => a.file === b.file ? a.line - b.line : a.file < b.file ? -1 : 1)) {
469
+ writeDiagnostic(` ${failure.file}:${failure.line} [${failure.reason}] ${failure.invocation}`);
470
+ for (const line of failure.stderr.split('\n')) {
471
+ if (line !== '')
472
+ writeDiagnostic(` ${line}`);
473
+ }
474
+ }
475
+ return EXIT_GATE_FAILED;
476
+ }
477
+ async function runCounts(root, section) {
478
+ const report = await runDocCounts(root, section);
479
+ writeOut(`doc-counts: ${report.numerals} numeral(s) across ${report.files} file(s) held against their source, ` +
480
+ `plus ${report.digits} count(s) written as digits, ${report.failures.length} disagreement(s)`);
481
+ return reportDocFailures('doc-counts', report.failures, 'count(s) disagree with their source');
482
+ }
483
+ async function runClaims(root, section) {
484
+ const report = await runDocClaims(root, section);
485
+ writeOut(`doc-claims: ${report.summary}`);
486
+ return reportDocFailures('doc-claims', report.failures, 'prose claim(s) disagree with the tree');
487
+ }
312
488
  async function run(invocation) {
313
489
  if (invocation.kind === 'help') {
314
490
  writeOut(USAGE);
@@ -329,15 +505,13 @@ async function run(invocation) {
329
505
  const loaded = await loadLockfileAgeConfig(options);
330
506
  if (loaded.kind === 'refused')
331
507
  return refused(loaded.message);
332
- const passed = await runLockfileAge(loaded.path, dirname(loaded.path), loaded.section);
333
- return passed ? EXIT_OK : EXIT_GATE_FAILED;
508
+ return runLockfileAge(loaded.path, dirname(loaded.path), loaded.section);
334
509
  }
335
510
  case 'licences': {
336
511
  const loaded = await loadLicencesConfig(options);
337
512
  if (loaded.kind === 'refused')
338
513
  return refused(loaded.message);
339
- const passed = await runLicences(loaded.path, dirname(loaded.path), loaded.section);
340
- return passed ? EXIT_OK : EXIT_GATE_FAILED;
514
+ return runLicences(loaded.path, dirname(loaded.path), loaded.section);
341
515
  }
342
516
  case 'dependency-direction': {
343
517
  const loaded = await loadDependencyDirectionConfig(options);
@@ -357,6 +531,24 @@ async function run(invocation) {
357
531
  return refused(loaded.message);
358
532
  return runOwnership(dirname(loaded.path), loaded.section);
359
533
  }
534
+ case 'doc-invocations': {
535
+ const loaded = await loadDocInvocationsConfig(options);
536
+ if (loaded.kind === 'refused')
537
+ return refused(loaded.message);
538
+ return runInvocations(dirname(loaded.path), loaded.section);
539
+ }
540
+ case 'doc-counts': {
541
+ const loaded = await loadDocCountsConfig(options);
542
+ if (loaded.kind === 'refused')
543
+ return refused(loaded.message);
544
+ return runCounts(dirname(loaded.path), loaded.section);
545
+ }
546
+ case 'doc-claims': {
547
+ const loaded = await loadDocClaimsConfig(options);
548
+ if (loaded.kind === 'refused')
549
+ return refused(loaded.message);
550
+ return runClaims(dirname(loaded.path), loaded.section);
551
+ }
360
552
  }
361
553
  // Exhaustive over `GateName`: a gate added to `GATE_NAMES` with no arm above
362
554
  // is a type error here rather than a binary that names it in its usage text
@@ -368,11 +560,12 @@ async function run(invocation) {
368
560
  * The refusals a gate raises as a coded error rather than as a return value,
369
561
  * and the exit each takes.
370
562
  *
371
- * A lockfile or a path the configuration named and the tree does not have is a
372
- * configuration error, so it takes the usage code: the repair is in the file.
373
- * So is an absent optional peer dependency. A tree the scan could not read to
374
- * the end takes the gate's own failure code instead, because that gate ran and
375
- * refused rather than being misinvoked.
563
+ * A lockfile, a page root, a built entry point, or a module export the
564
+ * configuration named and the tree does not have is a configuration error, so it
565
+ * takes the usage code: the repair is in the file. So is an absent optional peer
566
+ * dependency. A tree the scan could not read to the end takes the gate's own
567
+ * failure code instead, because that gate ran and refused rather than being
568
+ * misinvoked.
376
569
  *
377
570
  * Sharing one code across the two would let "scanned nothing" and "found
378
571
  * nothing" answer a caller the same way, which is the pass these refusals exist
@@ -382,6 +575,10 @@ const CODED_EXITS = new Map([
382
575
  [LOCKFILE_SHAPE_ERROR, EXIT_USAGE],
383
576
  [SCAN_PATH_ERROR, EXIT_USAGE],
384
577
  [TYPESCRIPT_UNAVAILABLE, EXIT_USAGE],
578
+ [DOC_PATH_ERROR, EXIT_USAGE],
579
+ [DOC_CLAIM_PATH, EXIT_USAGE],
580
+ [DOC_COUNT_SOURCE, EXIT_USAGE],
581
+ [MODULE_VALUE_ERROR, EXIT_USAGE],
385
582
  [SCAN_UNREADABLE, EXIT_GATE_FAILED],
386
583
  ]);
387
584
  async function main(argv) {
@@ -31,40 +31,37 @@
31
31
  // TypeScript enum, namespace, parameter property, or non-type re-export may
32
32
  // appear in this file or anything it imports.
33
33
  import { z } from 'zod';
34
- import { discoverEntries, RelativePath, RelativePrefix, ScannedPathList, } from './package-boundary.js';
34
+ import { discoverEntries, RelativePath, RelativePrefix, ScannedPathList, } from './scanned-paths.js';
35
+ import { loadTypeScriptScanner, TYPESCRIPT_UNAVAILABLE, } from './typescript-scanner.js';
35
36
  /** The gate needs `typescript` and could not resolve it. */
36
- export const TYPESCRIPT_UNAVAILABLE = 'EVAL_QUALITY_TYPESCRIPT_UNAVAILABLE';
37
- const codedError = (code, message) => Object.assign(new Error(message), { code });
38
- const importTokenScanner = async () => {
37
+ export { TYPESCRIPT_UNAVAILABLE };
38
+ const importTokenScanner = (gate) => async () => {
39
39
  // `token-scan.ts` imports `typescript/unstable/ast` at its own top level, so
40
- // this is the one place the dependency is reached and the one place its
41
- // absence can be turned into a sentence.
42
- const [ast, scan] = await Promise.all([
43
- import('typescript/unstable/ast'),
44
- import('./token-scan.js'),
45
- ]);
40
+ // the loader runs first and its refusal is the one a consumer reads; the
41
+ // import of `token-scan.ts` follows only once the scanner is known to be there.
42
+ const ast = await loadTypeScriptScanner(gate);
43
+ const scan = await import('./token-scan.js');
46
44
  return {
47
45
  scanTokens: scan.scanTokens,
48
46
  computeLineStarts: scan.computeLineStarts,
49
47
  lineOf: scan.lineOf,
48
+ // `loadTypeScriptScanner` types `SyntaxKind` as a generic
49
+ // `Readonly<Record<string, number>>`, since its own shape check indexes
50
+ // it by whichever member name each of the three scanner modules reads.
51
+ // That check has already run and passed by the time this line executes,
52
+ // so every member this file reads off `Syntax` is verified present; the
53
+ // double cast is regaining the precise type the runtime check earned.
50
54
  syntax: ast.SyntaxKind,
51
55
  };
52
56
  };
53
57
  /**
54
- * The tokenizer, or a refusal naming the dependency and the gate that needs it.
55
- * `load` is injectable so the refusal has a test that does not require
56
- * uninstalling anything.
58
+ * The tokenizer, or the refusal `loadTypeScriptScanner` throws naming the
59
+ * dependency and the gate that needs it. `load` is injectable so a test can
60
+ * exercise that refusal without uninstalling anything the test runner itself
61
+ * needs.
57
62
  */
58
- export async function loadTokenScanner(gate, load = importTokenScanner) {
59
- try {
60
- return await load();
61
- }
62
- catch (error) {
63
- if (error.code !== 'ERR_MODULE_NOT_FOUND') {
64
- throw error;
65
- }
66
- throw codedError(TYPESCRIPT_UNAVAILABLE, `the ${gate} gate reads your source through the typescript package's own scanner, and typescript did not resolve. Install typescript to run this gate; every other gate needs nothing beyond this package.`);
67
- }
63
+ export async function loadTokenScanner(gate, load = importTokenScanner(gate)) {
64
+ return load();
68
65
  }
69
66
  const Identifier = z
70
67
  .string()
@@ -0,0 +1,187 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ // How a configuration names a value it cannot spell in JSON.
10
+ //
11
+ // The three documentation gates hold a page against something the repository
12
+ // computes: how many contracts a corpus carries, which stages perform a
13
+ // comparison, which schema a worked example parses against, whether a claim
14
+ // that was true when it was written still is. None of those is a literal, and
15
+ // writing one into the configuration would create exactly the setting this
16
+ // format does not have: a number or a list a hand keeps in step with the code
17
+ // beside it.
18
+ //
19
+ // So a source is a module path and an export name. The gate imports the
20
+ // consumer's own module and reads the value out of it, which puts the
21
+ // computation in the consumer's code, where it can be tested, and leaves the
22
+ // configuration naming it.
23
+ //
24
+ // The module is the consumer's, and importing it runs it. That is the same
25
+ // trust a lint plugin or a test setup file has, and the published page says so
26
+ // plainly, so a consumer choosing to point a gate at a module knows what the
27
+ // gate does with it.
28
+ //
29
+ // Run by `node` directly: Node's type stripping erases types only, so no
30
+ // TypeScript enum, namespace, parameter property, or non-type re-export may
31
+ // appear in this file or anything it imports.
32
+ import { resolve } from 'node:path';
33
+ import { pathToFileURL } from 'node:url';
34
+ import { z } from 'zod';
35
+ import { RelativePath } from './scanned-paths.js';
36
+ /** A module a configuration named that could not be imported or read. */
37
+ export const MODULE_VALUE_ERROR = 'EVAL_QUALITY_MODULE_VALUE';
38
+ const NonEmpty = z.string().min(1);
39
+ /**
40
+ * What to take from the export. `value` is the export itself, `length` its
41
+ * `length`, and `keys` the number of its own enumerable keys.
42
+ *
43
+ * Three rather than one, because the alternative is a configuration naming a
44
+ * separate `…_COUNT` export beside every list, which is a second value a hand
45
+ * maintains in step with the first.
46
+ */
47
+ export const Take = z.enum(['value', 'length', 'keys']);
48
+ export const ModuleValue = z
49
+ .strictObject({
50
+ module: RelativePath.describe('The module to import, relative to the configuration file. It is imported, so it runs.'),
51
+ export: NonEmpty.describe('The export to read. A default export is named "default".'),
52
+ path: z
53
+ .array(NonEmpty)
54
+ .optional()
55
+ .describe('Properties to walk from the export before taking anything, so one exported table can back several sources.'),
56
+ take: Take.default('value').describe('What to take: the value itself, its length, or the number of its own keys.'),
57
+ })
58
+ .describe('A value this configuration cannot spell: a module of yours, an export of that module, and what to take from it.');
59
+ const codedError = (message) => Object.assign(new Error(message), { code: MODULE_VALUE_ERROR });
60
+ const detail = (error) => error instanceof Error ? error.message : String(error);
61
+ /** How a source reads in a refusal, so every message names the same thing. */
62
+ export const nameOf = (source) => {
63
+ const walked = source.path === undefined ? '' : `.${source.path.join('.')}`;
64
+ return `${source.module}'s ${source.export}${walked}`;
65
+ };
66
+ const typeOf = (value) => value === null ? 'null' : Array.isArray(value) ? 'an array' : typeof value;
67
+ /**
68
+ * The value behind one source. Every refusal names the module, the export and
69
+ * what was found, because those are the three things the reader has to compare
70
+ * against their own tree.
71
+ *
72
+ * Imports are not cached here. Node caches a module by URL for the life of the
73
+ * process, so two sources naming one module import it once.
74
+ */
75
+ export async function readModuleValue(root, source) {
76
+ const url = pathToFileURL(resolve(root, source.module));
77
+ let module;
78
+ try {
79
+ module = (await import(__rewriteRelativeImportExtension(url.href)));
80
+ }
81
+ catch (error) {
82
+ throw codedError(`${source.module} could not be imported: ${detail(error)}`);
83
+ }
84
+ if (!(source.export in module)) {
85
+ // `default` stays in the listing. Filtering it out told a module whose only
86
+ // export is a default that it exports nothing, while `export: "default"`
87
+ // would have resolved.
88
+ const exported = Object.keys(module).sort();
89
+ throw codedError(`${source.module} exports no "${source.export}"; it exports ${exported.length === 0 ? 'nothing' : exported.join(', ')}`);
90
+ }
91
+ let value = module[source.export];
92
+ for (const key of source.path ?? []) {
93
+ if (value === null || typeof value !== 'object') {
94
+ throw codedError(`${nameOf(source)}: "${key}" was reached on ${typeOf(value)}, which has no properties`);
95
+ }
96
+ const holder = value;
97
+ if (!(key in holder)) {
98
+ throw codedError(`${nameOf(source)}: "${key}" is absent; the keys there are ${Object.keys(holder).sort().join(', ')}`);
99
+ }
100
+ value = holder[key];
101
+ }
102
+ return value;
103
+ }
104
+ /**
105
+ * The count behind a value, however the configuration asked for it. Shared, so a
106
+ * module export and a value walked out of a JSON file answer to one rule and one
107
+ * wording rather than to two that drift.
108
+ *
109
+ * `where` is what the refusal names, which differs per caller: a module export
110
+ * for one, a file and a key path for the other.
111
+ */
112
+ export function takeCount(value, take, where) {
113
+ if (take === 'value') {
114
+ if (typeof value === 'number' && Number.isInteger(value) && value >= 0) {
115
+ return value;
116
+ }
117
+ throw codedError(`${where} is ${typeOf(value)} and take is "value", so a count was expected; a list takes "length" and a table takes "keys"`);
118
+ }
119
+ if (take === 'length') {
120
+ // A string has a length and counting its characters is never what a page
121
+ // meant, so it is refused rather than answered. Every other `length` a
122
+ // configuration can reach is a list's.
123
+ if (typeof value === 'string') {
124
+ throw codedError(`${where} is a string and take is "length", which would count its characters; a page counts members, so name a list`);
125
+ }
126
+ const length = value?.length;
127
+ if (typeof length === 'number' && Number.isInteger(length) && length >= 0) {
128
+ return length;
129
+ }
130
+ throw codedError(`${where} is ${typeOf(value)} and take is "length", which it has none of`);
131
+ }
132
+ if (value === null || typeof value !== 'object') {
133
+ throw codedError(`${where} is ${typeOf(value)} and take is "keys", which only an object has`);
134
+ }
135
+ return Object.keys(value).length;
136
+ }
137
+ /** A source that has to answer a number, which is every count a page carries. */
138
+ export async function readModuleCount(root, source) {
139
+ return takeCount(await readModuleValue(root, source), source.take, nameOf(source));
140
+ }
141
+ /** A source that has to answer a list of strings, which is every transcribed set. */
142
+ export async function readModuleStrings(root, source) {
143
+ const value = await readModuleValue(root, source);
144
+ if (!Array.isArray(value) || value.some((each) => typeof each !== 'string')) {
145
+ throw codedError(`${nameOf(source)} is ${typeOf(value)}, and a list of strings was expected`);
146
+ }
147
+ return value;
148
+ }
149
+ /** A source that has to answer one string, which is every transcription. */
150
+ export async function readModuleText(root, source) {
151
+ const value = await readModuleValue(root, source);
152
+ if (typeof value === 'string')
153
+ return value;
154
+ if (typeof value === 'function') {
155
+ const produced = await value();
156
+ if (typeof produced === 'string')
157
+ return produced;
158
+ throw codedError(`${nameOf(source)} is a function and it returned ${typeOf(produced)}; a transcription source returns the text`);
159
+ }
160
+ throw codedError(`${nameOf(source)} is ${typeOf(value)}, and a string or a function returning one was expected`);
161
+ }
162
+ /**
163
+ * A source that has to answer a predicate's verdict. A boolean export settles a
164
+ * claim that a constant decides; a function export settles one that needs the
165
+ * tree read, and it is awaited so a reader may be asynchronous.
166
+ */
167
+ export async function readModuleVerdict(root, source) {
168
+ const value = await readModuleValue(root, source);
169
+ if (typeof value === 'boolean')
170
+ return value;
171
+ if (typeof value === 'function') {
172
+ const produced = await value();
173
+ if (typeof produced === 'boolean')
174
+ return produced;
175
+ throw codedError(`${nameOf(source)} is a function and it returned ${typeOf(produced)}; a predicate answers true or false`);
176
+ }
177
+ throw codedError(`${nameOf(source)} is ${typeOf(value)}, and a boolean or a function returning one was expected`);
178
+ }
179
+ export async function readModuleParser(root, source) {
180
+ const value = await readModuleValue(root, source);
181
+ if (value !== null &&
182
+ typeof value === 'object' &&
183
+ typeof value.safeParse === 'function') {
184
+ return value;
185
+ }
186
+ throw codedError(`${nameOf(source)} is ${typeOf(value)} and has no safeParse; a schema source names a Zod schema`);
187
+ }