claudeos-core 2.4.4 → 2.5.1

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 (49) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/README.de.md +12 -10
  3. package/README.es.md +12 -10
  4. package/README.fr.md +12 -10
  5. package/README.hi.md +12 -10
  6. package/README.ja.md +12 -10
  7. package/README.ko.md +12 -10
  8. package/README.md +12 -10
  9. package/README.ru.md +12 -10
  10. package/README.vi.md +12 -10
  11. package/README.zh-CN.md +12 -10
  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 +98 -12
  17. package/lib/memory-scaffold.js +35 -16
  18. package/manifest-generator/index.js +15 -4
  19. package/package.json +92 -92
  20. package/pass-json-validator/index.js +1 -1
  21. package/pass-prompts/templates/angular/pass3.md +2 -1
  22. package/pass-prompts/templates/common/claude-md-scaffold.md +1 -1
  23. package/pass-prompts/templates/common/pass3a-facts.md +11 -9
  24. package/pass-prompts/templates/common/pass4.md +3 -3
  25. package/pass-prompts/templates/java-spring/pass1.md +10 -2
  26. package/pass-prompts/templates/java-spring/pass3.md +5 -4
  27. package/pass-prompts/templates/kotlin-spring/pass3.md +2 -2
  28. package/pass-prompts/templates/node-express/pass3.md +1 -1
  29. package/pass-prompts/templates/node-fastify/pass3.md +1 -0
  30. package/pass-prompts/templates/node-nestjs/pass3.md +1 -0
  31. package/pass-prompts/templates/node-nextjs/pass3.md +1 -1
  32. package/pass-prompts/templates/node-vite/pass3.md +1 -0
  33. package/pass-prompts/templates/python-django/pass3.md +1 -1
  34. package/pass-prompts/templates/python-fastapi/pass3.md +1 -1
  35. package/pass-prompts/templates/python-flask/pass3.md +1 -0
  36. package/pass-prompts/templates/vue-nuxt/pass3.md +1 -0
  37. package/plan-installer/domain-grouper.js +4 -1
  38. package/plan-installer/index.js +26 -7
  39. package/plan-installer/jvm-detect.js +562 -0
  40. package/plan-installer/pass3-context-builder.js +10 -0
  41. package/plan-installer/prompt-generator.js +18 -2
  42. package/plan-installer/scanners/scan-frontend.js +67 -6
  43. package/plan-installer/scanners/scan-java.js +214 -15
  44. package/plan-installer/scanners/scan-kotlin.js +68 -3
  45. package/plan-installer/scanners/scan-node.js +115 -0
  46. package/plan-installer/scanners/scan-python.js +56 -0
  47. package/plan-installer/source-paths.js +61 -0
  48. package/plan-installer/stack-detector.js +726 -51
  49. package/plan-installer/structure-scanner.js +15 -4
@@ -61,7 +61,11 @@ function dirGlobPrefix(dir) {
61
61
  return fwd.endsWith("/") ? fwd : fwd + "/";
62
62
  }
63
63
 
