explico 0.2.0 → 0.4.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 (83) hide show
  1. package/CHANGELOG.md +173 -0
  2. package/README.md +67 -7
  3. package/dist/compiler/astro.d.ts +30 -0
  4. package/dist/compiler/astro.js +79 -0
  5. package/dist/compiler/cli.d.ts +16 -0
  6. package/dist/compiler/cli.js +43 -9
  7. package/dist/compiler/collections.js +4 -1
  8. package/dist/compiler/course-config.d.ts +80 -4
  9. package/dist/compiler/course-config.js +12 -0
  10. package/dist/compiler/knowledge-version.d.ts +58 -0
  11. package/dist/compiler/knowledge-version.js +80 -0
  12. package/dist/compiler/lesson-derivation.d.ts +1 -1
  13. package/dist/compiler/lesson-derivation.js +2 -2
  14. package/dist/compiler/manifest.d.ts +98 -2
  15. package/dist/compiler/manifest.js +153 -24
  16. package/dist/compiler/resolution-report.d.ts +4 -0
  17. package/dist/reference/base-operators.d.mts +38 -0
  18. package/dist/reference/base-operators.mjs +103 -0
  19. package/dist/reference/consistency.d.ts +7 -2
  20. package/dist/reference/consistency.js +51 -2
  21. package/dist/reference/diagram-layout.d.ts +63 -0
  22. package/dist/reference/diagram-layout.js +122 -0
  23. package/dist/reference/equations-validate.d.ts +10 -0
  24. package/dist/reference/equations-validate.js +1 -1
  25. package/dist/reference/equations.d.mts +80 -13
  26. package/dist/reference/equations.mjs +109 -19
  27. package/dist/reference/figures-validate.d.ts +21 -0
  28. package/dist/reference/figures-validate.js +13 -0
  29. package/dist/reference/keyed-float-validate.d.ts +73 -0
  30. package/dist/reference/keyed-float-validate.js +110 -0
  31. package/dist/reference/keyed-jsx-floats.d.mts +64 -0
  32. package/dist/reference/keyed-jsx-floats.mjs +191 -0
  33. package/dist/reference/math-glyphs.d.mts +18 -0
  34. package/dist/reference/math-glyphs.mjs +92 -0
  35. package/dist/reference/remark-notation.d.mts +61 -0
  36. package/dist/reference/remark-notation.mjs +344 -10
  37. package/dist/reference/tables-validate.d.ts +21 -0
  38. package/dist/reference/tables-validate.js +13 -0
  39. package/dist/reference/units-vocab.js +19 -1
  40. package/docs/VERSIONING.md +66 -0
  41. package/docs/authoring/README.md +100 -0
  42. package/docs/authoring/agents.md +63 -0
  43. package/docs/authoring/migrating.md +169 -0
  44. package/docs/authoring/notation.md +297 -0
  45. package/docs/authoring/structure.md +139 -0
  46. package/docs/authoring/versioning.md +116 -0
  47. package/docs/authoring/visualizations.md +195 -0
  48. package/package.json +20 -5
  49. package/src/compiler/astro.ts +127 -0
  50. package/src/compiler/cli.ts +52 -7
  51. package/src/compiler/collections.ts +12 -3
  52. package/src/compiler/course-config.ts +93 -3
  53. package/src/compiler/knowledge-version.ts +157 -0
  54. package/src/compiler/lesson-derivation.ts +2 -2
  55. package/src/compiler/manifest.ts +320 -26
  56. package/src/components/CurriculumMap.astro +27 -0
  57. package/src/components/diagram/Diagram.astro +317 -0
  58. package/src/components/examples/CompactExamples.astro +1 -1
  59. package/src/components/figure/Figure.astro +104 -0
  60. package/src/components/notation/NotationLayer.astro +11 -0
  61. package/src/components/notation/render-notation-math.ts +13 -2
  62. package/src/components/reference/FloatRefLayer.astro +671 -0
  63. package/src/components/starlight/AboutPanel.astro +255 -0
  64. package/src/components/starlight/HeadingAnchorEnhancer.astro +3 -3
  65. package/src/components/starlight/KeyedFloatEnhancer.astro +137 -0
  66. package/src/components/starlight/LayoutSidebar.astro +62 -1
  67. package/src/components/starlight/LayoutSocialIcons.astro +12 -0
  68. package/src/components/starlight/LessonFooter.astro +15 -2
  69. package/src/components/table/Table.astro +105 -0
  70. package/src/reference/base-operators.mjs +103 -0
  71. package/src/reference/consistency.ts +83 -2
  72. package/src/reference/diagram-layout.ts +246 -0
  73. package/src/reference/equations-validate.ts +8 -1
  74. package/src/reference/equations.mjs +109 -19
  75. package/src/reference/figures-validate.ts +40 -0
  76. package/src/reference/keyed-float-validate.ts +210 -0
  77. package/src/reference/keyed-jsx-floats.mjs +191 -0
  78. package/src/reference/math-glyphs.mjs +92 -0
  79. package/src/reference/remark-notation.mjs +344 -10
  80. package/src/reference/tables-validate.ts +40 -0
  81. package/src/reference/units-vocab.ts +18 -0
  82. package/src/styles/global.css +244 -7
  83. package/src/components/starlight/EquationEnhancer.astro +0 -87
