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.
Files changed (113) hide show
  1. package/README.md +108 -0
  2. package/dist/analytics/AnalyticsEmitter.d.ts +22 -0
  3. package/dist/analytics/AnalyticsEmitter.js +5 -0
  4. package/dist/compiler/cli.d.ts +189 -0
  5. package/dist/compiler/cli.js +552 -0
  6. package/dist/compiler/collections.d.ts +70 -0
  7. package/dist/compiler/collections.js +383 -0
  8. package/dist/compiler/course-config.d.ts +31 -0
  9. package/dist/compiler/course-config.js +0 -0
  10. package/dist/compiler/lesson-derivation.d.ts +89 -0
  11. package/dist/compiler/lesson-derivation.js +91 -0
  12. package/dist/compiler/manifest.d.ts +178 -0
  13. package/dist/compiler/manifest.js +345 -0
  14. package/dist/compiler/resolution-report.d.ts +45 -0
  15. package/dist/compiler/resolution-report.js +97 -0
  16. package/dist/curriculum/validation.d.ts +76 -0
  17. package/dist/curriculum/validation.js +320 -0
  18. package/dist/progress/ProgressRepository.d.ts +53 -0
  19. package/dist/progress/ProgressRepository.js +12 -0
  20. package/dist/reference/base-library.mjs +27 -0
  21. package/dist/reference/citation-format.mjs +133 -0
  22. package/dist/reference/consistency.d.ts +64 -0
  23. package/dist/reference/consistency.js +234 -0
  24. package/dist/reference/curriculum-alignment.d.ts +7 -0
  25. package/dist/reference/curriculum-alignment.js +166 -0
  26. package/dist/reference/equations-validate.d.ts +50 -0
  27. package/dist/reference/equations-validate.js +90 -0
  28. package/dist/reference/equations.mjs +144 -0
  29. package/dist/reference/gate-math.d.ts +41 -0
  30. package/dist/reference/gate-math.js +151 -0
  31. package/dist/reference/katex-options.mjs +91 -0
  32. package/dist/reference/lab-math-scope.d.ts +21 -0
  33. package/dist/reference/lab-math-scope.js +28 -0
  34. package/dist/reference/label.d.ts +11 -0
  35. package/dist/reference/label.js +38 -0
  36. package/dist/reference/lint-ignore.d.ts +32 -0
  37. package/dist/reference/lint-ignore.js +39 -0
  38. package/dist/reference/math-glyphs.mjs +830 -0
  39. package/dist/reference/prose.d.ts +15 -0
  40. package/dist/reference/prose.js +12 -0
  41. package/dist/reference/references.d.ts +17 -0
  42. package/dist/reference/references.js +42 -0
  43. package/dist/reference/registry.d.ts +6 -0
  44. package/dist/reference/registry.js +548 -0
  45. package/dist/reference/rehype-fail-katex-errors.mjs +55 -0
  46. package/dist/reference/remark-citation.mjs +0 -0
  47. package/dist/reference/remark-notation.mjs +753 -0
  48. package/dist/reference/render-lab-math.d.ts +21 -0
  49. package/dist/reference/render-lab-math.js +58 -0
  50. package/dist/reference/types.d.ts +151 -0
  51. package/dist/reference/types.js +0 -0
  52. package/dist/reference/units-vocab.d.ts +26 -0
  53. package/dist/reference/units-vocab.js +119 -0
  54. package/dist/session/PreferenceStore.d.ts +28 -0
  55. package/dist/session/PreferenceStore.js +30 -0
  56. package/dist/session/user-context.d.ts +7 -0
  57. package/dist/session/user-context.js +10 -0
  58. package/dist/session/user.d.ts +21 -0
  59. package/dist/session/user.js +8 -0
  60. package/package.json +53 -0
  61. package/src/analytics/AnalyticsEmitter.ts +23 -0
  62. package/src/compiler/cli.ts +866 -0
  63. package/src/compiler/collections.ts +554 -0
  64. package/src/compiler/course-config.ts +32 -0
  65. package/src/compiler/lesson-derivation.ts +186 -0
  66. package/src/compiler/manifest.ts +601 -0
  67. package/src/compiler/resolution-report.ts +150 -0
  68. package/src/components/CurriculumMap.astro +208 -0
  69. package/src/components/HoverPanel.astro +67 -0
  70. package/src/components/assessments/AssessmentRunner.tsx +320 -0
  71. package/src/components/assessments/AssessmentSet.astro +54 -0
  72. package/src/components/citation/CitationLayer.astro +173 -0
  73. package/src/components/examples/CompactExample.astro +34 -0
  74. package/src/components/examples/CompactExamples.astro +253 -0
  75. package/src/components/hover-panel.ts +273 -0
  76. package/src/components/notation/NotationGlossary.astro +412 -0
  77. package/src/components/notation/NotationLayer.astro +594 -0
  78. package/src/components/notation/NotationMath.astro +24 -0
  79. package/src/components/notation/SafeMathNode.astro +23 -0
  80. package/src/components/notation/render-notation-math.ts +223 -0
  81. package/src/components/starlight/EquationEnhancer.astro +72 -0
  82. package/src/components/starlight/LayoutHeader.astro +102 -0
  83. package/src/components/starlight/LayoutPageSidebar.astro +33 -0
  84. package/src/components/starlight/LayoutSidebar.astro +29 -0
  85. package/src/components/starlight/LessonFooter.astro +58 -0
  86. package/src/content-config.ts +259 -0
  87. package/src/curriculum/validation.ts +481 -0
  88. package/src/progress/ProgressRepository.ts +64 -0
  89. package/src/reference/base-library.mjs +27 -0
  90. package/src/reference/citation-format.mjs +133 -0
  91. package/src/reference/consistency.ts +375 -0
  92. package/src/reference/curriculum-alignment.ts +228 -0
  93. package/src/reference/equations-validate.ts +167 -0
  94. package/src/reference/equations.mjs +144 -0
  95. package/src/reference/gate-math.ts +250 -0
  96. package/src/reference/katex-options.mjs +91 -0
  97. package/src/reference/lab-math-scope.ts +54 -0
  98. package/src/reference/label.ts +47 -0
  99. package/src/reference/lint-ignore.ts +75 -0
  100. package/src/reference/math-glyphs.mjs +830 -0
  101. package/src/reference/prose.ts +21 -0
  102. package/src/reference/references.ts +82 -0
  103. package/src/reference/registry.ts +723 -0
  104. package/src/reference/rehype-fail-katex-errors.mjs +55 -0
  105. package/src/reference/remark-citation.mjs +0 -0
  106. package/src/reference/remark-notation.mjs +753 -0
  107. package/src/reference/render-lab-math.ts +99 -0
  108. package/src/reference/types.ts +182 -0
  109. package/src/reference/units-vocab.ts +143 -0
  110. package/src/session/PreferenceStore.ts +54 -0
  111. package/src/session/user-context.ts +18 -0
  112. package/src/session/user.ts +24 -0
  113. package/src/styles/global.css +763 -0
