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,1026 @@
1
+ // A published gate: the hand-written prose claims in the pages a consumer
2
+ // names, held against the tree those pages describe. This is the class of
3
+ // sentence that is neither a number nor a fenced command, and that nothing in a
4
+ // normal build reads.
5
+ //
6
+ // It exists because opening one interface kind in this repository made every
7
+ // page describing that kind as refused stale at once, and four of those
8
+ // sentences were found by a person reading. A frontmatter check reads
9
+ // whitespace and never opens a page body; an invocation check judges fenced
10
+ // commands against their declared exit codes; a count check holds numerals. A
11
+ // sentence naming a symbol, transcribing a source list, citing a line, or
12
+ // saying a thing is not yet true fell through all three.
13
+ //
14
+ // Not every prose claim is mechanically decidable, and this gate does not
15
+ // pretend otherwise. Eight classes, each resolving against an artifact in the
16
+ // consumer's own tree, and each driven by its own block in the configuration:
17
+ //
18
+ // 1. Citations. A `path.ts:N` reference resolves to a file, the line is in
19
+ // range, and a symbol the sentence names and the cited file declares sits
20
+ // inside the cited window.
21
+ // 2. Symbols. An identifier a page spells in backticks is declared in the
22
+ // source roots. A mention in a comment does not save a name that was
23
+ // renamed.
24
+ // 3. Transcribed lists. A list a page spells out equals the set a module of
25
+ // yours exports.
26
+ // 4. Time-sensitive claims. A sentence saying a thing is not yet true, is
27
+ // true "today", or pins a reading to a released version, is registered
28
+ // with how it is settled: by a predicate of yours, or by a recorded human
29
+ // reading with the reason no artifact can decide it.
30
+ // 5. Named codes. A code a page says is raised exists in a registry.
31
+ // 6. Worked JSON. A published example block parses against the schema the
32
+ // prose names.
33
+ // 7. Vocabulary. A token a sentence says is accepted is in your accepted set,
34
+ // and one it says is refused is in your refused set.
35
+ // 8. Transcriptions. A page reprinting a string your code emits carries the
36
+ // same bytes.
37
+ //
38
+ // Six of the eight are classes and two are inventories, and the difference
39
+ // decides what the gate promises. Classes 1, 2, 5, 6 and 7 hold every sentence
40
+ // on every page, including one written tomorrow. Classes 3, 4 and 8 hold the
41
+ // sentences somebody enumerated, and what they guarantee is that a listed
42
+ // sentence cannot be rewritten or drift out from under its entry without
43
+ // failing.
44
+ //
45
+ // Class 4 is the one that needs explaining. The truth of "no live server has
46
+ // been scored end to end" is not in any tree, so no check can decide it. What a
47
+ // check can decide is that the sentence exists and is registered, which turns
48
+ // an invisible claim into an enumerated one: a new unproven claim fails until
49
+ // somebody writes down who holds it and why, and a registered claim whose
50
+ // sentence was rewritten fails as a dead entry. That is weaker than deciding
51
+ // the claim and stronger than the nothing that precedes it.
52
+ //
53
+ // What stays outside all eight: editorial judgment, design rationale, anything
54
+ // about the world beyond the tree, any claim about runtime behaviour that only
55
+ // executing the code would settle, and whether a code a page names is the one
56
+ // that surface actually raises, which class 5 does not ask. The report line
57
+ // prints what review holds, so the remainder is visible.
58
+ //
59
+ // The gate never rewrites a page, on the rule that a check able to repair what
60
+ // it checks is not a gate.
61
+ //
62
+ // Run by `node` directly: Node's type stripping erases types only, so no
63
+ // TypeScript enum, namespace, parameter property, or non-type re-export may
64
+ // appear in this file or anything it imports.
65
+ import { realpathSync } from 'node:fs';
66
+ import { lstat, readdir, readFile } from 'node:fs/promises';
67
+ import { resolve, sep } from 'node:path';
68
+ import { z } from 'zod';
69
+ import { compileGlobalPattern, compilePattern, ProsePattern, } from './consumer-pattern.js';
70
+ import { ModuleValue, nameOf, readModuleParser, readModuleStrings, readModuleText, readModuleVerdict, } from './module-value.js';
71
+ import { discoverEntries, RelativePath, ScannedPathList, } from './scanned-paths.js';
72
+ /** A path the configuration named that the gate could not read. */
73
+ export const DOC_CLAIM_PATH = 'EVAL_QUALITY_DOC_CLAIM_PATH';
74
+ const codedError = (code, message) => Object.assign(new Error(message), { code });
75
+ const NonEmpty = z.string().min(1);
76
+ const Extension = z
77
+ .string()
78
+ .regex(/^\.[A-Za-z0-9][A-Za-z0-9.]*$/, 'is not a file extension; write it with its leading dot, as ".ts"');
79
+ /**
80
+ * The identifier shapes a page backticks and the tree ought to declare:
81
+ * camelCase, PascalCase with an inner capital, SCREAMING_SNAKE, and a single
82
+ * PascalCase word, which is what covers a published type name a page names with
83
+ * no citation to hold it.
84
+ *
85
+ * Deliberately narrower than "any backticked word", because a page also
86
+ * backticks value names, channel names, failure codes and file paths, and none
87
+ * of those is a symbol the tree declares. A consumer whose vocabulary differs
88
+ * replaces it.
89
+ */
90
+ const DEFAULT_IDENTIFIER_SHAPE = {
91
+ match: '^(?:[a-z]+[A-Z]|[A-Z][a-z]+[A-Z]|[A-Z][A-Z0-9_]{3,}$|[A-Z][a-z]{3,}$)',
92
+ flags: '',
93
+ };
94
+ const CitationsBlock = z
95
+ .strictObject({
96
+ extensions: z
97
+ .array(Extension)
98
+ .min(1)
99
+ .default(['.ts', '.mjs', '.json'])
100
+ .describe('What a citation may point at.'),
101
+ window: z
102
+ .int()
103
+ .min(0)
104
+ .default(4)
105
+ .describe('How far a citation may drift before it reads as stale. Four lines absorbs a reformat or an inserted comment; a moved declaration is further than that.'),
106
+ unanchored: z
107
+ .array(z.strictObject({
108
+ file: RelativePath,
109
+ citation: NonEmpty.describe('The cited `path:line`, as the page spells it.'),
110
+ reason: NonEmpty.describe('Why the sentence names no symbol the cited file declares. The fix is prose, and the entry is what keeps the remainder counted.'),
111
+ }))
112
+ .default([])
113
+ .describe('Citations held by review. An entry matching no citation fails, so a fixed sentence cannot leave a stale exemption behind.'),
114
+ })
115
+ .describe('Every `path:line` a page cites resolves, is in range, and sits within the window of a symbol the sentence names.');
116
+ const SymbolsBlock = z
117
+ .strictObject({
118
+ shape: ProsePattern.default(DEFAULT_IDENTIFIER_SHAPE).describe('Which backticked tokens read as identifiers the tree should declare.'),
119
+ foreign: z
120
+ .array(z.strictObject({
121
+ token: NonEmpty,
122
+ reason: NonEmpty.describe('Why the page is right to name something the tree does not declare.'),
123
+ }))
124
+ .default([])
125
+ .describe('Identifiers no source root declares and the page is right to name. An entry no page spells fails, so a rename cannot leave one behind.'),
126
+ })
127
+ .describe('Every backticked identifier on a page is declared in the source roots.');
128
+ const ListEntry = z.strictObject({
129
+ file: RelativePath,
130
+ claim: NonEmpty.describe('What the sentence lists, for the failure message.'),
131
+ pattern: ProsePattern.describe('The sentence, with one capture group holding the stretch of prose that spells the list.'),
132
+ tokenShape: z
133
+ .union([ProsePattern, ModuleValue])
134
+ .describe('Which backticked tokens inside that stretch are members: a pattern their spelling matches, or a module export holding the whole vocabulary they come from. Without it a parenthetical the sentence carries for the reader reads as a member and the compare fails on prose.'),
135
+ expected: ModuleValue.describe('The set the source owns.'),
136
+ });
137
+ const CodesBlock = z
138
+ .strictObject({
139
+ pattern: ProsePattern.describe('Where a page says a code is raised, with one capture group holding the code.'),
140
+ registries: z
141
+ .array(ModuleValue)
142
+ .min(1)
143
+ .describe('The lists of codes that exist.'),
144
+ literalInSources: z
145
+ .boolean()
146
+ .default(true)
147
+ .describe('Whether a code spelled as a string literal anywhere in the source roots also counts, which is what covers a code no registry names.'),
148
+ })
149
+ .describe('Every code a page says is raised exists.');
150
+ const FenceEntry = z.strictObject({
151
+ file: RelativePath,
152
+ claim: NonEmpty,
153
+ intro: ProsePattern.describe('The sentence before the fence. The next json block after it is parsed, so the entry survives a paragraph moving.'),
154
+ schema: ModuleValue.describe('The schema the block parses against.'),
155
+ shape: z
156
+ .enum(['one', 'each'])
157
+ .default('one')
158
+ .describe('Whether the block is one value, or a top-level array whose every member parses.'),
159
+ lookahead: z
160
+ .int()
161
+ .min(1)
162
+ .default(3)
163
+ .describe('How far below its own sentence a fence may sit. Unbounded, an entry binds to whatever json block comes next, so an unrelated example inserted between silently retargets the check.'),
164
+ });
165
+ const VERBS_THAT_ACCEPT = [
166
+ 'accepts',
167
+ 'accept',
168
+ 'accepted',
169
+ 'admits',
170
+ 'admit',
171
+ 'admitted',
172
+ 'supports',
173
+ 'support',
174
+ 'supported',
175
+ 'compiles',
176
+ 'compile',
177
+ 'compiled',
178
+ ];
179
+ const VERBS_THAT_REFUSE = [
180
+ 'rejects',
181
+ 'reject',
182
+ 'rejected',
183
+ 'refuses',
184
+ 'refuse',
185
+ 'refused',
186
+ 'stops at compilation',
187
+ ];
188
+ const VocabularyBlock = z
189
+ .strictObject({
190
+ tokens: ModuleValue.describe('The whole vocabulary a sentence may name.'),
191
+ accepted: ModuleValue.describe('The members a sentence may call accepted.'),
192
+ refused: ModuleValue.describe('The members a sentence may call refused.'),
193
+ verbs: z
194
+ .strictObject({
195
+ accepts: z.array(NonEmpty).min(1).default(VERBS_THAT_ACCEPT),
196
+ refuses: z.array(NonEmpty).min(1).default(VERBS_THAT_REFUSE),
197
+ participles: z
198
+ .array(NonEmpty)
199
+ .optional()
200
+ .describe('Which of your verbs are participles, so a bare one reads as past tense and the same form after a present "be" reads as the passive present. Left out, every verb ending in "-ed" is one, which is wrong for a base form spelled that way, "exceed" or "succeed".'),
201
+ })
202
+ .default({ accepts: VERBS_THAT_ACCEPT, refuses: VERBS_THAT_REFUSE })
203
+ .describe('The verbs that classify a token. The vocabulary carries as much of the guarantee as the logic does: a verb missing from both lists leaves its sentence undecided.'),
204
+ })
205
+ .superRefine((block, ctx) => {
206
+ // A verb in both lists is read as a refusal, because the refusal set is
207
+ // what the classifier tests. Every sentence using it would then be judged
208
+ // backwards, and nothing else in the gate would notice.
209
+ const shared = block.verbs.accepts.filter((verb) => block.verbs.refuses.includes(verb));
210
+ if (shared.length > 0) {
211
+ ctx.addIssue({
212
+ code: 'custom',
213
+ path: ['verbs'],
214
+ message: `carries ${shared.join(', ')} in both lists, and a verb in both is read as a refusal`,
215
+ });
216
+ }
217
+ const all = [...block.verbs.accepts, ...block.verbs.refuses];
218
+ const stray = (block.verbs.participles ?? []).filter((verb) => !all.includes(verb));
219
+ if (stray.length === 0)
220
+ return;
221
+ ctx.addIssue({
222
+ code: 'custom',
223
+ path: ['verbs', 'participles'],
224
+ message: `names ${stray.join(', ')}, which neither verb list carries, so nothing would ever be tested against it`,
225
+ });
226
+ })
227
+ .describe('Every sentence saying a member of your vocabulary is accepted or refused agrees with the two sets.');
228
+ const DatedBlock = z
229
+ .strictObject({
230
+ triggers: z
231
+ .array(ProsePattern)
232
+ .min(1)
233
+ .describe('The shapes a claim takes when its truth depends on when it was written. A sentence matching one has to be registered below.'),
234
+ headings: ProsePattern.optional().describe('A heading that says its section is about what has not happened, so every bullet under one is dated whatever words it uses.'),
235
+ claims: z
236
+ .array(z.strictObject({
237
+ file: RelativePath,
238
+ key: NonEmpty.describe('A distinctive stretch of the sentence, matched literally. It names one sentence: a key short enough to match two lets a new and false claim ride in on an existing registration.'),
239
+ settles: z
240
+ .union([z.literal('read'), ModuleValue])
241
+ .describe('How the claim is settled. A predicate is run and a false answer fails the gate. "read" records that no artifact decides it.'),
242
+ reason: NonEmpty.describe('What the predicate reads, or why nothing in the tree can decide it.'),
243
+ }))
244
+ .min(1),
245
+ })
246
+ .describe('Every sentence whose truth depends on when it was written is registered with how it is settled.');
247
+ const TranscriptionEntry = z.strictObject({
248
+ file: RelativePath,
249
+ claim: NonEmpty,
250
+ text: ModuleValue.describe('The bytes the page reprints: a string export, or a function returning one.'),
251
+ });
252
+ /**
253
+ * How many capture groups a pattern has. A `lists` entry reads its first group
254
+ * and a `codes` block reads the code out of its own, so a pattern with none
255
+ * reaches an `undefined` at run time and throws a stack instead of a refusal.
256
+ *
257
+ * The alternation with an empty branch makes the pattern match the empty string,
258
+ * so the result carries one slot per group whatever the subject is.
259
+ */
260
+ const captureGroups = (pattern) => {
261
+ const probe = new RegExp(`${pattern.match}|`, pattern.flags);
262
+ return (probe.exec('')?.length ?? 1) - 1;
263
+ };
264
+ const requireOneGroup = (pattern, ctx, path, reads) => {
265
+ if (captureGroups(pattern) > 0)
266
+ return;
267
+ ctx.addIssue({
268
+ code: 'custom',
269
+ path,
270
+ message: `has no capture group, and ${reads} is read out of the first one`,
271
+ });
272
+ };
273
+ export const DocClaimsSection = z
274
+ .strictObject({
275
+ pages: z
276
+ .array(RelativePath)
277
+ .min(1)
278
+ .describe('The published pages, as files or directories to walk.'),
279
+ generated: z
280
+ .array(NonEmpty)
281
+ .default([])
282
+ .describe('Filename suffixes marking a generated page. A generator writes its own vocabulary, so the enumerated classes skip those pages while the derived classes still read them.'),
283
+ sources: ScannedPathList.optional().describe('The code the pages describe. Citations resolve into it, symbols are declared in it, and a literal code is looked up in it. Required by those three classes and by nothing else, so a section adopting only the enumerated ones leaves it out.'),
284
+ citations: CitationsBlock.optional(),
285
+ symbols: SymbolsBlock.optional(),
286
+ lists: z.array(ListEntry).min(1).optional(),
287
+ codes: CodesBlock.optional(),
288
+ fences: z.array(FenceEntry).min(1).optional(),
289
+ vocabulary: VocabularyBlock.optional(),
290
+ dated: DatedBlock.optional(),
291
+ transcriptions: z.array(TranscriptionEntry).min(1).optional(),
292
+ })
293
+ .superRefine((section, ctx) => {
294
+ // Three classes read the source roots and five do not, so requiring the
295
+ // declaration outright would make a section adopting only transcriptions
296
+ // name and walk a tree nothing looks at.
297
+ const readsSources = [
298
+ section.citations !== undefined && 'citations',
299
+ section.symbols !== undefined && 'symbols',
300
+ section.codes !== undefined && 'codes',
301
+ ].filter((name) => name !== false);
302
+ if (section.sources === undefined && readsSources.length > 0) {
303
+ ctx.addIssue({
304
+ code: 'custom',
305
+ path: ['sources'],
306
+ message: `is absent, and ${readsSources.join(', ')} resolve against it`,
307
+ });
308
+ }
309
+ section.lists?.forEach((entry, index) => {
310
+ requireOneGroup(entry.pattern, ctx, ['lists', index, 'pattern', 'match'], 'the stretch of prose that spells the list');
311
+ });
312
+ if (section.codes !== undefined) {
313
+ requireOneGroup(section.codes.pattern, ctx, ['codes', 'pattern', 'match'], 'the code the page names');
314
+ }
315
+ const classes = [
316
+ section.citations,
317
+ section.symbols,
318
+ section.lists,
319
+ section.codes,
320
+ section.fences,
321
+ section.vocabulary,
322
+ section.dated,
323
+ section.transcriptions,
324
+ ];
325
+ if (classes.some((block) => block !== undefined))
326
+ return;
327
+ ctx.addIssue({
328
+ code: 'custom',
329
+ path: [],
330
+ message: 'declares no class of claim, so the gate would report a pass over nothing; add at least one of citations, symbols, lists, codes, fences, vocabulary, dated or transcriptions',
331
+ });
332
+ })
333
+ .describe('Holds the prose claims in your documentation against your own tree. Each class is a block you opt into, and a section declaring none is refused rather than passing over nothing.');
334
+ const escapeForPattern = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
335
+ /**
336
+ * A `Set` key over two strings. `JSON.stringify` rather than a separator
337
+ * character, because a separator has to be a byte neither half can contain and
338
+ * every such byte is invisible in this file.
339
+ */
340
+ const compositeKey = (...parts) => JSON.stringify(parts);
341
+ const BACKTICKED = /`([A-Za-z_][A-Za-z0-9_]*)`/g;
342
+ /**
343
+ * Matches a declaration. A mention in a comment or a string does not count.
344
+ *
345
+ * The anchor check only demands a citation point at symbols the cited file
346
+ * defines: a sentence naming a value in an example has no line for the citation
347
+ * to be wrong about.
348
+ */
349
+ const declarationPattern = (identifier) => new RegExp(`(?:^|\\s)(?:export\\s+)?(?:async\\s+)?(?:const|let|function|type|class|interface)\\s+${identifier}\\b` +
350
+ `|^\\s*(?:readonly\\s+)?${identifier}\\??:`, 'm');
351
+ const declaresIdentifier = (body, identifier) => declarationPattern(identifier).test(body);
352
+ /** Where a declaration sits, 1-based, for a failure that says where the code went. */
353
+ const declarationLine = (body, identifier) => {
354
+ const found = declarationPattern(identifier).exec(body);
355
+ if (found === null)
356
+ return null;
357
+ return body.slice(0, found.index).split('\n').length;
358
+ };
359
+ /** The sentence around an offset: the enclosing table cell, or the enclosing sentence. */
360
+ const sentenceAround = (line, offset) => {
361
+ const before = line.slice(0, offset);
362
+ const start = Math.max(before.lastIndexOf('. '), before.lastIndexOf('| ')) + 1;
363
+ const rest = line.slice(offset);
364
+ const stop = rest.indexOf('. ');
365
+ const end = stop === -1 ? line.length : offset + stop + 1;
366
+ return line.slice(start, end);
367
+ };
368
+ /**
369
+ * Which backticked tokens in a captured stretch count as list members. A
370
+ * spelling rule covers a set whose members share a shape; a module export covers
371
+ * one whose members do not, and naming the export is what keeps the vocabulary
372
+ * out of the configuration as a second copy of itself.
373
+ */
374
+ async function memberTest(root, shape) {
375
+ if ('match' in shape) {
376
+ const pattern = compilePattern(shape);
377
+ return (token) => pattern.test(token);
378
+ }
379
+ const vocabulary = new Set(await readModuleStrings(root, shape));
380
+ return (token) => vocabulary.has(token);
381
+ }
382
+ /**
383
+ * A directory this walk never descends into. A dependency tree and a tool's own
384
+ * directory carry pages nobody here wrote.
385
+ */
386
+ const isSkipped = (name) => name === 'node_modules' || name.startsWith('.');
387
+ /**
388
+ * The markdown under one declared root. `lstat` rather than `stat`, and a link
389
+ * is refused rather than followed: a directory link pointing at an ancestor
390
+ * recurses until the stack goes, and one pointing outside the tree reads pages
391
+ * the configuration never named.
392
+ */
393
+ const walkPages = async (root, target) => {
394
+ const info = await lstat(resolve(root, target)).catch(() => null);
395
+ if (info === null)
396
+ return [];
397
+ if (info.isSymbolicLink()) {
398
+ throw codedError(DOC_CLAIM_PATH, `${target} is a symbolic link, and this walk does not follow links; name the directory itself under pages`);
399
+ }
400
+ if (info.isFile())
401
+ return target.endsWith('.md') ? [target] : [];
402
+ const entries = await readdir(resolve(root, target));
403
+ const nested = await Promise.all(entries
404
+ .filter((entry) => !isSkipped(entry))
405
+ .map((entry) => walkPages(root, `${target}/${entry}`)));
406
+ return nested.flat();
407
+ };
408
+ export async function runDocClaims(root, section) {
409
+ const failures = [];
410
+ const fail = (message) => {
411
+ failures.push(message);
412
+ };
413
+ // Canonical paths, because `resolve` follows no symlink: a link pointing at
414
+ // the configuration's own directory would otherwise walk straight past this.
415
+ const canonical = (target) => {
416
+ try {
417
+ return realpathSync(target);
418
+ }
419
+ catch {
420
+ return target;
421
+ }
422
+ };
423
+ const configRoot = canonical(resolve(root));
424
+ const found = [];
425
+ for (const page of section.pages) {
426
+ const named = canonical(resolve(root, page));
427
+ const enclosing = named.endsWith(sep) ? named : `${named}${sep}`;
428
+ if (`${configRoot}${configRoot.endsWith(sep) ? '' : sep}`.startsWith(enclosing)) {
429
+ throw codedError(DOC_CLAIM_PATH, `the "doc-claims" section names "${page}" under pages, and that encloses the directory the configuration sits in; name a page or a directory inside it`);
430
+ }
431
+ const reached = await walkPages(root, page);
432
+ // Per root rather than over the whole list: one mistyped entry among
433
+ // several drops its pages silently while the others keep the gate green.
434
+ if (reached.length === 0) {
435
+ throw codedError(DOC_CLAIM_PATH, `"${page}" holds no markdown, and the "doc-claims" section names it under pages; a root that reaches no page is coverage the gate reports as clean`);
436
+ }
437
+ found.push(...reached);
438
+ }
439
+ const pages = [...found].sort();
440
+ const pageText = new Map();
441
+ for (const page of pages) {
442
+ pageText.set(page, (await readFile(resolve(root, page), 'utf8')).split('\n'));
443
+ }
444
+ /**
445
+ * The pages a person writes. A generated page carries its generator's own
446
+ * vocabulary, so a registry of time-sensitive claims over one would be a
447
+ * registry of generator output. The derived classes still read them, because
448
+ * a generator can name a symbol that moved and no byte comparison notices.
449
+ */
450
+ const authoredPages = pages.filter((page) => !section.generated.some((suffix) => page.endsWith(suffix)));
451
+ // A suffix broad enough to match every page, `.md` say, turns the dated,
452
+ // codes and vocabulary classes into checks over nothing while the gate reports
453
+ // each of them as clean.
454
+ if (authoredPages.length === 0) {
455
+ throw codedError(DOC_CLAIM_PATH, `every one of the ${pages.length} page(s) matches a suffix under generated (${section.generated.join(', ')}); the classes that read authored pages would hold nothing`);
456
+ }
457
+ // Walked only when a class reads it. The schema is what guarantees the
458
+ // declaration is there whenever one does.
459
+ const srcBodies = section.sources === undefined
460
+ ? new Map()
461
+ : (await discoverEntries(root, section.sources, 'doc-claims')).entries;
462
+ const srcPaths = [...srcBodies.keys()];
463
+ const allSource = [...srcBodies.values()].join('\n');
464
+ const parts = [];
465
+ // -----------------------------------------------------------------------
466
+ // Class 1: citations
467
+ // -----------------------------------------------------------------------
468
+ if (section.citations !== undefined) {
469
+ const block = section.citations;
470
+ const citation = new RegExp(`\`?((?:[A-Za-z0-9._-]+\\/)*[A-Za-z0-9._-]+\\.(?:${block.extensions
471
+ .map((extension) => escapeForPattern(extension.slice(1)))
472
+ .join('|')})):(\\d+)(?:-(\\d+))?\`?`, 'g');
473
+ const unanchoredSeen = new Set();
474
+ let checked = 0;
475
+ let anchored = 0;
476
+ for (const page of pages) {
477
+ const lines = pageText.get(page);
478
+ // A bare `plan.ts` means the file a fully-qualified citation named
479
+ // earlier on the same page. Two files may share a basename, so without
480
+ // this the short form is ambiguous and the check would refuse a citation
481
+ // a reader resolves without effort.
482
+ const qualified = new Map();
483
+ // A fenced block is output rather than prose, and a diagnostic a page
484
+ // transcribes carries the file and line of whatever tree the command
485
+ // ran over, which is commonly a fixture the page itself created. Read
486
+ // as a citation that path resolves to nothing here and the class fails
487
+ // on a page that is correct. The invocation check already holds a
488
+ // transcribed block against the bytes the run really wrote, so the
489
+ // claim is held either way.
490
+ let fenced = false;
491
+ lines.forEach((line, index) => {
492
+ if (line.trimStart().startsWith('```')) {
493
+ fenced = !fenced;
494
+ return;
495
+ }
496
+ if (fenced)
497
+ return;
498
+ for (const match of line.matchAll(citation)) {
499
+ const cited = match[1];
500
+ const first = Number(match[2]);
501
+ const last = Number(match[3] ?? match[2]);
502
+ const at = `${page}:${index + 1}`;
503
+ const basename = cited.split('/').pop();
504
+ let resolved;
505
+ if (srcBodies.has(cited))
506
+ resolved = cited;
507
+ else if (qualified.has(basename))
508
+ resolved = qualified.get(basename);
509
+ else {
510
+ const candidates = srcPaths.filter((file) => file.endsWith(`/${cited}`));
511
+ if (candidates.length === 1)
512
+ resolved = candidates[0];
513
+ else {
514
+ fail(`${at}: the citation \`${cited}\` resolves to ${candidates.length} files in the source roots; ` +
515
+ 'spell the path from the repository root');
516
+ continue;
517
+ }
518
+ }
519
+ const target = resolved;
520
+ qualified.set(basename, target);
521
+ checked += 1;
522
+ const body = srcBodies.get(target);
523
+ const targetLines = body.split('\n');
524
+ if (targetLines.length < last) {
525
+ fail(`${at}: the citation \`${cited}:${last}\` is past the end of ${target}, ` +
526
+ `which has ${targetLines.length} lines`);
527
+ continue;
528
+ }
529
+ const sentence = sentenceAround(line, match.index ?? 0);
530
+ const named = [...sentence.matchAll(BACKTICKED)]
531
+ .map((found) => found[1])
532
+ .filter((token) => declaresIdentifier(body, token));
533
+ const spanned = `${target}:${match[2]}${match[3] ? `-${match[3]}` : ''}`;
534
+ if (named.length === 0) {
535
+ const exemption = block.unanchored.find((entry) => entry.file === page && spanned.endsWith(entry.citation));
536
+ if (exemption === undefined) {
537
+ fail(`${at}: the citation \`${cited}:${first}\` sits in a sentence naming no symbol ` +
538
+ `${target} declares, so nothing holds the line number; name one, or register it ` +
539
+ 'under citations.unanchored with the reason');
540
+ continue;
541
+ }
542
+ unanchoredSeen.add(compositeKey(exemption.file, exemption.citation));
543
+ continue;
544
+ }
545
+ const window = targetLines
546
+ .slice(Math.max(0, first - 1 - block.window), last + block.window)
547
+ .join('\n');
548
+ const held = named.filter((identifier) => new RegExp(`\\b${identifier}\\b`).test(window));
549
+ if (held.length === 0) {
550
+ const moved = named
551
+ .map((identifier) => ({
552
+ identifier,
553
+ line: declarationLine(body, identifier),
554
+ }))
555
+ .filter((each) => each.line !== null)
556
+ .map((each) => `\`${each.identifier}\` is at ${target}:${each.line}`)
557
+ .join(', ');
558
+ fail(`${at}: the citation \`${cited}:${first}\` names ${named.map((each) => `\`${each}\``).join(', ')} ` +
559
+ `and none of them is within ${block.window} lines of ${target}:${first}; ${moved}`);
560
+ continue;
561
+ }
562
+ anchored += 1;
563
+ }
564
+ });
565
+ }
566
+ for (const entry of block.unanchored) {
567
+ if (unanchoredSeen.has(compositeKey(entry.file, entry.citation)))
568
+ continue;
569
+ fail(`${entry.file}: citations.unanchored names \`${entry.citation}\`, which the page no longer ` +
570
+ 'carries unanchored; drop the entry');
571
+ }
572
+ if (checked === 0) {
573
+ throw codedError(DOC_CLAIM_PATH, 'the citations class examined no citation at all; adopt it only on pages that cite source, or the class reports a pass over nothing');
574
+ }
575
+ parts.push(`${checked} citations resolve (${anchored} anchored on a symbol, ${block.unanchored.length} held by review)`);
576
+ }
577
+ // -----------------------------------------------------------------------
578
+ // Class 2: symbols
579
+ // -----------------------------------------------------------------------
580
+ if (section.symbols !== undefined) {
581
+ const block = section.symbols;
582
+ const shape = compilePattern(block.shape);
583
+ const foreignSeen = new Set();
584
+ const unknown = new Map();
585
+ let checked = 0;
586
+ for (const page of pages) {
587
+ const lines = pageText.get(page);
588
+ lines.forEach((line, index) => {
589
+ for (const match of line.matchAll(BACKTICKED)) {
590
+ const token = match[1];
591
+ if (!shape.test(token))
592
+ continue;
593
+ checked += 1;
594
+ const foreign = block.foreign.find((entry) => entry.token === token);
595
+ if (foreign !== undefined) {
596
+ foreignSeen.add(token);
597
+ continue;
598
+ }
599
+ if (declaresIdentifier(allSource, token))
600
+ continue;
601
+ if (!unknown.has(token))
602
+ unknown.set(token, `${page}:${index + 1}`);
603
+ }
604
+ });
605
+ }
606
+ for (const [token, at] of unknown) {
607
+ fail(`${at}: the page spells \`${token}\`, which nothing in the source roots declares; ` +
608
+ 'it was renamed, removed, or mistyped, or it belongs under symbols.foreign with its reason');
609
+ }
610
+ for (const entry of block.foreign) {
611
+ if (foreignSeen.has(entry.token))
612
+ continue;
613
+ fail(`symbols.foreign names \`${entry.token}\`, which no page spells; drop the entry`);
614
+ }
615
+ if (checked === 0) {
616
+ throw codedError(DOC_CLAIM_PATH, `the symbols class examined no backticked identifier at all; its shape is ${block.shape.match}, and a shape matching nothing switches the class off`);
617
+ }
618
+ parts.push(`${checked} backticked identifiers are declared`);
619
+ }
620
+ // -----------------------------------------------------------------------
621
+ // Class 3: transcribed lists
622
+ // -----------------------------------------------------------------------
623
+ if (section.lists !== undefined) {
624
+ for (const entry of section.lists) {
625
+ const lines = pageText.get(entry.file);
626
+ if (lines === undefined) {
627
+ fail(`${entry.file}: missing, but a list entry names it`);
628
+ continue;
629
+ }
630
+ const text = lines.join('\n');
631
+ // The entry's own flags are carried over, so an entry written with `i`
632
+ // does not silently lose it and fail as dead for a reason nobody finds.
633
+ const flags = [...new Set([...entry.pattern.flags, 'g', 's'])].join('');
634
+ const found = [...text.matchAll(new RegExp(entry.pattern.match, flags))];
635
+ if (found.length !== 1) {
636
+ fail(`${entry.file}: ${found.length} sentences match the pattern for ${entry.claim}; ` +
637
+ 'a list entry names exactly one, so either the sentence or the entry has to move');
638
+ continue;
639
+ }
640
+ const match = found[0];
641
+ const line = text.slice(0, match.index ?? 0).split('\n').length;
642
+ const isMember = await memberTest(root, entry.tokenShape);
643
+ const spelled = [...match[1].matchAll(/`([^`]+)`/g)]
644
+ .map((each) => each[1])
645
+ .filter(isMember)
646
+ .sort();
647
+ const owed = [...(await readModuleStrings(root, entry.expected))].sort();
648
+ const extra = spelled.filter((each) => !owed.includes(each));
649
+ const absent = owed.filter((each) => !spelled.includes(each));
650
+ if (extra.length === 0 && absent.length === 0)
651
+ continue;
652
+ const said = [];
653
+ if (absent.length > 0)
654
+ said.push(`omits ${absent.join(', ')}`);
655
+ if (extra.length > 0)
656
+ said.push(`adds ${extra.join(', ')}`);
657
+ fail(`${entry.file}:${line}: ${entry.claim} ${said.join(' and ')}; ${nameOf(entry.expected)} has ` +
658
+ `${owed.join(', ')}`);
659
+ }
660
+ parts.push(`${section.lists.length} transcribed lists match their source`);
661
+ }
662
+ // -----------------------------------------------------------------------
663
+ // Class 4: time-sensitive claims
664
+ // -----------------------------------------------------------------------
665
+ if (section.dated !== undefined) {
666
+ const block = section.dated;
667
+ const triggers = block.triggers.map(compilePattern);
668
+ const heading = block.headings === undefined ? null : compilePattern(block.headings);
669
+ const seen = new Set();
670
+ let read = 0;
671
+ let derived = 0;
672
+ for (const page of authoredPages) {
673
+ const lines = pageText.get(page);
674
+ let owedSection = false;
675
+ for (const [index, line] of lines.entries()) {
676
+ if (line.startsWith('#')) {
677
+ // A heading names the section rather than making the claim, so it
678
+ // turns the section on and is never a registration of its own.
679
+ owedSection = heading?.test(line) ?? false;
680
+ continue;
681
+ }
682
+ const dated = triggers.some((trigger) => trigger.test(line)) ||
683
+ (owedSection && line.startsWith('- '));
684
+ if (!dated)
685
+ continue;
686
+ const entry = block.claims.find((each) => each.file === page && line.includes(each.key));
687
+ if (entry === undefined) {
688
+ fail(`${page}:${index + 1}: this sentence claims something is true as of now, or not yet ` +
689
+ 'true, and no dated.claims entry holds it; register it with how it is settled');
690
+ continue;
691
+ }
692
+ seen.add(compositeKey(entry.file, entry.key));
693
+ }
694
+ }
695
+ for (const entry of block.claims) {
696
+ // A key names one sentence. Without this an entry keyed on something
697
+ // short registers every later line carrying the same words, so a new and
698
+ // false dated claim rides in on an existing registration.
699
+ const lines = pageText.get(entry.file) ?? [];
700
+ const carrying = lines.filter((line) => line.includes(entry.key)).length;
701
+ if (carrying === 0) {
702
+ fail(`${entry.file}: dated.claims holds "${entry.key}", which the page no longer carries; ` +
703
+ 'the sentence was rewritten, so re-read the claim and move the entry');
704
+ continue;
705
+ }
706
+ if (carrying > 1) {
707
+ fail(`${entry.file}: ${carrying} sentences carry "${entry.key}"; a dated.claims entry names ` +
708
+ 'one, so either the new sentence needs its own entry or the key needs to be longer');
709
+ continue;
710
+ }
711
+ if (!seen.has(compositeKey(entry.file, entry.key))) {
712
+ fail(`${entry.file}: dated.claims holds "${entry.key}", and the sentence carrying it no longer ` +
713
+ 'reads as a dated claim; re-read it and move the entry');
714
+ continue;
715
+ }
716
+ if (entry.settles === 'read') {
717
+ read += 1;
718
+ continue;
719
+ }
720
+ derived += 1;
721
+ if (await readModuleVerdict(root, entry.settles))
722
+ continue;
723
+ fail(`${entry.file}: "${entry.key}" is no longer true; the check that settles it ` +
724
+ `(${entry.reason}) now answers no`);
725
+ }
726
+ parts.push(`${derived + read} time-sensitive claims registered (${derived} settled by a predicate, ${read} by review)`);
727
+ }
728
+ // -----------------------------------------------------------------------
729
+ // Class 5: named codes
730
+ // -----------------------------------------------------------------------
731
+ if (section.codes !== undefined) {
732
+ const block = section.codes;
733
+ const claim = compileGlobalPattern(block.pattern);
734
+ const known = new Set();
735
+ for (const registry of block.registries) {
736
+ for (const code of await readModuleStrings(root, registry)) {
737
+ known.add(code);
738
+ }
739
+ }
740
+ const unknown = new Map();
741
+ let checked = 0;
742
+ for (const page of authoredPages) {
743
+ const lines = pageText.get(page);
744
+ lines.forEach((line, index) => {
745
+ for (const match of line.matchAll(claim)) {
746
+ const token = match[1];
747
+ checked += 1;
748
+ if (known.has(token))
749
+ continue;
750
+ if (block.literalInSources && allSource.includes(`'${token}'`)) {
751
+ continue;
752
+ }
753
+ if (!unknown.has(token))
754
+ unknown.set(token, `${page}:${index + 1}`);
755
+ }
756
+ });
757
+ }
758
+ for (const [token, at] of unknown) {
759
+ fail(`${at}: the page says \`${token}\` is raised, and no registry this section names carries it` +
760
+ `${block.literalInSources ? ' and nothing in the source roots spells it as a string literal' : ''}; ` +
761
+ 'it was renamed, removed, or mistyped');
762
+ }
763
+ // The pattern lives in a configuration file now rather than in source, so a
764
+ // typo in it turns the class off with every gate staying green.
765
+ if (checked === 0) {
766
+ throw codedError(DOC_CLAIM_PATH, `the codes class examined no code at all; nothing on ${authoredPages.length} page(s) matched ${block.pattern.match}`);
767
+ }
768
+ parts.push(`${checked} named codes exist`);
769
+ }
770
+ // -----------------------------------------------------------------------
771
+ // Class 6: worked JSON
772
+ // -----------------------------------------------------------------------
773
+ if (section.fences !== undefined) {
774
+ for (const entry of section.fences) {
775
+ const lines = pageText.get(entry.file);
776
+ if (lines === undefined) {
777
+ fail(`${entry.file}: missing, but a fence entry names it`);
778
+ continue;
779
+ }
780
+ const intro = compilePattern(entry.intro);
781
+ const introAt = lines.findIndex((line) => intro.test(line));
782
+ if (introAt === -1) {
783
+ fail(`${entry.file}: no sentence introduces ${entry.claim}; the entry is dead and either ` +
784
+ 'the sentence or the entry has to move');
785
+ continue;
786
+ }
787
+ const open = lines.findIndex((line, index) => index > introAt &&
788
+ index <= introAt + entry.lookahead &&
789
+ line.trim() === '```json');
790
+ if (open === -1) {
791
+ fail(`${entry.file}:${introAt + 1}: ${entry.claim} is introduced with no json fence after it`);
792
+ continue;
793
+ }
794
+ const close = lines.findIndex((line, index) => index > open && line.trim() === '```');
795
+ if (close === -1) {
796
+ fail(`${entry.file}:${open + 1}: the json fence for ${entry.claim} is never closed`);
797
+ continue;
798
+ }
799
+ const body = lines.slice(open + 1, close).join('\n');
800
+ let value;
801
+ try {
802
+ value = JSON.parse(body);
803
+ }
804
+ catch (error) {
805
+ fail(`${entry.file}:${open + 2}: ${entry.claim} is not valid JSON (${error.message})`);
806
+ continue;
807
+ }
808
+ if (entry.shape === 'each' && !Array.isArray(value)) {
809
+ fail(`${entry.file}:${open + 2}: ${entry.claim} is declared as an array and is not one`);
810
+ continue;
811
+ }
812
+ const parser = await readModuleParser(root, entry.schema);
813
+ const values = entry.shape === 'each' ? value : [value];
814
+ values.forEach((member, index) => {
815
+ const parsed = parser.safeParse(member);
816
+ if (parsed.success)
817
+ return;
818
+ const where = entry.shape === 'each' ? ` member ${index}` : '';
819
+ const reported = flatten(parsed.error?.issues ?? [])
820
+ .slice(0, 3)
821
+ .map((issue) => `${issue.path.join('.') || '<root>'}: ${issue.message}`)
822
+ .join('; ');
823
+ // A parser that refuses and reports nothing still has to say so: an
824
+ // empty parenthesis reads as a gate that found no reason.
825
+ const issues = reported === ''
826
+ ? 'the schema refused it and reported no issue'
827
+ : reported;
828
+ fail(`${entry.file}:${open + 2}: ${entry.claim}${where} does not parse against ${nameOf(entry.schema)} ` +
829
+ `(${issues})`);
830
+ });
831
+ }
832
+ parts.push(`${section.fences.length} worked JSON blocks parse against their schema`);
833
+ }
834
+ // -----------------------------------------------------------------------
835
+ // Class 7: the accepted and refused vocabulary
836
+ // -----------------------------------------------------------------------
837
+ if (section.vocabulary !== undefined) {
838
+ const block = section.vocabulary;
839
+ const tokens = await readModuleStrings(root, block.tokens);
840
+ // An empty vocabulary compiles to a pattern matching the empty string, so
841
+ // the class would classify nothing and report a pass.
842
+ if (tokens.length === 0) {
843
+ throw codedError(DOC_CLAIM_PATH, `${nameOf(block.tokens)} is empty, so the vocabulary class would hold no sentence at all`);
844
+ }
845
+ const accepted = await readModuleStrings(root, block.accepted);
846
+ const refused = await readModuleStrings(root, block.refused);
847
+ const mentions = holdVocabulary(authoredPages, pageText, { tokens, accepted, refused, verbs: block.verbs }, { accepted: nameOf(block.accepted), refused: nameOf(block.refused) }, fail);
848
+ // The class the module header exists for: a sentence saying a member is
849
+ // refused passes every other class. A vocabulary that matches nothing on any
850
+ // page takes that guarantee away silently.
851
+ if (mentions === 0) {
852
+ throw codedError(DOC_CLAIM_PATH, `the vocabulary class classified no mention at all; ${nameOf(block.tokens)} is ${tokens.join(', ')}, and no page spells any of them beside a verb the section names`);
853
+ }
854
+ parts.push(`${mentions} vocabulary mentions agree with the two sets`);
855
+ }
856
+ // -----------------------------------------------------------------------
857
+ // Class 8: transcriptions
858
+ // -----------------------------------------------------------------------
859
+ if (section.transcriptions !== undefined) {
860
+ for (const entry of section.transcriptions) {
861
+ const lines = pageText.get(entry.file);
862
+ if (lines === undefined) {
863
+ fail(`${entry.file}: missing, but a transcription entry names it`);
864
+ continue;
865
+ }
866
+ const text = await readModuleText(root, entry.text);
867
+ // `includes('')` is true of every page, so an empty source is an entry
868
+ // that passes whatever the page carries.
869
+ if (text === '') {
870
+ throw codedError(DOC_CLAIM_PATH, `${nameOf(entry.text)} is empty, and a transcription of nothing matches every page`);
871
+ }
872
+ if (lines.join('\n').includes(text))
873
+ continue;
874
+ const head = text.split('\n')[0];
875
+ const at = lines.indexOf(head);
876
+ fail(`${entry.file}${at === -1 ? '' : `:${at + 1}`}: ${entry.claim} no longer matches ${nameOf(entry.text)}; ` +
877
+ 'a transcription and the thing it reprints have to be the same bytes');
878
+ }
879
+ parts.push(`${section.transcriptions.length} transcriptions match their source byte for byte`);
880
+ }
881
+ // The failure count rides on the summary line. Without it a failing run opens
882
+ // with "13 transcribed lists match their source" directly above the failures
883
+ // saying they do not.
884
+ parts.push(`${failures.length} disagreement(s)`);
885
+ return { failures, summary: parts.join(', ') };
886
+ }
887
+ /**
888
+ * A union reports one opaque message at the root and hides which branch came
889
+ * closest, so the branch errors are pulled up. Without this the failure names
890
+ * the block and nothing inside it, which is the difference between a gate a
891
+ * reader can act on and one they have to re-derive.
892
+ */
893
+ function flatten(issues) {
894
+ return issues.flatMap((issue) => {
895
+ const nested = issue.errors;
896
+ // A discriminated union whose tag is wrong reports `errors: []` and puts
897
+ // the whole message on the issue itself. Recursing into the empty list
898
+ // discards it and the failure prints an empty parenthesis.
899
+ if (nested === undefined || nested.length === 0)
900
+ return [issue];
901
+ const branches = nested
902
+ .map((branch) => flatten(branch))
903
+ .filter((branch) => branch.length > 0);
904
+ if (branches.length === 0)
905
+ return [issue];
906
+ // Deepest path wins. The branch with the fewest issues is the shallowest
907
+ // one, which reports a container's own complaint over the member that is
908
+ // actually malformed.
909
+ const reach = (branch) => Math.max(...branch.map((each) => each.path.length));
910
+ return [...branches].sort((a, b) => reach(b) - reach(a))[0];
911
+ });
912
+ }
913
+ /**
914
+ * The epic's own defect, as a class rather than as a list of sentences.
915
+ *
916
+ * A token is classified by the nearest verb before it in its own sentence, and
917
+ * by a refusal verb after it when nothing precedes it, which is what carries "a
918
+ * contract declaring `web` is rejected". Sentence boundaries stop a verb
919
+ * reaching across a full stop, and a negated or past verb is skipped because
920
+ * the tense makes it a statement about something other than the rule today.
921
+ *
922
+ * What it does not decide, and every one of these passes rather than failing: a
923
+ * token named with no verb near it; a token governed by a verb neither list
924
+ * carries; and a token whose verb the sentence negates or puts in the past, so
925
+ * "`compile` does not accept `mcp`" is false about the tree and passes green.
926
+ *
927
+ * One shape it decides wrongly, recorded rather than chased: a list used as the
928
+ * subject with its verb after it puts the governing verb past the intervening
929
+ * tokens and no span rule reaches it, so the leading members read as refused. It
930
+ * fails a true sentence rather than passing a false one.
931
+ */
932
+ function holdVocabulary(authoredPages, pageText, vocabulary, labels, fail) {
933
+ const tokenInProse = new RegExp(`\`(${vocabulary.tokens.map(escapeForPattern).join('|')})\``, 'g');
934
+ // Longest first, so a multi-word verb and a longer inflection are tried before
935
+ // the shorter spelling they contain.
936
+ const allVerbs = [...vocabulary.verbs.accepts, ...vocabulary.verbs.refuses]
937
+ .slice()
938
+ .sort((a, b) => b.length - a.length);
939
+ const verbPattern = new RegExp(`\\b(${allVerbs.map(escapeForPattern).join('|')})\\b`, 'gi');
940
+ const refuses = new Set(vocabulary.verbs.refuses.map((verb) => verb.toLowerCase()));
941
+ /**
942
+ * A verb the sentence negates or puts in the past decides nothing, and
943
+ * skipping both leaves them undecided.
944
+ */
945
+ const negatedOrPast = /\b(?:not|never|no longer|cannot|used to|nor)\b[^.]{0,24}$/i;
946
+ /**
947
+ * Which verbs are participles: past tense on their own, and the passive
948
+ * present a page uses for a live rule when a present `be` precedes them.
949
+ *
950
+ * The configuration may name them, because the spelling rule the default uses
951
+ * is wrong for a base form ending in `-ed`, "exceed" or "succeed". A consumer
952
+ * with one of those in its vocabulary lists its real participles instead.
953
+ */
954
+ const declared = vocabulary.verbs.participles;
955
+ const participles = declared === undefined
956
+ ? null
957
+ : new Set(declared.map((verb) => verb.toLowerCase()));
958
+ const isParticiple = (verb) => participles === null
959
+ ? /^(?:[a-z]+ed)$/i.test(verb)
960
+ : participles.has(verb.toLowerCase());
961
+ /**
962
+ * The `be` may be a word or two away, and an adverb between the auxiliary and
963
+ * the participle does not make the sentence past.
964
+ */
965
+ const presentBe = /\b(?:is|are|be|been|being)\s+(?:(?:[a-z]+ly|still|now|also|already|then)\s+){0,2}$/i;
966
+ /** Only whitespace and auxiliaries, so the token is the subject of what follows. */
967
+ const subjectGap = /^\s*(?:(?:is|are|be|been|being|still|now|also|then|already)\s+)*$/i;
968
+ let mentions = 0;
969
+ for (const page of authoredPages) {
970
+ const lines = pageText.get(page);
971
+ lines.forEach((line, index) => {
972
+ for (const sentence of line.split(/(?<=\.)\s+/)) {
973
+ const verbs = [...sentence.matchAll(verbPattern)]
974
+ .map((match) => ({
975
+ at: match.index ?? 0,
976
+ verb: match[1],
977
+ refuses: refuses.has(match[1].toLowerCase()),
978
+ }))
979
+ // A backticked verb is a command's name. Read as a verb it governs
980
+ // the token beside it, so "`web` is the one kind `compile` still
981
+ // refuses" would read as acceptance.
982
+ .filter((verb) => !(sentence[verb.at - 1] === '`' &&
983
+ sentence[verb.at + verb.verb.length] === '`'))
984
+ .filter((verb) => !negatedOrPast.test(sentence.slice(0, verb.at)))
985
+ .filter((verb) => !isParticiple(verb.verb) ||
986
+ presentBe.test(sentence.slice(0, verb.at)));
987
+ for (const found of sentence.matchAll(tokenInProse)) {
988
+ const at = found.index ?? 0;
989
+ const before = verbs.filter((verb) => verb.at < at).at(-1);
990
+ const after = verbs.find((verb) => verb.at > at);
991
+ // A verb governs the whole enumeration it opens, so a preceding verb
992
+ // with another token between it and this one wins whatever follows.
993
+ // With nothing between, the nearer verb wins.
994
+ const enumerated = before !== undefined &&
995
+ tokenInProse.test(sentence.slice(before.at, at));
996
+ tokenInProse.lastIndex = 0;
997
+ // An enumerated token is an object of the verb that opened the list,
998
+ // unless the verb after it is its own. What separates the two is the
999
+ // span on the far side: a list member is followed by punctuation and
1000
+ // a subject is followed by an auxiliary.
1001
+ const subjectOfAfter = after !== undefined &&
1002
+ subjectGap.test(sentence.slice(at + found[0].length, after.at));
1003
+ const nearer = before === undefined
1004
+ ? after
1005
+ : after === undefined || at - before.at <= after.at - at
1006
+ ? before
1007
+ : after;
1008
+ const governing = enumerated && !subjectOfAfter ? before : nearer;
1009
+ if (governing === undefined)
1010
+ continue;
1011
+ mentions += 1;
1012
+ const token = found[1];
1013
+ const owed = governing.refuses
1014
+ ? vocabulary.refused
1015
+ : vocabulary.accepted;
1016
+ if (owed.includes(token))
1017
+ continue;
1018
+ fail(`${page}:${index + 1}: the sentence has "${governing.verb}" governing \`${token}\`, and ` +
1019
+ `${governing.refuses ? labels.refused : labels.accepted} ` +
1020
+ `is ${owed.join(', ')}`);
1021
+ }
1022
+ }
1023
+ });
1024
+ }
1025
+ return mentions;
1026
+ }