explico 0.8.0 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1257 -1
- package/README.md +60 -15
- package/dist/compiler/astro.d.ts +6 -1
- package/dist/compiler/cli.d.ts +46 -2
- package/dist/compiler/cli.js +70 -4
- package/dist/compiler/collections.d.ts +35 -2
- package/dist/compiler/collections.js +68 -5
- package/dist/compiler/course-config.d.ts +3 -194
- package/dist/compiler/course-config.js +78 -5
- package/dist/compiler/exports/standalone-html.d.ts +6 -1
- package/dist/compiler/knowledge-version.js +1 -1
- package/dist/compiler/lesson-derivation.d.ts +27 -10
- package/dist/compiler/lesson-derivation.js +23 -8
- package/dist/compiler/logging.d.ts +4 -1
- package/dist/compiler/logging.js +4 -1
- package/dist/compiler/manifest.d.ts +204 -31
- package/dist/compiler/manifest.js +270 -55
- package/dist/compiler/resolution-report.d.ts +6 -1
- package/dist/course-config-Dsnri9Tk.d.ts +464 -0
- package/dist/reference/base-accents.d.mts +26 -0
- package/dist/reference/base-accents.mjs +31 -0
- package/dist/reference/base-roman-labels.d.mts +2 -2
- package/dist/reference/base-roman-labels.mjs +2 -2
- package/dist/reference/consistency.d.ts +14 -2
- package/dist/reference/consistency.js +108 -4
- package/dist/reference/delimiter-size.d.ts +3 -2
- package/dist/reference/diagrams-validate.d.ts +7 -2
- package/dist/reference/diagrams-validate.js +9 -1
- package/dist/reference/equations-validate.d.ts +8 -5
- package/dist/reference/equations-validate.js +1 -0
- package/dist/reference/equations.d.mts +60 -31
- package/dist/reference/equations.mjs +83 -43
- package/dist/reference/examples-validate.d.ts +31 -0
- package/dist/reference/examples-validate.js +48 -0
- package/dist/reference/figures-validate.d.ts +1 -1
- package/dist/reference/figures-validate.js +4 -1
- package/dist/reference/folds-validate.d.ts +33 -0
- package/dist/reference/folds-validate.js +32 -0
- package/dist/reference/gate-abbreviations.d.ts +3 -1
- package/dist/reference/gate-abbreviations.js +1 -0
- package/dist/reference/gate-course-rules.d.ts +32 -0
- package/dist/reference/gate-course-rules.js +85 -0
- package/dist/reference/gate-heading-length.d.ts +5 -2
- package/dist/reference/gate-heading-length.js +45 -21
- package/dist/reference/gate-heading-structure.d.ts +41 -0
- package/dist/reference/gate-heading-structure.js +113 -0
- package/dist/reference/gate-math-punctuation.d.ts +71 -0
- package/dist/reference/gate-math-punctuation.js +251 -0
- package/dist/reference/gate-math.d.ts +22 -6
- package/dist/reference/gate-math.js +42 -8
- package/dist/reference/gate-part-pages.d.ts +50 -0
- package/dist/reference/gate-part-pages.js +118 -0
- package/dist/reference/gate-prose-notation.d.ts +21 -11
- package/dist/reference/gate-prose-notation.js +25 -7
- package/dist/reference/katex-delimiters.mjs +20 -7
- package/dist/reference/keyed-float-validate.d.ts +27 -10
- package/dist/reference/keyed-float-validate.js +15 -1
- package/dist/reference/keyed-jsx-floats.d.mts +11 -5
- package/dist/reference/keyed-jsx-floats.mjs +73 -20
- package/dist/reference/knowledge-checks-validate.d.ts +1 -1
- package/dist/reference/knowledge-checks.d.mts +3 -1
- package/dist/reference/knowledge-checks.mjs +9 -4
- package/dist/reference/lab-math-scope.d.ts +1 -0
- package/dist/reference/lab-math-slot.d.ts +34 -0
- package/dist/reference/lab-math-slot.js +6 -0
- package/dist/reference/math-glyphs.d.mts +80 -4
- package/dist/reference/math-glyphs.mjs +572 -37
- package/dist/reference/page-notation.d.ts +6 -1
- package/dist/reference/raw-floats-validate.js +1 -1
- package/dist/reference/raw-floats.mjs +1 -1
- package/dist/reference/rehype-heading-anchors.d.mts +14 -0
- package/dist/reference/rehype-heading-anchors.mjs +116 -17
- package/dist/reference/rehype-table-header-labels.d.mts +6 -1
- package/dist/reference/rehype-table-header-labels.mjs +21 -3
- package/dist/reference/remark-asides.d.mts +3 -0
- package/dist/reference/remark-asides.mjs +180 -0
- package/dist/reference/remark-memo.d.mts +3 -1
- package/dist/reference/remark-memo.mjs +42 -11
- package/dist/reference/remark-notation.d.mts +60 -10
- package/dist/reference/remark-notation.mjs +387 -36
- package/dist/reference/render-lab-math.d.ts +4 -26
- package/dist/reference/render-lab-math.js +4 -4
- package/dist/reference/sections-validate.d.ts +27 -0
- package/dist/reference/sections-validate.js +41 -0
- package/dist/reference/sections.d.mts +97 -0
- package/dist/reference/sections.mjs +306 -0
- package/dist/reference/tables-validate.d.ts +1 -1
- package/dist/reference/types.d.ts +17 -1
- package/dist/reference/unit-words.d.ts +37 -0
- package/dist/reference/unit-words.js +170 -0
- package/dist/rules/catalog.d.ts +176 -0
- package/dist/rules/catalog.js +983 -0
- package/dist/rules/course-rules.d.ts +115 -0
- package/dist/rules/course-rules.js +292 -0
- package/dist/rules/course-rules.schema.json +138 -0
- package/dist/rules/in-effect.d.ts +53 -0
- package/dist/rules/in-effect.js +142 -0
- package/dist/rules/rules.schema.json +1072 -0
- package/dist/rules/settings.d.ts +3 -0
- package/dist/rules/settings.js +290 -0
- package/dist/rules/waivers.d.ts +53 -0
- package/dist/rules/waivers.js +132 -0
- package/dist/session/PreferenceStore.d.ts +5 -0
- package/dist/session/PreferenceStore.js +9 -0
- package/docs/VERSIONING.md +5 -3
- package/docs/authoring/README.md +232 -40
- package/docs/authoring/agents.md +55 -9
- package/docs/authoring/components.md +178 -22
- package/docs/authoring/migrating.md +1152 -2
- package/docs/authoring/notation.md +576 -322
- package/docs/authoring/prose.md +551 -0
- package/docs/authoring/retired.md +35 -0
- package/docs/authoring/structure-rules.md +422 -40
- package/docs/authoring/structure.md +445 -62
- package/docs/authoring/versioning.md +20 -28
- package/docs/authoring/visualizations.md +142 -20
- package/package.json +2 -2
- package/src/compiler/cli.ts +116 -6
- package/src/compiler/collections.ts +111 -7
- package/src/compiler/course-config.ts +273 -34
- package/src/compiler/knowledge-version.ts +1 -1
- package/src/compiler/lesson-derivation.ts +54 -15
- package/src/compiler/logging.ts +8 -2
- package/src/compiler/manifest.ts +573 -104
- package/src/components/BlockHeading.astro +113 -0
- package/src/components/CourseContents.astro +221 -0
- package/src/components/CurriculumMap.astro +5 -6
- package/src/components/FloatIndex.astro +200 -0
- package/src/components/HoverPanel.astro +431 -79
- package/src/components/PartBridge.astro +27 -0
- package/src/components/PartOverview.astro +401 -0
- package/src/components/PartScope.astro +27 -0
- package/src/components/abbreviation/AbbreviationLayer.astro +49 -51
- package/src/components/assessments/AssessmentRunner.tsx +8 -5
- package/src/components/assessments/AssessmentSet.astro +23 -26
- package/src/components/block-heading.ts +146 -0
- package/src/components/card-arrow.ts +243 -0
- package/src/components/card-drag.ts +631 -0
- package/src/components/card-jump.ts +45 -0
- package/src/components/card-manager.ts +1589 -0
- package/src/components/card-memory.ts +177 -0
- package/src/components/card-placement.ts +504 -0
- package/src/components/card-side.ts +336 -0
- package/src/components/card-travel.ts +197 -0
- package/src/components/citation/CitationLayer.astro +77 -76
- package/src/components/diagram/Diagram.astro +161 -316
- package/src/components/disclosure/course-disclosure.ts +24 -0
- package/src/components/disclosure/hover-preview.ts +122 -42
- package/src/components/disclosure/persisted-disclosure.ts +3 -3
- package/src/components/disclosure/print-as-shown.ts +252 -0
- package/src/components/examples/CompactExample.astro +3 -1
- package/src/components/examples/CompactExamples.astro +101 -94
- package/src/components/figure/Figure.astro +174 -274
- package/src/components/figure/FloatCaption.astro +87 -0
- package/src/components/figure/figure-state.ts +58 -0
- package/src/components/figure/figure-tools.ts +87 -0
- package/src/components/float-name.ts +101 -0
- package/src/components/fold/Fold.astro +125 -0
- package/src/components/fold/fold-id.ts +40 -0
- package/src/components/memo/Memo.astro +162 -231
- package/src/components/memo/ShortNote.astro +6 -6
- package/src/components/notation/NotationFormula.astro +5 -1
- package/src/components/notation/NotationGlossary.astro +3 -4
- package/src/components/notation/NotationLayer.astro +169 -162
- package/src/components/notation/NotationMath.astro +10 -1
- package/src/components/reference/FloatRefLayer.astro +485 -423
- package/src/components/starlight/AboutPanel.astro +5 -4
- package/src/components/starlight/CollapsibleSections.astro +207 -93
- package/src/components/starlight/DiagramEnhancer.astro +15 -138
- package/src/components/starlight/HeadingAnchorEnhancer.astro +193 -1
- package/src/components/starlight/KeyedFloatEnhancer.astro +100 -9
- package/src/components/starlight/LayoutHeader.astro +298 -19
- package/src/components/starlight/LayoutPageSidebar.astro +99 -12
- package/src/components/starlight/LayoutPageTitle.astro +120 -0
- package/src/components/starlight/LayoutSidebar.astro +229 -11
- package/src/components/starlight/LessonExportActions.astro +53 -72
- package/src/components/starlight/LessonFooter.astro +26 -4
- package/src/components/starlight/section-parts.ts +15 -2
- package/src/components/starlight/section-summary.ts +181 -0
- package/src/components/table/Table.astro +95 -76
- package/src/components/table/TableOverflowExpander.astro +65 -28
- package/src/content-config.ts +40 -4
- package/src/reference/base-accents.mjs +31 -0
- package/src/reference/base-roman-labels.mjs +2 -2
- package/src/reference/consistency.ts +176 -5
- package/src/reference/delimiter-size.ts +3 -2
- package/src/reference/diagrams-validate.ts +17 -1
- package/src/reference/equations-validate.ts +10 -5
- package/src/reference/equations.mjs +83 -43
- package/src/reference/examples-validate.ts +97 -0
- package/src/reference/figures-validate.ts +6 -1
- package/src/reference/folds-validate.ts +65 -0
- package/src/reference/gate-abbreviations.ts +1 -0
- package/src/reference/gate-course-rules.ts +134 -0
- package/src/reference/gate-heading-length.ts +66 -35
- package/src/reference/gate-heading-structure.ts +193 -0
- package/src/reference/gate-math-punctuation.ts +381 -0
- package/src/reference/gate-math.ts +69 -10
- package/src/reference/gate-part-pages.ts +217 -0
- package/src/reference/gate-prose-notation.ts +59 -12
- package/src/reference/katex-delimiters.mjs +20 -7
- package/src/reference/keyed-float-validate.ts +45 -11
- package/src/reference/keyed-jsx-floats.mjs +73 -20
- package/src/reference/knowledge-checks-validate.ts +1 -1
- package/src/reference/knowledge-checks.mjs +9 -4
- package/src/reference/lab-math-slot.ts +36 -0
- package/src/reference/math-glyphs.mjs +572 -37
- package/src/reference/raw-floats-validate.ts +1 -1
- package/src/reference/raw-floats.mjs +1 -1
- package/src/reference/rehype-heading-anchors.mjs +116 -17
- package/src/reference/rehype-table-header-labels.mjs +21 -3
- package/src/reference/remark-asides.mjs +180 -0
- package/src/reference/remark-memo.mjs +42 -11
- package/src/reference/remark-notation.mjs +387 -36
- package/src/reference/render-lab-math.ts +16 -31
- package/src/reference/sections-validate.ts +81 -0
- package/src/reference/sections.mjs +306 -0
- package/src/reference/tables-validate.ts +2 -1
- package/src/reference/types.ts +17 -0
- package/src/reference/unit-words.ts +233 -0
- package/src/rules/catalog.ts +1298 -0
- package/src/rules/course-rules.ts +456 -0
- package/src/rules/in-effect.ts +235 -0
- package/src/rules/settings.ts +448 -0
- package/src/rules/waivers.ts +249 -0
- package/src/session/PreferenceStore.ts +15 -0
- package/src/styles/global.css +1467 -163
- package/src/styles/surfaces.css +182 -0
- package/src/components/hover-panel.ts +0 -339
- 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
|
+
}
|