@@ -0,0 +1,866 @@
1
+ /**
2
+ * The `content` CLI core (Phase G1).
3
+ *
4
+ * `scripts/content.ts` is a thin argv wrapper; every command's logic lives here
5
+ * as a pure-ish function that takes a compiled {@link Manifest} and returns a
6
+ * plain data object. The router is **read-only** — only `content new` touches
7
+ * the filesystem, and even then `scaffoldLesson` / `scaffoldTerm` only return
8
+ * the path + contents for the wrapper to write, and never overwrite.
9
+ *
10
+ * Design points the G1 gate pins:
11
+ * - Every diagnostic carries a **stable code** (`<source>:<kind>`), never a
12
+ * bare string, so an AI caller can branch on it.
13
+ * - `--json` output is data only: no stack traces. The wrapper converts any
14
+ * thrown {@link ContentCliError} (and any unexpected error) into
15
+ * `{ code, message }`.
16
+ * - `content context <lesson>` emits that lesson plus only what a drafter
17
+ * needs — direct-prerequisite outcomes, the resolved notation bundle, cited
18
+ * source metadata + locators, the section's domain signatures, and the
19
+ * lesson's current diagnostics — and reports token counts against the doc
20
+ * set + corpus so the size win is visible.
21
+ * - `content check` compiles the lesson MDX → remark → KaTeX → HTML and fails
22
+ * on any renderability error (unparseable MDX, KaTeX error, unresolved
23
+ * notation, unknown `[[key]]`, unknown citation), on top of the manifest's
24
+ * schema / ref / math / equation diagnostics for that lesson.
25
+ */
26
+
27
+ import { execFileSync } from 'node:child_process';
28
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
29
+ import { join, relative } from 'node:path';
30
+ import { pathToFileURL } from 'node:url';
31
+
32
+ import {
33
+ createMarkdownProcessor,
34
+ parseFrontmatter,
35
+ } from '@astrojs/markdown-remark';
36
+ import rehypeKatex from 'rehype-katex';
37
+ import remarkMath from 'remark-math';
38
+
39
+ import { createNotationKatexOptions } from '../reference/katex-options.mjs';
40
+ import rehypeFailKatexErrors from '../reference/rehype-fail-katex-errors.mjs';
41
+ import remarkCitation from '../reference/remark-citation.mjs';
42
+ import remarkNotation from '../reference/remark-notation.mjs';
43
+ import type { Manifest, ManifestLesson } from './manifest';
44
+ import { isLessonSlug, lessonIdFromSlug } from './lesson-derivation';
45
+
46
+ const REPOSITORY_ROOT = process.cwd();
47
+ // Content and its course math are the top-level `content/` folder (Phase F).
48
+ const CONTENT_ROOT = join(REPOSITORY_ROOT, 'content');
49
+ const DOCS_ROOT = join(CONTENT_ROOT, 'docs');
50
+ const DOMAIN_ROOT = join(CONTENT_ROOT, 'domain');
51
+
52
+ // --- errors --------------------------------------------------------------
53
+
54
+ export type ContentCliCode =
55
+ | 'unknown-command'
56
+ | 'usage'
57
+ | 'unknown-lesson'
58
+ | 'unknown-kind'
59
+ | 'invalid-id'
60
+ | 'exists'
61
+ | 'not-a-lesson'
62
+ | 'no-git';
63
+
64
+ /** A user-facing failure with a stable code and no stack in `--json` output. */
65
+ export class ContentCliError extends Error {
66
+ readonly code: ContentCliCode;
67
+ constructor(code: ContentCliCode, message: string) {
68
+ super(message);
69
+ this.name = 'ContentCliError';
70
+ this.code = code;
71
+ }
72
+ }
73
+
74
+ // --- diagnostics ------------------------------------------------------
75
+
76
+ /** One normalized diagnostic. `code` is `<source>:<kind>` and always stable. */
77
+ export interface CliDiagnostic {
78
+ readonly code: string;
79
+ readonly severity: 'error' | 'warning';
80
+ readonly message: string;
81
+ readonly file?: string;
82
+ readonly line?: number;
83
+ }
84
+
85
+ function mentions(message: string, lessonId: string, file: string): boolean {
86
+ return (
87
+ message.includes(lessonId) ||
88
+ message.includes(file) ||
89
+ message.includes(file.replace(/^content\/docs\//, ''))
90
+ );
91
+ }
92
+
93
+ /**
94
+ * Every manifest diagnostic that names this lesson (by id or file), normalized
95
+ * to {@link CliDiagnostic}. Corpus-wide checks (curriculum) are included only
96
+ * when their message mentions the lesson; per-file checks (notation, math,
97
+ * equations, consistency) are filtered by `file`.
98
+ */
99
+ export function lessonDiagnostics(
100
+ manifest: Manifest,
101
+ lesson: ManifestLesson,
102
+ ): CliDiagnostic[] {
103
+ const out: CliDiagnostic[] = [];
104
+ const { file, id } = lesson;
105
+ const assessments = new Set(lesson.assessments);
106
+ const withFile = (value?: string) =>
107
+ value === undefined ? {} : { file: value };
108
+
109
+ for (const issue of manifest.diagnostics.curriculum) {
110
+ if (!mentions(issue.message, id, file)) continue;
111
+ out.push({
112
+ code: `curriculum:${issue.kind}`,
113
+ severity: issue.severity === 'warning' ? 'warning' : 'error',
114
+ message: issue.message,
115
+ });
116
+ }
117
+
118
+ for (const diagnostic of [
119
+ ...manifest.diagnostics.notation.map((d) => ['notation', d] as const),
120
+ ...manifest.diagnostics.alignment.map((d) => ['alignment', d] as const),
121
+ ]) {
122
+ const [group, entry] = diagnostic;
123
+ const entryFile = entry.source?.file;
124
+ if (
125
+ entry.lessonId !== id &&
126
+ entryFile !== file &&
127
+ !mentions(entry.message, id, file)
128
+ ) {
129
+ continue;
130
+ }
131
+ out.push({
132
+ code: `${group}:${entry.code}`,
133
+ severity: entry.severity === 'warning' ? 'warning' : 'error',
134
+ message: entry.message,
135
+ ...withFile(entryFile),
136
+ });
137
+ }
138
+
139
+ for (const diagnostic of manifest.diagnostics.math) {
140
+ const forLesson =
141
+ diagnostic.file === file ||
142
+ (diagnostic.context === 'assessment' &&
143
+ [...assessments].some((assessmentId) =>
144
+ diagnostic.file.startsWith(`${assessmentId} `),
145
+ ));
146
+ if (!forLesson) continue;
147
+ out.push({
148
+ code: `math:${diagnostic.context}`,
149
+ severity: diagnostic.severity,
150
+ message: diagnostic.message,
151
+ file: diagnostic.file,
152
+ ...(diagnostic.line === undefined ? {} : { line: diagnostic.line }),
153
+ });
154
+ }
155
+
156
+ for (const diagnostic of manifest.diagnostics.equations) {
157
+ if (
158
+ diagnostic.lessonId !== id &&
159
+ diagnostic.file !== file &&
160
+ !mentions(diagnostic.message, id, file)
161
+ ) {
162
+ continue;
163
+ }
164
+ out.push({
165
+ code: `equations:${diagnostic.code}`,
166
+ severity: diagnostic.severity === 'warning' ? 'warning' : 'error',
167
+ message: diagnostic.message,
168
+ ...withFile(diagnostic.file),
169
+ });
170
+ }
171
+
172
+ for (const diagnostic of manifest.diagnostics.consistency) {
173
+ if (diagnostic.file !== file && diagnostic.lessonId !== id) continue;
174
+ out.push({
175
+ code: `consistency:${diagnostic.code}`,
176
+ severity: 'warning',
177
+ message: diagnostic.message,
178
+ ...withFile(diagnostic.file),
179
+ });
180
+ }
181
+
182
+ return out.sort(
183
+ (a, b) =>
184
+ a.code.localeCompare(b.code) || a.message.localeCompare(b.message),
185
+ );
186
+ }
187
+
188
+ // --- renderability -------------------------------------------------------
189
+
190
+ let processorPromise:
191
+ Promise<Awaited<ReturnType<typeof createMarkdownProcessor>>> | undefined;
192
+
193
+ function processorFor(manifest: Manifest) {
194
+ processorPromise ??= createMarkdownProcessor({
195
+ syntaxHighlight: false,
196
+ smartypants: false,
197
+ remarkPlugins: [
198
+ remarkMath,
199
+ [
200
+ remarkNotation,
201
+ {
202
+ definitions: manifest.notation.raw,
203
+ equations: manifest.equations.numbersBySlug,
204
+ },
205
+ ],
206
+ [remarkCitation, { sources: manifest.sources }],
207
+ ],
208
+ rehypePlugins: [
209
+ [rehypeKatex, createNotationKatexOptions()],
210
+ rehypeFailKatexErrors,
211
+ ],
212
+ });
213
+ return processorPromise;
214
+ }
215
+
216
+ /** Drop `import` / `export` lines so they don't render as stray prose. */
217
+ function stripEsm(body: string): string {
218
+ return body.replace(/^(?:import|export)\s[^\n]*\n?/gm, '');
219
+ }
220
+
221
+ /** Classify a render error by which pipeline stage raised it. */
222
+ function renderErrorCode(message: string): string {
223
+ if (/Unresolved notation/i.test(message)) return 'render:notation';
224
+ if (/KaTeX|ParseError|katex-error/i.test(message)) return 'render:katex';
225
+ if (
226
+ /Unknown notation key|Malformed notation|Invalid notation key/i.test(
227
+ message,
228
+ )
229
+ ) {
230
+ return 'render:notation';
231
+ }
232
+ if (/equation reference|display equation|equation label eq:/i.test(message)) {
233
+ return 'render:equation';
234
+ }
235
+ if (/Unknown (?:source|citation)|Malformed citation/i.test(message)) {
236
+ return 'render:citation';
237
+ }
238
+ return 'render:mdx';
239
+ }
240
+
241
+ export interface RenderResult {
242
+ readonly rendered: boolean;
243
+ readonly diagnostics: readonly CliDiagnostic[];
244
+ }
245
+
246
+ /**
247
+ * Compile one lesson body through the real remark → KaTeX → HTML pipeline and
248
+ * report the first failure as a coded diagnostic. Exported so a fixture body
249
+ * can be checked directly, without a manifest entry.
250
+ */
251
+ export async function renderLesson(
252
+ manifest: Manifest,
253
+ input: { body: string; frontmatter: Record<string, unknown>; path: string },
254
+ ): Promise<RenderResult> {
255
+ const processor = await processorFor(manifest);
256
+ const lessonId = lessonIdFromSlug(
257
+ relative(DOCS_ROOT, input.path).replace(/\\/g, '/'),
258
+ );
259
+ try {
260
+ await processor.render(stripEsm(input.body), {
261
+ frontmatter: { ...input.frontmatter, lessonId },
262
+ fileURL: pathToFileURL(input.path),
263
+ });
264
+ return { rendered: true, diagnostics: [] };
265
+ } catch (error) {
266
+ const message = error instanceof Error ? error.message : String(error);
267
+ return {
268
+ rendered: false,
269
+ diagnostics: [
270
+ { code: renderErrorCode(message), severity: 'error', message },
271
+ ],
272
+ };
273
+ }
274
+ }
275
+
276
+ // --- token estimate ----------------------------------------------------
277
+
278
+ /** Rough token estimate — ~4 chars/token, the usual GPT-family heuristic. */
279
+ export function estimateTokens(text: string): number {
280
+ return Math.ceil(text.length / 4);
281
+ }
282
+
283
+ function readIfExists(path: string): string {
284
+ return existsSync(path) ? readFileSync(path, 'utf8') : '';
285
+ }
286
+
287
+ function walkFiles(directory: string): string[] {
288
+ if (!existsSync(directory)) return [];
289
+ return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
290
+ const path = join(directory, entry.name);
291
+ if (entry.isDirectory()) return walkFiles(path);
292
+ return entry.isFile() ? [path] : [];
293
+ });
294
+ }
295
+
296
+ export interface TokenCounts {
297
+ /** Tokens in the serialized `content context` payload. */
298
+ readonly context: number;
299
+ /** `README.md` + `AGENTS.md` + `docs/architecture.md`. */
300
+ readonly docSet: number;
301
+ /** Every file under `content/`. */
302
+ readonly corpus: number;
303
+ /** `context / (docSet + corpus)`, rounded to 4 dp. */
304
+ readonly ratio: number;
305
+ }
306
+
307
+ function docSetTokens(): number {
308
+ return ['README.md', 'AGENTS.md', join('docs', 'architecture.md')]
309
+ .map((relPath) =>
310
+ estimateTokens(readIfExists(join(REPOSITORY_ROOT, relPath))),
311
+ )
312
+ .reduce((sum, value) => sum + value, 0);
313
+ }
314
+
315
+ function corpusTokens(): number {
316
+ return walkFiles(CONTENT_ROOT)
317
+ .map((path) => estimateTokens(readFileSync(path, 'utf8')))
318
+ .reduce((sum, value) => sum + value, 0);
319
+ }
320
+
321
+ function tokenCounts(contextPayload: unknown): TokenCounts {
322
+ const context = estimateTokens(JSON.stringify(contextPayload));
323
+ const docSet = docSetTokens();
324
+ const corpus = corpusTokens();
325
+ const baseline = docSet + corpus;
326
+ return {
327
+ context,
328
+ docSet,
329
+ corpus,
330
+ ratio: baseline === 0 ? 0 : Math.round((context / baseline) * 1e4) / 1e4,
331
+ };
332
+ }
333
+
334
+ // --- lesson lookup + body --------------------------------------------
335
+
336
+ /** Normalize `foundations/discount-factors` or the dotted id to the dotted id. */
337
+ export function normalizeLessonId(raw: string): string {
338
+ const trimmed = raw.trim().replace(/\.mdx?$/, '');
339
+ if (trimmed.includes('/')) return trimmed.replace(/\//g, '.');
340
+ return trimmed;
341
+ }
342
+
343
+ export function findLesson(manifest: Manifest, rawId: string): ManifestLesson {
344
+ const id = normalizeLessonId(rawId);
345
+ const lesson = manifest.lessons.find((entry) => entry.id === id);
346
+ if (!lesson) {
347
+ throw new ContentCliError(
348
+ 'unknown-lesson',
349
+ `No lesson ${JSON.stringify(id)}. Known ids: ${manifest.lessons
350
+ .map((entry) => entry.id)
351
+ .join(', ')}`,
352
+ );
353
+ }
354
+ return lesson;
355
+ }
356
+
357
+ function lessonAbsolutePath(lesson: ManifestLesson): string {
358
+ return join(REPOSITORY_ROOT, lesson.file);
359
+ }
360
+
361
+ function lessonRaw(lesson: ManifestLesson): {
362
+ frontmatter: Record<string, unknown>;
363
+ body: string;
364
+ } {
365
+ const parsed = parseFrontmatter(
366
+ readFileSync(lessonAbsolutePath(lesson), 'utf8'),
367
+ );
368
+ return {
369
+ frontmatter: (parsed.frontmatter ?? {}) as Record<string, unknown>,
370
+ body: parsed.content.replace(/^\n/, ''),
371
+ };
372
+ }
373
+
374
+ // --- domain signatures ------------------------------------------------
375
+
376
+ /**
377
+ * Which `content/domain/` folders a lesson section draws on. A pragmatic map: most
378
+ * sections share a name with a domain folder; the mismatches (`foundations`
379
+ * pulls the shared primitives, option sections reuse the underlying folder)
380
+ * are listed explicitly.
381
+ */
382
+ const DOMAIN_DIRS_BY_SECTION: Record<string, readonly string[]> = {
383
+ foundations: ['.', 'rates', 'valuation'],
384
+ bonds: ['bonds', 'rates'],
385
+ rates: ['rates'],
386
+ derivatives: ['derivatives', 'valuation'],
387
+ 'bond-options': ['bonds', 'derivatives', 'valuation'],
388
+ credit: ['credit'],
389
+ cds: ['cds', 'credit'],
390
+ };
391
+
392
+ const EXPORT_FN =
393
+ /export\s+(?:async\s+)?function\s+([A-Za-z0-9_]+)\s*(\([\s\S]*?\))\s*(?::\s*([^\n{]+?))?\s*\{/g;
394
+ const EXPORT_ARROW =
395
+ /export\s+const\s+([A-Za-z0-9_]+)\s*(?::\s*[^=\n]+)?=\s*(?:async\s*)?(\([\s\S]*?\))\s*(?::\s*([^=\n]+?))?\s*=>/g;
396
+
397
+ function signaturesInFile(path: string): string[] {
398
+ const source = readFileSync(path, 'utf8');
399
+ const out: string[] = [];
400
+ for (const pattern of [EXPORT_FN, EXPORT_ARROW]) {
401
+ pattern.lastIndex = 0;
402
+ let match: RegExpExecArray | null;
403
+ while ((match = pattern.exec(source))) {
404
+ const params = (match[2] ?? '').replace(/\s+/g, ' ').trim();
405
+ const ret = (match[3] ?? '').replace(/\s+/g, ' ').trim();
406
+ out.push(`${match[1]}${params}${ret ? `: ${ret}` : ''}`);
407
+ }
408
+ }
409
+ return out.sort();
410
+ }
411
+
412
+ export interface DomainFileSignatures {
413
+ readonly file: string;
414
+ readonly signatures: readonly string[];
415
+ }
416
+
417
+ function domainSignaturesForSection(section: string): DomainFileSignatures[] {
418
+ const dirs = DOMAIN_DIRS_BY_SECTION[section] ?? [section];
419
+ const files = new Set<string>();
420
+ for (const dir of dirs) {
421
+ const base = dir === '.' ? DOMAIN_ROOT : join(DOMAIN_ROOT, dir);
422
+ if (dir === '.') {
423
+ for (const entry of existsSync(base) ? readdirSync(base) : []) {
424
+ if (entry.endsWith('.ts')) files.add(join(base, entry));
425
+ }
426
+ } else {
427
+ for (const path of walkFiles(base)) {
428
+ if (path.endsWith('.ts')) files.add(path);
429
+ }
430
+ }
431
+ }
432
+ return [...files]
433
+ .sort()
434
+ .map((path) => ({
435
+ file: relative(REPOSITORY_ROOT, path).replace(/\\/g, '/'),
436
+ signatures: signaturesInFile(path),
437
+ }))
438
+ .filter((entry) => entry.signatures.length > 0);
439
+ }
440
+
441
+ // --- content status ------------------------------------------------------
442
+
443
+ export interface StatusReport {
444
+ readonly counts: {
445
+ readonly lessons: number;
446
+ readonly competencies: number;
447
+ readonly assessments: number;
448
+ readonly sources: number;
449
+ readonly tracks: number;
450
+ readonly notationDefinitions: number;
451
+ readonly keyedEquations: number;
452
+ };
453
+ readonly draftDebt: {
454
+ readonly lessons: readonly string[];
455
+ readonly notation: readonly string[];
456
+ };
457
+ readonly orphans: readonly CliDiagnostic[];
458
+ readonly diagnostics: {
459
+ readonly errors: number;
460
+ readonly warnings: number;
461
+ };
462
+ }
463
+
464
+ export function contentStatus(manifest: Manifest): StatusReport {
465
+ const draftLessons = manifest.lessons
466
+ .filter((lesson) => lesson.status === 'draft')
467
+ .map((lesson) => lesson.id)
468
+ .sort();
469
+ const draftNotation = manifest.notation.definitions
470
+ .filter((definition) => definition.status === 'draft')
471
+ .map((definition) => definition.id)
472
+ .sort();
473
+
474
+ const orphans: CliDiagnostic[] = manifest.notation.diagnostics
475
+ .filter((diagnostic) => diagnostic.code === 'unused-definition')
476
+ .map((diagnostic) => ({
477
+ code: `notation:${diagnostic.code}`,
478
+ severity:
479
+ diagnostic.severity === 'warning'
480
+ ? ('warning' as const)
481
+ : ('error' as const),
482
+ message: diagnostic.message,
483
+ ...(diagnostic.source?.file === undefined
484
+ ? {}
485
+ : { file: diagnostic.source.file }),
486
+ }))
487
+ .sort((a, b) => a.message.localeCompare(b.message));
488
+
489
+ const allDiagnostics = [
490
+ ...manifest.diagnostics.curriculum.map((d) => d.severity ?? 'error'),
491
+ ...manifest.diagnostics.notation.map((d) => d.severity),
492
+ ...manifest.diagnostics.alignment.map((d) => d.severity),
493
+ ...manifest.diagnostics.math.map((d) => d.severity),
494
+ ...manifest.diagnostics.equations.map((d) => d.severity),
495
+ ...manifest.diagnostics.consistency.map(() => 'warning' as const),
496
+ ];
497
+
498
+ const keyedEquations = manifest.lessons.reduce(
499
+ (count, lesson) => count + lesson.equations.length,
500
+ 0,
501
+ );
502
+ const assessments = new Set(
503
+ manifest.lessons.flatMap((lesson) => lesson.assessments),
504
+ ).size;
505
+
506
+ return {
507
+ counts: {
508
+ lessons: manifest.lessons.length,
509
+ competencies: manifest.competencies.length,
510
+ assessments,
511
+ sources: manifest.sources.length,
512
+ tracks: manifest.tracks.length,
513
+ notationDefinitions: manifest.notation.definitions.length,
514
+ keyedEquations,
515
+ },
516
+ draftDebt: { lessons: draftLessons, notation: draftNotation },
517
+ orphans,
518
+ diagnostics: {
519
+ errors: allDiagnostics.filter((severity) => severity === 'error').length,
520
+ warnings: allDiagnostics.filter((severity) => severity === 'warning')
521
+ .length,
522
+ },
523
+ };
524
+ }
525
+
526
+ // --- content context ---------------------------------------------------
527
+
528
+ export interface ContextReport {
529
+ readonly lesson: {
530
+ readonly id: string;
531
+ readonly slug: string;
532
+ readonly file: string;
533
+ readonly title: string;
534
+ readonly description: string;
535
+ readonly status: string;
536
+ readonly teaches: readonly string[];
537
+ readonly requires: readonly string[];
538
+ readonly assumptions: readonly string[];
539
+ readonly notationLocal: unknown;
540
+ readonly body: string;
541
+ };
542
+ readonly taughtOutcomes: readonly { id: string; outcome: string }[];
543
+ readonly prerequisiteOutcomes: readonly { id: string; outcome: string }[];
544
+ readonly notation: readonly {
545
+ key: string;
546
+ latex: string;
547
+ meaning: string;
548
+ scope: string;
549
+ units?: string;
550
+ formula?: string;
551
+ }[];
552
+ readonly equations: readonly { key: string; number: string }[];
553
+ readonly sources: readonly {
554
+ id: string;
555
+ title: string;
556
+ type: string;
557
+ recordLocator?: string;
558
+ citedLocators: readonly string[];
559
+ }[];
560
+ readonly domainSignatures: readonly DomainFileSignatures[];
561
+ readonly diagnostics: readonly CliDiagnostic[];
562
+ readonly tokens: TokenCounts;
563
+ }
564
+
565
+ export function contentContext(
566
+ manifest: Manifest,
567
+ rawId: string,
568
+ ): ContextReport {
569
+ const lesson = findLesson(manifest, rawId);
570
+ const { frontmatter, body } = lessonRaw(lesson);
571
+
572
+ const outcomeById = new Map(
573
+ manifest.competencies.map((competency) => [
574
+ String(competency.id),
575
+ String(competency.outcome ?? ''),
576
+ ]),
577
+ );
578
+ const outcomesFor = (ids: readonly string[]) =>
579
+ ids
580
+ .filter((id) => outcomeById.has(id))
581
+ .map((id) => ({ id, outcome: outcomeById.get(id) ?? '' }));
582
+
583
+ const sourceById = new Map(
584
+ manifest.sources.map((source) => [String(source.id), source]),
585
+ );
586
+ const citedLocatorsById = new Map<string, string[]>();
587
+ for (const citation of lesson.citations) {
588
+ const list = citedLocatorsById.get(citation.id) ?? [];
589
+ if (citation.locator) list.push(citation.locator);
590
+ citedLocatorsById.set(citation.id, list);
591
+ }
592
+
593
+ const contextWithoutTokens = {
594
+ lesson: {
595
+ id: lesson.id,
596
+ slug: lesson.slug,
597
+ file: lesson.file,
598
+ title: lesson.title,
599
+ description: lesson.description,
600
+ status: lesson.status,
601
+ teaches: lesson.teaches,
602
+ requires: lesson.requires,
603
+ assumptions: lesson.assumptions,
604
+ notationLocal:
605
+ (frontmatter.notation as { local?: unknown } | undefined)?.local ?? [],
606
+ body,
607
+ },
608
+ taughtOutcomes: outcomesFor(lesson.teaches),
609
+ prerequisiteOutcomes: outcomesFor(lesson.requires),
610
+ notation: lesson.notation.definitions.map((definition) => ({
611
+ key: definition.key,
612
+ latex: definition.notation,
613
+ meaning: definition.summary,
614
+ scope: definition.scope,
615
+ ...(definition.units === undefined ? {} : { units: definition.units }),
616
+ ...(definition.formula === undefined
617
+ ? {}
618
+ : { formula: definition.formula }),
619
+ })),
620
+ equations: lesson.equations.map((equation) => ({ ...equation })),
621
+ sources: lesson.sources.map((id) => {
622
+ const record = sourceById.get(id) as Record<string, unknown> | undefined;
623
+ return {
624
+ id,
625
+ title: String(record?.title ?? id),
626
+ type: String(record?.type ?? 'unknown'),
627
+ ...(typeof record?.locator === 'string'
628
+ ? { recordLocator: record.locator }
629
+ : {}),
630
+ citedLocators: citedLocatorsById.get(id) ?? [],
631
+ };
632
+ }),
633
+ domainSignatures: domainSignaturesForSection(
634
+ lesson.slug.split('/')[0] ?? '',
635
+ ),
636
+ diagnostics: lessonDiagnostics(manifest, lesson),
637
+ };
638
+
639
+ return {
640
+ ...contextWithoutTokens,
641
+ tokens: tokenCounts(contextWithoutTokens),
642
+ };
643
+ }
644
+
645
+ // --- content check ---------------------------------------------------
646
+
647
+ export interface CheckLessonResult {
648
+ readonly lesson: string;
649
+ readonly file: string;
650
+ readonly ok: boolean;
651
+ readonly rendered: boolean;
652
+ readonly diagnostics: readonly CliDiagnostic[];
653
+ }
654
+
655
+ export interface CheckReport {
656
+ readonly ok: boolean;
657
+ readonly lessons: readonly CheckLessonResult[];
658
+ }
659
+
660
+ async function checkOneLesson(
661
+ manifest: Manifest,
662
+ lesson: ManifestLesson,
663
+ ): Promise<CheckLessonResult> {
664
+ const manifestDiagnostics = lessonDiagnostics(manifest, lesson);
665
+ const { frontmatter, body } = lessonRaw(lesson);
666
+ const render = await renderLesson(manifest, {
667
+ body,
668
+ frontmatter,
669
+ path: lessonAbsolutePath(lesson),
670
+ });
671
+
672
+ const diagnostics = [...manifestDiagnostics, ...render.diagnostics].sort(
673
+ (a, b) =>
674
+ a.code.localeCompare(b.code) || a.message.localeCompare(b.message),
675
+ );
676
+ return {
677
+ lesson: lesson.id,
678
+ file: lesson.file,
679
+ ok: !diagnostics.some((diagnostic) => diagnostic.severity === 'error'),
680
+ rendered: render.rendered,
681
+ diagnostics,
682
+ };
683
+ }
684
+
685
+ /** `.mdx` / `.checks.yml` paths that differ from `HEAD`, tracked or not. */
686
+ export function changedLessonIds(): string[] {
687
+ let output: string;
688
+ try {
689
+ const tracked = execFileSync(
690
+ 'git',
691
+ ['diff', '--name-only', 'HEAD', '--', 'content/docs'],
692
+ { cwd: REPOSITORY_ROOT, encoding: 'utf8' },
693
+ );
694
+ const untracked = execFileSync(
695
+ 'git',
696
+ ['ls-files', '--others', '--exclude-standard', '--', 'content/docs'],
697
+ { cwd: REPOSITORY_ROOT, encoding: 'utf8' },
698
+ );
699
+ output = `${tracked}\n${untracked}`;
700
+ } catch {
701
+ throw new ContentCliError(
702
+ 'no-git',
703
+ 'Could not read git status for --changed. Run inside a git working tree.',
704
+ );
705
+ }
706
+
707
+ const ids = new Set<string>();
708
+ for (const line of output.split('\n')) {
709
+ const path = line.trim();
710
+ if (!path) continue;
711
+ const slug = path
712
+ .replace(/^content\/docs\//, '')
713
+ .replace(/\.checks\.yml$/, '.mdx')
714
+ .replace(/\.mdx?$/, '');
715
+ if (!isLessonSlug(slug)) continue;
716
+ ids.add(slug.replace(/\//g, '.'));
717
+ }
718
+ return [...ids].sort();
719
+ }
720
+
721
+ export async function contentCheck(
722
+ manifest: Manifest,
723
+ options: { lesson?: string; changed?: boolean },
724
+ ): Promise<CheckReport> {
725
+ let targets: ManifestLesson[];
726
+ if (options.changed) {
727
+ const ids = changedLessonIds();
728
+ targets = manifest.lessons.filter((lesson) => ids.includes(lesson.id));
729
+ } else {
730
+ if (!options.lesson) {
731
+ throw new ContentCliError(
732
+ 'usage',
733
+ 'content check needs a <lesson> id or --changed.',
734
+ );
735
+ }
736
+ targets = [findLesson(manifest, options.lesson)];
737
+ }
738
+
739
+ const lessons = await Promise.all(
740
+ targets.map((lesson) => checkOneLesson(manifest, lesson)),
741
+ );
742
+ return { ok: lessons.every((entry) => entry.ok), lessons };
743
+ }
744
+
745
+ // --- content new -----------------------------------------------------
746
+
747
+ const ID_PATTERN = /^[a-z0-9]+(?:[.-][a-z0-9]+)*$/;
748
+
749
+ export interface Scaffold {
750
+ /** Repo-relative path the wrapper writes. */
751
+ readonly path: string;
752
+ readonly contents: string;
753
+ /** Companion files (e.g. a lesson's `checks.yml`), also non-overwriting. */
754
+ readonly companions: readonly { path: string; contents: string }[];
755
+ }
756
+
757
+ export function scaffoldLesson(rawId: string): Scaffold {
758
+ const slug = rawId
759
+ .trim()
760
+ .replace(/\.mdx?$/, '')
761
+ .replace(/\./g, '/');
762
+ if (!slug.includes('/')) {
763
+ throw new ContentCliError(
764
+ 'invalid-id',
765
+ `A lesson id is <section>/<slug> (or <section>.<slug>), got ${JSON.stringify(rawId)}.`,
766
+ );
767
+ }
768
+ if (
769
+ !isLessonSlug(slug) ||
770
+ slug.split('/').some((part) => !ID_PATTERN.test(part))
771
+ ) {
772
+ throw new ContentCliError(
773
+ 'invalid-id',
774
+ `Lesson slug ${JSON.stringify(slug)} is not a valid <section>/<slug>.`,
775
+ );
776
+ }
777
+
778
+ const relPath = `content/docs/${slug}.mdx`;
779
+ const checksPath = `content/docs/${slug}.checks.yml`;
780
+ if (existsSync(join(REPOSITORY_ROOT, relPath))) {
781
+ throw new ContentCliError(
782
+ 'exists',
783
+ `${relPath} already exists; not overwriting.`,
784
+ );
785
+ }
786
+
787
+ const title = (slug.split('/').at(-1) ?? slug)
788
+ .split('-')
789
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
790
+ .join(' ');
791
+
792
+ const contents = `---
793
+ title: ${title}
794
+ description: One sentence on what the reader can do after this lesson.
795
+ editorialStatus: draft
796
+ teaches: []
797
+ assumptions: []
798
+ ---
799
+
800
+ import CompactExample from '../../../components/examples/CompactExample.astro';
801
+ import AssessmentSet from '../../../components/assessments/AssessmentSet.astro';
802
+
803
+ ## What you will be able to do
804
+
805
+ After this lesson, you should be able to:
806
+
807
+ - <!-- one observable outcome per teaches entry -->
808
+
809
+ ## Intuition
810
+
811
+ <!-- draft prose; every symbol introduced with [[key]] before its first math use -->
812
+
813
+ ## Model
814
+
815
+ <!-- the reproducible worked example; every numeral tagged to a content/domain/ call or [@source; locator] -->
816
+ `;
817
+
818
+ return {
819
+ path: relPath,
820
+ contents,
821
+ companions: [
822
+ {
823
+ path: checksPath,
824
+ contents: `# Assessment sets for this lesson. Ids resolve against content/assessments/.\n[]\n`,
825
+ },
826
+ ],
827
+ };
828
+ }
829
+
830
+ export function scaffoldTerm(rawKey: string): Scaffold {
831
+ const key = rawKey.trim();
832
+ if (!ID_PATTERN.test(key)) {
833
+ throw new ContentCliError(
834
+ 'invalid-id',
835
+ `A notation key is lower-case, dot/hyphen separated, got ${JSON.stringify(rawKey)}.`,
836
+ );
837
+ }
838
+ const relPath = `content/notation/${key}.md`;
839
+ if (existsSync(join(REPOSITORY_ROOT, relPath))) {
840
+ throw new ContentCliError(
841
+ 'exists',
842
+ `${relPath} already exists; not overwriting.`,
843
+ );
844
+ }
845
+
846
+ const contents = `---
847
+ key: ${key}
848
+ latex: 'x'
849
+ meaning: Describe the quantity this symbol denotes.
850
+ domain: general
851
+ units: stated currency
852
+ sources: []
853
+ seeAlso: []
854
+ alignment:
855
+ kind: general
856
+ rationale: Draft scaffold — set a real alignment before review.
857
+ editorialStatus: draft
858
+ aiAssisted: true
859
+ ---
860
+
861
+ Draft definition for [[${key}]]. Replace this body and the placeholder
862
+ frontmatter before moving the entry past \`draft\`.
863
+ `;
864
+
865
+ return { path: relPath, contents, companions: [] };
866
+ }