eval-quality 2.0.0 → 3.1.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 (45) 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/compile/compile.js +6 -1
  5. package/dist/core/compile/schema-version.d.ts +15 -2
  6. package/dist/core/compile/schema-version.js +11 -3
  7. package/dist/core/emit/emit.js +4 -2
  8. package/dist/core/preflight/plan.js +15 -0
  9. package/dist/core/preflight/reduce.d.ts +1 -1
  10. package/dist/core/preflight/reduce.js +5 -2
  11. package/dist/core/schemas/evaluator-configuration.d.ts +9 -0
  12. package/dist/core/schemas/evaluator-configuration.js +9 -0
  13. package/dist/core/schemas/evidence-artifact.d.ts +9 -0
  14. package/dist/core/schemas/evidence-artifact.js +9 -0
  15. package/dist/core/schemas/isolation-manifest.d.ts +18 -0
  16. package/dist/core/schemas/isolation-manifest.js +18 -0
  17. package/dist/core/schemas/preflight-verdict.d.ts +9 -0
  18. package/dist/core/schemas/preflight-verdict.js +9 -0
  19. package/dist/core/schemas/private-artifact-manifest.d.ts +10 -0
  20. package/dist/core/schemas/private-artifact-manifest.js +10 -0
  21. package/dist/core/schemas/probe.d.ts +41 -0
  22. package/dist/core/schemas/probe.js +43 -0
  23. package/dist/core/schemas/scoring-policy.d.ts +11 -0
  24. package/dist/core/schemas/scoring-policy.js +11 -0
  25. package/dist/core/schemas/sealed-evaluator-brief.d.ts +12 -0
  26. package/dist/core/schemas/sealed-evaluator-brief.js +12 -0
  27. package/dist/core/schemas/sealed-run-record.d.ts +11 -0
  28. package/dist/core/schemas/sealed-run-record.js +11 -0
  29. package/dist/core/score/score.d.ts +1 -1
  30. package/dist/core/score/score.js +23 -0
  31. package/dist/core/seal/seal.js +4 -5
  32. package/dist/gates/audit-lockfile-age.mjs +295 -0
  33. package/dist/gates/check-dependency-direction.js +303 -0
  34. package/dist/gates/check-licenses.mjs +305 -0
  35. package/dist/gates/dependency-direction.js +555 -0
  36. package/dist/gates/discover-source-files.js +44 -0
  37. package/dist/gates/gate-config.js +251 -0
  38. package/dist/gates/gates-cli.js +410 -0
  39. package/dist/gates/lineage-ownership.js +364 -0
  40. package/dist/gates/package-boundary.js +388 -0
  41. package/dist/gates/token-scan.js +203 -0
  42. package/dist/index.d.ts +11 -1
  43. package/dist/index.js +20 -1
  44. package/dist/testing/probe-conformance.d.ts +23 -18
  45. package/package.json +20 -8
