naider 1.20.0 → 1.21.1

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/src/gen.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Lexer } from './lexer.js';
2
2
  import { Parser } from './parser.js';
3
+ import { readFileSync as _readFS, writeFileSync as _writeFS, existsSync as _existsFS } from 'node:fs';
3
4
 
4
5
  // ── Vocabulary ──────────────────────────────────────────────
5
6
 
@@ -81,8 +82,12 @@ const COMPOSITE_WORDS = {
81
82
  todo: ['todo', 'todos', 'to-do', 'タスク管理', 'todoアプリ', 'やることリスト', 'todo管理'],
82
83
  blog: ['blog', 'ブログ', '記事管理', '投稿管理', 'ブログサイト', 'ブログアプリ'],
83
84
  chat: ['chat', 'チャットアプリ', 'メッセンジャー', 'チャット機能', 'チャットルーム'],
84
- shop: ['shop', 'ecommerce', 'e-commerce', 'ショップ', 'ストア', '通販', 'ECサイト', 'EC', 'オンラインショップ'],
85
+ shop: ['shop', 'ecommerce', 'e-commerce', 'ショップ', 'ストア', '通販', 'ECサイト', 'EC', 'オンラインショップ', '通販サイト'],
85
86
  board: ['board', '掲示板', 'フォーラム', 'BBS', 'bbs'],
87
+ crm: ['crm', 'CRM', '顧客管理', '顧客管理システム', 'カスタマー管理'],
88
+ inventory: ['inventory', '在庫管理', '倉庫管理', '在庫システム'],
89
+ booking: ['booking', 'reservation', '予約システム', '予約管理', '予約アプリ', '予約サイト'],
90
+ sns: ['sns', 'SNS', 'ソーシャル', 'social', 'social-media', 'ソーシャルメディア'],
86
91
  };
87
92
 
