claudeos-core 2.4.4 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/README.de.md +9 -9
  3. package/README.es.md +9 -9
  4. package/README.fr.md +9 -9
  5. package/README.hi.md +9 -9
  6. package/README.ja.md +9 -9
  7. package/README.ko.md +9 -9
  8. package/README.md +9 -9
  9. package/README.ru.md +9 -9
  10. package/README.vi.md +9 -9
  11. package/README.zh-CN.md +9 -9
  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 +50 -12
  17. package/lib/memory-scaffold.js +35 -16
  18. package/manifest-generator/index.js +15 -4
  19. package/package.json +1 -1
  20. package/pass-prompts/templates/angular/pass3.md +2 -1
  21. package/pass-prompts/templates/common/claude-md-scaffold.md +1 -1
  22. package/pass-prompts/templates/common/pass3a-facts.md +11 -9
  23. package/pass-prompts/templates/common/pass4.md +3 -3
  24. package/pass-prompts/templates/java-spring/pass3.md +3 -3
  25. package/pass-prompts/templates/kotlin-spring/pass3.md +2 -2
  26. package/pass-prompts/templates/node-express/pass3.md +1 -1
  27. package/pass-prompts/templates/node-fastify/pass3.md +1 -0
  28. package/pass-prompts/templates/node-nestjs/pass3.md +1 -0
  29. package/pass-prompts/templates/node-nextjs/pass3.md +1 -1
  30. package/pass-prompts/templates/node-vite/pass3.md +1 -0
  31. package/pass-prompts/templates/python-django/pass3.md +1 -1
  32. package/pass-prompts/templates/python-fastapi/pass3.md +1 -1
  33. package/pass-prompts/templates/python-flask/pass3.md +1 -0
  34. package/pass-prompts/templates/vue-nuxt/pass3.md +1 -0
  35. package/plan-installer/domain-grouper.js +4 -1
  36. package/plan-installer/index.js +26 -7
  37. package/plan-installer/pass3-context-builder.js +10 -0
  38. package/plan-installer/prompt-generator.js +18 -2
  39. package/plan-installer/scanners/scan-frontend.js +67 -6
  40. package/plan-installer/scanners/scan-java.js +145 -14
  41. package/plan-installer/scanners/scan-kotlin.js +68 -3
  42. package/plan-installer/scanners/scan-node.js +115 -0
  43. package/plan-installer/scanners/scan-python.js +56 -0
  44. package/plan-installer/source-paths.js +61 -0
  45. package/plan-installer/stack-detector.js +262 -24
  46. package/plan-installer/structure-scanner.js +15 -4
@@ -234,9 +234,70 @@ function renderAllowedPathsSection(collected) {
234
234
  return lines.join("\n");
235
235
  }
236
236
 
