claudeos-core 2.3.1 → 2.3.2

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.
@@ -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: [],
@@ -111,16 +278,105 @@ async function detectStack(ROOT) {
111
278
  ];
112
279
  for (const pattern of svPatterns) {
113
280
  const sv = g.match(pattern);
114
- if (sv) { stack.frameworkVersion = sv[1]; break; }
281
+ if (sv) {
282
+ // Reject captures that are variable references like `${var}`
283
+ // — those need resolution via the fallback block below.
284
+ if (/^\$\{/.test(sv[1])) continue;
285
+ stack.frameworkVersion = sv[1];
286
+ break;
287
+ }
288
+ }
289
+ // Fallback: some projects centralize the Spring Boot version in
290
+ // an `ext { springBootVersion = '3.5.5' }` block and reference
291
+ // it from the plugin declaration (`version "${springBootVersion}"`).
292
+ // If none of the above patterns matched but we can find a
293
+ // variable-reference form, resolve the variable inside the same
294
+ // build.gradle.
295
+ if (!stack.frameworkVersion) {
296
+ const svVarRef = g.match(/springframework\.boot[^\n]*version\s*['"]\$\{?(\w+)\}?['"]/);
297
+ if (svVarRef) {
298
+ const varName = svVarRef[1];
299
+ const escapedVar = varName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
300
+ const varDef = new RegExp(`${escapedVar}\\s*=\\s*['"]([\\d.]+)['"]`);
301
+ const varVal = g.match(varDef);
302
+ if (varVal) stack.frameworkVersion = varVal[1];
303
+ }
304
+ }
305
+ // Java version — Gradle writes this in several forms. Try each
306
+ // pattern until one matches. Earlier patterns take precedence.
307
+ //
308
+ // Pattern 1: direct numeric literal (most common, v1.x era)
309
+ // sourceCompatibility = 21
310
+ // sourceCompatibility = '21'
311
+ // sourceCompatibility = "21"
312
+ //
313
+ // Pattern 2: JavaVersion enum (common with Spring Initializr)
314
+ // sourceCompatibility = JavaVersion.VERSION_21
315
+ // sourceCompatibility = JavaVersion.VERSION_1_8 (Java 8)
316
+ //
317
+ // Pattern 3: Gradle toolchain block (modern, Gradle 6.7+)
318
+ // java { toolchain { languageVersion = JavaLanguageVersion.of(21) } }
319
+ //
320
+ // Pattern 4: ext variable reference (common in team/enterprise
321
+ // projects that centralize versions)
322
+ // ext { javaVersion = '21' }
323
+ // java { sourceCompatibility = "${javaVersion}" }
324
+ //
325
+ // Without pattern 4, an ext-variable-reference-only build.gradle
326
+ // produces languageVersion=null, leaving the LLM to guess (e.g.
327
+ // "Java 17+" for a Spring Boot 3.x project that actually targets
328
+ // Java 21).
329
+ const javaVersionPatterns = [
330
+ // (1) numeric literal on sourceCompatibility or targetCompatibility
331
+ /sourceCompatibility\s*=\s*['"]?(\d+)['"]?/,
332
+ /targetCompatibility\s*=\s*['"]?(\d+)['"]?/,
333
+ // (2) JavaVersion enum — supports both VERSION_21 and VERSION_1_8
334
+ /JavaVersion\.VERSION_(?:1_)?(\d+)/,
335
+ // (3) toolchain block
336
+ /JavaLanguageVersion\.of\s*\(\s*(\d+)\s*\)/,
337
+ ];
338
+ for (const pattern of javaVersionPatterns) {
339
+ const m = g.match(pattern);
340
+ if (m) { stack.languageVersion = m[1]; break; }
341
+ }
342
+ // (4) ext variable reference fallback — if the Compatibility
343
+ // assignment used "${varName}" we now resolve varName inside the
344
+ // same file's ext block. We only run this if the numeric patterns
345
+ // above did not already find a value.
346
+ if (!stack.languageVersion) {
347
+ const varRefMatch = g.match(/(?:source|target)Compatibility\s*=\s*["']?\$\{?(\w+)\}?["']?/);
348
+ if (varRefMatch) {
349
+ const varName = varRefMatch[1];
350
+ // Escape the variable name for use in a RegExp. In practice
351
+ // Gradle variable names are [A-Za-z0-9_], so escaping is a
352
+ // defensive guard against unexpected characters, not a
353
+ // practical necessity for today's inputs.
354
+ const escapedVarName = varName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
355
+ const extAssign = new RegExp(`${escapedVarName}\\s*=\\s*['"]?(\\d+)['"]?`);
356
+ const extVal = g.match(extAssign);
357
+ if (extVal) stack.languageVersion = extVal[1];
358
+ }
115
359
  }
116
- const jv = g.match(/sourceCompatibility\s*=\s*['"]?(\d+)['"]?/);
117
- if (jv) stack.languageVersion = jv[1];
118
360
 
119
- detectFirst(stack, "orm", g, GRADLE_ORM_RULES);
361
+ // iBatis detection: runs BEFORE generic ORM_RULES because the
362
+ // generic table's "mybatis" keyword (substring match) would
363
+ // happily match Apache iBatis coords like `ibatis-core` if the
364
+ // order were reversed — "mybatis" is not a substring of
365
+ // "ibatis", but a future maintainer adding "ibatis" to the
366
+ // generic table would create ambiguity. Explicit precedence here.
367
+ if (IBATIS_REGEX.test(g)) {
368
+ stack.orm = "ibatis";
369
+ stack.detected.push("ibatis");
370
+ } else {
371
+ detectFirst(stack, "orm", g, GRADLE_ORM_RULES);
372
+ }
120
373
  // Exclude "postgres" (substring of postgresql — false positive on r2dbc-postgres) and "sqlite" (rare in Gradle deps)
121
374
  // "postgresql" is still matched via DB_KEYWORD_RULES; h2 uses word-boundary check separately
122
375
  detectDb(stack, g, DB_KEYWORD_RULES.filter(([kw]) => !["postgres", "sqlite"].includes(kw)));
123
376
 
377
+ // Logging framework detection from Gradle dependencies.
378
+ detectLogging(stack, g);
379
+
124
380
  // Kotlin detection: override language if Kotlin plugin found
125
381
  if (g.includes("kotlin") || g.includes("org.jetbrains.kotlin")) {
126
382
  stack.language = "kotlin"; stack.detected.push("kotlin");
@@ -155,9 +411,18 @@ async function detectStack(ROOT) {
155
411
  if (!stack.orm && vc.includes("exposed")) { stack.orm = "exposed"; stack.detected.push("exposed (catalog)"); }
156
412
  else if (!stack.orm && vc.includes("jooq")) { stack.orm = "jooq"; stack.detected.push("jooq (catalog)"); }
157
413
  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"; }
414
+ // DBs from version catalog dual output (primary + array)
415
+ const catalogDbs = [
416
+ ["postgresql", "postgresql"],
417
+ ["mysql", "mysql"],
418
+ ["mongodb", "mongodb"],
419
+ ];
420
+ for (const [keyword, value] of catalogDbs) {
421
+ if (vc.includes(keyword)) {
422
+ if (!stack.database) stack.database = value;
423
+ if (!stack.databases.includes(value)) stack.databases.push(value);
424
+ }
425
+ }
161
426
  if (!stack.language && vc.includes("kotlin")) {
162
427
  stack.language = "kotlin"; stack.detected.push("kotlin (catalog)");
163
428
  }
@@ -220,17 +485,91 @@ async function detectStack(ROOT) {
220
485
  if (!stack.buildTool) { stack.buildTool = "maven"; stack.language = "java"; stack.detected.push("pom.xml"); }
221
486
  const sv = pom.match(/<spring-boot[^>]*version>([^<]+)/);
222
487
  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);
488
+ // Java version — Maven commonly uses three patterns:
489
+ //
490
+ // Pattern 1: direct <java.version>21</java.version>
491
+ //
492
+ // Pattern 2: <maven.compiler.source>21</maven.compiler.source>
493
+ // (and matching <maven.compiler.target>), used when the project
494
+ // avoids the Spring Boot parent's `java.version` property.
495
+ //
496
+ // Pattern 3: property reference — `<java.version>${project.javaVersion}</java.version>`
497
+ // where `<project.javaVersion>21</project.javaVersion>` is
498
+ // declared earlier in <properties>. Enterprise projects
499
+ // centralize versions this way so all child modules pick up the
500
+ // same value.
501
+ //
502
+ // We try patterns in order (1) → (2) → (3). Pattern 3 is a
503
+ // fallback that resolves the referenced property within the same
504
+ // pom.xml (cross-file resolution — parent pom, BOM — is out of
505
+ // scope; the resulting null falls through to LLM-side analysis).
506
+ const mvnJavaPatterns = [
507
+ /<java\.version>\s*(\d+)\s*<\/java\.version>/,
508
+ /<maven\.compiler\.source>\s*(\d+)\s*<\/maven\.compiler\.source>/,
509
+ /<maven\.compiler\.target>\s*(\d+)\s*<\/maven\.compiler\.target>/,
510
+ ];
511
+ for (const pattern of mvnJavaPatterns) {
512
+ const m = pom.match(pattern);
513
+ if (m) { stack.languageVersion = m[1]; break; }
514
+ }
515
+ // Pattern 3 fallback: if <java.version> references a property,
516
+ // resolve it inside the same pom.
517
+ if (!stack.languageVersion) {
518
+ const propRef = pom.match(/<java\.version>\s*\$\{([^}]+)\}\s*<\/java\.version>/);
519
+ if (propRef) {
520
+ const propName = propRef[1].trim();
521
+ const escapedProp = propName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
522
+ const propDef = new RegExp(`<${escapedProp}>\\s*(\\d+)\\s*</${escapedProp}>`);
523
+ const propVal = pom.match(propDef);
524
+ if (propVal) stack.languageVersion = propVal[1];
525
+ }
526
+ }
527
+ // For dependency detection (framework, ORM, DB, logging), strip
528
+ // XML block comments first. A `<!-- <dependency>...</dependency> -->`
529
+ // block is a standard Maven pattern for disabling a dep during
530
+ // migration (e.g., commenting out a legacy log4j 1.x dep after
531
+ // switching to Spring Boot's managed Logback); without stripping,
532
+ // those deps are counted as "in use". The `<properties>` scan
533
+ // above stays on the raw `pom` because (a) commented-out property
534
+ // definitions are rare in practice and (b) the property-reference
535
+ // resolution already scopes itself to the declared property name.
536
+ const pomClean = stripComments(pom);
537
+ if (pomClean.includes("spring-boot") && !stack.framework) { stack.framework = "spring-boot"; stack.detected.push("spring-boot"); }
538
+ if (IBATIS_REGEX.test(pomClean)) {
539
+ stack.orm = "ibatis";
540
+ stack.detected.push("ibatis");
541
+ } else {
542
+ detectFirst(stack, "orm", pomClean, MAVEN_ORM_RULES);
543
+ }
227
544
  // 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";
545
+ // DB keyword scan: reuses detectDb's dual-output semantics
546
+ // (primary first-match stack.database; every match →
547
+ // stack.databases). Maven original did not push to `detected`
548
+ // (unlike Gradle), so we preserve that omission here.
549
+ const mvnDbRules = [
550
+ ["postgresql", "postgresql"],
551
+ ["mariadb", "mariadb"],
552
+ ["mysql", "mysql"],
553
+ ["oracle", "oracle"],
554
+ ["mongodb", "mongodb"],
555
+ ["sqlite", "sqlite"],
556
+ ];
557
+ for (const [keyword, value] of mvnDbRules) {
558
+ if (pomClean.includes(keyword)) {
559
+ if (!stack.database) stack.database = value;
560
+ if (!stack.databases.includes(value)) stack.databases.push(value);
561
+ }
562
+ }
563
+ if (H2_REGEX.test(pomClean)) {
564
+ if (!stack.database) stack.database = "h2";
565
+ if (!stack.databases.includes("h2")) stack.databases.push("h2");
566
+ }
567
+
568
+ // Logging framework detection from Maven dependencies. detectLogging
569
+ // does its own comment stripping internally, so passing raw `pom`
570
+ // works correctly — but we pass `pomClean` for consistency with
571
+ // the other Maven dependency scans in this block.
572
+ detectLogging(stack, pomClean);
234
573
  }
235
574
  }
236
575
 
@@ -329,7 +668,12 @@ async function detectStack(ROOT) {
329
668
  stack.orm = ormName; stack.detected.push(ormName);
330
669
  }
331
670
  }
332
- if (deps.mongoose) { if (!stack.database) stack.database = "mongodb"; if (!stack.orm) stack.orm = "mongoose"; stack.detected.push("mongoose"); }
671
+ if (deps.mongoose) {
672
+ if (!stack.database) stack.database = "mongodb";
673
+ if (!stack.databases.includes("mongodb")) stack.databases.push("mongodb");
674
+ if (!stack.orm) stack.orm = "mongoose";
675
+ stack.detected.push("mongoose");
676
+ }
333
677
 
334
678
  // DB
335
679
  const nodeDbRules = [["pg", "postgresql"], ["mysql2", "mysql"], ["mongodb", "mongodb"]];
@@ -383,8 +727,16 @@ async function detectStack(ROOT) {
383
727
  for (const [kw, name] of pyOrmRules) {
384
728
  if (r.includes(kw) && !stack.orm) { stack.orm = name; break; }
385
729
  }
386
- if (r.includes("psycopg") && !stack.database) stack.database = "postgresql";
387
- if (r.includes("mysqlclient") && !stack.database) stack.database = "mysql";
730
+ const pyDbs = [
731
+ ["psycopg", "postgresql"],
732
+ ["mysqlclient", "mysql"],
733
+ ];
734
+ for (const [kw, value] of pyDbs) {
735
+ if (r.includes(kw)) {
736
+ if (!stack.database) stack.database = value;
737
+ if (!stack.databases.includes(value)) stack.databases.push(value);
738
+ }
739
+ }
388
740
  }
389
741
  }
390
742
 
@@ -395,20 +747,94 @@ async function detectStack(ROOT) {
395
747
  }
396
748
 
397
749
  // ── DB from config files ──
398
- const ymls = await glob("**/application*.yml", { cwd: ROOT, absolute: true, ignore: ["**/node_modules/**", "**/build/**", "**/target/**", "**/.gradle/**"] });
750
+ //
751
+ // Glob covers Spring Boot's full configuration-file naming space:
752
+ // - `application.{yml,yaml,properties}` (Spring Initializr default;
753
+ // .yml is most common, .yaml is spec-official, .properties is the
754
+ // framework default when nothing specifies)
755
+ // - `application-*.{yml,yaml,properties}` profile variants
756
+ // (e.g. application-local.yml, application-dev.properties)
757
+ // - `bootstrap.{yml,yaml,properties}` + profile variants
758
+ // (Spring Cloud Config / Consul / Eureka; loaded before `application.*`
759
+ // so must be part of the same scan)
760
+ //
761
+ // The regexes inside the loop are format-agnostic: the `port` regex
762
+ // set covers both yml `server:\n port: N` syntax and .properties
763
+ // `server.port=N` flat-key syntax. DB keyword detection is
764
+ // substring-based and works identically across all three formats.
765
+ const configGlob = "**/{application,bootstrap}*.{yml,yaml,properties}";
766
+ const ymls = await glob(configGlob, {
767
+ cwd: ROOT, absolute: true,
768
+ ignore: ["**/node_modules/**", "**/build/**", "**/target/**", "**/.gradle/**"],
769
+ });
399
770
  for (const y of ymls) {
400
771
  const c = readFileSafe(y);
401
772
  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";
773
+ // DB detection dual output per source file.
774
+ //
775
+ // NOTE on mariadb: earlier versions of this detector deliberately
776
+ // OMITTED mariadb from yml-side keyword matching because some
777
+ // projects mention mariadb in commented-out profile sections or
778
+ // as fallback drivers. With multi-dialect support (`stack.databases`
779
+ // array in v2.3.2+), the cost of over-reporting is lower than the
780
+ // cost of under-reporting — `databases` is an informational list
781
+ // for the LLM, and a false positive is easier to filter out in the
782
+ // prompt than a miss is to detect. So mariadb is now included.
783
+ const ymlDbs = [
784
+ ["postgresql", "postgresql"],
785
+ ["mariadb", "mariadb"],
786
+ ["mysql", "mysql"],
787
+ ["oracle", "oracle"],
788
+ ["mongodb", "mongodb"],
789
+ ];
790
+ for (const [keyword, value] of ymlDbs) {
791
+ if (c.includes(keyword)) {
792
+ if (!stack.database) stack.database = value;
793
+ if (!stack.databases.includes(value)) stack.databases.push(value);
794
+ }
795
+ }
796
+ if (H2_REGEX.test(c)) {
797
+ if (!stack.database) stack.database = "h2";
798
+ if (!stack.databases.includes("h2")) stack.databases.push("h2");
799
+ }
800
+ if (c.includes("sqlite")) {
801
+ if (!stack.database) stack.database = "sqlite";
802
+ if (!stack.databases.includes("sqlite")) stack.databases.push("sqlite");
803
+ }
408
804
  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]);
805
+ // Port Spring Boot accepts both plain numeric values and
806
+ // property placeholders with a default:
807
+ // port: 8090
808
+ // port: ${APP_PORT:8090} ← Spring placeholder, default 8090
809
+ // server.port=8090 ← .properties-style, yml-inline
810
+ // server.port=${SERVER_PORT:8090}
811
+ //
812
+ // Without the placeholder patterns (3)/(4), a Spring Boot yml
813
+ // using `port: ${APP_PORT:8090}` produces stack.port=null, leaving
814
+ // the LLM to guess (e.g. assuming the "port 8080" Spring Boot
815
+ // framework default).
816
+ const portPatterns = [
817
+ // (1) direct numeric literal in yml `server:\n port: N`
818
+ /server:\s*\n\s*port:\s*(\d+)/,
819
+ // (2) flat-key style `server.port=N` or `server.port: N`
820
+ /server\.port\s*[=:]\s*(\d+)/,
821
+ // (3) placeholder-with-default in yml `server:\n port: ${VAR:N}`
822
+ // — capture the default value, which is what the app falls back
823
+ // to when VAR is unset (the most common dev/local scenario)
824
+ /server:\s*\n\s*port:\s*\$\{[^}:]+:(\d+)\}/,
825
+ // (4) placeholder-with-default in flat-key form
826
+ /server\.port\s*[=:]\s*\$\{[^}:]+:(\d+)\}/,
827
+ ];
828
+ for (const re of portPatterns) {
829
+ const pm = c.match(re);
830
+ if (pm) { stack.port = parseInt(pm[1]); break; }
831
+ }
411
832
  }
833
+
834
+ // Logging framework detection from yml. `logging.config:
835
+ // classpath:logback-app.xml` tells us Logback is primary; bare
836
+ // mentions of log4jdbc in the doc mean the adapter is in use.
837
+ detectLogging(stack, c);
412
838
  }
413
839
 
414
840
  // .env
@@ -417,11 +843,21 @@ async function detectStack(ROOT) {
417
843
  if (existsSafe(ep)) {
418
844
  const ec = readFileSafe(ep);
419
845
  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";
846
+ // .env: original checks postgres (not postgresql), no oracle/h2.
847
+ // Preserve that semantics, but update both primary and array
848
+ // outputs together.
849
+ const envDbs = [
850
+ ["postgres", "postgresql"],
851
+ ["mysql", "mysql"],
852
+ ["mongodb", "mongodb"],
853
+ ["sqlite", "sqlite"],
854
+ ];
855
+ for (const [keyword, value] of envDbs) {
856
+ if (ec.includes(keyword)) {
857
+ if (!stack.database) stack.database = value;
858
+ if (!stack.databases.includes(value)) stack.databases.push(value);
859
+ }
860
+ }
425
861
  }
426
862
  }
427
863
  }