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,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
+ }