@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.
- package/LICENSE +92 -0
- package/README.md +35 -106
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +25410 -0
- package/dist/commands/auth.d.ts +23 -20
- package/dist/commands/auth.d.ts.map +1 -0
- package/dist/commands/doctor.d.ts +20 -6
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/init.d.ts +25 -11
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/pull.d.ts +13 -0
- package/dist/commands/pull.d.ts.map +1 -0
- package/dist/commands/push.d.ts +40 -0
- package/dist/commands/push.d.ts.map +1 -0
- package/dist/commands/validate.d.ts +36 -0
- package/dist/commands/validate.d.ts.map +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24985 -853
- package/dist/lib/api.d.ts +139 -0
- package/dist/lib/api.d.ts.map +1 -0
- package/dist/lib/codegen.d.ts +4 -0
- package/dist/lib/codegen.d.ts.map +1 -0
- package/dist/lib/config.d.ts +43 -0
- package/dist/lib/config.d.ts.map +1 -0
- package/package.json +40 -20
- package/bin/gurulu.js +0 -2
- package/dist/api-client.d.ts +0 -33
- package/dist/api-client.js +0 -175
- package/dist/commands/add-server.d.ts +0 -9
- package/dist/commands/add-server.js +0 -162
- package/dist/commands/alerts.d.ts +0 -27
- package/dist/commands/alerts.js +0 -309
- package/dist/commands/api-keys.d.ts +0 -20
- package/dist/commands/api-keys.js +0 -130
- package/dist/commands/attribution.d.ts +0 -22
- package/dist/commands/attribution.js +0 -111
- package/dist/commands/audiences.d.ts +0 -23
- package/dist/commands/audiences.js +0 -243
- package/dist/commands/audit.d.ts +0 -20
- package/dist/commands/audit.js +0 -130
- package/dist/commands/auth.js +0 -249
- package/dist/commands/chat.d.ts +0 -18
- package/dist/commands/chat.js +0 -117
- package/dist/commands/config.d.ts +0 -10
- package/dist/commands/config.js +0 -92
- package/dist/commands/consent.d.ts +0 -27
- package/dist/commands/consent.js +0 -233
- package/dist/commands/conversion-paths.d.ts +0 -19
- package/dist/commands/conversion-paths.js +0 -55
- package/dist/commands/db.d.ts +0 -25
- package/dist/commands/db.js +0 -330
- package/dist/commands/destinations.d.ts +0 -20
- package/dist/commands/destinations.js +0 -191
- package/dist/commands/doctor.js +0 -360
- package/dist/commands/errors.d.ts +0 -27
- package/dist/commands/errors.js +0 -121
- package/dist/commands/events.d.ts +0 -33
- package/dist/commands/events.js +0 -349
- package/dist/commands/experiments.d.ts +0 -22
- package/dist/commands/experiments.js +0 -264
- package/dist/commands/funnels.d.ts +0 -17
- package/dist/commands/funnels.js +0 -203
- package/dist/commands/goals.d.ts +0 -18
- package/dist/commands/goals.js +0 -214
- package/dist/commands/heatmap.d.ts +0 -27
- package/dist/commands/heatmap.js +0 -112
- package/dist/commands/identity.d.ts +0 -29
- package/dist/commands/identity.js +0 -328
- package/dist/commands/init.js +0 -215
- package/dist/commands/insights.d.ts +0 -10
- package/dist/commands/insights.js +0 -65
- package/dist/commands/install.d.ts +0 -259
- package/dist/commands/install.js +0 -1590
- package/dist/commands/login.d.ts +0 -20
- package/dist/commands/login.js +0 -170
- package/dist/commands/logout.d.ts +0 -10
- package/dist/commands/logout.js +0 -41
- package/dist/commands/playground.d.ts +0 -11
- package/dist/commands/playground.js +0 -47
- package/dist/commands/releases.d.ts +0 -17
- package/dist/commands/releases.js +0 -54
- package/dist/commands/replay.d.ts +0 -18
- package/dist/commands/replay.js +0 -64
- package/dist/commands/secrets.d.ts +0 -19
- package/dist/commands/secrets.js +0 -145
- package/dist/commands/sites.d.ts +0 -18
- package/dist/commands/sites.js +0 -139
- package/dist/commands/skad.d.ts +0 -18
- package/dist/commands/skad.js +0 -53
- package/dist/commands/sourcemap.d.ts +0 -33
- package/dist/commands/sourcemap.js +0 -204
- package/dist/commands/status.d.ts +0 -7
- package/dist/commands/status.js +0 -136
- package/dist/commands/upgrade.d.ts +0 -21
- package/dist/commands/upgrade.js +0 -183
- package/dist/commands/warehouse.d.ts +0 -20
- package/dist/commands/warehouse.js +0 -65
- package/dist/commands/warehouses.d.ts +0 -17
- package/dist/commands/warehouses.js +0 -182
- package/dist/commands/watch.d.ts +0 -45
- package/dist/commands/watch.js +0 -258
- package/dist/commands/whoami.d.ts +0 -9
- package/dist/commands/whoami.js +0 -50
- package/dist/config.d.ts +0 -75
- package/dist/config.js +0 -329
- package/dist/frameworks/detect.d.ts +0 -8
- package/dist/frameworks/detect.js +0 -444
- package/dist/install-intent-proposal.d.ts +0 -99
- package/dist/install-intent-proposal.js +0 -202
- package/dist/utils/api.d.ts +0 -20
- package/dist/utils/api.js +0 -47
- package/dist/utils/config.d.ts +0 -13
- package/dist/utils/config.js +0 -30
- package/dist/utils/confirm.d.ts +0 -17
- package/dist/utils/confirm.js +0 -40
- package/dist/utils/dry-run.d.ts +0 -20
- package/dist/utils/dry-run.js +0 -67
- package/dist/utils/from-file.d.ts +0 -9
- package/dist/utils/from-file.js +0 -72
- package/dist/utils/redact.d.ts +0 -14
- package/dist/utils/redact.js +0 -48
- package/dist/utils/ui.d.ts +0 -14
- package/dist/utils/ui.js +0 -59
- package/scripts/.gitkeep +0 -0
- package/scripts/README-gurulu-agentic-install.md +0 -114
- package/scripts/README-gurulu-scan.md +0 -98
- package/scripts/audit-cli-scopes.mjs +0 -204
- package/scripts/backfill-tenant-id.mjs +0 -172
- package/scripts/backfill-tenant-links.ts +0 -252
- package/scripts/backup-clickhouse.sh +0 -27
- package/scripts/backup-postgres.sh +0 -19
- package/scripts/bootstrap-runtime-schema.mjs +0 -87
- package/scripts/bootstrap-stripe.mjs +0 -158
- package/scripts/gurulu-agentic-install.lib.cjs +0 -762
- package/scripts/gurulu-agentic-install.mjs +0 -623
- package/scripts/gurulu-scan.lib.cjs +0 -1509
- package/scripts/gurulu-scan.mjs +0 -91
- package/scripts/gurulu-verify-install.lib.cjs +0 -334
- package/scripts/gurulu-verify-install.mjs +0 -59
- package/scripts/init-ssl.sh +0 -26
- package/scripts/migrate-flow-graph-enums.sh +0 -86
- package/scripts/monitor-disk.sh +0 -24
- package/scripts/patches/astro.patch.cjs +0 -74
- package/scripts/patches/auto-instrument/ast-helper.cjs +0 -480
- package/scripts/patches/auto-instrument/astro.cjs +0 -273
- package/scripts/patches/auto-instrument/express.cjs +0 -383
- package/scripts/patches/auto-instrument/fastify.cjs +0 -262
- package/scripts/patches/auto-instrument/hono.cjs +0 -392
- package/scripts/patches/auto-instrument/index.cjs +0 -80
- package/scripts/patches/auto-instrument/nestjs.cjs +0 -286
- package/scripts/patches/auto-instrument/nextjs-app-router.cjs +0 -345
- package/scripts/patches/auto-instrument/nextjs-pages.cjs +0 -361
- package/scripts/patches/auto-instrument/remix.cjs +0 -168
- package/scripts/patches/auto-instrument/sdk-helper-map.cjs +0 -241
- package/scripts/patches/auto-instrument/singleton-helper.cjs +0 -193
- package/scripts/patches/auto-instrument/sveltekit.cjs +0 -161
- package/scripts/patches/auto-instrument/vite-react.cjs +0 -37
- package/scripts/patches/auto-instrument/vue.cjs +0 -196
- package/scripts/patches/express.patch.cjs +0 -99
- package/scripts/patches/fastify.patch.cjs +0 -108
- package/scripts/patches/index.cjs +0 -300
- package/scripts/patches/nestjs.patch.cjs +0 -112
- package/scripts/patches/nextjs-app-router.patch.cjs +0 -97
- package/scripts/patches/nextjs-pages.patch.cjs +0 -97
- package/scripts/patches/remix.patch.cjs +0 -75
- package/scripts/patches/sveltekit.patch.cjs +0 -72
- package/scripts/patches/vite-react.patch.cjs +0 -73
- package/scripts/patches/vue.patch.cjs +0 -82
- package/scripts/renew-ssl.sh +0 -14
- package/scripts/resolve-migration.sh +0 -23
- package/scripts/seed-cli-dev-keys.mjs +0 -130
- package/scripts/seed-test-data.mjs +0 -391
- package/scripts/spike-browserless.ts +0 -65
- package/scripts/tenant-pivot-consistency-check.mjs +0 -205
- package/scripts/tenant-pivot-phase-3-cleanup.lib.cjs +0 -258
- package/scripts/tenant-pivot-phase-3-cleanup.mjs +0 -98
- package/scripts/test-identity-resolution.ts +0 -804
- 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
|
-
};
|