explico 0.6.0 → 0.7.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 (73) hide show
  1. package/CHANGELOG.md +180 -1
  2. package/README.md +16 -2
  3. package/dist/compiler/astro.d.ts +13 -2
  4. package/dist/compiler/astro.js +39 -0
  5. package/dist/compiler/cli.d.ts +22 -3
  6. package/dist/compiler/cli.js +71 -10
  7. package/dist/compiler/exports/standalone-html.js +13 -0
  8. package/dist/compiler/logging.d.ts +109 -0
  9. package/dist/compiler/logging.js +107 -0
  10. package/dist/compiler/manifest.d.ts +7 -3
  11. package/dist/compiler/manifest.js +1 -1
  12. package/dist/reference/base-abbreviations.mjs +8 -2
  13. package/dist/reference/consistency.d.ts +8 -10
  14. package/dist/reference/consistency.js +3 -47
  15. package/dist/reference/delimiter-size.d.ts +39 -0
  16. package/dist/reference/delimiter-size.js +177 -0
  17. package/dist/reference/diagrams-validate.d.ts +1 -1
  18. package/dist/reference/equations-validate.d.ts +2 -2
  19. package/dist/reference/equations-validate.js +1 -1
  20. package/dist/reference/figures-validate.d.ts +1 -1
  21. package/dist/reference/gate-math.d.ts +2 -0
  22. package/dist/reference/gate-math.js +28 -6
  23. package/dist/reference/keyed-float-validate.d.ts +2 -2
  24. package/dist/reference/keyed-float-validate.js +1 -1
  25. package/dist/reference/references.js +2 -1
  26. package/dist/reference/registry.js +1 -1
  27. package/dist/reference/rehype-base-links.d.mts +23 -0
  28. package/dist/reference/rehype-base-links.mjs +40 -0
  29. package/dist/reference/remark-abbreviations.mjs +10 -12
  30. package/dist/reference/remark-citation.mjs +0 -0
  31. package/dist/reference/render-lab-math.d.ts +18 -1
  32. package/dist/reference/render-lab-math.js +10 -3
  33. package/dist/reference/tables-validate.d.ts +1 -1
  34. package/dist/reference/types.d.ts +1 -1
  35. package/docs/VERSIONING.md +2 -2
  36. package/docs/authoring/README.md +12 -2
  37. package/docs/authoring/agents.md +7 -3
  38. package/docs/authoring/migrating.md +286 -0
  39. package/docs/authoring/notation.md +139 -7
  40. package/docs/authoring/prose.md +64 -0
  41. package/package.json +1 -1
  42. package/src/compiler/astro.ts +89 -0
  43. package/src/compiler/cli.ts +144 -12
  44. package/src/compiler/exports/standalone-html.ts +15 -0
  45. package/src/compiler/logging.ts +248 -0
  46. package/src/compiler/manifest.ts +7 -3
  47. package/src/components/memo/Memo.astro +2 -5
  48. package/src/components/memo/ShortNote.astro +2 -5
  49. package/src/components/notation/NotationFormula.astro +199 -10
  50. package/src/components/notation/NotationGlossary.astro +89 -58
  51. package/src/components/notation/NotationLayer.astro +83 -81
  52. package/src/components/scroll-animation.ts +160 -0
  53. package/src/components/starlight/AnchorScrollEnhancer.astro +102 -0
  54. package/src/components/starlight/HeadingAnchorEnhancer.astro +2 -4
  55. package/src/components/starlight/KeyedFloatEnhancer.astro +17 -16
  56. package/src/components/starlight/LessonFooter.astro +2 -0
  57. package/src/reference/base-abbreviations.mjs +8 -2
  58. package/src/reference/consistency.ts +10 -66
  59. package/src/reference/delimiter-size.ts +301 -0
  60. package/src/reference/diagrams-validate.ts +1 -1
  61. package/src/reference/equations-validate.ts +3 -3
  62. package/src/reference/figures-validate.ts +1 -1
  63. package/src/reference/gate-math.ts +41 -6
  64. package/src/reference/keyed-float-validate.ts +3 -3
  65. package/src/reference/references.ts +2 -1
  66. package/src/reference/registry.ts +1 -1
  67. package/src/reference/rehype-base-links.mjs +40 -0
  68. package/src/reference/remark-abbreviations.mjs +10 -12
  69. package/src/reference/remark-citation.mjs +0 -0
  70. package/src/reference/render-lab-math.ts +24 -1
  71. package/src/reference/tables-validate.ts +1 -1
  72. package/src/reference/types.ts +1 -1
  73. package/src/styles/global.css +119 -14
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Run logs for `pnpm validate:content`, in Python's `logging` format.
3
+ *
4
+ * The terminal keeps the compact `[code] message` lines it has always shown.
5
+ * This module is the second handler over the same records: it writes them to
6
+ * `build/logs/<timestamp>/` in the format Python's `logging` emits, so a run
7
+ * can be grepped by level and by subsystem long after the terminal scrolled
8
+ * away.
9
+ *
10
+ * %(asctime)s %(levelname)s %(name)s: %(message)s
11
+ *
12
+ * 2026-09-18 03:22:47,482 WARNING explico.notation: [undefined-reference] …
13
+ *
14
+ * Three files per run, each a superset of the one above it, so you open the
15
+ * narrowest one that still answers the question:
16
+ *
17
+ * build/logs/2026-09-18T03-22-47/
18
+ * error.log errors only
19
+ * warning.log errors + warnings
20
+ * info.log errors + warnings + info
21
+ *
22
+ * Nothing is pruned — `build/` is git-ignored, so old runs are yours to clean
23
+ * up — and `build/logs/latest` points at the newest run.
24
+ *
25
+ * Levels carry their Python meaning. `error` is a blocking diagnostic (the
26
+ * caller still throws; this module only records), `warning` is a non-blocking
27
+ * finding, and `info` is the run summary plus the severity-`info` diagnostics
28
+ * the engine has always produced but never printed.
29
+ *
30
+ * Each level method **returns its message**, so a call site can log and print
31
+ * in one expression without the two drifting apart:
32
+ *
33
+ * console.warn(log.warning('notation', `[${d.code}] ${d.message}`));
34
+ */
35
+ /** Sits beside `build/manifest.json` and `build/resolution/` (Phase F). */
36
+ declare const LOGS_DIR: string;
37
+ /** Python's levels, narrowed to the three the engine actually emits. */
38
+ type LogLevel = 'ERROR' | 'WARNING' | 'INFO';
39
+ /**
40
+ * Python's `%(asctime)s` — local time, with the comma before the milliseconds
41
+ * that `logging.Formatter` writes by default.
42
+ */
43
+ declare function formatTimestamp(date: Date): string;
44
+ /** A filesystem-safe local-time stamp for the run directory's name. */
45
+ declare function formatRunStamp(date: Date): string;
46
+ /**
47
+ * `explico.<source>` — the logger name is the diagnostic's subsystem, so
48
+ * filtering by name is filtering by subsystem. `rawFloats` becomes
49
+ * `explico.raw-floats`; anything already lowercase passes through.
50
+ */
51
+ declare function loggerName(source: string): string;
52
+ /** One formatted line, without its trailing newline. */
53
+ declare function formatRecord(level: LogLevel, source: string, message: string, date: Date): string;
54
+ interface RunLogSummary {
55
+ /** Absolute path of this run's directory. */
56
+ readonly directory: string;
57
+ /** Repo-relative form, for printing. */
58
+ readonly relativeDirectory: string;
59
+ readonly counts: Readonly<Record<LogLevel, number>>;
60
+ }
61
+ /** One diagnostic, across every category's slightly different shape. */
62
+ interface AnyDiagnostic {
63
+ readonly severity?: string;
64
+ readonly message: string;
65
+ readonly code?: string;
66
+ readonly kind?: string;
67
+ readonly file?: string;
68
+ readonly line?: number;
69
+ readonly column?: number;
70
+ }
71
+ /** `manifest.diagnostics`, structurally — one array per subsystem. */
72
+ type DiagnosticGroups = Readonly<Record<string, readonly AnyDiagnostic[]>>;
73
+ interface RunLog {
74
+ /** Absolute path of this run's directory. */
75
+ readonly directory: string;
76
+ /** Records at `ERROR` and returns `message`. */
77
+ error(source: string, message: string): string;
78
+ /** Records at `WARNING` and returns `message`. */
79
+ warning(source: string, message: string): string;
80
+ /** Records at `INFO` and returns `message`. */
81
+ info(source: string, message: string): string;
82
+ /**
83
+ * Sweeps every severity-`info` diagnostic in a manifest's `diagnostics` into
84
+ * `info.log`. Errors and warnings are deliberately **not** taken from here:
85
+ * they are recorded at the call site that also prints the console line, so
86
+ * the two can never drift.
87
+ */
88
+ infoDiagnostics(diagnostics: DiagnosticGroups): number;
89
+ /** Refreshes `latest` and reports what the run recorded. */
90
+ close(): RunLogSummary;
91
+ }
92
+ /** `[code] message`, matching what the console has always printed. */
93
+ declare function diagnosticLine(diagnostic: AnyDiagnostic): string;
94
+ interface RunLogOptions {
95
+ /** Overridden in tests; defaults to `build/logs` under the repository root. */
96
+ readonly root?: string;
97
+ /** Overridden in tests so the directory name is deterministic. */
98
+ readonly now?: Date;
99
+ /** Set `false` to skip the `latest` symlink. */
100
+ readonly latest?: boolean;
101
+ }
102
+ /**
103
+ * Opens a run directory and returns its log. The directory and its three files
104
+ * are created up front, so a run that crashes before {@link RunLog.close} still
105
+ * leaves everything recorded up to that point on disk.
106
+ */
107
+ declare function createRunLog(options?: RunLogOptions): RunLog;
108
+
109
+ export { type AnyDiagnostic, type DiagnosticGroups, LOGS_DIR, type LogLevel, type RunLog, type RunLogOptions, type RunLogSummary, createRunLog, diagnosticLine, formatRecord, formatRunStamp, formatTimestamp, loggerName };
@@ -0,0 +1,107 @@
1
+ import {
2
+ appendFileSync,
3
+ existsSync,
4
+ mkdirSync,
5
+ rmSync,
6
+ symlinkSync,
7
+ writeFileSync
8
+ } from "node:fs";
9
+ import { join, relative } from "node:path";
10
+ const REPOSITORY_ROOT = process.cwd();
11
+ const LOGS_DIR = join(REPOSITORY_ROOT, "build", "logs");
12
+ const LEVELS = ["ERROR", "WARNING", "INFO"];
13
+ const FILES = [
14
+ { name: "error.log", level: "ERROR" },
15
+ { name: "warning.log", level: "WARNING" },
16
+ { name: "info.log", level: "INFO" }
17
+ ];
18
+ function includes(file, record) {
19
+ return LEVELS.indexOf(record) <= LEVELS.indexOf(file);
20
+ }
21
+ function pad(value, width = 2) {
22
+ return String(value).padStart(width, "0");
23
+ }
24
+ function formatTimestamp(date) {
25
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())},${pad(date.getMilliseconds(), 3)}`;
26
+ }
27
+ function formatRunStamp(date) {
28
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}-${pad(date.getMinutes())}-${pad(date.getSeconds())}`;
29
+ }
30
+ function loggerName(source) {
31
+ const normalized = source.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
32
+ return normalized.startsWith("explico.") ? normalized : `explico.${normalized}`;
33
+ }
34
+ function formatRecord(level, source, message, date) {
35
+ return `${formatTimestamp(date)} ${level} ${loggerName(source)}: ${message}`;
36
+ }
37
+ function diagnosticLine(diagnostic) {
38
+ const code = diagnostic.code ?? diagnostic.kind;
39
+ const at = [diagnostic.file, diagnostic.line, diagnostic.column].filter((part) => part !== void 0).join(":");
40
+ const prefix = code === void 0 ? "" : `[${code}] `;
41
+ return at === "" ? `${prefix}${diagnostic.message}` : `${prefix}${at}: ${diagnostic.message}`;
42
+ }
43
+ function createRunLog(options = {}) {
44
+ const root = options.root ?? LOGS_DIR;
45
+ const started = options.now ?? /* @__PURE__ */ new Date();
46
+ const stamp = formatRunStamp(started);
47
+ let directory = join(root, stamp);
48
+ for (let suffix = 2; existsSync(directory); suffix += 1) {
49
+ directory = join(root, `${stamp}-${suffix}`);
50
+ }
51
+ mkdirSync(directory, { recursive: true });
52
+ for (const file of FILES) writeFileSync(join(directory, file.name), "");
53
+ const counts = { ERROR: 0, WARNING: 0, INFO: 0 };
54
+ const record = (level, source, message) => {
55
+ counts[level] += 1;
56
+ const line = `${formatRecord(level, source, message, /* @__PURE__ */ new Date())}
57
+ `;
58
+ for (const file of FILES) {
59
+ if (includes(file.level, level)) {
60
+ appendFileSync(join(directory, file.name), line);
61
+ }
62
+ }
63
+ return message;
64
+ };
65
+ return {
66
+ directory,
67
+ error: (source, message) => record("ERROR", source, message),
68
+ warning: (source, message) => record("WARNING", source, message),
69
+ info: (source, message) => record("INFO", source, message),
70
+ infoDiagnostics(diagnostics) {
71
+ let written = 0;
72
+ for (const [source, group] of Object.entries(diagnostics)) {
73
+ if (!Array.isArray(group)) continue;
74
+ for (const diagnostic of group) {
75
+ if (diagnostic.severity !== "info") continue;
76
+ record("INFO", source, diagnosticLine(diagnostic));
77
+ written += 1;
78
+ }
79
+ }
80
+ return written;
81
+ },
82
+ close() {
83
+ if (options.latest !== false) {
84
+ const link = join(root, "latest");
85
+ try {
86
+ rmSync(link, { force: true });
87
+ symlinkSync(directory, link, "dir");
88
+ } catch {
89
+ }
90
+ }
91
+ return {
92
+ directory,
93
+ relativeDirectory: relative(REPOSITORY_ROOT, directory),
94
+ counts: { ...counts }
95
+ };
96
+ }
97
+ };
98
+ }
99
+ export {
100
+ LOGS_DIR,
101
+ createRunLog,
102
+ diagnosticLine,
103
+ formatRecord,
104
+ formatRunStamp,
105
+ formatTimestamp,
106
+ loggerName
107
+ };
@@ -96,8 +96,12 @@ import '../reference/keyed-float-validate.js';
96
96
  * engine-authored sections, not written by a lesson, so they get their own
