claudeos-core 2.5.0 → 2.5.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.
package/lib/env-parser.js CHANGED
@@ -220,6 +220,54 @@ function extractApiTarget(vars) {
220
220
  const SENSITIVE_VAR_PATTERNS = [
221
221
  /password/i,
222
222
  /passwd/i,
223
+ // Abbreviated form (`DB_PASS`, `MYSQL_PASS`, bare `PASS`). Anchored to a
224
+ // name-segment boundary (start / `_` / `-`) rather than a bare substring,
225
+ // so `BYPASS_AUTH`, `PASSENGER_NAME` and `COMPASS_URL` are NOT swept up.
226
+ // maskUrlCredentials' PARAM_RE has accepted `pass` as a connection
227
+ // parameter name since v2.5.0; this closes the same gap on the env-key
228
+ // side, where the value is copied verbatim into project-analysis.json.
229
+ //
230
+ // KNOWN, ACCEPTED OVER-MATCH: a leading `PASS_` segment also matches
231
+ // benign names such as `PASS_RATE`. Narrowing to a trailing segment
232
+ // (`/(^|[_-])pass$/i`) would fix that but drop `DB_PASS_2` / `PASS_FILE`.
233
+ // The two failure modes are not symmetric — over-redaction costs one
234
+ // config fact in project-analysis.json, under-redaction writes a live
235
+ // credential into a file the Pass 3/4 prompts tell the model to read — so
236
+ // the broader rule stands. Do not narrow it without re-reading this.
237
+ /(^|[_-])pass([_-]|$)/i,
238
+ // Same segment-anchored treatment for the `PW` abbreviation
239
+ // (`DB_PW`, `ADMIN_PW`, `ROOT_PW`).
240
+ /(^|[_-])pw([_-]|$)/i,
241
+ /passphrase/i,
242
+ // Trailing-segment only: `PASSWORD_PEPPER` / `PEPPER` are the secret,
243
+ // `PEPPER_ROUNDS` is a cost parameter.
244
+ /(^|[_-])pepper$/i,
245
+ // Anchored so `SSH_KEYSCAN_HOSTS` (a host list) is not swept up, while
246
+ // `SSH_KEY` / `SSH_KEY_PATH` still are.
247
+ /ssh[_-]?key([_-]|$)/i,
248
+ /sign(ing)?[_-]?key([_-]|$)/i,
249
+ // Specific well-known secret-bearing `*_KEY` names. Deliberately NOT a
250
+ // blanket `/(^|[_-])key([_-]|$)/i`: that would also redact `ROUTING_KEY`,
251
+ // `PARTITION_KEY`, `SORT_KEY`, `IDEMPOTENCY_KEY` and `FOREIGN_KEY_CHECKS`,
252
+ // which are architecture facts this tool exists to document. Each name is
253
+ // anchored at its trailing boundary so `MASTER_KEYSPACE` (Cassandra) and
254
+ // `SERVER_KEYSTORE_PATH` are not swept up.
255
+ //
256
+ // This is a curated list and will always trail real-world naming. If a
257
+ // leak is found, add the specific name here rather than widening to a
258
+ // blanket `key` rule.
259
+ /master[_-]?key([_-]|$)/i,
260
+ /deploy[_-]?key([_-]|$)/i,
261
+ /license[_-]?key([_-]|$)/i,
262
+ /server[_-]?key([_-]|$)/i,
263
+ // Bare `SERVICE_ACCOUNT` (the JSON blob itself) and its secret-bearing
264
+ // suffixes only. `SERVICE_ACCOUNT_EMAIL` / `_NAME` / `_ID` identify the
265
+ // account, they do not authenticate as it — those are config facts.
266
+ // Two patterns, not one with an optional group: an optional group matches
267
+ // empty and the trailing `[_-]` then swallows the separator, which makes
268
+ // `SERVICE_ACCOUNT_EMAIL` match after all.
269
+ /service[_-]?account$/i,
270
+ /service[_-]?account[_-](keys?|json|file|secret|token|creds?|credentials?)([_-]|$)/i,
223
271
  /secret/i,
224
272
  /api[_-]?key/i,
225
273
  /access[_-]?key/i,
@@ -253,6 +301,108 @@ function isSensitiveVarName(name) {
253
301
  * Scheme, host, port, path and query are preserved so consumers can still
254
302
  * identify the DB engine / host. Non-URL values pass through unchanged.
255
303
  */
304
+ // The userinfo rule maskUrlCredentials() applies. Shared so the backstop below
305
+ // can ask "did this already get masked?" instead of guessing from the result.
306
+ const USERINFO_RE = /^([a-z][a-z0-9+.:-]*:\/\/)([^/?#\s]*)@([^@/?#\s]+)/i;
307
+ // Schemes whose values routinely carry an `@` inside a PATH: scoped npm
308
+ // packages, image variants, `/users/@me`. A DSN scheme essentially never does.
309
+ const WEB_SCHEME_RE = /^(?:https?|wss?)$/i;
310
+ // A token that can follow the `@` terminating an authority: a host, an IPv6
311
+ // literal in brackets, optionally `:port`, then end-of-value or a delimiter.
312
+ const HOST_AFTER_AT_RE = /^(?:\[[0-9A-Fa-f:.]+\]|[A-Za-z0-9._~%-]+)(?::\d+)?(?:[/?#]|$)/;
313
+ // A complete `host[:port]` with a NUMERIC port.
314
+ const HOST_PORT_RE = /^(?:[A-Za-z0-9._~-]+)(?::\d+)?$/;
315
+
316
+ /**
317
+ * v2.5.2 — True when a `scheme://…` value carries userinfo that
318
+ * maskUrlCredentials() could not rewrite, because the password contains a raw
319
+ * `/`, `?`, `#` or space and so pushed the real authority past the point where
320
+ * the userinfo rule is forced to stop.
321
+ *
322
+ * The earlier implementation guessed from the TRUNCATED authority — the text
323
+ * before the first `/?#` — and asked whether the part after its `:` was
324
+ * all-digits. That inverted the test for the most common leak of all: in
325
+ * `postgres://user:12345/6@db/app` the truncated authority is `user:12345`,
326
+ * whose tail IS all digits, so a password merely BEGINNING with digits (which
327
+ * base64-generated passwords routinely do) was waved through. It also could
328
+ * not see a password holding a space, since the userinfo rule's own character
329
+ * class excludes whitespace.
330
+ *
331
+ * This version instead locates the `@` that actually terminates an authority
332
+ * and decides from there. `user:12345` and `a.com:8080` are syntactically
333
+ * indistinguishable, so where ambiguity is irreducible the scheme breaks the
334
+ * tie: for a DSN the value is treated as credentials, for http/https/ws/wss as
335
+ * a path.
336
+ *
337
+ * postgres://u:p/w@host/db true password holds `/`
338
+ * postgres://user:12345/6@db/app true digit-leading password
339
+ * postgres://u:pa ss@host/db true password holds a space
340
+ * redis://:pw?x@host/0 true password holds `?`
341
+ * postgres://u:p@host/db false the userinfo rule handles it
342
+ * https://cdn.example.com/npm/@x/y false no credential shape at all
343
+ * http://a.com:8080/img/@2x.png false web scheme, complete host:port
344
+ * http://[::1]:8080/img/@2x.png false bracketed IPv6 is a host
345
+ * mongodb://h:port/db?x=a@b false the `@` sits in the query
346
+ * https://api:${PORT}/v1/@me false unexpanded template
347
+ */
348
+ function hasUnmaskedUrlCredentials(value) {
349
+ if (typeof value !== "string") return false;
350
+ const m = value.match(/^([a-z][a-z0-9+.-]*(?::[a-z][a-z0-9+.-]*)*):\/\/(.*)$/i);
351
+ if (!m) return false;
352
+ const rest = m[2];
353
+ if (!rest || !rest.includes("@")) return false;
354
+ // An unexpanded `${VAR}` is a template, not a live secret. parseEnvContent
355
+ // deliberately does not expand these, so redacting one loses a host for
356
+ // nothing.
357
+ if (/\$\{[^}]*\}/.test(rest)) return false;
358
+ // Already masked by the userinfo rule — nothing is hidden.
359
+ if (USERINFO_RE.test(value)) return false;
360
+
361
+ const scheme = m[1].split(":")[0].toLowerCase(); // `jdbc:postgresql` → `jdbc`
362
+ // What a strict parser would read as the authority: everything up to the
363
+ // first `/`, `?` or `#`.
364
+ const head = rest.slice(0, rest.search(/[/?#]/) === -1 ? rest.length : rest.search(/[/?#]/));
365
+ // A bracketed IPv6 literal can only ever be a host, never userinfo.
366
+ if (head.startsWith("[")) return false;
367
+ // No `user:secret` shape at all. The empty-user form (`redis://:pw@host`)
368
+ // starts with `:`, so it is admitted here.
369
+ if (!head.includes(":")) return false;
370
+ // For a web scheme, a head that is ALREADY a complete `host:port` means the
371
+ // authority ended there and the `@` belongs to the path.
372
+ if (WEB_SCHEME_RE.test(scheme) && HOST_PORT_RE.test(head)) return false;
373
+
374
+ // The `@` that would terminate the real authority: the last one followed by
375
+ // something that can be a host.
376
+ // The loop stops at `i > 0`, NOT `i !== -1`. `String.prototype.lastIndexOf`
377
+ // clamps a negative `fromIndex` to 0 rather than returning -1, so an `@`
378
+ // sitting at index 0 that fails the host test re-finds itself forever and
379
+ // hangs `init` at 100% CPU with no error (`postgres://@ :x` reproduces it).
380
+ // Stopping at index 0 loses nothing: an `@` there means empty userinfo.
381
+ for (let i = rest.lastIndexOf("@"); i > 0; i = rest.lastIndexOf("@", i - 1)) {
382
+ // `continue`, NOT `return false`. Abandoning the whole search on the first
383
+ // `@` that turns out to be query content let a DSN carrying an email or a
384
+ // redirect URL in its query string defeat the backstop entirely:
385
+ // `postgres://app:pa/ss@db/app?redirect=user@host` starts at the LAST `@`,
386
+ // decides it is query content, and never examines the earlier `@` that
387
+ // terminates the real authority.
388
+ if (!HOST_AFTER_AT_RE.test(rest.slice(i + 1))) continue;
389
+ // If a `?` precedes this `@` AND a `/` precedes that `?`, then a path had
390
+ // already begun before the query started, so the authority was long since
391
+ // over and this `@` is query content (`mongodb://h:port/db?x=a@b`). When
392
+ // the `?` comes before any `/` it is inside the password instead
393
+ // (`redis://:pw?x@host/0`).
394
+ const q = rest.indexOf("?");
395
+ if (q !== -1 && q < i && rest.slice(0, q).includes("/")) continue;
396
+ return true;
397
+ }
398
+ return false;
399
+ }
400
+
401
+ const REDACTED = "***REDACTED***";
402
+ // Scalar env-derived fields (host, apiTarget) are rendered directly into
403
+ // generated docs, so the sentinel must never become their value.
404
+ const nullIfRedacted = (v) => (v === REDACTED ? null : v);
405
+
256
406
  function maskUrlCredentials(value) {
257
407
  if (typeof value !== "string") return value;
258
408
  // Scheme may itself contain `:` (`jdbc:postgresql://`, `jdbc:mysql://`).
@@ -270,9 +420,36 @@ function maskUrlCredentials(value) {
270
420
  // The parameter NAME is kept, the value becomes `***`.
271
421
  const PARAM_RE = /([?&;](?:password|passwd|pwd|pass|secret|token|access[_-]?key|secret[_-]?key|api[_-]?key|sas|signature)=)[^&;\s]*/gi;
272
422
  if (/^[a-z][a-z0-9+.:-]*:\/\//i.test(value)) {
273
- return value
274
- .replace(/^([a-z][a-z0-9+.:-]*:\/\/)([^/?#\s]*)@([^@/?#\s]+)/i, "$1***:***@$3")
423
+ const userinfoMasked = USERINFO_RE.test(value);
424
+ const masked = value
425
+ .replace(USERINFO_RE, "$1***:***@$3")
275
426
  .replace(PARAM_RE, "$1***");
427
+ // v2.5.2 — last-resort backstop for a password containing a raw `/`, `?`
428
+ // or `#` (`postgres://u:p/w@host/db`). The rule above deliberately does
429
+ // not rewrite an `@` that appears after the first `/` — such an `@`
430
+ // normally belongs to the path (`https://cdn.example.com/npm/@scope/pkg`)
431
+ // and masking it would replace the real host. The value therefore passed
432
+ // through verbatim, and because its key is `DATABASE_URL` the key-name
433
+ // rule did not backstop it either: it was the one combination that could
434
+ // still write a plaintext password into project-analysis.json, which
435
+ // Pass 3/4 prompts instruct the model to read.
436
+ //
437
+ // Base64-generated passwords contain `/` routinely, so this is not an
438
+ // exotic shape. When the value carries the tell (see
439
+ // hasUnmaskedUrlCredentials) the WHOLE value is dropped rather than
440
+ // rewritten — the host cannot be located reliably once the authority is
441
+ // ambiguous, and a lost host is a far cheaper failure than a leaked
442
+ // credential. Callers are told which key it was via
443
+ // envInfo.credentialWarnings.
444
+ //
445
+ // Gated on the USERINFO rule not having fired — NOT on `masked === value`.
446
+ // PARAM_RE may rewrite a query parameter on the same value
447
+ // (`postgres://u:p/w@host/db?sslmode=require&password=x`); a
448
+ // "did anything change" gate would then skip the backstop and let `p/w`
449
+ // through verbatim. Whether the userinfo rule matched is the only signal
450
+ // that says the authority itself was masked.
451
+ if (!userinfoMasked && hasUnmaskedUrlCredentials(value)) return "***REDACTED***";
452
+ return masked;
276
453
  }
277
454
  // Scheme-less credentials are recognized ONLY in the Go/MySQL DSN shape
278
455
  // (`user:pw@tcp(host:3306)/db`, `user:pw@unix(/path)/db`); the password may
@@ -329,9 +506,25 @@ function readStackEnvInfo(root) {
329
506
  return {
330
507
  source: file,
331
508
  vars: redactSensitiveVars(vars),
509
+ // v2.5.2 — the keys whose value `maskUrlCredentials` dropped whole rather
510
+ // than partially masking. The credential is not leaked, but the user loses
511
+ // the host for that key, so `init` names the keys in its Phase 1 summary
512
+ // instead of silently swallowing them. Key NAMES only, never any part of
513
+ // the value.
514
+ //
515
+ // Derived from what `maskUrlCredentials` ACTUALLY returned, so the list can
516
+ // never name a key whose value was kept (masked) rather than dropped. The
517
+ // `!== REDACTED` guard covers an env value that is literally the sentinel.
518
+ credentialWarnings: Object.keys(vars).filter(
519
+ k => !isSensitiveVarName(k) && vars[k] !== REDACTED && maskUrlCredentials(vars[k]) === REDACTED
520
+ ),
332
521
  port: extractPort(vars),
333
- host: maskUrlCredentials(extractHost(vars)),
334
- apiTarget: maskUrlCredentials(extractApiTarget(vars)),
522
+ // A value the backstop dropped whole must not travel on as the literal
523
+ // sentinel: `host` / `apiTarget` are rendered straight into CLAUDE.md §3,
524
+ // and the scaffold's only sentinel guard covers `envInfo.vars`. Null makes
525
+ // the row simply absent, which is what the scaffold already handles.
526
+ host: nullIfRedacted(maskUrlCredentials(extractHost(vars))),
527
+ apiTarget: nullIfRedacted(maskUrlCredentials(extractApiTarget(vars))),
335
528
  };
336
529
  }
337
530
 
@@ -346,6 +539,7 @@ module.exports = {
346
539
  isSensitiveVarName,
347
540
  redactSensitiveVars,
348
541
  maskUrlCredentials,
542
+ hasUnmaskedUrlCredentials,
349
543
  // Exported for test visibility:
350
544
  ENV_FILE_ORDER,
351
545
  PORT_VAR_KEYS,
package/package.json CHANGED
@@ -1,92 +1,92 @@
1
- {
2
- "name": "claudeos-core",
3
- "version": "2.5.0",
4
- "description": "Auto-generate Claude Code documentation from your actual source code — Standards, Rules, Skills, and Guides tailored to your project",
5
- "main": "bin/cli.js",
6
- "bin": {
7
- "claudeos-core": "bin/cli.js"
8
- },
9
- "files": [
10
- "bin/",
11
- "lib/",
12
- "claude-md-validator/",
13
- "content-validator/",
14
- "health-checker/",
15
- "manifest-generator/",
16
- "pass-json-validator/",
17
- "pass-prompts/",
18
- "plan-installer/",
19
- "plan-validator/",
20
- "sync-checker/",
21
- "bootstrap.sh",
22
- "README.md",
23
- "README.ko.md",
24
- "LICENSE",
25
- "CHANGELOG.md",
26
- "CONTRIBUTING.md",
27
- "CODE_OF_CONDUCT.md",
28
- "SECURITY.md",
29
- "README.zh-CN.md",
30
- "README.ja.md",
31
- "README.es.md",
32
- "README.vi.md",
33
- "README.hi.md",
34
- "README.ru.md",
35
- "README.fr.md",
36
- "README.de.md"
37
- ],
38
- "scripts": {
39
- "init": "node bin/cli.js init",
40
- "lint": "node bin/cli.js lint",
41
- "health": "node bin/cli.js health",
42
- "validate": "node bin/cli.js validate",
43
- "refresh": "node bin/cli.js refresh",
44
- "restore": "node bin/cli.js restore",
45
- "pretest": "node -e \"try{require('glob')}catch(e){process.exit(1)}\" || npm install",
46
- "test": "node scripts/run-tests.js",
47
- "test:health": "node health-checker/index.js"
48
- },
49
- "keywords": [
50
- "claude-code",
51
- "automation",
52
- "code-analysis",
53
- "CLAUDE.md",
54
- "standards",
55
- "rules",
56
- "skills",
57
- "scaffolding",
58
- "i18n",
59
- "multi-language",
60
- "spring-boot",
61
- "kotlin",
62
- "exposed",
63
- "jooq",
64
- "cqrs",
65
- "bff",
66
- "multi-module",
67
- "monorepo",
68
- "nextjs",
69
- "express",
70
- "fastify",
71
- "angular",
72
- "django",
73
- "fastapi"
74
- ],
75
- "author": "claudeos-core <claudeoscore@gmail.com> (https://github.com/claudeos-core)",
76
- "license": "ISC",
77
- "repository": {
78
- "type": "git",
79
- "url": "git+https://github.com/claudeos-core/claudeos-core.git"
80
- },
81
- "homepage": "https://github.com/claudeos-core/claudeos-core#readme",
82
- "bugs": {
83
- "url": "https://github.com/claudeos-core/claudeos-core/issues"
84
- },
85
- "engines": {
86
- "node": ">=18.0.0"
87
- },
88
- "dependencies": {
89
- "glob": "^13.0.6",
90
- "gray-matter": "^4.0.3"
91
- }
92
- }
1
+ {
2
+ "name": "claudeos-core",
3
+ "version": "2.5.2",
4
+ "description": "Auto-generate Claude Code documentation from your actual source code — Standards, Rules, Skills, and Guides tailored to your project",
5
+ "main": "bin/cli.js",
6
+ "bin": {
7
+ "claudeos-core": "bin/cli.js"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "lib/",
12
+ "claude-md-validator/",
13
+ "content-validator/",
14
+ "health-checker/",
15
+ "manifest-generator/",
16
+ "pass-json-validator/",
17
+ "pass-prompts/",
18
+ "plan-installer/",
19
+ "plan-validator/",
20
+ "sync-checker/",
21
+ "bootstrap.sh",
22
+ "README.md",
23
+ "README.ko.md",
24
+ "LICENSE",
25
+ "CHANGELOG.md",
26
+ "CONTRIBUTING.md",
27
+ "CODE_OF_CONDUCT.md",
28
+ "SECURITY.md",
29
+ "README.zh-CN.md",
30
+ "README.ja.md",
31
+ "README.es.md",
32
+ "README.vi.md",
33
+ "README.hi.md",
34
+ "README.ru.md",
35
+ "README.fr.md",
36
+ "README.de.md"
37
+ ],
38
+ "scripts": {
39
+ "init": "node bin/cli.js init",
40
+ "lint": "node bin/cli.js lint",
41
+ "health": "node bin/cli.js health",
42
+ "validate": "node bin/cli.js validate",
43
+ "refresh": "node bin/cli.js refresh",
44
+ "restore": "node bin/cli.js restore",
45
+ "pretest": "node -e \"try{require('glob')}catch(e){process.exit(1)}\" || npm install",
46
+ "test": "node scripts/run-tests.js",
47
+ "test:health": "node health-checker/index.js"
48
+ },
49
+ "keywords": [
50
+ "claude-code",
51
+ "automation",
52
+ "code-analysis",
53
+ "CLAUDE.md",
54
+ "standards",
55
+ "rules",
56
+ "skills",
57
+ "scaffolding",
58
+ "i18n",
59
+ "multi-language",
60
+ "spring-boot",
61
+ "kotlin",
62
+ "exposed",
63
+ "jooq",
64
+ "cqrs",
65
+ "bff",
66
+ "multi-module",
67
+ "monorepo",
68
+ "nextjs",
69
+ "express",
70
+ "fastify",
71
+ "angular",
72
+ "django",
73
+ "fastapi"
74
+ ],
75
+ "author": "claudeos-core <claudeoscore@gmail.com> (https://github.com/claudeos-core)",
76
+ "license": "ISC",
77
+ "repository": {
78
+ "type": "git",
79
+ "url": "git+https://github.com/claudeos-core/claudeos-core.git"
80
+ },
81
+ "homepage": "https://github.com/claudeos-core/claudeos-core#readme",
82
+ "bugs": {
83
+ "url": "https://github.com/claudeos-core/claudeos-core/issues"
84
+ },
85
+ "engines": {
86
+ "node": ">=18.0.0"
87
+ },
88
+ "dependencies": {
89
+ "glob": "^13.0.6",
90
+ "gray-matter": "^4.0.3"
91
+ }
92
+ }
@@ -228,7 +228,7 @@ async function main() {
228
228
  const framework = paData.stack?.framework;
229
229
  const language = paData.stack?.language;
230
230
  const architecture = paData.stack?.architecture;
231
- isBackend = !frontend || ["express", "nestjs", "fastify", "django", "fastapi", "flask", "spring-boot"].includes(framework);
231
+ isBackend = !frontend || ["express", "nestjs", "fastify", "django", "fastapi", "flask", "spring-boot", "spring-framework"].includes(framework);
232
232
  isKotlin = language === "kotlin";
233
233
  isKotlinCqrs = isKotlin && (architecture === "cqrs" || paData.stack?.multiModule);
234
234
  } catch (_e) { /* If project-analysis parsing fails, conservatively assume backend */ }
@@ -21,8 +21,16 @@ Required reads (if the file exists):
21
21
  the variable inside `ext { ... }` or `<properties>`. Record the
22
22
  ACTUAL Java version — do NOT infer "Java 17+" from the Spring
23
23
  Boot version.
24
- - Spring Boot version: verify it matches `project-analysis.json`'s
25
- frameworkVersion field; if they disagree, trust the build file.
24
+ - Spring version: verify it matches `project-analysis.json`'s
25
+ frameworkVersion field (Spring Boot, or the Spring Framework line when
26
+ `framework` is `"spring-framework"` — a pre-Boot project); if they
27
+ disagree, trust the build file. For a non-Boot project, also read
28
+ `WEB-INF/web.xml` and the Spring XML configs it references
29
+ (`applicationContext*.xml`, `*-servlet.xml`, `*.properties`) — they
30
+ hold what `application.yml` holds in a Boot project, and step 2 below
31
+ will find no `application.yml`. Do NOT describe Boot features
32
+ (auto-configuration, starters, actuator, `@SpringBootApplication`)
33
+ for a project that does not declare Boot.
26
34
  - Dependencies that indicate specific patterns (MyBatis/iBatis/JPA,
27
35
  multiple DB drivers, Jasypt, JWT library, Logback extras).
28
36
 
@@ -68,7 +68,8 @@ Generation targets:
68
68
  Content within each section adapts to this project based on pass2-merged.json.
69
69
  The scaffold's validation checklist MUST pass.
70
70
 
71
- Stack-specific hints for this project (Java Spring Boot):
71
+ Stack-specific hints for this project (Java Spring Boot, or Spring Framework
72
+ without Boot when `project-analysis.json` says `framework: "spring-framework"`):
72
73
  - Project type for Section 1 PROJECT_CONTEXT: "Backend Application" or "REST API Server"
73
74
  - Architecture diagram (Section 4): layered architecture (Controller → Service → Mapper/Repository)
74
75
  - Section 2 should include: JDK version, Gradle/Maven, DB, session/cache, server port
@@ -52,7 +52,23 @@ async function main() {
52
52
  console.log(` Database: ${stack.database || "none"}`);
53
53
  }
54
54
  console.log(` ORM: ${stack.orm || "none"}`);
55
- console.log(` PackageMgr: ${stack.packageManager || "none"}\n`);
55
+ console.log(` PackageMgr: ${stack.packageManager || "none"}`);
56
+ // v2.5.2 — a URL value whose password holds a raw `/`, `?` or `#` cannot
57
+ // have its userinfo rewritten without risking the host, so the whole value
58
+ // is dropped. Say so: silently losing a DATABASE_URL would otherwise look
59
+ // like a detection bug. Key names only, never any part of the value.
60
+ // Both env sources are reported. A sub-directory SPA keeps its own
61
+ // `frontend/.env`, read into `stack.frontendEnvInfo`, and reading only
62
+ // `stack.envInfo` swallowed exactly the drop this warning exists to announce.
63
+ for (const info of [stack.envInfo, stack.frontendEnvInfo]) {
64
+ const credWarn = (info && info.credentialWarnings) || [];
65
+ if (!credWarn.length) continue;
66
+ console.warn(`\n ⚠️ Credential-shaped value dropped from ${credWarn.join(", ")} (${info.source}).`);
67
+ console.warn(" The password contains a raw '/', '?', '#' or space, which makes the URL's host");
68
+ console.warn(" ambiguous, so the value was redacted whole rather than partially masked.");
69
+ console.warn(" Percent-encode the password (e.g. '/' as %2F) to keep the host visible.");
70
+ }
71
+ console.log("");
56
72
 
57
73
  // Phase 2: Structure scan
58
74
  console.log(" [Phase 2] Scanning structure...");