@gurulu/cli 0.4.6 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/LICENSE +92 -0
  2. package/README.md +35 -106
  3. package/dist/bin.d.ts +3 -0
  4. package/dist/bin.d.ts.map +1 -0
  5. package/dist/bin.js +25410 -0
  6. package/dist/commands/auth.d.ts +23 -20
  7. package/dist/commands/auth.d.ts.map +1 -0
  8. package/dist/commands/doctor.d.ts +20 -6
  9. package/dist/commands/doctor.d.ts.map +1 -0
  10. package/dist/commands/init.d.ts +25 -11
  11. package/dist/commands/init.d.ts.map +1 -0
  12. package/dist/commands/pull.d.ts +13 -0
  13. package/dist/commands/pull.d.ts.map +1 -0
  14. package/dist/commands/push.d.ts +40 -0
  15. package/dist/commands/push.d.ts.map +1 -0
  16. package/dist/commands/validate.d.ts +36 -0
  17. package/dist/commands/validate.d.ts.map +1 -0
  18. package/dist/index.d.ts +4 -1
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +24985 -853
  21. package/dist/lib/api.d.ts +139 -0
  22. package/dist/lib/api.d.ts.map +1 -0
  23. package/dist/lib/codegen.d.ts +4 -0
  24. package/dist/lib/codegen.d.ts.map +1 -0
  25. package/dist/lib/config.d.ts +43 -0
  26. package/dist/lib/config.d.ts.map +1 -0
  27. package/package.json +40 -20
  28. package/bin/gurulu.js +0 -2
  29. package/dist/api-client.d.ts +0 -33
  30. package/dist/api-client.js +0 -175
  31. package/dist/commands/add-server.d.ts +0 -9
  32. package/dist/commands/add-server.js +0 -162
  33. package/dist/commands/alerts.d.ts +0 -27
  34. package/dist/commands/alerts.js +0 -309
  35. package/dist/commands/api-keys.d.ts +0 -20
  36. package/dist/commands/api-keys.js +0 -130
  37. package/dist/commands/attribution.d.ts +0 -22
  38. package/dist/commands/attribution.js +0 -111
  39. package/dist/commands/audiences.d.ts +0 -23
  40. package/dist/commands/audiences.js +0 -243
  41. package/dist/commands/audit.d.ts +0 -20
  42. package/dist/commands/audit.js +0 -130
  43. package/dist/commands/auth.js +0 -249
  44. package/dist/commands/chat.d.ts +0 -18
  45. package/dist/commands/chat.js +0 -117
  46. package/dist/commands/config.d.ts +0 -10
  47. package/dist/commands/config.js +0 -92
  48. package/dist/commands/consent.d.ts +0 -27
  49. package/dist/commands/consent.js +0 -233
  50. package/dist/commands/conversion-paths.d.ts +0 -19
  51. package/dist/commands/conversion-paths.js +0 -55
  52. package/dist/commands/db.d.ts +0 -25
  53. package/dist/commands/db.js +0 -330
  54. package/dist/commands/destinations.d.ts +0 -20
  55. package/dist/commands/destinations.js +0 -191
  56. package/dist/commands/doctor.js +0 -360
  57. package/dist/commands/errors.d.ts +0 -27
  58. package/dist/commands/errors.js +0 -121
  59. package/dist/commands/events.d.ts +0 -33
  60. package/dist/commands/events.js +0 -349
  61. package/dist/commands/experiments.d.ts +0 -22
  62. package/dist/commands/experiments.js +0 -264
  63. package/dist/commands/funnels.d.ts +0 -17
  64. package/dist/commands/funnels.js +0 -203
  65. package/dist/commands/goals.d.ts +0 -18
  66. package/dist/commands/goals.js +0 -214
  67. package/dist/commands/heatmap.d.ts +0 -27
  68. package/dist/commands/heatmap.js +0 -112
  69. package/dist/commands/identity.d.ts +0 -29
  70. package/dist/commands/identity.js +0 -328
  71. package/dist/commands/init.js +0 -215
  72. package/dist/commands/insights.d.ts +0 -10
  73. package/dist/commands/insights.js +0 -65
  74. package/dist/commands/install.d.ts +0 -259
  75. package/dist/commands/install.js +0 -1590
  76. package/dist/commands/login.d.ts +0 -20
  77. package/dist/commands/login.js +0 -170
  78. package/dist/commands/logout.d.ts +0 -10
  79. package/dist/commands/logout.js +0 -41
  80. package/dist/commands/playground.d.ts +0 -11
  81. package/dist/commands/playground.js +0 -47
  82. package/dist/commands/releases.d.ts +0 -17
  83. package/dist/commands/releases.js +0 -54
  84. package/dist/commands/replay.d.ts +0 -18
  85. package/dist/commands/replay.js +0 -64
  86. package/dist/commands/secrets.d.ts +0 -19
  87. package/dist/commands/secrets.js +0 -145
  88. package/dist/commands/sites.d.ts +0 -18
  89. package/dist/commands/sites.js +0 -139
  90. package/dist/commands/skad.d.ts +0 -18
  91. package/dist/commands/skad.js +0 -53
  92. package/dist/commands/sourcemap.d.ts +0 -33
  93. package/dist/commands/sourcemap.js +0 -204
  94. package/dist/commands/status.d.ts +0 -7
  95. package/dist/commands/status.js +0 -136
  96. package/dist/commands/upgrade.d.ts +0 -21
  97. package/dist/commands/upgrade.js +0 -183
  98. package/dist/commands/warehouse.d.ts +0 -20
  99. package/dist/commands/warehouse.js +0 -65
  100. package/dist/commands/warehouses.d.ts +0 -17
  101. package/dist/commands/warehouses.js +0 -182
  102. package/dist/commands/watch.d.ts +0 -45
  103. package/dist/commands/watch.js +0 -258
  104. package/dist/commands/whoami.d.ts +0 -9
  105. package/dist/commands/whoami.js +0 -50
  106. package/dist/config.d.ts +0 -75
  107. package/dist/config.js +0 -329
  108. package/dist/frameworks/detect.d.ts +0 -8
  109. package/dist/frameworks/detect.js +0 -444
  110. package/dist/install-intent-proposal.d.ts +0 -99
  111. package/dist/install-intent-proposal.js +0 -202
  112. package/dist/utils/api.d.ts +0 -20
  113. package/dist/utils/api.js +0 -47
  114. package/dist/utils/config.d.ts +0 -13
  115. package/dist/utils/config.js +0 -30
  116. package/dist/utils/confirm.d.ts +0 -17
  117. package/dist/utils/confirm.js +0 -40
  118. package/dist/utils/dry-run.d.ts +0 -20
  119. package/dist/utils/dry-run.js +0 -67
  120. package/dist/utils/from-file.d.ts +0 -9
  121. package/dist/utils/from-file.js +0 -72
  122. package/dist/utils/redact.d.ts +0 -14
  123. package/dist/utils/redact.js +0 -48
  124. package/dist/utils/ui.d.ts +0 -14
  125. package/dist/utils/ui.js +0 -59
  126. package/scripts/.gitkeep +0 -0
  127. package/scripts/README-gurulu-agentic-install.md +0 -114
  128. package/scripts/README-gurulu-scan.md +0 -98
  129. package/scripts/audit-cli-scopes.mjs +0 -204
  130. package/scripts/backfill-tenant-id.mjs +0 -172
  131. package/scripts/backfill-tenant-links.ts +0 -252
  132. package/scripts/backup-clickhouse.sh +0 -27
  133. package/scripts/backup-postgres.sh +0 -19
  134. package/scripts/bootstrap-runtime-schema.mjs +0 -87
  135. package/scripts/bootstrap-stripe.mjs +0 -158
  136. package/scripts/gurulu-agentic-install.lib.cjs +0 -762
  137. package/scripts/gurulu-agentic-install.mjs +0 -623
  138. package/scripts/gurulu-scan.lib.cjs +0 -1509
  139. package/scripts/gurulu-scan.mjs +0 -91
  140. package/scripts/gurulu-verify-install.lib.cjs +0 -334
  141. package/scripts/gurulu-verify-install.mjs +0 -59
  142. package/scripts/init-ssl.sh +0 -26
  143. package/scripts/migrate-flow-graph-enums.sh +0 -86
  144. package/scripts/monitor-disk.sh +0 -24
  145. package/scripts/patches/astro.patch.cjs +0 -74
  146. package/scripts/patches/auto-instrument/ast-helper.cjs +0 -480
  147. package/scripts/patches/auto-instrument/astro.cjs +0 -273
  148. package/scripts/patches/auto-instrument/express.cjs +0 -383
  149. package/scripts/patches/auto-instrument/fastify.cjs +0 -262
  150. package/scripts/patches/auto-instrument/hono.cjs +0 -392
  151. package/scripts/patches/auto-instrument/index.cjs +0 -80
  152. package/scripts/patches/auto-instrument/nestjs.cjs +0 -286
  153. package/scripts/patches/auto-instrument/nextjs-app-router.cjs +0 -345
  154. package/scripts/patches/auto-instrument/nextjs-pages.cjs +0 -361
  155. package/scripts/patches/auto-instrument/remix.cjs +0 -168
  156. package/scripts/patches/auto-instrument/sdk-helper-map.cjs +0 -241
  157. package/scripts/patches/auto-instrument/singleton-helper.cjs +0 -193
  158. package/scripts/patches/auto-instrument/sveltekit.cjs +0 -161
  159. package/scripts/patches/auto-instrument/vite-react.cjs +0 -37
  160. package/scripts/patches/auto-instrument/vue.cjs +0 -196
  161. package/scripts/patches/express.patch.cjs +0 -99
  162. package/scripts/patches/fastify.patch.cjs +0 -108
  163. package/scripts/patches/index.cjs +0 -300
  164. package/scripts/patches/nestjs.patch.cjs +0 -112
  165. package/scripts/patches/nextjs-app-router.patch.cjs +0 -97
  166. package/scripts/patches/nextjs-pages.patch.cjs +0 -97
  167. package/scripts/patches/remix.patch.cjs +0 -75
  168. package/scripts/patches/sveltekit.patch.cjs +0 -72
  169. package/scripts/patches/vite-react.patch.cjs +0 -73
  170. package/scripts/patches/vue.patch.cjs +0 -82
  171. package/scripts/renew-ssl.sh +0 -14
  172. package/scripts/resolve-migration.sh +0 -23
  173. package/scripts/seed-cli-dev-keys.mjs +0 -130
  174. package/scripts/seed-test-data.mjs +0 -391
  175. package/scripts/spike-browserless.ts +0 -65
  176. package/scripts/tenant-pivot-consistency-check.mjs +0 -205
  177. package/scripts/tenant-pivot-phase-3-cleanup.lib.cjs +0 -258
  178. package/scripts/tenant-pivot-phase-3-cleanup.mjs +0 -98
  179. package/scripts/test-identity-resolution.ts +0 -804
  180. package/scripts/validate-gurulu-schemas.mjs +0 -79
