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,392 @@
1
+ #!/usr/bin/env node
2
+ // Audits every registry entry in package-lock.json for supply-chain freshness.
3
+ //
4
+ // .npmrc's `min-release-age=7` only filters *resolution*: a young package already sitting in a
5
+ // committed lockfile installs cleanly under `npm ci` regardless of that setting (a verified fail-open
6
+ // in this repo's history, see ARCHITECTURE-SPINE.md#Stack). This script re-checks every locked
7
+ // version's real registry publish timestamp and fails closed: on a young entry, on metadata that
8
+ // could not be fetched after retries, or on an entry whose `resolved` is not the registry tarball for
9
+ // that entry's own name and version (a relabelled lockfile entry pointing `resolved` at a mirror, or
10
+ // at another package on the registry itself, would otherwise sail through).
11
+ //
12
+ // These flags are the pre-install path: `.github/actions/audit-lockfile-age`
13
+ // runs this before `npm ci`, so nothing here may import from `node_modules`. A
14
+ // consumer runs the same audit through `eval-quality-gates lockfile-age`, which
15
+ // reads its lockfiles, its window and its exclusions out of a configuration
16
+ // file.
17
+ //
18
+ // Usage:
19
+ // node scripts/audit-lockfile-age.mjs [--lockfile <path>] [--window-days <n>] [--now <RFC3339>]
20
+ //
21
+ // --now lets a canary pin the clock to a fixed offset from a fixture entry's real publish date, so the
22
+ // fixture never rots as real time passes. Ordinary runs omit it and audit against the real wall clock.
23
+ import { readFile } from 'node:fs/promises';
24
+ import { pathToFileURL } from 'node:url';
25
+ // The window this script's own flags default to. `gate-config.ts` declares the
26
+ // same default for the configured path as `LOCKFILE_WINDOW_DAYS_DEFAULT`, and
27
+ // `tests/architecture/published-gates.test.ts` holds the two equal. The number
28
+ // is declared twice because this file runs before `npm ci` in CI and so may
29
+ // import nothing from `node_modules`, where the schema that carries the other
30
+ // one lives.
31
+ export const WINDOW_DAYS_DEFAULT = 7;
32
+ const CONCURRENCY = 8;
33
+ const MAX_RETRIES = 3;
34
+ const RETRY_BASE_MS = 300;
35
+ const FETCH_TIMEOUT_MS = 15_000;
36
+ const REGISTRY_PREFIX = 'https://registry.npmjs.org/';
37
+ /**
38
+ * `error.code` on the refusal a caller repairs by pointing the gate at a real
39
+ * lockfile. `check-licenses.mjs` exports the same string and
40
+ * `tests/architecture/published-gates.test.ts` holds the two equal, for the
41
+ * reason `WINDOW_DAYS_DEFAULT` is declared twice.
42
+ */
43
+ export const LOCKFILE_SHAPE_ERROR = 'EVAL_QUALITY_LOCKFILE_SHAPE';
44
+ function refuseLockfileShape(message) {
45
+ const error = new Error(message);
46
+ error.code = LOCKFILE_SHAPE_ERROR;
47
+ return error;
48
+ }
49
+ // The one URL the public registry serves `name@version` from. A scoped name's
50
+ // tarball basename is the segment after the slash, so `@scope/pkg` at 1.0.0 is
51
+ // `https://registry.npmjs.org/@scope/pkg/-/pkg-1.0.0.tgz`.
52
+ function registryTarballUrl(name, version) {
53
+ const basename = name.startsWith('@')
54
+ ? name.slice(name.indexOf('/') + 1)
55
+ : name;
56
+ return `${REGISTRY_PREFIX}${name}/-/${basename}-${version}.tgz`;
57
+ }
58
+ function parseArgs(argv) {
59
+ const args = {
60
+ lockfile: 'package-lock.json',
61
+ windowDays: WINDOW_DAYS_DEFAULT,
62
+ now: null,
63
+ cache: null,
64
+ };
65
+ for (let i = 0; i < argv.length; i++) {
66
+ const arg = argv[i];
67
+ if (arg === '--lockfile')
68
+ args.lockfile = argv[++i];
69
+ else if (arg === '--window-days')
70
+ args.windowDays = Number(argv[++i]);
71
+ else if (arg === '--now')
72
+ args.now = argv[++i];
73
+ else if (arg === '--cache')
74
+ args.cache = argv[++i];
75
+ else
76
+ throw new Error(`Unknown argument: ${arg}`);
77
+ }
78
+ return args;
79
+ }
80
+ // "node_modules/foo" -> "foo"; "node_modules/@scope/foo" -> "@scope/foo";
81
+ // "node_modules/a/node_modules/@scope/b" -> "@scope/b" (nested/duplicate installs).
82
+ function packageNameFromPath(pkgPath) {
83
+ const segments = pkgPath.split('node_modules/');
84
+ return segments[segments.length - 1].replace(/\/$/, '');
85
+ }
86
+ function registryUrlForName(name) {
87
+ const encoded = name.startsWith('@')
88
+ ? `${name.split('/')[0]}/${encodeURIComponent(name.split('/')[1])}`
89
+ : encodeURIComponent(name);
90
+ return `${REGISTRY_PREFIX}${encoded}`;
91
+ }
92
+ // A non-retryable 4xx (other than 429) means the request itself is wrong - e.g. a 404 for a name that
93
+ // does not exist on the registry - and retrying just wastes the retry budget for no benefit. 5xx, 429,
94
+ // and network/timeout errors are transient and worth retrying with backoff.
95
+ class NonRetryableFetchError extends Error {
96
+ }
97
+ async function fetchWithRetry(url, attempts = MAX_RETRIES) {
98
+ let lastError;
99
+ for (let attempt = 1; attempt <= attempts; attempt++) {
100
+ try {
101
+ const res = await fetch(url, {
102
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
103
+ });
104
+ if (res.ok)
105
+ return await res.json();
106
+ if (res.status !== 429 && res.status < 500) {
107
+ throw new NonRetryableFetchError(`HTTP ${res.status}`);
108
+ }
109
+ throw new Error(`HTTP ${res.status}`);
110
+ }
111
+ catch (err) {
112
+ lastError = err;
113
+ if (err instanceof NonRetryableFetchError)
114
+ break;
115
+ if (attempt < attempts) {
116
+ await new Promise((resolve) => setTimeout(resolve, RETRY_BASE_MS * attempt));
117
+ }
118
+ }
119
+ }
120
+ throw lastError;
121
+ }
122
+ // One registry request per unique package NAME (not per lockfile entry): the response carries a
123
+ // `time` map covering every published version, so every locked version of that package is checked
124
+ // from a single fetch. Exported so the cache generator reads the registry the same way the gate
125
+ // does, rather than carrying a second copy of the retry and URL rules.
126
+ export async function fetchTimeMap(name) {
127
+ const meta = await fetchWithRetry(registryUrlForName(name));
128
+ return meta.time ?? {};
129
+ }
130
+ async function mapWithConcurrency(items, limit, fn) {
131
+ const results = new Array(items.length);
132
+ let cursor = 0;
133
+ async function worker() {
134
+ while (cursor < items.length) {
135
+ const current = cursor++;
136
+ results[current] = await fn(items[current], current);
137
+ }
138
+ }
139
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
140
+ return results;
141
+ }
142
+ function collectLockedEntries(lockfile, source) {
143
+ // Every entry is read from `packages`, which npm writes from lockfileVersion 2 onward. Defaulting
144
+ // it to {} turned an npm 6 lockfile, or a path naming something that is not a lockfile at all, into
145
+ // a run that reported success over zero entries: a gate that scanned nothing, in the words of a
146
+ // gate that passed.
147
+ const packages = lockfile?.packages;
148
+ if (packages === null ||
149
+ typeof packages !== 'object' ||
150
+ Array.isArray(packages)) {
151
+ throw refuseLockfileShape(`audit-lockfile-age: ${source} carries no "packages" object (lockfileVersion ${JSON.stringify(lockfile?.lockfileVersion ?? null)}); every entry is read from there, so this run would have passed over nothing. npm writes "packages" from lockfileVersion 2 onward.`);
152
+ }
153
+ return Object.entries(packages)
154
+ .filter(([pkgPath, meta]) => pkgPath !== '' && meta.version && !meta.link)
155
+ .map(([pkgPath, meta]) => ({
156
+ path: pkgPath,
157
+ // meta.name (present on aliased entries, e.g. `"node_modules/foo": {"name": "bar", ...}`
158
+ // for an `npm:bar@x` alias) is the package actually installed. Falling back to the
159
+ // path-derived name would audit "foo" - a name that was never fetched - against "bar"'s
160
+ // lockfile version, checking the wrong package entirely.
161
+ name: meta.name ?? packageNameFromPath(pkgPath),
162
+ version: meta.version,
163
+ resolved: meta.resolved,
164
+ }));
165
+ }
166
+ /**
167
+ * `readTimeMap` is the one effect this function performs, named so a caller can
168
+ * supply the registry's answers itself. It defaults to the real fetch, so a
169
+ * caller that wants the registry gets it by saying nothing, and a case that
170
+ * wants a fixed answer runs offline.
171
+ *
172
+ * `cache` is a map from "name@version" to a publication timestamp, and it is
173
+ * what keeps a gate that runs on every build off the network. Both of this
174
+ * audit's inputs make it sound with no staleness bound: a package's publication
175
+ * time is fixed the moment it is published, so a reading taken once is correct
176
+ * forever, and the predicate is monotone in time, so an entry that passes today
177
+ * passes every day after. The entries needing a live fetch are the ones the
178
+ * cache does not carry, which are exactly the dependencies a change added, and
179
+ * fail-closed holds unchanged for them.
180
+ *
181
+ * `exclude` is the package names exempt from the window and from the fetch,
182
+ * the counterpart of `.npmrc`'s `min-release-age-exclude`. Every entry under
183
+ * one of those names comes back in `excludedEntries` and still counts among
184
+ * `entries`; one that also fails the resolved-URL check comes back in both
185
+ * lists, because the exclusion never reached that check.
186
+ *
187
+ * `source` is the path this lockfile was read from, named in the refusal a
188
+ * document without a `packages` object earns.
189
+ */
190
+ export async function auditLockfileAge({ lockfile, now, windowDays,
191
+ // The cast is for the TypeScript caller: a bare `[]` default reads as never[].
192
+ exclude = /** @type {readonly string[]} */ ([]), source = 'the lockfile', readTimeMap = fetchTimeMap, cache = {}, }) {
193
+ if (!Number.isFinite(windowDays) || windowDays < 1) {
194
+ throw new Error(`windowDays must be at least 1, got: ${windowDays}; a window of zero admits a package published this instant and still reports that every entry was published before the cutoff`);
195
+ }
196
+ // `new Set('left-pad')` is a set of eight characters that excludes nothing.
197
+ if (!Array.isArray(exclude) || exclude.some((n) => typeof n !== 'string')) {
198
+ throw new Error(`exclude must be an array of package names, got: ${JSON.stringify(exclude)}`);
199
+ }
200
+ const cutoff = new Date(now.getTime() - windowDays * 24 * 60 * 60 * 1000);
201
+ const entries = collectLockedEntries(lockfile, source);
202
+ // An entry whose `resolved` is not the registry tarball for its own name and version did not come
203
+ // from where an age check against npmjs.org assumes. A lockfile edit can point `resolved` at a
204
+ // mirror, or at another package on the registry itself, while the name and version this gate reads
205
+ // still name the harmless one; `npm ci` fetches `resolved` and checks `integrity` against whatever
206
+ // comes back, so the substituted tarball installs. Pinning the host alone left that second shape
207
+ // open. Fail closed on the mismatch: the age of a package the install never fetches establishes nothing.
208
+ const offRegistryEntries = [];
209
+ const registryEntries = [];
210
+ for (const entry of entries) {
211
+ if (entry.resolved === registryTarballUrl(entry.name, entry.version)) {
212
+ registryEntries.push(entry);
213
+ }
214
+ else {
215
+ offRegistryEntries.push(entry);
216
+ }
217
+ }
218
+ const cachedAt = (entry) => cache[`${entry.name}@${entry.version}`];
219
+ // An exclusion exempts a name from the window and from the fetch, and from
220
+ // nothing else. `min-release-age-exclude` says a package's young releases are
221
+ // accepted and says nothing about which tarball the install fetches, so an
222
+ // excluded entry is still held by the resolved-URL check above: a substituted
223
+ // `resolved` under an excluded name is the defect that check exists for. The
224
+ // exclusion is read over every entry, so a run prints it beside that failure.
225
+ const excludedNames = new Set(exclude);
226
+ const excludedEntries = entries.filter((entry) => excludedNames.has(entry.name));
227
+ const auditedEntries = registryEntries.filter((entry) => !excludedNames.has(entry.name));
228
+ // One request per unique package name, and only for a name carrying at least
229
+ // one version the cache does not answer. A name whose every locked version is
230
+ // cached, or excluded, is never asked for.
231
+ const uniqueNames = [
232
+ ...new Set(auditedEntries
233
+ .filter((entry) => cachedAt(entry) === undefined)
234
+ .map((entry) => entry.name)),
235
+ ];
236
+ const timeMaps = new Map();
237
+ const fetchFailures = new Set();
238
+ await mapWithConcurrency(uniqueNames, CONCURRENCY, async (name) => {
239
+ try {
240
+ timeMaps.set(name, await readTimeMap(name));
241
+ }
242
+ catch {
243
+ fetchFailures.add(name);
244
+ }
245
+ });
246
+ const youngEntries = [];
247
+ const unfetchableEntries = [];
248
+ for (const entry of auditedEntries) {
249
+ const fromCache = cachedAt(entry);
250
+ if (fromCache === undefined && fetchFailures.has(entry.name)) {
251
+ unfetchableEntries.push(entry);
252
+ continue;
253
+ }
254
+ const publishedAt = fromCache ?? timeMaps.get(entry.name)?.[entry.version];
255
+ if (!publishedAt) {
256
+ unfetchableEntries.push(entry);
257
+ continue;
258
+ }
259
+ const publishedDate = new Date(publishedAt);
260
+ if (Number.isNaN(publishedDate.getTime())) {
261
+ // An unparseable timestamp must not silently compare as "not young" (Date comparisons
262
+ // against an Invalid Date are always false) - that would fetch metadata, find it useless,
263
+ // and pass anyway. Treat it the same as metadata that could not be fetched at all.
264
+ unfetchableEntries.push(entry);
265
+ continue;
266
+ }
267
+ if (publishedDate > cutoff) {
268
+ youngEntries.push({ ...entry, publishedAt });
269
+ }
270
+ }
271
+ return {
272
+ cutoff,
273
+ entries,
274
+ youngEntries,
275
+ unfetchableEntries,
276
+ offRegistryEntries,
277
+ excludedEntries,
278
+ };
279
+ }
280
+ /**
281
+ * The cache document, checked before a single timestamp is trusted. A malformed
282
+ * one is a refusal: a cache whose values are not timestamps would answer every
283
+ * lookup with something the audit reads as unparseable, and every entry would
284
+ * land in `unfetchableEntries` with no explanation of why.
285
+ */
286
+ // `new Date(value)` alone accepts strings no publication record is ever written
287
+ // in, "12" parses as the year 2001, so the shape is checked first: an RFC3339
288
+ // date-time, which is what the npm registry's own `time` map writes and what
289
+ // `fetchTimeMap` reads back into the cache.
290
+ const RFC3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
291
+ export function readPublishCache(document, source) {
292
+ if (document === null ||
293
+ typeof document !== 'object' ||
294
+ Array.isArray(document)) {
295
+ throw refuseLockfileShape(`${source} is not a JSON object; a publication cache maps "name@version" to a timestamp`);
296
+ }
297
+ for (const [key, value] of Object.entries(document)) {
298
+ if (typeof value !== 'string' ||
299
+ !RFC3339.test(value) ||
300
+ Number.isNaN(new Date(value).getTime())) {
301
+ throw refuseLockfileShape(`${source} holds ${JSON.stringify(value)} for "${key}", which is not an RFC3339 timestamp`);
302
+ }
303
+ if (!key.includes('@', 1)) {
304
+ throw refuseLockfileShape(`${source} holds the key "${key}", which is not a "name@version"`);
305
+ }
306
+ }
307
+ return document;
308
+ }
309
+ /** The cache file, with the two ways reading it fails named rather than thrown raw. */
310
+ async function readCacheFile(path) {
311
+ let text;
312
+ try {
313
+ text = await readFile(path, 'utf8');
314
+ }
315
+ catch (error) {
316
+ throw refuseLockfileShape(`${path} could not be read: ${error.message}; --cache names it`);
317
+ }
318
+ let document;
319
+ try {
320
+ document = JSON.parse(text);
321
+ }
322
+ catch (error) {
323
+ throw refuseLockfileShape(`${path} is not valid JSON: ${error.message}`);
324
+ }
325
+ return readPublishCache(document, path);
326
+ }
327
+ async function main() {
328
+ const args = parseArgs(process.argv.slice(2));
329
+ const now = args.now ? new Date(args.now) : new Date();
330
+ if (Number.isNaN(now.getTime())) {
331
+ throw new Error(`--now is not a valid RFC3339 timestamp: ${args.now}`);
332
+ }
333
+ console.log(`Effective clock: ${now.toISOString()}`);
334
+ const lockfile = JSON.parse(await readFile(args.lockfile, 'utf8'));
335
+ // A cache the caller named and the tree does not have is a refusal rather
336
+ // than a silent full-fetch run: a mistyped path would read as a cache that
337
+ // happens to answer nothing, which is the shape a gate must never pass over.
338
+ const cache = args.cache === null ? {} : await readCacheFile(args.cache);
339
+ const { cutoff, entries, youngEntries, unfetchableEntries, offRegistryEntries, } = await auditLockfileAge({
340
+ lockfile,
341
+ now,
342
+ windowDays: args.windowDays,
343
+ source: args.lockfile,
344
+ cache,
345
+ });
346
+ if (youngEntries.length === 0 &&
347
+ unfetchableEntries.length === 0 &&
348
+ offRegistryEntries.length === 0) {
349
+ console.log(`Lockfile age audit passed: ${entries.length} entries, all published before ${cutoff.toISOString()}.`);
350
+ return;
351
+ }
352
+ if (offRegistryEntries.length > 0) {
353
+ console.error(`\nFailed closed: ${offRegistryEntries.length} entrie(s) do not resolve to their own registry tarball:`);
354
+ for (const entry of offRegistryEntries) {
355
+ console.error(` - ${entry.name}@${entry.version} resolved=${JSON.stringify(entry.resolved ?? null)}, expected ${registryTarballUrl(entry.name, entry.version)} (${entry.path})`);
356
+ }
357
+ }
358
+ if (unfetchableEntries.length > 0) {
359
+ console.error(`\nFailed closed: could not fetch publish metadata for ${unfetchableEntries.length} entrie(s):`);
360
+ for (const entry of unfetchableEntries) {
361
+ console.error(` - ${entry.name}@${entry.version} (${entry.path})`);
362
+ }
363
+ }
364
+ if (youngEntries.length > 0) {
365
+ console.error(`\nAge violation: ${youngEntries.length} entrie(s) published inside the ${args.windowDays}-day window (cutoff ${cutoff.toISOString()}):`);
366
+ for (const entry of youngEntries) {
367
+ console.error(` - ${entry.name}@${entry.version} published ${entry.publishedAt} (${entry.path})`);
368
+ }
369
+ }
370
+ process.exitCode = 1;
371
+ }
372
+ // pathToFileURL percent-encodes the same way import.meta.url does (spaces, non-ASCII, etc.); a raw
373
+ // `file://${process.argv[1]}` template comparison silently mismatches on such paths, so main() never
374
+ // runs and the script exits 0 with no output: a gate switched off while looking idle.
375
+ //
376
+ // process.argv[1] is undefined where there is no script path at all, which is every `node -e`, every
377
+ // `--input-type=module` evaluation, and the REPL. pathToFileURL throws ERR_INVALID_ARG_TYPE on
378
+ // undefined, so without this guard the module cannot be imported from any of them, and it ships in
379
+ // `dist/gates/` where a consumer does exactly that.
380
+ const entryPoint = process.argv[1];
381
+ if (entryPoint !== undefined &&
382
+ import.meta.url === pathToFileURL(entryPoint).href) {
383
+ main().catch((err) => {
384
+ // A refusal the caller repairs by pointing the gate at a real lockfile says so in one line; a
385
+ // stack trace for it is noise.
386
+ const detail = err.code === LOCKFILE_SHAPE_ERROR
387
+ ? err.message
388
+ : (err.stack ?? String(err));
389
+ console.error(detail);
390
+ process.exitCode = 1;
391
+ });
392
+ }