@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,154 @@
1
+ /**
2
+ * @fileoverview MySQL connection validation and management
3
+ */
4
+
5
+ import mysql from 'mysql2/promise';
6
+ import { clearColumnMetadataCache } from './schema.js';
7
+
8
+ /**
9
+ * Validates connection configuration exists and has required fields
10
+ * @param {string} connectionName - Name of the connection
11
+ * @param {Object} connections - Connections config from sprig.config.json
12
+ * @returns {{ valid: boolean, config?: Object, error?: string }} Validation result
13
+ */
14
+ export function validateConnectionConfig(connectionName, connections) {
15
+ if (!connections || typeof connections !== 'object') {
16
+ return {
17
+ valid: false,
18
+ error: `No connections configured in sprig.config.json`,
19
+ };
20
+ }
21
+
22
+ const config = connections[connectionName];
23
+ if (!config) {
24
+ return {
25
+ valid: false,
26
+ error: `Connection '${connectionName}' not found in connections config`,
27
+ };
28
+ }
29
+
30
+ if (config.provider !== 'mysql') {
31
+ return {
32
+ valid: false,
33
+ error: `Connection '${connectionName}' has provider '${config.provider}', expected 'mysql'`,
34
+ };
35
+ }
36
+
37
+ const requiredFields = ['host', 'port', 'database', 'user'];
38
+ const missingFields = requiredFields.filter((field) => !(field in config));
39
+
40
+ if (missingFields.length > 0) {
41
+ return {
42
+ valid: false,
43
+ error: `Connection '${connectionName}' missing required fields: ${missingFields.join(', ')}`,
44
+ };
45
+ }
46
+
47
+ return {
48
+ valid: true,
49
+ config,
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Creates a MySQL connection from config
55
+ * @param {Object} config - MySQL connection config
56
+ * @returns {Promise<mysql.Connection>} MySQL connection
57
+ */
58
+ export async function createConnection(config) {
59
+ const connectionConfig = {
60
+ host: config.host,
61
+ port: config.port,
62
+ database: config.database,
63
+ user: config.user,
64
+ password: config.password,
65
+ // Enable multiple statements if needed
66
+ multipleStatements: false,
67
+ };
68
+
69
+ try {
70
+ const connection = await mysql.createConnection(connectionConfig);
71
+ return connection;
72
+ } catch (error) {
73
+ // Never include password in error messages
74
+ const connectionInfo = `${config.host}:${config.port}/${config.database} (user: ${config.user})`;
75
+ throw new Error(
76
+ `Failed to connect to MySQL at ${connectionInfo}: ${error.code || error.message}`,
77
+ );
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Tests database connection and verifies information_schema access
83
+ * @param {mysql.Connection} connection - MySQL connection
84
+ * @param {Object} config - Connection config (for error messages)
85
+ * @returns {Promise<{ success: boolean, error?: string, databaseName?: string }>} Test result
86
+ */
87
+ export async function testConnection(connection, config) {
88
+ try {
89
+ // Test basic connectivity with SELECT DATABASE()
90
+ const [rows] = await connection.execute('SELECT DATABASE() as db');
91
+ const databaseName = rows[0]?.db;
92
+
93
+ // Verify information_schema access
94
+ const [schemaRows] = await connection.execute(
95
+ 'SELECT 1 FROM information_schema.tables LIMIT 1',
96
+ );
97
+
98
+ if (schemaRows.length === 0) {
99
+ return {
100
+ success: false,
101
+ error: 'Cannot access information_schema (permissions issue)',
102
+ };
103
+ }
104
+
105
+ return {
106
+ success: true,
107
+ databaseName,
108
+ };
109
+ } catch (error) {
110
+ const connectionInfo = `${config.host}:${config.port}/${config.database} (user: ${config.user})`;
111
+ return {
112
+ success: false,
113
+ error: `Connection test failed: ${error.code || error.message}`,
114
+ };
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Connection cache for reuse during validation
120
+ */
121
+ const connectionCache = new Map();
122
+
123
+ /**
124
+ * Gets or creates a cached connection
125
+ * @param {string} connectionName - Connection name
126
+ * @param {Object} config - Connection config
127
+ * @returns {Promise<mysql.Connection>} MySQL connection
128
+ */
129
+ export async function getCachedConnection(connectionName, config) {
130
+ if (connectionCache.has(connectionName)) {
131
+ return connectionCache.get(connectionName);
132
+ }
133
+
134
+ const connection = await createConnection(config);
135
+ connectionCache.set(connectionName, connection);
136
+ return connection;
137
+ }
138
+
139
+ /**
140
+ * Closes all cached connections
141
+ */
142
+ export async function closeAllConnections() {
143
+ for (const [name, connection] of connectionCache.entries()) {
144
+ try {
145
+ await connection.end();
146
+ } catch (error) {
147
+ // Ignore errors during cleanup
148
+ }
149
+ }
150
+ connectionCache.clear();
151
+ // Also clear column metadata cache
152
+ clearColumnMetadataCache();
153
+ }
154
+
@@ -0,0 +1,209 @@
1
+ /**
2
+ * @fileoverview MySQL schema introspection using information_schema
3
+ */
4
+
5
+ /**
6
+ * Column metadata cache for reuse during validation
7
+ * Key format: `${connectionName}|${database}|${table}`
8
+ */
9
+ const columnMetadataCache = new Map();
10
+
11
+ /**
12
+ * Checks if a table exists in the database
13
+ * @param {import('mysql2/promise').Connection} connection - MySQL connection
14
+ * @param {string} schema - Database schema name
15
+ * @param {string} table - Table name
16
+ * @returns {Promise<boolean>} True if table exists
17
+ */
18
+ export async function checkTableExists(connection, schema, table) {
19
+ try {
20
+ // First, try to get the actual database name from the connection
21
+ // This handles case sensitivity issues
22
+ let actualSchema = schema;
23
+ try {
24
+ const [dbRows] = await connection.execute('SELECT DATABASE() as db');
25
+ if (dbRows.length > 0 && dbRows[0].db) {
26
+ actualSchema = dbRows[0].db;
27
+ }
28
+ } catch {
29
+ // If DATABASE() fails, fall back to provided schema
30
+ }
31
+
32
+ const [rows] = await connection.execute(
33
+ 'SELECT 1 FROM information_schema.tables WHERE table_schema = ? AND table_name = ? LIMIT 1',
34
+ [actualSchema, table],
35
+ );
36
+ return rows.length > 0;
37
+ } catch (error) {
38
+ throw new Error(`Failed to check table existence: ${error.message}`);
39
+ }
40
+ }
41
+
42
+ /**
43
+ * @typedef {Object} ColumnMetadata
44
+ * @property {string} column_name
45
+ * @property {string} data_type - MySQL data type (e.g., 'int', 'varchar', 'enum')
46
+ * @property {string} column_type - Full column type (e.g., "int(11)", "enum('a','b')")
47
+ * @property {string} is_nullable - 'YES' or 'NO'
48
+ * @property {number|null} character_maximum_length
49
+ * @property {number|null} numeric_precision
50
+ * @property {number|null} numeric_scale
51
+ */
52
+
53
+ /**
54
+ * Gets column metadata for a table
55
+ * @param {import('mysql2/promise').Connection} connection - MySQL connection
56
+ * @param {string} schema - Database schema name
57
+ * @param {string} table - Table name
58
+ * @param {string} connectionName - Connection name for caching
59
+ * @returns {Promise<Map<string, ColumnMetadata>>} Map of column name to metadata
60
+ */
61
+ export async function getColumnMetadata(connection, schema, table, connectionName) {
62
+ try {
63
+ // First, try to get the actual database name from the connection
64
+ // This handles case sensitivity issues
65
+ let actualSchema = schema;
66
+ try {
67
+ const [dbRows] = await connection.execute('SELECT DATABASE() as db');
68
+ if (dbRows.length > 0 && dbRows[0].db) {
69
+ actualSchema = dbRows[0].db;
70
+ }
71
+ } catch {
72
+ // If DATABASE() fails, fall back to provided schema
73
+ }
74
+
75
+ // Check cache first
76
+ const cacheKey = `${connectionName}|${actualSchema}|${table}`;
77
+ if (columnMetadataCache.has(cacheKey)) {
78
+ return columnMetadataCache.get(cacheKey);
79
+ }
80
+
81
+ const [rows] = await connection.execute(
82
+ `SELECT
83
+ column_name,
84
+ data_type,
85
+ column_type,
86
+ is_nullable,
87
+ character_maximum_length,
88
+ numeric_precision,
89
+ numeric_scale
90
+ FROM information_schema.columns
91
+ WHERE table_schema = ? AND table_name = ?
92
+ ORDER BY ordinal_position`,
93
+ [actualSchema, table],
94
+ );
95
+
96
+ // Create a case-insensitive map by storing both original and lowercase keys
97
+ const columnMap = new Map();
98
+ const lowerToOriginal = new Map(); // Track original case for each lowercase name
99
+
100
+ for (const row of rows) {
101
+ // MySQL returns column names in uppercase (COLUMN_NAME) or lowercase (column_name)
102
+ // depending on version/config, so handle both
103
+ const originalName = row.column_name || row.COLUMN_NAME;
104
+
105
+ // Skip rows with missing column_name
106
+ if (!originalName || typeof originalName !== 'string') {
107
+ continue;
108
+ }
109
+
110
+ const lowerName = originalName.toLowerCase();
111
+
112
+ // Store metadata with original column name
113
+ // Handle both uppercase and lowercase column names from MySQL
114
+ const metadata = {
115
+ column_name: originalName,
116
+ data_type: row.data_type || row.DATA_TYPE,
117
+ column_type: row.column_type || row.COLUMN_TYPE,
118
+ is_nullable: row.is_nullable || row.IS_NULLABLE,
119
+ character_maximum_length: row.character_maximum_length || row.CHARACTER_MAXIMUM_LENGTH,
120
+ numeric_precision: row.numeric_precision || row.NUMERIC_PRECISION,
121
+ numeric_scale: row.numeric_scale || row.NUMERIC_SCALE,
122
+ };
123
+
124
+ // Store with original case as primary key
125
+ columnMap.set(originalName, metadata);
126
+
127
+ // Track lowercase mapping for case-insensitive lookup
128
+ if (!lowerToOriginal.has(lowerName)) {
129
+ lowerToOriginal.set(lowerName, originalName);
130
+ }
131
+ }
132
+
133
+ // Store the lowercase mapping on the map for later use
134
+ columnMap._lowerToOriginal = lowerToOriginal;
135
+
136
+ // Cache the result
137
+ columnMetadataCache.set(cacheKey, columnMap);
138
+
139
+ return columnMap;
140
+ } catch (error) {
141
+ throw new Error(`Failed to get column metadata: ${error.message}`);
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Clears the column metadata cache
147
+ * Should be called after validation is complete
148
+ */
149
+ export function clearColumnMetadataCache() {
150
+ columnMetadataCache.clear();
151
+ }
152
+
153
+ /**
154
+ * Parses ENUM values from column_type string
155
+ * @param {string} columnType - Column type string like "enum('player','playerpaperdoll')"
156
+ * @returns {string[]|null} Array of enum values or null if not an enum
157
+ */
158
+ export function parseEnumValues(columnType) {
159
+ if (!columnType || typeof columnType !== 'string') {
160
+ return null;
161
+ }
162
+
163
+ const enumMatch = columnType.match(/^enum\s*\((.+)\)$/i);
164
+ if (!enumMatch) {
165
+ return null;
166
+ }
167
+
168
+ const valuesStr = enumMatch[1];
169
+ // Parse quoted values: 'value1','value2' or "value1","value2"
170
+ const values = [];
171
+ let current = '';
172
+ let inQuotes = false;
173
+ let quoteChar = null;
174
+
175
+ for (let i = 0; i < valuesStr.length; i++) {
176
+ const char = valuesStr[i];
177
+
178
+ if (!inQuotes && (char === "'" || char === '"')) {
179
+ inQuotes = true;
180
+ quoteChar = char;
181
+ } else if (inQuotes && char === quoteChar) {
182
+ // Check if it's escaped
183
+ if (i + 1 < valuesStr.length && valuesStr[i + 1] === quoteChar) {
184
+ current += char;
185
+ i++; // Skip next quote
186
+ } else {
187
+ inQuotes = false;
188
+ quoteChar = null;
189
+ if (current) {
190
+ values.push(current);
191
+ current = '';
192
+ }
193
+ }
194
+ } else if (inQuotes) {
195
+ current += char;
196
+ } else if (char === ',' || char === ' ') {
197
+ // Skip commas and spaces outside quotes
198
+ continue;
199
+ }
200
+ }
201
+
202
+ // Handle last value if no trailing comma
203
+ if (current) {
204
+ values.push(current);
205
+ }
206
+
207
+ return values.length > 0 ? values : null;
208
+ }
209
+
@@ -0,0 +1,219 @@
1
+ /**
2
+ * @fileoverview Type compatibility checking between Sprig types and MySQL column types
3
+ */
4
+
5
+ import { parseEnumValues } from './schema.js';
6
+
7
+ /**
8
+ * @typedef {Object} TypeCompatibilityResult
9
+ * @property {boolean} compatible - Whether types are compatible
10
+ * @property {'error'|'warning'|'info'} severity - Severity if incompatible
11
+ * @property {string} message - Human-readable message
12
+ */
13
+
14
+ /**
15
+ * Checks if a Sprig integer type is compatible with MySQL column type
16
+ * @param {Object} columnMeta - Column metadata
17
+ * @returns {TypeCompatibilityResult}
18
+ */
19
+ function checkIntegerCompatibility(columnMeta) {
20
+ const integerTypes = ['tinyint', 'smallint', 'mediumint', 'int', 'bigint'];
21
+ const dataType = columnMeta.data_type.toLowerCase();
22
+
23
+ if (integerTypes.includes(dataType)) {
24
+ return { compatible: true, severity: 'info', message: '' };
25
+ }
26
+
27
+ // Check for decimal/numeric with scale 0
28
+ if (dataType === 'decimal' || dataType === 'numeric') {
29
+ if (columnMeta.numeric_scale === 0) {
30
+ return {
31
+ compatible: true,
32
+ severity: 'warning',
33
+ message: `Column is ${dataType} with scale 0 (acceptable for integer)`,
34
+ };
35
+ }
36
+ return {
37
+ compatible: false,
38
+ severity: 'error',
39
+ message: `Column is ${dataType} with scale ${columnMeta.numeric_scale} (expected integer type)`,
40
+ };
41
+ }
42
+
43
+ return {
44
+ compatible: false,
45
+ severity: 'error',
46
+ message: `Column type '${columnMeta.data_type}' is not compatible with integer (expected: tinyint, smallint, mediumint, int, bigint)`,
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Checks if a Sprig string oneOf type is compatible with MySQL column type
52
+ * @param {Object} fieldType - Sprig field type AST (oneOf with string values)
53
+ * @param {Object} columnMeta - Column metadata
54
+ * @returns {TypeCompatibilityResult}
55
+ */
56
+ function checkStringOneOfCompatibility(fieldType, columnMeta) {
57
+ const dataType = columnMeta.data_type.toLowerCase();
58
+ const stringTypes = ['varchar', 'char', 'text', 'tinytext', 'mediumtext', 'longtext'];
59
+
60
+ // Best case: ENUM type
61
+ if (dataType === 'enum') {
62
+ const enumValues = parseEnumValues(columnMeta.column_type);
63
+ if (enumValues) {
64
+ // Check if all field values are in enum
65
+ const missingValues = fieldType.values.filter(
66
+ (val) => !enumValues.includes(String(val)),
67
+ );
68
+ if (missingValues.length === 0) {
69
+ return {
70
+ compatible: true,
71
+ severity: 'info',
72
+ message: `ENUM contains all required values`,
73
+ };
74
+ }
75
+ return {
76
+ compatible: true,
77
+ severity: 'warning',
78
+ message: `ENUM missing values: ${missingValues.join(', ')}`,
79
+ };
80
+ }
81
+ }
82
+
83
+ // Acceptable: string types
84
+ if (stringTypes.includes(dataType)) {
85
+ return {
86
+ compatible: true,
87
+ severity: 'info',
88
+ message: `Column is ${dataType} (acceptable for string one-of)`,
89
+ };
90
+ }
91
+
92
+ return {
93
+ compatible: false,
94
+ severity: 'error',
95
+ message: `Column type '${columnMeta.data_type}' is not compatible with string one-of (expected: enum, varchar, char, or text types)`,
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Checks if a Sprig numeric oneOf type is compatible with MySQL column type
101
+ * @param {Object} fieldType - Sprig field type AST (oneOf with numeric values)
102
+ * @param {Object} columnMeta - Column metadata
103
+ * @returns {TypeCompatibilityResult}
104
+ */
105
+ function checkNumericOneOfCompatibility(fieldType, columnMeta) {
106
+ const dataType = columnMeta.data_type.toLowerCase();
107
+ const integerTypes = ['tinyint', 'smallint', 'mediumint', 'int', 'bigint'];
108
+ const stringTypes = ['varchar', 'char', 'text'];
109
+
110
+ // Best case: integer types
111
+ if (integerTypes.includes(dataType)) {
112
+ return { compatible: true, severity: 'info', message: '' };
113
+ }
114
+
115
+ // Acceptable but warn: ENUM
116
+ if (dataType === 'enum') {
117
+ return {
118
+ compatible: true,
119
+ severity: 'warning',
120
+ message: `Column is ENUM (acceptable but numeric one-of stored as enum)`,
121
+ };
122
+ }
123
+
124
+ // Warn: string types (numeric enum stored as string)
125
+ if (stringTypes.includes(dataType)) {
126
+ return {
127
+ compatible: true,
128
+ severity: 'warning',
129
+ message: `Column is ${dataType} but prose expects numeric one-of (numeric enum stored as string)`,
130
+ };
131
+ }
132
+
133
+ return {
134
+ compatible: false,
135
+ severity: 'error',
136
+ message: `Column type '${columnMeta.data_type}' is not compatible with numeric one-of (expected: integer types)`,
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Checks type compatibility between Sprig field type and MySQL column
142
+ * @param {Object} fieldType - Sprig field type AST
143
+ * @param {Object} columnMeta - MySQL column metadata
144
+ * @returns {TypeCompatibilityResult}
145
+ */
146
+ export function checkTypeCompatibility(fieldType, columnMeta) {
147
+ if (!fieldType || !columnMeta) {
148
+ return {
149
+ compatible: false,
150
+ severity: 'error',
151
+ message: 'Missing field type or column metadata',
152
+ };
153
+ }
154
+
155
+ // Handle optional wrapper
156
+ if (fieldType.kind === 'optional') {
157
+ const innerResult = checkTypeCompatibility(fieldType.of, columnMeta);
158
+ // Keep compatibility but preserve severity
159
+ return innerResult;
160
+ }
161
+
162
+ // Handle primitive types
163
+ if (fieldType.kind === 'primitive') {
164
+ if (fieldType.name === 'integer') {
165
+ return checkIntegerCompatibility(columnMeta);
166
+ }
167
+ if (fieldType.name === 'string') {
168
+ const stringTypes = ['varchar', 'char', 'text', 'tinytext', 'mediumtext', 'longtext'];
169
+ const dataType = columnMeta.data_type.toLowerCase();
170
+ if (stringTypes.includes(dataType)) {
171
+ return { compatible: true, severity: 'info', message: '' };
172
+ }
173
+ return {
174
+ compatible: false,
175
+ severity: 'error',
176
+ message: `Column type '${columnMeta.data_type}' is not compatible with string (expected: varchar, char, or text types)`,
177
+ };
178
+ }
179
+ if (fieldType.name === 'float' || fieldType.name === 'double' || fieldType.name === 'number') {
180
+ const floatTypes = ['float', 'double', 'decimal', 'numeric'];
181
+ const dataType = columnMeta.data_type.toLowerCase();
182
+ if (floatTypes.includes(dataType)) {
183
+ return { compatible: true, severity: 'info', message: '' };
184
+ }
185
+ return {
186
+ compatible: false,
187
+ severity: 'error',
188
+ message: `Column type '${columnMeta.data_type}' is not compatible with ${fieldType.name} (expected: float, double, decimal, or numeric)`,
189
+ };
190
+ }
191
+ // Unknown primitive - skip validation
192
+ return { compatible: true, severity: 'info', message: '' };
193
+ }
194
+
195
+ // Handle oneOf types
196
+ if (fieldType.kind === 'oneOf') {
197
+ if (fieldType.valueType === 'string') {
198
+ return checkStringOneOfCompatibility(fieldType, columnMeta);
199
+ }
200
+ if (fieldType.valueType === 'number') {
201
+ return checkNumericOneOfCompatibility(fieldType, columnMeta);
202
+ }
203
+ // Unknown value type
204
+ return { compatible: true, severity: 'info', message: '' };
205
+ }
206
+
207
+ // Handle nested objects - skip for v1
208
+ if (fieldType.kind === 'object' || fieldType.kind === 'reference') {
209
+ return {
210
+ compatible: true,
211
+ severity: 'warning',
212
+ message: 'Nested object not validated for mysql yet',
213
+ };
214
+ }
215
+
216
+ // Unknown type kind - skip validation
217
+ return { compatible: true, severity: 'info', message: '' };
218
+ }
219
+