explico 0.7.0 → 0.11.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 (236) hide show
  1. package/CHANGELOG.md +1409 -1
  2. package/README.md +60 -15
  3. package/dist/compiler/astro.d.ts +6 -1
  4. package/dist/compiler/cli.d.ts +54 -3
  5. package/dist/compiler/cli.js +80 -6
  6. package/dist/compiler/collections.d.ts +35 -2
  7. package/dist/compiler/collections.js +84 -8
  8. package/dist/compiler/course-config.d.ts +3 -194
  9. package/dist/compiler/course-config.js +78 -5
  10. package/dist/compiler/exports/standalone-html.d.ts +6 -1
  11. package/dist/compiler/knowledge-version.js +1 -1
  12. package/dist/compiler/lesson-derivation.d.ts +27 -10
  13. package/dist/compiler/lesson-derivation.js +23 -8
  14. package/dist/compiler/logging.d.ts +14 -1
  15. package/dist/compiler/logging.js +6 -2
  16. package/dist/compiler/manifest.d.ts +204 -31
  17. package/dist/compiler/manifest.js +270 -55
  18. package/dist/compiler/resolution-report.d.ts +6 -1
  19. package/dist/course-config-Dsnri9Tk.d.ts +464 -0
  20. package/dist/reference/base-accents.d.mts +26 -0
  21. package/dist/reference/base-accents.mjs +31 -0
  22. package/dist/reference/base-roman-labels.d.mts +2 -2
  23. package/dist/reference/base-roman-labels.mjs +2 -2
  24. package/dist/reference/consistency.d.ts +14 -2
  25. package/dist/reference/consistency.js +108 -4
  26. package/dist/reference/delimiter-size.d.ts +21 -11
  27. package/dist/reference/delimiter-size.js +15 -39
  28. package/dist/reference/diagrams-validate.d.ts +7 -2
  29. package/dist/reference/diagrams-validate.js +9 -1
  30. package/dist/reference/equations-validate.d.ts +8 -5
  31. package/dist/reference/equations-validate.js +1 -0
  32. package/dist/reference/equations.d.mts +69 -34
  33. package/dist/reference/equations.mjs +90 -44
  34. package/dist/reference/examples-validate.d.ts +31 -0
  35. package/dist/reference/examples-validate.js +48 -0
  36. package/dist/reference/figures-validate.d.ts +1 -1
  37. package/dist/reference/figures-validate.js +4 -1
  38. package/dist/reference/folds-validate.d.ts +33 -0
  39. package/dist/reference/folds-validate.js +32 -0
  40. package/dist/reference/gate-abbreviations.d.ts +3 -1
  41. package/dist/reference/gate-abbreviations.js +3 -2
  42. package/dist/reference/gate-course-rules.d.ts +32 -0
  43. package/dist/reference/gate-course-rules.js +85 -0
  44. package/dist/reference/gate-heading-length.d.ts +5 -2
  45. package/dist/reference/gate-heading-length.js +47 -23
  46. package/dist/reference/gate-heading-structure.d.ts +41 -0
  47. package/dist/reference/gate-heading-structure.js +113 -0
  48. package/dist/reference/gate-math-punctuation.d.ts +71 -0
  49. package/dist/reference/gate-math-punctuation.js +251 -0
  50. package/dist/reference/gate-math.d.ts +44 -6
  51. package/dist/reference/gate-math.js +63 -10
  52. package/dist/reference/gate-part-pages.d.ts +50 -0
  53. package/dist/reference/gate-part-pages.js +118 -0
  54. package/dist/reference/gate-prose-notation.d.ts +32 -11
  55. package/dist/reference/gate-prose-notation.js +30 -8
  56. package/dist/reference/katex-delimiters.d.mts +15 -0
  57. package/dist/reference/katex-delimiters.mjs +138 -0
  58. package/dist/reference/katex-options.mjs +4 -0
  59. package/dist/reference/keyed-float-validate.d.ts +27 -10
  60. package/dist/reference/keyed-float-validate.js +15 -1
  61. package/dist/reference/keyed-jsx-floats.d.mts +11 -5
  62. package/dist/reference/keyed-jsx-floats.mjs +73 -20
  63. package/dist/reference/knowledge-checks-validate.d.ts +1 -1
  64. package/dist/reference/knowledge-checks.d.mts +3 -1
  65. package/dist/reference/knowledge-checks.mjs +9 -4
  66. package/dist/reference/lab-math-scope.d.ts +1 -0
  67. package/dist/reference/lab-math-slot.d.ts +34 -0
  68. package/dist/reference/lab-math-slot.js +6 -0
  69. package/dist/reference/math-glyphs.d.mts +80 -4
  70. package/dist/reference/math-glyphs.mjs +588 -39
  71. package/dist/reference/page-notation.d.ts +6 -1
  72. package/dist/reference/raw-floats-validate.js +1 -1
  73. package/dist/reference/raw-floats.mjs +1 -1
  74. package/dist/reference/rehype-heading-anchors.d.mts +14 -0
  75. package/dist/reference/rehype-heading-anchors.mjs +116 -17
  76. package/dist/reference/rehype-table-header-labels.d.mts +6 -1
  77. package/dist/reference/rehype-table-header-labels.mjs +21 -3
  78. package/dist/reference/remark-asides.d.mts +3 -0
  79. package/dist/reference/remark-asides.mjs +180 -0
  80. package/dist/reference/remark-memo.d.mts +3 -1
  81. package/dist/reference/remark-memo.mjs +42 -11
  82. package/dist/reference/remark-notation.d.mts +60 -10
  83. package/dist/reference/remark-notation.mjs +387 -36
  84. package/dist/reference/render-lab-math.d.ts +4 -26
  85. package/dist/reference/render-lab-math.js +5 -5
  86. package/dist/reference/sections-validate.d.ts +27 -0
  87. package/dist/reference/sections-validate.js +41 -0
  88. package/dist/reference/sections.d.mts +97 -0
  89. package/dist/reference/sections.mjs +306 -0
  90. package/dist/reference/tables-validate.d.ts +1 -1
  91. package/dist/reference/types.d.ts +24 -1
  92. package/dist/reference/unit-words.d.ts +37 -0
  93. package/dist/reference/unit-words.js +170 -0
  94. package/dist/rules/catalog.d.ts +176 -0
  95. package/dist/rules/catalog.js +983 -0
  96. package/dist/rules/course-rules.d.ts +115 -0
  97. package/dist/rules/course-rules.js +292 -0
  98. package/dist/rules/course-rules.schema.json +138 -0
  99. package/dist/rules/in-effect.d.ts +53 -0
  100. package/dist/rules/in-effect.js +142 -0
  101. package/dist/rules/rules.schema.json +1072 -0
  102. package/dist/rules/settings.d.ts +3 -0
  103. package/dist/rules/settings.js +290 -0
  104. package/dist/rules/waivers.d.ts +53 -0
  105. package/dist/rules/waivers.js +132 -0
  106. package/dist/session/PreferenceStore.d.ts +5 -0
  107. package/dist/session/PreferenceStore.js +9 -0
  108. package/docs/VERSIONING.md +2 -2
  109. package/docs/authoring/README.md +232 -40
  110. package/docs/authoring/agents.md +55 -9
  111. package/docs/authoring/components.md +178 -22
  112. package/docs/authoring/migrating.md +1324 -2
  113. package/docs/authoring/notation.md +616 -344
  114. package/docs/authoring/prose.md +551 -0
  115. package/docs/authoring/retired.md +35 -0
  116. package/docs/authoring/structure-rules.md +422 -40
  117. package/docs/authoring/structure.md +445 -62
  118. package/docs/authoring/versioning.md +20 -28
  119. package/docs/authoring/visualizations.md +142 -20
  120. package/docs/katex-bracket-scaling.md +191 -0
  121. package/package.json +3 -2
  122. package/src/compiler/cli.ts +139 -6
  123. package/src/compiler/collections.ts +144 -10
  124. package/src/compiler/course-config.ts +273 -34
  125. package/src/compiler/knowledge-version.ts +1 -1
  126. package/src/compiler/lesson-derivation.ts +54 -15
  127. package/src/compiler/logging.ts +21 -4
  128. package/src/compiler/manifest.ts +573 -104
  129. package/src/components/BlockHeading.astro +113 -0
  130. package/src/components/CourseContents.astro +221 -0
  131. package/src/components/CurriculumMap.astro +5 -6
  132. package/src/components/FloatIndex.astro +200 -0
  133. package/src/components/HoverPanel.astro +431 -79
  134. package/src/components/PartBridge.astro +27 -0
  135. package/src/components/PartOverview.astro +401 -0
  136. package/src/components/PartScope.astro +27 -0
  137. package/src/components/abbreviation/AbbreviationLayer.astro +49 -51
  138. package/src/components/assessments/AssessmentRunner.tsx +8 -5
  139. package/src/components/assessments/AssessmentSet.astro +23 -26
  140. package/src/components/block-heading.ts +146 -0
  141. package/src/components/card-arrow.ts +243 -0
  142. package/src/components/card-drag.ts +631 -0
  143. package/src/components/card-jump.ts +45 -0
  144. package/src/components/card-manager.ts +1589 -0
  145. package/src/components/card-memory.ts +177 -0
  146. package/src/components/card-placement.ts +504 -0
  147. package/src/components/card-side.ts +336 -0
  148. package/src/components/card-travel.ts +197 -0
  149. package/src/components/citation/CitationLayer.astro +77 -76
  150. package/src/components/diagram/Diagram.astro +161 -316
  151. package/src/components/disclosure/course-disclosure.ts +24 -0
  152. package/src/components/disclosure/hover-preview.ts +122 -42
  153. package/src/components/disclosure/persisted-disclosure.ts +3 -3
  154. package/src/components/disclosure/print-as-shown.ts +252 -0
  155. package/src/components/examples/CompactExample.astro +3 -1
  156. package/src/components/examples/CompactExamples.astro +101 -94
  157. package/src/components/figure/Figure.astro +174 -274
  158. package/src/components/figure/FloatCaption.astro +87 -0
  159. package/src/components/figure/figure-state.ts +58 -0
  160. package/src/components/figure/figure-tools.ts +87 -0
  161. package/src/components/float-name.ts +101 -0
  162. package/src/components/fold/Fold.astro +125 -0
  163. package/src/components/fold/fold-id.ts +40 -0
  164. package/src/components/memo/Memo.astro +162 -231
  165. package/src/components/memo/ShortNote.astro +6 -6
  166. package/src/components/notation/NotationFormula.astro +5 -1
  167. package/src/components/notation/NotationGlossary.astro +24 -14
  168. package/src/components/notation/NotationLayer.astro +169 -162
  169. package/src/components/notation/NotationMath.astro +10 -1
  170. package/src/components/notation/render-notation-math.ts +111 -10
  171. package/src/components/reference/FloatRefLayer.astro +485 -423
  172. package/src/components/starlight/AboutPanel.astro +22 -6
  173. package/src/components/starlight/CollapsibleSections.astro +207 -93
  174. package/src/components/starlight/DiagramEnhancer.astro +15 -138
  175. package/src/components/starlight/HeadingAnchorEnhancer.astro +193 -1
  176. package/src/components/starlight/KeyedFloatEnhancer.astro +100 -9
  177. package/src/components/starlight/LayoutHeader.astro +298 -19
  178. package/src/components/starlight/LayoutPageSidebar.astro +99 -12
  179. package/src/components/starlight/LayoutPageTitle.astro +120 -0
  180. package/src/components/starlight/LayoutSidebar.astro +229 -11
  181. package/src/components/starlight/LessonExportActions.astro +53 -72
  182. package/src/components/starlight/LessonFooter.astro +26 -4
  183. package/src/components/starlight/section-parts.ts +15 -2
  184. package/src/components/starlight/section-summary.ts +181 -0
  185. package/src/components/table/Table.astro +95 -76
  186. package/src/components/table/TableOverflowExpander.astro +65 -28
  187. package/src/content-config.ts +40 -4
  188. package/src/reference/base-accents.mjs +31 -0
  189. package/src/reference/base-roman-labels.mjs +2 -2
  190. package/src/reference/consistency.ts +176 -5
  191. package/src/reference/delimiter-size.ts +40 -61
  192. package/src/reference/diagrams-validate.ts +17 -1
  193. package/src/reference/equations-validate.ts +10 -5
  194. package/src/reference/equations.mjs +90 -44
  195. package/src/reference/examples-validate.ts +97 -0
  196. package/src/reference/figures-validate.ts +6 -1
  197. package/src/reference/folds-validate.ts +65 -0
  198. package/src/reference/gate-abbreviations.ts +3 -2
  199. package/src/reference/gate-course-rules.ts +134 -0
  200. package/src/reference/gate-heading-length.ts +68 -37
  201. package/src/reference/gate-heading-structure.ts +193 -0
  202. package/src/reference/gate-math-punctuation.ts +381 -0
  203. package/src/reference/gate-math.ts +119 -12
  204. package/src/reference/gate-part-pages.ts +217 -0
  205. package/src/reference/gate-prose-notation.ts +74 -13
  206. package/src/reference/katex-delimiters.mjs +138 -0
  207. package/src/reference/katex-options.mjs +4 -0
  208. package/src/reference/keyed-float-validate.ts +45 -11
  209. package/src/reference/keyed-jsx-floats.mjs +73 -20
  210. package/src/reference/knowledge-checks-validate.ts +1 -1
  211. package/src/reference/knowledge-checks.mjs +9 -4
  212. package/src/reference/lab-math-slot.ts +36 -0
  213. package/src/reference/math-glyphs.mjs +588 -39
  214. package/src/reference/raw-floats-validate.ts +1 -1
  215. package/src/reference/raw-floats.mjs +1 -1
  216. package/src/reference/rehype-heading-anchors.mjs +116 -17
  217. package/src/reference/rehype-table-header-labels.mjs +21 -3
  218. package/src/reference/remark-asides.mjs +180 -0
  219. package/src/reference/remark-memo.mjs +42 -11
  220. package/src/reference/remark-notation.mjs +387 -36
  221. package/src/reference/render-lab-math.ts +17 -32
  222. package/src/reference/sections-validate.ts +81 -0
  223. package/src/reference/sections.mjs +306 -0
  224. package/src/reference/tables-validate.ts +2 -1
  225. package/src/reference/types.ts +24 -0
  226. package/src/reference/unit-words.ts +233 -0
  227. package/src/rules/catalog.ts +1298 -0
  228. package/src/rules/course-rules.ts +456 -0
  229. package/src/rules/in-effect.ts +235 -0
  230. package/src/rules/settings.ts +448 -0
  231. package/src/rules/waivers.ts +249 -0
  232. package/src/session/PreferenceStore.ts +15 -0
  233. package/src/styles/global.css +1471 -163
  234. package/src/styles/surfaces.css +182 -0
  235. package/src/components/hover-panel.ts +0 -339
  236. package/src/components/popup-stack.ts +0 -345
