@gurulu/cli 0.4.0 → 0.4.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.
Files changed (38) hide show
  1. package/dist/commands/add-server.js +13 -6
  2. package/dist/commands/alerts.d.ts +5 -0
  3. package/dist/commands/alerts.js +43 -15
  4. package/dist/commands/audiences.d.ts +3 -0
  5. package/dist/commands/audiences.js +34 -7
  6. package/dist/commands/events.d.ts +6 -0
  7. package/dist/commands/events.js +182 -1
  8. package/dist/commands/experiments.d.ts +4 -0
  9. package/dist/commands/experiments.js +46 -15
  10. package/dist/commands/funnels.d.ts +17 -0
  11. package/dist/commands/funnels.js +203 -0
  12. package/dist/commands/goals.d.ts +18 -0
  13. package/dist/commands/goals.js +214 -0
  14. package/dist/commands/install.d.ts +8 -0
  15. package/dist/commands/install.js +57 -1
  16. package/dist/commands/sourcemap.d.ts +17 -5
  17. package/dist/commands/sourcemap.js +73 -6
  18. package/dist/commands/watch.d.ts +45 -0
  19. package/dist/commands/watch.js +258 -0
  20. package/dist/frameworks/detect.js +29 -7
  21. package/dist/index.js +158 -13
  22. package/package.json +1 -1
  23. package/scripts/gurulu-agentic-install.mjs +275 -3
  24. package/scripts/gurulu-scan.lib.cjs +539 -19
  25. package/scripts/patches/auto-instrument/ast-helper.cjs +158 -10
  26. package/scripts/patches/auto-instrument/astro.cjs +12 -6
  27. package/scripts/patches/auto-instrument/express.cjs +23 -8
  28. package/scripts/patches/auto-instrument/fastify.cjs +7 -3
  29. package/scripts/patches/auto-instrument/hono.cjs +392 -0
  30. package/scripts/patches/auto-instrument/index.cjs +2 -0
  31. package/scripts/patches/auto-instrument/nestjs.cjs +7 -3
  32. package/scripts/patches/auto-instrument/nextjs-app-router.cjs +40 -13
  33. package/scripts/patches/auto-instrument/nextjs-pages.cjs +23 -10
  34. package/scripts/patches/auto-instrument/remix.cjs +7 -3
  35. package/scripts/patches/auto-instrument/sdk-helper-map.cjs +241 -0
  36. package/scripts/patches/auto-instrument/sveltekit.cjs +7 -3
  37. package/scripts/patches/auto-instrument/vue.cjs +7 -3
  38. package/scripts/patches/index.cjs +6 -0
@@ -8,6 +8,7 @@
8
8
  'use strict';
9
9
 
10
10
  const fs = require('node:fs').promises;
11
+ const fsSync = require('node:fs');
11
12
  const path = require('node:path');
12
13
 
13
14
  const SCAN_VERSION = '2';
@@ -22,24 +23,79 @@ const SKIP_DIRS = new Set([
22
23
  '.turbo',
23
24
  '.vercel',
24
25
  'coverage',
26
+ // Mobile
27
+ 'Pods', // iOS CocoaPods
28
+ '.gradle', // Android Gradle cache
29
+ '.dart_tool', // Flutter
30
+ '.pub-cache', // Flutter pub cache
25
31
  ]);
26
32
 
27
33
  const CODE_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']);
28
34
 
35
+ // Mobile code extensions for screen/route scanning
36
+ const MOBILE_CODE_EXTS = new Set([
37
+ '.ts', '.tsx', '.js', '.jsx', // React Native
38
+ '.swift', // iOS
39
+ '.kt', '.java', // Android
40
+ '.dart', // Flutter
41
+ ]);
42
+
29
43
  // ---------------------------------------------------------------------------
30
44
  // Framework detection
31
45
  // ---------------------------------------------------------------------------
