@gurulu/cli 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/package.json +7 -3
  2. package/scripts/.gitkeep +0 -0
  3. package/scripts/README-gurulu-agentic-install.md +114 -0
  4. package/scripts/README-gurulu-scan.md +98 -0
  5. package/scripts/audit-cli-scopes.mjs +204 -0
  6. package/scripts/backfill-tenant-id.mjs +172 -0
  7. package/scripts/backfill-tenant-links.ts +252 -0
  8. package/scripts/backup-clickhouse.sh +27 -0
  9. package/scripts/backup-postgres.sh +19 -0
  10. package/scripts/bootstrap-runtime-schema.mjs +105 -0
  11. package/scripts/bootstrap-stripe.mjs +158 -0
  12. package/scripts/gurulu-agentic-install.lib.cjs +734 -0
  13. package/scripts/gurulu-agentic-install.mjs +343 -0
  14. package/scripts/gurulu-scan.lib.cjs +989 -0
  15. package/scripts/gurulu-scan.mjs +91 -0
  16. package/scripts/gurulu-verify-install.lib.cjs +334 -0
  17. package/scripts/gurulu-verify-install.mjs +59 -0
  18. package/scripts/init-ssl.sh +26 -0
  19. package/scripts/migrate-flow-graph-enums.sh +86 -0
  20. package/scripts/monitor-disk.sh +24 -0
  21. package/scripts/patches/astro.patch.cjs +73 -0
  22. package/scripts/patches/auto-instrument/ast-helper.cjs +332 -0
  23. package/scripts/patches/auto-instrument/astro.cjs +267 -0
  24. package/scripts/patches/auto-instrument/express.cjs +368 -0
  25. package/scripts/patches/auto-instrument/fastify.cjs +258 -0
  26. package/scripts/patches/auto-instrument/index.cjs +78 -0
  27. package/scripts/patches/auto-instrument/nestjs.cjs +282 -0
  28. package/scripts/patches/auto-instrument/nextjs-app-router.cjs +318 -0
  29. package/scripts/patches/auto-instrument/nextjs-pages.cjs +348 -0
  30. package/scripts/patches/auto-instrument/remix.cjs +164 -0
  31. package/scripts/patches/auto-instrument/singleton-helper.cjs +193 -0
  32. package/scripts/patches/auto-instrument/sveltekit.cjs +157 -0
  33. package/scripts/patches/auto-instrument/vite-react.cjs +37 -0
  34. package/scripts/patches/auto-instrument/vue.cjs +192 -0
  35. package/scripts/patches/express.patch.cjs +99 -0
  36. package/scripts/patches/fastify.patch.cjs +107 -0
  37. package/scripts/patches/index.cjs +294 -0
  38. package/scripts/patches/nestjs.patch.cjs +111 -0
  39. package/scripts/patches/nextjs-app-router.patch.cjs +95 -0
  40. package/scripts/patches/nextjs-pages.patch.cjs +96 -0
  41. package/scripts/patches/remix.patch.cjs +74 -0
  42. package/scripts/patches/sveltekit.patch.cjs +71 -0
  43. package/scripts/patches/vite-react.patch.cjs +72 -0
  44. package/scripts/patches/vue.patch.cjs +81 -0
  45. package/scripts/renew-ssl.sh +14 -0
  46. package/scripts/resolve-migration.sh +23 -0
  47. package/scripts/seed-cli-dev-keys.mjs +130 -0
  48. package/scripts/seed-test-data.mjs +391 -0
  49. package/scripts/spike-browserless.ts +65 -0
  50. package/scripts/tenant-pivot-consistency-check.mjs +205 -0
  51. package/scripts/tenant-pivot-phase-3-cleanup.lib.cjs +258 -0
  52. package/scripts/tenant-pivot-phase-3-cleanup.mjs +98 -0
  53. package/scripts/test-identity-resolution.ts +804 -0
  54. package/scripts/validate-gurulu-schemas.mjs +79 -0
