apiforge-analyzer 1.1.0 → 1.3.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/dist/cjs/index.js CHANGED
@@ -1,10 +1,12 @@
1
1
  "use strict";
2
2
  /**
3
- * Main analyzer entry point with dual module support
3
+ * APIForge Analyzer — main entry point (dual ESM/CJS)
4
4
  */
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.detectFramework = exports.uploadRoutes = exports.exportToJSON = exports.analyzeReactRouter = exports.analyzeVite = exports.analyzeNextApp = exports.analyzeNextPages = exports.analyzeExpress = void 0;
6
+ exports.detectArchitectureType = exports.detectFramework = exports.uploadRoutes = exports.exportToJSON = exports.analyzeNestJS = exports.analyzeReactRouter = exports.analyzeVite = exports.analyzeNextApp = exports.analyzeNextPages = exports.analyzeExpress = void 0;
7
7
  exports.analyze = analyze;
8
+ const fs_1 = require("fs");
9
+ const path_1 = require("path");
8
10
  const express_js_1 = require("./frameworks/express.js");
9
11
  Object.defineProperty(exports, "analyzeExpress", { enumerable: true, get: function () { return express_js_1.analyzeExpress; } });
10
12
  const nextjs_pages_js_1 = require("./frameworks/nextjs-pages.js");
@@ -15,92 +17,185 @@ const vite_js_1 = require("./frameworks/vite.js");
15
17
  Object.defineProperty(exports, "analyzeVite", { enumerable: true, get: function () { return vite_js_1.analyzeVite; } });
16
18
  const react_router_js_1 = require("./frameworks/react-router.js");
17
19
  Object.defineProperty(exports, "analyzeReactRouter", { enumerable: true, get: function () { return react_router_js_1.analyzeReactRouter; } });
20
+ const nestjs_js_1 = require("./frameworks/nestjs.js");
21
+ Object.defineProperty(exports, "analyzeNestJS", { enumerable: true, get: function () { return nestjs_js_1.analyzeNestJS; } });
18
22
  const detect_js_1 = require("./detect.js");
19
23
  const export_js_1 = require("./export.js");
20
24
  const uploader_js_1 = require("./uploader.js");
21
- // Global state for debouncing
22
- const analysisState = new Map();
23
- const DEBOUNCE_TIME = 5000;
25
+ const arch_detect_js_1 = require("./arch-detect.js");
26
+ const express_js_2 = require("./modules/express.js");
27
+ const nestjs_js_2 = require("./modules/nestjs.js");
28
+ // ─── Env file loader — no dotenv dependency ───────────────────────────────────
24
29
  /**
25
- * Main analyze function with auto-detection
30
+ * Read a single variable from a .env-style file.
31
+ * Plain Node/Express doesn't auto-load .env.local, so we do it ourselves.
32
+ * The package never requires dotenv or any other dep for this.
26
33
  */
