eval-quality 3.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.
- 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 +295 -0
- package/dist/gates/check-dependency-direction.js +303 -0
- package/dist/gates/check-licenses.mjs +305 -0
- package/dist/gates/dependency-direction.js +555 -0
- package/dist/gates/discover-source-files.js +44 -0
- package/dist/gates/gate-config.js +251 -0
- package/dist/gates/gates-cli.js +410 -0
- package/dist/gates/lineage-ownership.js +364 -0
- package/dist/gates/package-boundary.js +388 -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 +20 -8
|
@@ -0,0 +1,295 @@
|
|
|
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 and its window out of a configuration file.
|
|
16
|
+
//
|
|
17
|
+
// Usage:
|
|
18
|
+
// node scripts/audit-lockfile-age.mjs [--lockfile <path>] [--window-days <n>] [--now <RFC3339>]
|
|
19
|
+
//
|
|
20
|
+
// --now lets a canary pin the clock to a fixed offset from a fixture entry's real publish date, so the
|
|
21
|
+
// fixture never rots as real time passes. Ordinary runs omit it and audit against the real wall clock.
|
|
22
|
+
import { readFile } from 'node:fs/promises';
|
|
23
|
+
import { pathToFileURL } from 'node:url';
|
|
24
|
+
// The window this script's own flags default to. `gate-config.ts` declares the
|
|
25
|
+
// same default for the configured path as `LOCKFILE_WINDOW_DAYS_DEFAULT`, and
|
|
26
|
+
// `tests/architecture/published-gates.test.ts` holds the two equal. The number
|
|
27
|
+
// is declared twice because this file runs before `npm ci` in CI and so may
|
|
28
|
+
// import nothing from `node_modules`, where the schema that carries the other
|
|
29
|
+
// one lives.
|
|
30
|
+
export const WINDOW_DAYS_DEFAULT = 7;
|
|
31
|
+
const CONCURRENCY = 8;
|
|
32
|
+
const MAX_RETRIES = 3;
|
|
33
|
+
const RETRY_BASE_MS = 300;
|
|
34
|
+
const FETCH_TIMEOUT_MS = 15_000;
|
|
35
|
+
const REGISTRY_PREFIX = 'https://registry.npmjs.org/';
|
|
36
|
+
/**
|
|
37
|
+
* `error.code` on the refusal a caller repairs by pointing the gate at a real
|
|
38
|
+
* lockfile. `check-licenses.mjs` exports the same string and
|
|
39
|
+
* `tests/architecture/published-gates.test.ts` holds the two equal, for the
|
|
40
|
+
* reason `WINDOW_DAYS_DEFAULT` is declared twice.
|
|
41
|
+
*/
|
|
42
|
+
export const LOCKFILE_SHAPE_ERROR = 'EVAL_QUALITY_LOCKFILE_SHAPE';
|
|
43
|
+
function refuseLockfileShape(message) {
|
|
44
|
+
const error = new Error(message);
|
|
45
|
+
error.code = LOCKFILE_SHAPE_ERROR;
|
|
46
|
+
return error;
|
|
47
|
+
}
|
|
48
|
+
// The one URL the public registry serves `name@version` from. A scoped name's
|
|
49
|
+
// tarball basename is the segment after the slash, so `@scope/pkg` at 1.0.0 is
|
|
50
|
+
// `https://registry.npmjs.org/@scope/pkg/-/pkg-1.0.0.tgz`.
|
|
51
|
+
function registryTarballUrl(name, version) {
|
|
52
|
+
const basename = name.startsWith('@')
|
|
53
|
+
? name.slice(name.indexOf('/') + 1)
|
|
54
|
+
: name;
|
|
55
|
+
return `${REGISTRY_PREFIX}${name}/-/${basename}-${version}.tgz`;
|
|
56
|
+
}
|
|
57
|
+
function parseArgs(argv) {
|
|
58
|
+
const args = {
|
|
59
|
+
lockfile: 'package-lock.json',
|
|
60
|
+
windowDays: WINDOW_DAYS_DEFAULT,
|
|
61
|
+
now: null,
|
|
62
|
+
};
|
|
63
|
+
for (let i = 0; i < argv.length; i++) {
|
|
64
|
+
const arg = argv[i];
|
|
65
|
+
if (arg === '--lockfile')
|
|
66
|
+
args.lockfile = argv[++i];
|
|
67
|
+
else if (arg === '--window-days')
|
|
68
|
+
args.windowDays = Number(argv[++i]);
|
|
69
|
+
else if (arg === '--now')
|
|
70
|
+
args.now = argv[++i];
|
|
71
|
+
else
|
|
72
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
73
|
+
}
|
|
74
|
+
return args;
|
|
75
|
+
}
|
|
76
|
+
// "node_modules/foo" -> "foo"; "node_modules/@scope/foo" -> "@scope/foo";
|
|
77
|
+
// "node_modules/a/node_modules/@scope/b" -> "@scope/b" (nested/duplicate installs).
|
|
78
|
+
function packageNameFromPath(pkgPath) {
|
|
79
|
+
const segments = pkgPath.split('node_modules/');
|
|
80
|
+
return segments[segments.length - 1].replace(/\/$/, '');
|
|
81
|
+
}
|
|
82
|
+
function registryUrlForName(name) {
|
|
83
|
+
const encoded = name.startsWith('@')
|
|
84
|
+
? `${name.split('/')[0]}/${encodeURIComponent(name.split('/')[1])}`
|
|
85
|
+
: encodeURIComponent(name);
|
|
86
|
+
return `${REGISTRY_PREFIX}${encoded}`;
|
|
87
|
+
}
|
|
88
|
+
// A non-retryable 4xx (other than 429) means the request itself is wrong - e.g. a 404 for a name that
|
|
89
|
+
// does not exist on the registry - and retrying just wastes the retry budget for no benefit. 5xx, 429,
|
|
90
|
+
// and network/timeout errors are transient and worth retrying with backoff.
|
|
91
|
+
class NonRetryableFetchError extends Error {
|
|
92
|
+
}
|
|
93
|
+
async function fetchWithRetry(url, attempts = MAX_RETRIES) {
|
|
94
|
+
let lastError;
|
|
95
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
96
|
+
try {
|
|
97
|
+
const res = await fetch(url, {
|
|
98
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
99
|
+
});
|
|
100
|
+
if (res.ok)
|
|
101
|
+
return await res.json();
|
|
102
|
+
if (res.status !== 429 && res.status < 500) {
|
|
103
|
+
throw new NonRetryableFetchError(`HTTP ${res.status}`);
|
|
104
|
+
}
|
|
105
|
+
throw new Error(`HTTP ${res.status}`);
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
lastError = err;
|
|
109
|
+
if (err instanceof NonRetryableFetchError)
|
|
110
|
+
break;
|
|
111
|
+
if (attempt < attempts) {
|
|
112
|
+
await new Promise((resolve) => setTimeout(resolve, RETRY_BASE_MS * attempt));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
throw lastError;
|
|
117
|
+
}
|
|
118
|
+
// One registry request per unique package NAME (not per lockfile entry): the response carries a
|
|
119
|
+
// `time` map covering every published version, so every locked version of that package is checked
|
|
120
|
+
// from a single fetch.
|
|
121
|
+
async function fetchTimeMap(name) {
|
|
122
|
+
const meta = await fetchWithRetry(registryUrlForName(name));
|
|
123
|
+
return meta.time ?? {};
|
|
124
|
+
}
|
|
125
|
+
async function mapWithConcurrency(items, limit, fn) {
|
|
126
|
+
const results = new Array(items.length);
|
|
127
|
+
let cursor = 0;
|
|
128
|
+
async function worker() {
|
|
129
|
+
while (cursor < items.length) {
|
|
130
|
+
const current = cursor++;
|
|
131
|
+
results[current] = await fn(items[current], current);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
135
|
+
return results;
|
|
136
|
+
}
|
|
137
|
+
function collectLockedEntries(lockfile, source) {
|
|
138
|
+
// Every entry is read from `packages`, which npm writes from lockfileVersion 2 onward. Defaulting
|
|
139
|
+
// it to {} turned an npm 6 lockfile, or a path naming something that is not a lockfile at all, into
|
|
140
|
+
// a run that reported success over zero entries: a gate that scanned nothing, in the words of a
|
|
141
|
+
// gate that passed.
|
|
142
|
+
const packages = lockfile?.packages;
|
|
143
|
+
if (packages === null ||
|
|
144
|
+
typeof packages !== 'object' ||
|
|
145
|
+
Array.isArray(packages)) {
|
|
146
|
+
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.`);
|
|
147
|
+
}
|
|
148
|
+
return Object.entries(packages)
|
|
149
|
+
.filter(([pkgPath, meta]) => pkgPath !== '' && meta.version && !meta.link)
|
|
150
|
+
.map(([pkgPath, meta]) => ({
|
|
151
|
+
path: pkgPath,
|
|
152
|
+
// meta.name (present on aliased entries, e.g. `"node_modules/foo": {"name": "bar", ...}`
|
|
153
|
+
// for an `npm:bar@x` alias) is the package actually installed. Falling back to the
|
|
154
|
+
// path-derived name would audit "foo" - a name that was never fetched - against "bar"'s
|
|
155
|
+
// lockfile version, checking the wrong package entirely.
|
|
156
|
+
name: meta.name ?? packageNameFromPath(pkgPath),
|
|
157
|
+
version: meta.version,
|
|
158
|
+
resolved: meta.resolved,
|
|
159
|
+
}));
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* `readTimeMap` is the one effect this function performs, named so a caller can
|
|
163
|
+
* supply the registry's answers itself. It defaults to the real fetch, so a
|
|
164
|
+
* caller that wants the registry gets it by saying nothing, and a case that
|
|
165
|
+
* wants a fixed answer runs offline.
|
|
166
|
+
*
|
|
167
|
+
* `source` is the path this lockfile was read from, named in the refusal a
|
|
168
|
+
* document without a `packages` object earns.
|
|
169
|
+
*/
|
|
170
|
+
export async function auditLockfileAge({ lockfile, now, windowDays, source = 'the lockfile', readTimeMap = fetchTimeMap, }) {
|
|
171
|
+
if (!Number.isFinite(windowDays) || windowDays < 1) {
|
|
172
|
+
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`);
|
|
173
|
+
}
|
|
174
|
+
const cutoff = new Date(now.getTime() - windowDays * 24 * 60 * 60 * 1000);
|
|
175
|
+
const entries = collectLockedEntries(lockfile, source);
|
|
176
|
+
// An entry whose `resolved` is not the registry tarball for its own name and version did not come
|
|
177
|
+
// from where an age check against npmjs.org assumes. A lockfile edit can point `resolved` at a
|
|
178
|
+
// mirror, or at another package on the registry itself, while the name and version this gate reads
|
|
179
|
+
// still name the harmless one; `npm ci` fetches `resolved` and checks `integrity` against whatever
|
|
180
|
+
// comes back, so the substituted tarball installs. Pinning the host alone left that second shape
|
|
181
|
+
// open. Fail closed on the mismatch: the age of a package the install never fetches establishes nothing.
|
|
182
|
+
const offRegistryEntries = [];
|
|
183
|
+
const registryEntries = [];
|
|
184
|
+
for (const entry of entries) {
|
|
185
|
+
if (entry.resolved === registryTarballUrl(entry.name, entry.version)) {
|
|
186
|
+
registryEntries.push(entry);
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
offRegistryEntries.push(entry);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const uniqueNames = [...new Set(registryEntries.map((e) => e.name))];
|
|
193
|
+
const timeMaps = new Map();
|
|
194
|
+
const fetchFailures = new Set();
|
|
195
|
+
await mapWithConcurrency(uniqueNames, CONCURRENCY, async (name) => {
|
|
196
|
+
try {
|
|
197
|
+
timeMaps.set(name, await readTimeMap(name));
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
fetchFailures.add(name);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
const youngEntries = [];
|
|
204
|
+
const unfetchableEntries = [];
|
|
205
|
+
for (const entry of registryEntries) {
|
|
206
|
+
if (fetchFailures.has(entry.name)) {
|
|
207
|
+
unfetchableEntries.push(entry);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
const publishedAt = timeMaps.get(entry.name)?.[entry.version];
|
|
211
|
+
if (!publishedAt) {
|
|
212
|
+
unfetchableEntries.push(entry);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
const publishedDate = new Date(publishedAt);
|
|
216
|
+
if (Number.isNaN(publishedDate.getTime())) {
|
|
217
|
+
// An unparseable timestamp must not silently compare as "not young" (Date comparisons
|
|
218
|
+
// against an Invalid Date are always false) - that would fetch metadata, find it useless,
|
|
219
|
+
// and pass anyway. Treat it the same as metadata that could not be fetched at all.
|
|
220
|
+
unfetchableEntries.push(entry);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (publishedDate > cutoff) {
|
|
224
|
+
youngEntries.push({ ...entry, publishedAt });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
cutoff,
|
|
229
|
+
entries,
|
|
230
|
+
youngEntries,
|
|
231
|
+
unfetchableEntries,
|
|
232
|
+
offRegistryEntries,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
async function main() {
|
|
236
|
+
const args = parseArgs(process.argv.slice(2));
|
|
237
|
+
const now = args.now ? new Date(args.now) : new Date();
|
|
238
|
+
if (Number.isNaN(now.getTime())) {
|
|
239
|
+
throw new Error(`--now is not a valid RFC3339 timestamp: ${args.now}`);
|
|
240
|
+
}
|
|
241
|
+
console.log(`Effective clock: ${now.toISOString()}`);
|
|
242
|
+
const lockfile = JSON.parse(await readFile(args.lockfile, 'utf8'));
|
|
243
|
+
const { cutoff, entries, youngEntries, unfetchableEntries, offRegistryEntries, } = await auditLockfileAge({
|
|
244
|
+
lockfile,
|
|
245
|
+
now,
|
|
246
|
+
windowDays: args.windowDays,
|
|
247
|
+
source: args.lockfile,
|
|
248
|
+
});
|
|
249
|
+
if (youngEntries.length === 0 &&
|
|
250
|
+
unfetchableEntries.length === 0 &&
|
|
251
|
+
offRegistryEntries.length === 0) {
|
|
252
|
+
console.log(`Lockfile age audit passed: ${entries.length} entries, all published before ${cutoff.toISOString()}.`);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (offRegistryEntries.length > 0) {
|
|
256
|
+
console.error(`\nFailed closed: ${offRegistryEntries.length} entrie(s) do not resolve to their own registry tarball:`);
|
|
257
|
+
for (const entry of offRegistryEntries) {
|
|
258
|
+
console.error(` - ${entry.name}@${entry.version} resolved=${JSON.stringify(entry.resolved ?? null)}, expected ${registryTarballUrl(entry.name, entry.version)} (${entry.path})`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (unfetchableEntries.length > 0) {
|
|
262
|
+
console.error(`\nFailed closed: could not fetch publish metadata for ${unfetchableEntries.length} entrie(s):`);
|
|
263
|
+
for (const entry of unfetchableEntries) {
|
|
264
|
+
console.error(` - ${entry.name}@${entry.version} (${entry.path})`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (youngEntries.length > 0) {
|
|
268
|
+
console.error(`\nAge violation: ${youngEntries.length} entrie(s) published inside the ${args.windowDays}-day window (cutoff ${cutoff.toISOString()}):`);
|
|
269
|
+
for (const entry of youngEntries) {
|
|
270
|
+
console.error(` - ${entry.name}@${entry.version} published ${entry.publishedAt} (${entry.path})`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
process.exitCode = 1;
|
|
274
|
+
}
|
|
275
|
+
// pathToFileURL percent-encodes the same way import.meta.url does (spaces, non-ASCII, etc.); a raw
|
|
276
|
+
// `file://${process.argv[1]}` template comparison silently mismatches on such paths, so main() never
|
|
277
|
+
// runs and the script exits 0 with no output: a gate switched off while looking idle.
|
|
278
|
+
//
|
|
279
|
+
// process.argv[1] is undefined where there is no script path at all, which is every `node -e`, every
|
|
280
|
+
// `--input-type=module` evaluation, and the REPL. pathToFileURL throws ERR_INVALID_ARG_TYPE on
|
|
281
|
+
// undefined, so without this guard the module cannot be imported from any of them, and it ships in
|
|
282
|
+
// `dist/gates/` where a consumer does exactly that.
|
|
283
|
+
const entryPoint = process.argv[1];
|
|
284
|
+
if (entryPoint !== undefined &&
|
|
285
|
+
import.meta.url === pathToFileURL(entryPoint).href) {
|
|
286
|
+
main().catch((err) => {
|
|
287
|
+
// A refusal the caller repairs by pointing the gate at a real lockfile says so in one line; a
|
|
288
|
+
// stack trace for it is noise.
|
|
289
|
+
const detail = err.code === LOCKFILE_SHAPE_ERROR
|
|
290
|
+
? err.message
|
|
291
|
+
: (err.stack ?? String(err));
|
|
292
|
+
console.error(detail);
|
|
293
|
+
process.exitCode = 1;
|
|
294
|
+
});
|
|
295
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
// The dependency-direction gate: its configuration format, its refusals, and
|
|
2
|
+
// the entry the gates binary calls.
|
|
3
|
+
//
|
|
4
|
+
// The layer graph used to live in this repository's code, as eight literal
|
|
5
|
+
// prefix tests and a `switch` per source layer. Here it is the consumer's data,
|
|
6
|
+
// so a repository adopting this gate declares its own trees, its own layers and
|
|
7
|
+
// its own edges, and nothing of eval-quality's architecture reaches it.
|
|
8
|
+
//
|
|
9
|
+
// Nothing in this module reaches `typescript`. That is deliberate and load
|
|
10
|
+
// bearing: the scanner needs `typescript/unstable/ast` at runtime, `typescript`
|
|
11
|
+
// is an optional peer dependency, and a static import of the scanner here would
|
|
12
|
+
// make a consumer without it fail at module load with a resolver stack instead
|
|
13
|
+
// of the named refusal below. The scanner is reached through a dynamic import,
|
|
14
|
+
// after the peer is probed.
|
|
15
|
+
//
|
|
16
|
+
// Run by `node` directly: Node's type stripping erases types only, so no
|
|
17
|
+
// TypeScript enum, namespace, parameter property, or non-type re-export may
|
|
18
|
+
// appear in this file or anything it imports, or the gate fails at load.
|
|
19
|
+
import { z } from 'zod';
|
|
20
|
+
import { discoverSourceFiles } from './discover-source-files.js';
|
|
21
|
+
/** The gate's key in the configuration file, and the token the binary dispatches on. */
|
|
22
|
+
export const DEPENDENCY_DIRECTION_GATE = 'dependency-direction';
|
|
23
|
+
/**
|
|
24
|
+
* How many violations this repository's own tree reports when the two layer
|
|
25
|
+
* rows whose prefixes nest are swapped. It is stated in the schema's own
|
|
26
|
+
* description and asserted by `tests/architecture/dependency-direction.test.ts`,
|
|
27
|
+
* so the ordering property below is a measured fact rather than a warning.
|
|
28
|
+
*/
|
|
29
|
+
export const ORDERING_WITNESS_VIOLATIONS = 78;
|
|
30
|
+
/** The optional peer is absent. The consumer repairs it by installing it, so it takes the usage code. */
|
|
31
|
+
export const TYPESCRIPT_PEER_MISSING = 'EVAL_QUALITY_TYPESCRIPT_PEER_MISSING';
|
|
32
|
+
/** A declared scan root could not be walked. Also a usage code: nothing was scanned, so nothing was answered. */
|
|
33
|
+
export const DIRECTION_SCAN_ERROR = 'EVAL_QUALITY_DIRECTION_SCAN_ERROR';
|
|
34
|
+
const NonEmpty = z.string().min(1);
|
|
35
|
+
/**
|
|
36
|
+
* A repository-relative POSIX path. No leading slash, no backslash, no `.` or
|
|
37
|
+
* `..` segment: every path in this section is resolved against the directory the
|
|
38
|
+
* configuration file sits in, and a path that can climb out of it would make the
|
|
39
|
+
* declared scan roots a suggestion.
|
|
40
|
+
*/
|
|
41
|
+
const RelativePath = NonEmpty.refine((value) => {
|
|
42
|
+
// One trailing slash is how a prefix layer says "this directory"; everything
|
|
43
|
+
// else about the shape is refused.
|
|
44
|
+
const body = value.endsWith('/') ? value.slice(0, -1) : value;
|
|
45
|
+
return (body.length > 0 &&
|
|
46
|
+
!body.startsWith('/') &&
|
|
47
|
+
!body.includes('\\') &&
|
|
48
|
+
!body
|
|
49
|
+
.split('/')
|
|
50
|
+
.some((part) => part === '.' || part === '..' || part === ''));
|
|
51
|
+
}, 'is not a repository-relative POSIX path: a leading slash, a backslash, an empty segment, and a "." or ".." segment are all refused');
|
|
52
|
+
const Extension = NonEmpty.regex(/^\.[A-Za-z0-9]+$/, 'is not a file extension: write it with its leading dot, as ".ts" or ".cjs"');
|
|
53
|
+
const LayerName = NonEmpty.regex(/^[a-z][a-z0-9-]*$/, 'is not a layer name: lowercase letters, digits and hyphens, starting with a letter');
|
|
54
|
+
const ScanRoot = z
|
|
55
|
+
.strictObject({
|
|
56
|
+
path: RelativePath.describe('A directory to walk, repository-relative. Its whole subtree is scanned.'),
|
|
57
|
+
extensions: z
|
|
58
|
+
.array(Extension)
|
|
59
|
+
.min(1)
|
|
60
|
+
.default(['.ts'])
|
|
61
|
+
.describe('Which files under this root are read. A root is a directory plus its extensions rather than a glob, so "every .cjs file under src/" is one root and needs no glob language to say.'),
|
|
62
|
+
})
|
|
63
|
+
.describe('One tree to scan, and the file extensions to read inside it.');
|
|
64
|
+
const Unrestricted = z.strictObject({ policy: z.literal('unrestricted') });
|
|
65
|
+
const DenyExternals = z.strictObject({
|
|
66
|
+
policy: z.literal('deny'),
|
|
67
|
+
rule: NonEmpty.describe('What a reader is told when this layer imports an external module. Your sentence, printed verbatim: the reason a layer holds no external dependency belongs to your architecture.'),
|
|
68
|
+
});
|
|
69
|
+
const AllowExternals = z.strictObject({
|
|
70
|
+
policy: z.literal('allow'),
|
|
71
|
+
modules: z
|
|
72
|
+
.array(NonEmpty)
|
|
73
|
+
.min(1)
|
|
74
|
+
.describe('The specifiers this layer may import, matched by exact string equality. "zod" admits "zod" and refuses "zod/v4" and "zod-to-json-schema", so a subpath is a separate entry you write out.'),
|
|
75
|
+
rule: NonEmpty.describe('What a reader is told when this layer imports something outside that list. Printed verbatim.'),
|
|
76
|
+
});
|
|
77
|
+
const ExternalPolicy = z
|
|
78
|
+
.discriminatedUnion('policy', [Unrestricted, DenyExternals, AllowExternals])
|
|
79
|
+
.describe('What this layer may reach outside the scanned trees. "unrestricted" admits everything and is the default. "deny" refuses every external module and runtime builtin. "allow" admits the listed specifiers and no others.');
|
|
80
|
+
const LayerRule = z
|
|
81
|
+
.strictObject({
|
|
82
|
+
name: LayerName.describe('How the other layers name this one in their "imports" lists.'),
|
|
83
|
+
match: z
|
|
84
|
+
.enum(['exact', 'prefix'])
|
|
85
|
+
.describe('"exact" matches one file by its whole path. "prefix" matches every file under a directory, and its path ends with "/".'),
|
|
86
|
+
path: RelativePath.describe('The path this layer matches: a file path for "exact", a directory path ending in "/" for "prefix".'),
|
|
87
|
+
label: NonEmpty.optional().describe('How this layer is named in a violation line. Defaults to the layer name.'),
|
|
88
|
+
imports: z
|
|
89
|
+
.array(LayerName)
|
|
90
|
+
.describe('Every layer this one may import, named in full. Name this layer here when it may import itself: an implicit self-edge would be a permission nobody wrote down and nobody can find. An unlisted layer is denied.'),
|
|
91
|
+
externals: ExternalPolicy.default({ policy: 'unrestricted' }),
|
|
92
|
+
})
|
|
93
|
+
.describe('One layer: what it matches, what it may import, what it may reach outside.');
|
|
94
|
+
const ImportExemption = z
|
|
95
|
+
.strictObject({
|
|
96
|
+
file: RelativePath.describe('The one file the exemption covers.'),
|
|
97
|
+
module: NonEmpty.describe('The external specifier that file may reach, by exact string equality.'),
|
|
98
|
+
binding: NonEmpty.regex(/^[A-Za-z_$][A-Za-z0-9_$]*$/, 'is not an identifier').describe('The single named binding the import clause may carry. `{ binding }` and `{ binding as other }` are the whole clause; a default or namespace binding beside it pulls in the rest of the module and is not the exemption.'),
|
|
99
|
+
rule: NonEmpty.describe('What a reader is told when that file reaches that module any other way. It is its own sentence, so a narrow exemption reads as a narrow exemption in the report.'),
|
|
100
|
+
})
|
|
101
|
+
.describe('One file, one external module, one named binding. The exemption reaches a static import declaration and nothing else: a re-export and a dynamic import of the same module are refused, because neither can be held to a binding list.');
|
|
102
|
+
const PurityScope = z
|
|
103
|
+
.strictObject({
|
|
104
|
+
layers: z
|
|
105
|
+
.array(LayerName)
|
|
106
|
+
.min(1)
|
|
107
|
+
.describe('The layers held to the bans below.'),
|
|
108
|
+
awaitRule: NonEmpty.describe('What a reader is told when an await appears in a purity-scoped layer.'),
|
|
109
|
+
asyncFunctionRule: NonEmpty.describe('What a reader is told when an async function appears in a purity-scoped layer.'),
|
|
110
|
+
newDateRule: NonEmpty.describe('What a reader is told when `new Date` appears in a purity-scoped layer.'),
|
|
111
|
+
members: z
|
|
112
|
+
.array(z.strictObject({
|
|
113
|
+
member: NonEmpty.regex(/^[A-Za-z_$][A-Za-z0-9_$]*\.[A-Za-z_$][A-Za-z0-9_$]*$/, 'is not an `object.member` pair').describe('The ambient read, as `object.member`.'),
|
|
114
|
+
rule: NonEmpty.describe('What a reader is told when it appears.'),
|
|
115
|
+
}))
|
|
116
|
+
.default([])
|
|
117
|
+
.describe('Ambient reads banned in these layers. A global such as `crypto` or `performance` needs no import, so no import rule can see it and only this table can.'),
|
|
118
|
+
})
|
|
119
|
+
.describe('Layers that must stay pure. `await`, an async function, `new Date`, and each listed ambient read are refused inside them.');
|
|
120
|
+
/**
|
|
121
|
+
* The section a consumer writes. Exported for `gate-config.ts` to compose into
|
|
122
|
+
* the whole document, and for `check-doc-claims.ts` to parse the documented
|
|
123
|
+
* example through.
|
|
124
|
+
*/
|
|
125
|
+
export const DependencyDirectionSection = z
|
|
126
|
+
.strictObject({
|
|
127
|
+
roots: z
|
|
128
|
+
.array(ScanRoot)
|
|
129
|
+
.min(1)
|
|
130
|
+
.describe('The trees this gate walks. Nothing outside them is read.'),
|
|
131
|
+
layers: z
|
|
132
|
+
.array(LayerRule)
|
|
133
|
+
.min(1)
|
|
134
|
+
.describe('THE LAYERS ARE AN ORDERED LIST AND THE FIRST MATCH WINS. Narrower prefixes are listed before the wider ones that contain them, because every file under "src/core/schemas/" also sits under "src/core/" and only the order decides which rules it is held to. In this repository\'s own configuration, swapping those two rows reports ' +
|
|
135
|
+
String(ORDERING_WITNESS_VIOLATIONS) +
|
|
136
|
+
' violations where there are none today. This is a list rather than an object keyed by layer name for that reason alone: a map has no order, and normalising this into one, or sorting it, silently rewrites the graph it describes. A layer that a row before it already matches in full is refused here, so the mistake is a configuration error rather than a quiet re-layering.'),
|
|
137
|
+
exemptions: z
|
|
138
|
+
.array(ImportExemption)
|
|
139
|
+
.default([])
|
|
140
|
+
.describe("Per-file holes in a layer's external policy, each naming the one module and the one binding it opens."),
|
|
141
|
+
purity: PurityScope.optional().describe('Absent means no layer is held to the purity bans.'),
|
|
142
|
+
commonjs: z
|
|
143
|
+
.enum(['forbid', 'check'])
|
|
144
|
+
.default('forbid')
|
|
145
|
+
.describe('"forbid" refuses `require()` and `import x = require()` outright, which is what an ESM-only tree wants. "check" reads a literal `require()` specifier as an edge and holds it to the same layer rules, which is what a tree with CommonJS files needs: the alternative of ignoring them would scan a .cjs tree, find no import statement in it, and report a clean pass over a file it never read an edge from.'),
|
|
146
|
+
reportOnly: z
|
|
147
|
+
.boolean()
|
|
148
|
+
.default(false)
|
|
149
|
+
.describe('true prints the violations and exits 0. It is declared here rather than passed as a flag so that "we are still counting" is a committed line a reviewer sees and a one-line diff turns off, instead of an invocation detail nobody reading the repository can find. The run still prints its count, including zero, and still fails at 64 when a declared root yielded no files, so a green report-only run can never mean the gate scanned nothing.'),
|
|
150
|
+
})
|
|
151
|
+
.superRefine((section, ctx) => {
|
|
152
|
+
const issue = (path, message) => {
|
|
153
|
+
ctx.addIssue({ code: 'custom', path, message });
|
|
154
|
+
};
|
|
155
|
+
// A root inside another root would read every file under the inner one
|
|
156
|
+
// twice, once under each root's extension list, and report every violation
|
|
157
|
+
// in it twice.
|
|
158
|
+
section.roots.forEach((root, index) => {
|
|
159
|
+
section.roots.forEach((other, otherIndex) => {
|
|
160
|
+
if (index === otherIndex)
|
|
161
|
+
return;
|
|
162
|
+
if (`${root.path}/`.startsWith(`${other.path}/`)) {
|
|
163
|
+
issue(['roots', index, 'path'], `"${root.path}" sits inside the root "${other.path}"; declare the outer root once and list every extension it reads`);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
const underARoot = (path) => section.roots.some((root) => path === root.path || path.startsWith(`${root.path}/`));
|
|
168
|
+
const names = new Set();
|
|
169
|
+
section.layers.forEach((layer, index) => {
|
|
170
|
+
if (names.has(layer.name)) {
|
|
171
|
+
issue(['layers', index, 'name'], `"${layer.name}" is declared twice`);
|
|
172
|
+
}
|
|
173
|
+
names.add(layer.name);
|
|
174
|
+
if (layer.match === 'prefix' && !layer.path.endsWith('/')) {
|
|
175
|
+
issue(['layers', index, 'path'], `"${layer.path}" is a prefix match and has to end with "/", so that a layer at "src/core/" never claims "src/core-experimental/x.ts"`);
|
|
176
|
+
}
|
|
177
|
+
if (layer.match === 'exact' && layer.path.endsWith('/')) {
|
|
178
|
+
issue(['layers', index, 'path'], `"${layer.path}" is an exact match on one file and may not end with "/"`);
|
|
179
|
+
}
|
|
180
|
+
if (!underARoot(layer.path.replace(/\/$/, ''))) {
|
|
181
|
+
issue(['layers', index, 'path'], `"${layer.path}" sits under none of the declared roots, so it can never match a scanned file`);
|
|
182
|
+
}
|
|
183
|
+
// The ordering property, enforced rather than documented: a row whose
|
|
184
|
+
// every match is already claimed by an earlier row never fires, and the
|
|
185
|
+
// files it was written for are silently held to the earlier row's rules.
|
|
186
|
+
section.layers.slice(0, index).forEach((earlier, earlierIndex) => {
|
|
187
|
+
if (earlier.match !== 'prefix')
|
|
188
|
+
return;
|
|
189
|
+
if (!layer.path.startsWith(earlier.path))
|
|
190
|
+
return;
|
|
191
|
+
issue(['layers', index, 'path'], `"${layer.path}" is unreachable: layers[${earlierIndex}] "${earlier.name}" matches "${earlier.path}" and every path under it, and it is listed first. Move "${layer.name}" above it.`);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
section.layers.forEach((layer, index) => {
|
|
195
|
+
layer.imports.forEach((target, position) => {
|
|
196
|
+
if (names.has(target))
|
|
197
|
+
return;
|
|
198
|
+
issue(['layers', index, 'imports', position], `names "${target}", which is not a declared layer: ${[...names].join(', ')}`);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
// A layer is matched by walking the list in order, which is what makes an
|
|
202
|
+
// exemption on an unrestricted layer dead configuration rather than a
|
|
203
|
+
// harmless extra: that layer already admits every module.
|
|
204
|
+
section.exemptions.forEach((exemption, index) => {
|
|
205
|
+
const layer = section.layers.find((candidate) => candidate.match === 'exact'
|
|
206
|
+
? candidate.path === exemption.file
|
|
207
|
+
: exemption.file.startsWith(candidate.path));
|
|
208
|
+
if (layer === undefined) {
|
|
209
|
+
issue(['exemptions', index, 'file'], `"${exemption.file}" matches no declared layer, so nothing holds it and the exemption opens nothing`);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (layer.externals.policy === 'unrestricted') {
|
|
213
|
+
issue(['exemptions', index, 'file'], `"${exemption.file}" sits in the layer "${layer.name}", whose externals are unrestricted, so this exemption grants what that layer already allows`);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
section.purity?.layers.forEach((name, position) => {
|
|
217
|
+
if (names.has(name))
|
|
218
|
+
return;
|
|
219
|
+
issue(['purity', 'layers', position], `names "${name}", which is not a declared layer: ${[...names].join(', ')}`);
|
|
220
|
+
});
|
|
221
|
+
})
|
|
222
|
+
.describe('Holds every import, re-export, dynamic import and triple-slash reference directive in the trees you declare against a layer graph you declare, and holds your pure layers to a ban on await, async functions, `new Date`, and the ambient reads you list.');
|
|
223
|
+
const refuse = (code, message) => ({
|
|
224
|
+
kind: 'refused',
|
|
225
|
+
code,
|
|
226
|
+
message,
|
|
227
|
+
});
|
|
228
|
+
/**
|
|
229
|
+
* The scanner needs `typescript/unstable/ast`, and `typescript` is an optional
|
|
230
|
+
* peer dependency so that a consumer running the other gates installs nothing.
|
|
231
|
+
* Probing it by name is what turns a resolver stack trace into a sentence naming
|
|
232
|
+
* the dependency and the gate that wanted it.
|
|
233
|
+
*
|
|
234
|
+
* `load` is injectable so a test can exercise the refusal without uninstalling
|
|
235
|
+
* the package the test runner itself needs.
|
|
236
|
+
*/
|
|
237
|
+
export async function probeTypeScript(load = () => import('typescript/unstable/ast')) {
|
|
238
|
+
try {
|
|
239
|
+
await load();
|
|
240
|
+
return { ok: true };
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
if (error.code !== 'ERR_MODULE_NOT_FOUND') {
|
|
244
|
+
throw error;
|
|
245
|
+
}
|
|
246
|
+
return {
|
|
247
|
+
ok: false,
|
|
248
|
+
message: `the ${DEPENDENCY_DIRECTION_GATE} gate reads your source with the TypeScript scanner, and the optional peer dependency "typescript" is not installed here. Install it (npm install --save-dev typescript), or drop the "${DEPENDENCY_DIRECTION_GATE}" section from your configuration to stop invoking this gate. No other gate needs it.`,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
const orderViolations = (violations) => [...violations].sort((a, b) => a.file === b.file ? a.line - b.line : a.file < b.file ? -1 : 1);
|
|
253
|
+
/**
|
|
254
|
+
* Runs the gate and returns what to print and what to exit with. It writes to no
|
|
255
|
+
* stream: the binary owns every write, and `summary` is the one line it always
|
|
256
|
+
* writes, so no outcome of this gate can be silent.
|
|
257
|
+
*/
|
|
258
|
+
export async function runDependencyDirection(options) {
|
|
259
|
+
const { section, root, configPath } = options;
|
|
260
|
+
const peer = await probeTypeScript();
|
|
261
|
+
if (!peer.ok)
|
|
262
|
+
return refuse(TYPESCRIPT_PEER_MISSING, peer.message);
|
|
263
|
+
const { compileGraph, scanSources } = await import('./dependency-direction.js');
|
|
264
|
+
let files;
|
|
265
|
+
try {
|
|
266
|
+
files = await discoverSourceFiles(root, section.roots);
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
return refuse(DIRECTION_SCAN_ERROR, `${DEPENDENCY_DIRECTION_GATE}: ${error instanceof Error ? error.message : String(error)}; ${configPath}'s "${DEPENDENCY_DIRECTION_GATE}" section declares the roots`);
|
|
270
|
+
}
|
|
271
|
+
let graph;
|
|
272
|
+
try {
|
|
273
|
+
graph = compileGraph(section);
|
|
274
|
+
}
|
|
275
|
+
catch (error) {
|
|
276
|
+
return refuse(DIRECTION_SCAN_ERROR, `${configPath}'s "${DEPENDENCY_DIRECTION_GATE}" section could not be compiled into a layer graph: ${error instanceof Error ? error.message : String(error)}`);
|
|
277
|
+
}
|
|
278
|
+
const violations = orderViolations(scanSources(files, graph));
|
|
279
|
+
const lines = violations.map((violation) => ` ${violation.file}:${violation.line} "${violation.specifier}": ${violation.rule}`);
|
|
280
|
+
const scope = `${violations.length} violation(s) across ${files.size} scanned file(s)`;
|
|
281
|
+
if (section.reportOnly) {
|
|
282
|
+
return {
|
|
283
|
+
kind: 'report',
|
|
284
|
+
reportOnly: true,
|
|
285
|
+
failed: false,
|
|
286
|
+
scannedFiles: files.size,
|
|
287
|
+
violations,
|
|
288
|
+
summary: `${DEPENDENCY_DIRECTION_GATE}: report-only, ${scope}; this run did not fail. Set "reportOnly": false in ${configPath} to make it.`,
|
|
289
|
+
lines,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
return {
|
|
293
|
+
kind: 'report',
|
|
294
|
+
reportOnly: false,
|
|
295
|
+
failed: violations.length > 0,
|
|
296
|
+
scannedFiles: files.size,
|
|
297
|
+
violations,
|
|
298
|
+
summary: violations.length === 0
|
|
299
|
+
? `${DEPENDENCY_DIRECTION_GATE}: passed, ${files.size} file(s) scanned across ${section.roots.length} root(s), 0 violations.`
|
|
300
|
+
: `${DEPENDENCY_DIRECTION_GATE}: ${scope}:`,
|
|
301
|
+
lines,
|
|
302
|
+
};
|
|
303
|
+
}
|