claudeos-core 1.6.2 → 1.7.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.
@@ -1,454 +1,466 @@
1
- /**
2
- * ClaudeOS-Core — Stack Detector
3
- *
4
- * Detects project language, framework, build tool, database, ORM, and frontend.
5
- * Supports: Java, Kotlin, TypeScript/JavaScript, Python
6
- * Multi-stack aware (backend + frontend simultaneous detection).
7
- */
8
-
9
- const path = require("path");
10
- const { glob } = require("glob");
11
- const { readFileSafe, readJsonSafe, existsSafe } = require("../lib/safe-fs");
12
-
13
- // ─── Lookup tables ──────────────────────────────────────────────
14
-
15
- // ORM detection rules: [keyword, ormName] (order = priority, first match wins)
16
- const GRADLE_ORM_RULES = [
17
- ["mybatis", "mybatis"],
18
- ["jpa", "jpa"], ["hibernate", "jpa"],
19
- ["exposed", "exposed"],
20
- ["jooq", "jooq"],
21
- ["spring-data-jdbc", "spring-data-jdbc"],
22
- ["r2dbc", "r2dbc"],
23
- ];
24
-
25
- const MAVEN_ORM_RULES = [
26
- ["mybatis", "mybatis"],
27
- ["jpa", "jpa"], ["hibernate", "jpa"],
28
- ["exposed", "exposed"],
29
- ["jooq", "jooq"],
30
- ];
31
-
32
- const NODE_ORM_RULES = [
33
- [["@prisma/client", "prisma"], "prisma"],
34
- [["typeorm"], "typeorm"],
35
- [["sequelize"], "sequelize"],
36
- [["drizzle-orm"], "drizzle"],
37
- [["knex"], "knex"],
38
- ];
39
-
40
- // DB detection rules: [keyword, dbName]
41
- const DB_KEYWORD_RULES = [
42
- ["postgresql", "postgresql"], ["postgres", "postgresql"],
43
- ["mysql", "mysql"],
44
- ["oracle", "oracle"],
45
- ["mongodb", "mongodb"],
46
- ["sqlite", "sqlite"],
47
- ];
48
-
49
- // h2 needs word-boundary check (avoid oauth2, cache2k false positives)
50
- const H2_REGEX = /\bh2\b/;
51
-
52
- // ─── Helpers ────────────────────────────────────────────────────
53
-
54
- function detectFirst(stack, field, content, rules) {
55
- if (stack[field]) return;
56
- for (const [keyword, value] of rules) {
57
- if (content.includes(keyword)) {
58
- stack[field] = value;
59
- stack.detected.push(value);
60
- return;
61
- }
62
- }
63
- }
64
-
65
- function detectDb(stack, content, rules) {
66
- if (stack.database) return;
67
- for (const [keyword, value] of rules) {
68
- if (content.includes(keyword)) {
69
- stack.database = value;
70
- stack.detected.push(value);
71
- return;
72
- }
73
- }
74
- // h2 with word boundary
75
- if (!stack.database && H2_REGEX.test(content)) {
76
- stack.database = "h2";
77
- stack.detected.push("h2");
78
- }
79
- }
80
-
81
-
82
- /**
83
- * Detect the project's technology stack.
84
- * @param {string} ROOT - project root path
85
- * @returns {Promise<object>} stack info
86
- */
87
- async function detectStack(ROOT) {
88
- const stack = {
89
- language: null, languageVersion: null,
90
- framework: null, frameworkVersion: null,
91
- buildTool: null, database: null, orm: null,
92
- frontend: null, frontendVersion: null,
93
- packageManager: null, monorepo: null, workspaces: null,
94
- detected: [],
95
- };
96
-
97
- // ── Java/Kotlin: Gradle ──
98
- const gradleFile = existsSafe(path.join(ROOT, "build.gradle.kts"))
99
- ? "build.gradle.kts"
100
- : existsSafe(path.join(ROOT, "build.gradle")) ? "build.gradle" : null;
101
- if (gradleFile) {
102
- const g = readFileSafe(path.join(ROOT, gradleFile));
103
- if (g) {
104
- stack.buildTool = "gradle"; stack.detected.push(gradleFile);
105
- if (g.includes("spring-boot")) { stack.language = "java"; stack.framework = "spring-boot"; stack.detected.push("spring-boot"); }
106
- const svPatterns = [
107
- /org\.springframework\.boot.*version\s*['"]([^'"]+)['"]/,
108
- /id\s*\(\s*["']org\.springframework\.boot["']\s*\)\s*version\s*["']([^"']+)["']/,
109
- /spring-boot-dependencies:([^'")\s]+)/,
110
- ];
111
- for (const pattern of svPatterns) {
112
- const sv = g.match(pattern);
113
- if (sv) { stack.frameworkVersion = sv[1]; break; }
114
- }
115
- const jv = g.match(/sourceCompatibility\s*=\s*['"]?(\d+)['"]?/);
116
- if (jv) stack.languageVersion = jv[1];
117
-
118
- detectFirst(stack, "orm", g, GRADLE_ORM_RULES);
119
- // Exclude "postgres" (substring of postgresql — false positive on r2dbc-postgres) and "sqlite" (rare in Gradle deps)
120
- // "postgresql" is still matched via DB_KEYWORD_RULES; h2 uses word-boundary check separately
121
- detectDb(stack, g, DB_KEYWORD_RULES.filter(([kw]) => !["postgres", "sqlite"].includes(kw)));
122
-
123
- // Kotlin detection: override language if Kotlin plugin found
124
- if (g.includes("kotlin") || g.includes("org.jetbrains.kotlin")) {
125
- stack.language = "kotlin"; stack.detected.push("kotlin");
126
- const kvPatterns = [
127
- /kotlin\S*\s*version\s*['"]([^'"]+)['"]/,
128
- /org\.jetbrains\.kotlin\S*\s*version\s*['"]([^'"]+)['"]/,
129
- /kotlin\("jvm"\)\s*version\s*["']([^"']+)["']/,
130
- ];
131
- for (const pattern of kvPatterns) {
132
- const match = g.match(pattern);
133
- if (match) { stack.languageVersion = match[1]; break; }
134
- }
135
- }
136
- }
137
- }
138
-
139
- // ── Gradle: version catalogs (libs.versions.toml) ──
140
- const versionCatalog = path.join(ROOT, "gradle/libs.versions.toml");
141
- if (existsSafe(versionCatalog)) {
142
- const vc = readFileSafe(versionCatalog);
143
- if (vc) {
144
- stack.detected.push("libs.versions.toml");
145
- if (!stack.languageVersion) {
146
- const kvMatch = vc.match(/kotlin\s*=\s*["']([^"']+)["']/);
147
- if (kvMatch) stack.languageVersion = kvMatch[1];
148
- }
149
- if (!stack.frameworkVersion) {
150
- const sbMatch = vc.match(/spring-boot\s*=\s*["']([^"']+)["']/);
151
- if (sbMatch) stack.frameworkVersion = sbMatch[1];
152
- }
153
- // Version catalog ORM (labels include " (catalog)" suffix)
154
- if (!stack.orm && vc.includes("exposed")) { stack.orm = "exposed"; stack.detected.push("exposed (catalog)"); }
155
- else if (!stack.orm && vc.includes("jooq")) { stack.orm = "jooq"; stack.detected.push("jooq (catalog)"); }
156
- else if (!stack.orm && (vc.includes("jpa") || vc.includes("hibernate"))) { stack.orm = "jpa"; stack.detected.push("jpa (catalog)"); }
157
- if (!stack.database && vc.includes("postgresql")) { stack.database = "postgresql"; }
158
- if (!stack.database && vc.includes("mysql")) { stack.database = "mysql"; }
159
- if (!stack.database && vc.includes("mongodb")) { stack.database = "mongodb"; }
160
- if (!stack.language && vc.includes("kotlin")) {
161
- stack.language = "kotlin"; stack.detected.push("kotlin (catalog)");
162
- }
163
- }
164
- }
165
-
166
- // ── Kotlin: multi-module Gradle detection ──
167
- if (stack.language !== "kotlin" && stack.buildTool === "gradle") {
168
- const subBuildFiles = await glob("**/build.gradle{,.kts}", { cwd: ROOT, ignore: ["**/node_modules/**", "**/build/**"] });
169
- for (const sbf of subBuildFiles.slice(0, 5)) {
170
- const sc = readFileSafe(path.join(ROOT, sbf));
171
- if (sc && (sc.includes("kotlin") || sc.includes("org.jetbrains.kotlin"))) {
172
- stack.language = "kotlin"; stack.detected.push("kotlin (submodule)");
173
- const kv = sc.match(/kotlin\S*\s*version\s*['"]([^'"]+)['"]/);
174
- if (kv) stack.languageVersion = kv[1];
175
- break;
176
- }
177
- }
178
- }
179
-
180
- // ── Kotlin: detect CQRS/multi-module from settings.gradle ──
181
- const settingsFile = existsSafe(path.join(ROOT, "settings.gradle.kts"))
182
- ? "settings.gradle.kts"
183
- : existsSafe(path.join(ROOT, "settings.gradle")) ? "settings.gradle" : null;
184
- if (settingsFile && stack.language === "kotlin") {
185
- const sg = readFileSafe(path.join(ROOT, settingsFile));
186
- if (sg) {
187
- const sgClean = sg.split("\n").filter(l => !l.trimStart().startsWith("//")).join("\n");
188
- const includes = [];
189
- const includeBlocks = [...sgClean.matchAll(/include\s*\(([^)]*)\)/gs)];
190
- for (const block of includeBlocks) {
191
- const quotedValues = [...block[1].matchAll(/["']([^"']+)["']/g)].map(m => m[1]);
192
- includes.push(...quotedValues);
193
- }
194
- if (includes.length === 0) {
195
- const groovyIncludes = [...sgClean.matchAll(/include\s+(.+)/g)];
196
- for (const line of groovyIncludes) {
197
- const quotedValues = [...line[1].matchAll(/['"]([^'"]+)['"]/g)].map(m => m[1]);
198
- includes.push(...quotedValues);
199
- }
200
- }
201
- const cleanModules = includes.map(m => m.replace(/^:/, ""));
202
- if (cleanModules.length > 0) {
203
- stack.multiModule = true;
204
- stack.modules = cleanModules;
205
- stack.detected.push(`multi-module (${cleanModules.length} modules)`);
206
- const hasCommand = cleanModules.some(m => m.includes("command"));
207
- const hasQuery = cleanModules.some(m => m.includes("query"));
208
- const hasBff = cleanModules.some(m => m.includes("bff"));
209
- if (hasCommand && hasQuery) { stack.architecture = "cqrs"; stack.detected.push("cqrs"); }
210
- if (hasBff) { stack.detected.push("bff"); }
211
- }
212
- }
213
- }
214
-
215
- // ── Java: Maven ──
216
- if (existsSafe(path.join(ROOT, "pom.xml"))) {
217
- const pom = readFileSafe(path.join(ROOT, "pom.xml"));
218
- if (pom) {
219
- if (!stack.buildTool) { stack.buildTool = "maven"; stack.language = "java"; stack.detected.push("pom.xml"); }
220
- const sv = pom.match(/<spring-boot[^>]*version>([^<]+)/);
221
- if (sv) stack.frameworkVersion = sv[1];
222
- const jv = pom.match(/<java\.version>(\d+)/);
223
- if (jv) stack.languageVersion = jv[1];
224
- if (pom.includes("spring-boot") && !stack.framework) { stack.framework = "spring-boot"; stack.detected.push("spring-boot"); }
225
- detectFirst(stack, "orm", pom, MAVEN_ORM_RULES);
226
- // Maven DB: original does not push to detected (unlike Gradle)
227
- if (!stack.database && pom.includes("postgresql")) stack.database = "postgresql";
228
- if (!stack.database && pom.includes("mysql")) stack.database = "mysql";
229
- if (!stack.database && pom.includes("oracle")) stack.database = "oracle";
230
- if (!stack.database && pom.includes("mongodb")) stack.database = "mongodb";
231
- if (!stack.database && H2_REGEX.test(pom)) stack.database = "h2";
232
- if (!stack.database && pom.includes("sqlite")) stack.database = "sqlite";
233
- }
234
- }
235
-
236
- // ── Node.js ──
237
- if (existsSafe(path.join(ROOT, "package.json"))) {
238
- const pkg = readJsonSafe(path.join(ROOT, "package.json"));
239
- if (pkg) {
240
- stack.detected.push("package.json");
241
-
242
- // ── Monorepo detection ──
243
- // Detect monorepo markers: turbo.json, pnpm-workspace.yaml, lerna.json, package.json#workspaces
244
- if (existsSafe(path.join(ROOT, "turbo.json"))) { stack.monorepo = "turborepo"; stack.detected.push("turbo.json"); }
245
- else if (existsSafe(path.join(ROOT, "pnpm-workspace.yaml"))) { stack.monorepo = "pnpm-workspace"; stack.detected.push("pnpm-workspace.yaml"); }
246
- else if (existsSafe(path.join(ROOT, "lerna.json"))) { stack.monorepo = "lerna"; stack.detected.push("lerna.json"); }
247
- else if (pkg.workspaces) { stack.monorepo = "npm-workspaces"; stack.detected.push("npm-workspaces"); }
248
- if (stack.monorepo) {
249
- // Resolve workspace paths from package.json#workspaces or pnpm-workspace.yaml
250
- let wsPatterns = [];
251
- if (Array.isArray(pkg.workspaces)) wsPatterns = pkg.workspaces;
252
- else if (pkg.workspaces && Array.isArray(pkg.workspaces.packages)) wsPatterns = pkg.workspaces.packages;
253
- if (wsPatterns.length === 0 && existsSafe(path.join(ROOT, "pnpm-workspace.yaml"))) {
254
- const wy = readFileSafe(path.join(ROOT, "pnpm-workspace.yaml"));
255
- if (wy) {
256
- const wm = [...wy.matchAll(/- ['"]?([^'"#\n]+)['"]?/g)].map(m => m[1].trim());
257
- if (wm.length > 0) wsPatterns = wm;
258
- }
259
- }
260
- if (wsPatterns.length > 0) stack.workspaces = wsPatterns;
261
- }
262
-
263
- // Merge deps from root + sub-package package.json files (monorepo)
264
- // Sub-packages provide framework/frontend/ORM that root may lack
265
- const deps = { ...pkg.dependencies, ...pkg.devDependencies };
266
- if (stack.monorepo) {
267
- const subPkgGlobs = ["{apps,packages}/*/package.json"];
268
- if (stack.workspaces) {
269
- for (const ws of stack.workspaces) {
270
- const wsGlob = /[*?]/.test(ws)
271
- ? ws.replace(/\/?\*?\*?$/, "/*/package.json")
272
- : `${ws.replace(/\/?$/, "")}/{,*/}package.json`;
273
- if (!subPkgGlobs.includes(wsGlob)) subPkgGlobs.push(wsGlob);
274
- }
275
- }
276
- for (const spg of subPkgGlobs) {
277
- const subPkgs = await glob(spg, { cwd: ROOT, ignore: ["**/node_modules/**"] });
278
- for (const sp of subPkgs) {
279
- const sub = readJsonSafe(path.join(ROOT, sp));
280
- if (sub) Object.assign(deps, sub.dependencies, sub.devDependencies);
281
- }
282
- }
283
- }
284
-
285
- if (!stack.language) stack.language = deps.typescript ? "typescript" : "javascript";
286
- if (deps.typescript) { stack.detected.push("typescript"); const tv = deps.typescript.match(/(\d+(?:\.\d+)*)/); if (tv) stack.languageVersion = tv[1]; }
287
-
288
- // Frontend (Angular checked before React — Angular projects may include react in devDependencies)
289
- const frontendRules = [
290
- ["next", "nextjs", "next.js"],
291
- ["@angular/core", "angular", "angular"],
292
- ["react", "react", "react"],
293
- ["vue", "vue", "vue"],
294
- ];
295
- for (const [dep, name, label] of frontendRules) {
296
- if (deps[dep] && !stack.frontend) {
297
- stack.frontend = name; stack.detected.push(label);
298
- stack.frontendVersion = deps[dep].replace(/[^0-9.]/g, "");
299
- break;
300
- }
301
- }
302
-
303
- // Backend framework (NestJS > Fastify > Express — more specific first)
304
- const frameworkRules = [
305
- ["@nestjs/core", "nestjs", "nestjs"],
306
- ["fastify", "fastify", "fastify"],
307
- ["express", "express", "express"],
308
- ];
309
- for (const [dep, name, label] of frameworkRules) {
310
- if (deps[dep] && !stack.framework) {
311
- stack.framework = name; stack.detected.push(label);
312
- if (dep !== "express") stack.frameworkVersion = deps[dep].replace(/[^0-9.]/g, "");
313
- break;
314
- }
315
- }
316
-
317
- // ORM
318
- for (const [depKeys, ormName] of NODE_ORM_RULES) {
319
- if (stack.orm) break;
320
- if (depKeys.some(d => deps[d])) {
321
- stack.orm = ormName; stack.detected.push(ormName);
322
- }
323
- }
324
- if (deps.mongoose) { if (!stack.database) stack.database = "mongodb"; if (!stack.orm) stack.orm = "mongoose"; stack.detected.push("mongoose"); }
325
-
326
- // DB
327
- const nodeDbRules = [["pg", "postgresql"], ["mysql2", "mysql"], ["mongodb", "mongodb"]];
328
- for (const [dep, db] of nodeDbRules) {
329
- if (deps[dep] && !stack.database) { stack.database = db; break; }
330
- }
331
-
332
- // Package manager
333
- stack.packageManager = existsSafe(path.join(ROOT, "pnpm-lock.yaml")) ? "pnpm"
334
- : existsSafe(path.join(ROOT, "yarn.lock")) ? "yarn" : "npm";
335
-
336
- if (pkg.engines && pkg.engines.node && !stack.languageVersion) {
337
- const nv = pkg.engines.node.match(/(\d+(?:\.\d+)*)/);
338
- if (nv) stack.languageVersion = nv[1];
339
- }
340
- }
341
- }
342
-
343
- // ── Python ──
344
- const hasPyproject = existsSafe(path.join(ROOT, "pyproject.toml"));
345
- const hasRequirements = existsSafe(path.join(ROOT, "requirements.txt"));
346
- if (hasPyproject || hasRequirements) {
347
- if (!stack.language) stack.language = "python";
348
- stack.detected.push("python");
349
-
350
- const pyFrameworkRules = [["django", "django"], ["fastapi", "fastapi"], ["flask", "flask"]];
351
- const pyOrmRules = [["sqlalchemy", "sqlalchemy"], ["tortoise", "tortoise-orm"]];
352
-
353
- if (hasPyproject) {
354
- const pp = readFileSafe(path.join(ROOT, "pyproject.toml"));
355
- if (pp) {
356
- const pv = pp.match(/python\s*=\s*"[><=^~]*(\d+\.\d+)/);
357
- if (pv && !stack.languageVersion) stack.languageVersion = pv[1];
358
- for (const [kw, name] of pyFrameworkRules) {
359
- if (pp.includes(kw) && !stack.framework) { stack.framework = name; stack.detected.push(name); break; }
360
- }
361
- for (const [kw, name] of pyOrmRules) {
362
- if (pp.includes(kw) && !stack.orm) { stack.orm = name; stack.detected.push(name); break; }
363
- }
364
- if (pp.includes("poetry")) { stack.packageManager = "poetry"; }
365
- if (pp.includes("pdm")) { stack.packageManager = "pdm"; }
366
- }
367
- }
368
-
369
- if (hasRequirements) {
370
- const r = readFileSafe(path.join(ROOT, "requirements.txt"));
371
- if (r) {
372
- for (const [kw, name] of pyFrameworkRules) {
373
- if (r.includes(kw) && !stack.framework) { stack.framework = name; stack.detected.push(name); break; }
374
- }
375
- for (const [kw, name] of pyOrmRules) {
376
- if (r.includes(kw) && !stack.orm) { stack.orm = name; break; }
377
- }
378
- if (r.includes("psycopg") && !stack.database) stack.database = "postgresql";
379
- if (r.includes("mysqlclient") && !stack.database) stack.database = "mysql";
380
- }
381
- }
382
-
383
- if (!stack.packageManager) {
384
- stack.packageManager = existsSafe(path.join(ROOT, "Pipfile")) ? "pipenv"
385
- : existsSafe(path.join(ROOT, "poetry.lock")) ? "poetry" : "pip";
386
- }
387
- }
388
-
389
- // ── DB from config files ──
390
- const ymls = await glob("**/application*.yml", { cwd: ROOT, absolute: true, ignore: ["**/node_modules/**", "**/build/**", "**/target/**", "**/.gradle/**"] });
391
- for (const y of ymls) {
392
- const c = readFileSafe(y);
393
- if (!c) continue;
394
- if (!stack.database && c.includes("postgresql")) stack.database = "postgresql";
395
- if (!stack.database && c.includes("mysql")) stack.database = "mysql";
396
- if (!stack.database && c.includes("oracle")) stack.database = "oracle";
397
- if (!stack.database && c.includes("mongodb")) stack.database = "mongodb";
398
- if (!stack.database && H2_REGEX.test(c)) stack.database = "h2";
399
- if (!stack.database && c.includes("sqlite")) stack.database = "sqlite";
400
- if (!stack.port) {
401
- const pm = c.match(/server:\s*\n\s*port:\s*(\d+)/) || c.match(/server\.port\s*[=:]\s*(\d+)/);
402
- if (pm) stack.port = parseInt(pm[1]);
403
- }
404
- }
405
-
406
- // .env
407
- for (const ef of [".env", ".env.local", ".env.development"]) {
408
- const ep = path.join(ROOT, ef);
409
- if (existsSafe(ep)) {
410
- const ec = readFileSafe(ep);
411
- if (ec && ec.includes("DATABASE_URL")) {
412
- // .env: original checks postgres (not postgresql), no oracle/h2
413
- if (ec.includes("postgres") && !stack.database) stack.database = "postgresql";
414
- if (ec.includes("mysql") && !stack.database) stack.database = "mysql";
415
- if (ec.includes("mongodb") && !stack.database) stack.database = "mongodb";
416
- if (ec.includes("sqlite") && !stack.database) stack.database = "sqlite";
417
- }
418
- }
419
- }
420
-
421
- // Prisma schema
422
- const prismaSchema = path.join(ROOT, "prisma/schema.prisma");
423
- if (existsSafe(prismaSchema)) {
424
- const ps = readFileSafe(prismaSchema);
425
- if (ps) {
426
- const prov = ps.match(/provider\s*=\s*"(\w+)"/);
427
- if (prov && !stack.database) {
428
- const db = { postgresql: "postgresql", mysql: "mysql", sqlite: "sqlite", mongodb: "mongodb" };
429
- if (db[prov[1]]) stack.database = db[prov[1]];
430
- }
431
- }
432
- }
433
-
434
- // ── Config file fallback (monorepo) ──
435
- const frontendFallbacks = [
436
- [["next.config.js", "next.config.mjs", "next.config.ts"], "nextjs", "next.config (fallback)"],
437
- [["vite.config.ts", "vite.config.js"], "react", "vite.config (fallback)"],
438
- [["nuxt.config.ts", "nuxt.config.js"], "vue", "nuxt.config (fallback)"],
439
- [["angular.json", ".angular.json"], "angular", "angular.json (fallback)"],
440
- ];
441
- if (!stack.frontend) {
442
- for (const [files, name, label] of frontendFallbacks) {
443
- if (files.some(f => existsSafe(path.join(ROOT, f)))) {
444
- stack.frontend = name; stack.detected.push(label);
445
- if (!stack.language) stack.language = "typescript";
446
- break;
447
- }
448
- }
449
- }
450
-
451
- return stack;
452
- }
453
-
454
- module.exports = { detectStack };
1
+ /**
2
+ * ClaudeOS-Core — Stack Detector
3
+ *
4
+ * Detects project language, framework, build tool, database, ORM, and frontend.
5
+ * Supports: Java, Kotlin, TypeScript/JavaScript, Python
6
+ * Multi-stack aware (backend + frontend simultaneous detection).
7
+ */
8
+
9
+ const path = require("path");
10
+ const { glob } = require("glob");
11
+ const { readFileSafe, readJsonSafe, existsSafe } = require("../lib/safe-fs");
12
+
13
+ // ─── Lookup tables ──────────────────────────────────────────────
14
+
15
+ // ORM detection rules: [keyword, ormName] (order = priority, first match wins)
16
+ const GRADLE_ORM_RULES = [
17
+ ["mybatis", "mybatis"],
18
+ ["jpa", "jpa"], ["hibernate", "jpa"],
19
+ ["exposed", "exposed"],
20
+ ["jooq", "jooq"],
21
+ ["spring-data-jdbc", "spring-data-jdbc"],
22
+ ["r2dbc", "r2dbc"],
23
+ ];
24
+
25
+ const MAVEN_ORM_RULES = [
26
+ ["mybatis", "mybatis"],
27
+ ["jpa", "jpa"], ["hibernate", "jpa"],
28
+ ["exposed", "exposed"],
29
+ ["jooq", "jooq"],
30
+ ];
31
+
32
+ const NODE_ORM_RULES = [
33
+ [["@prisma/client", "prisma"], "prisma"],
34
+ [["typeorm"], "typeorm"],
35
+ [["sequelize"], "sequelize"],
36
+ [["drizzle-orm"], "drizzle"],
37
+ [["knex"], "knex"],
38
+ ];
39
+
40
+ // DB detection rules: [keyword, dbName]
41
+ const DB_KEYWORD_RULES = [
42
+ ["postgresql", "postgresql"], ["postgres", "postgresql"],
43
+ ["mysql", "mysql"],
44
+ ["oracle", "oracle"],
45
+ ["mongodb", "mongodb"],
46
+ ["sqlite", "sqlite"],
47
+ ];
48
+
49
+ // h2 needs word-boundary check (avoid oauth2, cache2k false positives)
50
+ const H2_REGEX = /\bh2\b/;
51
+
52
+ // ─── Helpers ────────────────────────────────────────────────────
53
+
54
+ function detectFirst(stack, field, content, rules) {
55
+ if (stack[field]) return;
56
+ for (const [keyword, value] of rules) {
57
+ if (content.includes(keyword)) {
58
+ stack[field] = value;
59
+ stack.detected.push(value);
60
+ return;
61
+ }
62
+ }
63
+ }
64
+
65
+ function detectDb(stack, content, rules) {
66
+ if (stack.database) return;
67
+ for (const [keyword, value] of rules) {
68
+ if (content.includes(keyword)) {
69
+ stack.database = value;
70
+ stack.detected.push(value);
71
+ return;
72
+ }
73
+ }
74
+ // h2 with word boundary
75
+ if (!stack.database && H2_REGEX.test(content)) {
76
+ stack.database = "h2";
77
+ stack.detected.push("h2");
78
+ }
79
+ }
80
+
81
+
82
+ /**
83
+ * Detect the project's technology stack.
84
+ * @param {string} ROOT - project root path
85
+ * @returns {Promise<object>} stack info
86
+ */
87
+ async function detectStack(ROOT) {
88
+ const stack = {
89
+ language: null, languageVersion: null,
90
+ framework: null, frameworkVersion: null,
91
+ buildTool: null, database: null, orm: null,
92
+ frontend: null, frontendVersion: null,
93
+ packageManager: null, monorepo: null, workspaces: null,
94
+ detected: [],
95
+ };
96
+
97
+ // ── Java/Kotlin: Gradle ──
98
+ const gradleFile = existsSafe(path.join(ROOT, "build.gradle.kts"))
99
+ ? "build.gradle.kts"
100
+ : existsSafe(path.join(ROOT, "build.gradle")) ? "build.gradle" : null;
101
+ if (gradleFile) {
102
+ const g = readFileSafe(path.join(ROOT, gradleFile));
103
+ if (g) {
104
+ stack.buildTool = "gradle"; stack.detected.push(gradleFile);
105
+ if (g.includes("spring-boot")) { stack.language = "java"; stack.framework = "spring-boot"; stack.detected.push("spring-boot"); }
106
+ const svPatterns = [
107
+ /org\.springframework\.boot.*version\s*['"]([^'"]+)['"]/,
108
+ /id\s*\(\s*["']org\.springframework\.boot["']\s*\)\s*version\s*["']([^"']+)["']/,
109
+ /spring-boot-dependencies:([^'")\s]+)/,
110
+ ];
111
+ for (const pattern of svPatterns) {
112
+ const sv = g.match(pattern);
113
+ if (sv) { stack.frameworkVersion = sv[1]; break; }
114
+ }
115
+ const jv = g.match(/sourceCompatibility\s*=\s*['"]?(\d+)['"]?/);
116
+ if (jv) stack.languageVersion = jv[1];
117
+
118
+ detectFirst(stack, "orm", g, GRADLE_ORM_RULES);
119
+ // Exclude "postgres" (substring of postgresql — false positive on r2dbc-postgres) and "sqlite" (rare in Gradle deps)
120
+ // "postgresql" is still matched via DB_KEYWORD_RULES; h2 uses word-boundary check separately
121
+ detectDb(stack, g, DB_KEYWORD_RULES.filter(([kw]) => !["postgres", "sqlite"].includes(kw)));
122
+
123
+ // Kotlin detection: override language if Kotlin plugin found
124
+ if (g.includes("kotlin") || g.includes("org.jetbrains.kotlin")) {
125
+ stack.language = "kotlin"; stack.detected.push("kotlin");
126
+ const kvPatterns = [
127
+ /kotlin\S*\s*version\s*['"]([^'"]+)['"]/,
128
+ /org\.jetbrains\.kotlin\S*\s*version\s*['"]([^'"]+)['"]/,
129
+ /kotlin\("jvm"\)\s*version\s*["']([^"']+)["']/,
130
+ ];
131
+ for (const pattern of kvPatterns) {
132
+ const match = g.match(pattern);
133
+ if (match) { stack.languageVersion = match[1]; break; }
134
+ }
135
+ }
136
+ }
137
+ }
138
+
139
+ // ── Gradle: version catalogs (libs.versions.toml) ──
140
+ const versionCatalog = path.join(ROOT, "gradle/libs.versions.toml");
141
+ if (existsSafe(versionCatalog)) {
142
+ const vc = readFileSafe(versionCatalog);
143
+ if (vc) {
144
+ stack.detected.push("libs.versions.toml");
145
+ if (!stack.languageVersion) {
146
+ const kvMatch = vc.match(/kotlin\s*=\s*["']([^"']+)["']/);
147
+ if (kvMatch) stack.languageVersion = kvMatch[1];
148
+ }
149
+ if (!stack.frameworkVersion) {
150
+ const sbMatch = vc.match(/spring-boot\s*=\s*["']([^"']+)["']/);
151
+ if (sbMatch) stack.frameworkVersion = sbMatch[1];
152
+ }
153
+ // Version catalog ORM (labels include " (catalog)" suffix)
154
+ if (!stack.orm && vc.includes("exposed")) { stack.orm = "exposed"; stack.detected.push("exposed (catalog)"); }
155
+ else if (!stack.orm && vc.includes("jooq")) { stack.orm = "jooq"; stack.detected.push("jooq (catalog)"); }
156
+ else if (!stack.orm && (vc.includes("jpa") || vc.includes("hibernate"))) { stack.orm = "jpa"; stack.detected.push("jpa (catalog)"); }
157
+ if (!stack.database && vc.includes("postgresql")) { stack.database = "postgresql"; }
158
+ if (!stack.database && vc.includes("mysql")) { stack.database = "mysql"; }
159
+ if (!stack.database && vc.includes("mongodb")) { stack.database = "mongodb"; }
160
+ if (!stack.language && vc.includes("kotlin")) {
161
+ stack.language = "kotlin"; stack.detected.push("kotlin (catalog)");
162
+ }
163
+ }
164
+ }
165
+
166
+ // ── Kotlin: multi-module Gradle detection ──
167
+ if (stack.language !== "kotlin" && stack.buildTool === "gradle") {
168
+ const subBuildFiles = await glob("**/build.gradle{,.kts}", { cwd: ROOT, ignore: ["**/node_modules/**", "**/build/**"] });
169
+ for (const sbf of subBuildFiles.slice(0, 5)) {
170
+ const sc = readFileSafe(path.join(ROOT, sbf));
171
+ if (sc && (sc.includes("kotlin") || sc.includes("org.jetbrains.kotlin"))) {
172
+ stack.language = "kotlin"; stack.detected.push("kotlin (submodule)");
173
+ const kv = sc.match(/kotlin\S*\s*version\s*['"]([^'"]+)['"]/);
174
+ if (kv) stack.languageVersion = kv[1];
175
+ break;
176
+ }
177
+ }
178
+ }
179
+
180
+ // ── Kotlin: detect CQRS/multi-module from settings.gradle ──
181
+ const settingsFile = existsSafe(path.join(ROOT, "settings.gradle.kts"))
182
+ ? "settings.gradle.kts"
183
+ : existsSafe(path.join(ROOT, "settings.gradle")) ? "settings.gradle" : null;
184
+ if (settingsFile && stack.language === "kotlin") {
185
+ const sg = readFileSafe(path.join(ROOT, settingsFile));
186
+ if (sg) {
187
+ const sgClean = sg.split("\n").filter(l => !l.trimStart().startsWith("//")).join("\n");
188
+ const includes = [];
189
+ const includeBlocks = [...sgClean.matchAll(/include\s*\(([^)]*)\)/gs)];
190
+ for (const block of includeBlocks) {
191
+ const quotedValues = [...block[1].matchAll(/["']([^"']+)["']/g)].map(m => m[1]);
192
+ includes.push(...quotedValues);
193
+ }
194
+ if (includes.length === 0) {
195
+ const groovyIncludes = [...sgClean.matchAll(/include\s+(.+)/g)];
196
+ for (const line of groovyIncludes) {
197
+ const quotedValues = [...line[1].matchAll(/['"]([^'"]+)['"]/g)].map(m => m[1]);
198
+ includes.push(...quotedValues);
199
+ }
200
+ }
201
+ const cleanModules = includes.map(m => m.replace(/^:/, ""));
202
+ if (cleanModules.length > 0) {
203
+ stack.multiModule = true;
204
+ stack.modules = cleanModules;
205
+ stack.detected.push(`multi-module (${cleanModules.length} modules)`);
206
+ const hasCommand = cleanModules.some(m => m.includes("command"));
207
+ const hasQuery = cleanModules.some(m => m.includes("query"));
208
+ const hasBff = cleanModules.some(m => m.includes("bff"));
209
+ if (hasCommand && hasQuery) { stack.architecture = "cqrs"; stack.detected.push("cqrs"); }
210
+ if (hasBff) { stack.detected.push("bff"); }
211
+ }
212
+ }
213
+ }
214
+
215
+ // ── Java: Maven ──
216
+ if (existsSafe(path.join(ROOT, "pom.xml"))) {
217
+ const pom = readFileSafe(path.join(ROOT, "pom.xml"));
218
+ if (pom) {
219
+ if (!stack.buildTool) { stack.buildTool = "maven"; stack.language = "java"; stack.detected.push("pom.xml"); }
220
+ const sv = pom.match(/<spring-boot[^>]*version>([^<]+)/);
221
+ if (sv) stack.frameworkVersion = sv[1];
222
+ const jv = pom.match(/<java\.version>(\d+)/);
223
+ if (jv) stack.languageVersion = jv[1];
224
+ if (pom.includes("spring-boot") && !stack.framework) { stack.framework = "spring-boot"; stack.detected.push("spring-boot"); }
225
+ detectFirst(stack, "orm", pom, MAVEN_ORM_RULES);
226
+ // Maven DB: original does not push to detected (unlike Gradle)
227
+ if (!stack.database && pom.includes("postgresql")) stack.database = "postgresql";
228
+ if (!stack.database && pom.includes("mysql")) stack.database = "mysql";
229
+ if (!stack.database && pom.includes("oracle")) stack.database = "oracle";
230
+ if (!stack.database && pom.includes("mongodb")) stack.database = "mongodb";
231
+ if (!stack.database && H2_REGEX.test(pom)) stack.database = "h2";
232
+ if (!stack.database && pom.includes("sqlite")) stack.database = "sqlite";
233
+ }
234
+ }
235
+
236
+ // ── Node.js ──
237
+ if (existsSafe(path.join(ROOT, "package.json"))) {
238
+ const pkg = readJsonSafe(path.join(ROOT, "package.json"));
239
+ if (pkg) {
240
+ stack.detected.push("package.json");
241
+
242
+ // ── Monorepo detection ──
243
+ // Detect monorepo markers: turbo.json, pnpm-workspace.yaml, lerna.json, package.json#workspaces
244
+ if (existsSafe(path.join(ROOT, "turbo.json"))) { stack.monorepo = "turborepo"; stack.detected.push("turbo.json"); }
245
+ else if (existsSafe(path.join(ROOT, "pnpm-workspace.yaml"))) { stack.monorepo = "pnpm-workspace"; stack.detected.push("pnpm-workspace.yaml"); }
246
+ else if (existsSafe(path.join(ROOT, "lerna.json"))) { stack.monorepo = "lerna"; stack.detected.push("lerna.json"); }
247
+ else if (pkg.workspaces) { stack.monorepo = "npm-workspaces"; stack.detected.push("npm-workspaces"); }
248
+ if (stack.monorepo) {
249
+ // Resolve workspace paths from package.json#workspaces or pnpm-workspace.yaml
250
+ let wsPatterns = [];
251
+ if (Array.isArray(pkg.workspaces)) wsPatterns = pkg.workspaces;
252
+ else if (pkg.workspaces && Array.isArray(pkg.workspaces.packages)) wsPatterns = pkg.workspaces.packages;
253
+ if (wsPatterns.length === 0 && existsSafe(path.join(ROOT, "pnpm-workspace.yaml"))) {
254
+ const wy = readFileSafe(path.join(ROOT, "pnpm-workspace.yaml"));
255
+ if (wy) {
256
+ const wm = [...wy.matchAll(/- ['"]?([^'"#\n]+)['"]?/g)].map(m => m[1].trim());
257
+ if (wm.length > 0) wsPatterns = wm;
258
+ }
259
+ }
260
+ if (wsPatterns.length > 0) stack.workspaces = wsPatterns;
261
+ }
262
+
263
+ // Merge deps from root + sub-package package.json files (monorepo)
264
+ // Sub-packages provide framework/frontend/ORM that root may lack
265
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
266
+ if (stack.monorepo) {
267
+ const subPkgGlobs = ["{apps,packages}/*/package.json"];
268
+ if (stack.workspaces) {
269
+ for (const ws of stack.workspaces) {
270
+ const wsGlob = /[*?]/.test(ws)
271
+ ? ws.replace(/\/?\*?\*?$/, "/*/package.json")
272
+ : `${ws.replace(/\/?$/, "")}/{,*/}package.json`;
273
+ if (!subPkgGlobs.includes(wsGlob)) subPkgGlobs.push(wsGlob);
274
+ }
275
+ }
276
+ for (const spg of subPkgGlobs) {
277
+ const subPkgs = await glob(spg, { cwd: ROOT, ignore: ["**/node_modules/**"] });
278
+ for (const sp of subPkgs) {
279
+ const sub = readJsonSafe(path.join(ROOT, sp));
280
+ if (sub) Object.assign(deps, sub.dependencies, sub.devDependencies);
281
+ }
282
+ }
283
+ }
284
+
285
+ if (!stack.language) stack.language = deps.typescript ? "typescript" : "javascript";
286
+ if (deps.typescript) { stack.detected.push("typescript"); const tv = deps.typescript.match(/(\d+(?:\.\d+)*)/); if (tv) stack.languageVersion = tv[1]; }
287
+
288
+ // Frontend (Angular checked before React — Angular projects may include react in devDependencies)
289
+ const frontendRules = [
290
+ ["next", "nextjs", "next.js"],
291
+ ["@angular/core", "angular", "angular"],
292
+ ["react", "react", "react"],
293
+ ["vue", "vue", "vue"],
294
+ ];
295
+ for (const [dep, name, label] of frontendRules) {
296
+ if (deps[dep] && !stack.frontend) {
297
+ stack.frontend = name; stack.detected.push(label);
298
+ stack.frontendVersion = deps[dep].replace(/[^0-9.]/g, "");
299
+ break;
300
+ }
301
+ }
302
+
303
+ // Backend framework (NestJS > Fastify > Express — more specific first)
304
+ const frameworkRules = [
305
+ ["@nestjs/core", "nestjs", "nestjs"],
306
+ ["fastify", "fastify", "fastify"],
307
+ ["express", "express", "express"],
308
+ ];
309
+ for (const [dep, name, label] of frameworkRules) {
310
+ if (deps[dep] && !stack.framework) {
311
+ stack.framework = name; stack.detected.push(label);
312
+ if (dep !== "express") stack.frameworkVersion = deps[dep].replace(/[^0-9.]/g, "");
313
+ break;
314
+ }
315
+ }
316
+
317
+ // Vite as framework (only if no backend framework was detected — Vite is a build/dev tool for SPA)
318
+ if (deps.vite && !stack.framework) {
319
+ stack.framework = "vite";
320
+ stack.detected.push("vite");
321
+ stack.frameworkVersion = deps.vite.replace(/[^0-9.]/g, "");
322
+ }
323
+
324
+ // ORM
325
+ for (const [depKeys, ormName] of NODE_ORM_RULES) {
326
+ if (stack.orm) break;
327
+ if (depKeys.some(d => deps[d])) {
328
+ stack.orm = ormName; stack.detected.push(ormName);
329
+ }
330
+ }
331
+ if (deps.mongoose) { if (!stack.database) stack.database = "mongodb"; if (!stack.orm) stack.orm = "mongoose"; stack.detected.push("mongoose"); }
332
+
333
+ // DB
334
+ const nodeDbRules = [["pg", "postgresql"], ["mysql2", "mysql"], ["mongodb", "mongodb"]];
335
+ for (const [dep, db] of nodeDbRules) {
336
+ if (deps[dep] && !stack.database) { stack.database = db; break; }
337
+ }
338
+
339
+ // Package manager
340
+ stack.packageManager = existsSafe(path.join(ROOT, "pnpm-lock.yaml")) ? "pnpm"
341
+ : existsSafe(path.join(ROOT, "yarn.lock")) ? "yarn" : "npm";
342
+
343
+ if (pkg.engines && pkg.engines.node && !stack.languageVersion) {
344
+ const nv = pkg.engines.node.match(/(\d+(?:\.\d+)*)/);
345
+ if (nv) stack.languageVersion = nv[1];
346
+ }
347
+ }
348
+ }
349
+
350
+ // ── Python ──
351
+ const hasPyproject = existsSafe(path.join(ROOT, "pyproject.toml"));
352
+ const hasRequirements = existsSafe(path.join(ROOT, "requirements.txt"));
353
+ if (hasPyproject || hasRequirements) {
354
+ if (!stack.language) stack.language = "python";
355
+ stack.detected.push("python");
356
+
357
+ const pyFrameworkRules = [["django", "django"], ["fastapi", "fastapi"], ["flask", "flask"]];
358
+ const pyOrmRules = [["sqlalchemy", "sqlalchemy"], ["tortoise", "tortoise-orm"]];
359
+
360
+ if (hasPyproject) {
361
+ const pp = readFileSafe(path.join(ROOT, "pyproject.toml"));
362
+ if (pp) {
363
+ const pv = pp.match(/python\s*=\s*"[><=^~]*(\d+\.\d+)/);
364
+ if (pv && !stack.languageVersion) stack.languageVersion = pv[1];
365
+ for (const [kw, name] of pyFrameworkRules) {
366
+ if (pp.includes(kw) && !stack.framework) { stack.framework = name; stack.detected.push(name); break; }
367
+ }
368
+ for (const [kw, name] of pyOrmRules) {
369
+ if (pp.includes(kw) && !stack.orm) { stack.orm = name; stack.detected.push(name); break; }
370
+ }
371
+ if (pp.includes("poetry")) { stack.packageManager = "poetry"; }
372
+ if (pp.includes("pdm")) { stack.packageManager = "pdm"; }
373
+ }
374
+ }
375
+
376
+ if (hasRequirements) {
377
+ const r = readFileSafe(path.join(ROOT, "requirements.txt"));
378
+ if (r) {
379
+ for (const [kw, name] of pyFrameworkRules) {
380
+ if (r.includes(kw) && !stack.framework) { stack.framework = name; stack.detected.push(name); break; }
381
+ }
382
+ for (const [kw, name] of pyOrmRules) {
383
+ if (r.includes(kw) && !stack.orm) { stack.orm = name; break; }
384
+ }
385
+ if (r.includes("psycopg") && !stack.database) stack.database = "postgresql";
386
+ if (r.includes("mysqlclient") && !stack.database) stack.database = "mysql";
387
+ }
388
+ }
389
+
390
+ if (!stack.packageManager) {
391
+ stack.packageManager = existsSafe(path.join(ROOT, "Pipfile")) ? "pipenv"
392
+ : existsSafe(path.join(ROOT, "poetry.lock")) ? "poetry" : "pip";
393
+ }
394
+ }
395
+
396
+ // ── DB from config files ──
397
+ const ymls = await glob("**/application*.yml", { cwd: ROOT, absolute: true, ignore: ["**/node_modules/**", "**/build/**", "**/target/**", "**/.gradle/**"] });
398
+ for (const y of ymls) {
399
+ const c = readFileSafe(y);
400
+ if (!c) continue;
401
+ if (!stack.database && c.includes("postgresql")) stack.database = "postgresql";
402
+ if (!stack.database && c.includes("mysql")) stack.database = "mysql";
403
+ if (!stack.database && c.includes("oracle")) stack.database = "oracle";
404
+ if (!stack.database && c.includes("mongodb")) stack.database = "mongodb";
405
+ if (!stack.database && H2_REGEX.test(c)) stack.database = "h2";
406
+ if (!stack.database && c.includes("sqlite")) stack.database = "sqlite";
407
+ if (!stack.port) {
408
+ const pm = c.match(/server:\s*\n\s*port:\s*(\d+)/) || c.match(/server\.port\s*[=:]\s*(\d+)/);
409
+ if (pm) stack.port = parseInt(pm[1]);
410
+ }
411
+ }
412
+
413
+ // .env
414
+ for (const ef of [".env", ".env.local", ".env.development"]) {
415
+ const ep = path.join(ROOT, ef);
416
+ if (existsSafe(ep)) {
417
+ const ec = readFileSafe(ep);
418
+ if (ec && ec.includes("DATABASE_URL")) {
419
+ // .env: original checks postgres (not postgresql), no oracle/h2
420
+ if (ec.includes("postgres") && !stack.database) stack.database = "postgresql";
421
+ if (ec.includes("mysql") && !stack.database) stack.database = "mysql";
422
+ if (ec.includes("mongodb") && !stack.database) stack.database = "mongodb";
423
+ if (ec.includes("sqlite") && !stack.database) stack.database = "sqlite";
424
+ }
425
+ }
426
+ }
427
+
428
+ // Prisma schema
429
+ const prismaSchema = path.join(ROOT, "prisma/schema.prisma");
430
+ if (existsSafe(prismaSchema)) {
431
+ const ps = readFileSafe(prismaSchema);
432
+ if (ps) {
433
+ const prov = ps.match(/provider\s*=\s*"(\w+)"/);
434
+ if (prov && !stack.database) {
435
+ const db = { postgresql: "postgresql", mysql: "mysql", sqlite: "sqlite", mongodb: "mongodb" };
436
+ if (db[prov[1]]) stack.database = db[prov[1]];
437
+ }
438
+ }
439
+ }
440
+
441
+ // ── Config file fallback (monorepo) ──
442
+ // [configFiles, frontendName, frameworkName (optional), label]
443
+ const frontendFallbacks = [
444
+ [["next.config.js", "next.config.mjs", "next.config.ts"], "nextjs", null, "next.config (fallback)"],
445
+ [["vite.config.ts", "vite.config.js"], "react", "vite", "vite.config (fallback)"],
446
+ [["nuxt.config.ts", "nuxt.config.js"], "vue", null, "nuxt.config (fallback)"],
447
+ [["angular.json", ".angular.json"], "angular", null, "angular.json (fallback)"],
448
+ ];
449
+ if (!stack.frontend) {
450
+ for (const [files, frontendName, frameworkName, label] of frontendFallbacks) {
451
+ if (files.some(f => existsSafe(path.join(ROOT, f)))) {
452
+ stack.frontend = frontendName; stack.detected.push(label);
453
+ if (!stack.language) stack.language = "typescript";
454
+ if (frameworkName && !stack.framework) {
455
+ stack.framework = frameworkName;
456
+ stack.detected.push(frameworkName + " (fallback)");
457
+ }
458
+ break;
459
+ }
460
+ }
461
+ }
462
+
463
+ return stack;
464
+ }
465
+
466
+ module.exports = { detectStack };