@gurulu/cli 0.4.7 → 1.0.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.
Files changed (190) 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 +25751 -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 +33 -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 +25326 -876
  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/dist/lib/detect.d.ts +27 -0
  28. package/dist/lib/detect.d.ts.map +1 -0
  29. package/dist/lib/detect.js +106 -0
  30. package/dist/lib/exec-install.d.ts +21 -0
  31. package/dist/lib/exec-install.d.ts.map +1 -0
  32. package/dist/lib/install-plan.d.ts +25 -0
  33. package/dist/lib/install-plan.d.ts.map +1 -0
  34. package/dist/lib/install-plan.js +161 -0
  35. package/package.json +51 -20
  36. package/bin/gurulu.js +0 -2
  37. package/dist/api-client.d.ts +0 -33
  38. package/dist/api-client.js +0 -175
  39. package/dist/commands/add-server.d.ts +0 -9
  40. package/dist/commands/add-server.js +0 -162
  41. package/dist/commands/alerts.d.ts +0 -27
  42. package/dist/commands/alerts.js +0 -309
  43. package/dist/commands/api-keys.d.ts +0 -20
  44. package/dist/commands/api-keys.js +0 -130
  45. package/dist/commands/attribution.d.ts +0 -22
  46. package/dist/commands/attribution.js +0 -111
  47. package/dist/commands/audiences.d.ts +0 -23
  48. package/dist/commands/audiences.js +0 -243
  49. package/dist/commands/audit.d.ts +0 -20
  50. package/dist/commands/audit.js +0 -130
  51. package/dist/commands/auth.js +0 -249
  52. package/dist/commands/chat.d.ts +0 -19
  53. package/dist/commands/chat.js +0 -118
  54. package/dist/commands/config.d.ts +0 -10
  55. package/dist/commands/config.js +0 -92
  56. package/dist/commands/consent.d.ts +0 -27
  57. package/dist/commands/consent.js +0 -233
  58. package/dist/commands/conversion-paths.d.ts +0 -19
  59. package/dist/commands/conversion-paths.js +0 -55
  60. package/dist/commands/db.d.ts +0 -25
  61. package/dist/commands/db.js +0 -330
  62. package/dist/commands/destinations.d.ts +0 -20
  63. package/dist/commands/destinations.js +0 -191
  64. package/dist/commands/doctor.js +0 -360
  65. package/dist/commands/errors.d.ts +0 -27
  66. package/dist/commands/errors.js +0 -121
  67. package/dist/commands/events.d.ts +0 -33
  68. package/dist/commands/events.js +0 -371
  69. package/dist/commands/experiments.d.ts +0 -22
  70. package/dist/commands/experiments.js +0 -264
  71. package/dist/commands/funnels.d.ts +0 -17
  72. package/dist/commands/funnels.js +0 -203
  73. package/dist/commands/goals.d.ts +0 -18
  74. package/dist/commands/goals.js +0 -214
  75. package/dist/commands/heatmap.d.ts +0 -27
  76. package/dist/commands/heatmap.js +0 -112
  77. package/dist/commands/identity.d.ts +0 -29
  78. package/dist/commands/identity.js +0 -328
  79. package/dist/commands/init.js +0 -215
  80. package/dist/commands/insights.d.ts +0 -10
  81. package/dist/commands/insights.js +0 -77
  82. package/dist/commands/install.d.ts +0 -259
  83. package/dist/commands/install.js +0 -1590
  84. package/dist/commands/login.d.ts +0 -20
  85. package/dist/commands/login.js +0 -170
  86. package/dist/commands/logout.d.ts +0 -10
  87. package/dist/commands/logout.js +0 -41
  88. package/dist/commands/playground.d.ts +0 -11
  89. package/dist/commands/playground.js +0 -47
  90. package/dist/commands/releases.d.ts +0 -17
  91. package/dist/commands/releases.js +0 -54
  92. package/dist/commands/replay.d.ts +0 -18
  93. package/dist/commands/replay.js +0 -64
  94. package/dist/commands/secrets.d.ts +0 -19
  95. package/dist/commands/secrets.js +0 -145
  96. package/dist/commands/setup.d.ts +0 -21
  97. package/dist/commands/setup.js +0 -67
  98. package/dist/commands/sites.d.ts +0 -18
  99. package/dist/commands/sites.js +0 -139
  100. package/dist/commands/skad.d.ts +0 -18
  101. package/dist/commands/skad.js +0 -53
  102. package/dist/commands/sourcemap.d.ts +0 -33
  103. package/dist/commands/sourcemap.js +0 -204
  104. package/dist/commands/status.d.ts +0 -7
  105. package/dist/commands/status.js +0 -136
  106. package/dist/commands/upgrade.d.ts +0 -21
  107. package/dist/commands/upgrade.js +0 -183
  108. package/dist/commands/warehouse.d.ts +0 -20
  109. package/dist/commands/warehouse.js +0 -65
  110. package/dist/commands/warehouses.d.ts +0 -17
  111. package/dist/commands/warehouses.js +0 -182
  112. package/dist/commands/watch.d.ts +0 -45
  113. package/dist/commands/watch.js +0 -258
  114. package/dist/commands/whoami.d.ts +0 -9
  115. package/dist/commands/whoami.js +0 -50
  116. package/dist/config.d.ts +0 -75
  117. package/dist/config.js +0 -329
  118. package/dist/frameworks/detect.d.ts +0 -8
  119. package/dist/frameworks/detect.js +0 -458
  120. package/dist/install-intent-proposal.d.ts +0 -99
  121. package/dist/install-intent-proposal.js +0 -202
  122. package/dist/utils/api.d.ts +0 -20
  123. package/dist/utils/api.js +0 -47
  124. package/dist/utils/config.d.ts +0 -13
  125. package/dist/utils/config.js +0 -30
  126. package/dist/utils/confirm.d.ts +0 -17
  127. package/dist/utils/confirm.js +0 -40
  128. package/dist/utils/dry-run.d.ts +0 -20
  129. package/dist/utils/dry-run.js +0 -67
  130. package/dist/utils/from-file.d.ts +0 -9
  131. package/dist/utils/from-file.js +0 -72
  132. package/dist/utils/redact.d.ts +0 -14
  133. package/dist/utils/redact.js +0 -48
  134. package/dist/utils/ui.d.ts +0 -14
  135. package/dist/utils/ui.js +0 -59
  136. package/scripts/.gitkeep +0 -0
  137. package/scripts/README-gurulu-agentic-install.md +0 -114
  138. package/scripts/README-gurulu-scan.md +0 -98
  139. package/scripts/audit-cli-scopes.mjs +0 -204
  140. package/scripts/backfill-tenant-id.mjs +0 -172
  141. package/scripts/backfill-tenant-links.ts +0 -252
  142. package/scripts/backup-clickhouse.sh +0 -27
  143. package/scripts/backup-postgres.sh +0 -19
  144. package/scripts/bootstrap-runtime-schema.mjs +0 -87
  145. package/scripts/bootstrap-stripe.mjs +0 -158
  146. package/scripts/gurulu-agentic-install.lib.cjs +0 -762
  147. package/scripts/gurulu-agentic-install.mjs +0 -623
  148. package/scripts/gurulu-scan.lib.cjs +0 -1509
  149. package/scripts/gurulu-scan.mjs +0 -91
  150. package/scripts/gurulu-verify-install.lib.cjs +0 -334
  151. package/scripts/gurulu-verify-install.mjs +0 -59
  152. package/scripts/init-ssl.sh +0 -26
  153. package/scripts/migrate-flow-graph-enums.sh +0 -86
  154. package/scripts/monitor-disk.sh +0 -24
  155. package/scripts/patches/astro.patch.cjs +0 -74
  156. package/scripts/patches/auto-instrument/ast-helper.cjs +0 -480
  157. package/scripts/patches/auto-instrument/astro.cjs +0 -273
  158. package/scripts/patches/auto-instrument/express.cjs +0 -383
  159. package/scripts/patches/auto-instrument/fastify.cjs +0 -262
  160. package/scripts/patches/auto-instrument/hono.cjs +0 -392
  161. package/scripts/patches/auto-instrument/index.cjs +0 -80
  162. package/scripts/patches/auto-instrument/nestjs.cjs +0 -286
  163. package/scripts/patches/auto-instrument/nextjs-app-router.cjs +0 -345
  164. package/scripts/patches/auto-instrument/nextjs-pages.cjs +0 -361
  165. package/scripts/patches/auto-instrument/remix.cjs +0 -168
  166. package/scripts/patches/auto-instrument/sdk-helper-map.cjs +0 -241
  167. package/scripts/patches/auto-instrument/singleton-helper.cjs +0 -193
  168. package/scripts/patches/auto-instrument/sveltekit.cjs +0 -161
  169. package/scripts/patches/auto-instrument/vite-react.cjs +0 -37
  170. package/scripts/patches/auto-instrument/vue.cjs +0 -196
  171. package/scripts/patches/express.patch.cjs +0 -99
  172. package/scripts/patches/fastify.patch.cjs +0 -108
  173. package/scripts/patches/index.cjs +0 -300
  174. package/scripts/patches/nestjs.patch.cjs +0 -112
  175. package/scripts/patches/nextjs-app-router.patch.cjs +0 -97
  176. package/scripts/patches/nextjs-pages.patch.cjs +0 -97
  177. package/scripts/patches/remix.patch.cjs +0 -75
  178. package/scripts/patches/sveltekit.patch.cjs +0 -72
  179. package/scripts/patches/vite-react.patch.cjs +0 -73
  180. package/scripts/patches/vue.patch.cjs +0 -82
  181. package/scripts/renew-ssl.sh +0 -14
  182. package/scripts/resolve-migration.sh +0 -23
  183. package/scripts/seed-cli-dev-keys.mjs +0 -130
  184. package/scripts/seed-test-data.mjs +0 -391
  185. package/scripts/spike-browserless.ts +0 -65
  186. package/scripts/tenant-pivot-consistency-check.mjs +0 -205
  187. package/scripts/tenant-pivot-phase-3-cleanup.lib.cjs +0 -258
  188. package/scripts/tenant-pivot-phase-3-cleanup.mjs +0 -98
  189. package/scripts/test-identity-resolution.ts +0 -804
  190. 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
- };