eval-quality 3.1.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.
@@ -1,22 +1,46 @@
1
- import { lstat, readdir, readFile } from 'node:fs/promises';
1
+ // A published gate: everything a package ships, held against forbidden patterns
2
+ // the consumer declares.
3
+ //
4
+ // Both halves are the consumer's. The scanned set is a list of paths, each with
5
+ // a recursion flag and an extension filter, plus the manifest fields a registry
6
+ // publishes verbatim. What a consumer leaves out of that list is exempt, and
7
+ // omission is the only exemption the gate has. The rules are an ordered array of
8
+ // named regular expressions carrying a reason apiece.
9
+ //
10
+ // `scanPackageBoundary` is pure and synchronous over a file map, so one function
11
+ // backs both the real scan and a synthetic test map. It matches text line by
12
+ // line rather than tokenizing, because the references this class of rule forbids
13
+ // live in comments and in string literals, which a token scan reports by kind
14
+ // and not by content. Nothing here loads `typescript`.
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.
19
+ import { readFile } from 'node:fs/promises';
2
20
  import { resolve } from 'node:path';
3
21
  import { z } from 'zod';
22
+ import { PatternFlags, PatternSource, refinePattern, } from './consumer-pattern.js';
23
+ import { discoverEntries, RelativePath, SCAN_PATH_ERROR, ScannedPathList, } from './scanned-paths.js';
4
24
  /**
5
25
  * The bound on a consumer-supplied regular expression, and what it does not
6
26
  * cover.
7
27
  *
8
28
  * A pattern arrives as text from a file this package did not write, so the work
9
29
  * one pattern can do is the consumer's to choose. JavaScript's `RegExp` exposes
10
- * no step counter and no timeout, and `check-doc-claims.ts:943` records the same
30
+ * no step counter and no timeout, and `doc-claim-sources.ts` records the same
11
31
  * limit from the other side: `REGEX_STEP_BUDGET` is a number that exists because
12
- * a step count is the bound you would want and nothing offers one.
32
+ * a step count is the bound you would want and nothing offers one. A line
33
+ * citation is deliberately not used here: the doc-claims gate resolves citations
34
+ * on published pages, so one rotting inside `scripts/` is held by nothing.
13
35
  *
14
36
  * So the bound is on the two things that are measurable. The pattern side:
15
37
  * `MAX_PATTERN_LENGTH` on the source, `MAX_PATTERNS` on the array, a flag set
16
38
  * that excludes `g` and `y`, and a refusal of backreferences, which is the
17
- * construct that turns a linear scan of one line into an exponential one. The
18
- * input side: `MAX_SCANNED_LINE` on the text a pattern is matched against, so a
19
- * minified bundle or an embedded data URI cannot hand a pattern a megabyte.
39
+ * construct that turns a linear scan of one line into an exponential one. Those
40
+ * four live in `consumer-pattern.ts`, shared with every gate that takes a
41
+ * pattern. The input side is this scanner's own: `MAX_SCANNED_LINE` bounds the
42
+ * text a pattern is matched against, so a minified bundle or an embedded data
43
+ * URI cannot hand a pattern a megabyte.
20
44
  *
21
45
  * 64 KiB, not the single kilobyte a first draft of this bound used. This
22
46
  * package's own `corpus/` is a legitimate long-line source: an unminified
@@ -34,7 +58,6 @@ import { z } from 'zod';
34
58
  * more than the surface is worth.
35
59
  */
36
60
  export const MAX_PATTERNS = 64;
37
- export const MAX_PATTERN_LENGTH = 200;
38
61
  export const MAX_SCANNED_LINE = 65_536;
39
62
  /**
40
63
  * The name an over-long line is reported under. Reserved, so a consumer pattern
@@ -42,46 +65,9 @@ export const MAX_SCANNED_LINE = 65_536;
42
65
  * line the gate declined to match.
43
66
  */
44
67
  export const OVERLONG_LINE = 'line-exceeds-scan-bound';
45
- /** A path or manifest field the configuration named and the tree does not have. */
46
- export const SCAN_PATH_ERROR = 'EVAL_QUALITY_SCAN_PATH';
47
- /** A tree the gate could not read to the end, so the scan proves nothing. */
48
- export const SCAN_UNREADABLE = 'EVAL_QUALITY_SCAN_UNREADABLE';
49
68
  const codedError = (code, message) => Object.assign(new Error(message), { code });
50
69
  const detail = (error) => error instanceof Error ? error.message : String(error);
51
70
  const NonEmpty = z.string().min(1);
