claudeos-core 2.4.4 → 2.5.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 +125 -0
  2. package/README.de.md +12 -10
  3. package/README.es.md +12 -10
  4. package/README.fr.md +12 -10
  5. package/README.hi.md +12 -10
  6. package/README.ja.md +12 -10
  7. package/README.ko.md +12 -10
  8. package/README.md +12 -10
  9. package/README.ru.md +12 -10
  10. package/README.vi.md +12 -10
  11. package/README.zh-CN.md +12 -10
  12. package/bin/commands/init.js +121 -24
  13. package/bin/commands/lint.js +2 -0
  14. package/bin/commands/memory.js +10 -3
  15. package/content-validator/index.js +82 -13
  16. package/lib/env-parser.js +98 -12
  17. package/lib/memory-scaffold.js +35 -16
  18. package/manifest-generator/index.js +15 -4
  19. package/package.json +92 -92
  20. package/pass-json-validator/index.js +1 -1
  21. package/pass-prompts/templates/angular/pass3.md +2 -1
  22. package/pass-prompts/templates/common/claude-md-scaffold.md +1 -1
  23. package/pass-prompts/templates/common/pass3a-facts.md +11 -9
  24. package/pass-prompts/templates/common/pass4.md +3 -3
  25. package/pass-prompts/templates/java-spring/pass1.md +10 -2
  26. package/pass-prompts/templates/java-spring/pass3.md +5 -4
  27. package/pass-prompts/templates/kotlin-spring/pass3.md +2 -2
  28. package/pass-prompts/templates/node-express/pass3.md +1 -1
  29. package/pass-prompts/templates/node-fastify/pass3.md +1 -0
  30. package/pass-prompts/templates/node-nestjs/pass3.md +1 -0
  31. package/pass-prompts/templates/node-nextjs/pass3.md +1 -1
  32. package/pass-prompts/templates/node-vite/pass3.md +1 -0
  33. package/pass-prompts/templates/python-django/pass3.md +1 -1
  34. package/pass-prompts/templates/python-fastapi/pass3.md +1 -1
  35. package/pass-prompts/templates/python-flask/pass3.md +1 -0
  36. package/pass-prompts/templates/vue-nuxt/pass3.md +1 -0
  37. package/plan-installer/domain-grouper.js +4 -1
  38. package/plan-installer/index.js +26 -7
  39. package/plan-installer/jvm-detect.js +562 -0
  40. package/plan-installer/pass3-context-builder.js +10 -0
  41. package/plan-installer/prompt-generator.js +18 -2
  42. package/plan-installer/scanners/scan-frontend.js +67 -6
  43. package/plan-installer/scanners/scan-java.js +214 -15
  44. package/plan-installer/scanners/scan-kotlin.js +68 -3
  45. package/plan-installer/scanners/scan-node.js +115 -0
  46. package/plan-installer/scanners/scan-python.js +56 -0
  47. package/plan-installer/source-paths.js +61 -0
  48. package/plan-installer/stack-detector.js +726 -51
  49. package/plan-installer/structure-scanner.js +15 -4
@@ -9,7 +9,8 @@
9
9
  const path = require("path");
10
10
  const { glob } = require("glob");
11
11
  const { readFileSafe, readJsonSafe, existsSafe } = require("../lib/safe-fs");
12
- const { readStackEnvInfo } = require("../lib/env-parser");
12
+ const { readStackEnvInfo, extractPort } = require("../lib/env-parser");
13
+ const JVM = require("./jvm-detect");
13
14
 
14
15
  // ─── Lookup tables ──────────────────────────────────────────────
15
16
 
@@ -60,11 +61,42 @@ const DB_KEYWORD_RULES = [
60
61
  ["oracle", "oracle"],
61
62
  ["mongodb", "mongodb"],
62
63
  ["sqlite", "sqlite"],
64
+ // v2.5.1 — JDBC coordinates common in enterprise / Korean-market deployments.
65
+ ["mssql-jdbc", "mssql"], ["sqljdbc", "mssql"], ["jtds", "mssql"], ["sqlserver", "mssql"],
66
+ ["com.ibm.db2", "db2"], ["db2jcc", "db2"],
67
+ ["tibero", "tibero"], ["altibase", "altibase"], ["cubrid", "cubrid"],
63
68
  ];
64
69
 
65
70
  // h2 needs word-boundary check (avoid oauth2, cache2k false positives)
66
71
  const H2_REGEX = /\bh2\b/;
67
72
 
73
+ // Java version literals come in two spellings: modern `17` / `21` and the
74
+ // legacy dotted form `1.8` (Java 8 — still the norm in enterprise SI
75
+ // codebases). Normalize the legacy form so `1.8` → `8`; pass everything
76
+ // else through unchanged.
77
+ function normalizeJavaVersion(v) {
78
+ if (v == null) return v;
79
+ const m = String(v).match(/^1\.(\d+)$/);
80
+ return m ? m[1] : String(v);
81
+ }
82
+
83
+ // v2.5.0 — Source-language evidence. Build-file keywords alone are ambiguous:
84
+ // `buildSrc/build.gradle.kts` carries `kotlin-dsl` in pure-Java repos, and a
85
+ // Java catalog may pin `kotlin = "1.9.22"` only to settle kotlin-stdlib
86
+ // conflicts. When `.java` sources exist and no `.kt` sources do, a "kotlin"
87
+ // keyword must NOT flip the language — the Kotlin scanner would then find
88
+ // zero domains and `init` would abort. Memoized per detectStack() call.
89
+ async function hasJavaOnlySources(ROOT) {
90
+ // `buildSrc/` and `build-logic/` (Gradle's documented buildSrc replacement)
91
+ // hold convention plugins written in Kotlin DSL — build tooling, not
92
+ // application code.
93
+ const ignore = ["**/node_modules/**", "**/build/**", "**/target/**", "**/buildSrc/**", "**/build-logic/**", "**/gradle/plugins/**", "**/src/test/**", "**/.git/**"];
94
+ const kt = await glob("**/src/main/{java,kotlin}/**/*.kt", { cwd: ROOT, ignore });
95
+ if (kt.length > 0) return false;
96
+ const java = await glob("**/src/main/java/**/*.java", { cwd: ROOT, ignore });
97
+ return java.length > 0;
98
+ }
99
+
68
100
  // ─── Helpers ────────────────────────────────────────────────────
69
101
 