@@ -0,0 +1,448 @@
1
+ /**
2
+ * A course's per-rule settings — `rules` in `CourseConfig` — resolved against
3
+ * the catalog.
4
+ *
5
+ * The block is flat by rule ID and holds only deviations from the defaults:
6
+ *
7
+ * ```json
8
+ * "rules": {
9
+ * "P5": { "enabled": false },
10
+ * "S1": { "maxLength": 72 },
11
+ * "N14": { "allow": ["GDP"] }
12
+ * }
13
+ * ```
14
+ *
15
+ * It also resolves the course's *own* rules — `courseRules`, a separate block
16
+ * with a separate module (`src/rules/course-rules.ts`) — so that one call hands
17
+ * the compiler every rule decision the course made, engine or not.
18
+ *
19
+ * `resolveRules` validates it (an unknown ID, an unknown key, a value outside
20
+ * its spec, or `enabled: false` on a locked rule is an error — the settings
21
+ * file is wrong, and a wrong bump would ship), applies every default from the
22
+ * catalog, folds in the deprecated top-level `operators` / `romanLabels` /
23
+ * `abbreviations` blocks, and hands the gates one object to ask. It never
24
+ * decides *what* a rule means — that is the catalog's job.
25
+ *
26
+ * `rulesJsonSchema` renders the same catalog as the JSON Schema fragment the
27
+ * course's settings form uses (`dist/rules/rules.schema.json`, built by
28
+ * `scripts/gen-rules-schema.mjs`), with properties ordered by element and an
29
+ * `x-element` hint per rule so the form can group them.
30
+ */
31
+ import {
32
+ CATALOG,
33
+ ELEMENTS,
34
+ enforcementOf,
35
+ entryById,
36
+ isCourseRuleId,
37
+ retiredRule,
38
+ ruleById,
39
+ rules as catalogRules,
40
+ type Element,
41
+ type RuleEntry,
42
+ type RuleId,
43
+ type SettingSpec,
44
+ } from './catalog';
45
+ import { resolveCourseRules, type CourseRule } from './course-rules';
46
+ import type { CourseConfig, NormalizedPartPages } from '../compiler/course-config';
47
+
48
+ /** One rule's block in the settings file. Every key is optional. */
49
+ export interface RuleSettingsInput {
50
+ readonly enabled?: boolean;
51
+ readonly [key: string]: unknown;
52
+ }
53
+
54
+ /** `CourseConfig.rules` — flat by rule ID. */
55
+ export type CourseRulesConfig = Readonly<Record<string, RuleSettingsInput>>;
56
+
57
+ /** A non-fatal finding about the settings file itself. */
58
+ export interface ConfigDiagnostic {
59
+ readonly code: 'config-deprecated-key' | 'config-retired-rule';
60
+ readonly severity: 'warning';
61
+ readonly message: string;
62
+ }
63
+
64
+ export interface ResolvedRules {
65
+ /** False only for a rule the course turned off. An invariant is always on. */
66
+ isEnabled(id: string): boolean;
67
+ /** The course's own rules (`courseRules`), validated, in file order. */
68
+ readonly course: readonly CourseRule[];
69
+ /** The rule's settings with every default applied. `{}` for a rule without any. */
70
+ settings(id: string): Readonly<Record<string, unknown>>;
71
+ number(id: string, key: string): number;
72
+ strings(id: string, key: string): readonly string[];
73
+ /** Rules the course turned off — catalog order, then its own `X` rules. */
74
+ readonly disabled: readonly RuleId[];
75
+ readonly diagnostics: readonly ConfigDiagnostic[];
76
+ }
77
+
78
+ /** Top-level keys that predate `rules`, and the rule each one configures. */
79
+ const LEGACY_KEYS = {
80
+ operators: 'N8',
81
+ romanLabels: 'N10',
82
+ abbreviations: 'N14',
83
+ } as const;
84
+
85
+ /** Cross-field checks a single `SettingSpec` cannot express. */
86
+ const EXTRA_VALIDATION: Partial<
87
+ Record<RuleId, (settings: Readonly<Record<string, unknown>>) => string | undefined>
88
+ > = {
89
+ S2: (s) => {
90
+ for (const slot of ['bridge', 'scope'] as const) {
91
+ const min = s[`${slot}MinWords`] as number;
92
+ const max = s[`${slot}MaxWords`] as number;
93
+ if (min > max) {
94
+ return `${slot}MinWords (${min}) is larger than ${slot}MaxWords (${max}).`;
95
+ }
96
+ }
97
+ return undefined;
98
+ },
99
+ };
100
+
101
+ function describe(value: unknown): string {
102
+ return JSON.stringify(value) ?? String(value);
103
+ }
104
+
105
+ function validateSetting(id: string, spec: SettingSpec, value: unknown): void {
106
+ const at = `CourseConfig rules.${id}.${spec.key}`;
107
+ const { schema } = spec;
108
+ switch (schema.type) {
109
+ case 'integer': {
110
+ if (!Number.isInteger(value)) {
111
+ throw new Error(`${at} must be an integer; received ${describe(value)}.`);
112
+ }
113
+ if (schema.minimum !== undefined && (value as number) < schema.minimum) {
114
+ throw new Error(`${at} must be at least ${schema.minimum}; received ${describe(value)}.`);
115
+ }
116
+ return;
117
+ }
118
+ case 'boolean': {
119
+ if (typeof value !== 'boolean') {
120
+ throw new Error(`${at} must be true or false; received ${describe(value)}.`);
121
+ }
122
+ return;
123
+ }
124
+ case 'array': {
125
+ if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
126
+ throw new Error(`${at} must be an array of strings; received ${describe(value)}.`);
127
+ }
128
+ return;
129
+ }
130
+ }
131
+ }
132
+
133
+ function suggestion(id: string): string {
134
+ const area = id.charAt(0).toUpperCase();
135
+ const ids = catalogRules()
136
+ .filter((rule) => rule.id.startsWith(area))
137
+ .map((rule) => rule.id);
138
+ return ids.length === 0
139
+ ? `Rule IDs are a letter (N, V, C, S, P) and a number, e.g. N9.`
140
+ : `Rules in area ${area}: ${ids.join(', ')}.`;
141
+ }
142
+
143
+ function defaultsOf(rule: RuleEntry): Record<string, unknown> {
144
+ const out: Record<string, unknown> = {};
145
+ for (const spec of rule.settings ?? []) out[spec.key] = spec.schema.default;
146
+ return out;
147
+ }
148
+
149
+ /**
150
+ * Validate `config.rules` (plus the deprecated top-level blocks) against the
151
+ * catalog and apply defaults. Throws on anything that would make a gate run
152
+ * with a value the author did not mean.
153
+ */
154
+ export function resolveRules(config: CourseConfig): ResolvedRules {
155
+ const diagnostics: ConfigDiagnostic[] = [];
156
+ const given: Record<string, RuleSettingsInput> = { ...(config.rules ?? {}) };
157
+
158
+ for (const [legacyKey, id] of Object.entries(LEGACY_KEYS) as [keyof typeof LEGACY_KEYS, RuleId][]) {
159
+ const value = config[legacyKey];
160
+ if (value === undefined) continue;
161
+ if (given[id] !== undefined) {
162
+ throw new Error(
163
+ `CourseConfig sets both the deprecated \`${legacyKey}\` and \`rules.${id}\`; ` +
164
+ `keep \`rules.${id}\` and delete \`${legacyKey}\`.`,
165
+ );
166
+ }
167
+ given[id] = value as RuleSettingsInput;
168
+ diagnostics.push({
169
+ code: 'config-deprecated-key',
170
+ severity: 'warning',
171
+ message:
172
+ `CourseConfig \`${legacyKey}\` is deprecated — move it to \`rules.${id}\` ` +
173
+ `(same \`allow\` / \`deny\` shape). The old key is honoured for now.`,
174
+ });
175
+ }
176
+
177
+ const enabled = new Map<string, boolean>();
178
+ const settings = new Map<string, Readonly<Record<string, unknown>>>();
179
+
180
+ for (const rule of catalogRules()) {
181
+ enabled.set(rule.id, true);
182
+ settings.set(rule.id, defaultsOf(rule));
183
+ }
184
+
185
+ for (const [id, block] of Object.entries(given)) {
186
+ const rule = ruleById(id);
187
+ if (rule === undefined) {
188
+ const gone = retiredRule(id);
189
+ if (gone !== undefined) {
190
+ diagnostics.push({
191
+ code: 'config-retired-rule',
192
+ severity: 'warning',
193
+ message:
194
+ `CourseConfig rules.${id}: rule ${id} was retired on ${gone.date}` +
195
+ `${gone.into === undefined ? '' : ` into rule ${gone.into}`}; the block has no effect. ` +
196
+ 'See docs/authoring/retired.md.',
197
+ });
198
+ continue;
199
+ }
200
+ const invariant = entryById(id);
201
+ if (isCourseRuleId(id)) {
202
+ throw new Error(
203
+ `CourseConfig rules.${id}: \`${id}\` is one of this course's own rules. ` +
204
+ 'A course rule is defined *and* switched in `courseRules` (its `enabled` key); ' +
205
+ '`rules` is for the rules the engine ships.',
206
+ );
207
+ }
208
+ throw new Error(
209
+ invariant !== undefined
210
+ ? `CourseConfig rules.${id}: \`${id}\` is an invariant — always on, with no settings. Remove the block.`
211
+ : `CourseConfig rules.${id}: no such rule. ${suggestion(id)}`,
212
+ );
213
+ }
214
+ if (block === null || typeof block !== 'object' || Array.isArray(block)) {
215
+ throw new Error(
216
+ `CourseConfig rules.${id} must be an object such as { "enabled": false }; received ${describe(block)}.`,
217
+ );
218
+ }
219
+ const specs = new Map((rule.settings ?? []).map((spec) => [spec.key, spec]));
220
+ const resolved: Record<string, unknown> = defaultsOf(rule);
221
+ for (const [key, value] of Object.entries(block)) {
222
+ if (key === 'enabled') {
223
+ if (typeof value !== 'boolean') {
224
+ throw new Error(`CourseConfig rules.${id}.enabled must be true or false; received ${describe(value)}.`);
225
+ }
226
+ if (value === false && rule.locked !== undefined) {
227
+ throw new Error(
228
+ `CourseConfig rules.${id}: rule ${id} is locked and cannot be turned off — ${rule.locked}`,
229
+ );
230
+ }
231
+ enabled.set(id, value);
232
+ continue;
233
+ }
234
+ const spec = specs.get(key);
235
+ if (spec === undefined) {
236
+ const known = [...specs.keys()];
237
+ throw new Error(
238
+ `CourseConfig rules.${id}: unknown setting \`${key}\`. ` +
239
+ (known.length === 0
240
+ ? `Rule ${id} has no settings besides \`enabled\`.`
241
+ : `Rule ${id} takes: enabled, ${known.join(', ')}.`),
242
+ );
243
+ }
244
+ validateSetting(id, spec, value);
245
+ resolved[key] = value;
246
+ }
247
+ const problem = EXTRA_VALIDATION[rule.id]?.(resolved);
248
+ if (problem !== undefined) throw new Error(`CourseConfig rules.${id}: ${problem}`);
249
+ settings.set(id, resolved);
250
+ }
251
+
252
+ const course = resolveCourseRules(config);
253
+ const disabled: RuleId[] = [
254
+ ...catalogRules()
255
+ .map((rule) => rule.id)
256
+ .filter((id) => enabled.get(id) === false),
257
+ ...course.filter((rule) => !rule.enabled).map((rule) => rule.id),
258
+ ];
259
+ for (const rule of course) enabled.set(rule.id, rule.enabled);
260
+
261
+ const settingsOf = (id: string): Readonly<Record<string, unknown>> => {
262
+ const found = settings.get(id);
263
+ if (found === undefined) {
264
+ // An invariant has no settings; a course rule carries its own fields in
265
+ // `course` rather than a settings block.
266
+ if (entryById(id) !== undefined || isCourseRuleId(id)) return {};
267
+ throw new Error(`resolveRules: no rule ${id} in the catalog.`);
268
+ }
269
+ return found;
270
+ };
271
+
272
+ return {
273
+ isEnabled: (id) => enabled.get(id) ?? true,
274
+ course,
275
+ settings: settingsOf,
276
+ number: (id, key) => {
277
+ const value = settingsOf(id)[key];
278
+ if (typeof value !== 'number') throw new Error(`resolveRules: rules.${id}.${key} is not a number setting.`);
279
+ return value;
280
+ },
281
+ strings: (id, key) => {
282
+ const value = settingsOf(id)[key];
283
+ if (!Array.isArray(value)) throw new Error(`resolveRules: rules.${id}.${key} is not a string-array setting.`);
284
+ return value as readonly string[];
285
+ },
286
+ disabled,
287
+ diagnostics,
288
+ };
289
+ }
290
+
291
+ /** Rule S2's word ranges in the shape `gatePartPages` takes. */
292
+ export function partPageLimits(rules: ResolvedRules): NormalizedPartPages {
293
+ return {
294
+ bridge: {
295
+ minWords: rules.number('S2', 'bridgeMinWords'),
296
+ maxWords: rules.number('S2', 'bridgeMaxWords'),
297
+ },
298
+ scope: {
299
+ minWords: rules.number('S2', 'scopeMinWords'),
300
+ maxWords: rules.number('S2', 'scopeMaxWords'),
301
+ },
302
+ };
303
+ }
304
+
305
+ // --- JSON Schema -------------------------------------------------------------
306
+
307
+ type JsonSchema = Record<string, unknown>;
308
+
309
+ function settingSchema(spec: SettingSpec): JsonSchema {
310
+ const { schema } = spec;
311
+ const base: JsonSchema = { title: spec.title, description: spec.description };
312
+ switch (schema.type) {
313
+ case 'integer':
314
+ return {
315
+ ...base,
316
+ type: 'integer',
317
+ ...(schema.minimum === undefined ? {} : { minimum: schema.minimum }),
318
+ default: schema.default,
319
+ };
320
+ case 'boolean':
321
+ return { ...base, type: 'boolean', default: schema.default };
322
+ case 'array':
323
+ return {
324
+ ...base,
325
+ type: 'array',
326
+ items: { title: 'Entry', type: 'string', minLength: 1 },
327
+ default: [...schema.default],
328
+ };
329
+ }
330
+ }
331
+
332
+ function ruleSchema(rule: RuleEntry): JsonSchema {
333
+ const enforcement = enforcementOf(rule);
334
+ const properties: Record<string, JsonSchema> = {};
335
+ if (rule.locked === undefined) {
336
+ properties.enabled = {
337
+ title: 'Enabled',
338
+ description:
339
+ enforcement === 'guide'
340
+ ? 'Off: the rule leaves the rules-in-effect page and an agent neither applies it nor flags it in review.'
341
+ : 'Off: the check does not run, and the rule leaves the rules-in-effect page.',
342
+ type: 'boolean',
343
+ default: true,
344
+ };
345
+ }
346
+ for (const spec of rule.settings ?? []) properties[spec.key] = settingSchema(spec);
347
+ const description =
348
+ rule.summary +
349
+ (rule.locked === undefined ? '' : ` Locked — ${rule.locked}`) +
350
+ (rule.unchecked === undefined ? '' : ` Not checked by the build: ${rule.unchecked}`);
351
+ return {
352
+ title: `${rule.id} — ${rule.short ?? rule.title}`,
353
+ description,
354
+ 'x-element': rule.elements[0],
355
+ 'x-elements': [...rule.elements],
356
+ 'x-enforcement': rule.locked === undefined ? enforcement : 'locked',
357
+ 'x-doc': `docs/authoring/${rule.doc}`,
358
+ type: 'object',
359
+ additionalProperties: false,
360
+ properties,
361
+ };
362
+ }
363
+
364
+ /**
365
+ * The `rules` property for a course's `course.settings.schema.json`:
366
+ * self-contained (no `$ref`), properties ordered by primary element, then
367
+ * catalog order. A retired rule is not in the catalog at all, so it cannot be a
368
+ * form field; a block for one is a warning at resolve time.
369
+ */
370
+ export function rulesJsonSchema(): JsonSchema {
371
+ const properties: Record<string, JsonSchema> = {};
372
+ const byElement = new Map<Element, RuleEntry[]>();
373
+ for (const rule of catalogRules()) {
374
+ const primary = rule.elements[0];
375
+ if (!byElement.has(primary)) byElement.set(primary, []);
376
+ byElement.get(primary)!.push(rule);
377
+ }
378
+ for (const element of ELEMENTS) {
379
+ for (const rule of byElement.get(element) ?? []) properties[rule.id] = ruleSchema(rule);
380
+ }
381
+ return {
382
+ title: 'Rules',
383
+ description:
384
+ 'Authoring rules this course turns off or tunes, by rule ID. List only what differs from the defaults: every rule is on, with the defaults shown, until a block here says otherwise. A locked rule has no `enabled`. Invariants (always-on syntax checks) are not listed. See docs/authoring/README.md for the index.',
385
+ type: 'object',
386
+ additionalProperties: false,
387
+ properties,
388
+ };
389
+ }
390
+
391
+ /** Every rule and invariant ID, for tooling that wants the closed list. */
392
+ export function catalogIds(): readonly string[] {
393
+ return CATALOG.map((entry) => entry.id);
394
+ }
395
+
396
+ // --- applying the switches ---------------------------------------------------
397
+
398
+ /** What the manifest publishes about the course's rule settings. */
399
+ export interface ManifestRules {
400
+ /** Rules the course turned off — catalog order, then its own `X` rules. */
401
+ readonly disabled: readonly RuleId[];
402
+ /** Every rule's settings with defaults applied, by ID. `{}` for a rule without any. */
403
+ readonly settings: Readonly<Record<string, Readonly<Record<string, unknown>>>>;
404
+ /**
405
+ * The course's own rules, as written in `courseRules` and validated. Carried
406
+ * in the manifest so the rules-in-effect page and `content rules` list them
407
+ * beside the engine's without re-reading the settings file.
408
+ */
409
+ readonly course: readonly CourseRule[];
410
+ }
411
+
412
+ export function rulesSnapshot(rules: ResolvedRules): ManifestRules {
413
+ const settings: Record<string, Readonly<Record<string, unknown>>> = {};
414
+ for (const rule of catalogRules()) settings[rule.id] = rules.settings(rule.id);
415
+ return { disabled: rules.disabled, settings, course: rules.course };
416
+ }
417
+
418
+ /** The diagnostic codes owned by every rule the course turned off. */
419
+ export function disabledCodes(rules: ResolvedRules): ReadonlySet<string> {
420
+ const codes = new Set<string>();
421
+ for (const id of rules.disabled) {
422
+ // A course rule's one code is its ID; its gate is skipped at the call site
423
+ // as well, so this is belt and braces.
424
+ if (isCourseRuleId(id)) codes.add(id);
425
+ for (const check of ruleById(id)?.checks ?? []) codes.add(check.code);
426
+ }
427
+ return codes;
428
+ }
429
+
430
+ /**
431
+ * Drop every diagnostic a disabled rule owns. This is how a switch takes
432
+ * effect for a validator that hosts several owners (`consistency.ts`,
433
+ * `raw-floats-validate.ts`, the resolver behind `gate-math.ts`): the
434
+ * validator stays ignorant of the catalog and the manifest filters by code.
435
+ * A whole-rule gate (N9, N14, S1, S2) is additionally skipped at its call
436
+ * site so its work is not done at all. Curriculum issues carry `kind`, not
437
+ * `code`.
438
+ */
439
+ export function dropDisabled<T extends { readonly code?: string; readonly kind?: string }>(
440
+ diagnostics: readonly T[],
441
+ disabled: ReadonlySet<string>,
442
+ ): T[] {
443
+ if (disabled.size === 0) return [...diagnostics];
444
+ return diagnostics.filter((d) => {
445
+ const code = d.code ?? d.kind;
446
+ return code === undefined || !disabled.has(code);
447
+ });
448
+ }
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Per-place waivers — the in-source form of "record the reason in the diff".
3
+ *
4
+ * ```mdx
5
+ * {/* explico-allow N9: the unit is being defined here, not used *\/}
6
+ * The unit of acceleration is written m/s² …
7
+ * ```
8
+ *
9
+ * The comment sits on its own line and covers the next block: from the first
10
+ * non-blank line after it to the line before the next blank line (or the end
11
+ * of the file). Several rules: `explico-allow N9, N14: reason`. The reason is
12
+ * mandatory.
13
+ *
14
+ * A waiver may name any non-locked rule that is *waivable inline* — one with
15
+ * no check (a guide rule, where the waiver is documentation and never
16
+ * "unused"), or one whose every check reports a lesson-body `line`
17
+ * (`RuleCheck.located`). A rule whose checks are corpus-level (a notation
18
+ * entry, a per-lesson count) is waived per key in `lint-ignore.yml` instead;
19
+ * a per-file rule (S2) is turned off in `rules`. An invariant or a locked rule
20
+ * cannot be waived at all.
21
+ *
22
+ * One of the course's own rules (`X1`, from `courseRules`) is always waivable
23
+ * inline, whichever kind it is: an enforced one reports a line, a guide one has
24
+ * nothing to silence. Both functions take the course's rules so they can say
25
+ * "no rule `X9`" for an ID the settings never defined.
26
+ *
27
+ * `collectWaivers` parses a lesson body; `applyWaivers` drops the located
28
+ * diagnostics the waivers cover and reports a script-rule waiver that
29
+ * silenced nothing (`waiver-unused`), so stale waivers get cleaned up.
30
+ */
31
+ import { lessonLine } from '../reference/gate-prose-notation';
32
+ import {
33
+ entryById,
34
+ isCourseRuleId,
35
+ retiredRule,
36
+ ruleById,
37
+ ruleIdOf,
38
+ type RuleEntry,
39
+ type RuleId,
40
+ } from './catalog';
41
+ import { courseRuleEntry, type CourseRule } from './course-rules';
42
+
43
+ /**
44
+ * The course's own rules, when it has any — a waiver may name one (`X1`) just
45
+ * like an engine rule, so both the validation and the "silenced nothing" check
46
+ * need to see them.
47
+ */
48
+ export interface WaiverRuleSet {
49
+ readonly courseRules?: readonly CourseRule[];
50
+ }
51
+
52
+ /** Every rule a waiver may name, engine or course, as a catalog-shaped entry. */
53
+ function ruleEntry(id: string, set: WaiverRuleSet): RuleEntry | undefined {
54
+ const shipped = ruleById(id);
55
+ if (shipped !== undefined) return shipped;
56
+ const own = set.courseRules?.find((rule) => rule.id === id);
57
+ return own === undefined ? undefined : courseRuleEntry(own);
58
+ }
59
+
60
+ export interface Waiver {
61
+ readonly file: string;
62
+ /** Line of the comment itself. */
63
+ readonly line: number;
64
+ readonly ids: readonly RuleId[];
65
+ readonly reason: string;
66
+ /** Inclusive line range of the block the waiver covers, in file coordinates. */
67
+ readonly fromLine: number;
68
+ readonly toLine: number;
69
+ }
70
+
71
+ export type WaiverDiagnosticCode = 'waiver-malformed' | 'waiver-invalid' | 'waiver-unused';
72
+
73
+ export interface WaiverDiagnostic {
74
+ readonly code: WaiverDiagnosticCode;
75
+ readonly severity: 'error' | 'warning';
76
+ readonly message: string;
77
+ readonly file: string;
78
+ readonly line: number;
79
+ }
80
+
81
+ export interface WaiverLessonInput {
82
+ readonly body: string;
83
+ readonly bodyLine?: number;
84
+ readonly source: { readonly file: string };
85
+ }
86
+
87
+ /** True when `rule` may be waived with an inline comment. */
88
+ export function isWaivable(rule: RuleEntry): boolean {
89
+ if (rule.locked !== undefined) return false;
90
+ return rule.checks.length === 0 || rule.checks.every((check) => check.located);
91
+ }
92
+
93
+ const WAIVER_COMMENT = /\{\/\*\s*explico-allow\b([\s\S]*?)\*\/\}/g;
94
+ const RULE_ID = /^[A-Z]\d+$/;
95
+
96
+ /** Why a rule cannot be waived inline, or undefined when it can. */
97
+ function notWaivableBecause(id: string, set: WaiverRuleSet): string | undefined {
98
+ const rule = ruleEntry(id, set);
99
+ if (rule === undefined) {
100
+ if (entryById(id) !== undefined) {
101
+ return `\`${id}\` is an invariant — always on, never waived`;
102
+ }
103
+ const gone = retiredRule(id);
104
+ if (gone !== undefined) {
105
+ return (
106
+ `rule ${id} was retired on ${gone.date}` +
107
+ `${gone.into === undefined ? '' : ` into rule ${gone.into}, which is what to name instead`}`
108
+ );
109
+ }
110
+ return isCourseRuleId(id)
111
+ ? `no rule \`${id}\` — the \`X\` area is this course's own, and \`courseRules\` defines no ${id}`
112
+ : `no rule \`${id}\``;
113
+ }
114
+ if (rule.locked !== undefined) return `rule ${id} is locked — ${rule.locked}`;
115
+ if (!isWaivable(rule)) {
116
+ return (
117
+ `rule ${id} is not waivable inline — its check is corpus- or file-level; ` +
118
+ `silence it per key in \`lint-ignore.yml\`, or turn it off in \`rules.${id}\``
119
+ );
120
+ }
121
+ return undefined;
122
+ }
123
+
124
+ /** The inclusive line range of the block that starts after `commentEnd`. */
125
+ function blockAfter(body: string, commentEnd: number): { from: number; to: number } | undefined {
126
+ const lines = body.split('\n');
127
+ const commentLine = body.slice(0, commentEnd).split('\n').length; // 1-based
128
+ let from = commentLine + 1;
129
+ while (from <= lines.length && lines[from - 1]!.trim() === '') from += 1;
130
+ if (from > lines.length) return undefined;
131
+ let to = from;
132
+ while (to + 1 <= lines.length && lines[to]!.trim() !== '') to += 1;
133
+ return { from, to };
134
+ }
135
+
136
+ export function collectWaivers(
137
+ lesson: WaiverLessonInput,
138
+ set: WaiverRuleSet = {},
139
+ ): {
140
+ readonly waivers: Waiver[];
141
+ readonly diagnostics: WaiverDiagnostic[];
142
+ } {
143
+ const waivers: Waiver[] = [];
144
+ const diagnostics: WaiverDiagnostic[] = [];
145
+ const file = lesson.source.file;
146
+ const offset = (lesson.bodyLine ?? 1) - 1;
147
+
148
+ for (const match of lesson.body.matchAll(WAIVER_COMMENT)) {
149
+ const line = lessonLine(lesson, match.index);
150
+ const inner = (match[1] ?? '').trim();
151
+ const colon = inner.indexOf(':');
152
+ const idsText = colon === -1 ? inner : inner.slice(0, colon);
153
+ const reason = colon === -1 ? '' : inner.slice(colon + 1).trim();
154
+ const ids = idsText
155
+ .split(/[,\s]+/)
156
+ .map((id) => id.trim())
157
+ .filter(Boolean);
158
+
159
+ if (ids.length === 0 || !ids.every((id) => RULE_ID.test(id)) || reason === '') {
160
+ diagnostics.push({
161
+ code: 'waiver-malformed',
162
+ severity: 'error',
163
+ file,
164
+ line,
165
+ message:
166
+ `a waiver is \`{/* explico-allow <ID>[, <ID>]: <reason> */}\` — ` +
167
+ `rule IDs, a colon, and a non-empty reason; got ${JSON.stringify(inner)}.`,
168
+ });
169
+ continue;
170
+ }
171
+
172
+ const problems = ids
173
+ .map((id) => notWaivableBecause(id, set))
174
+ .filter((p): p is string => p !== undefined);
175
+ if (problems.length > 0) {
176
+ diagnostics.push({
177
+ code: 'waiver-invalid',
178
+ severity: 'error',
179
+ file,
180
+ line,
181
+ message: `waiver cannot apply — ${problems.join('; ')}.`,
182
+ });
183
+ continue;
184
+ }
185
+
186
+ const block = blockAfter(lesson.body, match.index + match[0].length);
187
+ waivers.push({
188
+ file,
189
+ line,
190
+ ids: ids as RuleId[],
191
+ reason,
192
+ fromLine: block === undefined ? line : block.from + offset,
193
+ toLine: block === undefined ? line : block.to + offset,
194
+ });
195
+ }
196
+
197
+ return { waivers, diagnostics };
198
+ }
199
+
200
+ /** Drop the located diagnostics the waivers cover; report waivers that covered nothing. */
201
+ export function applyWaivers<
202
+ T extends { readonly code?: string; readonly file?: string; readonly line?: number },
203
+ >(
204
+ diagnostics: readonly T[],
205
+ waivers: readonly Waiver[],
206
+ set: WaiverRuleSet = {},
207
+ ): { readonly kept: T[]; readonly unused: WaiverDiagnostic[] } {
208
+ if (waivers.length === 0) return { kept: [...diagnostics], unused: [] };
209
+ const used = new Map<Waiver, Set<RuleId>>(waivers.map((w) => [w, new Set()]));
210
+
211
+ const kept = diagnostics.filter((diagnostic) => {
212
+ if (diagnostic.code === undefined || diagnostic.file === undefined || diagnostic.line === undefined) {
213
+ return true;
214
+ }
215
+ const ownerId = ruleIdOf(diagnostic.code);
216
+ if (ownerId === undefined) return true;
217
+ for (const waiver of waivers) {
218
+ if (
219
+ waiver.file === diagnostic.file &&
220
+ diagnostic.line >= waiver.fromLine &&
221
+ diagnostic.line <= waiver.toLine &&
222
+ waiver.ids.includes(ownerId)
223
+ ) {
224
+ used.get(waiver)!.add(ownerId);
225
+ return false;
226
+ }
227
+ }
228
+ return true;
229
+ });
230
+
231
+ const unused: WaiverDiagnostic[] = [];
232
+ for (const waiver of waivers) {
233
+ // A guide rule's waiver is documentation; only a checked rule can be "unused".
234
+ const silent = waiver.ids.filter(
235
+ (id) => (ruleEntry(id, set)?.checks.length ?? 0) > 0 && !used.get(waiver)!.has(id),
236
+ );
237
+ if (silent.length === 0) continue;
238
+ unused.push({
239
+ code: 'waiver-unused',
240
+ severity: 'warning',
241
+ file: waiver.file,
242
+ line: waiver.line,
243
+ message:
244
+ `waiver for ${silent.join(', ')} silenced nothing on ` +
245
+ `lines ${waiver.fromLine}–${waiver.toLine}; remove it, or move it above the block it is for.`,
246
+ });
247
+ }
248
+ return { kept, unused };
249
+ }