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
@@ -181,11 +181,30 @@ function syncPageVersions(manifest, options = {}) {
181
181
  writeFileSync(path, expected);
182
182
  return { path, stale: true, written: true, changed };
183
183
  }
184
+ function deckStaleDiagnostics(decks, chapterSince) {
185
+ const out = [];
186
+ for (const deck of decks) {
187
+ if (deck.chapter === void 0) continue;
188
+ const chapterVersion = chapterSince.get(deck.chapter);
189
+ if (chapterVersion === void 0) continue;
190
+ if (compareKnowledgeVersions(chapterVersion, deck.since) <= 0) continue;
191
+ const chapterChange = chapterVersion === null ? "has unreleased changes" : `changed in version ${chapterVersion}`;
192
+ out.push({
193
+ context: "structure",
194
+ severity: "info",
195
+ code: "deck-stale",
196
+ file: deck.file,
197
+ message: `The chapter ${chapterChange}; this deck is still from version ${deck.since}.`
198
+ });
199
+ }
200
+ return out;
201
+ }
184
202
  export {
185
203
  PAGE_VERSIONS_FILE,
186
204
  PAGE_VERSIONS_FORMAT,
187
205
  PAGE_VERSIONS_GENERATED_BY,
188
206
  compareKnowledgeVersions,
207
+ deckStaleDiagnostics,
189
208
  newestSince,
190
209
  nextPageVersions,
191
210
  pageSourceDigest,
@@ -86,7 +86,7 @@ Run log: ${summary2.relativeDirectory}.`);
86
86
  const consistencyWarnings = consistency.filter((diagnostic) => diagnostic.severity === "warning");
87
87
  if (consistencyWarnings.length > 0) {
88
88
  console.warn(
89
- log.warning(
89
+ log.info(
90
90
  "consistency",
91
91
  `[consistency] ${consistencyWarnings.length} Tier-3 finding(s): ` + Object.entries(consistencyCounts(consistencyWarnings)).filter(([, count]) => count > 0).map(([code, count]) => `${code}=${count}`).join(", ")
92
92
  )
@@ -94,7 +94,7 @@ Run log: ${summary2.relativeDirectory}.`);
94
94
  }
95
95
  log.infoDiagnostics(manifest.diagnostics);
96
96
  for (const diagnostic of consistencyWarnings) {
97
- log.info("consistency", `[${diagnostic.code}] ${diagnostic.message}`);
97
+ log.warning("consistency", `[${diagnostic.code}] ${diagnostic.message}`);
98
98
  }
99
99
  const reports = syncResolutionReports(manifest);
100
100
  console.log(
@@ -159,6 +159,10 @@ function checkDefinitionRepeated(input, out) {
159
159
  byKey.set(definition.key, list);
160
160
  }
161
161
  }
162
+ const sharedByGlyph = /* @__PURE__ */ new Map();
163
+ for (const definition of input.notationInput.sharedDefinitions) {
164
+ if (!sharedByGlyph.has(definition.notation.trim())) sharedByGlyph.set(definition.notation.trim(), definition.key);
165
+ }
162
166
  for (const [key, copies] of [...byKey].sort(([a], [b]) => a.localeCompare(b))) {
163
167
  if (copies.length < 2) continue;
164
168
  if (input.lintIgnore.matches("definition-repeated", key)) continue;
@@ -167,6 +171,8 @@ function checkDefinitionRepeated(input, out) {
167
171
  (field) => copies.some((copy) => JSON.stringify(copy.definition[field] ?? null) !== JSON.stringify(first.definition[field] ?? null))
168
172
  );
169
173
  const lessons = copies.map((copy) => copy.lessonId).sort();
174
+ const owner = sharedByGlyph.get(first.definition.notation.trim());
175
+ const advice = owner !== void 0 && owner !== key ? ` \u2014 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} \u2014 <why>"), or, if they are two meanings, give one another key` : ` \u2014 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`;
170
176
  out.push({
171
177
  code: "definition-repeated",
172
178
  severity: "warning",
@@ -174,7 +180,7 @@ function checkDefinitionRepeated(input, out) {
174
180
  lessonId: first.lessonId,
175
181
  file: first.file,
176
182
  count: copies.length,
177
- message: `local entry ${key} (${first.definition.notation}) is declared in ${copies.length} lessons (${lessons.join(", ")}); ` + (differing.length === 0 ? "the copies agree" : `the copies differ in ${differing.join(", ")}`) + ` \u2014 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`
183
+ message: `local entry ${key} (${first.definition.notation}) is declared in ${copies.length} lessons (${lessons.join(", ")}); ` + (differing.length === 0 ? "the copies agree" : `the copies differ in ${differing.join(", ")}`) + advice
178
184
  });
179
185
  }
180
186
  }
