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
@@ -0,0 +1,151 @@
1
+ import { execFileSync, spawnSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { join, relative } from "node:path";
5
+ const VERIFY_DIR = join("node_modules", ".cache", "explico", "verify");
6
+ const RECORD = "last-pass.json";
7
+ const TAIL_LINES = 20;
8
+ function verifyTreeHash(cwd) {
9
+ try {
10
+ const git = (args) => execFileSync("git", args, { cwd, maxBuffer: 1 << 30, stdio: ["ignore", "pipe", "ignore"] });
11
+ const hash = createHash("sha1");
12
+ hash.update(git(["rev-parse", "HEAD"]));
13
+ hash.update(git(["diff", "HEAD", "--binary"]));
14
+ const untracked = git(["ls-files", "--others", "--exclude-standard", "-z"]).toString("utf8").split("\0").filter(Boolean).sort();
15
+ for (const file of untracked) {
16
+ hash.update(`\0${file}\0`);
17
+ if (existsSync(join(cwd, file))) hash.update(readFileSync(join(cwd, file)));
18
+ }
19
+ return hash.digest("hex");
20
+ } catch {
21
+ return null;
22
+ }
23
+ }
24
+ function scriptCommand(script, args) {
25
+ const execpath = process.env.npm_execpath ?? "";
26
+ const manager = /pnpm/.test(execpath) ? "pnpm" : /yarn/.test(execpath) ? "yarn" : /npm/.test(execpath) ? "npm" : "pnpm";
27
+ const tail = manager === "npm" && args.length > 0 ? ["run", script, "--", ...args] : ["run", script, ...args];
28
+ const display = `${manager} ${tail.join(" ")}`;
29
+ if (execpath === "" || !execpath.includes(manager)) return { file: manager, argv: tail, display };
30
+ return /\.[cm]?js$/.test(execpath) ? { file: process.execPath, argv: [execpath, ...tail], display } : { file: execpath, argv: tail, display };
31
+ }
32
+ function binCommand(cwd, bin, args) {
33
+ return { file: join(cwd, "node_modules", ".bin", process.platform === "win32" ? `${bin}.cmd` : bin), argv: [...args], display: [bin, ...args].join(" ") };
34
+ }
35
+ function slug(name) {
36
+ return name.replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
37
+ }
38
+ function tailOf(file) {
39
+ const lines = readFileSync(file, "utf8").split("\n").map((line) => line.trimEnd()).filter((line) => line !== "");
40
+ return lines.slice(-TAIL_LINES);
41
+ }
42
+ function runVerify(options = {}) {
43
+ const cwd = options.cwd ?? process.cwd();
44
+ const e2e = options.e2e ?? false;
45
+ const dir = join(cwd, VERIFY_DIR);
46
+ const recordFile = join(dir, RECORD);
47
+ if (!options.force && existsSync(recordFile)) {
48
+ try {
49
+ const record = JSON.parse(readFileSync(recordFile, "utf8"));
50
+ const tree2 = verifyTreeHash(cwd);
51
+ if (tree2 !== null && record.tree === tree2 && (record.e2e || !e2e)) {
52
+ return { ok: true, cached: true, since: record.when, tree: tree2, e2e: record.e2e, steps: record.steps };
53
+ }
54
+ } catch {
55
+ }
56
+ }
57
+ mkdirSync(dir, { recursive: true });
58
+ const pkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf8"));
59
+ const scripts = pkg.scripts ?? {};
60
+ const has = (path) => existsSync(join(cwd, path));
61
+ const playwrightConfig = ["playwright.config.ts", "playwright.config.mts", "playwright.config.js", "playwright.config.mjs"].some(has);
62
+ const plans = [
63
+ scripts["validate:content"] === void 0 ? { name: "validate:content", reason: "package.json has no validate:content script", fail: true } : { name: "validate:content", command: scriptCommand("validate:content", []) },
64
+ scripts.content === void 0 ? { name: "check --changed", reason: "package.json has no content script", fail: true } : { name: "check --changed", command: scriptCommand("content", ["check", "--changed"]) },
65
+ // Without @astrojs/check, `astro check` stops to ask whether to install it.
66
+ has(join("node_modules", "@astrojs", "check", "package.json")) ? { name: "astro check", command: binCommand(cwd, "astro", ["check"]) } : { name: "astro check", reason: "@astrojs/check is not installed (pnpm add -D @astrojs/check typescript)" },
67
+ scripts.test === void 0 ? { name: "tests", reason: "package.json has no test script" } : { name: "tests", command: scriptCommand("test", []) },
68
+ { name: "astro build", command: binCommand(cwd, "astro", ["build"]) }
69
+ ];
70
+ if (e2e) {
71
+ plans.push(
72
+ scripts["test:e2e"] !== void 0 ? { name: "e2e", command: scriptCommand("test:e2e", []) } : playwrightConfig ? { name: "e2e", command: binCommand(cwd, "playwright", ["test"]) } : { name: "e2e", reason: "no test:e2e script and no playwright.config" }
73
+ );
74
+ }
75
+ const steps = [];
76
+ for (const plan of plans) {
77
+ options.onStep?.({ phase: "start", name: plan.name });
78
+ let step;
79
+ if (plan.command === void 0) {
80
+ step = { name: plan.name, status: plan.fail ? "fail" : "skipped", reason: plan.reason };
81
+ } else {
82
+ const log = join(dir, `${slug(plan.name)}.log`);
83
+ const fd = openSync(log, "w");
84
+ const started = Date.now();
85
+ let result;
86
+ try {
87
+ result = spawnSync(plan.command.file, plan.command.argv, {
88
+ cwd,
89
+ stdio: ["ignore", fd, fd],
90
+ shell: process.platform === "win32",
91
+ env: process.env
92
+ });
93
+ } finally {
94
+ closeSync(fd);
95
+ }
96
+ const seconds = Math.round((Date.now() - started) / 100) / 10;
97
+ const passed = result.error === void 0 && result.status === 0;
98
+ step = {
99
+ name: plan.name,
100
+ status: passed ? "pass" : "fail",
101
+ command: plan.command.display,
102
+ seconds,
103
+ log: relative(cwd, log),
104
+ ...result.error === void 0 ? {} : { reason: result.error.message },
105
+ ...passed ? {} : { tail: tailOf(log) }
106
+ };
107
+ }
108
+ steps.push(step);
109
+ options.onStep?.({ phase: "end", step });
110
+ }
111
+ const ok = steps.every((step) => step.status !== "fail");
112
+ const tree = verifyTreeHash(cwd);
113
+ if (ok && tree !== null) {
114
+ const record = { tree, e2e, when: (/* @__PURE__ */ new Date()).toISOString(), steps };
115
+ writeFileSync(recordFile, `${JSON.stringify(record, null, 2)}
116
+ `);
117
+ }
118
+ return { ok, cached: false, tree, e2e, steps };
119
+ }
120
+ function formatVerifyReport(report) {
121
+ if (report.cached) {
122
+ return `verify: PASS, unchanged since ${report.since} (${report.e2e ? "with" : "without"} e2e); --force reruns
123
+ `;
124
+ }
125
+ const out = [];
126
+ for (const step of report.steps) {
127
+ if (step.status !== "fail") continue;
128
+ out.push("", `${step.name} failed${step.reason === void 0 ? "" : `: ${step.reason}`}${step.log === void 0 ? "" : ` \u2014 last lines of ${step.log}:`}`);
129
+ for (const line of step.tail ?? []) out.push(` ${line}`);
130
+ }
131
+ const failed = report.steps.filter((step) => step.status === "fail").map((step) => step.name);
132
+ out.push(
133
+ "",
134
+ failed.length === 0 ? `verify: PASS${report.tree === null ? " (not a git repository: nothing recorded)" : ""}` : `verify: FAIL \u2014 ${failed.join(", ")}; whole output under ${VERIFY_DIR}/`
135
+ );
136
+ return `${out.join("\n").replace(/^\n/, "")}
137
+ `;
138
+ }
139
+ function formatVerifyStep(step) {
140
+ const verdict = step.status === "pass" ? "ok" : step.status === "fail" ? "FAIL" : "skipped";
141
+ const detail = step.seconds !== void 0 ? `${step.seconds.toFixed(1)}s` : step.reason ?? "";
142
+ return ` ${step.name.padEnd(18)} ${verdict.padEnd(7)} ${detail}
143
+ `;
144
+ }
145
+ export {
146
+ VERIFY_DIR,
147
+ formatVerifyReport,
148
+ formatVerifyStep,
149
+ runVerify,
150
+ verifyTreeHash
151
+ };
@@ -1,9 +1,9 @@
1
1
  import { DiagnosticSeverity } from '../reference/types.js';
2
2
  import { EquationLink, ScopeEntry } from '../reference/gate-math.js';
3
- import { ManifestLesson, Manifest } from './manifest.js';
3
+ import { ManifestLesson, ManifestLessonFloat, Manifest } from './manifest.js';
4
4
  import { RenderDiagnostic } from './render-diagnostics.js';
5
5
  import { RetiredRule } from '../rules/catalog.js';
6
- import { RuleInEffect, RulesFilter } from '../rules/in-effect.js';
6
+ import { RulesFilter, RuleInEffect } from '../rules/in-effect.js';
7
7
  import '../curriculum/validation.js';
8
8
  import '../reference/consistency.js';
9
9
  import '../course-config-BLtIdkei.js';
@@ -64,7 +64,7 @@ import './lesson-derivation.js';
64
64
  * under the `render:` source.
65
65
  */
66
66
 
67
- type ContentCliCode = 'unknown-command' | 'usage' | 'unknown-lesson' | 'unknown-kind' | 'invalid-id' | 'exists' | 'not-a-lesson' | 'no-git' | 'unknown-version' | 'migration-gap' | 'unknown-key';
67
+ type ContentCliCode = 'unknown-command' | 'usage' | 'unknown-lesson' | 'unknown-kind' | 'invalid-id' | 'exists' | 'not-a-lesson' | 'no-git' | 'unknown-version' | 'migration-gap' | 'unknown-key' | 'no-changelog';
68
68
  /** A user-facing failure with a stable code and no stack in `--json` output. */
69
69
  declare class ContentCliError extends Error {
70
70
  readonly code: ContentCliCode;
@@ -312,22 +312,10 @@ interface ContextReport {
312
312
  units?: string;
313
313
  formula?: string;
314
314
  }[];
315
- readonly equations: readonly {
316
- key: string;
317
- number: string;
318
- }[];
319
- readonly tables: readonly {
320
- key: string;
321
- number: string;
322
- }[];
323
- readonly figures: readonly {
324
- key: string;
325
- number: string;
326
- }[];
327
- readonly diagrams: readonly {
328
- key: string;
329
- number: string;
330
- }[];
315
+ readonly equations: readonly ManifestLessonFloat[];
316
+ readonly tables: readonly ManifestLessonFloat[];
317
+ readonly figures: readonly ManifestLessonFloat[];
318
+ readonly diagrams: readonly ManifestLessonFloat[];
331
319
  /** Worked-example sets: `short` is the title `[[ex-key]]` names. */
332
320
  readonly examples: readonly {
333
321
  key: string;
@@ -357,13 +345,6 @@ interface ContextReport {
357
345
  readonly diagnostics: readonly CliDiagnostic[];
358
346
  readonly tokens: TokenCounts;
359
347
  }
360
- /**
361
- * The migration guide, cut to the sections an upgrade needs: from the
362
- * version a course pins to the version it has installed, oldest first. The
363
- * guide is one newest-first file of every release; an upgrade reads one or
364
- * two sections of it, and the heading grammar `## <from> → <to>` (a `0.4.x`
365
- * range on the left, `Unreleased` allowed on the right) says which.
366
- */
367
348
  interface MigrationSection {
368
349
  readonly from: string;
369
350
  readonly to: string;
@@ -401,14 +382,6 @@ declare function contentMigrate(options?: {
401
382
  }): MigrateReport;
402
383
  /** The sections, oldest first, under one line that says how much is coming. */
403
384
  declare function formatMigrateReport(report: MigrateReport): string;
404
- /**
405
- * What a lesson's math binds, span by span, before a build says so: each
406
- * glyph and the card it resolves to, how (`canonical`, `family`, `derived`,
407
- * `base`, `operator`, or an explicit `\explain` wrap), the wraps the bare
408
- * symbol would have resolved without, and every unresolved glyph with where
409
- * `content symbol` finds it defined. The same resolver and scope the
410
- * completeness gate uses, so what it prints is what the build will do.
411
- */
412
385
  interface SpanBinding {
413
386
  readonly token: string;
414
387
  readonly key: string;
@@ -503,11 +476,6 @@ declare function contentEquation(manifest: Manifest, latex: string, options?: {
503
476
  readonly lesson?: string;
504
477
  }): EquationReport;
505
478
  declare function formatEquationReport(report: EquationReport): string;
506
- /**
507
- * The refactors a model gets wrong most and reads most for, as deterministic
508
- * edits: every one is planned first (`FileEdit`s with the whole text before
509
- * and after), shown as a unified diff, and written only on `--write`.
510
- */
511
479
  interface FileEdit {
512
480
  /** Course-relative path. */
513
481
  readonly file: string;
@@ -537,10 +505,17 @@ declare function removeLocalItem(text: string, key: string): {
537
505
  text: string;
538
506
  removed: boolean;
539
507
  };
540
- /** A shared card's file from a page-local entry's fields, nothing invented. */
508
+ /**
509
+ * A shared card's file from a page-local entry's fields, nothing invented: no
510
+ * body (the glossary shows the meaning; what a reader needs beyond it is the
511
+ * author's to write, and a placeholder would publish), and the label only
512
+ * when the entry has one. `domain` is the caller's pick, else the key's
513
+ * prefix, else `general`.
514
+ */
541
515
  declare function sharedCardFromLocal(local: Record<string, unknown>, options: {
542
516
  readonly key: string;
543
517
  readonly fromLesson: string;
518
+ readonly domain?: string;
544
519
  }): string;
545
520
  /**
546
521
  * `content move <lesson>:<key> [--to <key>]`: a page-local entry becomes a
@@ -559,6 +534,8 @@ declare function planRename(manifest: Manifest, oldKey: string, newKey: string):
559
534
  * becomes `inner`.
560
535
  */
561
536
  declare function planFix(manifest: Manifest, lessonId: string): EditPlan;
537
+ /** `content fix` over several lessons (or every one, `--all`) as one plan: the lessons with nothing to fix are counted, not listed. */
538
+ declare function planFixMany(manifest: Manifest, lessonIds: readonly string[]): EditPlan;
562
539
  /** The plan as a unified diff (git's), file by file, under its summary lines. */
563
540
  declare function renderEditPlan(plan: EditPlan): string;
564
541
  /** Write a plan's edits. Returns the files touched. */
@@ -614,15 +591,7 @@ declare function contentRule(manifest: Manifest, rawId: string): RuleReport;
614
591
  /** The text form of a rule report: the entry's facts, then the page section. */
615
592
  declare function formatRuleReport(report: RuleReport): string;
616
593
  declare function contentContext(manifest: Manifest, rawId: string): ContextReport;
617
- /**
618
- * Where a symbol is already defined, asked before an author declares it
619
- * again. Reads the registry the manifest holds: shared cards, page-local
620
- * entries (`notation.local`) and glosses (a card's `glosses:`), matched on
621
- * the latex as written, a shared card's `aliases`, or the key. A `contains`
622
- * list follows for a letter that sits inside a longer notation (`v` in
623
- * `v_0`), so a near miss is visible too. Read-only; every list is sorted,
624
- * so two runs print the same report.
625
- */
594
+ /** Where a hit is defined: a shared card, a lesson's `notation.local`, or a card's gloss. */
626
595
  type SymbolTier = 'shared' | 'local' | 'gloss';
627
596
  interface SymbolHit {
628
597
  readonly tier: SymbolTier;
@@ -716,4 +685,4 @@ declare function scaffoldLesson(rawId: string): Scaffold;
716
685
  declare function scaffoldDeck(rawId: string): Scaffold;
717
686
  declare function scaffoldTerm(rawKey: string): Scaffold;
718
687
 
719
- export { type BindingsReport, type BindingsSpan, type CheckLessonResult, type CheckReport, type CheckTarget, type CliDiagnostic, type ContentCliCode, ContentCliError, type ContextReport, type DiagnosticThreshold, type DiagnosticsReport, type DiagnosticsSummary, type DomainFileSignatures, type EditPlan, type EquationReport, type ExplicitWrap, type FileEdit, type MigrateReport, type MigrationSection, type RenderResult, type RuleReport, type RulesReport, type Scaffold, type SpanBinding, type SpanBindings, type StatusReport, type SymbolHit, type SymbolReport, type SymbolTier, type TokenCounts, type UnresolvedGlyph, type WarningsReport, applyEditPlan, authoringDocsDir, changedLessonIds, contentBindings, contentCheck, contentContext, contentDiagnostics, contentEquation, contentMigrate, contentRule, contentRules, contentStatus, contentSymbol, contentWarnings, courseAgentDir, estimateTokens, filterDiagnostics, findCheckTarget, findLesson, formatBindingsReport, formatCliDiagnostic, formatDiagnosticsSummary, formatEquationReport, formatMigrateReport, formatRuleReport, formatSymbolReport, installedEngineVersion, lessonDiagnostics, lessonSource, normalizeLessonId, ownerTag, parseMigrationSections, parseRulesFilter, pinnedEngineVersion, planFix, planInitAgent, planMove, planRename, removeLocalItem, renderEditPlan, renderLesson, rewriteKeyReferences, ruleSection, scaffoldDeck, scaffoldLesson, scaffoldTerm, selectMigrationPath, sharedCardFromLocal, spanBindings, summarizeDiagnostics };
688
+ export { type BindingsReport, type BindingsSpan, type CheckLessonResult, type CheckReport, type CheckTarget, type CliDiagnostic, type ContentCliCode, ContentCliError, type ContextReport, type DiagnosticThreshold, type DiagnosticsReport, type DiagnosticsSummary, type DomainFileSignatures, type EditPlan, type EquationReport, type ExplicitWrap, type FileEdit, type MigrateReport, type MigrationSection, type RenderResult, type RuleReport, type RulesReport, type Scaffold, type SpanBinding, type SpanBindings, type StatusReport, type SymbolHit, type SymbolReport, type SymbolTier, type TokenCounts, type UnresolvedGlyph, type WarningsReport, applyEditPlan, authoringDocsDir, changedLessonIds, contentBindings, contentCheck, contentContext, contentDiagnostics, contentEquation, contentMigrate, contentRule, contentRules, contentStatus, contentSymbol, contentWarnings, courseAgentDir, estimateTokens, filterDiagnostics, findCheckTarget, findLesson, formatBindingsReport, formatCliDiagnostic, formatDiagnosticsSummary, formatEquationReport, formatMigrateReport, formatRuleReport, formatSymbolReport, installedEngineVersion, lessonDiagnostics, lessonSource, normalizeLessonId, ownerTag, parseMigrationSections, parseRulesFilter, pinnedEngineVersion, planFix, planFixMany, planInitAgent, planMove, planRename, removeLocalItem, renderEditPlan, renderLesson, rewriteKeyReferences, ruleSection, scaffoldDeck, scaffoldLesson, scaffoldTerm, selectMigrationPath, sharedCardFromLocal, spanBindings, summarizeDiagnostics };
@@ -1121,7 +1121,7 @@ function yamlBlock(value, indent) {
1121
1121
  }
1122
1122
  function sharedCardFromLocal(local, options) {
1123
1123
  const key = options.key;
1124
- const domain = key.includes(".") ? key.slice(0, key.indexOf(".")) : "general";
1124
+ const domain = options.domain ?? (key.includes(".") ? key.slice(0, key.indexOf(".")) : "general");
1125
1125
  const front = {
1126
1126
  key,
1127
1127
  latex: local.latex,
@@ -1143,10 +1143,22 @@ function sharedCardFromLocal(local, options) {
1143
1143
  return `---
1144
1144
  ${yamlBlock(front, 0).join("\n")}
1145
1145
  ---
1146
-
1147
- Promoted from \`${options.fromLesson}\`'s \`notation.local\`; the meaning above is what that page said. Extend this body with what a glossary reader needs.
1148
1146
  `;
1149
1147
  }
1148
+ function promotedDomain(manifest, lesson, key) {
1149
+ const shared = manifest.notation.definitions.filter((definition) => definition.kind === "shared");
1150
+ const prefix = key.includes(".") ? key.slice(0, key.indexOf(".")) : void 0;
1151
+ if (prefix !== void 0 && shared.some((definition) => definition.domain === prefix)) return prefix;
1152
+ const mostCommon = (definitions) => {
1153
+ const counts = /* @__PURE__ */ new Map();
1154
+ for (const definition of definitions) {
1155
+ if (definition.domain) counts.set(definition.domain, (counts.get(definition.domain) ?? 0) + 1);
1156
+ }
1157
+ return [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0]?.[0];
1158
+ };
1159
+ const bound = new Set(lesson.notation.definitionIds);
1160
+ return mostCommon(shared.filter((definition) => bound.has(definition.id))) ?? mostCommon(shared) ?? prefix ?? "general";
1161
+ }
1150
1162
  function courseTextFiles() {
1151
1163
  const out = [];
1152
1164
  const walk = (dir) => {
@@ -1195,8 +1207,20 @@ function planMove(manifest, lessonId, key, options = {}) {
1195
1207
  }
1196
1208
  const summary = [];
1197
1209
  const edits = [];
1198
- edits.push({ file: target, before: null, after: sharedCardFromLocal(source, { key: newKey, fromLesson: lesson.id }) });
1199
- summary.push(`${target}: new shared card from ${lesson.id}'s entry${newKey === key ? "" : ` (key ${key} \u2192 ${newKey})`}`);
1210
+ const domain = promotedDomain(manifest, lesson, newKey);
1211
+ const glyph = typeof source.latex === "string" ? source.latex.trim() : "";
1212
+ const owner = manifest.notation.definitions.find(
1213
+ (definition) => definition.kind === "shared" && definition.key !== newKey && definition.notation.trim() === glyph
1214
+ );
1215
+ if (owner !== void 0) {
1216
+ summary.push(
1217
+ `CAUTION: ${glyph} is already shared card ${owner.key}'s \u2014 a second shared card on one glyph is a glyph-unique-in-corpus finding; a reused letter stays local (rule N4). Write this only if the two cards are meant to share the glyph, and acknowledge the pair.`
1218
+ );
1219
+ }
1220
+ edits.push({ file: target, before: null, after: sharedCardFromLocal(source, { key: newKey, fromLesson: lesson.id, domain }) });
1221
+ summary.push(
1222
+ `${target}: new shared card from ${lesson.id}'s entry${newKey === key ? "" : ` (key ${key} \u2192 ${newKey})`}, domain ${domain}; no body yet \u2014 add what a glossary reader needs beyond the meaning` + (source.label === void 0 ? "; no label in the entry \u2014 the card shows one made from the key until you add `label:`" : "")
1223
+ );
1200
1224
  const touched = /* @__PURE__ */ new Set();
1201
1225
  for (const other of manifest.lessons) {
1202
1226
  const copy = local(other);
@@ -1285,6 +1309,20 @@ function planFix(manifest, lessonId) {
1285
1309
  next: [`pnpm content check ${lesson.id}`]
1286
1310
  };
1287
1311
  }
1312
+ function planFixMany(manifest, lessonIds) {
1313
+ if (lessonIds.length === 1) return planFix(manifest, lessonIds[0]);
1314
+ const plans = lessonIds.map((id) => planFix(manifest, id));
1315
+ const fixing = plans.filter((plan) => plan.edits.length > 0);
1316
+ const clean = plans.length - fixing.length;
1317
+ return {
1318
+ summary: [
1319
+ ...fixing.flatMap((plan) => plan.summary),
1320
+ ...clean > 0 ? [`${clean} lesson(s) with nothing to fix`] : []
1321
+ ],
1322
+ edits: fixing.flatMap((plan) => plan.edits),
1323
+ next: ["pnpm content check --changed"]
1324
+ };
1325
+ }
1288
1326
  function renderEditPlan(plan) {
1289
1327
  const lines = plan.summary.map((line) => ` ${line}`);
1290
1328
  const scratch = join(REPOSITORY_ROOT, ".agent", "content-edits");
@@ -1354,7 +1392,7 @@ function planInitAgent() {
1354
1392
  const before = existsSync(target) ? readFileSync(target, "utf8") : null;
1355
1393
  if (before === after) return;
1356
1394
  edits.push({ file: to, before, after });
1357
- summary.push(`${to}: ${before === null ? "installed" : "refreshed from the engine"}`);
1395
+ summary.push(`${to}: ${before === null ? "new" : "differs from the engine's copy"}`);
1358
1396
  };
1359
1397
  for (const file of filesUnder(join(source, "skills"))) install(`skills/${file}`, `.claude/skills/${file}`);
1360
1398
  for (const file of filesUnder(join(source, "agents"))) install(`agents/${file}`, `.claude/agents/${file}`);
@@ -2079,6 +2117,7 @@ export {
2079
2117
  parseRulesFilter,
2080
2118
  pinnedEngineVersion,
2081
2119
  planFix,
2120
+ planFixMany,
2082
2121
  planInitAgent,
2083
2122
  planMove,
2084
2123
  planRename,
@@ -0,0 +1,49 @@
1
+ import { EditPlan } from './cli.js';
2
+ import '../reference/types.js';
3
+ import '../reference/gate-math.js';
4
+ import './manifest.js';
5
+ import '../curriculum/validation.js';
6
+ import '../reference/consistency.js';
7
+ import '../course-config-BLtIdkei.js';
8
+ import '../rules/catalog.js';
9
+ import '../rules/course-rules.js';
10
+ import './collections.js';
11
+ import '../reference/gate-retired-keys.js';
12
+ import '../reference/lint-ignore.js';
13
+ import '../reference/equations-validate.js';
14
+ import '../reference/tables-validate.js';
15
+ import '../reference/keyed-float-validate.js';
16
+ import '../reference/figures-validate.js';
17
+ import '../reference/diagrams-validate.js';
18
+ import '../reference/examples-validate.js';
19
+ import '../reference/folds-validate.js';
20
+ import '../reference/knowledge-checks-validate.js';
21
+ import '../reference/raw-floats-validate.js';
22
+ import '../reference/lab-katex-validate.js';
23
+ import '../reference/mixed-content.js';
24
+ import '../reference/gloss.js';
25
+ import '../rules/waivers.js';
26
+ import './knowledge-version.js';
27
+ import './page-versions.js';
28
+ import './decks.js';
29
+ import '../reference/decks-validate.js';
30
+ import '../reference/deck-source.js';
31
+ import './lesson-derivation.js';
32
+ import './render-diagnostics.js';
33
+ import '../rules/in-effect.js';
34
+
35
+ type ReleaseBump = 'major' | 'minor' | 'patch';
36
+ interface ReleaseEdit {
37
+ readonly from: string;
38
+ readonly to: string;
39
+ readonly bump: ReleaseBump;
40
+ /** True when the bump was read from the section's headings, not asked for. */
41
+ readonly inferred: boolean;
42
+ readonly changelog: string;
43
+ readonly config: string;
44
+ }
45
+ /** The release of these two files' text on `date` (`YYYY-MM-DD`); `null` when `[Unreleased]` has no entry. */
46
+ declare function releaseEdit(changelog: string, config: string, date: string, requested?: ReleaseBump): ReleaseEdit | null;
47
+ declare function planContentRelease(requested?: string, date?: string): EditPlan;
48
+
49
+ export { type ReleaseBump, type ReleaseEdit, planContentRelease, releaseEdit };
@@ -0,0 +1,63 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { ContentCliError } from "./cli.js";
4
+ const CHANGELOG = "content/CHANGELOG.md";
5
+ const CONFIG = "content/course.config.ts";
6
+ function releaseEdit(changelog, config, date, requested) {
7
+ const unreleased = /^## \[Unreleased\]\r?$/m.exec(changelog);
8
+ if (!unreleased) throw new ContentCliError("no-changelog", `${CHANGELOG} has no "## [Unreleased]" heading.`);
9
+ const bodyStart = unreleased.index + unreleased[0].length;
10
+ const released = /^## \[\d+\.\d+\.\d+\](?: - .*?)?\r?$/m.exec(changelog.slice(bodyStart));
11
+ if (!released) throw new ContentCliError("no-changelog", `${CHANGELOG} has no released "## [x.y.z]" heading below "## [Unreleased]".`);
12
+ const bodyEnd = bodyStart + released.index;
13
+ const body = changelog.slice(bodyStart, bodyEnd);
14
+ if (!/^-\s+/m.test(body)) return null;
15
+ const version = /(about:\s*\{[\s\S]*?\bversion:\s*['"])(\d+)\.(\d+)\.(\d+)(['"])/.exec(config);
16
+ if (!version) throw new ContentCliError("unknown-version", `No about.version "x.y.z" in ${CONFIG}.`);
17
+ const [major, minor, patch] = version.slice(2, 5).map(Number);
18
+ const implied = /^### (Breaking|Removed)\s*$/m.test(body) ? "major" : /^### Added\s*$/m.test(body) ? "minor" : "patch";
19
+ const bump = requested ?? implied;
20
+ const to = bump === "major" ? `${major + 1}.0.0` : bump === "minor" ? `${major}.${minor + 1}.0` : `${major}.${minor}.${patch + 1}`;
21
+ return {
22
+ from: `${major}.${minor}.${patch}`,
23
+ to,
24
+ bump,
25
+ inferred: requested === void 0,
26
+ changelog: `${changelog.slice(0, bodyStart)}
27
+
28
+ ## [${to}] - ${date}${body}${changelog.slice(bodyEnd)}`,
29
+ config: config.replace(version[0], `${version[1]}${to}${version[5]}`)
30
+ };
31
+ }
32
+ function localDate(now = /* @__PURE__ */ new Date()) {
33
+ return [now.getFullYear(), now.getMonth() + 1, now.getDate()].map((n, i) => String(n).padStart(i === 0 ? 4 : 2, "0")).join("-");
34
+ }
35
+ function planContentRelease(requested, date = localDate()) {
36
+ if (requested !== void 0 && requested !== "major" && requested !== "minor" && requested !== "patch") {
37
+ throw new ContentCliError("usage", `content release takes major, minor or patch; got ${requested}.`);
38
+ }
39
+ for (const file of [CHANGELOG, CONFIG]) {
40
+ if (!existsSync(join(process.cwd(), file))) throw new ContentCliError("no-changelog", `No ${file} in this course.`);
41
+ }
42
+ const changelog = readFileSync(join(process.cwd(), CHANGELOG), "utf8");
43
+ const config = readFileSync(join(process.cwd(), CONFIG), "utf8");
44
+ const edit = releaseEdit(changelog, config, date, requested);
45
+ if (edit === null) {
46
+ return { summary: ["## [Unreleased] has no entries: nothing to release"], edits: [], next: [] };
47
+ }
48
+ return {
49
+ summary: [
50
+ `about.version ${edit.from} \u2192 ${edit.to} (${edit.bump}${edit.inferred ? ", from the section's headings" : ""})`,
51
+ `${CHANGELOG}: ## [Unreleased] \u2192 ## [${edit.to}] - ${date}, a fresh ## [Unreleased] above it`
52
+ ],
53
+ edits: [
54
+ { file: CHANGELOG, before: changelog, after: edit.changelog },
55
+ { file: CONFIG, before: config, after: edit.config }
56
+ ],
57
+ next: ["pnpm validate:content (records the release in content/versions.json)", "commit the changelog, the config and content/versions.json together"]
58
+ };
59
+ }
60
+ export {
61
+ planContentRelease,
62
+ releaseEdit
63
+ };
@@ -178,9 +178,9 @@ declare const trackSchema: z.ZodObject<{
178
178
  declare const sourceSchema: z.ZodObject<{
179
179
  id: z.ZodString;
180
180
  type: z.ZodEnum<{
181
+ paper: "paper";
181
182
  primary: "primary";
182
183
  "official-guidance": "official-guidance";
183
- paper: "paper";
184
184
  book: "book";
185
185
  secondary: "secondary";
186
186
  }>;
@@ -359,9 +359,9 @@ declare const CONTENT_SCHEMAS: {
359
359
  readonly sources: z.ZodObject<{
360
360
  id: z.ZodString;
361
361
  type: z.ZodEnum<{
362
+ paper: "paper";
362
363
  primary: "primary";
363
364
  "official-guidance": "official-guidance";
364
- paper: "paper";
365
365
  book: "book";
366
366
  secondary: "secondary";
367
367
  }>;
@@ -112,6 +112,7 @@ import {
112
112
  resolveKnowledgeVersion
113
113
  } from "./knowledge-version.js";
114
114
  import {
115
+ deckStaleDiagnostics,
115
116
  nextPageVersions,
116
117
  PAGE_VERSIONS_FILE,
117
118
  pageSourceDigest,
@@ -1170,6 +1171,32 @@ async function compileManifest(courseConfig) {
1170
1171
  disclaimer: courseConfig.about.disclaimer,
1171
1172
  ...courseConfig.about.changelogHref === void 0 ? {} : { changelogHref: courseConfig.about.changelogHref }
1172
1173
  };
1174
+ const lessonsWithSince = lessons.map((lesson) => ({ ...lesson, since: sinceOfPage(lesson.slug) }));
1175
+ const decksWithSince = deckModel.decks.map((deck) => {
1176
+ const page = pageInputById.get(deck.pageId);
1177
+ return {
1178
+ ...deck,
1179
+ since: sinceOfPage(deck.slug),
1180
+ notation: lessonNotationBundle(
1181
+ bundlesByLesson.get(deck.pageId),
1182
+ definitionsById,
1183
+ page === void 0 ? [] : lessonNotationResolution(page, definitionsById, availableDefinitionIds),
1184
+ cardsByKey,
1185
+ // A deck shows its lessons' symbols: the derived cards (rule N23)
1186
+ // its chapters' bodies and checks use ship with it, as its bundle
1187
+ // holds the chapters' entries.
1188
+ [
1189
+ ...indexGate.derivedByLesson.get(deck.pageId) ?? [],
1190
+ ...(page?.scopeLessonIds ?? []).flatMap((lessonId) => [
1191
+ ...indexGate.derivedByLesson.get(lessonId) ?? [],
1192
+ ...assessmentDerived.get(lessonId)?.values() ?? []
1193
+ ])
1194
+ ].filter((use, index, all) => all.findIndex((other) => other.key === use.key) === index)
1195
+ )
1196
+ };
1197
+ });
1198
+ const chapterSince = new Map(lessonsWithSince.map((lesson) => [lesson.slug, lesson.since]));
1199
+ mathIssues.push(...deckStaleDiagnostics(decksWithSince, chapterSince));
1173
1200
  return {
1174
1201
  schemaVersion: MANIFEST_SCHEMA_VERSION,
1175
1202
  lessonExports: normalizeLessonExports(courseConfig),
@@ -1182,29 +1209,7 @@ async function compileManifest(courseConfig) {
1182
1209
  readingMemory: normalizeReadingMemory(courseConfig),
1183
1210
  appearance: normalizeAppearance(courseConfig),
1184
1211
  deck: deckSettings,
1185
- decks: deckModel.decks.map((deck) => {
1186
- const page = pageInputById.get(deck.pageId);
1187
- return {
1188
- ...deck,
1189
- since: sinceOfPage(deck.slug),
1190
- notation: lessonNotationBundle(
1191
- bundlesByLesson.get(deck.pageId),
1192
- definitionsById,
1193
- page === void 0 ? [] : lessonNotationResolution(page, definitionsById, availableDefinitionIds),
1194
- cardsByKey,
1195
- // A deck shows its lessons' symbols: the derived cards (rule N23)
1196
- // its chapters' bodies and checks use ship with it, as its bundle
1197
- // holds the chapters' entries.
1198
- [
1199
- ...indexGate.derivedByLesson.get(deck.pageId) ?? [],
1200
- ...(page?.scopeLessonIds ?? []).flatMap((lessonId) => [
1201
- ...indexGate.derivedByLesson.get(lessonId) ?? [],
1202
- ...assessmentDerived.get(lessonId)?.values() ?? []
1203
- ])
1204
- ].filter((use, index, all) => all.findIndex((other) => other.key === use.key) === index)
1205
- )
1206
- };
1207
- }),
1212
+ decks: decksWithSince,
1208
1213
  about,
1209
1214
  notation: {
1210
1215
  definitions: registry.definitions,
@@ -1221,7 +1226,7 @@ async function compileManifest(courseConfig) {
1221
1226
  parts: manifestParts.map(
1222
1227
  ({ overview, ...part }) => overview ? { ...part, overview: { ...overview, since: sinceOfPage(`${part.dir}/index`) } } : part
1223
1228
  ),
1224
- lessons: lessons.map((lesson) => ({ ...lesson, since: sinceOfPage(lesson.slug) })),
1229
+ lessons: lessonsWithSince,
1225
1230
  pageVersions,
1226
1231
  pages,
1227
1232
  prereqEdges,
@@ -156,5 +156,29 @@ declare function syncPageVersions(manifest: {
156
156
  readonly frozen?: boolean;
157
157
  readonly root?: string;
158
158
  }): PageVersionsSync;
159
+ /**
160
+ * Rule D11, shaped like a `MathGateDiagnostic` (`src/reference/gate-math.ts`)
161
+ * so it can join `diagnostics.math` beside D1–D10's frame codes, without
162
+ * this module importing from `src/reference/`.
163
+ */
164
+ interface DeckStaleDiagnostic {
165
+ readonly context: 'structure';
166
+ readonly severity: 'info';
167
+ readonly code: 'deck-stale';
168
+ readonly file: string;
169
+ readonly message: string;
170
+ }
171
+ /**
172
+ * Rule D11: nothing else would notice a lesson's chapter and deck drifting
173
+ * apart, since two files keep each edition readable at the cost of that one
174
+ * check. Flags a deck whose chapter's `since` (rule C5) is newer than the
175
+ * deck's own. A part's deck has no one chapter to compare against, so a
176
+ * deck without a `chapter` (its `BuiltDeck.chapter`) is skipped.
177
+ */
178
+ declare function deckStaleDiagnostics(decks: readonly {
179
+ readonly file: string;
180
+ readonly chapter?: string;
181
+ readonly since: string | null;
182
+ }[], chapterSince: ReadonlyMap<string, string | null>): DeckStaleDiagnostic[];
159
183
 
160
- export { type ManifestPageVersions, type NextPageVersionsInput, PAGE_VERSIONS_FILE, PAGE_VERSIONS_FORMAT, PAGE_VERSIONS_GENERATED_BY, type PageVersionEntry, type PageVersionsRecord, type PageVersionsStaleDiagnostic, type PageVersionsSync, compareKnowledgeVersions, newestSince, nextPageVersions, pageSourceDigest, pageVersionsFrozen, readPageVersions, serializePageVersions, sinceOf, syncPageVersions, versionPhrase };
184
+ export { type DeckStaleDiagnostic, type ManifestPageVersions, type NextPageVersionsInput, PAGE_VERSIONS_FILE, PAGE_VERSIONS_FORMAT, PAGE_VERSIONS_GENERATED_BY, type PageVersionEntry, type PageVersionsRecord, type PageVersionsStaleDiagnostic, type PageVersionsSync, compareKnowledgeVersions, deckStaleDiagnostics, newestSince, nextPageVersions, pageSourceDigest, pageVersionsFrozen, readPageVersions, serializePageVersions, sinceOf, syncPageVersions, versionPhrase };