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.
@@ -0,0 +1,408 @@
1
+ // A published gate: every hand-written count in the pages a consumer names is
2
+ // computed from the thing it counts, rendered the way the page spells it, and
3
+ // compared against the numeral the page carries.
4
+ //
5
+ // It exists because these sentences were held by story discipline alone, and
6
+ // two epics of drift is what that bought. A frontmatter check reads whitespace
7
+ // and never reads a page body, and an invocation check judges fenced commands
8
+ // rather than prose. So a page could state a count no generator owned and
9
+ // nothing would notice.
10
+ //
11
+ // Both halves are the consumer's. A source says where a number comes from: an
12
+ // export of a module, a value in a JSON file, a count of files under a path, or
13
+ // a count of matches in a tree. An entry says which sentence carries it, in
14
+ // which file, and whether the page spells it as a word or as digits.
15
+ //
16
+ // A pattern that matches nothing is a failure, and so is one that matches
17
+ // twice. A rewritten sentence therefore cannot silence the gate by drifting out
18
+ // from under its own pattern. A declared source no entry uses fails for the
19
+ // same reason.
20
+ //
21
+ // The gate never rewrites a page. A check that can repair what it checks is not
22
+ // a gate, and the same rule keeps it out of every file it reads.
23
+ //
24
+ // Run by `node` directly: Node's type stripping erases types only, so no
25
+ // TypeScript enum, namespace, parameter property, or non-type re-export may
26
+ // appear in this file or anything it imports.
27
+ import { readFile } from 'node:fs/promises';
28
+ import { resolve } from 'node:path';
29
+ import { z } from 'zod';
30
+ import { ConsumerPattern, ProsePattern } from './consumer-pattern.js';
31
+ import { ModuleValue, readModuleCount, Take, takeCount, } from './module-value.js';
32
+ import { discoverEntries, RelativePath, ScannedPathList, } from './scanned-paths.js';
33
+ /** A source the configuration declared and the tree could not answer. */
34
+ export const DOC_COUNT_SOURCE = 'EVAL_QUALITY_DOC_COUNT_SOURCE';
35
+ const codedError = (code, message) => Object.assign(new Error(message), { code });
36
+ const detail = (error) => error instanceof Error ? error.message : String(error);
37
+ const NonEmpty = z.string().min(1);
38
+ const SourceName = NonEmpty.regex(/^[A-Za-z][A-Za-z0-9-]*$/, 'is not a source name: letters, digits and hyphens, opening with a letter');
39
+ const ModuleSource = z.strictObject({
40
+ kind: z.literal('module'),
41
+ from: ModuleValue,
42
+ });
43
+ const JsonSource = z.strictObject({
44
+ kind: z.literal('json'),
45
+ file: RelativePath.describe('The JSON file to read.'),
46
+ path: z
47
+ .array(NonEmpty)
48
+ .optional()
49
+ .describe('Keys to walk from the top of the document.'),
50
+ take: Take.default('value').describe('What to take once the walk arrives: the number itself, its length, or the number of its keys.'),
51
+ });
52
+ const FilesSource = z.strictObject({
53
+ kind: z.literal('files'),
54
+ paths: ScannedPathList.describe('The trees whose files are counted.'),
55
+ });
56
+ const MatchesSource = z.strictObject({
57
+ kind: z.literal('matches'),
58
+ paths: ScannedPathList.describe('The trees whose text is searched.'),
59
+ pattern: ConsumerPattern.describe('What is counted. Every occurrence across every file, in one number.'),
60
+ distinct: z
61
+ .boolean()
62
+ .default(false)
63
+ .describe("Whether to count distinct values of the pattern's first capture group instead of occurrences, which is what a set spelled across many files needs."),
64
+ });
65
+ const CountSource = z
66
+ .discriminatedUnion('kind', [
67
+ ModuleSource,
68
+ JsonSource,
69
+ FilesSource,
70
+ MatchesSource,
71
+ ])
72
+ .describe('Where one number comes from.');
73
+ const CountEntry = z.strictObject({
74
+ file: RelativePath.describe('The page or source file carrying the sentence.'),
75
+ claim: NonEmpty.describe('What the sentence claims, for the failure message. It is what a reader is told to go and fix.'),
76
+ pattern: ProsePattern.describe('The sentence, with one capture group per number it carries.'),
77
+ wrap: z
78
+ .boolean()
79
+ .default(false)
80
+ .describe('Whether a literal space in the pattern also matches a line wrap. It never matches a blank line, so a sentence cannot capture a word from the paragraph above it. Spaces inside a bracket expression are left alone.'),
81
+ counts: z
82
+ .array(SourceName)
83
+ .min(1)
84
+ .describe('The sources behind the capture groups, in the order the sentence carries them.'),
85
+ rendering: z
86
+ .enum(['word', 'digits'])
87
+ .default('word')
88
+ .describe('How the page spells the number. Words are rendered from a closed table covering zero to ninety-nine, and the comparison follows the case the page used.'),
89
+ });
90
+ /**
91
+ * A gap inside one paragraph: whitespace that may wrap a line and never crosses
92
+ * a blank one.
93
+ *
94
+ * `\s+` is the obvious spelling and it is wrong in front of a capture group: a
95
+ * blank line is whitespace, so the captured word can sit in the paragraph above
96
+ * the sentence being read, and the gate then compares a number that sentence
97
+ * never states.
98
+ */
99
+ const WRAP = '(?:[^\\S\\n]|\\n(?![ \\t]*\\n))+';
100
+ /** What may follow a literal space and change what widening it would mean. */
101
+ const QUANTIFIER = new Set(['?', '*', '+', '{']);
102
+ /**
103
+ * The pattern with its literal spaces widened into wrap gaps. A bracket
104
+ * expression is copied through untouched, because a space inside one is a
105
+ * member of a character set rather than a gap between words.
106
+ */
107
+ export function widenSpaces(source) {
108
+ let out = '';
109
+ let inClass = false;
110
+ for (let index = 0; index < source.length; index += 1) {
111
+ const char = source[index];
112
+ if (char === '\\') {
113
+ out += char + (source[index + 1] ?? '');
114
+ index += 1;
115
+ continue;
116
+ }
117
+ if (char === '[' && !inClass)
118
+ inClass = true;
119
+ else if (char === ']' && inClass)
120
+ inClass = false;
121
+ if (char === ' ' && !inClass) {
122
+ while (source[index + 1] === ' ')
123
+ index += 1;
124
+ // `' ?'` would become `WRAP?`, which makes the whole gap optional and
125
+ // turns a sentence pattern into one that matches the words run together.
126
+ // Refused rather than quietly widened, because the entry would read as
127
+ // dead and the reason would be invisible.
128
+ if (QUANTIFIER.has(source[index + 1] ?? '')) {
129
+ throw new Error(`a space followed by "${source[index + 1]}" cannot be widened into a wrap gap; write it as \\s* or drop wrap`);
130
+ }
131
+ out += WRAP;
132
+ continue;
133
+ }
134
+ out += char;
135
+ }
136
+ return out;
137
+ }
138
+ const sourceOf = (entry) => entry.wrap ? widenSpaces(entry.pattern.match) : entry.pattern.match;
139
+ /**
140
+ * How many capture groups a pattern has, so an entry whose sentence carries
141
+ * fewer numbers than it names sources is refused at load rather than reading
142
+ * `undefined` off a match.
143
+ *
144
+ * The alternation with an empty branch makes the pattern match the empty string,
145
+ * so the result carries one slot per group whatever the subject is.
146
+ */
147
+ const captureGroups = (source, flags) => {
148
+ const probe = new RegExp(`${source}|`, flags.replace(/[gy]/g, ''));
149
+ return (probe.exec('')?.length ?? 1) - 1;
150
+ };
151
+ export const DocCountsSection = z
152
+ .strictObject({
153
+ sources: z
154
+ .record(SourceName, CountSource)
155
+ .describe('Every number this configuration can hold a page against, by name.'),
156
+ entries: z
157
+ .array(CountEntry)
158
+ .min(1)
159
+ .describe('Every sentence held, one entry apiece.'),
160
+ })
161
+ .superRefine((section, ctx) => {
162
+ const declared = Object.keys(section.sources);
163
+ if (declared.length === 0) {
164
+ ctx.addIssue({
165
+ code: 'custom',
166
+ path: ['sources'],
167
+ message: 'declares no source, so every entry would be held against nothing',
168
+ });
169
+ }
170
+ const used = new Set();
171
+ section.entries.forEach((entry, index) => {
172
+ entry.counts.forEach((name, position) => {
173
+ used.add(name);
174
+ if (declared.includes(name))
175
+ return;
176
+ ctx.addIssue({
177
+ code: 'custom',
178
+ path: ['entries', index, 'counts', position],
179
+ message: `names "${name}", which this section declares no source for; it declares ${declared.join(', ')}`,
180
+ });
181
+ });
182
+ let groups;
183
+ try {
184
+ groups = captureGroups(sourceOf(entry), entry.pattern.flags);
185
+ }
186
+ catch (error) {
187
+ ctx.addIssue({
188
+ code: 'custom',
189
+ path: ['entries', index, 'pattern', 'match'],
190
+ message: `does not compile once its spaces are widened: ${detail(error)}`,
191
+ });
192
+ return;
193
+ }
194
+ if (groups === entry.counts.length)
195
+ return;
196
+ ctx.addIssue({
197
+ code: 'custom',
198
+ path: ['entries', index, 'pattern', 'match'],
199
+ message: `has ${groups} capture group(s) and the entry names ${entry.counts.length} count(s); one group holds one number`,
200
+ });
201
+ });
202
+ for (const name of declared) {
203
+ if (!used.has(name)) {
204
+ ctx.addIssue({
205
+ code: 'custom',
206
+ path: ['sources', name],
207
+ message: 'is declared and no entry uses it; a source nothing reads is a count nobody holds',
208
+ });
209
+ }
210
+ const source = section.sources[name];
211
+ if (source?.kind !== 'matches' || !source.distinct)
212
+ continue;
213
+ // Refused here rather than at the first file read, where the failure is
214
+ // one file's problem rather than the setting's.
215
+ if (captureGroups(source.pattern.match, source.pattern.flags) > 0)
216
+ continue;
217
+ ctx.addIssue({
218
+ code: 'custom',
219
+ path: ['sources', name, 'pattern', 'match'],
220
+ message: 'counts distinct values and has no capture group; the first group is what the distinct values are read from',
221
+ });
222
+ }
223
+ })
224
+ .describe('Holds every hand-written count in your documentation against the thing it counts. A source says where a number comes from and an entry says which sentence carries it; a sentence that drifts out from under its own pattern fails as a dead entry.');
225
+ const ONES = [
226
+ 'zero',
227
+ 'one',
228
+ 'two',
229
+ 'three',
230
+ 'four',
231
+ 'five',
232
+ 'six',
233
+ 'seven',
234
+ 'eight',
235
+ 'nine',
236
+ 'ten',
237
+ 'eleven',
238
+ 'twelve',
239
+ 'thirteen',
240
+ 'fourteen',
241
+ 'fifteen',
242
+ 'sixteen',
243
+ 'seventeen',
244
+ 'eighteen',
245
+ 'nineteen',
246
+ ];
247
+ const TENS = [
248
+ '',
249
+ '',
250
+ 'twenty',
251
+ 'thirty',
252
+ 'forty',
253
+ 'fifty',
254
+ 'sixty',
255
+ 'seventy',
256
+ 'eighty',
257
+ 'ninety',
258
+ ];
259
+ /**
260
+ * The closed word table. Zero to ninety-nine is the range documentation prose
261
+ * uses, and a value past it returns `null` so the report carries it beside every
262
+ * other failure rather than aborting the run at the first one.
263
+ */
264
+ export function inWords(value) {
265
+ if (!Number.isInteger(value) || value < 0 || value > 99)
266
+ return null;
267
+ const ones = ONES[value];
268
+ if (ones !== undefined)
269
+ return ones;
270
+ const tail = value % 10;
271
+ const tens = TENS[Math.floor(value / 10)];
272
+ return tail === 0 ? tens : `${tens}-${ONES[tail]}`;
273
+ }
274
+ /** A page may spell the same count either way, so the compare is case-free. */
275
+ export const matchCaseOf = (carried, owed) => carried.charAt(0) === carried.charAt(0).toUpperCase()
276
+ ? owed.charAt(0).toUpperCase() + owed.slice(1)
277
+ : owed;
278
+ const walkJson = (document, path, where) => {
279
+ let value = document;
280
+ for (const key of path) {
281
+ if (value === null || typeof value !== 'object') {
282
+ throw codedError(DOC_COUNT_SOURCE, `${where}: "${key}" was reached on a value with no properties`);
283
+ }
284
+ const holder = value;
285
+ if (!(key in holder)) {
286
+ throw codedError(DOC_COUNT_SOURCE, `${where}: "${key}" is absent; the keys there are ${Object.keys(holder).sort().join(', ')}`);
287
+ }
288
+ value = holder[key];
289
+ }
290
+ return value;
291
+ };
292
+ /** The number behind one named source. */
293
+ async function resolveSource(root, name, source) {
294
+ if (source.kind === 'module') {
295
+ return readModuleCount(root, source.from);
296
+ }
297
+ if (source.kind === 'json') {
298
+ const where = `the source "${name}": ${source.file}`;
299
+ let document;
300
+ try {
301
+ document = JSON.parse(await readFile(resolve(root, source.file), 'utf8'));
302
+ }
303
+ catch (error) {
304
+ throw codedError(DOC_COUNT_SOURCE, `${where} could not be read as JSON: ${detail(error)}`);
305
+ }
306
+ return takeCount(walkJson(document, source.path ?? [], where), source.take, where);
307
+ }
308
+ const discovered = await discoverEntries(root, source.paths, 'doc-counts');
309
+ if (source.kind === 'files')
310
+ return discovered.entries.size;
311
+ const pattern = new RegExp(source.pattern.match, `${source.pattern.flags}g`);
312
+ if (!source.distinct) {
313
+ let total = 0;
314
+ for (const body of discovered.entries.values()) {
315
+ total += [...body.matchAll(pattern)].length;
316
+ }
317
+ return total;
318
+ }
319
+ const seen = new Set();
320
+ for (const body of discovered.entries.values()) {
321
+ for (const match of body.matchAll(pattern)) {
322
+ // The capture group is guaranteed by the schema; an alternation branch
323
+ // that did not reach it contributes nothing rather than an empty name.
324
+ const captured = match[1];
325
+ if (captured !== undefined)
326
+ seen.add(captured);
327
+ }
328
+ }
329
+ return seen.size;
330
+ }
331
+ const lineOf = (text, offset) => text.slice(0, offset).split('\n').length;
332
+ export async function runDocCounts(root, section) {
333
+ const resolved = new Map();
334
+ for (const [name, source] of Object.entries(section.sources)) {
335
+ resolved.set(name, await resolveSource(root, name, source));
336
+ }
337
+ const failures = [];
338
+ let numerals = 0;
339
+ let digits = 0;
340
+ for (const entry of section.entries) {
341
+ if (entry.rendering === 'digits')
342
+ digits += entry.counts.length;
343
+ else
344
+ numerals += entry.counts.length;
345
+ let text;
346
+ try {
347
+ text = await readFile(resolve(root, entry.file), 'utf8');
348
+ }
349
+ catch {
350
+ failures.push(`${entry.file}: missing, but a count entry names it`);
351
+ continue;
352
+ }
353
+ // The entry's own flags are carried over. Replacing them drops an `m` or
354
+ // an `i` the entry was written with and turns an anchored pattern dead.
355
+ // `g` is never among them, because the schema excludes it.
356
+ const found = [
357
+ ...text.matchAll(new RegExp(sourceOf(entry), `${entry.pattern.flags}g`)),
358
+ ];
359
+ if (found.length === 0) {
360
+ failures.push(`${entry.file}: no sentence matches the pattern for ${entry.claim}; ` +
361
+ 'the entry is dead and either the sentence or the entry has to move');
362
+ continue;
363
+ }
364
+ if (found.length > 1) {
365
+ const lines = found
366
+ .map((match) => lineOf(text, match.index ?? 0))
367
+ .join(', ');
368
+ failures.push(`${entry.file}: ${found.length} sentences match the pattern for ` +
369
+ `${entry.claim} (lines ${lines}); a count entry names one sentence`);
370
+ continue;
371
+ }
372
+ const match = found[0];
373
+ const line = lineOf(text, match.index ?? 0);
374
+ entry.counts.forEach((name, index) => {
375
+ const value = resolved.get(name);
376
+ const carried = match[index + 1];
377
+ // An optional group, or one in an alternation branch the match did not
378
+ // take, leaves the slot empty. The entry is then holding a sentence it
379
+ // cannot read a number out of, which is a dead entry wearing a match.
380
+ if (carried === undefined) {
381
+ failures.push(`${entry.file}:${line}: ${entry.claim} matched, and capture group ${index + 1} took no text; ` +
382
+ 'a group a match can skip holds no number');
383
+ return;
384
+ }
385
+ let owed;
386
+ if (entry.rendering === 'digits')
387
+ owed = String(value);
388
+ else {
389
+ const word = inWords(value);
390
+ if (word === null) {
391
+ failures.push(`${entry.file}:${line}: ${entry.claim} is ${value}, outside the ` +
392
+ "word table's range (0-99); write this one as digits");
393
+ return;
394
+ }
395
+ owed = matchCaseOf(carried, word);
396
+ }
397
+ if (carried === owed)
398
+ return;
399
+ failures.push(`${entry.file}:${line}: ${entry.claim} reads "${carried}" and is ${owed} (${value})`);
400
+ });
401
+ }
402
+ return {
403
+ failures,
404
+ numerals,
405
+ digits,
406
+ files: new Set(section.entries.map((entry) => entry.file)).size,
407
+ };
408
+ }