explico 0.14.0 → 0.15.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 (52) hide show
  1. package/CHANGELOG.md +112 -1
  2. package/dist/compiler/cli-main.d.ts +61 -4
  3. package/dist/compiler/cli-main.js +77 -36
  4. package/dist/compiler/cli-reference.d.ts +50 -0
  5. package/dist/compiler/cli-reference.js +213 -0
  6. package/dist/compiler/cli-verbs.d.ts +45 -0
  7. package/dist/compiler/cli-verbs.js +224 -0
  8. package/dist/compiler/cli-verify.d.ts +51 -0
  9. package/dist/compiler/cli-verify.js +151 -0
  10. package/dist/compiler/cli.d.ts +19 -50
  11. package/dist/compiler/cli.js +45 -6
  12. package/dist/compiler/content-release.d.ts +49 -0
  13. package/dist/compiler/content-release.js +63 -0
  14. package/dist/compiler/content-schemas.d.ts +2 -2
  15. package/dist/compiler/manifest.js +29 -24
  16. package/dist/compiler/page-versions.d.ts +25 -1
  17. package/dist/compiler/page-versions.js +19 -0
  18. package/dist/compiler/validate-content.js +2 -2
  19. package/dist/reference/consistency.js +7 -1
  20. package/dist/reference/diagram-fit.d.ts +5 -1
  21. package/dist/reference/diagram-fit.js +5 -1
  22. package/dist/rules/catalog.js +16 -0
  23. package/dist/rules/in-effect.d.ts +1 -1
  24. package/dist/rules/in-effect.js +5 -4
  25. package/dist/rules/rules.schema.json +21 -0
  26. package/docs/authoring/README.md +2 -0
  27. package/docs/authoring/agents.md +18 -11
  28. package/docs/authoring/cli-reference.json +2172 -0
  29. package/docs/authoring/cli.md +887 -0
  30. package/docs/authoring/deck.md +24 -0
  31. package/docs/authoring/migrating.md +168 -18
  32. package/docs/authoring/versioning.md +2 -1
  33. package/docs/course-agent/AGENTS.md +10 -7
  34. package/docs/course-agent/agents/rules-reviewer.md +2 -2
  35. package/docs/course-agent/skills/draft-lesson/SKILL.md +7 -6
  36. package/docs/course-agent/skills/migrate-course/SKILL.md +5 -4
  37. package/docs/course-agent/skills/release-content/SKILL.md +5 -3
  38. package/docs/diagram-scaling.md +2 -2
  39. package/package.json +1 -1
  40. package/src/compiler/cli-main.ts +135 -47
  41. package/src/compiler/cli-reference.ts +285 -0
  42. package/src/compiler/cli-verbs.ts +270 -0
  43. package/src/compiler/cli-verify.ts +241 -0
  44. package/src/compiler/cli.ts +77 -17
  45. package/src/compiler/content-release.ts +91 -0
  46. package/src/compiler/manifest.ts +34 -24
  47. package/src/compiler/page-versions.ts +42 -0
  48. package/src/compiler/validate-content.ts +5 -2
  49. package/src/reference/consistency.ts +13 -2
  50. package/src/reference/diagram-fit.ts +18 -4
  51. package/src/rules/catalog.ts +17 -0
  52. package/src/rules/in-effect.ts +5 -4
@@ -63,7 +63,7 @@ import {
63
63
  } from '../reference/gate-math';
64
64
  import { resolveMathGlyphs } from '../reference/math-glyphs.mjs';
65
65
  import { stripEquationLabels } from '../reference/equations.mjs';
66
- import type { Manifest, ManifestLesson } from './manifest';
66
+ import type { Manifest, ManifestLesson, ManifestLessonFloat } from './manifest';
67
67
  import { readRenderDiagnostics, type RenderDiagnostic } from './render-diagnostics';
68
68
  import { isLessonSlug, lessonIdFromSlug, mergeTrackOrder } from './lesson-derivation';