@@ -0,0 +1,989 @@
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 path = require('node:path');
12
+
13
+ const SCAN_VERSION = '2';
14
+
15
+ const SKIP_DIRS = new Set([
16
+ 'node_modules',
17
+ '.next',
18
+ '.git',
19
+ 'dist',
20
+ 'build',
21
+ 'out',
22
+ '.turbo',
23
+ '.vercel',
24
+ 'coverage',
25
+ ]);
26
+
27
+ const CODE_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']);
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Framework detection
31
+ // ---------------------------------------------------------------------------
32
+ function detectFramework(deps = {}) {
33
+ const has = (name) => Object.prototype.hasOwnProperty.call(deps, name);
34
+ const ver = (name) =>
35
+ deps[name] ? String(deps[name]).replace(/^[^\d]*/, '') || null : null;
36
+
37
+ if (has('next')) return { name: 'nextjs', version: ver('next') };
38
+ if (has('@nestjs/core')) return { name: 'nestjs', version: ver('@nestjs/core') };
39
+ if (has('@remix-run/dev')) return { name: 'remix', version: ver('@remix-run/dev') };
40
+ if (has('@sveltejs/kit')) return { name: 'sveltekit', version: ver('@sveltejs/kit') };
41
+ if (has('astro')) return { name: 'astro', version: ver('astro') };
42
+ if (has('fastify')) return { name: 'fastify', version: ver('fastify') };
43
+ if (has('express')) return { name: 'express', version: ver('express') };
44
+ if (has('vite') && has('react')) return { name: 'vite-react', version: ver('vite') };
45
+ if (has('vue')) return { name: 'vue', version: ver('vue') };
46
+ return { name: 'unknown', version: null };
47
+ }
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // ORM detection
51
+ // ---------------------------------------------------------------------------
52
+ function detectORM(deps = {}, schemaHints = {}) {
53
+ const has = (name) => Object.prototype.hasOwnProperty.call(deps, name);
54
+
55
+ if (has('@prisma/client') || has('prisma')) {
56
+ return { name: 'prisma', schemaPath: schemaHints.prisma || null };
57
+ }
58
+ if (has('drizzle-orm')) {
59
+ return { name: 'drizzle', schemaPath: schemaHints.drizzle || null };
60
+ }
61
+ if (has('typeorm')) return { name: 'typeorm', schemaPath: null };
62
+ if (has('mongoose')) return { name: 'mongoose', schemaPath: null };
63
+ if (has('sequelize')) return { name: 'sequelize', schemaPath: null };
64
+ if (has('kysely')) return { name: 'kysely', schemaPath: null };
65
+ return { name: 'unknown', schemaPath: null };
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Auth detection
70
+ // ---------------------------------------------------------------------------
71
+ function detectAuth(deps = {}) {
72
+ const has = (name) => Object.prototype.hasOwnProperty.call(deps, name);
73
+
74
+ if (has('next-auth') || has('@auth/core')) return { name: 'nextauth' };
75
+ if (has('@clerk/nextjs') || has('@clerk/clerk-sdk-node') || has('@clerk/clerk-js')) {
76
+ return { name: 'clerk' };
77
+ }
78
+ if (
79
+ has('@supabase/supabase-js') &&
80
+ Object.keys(deps).some((d) => d.startsWith('@supabase/auth-helpers'))
81
+ ) {
82
+ return { name: 'supabase' };
83
+ }
84
+ if (has('firebase-admin') || has('firebase')) return { name: 'firebase' };
85
+ if (has('lucia')) return { name: 'lucia' };
86
+ if (has('passport')) return { name: 'passport' };
87
+ if (has('jsonwebtoken')) return { name: 'custom-jwt' };
88
+ return { name: 'unknown' };
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // File walking
93
+ // ---------------------------------------------------------------------------
94
+ async function walk(dir, collected = []) {
95
+ let entries;
96
+ try {
97
+ entries = await fs.readdir(dir, { withFileTypes: true });
98
+ } catch {
99
+ return collected;
100
+ }
101
+ for (const entry of entries) {
102
+ if (SKIP_DIRS.has(entry.name)) continue;
103
+ if (entry.name.startsWith('.') && entry.name !== '.gurulu') {
104
+ if (entry.name === '.next' || entry.name === '.git' || entry.name === '.turbo') continue;
105
+ }
106
+ const full = path.join(dir, entry.name);
107
+ if (entry.isDirectory()) {
108
+ await walk(full, collected);
109
+ } else if (entry.isFile()) {
110
+ collected.push(full);
111
+ }
112
+ }
113
+ return collected;
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Route detection (Next.js App + Pages router)
118
+ // ---------------------------------------------------------------------------
119
+ function routePathFromAppRouterFile(relFile) {
120
+ const parts = relFile.split(path.sep);
121
+ const appIdx = parts.indexOf('app');
122
+ if (appIdx === -1) return null;
123
+ const afterApp = parts.slice(appIdx + 1);
124
+ if (!/^route\.(ts|tsx|js|jsx|mjs|cjs)$/.test(afterApp[afterApp.length - 1])) return null;
125
+ const segments = afterApp
126
+ .slice(0, -1)
127
+ .filter((seg) => !(seg.startsWith('(') && seg.endsWith(')')) && !seg.startsWith('@'));
128
+ return '/' + segments.join('/');
129
+ }
130
+
131
+ function routePathFromPagesRouterFile(relFile) {
132
+ const parts = relFile.split(path.sep);
133
+ const pagesIdx = parts.indexOf('pages');
134
+ if (pagesIdx === -1) return null;
135
+ const afterPages = parts.slice(pagesIdx + 1);
136
+ if (afterPages[0] !== 'api') return null;
137
+ const last = afterPages[afterPages.length - 1].replace(/\.(ts|tsx|js|jsx|mjs|cjs)$/, '');
138
+ const segs = afterPages.slice(0, -1).concat(last === 'index' ? [] : [last]);
139
+ return '/' + segs.join('/');
140
+ }
141
+
142
+ async function detectRoutes(rootPath, framework) {
143
+ if (!framework || framework.name !== 'nextjs') {
144
+ if (
145
+ framework &&
146
+ ['express', 'fastify', 'nestjs'].includes(framework.name)
147
+ ) {
148
+ return {
149
+ routes: [],
150
+ note: `detection_not_implemented_for_${framework.name}`,
151
+ };
152
+ }
153
+ return { routes: [] };
154
+ }
155
+
156
+ const files = await walk(rootPath);
157
+ const routes = [];
158
+
159
+ for (const abs of files) {
160
+ const rel = path.relative(rootPath, abs);
161
+ const base = path.basename(abs);
162
+
163
+ if (
164
+ /(^|[\\/])app[\\/]/.test(rel) &&
165
+ /[\\/]api[\\/]/.test(rel) &&
166
+ /^route\.(ts|tsx|js|jsx)$/.test(base)
167
+ ) {
168
+ const routePath = routePathFromAppRouterFile(rel);
169
+ if (!routePath) continue;
170
+ let content = '';
171
+ try {
172
+ content = await fs.readFile(abs, 'utf8');
173
+ } catch {
174
+ continue;
175
+ }
176
+ const methods = [];
177
+ for (const m of ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']) {
178
+ const re = new RegExp(
179
+ `export\\s+(?:async\\s+)?(?:function\\s+${m}\\b|const\\s+${m}\\s*=)`,
180
+ );
181
+ if (re.test(content)) methods.push(m);
182
+ }
183
+ routes.push({
184
+ path: routePath,
185
+ methods: methods.length ? methods : ['*'],
186
+ file: rel,
187
+ });
188
+ continue;
189
+ }
190
+
191
+ if (
192
+ /(^|[\\/])pages[\\/]api[\\/]/.test(rel) &&
193
+ /\.(ts|tsx|js|jsx)$/.test(base) &&
194
+ base !== '_middleware.ts'
195
+ ) {
196
+ const routePath = routePathFromPagesRouterFile(rel);
197
+ if (!routePath) continue;
198
+ let content = '';
199
+ try {
200
+ content = await fs.readFile(abs, 'utf8');
201
+ } catch {
202
+ continue;
203
+ }
204
+ const methods = [];
205
+ const methodRe = /req\.method\s*===?\s*['"`](GET|POST|PUT|DELETE|PATCH)['"`]/g;
206
+ let match;
207
+ while ((match = methodRe.exec(content)) !== null) {
208
+ if (!methods.includes(match[1])) methods.push(match[1]);
209
+ }
210
+ routes.push({
211
+ path: routePath,
212
+ methods: methods.length ? methods : ['*'],
213
+ file: rel,
214
+ });
215
+ }
216
+ }
217
+
218
+ routes.sort((a, b) => (a.path + a.file).localeCompare(b.path + b.file));
219
+ return { routes };
220
+ }
221
+
222
+ // ---------------------------------------------------------------------------
223
+ // Business mutation detection
224
+ // ---------------------------------------------------------------------------
225
+ const MUTATION_PATTERNS = [
226
+ {
227
+ kind: 'prisma',
228
+ re: /prisma\.([a-zA-Z_][a-zA-Z0-9_]*)\.(create|createMany|update|updateMany|upsert|delete|deleteMany)\s*\(/g,
229
+ extract: (m) => ({ model: m[1], operation: m[2] }),
230
+ },
231
+ {
232
+ kind: 'drizzle',
233
+ re: /\bdb\.(insert|update|delete)\s*\(\s*([a-zA-Z_][a-zA-Z0-9_]*)?/g,
234
+ extract: (m) => ({ model: m[2] || 'unknown', operation: m[1] }),
235
+ },
236
+ {
237
+ kind: 'mongoose',
238
+ re: /\b([A-Z][a-zA-Z0-9_]*)\.(create|findOneAndUpdate|findOneAndDelete|deleteOne|deleteMany|updateOne|updateMany|insertMany)\s*\(/g,
239
+ extract: (m) => ({ model: m[1], operation: m[2] }),
240
+ },
241
+ {
242
+ kind: 'raw-sql',
243
+ re: /\b(INSERT\s+INTO|UPDATE|DELETE\s+FROM)\s+([`"']?[a-zA-Z_][a-zA-Z0-9_]*[`"']?)/gi,
244
+ extract: (m) => ({
245
+ model: m[2].replace(/[`"']/g, ''),
246
+ operation: m[1].trim().split(/\s+/)[0].toLowerCase(),
247
+ }),
248
+ },
249
+ ];
250
+
251
+ async function detectMutations(rootPath, { limit = 200 } = {}) {
252
+ const files = await walk(rootPath);
253
+ const results = [];
254
+ const seen = new Set();
255
+
256
+ outer: for (const abs of files) {
257
+ const ext = path.extname(abs);
258
+ if (!CODE_EXTS.has(ext)) continue;
259
+ const rel = path.relative(rootPath, abs);
260
+ let content;
261
+ try {
262
+ content = await fs.readFile(abs, 'utf8');
263
+ } catch {
264
+ continue;
265
+ }
266
+ if (content.length > 1_500_000) continue;
267
+
268
+ const lines = content.split(/\r?\n/);
269
+ for (const pattern of MUTATION_PATTERNS) {
270
+ const re = new RegExp(pattern.re.source, pattern.re.flags);
271
+ let match;
272
+ while ((match = re.exec(content)) !== null) {
273
+ const info = pattern.extract(match);
274
+ const upto = content.slice(0, match.index);
275
+ const line = upto.split(/\r?\n/).length;
276
+ const snippet = (lines[line - 1] || '').trim().slice(0, 200);
277
+ const key = `${rel}:${line}:${info.model}:${info.operation}:${snippet}`;
278
+ if (seen.has(key)) continue;
279
+ seen.add(key);
280
+ results.push({
281
+ file: rel,
282
+ line,
283
+ model: info.model,
284
+ operation: info.operation,
285
+ snippet,
286
+ });
287
+ if (results.length >= limit) break outer;
288
+ }
289
+ }
290
+ }
291
+
292
+ return results;
293
+ }
294
+
295
+ // ---------------------------------------------------------------------------
296
+ // Schema hint detection
297
+ // ---------------------------------------------------------------------------
298
+ async function detectSchemaHints(rootPath) {
299
+ const hints = {};
300
+ try {
301
+ await fs.access(path.join(rootPath, 'prisma', 'schema.prisma'));
302
+ hints.prisma = 'prisma/schema.prisma';
303
+ } catch {
304
+ /* noop */
305
+ }
306
+ for (const candidate of ['drizzle.config.ts', 'drizzle.config.js', 'drizzle.config.mjs']) {
307
+ try {
308
+ await fs.access(path.join(rootPath, candidate));
309
+ hints.drizzle = candidate;
310
+ break;
311
+ } catch {
312
+ /* noop */
313
+ }
314
+ }
315
+ return hints;
316
+ }
317
+
318
+ // ---------------------------------------------------------------------------
319
+ // Phase 18.6 A1 — Enhanced signal extractors
320
+ // ---------------------------------------------------------------------------
321
+
322
+ // Simplified Prisma field type classifier.
323
+ function simplifyPrismaType(raw) {
324
+ const t = String(raw).replace(/[\?\[\]]/g, '');
325
+ if (/^(String|Int|BigInt|Float|Decimal|Boolean|DateTime|Json|Bytes)$/.test(t)) {
326
+ return t;
327
+ }
328
+ // Anything else is either an enum or a relation.
329
+ return /^[A-Z]/.test(t) ? `relation:${t}` : t;
330
+ }
331
+
332
+ /**
333
+ * extractSchemaTables — parse ORM schema files for model/table structure.
334
+ *
335
+ * `orm.name === 'prisma'` parses prisma/schema.prisma. Drizzle/TypeORM get a
336
+ * best-effort regex sweep. Capped at 50 tables / 200 columns total to keep
337
+ * LLM prompt size bounded.
338
+ *
339
+ * Accepts an optional `schemaContent` argument so tests can pass inline
340
+ * fixtures without touching the filesystem.
341
+ */
342
+ async function extractSchemaTables(rootPath, orm, schemaContent) {
343
+ const MAX_TABLES = 50;
344
+ const MAX_COLUMNS_TOTAL = 200;
345
+ const tables = [];
346
+ let totalColumns = 0;
347
+
348
+ if (!orm || !orm.name) return tables;
349
+
350
+ let content = schemaContent;
351
+
352
+ if (orm.name === 'prisma') {
353
+ if (!content) {
354
+ const schemaRel = orm.schemaPath || 'prisma/schema.prisma';
355
+ try {
356
+ content = await fs.readFile(path.join(rootPath, schemaRel), 'utf8');
357
+ } catch {
358
+ return tables;
359
+ }
360
+ }
361
+ const modelRe = /model\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{([\s\S]*?)\}/g;
362
+ let m;
363
+ while ((m = modelRe.exec(content)) !== null && tables.length < MAX_TABLES) {
364
+ const name = m[1];
365
+ const body = m[2];
366
+ const columns = [];
367
+ for (const line of body.split(/\r?\n/)) {
368
+ const trimmed = line.trim();
369
+ if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('@@')) continue;
370
+ const fm = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)\s+([A-Za-z_][A-Za-z0-9_\?\[\]]*)/);
371
+ if (!fm) continue;
372
+ columns.push({ name: fm[1], type: simplifyPrismaType(fm[2]) });
373
+ totalColumns++;
374
+ if (totalColumns >= MAX_COLUMNS_TOTAL) break;
375
+ }
376
+ tables.push({ name, columns });
377
+ if (totalColumns >= MAX_COLUMNS_TOTAL) break;
378
+ }
379
+ return tables;
380
+ }
381
+
382
+ if (orm.name === 'drizzle') {
383
+ if (!content) {
384
+ // Best-effort sweep through src/ for drizzle table definitions.
385
+ try {
386
+ const files = await walk(rootPath);
387
+ const chunks = [];
388
+ for (const abs of files) {
389
+ if (!CODE_EXTS.has(path.extname(abs))) continue;
390
+ try {
391
+ const text = await fs.readFile(abs, 'utf8');
392
+ if (/pgTable\s*\(|mysqlTable\s*\(|sqliteTable\s*\(/.test(text)) {
393
+ chunks.push(text);
394
+ }
395
+ } catch {
396
+ /* skip */
397
+ }
398
+ if (chunks.length > 20) break;
399
+ }
400
+ content = chunks.join('\n\n');
401
+ } catch {
402
+ return tables;
403
+ }
404
+ }
405
+ const tableRe =
406
+ /(?:pgTable|mysqlTable|sqliteTable)\s*\(\s*['"`]([A-Za-z_][A-Za-z0-9_]*)['"`]\s*,\s*\{([\s\S]*?)\}\s*\)/g;
407
+ let m;
408
+ while ((m = tableRe.exec(content)) !== null && tables.length < MAX_TABLES) {
409
+ const name = m[1];
410
+ const body = m[2];
411
+ const columns = [];
412
+ const colRe = /([A-Za-z_][A-Za-z0-9_]*)\s*:\s*([A-Za-z_][A-Za-z0-9_]*)\s*\(/g;
413
+ let cm;
414
+ while ((cm = colRe.exec(body)) !== null) {
415
+ columns.push({ name: cm[1], type: cm[2] });
416
+ totalColumns++;
417
+ if (totalColumns >= MAX_COLUMNS_TOTAL) break;
418
+ }
419
+ tables.push({ name, columns });
420
+ if (totalColumns >= MAX_COLUMNS_TOTAL) break;
421
+ }
422
+ return tables;
423
+ }
424
+
425
+ if (orm.name === 'typeorm') {
426
+ if (!content) {
427
+ try {
428
+ const files = await walk(rootPath);
429
+ const chunks = [];
430
+ for (const abs of files) {
431
+ if (!CODE_EXTS.has(path.extname(abs))) continue;
432
+ try {
433
+ const text = await fs.readFile(abs, 'utf8');
434
+ if (/@Entity\s*\(/.test(text)) chunks.push(text);
435
+ } catch {
436
+ /* skip */
437
+ }
438
+ }
439
+ content = chunks.join('\n\n');
440
+ } catch {
441
+ return tables;
442
+ }
443
+ }
444
+ const entityRe =
445
+ /@Entity\s*\([^)]*\)\s*export\s+class\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{([\s\S]*?)\n\}/g;
446
+ let m;
447
+ while ((m = entityRe.exec(content)) !== null && tables.length < MAX_TABLES) {
448
+ const name = m[1];
449
+ const body = m[2];
450
+ const columns = [];
451
+ const colRe = /@Column\s*\([^)]*\)\s*([A-Za-z_][A-Za-z0-9_]*)\s*:\s*([A-Za-z_][A-Za-z0-9_]*)/g;
452
+ let cm;
453
+ while ((cm = colRe.exec(body)) !== null) {
454
+ columns.push({ name: cm[1], type: cm[2] });
455
+ totalColumns++;
456
+ if (totalColumns >= MAX_COLUMNS_TOTAL) break;
457
+ }
458
+ tables.push({ name, columns });
459
+ if (totalColumns >= MAX_COLUMNS_TOTAL) break;
460
+ }
461
+ return tables;
462
+ }
463
+
464
+ return tables;
465
+ }
466
+
467
+ /**
468
+ * extractDependencyFingerprint — filter package.json deps to curated
469
+ * business-signal allowlist. Returns unique sorted array of matched names.
470
+ */
471
+ const BUSINESS_DEPS = [
472
+ 'stripe', '@stripe/stripe-js', '@stripe/react-stripe-js',
473
+ 'plaid', 'dwolla-v2',
474
+ 'twilio', '@sendgrid/mail', 'resend', 'postmark',
475
+ '@jumio/sdk', 'onfido', 'veriff',
476
+ 'shopify-api', '@shopify/shopify-api', '@medusajs',
477
+ 'next-auth', '@auth/core', '@clerk/nextjs', '@clerk/clerk-sdk-node',
478
+ '@supabase/supabase-js', '@supabase/auth-helpers-nextjs',
479
+ '@lemonsqueezy/lemonsqueezy.js', 'revenuecat-node',
480
+ 'algolia', 'meilisearch', 'elasticsearch', '@pinecone-database/pinecone',
481
+ 'openai', '@anthropic-ai/sdk', 'cohere-ai',
482
+ 'mux-node', '@mux/mux-player-react', '@vimeo/player',
483
+ 'mapbox', 'google-maps', '@googlemaps/js-api-loader',
484
+ '@segment/analytics-next', 'mixpanel', 'amplitude-js',
485
+ 'pusher', '@soketi/soketi', 'ably',
486
+ 'prisma', 'drizzle-orm', 'typeorm', 'mongoose', 'sequelize',
487
+ 'redis', 'ioredis', 'bullmq',
488
+ 'bcrypt', 'argon2', 'jsonwebtoken',
489
+ 'socket.io', 'ws', '@trpc/server',
490
+ 'framer-motion', 'react-hook-form', 'zod', 'yup',
491
+ ];
492
+ const BUSINESS_DEPS_SET = new Set(BUSINESS_DEPS);
493
+
494
+ function extractDependencyFingerprint(packageJson) {
495
+ if (!packageJson || typeof packageJson !== 'object') return [];
496
+ const deps = {
497
+ ...(packageJson.dependencies || {}),
498
+ ...(packageJson.devDependencies || {}),
499
+ };
500
+ const matched = new Set();
501
+ for (const name of Object.keys(deps)) {
502
+ if (BUSINESS_DEPS_SET.has(name)) matched.add(name);
503
+ }
504
+ return Array.from(matched).sort();
505
+ }
506
+
507
+ // ---------------------------------------------------------------------------
508
+ // extractUiStrings — harvest user-facing strings from JSX/templates.
509
+ // ---------------------------------------------------------------------------
510
+
511
+ const UI_PROP_NAMES = new Set([
512
+ 'title', 'label', 'placeholder', 'heading', 'description',
513
+ 'aria-label', 'alt', 'subtitle', 'caption', 'tooltip',
514
+ ]);
515
+
516
+ function looksTechnical(s) {
517
+ if (s.length < 3) return true;
518
+ if (/^[a-z-]+$/.test(s)) return true; // slugs
519
+ if (/^[\w-]+\/[\w\-./]+$/.test(s)) return true; // paths / modules
520
+ if (/^https?:\/\//.test(s)) return true;
521
+ if (/^\.\.?\//.test(s)) return true;
522
+ if (/^[a-z]+:[a-z-]+/.test(s)) return true; // protocol: / css-like
523
+ // Tailwind / CSS className heuristic: tokens separated by spaces, all lower.
524
+ if (/\s/.test(s) && /^[\w\s:\-\[\]\/\.]+$/.test(s) && !/[A-Z]/.test(s) && s.split(/\s+/).every((t) => /^[\w:\-\[\]\/\.]+$/.test(t) && /[-:]/.test(t))) {
525
+ return true;
526
+ }
527
+ if (/^[a-z0-9]+(-[a-z0-9]+)+$/.test(s)) return true;
528
+ // Paths / package-style identifiers: must contain a / or _ and no spaces.
529
+ if (/^@?[a-z0-9._/-]+$/i.test(s) && /[\/_]/.test(s) && !/\s/.test(s)) return true;
530
+ if (/^#[0-9a-f]{3,8}$/i.test(s)) return true;
531
+ return false;
532
+ }
533
+
534
+ /**
535
+ * extractUiStringsFromSource — pure function so tests can pass inline source.
536
+ */
537
+ function extractUiStringsFromSource(source, { limit = 500, existing } = {}) {
538
+ const out = existing instanceof Set ? existing : new Set();
539
+
540
+ // JSX text nodes: >Text here<
541
+ const jsxTextRe = />([^<>{}]{3,200})</g;
542
+ let m;
543
+ while ((m = jsxTextRe.exec(source)) !== null) {
544
+ const raw = m[1].trim();
545
+ if (!raw) continue;
546
+ if (/^\s*$/.test(raw)) continue;
547
+ if (/^\{.*\}$/.test(raw)) continue;
548
+ if (looksTechnical(raw)) continue;
549
+ out.add(raw);
550
+ if (out.size >= limit) return Array.from(out);
551
+ }
552
+
553
+ // Props: title="...", label={"..."}
554
+ const propRe = /\b([a-zA-Z-]+)\s*=\s*(?:\{\s*['"`]([^'"`]{3,200})['"`]\s*\}|['"`]([^'"`]{3,200})['"`])/g;
555
+ while ((m = propRe.exec(source)) !== null) {
556
+ const prop = m[1];
557
+ if (!UI_PROP_NAMES.has(prop)) continue;
558
+ const val = (m[2] || m[3] || '').trim();
559
+ if (!val) continue;
560
+ if (looksTechnical(val)) continue;
561
+ out.add(val);
562
+ if (out.size >= limit) return Array.from(out);
563
+ }
564
+
565
+ // toast.*(...), alert(...), confirm(...), prompt(...)
566
+ const callRe = /\b(?:toast(?:\.[a-zA-Z]+)?|alert|confirm|prompt)\s*\(\s*['"`]([^'"`]{3,200})['"`]/g;
567
+ while ((m = callRe.exec(source)) !== null) {
568
+ const val = m[1].trim();
569
+ if (!val || looksTechnical(val)) continue;
570
+ out.add(val);
571
+ if (out.size >= limit) return Array.from(out);
572
+ }
573
+
574
+ return Array.from(out);
575
+ }
576
+
577
+ async function extractUiStrings(rootPath, framework) {
578
+ const limit = 500;
579
+ const collected = new Set();
580
+ const exts = new Set(['.tsx', '.jsx']);
581
+ if (framework && framework.name === 'vue') exts.add('.vue');
582
+ if (framework && framework.name === 'sveltekit') exts.add('.svelte');
583
+
584
+ let files;
585
+ try {
586
+ files = await walk(rootPath);
587
+ } catch {
588
+ return [];
589
+ }
590
+ for (const abs of files) {
591
+ if (collected.size >= limit) break;
592
+ if (!exts.has(path.extname(abs))) continue;
593
+ let text;
594
+ try {
595
+ text = await fs.readFile(abs, 'utf8');
596
+ } catch {
597
+ continue;
598
+ }
599
+ if (text.length > 500_000) continue;
600
+ extractUiStringsFromSource(text, { limit, existing: collected });
601
+ }
602
+ return Array.from(collected).slice(0, limit);
603
+ }
604
+
605
+ // ---------------------------------------------------------------------------
606
+ // extractI18nKeys — recursive key-path harvest from JSON locale files.
607
+ // ---------------------------------------------------------------------------
608
+
609
+ function harvestKeyPaths(obj, prefix, out, cap) {
610
+ if (out.size >= cap) return;
611
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return;
612
+ for (const k of Object.keys(obj)) {
613
+ const full = prefix ? `${prefix}.${k}` : k;
614
+ const v = obj[k];
615
+ if (v && typeof v === 'object' && !Array.isArray(v)) {
616
+ harvestKeyPaths(v, full, out, cap);
617
+ } else {
618
+ out.add(full);
619
+ }
620
+ if (out.size >= cap) return;
621
+ }
622
+ }
623
+
624
+ async function extractI18nKeys(rootPath, { inlineFiles } = {}) {
625
+ const cap = 200;
626
+ const keys = new Set();
627
+
628
+ if (inlineFiles) {
629
+ for (const obj of inlineFiles) {
630
+ harvestKeyPaths(obj, '', keys, cap);
631
+ if (keys.size >= cap) break;
632
+ }
633
+ return Array.from(keys).slice(0, cap);
634
+ }
635
+
636
+ const candidateDirs = [
637
+ path.join(rootPath, 'src', 'i18n'),
638
+ path.join(rootPath, 'messages'),
639
+ path.join(rootPath, 'locales'),
640
+ path.join(rootPath, 'public', 'locales'),
641
+ path.join(rootPath, 'src', 'locales'),
642
+ ];
643
+
644
+ for (const dir of candidateDirs) {
645
+ let files;
646
+ try {
647
+ files = await walk(dir);
648
+ } catch {
649
+ continue;
650
+ }
651
+ for (const abs of files) {
652
+ if (path.extname(abs) !== '.json') continue;
653
+ let text;
654
+ try {
655
+ text = await fs.readFile(abs, 'utf8');
656
+ } catch {
657
+ continue;
658
+ }
659
+ try {
660
+ const parsed = JSON.parse(text);
661
+ harvestKeyPaths(parsed, '', keys, cap);
662
+ } catch {
663
+ /* skip malformed */
664
+ }
665
+ if (keys.size >= cap) break;
666
+ }
667
+ if (keys.size >= cap) break;
668
+ }
669
+
670
+ return Array.from(keys).slice(0, cap);
671
+ }
672
+
673
+ // ---------------------------------------------------------------------------
674
+ // extractFolderStructureHints — list significant route / api subfolders.
675
+ // ---------------------------------------------------------------------------
676
+
677
+ async function extractFolderStructureHints(rootPath, framework) {
678
+ const cap = 100;
679
+ const hints = new Set();
680
+
681
+ async function listDirs(root) {
682
+ const out = [];
683
+ let entries;
684
+ try {
685
+ entries = await fs.readdir(root, { withFileTypes: true });
686
+ } catch {
687
+ return out;
688
+ }
689
+ for (const e of entries) {
690
+ if (!e.isDirectory()) continue;
691
+ if (e.name.startsWith('_')) continue; // private
692
+ if (SKIP_DIRS.has(e.name)) continue;
693
+ out.push(e.name);
694
+ }
695
+ return out;
696
+ }
697
+
698
+ async function walkDirs(root, prefix, depth) {
699
+ if (depth < 0 || hints.size >= cap) return;
700
+ const dirs = await listDirs(root);
701
+ for (const d of dirs) {
702
+ const full = prefix ? `${prefix}/${d}` : d;
703
+ hints.add(full);
704
+ if (hints.size >= cap) return;
705
+ await walkDirs(path.join(root, d), full, depth - 1);
706
+ }
707
+ }
708
+
709
+ if (framework && framework.name === 'nextjs') {
710
+ // App Router
711
+ for (const base of ['src/app', 'app']) {
712
+ const root = path.join(rootPath, base);
713
+ try {
714
+ await fs.access(root);
715
+ await walkDirs(root, base, 3);
716
+ } catch {
717
+ /* skip */
718
+ }
719
+ }
720
+ // Pages Router
721
+ for (const base of ['src/pages/api', 'pages/api']) {
722
+ const root = path.join(rootPath, base);
723
+ try {
724
+ await fs.access(root);
725
+ await walkDirs(root, base, 3);
726
+ } catch {
727
+ /* skip */
728
+ }
729
+ }
730
+ } else if (framework && ['express', 'fastify', 'nestjs'].includes(framework.name)) {
731
+ for (const base of ['src/routes', 'routes', 'src/controllers', 'src/modules']) {
732
+ const root = path.join(rootPath, base);
733
+ try {
734
+ const entries = await fs.readdir(root, { withFileTypes: true });
735
+ for (const e of entries) {
736
+ if (e.isFile()) {
737
+ hints.add(`${base}/${e.name.replace(/\.(ts|js|mjs)$/, '')}`);
738
+ } else if (e.isDirectory() && !SKIP_DIRS.has(e.name)) {
739
+ hints.add(`${base}/${e.name}`);
740
+ }
741
+ if (hints.size >= cap) break;
742
+ }
743
+ } catch {
744
+ /* skip */
745
+ }
746
+ }
747
+ }
748
+
749
+ return Array.from(hints).slice(0, cap);
750
+ }
751
+
752
+ // ---------------------------------------------------------------------------
753
+ // extractEnvVarHints — parse .env.example/.env for variable NAMES (never values).
754
+ // ---------------------------------------------------------------------------
755
+
756
+ function extractEnvNamesFromContent(content) {
757
+ const names = new Set();
758
+ for (const line of content.split(/\r?\n/)) {
759
+ const trimmed = line.trim();
760
+ if (!trimmed || trimmed.startsWith('#')) continue;
761
+ const m = trimmed.match(/^(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=/);
762
+ if (m) names.add(m[1]);
763
+ }
764
+ return names;
765
+ }
766
+
767
+ async function extractEnvVarHints(rootPath, { inlineContent } = {}) {
768
+ const cap = 100;
769
+ const names = new Set();
770
+
771
+ if (inlineContent !== undefined) {
772
+ for (const n of extractEnvNamesFromContent(inlineContent)) names.add(n);
773
+ return Array.from(names).slice(0, cap);
774
+ }
775
+
776
+ const candidates = ['.env.example', '.env.sample', '.env.local.example', '.env'];
777
+ for (const c of candidates) {
778
+ let text;
779
+ try {
780
+ text = await fs.readFile(path.join(rootPath, c), 'utf8');
781
+ } catch {
782
+ continue;
783
+ }
784
+ for (const n of extractEnvNamesFromContent(text)) {
785
+ names.add(n);
786
+ if (names.size >= cap) break;
787
+ }
788
+ if (names.size >= cap) break;
789
+ }
790
+
791
+ return Array.from(names).slice(0, cap);
792
+ }
793
+
794
+ // ---------------------------------------------------------------------------
795
+ // extractCommentBlockHints — grab first lines of JSDoc / block comments that
796
+ // mention business-term trigger words.
797
+ // ---------------------------------------------------------------------------
798
+
799
+ const BUSINESS_TRIGGER_WORDS = [
800
+ 'order', 'payment', 'checkout', 'subscription', 'user', 'account',
801
+ 'bet', 'odds', 'wager', 'wallet', 'deposit', 'withdrawal', 'kyc',
802
+ 'patient', 'appointment', 'prescription', 'provider',
803
+ 'course', 'lesson', 'enrollment', 'student',
804
+ 'policy', 'claim', 'invoice', 'transaction', 'card',
805
+ 'product', 'cart', 'shipment', 'refund', 'catalog',
806
+ 'listing', 'reservation', 'booking', 'ticket',
807
+ ];
808
+ const TRIGGER_RE = new RegExp(`\\b(${BUSINESS_TRIGGER_WORDS.join('|')})s?\\b`, 'i');
809
+
810
+ function extractCommentsFromSource(source) {
811
+ const out = [];
812
+ const blockRe = /\/\*\*?([\s\S]*?)\*\//g;
813
+ let m;
814
+ while ((m = blockRe.exec(source)) !== null) {
815
+ const body = m[1];
816
+ const firstLine = body
817
+ .split(/\r?\n/)
818
+ .map((l) => l.replace(/^\s*\*\s?/, '').trim())
819
+ .find((l) => l.length > 0);
820
+ if (!firstLine) continue;
821
+ if (!TRIGGER_RE.test(firstLine)) continue;
822
+ out.push(firstLine.slice(0, 160));
823
+ }
824
+ return out;
825
+ }
826
+
827
+ async function extractCommentBlockHints(rootPath) {
828
+ const cap = 50;
829
+ const out = [];
830
+ let files;
831
+ try {
832
+ files = await walk(rootPath);
833
+ } catch {
834
+ return out;
835
+ }
836
+ for (const abs of files) {
837
+ if (out.length >= cap) break;
838
+ if (!CODE_EXTS.has(path.extname(abs))) continue;
839
+ let text;
840
+ try {
841
+ text = await fs.readFile(abs, 'utf8');
842
+ } catch {
843
+ continue;
844
+ }
845
+ if (text.length > 1_500_000) continue;
846
+ for (const c of extractCommentsFromSource(text)) {
847
+ out.push(c);
848
+ if (out.length >= cap) break;
849
+ }
850
+ }
851
+ return out.slice(0, cap);
852
+ }
853
+
854
+ // ---------------------------------------------------------------------------
855
+ // Aggregated scan
856
+ // ---------------------------------------------------------------------------
857
+ async function scanRepo(rootPath, { log = () => {} } = {}) {
858
+ const abs = path.resolve(rootPath);
859
+ log(`[scan] root: ${abs}`);
860
+
861
+ let pkg = {};
862
+ try {
863
+ const raw = await fs.readFile(path.join(abs, 'package.json'), 'utf8');
864
+ pkg = JSON.parse(raw);
865
+ } catch {
866
+ log('[scan] no package.json — continuing with empty deps');
867
+ }
868
+ const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
869
+
870
+ const framework = detectFramework(deps);
871
+ log(`[scan] framework: ${framework.name} ${framework.version || ''}`);
872
+
873
+ const schemaHints = await detectSchemaHints(abs);
874
+ const orm = detectORM(deps, schemaHints);
875
+ log(`[scan] orm: ${orm.name}`);
876
+
877
+ const auth = detectAuth(deps);
878
+ log(`[scan] auth: ${auth.name}`);
879
+
880
+ let scanRoot = abs;
881
+ try {
882
+ const srcStat = await fs.stat(path.join(abs, 'src'));
883
+ if (srcStat.isDirectory()) scanRoot = path.join(abs, 'src');
884
+ } catch {
885
+ /* fallback to root */
886
+ }
887
+ log(`[scan] walk root: ${path.relative(abs, scanRoot) || '.'}`);
888
+
889
+ const { routes, note: routeNote } = await detectRoutes(scanRoot, framework);
890
+ log(`[scan] routes: ${routes.length}${routeNote ? ` (${routeNote})` : ''}`);
891
+
892
+ const mutations = await detectMutations(scanRoot);
893
+ log(`[scan] mutations: ${mutations.length}`);
894
+
895
+ // Phase 18.6 A1 — enriched signal extraction
896
+ const schemaTables = await extractSchemaTables(abs, orm);
897
+ log(`[scan] schemaTables: ${schemaTables.length}`);
898
+
899
+ const dependencyFingerprint = extractDependencyFingerprint(pkg);
900
+ log(`[scan] dependencyFingerprint: ${dependencyFingerprint.length}`);
901
+
902
+ const uiStrings = await extractUiStrings(scanRoot, framework);
903
+ log(`[scan] uiStrings: ${uiStrings.length}`);
904
+
905
+ const i18nKeys = await extractI18nKeys(abs);
906
+ log(`[scan] i18nKeys: ${i18nKeys.length}`);
907
+
908
+ const folderStructureHints = await extractFolderStructureHints(abs, framework);
909
+ log(`[scan] folderStructureHints: ${folderStructureHints.length}`);
910
+
911
+ const envVarHints = await extractEnvVarHints(abs);
912
+ log(`[scan] envVarHints: ${envVarHints.length}`);
913
+
914
+ const commentBlockHints = await extractCommentBlockHints(scanRoot);
915
+ log(`[scan] commentBlockHints: ${commentBlockHints.length}`);
916
+
917
+ // Rough total file count (for metadata only).
918
+ let totalFiles = 0;
919
+ try {
920
+ const files = await walk(scanRoot);
921
+ totalFiles = files.length;
922
+ } catch {
923
+ /* skip */
924
+ }
925
+
926
+ const normalizedRoutes = (routes || []).flatMap((r) =>
927
+ (r.methods && r.methods.length ? r.methods : ['*']).map((method) => ({
928
+ method,
929
+ path: r.path,
930
+ })),
931
+ );
932
+
933
+ const normalizedMutations = (mutations || []).map((m) => ({
934
+ file: m.file,
935
+ op: `${m.model || 'unknown'}.${m.operation}`,
936
+ model: m.model || undefined,
937
+ }));
938
+
939
+ const plan = {
940
+ site_id: null,
941
+ domains: [],
942
+ framework,
943
+ orm,
944
+ auth,
945
+ routes: normalizedRoutes,
946
+ rawRoutes: routes,
947
+ businessSurfaces: [],
948
+ mutations: normalizedMutations,
949
+ rawMutations: mutations,
950
+ schemaTables,
951
+ dependencyFingerprint,
952
+ uiStrings,
953
+ envVarHints,
954
+ folderStructureHints,
955
+ commentBlockHints,
956
+ i18nKeys,
957
+ metadata: {
958
+ scannedAt: new Date().toISOString(),
959
+ scanVersion: SCAN_VERSION,
960
+ repoPath: abs,
961
+ totalFiles,
962
+ },
963
+ scannedAt: new Date().toISOString(),
964
+ scanVersion: SCAN_VERSION,
965
+ };
966
+ if (routeNote) plan.routeDetectionNote = routeNote;
967
+ return plan;
968
+ }
969
+
970
+ module.exports = {
971
+ SCAN_VERSION,
972
+ detectFramework,
973
+ detectORM,
974
+ detectAuth,
975
+ detectRoutes,
976
+ detectMutations,
977
+ scanRepo,
978
+ // Phase 18.6 A1
979
+ extractSchemaTables,
980
+ extractDependencyFingerprint,
981
+ extractUiStrings,
982
+ extractUiStringsFromSource,
983
+ extractI18nKeys,
984
+ extractFolderStructureHints,
985
+ extractEnvVarHints,
986
+ extractCommentBlockHints,
987
+ extractCommentsFromSource,
988
+ BUSINESS_DEPS,
989
+ };