explico 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +112 -1
  2. package/dist/compiler/cli-main.d.ts +61 -4
  3. package/dist/compiler/cli-main.js +77 -36
  4. package/dist/compiler/cli-reference.d.ts +50 -0
  5. package/dist/compiler/cli-reference.js +213 -0
  6. package/dist/compiler/cli-verbs.d.ts +45 -0
  7. package/dist/compiler/cli-verbs.js +224 -0
  8. package/dist/compiler/cli-verify.d.ts +51 -0
  9. package/dist/compiler/cli-verify.js +151 -0
  10. package/dist/compiler/cli.d.ts +19 -50
  11. package/dist/compiler/cli.js +45 -6
  12. package/dist/compiler/content-release.d.ts +49 -0
  13. package/dist/compiler/content-release.js +63 -0
  14. package/dist/compiler/content-schemas.d.ts +2 -2
  15. package/dist/compiler/manifest.js +29 -24
  16. package/dist/compiler/page-versions.d.ts +25 -1
  17. package/dist/compiler/page-versions.js +19 -0
  18. package/dist/compiler/validate-content.js +2 -2
  19. package/dist/reference/consistency.js +7 -1
  20. package/dist/reference/diagram-fit.d.ts +5 -1
  21. package/dist/reference/diagram-fit.js +5 -1
  22. package/dist/rules/catalog.js +16 -0
  23. package/dist/rules/in-effect.d.ts +1 -1
  24. package/dist/rules/in-effect.js +5 -4
  25. package/dist/rules/rules.schema.json +21 -0
  26. package/docs/authoring/README.md +2 -0
  27. package/docs/authoring/agents.md +18 -11
  28. package/docs/authoring/cli-reference.json +2172 -0
  29. package/docs/authoring/cli.md +887 -0
  30. package/docs/authoring/deck.md +24 -0
  31. package/docs/authoring/migrating.md +168 -18
  32. package/docs/authoring/versioning.md +2 -1
  33. package/docs/course-agent/AGENTS.md +10 -7
  34. package/docs/course-agent/agents/rules-reviewer.md +2 -2
  35. package/docs/course-agent/skills/draft-lesson/SKILL.md +7 -6
  36. package/docs/course-agent/skills/migrate-course/SKILL.md +5 -4
  37. package/docs/course-agent/skills/release-content/SKILL.md +5 -3
  38. package/docs/diagram-scaling.md +2 -2
  39. package/package.json +1 -1
  40. package/src/compiler/cli-main.ts +135 -47
  41. package/src/compiler/cli-reference.ts +285 -0
  42. package/src/compiler/cli-verbs.ts +270 -0
  43. package/src/compiler/cli-verify.ts +241 -0
  44. package/src/compiler/cli.ts +77 -17
  45. package/src/compiler/content-release.ts +91 -0
  46. package/src/compiler/manifest.ts +34 -24
  47. package/src/compiler/page-versions.ts +42 -0
  48. package/src/compiler/validate-content.ts +5 -2
  49. package/src/reference/consistency.ts +13 -2
  50. package/src/reference/diagram-fit.ts +18 -4
  51. package/src/rules/catalog.ts +17 -0
  52. package/src/rules/in-effect.ts +5 -4
@@ -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` and `fix`
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
- planFix,
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
- /** The usage text `content help` prints; `scripts/agent/gen-map.mjs` lists the verbs from it. */
62
- export const CONTENT_USAGE = `Usage:
63
- pnpm content status
64
- pnpm content diagnostics [info|warning|error] [--all | --code <c> | --lesson <id> | --changed] [--json]
65
- pnpm content warnings [--json]
66
- pnpm content context <lesson> [--json]
67
- pnpm content equation "<latex>" [--lesson <id>] [--json]
68
- pnpm content bindings <lesson> [--json]
69
- pnpm content symbol <latex | key> [--json]
70
- pnpm content check <lesson | deck>… [--changed] [--json]
71
- pnpm content rule <ID> [--json]
72
- pnpm content migrate [--from <version>] [--to <version>] [--json]
73
- pnpm content rules [--element <e>[,<e>]] [--enforcement script|partial|guide|locked] [--edition chapter|deck] [--json]
74
- pnpm content move <lesson>:<key> [--to <key>] [--write]
75
- pnpm content rename <old> <new> [--write]
76
- pnpm content fix <lesson> [--write]
77
- pnpm content new lesson <id>
78
- pnpm content new deck <lesson | part>
79
- pnpm content new term <key>
80
- pnpm content init-agent [--write]`;
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: string,
187
+ readonly code: ContentCliCode,
152
188
  message: string,
153
189
  ) {
154
190
  super(message);
155
191
  }
156
192
  }
157
193
 
158
- function usage(code: string, message: string): never {
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 === '--help') {
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: no manifest.
232
- if (args.command === 'init-agent') {
233
- if (args.positionals.length > 0) usage('usage', `content init-agent takes no argument; got ${args.positionals.join(' ')}.`);
234
- const plan = planInitAgent();
235
- if (args.json) json({ ...plan, written: args.write });
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('\ndry run: nothing written. Add --write to install.\n');
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 ? ` (${unreleased.join(', ')})` : ''}\n` +
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
- const [lesson, ...extra] = args.positionals;
318
- if (!lesson || extra.length > 0) usage('usage', 'content fix needs one <lesson> id.');
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
- out(` [${diagnostic.severity}] ${diagnostic.code}${ownerTag(diagnostic)}: ${diagnostic.message}\n`);
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
+ }