32
- function detectFramework(deps = {}) {
46
+ function detectFramework(deps = {}, rootPath) {
33
47
  const has = (name) => Object.prototype.hasOwnProperty.call(deps, name);
34
48
  const ver = (name) =>
35
49
  deps[name] ? String(deps[name]).replace(/^[^\d]*/, '') || null : null;
36
50
 
51
+ // --- Mobile framework detection (must come before web frameworks) ---
52
+
53
+ // React Native: has react-native in deps — check BEFORE other React-based frameworks
54
+ if (has('react-native')) {
55
+ return { name: 'react-native', version: ver('react-native') };
56
+ }
57
+
58
+ if (rootPath) {
59
+ // Flutter: check for pubspec.yaml
60
+ if (fsSync.existsSync(path.join(rootPath, 'pubspec.yaml'))) {
61
+ return { name: 'flutter', version: null };
62
+ }
63
+
64
+ // iOS: check for .xcodeproj, .xcworkspace, or Package.swift
65
+ try {
66
+ const entries = fsSync.readdirSync(rootPath);
67
+ const hasXcode = entries.some(
68
+ (e) => e.endsWith('.xcodeproj') || e.endsWith('.xcworkspace'),
69
+ );
70
+ const hasPackageSwift = fsSync.existsSync(path.join(rootPath, 'Package.swift'));
71
+ if (hasXcode || hasPackageSwift) {
72
+ return { name: 'ios-swift', version: null };
73
+ }
74
+ } catch {
75
+ /* skip */
76
+ }
77
+
78
+ // Android: check for build.gradle / build.gradle.kts (only when no package.json deps,
79
+ // otherwise it could be a monorepo with an android/ subfolder)
80
+ if (Object.keys(deps).length === 0) {
81
+ if (
82
+ fsSync.existsSync(path.join(rootPath, 'build.gradle')) ||
83
+ fsSync.existsSync(path.join(rootPath, 'build.gradle.kts')) ||
84
+ fsSync.existsSync(path.join(rootPath, 'app', 'build.gradle'))
85
+ ) {
86
+ return { name: 'android-kotlin', version: null };
87
+ }
88
+ }
89
+ }
90
+
91
+ // --- Web framework detection ---
37
92
  if (has('next')) return { name: 'nextjs', version: ver('next') };
38
93
  if (has('@nestjs/core')) return { name: 'nestjs', version: ver('@nestjs/core') };
39
94
  if (has('@remix-run/dev')) return { name: 'remix', version: ver('@remix-run/dev') };
40
95
  if (has('@sveltejs/kit')) return { name: 'sveltekit', version: ver('@sveltejs/kit') };
41
96
  if (has('astro')) return { name: 'astro', version: ver('astro') };
42
97
  if (has('fastify')) return { name: 'fastify', version: ver('fastify') };
98
+ if (has('hono')) return { name: 'hono', version: ver('hono') };
43
99
  if (has('express')) return { name: 'express', version: ver('express') };
44
100
  if (has('vite') && has('react')) return { name: 'vite-react', version: ver('vite') };
45
101
  if (has('vue')) return { name: 'vue', version: ver('vue') };
@@ -52,6 +108,13 @@ function detectFramework(deps = {}) {
52
108
  function detectORM(deps = {}, schemaHints = {}) {
53
109
  const has = (name) => Object.prototype.hasOwnProperty.call(deps, name);
54
110
 
111
+ // Mobile ORM / persistence (package.json-based, e.g. React Native)
112
+ if (has('realm') || has('@realm/react')) return { name: 'realm', schemaPath: null };
113
+ if (has('react-native-sqlite-storage') || has('react-native-sqlite-2') || has('expo-sqlite')) {
114
+ return { name: 'sqlite', schemaPath: null };
115
+ }
116
+
117
+ // Web ORMs
55
118
  if (has('@prisma/client') || has('prisma')) {
56
119
  return { name: 'prisma', schemaPath: schemaHints.prisma || null };
57
120
  }
@@ -65,6 +128,63 @@ function detectORM(deps = {}, schemaHints = {}) {
65
128
  return { name: 'unknown', schemaPath: null };
66
129
  }
67
130
 
131
+ /**
132
+ * detectMobileORM — filesystem-based ORM detection for mobile projects
133
+ * that don't use package.json (iOS/Android/Flutter).
134
+ */
135
+ function detectMobileORM(rootPath, framework) {
136
+ if (!framework || !rootPath) return { name: 'unknown', schemaPath: null };
137
+
138
+ if (framework.name === 'ios-swift') {
139
+ // Core Data: look for .xcdatamodeld directories
140
+ try {
141
+ const entries = fsSync.readdirSync(rootPath);
142
+ if (entries.some((e) => e.endsWith('.xcdatamodeld'))) {
143
+ return { name: 'coredata', schemaPath: null };
144
+ }
145
+ } catch {
146
+ /* skip */
147
+ }
148
+ // Realm for iOS (check via SPM Package.swift or Podfile)
149
+ for (const f of ['Package.swift', 'Podfile']) {
150
+ try {
151
+ const content = fsSync.readFileSync(path.join(rootPath, f), 'utf8');
152
+ if (/realm/i.test(content)) return { name: 'realm', schemaPath: null };
153
+ } catch {
154
+ /* skip */
155
+ }
156
+ }
157
+ }
158
+
159
+ if (framework.name === 'android-kotlin') {
160
+ // Room: check build.gradle for androidx.room dependency
161
+ for (const gf of ['build.gradle', 'build.gradle.kts', 'app/build.gradle', 'app/build.gradle.kts']) {
162
+ try {
163
+ const content = fsSync.readFileSync(path.join(rootPath, gf), 'utf8');
164
+ if (/androidx\.room/.test(content)) return { name: 'room', schemaPath: null };
165
+ if (/realm/i.test(content)) return { name: 'realm', schemaPath: null };
166
+ } catch {
167
+ /* skip */
168
+ }
169
+ }
170
+ }
171
+
172
+ if (framework.name === 'flutter') {
173
+ // sqflite / drift / floor — check pubspec.yaml
174
+ try {
175
+ const pubspec = fsSync.readFileSync(path.join(rootPath, 'pubspec.yaml'), 'utf8');
176
+ if (/\bsqflite\b/.test(pubspec)) return { name: 'sqlite', schemaPath: null };
177
+ if (/\bdrift\b/.test(pubspec)) return { name: 'drift', schemaPath: null };
178
+ if (/\bfloor\b/.test(pubspec)) return { name: 'floor', schemaPath: null };
179
+ if (/\brealm\b/.test(pubspec)) return { name: 'realm', schemaPath: null };
180
+ } catch {
181
+ /* skip */
182
+ }
183
+ }
184
+
185
+ return { name: 'unknown', schemaPath: null };
186
+ }
187
+
68
188
  // ---------------------------------------------------------------------------
69
189
  // Auth detection
70
190
  // ---------------------------------------------------------------------------
@@ -113,6 +233,308 @@ async function walk(dir, collected = []) {
113
233
  return collected;
114
234
  }
115
235
 
236
+ // ---------------------------------------------------------------------------
237
+ // Route detection — Express
238
+ // ---------------------------------------------------------------------------
239
+ const EXPRESS_ROUTE_PATTERN =
240
+ /(?:app|router)\.(get|post|put|delete|patch|options|head)\s*\(\s*['"`]([^'"`]+)['"`]/gi;
241
+ const EXPRESS_USE_PATTERN =
242
+ /(?:app|router)\.use\s*\(\s*['"`]([^'"`]+)['"`]/gi;
243
+
244
+ async function scanExpressRoutes(rootPath) {
245
+ const ROUTE_LIMIT = 100;
246
+ const candidateDirs = ['src/routes', 'routes', 'src/controllers', 'src/api', 'app'];
247
+ const seen = new Set();
248
+ const routes = [];
249
+
250
+ // Collect prefix hints from app.use('/prefix', router)
251
+ const prefixes = [];
252
+
253
+ for (const base of candidateDirs) {
254
+ const dir = path.join(rootPath, base);
255
+ let files;
256
+ try {
257
+ files = await walk(dir);
258
+ } catch {
259
+ continue;
260
+ }
261
+ for (const abs of files) {
262
+ if (!CODE_EXTS.has(path.extname(abs))) continue;
263
+ let content;
264
+ try {
265
+ content = await fs.readFile(abs, 'utf8');
266
+ } catch {
267
+ continue;
268
+ }
269
+ if (content.length > 1_500_000) continue;
270
+
271
+ // Collect use() prefixes
272
+ const useRe = new RegExp(EXPRESS_USE_PATTERN.source, EXPRESS_USE_PATTERN.flags);
273
+ let um;
274
+ while ((um = useRe.exec(content)) !== null) {
275
+ const prefix = um[1].replace(/\/+$/, '');
276
+ if (prefix && prefix !== '/') prefixes.push(prefix);
277
+ }
278
+
279
+ // Collect routes
280
+ const re = new RegExp(EXPRESS_ROUTE_PATTERN.source, EXPRESS_ROUTE_PATTERN.flags);
281
+ let m;
282
+ while ((m = re.exec(content)) !== null) {
283
+ const method = m[1].toUpperCase();
284
+ const routePath = m[2];
285
+ const key = `${method}:${routePath}`;
286
+ if (seen.has(key)) continue;
287
+ seen.add(key);
288
+ routes.push({ method, path: routePath });
289
+ if (routes.length >= ROUTE_LIMIT) return { routes };
290
+ }
291
+ }
292
+ }
293
+
294
+ // Also scan entry files at root and src/ level
295
+ for (const baseDir of ['', 'src']) {
296
+ for (const entry of ['index', 'server', 'app', 'main']) {
297
+ for (const ext of ['.ts', '.js', '.mjs', '.cjs']) {
298
+ const abs = path.join(rootPath, baseDir, `${entry}${ext}`);
299
+ let content;
300
+ try {
301
+ content = await fs.readFile(abs, 'utf8');
302
+ } catch {
303
+ continue;
304
+ }
305
+ if (content.length > 1_500_000) continue;
306
+
307
+ const useRe = new RegExp(EXPRESS_USE_PATTERN.source, EXPRESS_USE_PATTERN.flags);
308
+ let um;
309
+ while ((um = useRe.exec(content)) !== null) {
310
+ const prefix = um[1].replace(/\/+$/, '');
311
+ if (prefix && prefix !== '/') prefixes.push(prefix);
312
+ }
313
+
314
+ const re = new RegExp(EXPRESS_ROUTE_PATTERN.source, EXPRESS_ROUTE_PATTERN.flags);
315
+ let m;
316
+ while ((m = re.exec(content)) !== null) {
317
+ const method = m[1].toUpperCase();
318
+ const routePath = m[2];
319
+ const key = `${method}:${routePath}`;
320
+ if (seen.has(key)) continue;
321
+ seen.add(key);
322
+ routes.push({ method, path: routePath });
323
+ if (routes.length >= ROUTE_LIMIT) return { routes };
324
+ }
325
+ }
326
+ }
327
+ }
328
+
329
+ return { routes };
330
+ }
331
+
332
+ // ---------------------------------------------------------------------------
333
+ // Route detection — Fastify
334
+ // ---------------------------------------------------------------------------
335
+ const FASTIFY_PATTERN =
336
+ /fastify\.(get|post|put|delete|patch|options|head)\s*\(\s*['"`]([^'"`]+)['"`]/gi;
337
+ const FASTIFY_ROUTE_PATTERN =
338
+ /fastify\.route\s*\(\s*\{[^}]*method\s*:\s*['"`](\w+)['"`][^}]*url\s*:\s*['"`]([^'"`]+)['"`]/gi;
339
+
340
+ async function scanFastifyRoutes(rootPath) {
341
+ const ROUTE_LIMIT = 100;
342
+ const candidateDirs = ['src/routes', 'routes', 'src/controllers', 'src/api', 'app'];
343
+ const seen = new Set();
344
+ const routes = [];
345
+
346
+ const scanFile = async (abs) => {
347
+ if (!CODE_EXTS.has(path.extname(abs))) return;
348
+ let content;
349
+ try {
350
+ content = await fs.readFile(abs, 'utf8');
351
+ } catch {
352
+ return;
353
+ }
354
+ if (content.length > 1_500_000) return;
355
+
356
+ // Shorthand: fastify.get('/path', ...)
357
+ const re1 = new RegExp(FASTIFY_PATTERN.source, FASTIFY_PATTERN.flags);
358
+ let m;
359
+ while ((m = re1.exec(content)) !== null) {
360
+ const method = m[1].toUpperCase();
361
+ const routePath = m[2];
362
+ const key = `${method}:${routePath}`;
363
+ if (seen.has(key)) continue;
364
+ seen.add(key);
365
+ routes.push({ method, path: routePath });
366
+ if (routes.length >= ROUTE_LIMIT) return;
367
+ }
368
+
369
+ // fastify.route({ method: 'GET', url: '/path' })
370
+ const re2 = new RegExp(FASTIFY_ROUTE_PATTERN.source, FASTIFY_ROUTE_PATTERN.flags);
371
+ while ((m = re2.exec(content)) !== null) {
372
+ const method = m[1].toUpperCase();
373
+ const routePath = m[2];
374
+ const key = `${method}:${routePath}`;
375
+ if (seen.has(key)) continue;
376
+ seen.add(key);
377
+ routes.push({ method, path: routePath });
378
+ if (routes.length >= ROUTE_LIMIT) return;
379
+ }
380
+ };
381
+
382
+ for (const base of candidateDirs) {
383
+ const dir = path.join(rootPath, base);
384
+ let files;
385
+ try {
386
+ files = await walk(dir);
387
+ } catch {
388
+ continue;
389
+ }
390
+ for (const abs of files) {
391
+ await scanFile(abs);
392
+ if (routes.length >= ROUTE_LIMIT) return { routes };
393
+ }
394
+ }
395
+
396
+ // Scan entry files at root and src/ level
397
+ for (const baseDir of ['', 'src']) {
398
+ for (const entry of ['index', 'server', 'app', 'main']) {
399
+ for (const ext of ['.ts', '.js', '.mjs', '.cjs']) {
400
+ await scanFile(path.join(rootPath, baseDir, `${entry}${ext}`));
401
+ if (routes.length >= ROUTE_LIMIT) return { routes };
402
+ }
403
+ }
404
+ }
405
+
406
+ return { routes };
407
+ }
408
+
409
+ // ---------------------------------------------------------------------------
410
+ // Route detection — Hono
411
+ // ---------------------------------------------------------------------------
412
+ const HONO_PATTERN =
413
+ /(?:app|api|router|server)\.(get|post|put|delete|patch|options|head)\s*\(\s*['"`]([^'"`]+)['"`]/gi;
414
+
415
+ async function scanHonoRoutes(rootPath) {
416
+ const ROUTE_LIMIT = 100;
417
+ const candidateDirs = ['src/routes', 'routes', 'src/api', 'app', 'src/app'];
418
+ const seen = new Set();
419
+ const routes = [];
420
+
421
+ const scanFile = async (abs) => {
422
+ if (!CODE_EXTS.has(path.extname(abs))) return;
423
+ let content;
424
+ try {
425
+ content = await fs.readFile(abs, 'utf8');
426
+ } catch {
427
+ return;
428
+ }
429
+ if (content.length > 1_500_000) return;
430
+ // Must reference Hono somewhere in the file to avoid false positives
431
+ // from Express/Fastify files using the same `.get(...)` pattern.
432
+ if (!content.includes('Hono') && !content.includes('hono')) return;
433
+
434
+ const re1 = new RegExp(HONO_PATTERN.source, HONO_PATTERN.flags);
435
+ let m;
436
+ while ((m = re1.exec(content)) !== null) {
437
+ const method = m[1].toUpperCase();
438
+ const routePath = m[2];
439
+ const key = `${method}:${routePath}`;
440
+ if (seen.has(key)) continue;
441
+ seen.add(key);
442
+ routes.push({ method, path: routePath });
443
+ if (routes.length >= ROUTE_LIMIT) return;
444
+ }
445
+ };
446
+
447
+ for (const base of candidateDirs) {
448
+ const dir = path.join(rootPath, base);
449
+ let files;
450
+ try {
451
+ files = await walk(dir);
452
+ } catch {
453
+ continue;
454
+ }
455
+ for (const abs of files) {
456
+ await scanFile(abs);
457
+ if (routes.length >= ROUTE_LIMIT) return { routes };
458
+ }
459
+ }
460
+
461
+ // Scan entry files at root and src/ level
462
+ for (const baseDir of ['', 'src']) {
463
+ for (const entry of ['index', 'server', 'app', 'main']) {
464
+ for (const ext of ['.ts', '.js', '.mjs', '.cjs']) {
465
+ await scanFile(path.join(rootPath, baseDir, `${entry}${ext}`));
466
+ if (routes.length >= ROUTE_LIMIT) return { routes };
467
+ }
468
+ }
469
+ }
470
+
471
+ return { routes };
472
+ }
473
+
474
+ // ---------------------------------------------------------------------------
475
+ // Route detection — NestJS
476
+ // ---------------------------------------------------------------------------
477
+ const NEST_DECORATOR =
478
+ /@(Get|Post|Put|Delete|Patch|Head|Options)\s*\(\s*['"`]?([^'"`\)]*)/gi;
479
+ const NEST_CONTROLLER =
480
+ /@Controller\s*\(\s*['"`]([^'"`]+)/gi;
481
+
482
+ async function scanNestRoutes(rootPath) {
483
+ const ROUTE_LIMIT = 100;
484
+ const candidateDirs = ['src', 'src/modules', 'src/controllers', 'src/api', 'app'];
485
+ const seen = new Set();
486
+ const routes = [];
487
+
488
+ const scanFile = async (abs) => {
489
+ if (!CODE_EXTS.has(path.extname(abs))) return;
490
+ let content;
491
+ try {
492
+ content = await fs.readFile(abs, 'utf8');
493
+ } catch {
494
+ return;
495
+ }
496
+ if (content.length > 1_500_000) return;
497
+
498
+ // Find @Controller prefix
499
+ const controllerRe = new RegExp(NEST_CONTROLLER.source, NEST_CONTROLLER.flags);
500
+ const controllerMatch = controllerRe.exec(content);
501
+ const controllerPrefix = controllerMatch
502
+ ? '/' + controllerMatch[1].replace(/^\/+|\/+$/g, '')
503
+ : '';
504
+
505
+ // Find route decorators
506
+ const decoratorRe = new RegExp(NEST_DECORATOR.source, NEST_DECORATOR.flags);
507
+ let m;
508
+ while ((m = decoratorRe.exec(content)) !== null) {
509
+ const method = m[1].toUpperCase();
510
+ const rawPath = (m[2] || '').trim().replace(/['"`]/g, '');
511
+ const suffix = rawPath ? '/' + rawPath.replace(/^\/+/, '') : '';
512
+ const routePath = (controllerPrefix + suffix) || '/';
513
+ const key = `${method}:${routePath}`;
514
+ if (seen.has(key)) continue;
515
+ seen.add(key);
516
+ routes.push({ method, path: routePath });
517
+ if (routes.length >= ROUTE_LIMIT) return;
518
+ }
519
+ };
520
+
521
+ for (const base of candidateDirs) {
522
+ const dir = path.join(rootPath, base);
523
+ let files;
524
+ try {
525
+ files = await walk(dir);
526
+ } catch {
527
+ continue;
528
+ }
529
+ for (const abs of files) {
530
+ await scanFile(abs);
531
+ if (routes.length >= ROUTE_LIMIT) return { routes };
532
+ }
533
+ }
534
+
535
+ return { routes };
536
+ }
537
+
116
538
  // ---------------------------------------------------------------------------
117
539
  // Route detection (Next.js App + Pages router)
118
540
  // ---------------------------------------------------------------------------
@@ -139,17 +561,95 @@ function routePathFromPagesRouterFile(relFile) {
139
561
  return '/' + segs.join('/');
140
562
  }
141
563
 
142
- async function detectRoutes(rootPath, framework) {
143
- if (!framework || framework.name !== 'nextjs') {
144
- if (
145
- framework &&
146
- ['express', 'fastify', 'nestjs'].includes(framework.name)
147
- ) {
148
- return {
149
- routes: [],
150
- note: `detection_not_implemented_for_${framework.name}`,
151
- };
564
+ // ---------------------------------------------------------------------------
565
+ // Mobile screen/route detection
566
+ // ---------------------------------------------------------------------------
567
+
568
+ // React Native: Stack.Screen, Tab.Screen, Drawer.Screen name patterns
569
+ const RN_SCREEN_RE = /(?:Stack|Tab|Drawer)\.Screen\s+name\s*=\s*['"`]([^'"`]+)['"`]/g;
570
+ // iOS: UIViewController subclasses
571
+ const IOS_VC_RE = /class\s+(\w+)\s*:\s*(?:UI)?ViewController/g;
572
+ // Android: Activity / Fragment classes
573
+ const ANDROID_ACTIVITY_RE = /class\s+(\w+)\s*:\s*(?:AppCompat)?Activity\b/g;
574
+ const ANDROID_FRAGMENT_RE = /class\s+(\w+)\s*:\s*(?:Fragment)\b/g;
575
+ // Flutter: StatelessWidget / StatefulWidget
576
+ const FLUTTER_WIDGET_RE = /class\s+(\w+)\s+extends\s+Stateful?Widget\b/g;
577
+
578
+ async function scanMobileScreens(rootPath, framework) {
579
+ const SCREEN_LIMIT = 100;
580
+ const seen = new Set();
581
+ const routes = [];
582
+
583
+ let files;
584
+ try {
585
+ files = await walk(rootPath);
586
+ } catch {
587
+ return { routes: [] };
588
+ }
589
+
590
+ for (const abs of files) {
591
+ const ext = path.extname(abs);
592
+ if (!MOBILE_CODE_EXTS.has(ext)) continue;
593
+ let content;
594
+ try {
595
+ content = await fs.readFile(abs, 'utf8');
596
+ } catch {
597
+ continue;
598
+ }
599
+ if (content.length > 1_500_000) continue;
600
+
601
+ const rel = path.relative(rootPath, abs);
602
+ let patterns = [];
603
+
604
+ if (framework.name === 'react-native') {
605
+ patterns = [RN_SCREEN_RE];
606
+ } else if (framework.name === 'ios-swift') {
607
+ patterns = [IOS_VC_RE];
608
+ } else if (framework.name === 'android-kotlin') {
609
+ patterns = [ANDROID_ACTIVITY_RE, ANDROID_FRAGMENT_RE];
610
+ } else if (framework.name === 'flutter') {
611
+ patterns = [FLUTTER_WIDGET_RE];
152
612
  }
613
+
614
+ for (const pattern of patterns) {
615
+ const re = new RegExp(pattern.source, pattern.flags);
616
+ let m;
617
+ while ((m = re.exec(content)) !== null) {
618
+ const name = m[1];
619
+ if (seen.has(name)) continue;
620
+ seen.add(name);
621
+ routes.push({ method: 'SCREEN', path: name, file: rel });
622
+ if (routes.length >= SCREEN_LIMIT) return { routes };
623
+ }
624
+ }
625
+ }
626
+
627
+ routes.sort((a, b) => a.path.localeCompare(b.path));
628
+ return { routes };
629
+ }
630
+
631
+ async function detectRoutes(rootPath, framework) {
632
+ if (!framework) return { routes: [] };
633
+
634
+ // Mobile frameworks — detect screens instead of HTTP routes
635
+ if (['react-native', 'ios-swift', 'android-kotlin', 'flutter'].includes(framework.name)) {
636
+ return scanMobileScreens(rootPath, framework);
637
+ }
638
+
639
+ if (framework.name === 'express') {
640
+ return scanExpressRoutes(rootPath);
641
+ }
642
+ if (framework.name === 'fastify') {
643
+ return scanFastifyRoutes(rootPath);
644
+ }
645
+ if (framework.name === 'hono') {
646
+ return scanHonoRoutes(rootPath);
647
+ }
648
+ if (framework.name === 'nestjs') {
649
+ return scanNestRoutes(rootPath);
650
+ }
651
+
652
+ if (framework.name !== 'nextjs') {
153
653
  return { routes: [] };
154
654
  }
155
655
 
@@ -727,7 +1227,7 @@ async function extractFolderStructureHints(rootPath, framework) {
727
1227
  /* skip */
728
1228
  }
729
1229
  }
730
- } else if (framework && ['express', 'fastify', 'nestjs'].includes(framework.name)) {
1230
+ } else if (framework && ['express', 'fastify', 'hono', 'nestjs'].includes(framework.name)) {
731
1231
  for (const base of ['src/routes', 'routes', 'src/controllers', 'src/modules']) {
732
1232
  const root = path.join(rootPath, base);
733
1233
  try {
@@ -867,11 +1367,17 @@ async function scanRepo(rootPath, { log = () => {} } = {}) {
867
1367
  }
868
1368
  const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
869
1369
 
870
- const framework = detectFramework(deps);
1370
+ const framework = detectFramework(deps, abs);
871
1371
  log(`[scan] framework: ${framework.name} ${framework.version || ''}`);
872
1372
 
1373
+ const isMobile = ['react-native', 'ios-swift', 'android-kotlin', 'flutter'].includes(framework.name);
1374
+
873
1375
  const schemaHints = await detectSchemaHints(abs);
874
- const orm = detectORM(deps, schemaHints);
1376
+ let orm = detectORM(deps, schemaHints);
1377
+ // For non-package.json mobile projects, fall back to filesystem-based ORM detection
1378
+ if (isMobile && orm.name === 'unknown') {
1379
+ orm = detectMobileORM(abs, framework);
1380
+ }
875
1381
  log(`[scan] orm: ${orm.name}`);
876
1382
 
877
1383
  const auth = detectAuth(deps);
@@ -886,7 +1392,13 @@ async function scanRepo(rootPath, { log = () => {} } = {}) {
886
1392
  }
887
1393
  log(`[scan] walk root: ${path.relative(abs, scanRoot) || '.'}`);
888
1394
 
889
- const { routes, note: routeNote } = await detectRoutes(scanRoot, framework);
1395
+ // Next.js routes live under src/app or app/ (walk handles both from scanRoot).
1396
+ // Express/Fastify/Hono/NestJS scanners specify their own candidate dirs
1397
+ // relative to project root, so pass `abs` for those frameworks.
1398
+ // Mobile frameworks also scan from project root for screen detection.
1399
+ const routeScanRoot =
1400
+ framework && ['express', 'fastify', 'hono', 'nestjs', 'react-native', 'ios-swift', 'android-kotlin', 'flutter'].includes(framework.name) ? abs : scanRoot;
1401
+ const { routes, note: routeNote } = await detectRoutes(routeScanRoot, framework);
890
1402
  log(`[scan] routes: ${routes.length}${routeNote ? ` (${routeNote})` : ''}`);
891
1403
 
892
1404
  const mutations = await detectMutations(scanRoot);
@@ -923,12 +1435,15 @@ async function scanRepo(rootPath, { log = () => {} } = {}) {
923
1435
  /* skip */
924
1436
  }
925
1437
 
926
- const normalizedRoutes = (routes || []).flatMap((r) =>
927
- (r.methods && r.methods.length ? r.methods : ['*']).map((method) => ({
1438
+ const normalizedRoutes = (routes || []).flatMap((r) => {
1439
+ // Express/Fastify/NestJS routes have { method, path } directly.
1440
+ if (r.method) return [{ method: r.method, path: r.path }];
1441
+ // Next.js routes have { methods: [...], path, file }.
1442
+ return (r.methods && r.methods.length ? r.methods : ['*']).map((method) => ({
928
1443
  method,
929
1444
  path: r.path,
930
- })),
931
- );
1445
+ }));
1446
+ });
932
1447
 
933
1448
  const normalizedMutations = (mutations || []).map((m) => ({
934
1449
  file: m.file,
@@ -971,8 +1486,13 @@ module.exports = {
971
1486
  SCAN_VERSION,
972
1487
  detectFramework,
973
1488
  detectORM,
1489
+ detectMobileORM,
974
1490
  detectAuth,
975
1491
  detectRoutes,
1492
+ scanExpressRoutes,
1493
+ scanFastifyRoutes,
1494
+ scanNestRoutes,
1495
+ scanMobileScreens,
976
1496
  detectMutations,
977
1497
  scanRepo,
978
1498
  // Phase 18.6 A1