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.
- package/CHANGELOG.md +112 -1
- package/dist/compiler/cli-main.d.ts +61 -4
- package/dist/compiler/cli-main.js +77 -36
- package/dist/compiler/cli-reference.d.ts +50 -0
- package/dist/compiler/cli-reference.js +213 -0
- package/dist/compiler/cli-verbs.d.ts +45 -0
- package/dist/compiler/cli-verbs.js +224 -0
- package/dist/compiler/cli-verify.d.ts +51 -0
- package/dist/compiler/cli-verify.js +151 -0
- package/dist/compiler/cli.d.ts +19 -50
- package/dist/compiler/cli.js +45 -6
- package/dist/compiler/content-release.d.ts +49 -0
- package/dist/compiler/content-release.js +63 -0
- package/dist/compiler/content-schemas.d.ts +2 -2
- package/dist/compiler/manifest.js +29 -24
- package/dist/compiler/page-versions.d.ts +25 -1
- package/dist/compiler/page-versions.js +19 -0
- package/dist/compiler/validate-content.js +2 -2
- package/dist/reference/consistency.js +7 -1
- package/dist/reference/diagram-fit.d.ts +5 -1
- package/dist/reference/diagram-fit.js +5 -1
- package/dist/rules/catalog.js +16 -0
- package/dist/rules/in-effect.d.ts +1 -1
- package/dist/rules/in-effect.js +5 -4
- package/dist/rules/rules.schema.json +21 -0
- package/docs/authoring/README.md +2 -0
- package/docs/authoring/agents.md +18 -11
- package/docs/authoring/cli-reference.json +2172 -0
- package/docs/authoring/cli.md +887 -0
- package/docs/authoring/deck.md +24 -0
- package/docs/authoring/migrating.md +168 -18
- package/docs/authoring/versioning.md +2 -1
- package/docs/course-agent/AGENTS.md +10 -7
- package/docs/course-agent/agents/rules-reviewer.md +2 -2
- package/docs/course-agent/skills/draft-lesson/SKILL.md +7 -6
- package/docs/course-agent/skills/migrate-course/SKILL.md +5 -4
- package/docs/course-agent/skills/release-content/SKILL.md +5 -3
- package/docs/diagram-scaling.md +2 -2
- package/package.json +1 -1
- package/src/compiler/cli-main.ts +135 -47
- package/src/compiler/cli-reference.ts +285 -0
- package/src/compiler/cli-verbs.ts +270 -0
- package/src/compiler/cli-verify.ts +241 -0
- package/src/compiler/cli.ts +77 -17
- package/src/compiler/content-release.ts +91 -0
- package/src/compiler/manifest.ts +34 -24
- package/src/compiler/page-versions.ts +42 -0
- package/src/compiler/validate-content.ts +5 -2
- package/src/reference/consistency.ts +13 -2
- package/src/reference/diagram-fit.ts +18 -4
- package/src/rules/catalog.ts +17 -0
- package/src/rules/in-effect.ts +5 -4
package/src/compiler/cli.ts
CHANGED
|
@@ -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
|
|
1217
|
-
readonly tables: readonly
|
|
1218
|
-
readonly figures: readonly
|
|
1219
|
-
readonly diagrams: readonly
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
2018
|
-
|
|
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 ? '
|
|
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
|
+
}
|
package/src/compiler/manifest.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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.
|
|
50
|
-
|
|
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
|
}
|
package/src/rules/catalog.ts
CHANGED
|
@@ -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
|
/**
|
package/src/rules/in-effect.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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', '');
|