@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
@@ -0,0 +1,332 @@
1
+ /**
2
+ * @fileoverview MySQL actor source validator
3
+ */
4
+
5
+ import {
6
+ validateConnectionConfig,
7
+ getCachedConnection,
8
+ testConnection,
9
+ closeAllConnections,
10
+ } from './connection.js';
11
+ import { checkTableExists, getColumnMetadata } from './schema.js';
12
+ import { checkTypeCompatibility } from './type-compat.js';
13
+
14
+ /**
15
+ * Validates a MySQL source for an actor
16
+ * @param {Object} actor - Actor from manifest
17
+ * @param {Object} mysqlSource - MySQL source block from manifest
18
+ * @param {Object} connections - Connections config from sprig.config.json
19
+ * @returns {Promise<Array>} Array of validation errors (empty if valid)
20
+ */
21
+ export async function validateMysqlSource(actor, mysqlSource, connections) {
22
+ const errors = [];
23
+ const connectionName = mysqlSource.connection;
24
+ const tableName = mysqlSource.table;
25
+ const sourceLocation = mysqlSource.location || actor.location;
26
+
27
+ // Step 1: Validate connection config
28
+ const configResult = validateConnectionConfig(connectionName, connections);
29
+ if (!configResult.valid) {
30
+ errors.push({
31
+ actorName: actor.name,
32
+ sourceKind: 'mysql',
33
+ path: connectionName,
34
+ message: configResult.error,
35
+ location: sourceLocation,
36
+ fieldName: '<root>',
37
+ errorKind: 'mysql.connection',
38
+ expected: `valid MySQL connection '${connectionName}'`,
39
+ actual: configResult.error,
40
+ schemaLocation: sourceLocation || actor.location,
41
+ occurrence: {
42
+ dataFile: connectionName,
43
+ recordIndex: null,
44
+ },
45
+ });
46
+ return errors; // Can't continue without valid config
47
+ }
48
+
49
+ const config = configResult.config;
50
+
51
+ // Step 2: Test database connection and get actual database name
52
+ let connection;
53
+ let actualSchema;
54
+ try {
55
+ connection = await getCachedConnection(connectionName, config);
56
+ const testResult = await testConnection(connection, config);
57
+ if (!testResult.success) {
58
+ const connectionInfo = `${config.host}:${config.port}/${config.database} (user: ${config.user})`;
59
+ errors.push({
60
+ actorName: actor.name,
61
+ sourceKind: 'mysql',
62
+ path: connectionName,
63
+ message: `Failed to connect to MySQL: ${testResult.error}`,
64
+ location: sourceLocation,
65
+ fieldName: '<root>',
66
+ errorKind: 'mysql.connection',
67
+ expected: `successful connection to ${connectionInfo}`,
68
+ actual: testResult.error,
69
+ schemaLocation: sourceLocation || actor.location,
70
+ occurrence: {
71
+ dataFile: connectionName,
72
+ recordIndex: null,
73
+ },
74
+ });
75
+ return errors; // Can't continue without connection
76
+ }
77
+ // Get the actual database name from the connection (handles case sensitivity)
78
+ actualSchema = testResult.databaseName || config.database;
79
+ } catch (error) {
80
+ const connectionInfo = `${config.host}:${config.port}/${config.database} (user: ${config.user})`;
81
+ errors.push({
82
+ actorName: actor.name,
83
+ sourceKind: 'mysql',
84
+ path: connectionName,
85
+ message: `Failed to connect to MySQL: ${error.message}`,
86
+ location: sourceLocation,
87
+ fieldName: '<root>',
88
+ errorKind: 'mysql.connection',
89
+ expected: `successful connection to ${connectionInfo}`,
90
+ actual: error.message,
91
+ schemaLocation: sourceLocation || actor.location,
92
+ occurrence: {
93
+ dataFile: connectionName,
94
+ recordIndex: null,
95
+ },
96
+ });
97
+ return errors;
98
+ }
99
+
100
+ // Step 3: Check table exists
101
+ let tableExists;
102
+ try {
103
+ tableExists = await checkTableExists(connection, actualSchema, tableName);
104
+ } catch (error) {
105
+ errors.push({
106
+ actorName: actor.name,
107
+ sourceKind: 'mysql',
108
+ path: tableName,
109
+ message: `Failed to check table existence: ${error.message}`,
110
+ location: sourceLocation,
111
+ fieldName: '<root>',
112
+ errorKind: 'mysql.tableExists',
113
+ expected: `table '${tableName}' exists in schema '${actualSchema}'`,
114
+ actual: `error: ${error.message}`,
115
+ schemaLocation: sourceLocation || actor.location,
116
+ occurrence: {
117
+ dataFile: `${actualSchema}.${tableName}`,
118
+ recordIndex: null,
119
+ },
120
+ });
121
+ return errors;
122
+ }
123
+
124
+ if (!tableExists) {
125
+ errors.push({
126
+ actorName: actor.name,
127
+ sourceKind: 'mysql',
128
+ path: tableName,
129
+ message: `Table '${tableName}' does not exist in schema '${actualSchema}'`,
130
+ location: sourceLocation,
131
+ fieldName: '<root>',
132
+ errorKind: 'mysql.tableExists',
133
+ expected: `table '${tableName}' exists in schema '${actualSchema}'`,
134
+ actual: 'table not found',
135
+ schemaLocation: sourceLocation || actor.location,
136
+ occurrence: {
137
+ dataFile: `${actualSchema}.${tableName}`,
138
+ recordIndex: null,
139
+ },
140
+ });
141
+ return errors; // Can't continue without table
142
+ }
143
+
144
+ // Step 4: Get column metadata
145
+ let columnMetadata;
146
+ try {
147
+ columnMetadata = await getColumnMetadata(connection, actualSchema, tableName, connectionName);
148
+
149
+ // If table exists but no columns found, this indicates a problem
150
+ if (columnMetadata.size === 0) {
151
+ errors.push({
152
+ actorName: actor.name,
153
+ sourceKind: 'mysql',
154
+ path: tableName,
155
+ message: `Table '${tableName}' exists but no columns found in schema '${actualSchema}'. This may indicate a permissions issue or schema mismatch.`,
156
+ location: sourceLocation,
157
+ fieldName: '<root>',
158
+ errorKind: 'mysql.columns',
159
+ expected: `column metadata for table '${tableName}' in schema '${actualSchema}'`,
160
+ actual: '0 columns found',
161
+ schemaLocation: sourceLocation || actor.location,
162
+ occurrence: {
163
+ dataFile: `${actualSchema}.${tableName}`,
164
+ recordIndex: null,
165
+ },
166
+ hint: 'Verify the table exists and you have SELECT permissions on information_schema.columns',
167
+ });
168
+ return errors;
169
+ }
170
+ } catch (error) {
171
+ errors.push({
172
+ actorName: actor.name,
173
+ sourceKind: 'mysql',
174
+ path: tableName,
175
+ message: `Failed to get column metadata: ${error.message}`,
176
+ location: sourceLocation,
177
+ fieldName: '<root>',
178
+ errorKind: 'mysql.columns',
179
+ expected: `column metadata for table '${tableName}'`,
180
+ actual: `error: ${error.message}`,
181
+ schemaLocation: sourceLocation || actor.location,
182
+ occurrence: {
183
+ dataFile: `${actualSchema}.${tableName}`,
184
+ recordIndex: null,
185
+ },
186
+ });
187
+ return errors;
188
+ }
189
+
190
+ // Step 5: Validate each field
191
+ const typeFields = actor.type?.fields || [];
192
+ for (const field of typeFields) {
193
+ const fieldName = field.name;
194
+ const fieldType = field.type;
195
+ const fieldLocation = field.location || sourceLocation;
196
+
197
+ // Skip nested objects for v1 (emit warning)
198
+ if (fieldType && (fieldType.kind === 'object' || fieldType.kind === 'reference')) {
199
+ errors.push({
200
+ actorName: actor.name,
201
+ sourceKind: 'mysql',
202
+ path: tableName,
203
+ message: `Field '${fieldName}' is a nested object (not validated for mysql yet)`,
204
+ location: fieldLocation,
205
+ fieldName: fieldName,
206
+ errorKind: 'mysql.columns',
207
+ expected: 'nested object validation (not implemented in v1)',
208
+ actual: 'skipped',
209
+ schemaLocation: fieldLocation || actor.location,
210
+ occurrence: {
211
+ dataFile: `${actualSchema}.${tableName}`,
212
+ recordIndex: null,
213
+ },
214
+ hint: 'Nested object validation for MySQL is not yet implemented',
215
+ });
216
+ continue;
217
+ }
218
+
219
+ // Check column exists (case-insensitive lookup)
220
+ let columnMeta = columnMetadata.get(fieldName);
221
+
222
+ // If not found, try case-insensitive lookup
223
+ if (!columnMeta && columnMetadata._lowerToOriginal) {
224
+ const lowerName = fieldName.toLowerCase();
225
+ const originalName = columnMetadata._lowerToOriginal.get(lowerName);
226
+ if (originalName) {
227
+ columnMeta = columnMetadata.get(originalName);
228
+ }
229
+ }
230
+
231
+ if (!columnMeta) {
232
+ // Get list of available columns for better error message
233
+ const availableColumns = Array.from(columnMetadata.keys())
234
+ .filter(k => typeof k === 'string' && !k.startsWith('__lower__'))
235
+ .join(', ');
236
+
237
+ errors.push({
238
+ actorName: actor.name,
239
+ sourceKind: 'mysql',
240
+ path: tableName,
241
+ message: `Missing column '${fieldName}' in table '${tableName}'. Available columns: ${availableColumns || '(none)'}`,
242
+ location: fieldLocation,
243
+ fieldName: fieldName,
244
+ errorKind: 'mysql.missingColumn',
245
+ expected: `column '${fieldName}' exists`,
246
+ actual: `column not found. Available: ${availableColumns || '(none)'}`,
247
+ schemaLocation: fieldLocation || actor.location,
248
+ occurrence: {
249
+ dataFile: `${actualSchema}.${tableName}`,
250
+ recordIndex: null,
251
+ },
252
+ });
253
+ continue;
254
+ }
255
+
256
+ // Check nullability compatibility (informational warning)
257
+ const isNullable = columnMeta.is_nullable === 'YES';
258
+ const isOptional = !field.required;
259
+
260
+ if (!isOptional && isNullable) {
261
+ // Required field but column is nullable - structural warning
262
+ // The database schema does not enforce the required constraint
263
+ errors.push({
264
+ actorName: actor.name,
265
+ sourceKind: 'mysql',
266
+ path: tableName,
267
+ message: `Database column '${fieldName}' is nullable but prose requires this field (DB does not enforce required field)`,
268
+ location: fieldLocation,
269
+ fieldName: fieldName,
270
+ errorKind: 'mysql.nullability',
271
+ severity: 'warning',
272
+ expected: 'required (NOT NULL)',
273
+ actual: 'nullable (NULL allowed)',
274
+ schemaLocation: fieldLocation || actor.location,
275
+ occurrence: {
276
+ dataFile: `${actualSchema}.${tableName}`,
277
+ recordIndex: null,
278
+ },
279
+ hint: 'The database allows NULLs for this field. If you rely on this field being present, consider enforcing NOT NULL after any backfill or migration.',
280
+ });
281
+ }
282
+
283
+ // Check type compatibility
284
+ const typeResult = checkTypeCompatibility(fieldType, columnMeta);
285
+ if (!typeResult.compatible) {
286
+ errors.push({
287
+ actorName: actor.name,
288
+ sourceKind: 'mysql',
289
+ path: tableName,
290
+ message: `Column '${fieldName}' type mismatch: ${typeResult.message}`,
291
+ location: fieldLocation,
292
+ fieldName: fieldName,
293
+ errorKind: 'mysql.typeMismatch',
294
+ expected: field.typeExpr || 'compatible type',
295
+ actual: columnMeta.column_type,
296
+ schemaLocation: fieldLocation || actor.location,
297
+ occurrence: {
298
+ dataFile: `${actualSchema}.${tableName}`,
299
+ recordIndex: null,
300
+ },
301
+ });
302
+ } else if (typeResult.severity === 'warning' && typeResult.message) {
303
+ // Add warning-level errors for type compatibility warnings
304
+ errors.push({
305
+ actorName: actor.name,
306
+ sourceKind: 'mysql',
307
+ path: tableName,
308
+ message: `Column '${fieldName}' type compatibility: ${typeResult.message}`,
309
+ location: fieldLocation,
310
+ fieldName: fieldName,
311
+ errorKind: 'mysql.typeMismatch',
312
+ expected: field.typeExpr || 'compatible type',
313
+ actual: columnMeta.column_type,
314
+ schemaLocation: fieldLocation || actor.location,
315
+ occurrence: {
316
+ dataFile: `${actualSchema}.${tableName}`,
317
+ recordIndex: null,
318
+ },
319
+ hint: typeResult.message,
320
+ });
321
+ }
322
+ }
323
+
324
+ return errors;
325
+ }
326
+
327
+ /**
328
+ * Cleanup function to close all connections
329
+ * Should be called after validation is complete
330
+ */
331
+ export { closeAllConnections };
332
+
@@ -0,0 +1,53 @@
1
+ universe Amaranthine {
2
+ describe {
3
+ Amaranthine is a persistent text-based roleplaying game. At its core, it is
4
+ a semi-afk game where you can do any of the twelve in-game skills and
5
+ progress your character.
6
+ }
7
+
8
+ series Player {
9
+ describe {
10
+ The player is central to everything. From chat to skills to inventory,
11
+ nearly everything is based around the player.
12
+ }
13
+
14
+ references {
15
+ reference {
16
+ repository { 'amaranthine' }
17
+ paths { '/backends/api/src/routers/players.js' }
18
+ }
19
+ }
20
+ }
21
+
22
+ series Item {
23
+ describe {
24
+ Items are physical objects in the game that players can interact with.
25
+ }
26
+
27
+ references {
28
+ reference {
29
+ repository { 'amaranthine' }
30
+ paths { '/data/items/*.yaml' }
31
+ }
32
+ }
33
+ }
34
+
35
+ book Equipment in Item {
36
+ describe {
37
+ Equipment is a category of items that are used to equip a player.
38
+ }
39
+ }
40
+
41
+ book Resource in Item {
42
+ describe {
43
+ Resources are items that are used to create other items.
44
+ }
45
+ }
46
+
47
+ chapter Tool in Equipment {
48
+ describe {
49
+ Tools are categories of items that are used to perform a task.
50
+ }
51
+ }
52
+ }
53
+
@@ -0,0 +1,8 @@
1
+ universe Amaranthine {
2
+ series Items {
3
+ describe {
4
+ Items are objects.
5
+ }
6
+ }
7
+ }
8
+
@@ -0,0 +1,8 @@
1
+ universe OtherUniverse {
2
+ series Skills {
3
+ describe {
4
+ Skills are abilities.
5
+ }
6
+ }
7
+ }
8
+
@@ -0,0 +1,20 @@
1
+ universe Test {
2
+ series Duplicate {
3
+ describe { First occurrence }
4
+ }
5
+
6
+ -- This is a duplicate name
7
+ series Duplicate {
8
+ describe { Second occurrence }
9
+ }
10
+
11
+ book Book1 in Duplicate {
12
+ describe { Book 1 }
13
+ }
14
+
15
+ -- Another duplicate (different kind, but same name)
16
+ book Book1 in Duplicate {
17
+ describe { Book 1 duplicate }
18
+ }
19
+ }
20
+
@@ -0,0 +1,32 @@
1
+ universe Test {
2
+ describe {
3
+ This is a test universe
4
+ with multiple lines
5
+ and indented content
6
+ that should be dedented.
7
+ }
8
+
9
+ series Example {
10
+ describe {
11
+ This describe block
12
+ has leading indentation
13
+ that should be removed
14
+ but internal indentation preserved.
15
+ }
16
+ }
17
+
18
+ book SingleLine in Example {
19
+ describe {
20
+ Single line describe should remain unchanged.
21
+ }
22
+ }
23
+
24
+ book AllIndented in Example {
25
+ describe {
26
+ All lines
27
+ are indented
28
+ consistently
29
+ }
30
+ }
31
+ }
32
+
@@ -0,0 +1,18 @@
1
+ universe Test {
2
+ describe {
3
+ This is a test universe
4
+ with multiple lines
5
+ and indented content
6
+ that should be dedented.
7
+ }
8
+
9
+ series Example {
10
+ describe {
11
+ This describe block
12
+ has leading indentation
13
+ that should be removed
14
+ but internal indentation preserved.
15
+ }
16
+ }
17
+ }
18
+
@@ -0,0 +1,15 @@
1
+ universe Amaranthine {
2
+ series Items {
3
+ describe {
4
+ Items are objects that can exist in the world.
5
+ }
6
+ }
7
+
8
+ references {
9
+ reference {
10
+ repository { 'amaranthine' }
11
+ paths { '/data/items.yaml' }
12
+ }
13
+ }
14
+ }
15
+
@@ -0,0 +1,15 @@
1
+ universe Amaranthine {
2
+ series Skills {
3
+ describe {
4
+ Skills represent abilities that players can learn.
5
+ }
6
+ }
7
+
8
+ references {
9
+ reference {
10
+ repository { 'amaranthine' }
11
+ paths { '/data/skills.yaml' }
12
+ }
13
+ }
14
+ }
15
+
@@ -0,0 +1,12 @@
1
+ universe Amaranthine {
2
+ describe {
3
+ This is a conflicting describe block.
4
+ }
5
+
6
+ series Conflict {
7
+ describe {
8
+ This series should still work.
9
+ }
10
+ }
11
+ }
12
+
@@ -0,0 +1,4 @@
1
+ universe Amaranthine {
2
+ title { Conflicting Title }
3
+ }
4
+
@@ -0,0 +1,10 @@
1
+ universe Amaranthine {
2
+ title { Amaranthine Universe }
3
+
4
+ series Items {
5
+ describe {
6
+ Items are objects.
7
+ }
8
+ }
9
+ }
10
+
@@ -0,0 +1,17 @@
1
+ universe Test {
2
+ document ItemsDesignDoc {
3
+ kind { 'internal' }
4
+ path { '/docs/items/overview.md' }
5
+ describe { High-level items design notes. }
6
+ }
7
+
8
+ series Items {
9
+ documentation {
10
+ document {
11
+ kind { 'internal' }
12
+ path { '/docs/items/details.md' }
13
+ }
14
+ }
15
+ }
16
+ }
17
+
@@ -0,0 +1,22 @@
1
+ universe Test {
2
+ reference ItemRouter {
3
+ repository { 'amaranthine-backend' }
4
+ paths { '/src/routers/items.ts' }
5
+ }
6
+
7
+ reference ItemRouter {
8
+ repository { 'amaranthine-backend' }
9
+ paths { '/src/routers/items-v2.ts' }
10
+ }
11
+
12
+ document ItemsDesignDoc {
13
+ kind { 'internal' }
14
+ path { '/docs/items/overview.md' }
15
+ }
16
+
17
+ document ItemsDesignDoc {
18
+ kind { 'public' }
19
+ path { '/docs/items/public-overview.md' }
20
+ }
21
+ }
22
+
@@ -0,0 +1,17 @@
1
+ universe Test {
2
+ reference ItemRouter {
3
+ repository { 'amaranthine-backend' }
4
+ paths { '/src/routers/items.ts' }
5
+ describe { Routes that implement item endpoints. }
6
+ }
7
+
8
+ series Items {
9
+ references {
10
+ reference {
11
+ repository { 'amaranthine-backend' }
12
+ paths { '/src/items/helpers.ts' }
13
+ }
14
+ }
15
+ }
16
+ }
17
+
@@ -0,0 +1,38 @@
1
+ universe Test {
2
+ series Item {
3
+ describe { Items are physical objects in the game. }
4
+ }
5
+
6
+ series Action {
7
+ describe { Actions are things players can do. }
8
+ }
9
+
10
+ -- Unknown endpoint
11
+ relates Unknown and Item {
12
+ describe { This should produce a warning. }
13
+ }
14
+
15
+ -- Invalid from endpoint
16
+ relates Item and Action {
17
+ from Tool {
18
+ relationships { 'invalid' }
19
+ }
20
+ }
21
+
22
+ -- Duplicate describe
23
+ relates Item and Action {
24
+ describe { First describe. }
25
+ describe { Second describe. }
26
+ }
27
+
28
+ -- Duplicate from
29
+ relates Item and Action {
30
+ from Item {
31
+ relationships { 'first' }
32
+ }
33
+ from Item {
34
+ relationships { 'second' }
35
+ }
36
+ }
37
+ }
38
+
@@ -0,0 +1,14 @@
1
+ universe Test {
2
+ series Item {
3
+ describe { Items are physical objects in the game. }
4
+ }
5
+
6
+ series Action {
7
+ describe { Actions are things players can do. }
8
+ }
9
+
10
+ relates Item and Action {
11
+ describe { Items can be consumed by actions and can also be produced by actions. }
12
+ }
13
+ }
14
+
@@ -0,0 +1,16 @@
1
+ universe Test {
2
+ series Item {
3
+ describe { Items are physical objects in the game. }
4
+ }
5
+
6
+ series Action {
7
+ describe { Actions are things players can do. }
8
+ }
9
+
10
+ relates Item and Action {
11
+ from Item { relationships { 'used in' } }
12
+ from Action { relationships { 'produces and consumes' } }
13
+ describe { Items can be consumed by actions and can also be produced by actions. }
14
+ }
15
+ }
16
+
@@ -0,0 +1,21 @@
1
+ universe Test {
2
+ series Item {
3
+ describe { Items are physical objects in the game. }
4
+ }
5
+
6
+ series Action {
7
+ describe { Actions are things players can do. }
8
+ }
9
+
10
+ relates Item and Action {
11
+ from Item {
12
+ relationships { 'produced by', 'consumed by', }
13
+ describe { Items can be produced by and consumed by actions. }
14
+ }
15
+ from Action {
16
+ relationships { 'produces', 'consumes' }
17
+ describe { Actions can produce and consume items. }
18
+ }
19
+ }
20
+ }
21
+