claudeos-core 2.2.0 → 2.3.1

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 (49) hide show
  1. package/CHANGELOG.md +1664 -907
  2. package/CONTRIBUTING.md +92 -92
  3. package/README.de.md +28 -0
  4. package/README.es.md +28 -0
  5. package/README.fr.md +28 -0
  6. package/README.hi.md +28 -0
  7. package/README.ja.md +28 -0
  8. package/README.ko.md +1014 -986
  9. package/README.md +1016 -987
  10. package/README.ru.md +28 -0
  11. package/README.vi.md +1015 -987
  12. package/README.zh-CN.md +28 -0
  13. package/bin/cli.js +152 -148
  14. package/bin/commands/init.js +1673 -1554
  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 -441
  22. package/lib/expected-guides.js +23 -23
  23. package/lib/expected-outputs.js +90 -90
  24. package/lib/language-config.js +35 -35
  25. package/lib/memory-scaffold.js +1058 -1054
  26. package/lib/plan-parser.js +165 -165
  27. package/lib/staged-rules.js +118 -118
  28. package/manifest-generator/index.js +174 -174
  29. package/package.json +90 -87
  30. package/pass-json-validator/index.js +337 -337
  31. package/pass-prompts/templates/common/claude-md-scaffold.md +52 -10
  32. package/pass-prompts/templates/common/pass3-footer.md +402 -224
  33. package/pass-prompts/templates/common/pass3b-core-header.md +43 -0
  34. package/pass-prompts/templates/common/pass4.md +375 -305
  35. package/pass-prompts/templates/common/staging-override.md +26 -26
  36. package/pass-prompts/templates/node-vite/pass1.md +117 -117
  37. package/pass-prompts/templates/node-vite/pass2.md +78 -78
  38. package/pass-prompts/templates/python-flask/pass1.md +119 -119
  39. package/pass-prompts/templates/python-flask/pass2.md +85 -85
  40. package/plan-installer/domain-grouper.js +76 -76
  41. package/plan-installer/index.js +137 -137
  42. package/plan-installer/prompt-generator.js +188 -145
  43. package/plan-installer/scanners/scan-frontend.js +505 -473
  44. package/plan-installer/scanners/scan-java.js +226 -226
  45. package/plan-installer/scanners/scan-node.js +57 -57
  46. package/plan-installer/scanners/scan-python.js +85 -85
  47. package/plan-installer/stack-detector.js +482 -482
  48. package/plan-installer/structure-scanner.js +65 -65
  49. package/sync-checker/index.js +177 -177
@@ -1,482 +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
- 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 };
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 };