@sprig-and-prose/sprig-universe 0.1.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 (45) hide show
  1. package/PHILOSOPHY.md +201 -0
  2. package/README.md +168 -0
  3. package/REFERENCE.md +355 -0
  4. package/biome.json +24 -0
  5. package/package.json +30 -0
  6. package/repositories/sprig-repository-github/index.js +29 -0
  7. package/src/ast.js +257 -0
  8. package/src/cli.js +1510 -0
  9. package/src/graph.js +950 -0
  10. package/src/index.js +46 -0
  11. package/src/ir.js +121 -0
  12. package/src/parser.js +1656 -0
  13. package/src/scanner.js +255 -0
  14. package/src/scene-manifest.js +856 -0
  15. package/src/util/span.js +46 -0
  16. package/src/util/text.js +126 -0
  17. package/src/validator.js +862 -0
  18. package/src/validators/mysql/connection.js +154 -0
  19. package/src/validators/mysql/schema.js +209 -0
  20. package/src/validators/mysql/type-compat.js +219 -0
  21. package/src/validators/mysql/validator.js +332 -0
  22. package/test/fixtures/amaranthine-mini.prose +53 -0
  23. package/test/fixtures/conflicting-universes-a.prose +8 -0
  24. package/test/fixtures/conflicting-universes-b.prose +8 -0
  25. package/test/fixtures/duplicate-names.prose +20 -0
  26. package/test/fixtures/first-line-aware.prose +32 -0
  27. package/test/fixtures/indented-describe.prose +18 -0
  28. package/test/fixtures/multi-file-universe-a.prose +15 -0
  29. package/test/fixtures/multi-file-universe-b.prose +15 -0
  30. package/test/fixtures/multi-file-universe-conflict-desc.prose +12 -0
  31. package/test/fixtures/multi-file-universe-conflict-title.prose +4 -0
  32. package/test/fixtures/multi-file-universe-with-title.prose +10 -0
  33. package/test/fixtures/named-document.prose +17 -0
  34. package/test/fixtures/named-duplicate.prose +22 -0
  35. package/test/fixtures/named-reference.prose +17 -0
  36. package/test/fixtures/relates-errors.prose +38 -0
  37. package/test/fixtures/relates-tier1.prose +14 -0
  38. package/test/fixtures/relates-tier2.prose +16 -0
  39. package/test/fixtures/relates-tier3.prose +21 -0
  40. package/test/fixtures/sprig-meta-mini.prose +62 -0
  41. package/test/fixtures/unresolved-relates.prose +15 -0
  42. package/test/fixtures/using-in-references.prose +35 -0
  43. package/test/fixtures/using-unknown.prose +8 -0
  44. package/test/universe-basic.test.js +804 -0
  45. package/tsconfig.json +15 -0