88
93
  const NOISE = new Set([
@@ -220,6 +225,47 @@ function defaultFields(schemaName) {
220
225
  ];
221
226
  }
222
227
 
228
+ // ── Relationship Detection ─────────────────────────────────
229
+
230
+ const PARENT_CHILD = {
231
+ User: ['Post', 'Comment', 'Order', 'Message', 'Review', 'Task', 'Notification', 'File',
232
+ 'Payment', 'Ticket', 'Note', 'Reservation', 'Activity', 'Log', 'Thread', 'Recipe'],
233
+ Category: ['Product', 'Post', 'Item'],
234
+ Product: ['Order', 'Review', 'Inventory'],
235
+ Post: ['Comment', 'Review'],
236
+ Project: ['Task', 'Ticket'],
237
+ Team: ['Employee', 'Staff', 'Project'],
238
+ Order: ['Payment'],
239
+ Event: ['Reservation'],
240
+ };
241
+
242
+ function detectRelationships(entities) {
243
+ const rels = [];
244
+ const names = entities.map(e => e.name);
245
+ for (let i = 0; i < names.length; i++) {
246
+ for (let j = 0; j < names.length; j++) {
247
+ if (i === j) continue;
248
+ const children = PARENT_CHILD[names[i]];
249
+ if (children && children.includes(names[j])) {
250
+ rels.push({ parent: names[i], child: names[j], fk: names[i].toLowerCase() + '_id' });
251
+ }
252
+ }
253
+ }
254
+ return rels;
255
+ }
256
+
257
+ function applyRelationships(entities, relationships) {
258
+ for (const rel of relationships) {
259
+ const child = entities.find(e => e.name === rel.child);
260
+ if (child && !child.fields.some(f => f.name === rel.fk)) {
261
+ const insertIdx = child.fields.findIndex(f => f.type !== 'auto' || f.name !== 'id');
262
+ const fkField = { name: rel.fk, type: 'int', modifiers: ['required'] };
263
+ if (insertIdx > 0) child.fields.splice(insertIdx, 0, fkField);
264
+ else child.fields.push(fkField);
265
+ }
266
+ }
267
+ }
268
+
223
269
  // ── Utilities ───────────────────────────────────────────────
224
270
 
225
271
  function singularize(w) {
@@ -302,16 +348,20 @@ function classify(words, raw) {
302
348
  }
303
349
  }
304
350
 
305
- // Composite detection
351
+ // Composite detection (match both lowercase and original for mixed JP/EN keywords)
306
352
  for (const [comp, kws] of Object.entries(COMPOSITE_WORDS)) {
307
- if (kws.some(k => lower.includes(k))) {
353
+ if (kws.some(k => lower.includes(k.toLowerCase()) || raw.includes(k))) {
308
354
  matchStrength += 2;
309
355
  if (comp === 'fullstack') { intents.add('server'); intents.add('schema'); intents.add('crud'); intents.add('auth'); intents.add('database'); }
310
356
  if (comp === 'todo') { intents.add('server'); intents.add('schema'); intents.add('crud'); intents.add('database'); mods.preset = 'todo'; }
311
357
  if (comp === 'blog') { intents.add('server'); intents.add('schema'); intents.add('crud'); intents.add('database'); mods.preset = 'post'; }
312
358
  if (comp === 'chat') { intents.add('server'); intents.add('schema'); intents.add('websocket'); intents.add('database'); mods.preset = 'message'; }
313
- if (comp === 'shop') { intents.add('server'); intents.add('schema'); intents.add('crud'); intents.add('auth'); intents.add('database'); mods.preset = 'product'; }
359
+ if (comp === 'shop') { intents.add('server'); intents.add('schema'); intents.add('crud'); intents.add('auth'); intents.add('database'); mods.preset = 'product'; mods.multiEntity = ['User', 'Product', 'Order']; }
314
360
  if (comp === 'board') { intents.add('server'); intents.add('schema'); intents.add('crud'); intents.add('database'); mods.preset = 'post'; }
361
+ if (comp === 'crm') { intents.add('server'); intents.add('schema'); intents.add('crud'); intents.add('auth'); intents.add('database'); mods.multiEntity = ['Customer', 'Contact', 'Note']; }
362
+ if (comp === 'inventory') { intents.add('server'); intents.add('schema'); intents.add('crud'); intents.add('database'); mods.multiEntity = ['Product', 'Inventory']; }
363
+ if (comp === 'booking') { intents.add('server'); intents.add('schema'); intents.add('crud'); intents.add('auth'); intents.add('database'); mods.multiEntity = ['User', 'Reservation']; }
364
+ if (comp === 'sns') { intents.add('server'); intents.add('schema'); intents.add('crud'); intents.add('auth'); intents.add('websocket'); intents.add('database'); mods.multiEntity = ['User', 'Post', 'Comment']; }
315
365
  }
316
366
  }
317
367
 
@@ -332,6 +382,33 @@ function classify(words, raw) {
332
382
  return !NOISE.has(wl) && !allKnown.has(wl) && !/^\d+$/.test(wl) && wl.length > 2 && /^[a-z]+s?$/i.test(wl);
333
383
  }) || Object.keys(JA_ENTITIES).some(ja => raw.includes(ja));
334
384
 
385
+ // Complex NL phrase detection
386
+ if (/(?:users?\s+can\s+(?:comment|post|review|write)|コメントできる|投稿できる|レビューできる)/i.test(raw)) {
387
+ intents.add('auth'); intents.add('crud');
388
+ }
389
+ if (/(?:admin.*(?:delete|manage|moderate|ban)|管理者.*(?:削除|管理|モデレート))/i.test(raw)) {
390
+ intents.add('auth');
391
+ mods.needsAdmin = true;
392
+ }
393
+ if (/(?:upload|file\s+upload|画像.*アップ|ファイル.*アップ)/i.test(raw)) {
394
+ intents.add('crud');
395
+ }
396
+ if (/(?:search|検索|フィルタ|filter|sort|ソート)/i.test(raw)) {
397
+ intents.add('crud');
398
+ }
399
+ if (/(?:notification|通知|push|プッシュ|alert|アラート)/i.test(raw) && !intents.has('mail')) {
400
+ intents.add('websocket');
401
+ }
402
+ if (/(?:deploy|デプロイ|docker|コンテナ|container)/i.test(raw)) {
403
+ mods.needsDocker = true;
404
+ }
405
+ if (/(?:pagina|ページネーション|ページ送り|一覧表示)/i.test(raw)) {
406
+ intents.add('crud');
407
+ }
408
+ if (/(?:seed|初期データ|サンプルデータ|テストデータ|ダミーデータ)/i.test(raw)) {
409
+ mods.needsSeed = true;
410
+ }
411
+
335
412
  // "for X" pattern implies schema + crud
336
413
  if (hasEntity && intents.has('server')) {
337
414
  intents.add('schema'); intents.add('crud');
@@ -358,7 +435,8 @@ function classify(words, raw) {
358
435
  }
359
436
 
360
437
  function extractParams(words, raw, intents, mods) {
361
- const params = { port: 3000, schemaName: null, fields: [], features: intents, platform: mods.platform || null, dbType: mods.dbType || null, commands: [] };
438
+ const params = { port: 3000, schemaName: null, fields: [], entities: [], relationships: [],
439
+ features: intents, platform: mods.platform || null, dbType: mods.dbType || null, commands: [] };
362
440
 
363
441
  // Extract port number
364
442
  for (let i = 0; i < words.length; i++) {
@@ -370,55 +448,98 @@ function extractParams(words, raw, intents, mods) {
370
448
  }
371
449
  }
372
450
 
373
- // Extract entity name — Japanese first (longest match wins)
451
+ // ── Multi-entity extraction ──
452
+ const entityNames = [];
453
+
454
+ // Japanese entities (longest match first, collect ALL matches)
374
455
  const jaEntries = Object.entries(JA_ENTITIES).sort((a, b) => b[0].length - a[0].length);
456
+ const jaUsed = new Set();
375
457
  for (const [ja, en] of jaEntries) {
376
- if (raw.includes(ja)) { params.schemaName = en; break; }
458
+ if (raw.includes(ja) && !jaUsed.has(en)) {
459
+ entityNames.push(en);
460
+ jaUsed.add(en);
461
+ }
377
462
  }
378
463
 
379
- // Extract entity name — English (find nouns that aren't keywords)
380
- if (!params.schemaName) {
464
+ // English entities: extract nouns from the full instruction, splitting on "and" / ","
465
+ if (entityNames.length === 0) {
381
466
  const allKnown = new Set([...Object.values(INTENT_WORDS).flat(), ...Object.values(PLATFORM_WORDS).flat(),
382
467
  ...Object.values(COMPOSITE_WORDS).flat()]);
383
- for (const w of words) {
384
- const wl = w.toLowerCase();
385
- if (NOISE.has(wl) || allKnown.has(wl) || /^\d+$/.test(wl) || wl.length <= 2) continue;
386
- params.schemaName = capitalize(singularize(wl));
387
- break;
468
+ const lower = raw.toLowerCase();
469
+ const segments = lower.split(/\s+(?:and|&)\s+|,\s*/);
470
+ for (const seg of segments) {
471
+ const segWords = seg.trim().split(/\s+/).filter(w => w !== 'with');
472
+ for (const w of segWords) {
473
+ const wl = w.toLowerCase().replace(/[^a-z0-9]/g, '');
474
+ if (!wl || NOISE.has(wl) || allKnown.has(wl) || /^\d+$/.test(wl) || wl.length <= 2) continue;
475
+ const name = capitalize(singularize(wl));
476
+ if (!entityNames.includes(name)) entityNames.push(name);
477
+ break;
478
+ }
479
+ }
480
+ }
481
+
482
+ // Composite multi-entity presets (shop, crm, booking, sns, etc.)
483
+ if (mods.multiEntity && entityNames.length <= 1) {
484
+ for (const e of mods.multiEntity) {
485
+ if (!entityNames.includes(e)) entityNames.push(e);
388
486
  }
389
487
  }
390
488
 
391
489
  // Preset override
392
- if (mods.preset && !params.schemaName) {
393
- params.schemaName = capitalize(mods.preset);
490
+ if (mods.preset && entityNames.length === 0) {
491
+ entityNames.push(capitalize(mods.preset));
394
492
  }
395
493
 
396
- // Default schema name
397
- if (!params.schemaName && intents.has('schema')) params.schemaName = 'Item';
494
+ // Default
495
+ if (entityNames.length === 0 && intents.has('schema')) entityNames.push('Item');
398
496
 
399
- // Extract fields from "with" clause
497
+ // Primary entity (backward compat)
498
+ params.schemaName = entityNames[0] || null;
499
+
500
+ // Extract fields from "with" clause (applies to primary entity)
501
+ // Only treat words after "with" as fields if they look like field names (not entity names or intent keywords)
400
502
  const lower = raw.toLowerCase();
503
+ let primaryFields = [];
401
504
  const withIdx = lower.indexOf(' with ');
402
505
  if (withIdx !== -1) {
403
506
  const afterWith = lower.slice(withIdx + 6);
404
- const stopWords = new Set(Object.values(INTENT_WORDS).flat());
405
- const fieldNames = afterWith
507
+ const primaryIntents = new Set(['server', 'api', 'rest', 'bot', 'cli', 'page', 'test', 'database',
508
+ 'auth', 'crud', 'websocket', 'mail', 'graphql', 'sqlite', 'postgres']);
509
+ const entityLike = new Set(entityNames.map(e => e.toLowerCase()));
510
+ const entityLikePlural = new Set(entityNames.map(e => pluralize(e.toLowerCase())));
511
+ const candidateWords = afterWith
406
512
  .split(/[\s,、]+/)
407
513
  .map(w => w.replace(/[^a-zA-Z0-9_\-]/g, ''))
408
- .filter(w => w.length > 1 && !NOISE.has(w) && !stopWords.has(w) && !/^\d+$/.test(w));
409
- if (fieldNames.length > 0) {
410
- params.fields = [{ name: 'id', type: 'auto', modifiers: [] }];
411
- for (const rawName of fieldNames) {
514
+ .filter(w => w.length > 1 && !NOISE.has(w) && !primaryIntents.has(w) && !/^\d+$/.test(w)
515
+ && !entityLike.has(w) && !entityLikePlural.has(w) && w !== 'and');
516
+ // Only use as fields if they look like actual field names (not entity names)
517
+ const knownFieldish = new Set([...Object.keys(FIELD_SYNONYMS), ...Object.keys(FIELD_TYPES).flatMap(k => FIELD_TYPES[k])]);
518
+ const hasFieldNames = candidateWords.some(w => knownFieldish.has(normalizeField(w)));
519
+ if (candidateWords.length > 0 && hasFieldNames) {
520
+ primaryFields = [{ name: 'id', type: 'auto', modifiers: [] }];
521
+ for (const rawName of candidateWords) {
412
522
  const name = normalizeField(rawName);
413
523
  const type = inferType(name);
414
- params.fields.push({ name, type, modifiers: inferModifiers(name, type) });
524
+ primaryFields.push({ name, type, modifiers: inferModifiers(name, type) });
415
525
  }
416
526
  }
417
527
  }
418
528
 
419
- // Use preset fields if none extracted
420
- if (params.fields.length === 0 && params.schemaName) {
421
- params.fields = defaultFields(params.schemaName);
529
+ // Build entities array
530
+ for (let i = 0; i < entityNames.length; i++) {
531
+ const name = entityNames[i];
532
+ const fields = (i === 0 && primaryFields.length > 0) ? primaryFields : defaultFields(name);
533
+ params.entities.push({ name, fields });
534
+ }
535
+
536
+ // Backward compat: primary entity fields
537
+ params.fields = params.entities.length > 0 ? params.entities[0].fields : [];
538
+
539
+ // Detect relationships between entities
540
+ if (params.entities.length > 1) {
541
+ params.relationships = detectRelationships(params.entities);
542
+ applyRelationships(params.entities, params.relationships);
422
543
  }
423
544
 
424
545
  // Extract bot commands
@@ -483,6 +604,87 @@ function testBlock(name) {
483
604
  assert [1, 2, 3].length == 3`;
484
605
  }
485
606
 
607
+ function entityTestBlock(entity) {
608
+ const name = entity.name;
609
+ const lower = name.toLowerCase();
610
+ const plural = pluralize(lower);
611
+ const store = name + 'Store';
612
+ const requiredFields = entity.fields.filter(f => f.modifiers.includes('required') && f.type !== 'auto');
613
+ const sampleObj = requiredFields.map(f => {
614
+ if (f.type === 'int') return `${f.name}: 1`;
615
+ if (f.type === 'bool') return `${f.name}: true`;
616
+ return `${f.name}: "test"`;
617
+ }).join(', ');
618
+
619
+ return `test "${lower} CRUD":
620
+ any created = ${store}.create({${sampleObj}})
621
+ assert created.id
622
+
623
+ any found = ${store}.find(created.id)
624
+ assert found.id == created.id
625
+
626
+ list all = ${store}.all()
627
+ assert all.length > 0
628
+
629
+ ${store}.remove(created.id)
630
+ any deleted = ${store}.find(created.id)
631
+ assert not deleted`;
632
+ }
633
+
634
+ function nestedRouteBlock(parent, child, fk) {
635
+ const parentLower = parent.toLowerCase();
636
+ const childLower = child.toLowerCase();
637
+ const childPlural = pluralize(childLower);
638
+ const childStore = child + 'Store';
639
+ return [
640
+ `get "/api/${pluralize(parentLower)}/:${fk.replace('_id', 'Id')}/${childPlural}" (req, res):`,
641
+ ` list items = ${childStore}.findAll("${fk}", req.params.${fk.replace('_id', 'Id')})`,
642
+ ` ret items`,
643
+ ];
644
+ }
645
+
646
+ function paginatedListBlock(entity) {
647
+ const lower = entity.name.toLowerCase();
648
+ const plural = pluralize(lower);
649
+ const store = entity.name + 'Store';
650
+ return [
651
+ `get "/api/${plural}" (req, res):`,
652
+ ` int page = req.query.page || 1`,
653
+ ` int limit = req.query.limit || 20`,
654
+ ` str search = req.query.search || ""`,
655
+ ` list all = ${store}.all()`,
656
+ ` if search:`,
657
+ ` all = all.filter((item) => JSON.stringify(item).includes(search))`,
658
+ ` int total = all.length`,
659
+ ` int start = (page - 1) * limit`,
660
+ ` list items = all.slice(start, start + limit)`,
661
+ ` ret {items, total, page, limit}`,
662
+ ];
663
+ }
664
+
665
+ function seedBlock(entities) {
666
+ const lines = ['fn seed():'];
667
+ for (const entity of entities) {
668
+ const store = entity.name + 'Store';
669
+ const seen = new Set();
670
+ const sampleFields = [];
671
+ for (const f of entity.fields) {
672
+ if (f.type === 'auto' || seen.has(f.name)) continue;
673
+ seen.add(f.name);
674
+ if (f.name === 'password') { sampleFields.push('password: "hashed_demo"'); continue; }
675
+ if (!f.modifiers.includes('required') && !f.name.endsWith('_id')) continue;
676
+ if (f.name.endsWith('_id')) sampleFields.push(`${f.name}: 1`);
677
+ else if (f.type === 'int') sampleFields.push(`${f.name}: ${f.name === 'price' ? 1000 : f.name === 'total' ? 2500 : 1}`);
678
+ else if (f.type === 'bool') sampleFields.push(`${f.name}: false`);
679
+ else if (f.name === 'email') sampleFields.push(`${f.name}: "demo@example.com"`);
680
+ else sampleFields.push(`${f.name}: "Sample ${entity.name}"`);
681
+ }
682
+ lines.push(` ${store}.create({${sampleFields.join(', ')}})`);
683
+ }
684
+ lines.push(' log("Seed data inserted")');
685
+ return lines.join('\n');
686
+ }
687
+
486
688
  function graphqlBlock() {
487
689
  return `graphql "/graphql"`;
488
690
  }
@@ -499,16 +701,380 @@ function mailBlock() {
499
701
  pass SMTP_PASS`;
500
702
  }
501
703
 
704
+ // ── Middleware & Utility Templates ──────────────────────────
705
+
706
+ function utilityFunctions(intents) {
707
+ const fns = [];
708
+ if (intents.has('server')) {
709
+ fns.push(`fn ok(any data, any meta):
710
+ ret {ok: true, data, error: null, meta}`);
711
+ fns.push(`fn err(str message, int code):
712
+ ret {ok: false, data: null, error: message, code}`);
713
+ fns.push(`fn sanitize(str input):
714
+ ret input.replace("<", "&lt;").replace(">", "&gt;").replace("'", "&#39;")`);
715
+ }
716
+ return fns;
717
+ }
718
+
719
+ function softDeleteRoutes(entity) {
720
+ const lower = entity.name.toLowerCase();
721
+ const plural = pluralize(lower);
722
+ const store = entity.name + 'Store';
723
+ return [
724
+ `del "/api/${plural}/:id/soft" (req, res):`,
725
+ ` ${store}.update(req.params.id, {deleted: true})`,
726
+ ` ret ok({archived: true}, null)`,
727
+ ``,
728
+ `put "/api/${plural}/:id/restore" (req, res):`,
729
+ ` ${store}.update(req.params.id, {deleted: false})`,
730
+ ` ret ok({restored: true}, null)`,
731
+ ];
732
+ }
733
+
734
+ function batchRoutes(entity) {
735
+ const lower = entity.name.toLowerCase();
736
+ const plural = pluralize(lower);
737
+ const store = entity.name + 'Store';
738
+ return [
739
+ `post "/api/${plural}/batch" (req, res):`,
740
+ ` list results = []`,
741
+ ` each item in req.body.items:`,
742
+ ` any created = ${store}.create(item)`,
743
+ ` results.push(created)`,
744
+ ` ret ok({created: results.length, items: results}, null)`,
745
+ ``,
746
+ `del "/api/${plural}/batch" (req, res):`,
747
+ ` each id in req.body.ids:`,
748
+ ` ${store}.remove(id)`,
749
+ ` ret ok({deleted: req.body.ids.length}, null)`,
750
+ ];
751
+ }
752
+
753
+ function uploadRoute() {
754
+ return [
755
+ `post "/api/upload" (req, res):`,
756
+ ` any file = req.files.file`,
757
+ ` if not file:`,
758
+ ` ret.status(400) err("No file provided", 400)`,
759
+ ` str path = "uploads/" + file.name`,
760
+ ` file.mv(path)`,
761
+ ` ret ok({path, name: file.name, size: file.size}, null)`,
762
+ ];
763
+ }
764
+
765
+ function auditSchema() {
766
+ return `schema AuditLog:
767
+ id auto
768
+ action str required
769
+ entity str required
770
+ entity_id int
771
+ user_id int
772
+ detail str
773
+ created auto`;
774
+ }
775
+
776
+ function auditFn() {
777
+ return `fn auditLog(str action, str entity, int entityId, int userId):
778
+ AuditLogStore.create({action, entity, entity_id: entityId, user_id: userId})`;
779
+ }
780
+
781
+ function webhookRoute() {
782
+ return [
783
+ `post "/api/webhooks" (req, res):`,
784
+ ` str event = req.body.event`,
785
+ ` any payload = req.body.payload`,
786
+ ` log("Webhook received: " + event)`,
787
+ ` ret ok({received: true, event}, null)`,
788
+ ``,
789
+ `get "/api/webhooks/health" (req, res):`,
790
+ ` ret ok({status: "listening"}, null)`,
791
+ ];
792
+ }
793
+
794
+ function serverMiddlewareHints(intents, params) {
795
+ const hints = [];
796
+ hints.push('# ── Middleware (install if needed) ──');
797
+ hints.push('# npm install express-rate-limit → rateLimit({windowMs: 900000, max: 100})');
798
+ hints.push('# npm install helmet → helmet() for security headers');
799
+ hints.push('# npm install compression → compression() for gzip');
800
+ if (intents.has('auth')) {
801
+ hints.push('# npm install cors → cors({origin: "https://yourdomain.com"})');
802
+ }
803
+ return hints.join('\n');
804
+ }
805
+
806
+ function openApiSpec(entities, intents, params, relationships) {
807
+ const paths = {};
808
+ for (const entity of entities) {
809
+ const plural = pluralize(entity.name.toLowerCase());
810
+ const requiredFields = entity.fields.filter(f => f.modifiers.includes('required') && f.type !== 'auto');
811
+ const properties = {};
812
+ for (const f of entity.fields) {
813
+ properties[f.name] = { type: f.type === 'auto' ? 'integer' : f.type === 'int' ? 'integer' : f.type === 'bool' ? 'boolean' : 'string' };
814
+ }
815
+ paths[`/api/${plural}`] = {
816
+ get: { summary: `List ${plural} (paginated)`, parameters: [
817
+ { name: 'page', in: 'query', schema: { type: 'integer', default: 1 } },
818
+ { name: 'limit', in: 'query', schema: { type: 'integer', default: 20 } },
819
+ { name: 'search', in: 'query', schema: { type: 'string' } },
820
+ ], responses: { '200': { description: `List of ${plural}` } } },
821
+ post: { summary: `Create ${entity.name.toLowerCase()}`, requestBody: { content: { 'application/json': { schema: {
822
+ type: 'object', required: requiredFields.map(f => f.name),
823
+ properties: Object.fromEntries(requiredFields.map(f => [f.name, properties[f.name]])),
824
+ } } } }, responses: { '201': { description: 'Created' } } },
825
+ };
826
+ paths[`/api/${plural}/{id}`] = {
827
+ get: { summary: `Get ${entity.name.toLowerCase()} by ID`, responses: { '200': { description: entity.name } } },
828
+ put: { summary: `Update ${entity.name.toLowerCase()}`, responses: { '200': { description: 'Updated' } } },
829
+ delete: { summary: `Delete ${entity.name.toLowerCase()}`, responses: { '200': { description: 'Deleted' } } },
830
+ };
831
+ paths[`/api/${plural}/batch`] = {
832
+ post: { summary: `Batch create ${plural}`, responses: { '200': { description: 'Batch created' } } },
833
+ delete: { summary: `Batch delete ${plural}`, responses: { '200': { description: 'Batch deleted' } } },
834
+ };
835
+ paths[`/api/${plural}/{id}/soft`] = {
836
+ delete: { summary: `Soft delete ${entity.name.toLowerCase()}`, responses: { '200': { description: 'Archived' } } },
837
+ };
838
+ paths[`/api/${plural}/{id}/restore`] = {
839
+ put: { summary: `Restore ${entity.name.toLowerCase()}`, responses: { '200': { description: 'Restored' } } },
840
+ };
841
+ }
842
+ for (const rel of relationships) {
843
+ const parentPlural = pluralize(rel.parent.toLowerCase());
844
+ const childPlural = pluralize(rel.child.toLowerCase());
845
+ paths[`/api/${parentPlural}/{${rel.fk.replace('_id', 'Id')}}/${childPlural}`] = {
846
+ get: { summary: `${rel.child}s by ${rel.parent}`, responses: { '200': { description: `List of ${childPlural}` } } },
847
+ };
848
+ }
849
+ if (intents.has('auth')) {
850
+ paths['/api/auth/register'] = { post: { summary: 'Register', responses: { '200': { description: 'Token + user' } } } };
851
+ paths['/api/auth/login'] = { post: { summary: 'Login', responses: { '200': { description: 'Token + user' } } } };
852
+ paths['/api/auth/me'] = { get: { summary: 'Current user', security: [{ bearerAuth: [] }], responses: { '200': { description: 'User' } } } };
853
+ }
854
+ paths['/api/health'] = { get: { summary: 'Health check', responses: { '200': { description: 'Status' } } } };
855
+ paths['/api/upload'] = { post: { summary: 'Upload file', responses: { '200': { description: 'File info' } } } };
856
+
857
+ const spec = {
858
+ openapi: '3.0.3',
859
+ info: { title: (entities[0]?.name || 'App') + ' API', version: '1.0.0', description: 'Generated by NAIDE Agent Coder' },
860
+ servers: [{ url: `http://localhost:${params.port}` }],
861
+ paths,
862
+ };
863
+ if (intents.has('auth')) {
864
+ spec.components = { securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' } } };
865
+ }
866
+ return spec;
867
+ }
868
+
869
+ function adminPage(entities, intents) {
870
+ const lines = [];
871
+ lines.push('<!DOCTYPE html>');
872
+ lines.push('<html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">');
873
+ lines.push('<title>Admin Dashboard</title>');
874
+ lines.push('<style>*{margin:0;padding:0;box-sizing:border-box}body{font-family:system-ui;background:#f5f5f5;padding:2rem}');
875
+ lines.push('h1{margin-bottom:1rem}.card{background:#fff;border-radius:8px;padding:1.5rem;margin-bottom:1rem;box-shadow:0 1px 3px rgba(0,0,0,.1)}');
876
+ lines.push('table{width:100%;border-collapse:collapse}th,td{padding:.5rem;text-align:left;border-bottom:1px solid #eee}');
877
+ lines.push('th{background:#f9f9f9;font-weight:600}button{padding:.4rem 1rem;border:none;border-radius:4px;cursor:pointer;background:#2563eb;color:#fff}');
878
+ lines.push('button:hover{background:#1d4ed8}button.danger{background:#dc2626}button.danger:hover{background:#b91c1c}');
879
+ lines.push('input,select{padding:.4rem;border:1px solid #ddd;border-radius:4px;margin-right:.5rem}');
880
+ lines.push('.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:1rem;margin-bottom:1.5rem}');
881
+ lines.push('.stat{background:#fff;border-radius:8px;padding:1.5rem;text-align:center;box-shadow:0 1px 3px rgba(0,0,0,.1)}');
882
+ lines.push('.stat h3{font-size:2rem;color:#2563eb}.stat p{color:#666;margin-top:.5rem}');
883
+ lines.push('#toast{position:fixed;top:1rem;right:1rem;background:#22c55e;color:#fff;padding:.8rem 1.5rem;border-radius:8px;display:none}');
884
+ lines.push('</style></head><body>');
885
+ lines.push('<h1>Admin Dashboard</h1>');
886
+ lines.push('<div id="toast"></div>');
887
+ lines.push('<div class="stats" id="stats"></div>');
888
+ for (const entity of entities) {
889
+ const lower = entity.name.toLowerCase();
890
+ const plural = pluralize(lower);
891
+ lines.push(`<div class="card"><h2>${entity.name}s</h2>`);
892
+ lines.push(`<div style="margin:1rem 0"><input id="${lower}-search" placeholder="Search..." oninput="${lower}Load()">`);
893
+ lines.push(`<button onclick="${lower}Load()">Refresh</button> <button onclick="${lower}ShowForm()">+ New</button></div>`);
894
+ lines.push(`<div id="${lower}-form" style="display:none;margin:1rem 0;padding:1rem;background:#f9f9f9;border-radius:4px">`);
895
+ for (const f of entity.fields) {
896
+ if (f.type === 'auto') continue;
897
+ const inputType = f.type === 'int' ? 'number' : f.type === 'bool' ? 'checkbox' : f.name === 'password' ? 'password' : f.name === 'email' ? 'email' : 'text';
898
+ if (f.type === 'bool') {
899
+ lines.push(`<label><input type="checkbox" id="${lower}-${f.name}"> ${f.name}</label> `);
900
+ } else {
901
+ lines.push(`<input type="${inputType}" id="${lower}-${f.name}" placeholder="${f.name}${f.modifiers.includes('required') ? ' *' : ''}">`);
902
+ }
903
+ }
904
+ lines.push(`<button onclick="${lower}Create()">Save</button> <button onclick="document.getElementById('${lower}-form').style.display='none'" class="danger">Cancel</button></div>`);
905
+ lines.push(`<table><thead><tr>${entity.fields.map(f => `<th>${f.name}</th>`).join('')}<th>Actions</th></tr></thead>`);
906
+ lines.push(`<tbody id="${lower}-table"></tbody></table></div>`);
907
+ }
908
+ lines.push('<script>');
909
+ lines.push('const API="";function toast(m){const t=document.getElementById("toast");t.textContent=m;t.style.display="block";setTimeout(()=>t.style.display="none",2000)}');
910
+ lines.push('async function loadStats(){try{const r=await fetch(API+"/api/health");const d=await r.json();const s=document.getElementById("stats");');
911
+ lines.push('s.innerHTML=Object.entries(d).filter(([k])=>k!=="status").map(([k,v])=>`<div class="stat"><h3>${v}</h3><p>${k}</p></div>`).join("")}catch(e){}}');
912
+ for (const entity of entities) {
913
+ const lower = entity.name.toLowerCase();
914
+ const plural = pluralize(lower);
915
+ const fields = entity.fields.filter(f => f.type !== 'auto');
916
+ lines.push(`async function ${lower}Load(){const s=document.getElementById("${lower}-search").value;`);
917
+ lines.push(`const r=await fetch(API+"/api/${plural}?search="+s);const d=await r.json();const items=d.items||d;`);
918
+ lines.push(`document.getElementById("${lower}-table").innerHTML=items.map(i=>"<tr>${entity.fields.map(f => `<td>"+i.${f.name}+"`).join('')}<td>"+`);
919
+ lines.push(`"<button onclick=\\"${lower}Del("+i.id+")\\">Del</button></td></tr>").join("")}`);
920
+ lines.push(`function ${lower}ShowForm(){document.getElementById("${lower}-form").style.display="block"}`);
921
+ lines.push(`async function ${lower}Create(){const body={${fields.map(f => {
922
+ if (f.type === 'bool') return `${f.name}:document.getElementById("${lower}-${f.name}").checked`;
923
+ if (f.type === 'int') return `${f.name}:+document.getElementById("${lower}-${f.name}").value`;
924
+ return `${f.name}:document.getElementById("${lower}-${f.name}").value`;
925
+ }).join(',')}};`);
926
+ lines.push(`await fetch(API+"/api/${plural}",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)});`);
927
+ lines.push(`document.getElementById("${lower}-form").style.display="none";toast("Created!");${lower}Load();loadStats()}`);
928
+ lines.push(`async function ${lower}Del(id){if(!confirm("Delete?"))return;await fetch(API+"/api/${plural}/"+id,{method:"DELETE"});toast("Deleted!");${lower}Load();loadStats()}`);
929
+ }
930
+ lines.push(`loadStats();${entities.map(e => pluralize(e.name.toLowerCase()).replace(/^./, c => c) && e.name.toLowerCase() + 'Load()').join(';')}`);
931
+ lines.push('</script></body></html>');
932
+ return lines.join('\n');
933
+ }
934
+
935
+ function apiClientCode(entities, intents, port) {
936
+ const lines = [];
937
+ lines.push('// Auto-generated API client — NAIDE Agent Coder');
938
+ lines.push(`const BASE = typeof window !== 'undefined' ? '' : 'http://localhost:${port}';`);
939
+ lines.push('let _token = null;');
940
+ lines.push('');
941
+ lines.push('function headers() {');
942
+ lines.push(" const h = { 'Content-Type': 'application/json' };");
943
+ lines.push(" if (_token) h['Authorization'] = 'Bearer ' + _token;");
944
+ lines.push(' return h;');
945
+ lines.push('}');
946
+ lines.push('');
947
+ lines.push('async function api(method, path, body) {');
948
+ lines.push(' const opts = { method, headers: headers() };');
949
+ lines.push(' if (body) opts.body = JSON.stringify(body);');
950
+ lines.push(' const res = await fetch(BASE + path, opts);');
951
+ lines.push(' return res.json();');
952
+ lines.push('}');
953
+ lines.push('');
954
+ if (intents.has('auth')) {
955
+ lines.push('export async function register(name, email, password) {');
956
+ lines.push(" const r = await api('POST', '/api/auth/register', { name, email, password });");
957
+ lines.push(' if (r.token) _token = r.token;');
958
+ lines.push(' return r;');
959
+ lines.push('}');
960
+ lines.push('');
961
+ lines.push('export async function login(email, password) {');
962
+ lines.push(" const r = await api('POST', '/api/auth/login', { email, password });");
963
+ lines.push(' if (r.token) _token = r.token;');
964
+ lines.push(' return r;');
965
+ lines.push('}');
966
+ lines.push('');
967
+ lines.push("export async function me() { return api('GET', '/api/auth/me'); }");
968
+ lines.push('export function setToken(t) { _token = t; }');
969
+ lines.push('');
970
+ }
971
+ for (const entity of entities) {
972
+ const lower = entity.name.toLowerCase();
973
+ const plural = pluralize(lower);
974
+ const Name = entity.name;
975
+ lines.push(`// ${Name}`);
976
+ lines.push(`export async function list${Name}s(page = 1, limit = 20, search = '') {`);
977
+ lines.push(` return api('GET', \`/api/${plural}?page=\${page}&limit=\${limit}&search=\${search}\`);`);
978
+ lines.push('}');
979
+ lines.push(`export async function get${Name}(id) { return api('GET', \`/api/${plural}/\${id}\`); }`);
980
+ lines.push(`export async function create${Name}(data) { return api('POST', '/api/${plural}', data); }`);
981
+ lines.push(`export async function update${Name}(id, data) { return api('PUT', \`/api/${plural}/\${id}\`, data); }`);
982
+ lines.push(`export async function remove${Name}(id) { return api('DELETE', \`/api/${plural}/\${id}\`); }`);
983
+ lines.push(`export async function archive${Name}(id) { return api('DELETE', \`/api/${plural}/\${id}/soft\`); }`);
984
+ lines.push(`export async function restore${Name}(id) { return api('PUT', \`/api/${plural}/\${id}/restore\`); }`);
985
+ lines.push(`export async function batchCreate${Name}s(items) { return api('POST', '/api/${plural}/batch', { items }); }`);
986
+ lines.push(`export async function batchRemove${Name}s(ids) { return api('DELETE', '/api/${plural}/batch', { ids }); }`);
987
+ lines.push('');
988
+ }
989
+ lines.push("export async function upload(file) { const fd = new FormData(); fd.append('file', file); const r = await fetch(BASE + '/api/upload', { method: 'POST', headers: _token ? { Authorization: 'Bearer ' + _token } : {}, body: fd }); return r.json(); }");
990
+ lines.push("export async function health() { return api('GET', '/api/health'); }");
991
+ return lines.join('\n') + '\n';
992
+ }
993
+
994
+ // ── Auth Route Templates ────────────────────────────────────
995
+
996
+ function authRoutesBlock(userEntity) {
997
+ const store = userEntity + 'Store';
998
+ return [
999
+ `post "/api/auth/register" (req, res):`,
1000
+ ` str hashed = await hash(req.body.password)`,
1001
+ ` any user = ${store}.create({name: req.body.name, email: req.body.email, password: hashed})`,
1002
+ ` str token = jwt.sign({id: user.id}, JWT_SECRET)`,
1003
+ ` ret {token, user: {id: user.id, name: user.name, email: user.email}}`,
1004
+ ``,
1005
+ `post "/api/auth/login" (req, res):`,
1006
+ ` any user = ${store}.findBy("email", req.body.email)`,
1007
+ ` if not user:`,
1008
+ ` ret.status(401) {error: "Invalid credentials"}`,
1009
+ ` bool valid = await verify(req.body.password, user.password)`,
1010
+ ` if not valid:`,
1011
+ ` ret.status(401) {error: "Invalid credentials"}`,
1012
+ ` str token = jwt.sign({id: user.id}, JWT_SECRET)`,
1013
+ ` ret {token, user: {id: user.id, name: user.name, email: user.email}}`,
1014
+ ``,
1015
+ `get "/api/auth/me" (req, res):`,
1016
+ ` ret {user: req.user}`,
1017
+ ];
1018
+ }
1019
+
1020
+ function envLines(intents, params) {
1021
+ const lines = [];
1022
+ if (intents.has('auth')) lines.push('# env JWT_SECRET "change-me-in-production"');
1023
+ if (params.dbType === 'postgres') lines.push('# env DATABASE_URL "postgres://localhost:5432/app"');
1024
+ if (intents.has('mail')) {
1025
+ lines.push('# env SMTP_HOST "smtp.example.com"');
1026
+ lines.push('# env SMTP_USER "user@example.com"');
1027
+ lines.push('# env SMTP_PASS "password"');
1028
+ }
1029
+ if (intents.has('bot')) {
1030
+ const token = (params.platform || 'discord').toUpperCase() + '_TOKEN';
1031
+ lines.push(`# env ${token} "your-bot-token"`);
1032
+ }
1033
+ if (intents.has('ai')) lines.push('# env OPENAI_API_KEY "your-api-key"');
1034
+ return lines;
1035
+ }
1036
+
502
1037
  // ── Code Composer ───────────────────────────────────────────
503
1038
 
504
1039
  function compose(intents, params) {
505
1040
  const sections = [];
506
- const schemaName = params.schemaName || 'Item';
507
- const nameLower = schemaName.toLowerCase();
508
- const namePlural = pluralize(nameLower);
1041
+ const entities = params.entities.length > 0 ? params.entities : [{ name: params.schemaName || 'Item', fields: params.fields }];
1042
+ const primaryName = entities[0].name;
1043
+ const primaryLower = primaryName.toLowerCase();
1044
+ const relationships = params.relationships || [];
1045
+ const hasAuth = intents.has('auth');
1046
+ const userEntity = entities.find(e => ['User', 'Customer', 'Employee', 'Staff'].includes(e.name));
1047
+
1048
+ // Middleware hints
1049
+ if (intents.has('server')) {
1050
+ sections.push(serverMiddlewareHints(intents, params));
1051
+ }
509
1052
 
510
- if (intents.has('schema') && params.fields.length > 0) {
511
- sections.push(schemaBlock(schemaName, params.fields));
1053
+ // Env hints
1054
+ const env = envLines(intents, params);
1055
+ if (env.length > 0) sections.push(env.join('\n'));
1056
+
1057
+ // Utility functions (envelope, sanitize)
1058
+ const utils = utilityFunctions(intents);
1059
+ if (utils.length > 0) sections.push(...utils);
1060
+
1061
+ // Schema blocks for all entities (add role + deleted fields)
1062
+ if (intents.has('schema')) {
1063
+ for (const entity of entities) {
1064
+ if (entity.fields.length > 0) {
1065
+ if (hasAuth && userEntity && entity.name === userEntity.name && !entity.fields.some(f => f.name === 'role')) {
1066
+ entity.fields.push({ name: 'role', type: 'str', modifiers: [] });
1067
+ }
1068
+ if (intents.has('crud') && !entity.fields.some(f => f.name === 'deleted')) {
1069
+ entity.fields.push({ name: 'deleted', type: 'bool', modifiers: [] });
1070
+ }
1071
+ sections.push(schemaBlock(entity.name, entity.fields));
1072
+ }
1073
+ }
1074
+ // Audit log schema when auth is present
1075
+ if (hasAuth) {
1076
+ sections.push(auditSchema());
1077
+ }
512
1078
  }
513
1079
 
514
1080
  if (intents.has('database')) {
@@ -519,39 +1085,117 @@ function compose(intents, params) {
519
1085
  const body = [];
520
1086
  body.push('cors "*"');
521
1087
 
1088
+ // CRUD for each entity
522
1089
  if (intents.has('crud') && intents.has('schema')) {
523
- body.push(`crud "/api/${namePlural}" ${schemaName}`);
1090
+ for (const entity of entities) {
1091
+ const ep = pluralize(entity.name.toLowerCase());
1092
+ body.push(`crud "/api/${ep}" ${entity.name}`);
1093
+ }
524
1094
  }
525
1095
 
526
- if (intents.has('auth')) {
1096
+ // Auth: full routes + role-based access
1097
+ if (hasAuth) {
527
1098
  body.push('auth JWT_SECRET:');
528
1099
  body.push(' protect "/api/*"');
529
1100
  body.push(' public "/api/auth/*"');
1101
+ body.push(' public "/api/health"');
1102
+ if (userEntity) {
1103
+ body.push('');
1104
+ body.push(...authRoutesBlock(userEntity.name));
1105
+ // Role-based admin check route
1106
+ body.push('');
1107
+ body.push('get "/api/admin/stats" (req, res):');
1108
+ body.push(' if req.user.role != "admin":');
1109
+ body.push(' ret.status(403) {error: "Admin access required"}');
1110
+ if (entities.length > 0) {
1111
+ const counts = entities.map(e => `${pluralize(e.name.toLowerCase())}: ${e.name}Store.count()`).join(', ');
1112
+ body.push(` ret {${counts}}`);
1113
+ } else {
1114
+ body.push(' ret {status: "ok"}');
1115
+ }
1116
+ }
530
1117
  }
531
1118
 
532
- if (intents.has('websocket')) {
1119
+ // Paginated list endpoints (override default CRUD list)
1120
+ if (intents.has('crud') && intents.has('schema')) {
533
1121
  body.push('');
1122
+ for (const entity of entities) {
1123
+ body.push(...paginatedListBlock(entity));
1124
+ body.push('');
1125
+ }
1126
+ }
1127
+
1128
+ // Nested routes for relationships
1129
+ for (const rel of relationships) {
1130
+ body.push(...nestedRouteBlock(rel.parent, rel.child, rel.fk));
1131
+ body.push('');
1132
+ }
1133
+
1134
+ // WebSocket
1135
+ if (intents.has('websocket')) {
534
1136
  body.push('ws "/ws":');
535
- body.push(' on "message" (data):');
1137
+ body.push(' on "connection" (socket):');
1138
+ body.push(' log("Client connected")');
1139
+ body.push(' on "message" (data, socket):');
536
1140
  body.push(' socket.send(data)');
1141
+ body.push(' on "close" (socket):');
1142
+ body.push(' log("Client disconnected")');
1143
+ body.push('');
537
1144
  }
538
1145
 
1146
+ // AI endpoint
539
1147
  if (intents.has('ai')) {
540
- body.push('');
541
1148
  body.push('post "/api/ask" (req, res):');
1149
+ body.push(' if not req.body.prompt:');
1150
+ body.push(' ret.status(400) {error: "prompt is required"}');
542
1151
  body.push(' str answer = await ai.ask(req.body.prompt)');
543
1152
  body.push(' ret {answer}');
1153
+ body.push('');
544
1154
  }
545
1155
 
546
- body.push('');
1156
+ // Soft delete + batch for each entity
1157
+ if (intents.has('crud') && intents.has('schema')) {
1158
+ for (const entity of entities) {
1159
+ body.push(...softDeleteRoutes(entity));
1160
+ body.push('');
1161
+ body.push(...batchRoutes(entity));
1162
+ body.push('');
1163
+ }
1164
+ }
1165
+
1166
+ // File upload
1167
+ if (intents.has('crud')) {
1168
+ body.push(...uploadRoute());
1169
+ body.push('');
1170
+ }
1171
+
1172
+ // Webhook
1173
+ if (intents.has('server')) {
1174
+ body.push(...webhookRoute());
1175
+ body.push('');
1176
+ }
1177
+
1178
+ // Health check
547
1179
  body.push('get "/api/health" (req, res):');
548
- if (intents.has('schema') && intents.has('crud')) {
549
- body.push(` ret {status: "ok", ${namePlural}: ${schemaName}Store.count()}`);
1180
+ if (intents.has('schema') && intents.has('crud') && entities.length > 0) {
1181
+ const counts = entities.map(e => `${pluralize(e.name.toLowerCase())}: ${e.name}Store.count()`).join(', ');
1182
+ body.push(` ret {status: "ok", ${counts}}`);
550
1183
  } else {
551
1184
  body.push(' ret {status: "ok"}');
552
1185
  }
553
1186
 
554
- sections.push(serverBlock(safeName(nameLower === 'item' ? 'app' : nameLower), params.port, body));
1187
+ const serverName = safeName(primaryLower === 'item' ? 'app' : primaryLower);
1188
+ sections.push(serverBlock(serverName, params.port, body));
1189
+ }
1190
+
1191
+ // Seed function
1192
+ if (intents.has('database') && intents.has('schema') && entities.length > 0) {
1193
+ sections.push(seedBlock(entities));
1194
+ }
1195
+
1196
+ // Audit log function
1197
+ if (hasAuth) {
1198
+ sections.push(auditFn());
555
1199
  }
556
1200
 
557
1201
  if (intents.has('bot')) {
@@ -559,7 +1203,7 @@ function compose(intents, params) {
559
1203
  }
560
1204
 
561
1205
  if (intents.has('cli')) {
562
- sections.push(cliBlock(nameLower));
1206
+ sections.push(cliBlock(primaryLower));
563
1207
  }
564
1208
 
565
1209
  if (intents.has('graphql')) {
@@ -567,15 +1211,18 @@ function compose(intents, params) {
567
1211
  }
568
1212
 
569
1213
  if (intents.has('page')) {
570
- sections.push(pageBlock(schemaName));
1214
+ sections.push(pageBlock(primaryName));
571
1215
  }
572
1216
 
573
1217
  if (intents.has('mail')) {
574
1218
  sections.push(mailBlock());
575
1219
  }
576
1220
 
577
- if (intents.has('test')) {
578
- sections.push(testBlock(nameLower));
1221
+ // Auto-generate entity tests when test intent or when entities exist
1222
+ if (intents.has('test') || (intents.has('schema') && intents.has('crud') && entities.length > 0)) {
1223
+ for (const entity of entities) {
1224
+ sections.push(entityTestBlock(entity));
1225
+ }
579
1226
  }
580
1227
 
581
1228
  return sections.join('\n\n') + '\n';
@@ -661,7 +1308,7 @@ function heal(code, err) {
661
1308
 
662
1309
  const KEYWORD_CHEATSHEET = {
663
1310
  'Intent': 'server, api, rest, bot, cli, page, test, database, ai, crud, auth, websocket, mail, graphql',
664
- 'Composite': 'todo, blog, chat, shop, fullstack, board',
1311
+ 'Composite': 'todo, blog, chat, shop, fullstack, board, crm, inventory, booking, sns',
665
1312
  'Platform': 'discord, slack, telegram, line',
666
1313
  'DB': 'sqlite, postgres',
667
1314
  'Japanese': 'サーバー, 認証, ログイン, 会員, CRUD, 管理, データベース, ボット, テスト, ページ',
@@ -677,6 +1324,431 @@ function buildSuggestion(mods) {
677
1324
  return lines.join('\n');
678
1325
  }
679
1326
 
1327
+ // ── Project Generator ──────────────────────────────────────
1328
+
1329
+ export function generateProject(instruction, options = {}) {
1330
+ const result = generate(instruction, options);
1331
+ const files = [];
1332
+ const intents = new Set(result.intents);
1333
+ const entities = result.entities || [];
1334
+ const primaryName = (entities[0] || 'app').toLowerCase();
1335
+ const projectName = safeName(primaryName) + '-app';
1336
+
1337
+ // Main app file
1338
+ files.push({ path: 'app.naide', content: result.code });
1339
+
1340
+ // Package.json
1341
+ const deps = {};
1342
+ if (intents.has('server')) deps.express = '^4.21.0';
1343
+ if (intents.has('websocket')) deps.ws = '^8.18.0';
1344
+ if (intents.has('auth')) {
1345
+ deps.jsonwebtoken = '^9.0.2';
1346
+ deps.bcryptjs = '^2.4.3';
1347
+ }
1348
+ if (intents.has('mail')) deps.nodemailer = '^6.9.0';
1349
+ if (result.analysis.entities.some(() => true)) deps.naider = '^1.21.0';
1350
+ const pkg = {
1351
+ name: projectName,
1352
+ version: '1.0.0',
1353
+ type: 'module',
1354
+ scripts: {
1355
+ dev: 'naide app.naide',
1356
+ build: 'naide app.naide --emit -o dist/app.mjs',
1357
+ start: 'node dist/app.mjs',
1358
+ test: 'naide test app.naide',
1359
+ seed: 'naide app.naide --run-seed',
1360
+ },
1361
+ dependencies: deps,
1362
+ };
1363
+ files.push({ path: 'package.json', content: JSON.stringify(pkg, null, 2) + '\n' });
1364
+
1365
+ // .env file
1366
+ const envL = [];
1367
+ envL.push(`PORT=${result.port}`);
1368
+ if (intents.has('auth')) envL.push('JWT_SECRET=change-me-in-production');
1369
+ if (intents.has('bot')) envL.push(`${(options.platform || 'DISCORD').toUpperCase()}_TOKEN=your-bot-token`);
1370
+ if (intents.has('mail')) {
1371
+ envL.push('SMTP_HOST=smtp.example.com');
1372
+ envL.push('SMTP_USER=user@example.com');
1373
+ envL.push('SMTP_PASS=password');
1374
+ }
1375
+ if (intents.has('ai')) envL.push('OPENAI_API_KEY=your-api-key');
1376
+ files.push({ path: '.env', content: envL.join('\n') + '\n' });
1377
+
1378
+ // .gitignore
1379
+ files.push({ path: '.gitignore', content: 'node_modules/\ndist/\ndata/\n.env\n*.mjs\n' });
1380
+
1381
+ // Dockerfile
1382
+ if (intents.has('server')) {
1383
+ const dockerfile = `FROM node:22-slim
1384
+ WORKDIR /app
1385
+ COPY package*.json ./
1386
+ RUN npm ci --omit=dev
1387
+ RUN npm install -g naider
1388
+ COPY . .
1389
+ RUN naide app.naide --emit -o dist/app.mjs
1390
+ EXPOSE ${result.port}
1391
+ CMD ["node", "dist/app.mjs"]
1392
+ `;
1393
+ files.push({ path: 'Dockerfile', content: dockerfile });
1394
+
1395
+ files.push({ path: '.dockerignore', content: 'node_modules\ndist\ndata\n.env\n.git\n' });
1396
+ }
1397
+
1398
+ // README
1399
+ const readmeLines = [`# ${projectName}\n`];
1400
+ readmeLines.push(`Generated by NAIDE Agent Coder (~~)\n`);
1401
+ readmeLines.push(`## Features\n`);
1402
+ for (const step of result.analysis.plan) readmeLines.push(`- ${step}`);
1403
+ if (entities.length > 0) {
1404
+ readmeLines.push(`\n## Entities\n`);
1405
+ for (const e of result.analysis.entities) readmeLines.push(`- **${e.name}** (${e.fieldCount} fields)`);
1406
+ }
1407
+ if (result.analysis.relationships.length > 0) {
1408
+ readmeLines.push(`\n## Relationships\n`);
1409
+ for (const r of result.analysis.relationships) readmeLines.push(`- ${r.parent} → ${r.child} (${r.fk})`);
1410
+ }
1411
+ readmeLines.push(`\n## Quick Start\n`);
1412
+ readmeLines.push('```bash');
1413
+ readmeLines.push('npm install');
1414
+ readmeLines.push('npm run dev');
1415
+ readmeLines.push('```\n');
1416
+ if (intents.has('server')) {
1417
+ readmeLines.push(`## API Endpoints\n`);
1418
+ readmeLines.push(`- \`GET /api/health\` — Health check`);
1419
+ for (const e of entities) {
1420
+ const ep = pluralize(e.toLowerCase());
1421
+ readmeLines.push(`- \`GET /api/${ep}?page=1&limit=20&search=\` — List ${ep} (paginated)`);
1422
+ readmeLines.push(`- \`POST /api/${ep}\` — Create ${e.toLowerCase()}`);
1423
+ readmeLines.push(`- \`GET /api/${ep}/:id\` — Get ${e.toLowerCase()}`);
1424
+ readmeLines.push(`- \`PUT /api/${ep}/:id\` — Update ${e.toLowerCase()}`);
1425
+ readmeLines.push(`- \`DELETE /api/${ep}/:id\` — Delete ${e.toLowerCase()}`);
1426
+ readmeLines.push(`- \`DELETE /api/${ep}/:id/soft\` — Soft delete ${e.toLowerCase()}`);
1427
+ readmeLines.push(`- \`PUT /api/${ep}/:id/restore\` — Restore ${e.toLowerCase()}`);
1428
+ readmeLines.push(`- \`POST /api/${ep}/batch\` — Batch create ${ep}`);
1429
+ readmeLines.push(`- \`DELETE /api/${ep}/batch\` — Batch delete ${ep}`);
1430
+ }
1431
+ for (const r of result.analysis.relationships) {
1432
+ const parentPlural = pluralize(r.parent.toLowerCase());
1433
+ const childPlural = pluralize(r.child.toLowerCase());
1434
+ readmeLines.push(`- \`GET /api/${parentPlural}/:id/${childPlural}\` — ${r.child}s by ${r.parent}`);
1435
+ }
1436
+ readmeLines.push(`- \`POST /api/upload\` — File upload`);
1437
+ readmeLines.push(`- \`POST /api/webhooks\` — Register webhook`);
1438
+ readmeLines.push(`- \`GET /api/webhooks/health\` — Webhook health`);
1439
+ if (intents.has('auth')) {
1440
+ readmeLines.push(`- \`POST /api/auth/register\` — Register`);
1441
+ readmeLines.push(`- \`POST /api/auth/login\` — Login`);
1442
+ readmeLines.push(`- \`GET /api/auth/me\` — Current user`);
1443
+ readmeLines.push(`- \`GET /api/admin/stats\` — Admin stats (role: admin)`);
1444
+ }
1445
+ }
1446
+ if (intents.has('server')) {
1447
+ readmeLines.push(`\n## Docker\n`);
1448
+ readmeLines.push('```bash');
1449
+ readmeLines.push(`docker build -t ${projectName} .`);
1450
+ readmeLines.push(`docker run -p ${result.port}:${result.port} --env-file .env ${projectName}`);
1451
+ readmeLines.push('```');
1452
+ }
1453
+ files.push({ path: 'README.md', content: readmeLines.join('\n') + '\n' });
1454
+
1455
+ // Build full entity objects for generators
1456
+ const entityObjs = entities.map(name => {
1457
+ const preset = SCHEMA_PRESETS[name.toLowerCase()];
1458
+ const fields = preset ? preset.map(f => ({ name: f[0], type: f[1], modifiers: f.slice(2) })) : defaultFields(name);
1459
+ if (!fields.some(f => f.name === 'deleted')) fields.push({ name: 'deleted', type: 'bool', modifiers: [] });
1460
+ return { name, fields };
1461
+ });
1462
+
1463
+ // OpenAPI spec
1464
+ if (intents.has('server') && entityObjs.length > 0) {
1465
+ const spec = openApiSpec(entityObjs, intents, { port: result.port }, result.relationships || []);
1466
+ files.push({ path: 'openapi.json', content: JSON.stringify(spec, null, 2) + '\n' });
1467
+ }
1468
+
1469
+ // Admin dashboard
1470
+ if (intents.has('server') && entityObjs.length > 0) {
1471
+ files.push({ path: 'admin.html', content: adminPage(entityObjs, intents) });
1472
+ }
1473
+
1474
+ // API client
1475
+ if (intents.has('server')) {
1476
+ files.push({ path: 'client.mjs', content: apiClientCode(entityObjs, intents, result.port) });
1477
+ }
1478
+
1479
+ return { ...result, files };
1480
+ }
1481
+
1482
+ // ── Project Update Mode ───────────────────────────────────
1483
+
1484
+ export function updateProject(projectDir, instruction, options = {}) {
1485
+ const appPath = projectDir + '/app.naide';
1486
+ const existing = _existsFS(appPath) ? _readFS(appPath, 'utf-8') : '';
1487
+
1488
+ const existingState = existing ? parseExistingProject(existing) : { entities: [], intents: [], port: 3000 };
1489
+ const newResult = generate(instruction, options);
1490
+ const newEntities = (newResult.entities || []).filter(e => !existingState.entities.includes(e));
1491
+
1492
+ const allEntities = [...existingState.entities, ...newEntities];
1493
+ const combinedIntents = [...new Set([...existingState.intents, ...newResult.intents])];
1494
+ const extras = [];
1495
+ if (combinedIntents.includes('auth')) extras.push('auth');
1496
+ if (combinedIntents.includes('websocket')) extras.push('websocket');
1497
+ if (combinedIntents.includes('database')) extras.push('database');
1498
+ const fullInstruction = 'REST API for ' + allEntities.join(' and ') + (extras.length ? ' with ' + extras.join(' and ') : '');
1499
+
1500
+ const combined = generate(fullInstruction, options);
1501
+ const finalCode = combined.code;
1502
+
1503
+ const files = [];
1504
+ files.push({ path: 'app.naide', content: finalCode });
1505
+
1506
+ const intents = new Set(combined.intents);
1507
+ const entities = combined.entities || allEntities;
1508
+ const relationships = combined.relationships || detectRelationships(entities);
1509
+
1510
+ const primaryName = (entities[0] || 'app').toLowerCase();
1511
+ const projectName = safeName(primaryName) + '-app';
1512
+ const port = existingState.port || combined.port || 3000;
1513
+
1514
+ const deps = {};
1515
+ if (intents.has('server')) deps.express = '^4.21.0';
1516
+ if (intents.has('websocket')) deps.ws = '^8.18.0';
1517
+ if (intents.has('auth')) {
1518
+ deps.jsonwebtoken = '^9.0.2';
1519
+ deps.bcryptjs = '^2.4.3';
1520
+ }
1521
+ if (intents.has('mail')) deps.nodemailer = '^6.9.0';
1522
+ if (entities.length > 0) deps.naider = '^1.21.0';
1523
+
1524
+ const existingPkgPath = projectDir + '/package.json';
1525
+ let pkg;
1526
+ if (_existsFS(existingPkgPath)) {
1527
+ pkg = JSON.parse(_readFS(existingPkgPath, 'utf-8'));
1528
+ pkg.dependencies = { ...pkg.dependencies, ...deps };
1529
+ } else {
1530
+ pkg = {
1531
+ name: projectName, version: '1.0.0', type: 'module',
1532
+ scripts: { dev: 'naide app.naide', build: 'naide app.naide --emit -o dist/app.mjs',
1533
+ start: 'node dist/app.mjs', test: 'naide test app.naide', seed: 'naide app.naide --run-seed' },
1534
+ dependencies: deps,
1535
+ };
1536
+ }
1537
+ files.push({ path: 'package.json', content: JSON.stringify(pkg, null, 2) + '\n' });
1538
+
1539
+ const envL = [];
1540
+ envL.push(`PORT=${port}`);
1541
+ if (intents.has('auth')) envL.push('JWT_SECRET=change-me-in-production');
1542
+ if (intents.has('bot')) envL.push(`${(options.platform || 'DISCORD').toUpperCase()}_TOKEN=your-bot-token`);
1543
+ if (intents.has('mail')) { envL.push('SMTP_HOST=smtp.example.com'); envL.push('SMTP_USER=user@example.com'); envL.push('SMTP_PASS=password'); }
1544
+ if (intents.has('ai')) envL.push('OPENAI_API_KEY=your-api-key');
1545
+ const existingEnvPath = projectDir + '/.env';
1546
+ if (_existsFS(existingEnvPath)) {
1547
+ const existingEnv = _readFS(existingEnvPath, 'utf-8');
1548
+ const existingKeys = new Set(existingEnv.split('\n').map(l => l.split('=')[0]).filter(Boolean));
1549
+ const newEnvLines = envL.filter(l => !existingKeys.has(l.split('=')[0]));
1550
+ if (newEnvLines.length > 0) {
1551
+ files.push({ path: '.env', content: existingEnv.trimEnd() + '\n' + newEnvLines.join('\n') + '\n' });
1552
+ }
1553
+ } else {
1554
+ files.push({ path: '.env', content: envL.join('\n') + '\n' });
1555
+ }
1556
+
1557
+ files.push({ path: '.gitignore', content: 'node_modules/\ndist/\ndata/\n.env\n*.mjs\n' });
1558
+
1559
+ if (intents.has('server')) {
1560
+ files.push({ path: 'Dockerfile', content: `FROM node:22-slim\nWORKDIR /app\nCOPY package*.json ./\nRUN npm ci --omit=dev\nRUN npm install -g naider\nCOPY . .\nRUN naide app.naide --emit -o dist/app.mjs\nEXPOSE ${port}\nCMD ["node", "dist/app.mjs"]\n` });
1561
+ files.push({ path: '.dockerignore', content: 'node_modules\ndist\ndata\n.env\n.git\n' });
1562
+ }
1563
+
1564
+ const readmeLines = [`# ${projectName}\n`];
1565
+ readmeLines.push(`Generated by NAIDE Agent Coder (~~)\n`);
1566
+ readmeLines.push(`## Entities\n`);
1567
+ for (const e of entities) {
1568
+ const preset = SCHEMA_PRESETS[e.toLowerCase()];
1569
+ const fc = preset ? preset.length : 3;
1570
+ readmeLines.push(`- **${e}** (${fc} fields)`);
1571
+ }
1572
+ if (relationships.length > 0) {
1573
+ readmeLines.push(`\n## Relationships\n`);
1574
+ for (const r of relationships) readmeLines.push(`- ${r.parent} → ${r.child} (${r.fk})`);
1575
+ }
1576
+ readmeLines.push(`\n## Quick Start\n`);
1577
+ readmeLines.push('```bash\nnpm install\nnpm run dev\n```\n');
1578
+ if (intents.has('server')) {
1579
+ readmeLines.push(`## API Endpoints\n`);
1580
+ readmeLines.push(`- \`GET /api/health\` — Health check`);
1581
+ for (const e of entities) {
1582
+ const ep = pluralize(e.toLowerCase());
1583
+ readmeLines.push(`- \`GET /api/${ep}?page=1&limit=20&search=\` — List ${ep} (paginated)`);
1584
+ readmeLines.push(`- \`POST /api/${ep}\` — Create ${e.toLowerCase()}`);
1585
+ readmeLines.push(`- \`GET /api/${ep}/:id\` — Get ${e.toLowerCase()}`);
1586
+ readmeLines.push(`- \`PUT /api/${ep}/:id\` — Update ${e.toLowerCase()}`);
1587
+ readmeLines.push(`- \`DELETE /api/${ep}/:id\` — Delete ${e.toLowerCase()}`);
1588
+ readmeLines.push(`- \`DELETE /api/${ep}/:id/soft\` — Soft delete`);
1589
+ readmeLines.push(`- \`PUT /api/${ep}/:id/restore\` — Restore`);
1590
+ readmeLines.push(`- \`POST /api/${ep}/batch\` — Batch create`);
1591
+ readmeLines.push(`- \`DELETE /api/${ep}/batch\` — Batch delete`);
1592
+ }
1593
+ for (const r of relationships) {
1594
+ readmeLines.push(`- \`GET /api/${pluralize(r.parent.toLowerCase())}/:id/${pluralize(r.child.toLowerCase())}\` — ${r.child}s by ${r.parent}`);
1595
+ }
1596
+ readmeLines.push(`- \`POST /api/upload\` — File upload`);
1597
+ readmeLines.push(`- \`POST /api/webhooks\` — Register webhook`);
1598
+ if (intents.has('auth')) {
1599
+ readmeLines.push(`- \`POST /api/auth/register\` — Register`);
1600
+ readmeLines.push(`- \`POST /api/auth/login\` — Login`);
1601
+ readmeLines.push(`- \`GET /api/auth/me\` — Current user`);
1602
+ readmeLines.push(`- \`GET /api/admin/stats\` — Admin stats`);
1603
+ }
1604
+ }
1605
+ if (intents.has('server')) {
1606
+ readmeLines.push(`\n## Docker\n`);
1607
+ readmeLines.push('```bash');
1608
+ readmeLines.push(`docker build -t ${projectName} .`);
1609
+ readmeLines.push(`docker run -p ${port}:${port} --env-file .env ${projectName}`);
1610
+ readmeLines.push('```');
1611
+ }
1612
+ files.push({ path: 'README.md', content: readmeLines.join('\n') + '\n' });
1613
+
1614
+ const entityObjs = entities.map(name => {
1615
+ const preset = SCHEMA_PRESETS[name.toLowerCase()];
1616
+ const fields = preset ? preset.map(f => ({ name: f[0], type: f[1], modifiers: f.slice(2) })) : defaultFields(name);
1617
+ if (!fields.some(f => f.name === 'deleted')) fields.push({ name: 'deleted', type: 'bool', modifiers: [] });
1618
+ return { name, fields };
1619
+ });
1620
+
1621
+ if (intents.has('server') && entityObjs.length > 0) {
1622
+ files.push({ path: 'openapi.json', content: JSON.stringify(openApiSpec(entityObjs, intents, { port }, relationships), null, 2) + '\n' });
1623
+ files.push({ path: 'admin.html', content: adminPage(entityObjs, intents) });
1624
+ }
1625
+ if (intents.has('server')) {
1626
+ files.push({ path: 'client.mjs', content: apiClientCode(entityObjs, intents, port) });
1627
+ }
1628
+
1629
+ const added = existing
1630
+ ? entities.filter(e => !existing.includes(`schema ${e}`))
1631
+ : entities;
1632
+ const analysis = {
1633
+ instruction,
1634
+ entities: entityObjs.map(e => ({ name: e.name, fieldCount: e.fields.length })),
1635
+ relationships,
1636
+ plan: [],
1637
+ added,
1638
+ };
1639
+ if (added.length > 0) analysis.plan.push(`Added: ${added.join(', ')}`);
1640
+ if (existing) analysis.plan.push('Merged with existing app.naide');
1641
+ analysis.plan.push(`Total entities: ${entities.length}`);
1642
+ analysis.plan.push(`Supporting files regenerated`);
1643
+
1644
+ return {
1645
+ code: finalCode, valid: combined.valid, fixed: combined.fixed, repairs: combined.repairs,
1646
+ intents: [...intents], entities, relationships, port,
1647
+ files, analysis, action: existing ? 'updated' : 'created',
1648
+ };
1649
+ }
1650
+
1651
+ function parseExistingProject(code) {
1652
+ const lines = code.split('\n');
1653
+ const entities = [];
1654
+ const intents = new Set();
1655
+ let port = 3000;
1656
+
1657
+ for (const line of lines) {
1658
+ const sm = line.match(/^schema\s+(\w+)\s*:/);
1659
+ if (sm && sm[1] !== 'AuditLog') {
1660
+ entities.push(sm[1]);
1661
+ intents.add('schema');
1662
+ }
1663
+ if (line.match(/^server\s/)) intents.add('server');
1664
+ if (line.match(/\bcrud\b/)) intents.add('crud');
1665
+ if (line.match(/\bauth\b/)) intents.add('auth');
1666
+ if (line.match(/\bdb\b/)) intents.add('database');
1667
+ if (line.match(/\bws\b/)) intents.add('websocket');
1668
+ if (line.match(/\bbot\b/)) intents.add('bot');
1669
+ const pm = line.match(/port\s+(\d+)/);
1670
+ if (pm) port = parseInt(pm[1], 10);
1671
+ }
1672
+
1673
+ return { entities, intents: [...intents], port };
1674
+ }
1675
+
1676
+ // ── File Write Mode ────────────────────────────────────────
1677
+
1678
+ export function writeToFile(filePath, instruction, options = {}) {
1679
+ const result = generate(instruction, options);
1680
+ const code = result.code;
1681
+
1682
+ if (!_existsFS(filePath)) {
1683
+ _writeFS(filePath, code, 'utf-8');
1684
+ return { ...result, action: 'created', path: filePath };
1685
+ }
1686
+
1687
+ const existing = _readFS(filePath, 'utf-8');
1688
+ const mode = options.mode || 'append';
1689
+
1690
+ if (mode === 'replace') {
1691
+ _writeFS(filePath, code, 'utf-8');
1692
+ return { ...result, action: 'replaced', path: filePath };
1693
+ }
1694
+
1695
+ // Append mode: merge intelligently
1696
+ const merged = mergeCode(existing, code);
1697
+ const validated = validate(merged);
1698
+ _writeFS(filePath, validated.code, 'utf-8');
1699
+ return { ...result, code: validated.code, valid: validated.valid, fixed: validated.fixed,
1700
+ repairs: validated.repairs, action: 'merged', path: filePath };
1701
+ }
1702
+
1703
+ function mergeCode(existing, generated) {
1704
+ const existingLines = existing.split('\n');
1705
+ const generatedLines = generated.split('\n');
1706
+
1707
+ const existingSchemas = new Set();
1708
+ const existingServers = new Set();
1709
+ for (const line of existingLines) {
1710
+ const schemaMatch = line.match(/^schema\s+(\w+)\s*:/);
1711
+ if (schemaMatch) existingSchemas.add(schemaMatch[1]);
1712
+ const serverMatch = line.match(/^server\s+(\w+)\s/);
1713
+ if (serverMatch) existingServers.add(serverMatch[1]);
1714
+ }
1715
+
1716
+ // Filter out duplicate top-level blocks from generated
1717
+ const filteredLines = [];
1718
+ let skipBlock = false;
1719
+ let blockIndent = -1;
1720
+ for (let i = 0; i < generatedLines.length; i++) {
1721
+ const line = generatedLines[i];
1722
+ const schemaMatch = line.match(/^schema\s+(\w+)\s*:/);
1723
+ const serverMatch = line.match(/^server\s+(\w+)\s/);
1724
+
1725
+ if (schemaMatch && existingSchemas.has(schemaMatch[1])) {
1726
+ skipBlock = true;
1727
+ blockIndent = 0;
1728
+ continue;
1729
+ }
1730
+ if (serverMatch && existingServers.has(serverMatch[1])) {
1731
+ // For server blocks, extract only new routes
1732
+ skipBlock = true;
1733
+ blockIndent = 0;
1734
+ continue;
1735
+ }
1736
+
1737
+ if (skipBlock) {
1738
+ const indent = line.match(/^(\s*)/)[1].length;
1739
+ if (line.trim() === '' || indent > blockIndent) continue;
1740
+ skipBlock = false;
1741
+ }
1742
+
1743
+ filteredLines.push(line);
1744
+ }
1745
+
1746
+ const newCode = filteredLines.join('\n').trim();
1747
+ if (!newCode) return existing;
1748
+
1749
+ return existing.trimEnd() + '\n\n' + newCode + '\n';
1750
+ }
1751
+
680
1752
  // ── Pre-Processor (for ~~ in source files) ──────────────────
681
1753
 
682
1754
  export function expandDirectives(source) {
@@ -703,6 +1775,47 @@ export function expandDirectives(source) {
703
1775
  return { source: result.join('\n'), expanded };
704
1776
  }
705
1777
 
1778
+ // ── Analysis Builder ───────────────────────────────────────
1779
+
1780
+ function buildAnalysis(instruction, intents, params) {
1781
+ const plan = [];
1782
+
1783
+ if (params.entities.length > 0) {
1784
+ for (const e of params.entities) {
1785
+ plan.push(`Schema "${e.name}" (${e.fields.map(f => f.name).join(', ')})`);
1786
+ }
1787
+ }
1788
+
1789
+ if (params.relationships.length > 0) {
1790
+ for (const r of params.relationships) {
1791
+ plan.push(`Relation: ${r.parent} → ${r.child} (${r.fk})`);
1792
+ }
1793
+ }
1794
+
1795
+ if (intents.has('database')) plan.push(`Database: ${params.dbType || 'file-based'}`);
1796
+ if (intents.has('server')) plan.push(`Server on port ${params.port}`);
1797
+ if (intents.has('crud')) plan.push(`CRUD endpoints for ${params.entities.map(e => pluralize(e.name.toLowerCase())).join(', ')} (paginated)`);
1798
+ if (intents.has('auth')) plan.push('Auth: JWT + register/login/me + role-based admin');
1799
+ if (params.relationships.length > 0) plan.push(`Nested routes: ${params.relationships.map(r => `/${pluralize(r.parent.toLowerCase())}/:id/${pluralize(r.child.toLowerCase())}`).join(', ')}`);
1800
+ if (intents.has('websocket'))plan.push('WebSocket: real-time messaging');
1801
+ if (intents.has('ai')) plan.push('AI endpoint: /api/ask');
1802
+ if (intents.has('bot')) plan.push(`Bot: ${params.platform || 'discord'}`);
1803
+ if (intents.has('mail')) plan.push('Mail: SMTP configuration');
1804
+ if (intents.has('graphql')) plan.push('GraphQL endpoint');
1805
+ if (intents.has('page')) plan.push('Page: HTML generation');
1806
+ if (intents.has('cli')) plan.push('CLI app with flags');
1807
+ if (intents.has('database') && intents.has('schema')) plan.push('Seed: sample data function');
1808
+ plan.push(`Tests: ${params.entities.length} entity CRUD test suites`);
1809
+
1810
+ return {
1811
+ instruction,
1812
+ entities: params.entities.map(e => ({ name: e.name, fieldCount: e.fields.length })),
1813
+ relationships: params.relationships,
1814
+ features: [...intents],
1815
+ plan,
1816
+ };
1817
+ }
1818
+
706
1819
  // ── Public API ──────────────────────────────────────────────
707
1820
 
708
1821
  export function generate(instruction, options = {}) {
@@ -711,11 +1824,18 @@ export function generate(instruction, options = {}) {
711
1824
  const params = extractParams(words, instruction, intents, mods);
712
1825
 
713
1826
  if (options.port) params.port = options.port;
714
- if (options.schemaName) params.schemaName = options.schemaName;
715
- if (options.fields) params.fields = options.fields;
1827
+ if (options.schemaName) {
1828
+ params.schemaName = options.schemaName;
1829
+ if (params.entities.length > 0) params.entities[0].name = options.schemaName;
1830
+ }
1831
+ if (options.fields) {
1832
+ params.fields = options.fields;
1833
+ if (params.entities.length > 0) params.entities[0].fields = options.fields;
1834
+ }
716
1835
 
717
1836
  let code = compose(intents, params);
718
1837
  const result = validate(code);
1838
+ const analysis = buildAnalysis(instruction, intents, params);
719
1839
 
720
1840
  return {
721
1841
  code: result.code,
@@ -724,8 +1844,11 @@ export function generate(instruction, options = {}) {
724
1844
  repairs: result.repairs,
725
1845
  intents: [...intents],
726
1846
  schema: params.schemaName,
1847
+ entities: params.entities.map(e => e.name),
1848
+ relationships: params.relationships,
727
1849
  port: params.port,
728
1850
  suggestion: buildSuggestion(mods),
729
1851
  fallback: !!mods.fallback,
1852
+ analysis,
730
1853
  };
731
1854
  }