eval-quality 3.0.0 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/dist/application/index.d.ts +4 -0
- package/dist/application/index.js +2 -0
- package/dist/core/emit/emit.js +4 -2
- package/dist/core/preflight/reduce.d.ts +1 -1
- package/dist/core/preflight/reduce.js +5 -2
- package/dist/core/schemas/evaluator-configuration.d.ts +9 -0
- package/dist/core/schemas/evaluator-configuration.js +9 -0
- package/dist/core/schemas/evidence-artifact.d.ts +9 -0
- package/dist/core/schemas/evidence-artifact.js +9 -0
- package/dist/core/schemas/isolation-manifest.d.ts +18 -0
- package/dist/core/schemas/isolation-manifest.js +18 -0
- package/dist/core/schemas/preflight-verdict.d.ts +9 -0
- package/dist/core/schemas/preflight-verdict.js +9 -0
- package/dist/core/schemas/private-artifact-manifest.d.ts +10 -0
- package/dist/core/schemas/private-artifact-manifest.js +10 -0
- package/dist/core/schemas/scoring-policy.d.ts +11 -0
- package/dist/core/schemas/scoring-policy.js +11 -0
- package/dist/core/schemas/sealed-evaluator-brief.d.ts +12 -0
- package/dist/core/schemas/sealed-evaluator-brief.js +12 -0
- package/dist/core/schemas/sealed-run-record.d.ts +11 -0
- package/dist/core/schemas/sealed-run-record.js +11 -0
- package/dist/core/seal/seal.js +4 -5
- package/dist/gates/audit-lockfile-age.mjs +392 -0
- package/dist/gates/check-dependency-direction.js +303 -0
- package/dist/gates/check-doc-claims.js +1012 -0
- package/dist/gates/check-doc-counts.js +408 -0
- package/dist/gates/check-doc-invocations.mjs +618 -0
- package/dist/gates/check-licenses.mjs +378 -0
- package/dist/gates/consumer-pattern.js +104 -0
- package/dist/gates/dependency-direction.js +555 -0
- package/dist/gates/discover-source-files.js +44 -0
- package/dist/gates/gate-config.js +415 -0
- package/dist/gates/gates-cli.js +607 -0
- package/dist/gates/lineage-ownership.js +364 -0
- package/dist/gates/module-value.js +187 -0
- package/dist/gates/package-boundary.js +277 -0
- package/dist/gates/scanned-paths.js +110 -0
- package/dist/gates/token-scan.js +203 -0
- package/dist/index.d.ts +11 -1
- package/dist/index.js +20 -1
- package/dist/testing/probe-conformance.d.ts +23 -18
- package/package.json +24 -11
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The `bin` entry for the published gates: one binary, dispatching on
|
|
4
|
+
* `argv[2]`, over the gate names `gate-config.ts` publishes.
|
|
5
|
+
*
|
|
6
|
+
* It is the only file in the gate surface that reads `process.argv` or writes to
|
|
7
|
+
* a stream, so its whole body is turning a configuration section into a report
|
|
8
|
+
* and a report into `process.exitCode`. A gate returns its report or raises a
|
|
9
|
+
* coded error; the stream and the exit code are this file's alone.
|
|
10
|
+
*
|
|
11
|
+
* `process.exit` is called nowhere, for the reason `src/cli/main.ts` records:
|
|
12
|
+
* exiting truncates a pending stdout write, and a gate over a large lockfile
|
|
13
|
+
* writes more than a pipe buffer holds.
|
|
14
|
+
*
|
|
15
|
+
* Every path a configuration names resolves against the directory that
|
|
16
|
+
* configuration file sits in, so a configuration file is self-contained and a
|
|
17
|
+
* consumer can keep one outside the repository root and still have it mean what
|
|
18
|
+
* it says.
|
|
19
|
+
*
|
|
20
|
+
* Run by `node` directly: Node's type stripping erases types only, so no
|
|
21
|
+
* TypeScript enum, namespace, parameter property, or non-type re-export may
|
|
22
|
+
* appear in this file or anything it imports, or the binary fails at load.
|
|
23
|
+
*/
|
|
24
|
+
import { readFile } from 'node:fs/promises';
|
|
25
|
+
import { dirname, resolve } from 'node:path';
|
|
26
|
+
import process from 'node:process';
|
|
27
|
+
import { auditLockfileAge, LOCKFILE_SHAPE_ERROR, readPublishCache, } from './audit-lockfile-age.mjs';
|
|
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';
|
|
32
|
+
import { checkLicenses } from './check-licenses.mjs';
|
|
33
|
+
import { DEFAULT_CONFIG_FILE, GATE_NAMES, loadDependencyDirectionConfig, loadDocClaimsConfig, loadDocCountsConfig, loadDocInvocationsConfig, loadFieldOwnershipConfig, loadLicencesConfig, loadLockfileAgeConfig, loadPackageBoundaryConfig, } from './gate-config.js';
|
|
34
|
+
import { runFieldOwnership, TYPESCRIPT_UNAVAILABLE, } from './lineage-ownership.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';
|
|
38
|
+
const EXIT_OK = 0;
|
|
39
|
+
/** The gate ran and found what it exists to find. */
|
|
40
|
+
const EXIT_GATE_FAILED = 1;
|
|
41
|
+
/**
|
|
42
|
+
* sysexits.h EX_USAGE, the same number `src/cli/exit-codes.ts` assigns and for
|
|
43
|
+
* the same reason: the caller repairs it by changing what it passed. This build
|
|
44
|
+
* roots at `scripts/`, so it cannot import that module; a test case holds the
|
|
45
|
+
* two numbers equal.
|
|
46
|
+
*/
|
|
47
|
+
const EXIT_USAGE = 64;
|
|
48
|
+
const BINARY = 'eval-quality-gates';
|
|
49
|
+
/**
|
|
50
|
+
* One line per gate. Keyed by `GateName`, so publishing a gate without
|
|
51
|
+
* describing it here is a type error and the usage text cannot go stale.
|
|
52
|
+
*/
|
|
53
|
+
const GATE_SUMMARY = {
|
|
54
|
+
'lockfile-age': "every locked entry's registry publication age, against a window you declare",
|
|
55
|
+
licences: "every locked entry's licence, against an allowlist of identifiers you declare",
|
|
56
|
+
'dependency-direction': 'every import in the trees you name, against a layer graph you declare',
|
|
57
|
+
'package-boundary': 'every line your package would publish, against the patterns you forbid',
|
|
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',
|
|
62
|
+
};
|
|
63
|
+
/** The widest gate name, plus the two spaces that separate it from its summary. */
|
|
64
|
+
const GATE_COLUMN = Math.max(...GATE_NAMES.map((gate) => gate.length)) + 2;
|
|
65
|
+
const GATE_LINES = GATE_NAMES.map((gate) => ` ${gate.padEnd(GATE_COLUMN)}${GATE_SUMMARY[gate]}`).join('\n');
|
|
66
|
+
const USAGE = `Usage:
|
|
67
|
+
${BINARY} <gate> [--config <path>]
|
|
68
|
+
|
|
69
|
+
${GATE_LINES}
|
|
70
|
+
|
|
71
|
+
--config <path> the configuration file; ${DEFAULT_CONFIG_FILE} in the working directory by default
|
|
72
|
+
--help, -h this text
|
|
73
|
+
|
|
74
|
+
Each gate reads its own section of that file, and configuring a gate is what
|
|
75
|
+
opts into it. A gate invoked with no section refuses by name and falls back to
|
|
76
|
+
nothing. Every path a section names is relative to the configuration file.
|
|
77
|
+
|
|
78
|
+
dependency-direction and field-ownership read your source with the TypeScript
|
|
79
|
+
scanner, so those two need the optional peer dependency "typescript". Install it
|
|
80
|
+
only if you run one of them; each refuses by name when it is absent.
|
|
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
|
+
|
|
86
|
+
Exit codes: ${EXIT_OK} the gate passed, ${EXIT_GATE_FAILED} the gate failed, ${EXIT_USAGE} a usage or configuration error.`;
|
|
87
|
+
const writeOut = (line) => {
|
|
88
|
+
process.stdout.write(`${line}\n`);
|
|
89
|
+
};
|
|
90
|
+
const writeDiagnostic = (line) => {
|
|
91
|
+
process.stderr.write(`${line}\n`);
|
|
92
|
+
};
|
|
93
|
+
/** A refusal the caller repairs by editing its configuration, so it takes 64. */
|
|
94
|
+
class ConfigurationError extends Error {
|
|
95
|
+
}
|
|
96
|
+
const usageError = (message) => ({
|
|
97
|
+
kind: 'usage-error',
|
|
98
|
+
message,
|
|
99
|
+
});
|
|
100
|
+
const isGate = (token) => GATE_NAMES.includes(token);
|
|
101
|
+
/** `--flag=value`, split on the first `=` so a value may contain one. */
|
|
102
|
+
function splitFlag(token) {
|
|
103
|
+
const equals = token.indexOf('=');
|
|
104
|
+
if (equals === -1)
|
|
105
|
+
return { flag: token, inline: null };
|
|
106
|
+
return { flag: token.slice(0, equals), inline: token.slice(equals + 1) };
|
|
107
|
+
}
|
|
108
|
+
function parseArguments(argv) {
|
|
109
|
+
const first = argv[0];
|
|
110
|
+
if (first === undefined) {
|
|
111
|
+
return usageError(`no gate given; expected one of ${GATE_NAMES.join(', ')}`);
|
|
112
|
+
}
|
|
113
|
+
if (first === '--help' || first === '-h' || first === 'help') {
|
|
114
|
+
return { kind: 'help' };
|
|
115
|
+
}
|
|
116
|
+
if (!isGate(first)) {
|
|
117
|
+
return usageError(`unknown gate "${first}"; expected one of ${GATE_NAMES.join(', ')}`);
|
|
118
|
+
}
|
|
119
|
+
let configPath = null;
|
|
120
|
+
const rest = argv.slice(1);
|
|
121
|
+
for (let index = 0; index < rest.length;) {
|
|
122
|
+
const token = rest[index];
|
|
123
|
+
if (token === '--help' || token === '-h')
|
|
124
|
+
return { kind: 'help' };
|
|
125
|
+
const { flag, inline } = splitFlag(token);
|
|
126
|
+
if (flag !== '--config') {
|
|
127
|
+
return usageError(`unknown flag "${token}" for ${first}`);
|
|
128
|
+
}
|
|
129
|
+
let value;
|
|
130
|
+
if (inline !== null) {
|
|
131
|
+
value = inline;
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
const next = rest[index + 1];
|
|
135
|
+
if (next === undefined)
|
|
136
|
+
return usageError('--config requires a value');
|
|
137
|
+
// A flag-shaped token is the next flag, so the space form treats it as a
|
|
138
|
+
// missing value. A path beginning with "-" is what the equals form is for.
|
|
139
|
+
if (next.length > 1 && next.startsWith('-')) {
|
|
140
|
+
return usageError(`--config requires a value, but the next token is "${next}"; use --config=${next} for a path that begins with "-"`);
|
|
141
|
+
}
|
|
142
|
+
value = next;
|
|
143
|
+
}
|
|
144
|
+
if (value === '')
|
|
145
|
+
return usageError('--config was given an empty value');
|
|
146
|
+
if (configPath !== null && configPath !== value) {
|
|
147
|
+
return usageError(`--config given twice with different values, "${configPath}" and "${value}"`);
|
|
148
|
+
}
|
|
149
|
+
configPath = value;
|
|
150
|
+
index += inline === null ? 2 : 1;
|
|
151
|
+
}
|
|
152
|
+
return { kind: 'run', gate: first, configPath };
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* A lockfile the configuration named. A path that is not there is a
|
|
156
|
+
* configuration error and says which setting named it, because the repair is in
|
|
157
|
+
* the file rather than in the tree.
|
|
158
|
+
*/
|
|
159
|
+
async function readLockfile(root, relative, configFile, gate) {
|
|
160
|
+
const path = resolve(root, relative);
|
|
161
|
+
try {
|
|
162
|
+
return JSON.parse(await readFile(path, 'utf8'));
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
const code = error.code;
|
|
166
|
+
const detail = code === 'ENOENT'
|
|
167
|
+
? 'does not exist'
|
|
168
|
+
: `could not be read: ${error instanceof Error ? error.message : String(error)}`;
|
|
169
|
+
throw new ConfigurationError(`${path} ${detail}; ${configFile}'s "${gate}" section names it under lockfiles`);
|
|
170
|
+
}
|
|
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
|
+
}
|
|
233
|
+
async function runLockfileAge(configFile, root, section) {
|
|
234
|
+
const cache = await readCache(configFile, root, section.cache);
|
|
235
|
+
const now = new Date();
|
|
236
|
+
// The line `.github/actions/audit-lockfile-age/action.yml` greps for: a
|
|
237
|
+
// clock-parsing bug that made every entry look permanently old would
|
|
238
|
+
// otherwise be invisible.
|
|
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
|
+
}
|
|
243
|
+
let passed = true;
|
|
244
|
+
const unreached = new UnreachedRows(section.exclude ?? []);
|
|
245
|
+
for (const relative of section.lockfiles) {
|
|
246
|
+
const lockfile = await readLockfile(root, relative, configFile, 'lockfile-age');
|
|
247
|
+
const exclusions = (section.exclude ?? []).filter((row) => row.lockfiles.includes(relative));
|
|
248
|
+
const report = (await auditLockfileAge({
|
|
249
|
+
lockfile,
|
|
250
|
+
now,
|
|
251
|
+
windowDays: section.windowDays,
|
|
252
|
+
exclude: exclusions.map((row) => row.name),
|
|
253
|
+
source: relative,
|
|
254
|
+
cache,
|
|
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
|
+
};
|
|
270
|
+
if (report.youngEntries.length === 0 &&
|
|
271
|
+
report.unfetchableEntries.length === 0 &&
|
|
272
|
+
report.offRegistryEntries.length === 0) {
|
|
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();
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
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
|
+
}
|
|
286
|
+
if (report.offRegistryEntries.length > 0) {
|
|
287
|
+
writeDiagnostic(`\nlockfile-age ${relative}: failed closed, ${report.offRegistryEntries.length} entrie(s) do not resolve to the npm registry:`);
|
|
288
|
+
for (const entry of report.offRegistryEntries) {
|
|
289
|
+
writeDiagnostic(` - ${entry.name}@${entry.version} resolved=${JSON.stringify(entry.resolved ?? null)} (${entry.path})`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (report.unfetchableEntries.length > 0) {
|
|
293
|
+
writeDiagnostic(`\nlockfile-age ${relative}: failed closed, could not fetch publish metadata for ${report.unfetchableEntries.length} entrie(s):`);
|
|
294
|
+
for (const entry of report.unfetchableEntries) {
|
|
295
|
+
writeDiagnostic(` - ${entry.name}@${entry.version} (${entry.path})`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (report.youngEntries.length > 0) {
|
|
299
|
+
writeDiagnostic(`\nlockfile-age ${relative}: ${report.youngEntries.length} entrie(s) published inside the ${section.windowDays}-day window (cutoff ${report.cutoff.toISOString()}):`);
|
|
300
|
+
for (const entry of report.youngEntries) {
|
|
301
|
+
writeDiagnostic(` - ${entry.name}@${entry.version} published ${entry.publishedAt} (${entry.path})`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
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`);
|
|
311
|
+
}
|
|
312
|
+
/** A tolerance holds only while its marker does, so the file is read on every run. */
|
|
313
|
+
async function markerHolds(root, marker) {
|
|
314
|
+
if (marker === undefined)
|
|
315
|
+
return true;
|
|
316
|
+
try {
|
|
317
|
+
const text = await readFile(resolve(root, marker.file), 'utf8');
|
|
318
|
+
return text.includes(marker.contains);
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
async function runLicences(configFile, root, section) {
|
|
325
|
+
let passed = true;
|
|
326
|
+
const unreached = new UnreachedRows(section.undeclared ?? []);
|
|
327
|
+
for (const relative of section.lockfiles) {
|
|
328
|
+
const lockfile = await readLockfile(root, relative, configFile, 'licences');
|
|
329
|
+
const policy = section.policies?.[relative];
|
|
330
|
+
const allowlist = policy === undefined
|
|
331
|
+
? section.allowlist
|
|
332
|
+
: [...section.allowlist, ...policy.also];
|
|
333
|
+
const label = policy === undefined ? 'the allowlist' : policy.label;
|
|
334
|
+
const applicable = [];
|
|
335
|
+
for (const tolerance of section.tolerances ?? []) {
|
|
336
|
+
if (!tolerance.lockfiles.includes(relative))
|
|
337
|
+
continue;
|
|
338
|
+
if (!(await markerHolds(root, tolerance.marker)))
|
|
339
|
+
continue;
|
|
340
|
+
applicable.push(tolerance);
|
|
341
|
+
}
|
|
342
|
+
const undeclared = (section.undeclared ?? []).filter((row) => row.lockfiles.includes(relative));
|
|
343
|
+
const report = checkLicenses(lockfile, {
|
|
344
|
+
allowlist,
|
|
345
|
+
label,
|
|
346
|
+
tolerances: applicable,
|
|
347
|
+
undeclared,
|
|
348
|
+
source: relative,
|
|
349
|
+
});
|
|
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
|
+
}
|
|
365
|
+
if (report.tolerated.length > 0) {
|
|
366
|
+
writeOut(` tolerated: ${report.tolerated.join(', ')}`);
|
|
367
|
+
for (const reason of report.toleranceReasons) {
|
|
368
|
+
writeOut(` because: ${reason}`);
|
|
369
|
+
}
|
|
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();
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
passed = false;
|
|
380
|
+
writeExceptions();
|
|
381
|
+
writeDiagnostic(`\nlicences ${relative}: ${report.violations.length} entrie(s) outside ${label}:`);
|
|
382
|
+
for (const violation of report.violations) {
|
|
383
|
+
writeDiagnostic(` - ${violation.name}@${violation.version}: license=${JSON.stringify(violation.license)}${violation.reason ? ` (${violation.reason})` : ''}`);
|
|
384
|
+
writeDiagnostic(` dependency path: ${violation.dependencyPath}`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
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`);
|
|
393
|
+
}
|
|
394
|
+
/** The order every violation report prints in, so two runs read the same. */
|
|
395
|
+
const byFileThenLine = (violations) => [...violations].sort((a, b) => a.file === b.file ? a.line - b.line : a.file < b.file ? -1 : 1);
|
|
396
|
+
/**
|
|
397
|
+
* The direction gate writes to no stream and hands back what to print, so this
|
|
398
|
+
* is the whole mapping. `summary` is written on every outcome, including a clean
|
|
399
|
+
* one and a report-only one, which is what stops a report-only run being silent:
|
|
400
|
+
* a green run that printed nothing is the vacuous pass report-only mode exists
|
|
401
|
+
* to prevent.
|
|
402
|
+
*
|
|
403
|
+
* The violation lines go to stdout under report-only and to stderr otherwise.
|
|
404
|
+
* Report-only output is the thing the run was for, and a caller that redirects
|
|
405
|
+
* stderr away should still get it.
|
|
406
|
+
*/
|
|
407
|
+
async function runDirection(configPath, root, section) {
|
|
408
|
+
const outcome = await runDependencyDirection({ section, root, configPath });
|
|
409
|
+
if (outcome.kind === 'refused') {
|
|
410
|
+
writeDiagnostic(`${BINARY}: ${outcome.message}`);
|
|
411
|
+
return EXIT_USAGE;
|
|
412
|
+
}
|
|
413
|
+
writeOut(outcome.summary);
|
|
414
|
+
const write = outcome.reportOnly ? writeOut : writeDiagnostic;
|
|
415
|
+
for (const line of outcome.lines)
|
|
416
|
+
write(line);
|
|
417
|
+
return outcome.failed ? EXIT_GATE_FAILED : EXIT_OK;
|
|
418
|
+
}
|
|
419
|
+
async function runBoundary(root, section) {
|
|
420
|
+
const report = await runPackageBoundary(root, section, 'package-boundary');
|
|
421
|
+
if (report.violations.length === 0) {
|
|
422
|
+
const where = report.counts
|
|
423
|
+
.map((count) => `${count.files} from ${count.path}`)
|
|
424
|
+
.join(', ');
|
|
425
|
+
writeOut(`package-boundary: ${report.scanned} entr(ies) scanned, 0 violations (${where})`);
|
|
426
|
+
return EXIT_OK;
|
|
427
|
+
}
|
|
428
|
+
writeDiagnostic(`\npackage-boundary: ${report.violations.length} violation(s) across ${report.scanned} scanned entr(ies):`);
|
|
429
|
+
for (const violation of byFileThenLine(report.violations)) {
|
|
430
|
+
writeDiagnostic(` ${violation.file}:${violation.line} [${violation.pattern}] ${violation.text}`);
|
|
431
|
+
writeDiagnostic(` ${violation.reason}`);
|
|
432
|
+
}
|
|
433
|
+
return EXIT_GATE_FAILED;
|
|
434
|
+
}
|
|
435
|
+
async function runOwnership(root, section) {
|
|
436
|
+
const report = await runFieldOwnership(root, section, 'field-ownership');
|
|
437
|
+
if (report.violations.length === 0) {
|
|
438
|
+
writeOut(`field-ownership: ${report.scanned} file(s) scanned, 0 violations`);
|
|
439
|
+
return EXIT_OK;
|
|
440
|
+
}
|
|
441
|
+
writeDiagnostic(`\nfield-ownership: ${report.violations.length} violation(s) across ${report.scanned} scanned file(s):`);
|
|
442
|
+
for (const violation of byFileThenLine(report.violations)) {
|
|
443
|
+
writeDiagnostic(` ${violation.file}:${violation.line} ${violation.subject}: ${violation.rule}`);
|
|
444
|
+
}
|
|
445
|
+
return EXIT_GATE_FAILED;
|
|
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
|
+
}
|
|
488
|
+
async function run(invocation) {
|
|
489
|
+
if (invocation.kind === 'help') {
|
|
490
|
+
writeOut(USAGE);
|
|
491
|
+
return EXIT_OK;
|
|
492
|
+
}
|
|
493
|
+
if (invocation.kind === 'usage-error') {
|
|
494
|
+
writeDiagnostic(`${BINARY}: usage: ${invocation.message}`);
|
|
495
|
+
writeDiagnostic(USAGE);
|
|
496
|
+
return EXIT_USAGE;
|
|
497
|
+
}
|
|
498
|
+
const options = { configPath: invocation.configPath ?? undefined };
|
|
499
|
+
const refused = (message) => {
|
|
500
|
+
writeDiagnostic(`${BINARY}: ${invocation.gate}: ${message}`);
|
|
501
|
+
return EXIT_USAGE;
|
|
502
|
+
};
|
|
503
|
+
switch (invocation.gate) {
|
|
504
|
+
case 'lockfile-age': {
|
|
505
|
+
const loaded = await loadLockfileAgeConfig(options);
|
|
506
|
+
if (loaded.kind === 'refused')
|
|
507
|
+
return refused(loaded.message);
|
|
508
|
+
return runLockfileAge(loaded.path, dirname(loaded.path), loaded.section);
|
|
509
|
+
}
|
|
510
|
+
case 'licences': {
|
|
511
|
+
const loaded = await loadLicencesConfig(options);
|
|
512
|
+
if (loaded.kind === 'refused')
|
|
513
|
+
return refused(loaded.message);
|
|
514
|
+
return runLicences(loaded.path, dirname(loaded.path), loaded.section);
|
|
515
|
+
}
|
|
516
|
+
case 'dependency-direction': {
|
|
517
|
+
const loaded = await loadDependencyDirectionConfig(options);
|
|
518
|
+
if (loaded.kind === 'refused')
|
|
519
|
+
return refused(loaded.message);
|
|
520
|
+
return runDirection(loaded.path, dirname(loaded.path), loaded.section);
|
|
521
|
+
}
|
|
522
|
+
case 'package-boundary': {
|
|
523
|
+
const loaded = await loadPackageBoundaryConfig(options);
|
|
524
|
+
if (loaded.kind === 'refused')
|
|
525
|
+
return refused(loaded.message);
|
|
526
|
+
return runBoundary(dirname(loaded.path), loaded.section);
|
|
527
|
+
}
|
|
528
|
+
case 'field-ownership': {
|
|
529
|
+
const loaded = await loadFieldOwnershipConfig(options);
|
|
530
|
+
if (loaded.kind === 'refused')
|
|
531
|
+
return refused(loaded.message);
|
|
532
|
+
return runOwnership(dirname(loaded.path), loaded.section);
|
|
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
|
+
}
|
|
552
|
+
}
|
|
553
|
+
// Exhaustive over `GateName`: a gate added to `GATE_NAMES` with no arm above
|
|
554
|
+
// is a type error here rather than a binary that names it in its usage text
|
|
555
|
+
// and does nothing when invoked.
|
|
556
|
+
const unhandled = invocation.gate;
|
|
557
|
+
throw new Error(`no dispatch arm for the gate "${String(unhandled)}"`);
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* The refusals a gate raises as a coded error rather than as a return value,
|
|
561
|
+
* and the exit each takes.
|
|
562
|
+
*
|
|
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.
|
|
569
|
+
*
|
|
570
|
+
* Sharing one code across the two would let "scanned nothing" and "found
|
|
571
|
+
* nothing" answer a caller the same way, which is the pass these refusals exist
|
|
572
|
+
* to stop.
|
|
573
|
+
*/
|
|
574
|
+
const CODED_EXITS = new Map([
|
|
575
|
+
[LOCKFILE_SHAPE_ERROR, EXIT_USAGE],
|
|
576
|
+
[SCAN_PATH_ERROR, EXIT_USAGE],
|
|
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],
|
|
582
|
+
[SCAN_UNREADABLE, EXIT_GATE_FAILED],
|
|
583
|
+
]);
|
|
584
|
+
async function main(argv) {
|
|
585
|
+
try {
|
|
586
|
+
process.exitCode = await run(parseArguments(argv));
|
|
587
|
+
}
|
|
588
|
+
catch (error) {
|
|
589
|
+
if (error instanceof ConfigurationError) {
|
|
590
|
+
writeDiagnostic(`${BINARY}: ${error.message}`);
|
|
591
|
+
process.exitCode = EXIT_USAGE;
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
const code = error !== null && typeof error === 'object'
|
|
595
|
+
? error.code
|
|
596
|
+
: undefined;
|
|
597
|
+
const mapped = typeof code === 'string' ? CODED_EXITS.get(code) : undefined;
|
|
598
|
+
if (mapped !== undefined) {
|
|
599
|
+
writeDiagnostic(`${BINARY}: ${error.message}`);
|
|
600
|
+
process.exitCode = mapped;
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
writeDiagnostic(error instanceof Error ? (error.stack ?? error.message) : String(error));
|
|
604
|
+
process.exitCode = EXIT_GATE_FAILED;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
await main(process.argv.slice(2));
|