@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.
- package/CLAUDE.md +27 -3
- package/README.md +34 -0
- package/bin/book-scaffold.mjs +4 -0
- package/dist/index.mjs +6 -2
- package/package.json +2 -1
- package/recipes/25-qa-readiness.md +197 -0
- package/recipes/README.md +1 -0
- package/scripts/init-qa.mjs +209 -0
- package/scripts/qa-core.mjs +1511 -0
- package/scripts/qa.mjs +293 -0
- package/scripts/resolve-book-config.mjs +39 -0
- package/scripts/validate-core.mjs +1675 -0
- package/scripts/validate.mjs +18 -1492
- package/scripts/validation-artifacts.mjs +23 -0
|
@@ -0,0 +1,1511 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic content-health engine for `book-scaffold qa` (#158).
|
|
3
|
+
*
|
|
4
|
+
* This module deliberately owns no process I/O: it never prints, exits,
|
|
5
|
+
* spawns, or writes. The command adapter injects the shared validation core
|
|
6
|
+
* and decides whether to render human or schema-v1 JSON output.
|
|
7
|
+
*/
|
|
8
|
+
import { readFile, readdir, realpath } from 'node:fs/promises';
|
|
9
|
+
import { dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
10
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
11
|
+
import { createMarkdownProcessor } from '@astrojs/markdown-remark';
|
|
12
|
+
import remarkMath from 'remark-math';
|
|
13
|
+
import remarkMdx from 'remark-mdx';
|
|
14
|
+
import remarkParse from 'remark-parse';
|
|
15
|
+
import { unified } from 'unified';
|
|
16
|
+
import {
|
|
17
|
+
findAuthoredTargets,
|
|
18
|
+
normalizeAstroBase,
|
|
19
|
+
} from './authored-links.mjs';
|
|
20
|
+
import {
|
|
21
|
+
parseFrontmatter,
|
|
22
|
+
resolveBookSelection,
|
|
23
|
+
} from './corpus-tooling.mjs';
|
|
24
|
+
import { loadResolvedBookConfig } from './resolve-book-config.mjs';
|
|
25
|
+
import { readChaptersBase, walkMdx } from './walk-mdx.mjs';
|
|
26
|
+
|
|
27
|
+
export const QA_SCHEMA_VERSION = 1;
|
|
28
|
+
export const QA_SINGLE_BOOK_ID = 'book';
|
|
29
|
+
export const QA_CORPUS_BOOK_ID = 'corpus';
|
|
30
|
+
|
|
31
|
+
export const QA_PRESETS = Object.freeze([
|
|
32
|
+
'academic',
|
|
33
|
+
'tools',
|
|
34
|
+
'minimal',
|
|
35
|
+
'course-notes',
|
|
36
|
+
'research-portfolio',
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
export const QA_CHECKS = Object.freeze([
|
|
40
|
+
'content_contract',
|
|
41
|
+
'chapters',
|
|
42
|
+
'links',
|
|
43
|
+
'learning_objectives',
|
|
44
|
+
'components',
|
|
45
|
+
'demo_fixtures',
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
const CHECK_ORDER = new Map(QA_CHECKS.map((check, index) => [check, index]));
|
|
49
|
+
const STATE_RANK = Object.freeze({ not_applicable: -1, green: 0, amber: 1, red: 2 });
|
|
50
|
+
const GENERATED_DATA_PATHS = new Set([
|
|
51
|
+
'labels.json',
|
|
52
|
+
'references.json',
|
|
53
|
+
// build-bib emits this tools-profile artifact alongside references.json.
|
|
54
|
+
'sources.json',
|
|
55
|
+
'tips.json',
|
|
56
|
+
'exercises.json',
|
|
57
|
+
]);
|
|
58
|
+
const LINK_KINDS = new Set([
|
|
59
|
+
'Markdown link destination',
|
|
60
|
+
'Markdown reference destination',
|
|
61
|
+
'href attribute',
|
|
62
|
+
]);
|
|
63
|
+
const ORIGIN = 'https://book-scaffold.invalid';
|
|
64
|
+
|
|
65
|
+
/** Public component names that may appear in authored MDX. */
|
|
66
|
+
export const SCAFFOLD_MDX_COMPONENTS = Object.freeze([
|
|
67
|
+
'AICollaborationDisclosure',
|
|
68
|
+
'AssessmentTest',
|
|
69
|
+
'BlockedByCallout',
|
|
70
|
+
'BookLink',
|
|
71
|
+
'CaseStudy',
|
|
72
|
+
'Citation',
|
|
73
|
+
'Cite',
|
|
74
|
+
'CodeBlock',
|
|
75
|
+
'CodeRef',
|
|
76
|
+
'ConceptBox',
|
|
77
|
+
'Convergence',
|
|
78
|
+
'CounterBox',
|
|
79
|
+
'DemoFrame',
|
|
80
|
+
'Diagnostic',
|
|
81
|
+
'Divergence',
|
|
82
|
+
'DynConnect',
|
|
83
|
+
'Epigraph',
|
|
84
|
+
'EvidenceTag',
|
|
85
|
+
'ExampleBox',
|
|
86
|
+
'Exercise',
|
|
87
|
+
'ExerciseSolutions',
|
|
88
|
+
'Figure',
|
|
89
|
+
'InsightBox',
|
|
90
|
+
'KeyIdea',
|
|
91
|
+
'MarginFigure',
|
|
92
|
+
'MarginNote',
|
|
93
|
+
'Newthought',
|
|
94
|
+
'NoteBox',
|
|
95
|
+
'ObjectiveMap',
|
|
96
|
+
'OpenQuestion',
|
|
97
|
+
'PaperBox',
|
|
98
|
+
'PartReview',
|
|
99
|
+
'Pitfall',
|
|
100
|
+
'PocLayout',
|
|
101
|
+
'PolicyRef',
|
|
102
|
+
'Practice',
|
|
103
|
+
'PreReleaseBanner',
|
|
104
|
+
'Rationale',
|
|
105
|
+
'Recovery',
|
|
106
|
+
'ResultBox',
|
|
107
|
+
'Sidenote',
|
|
108
|
+
'SkillBox',
|
|
109
|
+
'Slider',
|
|
110
|
+
'Solution',
|
|
111
|
+
'SourceArchive',
|
|
112
|
+
'StatCards',
|
|
113
|
+
'StatusBadge',
|
|
114
|
+
'Tag',
|
|
115
|
+
'Term',
|
|
116
|
+
'Theorem',
|
|
117
|
+
'Tip',
|
|
118
|
+
'TipBox',
|
|
119
|
+
'TipsCard',
|
|
120
|
+
'TryThis',
|
|
121
|
+
'VersionSelector',
|
|
122
|
+
'WarnBox',
|
|
123
|
+
'WeekRef',
|
|
124
|
+
'WorkedExample',
|
|
125
|
+
'XRef',
|
|
126
|
+
'YouWillLearn',
|
|
127
|
+
]);
|
|
128
|
+
const SCAFFOLD_COMPONENT_SET = new Set(SCAFFOLD_MDX_COMPONENTS);
|
|
129
|
+
|
|
130
|
+
export class QaExecutionError extends Error {
|
|
131
|
+
constructor(message, options) {
|
|
132
|
+
super(message, options);
|
|
133
|
+
this.name = 'QaExecutionError';
|
|
134
|
+
this.code = 'QA_EXECUTION_FAILURE';
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function posix(path) {
|
|
139
|
+
return path.split(sep).join('/');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function sourcePath(root, path) {
|
|
143
|
+
return posix(relative(root, path));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function lineOf(source, index) {
|
|
147
|
+
return source.slice(0, index).split('\n').length;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function normalizeRoute(pathname) {
|
|
151
|
+
const value = pathname.startsWith('/') ? pathname : `/${pathname}`;
|
|
152
|
+
try {
|
|
153
|
+
return new URL(value, ORIGIN).pathname.replace(/\/+$/, '') || '/';
|
|
154
|
+
} catch {
|
|
155
|
+
return value.replace(/\/+$/, '') || '/';
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function routeTokens(pattern, tokens) {
|
|
160
|
+
return pattern.replace(/:(book|slug|route|id)\b/g, (_match, key) => tokens[key] ?? '');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function chapterRouteFor(config, book, localId, frontmatter) {
|
|
164
|
+
const entryId = book ? `${book.id}/${localId}` : localId;
|
|
165
|
+
const bookValue = book?.id ?? (
|
|
166
|
+
typeof frontmatter?.[config.bookField] === 'string'
|
|
167
|
+
? frontmatter[config.bookField]
|
|
168
|
+
: ''
|
|
169
|
+
);
|
|
170
|
+
const slug = bookValue && entryId.startsWith(`${bookValue}/`)
|
|
171
|
+
? entryId.slice(bookValue.length + 1)
|
|
172
|
+
: entryId;
|
|
173
|
+
return normalizeRoute(routeTokens(config.chapterRoute, {
|
|
174
|
+
id: entryId,
|
|
175
|
+
book: bookValue,
|
|
176
|
+
slug,
|
|
177
|
+
route: '',
|
|
178
|
+
}));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function worstState(states) {
|
|
182
|
+
let worst = 'not_applicable';
|
|
183
|
+
for (const state of states) {
|
|
184
|
+
if ((STATE_RANK[state] ?? -1) > STATE_RANK[worst]) worst = state;
|
|
185
|
+
}
|
|
186
|
+
return worst;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function checkResult(state, metrics, diagnosticIds = []) {
|
|
190
|
+
return { state, metrics, diagnosticIds };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function diagnostic({ severity, code, message, book, check, file, line, column }) {
|
|
194
|
+
return {
|
|
195
|
+
severity,
|
|
196
|
+
code,
|
|
197
|
+
message,
|
|
198
|
+
book,
|
|
199
|
+
check,
|
|
200
|
+
...(file ? { file } : {}),
|
|
201
|
+
...(Number.isInteger(line) && line > 0 ? { line } : {}),
|
|
202
|
+
...(Number.isInteger(column) && column > 0 ? { column } : {}),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function compareDiagnostics(a, b) {
|
|
207
|
+
return (a.file ?? '').localeCompare(b.file ?? '') ||
|
|
208
|
+
(a.line ?? 0) - (b.line ?? 0) ||
|
|
209
|
+
(a.column ?? 0) - (b.column ?? 0) ||
|
|
210
|
+
(CHECK_ORDER.get(a.check) ?? 99) - (CHECK_ORDER.get(b.check) ?? 99) ||
|
|
211
|
+
a.severity.localeCompare(b.severity) ||
|
|
212
|
+
a.code.localeCompare(b.code) ||
|
|
213
|
+
a.message.localeCompare(b.message);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function finalizeDiagnostics(book, diagnostics) {
|
|
217
|
+
return [...diagnostics]
|
|
218
|
+
.sort(compareDiagnostics)
|
|
219
|
+
.map((entry, index) => {
|
|
220
|
+
const { check: _check, ...publicEntry } = entry;
|
|
221
|
+
return {
|
|
222
|
+
id: `qa:${book}:${String(index + 1).padStart(4, '0')}`,
|
|
223
|
+
...publicEntry,
|
|
224
|
+
};
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function idsForCheck(diagnostics, sourceDiagnostics, check) {
|
|
229
|
+
const signatures = new Set(
|
|
230
|
+
sourceDiagnostics
|
|
231
|
+
.filter((entry) => entry.check === check)
|
|
232
|
+
.map((entry) => [
|
|
233
|
+
entry.severity,
|
|
234
|
+
entry.code,
|
|
235
|
+
entry.message,
|
|
236
|
+
entry.file ?? '',
|
|
237
|
+
entry.line ?? 0,
|
|
238
|
+
entry.column ?? 0,
|
|
239
|
+
].join('\u0000')),
|
|
240
|
+
);
|
|
241
|
+
return diagnostics
|
|
242
|
+
.filter((entry) => signatures.has([
|
|
243
|
+
entry.severity,
|
|
244
|
+
entry.code,
|
|
245
|
+
entry.message,
|
|
246
|
+
entry.file ?? '',
|
|
247
|
+
entry.line ?? 0,
|
|
248
|
+
entry.column ?? 0,
|
|
249
|
+
].join('\u0000')))
|
|
250
|
+
.map((entry) => entry.id);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function validationDiagnostic(entry, severity, book) {
|
|
254
|
+
const message = entry?.message ?? entry?.msg ?? String(entry);
|
|
255
|
+
return diagnostic({
|
|
256
|
+
severity,
|
|
257
|
+
code: typeof entry?.code === 'string' && entry.code.length > 0
|
|
258
|
+
? entry.code
|
|
259
|
+
: `validation.${severity === 'error' ? 'error' : 'advisory'}`,
|
|
260
|
+
message,
|
|
261
|
+
book,
|
|
262
|
+
check: 'content_contract',
|
|
263
|
+
file: entry?.file,
|
|
264
|
+
line: entry?.line,
|
|
265
|
+
column: entry?.column,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function contentContractCheck(diagnostics, allDiagnostics) {
|
|
270
|
+
const errors = diagnostics.filter((entry) => entry.severity === 'error').length;
|
|
271
|
+
const advisories = diagnostics.filter((entry) => entry.severity === 'warning').length;
|
|
272
|
+
return checkResult(
|
|
273
|
+
errors > 0 ? 'red' : advisories > 0 ? 'amber' : 'green',
|
|
274
|
+
{ errors, advisories },
|
|
275
|
+
idsForCheck(allDiagnostics, diagnostics, 'content_contract'),
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function isRecord(value) {
|
|
280
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function mathEnabled(preset) {
|
|
284
|
+
return preset === 'academic' || preset === 'research-portfolio';
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function mdxParser(preset) {
|
|
288
|
+
const processor = unified().use(remarkParse).use(remarkMdx);
|
|
289
|
+
if (mathEnabled(preset)) processor.use(remarkMath);
|
|
290
|
+
return processor;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function literalAttribute(attribute) {
|
|
294
|
+
if (typeof attribute?.value === 'string') return attribute.value;
|
|
295
|
+
const expression = attribute?.value?.data?.estree?.body?.[0]?.expression;
|
|
296
|
+
if (
|
|
297
|
+
expression?.type === 'Literal' &&
|
|
298
|
+
(typeof expression.value === 'string' || typeof expression.value === 'number')
|
|
299
|
+
) {
|
|
300
|
+
return String(expression.value);
|
|
301
|
+
}
|
|
302
|
+
if (
|
|
303
|
+
expression?.type === 'TemplateLiteral' &&
|
|
304
|
+
expression.expressions?.length === 0 &&
|
|
305
|
+
expression.quasis?.length === 1
|
|
306
|
+
) {
|
|
307
|
+
return expression.quasis[0].value?.cooked ?? null;
|
|
308
|
+
}
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function inspectMdxTree(tree) {
|
|
313
|
+
const components = new Map();
|
|
314
|
+
const ids = new Set();
|
|
315
|
+
const uncertainIds = new Set();
|
|
316
|
+
const visit = (node) => {
|
|
317
|
+
if (!node || typeof node !== 'object') return;
|
|
318
|
+
if (node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement') {
|
|
319
|
+
if (SCAFFOLD_COMPONENT_SET.has(node.name)) {
|
|
320
|
+
components.set(node.name, (components.get(node.name) ?? 0) + 1);
|
|
321
|
+
}
|
|
322
|
+
const valueOf = (name) => literalAttribute(
|
|
323
|
+
(node.attributes ?? []).find(
|
|
324
|
+
(attribute) => attribute?.type === 'mdxJsxAttribute' && attribute.name === name,
|
|
325
|
+
),
|
|
326
|
+
);
|
|
327
|
+
const rawId = valueOf('id');
|
|
328
|
+
let idSemanticsKnown = false;
|
|
329
|
+
if (/^[a-z]/.test(node.name ?? '') && rawId) {
|
|
330
|
+
ids.add(rawId);
|
|
331
|
+
idSemanticsKnown = true;
|
|
332
|
+
} else if (
|
|
333
|
+
['Theorem', 'Figure', 'MarginFigure', 'DemoFrame', 'Slider'].includes(node.name) &&
|
|
334
|
+
rawId
|
|
335
|
+
) {
|
|
336
|
+
ids.add(rawId);
|
|
337
|
+
idSemanticsKnown = true;
|
|
338
|
+
} else if (node.name === 'Exercise' && rawId) {
|
|
339
|
+
ids.add(`exercise-${rawId}`);
|
|
340
|
+
idSemanticsKnown = true;
|
|
341
|
+
} else if (node.name === 'Practice' && rawId) {
|
|
342
|
+
ids.add(`practice-${rawId}`);
|
|
343
|
+
idSemanticsKnown = true;
|
|
344
|
+
} else if (node.name === 'WorkedExample' && rawId) {
|
|
345
|
+
ids.add(`worked-example-${rawId}`);
|
|
346
|
+
idSemanticsKnown = true;
|
|
347
|
+
}
|
|
348
|
+
if (
|
|
349
|
+
rawId &&
|
|
350
|
+
!idSemanticsKnown &&
|
|
351
|
+
/^[A-Z]/.test(node.name ?? '') &&
|
|
352
|
+
!SCAFFOLD_COMPONENT_SET.has(node.name)
|
|
353
|
+
) {
|
|
354
|
+
uncertainIds.add(rawId);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (node.name === 'DemoFrame' && rawId) {
|
|
358
|
+
ids.add(`${rawId}-title`);
|
|
359
|
+
if (valueOf('description')) ids.add(`${rawId}-description`);
|
|
360
|
+
if (valueOf('caption')) ids.add(`${rawId}-caption`);
|
|
361
|
+
}
|
|
362
|
+
if (node.name === 'Slider' && rawId && valueOf('description')) {
|
|
363
|
+
ids.add(`${rawId}-description`);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const tipNumber = node.name === 'Tip' ? valueOf('n') : null;
|
|
367
|
+
if (tipNumber) ids.add(`tip-${tipNumber}`);
|
|
368
|
+
const solutionFor = node.name === 'Solution' ? valueOf('for') : null;
|
|
369
|
+
if (solutionFor) ids.add(`solution-${solutionFor}`);
|
|
370
|
+
if (node.name === 'ExerciseSolutions') ids.add('exercise-solutions');
|
|
371
|
+
if (node.name === 'AICollaborationDisclosure') ids.add('ai-collab-h');
|
|
372
|
+
if (node.name === 'BlockedByCallout') ids.add('blocked-by-h');
|
|
373
|
+
if (node.name === 'SourceArchive') {
|
|
374
|
+
for (const tier of [
|
|
375
|
+
'T1-official',
|
|
376
|
+
'T2-release-notes',
|
|
377
|
+
'T3-practitioner',
|
|
378
|
+
'T4-conjecture',
|
|
379
|
+
]) ids.add(`tier-${tier}`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
for (const child of node.children ?? []) visit(child);
|
|
383
|
+
};
|
|
384
|
+
visit(tree);
|
|
385
|
+
return { components, ids, uncertainIds };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function* walkFiles(
|
|
389
|
+
dir,
|
|
390
|
+
baseDir = dir,
|
|
391
|
+
{ includeHidden = false, includeWellKnown = false } = {},
|
|
392
|
+
) {
|
|
393
|
+
let entries;
|
|
394
|
+
try {
|
|
395
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
396
|
+
} catch (error) {
|
|
397
|
+
if (error?.code === 'ENOENT') return;
|
|
398
|
+
throw error;
|
|
399
|
+
}
|
|
400
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
401
|
+
for (const entry of entries) {
|
|
402
|
+
if (
|
|
403
|
+
!includeHidden &&
|
|
404
|
+
(entry.name.startsWith('.') || entry.name.startsWith('_')) &&
|
|
405
|
+
!(includeWellKnown && entry.name === '.well-known')
|
|
406
|
+
) continue;
|
|
407
|
+
const full = join(dir, entry.name);
|
|
408
|
+
if (entry.isDirectory()) {
|
|
409
|
+
yield* walkFiles(full, baseDir, { includeHidden, includeWellKnown });
|
|
410
|
+
}
|
|
411
|
+
else yield posix(relative(baseDir, full));
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function addCounts(target, source) {
|
|
416
|
+
for (const [name, count] of source) {
|
|
417
|
+
target.set(name, (target.get(name) ?? 0) + count);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function stableComponentMetrics(counts) {
|
|
422
|
+
const byName = {};
|
|
423
|
+
let total = 0;
|
|
424
|
+
for (const name of [...counts.keys()].sort((a, b) => a.localeCompare(b))) {
|
|
425
|
+
const count = counts.get(name);
|
|
426
|
+
if (!count) continue;
|
|
427
|
+
byName[name] = count;
|
|
428
|
+
total += count;
|
|
429
|
+
}
|
|
430
|
+
return { total, byName };
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function objectiveAnalysis(chapter, book, diagnostics) {
|
|
434
|
+
if (!chapter.hasObjectives) return { applicable: false, declared: 0, resolved: 0 };
|
|
435
|
+
|
|
436
|
+
const los = chapter.frontmatter.los;
|
|
437
|
+
const entries = Array.isArray(los) ? los : [];
|
|
438
|
+
const markers = [...chapter.body.matchAll(
|
|
439
|
+
/\{\s*\/\*\s*anchor:\s*([^\s*]+)\s*\*\/\s*\}/g,
|
|
440
|
+
)];
|
|
441
|
+
const markerSlugs = new Set(markers.map((match) => match[1]));
|
|
442
|
+
const declaredSlugs = new Set();
|
|
443
|
+
let resolvedCount = 0;
|
|
444
|
+
|
|
445
|
+
if (!Array.isArray(los)) {
|
|
446
|
+
diagnostics.push(diagnostic({
|
|
447
|
+
severity: 'error',
|
|
448
|
+
code: 'qa.learning_objectives.invalid_declaration',
|
|
449
|
+
message: 'Frontmatter los must be an array of objective objects.',
|
|
450
|
+
book,
|
|
451
|
+
check: 'learning_objectives',
|
|
452
|
+
file: chapter.file,
|
|
453
|
+
line: chapter.frontmatterLines.los ?? 2,
|
|
454
|
+
}));
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
entries.forEach((entry, index) => {
|
|
458
|
+
const anchor = isRecord(entry) && typeof entry.anchor === 'string'
|
|
459
|
+
? entry.anchor.trim()
|
|
460
|
+
: '';
|
|
461
|
+
if (!anchor) {
|
|
462
|
+
diagnostics.push(diagnostic({
|
|
463
|
+
severity: 'error',
|
|
464
|
+
code: 'qa.learning_objectives.missing_anchor',
|
|
465
|
+
message: `Learning objective ${index + 1} has no non-empty anchor.`,
|
|
466
|
+
book,
|
|
467
|
+
check: 'learning_objectives',
|
|
468
|
+
file: chapter.file,
|
|
469
|
+
line: chapter.frontmatterLines.los ?? 2,
|
|
470
|
+
}));
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
declaredSlugs.add(anchor);
|
|
474
|
+
if (markerSlugs.has(anchor)) {
|
|
475
|
+
resolvedCount += 1;
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
diagnostics.push(diagnostic({
|
|
479
|
+
severity: 'error',
|
|
480
|
+
code: 'qa.learning_objectives.unresolved_anchor',
|
|
481
|
+
message: `Learning objective anchor ${JSON.stringify(anchor)} has no matching prose marker.`,
|
|
482
|
+
book,
|
|
483
|
+
check: 'learning_objectives',
|
|
484
|
+
file: chapter.file,
|
|
485
|
+
line: chapter.frontmatterLines.los ?? 2,
|
|
486
|
+
}));
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
for (const marker of markers) {
|
|
490
|
+
if (declaredSlugs.has(marker[1])) continue;
|
|
491
|
+
diagnostics.push(diagnostic({
|
|
492
|
+
severity: 'error',
|
|
493
|
+
code: 'qa.learning_objectives.orphan_marker',
|
|
494
|
+
message: `Prose anchor marker ${JSON.stringify(marker[1])} has no declared learning objective.`,
|
|
495
|
+
book,
|
|
496
|
+
check: 'learning_objectives',
|
|
497
|
+
file: chapter.file,
|
|
498
|
+
line: chapter.bodyLineOffset + lineOf(chapter.body, marker.index),
|
|
499
|
+
}));
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
return {
|
|
503
|
+
applicable: true,
|
|
504
|
+
declared: entries.length,
|
|
505
|
+
resolved: resolvedCount,
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
async function inspectChapter({
|
|
510
|
+
root,
|
|
511
|
+
path,
|
|
512
|
+
file,
|
|
513
|
+
book,
|
|
514
|
+
runDir,
|
|
515
|
+
preset,
|
|
516
|
+
config,
|
|
517
|
+
parser,
|
|
518
|
+
headingProcessor,
|
|
519
|
+
}) {
|
|
520
|
+
const source = await readFile(path, 'utf8');
|
|
521
|
+
let parsed;
|
|
522
|
+
try {
|
|
523
|
+
parsed = parseFrontmatter(source, file);
|
|
524
|
+
} catch {
|
|
525
|
+
parsed = { frontmatter: {}, body: source, lines: {} };
|
|
526
|
+
}
|
|
527
|
+
const localIdFromFile = posix(relative(runDir, path)).replace(/\.mdx?$/, '');
|
|
528
|
+
const localId = typeof parsed.frontmatter.slug === 'string' && parsed.frontmatter.slug.length > 0
|
|
529
|
+
? parsed.frontmatter.slug
|
|
530
|
+
: localIdFromFile;
|
|
531
|
+
const route = chapterRouteFor(config, book, localId, parsed.frontmatter);
|
|
532
|
+
const result = {
|
|
533
|
+
path,
|
|
534
|
+
file,
|
|
535
|
+
source,
|
|
536
|
+
body: parsed.body,
|
|
537
|
+
frontmatter: parsed.frontmatter,
|
|
538
|
+
frontmatterLines: parsed.lines,
|
|
539
|
+
bodyLineOffset: source.slice(0, source.length - parsed.body.length).split('\n').length - 1,
|
|
540
|
+
localId,
|
|
541
|
+
route,
|
|
542
|
+
draft: parsed.frontmatter.draft === true,
|
|
543
|
+
hasObjectives: Object.prototype.hasOwnProperty.call(parsed.frontmatter, 'los'),
|
|
544
|
+
components: new Map(),
|
|
545
|
+
anchors: new Set(),
|
|
546
|
+
uncertainAnchors: new Set(),
|
|
547
|
+
targets: [],
|
|
548
|
+
parseDiagnostics: [],
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
try {
|
|
552
|
+
const tree = parser.parse(parsed.body);
|
|
553
|
+
const inspected = inspectMdxTree(tree);
|
|
554
|
+
result.components = inspected.components;
|
|
555
|
+
for (const id of inspected.ids) result.anchors.add(id);
|
|
556
|
+
for (const id of inspected.uncertainIds) result.uncertainAnchors.add(id);
|
|
557
|
+
} catch (error) {
|
|
558
|
+
result.parseDiagnostics.push(diagnostic({
|
|
559
|
+
severity: 'warning',
|
|
560
|
+
code: 'qa.components.parse_failed',
|
|
561
|
+
message: `Could not count scaffold components: ${error?.reason ?? error?.message ?? error}`,
|
|
562
|
+
book: book?.id ?? QA_SINGLE_BOOK_ID,
|
|
563
|
+
check: 'components',
|
|
564
|
+
file,
|
|
565
|
+
line: result.bodyLineOffset + (error?.position?.start?.line ?? error?.line ?? 1),
|
|
566
|
+
}));
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
try {
|
|
570
|
+
result.targets = findAuthoredTargets(source, {
|
|
571
|
+
format: extname(file).toLowerCase() === '.md' ? 'md' : 'mdx',
|
|
572
|
+
math: mathEnabled(preset),
|
|
573
|
+
}).filter((target) => LINK_KINDS.has(target.kind));
|
|
574
|
+
} catch (error) {
|
|
575
|
+
result.parseDiagnostics.push(diagnostic({
|
|
576
|
+
severity: 'error',
|
|
577
|
+
code: 'qa.links.parse_failed',
|
|
578
|
+
message: `Could not inspect internal links: ${error?.reason ?? error?.message ?? error}`,
|
|
579
|
+
book: book?.id ?? QA_SINGLE_BOOK_ID,
|
|
580
|
+
check: 'links',
|
|
581
|
+
file,
|
|
582
|
+
line: error?.position?.start?.line ?? error?.line ?? 1,
|
|
583
|
+
}));
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
try {
|
|
587
|
+
const rendered = await headingProcessor.render(parsed.body, {
|
|
588
|
+
fileURL: pathToFileURL(path),
|
|
589
|
+
frontmatter: parsed.frontmatter,
|
|
590
|
+
});
|
|
591
|
+
for (const heading of rendered.metadata.headings ?? []) {
|
|
592
|
+
if (heading.depth >= 2 && heading.depth <= 6) result.anchors.add(heading.slug);
|
|
593
|
+
}
|
|
594
|
+
} catch (error) {
|
|
595
|
+
result.parseDiagnostics.push(diagnostic({
|
|
596
|
+
severity: 'error',
|
|
597
|
+
code: 'qa.links.anchor_index_failed',
|
|
598
|
+
message: `Could not index chapter anchors: ${error?.message ?? error}`,
|
|
599
|
+
book: book?.id ?? QA_SINGLE_BOOK_ID,
|
|
600
|
+
check: 'links',
|
|
601
|
+
file,
|
|
602
|
+
line: result.bodyLineOffset + (error?.position?.start?.line ?? 1),
|
|
603
|
+
}));
|
|
604
|
+
}
|
|
605
|
+
return result;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
async function collectChapters({ root, chaptersRoot, corpus, preset, config }) {
|
|
609
|
+
const parser = mdxParser(preset);
|
|
610
|
+
const headingProcessor = await createMarkdownProcessor({ syntaxHighlight: false });
|
|
611
|
+
const runs = corpus
|
|
612
|
+
? corpus.books.map((book) => ({ book, dir: resolve(chaptersRoot, book.id) }))
|
|
613
|
+
: [{ book: null, dir: chaptersRoot }];
|
|
614
|
+
const chapters = new Map(runs.map((run) => [run.book?.id ?? QA_SINGLE_BOOK_ID, []]));
|
|
615
|
+
for (const run of runs) {
|
|
616
|
+
for await (const localFile of walkMdx(run.dir)) {
|
|
617
|
+
const path = resolve(run.dir, localFile);
|
|
618
|
+
const file = sourcePath(root, path);
|
|
619
|
+
chapters.get(run.book?.id ?? QA_SINGLE_BOOK_ID).push(await inspectChapter({
|
|
620
|
+
root,
|
|
621
|
+
path,
|
|
622
|
+
file,
|
|
623
|
+
book: run.book,
|
|
624
|
+
runDir: run.dir,
|
|
625
|
+
preset,
|
|
626
|
+
config,
|
|
627
|
+
parser,
|
|
628
|
+
headingProcessor,
|
|
629
|
+
}));
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
return chapters;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function escapeRegex(value) {
|
|
636
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function routePatternMatcher(pattern) {
|
|
640
|
+
const normalized = `/${String(pattern).replace(/^\/+|\/+$/g, '')}`;
|
|
641
|
+
if (normalized === '/') return /^\/?$/;
|
|
642
|
+
const segments = normalized.slice(1).split('/');
|
|
643
|
+
let expression = '^';
|
|
644
|
+
for (const segment of segments) {
|
|
645
|
+
if (/^\[\.\.\.[^\]]+\]$/.test(segment)) {
|
|
646
|
+
expression += '(?:/(?:.*))?';
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
let cursor = 0;
|
|
650
|
+
let compiled = '';
|
|
651
|
+
for (const match of segment.matchAll(/\[(\.\.\.)?[^\]]+\]/g)) {
|
|
652
|
+
compiled += escapeRegex(segment.slice(cursor, match.index));
|
|
653
|
+
compiled += match[1] ? '(?:.*)' : '[^/]+';
|
|
654
|
+
cursor = match.index + match[0].length;
|
|
655
|
+
}
|
|
656
|
+
expression += `/${compiled}${escapeRegex(segment.slice(cursor))}`;
|
|
657
|
+
}
|
|
658
|
+
return new RegExp(`${expression}/?$`);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
async function collectConsumerRoutes(root) {
|
|
662
|
+
const matchers = [];
|
|
663
|
+
const pagesRoot = resolve(root, 'src/pages');
|
|
664
|
+
for await (const file of walkFiles(pagesRoot, pagesRoot, { includeWellKnown: true })) {
|
|
665
|
+
if (!/\.(?:astro|html|md|markdown|mdown|mkdn|mkd|mdwn|mdx|js|ts)$/.test(file)) continue;
|
|
666
|
+
const withoutExtension = file.replace(/\.[^.]+$/, '');
|
|
667
|
+
const segments = withoutExtension.split('/');
|
|
668
|
+
if (segments.at(-1) === 'index') segments.pop();
|
|
669
|
+
matchers.push(routePatternMatcher(`/${segments.join('/')}`));
|
|
670
|
+
}
|
|
671
|
+
return matchers;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
async function collectPublicRoutes(root) {
|
|
675
|
+
const routes = new Set();
|
|
676
|
+
const publicRoot = resolve(root, 'public');
|
|
677
|
+
for await (const file of walkFiles(publicRoot, publicRoot, { includeHidden: true })) {
|
|
678
|
+
routes.add(normalizeRoute(`/${file}`));
|
|
679
|
+
if (file.endsWith('/index.html') || file === 'index.html') {
|
|
680
|
+
routes.add(normalizeRoute(`/${file.replace(/(?:^|\/)index\.html$/, '')}`));
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
return routes;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function knownScaffoldRoutes(config, corpus) {
|
|
687
|
+
const enabled = new Set(config.enabledRoutes ?? []);
|
|
688
|
+
const routes = new Set();
|
|
689
|
+
const apparatusRouteByToggle = {
|
|
690
|
+
references: 'references',
|
|
691
|
+
print: 'print',
|
|
692
|
+
convergence: 'convergence',
|
|
693
|
+
tips: 'tips',
|
|
694
|
+
exercises: 'exercises',
|
|
695
|
+
practiceExam: 'practice-exam',
|
|
696
|
+
glossary: 'glossary',
|
|
697
|
+
answers: 'answers',
|
|
698
|
+
flashcards: 'flashcards',
|
|
699
|
+
};
|
|
700
|
+
if (!corpus) {
|
|
701
|
+
if (enabled.has('landing')) routes.add('/');
|
|
702
|
+
if (enabled.has('chapters')) routes.add('/chapters');
|
|
703
|
+
if (enabled.has('search')) routes.add('/search');
|
|
704
|
+
for (const [toggle, route] of Object.entries(apparatusRouteByToggle)) {
|
|
705
|
+
if (!enabled.has(toggle)) continue;
|
|
706
|
+
routes.add(normalizeRoute(routeTokens(config.apparatusRoute, {
|
|
707
|
+
book: '', id: '', slug: '', route,
|
|
708
|
+
})));
|
|
709
|
+
}
|
|
710
|
+
return routes;
|
|
711
|
+
}
|
|
712
|
+
if (enabled.has('landing')) routes.add('/');
|
|
713
|
+
if (enabled.has('chapters')) routes.add('/chapters');
|
|
714
|
+
if (enabled.has('search')) routes.add('/search');
|
|
715
|
+
for (const book of corpus.books) {
|
|
716
|
+
if (enabled.has('landing')) routes.add(normalizeRoute(`/${book.id}`));
|
|
717
|
+
if (enabled.has('chapters')) routes.add(normalizeRoute(`/chapters/${book.id}`));
|
|
718
|
+
for (const route of book.apparatus ?? config.apparatusRoutes ?? []) {
|
|
719
|
+
routes.add(normalizeRoute(routeTokens(config.apparatusRoute, {
|
|
720
|
+
book: book.id, id: '', slug: '', route,
|
|
721
|
+
})));
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
return routes;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function knownScaffoldRouteMatchers(config) {
|
|
728
|
+
const enabled = new Set(config.enabledRoutes ?? []);
|
|
729
|
+
return enabled.has('frontmatter')
|
|
730
|
+
? [routePatternMatcher(config.frontmatterRoute)]
|
|
731
|
+
: [];
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function internalTarget(target, currentRoute, base) {
|
|
735
|
+
const value = target.trim();
|
|
736
|
+
if (!value || value.startsWith('//')) return null;
|
|
737
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(value)) return null;
|
|
738
|
+
let url;
|
|
739
|
+
try {
|
|
740
|
+
url = new URL(value, `${ORIGIN}${currentRoute.endsWith('/') ? currentRoute : `${currentRoute}/`}`);
|
|
741
|
+
} catch {
|
|
742
|
+
return { malformed: true, pathname: '', fragment: '', original: target };
|
|
743
|
+
}
|
|
744
|
+
if (url.origin !== ORIGIN) return null;
|
|
745
|
+
const normalizedBase = normalizeAstroBase(base);
|
|
746
|
+
let pathname = url.pathname;
|
|
747
|
+
if (
|
|
748
|
+
normalizedBase !== '/' &&
|
|
749
|
+
(pathname === normalizedBase || pathname.startsWith(`${normalizedBase}/`))
|
|
750
|
+
) {
|
|
751
|
+
pathname = pathname.slice(normalizedBase.length) || '/';
|
|
752
|
+
}
|
|
753
|
+
let fragment = url.hash.slice(1);
|
|
754
|
+
try {
|
|
755
|
+
fragment = decodeURIComponent(fragment);
|
|
756
|
+
} catch {
|
|
757
|
+
return { malformed: true, pathname, fragment, original: target };
|
|
758
|
+
}
|
|
759
|
+
return {
|
|
760
|
+
malformed: false,
|
|
761
|
+
pathname: normalizeRoute(pathname),
|
|
762
|
+
fragment,
|
|
763
|
+
original: target,
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
async function analyzeLinks({ root, chapters, selectedIds, config, corpus, diagnosticsByBook }) {
|
|
768
|
+
const routeAnchors = new Map();
|
|
769
|
+
const scaffoldChaptersEnabled = (config.enabledRoutes ?? []).includes('chapters');
|
|
770
|
+
for (const entries of chapters.values()) {
|
|
771
|
+
for (const chapter of entries) {
|
|
772
|
+
// Draft sources are scanned for outgoing defects, but Astro does not
|
|
773
|
+
// publish their chapter routes, so they cannot satisfy an inbound link.
|
|
774
|
+
if (scaffoldChaptersEnabled && !chapter.draft) {
|
|
775
|
+
routeAnchors.set(chapter.route, {
|
|
776
|
+
verified: new Set([...chapter.anchors, 'provenance-h']),
|
|
777
|
+
uncertain: chapter.uncertainAnchors,
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
const scaffoldRoutes = knownScaffoldRoutes(config, corpus);
|
|
783
|
+
const scaffoldMatchers = knownScaffoldRouteMatchers(config);
|
|
784
|
+
const consumerMatchers = await collectConsumerRoutes(root);
|
|
785
|
+
const publicRoutes = await collectPublicRoutes(root);
|
|
786
|
+
const metrics = new Map();
|
|
787
|
+
|
|
788
|
+
for (const book of selectedIds) {
|
|
789
|
+
let checked = 0;
|
|
790
|
+
let broken = 0;
|
|
791
|
+
let skippedFragments = 0;
|
|
792
|
+
const bookDiagnostics = diagnosticsByBook.get(book);
|
|
793
|
+
for (const chapter of chapters.get(book) ?? []) {
|
|
794
|
+
bookDiagnostics.push(...chapter.parseDiagnostics.filter((entry) => entry.check === 'links'));
|
|
795
|
+
broken += chapter.parseDiagnostics.filter(
|
|
796
|
+
(entry) => entry.check === 'links' && entry.severity === 'error',
|
|
797
|
+
).length;
|
|
798
|
+
for (const authored of chapter.targets) {
|
|
799
|
+
const target = internalTarget(authored.target, chapter.route, config.base);
|
|
800
|
+
if (target === null) continue;
|
|
801
|
+
checked += 1;
|
|
802
|
+
let reason = null;
|
|
803
|
+
const localTarget = authored.target.trim().startsWith('#');
|
|
804
|
+
const indexed = localTarget
|
|
805
|
+
? {
|
|
806
|
+
verified: scaffoldChaptersEnabled
|
|
807
|
+
? new Set([...chapter.anchors, 'provenance-h'])
|
|
808
|
+
: chapter.anchors,
|
|
809
|
+
uncertain: chapter.uncertainAnchors,
|
|
810
|
+
}
|
|
811
|
+
: routeAnchors.get(target.pathname);
|
|
812
|
+
if (target.malformed) {
|
|
813
|
+
reason = 'is malformed';
|
|
814
|
+
} else {
|
|
815
|
+
const routeExists = localTarget ||
|
|
816
|
+
routeAnchors.has(target.pathname) ||
|
|
817
|
+
scaffoldRoutes.has(target.pathname) ||
|
|
818
|
+
publicRoutes.has(target.pathname) ||
|
|
819
|
+
scaffoldMatchers.some((matcher) => matcher.test(target.pathname)) ||
|
|
820
|
+
consumerMatchers.some((matcher) => matcher.test(target.pathname));
|
|
821
|
+
if (!routeExists) reason = 'does not resolve to a known route or public asset';
|
|
822
|
+
else if (target.fragment && indexed) {
|
|
823
|
+
if (indexed.verified.has(target.fragment)) {
|
|
824
|
+
// Verified chapter/local fragment.
|
|
825
|
+
} else if (indexed.uncertain.has(target.fragment)) {
|
|
826
|
+
skippedFragments += 1;
|
|
827
|
+
bookDiagnostics.push(diagnostic({
|
|
828
|
+
severity: 'warning',
|
|
829
|
+
code: 'qa.links.fragment_unverified',
|
|
830
|
+
message: `Internal link ${JSON.stringify(authored.target)} may be rendered by a custom MDX component, so QA could not verify its fragment.`,
|
|
831
|
+
book,
|
|
832
|
+
check: 'links',
|
|
833
|
+
file: chapter.file,
|
|
834
|
+
line: lineOf(chapter.source, authored.index),
|
|
835
|
+
}));
|
|
836
|
+
} else {
|
|
837
|
+
reason = `has no fragment ${JSON.stringify(target.fragment)}`;
|
|
838
|
+
}
|
|
839
|
+
} else if (target.fragment) {
|
|
840
|
+
skippedFragments += 1;
|
|
841
|
+
bookDiagnostics.push(diagnostic({
|
|
842
|
+
severity: 'warning',
|
|
843
|
+
code: 'qa.links.fragment_unverified',
|
|
844
|
+
message: `Internal link ${JSON.stringify(authored.target)} reaches a known non-chapter route, but QA could not index that route's fragments.`,
|
|
845
|
+
book,
|
|
846
|
+
check: 'links',
|
|
847
|
+
file: chapter.file,
|
|
848
|
+
line: lineOf(chapter.source, authored.index),
|
|
849
|
+
}));
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
if (!reason) continue;
|
|
853
|
+
broken += 1;
|
|
854
|
+
bookDiagnostics.push(diagnostic({
|
|
855
|
+
severity: 'error',
|
|
856
|
+
code: 'qa.links.broken_target',
|
|
857
|
+
message: `Internal link ${JSON.stringify(authored.target)} ${reason}.`,
|
|
858
|
+
book,
|
|
859
|
+
check: 'links',
|
|
860
|
+
file: chapter.file,
|
|
861
|
+
line: lineOf(chapter.source, authored.index),
|
|
862
|
+
}));
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
metrics.set(book, { checked, broken, skippedFragments });
|
|
866
|
+
}
|
|
867
|
+
return metrics;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function jsonErrorLocation(source, error) {
|
|
871
|
+
const match = String(error?.message ?? error).match(/position\s+(\d+)/i);
|
|
872
|
+
if (!match) return {};
|
|
873
|
+
const index = Number(match[1]);
|
|
874
|
+
const before = source.slice(0, index);
|
|
875
|
+
const lines = before.split('\n');
|
|
876
|
+
return { line: lines.length, column: lines.at(-1).length + 1 };
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function schemaReference(root, fixturePath, reference) {
|
|
880
|
+
if (typeof reference !== 'string' || reference.trim().length === 0) {
|
|
881
|
+
return { error: 'must be a non-empty string' };
|
|
882
|
+
}
|
|
883
|
+
let pathPart = reference.trim();
|
|
884
|
+
const hash = pathPart.indexOf('#');
|
|
885
|
+
const fragment = hash >= 0 ? pathPart.slice(hash) : '';
|
|
886
|
+
if (hash >= 0) pathPart = pathPart.slice(0, hash);
|
|
887
|
+
if (/^https?:/i.test(pathPart)) {
|
|
888
|
+
return { error: 'must reference a local schema; QA never fetches network schemas' };
|
|
889
|
+
}
|
|
890
|
+
if (pathPart.startsWith('//') || pathPart.startsWith('\\\\')) {
|
|
891
|
+
return { error: 'must not use a protocol-relative or network path' };
|
|
892
|
+
}
|
|
893
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(pathPart) && !pathPart.startsWith('file:')) {
|
|
894
|
+
return { error: `uses unsupported URI scheme in ${JSON.stringify(reference)}` };
|
|
895
|
+
}
|
|
896
|
+
let path;
|
|
897
|
+
try {
|
|
898
|
+
if (pathPart.startsWith('file:')) path = fileURLToPath(pathPart);
|
|
899
|
+
else if (isAbsolute(pathPart)) path = resolve(root, `.${pathPart}`);
|
|
900
|
+
else path = resolve(dirname(fixturePath), pathPart);
|
|
901
|
+
} catch (error) {
|
|
902
|
+
return { error: `cannot be resolved: ${error?.message ?? error}` };
|
|
903
|
+
}
|
|
904
|
+
const rootPrefix = `${resolve(root)}${sep}`;
|
|
905
|
+
if (path !== resolve(root) && !path.startsWith(rootPrefix)) {
|
|
906
|
+
return { error: 'resolves outside the project root' };
|
|
907
|
+
}
|
|
908
|
+
if (path === fixturePath && !fragment) {
|
|
909
|
+
return { error: 'resolves to the fixture itself' };
|
|
910
|
+
}
|
|
911
|
+
return { path, fragment, reference };
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function schemaFailureMessage(errors) {
|
|
915
|
+
if (!Array.isArray(errors) || errors.length === 0) return 'does not satisfy its JSON Schema';
|
|
916
|
+
return errors
|
|
917
|
+
.map((error) => {
|
|
918
|
+
const at = error?.instancePath || error?.dataPath || '/';
|
|
919
|
+
return `${at}: ${error?.message ?? String(error)}`;
|
|
920
|
+
})
|
|
921
|
+
.sort((a, b) => a.localeCompare(b))
|
|
922
|
+
.join('; ');
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
function* nestedSchemaReferences(value) {
|
|
926
|
+
if (Array.isArray(value)) {
|
|
927
|
+
for (const entry of value) yield* nestedSchemaReferences(entry);
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
if (!isRecord(value)) return;
|
|
931
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
932
|
+
if (
|
|
933
|
+
(key === '$ref' || key === '$dynamicRef' || key === '$recursiveRef') &&
|
|
934
|
+
typeof entry === 'string'
|
|
935
|
+
) {
|
|
936
|
+
yield entry;
|
|
937
|
+
} else {
|
|
938
|
+
yield* nestedSchemaReferences(entry);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
async function analyzeDemoFixtures({
|
|
944
|
+
root,
|
|
945
|
+
corpus,
|
|
946
|
+
selectedIds,
|
|
947
|
+
validateJsonSchema,
|
|
948
|
+
diagnosticsByBook,
|
|
949
|
+
sharedDiagnostics,
|
|
950
|
+
}) {
|
|
951
|
+
const dataRoot = resolve(root, 'src/data');
|
|
952
|
+
const canonicalRoot = await realpath(root);
|
|
953
|
+
const candidates = [];
|
|
954
|
+
for await (const file of walkFiles(dataRoot, dataRoot, { includeHidden: true })) {
|
|
955
|
+
if (!file.endsWith('.json') || GENERATED_DATA_PATHS.has(file)) continue;
|
|
956
|
+
const path = resolve(dataRoot, file);
|
|
957
|
+
const source = await readFile(path, 'utf8');
|
|
958
|
+
let value;
|
|
959
|
+
let parseError = null;
|
|
960
|
+
try {
|
|
961
|
+
value = JSON.parse(source);
|
|
962
|
+
} catch (error) {
|
|
963
|
+
parseError = error;
|
|
964
|
+
}
|
|
965
|
+
candidates.push({ path, file: sourcePath(root, path), dataFile: file, source, value, parseError });
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
const referencedSchemas = new Set();
|
|
969
|
+
const candidateByPath = new Map(candidates.map((candidate) => [candidate.path, candidate]));
|
|
970
|
+
for (const candidate of candidates) {
|
|
971
|
+
if (candidate.parseError || !isRecord(candidate.value) || !('$schema' in candidate.value)) continue;
|
|
972
|
+
const resolved = schemaReference(root, candidate.path, candidate.value.$schema);
|
|
973
|
+
if (resolved.path) referencedSchemas.add(resolved.path);
|
|
974
|
+
}
|
|
975
|
+
const pendingSchemas = [...referencedSchemas];
|
|
976
|
+
for (let index = 0; index < pendingSchemas.length; index += 1) {
|
|
977
|
+
const candidate = candidateByPath.get(pendingSchemas[index]);
|
|
978
|
+
if (!candidate || candidate.parseError) continue;
|
|
979
|
+
for (const reference of nestedSchemaReferences(candidate.value)) {
|
|
980
|
+
if (reference.startsWith('#')) continue;
|
|
981
|
+
const resolved = schemaReference(root, candidate.path, reference);
|
|
982
|
+
if (
|
|
983
|
+
resolved.path &&
|
|
984
|
+
candidateByPath.has(resolved.path) &&
|
|
985
|
+
!referencedSchemas.has(resolved.path)
|
|
986
|
+
) {
|
|
987
|
+
referencedSchemas.add(resolved.path);
|
|
988
|
+
pendingSchemas.push(resolved.path);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
const bookMetrics = new Map(selectedIds.map((id) => [id, {
|
|
994
|
+
discovered: 0,
|
|
995
|
+
valid: 0,
|
|
996
|
+
invalid: 0,
|
|
997
|
+
schemasValidated: 0,
|
|
998
|
+
}]));
|
|
999
|
+
const sharedMetrics = { discovered: 0, valid: 0, invalid: 0, schemasValidated: 0 };
|
|
1000
|
+
const registered = new Set(corpus?.books.map((book) => book.id) ?? []);
|
|
1001
|
+
|
|
1002
|
+
for (const candidate of candidates) {
|
|
1003
|
+
if (referencedSchemas.has(candidate.path)) continue;
|
|
1004
|
+
const firstSegment = candidate.dataFile.split('/')[0];
|
|
1005
|
+
const owner = corpus && registered.has(firstSegment) ? firstSegment : null;
|
|
1006
|
+
if (owner && !selectedIds.includes(owner)) continue;
|
|
1007
|
+
const book = owner ?? (corpus ? QA_CORPUS_BOOK_ID : QA_SINGLE_BOOK_ID);
|
|
1008
|
+
const metrics = book === QA_CORPUS_BOOK_ID ? sharedMetrics : bookMetrics.get(book);
|
|
1009
|
+
const diagnostics = book === QA_CORPUS_BOOK_ID ? sharedDiagnostics : diagnosticsByBook.get(book);
|
|
1010
|
+
metrics.discovered += 1;
|
|
1011
|
+
|
|
1012
|
+
if (candidate.parseError) {
|
|
1013
|
+
metrics.invalid += 1;
|
|
1014
|
+
diagnostics.push(diagnostic({
|
|
1015
|
+
severity: 'error',
|
|
1016
|
+
code: 'qa.demo_fixtures.invalid_json',
|
|
1017
|
+
message: `Demo fixture contains invalid JSON: ${candidate.parseError.message}`,
|
|
1018
|
+
book,
|
|
1019
|
+
check: 'demo_fixtures',
|
|
1020
|
+
file: candidate.file,
|
|
1021
|
+
...jsonErrorLocation(candidate.source, candidate.parseError),
|
|
1022
|
+
}));
|
|
1023
|
+
continue;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
if (isRecord(candidate.value) && Object.prototype.hasOwnProperty.call(candidate.value, '$schema')) {
|
|
1027
|
+
const reference = schemaReference(root, candidate.path, candidate.value.$schema);
|
|
1028
|
+
if (reference.error) {
|
|
1029
|
+
metrics.invalid += 1;
|
|
1030
|
+
diagnostics.push(diagnostic({
|
|
1031
|
+
severity: 'error',
|
|
1032
|
+
code: 'qa.demo_fixtures.invalid_schema_reference',
|
|
1033
|
+
message: `Demo fixture $schema ${reference.error}.`,
|
|
1034
|
+
book,
|
|
1035
|
+
check: 'demo_fixtures',
|
|
1036
|
+
file: candidate.file,
|
|
1037
|
+
line: 1,
|
|
1038
|
+
}));
|
|
1039
|
+
continue;
|
|
1040
|
+
}
|
|
1041
|
+
let schemaSource;
|
|
1042
|
+
let schema;
|
|
1043
|
+
try {
|
|
1044
|
+
const canonicalSchemaPath = await realpath(reference.path);
|
|
1045
|
+
const local = relative(canonicalRoot, canonicalSchemaPath);
|
|
1046
|
+
if (local.startsWith('..') || isAbsolute(local)) {
|
|
1047
|
+
throw new Error('resolved schema escapes the project root after symlink resolution');
|
|
1048
|
+
}
|
|
1049
|
+
schemaSource = await readFile(reference.path, 'utf8');
|
|
1050
|
+
schema = JSON.parse(schemaSource);
|
|
1051
|
+
} catch (error) {
|
|
1052
|
+
metrics.invalid += 1;
|
|
1053
|
+
diagnostics.push(diagnostic({
|
|
1054
|
+
severity: 'error',
|
|
1055
|
+
code: 'qa.demo_fixtures.unreadable_schema',
|
|
1056
|
+
message: `Referenced JSON Schema ${JSON.stringify(sourcePath(root, reference.path))} is missing or invalid: ${error?.message ?? error}`,
|
|
1057
|
+
book,
|
|
1058
|
+
check: 'demo_fixtures',
|
|
1059
|
+
file: candidate.file,
|
|
1060
|
+
line: 1,
|
|
1061
|
+
}));
|
|
1062
|
+
continue;
|
|
1063
|
+
}
|
|
1064
|
+
if (typeof validateJsonSchema !== 'function') {
|
|
1065
|
+
throw new QaExecutionError(
|
|
1066
|
+
`QA found ${candidate.file} with $schema but no validateJsonSchema adapter was provided.`,
|
|
1067
|
+
);
|
|
1068
|
+
}
|
|
1069
|
+
let outcome;
|
|
1070
|
+
try {
|
|
1071
|
+
outcome = await validateJsonSchema({
|
|
1072
|
+
value: candidate.value,
|
|
1073
|
+
schema,
|
|
1074
|
+
schemaPath: reference.path,
|
|
1075
|
+
schemaReference: reference.reference,
|
|
1076
|
+
schemaFragment: reference.fragment,
|
|
1077
|
+
fixturePath: candidate.path,
|
|
1078
|
+
root,
|
|
1079
|
+
});
|
|
1080
|
+
} catch (error) {
|
|
1081
|
+
metrics.invalid += 1;
|
|
1082
|
+
diagnostics.push(diagnostic({
|
|
1083
|
+
severity: 'error',
|
|
1084
|
+
code: 'qa.demo_fixtures.schema_error',
|
|
1085
|
+
message: `Referenced JSON Schema could not be evaluated: ${error?.message ?? error}`,
|
|
1086
|
+
book,
|
|
1087
|
+
check: 'demo_fixtures',
|
|
1088
|
+
file: candidate.file,
|
|
1089
|
+
line: 1,
|
|
1090
|
+
}));
|
|
1091
|
+
continue;
|
|
1092
|
+
}
|
|
1093
|
+
const valid = outcome === true || outcome?.valid === true;
|
|
1094
|
+
metrics.schemasValidated += 1;
|
|
1095
|
+
if (!valid) {
|
|
1096
|
+
metrics.invalid += 1;
|
|
1097
|
+
diagnostics.push(diagnostic({
|
|
1098
|
+
severity: 'error',
|
|
1099
|
+
code: 'qa.demo_fixtures.schema_mismatch',
|
|
1100
|
+
message: `Demo fixture ${schemaFailureMessage(outcome?.errors)}.`,
|
|
1101
|
+
book,
|
|
1102
|
+
check: 'demo_fixtures',
|
|
1103
|
+
file: candidate.file,
|
|
1104
|
+
line: 1,
|
|
1105
|
+
}));
|
|
1106
|
+
continue;
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
metrics.valid += 1;
|
|
1110
|
+
}
|
|
1111
|
+
return { bookMetrics, sharedMetrics };
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
function checkFromDiagnostics(stateWhenClean, metrics, sourceDiagnostics, publicDiagnostics, check) {
|
|
1115
|
+
const relevant = sourceDiagnostics.filter((entry) => entry.check === check);
|
|
1116
|
+
const state = relevant.some((entry) => entry.severity === 'error')
|
|
1117
|
+
? 'red'
|
|
1118
|
+
: relevant.some((entry) => entry.severity === 'warning')
|
|
1119
|
+
? 'amber'
|
|
1120
|
+
: stateWhenClean;
|
|
1121
|
+
return checkResult(state, metrics, idsForCheck(publicDiagnostics, sourceDiagnostics, check));
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
function validationResultBook(entry, corpus, selectedIds) {
|
|
1125
|
+
if (!corpus) return QA_SINGLE_BOOK_ID;
|
|
1126
|
+
if (selectedIds.includes(entry?.book)) return entry.book;
|
|
1127
|
+
return QA_CORPUS_BOOK_ID;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
function validationBookResult(bookResults, id) {
|
|
1131
|
+
if (bookResults instanceof Map) return bookResults.get(id) ?? null;
|
|
1132
|
+
if (Array.isArray(bookResults)) {
|
|
1133
|
+
return bookResults.find((entry) => entry?.book === id || entry?.id === id) ?? null;
|
|
1134
|
+
}
|
|
1135
|
+
return isRecord(bookResults) && isRecord(bookResults[id]) ? bookResults[id] : null;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
async function resolveValidation(options) {
|
|
1139
|
+
if (options.validationResult) return options.validationResult;
|
|
1140
|
+
if (typeof options.runValidation !== 'function') {
|
|
1141
|
+
throw new QaExecutionError('runQa requires an injected runValidation function or validationResult.');
|
|
1142
|
+
}
|
|
1143
|
+
try {
|
|
1144
|
+
return await options.runValidation({
|
|
1145
|
+
root: options.root,
|
|
1146
|
+
argv: options.argv,
|
|
1147
|
+
env: options.env,
|
|
1148
|
+
...(options.validationOptions ?? {}),
|
|
1149
|
+
});
|
|
1150
|
+
} catch (error) {
|
|
1151
|
+
throw new QaExecutionError(`Content validation failed to execute: ${error?.message ?? error}`, {
|
|
1152
|
+
cause: error,
|
|
1153
|
+
});
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
function fatalMessage(fatal) {
|
|
1158
|
+
if (fatal instanceof Error) return fatal.message;
|
|
1159
|
+
if (typeof fatal === 'string') return fatal;
|
|
1160
|
+
if (isRecord(fatal)) return fatal.message ?? JSON.stringify(fatal);
|
|
1161
|
+
return String(fatal);
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
/**
|
|
1165
|
+
* Run the complete QA analysis without process side effects.
|
|
1166
|
+
*
|
|
1167
|
+
* `runValidation` must implement the shared validator-core contract:
|
|
1168
|
+
* ({ root, argv, env, ... }) =>
|
|
1169
|
+
* { preset, scope: { selected }, bookResults, errors?, warnings?, notices?, fatal? }
|
|
1170
|
+
* Evaluated config/corpus metadata may be omitted; QA resolves the same
|
|
1171
|
+
* tooling config as a fallback. Legacy aggregate diagnostic fields are also
|
|
1172
|
+
* accepted and de-duplicated with per-book results.
|
|
1173
|
+
*
|
|
1174
|
+
* `validateJsonSchema`, when needed, receives local parsed schema/value data
|
|
1175
|
+
* and returns `{ valid, errors? }` (or boolean true). It must never fetch.
|
|
1176
|
+
*/
|
|
1177
|
+
export async function runQa(options = {}) {
|
|
1178
|
+
const root = resolve(options.root ?? process.cwd());
|
|
1179
|
+
const argv = options.argv ?? [];
|
|
1180
|
+
const env = options.env ?? process.env;
|
|
1181
|
+
const validation = await resolveValidation({ ...options, root, argv, env });
|
|
1182
|
+
if (!isRecord(validation)) throw new QaExecutionError('Validation core returned no result object.');
|
|
1183
|
+
if (validation.fatal) {
|
|
1184
|
+
throw new QaExecutionError(`Validation reported a fatal failure: ${fatalMessage(validation.fatal)}`);
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
const preset = validation.preset;
|
|
1188
|
+
if (!QA_PRESETS.includes(preset)) {
|
|
1189
|
+
throw new QaExecutionError(
|
|
1190
|
+
`Validation returned invalid preset ${JSON.stringify(preset)}; expected ${QA_PRESETS.join(' | ')}.`,
|
|
1191
|
+
);
|
|
1192
|
+
}
|
|
1193
|
+
const config = validation.toolingConfig ?? validation.config ?? options.toolingConfig ??
|
|
1194
|
+
await loadResolvedBookConfig(root);
|
|
1195
|
+
const corpus = validation.corpus ?? config.corpus ?? null;
|
|
1196
|
+
const fallbackSelection = resolveBookSelection(config, argv, 'book-scaffold qa');
|
|
1197
|
+
const scopedBooks = Array.isArray(validation.scope?.selected)
|
|
1198
|
+
? validation.scope.selected
|
|
1199
|
+
: [];
|
|
1200
|
+
const returnedBooks = scopedBooks.length > 0
|
|
1201
|
+
? scopedBooks
|
|
1202
|
+
: Array.isArray(validation.selectedBooks)
|
|
1203
|
+
? validation.selectedBooks.map((book) => typeof book === 'string' ? book : book?.id)
|
|
1204
|
+
: [];
|
|
1205
|
+
const selectedIds = corpus
|
|
1206
|
+
? (returnedBooks.length > 0 ? returnedBooks : fallbackSelection.books.map((book) => book.id))
|
|
1207
|
+
: [QA_SINGLE_BOOK_ID];
|
|
1208
|
+
const registeredIds = new Set(corpus?.books.map((book) => book.id) ?? []);
|
|
1209
|
+
if (corpus && selectedIds.some((id) => !registeredIds.has(id))) {
|
|
1210
|
+
throw new QaExecutionError('Validation returned an unregistered selected corpus book.');
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
const diagnosticsByBook = new Map(selectedIds.map((id) => [id, []]));
|
|
1214
|
+
const sharedDiagnostics = [];
|
|
1215
|
+
const seenValidationDiagnostics = new Set();
|
|
1216
|
+
const addValidationDiagnostics = (severity, entries, forcedBook = null) => {
|
|
1217
|
+
if (!Array.isArray(entries)) {
|
|
1218
|
+
throw new QaExecutionError(`Validation ${severity} diagnostics must be an array.`);
|
|
1219
|
+
}
|
|
1220
|
+
for (const entry of entries) {
|
|
1221
|
+
const book = forcedBook ?? validationResultBook(entry, corpus, selectedIds);
|
|
1222
|
+
const normalized = validationDiagnostic(entry, severity, book);
|
|
1223
|
+
const signature = [
|
|
1224
|
+
normalized.severity,
|
|
1225
|
+
normalized.code,
|
|
1226
|
+
normalized.message,
|
|
1227
|
+
normalized.book,
|
|
1228
|
+
normalized.file ?? '',
|
|
1229
|
+
normalized.line ?? 0,
|
|
1230
|
+
normalized.column ?? 0,
|
|
1231
|
+
].join('\u0000');
|
|
1232
|
+
if (seenValidationDiagnostics.has(signature)) continue;
|
|
1233
|
+
seenValidationDiagnostics.add(signature);
|
|
1234
|
+
if (book === QA_CORPUS_BOOK_ID) sharedDiagnostics.push(normalized);
|
|
1235
|
+
else diagnosticsByBook.get(book).push(normalized);
|
|
1236
|
+
}
|
|
1237
|
+
};
|
|
1238
|
+
addValidationDiagnostics('error', validation.errors ?? []);
|
|
1239
|
+
addValidationDiagnostics('warning', validation.warnings ?? []);
|
|
1240
|
+
addValidationDiagnostics('warning', validation.notices ?? []);
|
|
1241
|
+
for (const book of selectedIds) {
|
|
1242
|
+
const bookResult = validationBookResult(validation.bookResults, book);
|
|
1243
|
+
if (!bookResult) continue;
|
|
1244
|
+
addValidationDiagnostics('error', bookResult.errors ?? [], book);
|
|
1245
|
+
addValidationDiagnostics('warning', bookResult.warnings ?? [], book);
|
|
1246
|
+
addValidationDiagnostics('warning', bookResult.notices ?? [], book);
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
const chaptersRoot = options.chaptersRoot ?? (
|
|
1250
|
+
env.BOOK_CHAPTERS_DIR
|
|
1251
|
+
? resolve(root, env.BOOK_CHAPTERS_DIR)
|
|
1252
|
+
: await readChaptersBase(root, { corpus })
|
|
1253
|
+
);
|
|
1254
|
+
const chapters = await collectChapters({ root, chaptersRoot, corpus, preset, config });
|
|
1255
|
+
|
|
1256
|
+
const chapterMetrics = new Map();
|
|
1257
|
+
const objectiveMetrics = new Map();
|
|
1258
|
+
const componentMetrics = new Map();
|
|
1259
|
+
for (const book of selectedIds) {
|
|
1260
|
+
const entries = chapters.get(book) ?? [];
|
|
1261
|
+
const nonDraft = entries.filter((chapter) => !chapter.draft).length;
|
|
1262
|
+
const draft = entries.length - nonDraft;
|
|
1263
|
+
chapterMetrics.set(book, { total: entries.length, nonDraft, draft });
|
|
1264
|
+
if (nonDraft === 0) {
|
|
1265
|
+
diagnosticsByBook.get(book).push(diagnostic({
|
|
1266
|
+
severity: 'error',
|
|
1267
|
+
code: 'qa.chapters.no_non_draft',
|
|
1268
|
+
message: 'Book must contain at least one non-draft chapter.',
|
|
1269
|
+
book,
|
|
1270
|
+
check: 'chapters',
|
|
1271
|
+
}));
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
let applicable = false;
|
|
1275
|
+
let declared = 0;
|
|
1276
|
+
let resolvedObjectives = 0;
|
|
1277
|
+
const componentCounts = new Map();
|
|
1278
|
+
for (const chapter of entries) {
|
|
1279
|
+
diagnosticsByBook.get(book).push(
|
|
1280
|
+
...chapter.parseDiagnostics.filter((entry) => entry.check === 'components'),
|
|
1281
|
+
);
|
|
1282
|
+
addCounts(componentCounts, chapter.components);
|
|
1283
|
+
const objective = objectiveAnalysis(chapter, book, diagnosticsByBook.get(book));
|
|
1284
|
+
applicable ||= objective.applicable;
|
|
1285
|
+
declared += objective.declared;
|
|
1286
|
+
resolvedObjectives += objective.resolved;
|
|
1287
|
+
}
|
|
1288
|
+
objectiveMetrics.set(book, applicable
|
|
1289
|
+
? {
|
|
1290
|
+
declared,
|
|
1291
|
+
resolved: resolvedObjectives,
|
|
1292
|
+
coverage: declared === 0 ? 1 : resolvedObjectives / declared,
|
|
1293
|
+
}
|
|
1294
|
+
: null);
|
|
1295
|
+
componentMetrics.set(book, stableComponentMetrics(componentCounts));
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
const linkMetrics = await analyzeLinks({
|
|
1299
|
+
root,
|
|
1300
|
+
chapters,
|
|
1301
|
+
selectedIds,
|
|
1302
|
+
config,
|
|
1303
|
+
corpus,
|
|
1304
|
+
diagnosticsByBook,
|
|
1305
|
+
});
|
|
1306
|
+
const fixtureAnalysis = await analyzeDemoFixtures({
|
|
1307
|
+
root,
|
|
1308
|
+
corpus,
|
|
1309
|
+
selectedIds,
|
|
1310
|
+
validateJsonSchema: options.validateJsonSchema,
|
|
1311
|
+
diagnosticsByBook,
|
|
1312
|
+
sharedDiagnostics,
|
|
1313
|
+
});
|
|
1314
|
+
|
|
1315
|
+
const books = {};
|
|
1316
|
+
for (const book of selectedIds) {
|
|
1317
|
+
const sourceDiagnostics = diagnosticsByBook.get(book);
|
|
1318
|
+
const publicDiagnostics = finalizeDiagnostics(book, sourceDiagnostics);
|
|
1319
|
+
const objective = objectiveMetrics.get(book);
|
|
1320
|
+
const fixtureMetrics = fixtureAnalysis.bookMetrics.get(book);
|
|
1321
|
+
const checks = {
|
|
1322
|
+
content_contract: contentContractCheck(
|
|
1323
|
+
sourceDiagnostics.filter((entry) => entry.check === 'content_contract'),
|
|
1324
|
+
publicDiagnostics,
|
|
1325
|
+
),
|
|
1326
|
+
chapters: checkFromDiagnostics(
|
|
1327
|
+
'green', chapterMetrics.get(book), sourceDiagnostics, publicDiagnostics, 'chapters',
|
|
1328
|
+
),
|
|
1329
|
+
links: checkFromDiagnostics(
|
|
1330
|
+
'green', linkMetrics.get(book), sourceDiagnostics, publicDiagnostics, 'links',
|
|
1331
|
+
),
|
|
1332
|
+
learning_objectives: objective === null
|
|
1333
|
+
? checkResult('not_applicable', {}, [])
|
|
1334
|
+
: checkFromDiagnostics(
|
|
1335
|
+
'green', objective, sourceDiagnostics, publicDiagnostics, 'learning_objectives',
|
|
1336
|
+
),
|
|
1337
|
+
components: checkFromDiagnostics(
|
|
1338
|
+
'green', componentMetrics.get(book), sourceDiagnostics, publicDiagnostics, 'components',
|
|
1339
|
+
),
|
|
1340
|
+
demo_fixtures: fixtureMetrics.discovered === 0
|
|
1341
|
+
? checkResult('not_applicable', fixtureMetrics, [])
|
|
1342
|
+
: checkFromDiagnostics(
|
|
1343
|
+
'green', fixtureMetrics, sourceDiagnostics, publicDiagnostics, 'demo_fixtures',
|
|
1344
|
+
),
|
|
1345
|
+
};
|
|
1346
|
+
books[book] = {
|
|
1347
|
+
verdict: worstState(Object.values(checks).map((check) => check.state)),
|
|
1348
|
+
checks,
|
|
1349
|
+
diagnostics: publicDiagnostics,
|
|
1350
|
+
};
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
let shared;
|
|
1354
|
+
if (!corpus) {
|
|
1355
|
+
shared = { verdict: 'not_applicable', checks: {}, diagnostics: [] };
|
|
1356
|
+
} else {
|
|
1357
|
+
const sourceDiagnostics = sharedDiagnostics;
|
|
1358
|
+
const publicDiagnostics = finalizeDiagnostics(QA_CORPUS_BOOK_ID, sourceDiagnostics);
|
|
1359
|
+
const checks = {};
|
|
1360
|
+
const contentDiagnostics = sourceDiagnostics.filter(
|
|
1361
|
+
(entry) => entry.check === 'content_contract',
|
|
1362
|
+
);
|
|
1363
|
+
if (contentDiagnostics.length > 0) {
|
|
1364
|
+
checks.content_contract = contentContractCheck(contentDiagnostics, publicDiagnostics);
|
|
1365
|
+
}
|
|
1366
|
+
checks.demo_fixtures = fixtureAnalysis.sharedMetrics.discovered === 0
|
|
1367
|
+
? checkResult('not_applicable', fixtureAnalysis.sharedMetrics, [])
|
|
1368
|
+
: checkFromDiagnostics(
|
|
1369
|
+
'green',
|
|
1370
|
+
fixtureAnalysis.sharedMetrics,
|
|
1371
|
+
sourceDiagnostics,
|
|
1372
|
+
publicDiagnostics,
|
|
1373
|
+
'demo_fixtures',
|
|
1374
|
+
);
|
|
1375
|
+
shared = {
|
|
1376
|
+
verdict: worstState(Object.values(checks).map((check) => check.state)),
|
|
1377
|
+
checks,
|
|
1378
|
+
diagnostics: publicDiagnostics,
|
|
1379
|
+
};
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
const allDiagnostics = [
|
|
1383
|
+
...Object.values(books).flatMap((book) => book.diagnostics),
|
|
1384
|
+
...shared.diagnostics,
|
|
1385
|
+
];
|
|
1386
|
+
const verdict = worstState([
|
|
1387
|
+
...Object.values(books).map((book) => book.verdict),
|
|
1388
|
+
shared.verdict,
|
|
1389
|
+
]);
|
|
1390
|
+
return {
|
|
1391
|
+
schemaVersion: QA_SCHEMA_VERSION,
|
|
1392
|
+
preset,
|
|
1393
|
+
scope: {
|
|
1394
|
+
kind: corpus ? 'corpus' : 'single',
|
|
1395
|
+
selected: [...selectedIds],
|
|
1396
|
+
},
|
|
1397
|
+
verdict,
|
|
1398
|
+
books,
|
|
1399
|
+
shared,
|
|
1400
|
+
summary: {
|
|
1401
|
+
booksChecked: selectedIds.length,
|
|
1402
|
+
blockingFailures: allDiagnostics.filter((entry) => entry.severity === 'error').length,
|
|
1403
|
+
advisories: allDiagnostics.filter((entry) => entry.severity === 'warning').length,
|
|
1404
|
+
},
|
|
1405
|
+
};
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
const STATE_LABEL = Object.freeze({
|
|
1409
|
+
green: 'GREEN',
|
|
1410
|
+
amber: 'AMBER',
|
|
1411
|
+
red: 'RED',
|
|
1412
|
+
not_applicable: 'N/A',
|
|
1413
|
+
});
|
|
1414
|
+
const STATE_COLOR = Object.freeze({ green: 32, amber: 33, red: 31, not_applicable: 2 });
|
|
1415
|
+
|
|
1416
|
+
function stateLabel(state, color) {
|
|
1417
|
+
const label = STATE_LABEL[state] ?? String(state).toUpperCase();
|
|
1418
|
+
return color ? `\u001b[${STATE_COLOR[state] ?? 0}m${label}\u001b[0m` : label;
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
function checkSummary(name, check) {
|
|
1422
|
+
const metrics = check.metrics;
|
|
1423
|
+
switch (name) {
|
|
1424
|
+
case 'content_contract':
|
|
1425
|
+
return `${metrics.errors} error${metrics.errors === 1 ? '' : 's'}, ` +
|
|
1426
|
+
`${metrics.advisories} ${metrics.advisories === 1 ? 'advisory' : 'advisories'}`;
|
|
1427
|
+
case 'chapters':
|
|
1428
|
+
return `${metrics.nonDraft} ready, ${metrics.draft} draft`;
|
|
1429
|
+
case 'links':
|
|
1430
|
+
return `${metrics.checked} checked` +
|
|
1431
|
+
(metrics.broken ? `, ${metrics.broken} broken` : '') +
|
|
1432
|
+
(metrics.skippedFragments ? `, ${metrics.skippedFragments} fragment checks skipped` : '');
|
|
1433
|
+
case 'learning_objectives':
|
|
1434
|
+
return check.state === 'not_applicable'
|
|
1435
|
+
? 'objective-anchor convention not exposed'
|
|
1436
|
+
: `${metrics.resolved}/${metrics.declared} anchors`;
|
|
1437
|
+
case 'components': {
|
|
1438
|
+
const entries = Object.entries(metrics.byName ?? {});
|
|
1439
|
+
return entries.length === 0
|
|
1440
|
+
? 'none found'
|
|
1441
|
+
: entries.map(([component, count]) => `${component} ${count}`).join(', ');
|
|
1442
|
+
}
|
|
1443
|
+
case 'demo_fixtures':
|
|
1444
|
+
return check.state === 'not_applicable'
|
|
1445
|
+
? 'none discovered'
|
|
1446
|
+
: `${metrics.valid}/${metrics.discovered} valid` +
|
|
1447
|
+
(metrics.schemasValidated ? `, ${metrics.schemasValidated} schema-validated` : '') +
|
|
1448
|
+
(metrics.invalid ? `, ${metrics.invalid} invalid` : '');
|
|
1449
|
+
default:
|
|
1450
|
+
return '';
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
function diagnosticLine(entry) {
|
|
1455
|
+
const location = entry.file
|
|
1456
|
+
? `${entry.file}${entry.line ? `:${entry.line}${entry.column ? `:${entry.column}` : ''}` : ''}`
|
|
1457
|
+
: '(no source location)';
|
|
1458
|
+
return ` ${entry.severity.toUpperCase()} [${entry.code}] ${location} ${entry.message}`;
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
function renderSection(lines, name, result, color) {
|
|
1462
|
+
lines.push(`${name.padEnd(24)} ${stateLabel(result.verdict, color)}`);
|
|
1463
|
+
for (const [checkName, check] of Object.entries(result.checks)) {
|
|
1464
|
+
lines.push(
|
|
1465
|
+
` ${checkName.padEnd(22)} ${stateLabel(check.state, color).padEnd(color ? 18 : 7)} ` +
|
|
1466
|
+
checkSummary(checkName, check),
|
|
1467
|
+
);
|
|
1468
|
+
}
|
|
1469
|
+
for (const entry of result.diagnostics) lines.push(diagnosticLine(entry));
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
/** Render the stable result as JSON with a single trailing newline. */
|
|
1473
|
+
export function renderQaJson(result) {
|
|
1474
|
+
if (result?.schemaVersion !== QA_SCHEMA_VERSION) {
|
|
1475
|
+
throw new QaExecutionError('Cannot render a QA result with an unknown schemaVersion.');
|
|
1476
|
+
}
|
|
1477
|
+
return `${JSON.stringify(result, null, 2)}\n`;
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
/** Render compact human output. Caller decides terminal color support. */
|
|
1481
|
+
export function renderQaHuman(result, { color = false } = {}) {
|
|
1482
|
+
if (result?.schemaVersion !== QA_SCHEMA_VERSION) {
|
|
1483
|
+
throw new QaExecutionError('Cannot render a QA result with an unknown schemaVersion.');
|
|
1484
|
+
}
|
|
1485
|
+
const selected = result.scope.selected.join(', ');
|
|
1486
|
+
const lines = [
|
|
1487
|
+
`preset ${result.preset}`,
|
|
1488
|
+
`scope ${result.scope.kind}${selected ? ` (${selected})` : ''}`,
|
|
1489
|
+
'',
|
|
1490
|
+
];
|
|
1491
|
+
for (const id of result.scope.selected) {
|
|
1492
|
+
renderSection(lines, id, result.books[id], color);
|
|
1493
|
+
lines.push('');
|
|
1494
|
+
}
|
|
1495
|
+
if (result.scope.kind === 'corpus') {
|
|
1496
|
+
if (result.shared.verdict !== 'not_applicable') {
|
|
1497
|
+
renderSection(lines, 'shared', result.shared, color);
|
|
1498
|
+
lines.push('');
|
|
1499
|
+
}
|
|
1500
|
+
lines.push(
|
|
1501
|
+
`${'corpus'.padEnd(24)} ${stateLabel(result.verdict, color)} ` +
|
|
1502
|
+
`${result.summary.booksChecked} book${result.summary.booksChecked === 1 ? '' : 's'} checked`,
|
|
1503
|
+
);
|
|
1504
|
+
}
|
|
1505
|
+
return `${lines.join('\n').replace(/\n+$/, '')}\n`;
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
/** Map a completed content verdict to the public QA command's CI exit code. */
|
|
1509
|
+
export function qaExitCode(result) {
|
|
1510
|
+
return result?.verdict === 'red' ? 1 : 0;
|
|
1511
|
+
}
|