237
+ /**
238
+ * Deterministically insert (or replace) the `## Allowed Source Paths` section
239
+ * in a pass3a-facts.md body. Called by the orchestrator right after Pass 3a
240
+ * finishes, so the allowlist reaches Pass 3b/3c/3d verbatim — without asking
241
+ * the LLM to hand-copy up to 500 paths out of pass3-context.json.
242
+ *
243
+ * - If a `## Allowed Source Paths` section already exists (LLM wrote one),
244
+ * it is replaced wholesale up to the next `## ` heading / EOF.
245
+ * - Otherwise the section is appended.
246
+ * - `collected` is the `allowedSourcePaths` object from project-analysis.json.
247
+ * A missing/empty allowlist yields the documented fallback line so
248
+ * downstream prompts see an explicit "unavailable" marker, not silence.
249
+ *
250
+ * Returns the new markdown string.
251
+ */
252
+ function injectAllowedPathsSection(factsMd, collected) {
253
+ const heading = "## Allowed Source Paths";
254
+ const hasPaths = collected && Array.isArray(collected.paths) && collected.paths.length > 0;
255
+ const body = hasPaths
256
+ ? renderAllowedPathsSection(collected)
257
+ : "(allowlist unavailable — fall back to pass2-merged.json verification per file)";
258
+ const section = `${heading}\n\n${body}\n`;
259
+
260
+ const src = typeof factsMd === "string" ? factsMd : "";
261
+ const lines = src.split(/\r?\n/);
262
+ // Locate EVERY existing section heading outside fenced blocks. A model on a
263
+ // retry/resume may have written the section twice; all copies are removed
264
+ // and exactly one is put back at the position of the first.
265
+ const ranges = []; // [start, end) line indices
266
+ let inFence = false, cur = -1;
267
+ for (let i = 0; i < lines.length; i++) {
268
+ if (/^(```|~~~)/.test(lines[i].trimStart())) inFence = !inFence;
269
+ if (inFence) continue;
270
+ const isTarget = /^##\s+Allowed Source Paths\b/i.test(lines[i]);
271
+ const isHeading = /^##\s+/.test(lines[i]);
272
+ if (cur >= 0 && isHeading) { ranges.push([cur, i]); cur = -1; }
273
+ if (cur < 0 && isTarget) cur = i;
274
+ }
275
+ if (cur >= 0) ranges.push([cur, lines.length]);
276
+ if (ranges.length > 0) {
277
+ const out = [];
278
+ let pos = 0;
279
+ ranges.forEach(([a, b], idx) => {
280
+ out.push(...lines.slice(pos, a));
281
+ if (idx === 0) out.push(...section.split("\n"));
282
+ pos = b;
283
+ });
284
+ out.push(...lines.slice(pos));
285
+ return out.join("\n");
286
+ }
287
+ // Rebuild from `lines` (not `src`) so a CRLF input comes out as consistent LF
288
+ // instead of a mixed-ending file (CRLF body + LF appended section).
289
+ let trimmed = lines.join("\n").replace(/\s+$/, "");
290
+ // If the file ends inside an unclosed fence (LLM forgot the closing ```),
291
+ // close it first — otherwise the appended heading would sit inside a code
292
+ // block and be invisible to fence-aware readers of this file.
293
+ if (inFence) trimmed += "\n```";
294
+ return (trimmed ? trimmed + "\n\n" : "") + section;
295
+ }
296
+
237
297
  module.exports = {
238
298
  collectSourcePaths,
239
299
  renderAllowedPathsSection,
300
+ injectAllowedPathsSection,
240
301
  // Exported for test visibility.
241
302
  _constants: { SOURCE_EXTENSIONS, EXCLUDED_DIRS, MAX_PATHS, MIN_FILES_PER_DIR, MAX_DIRS },
242
303
  };
@@ -9,7 +9,7 @@
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
13
 
14
14
  // ─── Lookup tables ──────────────────────────────────────────────
15
15
 
@@ -65,6 +65,33 @@ const DB_KEYWORD_RULES = [
65
65
  // h2 needs word-boundary check (avoid oauth2, cache2k false positives)
66
66
  const H2_REGEX = /\bh2\b/;
67
67
 
68
+ // Java version literals come in two spellings: modern `17` / `21` and the
69
+ // legacy dotted form `1.8` (Java 8 — still the norm in enterprise SI
70
+ // codebases). Normalize the legacy form so `1.8` → `8`; pass everything
71
+ // else through unchanged.
72
+ function normalizeJavaVersion(v) {
73
+ if (v == null) return v;
74
+ const m = String(v).match(/^1\.(\d+)$/);
75
+ return m ? m[1] : String(v);
76
+ }
77
+
78
+ // v2.5.0 — Source-language evidence. Build-file keywords alone are ambiguous:
79
+ // `buildSrc/build.gradle.kts` carries `kotlin-dsl` in pure-Java repos, and a
80
+ // Java catalog may pin `kotlin = "1.9.22"` only to settle kotlin-stdlib
81
+ // conflicts. When `.java` sources exist and no `.kt` sources do, a "kotlin"
82
+ // keyword must NOT flip the language — the Kotlin scanner would then find
83
+ // zero domains and `init` would abort. Memoized per detectStack() call.
84
+ async function hasJavaOnlySources(ROOT) {
85
+ // `buildSrc/` and `build-logic/` (Gradle's documented buildSrc replacement)
86
+ // hold convention plugins written in Kotlin DSL — build tooling, not
87
+ // application code.
88
+ const ignore = ["**/node_modules/**", "**/build/**", "**/target/**", "**/buildSrc/**", "**/build-logic/**", "**/gradle/plugins/**", "**/src/test/**", "**/.git/**"];
89
+ const kt = await glob("**/src/main/{java,kotlin}/**/*.kt", { cwd: ROOT, ignore });
90
+ if (kt.length > 0) return false;
91
+ const java = await glob("**/src/main/java/**/*.java", { cwd: ROOT, ignore });
92
+ return java.length > 0;
93
+ }
94
+
68
95
  // ─── Helpers ────────────────────────────────────────────────────
69
96
 
70
97
  function detectFirst(stack, field, content, rules) {
@@ -233,6 +260,16 @@ function detectDb(stack, content, rules) {
233
260
  * @returns {Promise<object>} stack info
234
261
  */
235
262
  async function detectStack(ROOT) {
263
+ // Lazily evaluated once per call; only consulted when a "kotlin" keyword
264
+ // would otherwise flip the language (see hasJavaOnlySources).
265
+ let javaOnlyMemo = null;
266
+ const isJavaOnly = async () => {
267
+ if (javaOnlyMemo === null) javaOnlyMemo = await hasJavaOnlySources(ROOT);
268
+ return javaOnlyMemo;
269
+ };
270
+ // Set when `language` was taken from a root package.json (see Node block);
271
+ // the Python block reclaims it when the backend turns out to be Python.
272
+ let languageFromPackageJson = false;
236
273
  const stack = {
237
274
  language: null, languageVersion: null,
238
275
  framework: null, frameworkVersion: null,
@@ -274,7 +311,12 @@ async function detectStack(ROOT) {
274
311
  // and downstream tooling don't show "PackageMgr: none" for a build tool
275
312
  // that IS the package manager. Only set if not already detected.
276
313
  if (!stack.packageManager) stack.packageManager = "gradle";
277
- if (g.includes("spring-boot")) { stack.language = "java"; stack.framework = "spring-boot"; stack.detected.push("spring-boot"); }
314
+ // `spring-boot` (starter coords) OR `org.springframework.boot` (plugin id —
315
+ // the only spelling present in a multi-module root that declares
316
+ // `id 'org.springframework.boot' version 'x' apply false`).
317
+ if (g.includes("spring-boot") || g.includes("org.springframework.boot")) {
318
+ stack.language = "java"; stack.framework = "spring-boot"; stack.detected.push("spring-boot");
319
+ }
278
320
  const svPatterns = [
279
321
  /org\.springframework\.boot.*version\s*['"]([^'"]+)['"]/,
280
322
  /id\s*\(\s*["']org\.springframework\.boot["']\s*\)\s*version\s*["']([^"']+)["']/,
@@ -332,8 +374,9 @@ async function detectStack(ROOT) {
332
374
  // Java 21).
333
375
  const javaVersionPatterns = [
334
376
  // (1) numeric literal on sourceCompatibility or targetCompatibility
335
- /sourceCompatibility\s*=\s*['"]?(\d+)['"]?/,
336
- /targetCompatibility\s*=\s*['"]?(\d+)['"]?/,
377
+ // `(\d+(?:\.\d+)?)` — captures `1.8` whole instead of stopping at `1`
378
+ /sourceCompatibility\s*=\s*['"]?(\d+(?:\.\d+)?)['"]?/,
379
+ /targetCompatibility\s*=\s*['"]?(\d+(?:\.\d+)?)['"]?/,
337
380
  // (2) JavaVersion enum — supports both VERSION_21 and VERSION_1_8
338
381
  /JavaVersion\.VERSION_(?:1_)?(\d+)/,
339
382
  // (3) toolchain block
@@ -341,7 +384,7 @@ async function detectStack(ROOT) {
341
384
  ];
342
385
  for (const pattern of javaVersionPatterns) {
343
386
  const m = g.match(pattern);
344
- if (m) { stack.languageVersion = m[1]; break; }
387
+ if (m) { stack.languageVersion = normalizeJavaVersion(m[1]); break; }
345
388
  }
346
389
  // (4) ext variable reference fallback — if the Compatibility
347
390
  // assignment used "${varName}" we now resolve varName inside the
@@ -356,9 +399,9 @@ async function detectStack(ROOT) {
356
399
  // defensive guard against unexpected characters, not a
357
400
  // practical necessity for today's inputs.
358
401
  const escapedVarName = varName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
359
- const extAssign = new RegExp(`${escapedVarName}\\s*=\\s*['"]?(\\d+)['"]?`);
402
+ const extAssign = new RegExp(`${escapedVarName}\\s*=\\s*['"]?(\\d+(?:\\.\\d+)?)['"]?`);
360
403
  const extVal = g.match(extAssign);
361
- if (extVal) stack.languageVersion = extVal[1];
404
+ if (extVal) stack.languageVersion = normalizeJavaVersion(extVal[1]);
362
405
  }
363
406
  }
364
407
 
@@ -382,7 +425,8 @@ async function detectStack(ROOT) {
382
425
  detectLogging(stack, g);
383
426
 
384
427
  // Kotlin detection: override language if Kotlin plugin found
385
- if (g.includes("kotlin") || g.includes("org.jetbrains.kotlin")) {
428
+ // (unless the source tree is Java-only — see hasJavaOnlySources).
429
+ if ((g.includes("kotlin") || g.includes("org.jetbrains.kotlin")) && !(await isJavaOnly())) {
386
430
  stack.language = "kotlin"; stack.detected.push("kotlin");
387
431
  const kvPatterns = [
388
432
  /kotlin\S*\s*version\s*['"]([^'"]+)['"]/,
@@ -427,15 +471,66 @@ async function detectStack(ROOT) {
427
471
  if (!stack.databases.includes(value)) stack.databases.push(value);
428
472
  }
429
473
  }
430
- if (!stack.language && vc.includes("kotlin")) {
474
+ // A `kotlin = "x.y.z"` version entry or a Kotlin *plugin* coordinate in
475
+ // the catalog is decisive — it overrides the `java` default that the
476
+ // root build file's `org.springframework.boot` plugin id sets.
477
+ // Library coordinates (`org.jetbrains.kotlin:kotlin-stdlib`) are NOT a
478
+ // signal: Java projects pin them in the catalog to settle transitive
479
+ // version conflicts without writing a line of Kotlin.
480
+ const KOTLIN_CATALOG_RE = /(^\s*kotlin\s*=|org\.jetbrains\.kotlin\.(?:jvm|plugin|multiplatform|android|kapt)|kotlin-gradle-plugin)/m;
481
+ if (stack.language !== "kotlin" && KOTLIN_CATALOG_RE.test(vc) && !(await isJavaOnly())) {
431
482
  stack.language = "kotlin"; stack.detected.push("kotlin (catalog)");
432
483
  }
433
484
  }
434
485
  }
435
486
 
487
+ // ── Java: multi-module Gradle detection ──
488
+ // Root build.gradle of a multi-module project often holds only
489
+ // `allprojects { repositories {...} }` and no framework coords at all;
490
+ // the real declarations live in `api/build.gradle`, `core/build.gradle`, …
491
+ // Scan sub-module build files (same bound as the Kotlin block below) for
492
+ // the Java plugin / Spring Boot coords so the project isn't reported as
493
+ // "no language detected" and the Java scanner actually runs.
494
+ if (!stack.language && stack.buildTool === "gradle") {
495
+ const subBuildFiles = await glob("*/**/build.gradle{,.kts}", { cwd: ROOT, ignore: ["**/node_modules/**", "**/build/**", "**/buildSrc/**"] });
496
+ for (const sbf of subBuildFiles.slice(0, 30)) {
497
+ const sc = readFileSafe(path.join(ROOT, sbf));
498
+ if (!sc) continue;
499
+ if (sc.includes("kotlin") || sc.includes("org.jetbrains.kotlin")) continue; // handled by the Kotlin block
500
+ const isJava = /\bid\s*\(?\s*['"](java|java-library|org\.springframework\.boot)['"]/.test(sc)
501
+ || /apply\s+plugin:\s*['"](java|java-library)['"]/.test(sc)
502
+ || sc.includes("spring-boot");
503
+ if (isJava) {
504
+ // Do not `break` on the first Java module: a `core` library module
505
+ // usually comes before the `api` module that actually declares
506
+ // Spring Boot. Set language once, keep sweeping for framework/versions.
507
+ if (stack.language !== "java") { stack.language = "java"; stack.detected.push("java (submodule)"); }
508
+ if (!stack.framework && (sc.includes("spring-boot") || sc.includes("org.springframework.boot"))) {
509
+ stack.framework = "spring-boot"; stack.detected.push("spring-boot (submodule)");
510
+ }
511
+ if (!stack.frameworkVersion) {
512
+ // `spring-boot-starter-web:2.7.18`, `spring-boot-dependencies:3.2.0`,
513
+ // or `id 'org.springframework.boot' version '3.2.5'` inside the module.
514
+ const sv = sc.match(/spring-boot[\w-]*[:\s'"]+(\d+\.\d+\.\d+)/)
515
+ || sc.match(/org\.springframework\.boot[^\n]*?version\s*\(?\s*['"](\d+\.\d+\.\d+)['"]/);
516
+ if (sv) stack.frameworkVersion = sv[1];
517
+ }
518
+ if (!stack.languageVersion) {
519
+ const jv = sc.match(/(?:sourceCompatibility|targetCompatibility)\s*=\s*['"]?(\d+(?:\.\d+)?)['"]?/)
520
+ || sc.match(/JavaVersion\.VERSION_(?:1_)?(\d+)/)
521
+ || sc.match(/JavaLanguageVersion\.of\s*\(\s*(\d+)\s*\)/);
522
+ if (jv) stack.languageVersion = normalizeJavaVersion(jv[1]);
523
+ }
524
+ if (stack.framework && stack.frameworkVersion && stack.languageVersion) break;
525
+ }
526
+ }
527
+ }
528
+
436
529
  // ── 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/**"] });
530
+ // v2.5.0: `buildSrc/` is ignored (its `kotlin-dsl` plugin is not evidence of
531
+ // Kotlin application code) and a Java-only source tree never flips.
532
+ if (stack.language !== "kotlin" && stack.buildTool === "gradle" && !(await isJavaOnly())) {
533
+ const subBuildFiles = await glob("**/build.gradle{,.kts}", { cwd: ROOT, ignore: ["**/node_modules/**", "**/build/**", "**/buildSrc/**"] });
439
534
  for (const sbf of subBuildFiles.slice(0, 5)) {
440
535
  const sc = readFileSafe(path.join(ROOT, sbf));
441
536
  if (sc && (sc.includes("kotlin") || sc.includes("org.jetbrains.kotlin"))) {
@@ -510,13 +605,13 @@ async function detectStack(ROOT) {
510
605
  // pom.xml (cross-file resolution — parent pom, BOM — is out of
511
606
  // scope; the resulting null falls through to LLM-side analysis).
512
607
  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>/,
608
+ /<java\.version>\s*(\d+(?:\.\d+)?)\s*<\/java\.version>/,
609
+ /<maven\.compiler\.source>\s*(\d+(?:\.\d+)?)\s*<\/maven\.compiler\.source>/,
610
+ /<maven\.compiler\.target>\s*(\d+(?:\.\d+)?)\s*<\/maven\.compiler\.target>/,
516
611
  ];
517
612
  for (const pattern of mvnJavaPatterns) {
518
613
  const m = pom.match(pattern);
519
- if (m) { stack.languageVersion = m[1]; break; }
614
+ if (m) { stack.languageVersion = normalizeJavaVersion(m[1]); break; }
520
615
  }
521
616
  // Pattern 3 fallback: if <java.version> references a property,
522
617
  // resolve it inside the same pom.
@@ -525,9 +620,9 @@ async function detectStack(ROOT) {
525
620
  if (propRef) {
526
621
  const propName = propRef[1].trim();
527
622
  const escapedProp = propName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
528
- const propDef = new RegExp(`<${escapedProp}>\\s*(\\d+)\\s*</${escapedProp}>`);
623
+ const propDef = new RegExp(`<${escapedProp}>\\s*(\\d+(?:\\.\\d+)?)\\s*</${escapedProp}>`);
529
624
  const propVal = pom.match(propDef);
530
- if (propVal) stack.languageVersion = propVal[1];
625
+ if (propVal) stack.languageVersion = normalizeJavaVersion(propVal[1]);
531
626
  }
532
627
  }
533
628
  // For dependency detection (framework, ORM, DB, logging), strip
@@ -628,7 +723,10 @@ async function detectStack(ROOT) {
628
723
  }
629
724
  }
630
725
 
631
- if (!stack.language) stack.language = deps.typescript ? "typescript" : "javascript";
726
+ // Provisional: a root package.json may exist only for frontend tooling
727
+ // (Tailwind/PostCSS) in a Python repo. The Python block below reclaims
728
+ // `language` when a Python framework is detected and no Node backend is.
729
+ if (!stack.language) { stack.language = deps.typescript ? "typescript" : "javascript"; languageFromPackageJson = true; }
632
730
  if (deps.typescript) { stack.detected.push("typescript"); const tv = deps.typescript.match(/(\d+(?:\.\d+)*)/); if (tv) stack.languageVersion = tv[1]; }
633
731
 
634
732
  // Frontend (Angular checked before React — Angular projects may include react in devDependencies)
@@ -666,6 +764,14 @@ async function detectStack(ROOT) {
666
764
  stack.detected.push("vite");
667
765
  stack.frameworkVersion = deps.vite.replace(/[^0-9.]/g, "");
668
766
  }
767
+ // v2.5.0 — Record the frontend bundler independently of `framework`.
768
+ // When a backend framework occupies `stack.framework` (Spring + React/Vite
769
+ // in one repo), `framework === "vite"` can never be true, and
770
+ // selectTemplates() used to fall back to the Next.js template for a
771
+ // Vite SPA. `frontendBundler` carries that signal regardless of backend.
772
+ if (deps.vite && stack.frontend && stack.frontend !== "nextjs") {
773
+ stack.frontendBundler = "vite";
774
+ }
669
775
 
670
776
  // ORM
671
777
  for (const [depKeys, ormName] of NODE_ORM_RULES) {
@@ -698,18 +804,95 @@ async function detectStack(ROOT) {
698
804
  }
699
805
  }
700
806
 
807
+ let subDirSpa = null;
808
+ // ── Frontend in a sub-directory (v2.5.0) ──
809
+ // Spring/Django/... repos commonly keep the SPA in frontend/, client/, web/ or
810
+ // ui/ with its own package.json and no root package.json. Detect it there and
811
+ // record the sub-directory so the frontend scanner can be rooted at it.
812
+ if (!stack.frontend) {
813
+ for (const sub of ["frontend", "client", "web", "ui", "webapp", "front"]) {
814
+ const subDir = path.join(ROOT, sub);
815
+ const pj = path.join(subDir, "package.json");
816
+ const spkg = existsSafe(pj) ? readJsonSafe(pj) : null;
817
+ const sdeps = spkg ? { ...(spkg.dependencies || {}), ...(spkg.devDependencies || {}) } : null;
818
+ if (sdeps) {
819
+ // Same precedence as the root package.json rules (Angular before React —
820
+ // Angular projects may carry react in devDependencies). `nuxt` maps to
821
+ // vue so a Nuxt app is not missed when `vue` is only transitive.
822
+ const rules = [["next", "nextjs", "next.js"], ["@angular/core", "angular", "angular"], ["nuxt", "vue", "nuxt"], ["react", "react", "react"], ["vue", "vue", "vue"]];
823
+ for (const [dep, name, label] of rules) {
824
+ if (sdeps[dep]) {
825
+ stack.frontend = name;
826
+ // `frontendVersion` is the framework's version (Vue for a Nuxt app),
827
+ // never the meta-framework's — a Nuxt 3.11 app is not "Vue 3.11".
828
+ const verDep = dep === "nuxt" ? (sdeps.vue ? "vue" : null) : dep;
829
+ stack.frontendVersion = verDep ? String(sdeps[verDep]).replace(/[^0-9.]/g, "") : null;
830
+ stack.frontendRoot = sub;
831
+ stack.detected.push(`${label} (${sub}/)`);
832
+ break;
833
+ }
834
+ }
835
+ if (stack.frontend && sdeps.vite && stack.frontend !== "nextjs") stack.frontendBundler = "vite";
836
+ }
837
+ // Config-file fallback inside the sub-directory (mirrors the root fallback
838
+ // below): a package.json without a recognizable framework dep, or none at all.
839
+ if (!stack.frontend) {
840
+ const subFallbacks = [
841
+ [["next.config.js", "next.config.mjs", "next.config.ts"], "nextjs", null, "next.config"],
842
+ [["vite.config.ts", "vite.config.js"], "react", "vite", "vite.config"],
843
+ [["nuxt.config.ts", "nuxt.config.js"], "vue", null, "nuxt.config"],
844
+ [["angular.json", ".angular.json"], "angular", null, "angular.json"],
845
+ ];
846
+ for (const [files, frontendName, bundler, label] of subFallbacks) {
847
+ if (files.some(f => existsSafe(path.join(subDir, f)))) {
848
+ stack.frontend = frontendName;
849
+ stack.frontendRoot = sub;
850
+ if (bundler) stack.frontendBundler = bundler;
851
+ stack.detected.push(`${label} (${sub}/, fallback)`);
852
+ break;
853
+ }
854
+ }
855
+ }
856
+ if (stack.frontend) {
857
+ // Remember the sub-directory; language / package manager are filled
858
+ // AFTER every backend block has run (see "SPA-only sub-directory
859
+ // repo" below). Filling them here would pre-empt the Python block's
860
+ // `if (!stack.language)` and turn a Django + frontend/ repo into
861
+ // "typescript", silently skipping the Python scanner.
862
+ subDirSpa = { subDir, sdeps };
863
+ break;
864
+ }
865
+ }
866
+ }
867
+
701
868
  // ── Python ──
702
869
  const hasPyproject = existsSafe(path.join(ROOT, "pyproject.toml"));
703
870
  const hasRequirements = existsSafe(path.join(ROOT, "requirements.txt"));
704
871
  if (hasPyproject || hasRequirements) {
705
- if (!stack.language) stack.language = "python";
872
+ // v2.5.0 — a Python manifest at the root beats a `language` that came
873
+ // from a root package.json with NO Node backend framework: that
874
+ // package.json exists for Tailwind/PostCSS/ESLint tooling, and the
875
+ // backend is Python. (A NestJS/Express/Fastify framework keeps Node.)
876
+ const NODE_BACKENDS = ["nestjs", "express", "fastify"];
877
+ if (!stack.language) {
878
+ stack.language = "python";
879
+ } else if (languageFromPackageJson && !NODE_BACKENDS.includes(stack.framework)) {
880
+ stack.language = "python";
881
+ stack.languageVersion = null; // was the TypeScript version; Python's is read below
882
+ }
706
883
  stack.detected.push("python");
707
884
 
708
885
  const pyFrameworkRules = [["django", "django"], ["fastapi", "fastapi"], ["flask", "flask"]];
709
886
  const pyOrmRules = [["sqlalchemy", "sqlalchemy"], ["tortoise", "tortoise-orm"]];
710
887
 
888
+ // v2.5.0 — keyword matching is case-insensitive. `pip freeze` and PyPI
889
+ // canonical names are capitalized (`Django==5.0`, `Flask==3.0`,
890
+ // `SQLAlchemy==2.0`); the previous case-sensitive `includes()` never
891
+ // recognized Django/Flask from requirements.txt, and a Django project
892
+ // then aborted `init` with "domain-groups.json has invalid totalGroups: 0".
711
893
  if (hasPyproject) {
712
- const pp = readFileSafe(path.join(ROOT, "pyproject.toml"));
894
+ const ppRaw = readFileSafe(path.join(ROOT, "pyproject.toml"));
895
+ const pp = ppRaw ? ppRaw.toLowerCase() : ppRaw;
713
896
  if (pp) {
714
897
  const pv = pp.match(/python\s*=\s*"[><=^~]*(\d+\.\d+)/);
715
898
  if (pv && !stack.languageVersion) stack.languageVersion = pv[1];
@@ -725,7 +908,8 @@ async function detectStack(ROOT) {
725
908
  }
726
909
 
727
910
  if (hasRequirements) {
728
- const r = readFileSafe(path.join(ROOT, "requirements.txt"));
911
+ const rRaw = readFileSafe(path.join(ROOT, "requirements.txt"));
912
+ const r = rRaw ? rRaw.toLowerCase() : rRaw;
729
913
  if (r) {
730
914
  for (const [kw, name] of pyFrameworkRules) {
731
915
  if (r.includes(kw) && !stack.framework) { stack.framework = name; stack.detected.push(name); break; }
@@ -922,6 +1106,8 @@ async function detectStack(ROOT) {
922
1106
  stack.framework = frameworkName;
923
1107
  stack.detected.push(frameworkName + " (fallback)");
924
1108
  }
1109
+ // v2.5.0 — keep the bundler signal even when a backend owns `framework`.
1110
+ if (frameworkName === "vite") stack.frontendBundler = "vite";
925
1111
  break;
926
1112
  }
927
1113
  }
@@ -932,13 +1118,65 @@ async function detectStack(ROOT) {
932
1118
  // the project actually declares. This overrides framework-default guesses
933
1119
  // in downstream code (plan-installer/index.js defaultPort) and exposes the
934
1120
  // full variable map to Pass 3 prompts via project-analysis.json.
1121
+ // v2.5.0 — SPA-only sub-directory repo. Runs after EVERY backend block
1122
+ // (Gradle / Maven / Node / Python): a repo whose only application is the
1123
+ // SPA in `frontend/` must not be reported as "no language detected", but a
1124
+ // backend's language / package manager always takes precedence.
1125
+ if (subDirSpa && !stack.language) {
1126
+ const { subDir, sdeps } = subDirSpa;
1127
+ const ts = (sdeps && sdeps.typescript) || existsSafe(path.join(subDir, "tsconfig.json"));
1128
+ stack.language = ts ? "typescript" : "javascript";
1129
+ if (sdeps && sdeps.typescript && !stack.languageVersion) {
1130
+ const tv = String(sdeps.typescript).match(/(\d+(?:\.\d+)*)/);
1131
+ if (tv) stack.languageVersion = tv[1];
1132
+ }
1133
+ }
1134
+ if (subDirSpa && !stack.packageManager) {
1135
+ const { subDir } = subDirSpa;
1136
+ stack.packageManager = existsSafe(path.join(subDir, "pnpm-lock.yaml")) ? "pnpm"
1137
+ : existsSafe(path.join(subDir, "yarn.lock")) ? "yarn"
1138
+ : existsSafe(path.join(subDir, "bun.lockb")) || existsSafe(path.join(subDir, "bun.lock")) ? "bun" : "npm";
1139
+ }
1140
+
935
1141
  const envInfo = readStackEnvInfo(ROOT);
936
1142
  if (envInfo) {
937
1143
  stack.envInfo = envInfo;
938
1144
  // 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;
1145
+ // (e.g., Spring application.yml parsing).
1146
+ //
1147
+ // v2.5.0 — a root `.env*` may carry BOTH a backend port and a frontend
1148
+ // dev-server port (`VITE_PORT` / `NEXT_PUBLIC_PORT` / `NUXT_PORT` /
1149
+ // `NG_PORT`). `extractPort()` prefers the frontend keys, so when a
1150
+ // backend exists the frontend key must go to `frontendPort`, never to the
1151
+ // backend's `stack.port`.
1152
+ const vars = envInfo.vars || {};
1153
+ const feKeys = Object.keys(vars).filter(k => /^(VITE_|NEXT_|NUXT_|NG_)\w*PORT$/.test(k));
1154
+ const backendOnlyVars = Object.fromEntries(Object.entries(vars).filter(([k]) => !feKeys.includes(k)));
1155
+ const backendPort = extractPort(backendOnlyVars);
1156
+ const frontendPort = feKeys.length ? extractPort(Object.fromEntries(feKeys.map(k => [k, vars[k]]))) : null;
1157
+ const hasBackend = (!!stack.framework && stack.framework !== "vite") || ["java", "kotlin", "python"].includes(stack.language);
1158
+ if (!stack.port) {
1159
+ const p = hasBackend ? backendPort : envInfo.port;
1160
+ if (p) stack.port = p;
1161
+ }
1162
+ if (frontendPort && stack.frontend && !stack.frontendRoot && !stack.frontendPort) stack.frontendPort = frontendPort;
1163
+ // Keep `stack.envInfo.port` consistent with the split: Pass 3 prompts read
1164
+ // `stack.envInfo.port` for the backend Server Port row, so it must never
1165
+ // carry the frontend dev-server value when a backend exists.
1166
+ envInfo.port = hasBackend ? (backendPort || null) : envInfo.port;
1167
+ if (frontendPort) envInfo.frontendPort = frontendPort;
1168
+ }
1169
+ // v2.5.0 — Sub-directory SPA: its own `.env*` (VITE_PORT, VITE_API_URL,
1170
+ // NEXT_PUBLIC_*) lives under `frontend/`, not at the project root. Read it
1171
+ // separately (same redaction/masking) so the dev-server port and API target
1172
+ // come from the project instead of framework-default guesses. Kept apart
1173
+ // from `stack.envInfo` / `stack.port` so a frontend `PORT=3000` never
1174
+ // overrides the backend's port.
1175
+ if (stack.frontendRoot) {
1176
+ const feEnv = readStackEnvInfo(path.join(ROOT, stack.frontendRoot));
1177
+ if (feEnv) {
1178
+ stack.frontendEnvInfo = { ...feEnv, source: `${stack.frontendRoot}/${feEnv.source}` };
1179
+ if (feEnv.port) stack.frontendPort = feEnv.port;
942
1180
  }
943
1181
  }
944
1182
 
@@ -17,6 +17,7 @@ const { scanKotlinDomains, resolveSharedQueryDomains } = require("./scanners/sca
17
17
  const { scanNodeDomains } = require("./scanners/scan-node");
18
18
  const { scanPythonDomains } = require("./scanners/scan-python");
19
19
  const { scanFrontendDomains, countFrontendStats } = require("./scanners/scan-frontend");
20
+ const path = require("path");
20
21
 
21
22
  async function scanStructure(stack, ROOT) {
22
23
  let backendDomains = [];
@@ -36,22 +37,32 @@ async function scanStructure(stack, ROOT) {
36
37
  if (r.rootPackage) rootPackage = r.rootPackage;
37
38
  }
38
39
 
39
- if ((stack.language === "typescript" || stack.language === "javascript") && stack.framework && stack.framework !== "vite") {
40
+ // v2.5.0 dispatch is framework-aware for Python. A Django/FastAPI/Flask
41
+ // repo may carry a package.json (tailwind/postcss tooling, or a SPA in a
42
+ // sub-directory) that sets `language` to typescript/javascript; the backend
43
+ // is still Python and must be scanned by the Python scanner, never by the
44
+ // Node scanner.
45
+ const PY_FRAMEWORKS = ["django", "fastapi", "flask"];
46
+ const isPythonBackend = stack.language === "python" || PY_FRAMEWORKS.includes(stack.framework);
47
+
48
+ if ((stack.language === "typescript" || stack.language === "javascript") && stack.framework && stack.framework !== "vite" && !isPythonBackend) {
40
49
  const r = await scanNodeDomains(stack, ROOT);
41
50
  backendDomains.push(...r.backendDomains);
42
51
  }
43
52
 
44
- if (stack.language === "python") {
53
+ if (isPythonBackend) {
45
54
  const r = await scanPythonDomains(stack, ROOT);
46
55
  backendDomains.push(...r.backendDomains);
47
56
  }
48
57
 
49
58
  // ── Frontend scanner ──
50
- const fe = await scanFrontendDomains(stack, ROOT);
59
+ // v2.5.0: when the SPA lives in frontend/ (stack.frontendRoot), scan there.
60
+ const FE_ROOT = stack.frontendRoot ? path.join(ROOT, stack.frontendRoot) : ROOT;
61
+ const fe = await scanFrontendDomains(stack, FE_ROOT, { projectRoot: ROOT });
51
62
  frontendDomains.push(...fe.frontendDomains);
52
63
 
53
64
  // ── Frontend stats ──
54
- const frontend = await countFrontendStats(stack, ROOT);
65
+ const frontend = await countFrontendStats(stack, FE_ROOT);
55
66
 
56
67
  // ── Aggregate ──
57
68
  const allDomains = [