@brandon_m_behring/book-scaffold-astro 5.0.0 → 5.1.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,1501 +1,27 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * validate.mjs — pre-flight check for book content.
3
+ * Legacy `book-scaffold validate` CLI adapter.
4
4
  *
5
- * Catches authoring errors that astro build either misses or surfaces
6
- * with insufficient context. Designed to run in <2 s on a medium-sized
7
- * book so it's pre-commit-hook friendly.
8
- *
9
- * Checks performed (per Q14 in the v2.0 plan):
10
- * 1. <Cite key="..." /> — key exists in src/data/references.json.
11
- * 2. <XRef id="..." /> — id exists in src/data/labels.json.
12
- * 3. <Figure src="/path/..." /> — file exists under public/.
13
- * 4. Internal markdown links [text](/foo) — target resolves.
14
- * 5. <CodeRef path="..." line={N} /> — when BOOK_REPO_ROOT set,
15
- * path exists + line in bounds.
16
- * 6. <Theorem> — has a resolvable kind= (or legacy type=); else it would
17
- * render an empty label and throw at build (#121). An id'd theorem must
18
- * resolve in labels.json, and a literal n= must agree with that index.
19
- * 7. <BookLink book="…" to="…"> (#96/#147) — both props present, book= is
20
- * registered, and literal fragment targets resolve in the sibling's
21
- * declared vendored labels index. Dynamic and URL-only entries warn/skip.
22
- * 8. Questions collection (#112) — each question's frontmatter `domain` is a
23
- * member of the consumer's examDomains registry (best-effort), and question
24
- * `id`s are unique (the cross-ref key for the appendix / flashcards).
25
- * 9. Learning-objective anchors (#130) — when a chapter declares frontmatter
26
- * `los:` entries with `anchor:` slugs, the declared set and the prose's
27
- * MDX anchor-comment marker set must agree in both directions.
28
- * 10. Authored root-absolute href/src targets (#190) — under a non-root Astro
29
- * base, literal Markdown, HTML, and JSX targets must stay inside that base.
30
- *
31
- * Run from the consumer's project root. Closes #8 (was resolving paths
32
- * from the package's own directory inside node_modules — false negatives
33
- * across all reference consumers).
34
- *
35
- * Usage:
36
- * book-scaffold validate
37
- * book-scaffold validate --preset academic
38
- * BOOK_REPO_ROOT=/abs/path npx book-scaffold validate
39
- *
40
- * Exit code = total failure count capped at 255 (0 = pass, >=1 = errors).
5
+ * Validation itself lives in validate-core.mjs so QA and other package code
6
+ * can call it in-process. This adapter alone owns process I/O, exit status,
7
+ * and the historical child-process artifact self-heal behavior.
41
8
  */
42
- import { readFile, access } from 'node:fs/promises';
43
- import { existsSync, readFileSync } from 'node:fs';
44
- import { resolve, dirname, extname, join } from 'node:path';
45
- import { fileURLToPath } from 'node:url';
46
- import { spawnSync } from 'node:child_process';
47
- import { unified } from 'unified';
48
- import remarkParse from 'remark-parse';
49
- import remarkMath from 'remark-math';
50
- import remarkMdx from 'remark-mdx';
51
- import { walkMdx, readChaptersBase, readBookSchemaConfig } from './walk-mdx.mjs';
52
- import { readEnvFile } from './read-env.mjs';
53
- import { loadResolvedBookConfig } from './resolve-book-config.mjs';
54
- import {
55
- assertCorpusEnvelope,
56
- frontmatterSlug,
57
- legacyFrontmatterBook,
58
- parseFrontmatter,
59
- resolveBookSelection,
60
- } from './corpus-tooling.mjs';
61
- import {
62
- findAuthoredTargets,
63
- normalizeAstroBase,
64
- rootTargetEscapesBase,
65
- rootTargetPathname,
66
- suggestBaseContainedTarget,
67
- } from './authored-links.mjs';
68
-
69
- // --help / -h: non-mutating (closes #14).
70
- const USAGE = `Usage: book-scaffold validate [--preset <name>]
9
+ import { runValidation, VALIDATE_USAGE } from './validate-core.mjs';
10
+ import { regenerateValidationArtifact } from './validation-artifacts.mjs';
71
11
 
72
- Pre-flight content validator. Checks Cite keys, XRef ids, Figure srcs,
73
- internal authored links, and (when BOOK_REPO_ROOT is set) CodeRef paths.
74
-
75
- Options:
76
- --preset <name> academic | tools | minimal | course-notes | research-portfolio
77
- Explicit override when no scaffold integration is resolved.
78
- --book <id> In corpus mode, validate only one registered book.
79
- --help, -h Print this message and exit (non-mutating).
80
-
81
- Env:
82
- BOOK_PRESET Preset name (preferred over BOOK_PROFILE).
83
- BOOK_PROFILE Backward-compat alias for BOOK_PRESET.
84
- BOOK_REPO_ROOT Absolute path to a sibling code repo for CodeRef checks.
85
-
86
- Exit code = total failure count, capped at 255 so failures never wrap to success.
87
- No preset is inferred: configure a Style/corpus, content schema preset, flag, or env.
88
- `;
89
-
90
- if (process.argv.includes('--help') || process.argv.includes('-h')) {
91
- process.stdout.write(USAGE);
92
- process.exit(0);
93
- }
94
-
95
- // --preset <name> CLI flag (closes #9 — single source of truth across
96
- // defineBookConfig + validate).
97
12
  const argv = process.argv.slice(2);
98
- const presetFlagIdx = argv.findIndex((a) => a === '--preset');
99
- const presetFromFlag = presetFlagIdx >= 0 ? argv[presetFlagIdx + 1] : undefined;
100
-
101
- // v3.4.0: ROOT is the consumer's CWD, not the package's own dir.
102
- // Resolves issue #8 — three reference consumers reported "0 chapter(s) checked"
103
- // because ROOT was the package directory inside node_modules.
104
- const ROOT = process.cwd();
105
- const PUBLIC_DIR = resolve(ROOT, 'public');
106
- const DATA_DIR = resolve(ROOT, 'src/data');
107
-
108
- let TOOLING_CONFIG;
109
- try {
110
- TOOLING_CONFIG = await loadResolvedBookConfig(ROOT);
111
- } catch (error) {
112
- process.stderr.write(`validate: fatal: ${error?.message ?? error}\n`);
113
- process.exit(1);
114
- }
115
- let BOOK_SELECTION;
116
- try {
117
- BOOK_SELECTION = resolveBookSelection(TOOLING_CONFIG, argv, 'validate');
118
- } catch (error) {
119
- const prefix = TOOLING_CONFIG.corpus ? '[book:corpus] ' : '';
120
- process.stderr.write(`${prefix}validate: fatal: ${error?.message ?? error}\n`);
121
- process.exit(1);
122
- }
123
- const CORPUS_DIAGNOSTIC_PREFIX = BOOK_SELECTION.corpus ? '[book:corpus] ' : '';
124
- // Corpus content uses one root with a registered first segment per book;
125
- // single-book consumers retain the historical chapters directory.
126
- let CHAPTERS_DIR;
127
- try {
128
- CHAPTERS_DIR = await readChaptersBase(ROOT, { corpus: BOOK_SELECTION.corpus });
129
- } catch (error) {
130
- process.stderr.write(
131
- `${CORPUS_DIAGNOSTIC_PREFIX}validate: fatal: ${error?.message ?? error}\n`,
132
- );
133
- process.exit(1);
134
- }
135
-
136
- // Preset resolution:
137
- // composed Astro-config preset > --preset flag > BOOK_PRESET env > BOOK_PROFILE env >
138
- // .env BOOK_PRESET > .env BOOK_PROFILE >
139
- // defineBookSchemas({ preset }) in content.config.ts >
140
- // defineBookSchemas({ profile }) in content.config.ts (alias).
141
- // .env fallback closes #20 — without it, consumers who set BOOK_PROFILE in
142
- // .env (the documented convenience in SKILL.md + create-book defaults) saw
143
- // the CLI silently default to minimal, hiding academic-profile errors.
144
- // content.config.ts fallback closes #75 — without it, consumers using the
145
- // canonical v4.5+ defineBookSchemas({ preset, chaptersBase }) form had the
146
- // CLI silently default to minimal, hiding research-portfolio (and any
147
- // non-env-set) profile errors while astro build applied the correct settings.
148
- const dotenv = readEnvFile(ROOT);
149
- const schemaConfig = await readBookSchemaConfig(ROOT);
150
- const PRESET_CANDIDATE =
151
- TOOLING_CONFIG.preset ??
152
- presetFromFlag ??
153
- process.env.BOOK_PRESET ??
154
- process.env.BOOK_PROFILE ??
155
- dotenv.BOOK_PRESET ??
156
- dotenv.BOOK_PROFILE ??
157
- schemaConfig.preset;
158
- const PRESETS = ['academic', 'tools', 'minimal', 'course-notes', 'research-portfolio'];
159
- if (presetFlagIdx >= 0 && !presetFromFlag) {
160
- process.stderr.write(`${CORPUS_DIAGNOSTIC_PREFIX}validate: --preset requires a value.\n`);
161
- process.exit(2);
162
- }
163
- if (PRESET_CANDIDATE && !PRESETS.includes(PRESET_CANDIDATE)) {
164
- process.stderr.write(
165
- `${CORPUS_DIAGNOSTIC_PREFIX}validate: preset must be one of ${PRESETS.join(' | ')} ` +
166
- `(got ${JSON.stringify(PRESET_CANDIDATE)}).\n`,
167
- );
168
- process.exit(1);
169
- }
170
- if (!PRESET_CANDIDATE) {
171
- process.stderr.write(
172
- `${CORPUS_DIAGNOSTIC_PREFIX}validate: no book preset was resolved. Add a built-in Style to ` +
173
- '`defineBookConfig({ styles: [...] })` and pass the same preset to ' +
174
- '`defineBookSchemas({ preset: "..." })`, pass one `defineBookCorpus` manifest ' +
175
- 'to both entrypoints, use --preset, or set BOOK_PRESET in the environment or .env. ' +
176
- `Valid presets: ${PRESETS.join(' | ')}. See ` +
177
- 'https://github.com/brandon-behring/book-scaffold-astro/blob/main/' +
178
- 'package/MIGRATION-v4-to-v5.md.\n',
179
- );
180
- process.exit(1);
181
- }
182
- const PRESET = PRESET_CANDIDATE;
183
- // Alias kept for downstream message text only; the resolution above is canonical.
184
- const PROFILE = PRESET;
185
- const MATH_ENABLED = PROFILE === 'academic' || PROFILE === 'research-portfolio';
186
- const REPO_ROOT = process.env.BOOK_REPO_ROOT ?? null;
187
- const corpusDependencyParser = unified().use(remarkParse).use(remarkMdx);
188
- if (MATH_ENABLED) corpusDependencyParser.use(remarkMath);
189
-
190
- let selectedCorpusDependencies = null;
191
- async function resolveSelectedCorpusDependencies() {
192
- if (selectedCorpusDependencies) return selectedCorpusDependencies;
193
-
194
- const routeBooks = new Set(BOOK_SELECTION.books.map((book) => book.id));
195
- const labelBooks = new Set(BOOK_SELECTION.books.map((book) => book.id));
196
- if (!BOOK_SELECTION.corpus || BOOK_SELECTION.requestedBook === null) {
197
- selectedCorpusDependencies = { routeBooks, labelBooks };
198
- return selectedCorpusDependencies;
199
- }
200
-
201
- const selectedBook = BOOK_SELECTION.books[0];
202
- const selectedDir = join(CHAPTERS_DIR, selectedBook.id);
203
- const corpusIds = new Set(BOOK_SELECTION.corpus.books.map((book) => book.id));
204
- const configuredBase = normalizeAstroBase(TOOLING_CONFIG.base);
205
- for await (const file of walkMdx(selectedDir)) {
206
- const content = await readFile(join(selectedDir, file), 'utf8');
207
-
208
- // Root-absolute Markdown chapter links need only the referenced book's
209
- // slug index. Structural collection avoids examples in code/comments.
210
- try {
211
- const targets = findAuthoredTargets(content, {
212
- format: authoredFormat(file),
213
- math: MATH_ENABLED,
214
- });
215
- for (const target of targets) {
216
- if (target.kind !== 'Markdown link destination') continue;
217
- const pathname = rootTargetPathname(target.target);
218
- if (pathname === null) continue;
219
- const localPath =
220
- configuredBase !== '/' &&
221
- (pathname === configuredBase || pathname.startsWith(`${configuredBase}/`))
222
- ? pathname.slice(configuredBase.length) || '/'
223
- : pathname;
224
- const match = localPath.match(/^\/chapters\/([^/]+)(?:\/|$)/);
225
- if (match && corpusIds.has(match[1])) routeBooks.add(match[1]);
226
- }
227
- } catch {
228
- // The main validation pass reports the parse failure with source context.
229
- }
230
-
231
- if (!content.includes('<BookLink')) continue;
232
- try {
233
- for (const element of mdxElements(corpusDependencyParser.parse(content), 'BookLink')) {
234
- const bookAttr = mdxStringProp(element, 'book');
235
- const toAttr = mdxStringProp(element, 'to');
236
- if (!bookAttr.literal || !toAttr.literal || !corpusIds.has(bookAttr.value)) continue;
237
- const target = normalizeCorpusTarget(
238
- BOOK_SELECTION.corpus.books.find((book) => book.id === bookAttr.value),
239
- toAttr.value,
240
- );
241
- if (target.error || !target.chapterSlug) continue;
242
- routeBooks.add(bookAttr.value);
243
- if (target.fragment !== null) labelBooks.add(bookAttr.value);
244
- }
245
- } catch {
246
- // The main validation pass reports the parse failure with source context.
247
- }
248
- }
249
-
250
- selectedCorpusDependencies = { routeBooks, labelBooks };
251
- return selectedCorpusDependencies;
252
- }
253
-
254
- // v4.6.0 (issue #76 Layer 3b): chapter-route shadow warning. Detect a
255
- // consumer-owned `src/pages/chapters/[...slug].astro` that shadows the
256
- // scaffold v4.3.0+ auto-injected route. Non-blocking — emits to stderr,
257
- // validate continues normally. Suppressed when the consumer explicitly
258
- // disabled the scaffold's chapter route (intentional override).
259
- //
260
- // Edge cases per issue #76:
261
- // file + routes.chapters undefined/true → WARN
262
- // file + routes.chapters: false → silent (intentional override)
263
- // no file (any routes config) → silent
264
- //
265
- // Heuristic for "routes.chapters: false": regex-grep astro.config.mjs for
266
- // the literal `chapters: false`. Light-touch detection that matches the
267
- // issue's warning-not-error intent; consumers wanting a stricter detector
268
- // can run `astro check` separately.
269
- {
270
- const consumerChapterRoute = resolve(ROOT, 'src/pages/chapters/[...slug].astro');
271
- if (existsSync(consumerChapterRoute)) {
272
- const astroConfigPath = resolve(ROOT, 'astro.config.mjs');
273
- let chaptersDisabled = false;
274
- if (existsSync(astroConfigPath)) {
275
- const astroConfig = readFileSync(astroConfigPath, 'utf8');
276
- // Match `chapters: false` (with optional whitespace) inside a routes
277
- // object. Slight false-positive risk on commented-out code; acceptable
278
- // for a non-blocking warning.
279
- chaptersDisabled = /\bchapters\s*:\s*false\b/.test(astroConfig);
280
- }
281
- if (!chaptersDisabled) {
282
- console.warn(
283
- (BOOK_SELECTION.corpus
284
- ? '[book:corpus] validate: WARN consumer-owned chapter route at '
285
- : '\n⚠ Consumer-owned chapter route at ') +
286
- `src/pages/chapters/[...slug].astro\n` +
287
- ` shadows the scaffold v4.3.0+ auto-injected route. Either:\n` +
288
- ` • Delete the consumer file to defer to the scaffold (recommended), OR\n` +
289
- ` • Set 'routes: { chapters: false }' in defineBookConfig to keep\n` +
290
- ` your override (intentional).\n` +
291
- ` See: package/recipes/18-chapter-route-ownership.md\n`,
292
- );
293
- }
294
- }
295
- }
296
-
297
- // v4.20.0 (issue #129): the same shadow warning for the landing route. A
298
- // consumer-owned `src/pages/index.astro` collides with the scaffold's
299
- // auto-injected `/` (Astro warns today and has announced a hard error in a
300
- // future major). The escape hatch already exists — `routes: { landing: false }`
301
- // — this check makes it discoverable before Astro's break lands. Same edge
302
- // cases + heuristic as the chapters check above.
303
- {
304
- const consumerLanding = resolve(ROOT, 'src/pages/index.astro');
305
- if (existsSync(consumerLanding)) {
306
- const astroConfigPath = resolve(ROOT, 'astro.config.mjs');
307
- let landingDisabled = false;
308
- if (existsSync(astroConfigPath)) {
309
- const astroConfig = readFileSync(astroConfigPath, 'utf8');
310
- landingDisabled = /\blanding\s*:\s*false\b/.test(astroConfig);
311
- }
312
- if (!landingDisabled) {
313
- console.warn(
314
- (BOOK_SELECTION.corpus
315
- ? '[book:corpus] validate: WARN consumer-owned landing page at '
316
- : '\n⚠ Consumer-owned landing page at ') +
317
- `src/pages/index.astro shadows the\n` +
318
- ` scaffold's auto-injected "/" route. Your page wins today, but Astro\n` +
319
- ` has announced route collisions become a HARD ERROR in a future\n` +
320
- ` version. Set 'routes: { landing: false }' in defineBookConfig to\n` +
321
- ` declare the override and silence the collision.\n` +
322
- ` See: package/recipes/18-chapter-route-ownership.md\n`,
323
- );
324
- }
325
- }
326
- }
327
-
328
- const errors = [];
329
- const warnings = [];
330
- let ACTIVE_BOOK_ID = BOOK_SELECTION.corpus ? 'corpus' : null;
331
- const fail = (file, line, msg, book = ACTIVE_BOOK_ID) => errors.push({ file, line, msg, book });
332
- const warn = (file, line, msg, book = ACTIVE_BOOK_ID) => warnings.push({ file, line, msg, book });
333
- const invalidFrontmatterFiles = new Set();
334
- function recordFrontmatterFailure(error, file, book) {
335
- invalidFrontmatterFiles.add(file);
336
- fail(
337
- file,
338
- error?.frontmatterLine ?? 1,
339
- error?.frontmatterDetail ?? `Invalid YAML frontmatter: ${error?.message ?? error}`,
340
- book,
341
- );
342
- }
343
-
344
- // ===== Self-heal missing generated artifacts (#186) =====
345
- // These files are intentionally gitignored. Direct `book-scaffold validate`
346
- // bypasses npm's prevalidate lifecycle, so rebuild each missing artifact before
347
- // loading it. In selected corpus runs, placeholder-empty requested/dependency
348
- // namespaces are also materialized to keep results independent of run order.
349
- // Child diagnostics and failures are propagated verbatim instead of becoming
350
- // downstream unknown-id noise.
351
- const scriptDir = dirname(fileURLToPath(import.meta.url));
352
- function regenerate(scriptName, artifact, book = null, reason = null) {
353
- const prefix = BOOK_SELECTION.corpus ? '[book:corpus] ' : '';
354
- process.stdout.write(
355
- `${prefix}validate: ${reason ?? `${artifact} is missing — regenerating via ${scriptName} (#186)`}\n`,
356
- );
357
- const childArgs = [join(scriptDir, scriptName)];
358
- if (book) childArgs.push('--book', book);
359
- const result = spawnSync(process.execPath, childArgs, {
360
- cwd: ROOT,
361
- env: process.env,
362
- encoding: 'utf8',
363
- });
364
- if (result.stdout) process.stdout.write(result.stdout);
365
- if (result.stderr) process.stderr.write(result.stderr);
366
- if (result.error || result.status !== 0) {
367
- const detail = result.error ? `: ${result.error.message}` : '';
368
- process.stderr.write(
369
- `${prefix}validate: ${scriptName} failed (exit ${result.status ?? 1})${detail} — cannot self-heal.\n`,
370
- );
371
- process.exit(result.status ?? 1);
372
- }
373
- }
374
-
375
- async function readableCorpusArtifactBooks(path, artifact) {
376
- try {
377
- const value = JSON.parse(await readFile(path, 'utf8'));
378
- return assertCorpusEnvelope(
379
- value,
380
- BOOK_SELECTION.corpus,
381
- artifact,
382
- isRecord,
383
- ).books;
384
- } catch {
385
- return null;
386
- }
387
- }
388
-
389
- function isEmptyRecord(value) {
390
- return isRecord(value) && Object.keys(value).length === 0;
391
- }
392
-
393
- const labelsPath = join(DATA_DIR, 'labels.json');
394
- const materializedLabels = new Set();
395
- let materializedAllLabels = false;
396
- if (!existsSync(labelsPath)) {
397
- regenerate(
398
- 'build-labels.mjs',
399
- 'src/data/labels.json',
400
- BOOK_SELECTION.requestedBook,
401
- );
402
- if (BOOK_SELECTION.requestedBook) materializedLabels.add(BOOK_SELECTION.requestedBook);
403
- else if (BOOK_SELECTION.corpus) materializedAllLabels = true;
404
- }
405
- if (BOOK_SELECTION.requestedBook) {
406
- const dependencies = await resolveSelectedCorpusDependencies();
407
- const books = await readableCorpusArtifactBooks(labelsPath, 'src/data/labels.json');
408
- if (books) {
409
- for (const book of dependencies.labelBooks) {
410
- if (materializedLabels.has(book) || !isEmptyRecord(books[book])) continue;
411
- regenerate(
412
- 'build-labels.mjs',
413
- 'src/data/labels.json',
414
- book,
415
- `${book === BOOK_SELECTION.requestedBook
416
- ? `selected book ${book}`
417
- : `local BookLink from ${BOOK_SELECTION.requestedBook} requires ${book}`} ` +
418
- 'has an empty labels namespace — materializing it (#186)',
419
- );
420
- }
421
- }
422
- } else if (BOOK_SELECTION.corpus && !materializedAllLabels) {
423
- const books = await readableCorpusArtifactBooks(labelsPath, 'src/data/labels.json');
424
- if (books && BOOK_SELECTION.corpus.books.some((book) => isEmptyRecord(books[book.id]))) {
425
- regenerate(
426
- 'build-labels.mjs',
427
- 'src/data/labels.json',
428
- null,
429
- 'corpus labels contain placeholder-empty namespaces — rebuilding all books (#186)',
430
- );
431
- }
432
- }
433
-
434
- const referencesPath = join(DATA_DIR, 'references.json');
435
- let materializedReferences = false;
436
- let materializedAllReferences = false;
437
- if (!existsSync(referencesPath)) {
438
- regenerate(
439
- 'build-bib.mjs',
440
- 'src/data/references.json',
441
- BOOK_SELECTION.requestedBook,
442
- );
443
- materializedReferences = BOOK_SELECTION.requestedBook !== null;
444
- materializedAllReferences = Boolean(BOOK_SELECTION.corpus && !BOOK_SELECTION.requestedBook);
445
- }
446
- if (BOOK_SELECTION.requestedBook) {
447
- if (!materializedReferences) {
448
- const books = await readableCorpusArtifactBooks(
449
- referencesPath,
450
- 'src/data/references.json',
451
- );
452
- if (books && isEmptyRecord(books[BOOK_SELECTION.requestedBook])) {
453
- regenerate(
454
- 'build-bib.mjs',
455
- 'src/data/references.json',
456
- BOOK_SELECTION.requestedBook,
457
- `selected book ${BOOK_SELECTION.requestedBook} has an empty references namespace — ` +
458
- 'materializing it (#186)',
459
- );
460
- }
461
- }
462
- } else if (BOOK_SELECTION.corpus && !materializedAllReferences) {
463
- const books = await readableCorpusArtifactBooks(
464
- referencesPath,
465
- 'src/data/references.json',
466
- );
467
- if (books && BOOK_SELECTION.corpus.books.some((book) => isEmptyRecord(books[book.id]))) {
468
- regenerate(
469
- 'build-bib.mjs',
470
- 'src/data/references.json',
471
- null,
472
- 'corpus references contain placeholder-empty namespaces — rebuilding all books (#186)',
473
- );
474
- }
475
- }
476
-
477
- // ===== Load reference data (graceful when missing) =====
478
- async function loadJson(path) {
479
- try {
480
- return JSON.parse(await readFile(path, 'utf8'));
481
- } catch {
482
- return {};
483
- }
484
- }
485
- function isRecord(value) {
486
- return value !== null && typeof value === 'object' && !Array.isArray(value);
487
- }
488
-
489
- async function loadGeneratedArtifact(name) {
490
- const path = join(DATA_DIR, name);
491
- if (!BOOK_SELECTION.corpus) return loadJson(path);
492
- try {
493
- const parsed = JSON.parse(await readFile(path, 'utf8'));
494
- return assertCorpusEnvelope(parsed, BOOK_SELECTION.corpus, `src/data/${name}`, isRecord);
495
- } catch (error) {
496
- process.stderr.write(
497
- `[book:corpus] validate: fatal: ${error?.message ?? error}. ` +
498
- `Run book-scaffold ${name === 'labels.json' ? 'build-labels' : 'build-bib'} ` +
499
- 'to regenerate the corpus envelope.\n',
500
- );
501
- process.exit(1);
502
- }
503
- }
504
-
505
- const refsArtifact = await loadGeneratedArtifact('references.json');
506
- const labelsArtifact = await loadGeneratedArtifact('labels.json');
507
-
508
- // #147: eagerly load every labels index explicitly declared by the evaluated
509
- // siblingBooks registry. Unlike this book's generated labels.json, sibling
510
- // indexes are vendored inputs: never self-heal or silently replace them. A
511
- // missing, unreadable, malformed, or non-object index is a configuration error
512
- // even when no current chapter happens to reference that sibling.
513
- const siblingLabelIndexes = new Map();
514
- for (const [book, entry] of Object.entries(TOOLING_CONFIG.siblingBooks)) {
515
- if (typeof entry === 'string' || entry.labels === undefined) continue;
516
- const indexPath = resolve(ROOT, entry.labels);
517
- try {
518
- const index = JSON.parse(await readFile(indexPath, 'utf8'));
519
- if (index === null || typeof index !== 'object' || Array.isArray(index)) {
520
- throw new Error('top-level JSON value must be an object');
521
- }
522
- siblingLabelIndexes.set(book, { index, configuredPath: entry.labels });
523
- } catch (error) {
524
- const detail = error instanceof Error ? error.message : String(error);
525
- fail(
526
- 'astro.config',
527
- 1,
528
- `siblingBooks.${book}.labels (${JSON.stringify(entry.labels)}) is missing, unreadable, or invalid: ${detail}. ` +
529
- 'Vendor a readable sibling labels.json at that path or remove labels to opt out with a warning.',
530
- );
531
- }
532
- }
533
-
534
- // ===== Collect chapter files =====
535
- // v3.7.1 (closes #52): walkMdx (in ./walk-mdx.mjs) is a recursive readdir
536
- // walker that replaces the previous `glob` import from `node:fs/promises`.
537
- // The `glob` API was added in Node 22 but consumer CI templates ship
538
- // Node 20 — `npm run validate` crashed on every consumer's prebuild hook.
539
- // Walker uses readdir + path only; works on Node 18+.
540
- const chapterFiles = [];
541
- const chapterBookByFile = new Map();
542
- const chapterCountsByBook = new Map();
543
- if (BOOK_SELECTION.corpus) {
544
- for (const book of BOOK_SELECTION.books) {
545
- let count = 0;
546
- for await (const file of walkMdx(join(CHAPTERS_DIR, book.id))) {
547
- if (file.split('/').pop().startsWith('_')) continue;
548
- const scoped = `${book.id}/${file}`;
549
- chapterFiles.push(scoped);
550
- chapterBookByFile.set(scoped, book.id);
551
- count += 1;
552
- }
553
- chapterCountsByBook.set(book.id, count);
554
- }
555
- } else {
556
- for await (const file of walkMdx(CHAPTERS_DIR)) {
557
- if (!file.split('/').pop().startsWith('_')) chapterFiles.push(file);
558
- }
559
- }
560
-
561
- function bookArtifacts(rel) {
562
- const book = chapterBookByFile.get(rel) ?? null;
563
- if (!book) return { book: null, refs: refsArtifact, labels: labelsArtifact };
564
- return {
565
- book,
566
- refs: refsArtifact.books[book],
567
- labels: labelsArtifact.books[book],
568
- };
569
- }
570
-
571
- // ===== Build slug set from chapter filenames (for internal-link check) =====
572
- const validSlugs = new Set(
573
- BOOK_SELECTION.corpus
574
- ? []
575
- : chapterFiles.map((file) => file.replace(/\.(md|mdx)$/, '')),
576
- );
577
- if (BOOK_SELECTION.corpus) {
578
- // Full validation indexes every book. A selected run indexes its own book
579
- // plus literal chapter-link dependencies only, so unrelated malformed
580
- // content cannot make `validate --book <id>` cache-dependent.
581
- const dependencies = await resolveSelectedCorpusDependencies();
582
- for (const book of BOOK_SELECTION.corpus.books.filter((candidate) =>
583
- dependencies.routeBooks.has(candidate.id))) {
584
- for await (const file of walkMdx(join(CHAPTERS_DIR, book.id))) {
585
- if (!file.split('/').pop().startsWith('_')) {
586
- const fileLabel = `[book:${book.id}] ${book.id}/${file}`;
587
- const source = await readFile(join(CHAPTERS_DIR, book.id, file), 'utf8');
588
- try {
589
- const localId = frontmatterSlug(source, fileLabel) ?? file.replace(/\.(md|mdx)$/, '');
590
- validSlugs.add(`${book.id}/${localId}`);
591
- } catch (error) {
592
- recordFrontmatterFailure(error, `${book.id}/${file}`, book.id);
593
- }
594
- }
595
- }
596
- }
597
- }
598
- const validTopLevelRoutes = new Set(
599
- BOOK_SELECTION.corpus
600
- ? ['/', '/chapters/', '/search/']
601
- : ['/', '/chapters/', '/references/', '/search/', '/print/', '/convergence/'],
602
- );
603
- const KNOWN_CORPUS_APPARATUS_ROUTES = new Set([
604
- 'references',
605
- 'print',
606
- 'convergence',
607
- 'tips',
608
- 'exercises',
609
- 'glossary',
610
- 'practice-exam',
611
- 'flashcards',
612
- 'answers',
613
- ]);
614
- if (BOOK_SELECTION.corpus) {
615
- for (const book of BOOK_SELECTION.corpus.books) {
616
- validTopLevelRoutes.add(`/${book.id}/`);
617
- validTopLevelRoutes.add(`/chapters/${book.id}/`);
618
- for (const route of book.apparatus ?? TOOLING_CONFIG.apparatusRoutes) {
619
- validTopLevelRoutes.add(`/${book.id}/${route}/`);
620
- }
621
- }
622
- }
623
-
624
- // ===== Pattern helpers for component-specific checks =====
625
- const RE_CITE = /<Cite[^>]+key=["']([^"']+)["']/g;
626
- const RE_XREF = /<XRef[^>]+id=["']([^"']+)["']/g;
627
- const RE_FIGURE = /<Figure[^>]+src=["']([^"']+)["']/g;
628
- const RE_CODEREF = /<CodeRef[^>]+path=["']([^"']+)["'](?:[^>]*line=\{(\d+)\})?(?:[^>]*lineEnd=\{(\d+)\})?/g;
629
- // #121: a <Theorem> opening tag — capture its attributes to assert a
630
- // resolvable kind= (or legacy type=) is present.
631
- const RE_THEOREM = /<Theorem\b([^>]*)>/g;
632
- // #96/#147: BookLink attributes must be read structurally. An opening-tag
633
- // regex stops at `>` inside a quoted value or expression and can mistake text
634
- // inside another prop for a real book=/to= assignment.
635
- const mdxParser = unified().use(remarkParse).use(remarkMdx);
636
- if (MATH_ENABLED) mdxParser.use(remarkMath);
637
-
638
- async function fileExists(p) {
639
- try {
640
- await access(p);
641
- return true;
642
- } catch {
643
- return false;
644
- }
645
- }
646
-
647
- function lineOf(content, idx) {
648
- return content.slice(0, idx).split('\n').length;
649
- }
650
-
651
- const ASTRO_BASE = normalizeAstroBase(TOOLING_CONFIG.base);
652
-
653
- function authoredFormat(file) {
654
- return extname(file).toLowerCase() === '.md' ? 'md' : 'mdx';
655
- }
656
-
657
- function collectAuthoredTargets(file, content) {
658
- try {
659
- return findAuthoredTargets(content, {
660
- format: authoredFormat(file),
661
- math: MATH_ENABLED,
662
- });
663
- } catch (error) {
664
- const line = error?.line ?? error?.place?.start?.line ?? error?.position?.start?.line ?? 1;
665
- const detail = String(error?.reason ?? error?.message ?? error).split('\n')[0];
666
- fail(file, line, `Could not parse authored links as ${authoredFormat(file).toUpperCase()}: ${detail}`);
667
- return [];
668
- }
669
- }
670
-
671
- function validateAuthoredTargets(file, content, targets) {
672
- for (const violation of targets) {
673
- if (!rootTargetEscapesBase(violation.target, ASTRO_BASE)) continue;
674
- const suggested = suggestBaseContainedTarget(violation.target, ASTRO_BASE);
675
- fail(
676
- file,
677
- lineOf(content, violation.index),
678
- `Authored ${violation.kind} ${JSON.stringify(violation.target)} escapes configured Astro base ` +
679
- `${JSON.stringify(ASTRO_BASE)}. Use ${JSON.stringify(suggested)}, ` +
680
- 'import.meta.env.BASE_URL in JSX, or a base-aware component. ' +
681
- 'Validation does not rewrite authored URLs (#190).',
682
- );
683
- }
684
- }
685
-
686
- function validateInternalMarkdownLinks(file, content, targets) {
687
- for (const authored of targets) {
688
- if (authored.kind !== 'Markdown link destination') continue;
689
- const pathname = rootTargetPathname(authored.target);
690
- if (pathname === null) continue;
691
- const baseRelativeTarget =
692
- ASTRO_BASE !== '/' && (pathname === ASTRO_BASE || pathname.startsWith(`${ASTRO_BASE}/`))
693
- ? pathname.slice(ASTRO_BASE.length) || '/'
694
- : pathname;
695
- const target = baseRelativeTarget.replace(/\/$/, '') || '/';
696
- if (validTopLevelRoutes.has(`${target}/`) || validTopLevelRoutes.has(target)) continue;
697
- const chMatch = target.match(/^\/chapters\/(.+)$/);
698
- if (chMatch && validSlugs.has(chMatch[1])) continue;
699
- warn(
700
- file,
701
- lineOf(content, authored.index),
702
- `Internal link ${JSON.stringify(authored.target)} — target may not resolve (check spelling or route)`,
703
- );
704
- }
705
- }
706
-
707
- /**
708
- * Return a statically knowable n= value. Quoted strings and braced
709
- * string/numeric literals are safe to compare; identifiers, calls,
710
- * interpolation, and all other expressions are deliberately skipped.
711
- */
712
- function literalTheoremNumber(attrs) {
713
- const quoted = attrs.match(/\bn\s*=\s*(["'])(.*?)\1/);
714
- if (quoted) return quoted[2];
715
-
716
- const braced = attrs.match(/\bn\s*=\s*\{\s*([^}]*?)\s*\}/);
717
- if (!braced) return null;
718
- const expression = braced[1].trim();
719
- const stringLiteral = expression.match(/^(["'`])([\s\S]*)\1$/);
720
- if (stringLiteral) {
721
- if (stringLiteral[1] === '`' && stringLiteral[2].includes('${')) return null;
722
- return stringLiteral[2];
723
- }
724
- if (/^[+-]?(?:\d+(?:\.\d+)?|\.\d+)$/.test(expression)) {
725
- return String(Number(expression));
726
- }
727
- return null;
728
- }
729
-
730
- /** Yield MDX JSX elements with an exact component name, excluding examples in
731
- * fenced/inline code and strings in expressions by construction. */
732
- function* mdxElements(node, name) {
733
- if (
734
- (node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement') &&
735
- node.name === name
736
- ) {
737
- yield node;
738
- }
739
- if (!Array.isArray(node.children)) return;
740
- for (const child of node.children) yield* mdxElements(child, name);
741
- }
742
-
743
- function expressionString(value) {
744
- const program = value?.data?.estree;
745
- if (program?.body?.length !== 1 || program.body[0].type !== 'ExpressionStatement') {
746
- return null;
747
- }
748
- const expression = program.body[0].expression;
749
- if (expression.type === 'Literal' && typeof expression.value === 'string') {
750
- return expression.value;
751
- }
752
- if (expression.type === 'TemplateLiteral' && expression.expressions.length === 0) {
753
- return expression.quasis[0]?.value?.cooked ?? expression.quasis[0]?.value?.raw ?? '';
754
- }
755
- return null;
756
- }
757
-
758
- /**
759
- * Evaluate one JSX prop in source order. Later explicit attributes override an
760
- * earlier spread; a later spread makes the value dynamic, exactly as JSX does.
761
- * Literal ESTree values are already decoded, so entities and JS escapes cannot
762
- * disguise the href that the component receives at runtime.
763
- */
764
- function mdxStringProp(element, name) {
765
- let result = { present: false, literal: false, value: null, source: 'absent' };
766
- for (const attribute of element.attributes) {
767
- if (attribute.type === 'mdxJsxExpressionAttribute') {
768
- result = { present: true, literal: false, value: null, source: 'spread' };
769
- continue;
770
- }
771
- if (attribute.type !== 'mdxJsxAttribute' || attribute.name !== name) continue;
772
- if (typeof attribute.value === 'string') {
773
- result = { present: true, literal: true, value: attribute.value, source: 'attribute' };
774
- continue;
775
- }
776
- const value = expressionString(attribute.value);
777
- result = value === null
778
- ? { present: true, literal: false, value: null, source: 'expression' }
779
- : { present: true, literal: true, value, source: 'attribute' };
780
- }
781
- return result;
782
- }
783
-
784
- /**
785
- * Canonical comparison shape for a sibling labels href. The generated index
786
- * intentionally omits the deployment base, so only normalize route seams:
787
- * leading slashes and the optional trailing slash immediately before `#`.
788
- */
789
- function normalizeSiblingTarget(value) {
790
- const hash = value.indexOf('#');
791
- if (hash < 0 || hash === value.length - 1) return null;
792
- const fragment = value.slice(hash + 1);
793
- const path = value.slice(0, hash).trim().replace(/^\/+/, '').replace(/\/+$/, '');
794
- return { fragment, href: `${path}#${fragment}` };
795
- }
796
-
797
- /**
798
- * Heading keys in labels.json are deliberately opaque and path-qualified so
799
- * `#summary` can exist in every chapter. Resolve sibling targets from href
800
- * values, while remaining compatible with historical component-id keys.
801
- */
802
- function siblingTargetCandidates(index, fragment) {
803
- const candidates = [];
804
- for (const [key, entry] of Object.entries(index)) {
805
- if (entry === null || typeof entry !== 'object' || typeof entry.href !== 'string') continue;
806
- const normalized = normalizeSiblingTarget(entry.href);
807
- if (normalized?.fragment === fragment) {
808
- candidates.push({ key, href: entry.href, normalized });
809
- }
810
- }
811
- return candidates;
812
- }
813
-
814
- function normalizeCorpusTarget(book, to) {
815
- if (typeof to !== 'string' || to.trim().length === 0) {
816
- return { error: 'target must be non-empty' };
817
- }
818
- if (to !== to.trim()) return { error: 'surrounding whitespace is not allowed' };
819
- if (/^[a-z][a-z0-9+.-]*:/i.test(to) || to.startsWith('/') || to.includes('\\')) {
820
- return { error: 'absolute URLs and paths are not allowed' };
821
- }
822
- if (/[\u0000-\u001f\u007f]/.test(to)) return { error: 'control characters are not allowed' };
823
-
824
- const query = to.indexOf('?');
825
- const hash = to.indexOf('#');
826
- const suffixIndex = [query, hash]
827
- .filter((index) => index >= 0)
828
- .reduce((minimum, index) => Math.min(minimum, index), to.length);
829
- const path = to.slice(0, suffixIndex).replace(/\/+$/, '');
830
- if (!path) return { error: 'query-only and fragment-only targets are not allowed' };
831
- const segments = path.split('/');
832
- if (segments.some((segment) => segment.length === 0)) {
833
- return { error: 'empty path segments are not allowed' };
834
- }
835
- for (const segment of segments) {
836
- let decoded;
837
- try {
838
- decoded = decodeURIComponent(segment);
839
- } catch {
840
- return { error: 'malformed percent encoding' };
841
- }
842
- if (decoded === '.' || decoded === '..' || decoded.includes('/') || decoded.includes('\\')) {
843
- return { error: 'path traversal is not allowed' };
844
- }
845
- }
846
-
847
- const localPath = segments.join('/');
848
- const canonicalPath = localPath === 'chapters'
849
- ? `chapters/${book.id}`
850
- : localPath.startsWith('chapters/')
851
- ? `chapters/${book.id}/${localPath.slice('chapters/'.length)}`
852
- : `${book.id}/${localPath}`;
853
- const fragment = hash >= 0 ? to.slice(hash + 1) : null;
854
- return {
855
- error: null,
856
- localPath,
857
- canonicalPath,
858
- fragment,
859
- href: fragment ? `${canonicalPath}#${fragment}` : canonicalPath,
860
- chapterSlug: localPath.startsWith('chapters/')
861
- ? `${book.id}/${localPath.slice('chapters/'.length)}`
862
- : null,
863
- };
864
- }
865
-
866
- // ===== Run all checks on each chapter =====
867
- for (const rel of chapterFiles) {
868
- const abs = join(CHAPTERS_DIR, rel);
869
- const content = await readFile(abs, 'utf8');
870
- const artifacts = bookArtifacts(rel);
871
- const { refs, labels } = artifacts;
872
- ACTIVE_BOOK_ID = artifacts.book;
873
- if (artifacts.book && !invalidFrontmatterFiles.has(rel)) {
874
- try {
875
- const legacy = legacyFrontmatterBook(content, `[book:${artifacts.book}] ${rel}`);
876
- if (legacy && legacy.value !== artifacts.book) {
877
- fail(
878
- rel,
879
- legacy.line,
880
- `frontmatter book ${JSON.stringify(legacy.value)} does not match ` +
881
- `path-derived corpus book ${JSON.stringify(artifacts.book)}.`,
882
- );
883
- }
884
- } catch (error) {
885
- recordFrontmatterFailure(error, rel, artifacts.book);
886
- }
887
- }
888
- const authoredTargets = collectAuthoredTargets(rel, content);
889
-
890
- // 10. Root-absolute authored targets under a non-root Astro base (#190).
891
- // Structural parsing covers Markdown link/image destinations, HTML
892
- // href/src, and decoded static JSX strings while excluding code examples.
893
- validateAuthoredTargets(rel, content, authoredTargets);
894
-
895
- let bookLinks = [];
896
- if (content.includes('<BookLink')) {
897
- try {
898
- bookLinks = [...mdxElements(mdxParser.parse(content), 'BookLink')];
899
- } catch (error) {
900
- const line = error?.position?.start?.line ?? error?.line ?? 1;
901
- fail(
902
- rel,
903
- line,
904
- `Cannot structurally validate <BookLink>: ${error?.reason ?? error?.message ?? error}`,
905
- );
906
- }
907
- }
908
-
909
- // 1. Cite keys (academic profile only — tools profile uses YAML manifest)
910
- if (PROFILE === 'academic') {
911
- for (const m of content.matchAll(RE_CITE)) {
912
- if (!refs[m[1]]) fail(rel, lineOf(content, m.index), `Unknown bibkey "${m[1]}" — not in references.json`);
913
- }
914
- }
915
-
916
- // 2. XRef ids
917
- for (const m of content.matchAll(RE_XREF)) {
918
- if (!labels[m[1]]) fail(rel, lineOf(content, m.index), `Unknown XRef id "${m[1]}" — not in labels.json`);
919
- }
920
-
921
- // 3. Figure src exists in public/
922
- for (const m of content.matchAll(RE_FIGURE)) {
923
- const src = m[1];
924
- if (src.startsWith('http')) continue; // external image
925
- const path = src.startsWith('/') ? join(PUBLIC_DIR, src) : join(dirname(abs), src);
926
- if (!(await fileExists(path))) {
927
- fail(rel, lineOf(content, m.index), `Figure src "${src}" not found at ${path}`);
928
- }
929
- }
930
-
931
- // 4. Internal Markdown links resolve. Reuse the structural authored-target
932
- // traversal so code examples and comments cannot create false warnings.
933
- validateInternalMarkdownLinks(rel, content, authoredTargets);
934
-
935
- // 5. CodeRef path + line bounds (only when BOOK_REPO_ROOT set)
936
- if (REPO_ROOT) {
937
- for (const m of content.matchAll(RE_CODEREF)) {
938
- const [, path, lineStart, lineEnd] = m;
939
- const abs2 = resolve(REPO_ROOT, path);
940
- if (!(await fileExists(abs2))) {
941
- fail(rel, lineOf(content, m.index), `CodeRef path "${path}" not found at ${abs2}`);
942
- continue;
943
- }
944
- if (lineStart) {
945
- const fileLineCount = (await readFile(abs2, 'utf8')).split('\n').length;
946
- const lo = Number(lineStart);
947
- const hi = lineEnd ? Number(lineEnd) : lo;
948
- if (lo > fileLineCount || hi > fileLineCount) {
949
- fail(rel, lineOf(content, m.index), `CodeRef line ${lo}-${hi} exceeds file length (${fileLineCount}) in "${path}"`);
950
- }
951
- }
952
- }
953
- }
954
-
955
- // 6. Theorem requires a resolvable kind (#121) — kind= canonical, type=
956
- // legacy alias. Catches the silent-empty-label / build-throw case at the
957
- // earliest gate. (Value typos are caught at build by theoremLabel's throw.)
958
- // Plus (#126): an id'd <Theorem> without a label= override auto-numbers
959
- // from labels.json — an id absent from the index silently renders the
960
- // heading UNNUMBERED (no [?id] placeholder, unlike <XRef>). Fail loud to
961
- // restore symmetry with check #2. (A label= override opts out → number:null.)
962
- for (const m of content.matchAll(RE_THEOREM)) {
963
- const attrs = m[1];
964
- if (!/\b(?:kind|type)\s*=/.test(attrs)) {
965
- fail(
966
- rel,
967
- lineOf(content, m.index),
968
- `<Theorem> has no kind= (or legacy type=) — renders an empty label / throws at build. Add e.g. kind="theorem".`,
969
- );
970
- }
971
- const thmId = attrs.match(/\bid=["']([^"']+)["']/);
972
- const hasLabelOverride = /\blabel\s*=/.test(attrs);
973
- if (thmId && !hasLabelOverride && !labels[thmId[1]]) {
974
- fail(
975
- rel,
976
- lineOf(content, m.index),
977
- `<Theorem id="${thmId[1]}"> — not in labels.json; heading silently renders unnumbered. Run build:labels, or fix the id.`,
978
- );
979
- }
980
- // #176: compare only literal n= values. Dynamic expressions cannot be
981
- // evaluated reliably by this intentionally regex-based validator, and a
982
- // label= override explicitly opts out of labels.json auto-numbering.
983
- const explicitNumber = hasLabelOverride ? null : literalTheoremNumber(attrs);
984
- const indexedNumber = thmId ? labels[thmId[1]]?.number : null;
985
- if (
986
- thmId &&
987
- explicitNumber !== null &&
988
- indexedNumber != null &&
989
- explicitNumber !== String(indexedNumber)
990
- ) {
991
- fail(
992
- rel,
993
- lineOf(content, m.index),
994
- `<Theorem id="${thmId[1]}" n="${explicitNumber}"> — labels.json numbers it ` +
995
- `${indexedNumber}, and the rendered heading + every XRef use the index. ` +
996
- 'Drop the stale n= (auto-numbering wins) or re-run build:labels.',
997
- );
998
- }
999
- }
1000
-
1001
- // 7. BookLink (#96/#147): structural props + evaluated registry membership,
1002
- // then literal path/fragment validation against a declared vendored index.
1003
- // Dynamic values and URL-only compatibility entries are explicit warnings
1004
- // rather than guessed validations.
1005
- for (const element of bookLinks) {
1006
- const line = element.position?.start?.line ?? 1;
1007
- const bookAttr = mdxStringProp(element, 'book');
1008
- const toAttr = mdxStringProp(element, 'to');
1009
-
1010
- if (bookAttr.source === 'spread' || toAttr.source === 'spread') {
1011
- warn(
1012
- rel,
1013
- line,
1014
- '<BookLink> validation skipped: a dynamic prop spread may supply or override book=/to=, so the target cannot be proven statically.',
1015
- );
1016
- continue;
1017
- }
1018
- if (!bookAttr.present || !toAttr.present) {
1019
- fail(rel, line, `<BookLink> requires both book="…" and to="…".`);
1020
- continue;
1021
- }
1022
-
1023
- if (!bookAttr.literal) {
1024
- warn(
1025
- rel,
1026
- line,
1027
- '<BookLink> target validation skipped: dynamic book= expression cannot be evaluated statically.',
1028
- );
1029
- continue;
1030
- }
1031
-
1032
- const book = bookAttr.value;
1033
- const corpusBook = BOOK_SELECTION.corpus?.books.find((candidate) => candidate.id === book);
1034
- if (corpusBook) {
1035
- if (!toAttr.literal) {
1036
- warn(
1037
- rel,
1038
- line,
1039
- `<BookLink book="${book}"> target validation skipped: dynamic to= expression cannot be evaluated statically.`,
1040
- );
1041
- continue;
1042
- }
1043
-
1044
- const target = normalizeCorpusTarget(corpusBook, toAttr.value);
1045
- if (target.error) {
1046
- fail(
1047
- rel,
1048
- line,
1049
- `<BookLink book="${book}" to="${toAttr.value}"> has invalid local corpus target: ` +
1050
- `${target.error}.`,
1051
- );
1052
- continue;
1053
- }
1054
-
1055
- if (target.chapterSlug && !validSlugs.has(target.chapterSlug)) {
1056
- fail(
1057
- rel,
1058
- line,
1059
- `<BookLink book="${book}" to="${toAttr.value}"> — unknown local corpus target ` +
1060
- `"${target.canonicalPath}".`,
1061
- );
1062
- continue;
1063
- }
1064
-
1065
- // Exact apparatus roots are known statically and may be checked against
1066
- // the target book's route set. Nested/custom non-chapter paths remain a
1067
- // deliberate runtime escape hatch (for example about/team or a custom
1068
- // glossary detail route), so validate must not reject them merely
1069
- // because they are absent from the scaffold apparatus registry.
1070
- if (
1071
- !target.chapterSlug &&
1072
- KNOWN_CORPUS_APPARATUS_ROUTES.has(target.localPath) &&
1073
- !(corpusBook.apparatus ?? TOOLING_CONFIG.apparatusRoutes).includes(target.localPath)
1074
- ) {
1075
- fail(
1076
- rel,
1077
- line,
1078
- `<BookLink book="${book}" to="${toAttr.value}"> — local corpus apparatus route ` +
1079
- `"${target.localPath}" is not enabled for book "${book}".`,
1080
- );
1081
- continue;
1082
- }
1083
-
1084
- if (target.fragment !== null && target.chapterSlug) {
1085
- if (target.fragment.length === 0) {
1086
- fail(
1087
- rel,
1088
- line,
1089
- `<BookLink book="${book}" to="${toAttr.value}"> — local fragment is empty.`,
1090
- );
1091
- continue;
1092
- }
1093
- const localIndex = labelsArtifact.books[book];
1094
- const candidates = siblingTargetCandidates(localIndex, target.fragment);
1095
- if (!candidates.some((candidate) => candidate.normalized.href === target.href)) {
1096
- const indexedHrefs = candidates.map((candidate) => `"${candidate.href}"`).join(', ');
1097
- fail(
1098
- rel,
1099
- line,
1100
- `<BookLink book="${book}" to="${toAttr.value}"> — local fragment ` +
1101
- `"${target.fragment}" does not resolve in labels.json book namespace "${book}"` +
1102
- (indexedHrefs ? ` (indexed at ${indexedHrefs})` : '') +
1103
- '.',
1104
- );
1105
- }
1106
- } else if (target.fragment !== null) {
1107
- // labels.json indexes chapter prose/components only. Runtime accepts
1108
- // fragments on arbitrary book-local routes, so a non-chapter fragment
1109
- // cannot be proven (or disproven) from this artifact.
1110
- warn(
1111
- rel,
1112
- line,
1113
- `<BookLink book="${book}" to="${toAttr.value}"> non-chapter fragment ` +
1114
- 'validation skipped; the target route owns its anchors.',
1115
- );
1116
- }
1117
- continue;
1118
- }
1119
-
1120
- const registered = Object.prototype.hasOwnProperty.call(TOOLING_CONFIG.siblingBooks, book);
1121
- if (!registered) {
1122
- const known = [
1123
- ...(BOOK_SELECTION.corpus?.books.map((candidate) => candidate.id) ?? []),
1124
- ...Object.keys(TOOLING_CONFIG.siblingBooks),
1125
- ];
1126
- fail(
1127
- rel,
1128
- line,
1129
- BOOK_SELECTION.corpus
1130
- ? `<BookLink book="${book}"> — not a registered local corpus or sibling book ` +
1131
- `(${known.join(', ') || 'none'}). Register it or fix the key.`
1132
- : `<BookLink book="${book}"> — not in evaluated defineBookConfig siblingBooks ` +
1133
- `(${Object.keys(TOOLING_CONFIG.siblingBooks).join(', ') || 'none'}). ` +
1134
- 'Register it or fix the key.',
1135
- );
1136
- continue;
1137
- }
1138
- const siblingEntry = TOOLING_CONFIG.siblingBooks[book];
1139
-
1140
- if (!toAttr.literal) {
1141
- warn(
1142
- rel,
1143
- line,
1144
- `<BookLink book="${book}"> target validation skipped: dynamic to= expression cannot be evaluated statically.`,
1145
- );
1146
- continue;
1147
- }
1148
-
1149
- const target = normalizeSiblingTarget(toAttr.value);
1150
- if (!target) {
1151
- warn(
1152
- rel,
1153
- line,
1154
- `<BookLink book="${book}" to="${toAttr.value}"> has no static #fragment; sibling labels validation skipped.`,
1155
- );
1156
- continue;
1157
- }
1158
-
1159
- if (typeof siblingEntry === 'string' || siblingEntry.labels === undefined) {
1160
- warn(
1161
- rel,
1162
- line,
1163
- `<BookLink book="${book}" to="${toAttr.value}"> target validation skipped: ` +
1164
- 'the siblingBooks entry is URL-only. Use { url, labels } to enable vendored-label validation.',
1165
- );
1166
- continue;
1167
- }
1168
-
1169
- const loaded = siblingLabelIndexes.get(book);
1170
- if (!loaded) continue; // A config-level error was already recorded above.
1171
-
1172
- const candidates = siblingTargetCandidates(loaded.index, target.fragment);
1173
- if (candidates.some((candidate) => candidate.normalized.href === target.href)) {
1174
- continue;
1175
- }
1176
-
1177
- if (candidates.length === 0) {
1178
- // Preserve the actionable diagnostic for a malformed historical entry
1179
- // whose key is the literal fragment. Opaque heading keys are found by
1180
- // valid href values and never need to be decoded here.
1181
- const legacyEntry = loaded.index[target.fragment];
1182
- if (
1183
- Object.prototype.hasOwnProperty.call(loaded.index, target.fragment) &&
1184
- (legacyEntry === null ||
1185
- typeof legacyEntry !== 'object' ||
1186
- typeof legacyEntry.href !== 'string')
1187
- ) {
1188
- fail(
1189
- rel,
1190
- line,
1191
- `<BookLink book="${book}" to="${toAttr.value}"> — ${loaded.configuredPath} entry ` +
1192
- `"${target.fragment}" has no string href; re-vendor a valid sibling labels.json.`,
1193
- );
1194
- continue;
1195
- }
1196
- fail(
1197
- rel,
1198
- line,
1199
- `<BookLink book="${book}" to="${toAttr.value}"> — fragment "${target.fragment}" is not in ` +
1200
- `${loaded.configuredPath}. Vendor the current sibling labels.json, or fix the target id.`,
1201
- );
1202
- continue;
1203
- }
1204
-
1205
- const indexedHrefs = candidates.map((candidate) => `"${candidate.href}"`).join(', ');
1206
- fail(
1207
- rel,
1208
- line,
1209
- `<BookLink book="${book}" to="${toAttr.value}"> — path/fragment does not match ` +
1210
- `${loaded.configuredPath}, which indexes "${target.fragment}" at ${indexedHrefs}. ` +
1211
- 'Fix to= or refresh the vendored index.',
1212
- );
1213
- }
1214
-
1215
- // <Rationale appendix> in CHAPTER bodies (v4.21.0, #114): same missing-for=
1216
- // pre-flight as the questions scan below (the for===id rule is
1217
- // question-file-scoped and doesn't apply here). The component still throws
1218
- // at build either way; this just catches it at the earliest gate.
1219
- for (const m of content.matchAll(/<Rationale\b([^>]*)>/g)) {
1220
- const attrs = m[1];
1221
- if (/(^|\s)appendix(\s|=|$)/.test(attrs) && !/\bfor\s*=/.test(attrs)) {
1222
- fail(
1223
- rel,
1224
- lineOf(content, m.index),
1225
- `<Rationale appendix> without for="<question-id>" — no appendix anchor target; throws at build.`,
1226
- );
1227
- }
1228
- }
1229
-
1230
- // 9. Learning-objective anchor binding (#130). Convention (consumer-defined
1231
- // `los` frontmatter, guides-ai-engineering): each `los[].anchor` slug has
1232
- // a matching MDX comment marker in the prose binding the objective to its
1233
- // section. Both drift directions built + validated green before this
1234
- // check, so both fail loud: a declared anchor with no marker (dangling
1235
- // objective) and a marker with no declaration (orphan). Scoped to
1236
- // chapters that opt into the convention (a `los:` frontmatter key) —
1237
- // `los` is not a scaffold schema field, so this can't false-fire on
1238
- // books that don't use it. Heuristic, like #8: any indented `anchor:`
1239
- // line inside the frontmatter counts as a declaration.
1240
- {
1241
- const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
1242
- const front = fmMatch ? fmMatch[1] : '';
1243
- if (/^los\s*:/m.test(front)) {
1244
- const frontOffset = content.indexOf(front);
1245
- const bodyOffset = fmMatch ? fmMatch[0].length : 0;
1246
- const body = content.slice(bodyOffset);
1247
- // Matches both YAML styles: block items (`- anchor: slug` / `anchor: slug`
1248
- // on its own indented line) and flow/inline maps (`- { text: …, anchor:
1249
- // slug }`), where the key follows a `{` or `,`. The prefix alternation —
1250
- // never a bare `.*` — keeps `my-anchor:` from matching as "anchor:".
1251
- const declared = [
1252
- ...front.matchAll(
1253
- /^\s+(?:-\s+|.*[{,]\s*)?anchor\s*:\s*["']?([^"',}\n]+?)["']?\s*(?:[,}].*)?$/gm,
1254
- ),
1255
- ];
1256
- const markers = [...body.matchAll(/\{\s*\/\*\s*anchor:\s*([^\s*]+)\s*\*\/\s*\}/g)];
1257
- const markerSlugs = new Set(markers.map((m) => m[1]));
1258
- const declaredSlugs = new Set(declared.map((m) => m[1].trim()));
1259
- for (const d of declared) {
1260
- const slug = d[1].trim();
1261
- if (!markerSlugs.has(slug)) {
1262
- fail(
1263
- rel,
1264
- lineOf(content, frontOffset + d.index),
1265
- `los anchor "${slug}" has no matching {/* anchor: ${slug} */} marker in the prose — dangling learning objective.`,
1266
- );
1267
- }
1268
- }
1269
- for (const m of markers) {
1270
- if (!declaredSlugs.has(m[1])) {
1271
- fail(
1272
- rel,
1273
- lineOf(content, bodyOffset + m.index),
1274
- `prose anchor marker "${m[1]}" has no matching los[].anchor in the frontmatter — orphan anchor.`,
1275
- );
1276
- }
1277
- }
1278
- }
1279
- }
1280
- }
1281
- ACTIVE_BOOK_ID = BOOK_SELECTION.corpus ? 'corpus' : null;
1282
-
1283
- // ===== 8. Questions collection (#112): domain membership + unique ids =====
1284
- //
1285
- // The study-guide `questions` collection (src/content/questions/**) is scanned
1286
- // separately from chapters: a question's frontmatter `domain` must be in the
1287
- // consumer's examDomains registry — an unregistered domain throws at build via
1288
- // assertKnownDomain (route layer); we flag it here, earlier, the same way #7
1289
- // pre-flights BookLink. Question `id`s must also be unique (they're the stable
1290
- // cross-ref key the appendix/flashcards resolve against). Best-effort: when
1291
- // examDomains can't be read from astro.config.mjs, membership is left to the
1292
- // build-time throw.
1293
- let questionsChecked = 0;
1294
- const questionsCheckedByBook = new Map();
1295
-
1296
- if (BOOK_SELECTION.corpus) {
1297
- const manifestIds = new Set(BOOK_SELECTION.corpus.books.map((book) => book.id));
1298
- for (const [collection, label] of [
1299
- ['questions', 'Question'],
1300
- ['glossary', 'Glossary term'],
1301
- ]) {
1302
- const collectionDir = resolve(ROOT, 'src/content', collection);
1303
- if (!existsSync(collectionDir)) continue;
1304
- // Collection ownership is path-derived. Scan the complete non-hidden root
1305
- // even for `--book` so direct files and unknown first segments cannot
1306
- // silently disappear from every per-book run.
1307
- for await (const file of walkMdx(collectionDir)) {
1308
- const owner = file.split('/')[0] ?? '';
1309
- if (manifestIds.has(owner)) continue;
1310
- fail(
1311
- `${collection}/${file}`,
1312
- 1,
1313
- `${label} content must be nested under a registered corpus book id ` +
1314
- `(${[...manifestIds].join(' | ')}); found first segment ${JSON.stringify(owner)}.`,
1315
- 'corpus',
1316
- );
1317
- }
1318
- }
1319
- }
1320
-
1321
- {
1322
- const QUESTIONS_DIR = resolve(ROOT, 'src/content/questions');
1323
- if (existsSync(QUESTIONS_DIR)) {
1324
- // examDomains registry from astro.config.mjs (best-effort, mirrors siblingBooks).
1325
- let examDomains = null;
1326
- const astroConfigPath = resolve(ROOT, 'astro.config.mjs');
1327
- if (existsSync(astroConfigPath)) {
1328
- const block = readFileSync(astroConfigPath, 'utf8').match(/examDomains\s*:\s*\[([^\]]*)\]/);
1329
- if (block) {
1330
- examDomains = new Set([...block[1].matchAll(/['"]([^'"]+)['"]/g)].map((x) => x[1]));
1331
- }
1332
- }
1333
-
1334
- const runs = BOOK_SELECTION.corpus
1335
- ? BOOK_SELECTION.books.map((book) => ({
1336
- book,
1337
- dir: join(QUESTIONS_DIR, book.id),
1338
- prefix: `questions/${book.id}`,
1339
- }))
1340
- : [{ book: null, dir: QUESTIONS_DIR, prefix: 'questions' }];
1341
-
1342
- for (const run of runs) {
1343
- ACTIVE_BOOK_ID = run.book?.id ?? null;
1344
- const seenIds = new Map(); // ids are book-local in corpus mode
1345
- const questionFiles = [];
1346
- for await (const file of walkMdx(run.dir)) {
1347
- if (!file.split('/').pop().startsWith('_')) questionFiles.push(file);
1348
- }
1349
- for (const rel of questionFiles) {
1350
- const abs = join(run.dir, rel);
1351
- const content = await readFile(abs, 'utf8');
1352
- const qrel = `${run.prefix}/${rel}`;
1353
- let parsedFrontmatter;
1354
- try {
1355
- parsedFrontmatter = parseFrontmatter(
1356
- content,
1357
- run.book ? `[book:${run.book.id}] ${qrel}` : qrel,
1358
- );
1359
- } catch (error) {
1360
- recordFrontmatterFailure(error, qrel, run.book?.id ?? null);
1361
- continue;
1362
- }
1363
- const frontmatter = parsedFrontmatter.frontmatter;
1364
- if (run.book) {
1365
- if (
1366
- Object.prototype.hasOwnProperty.call(frontmatter, 'book') &&
1367
- frontmatter.book !== run.book.id
1368
- ) {
1369
- fail(
1370
- qrel,
1371
- parsedFrontmatter.lines.book ?? 2,
1372
- `frontmatter book ${JSON.stringify(frontmatter.book)} does not match ` +
1373
- `path-derived corpus book ${JSON.stringify(run.book.id)}.`,
1374
- );
1375
- }
1376
- }
1377
-
1378
- // Questions do not run the legacy link-resolution advisory, so keep the
1379
- // #190 root-base contract fully inert by parsing only for a non-root base.
1380
- const authoredTargets = ASTRO_BASE === '/' ? [] : collectAuthoredTargets(qrel, content);
1381
- validateAuthoredTargets(qrel, content, authoredTargets);
1382
-
1383
- const normalizedQuestionId = typeof frontmatter.id === 'string'
1384
- ? frontmatter.id.trim()
1385
- : '';
1386
- const questionId = normalizedQuestionId.length > 0 ? normalizedQuestionId : null;
1387
- if (questionId) {
1388
- const id = questionId;
1389
- if (seenIds.has(id)) {
1390
- fail(
1391
- qrel,
1392
- parsedFrontmatter.lines.id ?? 1,
1393
- `Duplicate question id "${id}" — also declared in ${seenIds.get(id)}. Question ids must be unique (cross-ref key for the appendix / flashcards).`,
1394
- );
1395
- } else {
1396
- seenIds.set(id, qrel);
1397
- }
1398
- }
1399
-
1400
- if (examDomains) {
1401
- const normalizedDomain = typeof frontmatter.domain === 'string'
1402
- ? frontmatter.domain.trim()
1403
- : '';
1404
- const domain = normalizedDomain.length > 0 ? normalizedDomain : null;
1405
- if (domain && !examDomains.has(domain)) {
1406
- fail(
1407
- qrel,
1408
- parsedFrontmatter.lines.domain ?? 1,
1409
- `Question domain "${domain}" not in defineBookConfig examDomains (${[...examDomains].join(', ') || 'none'}). Register it or fix the value.`,
1410
- );
1411
- }
1412
- }
1413
-
1414
- // v4.21.0 (#114): <Rationale appendix> needs for= (the /answers#answer-<id>
1415
- // anchor target) — the component throws at build; flag it here, earlier.
1416
- for (const m of content.matchAll(/<Rationale\b([^>]*)>/g)) {
1417
- const attrs = m[1];
1418
- if (!/(^|\s)appendix(\s|=|$)/.test(attrs)) continue;
1419
- const forMatch = attrs.match(/\bfor\s*=\s*["']([^"']+)["']/);
1420
- if (!forMatch) {
1421
- fail(
1422
- qrel,
1423
- lineOf(content, m.index),
1424
- `<Rationale appendix> without for="<question-id>" — no appendix anchor target; throws at build.`,
1425
- );
1426
- } else if (questionId && forMatch[1] !== questionId) {
1427
- fail(
1428
- qrel,
1429
- lineOf(content, m.index),
1430
- `<Rationale appendix for="${forMatch[1]}"> does not match this question's id "${questionId}" — the /answers anchor would land on the wrong (or no) answer.`,
1431
- );
1432
- }
1433
- }
1434
- }
1435
- questionsChecked += questionFiles.length;
1436
- if (run.book) questionsCheckedByBook.set(run.book.id, questionFiles.length);
1437
- }
1438
- ACTIVE_BOOK_ID = BOOK_SELECTION.corpus ? 'corpus' : null;
1439
- }
1440
- }
1441
-
1442
- // ===== Report =====
1443
- const format = ({ file, line, msg, book }) =>
1444
- `${book ? `[book:${book}] ` : ' '}${file}:${line} ${msg}`;
1445
- if (warnings.length > 0) {
1446
- const prefix = BOOK_SELECTION.corpus ? '[book:corpus] ' : '';
1447
- console.warn(`${prefix}validate: ${warnings.length} warning(s):`);
1448
- warnings.forEach((warning) => console.warn(format(warning)));
13
+ if (argv.includes('--help') || argv.includes('-h')) {
14
+ process.stdout.write(VALIDATE_USAGE);
15
+ process.exit(0);
1449
16
  }
1450
17
 
1451
- if (BOOK_SELECTION.corpus) {
1452
- for (const book of BOOK_SELECTION.books) {
1453
- const count = chapterCountsByBook.get(book.id) ?? 0;
1454
- const questionCount = questionsCheckedByBook.get(book.id) ?? 0;
1455
- const questionNote = questionCount > 0 ? ` + ${questionCount} question(s)` : '';
1456
- const bookErrors = errors.filter((error) => error.book === book.id).length;
1457
- const bookWarnings = warnings.filter((warning) => warning.book === book.id).length;
1458
- const result = bookErrors === 0 ? '✓' : '✗';
1459
- const detail = bookErrors === 0
1460
- ? 'no errors'
1461
- : `${bookErrors} error${bookErrors === 1 ? '' : 's'}`;
1462
- console.log(
1463
- `[book:${book.id}] validate: ${result} ${count} chapter(s)${questionNote} checked; ${detail}, ` +
1464
- `${bookWarnings} warning${bookWarnings === 1 ? '' : 's'} ` +
1465
- `(profile=${PROFILE}, number-style=${TOOLING_CONFIG.numberStyle}).`,
1466
- );
1467
- }
18
+ const result = await runValidation({
19
+ root: process.cwd(),
20
+ argv,
21
+ env: process.env,
22
+ regenerateArtifact: regenerateValidationArtifact,
23
+ });
1468
24
 
1469
- const qNote = questionsChecked > 0 ? ` + ${questionsChecked} question(s)` : '';
1470
- const aggregateResult = errors.length === 0 ? '✓' : '✗';
1471
- const aggregateDetail = errors.length === 0
1472
- ? 'no errors'
1473
- : `${errors.length} error${errors.length === 1 ? '' : 's'}`;
1474
- const aggregateMessage =
1475
- `[book:corpus] validate: ${aggregateResult} ${chapterFiles.length} chapter(s)${qNote} ` +
1476
- `across ${BOOK_SELECTION.books.length} book${BOOK_SELECTION.books.length === 1 ? '' : 's'}; ` +
1477
- `${aggregateDetail}, ${warnings.length} warning${warnings.length === 1 ? '' : 's'} ` +
1478
- `(profile=${PROFILE}, number-style=${TOOLING_CONFIG.numberStyle})`;
1479
- if (errors.length === 0) {
1480
- console.log(`${aggregateMessage}.`);
1481
- process.exit(0);
1482
- }
1483
- console.error(`${aggregateMessage}:`);
1484
- errors.forEach((error) => console.error(format(error)));
1485
- process.exit(Math.min(errors.length, 255));
1486
- }
1487
-
1488
- if (errors.length === 0) {
1489
- const qNote = questionsChecked > 0 ? ` + ${questionsChecked} question(s)` : '';
1490
- console.log(
1491
- `validate: ✓ ${chapterFiles.length} chapter(s)${qNote} checked ` +
1492
- `(profile=${PROFILE}, number-style=${TOOLING_CONFIG.numberStyle}); no errors.`,
1493
- );
1494
- process.exit(0);
1495
- }
1496
- console.error(
1497
- `validate: ✗ ${errors.length} error(s) in ${chapterFiles.length} chapter(s) ` +
1498
- `(profile=${PROFILE}, number-style=${TOOLING_CONFIG.numberStyle}):`,
1499
- );
1500
- errors.forEach((error) => console.error(format(error)));
1501
- process.exit(Math.min(errors.length, 255));
25
+ if (result.output.stdout) process.stdout.write(result.output.stdout);
26
+ if (result.output.stderr) process.stderr.write(result.output.stderr);
27
+ process.exit(result.exitCode);