claudeos-core 2.3.1 → 2.4.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 (43) hide show
  1. package/CHANGELOG.md +1460 -73
  2. package/CODE_OF_CONDUCT.md +15 -0
  3. package/README.de.md +321 -883
  4. package/README.es.md +322 -883
  5. package/README.fr.md +322 -883
  6. package/README.hi.md +322 -883
  7. package/README.ja.md +322 -883
  8. package/README.ko.md +322 -882
  9. package/README.md +321 -883
  10. package/README.ru.md +322 -885
  11. package/README.vi.md +322 -883
  12. package/README.zh-CN.md +321 -881
  13. package/SECURITY.md +51 -0
  14. package/bin/commands/init.js +570 -264
  15. package/content-validator/index.js +185 -12
  16. package/health-checker/index.js +44 -10
  17. package/package.json +92 -90
  18. package/pass-json-validator/index.js +58 -7
  19. package/pass-prompts/templates/angular/pass3.md +15 -14
  20. package/pass-prompts/templates/common/claude-md-scaffold.md +203 -20
  21. package/pass-prompts/templates/common/pass3-footer.md +297 -56
  22. package/pass-prompts/templates/common/pass3a-facts.md +48 -3
  23. package/pass-prompts/templates/common/pass4.md +78 -40
  24. package/pass-prompts/templates/java-spring/pass1.md +54 -0
  25. package/pass-prompts/templates/java-spring/pass3.md +20 -19
  26. package/pass-prompts/templates/kotlin-spring/pass1.md +45 -0
  27. package/pass-prompts/templates/kotlin-spring/pass3.md +24 -23
  28. package/pass-prompts/templates/node-express/pass3.md +18 -17
  29. package/pass-prompts/templates/node-fastify/pass3.md +11 -10
  30. package/pass-prompts/templates/node-nestjs/pass3.md +11 -10
  31. package/pass-prompts/templates/node-nextjs/pass3.md +18 -17
  32. package/pass-prompts/templates/node-vite/pass3.md +11 -10
  33. package/pass-prompts/templates/python-django/pass3.md +18 -17
  34. package/pass-prompts/templates/python-fastapi/pass3.md +18 -17
  35. package/pass-prompts/templates/python-flask/pass3.md +9 -8
  36. package/pass-prompts/templates/vue-nuxt/pass3.md +9 -8
  37. package/plan-installer/domain-grouper.js +45 -5
  38. package/plan-installer/index.js +34 -1
  39. package/plan-installer/pass3-context-builder.js +14 -0
  40. package/plan-installer/scanners/scan-frontend.js +2 -1
  41. package/plan-installer/scanners/scan-java.js +98 -2
  42. package/plan-installer/source-paths.js +242 -0
  43. package/plan-installer/stack-detector.js +522 -42
@@ -13,6 +13,20 @@ const { readStackEnvInfo } = require("../lib/env-parser");
13
13
 
14
14
  // ─── Lookup tables ──────────────────────────────────────────────
15
15
 
16
+ // iBatis detection — ONLY matches Apache iBatis (EOL 2010) or
17
+ // Spring iBatis (`spring-ibatis`, `ibatis-sqlmap`, `ibatis-core`).
18
+ // This pattern intentionally avoids matching MyBatis coords
19
+ // (`org.mybatis:mybatis`, `mybatis-spring-boot-starter`) — MyBatis
20
+ // evolved out of iBatis but is a separate library with different
21
+ // XML namespace and SqlSessionFactory architecture.
22
+ //
23
+ // Why separate from the ORM_RULES tables: the other entries match on
24
+ // substring include() calls, which would produce false positives for
25
+ // iBatis (MyBatis coords contain "mybatis" which does NOT include
26
+ // "ibatis" — but legacy Spring projects may have both). We use a
27
+ // precise regex on specific library coords instead.
28
+ const IBATIS_REGEX = /\borg\.apache\.ibatis\b|\bspring-ibatis\b|\bibatis-sqlmap\b|\bibatis-core\b|\bibatis-common\b/i;
29
+
16
30
  // ORM detection rules: [keyword, ormName] (order = priority, first match wins)