@@ -1,1509 +0,0 @@
1
- // gurulu-scan.lib.cjs — detection logic for the repo scanner spike (Phase 13 B2).
2
- //
3
- // Exported as CommonJS so it can be loaded both by the ESM CLI wrapper
4
- // (`scripts/gurulu-scan.mjs`) via native ESM interop AND by ts-jest tests via
5
- // `require()`. Keeping the library in a single module avoids duplicating the
6
- // detection heuristics across CLI and tests.
7
-
8
- 'use strict';
9
-
10
- const fs = require('node:fs').promises;
11
- const fsSync = require('node:fs');
12
- const path = require('node:path');
13
-
14
- const SCAN_VERSION = '2';
15
-
16
- const SKIP_DIRS = new Set([
17
- 'node_modules',
18
- '.next',
19
- '.git',
20
- 'dist',
21
- 'build',
22
- 'out',
23
- '.turbo',
24
- '.vercel',
25
- 'coverage',
26
- // Mobile
27
- 'Pods', // iOS CocoaPods
28
- '.gradle', // Android Gradle cache
29
- '.dart_tool', // Flutter
30
- '.pub-cache', // Flutter pub cache
31
- ]);
32
-
33
- const CODE_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']);
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
-
43
- // ---------------------------------------------------------------------------
44
- // Framework detection
45
- // ---------------------------------------------------------------------------
46
- function detectFramework(deps = {}, rootPath) {
47
- const has = (name) => Object.prototype.hasOwnProperty.call(deps, name);
48
- const ver = (name) =>
49
- deps[name] ? String(deps[name]).replace(/^[^\d]*/, '') || null : null;
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 ---
92
- if (has('next')) return { name: 'nextjs', version: ver('next') };
93
- if (has('@nestjs/core')) return { name: 'nestjs', version: ver('@nestjs/core') };
94
- if (has('@remix-run/dev')) return { name: 'remix', version: ver('@remix-run/dev') };
95
- if (has('@sveltejs/kit')) return { name: 'sveltekit', version: ver('@sveltejs/kit') };
96
- if (has('astro')) return { name: 'astro', version: ver('astro') };
97
- if (has('fastify')) return { name: 'fastify', version: ver('fastify') };
98
- if (has('hono')) return { name: 'hono', version: ver('hono') };
99
- if (has('express')) return { name: 'express', version: ver('express') };
100
- if (has('vite') && has('react')) return { name: 'vite-react', version: ver('vite') };
101
- if (has('vue')) return { name: 'vue', version: ver('vue') };
102
- return { name: 'unknown', version: null };
103
- }
104
-
105
- // ---------------------------------------------------------------------------
106
- // ORM detection
107
- // ---------------------------------------------------------------------------
108
- function detectORM(deps = {}, schemaHints = {}) {
109
- const has = (name) => Object.prototype.hasOwnProperty.call(deps, name);
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
118
- if (has('@prisma/client') || has('prisma')) {
119
- return { name: 'prisma', schemaPath: schemaHints.prisma || null };
120
- }
121
- if (has('drizzle-orm')) {
122
- return { name: 'drizzle', schemaPath: schemaHints.drizzle || null };
123
- }
124
- if (has('typeorm')) return { name: 'typeorm', schemaPath: null };
125
- if (has('mongoose')) return { name: 'mongoose', schemaPath: null };
126
- if (has('sequelize')) return { name: 'sequelize', schemaPath: null };
127
- if (has('kysely')) return { name: 'kysely', schemaPath: null };
128
- return { name: 'unknown', schemaPath: null };
129
- }
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
-
188
- // ---------------------------------------------------------------------------
189
- // Auth detection
190
- // ---------------------------------------------------------------------------
191
- function detectAuth(deps = {}) {
192
- const has = (name) => Object.prototype.hasOwnProperty.call(deps, name);
193
-
194
- if (has('next-auth') || has('@auth/core')) return { name: 'nextauth' };
195
- if (has('@clerk/nextjs') || has('@clerk/clerk-sdk-node') || has('@clerk/clerk-js')) {
196
- return { name: 'clerk' };
197
- }
198
- if (
199
- has('@supabase/supabase-js') &&
200
- Object.keys(deps).some((d) => d.startsWith('@supabase/auth-helpers'))
201
- ) {
202
- return { name: 'supabase' };
203
- }
204
- if (has('firebase-admin') || has('firebase')) return { name: 'firebase' };
205
- if (has('lucia')) return { name: 'lucia' };
206
- if (has('passport')) return { name: 'passport' };
207
- if (has('jsonwebtoken')) return { name: 'custom-jwt' };
208
- return { name: 'unknown' };
209
- }
210
-
211
- // ---------------------------------------------------------------------------
212
- // File walking
213
- // ---------------------------------------------------------------------------
214
- async function walk(dir, collected = []) {
215
- let entries;
216
- try {
217
- entries = await fs.readdir(dir, { withFileTypes: true });
218
- } catch {
219
- return collected;
220
- }
221
- for (const entry of entries) {
222
- if (SKIP_DIRS.has(entry.name)) continue;
223
- if (entry.name.startsWith('.') && entry.name !== '.gurulu') {
224
- if (entry.name === '.next' || entry.name === '.git' || entry.name === '.turbo') continue;
225
- }
226
- const full = path.join(dir, entry.name);
227
- if (entry.isDirectory()) {
228
- await walk(full, collected);
229
- } else if (entry.isFile()) {
230
- collected.push(full);
231
- }
232
- }
233
- return collected;
234
- }
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
-
538
- // ---------------------------------------------------------------------------
539
- // Route detection (Next.js App + Pages router)
540
- // ---------------------------------------------------------------------------
541
- function routePathFromAppRouterFile(relFile) {
542
- const parts = relFile.split(path.sep);
543
- const appIdx = parts.indexOf('app');
544
- if (appIdx === -1) return null;
545
- const afterApp = parts.slice(appIdx + 1);
546
- if (!/^route\.(ts|tsx|js|jsx|mjs|cjs)$/.test(afterApp[afterApp.length - 1])) return null;
547
- const segments = afterApp
548
- .slice(0, -1)
549
- .filter((seg) => !(seg.startsWith('(') && seg.endsWith(')')) && !seg.startsWith('@'));
550
- return '/' + segments.join('/');
551
- }
552
-
553
- function routePathFromPagesRouterFile(relFile) {
554
- const parts = relFile.split(path.sep);
555
- const pagesIdx = parts.indexOf('pages');
556
- if (pagesIdx === -1) return null;
557
- const afterPages = parts.slice(pagesIdx + 1);
558
- if (afterPages[0] !== 'api') return null;
559
- const last = afterPages[afterPages.length - 1].replace(/\.(ts|tsx|js|jsx|mjs|cjs)$/, '');
560
- const segs = afterPages.slice(0, -1).concat(last === 'index' ? [] : [last]);
561
- return '/' + segs.join('/');
562
- }
563
-
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];
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') {
653
- return { routes: [] };
654
- }
655
-
656
- const files = await walk(rootPath);
657
- const routes = [];
658
-
659
- for (const abs of files) {
660
- const rel = path.relative(rootPath, abs);
661
- const base = path.basename(abs);
662
-
663
- if (
664
- /(^|[\\/])app[\\/]/.test(rel) &&
665
- /[\\/]api[\\/]/.test(rel) &&
666
- /^route\.(ts|tsx|js|jsx)$/.test(base)
667
- ) {
668
- const routePath = routePathFromAppRouterFile(rel);
669
- if (!routePath) continue;
670
- let content = '';
671
- try {
672
- content = await fs.readFile(abs, 'utf8');
673
- } catch {
674
- continue;
675
- }
676
- const methods = [];
677
- for (const m of ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']) {
678
- const re = new RegExp(
679
- `export\\s+(?:async\\s+)?(?:function\\s+${m}\\b|const\\s+${m}\\s*=)`,
680
- );
681
- if (re.test(content)) methods.push(m);
682
- }
683
- routes.push({
684
- path: routePath,
685
- methods: methods.length ? methods : ['*'],
686
- file: rel,
687
- });
688
- continue;
689
- }
690
-
691
- if (
692
- /(^|[\\/])pages[\\/]api[\\/]/.test(rel) &&
693
- /\.(ts|tsx|js|jsx)$/.test(base) &&
694
- base !== '_middleware.ts'
695
- ) {
696
- const routePath = routePathFromPagesRouterFile(rel);
697
- if (!routePath) continue;
698
- let content = '';
699
- try {
700
- content = await fs.readFile(abs, 'utf8');
701
- } catch {
702
- continue;
703
- }
704
- const methods = [];
705
- const methodRe = /req\.method\s*===?\s*['"`](GET|POST|PUT|DELETE|PATCH)['"`]/g;
706
- let match;
707
- while ((match = methodRe.exec(content)) !== null) {
708
- if (!methods.includes(match[1])) methods.push(match[1]);
709
- }
710
- routes.push({
711
- path: routePath,
712
- methods: methods.length ? methods : ['*'],
713
- file: rel,
714
- });
715
- }
716
- }
717
-
718
- routes.sort((a, b) => (a.path + a.file).localeCompare(b.path + b.file));
719
- return { routes };
720
- }
721
-
722
- // ---------------------------------------------------------------------------
723
- // Business mutation detection
724
- // ---------------------------------------------------------------------------
725
- const MUTATION_PATTERNS = [
726
- {
727
- kind: 'prisma',
728
- re: /prisma\.([a-zA-Z_][a-zA-Z0-9_]*)\.(create|createMany|update|updateMany|upsert|delete|deleteMany)\s*\(/g,
729
- extract: (m) => ({ model: m[1], operation: m[2] }),
730
- },
731
- {
732
- kind: 'drizzle',
733
- re: /\bdb\.(insert|update|delete)\s*\(\s*([a-zA-Z_][a-zA-Z0-9_]*)?/g,
734
- extract: (m) => ({ model: m[2] || 'unknown', operation: m[1] }),
735
- },
736
- {
737
- kind: 'mongoose',
738
- re: /\b([A-Z][a-zA-Z0-9_]*)\.(create|findOneAndUpdate|findOneAndDelete|deleteOne|deleteMany|updateOne|updateMany|insertMany)\s*\(/g,
739
- extract: (m) => ({ model: m[1], operation: m[2] }),
740
- },
741
- {
742
- kind: 'raw-sql',
743
- re: /\b(INSERT\s+INTO|UPDATE|DELETE\s+FROM)\s+([`"']?[a-zA-Z_][a-zA-Z0-9_]*[`"']?)/gi,
744
- extract: (m) => ({
745
- model: m[2].replace(/[`"']/g, ''),
746
- operation: m[1].trim().split(/\s+/)[0].toLowerCase(),
747
- }),
748
- },
749
- ];
750
-
751
- async function detectMutations(rootPath, { limit = 200 } = {}) {
752
- const files = await walk(rootPath);
753
- const results = [];
754
- const seen = new Set();
755
-
756
- outer: for (const abs of files) {
757
- const ext = path.extname(abs);
758
- if (!CODE_EXTS.has(ext)) continue;
759
- const rel = path.relative(rootPath, abs);
760
- let content;
761
- try {
762
- content = await fs.readFile(abs, 'utf8');
763
- } catch {
764
- continue;
765
- }
766
- if (content.length > 1_500_000) continue;
767
-
768
- const lines = content.split(/\r?\n/);
769
- for (const pattern of MUTATION_PATTERNS) {
770
- const re = new RegExp(pattern.re.source, pattern.re.flags);
771
- let match;
772
- while ((match = re.exec(content)) !== null) {
773
- const info = pattern.extract(match);
774
- const upto = content.slice(0, match.index);
775
- const line = upto.split(/\r?\n/).length;
776
- const snippet = (lines[line - 1] || '').trim().slice(0, 200);
777
- const key = `${rel}:${line}:${info.model}:${info.operation}:${snippet}`;
778
- if (seen.has(key)) continue;
779
- seen.add(key);
780
- results.push({
781
- file: rel,
782
- line,
783
- model: info.model,
784
- operation: info.operation,
785
- snippet,
786
- });
787
- if (results.length >= limit) break outer;
788
- }
789
- }
790
- }
791
-
792
- return results;
793
- }
794
-
795
- // ---------------------------------------------------------------------------
796
- // Schema hint detection
797
- // ---------------------------------------------------------------------------
798
- async function detectSchemaHints(rootPath) {
799
- const hints = {};
800
- try {
801
- await fs.access(path.join(rootPath, 'prisma', 'schema.prisma'));
802
- hints.prisma = 'prisma/schema.prisma';
803
- } catch {
804
- /* noop */
805
- }
806
- for (const candidate of ['drizzle.config.ts', 'drizzle.config.js', 'drizzle.config.mjs']) {
807
- try {
808
- await fs.access(path.join(rootPath, candidate));
809
- hints.drizzle = candidate;
810
- break;
811
- } catch {
812
- /* noop */
813
- }
814
- }
815
- return hints;
816
- }
817
-
818
- // ---------------------------------------------------------------------------
819
- // Phase 18.6 A1 — Enhanced signal extractors
820
- // ---------------------------------------------------------------------------
821
-
822
- // Simplified Prisma field type classifier.
823
- function simplifyPrismaType(raw) {
824
- const t = String(raw).replace(/[\?\[\]]/g, '');
825
- if (/^(String|Int|BigInt|Float|Decimal|Boolean|DateTime|Json|Bytes)$/.test(t)) {
826
- return t;
827
- }
828
- // Anything else is either an enum or a relation.
829
- return /^[A-Z]/.test(t) ? `relation:${t}` : t;
830
- }
831
-
832
- /**
833
- * extractSchemaTables — parse ORM schema files for model/table structure.
834
- *
835
- * `orm.name === 'prisma'` parses prisma/schema.prisma. Drizzle/TypeORM get a
836
- * best-effort regex sweep. Capped at 50 tables / 200 columns total to keep
837
- * LLM prompt size bounded.
838
- *
839
- * Accepts an optional `schemaContent` argument so tests can pass inline
840
- * fixtures without touching the filesystem.
841
- */
842
- async function extractSchemaTables(rootPath, orm, schemaContent) {
843
- const MAX_TABLES = 50;
844
- const MAX_COLUMNS_TOTAL = 200;
845
- const tables = [];
846
- let totalColumns = 0;
847
-
848
- if (!orm || !orm.name) return tables;
849
-
850
- let content = schemaContent;
851
-
852
- if (orm.name === 'prisma') {
853
- if (!content) {
854
- const schemaRel = orm.schemaPath || 'prisma/schema.prisma';
855
- try {
856
- content = await fs.readFile(path.join(rootPath, schemaRel), 'utf8');
857
- } catch {
858
- return tables;
859
- }
860
- }
861
- const modelRe = /model\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{([\s\S]*?)\}/g;
862
- let m;
863
- while ((m = modelRe.exec(content)) !== null && tables.length < MAX_TABLES) {
864
- const name = m[1];
865
- const body = m[2];
866
- const columns = [];
867
- for (const line of body.split(/\r?\n/)) {
868
- const trimmed = line.trim();
869
- if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('@@')) continue;
870
- const fm = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)\s+([A-Za-z_][A-Za-z0-9_\?\[\]]*)/);
871
- if (!fm) continue;
872
- columns.push({ name: fm[1], type: simplifyPrismaType(fm[2]) });
873
- totalColumns++;
874
- if (totalColumns >= MAX_COLUMNS_TOTAL) break;
875
- }
876
- tables.push({ name, columns });
877
- if (totalColumns >= MAX_COLUMNS_TOTAL) break;
878
- }
879
- return tables;
880
- }
881
-
882
- if (orm.name === 'drizzle') {
883
- if (!content) {
884
- // Best-effort sweep through src/ for drizzle table definitions.
885
- try {
886
- const files = await walk(rootPath);
887
- const chunks = [];
888
- for (const abs of files) {
889
- if (!CODE_EXTS.has(path.extname(abs))) continue;
890
- try {
891
- const text = await fs.readFile(abs, 'utf8');
892
- if (/pgTable\s*\(|mysqlTable\s*\(|sqliteTable\s*\(/.test(text)) {
893
- chunks.push(text);
894
- }
895
- } catch {
896
- /* skip */
897
- }
898
- if (chunks.length > 20) break;
899
- }
900
- content = chunks.join('\n\n');
901
- } catch {
902
- return tables;
903
- }
904
- }
905
- const tableRe =
906
- /(?:pgTable|mysqlTable|sqliteTable)\s*\(\s*['"`]([A-Za-z_][A-Za-z0-9_]*)['"`]\s*,\s*\{([\s\S]*?)\}\s*\)/g;
907
- let m;
908
- while ((m = tableRe.exec(content)) !== null && tables.length < MAX_TABLES) {
909
- const name = m[1];
910
- const body = m[2];
911
- const columns = [];
912
- const colRe = /([A-Za-z_][A-Za-z0-9_]*)\s*:\s*([A-Za-z_][A-Za-z0-9_]*)\s*\(/g;
913
- let cm;
914
- while ((cm = colRe.exec(body)) !== null) {
915
- columns.push({ name: cm[1], type: cm[2] });
916
- totalColumns++;
917
- if (totalColumns >= MAX_COLUMNS_TOTAL) break;
918
- }
919
- tables.push({ name, columns });
920
- if (totalColumns >= MAX_COLUMNS_TOTAL) break;
921
- }
922
- return tables;
923
- }
924
-
925
- if (orm.name === 'typeorm') {
926
- if (!content) {
927
- try {
928
- const files = await walk(rootPath);
929
- const chunks = [];
930
- for (const abs of files) {
931
- if (!CODE_EXTS.has(path.extname(abs))) continue;
932
- try {
933
- const text = await fs.readFile(abs, 'utf8');
934
- if (/@Entity\s*\(/.test(text)) chunks.push(text);
935
- } catch {
936
- /* skip */
937
- }
938
- }
939
- content = chunks.join('\n\n');
940
- } catch {
941
- return tables;
942
- }
943
- }
944
- const entityRe =
945
- /@Entity\s*\([^)]*\)\s*export\s+class\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{([\s\S]*?)\n\}/g;
946
- let m;
947
- while ((m = entityRe.exec(content)) !== null && tables.length < MAX_TABLES) {
948
- const name = m[1];
949
- const body = m[2];
950
- const columns = [];
951
- const colRe = /@Column\s*\([^)]*\)\s*([A-Za-z_][A-Za-z0-9_]*)\s*:\s*([A-Za-z_][A-Za-z0-9_]*)/g;
952
- let cm;
953
- while ((cm = colRe.exec(body)) !== null) {
954
- columns.push({ name: cm[1], type: cm[2] });
955
- totalColumns++;
956
- if (totalColumns >= MAX_COLUMNS_TOTAL) break;
957
- }
958
- tables.push({ name, columns });
959
- if (totalColumns >= MAX_COLUMNS_TOTAL) break;
960
- }
961
- return tables;
962
- }
963
-
964
- return tables;
965
- }
966
-
967
- /**
968
- * extractDependencyFingerprint — filter package.json deps to curated
969
- * business-signal allowlist. Returns unique sorted array of matched names.
970
- */
971
- const BUSINESS_DEPS = [
972
- 'stripe', '@stripe/stripe-js', '@stripe/react-stripe-js',
973
- 'plaid', 'dwolla-v2',
974
- 'twilio', '@sendgrid/mail', 'resend', 'postmark',
975
- '@jumio/sdk', 'onfido', 'veriff',
976
- 'shopify-api', '@shopify/shopify-api', '@medusajs',
977
- 'next-auth', '@auth/core', '@clerk/nextjs', '@clerk/clerk-sdk-node',
978
- '@supabase/supabase-js', '@supabase/auth-helpers-nextjs',
979
- '@lemonsqueezy/lemonsqueezy.js', 'revenuecat-node',
980
- 'algolia', 'meilisearch', 'elasticsearch', '@pinecone-database/pinecone',
981
- 'openai', '@anthropic-ai/sdk', 'cohere-ai',
982
- 'mux-node', '@mux/mux-player-react', '@vimeo/player',
983
- 'mapbox', 'google-maps', '@googlemaps/js-api-loader',
984
- '@segment/analytics-next', 'mixpanel', 'amplitude-js',
985
- 'pusher', '@soketi/soketi', 'ably',
986
- 'prisma', 'drizzle-orm', 'typeorm', 'mongoose', 'sequelize',
987
- 'redis', 'ioredis', 'bullmq',
988
- 'bcrypt', 'argon2', 'jsonwebtoken',
989
- 'socket.io', 'ws', '@trpc/server',
990
- 'framer-motion', 'react-hook-form', 'zod', 'yup',
991
- ];
992
- const BUSINESS_DEPS_SET = new Set(BUSINESS_DEPS);
993
-
994
- function extractDependencyFingerprint(packageJson) {
995
- if (!packageJson || typeof packageJson !== 'object') return [];
996
- const deps = {
997
- ...(packageJson.dependencies || {}),
998
- ...(packageJson.devDependencies || {}),
999
- };
1000
- const matched = new Set();
1001
- for (const name of Object.keys(deps)) {
1002
- if (BUSINESS_DEPS_SET.has(name)) matched.add(name);
1003
- }
1004
- return Array.from(matched).sort();
1005
- }
1006
-
1007
- // ---------------------------------------------------------------------------
1008
- // extractUiStrings — harvest user-facing strings from JSX/templates.
1009
- // ---------------------------------------------------------------------------
1010
-
1011
- const UI_PROP_NAMES = new Set([
1012
- 'title', 'label', 'placeholder', 'heading', 'description',
1013
- 'aria-label', 'alt', 'subtitle', 'caption', 'tooltip',
1014
- ]);
1015
-
1016
- function looksTechnical(s) {
1017
- if (s.length < 3) return true;
1018
- if (/^[a-z-]+$/.test(s)) return true; // slugs
1019
- if (/^[\w-]+\/[\w\-./]+$/.test(s)) return true; // paths / modules
1020
- if (/^https?:\/\//.test(s)) return true;
1021
- if (/^\.\.?\//.test(s)) return true;
1022
- if (/^[a-z]+:[a-z-]+/.test(s)) return true; // protocol: / css-like
1023
- // Tailwind / CSS className heuristic: tokens separated by spaces, all lower.
1024
- if (/\s/.test(s) && /^[\w\s:\-\[\]\/\.]+$/.test(s) && !/[A-Z]/.test(s) && s.split(/\s+/).every((t) => /^[\w:\-\[\]\/\.]+$/.test(t) && /[-:]/.test(t))) {
1025
- return true;
1026
- }
1027
- if (/^[a-z0-9]+(-[a-z0-9]+)+$/.test(s)) return true;
1028
- // Paths / package-style identifiers: must contain a / or _ and no spaces.
1029
- if (/^@?[a-z0-9._/-]+$/i.test(s) && /[\/_]/.test(s) && !/\s/.test(s)) return true;
1030
- if (/^#[0-9a-f]{3,8}$/i.test(s)) return true;
1031
- return false;
1032
- }
1033
-
1034
- /**
1035
- * extractUiStringsFromSource — pure function so tests can pass inline source.
1036
- */
1037
- function extractUiStringsFromSource(source, { limit = 500, existing } = {}) {
1038
- const out = existing instanceof Set ? existing : new Set();
1039
-
1040
- // JSX text nodes: >Text here<
1041
- const jsxTextRe = />([^<>{}]{3,200})</g;
1042
- let m;
1043
- while ((m = jsxTextRe.exec(source)) !== null) {
1044
- const raw = m[1].trim();
1045
- if (!raw) continue;
1046
- if (/^\s*$/.test(raw)) continue;
1047
- if (/^\{.*\}$/.test(raw)) continue;
1048
- if (looksTechnical(raw)) continue;
1049
- out.add(raw);
1050
- if (out.size >= limit) return Array.from(out);
1051
- }
1052
-
1053
- // Props: title="...", label={"..."}
1054
- const propRe = /\b([a-zA-Z-]+)\s*=\s*(?:\{\s*['"`]([^'"`]{3,200})['"`]\s*\}|['"`]([^'"`]{3,200})['"`])/g;
1055
- while ((m = propRe.exec(source)) !== null) {
1056
- const prop = m[1];
1057
- if (!UI_PROP_NAMES.has(prop)) continue;
1058
- const val = (m[2] || m[3] || '').trim();
1059
- if (!val) continue;
1060
- if (looksTechnical(val)) continue;
1061
- out.add(val);
1062
- if (out.size >= limit) return Array.from(out);
1063
- }
1064
-
1065
- // toast.*(...), alert(...), confirm(...), prompt(...)
1066
- const callRe = /\b(?:toast(?:\.[a-zA-Z]+)?|alert|confirm|prompt)\s*\(\s*['"`]([^'"`]{3,200})['"`]/g;
1067
- while ((m = callRe.exec(source)) !== null) {
1068
- const val = m[1].trim();
1069
- if (!val || looksTechnical(val)) continue;
1070
- out.add(val);
1071
- if (out.size >= limit) return Array.from(out);
1072
- }
1073
-
1074
- return Array.from(out);
1075
- }
1076
-
1077
- async function extractUiStrings(rootPath, framework) {
1078
- const limit = 500;
1079
- const collected = new Set();
1080
- const exts = new Set(['.tsx', '.jsx']);
1081
- if (framework && framework.name === 'vue') exts.add('.vue');
1082
- if (framework && framework.name === 'sveltekit') exts.add('.svelte');
1083
-
1084
- let files;
1085
- try {
1086
- files = await walk(rootPath);
1087
- } catch {
1088
- return [];
1089
- }
1090
- for (const abs of files) {
1091
- if (collected.size >= limit) break;
1092
- if (!exts.has(path.extname(abs))) continue;
1093
- let text;
1094
- try {
1095
- text = await fs.readFile(abs, 'utf8');
1096
- } catch {
1097
- continue;
1098
- }
1099
- if (text.length > 500_000) continue;
1100
- extractUiStringsFromSource(text, { limit, existing: collected });
1101
- }
1102
- return Array.from(collected).slice(0, limit);
1103
- }
1104
-
1105
- // ---------------------------------------------------------------------------
1106
- // extractI18nKeys — recursive key-path harvest from JSON locale files.
1107
- // ---------------------------------------------------------------------------
1108
-
1109
- function harvestKeyPaths(obj, prefix, out, cap) {
1110
- if (out.size >= cap) return;
1111
- if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return;
1112
- for (const k of Object.keys(obj)) {
1113
- const full = prefix ? `${prefix}.${k}` : k;
1114
- const v = obj[k];
1115
- if (v && typeof v === 'object' && !Array.isArray(v)) {
1116
- harvestKeyPaths(v, full, out, cap);
1117
- } else {
1118
- out.add(full);
1119
- }
1120
- if (out.size >= cap) return;
1121
- }
1122
- }
1123
-
1124
- async function extractI18nKeys(rootPath, { inlineFiles } = {}) {
1125
- const cap = 200;
1126
- const keys = new Set();
1127
-
1128
- if (inlineFiles) {
1129
- for (const obj of inlineFiles) {
1130
- harvestKeyPaths(obj, '', keys, cap);
1131
- if (keys.size >= cap) break;
1132
- }
1133
- return Array.from(keys).slice(0, cap);
1134
- }
1135
-
1136
- const candidateDirs = [
1137
- path.join(rootPath, 'src', 'i18n'),
1138
- path.join(rootPath, 'messages'),
1139
- path.join(rootPath, 'locales'),
1140
- path.join(rootPath, 'public', 'locales'),
1141
- path.join(rootPath, 'src', 'locales'),
1142
- ];
1143
-
1144
- for (const dir of candidateDirs) {
1145
- let files;
1146
- try {
1147
- files = await walk(dir);
1148
- } catch {
1149
- continue;
1150
- }
1151
- for (const abs of files) {
1152
- if (path.extname(abs) !== '.json') continue;
1153
- let text;
1154
- try {
1155
- text = await fs.readFile(abs, 'utf8');
1156
- } catch {
1157
- continue;
1158
- }
1159
- try {
1160
- const parsed = JSON.parse(text);
1161
- harvestKeyPaths(parsed, '', keys, cap);
1162
- } catch {
1163
- /* skip malformed */
1164
- }
1165
- if (keys.size >= cap) break;
1166
- }
1167
- if (keys.size >= cap) break;
1168
- }
1169
-
1170
- return Array.from(keys).slice(0, cap);
1171
- }
1172
-
1173
- // ---------------------------------------------------------------------------
1174
- // extractFolderStructureHints — list significant route / api subfolders.
1175
- // ---------------------------------------------------------------------------
1176
-
1177
- async function extractFolderStructureHints(rootPath, framework) {
1178
- const cap = 100;
1179
- const hints = new Set();
1180
-
1181
- async function listDirs(root) {
1182
- const out = [];
1183
- let entries;
1184
- try {
1185
- entries = await fs.readdir(root, { withFileTypes: true });
1186
- } catch {
1187
- return out;
1188
- }
1189
- for (const e of entries) {
1190
- if (!e.isDirectory()) continue;
1191
- if (e.name.startsWith('_')) continue; // private
1192
- if (SKIP_DIRS.has(e.name)) continue;
1193
- out.push(e.name);
1194
- }
1195
- return out;
1196
- }
1197
-
1198
- async function walkDirs(root, prefix, depth) {
1199
- if (depth < 0 || hints.size >= cap) return;
1200
- const dirs = await listDirs(root);
1201
- for (const d of dirs) {
1202
- const full = prefix ? `${prefix}/${d}` : d;
1203
- hints.add(full);
1204
- if (hints.size >= cap) return;
1205
- await walkDirs(path.join(root, d), full, depth - 1);
1206
- }
1207
- }
1208
-
1209
- if (framework && framework.name === 'nextjs') {
1210
- // App Router
1211
- for (const base of ['src/app', 'app']) {
1212
- const root = path.join(rootPath, base);
1213
- try {
1214
- await fs.access(root);
1215
- await walkDirs(root, base, 3);
1216
- } catch {
1217
- /* skip */
1218
- }
1219
- }
1220
- // Pages Router
1221
- for (const base of ['src/pages/api', 'pages/api']) {
1222
- const root = path.join(rootPath, base);
1223
- try {
1224
- await fs.access(root);
1225
- await walkDirs(root, base, 3);
1226
- } catch {
1227
- /* skip */
1228
- }
1229
- }
1230
- } else if (framework && ['express', 'fastify', 'hono', 'nestjs'].includes(framework.name)) {
1231
- for (const base of ['src/routes', 'routes', 'src/controllers', 'src/modules']) {
1232
- const root = path.join(rootPath, base);
1233
- try {
1234
- const entries = await fs.readdir(root, { withFileTypes: true });
1235
- for (const e of entries) {
1236
- if (e.isFile()) {
1237
- hints.add(`${base}/${e.name.replace(/\.(ts|js|mjs)$/, '')}`);
1238
- } else if (e.isDirectory() && !SKIP_DIRS.has(e.name)) {
1239
- hints.add(`${base}/${e.name}`);
1240
- }
1241
- if (hints.size >= cap) break;
1242
- }
1243
- } catch {
1244
- /* skip */
1245
- }
1246
- }
1247
- }
1248
-
1249
- return Array.from(hints).slice(0, cap);
1250
- }
1251
-
1252
- // ---------------------------------------------------------------------------
1253
- // extractEnvVarHints — parse .env.example/.env for variable NAMES (never values).
1254
- // ---------------------------------------------------------------------------
1255
-
1256
- function extractEnvNamesFromContent(content) {
1257
- const names = new Set();
1258
- for (const line of content.split(/\r?\n/)) {
1259
- const trimmed = line.trim();
1260
- if (!trimmed || trimmed.startsWith('#')) continue;
1261
- const m = trimmed.match(/^(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=/);
1262
- if (m) names.add(m[1]);
1263
- }
1264
- return names;
1265
- }
1266
-
1267
- async function extractEnvVarHints(rootPath, { inlineContent } = {}) {
1268
- const cap = 100;
1269
- const names = new Set();
1270
-
1271
- if (inlineContent !== undefined) {
1272
- for (const n of extractEnvNamesFromContent(inlineContent)) names.add(n);
1273
- return Array.from(names).slice(0, cap);
1274
- }
1275
-
1276
- const candidates = ['.env.example', '.env.sample', '.env.local.example', '.env'];
1277
- for (const c of candidates) {
1278
- let text;
1279
- try {
1280
- text = await fs.readFile(path.join(rootPath, c), 'utf8');
1281
- } catch {
1282
- continue;
1283
- }
1284
- for (const n of extractEnvNamesFromContent(text)) {
1285
- names.add(n);
1286
- if (names.size >= cap) break;
1287
- }
1288
- if (names.size >= cap) break;
1289
- }
1290
-
1291
- return Array.from(names).slice(0, cap);
1292
- }
1293
-
1294
- // ---------------------------------------------------------------------------
1295
- // extractCommentBlockHints — grab first lines of JSDoc / block comments that
1296
- // mention business-term trigger words.
1297
- // ---------------------------------------------------------------------------
1298
-
1299
- const BUSINESS_TRIGGER_WORDS = [
1300
- 'order', 'payment', 'checkout', 'subscription', 'user', 'account',
1301
- 'bet', 'odds', 'wager', 'wallet', 'deposit', 'withdrawal', 'kyc',
1302
- 'patient', 'appointment', 'prescription', 'provider',
1303
- 'course', 'lesson', 'enrollment', 'student',
1304
- 'policy', 'claim', 'invoice', 'transaction', 'card',
1305
- 'product', 'cart', 'shipment', 'refund', 'catalog',
1306
- 'listing', 'reservation', 'booking', 'ticket',
1307
- ];
1308
- const TRIGGER_RE = new RegExp(`\\b(${BUSINESS_TRIGGER_WORDS.join('|')})s?\\b`, 'i');
1309
-
1310
- function extractCommentsFromSource(source) {
1311
- const out = [];
1312
- const blockRe = /\/\*\*?([\s\S]*?)\*\//g;
1313
- let m;
1314
- while ((m = blockRe.exec(source)) !== null) {
1315
- const body = m[1];
1316
- const firstLine = body
1317
- .split(/\r?\n/)
1318
- .map((l) => l.replace(/^\s*\*\s?/, '').trim())
1319
- .find((l) => l.length > 0);
1320
- if (!firstLine) continue;
1321
- if (!TRIGGER_RE.test(firstLine)) continue;
1322
- out.push(firstLine.slice(0, 160));
1323
- }
1324
- return out;
1325
- }
1326
-
1327
- async function extractCommentBlockHints(rootPath) {
1328
- const cap = 50;
1329
- const out = [];
1330
- let files;
1331
- try {
1332
- files = await walk(rootPath);
1333
- } catch {
1334
- return out;
1335
- }
1336
- for (const abs of files) {
1337
- if (out.length >= cap) break;
1338
- if (!CODE_EXTS.has(path.extname(abs))) continue;
1339
- let text;
1340
- try {
1341
- text = await fs.readFile(abs, 'utf8');
1342
- } catch {
1343
- continue;
1344
- }
1345
- if (text.length > 1_500_000) continue;
1346
- for (const c of extractCommentsFromSource(text)) {
1347
- out.push(c);
1348
- if (out.length >= cap) break;
1349
- }
1350
- }
1351
- return out.slice(0, cap);
1352
- }
1353
-
1354
- // ---------------------------------------------------------------------------
1355
- // Aggregated scan
1356
- // ---------------------------------------------------------------------------
1357
- async function scanRepo(rootPath, { log = () => {} } = {}) {
1358
- const abs = path.resolve(rootPath);
1359
- log(`[scan] root: ${abs}`);
1360
-
1361
- let pkg = {};
1362
- try {
1363
- const raw = await fs.readFile(path.join(abs, 'package.json'), 'utf8');
1364
- pkg = JSON.parse(raw);
1365
- } catch {
1366
- log('[scan] no package.json — continuing with empty deps');
1367
- }
1368
- const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
1369
-
1370
- const framework = detectFramework(deps, abs);
1371
- log(`[scan] framework: ${framework.name} ${framework.version || ''}`);
1372
-
1373
- const isMobile = ['react-native', 'ios-swift', 'android-kotlin', 'flutter'].includes(framework.name);
1374
-
1375
- const schemaHints = await detectSchemaHints(abs);
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
- }
1381
- log(`[scan] orm: ${orm.name}`);
1382
-
1383
- const auth = detectAuth(deps);
1384
- log(`[scan] auth: ${auth.name}`);
1385
-
1386
- let scanRoot = abs;
1387
- try {
1388
- const srcStat = await fs.stat(path.join(abs, 'src'));
1389
- if (srcStat.isDirectory()) scanRoot = path.join(abs, 'src');
1390
- } catch {
1391
- /* fallback to root */
1392
- }
1393
- log(`[scan] walk root: ${path.relative(abs, scanRoot) || '.'}`);
1394
-
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);
1402
- log(`[scan] routes: ${routes.length}${routeNote ? ` (${routeNote})` : ''}`);
1403
-
1404
- const mutations = await detectMutations(scanRoot);
1405
- log(`[scan] mutations: ${mutations.length}`);
1406
-
1407
- // Phase 18.6 A1 — enriched signal extraction
1408
- const schemaTables = await extractSchemaTables(abs, orm);
1409
- log(`[scan] schemaTables: ${schemaTables.length}`);
1410
-
1411
- const dependencyFingerprint = extractDependencyFingerprint(pkg);
1412
- log(`[scan] dependencyFingerprint: ${dependencyFingerprint.length}`);
1413
-
1414
- const uiStrings = await extractUiStrings(scanRoot, framework);
1415
- log(`[scan] uiStrings: ${uiStrings.length}`);
1416
-
1417
- const i18nKeys = await extractI18nKeys(abs);
1418
- log(`[scan] i18nKeys: ${i18nKeys.length}`);
1419
-
1420
- const folderStructureHints = await extractFolderStructureHints(abs, framework);
1421
- log(`[scan] folderStructureHints: ${folderStructureHints.length}`);
1422
-
1423
- const envVarHints = await extractEnvVarHints(abs);
1424
- log(`[scan] envVarHints: ${envVarHints.length}`);
1425
-
1426
- const commentBlockHints = await extractCommentBlockHints(scanRoot);
1427
- log(`[scan] commentBlockHints: ${commentBlockHints.length}`);
1428
-
1429
- // Rough total file count (for metadata only).
1430
- let totalFiles = 0;
1431
- try {
1432
- const files = await walk(scanRoot);
1433
- totalFiles = files.length;
1434
- } catch {
1435
- /* skip */
1436
- }
1437
-
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) => ({
1443
- method,
1444
- path: r.path,
1445
- }));
1446
- });
1447
-
1448
- const normalizedMutations = (mutations || []).map((m) => ({
1449
- file: m.file,
1450
- op: `${m.model || 'unknown'}.${m.operation}`,
1451
- model: m.model || undefined,
1452
- }));
1453
-
1454
- const plan = {
1455
- site_id: null,
1456
- domains: [],
1457
- framework,
1458
- orm,
1459
- auth,
1460
- routes: normalizedRoutes,
1461
- rawRoutes: routes,
1462
- businessSurfaces: [],
1463
- mutations: normalizedMutations,
1464
- rawMutations: mutations,
1465
- schemaTables,
1466
- dependencyFingerprint,
1467
- uiStrings,
1468
- envVarHints,
1469
- folderStructureHints,
1470
- commentBlockHints,
1471
- i18nKeys,
1472
- metadata: {
1473
- scannedAt: new Date().toISOString(),
1474
- scanVersion: SCAN_VERSION,
1475
- repoPath: abs,
1476
- totalFiles,
1477
- },
1478
- scannedAt: new Date().toISOString(),
1479
- scanVersion: SCAN_VERSION,
1480
- };
1481
- if (routeNote) plan.routeDetectionNote = routeNote;
1482
- return plan;
1483
- }
1484
-
1485
- module.exports = {
1486
- SCAN_VERSION,
1487
- detectFramework,
1488
- detectORM,
1489
- detectMobileORM,
1490
- detectAuth,
1491
- detectRoutes,
1492
- scanExpressRoutes,
1493
- scanFastifyRoutes,
1494
- scanNestRoutes,
1495
- scanMobileScreens,
1496
- detectMutations,
1497
- scanRepo,
1498
- // Phase 18.6 A1
1499
- extractSchemaTables,
1500
- extractDependencyFingerprint,
1501
- extractUiStrings,
1502
- extractUiStringsFromSource,
1503
- extractI18nKeys,
1504
- extractFolderStructureHints,
1505
- extractEnvVarHints,
1506
- extractCommentBlockHints,
1507
- extractCommentsFromSource,
1508
- BUSINESS_DEPS,
1509
- };