97
97
  * default independent of a course's `##` heading default; closed unless the
98
98
  * course sets one.
99
+ * v15 (diagnostic severity): public diagnostic severity unions add `info` for
100
+ * optional advice. Unused keys/definitions and restructuring candidates move
101
+ * from `warning` to `info`; their diagnostic codes and container fields stay
102
+ * unchanged.
99
103
  */
100
- declare const MANIFEST_SCHEMA_VERSION = 14;
104
+ declare const MANIFEST_SCHEMA_VERSION = 15;
101
105
  declare const MANIFEST_PATH: string;
102
106
  interface ManifestCitation {
103
107
  readonly id: string;
@@ -324,11 +328,11 @@ interface Manifest {
324
328
  readonly notation: readonly NotationDiagnostic[];
325
329
  readonly alignment: readonly NotationDiagnostic[];
326
330
  readonly math: readonly MathGateDiagnostic[];
327
- /** Tier-3 corpus consistency checks (D6). Warnings only. */
331
+ /** Tier-3 corpus consistency checks (D6). Warnings and optional info. */
328
332
  readonly consistency: readonly ConsistencyDiagnostic[];
329
333
  /** Knowledge-version checks (C3): `about.version` vs `content/CHANGELOG.md`. Warnings only. */
330
334
  readonly knowledge: readonly KnowledgeVersionDiagnostic[];
331
- /** Equation-identity checks (D7): `eq-duplicate-key`, `eq-ref-resolves` (error); `eq-key-unused` (warning). */
335
+ /** Equation identity: broken identities are errors; unused keys are info. */
332
336
  readonly equations: readonly EquationDiagnostic[];
333
337
  /** `<Table>` identity checks (F-tables-figures) — same codes, `tab-` prefixed. */
334
338
  readonly tables: readonly TableDiagnostic[];
@@ -75,7 +75,7 @@ import {
75
75
  isLessonSlug,
76
76
  lessonIdFromSlug
77
77
  } from "./lesson-derivation.js";
78
- const MANIFEST_SCHEMA_VERSION = 14;
78
+ const MANIFEST_SCHEMA_VERSION = 15;
79
79
  const REPOSITORY_ROOT = process.cwd();
80
80
  const CONTENT_ROOT = join(REPOSITORY_ROOT, "content");
81
81
  const MANIFEST_PATH = join(REPOSITORY_ROOT, "build", "manifest.json");
@@ -20,12 +20,18 @@ export const BASE_ABBREVIATIONS = Object.freeze(
20
20
  'e.g.',
21
21
  'i.e.',
22
22
  'etc.',
23
+ 'e.t.c.',
23
24
  'cf.',
24
25
  'vs.',
25
- 'a.m.',
26
- 'p.m.',
27
26
  'a.k.a.',
28
27
  'et al.',
28
+ // time of day, in each casing/punctuation style a lesson might use
29
+ 'a.m.',
30
+ 'p.m.',
31
+ 'A.M.',
32
+ 'P.M.',
33
+ 'AM',
34
+ 'PM',
29
35
  // generic acronyms with no domain-specific meaning in this engine
30
36
  'AI',
31
37
  'ID',
@@ -8,11 +8,11 @@ import { NotationRegistry, NotationRegistryInput } from './types.js';
8
8
  * Corpus consistency checks (Step D6) — the Tier 1–2 checks from the cleanup
9
9
  * plan's "Enforced checks" section that no earlier step covers.
10
10
  *
11
- * Every check here enters as a **Tier-3 warning**: `pnpm validate:content`
12
- * surfaces the counts, `manifest.diagnostics.consistency` records the detail,
13
- * and `resolution/consistency-report.json` is the committed, diff-checkable
14
- * snapshot. Nothing here changes content — each finding is an open item for a
15
- * separate human-quantitative-reviewed pass. A finding is silenced only by a
11
+ * Every check here is non-blocking Tier 3: repairable violations are warnings
12
+ * and optional restructuring advice is info. `pnpm validate:content` surfaces
13
+ * warning counts, `manifest.diagnostics.consistency` records every detail, and
14
+ * `resolution/consistency-report.json` is the committed, diff-checkable
15
+ * snapshot. Nothing here changes content. A finding is silenced only by a
16
16
  * reviewed line in `lint-ignore.yml` (see `./lint-ignore`).
17
17
  *
18
18
  * (a) glyph-unique-in-corpus one rendered glyph → one meaning corpus-wide
@@ -20,8 +20,6 @@ import { NotationRegistry, NotationRegistryInput } from './types.js';
20
20
  * unit strings drawn from a controlled vocab
21
21
  * (c) convention-single-definition a sign / cash-flow-perspective convention a
22
22
  * lesson uses resolves to one declaration
23
- * (e) numerals-tagged numerals in `$…$` / result tables are tagged
24
- * to a `src/domain/` call or a source locator
25
23
  * (f) weak-local a `notation.local` symbol used only in its
26
24
  * own defining equation and prose
27
25
  * (g) notation-source-locator every notation `sources` entry has a locator
@@ -37,11 +35,11 @@ import { NotationRegistry, NotationRegistryInput } from './types.js';
37
35
  * `src/content/resolution-report.ts`.
38
36
  */
39
37
 
40
- type ConsistencyCode = 'glyph-unique-in-corpus' | 'notation-units' | 'units-vocab' | 'convention-single-definition' | 'numerals-tagged' | 'weak-local' | 'notation-source-locator' | 'gloss-wants-promoting' | 'card-wants-demoting' | 'gloss-name-shape' | 'operator-explained' | 'roman-label-explained';
38
+ type ConsistencyCode = 'glyph-unique-in-corpus' | 'notation-units' | 'units-vocab' | 'convention-single-definition' | 'weak-local' | 'notation-source-locator' | 'gloss-wants-promoting' | 'card-wants-demoting' | 'gloss-name-shape' | 'operator-explained' | 'roman-label-explained';
41
39
  interface ConsistencyDiagnostic {
42
40
  readonly code: ConsistencyCode;
43
- /** Tier 3 — every finding here is a warning until content is brought clean. */
44
- readonly severity: 'warning';
41
+ /** Tier 3 — repairable violations are warnings; optional advice is info. */
42
+ readonly severity: 'warning' | 'info';
45
43
  readonly message: string;
46
44
  readonly file?: string;
47
45
  readonly lessonId?: string;
@@ -17,23 +17,6 @@ function mathSpans(body) {
17
17
  }
18
18
  return spans;
19
19
  }
20
- function tableRows(body) {
21
- const rows = [];
22
- for (const line of stripCode(body).split("\n")) {
23
- const trimmed = line.trim();
24
- if (!trimmed.startsWith("|") || !trimmed.slice(1).includes("|")) continue;
25
- if (/^\s*\|[\s:|-]+\|\s*$/.test(line)) continue;
26
- rows.push(line);
27
- }
28
- return rows;
29
- }
30
- const NUMERAL_RE = /(?<![\w.])-?\d+(?:\.\d+)?/g;
31
- function numeralCount(text) {
32
- return (text.replace(/\\[a-zA-Z]+/g, " ").match(NUMERAL_RE) ?? []).length;
33
- }
34
- function isTagged(text) {
35
- return /\[@[a-z0-9]/i.test(text) || /<!--\s*domain:/i.test(text);
36
- }
37
20
  function glyphMatcher(latex) {
38
21
  const trimmed = latex.trim();
39
22
  if (trimmed === "") return void 0;
@@ -141,31 +124,6 @@ function checkConventionSingleDefinition(input, out) {
141
124
  });
142
125
  }
143
126
  }
144
- function checkNumeralsTagged(input, out) {
145
- for (const lesson of input.notationInput.lessons) {
146
- if (input.lintIgnore.matches("numerals-tagged", lesson.lessonId)) continue;
147
- let math = 0;
148
- for (const span of mathSpans(lesson.body)) {
149
- if (isTagged(span.latex)) continue;
150
- math += numeralCount(span.latex);
151
- }
152
- let table = 0;
153
- for (const row of tableRows(lesson.body)) {
154
- if (isTagged(row)) continue;
155
- table += numeralCount(row.replace(/\$[^$\n]*\$/g, " "));
156
- }
157
- const count = math + table;
158
- if (count === 0) continue;
159
- out.push({
160
- code: "numerals-tagged",
161
- severity: "warning",
162
- lessonId: lesson.lessonId,
163
- file: lesson.source.file,
164
- count,
165
- message: `${lesson.lessonId}: ${count} numeral(s) in $\u2026$ / result tables (${math} in math, ${table} in tables) are not tagged to a src/domain/ call or a source locator`
166
- });
167
- }
168
- }
169
127
  function checkWeakLocal(input, out) {
170
128
  for (const lesson of input.notationInput.lessons) {
171
129
  const spans = mathSpans(lesson.body).map((span) => span.latex);
@@ -177,7 +135,7 @@ function checkWeakLocal(input, out) {
177
135
  if (uses > 1) continue;
178
136
  out.push({
179
137
  code: "weak-local",
180
- severity: "warning",
138
+ severity: "info",
181
139
  lessonId: lesson.lessonId,
182
140
  file: lesson.source.file,
183
141
  key: definition.key,
@@ -228,7 +186,7 @@ function checkGlossWantsPromoting(input, out) {
228
186
  if (input.lintIgnore.matches("gloss-wants-promoting", glyph)) continue;
229
187
  out.push({
230
188
  code: "gloss-wants-promoting",
231
- severity: "warning",
189
+ severity: "info",
232
190
  glyph,
233
191
  message: `gloss ${JSON.stringify(glyph)} (${label}) is declared on ${keys.size} entries (${[...keys].sort().join(", ")}); a meaning reused this widely wants promoting to a card`
234
192
  });
@@ -250,7 +208,7 @@ function checkCardWantsDemoting(input, out) {
250
208
  }
251
209
  out.push({
252
210
  code: "card-wants-demoting",
253
- severity: "warning",
211
+ severity: "info",
254
212
  key: definition.key,
255
213
  file: definition.source.file,
256
214
  count: words,
@@ -381,7 +339,6 @@ const CHECKS = [
381
339
  checkGlyphUniqueness,
382
340
  checkNotationUnits,
383
341
  checkConventionSingleDefinition,
384
- checkNumeralsTagged,
385
342
  checkWeakLocal,
386
343
  checkNotationSourceLocator,
387
344
  checkGlossWantsPromoting,
@@ -403,7 +360,6 @@ function consistencyCounts(diagnostics) {
403
360
  "notation-units": 0,
404
361
  "units-vocab": 0,
405
362
  "convention-single-definition": 0,
406
- "numerals-tagged": 0,
407
363
  "weak-local": 0,
408
364
  "notation-source-locator": 0,
409
365
  "gloss-wants-promoting": 0,
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Rule N17: nested scalable delimiters must make their hierarchy visible.
3
+ *
4
+ * This deliberately parses only sizing syntax. It does not decide whether a
5
+ * parenthesis, bracket, or brace is semantic, and it never recommends changing
6
+ * one delimiter shape into another. N16's alternating-shape preference remains
7
+ * a review-only authoring concern.
8
+ */
9
+ declare const DELIMITER_SIZE_DIAGNOSTIC_CODE: "delimiter-size-hierarchy";
10
+ type DelimiterSizeName = 'automatic' | 'big' | 'Big' | 'bigg' | 'Bigg';
11
+ interface DelimiterSizeSuggestion {
12
+ readonly size: Exclude<DelimiterSizeName, 'automatic' | 'big'>;
13
+ readonly open: string;
14
+ readonly close: string;
15
+ readonly latex: string;
16
+ }
17
+ interface DelimiterSizeDiagnostic {
18
+ readonly code: typeof DELIMITER_SIZE_DIAGNOSTIC_CODE;
19
+ readonly severity: 'warning';
20
+ readonly offset: number;
21
+ readonly line: number;
22
+ readonly column: number;
23
+ readonly outerSize: DelimiterSizeName;
24
+ readonly nestedSize: DelimiterSizeName;
25
+ readonly suggestion?: DelimiterSizeSuggestion;
26
+ readonly message: string;
27
+ }
28
+ /**
29
+ * Warn when a scalable outer pair is not at least one manual size level above
30
+ * everything nested inside it. `\left` and `\big` form the baseline level;
31
+ * progressively larger levels are `\Big`, `\bigg`, and `\Bigg`.
32
+ *
33
+ * Only `()`, `[]`, and `\{\}` are parsed. Bare delimiters are intentionally
34
+ * ignored: N15 owns that separate error, while ordinary semantic notation
35
+ * such as `f(x)` and `\mathbb{E}[X]` must not trigger this size-only linter.
36
+ */
37
+ declare function lintDelimiterSizes(latex: string): DelimiterSizeDiagnostic[];
38
+
39
+ export { DELIMITER_SIZE_DIAGNOSTIC_CODE, type DelimiterSizeDiagnostic, type DelimiterSizeName, type DelimiterSizeSuggestion, lintDelimiterSizes };
@@ -0,0 +1,177 @@
1
+ const DELIMITER_SIZE_DIAGNOSTIC_CODE = "delimiter-size-hierarchy";
2
+ const SIZE_SPECS = [
3
+ {
4
+ name: "automatic",
5
+ rank: 1,
6
+ openCommand: "\\left",
7
+ closeCommand: "\\right",
8
+ kind: "automatic"
9
+ },
10
+ {
11
+ name: "big",
12
+ rank: 1,
13
+ openCommand: "\\bigl",
14
+ closeCommand: "\\bigr",
15
+ kind: "manual"
16
+ },
17
+ {
18
+ name: "Big",
19
+ rank: 2,
20
+ openCommand: "\\Bigl",
21
+ closeCommand: "\\Bigr",
22
+ kind: "manual"
23
+ },
24
+ {
25
+ name: "bigg",
26
+ rank: 3,
27
+ openCommand: "\\biggl",
28
+ closeCommand: "\\biggr",
29
+ kind: "manual"
30
+ },
31
+ {
32
+ name: "Bigg",
33
+ rank: 4,
34
+ openCommand: "\\Biggl",
35
+ closeCommand: "\\Biggr",
36
+ kind: "manual"
37
+ }
38
+ ];
39
+ const COMMAND_SPECS = SIZE_SPECS.flatMap((spec) => [
40
+ { ...spec, command: spec.openCommand, tokenKind: "open" },
41
+ { ...spec, command: spec.closeCommand, tokenKind: "close" }
42
+ ]).sort((left, right) => right.command.length - left.command.length);
43
+ const SUGGESTION_BY_RANK = new Map(
44
+ SIZE_SPECS.filter((spec) => spec.rank >= 2).map((spec) => [spec.rank, spec])
45
+ );
46
+ function delimiterAt(latex, offset, kind) {
47
+ const candidates = kind === "open" ? ["\\{", "(", "["] : ["\\}", ")", "]"];
48
+ for (const delimiter of candidates) {
49
+ if (latex.startsWith(delimiter, offset)) {
50
+ return { delimiter, end: offset + delimiter.length };
51
+ }
52
+ }
53
+ return void 0;
54
+ }
55
+ function tokenAt(latex, offset) {
56
+ if (latex[offset] !== "\\") return void 0;
57
+ for (const spec of COMMAND_SPECS) {
58
+ if (!latex.startsWith(spec.command, offset)) continue;
59
+ const afterCommand = offset + spec.command.length;
60
+ if (/[A-Za-z]/.test(latex[afterCommand] ?? "")) continue;
61
+ let delimiterOffset = afterCommand;
62
+ while (/\s/.test(latex[delimiterOffset] ?? "")) delimiterOffset += 1;
63
+ const delimiter = delimiterAt(latex, delimiterOffset, spec.tokenKind);
64
+ if (!delimiter) continue;
65
+ return {
66
+ kind: spec.tokenKind,
67
+ pairKind: spec.kind,
68
+ rank: spec.rank,
69
+ size: spec.name,
70
+ delimiter: delimiter.delimiter,
71
+ offset,
72
+ end: delimiter.end
73
+ };
74
+ }
75
+ return void 0;
76
+ }
77
+ function pairMatches(open, close) {
78
+ return open.kind === "open" && close.kind === "close" && open.pairKind === close.pairKind && open.rank === close.rank;
79
+ }
80
+ function isEscaped(latex, offset) {
81
+ let slashes = 0;
82
+ for (let index = offset - 1; index >= 0 && latex[index] === "\\"; index -= 1) {
83
+ slashes += 1;
84
+ }
85
+ return slashes % 2 === 1;
86
+ }
87
+ function parseDelimiterPairs(latex) {
88
+ const roots = [];
89
+ const stack = [];
90
+ for (let offset = 0; offset < latex.length; offset += 1) {
91
+ if (latex[offset] === "%" && !isEscaped(latex, offset)) {
92
+ const newline = latex.indexOf("\n", offset + 1);
93
+ if (newline === -1) break;
94
+ offset = newline;
95
+ continue;
96
+ }
97
+ const token = isEscaped(latex, offset) ? void 0 : tokenAt(latex, offset);
98
+ if (!token) continue;
99
+ offset = token.end - 1;
100
+ if (token.kind === "open") {
101
+ const pair2 = { open: token, children: [] };
102
+ const parent = stack[stack.length - 1];
103
+ if (parent) parent.children.push(pair2);
104
+ else roots.push(pair2);
105
+ stack.push(pair2);
106
+ continue;
107
+ }
108
+ const pair = stack[stack.length - 1];
109
+ if (!pair || !pairMatches(pair.open, token)) return void 0;
110
+ pair.close = token;
111
+ stack.pop();
112
+ }
113
+ return stack.length === 0 ? roots : void 0;
114
+ }
115
+ function lineAndColumn(source, offset) {
116
+ const before = source.slice(0, offset);
117
+ const lastNewline = before.lastIndexOf("\n");
118
+ return {
119
+ line: before.split("\n").length,
120
+ column: offset - lastNewline
121
+ };
122
+ }
123
+ function sizeName(rank) {
124
+ return SUGGESTION_BY_RANK.get(rank)?.name ?? "Bigg";
125
+ }
126
+ function suggestionFor(pair, requiredRank) {
127
+ const close = pair.close;
128
+ const spec = SUGGESTION_BY_RANK.get(requiredRank);
129
+ if (!close || !spec || spec.name === "automatic" || spec.name === "big") {
130
+ return void 0;
131
+ }
132
+ return {
133
+ size: spec.name,
134
+ open: `${spec.openCommand}${pair.open.delimiter}`,
135
+ close: `${spec.closeCommand}${close.delimiter}`,
136
+ latex: `${spec.openCommand}${pair.open.delimiter} ... ${spec.closeCommand}${close.delimiter}`
137
+ };
138
+ }
139
+ function lintDelimiterSizes(latex) {
140
+ const roots = parseDelimiterPairs(latex);
141
+ if (!roots) return [];
142
+ const diagnostics = [];
143
+ const assess = (pair) => {
144
+ let largestNestedRequirement = 0;
145
+ let representativeNestedSize = "automatic";
146
+ for (const child of pair.children) {
147
+ const childRequired = assess(child);
148
+ if (childRequired > largestNestedRequirement) {
149
+ largestNestedRequirement = childRequired;
150
+ representativeNestedSize = sizeName(childRequired);
151
+ }
152
+ }
153
+ const requiredRank = largestNestedRequirement === 0 ? pair.open.rank : largestNestedRequirement + 1;
154
+ pair.requiredRank = Math.max(pair.open.rank, requiredRank);
155
+ if (largestNestedRequirement > 0 && pair.open.rank < requiredRank) {
156
+ const location = lineAndColumn(latex, pair.open.offset);
157
+ const suggestion = suggestionFor(pair, requiredRank);
158
+ diagnostics.push({
159
+ code: DELIMITER_SIZE_DIAGNOSTIC_CODE,
160
+ severity: "warning",
161
+ offset: pair.open.offset,
162
+ ...location,
163
+ outerSize: pair.open.size,
164
+ nestedSize: representativeNestedSize,
165
+ ...suggestion ? { suggestion } : {},
166
+ message: suggestion ? `Outer delimiters are not larger than their nested pair. Use ${suggestion.latex}; preserve the original opening and closing delimiter shapes (rule N17).` : `Outer delimiters are not larger than their nested pair, and no larger standard manual delimiter size remains. Preserve the original opening and closing shapes and restructure the expression if necessary (rule N17).`
167
+ });
168
+ }
169
+ return pair.requiredRank;
170
+ };
171
+ for (const root of roots) assess(root);
172
+ return diagnostics.sort((left, right) => left.offset - right.offset);
173
+ }
174
+ export {
175
+ DELIMITER_SIZE_DIAGNOSTIC_CODE,
176
+ lintDelimiterSizes
177
+ };
@@ -5,7 +5,7 @@ import { KeyedFloatLabelRecord, KeyedFloatLessonInput, KeyedFloatValidation } fr
5
5
  type DiagramDiagnosticCode = 'dia-invalid-key' | 'dia-duplicate-key' | 'dia-ref-resolves' | 'dia-key-unused';
6
6
  interface DiagramDiagnostic {
7
7
  readonly code: DiagramDiagnosticCode;
8
- readonly severity: 'error' | 'warning';
8
+ readonly severity: 'error' | 'warning' | 'info';
9
9
  readonly message: string;
10
10
  readonly file?: string;
11
11
  readonly lessonId?: string;
@@ -4,7 +4,7 @@
4
4
  * - `eq-duplicate-key` (Tier 1, error): an `eq:` key is unique per lesson.
5
5
  * - `eq-ref-resolves` (Tier 1, error): every `[[eq-key]]` / `[[slug#eq-key]]`
6
6
  * points at a display equation that carries `\label{eq:key}`.
7
- * - `eq-key-unused` (Tier 3, warning): a labelled equation never referenced.
7
+ * - `eq-key-unused` (info): a labelled equation is never referenced.
8
8
  *
9
9
  * The remark pass enforces the same rules on the render path; this gives the
10
10
  * fast `pnpm validate:content` the same coverage and feeds the manifest.
@@ -26,7 +26,7 @@ interface EquationRefRecord {
26
26
  type EquationDiagnosticCode = 'eq-duplicate-key' | 'eq-ref-resolves' | 'eq-key-unused';
27
27
  interface EquationDiagnostic {
28
28
  readonly code: EquationDiagnosticCode;
29
- readonly severity: 'error' | 'warning';
29
+ readonly severity: 'error' | 'warning' | 'info';
30
30
  readonly message: string;
31
31
  readonly file?: string;
32
32
  readonly lessonId?: string;
@@ -73,7 +73,7 @@ function validateEquations(lessons) {
73
73
  if (referenced.has(`${label.slug}#${label.key}`)) continue;
74
74
  diagnostics.push({
75
75
  code: "eq-key-unused",
76
- severity: "warning",
76
+ severity: "info",
77
77
  message: `${label.lessonId} labels eq:${label.key} (${label.number}) but nothing references it with [[eq-${label.key}]]`,
78
78
  file: label.slug,
79
79
  lessonId: label.lessonId,