52
- const isSafeRelative = (value) => !value.startsWith('/') &&
53
- !/^[A-Za-z]:/.test(value) &&
54
- !value.includes('\\') &&
55
- value.split('/').every((segment) => segment !== '' && segment !== '..');
56
- const RELATIVE_PATH_MESSAGE = 'is not a repository-relative path: write it with forward slashes, no leading slash, no drive letter, and no ".." segment, so a configuration can only name files beneath itself';
57
- /** Shared with `lineage-ownership.ts`; see the note on `ScannedPathList`. */
58
- export const RelativePath = NonEmpty.max(400).refine(isSafeRelative, RELATIVE_PATH_MESSAGE);
59
- /** The same shape with a trailing slash allowed, for a directory prefix. */
60
- export const RelativePrefix = NonEmpty.max(400).refine((value) => isSafeRelative(value.endsWith('/') ? value.slice(0, -1) : value), RELATIVE_PATH_MESSAGE);
61
- const ScannedPath = z.strictObject({
62
- path: RelativePath.describe('A directory to walk, or a single file to read, relative to this configuration file.'),
63
- recursive: z
64
- .boolean()
65
- .default(true)
66
- .describe('Whether a directory is walked to the bottom. Ignored when the path names a file.'),
67
- extensions: z
68
- .array(z
69
- .string()
70
- .regex(/^\.[A-Za-z0-9][A-Za-z0-9.]*$/, 'is not a file extension; write it with its leading dot, as ".ts"'))
71
- .min(1)
72
- .optional()
73
- .describe('Which files under this path are read. Leave it out and every file is read whatever its name, which is what a tree of generated data needs.'),
74
- optional: z
75
- .boolean()
76
- .default(false)
77
- .describe('Whether this path may contribute nothing. False, the default, fails when the path is absent or holds no matching file, so a generated tree nobody built cannot read as a clean scan.'),
78
- });
79
- /**
80
- * The scanned-set declaration, shared with the field-ownership gate. It sits
81
- * here because this is the gate whose headline is the scanned set; a third gate
82
- * that needs it is the point at which it earns a module of its own.
83
- */
84
- export const ScannedPathList = z.array(ScannedPath).min(1);
85
71
  const ManifestField = NonEmpty.regex(/^[A-Za-z_$][A-Za-z0-9_$-]*(?:\.[A-Za-z_$][A-Za-z0-9_$-]*)*$/, 'is not a manifest field path; write one key, or several joined by dots');
86
72
  const ManifestScan = z
87
73
  .strictObject({
@@ -92,25 +78,11 @@ const ManifestScan = z
92
78
  .describe('Which fields are scanned. A string is one entry, an array joins into one, and an object becomes one entry per key, so "scripts" covers every script by name. A field named here and absent from the manifest is refused, so a typo cannot read as a field with nothing in it.'),
93
79
  })
94
80
  .describe('The manifest fields scanned as synthetic entries. A JSON value has no line of its own, so every one of them reports at line 1.');
95
- /**
96
- * `\1` through `\9` and `\k<name>`. It over-refuses an escaped backslash
97
- * followed by a digit, which is a literal backslash and not a backreference,
98
- * and that spelling has no place in a boundary pattern anyway.
99
- */
100
- const BACKREFERENCE = /\\[1-9]|\\k</;
101
81
  const ForbiddenPattern = z
102
82
  .strictObject({
103
83
  name: NonEmpty.describe('What a violation is reported under. Unique across the array.'),
104
- match: z
105
- .string()
106
- .min(1)
107
- .max(MAX_PATTERN_LENGTH)
108
- .describe('The regular expression, as source text. It is matched against one logical line at a time.'),
109
- flags: z
110
- .string()
111
- .regex(/^[imsuv]*$/, 'admits only i, m, s, u and v. A g or a y carries a match position between calls, so a pattern holding either would match every second line it should have matched')
112
- .default('')
113
- .describe('Regular-expression flags. Empty by default.'),
84
+ match: PatternSource.describe('The regular expression, as source text. It is matched against one logical line at a time.'),
85
+ flags: PatternFlags.describe('Regular-expression flags. Empty by default.'),
114
86
  reason: NonEmpty.describe('Why the package may not carry it. Printed beside every violation, so the report says what to do rather than only what fired.'),
115
87
  })
116
88
  .superRefine((pattern, ctx) => {
@@ -121,23 +93,7 @@ const ForbiddenPattern = z
121
93
  message: `is reserved: the gate reports a line past its own matching bound under "${OVERLONG_LINE}"`,
122
94
  });
123
95
  }