@@ -29,7 +29,11 @@ declare const DEFAULT_COLUMN_WIDTH = 707;
29
29
  * `warnAboveWidth` replaces the course's column width for that diagram.
30
30
  */
31
31
  declare function diagramWidthLimit(warnAboveWidth: number | false | undefined, rules: Pick<ManifestRules, 'disabled' | 'settings'> | undefined): number | undefined;
32
- /** What to tell the author about one diagram wider than its limit. */
32
+ /**
33
+ * What to tell the author about one diagram wider than its limit. The
34
+ * competency graph is `<CurriculumMap>`'s, whose nodes the author does not
35
+ * write, so its advice names the map's own props.
36
+ */
33
37
  declare function diagramTooWideMessage(id: string, width: number, limit: number): string;
34
38
  /** A diagram on a built page that is wider than its limit. */
35
39
  interface OversizedDiagram {
@@ -7,7 +7,11 @@ function diagramWidthLimit(warnAboveWidth, rules) {
7
7
  return typeof columnWidth === "number" ? columnWidth : DEFAULT_COLUMN_WIDTH;
8
8
  }
9
9
  function diagramTooWideMessage(id, width, limit) {
10
- return `Diagram "${id}" lays out ${Math.round(width)}px wide, wider than the ${limit}px it may take (rule V8), so a reader has to scroll it or widen it to see it whole. Try direction="TB", a shorter label, or a narrower node \`width\` (its label wraps inside it); set warnAboveWidth={false} only when the diagram is meant to scroll.`;
10
+ const head = `Diagram "${id}" lays out ${Math.round(width)}px wide, wider than the ${limit}px it may take (rule V8), so a reader has to scroll it or widen it to see it whole. `;
11
+ if (id.startsWith("competency-graph")) {
12
+ return head + `It is <CurriculumMap>'s competency graph: try graph="per-part" (one graph per part) and direction="LR" or "TB" on the map; set warnAboveWidth={false} there when no layout fits and the graph is meant to scroll.`;
13
+ }
14
+ return head + `Try direction="TB", a shorter label, or a narrower node \`width\` (its label wraps inside it); set warnAboveWidth={false} only when the diagram is meant to scroll.`;
11
15
  }
12
16
  function isElement(node) {
13
17
  return "tagName" in node;
@@ -1050,6 +1050,22 @@ const RULES = [
1050
1050
  doc: "deck.md#d10",
1051
1051
  summary: "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.",
1052
1052
  checks: []
1053
+ },
1054
+ {
1055
+ kind: "rule",
1056
+ id: "D11",
1057
+ editions: "deck",
1058
+ short: "A deck is flagged when its chapter changed after it did",
1059
+ title: "A deck is flagged when its chapter changed, by rule C5\u2019s version, after the deck itself last did.",
1060
+ elements: ["slide", "changelog"],
1061
+ doc: "deck.md#d11",
1062
+ summary: "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\u2019s own deck whose chapter\u2019s rule C5 version is newer than the deck\u2019s; read the chapter\u2019s change and judge whether the deck needs it too. Scoped to a lesson\u2019s own deck \u2014 a part\u2019s deck covers more than one chapter, so it has none to compare against.",
1063
+ checks: [
1064
+ check("deck-stale", "info", false, {
1065
+ module: "src/compiler/page-versions.ts",
1066
+ fn: "deckStaleDiagnostics"
1067
+ })
1068
+ ]
1053
1069
  }
1054
1070
  ];
1055
1071
  const RETIRED = [
@@ -1,4 +1,4 @@
1
- import { Element, Enforcement, Editions, Edition } from './catalog.js';
1
+ import { Element, Enforcement, Edition, Editions } from './catalog.js';
2
2
  import { M as ManifestRules } from '../course-config-BLtIdkei.js';
3
3
  import './course-rules.js';
4
4
 
@@ -93,7 +93,8 @@ function renderRulesInEffect(input, filter = {}) {
93
93
  ""
94
94
  ] : [],
95
95
  "Generated by `pnpm validate:content` (and `pnpm content rules`) from the rule",
96
- "catalog and `rules` / `courseRules` in `course.settings.json`. Do not edit. Read this before",
96
+ "catalog and the course's `rules` / `courseRules` (`course.settings.json`, or",
97
+ "`course.config.ts`). Do not edit. Read this before",
97
98
  "drafting, and open a rule page only when it is listed here and you need the",
98
99
  "example: a rule absent from this page is either off for this course or does",
99
100
  "not exist, and is neither applied nor flagged in review.",
@@ -104,8 +105,8 @@ function renderRulesInEffect(input, filter = {}) {
104
105
  ].filter(Boolean).join("; ")} \u2014 ${visible.length} shown.` : ""),
105
106
  "",
106
107
  "A line is `ID \xB7 enforcement \xB7 codes \u2014 what to do (settings in effect) \u2192 full",
107
- "text`. **course**: this course wrote the rule in `courseRules` in",
108
- "`course.settings.json`, where it can be edited; the `X` area is reserved for",
108
+ "text`. **course**: this course wrote the rule in `courseRules` (in",
109
+ "`course.settings.json` or `course.config.ts`), where it can be edited; the `X` area is reserved for",
109
110
  "them. **script**: the build reports every violation under those codes.",
110
111
  "**partial**: the build reports the codes; the rest is on you. **guide**: nothing",
111
112
  "runs; it is on you. **locked**: script, and the course cannot turn it off.",
@@ -134,7 +135,7 @@ function renderRulesInEffect(input, filter = {}) {
134
135
  }
135
136
  lines.push("", "## Off", "");
136
137
  lines.push(
137
- offHere.length === 0 ? "- Nothing \u2014 every rule is on." : `- ${offHere.join(", ")} \u2014 turned off in \`course.settings.json\` (\`rules\`, or \`courseRules\` for an \`X\` rule). Do not apply them; do not flag them in review.`
138
+ offHere.length === 0 ? "- Nothing \u2014 every rule is on." : `- ${offHere.join(", ")} \u2014 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.`
138
139
  );
139
140
  lines.push("", "## Locked", "");
140
141
  lines.push(`- ${locked.join(", ")} \u2014 cannot be turned off; listed above under their element.`);
@@ -244,6 +244,27 @@
244
244
  }
