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.
Files changed (43) hide show
  1. package/README.md +4 -1
  2. package/dist/application/index.d.ts +4 -0
  3. package/dist/application/index.js +2 -0
  4. package/dist/core/emit/emit.js +4 -2
  5. package/dist/core/preflight/reduce.d.ts +1 -1
  6. package/dist/core/preflight/reduce.js +5 -2
  7. package/dist/core/schemas/evaluator-configuration.d.ts +9 -0
  8. package/dist/core/schemas/evaluator-configuration.js +9 -0
  9. package/dist/core/schemas/evidence-artifact.d.ts +9 -0
  10. package/dist/core/schemas/evidence-artifact.js +9 -0
  11. package/dist/core/schemas/isolation-manifest.d.ts +18 -0
  12. package/dist/core/schemas/isolation-manifest.js +18 -0
  13. package/dist/core/schemas/preflight-verdict.d.ts +9 -0
  14. package/dist/core/schemas/preflight-verdict.js +9 -0
  15. package/dist/core/schemas/private-artifact-manifest.d.ts +10 -0
  16. package/dist/core/schemas/private-artifact-manifest.js +10 -0
  17. package/dist/core/schemas/scoring-policy.d.ts +11 -0
  18. package/dist/core/schemas/scoring-policy.js +11 -0
  19. package/dist/core/schemas/sealed-evaluator-brief.d.ts +12 -0
  20. package/dist/core/schemas/sealed-evaluator-brief.js +12 -0
  21. package/dist/core/schemas/sealed-run-record.d.ts +11 -0
  22. package/dist/core/schemas/sealed-run-record.js +11 -0
  23. package/dist/core/seal/seal.js +4 -5
  24. package/dist/gates/audit-lockfile-age.mjs +392 -0
  25. package/dist/gates/check-dependency-direction.js +303 -0
  26. package/dist/gates/check-doc-claims.js +1012 -0
  27. package/dist/gates/check-doc-counts.js +408 -0
  28. package/dist/gates/check-doc-invocations.mjs +618 -0
  29. package/dist/gates/check-licenses.mjs +378 -0
  30. package/dist/gates/consumer-pattern.js +104 -0
  31. package/dist/gates/dependency-direction.js +555 -0
  32. package/dist/gates/discover-source-files.js +44 -0
  33. package/dist/gates/gate-config.js +415 -0
  34. package/dist/gates/gates-cli.js +607 -0
  35. package/dist/gates/lineage-ownership.js +364 -0
  36. package/dist/gates/module-value.js +187 -0
  37. package/dist/gates/package-boundary.js +277 -0
  38. package/dist/gates/scanned-paths.js +110 -0
  39. package/dist/gates/token-scan.js +203 -0
  40. package/dist/index.d.ts +11 -1
  41. package/dist/index.js +20 -1
  42. package/dist/testing/probe-conformance.d.ts +23 -18
  43. 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);