@ferax564/noma-cli 0.11.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 (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +199 -0
  3. package/bin/noma.mjs +8 -0
  4. package/dist/ast.d.ts +111 -0
  5. package/dist/ast.js +23 -0
  6. package/dist/ast.js.map +1 -0
  7. package/dist/book.d.ts +56 -0
  8. package/dist/book.js +120 -0
  9. package/dist/book.js.map +1 -0
  10. package/dist/cli.d.ts +2 -0
  11. package/dist/cli.js +573 -0
  12. package/dist/cli.js.map +1 -0
  13. package/dist/diff.d.ts +29 -0
  14. package/dist/diff.js +77 -0
  15. package/dist/diff.js.map +1 -0
  16. package/dist/fmt.d.ts +1 -0
  17. package/dist/fmt.js +105 -0
  18. package/dist/fmt.js.map +1 -0
  19. package/dist/ids.d.ts +15 -0
  20. package/dist/ids.js +27 -0
  21. package/dist/ids.js.map +1 -0
  22. package/dist/index.d.ts +20 -0
  23. package/dist/index.js +12 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/inline.d.ts +14 -0
  26. package/dist/inline.js +83 -0
  27. package/dist/inline.js.map +1 -0
  28. package/dist/loader.d.ts +12 -0
  29. package/dist/loader.js +59 -0
  30. package/dist/loader.js.map +1 -0
  31. package/dist/parser.d.ts +7 -0
  32. package/dist/parser.js +434 -0
  33. package/dist/parser.js.map +1 -0
  34. package/dist/patch.d.ts +61 -0
  35. package/dist/patch.js +530 -0
  36. package/dist/patch.js.map +1 -0
  37. package/dist/renderer-html.d.ts +44 -0
  38. package/dist/renderer-html.js +929 -0
  39. package/dist/renderer-html.js.map +1 -0
  40. package/dist/renderer-json.d.ts +5 -0
  41. package/dist/renderer-json.js +4 -0
  42. package/dist/renderer-json.js.map +1 -0
  43. package/dist/renderer-llm.d.ts +29 -0
  44. package/dist/renderer-llm.js +275 -0
  45. package/dist/renderer-llm.js.map +1 -0
  46. package/dist/renderer-noma.d.ts +10 -0
  47. package/dist/renderer-noma.js +179 -0
  48. package/dist/renderer-noma.js.map +1 -0
  49. package/dist/renderer-site.d.ts +11 -0
  50. package/dist/renderer-site.js +175 -0
  51. package/dist/renderer-site.js.map +1 -0
  52. package/dist/validator.d.ts +24 -0
  53. package/dist/validator.js +699 -0
  54. package/dist/validator.js.map +1 -0
  55. package/dist/verify.d.ts +10 -0
  56. package/dist/verify.js +141 -0
  57. package/dist/verify.js.map +1 -0
  58. package/package.json +83 -0
  59. package/schemas/ast.schema.json +187 -0
  60. package/schemas/capability.schema.json +70 -0
  61. package/schemas/patch-op.schema.json +92 -0
  62. package/schemas/patch-transaction.schema.json +28 -0
  63. package/schemas/transcript.schema.json +95 -0
  64. package/src/ast.ts +152 -0
  65. package/src/book.ts +162 -0
  66. package/src/cli.ts +595 -0
  67. package/src/diff.ts +108 -0
  68. package/src/fmt.ts +126 -0
  69. package/src/ids.ts +42 -0
  70. package/src/index.ts +20 -0
  71. package/src/inline.ts +92 -0
  72. package/src/loader.ts +55 -0
  73. package/src/parser.ts +501 -0
  74. package/src/patch.ts +646 -0
  75. package/src/renderer-html.ts +1047 -0
  76. package/src/renderer-json.ts +9 -0
  77. package/src/renderer-llm.ts +320 -0
  78. package/src/renderer-noma.ts +220 -0
  79. package/src/renderer-site.ts +245 -0
  80. package/src/validator.ts +733 -0
  81. package/src/verify.ts +157 -0
  82. package/themes/dark.css +382 -0
  83. package/themes/default.css +537 -0
package/src/cli.ts ADDED
@@ -0,0 +1,595 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
3
+ import { dirname, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import yaml from "js-yaml";
6
+ import { parse } from "./parser.js";
7
+ import { renderHtml } from "./renderer-html.js";
8
+ import { renderLlm } from "./renderer-llm.js";
9
+ import { renderJson } from "./renderer-json.js";
10
+ import { renderNoma } from "./renderer-noma.js";
11
+ import { patchSource, type PatchOp } from "./patch.js";
12
+ import { loadBook, loadBookChapters, isBookManifestPath } from "./book.js";
13
+ import { inlineDatasetSources } from "./loader.js";
14
+ import { renderSite } from "./renderer-site.js";
15
+ import { validate, formatDiagnostics } from "./validator.js";
16
+ import { formatSource } from "./fmt.js";
17
+ import { verifyFixtureDir } from "./verify.js";
18
+ import { diffDocs } from "./diff.js";
19
+ import { collectIdRegistry } from "./ids.js";
20
+ import type { DocumentNode } from "./ast.js";
21
+
22
+ const HELP = `noma — readable document format for humans and agents
23
+
24
+ Usage:
25
+ noma parse <file.noma|book.yml> Print the AST as JSON
26
+ noma render <file.noma|book.yml> [opts] Render to a target format
27
+ noma check <file.noma|book.yml> Validate the document
28
+ noma export <file.noma|book.yml> [opts] Alias for render --to json
29
+ noma patch <file.noma> [opts] Apply block-level patch ops
30
+ noma init [dir] Create a starter .noma document
31
+ noma ids <file.noma|book.yml> Print canonical ID and alias registry
32
+ noma schema <name> Print bundled JSON Schema
33
+ noma fmt <file.noma> [--inplace|--out p] Re-align pipe tables in source
34
+ noma verify <fixture-dir> Run conformance suite against fixtures
35
+ noma diff <before.noma> <after.noma> --at <date> Emit ::state_change for attribute drift
36
+ noma --help Show this help
37
+ noma --version Print the CLI version
38
+
39
+ Render options:
40
+ --to <html|llm|json|noma|site> Target format (default: html). 'site' renders
41
+ a book manifest as a multi-page HTML site.
42
+ --out <path> Write to file (or directory for --to site)
43
+ --no-standalone HTML: emit body fragment without <html> wrapper
44
+ --title <text> Override document title
45
+ --theme <name> HTML theme: default | dark (default: default)
46
+ --no-unsafe HTML: block ::html / ::svg / ::script escape hatches
47
+ --strict HTML: block escape hatches and external CDN assets
48
+ --math <katex|none> Math rendering: enable KaTeX assets in standalone HTML
49
+ (default: auto-detect from doc / book manifest)
50
+ --ignore-rule <name> Suppress a validator rule (repeatable)
51
+ --exclude-stale-days <n> LLM: omit ::memory blocks whose last_seen is older
52
+ than <n> days from --now (or system clock).
53
+ --select <a,b> LLM: include only node types or directive names
54
+ --exclude <a,b> LLM: omit node types or directive names
55
+ --budget <chars> LLM: trim output to a maximum character count
56
+ --now <ISO> LLM: fix the clock used by --exclude-stale-days
57
+ (default: system clock). Useful for tests.
58
+
59
+ Check options:
60
+ --stale-days <n> Override the citation staleness window (days)
61
+ --ignore-rule <name> Suppress a validator rule (repeatable)
62
+
63
+ Patch options:
64
+ --op <json> One inline patch op (JSON object)
65
+ --ops <file.json> File containing one op, an array of ops, or
66
+ { "ops": [...], "prevalidate": true,
67
+ "postvalidate": true }
68
+ --inplace Write the result back to <file.noma>
69
+ --out <path> Write to file instead of stdout
70
+
71
+ Diff options:
72
+ --at <YYYY-MM-DD> Required. Timestamp written as at="..." on each delta.
73
+ Required so output is deterministic.
74
+ --reason <text> Embed reason="..." on every emitted state_change
75
+ --out <path> Write to file instead of stdout
76
+
77
+ Examples:
78
+ noma parse examples/thesis.noma
79
+ noma schema patch-op
80
+ noma render examples/thesis.noma --to html --out dist/thesis.html
81
+ noma render examples/thesis.noma --to llm
82
+ noma check examples/thesis.noma
83
+ noma patch examples/thesis.noma --op '{"op":"update_attribute","id":"asml-euv-moat","key":"confidence","value":0.9}' --inplace
84
+ noma diff before.noma after.noma --at 2026-05-12 --reason "Q1 refresh"
85
+ `;
86
+
87
+ interface CliArgs {
88
+ command: string;
89
+ file?: string;
90
+ fileB?: string;
91
+ diffReason?: string;
92
+ diffAt?: string;
93
+ to: string;
94
+ out?: string;
95
+ standalone: boolean;
96
+ title?: string;
97
+ help: boolean;
98
+ op?: string;
99
+ opsFile?: string;
100
+ inplace: boolean;
101
+ theme: string;
102
+ allowEscapeHatches: boolean;
103
+ externalAssets: boolean;
104
+ staleDays?: number;
105
+ ignoreRules: string[];
106
+ math?: "katex" | "none";
107
+ excludeStaleDays?: number;
108
+ llmSelect: string[];
109
+ llmExclude: string[];
110
+ llmBudget?: number;
111
+ now?: string;
112
+ }
113
+
114
+ function parseArgs(argv: string[]): CliArgs {
115
+ const args: CliArgs = {
116
+ command: argv[0] ?? "",
117
+ to: "html",
118
+ standalone: true,
119
+ help: false,
120
+ inplace: false,
121
+ theme: "default",
122
+ allowEscapeHatches: true,
123
+ externalAssets: true,
124
+ ignoreRules: [],
125
+ llmSelect: [],
126
+ llmExclude: [],
127
+ };
128
+ let i = 1;
129
+ while (i < argv.length) {
130
+ const a = argv[i]!;
131
+ if (a === "--help" || a === "-h") {
132
+ args.help = true;
133
+ i++;
134
+ } else if (a === "--to") {
135
+ args.to = argv[++i] ?? "html";
136
+ i++;
137
+ } else if (a === "--out" || a === "-o") {
138
+ args.out = argv[++i];
139
+ i++;
140
+ } else if (a === "--no-standalone") {
141
+ args.standalone = false;
142
+ i++;
143
+ } else if (a === "--standalone") {
144
+ args.standalone = true;
145
+ i++;
146
+ } else if (a === "--title") {
147
+ args.title = argv[++i];
148
+ i++;
149
+ } else if (a === "--theme") {
150
+ args.theme = argv[++i] ?? "default";
151
+ i++;
152
+ } else if (a === "--no-unsafe") {
153
+ args.allowEscapeHatches = false;
154
+ i++;
155
+ } else if (a === "--strict") {
156
+ args.allowEscapeHatches = false;
157
+ args.externalAssets = false;
158
+ i++;
159
+ } else if (a === "--stale-days") {
160
+ const v = Number(argv[++i]);
161
+ if (Number.isFinite(v) && v > 0) args.staleDays = v;
162
+ i++;
163
+ } else if (a === "--ignore-rule") {
164
+ const v = argv[++i];
165
+ if (v) args.ignoreRules.push(v);
166
+ i++;
167
+ } else if (a === "--math") {
168
+ const v = argv[++i];
169
+ args.math = v === "none" ? "none" : "katex";
170
+ i++;
171
+ } else if (a === "--exclude-stale-days") {
172
+ const v = Number(argv[++i]);
173
+ if (Number.isFinite(v) && v > 0) args.excludeStaleDays = v;
174
+ i++;
175
+ } else if (a === "--select") {
176
+ const v = argv[++i];
177
+ if (v) args.llmSelect.push(v);
178
+ i++;
179
+ } else if (a === "--exclude") {
180
+ const v = argv[++i];
181
+ if (v) args.llmExclude.push(v);
182
+ i++;
183
+ } else if (a === "--budget") {
184
+ const v = Number(argv[++i]);
185
+ if (Number.isFinite(v) && v > 0) args.llmBudget = v;
186
+ i++;
187
+ } else if (a === "--now") {
188
+ args.now = argv[++i];
189
+ i++;
190
+ } else if (a === "--op") {
191
+ args.op = argv[++i];
192
+ i++;
193
+ } else if (a === "--ops") {
194
+ args.opsFile = argv[++i];
195
+ i++;
196
+ } else if (a === "--inplace") {
197
+ args.inplace = true;
198
+ i++;
199
+ } else if (a === "--reason") {
200
+ args.diffReason = argv[++i];
201
+ i++;
202
+ } else if (a === "--at") {
203
+ args.diffAt = argv[++i];
204
+ i++;
205
+ } else if (!a.startsWith("--") && !args.file) {
206
+ args.file = a;
207
+ i++;
208
+ } else if (!a.startsWith("--") && !args.fileB) {
209
+ args.fileB = a;
210
+ i++;
211
+ } else {
212
+ i++;
213
+ }
214
+ }
215
+ return args;
216
+ }
217
+
218
+ function loadTheme(name = "default"): string {
219
+ const safe = /^[a-z0-9-]+$/.test(name) ? name : "default";
220
+ const here = dirname(fileURLToPath(import.meta.url));
221
+ const candidates = [
222
+ resolve(here, "..", "themes", `${safe}.css`),
223
+ resolve(here, "..", "..", "themes", `${safe}.css`),
224
+ ];
225
+ for (const c of candidates) {
226
+ if (existsSync(c)) return readFileSync(c, "utf8");
227
+ }
228
+ if (safe !== "default") return loadTheme("default");
229
+ return "";
230
+ }
231
+
232
+ function loadSchema(name: string): string {
233
+ const safe = name.replace(/\.schema\.json$/i, "");
234
+ const filenames: Record<string, string> = {
235
+ ast: "ast.schema.json",
236
+ capability: "capability.schema.json",
237
+ "patch-op": "patch-op.schema.json",
238
+ "patch-transaction": "patch-transaction.schema.json",
239
+ transcript: "transcript.schema.json",
240
+ };
241
+ const filename = filenames[safe];
242
+ if (!filename) {
243
+ const names = Object.keys(filenames).sort().join(", ");
244
+ throw new Error(`unknown schema "${name}" (expected one of: ${names})`);
245
+ }
246
+ const here = dirname(fileURLToPath(import.meta.url));
247
+ const candidates = [
248
+ resolve(here, "..", "schemas", filename),
249
+ resolve(here, "..", "..", "schemas", filename),
250
+ ];
251
+ for (const c of candidates) {
252
+ if (existsSync(c)) return readFileSync(c, "utf8");
253
+ }
254
+ throw new Error(`schema file not found: ${filename}`);
255
+ }
256
+
257
+ function packageVersion(): string {
258
+ const here = dirname(fileURLToPath(import.meta.url));
259
+ const packagePath = resolve(here, "..", "package.json");
260
+ try {
261
+ const pkg = JSON.parse(readFileSync(packagePath, "utf8")) as { version?: unknown };
262
+ return typeof pkg.version === "string" ? pkg.version : "unknown";
263
+ } catch {
264
+ return "unknown";
265
+ }
266
+ }
267
+
268
+ function output(content: string, out?: string): void {
269
+ if (!out) {
270
+ process.stdout.write(content + (content.endsWith("\n") ? "" : "\n"));
271
+ return;
272
+ }
273
+ const dir = dirname(out);
274
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
275
+ writeFileSync(out, content, "utf8");
276
+ process.stderr.write(`✓ wrote ${out}\n`);
277
+ }
278
+
279
+ function starterSource(): string {
280
+ return `---
281
+ title: Agent-Safe Spec
282
+ ---
283
+
284
+ # Agent-Safe Spec
285
+
286
+ ::summary
287
+ A small Noma starter document with stable block IDs for agent-safe edits.
288
+ ::
289
+
290
+ ::claim{id="core-claim" confidence=0.8}
291
+ Noma documents stay readable for humans while giving agents precise blocks to patch.
292
+ ::
293
+
294
+ ::evidence{for="core-claim" source="starter"}
295
+ This claim can be updated with \`noma patch\` without rewriting the whole file.
296
+ ::
297
+
298
+ ::risk{id="first-risk" severity="medium" owner="you"}
299
+ Replace this with the first risk your spec needs to track.
300
+ ::
301
+ `;
302
+ }
303
+
304
+ interface PatchTransaction {
305
+ ops: PatchOp[];
306
+ prevalidate: boolean;
307
+ postvalidate: boolean;
308
+ }
309
+
310
+ function patchTransactionFromArgs(args: CliArgs): PatchTransaction {
311
+ const ops: PatchOp[] = [];
312
+ let prevalidate = false;
313
+ let postvalidate = false;
314
+ if (args.op) {
315
+ ops.push(JSON.parse(args.op) as PatchOp);
316
+ }
317
+ if (args.opsFile) {
318
+ const raw = JSON.parse(readFileSync(resolve(args.opsFile), "utf8")) as unknown;
319
+ if (isPatchTransactionPayload(raw)) {
320
+ ops.push(...raw.ops);
321
+ prevalidate = raw.prevalidate === true;
322
+ postvalidate = raw.postvalidate === true;
323
+ } else if (Array.isArray(raw)) {
324
+ ops.push(...(raw as PatchOp[]));
325
+ } else {
326
+ ops.push(raw as PatchOp);
327
+ }
328
+ }
329
+ return { ops, prevalidate, postvalidate };
330
+ }
331
+
332
+ function isPatchTransactionPayload(
333
+ value: unknown,
334
+ ): value is { ops: PatchOp[]; prevalidate?: unknown; postvalidate?: unknown } {
335
+ return (
336
+ typeof value === "object" &&
337
+ value !== null &&
338
+ "ops" in value &&
339
+ Array.isArray((value as { ops?: unknown }).ops)
340
+ );
341
+ }
342
+
343
+ function validatePatchSource(source: string, filename: string): string {
344
+ const doc = parse(source, { filename });
345
+ inlineDatasetSources(doc);
346
+ const diagnostics = validate(doc);
347
+ const errors = diagnostics.filter((d) => d.severity === "error");
348
+ return errors.length > 0 ? formatDiagnostics(errors, filename) : "";
349
+ }
350
+
351
+ function main(): void {
352
+ const argv = process.argv.slice(2);
353
+ if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") {
354
+ process.stdout.write(HELP);
355
+ return;
356
+ }
357
+ if (argv[0] === "--version" || argv[0] === "-v") {
358
+ process.stdout.write(`${packageVersion()}\n`);
359
+ return;
360
+ }
361
+
362
+ const args = parseArgs(argv);
363
+ if (args.help) {
364
+ process.stdout.write(HELP);
365
+ return;
366
+ }
367
+
368
+ const cmd = args.command;
369
+ if (cmd === "init") {
370
+ const targetDir = resolve(args.file ?? "noma-starter");
371
+ const targetFile = resolve(targetDir, "demo.noma");
372
+ if (existsSync(targetFile)) {
373
+ process.stderr.write(`error: ${targetFile} already exists\n`);
374
+ process.exit(2);
375
+ }
376
+ mkdirSync(targetDir, { recursive: true });
377
+ writeFileSync(targetFile, starterSource(), "utf8");
378
+ process.stderr.write(`✓ wrote ${targetFile}\n`);
379
+ process.stdout.write(`Next: noma render ${targetFile} --to html --out ${resolve(targetDir, "demo.html")}\n`);
380
+ return;
381
+ }
382
+
383
+ if (cmd === "verify") {
384
+ const dir = args.file;
385
+ if (!dir) { console.error("noma verify: <fixture-dir> required"); process.exit(2); }
386
+ const resolvedDir = resolve(dir);
387
+ if (!existsSync(resolvedDir)) {
388
+ console.error(`noma verify: ${dir} does not exist`);
389
+ process.exit(2);
390
+ }
391
+ const report = verifyFixtureDir(resolvedDir);
392
+ for (const f of report.fixtures) {
393
+ const tag = f.status === "pass" ? "PASS" : f.status === "fail" ? "FAIL" : "SKIP";
394
+ console.log(`${tag} ${f.name}${f.error ? ` — ${f.error}` : ""}`);
395
+ }
396
+ console.log(`\n${report.fixtures.length} fixtures, ${report.fixtures.filter((f) => f.status === "pass").length} passed`);
397
+ process.exit(report.ok ? 0 : 1);
398
+ }
399
+
400
+ if (cmd === "schema") {
401
+ if (!args.file) {
402
+ process.stderr.write("noma schema: <ast|capability|patch-op|patch-transaction|transcript> required\n");
403
+ process.exit(2);
404
+ }
405
+ try {
406
+ output(loadSchema(args.file), args.out);
407
+ } catch (error) {
408
+ process.stderr.write(`error: ${(error as Error).message}\n`);
409
+ process.exit(2);
410
+ }
411
+ return;
412
+ }
413
+
414
+ if (cmd === "diff") {
415
+ if (!args.file || !args.fileB) {
416
+ process.stderr.write("noma diff: <before.noma> <after.noma> required\n");
417
+ process.exit(2);
418
+ }
419
+ if (!args.diffAt) {
420
+ process.stderr.write("noma diff: --at <YYYY-MM-DD> is required (output must be deterministic)\n");
421
+ process.exit(2);
422
+ }
423
+ const a = parse(readFileSync(resolve(args.file), "utf8"), { filename: args.file });
424
+ const b = parse(readFileSync(resolve(args.fileB), "utf8"), { filename: args.fileB });
425
+ const deltas = diffDocs(a, b, {
426
+ at: args.diffAt,
427
+ ...(args.diffReason !== undefined ? { reason: args.diffReason } : {}),
428
+ });
429
+ if (deltas.length === 0) {
430
+ return;
431
+ }
432
+ const deltaDoc: DocumentNode = { type: "document", meta: {}, children: deltas };
433
+ output(renderNoma(deltaDoc), args.out);
434
+ return;
435
+ }
436
+
437
+ if (!args.file) {
438
+ process.stderr.write(`error: missing input file\n\n${HELP}`);
439
+ process.exit(2);
440
+ }
441
+
442
+ const filePath = resolve(args.file);
443
+ if (cmd === "fmt") {
444
+ if (isBookManifestPath(filePath)) {
445
+ process.stderr.write(`error: noma fmt operates on .noma source files, not book manifests\n`);
446
+ process.exit(2);
447
+ }
448
+ const formatted = formatSource(readFileSync(filePath, "utf8"));
449
+ const target = args.inplace ? filePath : args.out;
450
+ output(formatted, target);
451
+ return;
452
+ }
453
+
454
+ if ((cmd === "render" || cmd === "export") && args.to === "site") {
455
+ if (!isBookManifestPath(filePath)) {
456
+ process.stderr.write(`error: --to site requires a book manifest (.yml)\n`);
457
+ process.exit(2);
458
+ }
459
+ if (!args.out) {
460
+ process.stderr.write(`error: --to site requires --out <directory>\n`);
461
+ process.exit(2);
462
+ }
463
+ const themeCss = loadTheme(args.theme);
464
+ const { manifest, chapters } = loadBookChapters(filePath);
465
+ const allowEscapeHatches = args.allowEscapeHatches && manifest.trusted_publishing !== true;
466
+ renderSite(manifest, chapters, args.out, {
467
+ themeCss,
468
+ title: args.title,
469
+ allowEscapeHatches,
470
+ externalAssets: args.externalAssets,
471
+ ...(args.math ? { math: args.math } : {}),
472
+ });
473
+ process.stderr.write(`✓ wrote ${chapters.length + 1} pages to ${args.out}\n`);
474
+ return;
475
+ }
476
+
477
+ const manifestForTrust = isBookManifestPath(filePath)
478
+ ? (yaml.load(readFileSync(filePath, "utf8")) as Record<string, unknown> | null)
479
+ : null;
480
+ const allowEscapeHatches =
481
+ args.allowEscapeHatches && manifestForTrust?.trusted_publishing !== true;
482
+
483
+ const doc = isBookManifestPath(filePath)
484
+ ? loadBook(filePath)
485
+ : parse(readFileSync(filePath, "utf8"), { filename: filePath });
486
+ inlineDatasetSources(doc);
487
+
488
+ switch (cmd) {
489
+ case "parse": {
490
+ output(renderJson(doc, { pretty: true }), args.out);
491
+ return;
492
+ }
493
+ case "ids": {
494
+ output(JSON.stringify(collectIdRegistry(doc), null, 2), args.out);
495
+ return;
496
+ }
497
+ case "export":
498
+ case "render": {
499
+ const to = cmd === "export" ? "json" : args.to;
500
+ switch (to) {
501
+ case "html": {
502
+ const themeCss = loadTheme(args.theme);
503
+ const html = renderHtml(doc, {
504
+ standalone: args.standalone,
505
+ title: args.title,
506
+ themeCss,
507
+ allowEscapeHatches,
508
+ externalAssets: args.externalAssets,
509
+ ...(args.math ? { math: args.math } : {}),
510
+ });
511
+ output(html, args.out);
512
+ return;
513
+ }
514
+ case "llm": {
515
+ const llmOpts: {
516
+ excludeStale?: { now: Date; days: number };
517
+ select?: string[];
518
+ exclude?: string[];
519
+ budget?: number;
520
+ } = {};
521
+ if (args.excludeStaleDays !== undefined) {
522
+ const now = args.now ? new Date(args.now) : new Date();
523
+ if (Number.isNaN(now.getTime())) {
524
+ process.stderr.write(`error: --now value is not a valid date\n`);
525
+ process.exit(2);
526
+ }
527
+ llmOpts.excludeStale = { now, days: args.excludeStaleDays };
528
+ }
529
+ if (args.llmSelect.length > 0) llmOpts.select = args.llmSelect;
530
+ if (args.llmExclude.length > 0) llmOpts.exclude = args.llmExclude;
531
+ if (args.llmBudget !== undefined) llmOpts.budget = args.llmBudget;
532
+ output(renderLlm(doc, llmOpts), args.out);
533
+ return;
534
+ }
535
+ case "json": {
536
+ output(renderJson(doc, { pretty: true }), args.out);
537
+ return;
538
+ }
539
+ case "noma": {
540
+ output(renderNoma(doc), args.out);
541
+ return;
542
+ }
543
+ default:
544
+ process.stderr.write(`error: unknown target "${to}"\n`);
545
+ process.exit(2);
546
+ }
547
+ return;
548
+ }
549
+ case "check": {
550
+ const diagnostics = validate(doc, {
551
+ ...(args.staleDays !== undefined ? { staleCitationDays: args.staleDays } : {}),
552
+ ...(args.ignoreRules.length > 0 ? { ignoreRules: args.ignoreRules } : {}),
553
+ });
554
+ const formatted = formatDiagnostics(diagnostics, args.file);
555
+ process.stdout.write(formatted + "\n");
556
+ const hasError = diagnostics.some((d) => d.severity === "error");
557
+ process.exit(hasError ? 1 : 0);
558
+ }
559
+ case "patch": {
560
+ const tx = patchTransactionFromArgs(args);
561
+ if (tx.ops.length === 0) {
562
+ process.stderr.write(`error: noma patch needs --op or --ops\n`);
563
+ process.exit(2);
564
+ }
565
+ if (isBookManifestPath(filePath)) {
566
+ process.stderr.write(`error: noma patch operates on .noma source files, not book manifests\n`);
567
+ process.exit(2);
568
+ }
569
+ const before = readFileSync(filePath, "utf8");
570
+ if (tx.prevalidate) {
571
+ const preErrors = validatePatchSource(before, filePath);
572
+ if (preErrors) {
573
+ process.stderr.write(`error: pre-validation failed\n${preErrors}\n`);
574
+ process.exit(1);
575
+ }
576
+ }
577
+ const printed = patchSource(before, tx.ops);
578
+ if (tx.postvalidate) {
579
+ const postErrors = validatePatchSource(printed, filePath);
580
+ if (postErrors) {
581
+ process.stderr.write(`error: post-validation failed\n${postErrors}\n`);
582
+ process.exit(1);
583
+ }
584
+ }
585
+ const target = args.inplace ? filePath : args.out;
586
+ output(printed, target);
587
+ return;
588
+ }
589
+ default:
590
+ process.stderr.write(`error: unknown command "${cmd}"\n\n${HELP}`);
591
+ process.exit(2);
592
+ }
593
+ }
594
+
595
+ main();
package/src/diff.ts ADDED
@@ -0,0 +1,108 @@
1
+ import type { Attrs, AttrValue, DirectiveNode, DocumentNode } from "./ast.js";
2
+ import { isDirective, walk } from "./ast.js";
3
+
4
+ export interface DiffOptions {
5
+ /**
6
+ * Required timestamp written as `at=` on every emitted state_change. Format:
7
+ * `YYYY-MM-DD` (or any ISO-8601 date string). Required so output is
8
+ * deterministic — same inputs always produce the same bytes.
9
+ */
10
+ at: string;
11
+ /**
12
+ * Optional reason string applied to every emitted state_change. Surfaces in
13
+ * the rendered narrative; agents typically set this to a commit message or
14
+ * a refresh trigger description.
15
+ */
16
+ reason?: string;
17
+ }
18
+
19
+ /**
20
+ * Compare two parsed documents and emit a flat list of synthesized
21
+ * `::state_change` directives describing every scalar attribute drift on
22
+ * blocks identified by `id` and present in both snapshots.
23
+ *
24
+ * v0.7 scope: only attributes defined on both sides where the value differs.
25
+ * Out of scope: attribute add (only-after), attribute delete (only-before),
26
+ * prose/heading changes, block adds/deletes, block renames, ID-less
27
+ * directives, nested-children diffs. Tracked for v0.7.1.
28
+ *
29
+ * Throws if either document contains duplicate IDs (the validator already
30
+ * flags this as an error; diff cannot reason about which copy to compare).
31
+ */
32
+ export function diffDocs(
33
+ before: DocumentNode,
34
+ after: DocumentNode,
35
+ options: DiffOptions,
36
+ ): DirectiveNode[] {
37
+ if (typeof options.at !== "string" || options.at.length === 0) {
38
+ throw new Error("diffDocs: options.at is required (YYYY-MM-DD)");
39
+ }
40
+ const at = options.at;
41
+ const beforeIdx = indexById(before);
42
+ const afterIdx = indexById(after);
43
+ const deltas: DirectiveNode[] = [];
44
+
45
+ for (const [id, afterNode] of afterIdx) {
46
+ const beforeNode = beforeIdx.get(id);
47
+ if (!beforeNode) continue;
48
+ for (const delta of diffAttrs(beforeNode.attrs, afterNode.attrs)) {
49
+ deltas.push(makeStateChange(id, delta, at, options.reason));
50
+ }
51
+ }
52
+
53
+ return deltas;
54
+ }
55
+
56
+ interface AttrDelta {
57
+ attribute: string;
58
+ from: AttrValue;
59
+ to: AttrValue;
60
+ }
61
+
62
+ function diffAttrs(a: Attrs, b: Attrs): AttrDelta[] {
63
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
64
+ keys.delete("id");
65
+ const out: AttrDelta[] = [];
66
+ for (const k of [...keys].sort()) {
67
+ const av = a[k];
68
+ const bv = b[k];
69
+ if (av === undefined || bv === undefined) continue;
70
+ if (Object.is(av, bv)) continue;
71
+ out.push({ attribute: k, from: av, to: bv });
72
+ }
73
+ return out;
74
+ }
75
+
76
+ function indexById(doc: DocumentNode): Map<string, DirectiveNode> {
77
+ const map = new Map<string, DirectiveNode>();
78
+ for (const node of walk(doc)) {
79
+ if (!isDirective(node) || !node.id) continue;
80
+ if (map.has(node.id)) {
81
+ throw new Error(`diffDocs: duplicate id "${node.id}" — fix with \`noma check\` first`);
82
+ }
83
+ map.set(node.id, node);
84
+ }
85
+ return map;
86
+ }
87
+
88
+ function makeStateChange(
89
+ blockId: string,
90
+ delta: AttrDelta,
91
+ at: string,
92
+ reason: string | undefined,
93
+ ): DirectiveNode {
94
+ const attrs: Attrs = {
95
+ block: blockId,
96
+ attribute: delta.attribute,
97
+ from: delta.from,
98
+ to: delta.to,
99
+ at,
100
+ };
101
+ if (reason) attrs.reason = reason;
102
+ return {
103
+ type: "directive",
104
+ name: "state_change",
105
+ attrs,
106
+ children: [],
107
+ };
108
+ }