34
+ function readEnvFile(varName) {
35
+ const candidates = [
36
+ '.env.local',
37
+ '.env.development.local',
38
+ '.env.development',
39
+ '.env',
40
+ ];
41
+ for (const file of candidates) {
42
+ try {
43
+ const content = (0, fs_1.readFileSync)((0, path_1.join)(process.cwd(), file), 'utf-8');
44
+ for (const line of content.split('\n')) {
45
+ const trimmed = line.trim();
46
+ if (trimmed.startsWith('#') || !trimmed.includes('='))
47
+ continue;
48
+ const eqIdx = trimmed.indexOf('=');
49
+ const key = trimmed.slice(0, eqIdx).trim();
50
+ if (key !== varName)
51
+ continue;
52
+ return trimmed.slice(eqIdx + 1).trim().replace(/^['"]|['"]$/g, '');
53
+ }
54
+ }
55
+ catch { }
56
+ }
57
+ return undefined;
58
+ }
59
+ // ─── File-based debounce (survives nodemon restarts) ──────────────────────────
60
+ const DEBOUNCE_FILE = '.apiforge-last-run.json';
61
+ const DEBOUNCE_MS = 30000; // 30 s
62
+ function readLastRunMs() {
63
+ try {
64
+ const raw = (0, fs_1.readFileSync)((0, path_1.join)(process.cwd(), DEBOUNCE_FILE), 'utf-8');
65
+ return JSON.parse(raw).timestamp ?? 0;
66
+ }
67
+ catch {
68
+ return 0;
69
+ }
70
+ }
71
+ function stampLastRun() {
72
+ try {
73
+ (0, fs_1.writeFileSync)((0, path_1.join)(process.cwd(), DEBOUNCE_FILE), JSON.stringify({ timestamp: Date.now() }), 'utf-8');
74
+ }
75
+ catch { }
76
+ }
77
+ // ─── Main analyze ─────────────────────────────────────────────────────────────
27
78
  async function analyze(app, options) {
28
- // Skip in production
79
+ // Never run in production
29
80
  if (options.skipInProduction !== false && process.env.NODE_ENV === 'production') {
30
- console.log('⏭️ Skipping analysis in production');
31
81
  return [];
32
82
  }
33
- // Debounce check
34
- if (options.runOnce !== false) {
35
- const cached = analysisState.get(options.projectName);
36
- const now = Date.now();
37
- if (cached && (now - cached.timestamp) < DEBOUNCE_TIME) {
38
- console.log(`⚡ Analysis skipped - already ran ${Math.round((now - cached.timestamp) / 1000)}s ago`);
39
- return cached.routes;
83
+ // File-based debounce — survives nodemon restarts.
84
+ // Skipped in collection mode (statusFile set) because the CLI controls each service start
85
+ // and the shared .apiforge-last-run.json would cause services 2+ to be skipped.
86
+ if (options.runOnce !== false && !options.statusFile) {
87
+ const elapsed = Date.now() - readLastRunMs();
88
+ if (elapsed < DEBOUNCE_MS) {
89
+ console.log(`⚡ APIForge: skipped (last run ${Math.round(elapsed / 1000)}s ago)`);
90
+ return [];
40
91
  }
92
+ stampLastRun();
41
93
  }
42
- console.log(`🔍 Analyzing ${options.projectName}...`);
43
- // CRITICAL: Prevent restart loops - force saveJson=false when uploading
94
+ // ── Resolve API key ─────────────────────────────────────────────────────────
95
+ // Priority: explicit option → process.env → .env.local/.env file
96
+ // This means the package works even if the user's server doesn't load dotenv.
97
+ if (!options.apiKey) {
98
+ options.apiKey =
99
+ process.env.APIFORGE_API_KEY ||
100
+ readEnvFile('APIFORGE_API_KEY');
101
+ }
102
+ console.log(`🔍 APIForge: analyzing ${options.projectName}…`);
103
+ // Writing saveJson files into the watched dir triggers nodemon restart loops
44
104
  if (options.upload !== false && options.apiKey) {
45
105
  options.saveJson = false;
46
106
  }
107
+ // ── Early failure: upload desired but no API key anywhere ──────────────────
108
+ if (options.upload !== false && !options.apiKey) {
109
+ const msg = 'APIFORGE_API_KEY not found in options, process.env, or .env.local';
110
+ console.error(`❌ APIForge: ${msg}`);
111
+ (0, uploader_js_1.writeStatusFile)('error', { error: msg });
112
+ return [];
113
+ }
47
114
  let routes = [];
48
115
  let framework = options.framework;
49
- // Auto-detect framework if not specified
50
116
  if (!framework) {
51
- const detected = (0, detect_js_1.detectFramework)(app);
52
- framework = detected.name;
117
+ framework = (0, detect_js_1.detectFramework)(app).name;
53
118
  console.log(`📦 Detected framework: ${framework}`);
54
119
  }
55
- // Analyze based on framework
56
- switch (framework) {
57
- case 'express':
58
- if (!app) {
59
- console.error('❌ Express app instance required');
120
+ try {
121
+ switch (framework) {
122
+ case 'nestjs':
123
+ routes = await (0, nestjs_js_1.analyzeNestJS)(app, options.nextDir || process.cwd());
124
+ break;
125
+ case 'express':
126
+ if (!app) {
127
+ const msg = 'Express app instance required — pass your app object to analyze()';
128
+ console.error(`❌ ${msg}`);
129
+ (0, uploader_js_1.writeStatusFile)('error', { error: msg });
130
+ return [];
131
+ }
132
+ routes = await (0, express_js_1.analyzeExpress)(app);
133
+ // For modular-monolithic: annotate each route with its module name
134
+ if (options.architectureType === 'modular-monolithic') {
135
+ const dir = options.nextDir || process.cwd();
136
+ const modules = (0, nestjs_js_2.detectNestModules)(dir).length > 0
137
+ ? (0, nestjs_js_2.detectNestModules)(dir)
138
+ : (0, express_js_2.detectExpressModules)(dir);
139
+ if (modules.length > 0) {
140
+ routes = (0, express_js_2.annotateRoutesWithModules)(routes, modules);
141
+ }
142
+ }
143
+ break;
144
+ case 'nextjs-pages':
145
+ routes = await (0, nextjs_pages_js_1.analyzeNextPages)(options.nextDir || 'pages/api');
146
+ break;
147
+ case 'nextjs-app':
148
+ routes = await (0, nextjs_app_js_1.analyzeNextApp)(options.nextDir || 'app');
149
+ break;
150
+ case 'vite':
151
+ routes = await (0, vite_js_1.analyzeVite)(options.nextDir || 'vite.config.js');
152
+ break;
153
+ case 'react-router':
154
+ routes = await (0, react_router_js_1.analyzeReactRouter)(options.nextDir || 'src/routes.tsx');
155
+ break;
156
+ default:
157
+ const msg = `Framework "${framework}" not supported yet (express, nextjs-pages, nextjs-app, vite, react-router)`;
158
+ console.warn(`⚠️ APIForge: ${msg}`);
159
+ (0, uploader_js_1.writeStatusFile)('error', { error: msg });
60
160
  return [];
61
- }
62
- routes = await (0, express_js_1.analyzeExpress)(app);
63
- break;
64
- case 'nextjs-pages':
65
- routes = await (0, nextjs_pages_js_1.analyzeNextPages)(options.nextDir || 'pages/api');
66
- break;
67
- case 'nextjs-app':
68
- routes = await (0, nextjs_app_js_1.analyzeNextApp)(options.nextDir || 'app');
69
- break;
70
- case 'vite':
71
- routes = await (0, vite_js_1.analyzeVite)(options.nextDir || 'vite.config.js');
72
- break;
73
- case 'react-router':
74
- routes = await (0, react_router_js_1.analyzeReactRouter)(options.nextDir || 'src/routes.tsx');
75
- break;
76
- case 'node-http':
77
- case 'unknown':
78
- default:
79
- console.warn(`⚠️ Framework "${framework}" not supported yet`);
80
- console.log('💡 Supported: express, nextjs-pages, nextjs-app, vite, react-router');
81
- console.log('💡 Tip: Specify framework manually: { framework: "express" }');
82
- return [];
161
+ }
83
162
  }
84
- console.log(`✅ Found ${routes.length} routes`);
85
- // Cache results
86
- if (options.runOnce !== false) {
87
- analysisState.set(options.projectName, {
88
- timestamp: Date.now(),
89
- routes
90
- });
163
+ catch (err) {
164
+ const msg = err.message || 'Unknown error during route extraction';
165
+ console.error(`❌ APIForge analysis error: ${msg}`);
166
+ (0, uploader_js_1.writeStatusFile)('error', { error: msg });
167
+ return [];
91
168
  }
92
- // Save JSON (only if not uploading)
169
+ if (routes.length === 0) {
170
+ const msg = 'No routes found — ensure routes are registered before analyze() is called';
171
+ console.warn(`⚠️ APIForge: ${msg}`);
172
+ (0, uploader_js_1.writeStatusFile)('error', { error: msg });
173
+ return [];
174
+ }
175
+ console.log(`✅ Found ${routes.length} routes`);
93
176
  if (options.saveJson) {
94
- const filePath = options.jsonPath || 'api-export.json';
95
- await (0, export_js_1.exportToJSON)(routes, options.projectName, filePath, options.baseUrl);
177
+ await (0, export_js_1.exportToJSON)(routes, options.projectName, options.jsonPath || 'api-export.json', options.baseUrl);
178
+ }
179
+ // Stamp service name on routes if running in microservices mode
180
+ if (options.service) {
181
+ routes = routes.map(r => ({ ...r, service: options.service }));
182
+ }
183
+ // Multi-service collection mode: write routes to a per-service file for the CLI to aggregate.
184
+ // The CLI spawns each service individually, collects via this file, then uploads once.
185
+ if (options.statusFile) {
186
+ try {
187
+ (0, fs_1.writeFileSync)((0, path_1.join)(process.cwd(), options.statusFile), JSON.stringify({ status: 'collected', service: options.service, routes, routeCount: routes.length }), 'utf-8');
188
+ }
189
+ catch { }
190
+ return routes;
96
191
  }
97
- // Upload to APIForge
98
192
  if (options.upload !== false && options.apiKey) {
99
193
  await (0, uploader_js_1.uploadRoutes)(routes, {
100
194
  projectName: options.projectName,
101
195
  apiKey: options.apiKey,
102
196
  baseUrl: options.baseUrl,
103
197
  apiForgeUrl: options.apiForgeUrl,
198
+ architectureType: options.architectureType,
104
199
  });
105
200
  }
106
201
  return routes;
@@ -111,7 +206,8 @@ var uploader_js_2 = require("./uploader.js");
111
206
  Object.defineProperty(exports, "uploadRoutes", { enumerable: true, get: function () { return uploader_js_2.uploadRoutes; } });
112
207
  var detect_js_2 = require("./detect.js");
113
208
  Object.defineProperty(exports, "detectFramework", { enumerable: true, get: function () { return detect_js_2.detectFramework; } });
114
- // Default export for CommonJS
209
+ var arch_detect_js_2 = require("./arch-detect.js");
210
+ Object.defineProperty(exports, "detectArchitectureType", { enumerable: true, get: function () { return arch_detect_js_2.detectArchitectureType; } });
115
211
  exports.default = {
116
212
  analyze,
117
213
  analyzeExpress: express_js_1.analyzeExpress,
@@ -119,7 +215,9 @@ exports.default = {
119
215
  analyzeNextApp: nextjs_app_js_1.analyzeNextApp,
120
216
  analyzeVite: vite_js_1.analyzeVite,
121
217
  analyzeReactRouter: react_router_js_1.analyzeReactRouter,
218
+ analyzeNestJS: nestjs_js_1.analyzeNestJS,
122
219
  exportToJSON: export_js_1.exportToJSON,
123
220
  uploadRoutes: uploader_js_1.uploadRoutes,
124
- detectFramework: detect_js_1.detectFramework
221
+ detectFramework: detect_js_1.detectFramework,
222
+ detectArchitectureType: arch_detect_js_1.detectArchitectureType,
125
223
  };
@@ -0,0 +1,65 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.detectExpressModules = detectExpressModules;
4
+ exports.resolveModuleForPath = resolveModuleForPath;
5
+ exports.annotateRoutesWithModules = annotateRoutesWithModules;
6
+ const fs_1 = require("fs");
7
+ const path_1 = require("path");
8
+ /**
9
+ * Detect Express feature-module structure by scanning standard directory patterns.
10
+ * Works for: src/modules/, src/features/, src/domains/ conventions.
11
+ */
12
+ function detectExpressModules(rootDir) {
13
+ const modules = [];
14
+ const srcDir = (0, path_1.join)(rootDir, 'src');
15
+ for (const parentDir of ['modules', 'features', 'domains']) {
16
+ const dir = (0, fs_1.existsSync)(srcDir)
17
+ ? (0, path_1.join)(srcDir, parentDir)
18
+ : (0, path_1.join)(rootDir, parentDir);
19
+ if (!(0, fs_1.existsSync)(dir))
20
+ continue;
21
+ const subdirs = (0, fs_1.readdirSync)(dir, { withFileTypes: true })
22
+ .filter(entry => entry.isDirectory())
23
+ .map(entry => entry.name);
24
+ for (const name of subdirs) {
25
+ const routePrefix = `/${name}`;
26
+ modules.push({ name, routePrefix });
27
+ }
28
+ if (modules.length > 0)
29
+ break;
30
+ }
31
+ return modules;
32
+ }
33
+ /**
34
+ * Given a route path, return the module name it belongs to.
35
+ * Falls back to the tag (first meaningful path segment) when no module matches.
36
+ */
37
+ function resolveModuleForPath(routePath, modules) {
38
+ if (modules.length === 0)
39
+ return undefined;
40
+ // Strip /api/v1/ etc. prefixes before matching
41
+ const normalized = routePath.replace(/^\/(api\/)?(v\d+\/)?/, '/');
42
+ for (const mod of modules) {
43
+ if (normalized.startsWith(mod.routePrefix + '/') || normalized === mod.routePrefix) {
44
+ return mod.name;
45
+ }
46
+ }
47
+ // Fallback: first non-param, non-version segment
48
+ const segment = normalized.split('/').filter(s => s && !s.startsWith(':'))[0];
49
+ return segment || undefined;
50
+ }
51
+ /**
52
+ * Annotate a list of routes with their module names using path-prefix matching.
53
+ */
54
+ function annotateRoutesWithModules(routes, modules) {
55
+ return routes.map(route => ({
56
+ ...route,
57
+ module: resolveModuleForPath(route.path, modules) || inferModuleFromPath(route.path),
58
+ }));
59
+ }
60
+ function inferModuleFromPath(routePath) {
61
+ const skip = new Set(['api', 'v1', 'v2', 'v3']);
62
+ return (routePath
63
+ .split('/')
64
+ .filter(s => s && !s.startsWith(':') && !skip.has(s))[0] || 'general');
65
+ }
@@ -0,0 +1,50 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.detectNestModules = detectNestModules;
4
+ const fs_1 = require("fs");
5
+ const path_1 = require("path");
6
+ /**
7
+ * Detect NestJS modules by scanning for *.module.ts files and extracting
8
+ * their declared controllers with the @Module() decorator.
9
+ */
10
+ function detectNestModules(rootDir) {
11
+ const srcDir = (0, path_1.join)(rootDir, 'src');
12
+ const searchDir = (0, fs_1.existsSync)(srcDir) ? srcDir : rootDir;
13
+ const moduleFiles = findModuleFiles(searchDir);
14
+ if (moduleFiles.length === 0)
15
+ return [];
16
+ const modules = [];
17
+ for (const filePath of moduleFiles) {
18
+ const name = (0, path_1.basename)(filePath).replace('.module.ts', '');
19
+ // Skip the root AppModule — it's the container, not a domain module
20
+ if (name === 'app')
21
+ continue;
22
+ const routePrefix = inferPrefixFromModuleName(name);
23
+ modules.push({ name, routePrefix });
24
+ }
25
+ return modules;
26
+ }
27
+ function findModuleFiles(dir, depth = 0) {
28
+ if (depth > 8)
29
+ return [];
30
+ const results = [];
31
+ try {
32
+ const entries = (0, fs_1.readdirSync)(dir, { withFileTypes: true });
33
+ for (const entry of entries) {
34
+ if (['node_modules', 'dist', '.next'].includes(entry.name))
35
+ continue;
36
+ if (entry.isDirectory()) {
37
+ results.push(...findModuleFiles((0, path_1.join)(dir, entry.name), depth + 1));
38
+ }
39
+ else if (entry.isFile() && entry.name.endsWith('.module.ts')) {
40
+ results.push((0, path_1.join)(dir, entry.name));
41
+ }
42
+ }
43
+ }
44
+ catch { }
45
+ return results;
46
+ }
47
+ function inferPrefixFromModuleName(name) {
48
+ // user-profile → /user-profile, users → /users
49
+ return `/${name}`;
50
+ }
@@ -3,32 +3,72 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.writeStatusFile = writeStatusFile;
7
+ exports.removeStatusFile = removeStatusFile;
6
8
  exports.uploadRoutes = uploadRoutes;
7
9
  const axios_1 = __importDefault(require("axios"));
10
+ const fs_1 = require("fs");
11
+ const path_1 = require("path");
8
12
  const export_js_1 = require("./export.js");
13
+ /** Status file written to the project root so the CLI can detect completion. */
14
+ const STATUS_FILE = '.apiforge-status.json';
15
+ function writeStatusFile(status, data) {
16
+ try {
17
+ (0, fs_1.writeFileSync)((0, path_1.join)(process.cwd(), STATUS_FILE), JSON.stringify({ status, timestamp: Date.now(), ...data }), 'utf-8');
18
+ }
19
+ catch { }
20
+ }
21
+ function removeStatusFile() {
22
+ try {
23
+ const p = (0, path_1.join)(process.cwd(), STATUS_FILE);
24
+ if ((0, fs_1.existsSync)(p))
25
+ (0, fs_1.unlinkSync)(p);
26
+ }
27
+ catch { }
28
+ }
9
29
  /**
10
- * Upload routes to APIForge server
30
+ * Submit routes to the APIForge backend for user review.
31
+ * Returns the pendingImportId so the CLI can poll for approval.
11
32
  */
12
33
  async function uploadRoutes(routes, options) {
13
- const url = options.apiForgeUrl || process.env.APIFORGE_URL || 'http://localhost:3000';
34
+ // Production URL is the built-in default — no config needed.
35
+ // Override only for local testing via option or env var.
36
+ const backendUrl = (options.apiForgeUrl ||
37
+ process.env.APIFORGE_URL ||
38
+ 'https://apiforgeapi.brainfogagency.com').replace(/\/$/, '');
14
39
  const enrichedRoutes = (0, export_js_1.enrichRoutes)(routes);
40
+ const payload = {
41
+ projectName: options.projectName,
42
+ routes: enrichedRoutes,
43
+ baseUrl: options.baseUrl,
44
+ framework: enrichedRoutes[0]?.framework || 'unknown',
45
+ architectureType: options.architectureType || 'monolithic',
46
+ services: options.services || [],
47
+ };
15
48
  try {
16
- const response = await axios_1.default.post(`${url}/api/upload-routes`, {
17
- projectName: options.projectName,
18
- routes: enrichedRoutes,
19
- baseUrl: options.baseUrl,
20
- framework: enrichedRoutes[0]?.framework || 'unknown',
21
- }, {
49
+ const response = await axios_1.default.post(`${backendUrl}/api/cli/submit`, payload, {
22
50
  headers: {
23
51
  'Content-Type': 'application/json',
24
52
  'X-API-Key': options.apiKey,
53
+ 'User-Agent': 'apiforge-analyzer/1.3.0',
25
54
  },
26
55
  timeout: 30000,
27
56
  });
28
- console.log('✅ Uploaded to APIForge:', response.data);
57
+ const data = response.data?.data;
58
+ console.log(`✅ APIForge: ${data?.message || 'Routes submitted for review'}`);
59
+ writeStatusFile('submitted', {
60
+ pendingImportId: data.pendingImportId,
61
+ routeCount: data.routeCount,
62
+ });
63
+ return data;
29
64
  }
30
65
  catch (error) {
31
- console.error('❌ Upload failed:', error.message);
32
- throw error;
66
+ const msg = error.response?.data?.message ||
67
+ error.response?.data?.error ||
68
+ error.message ||
69
+ 'Unknown error';
70
+ console.error(`❌ APIForge upload failed: ${msg}`);
71
+ writeStatusFile('error', { error: msg });
72
+ return null;
33
73
  }
34
74
  }
@@ -0,0 +1,130 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'fs';
2
+ import { join, basename } from 'path';
3
+ export function detectArchitectureType(rootDir) {
4
+ let microScore = 0;
5
+ let modularScore = 0;
6
+ const evidence = [];
7
+ const servicePaths = [];
8
+ const moduleNames = [];
9
+ // ── Microservices signals ────────────────────────────────────────────────────
10
+ // Definitive: config file lists services[]
11
+ const configPaths = [join(rootDir, 'apiforge.config.ts'), join(rootDir, '.apiforge.json')];
12
+ for (const configPath of configPaths) {
13
+ if (existsSync(configPath)) {
14
+ try {
15
+ const raw = readFileSync(configPath, 'utf-8');
16
+ if (raw.includes('"services"') || raw.includes("'services'") || raw.includes('services:')) {
17
+ microScore += 50;
18
+ evidence.push(`Config file with services[]: ${basename(configPath)}`);
19
+ }
20
+ }
21
+ catch { }
22
+ }
23
+ }
24
+ // docker-compose.yml with multiple node service entries
25
+ const composePath = join(rootDir, 'docker-compose.yml');
26
+ if (existsSync(composePath)) {
27
+ try {
28
+ const compose = readFileSync(composePath, 'utf-8');
29
+ const serviceMatches = compose.match(/^\s{2}\w[\w-]+:/gm) || [];
30
+ if (serviceMatches.length >= 3) {
31
+ microScore += 35;
32
+ evidence.push(`docker-compose.yml with ${serviceMatches.length} services`);
33
+ }
34
+ }
35
+ catch { }
36
+ }
37
+ // Multiple sibling package.json at depth 1
38
+ const siblingPackages = findSiblingPackages(rootDir);
39
+ if (siblingPackages.length >= 2) {
40
+ microScore += 40;
41
+ siblingPackages.forEach(p => servicePaths.push(p));
42
+ evidence.push(`${siblingPackages.length} sibling package.json files (${siblingPackages.join(', ')})`);
43
+ }
44
+ // ── Modular Monolithic signals ────────────────────────────────────────────────
45
+ // NestJS module files anywhere in src/
46
+ const srcDir = join(rootDir, 'src');
47
+ if (existsSync(srcDir)) {
48
+ const nestModules = findFilesRecursive(srcDir, '.module.ts');
49
+ if (nestModules.length > 0) {
50
+ modularScore += 40;
51
+ nestModules.forEach(f => {
52
+ const name = basename(f).replace('.module.ts', '');
53
+ if (!moduleNames.includes(name))
54
+ moduleNames.push(name);
55
+ });
56
+ evidence.push(`NestJS @Module files: ${nestModules.map(f => basename(f)).join(', ')}`);
57
+ }
58
+ // src/modules/, src/features/, or src/domains/
59
+ for (const dir of ['modules', 'features', 'domains']) {
60
+ if (existsSync(join(srcDir, dir))) {
61
+ modularScore += 35;
62
+ evidence.push(`Feature-based directory: src/${dir}/`);
63
+ const subdirs = readdirSync(join(srcDir, dir), { withFileTypes: true })
64
+ .filter(d => d.isDirectory())
65
+ .map(d => d.name);
66
+ subdirs.forEach(name => { if (!moduleNames.includes(name))
67
+ moduleNames.push(name); });
68
+ break;
69
+ }
70
+ }
71
+ }
72
+ // nest-cli.json at root
73
+ if (existsSync(join(rootDir, 'nest-cli.json'))) {
74
+ modularScore += 20;
75
+ evidence.push('nest-cli.json found');
76
+ }
77
+ // Classify
78
+ let type = 'monolithic';
79
+ let confidence = 0.6;
80
+ if (microScore >= 40) {
81
+ type = 'microservices';
82
+ confidence = Math.min(0.5 + microScore / 100, 0.98);
83
+ }
84
+ else if (modularScore >= 35) {
85
+ type = 'modular-monolithic';
86
+ confidence = Math.min(0.5 + modularScore / 100, 0.95);
87
+ }
88
+ if (evidence.length === 0) {
89
+ evidence.push('Single package.json, flat route structure');
90
+ }
91
+ return { type, confidence, evidence, moduleNames, servicePaths };
92
+ }
93
+ function findSiblingPackages(rootDir) {
94
+ const results = [];
95
+ try {
96
+ const entries = readdirSync(rootDir, { withFileTypes: true });
97
+ for (const entry of entries) {
98
+ if (!entry.isDirectory())
99
+ continue;
100
+ if (['node_modules', '.git', 'dist', '.next', 'build'].includes(entry.name))
101
+ continue;
102
+ const pkgPath = join(rootDir, entry.name, 'package.json');
103
+ if (existsSync(pkgPath)) {
104
+ results.push(`./${entry.name}`);
105
+ }
106
+ }
107
+ }
108
+ catch { }
109
+ return results;
110
+ }
111
+ function findFilesRecursive(dir, suffix, maxDepth = 6, depth = 0) {
112
+ if (depth > maxDepth)
113
+ return [];
114
+ const results = [];
115
+ try {
116
+ const entries = readdirSync(dir, { withFileTypes: true });
117
+ for (const entry of entries) {
118
+ if (entry.name === 'node_modules' || entry.name === 'dist')
119
+ continue;
120
+ if (entry.isDirectory()) {
121
+ results.push(...findFilesRecursive(join(dir, entry.name), suffix, maxDepth, depth + 1));
122
+ }
123
+ else if (entry.isFile() && entry.name.endsWith(suffix)) {
124
+ results.push(join(dir, entry.name));
125
+ }
126
+ }
127
+ }
128
+ catch { }
129
+ return results;
130
+ }