claudeos-core 2.4.3 → 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 +93 -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 +10 -10
  6. package/README.ja.md +9 -9
  7. package/README.ko.md +10 -10
  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
@@ -615,6 +615,42 @@ async function runPass3Split(ctx) {
615
615
  },
616
616
  });
617
617
 
618
+ // v2.5.0 — Deterministic allowlist injection ("LLMs guess, code confirms").
619
+ // The `## Allowed Source Paths` section is the single most important
620
+ // anti-hallucination input for 3b/3c/3d, yet until now it reached
621
+ // pass3a-facts.md only if the LLM faithfully hand-copied up to 500 paths
622
+ // out of pass3-context.json — and the 3a validator only checked file
623
+ // length. Node now writes that section itself from project-analysis.json
624
+ // (replacing any LLM-written version), so the allowlist is byte-exact
625
+ // every run, including resumed runs where 3a was skipped.
626
+ {
627
+ const { injectAllowedPathsSection } = require("../../plan-installer/source-paths");
628
+ let collected = null;
629
+ try {
630
+ const pa = JSON.parse(readFile(path.join(GENERATED_DIR, "project-analysis.json")));
631
+ collected = pa && pa.allowedSourcePaths;
632
+ } catch (_e) { /* tolerated — fallback line is written instead */ }
633
+ const before = readFileSafe(factsFile, "");
634
+ if (before.replace(/^\uFEFF/, "").trim().length === 0) {
635
+ // 3a is marked complete in the marker but the facts file is missing/empty
636
+ // (deleted by hand, or a crash between validate and marker write). Do not
637
+ // fabricate a facts file that contains only the allowlist — 3b would run
638
+ // against an empty fact sheet. Surface it and let the user resume/--force.
639
+ throw new InitError(
640
+ "pass3a-facts.md is missing or empty although Pass 3a is marked complete.\n" +
641
+ " Delete claudeos-core/generated/pass3-complete.json to re-run Pass 3a, or use `init --force`."
642
+ );
643
+ }
644
+ const after = injectAllowedPathsSection(before, collected);
645
+ if (after !== before) {
646
+ if (!writeFileSafe(factsFile, after)) {
647
+ throw new InitError("Failed to write the Allowed Source Paths section into pass3a-facts.md.");
648
+ }
649
+ const n = collected && Array.isArray(collected.paths) ? collected.paths.length : 0;
650
+ log(` 📎 Allowed Source Paths section written by orchestrator (${n} ${collected && collected.mode === "rollup" ? "dirs" : "paths"})`);
651
+ }
652
+ }
653
+
618
654
  // ═══ Stage 3b: CLAUDE.md + standard/ + .claude/rules/ ═══════════
619
655
  //
620
656
  // Single batch (domains ≤ 15): keep legacy "3b" marker (backward-compatible).
@@ -699,8 +735,16 @@ async function runPass3Split(ctx) {
699
735
  }
700
736
  }
701
737
  // For every batch, confirm rules/ was generated (at least one staged-rules move must succeed).
738
+ // Count ONLY claudeos-core-managed categories (`NN.` prefix). Since v2.5.0
739
+ // --force/fresh preserve user-authored entries under .claude/rules/, so a
740
+ // plain recursive count could be >0 even when Pass 3 produced nothing.
702
741
  const rulesDir = path.join(PROJECT_ROOT, ".claude/rules");
703
- const rulesCount = countFilesRecursive(rulesDir);
742
+ let rulesCount = 0;
743
+ try {
744
+ for (const e of fs.readdirSync(rulesDir, { withFileTypes: true })) {
745
+ if (e.isDirectory() && /^\d{2}\./.test(e.name)) rulesCount += countFilesRecursive(path.join(rulesDir, e.name));
746
+ }
747
+ } catch (_e) { rulesCount = 0; }
704
748
  if (rulesCount === 0) {
705
749
  problems.push(".claude/rules/ has 0 files (staging-override may have been ignored)");
706
750
  }
@@ -855,7 +899,7 @@ function checkPrerequisites() {
855
899
  );
856
900
  if (!hasProjectMarker) {
857
901
  log(`\n ⚠️ Warning: ${PROJECT_ROOT} does not look like a project root.`);
858
- log(" No .git, package.json, build.gradle, or pom.xml found.");
902
+ log(" No .git, package.json, build.gradle(.kts), pom.xml, or pyproject.toml found.");
859
903
  log(" Run this command from your project directory.\n");
860
904
  }