package/src/cli.js ADDED
@@ -0,0 +1,1510 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @fileoverview CLI for Sprig universe parser
4
+ */
5
+
6
+ import { readFileSync, writeFileSync, statSync, readdirSync, mkdirSync, existsSync } from 'fs';
7
+ import { join, resolve, dirname, relative } from 'path';
8
+ import { fileURLToPath } from 'url';
9
+ import { parseFiles, parseText } from './index.js';
10
+ import { convertToSceneManifest, convertFilesToSceneManifest } from './scene-manifest.js';
11
+ import { validateScenes } from './validator.js';
12
+ import chokidar from 'chokidar';
13
+ import { globSync } from 'glob';
14
+
15
+ // Get the directory where this CLI script is located (sprig-universe package root)
16
+ const __filename = fileURLToPath(import.meta.url);
17
+ const __dirname = dirname(__filename);
18
+ const PACKAGE_ROOT = resolve(__dirname, '..');
19
+
20
+ const DEFAULT_EXCLUDES = ['.sprig/**', 'dist/**', 'node_modules/**', '.git/**'];
21
+
22
+ function printGlobalHelp() {
23
+ console.log('Usage: sprig-universe <command> [options]');
24
+ console.log('Commands:');
25
+ console.log(' compile Compile universe and scene files to manifests');
26
+ console.log(' watch Watch universe and scene files and recompile on change');
27
+ console.log(' check:references Validate repository references');
28
+ console.log(' validate Validate scene file sources');
29
+ console.log(' parse Parse files (legacy command)');
30
+ console.log('');
31
+ console.log('Run: sprig-universe <command> --help for command-specific help.');
32
+ }
33
+
34
+ function printCompileHelp() {
35
+ console.log('Usage: sprig-universe compile [--root <path>] [--quiet]');
36
+ console.log('');
37
+ console.log('Compiles universe files and scene files to JSON manifests.');
38
+ console.log('');
39
+ console.log('Discovery:');
40
+ console.log(' - Walks upward from current directory (or --root) to find universe.prose');
41
+ console.log(' - Loads all **/*.prose files under the discovered root');
42
+ console.log(' - Excludes: .sprig/**, dist/**, node_modules/**, .git/**');
43
+ console.log(' - Requires exactly one universe declaration across all files');
44
+ console.log('');
45
+ console.log('Output:');
46
+ console.log(' Universe manifest: <root>/.sprig/manifest.json');
47
+ console.log(' Scenes manifest: <root>/.sprig/scenes.json');
48
+ console.log('');
49
+ console.log('Options:');
50
+ console.log(' --root <path> Override discovery start path (default: current directory)');
51
+ console.log(' --quiet Suppress observable header output');
52
+ }
53
+
54
+ function printWatchHelp() {
55
+ console.log('Usage: sprig-universe watch [--root <path>] [--quiet]');
56
+ console.log('');
57
+ console.log('Watches universe and scene files and recompiles on change.');
58
+ console.log('');
59
+ console.log('Discovery:');
60
+ console.log(' - Walks upward from current directory (or --root) to find universe.prose');
61
+ console.log(' - Loads all **/*.prose files under the discovered root');
62
+ console.log(' - Excludes: .sprig/**, dist/**, node_modules/**, .git/**');
63
+ console.log('');
64
+ console.log('Options:');
65
+ console.log(' --root <path> Override discovery start path (default: current directory)');
66
+ console.log(' --quiet Suppress observable header output');
67
+ }
68
+
69
+ function printValidateHelp() {
70
+ console.log('Usage: sprig-universe validate [--root <path>] [--quiet]');
71
+ console.log('');
72
+ console.log('Validates scene sources against the compiled scenes manifest.');
73
+ console.log('Run "sprig-universe compile" first to generate the manifest.');
74
+ console.log('');
75
+ console.log('Manifest location: <root>/.sprig/scenes.json');
76
+ console.log('');
77
+ console.log('Options:');
78
+ console.log(' --root <path> Override discovery start path (default: current directory)');
79
+ console.log(' --quiet Suppress observable header output');
80
+ }
81
+
82
+ function printCheckReferencesHelp() {
83
+ console.log('Usage: sprig-universe check:references [--root <path>] [--quiet]');
84
+ console.log('');
85
+ console.log('Validates repository references in the universe manifest.');
86
+ console.log('');
87
+ console.log('Discovery:');
88
+ console.log(' - Walks upward from current directory (or --root) to find universe.prose');
89
+ console.log(' - Loads all **/*.prose files under the discovered root');
90
+ console.log(' - Excludes: .sprig/**, dist/**, node_modules/**, .git/**');
91
+ console.log('');
92
+ console.log('Options:');
93
+ console.log(' --root <path> Override discovery start path (default: current directory)');
94
+ console.log(' --quiet Suppress observable header output');
95
+ }
96
+
97
+ function printParseHelp() {
98
+ console.log('Usage: sprig-universe parse <fileOrDir> [--out <path>]');
99
+ console.log('');
100
+ console.log('Parses .prose files and prints the JSON graph (or writes to --out).');
101
+ }
102
+
103
+ function hasHelpFlag(args) {
104
+ return args.includes('--help') || args.includes('-h');
105
+ }
106
+
107
+ function resolveRootPath(args) {
108
+ const rootIndex = args.indexOf('--root');
109
+ if (rootIndex >= 0 && rootIndex + 1 < args.length) {
110
+ return resolve(args[rootIndex + 1]);
111
+ }
112
+ return process.cwd();
113
+ }
114
+
115
+ function hasQuietFlag(args) {
116
+ return args.includes('--quiet');
117
+ }
118
+
119
+ /**
120
+ * Validates that exactly one universe declaration exists in the parsed graph
121
+ * @param {any} graph - Parsed universe graph
122
+ * @returns {{ valid: boolean, universeName?: string, error?: string }}
123
+ */
124
+ function validateUniverseCount(graph) {
125
+ const universeNames = Object.keys(graph.universes);
126
+
127
+ if (universeNames.length === 0) {
128
+ return {
129
+ valid: false,
130
+ error: 'No universe declaration found. At least one universe declaration is required.',
131
+ };
132
+ }
133
+
134
+ if (universeNames.length > 1) {
135
+ const fileList = Array.from(universeNames)
136
+ .map((name) => {
137
+ const universe = graph.universes[name];
138
+ const rootNode = graph.nodes[universe.root];
139
+ return rootNode?.source?.file || 'unknown';
140
+ })
141
+ .filter((file, index, arr) => arr.indexOf(file) === index) // unique files
142
+ .sort()
143
+ .join(', ');
144
+ return {
145
+ valid: false,
146
+ error: `Multiple distinct universes found: ${universeNames.join(', ')}. Files: ${fileList}. Exactly one universe declaration is required.`,
147
+ };
148
+ }
149
+
150
+ return {
151
+ valid: true,
152
+ universeName: universeNames[0],
153
+ };
154
+ }
155
+
156
+ /**
157
+ * Prints observable header with universe info
158
+ * @param {string} universeName - Name of the universe
159
+ * @param {string} root - Root directory path
160
+ * @param {number} fileCount - Number of prose files loaded
161
+ */
162
+ function printObservableHeader(universeName, root, fileCount) {
163
+ console.log(`Universe: ${universeName} (root: ${root})`);
164
+ console.log(`Loaded: ${fileCount} prose files`);
165
+ console.log(`Ignored: ${DEFAULT_EXCLUDES.join(', ')}`);
166
+ }
167
+
168
+ /**
169
+ * Walks upward from startPath to find universe.prose marker
170
+ * @param {string} startPath - Starting directory path
171
+ * @returns {string | null} - Path to directory containing universe.prose, or null if not found
172
+ */
173
+ function discoverUniverseRoot(startPath) {
174
+ let current = resolve(startPath);
175
+ const root = resolve('/');
176
+
177
+ while (current !== root) {
178
+ const markerPath = join(current, 'universe.prose');
179
+ if (existsSync(markerPath) && statSync(markerPath).isFile()) {
180
+ return current;
181
+ }
182
+ current = dirname(current);
183
+ }
184
+
185
+ // Check root directory as well
186
+ const rootMarkerPath = join(root, 'universe.prose');
187
+ if (existsSync(rootMarkerPath) && statSync(rootMarkerPath).isFile()) {
188
+ return root;
189
+ }
190
+
191
+ return null;
192
+ }
193
+
194
+ /**
195
+ * Loads all .prose files under root with default excludes
196
+ * @param {string} root - Universe root directory
197
+ * @returns {string[]} - Array of prose file paths, sorted deterministically
198
+ */
199
+ function loadProseFiles(root) {
200
+ const pattern = join(root, '**/*.prose');
201
+ const allFiles = globSync(pattern, {
202
+ absolute: true,
203
+ ignore: DEFAULT_EXCLUDES.map((exclude) => join(root, exclude)),
204
+ });
205
+
206
+ // Sort for deterministic ordering
207
+ return allFiles.sort();
208
+ }
209
+
210
+ /**
211
+ * Recursively finds all .prose files in a directory
212
+ * @param {string} dir - Directory path
213
+ * @returns {string[]}
214
+ */
215
+ function findSprigFiles(dir) {
216
+ const files = [];
217
+ const entries = readdirSync(dir);
218
+
219
+ for (const entry of entries) {
220
+ const fullPath = join(dir, entry);
221
+ const stat = statSync(fullPath);
222
+
223
+ if (stat.isDirectory()) {
224
+ files.push(...findSprigFiles(fullPath));
225
+ } else if (entry.endsWith('.prose')) {
226
+ files.push(fullPath);
227
+ }
228
+ }
229
+
230
+ return files;
231
+ }
232
+
233
+ /**
234
+ * Recursively finds all .scene.prose files in a directory
235
+ * @param {string} dir - Directory path
236
+ * @returns {string[]}
237
+ */
238
+ function findSceneFiles(dir) {
239
+ const files = [];
240
+ const entries = readdirSync(dir);
241
+
242
+ for (const entry of entries) {
243
+ const fullPath = join(dir, entry);
244
+ const stat = statSync(fullPath);
245
+
246
+ if (stat.isDirectory()) {
247
+ files.push(...findSceneFiles(fullPath));
248
+ } else if (entry.endsWith('.scene.prose')) {
249
+ files.push(fullPath);
250
+ }
251
+ }
252
+
253
+ return files;
254
+ }
255
+
256
+ /**
257
+ * Validate repository kinds exist in repositories directory
258
+ * @param {Record<string, any>} repositories - Repository config
259
+ * @param {string} universeRoot - Universe root directory (unused, kept for API compatibility)
260
+ * @returns {boolean} - True if valid, false otherwise
261
+ */
262
+ function validateRepositoryKinds(repositories, universeRoot) {
263
+ // Repositories are located in the sprig-universe package directory, not in the universe project
264
+ const repositoriesDir = join(PACKAGE_ROOT, 'repositories');
265
+ const errors = [];
266
+
267
+ for (const [repoName, repoConfig] of Object.entries(repositories)) {
268
+ const kind = repoConfig.kind;
269
+ if (!kind) {
270
+ errors.push(`Repository "${repoName}" has no kind specified`);
271
+ continue;
272
+ }
273
+
274
+ const kindDir = join(repositoriesDir, kind);
275
+ const indexFile = join(kindDir, 'index.js');
276
+
277
+ if (!existsSync(indexFile)) {
278
+ errors.push(`Repository kind "${kind}" not found. Expected: ${indexFile}`);
279
+ }
280
+ }
281
+
282
+ if (errors.length > 0) {
283
+ console.error('Error: Invalid repository configurations:');
284
+ for (const error of errors) {
285
+ console.error(` - ${error}`);
286
+ }
287
+ return false;
288
+ }
289
+
290
+ return true;
291
+ }
292
+
293
+ /**
294
+ * Validate repository references
295
+ * @param {any} graph - Parsed graph
296
+ * @param {Record<string, any>} repositories - Repository config
297
+ * @returns {boolean} - True if valid, false otherwise
298
+ */
299
+ function validateReferences(graph, repositories) {
300
+ const repoKeys = new Set(Object.keys(repositories || {}));
301
+ /** @type {Map<string, { count: number, examples: Array<{ file: string, line: number }> }>} */
302
+ const unknownRepos = new Map();
303
+
304
+ for (const node of Object.values(graph.nodes)) {
305
+ if (!node.references) continue;
306
+
307
+ for (const ref of node.references) {
308
+ if (repoKeys.has(ref.repository)) continue;
309
+
310
+ const source = ref.source;
311
+ const entry = unknownRepos.get(ref.repository) || { count: 0, examples: [] };
312
+ entry.count += 1;
313
+
314
+ if (entry.examples.length < 3 && source?.file && source?.start?.line) {
315
+ entry.examples.push({ file: source.file, line: source.start.line });
316
+ }
317
+
318
+ unknownRepos.set(ref.repository, entry);
319
+ }
320
+ }
321
+
322
+ if (unknownRepos.size === 0) {
323
+ return true;
324
+ }
325
+
326
+ console.error('Error: Reference(s) to unknown repositories:');
327
+ for (const [repo, info] of unknownRepos.entries()) {
328
+ const exampleText =
329
+ info.examples.length > 0
330
+ ? ` (e.g. ${info.examples
331
+ .map((ex) => `${ex.file}:${ex.line}`)
332
+ .join(', ')})`
333
+ : '';
334
+ console.error(` - ${repo}: ${info.count} occurrence(s)${exampleText}`);
335
+ }
336
+
337
+ return false;
338
+ }
339
+
340
+ /**
341
+ * Main CLI entry point
342
+ */
343
+ function main() {
344
+ const args = process.argv.slice(2);
345
+
346
+ const command = args[0];
347
+
348
+ if (!command || command === '--help' || command === '-h') {
349
+ printGlobalHelp();
350
+ return;
351
+ }
352
+
353
+ const commandArgs = args.slice(1);
354
+ if (hasHelpFlag(commandArgs)) {
355
+ if (command === 'compile') {
356
+ printCompileHelp();
357
+ return;
358
+ }
359
+ if (command === 'watch') {
360
+ printWatchHelp();
361
+ return;
362
+ }
363
+ if (command === 'validate' || command === 'check') {
364
+ printValidateHelp();
365
+ return;
366
+ }
367
+ if (command === 'check:references') {
368
+ printCheckReferencesHelp();
369
+ return;
370
+ }
371
+ if (command === 'parse') {
372
+ printParseHelp();
373
+ return;
374
+ }
375
+ }
376
+
377
+ if (command === 'compile') {
378
+ handleCompile(commandArgs);
379
+ return;
380
+ }
381
+
382
+ if (command === 'check:references') {
383
+ handleCheckReferences(commandArgs);
384
+ return;
385
+ }
386
+
387
+ if (command === 'validate' || command === 'check') {
388
+ handleValidate(commandArgs);
389
+ return;
390
+ }
391
+
392
+ if (command === 'watch') {
393
+ handleWatch(commandArgs);
394
+ return;
395
+ }
396
+
397
+ if (command === 'parse') {
398
+ handleParse(commandArgs);
399
+ return;
400
+ }
401
+
402
+ console.error(`Unknown command: ${command}`);
403
+ process.exit(1);
404
+ }
405
+
406
+ /**
407
+ * Get files to compile from config
408
+ * @param {any} universeConfig - Universe config section
409
+ * @param {string} configDir - Directory containing config file
410
+ * @returns {string[]} - Array of file paths
411
+ */
412
+ function getFilesToCompile(universeConfig, configDir) {
413
+ // Support entries array (preferred) or single entry string (backward compat)
414
+ const entries = universeConfig.input?.entries;
415
+ const entry = universeConfig.input?.entry || universeConfig.entry;
416
+
417
+ // If both are provided, entries takes precedence
418
+ if (entries && entry) {
419
+ throw new Error('Cannot specify both "entry" and "entries" in universe config. Use "entries" array for multiple files.');
420
+ }
421
+
422
+ if (!entries && !entry) {
423
+ throw new Error('Missing "entry" or "entries" in universe config (expected universe.input.entry, universe.input.entries, universe.entry)');
424
+ }
425
+
426
+ const allFiles = new Set(); // Use Set to deduplicate
427
+
428
+ // Handle entries array (new format)
429
+ if (entries) {
430
+ if (!Array.isArray(entries)) {
431
+ throw new Error('"entries" must be an array of file paths or glob patterns');
432
+ }
433
+
434
+ // Process each entry in config order
435
+ // Ordering: preserve config order for explicit files, sort by path for glob results
436
+ for (const entryPattern of entries) {
437
+ const resolvedPattern = resolve(configDir, entryPattern);
438
+ let entryFiles = [];
439
+
440
+ try {
441
+ // Check if it's a glob pattern (contains *, ?, [, {, or **)
442
+ const isGlob = /[*?[{}]|\*\*/.test(entryPattern);
443
+
444
+ if (isGlob) {
445
+ // Expand glob pattern
446
+ const globFiles = globSync(resolvedPattern, { absolute: true });
447
+ entryFiles = globFiles.filter((f) => f.endsWith('.prose'));
448
+ } else {
449
+ // Treat as file or directory path
450
+ const stat = statSync(resolvedPattern);
451
+ if (stat.isDirectory()) {
452
+ entryFiles = findSprigFiles(resolvedPattern);
453
+ } else if (stat.isFile()) {
454
+ if (resolvedPattern.endsWith('.prose')) {
455
+ entryFiles = [resolvedPattern];
456
+ }
457
+ } else {
458
+ throw new Error(`Entry path ${entryPattern} is not a file or directory`);
459
+ }
460
+ }
461
+ } catch (error) {
462
+ if (error.code === 'ENOENT') {
463
+ throw new Error(`Cannot read entry path ${entryPattern}: file or directory not found`);
464
+ }
465
+ throw new Error(`Cannot read entry path ${entryPattern}: ${error.message}`);
466
+ }
467
+
468
+ // Add files to set (deduplicates automatically)
469
+ for (const file of entryFiles) {
470
+ allFiles.add(file);
471
+ }
472
+ }
473
+
474
+ // Convert to array and sort for deterministic ordering
475
+ // Sort by path to ensure consistent output order
476
+ const sortedFiles = Array.from(allFiles).sort();
477
+
478
+ if (sortedFiles.length === 0) {
479
+ throw new Error(`No .prose files found in entries: ${entries.join(', ')}`);
480
+ }
481
+
482
+ return sortedFiles;
483
+ }
484
+
485
+ // Handle single entry string (backward compat)
486
+ const entryPath = resolve(configDir, entry);
487
+ let files = [];
488
+
489
+ try {
490
+ const stat = statSync(entryPath);
491
+ if (stat.isDirectory()) {
492
+ files = findSprigFiles(entryPath);
493
+ } else if (stat.isFile()) {
494
+ files = [entryPath];
495
+ } else {
496
+ throw new Error(`Entry path ${entry} is not a file or directory`);
497
+ }
498
+ } catch (error) {
499
+ throw new Error(`Cannot read entry file ${entry}: ${error.message}`);
500
+ }
501
+
502
+ if (files.length === 0) {
503
+ throw new Error(`No .prose files found in ${entry}`);
504
+ }
505
+
506
+ return files;
507
+ }
508
+
509
+ /**
510
+ * Compile scene files to a single merged manifest
511
+ * @param {string} universeRoot - Universe root directory
512
+ * @param {boolean} silent - If true, don't print success message
513
+ * @returns {boolean} - True if successful, false otherwise
514
+ */
515
+ function compileScenes(universeRoot, silent = false) {
516
+ // Use defaults relative to universe root
517
+ const inputDir = resolve(universeRoot, './sprig');
518
+ const outputDir = resolve(universeRoot, './.sprig');
519
+ const manifestPath = join(outputDir, 'scenes.json');
520
+
521
+ // Find all .scene.prose files in the input directory
522
+ let sceneFiles = [];
523
+ try {
524
+ if (statSync(inputDir).isDirectory()) {
525
+ sceneFiles = findSceneFiles(inputDir);
526
+ }
527
+ } catch (error) {
528
+ // Input directory doesn't exist, skip
529
+ return true;
530
+ }
531
+
532
+ if (sceneFiles.length === 0) {
533
+ return true; // No scene files found, nothing to do
534
+ }
535
+
536
+ // Ensure output directory exists
537
+ try {
538
+ mkdirSync(outputDir, { recursive: true });
539
+ } catch (error) {
540
+ // Directory might already exist, ignore
541
+ }
542
+
543
+ const fileASTs = [];
544
+ const errors = [];
545
+ const sceneNames = [];
546
+
547
+ // Parse all scene files
548
+ for (const sceneFile of sceneFiles) {
549
+ try {
550
+ const text = readFileSync(sceneFile, 'utf-8');
551
+ const ast = parseText(text, sceneFile);
552
+
553
+ // Only process if file contains scenes
554
+ if (ast.scenes && ast.scenes.length > 0) {
555
+ fileASTs.push(ast);
556
+ // Collect scene names for status message
557
+ for (const scene of ast.scenes) {
558
+ sceneNames.push(scene.name);
559
+ }
560
+ }
561
+ } catch (error) {
562
+ errors.push({ file: sceneFile, error: error.message });
563
+ if (!silent) {
564
+ console.error(`Error compiling ${sceneFile}: ${error.message}`);
565
+ }
566
+ }
567
+ }
568
+
569
+ if (errors.length > 0) {
570
+ return false;
571
+ }
572
+
573
+ if (fileASTs.length === 0) {
574
+ return true; // No scenes found, nothing to do
575
+ }
576
+
577
+ // Merge all scenes into a single manifest
578
+ try {
579
+ const manifest = convertFilesToSceneManifest(fileASTs);
580
+
581
+ // Write merged manifest
582
+ const json = JSON.stringify(manifest, null, 2);
583
+ writeFileSync(manifestPath, json, 'utf-8');
584
+
585
+ if (!silent && sceneNames.length > 0) {
586
+ const sceneList =
587
+ sceneNames.length > 3
588
+ ? `${sceneNames.slice(0, 3).join(', ')}, ...`
589
+ : sceneNames.join(', ');
590
+ console.log(`✓ Scenes compiled (${sceneList})`);
591
+ }
592
+
593
+ return true;
594
+ } catch (error) {
595
+ if (!silent) {
596
+ console.error(`Error writing scene manifest: ${error.message}`);
597
+ }
598
+ return false;
599
+ }
600
+ }
601
+
602
+ /**
603
+ * Compile universe files to manifest (using discovered files)
604
+ * @param {string} universeRoot - Universe root directory
605
+ * @param {string[]} files - Array of prose file paths to compile
606
+ * @param {boolean} silent - If true, don't print success message
607
+ * @returns {boolean} - True if successful, false otherwise
608
+ */
609
+ function compileUniverseWithFiles(universeRoot, files, silent = false) {
610
+ // Repositories will be extracted from prose files by buildGraph
611
+ // We'll validate them after parsing
612
+
613
+ // Use default output path relative to universe root
614
+ const outputDir = resolve(universeRoot, './.sprig');
615
+ const manifestPath = join(outputDir, 'manifest.json');
616
+
617
+ // Read and parse files
618
+ const fileContents = files.map((file) => ({
619
+ file,
620
+ text: readFileSync(file, 'utf-8'),
621
+ }));
622
+
623
+ try {
624
+ const graph = parseFiles(fileContents);
625
+
626
+ // Validate exactly one universe (should already be validated, but double-check)
627
+ const validation = validateUniverseCount(graph);
628
+ if (!validation.valid) {
629
+ console.error(`Error: ${validation.error}`);
630
+ return false;
631
+ }
632
+
633
+ // Validate repository kinds exist
634
+ if (!validateRepositoryKinds(graph.repositories || {}, universeRoot)) {
635
+ return false;
636
+ }
637
+
638
+ // Validate references
639
+ if (!validateReferences(graph, graph.repositories || {})) {
640
+ return false;
641
+ }
642
+
643
+ // Get series names for status message
644
+ const seriesNames = Object.values(graph.nodes)
645
+ .filter((node) => node.kind === 'series')
646
+ .map((node) => node.name)
647
+ .sort();
648
+
649
+ // Add repositories and metadata to manifest
650
+ const manifest = {
651
+ ...graph,
652
+ repositories: graph.repositories || {},
653
+ generatedAt: new Date().toISOString(),
654
+ };
655
+
656
+ // Ensure output directory exists
657
+ const manifestDir = dirname(manifestPath);
658
+ try {
659
+ mkdirSync(manifestDir, { recursive: true });
660
+ } catch (error) {
661
+ // Directory might already exist, ignore
662
+ }
663
+
664
+ // Write manifest
665
+ const json = JSON.stringify(manifest, null, 2);
666
+ writeFileSync(manifestPath, json, 'utf-8');
667
+
668
+ // Check for parsing errors
669
+ const hasErrors = graph.diagnostics.some((d) => d.severity === 'error');
670
+ if (hasErrors) {
671
+ return false;
672
+ }
673
+
674
+ // Display warnings
675
+ const warnings = graph.diagnostics.filter((d) => d.severity === 'warning');
676
+ if (warnings.length > 0 && !silent) {
677
+ console.warn(`\n⚠️ ${warnings.length} warning(s):`);
678
+ for (const warning of warnings) {
679
+ const source = warning.source
680
+ ? `${warning.source.file}:${warning.source.start.line}:${warning.source.start.col}`
681
+ : 'unknown location';
682
+ console.warn(` ${source}: ${warning.message}`);
683
+ }
684
+ console.warn('');
685
+ }
686
+
687
+ if (!silent) {
688
+ if (seriesNames.length > 0) {
689
+ const seriesList =
690
+ seriesNames.length > 3
691
+ ? `${seriesNames.slice(0, 3).join(', ')}, ...`
692
+ : seriesNames.join(', ');
693
+ console.log(`✓ Universe compiled (${seriesList})`);
694
+ } else {
695
+ console.log(`✓ Universe compiled`);
696
+ }
697
+ }
698
+
699
+ return true;
700
+ } catch (error) {
701
+ console.error(`Error: ${error.message}`);
702
+ if (error.stack && !silent) {
703
+ console.error(error.stack);
704
+ }
705
+ return false;
706
+ }
707
+ }
708
+
709
+ /**
710
+ * Compile universe files to manifest
711
+ * @param {any} config - Full config object
712
+ * @param {string} configPath - Path to config file
713
+ * @param {boolean} silent - If true, don't print success message
714
+ * @returns {boolean} - True if successful, false otherwise
715
+ */
716
+ function compileUniverse(config, configPath, silent = false) {
717
+ const universeConfig = config.universe;
718
+ const repositories = config.repositories || {};
719
+
720
+ if (!universeConfig) {
721
+ console.error('Error: Missing "universe" section in config');
722
+ return false;
723
+ }
724
+
725
+ const configDir = dirname(configPath);
726
+
727
+ // Support both old format (manifest at top level) and new format (manifest in output)
728
+ let manifestPath;
729
+ if (universeConfig.output?.manifest) {
730
+ // New format: manifest is just a filename, goes in output directory
731
+ const outputDir = resolve(configDir, universeConfig.output.directory || './dist/sprig');
732
+ manifestPath = join(outputDir, universeConfig.output.manifest);
733
+ } else if (universeConfig.manifest) {
734
+ // Old format: manifest might be a full path or relative path
735
+ manifestPath = resolve(configDir, universeConfig.manifest);
736
+ } else {
737
+ // Default: manifest.json in dist/sprig
738
+ const outputDir = resolve(configDir, './dist/sprig');
739
+ manifestPath = join(outputDir, 'manifest.json');
740
+ }
741
+
742
+ let files;
743
+ try {
744
+ files = getFilesToCompile(universeConfig, configDir);
745
+ } catch (error) {
746
+ console.error(`Error: ${error.message}`);
747
+ return false;
748
+ }
749
+
750
+ // Read and parse files
751
+ const fileContents = files.map((file) => ({
752
+ file,
753
+ text: readFileSync(file, 'utf-8'),
754
+ }));
755
+
756
+ try {
757
+ const graph = parseFiles(fileContents);
758
+
759
+ // Validate references
760
+ if (!validateReferences(graph, repositories)) {
761
+ return false;
762
+ }
763
+
764
+ // Get series names for status message
765
+ const seriesNames = Object.values(graph.nodes)
766
+ .filter((node) => node.kind === 'series')
767
+ .map((node) => node.name)
768
+ .sort();
769
+
770
+ // Add repositories and metadata to manifest
771
+ const manifest = {
772
+ ...graph,
773
+ repositories,
774
+ generatedAt: new Date().toISOString(),
775
+ };
776
+
777
+ // Ensure output directory exists
778
+ const manifestDir = dirname(manifestPath);
779
+ try {
780
+ mkdirSync(manifestDir, { recursive: true });
781
+ } catch (error) {
782
+ // Directory might already exist, ignore
783
+ }
784
+
785
+ // Write manifest
786
+ const json = JSON.stringify(manifest, null, 2);
787
+ writeFileSync(manifestPath, json, 'utf-8');
788
+
789
+ // Check for parsing errors
790
+ const hasErrors = graph.diagnostics.some((d) => d.severity === 'error');
791
+ if (hasErrors) {
792
+ return false;
793
+ }
794
+
795
+ // Display warnings
796
+ const warnings = graph.diagnostics.filter((d) => d.severity === 'warning');
797
+ if (warnings.length > 0 && !silent) {
798
+ console.warn(`\n⚠️ ${warnings.length} warning(s):`);
799
+ for (const warning of warnings) {
800
+ const source = warning.source
801
+ ? `${warning.source.file}:${warning.source.start.line}:${warning.source.start.col}`
802
+ : 'unknown location';
803
+ console.warn(` ${source}: ${warning.message}`);
804
+ }
805
+ console.warn('');
806
+ }
807
+
808
+ if (!silent) {
809
+ if (seriesNames.length > 0) {
810
+ const seriesList =
811
+ seriesNames.length > 3
812
+ ? `${seriesNames.slice(0, 3).join(', ')}, ...`
813
+ : seriesNames.join(', ');
814
+ console.log(`✓ Universe compiled (${seriesList})`);
815
+ } else {
816
+ console.log(`✓ Universe compiled`);
817
+ }
818
+ }
819
+
820
+ return true;
821
+ } catch (error) {
822
+ console.error(`Error: ${error.message}`);
823
+ if (error.stack && !silent) {
824
+ console.error(error.stack);
825
+ }
826
+ return false;
827
+ }
828
+ }
829
+
830
+ /**
831
+ * Handle compile command
832
+ * @param {string[]} args
833
+ */
834
+ function handleCompile(args) {
835
+ const quiet = hasQuietFlag(args);
836
+ const rootStart = resolveRootPath(args);
837
+
838
+ // Discover universe root
839
+ const universeRoot = discoverUniverseRoot(rootStart);
840
+ if (!universeRoot) {
841
+ console.error(`Error: Could not find universe.prose marker. Searched upward from: ${rootStart}`);
842
+ process.exit(1);
843
+ }
844
+
845
+ // Load prose files using new discovery mechanism
846
+ const proseFiles = loadProseFiles(universeRoot);
847
+ if (proseFiles.length === 0) {
848
+ console.error(`Error: No .prose files found under root: ${universeRoot}`);
849
+ process.exit(1);
850
+ }
851
+
852
+ // Parse files to get universe info for header
853
+ const fileContents = proseFiles.map((file) => ({
854
+ file,
855
+ text: readFileSync(file, 'utf-8'),
856
+ }));
857
+
858
+ let graph;
859
+ try {
860
+ graph = parseFiles(fileContents);
861
+ } catch (error) {
862
+ console.error(`Error parsing files: ${error.message}`);
863
+ process.exit(1);
864
+ }
865
+
866
+ // Validate exactly one universe
867
+ const validation = validateUniverseCount(graph);
868
+ if (!validation.valid) {
869
+ console.error(`Error: ${validation.error}`);
870
+ process.exit(1);
871
+ }
872
+
873
+ // Print observable header unless quiet
874
+ if (!quiet) {
875
+ printObservableHeader(validation.universeName, universeRoot, proseFiles.length);
876
+ }
877
+
878
+ // Use discovered files for compilation
879
+ const universeSuccess = compileUniverseWithFiles(universeRoot, proseFiles, quiet);
880
+ const sceneSuccess = compileScenes(universeRoot, quiet);
881
+
882
+ if (!universeSuccess || !sceneSuccess) {
883
+ process.exit(1);
884
+ }
885
+ }
886
+
887
+ /**
888
+ * Groups errors by a stable signature
889
+ * @param {Array} errors - Array of validation errors
890
+ * @returns {Array} Array of error groups
891
+ */
892
+ function groupErrors(errors) {
893
+ const groups = new Map();
894
+
895
+ for (const error of errors) {
896
+ // Create signature key from: actorName, fieldName, errorKind, expected, actual
897
+ // Normalize expected/actual for stable grouping
898
+ const fieldName = error.fieldName || '<root>';
899
+ const errorKind = error.errorKind || 'unknown';
900
+ const expected = String(error.expected || '').trim();
901
+ const actual = String(error.actual || '').trim();
902
+ const severity = error.severity || 'error';
903
+
904
+ // Include dataPath in signature if it materially changes meaning (e.g., nested array errors)
905
+ const signatureKey = `${error.actorName}|${fieldName}|${errorKind}|${expected}|${actual}${error.occurrence?.dataPath ? `|${error.occurrence.dataPath}` : ''}`;
906
+
907
+ if (!groups.has(signatureKey)) {
908
+ groups.set(signatureKey, {
909
+ actorName: error.actorName,
910
+ fieldName,
911
+ errorKind,
912
+ severity,
913
+ expected,
914
+ actual,
915
+ schemaLocation: error.schemaLocation || error.location,
916
+ hint: error.hint,
917
+ occurrences: [],
918
+ files: new Set(),
919
+ });
920
+ }
921
+
922
+ const group = groups.get(signatureKey);
923
+ const dataFile = error.occurrence?.dataFile || error.path;
924
+ const recordIndex = error.occurrence?.recordIndex ?? null;
925
+
926
+ group.occurrences.push({
927
+ dataFile,
928
+ recordIndex,
929
+ dataPath: error.occurrence?.dataPath,
930
+ });
931
+ group.files.add(dataFile);
932
+ }
933
+
934
+ // Sort occurrences within each group: files sorted, then record index ascending
935
+ for (const group of groups.values()) {
936
+ group.occurrences.sort((a, b) => {
937
+ if (a.dataFile !== b.dataFile) {
938
+ return a.dataFile.localeCompare(b.dataFile);
939
+ }
940
+ // Same file: sort by record index (nulls last)
941
+ if (a.recordIndex === null && b.recordIndex === null) return 0;
942
+ if (a.recordIndex === null) return 1;
943
+ if (b.recordIndex === null) return -1;
944
+ return a.recordIndex - b.recordIndex;
945
+ });
946
+ }
947
+
948
+ return Array.from(groups.values());
949
+ }
950
+
951
+ /**
952
+ * Selects representative examples across files (at most 1 per file until N reached)
953
+ * @param {Array} occurrences - Sorted array of occurrences
954
+ * @param {number} maxExamples - Maximum number of examples to return
955
+ * @returns {Array} Selected examples
956
+ */
957
+ function selectRepresentativeExamples(occurrences, maxExamples = 3) {
958
+ const examples = [];
959
+ const filesUsed = new Set();
960
+ const remaining = [];
961
+
962
+ // First pass: take one example per file
963
+ for (const occ of occurrences) {
964
+ if (examples.length >= maxExamples) break;
965
+
966
+ if (!filesUsed.has(occ.dataFile)) {
967
+ examples.push(occ);
968
+ filesUsed.add(occ.dataFile);
969
+ } else {
970
+ remaining.push(occ);
971
+ }
972
+ }
973
+
974
+ // Second pass: if we still need more examples, take from remaining (same files)
975
+ if (examples.length < maxExamples && remaining.length > 0) {
976
+ const needed = maxExamples - examples.length;
977
+ examples.push(...remaining.slice(0, needed));
978
+ }
979
+
980
+ return examples;
981
+ }
982
+
983
+ /**
984
+ * Wraps text to a comfortable line width for hints/notes
985
+ * @param {string} text - Text to wrap
986
+ * @param {number} width - Maximum line width (default 75)
987
+ * @returns {string[]} Array of wrapped lines
988
+ */
989
+ function wrapHintText(text, width = 75) {
990
+ if (!text) return [];
991
+
992
+ const words = text.split(/\s+/);
993
+ const lines = [];
994
+ let currentLine = '';
995
+
996
+ for (const word of words) {
997
+ const testLine = currentLine ? `${currentLine} ${word}` : word;
998
+ if (testLine.length <= width) {
999
+ currentLine = testLine;
1000
+ } else {
1001
+ if (currentLine) {
1002
+ lines.push(currentLine);
1003
+ }
1004
+ currentLine = word;
1005
+ }
1006
+ }
1007
+
1008
+ if (currentLine) {
1009
+ lines.push(currentLine);
1010
+ }
1011
+
1012
+ return lines;
1013
+ }
1014
+
1015
+ /**
1016
+ * Prints error groups in a calm report format
1017
+ * @param {Array} groups - Array of error groups
1018
+ * @param {string} repoRoot - Repository root directory for relativizing paths
1019
+ */
1020
+ function printErrorGroups(groups, repoRoot) {
1021
+ // Separate errors and warnings
1022
+ const errorGroups = groups.filter(g => (g.severity || 'error') === 'error');
1023
+ const warningGroups = groups.filter(g => (g.severity || 'error') === 'warning');
1024
+ const infoGroups = groups.filter(g => (g.severity || 'error') === 'info');
1025
+
1026
+ if (errorGroups.length > 0) {
1027
+ console.log(`\nErrors:`);
1028
+ for (const group of errorGroups) {
1029
+ printErrorGroup(group, repoRoot);
1030
+ }
1031
+ }
1032
+
1033
+ if (warningGroups.length > 0) {
1034
+ console.log(`\nWarnings:`);
1035
+ for (const group of warningGroups) {
1036
+ printErrorGroup(group, repoRoot);
1037
+ }
1038
+ }
1039
+
1040
+ if (infoGroups.length > 0) {
1041
+ console.log(`\nInfo:`);
1042
+ for (const group of infoGroups) {
1043
+ printErrorGroup(group, repoRoot);
1044
+ }
1045
+ }
1046
+ }
1047
+
1048
+ /**
1049
+ * Prints a single error group
1050
+ * @param {Object} group - Error group
1051
+ * @param {string} repoRoot - Repository root directory for relativizing paths
1052
+ */
1053
+ function printErrorGroup(group, repoRoot) {
1054
+ // Check if this is a MySQL validation error (schema-level, not data-level)
1055
+ const isMysqlError = group.errorKind && group.errorKind.startsWith('mysql.');
1056
+
1057
+ // Format header: [Actor.field] Error kind
1058
+ const header = group.fieldName === '<root>'
1059
+ ? `[${group.actorName}] ${formatErrorKind(group.errorKind)}`
1060
+ : `[${group.actorName}.${group.fieldName}] ${formatErrorKind(group.errorKind)}`;
1061
+
1062
+ console.log(` ${header}`);
1063
+
1064
+ // Expected vs Actual (or Scene vs DB for MySQL errors)
1065
+ if (group.expected && group.actual) {
1066
+ if (isMysqlError) {
1067
+ // For MySQL errors, use Scene/DB labels for clarity
1068
+ console.log(` Scene: ${group.expected}`);
1069
+ console.log(` DB: ${group.actual}`);
1070
+ } else {
1071
+ // For file validation, use Expected/Actual
1072
+ console.log(` Expected: ${group.expected}`);
1073
+ console.log(` Actual: ${group.actual}`);
1074
+ }
1075
+ }
1076
+
1077
+ // Occurrences summary - different phrasing for MySQL vs file validation
1078
+ const totalOccurrences = group.occurrences.length;
1079
+ const uniqueFiles = group.files.size;
1080
+
1081
+ if (isMysqlError) {
1082
+ // MySQL validation is schema-level, so use database schema/table phrasing
1083
+ if (totalOccurrences === 1) {
1084
+ console.log(` Database schema: ${group.occurrences[0].dataFile}`);
1085
+ } else {
1086
+ console.log(` Found in ${totalOccurrences} database schema${totalOccurrences !== 1 ? 's' : ''}`);
1087
+ }
1088
+ } else {
1089
+ // File validation is data-level, so use record/file phrasing
1090
+ console.log(` Occurrences: ${totalOccurrences} record${totalOccurrences !== 1 ? 's' : ''} across ${uniqueFiles} file${uniqueFiles !== 1 ? 's' : ''}`);
1091
+ }
1092
+
1093
+ // Examples (representative across files, up to 3)
1094
+ if (group.occurrences.length > 0) {
1095
+ if (isMysqlError && totalOccurrences === 1) {
1096
+ // For single MySQL occurrence, skip examples section since we already showed it
1097
+ } else {
1098
+ console.log(` Examples:`);
1099
+ const examples = selectRepresentativeExamples(group.occurrences, 3);
1100
+ for (const occ of examples) {
1101
+ if (isMysqlError) {
1102
+ // For MySQL, just show the schema.table
1103
+ console.log(` - ${occ.dataFile}`);
1104
+ } else {
1105
+ // For file validation, show file with record index if applicable
1106
+ const recordStr = occ.recordIndex !== null ? ` (record ${occ.recordIndex})` : '';
1107
+ const pathStr = occ.dataPath ? ` at ${occ.dataPath}` : '';
1108
+ console.log(` - ${occ.dataFile}${recordStr}${pathStr}`);
1109
+ }
1110
+ }
1111
+ if (group.occurrences.length > examples.length) {
1112
+ console.log(` - ...`);
1113
+ }
1114
+ }
1115
+ }
1116
+
1117
+ // Schema location (repo-relative if possible)
1118
+ if (group.schemaLocation) {
1119
+ let schemaFile = group.schemaLocation.file;
1120
+ if (schemaFile && repoRoot) {
1121
+ try {
1122
+ schemaFile = relative(repoRoot, schemaFile);
1123
+ } catch {
1124
+ // If relativization fails, use absolute path
1125
+ }
1126
+ }
1127
+ const schemaStr = schemaFile && group.schemaLocation.start
1128
+ ? `${schemaFile}:${group.schemaLocation.start.line}:${group.schemaLocation.start.col}`
1129
+ : 'unknown location';
1130
+ console.log(` Schema: ${schemaStr}`);
1131
+ }
1132
+
1133
+ // Display hint if present (formatted with line breaks)
1134
+ if (group.hint) {
1135
+ console.log(` Note:`);
1136
+ const wrappedLines = wrapHintText(group.hint, 75);
1137
+ for (const line of wrappedLines) {
1138
+ console.log(` ${line}`);
1139
+ }
1140
+ }
1141
+
1142
+ console.log(''); // Blank line between groups
1143
+ }
1144
+
1145
+ /**
1146
+ * Formats error kind for display
1147
+ * @param {string} errorKind - Error kind
1148
+ * @returns {string} Formatted error kind
1149
+ */
1150
+ function formatErrorKind(errorKind) {
1151
+ const kindMap = {
1152
+ 'typeMismatch': 'Type mismatch',
1153
+ 'missingRequired': 'Missing required field',
1154
+ 'enum': 'Invalid enum value',
1155
+ 'identityDuplicate': 'Duplicate identity value',
1156
+ 'shapeMismatch': 'Shape mismatch',
1157
+ 'parseError': 'Parse error',
1158
+ 'fileNotFound': 'File not found',
1159
+ 'configError': 'Configuration error',
1160
+ 'mysql.connection': 'MySQL connection error',
1161
+ 'mysql.tableExists': 'MySQL table not found',
1162
+ 'mysql.missingColumn': 'MySQL column not found',
1163
+ 'mysql.nullability': 'DB does not enforce required field',
1164
+ 'mysql.typeMismatch': 'MySQL type mismatch',
1165
+ 'mysql.columns': 'MySQL column validation',
1166
+ };
1167
+ return kindMap[errorKind] || (errorKind || 'Validation error');
1168
+ }
1169
+
1170
+ /**
1171
+ * Handle validate command
1172
+ * @param {string[]} args
1173
+ */
1174
+ async function handleValidate(args) {
1175
+ const quiet = hasQuietFlag(args);
1176
+ const rootStart = resolveRootPath(args);
1177
+
1178
+ // Discover universe root
1179
+ const universeRoot = discoverUniverseRoot(rootStart);
1180
+ if (!universeRoot) {
1181
+ console.error(`Error: Could not find universe.prose marker. Searched upward from: ${rootStart}`);
1182
+ process.exit(1);
1183
+ }
1184
+
1185
+ // Load scenes manifest using default path
1186
+ const scenesManifestPath = join(universeRoot, './.sprig/scenes.json');
1187
+
1188
+ let scenesManifest;
1189
+ try {
1190
+ const manifestText = readFileSync(scenesManifestPath, 'utf-8');
1191
+ scenesManifest = JSON.parse(manifestText);
1192
+ } catch (error) {
1193
+ console.error(`Error: Failed to load scenes manifest from ${scenesManifestPath}: ${error.message}`);
1194
+ console.error('Run "sprig-universe compile" first to generate the scenes manifest');
1195
+ process.exit(1);
1196
+ }
1197
+
1198
+ // Run validation with empty config (no repositories or connections in v0)
1199
+ const emptyConfig = { repositories: {}, connections: {} };
1200
+ const report = await validateScenes(emptyConfig, scenesManifest, universeRoot);
1201
+
1202
+ // Separate errors by severity
1203
+ const errors = report.errors.filter(e => (e.severity || 'error') === 'error');
1204
+ const warnings = report.errors.filter(e => (e.severity || 'error') === 'warning');
1205
+ const infos = report.errors.filter(e => (e.severity || 'error') === 'info');
1206
+
1207
+ // Print summary
1208
+ console.log(`\nValidation Summary:`);
1209
+ console.log(` Actors validated: ${report.totalActors}`);
1210
+ console.log(` Files validated: ${report.totalFiles}`);
1211
+ console.log(` Errors found: ${errors.length}`);
1212
+ if (warnings.length > 0) {
1213
+ console.log(` Warnings found: ${warnings.length}`);
1214
+ }
1215
+ if (infos.length > 0) {
1216
+ console.log(` Info messages: ${infos.length}`);
1217
+ }
1218
+
1219
+ // Group and print errors
1220
+ if (report.errors.length > 0) {
1221
+ const groups = groupErrors(report.errors);
1222
+ printErrorGroups(groups, universeRoot);
1223
+ }
1224
+
1225
+ // Write JSON report
1226
+ const reportOutputDir = resolve(universeRoot, './.sprig');
1227
+ const reportPath = join(reportOutputDir, 'validation.json');
1228
+ try {
1229
+ mkdirSync(reportOutputDir, { recursive: true });
1230
+ } catch {
1231
+ // Directory might already exist
1232
+ }
1233
+ writeFileSync(reportPath, JSON.stringify(report, null, 2), 'utf-8');
1234
+ if (!quiet) {
1235
+ console.log(`\nValidation report written to: ${reportPath}`);
1236
+ }
1237
+
1238
+ // Exit with error code only if there are actual errors (not warnings or info)
1239
+ if (errors.length > 0) {
1240
+ process.exit(1);
1241
+ }
1242
+ }
1243
+
1244
+ /**
1245
+ * Handle watch command
1246
+ * @param {string[]} args
1247
+ */
1248
+ function handleWatch(args) {
1249
+ const quiet = hasQuietFlag(args);
1250
+ const rootStart = resolveRootPath(args);
1251
+
1252
+ // Discover universe root
1253
+ const universeRoot = discoverUniverseRoot(rootStart);
1254
+ if (!universeRoot) {
1255
+ console.error(`Error: Could not find universe.prose marker. Searched upward from: ${rootStart}`);
1256
+ process.exit(1);
1257
+ }
1258
+
1259
+ // Load prose files using new discovery mechanism
1260
+ const proseFiles = loadProseFiles(universeRoot);
1261
+ if (proseFiles.length === 0) {
1262
+ console.error(`Error: No .prose files found under root: ${universeRoot}`);
1263
+ process.exit(1);
1264
+ }
1265
+
1266
+ // Parse files to get universe info for header
1267
+ const fileContents = proseFiles.map((file) => ({
1268
+ file,
1269
+ text: readFileSync(file, 'utf-8'),
1270
+ }));
1271
+
1272
+ let graph;
1273
+ try {
1274
+ graph = parseFiles(fileContents);
1275
+ } catch (error) {
1276
+ console.error(`Error parsing files: ${error.message}`);
1277
+ process.exit(1);
1278
+ }
1279
+
1280
+ // Validate exactly one universe
1281
+ const validation = validateUniverseCount(graph);
1282
+ if (!validation.valid) {
1283
+ console.error(`Error: ${validation.error}`);
1284
+ process.exit(1);
1285
+ }
1286
+
1287
+ // Print observable header unless quiet
1288
+ if (!quiet) {
1289
+ printObservableHeader(validation.universeName, universeRoot, proseFiles.length);
1290
+ }
1291
+
1292
+ // Build watch patterns from discovered files
1293
+ const watchPatterns = [...proseFiles];
1294
+
1295
+ // Add scene files to watch patterns using default path
1296
+ const scenesInputDir = resolve(universeRoot, './sprig');
1297
+ try {
1298
+ if (statSync(scenesInputDir).isDirectory()) {
1299
+ watchPatterns.push(join(scenesInputDir, '**/*.scene.prose'));
1300
+ }
1301
+ } catch {
1302
+ // Directory doesn't exist, skip
1303
+ }
1304
+
1305
+ // Initial compile
1306
+ if (!quiet) {
1307
+ console.log('Watching universe and scene files...');
1308
+ }
1309
+ compileUniverseWithFiles(universeRoot, proseFiles, quiet);
1310
+ compileScenes(universeRoot, quiet);
1311
+
1312
+ // Debounce timer
1313
+ /** @type {NodeJS.Timeout | null} */
1314
+ let debounceTimer = null;
1315
+ const DEBOUNCE_MS = 100;
1316
+
1317
+ // Watch files
1318
+ const watcher = chokidar.watch(watchPatterns, {
1319
+ ignored: [
1320
+ /(^|[\/\\])\../, // dotfiles
1321
+ /node_modules/,
1322
+ /dist/,
1323
+ ],
1324
+ persistent: true,
1325
+ ignoreInitial: true,
1326
+ });
1327
+
1328
+ watcher.on('change', (/** @type {string} */ filePath) => {
1329
+ // Debounce rapid changes
1330
+ if (debounceTimer) {
1331
+ clearTimeout(debounceTimer);
1332
+ }
1333
+
1334
+ debounceTimer = setTimeout(() => {
1335
+ const relativePath = relative(process.cwd(), filePath);
1336
+ if (!quiet) {
1337
+ console.log(`Changed: ${relativePath}`);
1338
+ }
1339
+ if (filePath.endsWith('.scene.prose')) {
1340
+ compileScenes(universeRoot, quiet);
1341
+ } else {
1342
+ // Reload prose files in case new files were added
1343
+ const currentProseFiles = loadProseFiles(universeRoot);
1344
+ compileUniverseWithFiles(universeRoot, currentProseFiles, quiet);
1345
+ }
1346
+ }, DEBOUNCE_MS);
1347
+ });
1348
+
1349
+ watcher.on('error', (/** @type {Error} */ error) => {
1350
+ console.error(`Watch error: ${error.message}`);
1351
+ });
1352
+
1353
+ // Handle graceful shutdown
1354
+ process.on('SIGINT', () => {
1355
+ console.log('\nStopping watcher...');
1356
+ watcher.close();
1357
+ process.exit(0);
1358
+ });
1359
+ }
1360
+
1361
+ /**
1362
+ * Handle check:references command
1363
+ * @param {string[]} args
1364
+ */
1365
+ function handleCheckReferences(args) {
1366
+ const quiet = hasQuietFlag(args);
1367
+ const rootStart = resolveRootPath(args);
1368
+
1369
+ // Discover universe root
1370
+ const universeRoot = discoverUniverseRoot(rootStart);
1371
+ if (!universeRoot) {
1372
+ console.error(`Error: Could not find universe.prose marker. Searched upward from: ${rootStart}`);
1373
+ process.exit(1);
1374
+ }
1375
+
1376
+ // Load prose files using new discovery mechanism
1377
+ const proseFiles = loadProseFiles(universeRoot);
1378
+ if (proseFiles.length === 0) {
1379
+ console.error(`Error: No .prose files found under root: ${universeRoot}`);
1380
+ process.exit(1);
1381
+ }
1382
+
1383
+ // Parse files to get universe info for header
1384
+ const fileContents = proseFiles.map((file) => ({
1385
+ file,
1386
+ text: readFileSync(file, 'utf-8'),
1387
+ }));
1388
+
1389
+ let graph;
1390
+ try {
1391
+ graph = parseFiles(fileContents);
1392
+ } catch (error) {
1393
+ console.error(`Error parsing files: ${error.message}`);
1394
+ process.exit(1);
1395
+ }
1396
+
1397
+ // Validate exactly one universe
1398
+ const validation = validateUniverseCount(graph);
1399
+ if (!validation.valid) {
1400
+ console.error(`Error: ${validation.error}`);
1401
+ process.exit(1);
1402
+ }
1403
+
1404
+ // Print observable header unless quiet
1405
+ if (!quiet) {
1406
+ printObservableHeader(validation.universeName, universeRoot, proseFiles.length);
1407
+ }
1408
+
1409
+ // Validate repository kinds exist
1410
+ if (!validateRepositoryKinds(graph.repositories || {}, universeRoot)) {
1411
+ console.error('❌ Repository kind validation failed');
1412
+ process.exit(1);
1413
+ }
1414
+
1415
+ // Validate references
1416
+ if (!validateReferences(graph, graph.repositories || {})) {
1417
+ console.error('❌ Reference validation failed');
1418
+ process.exit(1);
1419
+ }
1420
+
1421
+ // Check for parsing errors
1422
+ const hasErrors = graph.diagnostics.some((d) => d.severity === 'error');
1423
+ if (hasErrors) {
1424
+ process.exit(1);
1425
+ }
1426
+
1427
+ // Display warnings
1428
+ const warnings = graph.diagnostics.filter((d) => d.severity === 'warning');
1429
+ if (warnings.length > 0) {
1430
+ console.warn(`\n⚠️ ${warnings.length} warning(s):`);
1431
+ for (const warning of warnings) {
1432
+ const source = warning.source
1433
+ ? `${warning.source.file}:${warning.source.start.line}:${warning.source.start.col}`
1434
+ : 'unknown location';
1435
+ console.warn(` ${source}: ${warning.message}`);
1436
+ }
1437
+ console.warn('');
1438
+ }
1439
+
1440
+ console.log('✅ All references are valid');
1441
+ }
1442
+
1443
+ /**
1444
+ * Handle parse command (legacy)
1445
+ * @param {string[]} args
1446
+ */
1447
+ function handleParse(args) {
1448
+
1449
+ const inputPath = args[0];
1450
+ if (!inputPath) {
1451
+ console.error('Error: <fileOrDir> is required');
1452
+ process.exit(1);
1453
+ }
1454
+
1455
+ const outIndex = args.indexOf('--out');
1456
+ const outPath = outIndex >= 0 && outIndex + 1 < args.length ? args[outIndex + 1] : null;
1457
+
1458
+ // Resolve input path
1459
+ const resolvedInput = resolve(inputPath);
1460
+ const stat = statSync(resolvedInput);
1461
+
1462
+ let files = [];
1463
+ if (stat.isDirectory()) {
1464
+ files = findSprigFiles(resolvedInput);
1465
+ } else if (stat.isFile()) {
1466
+ files = [resolvedInput];
1467
+ } else {
1468
+ console.error(`Error: ${inputPath} is not a file or directory`);
1469
+ process.exit(1);
1470
+ }
1471
+
1472
+ if (files.length === 0) {
1473
+ console.error(`Error: No .prose files found in ${inputPath}`);
1474
+ process.exit(1);
1475
+ }
1476
+
1477
+ // Read and parse files
1478
+ const fileContents = files.map((file) => ({
1479
+ file,
1480
+ text: readFileSync(file, 'utf-8'),
1481
+ }));
1482
+
1483
+ try {
1484
+ const graph = parseFiles(fileContents);
1485
+
1486
+ // Output JSON
1487
+ const json = JSON.stringify(graph, null, 2);
1488
+
1489
+ if (outPath) {
1490
+ writeFileSync(outPath, json, 'utf-8');
1491
+ } else {
1492
+ console.log(json);
1493
+ }
1494
+
1495
+ // Check for errors
1496
+ const hasErrors = graph.diagnostics.some((d) => d.severity === 'error');
1497
+ if (hasErrors) {
1498
+ process.exit(1);
1499
+ }
1500
+ } catch (error) {
1501
+ console.error(`Error: ${error.message}`);
1502
+ if (error.stack) {
1503
+ console.error(error.stack);
1504
+ }
1505
+ process.exit(1);
1506
+ }
1507
+ }
1508
+
1509
+ main();
1510
+