17
31
  const GRADLE_ORM_RULES = [
18
32
  ["mybatis", "mybatis"],
@@ -41,6 +55,7 @@ const NODE_ORM_RULES = [
41
55
  // DB detection rules: [keyword, dbName]
42
56
  const DB_KEYWORD_RULES = [
43
57
  ["postgresql", "postgresql"], ["postgres", "postgresql"],
58
+ ["mariadb", "mariadb"], // v2.3.2+: MariaDB is a distinct DB, not a MySQL alias
44
59
  ["mysql", "mysql"],
45
60
  ["oracle", "oracle"],
46
61
  ["mongodb", "mongodb"],
@@ -63,19 +78,151 @@ function detectFirst(stack, field, content, rules) {
63
78
  }
64
79
  }
65
80
 
81
+ // Logging framework rules: [regex, frameworkName]
82
+ // Order matters only for identification — all matches are collected.
83
+ // We use regexes (not plain includes()) because "log4j" is a substring
84
+ // of "log4jdbc" and "log4j-to-slf4j", which are JDBC adapters /
85
+ // bridges, not Log4j2 as the primary logging framework.
86
+ const LOGGING_RULES = [
87
+ // Log4j2 — the current Apache Logging project.
88
+ // Matches artifact coords in two forms:
89
+ // - Gradle coord string: `org.apache.logging.log4j:log4j-core`
90
+ // (or `org.apache.logging.log4j.log4j-core` in some catalogs).
91
+ // - Maven XML: `<groupId>org.apache.logging.log4j</groupId>` paired
92
+ // with `<artifactId>log4j-core</artifactId>`. The two tags are
93
+ // matched on the same content (after comment stripping, so the
94
+ // order and proximity within pom.xml is what matters — both must
95
+ // be present somewhere in the non-commented dependency region).
96
+ // Does NOT match `log4j-to-slf4j` / `log4j-api` alone (which bridge
97
+ // Log4j API to SLF4J and are usually paired with Logback).
98
+ [/org\.apache\.logging\.log4j[.:]log4j-core/i, "log4j2"],
99
+ [/<groupId>\s*org\.apache\.logging\.log4j\s*<\/groupId>[\s\S]{0,300}?<artifactId>\s*log4j-core\s*<\/artifactId>/i, "log4j2"],
100
+ // Log4j2 via config file patterns
101
+ [/\blog4j2[\w.-]*\.(?:xml|properties|yaml|yml|json)\b/i, "log4j2"],
102
+
103
+ // Logback — Spring Boot's default. Matches the dependency in two forms:
104
+ // - Gradle coord string: `ch.qos.logback:logback-classic`
105
+ // - Maven XML: `<groupId>ch.qos.logback</groupId>` paired with
106
+ // `<artifactId>logback-classic</artifactId>` (or logback-core).
107
+ // Also matches config file references `logback-*.xml` / `logback*.groovy`.
108
+ [/ch\.qos\.logback[.:]logback-classic|logback[\w.-]*\.xml|logback[\w.-]*\.groovy/i, "logback"],
109
+ [/<groupId>\s*ch\.qos\.logback\s*<\/groupId>[\s\S]{0,300}?<artifactId>\s*logback-(?:classic|core)\s*<\/artifactId>/i, "logback"],
110
+
111
+ // log4jdbc — JDBC logging adapter. Not a primary logging framework
112
+ // but useful metadata because CLAUDE.md / logging standards commonly
113
+ // describe both the primary framework AND JDBC adapters.
114
+ [/log4jdbc/i, "log4jdbc"],
115
+
116
+ // Log4j 1.x (EOL 2015) — still appears in legacy projects.
117
+ // The challenge is distinguishing it from Log4j2 adapters such as
118
+ // `log4j-to-slf4j`, `log4j-api`, `log4j-core` (all Log4j2 ecosystem).
119
+ //
120
+ // The groupId for Log4j 1.x is literally `log4j` (not
121
+ // `org.apache.logging.log4j`), so we anchor on the coord form
122
+ // `log4j:log4j` with surrounding quotes/whitespace boundaries so
123
+ // that `org.apache.logging.log4j:log4j-to-slf4j` does NOT match
124
+ // (word boundary alone isn't enough — `log4j:log4j` appears as a
125
+ // substring in `...log4j:log4j-to-slf4j`).
126
+ //
127
+ // Also matches:
128
+ // <groupId>log4j</groupId> (Maven XML form)
129
+ // log4j.properties / log4j.xml (config files, not log4j2.*)
130
+ [
131
+ /(?:['":\s]log4j:log4j(?:[:'"]|\s|$))|<groupId>\s*log4j\s*<\/groupId>|\blog4j(?!2)\.(?:properties|xml)\b/im,
132
+ "log4j",
133
+ ],
134
+ ];
135
+
136
+ // Comment stripping for dependency/config content before regex scanning.
137
+ // Used by detectLogging and the Maven DB scan to ensure commented-out
138
+ // dependencies are never interpreted as "in use".
139
+ //
140
+ // Three comment styles are handled:
141
+ // 1. Line-level `//` (Gradle Kotlin/Groovy DSL).
142
+ // 2. Line-level `#` (yml, properties, shell).
143
+ // 3. Block-level `<!-- ... -->` (Maven pom.xml, XML config). Commonly
144
+ // used to disable a whole `<dependency>` block during migration
145
+ // (e.g., commenting out an old log4j 1.x dep after switching to
146
+ // Spring Boot's managed Logback). Block stripping is non-greedy
147
+ // and multi-line so nested blocks and `<dependency>` blocks
148
+ // spanning many lines are handled correctly.
149
+ //
150
+ // Returns a new string with the commented regions removed (replaced
151
+ // with a newline to preserve approximate line counts for any
152
+ // downstream logic that cares about position). Content outside
153
+ // comments is preserved byte-for-byte.
154
+ function stripComments(content) {
155
+ // Step 1: remove XML block comments first (can span multiple lines).
156
+ // Non-greedy `[\s\S]*?` matches newlines; the `/g` flag removes every
157
+ // occurrence. We intentionally do NOT handle nested `<!-- ... -->`
158
+ // because XML spec forbids nesting — any well-formed `<!-- ... -->`
159
+ // is flat.
160
+ const withoutBlock = content.replace(/<!--[\s\S]*?-->/g, "\n");
161
+ // Step 2: drop lines that are entirely a line-comment.
162
+ return withoutBlock
163
+ .split(/\r?\n/)
164
+ .filter(line => {
165
+ const trimmed = line.trimStart();
166
+ return !trimmed.startsWith("//") && !trimmed.startsWith("#");
167
+ })
168
+ .join("\n");
169
+ }
170
+
171
+ function detectLogging(stack, content) {
172
+ // Collect every matching logging framework. Uses stack.loggingFrameworks
173
+ // array (multi-valued) because it is common for a project to declare
174
+ // two: Logback as primary + log4jdbc as JDBC adapter.
175
+ //
176
+ // Comment stripping: commented-out lines in Gradle (`//`),
177
+ // yml/properties/shell-style (`#`), and XML block comments
178
+ // (`<!-- ... -->`) must not match. Without this, a line like
179
+ // `// implementation 'ch.qos.logback:logback-classic'` (commented
180
+ // out because the project switched away from explicit version
181
+ // pinning to Spring Boot's managed version) or a pom.xml
182
+ // `<!-- <dependency>log4j:log4j:1.2.17</dependency> -->` block
183
+ // (commented out during migration to Logback) is mistakenly
184
+ // reported as in use. We preserve the classic Logback detection
185
+ // path via the `logging.config:` yml reference or the logback
186
+ // config file glob elsewhere.
187
+ const stripped = stripComments(content);
188
+ for (const [regex, name] of LOGGING_RULES) {
189
+ if (regex.test(stripped) && !stack.loggingFrameworks.includes(name)) {
190
+ stack.loggingFrameworks.push(name);
191
+ }
192
+ }
193
+ }
194
+
66
195
  function detectDb(stack, content, rules) {
67
- if (stack.database) return;
196
+ // Iterate every rule and record every DB keyword present in `content`.
197
+ // Two outputs:
198
+ // (a) stack.database — primary DB (first match, legacy semantics).
199
+ // Skipped once set, so earlier-called detectDb invocations
200
+ // (Gradle build.gradle → application.yml → pom.xml) establish
201
+ // the primary DB in source-file order.
202
+ // (b) stack.databases — every DB keyword detected across all sources.
203
+ // Deduped and order-preserved. Fills in multi-dialect projects.
68
204
  for (const [keyword, value] of rules) {
69
205
  if (content.includes(keyword)) {
70
- stack.database = value;
71
- stack.detected.push(value);
72
- return;
206
+ if (!stack.database) {
207
+ stack.database = value;
208
+ stack.detected.push(value);
209
+ }
210
+ if (!stack.databases.includes(value)) {
211
+ stack.databases.push(value);
212
+ }
73
213
  }
74
214
  }
75
- // h2 with word boundary
76
- if (!stack.database && H2_REGEX.test(content)) {
77
- stack.database = "h2";
78
- stack.detected.push("h2");
215
+ // h2 with word boundary — same pattern, separate from the keyword
216
+ // rules table because it needs regex (to avoid false positives from
217
+ // oauth2, cache2k, etc.).
218
+ if (H2_REGEX.test(content)) {
219
+ if (!stack.database) {
220
+ stack.database = "h2";
221
+ stack.detected.push("h2");
222
+ }
223
+ if (!stack.databases.includes("h2")) {
224
+ stack.databases.push("h2");
225
+ }
79
226
  }
80
227
  }
81
228
 
@@ -90,6 +237,26 @@ async function detectStack(ROOT) {
90
237
  language: null, languageVersion: null,
91
238
  framework: null, frameworkVersion: null,
92
239
  buildTool: null, database: null, orm: null,
240
+ // databases: multi-dialect projects declare more than one DB
241
+ // driver (e.g., PostgreSQL + MariaDB + Oracle for dialect-switchable
242
+ // backends). `database` keeps its legacy semantics of "the primary
243
+ // DB that wins the first-match race" for backward compatibility
244
+ // with v2.x consumers; `databases` is the full ordered list of
245
+ // every DB keyword detected across all config sources. Consumers
246
+ // that care about multi-dialect support (Pass 1 prompts, Pass 3
247
+ // standard files for database-schema docs) should prefer this
248
+ // field. Empty array, not null, when no DB is detected — makes
249
+ // the array-comprehension in prompts simpler.
250
+ databases: [],
251
+ orm: null,
252
+ // loggingFrameworks: detected JVM logging frameworks (Logback,
253
+ // Log4j2, SLF4J-only, etc.). Like `databases`, this is an
254
+ // informational list for Pass 1 prompts — the LLM can use it to
255
+ // ground logging-related standard/rule content. Empty array when
256
+ // no JVM logging evidence is found (e.g., Node.js projects).
257
+ // Mainly populated from Gradle/Maven dependency keywords and from
258
+ // `logging.config` references in application.yml.
259
+ loggingFrameworks: [],
93
260
  frontend: null, frontendVersion: null,
94
261
  packageManager: null, monorepo: null, workspaces: null,
95
262
  detected: [],
@@ -103,6 +270,10 @@ async function detectStack(ROOT) {
103
270
  const g = readFileSafe(path.join(ROOT, gradleFile));
104
271
  if (g) {
105
272
  stack.buildTool = "gradle"; stack.detected.push(gradleFile);
273
+ // v2.4.0 — JVM project package manager. Set "gradle" so generated docs
274
+ // and downstream tooling don't show "PackageMgr: none" for a build tool
275
+ // that IS the package manager. Only set if not already detected.
276
+ if (!stack.packageManager) stack.packageManager = "gradle";
106
277
  if (g.includes("spring-boot")) { stack.language = "java"; stack.framework = "spring-boot"; stack.detected.push("spring-boot"); }
107
278
  const svPatterns = [
108
279
  /org\.springframework\.boot.*version\s*['"]([^'"]+)['"]/,
@@ -111,16 +282,105 @@ async function detectStack(ROOT) {
111
282
  ];
112
283
  for (const pattern of svPatterns) {
113
284
  const sv = g.match(pattern);
114
- if (sv) { stack.frameworkVersion = sv[1]; break; }
285
+ if (sv) {
286
+ // Reject captures that are variable references like `${var}`
287
+ // — those need resolution via the fallback block below.
288
+ if (/^\$\{/.test(sv[1])) continue;
289
+ stack.frameworkVersion = sv[1];
290
+ break;
291
+ }
292
+ }
293
+ // Fallback: some projects centralize the Spring Boot version in
294
+ // an `ext { springBootVersion = '3.5.5' }` block and reference
295
+ // it from the plugin declaration (`version "${springBootVersion}"`).
296
+ // If none of the above patterns matched but we can find a
297
+ // variable-reference form, resolve the variable inside the same
298
+ // build.gradle.
299
+ if (!stack.frameworkVersion) {
300
+ const svVarRef = g.match(/springframework\.boot[^\n]*version\s*['"]\$\{?(\w+)\}?['"]/);
301
+ if (svVarRef) {
302
+ const varName = svVarRef[1];
303
+ const escapedVar = varName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
304
+ const varDef = new RegExp(`${escapedVar}\\s*=\\s*['"]([\\d.]+)['"]`);
305
+ const varVal = g.match(varDef);
306
+ if (varVal) stack.frameworkVersion = varVal[1];
307
+ }
308
+ }
309
+ // Java version — Gradle writes this in several forms. Try each
310
+ // pattern until one matches. Earlier patterns take precedence.
311
+ //
312
+ // Pattern 1: direct numeric literal (most common, v1.x era)
313
+ // sourceCompatibility = 21
314
+ // sourceCompatibility = '21'
315
+ // sourceCompatibility = "21"
316
+ //
317
+ // Pattern 2: JavaVersion enum (common with Spring Initializr)
318
+ // sourceCompatibility = JavaVersion.VERSION_21
319
+ // sourceCompatibility = JavaVersion.VERSION_1_8 (Java 8)
320
+ //
321
+ // Pattern 3: Gradle toolchain block (modern, Gradle 6.7+)
322
+ // java { toolchain { languageVersion = JavaLanguageVersion.of(21) } }
323
+ //
324
+ // Pattern 4: ext variable reference (common in team/enterprise
325
+ // projects that centralize versions)
326
+ // ext { javaVersion = '21' }
327
+ // java { sourceCompatibility = "${javaVersion}" }
328
+ //
329
+ // Without pattern 4, an ext-variable-reference-only build.gradle
330
+ // produces languageVersion=null, leaving the LLM to guess (e.g.
331
+ // "Java 17+" for a Spring Boot 3.x project that actually targets
332
+ // Java 21).
333
+ const javaVersionPatterns = [
334
+ // (1) numeric literal on sourceCompatibility or targetCompatibility
335
+ /sourceCompatibility\s*=\s*['"]?(\d+)['"]?/,
336
+ /targetCompatibility\s*=\s*['"]?(\d+)['"]?/,
337
+ // (2) JavaVersion enum — supports both VERSION_21 and VERSION_1_8
338
+ /JavaVersion\.VERSION_(?:1_)?(\d+)/,
339
+ // (3) toolchain block
340
+ /JavaLanguageVersion\.of\s*\(\s*(\d+)\s*\)/,
341
+ ];
342
+ for (const pattern of javaVersionPatterns) {
343
+ const m = g.match(pattern);
344
+ if (m) { stack.languageVersion = m[1]; break; }
345
+ }
346
+ // (4) ext variable reference fallback — if the Compatibility
347
+ // assignment used "${varName}" we now resolve varName inside the
348
+ // same file's ext block. We only run this if the numeric patterns
349
+ // above did not already find a value.
350
+ if (!stack.languageVersion) {
351
+ const varRefMatch = g.match(/(?:source|target)Compatibility\s*=\s*["']?\$\{?(\w+)\}?["']?/);
352
+ if (varRefMatch) {
353
+ const varName = varRefMatch[1];
354
+ // Escape the variable name for use in a RegExp. In practice
355
+ // Gradle variable names are [A-Za-z0-9_], so escaping is a
356
+ // defensive guard against unexpected characters, not a
357
+ // practical necessity for today's inputs.
358
+ const escapedVarName = varName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
359
+ const extAssign = new RegExp(`${escapedVarName}\\s*=\\s*['"]?(\\d+)['"]?`);
360
+ const extVal = g.match(extAssign);
361
+ if (extVal) stack.languageVersion = extVal[1];
362
+ }
115
363
  }
116
- const jv = g.match(/sourceCompatibility\s*=\s*['"]?(\d+)['"]?/);
117
- if (jv) stack.languageVersion = jv[1];
118
364
 
119
- detectFirst(stack, "orm", g, GRADLE_ORM_RULES);
365
+ // iBatis detection: runs BEFORE generic ORM_RULES because the
366
+ // generic table's "mybatis" keyword (substring match) would
367
+ // happily match Apache iBatis coords like `ibatis-core` if the
368
+ // order were reversed — "mybatis" is not a substring of
369
+ // "ibatis", but a future maintainer adding "ibatis" to the
370
+ // generic table would create ambiguity. Explicit precedence here.
371
+ if (IBATIS_REGEX.test(g)) {
372
+ stack.orm = "ibatis";
373
+ stack.detected.push("ibatis");
374
+ } else {
375
+ detectFirst(stack, "orm", g, GRADLE_ORM_RULES);
376
+ }
120
377
  // Exclude "postgres" (substring of postgresql — false positive on r2dbc-postgres) and "sqlite" (rare in Gradle deps)
121
378
  // "postgresql" is still matched via DB_KEYWORD_RULES; h2 uses word-boundary check separately
122
379
  detectDb(stack, g, DB_KEYWORD_RULES.filter(([kw]) => !["postgres", "sqlite"].includes(kw)));
123
380
 
381
+ // Logging framework detection from Gradle dependencies.
382
+ detectLogging(stack, g);
383
+
124
384
  // Kotlin detection: override language if Kotlin plugin found
125
385
  if (g.includes("kotlin") || g.includes("org.jetbrains.kotlin")) {
126
386
  stack.language = "kotlin"; stack.detected.push("kotlin");
@@ -155,9 +415,18 @@ async function detectStack(ROOT) {
155
415
  if (!stack.orm && vc.includes("exposed")) { stack.orm = "exposed"; stack.detected.push("exposed (catalog)"); }
156
416
  else if (!stack.orm && vc.includes("jooq")) { stack.orm = "jooq"; stack.detected.push("jooq (catalog)"); }
157
417
  else if (!stack.orm && (vc.includes("jpa") || vc.includes("hibernate"))) { stack.orm = "jpa"; stack.detected.push("jpa (catalog)"); }
158
- if (!stack.database && vc.includes("postgresql")) { stack.database = "postgresql"; }
159
- if (!stack.database && vc.includes("mysql")) { stack.database = "mysql"; }
160
- if (!stack.database && vc.includes("mongodb")) { stack.database = "mongodb"; }
418
+ // DBs from version catalog dual output (primary + array)
419
+ const catalogDbs = [
420
+ ["postgresql", "postgresql"],
421
+ ["mysql", "mysql"],
422
+ ["mongodb", "mongodb"],
423
+ ];
424
+ for (const [keyword, value] of catalogDbs) {
425
+ if (vc.includes(keyword)) {
426
+ if (!stack.database) stack.database = value;
427
+ if (!stack.databases.includes(value)) stack.databases.push(value);
428
+ }
429
+ }
161
430
  if (!stack.language && vc.includes("kotlin")) {
162
431
  stack.language = "kotlin"; stack.detected.push("kotlin (catalog)");
163
432
  }
@@ -218,19 +487,95 @@ async function detectStack(ROOT) {
218
487
  const pom = readFileSafe(path.join(ROOT, "pom.xml"));
219
488
  if (pom) {
220
489
  if (!stack.buildTool) { stack.buildTool = "maven"; stack.language = "java"; stack.detected.push("pom.xml"); }
490
+ // v2.4.0 — JVM package manager (parallel to Gradle case above).
491
+ if (!stack.packageManager) stack.packageManager = "maven";
221
492
  const sv = pom.match(/<spring-boot[^>]*version>([^<]+)/);
222
493
  if (sv) stack.frameworkVersion = sv[1];
223
- const jv = pom.match(/<java\.version>(\d+)/);
224
- if (jv) stack.languageVersion = jv[1];
225
- if (pom.includes("spring-boot") && !stack.framework) { stack.framework = "spring-boot"; stack.detected.push("spring-boot"); }
226
- detectFirst(stack, "orm", pom, MAVEN_ORM_RULES);
494
+ // Java version — Maven commonly uses three patterns:
495
+ //
496
+ // Pattern 1: direct <java.version>21</java.version>
497
+ //
498
+ // Pattern 2: <maven.compiler.source>21</maven.compiler.source>
499
+ // (and matching <maven.compiler.target>), used when the project
500
+ // avoids the Spring Boot parent's `java.version` property.
501
+ //
502
+ // Pattern 3: property reference — `<java.version>${project.javaVersion}</java.version>`
503
+ // where `<project.javaVersion>21</project.javaVersion>` is
504
+ // declared earlier in <properties>. Enterprise projects
505
+ // centralize versions this way so all child modules pick up the
506
+ // same value.
507
+ //
508
+ // We try patterns in order (1) → (2) → (3). Pattern 3 is a
509
+ // fallback that resolves the referenced property within the same
510
+ // pom.xml (cross-file resolution — parent pom, BOM — is out of
511
+ // scope; the resulting null falls through to LLM-side analysis).
512
+ 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>/,
516
+ ];
517
+ for (const pattern of mvnJavaPatterns) {
518
+ const m = pom.match(pattern);
519
+ if (m) { stack.languageVersion = m[1]; break; }
520
+ }
521
+ // Pattern 3 fallback: if <java.version> references a property,
522
+ // resolve it inside the same pom.
523
+ if (!stack.languageVersion) {
524
+ const propRef = pom.match(/<java\.version>\s*\$\{([^}]+)\}\s*<\/java\.version>/);
525
+ if (propRef) {
526
+ const propName = propRef[1].trim();
527
+ const escapedProp = propName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
528
+ const propDef = new RegExp(`<${escapedProp}>\\s*(\\d+)\\s*</${escapedProp}>`);
529
+ const propVal = pom.match(propDef);
530
+ if (propVal) stack.languageVersion = propVal[1];
531
+ }
532
+ }
533
+ // For dependency detection (framework, ORM, DB, logging), strip
534
+ // XML block comments first. A `<!-- <dependency>...</dependency> -->`
535
+ // block is a standard Maven pattern for disabling a dep during
536
+ // migration (e.g., commenting out a legacy log4j 1.x dep after
537
+ // switching to Spring Boot's managed Logback); without stripping,
538
+ // those deps are counted as "in use". The `<properties>` scan
539
+ // above stays on the raw `pom` because (a) commented-out property
540
+ // definitions are rare in practice and (b) the property-reference
541
+ // resolution already scopes itself to the declared property name.
542
+ const pomClean = stripComments(pom);
543
+ if (pomClean.includes("spring-boot") && !stack.framework) { stack.framework = "spring-boot"; stack.detected.push("spring-boot"); }
544
+ if (IBATIS_REGEX.test(pomClean)) {
545
+ stack.orm = "ibatis";
546
+ stack.detected.push("ibatis");
547
+ } else {
548
+ detectFirst(stack, "orm", pomClean, MAVEN_ORM_RULES);
549
+ }
227
550
  // Maven DB: original does not push to detected (unlike Gradle)
228
- if (!stack.database && pom.includes("postgresql")) stack.database = "postgresql";
229
- if (!stack.database && pom.includes("mysql")) stack.database = "mysql";
230
- if (!stack.database && pom.includes("oracle")) stack.database = "oracle";
231
- if (!stack.database && pom.includes("mongodb")) stack.database = "mongodb";
232
- if (!stack.database && H2_REGEX.test(pom)) stack.database = "h2";
233
- if (!stack.database && pom.includes("sqlite")) stack.database = "sqlite";
551
+ // DB keyword scan: reuses detectDb's dual-output semantics
552
+ // (primary first-match stack.database; every match →
553
+ // stack.databases). Maven original did not push to `detected`
554
+ // (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
+ ];
563
+ for (const [keyword, value] of mvnDbRules) {
564
+ if (pomClean.includes(keyword)) {
565
+ if (!stack.database) stack.database = value;
566
+ if (!stack.databases.includes(value)) stack.databases.push(value);
567
+ }
568
+ }
569
+ if (H2_REGEX.test(pomClean)) {
570
+ if (!stack.database) stack.database = "h2";
571
+ if (!stack.databases.includes("h2")) stack.databases.push("h2");
572
+ }
573
+
574
+ // Logging framework detection from Maven dependencies. detectLogging
575
+ // does its own comment stripping internally, so passing raw `pom`
576
+ // works correctly — but we pass `pomClean` for consistency with
577
+ // the other Maven dependency scans in this block.
578
+ detectLogging(stack, pomClean);
234
579
  }
235
580
  }
236
581
 
@@ -329,7 +674,12 @@ async function detectStack(ROOT) {
329
674
  stack.orm = ormName; stack.detected.push(ormName);
330
675
  }
331
676
  }
332
- if (deps.mongoose) { if (!stack.database) stack.database = "mongodb"; if (!stack.orm) stack.orm = "mongoose"; stack.detected.push("mongoose"); }
677
+ if (deps.mongoose) {
678
+ if (!stack.database) stack.database = "mongodb";
679
+ if (!stack.databases.includes("mongodb")) stack.databases.push("mongodb");
680
+ if (!stack.orm) stack.orm = "mongoose";
681
+ stack.detected.push("mongoose");
682
+ }
333
683
 
334
684
  // DB
335
685
  const nodeDbRules = [["pg", "postgresql"], ["mysql2", "mysql"], ["mongodb", "mongodb"]];
@@ -383,8 +733,16 @@ async function detectStack(ROOT) {
383
733
  for (const [kw, name] of pyOrmRules) {
384
734
  if (r.includes(kw) && !stack.orm) { stack.orm = name; break; }
385
735
  }
386
- if (r.includes("psycopg") && !stack.database) stack.database = "postgresql";
387
- if (r.includes("mysqlclient") && !stack.database) stack.database = "mysql";
736
+ const pyDbs = [
737
+ ["psycopg", "postgresql"],
738
+ ["mysqlclient", "mysql"],
739
+ ];
740
+ for (const [kw, value] of pyDbs) {
741
+ if (r.includes(kw)) {
742
+ if (!stack.database) stack.database = value;
743
+ if (!stack.databases.includes(value)) stack.databases.push(value);
744
+ }
745
+ }
388
746
  }
389
747
  }
390
748
 
@@ -395,20 +753,118 @@ async function detectStack(ROOT) {
395
753
  }
396
754
 
397
755
  // ── DB from config files ──
398
- const ymls = await glob("**/application*.yml", { cwd: ROOT, absolute: true, ignore: ["**/node_modules/**", "**/build/**", "**/target/**", "**/.gradle/**"] });
756
+ //
757
+ // Glob covers Spring Boot's full configuration-file naming space:
758
+ // - `application.{yml,yaml,properties}` (Spring Initializr default;
759
+ // .yml is most common, .yaml is spec-official, .properties is the
760
+ // framework default when nothing specifies)
761
+ // - `application-*.{yml,yaml,properties}` profile variants
762
+ // (e.g. application-local.yml, application-dev.properties)
763
+ // - `bootstrap.{yml,yaml,properties}` + profile variants
764
+ // (Spring Cloud Config / Consul / Eureka; loaded before `application.*`
765
+ // so must be part of the same scan)
766
+ //
767
+ // The regexes inside the loop are format-agnostic: the `port` regex
768
+ // set covers both yml `server:\n port: N` syntax and .properties
769
+ // `server.port=N` flat-key syntax. DB keyword detection is
770
+ // substring-based and works identically across all three formats.
771
+ const configGlob = "**/{application,bootstrap}*.{yml,yaml,properties}";
772
+ const ymls = await glob(configGlob, {
773
+ cwd: ROOT, absolute: true,
774
+ ignore: ["**/node_modules/**", "**/build/**", "**/target/**", "**/.gradle/**"],
775
+ });
399
776
  for (const y of ymls) {
400
777
  const c = readFileSafe(y);
401
778
  if (!c) continue;
402
- if (!stack.database && c.includes("postgresql")) stack.database = "postgresql";
403
- if (!stack.database && c.includes("mysql")) stack.database = "mysql";
404
- if (!stack.database && c.includes("oracle")) stack.database = "oracle";
405
- if (!stack.database && c.includes("mongodb")) stack.database = "mongodb";
406
- if (!stack.database && H2_REGEX.test(c)) stack.database = "h2";
407
- if (!stack.database && c.includes("sqlite")) stack.database = "sqlite";
779
+ // DB detection dual output per source file.
780
+ //
781
+ // NOTE on mariadb: earlier versions of this detector deliberately
782
+ // OMITTED mariadb from yml-side keyword matching because some
783
+ // projects mention mariadb in commented-out profile sections or
784
+ // as fallback drivers. With multi-dialect support (`stack.databases`
785
+ // array in v2.3.2+), the cost of over-reporting is lower than the
786
+ // cost of under-reporting — `databases` is an informational list
787
+ // for the LLM, and a false positive is easier to filter out in the
788
+ // prompt than a miss is to detect. So mariadb is now included.
789
+ const ymlDbs = [
790
+ ["postgresql", "postgresql"],
791
+ ["mariadb", "mariadb"],
792
+ ["mysql", "mysql"],
793
+ ["oracle", "oracle"],
794
+ ["mongodb", "mongodb"],
795
+ ];
796
+ for (const [keyword, value] of ymlDbs) {
797
+ if (c.includes(keyword)) {
798
+ if (!stack.database) stack.database = value;
799
+ if (!stack.databases.includes(value)) stack.databases.push(value);
800
+ }
801
+ }
802
+ if (H2_REGEX.test(c)) {
803
+ if (!stack.database) stack.database = "h2";
804
+ if (!stack.databases.includes("h2")) stack.databases.push("h2");
805
+ }
806
+ if (c.includes("sqlite")) {
807
+ if (!stack.database) stack.database = "sqlite";
808
+ if (!stack.databases.includes("sqlite")) stack.databases.push("sqlite");
809
+ }
408
810
  if (!stack.port) {
409
- const pm = c.match(/server:\s*\n\s*port:\s*(\d+)/) || c.match(/server\.port\s*[=:]\s*(\d+)/);
410
- if (pm) stack.port = parseInt(pm[1]);
811
+ // Port Spring Boot accepts both plain numeric values and
812
+ // property placeholders with a default:
813
+ // port: 8090
814
+ // port: ${APP_PORT:8090} ← Spring placeholder, default 8090
815
+ // server.port=8090 ← .properties-style, yml-inline
816
+ // server.port=${SERVER_PORT:8090}
817
+ //
818
+ // Without the placeholder patterns (3)/(4), a Spring Boot yml
819
+ // using `port: ${APP_PORT:8090}` produces stack.port=null, leaving
820
+ // the LLM to guess (e.g. assuming the "port 8080" Spring Boot
821
+ // framework default).
822
+ const portPatterns = [
823
+ // (1) direct numeric literal in yml `server:\n port: N`
824
+ // (port immediately after server: with no intermediate keys)
825
+ /server:\s*\n\s*port:\s*(\d+)/,
826
+ // (2) flat-key style `server.port=N` or `server.port: N`
827
+ /server\.port\s*[=:]\s*(\d+)/,
828
+ // (3) placeholder-with-default in yml `server:\n port: ${VAR:N}`
829
+ // — capture the default value, which is what the app falls back
830
+ // to when VAR is unset (the most common dev/local scenario)
831
+ /server:\s*\n\s*port:\s*\$\{[^}:]+:(\d+)\}/,
832
+ // (4) placeholder-with-default in flat-key form
833
+ /server\.port\s*[=:]\s*\$\{[^}:]+:(\d+)\}/,
834
+ // (5) v2.4.0 — nested-block yml: `server:` block with intermediate
835
+ // keys (e.g. `ssl:`, `http:`, `error:`, `tomcat:`, `compression:`)
836
+ // BEFORE `port:`. Real Spring Boot configs often look like:
837
+ // server:
838
+ // ssl:
839
+ // key-store: ...
840
+ // port: 8443
841
+ // Pre-v2.4.0 pattern (1) requires `port:` immediately after
842
+ // `server:\n`, missing this common form. The lazy-quantified
843
+ // gap allows up to ~20000 chars between `server:` and `port:`,
844
+ // while the leading-whitespace constraint on `port:` ensures
845
+ // we match an INDENTED key (still inside the server: block),
846
+ // not an outdented sibling key at column 0.
847
+ //
848
+ // v2.4.0: window expanded from 2000 to 20000 chars after
849
+ // observing an enterprise YAML where the `server:` block
850
+ // contained ssl/http/tomcat/compression children spanning
851
+ // ~3000 chars before `port: 8443`. The 2000 limit silently
852
+ // missed the port and the detector defaulted to the Spring
853
+ // Boot 8080 fallback.
854
+ /^server:[\s\S]{0,20000}?\n[ \t]+port:\s*(\d+)/m,
855
+ // (6) v2.4.0 — same nested-block form with placeholder-with-default
856
+ /^server:[\s\S]{0,20000}?\n[ \t]+port:\s*\$\{[^}:]+:(\d+)\}/m,
857
+ ];
858
+ for (const re of portPatterns) {
859
+ const pm = c.match(re);
860
+ if (pm) { stack.port = parseInt(pm[1]); break; }
861
+ }
411
862
  }
863
+
864
+ // Logging framework detection from yml. `logging.config:
865
+ // classpath:logback-app.xml` tells us Logback is primary; bare
866
+ // mentions of log4jdbc in the doc mean the adapter is in use.
867
+ detectLogging(stack, c);
412
868
  }
413
869
 
414
870
  // .env
@@ -417,11 +873,21 @@ async function detectStack(ROOT) {
417
873
  if (existsSafe(ep)) {
418
874
  const ec = readFileSafe(ep);
419
875
  if (ec && ec.includes("DATABASE_URL")) {
420
- // .env: original checks postgres (not postgresql), no oracle/h2
421
- if (ec.includes("postgres") && !stack.database) stack.database = "postgresql";
422
- if (ec.includes("mysql") && !stack.database) stack.database = "mysql";
423
- if (ec.includes("mongodb") && !stack.database) stack.database = "mongodb";
424
- if (ec.includes("sqlite") && !stack.database) stack.database = "sqlite";
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
+ }
890
+ }
425
891
  }
426
892
  }
427
893
  }
@@ -476,6 +942,20 @@ async function detectStack(ROOT) {
476
942
  }
477
943
  }
478
944
 
945
+ // v2.4.0 — Spring Boot ships Logback as the default logging implementation
946
+ // via spring-boot-starter (transitively). Most projects do not declare
947
+ // `ch.qos.logback:logback-classic` explicitly because the starter brings
948
+ // it in. The dependency-only LOGGING_RULES regex therefore misses Logback
949
+ // for the common case. Fill in the default unless the project has
950
+ // explicitly opted into log4j2 (in which case spring-boot-starter-log4j2
951
+ // would replace the Logback default).
952
+ if (stack.framework === "spring-boot"
953
+ && !stack.loggingFrameworks.includes("log4j2")
954
+ && !stack.loggingFrameworks.includes("logback")) {
955
+ stack.loggingFrameworks.push("logback");
956
+ stack.detected.push("logback (spring-boot default)");
957
+ }
958
+
479
959
  return stack;
480
960
  }
481
961