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