861
905
 
@@ -910,6 +954,28 @@ async function resolveLanguage(parsedArgs) {
910
954
  return lang;
911
955
  }
912
956
 
957
+ // Remove only the rule categories claudeos-core generates (`NN.` prefixed:
958
+ // 00.core, 10.backend, …, 90.optional). Files/dirs the user authored under
959
+ // .claude/rules/ without that prefix are left untouched. Returns the number
960
+ // of entries removed.
961
+ function wipeManagedRuleCategories(rulesDir) {
962
+ if (!fileExists(rulesDir)) return 0;
963
+ let removed = 0;
964
+ let entries;
965
+ try { entries = fs.readdirSync(rulesDir, { withFileTypes: true }); }
966
+ catch (_e) { return 0; }
967
+ for (const e of entries) {
968
+ // Managed entries are category DIRECTORIES (00.core/, 10.backend/, …).
969
+ // claudeos-core never writes a `NN.`-prefixed FILE at the rules root, so
970
+ // such a file (`.claude/rules/01.team-style.md`) is user-authored — keep it.
971
+ // Guard 2 (zero-rules detection) counts with the same predicate.
972
+ if (!e.isDirectory() || !/^\d{2}\./.test(e.name)) continue;
973
+ fs.rmSync(path.join(rulesDir, e.name), { recursive: true, force: true });
974
+ removed++;
975
+ }
976
+ return removed;
977
+ }
978
+
913
979
  // ─── Stage 3: Resume/Fresh selection ──────────────────────────────
914
980
  // Returns { wasFreshClean: boolean } — the caller uses this to gate the
915
981
  // v1.7.x migration backfill in dispatchPass3.
