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,415 @@
|
|
|
1
|
+
// The configuration a consumer writes for the gates it has chosen to run, and
|
|
2
|
+
// the loader every published gate reads it through.
|
|
3
|
+
//
|
|
4
|
+
// One JSON file in the consumer's repository, `eval-quality.config.json` at the
|
|
5
|
+
// repository root by default and any path `--config` names. The top level is an
|
|
6
|
+
// object keyed by gate name, and it carries only the gates the consumer has
|
|
7
|
+
// chosen to run: configuring a gate is what opts into it, so a repository
|
|
8
|
+
// adopting one gate never reads, writes, or understands the others. That is the
|
|
9
|
+
// format's own property and the schema's own description states it.
|
|
10
|
+
//
|
|
11
|
+
// A gate invoked with no configuration for it refuses by name. There is no
|
|
12
|
+
// fallback to this package's own values, which sit in this repository's own
|
|
13
|
+
// `eval-quality.config.json` like anyone else's. `check-doc-invocations.mjs` and
|
|
14
|
+
// `audit-lockfile-age.mjs` set the register: fail closed on an absent or
|
|
15
|
+
// malformed value, and say what was expected.
|
|
16
|
+
//
|
|
17
|
+
// The loader reads and rewrites nothing. It validates one named section per
|
|
18
|
+
// call, so a malformed section for a gate the caller is not running never blocks
|
|
19
|
+
// the gate it is running.
|
|
20
|
+
//
|
|
21
|
+
// Run by `node` directly: Node's type stripping erases types only, so no
|
|
22
|
+
// TypeScript enum, namespace, parameter property, or non-type re-export may
|
|
23
|
+
// appear in this file or anything it imports, or the gate fails at load.
|
|
24
|
+
import { readFile } from 'node:fs/promises';
|
|
25
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
26
|
+
import { z } from 'zod';
|
|
27
|
+
import { DependencyDirectionSection } from './check-dependency-direction.js';
|
|
28
|
+
import { DocClaimsSection } from './check-doc-claims.js';
|
|
29
|
+
import { DocCountsSection } from './check-doc-counts.js';
|
|
30
|
+
import { FieldOwnershipSection } from './lineage-ownership.js';
|
|
31
|
+
import { PackageBoundarySection } from './package-boundary.js';
|
|
32
|
+
import { RelativePath } from './scanned-paths.js';
|
|
33
|
+
/** The file a consumer writes, resolved against the directory the gate runs in. */
|
|
34
|
+
export const DEFAULT_CONFIG_FILE = 'eval-quality.config.json';
|
|
35
|
+
/**
|
|
36
|
+
* The gates this build publishes, in the order the usage text lists them.
|
|
37
|
+
*
|
|
38
|
+
* Five of the eight keep their schema in the gate module rather than here. Three
|
|
39
|
+
* do so because they are reachable only after `typescript` has been probed and
|
|
40
|
+
* still have to declare their own section, and two because their schemas are
|
|
41
|
+
* large enough that carrying them here would make this file the place every gate
|
|
42
|
+
* is described. Importing any of those schemas is safe on any load path: each
|
|
43
|
+
* reaches `typescript` through a dynamic import and nothing else.
|
|
44
|
+
*
|
|
45
|
+
* The two `.mjs` gates and the invocation gate keep their schemas here, because
|
|
46
|
+
* a `.mjs` module cannot import `zod` without breaking the pre-install path PR 1
|
|
47
|
+
* records.
|
|
48
|
+
*/
|
|
49
|
+
export const GATE_NAMES = [
|
|
50
|
+
'lockfile-age',
|
|
51
|
+
'licences',
|
|
52
|
+
'dependency-direction',
|
|
53
|
+
'package-boundary',
|
|
54
|
+
'field-ownership',
|
|
55
|
+
'doc-invocations',
|
|
56
|
+
'doc-counts',
|
|
57
|
+
'doc-claims',
|
|
58
|
+
];
|
|
59
|
+
/**
|
|
60
|
+
* The audit window when a configuration names none, in days.
|
|
61
|
+
*
|
|
62
|
+
* A duration and not a date, which is what keeps it out of the class of settings
|
|
63
|
+
* a hand maintains: a cutoff date goes stale the day after it is written and a
|
|
64
|
+
* window never does. It matches `.npmrc`'s `min-release-age`, which filters
|
|
65
|
+
* resolution and fails open on a lockfile already carrying a young entry; this
|
|
66
|
+
* audit is what closes that.
|
|
67
|
+
*/
|
|
68
|
+
export const LOCKFILE_WINDOW_DAYS_DEFAULT = 7;
|
|
69
|
+
const NonEmpty = z.string().min(1);
|
|
70
|
+
/**
|
|
71
|
+
* An SPDX short identifier, for the allowlist and for a tolerance's one added
|
|
72
|
+
* identifier. The charset admits `MIT`, `Apache-2.0`, `0BSD` and
|
|
73
|
+
* `LGPL-3.0-or-later`, and refuses `@` and `/`, so a `name@version` pin cannot
|
|
74
|
+
* be written here. Both settings name a licence; a version pin would be a value
|
|
75
|
+
* a hand maintains in step with the dependency graph.
|
|
76
|
+
*/
|
|
77
|
+
const SpdxIdentifier = NonEmpty.regex(/^[A-Za-z0-9][A-Za-z0-9.+-]*$/, 'is not an SPDX short identifier: this setting takes identifiers such as MIT or Apache-2.0, and a package-and-version pin is not one');
|
|
78
|
+
/**
|
|
79
|
+
* An npm package name, for an age exclusion: an optional `@scope/` and then the
|
|
80
|
+
* name, in the URL-safe charset npm admits. A second `@` is refused anywhere, so
|
|
81
|
+
* `left-pad@1.3.0` cannot be written here. Uppercase is admitted because a
|
|
82
|
+
* lockfile can carry a legacy name that has it, and this setting names what the
|
|
83
|
+
* lockfile has. The setting mirrors `.npmrc`'s `min-release-age-exclude`, which
|
|
84
|
+
* takes names for the reason the allowlist takes identifiers: a version here
|
|
85
|
+
* would be a value a hand maintains in step with the dependency graph.
|
|
86
|
+
*/
|
|
87
|
+
const PackageName = NonEmpty.regex(/^(?:@[A-Za-z0-9._~!*'()-]+\/)?[A-Za-z0-9._~!*'()-]+$/, 'is not a package name: this setting takes names such as left-pad or @scope/name, and a package-and-version pin is not one');
|
|
88
|
+
/**
|
|
89
|
+
* One exemption from the age window. It names the package, the lockfiles it
|
|
90
|
+
* holds in, and why, so the run prints the reason beside the entry the way a
|
|
91
|
+
* policy and a tolerance do. A row that reaches no entry in any lockfile it
|
|
92
|
+
* names is refused at run time: the package left the lockfile, or the name is
|
|
93
|
+
* mistyped, and either way the row is a value nobody is holding.
|
|
94
|
+
*/
|
|
95
|
+
const AgeExclusion = z.strictObject({
|
|
96
|
+
name: PackageName.describe('The package the exemption covers, as the lockfile names the installed package, and every entry under that name in the lockfile, a nested duplicate at another version included. For an npm: alias that is the aliased package, so an entry installed at node_modules/foo from npm:bar@x is excluded by writing bar.'),
|
|
97
|
+
reason: NonEmpty.describe('Why this package may be adopted inside the window. It is printed on every run that uses it.'),
|
|
98
|
+
lockfiles: z
|
|
99
|
+
.array(NonEmpty)
|
|
100
|
+
.min(1)
|
|
101
|
+
.describe('The lockfiles this exemption applies to, and no others.'),
|
|
102
|
+
});
|
|
103
|
+
const LockfileAgeSection = z
|
|
104
|
+
.strictObject({
|
|
105
|
+
lockfiles: z
|
|
106
|
+
.array(NonEmpty)
|
|
107
|
+
.min(1)
|
|
108
|
+
.describe('Every lockfile to audit, repository-relative. One invocation covers all of them.'),
|
|
109
|
+
windowDays: z
|
|
110
|
+
.int()
|
|
111
|
+
.min(1)
|
|
112
|
+
.default(LOCKFILE_WINDOW_DAYS_DEFAULT)
|
|
113
|
+
.describe('How old an entry has to be, in days, and at least 1. A duration, so nothing here goes stale as time passes. Zero puts the cutoff at the instant of the run, admits a package published that same instant, and still reports that every entry was published before the cutoff.'),
|
|
114
|
+
cache: RelativePath.optional().describe('A JSON file mapping "name@version" to a publication timestamp. A publication time is fixed the moment it happens, so a reading taken once is correct forever and this cache carries no staleness bound. An entry it holds is used with no request; an entry it does not is fetched, and a fetch that fails still fails the gate. The gate only reads it.'),
|
|
115
|
+
exclude: z
|
|
116
|
+
.array(AgeExclusion)
|
|
117
|
+
.optional()
|
|
118
|
+
.describe("Packages exempt from the window and from the registry fetch, and never from the resolved-URL check, each row carrying its reason. The counterpart of .npmrc's min-release-age-exclude: names, so no version is pinned here. Every excluded entry is printed on every run, passing or failing."),
|
|
119
|
+
})
|
|
120
|
+
// A row naming a lockfile the section does not declare would load clean and
|
|
121
|
+
// apply to nothing, for the reason the licences section refuses the same
|
|
122
|
+
// under `tolerances` and `undeclared`. Two rows exempting one name in one
|
|
123
|
+
// lockfile are one exemption written twice with two reasons, so the second
|
|
124
|
+
// is refused.
|
|
125
|
+
.superRefine((section, ctx) => {
|
|
126
|
+
const declared = new Set(section.lockfiles);
|
|
127
|
+
const seen = new Set();
|
|
128
|
+
section.exclude?.forEach((row, index) => {
|
|
129
|
+
row.lockfiles.forEach((named, position) => {
|
|
130
|
+
if (!declared.has(named)) {
|
|
131
|
+
ctx.addIssue({
|
|
132
|
+
code: 'custom',
|
|
133
|
+
path: ['exclude', index, 'lockfiles', position],
|
|
134
|
+
message: `names "${named}", which is not one of the lockfiles this section declares: ${section.lockfiles.join(', ')}`,
|
|
135
|
+
});
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const key = `${row.name}\u0000${named}`;
|
|
139
|
+
if (seen.has(key)) {
|
|
140
|
+
ctx.addIssue({
|
|
141
|
+
code: 'custom',
|
|
142
|
+
path: ['exclude', index],
|
|
143
|
+
message: `excludes "${row.name}" in ${named}, which an earlier row already excludes; one exemption carries one reason`,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
seen.add(key);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
})
|
|
150
|
+
.describe("Fails on a locked entry published inside the window, on metadata that could not be fetched, and on an entry whose resolved URL is not that entry's own tarball on the npm registry.");
|
|
151
|
+
/**
|
|
152
|
+
* The per-lockfile split, keyed by lockfile path. `also` extends the top-level
|
|
153
|
+
* allowlist instead of restating it, so the two lists cannot drift apart: there
|
|
154
|
+
* is one allowlist and one delta.
|
|
155
|
+
*/
|
|
156
|
+
const LicencePolicy = z.strictObject({
|
|
157
|
+
label: NonEmpty.describe('What a reader of CI output sees beside the result, so two policies can never be confused.'),
|
|
158
|
+
reason: NonEmpty.describe('Why this lockfile may allow more than the others. A policy that widens the allowlist says so in the file that widens it.'),
|
|
159
|
+
also: z
|
|
160
|
+
.array(SpdxIdentifier)
|
|
161
|
+
.min(1)
|
|
162
|
+
.describe('Identifiers this lockfile allows on top of the allowlist.'),
|
|
163
|
+
});
|
|
164
|
+
/**
|
|
165
|
+
* A scoped exception: a family of packages named by prefix, the one identifier
|
|
166
|
+
* the allowlist gains inside that family, and the condition under which the
|
|
167
|
+
* exception holds at all. It is narrower than an allowlist entry, which applies
|
|
168
|
+
* to every package in the lockfile and carries no condition.
|
|
169
|
+
*/
|
|
170
|
+
const LicenceTolerance = z.strictObject({
|
|
171
|
+
reason: NonEmpty.describe('Why the exception is sound. It is printed on every run that uses it.'),
|
|
172
|
+
lockfiles: z
|
|
173
|
+
.array(NonEmpty)
|
|
174
|
+
.min(1)
|
|
175
|
+
.describe('The lockfiles this exception applies to, and no others.'),
|
|
176
|
+
prefix: NonEmpty.describe('The package-name prefix the exception covers. A prefix, so no version is pinned here.'),
|
|
177
|
+
license: SpdxIdentifier.describe('The one identifier this exception adds to the allowlist, for this family of packages alone. The expression is then read by the rule every other entry is read by, so an AND still needs every operand covered and a WITH compound still has to be listed exactly.'),
|
|
178
|
+
optional: z
|
|
179
|
+
.boolean()
|
|
180
|
+
.default(true)
|
|
181
|
+
.describe('Whether the exception is limited to entries npm recorded as optional.'),
|
|
182
|
+
marker: z
|
|
183
|
+
.strictObject({ file: NonEmpty, contains: NonEmpty })
|
|
184
|
+
.optional()
|
|
185
|
+
.describe('The exception holds only while this file carries this text. An absent or unreadable file withdraws it.'),
|
|
186
|
+
});
|
|
187
|
+
/**
|
|
188
|
+
* A reading for an entry whose manifest declares no licence. A tolerance widens
|
|
189
|
+
* the allowlist for a family that declares one, under a condition the gate can
|
|
190
|
+
* re-read; an undeclared entry declares nothing, so there is no expression to
|
|
191
|
+
* widen. The row supplies what the manifest would have said and the evidence
|
|
192
|
+
* for it, and the identifier is then held by the rule every other entry is held
|
|
193
|
+
* by, so a row cannot admit what the allowlist refuses. "Declares no licence"
|
|
194
|
+
* is an absent, null or blank `license` field: a field present in a shape the
|
|
195
|
+
* gate does not read, an array or an object with no `type`, declares something
|
|
196
|
+
* and fails as it always has.
|
|
197
|
+
*/
|
|
198
|
+
const UndeclaredLicence = z.strictObject({
|
|
199
|
+
reason: NonEmpty.describe('Why the manifest carries no licence field. It is printed on every run that uses it.'),
|
|
200
|
+
lockfiles: z
|
|
201
|
+
.array(NonEmpty)
|
|
202
|
+
.min(1)
|
|
203
|
+
.describe('The lockfiles this reading applies to, and no others.'),
|
|
204
|
+
prefix: NonEmpty.describe('The package-name prefix the reading covers, matched as a plain string prefix with no boundary, so a whole name is the tightest prefix and zod-to-ts also reaches zod-to-ts-plugin the day one appears undeclared. A prefix, so no version is pinned here. It reaches only an entry that declares no licence; an entry under it that declares one is held to its declaration, and a row that reaches no such entry in any lockfile it names is refused at run time.'),
|
|
205
|
+
readAs: SpdxIdentifier.describe('The one identifier the entry is read as, held against the allowlist like any declared identifier. One identifier only: an expression, a marker or an optional flag has no meaning for an entry that declares nothing.'),
|
|
206
|
+
evidence: NonEmpty.describe('Where the reading comes from, such as the registry packument or a LICENSE file in the repository. It is printed on every run that uses it.'),
|
|
207
|
+
});
|
|
208
|
+
const LicencesSection = z
|
|
209
|
+
.strictObject({
|
|
210
|
+
lockfiles: z
|
|
211
|
+
.array(NonEmpty)
|
|
212
|
+
.min(1)
|
|
213
|
+
.describe('Every lockfile to scan, repository-relative. One invocation covers all of them.'),
|
|
214
|
+
allowlist: z
|
|
215
|
+
.array(SpdxIdentifier)
|
|
216
|
+
.min(1)
|
|
217
|
+
.describe('The identifiers every entry is held against. Required: an absent allowlist would either fail everything or silently permit everything, and this gate does neither.'),
|
|
218
|
+
policies: z
|
|
219
|
+
.record(NonEmpty, LicencePolicy)
|
|
220
|
+
.optional()
|
|
221
|
+
.describe('Per-lockfile additions, keyed by lockfile path. A lockfile with no entry here is held against the allowlist alone.'),
|
|
222
|
+
tolerances: z
|
|
223
|
+
.array(LicenceTolerance)
|
|
224
|
+
.optional()
|
|
225
|
+
.describe('Scoped exceptions, each carrying its own reason.'),
|
|
226
|
+
undeclared: z
|
|
227
|
+
.array(UndeclaredLicence)
|
|
228
|
+
.optional()
|
|
229
|
+
.describe('Readings for entries whose manifest declares no licence, each carrying its evidence and its reason.'),
|
|
230
|
+
})
|
|
231
|
+
// `policies` is keyed by lockfile path, and every tolerance and every
|
|
232
|
+
// undeclared row names the lockfiles it applies to, all by the same string
|
|
233
|
+
// `lockfiles` names them by. A value matching no declared lockfile loads clean
|
|
234
|
+
// and applies to nothing, so a typo like "pacakge-lock.json" reads as a policy
|
|
235
|
+
// that was written and never runs. Keeping those lists in step by hand is the
|
|
236
|
+
// class of setting this format does not have, so a name matching nothing is
|
|
237
|
+
// refused here.
|
|
238
|
+
.superRefine((section, ctx) => {
|
|
239
|
+
const declared = new Set(section.lockfiles);
|
|
240
|
+
const requireDeclared = (named, path) => {
|
|
241
|
+
if (declared.has(named))
|
|
242
|
+
return;
|
|
243
|
+
ctx.addIssue({
|
|
244
|
+
code: 'custom',
|
|
245
|
+
path,
|
|
246
|
+
message: `names "${named}", which is not one of the lockfiles this section declares: ${section.lockfiles.join(', ')}`,
|
|
247
|
+
});
|
|
248
|
+
};
|
|
249
|
+
for (const key of Object.keys(section.policies ?? {})) {
|
|
250
|
+
requireDeclared(key, ['policies', key]);
|
|
251
|
+
}
|
|
252
|
+
section.tolerances?.forEach((tolerance, index) => {
|
|
253
|
+
tolerance.lockfiles.forEach((named, position) => {
|
|
254
|
+
requireDeclared(named, ['tolerances', index, 'lockfiles', position]);
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
// Two rows reading one prefix in one lockfile would read one package as
|
|
258
|
+
// two licences, so the second is refused.
|
|
259
|
+
const seen = new Set();
|
|
260
|
+
section.undeclared?.forEach((row, index) => {
|
|
261
|
+
row.lockfiles.forEach((named, position) => {
|
|
262
|
+
requireDeclared(named, ['undeclared', index, 'lockfiles', position]);
|
|
263
|
+
const key = `${row.prefix}\u0000${named}`;
|
|
264
|
+
if (declared.has(named) && seen.has(key)) {
|
|
265
|
+
ctx.addIssue({
|
|
266
|
+
code: 'custom',
|
|
267
|
+
path: ['undeclared', index],
|
|
268
|
+
message: `reads "${row.prefix}" in ${named}, which an earlier row already reads; one package is read as one licence`,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
seen.add(key);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
})
|
|
275
|
+
.describe("Holds every locked entry's licence expression against an allowlist of SPDX identifiers, and fails on an entry whose resolved URL is not that entry's own tarball on the npm registry.");
|
|
276
|
+
/**
|
|
277
|
+
* The invocation gate's section. It sits here rather than in its gate module,
|
|
278
|
+
* because that gate is `.mjs` and stays free of every import from
|
|
279
|
+
* `node_modules`, which is what keeps the pre-install path open for the two
|
|
280
|
+
* gates that run before `npm ci`.
|
|
281
|
+
*/
|
|
282
|
+
const DocInvocationsSection = z
|
|
283
|
+
.strictObject({
|
|
284
|
+
pages: z
|
|
285
|
+
.array(RelativePath)
|
|
286
|
+
.min(1)
|
|
287
|
+
.describe("The pages whose fenced commands are run, as files or directories to walk. Naming the configuration's own directory is refused: every fenced command in a whole repository is more than this gate should run."),
|
|
288
|
+
binary: z
|
|
289
|
+
.strictObject({
|
|
290
|
+
entry: RelativePath.describe('The built entry point every documented command is run against. It is a precondition: a gate that skipped when it was absent would exit 0 having executed nothing.'),
|
|
291
|
+
spellings: z
|
|
292
|
+
.array(NonEmpty)
|
|
293
|
+
.min(1)
|
|
294
|
+
.describe('How your documentation writes the command, as the literal text a reader types. Each is matched at the start of a fenced line, with whitespace or end of line after it, so a sample of your own diagnostic output is left alone.'),
|
|
295
|
+
installedPrefix: NonEmpty.optional().describe('The path prefix a page uses for a file inside the installed package, such as "node_modules/your-package/". Mapping it away is what lets those examples be checked against real bytes.'),
|
|
296
|
+
})
|
|
297
|
+
.describe('What a documented command line runs.'),
|
|
298
|
+
sampleInput: RelativePath.describe('A file that stands in for an input only a reader has, such as `<path>`. A run that needed one is judged for usage errors and crashes and no more.'),
|
|
299
|
+
usageExit: z
|
|
300
|
+
.int()
|
|
301
|
+
.min(1)
|
|
302
|
+
.max(255)
|
|
303
|
+
.default(64)
|
|
304
|
+
.describe('The code your command line returns when a command or a flag does not exist. Every documented invocation exiting with it fails, whatever inputs it named. 64 is sysexits.h EX_USAGE.'),
|
|
305
|
+
timeoutMs: z
|
|
306
|
+
.int()
|
|
307
|
+
.min(1000)
|
|
308
|
+
.default(30_000)
|
|
309
|
+
.describe('How long one documented invocation may run.'),
|
|
310
|
+
elisionLimit: z
|
|
311
|
+
.int()
|
|
312
|
+
.min(0)
|
|
313
|
+
.default(3)
|
|
314
|
+
.describe('How many "..." elisions one transcribed output line may carry. Matching them is polynomial in the count, so a line carrying many over a long repetitive diagnostic can run for minutes.'),
|
|
315
|
+
})
|
|
316
|
+
.describe('Runs every fenced command in your documentation against your built binary and compares the exit code with what the page claims. A page that declares its exit may transcribe the diagnostic beside it, and that block is compared line for line.');
|
|
317
|
+
/**
|
|
318
|
+
* The whole document, as one schema. It is where the format states its own
|
|
319
|
+
* incremental-adoption property, and its one consumer is the `doc-claims` gate,
|
|
320
|
+
* which parses the documented example through it so the page a consumer copies
|
|
321
|
+
* is held to the format it describes. The relation runs through the
|
|
322
|
+
* configuration rather than through an import: `eval-quality.config.json` names
|
|
323
|
+
* this export as the schema for that fence, and the gate imports it at run time.
|
|
324
|
+
*
|
|
325
|
+
* The loader never uses it. Validating the document whole would block a gate
|
|
326
|
+
* the caller is running on a gate it is not, which is the opposite of the
|
|
327
|
+
* property this object describes.
|
|
328
|
+
*/
|
|
329
|
+
export const GateConfiguration = z
|
|
330
|
+
.object({
|
|
331
|
+
'lockfile-age': LockfileAgeSection.optional(),
|
|
332
|
+
licences: LicencesSection.optional(),
|
|
333
|
+
'dependency-direction': DependencyDirectionSection.optional(),
|
|
334
|
+
'package-boundary': PackageBoundarySection.optional(),
|
|
335
|
+
'field-ownership': FieldOwnershipSection.optional(),
|
|
336
|
+
'doc-invocations': DocInvocationsSection.optional(),
|
|
337
|
+
'doc-counts': DocCountsSection.optional(),
|
|
338
|
+
'doc-claims': DocClaimsSection.optional(),
|
|
339
|
+
})
|
|
340
|
+
.describe("The gates this repository has chosen to run, keyed by gate name. Incremental adoption is structural: the file carries only the gates you have adopted, and configuring a gate is what opts into it. A gate you invoke with no section here refuses by name; it falls back to nobody else's values.");
|
|
341
|
+
const refuse = (message) => ({
|
|
342
|
+
kind: 'refused',
|
|
343
|
+
message,
|
|
344
|
+
});
|
|
345
|
+
/** Which other gates the file does configure, so a refusal says what is there. */
|
|
346
|
+
const otherGates = (gate, document) => {
|
|
347
|
+
const present = GATE_NAMES.filter((name) => name !== gate && document[name] !== undefined);
|
|
348
|
+
return present.length === 0
|
|
349
|
+
? 'it configures no gate at all'
|
|
350
|
+
: `it configures ${present.join(', ')}`;
|
|
351
|
+
};
|
|
352
|
+
const renderIssues = (error) => error.issues
|
|
353
|
+
.map((issue) => {
|
|
354
|
+
const at = issue.path.length === 0 ? '(the section itself)' : issue.path.join('.');
|
|
355
|
+
return ` ${at}: ${issue.message}`;
|
|
356
|
+
})
|
|
357
|
+
.join('\n');
|
|
358
|
+
/**
|
|
359
|
+
* Four refusals, each its own because the repair is different: the file is not
|
|
360
|
+
* there, the file is not JSON, the file configures some other gate, and the
|
|
361
|
+
* section is there and wrong. Every one names the file and the gate.
|
|
362
|
+
*/
|
|
363
|
+
async function findSection(gate, options) {
|
|
364
|
+
const cwd = options.cwd ?? process.cwd();
|
|
365
|
+
const named = options.configPath ?? DEFAULT_CONFIG_FILE;
|
|
366
|
+
const path = isAbsolute(named) ? named : resolve(cwd, named);
|
|
367
|
+
let text;
|
|
368
|
+
try {
|
|
369
|
+
text = await readFile(path, 'utf8');
|
|
370
|
+
}
|
|
371
|
+
catch (error) {
|
|
372
|
+
if (error.code === 'ENOENT') {
|
|
373
|
+
return refuse(`${path} does not exist, and the ${gate} gate is configured there; write the file, or name another with --config <path>`);
|
|
374
|
+
}
|
|
375
|
+
return refuse(`${path} could not be read: ${error instanceof Error ? error.message : String(error)}`);
|
|
376
|
+
}
|
|
377
|
+
let parsed;
|
|
378
|
+
try {
|
|
379
|
+
parsed = JSON.parse(text);
|
|
380
|
+
}
|
|
381
|
+
catch (error) {
|
|
382
|
+
return refuse(`${path} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
383
|
+
}
|
|
384
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
385
|
+
return refuse(`${path} is not a JSON object; the top level is an object keyed by gate name, and the ${gate} gate reads its "${gate}" key`);
|
|
386
|
+
}
|
|
387
|
+
const document = parsed;
|
|
388
|
+
const section = document[gate];
|
|
389
|
+
if (section === undefined) {
|
|
390
|
+
return refuse(`${path} declares no "${gate}" section, and ${otherGates(gate, document)}; configuring a gate is what opts into it, so add a "${gate}" object to run this one`);
|
|
391
|
+
}
|
|
392
|
+
return { kind: 'found', path, raw: section };
|
|
393
|
+
}
|
|
394
|
+
async function loadSection(gate, schema, options) {
|
|
395
|
+
const found = await findSection(gate, options);
|
|
396
|
+
if (found.kind === 'refused')
|
|
397
|
+
return found;
|
|
398
|
+
const result = schema.safeParse(found.raw);
|
|
399
|
+
if (!result.success) {
|
|
400
|
+
return refuse(`${found.path}'s "${gate}" section is malformed:\n${renderIssues(result.error)}`);
|
|
401
|
+
}
|
|
402
|
+
return {
|
|
403
|
+
kind: 'section',
|
|
404
|
+
path: found.path,
|
|
405
|
+
section: result.data,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
export const loadLockfileAgeConfig = (options = {}) => loadSection('lockfile-age', LockfileAgeSection, options);
|
|
409
|
+
export const loadLicencesConfig = (options = {}) => loadSection('licences', LicencesSection, options);
|
|
410
|
+
export const loadDependencyDirectionConfig = (options = {}) => loadSection('dependency-direction', DependencyDirectionSection, options);
|
|
411
|
+
export const loadPackageBoundaryConfig = (options = {}) => loadSection('package-boundary', PackageBoundarySection, options);
|
|
412
|
+
export const loadFieldOwnershipConfig = (options = {}) => loadSection('field-ownership', FieldOwnershipSection, options);
|
|
413
|
+
export const loadDocInvocationsConfig = (options = {}) => loadSection('doc-invocations', DocInvocationsSection, options);
|
|
414
|
+
export const loadDocCountsConfig = (options = {}) => loadSection('doc-counts', DocCountsSection, options);
|
|
415
|
+
export const loadDocClaimsConfig = (options = {}) => loadSection('doc-claims', DocClaimsSection, options);
|