69
69
  import {
@@ -103,7 +103,8 @@ export type ContentCliCode =
103
103
  | 'no-git'
104
104
  | 'unknown-version'
105
105
  | 'migration-gap'
106
- | 'unknown-key';
106
+ | 'unknown-key'
107
+ | 'no-changelog';
107
108
 
108
109
  /** A user-facing failure with a stable code and no stack in `--json` output. */
109
110
  export class ContentCliError extends Error {
@@ -1213,10 +1214,10 @@ export interface ContextReport {
1213
1214
  units?: string;
1214
1215
  formula?: string;
1215
1216
  }[];
1216
- readonly equations: readonly { key: string; number: string }[];
1217
- readonly tables: readonly { key: string; number: string }[];
1218
- readonly figures: readonly { key: string; number: string }[];
1219
- readonly diagrams: readonly { key: string; number: string }[];
1217
+ readonly equations: readonly ManifestLessonFloat[];
1218
+ readonly tables: readonly ManifestLessonFloat[];
1219
+ readonly figures: readonly ManifestLessonFloat[];
1220
+ readonly diagrams: readonly ManifestLessonFloat[];
1220
1221
  /** Worked-example sets: `short` is the title `[[ex-key]]` names. */
1221
1222
  readonly examples: readonly { key: string; number: string; short?: string }[];
1222
1223
  readonly knowledgeChecks: readonly { key: string; number: string; title?: string }[];
@@ -1241,7 +1242,7 @@ export interface ContextReport {
1241
1242
 
1242
1243
  // --- content migrate -------------------------------------------------
1243
1244
 
1244
- /**
1245
+ /*
1245
1246
  * The migration guide, cut to the sections an upgrade needs: from the
1246
1247
  * version a course pins to the version it has installed, oldest first. The
1247
1248
  * guide is one newest-first file of every release; an upgrade reads one or
@@ -1441,7 +1442,7 @@ export function formatMigrateReport(report: MigrateReport): string {
1441
1442
 
1442
1443
  // --- content bindings ------------------------------------------------
1443
1444
 
1444
- /**
1445
+ /*
1445
1446
  * What a lesson's math binds, span by span, before a build says so: each
1446
1447
  * glyph and the card it resolves to, how (`canonical`, `family`, `derived`,
1447
1448
  * `base`, `operator`, or an explicit `\explain` wrap), the wraps the bare
@@ -1787,7 +1788,7 @@ export function formatEquationReport(report: EquationReport): string {
1787
1788
 
1788
1789
  // --- content move / rename / fix: edits as a plan ----------------------
1789
1790
 
1790
- /**
1791
+ /*
1791
1792
  * The refactors a model gets wrong most and reads most for, as deterministic
1792
1793
  * edits: every one is planned first (`FileEdit`s with the whole text before
1793
1794
  * and after), shown as a unified diff, and written only on `--write`.
@@ -1928,13 +1929,19 @@ function yamlBlock(value: unknown, indent: number): string[] {
1928
1929
  return [`${pad}${yamlScalar(value)}`];
1929
1930
  }
1930
1931
 
1931
- /** A shared card's file from a page-local entry's fields, nothing invented. */
1932
+ /**
1933
+ * A shared card's file from a page-local entry's fields, nothing invented: no
1934
+ * body (the glossary shows the meaning; what a reader needs beyond it is the
1935
+ * author's to write, and a placeholder would publish), and the label only
1936
+ * when the entry has one. `domain` is the caller's pick, else the key's
1937
+ * prefix, else `general`.
1938
+ */
1932
1939
  export function sharedCardFromLocal(
1933
1940
  local: Record<string, unknown>,
1934
- options: { readonly key: string; readonly fromLesson: string },
1941
+ options: { readonly key: string; readonly fromLesson: string; readonly domain?: string },
1935
1942
  ): string {
1936
1943
  const key = options.key;
1937
- const domain = key.includes('.') ? key.slice(0, key.indexOf('.')) : 'general';
1944
+ const domain = options.domain ?? (key.includes('.') ? key.slice(0, key.indexOf('.')) : 'general');
1938
1945
  const front: Record<string, unknown> = {
1939
1946
  key,
1940
1947
  latex: local.latex,
@@ -1953,7 +1960,28 @@ export function sharedCardFromLocal(
1953
1960
  alignment: local.alignment ?? { kind: 'general', rationale: `Promoted from ${options.fromLesson}'s page-local entry.` },
1954
1961
  aiAssisted: true,
1955
1962
  };
1956
- return `---\n${yamlBlock(front, 0).join('\n')}\n---\n\nPromoted from \`${options.fromLesson}\`'s \`notation.local\`; the meaning above is what that page said. Extend this body with what a glossary reader needs.\n`;
1963
+ return `---\n${yamlBlock(front, 0).join('\n')}\n---\n`;
1964
+ }
1965
+
1966
+ /**
1967
+ * The domain a card promoted under `key` belongs to: the key's prefix when the
1968
+ * course already has that domain, else the one most of the lesson's shared
1969
+ * cards have, else the course's most common, else the prefix or `general`.
1970
+ */
1971
+ function promotedDomain(manifest: Manifest, lesson: ManifestLesson, key: string): string {
1972
+ type Shared = Extract<Manifest['notation']['definitions'][number], { kind: 'shared' }>;
1973
+ const shared = manifest.notation.definitions.filter((definition): definition is Shared => definition.kind === 'shared');
1974
+ const prefix = key.includes('.') ? key.slice(0, key.indexOf('.')) : undefined;
1975
+ if (prefix !== undefined && shared.some((definition) => definition.domain === prefix)) return prefix;
1976
+ const mostCommon = (definitions: typeof shared): string | undefined => {
1977
+ const counts = new Map<string, number>();
1978
+ for (const definition of definitions) {
1979
+ if (definition.domain) counts.set(definition.domain, (counts.get(definition.domain) ?? 0) + 1);
1980
+ }
1981
+ return [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0]?.[0];
1982
+ };
1983
+ const bound = new Set(lesson.notation.definitionIds);
1984
+ return mostCommon(shared.filter((definition) => bound.has(definition.id))) ?? mostCommon(shared) ?? prefix ?? 'general';
1957
1985
  }
1958
1986
 
1959
1987
  function courseTextFiles(): string[] {
@@ -2014,8 +2042,23 @@ export function planMove(manifest: Manifest, lessonId: string, key: string, opti
2014
2042
  }
2015
2043
  const summary: string[] = [];
2016
2044
  const edits: FileEdit[] = [];
2017
- edits.push({ file: target, before: null, after: sharedCardFromLocal(source, { key: newKey, fromLesson: lesson.id }) });
2018
- summary.push(`${target}: new shared card from ${lesson.id}'s entry${newKey === key ? '' : ` (key ${key} → ${newKey})`}`);
2045
+ const domain = promotedDomain(manifest, lesson, newKey);
2046
+ const glyph = typeof source.latex === 'string' ? source.latex.trim() : '';
2047
+ const owner = manifest.notation.definitions.find(
2048
+ (definition) => definition.kind === 'shared' && definition.key !== newKey && definition.notation.trim() === glyph,
2049
+ );
2050
+ if (owner !== undefined) {
2051
+ summary.push(
2052
+ `CAUTION: ${glyph} is already shared card ${owner.key}'s — a second shared card on one glyph is a glyph-unique-in-corpus finding; ` +
2053
+ 'a reused letter stays local (rule N4). Write this only if the two cards are meant to share the glyph, and acknowledge the pair.',
2054
+ );
2055
+ }
2056
+ edits.push({ file: target, before: null, after: sharedCardFromLocal(source, { key: newKey, fromLesson: lesson.id, domain }) });
2057
+ summary.push(
2058
+ `${target}: new shared card from ${lesson.id}'s entry${newKey === key ? '' : ` (key ${key} → ${newKey})`}, domain ${domain}` +
2059
+ `; no body yet — add what a glossary reader needs beyond the meaning` +
2060
+ (source.label === undefined ? '; no label in the entry — the card shows one made from the key until you add `label:`' : ''),
2061
+ );
2019
2062
  const touched = new Set<string>();
2020
2063
  for (const other of manifest.lessons) {
2021
2064
  const copy = local(other);
@@ -2124,6 +2167,22 @@ export function planFix(manifest: Manifest, lessonId: string): EditPlan {
2124
2167
  };
2125
2168
  }
2126
2169
 
2170
+ /** `content fix` over several lessons (or every one, `--all`) as one plan: the lessons with nothing to fix are counted, not listed. */
2171
+ export function planFixMany(manifest: Manifest, lessonIds: readonly string[]): EditPlan {
2172
+ if (lessonIds.length === 1) return planFix(manifest, lessonIds[0]!);
2173
+ const plans = lessonIds.map((id) => planFix(manifest, id));
2174
+ const fixing = plans.filter((plan) => plan.edits.length > 0);
2175
+ const clean = plans.length - fixing.length;
2176
+ return {
2177
+ summary: [
2178
+ ...fixing.flatMap((plan) => plan.summary),
2179
+ ...(clean > 0 ? [`${clean} lesson(s) with nothing to fix`] : []),
2180
+ ],
2181
+ edits: fixing.flatMap((plan) => plan.edits),
2182
+ next: ['pnpm content check --changed'],
2183
+ };
2184
+ }
2185
+
2127
2186
  /** The plan as a unified diff (git's), file by file, under its summary lines. */
2128
2187
  export function renderEditPlan(plan: EditPlan): string {
2129
2188
  const lines = plan.summary.map((line) => ` ${line}`);
@@ -2212,7 +2271,7 @@ export function planInitAgent(): EditPlan {
2212
2271
  const before = existsSync(target) ? readFileSync(target, 'utf8') : null;
2213
2272
  if (before === after) return;
2214
2273
  edits.push({ file: to, before, after });
2215
- summary.push(`${to}: ${before === null ? 'installed' : 'refreshed from the engine'}`);
2274
+ summary.push(`${to}: ${before === null ? 'new' : "differs from the engine's copy"}`);
2216
2275
  };
2217
2276
  for (const file of filesUnder(join(source, 'skills'))) install(`skills/${file}`, `.claude/skills/${file}`);
2218
2277
  for (const file of filesUnder(join(source, 'agents'))) install(`agents/${file}`, `.claude/agents/${file}`);
@@ -2447,7 +2506,7 @@ export function contentContext(
2447
2506
 
2448
2507
  // --- content symbol --------------------------------------------------
2449
2508
 
2450
- /**
2509
+ /*
2451
2510
  * Where a symbol is already defined, asked before an author declares it
2452
2511
  * again. Reads the registry the manifest holds: shared cards, page-local
2453
2512
  * entries (`notation.local`) and glosses (a card's `glosses:`), matched on
@@ -2456,6 +2515,7 @@ export function contentContext(
2456
2515
  * `v_0`), so a near miss is visible too. Read-only; every list is sorted,
2457
2516
  * so two runs print the same report.
2458
2517
  */
2518
+ /** Where a hit is defined: a shared card, a lesson's `notation.local`, or a card's gloss. */
2459
2519
  export type SymbolTier = 'shared' | 'local' | 'gloss';
2460
2520
 
2461
2521
  export interface SymbolHit {
@@ -0,0 +1,91 @@
1
+ /**
2
+ * `content release [major|minor|patch]` — cut a course content release (rule
3
+ * C3): `## [Unreleased]` in `content/CHANGELOG.md` becomes a dated
4
+ * `## [x.y.z]` heading and `content/course.config.ts` `about.version` becomes
5
+ * `x.y.z`, in one plan, shown as a diff and written only on `--write`, like
6
+ * the other edit verbs. The bump defaults to what the section's own headings
7
+ * imply (rule C2): `### Breaking` or `### Removed` a MAJOR, `### Added` a
8
+ * MINOR, anything else a PATCH.
9
+ *
10
+ * Each page's own version (rule C5) follows on the next `validate:content`,
11
+ * which records the release in `content/versions.json`; the release commit
12
+ * carries that file too.
13
+ */
14
+ import { existsSync, readFileSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+
17
+ import { ContentCliError, type EditPlan } from './cli';
18
+
19
+ export type ReleaseBump = 'major' | 'minor' | 'patch';
20
+
21
+ const CHANGELOG = 'content/CHANGELOG.md';
22
+ const CONFIG = 'content/course.config.ts';
23
+
24
+ export interface ReleaseEdit {
25
+ readonly from: string;
26
+ readonly to: string;
27
+ readonly bump: ReleaseBump;
28
+ /** True when the bump was read from the section's headings, not asked for. */
29
+ readonly inferred: boolean;
30
+ readonly changelog: string;
31
+ readonly config: string;
32
+ }
33
+
34
+ /** The release of these two files' text on `date` (`YYYY-MM-DD`); `null` when `[Unreleased]` has no entry. */
35
+ export function releaseEdit(changelog: string, config: string, date: string, requested?: ReleaseBump): ReleaseEdit | null {
36
+ const unreleased = /^## \[Unreleased\]\r?$/m.exec(changelog);
37
+ if (!unreleased) throw new ContentCliError('no-changelog', `${CHANGELOG} has no "## [Unreleased]" heading.`);
38
+ const bodyStart = unreleased.index + unreleased[0].length;
39
+ const released = /^## \[\d+\.\d+\.\d+\](?: - .*?)?\r?$/m.exec(changelog.slice(bodyStart));
40
+ if (!released) throw new ContentCliError('no-changelog', `${CHANGELOG} has no released "## [x.y.z]" heading below "## [Unreleased]".`);
41
+ const bodyEnd = bodyStart + released.index;
42
+ const body = changelog.slice(bodyStart, bodyEnd);
43
+ if (!/^-\s+/m.test(body)) return null;
44
+
45
+ const version = /(about:\s*\{[\s\S]*?\bversion:\s*['"])(\d+)\.(\d+)\.(\d+)(['"])/.exec(config);
46
+ if (!version) throw new ContentCliError('unknown-version', `No about.version "x.y.z" in ${CONFIG}.`);
47
+ const [major, minor, patch] = version.slice(2, 5).map(Number) as [number, number, number];
48
+ const implied: ReleaseBump = /^### (Breaking|Removed)\s*$/m.test(body) ? 'major' : /^### Added\s*$/m.test(body) ? 'minor' : 'patch';
49
+ const bump = requested ?? implied;
50
+ const to = bump === 'major' ? `${major + 1}.0.0` : bump === 'minor' ? `${major}.${minor + 1}.0` : `${major}.${minor}.${patch + 1}`;
51
+ return {
52
+ from: `${major}.${minor}.${patch}`,
53
+ to,
54
+ bump,
55
+ inferred: requested === undefined,
56
+ changelog: `${changelog.slice(0, bodyStart)}\n\n## [${to}] - ${date}${body}${changelog.slice(bodyEnd)}`,
57
+ config: config.replace(version[0], `${version[1]}${to}${version[5]}`),
58
+ };
59
+ }
60
+
61
+ /** The release as an edit plan over the course in the working directory. */
62
+ /** Today where the release is cut, `YYYY-MM-DD`: a release at 9 pm is dated that day, not the next one in UTC. */
63
+ function localDate(now = new Date()): string {
64
+ return [now.getFullYear(), now.getMonth() + 1, now.getDate()].map((n, i) => String(n).padStart(i === 0 ? 4 : 2, '0')).join('-');
65
+ }
66
+
67
+ export function planContentRelease(requested?: string, date = localDate()): EditPlan {
68
+ if (requested !== undefined && requested !== 'major' && requested !== 'minor' && requested !== 'patch') {
69
+ throw new ContentCliError('usage', `content release takes major, minor or patch; got ${requested}.`);
70
+ }
71
+ for (const file of [CHANGELOG, CONFIG]) {
72
+ if (!existsSync(join(process.cwd(), file))) throw new ContentCliError('no-changelog', `No ${file} in this course.`);
73
+ }
74
+ const changelog = readFileSync(join(process.cwd(), CHANGELOG), 'utf8');
75
+ const config = readFileSync(join(process.cwd(), CONFIG), 'utf8');
76
+ const edit = releaseEdit(changelog, config, date, requested);
77
+ if (edit === null) {
78
+ return { summary: ['## [Unreleased] has no entries: nothing to release'], edits: [], next: [] };
79
+ }
80
+ return {
81
+ summary: [
82
+ `about.version ${edit.from} → ${edit.to} (${edit.bump}${edit.inferred ? ", from the section's headings" : ''})`,
83
+ `${CHANGELOG}: ## [Unreleased] → ## [${edit.to}] - ${date}, a fresh ## [Unreleased] above it`,
84
+ ],
85
+ edits: [
86
+ { file: CHANGELOG, before: changelog, after: edit.changelog },
87
+ { file: CONFIG, before: config, after: edit.config },
88
+ ],
89
+ next: ['pnpm validate:content (records the release in content/versions.json)', 'commit the changelog, the config and content/versions.json together'],
90
+ };
91
+ }
@@ -183,6 +183,7 @@ import {
183
183
  type KnowledgeVersionDiagnostic,
184
184
  } from './knowledge-version';
185
185
  import {
186
+ deckStaleDiagnostics,
186
187
  nextPageVersions,
187
188
  PAGE_VERSIONS_FILE,
188
189
  pageSourceDigest,
@@ -2329,6 +2330,37 @@ export async function compileManifest(
2329
2330
  : { changelogHref: courseConfig.about.changelogHref }),
2330
2331
  };
2331
2332
 
2333
+ const lessonsWithSince = lessons.map((lesson) => ({ ...lesson, since: sinceOfPage(lesson.slug) }));
2334
+ const decksWithSince = deckModel.decks.map((deck) => {
2335
+ const page = pageInputById.get(deck.pageId);
2336
+ return {
2337
+ ...deck,
2338
+ since: sinceOfPage(deck.slug),
2339
+ notation: lessonNotationBundle(
2340
+ bundlesByLesson.get(deck.pageId),
2341
+ definitionsById,
2342
+ page === undefined ? [] : lessonNotationResolution(page, definitionsById, availableDefinitionIds),
2343
+ cardsByKey,
2344
+ // A deck shows its lessons' symbols: the derived cards (rule N23)
2345
+ // its chapters' bodies and checks use ship with it, as its bundle
2346
+ // holds the chapters' entries.
2347
+ [
2348
+ ...(indexGate.derivedByLesson.get(deck.pageId) ?? []),
2349
+ ...(page?.scopeLessonIds ?? []).flatMap((lessonId) => [
2350
+ ...(indexGate.derivedByLesson.get(lessonId) ?? []),
2351
+ ...(assessmentDerived.get(lessonId)?.values() ?? []),
2352
+ ]),
2353
+ ].filter((use, index, all) => all.findIndex((other) => other.key === use.key) === index),
2354
+ ),
2355
+ };
2356
+ });
2357
+ // Rule D11: a lesson's own deck whose chapter changed after the deck did.
2358
+ // `since` is only known this late, so this runs after `mathIssues` (D1–D10's
2359
+ // frame codes) is already waived; pushed in after, it joins `diagnostics.math`
2360
+ // the same way, just outside the waiver pass — not a per-place finding to waive.
2361
+ const chapterSince = new Map(lessonsWithSince.map((lesson) => [lesson.slug, lesson.since] as const));
2362
+ mathIssues.push(...deckStaleDiagnostics(decksWithSince, chapterSince));
2363
+
2332
2364
  return {
2333
2365
  schemaVersion: MANIFEST_SCHEMA_VERSION,
2334
2366
  lessonExports: normalizeLessonExports(courseConfig),
@@ -2341,29 +2373,7 @@ export async function compileManifest(
2341
2373
  readingMemory: normalizeReadingMemory(courseConfig),
2342
2374
  appearance: normalizeAppearance(courseConfig),
2343
2375
  deck: deckSettings,
2344
- decks: deckModel.decks.map((deck) => {
2345
- const page = pageInputById.get(deck.pageId);
2346
- return {
2347
- ...deck,
2348
- since: sinceOfPage(deck.slug),
2349
- notation: lessonNotationBundle(
2350
- bundlesByLesson.get(deck.pageId),
2351
- definitionsById,
2352
- page === undefined ? [] : lessonNotationResolution(page, definitionsById, availableDefinitionIds),
2353
- cardsByKey,
2354
- // A deck shows its lessons' symbols: the derived cards (rule N23)
2355
- // its chapters' bodies and checks use ship with it, as its bundle
2356
- // holds the chapters' entries.
2357
- [
2358
- ...(indexGate.derivedByLesson.get(deck.pageId) ?? []),
2359
- ...(page?.scopeLessonIds ?? []).flatMap((lessonId) => [
2360
- ...(indexGate.derivedByLesson.get(lessonId) ?? []),
2361
- ...(assessmentDerived.get(lessonId)?.values() ?? []),
2362
- ]),
2363
- ].filter((use, index, all) => all.findIndex((other) => other.key === use.key) === index),
2364
- ),
2365
- };
2366
- }),
2376
+ decks: decksWithSince,
2367
2377
  about,
2368
2378
  notation: {
2369
2379
  definitions: registry.definitions,
@@ -2382,7 +2392,7 @@ export async function compileManifest(
2382
2392
  ? { ...part, overview: { ...overview, since: sinceOfPage(`${part.dir}/index`) } }
2383
2393
  : part,
2384
2394
  ),
2385
- lessons: lessons.map((lesson) => ({ ...lesson, since: sinceOfPage(lesson.slug) })),
2395
+ lessons: lessonsWithSince,
2386
2396
  pageVersions,
2387
2397
  pages,
2388
2398
  prereqEdges,
@@ -357,3 +357,45 @@ export function syncPageVersions(
357
357
  writeFileSync(path, expected);
358
358
  return { path, stale: true, written: true, changed };
359
359
  }
360
+
361
+ /**
362
+ * Rule D11, shaped like a `MathGateDiagnostic` (`src/reference/gate-math.ts`)
363
+ * so it can join `diagnostics.math` beside D1–D10's frame codes, without
364
+ * this module importing from `src/reference/`.
365
+ */
366
+ export interface DeckStaleDiagnostic {
367
+ readonly context: 'structure';
368
+ readonly severity: 'info';
369
+ readonly code: 'deck-stale';
370
+ readonly file: string;
371
+ readonly message: string;
372
+ }
373
+
374
+ /**
375
+ * Rule D11: nothing else would notice a lesson's chapter and deck drifting
376
+ * apart, since two files keep each edition readable at the cost of that one
377
+ * check. Flags a deck whose chapter's `since` (rule C5) is newer than the
378
+ * deck's own. A part's deck has no one chapter to compare against, so a
379
+ * deck without a `chapter` (its `BuiltDeck.chapter`) is skipped.
380
+ */
381
+ export function deckStaleDiagnostics(
382
+ decks: readonly { readonly file: string; readonly chapter?: string; readonly since: string | null }[],
383
+ chapterSince: ReadonlyMap<string, string | null>,
384
+ ): DeckStaleDiagnostic[] {
385
+ const out: DeckStaleDiagnostic[] = [];
386
+ for (const deck of decks) {
387
+ if (deck.chapter === undefined) continue;
388
+ const chapterVersion = chapterSince.get(deck.chapter);
389
+ if (chapterVersion === undefined) continue;
390
+ if (compareKnowledgeVersions(chapterVersion, deck.since) <= 0) continue;
391
+ const chapterChange = chapterVersion === null ? 'has unreleased changes' : `changed in version ${chapterVersion}`;
392
+ out.push({
393
+ context: 'structure',
394
+ severity: 'info',
395
+ code: 'deck-stale',
396
+ file: deck.file,
397
+ message: `The chapter ${chapterChange}; this deck is still from version ${deck.since}.`,
398
+ });
399
+ }
400
+ return out;
401
+ }
@@ -116,8 +116,11 @@ export async function runValidateContent(courseConfig: CourseConfig): Promise<nu
116
116
 
117
117
  const consistencyWarnings = consistency.filter((diagnostic) => diagnostic.severity === 'warning');
118
118
  if (consistencyWarnings.length > 0) {
119
+ // The console gets one digest line; the log records each finding at
120
+ // WARNING (below), so its counts agree with `content status`, and the
121
+ // digest at INFO, so it is not counted as one more warning.
119
122
  console.warn(
120
- log.warning(
123
+ log.info(
121
124
  'consistency',
122
125
  `[consistency] ${consistencyWarnings.length} Tier-3 finding(s): ` +
123
126
  Object.entries(consistencyCounts(consistencyWarnings))
@@ -131,7 +134,7 @@ export async function runValidateContent(courseConfig: CourseConfig): Promise<nu
131
134
  // Every severity-`info` diagnostic goes to the log only.
132
135
  log.infoDiagnostics(manifest.diagnostics);
133
136
  for (const diagnostic of consistencyWarnings) {
134
- log.info('consistency', `[${diagnostic.code}] ${diagnostic.message}`);
137
+ log.warning('consistency', `[${diagnostic.code}] ${diagnostic.message}`);
135
138
  }
136
139
 
137
140
  const reports = syncResolutionReports(manifest);
@@ -308,7 +308,9 @@ function checkNotationSourceLocator(
308
308
  * it is one meaning, so it belongs in `content/notation/<key>.md` (every
309
309
  * `[[key]]` keeps working), or it is two, so one needs another key. A deck
310
310
  * inherits its chapters' entries rather than redeclaring them, so decks never
311
- * count.
311
+ * count. When a shared card already owns the glyph, the entry is a reused
312
+ * letter (N4) and promoting it would put two shared cards on one glyph, so
313
+ * the message says that instead of "move one copy".
312
314
  */
313
315
  function checkDefinitionRepeated(
314
316
  input: ConsistencyInput,
@@ -327,6 +329,10 @@ function checkDefinitionRepeated(
327
329
  byKey.set(definition.key, list);
328
330
  }
329
331
  }
332
+ const sharedByGlyph = new Map<string, string>();
333
+ for (const definition of input.notationInput.sharedDefinitions) {
334
+ if (!sharedByGlyph.has(definition.notation.trim())) sharedByGlyph.set(definition.notation.trim(), definition.key);
335
+ }
330
336
  for (const [key, copies] of [...byKey].sort(([a], [b]) => a.localeCompare(b))) {
331
337
  if (copies.length < 2) continue;
332
338
  if (input.lintIgnore.matches('definition-repeated', key)) continue;
@@ -335,6 +341,11 @@ function checkDefinitionRepeated(
335
341
  copies.some((copy) => JSON.stringify(copy.definition[field] ?? null) !== JSON.stringify(first.definition[field] ?? null)),
336
342
  );
337
343
  const lessons = copies.map((copy) => copy.lessonId).sort();
344
+ const owner = sharedByGlyph.get(first.definition.notation.trim());
345
+ const advice =
346
+ owner !== undefined && owner !== key
347
+ ? ` — its glyph is already shared card ${owner}'s, so it is a reused letter (rule N4) and promoting it would put two shared cards on one glyph (glyph-unique-in-corpus): keep the copies local and acknowledge them in lint-ignore.yml ("definition-repeated:${key} — <why>"), or, if they are two meanings, give one another key`
348
+ : ` — move one copy to content/notation/${key}.md and delete the others: every [[${key}]] and \\explain{${key}} keeps working; if they are two meanings, give one another key`;
338
349
  out.push({
339
350
  code: 'definition-repeated',
340
351
  severity: 'warning',
@@ -347,7 +358,7 @@ function checkDefinitionRepeated(
347
358
  (differing.length === 0
348
359
  ? 'the copies agree'
349
360
  : `the copies differ in ${differing.join(', ')}`) +
350
- ` — move one copy to content/notation/${key}.md and delete the others: every [[${key}]] and \\explain{${key}} keeps working; if they are two meanings, give one another key`,
361
+ advice,
351
362
  });
352
363
  }
353
364
  }
@@ -42,12 +42,26 @@ export function diagramWidthLimit(
42
42
  return typeof columnWidth === 'number' ? columnWidth : DEFAULT_COLUMN_WIDTH;
43
43
  }
44
44
 
45
- /** What to tell the author about one diagram wider than its limit. */
45
+ /**
46
+ * What to tell the author about one diagram wider than its limit. The
47
+ * competency graph is `<CurriculumMap>`'s, whose nodes the author does not
48
+ * write, so its advice names the map's own props.
49
+ */
46
50
  export function diagramTooWideMessage(id: string, width: number, limit: number): string {
47
- return (
51
+ const head =
48
52
  `Diagram "${id}" lays out ${Math.round(width)}px wide, wider than the ${limit}px it may ` +
49
- `take (rule V8), so a reader has to scroll it or widen it to see it whole. Try ` +
50
- `direction="TB", a shorter label, or a narrower node \`width\` (its label wraps inside ` +
53
+ `take (rule V8), so a reader has to scroll it or widen it to see it whole. `;
54
+ if (id.startsWith('competency-graph')) {
55
+ return (
56
+ head +
57
+ `It is <CurriculumMap>'s competency graph: try graph="per-part" (one graph per part) and ` +
58
+ `direction="LR" or "TB" on the map; set warnAboveWidth={false} there when no layout fits ` +
59
+ `and the graph is meant to scroll.`
60
+ );
61
+ }
62
+ return (
63
+ head +
64
+ `Try direction="TB", a shorter label, or a narrower node \`width\` (its label wraps inside ` +
51
65
  `it); set warnAboveWidth={false} only when the diagram is meant to scroll.`
52
66
  );
53
67
  }
@@ -1366,6 +1366,23 @@ const RULES: readonly RuleEntry[] = [
1366
1366
  'The title frame (the lesson, the course, its authors and version) and the closing frame (the outcomes of the competencies the lesson teaches, `deck.outcomesFrame`) are generated: do not write them. With `deck.outcomesFrame` off, end on a `###` frame that says what the student can now do.',
1367
1367
  checks: [],
1368
1368
  },
1369
+ {
1370
+ kind: 'rule',
1371
+ id: 'D11',
1372
+ editions: 'deck',
1373
+ short: 'A deck is flagged when its chapter changed after it did',
1374
+ title: 'A deck is flagged when its chapter changed, by rule C5’s version, after the deck itself last did.',
1375
+ elements: ['slide', 'changelog'],
1376
+ doc: 'deck.md#d11',
1377
+ summary:
1378
+ 'No authoring action by itself: two files mean the chapter and the deck can drift apart with nothing to notice it. `deck-stale` is an info diagnostic naming a lesson’s own deck whose chapter’s rule C5 version is newer than the deck’s; read the chapter’s change and judge whether the deck needs it too. Scoped to a lesson’s own deck — a part’s deck covers more than one chapter, so it has none to compare against.',
1379
+ checks: [
1380
+ check('deck-stale', 'info', false, {
1381
+ module: 'src/compiler/page-versions.ts',
1382
+ fn: 'deckStaleDiagnostics',
1383
+ }),
1384
+ ],
1385
+ },
1369
1386
  ];
1370
1387
 
1371
1388
  /**
@@ -193,7 +193,8 @@ export function renderRulesInEffect(input: RulesInEffectInput, filter: RulesFilt
193
193
  ]
194
194
  : []),
195
195
  'Generated by `pnpm validate:content` (and `pnpm content rules`) from the rule',
196
- 'catalog and `rules` / `courseRules` in `course.settings.json`. Do not edit. Read this before',
196
+ "catalog and the course's `rules` / `courseRules` (`course.settings.json`, or",
197
+ '`course.config.ts`). Do not edit. Read this before',
197
198
  'drafting, and open a rule page only when it is listed here and you need the',
198
199
  'example: a rule absent from this page is either off for this course or does',
199
200
  'not exist, and is neither applied nor flagged in review.',
@@ -210,8 +211,8 @@ export function renderRulesInEffect(input: RulesInEffectInput, filter: RulesFilt
210
211
  : ''),
211
212
  '',
212
213
  'A line is `ID · enforcement · codes — what to do (settings in effect) → full',
213
- 'text`. **course**: this course wrote the rule in `courseRules` in',
214
- '`course.settings.json`, where it can be edited; the `X` area is reserved for',
214
+ 'text`. **course**: this course wrote the rule in `courseRules` (in',
215
+ '`course.settings.json` or `course.config.ts`), where it can be edited; the `X` area is reserved for',
215
216
  'them. **script**: the build reports every violation under those codes.',
216
217
  '**partial**: the build reports the codes; the rest is on you. **guide**: nothing',
217
218
  'runs; it is on you. **locked**: script, and the course cannot turn it off.',
@@ -245,7 +246,7 @@ export function renderRulesInEffect(input: RulesInEffectInput, filter: RulesFilt
245
246
  lines.push(
246
247
  offHere.length === 0
247
248
  ? '- Nothing — every rule is on.'
248
- : `- ${offHere.join(', ')} — turned off in \`course.settings.json\` (\`rules\`, or \`courseRules\` for an \`X\` rule). Do not apply them; do not flag them in review.`,
249
+ : `- ${offHere.join(', ')} — turned off in the course's \`rules\` (or \`courseRules\` for an \`X\` rule), in \`course.settings.json\` or \`course.config.ts\`. Do not apply them; do not flag them in review.`,
249
250
  );
250
251
 
251
252
  lines.push('', '## Locked', '');