@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.
- package/PHILOSOPHY.md +201 -0
- package/README.md +168 -0
- package/REFERENCE.md +355 -0
- package/biome.json +24 -0
- package/package.json +30 -0
- package/repositories/sprig-repository-github/index.js +29 -0
- package/src/ast.js +257 -0
- package/src/cli.js +1510 -0
- package/src/graph.js +950 -0
- package/src/index.js +46 -0
- package/src/ir.js +121 -0
- package/src/parser.js +1656 -0
- package/src/scanner.js +255 -0
- package/src/scene-manifest.js +856 -0
- package/src/util/span.js +46 -0
- package/src/util/text.js +126 -0
- package/src/validator.js +862 -0
- package/src/validators/mysql/connection.js +154 -0
- package/src/validators/mysql/schema.js +209 -0
- package/src/validators/mysql/type-compat.js +219 -0
- package/src/validators/mysql/validator.js +332 -0
- package/test/fixtures/amaranthine-mini.prose +53 -0
- package/test/fixtures/conflicting-universes-a.prose +8 -0
- package/test/fixtures/conflicting-universes-b.prose +8 -0
- package/test/fixtures/duplicate-names.prose +20 -0
- package/test/fixtures/first-line-aware.prose +32 -0
- package/test/fixtures/indented-describe.prose +18 -0
- package/test/fixtures/multi-file-universe-a.prose +15 -0
- package/test/fixtures/multi-file-universe-b.prose +15 -0
- package/test/fixtures/multi-file-universe-conflict-desc.prose +12 -0
- package/test/fixtures/multi-file-universe-conflict-title.prose +4 -0
- package/test/fixtures/multi-file-universe-with-title.prose +10 -0
- package/test/fixtures/named-document.prose +17 -0
- package/test/fixtures/named-duplicate.prose +22 -0
- package/test/fixtures/named-reference.prose +17 -0
- package/test/fixtures/relates-errors.prose +38 -0
- package/test/fixtures/relates-tier1.prose +14 -0
- package/test/fixtures/relates-tier2.prose +16 -0
- package/test/fixtures/relates-tier3.prose +21 -0
- package/test/fixtures/sprig-meta-mini.prose +62 -0
- package/test/fixtures/unresolved-relates.prose +15 -0
- package/test/fixtures/using-in-references.prose +35 -0
- package/test/fixtures/using-unknown.prose +8 -0
- package/test/universe-basic.test.js +804 -0
- 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
|
+
|