@@ -0,0 +1,251 @@
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 { FieldOwnershipSection } from './lineage-ownership.js';
29
+ import { PackageBoundarySection } from './package-boundary.js';
30
+ /** The file a consumer writes, resolved against the directory the gate runs in. */
31
+ export const DEFAULT_CONFIG_FILE = 'eval-quality.config.json';
32
+ /**
33
+ * The gates this build publishes, in the order the usage text lists them.
34
+ *
35
+ * Three of the five keep their schema in the gate module rather than here, so
36
+ * that a module reachable only after `typescript` has been probed still declares
37
+ * its own section. Importing those schemas is safe on any load path: each of the
38
+ * three reaches `typescript` through a dynamic import and nothing else.
39
+ */
40
+ export const GATE_NAMES = [
41
+ 'lockfile-age',
42
+ 'licences',
43
+ 'dependency-direction',
44
+ 'package-boundary',
45
+ 'field-ownership',
46
+ ];
47
+ /**
48
+ * The audit window when a configuration names none, in days.
49
+ *
50
+ * A duration and not a date, which is what keeps it out of the class of settings
51
+ * a hand maintains: a cutoff date goes stale the day after it is written and a
52
+ * window never does. It matches `.npmrc`'s `min-release-age`, which filters
53
+ * resolution and fails open on a lockfile already carrying a young entry; this
54
+ * audit is what closes that.
55
+ */
56
+ export const LOCKFILE_WINDOW_DAYS_DEFAULT = 7;
57
+ const NonEmpty = z.string().min(1);
58
+ /**
59
+ * An SPDX short identifier, for the allowlist and for a tolerance's one added
60
+ * identifier. The charset admits `MIT`, `Apache-2.0`, `0BSD` and
61
+ * `LGPL-3.0-or-later`, and refuses `@` and `/`, so a `name@version` pin cannot
62
+ * be written here. Both settings name a licence; a version pin would be a value
63
+ * a hand maintains in step with the dependency graph.
64
+ */
65
+ 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');
66
+ const LockfileAgeSection = z
67
+ .strictObject({
68
+ lockfiles: z
69
+ .array(NonEmpty)
70
+ .min(1)
71
+ .describe('Every lockfile to audit, repository-relative. One invocation covers all of them.'),
72
+ windowDays: z
73
+ .int()
74
+ .min(1)
75
+ .default(LOCKFILE_WINDOW_DAYS_DEFAULT)
76
+ .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.'),
77
+ })
78
+ .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.");
79
+ /**
80
+ * The per-lockfile split, keyed by lockfile path. `also` extends the top-level
81
+ * allowlist instead of restating it, so the two lists cannot drift apart: there
82
+ * is one allowlist and one delta.
83
+ */
84
+ const LicencePolicy = z.strictObject({
85
+ label: NonEmpty.describe('What a reader of CI output sees beside the result, so two policies can never be confused.'),
86
+ 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.'),
87
+ also: z
88
+ .array(SpdxIdentifier)
89
+ .min(1)
90
+ .describe('Identifiers this lockfile allows on top of the allowlist.'),
91
+ });
92
+ /**
93
+ * A scoped exception: a family of packages named by prefix, the one identifier
94
+ * the allowlist gains inside that family, and the condition under which the
95
+ * exception holds at all. It is narrower than an allowlist entry, which applies
96
+ * to every package in the lockfile and carries no condition.
97
+ */
98
+ const LicenceTolerance = z.strictObject({
99
+ reason: NonEmpty.describe('Why the exception is sound. It is printed on every run that uses it.'),
100
+ lockfiles: z
101
+ .array(NonEmpty)
102
+ .min(1)
103
+ .describe('The lockfiles this exception applies to, and no others.'),
104
+ prefix: NonEmpty.describe('The package-name prefix the exception covers. A prefix, so no version is pinned here.'),
105
+ 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.'),
106
+ optional: z
107
+ .boolean()
108
+ .default(true)
109
+ .describe('Whether the exception is limited to entries npm recorded as optional.'),
110
+ marker: z
111
+ .strictObject({ file: NonEmpty, contains: NonEmpty })
112
+ .optional()
113
+ .describe('The exception holds only while this file carries this text. An absent or unreadable file withdraws it.'),
114
+ });
115
+ const LicencesSection = z
116
+ .strictObject({
117
+ lockfiles: z
118
+ .array(NonEmpty)
119
+ .min(1)
120
+ .describe('Every lockfile to scan, repository-relative. One invocation covers all of them.'),
121
+ allowlist: z
122
+ .array(SpdxIdentifier)
123
+ .min(1)
124
+ .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.'),
125
+ policies: z
126
+ .record(NonEmpty, LicencePolicy)
127
+ .optional()
128
+ .describe('Per-lockfile additions, keyed by lockfile path. A lockfile with no entry here is held against the allowlist alone.'),
129
+ tolerances: z
130
+ .array(LicenceTolerance)
131
+ .optional()
132
+ .describe('Scoped exceptions, each carrying its own reason.'),
133
+ })
134
+ // `policies` is keyed by lockfile path and every tolerance names the lockfiles
135
+ // it applies to, both by the same string `lockfiles` names them by. A value
136
+ // matching no declared lockfile loads clean and applies to nothing, so a typo
137
+ // like "pacakge-lock.json" reads as a policy that was written and never runs.
138
+ // Keeping those three lists in step by hand is the class of setting this format
139
+ // does not have, so a name matching nothing is refused here.
140
+ .superRefine((section, ctx) => {
141
+ const declared = new Set(section.lockfiles);
142
+ const requireDeclared = (named, path) => {
143
+ if (declared.has(named))
144
+ return;
145
+ ctx.addIssue({
146
+ code: 'custom',
147
+ path,
148
+ message: `names "${named}", which is not one of the lockfiles this section declares: ${section.lockfiles.join(', ')}`,
149
+ });
150
+ };
151
+ for (const key of Object.keys(section.policies ?? {})) {
152
+ requireDeclared(key, ['policies', key]);
153
+ }
154
+ section.tolerances?.forEach((tolerance, index) => {
155
+ tolerance.lockfiles.forEach((named, position) => {
156
+ requireDeclared(named, ['tolerances', index, 'lockfiles', position]);
157
+ });
158
+ });
159
+ })
160
+ .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.");
161
+ /**
162
+ * The whole document, as one schema. It is where the format states its own
163
+ * incremental-adoption property, and its one consumer is `check-doc-claims.ts`,
164
+ * which parses the documented example through it so the page a consumer copies
165
+ * is held to the format it describes.
166
+ *
167
+ * The loader never uses it. Validating the document whole would block a gate
168
+ * the caller is running on a gate it is not, which is the opposite of the
169
+ * property this object describes.
170
+ */
171
+ export const GateConfiguration = z
172
+ .object({
173
+ 'lockfile-age': LockfileAgeSection.optional(),
174
+ licences: LicencesSection.optional(),
175
+ 'dependency-direction': DependencyDirectionSection.optional(),
176
+ 'package-boundary': PackageBoundarySection.optional(),
177
+ 'field-ownership': FieldOwnershipSection.optional(),
178
+ })
179
+ .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.");
180
+ const refuse = (message) => ({
181
+ kind: 'refused',
182
+ message,
183
+ });
184
+ /** Which other gates the file does configure, so a refusal says what is there. */
185
+ const otherGates = (gate, document) => {
186
+ const present = GATE_NAMES.filter((name) => name !== gate && document[name] !== undefined);
187
+ return present.length === 0
188
+ ? 'it configures no gate at all'
189
+ : `it configures ${present.join(', ')}`;
190
+ };
191
+ const renderIssues = (error) => error.issues
192
+ .map((issue) => {
193
+ const at = issue.path.length === 0 ? '(the section itself)' : issue.path.join('.');
194
+ return ` ${at}: ${issue.message}`;
195
+ })
196
+ .join('\n');
197
+ /**
198
+ * Four refusals, each its own because the repair is different: the file is not
199
+ * there, the file is not JSON, the file configures some other gate, and the
200
+ * section is there and wrong. Every one names the file and the gate.
201
+ */
202
+ async function findSection(gate, options) {
203
+ const cwd = options.cwd ?? process.cwd();
204
+ const named = options.configPath ?? DEFAULT_CONFIG_FILE;
205
+ const path = isAbsolute(named) ? named : resolve(cwd, named);
206
+ let text;
207
+ try {
208
+ text = await readFile(path, 'utf8');
209
+ }
210
+ catch (error) {
211
+ if (error.code === 'ENOENT') {
212
+ return refuse(`${path} does not exist, and the ${gate} gate is configured there; write the file, or name another with --config <path>`);
213
+ }
214
+ return refuse(`${path} could not be read: ${error instanceof Error ? error.message : String(error)}`);
215
+ }
216
+ let parsed;
217
+ try {
218
+ parsed = JSON.parse(text);
219
+ }
220
+ catch (error) {
221
+ return refuse(`${path} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
222
+ }
223
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
224
+ 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`);
225
+ }
226
+ const document = parsed;
227
+ const section = document[gate];
228
+ if (section === undefined) {
229
+ 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`);
230
+ }
231
+ return { kind: 'found', path, raw: section };
232
+ }
233
+ async function loadSection(gate, schema, options) {
234
+ const found = await findSection(gate, options);
235
+ if (found.kind === 'refused')
236
+ return found;
237
+ const result = schema.safeParse(found.raw);
238
+ if (!result.success) {
239
+ return refuse(`${found.path}'s "${gate}" section is malformed:\n${renderIssues(result.error)}`);
240
+ }
241
+ return {
242
+ kind: 'section',
243
+ path: found.path,
244
+ section: result.data,
245
+ };
246
+ }
247
+ export const loadLockfileAgeConfig = (options = {}) => loadSection('lockfile-age', LockfileAgeSection, options);
248
+ export const loadLicencesConfig = (options = {}) => loadSection('licences', LicencesSection, options);
249
+ export const loadDependencyDirectionConfig = (options = {}) => loadSection('dependency-direction', DependencyDirectionSection, options);
250
+ export const loadPackageBoundaryConfig = (options = {}) => loadSection('package-boundary', PackageBoundarySection, options);
251
+ export const loadFieldOwnershipConfig = (options = {}) => loadSection('field-ownership', FieldOwnershipSection, options);
@@ -0,0 +1,410 @@
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, } from './audit-lockfile-age.mjs';
28
+ import { runDependencyDirection } from './check-dependency-direction.js';
29
+ import { checkLicenses } from './check-licenses.mjs';
30
+ import { DEFAULT_CONFIG_FILE, GATE_NAMES, loadDependencyDirectionConfig, loadFieldOwnershipConfig, loadLicencesConfig, loadLockfileAgeConfig, loadPackageBoundaryConfig, } from './gate-config.js';
31
+ import { runFieldOwnership, TYPESCRIPT_UNAVAILABLE, } from './lineage-ownership.js';
32
+ import { runPackageBoundary, SCAN_PATH_ERROR, SCAN_UNREADABLE, } from './package-boundary.js';
33
+ const EXIT_OK = 0;
34
+ /** The gate ran and found what it exists to find. */
35
+ const EXIT_GATE_FAILED = 1;
36
+ /**
37
+ * sysexits.h EX_USAGE, the same number `src/cli/exit-codes.ts` assigns and for
38
+ * the same reason: the caller repairs it by changing what it passed. This build
39
+ * roots at `scripts/`, so it cannot import that module; a test case holds the
40
+ * two numbers equal.
41
+ */
42
+ const EXIT_USAGE = 64;
43
+ const BINARY = 'eval-quality-gates';
44
+ /**
45
+ * One line per gate. Keyed by `GateName`, so publishing a gate without
46
+ * describing it here is a type error and the usage text cannot go stale.
47
+ */
48
+ const GATE_SUMMARY = {
49
+ 'lockfile-age': "every locked entry's registry publication age, against a window you declare",
50
+ licences: "every locked entry's licence, against an allowlist of identifiers you declare",
51
+ 'dependency-direction': 'every import in the trees you name, against a layer graph you declare',
52
+ 'package-boundary': 'every line your package would publish, against the patterns you forbid',
53
+ 'field-ownership': 'every write to a field you own, against the modules you let write it',
54
+ };
55
+ /** The widest gate name, plus the two spaces that separate it from its summary. */
56
+ const GATE_COLUMN = Math.max(...GATE_NAMES.map((gate) => gate.length)) + 2;
57
+ const GATE_LINES = GATE_NAMES.map((gate) => ` ${gate.padEnd(GATE_COLUMN)}${GATE_SUMMARY[gate]}`).join('\n');
58
+ const USAGE = `Usage:
59
+ ${BINARY} <gate> [--config <path>]
60
+
61
+ ${GATE_LINES}
62
+
63
+ --config <path> the configuration file; ${DEFAULT_CONFIG_FILE} in the working directory by default
64
+ --help, -h this text
65
+
66
+ Each gate reads its own section of that file, and configuring a gate is what
67
+ opts into it. A gate invoked with no section refuses by name and falls back to
68
+ nothing. Every path a section names is relative to the configuration file.
69
+
70
+ dependency-direction and field-ownership read your source with the TypeScript
71
+ scanner, so those two need the optional peer dependency "typescript". Install it
72
+ only if you run one of them; each refuses by name when it is absent.
73
+
74
+ Exit codes: ${EXIT_OK} the gate passed, ${EXIT_GATE_FAILED} the gate failed, ${EXIT_USAGE} a usage or configuration error.`;
75
+ const writeOut = (line) => {
76
+ process.stdout.write(`${line}\n`);
77
+ };
78
+ const writeDiagnostic = (line) => {
79
+ process.stderr.write(`${line}\n`);
80
+ };
81
+ /** A refusal the caller repairs by editing its configuration, so it takes 64. */
82
+ class ConfigurationError extends Error {
83
+ }
84
+ const usageError = (message) => ({
85
+ kind: 'usage-error',
86
+ message,
87
+ });
88
+ const isGate = (token) => GATE_NAMES.includes(token);
89
+ /** `--flag=value`, split on the first `=` so a value may contain one. */
90
+ function splitFlag(token) {
91
+ const equals = token.indexOf('=');
92
+ if (equals === -1)
93
+ return { flag: token, inline: null };
94
+ return { flag: token.slice(0, equals), inline: token.slice(equals + 1) };
95
+ }
96
+ function parseArguments(argv) {
97
+ const first = argv[0];
98
+ if (first === undefined) {
99
+ return usageError(`no gate given; expected one of ${GATE_NAMES.join(', ')}`);
100
+ }
101
+ if (first === '--help' || first === '-h' || first === 'help') {
102
+ return { kind: 'help' };
103
+ }
104
+ if (!isGate(first)) {
105
+ return usageError(`unknown gate "${first}"; expected one of ${GATE_NAMES.join(', ')}`);
106
+ }
107
+ let configPath = null;
108
+ const rest = argv.slice(1);
109
+ for (let index = 0; index < rest.length;) {
110
+ const token = rest[index];
111
+ if (token === '--help' || token === '-h')
112
+ return { kind: 'help' };
113
+ const { flag, inline } = splitFlag(token);
114
+ if (flag !== '--config') {
115
+ return usageError(`unknown flag "${token}" for ${first}`);
116
+ }
117
+ let value;
118
+ if (inline !== null) {
119
+ value = inline;
120
+ }
121
+ else {
122
+ const next = rest[index + 1];
123
+ if (next === undefined)
124
+ return usageError('--config requires a value');
125
+ // A flag-shaped token is the next flag, so the space form treats it as a
126
+ // missing value. A path beginning with "-" is what the equals form is for.
127
+ if (next.length > 1 && next.startsWith('-')) {
128
+ return usageError(`--config requires a value, but the next token is "${next}"; use --config=${next} for a path that begins with "-"`);
129
+ }
130
+ value = next;
131
+ }
132
+ if (value === '')
133
+ return usageError('--config was given an empty value');
134
+ if (configPath !== null && configPath !== value) {
135
+ return usageError(`--config given twice with different values, "${configPath}" and "${value}"`);
136
+ }
137
+ configPath = value;
138
+ index += inline === null ? 2 : 1;
139
+ }
140
+ return { kind: 'run', gate: first, configPath };
141
+ }
142
+ /**
143
+ * A lockfile the configuration named. A path that is not there is a
144
+ * configuration error and says which setting named it, because the repair is in
145
+ * the file rather than in the tree.
146
+ */
147
+ async function readLockfile(root, relative, configFile, gate) {
148
+ const path = resolve(root, relative);
149
+ try {
150
+ return JSON.parse(await readFile(path, 'utf8'));
151
+ }
152
+ catch (error) {
153
+ const code = error.code;
154
+ const detail = code === 'ENOENT'
155
+ ? 'does not exist'
156
+ : `could not be read: ${error instanceof Error ? error.message : String(error)}`;
157
+ throw new ConfigurationError(`${path} ${detail}; ${configFile}'s "${gate}" section names it under lockfiles`);
158
+ }
159
+ }
160
+ async function runLockfileAge(configFile, root, section) {
161
+ const now = new Date();
162
+ // The line `.github/actions/audit-lockfile-age/action.yml` greps for: a
163
+ // clock-parsing bug that made every entry look permanently old would
164
+ // otherwise be invisible.
165
+ writeOut(`Effective clock: ${now.toISOString()}`);
166
+ let passed = true;
167
+ for (const relative of section.lockfiles) {
168
+ const lockfile = await readLockfile(root, relative, configFile, 'lockfile-age');
169
+ const report = (await auditLockfileAge({
170
+ lockfile,
171
+ now,
172
+ windowDays: section.windowDays,
173
+ source: relative,
174
+ }));
175
+ if (report.youngEntries.length === 0 &&
176
+ report.unfetchableEntries.length === 0 &&
177
+ report.offRegistryEntries.length === 0) {
178
+ writeOut(`lockfile-age ${relative}: passed, ${report.entries.length} entrie(s), all published before ${report.cutoff.toISOString()}.`);
179
+ continue;
180
+ }
181
+ passed = false;
182
+ if (report.offRegistryEntries.length > 0) {
183
+ writeDiagnostic(`\nlockfile-age ${relative}: failed closed, ${report.offRegistryEntries.length} entrie(s) do not resolve to the npm registry:`);
184
+ for (const entry of report.offRegistryEntries) {
185
+ writeDiagnostic(` - ${entry.name}@${entry.version} resolved=${JSON.stringify(entry.resolved ?? null)} (${entry.path})`);
186
+ }
187
+ }
188
+ if (report.unfetchableEntries.length > 0) {
189
+ writeDiagnostic(`\nlockfile-age ${relative}: failed closed, could not fetch publish metadata for ${report.unfetchableEntries.length} entrie(s):`);
190
+ for (const entry of report.unfetchableEntries) {
191
+ writeDiagnostic(` - ${entry.name}@${entry.version} (${entry.path})`);
192
+ }
193
+ }
194
+ if (report.youngEntries.length > 0) {
195
+ writeDiagnostic(`\nlockfile-age ${relative}: ${report.youngEntries.length} entrie(s) published inside the ${section.windowDays}-day window (cutoff ${report.cutoff.toISOString()}):`);
196
+ for (const entry of report.youngEntries) {
197
+ writeDiagnostic(` - ${entry.name}@${entry.version} published ${entry.publishedAt} (${entry.path})`);
198
+ }
199
+ }
200
+ }
201
+ return passed;
202
+ }
203
+ /** A tolerance holds only while its marker does, so the file is read on every run. */
204
+ async function markerHolds(root, marker) {
205
+ if (marker === undefined)
206
+ return true;
207
+ try {
208
+ const text = await readFile(resolve(root, marker.file), 'utf8');
209
+ return text.includes(marker.contains);
210
+ }
211
+ catch {
212
+ return false;
213
+ }
214
+ }
215
+ async function runLicences(configFile, root, section) {
216
+ let passed = true;
217
+ for (const relative of section.lockfiles) {
218
+ const lockfile = await readLockfile(root, relative, configFile, 'licences');
219
+ const policy = section.policies?.[relative];
220
+ const allowlist = policy === undefined
221
+ ? section.allowlist
222
+ : [...section.allowlist, ...policy.also];
223
+ const label = policy === undefined ? 'the allowlist' : policy.label;
224
+ const applicable = [];
225
+ for (const tolerance of section.tolerances ?? []) {
226
+ if (!tolerance.lockfiles.includes(relative))
227
+ continue;
228
+ if (!(await markerHolds(root, tolerance.marker)))
229
+ continue;
230
+ applicable.push(tolerance);
231
+ }
232
+ const report = checkLicenses(lockfile, {
233
+ allowlist,
234
+ label,
235
+ tolerances: applicable,
236
+ source: relative,
237
+ });
238
+ if (report.violations.length === 0) {
239
+ writeOut(`licences ${relative}: passed against ${label}, ${report.entryCount} entrie(s), all allowlisted.`);
240
+ if (policy !== undefined)
241
+ writeOut(` ${label}: ${policy.reason}`);
242
+ if (report.tolerated.length > 0) {
243
+ writeOut(` tolerated: ${report.tolerated.join(', ')}`);
244
+ for (const reason of report.toleranceReasons) {
245
+ writeOut(` because: ${reason}`);
246
+ }
247
+ }
248
+ continue;
249
+ }
250
+ passed = false;
251
+ writeDiagnostic(`\nlicences ${relative}: ${report.violations.length} entrie(s) outside ${label}:`);
252
+ for (const violation of report.violations) {
253
+ writeDiagnostic(` - ${violation.name}@${violation.version}: license=${JSON.stringify(violation.license)}${violation.reason ? ` (${violation.reason})` : ''}`);
254
+ writeDiagnostic(` dependency path: ${violation.dependencyPath}`);
255
+ }
256
+ }
257
+ return passed;
258
+ }
259
+ /** The order every violation report prints in, so two runs read the same. */
260
+ const byFileThenLine = (violations) => [...violations].sort((a, b) => a.file === b.file ? a.line - b.line : a.file < b.file ? -1 : 1);
261
+ /**
262
+ * The direction gate writes to no stream and hands back what to print, so this
263
+ * is the whole mapping. `summary` is written on every outcome, including a clean
264
+ * one and a report-only one, which is what stops a report-only run being silent:
265
+ * a green run that printed nothing is the vacuous pass report-only mode exists
266
+ * to prevent.
267
+ *
268
+ * The violation lines go to stdout under report-only and to stderr otherwise.
269
+ * Report-only output is the thing the run was for, and a caller that redirects
270
+ * stderr away should still get it.
271
+ */
272
+ async function runDirection(configPath, root, section) {
273
+ const outcome = await runDependencyDirection({ section, root, configPath });
274
+ if (outcome.kind === 'refused') {
275
+ writeDiagnostic(`${BINARY}: ${outcome.message}`);
276
+ return EXIT_USAGE;
277
+ }
278
+ writeOut(outcome.summary);
279
+ const write = outcome.reportOnly ? writeOut : writeDiagnostic;
280
+ for (const line of outcome.lines)
281
+ write(line);
282
+ return outcome.failed ? EXIT_GATE_FAILED : EXIT_OK;
283
+ }
284
+ async function runBoundary(root, section) {
285
+ const report = await runPackageBoundary(root, section, 'package-boundary');
286
+ if (report.violations.length === 0) {
287
+ const where = report.counts
288
+ .map((count) => `${count.files} from ${count.path}`)
289
+ .join(', ');
290
+ writeOut(`package-boundary: ${report.scanned} entr(ies) scanned, 0 violations (${where})`);
291
+ return EXIT_OK;
292
+ }
293
+ writeDiagnostic(`\npackage-boundary: ${report.violations.length} violation(s) across ${report.scanned} scanned entr(ies):`);
294
+ for (const violation of byFileThenLine(report.violations)) {
295
+ writeDiagnostic(` ${violation.file}:${violation.line} [${violation.pattern}] ${violation.text}`);
296
+ writeDiagnostic(` ${violation.reason}`);
297
+ }
298
+ return EXIT_GATE_FAILED;
299
+ }
300
+ async function runOwnership(root, section) {
301
+ const report = await runFieldOwnership(root, section, 'field-ownership');
302
+ if (report.violations.length === 0) {
303
+ writeOut(`field-ownership: ${report.scanned} file(s) scanned, 0 violations`);
304
+ return EXIT_OK;
305
+ }
306
+ writeDiagnostic(`\nfield-ownership: ${report.violations.length} violation(s) across ${report.scanned} scanned file(s):`);
307
+ for (const violation of byFileThenLine(report.violations)) {
308
+ writeDiagnostic(` ${violation.file}:${violation.line} ${violation.subject}: ${violation.rule}`);
309
+ }
310
+ return EXIT_GATE_FAILED;
311
+ }
312
+ async function run(invocation) {
313
+ if (invocation.kind === 'help') {
314
+ writeOut(USAGE);
315
+ return EXIT_OK;
316
+ }
317
+ if (invocation.kind === 'usage-error') {
318
+ writeDiagnostic(`${BINARY}: usage: ${invocation.message}`);
319
+ writeDiagnostic(USAGE);
320
+ return EXIT_USAGE;
321
+ }
322
+ const options = { configPath: invocation.configPath ?? undefined };
323
+ const refused = (message) => {
324
+ writeDiagnostic(`${BINARY}: ${invocation.gate}: ${message}`);
325
+ return EXIT_USAGE;
326
+ };
327
+ switch (invocation.gate) {
328
+ case 'lockfile-age': {
329
+ const loaded = await loadLockfileAgeConfig(options);
330
+ if (loaded.kind === 'refused')
331
+ return refused(loaded.message);
332
+ const passed = await runLockfileAge(loaded.path, dirname(loaded.path), loaded.section);
333
+ return passed ? EXIT_OK : EXIT_GATE_FAILED;
334
+ }
335
+ case 'licences': {
336
+ const loaded = await loadLicencesConfig(options);
337
+ if (loaded.kind === 'refused')
338
+ return refused(loaded.message);
339
+ const passed = await runLicences(loaded.path, dirname(loaded.path), loaded.section);
340
+ return passed ? EXIT_OK : EXIT_GATE_FAILED;
341
+ }
342
+ case 'dependency-direction': {
343
+ const loaded = await loadDependencyDirectionConfig(options);
344
+ if (loaded.kind === 'refused')
345
+ return refused(loaded.message);
346
+ return runDirection(loaded.path, dirname(loaded.path), loaded.section);
347
+ }
348
+ case 'package-boundary': {
349
+ const loaded = await loadPackageBoundaryConfig(options);
350
+ if (loaded.kind === 'refused')
351
+ return refused(loaded.message);
352
+ return runBoundary(dirname(loaded.path), loaded.section);
353
+ }
354
+ case 'field-ownership': {
355
+ const loaded = await loadFieldOwnershipConfig(options);
356
+ if (loaded.kind === 'refused')
357
+ return refused(loaded.message);
358
+ return runOwnership(dirname(loaded.path), loaded.section);
359
+ }
360
+ }
361
+ // Exhaustive over `GateName`: a gate added to `GATE_NAMES` with no arm above
362
+ // is a type error here rather than a binary that names it in its usage text
363
+ // and does nothing when invoked.
364
+ const unhandled = invocation.gate;
365
+ throw new Error(`no dispatch arm for the gate "${String(unhandled)}"`);
366
+ }
367
+ /**
368
+ * The refusals a gate raises as a coded error rather than as a return value,
369
+ * and the exit each takes.
370
+ *
371
+ * A lockfile or a path the configuration named and the tree does not have is a
372
+ * configuration error, so it takes the usage code: the repair is in the file.
373
+ * So is an absent optional peer dependency. A tree the scan could not read to
374
+ * the end takes the gate's own failure code instead, because that gate ran and
375
+ * refused rather than being misinvoked.
376
+ *
377
+ * Sharing one code across the two would let "scanned nothing" and "found
378
+ * nothing" answer a caller the same way, which is the pass these refusals exist
379
+ * to stop.
380
+ */
381
+ const CODED_EXITS = new Map([
382
+ [LOCKFILE_SHAPE_ERROR, EXIT_USAGE],
383
+ [SCAN_PATH_ERROR, EXIT_USAGE],
384
+ [TYPESCRIPT_UNAVAILABLE, EXIT_USAGE],
385
+ [SCAN_UNREADABLE, EXIT_GATE_FAILED],
386
+ ]);
387
+ async function main(argv) {
388
+ try {
389
+ process.exitCode = await run(parseArguments(argv));
390
+ }
391
+ catch (error) {
392
+ if (error instanceof ConfigurationError) {
393
+ writeDiagnostic(`${BINARY}: ${error.message}`);
394
+ process.exitCode = EXIT_USAGE;
395
+ return;
396
+ }
397
+ const code = error !== null && typeof error === 'object'
398
+ ? error.code
399
+ : undefined;
400
+ const mapped = typeof code === 'string' ? CODED_EXITS.get(code) : undefined;
401
+ if (mapped !== undefined) {
402
+ writeDiagnostic(`${BINARY}: ${error.message}`);
403
+ process.exitCode = mapped;
404
+ return;
405
+ }
406
+ writeDiagnostic(error instanceof Error ? (error.stack ?? error.message) : String(error));
407
+ process.exitCode = EXIT_GATE_FAILED;
408
+ }
409
+ }
410
+ await main(process.argv.slice(2));