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 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 window out of a configuration file.
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
- async function fetchTimeMap(name) {
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, source = 'the lockfile', readTimeMap = fetchTimeMap, }) {
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 uniqueNames = [...new Set(registryEntries.map((e) => e.name))];
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 registryEntries) {
206
- if (fetchFailures.has(entry.name)) {
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 = 78;
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
- * Probing it by name is what turns a resolver stack trace into a sentence naming
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 = () => import('typescript/unstable/ast')) {
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.code !== 'ERR_MODULE_NOT_FOUND') {
244
- throw error;
246
+ if (isCode(error, TYPESCRIPT_UNAVAILABLE)) {
247
+ return { ok: false, message: error.message };
245
248
  }
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
- };
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);