eval-quality 3.1.0 → 3.3.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 +1 -1
- package/dist/application/index.d.ts +6 -0
- package/dist/application/index.js +8 -0
- package/dist/gates/audit-lockfile-age.mjs +105 -8
- package/dist/gates/check-dependency-direction.js +10 -10
- package/dist/gates/check-doc-claims.js +1026 -0
- package/dist/gates/check-doc-counts.js +408 -0
- package/dist/gates/check-doc-invocations.mjs +677 -0
- package/dist/gates/check-licenses.mjs +89 -16
- package/dist/gates/consumer-pattern.js +104 -0
- package/dist/gates/dependency-direction.js +130 -0
- package/dist/gates/gate-config.js +179 -12
- package/dist/gates/gates-cli.js +216 -19
- package/dist/gates/lineage-ownership.js +20 -23
- package/dist/gates/module-value.js +187 -0
- package/dist/gates/package-boundary.js +33 -144
- package/dist/gates/scanned-paths.js +110 -0
- package/dist/gates/typescript-scanner.js +181 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.js +1 -1
- package/dist/ports/environment-probe-port.d.ts +12 -12
- package/package.json +8 -5
package/README.md
CHANGED
|
@@ -134,7 +134,7 @@ Node.js 22.20.0 or newer. `zod` is the only production dependency.
|
|
|
134
134
|
npm install eval-quality
|
|
135
135
|
```
|
|
136
136
|
|
|
137
|
-
Every command runs through `npx
|
|
137
|
+
Every command runs through `npx`. The four below are the grammar rather than a sequence to copy: they name files your own harness produces. [The full walkthrough](https://bmad-code-org.github.io/bmad-eval-quality/how-to/author-behavioral-contracts/) runs the same four end to end over files this repository commits, with no placeholder in the path.
|
|
138
138
|
|
|
139
139
|
```bash
|
|
140
140
|
npx eval-quality compile --in contract.json --out ./eval-out
|
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
* unamended.
|
|
7
7
|
*/
|
|
8
8
|
export { digestArtifact, digestBytes, digestComposite, } from '../core/canonical/digest.ts';
|
|
9
|
+
export { makePointerDenotesCollection, makeResolveOperand, referenceSetKeysOf, } from '../core/evaluate/evidence-resolution.ts';
|
|
10
|
+
export type { PointerDenotesCollection, ReferenceSetKeys, ResolveOperand, } from '../core/evaluate/resolution.ts';
|
|
11
|
+
export { resolveCheck } from '../core/evaluate/resolution.ts';
|
|
12
|
+
export type { ResolvedValue } from '../core/evaluate/resolved-value.ts';
|
|
13
|
+
export { ABSENT } from '../core/evaluate/resolved-value.ts';
|
|
9
14
|
export type { FailureCode } from '../core/failure-codes.ts';
|
|
10
15
|
export { FAILURE_CODES, StructuralFailure } from '../core/failure-codes.ts';
|
|
11
16
|
export type { LineageChainReport, LineageFinding, } from '../core/lineage/chain.ts';
|
|
@@ -21,6 +26,7 @@ export type { QualificationFailure, QualificationFailureCode, QualificationResul
|
|
|
21
26
|
export { QUALIFICATION_FAILURES } from '../core/score/qualification.ts';
|
|
22
27
|
export type { ComparableResult, DominanceRelationValue, } from '../core/score/strength.ts';
|
|
23
28
|
export { compareDominance, DOMINANCE_RELATIONS, } from '../core/score/strength.ts';
|
|
29
|
+
export type { PlanIndex } from '../core/seal/plan-index.ts';
|
|
24
30
|
export { compile } from './compile.ts';
|
|
25
31
|
export type { Diagnostic, DiagnosticSink } from './diagnostics.ts';
|
|
26
32
|
export type { PreflightFromObservationsOptions, RunPreflightOptions, } from './preflight.ts';
|
|
@@ -6,6 +6,14 @@
|
|
|
6
6
|
* unamended.
|
|
7
7
|
*/
|
|
8
8
|
export { digestArtifact, digestBytes, digestComposite, } from '../core/canonical/digest.js';
|
|
9
|
+
// The evaluator, for a consumer that resolves a check itself: the resolver,
|
|
10
|
+
// the two factories that build its operand and collection predicates from a
|
|
11
|
+
// contract and its observations, and the reference-set keys. `ABSENT` ships
|
|
12
|
+
// beside them because a `ResolveOperand` a consumer writes has to return it,
|
|
13
|
+
// and a sentinel the type names and the barrel withholds cannot be returned.
|
|
14
|
+
export { makePointerDenotesCollection, makeResolveOperand, referenceSetKeysOf, } from '../core/evaluate/evidence-resolution.js';
|
|
15
|
+
export { resolveCheck } from '../core/evaluate/resolution.js';
|
|
16
|
+
export { ABSENT } from '../core/evaluate/resolved-value.js';
|
|
9
17
|
export { FAILURE_CODES, StructuralFailure } from '../core/failure-codes.js';
|
|
10
18
|
export { validateLineageChain } from '../core/lineage/chain.js';
|
|
11
19
|
export { INTERCHANGE_ARTIFACT_KEYS } from '../core/schemas/artifact.js';
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
// These flags are the pre-install path: `.github/actions/audit-lockfile-age`
|
|
13
13
|
// runs this before `npm ci`, so nothing here may import from `node_modules`. A
|
|
14
14
|
// consumer runs the same audit through `eval-quality-gates lockfile-age`, which
|
|
15
|
-
// reads its lockfiles and its
|
|
15
|
+
// reads its lockfiles, its window and its exclusions out of a configuration
|
|
16
|
+
// file.
|
|
16
17
|
//
|
|
17
18
|
// Usage:
|
|
18
19
|
// node scripts/audit-lockfile-age.mjs [--lockfile <path>] [--window-days <n>] [--now <RFC3339>]
|
|
@@ -59,6 +60,7 @@ function parseArgs(argv) {
|
|
|
59
60
|
lockfile: 'package-lock.json',
|
|
60
61
|
windowDays: WINDOW_DAYS_DEFAULT,
|
|
61
62
|
now: null,
|
|
63
|
+
cache: null,
|
|
62
64
|
};
|
|
63
65
|
for (let i = 0; i < argv.length; i++) {
|
|
64
66
|
const arg = argv[i];
|
|
@@ -68,6 +70,8 @@ function parseArgs(argv) {
|
|
|
68
70
|
args.windowDays = Number(argv[++i]);
|
|
69
71
|
else if (arg === '--now')
|
|
70
72
|
args.now = argv[++i];
|
|
73
|
+
else if (arg === '--cache')
|
|
74
|
+
args.cache = argv[++i];
|
|
71
75
|
else
|
|
72
76
|
throw new Error(`Unknown argument: ${arg}`);
|
|
73
77
|
}
|
|
@@ -117,8 +121,9 @@ async function fetchWithRetry(url, attempts = MAX_RETRIES) {
|
|
|
117
121
|
}
|
|
118
122
|
// One registry request per unique package NAME (not per lockfile entry): the response carries a
|
|
119
123
|
// `time` map covering every published version, so every locked version of that package is checked
|
|
120
|
-
// from a single fetch.
|
|
121
|
-
|
|
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) {
|
|
122
127
|
const meta = await fetchWithRetry(registryUrlForName(name));
|
|
123
128
|
return meta.time ?? {};
|
|
124
129
|
}
|
|
@@ -164,13 +169,34 @@ function collectLockedEntries(lockfile, source) {
|
|
|
164
169
|
* caller that wants the registry gets it by saying nothing, and a case that
|
|
165
170
|
* wants a fixed answer runs offline.
|
|
166
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
|
+
*
|
|
167
187
|
* `source` is the path this lockfile was read from, named in the refusal a
|
|
168
188
|
* document without a `packages` object earns.
|
|
169
189
|
*/
|
|
170
|
-
export async function auditLockfileAge({ lockfile, now, windowDays,
|
|
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 = {}, }) {
|
|
171
193
|
if (!Number.isFinite(windowDays) || windowDays < 1) {
|
|
172
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`);
|
|
173
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
|
+
}
|
|
174
200
|
const cutoff = new Date(now.getTime() - windowDays * 24 * 60 * 60 * 1000);
|
|
175
201
|
const entries = collectLockedEntries(lockfile, source);
|
|
176
202
|
// An entry whose `resolved` is not the registry tarball for its own name and version did not come
|
|
@@ -189,7 +215,24 @@ export async function auditLockfileAge({ lockfile, now, windowDays, source = 'th
|
|
|
189
215
|
offRegistryEntries.push(entry);
|
|
190
216
|
}
|
|
191
217
|
}
|
|
192
|
-
const
|
|
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
|
+
];
|
|
193
236
|
const timeMaps = new Map();
|
|
194
237
|
const fetchFailures = new Set();
|
|
195
238
|
await mapWithConcurrency(uniqueNames, CONCURRENCY, async (name) => {
|
|
@@ -202,12 +245,13 @@ export async function auditLockfileAge({ lockfile, now, windowDays, source = 'th
|
|
|
202
245
|
});
|
|
203
246
|
const youngEntries = [];
|
|
204
247
|
const unfetchableEntries = [];
|
|
205
|
-
for (const entry of
|
|
206
|
-
|
|
248
|
+
for (const entry of auditedEntries) {
|
|
249
|
+
const fromCache = cachedAt(entry);
|
|
250
|
+
if (fromCache === undefined && fetchFailures.has(entry.name)) {
|
|
207
251
|
unfetchableEntries.push(entry);
|
|
208
252
|
continue;
|
|
209
253
|
}
|
|
210
|
-
const publishedAt = timeMaps.get(entry.name)?.[entry.version];
|
|
254
|
+
const publishedAt = fromCache ?? timeMaps.get(entry.name)?.[entry.version];
|
|
211
255
|
if (!publishedAt) {
|
|
212
256
|
unfetchableEntries.push(entry);
|
|
213
257
|
continue;
|
|
@@ -230,8 +274,56 @@ export async function auditLockfileAge({ lockfile, now, windowDays, source = 'th
|
|
|
230
274
|
youngEntries,
|
|
231
275
|
unfetchableEntries,
|
|
232
276
|
offRegistryEntries,
|
|
277
|
+
excludedEntries,
|
|
233
278
|
};
|
|
234
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
|
+
}
|
|
235
327
|
async function main() {
|
|
236
328
|
const args = parseArgs(process.argv.slice(2));
|
|
237
329
|
const now = args.now ? new Date(args.now) : new Date();
|
|
@@ -240,11 +332,16 @@ async function main() {
|
|
|
240
332
|
}
|
|
241
333
|
console.log(`Effective clock: ${now.toISOString()}`);
|
|
242
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);
|
|
243
339
|
const { cutoff, entries, youngEntries, unfetchableEntries, offRegistryEntries, } = await auditLockfileAge({
|
|
244
340
|
lockfile,
|
|
245
341
|
now,
|
|
246
342
|
windowDays: args.windowDays,
|
|
247
343
|
source: args.lockfile,
|
|
344
|
+
cache,
|
|
248
345
|
});
|
|
249
346
|
if (youngEntries.length === 0 &&
|
|
250
347
|
unfetchableEntries.length === 0 &&
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
// appear in this file or anything it imports, or the gate fails at load.
|
|
19
19
|
import { z } from 'zod';
|
|
20
20
|
import { discoverSourceFiles } from './discover-source-files.js';
|
|
21
|
+
import { isCode, loadTypeScriptScanner, TYPESCRIPT_UNAVAILABLE, } from './typescript-scanner.js';
|
|
21
22
|
/** The gate's key in the configuration file, and the token the binary dispatches on. */
|
|
22
23
|
export const DEPENDENCY_DIRECTION_GATE = 'dependency-direction';
|
|
23
24
|
/**
|
|
@@ -26,7 +27,7 @@ export const DEPENDENCY_DIRECTION_GATE = 'dependency-direction';
|
|
|
26
27
|
* description and asserted by `tests/architecture/dependency-direction.test.ts`,
|
|
27
28
|
* so the ordering property below is a measured fact rather than a warning.
|
|
28
29
|
*/
|
|
29
|
-
export const ORDERING_WITNESS_VIOLATIONS =
|
|
30
|
+
export const ORDERING_WITNESS_VIOLATIONS = 80;
|
|
30
31
|
/** The optional peer is absent. The consumer repairs it by installing it, so it takes the usage code. */
|
|
31
32
|
export const TYPESCRIPT_PEER_MISSING = 'EVAL_QUALITY_TYPESCRIPT_PEER_MISSING';
|
|
32
33
|
/** A declared scan root could not be walked. Also a usage code: nothing was scanned, so nothing was answered. */
|
|
@@ -228,25 +229,24 @@ const refuse = (code, message) => ({
|
|
|
228
229
|
/**
|
|
229
230
|
* The scanner needs `typescript/unstable/ast`, and `typescript` is an optional
|
|
230
231
|
* peer dependency so that a consumer running the other gates installs nothing.
|
|
231
|
-
*
|
|
232
|
-
* the dependency and the gate that wanted it
|
|
232
|
+
* `typescript-scanner.ts` is what turns a resolver stack trace into a sentence
|
|
233
|
+
* naming the dependency, the installed version and the gate that wanted it; a
|
|
234
|
+
* failure of any other shape is a bug in the scanner, not a missing peer, and
|
|
235
|
+
* is rethrown unchanged.
|
|
233
236
|
*
|
|
234
237
|
* `load` is injectable so a test can exercise the refusal without uninstalling
|
|
235
238
|
* the package the test runner itself needs.
|
|
236
239
|
*/
|
|
237
|
-
export async function probeTypeScript(load = () =>
|
|
240
|
+
export async function probeTypeScript(load = () => loadTypeScriptScanner(DEPENDENCY_DIRECTION_GATE)) {
|
|
238
241
|
try {
|
|
239
242
|
await load();
|
|
240
243
|
return { ok: true };
|
|
241
244
|
}
|
|
242
245
|
catch (error) {
|
|
243
|
-
if (error
|
|
244
|
-
|
|
246
|
+
if (isCode(error, TYPESCRIPT_UNAVAILABLE)) {
|
|
247
|
+
return { ok: false, message: error.message };
|
|
245
248
|
}
|
|
246
|
-
|
|
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
|
-
};
|
|
249
|
+
throw error;
|
|
250
250
|
}
|
|
251
251
|
}
|
|
252
252
|
const orderViolations = (violations) => [...violations].sort((a, b) => a.file === b.file ? a.line - b.line : a.file < b.file ? -1 : 1);
|