@@ -35,6 +35,7 @@ import {
35
35
  } from '@astrojs/markdown-remark';
36
36
  import rehypeKatex from 'rehype-katex';
37
37
  import remarkMath from 'remark-math';
38
+ import remarkMdx from 'remark-mdx';
38
39
 
39
40
  import { createNotationKatexOptions } from '../reference/katex-options.mjs';
40
41
  import rehypeFailKatexErrors from '../reference/rehype-fail-katex-errors.mjs';
@@ -169,6 +170,25 @@ export function lessonDiagnostics(
169
170
  });
170
171
  }
171
172
 
173
+ for (const diagnostic of [
174
+ ...manifest.diagnostics.tables,
175
+ ...manifest.diagnostics.figures,
176
+ ]) {
177
+ if (
178
+ diagnostic.lessonId !== id &&
179
+ diagnostic.file !== file &&
180
+ !mentions(diagnostic.message, id, file)
181
+ ) {
182
+ continue;
183
+ }
184
+ out.push({
185
+ code: diagnostic.code,
186
+ severity: diagnostic.severity === 'warning' ? 'warning' : 'error',
187
+ message: diagnostic.message,
188
+ ...withFile(diagnostic.file),
189
+ });
190
+ }
191
+
172
192
  for (const diagnostic of manifest.diagnostics.consistency) {
173
193
  if (diagnostic.file !== file && diagnostic.lessonId !== id) continue;
174
194
  out.push({
@@ -195,14 +215,17 @@ function processorFor(manifest: Manifest) {
195
215
  syntaxHighlight: false,
196
216
  smartypants: false,
197
217
  remarkPlugins: [
218
+ // Syntax only (no JSX evaluation) — teaches the parser `<Table>` /
219
+ // `<Figure>` / `<CompactExample>` are `mdxJsxFlowElement` nodes, same
220
+ // as the real Astro/MDX build, so `remarkNotation` sees the same tree
221
+ // shape here that it does there (D7/F-tables-figures parity).
222
+ remarkMdx,
198
223
  remarkMath,
199
- [
200
- remarkNotation,
201
- {
202
- definitions: manifest.notation.raw,
203
- equations: manifest.equations.numbersBySlug,
204
- },
205
- ],
224
+ // `manifest` carries the number tables *and* the part/offset placement
225
+ // together, so this check numbers a page exactly as the Astro build
226
+ // does. Passing the tables alone silently numbered pages by `##` section
227
+ // here while cross-page references read the part-scheme manifest.
228
+ [remarkNotation, { definitions: manifest.notation.raw, manifest }],
206
229
  [remarkCitation, { sources: manifest.sources }],
207
230
  ],
208
231
  rehypePlugins: [
@@ -441,6 +464,8 @@ function domainSignaturesForSection(section: string): DomainFileSignatures[] {
441
464
  // --- content status ------------------------------------------------------
442
465
 
443
466
  export interface StatusReport {
467
+ /** The course's published knowledge version (`about.version`). */
468
+ readonly knowledgeVersion: string;
444
469
  readonly counts: {
445
470
  readonly lessons: number;
446
471
  readonly competencies: number;
@@ -449,6 +474,8 @@ export interface StatusReport {
449
474
  readonly tracks: number;
450
475
  readonly notationDefinitions: number;
451
476
  readonly keyedEquations: number;
477
+ readonly keyedTables: number;
478
+ readonly keyedFigures: number;
452
479
  };
453
480
  readonly draftDebt: {
454
481
  readonly lessons: readonly string[];
@@ -492,18 +519,30 @@ export function contentStatus(manifest: Manifest): StatusReport {
492
519
  ...manifest.diagnostics.alignment.map((d) => d.severity),
493
520
  ...manifest.diagnostics.math.map((d) => d.severity),
494
521
  ...manifest.diagnostics.equations.map((d) => d.severity),
522
+ ...manifest.diagnostics.tables.map((d) => d.severity),
523
+ ...manifest.diagnostics.figures.map((d) => d.severity),
495
524
  ...manifest.diagnostics.consistency.map(() => 'warning' as const),
525
+ ...manifest.diagnostics.knowledge.map((d) => d.severity),
496
526
  ];
497
527
 
498
528
  const keyedEquations = manifest.lessons.reduce(
499
529
  (count, lesson) => count + lesson.equations.length,
500
530
  0,
501
531
  );
532
+ const keyedTables = manifest.lessons.reduce(
533
+ (count, lesson) => count + lesson.tables.length,
534
+ 0,
535
+ );
536
+ const keyedFigures = manifest.lessons.reduce(
537
+ (count, lesson) => count + lesson.figures.length,
538
+ 0,
539
+ );
502
540
  const assessments = new Set(
503
541
  manifest.lessons.flatMap((lesson) => lesson.assessments),
504
542
  ).size;
505
543
 
506
544
  return {
545
+ knowledgeVersion: manifest.about.knowledge.version,
507
546
  counts: {
508
547
  lessons: manifest.lessons.length,
509
548
  competencies: manifest.competencies.length,
@@ -512,6 +551,8 @@ export function contentStatus(manifest: Manifest): StatusReport {
512
551
  tracks: manifest.tracks.length,
513
552
  notationDefinitions: manifest.notation.definitions.length,
514
553
  keyedEquations,
554
+ keyedTables,
555
+ keyedFigures,
515
556
  },
516
557
  draftDebt: { lessons: draftLessons, notation: draftNotation },
517
558
  orphans,
@@ -550,6 +591,8 @@ export interface ContextReport {
550
591
  formula?: string;
551
592
  }[];
552
593
  readonly equations: readonly { key: string; number: string }[];
594
+ readonly tables: readonly { key: string; number: string }[];
595
+ readonly figures: readonly { key: string; number: string }[];
553
596
  readonly sources: readonly {
554
597
  id: string;
555
598
  title: string;
@@ -618,6 +661,8 @@ export function contentContext(
618
661
  : { formula: definition.formula }),
619
662
  })),
620
663
  equations: lesson.equations.map((equation) => ({ ...equation })),
664
+ tables: lesson.tables.map((table) => ({ ...table })),
665
+ figures: lesson.figures.map((figure) => ({ ...figure })),
621
666
  sources: lesson.sources.map((id) => {
622
667
  const record = sourceById.get(id) as Record<string, unknown> | undefined;
623
668
  return {
@@ -36,6 +36,7 @@ import type {
36
36
  } from '../reference/types';
37
37
  import { isSubstantiveMeaning } from '../reference/prose';
38
38
  import { parseEquationRef } from '../reference/equations.mjs';
39
+ import { parseKeyedFloatRef } from '../reference/keyed-jsx-floats.mjs';
39
40
  import { resolveLabel } from '../reference/label';
40
41
  import { resolveGlosses } from '../reference/gloss';
41
42
  import {
@@ -229,9 +230,17 @@ export function extractNotationReferences(
229
230
  for (const [pattern, referenceKind] of patterns) {
230
231
  for (const match of searchable.matchAll(pattern)) {
231
232
  const key = (match[1] ?? '').trim();
232
- // `[[eq:key]]` / `[[slug#eq:key]]` are equation references (D7), resolved
233
- // by the remark pass and the manifest, not notation keys.
234
- if (parseEquationRef(key)) continue;
233
+ // `[[eq-key]]` / `[[slug#eq-key]]` are equation references (D7), and
234
+ // `[[tab-key]]`/`[[fig-key]]` (and their cross-page forms) are
235
+ // `<Table>`/`<Figure>` references (F-tables-figures) — all resolved by
236
+ // the remark pass and the manifest, not notation keys.
237
+ if (
238
+ parseEquationRef(key) ||
239
+ parseKeyedFloatRef(key, { prefix: 'tab' }) ||
240
+ parseKeyedFloatRef(key, { prefix: 'fig' })
241
+ ) {
242
+ continue;
243
+ }
235
244
  references.push({
236
245
  key,
237
246
  kind: referenceKind,
@@ -7,13 +7,25 @@
7
7
  * supplies its own value of this shape.
8
8
  */
9
9
 
10
- export interface CourseSection {
10
+ /**
11
+ * One **part** — a sidebar group, holding the chapters (lessons) whose slugs
12
+ * start with `dir`. A part's 1-based position in `CourseConfig.parts` is the
13
+ * number before the dot in every equation, table, and figure number inside it.
14
+ * See `docs/authoring/structure.md` for the full level taxonomy.
15
+ */
16
+ export interface CoursePart {
11
17
  /** Lesson-slug prefix, e.g. `bonds` for `bonds/yield-to-maturity`. */
12
18
  readonly dir: string;
13
19
  /** Sidebar group heading. */
14
20
  readonly label: string;
15
21
  }
16
22
 
23
+ /**
24
+ * @deprecated Renamed to {@link CoursePart}. "Section" now only ever means an
25
+ * `##` heading inside a lesson. Removed in the next MINOR release.
26
+ */
27
+ export type CourseSection = CoursePart;
28
+
17
29
  export interface CourseConventions {
18
30
  /** Notation key a lesson's stated sign convention must resolve to. */
19
31
  readonly signConventionKey: string;
@@ -21,12 +33,90 @@ export interface CourseConventions {
21
33
  readonly cashFlowPerspectiveCompetency: string;
22
34
  }
23
35
 
36
+ /**
37
+ * A course's edits to the shipped named-operator baseline (`BASE_OPERATORS`),
38
+ * consumed by the `operator-explained` consistency check (rule N8). An operator
39
+ * a lesson uses is fine when it is on the baseline plus `allow` minus `deny`;
40
+ * otherwise it needs a notation entry the reader can hover.
41
+ *
42
+ * Names are the KaTeX command without its backslash (`erf`, not `\erf`) for a
43
+ * built-in, and the verbatim body for an `\operatorname{…}` operator (`Var`).
44
+ */
45
+ export interface CourseOperators {
46
+ /** Operators this course also treats as universal (`Var`, `Cov`, `sgn`, …). */
47
+ readonly allow?: readonly string[];
48
+ /** Baseline operators this course wants explained anyway. */
49
+ readonly deny?: readonly string[];
50
+ }
51
+
52
+ /** One person or team credited for the study material. */
53
+ export interface CourseAuthor {
54
+ readonly name: string;
55
+ /**
56
+ * Email, URL, or handle. Rendered in the About panel as a link when it looks
57
+ * like a `mailto:` or `https:` target, plain text otherwise.
58
+ */
59
+ readonly contact?: string;
60
+ }
61
+
62
+ /**
63
+ * The reader-facing "about this material" block, surfaced by the `[?]` panel in
64
+ * the header (`components/starlight/AboutPanel.astro`) on every page.
65
+ *
66
+ * `version` is the course's **knowledge version** — semver over the
67
+ * learning-facing content, independent of the `explico` engine's own version.
68
+ * It must equal the newest entry in `content/CHANGELOG.md`; a mismatch is a
69
+ * Tier-3 `knowledge-version-mismatch` warning (rule C3, `docs/authoring/versioning.md`).
70
+ */
71
+ export interface CourseAbout {
72
+ /** Display name of the material. Defaults to {@link CourseConfig.title}. */
73
+ readonly materialName?: string;
74
+ /** One or two sentences on what the material covers. Defaults to {@link CourseConfig.description}. */
75
+ readonly summary?: string;
76
+ /** Knowledge version — semver; equals the latest `content/CHANGELOG.md` heading. */
77
+ readonly version: string;
78
+ /** Who authored / maintains the material. At least one entry. */
79
+ readonly authors: readonly CourseAuthor[];
80
+ /** Shown verbatim in the About panel — scope, warranty, AI-assistance note. */
81
+ readonly disclaimer: string;
82
+ /** Link to the published content changelog (repo blob, docs page, …). */
83
+ readonly changelogHref?: string;
84
+ }
85
+
24
86
  export interface CourseConfig {
25
87
  readonly title: string;
26
88
  readonly description: string;
27
- /** Sidebar section groups, in order. */
28
- readonly sections: readonly CourseSection[];
89
+ /**
90
+ * The course's parts (sidebar groups), in order. Exactly one of `parts` or
91
+ * the deprecated `sections` must be given.
92
+ */
93
+ readonly parts?: readonly CoursePart[];
94
+ /**
95
+ * @deprecated Renamed to {@link CourseConfig.parts}. Still honoured, so a
96
+ * course on the old name keeps building; removed in the next MINOR release.
97
+ */
98
+ readonly sections?: readonly CoursePart[];
29
99
  /** Namespaces used by competency / notation `domain` fields. */
30
100
  readonly domains: readonly string[];
31
101
  readonly conventions: CourseConventions;
102
+ /** Reader-facing identity + knowledge version (the `[?]` About panel). */
103
+ readonly about: CourseAbout;
104
+ /** Edits to the shipped named-operator baseline (rule N8). Optional. */
105
+ readonly operators?: CourseOperators;
106
+ }
107
+
108
+ /**
109
+ * The course's parts, from whichever field the config uses. Throws rather than
110
+ * silently producing a course with no sidebar groups — a config with neither
111
+ * field would otherwise number every float by the `##`-section fallback.
112
+ */
113
+ export function coursePartsOf(config: CourseConfig): readonly CoursePart[] {
114
+ const parts = config.parts ?? config.sections;
115
+ if (!parts || parts.length === 0) {
116
+ throw new Error(
117
+ 'CourseConfig needs at least one entry in `parts` (a sidebar group: ' +
118
+ '{ dir, label }). The old name `sections` is still accepted.',
119
+ );
120
+ }
121
+ return parts;
32
122
  }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * The course's **knowledge version** — semver over the learning-facing content,
3
+ * independent of the `explico` engine's own package version.
4
+ *
5
+ * The declared value lives in `content/course.config.ts` (`about.version`); the
6
+ * history and the human-readable notes live in `content/CHANGELOG.md`
7
+ * (Keep a Changelog shape). This module parses that file and checks the two
8
+ * agree — rule C3 in `docs/authoring/versioning.md`. Every finding is a Tier-3
9
+ * warning: `pnpm validate:content` prints it, `manifest.diagnostics.knowledge`
10
+ * records it, and the build still passes. The resolved entry is what the `[?]`
11
+ * About panel publishes to readers (`manifest.about.knowledge`).
12
+ *
13
+ * Pure: `parseKnowledgeChangelog` takes text, `resolveKnowledgeVersion` takes
14
+ * text + the declared version. The caller (`compileManifest`) does the file IO.
15
+ */
16
+
17
+ /** A released entry from `content/CHANGELOG.md`, newest first in file order. */
18
+ export interface KnowledgeChangelogEntry {
19
+ readonly version: string;
20
+ /** The `- YYYY-MM-DD` part of the heading, if present. */
21
+ readonly date?: string;
22
+ /** Bullet lines under the heading, marker stripped, in file order. */
23
+ readonly notes: readonly string[];
24
+ }
25
+
26
+ export type KnowledgeVersionCode =
27
+ | 'knowledge-changelog-missing'
28
+ | 'knowledge-version-invalid'
29
+ | 'knowledge-version-mismatch';
30
+
31
+ export interface KnowledgeVersionDiagnostic {
32
+ readonly code: KnowledgeVersionCode;
33
+ /** Tier 3 — a knowledge-version drift never fails the build. */
34
+ readonly severity: 'warning';
35
+ readonly message: string;
36
+ readonly file?: string;
37
+ }
38
+
39
+ export interface KnowledgeVersion {
40
+ /** The declared `about.version`, echoed for consumers. */
41
+ readonly version: string;
42
+ /** The newest released `## [x.y.z]` entry, when the changelog has one. */
43
+ readonly latest?: KnowledgeChangelogEntry;
44
+ readonly diagnostics: readonly KnowledgeVersionDiagnostic[];
45
+ }
46
+
47
+ // `1.2.3`, optionally `-rc.1` / `+build.5` — the shape `about.version` and a
48
+ // `## [x.y.z]` heading must both take.
49
+ const SEMVER_RE =
50
+ /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
51
+ const HEADING_RE = /^##\s+\[([^\]]+)\]\s*(?:[-–—]\s*(\S.*?))?\s*$/;
52
+ const BULLET_RE = /^[-*]\s+(.*\S)\s*$/;
53
+
54
+ export function isKnowledgeSemver(value: string): boolean {
55
+ return SEMVER_RE.test(value.trim());
56
+ }
57
+
58
+ /**
59
+ * Every released entry in a `content/CHANGELOG.md` body, in file order (newest
60
+ * first, by Keep a Changelog convention). `## [Unreleased]` is skipped.
61
+ */
62
+ export function parseKnowledgeChangelog(
63
+ text: string,
64
+ ): KnowledgeChangelogEntry[] {
65
+ const lines = text.split(/\r?\n/);
66
+ const entries: {
67
+ version: string;
68
+ date?: string;
69
+ notes: string[];
70
+ }[] = [];
71
+ let current: { version: string; date?: string; notes: string[] } | null =
72
+ null;
73
+
74
+ for (const line of lines) {
75
+ const heading = HEADING_RE.exec(line);
76
+ if (heading) {
77
+ const token = (heading[1] ?? '').trim();
78
+ if (/^unreleased$/i.test(token)) {
79
+ current = null;
80
+ continue;
81
+ }
82
+ current = { version: token, notes: [] };
83
+ const date = (heading[2] ?? '').trim();
84
+ if (date) current.date = date;
85
+ entries.push(current);
86
+ continue;
87
+ }
88
+ // A top-level `#` or a deeper `###` heading ends the current entry's notes.
89
+ if (/^#{1,3}\s/.test(line) && !line.startsWith('## [')) {
90
+ current = null;
91
+ continue;
92
+ }
93
+ if (!current) continue;
94
+ const bullet = BULLET_RE.exec(line);
95
+ if (bullet) current.notes.push(bullet[1] ?? '');
96
+ }
97
+
98
+ return entries.map((entry) => ({
99
+ version: entry.version,
100
+ ...(entry.date === undefined ? {} : { date: entry.date }),
101
+ notes: [...entry.notes],
102
+ }));
103
+ }
104
+
105
+ /**
106
+ * Check the declared knowledge version against `content/CHANGELOG.md`.
107
+ *
108
+ * @param options.changelogText the file body, or `undefined` when it is absent
109
+ * @param options.declaredVersion `courseConfig.about.version`
110
+ * @param options.file repo-relative path, for diagnostic messages
111
+ */
112
+ export function resolveKnowledgeVersion(options: {
113
+ readonly changelogText: string | undefined;
114
+ readonly declaredVersion: string;
115
+ readonly file?: string;
116
+ }): KnowledgeVersion {
117
+ const { changelogText, declaredVersion } = options;
118
+ const file = options.file ?? 'content/CHANGELOG.md';
119
+ const diagnostics: KnowledgeVersionDiagnostic[] = [];
120
+ const warn = (code: KnowledgeVersionCode, message: string): void => {
121
+ diagnostics.push({ code, severity: 'warning', message, file });
122
+ };
123
+
124
+ if (!isKnowledgeSemver(declaredVersion)) {
125
+ warn(
126
+ 'knowledge-version-invalid',
127
+ `about.version ${JSON.stringify(declaredVersion)} in content/course.config.ts is not a semver \`x.y.z\` string.`,
128
+ );
129
+ }
130
+
131
+ if (changelogText === undefined) {
132
+ warn(
133
+ 'knowledge-changelog-missing',
134
+ `No ${file}. Add a Keep a Changelog file whose newest \`## [x.y.z]\` heading is ${JSON.stringify(declaredVersion)} (rule C1).`,
135
+ );
136
+ return { version: declaredVersion, diagnostics };
137
+ }
138
+
139
+ const entries = parseKnowledgeChangelog(changelogText);
140
+ const latest = entries[0];
141
+ if (!latest) {
142
+ warn(
143
+ 'knowledge-changelog-missing',
144
+ `${file} has no released \`## [x.y.z]\` entry — only \`## [Unreleased]\`. Cut a release: move it to \`## [${declaredVersion}] - <date>\` (rule C4).`,
145
+ );
146
+ return { version: declaredVersion, diagnostics };
147
+ }
148
+
149
+ if (isKnowledgeSemver(declaredVersion) && latest.version !== declaredVersion) {
150
+ warn(
151
+ 'knowledge-version-mismatch',
152
+ `about.version is ${JSON.stringify(declaredVersion)} but the newest ${file} entry is ${JSON.stringify(latest.version)}. Bump one to match the other (rule C3).`,
153
+ );
154
+ }
155
+
156
+ return { version: declaredVersion, latest, diagnostics };
157
+ }
@@ -156,7 +156,7 @@ export type SidebarGroup =
156
156
  export function buildSidebar(
157
157
  tracks: readonly TrackLike[],
158
158
  lessonSlugs: readonly string[],
159
- sections: readonly SidebarSection[],
159
+ parts: readonly SidebarSection[],
160
160
  ): SidebarGroup[] {
161
161
  const rank = new Map(
162
162
  mergeTrackOrder(tracks).map((lessonId, index) => [lessonId, index]),
@@ -164,7 +164,7 @@ export function buildSidebar(
164
164
  const positionOf = (slug: string) =>
165
165
  rank.get(lessonIdFromSlug(slug)) ?? Number.POSITIVE_INFINITY;
166
166
 
167
- const groups: SidebarGroup[] = sections.map(({ dir, label }) => ({
167
+ const groups: SidebarGroup[] = parts.map(({ dir, label }) => ({
168
168
  label,
169
169
  items: lessonSlugs
170
170
  .filter((slug) => slug.startsWith(`${dir}/`))