explico 0.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/README.md +108 -0
- package/dist/analytics/AnalyticsEmitter.d.ts +22 -0
- package/dist/analytics/AnalyticsEmitter.js +5 -0
- package/dist/compiler/cli.d.ts +189 -0
- package/dist/compiler/cli.js +552 -0
- package/dist/compiler/collections.d.ts +70 -0
- package/dist/compiler/collections.js +383 -0
- package/dist/compiler/course-config.d.ts +31 -0
- package/dist/compiler/course-config.js +0 -0
- package/dist/compiler/lesson-derivation.d.ts +89 -0
- package/dist/compiler/lesson-derivation.js +91 -0
- package/dist/compiler/manifest.d.ts +178 -0
- package/dist/compiler/manifest.js +345 -0
- package/dist/compiler/resolution-report.d.ts +45 -0
- package/dist/compiler/resolution-report.js +97 -0
- package/dist/curriculum/validation.d.ts +76 -0
- package/dist/curriculum/validation.js +320 -0
- package/dist/progress/ProgressRepository.d.ts +53 -0
- package/dist/progress/ProgressRepository.js +12 -0
- package/dist/reference/base-library.mjs +27 -0
- package/dist/reference/citation-format.mjs +133 -0
- package/dist/reference/consistency.d.ts +64 -0
- package/dist/reference/consistency.js +234 -0
- package/dist/reference/curriculum-alignment.d.ts +7 -0
- package/dist/reference/curriculum-alignment.js +166 -0
- package/dist/reference/equations-validate.d.ts +50 -0
- package/dist/reference/equations-validate.js +90 -0
- package/dist/reference/equations.mjs +144 -0
- package/dist/reference/gate-math.d.ts +41 -0
- package/dist/reference/gate-math.js +151 -0
- package/dist/reference/katex-options.mjs +91 -0
- package/dist/reference/lab-math-scope.d.ts +21 -0
- package/dist/reference/lab-math-scope.js +28 -0
- package/dist/reference/label.d.ts +11 -0
- package/dist/reference/label.js +38 -0
- package/dist/reference/lint-ignore.d.ts +32 -0
- package/dist/reference/lint-ignore.js +39 -0
- package/dist/reference/math-glyphs.mjs +830 -0
- package/dist/reference/prose.d.ts +15 -0
- package/dist/reference/prose.js +12 -0
- package/dist/reference/references.d.ts +17 -0
- package/dist/reference/references.js +42 -0
- package/dist/reference/registry.d.ts +6 -0
- package/dist/reference/registry.js +548 -0
- package/dist/reference/rehype-fail-katex-errors.mjs +55 -0
- package/dist/reference/remark-citation.mjs +0 -0
- package/dist/reference/remark-notation.mjs +753 -0
- package/dist/reference/render-lab-math.d.ts +21 -0
- package/dist/reference/render-lab-math.js +58 -0
- package/dist/reference/types.d.ts +151 -0
- package/dist/reference/types.js +0 -0
- package/dist/reference/units-vocab.d.ts +26 -0
- package/dist/reference/units-vocab.js +119 -0
- package/dist/session/PreferenceStore.d.ts +28 -0
- package/dist/session/PreferenceStore.js +30 -0
- package/dist/session/user-context.d.ts +7 -0
- package/dist/session/user-context.js +10 -0
- package/dist/session/user.d.ts +21 -0
- package/dist/session/user.js +8 -0
- package/package.json +53 -0
- package/src/analytics/AnalyticsEmitter.ts +23 -0
- package/src/compiler/cli.ts +866 -0
- package/src/compiler/collections.ts +554 -0
- package/src/compiler/course-config.ts +32 -0
- package/src/compiler/lesson-derivation.ts +186 -0
- package/src/compiler/manifest.ts +601 -0
- package/src/compiler/resolution-report.ts +150 -0
- package/src/components/CurriculumMap.astro +208 -0
- package/src/components/HoverPanel.astro +67 -0
- package/src/components/assessments/AssessmentRunner.tsx +320 -0
- package/src/components/assessments/AssessmentSet.astro +54 -0
- package/src/components/citation/CitationLayer.astro +173 -0
- package/src/components/examples/CompactExample.astro +34 -0
- package/src/components/examples/CompactExamples.astro +253 -0
- package/src/components/hover-panel.ts +273 -0
- package/src/components/notation/NotationGlossary.astro +412 -0
- package/src/components/notation/NotationLayer.astro +594 -0
- package/src/components/notation/NotationMath.astro +24 -0
- package/src/components/notation/SafeMathNode.astro +23 -0
- package/src/components/notation/render-notation-math.ts +223 -0
- package/src/components/starlight/EquationEnhancer.astro +72 -0
- package/src/components/starlight/LayoutHeader.astro +102 -0
- package/src/components/starlight/LayoutPageSidebar.astro +33 -0
- package/src/components/starlight/LayoutSidebar.astro +29 -0
- package/src/components/starlight/LessonFooter.astro +58 -0
- package/src/content-config.ts +259 -0
- package/src/curriculum/validation.ts +481 -0
- package/src/progress/ProgressRepository.ts +64 -0
- package/src/reference/base-library.mjs +27 -0
- package/src/reference/citation-format.mjs +133 -0
- package/src/reference/consistency.ts +375 -0
- package/src/reference/curriculum-alignment.ts +228 -0
- package/src/reference/equations-validate.ts +167 -0
- package/src/reference/equations.mjs +144 -0
- package/src/reference/gate-math.ts +250 -0
- package/src/reference/katex-options.mjs +91 -0
- package/src/reference/lab-math-scope.ts +54 -0
- package/src/reference/label.ts +47 -0
- package/src/reference/lint-ignore.ts +75 -0
- package/src/reference/math-glyphs.mjs +830 -0
- package/src/reference/prose.ts +21 -0
- package/src/reference/references.ts +82 -0
- package/src/reference/registry.ts +723 -0
- package/src/reference/rehype-fail-katex-errors.mjs +55 -0
- package/src/reference/remark-citation.mjs +0 -0
- package/src/reference/remark-notation.mjs +753 -0
- package/src/reference/render-lab-math.ts +99 -0
- package/src/reference/types.ts +182 -0
- package/src/reference/units-vocab.ts +143 -0
- package/src/session/PreferenceStore.ts +54 -0
- package/src/session/user-context.ts +18 -0
- package/src/session/user.ts +24 -0
- package/src/styles/global.css +763 -0
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Corpus consistency checks (Step D6) — the Tier 1–2 checks from the cleanup
|
|
3
|
+
* plan's "Enforced checks" section that no earlier step covers.
|
|
4
|
+
*
|
|
5
|
+
* Every check here enters as a **Tier-3 warning**: `pnpm validate:content`
|
|
6
|
+
* surfaces the counts, `manifest.diagnostics.consistency` records the detail,
|
|
7
|
+
* and `resolution/consistency-report.json` is the committed, diff-checkable
|
|
8
|
+
* snapshot. Nothing here changes content — each finding is an open item for a
|
|
9
|
+
* separate human-quantitative-reviewed pass. A finding is silenced only by a
|
|
10
|
+
* reviewed line in `lint-ignore.yml` (see `./lint-ignore`).
|
|
11
|
+
*
|
|
12
|
+
* (a) glyph-unique-in-corpus one rendered glyph → one meaning corpus-wide
|
|
13
|
+
* (b) notation-units / units-vocab every entry has units or `dimensionless`,
|
|
14
|
+
* unit strings drawn from a controlled vocab
|
|
15
|
+
* (c) convention-single-definition a sign / cash-flow-perspective convention a
|
|
16
|
+
* lesson uses resolves to one declaration
|
|
17
|
+
* (e) numerals-tagged numerals in `$…$` / result tables are tagged
|
|
18
|
+
* to a `src/domain/` call or a source locator
|
|
19
|
+
* (f) weak-local a `notation.local` symbol used only in its
|
|
20
|
+
* own defining equation and prose
|
|
21
|
+
* (g) notation-source-locator every notation `sources` entry has a locator
|
|
22
|
+
*
|
|
23
|
+
* Check (d) — the committed per-lesson resolution report — lives in
|
|
24
|
+
* `src/content/resolution-report.ts`.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { CourseConventions } from '../compiler/course-config';
|
|
28
|
+
import type { CurriculumCatalog } from '../curriculum/validation';
|
|
29
|
+
import type { NotationSpec } from '../compiler/collections';
|
|
30
|
+
import type { LintIgnore } from './lint-ignore';
|
|
31
|
+
import { offVocabularyTokens } from './units-vocab';
|
|
32
|
+
import type {
|
|
33
|
+
NotationDefinitionRecord,
|
|
34
|
+
NotationRegistry,
|
|
35
|
+
NotationRegistryInput,
|
|
36
|
+
} from './types';
|
|
37
|
+
|
|
38
|
+
export type ConsistencyCode =
|
|
39
|
+
| 'glyph-unique-in-corpus'
|
|
40
|
+
| 'notation-units'
|
|
41
|
+
| 'units-vocab'
|
|
42
|
+
| 'convention-single-definition'
|
|
43
|
+
| 'numerals-tagged'
|
|
44
|
+
| 'weak-local'
|
|
45
|
+
| 'notation-source-locator';
|
|
46
|
+
|
|
47
|
+
export interface ConsistencyDiagnostic {
|
|
48
|
+
readonly code: ConsistencyCode;
|
|
49
|
+
/** Tier 3 — every finding here is a warning until content is brought clean. */
|
|
50
|
+
readonly severity: 'warning';
|
|
51
|
+
readonly message: string;
|
|
52
|
+
readonly file?: string;
|
|
53
|
+
readonly lessonId?: string;
|
|
54
|
+
readonly key?: string;
|
|
55
|
+
readonly glyph?: string;
|
|
56
|
+
/** For the aggregate numeral check. */
|
|
57
|
+
readonly count?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface ConsistencyInput {
|
|
61
|
+
readonly registry: NotationRegistry;
|
|
62
|
+
readonly notationInput: NotationRegistryInput;
|
|
63
|
+
readonly specs: readonly NotationSpec[];
|
|
64
|
+
readonly catalog: CurriculumCatalog;
|
|
65
|
+
readonly lintIgnore: LintIgnore;
|
|
66
|
+
/** Convention keys this course anchors on (from `content/course.config.ts`). */
|
|
67
|
+
readonly conventions: CourseConventions;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const CONVENTION_RE =
|
|
71
|
+
/\bsign convention\b|\bcash-flow perspective\b|\bholder(?:'s)?(?:\s+\w+){0,2}\s+perspective\b|\bfrom (?:a|one|the|its)(?:\s+\w+){0,3}\s+perspective\b|\breverses? every sign\b|\bpositive (?:means|amounts?)\b/i;
|
|
72
|
+
|
|
73
|
+
// --- shared text helpers ------------------------------------------------
|
|
74
|
+
|
|
75
|
+
function stripCode(text: string): string {
|
|
76
|
+
return text
|
|
77
|
+
.replace(/```[\s\S]*?```/g, (block) => block.replace(/[^\n]/g, ' '))
|
|
78
|
+
.replace(/(^|[^`])`[^`\n]*`/g, (match) => match.replace(/[^\n]/g, ' '));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface MathSpan {
|
|
82
|
+
readonly latex: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function mathSpans(body: string): MathSpan[] {
|
|
86
|
+
const searchable = stripCode(body);
|
|
87
|
+
const spans: MathSpan[] = [];
|
|
88
|
+
for (const match of searchable.matchAll(/\$\$([\s\S]+?)\$\$/g)) {
|
|
89
|
+
spans.push({ latex: match[1] ?? '' });
|
|
90
|
+
}
|
|
91
|
+
for (const match of searchable
|
|
92
|
+
.replace(/\$\$([\s\S]+?)\$\$/g, (block) => block.replace(/[^\n]/g, ' '))
|
|
93
|
+
.matchAll(/(?<![\\$])\$(?!\s)([^$\n]+?)(?<![\\\s])\$(?!\d)/g)) {
|
|
94
|
+
spans.push({ latex: match[1] ?? '' });
|
|
95
|
+
}
|
|
96
|
+
return spans;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function tableRows(body: string): string[] {
|
|
100
|
+
const rows: string[] = [];
|
|
101
|
+
for (const line of stripCode(body).split('\n')) {
|
|
102
|
+
const trimmed = line.trim();
|
|
103
|
+
if (!trimmed.startsWith('|') || !trimmed.slice(1).includes('|')) continue;
|
|
104
|
+
if (/^\s*\|[\s:|-]+\|\s*$/.test(line)) continue;
|
|
105
|
+
rows.push(line);
|
|
106
|
+
}
|
|
107
|
+
return rows;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const NUMERAL_RE = /(?<![\w.])-?\d+(?:\.\d+)?/g;
|
|
111
|
+
|
|
112
|
+
function numeralCount(text: string): number {
|
|
113
|
+
return (text.replace(/\\[a-zA-Z]+/g, ' ').match(NUMERAL_RE) ?? []).length;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A math span / table row is "tagged" when it carries a citation or a domain marker. */
|
|
117
|
+
function isTagged(text: string): boolean {
|
|
118
|
+
return /\[@[a-z0-9]/i.test(text) || /<!--\s*domain:/i.test(text);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// --- glyph regex for weak-local --------------------------------------
|
|
122
|
+
|
|
123
|
+
function glyphMatcher(latex: string): RegExp | undefined {
|
|
124
|
+
const trimmed = latex.trim();
|
|
125
|
+
if (trimmed === '') return undefined;
|
|
126
|
+
if (/^[A-Za-z]$/.test(trimmed)) {
|
|
127
|
+
return new RegExp(`(?<![A-Za-z\\\\])${trimmed}(?![A-Za-z])`);
|
|
128
|
+
}
|
|
129
|
+
const escaped = trimmed.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
130
|
+
return new RegExp(
|
|
131
|
+
/[A-Za-z]$/.test(trimmed) ? `${escaped}(?![A-Za-z])` : escaped,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// --- checks ----------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
function checkGlyphUniqueness(
|
|
138
|
+
input: ConsistencyInput,
|
|
139
|
+
out: ConsistencyDiagnostic[],
|
|
140
|
+
): void {
|
|
141
|
+
const byId = new Map<string, NotationDefinitionRecord>(
|
|
142
|
+
input.registry.definitions.map((definition) => [definition.id, definition]),
|
|
143
|
+
);
|
|
144
|
+
// glyph -> key -> lessons that bind it
|
|
145
|
+
const glyphs = new Map<string, Map<string, Set<string>>>();
|
|
146
|
+
const record = (glyph: string, key: string, lessonId: string): void => {
|
|
147
|
+
const trimmed = glyph.trim();
|
|
148
|
+
if (trimmed === '') return;
|
|
149
|
+
const keys = glyphs.get(trimmed) ?? new Map<string, Set<string>>();
|
|
150
|
+
const lessons = keys.get(key) ?? new Set<string>();
|
|
151
|
+
lessons.add(lessonId);
|
|
152
|
+
keys.set(key, lessons);
|
|
153
|
+
glyphs.set(trimmed, keys);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
for (const definition of input.registry.definitions) {
|
|
157
|
+
if (definition.kind === 'local') {
|
|
158
|
+
record(definition.notation, definition.key, definition.lessonId);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
for (const bundle of input.registry.bundles) {
|
|
162
|
+
for (const binding of bundle.bindings) {
|
|
163
|
+
const definition = byId.get(binding.definitionId);
|
|
164
|
+
if (definition) record(definition.notation, binding.key, bundle.lessonId);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
for (const [glyph, keys] of [...glyphs].sort(([a], [b]) =>
|
|
169
|
+
a.localeCompare(b),
|
|
170
|
+
)) {
|
|
171
|
+
if (keys.size < 2) continue;
|
|
172
|
+
if (input.lintIgnore.matches('glyph-unique-in-corpus', glyph)) continue;
|
|
173
|
+
const detail = [...keys]
|
|
174
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
175
|
+
.map(([key, lessons]) => `${key} (${[...lessons].sort().join(', ')})`)
|
|
176
|
+
.join('; ');
|
|
177
|
+
out.push({
|
|
178
|
+
code: 'glyph-unique-in-corpus',
|
|
179
|
+
severity: 'warning',
|
|
180
|
+
glyph,
|
|
181
|
+
message: `glyph ${JSON.stringify(glyph)} resolves to ${keys.size} meanings across the corpus: ${detail}`,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function checkNotationUnits(
|
|
187
|
+
input: ConsistencyInput,
|
|
188
|
+
out: ConsistencyDiagnostic[],
|
|
189
|
+
): void {
|
|
190
|
+
for (const spec of input.specs) {
|
|
191
|
+
if (spec.units === undefined && !spec.dimensionless) {
|
|
192
|
+
if (input.lintIgnore.matches('notation-units', spec.key)) continue;
|
|
193
|
+
out.push({
|
|
194
|
+
code: 'notation-units',
|
|
195
|
+
severity: 'warning',
|
|
196
|
+
key: spec.key,
|
|
197
|
+
file: spec.file,
|
|
198
|
+
...(spec.lessonId ? { lessonId: spec.lessonId } : {}),
|
|
199
|
+
message: `notation ${spec.key} (${spec.scope}) declares neither units nor dimensionless`,
|
|
200
|
+
});
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (spec.units === undefined) continue;
|
|
204
|
+
const offVocab = offVocabularyTokens(spec.units);
|
|
205
|
+
if (offVocab.length === 0) continue;
|
|
206
|
+
if (input.lintIgnore.matches('units-vocab', spec.key)) continue;
|
|
207
|
+
out.push({
|
|
208
|
+
code: 'units-vocab',
|
|
209
|
+
severity: 'warning',
|
|
210
|
+
key: spec.key,
|
|
211
|
+
file: spec.file,
|
|
212
|
+
...(spec.lessonId ? { lessonId: spec.lessonId } : {}),
|
|
213
|
+
message: `notation ${spec.key} units ${JSON.stringify(spec.units)} use non-vocabulary token(s): ${offVocab.join(', ')}`,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function checkConventionSingleDefinition(
|
|
219
|
+
input: ConsistencyInput,
|
|
220
|
+
out: ConsistencyDiagnostic[],
|
|
221
|
+
): void {
|
|
222
|
+
const lessonById = new Map(
|
|
223
|
+
input.catalog.lessons.map((lesson) => [lesson.id, lesson]),
|
|
224
|
+
);
|
|
225
|
+
for (const lesson of input.notationInput.lessons) {
|
|
226
|
+
const body = lesson.body;
|
|
227
|
+
if (!CONVENTION_RE.test(body)) continue;
|
|
228
|
+
const meta = lessonById.get(lesson.lessonId);
|
|
229
|
+
const { signConventionKey, cashFlowPerspectiveCompetency } =
|
|
230
|
+
input.conventions;
|
|
231
|
+
const anchoredByCompetency = [
|
|
232
|
+
...(meta?.teaches ?? []),
|
|
233
|
+
...(meta?.requires ?? []),
|
|
234
|
+
].includes(cashFlowPerspectiveCompetency);
|
|
235
|
+
const anchoredByTerm = new RegExp(
|
|
236
|
+
`\\[\\[\\s*${signConventionKey}\\s*\\]\\]`,
|
|
237
|
+
).test(body);
|
|
238
|
+
if (anchoredByCompetency || anchoredByTerm) continue;
|
|
239
|
+
if (
|
|
240
|
+
input.lintIgnore.matches('convention-single-definition', lesson.lessonId)
|
|
241
|
+
) {
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
out.push({
|
|
245
|
+
code: 'convention-single-definition',
|
|
246
|
+
severity: 'warning',
|
|
247
|
+
lessonId: lesson.lessonId,
|
|
248
|
+
file: lesson.source.file,
|
|
249
|
+
message: `${lesson.lessonId} states a sign / cash-flow-perspective convention in prose but does not resolve it to a single declaration ([[${signConventionKey}]] or ${cashFlowPerspectiveCompetency})`,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function checkNumeralsTagged(
|
|
255
|
+
input: ConsistencyInput,
|
|
256
|
+
out: ConsistencyDiagnostic[],
|
|
257
|
+
): void {
|
|
258
|
+
for (const lesson of input.notationInput.lessons) {
|
|
259
|
+
if (input.lintIgnore.matches('numerals-tagged', lesson.lessonId)) continue;
|
|
260
|
+
let math = 0;
|
|
261
|
+
for (const span of mathSpans(lesson.body)) {
|
|
262
|
+
if (isTagged(span.latex)) continue;
|
|
263
|
+
math += numeralCount(span.latex);
|
|
264
|
+
}
|
|
265
|
+
let table = 0;
|
|
266
|
+
for (const row of tableRows(lesson.body)) {
|
|
267
|
+
if (isTagged(row)) continue;
|
|
268
|
+
table += numeralCount(row.replace(/\$[^$\n]*\$/g, ' '));
|
|
269
|
+
}
|
|
270
|
+
const count = math + table;
|
|
271
|
+
if (count === 0) continue;
|
|
272
|
+
out.push({
|
|
273
|
+
code: 'numerals-tagged',
|
|
274
|
+
severity: 'warning',
|
|
275
|
+
lessonId: lesson.lessonId,
|
|
276
|
+
file: lesson.source.file,
|
|
277
|
+
count,
|
|
278
|
+
message: `${lesson.lessonId}: ${count} numeral(s) in $…$ / result tables (${math} in math, ${table} in tables) are not tagged to a src/domain/ call or a source locator`,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function checkWeakLocal(
|
|
284
|
+
input: ConsistencyInput,
|
|
285
|
+
out: ConsistencyDiagnostic[],
|
|
286
|
+
): void {
|
|
287
|
+
for (const lesson of input.notationInput.lessons) {
|
|
288
|
+
const spans = mathSpans(lesson.body).map((span) => span.latex);
|
|
289
|
+
for (const definition of lesson.localDefinitions) {
|
|
290
|
+
if (input.lintIgnore.matches('weak-local', definition.key)) continue;
|
|
291
|
+
const matcher = glyphMatcher(definition.notation);
|
|
292
|
+
if (!matcher) continue;
|
|
293
|
+
const uses = spans.filter((latex) => matcher.test(latex)).length;
|
|
294
|
+
if (uses > 1) continue;
|
|
295
|
+
out.push({
|
|
296
|
+
code: 'weak-local',
|
|
297
|
+
severity: 'warning',
|
|
298
|
+
lessonId: lesson.lessonId,
|
|
299
|
+
file: lesson.source.file,
|
|
300
|
+
key: definition.key,
|
|
301
|
+
count: uses,
|
|
302
|
+
message: `${lesson.lessonId}: local symbol ${definition.key} (${definition.notation}) appears in ${uses} body equation(s); referenced only in its defining equation and prose — inline candidate`,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function checkNotationSourceLocator(
|
|
309
|
+
input: ConsistencyInput,
|
|
310
|
+
out: ConsistencyDiagnostic[],
|
|
311
|
+
): void {
|
|
312
|
+
for (const spec of input.specs) {
|
|
313
|
+
for (const [index, source] of spec.sources.entries()) {
|
|
314
|
+
if (source.hasLocator) continue;
|
|
315
|
+
if (input.lintIgnore.matches('notation-source-locator', spec.key)) break;
|
|
316
|
+
out.push({
|
|
317
|
+
code: 'notation-source-locator',
|
|
318
|
+
severity: 'warning',
|
|
319
|
+
key: spec.key,
|
|
320
|
+
file: spec.file,
|
|
321
|
+
...(spec.lessonId ? { lessonId: spec.lessonId } : {}),
|
|
322
|
+
message: `notation ${spec.key} sources[${index}] (${source.id || 'bare id'}) has no non-empty locator`,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const CHECKS: ((
|
|
329
|
+
input: ConsistencyInput,
|
|
330
|
+
out: ConsistencyDiagnostic[],
|
|
331
|
+
) => void)[] = [
|
|
332
|
+
checkGlyphUniqueness,
|
|
333
|
+
checkNotationUnits,
|
|
334
|
+
checkConventionSingleDefinition,
|
|
335
|
+
checkNumeralsTagged,
|
|
336
|
+
checkWeakLocal,
|
|
337
|
+
checkNotationSourceLocator,
|
|
338
|
+
];
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Run every consistency check. The result is sorted deterministically (code,
|
|
342
|
+
* then lesson / key / glyph, then message) so it is byte-stable in the manifest
|
|
343
|
+
* and the committed report.
|
|
344
|
+
*/
|
|
345
|
+
export function runConsistencyChecks(
|
|
346
|
+
input: ConsistencyInput,
|
|
347
|
+
): ConsistencyDiagnostic[] {
|
|
348
|
+
const diagnostics: ConsistencyDiagnostic[] = [];
|
|
349
|
+
for (const check of CHECKS) check(input, diagnostics);
|
|
350
|
+
return diagnostics.sort(
|
|
351
|
+
(left, right) =>
|
|
352
|
+
left.code.localeCompare(right.code) ||
|
|
353
|
+
(left.lessonId ?? '').localeCompare(right.lessonId ?? '') ||
|
|
354
|
+
(left.key ?? '').localeCompare(right.key ?? '') ||
|
|
355
|
+
(left.glyph ?? '').localeCompare(right.glyph ?? '') ||
|
|
356
|
+
left.message.localeCompare(right.message),
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** `{ code: count }` for the CLI summary and the report header. */
|
|
361
|
+
export function consistencyCounts(
|
|
362
|
+
diagnostics: readonly ConsistencyDiagnostic[],
|
|
363
|
+
): Record<ConsistencyCode, number> {
|
|
364
|
+
const counts = {
|
|
365
|
+
'glyph-unique-in-corpus': 0,
|
|
366
|
+
'notation-units': 0,
|
|
367
|
+
'units-vocab': 0,
|
|
368
|
+
'convention-single-definition': 0,
|
|
369
|
+
'numerals-tagged': 0,
|
|
370
|
+
'weak-local': 0,
|
|
371
|
+
'notation-source-locator': 0,
|
|
372
|
+
} satisfies Record<ConsistencyCode, number>;
|
|
373
|
+
for (const diagnostic of diagnostics) counts[diagnostic.code] += 1;
|
|
374
|
+
return counts;
|
|
375
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import type { CurriculumCatalog } from '../curriculum/validation';
|
|
2
|
+
import { compareDiagnostics } from './references';
|
|
3
|
+
import type {
|
|
4
|
+
NotationDefinitionRecord,
|
|
5
|
+
NotationDiagnostic,
|
|
6
|
+
NotationRegistry,
|
|
7
|
+
} from './types';
|
|
8
|
+
|
|
9
|
+
interface EditorialCompetency {
|
|
10
|
+
readonly id: string;
|
|
11
|
+
readonly editorialStatus?: 'draft' | 'in-review' | 'reviewed';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function validateNotationAlignment(
|
|
15
|
+
registry: NotationRegistry,
|
|
16
|
+
catalog: CurriculumCatalog,
|
|
17
|
+
): readonly NotationDiagnostic[] {
|
|
18
|
+
const diagnostics: NotationDiagnostic[] = [];
|
|
19
|
+
const competencies = new Map(
|
|
20
|
+
(catalog.competencies as readonly EditorialCompetency[]).map((entry) => [
|
|
21
|
+
entry.id,
|
|
22
|
+
entry,
|
|
23
|
+
]),
|
|
24
|
+
);
|
|
25
|
+
const lessons = new Map(catalog.lessons.map((entry) => [entry.id, entry]));
|
|
26
|
+
const sources = new Map(catalog.sources.map((entry) => [entry.id, entry]));
|
|
27
|
+
const trackPositions = buildTrackPositions(catalog, diagnostics);
|
|
28
|
+
|
|
29
|
+
for (const definition of registry.definitions) {
|
|
30
|
+
validateSources(definition, sources, diagnostics);
|
|
31
|
+
|
|
32
|
+
const alignment = definition.alignment;
|
|
33
|
+
if (alignment.kind === 'general') continue;
|
|
34
|
+
|
|
35
|
+
const competency = competencies.get(alignment.introducedByCompetency);
|
|
36
|
+
const lesson = lessons.get(alignment.introducedInLesson);
|
|
37
|
+
if (!competency) {
|
|
38
|
+
diagnostics.push(
|
|
39
|
+
error(
|
|
40
|
+
'alignment-unknown-competency',
|
|
41
|
+
`${definition.id} is aligned to unknown competency ${alignment.introducedByCompetency}`,
|
|
42
|
+
definition,
|
|
43
|
+
),
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
if (!lesson) {
|
|
47
|
+
diagnostics.push(
|
|
48
|
+
error(
|
|
49
|
+
'alignment-unknown-lesson',
|
|
50
|
+
`${definition.id} is introduced in unknown lesson ${alignment.introducedInLesson}`,
|
|
51
|
+
definition,
|
|
52
|
+
),
|
|
53
|
+
);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (!lesson.teaches.includes(alignment.introducedByCompetency)) {
|
|
57
|
+
diagnostics.push(
|
|
58
|
+
error(
|
|
59
|
+
'alignment-introduction',
|
|
60
|
+
`${definition.id} names ${alignment.introducedInLesson} as its introduction, but that lesson does not teach ${alignment.introducedByCompetency}`,
|
|
61
|
+
definition,
|
|
62
|
+
),
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
if (
|
|
66
|
+
definition.kind === 'local' &&
|
|
67
|
+
alignment.introducedInLesson !== definition.lessonId
|
|
68
|
+
) {
|
|
69
|
+
diagnostics.push(
|
|
70
|
+
error(
|
|
71
|
+
'alignment-introduction',
|
|
72
|
+
`${definition.id} is page-local to ${definition.lessonId} but declares its introduction in ${alignment.introducedInLesson}`,
|
|
73
|
+
definition,
|
|
74
|
+
),
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
validateIntroductionOrder(
|
|
79
|
+
definition,
|
|
80
|
+
alignment.introducedInLesson,
|
|
81
|
+
registry,
|
|
82
|
+
trackPositions,
|
|
83
|
+
diagnostics,
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
if (
|
|
87
|
+
definition.status === 'reviewed' &&
|
|
88
|
+
(lesson.status !== 'reviewed' ||
|
|
89
|
+
competency?.editorialStatus !== 'reviewed')
|
|
90
|
+
) {
|
|
91
|
+
diagnostics.push(
|
|
92
|
+
error(
|
|
93
|
+
'alignment-review-state',
|
|
94
|
+
`${definition.id} is reviewed while its owning lesson or competency is not reviewed`,
|
|
95
|
+
definition,
|
|
96
|
+
),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return diagnostics.sort(compareDiagnostics);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function assertValidNotationAlignment(
|
|
105
|
+
registry: NotationRegistry,
|
|
106
|
+
catalog: CurriculumCatalog,
|
|
107
|
+
): void {
|
|
108
|
+
const errors = validateNotationAlignment(registry, catalog).filter(
|
|
109
|
+
(diagnostic) => diagnostic.severity === 'error',
|
|
110
|
+
);
|
|
111
|
+
if (errors.length === 0) return;
|
|
112
|
+
throw new Error(
|
|
113
|
+
`Invalid notation alignment:\n${errors
|
|
114
|
+
.map((diagnostic) => `- [${diagnostic.code}] ${diagnostic.message}`)
|
|
115
|
+
.join('\n')}`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function buildTrackPositions(
|
|
120
|
+
catalog: CurriculumCatalog,
|
|
121
|
+
diagnostics: NotationDiagnostic[],
|
|
122
|
+
): Map<string, Map<string, number>> {
|
|
123
|
+
const positions = new Map<string, Map<string, number>>();
|
|
124
|
+
for (const track of catalog.tracks) {
|
|
125
|
+
const trackMap = new Map<string, number>();
|
|
126
|
+
track.lessons.forEach((lessonId, index) => trackMap.set(lessonId, index));
|
|
127
|
+
positions.set(track.id, trackMap);
|
|
128
|
+
}
|
|
129
|
+
if (positions.size === 0 && catalog.lessons.length > 0) {
|
|
130
|
+
diagnostics.push({
|
|
131
|
+
code: 'alignment-unavailable',
|
|
132
|
+
severity: 'error',
|
|
133
|
+
message: 'notation introduction order cannot be checked without a track',
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
return positions;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function validateIntroductionOrder(
|
|
140
|
+
definition: NotationDefinitionRecord,
|
|
141
|
+
introductionLessonId: string,
|
|
142
|
+
registry: NotationRegistry,
|
|
143
|
+
trackPositions: ReadonlyMap<string, ReadonlyMap<string, number>>,
|
|
144
|
+
diagnostics: NotationDiagnostic[],
|
|
145
|
+
): void {
|
|
146
|
+
const usedIn = registry.backlinks
|
|
147
|
+
.filter((backlink) => backlink.definitionId === definition.id)
|
|
148
|
+
.map((backlink) => backlink.lessonId);
|
|
149
|
+
|
|
150
|
+
for (const [trackId, positions] of trackPositions) {
|
|
151
|
+
const introductionPosition = positions.get(introductionLessonId);
|
|
152
|
+
for (const lessonId of usedIn) {
|
|
153
|
+
const usePosition = positions.get(lessonId);
|
|
154
|
+
if (usePosition === undefined) continue;
|
|
155
|
+
if (introductionPosition === undefined) {
|
|
156
|
+
diagnostics.push(
|
|
157
|
+
error(
|
|
158
|
+
'alignment-unavailable',
|
|
159
|
+
`${definition.id} is used by ${lessonId} in track ${trackId}, but its declared introduction ${introductionLessonId} is absent from that track`,
|
|
160
|
+
definition,
|
|
161
|
+
lessonId,
|
|
162
|
+
),
|
|
163
|
+
);
|
|
164
|
+
} else if (usePosition < introductionPosition) {
|
|
165
|
+
diagnostics.push(
|
|
166
|
+
error(
|
|
167
|
+
'alignment-introduction',
|
|
168
|
+
`${definition.id} is used by ${lessonId} before its declared introduction ${introductionLessonId} in track ${trackId}`,
|
|
169
|
+
definition,
|
|
170
|
+
lessonId,
|
|
171
|
+
),
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function validateSources(
|
|
179
|
+
definition: NotationDefinitionRecord,
|
|
180
|
+
sources: ReadonlyMap<
|
|
181
|
+
string,
|
|
182
|
+
{ readonly editorialStatus: 'draft' | 'in-review' | 'reviewed' }
|
|
183
|
+
>,
|
|
184
|
+
diagnostics: NotationDiagnostic[],
|
|
185
|
+
): void {
|
|
186
|
+
for (const sourceId of definition.sources) {
|
|
187
|
+
const source = sources.get(sourceId);
|
|
188
|
+
if (!source) {
|
|
189
|
+
diagnostics.push(
|
|
190
|
+
error(
|
|
191
|
+
'alignment-unavailable',
|
|
192
|
+
`${definition.id} cites unknown source ${sourceId}`,
|
|
193
|
+
definition,
|
|
194
|
+
),
|
|
195
|
+
);
|
|
196
|
+
} else if (
|
|
197
|
+
definition.status === 'reviewed' &&
|
|
198
|
+
source.editorialStatus !== 'reviewed'
|
|
199
|
+
) {
|
|
200
|
+
diagnostics.push(
|
|
201
|
+
error(
|
|
202
|
+
'alignment-review-state',
|
|
203
|
+
`${definition.id} is reviewed while source ${sourceId} is ${source.editorialStatus}`,
|
|
204
|
+
definition,
|
|
205
|
+
),
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function error(
|
|
212
|
+
code: NotationDiagnostic['code'],
|
|
213
|
+
message: string,
|
|
214
|
+
definition: NotationDefinitionRecord,
|
|
215
|
+
lessonId?: string,
|
|
216
|
+
): NotationDiagnostic {
|
|
217
|
+
return {
|
|
218
|
+
code,
|
|
219
|
+
severity: 'error',
|
|
220
|
+
message,
|
|
221
|
+
key: definition.key,
|
|
222
|
+
definitionId: definition.id,
|
|
223
|
+
lessonId:
|
|
224
|
+
lessonId ??
|
|
225
|
+
(definition.kind === 'local' ? definition.lessonId : undefined),
|
|
226
|
+
source: definition.source,
|
|
227
|
+
};
|
|
228
|
+
}
|