@@ -930,14 +996,15 @@ async function applyResumeMode(parsedArgs, lang) {
930
996
  // (only .json/.md are unlinked above; directories aren't touched).
931
997
  const stagedDir = path.join(GENERATED_DIR, ".staged-rules");
932
998
  if (fileExists(stagedDir)) fs.rmSync(stagedDir, { recursive: true, force: true });
933
- // Also wipe .claude/rules/ so Guard 2 (zero-rules detection) can't
934
- // false-negative on stale rules from a previous run when the fresh
935
- // Pass 3 run fails silently (e.g. Claude ignores staging-override).
936
- // Step [2] recreates the subdirs from scratch. Any manual edits the
937
- // user made to rule files are lost — acceptable under --force
938
- // ("truly fresh start").
939
- const rulesDir = path.join(PROJECT_ROOT, ".claude/rules");
940
- if (fileExists(rulesDir)) fs.rmSync(rulesDir, { recursive: true, force: true });
999
+ // Also wipe the claudeos-core-managed categories under .claude/rules/
1000
+ // so Guard 2 (zero-rules detection) can't false-negative on stale rules
1001
+ // from a previous run when the fresh Pass 3 run fails silently (e.g.
1002
+ // Claude ignores staging-override). Step [2] recreates the subdirs.
1003
+ // Manual edits to GENERATED rule files are lost — acceptable under
1004
+ // --force ("truly fresh start"). Anything the user placed under
1005
+ // .claude/rules/ that is NOT a claudeos-core category (no `NN.` prefix)
1006
+ // is preserved it was never ours to delete.
1007
+ wipeManagedRuleCategories(path.join(PROJECT_ROOT, ".claude/rules"));
941
1008
  wasFreshClean = true;
942
1009
  log(" 🔄 Previous results deleted (--force)\n");
943
1010
  return { wasFreshClean };
@@ -993,11 +1060,9 @@ async function applyResumeMode(parsedArgs, lang) {
993
1060
  // Clean .staged-rules/ leftover from a prior crashed run (same reason as --force branch).
994
1061
  const stagedDir = path.join(GENERATED_DIR, ".staged-rules");
995
1062
  if (fileExists(stagedDir)) fs.rmSync(stagedDir, { recursive: true, force: true });
996
- // Wipe .claude/rules/ for the same Guard 2 false-negative reason as
997
- // the --force branch. Step [2] recreates the subdirs; any manual
998
- // edits are lost — acceptable under an explicit "fresh" choice.
999
- const rulesDir = path.join(PROJECT_ROOT, ".claude/rules");
1000
- if (fileExists(rulesDir)) fs.rmSync(rulesDir, { recursive: true, force: true });
1063
+ // Wipe managed rule categories for the same Guard 2 false-negative
1064
+ // reason as the --force branch (user-owned, non-`NN.` entries survive).
1065
+ wipeManagedRuleCategories(path.join(PROJECT_ROOT, ".claude/rules"));
1001
1066
  wasFreshClean = true;
1002
1067
  } else if (mode === "continue" && existingPass1.length === 0 && pass2Exists) {
1003
1068
  // pass2 exists but no pass1 → pass2 is stale, force re-run
@@ -1078,7 +1143,7 @@ function ensureDirectories() {
1078
1143
  }
1079
1144
 
1080
1145
  // ─── Stage 5: Load & validate domain-groups.json ──────────────────
1081
- function loadDomainGroups() {
1146
+ function loadDomainGroups({ wasFreshClean = false } = {}) {
1082
1147
  let domainGroups;
1083
1148
  try {
1084
1149
  domainGroups = JSON.parse(
@@ -1089,6 +1154,34 @@ function loadDomainGroups() {
1089
1154
  }
1090
1155
  const totalGroups = domainGroups.totalGroups;
1091
1156
  if (!totalGroups || typeof totalGroups !== "number" || totalGroups < 1) {
1157
+ if (totalGroups === 0) {
1158
+ let stackLine = "";
1159
+ try {
1160
+ const pa = JSON.parse(readFile(path.join(GENERATED_DIR, "project-analysis.json")));
1161
+ const st = pa.stack || {};
1162
+ stackLine = ` Detected stack: language=${st.language || "none"} framework=${st.framework || "none"} frontend=${st.frontend || "none"}\n`;
1163
+ } catch (_e) { /* best effort */ }
1164
+ throw new InitError(
1165
+ "No domains were found in this project, so there is nothing for Pass 1-3 to analyze.\n" +
1166
+ stackLine +
1167
+ " The scanner recognizes layouts such as:\n" +
1168
+ " Java/Kotlin src/main/java/<pkg>/<domain>/controller/*.java, <pkg>/controller/<domain>/*.java,\n" +
1169
+ " <pkg>/controller/*Controller.java, <pkg>/<domain>/adapter/in/web/*.java,\n" +
1170
+ " <pkg>/<feature>/*Controller.kt, <module>/src/main/java/... (Gradle/Maven multi-module)\n" +
1171
+ " Node src/<domain>/*.ts, src/modules/<domain>/, src/{controllers,routes,services}/<domain>.*\n" +
1172
+ " Python app/<domain>/, app/{routers,routes}/<domain>.py, Django apps with models.py\n" +
1173
+ " Frontend app/<route>/page.tsx (incl. (group)/), pages/<route>/, src/components/<name>/, src/features/<name>/\n" +
1174
+ " If your layout differs, open an issue with your directory tree, or check claudeos-core/generated/project-analysis.json\n" +
1175
+ " to see what was detected. " +
1176
+ (wasFreshClean
1177
+ // --force / "fresh" already wiped the managed rule categories and
1178
+ // generated/*.json|*.md before the scanner ran — say so, do not
1179
+ // claim nothing was touched.
1180
+ ? "CLAUDE.md was not modified, but --force / fresh had already removed the previously generated\n" +
1181
+ " .claude/rules/NN.* categories and claudeos-core/generated/ pass files before this check. Restore them from version control if needed."
1182
+ : "Nothing was written to CLAUDE.md or .claude/rules/.")
1183
+ );
1184
+ }
1092
1185
  throw new InitError(`domain-groups.json has invalid totalGroups: ${totalGroups}\n Re-run plan-installer or check claudeos-core/generated/`);
1093
1186
  }
1094
1187
  if (!domainGroups.groups || totalGroups !== domainGroups.groups.length) {
@@ -1564,7 +1657,8 @@ async function runPass4(opts) {
1564
1657
  // Note: master plan files are no longer generated (previously this
1565
1658
  // included "claudeos-core/plan/50.memory-master.md"). The marker schema
1566
1659
  // still accepts an optional planFiles field for backward compatibility.
1567
- claudeMdAppended: true,
1660
+ // v2.3.0+: CLAUDE.md is never touched by Pass 4 (Pass 3 §8 is authoritative).
1661
+ claudeMdAppended: false,
1568
1662
  }, null, 2);
1569
1663
  return writeFileSafe(pass4Marker, markerBody);
1570
1664
  }
@@ -1625,7 +1719,7 @@ async function runPass4(opts) {
1625
1719
  pass4Label = "Pass 4 already present";
1626
1720
  } else if (!fileExists(pass4PromptFile)) {
1627
1721
  log(" ⚠️ pass4-prompt.md not found — falling back to static scaffold");
1628
- if (applyStaticFallback()) { log(" ✅ Memory/Rules/Plans scaffolded + CLAUDE.md appended (static fallback)"); pass4Label = "Pass 4 (static fallback)"; }
1722
+ if (applyStaticFallback()) { log(" ✅ Memory/Rules/Standard scaffolded (static fallback)"); pass4Label = "Pass 4 (static fallback)"; }
1629
1723
  else { log(" ❌ Static fallback failed to write marker"); pass4Label = "Pass 4 fallback failed"; }
1630
1724
  } else {
1631
1725
  let prompt4 = injectProjectRoot(readFile(pass4PromptFile));
@@ -1675,7 +1769,7 @@ async function runPass4(opts) {
1675
1769
 
1676
1770
  if (!ok4 || !isValidPass4Marker(pass4Marker)) {
1677
1771
  log(" ⚠️ Pass 4 did not produce a valid pass4-memory.json — using static fallback");
1678
- if (applyStaticFallback()) { log(" ✅ Memory/Rules/Plans scaffolded + CLAUDE.md appended (static fallback)"); pass4Label = `Pass 4 (static fallback, ${formatElapsed(elapsed4)})`; }
1772
+ if (applyStaticFallback()) { log(" ✅ Memory/Rules/Standard scaffolded (static fallback)"); pass4Label = `Pass 4 (static fallback, ${formatElapsed(elapsed4)})`; }
1679
1773
  else { log(" ❌ Static fallback failed to write marker"); pass4Label = "Pass 4 fallback failed"; }
1680
1774
  } else {
1681
1775
  // Claude-driven Pass 4 succeeded. Ensure memory + rules + plans + standard + CLAUDE.md append exist
@@ -1744,8 +1838,11 @@ async function runPass4(opts) {
1744
1838
  // ─── Stage 11: Run external verification tools ────────────────────
1745
1839
  function runVerificationTools() {
1746
1840
  const verifyTools = [
1747
- { name: "manifest-generator", script: path.join(TOOLS_DIR, "manifest-generator/index.js") },
1748
- { name: "health-checker", script: path.join(TOOLS_DIR, "health-checker/index.js") },
1841
+ // --sync-skills: only `init` is allowed to let manifest-generator patch
1842
+ // CLAUDE.md §6 / MANIFEST.md (post-generation reconciliation). `health`
1843
+ // runs the same tool without the flag and stays read-only.
1844
+ { name: "manifest-generator", script: path.join(TOOLS_DIR, "manifest-generator/index.js"), args: " --sync-skills" },
1845
+ { name: "health-checker", script: path.join(TOOLS_DIR, "health-checker/index.js"), args: "" },
1749
1846
  ];
1750
1847
 
1751
1848
  for (const t of verifyTools) {
@@ -1753,7 +1850,7 @@ function runVerificationTools() {
1753
1850
  log(` ⏭️ ${t.name} — not found, skipping`);
1754
1851
  continue;
1755
1852
  }
1756
- const ok = run(`node "${t.script}"`, { ignoreError: true });
1853
+ const ok = run(`node "${t.script}"${t.args}`, { ignoreError: true });
1757
1854
  if (!ok) {
1758
1855
  log(` ⚠️ ${t.name} reported issues (non-fatal)`);
1759
1856
  }
@@ -1914,7 +2011,7 @@ async function cmdInit(parsedArgs) {
1914
2011
 
1915
2012
  // ─── [4] Pass 1: Deep analysis per domain group ────────────
1916
2013
  header("[4] Pass 1 — Deep analysis per domain group...");
1917
- const { domainGroups, totalGroups } = loadDomainGroups();
2014
+ const { domainGroups, totalGroups } = loadDomainGroups({ wasFreshClean });
1918
2015
  const pass1Prompts = loadPass1Prompts();
1919
2016
 
1920
2017
  // Progress tracking: Pass 1 (N groups) + Pass 2 + Pass 3 + Pass 4 = totalSteps
@@ -1976,4 +2073,4 @@ async function cmdInit(parsedArgs) {
1976
2073
  printCompletionBanner({ lang, totalGroups, totalStart });
1977
2074
  }
1978
2075
 
1979
- module.exports = { cmdInit, InitError };
2076
+ module.exports = { cmdInit, InitError, wipeManagedRuleCategories };
@@ -32,6 +32,8 @@ Checks:
32
32
  - Section 6 has exactly 3 ### sub-sections
33
33
  - Section 8 has exactly 2 ### sub-sections + 2 #### headings
34
34
  - Each L4 memory file appears in exactly 1 table row (inside Section 8)
35
+ - Each \`## N.\` heading contains its English canonical token (T1)
36
+ - Each section has non-trivial body content (S2, warning)
35
37
 
36
38
  Exit codes:
37
39
  0 — structure valid
@@ -2,7 +2,7 @@
2
2
  * ClaudeOS-Core — Memory command (L4 memory)
3
3
  *
4
4
  * Subcommands:
5
- * memory compact — apply 4-stage compaction to decision-log.md / failure-patterns.md
5
+ * memory compact — apply 4-stage compaction to failure-patterns.md (decision-log.md is append-only)
6
6
  * memory score — recompute importance of failure-patterns.md entries
7
7
  * memory propose-rules — analyze failure patterns, append suggestions to auto-rule-update.md
8
8
  */
@@ -247,7 +247,13 @@ function cmdCompact() {
247
247
  ensureDir(MEMORY_DIR);
248
248
  const activeRulePaths = loadActiveRulePaths();
249
249
 
250
- const files = ["decision-log.md", "failure-patterns.md"];
250
+ // decision-log.md is EXCLUDED from compaction. Its contract (rules/60.memory/
251
+ // 01.decision-log.md, CLAUDE.md §8) is "permanent, append-only". Stage 1
252
+ // summarization dropped the Context/Options/Decision/Consequences body of
253
+ // every entry older than 30 days — i.e. exactly the "why" the file exists
254
+ // to preserve. Only failure-patterns.md carries the frequency/importance
255
+ // metadata the 4-stage policy is designed around.
256
+ const files = ["failure-patterns.md"];
251
257
  const summaries = [];
252
258
  for (const f of files) {
253
259
  const r = compactFile(path.join(MEMORY_DIR, f), activeRulePaths);
@@ -255,6 +261,7 @@ function cmdCompact() {
255
261
  if (r.changed) log(` ✅ ${f}: ${r.before} → ${r.after} entries`);
256
262
  else log(` ⏭️ ${f}: ${r.reason}`);
257
263
  }
264
+ log(" ⏭️ decision-log.md: append-only, never compacted");
258
265
 
259
266
  // Update compaction.md "Last Compaction" section.
260
267
  // Replace ONLY the "## Last Compaction" section (up to next `##` heading or EOF).
@@ -411,7 +418,7 @@ function showHelp() {
411
418
  Usage: npx claudeos-core memory <subcommand>
412
419
 
413
420
  Subcommands:
414
- compact Apply 4-stage compaction to decision-log.md and failure-patterns.md
421
+ compact Apply 4-stage compaction to failure-patterns.md (decision-log.md is append-only, never compacted)
415
422
  score Recompute importance of failure-patterns.md entries (frequency × recency)
416
423
  propose-rules Analyze failure patterns, append rule update suggestions to auto-rule-update.md
417
424
  `);
@@ -423,10 +423,35 @@ async function main() {
423
423
  // check covers both. No natural-language matching involved.
424
424
  console.log(" [10/10] path-claim verification (hallucination + MANIFEST drift)...");
425
425
 
426
- // Regex: matches `src/...` paths to TS/TSX/JS/JSX files, not inside
427
- // inline code already fenced. We still strip fenced blocks first so
428
- // example blocks inside ```...``` don't produce false positives.
429
- const SRC_PATH_RE = /\bsrc\/[\w\-./]+\.(?:ts|tsx|js|jsx)\b/g;
426
+ // Regex: matches `src/...` paths to source files, not inside inline code
427
+ // already fenced. We still strip fenced blocks first so example blocks
428
+ // inside ```...``` don't produce false positives.
429
+ //
430
+ // v2.5.0 — extension list widened. Pre-v2.5.0 only ts/tsx/js/jsx were
431
+ // checked, so Java/Kotlin/Python projects (and MyBatis mapper XML, the
432
+ // single most-hallucinated path class on Spring projects) got zero
433
+ // path-claim coverage despite "no invented paths" being the headline
434
+ // guarantee. Config extensions (yml/properties/json) are deliberately
435
+ // NOT included: those are cited as illustrative profile names
436
+ // (`application-{profile}.yml`) far more often than as path claims.
437
+ //
438
+ // An optional module prefix (`api/src/…`, `apps/web/src/…`,
439
+ // `servers/query/x/src/…`) is captured as part of the claim. A prefixed
440
+ // claim is checked at that exact location only — citing `core/src/…` for a
441
+ // file that lives under `api/` is a wrong-module claim and must be flagged,
442
+ // which the bare-`src/` module search below would otherwise hide.
443
+ //
444
+ // Two alternatives, each with its own left boundary:
445
+ // (1) `<module>/…/src/…` — the prefix may not start right after a word
446
+ // char, `.`, `/` or `@`, so `@acme/ui/src/Button.tsx` (a package
447
+ // import, not a repo path) is NOT captured as module `acme/ui`;
448
+ // (2) bare `src/…` — may be preceded by `/` (so the `src/Button.tsx`
449
+ // tail of that import is still checked via the module search) but
450
+ // not by a word char, so `libsrc/x.ts` never yields `src/x.ts`.
451
+ // Dependency paths (`node_modules/<pkg>/src/…`) are skipped below.
452
+ // `:` is excluded before a prefix so a dev-server URL (`localhost:5173/src/main.tsx`)
453
+ // does not yield the module `5173`; its `src/main.tsx` tail is still checked.
454
+ const SRC_PATH_RE = /(?:(?<![\w\-./@:])[\w\-][\w\-.]*\/(?:[\w\-][\w\-.]*\/)*src\/|(?<![\w\-.@])src\/)[\w\-./]+\.(?:ts|tsx|js|jsx|mjs|cjs|vue|svelte|java|kt|kts|py|xml|sql)\b/g;
430
455
 
431
456
  // Placeholder paths are scaffold templates / teaching examples, not
432
457
  // real path claims. We skip them. Three patterns qualify:
@@ -541,18 +566,59 @@ async function main() {
541
566
  // The monorepo fallback only fires for paths starting with `src/`,
542
567
  // which is the conventional workspace-relative form. Non-`src/` paths
543
568
  // (e.g., `claudeos-core/skills/...`) are checked direct-only.
569
+ // Directories that are never application modules even when they contain a
570
+ // `src/` child: virtualenvs (pip editable installs default to `<venv>/src`),
571
+ // vendored code, docs, tooling, test fixtures.
572
+ //
573
+ // Two tiers: HARD names are never modules at any depth (build output,
574
+ // dependencies, virtualenvs). SOFT names (`docs`, `tools`, `scripts`,
575
+ // `test(s)`, `fixtures`) are skipped only at the project root — inside a
576
+ // workspace container (`apps/docs`, `packages/tools`) they are legitimate
577
+ // packages, and skipping them there produced false STALE_PATH advisories on
578
+ // the default Turborepo layout.
579
+ const MODULE_SCAN_SKIP = new Set(["node_modules", ".git", ".claude", "claudeos-core", "build", "target", "dist", "out", ".gradle", ".idea", "coverage", ".next",
580
+ "venv", ".venv", "env", "virtualenv", "site-packages", "__pycache__", "vendor", "tmp", "temp"]);
581
+ const MODULE_SCAN_SKIP_ROOT_ONLY = new Set(["docs", "doc", "tools", "scripts", "test", "tests", "__tests__", "fixtures"]);
582
+ const WORKSPACE_CONTAINERS = new Set(["apps", "packages", "libs", "services", "servers", "modules"]);
583
+ function listModuleDirs(dir, { root = false, parentName = "" } = {}) {
584
+ let entries;
585
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
586
+ catch (_e) { return []; }
587
+ const inWorkspace = WORKSPACE_CONTAINERS.has(parentName);
588
+ return entries
589
+ .filter(e => e.isDirectory() && !MODULE_SCAN_SKIP.has(e.name) && !e.name.startsWith(".")
590
+ && !((root || !inWorkspace) && MODULE_SCAN_SKIP_ROOT_ONLY.has(e.name)))
591
+ .map(e => path.join(dir, e.name));
592
+ }
593
+ // Memoized once per run: every directory up to three levels deep that
594
+ // contains a `src/` folder (JS workspaces `apps/x`, Gradle modules `api`,
595
+ // and the nested Kotlin CQRS layout `servers/query/x` the scanner itself
596
+ // supports). Computed lazily on the first `src/...` miss so projects with
597
+ // zero misses pay nothing.
598
+ let moduleSrcRoots = null;
599
+ function getModuleSrcRoots(ROOT) {
600
+ if (moduleSrcRoots) return moduleSrcRoots;
601
+ moduleSrcRoots = [];
602
+ const walk = (dir, depth) => {
603
+ for (const child of listModuleDirs(dir, { root: depth === 1, parentName: path.basename(dir) })) {
604
+ if (fs.existsSync(path.join(child, "src"))) moduleSrcRoots.push(child);
605
+ if (depth < 3) walk(child, depth + 1);
606
+ }
607
+ };
608
+ walk(ROOT, 1);
609
+ return moduleSrcRoots;
610
+ }
544
611
  function resolvePathClaim(ROOT, claimed) {
545
612
  if (fs.existsSync(path.join(ROOT, claimed))) return true;
546
613
  if (!claimed.startsWith("src/")) return false;
547
- for (const workspace of ["apps", "packages"]) {
548
- const wsDir = path.join(ROOT, workspace);
549
- let entries;
550
- try { entries = fs.readdirSync(wsDir, { withFileTypes: true }); }
551
- catch (_e) { continue; }
552
- for (const entry of entries) {
553
- if (!entry.isDirectory()) continue;
554
- if (fs.existsSync(path.join(wsDir, entry.name, claimed))) return true;
555
- }
614
+ // v2.4.0: JS monorepo workspaces (apps/*, packages/*).
615
+ // v2.5.0: any first- or second-level module directory — Gradle/Maven
616
+ // multi-module (`api/src/main/java/...`, `servers/query/x/src/main/kotlin/...`)
617
+ // cite `src/...` relative to the module, exactly like JS workspaces do.
618
+ // Without this, widening SRC_PATH_RE to .java/.kt/.py would have turned
619
+ // every valid multi-module citation into a STALE_PATH false positive.
620
+ for (const moduleRoot of getModuleSrcRoots(ROOT)) {
621
+ if (fs.existsSync(path.join(moduleRoot, claimed))) return true;
556
622
  }
557
623
  return false;
558
624
  }
@@ -608,6 +674,9 @@ async function main() {
608
674
  if (seen.has(claimed)) continue;
609
675
  seen.add(claimed);
610
676
  if (hasPlaceholder(claimed)) continue;
677
+ // A dependency path is a reference to a library, not a claim about
678
+ // this repository's source tree.
679
+ if (/(^|\/)node_modules\//.test(claimed)) continue;
611
680
  pathClaimsChecked++;
612
681
  if (!resolvePathClaim(ROOT, claimed)) {
613
682
  pathClaimErrors++;
package/lib/env-parser.js CHANGED
@@ -247,28 +247,65 @@ function isSensitiveVarName(name) {
247
247
  return SENSITIVE_VAR_PATTERNS.some(re => re.test(name));
248
248
  }
249
249
 
250
+ /**
251
+ * Mask the userinfo component of a URL-shaped value:
252
+ * postgres://app:s3cret@db.internal:5432/app → postgres://***:***@db.internal:5432/app
253
+ * Scheme, host, port, path and query are preserved so consumers can still
254
+ * identify the DB engine / host. Non-URL values pass through unchanged.
255
+ */
256
+ function maskUrlCredentials(value) {
257
+ if (typeof value !== "string") return value;
258
+ // Scheme may itself contain `:` (`jdbc:postgresql://`, `jdbc:mysql://`).
259
+ // Userinfo is everything between `://` and the LAST `@` of the authority
260
+ // part, so a password containing `@` (`p@ss`) is masked whole. It may NOT
261
+ // contain `/`, `?` or `#`: an `@` that appears after the first `/` belongs
262
+ // to the path or query (`https://cdn.example.com/npm/@scope/pkg`,
263
+ // `/users/@me`, `?redirect=user@host`) and must never be rewritten —
264
+ // masking it would replace the real host with a path fragment. A raw `/`
265
+ // inside a password is not a valid URL and is deliberately left alone.
266
+ // Credentials carried as connection PARAMETERS rather than userinfo:
267
+ // jdbc:postgresql://db/app?user=app&password=s3cret
268
+ // mongodb://host/db?authSource=admin&password=x
269
+ // sqlserver://host;databaseName=app;user=sa;password=x
270
+ // The parameter NAME is kept, the value becomes `***`.
271
+ const PARAM_RE = /([?&;](?:password|passwd|pwd|pass|secret|token|access[_-]?key|secret[_-]?key|api[_-]?key|sas|signature)=)[^&;\s]*/gi;
272
+ if (/^[a-z][a-z0-9+.:-]*:\/\//i.test(value)) {
273
+ return value
274
+ .replace(/^([a-z][a-z0-9+.:-]*:\/\/)([^/?#\s]*)@([^@/?#\s]+)/i, "$1***:***@$3")
275
+ .replace(PARAM_RE, "$1***");
276
+ }
277
+ // Scheme-less credentials are recognized ONLY in the Go/MySQL DSN shape
278
+ // (`user:pw@tcp(host:3306)/db`, `user:pw@unix(/path)/db`); the password may
279
+ // itself contain `@` (`p@ss`) — everything up to the `@` before `tcp(`/`unix(`
280
+ // is userinfo. A generic `a:b@c` rule would corrupt `mailto:ops@example.com`
281
+ // or `0:30@daily`.
282
+ return value
283
+ .replace(/^([^:@/\s]+):(.*)@(?=(?:tcp|unix)\()/, "***:***@")
284
+ .replace(PARAM_RE, "$1***");
285
+ }
286
+
250
287
  /**
251
288
  * Redacts sensitive values in an env vars map. Returns a new object;
252
289
  * original is not mutated. Preserves keys so "variable exists" signal
253
290
  * is kept, but replaces values with a sentinel string.
254
291
  *
255
- * Whitelist exception: DATABASE_URL is kept as-is because stack-detector's
256
- * db-identification path has always used it and existing project-analysis
257
- * consumers depend on reading it. (The DB URL contains credentials, but
258
- * this has been the established behavior since v1.x and changing it would
259
- * be a breaking change. Downstream consumers that write CLAUDE.md content
260
- * from vars should still redact it at their layer.)
292
+ * v2.5.0 the former DATABASE_URL whitelist is gone. Its stated
293
+ * justification ("stack-detector's db-identification path depends on it")
294
+ * was stale: stack-detector scans the raw .env text with includes() and
295
+ * never reads envInfo.vars. Meanwhile the unredacted value typically
296
+ * `postgres://user:password@host/db` landed verbatim in
297
+ * project-analysis.json, which Pass 3/4 prompts instruct the LLM to read.
298
+ * Every URL-shaped value (DATABASE_URL, REDIS_URL, MONGO_URI, AMQP_URL, …)
299
+ * now has its userinfo masked while keeping scheme/host/path intact.
261
300
  */
262
301
  function redactSensitiveVars(vars) {
263
302
  if (!vars || typeof vars !== "object") return vars;
264
303
  const out = {};
265
304
  for (const [k, v] of Object.entries(vars)) {
266
- if (k === "DATABASE_URL") {
267
- out[k] = v; // documented whitelist for stack-detector back-compat
268
- } else if (isSensitiveVarName(k)) {
305
+ if (isSensitiveVarName(k)) {
269
306
  out[k] = "***REDACTED***";
270
307
  } else {
271
- out[k] = v;
308
+ out[k] = maskUrlCredentials(v);
272
309
  }
273
310
  }
274
311
  return out;
@@ -293,8 +330,8 @@ function readStackEnvInfo(root) {
293
330
  source: file,
294
331
  vars: redactSensitiveVars(vars),
295
332
  port: extractPort(vars),
296
- host: extractHost(vars),
297
- apiTarget: extractApiTarget(vars),
333
+ host: maskUrlCredentials(extractHost(vars)),
334
+ apiTarget: maskUrlCredentials(extractApiTarget(vars)),
298
335
  };
299
336
  }
300
337
 
@@ -308,6 +345,7 @@ module.exports = {
308
345
  readStackEnvInfo,
309
346
  isSensitiveVarName,
310
347
  redactSensitiveVars,
348
+ maskUrlCredentials,
311
349
  // Exported for test visibility:
312
350
  ENV_FILE_ORDER,
313
351
  PORT_VAR_KEYS,