@claudetools/tools 0.9.0 → 0.9.2
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/dist/cli.js +9 -1
- package/dist/codedna/__tests__/examples/mongoose-example.d.ts +6 -0
- package/dist/codedna/__tests__/examples/mongoose-example.js +163 -0
- package/dist/codedna/__tests__/fixtures/typeorm-production-test.d.ts +1 -0
- package/dist/codedna/__tests__/fixtures/typeorm-production-test.js +231 -0
- package/dist/codedna/__tests__/fixtures/typeorm-test.d.ts +1 -0
- package/dist/codedna/__tests__/fixtures/typeorm-test.js +124 -0
- package/dist/codedna/__tests__/laravel-output-review.d.ts +1 -0
- package/dist/codedna/__tests__/laravel-output-review.js +249 -0
- package/dist/codedna/__tests__/mongoose-output-test.d.ts +1 -0
- package/dist/codedna/__tests__/mongoose-output-test.js +178 -0
- package/dist/codedna/examples/radix-example.d.ts +2 -0
- package/dist/codedna/examples/radix-example.js +259 -0
- package/dist/codedna/index.d.ts +5 -3
- package/dist/codedna/index.js +6 -3
- package/dist/codedna/kappa-ast.d.ts +143 -5
- package/dist/codedna/kappa-drizzle-generator.js +8 -5
- package/dist/codedna/kappa-gofiber-generator.d.ts +65 -0
- package/dist/codedna/kappa-gofiber-generator.js +587 -0
- package/dist/codedna/kappa-laravel-generator.d.ts +68 -0
- package/dist/codedna/kappa-laravel-generator.js +741 -0
- package/dist/codedna/kappa-lexer.d.ts +44 -0
- package/dist/codedna/kappa-lexer.js +124 -0
- package/dist/codedna/kappa-mantine-generator.d.ts +65 -0
- package/dist/codedna/kappa-mantine-generator.js +518 -0
- package/dist/codedna/kappa-mongoose-generator.d.ts +44 -0
- package/dist/codedna/kappa-mongoose-generator.js +442 -0
- package/dist/codedna/kappa-parser.d.ts +43 -1
- package/dist/codedna/kappa-parser.js +601 -0
- package/dist/codedna/kappa-radix-generator.d.ts +61 -0
- package/dist/codedna/kappa-radix-generator.js +566 -0
- package/dist/codedna/kappa-typeorm-generator.d.ts +59 -0
- package/dist/codedna/kappa-typeorm-generator.js +723 -0
- package/dist/codedna/kappa-vitest-generator.d.ts +85 -0
- package/dist/codedna/kappa-vitest-generator.js +739 -0
- package/dist/codedna/parser.js +26 -1
- package/dist/codegen/cloud-client.d.ts +160 -0
- package/dist/codegen/cloud-client.js +195 -0
- package/dist/codegen/codegen-tool.d.ts +35 -0
- package/dist/codegen/codegen-tool.js +312 -0
- package/dist/codegen/field-inference.d.ts +24 -0
- package/dist/codegen/field-inference.js +101 -0
- package/dist/codegen/form-parser.d.ts +13 -0
- package/dist/codegen/form-parser.js +186 -0
- package/dist/codegen/index.d.ts +2 -0
- package/dist/codegen/index.js +4 -0
- package/dist/codegen/natural-parser.d.ts +50 -0
- package/dist/codegen/natural-parser.js +769 -0
- package/dist/handlers/codedna-handlers.d.ts +1 -1
- package/dist/handlers/codegen-handlers.d.ts +20 -0
- package/dist/handlers/codegen-handlers.js +60 -0
- package/dist/handlers/kappa-handlers.d.ts +97 -0
- package/dist/handlers/kappa-handlers.js +408 -0
- package/dist/handlers/tool-handlers.js +124 -221
- package/dist/helpers/api-client.js +48 -3
- package/dist/helpers/compact-formatter.d.ts +9 -2
- package/dist/helpers/compact-formatter.js +26 -2
- package/dist/helpers/config.d.ts +7 -2
- package/dist/helpers/config.js +25 -10
- package/dist/helpers/session-validation.d.ts +1 -1
- package/dist/helpers/session-validation.js +2 -4
- package/dist/helpers/tasks.d.ts +21 -0
- package/dist/helpers/tasks.js +52 -0
- package/dist/helpers/workers.d.ts +1 -1
- package/dist/helpers/workers.js +19 -19
- package/dist/setup.d.ts +1 -0
- package/dist/setup.js +228 -3
- package/dist/templates/claude-md.d.ts +1 -1
- package/dist/templates/claude-md.js +37 -152
- package/dist/templates/orchestrator-prompt.d.ts +2 -2
- package/dist/templates/orchestrator-prompt.js +31 -38
- package/dist/templates/self-critique.d.ts +50 -0
- package/dist/templates/self-critique.js +209 -0
- package/dist/templates/worker-prompt.d.ts +3 -3
- package/dist/templates/worker-prompt.js +18 -18
- package/dist/tools.js +77 -413
- package/docs/codedna/generator-testing-summary.md +205 -0
- package/docs/codedna/radix-ui-generator.md +478 -0
- package/docs/kappa-gofiber-generator.md +274 -0
- package/docs/kappa-laravel-fixes.md +172 -0
- package/docs/kappa-mongoose-generator.md +322 -0
- package/docs/kappa-vitest-generator.md +337 -0
- package/package.json +1 -1
- package/dist/context/deduplication.test.d.ts +0 -6
- package/dist/context/deduplication.test.js +0 -84
|
@@ -0,0 +1,769 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// Natural Language → Kappa AST Parser
|
|
3
|
+
// =============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Converts natural language entity descriptions into Kappa AST.
|
|
6
|
+
// No DSL syntax required - just describe what you want.
|
|
7
|
+
//
|
|
8
|
+
// Examples:
|
|
9
|
+
// "User with email, password, role (admin/user)"
|
|
10
|
+
// "Post with title, content, published, belongs to User as author"
|
|
11
|
+
// "Comment with body, approved, belongs to User, belongs to Post"
|
|
12
|
+
//
|
|
13
|
+
import { inferFieldType, parseInlineEnum, parseExplicitType } from './field-inference.js';
|
|
14
|
+
const PRIMITIVE_TYPES = [
|
|
15
|
+
'string', 'int', 'float', 'bool', 'email', 'url', 'uuid',
|
|
16
|
+
'phone', 'slug', 'markdown', 'json', 'timestamp', 'date', 'time', 'duration'
|
|
17
|
+
];
|
|
18
|
+
/**
|
|
19
|
+
* Parse natural language description into Kappa entities
|
|
20
|
+
*/
|
|
21
|
+
export function parseNaturalLanguage(description) {
|
|
22
|
+
const errors = [];
|
|
23
|
+
const entities = [];
|
|
24
|
+
// Split by entity boundaries (look for "Entity with" pattern)
|
|
25
|
+
const entityBlocks = splitIntoEntities(description);
|
|
26
|
+
for (const block of entityBlocks) {
|
|
27
|
+
try {
|
|
28
|
+
const entity = parseEntityBlock(block);
|
|
29
|
+
entities.push(entity);
|
|
30
|
+
}
|
|
31
|
+
catch (e) {
|
|
32
|
+
errors.push(e instanceof Error ? e.message : String(e));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
success: errors.length === 0 && entities.length > 0,
|
|
37
|
+
entities,
|
|
38
|
+
components: [], // TODO: Will be populated when component parser is added
|
|
39
|
+
errors,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Split description into entity blocks
|
|
44
|
+
*/
|
|
45
|
+
function splitIntoEntities(description) {
|
|
46
|
+
// Pattern: EntityName with/has ...
|
|
47
|
+
const pattern = /([A-Z][a-zA-Z]*)\s+(?:with|has|having)\s+/gi;
|
|
48
|
+
const matches = [...description.matchAll(pattern)];
|
|
49
|
+
if (matches.length === 0) {
|
|
50
|
+
// Try simpler pattern: just EntityName followed by fields
|
|
51
|
+
const simpleMatch = description.match(/^([A-Z][a-zA-Z]*)\s*[:\-]?\s*(.+)$/);
|
|
52
|
+
if (simpleMatch) {
|
|
53
|
+
return [description];
|
|
54
|
+
}
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
const blocks = [];
|
|
58
|
+
for (let i = 0; i < matches.length; i++) {
|
|
59
|
+
const start = matches[i].index;
|
|
60
|
+
const end = i < matches.length - 1 ? matches[i + 1].index : description.length;
|
|
61
|
+
blocks.push(description.slice(start, end).trim());
|
|
62
|
+
}
|
|
63
|
+
return blocks;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Parse a single entity block
|
|
67
|
+
*/
|
|
68
|
+
function parseEntityBlock(block) {
|
|
69
|
+
// Extract entity name
|
|
70
|
+
const nameMatch = block.match(/^([A-Z][a-zA-Z]*)/);
|
|
71
|
+
if (!nameMatch) {
|
|
72
|
+
throw new Error(`Could not find entity name in: ${block}`);
|
|
73
|
+
}
|
|
74
|
+
const entityName = nameMatch[1];
|
|
75
|
+
// Extract fields part (after "with" or "has" or ":")
|
|
76
|
+
const fieldsMatch = block.match(/(?:with|has|having|:)\s*(.+)$/i);
|
|
77
|
+
if (!fieldsMatch) {
|
|
78
|
+
throw new Error(`Could not find fields in: ${block}`);
|
|
79
|
+
}
|
|
80
|
+
const fieldsStr = fieldsMatch[1];
|
|
81
|
+
const { fields, relationships } = parseFieldsList(fieldsStr, entityName);
|
|
82
|
+
// Auto-add id if not present
|
|
83
|
+
const hasId = fields.some(f => f.name === 'id');
|
|
84
|
+
if (!hasId) {
|
|
85
|
+
fields.unshift(createField('id', 'uuid', ['primary', 'auto']));
|
|
86
|
+
}
|
|
87
|
+
// Auto-add timestamps if entity has content fields
|
|
88
|
+
const hasContent = fields.some(f => ['content', 'body', 'title', 'headline'].includes(f.name));
|
|
89
|
+
const hasCreatedAt = fields.some(f => f.name === 'createdAt');
|
|
90
|
+
if (hasContent && !hasCreatedAt) {
|
|
91
|
+
fields.push(createField('createdAt', 'timestamp', ['auto']));
|
|
92
|
+
fields.push(createField('updatedAt', 'timestamp', ['auto']));
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
kind: 'EntityBlock',
|
|
96
|
+
name: entityName,
|
|
97
|
+
fields,
|
|
98
|
+
relationships,
|
|
99
|
+
capabilities: [],
|
|
100
|
+
lifecycleHooks: [],
|
|
101
|
+
indexes: [],
|
|
102
|
+
loc: { startLine: 1, startColumn: 1, endLine: 1, endColumn: 1, startOffset: 0, endOffset: 0 },
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Parse comma-separated fields list
|
|
107
|
+
*/
|
|
108
|
+
function parseFieldsList(fieldsStr, entityName) {
|
|
109
|
+
const fields = [];
|
|
110
|
+
const relationships = [];
|
|
111
|
+
// Split by comma, but not inside parentheses
|
|
112
|
+
const parts = smartSplit(fieldsStr, ',');
|
|
113
|
+
for (const part of parts) {
|
|
114
|
+
const trimmed = part.trim();
|
|
115
|
+
if (!trimmed)
|
|
116
|
+
continue;
|
|
117
|
+
// Check for relationship
|
|
118
|
+
const rel = parseRelationship(trimmed);
|
|
119
|
+
if (rel) {
|
|
120
|
+
relationships.push(rel);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
// Check for enum notation: "role (admin, user, guest)"
|
|
124
|
+
const enumMatch = parseInlineEnum(trimmed);
|
|
125
|
+
if (enumMatch) {
|
|
126
|
+
fields.push(createEnumField(enumMatch.name, enumMatch.values));
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
// Check for explicit type: "age:int" or "name:string:optional"
|
|
130
|
+
const explicit = parseExplicitType(trimmed);
|
|
131
|
+
if (explicit) {
|
|
132
|
+
fields.push(createFieldFromExplicit(explicit.name, explicit.type));
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
// Infer from field name - strip any trailing type-like suffixes that weren't parsed
|
|
136
|
+
const cleanedField = trimmed.replace(/[.,;!?]+$/, '').trim();
|
|
137
|
+
// If there's a colon that wasn't parsed as explicit type, take only the name part
|
|
138
|
+
const fieldName = cleanedField.includes(':')
|
|
139
|
+
? cleanedField.split(':')[0].replace(/[^a-zA-Z0-9]/g, '')
|
|
140
|
+
: cleanedField.replace(/[^a-zA-Z0-9]/g, '');
|
|
141
|
+
if (fieldName) {
|
|
142
|
+
const inferred = inferFieldType(fieldName);
|
|
143
|
+
fields.push(createField(fieldName, inferred.type, inferred.modifiers));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { fields, relationships };
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Parse relationship notation
|
|
150
|
+
* Examples: "belongs to User", "has many Posts", "belongs to User as author"
|
|
151
|
+
*/
|
|
152
|
+
function parseRelationship(text) {
|
|
153
|
+
// belongs_to: "belongs to Entity (as alias)"
|
|
154
|
+
const belongsMatch = text.match(/^belongs\s+to\s+([A-Z][a-zA-Z]*)(?:\s+as\s+(\w+))?$/i);
|
|
155
|
+
if (belongsMatch) {
|
|
156
|
+
return {
|
|
157
|
+
kind: 'EntityRelationship',
|
|
158
|
+
type: 'belongs_to',
|
|
159
|
+
entity: belongsMatch[1],
|
|
160
|
+
as: belongsMatch[2],
|
|
161
|
+
loc: { startLine: 1, startColumn: 1, endLine: 1, endColumn: 1, startOffset: 0, endOffset: 0 },
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
// has_many: "has many Entities"
|
|
165
|
+
const hasManyMatch = text.match(/^has\s+many\s+([A-Z][a-zA-Z]*)$/i);
|
|
166
|
+
if (hasManyMatch) {
|
|
167
|
+
return {
|
|
168
|
+
kind: 'EntityRelationship',
|
|
169
|
+
type: 'has_many',
|
|
170
|
+
entity: hasManyMatch[1],
|
|
171
|
+
loc: { startLine: 1, startColumn: 1, endLine: 1, endColumn: 1, startOffset: 0, endOffset: 0 },
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
// has_one: "has one Profile"
|
|
175
|
+
const hasOneMatch = text.match(/^has\s+one\s+([A-Z][a-zA-Z]*)$/i);
|
|
176
|
+
if (hasOneMatch) {
|
|
177
|
+
return {
|
|
178
|
+
kind: 'EntityRelationship',
|
|
179
|
+
type: 'has_one',
|
|
180
|
+
entity: hasOneMatch[1],
|
|
181
|
+
loc: { startLine: 1, startColumn: 1, endLine: 1, endColumn: 1, startOffset: 0, endOffset: 0 },
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Create a field with primitive type
|
|
188
|
+
*/
|
|
189
|
+
function createField(name, typeName, modifiers) {
|
|
190
|
+
// Normalize type name
|
|
191
|
+
const normalizedType = normalizeTypeName(typeName);
|
|
192
|
+
const fieldType = {
|
|
193
|
+
kind: 'primitive',
|
|
194
|
+
type: normalizedType,
|
|
195
|
+
};
|
|
196
|
+
return {
|
|
197
|
+
kind: 'EntityField',
|
|
198
|
+
name,
|
|
199
|
+
type: fieldType,
|
|
200
|
+
modifiers: modifiers,
|
|
201
|
+
loc: { startLine: 1, startColumn: 1, endLine: 1, endColumn: 1, startOffset: 0, endOffset: 0 },
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Create an enum field
|
|
206
|
+
*/
|
|
207
|
+
function createEnumField(name, values) {
|
|
208
|
+
return {
|
|
209
|
+
kind: 'EntityField',
|
|
210
|
+
name,
|
|
211
|
+
type: {
|
|
212
|
+
kind: 'enum',
|
|
213
|
+
values,
|
|
214
|
+
},
|
|
215
|
+
modifiers: [],
|
|
216
|
+
loc: { startLine: 1, startColumn: 1, endLine: 1, endColumn: 1, startOffset: 0, endOffset: 0 },
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Create field from explicit type specification
|
|
221
|
+
*/
|
|
222
|
+
function createFieldFromExplicit(name, typeInfo) {
|
|
223
|
+
const { type, modifiers } = typeInfo;
|
|
224
|
+
// Check if it's an enum
|
|
225
|
+
if (type.startsWith('enum:')) {
|
|
226
|
+
const values = type.slice(5).split(',').map(v => v.trim());
|
|
227
|
+
return createEnumField(name, values);
|
|
228
|
+
}
|
|
229
|
+
return createField(name, type, modifiers);
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Normalize type name to Kappa primitive
|
|
233
|
+
*/
|
|
234
|
+
function normalizeTypeName(type) {
|
|
235
|
+
const normalized = type.toLowerCase();
|
|
236
|
+
const typeMap = {
|
|
237
|
+
'integer': 'int',
|
|
238
|
+
'number': 'int',
|
|
239
|
+
'decimal': 'float',
|
|
240
|
+
'double': 'float',
|
|
241
|
+
'boolean': 'bool',
|
|
242
|
+
'datetime': 'timestamp',
|
|
243
|
+
'text': 'markdown',
|
|
244
|
+
'varchar': 'string',
|
|
245
|
+
'char': 'string',
|
|
246
|
+
};
|
|
247
|
+
return typeMap[normalized] || normalized;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Smart split that respects parentheses
|
|
251
|
+
*/
|
|
252
|
+
function smartSplit(str, delimiter) {
|
|
253
|
+
const result = [];
|
|
254
|
+
let current = '';
|
|
255
|
+
let depth = 0;
|
|
256
|
+
for (const char of str) {
|
|
257
|
+
if (char === '(')
|
|
258
|
+
depth++;
|
|
259
|
+
if (char === ')')
|
|
260
|
+
depth--;
|
|
261
|
+
if (char === delimiter && depth === 0) {
|
|
262
|
+
result.push(current);
|
|
263
|
+
current = '';
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
current += char;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (current)
|
|
270
|
+
result.push(current);
|
|
271
|
+
return result;
|
|
272
|
+
}
|
|
273
|
+
// =============================================================================
|
|
274
|
+
// Page Parsing (Natural Language → PageBlock)
|
|
275
|
+
// =============================================================================
|
|
276
|
+
/**
|
|
277
|
+
* Parse natural language page descriptions into PageBlock AST
|
|
278
|
+
*
|
|
279
|
+
* Examples:
|
|
280
|
+
* "Dashboard page at /dashboard"
|
|
281
|
+
* "User profile at /users/:id with auth guard"
|
|
282
|
+
* "Login page at /login for guests only"
|
|
283
|
+
*/
|
|
284
|
+
export function parsePageDescription(description) {
|
|
285
|
+
const pages = [];
|
|
286
|
+
// Try to parse as a single page description first
|
|
287
|
+
const page = parsePageBlock(description.trim());
|
|
288
|
+
if (page) {
|
|
289
|
+
pages.push(page);
|
|
290
|
+
}
|
|
291
|
+
return pages;
|
|
292
|
+
}
|
|
293
|
+
function parsePageBlock(block) {
|
|
294
|
+
// Extract page name and route
|
|
295
|
+
// Pattern: "PageName page at /route"
|
|
296
|
+
const routeMatch = block.match(/^([A-Z][a-zA-Z]*)\s+page\s+at\s+(\/[\w/:]*)/i);
|
|
297
|
+
if (!routeMatch) {
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
const pageName = routeMatch[1];
|
|
301
|
+
const route = routeMatch[2];
|
|
302
|
+
// Extract guard (authenticated, guest_only, admin, etc.)
|
|
303
|
+
let guard;
|
|
304
|
+
if (/with\s+auth(?:\s+guard)?|authenticated|requires?\s+auth/i.test(block)) {
|
|
305
|
+
guard = 'authenticated';
|
|
306
|
+
}
|
|
307
|
+
else if (/for\s+guests?(?:\s+only)?|guest[_\s]only/i.test(block)) {
|
|
308
|
+
guard = 'guest_only';
|
|
309
|
+
}
|
|
310
|
+
else if (/admin\s+only|requires?\s+admin/i.test(block)) {
|
|
311
|
+
guard = 'admin';
|
|
312
|
+
}
|
|
313
|
+
// Extract title
|
|
314
|
+
const titleMatch = block.match(/title[:\s]+["']([^"']+)["']/i);
|
|
315
|
+
const title = titleMatch ? titleMatch[1] : pageName;
|
|
316
|
+
// Extract loaders from "loads Entity data" or "loads Entity"
|
|
317
|
+
const loaders = [];
|
|
318
|
+
const loadsPattern = /loads?\s+([A-Z][a-zA-Z]*)\s*(?:data)?/gi;
|
|
319
|
+
const loadsMatches = [...block.matchAll(loadsPattern)];
|
|
320
|
+
for (const match of loadsMatches) {
|
|
321
|
+
const entityName = match[1];
|
|
322
|
+
const loaderName = entityName.toLowerCase();
|
|
323
|
+
// Determine source based on route parameters
|
|
324
|
+
let source;
|
|
325
|
+
if (route.includes(':id')) {
|
|
326
|
+
source = `api.get_${loaderName}($params.id)`;
|
|
327
|
+
}
|
|
328
|
+
else if (route.includes(`:${loaderName}Id`)) {
|
|
329
|
+
source = `api.get_${loaderName}($params.${loaderName}Id)`;
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
// Default to list
|
|
333
|
+
source = `api.list_${loaderName}s`;
|
|
334
|
+
}
|
|
335
|
+
loaders.push({
|
|
336
|
+
kind: 'PageLoader',
|
|
337
|
+
name: loaderName,
|
|
338
|
+
source,
|
|
339
|
+
loc: { startLine: 1, startColumn: 1, endLine: 1, endColumn: 1, startOffset: 0, endOffset: 0 },
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
const actions = [];
|
|
343
|
+
return {
|
|
344
|
+
kind: 'PageBlock',
|
|
345
|
+
name: pageName,
|
|
346
|
+
route,
|
|
347
|
+
guard,
|
|
348
|
+
loaders,
|
|
349
|
+
actions,
|
|
350
|
+
title,
|
|
351
|
+
loc: { startLine: 1, startColumn: 1, endLine: 1, endColumn: 1, startOffset: 0, endOffset: 0 },
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* UI component keywords for detection
|
|
356
|
+
*/
|
|
357
|
+
const UI_COMPONENT_KEYWORDS = [
|
|
358
|
+
'button', 'input', 'card', 'modal', 'dialog', 'form', 'table',
|
|
359
|
+
'dropdown', 'select', 'checkbox', 'radio', 'switch', 'slider',
|
|
360
|
+
'tabs', 'accordion', 'tooltip', 'popover', 'menu', 'navbar',
|
|
361
|
+
'sidebar', 'footer', 'header', 'badge', 'chip', 'avatar',
|
|
362
|
+
'alert', 'notification', 'toast', 'skeleton', 'spinner', 'loader',
|
|
363
|
+
'progress', 'pagination', 'breadcrumb', 'stepper', 'calendar',
|
|
364
|
+
'datepicker', 'timepicker', 'textarea', 'search', 'combobox'
|
|
365
|
+
];
|
|
366
|
+
/**
|
|
367
|
+
* Detect if a name represents a UI component
|
|
368
|
+
*/
|
|
369
|
+
function isUIComponent(name) {
|
|
370
|
+
const lowerName = name.toLowerCase();
|
|
371
|
+
return UI_COMPONENT_KEYWORDS.some(keyword => lowerName.includes(keyword));
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Parse component description into ComponentBlock
|
|
375
|
+
*
|
|
376
|
+
* Examples:
|
|
377
|
+
* "Button with label, onClick, variant (primary/secondary)"
|
|
378
|
+
* "Card with title, content, footer, variant (default/outlined/elevated)"
|
|
379
|
+
* "Input with value, onChange, placeholder, disabled"
|
|
380
|
+
*/
|
|
381
|
+
export function parseComponentDescription(description) {
|
|
382
|
+
const errors = [];
|
|
383
|
+
const components = [];
|
|
384
|
+
// Split by component boundaries
|
|
385
|
+
const componentBlocks = splitIntoComponents(description);
|
|
386
|
+
for (const block of componentBlocks) {
|
|
387
|
+
try {
|
|
388
|
+
const component = parseComponentBlock(block);
|
|
389
|
+
components.push(component);
|
|
390
|
+
}
|
|
391
|
+
catch (e) {
|
|
392
|
+
errors.push(e instanceof Error ? e.message : String(e));
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return {
|
|
396
|
+
success: errors.length === 0 && components.length > 0,
|
|
397
|
+
components,
|
|
398
|
+
errors,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Split description into component blocks
|
|
403
|
+
*/
|
|
404
|
+
function splitIntoComponents(description) {
|
|
405
|
+
// Pattern: ComponentName with/has ...
|
|
406
|
+
const pattern = /([A-Z][a-zA-Z]*)\s+(?:with|has|having)\s+/gi;
|
|
407
|
+
const matches = [...description.matchAll(pattern)];
|
|
408
|
+
if (matches.length === 0) {
|
|
409
|
+
// Try simpler pattern
|
|
410
|
+
const simpleMatch = description.match(/^([A-Z][a-zA-Z]*)\s*[:\-]?\s*(.+)$/);
|
|
411
|
+
if (simpleMatch) {
|
|
412
|
+
return [description];
|
|
413
|
+
}
|
|
414
|
+
return [];
|
|
415
|
+
}
|
|
416
|
+
const blocks = [];
|
|
417
|
+
for (let i = 0; i < matches.length; i++) {
|
|
418
|
+
const start = matches[i].index;
|
|
419
|
+
const end = i < matches.length - 1 ? matches[i + 1].index : description.length;
|
|
420
|
+
blocks.push(description.slice(start, end).trim());
|
|
421
|
+
}
|
|
422
|
+
return blocks;
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Parse a single component block
|
|
426
|
+
*/
|
|
427
|
+
function parseComponentBlock(block) {
|
|
428
|
+
// Extract component name
|
|
429
|
+
const nameMatch = block.match(/^([A-Z][a-zA-Z]*)/);
|
|
430
|
+
if (!nameMatch) {
|
|
431
|
+
throw new Error(`Could not find component name in: ${block}`);
|
|
432
|
+
}
|
|
433
|
+
const componentName = nameMatch[1];
|
|
434
|
+
// Verify it's a UI component
|
|
435
|
+
if (!isUIComponent(componentName)) {
|
|
436
|
+
throw new Error(`"${componentName}" does not appear to be a UI component. Use entity parser for data models.`);
|
|
437
|
+
}
|
|
438
|
+
// Extract props part (after "with" or "has" or ":")
|
|
439
|
+
const propsMatch = block.match(/(?:with|has|having|:)\s*(.+)$/i);
|
|
440
|
+
if (!propsMatch) {
|
|
441
|
+
throw new Error(`Could not find props in: ${block}`);
|
|
442
|
+
}
|
|
443
|
+
const propsStr = propsMatch[1];
|
|
444
|
+
const { props, variants } = parseComponentPropsList(propsStr);
|
|
445
|
+
return {
|
|
446
|
+
kind: 'ComponentBlock',
|
|
447
|
+
name: componentName,
|
|
448
|
+
props,
|
|
449
|
+
variants,
|
|
450
|
+
loc: { startLine: 1, startColumn: 1, endLine: 1, endColumn: 1, startOffset: 0, endOffset: 0 },
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Parse comma-separated props list
|
|
455
|
+
*/
|
|
456
|
+
function parseComponentPropsList(propsStr) {
|
|
457
|
+
const props = [];
|
|
458
|
+
const variants = [];
|
|
459
|
+
// Split by comma, but not inside parentheses
|
|
460
|
+
const parts = smartSplit(propsStr, ',');
|
|
461
|
+
for (const part of parts) {
|
|
462
|
+
const trimmed = part.trim();
|
|
463
|
+
if (!trimmed)
|
|
464
|
+
continue;
|
|
465
|
+
// Check for variant notation: "variant (primary/secondary/tertiary)"
|
|
466
|
+
const variantMatch = trimmed.match(/^variant\s*\(([^)]+)\)$/i);
|
|
467
|
+
if (variantMatch) {
|
|
468
|
+
const variantNames = variantMatch[1]
|
|
469
|
+
.split('/')
|
|
470
|
+
.map(v => v.trim())
|
|
471
|
+
.filter(v => v.length > 0);
|
|
472
|
+
for (const variantName of variantNames) {
|
|
473
|
+
variants.push({
|
|
474
|
+
kind: 'ComponentVariant',
|
|
475
|
+
name: variantName,
|
|
476
|
+
modifications: [],
|
|
477
|
+
loc: { startLine: 1, startColumn: 1, endLine: 1, endColumn: 1, startOffset: 0, endOffset: 0 },
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
// Check for enum notation: "size (sm/md/lg)"
|
|
483
|
+
const enumMatch = parseInlineEnum(trimmed);
|
|
484
|
+
if (enumMatch) {
|
|
485
|
+
// For components, enums become union type props
|
|
486
|
+
const unionType = enumMatch.values.map(v => `'${v}'`).join(' | ');
|
|
487
|
+
props.push(createComponentProp(enumMatch.name, unionType, false));
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
// Check for explicit type: "onClick:function" or "disabled:boolean"
|
|
491
|
+
const explicit = parseExplicitType(trimmed);
|
|
492
|
+
if (explicit) {
|
|
493
|
+
const optional = explicit.type.modifiers.includes('optional');
|
|
494
|
+
const propType = inferComponentPropType(explicit.name, explicit.type.type);
|
|
495
|
+
props.push(createComponentProp(explicit.name, propType, optional));
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
// Infer from prop name
|
|
499
|
+
const propName = trimmed.replace(/[^a-zA-Z0-9]/g, '');
|
|
500
|
+
if (propName) {
|
|
501
|
+
const propType = inferComponentPropType(propName);
|
|
502
|
+
const optional = isOptionalProp(propName);
|
|
503
|
+
props.push(createComponentProp(propName, propType, optional));
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return { props, variants };
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Create a component prop
|
|
510
|
+
*/
|
|
511
|
+
function createComponentProp(name, type, optional) {
|
|
512
|
+
return {
|
|
513
|
+
kind: 'ComponentProp',
|
|
514
|
+
name,
|
|
515
|
+
type,
|
|
516
|
+
optional,
|
|
517
|
+
loc: { startLine: 1, startColumn: 1, endLine: 1, endColumn: 1, startOffset: 0, endOffset: 0 },
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Infer component prop type from name
|
|
522
|
+
*/
|
|
523
|
+
function inferComponentPropType(name, explicitType) {
|
|
524
|
+
if (explicitType) {
|
|
525
|
+
const typeMap = {
|
|
526
|
+
'string': 'string',
|
|
527
|
+
'int': 'number',
|
|
528
|
+
'integer': 'number',
|
|
529
|
+
'float': 'number',
|
|
530
|
+
'number': 'number',
|
|
531
|
+
'bool': 'boolean',
|
|
532
|
+
'boolean': 'boolean',
|
|
533
|
+
'function': '() => void',
|
|
534
|
+
'callback': '() => void',
|
|
535
|
+
};
|
|
536
|
+
return typeMap[explicitType.toLowerCase()] || explicitType;
|
|
537
|
+
}
|
|
538
|
+
const lowerName = name.toLowerCase();
|
|
539
|
+
// Event handlers
|
|
540
|
+
if (lowerName.startsWith('on')) {
|
|
541
|
+
return '() => void';
|
|
542
|
+
}
|
|
543
|
+
// Boolean flags
|
|
544
|
+
if (lowerName.startsWith('is') || lowerName.startsWith('has') || lowerName.startsWith('show') ||
|
|
545
|
+
['disabled', 'readonly', 'required', 'loading', 'active', 'open', 'checked'].includes(lowerName)) {
|
|
546
|
+
return 'boolean';
|
|
547
|
+
}
|
|
548
|
+
// Children/content
|
|
549
|
+
if (['children', 'content', 'body'].includes(lowerName)) {
|
|
550
|
+
return 'React.ReactNode';
|
|
551
|
+
}
|
|
552
|
+
// Style/className
|
|
553
|
+
if (['classname', 'style'].includes(lowerName)) {
|
|
554
|
+
return lowerName === 'classname' ? 'string' : 'React.CSSProperties';
|
|
555
|
+
}
|
|
556
|
+
// Numeric values
|
|
557
|
+
if (['value', 'count', 'max', 'min', 'step', 'width', 'height'].includes(lowerName)) {
|
|
558
|
+
return 'number';
|
|
559
|
+
}
|
|
560
|
+
// Default to string
|
|
561
|
+
return 'string';
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Determine if prop is optional based on common patterns
|
|
565
|
+
*/
|
|
566
|
+
function isOptionalProp(name) {
|
|
567
|
+
const lowerName = name.toLowerCase();
|
|
568
|
+
// Required props
|
|
569
|
+
const requiredProps = ['children', 'value', 'onchange', 'onclick', 'label', 'title'];
|
|
570
|
+
if (requiredProps.includes(lowerName)) {
|
|
571
|
+
return false;
|
|
572
|
+
}
|
|
573
|
+
// Optional props
|
|
574
|
+
const optionalProps = ['placeholder', 'disabled', 'readonly', 'className', 'style',
|
|
575
|
+
'variant', 'size', 'icon', 'tooltip', 'ariaLabel'];
|
|
576
|
+
if (optionalProps.includes(lowerName)) {
|
|
577
|
+
return true;
|
|
578
|
+
}
|
|
579
|
+
// Event handlers are usually optional except onChange/onClick
|
|
580
|
+
if (lowerName.startsWith('on') && !['onchange', 'onclick'].includes(lowerName)) {
|
|
581
|
+
return true;
|
|
582
|
+
}
|
|
583
|
+
// Default to optional
|
|
584
|
+
return true;
|
|
585
|
+
}
|
|
586
|
+
// =============================================================================
|
|
587
|
+
// Form Parsing (Natural Language → FormBlock)
|
|
588
|
+
// =============================================================================
|
|
589
|
+
/**
|
|
590
|
+
* Parse natural language form description into FormBlock
|
|
591
|
+
*
|
|
592
|
+
* Examples:
|
|
593
|
+
* "Login form with email, password, submit to /auth/login"
|
|
594
|
+
* "Create Project form with name, description, submit to /api/projects"
|
|
595
|
+
* "Contact form with name, email, message:textarea, submit to /contact"
|
|
596
|
+
*/
|
|
597
|
+
export function parseFormDescription(description) {
|
|
598
|
+
// Check if this is a form description
|
|
599
|
+
const formPattern = /^([A-Z][a-zA-Z\s]*?)\s+form\s+with\s+(.+?)(?:,\s*submit\s+to\s+([^\s,]+))?\s*$/i;
|
|
600
|
+
const match = description.match(formPattern);
|
|
601
|
+
if (!match) {
|
|
602
|
+
return null;
|
|
603
|
+
}
|
|
604
|
+
const [, formName, fieldsStr, submitEndpoint] = match;
|
|
605
|
+
// Parse fields
|
|
606
|
+
const fieldParts = smartSplit(fieldsStr, ',').map(s => s.trim()).filter(Boolean);
|
|
607
|
+
const fields = [];
|
|
608
|
+
for (const fieldPart of fieldParts) {
|
|
609
|
+
// Skip if this is the submit directive
|
|
610
|
+
if (fieldPart.toLowerCase().startsWith('submit to')) {
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
const field = parseFormField(fieldPart);
|
|
614
|
+
if (field) {
|
|
615
|
+
fields.push(field);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
// Create submit action
|
|
619
|
+
const submit = {
|
|
620
|
+
kind: 'FormSubmit',
|
|
621
|
+
action: submitEndpoint || '/submit',
|
|
622
|
+
button: 'Submit',
|
|
623
|
+
onSuccess: 'redirect',
|
|
624
|
+
onError: 'show_error',
|
|
625
|
+
loc: createSourceLocation(),
|
|
626
|
+
};
|
|
627
|
+
// Create simple single-column layout
|
|
628
|
+
const layout = fields.map(field => ({
|
|
629
|
+
kind: 'FormLayoutRow',
|
|
630
|
+
fields: [field.name],
|
|
631
|
+
columns: 1,
|
|
632
|
+
loc: createSourceLocation(),
|
|
633
|
+
}));
|
|
634
|
+
return {
|
|
635
|
+
kind: 'FormBlock',
|
|
636
|
+
name: formName.trim().replace(/\s+/g, ''),
|
|
637
|
+
fields,
|
|
638
|
+
submit,
|
|
639
|
+
layout,
|
|
640
|
+
loc: createSourceLocation(),
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Parse a single form field from natural language
|
|
645
|
+
* Examples:
|
|
646
|
+
* "email" -> email field with email validation
|
|
647
|
+
* "password" -> password field
|
|
648
|
+
* "message:textarea" -> textarea field
|
|
649
|
+
* "role:select(admin,user)" -> select field with options
|
|
650
|
+
*/
|
|
651
|
+
function parseFormField(fieldStr) {
|
|
652
|
+
// Check for explicit type: "fieldname:type"
|
|
653
|
+
const typeMatch = fieldStr.match(/^(\w+):(\w+)(?:\(([^)]+)\))?$/);
|
|
654
|
+
let name;
|
|
655
|
+
let type;
|
|
656
|
+
let options;
|
|
657
|
+
if (typeMatch) {
|
|
658
|
+
name = typeMatch[1];
|
|
659
|
+
const explicitType = typeMatch[2].toLowerCase();
|
|
660
|
+
const optionsStr = typeMatch[3];
|
|
661
|
+
// Map explicit types to form field types
|
|
662
|
+
type = mapToFormFieldType(explicitType);
|
|
663
|
+
// Parse options if present
|
|
664
|
+
if (optionsStr) {
|
|
665
|
+
options = optionsStr.split(',').map(s => s.trim());
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
else {
|
|
669
|
+
// Infer from field name
|
|
670
|
+
name = fieldStr.trim();
|
|
671
|
+
type = inferFormFieldType(name);
|
|
672
|
+
}
|
|
673
|
+
const label = capitalizeWords(name);
|
|
674
|
+
const required = !name.toLowerCase().includes('optional');
|
|
675
|
+
const field = {
|
|
676
|
+
kind: 'FormField',
|
|
677
|
+
name,
|
|
678
|
+
type,
|
|
679
|
+
label,
|
|
680
|
+
required,
|
|
681
|
+
loc: createSourceLocation(),
|
|
682
|
+
};
|
|
683
|
+
// Add options for select/multiselect
|
|
684
|
+
if (options && (type === 'select' || type === 'multiselect' || type === 'radio')) {
|
|
685
|
+
field.options = options.map(value => ({
|
|
686
|
+
kind: 'FormFieldOption',
|
|
687
|
+
value,
|
|
688
|
+
label: capitalizeWords(value),
|
|
689
|
+
loc: createSourceLocation(),
|
|
690
|
+
}));
|
|
691
|
+
}
|
|
692
|
+
// Add placeholder for text fields
|
|
693
|
+
if (['text', 'email', 'password', 'textarea'].includes(type)) {
|
|
694
|
+
field.placeholder = 'Enter ' + label.toLowerCase();
|
|
695
|
+
}
|
|
696
|
+
return field;
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* Map explicit type string to FormFieldType
|
|
700
|
+
*/
|
|
701
|
+
function mapToFormFieldType(typeStr) {
|
|
702
|
+
const typeMap = {
|
|
703
|
+
'text': 'text',
|
|
704
|
+
'textarea': 'textarea',
|
|
705
|
+
'email': 'email',
|
|
706
|
+
'password': 'password',
|
|
707
|
+
'number': 'number',
|
|
708
|
+
'select': 'select',
|
|
709
|
+
'multiselect': 'multiselect',
|
|
710
|
+
'checkbox': 'checkbox',
|
|
711
|
+
'radio': 'radio',
|
|
712
|
+
'date': 'date',
|
|
713
|
+
'time': 'time',
|
|
714
|
+
'datetime': 'datetime',
|
|
715
|
+
'file': 'file',
|
|
716
|
+
};
|
|
717
|
+
return typeMap[typeStr] || 'text';
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Infer form field type from field name
|
|
721
|
+
*/
|
|
722
|
+
function inferFormFieldType(name) {
|
|
723
|
+
const lowerName = name.toLowerCase();
|
|
724
|
+
if (lowerName.includes('email'))
|
|
725
|
+
return 'email';
|
|
726
|
+
if (lowerName.includes('password'))
|
|
727
|
+
return 'password';
|
|
728
|
+
if (lowerName.includes('phone') || lowerName.includes('tel'))
|
|
729
|
+
return 'text';
|
|
730
|
+
if (lowerName.includes('message') || lowerName.includes('description') || lowerName.includes('bio'))
|
|
731
|
+
return 'textarea';
|
|
732
|
+
if (lowerName.includes('date') && lowerName.includes('time'))
|
|
733
|
+
return 'datetime';
|
|
734
|
+
if (lowerName.includes('date'))
|
|
735
|
+
return 'date';
|
|
736
|
+
if (lowerName.includes('time'))
|
|
737
|
+
return 'time';
|
|
738
|
+
if (lowerName.includes('age') || lowerName.includes('count') || lowerName.includes('number'))
|
|
739
|
+
return 'number';
|
|
740
|
+
if (lowerName.includes('agree') || lowerName.includes('accept') || lowerName.includes('confirm'))
|
|
741
|
+
return 'checkbox';
|
|
742
|
+
if (lowerName.includes('file') || lowerName.includes('upload') || lowerName.includes('attachment'))
|
|
743
|
+
return 'file';
|
|
744
|
+
return 'text';
|
|
745
|
+
}
|
|
746
|
+
/**
|
|
747
|
+
* Capitalize words in a string
|
|
748
|
+
*/
|
|
749
|
+
function capitalizeWords(str) {
|
|
750
|
+
return str
|
|
751
|
+
.replace(/([A-Z])/g, ' $1') // Add space before caps
|
|
752
|
+
.trim()
|
|
753
|
+
.split(/\s+/)
|
|
754
|
+
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
|
755
|
+
.join(' ');
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* Create a default source location
|
|
759
|
+
*/
|
|
760
|
+
function createSourceLocation() {
|
|
761
|
+
return {
|
|
762
|
+
startLine: 1,
|
|
763
|
+
startColumn: 1,
|
|
764
|
+
endLine: 1,
|
|
765
|
+
endColumn: 1,
|
|
766
|
+
startOffset: 0,
|
|
767
|
+
endOffset: 0,
|
|
768
|
+
};
|
|
769
|
+
}
|