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
package/dist/gates/gates-cli.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
372
|
-
* configuration
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
* refused rather than being
|
|
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 './
|
|
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
|
|
37
|
-
const
|
|
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
|
-
//
|
|
41
|
-
//
|
|
42
|
-
const
|
|
43
|
-
|
|
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
|
|
55
|
-
* `load` is injectable so
|
|
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
|
-
|
|
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
|
+
}
|