124
- if (BACKREFERENCE.test(pattern.match)) {
125
- ctx.addIssue({
126
- code: 'custom',
127
- path: ['match'],
128
- message: 'carries a backreference, which is the construct that turns a scan of one line into an exponential one; write the pattern without one',
129
- });
130
- }
131
- try {
132
- new RegExp(pattern.match, pattern.flags);
133
- }
134
- catch (error) {
135
- ctx.addIssue({
136
- code: 'custom',
137
- path: ['match'],
138
- message: `is not a regular expression: ${detail(error)}`,
139
- });
140
- }
96
+ refinePattern(pattern.match, pattern.flags, ctx, ['match']);
141
97
  });
142
98
  export const PackageBoundarySection = z
143
99
  .strictObject({
@@ -251,73 +207,6 @@ export function scanPackageBoundary(files, patterns) {
251
207
  }
252
208
  return violations;
253
209
  }
254
- const matchesExtension = (declared, name) => declared.extensions === undefined ||
255
- declared.extensions.some((extension) => name.endsWith(extension));
256
- async function walk(root, posix, declared, entries) {
257
- let dirents;
258
- try {
259
- dirents = await readdir(resolve(root, posix), { withFileTypes: true });
260
- }
261
- catch (error) {
262
- throw codedError(SCAN_UNREADABLE, `${posix} could not be read: ${detail(error)}`);
263
- }
264
- let count = 0;
265
- for (const entry of dirents) {
266
- const child = `${posix}/${entry.name}`;
267
- if (entry.isSymbolicLink()) {
268
- throw codedError(SCAN_UNREADABLE, `${child} is a symbolic link; this scan does not follow links, and skipping one would leave a file unscanned while the run reported a clean tree`);
269
- }
270
- if (entry.isDirectory()) {
271
- if (!declared.recursive)
272
- continue;
273
- count += await walk(root, child, declared, entries);
274
- }
275
- else if (entry.isFile() && matchesExtension(declared, entry.name)) {
276
- entries.set(child, await readFile(resolve(root, child), 'utf8'));
277
- count += 1;
278
- }
279
- }
280
- return count;
281
- }
282
- /**
283
- * Everything the declared paths hold, keyed by the path a report prints. Fails
284
- * closed on the three ways a walk under-reports: a path that is not there, a
285
- * path that matched no file, and a link whose target could sit anywhere.
286
- */
287
- export async function discoverEntries(root, paths, gate) {
288
- const entries = new Map();
289
- const counts = [];
290
- for (const declared of paths) {
291
- let info;
292
- try {
293
- info = await lstat(resolve(root, declared.path));
294
- }
295
- catch (error) {
296
- if (error.code !== 'ENOENT') {
297
- throw codedError(SCAN_UNREADABLE, `${declared.path} could not be read: ${detail(error)}`);
298
- }
299
- if (declared.optional) {
300
- counts.push({ path: declared.path, files: 0 });
301
- continue;
302
- }
303
- throw codedError(SCAN_PATH_ERROR, `${declared.path} does not exist, and the "${gate}" section names it under paths; mark it optional if it may be absent`);
304
- }
305
- if (info.isSymbolicLink()) {
306
- throw codedError(SCAN_UNREADABLE, `${declared.path} is a symbolic link, and this scan does not follow links`);
307
- }
308
- if (info.isFile()) {
309
- entries.set(declared.path, await readFile(resolve(root, declared.path), 'utf8'));
310
- counts.push({ path: declared.path, files: 1 });
311
- continue;
312
- }
313
- const found = await walk(root, declared.path, declared, entries);
314
- if (found === 0 && !declared.optional) {
315
- throw codedError(SCAN_PATH_ERROR, `${declared.path} holds no file the "${gate}" section asked for, so a scan of nothing would report zero violations for the wrong reason; mark it optional if it may be empty`);
316
- }
317
- counts.push({ path: declared.path, files: found });
318
- }
319
- return { entries, counts };
320
- }
321
210
  function flattenField(key, value, into) {
322
211
  if (typeof value === 'string') {
323
212
  into.set(key, value);
@@ -0,0 +1,110 @@
1
+ import { lstat, readdir, readFile } from 'node:fs/promises';
2
+ import { resolve } from 'node:path';
3
+ import { z } from 'zod';
4
+ /** A path or manifest field the configuration named and the tree does not have. */
5
+ export const SCAN_PATH_ERROR = 'EVAL_QUALITY_SCAN_PATH';
6
+ /** A tree the gate could not read to the end, so the scan proves nothing. */
7
+ export const SCAN_UNREADABLE = 'EVAL_QUALITY_SCAN_UNREADABLE';
8
+ const codedError = (code, message) => Object.assign(new Error(message), { code });
9
+ const detail = (error) => error instanceof Error ? error.message : String(error);
10
+ const NonEmpty = z.string().min(1);
11
+ const isSafeRelative = (value) => !value.startsWith('/') &&
12
+ !/^[A-Za-z]:/.test(value) &&
13
+ !value.includes('\\') &&
14
+ value.split('/').every((segment) => segment !== '' && segment !== '..');
15
+ const RELATIVE_PATH_MESSAGE = 'is not a repository-relative path: write it with forward slashes, no leading slash, no drive letter, and no ".." segment, so a configuration can only name files beneath itself';
16
+ /** Shared with `lineage-ownership.ts`; see the note on `ScannedPathList`. */
17
+ export const RelativePath = NonEmpty.max(400).refine(isSafeRelative, RELATIVE_PATH_MESSAGE);
18
+ /** The same shape with a trailing slash allowed, for a directory prefix. */
19
+ export const RelativePrefix = NonEmpty.max(400).refine((value) => isSafeRelative(value.endsWith('/') ? value.slice(0, -1) : value), RELATIVE_PATH_MESSAGE);
20
+ const ScannedPath = z.strictObject({
21
+ path: RelativePath.describe('A directory to walk, or a single file to read, relative to this configuration file.'),
22
+ recursive: z
23
+ .boolean()
24
+ .default(true)
25
+ .describe('Whether a directory is walked to the bottom. Ignored when the path names a file.'),
26
+ extensions: z
27
+ .array(z
28
+ .string()
29
+ .regex(/^\.[A-Za-z0-9][A-Za-z0-9.]*$/, 'is not a file extension; write it with its leading dot, as ".ts"'))
30
+ .min(1)
31
+ .optional()
32
+ .describe('Which files under this path are read. Leave it out and every file is read whatever its name, which is what a tree of generated data needs.'),
33
+ optional: z
34
+ .boolean()
35
+ .default(false)
36
+ .describe('Whether this path may contribute nothing. False, the default, fails when the path is absent or holds no matching file, so a generated tree nobody built cannot read as a clean scan.'),
37
+ });
38
+ /**
39
+ * The scanned-set declaration. It lived in `package-boundary.ts` while that gate
40
+ * was its only owner, on a note saying a third gate needing it was the point at
41
+ * which it earned a module of its own. The two documentation gates made three.
42
+ */
43
+ export const ScannedPathList = z.array(ScannedPath).min(1);
44
+ const matchesExtension = (declared, name) => declared.extensions === undefined ||
45
+ declared.extensions.some((extension) => name.endsWith(extension));
46
+ async function walk(root, posix, declared, entries) {
47
+ let dirents;
48
+ try {
49
+ dirents = await readdir(resolve(root, posix), { withFileTypes: true });
50
+ }
51
+ catch (error) {
52
+ throw codedError(SCAN_UNREADABLE, `${posix} could not be read: ${detail(error)}`);
53
+ }
54
+ let count = 0;
55
+ for (const entry of dirents) {
56
+ const child = `${posix}/${entry.name}`;
57
+ if (entry.isSymbolicLink()) {
58
+ throw codedError(SCAN_UNREADABLE, `${child} is a symbolic link; this scan does not follow links, and skipping one would leave a file unscanned while the run reported a clean tree`);
59
+ }
60
+ if (entry.isDirectory()) {
61
+ if (!declared.recursive)
62
+ continue;
63
+ count += await walk(root, child, declared, entries);
64
+ }
65
+ else if (entry.isFile() && matchesExtension(declared, entry.name)) {
66
+ entries.set(child, await readFile(resolve(root, child), 'utf8'));
67
+ count += 1;
68
+ }
69
+ }
70
+ return count;
71
+ }
72
+ /**
73
+ * Everything the declared paths hold, keyed by the path a report prints. Fails
74
+ * closed on the three ways a walk under-reports: a path that is not there, a
75
+ * path that matched no file, and a link whose target could sit anywhere.
76
+ */
77
+ export async function discoverEntries(root, paths, gate) {
78
+ const entries = new Map();
79
+ const counts = [];
80
+ for (const declared of paths) {
81
+ let info;
82
+ try {
83
+ info = await lstat(resolve(root, declared.path));
84
+ }
85
+ catch (error) {
86
+ if (error.code !== 'ENOENT') {
87
+ throw codedError(SCAN_UNREADABLE, `${declared.path} could not be read: ${detail(error)}`);
88
+ }
89
+ if (declared.optional) {
90
+ counts.push({ path: declared.path, files: 0 });
91
+ continue;
92
+ }
93
+ throw codedError(SCAN_PATH_ERROR, `${declared.path} does not exist, and the "${gate}" section names it under paths; mark it optional if it may be absent`);
94
+ }
95
+ if (info.isSymbolicLink()) {
96
+ throw codedError(SCAN_UNREADABLE, `${declared.path} is a symbolic link, and this scan does not follow links`);
97
+ }
98
+ if (info.isFile()) {
99
+ entries.set(declared.path, await readFile(resolve(root, declared.path), 'utf8'));
100
+ counts.push({ path: declared.path, files: 1 });
101
+ continue;
102
+ }
103
+ const found = await walk(root, declared.path, declared, entries);
104
+ if (found === 0 && !declared.optional) {
105
+ throw codedError(SCAN_PATH_ERROR, `${declared.path} holds no file the "${gate}" section asked for, so a scan of nothing would report zero violations for the wrong reason; mark it optional if it may be empty`);
106
+ }
107
+ counts.push({ path: declared.path, files: found });
108
+ }
109
+ return { entries, counts };
110
+ }
package/dist/index.d.ts CHANGED
@@ -22,4 +22,4 @@ export { SEALED_EVALUATOR_BRIEF_SCHEMA_VERSION } from './core/schemas/sealed-eva
22
22
  export type { SealedRunRecord } from './core/schemas/sealed-run-record.ts';
23
23
  export { SEALED_RUN_RECORD_SCHEMA_VERSION } from './core/schemas/sealed-run-record.ts';
24
24
  export type { FixtureReset, ManifestationWitness, SensitivityWitness, SensitivityWitnessLeg, WitnessChannel, WitnessInputs, } from './core/schemas/sensitivity-witness.ts';
25
- export declare const VERSION = "3.1.0";
25
+ export declare const VERSION = "3.2.0";
package/dist/index.js CHANGED
@@ -38,4 +38,4 @@ export { PROBE_SCHEMA_VERSION } from './core/schemas/probe.js';
38
38
  export { SCORING_POLICY_SCHEMA_VERSION } from './core/schemas/scoring-policy.js';
39
39
  export { SEALED_EVALUATOR_BRIEF_SCHEMA_VERSION } from './core/schemas/sealed-evaluator-brief.js';
40
40
  export { SEALED_RUN_RECORD_SCHEMA_VERSION } from './core/schemas/sealed-run-record.js';
41
- export const VERSION = '3.1.0';
41
+ export const VERSION = '3.2.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eval-quality",
3
- "version": "3.1.0",
3
+ "version": "3.2.0",
4
4
  "description": "Compile disciplined Behavioral Evaluation Contracts and score their ability to catch known defects.",
5
5
  "author": "Murat Ozcan",
6
6
  "license": "Apache-2.0",
@@ -73,9 +73,9 @@
73
73
  "lint:fix": "biome check --write .",
74
74
  "format": "biome format --write .",
75
75
  "check:docs": "node scripts/check-docs.mjs",
76
- "check:doc-invocations": "node scripts/check-doc-invocations.mjs",
77
- "check:doc-counts": "node scripts/check-doc-counts.ts",
78
- "check:doc-claims": "node scripts/check-doc-claims.ts",
76
+ "check:doc-invocations": "node scripts/gates-cli.ts doc-invocations",
77
+ "check:doc-counts": "node scripts/gates-cli.ts doc-counts",
78
+ "check:doc-claims": "node scripts/gates-cli.ts doc-claims",
79
79
  "lint:spine": "python3 scripts/spine-lint/lint_spine.py --registry-ad 5 --workspace-root . --fail-on high",
80
80
  "test:spine-lint": "uv run --with pytest pytest scripts/spine-lint/tests -q",
81
81
  "build:shareable": "node scripts/build-shareable.mjs",
@@ -103,6 +103,7 @@
103
103
  "check:worked-example": "node scripts/check-worked-example.ts",
104
104
  "generate:version": "node scripts/generate-version.ts",
105
105
  "check:version": "node scripts/check-version.ts",
106
+ "generate:lockfile-age-cache": "node scripts/generate-lockfile-age-cache.ts",
106
107
  "check:lockfile-age": "node dist/gates/gates-cli.js lockfile-age",
107
108
  "check:licences": "node scripts/gates-cli.ts licences",
108
109
  "check:shareable": "node scripts/check-shareable.mjs",