explico 0.13.1 → 0.14.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 +879 -1
- package/README.md +200 -30
- package/dist/compiler/astro.d.ts +15 -2
- package/dist/compiler/astro.js +40 -11
- package/dist/compiler/cli-main.d.ts +43 -0
- package/dist/compiler/cli-main.js +392 -0
- package/dist/compiler/cli.d.ts +424 -9
- package/dist/compiler/cli.js +1289 -8
- package/dist/compiler/collections.d.ts +88 -1
- package/dist/compiler/collections.js +170 -25
- package/dist/compiler/course-config.d.ts +1 -1
- package/dist/compiler/course-config.js +105 -1
- package/dist/compiler/decks.d.ts +161 -0
- package/dist/compiler/decks.js +235 -0
- package/dist/compiler/exports/combined-html.d.ts +5 -1
- package/dist/compiler/exports/deck-fit.d.ts +55 -0
- package/dist/compiler/exports/deck-fit.js +90 -0
- package/dist/compiler/exports/export-chapters.d.ts +37 -6
- package/dist/compiler/exports/export-chapters.js +64 -5
- package/dist/compiler/exports/export-pages.d.ts +30 -3
- package/dist/compiler/exports/export-pages.js +24 -1
- package/dist/compiler/exports/latex/beamerthemeexplico.sty +124 -0
- package/dist/compiler/exports/latex/cache.d.ts +78 -0
- package/dist/compiler/exports/latex/cache.js +129 -0
- package/dist/compiler/exports/latex/capture.d.ts +10 -1
- package/dist/compiler/exports/latex/capture.js +3 -1
- package/dist/compiler/exports/latex/compile.d.ts +13 -2
- package/dist/compiler/exports/latex/compile.js +12 -1
- package/dist/compiler/exports/latex/deck.d.ts +92 -0
- package/dist/compiler/exports/latex/deck.js +349 -0
- package/dist/compiler/exports/latex/document.d.ts +51 -6
- package/dist/compiler/exports/latex/document.js +71 -12
- package/dist/compiler/exports/latex/explico-common.sty +198 -0
- package/dist/compiler/exports/latex/explico.cls +13 -183
- package/dist/compiler/exports/latex/serialize.d.ts +60 -4
- package/dist/compiler/exports/latex/serialize.js +4 -2
- package/dist/compiler/exports/latex-export.d.ts +24 -2
- package/dist/compiler/exports/latex-export.js +107 -26
- package/dist/compiler/exports/markdown/serialize.d.ts +24 -4
- package/dist/compiler/exports/markdown/serialize.js +85 -8
- package/dist/compiler/exports/markdown-export.d.ts +6 -1
- package/dist/compiler/exports/markdown-export.js +50 -11
- package/dist/compiler/exports/standalone-html.d.ts +10 -5
- package/dist/compiler/exports/standalone-html.js +3 -3
- package/dist/compiler/exports/static-tree.d.ts +38 -1
- package/dist/compiler/exports/static-tree.js +120 -4
- package/dist/compiler/knowledge-version.d.ts +26 -2
- package/dist/compiler/knowledge-version.js +27 -17
- package/dist/compiler/lesson-derivation.d.ts +143 -6
- package/dist/compiler/lesson-derivation.js +145 -13
- package/dist/compiler/manifest.d.ts +133 -4
- package/dist/compiler/manifest.js +359 -58
- package/dist/compiler/page-versions.d.ts +160 -0
- package/dist/compiler/page-versions.js +198 -0
- package/dist/compiler/render-diagnostics.d.ts +5 -1
- package/dist/compiler/resolution-report.d.ts +24 -6
- package/dist/compiler/resolution-report.js +22 -4
- package/dist/compiler/validate-content.d.ts +7 -0
- package/dist/compiler/validate-content.js +160 -0
- package/dist/{course-config-rRtME5UV.d.ts → course-config-BLtIdkei.d.ts} +164 -1
- package/dist/curriculum/competency-graph.d.ts +54 -0
- package/dist/curriculum/competency-graph.js +41 -0
- package/dist/reference/base-library.d.mts +8 -0
- package/dist/reference/base-library.mjs +19 -0
- package/dist/reference/consistency.d.ts +8 -4
- package/dist/reference/consistency.js +76 -26
- package/dist/reference/deck-remark.d.mts +123 -0
- package/dist/reference/deck-remark.mjs +600 -0
- package/dist/reference/deck-source.d.ts +107 -0
- package/dist/reference/deck-source.js +140 -0
- package/dist/reference/decks-validate.d.ts +133 -0
- package/dist/reference/decks-validate.js +359 -0
- package/dist/reference/derived-entries.d.ts +5 -1
- package/dist/reference/diagram-fit.d.ts +1 -1
- package/dist/reference/figure-symbols.d.ts +23 -6
- package/dist/reference/figure-symbols.js +51 -15
- package/dist/reference/float-name.d.ts +43 -0
- package/dist/reference/float-name.js +58 -0
- package/dist/reference/folds-validate.d.ts +1 -1
- package/dist/reference/gate-abbreviations.d.ts +1 -1
- package/dist/reference/gate-indices.js +5 -4
- package/dist/reference/gate-math.d.ts +29 -2
- package/dist/reference/gate-math.js +99 -18
- package/dist/reference/gate-part-pages.d.ts +1 -1
- package/dist/reference/lab-katex-validate.d.ts +10 -6
- package/dist/reference/lab-katex-validate.js +17 -9
- package/dist/reference/lab-math-scope.js +29 -4
- package/dist/reference/math-glyphs.d.mts +25 -0
- package/dist/reference/math-glyphs.mjs +189 -25
- package/dist/reference/page-notation.d.ts +22 -3
- package/dist/reference/page-notation.js +32 -10
- package/dist/reference/references.d.ts +14 -1
- package/dist/reference/references.js +13 -0
- package/dist/reference/registry-scope.d.mts +33 -0
- package/dist/reference/registry-scope.mjs +34 -0
- package/dist/reference/registry.js +29 -10
- package/dist/reference/rehype-deck.d.mts +6 -0
- package/dist/reference/rehype-deck.mjs +286 -0
- package/dist/reference/rehype-heading-anchors.d.mts +20 -0
- package/dist/reference/rehype-heading-anchors.mjs +48 -6
- package/dist/reference/remark-notation.mjs +249 -40
- package/dist/reference/source-lines.d.mts +12 -0
- package/dist/reference/source-lines.mjs +65 -0
- package/dist/reference/types.d.ts +28 -0
- package/dist/rules/catalog.d.ts +21 -4
- package/dist/rules/catalog.js +221 -2
- package/dist/rules/course-rules.d.ts +5 -1
- package/dist/rules/course-rules.js +17 -3
- package/dist/rules/course-rules.schema.json +12 -0
- package/dist/rules/in-effect.d.ts +13 -3
- package/dist/rules/in-effect.js +30 -7
- package/dist/rules/rules.schema.json +259 -1
- package/dist/rules/settings.d.ts +1 -1
- package/dist/session/PreferenceStore.d.ts +6 -0
- package/dist/session/PreferenceStore.js +18 -1
- package/dist/session/deck-memory.d.ts +116 -0
- package/dist/session/deck-memory.js +143 -0
- package/dist/session/reading-memory.d.ts +96 -0
- package/dist/session/reading-memory.js +133 -0
- package/docs/VERSIONING.md +21 -18
- package/docs/authoring/README.md +29 -9
- package/docs/authoring/agents.md +63 -16
- package/docs/authoring/components.md +15 -0
- package/docs/authoring/deck.md +588 -0
- package/docs/authoring/migrating.md +1045 -16
- package/docs/authoring/notation.md +49 -6
- package/docs/authoring/structure.md +80 -1
- package/docs/authoring/versioning.md +54 -2
- package/docs/authoring/visualizations.md +7 -2
- package/docs/course-agent/AGENTS.md +37 -0
- package/docs/course-agent/agents/rules-reviewer.md +33 -0
- package/docs/course-agent/skills/draft-lesson/SKILL.md +37 -0
- package/docs/course-agent/skills/migrate-course/SKILL.md +31 -0
- package/docs/course-agent/skills/release-content/SKILL.md +24 -0
- package/docs/diagram-scaling.md +148 -0
- package/docs/privacy.md +59 -0
- package/package.json +8 -2
- package/src/compiler/astro.ts +60 -21
- package/src/compiler/cli-main.ts +469 -0
- package/src/compiler/cli.ts +1905 -16
- package/src/compiler/collections.ts +284 -25
- package/src/compiler/course-config.ts +267 -0
- package/src/compiler/decks.ts +455 -0
- package/src/compiler/exports/deck-fit.ts +127 -0
- package/src/compiler/exports/export-chapters.ts +105 -6
- package/src/compiler/exports/export-pages.ts +46 -2
- package/src/compiler/exports/latex/beamerthemeexplico.sty +124 -0
- package/src/compiler/exports/latex/cache.ts +183 -0
- package/src/compiler/exports/latex/capture.ts +5 -2
- package/src/compiler/exports/latex/compile.ts +18 -0
- package/src/compiler/exports/latex/deck.ts +419 -0
- package/src/compiler/exports/latex/document.ts +117 -21
- package/src/compiler/exports/latex/explico-common.sty +198 -0
- package/src/compiler/exports/latex/explico.cls +13 -183
- package/src/compiler/exports/latex/serialize.ts +25 -21
- package/src/compiler/exports/latex/texlive-packages.txt +6 -2
- package/src/compiler/exports/latex-export.ts +187 -45
- package/src/compiler/exports/markdown/serialize.ts +118 -9
- package/src/compiler/exports/markdown-export.ts +56 -11
- package/src/compiler/exports/standalone-html.ts +8 -7
- package/src/compiler/exports/static-tree.ts +175 -3
- package/src/compiler/knowledge-version.ts +56 -21
- package/src/compiler/lesson-derivation.ts +282 -16
- package/src/compiler/manifest.ts +558 -63
- package/src/compiler/page-versions.ts +359 -0
- package/src/compiler/resolution-report.ts +38 -7
- package/src/compiler/validate-content.ts +212 -0
- package/src/components/CourseContents.astro +124 -6
- package/src/components/CurriculumMap.astro +49 -1
- package/src/components/FloatIndex.astro +1 -1
- package/src/components/PartOverview.astro +51 -5
- package/src/components/assessments/AssessmentSet.astro +41 -15
- package/src/components/block-heading.ts +15 -6
- package/src/components/card-carry.ts +7 -4
- package/src/components/card-manager.ts +14 -2
- package/src/components/card-memory.ts +13 -9
- package/src/components/deck/DeckEnhancer.astro +13 -0
- package/src/components/deck/deck-context.ts +104 -0
- package/src/components/deck/deck-controller.ts +1287 -0
- package/src/components/deck/deck-map.ts +91 -0
- package/src/components/deck-lane.ts +163 -0
- package/src/components/diagram/Diagram.astro +131 -86
- package/src/components/disclosure/persisted-disclosure.ts +9 -1
- package/src/components/figure/Figure.astro +7 -2
- package/src/components/figure/FloatCaption.astro +5 -3
- package/src/components/fold/Fold.astro +2 -2
- package/src/components/notation/NotationFormula.astro +81 -11
- package/src/components/notation/NotationGlossary.astro +132 -1
- package/src/components/notation/NotationLayer.astro +15 -7
- package/src/components/starlight/AboutPanel.astro +186 -3
- package/src/components/starlight/CollapsibleSections.astro +24 -3
- package/src/components/starlight/DiagramEnhancer.astro +5 -1
- package/src/components/starlight/HeadingAnchorEnhancer.astro +1 -1
- package/src/components/starlight/LayoutHeader.astro +17 -0
- package/src/components/starlight/LayoutPageTitle.astro +110 -0
- package/src/components/starlight/LayoutSidebar.astro +807 -21
- package/src/components/starlight/LayoutSocialIcons.astro +62 -6
- package/src/components/starlight/LessonExportActions.astro +27 -9
- package/src/components/starlight/LessonFooter.astro +59 -0
- package/src/components/starlight/ReadingMemoryNotice.astro +114 -0
- package/src/components/starlight/TextSizeControl.astro +24 -13
- package/src/components/starlight/page-marks.ts +139 -0
- package/src/components/starlight/section-summary.ts +1 -1
- package/src/components/starlight/sidebar-editions.ts +245 -0
- package/src/components/table/Table.astro +15 -7
- package/src/content-config.ts +87 -1
- package/src/curriculum/competency-graph.ts +95 -0
- package/src/reference/base-library.mjs +19 -0
- package/src/reference/consistency.ts +126 -39
- package/src/reference/deck-remark.mjs +600 -0
- package/src/reference/deck-source.ts +273 -0
- package/src/reference/decks-validate.ts +604 -0
- package/src/reference/diagram-layout.ts +3 -1
- package/src/reference/figure-symbols.ts +108 -31
- package/src/reference/folds-validate.ts +1 -1
- package/src/reference/gate-indices.ts +5 -3
- package/src/reference/gate-math.ts +169 -25
- package/src/reference/lab-katex-validate.ts +30 -15
- package/src/reference/lab-math-scope.ts +34 -3
- package/src/reference/math-glyphs.mjs +189 -25
- package/src/reference/page-notation.ts +69 -15
- package/src/reference/references.ts +28 -0
- package/src/reference/registry-scope.mjs +34 -0
- package/src/reference/registry.ts +41 -11
- package/src/reference/rehype-deck.mjs +286 -0
- package/src/reference/rehype-heading-anchors.mjs +48 -6
- package/src/reference/remark-notation.mjs +249 -40
- package/src/reference/source-lines.mjs +65 -0
- package/src/reference/types.ts +28 -0
- package/src/rules/catalog.ts +257 -4
- package/src/rules/course-rules.ts +21 -1
- package/src/rules/in-effect.ts +54 -8
- package/src/session/PreferenceStore.ts +23 -0
- package/src/session/deck-memory.ts +263 -0
- package/src/session/reading-memory.ts +243 -0
- package/src/styles/deck.css +1150 -0
- package/src/styles/global.css +958 -27
- package/src/styles/zones.css +277 -0
- /package/src/{components → reference}/float-name.ts +0 -0
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A page's own content version (rule C5): the knowledge version its content
|
|
3
|
+
* last changed in, as against the course's, which moves at every release.
|
|
4
|
+
*
|
|
5
|
+
* A build sees one commit, and "unchanged since 0.2.0" is history, so the
|
|
6
|
+
* record is a committed file, `content/versions.json`, kept like a lockfile:
|
|
7
|
+
* `pnpm validate:content` works the next record out from the committed one
|
|
8
|
+
* and the content tree, writes it back on a developer's machine, and in CI
|
|
9
|
+
* (`CI` set) fails when the committed file is not the one it would write.
|
|
10
|
+
* The manifest carries each chapter's and part page's `since` either way,
|
|
11
|
+
* computed from the committed record by the same rules, so a build never
|
|
12
|
+
* depends on the file having been rewritten.
|
|
13
|
+
*
|
|
14
|
+
* What counts as the page: its own file, its `.checks.yml` and the
|
|
15
|
+
* assessments it lists, the shared notation entries its text uses, the
|
|
16
|
+
* sources it cites, and its numbers (`part.chapter` and the float offsets its
|
|
17
|
+
* earlier chapters leave it). A lab, `content/domain/`, a part's label or the
|
|
18
|
+
* engine change a page's PDF without changing its stamp.
|
|
19
|
+
*
|
|
20
|
+
* The three rules, applied to the committed entry of each page:
|
|
21
|
+
*
|
|
22
|
+
* 1. The record's `version` is not `about.version`: a release came after the
|
|
23
|
+
* file was written, and a release takes every change `## [Unreleased]`
|
|
24
|
+
* held. Every page with a `pending` hash, or never released, is now
|
|
25
|
+
* `since` that version.
|
|
26
|
+
* 2. The page hashes as recorded: `since` stays. A pending edit undone
|
|
27
|
+
* (the page hashes as it did at `since`) is unchanged again.
|
|
28
|
+
* 3. The page changed: with bullets under `## [Unreleased]` the change is
|
|
29
|
+
* not yet released (`pending`); with none it is the release's own, or a
|
|
30
|
+
* fix rule C1 lets go unlisted, and `since` is `about.version`.
|
|
31
|
+
*
|
|
32
|
+
* Pure except `readPageVersions` and `syncPageVersions`, which do the file IO.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { createHash } from 'node:crypto';
|
|
36
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
37
|
+
import { join } from 'node:path';
|
|
38
|
+
|
|
39
|
+
import type { KnowledgeVersionDiagnostic } from './knowledge-version';
|
|
40
|
+
|
|
41
|
+
/** Repo-relative path of the record. */
|
|
42
|
+
export const PAGE_VERSIONS_FILE = 'content/versions.json';
|
|
43
|
+
export const PAGE_VERSIONS_GENERATED_BY = 'pnpm validate:content (rule C5); do not edit';
|
|
44
|
+
/**
|
|
45
|
+
* The format of the record and of the hashes in it. A record of another
|
|
46
|
+
* format keeps every page's `since` and takes the hashes as they are now, so
|
|
47
|
+
* a change to what is hashed never marks every page changed.
|
|
48
|
+
*/
|
|
49
|
+
export const PAGE_VERSIONS_FORMAT = 1;
|
|
50
|
+
|
|
51
|
+
export interface PageVersionEntry {
|
|
52
|
+
/** The release the page's content last changed in; `null` before its first. */
|
|
53
|
+
readonly since: string | null;
|
|
54
|
+
/** `sha256:…` of the page's inputs as of `since`. */
|
|
55
|
+
readonly source: string;
|
|
56
|
+
/** The inputs' hash now, while they differ from `source` and `## [Unreleased]` has bullets. */
|
|
57
|
+
readonly pending?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface PageVersionsRecord {
|
|
61
|
+
readonly generatedBy: string;
|
|
62
|
+
readonly format: number;
|
|
63
|
+
/** `about.version` when the record was written. */
|
|
64
|
+
readonly version: string;
|
|
65
|
+
/** By docs slug (`motion/constant-acceleration`, `motion/index`), sorted. */
|
|
66
|
+
readonly pages: Readonly<Record<string, PageVersionEntry>>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** What the manifest carries for the record (`manifest.pageVersions`). */
|
|
70
|
+
export interface ManifestPageVersions {
|
|
71
|
+
/** False when rule C5 is off: every page then carries the course's version. */
|
|
72
|
+
readonly enabled: boolean;
|
|
73
|
+
/** Repo-relative path of the record, `content/versions.json`. */
|
|
74
|
+
readonly file: string;
|
|
75
|
+
/** The record as `validate:content` should write it. */
|
|
76
|
+
readonly record: PageVersionsRecord;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const ENTRY_HASH = /^sha256:[0-9a-f]{64}$/u;
|
|
80
|
+
|
|
81
|
+
function compareSlugs(a: string, b: string): number {
|
|
82
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** JSON with recursively sorted keys and a trailing newline, as the resolution reports are written. */
|
|
86
|
+
function stableJson(value: unknown): string {
|
|
87
|
+
const sortKeys = (node: unknown): unknown => {
|
|
88
|
+
if (Array.isArray(node)) return node.map(sortKeys);
|
|
89
|
+
if (node && typeof node === 'object') {
|
|
90
|
+
return Object.fromEntries(
|
|
91
|
+
Object.keys(node as Record<string, unknown>)
|
|
92
|
+
.sort()
|
|
93
|
+
.map((key) => [key, sortKeys((node as Record<string, unknown>)[key])]),
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
return node;
|
|
97
|
+
};
|
|
98
|
+
return `${JSON.stringify(sortKeys(value), null, 2)}\n`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The hash of one page's inputs: each labelled file digest, in label order,
|
|
103
|
+
* then the page's facts (its numbers) as sorted JSON.
|
|
104
|
+
*/
|
|
105
|
+
export function pageSourceDigest(
|
|
106
|
+
inputs: readonly { readonly label: string; readonly digest: string }[],
|
|
107
|
+
facts: Record<string, unknown> = {},
|
|
108
|
+
): string {
|
|
109
|
+
const hash = createHash('sha256');
|
|
110
|
+
for (const { label, digest } of [...inputs].sort((a, b) => compareSlugs(a.label, b.label))) {
|
|
111
|
+
hash.update(`${label} ${digest}\n`);
|
|
112
|
+
}
|
|
113
|
+
hash.update(stableJson(facts));
|
|
114
|
+
return `sha256:${hash.digest('hex')}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Semver order over `x.y.z`, a prerelease below its release; `null` (not
|
|
119
|
+
* yet released) above everything, since it is the newest state a page can
|
|
120
|
+
* be in. A string that is not semver sorts by text, after every semver.
|
|
121
|
+
*/
|
|
122
|
+
export function compareKnowledgeVersions(a: string | null, b: string | null): number {
|
|
123
|
+
if (a === b) return 0;
|
|
124
|
+
if (a === null) return 1;
|
|
125
|
+
if (b === null) return -1;
|
|
126
|
+
const parse = (value: string) => /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/u.exec(value.trim());
|
|
127
|
+
const left = parse(a);
|
|
128
|
+
const right = parse(b);
|
|
129
|
+
if (!left || !right) {
|
|
130
|
+
if (left) return -1;
|
|
131
|
+
if (right) return 1;
|
|
132
|
+
return compareSlugs(a, b);
|
|
133
|
+
}
|
|
134
|
+
for (const index of [1, 2, 3]) {
|
|
135
|
+
const difference = Number(left[index]) - Number(right[index]);
|
|
136
|
+
if (difference !== 0) return Math.sign(difference);
|
|
137
|
+
}
|
|
138
|
+
const leftPre = left[4];
|
|
139
|
+
const rightPre = right[4];
|
|
140
|
+
if (leftPre === rightPre) return 0;
|
|
141
|
+
if (leftPre === undefined) return 1;
|
|
142
|
+
if (rightPre === undefined) return -1;
|
|
143
|
+
const leftParts = leftPre.split('.');
|
|
144
|
+
const rightParts = rightPre.split('.');
|
|
145
|
+
for (let index = 0; index < Math.max(leftParts.length, rightParts.length); index += 1) {
|
|
146
|
+
const l = leftParts[index];
|
|
147
|
+
const r = rightParts[index];
|
|
148
|
+
if (l === undefined) return -1;
|
|
149
|
+
if (r === undefined) return 1;
|
|
150
|
+
const numeric = /^\d+$/u.test(l) && /^\d+$/u.test(r);
|
|
151
|
+
const difference = numeric ? Number(l) - Number(r) : compareSlugs(l, r);
|
|
152
|
+
if (difference !== 0) return Math.sign(difference);
|
|
153
|
+
}
|
|
154
|
+
return 0;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The version of a document made of several pages: the newest among them,
|
|
159
|
+
* `null` when any page has a change not yet released, `null` for no pages.
|
|
160
|
+
*/
|
|
161
|
+
export function newestSince(values: readonly (string | null)[]): string | null {
|
|
162
|
+
if (values.length === 0) return null;
|
|
163
|
+
return values.reduce<string | null>(
|
|
164
|
+
(newest, value) => (compareKnowledgeVersions(value, newest) > 0 ? value : newest),
|
|
165
|
+
values[0]!,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The version a page's entry gives it: `null` while it has a change not yet released. */
|
|
170
|
+
export function sinceOf(entry: PageVersionEntry): string | null {
|
|
171
|
+
return entry.pending !== undefined || entry.since === null ? null : entry.since;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The phrase every export prints for a document's version (rule C5):
|
|
176
|
+
* "unchanged since version 0.2.0", or, for a change not yet released, "with
|
|
177
|
+
* changes after version 0.4.0, not yet released", 0.4.0 being the newest
|
|
178
|
+
* release, the last one the document agrees with.
|
|
179
|
+
*/
|
|
180
|
+
export function versionPhrase(since: string | null, current: string): string {
|
|
181
|
+
return since === null
|
|
182
|
+
? `with changes after version ${current}, not yet released`
|
|
183
|
+
: `unchanged since version ${since}`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export interface NextPageVersionsInput {
|
|
187
|
+
/** The committed record, or `undefined` for a course without one. */
|
|
188
|
+
readonly previous: PageVersionsRecord | undefined;
|
|
189
|
+
/** `about.version`. */
|
|
190
|
+
readonly version: string;
|
|
191
|
+
/** Whether `## [Unreleased]` holds bullets. */
|
|
192
|
+
readonly unreleased: boolean;
|
|
193
|
+
/** Every tracked page, with its inputs' hash now. */
|
|
194
|
+
readonly pages: readonly { readonly slug: string; readonly source: string }[];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The record `validate:content` writes: the committed one, brought to the content as it is now. */
|
|
198
|
+
export function nextPageVersions(input: NextPageVersionsInput): PageVersionsRecord {
|
|
199
|
+
const { previous, version, unreleased } = input;
|
|
200
|
+
const released = previous !== undefined && previous.version !== version;
|
|
201
|
+
const sameFormat = previous !== undefined && previous.format === PAGE_VERSIONS_FORMAT;
|
|
202
|
+
const pages: Record<string, PageVersionEntry> = {};
|
|
203
|
+
for (const page of [...input.pages].sort((a, b) => compareSlugs(a.slug, b.slug))) {
|
|
204
|
+
const current = page.source;
|
|
205
|
+
let entry = previous?.pages[page.slug];
|
|
206
|
+
if (entry && !sameFormat) {
|
|
207
|
+
// Another format hashed differently: the page keeps its version, and
|
|
208
|
+
// whether it has a pending change, with the hash as it is now.
|
|
209
|
+
entry = { since: entry.since, source: current, ...(entry.pending === undefined ? {} : { pending: current }) };
|
|
210
|
+
}
|
|
211
|
+
if (entry && released && (entry.pending !== undefined || entry.since === null)) {
|
|
212
|
+
// 1. The release took every change that was waiting.
|
|
213
|
+
entry = { since: version, source: entry.pending ?? entry.source };
|
|
214
|
+
}
|
|
215
|
+
if (entry === undefined) {
|
|
216
|
+
pages[page.slug] = unreleased ? { since: null, source: current } : { since: version, source: current };
|
|
217
|
+
} else if ((entry.pending ?? entry.source) === current) {
|
|
218
|
+
// 2. As recorded.
|
|
219
|
+
pages[page.slug] = entry;
|
|
220
|
+
} else if (entry.pending !== undefined && entry.source === current) {
|
|
221
|
+
// 2. A pending edit undone: the page is as it was at `since`.
|
|
222
|
+
pages[page.slug] = { since: entry.since, source: entry.source };
|
|
223
|
+
} else if (unreleased) {
|
|
224
|
+
// 3. Changed, and the change waits for a release.
|
|
225
|
+
pages[page.slug] =
|
|
226
|
+
entry.since === null ? { since: null, source: current } : { since: entry.since, source: entry.source, pending: current };
|
|
227
|
+
} else {
|
|
228
|
+
// 3. Changed with nothing waiting: the release's own change, or one rule C1 lets go unlisted.
|
|
229
|
+
pages[page.slug] = { since: version, source: current };
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return { generatedBy: PAGE_VERSIONS_GENERATED_BY, format: PAGE_VERSIONS_FORMAT, version, pages };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** The record's text as `syncPageVersions` writes it. */
|
|
236
|
+
export function serializePageVersions(record: PageVersionsRecord): string {
|
|
237
|
+
return stableJson(record);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The committed record, or `undefined` when there is none. A file that is
|
|
242
|
+
* not a record (hand-edited, truncated) is reported as `page-versions-invalid`
|
|
243
|
+
* and treated as none: every page then counts as new.
|
|
244
|
+
*/
|
|
245
|
+
export function readPageVersions(
|
|
246
|
+
path: string,
|
|
247
|
+
file: string = PAGE_VERSIONS_FILE,
|
|
248
|
+
): { readonly record: PageVersionsRecord | undefined; readonly diagnostics: readonly KnowledgeVersionDiagnostic[] } {
|
|
249
|
+
if (!existsSync(path)) return { record: undefined, diagnostics: [] };
|
|
250
|
+
const invalid = (reason: string) => ({
|
|
251
|
+
record: undefined,
|
|
252
|
+
diagnostics: [
|
|
253
|
+
{
|
|
254
|
+
code: 'page-versions-invalid' as const,
|
|
255
|
+
severity: 'warning' as const,
|
|
256
|
+
message: `${file} is not a record validate:content wrote (${reason}); it is rebuilt, and every page counts as new. Restore the committed file to keep the pages' versions (rule C5).`,
|
|
257
|
+
file,
|
|
258
|
+
},
|
|
259
|
+
],
|
|
260
|
+
});
|
|
261
|
+
let parsed: unknown;
|
|
262
|
+
try {
|
|
263
|
+
parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
264
|
+
} catch (error) {
|
|
265
|
+
return invalid(error instanceof Error ? error.message : String(error));
|
|
266
|
+
}
|
|
267
|
+
if (!parsed || typeof parsed !== 'object') return invalid('not an object');
|
|
268
|
+
const { version, format, pages, generatedBy } = parsed as Record<string, unknown>;
|
|
269
|
+
if (typeof version !== 'string' || typeof format !== 'number' || !pages || typeof pages !== 'object') {
|
|
270
|
+
return invalid('missing version, format or pages');
|
|
271
|
+
}
|
|
272
|
+
const entries: Record<string, PageVersionEntry> = {};
|
|
273
|
+
for (const [slug, value] of Object.entries(pages as Record<string, unknown>)) {
|
|
274
|
+
if (!value || typeof value !== 'object') return invalid(`page ${slug} is not an object`);
|
|
275
|
+
const { since, source, pending } = value as Record<string, unknown>;
|
|
276
|
+
if (since !== null && typeof since !== 'string') return invalid(`page ${slug}: since must be a version or null`);
|
|
277
|
+
if (typeof source !== 'string' || !ENTRY_HASH.test(source)) return invalid(`page ${slug}: source is not a sha256`);
|
|
278
|
+
if (pending !== undefined && (typeof pending !== 'string' || !ENTRY_HASH.test(pending))) {
|
|
279
|
+
return invalid(`page ${slug}: pending is not a sha256`);
|
|
280
|
+
}
|
|
281
|
+
entries[slug] = { since, source, ...(pending === undefined ? {} : { pending }) };
|
|
282
|
+
}
|
|
283
|
+
return {
|
|
284
|
+
record: { generatedBy: typeof generatedBy === 'string' ? generatedBy : '', format, version, pages: entries },
|
|
285
|
+
diagnostics: [],
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** The one error rule C5 raises: frozen, and the committed record is not the content's. */
|
|
290
|
+
export interface PageVersionsStaleDiagnostic {
|
|
291
|
+
readonly code: 'page-versions-stale';
|
|
292
|
+
readonly severity: 'error';
|
|
293
|
+
readonly message: string;
|
|
294
|
+
readonly file: string;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export interface PageVersionsSync {
|
|
298
|
+
/** Absolute path of the record. */
|
|
299
|
+
readonly path: string;
|
|
300
|
+
/** The record on disk was not the one the content gives. */
|
|
301
|
+
readonly stale: boolean;
|
|
302
|
+
/** The record was written (never when frozen). */
|
|
303
|
+
readonly written: boolean;
|
|
304
|
+
/** Pages whose entry changed, or `['*']` for a record written first. */
|
|
305
|
+
readonly changed: readonly string[];
|
|
306
|
+
/** Set when frozen and stale: the error that fails the build. */
|
|
307
|
+
readonly diagnostic?: PageVersionsStaleDiagnostic;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** `CI` set (and not `false` or `0`): the record is frozen, like a lockfile. */
|
|
311
|
+
export function pageVersionsFrozen(): boolean {
|
|
312
|
+
const ci = process.env.CI;
|
|
313
|
+
return !!ci && ci !== 'false' && ci !== '0';
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Bring `content/versions.json` to what the manifest computed. On a
|
|
318
|
+
* developer's machine a stale record is rewritten and the caller logs the
|
|
319
|
+
* pages that moved; frozen (CI by default) a stale record is left as it is
|
|
320
|
+
* and the result carries a `page-versions-stale` error, so a content change
|
|
321
|
+
* cannot land without its record.
|
|
322
|
+
*/
|
|
323
|
+
export function syncPageVersions(
|
|
324
|
+
manifest: { readonly pageVersions: ManifestPageVersions },
|
|
325
|
+
options: { readonly frozen?: boolean; readonly root?: string } = {},
|
|
326
|
+
): PageVersionsSync {
|
|
327
|
+
const root = options.root ?? process.cwd();
|
|
328
|
+
const { enabled, file, record } = manifest.pageVersions;
|
|
329
|
+
const path = join(root, file);
|
|
330
|
+
if (!enabled) return { path, stale: false, written: false, changed: [] };
|
|
331
|
+
const expected = serializePageVersions(record);
|
|
332
|
+
const onDisk = existsSync(path) ? readFileSync(path, 'utf8') : undefined;
|
|
333
|
+
if (onDisk === expected) return { path, stale: false, written: false, changed: [] };
|
|
334
|
+
const previous = onDisk === undefined ? undefined : readPageVersions(path, file).record;
|
|
335
|
+
const changed =
|
|
336
|
+
previous === undefined
|
|
337
|
+
? ['*']
|
|
338
|
+
: [...new Set([...Object.keys(previous.pages), ...Object.keys(record.pages)])]
|
|
339
|
+
.filter((slug) => JSON.stringify(previous.pages[slug] ?? null) !== JSON.stringify(record.pages[slug] ?? null))
|
|
340
|
+
.sort(compareSlugs);
|
|
341
|
+
const frozen = options.frozen ?? pageVersionsFrozen();
|
|
342
|
+
if (frozen) {
|
|
343
|
+
const moved = changed[0] === '*' ? 'the file is missing' : `${changed.length} page(s): ${changed.slice(0, 6).join(', ')}${changed.length > 6 ? ', …' : ''}`;
|
|
344
|
+
return {
|
|
345
|
+
path,
|
|
346
|
+
stale: true,
|
|
347
|
+
written: false,
|
|
348
|
+
changed,
|
|
349
|
+
diagnostic: {
|
|
350
|
+
code: 'page-versions-stale',
|
|
351
|
+
severity: 'error',
|
|
352
|
+
message: `${file} is out of date (${moved}). Run \`pnpm validate:content\` and commit ${file} with the change (rule C5).`,
|
|
353
|
+
file,
|
|
354
|
+
},
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
writeFileSync(path, expected);
|
|
358
|
+
return { path, stale: true, written: true, changed };
|
|
359
|
+
}
|
|
@@ -9,13 +9,16 @@
|
|
|
9
9
|
* lesson — plus `resolution/consistency-report.json` for the Tier-3 corpus
|
|
10
10
|
* checks. `pnpm validate:content` regenerates them and **fails if a committed
|
|
11
11
|
* file is stale**, so a drift between content and the checked-in report cannot
|
|
12
|
-
* pass CI unnoticed.
|
|
12
|
+
* pass CI unnoticed. A report git does not track (a course that ignores
|
|
13
|
+
* `build/`) is refreshed and reported, never a failure: nothing could be
|
|
14
|
+
* committed, so nothing can be stale, and the first run passes.
|
|
13
15
|
*
|
|
14
16
|
* The reports are deterministic: keys are sorted recursively, rows follow the
|
|
15
17
|
* manifest's stable ordering, and nothing records a timestamp or a content
|
|
16
18
|
* hash — a file changes only when a resolution or a finding changes.
|
|
17
19
|
*/
|
|
18
20
|
|
|
21
|
+
import { execFileSync } from 'node:child_process';
|
|
19
22
|
import {
|
|
20
23
|
existsSync,
|
|
21
24
|
mkdirSync,
|
|
@@ -100,14 +103,38 @@ export function buildResolutionReports(
|
|
|
100
103
|
|
|
101
104
|
export interface ResolutionReportSync {
|
|
102
105
|
readonly written: number;
|
|
103
|
-
/**
|
|
106
|
+
/**
|
|
107
|
+
* Repo-relative paths git tracks that were out of date (created, changed,
|
|
108
|
+
* or removed): the caller fails on these so the checked-in reports stay honest.
|
|
109
|
+
*/
|
|
104
110
|
readonly stale: readonly string[];
|
|
111
|
+
/** Paths rewritten or removed that git does not track: refreshed, nothing to commit. */
|
|
112
|
+
readonly refreshed: readonly string[];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The report files git tracks under `dir`, by name. Empty when `dir` is
|
|
117
|
+
* ignored, outside a repository, or git is not on the path: then no report
|
|
118
|
+
* can be committed, so none can be stale.
|
|
119
|
+
*/
|
|
120
|
+
export function trackedReportNames(dir: string): Set<string> {
|
|
121
|
+
try {
|
|
122
|
+
const out = execFileSync('git', ['ls-files', '-z', '--', '.'], {
|
|
123
|
+
cwd: dir,
|
|
124
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
125
|
+
encoding: 'utf8',
|
|
126
|
+
});
|
|
127
|
+
return new Set(out.split('\0').filter((name) => name !== ''));
|
|
128
|
+
} catch {
|
|
129
|
+
return new Set();
|
|
130
|
+
}
|
|
105
131
|
}
|
|
106
132
|
|
|
107
133
|
/**
|
|
108
134
|
* Write every expected report and prune orphans. Returns the paths that did not
|
|
109
|
-
* already match on disk — the caller turns a
|
|
110
|
-
* failure so the checked-in reports stay
|
|
135
|
+
* already match on disk, split by whether git tracks them — the caller turns a
|
|
136
|
+
* non-empty `stale` list into a build failure so the checked-in reports stay
|
|
137
|
+
* honest, and prints `refreshed` as information.
|
|
111
138
|
*/
|
|
112
139
|
export function syncResolutionReports(
|
|
113
140
|
manifest: Manifest,
|
|
@@ -120,7 +147,9 @@ export function syncResolutionReports(
|
|
|
120
147
|
? readdirSync(RESOLUTION_DIR).filter((name) => name.endsWith('.json'))
|
|
121
148
|
: [],
|
|
122
149
|
);
|
|
150
|
+
const tracked = trackedReportNames(RESOLUTION_DIR);
|
|
123
151
|
const stale: string[] = [];
|
|
152
|
+
const refreshed: string[] = [];
|
|
124
153
|
|
|
125
154
|
for (const [relPath, content] of [...expected].sort(([a], [b]) =>
|
|
126
155
|
a.localeCompare(b),
|
|
@@ -133,7 +162,7 @@ export function syncResolutionReports(
|
|
|
133
162
|
: undefined;
|
|
134
163
|
if (current !== content) {
|
|
135
164
|
writeFileSync(absolute, content);
|
|
136
|
-
stale.push(
|
|
165
|
+
(tracked.has(name) ? stale : refreshed).push(
|
|
137
166
|
`${relPath} (${current === undefined ? 'missing' : 'changed'})`,
|
|
138
167
|
);
|
|
139
168
|
}
|
|
@@ -141,10 +170,12 @@ export function syncResolutionReports(
|
|
|
141
170
|
|
|
142
171
|
for (const orphan of [...onDisk].sort()) {
|
|
143
172
|
rmSync(join(RESOLUTION_DIR, orphan));
|
|
144
|
-
|
|
173
|
+
(tracked.has(orphan) ? stale : refreshed).push(
|
|
174
|
+
`${RESOLUTION_PREFIX}${orphan} (stale, removed)`,
|
|
175
|
+
);
|
|
145
176
|
}
|
|
146
177
|
|
|
147
|
-
return { written: expected.size, stale };
|
|
178
|
+
return { written: expected.size, stale, refreshed };
|
|
148
179
|
}
|
|
149
180
|
|
|
150
181
|
export { RESOLUTION_DIR };
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `pnpm validate:content` — regenerate `build/manifest.json` and report its
|
|
3
|
+
* diagnostics, whole. A course's `scripts/compile-manifest.ts` is three lines:
|
|
4
|
+
*
|
|
5
|
+
* import { runValidateContent } from 'explico/compiler/validate-content';
|
|
6
|
+
* import { courseConfig } from '../content/course.config';
|
|
7
|
+
* process.exitCode = await runValidateContent(courseConfig);
|
|
8
|
+
*
|
|
9
|
+
* It compiles the one manifest (the single walk of the content tree), writes
|
|
10
|
+
* it deterministically, prints the warnings, refreshes the per-lesson
|
|
11
|
+
* resolution reports, `content/versions.json` (rule C5) and the
|
|
12
|
+
* rules-in-effect page, and returns 1 if any blocking diagnostic is present.
|
|
13
|
+
* `pnpm build` runs it first, so `astro build` and every page component read a
|
|
14
|
+
* current manifest and never re-read the content tree.
|
|
15
|
+
*
|
|
16
|
+
* Every line printed is also recorded, in Python's `logging` format, to
|
|
17
|
+
* `build/logs/<timestamp>/{error,warning,info}.log` (`./logging`); `log.<level>()`
|
|
18
|
+
* returns its message so the printed line and the recorded one cannot drift.
|
|
19
|
+
*/
|
|
20
|
+
import { notationSourceLocatorGaps } from './collections';
|
|
21
|
+
import type { CourseConfig } from './course-config';
|
|
22
|
+
import { createRunLog, diagnosticLine } from './logging';
|
|
23
|
+
import { compileManifest, MANIFEST_PATH, writeManifest } from './manifest';
|
|
24
|
+
import { syncPageVersions } from './page-versions';
|
|
25
|
+
import { syncResolutionReports } from './resolution-report';
|
|
26
|
+
import { consistencyCounts } from '../reference/consistency';
|
|
27
|
+
import { curriculumErrors } from '../curriculum/validation';
|
|
28
|
+
import { writeRulesInEffect } from '../rules/in-effect';
|
|
29
|
+
|
|
30
|
+
export async function runValidateContent(courseConfig: CourseConfig): Promise<number> {
|
|
31
|
+
const log = createRunLog();
|
|
32
|
+
|
|
33
|
+
let manifest;
|
|
34
|
+
try {
|
|
35
|
+
manifest = await compileManifest(courseConfig);
|
|
36
|
+
} catch (error) {
|
|
37
|
+
// The log keeps why the run ended; the console gets the message, not a stack.
|
|
38
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
39
|
+
log.error('manifest', `Manifest compilation failed: ${message}`);
|
|
40
|
+
const summary = log.close();
|
|
41
|
+
console.error(`Manifest compilation failed: ${message}\nRun log: ${summary.relativeDirectory}.`);
|
|
42
|
+
return 1;
|
|
43
|
+
}
|
|
44
|
+
writeManifest(manifest);
|
|
45
|
+
|
|
46
|
+
const {
|
|
47
|
+
curriculum,
|
|
48
|
+
notation,
|
|
49
|
+
alignment,
|
|
50
|
+
math,
|
|
51
|
+
consistency,
|
|
52
|
+
equations,
|
|
53
|
+
tables,
|
|
54
|
+
figures,
|
|
55
|
+
diagrams,
|
|
56
|
+
checks,
|
|
57
|
+
examples,
|
|
58
|
+
folds,
|
|
59
|
+
knowledge,
|
|
60
|
+
rawFloats,
|
|
61
|
+
config,
|
|
62
|
+
waivers,
|
|
63
|
+
} = manifest.diagnostics;
|
|
64
|
+
|
|
65
|
+
for (const warning of curriculum.filter((issue) => issue.severity === 'warning')) {
|
|
66
|
+
console.warn(log.warning('curriculum', `[${warning.kind}] ${warning.message}`));
|
|
67
|
+
}
|
|
68
|
+
for (const warning of notation.filter((d) => d.severity === 'warning')) {
|
|
69
|
+
console.warn(log.warning('notation', `[${warning.code}] ${warning.message}`));
|
|
70
|
+
}
|
|
71
|
+
for (const warning of alignment.filter((d) => d.severity === 'warning')) {
|
|
72
|
+
console.warn(log.warning('alignment', `[${warning.code}] ${warning.message}`));
|
|
73
|
+
}
|
|
74
|
+
const floatGroups = [
|
|
75
|
+
['equations', equations],
|
|
76
|
+
['tables', tables],
|
|
77
|
+
['figures', figures],
|
|
78
|
+
['diagrams', diagrams],
|
|
79
|
+
['checks', checks],
|
|
80
|
+
['examples', examples],
|
|
81
|
+
['folds', folds],
|
|
82
|
+
['rawFloats', rawFloats],
|
|
83
|
+
] as const;
|
|
84
|
+
for (const [source, group] of floatGroups) {
|
|
85
|
+
for (const warning of group.filter((d) => d.severity === 'warning')) {
|
|
86
|
+
console.warn(log.warning(source, `[${warning.code}] ${warning.message}`));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// `diagnosticLine` prefixes a code owned by a lettered rule with its ID
|
|
90
|
+
// (`[N9 prose-notation] file:line: …`), so the console names the rule page.
|
|
91
|
+
for (const issue of math.filter((item) => item.severity === 'warning')) {
|
|
92
|
+
console.warn(log.warning('math', diagnosticLine(issue)));
|
|
93
|
+
}
|
|
94
|
+
for (const warning of knowledge) {
|
|
95
|
+
console.warn(log.warning('knowledge', `[${warning.code}] ${warning.message}`));
|
|
96
|
+
}
|
|
97
|
+
// The course settings themselves: a deprecated top-level block, a retired rule.
|
|
98
|
+
for (const warning of config) {
|
|
99
|
+
console.warn(log.warning('config', `[${warning.code}] ${warning.message}`));
|
|
100
|
+
}
|
|
101
|
+
// Per-place waivers: a stale one is a warning; a malformed or invalid one
|
|
102
|
+
// fails below with the other errors.
|
|
103
|
+
for (const warning of waivers.filter((d) => d.severity === 'warning')) {
|
|
104
|
+
console.warn(log.warning('waivers', diagnosticLine(warning)));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const locatorGaps = notationSourceLocatorGaps();
|
|
108
|
+
if (locatorGaps.bare > 0) {
|
|
109
|
+
console.warn(
|
|
110
|
+
log.warning(
|
|
111
|
+
'consistency',
|
|
112
|
+
`[notation-source-locator] ${locatorGaps.bare} of ${locatorGaps.total} notation source citations lack a non-empty locator.`,
|
|
113
|
+
),
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const consistencyWarnings = consistency.filter((diagnostic) => diagnostic.severity === 'warning');
|
|
118
|
+
if (consistencyWarnings.length > 0) {
|
|
119
|
+
console.warn(
|
|
120
|
+
log.warning(
|
|
121
|
+
'consistency',
|
|
122
|
+
`[consistency] ${consistencyWarnings.length} Tier-3 finding(s): ` +
|
|
123
|
+
Object.entries(consistencyCounts(consistencyWarnings))
|
|
124
|
+
.filter(([, count]) => count > 0)
|
|
125
|
+
.map(([code, count]) => `${code}=${count}`)
|
|
126
|
+
.join(', '),
|
|
127
|
+
),
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Every severity-`info` diagnostic goes to the log only.
|
|
132
|
+
log.infoDiagnostics(manifest.diagnostics);
|
|
133
|
+
for (const diagnostic of consistencyWarnings) {
|
|
134
|
+
log.info('consistency', `[${diagnostic.code}] ${diagnostic.message}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const reports = syncResolutionReports(manifest);
|
|
138
|
+
console.log(
|
|
139
|
+
log.info(
|
|
140
|
+
'manifest',
|
|
141
|
+
`Resolution report: ${reports.written} file(s) under resolution/` +
|
|
142
|
+
(reports.refreshed.length > 0
|
|
143
|
+
? ` (${reports.refreshed.length} refreshed; git does not track them, nothing to commit).`
|
|
144
|
+
: '.'),
|
|
145
|
+
),
|
|
146
|
+
);
|
|
147
|
+
// Each page's own version (rule C5): rewritten here and committed with the
|
|
148
|
+
// change; in CI a record that would change fails below, like a lockfile.
|
|
149
|
+
const pageVersions = syncPageVersions(manifest);
|
|
150
|
+
if (pageVersions.written) {
|
|
151
|
+
console.log(
|
|
152
|
+
log.info(
|
|
153
|
+
'page-versions',
|
|
154
|
+
`content/versions.json ${
|
|
155
|
+
pageVersions.changed[0] === '*' ? 'written' : `updated for ${pageVersions.changed.length} page(s)`
|
|
156
|
+
} — commit it with the change (rule C5).`,
|
|
157
|
+
),
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
// The page an agent reads before drafting. Regenerated every run, never committed.
|
|
161
|
+
writeRulesInEffect(manifest);
|
|
162
|
+
console.log(
|
|
163
|
+
log.info('manifest', `Rules in effect: build/rules-in-effect.md (${manifest.rules.disabled.length} rule(s) off).`),
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
const failures: string[] = [];
|
|
167
|
+
// `block(source, …)`: a blocking finding under its log source (not a diagnostic code).
|
|
168
|
+
const block = (source: string, message: string): void => {
|
|
169
|
+
failures.push(log.error(source, message));
|
|
170
|
+
};
|
|
171
|
+
for (const stalePath of reports.stale) {
|
|
172
|
+
block('resolution-report', `[resolution-report] ${stalePath} is out of date — commit the regenerated resolution/ files`);
|
|
173
|
+
}
|
|
174
|
+
if (pageVersions.diagnostic) block('page-versions', diagnosticLine(pageVersions.diagnostic));
|
|
175
|
+
for (const issue of curriculumErrors(curriculum)) block('curriculum', `[curriculum:${issue.kind}] ${issue.message}`);
|
|
176
|
+
for (const issue of notation.filter((d) => d.severity === 'error')) block('notation', `[notation:${issue.code}] ${issue.message}`);
|
|
177
|
+
for (const issue of alignment.filter((d) => d.severity === 'error')) block('alignment', `[alignment:${issue.code}] ${issue.message}`);
|
|
178
|
+
for (const issue of math.filter((item) => item.severity === 'error')) block('math', diagnosticLine(issue));
|
|
179
|
+
for (const issue of waivers.filter((d) => d.severity === 'error')) block('waivers', diagnosticLine(issue));
|
|
180
|
+
for (const [source, group] of floatGroups) {
|
|
181
|
+
for (const issue of group.filter((d) => d.severity === 'error')) block(source, `[${issue.code}] ${issue.message}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (failures.length > 0) {
|
|
185
|
+
const summary = log.close();
|
|
186
|
+
console.error(
|
|
187
|
+
`Content manifest has ${failures.length} blocking diagnostic(s):\n${failures.map((line) => `- ${line}`).join('\n')}\n` +
|
|
188
|
+
`Run log: ${summary.relativeDirectory} (${summary.counts.ERROR} error(s), ${summary.counts.WARNING} warning(s)).`,
|
|
189
|
+
);
|
|
190
|
+
return 1;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const itemCount = manifest.lessons.reduce((count, lesson) => count + lesson.assessments.length, 0);
|
|
194
|
+
const relPath = MANIFEST_PATH.replace(`${process.cwd()}/`, '');
|
|
195
|
+
console.log(
|
|
196
|
+
log.info(
|
|
197
|
+
'manifest',
|
|
198
|
+
`Manifest written to ${relPath} (schema v${manifest.schemaVersion}, knowledge v${manifest.about.knowledge.version}): ` +
|
|
199
|
+
`${manifest.competencies.length} competencies, ${manifest.lessons.length} lessons, ` +
|
|
200
|
+
`${itemCount} lesson assessment links, ${manifest.notation.definitions.length} notation definitions, ` +
|
|
201
|
+
`${manifest.sources.length} sources, ${manifest.tracks.length} track(s). ` +
|
|
202
|
+
`Content fingerprint ${manifest.hashes.contentTree}.`,
|
|
203
|
+
),
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
const summary = log.close();
|
|
207
|
+
console.log(
|
|
208
|
+
`Run log: ${summary.relativeDirectory} ` +
|
|
209
|
+
`(${summary.counts.ERROR} error(s), ${summary.counts.WARNING} warning(s), ${summary.counts.INFO} info).`,
|
|
210
|
+
);
|
|
211
|
+
return 0;
|
|
212
|
+
}
|