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-main.ts
CHANGED
|
@@ -12,8 +12,10 @@
|
|
|
12
12
|
* so every verb reaches every course with the package and no course forks
|
|
13
13
|
* the router. Each verb's logic lives in `./cli`; this module only parses
|
|
14
14
|
* argv, prints (`--json` = data only, no stack traces), and returns the exit
|
|
15
|
-
* code. Everything except `new` is read-only; `move`, `rename
|
|
16
|
-
* print a diff and change nothing without `--write`.
|
|
15
|
+
* code. Everything except `new` and `verify` is read-only; `move`, `rename`,
|
|
16
|
+
* `fix` and `init-agent` print a diff and change nothing without `--write`.
|
|
17
|
+
* Each verb's row — usage, flags, what it writes, the type its `--json`
|
|
18
|
+
* prints — is in `./cli-verbs`; `content help <verb>` prints it.
|
|
17
19
|
*/
|
|
18
20
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
19
21
|
import { dirname, join } from 'node:path';
|
|
@@ -44,7 +46,7 @@ import {
|
|
|
44
46
|
formatSymbolReport,
|
|
45
47
|
ownerTag,
|
|
46
48
|
parseRulesFilter,
|
|
47
|
-
|
|
49
|
+
planFixMany,
|
|
48
50
|
planInitAgent,
|
|
49
51
|
planMove,
|
|
50
52
|
planRename,
|
|
@@ -53,31 +55,45 @@ import {
|
|
|
53
55
|
scaffoldLesson,
|
|
54
56
|
scaffoldTerm,
|
|
55
57
|
summarizeDiagnostics,
|
|
58
|
+
type ContentCliCode,
|
|
59
|
+
type EditPlan,
|
|
60
|
+
type RulesReport,
|
|
56
61
|
type Scaffold,
|
|
57
62
|
} from './cli';
|
|
63
|
+
import { formatVerbHelp, loadCliReference, verbHelp, type HelpJson } from './cli-reference';
|
|
64
|
+
import { CONTENT_USAGE } from './cli-verbs';
|
|
65
|
+
import { planContentRelease } from './content-release';
|
|
66
|
+
import { formatVerifyReport, formatVerifyStep, runVerify, type VerifyReport } from './cli-verify';
|
|
58
67
|
import type { CourseConfig } from './course-config';
|
|
59
|
-
import { compileManifest } from './manifest';
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
68
|
+
import { compileManifest, type Manifest } from './manifest';
|
|
69
|
+
|
|
70
|
+
export { CONTENT_USAGE };
|
|
71
|
+
|
|
72
|
+
/** `--json` of `rules`: the report without its Markdown page. */
|
|
73
|
+
export type RulesJson = Pick<RulesReport, 'filter' | 'count' | 'rules'>;
|
|
74
|
+
|
|
75
|
+
/** `--json` of `move`, `rename`, `fix` and `init-agent`: the plan, and whether `--write` applied it. */
|
|
76
|
+
export type EditPlanJson = EditPlan & {
|
|
77
|
+
/** True when `--write` applied the edits; false for a dry run. */
|
|
78
|
+
readonly written: boolean;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** `--json` of `new lesson`, `new deck` and `new term`. */
|
|
82
|
+
export interface NewJson {
|
|
83
|
+
readonly ok: true;
|
|
84
|
+
/** Course-relative paths of the files it created. */
|
|
85
|
+
readonly created: readonly string[];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** `--json` of any verb whose run failed: a stable code and a message, never a stack. */
|
|
89
|
+
export interface ErrorJson {
|
|
90
|
+
readonly ok: false;
|
|
91
|
+
readonly error: {
|
|
92
|
+
/** `internal` for an unexpected throw; every other code is stable. */
|
|
93
|
+
readonly code: ContentCliCode | 'internal';
|
|
94
|
+
readonly message: string;
|
|
95
|
+
};
|
|
96
|
+
}
|
|
81
97
|
|
|
82
98
|
export interface ContentArgs {
|
|
83
99
|
readonly command: string;
|
|
@@ -101,6 +117,10 @@ export interface ContentArgs {
|
|
|
101
117
|
readonly to?: string;
|
|
102
118
|
/** `--write`: apply a planned edit instead of only showing its diff. */
|
|
103
119
|
readonly write: boolean;
|
|
120
|
+
/** `--e2e`: `verify` runs the browser tests too. */
|
|
121
|
+
readonly e2e: boolean;
|
|
122
|
+
/** `--force`: `verify` runs even on a tree its last pass covered. */
|
|
123
|
+
readonly force: boolean;
|
|
104
124
|
}
|
|
105
125
|
|
|
106
126
|
const VALUED_FLAGS = ['element', 'enforcement', 'edition', 'code', 'lesson', 'from', 'to'] as const;
|
|
@@ -112,6 +132,8 @@ export function parseContentArgs(argv: readonly string[]): ContentArgs {
|
|
|
112
132
|
let changed = false;
|
|
113
133
|
let all = false;
|
|
114
134
|
let write = false;
|
|
135
|
+
let e2e = false;
|
|
136
|
+
let force = false;
|
|
115
137
|
const valued = new RegExp(`^--(${VALUED_FLAGS.join('|')})(?:=(.*))?$`);
|
|
116
138
|
for (let i = 0; i < argv.length; i += 1) {
|
|
117
139
|
const arg = argv[i]!;
|
|
@@ -120,6 +142,8 @@ export function parseContentArgs(argv: readonly string[]): ContentArgs {
|
|
|
120
142
|
else if (arg === '--changed') changed = true;
|
|
121
143
|
else if (arg === '--all') all = true;
|
|
122
144
|
else if (arg === '--write') write = true;
|
|
145
|
+
else if (arg === '--e2e') e2e = true;
|
|
146
|
+
else if (arg === '--force') force = true;
|
|
123
147
|
else if (match) values[match[1] as (typeof VALUED_FLAGS)[number]] = match[2] ?? argv[(i += 1)] ?? '';
|
|
124
148
|
else positionals.push(arg);
|
|
125
149
|
}
|
|
@@ -130,10 +154,22 @@ export function parseContentArgs(argv: readonly string[]): ContentArgs {
|
|
|
130
154
|
changed,
|
|
131
155
|
all,
|
|
132
156
|
write,
|
|
157
|
+
e2e,
|
|
158
|
+
force,
|
|
133
159
|
...values,
|
|
134
160
|
};
|
|
135
161
|
}
|
|
136
162
|
|
|
163
|
+
/**
|
|
164
|
+
* A lesson's diagnostics include the shared cards it shows (a fault in one is
|
|
165
|
+
* the page's too), so `context` says how many sit in the lesson's own file —
|
|
166
|
+
* the number `diagnostics --lesson` lists.
|
|
167
|
+
*/
|
|
168
|
+
function diagnosticsWhere(diagnostics: readonly { readonly file?: string }[], file: string): string {
|
|
169
|
+
const own = diagnostics.filter((diagnostic) => diagnostic.file === file).length;
|
|
170
|
+
return own === diagnostics.length ? '' : ` (${own} in ${file}, ${diagnostics.length - own} in the shared cards it shows)`;
|
|
171
|
+
}
|
|
172
|
+
|
|
137
173
|
/** Where a run prints; the process streams by default, a buffer in a test. */
|
|
138
174
|
export interface ContentIo {
|
|
139
175
|
readonly out: (text: string) => void;
|
|
@@ -148,14 +184,14 @@ const PROCESS_IO: ContentIo = {
|
|
|
148
184
|
/** A usage or input mistake, reported like a `ContentCliError` and ending the run with 1. */
|
|
149
185
|
class Usage extends Error {
|
|
150
186
|
constructor(
|
|
151
|
-
readonly code:
|
|
187
|
+
readonly code: ContentCliCode,
|
|
152
188
|
message: string,
|
|
153
189
|
) {
|
|
154
190
|
super(message);
|
|
155
191
|
}
|
|
156
192
|
}
|
|
157
193
|
|
|
158
|
-
function usage(code:
|
|
194
|
+
function usage(code: ContentCliCode, message: string): never {
|
|
159
195
|
throw new Usage(code, message);
|
|
160
196
|
}
|
|
161
197
|
|
|
@@ -179,16 +215,18 @@ export async function runContentCli(
|
|
|
179
215
|
courseConfig: CourseConfig,
|
|
180
216
|
argv: readonly string[] = process.argv.slice(2),
|
|
181
217
|
io: ContentIo = PROCESS_IO,
|
|
218
|
+
/** A manifest already compiled for this course, reused instead of compiling one: a test running many verbs compiles once. */
|
|
219
|
+
compiled?: Manifest,
|
|
182
220
|
): Promise<number> {
|
|
183
221
|
const args = parseContentArgs(argv);
|
|
184
222
|
const json = (value: unknown) => io.out(`${JSON.stringify(value, null, 2)}\n`);
|
|
185
223
|
try {
|
|
186
|
-
return await dispatch(courseConfig, args, io, json);
|
|
224
|
+
return await dispatch(courseConfig, args, io, json, compiled);
|
|
187
225
|
} catch (error) {
|
|
188
226
|
const code =
|
|
189
227
|
error instanceof Usage || error instanceof ContentCliError ? error.code : 'internal';
|
|
190
228
|
const message = error instanceof Error ? error.message : String(error);
|
|
191
|
-
if (args.json) json({ ok: false, error: { code, message } });
|
|
229
|
+
if (args.json) json({ ok: false, error: { code, message } } satisfies ErrorJson);
|
|
192
230
|
else io.err(`Error [${code}]: ${message}\n`);
|
|
193
231
|
return 1;
|
|
194
232
|
}
|
|
@@ -199,13 +237,43 @@ async function dispatch(
|
|
|
199
237
|
args: ContentArgs,
|
|
200
238
|
io: ContentIo,
|
|
201
239
|
json: (value: unknown) => void,
|
|
240
|
+
compiled: Manifest | undefined,
|
|
202
241
|
): Promise<number> {
|
|
203
242
|
const out = io.out;
|
|
204
|
-
if (args.command === '' || args.command === 'help' || args.command === '
|
|
243
|
+
if (args.command === '' || args.command === '--help' || (args.command === 'help' && args.positionals.length === 0 && !args.json)) {
|
|
205
244
|
out(`${CONTENT_USAGE}\n`);
|
|
206
245
|
return 0;
|
|
207
246
|
}
|
|
208
247
|
|
|
248
|
+
// `help` reads the generated reference beside the engine: no manifest.
|
|
249
|
+
if (args.command === 'help') {
|
|
250
|
+
if (args.positionals.length > 1) usage('usage', `content help takes one verb; got ${args.positionals.join(' ')}.`);
|
|
251
|
+
const reference = loadCliReference();
|
|
252
|
+
const [word] = args.positionals;
|
|
253
|
+
if (word === undefined) {
|
|
254
|
+
json(reference satisfies HelpJson);
|
|
255
|
+
return 0;
|
|
256
|
+
}
|
|
257
|
+
const help = verbHelp(reference, word);
|
|
258
|
+
if (args.json) json(help satisfies HelpJson);
|
|
259
|
+
else out(formatVerbHelp(reference, help));
|
|
260
|
+
return 0;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// `verify` runs the course's own scripts, each in its own process: no manifest here.
|
|
264
|
+
if (args.command === 'verify') {
|
|
265
|
+
if (args.positionals.length > 0) usage('usage', `content verify takes no argument; got ${args.positionals.join(' ')}.`);
|
|
266
|
+
if (!args.json) out(`verify (${args.e2e ? 'with' : 'without'} e2e; whole output under node_modules/.cache/explico/verify/)\n`);
|
|
267
|
+
const report = runVerify({
|
|
268
|
+
e2e: args.e2e,
|
|
269
|
+
force: args.force,
|
|
270
|
+
...(args.json ? {} : { onStep: (event) => event.phase === 'end' && out(formatVerifyStep(event.step)) }),
|
|
271
|
+
});
|
|
272
|
+
if (args.json) json(report satisfies VerifyReport);
|
|
273
|
+
else out(report.cached ? formatVerifyReport(report) : `\n${formatVerifyReport(report)}`);
|
|
274
|
+
return report.ok ? 0 : 1;
|
|
275
|
+
}
|
|
276
|
+
|
|
209
277
|
if (args.command === 'new') {
|
|
210
278
|
const [kind, id] = args.positionals;
|
|
211
279
|
if (kind !== 'lesson' && kind !== 'deck' && kind !== 'term') {
|
|
@@ -217,7 +285,7 @@ async function dispatch(
|
|
|
217
285
|
}
|
|
218
286
|
const scaffold = kind === 'lesson' ? scaffoldLesson(id) : kind === 'deck' ? scaffoldDeck(id) : scaffoldTerm(id);
|
|
219
287
|
const written = writeScaffold(scaffold);
|
|
220
|
-
if (args.json) json({ ok: true, created: written });
|
|
288
|
+
if (args.json) json({ ok: true, created: written } satisfies NewJson);
|
|
221
289
|
else out(`Created:\n${written.map((p) => ` ${p}`).join('\n')}\n`);
|
|
222
290
|
return 0;
|
|
223
291
|
}
|
|
@@ -228,17 +296,25 @@ async function dispatch(
|
|
|
228
296
|
usage('usage', `content ${args.command} takes no <lesson>; got ${args.positionals.join(' ')}.`);
|
|
229
297
|
}
|
|
230
298
|
|
|
231
|
-
// `init-agent` copies the engine's procedures into the course
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
if (args.
|
|
299
|
+
// `init-agent` copies the engine's procedures into the course, `release`
|
|
300
|
+
// edits the changelog and the config: no manifest for either.
|
|
301
|
+
if (args.command === 'init-agent' || args.command === 'release') {
|
|
302
|
+
let plan;
|
|
303
|
+
if (args.command === 'init-agent') {
|
|
304
|
+
if (args.positionals.length > 0) usage('usage', `content init-agent takes no argument; got ${args.positionals.join(' ')}.`);
|
|
305
|
+
plan = planInitAgent();
|
|
306
|
+
} else {
|
|
307
|
+
if (args.positionals.length > 1) usage('usage', `content release takes one of major, minor, patch; got ${args.positionals.join(' ')}.`);
|
|
308
|
+
plan = planContentRelease(args.positionals[0]);
|
|
309
|
+
}
|
|
310
|
+
if (args.json) json({ ...plan, written: args.write } satisfies EditPlanJson);
|
|
236
311
|
else out(renderEditPlan(plan));
|
|
237
312
|
if (plan.edits.length === 0) return 0;
|
|
313
|
+
const next = plan.next.length > 0 ? plan.next.join(' · ') : '';
|
|
238
314
|
if (args.write) {
|
|
239
315
|
const touched = applyEditPlan(plan);
|
|
240
|
-
if (!args.json) out(`\nwritten: ${touched.join(', ')}\n`);
|
|
241
|
-
} else if (!args.json) out(
|
|
316
|
+
if (!args.json) out(`\nwritten: ${touched.join(', ')}${next === '' ? '' : `\nnext: ${next}`}\n`);
|
|
317
|
+
} else if (!args.json) out(`\ndry run: nothing written. Add --write to apply${next === '' ? '' : `; then ${next}`}.\n`);
|
|
242
318
|
return 0;
|
|
243
319
|
}
|
|
244
320
|
|
|
@@ -264,7 +340,7 @@ async function dispatch(
|
|
|
264
340
|
usage('unknown-command', `Unknown command "${args.command}".\n${CONTENT_USAGE}`);
|
|
265
341
|
}
|
|
266
342
|
|
|
267
|
-
const manifest = await compileManifest(courseConfig);
|
|
343
|
+
const manifest = compiled ?? (await compileManifest(courseConfig));
|
|
268
344
|
|
|
269
345
|
switch (args.command) {
|
|
270
346
|
case 'status': {
|
|
@@ -275,11 +351,16 @@ async function dispatch(
|
|
|
275
351
|
}
|
|
276
352
|
const c = report.counts;
|
|
277
353
|
const unreleased = report.pageVersions.unreleased;
|
|
354
|
+
const UNRELEASED_SHOWN = 6;
|
|
355
|
+
const unreleasedList =
|
|
356
|
+
unreleased.length <= UNRELEASED_SHOWN
|
|
357
|
+
? unreleased.join(', ')
|
|
358
|
+
: `${unreleased.slice(0, UNRELEASED_SHOWN).join(', ')}, … ${unreleased.length - UNRELEASED_SHOWN} more in --json`;
|
|
278
359
|
out(
|
|
279
360
|
`Knowledge version ${report.knowledgeVersion}\n` +
|
|
280
361
|
`Page versions: ${report.pageVersions.tracked} page(s) tracked, ` +
|
|
281
362
|
`${unreleased.length} with changes not yet released` +
|
|
282
|
-
`${unreleased.length > 0 ? ` (${
|
|
363
|
+
`${unreleased.length > 0 ? ` (${unreleasedList})` : ''}\n` +
|
|
283
364
|
`Lessons ${c.lessons} · competencies ${c.competencies} · assessments ${c.assessments} · ` +
|
|
284
365
|
`sources ${c.sources} · tracks ${c.tracks} · notation ${c.notationDefinitions} · ` +
|
|
285
366
|
`keyed equations ${c.keyedEquations} · tables ${c.keyedTables} · ` +
|
|
@@ -314,11 +395,10 @@ async function dispatch(
|
|
|
314
395
|
if (!oldKey || !newKey || extra.length > 0) usage('usage', 'content rename needs <old> <new>.');
|
|
315
396
|
plan = planRename(manifest, oldKey, newKey);
|
|
316
397
|
} else {
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
plan = planFix(manifest, lesson);
|
|
398
|
+
if (args.all === (args.positionals.length > 0)) usage('usage', 'content fix needs <lesson> ids, or --all for every lesson.');
|
|
399
|
+
plan = planFixMany(manifest, args.all ? manifest.lessons.map((lesson) => lesson.id) : args.positionals);
|
|
320
400
|
}
|
|
321
|
-
if (args.json) json({ ...plan, written: args.write });
|
|
401
|
+
if (args.json) json({ ...plan, written: args.write } satisfies EditPlanJson);
|
|
322
402
|
else out(renderEditPlan(plan));
|
|
323
403
|
if (plan.edits.length === 0) return 0;
|
|
324
404
|
if (args.write) {
|
|
@@ -347,7 +427,7 @@ async function dispatch(
|
|
|
347
427
|
...(args.edition === undefined ? {} : { edition: args.edition }),
|
|
348
428
|
});
|
|
349
429
|
const report = contentRules(manifest, filter);
|
|
350
|
-
if (args.json) json({ filter: report.filter, count: report.count, rules: report.rules });
|
|
430
|
+
if (args.json) json({ filter: report.filter, count: report.count, rules: report.rules } satisfies RulesJson);
|
|
351
431
|
else out(report.markdown);
|
|
352
432
|
return 0;
|
|
353
433
|
}
|
|
@@ -408,7 +488,7 @@ async function dispatch(
|
|
|
408
488
|
`requires: ${report.lesson.requires.join(', ') || '(none)'}\n` +
|
|
409
489
|
`notation: ${report.notation.map((n) => n.key).join(', ') || '(none)'}\n` +
|
|
410
490
|
`sources: ${report.sources.map((s) => s.id).join(', ') || '(none)'}\n` +
|
|
411
|
-
`diagnostics: ${report.diagnostics.length}\n` +
|
|
491
|
+
`diagnostics: ${report.diagnostics.length}${diagnosticsWhere(report.diagnostics, report.lesson.file)}\n` +
|
|
412
492
|
`tokens: context ${t.context} vs doc set ${t.docSet} + corpus ${t.corpus} ` +
|
|
413
493
|
`(${(t.ratio * 100).toFixed(2)}% of the baseline)\n`,
|
|
414
494
|
);
|
|
@@ -454,8 +534,16 @@ async function dispatch(
|
|
|
454
534
|
else {
|
|
455
535
|
for (const entry of report.lessons) {
|
|
456
536
|
out(`${entry.ok ? 'ok ' : 'FAIL'} ${entry.lesson}${entry.rendered ? '' : ' (render failed)'}\n`);
|
|
537
|
+
// Errors and warnings line by line; info, which never fails a check, counted by code.
|
|
538
|
+
const infos = new Map<string, number>();
|
|
457
539
|
for (const diagnostic of entry.diagnostics) {
|
|
458
|
-
|
|
540
|
+
if (diagnostic.severity === 'info') infos.set(diagnostic.code, (infos.get(diagnostic.code) ?? 0) + 1);
|
|
541
|
+
else out(` [${diagnostic.severity}] ${diagnostic.code}${ownerTag(diagnostic)}: ${diagnostic.message}\n`);
|
|
542
|
+
}
|
|
543
|
+
if (infos.size > 0) {
|
|
544
|
+
const total = [...infos.values()].reduce((sum, n) => sum + n, 0);
|
|
545
|
+
const byCode = [...infos].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([code, n]) => `${code} ${n}`).join(', ');
|
|
546
|
+
out(` + ${total} info (${byCode}); content diagnostics --lesson ${entry.lesson} lists them\n`);
|
|
459
547
|
}
|
|
460
548
|
}
|
|
461
549
|
if (report.lessons.length === 0) out('No changed lessons.\n');
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `content` CLI's machine-readable reference: every verb's row from
|
|
3
|
+
* `./cli-verbs`, and a JSON Schema of every type a verb's `--json` prints.
|
|
4
|
+
*
|
|
5
|
+
* TypeScript types are gone at run time, so `scripts/agent/gen-cli.mjs`
|
|
6
|
+
* reads them from the source with the compiler API and writes the schemas
|
|
7
|
+
* into `docs/authoring/cli-reference.json` beside the rule pages, where the
|
|
8
|
+
* CLI finds them in the workspace and in an installed package alike; it
|
|
9
|
+
* writes `docs/authoring/cli.md` from the same data with `cliReferenceMarkdown`.
|
|
10
|
+
* `--check` holds both to the source in verify and CI.
|
|
11
|
+
*
|
|
12
|
+
* `content help <verb>` prints a verb's row and the shape of its `--json`
|
|
13
|
+
* (`formatVerbHelp`); `--json` prints the row with a self-contained schema.
|
|
14
|
+
* `jsonShapeErrors` checks a value against a schema — the playground's
|
|
15
|
+
* contract test runs every read-only verb through it.
|
|
16
|
+
*/
|
|
17
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
|
|
20
|
+
import { ContentCliError, authoringDocsDir } from './cli';
|
|
21
|
+
import { CONTENT_VERBS, usageLine, type ContentVerb } from './cli-verbs';
|
|
22
|
+
|
|
23
|
+
/** A JSON Schema (draft 2020-12), the subset the generator writes. */
|
|
24
|
+
export type JsonSchema = Readonly<Record<string, unknown>>;
|
|
25
|
+
|
|
26
|
+
/** `docs/authoring/cli-reference.json`, whole: what `content help --json` prints. */
|
|
27
|
+
export interface CliReference {
|
|
28
|
+
/** Where it came from, and that it is generated. */
|
|
29
|
+
readonly generatedBy: string;
|
|
30
|
+
readonly verbs: readonly ContentVerb[];
|
|
31
|
+
/** The type every verb's `--json` prints when the run fails: a stable code and a message, never a stack. */
|
|
32
|
+
readonly error: string;
|
|
33
|
+
/** A JSON Schema of every type a verb's `--json` prints, and of every named type inside one, by name. */
|
|
34
|
+
readonly $defs: Readonly<Record<string, JsonSchema>>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** One verb's row, its usage line, and a self-contained JSON Schema of its `--json`. */
|
|
38
|
+
export interface VerbHelp extends ContentVerb {
|
|
39
|
+
readonly usage: string;
|
|
40
|
+
readonly schema: JsonSchema;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** What `content help <verb> --json` prints: every verb the word names (`new` names three). */
|
|
44
|
+
export interface VerbHelpJson {
|
|
45
|
+
readonly verbs: readonly VerbHelp[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** `content help --json` prints the whole reference; `content help <verb> --json` that verb's rows. */
|
|
49
|
+
export type HelpJson = CliReference | VerbHelpJson;
|
|
50
|
+
|
|
51
|
+
export const CLI_REFERENCE_FILE = 'cli-reference.json';
|
|
52
|
+
|
|
53
|
+
/** The reference beside the engine (`node_modules/explico/docs/authoring/`, or this repository's). */
|
|
54
|
+
export function loadCliReference(): CliReference {
|
|
55
|
+
const dir = authoringDocsDir();
|
|
56
|
+
const file = dir === undefined ? undefined : join(dir, CLI_REFERENCE_FILE);
|
|
57
|
+
if (file === undefined || !existsSync(file)) {
|
|
58
|
+
throw new ContentCliError('unknown-command', `No ${CLI_REFERENCE_FILE} beside the engine (node_modules/explico/docs/authoring).`);
|
|
59
|
+
}
|
|
60
|
+
return JSON.parse(readFileSync(file, 'utf8')) as CliReference;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The verbs a word names: `symbol` one, `new` the three `new …` verbs. */
|
|
64
|
+
export function verbsNamed(word: string): readonly ContentVerb[] {
|
|
65
|
+
const hits = CONTENT_VERBS.filter((verb) => verb.verb === word || verb.verb.startsWith(`${word} `));
|
|
66
|
+
if (hits.length === 0) {
|
|
67
|
+
throw new ContentCliError(
|
|
68
|
+
'unknown-command',
|
|
69
|
+
`Unknown verb "${word}". The verbs: ${[...new Set(CONTENT_VERBS.map((verb) => verb.verb.split(' ')[0]))].join(', ')}.`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
return hits;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The names a schema refers to, itself and transitively. */
|
|
76
|
+
function reachableDefs(name: string, defs: CliReference['$defs'], seen = new Set<string>()): Set<string> {
|
|
77
|
+
if (seen.has(name) || defs[name] === undefined) return seen;
|
|
78
|
+
seen.add(name);
|
|
79
|
+
for (const match of JSON.stringify(defs[name]).matchAll(/"#\/\$defs\/([^"]+)"/g)) reachableDefs(match[1]!, defs, seen);
|
|
80
|
+
return seen;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A self-contained schema of one named type: a `$ref` and the `$defs` it reaches. */
|
|
84
|
+
export function schemaOf(reference: CliReference, name: string): JsonSchema {
|
|
85
|
+
const names = [...reachableDefs(name, reference.$defs)].sort();
|
|
86
|
+
return {
|
|
87
|
+
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
88
|
+
$ref: `#/$defs/${name}`,
|
|
89
|
+
$defs: Object.fromEntries(names.map((n) => [n, reference.$defs[n]])),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function verbHelp(reference: CliReference, word: string): VerbHelpJson {
|
|
94
|
+
return {
|
|
95
|
+
verbs: verbsNamed(word).map((verb) => ({ ...verb, usage: usageLine(verb), schema: schemaOf(reference, verb.json) })),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// --- rendering a schema as a TypeScript-like shape -------------------------
|
|
100
|
+
|
|
101
|
+
function oneLine(text: unknown): string {
|
|
102
|
+
return typeof text === 'string' ? text.replace(/\s+/g, ' ').trim() : '';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function refName(ref: string): string {
|
|
106
|
+
return ref.replace('#/$defs/', '');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function isObjectShape(schema: JsonSchema): boolean {
|
|
110
|
+
return schema.type === 'object' && schema.properties !== undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** A schema's type as one TypeScript-like expression; an object with properties is `{…}` here and expanded by `shapeLines`. */
|
|
114
|
+
function typeExpression(schema: JsonSchema): string {
|
|
115
|
+
if (typeof schema.$ref === 'string') return refName(schema.$ref);
|
|
116
|
+
if ('const' in schema) return JSON.stringify(schema.const);
|
|
117
|
+
if (Array.isArray(schema.enum)) return schema.enum.map((value) => JSON.stringify(value)).join(' | ');
|
|
118
|
+
if (Array.isArray(schema.anyOf)) return (schema.anyOf as JsonSchema[]).map(typeExpression).join(' | ');
|
|
119
|
+
if (schema.type === 'array') {
|
|
120
|
+
const items = typeExpression((schema.items ?? {}) as JsonSchema);
|
|
121
|
+
return / \| /.test(items) ? `(${items})[]` : `${items}[]`;
|
|
122
|
+
}
|
|
123
|
+
if (schema.type === 'object') {
|
|
124
|
+
if (schema.properties !== undefined) return '{…}';
|
|
125
|
+
const value = schema.additionalProperties;
|
|
126
|
+
return `Record<string, ${typeof value === 'object' && value !== null ? typeExpression(value as JsonSchema) : 'unknown'}>`;
|
|
127
|
+
}
|
|
128
|
+
if (schema.type === 'integer') return 'number';
|
|
129
|
+
if (typeof schema.type === 'string') return schema.type;
|
|
130
|
+
return 'unknown';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The object a property's type expands to, when it has one: `{…}`, `{…}[]`, `{…} | null`. */
|
|
134
|
+
function expandable(schema: JsonSchema): JsonSchema | undefined {
|
|
135
|
+
if (isObjectShape(schema)) return schema;
|
|
136
|
+
if (schema.type === 'array') return expandable((schema.items ?? {}) as JsonSchema);
|
|
137
|
+
if (Array.isArray(schema.anyOf)) {
|
|
138
|
+
const objects = (schema.anyOf as JsonSchema[]).map(expandable).filter((s): s is JsonSchema => s !== undefined);
|
|
139
|
+
return objects.length === 1 ? objects[0] : undefined;
|
|
140
|
+
}
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** One line per property, `name?: type — description`, nested objects indented under their property. */
|
|
145
|
+
export function shapeLines(schema: JsonSchema, indent = ' '): string[] {
|
|
146
|
+
const lines: string[] = [];
|
|
147
|
+
const required = new Set((schema.required ?? []) as string[]);
|
|
148
|
+
for (const [name, raw] of Object.entries((schema.properties ?? {}) as Record<string, JsonSchema>)) {
|
|
149
|
+
const description = oneLine(raw.description);
|
|
150
|
+
lines.push(`${indent}${name}${required.has(name) ? '' : '?'}: ${typeExpression(raw)}${description === '' ? '' : ` — ${description}`}`);
|
|
151
|
+
const nested = expandable(raw);
|
|
152
|
+
if (nested !== undefined) lines.push(...shapeLines(nested, `${indent} `));
|
|
153
|
+
}
|
|
154
|
+
return lines;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** A named type as a block: its name and description, then its shape. */
|
|
158
|
+
export function defLines(name: string, schema: JsonSchema): string[] {
|
|
159
|
+
const description = oneLine(schema.description);
|
|
160
|
+
const head = `${name}${isObjectShape(schema) ? '' : ` = ${typeExpression(schema)}`}`;
|
|
161
|
+
return [description === '' ? head : `${head} — ${description}`, ...(isObjectShape(schema) ? shapeLines(schema) : [])];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** A verb's row as labelled lines: what it is for, what it prints and writes, when it exits 1. */
|
|
165
|
+
function verbFacts(verb: ContentVerb): (readonly [string, string])[] {
|
|
166
|
+
return [
|
|
167
|
+
['For', verb.when],
|
|
168
|
+
['Prints', verb.prints],
|
|
169
|
+
['Writes', verb.writes],
|
|
170
|
+
['Exits 1', `on a usage error${verb.exit === undefined ? '' : `, and ${verb.exit}`}`],
|
|
171
|
+
];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The verb's own type first, then the types it reaches, by name. */
|
|
175
|
+
function typeOrder(verb: VerbHelp): string[] {
|
|
176
|
+
return [verb.json, ...Object.keys(verb.schema.$defs as object).filter((name) => name !== verb.json).sort()];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** `content help <verb>` as text: the usage, what it is for, its flags, and the shape of its `--json`. */
|
|
180
|
+
export function formatVerbHelp(reference: CliReference, help: VerbHelpJson): string {
|
|
181
|
+
const blocks = help.verbs.map((verb) => {
|
|
182
|
+
const lines = [verb.usage, '', ...verbFacts(verb).map(([label, text]) => ` ${`${label}:`.padEnd(9)}${text}`)];
|
|
183
|
+
if (verb.flags.length > 0) {
|
|
184
|
+
lines.push('', 'Flags:');
|
|
185
|
+
const width = Math.max(...verb.flags.map((flag) => flag.flag.length));
|
|
186
|
+
for (const flag of verb.flags) lines.push(` ${flag.flag.padEnd(width)} ${flag.description}`);
|
|
187
|
+
}
|
|
188
|
+
lines.push('', `--json prints ${verb.json}:`);
|
|
189
|
+
for (const name of typeOrder(verb)) lines.push(...defLines(name, reference.$defs[name]!).map((line) => ` ${line}`), '');
|
|
190
|
+
return lines.join('\n').trimEnd();
|
|
191
|
+
});
|
|
192
|
+
return `${blocks.join('\n\n')}\n\nOn a failure --json prints ${reference.error}: { "ok": false, "error": { "code", "message" } }.\n`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// --- docs/authoring/cli.md ---------------------------------------------------
|
|
196
|
+
|
|
197
|
+
function anchor(name: string): string {
|
|
198
|
+
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** `docs/authoring/cli.md`, generated: one section per verb, then every `--json` type. */
|
|
202
|
+
export function cliReferenceMarkdown(reference: CliReference): string {
|
|
203
|
+
const out = [
|
|
204
|
+
'<!-- generated by scripts/agent/gen-cli.mjs from src/compiler/cli-verbs.ts and the report types — do not edit; `node scripts/agent/gen-cli.mjs` rewrites it, `--check` gates it -->',
|
|
205
|
+
'',
|
|
206
|
+
'# The `content` CLI',
|
|
207
|
+
'',
|
|
208
|
+
'Every verb of `pnpm content`, what it writes, when it exits 1, and the shape',
|
|
209
|
+
'of its `--json`. The same data, with a JSON Schema per type, is',
|
|
210
|
+
'[`cli-reference.json`](cli-reference.json); `pnpm content help <verb> [--json]`',
|
|
211
|
+
'prints one verb of it. Every verb takes `--json`: data only on stdout, and on',
|
|
212
|
+
`a failure \`{ "ok": false, "error": { "code", "message" } }\` ([\`${reference.error}\`](#${anchor(reference.error)})).`,
|
|
213
|
+
'',
|
|
214
|
+
'| Verb | You want to know or do | Writes |',
|
|
215
|
+
'| --- | --- | --- |',
|
|
216
|
+
...reference.verbs.map((verb) => `| [\`${verb.verb}\`](#${anchor(`content ${verb.verb}`)}) | ${verb.when} | ${verb.writes} |`),
|
|
217
|
+
'',
|
|
218
|
+
];
|
|
219
|
+
for (const verb of reference.verbs) {
|
|
220
|
+
out.push(`## content ${verb.verb}`, '', '```text', usageLine(verb), '```', '');
|
|
221
|
+
for (const [label, text] of verbFacts(verb)) out.push(`- **${label}:** ${text.replace(/\\explain/g, '`\\explain`')}`);
|
|
222
|
+
out.push('');
|
|
223
|
+
if (verb.flags.length > 0) {
|
|
224
|
+
out.push('| Flag | Effect |', '| --- | --- |');
|
|
225
|
+
for (const flag of verb.flags) out.push(`| \`${flag.flag}\` | ${flag.description} |`);
|
|
226
|
+
out.push('');
|
|
227
|
+
}
|
|
228
|
+
out.push(`\`--json\`: [\`${verb.json}\`](#${anchor(verb.json)})`, '');
|
|
229
|
+
}
|
|
230
|
+
out.push('## The `--json` types', '', 'A `?` marks a property that may be absent.', '');
|
|
231
|
+
for (const name of Object.keys(reference.$defs).sort()) {
|
|
232
|
+
out.push(`### ${name}`, '', '```ts', ...defLines(name, reference.$defs[name]!), '```', '');
|
|
233
|
+
}
|
|
234
|
+
return `${out.join('\n').trimEnd()}\n`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// --- checking a value against a schema ---------------------------------------
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Where `value` departs from `schema`, one message per place (`$.hits[0].tier:
|
|
241
|
+
* expected one of …`); empty when it conforms. The subset the generator
|
|
242
|
+
* writes: `$ref`, `type`, `const`, `enum`, `anyOf`, `properties`, `required`,
|
|
243
|
+
* `additionalProperties`, `items`.
|
|
244
|
+
*/
|
|
245
|
+
export function jsonShapeErrors(schema: JsonSchema, value: unknown, defs = (schema.$defs ?? {}) as CliReference['$defs'], path = '$'): string[] {
|
|
246
|
+
if (typeof schema.$ref === 'string') {
|
|
247
|
+
const target = defs[refName(schema.$ref)];
|
|
248
|
+
return target === undefined ? [`${path}: unknown ${schema.$ref}`] : jsonShapeErrors(target, value, defs, path);
|
|
249
|
+
}
|
|
250
|
+
if ('const' in schema) return value === schema.const ? [] : [`${path}: expected ${JSON.stringify(schema.const)}, got ${JSON.stringify(value)}`];
|
|
251
|
+
if (Array.isArray(schema.enum)) {
|
|
252
|
+
return schema.enum.includes(value) ? [] : [`${path}: expected one of ${schema.enum.map((v) => JSON.stringify(v)).join(', ')}, got ${JSON.stringify(value)}`];
|
|
253
|
+
}
|
|
254
|
+
if (Array.isArray(schema.anyOf)) {
|
|
255
|
+
const branches = (schema.anyOf as JsonSchema[]).map((branch) => jsonShapeErrors(branch, value, defs, path));
|
|
256
|
+
if (branches.some((errors) => errors.length === 0)) return [];
|
|
257
|
+
return [`${path}: matches none of ${typeExpression(schema)} (${branches.map((errors) => errors[0]).join('; ')})`];
|
|
258
|
+
}
|
|
259
|
+
const type = schema.type;
|
|
260
|
+
if (type === undefined) return [];
|
|
261
|
+
const actual = value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value;
|
|
262
|
+
const ok = type === 'integer' ? Number.isInteger(value) : type === actual;
|
|
263
|
+
if (!ok) return [`${path}: expected ${type}, got ${actual}`];
|
|
264
|
+
if (type === 'array') {
|
|
265
|
+
const items = (schema.items ?? {}) as JsonSchema;
|
|
266
|
+
return (value as unknown[]).flatMap((item, index) => jsonShapeErrors(items, item, defs, `${path}[${index}]`));
|
|
267
|
+
}
|
|
268
|
+
if (type !== 'object') return [];
|
|
269
|
+
const record = value as Record<string, unknown>;
|
|
270
|
+
const properties = (schema.properties ?? {}) as Record<string, JsonSchema>;
|
|
271
|
+
const errors: string[] = [];
|
|
272
|
+
for (const name of (schema.required ?? []) as string[]) {
|
|
273
|
+
if (!(name in record)) errors.push(`${path}.${name}: missing`);
|
|
274
|
+
}
|
|
275
|
+
for (const [name, item] of Object.entries(record)) {
|
|
276
|
+
if (item === undefined) continue;
|
|
277
|
+
const declared = properties[name];
|
|
278
|
+
if (declared !== undefined) errors.push(...jsonShapeErrors(declared, item, defs, `${path}.${name}`));
|
|
279
|
+
else if (schema.additionalProperties === false) errors.push(`${path}.${name}: not in the type`);
|
|
280
|
+
else if (typeof schema.additionalProperties === 'object') {
|
|
281
|
+
errors.push(...jsonShapeErrors(schema.additionalProperties as JsonSchema, item, defs, `${path}.${name}`));
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return errors;
|
|
285
|
+
}
|