70
102
  function detectFirst(stack, field, content, rules) {
@@ -233,6 +265,16 @@ function detectDb(stack, content, rules) {
233
265
  * @returns {Promise<object>} stack info
234
266
  */
235
267
  async function detectStack(ROOT) {
268
+ // Lazily evaluated once per call; only consulted when a "kotlin" keyword
269
+ // would otherwise flip the language (see hasJavaOnlySources).
270
+ let javaOnlyMemo = null;
271
+ const isJavaOnly = async () => {
272
+ if (javaOnlyMemo === null) javaOnlyMemo = await hasJavaOnlySources(ROOT);
273
+ return javaOnlyMemo;
274
+ };
275
+ // Set when `language` was taken from a root package.json (see Node block);
276
+ // the Python block reclaims it when the backend turns out to be Python.
277
+ let languageFromPackageJson = false;
236
278
  const stack = {
237
279
  language: null, languageVersion: null,
238
280
  framework: null, frameworkVersion: null,
@@ -259,6 +301,13 @@ async function detectStack(ROOT) {
259
301
  loggingFrameworks: [],
260
302
  frontend: null, frontendVersion: null,
261
303
  packageManager: null, monorepo: null, workspaces: null,
304
+ // v2.5.x — JVM legacy support. `packaging` is what the build file
305
+ // DECLARES (Gradle `war`/`ear` plugin, Maven `<packaging>`, or a
306
+ // `WEB-INF/lib` tree) — null when nothing is declared, never an
307
+ // invented "jar". `springFrameworkVersion` is the org.springframework
308
+ // line when it is pinned explicitly; for Boot projects it is normally
309
+ // null because Boot manages it.
310
+ packaging: null, springFrameworkVersion: null,
262
311
  detected: [],
263
312
  };
264
313
 
@@ -266,6 +315,12 @@ async function detectStack(ROOT) {
266
315
  const gradleFile = existsSafe(path.join(ROOT, "build.gradle.kts"))
267
316
  ? "build.gradle.kts"
268
317
  : existsSafe(path.join(ROOT, "build.gradle")) ? "build.gradle" : null;
318
+ // v2.5.1 — a root holding only settings.gradle{,.kts} (modules carry the
319
+ // build files) is still a Gradle project; the sub-module sweep does the rest.
320
+ if (!gradleFile) {
321
+ const sg = ["settings.gradle.kts", "settings.gradle"].find(f => existsSafe(path.join(ROOT, f)));
322
+ if (sg) { stack.buildTool = "gradle"; stack.detected.push(sg); if (!stack.packageManager) stack.packageManager = "gradle"; }
323
+ }
269
324
  if (gradleFile) {
270
325
  const g = readFileSafe(path.join(ROOT, gradleFile));
271
326
  if (g) {
@@ -274,7 +329,60 @@ async function detectStack(ROOT) {
274
329
  // and downstream tooling don't show "PackageMgr: none" for a build tool
275
330
  // that IS the package manager. Only set if not already detected.
276
331
  if (!stack.packageManager) stack.packageManager = "gradle";
277
- if (g.includes("spring-boot")) { stack.language = "java"; stack.framework = "spring-boot"; stack.detected.push("spring-boot"); }
332
+ // v2.5.x JVM plugin evidence first, independent of Spring Boot.
333
+ // `apply plugin: 'java'` / `'war'` / `'application'` / `plugins { java }`
334
+ // is proof of a Java project on its own. Before this, a legacy
335
+ // `apply plugin: 'java'` + `spring-webmvc:4.3.30.RELEASE` build reported
336
+ // `language: null` and the Java scanner never ran.
337
+ // v2.5.1 — gradle.properties is a second variable source for
338
+ // `${springVersion}` / `${springBootVersion}` references.
339
+ const gProps = JVM.parseGradleProperties(readFileSafe(path.join(ROOT, "gradle.properties")));
340
+ // v2.5.1 — variable definitions may live outside build.gradle:
341
+ // `apply from: 'gradle/dependencies.gradle'` scripts and buildSrc
342
+ // Kotlin constants (`object Versions { const val spring = "…" }`).
343
+ // Appended to the RESOLUTION text only; detection still reads `g`.
344
+ let gResolve = g;
345
+ for (const rel of JVM.gradleAppliedScripts(g).slice(0, 10)) {
346
+ const t = readFileSafe(path.join(ROOT, rel)); if (t) gResolve += "\n" + t;
347
+ }
348
+ if (existsSafe(path.join(ROOT, "buildSrc"))) {
349
+ const kts = await glob("buildSrc/src/main/{kotlin,java,groovy}/**/*.{kt,groovy,java}", { cwd: ROOT, nodir: true });
350
+ for (const f of kts.slice(0, 20)) { const t = readFileSafe(path.join(ROOT, f)); if (t) gResolve += "\n" + t; }
351
+ }
352
+ const gPlugins = JVM.gradleJvmPlugins(g);
353
+ const jvmByPlugin = JVM.gradleIsJvm(gPlugins) && !stack.language;
354
+ if (jvmByPlugin) stack.language = "java";
355
+ const gPack = JVM.gradlePackaging(gPlugins);
356
+ if (gPack) stack.packaging = gPack;
357
+ // `spring-boot` (starter coords) OR `org.springframework.boot` (plugin id —
358
+ // the only spelling present in a multi-module root that declares
359
+ // `id 'org.springframework.boot' version 'x' apply false`).
360
+ if (g.includes("spring-boot") || g.includes("org.springframework.boot")) {
361
+ stack.language = "java"; stack.framework = "spring-boot"; stack.detected.push("spring-boot");
362
+ } else if (jvmByPlugin) {
363
+ // Label the plugin evidence only when Boot is absent, so the
364
+ // `detected` array of every existing Boot project stays
365
+ // byte-identical to pre-v2.5.x output.
366
+ stack.detected.push("java (gradle plugin)");
367
+ }
368
+ // v2.5.x — Spring Framework WITHOUT Boot (group exactly
369
+ // org.springframework: spring-webmvc / spring-context / the 2.x
370
+ // single `spring` jar / spring-framework-bom). Boot wins when both
371
+ // appear because Boot manages the Framework version.
372
+ if (!stack.framework && JVM.gradleHasSpringFramework(g)) {
373
+ stack.language = "java"; stack.framework = "spring-framework"; stack.detected.push("spring-framework");
374
+ }
375
+ if (JVM.hasEgovframe(g)) {
376
+ const ev = JVM.egovframeVersion(g);
377
+ stack.detected.push(ev ? `egovframe ${ev}` : "egovframe");
378
+ if (!stack.framework) { stack.language = "java"; stack.framework = "spring-framework"; stack.detected.push("spring-framework"); }
379
+ }
380
+ const gSfv = JVM.gradleSpringFrameworkVersion(gResolve, gProps);
381
+ if (gSfv) stack.springFrameworkVersion = gSfv;
382
+ if (stack.framework === "spring-framework" && gSfv) stack.frameworkVersion = gSfv;
383
+ // Struts / JSF tags are legacy evidence; not pushed for Boot projects
384
+ // (their `detected` array must stay byte-identical to v2.5.0 output).
385
+ if (stack.framework !== "spring-boot") for (const t of JVM.legacyFrameworkTags(g)) stack.detected.push(t.version ? `${t.tag} ${t.version}` : t.tag);
278
386
  const svPatterns = [
279
387
  /org\.springframework\.boot.*version\s*['"]([^'"]+)['"]/,
280
388
  /id\s*\(\s*["']org\.springframework\.boot["']\s*\)\s*version\s*["']([^"']+)["']/,
@@ -306,6 +414,15 @@ async function detectStack(ROOT) {
306
414
  if (varVal) stack.frameworkVersion = varVal[1];
307
415
  }
308
416
  }
417
+ // v2.5.x — Boot version. The helper covers every form the loop above
418
+ // covered PLUS the Boot 1.x/2.x buildscript-classpath form
419
+ // (`classpath("org.springframework.boot:spring-boot-gradle-plugin:1.5.22.RELEASE")`,
420
+ // optionally via `${springBootVersion}`), which had no `version`
421
+ // keyword for the old regexes to anchor on.
422
+ if (stack.framework === "spring-boot" && !stack.frameworkVersion) {
423
+ const bv = JVM.gradleSpringBootVersion(gResolve, gProps);
424
+ if (bv) stack.frameworkVersion = bv;
425
+ }
309
426
  // Java version — Gradle writes this in several forms. Try each
310
427
  // pattern until one matches. Earlier patterns take precedence.
311
428
  //
@@ -332,16 +449,19 @@ async function detectStack(ROOT) {
332
449
  // Java 21).
333
450
  const javaVersionPatterns = [
334
451
  // (1) numeric literal on sourceCompatibility or targetCompatibility
335
- /sourceCompatibility\s*=\s*['"]?(\d+)['"]?/,
336
- /targetCompatibility\s*=\s*['"]?(\d+)['"]?/,
452
+ // `(\d+(?:\.\d+)?)` — captures `1.8` whole instead of stopping at `1`
453
+ /sourceCompatibility\s*=\s*['"]?(\d+(?:\.\d+)?)['"]?/,
454
+ /targetCompatibility\s*=\s*['"]?(\d+(?:\.\d+)?)['"]?/,
337
455
  // (2) JavaVersion enum — supports both VERSION_21 and VERSION_1_8
338
456
  /JavaVersion\.VERSION_(?:1_)?(\d+)/,
339
457
  // (3) toolchain block
340
458
  /JavaLanguageVersion\.of\s*\(\s*(\d+)\s*\)/,
459
+ // (3b) v2.5.1 — `options.release = 17` / `options.release.set(17)`
460
+ /options\.release(?:\.set)?\s*[=(]\s*(\d+)/,
341
461
  ];
342
462
  for (const pattern of javaVersionPatterns) {
343
463
  const m = g.match(pattern);
344
- if (m) { stack.languageVersion = m[1]; break; }
464
+ if (m) { stack.languageVersion = normalizeJavaVersion(m[1]); break; }
345
465
  }
346
466
  // (4) ext variable reference fallback — if the Compatibility
347
467
  // assignment used "${varName}" we now resolve varName inside the
@@ -356,9 +476,9 @@ async function detectStack(ROOT) {
356
476
  // defensive guard against unexpected characters, not a
357
477
  // practical necessity for today's inputs.
358
478
  const escapedVarName = varName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
359
- const extAssign = new RegExp(`${escapedVarName}\\s*=\\s*['"]?(\\d+)['"]?`);
479
+ const extAssign = new RegExp(`${escapedVarName}\\s*=\\s*['"]?(\\d+(?:\\.\\d+)?)['"]?`);
360
480
  const extVal = g.match(extAssign);
361
- if (extVal) stack.languageVersion = extVal[1];
481
+ if (extVal) stack.languageVersion = normalizeJavaVersion(extVal[1]);
362
482
  }
363
483
  }
364
484
 
@@ -382,7 +502,8 @@ async function detectStack(ROOT) {
382
502
  detectLogging(stack, g);
383
503
 
384
504
  // Kotlin detection: override language if Kotlin plugin found
385
- if (g.includes("kotlin") || g.includes("org.jetbrains.kotlin")) {
505
+ // (unless the source tree is Java-only — see hasJavaOnlySources).
506
+ if ((g.includes("kotlin") || g.includes("org.jetbrains.kotlin")) && !(await isJavaOnly())) {
386
507
  stack.language = "kotlin"; stack.detected.push("kotlin");
387
508
  const kvPatterns = [
388
509
  /kotlin\S*\s*version\s*['"]([^'"]+)['"]/,
@@ -411,6 +532,22 @@ async function detectStack(ROOT) {
411
532
  const sbMatch = vc.match(/spring-boot\s*=\s*["']([^"']+)["']/);
412
533
  if (sbMatch) stack.frameworkVersion = sbMatch[1];
413
534
  }
535
+ // v2.5.x — Spring Framework declared through the catalog
536
+ // (`spring-webmvc = { module = "org.springframework:spring-webmvc",
537
+ // version.ref = "spring" }`). The build file only shows
538
+ // `libs.spring.webmvc`, so this is the one place the coordinate and
539
+ // its version are visible.
540
+ if (!stack.framework && JVM.catalogHasSpringFramework(vc)) {
541
+ if (!stack.language) stack.language = "java";
542
+ stack.framework = "spring-framework"; stack.detected.push("spring-framework (catalog)");
543
+ }
544
+ if (!stack.springFrameworkVersion) {
545
+ const cSfv = JVM.catalogSpringFrameworkVersion(vc);
546
+ if (cSfv) {
547
+ stack.springFrameworkVersion = cSfv;
548
+ if (stack.framework === "spring-framework" && !stack.frameworkVersion) stack.frameworkVersion = cSfv;
549
+ }
550
+ }
414
551
  // Version catalog ORM (labels include " (catalog)" suffix)
415
552
  if (!stack.orm && vc.includes("exposed")) { stack.orm = "exposed"; stack.detected.push("exposed (catalog)"); }
416
553
  else if (!stack.orm && vc.includes("jooq")) { stack.orm = "jooq"; stack.detected.push("jooq (catalog)"); }
@@ -427,15 +564,77 @@ async function detectStack(ROOT) {
427
564
  if (!stack.databases.includes(value)) stack.databases.push(value);
428
565
  }
429
566
  }
430
- if (!stack.language && vc.includes("kotlin")) {
567
+ // A `kotlin = "x.y.z"` version entry or a Kotlin *plugin* coordinate in
568
+ // the catalog is decisive — it overrides the `java` default that the
569
+ // root build file's `org.springframework.boot` plugin id sets.
570
+ // Library coordinates (`org.jetbrains.kotlin:kotlin-stdlib`) are NOT a
571
+ // signal: Java projects pin them in the catalog to settle transitive
572
+ // version conflicts without writing a line of Kotlin.
573
+ const KOTLIN_CATALOG_RE = /(^\s*kotlin\s*=|org\.jetbrains\.kotlin\.(?:jvm|plugin|multiplatform|android|kapt)|kotlin-gradle-plugin)/m;
574
+ if (stack.language !== "kotlin" && KOTLIN_CATALOG_RE.test(vc) && !(await isJavaOnly())) {
431
575
  stack.language = "kotlin"; stack.detected.push("kotlin (catalog)");
432
576
  }
433
577
  }
434
578
  }
435
579
 
580
+ // ── Java: multi-module Gradle detection ──
581
+ // Root build.gradle of a multi-module project often holds only
582
+ // `allprojects { repositories {...} }` and no framework coords at all;
583
+ // the real declarations live in `api/build.gradle`, `core/build.gradle`, …
584
+ // Scan sub-module build files (same bound as the Kotlin block below) for
585
+ // the Java plugin / Spring Boot coords so the project isn't reported as
586
+ // "no language detected" and the Java scanner actually runs.
587
+ if (!stack.language && stack.buildTool === "gradle") {
588
+ const subBuildFiles = await glob("*/**/build.gradle{,.kts}", { cwd: ROOT, ignore: ["**/node_modules/**", "**/build/**", "**/buildSrc/**"] });
589
+ for (const sbf of subBuildFiles.slice(0, 30)) {
590
+ const sc = readFileSafe(path.join(ROOT, sbf));
591
+ if (!sc) continue;
592
+ if (sc.includes("kotlin") || sc.includes("org.jetbrains.kotlin")) continue; // handled by the Kotlin block
593
+ // v2.5.x — any JVM plugin or an org.springframework coordinate counts,
594
+ // not only java/java-library/Boot.
595
+ const scPlugins = JVM.gradleJvmPlugins(sc);
596
+ const isJava = JVM.gradleIsJvm(scPlugins)
597
+ || sc.includes("spring-boot")
598
+ || JVM.gradleHasSpringFramework(sc);
599
+ if (isJava) {
600
+ // Do not `break` on the first Java module: a `core` library module
601
+ // usually comes before the `api` module that actually declares
602
+ // Spring Boot. Set language once, keep sweeping for framework/versions.
603
+ if (stack.language !== "java") { stack.language = "java"; stack.detected.push("java (submodule)"); }
604
+ if (!stack.packaging) { const p = JVM.gradlePackaging(scPlugins); if (p) stack.packaging = p; }
605
+ if (!stack.framework && (sc.includes("spring-boot") || sc.includes("org.springframework.boot"))) {
606
+ stack.framework = "spring-boot"; stack.detected.push("spring-boot (submodule)");
607
+ }
608
+ if (!stack.framework && JVM.gradleHasSpringFramework(sc)) {
609
+ stack.framework = "spring-framework"; stack.detected.push("spring-framework (submodule)");
610
+ }
611
+ if (!stack.springFrameworkVersion) {
612
+ const sfv = JVM.gradleSpringFrameworkVersion(sc);
613
+ if (sfv) { stack.springFrameworkVersion = sfv; if (stack.framework === "spring-framework" && !stack.frameworkVersion) stack.frameworkVersion = sfv; }
614
+ }
615
+ if (!stack.frameworkVersion && stack.framework === "spring-boot") {
616
+ // `spring-boot-starter-web:2.7.18`, `spring-boot-dependencies:3.2.0`,
617
+ // or `id 'org.springframework.boot' version '3.2.5'` inside the module.
618
+ const sv = JVM.gradleSpringBootVersion(sc)
619
+ || (sc.match(/spring-boot[\w-]*[:\s'"]+(\d+\.\d+\.\d+)/) || [])[1];
620
+ if (sv) stack.frameworkVersion = sv;
621
+ }
622
+ if (!stack.languageVersion) {
623
+ const jv = sc.match(/(?:sourceCompatibility|targetCompatibility)\s*=\s*['"]?(\d+(?:\.\d+)?)['"]?/)
624
+ || sc.match(/JavaVersion\.VERSION_(?:1_)?(\d+)/)
625
+ || sc.match(/JavaLanguageVersion\.of\s*\(\s*(\d+)\s*\)/);
626
+ if (jv) stack.languageVersion = normalizeJavaVersion(jv[1]);
627
+ }
628
+ if (stack.framework && stack.frameworkVersion && stack.languageVersion) break;
629
+ }
630
+ }
631
+ }
632
+
436
633
  // ── Kotlin: multi-module Gradle detection ──
437
- if (stack.language !== "kotlin" && stack.buildTool === "gradle") {
438
- const subBuildFiles = await glob("**/build.gradle{,.kts}", { cwd: ROOT, ignore: ["**/node_modules/**", "**/build/**"] });
634
+ // v2.5.0: `buildSrc/` is ignored (its `kotlin-dsl` plugin is not evidence of
635
+ // Kotlin application code) and a Java-only source tree never flips.
636
+ if (stack.language !== "kotlin" && stack.buildTool === "gradle" && !(await isJavaOnly())) {
637
+ const subBuildFiles = await glob("**/build.gradle{,.kts}", { cwd: ROOT, ignore: ["**/node_modules/**", "**/build/**", "**/buildSrc/**"] });
439
638
  for (const sbf of subBuildFiles.slice(0, 5)) {
440
639
  const sc = readFileSafe(path.join(ROOT, sbf));
441
640
  if (sc && (sc.includes("kotlin") || sc.includes("org.jetbrains.kotlin"))) {
@@ -482,6 +681,39 @@ async function detectStack(ROOT) {
482
681
  }
483
682
  }
484
683
 
684
+ // v2.5.1 — Fold one child pom (Maven <module> or a depth-1 sibling
685
+ // project) into `stack`, filling only what is still null. `${prop}` in the
686
+ // child resolves against the child first, then `rootPom` (may be "").
687
+ const absorbMavenPom = (cp, rootPom, mod) => {
688
+ const cpClean = stripComments(cp);
689
+ const propsText = cp + "\n" + rootPom;
690
+ if (!stack.framework && cpClean.includes("spring-boot")) { stack.framework = "spring-boot"; stack.detected.push(`spring-boot (${mod})`); }
691
+ if (stack.framework === "spring-boot" && !stack.frameworkVersion) { const bv = JVM.mavenSpringBootVersion(propsText, cpClean); if (bv) stack.frameworkVersion = bv; }
692
+ if (!stack.framework && JVM.mavenHasSpringFramework(cpClean)) { stack.framework = "spring-framework"; stack.detected.push(`spring-framework (${mod})`); }
693
+ // Root <properties> may already have yielded the Framework version
694
+ // before any module declared the framework itself — link them.
695
+ if (stack.framework === "spring-framework" && !stack.frameworkVersion && stack.springFrameworkVersion) stack.frameworkVersion = stack.springFrameworkVersion;
696
+ if (!stack.springFrameworkVersion) {
697
+ const v = JVM.mavenSpringFrameworkVersion(propsText, cpClean);
698
+ if (v) { stack.springFrameworkVersion = v; if (stack.framework === "spring-framework" && !stack.frameworkVersion) stack.frameworkVersion = v; }
699
+ }
700
+ if (JVM.hasEgovframe(cpClean) && !stack.detected.some(d => d.startsWith("egovframe"))) {
701
+ const ev = JVM.egovframeVersion(cpClean, propsText);
702
+ stack.detected.push(ev ? `egovframe ${ev}` : "egovframe");
703
+ if (!stack.framework) { stack.framework = "spring-framework"; stack.detected.push("spring-framework"); }
704
+ }
705
+ if (!stack.orm) { if (IBATIS_REGEX.test(cpClean)) { stack.orm = "ibatis"; stack.detected.push(`ibatis (${mod})`); } else detectFirst(stack, "orm", cpClean, MAVEN_ORM_RULES); }
706
+ for (const [keyword, value] of DB_KEYWORD_RULES.filter(([kw]) => kw !== "postgres")) {
707
+ if (cpClean.includes(keyword)) { if (!stack.database) stack.database = value; if (!stack.databases.includes(value)) stack.databases.push(value); }
708
+ }
709
+ if (!stack.languageVersion) {
710
+ const jv = cp.match(/<java\.version>\s*(\d+(?:\.\d+)?)\s*<\/java\.version>/) || cp.match(/<maven\.compiler\.(?:source|release)>\s*(\d+(?:\.\d+)?)\s*</);
711
+ if (jv) stack.languageVersion = normalizeJavaVersion(jv[1]);
712
+ }
713
+ if (!stack.packaging) { const pk = JVM.mavenPackaging(cp); if (pk && pk !== "pom") stack.packaging = pk; }
714
+ if (stack.framework !== "spring-boot") for (const t of JVM.legacyFrameworkTags(cpClean)) { const tag = t.version ? `${t.tag} ${t.version}` : t.tag; if (!stack.detected.includes(tag)) stack.detected.push(tag); }
715
+ };
716
+
485
717
  // ── Java: Maven ──
486
718
  if (existsSafe(path.join(ROOT, "pom.xml"))) {
487
719
  const pom = readFileSafe(path.join(ROOT, "pom.xml"));
@@ -489,6 +721,13 @@ async function detectStack(ROOT) {
489
721
  if (!stack.buildTool) { stack.buildTool = "maven"; stack.language = "java"; stack.detected.push("pom.xml"); }
490
722
  // v2.4.0 — JVM package manager (parallel to Gradle case above).
491
723
  if (!stack.packageManager) stack.packageManager = "maven";
724
+ // v2.5.x — declared packaging (war / ear / jar / pom). Null when the
725
+ // pom is silent; Maven's implicit "jar" default is NOT written back.
726
+ const mPack = JVM.mavenPackaging(pom);
727
+ if (mPack && !stack.packaging) stack.packaging = mPack;
728
+ // Boot version: <spring-boot.version> property (the only form the
729
+ // old regex knew), the starter-parent <version>, or the
730
+ // spring-boot-dependencies BOM import.
492
731
  const sv = pom.match(/<spring-boot[^>]*version>([^<]+)/);
493
732
  if (sv) stack.frameworkVersion = sv[1];
494
733
  // Java version — Maven commonly uses three patterns:
@@ -510,13 +749,15 @@ async function detectStack(ROOT) {
510
749
  // pom.xml (cross-file resolution — parent pom, BOM — is out of
511
750
  // scope; the resulting null falls through to LLM-side analysis).
512
751
  const mvnJavaPatterns = [
513
- /<java\.version>\s*(\d+)\s*<\/java\.version>/,
514
- /<maven\.compiler\.source>\s*(\d+)\s*<\/maven\.compiler\.source>/,
515
- /<maven\.compiler\.target>\s*(\d+)\s*<\/maven\.compiler\.target>/,
752
+ /<java\.version>\s*(\d+(?:\.\d+)?)\s*<\/java\.version>/,
753
+ /<maven\.compiler\.source>\s*(\d+(?:\.\d+)?)\s*<\/maven\.compiler\.source>/,
754
+ /<maven\.compiler\.target>\s*(\d+(?:\.\d+)?)\s*<\/maven\.compiler\.target>/,
755
+ // v2.5.1 — `<maven.compiler.release>17</maven.compiler.release>` (JEP 247 style)
756
+ /<maven\.compiler\.release>\s*(\d+)\s*<\/maven\.compiler\.release>/,
516
757
  ];
517
758
  for (const pattern of mvnJavaPatterns) {
518
759
  const m = pom.match(pattern);
519
- if (m) { stack.languageVersion = m[1]; break; }
760
+ if (m) { stack.languageVersion = normalizeJavaVersion(m[1]); break; }
520
761
  }
521
762
  // Pattern 3 fallback: if <java.version> references a property,
522
763
  // resolve it inside the same pom.
@@ -525,11 +766,19 @@ async function detectStack(ROOT) {
525
766
  if (propRef) {
526
767
  const propName = propRef[1].trim();
527
768
  const escapedProp = propName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
528
- const propDef = new RegExp(`<${escapedProp}>\\s*(\\d+)\\s*</${escapedProp}>`);
769
+ const propDef = new RegExp(`<${escapedProp}>\\s*(\\d+(?:\\.\\d+)?)\\s*</${escapedProp}>`);
529
770
  const propVal = pom.match(propDef);
530
- if (propVal) stack.languageVersion = propVal[1];
771
+ if (propVal) stack.languageVersion = normalizeJavaVersion(propVal[1]);
531
772
  }
532
773
  }
774
+ // v2.5.x — Pattern 4: pre-properties era. Maven 2 poms carried the
775
+ // level only inside the compiler plugin:
776
+ // <plugin><artifactId>maven-compiler-plugin</artifactId>
777
+ // <configuration><source>1.5</source><target>1.5</target></configuration>
778
+ if (!stack.languageVersion) {
779
+ const cps = JVM.mavenCompilerPluginSource(pom);
780
+ if (cps) stack.languageVersion = normalizeJavaVersion(cps);
781
+ }
533
782
  // For dependency detection (framework, ORM, DB, logging), strip
534
783
  // XML block comments first. A `<!-- <dependency>...</dependency> -->`
535
784
  // block is a standard Maven pattern for disabling a dep during
@@ -541,6 +790,40 @@ async function detectStack(ROOT) {
541
790
  // resolution already scopes itself to the declared property name.
542
791
  const pomClean = stripComments(pom);
543
792
  if (pomClean.includes("spring-boot") && !stack.framework) { stack.framework = "spring-boot"; stack.detected.push("spring-boot"); }
793
+ if (stack.framework === "spring-boot" && !stack.frameworkVersion) {
794
+ const bv = JVM.mavenSpringBootVersion(pom, pomClean);
795
+ if (bv) stack.frameworkVersion = bv;
796
+ }
797
+ // v2.5.x — Spring Framework without Boot. Only <dependency> blocks
798
+ // whose groupId is EXACTLY org.springframework count (comment-stripped
799
+ // text, so a `<!-- … -->`-disabled dependency is ignored). Version comes
800
+ // from spring-framework-bom, the first versioned Framework dependency,
801
+ // or a <spring.version>-style property — `${prop}` resolved in-file.
802
+ if (!stack.framework && JVM.mavenHasSpringFramework(pomClean)) {
803
+ stack.framework = "spring-framework"; stack.detected.push("spring-framework");
804
+ }
805
+ const mSfv = JVM.mavenSpringFrameworkVersion(pom, pomClean);
806
+ if (mSfv) {
807
+ stack.springFrameworkVersion = mSfv;
808
+ if (stack.framework === "spring-framework" && !stack.frameworkVersion) stack.frameworkVersion = mSfv;
809
+ }
810
+ // v2.5.1 — eGovFrame (전자정부 표준프레임워크): Spring MVC underneath.
811
+ if (JVM.hasEgovframe(pomClean)) {
812
+ const ev = JVM.egovframeVersion(pomClean, pom);
813
+ stack.detected.push(ev ? `egovframe ${ev}` : "egovframe");
814
+ if (!stack.framework) { stack.framework = "spring-framework"; stack.detected.push("spring-framework"); }
815
+ }
816
+ if (stack.framework !== "spring-boot") for (const t of JVM.legacyFrameworkTags(pomClean)) stack.detected.push(t.version ? `${t.tag} ${t.version}` : t.tag);
817
+ // v2.5.1 — Maven multi-module. A root `<packaging>pom</packaging>` with
818
+ // `<modules>` usually declares nothing but dependencyManagement; the
819
+ // Spring coordinates live in `web/pom.xml`. Sweep the listed modules
820
+ // (bounded) and fill only what the root left null. `${prop}` in a child
821
+ // resolves against the child first, then the root <properties>.
822
+ const moduleNames = [...pomClean.matchAll(/<module>\s*([^<]+?)\s*<\/module>/g)].map(m => m[1]);
823
+ for (const mod of moduleNames.slice(0, 30)) {
824
+ const cp = readFileSafe(path.join(ROOT, mod, "pom.xml"));
825
+ if (cp) absorbMavenPom(cp, pom, mod);
826
+ }
544
827
  if (IBATIS_REGEX.test(pomClean)) {
545
828
  stack.orm = "ibatis";
546
829
  stack.detected.push("ibatis");
@@ -552,14 +835,9 @@ async function detectStack(ROOT) {
552
835
  // (primary first-match → stack.database; every match →
553
836
  // stack.databases). Maven original did not push to `detected`
554
837
  // (unlike Gradle), so we preserve that omission here.
555
- const mvnDbRules = [
556
- ["postgresql", "postgresql"],
557
- ["mariadb", "mariadb"],
558
- ["mysql", "mysql"],
559
- ["oracle", "oracle"],
560
- ["mongodb", "mongodb"],
561
- ["sqlite", "sqlite"],
562
- ];
838
+ // "postgres" is excluded (substring of postgresql — Maven coords always
839
+ // spell it out) so the primary-DB race is identical to pre-v2.5.1.
840
+ const mvnDbRules = DB_KEYWORD_RULES.filter(([kw]) => kw !== "postgres");
563
841
  for (const [keyword, value] of mvnDbRules) {
564
842
  if (pomClean.includes(keyword)) {
565
843
  if (!stack.database) stack.database = value;
@@ -628,7 +906,10 @@ async function detectStack(ROOT) {
628
906
  }
629
907
  }
630
908
 
631
- if (!stack.language) stack.language = deps.typescript ? "typescript" : "javascript";
909
+ // Provisional: a root package.json may exist only for frontend tooling
910
+ // (Tailwind/PostCSS) in a Python repo. The Python block below reclaims
911
+ // `language` when a Python framework is detected and no Node backend is.
912
+ if (!stack.language) { stack.language = deps.typescript ? "typescript" : "javascript"; languageFromPackageJson = true; }
632
913
  if (deps.typescript) { stack.detected.push("typescript"); const tv = deps.typescript.match(/(\d+(?:\.\d+)*)/); if (tv) stack.languageVersion = tv[1]; }
633
914
 
634
915
  // Frontend (Angular checked before React — Angular projects may include react in devDependencies)
@@ -666,6 +947,14 @@ async function detectStack(ROOT) {
666
947
  stack.detected.push("vite");
667
948
  stack.frameworkVersion = deps.vite.replace(/[^0-9.]/g, "");
668
949
  }
950
+ // v2.5.0 — Record the frontend bundler independently of `framework`.
951
+ // When a backend framework occupies `stack.framework` (Spring + React/Vite
952
+ // in one repo), `framework === "vite"` can never be true, and
953
+ // selectTemplates() used to fall back to the Next.js template for a
954
+ // Vite SPA. `frontendBundler` carries that signal regardless of backend.
955
+ if (deps.vite && stack.frontend && stack.frontend !== "nextjs") {
956
+ stack.frontendBundler = "vite";
957
+ }
669
958
 
670
959
  // ORM
671
960
  for (const [depKeys, ormName] of NODE_ORM_RULES) {
@@ -698,18 +987,95 @@ async function detectStack(ROOT) {
698
987
  }
699
988
  }
700
989
 
990
+ let subDirSpa = null;
991
+ // ── Frontend in a sub-directory (v2.5.0) ──
992
+ // Spring/Django/... repos commonly keep the SPA in frontend/, client/, web/ or
993
+ // ui/ with its own package.json and no root package.json. Detect it there and
994
+ // record the sub-directory so the frontend scanner can be rooted at it.
995
+ if (!stack.frontend) {
996
+ for (const sub of ["frontend", "client", "web", "ui", "webapp", "front"]) {
997
+ const subDir = path.join(ROOT, sub);
998
+ const pj = path.join(subDir, "package.json");
999
+ const spkg = existsSafe(pj) ? readJsonSafe(pj) : null;
1000
+ const sdeps = spkg ? { ...(spkg.dependencies || {}), ...(spkg.devDependencies || {}) } : null;
1001
+ if (sdeps) {
1002
+ // Same precedence as the root package.json rules (Angular before React —
1003
+ // Angular projects may carry react in devDependencies). `nuxt` maps to
1004
+ // vue so a Nuxt app is not missed when `vue` is only transitive.
1005
+ const rules = [["next", "nextjs", "next.js"], ["@angular/core", "angular", "angular"], ["nuxt", "vue", "nuxt"], ["react", "react", "react"], ["vue", "vue", "vue"]];
1006
+ for (const [dep, name, label] of rules) {
1007
+ if (sdeps[dep]) {
1008
+ stack.frontend = name;
1009
+ // `frontendVersion` is the framework's version (Vue for a Nuxt app),
1010
+ // never the meta-framework's — a Nuxt 3.11 app is not "Vue 3.11".
1011
+ const verDep = dep === "nuxt" ? (sdeps.vue ? "vue" : null) : dep;
1012
+ stack.frontendVersion = verDep ? String(sdeps[verDep]).replace(/[^0-9.]/g, "") : null;
1013
+ stack.frontendRoot = sub;
1014
+ stack.detected.push(`${label} (${sub}/)`);
1015
+ break;
1016
+ }
1017
+ }
1018
+ if (stack.frontend && sdeps.vite && stack.frontend !== "nextjs") stack.frontendBundler = "vite";
1019
+ }
1020
+ // Config-file fallback inside the sub-directory (mirrors the root fallback
1021
+ // below): a package.json without a recognizable framework dep, or none at all.
1022
+ if (!stack.frontend) {
1023
+ const subFallbacks = [
1024
+ [["next.config.js", "next.config.mjs", "next.config.ts"], "nextjs", null, "next.config"],
1025
+ [["vite.config.ts", "vite.config.js"], "react", "vite", "vite.config"],
1026
+ [["nuxt.config.ts", "nuxt.config.js"], "vue", null, "nuxt.config"],
1027
+ [["angular.json", ".angular.json"], "angular", null, "angular.json"],
1028
+ ];
1029
+ for (const [files, frontendName, bundler, label] of subFallbacks) {
1030
+ if (files.some(f => existsSafe(path.join(subDir, f)))) {
1031
+ stack.frontend = frontendName;
1032
+ stack.frontendRoot = sub;
1033
+ if (bundler) stack.frontendBundler = bundler;
1034
+ stack.detected.push(`${label} (${sub}/, fallback)`);
1035
+ break;
1036
+ }
1037
+ }
1038
+ }
1039
+ if (stack.frontend) {
1040
+ // Remember the sub-directory; language / package manager are filled
1041
+ // AFTER every backend block has run (see "SPA-only sub-directory
1042
+ // repo" below). Filling them here would pre-empt the Python block's
1043
+ // `if (!stack.language)` and turn a Django + frontend/ repo into
1044
+ // "typescript", silently skipping the Python scanner.
1045
+ subDirSpa = { subDir, sdeps };
1046
+ break;
1047
+ }
1048
+ }
1049
+ }
1050
+
701
1051
  // ── Python ──
702
1052
  const hasPyproject = existsSafe(path.join(ROOT, "pyproject.toml"));
703
1053
  const hasRequirements = existsSafe(path.join(ROOT, "requirements.txt"));
704
1054
  if (hasPyproject || hasRequirements) {
705
- if (!stack.language) stack.language = "python";
1055
+ // v2.5.0 — a Python manifest at the root beats a `language` that came
1056
+ // from a root package.json with NO Node backend framework: that
1057
+ // package.json exists for Tailwind/PostCSS/ESLint tooling, and the
1058
+ // backend is Python. (A NestJS/Express/Fastify framework keeps Node.)
1059
+ const NODE_BACKENDS = ["nestjs", "express", "fastify"];
1060
+ if (!stack.language) {
1061
+ stack.language = "python";
1062
+ } else if (languageFromPackageJson && !NODE_BACKENDS.includes(stack.framework)) {
1063
+ stack.language = "python";
1064
+ stack.languageVersion = null; // was the TypeScript version; Python's is read below
1065
+ }
706
1066
  stack.detected.push("python");
707
1067
 
708
1068
  const pyFrameworkRules = [["django", "django"], ["fastapi", "fastapi"], ["flask", "flask"]];
709
1069
  const pyOrmRules = [["sqlalchemy", "sqlalchemy"], ["tortoise", "tortoise-orm"]];
710
1070
 
1071
+ // v2.5.0 — keyword matching is case-insensitive. `pip freeze` and PyPI
1072
+ // canonical names are capitalized (`Django==5.0`, `Flask==3.0`,
1073
+ // `SQLAlchemy==2.0`); the previous case-sensitive `includes()` never
1074
+ // recognized Django/Flask from requirements.txt, and a Django project
1075
+ // then aborted `init` with "domain-groups.json has invalid totalGroups: 0".
711
1076
  if (hasPyproject) {
712
- const pp = readFileSafe(path.join(ROOT, "pyproject.toml"));
1077
+ const ppRaw = readFileSafe(path.join(ROOT, "pyproject.toml"));
1078
+ const pp = ppRaw ? ppRaw.toLowerCase() : ppRaw;
713
1079
  if (pp) {
714
1080
  const pv = pp.match(/python\s*=\s*"[><=^~]*(\d+\.\d+)/);
715
1081
  if (pv && !stack.languageVersion) stack.languageVersion = pv[1];
@@ -725,7 +1091,8 @@ async function detectStack(ROOT) {
725
1091
  }
726
1092
 
727
1093
  if (hasRequirements) {
728
- const r = readFileSafe(path.join(ROOT, "requirements.txt"));
1094
+ const rRaw = readFileSafe(path.join(ROOT, "requirements.txt"));
1095
+ const r = rRaw ? rRaw.toLowerCase() : rRaw;
729
1096
  if (r) {
730
1097
  for (const [kw, name] of pyFrameworkRules) {
731
1098
  if (r.includes(kw) && !stack.framework) { stack.framework = name; stack.detected.push(name); break; }
@@ -752,6 +1119,224 @@ async function detectStack(ROOT) {
752
1119
  }
753
1120
  }
754
1121
 
1122
+ // ── Java: legacy evidence (runs AFTER Node/Python) ──
1123
+ // v2.5.1 — Everything below reads evidence weaker than a root build file:
1124
+ // sibling-directory build files, Ant / Eclipse / IntelliJ / NetBeans
1125
+ // metadata, jars on disk, `WEB-INF/web.xml`, Spring XSDs. It therefore runs
1126
+ // last and may only (a) fill a language nobody claimed, or (b) reclaim a
1127
+ // PROVISIONAL Node language — a root package.json that exists for gulp /
1128
+ // jQuery / Tailwind asset tooling, with NO Node framework detected — and
1129
+ // only on STRONG JVM evidence (build.xml, `.project` javanature, a sibling
1130
+ // pom/gradle, a WEB-INF/web.xml, or Spring jars beside *.java sources).
1131
+ // A Next.js / Express / Django project with a stray `.idea/misc.xml` or a
1132
+ // vendored `tools/lib/*.jar` is never flipped to Java.
1133
+ // `src/test/**` is excluded everywhere below: a test-resources `web.xml`
1134
+ // or a test fixture jar is not deployment evidence for the application.
1135
+ const jarIgnore = ["**/node_modules/**", "**/build/**", "**/target/**", "**/.git/**", "**/dist/**", "**/src/test/**"];
1136
+ const referencedJars = [];
1137
+ // Memoized: does the tree hold any *.java source at all? Shared by the
1138
+ // Ant / jar / last-resort decisions below so the walk happens at most once.
1139
+ let anyJavaMemo = null;
1140
+ const anyJavaSources = async () => {
1141
+ if (anyJavaMemo === null) anyJavaMemo = (await glob("**/*.java", { cwd: ROOT, ignore: jarIgnore, nodir: true })).length > 0;
1142
+ return anyJavaMemo;
1143
+ };
1144
+ let jvmMayClaim = !stack.language;
1145
+ // Provisional-language reclaim. The Node language is PARKED, not dropped:
1146
+ // if none of the JVM blocks below actually claims the project (a `build.xml`
1147
+ // that is not Ant — a Phing file or an empty stub — with no *.java anywhere),
1148
+ // the parked values are restored at the end of this section. A gulp-only
1149
+ // site therefore never ends up with `language: null`, and a reclaimed Java
1150
+ // project does not keep `packageManager: "npm"` from the asset tooling.
1151
+ let parked = null;
1152
+ if (!jvmMayClaim && languageFromPackageJson && !stack.framework && !stack.frontend && !stack.buildTool) {
1153
+ const strong =
1154
+ existsSafe(path.join(ROOT, "build.xml")) ||
1155
+ JVM.eclipseHasJavaNature(readFileSafe(path.join(ROOT, ".project"))) ||
1156
+ (await glob("*/{pom.xml,build.gradle,build.gradle.kts}", { cwd: ROOT, ignore: ["node_modules/**"] })).length > 0 ||
1157
+ (await glob("**/WEB-INF/web.xml", { cwd: ROOT, ignore: jarIgnore, nodir: true })).length > 0;
1158
+ if (strong) {
1159
+ jvmMayClaim = true;
1160
+ parked = { language: stack.language, languageVersion: stack.languageVersion, packageManager: stack.packageManager };
1161
+ stack.language = null; stack.languageVersion = null; stack.packageManager = null;
1162
+ }
1163
+ }
1164
+ // ── Java: build files one directory down (no root build file) ──
1165
+ // v2.5.1 — SI repositories often hold sibling projects (`erp-web/pom.xml`,
1166
+ // `erp-batch/pom.xml`) with no aggregator at the root. Depth-1 poms are
1167
+ // absorbed with the same rules as Maven <modules>. Depth-1 Gradle files
1168
+ // without a root `settings.gradle` / `build.gradle` are NOT swept — they
1169
+ // count as strong evidence for the reclaim above and the `*.java` last
1170
+ // resort then sets the language, but framework/version stay null (a root
1171
+ // `settings.gradle` is what makes the Gradle sub-module sweep run).
1172
+ if (!stack.buildTool && jvmMayClaim) {
1173
+ const siblingPoms = (await glob("*/pom.xml", { cwd: ROOT, ignore: ["node_modules/**"] })).map(p => p.replace(/\\/g, "/")).sort();
1174
+ if (siblingPoms.length) {
1175
+ stack.buildTool = "maven"; stack.language = "java";
1176
+ stack.detected.push(`pom.xml (${siblingPoms.length} sibling project${siblingPoms.length > 1 ? "s" : ""})`);
1177
+ if (!stack.packageManager) stack.packageManager = "maven";
1178
+ for (const sp of siblingPoms.slice(0, 30)) {
1179
+ const cp = readFileSafe(path.join(ROOT, sp));
1180
+ if (cp) absorbMavenPom(cp, "", path.dirname(sp));
1181
+ }
1182
+ }
1183
+ }
1184
+
1185
+ // ── Java: Ant / Eclipse WTP / no build tool (legacy) ──
1186
+ // v2.5.x — The shape of most pre-Maven enterprise code: a `build.xml`,
1187
+ // an Eclipse `.classpath`/`.project`, `WebContent/WEB-INF/lib/*.jar`, and
1188
+ // sources under `src/` (no `src/main/java`). Evidence, in order of
1189
+ // strength:
1190
+ // 1. build.xml → buildTool "ant", Java level from <javac source="">
1191
+ // 2. .classpath/.project → Java level from the JRE container, javanature
1192
+ // 3. **/WEB-INF/lib/*.jar → packaging "war"; Spring jars → framework +
1193
+ // version parsed from the jar NAME
1194
+ // (spring-webmvc-3.0.5.RELEASE.jar). An
1195
+ // unversioned `spring.jar` (2.0 era) reports the
1196
+ // framework with version null — never a guess.
1197
+ // 4. **/*.java → language "java" as a last resort
1198
+ // Only runs when no Gradle/Maven build file claimed the project.
1199
+ if (!stack.buildTool && jvmMayClaim) {
1200
+ const buildXml = path.join(ROOT, "build.xml");
1201
+ if (existsSafe(buildXml)) {
1202
+ const bx = readFileSafe(buildXml);
1203
+ // `<project>` alone is not Ant — Phing (PHP) and other XML build tools
1204
+ // use the same root element. Require a `<javac>` task or *.java sources.
1205
+ if (bx && /<project\b/.test(bx) && (/<javac\b/.test(bx) || await anyJavaSources())) {
1206
+ stack.buildTool = "ant"; stack.language = "java"; stack.detected.push("build.xml");
1207
+ if (!stack.packageManager) stack.packageManager = "ant";
1208
+ const src = JVM.antJavacSource(bx);
1209
+ if (src && !stack.languageVersion) stack.languageVersion = normalizeJavaVersion(src);
1210
+ // v2.5.1 — Ant + Ivy: ivy.xml is the dependency manifest.
1211
+ const ivy = readFileSafe(path.join(ROOT, "ivy.xml"));
1212
+ if (ivy) {
1213
+ stack.detected.push("ivy.xml");
1214
+ if (!stack.framework && JVM.ivyHasSpringFramework(ivy)) { stack.framework = "spring-framework"; stack.detected.push("spring-framework (ivy)"); }
1215
+ const iv = JVM.ivySpringFrameworkVersion(ivy);
1216
+ if (iv) { stack.springFrameworkVersion = iv; if (stack.framework === "spring-framework" && !stack.frameworkVersion) stack.frameworkVersion = iv; }
1217
+ if (!stack.orm) { if (IBATIS_REGEX.test(ivy)) { stack.orm = "ibatis"; stack.detected.push("ibatis (ivy)"); } else detectFirst(stack, "orm", ivy, GRADLE_ORM_RULES); }
1218
+ detectDb(stack, ivy, DB_KEYWORD_RULES.filter(([kw]) => !["postgres", "sqlite"].includes(kw)));
1219
+ }
1220
+ }
1221
+ }
1222
+ const classpathXml = readFileSafe(path.join(ROOT, ".classpath"));
1223
+ const projectXml = readFileSafe(path.join(ROOT, ".project"));
1224
+ const jdtPrefs = readFileSafe(path.join(ROOT, ".settings/org.eclipse.jdt.core.prefs"));
1225
+ if (classpathXml || projectXml || jdtPrefs) {
1226
+ if (JVM.eclipseHasJavaNature(projectXml) || (classpathXml && /JRE_CONTAINER|kind="src"/.test(classpathXml)) || jdtPrefs) {
1227
+ if (!stack.language) { stack.language = "java"; stack.detected.push(".classpath/.project"); }
1228
+ // `.settings/org.eclipse.jdt.core.prefs` compliance level is what the
1229
+ // compiler actually used — it outranks the JRE container name.
1230
+ const lvl = JVM.eclipseJdtPrefsLevel(jdtPrefs) || (classpathXml ? JVM.eclipseJreLevel(classpathXml) : null);
1231
+ if (lvl && !stack.languageVersion) stack.languageVersion = normalizeJavaVersion(lvl);
1232
+ }
1233
+ }
1234
+ // IntelliJ / NetBeans project metadata.
1235
+ const ideaMisc = readFileSafe(path.join(ROOT, ".idea/misc.xml"));
1236
+ if (ideaMisc) {
1237
+ const lvl = JVM.intellijLanguageLevel(ideaMisc);
1238
+ if (lvl) { if (!stack.language) { stack.language = "java"; stack.detected.push(".idea/misc.xml"); } if (!stack.languageVersion) stack.languageVersion = normalizeJavaVersion(lvl); }
1239
+ }
1240
+ const nbProps = readFileSafe(path.join(ROOT, "nbproject/project.properties"));
1241
+ const nb = JVM.netbeansProject(nbProps);
1242
+ if (nbProps && (nb.level || nb.jars.length)) {
1243
+ if (!stack.language) { stack.language = "java"; stack.detected.push("nbproject"); }
1244
+ if (nb.level && !stack.languageVersion) stack.languageVersion = normalizeJavaVersion(nb.level);
1245
+ }
1246
+ // Jar names referenced by IDE metadata even when the jars themselves are
1247
+ // not committed (`.classpath kind="lib"/"var"`, NetBeans file.reference).
1248
+ // Fed into the same classifier as jars on disk, below.
1249
+ referencedJars.push(...JVM.eclipseClasspathJars(classpathXml), ...nb.jars);
1250
+ if (referencedJars.some(j => /WEB-INF\/lib\//.test(j)) && !stack.packaging) stack.packaging = "war";
1251
+ if (!stack.language && await anyJavaSources()) { stack.language = "java"; stack.detected.push("java sources"); }
1252
+ }
1253
+
1254
+ // ── Java: jars on disk (any build tool, or none) ──
1255
+ // v2.5.1 — Also runs for Gradle/Maven projects that still resolve from
1256
+ // `fileTree(dir: 'WEB-INF/lib')` instead of coordinates — common in SI
1257
+ // codebases that adopted a build tool without migrating the jars. Fills
1258
+ // nulls only; coordinate-based answers above always win. Capped so a
1259
+ // vendored `lib/` with thousands of jars cannot stall detection.
1260
+ if (!stack.framework && (stack.language === "java" || (jvmMayClaim && !stack.buildTool))) {
1261
+ // Recursive under the lib roots: `lib/spring/*.jar`, `lib/db/*.jar` are
1262
+ // common hand-sorted layouts.
1263
+ const onDisk = await glob("**/{WEB-INF/lib,lib,libs}/**/*.jar", { cwd: ROOT, ignore: jarIgnore, nodir: true });
1264
+ const jars = [...onDisk.map(j => j.replace(/\\/g, "/")), ...referencedJars];
1265
+ if (jars.length) {
1266
+ if (jars.some(j => /WEB-INF\/lib\//.test(j)) && !stack.packaging) stack.packaging = "war";
1267
+ const cls = JVM.classifyJars(jars.slice(0, 500).map(j => path.basename(j)));
1268
+ // Jars alone are weak evidence — require *.java sources beside them.
1269
+ if (!stack.language && await anyJavaSources()) { stack.language = "java"; stack.detected.push("jars"); }
1270
+ // Everything below is dependency evidence for a JAVA project. A jar
1271
+ // directory with no sources is not a project and gets no DB / ORM /
1272
+ // framework either.
1273
+ if (stack.language === "java") {
1274
+ // JDBC drivers and ORM jars on disk are the only dependency evidence a
1275
+ // no-build-tool project has. Same dual output as every other DB source.
1276
+ for (const db of cls.databases) {
1277
+ if (!stack.database) stack.database = db;
1278
+ if (!stack.databases.includes(db)) stack.databases.push(db);
1279
+ }
1280
+ if (cls.orm && !stack.orm) { stack.orm = cls.orm; stack.detected.push(`${cls.orm} (jar)`); }
1281
+ if (cls.springBoot || cls.springFramework) {
1282
+ if (!stack.framework) {
1283
+ stack.framework = cls.springBoot ? "spring-boot" : "spring-framework";
1284
+ stack.detected.push(`${stack.framework} (jar)`);
1285
+ }
1286
+ if (cls.springFrameworkVersion && !stack.springFrameworkVersion) stack.springFrameworkVersion = cls.springFrameworkVersion;
1287
+ if (!stack.frameworkVersion) {
1288
+ if (stack.framework === "spring-boot" && cls.springBootVersion) stack.frameworkVersion = cls.springBootVersion;
1289
+ if (stack.framework === "spring-framework" && cls.springFrameworkVersion) stack.frameworkVersion = cls.springFrameworkVersion;
1290
+ }
1291
+ }
1292
+ } // language === "java"
1293
+ }
1294
+ }
1295
+
1296
+ // ── Java: deployment descriptor + Spring XML schema evidence ──
1297
+ // v2.5.1 — For trees with no coordinates and no jars (jars gitignored,
1298
+ // IDE metadata absent): `WEB-INF/web.xml` naming DispatcherServlet /
1299
+ // ContextLoaderListener is Spring MVC evidence and a war signal; Spring XML
1300
+ // configs carry `spring-beans-3.0.xsd` — major.minor only, the
1301
+ // lowest-fidelity version source, consulted last and never overriding a
1302
+ // pinned version. Struts descriptors add a tag without setting a framework.
1303
+ // Spring Boot projects are skipped: Boot owns the servlet container, its
1304
+ // WAR packaging is declared in the build file, and the `detected` array of
1305
+ // a Boot project must stay byte-identical to v2.5.0 output.
1306
+ if (stack.framework !== "spring-boot" && (stack.language === "java" || (jvmMayClaim && !stack.buildTool))) {
1307
+ const webXmls = await glob("**/WEB-INF/web.xml", { cwd: ROOT, ignore: jarIgnore, nodir: true });
1308
+ for (const wx of webXmls.slice(0, 5)) {
1309
+ const facts = JVM.webXmlFacts(readFileSafe(path.join(ROOT, wx)));
1310
+ if (!stack.packaging) stack.packaging = "war";
1311
+ if (facts.spring) {
1312
+ if (!stack.language) stack.language = "java";
1313
+ if (!stack.framework) { stack.framework = "spring-framework"; stack.detected.push("spring-framework (web.xml)"); }
1314
+ }
1315
+ if (facts.struts && !stack.detected.some(d => d.startsWith(facts.struts))) stack.detected.push(`${facts.struts} (web.xml)`);
1316
+ if (facts.servletVersion && !stack.detected.some(d => d.startsWith("servlet "))) stack.detected.push(`servlet ${facts.servletVersion}`);
1317
+ if (!stack.language) { stack.language = "java"; stack.detected.push("web.xml"); }
1318
+ }
1319
+ if (stack.framework === "spring-framework" && !stack.frameworkVersion) {
1320
+ const xmls = await glob("**/{WEB-INF,resources,config,conf,spring}/**/*.xml", { cwd: ROOT, ignore: jarIgnore, nodir: true });
1321
+ let best = null;
1322
+ for (const x of xmls.slice(0, 50)) {
1323
+ const v = JVM.springXsdVersion(readFileSafe(path.join(ROOT, x)));
1324
+ if (v && (!best || parseFloat(v) > parseFloat(best))) best = v;
1325
+ }
1326
+ if (best) { stack.detected.push(`spring-xsd ${best}`); stack.frameworkVersion = best; if (!stack.springFrameworkVersion) stack.springFrameworkVersion = best; }
1327
+ }
1328
+ }
1329
+
1330
+ // Settle the provisional-language reclaim (see `parked` above).
1331
+ if (parked) {
1332
+ if (stack.language === "java") {
1333
+ stack.detected.push("java (reclaimed from provisional package.json language)");
1334
+ } else {
1335
+ stack.language = parked.language; stack.languageVersion = parked.languageVersion;
1336
+ if (!stack.packageManager) stack.packageManager = parked.packageManager;
1337
+ }
1338
+ }
1339
+
755
1340
  // ── DB from config files ──
756
1341
  //
757
1342
  // Glob covers Spring Boot's full configuration-file naming space:
@@ -868,29 +1453,41 @@ async function detectStack(ROOT) {
868
1453
  }
869
1454
 
870
1455
  // .env
871
- for (const ef of [".env", ".env.local", ".env.development"]) {
872
- const ep = path.join(ROOT, ef);
873
- if (existsSafe(ep)) {
1456
+ // .env: original checks postgres (not postgresql), no oracle/h2.
1457
+ // Preserve that semantics, but update both primary and array
1458
+ // outputs together.
1459
+ //
1460
+ // Returns true when a file in `files` actually DECLARED a DATABASE_URL —
1461
+ // not merely when a keyword matched. A runtime `.env` carrying a dialect
1462
+ // this keyword list does not cover (`jdbc:oracle:thin:@…`, `jdbc:sqlserver://…`)
1463
+ // still means the project answered the question, and a template must not
1464
+ // answer it differently.
1465
+ const detectDbFromEnvFiles = (files) => {
1466
+ let declared = false;
1467
+ for (const ef of files) {
1468
+ const ep = path.join(ROOT, ef);
1469
+ if (!existsSafe(ep)) continue;
874
1470
  const ec = readFileSafe(ep);
875
- if (ec && ec.includes("DATABASE_URL")) {
876
- // .env: original checks postgres (not postgresql), no oracle/h2.
877
- // Preserve that semantics, but update both primary and array
878
- // outputs together.
879
- const envDbs = [
880
- ["postgres", "postgresql"],
881
- ["mysql", "mysql"],
882
- ["mongodb", "mongodb"],
883
- ["sqlite", "sqlite"],
884
- ];
885
- for (const [keyword, value] of envDbs) {
886
- if (ec.includes(keyword)) {
887
- if (!stack.database) stack.database = value;
888
- if (!stack.databases.includes(value)) stack.databases.push(value);
889
- }
1471
+ if (!ec || !ec.includes("DATABASE_URL")) continue;
1472
+ declared = true;
1473
+ const envDbs = [
1474
+ ["postgres", "postgresql"],
1475
+ ["mysql", "mysql"],
1476
+ ["mongodb", "mongodb"],
1477
+ ["sqlite", "sqlite"],
1478
+ ];
1479
+ for (const [keyword, value] of envDbs) {
1480
+ if (ec.includes(keyword)) {
1481
+ if (!stack.database) stack.database = value;
1482
+ if (!stack.databases.includes(value)) stack.databases.push(value);
890
1483
  }
891
1484
  }
892
1485
  }
893
- }
1486
+ return declared;
1487
+ };
1488
+
1489
+ // Runtime env files first — they hold the real values. Unchanged behaviour.
1490
+ const runtimeEnvDeclared = detectDbFromEnvFiles([".env", ".env.local", ".env.development"]);
894
1491
 
895
1492
  // Prisma schema
896
1493
  const prismaSchema = path.join(ROOT, "prisma/schema.prisma");
@@ -905,6 +1502,30 @@ async function detectStack(ROOT) {
905
1502
  }
906
1503
  }
907
1504
 
1505
+ // v2.5.x — `.env` is gitignored in most repos, so on a fresh clone the
1506
+ // runtime files are absent and the DB type went undetected even though the
1507
+ // project committed a perfectly good `.env.example`. lib/env-parser.js has
1508
+ // always treated `.env.example` as the canonical "shape of truth" (it heads
1509
+ // ENV_FILE_ORDER); this aligns DB detection with that.
1510
+ //
1511
+ // POSITION MATTERS: this runs LAST, after every other DB source including
1512
+ // the Prisma block above. An `.env.example` value is a placeholder by
1513
+ // definition; `schema.prisma`, build files and `application.yml` are
1514
+ // declarative statements of intent. Hoisting this above them would let a
1515
+ // stale placeholder win the `if (!stack.database)` race against a real
1516
+ // declaration — worse than the null it replaces.
1517
+ //
1518
+ // Two guards keep it strictly additive — a template is consulted ONLY to
1519
+ // fill a total blank, never to contradict or pad an existing answer:
1520
+ // 1. no runtime env file declared a DATABASE_URL, and
1521
+ // 2. no other source (build.gradle / pom.xml / requirements.txt /
1522
+ // application.yml / schema.prisma) has identified a database.
1523
+ // So a placeholder `mysql://` in `.env.example` can never append a phantom
1524
+ // dialect to a project whose build file already said `oracle`.
1525
+ if (!runtimeEnvDeclared && stack.databases.length === 0 && !stack.database) {
1526
+ detectDbFromEnvFiles([".env.example", ".env.sample", ".env.template"]);
1527
+ }
1528
+
908
1529
  // ── Config file fallback (monorepo) ──
909
1530
  // [configFiles, frontendName, frameworkName (optional), label]
910
1531
  const frontendFallbacks = [
@@ -922,6 +1543,8 @@ async function detectStack(ROOT) {
922
1543
  stack.framework = frameworkName;
923
1544
  stack.detected.push(frameworkName + " (fallback)");
924
1545
  }
1546
+ // v2.5.0 — keep the bundler signal even when a backend owns `framework`.
1547
+ if (frameworkName === "vite") stack.frontendBundler = "vite";
925
1548
  break;
926
1549
  }
927
1550
  }
@@ -932,13 +1555,65 @@ async function detectStack(ROOT) {
932
1555
  // the project actually declares. This overrides framework-default guesses
933
1556
  // in downstream code (plan-installer/index.js defaultPort) and exposes the
934
1557
  // full variable map to Pass 3 prompts via project-analysis.json.
1558
+ // v2.5.0 — SPA-only sub-directory repo. Runs after EVERY backend block
1559
+ // (Gradle / Maven / Node / Python): a repo whose only application is the
1560
+ // SPA in `frontend/` must not be reported as "no language detected", but a
1561
+ // backend's language / package manager always takes precedence.
1562
+ if (subDirSpa && !stack.language) {
1563
+ const { subDir, sdeps } = subDirSpa;
1564
+ const ts = (sdeps && sdeps.typescript) || existsSafe(path.join(subDir, "tsconfig.json"));
1565
+ stack.language = ts ? "typescript" : "javascript";
1566
+ if (sdeps && sdeps.typescript && !stack.languageVersion) {
1567
+ const tv = String(sdeps.typescript).match(/(\d+(?:\.\d+)*)/);
1568
+ if (tv) stack.languageVersion = tv[1];
1569
+ }
1570
+ }
1571
+ if (subDirSpa && !stack.packageManager) {
1572
+ const { subDir } = subDirSpa;
1573
+ stack.packageManager = existsSafe(path.join(subDir, "pnpm-lock.yaml")) ? "pnpm"
1574
+ : existsSafe(path.join(subDir, "yarn.lock")) ? "yarn"
1575
+ : existsSafe(path.join(subDir, "bun.lockb")) || existsSafe(path.join(subDir, "bun.lock")) ? "bun" : "npm";
1576
+ }
1577
+
935
1578
  const envInfo = readStackEnvInfo(ROOT);
936
1579
  if (envInfo) {
937
1580
  stack.envInfo = envInfo;
938
1581
  // Promote .env-declared port to stack.port if no earlier detection won
939
- // (e.g., Spring application.yml parsing at line 407).
940
- if (!stack.port && envInfo.port) {
941
- stack.port = envInfo.port;
1582
+ // (e.g., Spring application.yml parsing).
1583
+ //
1584
+ // v2.5.0 — a root `.env*` may carry BOTH a backend port and a frontend
1585
+ // dev-server port (`VITE_PORT` / `NEXT_PUBLIC_PORT` / `NUXT_PORT` /
1586
+ // `NG_PORT`). `extractPort()` prefers the frontend keys, so when a
1587
+ // backend exists the frontend key must go to `frontendPort`, never to the
1588
+ // backend's `stack.port`.
1589
+ const vars = envInfo.vars || {};
1590
+ const feKeys = Object.keys(vars).filter(k => /^(VITE_|NEXT_|NUXT_|NG_)\w*PORT$/.test(k));
1591
+ const backendOnlyVars = Object.fromEntries(Object.entries(vars).filter(([k]) => !feKeys.includes(k)));
1592
+ const backendPort = extractPort(backendOnlyVars);
1593
+ const frontendPort = feKeys.length ? extractPort(Object.fromEntries(feKeys.map(k => [k, vars[k]]))) : null;
1594
+ const hasBackend = (!!stack.framework && stack.framework !== "vite") || ["java", "kotlin", "python"].includes(stack.language);
1595
+ if (!stack.port) {
1596
+ const p = hasBackend ? backendPort : envInfo.port;
1597
+ if (p) stack.port = p;
1598
+ }
1599
+ if (frontendPort && stack.frontend && !stack.frontendRoot && !stack.frontendPort) stack.frontendPort = frontendPort;
1600
+ // Keep `stack.envInfo.port` consistent with the split: Pass 3 prompts read
1601
+ // `stack.envInfo.port` for the backend Server Port row, so it must never
1602
+ // carry the frontend dev-server value when a backend exists.
1603
+ envInfo.port = hasBackend ? (backendPort || null) : envInfo.port;
1604
+ if (frontendPort) envInfo.frontendPort = frontendPort;
1605
+ }
1606
+ // v2.5.0 — Sub-directory SPA: its own `.env*` (VITE_PORT, VITE_API_URL,
1607
+ // NEXT_PUBLIC_*) lives under `frontend/`, not at the project root. Read it
1608
+ // separately (same redaction/masking) so the dev-server port and API target
1609
+ // come from the project instead of framework-default guesses. Kept apart
1610
+ // from `stack.envInfo` / `stack.port` so a frontend `PORT=3000` never
1611
+ // overrides the backend's port.
1612
+ if (stack.frontendRoot) {
1613
+ const feEnv = readStackEnvInfo(path.join(ROOT, stack.frontendRoot));
1614
+ if (feEnv) {
1615
+ stack.frontendEnvInfo = { ...feEnv, source: `${stack.frontendRoot}/${feEnv.source}` };
1616
+ if (feEnv.port) stack.frontendPort = feEnv.port;
942
1617
  }
943
1618
  }
944
1619