245
245
  }
246
246
  },
247
+ "D11": {
248
+ "title": "D11 — A deck is flagged when its chapter changed after it did",
249
+ "description": "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.",
250
+ "x-element": "slide",
251
+ "x-elements": [
252
+ "slide",
253
+ "changelog"
254
+ ],
255
+ "x-enforcement": "script",
256
+ "x-doc": "docs/authoring/deck.md#d11",
257
+ "type": "object",
258
+ "additionalProperties": false,
259
+ "properties": {
260
+ "enabled": {
261
+ "title": "Enabled",
262
+ "description": "Off: the check does not run, and the rule leaves the rules-in-effect page.",
263
+ "type": "boolean",
264
+ "default": true
265
+ }
266
+ }
267
+ },
247
268
  "N9": {
248
269
  "title": "N9 — A quantity/unit/operator is typeset in `$…$`, never as plain text",
249
270
  "description": "Never write `45 m/s`, `t (s)`, `vx` or `KE/m` as plain text, and never spell a stated quantity's unit out: \"4 seconds\", \"2 meters per second squared\", \"30 degrees\", \"$x = 3$ meters\" are `$4\\ \\text{s}$`, `$2\\ \\text{m/s}^2$`, `$30^{\\circ}$`, `$x = 3\\ \\text{m}$`. A unit inside math is `\\text{}`-wrapped, short form, in the same span as its number or symbol. A unit named on its own (\"measured in meters\") stays a word; a sentence that defines a unit is waived. JSX prop strings (`caption=\"…\"`) render no math, so N9 does not apply there; nor does it apply to an assessment choice label, which may still hold math (`$30^{\\circ}$`).",
@@ -241,6 +241,7 @@ of to the settings file.
241
241
  | [deck.md](deck.md) | `D` | a lesson's deck: its frames, steps, notes and recalls, and what fits a frame |
242
242
  | [components.md](components.md) | — | _catalog_: inline/block features available in a lesson body — tooltips, tables, figures, diagrams, memos, abbreviations, citations |
243
243
  | [agents.md](agents.md) | — | _guide_: working on a course repo (with an AI agent) |
244
+ | [cli.md](cli.md) | — | _reference_, generated: every `content` verb, its flags, what it writes, and the shape of its `--json` ([cli-reference.json](cli-reference.json) as JSON Schema) |
244
245
  | [migrating.md](migrating.md) | — | _guide_: per-version steps when bumping the `explico` dep |
245
246
  | [retired.md](retired.md) | — | _history_: one generated row per rule no longer in force, and its successor |
246
247
 
@@ -308,6 +309,7 @@ of to the settings file.
308
309
  | [D8](deck.md#d8) | slide, equation | Recall a chapter’s result; never retype it _(deck only)_ | Script — `frame-retyped` (warning) in [`decks-validate.ts`](../../src/reference/decks-validate.ts) `validateDecks` |
309
310
  | [D9](deck.md#d9) | slide, math, notation | A frame introduces at most `maxNewSymbols` symbols _(deck only)_ — settings: `maxNewSymbols` = `4` | Script — `frame-new-symbols` (info) in [`decks-validate.ts`](../../src/reference/decks-validate.ts) `validateDecks` |
310
311
  | [D10](deck.md#d10) | slide | A deck opens on its title and closes on what the student can now do _(deck only)_ | Guide |
312
+ | [D11](deck.md#d11) | slide, changelog | A deck is flagged when its chapter changed after it did _(deck only)_ | Script — `deck-stale` (info) in [`page-versions.ts`](../../src/compiler/page-versions.ts) `deckStaleDiagnostics` |
311
313
 
312
314
  _Element_ is what the rule is about; the first tag is primary. _Enforcement_ is
313
315
  derived from the catalog: **Script** — every violation a check can see is
@@ -24,7 +24,7 @@ and a rule disagree, the engine wins — see [`README.md`](README.md).
24
24
  - **Read the rules in effect first, one rule at a time after that.**
25
25
  `pnpm content rule <ID>` prints one rule whole: the entry as this course
26
26
  has it and the page's own section with its example, about half a thousand
27
- tokens. `pnpm --filter <course> content rules`
27
+ tokens (N4, the largest, about three thousand). `pnpm --filter <course> content rules`
28
28
  (or `build/rules-in-effect.md` after a `validate:content`) lists every rule
29
29
  this course has *on*, grouped by the element it is about — prose, math,
30
30
  equation, notation, heading, part-page, plot, diagram, table, assessment,
@@ -46,17 +46,22 @@ and a rule disagree, the engine wins — see [`README.md`](README.md).
46
46
  ([README.md § Rules of your own](README.md#rules-of-your-own)) — never by
47
47
  editing this folder.
48
48
  - **Start from the CLI, not a file hunt.** Run it from the course package;
49
- `pnpm content help` lists every verb, and every verb takes `--json` for the
50
- whole report. Read what it prints before opening files. The table under
51
- "The CLI" says which verb answers which question.
49
+ `pnpm content help` lists every verb, `pnpm content help <verb>` prints one
50
+ verb's flags and the shape of its `--json` ([`cli.md`](cli.md) has them all),
51
+ and every verb takes `--json` for the whole report. Read what it prints
52
+ before opening files. The table under "The CLI" says which verb answers which
53
+ question.
52
54
  - **Validate once.** `pnpm --filter <course> validate:content` rewrites
53
- `build/resolution/*` and passes on its first run; it fails only on a report
54
- git tracks that it had to rewrite, so a course that commits `build/resolution/`
55
- commits the rewritten files with the change, and a course whose `build/` is
56
- git-ignored has nothing to commit from it.
57
- - **Before handing work back:** `validate:content` → `astro check` →
58
- `astro build`. `astro build` is the only step that runs every gate the CI
59
- does.
55
+ `build/resolution/*` and fails only on a report git tracks that it had to
56
+ rewrite. A course whose `build/` is git-ignored passes on the first run; one
57
+ that commits `build/resolution/` commits the rewritten reports with the
58
+ change and runs it again (after an engine upgrade every report is
59
+ rewritten).
60
+ - **Before handing work back:** `pnpm content verify` runs `validate:content`
61
+ → `content check --changed` → `astro check` → the course's tests →
62
+ `astro build` (`--e2e` adds the browser tests), one line per step and the
63
+ tail of a failed step's log; a rerun on an unchanged tree answers from its
64
+ last pass. `astro build` is the only step that runs every gate the CI does.
60
65
 
61
66
  ## The CLI
62
67
 
@@ -83,6 +88,8 @@ release that introduced it). Read-only except `new` and `init-agent`, and
83
88
  | the mechanical repairs | `fix <lesson> [--write]` | redundant `\explain` wraps unwrapped, as a diff |
84
89
  | a draft to start from | `new lesson <id>`, `new deck <lesson \| part>`, `new term <key>` | the files it created; never overwrites |
85
90
  | install these procedures | `init-agent [--write]` | the skills and the `rules-reviewer` agent copied into `.claude/`, a baseline `AGENTS.md` when the course has none |
91
+ | is it ready to hand back | `verify [--e2e] [--force]` | one line per step — validate, check, `astro check`, tests, `astro build` — and a failed step's log tail; exit 1 if one failed |
92
+ | a verb's flags and `--json` | `help [<verb>]` | the usage; with a verb, its flags, what it writes, its exit code and its `--json` type |
86
93
 
87
94
  The procedures — drafting a lesson, upgrading the engine, releasing the
88
95
  course's content, reviewing a lesson against the guide-only rules — ship in