64
- async function scanFrontendDomains(stack, ROOT) {
64
+ // `opts.projectRoot` (v2.5.0): when the SPA lives in a sub-directory, ROOT is
65
+ // that sub-directory (so all globs are relative to it) while `.claudeos-scan.json`
66
+ // is still read from the PROJECT root, where it is documented to live.
67
+ async function scanFrontendDomains(stack, ROOT, opts = {}) {
68
+ const PROJECT_ROOT = opts.projectRoot || ROOT;
65
69
  const frontendDomains = [];
66
70
 
67
71
  // ── Angular ──
@@ -129,9 +133,63 @@ async function scanFrontendDomains(stack, ROOT) {
129
133
  const skipPages = ["api", "_app", "_document", "fonts", "not-found", "error", "loading",
130
134
  "components", "hooks", "widgets", "entities", "features", "modules",
131
135
  "lib", "libs", "utils", "util", "config", "types", "shared", "common", "assets"];
132
- for (const dir of allDirs) {
136
+
137
+ // v2.5.0 — Next.js App Router route groups. `app/(marketing)/about/`,
138
+ // `app/(shop)/cart/` — the parenthesized folder is invisible in the URL
139
+ // and exists only to share layouts. Pre-v2.5.0 these were skipped
140
+ // outright (`name.startsWith("(")`), so any project that organizes
141
+ // routes under groups (the App Router default in most starters) came
142
+ // back with ZERO route domains. Expand each group one level so its
143
+ // children are evaluated exactly like top-level route folders. Nested
144
+ // groups (`(a)/(b)/x`) are expanded recursively up to 3 levels.
145
+ async function expandRouteGroups(dirs, depth = 0) {
146
+ const out = [];
147
+ for (const dir of dirs) {
148
+ const name = path.basename(dir.replace(/\/$/, ""));
149
+ if (name.startsWith("(") && name.endsWith(")") && depth < 3) {
150
+ const children = await glob(`${dirGlobPrefix(dir)}*/`, { cwd: ROOT, ignore: ["**/node_modules/**"] });
151
+ out.push(...await expandRouteGroups(children, depth + 1));
152
+ } else {
153
+ out.push(dir);
154
+ }
155
+ }
156
+ return out;
157
+ }
158
+ const routeDirs = [...new Set(await expandRouteGroups(allDirs))];
159
+ // Same leaf under different route groups — `(shop)/settings` and
160
+ // `(admin)/settings` — are DIFFERENT features. Domain names must stay
161
+ // unique (domain-groups.json, per-domain rules/standards are keyed by
162
+ // name), so colliding leaves are qualified with their group path:
163
+ // `shop-settings`, `admin-settings`. Non-colliding leaves keep the bare name.
164
+ // Two qualification levels: (1) segments after the last `app`/`pages`
165
+ // (route groups: `shop-settings`); (2) if still not unique — leaves that
166
+ // differ only BEFORE `pages`, such as `src/mobile/pages/home` vs
167
+ // `src/desktop/pages/home` — every non-structural segment of the path
168
+ // (`mobile-home`, `desktop-home`).
169
+ const STRUCTURAL = new Set(["src", "app", "pages", "apps", "packages"]);
170
+ const qualify = (dir, full) => {
171
+ const segs = dir.replace(/\\/g, "/").replace(/\/$/, "").split("/");
172
+ let from;
173
+ if (full) from = 0;
174
+ else {
175
+ const anchor = Math.max(segs.lastIndexOf("app"), segs.lastIndexOf("pages"));
176
+ // Segments after the `app`/`pages` anchor (route groups → `shop-settings`).
177
+ // Without an anchor (`src/views/home`), or when the anchored form adds
178
+ // nothing (`src/pages/home` → still `home`), qualify with the immediate
179
+ // parent (`views-home`, `pages-home`) — never with the whole path.
180
+ from = anchor >= 0 && anchor + 1 < segs.length - 1 ? anchor + 1 : Math.max(0, segs.length - 2);
181
+ }
182
+ return segs.slice(from).filter(s => !full || !STRUCTURAL.has(s)).map(s => s.replace(/^\((.*)\)$/, "$1")).join("-");
183
+ };
184
+ const count = (arr) => arr.reduce((m, n) => { m[n] = (m[n] || 0) + 1; return m; }, {});
185
+ const leafCount = count(routeDirs.map(d => path.basename(d)));
186
+ const level1 = new Map(routeDirs.map(d => [d, leafCount[path.basename(d)] > 1 ? qualify(d, false) : path.basename(d)]));
187
+ const level1Count = count([...level1.values()]);
188
+ const domainNames = new Map(routeDirs.map(d => [d, level1Count[level1.get(d)] > 1 ? qualify(d, true) : level1.get(d)]));
189
+ for (const dir of routeDirs) {
133
190
  const name = path.basename(dir);
134
191
  if (skipPages.includes(name) || name.startsWith("(") || name.startsWith("[") || name.startsWith("_") || name.startsWith(".")) continue;
192
+ const domainName = domainNames.get(dir);
135
193
  const files = await glob(`${dirGlobPrefix(dir)}**/*.{tsx,jsx,ts,js,vue}`, { cwd: ROOT });
136
194
  if (files.length > 0) {
137
195
  const pages = files.filter(f => /page\.|index\./.test(f)).length;
@@ -140,7 +198,7 @@ async function scanFrontendDomains(stack, ROOT) {
140
198
  const serverFiles = pages + layouts;
141
199
  const components = files.filter(f => !/page\.|layout\.|index\.|client\./.test(f)).length;
142
200
  frontendDomains.push({
143
- name, type: "frontend", pages, layouts, clientFiles, serverFiles, components, totalFiles: files.length,
201
+ name: domainName, type: "frontend", pages, layouts, clientFiles, serverFiles, components, totalFiles: files.length,
144
202
  rscPattern: clientFiles > 0 ? "RSC+Client split" : "default",
145
203
  });
146
204
  }
@@ -190,7 +248,9 @@ async function scanFrontendDomains(stack, ROOT) {
190
248
  const parts = f.replace(/\\/g, "/").split("/");
191
249
  const appIdx = parts.indexOf("app");
192
250
  const pagesIdx = parts.indexOf("pages");
193
- const baseIdx = appIdx >= 0 ? appIdx : pagesIdx;
251
+ let baseIdx = appIdx >= 0 ? appIdx : pagesIdx;
252
+ // Route groups `(group)` are URL-transparent — step over them.
253
+ while (baseIdx >= 0 && baseIdx + 1 < parts.length - 1 && /^\(.*\)$/.test(parts[baseIdx + 1])) baseIdx++;
194
254
  if (baseIdx >= 0 && baseIdx + 1 < parts.length - 1) {
195
255
  const domain = parts[baseIdx + 1];
196
256
  if (!skipNames.includes(domain) && !domain.startsWith("_") && !domain.startsWith("(") && !domain.startsWith("[") && !domain.startsWith(".")) {
@@ -204,7 +264,8 @@ async function scanFrontendDomains(stack, ROOT) {
204
264
  for (const f of clientFiles) {
205
265
  const parts = f.replace(/\\/g, "/").split("/");
206
266
  const appIdx = parts.indexOf("app");
207
- const baseIdx = appIdx >= 0 ? appIdx : -1;
267
+ let baseIdx = appIdx >= 0 ? appIdx : -1;
268
+ while (baseIdx >= 0 && baseIdx + 1 < parts.length - 1 && /^\(.*\)$/.test(parts[baseIdx + 1])) baseIdx++;
208
269
  if (baseIdx >= 0 && baseIdx + 1 < parts.length - 1) {
209
270
  const domain = parts[baseIdx + 1];
210
271
  if (domainSet[domain]) {
@@ -298,7 +359,7 @@ async function scanFrontendDomains(stack, ROOT) {
298
359
  // by routes/-file layouts, which appear across all frontend frameworks.
299
360
  if (stack.frontend) {
300
361
  // Read optional per-project override (.claudeos-scan.json).
301
- const overrides = loadScanOverrides(ROOT).frontendScan || {};
362
+ const overrides = loadScanOverrides(PROJECT_ROOT).frontendScan || {};
302
363
  // Platform-split layout: src/{platform}/{subapp}/ where platform is a
303
364
  // device/target-environment OR access-tier keyword. Both form the same
304
365
  // structural pattern (top-level segmentation with a common subapp layout).
@@ -13,15 +13,122 @@
13
13
 
14
14
  const path = require("path");
15
15
  const { glob } = require("glob");
16
+ const { readFileSafe, existsSafe } = require("../../lib/safe-fs");
16
17
 
17
18
  // Normalize backslash paths from glob on Windows to forward slashes
18
19
  const norm = (p) => p.replace(/\\/g, "/");
19
20
 
21
+ // v2.5.0 — Module-aware scanning.
22
+ // Source roots (`[<module>/]src/main/java`, `[<module>/]src/main/resources`)
23
+ // are discovered ONCE with a single ignore-filtered walk; every subsequent
24
+ // pattern is then anchored at each discovered module prefix. This finds
25
+ // Gradle/Maven multi-module layouts (`api/src/main/java/...`) with the same
26
+ // pattern set as a single-module root, without re-walking the whole tree
27
+ // (node_modules, web bundles, build output) for every per-domain glob.
28
+ // `src/test/**` and `buildSrc/` are excluded: test-fixture projects
29
+ // (`src/test/resources/projects/demo/src/main/java/...`) and Gradle
30
+ // convention plugins are not application modules.
31
+ const JAVA_ROOT_IGNORE = ["**/node_modules/**", "**/build/**", "**/target/**", "**/out/**", "**/.gradle/**", "**/generated/**", "**/.git/**", "**/src/test/**", "**/buildSrc/**"];
32
+
33
+ // v2.5.x — Source roots, not module prefixes. Every pattern below is written
34
+ // against the Maven/Gradle convention (`src/main/java`, `src/main/resources`)
35
+ // and is rewritten per discovered root, so the pattern set stays a single
36
+ // source of truth while legacy layouts become scannable:
37
+ //
38
+ // modern [<module>/]src/main/java (unchanged behaviour)
39
+ // Eclipse <classpathentry kind="src" path="src"/> ← consulted first
40
+ // Ant <javac srcdir="src"> (with <property> resolution)
41
+ // bare src/java, src, JavaSource, java, WebContent/WEB-INF/src holding *.java
42
+ //
43
+ // Candidates are tried in that order and every candidate that holds *.java
44
+ // becomes a root, except one nested inside (or enclosing) a root already
45
+ // accepted — the FIRST-listed of a nested pair wins, which is why the bare
46
+ // list names `src/java` before `src`.
47
+ //
48
+ // Legacy roots are consulted ONLY when no `src/main/java` exists anywhere in
49
+ // the tree, so a modern project with a stray top-level `src/` cannot be
50
+ // mis-rooted. For legacy roots the resources root is the java root itself:
51
+ // iBatis-era projects keep sqlmap XML next to the classes.
52
+ async function discoverSourceRoots(ROOT) {
53
+ const javaRoots = (await glob("**/src/main/java/", { cwd: ROOT, ignore: JAVA_ROOT_IGNORE })).map(norm);
54
+ const resRoots = (await glob("**/src/main/resources/", { cwd: ROOT, ignore: JAVA_ROOT_IGNORE })).map(norm);
55
+ const prefixes = new Set();
56
+ for (const r of [...javaRoots, ...resRoots]) {
57
+ const m = r.replace(/\/$/, "").match(/^(.*?)src\/main\/(?:java|resources)$/);
58
+ if (m) prefixes.add(m[1]); // "" for root, "api/" for a module
59
+ }
60
+ if (prefixes.size) {
61
+ return [...prefixes].sort().map(pre => ({ prefix: pre, javaRoot: pre + "src/main/java", resRoot: pre + "src/main/resources", legacy: false }));
62
+ }
63
+
64
+ // ── legacy fallbacks ──
65
+ const candidates = [];
66
+ const cpXml = readFileSafe(path.join(ROOT, ".classpath"));
67
+ if (cpXml) {
68
+ for (const m of cpXml.matchAll(/<classpathentry\b[^>]*\bkind\s*=\s*["']src["'][^>]*\bpath\s*=\s*["']([^"']+)["']/g)) {
69
+ const p = norm(m[1]).replace(/^\/|\/$/g, "");
70
+ if (p && !/(^|\/)test(s)?(\/|$)/i.test(p)) candidates.push(p);
71
+ }
72
+ }
73
+ const bx = readFileSafe(path.join(ROOT, "build.xml"));
74
+ if (bx) {
75
+ const props = {};
76
+ for (const m of bx.matchAll(/<property\b[^>]*\bname\s*=\s*["']([^"']+)["'][^>]*\bvalue\s*=\s*["']([^"']+)["']/g)) props[m[1]] = m[2];
77
+ for (const m of bx.matchAll(/<javac\b[^>]*\bsrcdir\s*=\s*["']([^"']+)["']/g)) {
78
+ const p = norm(m[1].replace(/\$\{([^}]+)\}/g, (_, k) => props[k] ?? "")).replace(/^\.?\/|\/$/g, "");
79
+ if (p && !/test/i.test(p)) candidates.push(p);
80
+ }
81
+ }
82
+ for (const c of ["src/java", "src", "JavaSource", "java", "WebContent/WEB-INF/src"]) candidates.push(c);
83
+
84
+ const roots = [];
85
+ const seen = new Set();
86
+ for (const c of candidates) {
87
+ if (seen.has(c)) continue;
88
+ seen.add(c);
89
+ if (!existsSafe(path.join(ROOT, c))) continue;
90
+ const hasJava = (await glob(c + "/**/*.java", { cwd: ROOT, ignore: JAVA_ROOT_IGNORE, nodir: true })).length > 0;
91
+ if (!hasJava) continue;
92
+ // Nested pair (`src` vs `src/java`) → keep the first-accepted one only.
93
+ if (roots.some(r => c.startsWith(r.javaRoot + "/") || r.javaRoot.startsWith(c + "/"))) continue;
94
+ roots.push({ prefix: "", javaRoot: c, resRoot: c, legacy: true });
95
+ }
96
+ return roots;
97
+ }
98
+
99
+ // Run one `src/main/...`-relative pattern against every discovered source
100
+ // root — rewriting the conventional leading segment to that root's actual
101
+ // directory — and return the merged, normalized, de-duplicated file list.
102
+ function makeModuleGlob(ROOT, roots) {
103
+ return async (pattern) => {
104
+ const out = new Set();
105
+ for (const r of roots) {
106
+ // Replacement FUNCTIONS so a `$` in a discovered root path is literal.
107
+ const p = pattern
108
+ .replace(/^src\/main\/java(?=\/|$)/, () => r.javaRoot)
109
+ .replace(/^src\/main\/resources(?=\/|$)/, () => r.resRoot);
110
+ for (const f of await glob(p, { cwd: ROOT })) out.add(norm(f));
111
+ }
112
+ return [...out];
113
+ };
114
+ }
115
+
20
116
  async function scanJavaDomains(stack, ROOT) {
21
117
  const backendDomains = [];
22
118
  let rootPackage = null;
23
119
 
24
- const javaFiles = (await glob("src/main/java/**/*.java", { cwd: ROOT })).map(norm);
120
+ const sourceRoots = await discoverSourceRoots(ROOT);
121
+ const rootsInUse = sourceRoots.length ? sourceRoots : [{ prefix: "", javaRoot: "src/main/java", resRoot: "src/main/resources", legacy: false }];
122
+ const modulePrefixes = rootsInUse.map(r => r.prefix).filter(Boolean);
123
+ const gj = makeModuleGlob(ROOT, rootsInUse);
124
+ // Regex fragment matching any java root — used where file PATHS (not
125
+ // glob patterns) are inspected below. Modern roots collapse to the
126
+ // conventional `src/main/java`; legacy roots contribute their own dir.
127
+ const escRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
128
+ const JAVA_ROOT_ALT = [...new Set(rootsInUse.map(r => r.legacy ? r.javaRoot : "src/main/java"))].map(escRe).join("|");
129
+ if (stack && rootsInUse.some(r => r.legacy)) stack.sourceLayout = "legacy";
130
+
131
+ const javaFiles = (await gj("src/main/java/**/*.java"));
25
132
 
26
133
  // v2.4.0 — Pick the LONGEST package prefix (1-4 segments) that still
27
134
  // covers ≥80% of layer-bearing files. Pre-v2.4.0 the first matched file
@@ -42,7 +149,7 @@ async function scanJavaDomains(stack, ROOT) {
42
149
  // not the minority `<root>.misc.*` location (no longer first-match).
43
150
  const pkgCounts = new Map();
44
151
  for (const f of javaFiles) {
45
- const m = f.match(/src\/main\/java\/(.+?)\/(controller|aggregator|facade|usecase|orchestrator|service|mapper|dao|dto|entity|repository|adapter)/);
152
+ const m = f.match(new RegExp(`(?:${JAVA_ROOT_ALT})/(.+?)/(controller|aggregator|facade|usecase|orchestrator|service|mapper|dao|dto|entity|repository|adapter)`));
46
153
  if (!m) continue;
47
154
  const segs = m[1].split("/");
48
155
  for (let len = Math.min(4, segs.length); len >= 1; len--) {
@@ -62,8 +169,85 @@ async function scanJavaDomains(stack, ROOT) {
62
169
  const domainMap = {};
63
170
  let detectedPattern = null;
64
171
 
172
+ // v2.5.0 — Flat-layout guard for Pattern B/D and the supplementary scan.
173
+ //
174
+ // In the standard Spring Initializr layout the layer dirs sit DIRECTLY
175
+ // under the root package: `com/example/demo/controller/UserController.java`.
176
+ // The Pattern B glob `**/*/controller/*.java` matched that with `*` =
177
+ // `demo` (the root package's last segment), so every flat project was
178
+ // classified as "Pattern B, single domain named after the package" and
179
+ // Pattern C (domain from class name) was unreachable.
180
+ //
181
+ // Two signals must BOTH hold for a `{d}/{layer}/` path to count as flat:
182
+ // 1. `{d}` is the root package's last segment (the layer dir is a
183
+ // direct child of the root package), AND
184
+ // 2. none of the `*Controller` class stems under `{d}/controller/`
185
+ // start with `{d}` — i.e. the classes are named after OTHER things
186
+ // (`UserController`, `OrderController` under `demo/`).
187
+ // Signal 2 keeps single-domain domain-first projects intact: in
188
+ // `com/example/payment/controller/PaymentController.java` the root
189
+ // package also ends in `payment`, but the controller stem IS `payment`,
190
+ // so it stays Pattern B.
191
+ //
192
+ // Signal 2 looks at EVERY layer class under the base dir (controller,
193
+ // service, mapper, repository, dao, dto), not only controllers: a
194
+ // single-domain project such as `account/{controller/LoginController,
195
+ // service/AccountService, dto/LoginDto}` is domain-first because
196
+ // `AccountService` is named after the package, even though no controller is.
197
+ // A `*Application.java` (Spring Boot main class) sitting DIRECTLY in the base
198
+ // dir is a positive flat signal on its own — Initializr places it there,
199
+ // domain-first projects keep it one level above the domain packages.
200
+ //
201
+ // Base dirs: the root package, plus — for a file inside a Gradle/Maven
202
+ // module — `<rootPkg>/<moduleName>` (`api/src/main/java/com/example/api/
203
+ // controller/`), where the module's own sub-package plays the role of the
204
+ // Initializr base package and the domains again come from class names.
205
+ const rootPkgPath = rootPackage ? rootPackage.replace(/\./g, "/") : null;
206
+ const flatDirCache = new Map();
207
+ const LAYER_CLASS_RE = /^(?:controller|service|mapper|repository|dao|dto)\/([A-Za-z0-9]+?)(?:Controller|Service|Mapper|Repository|Dao|Dto)\.java$/;
208
+ const isFlatBase = (base) => {
209
+ if (!flatDirCache.has(base)) {
210
+ const dirRe = new RegExp(`(^|/)(?:${JAVA_ROOT_ALT})/${escRe(base)}/`);
211
+ const under = [];
212
+ for (const x of javaFiles) {
213
+ const m = x.match(dirRe);
214
+ if (m) under.push(x.slice(m.index + m[0].length));
215
+ }
216
+ const appInBase = under.some(rel => /^[A-Za-z0-9]*Application\.java$/.test(rel));
217
+ const stems = under.map(rel => (rel.match(LAYER_CLASS_RE) || [])[1]).filter(Boolean).map(s => s.toLowerCase());
218
+ const tail = base.split("/").pop().toLowerCase();
219
+ flatDirCache.set(base, appInBase || (stems.length > 0 && !stems.some(s => s.startsWith(tail))));
220
+ }
221
+ return flatDirCache.get(base);
222
+ };
223
+ // Directories (relative to src/main/java) holding a Spring Boot main class
224
+ // (`*Application.java`). The Initializr base package is wherever that class
225
+ // lives, independent of `rootPackage` (which is capped at 4 segments and
226
+ // therefore misses `kr/co/<org>/<proj>/<app>` style base packages).
227
+ const appBases = [...new Set(javaFiles
228
+ .map(f => (f.match(new RegExp(`(?:${JAVA_ROOT_ALT})/(.+)/[A-Za-z0-9]*Application\\.java$`)) || [])[1])
229
+ .filter(Boolean))];
230
+ const isFlatLayerPath = (f, layerSegment) => {
231
+ if (!rootPkgPath && appBases.length === 0) return false;
232
+ const bases = [rootPkgPath, ...appBases].filter(Boolean);
233
+ const pre = modulePrefixes.find(p => p && f.startsWith(p));
234
+ if (pre && rootPkgPath) bases.push(`${rootPkgPath}/${pre.replace(/\/$/, "").split("/").pop()}`);
235
+ for (const base of bases) {
236
+ const layerRe = new RegExp(`(^|/)(?:${JAVA_ROOT_ALT})/${escRe(base)}/${escRe(layerSegment)}/[^/]+\\.java$`);
237
+ if (layerRe.test(f) && isFlatBase(base)) return true;
238
+ }
239
+ return false;
240
+ };
241
+
242
+ // Controllers that Pattern B skipped as "flat" (layer dir directly under the
243
+ // base package). If another pattern wins (mixed tree: `demo/controller/
244
+ // HomeController.java` next to `demo/user/controller/UserController.java`),
245
+ // Pattern C never runs, so these are re-attached below by class name —
246
+ // a controller must never silently belong to no domain.
247
+ const flatSkippedControllers = [];
248
+
65
249
  // Pattern A: controller/{domain}/*.java (layer-first — domain under controller)
66
- const controllersA = (await glob("src/main/java/**/controller/*/*.java", { cwd: ROOT })).map(norm);
250
+ const controllersA = (await gj("src/main/java/**/controller/*/*.java"));
67
251
  for (const f of controllersA) {
68
252
  const m = f.match(/controller\/([^/]+)\//);
69
253
  if (m) {
@@ -77,9 +261,10 @@ async function scanJavaDomains(stack, ROOT) {
77
261
  // Pattern B/D: {domain}/controller/*.java (domain-first — controller under domain)
78
262
  // D extends B: {module}/{domain}/controller/ — auto-upgrade to module/domain on name conflict
79
263
  if (!detectedPattern) {
80
- const controllersB = (await glob("src/main/java/**/*/controller/*.java", { cwd: ROOT })).map(norm);
264
+ const controllersB = (await gj("src/main/java/**/*/controller/*.java"));
81
265
  const domainPaths = {};
82
266
  for (const f of controllersB) {
267
+ if (isFlatLayerPath(f, "controller")) { flatSkippedControllers.push(f); continue; }
83
268
  const m = f.match(/\/([^/]+)\/controller\/[^/]+\.java$/);
84
269
  if (m) {
85
270
  const d = m[1];
@@ -115,7 +300,7 @@ async function scanJavaDomains(stack, ROOT) {
115
300
 
116
301
  // Pattern E: DDD/Hexagonal — {domain}/adapter/in/web/*.java or {domain}/adapter/in/rest/*.java
117
302
  if (!detectedPattern) {
118
- const controllersE = (await glob("src/main/java/**/adapter/in/{web,rest}/*.java", { cwd: ROOT })).map(norm);
303
+ const controllersE = (await gj("src/main/java/**/adapter/in/{web,rest}/*.java"));
119
304
  for (const f of controllersE) {
120
305
  const m = f.match(/\/([^/]+)\/adapter\/in\/(web|rest)\/[^/]+\.java$/);
121
306
  if (m) {
@@ -129,7 +314,7 @@ async function scanJavaDomains(stack, ROOT) {
129
314
 
130
315
  // Pattern C: Flat structure — controller/*.java (no domain directory, extract domain from class name)
131
316
  if (!detectedPattern) {
132
- const controllersC = (await glob("src/main/java/**/controller/*.java", { cwd: ROOT })).map(norm);
317
+ const controllersC = (await gj("src/main/java/**/controller/*.java"));
133
318
  for (const f of controllersC) {
134
319
  const m = f.match(/\/([A-Z][a-zA-Z]*)Controller\.java$/);
135
320
  if (m) {
@@ -141,16 +326,30 @@ async function scanJavaDomains(stack, ROOT) {
141
326
  if (Object.keys(domainMap).length > 0) detectedPattern = "C";
142
327
  }
143
328
 
329
+ // Mixed tree: Pattern B/D/E claimed the tree, but flat controllers under the
330
+ // base package were skipped. Attach each by class name as a Pattern C
331
+ // domain (`HomeController` → `home`) so it is analyzed and gets rules.
332
+ if (detectedPattern && detectedPattern !== "C") {
333
+ for (const f of flatSkippedControllers) {
334
+ const m = f.match(/\/([A-Z][a-zA-Z]*)Controller\.java$/);
335
+ if (!m) continue;
336
+ const d = m[1].toLowerCase();
337
+ if (!domainMap[d]) domainMap[d] = { controllers: 0, services: 0, mappers: 0, dtos: 0, xmlMappers: 0, pattern: "C" };
338
+ domainMap[d].controllers++;
339
+ }
340
+ }
341
+
144
342
  // ── Supplementary scan: detect domains without controllers (service/dao/aggregator/facade/usecase only) ──
145
343
  // Runs for ALL detected patterns (A/B/C/D/E) to catch core-only domains
146
344
  {
147
- const serviceDirs = (await glob("src/main/java/**/*/service/*.java", { cwd: ROOT })).map(norm);
148
- const mapperDirs = (await glob("src/main/java/**/*/{mapper,repository,dao}/*.java", { cwd: ROOT })).map(norm);
149
- const orchestrationDirs = (await glob("src/main/java/**/*/{aggregator,facade,usecase,orchestrator}/*.java", { cwd: ROOT })).map(norm);
345
+ const serviceDirs = (await gj("src/main/java/**/*/service/*.java"));
346
+ const mapperDirs = (await gj("src/main/java/**/*/{mapper,repository,dao}/*.java"));
347
+ const orchestrationDirs = (await gj("src/main/java/**/*/{aggregator,facade,usecase,orchestrator}/*.java"));
150
348
  const allServiceFiles = [...serviceDirs, ...mapperDirs, ...orchestrationDirs];
151
349
  const skipDomains = ["common", "config", "util", "utils", "base", "core", "shared", "global", "framework", "infra", "front", "admin", "back", "internal", "external", "web", "app", "test", "tests", "main", "generated", "build"];
152
350
  for (const f of allServiceFiles) {
153
351
  const m = f.match(/\/([^/]+)\/(service|mapper|repository|dao|aggregator|facade|usecase|orchestrator)\/[^/]+\.java$/);
352
+ if (m && isFlatLayerPath(f, m[2])) continue; // flat layout: layer dir directly under root package
154
353
  if (m) {
155
354
  const d = m[1];
156
355
  if (!domainMap[d] && !skipDomains.includes(d) && !/^v\d+$/.test(d)) {
@@ -196,11 +395,11 @@ async function scanJavaDomains(stack, ROOT) {
196
395
  ? `src/main/resources/{mapper,mybatis}/**/{${dn}/${capDn}*.xml,${capDn}*.xml}`
197
396
  : `src/main/resources/{mapper,mybatis}/**/${dn}/*.xml`;
198
397
 
199
- const svc = await glob(svcGlob, { cwd: ROOT });
200
- const mpr = await glob(mprGlob, { cwd: ROOT });
201
- const dto = await glob(dtoGlob, { cwd: ROOT });
202
- const xml = await glob(xmlGlob, { cwd: ROOT });
203
- const agg = aggGlob ? await glob(aggGlob, { cwd: ROOT }) : [];
398
+ const svc = await gj(svcGlob);
399
+ const mpr = await gj(mprGlob);
400
+ const dto = await gj(dtoGlob);
401
+ const xml = await gj(xmlGlob);
402
+ const agg = aggGlob ? await gj(aggGlob) : [];
204
403
  domainMap[d].services = svc.length + agg.length;
205
404
  domainMap[d].mappers = mpr.length;
206
405
  domainMap[d].dtos = dto.length;
@@ -233,7 +432,7 @@ async function scanJavaDomains(stack, ROOT) {
233
432
  // domains with healthy direct-layout file counts.
234
433
  const standardCount = svc.length + agg.length + mpr.length + dto.length + xml.length;
235
434
  if (standardCount === 0 && (p === "B" || p === "D")) {
236
- const deepFiles = (await glob(`src/main/java/**/${dn}/**/*.java`, { cwd: ROOT })).map(norm);
435
+ const deepFiles = (await gj(`src/main/java/**/${dn}/**/*.java`));
237
436
  // v2.4.0 — extended layer recognition. Enterprise codebases
238
437
  // commonly include implementation/support layers beyond the canonical
239
438
  // controller/service/mapper/dto trio. Files in `factory/`, `strategy/`,
@@ -184,10 +184,12 @@ async function scanKotlinDomains(stack, ROOT) {
184
184
  const ktDomains = {};
185
185
  const skipNames = ["common", "config", "util", "utils", "base", "shared", "global", "framework", "infra", "main", "generated", "build"];
186
186
  const layerKw = ["controller", "service", "repository", "mapper", "dao", "dto", "vo", "entity", "aggregate", "adapter"];
187
+ const handledByLayerDir = new Set();
187
188
  for (const f of ktFiles) {
188
189
  const parts = f.replace(/\\/g, "/").split("/");
189
190
  for (let i = 0; i < parts.length - 1; i++) {
190
191
  if (layerKw.includes(parts[i].toLowerCase())) {
192
+ handledByLayerDir.add(f);
191
193
  // domain/layer/ pattern
192
194
  if (i > 0) {
193
195
  const d = parts[i - 1].toLowerCase();
@@ -204,10 +206,73 @@ async function scanKotlinDomains(stack, ROOT) {
204
206
  }
205
207
  }
206
208
  }
207
- for (const [d, data] of Object.entries(ktDomains)) {
208
- if (data.totalFiles > 0) {
209
- backendDomains.push({ name: d, type: "backend", ...data, pattern: "kotlin-single" });
209
+ // v2.5.0 Package-by-feature without layer sub-directories:
210
+ // com/acme/user/UserController.kt, com/acme/user/UserService.kt
211
+ // (the idiomatic Kotlin/Spring layout no controller/ or service/ folder).
212
+ // The loop above needs a layer folder name in the path; before v2.5.0 a
213
+ // project with none aborted `init` with "invalid totalGroups: 0".
214
+ // Derive the domain from the directory that directly holds layer-suffixed
215
+ // classes; if that directory is the root package itself (flat), use the
216
+ // class-name stem instead (UserController → user).
217
+ //
218
+ // Runs for every file the layer-dir loop did NOT handle, so a MIXED layout
219
+ // (`user/controller/UserController.kt` + `order/OrderController.kt`) keeps
220
+ // both domains. In that mixed case only feature packages (parent named
221
+ // after its classes) are accepted; the class-name-stem fallback is reserved
222
+ // for projects with no layer dirs at all, so a stray `SomeHandler.kt` in
223
+ // the root package never becomes a domain of an otherwise structured tree.
224
+ {
225
+ // The layer-dir loop registers a domain named after the ROOT package
226
+ // whenever a layer folder (dto/, vo/, entity/…) sits directly under it
227
+ // (`com/acme/dto/UserDto.kt` → "acme"). That is a flat-root artifact,
228
+ // not a feature: it must neither switch this fallback into strict mode
229
+ // nor survive next to real domains.
230
+ // "Root" here is the longest package prefix shared by ALL .kt files
231
+ // (`rootPackage` above stops at the first layer dir and may include a
232
+ // domain segment, so it cannot be used for this). The artifact is the
233
+ // entry named after that root tail that carries no controllers and no
234
+ // services — only dto/mapper counts.
235
+ const pkgDirs = ktFiles.map(f => (f.match(/src\/main\/kotlin\/(.+)\/[^/]+\.kt$/) || [])[1]).filter(Boolean).map(d => d.split("/"));
236
+ let common = pkgDirs.length ? pkgDirs[0].slice() : [];
237
+ for (const d of pkgDirs) { let i = 0; while (i < common.length && i < d.length && common[i] === d[i]) i++; common = common.slice(0, i); }
238
+ const rootTail = common.length ? common[common.length - 1].toLowerCase() : null;
239
+ const isFlatRootArtifact = (d) => d === rootTail && ktDomains[d] && ktDomains[d].controllers === 0 && ktDomains[d].services === 0;
240
+ const strict = Object.keys(ktDomains).some(d => !isFlatRootArtifact(d));
241
+ const suffixRe = /([A-Za-z0-9]+?)(Controller|Service|Repository|Handler|UseCase|Mapper|Dao|Router|Resource)\.kt$/;
242
+ const bucket = (m) => (m === "Controller" || m === "Router" || m === "Resource") ? "controllers"
243
+ : (m === "Service" || m === "Handler" || m === "UseCase") ? "services" : "mappers";
244
+ // Group layer-suffixed classes by the directory that directly holds them.
245
+ const byParent = {};
246
+ for (const f of ktFiles) {
247
+ if (handledByLayerDir.has(f)) continue;
248
+ const m = f.match(suffixRe);
249
+ if (!m) continue;
250
+ const parts = f.split("/");
251
+ const parent = parts.length >= 2 ? parts[parts.length - 2].toLowerCase() : "";
252
+ (byParent[parent] = byParent[parent] || []).push({ stem: m[1], kind: m[2] });
253
+ }
254
+ const add = (d, kind) => {
255
+ if (!ktDomains[d]) ktDomains[d] = { controllers: 0, services: 0, mappers: 0, dtos: 0, totalFiles: 0 };
256
+ ktDomains[d][bucket(kind)]++;
257
+ ktDomains[d].totalFiles++;
258
+ };
259
+ for (const [parent, files] of Object.entries(byParent)) {
260
+ // A feature package is one whose classes are named after it (user/UserController.kt).
261
+ // Otherwise the directory is a flat root/app package (app/UserController.kt,
262
+ // app/OrderController.kt) and each class-name stem is its own domain.
263
+ const stems = files.map(x => x.stem.toLowerCase());
264
+ const featurePkg = parent.length > 1 && !skipNames.includes(parent) && !layerKw.includes(parent)
265
+ && parent !== "kotlin" && parent !== "app" && stems.some(st => st.startsWith(parent.replace(/-/g, "")));
266
+ if (strict && !featurePkg) continue;
267
+ for (const x of files) {
268
+ const d = featurePkg ? parent : x.stem.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
269
+ if (d.length > 1 && !skipNames.includes(d)) add(d, x.kind);
270
+ }
210
271
  }
272
+ if (rootTail && isFlatRootArtifact(rootTail) && Object.keys(ktDomains).length > 1) delete ktDomains[rootTail];
273
+ }
274
+ for (const [d, data] of Object.entries(ktDomains)) {
275
+ if (data.totalFiles > 0) backendDomains.push({ name: d, type: "backend", ...data, pattern: "kotlin-single" });
211
276
  }
212
277
  }
213
278