claudeos-core 2.4.4 → 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.
- package/CHANGELOG.md +81 -0
- package/README.de.md +9 -9
- package/README.es.md +9 -9
- package/README.fr.md +9 -9
- package/README.hi.md +9 -9
- package/README.ja.md +9 -9
- package/README.ko.md +9 -9
- package/README.md +9 -9
- package/README.ru.md +9 -9
- package/README.vi.md +9 -9
- package/README.zh-CN.md +9 -9
- package/bin/commands/init.js +121 -24
- package/bin/commands/lint.js +2 -0
- package/bin/commands/memory.js +10 -3
- package/content-validator/index.js +82 -13
- package/lib/env-parser.js +50 -12
- package/lib/memory-scaffold.js +35 -16
- package/manifest-generator/index.js +15 -4
- package/package.json +1 -1
- package/pass-prompts/templates/angular/pass3.md +2 -1
- package/pass-prompts/templates/common/claude-md-scaffold.md +1 -1
- package/pass-prompts/templates/common/pass3a-facts.md +11 -9
- package/pass-prompts/templates/common/pass4.md +3 -3
- package/pass-prompts/templates/java-spring/pass3.md +3 -3
- package/pass-prompts/templates/kotlin-spring/pass3.md +2 -2
- package/pass-prompts/templates/node-express/pass3.md +1 -1
- package/pass-prompts/templates/node-fastify/pass3.md +1 -0
- package/pass-prompts/templates/node-nestjs/pass3.md +1 -0
- package/pass-prompts/templates/node-nextjs/pass3.md +1 -1
- package/pass-prompts/templates/node-vite/pass3.md +1 -0
- package/pass-prompts/templates/python-django/pass3.md +1 -1
- package/pass-prompts/templates/python-fastapi/pass3.md +1 -1
- package/pass-prompts/templates/python-flask/pass3.md +1 -0
- package/pass-prompts/templates/vue-nuxt/pass3.md +1 -0
- package/plan-installer/domain-grouper.js +4 -1
- package/plan-installer/index.js +26 -7
- package/plan-installer/pass3-context-builder.js +10 -0
- package/plan-installer/prompt-generator.js +18 -2
- package/plan-installer/scanners/scan-frontend.js +67 -6
- package/plan-installer/scanners/scan-java.js +145 -14
- package/plan-installer/scanners/scan-kotlin.js +68 -3
- package/plan-installer/scanners/scan-node.js +115 -0
- package/plan-installer/scanners/scan-python.js +56 -0
- package/plan-installer/source-paths.js +61 -0
- package/plan-installer/stack-detector.js +262 -24
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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).
|
|
@@ -17,11 +17,49 @@ const { glob } = require("glob");
|
|
|
17
17
|
// Normalize backslash paths from glob on Windows to forward slashes
|
|
18
18
|
const norm = (p) => p.replace(/\\/g, "/");
|
|
19
19
|
|
|
20
|
+
// v2.5.0 — Module-aware scanning.
|
|
21
|
+
// Source roots (`[<module>/]src/main/java`, `[<module>/]src/main/resources`)
|
|
22
|
+
// are discovered ONCE with a single ignore-filtered walk; every subsequent
|
|
23
|
+
// pattern is then anchored at each discovered module prefix. This finds
|
|
24
|
+
// Gradle/Maven multi-module layouts (`api/src/main/java/...`) with the same
|
|
25
|
+
// pattern set as a single-module root, without re-walking the whole tree
|
|
26
|
+
// (node_modules, web bundles, build output) for every per-domain glob.
|
|
27
|
+
// `src/test/**` and `buildSrc/` are excluded: test-fixture projects
|
|
28
|
+
// (`src/test/resources/projects/demo/src/main/java/...`) and Gradle
|
|
29
|
+
// convention plugins are not application modules.
|
|
30
|
+
const JAVA_ROOT_IGNORE = ["**/node_modules/**", "**/build/**", "**/target/**", "**/out/**", "**/.gradle/**", "**/generated/**", "**/.git/**", "**/src/test/**", "**/buildSrc/**"];
|
|
31
|
+
|
|
32
|
+
async function discoverModulePrefixes(ROOT) {
|
|
33
|
+
const javaRoots = (await glob("**/src/main/java/", { cwd: ROOT, ignore: JAVA_ROOT_IGNORE })).map(norm);
|
|
34
|
+
const resRoots = (await glob("**/src/main/resources/", { cwd: ROOT, ignore: JAVA_ROOT_IGNORE })).map(norm);
|
|
35
|
+
const prefixes = new Set();
|
|
36
|
+
for (const r of [...javaRoots, ...resRoots]) {
|
|
37
|
+
const m = r.replace(/\/$/, "").match(/^(.*?)src\/main\/(?:java|resources)$/);
|
|
38
|
+
if (m) prefixes.add(m[1]); // "" for root, "api/" for a module
|
|
39
|
+
}
|
|
40
|
+
return [...prefixes].sort();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Run one `src/main/...`-relative pattern against every discovered module
|
|
44
|
+
// prefix and return the merged, normalized, de-duplicated file list.
|
|
45
|
+
function makeModuleGlob(ROOT, prefixes) {
|
|
46
|
+
return async (pattern) => {
|
|
47
|
+
const out = new Set();
|
|
48
|
+
for (const pre of prefixes) {
|
|
49
|
+
for (const f of await glob(pre + pattern, { cwd: ROOT })) out.add(norm(f));
|
|
50
|
+
}
|
|
51
|
+
return [...out];
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
20
55
|
async function scanJavaDomains(stack, ROOT) {
|
|
21
56
|
const backendDomains = [];
|
|
22
57
|
let rootPackage = null;
|
|
23
58
|
|
|
24
|
-
const
|
|
59
|
+
const modulePrefixes = await discoverModulePrefixes(ROOT);
|
|
60
|
+
const gj = makeModuleGlob(ROOT, modulePrefixes.length ? modulePrefixes : [""]);
|
|
61
|
+
|
|
62
|
+
const javaFiles = (await gj("src/main/java/**/*.java"));
|
|
25
63
|
|
|
26
64
|
// v2.4.0 — Pick the LONGEST package prefix (1-4 segments) that still
|
|
27
65
|
// covers ≥80% of layer-bearing files. Pre-v2.4.0 the first matched file
|
|
@@ -62,8 +100,86 @@ async function scanJavaDomains(stack, ROOT) {
|
|
|
62
100
|
const domainMap = {};
|
|
63
101
|
let detectedPattern = null;
|
|
64
102
|
|
|
103
|
+
// v2.5.0 — Flat-layout guard for Pattern B/D and the supplementary scan.
|
|
104
|
+
//
|
|
105
|
+
// In the standard Spring Initializr layout the layer dirs sit DIRECTLY
|
|
106
|
+
// under the root package: `com/example/demo/controller/UserController.java`.
|
|
107
|
+
// The Pattern B glob `**/*/controller/*.java` matched that with `*` =
|
|
108
|
+
// `demo` (the root package's last segment), so every flat project was
|
|
109
|
+
// classified as "Pattern B, single domain named after the package" and
|
|
110
|
+
// Pattern C (domain from class name) was unreachable.
|
|
111
|
+
//
|
|
112
|
+
// Two signals must BOTH hold for a `{d}/{layer}/` path to count as flat:
|
|
113
|
+
// 1. `{d}` is the root package's last segment (the layer dir is a
|
|
114
|
+
// direct child of the root package), AND
|
|
115
|
+
// 2. none of the `*Controller` class stems under `{d}/controller/`
|
|
116
|
+
// start with `{d}` — i.e. the classes are named after OTHER things
|
|
117
|
+
// (`UserController`, `OrderController` under `demo/`).
|
|
118
|
+
// Signal 2 keeps single-domain domain-first projects intact: in
|
|
119
|
+
// `com/example/payment/controller/PaymentController.java` the root
|
|
120
|
+
// package also ends in `payment`, but the controller stem IS `payment`,
|
|
121
|
+
// so it stays Pattern B.
|
|
122
|
+
//
|
|
123
|
+
// Signal 2 looks at EVERY layer class under the base dir (controller,
|
|
124
|
+
// service, mapper, repository, dao, dto), not only controllers: a
|
|
125
|
+
// single-domain project such as `account/{controller/LoginController,
|
|
126
|
+
// service/AccountService, dto/LoginDto}` is domain-first because
|
|
127
|
+
// `AccountService` is named after the package, even though no controller is.
|
|
128
|
+
// A `*Application.java` (Spring Boot main class) sitting DIRECTLY in the base
|
|
129
|
+
// dir is a positive flat signal on its own — Initializr places it there,
|
|
130
|
+
// domain-first projects keep it one level above the domain packages.
|
|
131
|
+
//
|
|
132
|
+
// Base dirs: the root package, plus — for a file inside a Gradle/Maven
|
|
133
|
+
// module — `<rootPkg>/<moduleName>` (`api/src/main/java/com/example/api/
|
|
134
|
+
// controller/`), where the module's own sub-package plays the role of the
|
|
135
|
+
// Initializr base package and the domains again come from class names.
|
|
136
|
+
const rootPkgPath = rootPackage ? rootPackage.replace(/\./g, "/") : null;
|
|
137
|
+
const escRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
138
|
+
const flatDirCache = new Map();
|
|
139
|
+
const LAYER_CLASS_RE = /^(?:controller|service|mapper|repository|dao|dto)\/([A-Za-z0-9]+?)(?:Controller|Service|Mapper|Repository|Dao|Dto)\.java$/;
|
|
140
|
+
const isFlatBase = (base) => {
|
|
141
|
+
if (!flatDirCache.has(base)) {
|
|
142
|
+
const dirRe = new RegExp(`(^|/)src/main/java/${escRe(base)}/`);
|
|
143
|
+
const under = [];
|
|
144
|
+
for (const x of javaFiles) {
|
|
145
|
+
const m = x.match(dirRe);
|
|
146
|
+
if (m) under.push(x.slice(m.index + m[0].length));
|
|
147
|
+
}
|
|
148
|
+
const appInBase = under.some(rel => /^[A-Za-z0-9]*Application\.java$/.test(rel));
|
|
149
|
+
const stems = under.map(rel => (rel.match(LAYER_CLASS_RE) || [])[1]).filter(Boolean).map(s => s.toLowerCase());
|
|
150
|
+
const tail = base.split("/").pop().toLowerCase();
|
|
151
|
+
flatDirCache.set(base, appInBase || (stems.length > 0 && !stems.some(s => s.startsWith(tail))));
|
|
152
|
+
}
|
|
153
|
+
return flatDirCache.get(base);
|
|
154
|
+
};
|
|
155
|
+
// Directories (relative to src/main/java) holding a Spring Boot main class
|
|
156
|
+
// (`*Application.java`). The Initializr base package is wherever that class
|
|
157
|
+
// lives, independent of `rootPackage` (which is capped at 4 segments and
|
|
158
|
+
// therefore misses `kr/co/<org>/<proj>/<app>` style base packages).
|
|
159
|
+
const appBases = [...new Set(javaFiles
|
|
160
|
+
.map(f => (f.match(/src\/main\/java\/(.+)\/[A-Za-z0-9]*Application\.java$/) || [])[1])
|
|
161
|
+
.filter(Boolean))];
|
|
162
|
+
const isFlatLayerPath = (f, layerSegment) => {
|
|
163
|
+
if (!rootPkgPath && appBases.length === 0) return false;
|
|
164
|
+
const bases = [rootPkgPath, ...appBases].filter(Boolean);
|
|
165
|
+
const pre = modulePrefixes.find(p => p && f.startsWith(p));
|
|
166
|
+
if (pre && rootPkgPath) bases.push(`${rootPkgPath}/${pre.replace(/\/$/, "").split("/").pop()}`);
|
|
167
|
+
for (const base of bases) {
|
|
168
|
+
const layerRe = new RegExp(`(^|/)src/main/java/${escRe(base)}/${escRe(layerSegment)}/[^/]+\\.java$`);
|
|
169
|
+
if (layerRe.test(f) && isFlatBase(base)) return true;
|
|
170
|
+
}
|
|
171
|
+
return false;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
// Controllers that Pattern B skipped as "flat" (layer dir directly under the
|
|
175
|
+
// base package). If another pattern wins (mixed tree: `demo/controller/
|
|
176
|
+
// HomeController.java` next to `demo/user/controller/UserController.java`),
|
|
177
|
+
// Pattern C never runs, so these are re-attached below by class name —
|
|
178
|
+
// a controller must never silently belong to no domain.
|
|
179
|
+
const flatSkippedControllers = [];
|
|
180
|
+
|
|
65
181
|
// Pattern A: controller/{domain}/*.java (layer-first — domain under controller)
|
|
66
|
-
const controllersA = (await
|
|
182
|
+
const controllersA = (await gj("src/main/java/**/controller/*/*.java"));
|
|
67
183
|
for (const f of controllersA) {
|
|
68
184
|
const m = f.match(/controller\/([^/]+)\//);
|
|
69
185
|
if (m) {
|
|
@@ -77,9 +193,10 @@ async function scanJavaDomains(stack, ROOT) {
|
|
|
77
193
|
// Pattern B/D: {domain}/controller/*.java (domain-first — controller under domain)
|
|
78
194
|
// D extends B: {module}/{domain}/controller/ — auto-upgrade to module/domain on name conflict
|
|
79
195
|
if (!detectedPattern) {
|
|
80
|
-
const controllersB = (await
|
|
196
|
+
const controllersB = (await gj("src/main/java/**/*/controller/*.java"));
|
|
81
197
|
const domainPaths = {};
|
|
82
198
|
for (const f of controllersB) {
|
|
199
|
+
if (isFlatLayerPath(f, "controller")) { flatSkippedControllers.push(f); continue; }
|
|
83
200
|
const m = f.match(/\/([^/]+)\/controller\/[^/]+\.java$/);
|
|
84
201
|
if (m) {
|
|
85
202
|
const d = m[1];
|
|
@@ -115,7 +232,7 @@ async function scanJavaDomains(stack, ROOT) {
|
|
|
115
232
|
|
|
116
233
|
// Pattern E: DDD/Hexagonal — {domain}/adapter/in/web/*.java or {domain}/adapter/in/rest/*.java
|
|
117
234
|
if (!detectedPattern) {
|
|
118
|
-
const controllersE = (await
|
|
235
|
+
const controllersE = (await gj("src/main/java/**/adapter/in/{web,rest}/*.java"));
|
|
119
236
|
for (const f of controllersE) {
|
|
120
237
|
const m = f.match(/\/([^/]+)\/adapter\/in\/(web|rest)\/[^/]+\.java$/);
|
|
121
238
|
if (m) {
|
|
@@ -129,7 +246,7 @@ async function scanJavaDomains(stack, ROOT) {
|
|
|
129
246
|
|
|
130
247
|
// Pattern C: Flat structure — controller/*.java (no domain directory, extract domain from class name)
|
|
131
248
|
if (!detectedPattern) {
|
|
132
|
-
const controllersC = (await
|
|
249
|
+
const controllersC = (await gj("src/main/java/**/controller/*.java"));
|
|
133
250
|
for (const f of controllersC) {
|
|
134
251
|
const m = f.match(/\/([A-Z][a-zA-Z]*)Controller\.java$/);
|
|
135
252
|
if (m) {
|
|
@@ -141,16 +258,30 @@ async function scanJavaDomains(stack, ROOT) {
|
|
|
141
258
|
if (Object.keys(domainMap).length > 0) detectedPattern = "C";
|
|
142
259
|
}
|
|
143
260
|
|
|
261
|
+
// Mixed tree: Pattern B/D/E claimed the tree, but flat controllers under the
|
|
262
|
+
// base package were skipped. Attach each by class name as a Pattern C
|
|
263
|
+
// domain (`HomeController` → `home`) so it is analyzed and gets rules.
|
|
264
|
+
if (detectedPattern && detectedPattern !== "C") {
|
|
265
|
+
for (const f of flatSkippedControllers) {
|
|
266
|
+
const m = f.match(/\/([A-Z][a-zA-Z]*)Controller\.java$/);
|
|
267
|
+
if (!m) continue;
|
|
268
|
+
const d = m[1].toLowerCase();
|
|
269
|
+
if (!domainMap[d]) domainMap[d] = { controllers: 0, services: 0, mappers: 0, dtos: 0, xmlMappers: 0, pattern: "C" };
|
|
270
|
+
domainMap[d].controllers++;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
144
274
|
// ── Supplementary scan: detect domains without controllers (service/dao/aggregator/facade/usecase only) ──
|
|
145
275
|
// Runs for ALL detected patterns (A/B/C/D/E) to catch core-only domains
|
|
146
276
|
{
|
|
147
|
-
const serviceDirs = (await
|
|
148
|
-
const mapperDirs = (await
|
|
149
|
-
const orchestrationDirs = (await
|
|
277
|
+
const serviceDirs = (await gj("src/main/java/**/*/service/*.java"));
|
|
278
|
+
const mapperDirs = (await gj("src/main/java/**/*/{mapper,repository,dao}/*.java"));
|
|
279
|
+
const orchestrationDirs = (await gj("src/main/java/**/*/{aggregator,facade,usecase,orchestrator}/*.java"));
|
|
150
280
|
const allServiceFiles = [...serviceDirs, ...mapperDirs, ...orchestrationDirs];
|
|
151
281
|
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
282
|
for (const f of allServiceFiles) {
|
|
153
283
|
const m = f.match(/\/([^/]+)\/(service|mapper|repository|dao|aggregator|facade|usecase|orchestrator)\/[^/]+\.java$/);
|
|
284
|
+
if (m && isFlatLayerPath(f, m[2])) continue; // flat layout: layer dir directly under root package
|
|
154
285
|
if (m) {
|
|
155
286
|
const d = m[1];
|
|
156
287
|
if (!domainMap[d] && !skipDomains.includes(d) && !/^v\d+$/.test(d)) {
|
|
@@ -196,11 +327,11 @@ async function scanJavaDomains(stack, ROOT) {
|
|
|
196
327
|
? `src/main/resources/{mapper,mybatis}/**/{${dn}/${capDn}*.xml,${capDn}*.xml}`
|
|
197
328
|
: `src/main/resources/{mapper,mybatis}/**/${dn}/*.xml`;
|
|
198
329
|
|
|
199
|
-
const svc = await
|
|
200
|
-
const mpr = await
|
|
201
|
-
const dto = await
|
|
202
|
-
const xml = await
|
|
203
|
-
const agg = aggGlob ? await
|
|
330
|
+
const svc = await gj(svcGlob);
|
|
331
|
+
const mpr = await gj(mprGlob);
|
|
332
|
+
const dto = await gj(dtoGlob);
|
|
333
|
+
const xml = await gj(xmlGlob);
|
|
334
|
+
const agg = aggGlob ? await gj(aggGlob) : [];
|
|
204
335
|
domainMap[d].services = svc.length + agg.length;
|
|
205
336
|
domainMap[d].mappers = mpr.length;
|
|
206
337
|
domainMap[d].dtos = dto.length;
|
|
@@ -233,7 +364,7 @@ async function scanJavaDomains(stack, ROOT) {
|
|
|
233
364
|
// domains with healthy direct-layout file counts.
|
|
234
365
|
const standardCount = svc.length + agg.length + mpr.length + dto.length + xml.length;
|
|
235
366
|
if (standardCount === 0 && (p === "B" || p === "D")) {
|
|
236
|
-
const deepFiles = (await
|
|
367
|
+
const deepFiles = (await gj(`src/main/java/**/${dn}/**/*.java`));
|
|
237
368
|
// v2.4.0 — extended layer recognition. Enterprise codebases
|
|
238
369
|
// commonly include implementation/support layers beyond the canonical
|
|
239
370
|
// 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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
|
|
@@ -8,6 +8,24 @@
|
|
|
8
8
|
const path = require("path");
|
|
9
9
|
const { glob } = require("glob");
|
|
10
10
|
|
|
11
|
+
// v2.5.0 — Layer-first stem de-duplication (shared shape with scan-python.js).
|
|
12
|
+
// For every plural key whose singular form is ALSO a key, fold the plural
|
|
13
|
+
// into the singular via `merge(into, from)` and drop the plural. Handles
|
|
14
|
+
// `-ies`→`-y`, `-(s|x|z|ch|sh)es`→base, and plain `-s`. No merge happens
|
|
15
|
+
// when only one form exists — the scanner never invents a singular.
|
|
16
|
+
function mergePluralStems(byDomain, merge) {
|
|
17
|
+
for (const name of Object.keys(byDomain)) {
|
|
18
|
+
const cands = [];
|
|
19
|
+
if (/ies$/.test(name)) cands.push(name.slice(0, -3) + "y");
|
|
20
|
+
if (/(?:s|x|z|ch|sh)es$/.test(name)) cands.push(name.slice(0, -2));
|
|
21
|
+
if (/[^s]s$/.test(name)) cands.push(name.slice(0, -1));
|
|
22
|
+
const singular = cands.find(c => c !== name && byDomain[c]);
|
|
23
|
+
if (!singular) continue;
|
|
24
|
+
merge(byDomain[singular], byDomain[name]);
|
|
25
|
+
delete byDomain[name];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
11
29
|
async function scanNodeDomains(stack, ROOT) {
|
|
12
30
|
const backendDomains = [];
|
|
13
31
|
const skipDirs = ["common", "shared", "config", "utils", "lib", "core", "main", "interfaces", "types", "constants", "guards", "decorators", "pipes", "filters", "interceptors"];
|
|
@@ -27,7 +45,104 @@ async function scanNodeDomains(stack, ROOT) {
|
|
|
27
45
|
}
|
|
28
46
|
}
|
|
29
47
|
|
|
48
|
+
// v2.5.0 — Layer-first layouts (Express / Fastify / Koa): src/controllers/,
|
|
49
|
+
// src/routes/, src/services/, src/models/. Every folder is a LAYER, so the
|
|
50
|
+
// loop below used to emit "controllers", "services", "routes" as domains.
|
|
51
|
+
// When (almost) every candidate folder is a layer name, derive domains from
|
|
52
|
+
// the file stems inside those layers instead: user.controller.js,
|
|
53
|
+
// users.routes.ts, orderService.js → user, users, order.
|
|
54
|
+
const LAYER_DIRS = new Set(["controllers", "controller", "routes", "route", "routers", "router", "services", "service",
|
|
55
|
+
"models", "model", "repositories", "repository", "middlewares", "middleware", "handlers", "handler",
|
|
56
|
+
"validators", "validations", "schemas", "dtos", "entities", "dao", "helpers", "api"]);
|
|
57
|
+
// Infrastructure folders that are neither layers nor features.
|
|
58
|
+
const NODE_GENERIC_DIRS = new Set(["db", "database", "migrations", "seeds", "scripts", "public", "static", "views", "templates",
|
|
59
|
+
"assets", "test", "tests", "__tests__", "mocks", "__mocks__", "docs", "node_modules", "dist", "build"]);
|
|
60
|
+
const candidateNames = srcDirs.map(d => path.basename(d.replace(/\/$/, ""))).filter(n => !skipDirs.includes(n) && !NODE_GENERIC_DIRS.has(n));
|
|
61
|
+
const layerNames = candidateNames.filter(n => LAYER_DIRS.has(n));
|
|
62
|
+
// A tree is layer-first only when a ROUTING layer folder exists at the
|
|
63
|
+
// top (`controllers/`, `routes/`, `handlers/`, `api/`). Data-only layer
|
|
64
|
+
// folders (`entities/`, `dtos/`, `schemas/`, `models/`) also appear in
|
|
65
|
+
// module-first NestJS trees (`src/users/`, `src/orders/` + shared
|
|
66
|
+
// `src/entities/`), where the modules are the domains — treating that as
|
|
67
|
+
// layer-first would rename `users` to `user` and invent one-file domains.
|
|
68
|
+
const ROUTING_LAYERS = new Set(["controllers", "controller", "routes", "route", "routers", "router", "handlers", "handler", "api"]);
|
|
69
|
+
const hasRoutingLayer = layerNames.some(n => ROUTING_LAYERS.has(n));
|
|
70
|
+
// Then: at least two layer folders (or the only candidate is a layer).
|
|
71
|
+
// Remaining non-layer siblings (`src/jobs/`, `src/billing/`) are MIXED-
|
|
72
|
+
// layout feature folders and become whole-folder domains — they must not
|
|
73
|
+
// disable the layer-first path, which would resurrect `controllers` /
|
|
74
|
+
// `routes` / `services` as domains.
|
|
75
|
+
if (hasRoutingLayer && (layerNames.length >= 2 || (layerNames.length === 1 && candidateNames.length === 1))) {
|
|
76
|
+
const byDomain = {};
|
|
77
|
+
const featureNames = candidateNames.filter(n => !LAYER_DIRS.has(n));
|
|
78
|
+
for (const dir of srcDirs) {
|
|
79
|
+
const name = path.basename(dir.replace(/\/$/, ""));
|
|
80
|
+
if (!featureNames.includes(name)) continue;
|
|
81
|
+
const files = await glob(`${dir.replace(/\\/g, "/").replace(/\/?$/, "/")}**/*.{ts,js,mjs,cjs}`, { cwd: ROOT, ignore: ["**/*.spec.*", "**/*.test.*", "**/*.d.ts", "**/node_modules/**"] });
|
|
82
|
+
if (files.length === 0) continue;
|
|
83
|
+
const e = (byDomain[name] = byDomain[name] || { name, type: "backend", controllers: 0, services: 0, dtos: 0, totalFiles: 0 });
|
|
84
|
+
for (const f of files) {
|
|
85
|
+
if (/controller|router|route|handler/.test(f)) e.controllers++;
|
|
86
|
+
if (/service/.test(f)) e.services++;
|
|
87
|
+
if (/dto|schema|type|validator/.test(f)) e.dtos++;
|
|
88
|
+
e.totalFiles++;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// Layer/role suffixes are stripped REPEATEDLY (`email.service.impl.ts` →
|
|
92
|
+
// `email`, not `emailimpl`); `impl`, `base`, `abstract`, `interface`,
|
|
93
|
+
// `types`, `spec` are role words, not domains.
|
|
94
|
+
// A suffix counts only after a separator (`user.controller`, `user-controller`)
|
|
95
|
+
// or as a PascalCase word (`userController`, `UserService`) — never as a bare
|
|
96
|
+
// substring, so `prototype` is not cut down to `proto`.
|
|
97
|
+
const DOTTED_SUFFIX_RE = /[.\-_](?:controller|router|routes?|service|handler|model|repository|repo|middleware|validator|schema|dto|entity|dao|helper|impl|base|abstract|interface|types?|spec|mock|factory|utils?)s?$/i;
|
|
98
|
+
const CAMEL_SUFFIX_RE = /(?<=[a-z0-9])(?:Controller|Router|Routes?|Service|Handler|Model|Repository|Repo|Middleware|Validator|Schema|Dto|DTO|Entity|Dao|DAO|Helper|Impl|Base|Abstract|Interface|Types?|Spec|Mock|Factory|Utils?)s?$/;
|
|
99
|
+
const stemOf = (f) => {
|
|
100
|
+
const base = path.basename(f).replace(/\.(ts|js|mjs|cjs)$/, "");
|
|
101
|
+
// user.controller | user-controller | userController | users.routes | UserService | index
|
|
102
|
+
let stem = base;
|
|
103
|
+
for (let i = 0; i < 6; i++) {
|
|
104
|
+
const next = stem.replace(DOTTED_SUFFIX_RE, "").replace(CAMEL_SUFFIX_RE, "");
|
|
105
|
+
if (next === stem) break;
|
|
106
|
+
stem = next;
|
|
107
|
+
}
|
|
108
|
+
stem = stem.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase().replace(/^[.\-_]+|[.\-_]+$/g, "");
|
|
109
|
+
return stem && stem !== "index" && stem !== "app" && stem !== "main" ? stem : null;
|
|
110
|
+
};
|
|
111
|
+
// Role comes from the LAYER FOLDER (authoritative), never from substrings
|
|
112
|
+
// of the file path (`models/prototype.ts` is not a dto because it contains
|
|
113
|
+
// "type").
|
|
114
|
+
const ROLE_OF_LAYER = (layer) => ROUTING_LAYERS.has(layer) ? "controllers"
|
|
115
|
+
: /^services?$/.test(layer) ? "services"
|
|
116
|
+
: /^(dtos?|schemas?|validators?|validations|entities)$/.test(layer) ? "dtos" : null;
|
|
117
|
+
for (const dir of srcDirs) {
|
|
118
|
+
const layer = path.basename(dir.replace(/\/$/, ""));
|
|
119
|
+
if (!LAYER_DIRS.has(layer)) continue;
|
|
120
|
+
const role = ROLE_OF_LAYER(layer);
|
|
121
|
+
const files = await glob(`${dir.replace(/\\/g, "/").replace(/\/?$/, "/")}**/*.{ts,js,mjs,cjs}`, { cwd: ROOT, ignore: ["**/*.spec.*", "**/*.test.*", "**/*.d.ts"] });
|
|
122
|
+
for (const f of files) {
|
|
123
|
+
const d = stemOf(f);
|
|
124
|
+
if (!d) continue;
|
|
125
|
+
const e = (byDomain[d] = byDomain[d] || { name: d, type: "backend", controllers: 0, services: 0, dtos: 0, totalFiles: 0 });
|
|
126
|
+
if (role) e[role]++;
|
|
127
|
+
e.totalFiles++;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// Merge a plural stem into its singular twin when BOTH exist
|
|
131
|
+
// (`users.routes.js` + `user.controller.js` → `user`). Only pairs are
|
|
132
|
+
// merged — a lone `orders` stays `orders`, so no guessed singularization.
|
|
133
|
+
mergePluralStems(byDomain, (into, from) => {
|
|
134
|
+
into.controllers += from.controllers; into.services += from.services;
|
|
135
|
+
into.dtos += from.dtos; into.totalFiles += from.totalFiles;
|
|
136
|
+
});
|
|
137
|
+
for (const d of Object.values(byDomain)) backendDomains.push({ ...d, pattern: "layer-first" });
|
|
138
|
+
if (backendDomains.length > 0) return { backendDomains };
|
|
139
|
+
// fall through to the directory loop if stems yielded nothing
|
|
140
|
+
}
|
|
141
|
+
|
|
30
142
|
for (let dir of srcDirs) {
|
|
143
|
+
// A folder named after a layer (`entities/`, `dtos/`, `controllers/`) is
|
|
144
|
+
// never a feature domain, whichever layout won above.
|
|
145
|
+
if (LAYER_DIRS.has(path.basename(dir.replace(/\/$/, "")))) continue;
|
|
31
146
|
if (!dir.endsWith("/")) dir += "/";
|
|
32
147
|
const name = path.basename(dir.replace(/\/$/, ""));
|
|
33
148
|
if (skipDirs.includes(name)) continue;
|
|
@@ -7,6 +7,22 @@
|
|
|
7
7
|
const path = require("path");
|
|
8
8
|
const { glob } = require("glob");
|
|
9
9
|
|
|
10
|
+
// v2.5.0 — Layer-first stem de-duplication (same rule as scan-node.js).
|
|
11
|
+
// Fold a plural key into its singular twin ONLY when both exist
|
|
12
|
+
// (`routers/users.py` + `models/user.py` → `user`); a lone plural is kept.
|
|
13
|
+
function mergePluralStems(byDomain, merge) {
|
|
14
|
+
for (const name of Object.keys(byDomain)) {
|
|
15
|
+
const cands = [];
|
|
16
|
+
if (/ies$/.test(name)) cands.push(name.slice(0, -3) + "y");
|
|
17
|
+
if (/(?:s|x|z|ch|sh)es$/.test(name)) cands.push(name.slice(0, -2));
|
|
18
|
+
if (/[^s]s$/.test(name)) cands.push(name.slice(0, -1));
|
|
19
|
+
const singular = cands.find(c => c !== name && byDomain[c]);
|
|
20
|
+
if (!singular) continue;
|
|
21
|
+
merge(byDomain[singular], byDomain[name]);
|
|
22
|
+
delete byDomain[name];
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
10
26
|
async function scanPythonDomains(stack, ROOT) {
|
|
11
27
|
const backendDomains = [];
|
|
12
28
|
|
|
@@ -46,6 +62,46 @@ async function scanPythonDomains(stack, ROOT) {
|
|
|
46
62
|
const appFiles = await glob(`${dir.replace(/\\/g, "/")}/*.py`, { cwd: ROOT });
|
|
47
63
|
backendDomains.push({ name, type: "backend", totalFiles: appFiles.length });
|
|
48
64
|
}
|
|
65
|
+
// v2.5.0 — Layer-first FastAPI/Flask: app/routers/users.py, app/models/user.py,
|
|
66
|
+
// app/schemas/user.py. Folders are layers, so derive domains from file stems.
|
|
67
|
+
if (backendDomains.filter(d => d.type === "backend").length === 0) {
|
|
68
|
+
const PY_LAYER_DIRS = new Set(["routers", "router", "routes", "route", "api", "endpoints", "views", "controllers",
|
|
69
|
+
"services", "service", "models", "model", "schemas", "schema", "repositories", "repository", "crud", "dao", "handlers"]);
|
|
70
|
+
const PY_GENERIC_DIRS = new Set(["core", "common", "utils", "__pycache__", "config", "db", "database", "tests", "test", "app", "static", "templates", "migrations",
|
|
71
|
+
"env", "venv", ".venv", "virtualenv", "node_modules", "site-packages", "scripts", "docs"]);
|
|
72
|
+
const allSub = (await glob("{app,src/app,src}/*/", { cwd: ROOT, ignore: ["**/venv/**", "**/.venv/**", "**/env/**", "**/virtualenv/**", "**/node_modules/**", "**/site-packages/**", "**/__pycache__/**"] }))
|
|
73
|
+
.map(d => d.replace(/\\/g, "/").replace(/\/?$/, "/"));
|
|
74
|
+
const baseOf = (d) => path.basename(d.replace(/\/$/, ""));
|
|
75
|
+
const parentOf = (d) => d.replace(/\/$/, "").split("/").slice(0, -1).join("/") + "/";
|
|
76
|
+
const layerDirs = allSub.filter(d => PY_LAYER_DIRS.has(baseOf(d)));
|
|
77
|
+
// Feature packages that sit NEXT TO the layer folders (same parent):
|
|
78
|
+
// `src/routers/` + `src/tasks/` is a mixed layout — `tasks` must become a
|
|
79
|
+
// domain too, not be silently dropped. Scanned symmetrically with the
|
|
80
|
+
// layer folders (both come from the same glob), never from other trees.
|
|
81
|
+
const layerParents = new Set(layerDirs.map(parentOf));
|
|
82
|
+
const featureDirs = allSub.filter(d => layerParents.has(parentOf(d)) && !PY_LAYER_DIRS.has(baseOf(d)) && !PY_GENERIC_DIRS.has(baseOf(d)));
|
|
83
|
+
if (layerDirs.length > 0) {
|
|
84
|
+
const byDomain = {};
|
|
85
|
+
for (const dir of layerDirs) {
|
|
86
|
+
const files = await glob(`${dir}*.py`, { cwd: ROOT });
|
|
87
|
+
for (const f of files) {
|
|
88
|
+
let stem = path.basename(f, ".py").replace(/_(router|routes?|service|model|schema|repository|crud|handler|views?|api)s?$/i, "").toLowerCase();
|
|
89
|
+
if (!stem || ["__init__", "base", "deps", "dependencies", "main", "app", "utils", "common"].includes(stem)) continue;
|
|
90
|
+
const e = (byDomain[stem] = byDomain[stem] || { name: stem, type: "backend", totalFiles: 0 });
|
|
91
|
+
e.totalFiles++;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
for (const dir of featureDirs) {
|
|
95
|
+
const files = await glob(`${dir}**/*.py`, { cwd: ROOT, ignore: ["**/__pycache__/**"] });
|
|
96
|
+
if (files.length === 0) continue;
|
|
97
|
+
const name = baseOf(dir).toLowerCase();
|
|
98
|
+
const e = (byDomain[name] = byDomain[name] || { name, type: "backend", totalFiles: 0 });
|
|
99
|
+
e.totalFiles += files.length;
|
|
100
|
+
}
|
|
101
|
+
mergePluralStems(byDomain, (into, from) => { into.totalFiles += from.totalFiles; });
|
|
102
|
+
for (const d of Object.values(byDomain)) backendDomains.push({ ...d, pattern: "layer-first" });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
49
105
|
if (backendDomains.filter(d => d.type === "backend").length === 0) {
|
|
50
106
|
const appDirs = await glob("{app,src/app}/*/", { cwd: ROOT });
|
|
51
107
|
for (let dir of appDirs) {
|