claudeos-core 2.2.0 → 2.3.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 (49) hide show
  1. package/CHANGELOG.md +1649 -907
  2. package/CONTRIBUTING.md +92 -92
  3. package/README.de.md +32 -0
  4. package/README.es.md +32 -0
  5. package/README.fr.md +32 -0
  6. package/README.hi.md +32 -0
  7. package/README.ja.md +32 -0
  8. package/README.ko.md +1018 -986
  9. package/README.md +1020 -987
  10. package/README.ru.md +32 -0
  11. package/README.vi.md +1019 -987
  12. package/README.zh-CN.md +32 -0
  13. package/bin/cli.js +152 -148
  14. package/bin/commands/init.js +1673 -1554
  15. package/bin/commands/lint.js +62 -0
  16. package/bin/commands/memory.js +438 -438
  17. package/bin/lib/cli-utils.js +206 -206
  18. package/claude-md-validator/index.js +184 -0
  19. package/claude-md-validator/reporter.js +66 -0
  20. package/claude-md-validator/structural-checks.js +528 -0
  21. package/content-validator/index.js +666 -441
  22. package/lib/expected-guides.js +23 -23
  23. package/lib/expected-outputs.js +90 -90
  24. package/lib/language-config.js +35 -35
  25. package/lib/memory-scaffold.js +1058 -1054
  26. package/lib/plan-parser.js +165 -165
  27. package/lib/staged-rules.js +118 -118
  28. package/manifest-generator/index.js +174 -174
  29. package/package.json +90 -87
  30. package/pass-json-validator/index.js +337 -337
  31. package/pass-prompts/templates/common/claude-md-scaffold.md +52 -10
  32. package/pass-prompts/templates/common/pass3-footer.md +402 -224
  33. package/pass-prompts/templates/common/pass3b-core-header.md +43 -0
  34. package/pass-prompts/templates/common/pass4.md +375 -305
  35. package/pass-prompts/templates/common/staging-override.md +26 -26
  36. package/pass-prompts/templates/node-vite/pass1.md +117 -117
  37. package/pass-prompts/templates/node-vite/pass2.md +78 -78
  38. package/pass-prompts/templates/python-flask/pass1.md +119 -119
  39. package/pass-prompts/templates/python-flask/pass2.md +85 -85
  40. package/plan-installer/domain-grouper.js +76 -76
  41. package/plan-installer/index.js +137 -137
  42. package/plan-installer/prompt-generator.js +188 -145
  43. package/plan-installer/scanners/scan-frontend.js +505 -473
  44. package/plan-installer/scanners/scan-java.js +226 -226
  45. package/plan-installer/scanners/scan-node.js +57 -57
  46. package/plan-installer/scanners/scan-python.js +85 -85
  47. package/plan-installer/stack-detector.js +482 -482
  48. package/plan-installer/structure-scanner.js +65 -65
  49. package/sync-checker/index.js +177 -177
@@ -1,473 +1,505 @@
1
- /**
2
- * ClaudeOS-Core — Frontend Structure Scanner
3
- *
4
- * Scans frontend project directory structure to discover domains.
5
- * Supports: Angular, Next.js, React, Vue.
6
- * Includes FSD (Feature-Sliced Design), components scan, and 4-stage fallback.
7
- * Also provides frontend file count statistics.
8
- */
9
-
10
- const fs = require("fs");
11
- const path = require("path");
12
- const { glob } = require("glob");
13
-
14
- // Project-level override: `.claudeos-scan.json` at project root can extend
15
- // the defaults. Supported fields (all optional, all additive — never replace
16
- // defaults):
17
- // {
18
- // "frontendScan": {
19
- // "platformKeywords": ["extra-platform", "custom-tier"],
20
- // "skipSubappNames": ["my-shared-dir"],
21
- // "minSubappFiles": 3
22
- // }
23
- // }
24
- // Invalid JSON or missing file: silently falls back to defaults.
25
- function loadScanOverrides(ROOT) {
26
- try {
27
- const content = fs.readFileSync(path.join(ROOT, ".claudeos-scan.json"), "utf-8");
28
- return JSON.parse(content) || {};
29
- } catch (_e) {
30
- return {};
31
- }
32
- }
33
-
34
- // Build output / cache / generated dirs that should never be scanned for
35
- // source code. Centralized so platform scan, Fallback E, and future scanners
36
- // share the same exclusion set.
37
- const BUILD_IGNORE_DIRS = [
38
- "**/node_modules/**",
39
- "**/build/**", "**/dist/**", "**/out/**",
40
- "**/.next/**", "**/.nuxt/**", "**/.svelte-kit/**", "**/.angular/**",
41
- "**/.turbo/**", "**/.cache/**", "**/.parcel-cache/**",
42
- "**/coverage/**", "**/storybook-static/**",
43
- "**/.vercel/**", "**/.netlify/**",
44
- ];
45
-
46
- // Test / story / type-declaration file globs.
47
- const TEST_FILE_IGNORE = [
48
- "**/*.spec.*", "**/*.test.*", "**/*.stories.*",
49
- "**/*.e2e.*", "**/*.cy.*",
50
- "**/__snapshots__/**", "**/__tests__/**",
51
- ];
52
-
53
- // Build a glob prefix from a glob-returned directory path. Glob v10+ strips
54
- // trailing slashes from results, and on Windows returns backslash paths;
55
- // without normalization, the pattern `${dir}**/*.tsx` becomes something like
56
- // `src/foo**/*.tsx` which only matches one level deep (foo/X.tsx) — not
57
- // nested paths like foo/routes/X.tsx. Ensuring a trailing `/` turns it into
58
- // `src/foo/**/*.tsx` which matches any depth.
59
- function dirGlobPrefix(dir) {
60
- const fwd = dir.replace(/\\/g, "/");
61
- return fwd.endsWith("/") ? fwd : fwd + "/";
62
- }
63
-
64
- async function scanFrontendDomains(stack, ROOT) {
65
- const frontendDomains = [];
66
-
67
- // ── Angular ──
68
- if (stack.frontend === "angular") {
69
- // Angular feature modules: src/app/*/ with *.component.ts or *.module.ts (+ monorepo apps/*/)
70
- const angularAppDirs = [
71
- ...await glob("{src/app,app}/*/", { cwd: ROOT }),
72
- ...await glob("{apps,packages}/*/src/app/*/", { cwd: ROOT, ignore: ["**/node_modules/**"] }),
73
- ];
74
- // Skip structural containers (modules/features/pages/views) at the
75
- // src/app/*/ level — the files INSIDE those containers are the real
76
- // features, and the Angular deep fallback below extracts them properly.
77
- const skipAngularDirs = ["shared", "core", "common", "layout", "layouts",
78
- "environments", "assets", "styles", "testing", "utils",
79
- "modules", "features", "pages", "views"];
80
- for (const dir of angularAppDirs) {
81
- const name = path.basename(dir.replace(/\/$/, ""));
82
- if (skipAngularDirs.includes(name) || name.startsWith("_") || name.startsWith(".")) continue;
83
- const files = await glob(`${dirGlobPrefix(dir)}**/*.ts`, { cwd: ROOT, ignore: ["**/*.spec.ts", "**/*.test.ts"] });
84
- if (files.length > 0) {
85
- const components = files.filter(f => /\.component\.ts$/.test(f)).length;
86
- const services = files.filter(f => /\.service\.ts$/.test(f)).length;
87
- const modules = files.filter(f => /\.module\.ts$/.test(f)).length;
88
- const pipes = files.filter(f => /\.pipe\.ts$/.test(f)).length;
89
- const directives = files.filter(f => /\.directive\.ts$/.test(f)).length;
90
- const guards = files.filter(f => /\.guard\.ts$/.test(f)).length;
91
- frontendDomains.push({ name, type: "frontend", components, services, modules, pipes, directives, guards, totalFiles: files.length });
92
- }
93
- }
94
-
95
- // Angular fallback: scan **/modules/*/ and **/features/*/ with *.component.ts detection
96
- if (frontendDomains.length === 0) {
97
- const deepAngularDirs = await glob("**/{modules,features,pages,views}/*/", { cwd: ROOT, ignore: ["**/node_modules/**", "**/dist/**", "**/.angular/**"] });
98
- for (const dir of deepAngularDirs) {
99
- const name = path.basename(dir.replace(/\/$/, ""));
100
- if (skipAngularDirs.includes(name) || name.startsWith("_") || name.startsWith(".")) continue;
101
- const files = await glob(`${dirGlobPrefix(dir)}**/*.ts`, { cwd: ROOT, ignore: ["**/*.spec.ts", "**/*.test.ts"] });
102
- if (files.length >= 2) {
103
- const components = files.filter(f => /\.component\.ts$/.test(f)).length;
104
- const services = files.filter(f => /\.service\.ts$/.test(f)).length;
105
- frontendDomains.push({ name, type: "frontend", components, services, totalFiles: files.length });
106
- }
107
- }
108
- }
109
- }
110
-
111
- // ── Next.js/React/Vue ──
112
- if (stack.frontend === "nextjs" || stack.frontend === "react" || stack.frontend === "vue") {
113
- // App Router / Pages Router / SPA domains (standard + monorepo + Vite SPA paths)
114
- const allDirs = [
115
- ...await glob("{app,src/app}/*/", { cwd: ROOT }),
116
- ...await glob("{pages,src/pages}/*/", { cwd: ROOT }),
117
- ...await glob("{apps,packages}/*/app/*/", { cwd: ROOT, ignore: ["**/node_modules/**"] }),
118
- ...await glob("{apps,packages}/*/src/app/*/", { cwd: ROOT, ignore: ["**/node_modules/**"] }),
119
- ...await glob("{apps,packages}/*/pages/*/", { cwd: ROOT, ignore: ["**/node_modules/**"] }),
120
- ...await glob("{apps,packages}/*/src/pages/*/", { cwd: ROOT, ignore: ["**/node_modules/**"] }),
121
- // Non-standard nested page paths (e.g., src/admin/pages/*, src/dashboard/app/*)
122
- ...await glob("src/*/{app,pages}/*/", { cwd: ROOT, ignore: ["**/node_modules/**"] }),
123
- // Vite SPA / CRA common paths (src/views/*, src/screens/*, src/routes/*)
124
- ...await glob("src/{views,screens,routes}/*/", { cwd: ROOT, ignore: ["**/node_modules/**"] }),
125
- ];
126
- // Reserved Next.js/Router segments + structural containers that are never
127
- // real route features. "components"/"hooks"/"widgets" under app/ or
128
- // pages/ are UI containers handled by dedicated scanners — not routes.
129
- const skipPages = ["api", "_app", "_document", "fonts", "not-found", "error", "loading",
130
- "components", "hooks", "widgets", "entities", "features", "modules",
131
- "lib", "libs", "utils", "util", "config", "types", "shared", "common", "assets"];
132
- for (const dir of allDirs) {
133
- const name = path.basename(dir);
134
- if (skipPages.includes(name) || name.startsWith("(") || name.startsWith("[") || name.startsWith("_") || name.startsWith(".")) continue;
135
- const files = await glob(`${dirGlobPrefix(dir)}**/*.{tsx,jsx,ts,js,vue}`, { cwd: ROOT });
136
- if (files.length > 0) {
137
- const pages = files.filter(f => /page\.|index\./.test(f)).length;
138
- const layouts = files.filter(f => /layout\./.test(f)).length;
139
- const clientFiles = files.filter(f => /client\./.test(f)).length;
140
- const serverFiles = pages + layouts;
141
- const components = files.filter(f => !/page\.|layout\.|index\.|client\./.test(f)).length;
142
- frontendDomains.push({
143
- name, type: "frontend", pages, layouts, clientFiles, serverFiles, components, totalFiles: files.length,
144
- rscPattern: clientFiles > 0 ? "RSC+Client split" : "default",
145
- });
146
- }
147
- }
148
-
149
- // FSD (Feature-Sliced Design): features/*, widgets/*, entities/*
150
- const fsdLayers = ["features", "widgets", "entities"];
151
- for (const layer of fsdLayers) {
152
- const fsdDirs = [...new Set([
153
- ...await glob(`{${layer},src/${layer}}/*/`, { cwd: ROOT }),
154
- ...await glob(`src/*/${layer}/*/`, { cwd: ROOT, ignore: ["**/node_modules/**"] }),
155
- ])];
156
- for (const dir of fsdDirs) {
157
- const name = path.basename(dir);
158
- if (["ui", "common", "shared", "lib", "config", "index"].includes(name)) continue;
159
- const files = await glob(`${dirGlobPrefix(dir)}**/*.{tsx,jsx,ts,js,vue}`, { cwd: ROOT, ignore: ["**/*.spec.*", "**/*.test.*", "**/*.stories.*"] });
160
- if (files.length > 0) {
161
- const uiFiles = files.filter(f => /\bui\b/.test(f)).length;
162
- const modelFiles = files.filter(f => /model|store|hook/.test(f)).length;
163
- frontendDomains.push({ name: `${layer}/${name}`, type: "frontend", components: uiFiles, models: modelFiles, totalFiles: files.length });
164
- }
165
- }
166
- }
167
-
168
- // components/* (existing + nested src/*/components/*)
169
- const compDirSet = new Set([
170
- ...await glob("{src/,}components/*/", { cwd: ROOT }),
171
- ...await glob("src/*/components/*/", { cwd: ROOT, ignore: ["**/node_modules/**"] }),
172
- ]);
173
- const compDirs = [...compDirSet];
174
- for (const dir of compDirs) {
175
- const name = path.basename(dir);
176
- if (["ui", "common", "shared", "layout", "icons"].includes(name)) continue;
177
- const files = await glob(`${dirGlobPrefix(dir)}**/*.{tsx,jsx,vue}`, { cwd: ROOT });
178
- if (files.length >= 2) {
179
- frontendDomains.push({ name: `comp-${name}`, type: "frontend", components: files.length, totalFiles: files.length });
180
- }
181
- }
182
-
183
- // ── Fallback: extract domains when primary scanners return 0 ──
184
- if (frontendDomains.length === 0) {
185
- // Fallback A: Next.js page.tsx / client.tsx based detection
186
- const pageFiles = await glob("**/page.{tsx,jsx}", { cwd: ROOT, ignore: ["**/node_modules/**", "**/.next/**"] });
187
- const domainSet = {};
188
- const skipNames = ["app", "src", "pages", "api", "_app", "_document"];
189
- for (const f of pageFiles) {
190
- const parts = f.replace(/\\/g, "/").split("/");
191
- const appIdx = parts.indexOf("app");
192
- const pagesIdx = parts.indexOf("pages");
193
- const baseIdx = appIdx >= 0 ? appIdx : pagesIdx;
194
- if (baseIdx >= 0 && baseIdx + 1 < parts.length - 1) {
195
- const domain = parts[baseIdx + 1];
196
- if (!skipNames.includes(domain) && !domain.startsWith("_") && !domain.startsWith("(") && !domain.startsWith("[") && !domain.startsWith(".")) {
197
- if (!domainSet[domain]) domainSet[domain] = { pages: 0, clientFiles: 0, totalFiles: 0 };
198
- domainSet[domain].pages++;
199
- domainSet[domain].totalFiles++;
200
- }
201
- }
202
- }
203
- const clientFiles = await glob("**/client.{tsx,jsx}", { cwd: ROOT, ignore: ["**/node_modules/**", "**/.next/**"] });
204
- for (const f of clientFiles) {
205
- const parts = f.replace(/\\/g, "/").split("/");
206
- const appIdx = parts.indexOf("app");
207
- const baseIdx = appIdx >= 0 ? appIdx : -1;
208
- if (baseIdx >= 0 && baseIdx + 1 < parts.length - 1) {
209
- const domain = parts[baseIdx + 1];
210
- if (domainSet[domain]) {
211
- domainSet[domain].clientFiles++;
212
- domainSet[domain].totalFiles++;
213
- }
214
- }
215
- }
216
- for (const [name, data] of Object.entries(domainSet)) {
217
- frontendDomains.push({
218
- name, type: "frontend", pages: data.pages, clientFiles: data.clientFiles, totalFiles: data.totalFiles,
219
- rscPattern: data.clientFiles > 0 ? "RSC+Client split" : "default",
220
- });
221
- }
222
-
223
- // Fallback B: FSD widgets/features/entities
224
- for (const layer of ["widgets", "features", "entities"]) {
225
- const layerFiles = await glob(`**/${layer}/*/**/*.{tsx,jsx,ts,js,vue}`, { cwd: ROOT, ignore: ["**/node_modules/**", "**/.next/**", "**/*.spec.*", "**/*.test.*"] });
226
- const layerDomains = {};
227
- for (const f of layerFiles) {
228
- const parts = f.replace(/\\/g, "/").split("/");
229
- const layerIdx = parts.indexOf(layer);
230
- if (layerIdx >= 0 && layerIdx + 1 < parts.length) {
231
- const domain = parts[layerIdx + 1];
232
- if (!["ui", "common", "shared", "lib", "config"].includes(domain)) {
233
- if (!layerDomains[domain]) layerDomains[domain] = 0;
234
- layerDomains[domain]++;
235
- }
236
- }
237
- }
238
- for (const [name, count] of Object.entries(layerDomains)) {
239
- frontendDomains.push({ name: `${layer}/${name}`, type: "frontend", totalFiles: count });
240
- }
241
- }
242
-
243
- // Fallback C: Deep components/**/components/*/ detection (React/CRA/Vite projects)
244
- if (frontendDomains.length === 0) {
245
- const deepCompDirs = await glob("**/components/*/", { cwd: ROOT, ignore: ["**/node_modules/**", "**/.next/**", "**/build/**", "**/dist/**", "**/.git/**", "**/vendor/**", "**/__pycache__/**", "**/coverage/**"] });
246
- const deepDomains = {};
247
- const skipDomainNames = ["ui", "common", "shared", "layout", "layouts", "icons", "assets", "config", "utils", "lib", "error", "footer", "header", "inputs", "template"];
248
- for (const dir of deepCompDirs) {
249
- const name = path.basename(dir.replace(/\/$/, ""));
250
- if (skipDomainNames.includes(name) || name.startsWith("_") || name.startsWith(".")) continue;
251
- const files = await glob(`${dirGlobPrefix(dir)}**/*.{tsx,jsx,ts,js,vue}`, { cwd: ROOT, ignore: ["**/*.spec.*", "**/*.test.*", "**/*.stories.*"] });
252
- if (files.length >= 2) {
253
- if (!deepDomains[name]) deepDomains[name] = { components: 0, totalFiles: 0 };
254
- deepDomains[name].components += files.length;
255
- deepDomains[name].totalFiles += files.length;
256
- }
257
- }
258
- for (const [name, data] of Object.entries(deepDomains)) {
259
- frontendDomains.push({ name, type: "frontend", components: data.components, totalFiles: data.totalFiles });
260
- }
261
- }
262
-
263
- // Fallback D: views/screens/containers/pages/routes deep detection
264
- if (frontendDomains.length === 0) {
265
- const domainDirPatterns = ["views", "screens", "containers", "pages", "routes", "modules", "domains"];
266
- const deepDirDomains = {};
267
- const skipDirNames = ["api", "auth", "_app", "_document", "index", "ui", "common", "shared",
268
- "layout", "layouts", "lib", "config", "utils", "assets", "hooks", "store", "types",
269
- "constants", "helpers", "services", "middleware", "interceptors", "guards", "decorators"];
270
-
271
- for (const pattern of domainDirPatterns) {
272
- const dirs = await glob(`**/${pattern}/*/`, { cwd: ROOT, ignore: ["**/node_modules/**", "**/.next/**", "**/build/**", "**/dist/**", "**/.git/**", "**/vendor/**", "**/__pycache__/**", "**/coverage/**"] });
273
- for (const dir of dirs) {
274
- const name = path.basename(dir.replace(/\/$/, ""));
275
- if (skipDirNames.includes(name) || name.startsWith("_") || name.startsWith(".") || name.startsWith("(") || name.startsWith("[")) continue;
276
- const files = await glob(`${dirGlobPrefix(dir)}**/*.{tsx,jsx,ts,js,vue}`, { cwd: ROOT, ignore: ["**/*.spec.*", "**/*.test.*", "**/*.stories.*"] });
277
- if (files.length >= 2) {
278
- if (!deepDirDomains[name]) deepDirDomains[name] = { components: 0, pages: 0, totalFiles: 0, sources: [] };
279
- const tsx = files.filter(f => /\.(tsx|jsx|vue)$/.test(f)).length;
280
- deepDirDomains[name].components += tsx;
281
- deepDirDomains[name].totalFiles += files.length;
282
- if (!deepDirDomains[name].sources.includes(pattern)) deepDirDomains[name].sources.push(pattern);
283
- }
284
- }
285
- }
286
- for (const [name, data] of Object.entries(deepDirDomains)) {
287
- frontendDomains.push({ name, type: "frontend", components: data.components, totalFiles: data.totalFiles, sources: data.sources });
288
- }
289
- }
290
-
291
- }
292
- }
293
-
294
- // ── Shared frontend patterns (run for ANY frontend: Angular, Next.js, React, Vue) ──
295
- //
296
- // These patterns don't depend on framework-specific file extensions or
297
- // folder conventions — they look for top-level segmentation by platform or
298
- // by routes/-file layouts, which appear across all frontend frameworks.
299
- if (stack.frontend) {
300
- // Read optional per-project override (.claudeos-scan.json).
301
- const overrides = loadScanOverrides(ROOT).frontendScan || {};
302
- // Platform-split layout: src/{platform}/{subapp}/ where platform is a
303
- // device/target-environment OR access-tier keyword. Both form the same
304
- // structural pattern (top-level segmentation with a common subapp layout).
305
- // Subapp name comes from the filesystem at scan time via path.basename —
306
- // no project-specific names are hardcoded.
307
- // Produces one domain per (platform, subapp) pair, named `{platform}-{subapp}`.
308
- // NOTE: 3-letter+ access-tier names only. The short `adm` abbreviation
309
- // is deliberately excluded — too ambiguous in isolation and false-positive
310
- // risk isn't worth the small convenience gain. If a project uses `src/adm/`
311
- // as an admin tier root, rename to `admin` or add `"adm"` to
312
- // `frontendScan.platformKeywords` in `.claudeos-scan.json`.
313
- const DEFAULT_PLATFORM_KEYWORDS = [
314
- // device / target-environment
315
- "desktop", "pc", "web",
316
- "mobile", "mc", "mo", "sp",
317
- "tablet", "tab",
318
- "pwa",
319
- "tv", "ctv", "ott",
320
- "watch", "wear",
321
- // access-tier / audience
322
- "admin", "cms", "backoffice", "back-office", "portal",
323
- ];
324
- const PLATFORM_KEYWORDS = [
325
- ...DEFAULT_PLATFORM_KEYWORDS,
326
- ...(Array.isArray(overrides.platformKeywords) ? overrides.platformKeywords : []),
327
- ];
328
- // Minimum source files to qualify as a subapp. A single-file directory
329
- // under a platform root is almost always an accidental fixture or a
330
- // placeholder, not a real subapp. Raising the floor avoids noisy
331
- // 1-file "domains" in the Pass 1 group plan.
332
- const MIN_SUBAPP_FILES = typeof overrides.minSubappFiles === "number" && overrides.minSubappFiles >= 1
333
- ? overrides.minSubappFiles
334
- : 2;
335
- // Conservative skip list — never-a-feature names at the subapp level.
336
- // Includes infrastructure dirs, structural dirs that other scanners
337
- // already handle (components/hooks/layouts), FSD layer names, and
338
- // framework router dirs. Patterns like `src/admin/pages/*/` and
339
- // `src/admin/components/*/` fall through to the App/Pages Router and
340
- // components scanners instead of being captured as bare subapps.
341
- // `store`/`stores` are deliberately NOT skipped — e-commerce projects
342
- // legitimately use them as subapp names.
343
- const DEFAULT_SKIP_SUBAPP_NAMES = [
344
- // infrastructure
345
- "assets", "common", "shared", "utils", "util",
346
- "lib", "libs", "config", "constants", "helpers", "types",
347
- "test", "tests", "__mocks__", "mocks", "__tests__",
348
- // structural (handled by dedicated scanners at a deeper level)
349
- "components", "hooks", "layouts", "layout",
350
- // FSD layers (handled by FSD scanner)
351
- "widgets", "features", "entities",
352
- // framework router dirs (handled by App/Pages Router scanner + fallback D)
353
- "app", "pages", "routes", "views", "screens", "containers",
354
- "modules", "domains",
355
- ];
356
- const SKIP_SUBAPP_NAMES = [
357
- ...DEFAULT_SKIP_SUBAPP_NAMES,
358
- ...(Array.isArray(overrides.skipSubappNames) ? overrides.skipSubappNames : []),
359
- ];
360
- // Match both standalone projects (src/{platform}/{subapp}/) and monorepo
361
- // workspaces ({apps,packages}/*/src/{platform}/{subapp}/ and
362
- // {apps,packages}/{platform}/{subapp}/ — some monorepos skip the src/ wrapper).
363
- const platformGlobs = [
364
- `src/{${PLATFORM_KEYWORDS.join(",")}}/*/`,
365
- `{apps,packages}/*/src/{${PLATFORM_KEYWORDS.join(",")}}/*/`,
366
- `{apps,packages}/{${PLATFORM_KEYWORDS.join(",")}}/*/`,
367
- ];
368
- const platformDirs = [];
369
- for (const p of platformGlobs) {
370
- const dirs = await glob(p, { cwd: ROOT, ignore: ["**/node_modules/**"] });
371
- platformDirs.push(...dirs);
372
- }
373
- // Dedupe (the three globs can produce overlapping matches in some layouts)
374
- const seenPlatformDirs = new Set();
375
- for (const dir of platformDirs) {
376
- const dirFwd = dir.replace(/\\/g, "/").replace(/\/$/, "");
377
- if (seenPlatformDirs.has(dirFwd)) continue;
378
- seenPlatformDirs.add(dirFwd);
379
- const parts = dirFwd.split("/");
380
- // Locate platform segment: the FIRST segment that matches a keyword.
381
- // findIndex (not findLast) if the subapp name also happens to be a
382
- // keyword (e.g., `src/pc/admin/`), the subapp should stay as the
383
- // second match, not be mistaken for the platform segment.
384
- // Paths handled: src/<p>/<s>, apps/<workspace>/src/<p>/<s>, apps/<p>/<s>.
385
- const platformIdx = parts.findIndex(seg => PLATFORM_KEYWORDS.includes(seg));
386
- if (platformIdx < 0 || platformIdx + 1 >= parts.length) continue;
387
- const platform = parts[platformIdx];
388
- const subapp = parts[platformIdx + 1];
389
- if (!subapp || SKIP_SUBAPP_NAMES.includes(subapp) || subapp.startsWith("_") || subapp.startsWith(".")) continue;
390
- const files = await glob(`${dirGlobPrefix(dir)}**/*.{tsx,jsx,ts,js,vue}`, {
391
- cwd: ROOT,
392
- ignore: [...BUILD_IGNORE_DIRS, ...TEST_FILE_IGNORE],
393
- });
394
- if (files.length < MIN_SUBAPP_FILES) continue;
395
- // Normalize Windows backslashes so the segment regex works cross-platform.
396
- const filesFwd = files.map(f => f.replace(/\\/g, "/"));
397
- const routes = filesFwd.filter(f => /\/routes\//.test(f)).length;
398
- const components = filesFwd.filter(f => /\/components\//.test(f)).length;
399
- const layouts = filesFwd.filter(f => /\/layouts?\//.test(f)).length;
400
- const hooks = filesFwd.filter(f => /\/hooks\//.test(f)).length;
401
- frontendDomains.push({
402
- name: `${platform}-${subapp}`,
403
- type: "frontend",
404
- platform,
405
- subapp,
406
- routes, components, layouts, hooks,
407
- totalFiles: files.length,
408
- });
409
- }
410
-
411
- // Fallback E: React Router file-routing (any depth). Groups by the
412
- // parent dir of `routes/`. Domain name = parent basename. Covers
413
- // CRA/Vite + React Router projects that don't match Next.js page.tsx
414
- // or FSD layout. Only fires when every other primary and fallback
415
- // scanner returned 0 domains.
416
- if (frontendDomains.length === 0) {
417
- const routeFiles = await glob("**/routes/*.{tsx,jsx,ts,js,vue}", {
418
- cwd: ROOT,
419
- ignore: [...BUILD_IGNORE_DIRS, ...TEST_FILE_IGNORE],
420
- });
421
- const routeDomains = {};
422
- const skipParents = ["src", "app", "pages", "", "."];
423
- for (const f of routeFiles) {
424
- const parts = f.replace(/\\/g, "/").split("/");
425
- const routesIdx = parts.lastIndexOf("routes");
426
- if (routesIdx < 1) continue;
427
- const parent = parts[routesIdx - 1];
428
- if (skipParents.includes(parent) || parent.startsWith("_") || parent.startsWith(".")) continue;
429
- if (!routeDomains[parent]) routeDomains[parent] = { routes: 0, totalFiles: 0 };
430
- routeDomains[parent].routes++;
431
- routeDomains[parent].totalFiles++;
432
- }
433
- for (const [name, data] of Object.entries(routeDomains)) {
434
- frontendDomains.push({ name, type: "frontend", routes: data.routes, totalFiles: data.totalFiles });
435
- }
436
- }
437
- }
438
-
439
- return { frontendDomains };
440
- }
441
-
442
- // ── Frontend file count statistics ──
443
- async function countFrontendStats(stack, ROOT) {
444
- const frontend = { exists: false, components: 0, pages: 0, hooks: 0 };
445
- if (stack.frontend) {
446
- frontend.exists = true;
447
- if (stack.frontend === "angular") {
448
- frontend.components = (await glob("{src/,}**/*.component.ts", { cwd: ROOT, ignore: ["**/node_modules/**", "**/dist/**"] })).length;
449
- frontend.pages = (await glob("{src/,}**/*.module.ts", { cwd: ROOT, ignore: ["**/node_modules/**", "**/dist/**"] })).length;
450
- frontend.hooks = (await glob("{src/,}**/*.service.ts", { cwd: ROOT, ignore: ["**/node_modules/**", "**/dist/**"] })).length;
451
- } else {
452
- frontend.components = (await glob("{src/,}**/components/**/*.{tsx,jsx,vue}", { cwd: ROOT, ignore: ["**/node_modules/**"] })).length;
453
- frontend.pages = (await glob("{src/,}{app,pages}/**/{page,index}.{tsx,jsx,vue}", { cwd: ROOT, ignore: ["**/node_modules/**"] })).length
454
- + (await glob("src/*/{app,pages}/**/{page,index}.{tsx,jsx,vue}", { cwd: ROOT, ignore: ["**/node_modules/**"] })).length;
455
- frontend.hooks = (await glob("{src/,}**/hooks/**/*.{ts,js}", { cwd: ROOT, ignore: ["**/node_modules/**"] })).length;
456
- }
457
- }
458
-
459
- // App Router RSC/Client overall stats (for project-analysis.json)
460
- if (stack.frontend === "nextjs") {
461
- const allClientFiles = await glob("{app,src/app}/**/client.{tsx,ts,jsx,js}", { cwd: ROOT });
462
- const allPageFiles = await glob("{app,src/app}/**/page.{tsx,ts,jsx,js}", { cwd: ROOT });
463
- const allLayoutFiles = await glob("{app,src/app}/**/layout.{tsx,ts,jsx,js}", { cwd: ROOT });
464
- frontend.clientComponents = allClientFiles.length;
465
- frontend.serverPages = allPageFiles.length;
466
- frontend.layouts = allLayoutFiles.length;
467
- frontend.rscPattern = allClientFiles.length > 0;
468
- }
469
-
470
- return frontend;
471
- }
472
-
473
- module.exports = { scanFrontendDomains, countFrontendStats };
1
+ /**
2
+ * ClaudeOS-Core — Frontend Structure Scanner
3
+ *
4
+ * Scans frontend project directory structure to discover domains.
5
+ * Supports: Angular, Next.js, React, Vue.
6
+ * Includes FSD (Feature-Sliced Design), components scan, and 4-stage fallback.
7
+ * Also provides frontend file count statistics.
8
+ */
9
+
10
+ const fs = require("fs");
11
+ const path = require("path");
12
+ const { glob } = require("glob");
13
+
14
+ // Project-level override: `.claudeos-scan.json` at project root can extend
15
+ // the defaults. Supported fields (all optional, all additive — never replace
16
+ // defaults):
17
+ // {
18
+ // "frontendScan": {
19
+ // "platformKeywords": ["extra-platform", "custom-tier"],
20
+ // "skipSubappNames": ["my-shared-dir"],
21
+ // "minSubappFiles": 3
22
+ // }
23
+ // }
24
+ // Invalid JSON or missing file: silently falls back to defaults.
25
+ function loadScanOverrides(ROOT) {
26
+ try {
27
+ const content = fs.readFileSync(path.join(ROOT, ".claudeos-scan.json"), "utf-8");
28
+ return JSON.parse(content) || {};
29
+ } catch (_e) {
30
+ return {};
31
+ }
32
+ }
33
+
34
+ // Build output / cache / generated dirs that should never be scanned for
35
+ // source code. Centralized so platform scan, Fallback E, and future scanners
36
+ // share the same exclusion set.
37
+ const BUILD_IGNORE_DIRS = [
38
+ "**/node_modules/**",
39
+ "**/build/**", "**/dist/**", "**/out/**",
40
+ "**/.next/**", "**/.nuxt/**", "**/.svelte-kit/**", "**/.angular/**",
41
+ "**/.turbo/**", "**/.cache/**", "**/.parcel-cache/**",
42
+ "**/coverage/**", "**/storybook-static/**",
43
+ "**/.vercel/**", "**/.netlify/**",
44
+ ];
45
+
46
+ // Test / story / type-declaration file globs.
47
+ const TEST_FILE_IGNORE = [
48
+ "**/*.spec.*", "**/*.test.*", "**/*.stories.*",
49
+ "**/*.e2e.*", "**/*.cy.*",
50
+ "**/__snapshots__/**", "**/__tests__/**",
51
+ ];
52
+
53
+ // Build a glob prefix from a glob-returned directory path. Glob v10+ strips
54
+ // trailing slashes from results, and on Windows returns backslash paths;
55
+ // without normalization, the pattern `${dir}**/*.tsx` becomes something like
56
+ // `src/foo**/*.tsx` which only matches one level deep (foo/X.tsx) — not
57
+ // nested paths like foo/routes/X.tsx. Ensuring a trailing `/` turns it into
58
+ // `src/foo/**/*.tsx` which matches any depth.
59
+ function dirGlobPrefix(dir) {
60
+ const fwd = dir.replace(/\\/g, "/");
61
+ return fwd.endsWith("/") ? fwd : fwd + "/";
62
+ }
63
+
64
+ async function scanFrontendDomains(stack, ROOT) {
65
+ const frontendDomains = [];
66
+
67
+ // ── Angular ──
68
+ if (stack.frontend === "angular") {
69
+ // Angular feature modules: src/app/*/ with *.component.ts or *.module.ts (+ monorepo apps/*/)
70
+ const angularAppDirs = [
71
+ ...await glob("{src/app,app}/*/", { cwd: ROOT }),
72
+ ...await glob("{apps,packages}/*/src/app/*/", { cwd: ROOT, ignore: ["**/node_modules/**"] }),
73
+ ];
74
+ // Skip structural containers (modules/features/pages/views) at the
75
+ // src/app/*/ level — the files INSIDE those containers are the real
76
+ // features, and the Angular deep fallback below extracts them properly.
77
+ const skipAngularDirs = ["shared", "core", "common", "layout", "layouts",
78
+ "environments", "assets", "styles", "testing", "utils",
79
+ "modules", "features", "pages", "views"];
80
+ for (const dir of angularAppDirs) {
81
+ const name = path.basename(dir.replace(/\/$/, ""));
82
+ if (skipAngularDirs.includes(name) || name.startsWith("_") || name.startsWith(".")) continue;
83
+ const files = await glob(`${dirGlobPrefix(dir)}**/*.ts`, { cwd: ROOT, ignore: ["**/*.spec.ts", "**/*.test.ts"] });
84
+ if (files.length > 0) {
85
+ const components = files.filter(f => /\.component\.ts$/.test(f)).length;
86
+ const services = files.filter(f => /\.service\.ts$/.test(f)).length;
87
+ const modules = files.filter(f => /\.module\.ts$/.test(f)).length;
88
+ const pipes = files.filter(f => /\.pipe\.ts$/.test(f)).length;
89
+ const directives = files.filter(f => /\.directive\.ts$/.test(f)).length;
90
+ const guards = files.filter(f => /\.guard\.ts$/.test(f)).length;
91
+ frontendDomains.push({ name, type: "frontend", components, services, modules, pipes, directives, guards, totalFiles: files.length });
92
+ }
93
+ }
94
+
95
+ // Angular fallback: scan **/modules/*/ and **/features/*/ with *.component.ts detection
96
+ if (frontendDomains.length === 0) {
97
+ const deepAngularDirs = await glob("**/{modules,features,pages,views}/*/", { cwd: ROOT, ignore: ["**/node_modules/**", "**/dist/**", "**/.angular/**"] });
98
+ for (const dir of deepAngularDirs) {
99
+ const name = path.basename(dir.replace(/\/$/, ""));
100
+ if (skipAngularDirs.includes(name) || name.startsWith("_") || name.startsWith(".")) continue;
101
+ const files = await glob(`${dirGlobPrefix(dir)}**/*.ts`, { cwd: ROOT, ignore: ["**/*.spec.ts", "**/*.test.ts"] });
102
+ if (files.length >= 2) {
103
+ const components = files.filter(f => /\.component\.ts$/.test(f)).length;
104
+ const services = files.filter(f => /\.service\.ts$/.test(f)).length;
105
+ frontendDomains.push({ name, type: "frontend", components, services, totalFiles: files.length });
106
+ }
107
+ }
108
+ }
109
+ }
110
+
111
+ // ── Next.js/React/Vue ──
112
+ if (stack.frontend === "nextjs" || stack.frontend === "react" || stack.frontend === "vue") {
113
+ // App Router / Pages Router / SPA domains (standard + monorepo + Vite SPA paths)
114
+ const allDirs = [
115
+ ...await glob("{app,src/app}/*/", { cwd: ROOT }),
116
+ ...await glob("{pages,src/pages}/*/", { cwd: ROOT }),
117
+ ...await glob("{apps,packages}/*/app/*/", { cwd: ROOT, ignore: ["**/node_modules/**"] }),
118
+ ...await glob("{apps,packages}/*/src/app/*/", { cwd: ROOT, ignore: ["**/node_modules/**"] }),
119
+ ...await glob("{apps,packages}/*/pages/*/", { cwd: ROOT, ignore: ["**/node_modules/**"] }),
120
+ ...await glob("{apps,packages}/*/src/pages/*/", { cwd: ROOT, ignore: ["**/node_modules/**"] }),
121
+ // Non-standard nested page paths (e.g., src/admin/pages/*, src/dashboard/app/*)
122
+ ...await glob("src/*/{app,pages}/*/", { cwd: ROOT, ignore: ["**/node_modules/**"] }),
123
+ // Vite SPA / CRA common paths (src/views/*, src/screens/*, src/routes/*)
124
+ ...await glob("src/{views,screens,routes}/*/", { cwd: ROOT, ignore: ["**/node_modules/**"] }),
125
+ ];
126
+ // Reserved Next.js/Router segments + structural containers that are never
127
+ // real route features. "components"/"hooks"/"widgets" under app/ or
128
+ // pages/ are UI containers handled by dedicated scanners — not routes.
129
+ const skipPages = ["api", "_app", "_document", "fonts", "not-found", "error", "loading",
130
+ "components", "hooks", "widgets", "entities", "features", "modules",
131
+ "lib", "libs", "utils", "util", "config", "types", "shared", "common", "assets"];
132
+ for (const dir of allDirs) {
133
+ const name = path.basename(dir);
134
+ if (skipPages.includes(name) || name.startsWith("(") || name.startsWith("[") || name.startsWith("_") || name.startsWith(".")) continue;
135
+ const files = await glob(`${dirGlobPrefix(dir)}**/*.{tsx,jsx,ts,js,vue}`, { cwd: ROOT });
136
+ if (files.length > 0) {
137
+ const pages = files.filter(f => /page\.|index\./.test(f)).length;
138
+ const layouts = files.filter(f => /layout\./.test(f)).length;
139
+ const clientFiles = files.filter(f => /client\./.test(f)).length;
140
+ const serverFiles = pages + layouts;
141
+ const components = files.filter(f => !/page\.|layout\.|index\.|client\./.test(f)).length;
142
+ frontendDomains.push({
143
+ name, type: "frontend", pages, layouts, clientFiles, serverFiles, components, totalFiles: files.length,
144
+ rscPattern: clientFiles > 0 ? "RSC+Client split" : "default",
145
+ });
146
+ }
147
+ }
148
+
149
+ // FSD (Feature-Sliced Design): features/*, widgets/*, entities/*
150
+ const fsdLayers = ["features", "widgets", "entities"];
151
+ for (const layer of fsdLayers) {
152
+ const fsdDirs = [...new Set([
153
+ ...await glob(`{${layer},src/${layer}}/*/`, { cwd: ROOT }),
154
+ ...await glob(`src/*/${layer}/*/`, { cwd: ROOT, ignore: ["**/node_modules/**"] }),
155
+ ])];
156
+ for (const dir of fsdDirs) {
157
+ const name = path.basename(dir);
158
+ if (["ui", "common", "shared", "lib", "config", "index"].includes(name)) continue;
159
+ const files = await glob(`${dirGlobPrefix(dir)}**/*.{tsx,jsx,ts,js,vue}`, { cwd: ROOT, ignore: ["**/*.spec.*", "**/*.test.*", "**/*.stories.*"] });
160
+ if (files.length > 0) {
161
+ const uiFiles = files.filter(f => /\bui\b/.test(f)).length;
162
+ const modelFiles = files.filter(f => /model|store|hook/.test(f)).length;
163
+ frontendDomains.push({ name: `${layer}/${name}`, type: "frontend", components: uiFiles, models: modelFiles, totalFiles: files.length });
164
+ }
165
+ }
166
+ }
167
+
168
+ // components/* (existing + nested src/*/components/*)
169
+ const compDirSet = new Set([
170
+ ...await glob("{src/,}components/*/", { cwd: ROOT }),
171
+ ...await glob("src/*/components/*/", { cwd: ROOT, ignore: ["**/node_modules/**"] }),
172
+ ]);
173
+ const compDirs = [...compDirSet];
174
+ for (const dir of compDirs) {
175
+ const name = path.basename(dir);
176
+ if (["ui", "common", "shared", "layout", "icons"].includes(name)) continue;
177
+ const files = await glob(`${dirGlobPrefix(dir)}**/*.{tsx,jsx,vue}`, { cwd: ROOT });
178
+ if (files.length >= 2) {
179
+ frontendDomains.push({ name: `comp-${name}`, type: "frontend", components: files.length, totalFiles: files.length });
180
+ }
181
+ }
182
+
183
+ // ── Fallback: extract domains when primary scanners return 0 ──
184
+ if (frontendDomains.length === 0) {
185
+ // Fallback A: Next.js page.tsx / client.tsx based detection
186
+ const pageFiles = await glob("**/page.{tsx,jsx}", { cwd: ROOT, ignore: ["**/node_modules/**", "**/.next/**"] });
187
+ const domainSet = {};
188
+ const skipNames = ["app", "src", "pages", "api", "_app", "_document"];
189
+ for (const f of pageFiles) {
190
+ const parts = f.replace(/\\/g, "/").split("/");
191
+ const appIdx = parts.indexOf("app");
192
+ const pagesIdx = parts.indexOf("pages");
193
+ const baseIdx = appIdx >= 0 ? appIdx : pagesIdx;
194
+ if (baseIdx >= 0 && baseIdx + 1 < parts.length - 1) {
195
+ const domain = parts[baseIdx + 1];
196
+ if (!skipNames.includes(domain) && !domain.startsWith("_") && !domain.startsWith("(") && !domain.startsWith("[") && !domain.startsWith(".")) {
197
+ if (!domainSet[domain]) domainSet[domain] = { pages: 0, clientFiles: 0, totalFiles: 0 };
198
+ domainSet[domain].pages++;
199
+ domainSet[domain].totalFiles++;
200
+ }
201
+ }
202
+ }
203
+ const clientFiles = await glob("**/client.{tsx,jsx}", { cwd: ROOT, ignore: ["**/node_modules/**", "**/.next/**"] });
204
+ for (const f of clientFiles) {
205
+ const parts = f.replace(/\\/g, "/").split("/");
206
+ const appIdx = parts.indexOf("app");
207
+ const baseIdx = appIdx >= 0 ? appIdx : -1;
208
+ if (baseIdx >= 0 && baseIdx + 1 < parts.length - 1) {
209
+ const domain = parts[baseIdx + 1];
210
+ if (domainSet[domain]) {
211
+ domainSet[domain].clientFiles++;
212
+ domainSet[domain].totalFiles++;
213
+ }
214
+ }
215
+ }
216
+ for (const [name, data] of Object.entries(domainSet)) {
217
+ frontendDomains.push({
218
+ name, type: "frontend", pages: data.pages, clientFiles: data.clientFiles, totalFiles: data.totalFiles,
219
+ rscPattern: data.clientFiles > 0 ? "RSC+Client split" : "default",
220
+ });
221
+ }
222
+
223
+ // Fallback B: FSD widgets/features/entities
224
+ for (const layer of ["widgets", "features", "entities"]) {
225
+ const layerFiles = await glob(`**/${layer}/*/**/*.{tsx,jsx,ts,js,vue}`, { cwd: ROOT, ignore: ["**/node_modules/**", "**/.next/**", "**/*.spec.*", "**/*.test.*"] });
226
+ const layerDomains = {};
227
+ for (const f of layerFiles) {
228
+ const parts = f.replace(/\\/g, "/").split("/");
229
+ const layerIdx = parts.indexOf(layer);
230
+ if (layerIdx >= 0 && layerIdx + 1 < parts.length) {
231
+ const domain = parts[layerIdx + 1];
232
+ if (!["ui", "common", "shared", "lib", "config"].includes(domain)) {
233
+ if (!layerDomains[domain]) layerDomains[domain] = 0;
234
+ layerDomains[domain]++;
235
+ }
236
+ }
237
+ }
238
+ for (const [name, count] of Object.entries(layerDomains)) {
239
+ frontendDomains.push({ name: `${layer}/${name}`, type: "frontend", totalFiles: count });
240
+ }
241
+ }
242
+
243
+ // Fallback C: Deep components/**/components/*/ detection (React/CRA/Vite projects)
244
+ if (frontendDomains.length === 0) {
245
+ const deepCompDirs = await glob("**/components/*/", { cwd: ROOT, ignore: ["**/node_modules/**", "**/.next/**", "**/build/**", "**/dist/**", "**/.git/**", "**/vendor/**", "**/__pycache__/**", "**/coverage/**"] });
246
+ const deepDomains = {};
247
+ const skipDomainNames = ["ui", "common", "shared", "layout", "layouts", "icons", "assets", "config", "utils", "lib", "error", "footer", "header", "inputs", "template"];
248
+ for (const dir of deepCompDirs) {
249
+ const name = path.basename(dir.replace(/\/$/, ""));
250
+ if (skipDomainNames.includes(name) || name.startsWith("_") || name.startsWith(".")) continue;
251
+ const files = await glob(`${dirGlobPrefix(dir)}**/*.{tsx,jsx,ts,js,vue}`, { cwd: ROOT, ignore: ["**/*.spec.*", "**/*.test.*", "**/*.stories.*"] });
252
+ if (files.length >= 2) {
253
+ if (!deepDomains[name]) deepDomains[name] = { components: 0, totalFiles: 0 };
254
+ deepDomains[name].components += files.length;
255
+ deepDomains[name].totalFiles += files.length;
256
+ }
257
+ }
258
+ for (const [name, data] of Object.entries(deepDomains)) {
259
+ frontendDomains.push({ name, type: "frontend", components: data.components, totalFiles: data.totalFiles });
260
+ }
261
+ }
262
+
263
+ // Fallback D: views/screens/containers/pages/routes deep detection
264
+ if (frontendDomains.length === 0) {
265
+ const domainDirPatterns = ["views", "screens", "containers", "pages", "routes", "modules", "domains"];
266
+ const deepDirDomains = {};
267
+ const skipDirNames = ["api", "auth", "_app", "_document", "index", "ui", "common", "shared",
268
+ "layout", "layouts", "lib", "config", "utils", "assets", "hooks", "store", "types",
269
+ "constants", "helpers", "services", "middleware", "interceptors", "guards", "decorators"];
270
+
271
+ for (const pattern of domainDirPatterns) {
272
+ const dirs = await glob(`**/${pattern}/*/`, { cwd: ROOT, ignore: ["**/node_modules/**", "**/.next/**", "**/build/**", "**/dist/**", "**/.git/**", "**/vendor/**", "**/__pycache__/**", "**/coverage/**"] });
273
+ for (const dir of dirs) {
274
+ const name = path.basename(dir.replace(/\/$/, ""));
275
+ if (skipDirNames.includes(name) || name.startsWith("_") || name.startsWith(".") || name.startsWith("(") || name.startsWith("[")) continue;
276
+ const files = await glob(`${dirGlobPrefix(dir)}**/*.{tsx,jsx,ts,js,vue}`, { cwd: ROOT, ignore: ["**/*.spec.*", "**/*.test.*", "**/*.stories.*"] });
277
+ if (files.length >= 2) {
278
+ if (!deepDirDomains[name]) deepDirDomains[name] = { components: 0, pages: 0, totalFiles: 0, sources: [] };
279
+ const tsx = files.filter(f => /\.(tsx|jsx|vue)$/.test(f)).length;
280
+ deepDirDomains[name].components += tsx;
281
+ deepDirDomains[name].totalFiles += files.length;
282
+ if (!deepDirDomains[name].sources.includes(pattern)) deepDirDomains[name].sources.push(pattern);
283
+ }
284
+ }
285
+ }
286
+ for (const [name, data] of Object.entries(deepDirDomains)) {
287
+ frontendDomains.push({ name, type: "frontend", components: data.components, totalFiles: data.totalFiles, sources: data.sources });
288
+ }
289
+ }
290
+
291
+ }
292
+ }
293
+
294
+ // ── Shared frontend patterns (run for ANY frontend: Angular, Next.js, React, Vue) ──
295
+ //
296
+ // These patterns don't depend on framework-specific file extensions or
297
+ // folder conventions — they look for top-level segmentation by platform or
298
+ // by routes/-file layouts, which appear across all frontend frameworks.
299
+ if (stack.frontend) {
300
+ // Read optional per-project override (.claudeos-scan.json).
301
+ const overrides = loadScanOverrides(ROOT).frontendScan || {};
302
+ // Platform-split layout: src/{platform}/{subapp}/ where platform is a
303
+ // device/target-environment OR access-tier keyword. Both form the same
304
+ // structural pattern (top-level segmentation with a common subapp layout).
305
+ // Subapp name comes from the filesystem at scan time via path.basename —
306
+ // no project-specific names are hardcoded.
307
+ // Produces one domain per (platform, subapp) pair, named `{platform}-{subapp}`.
308
+ // NOTE: 3-letter+ access-tier names only. The short `adm` abbreviation
309
+ // is deliberately excluded — too ambiguous in isolation and false-positive
310
+ // risk isn't worth the small convenience gain. If a project uses `src/adm/`
311
+ // as an admin tier root, rename to `admin` or add `"adm"` to
312
+ // `frontendScan.platformKeywords` in `.claudeos-scan.json`.
313
+ const DEFAULT_PLATFORM_KEYWORDS = [
314
+ // device / target-environment
315
+ "desktop", "pc", "web",
316
+ "mobile", "mc", "mo", "sp",
317
+ "tablet", "tab",
318
+ "pwa",
319
+ "tv", "ctv", "ott",
320
+ "watch", "wear",
321
+ // access-tier / audience
322
+ "admin", "cms", "backoffice", "back-office", "portal",
323
+ ];
324
+ const PLATFORM_KEYWORDS = [
325
+ ...DEFAULT_PLATFORM_KEYWORDS,
326
+ ...(Array.isArray(overrides.platformKeywords) ? overrides.platformKeywords : []),
327
+ ];
328
+ // Minimum source files to qualify as a subapp. A single-file directory
329
+ // under a platform root is almost always an accidental fixture or a
330
+ // placeholder, not a real subapp. Raising the floor avoids noisy
331
+ // 1-file "domains" in the Pass 1 group plan.
332
+ const MIN_SUBAPP_FILES = typeof overrides.minSubappFiles === "number" && overrides.minSubappFiles >= 1
333
+ ? overrides.minSubappFiles
334
+ : 2;
335
+ // Conservative skip list — never-a-feature names at the subapp level.
336
+ // Includes infrastructure dirs, structural dirs that other scanners
337
+ // already handle (components/hooks/layouts), FSD layer names, and
338
+ // framework router dirs. Patterns like `src/admin/pages/*/` and
339
+ // `src/admin/components/*/` fall through to the App/Pages Router and
340
+ // components scanners instead of being captured as bare subapps.
341
+ // `store`/`stores` are deliberately NOT skipped — e-commerce projects
342
+ // legitimately use them as subapp names.
343
+ const DEFAULT_SKIP_SUBAPP_NAMES = [
344
+ // infrastructure
345
+ "assets", "common", "shared", "utils", "util",
346
+ "lib", "libs", "config", "constants", "helpers", "types",
347
+ "test", "tests", "__mocks__", "mocks", "__tests__",
348
+ // structural (handled by dedicated scanners at a deeper level)
349
+ "components", "hooks", "layouts", "layout",
350
+ // FSD layers (handled by FSD scanner)
351
+ "widgets", "features", "entities",
352
+ // framework router dirs (handled by App/Pages Router scanner + fallback D)
353
+ "app", "pages", "routes", "views", "screens", "containers",
354
+ "modules", "domains",
355
+ ];
356
+ const SKIP_SUBAPP_NAMES = [
357
+ ...DEFAULT_SKIP_SUBAPP_NAMES,
358
+ ...(Array.isArray(overrides.skipSubappNames) ? overrides.skipSubappNames : []),
359
+ ];
360
+ // Match both standalone projects (src/{platform}/{subapp}/) and monorepo
361
+ // workspaces ({apps,packages}/*/src/{platform}/{subapp}/ and
362
+ // {apps,packages}/{platform}/{subapp}/ — some monorepos skip the src/ wrapper).
363
+ const platformGlobs = [
364
+ `src/{${PLATFORM_KEYWORDS.join(",")}}/*/`,
365
+ `{apps,packages}/*/src/{${PLATFORM_KEYWORDS.join(",")}}/*/`,
366
+ `{apps,packages}/{${PLATFORM_KEYWORDS.join(",")}}/*/`,
367
+ ];
368
+ const platformDirs = [];
369
+ for (const p of platformGlobs) {
370
+ const dirs = await glob(p, { cwd: ROOT, ignore: ["**/node_modules/**"] });
371
+ platformDirs.push(...dirs);
372
+ }
373
+ // Dedupe (the three globs can produce overlapping matches in some layouts)
374
+ const seenPlatformDirs = new Set();
375
+
376
+ // v2.3.0 Single-platform skip rule.
377
+ // Scanner designed for dual-platform layouts where the same subapp
378
+ // is implemented twice (e.g., `src/pc/admin/` + `src/mobile/admin/`).
379
+ // When platform keywords match only ONE distinct root — as in a
380
+ // single-SPA project with `src/admin/` but no sibling platform
381
+ // the "subapps" underneath are not subapps at all; they are
382
+ // internal layers of one SPA (api, context, dto, routers, …).
383
+ // Emitting those as domains fragments one logical app into 5+
384
+ // pseudo-domains, which in turn primes Pass 3 to fabricate
385
+ // prefixed filenames (featureRoutePath.ts, admin-api.service.ts)
386
+ // the hallucination class observed in frontend-react-B dogfooding.
387
+ //
388
+ // Detection of "single-SPA" mode: inspect the top-level platform
389
+ // segment for every glob match and count distinct values. If only
390
+ // one platform is present, skip subapp emission entirely and let
391
+ // the downstream scanners (routes/pages/components/FSD) identify
392
+ // real feature domains inside that single SPA.
393
+ //
394
+ // Overrides: `frontendScan.forceSubappSplit` in .claudeos-scan.json
395
+ // opts back into the old behavior for projects that intentionally
396
+ // treat their lone platform's children as feature domains.
397
+ const distinctPlatforms = new Set();
398
+ for (const dir of platformDirs) {
399
+ const dirFwd = dir.replace(/\\/g, "/").replace(/\/$/, "");
400
+ const parts = dirFwd.split("/");
401
+ const platformIdx = parts.findIndex(seg => PLATFORM_KEYWORDS.includes(seg));
402
+ if (platformIdx >= 0) distinctPlatforms.add(parts[platformIdx]);
403
+ }
404
+ const forceSubappSplit = overrides.forceSubappSplit === true;
405
+ const singleSpaMode = distinctPlatforms.size <= 1 && !forceSubappSplit;
406
+
407
+ for (const dir of (singleSpaMode ? [] : platformDirs)) {
408
+ const dirFwd = dir.replace(/\\/g, "/").replace(/\/$/, "");
409
+ if (seenPlatformDirs.has(dirFwd)) continue;
410
+ seenPlatformDirs.add(dirFwd);
411
+ const parts = dirFwd.split("/");
412
+ // Locate platform segment: the FIRST segment that matches a keyword.
413
+ // findIndex (not findLast) if the subapp name also happens to be a
414
+ // keyword (e.g., `src/pc/admin/`), the subapp should stay as the
415
+ // second match, not be mistaken for the platform segment.
416
+ // Paths handled: src/<p>/<s>, apps/<workspace>/src/<p>/<s>, apps/<p>/<s>.
417
+ const platformIdx = parts.findIndex(seg => PLATFORM_KEYWORDS.includes(seg));
418
+ if (platformIdx < 0 || platformIdx + 1 >= parts.length) continue;
419
+ const platform = parts[platformIdx];
420
+ const subapp = parts[platformIdx + 1];
421
+ if (!subapp || SKIP_SUBAPP_NAMES.includes(subapp) || subapp.startsWith("_") || subapp.startsWith(".")) continue;
422
+ const files = await glob(`${dirGlobPrefix(dir)}**/*.{tsx,jsx,ts,js,vue}`, {
423
+ cwd: ROOT,
424
+ ignore: [...BUILD_IGNORE_DIRS, ...TEST_FILE_IGNORE],
425
+ });
426
+ if (files.length < MIN_SUBAPP_FILES) continue;
427
+ // Normalize Windows backslashes so the segment regex works cross-platform.
428
+ const filesFwd = files.map(f => f.replace(/\\/g, "/"));
429
+ const routes = filesFwd.filter(f => /\/routes\//.test(f)).length;
430
+ const components = filesFwd.filter(f => /\/components\//.test(f)).length;
431
+ const layouts = filesFwd.filter(f => /\/layouts?\//.test(f)).length;
432
+ const hooks = filesFwd.filter(f => /\/hooks\//.test(f)).length;
433
+ frontendDomains.push({
434
+ name: `${platform}-${subapp}`,
435
+ type: "frontend",
436
+ platform,
437
+ subapp,
438
+ routes, components, layouts, hooks,
439
+ totalFiles: files.length,
440
+ });
441
+ }
442
+
443
+ // Fallback E: React Router file-routing (any depth). Groups by the
444
+ // parent dir of `routes/`. Domain name = parent basename. Covers
445
+ // CRA/Vite + React Router projects that don't match Next.js page.tsx
446
+ // or FSD layout. Only fires when every other primary and fallback
447
+ // scanner returned 0 domains.
448
+ if (frontendDomains.length === 0) {
449
+ const routeFiles = await glob("**/routes/*.{tsx,jsx,ts,js,vue}", {
450
+ cwd: ROOT,
451
+ ignore: [...BUILD_IGNORE_DIRS, ...TEST_FILE_IGNORE],
452
+ });
453
+ const routeDomains = {};
454
+ const skipParents = ["src", "app", "pages", "", "."];
455
+ for (const f of routeFiles) {
456
+ const parts = f.replace(/\\/g, "/").split("/");
457
+ const routesIdx = parts.lastIndexOf("routes");
458
+ if (routesIdx < 1) continue;
459
+ const parent = parts[routesIdx - 1];
460
+ if (skipParents.includes(parent) || parent.startsWith("_") || parent.startsWith(".")) continue;
461
+ if (!routeDomains[parent]) routeDomains[parent] = { routes: 0, totalFiles: 0 };
462
+ routeDomains[parent].routes++;
463
+ routeDomains[parent].totalFiles++;
464
+ }
465
+ for (const [name, data] of Object.entries(routeDomains)) {
466
+ frontendDomains.push({ name, type: "frontend", routes: data.routes, totalFiles: data.totalFiles });
467
+ }
468
+ }
469
+ }
470
+
471
+ return { frontendDomains };
472
+ }
473
+
474
+ // ── Frontend file count statistics ──
475
+ async function countFrontendStats(stack, ROOT) {
476
+ const frontend = { exists: false, components: 0, pages: 0, hooks: 0 };
477
+ if (stack.frontend) {
478
+ frontend.exists = true;
479
+ if (stack.frontend === "angular") {
480
+ frontend.components = (await glob("{src/,}**/*.component.ts", { cwd: ROOT, ignore: ["**/node_modules/**", "**/dist/**"] })).length;
481
+ frontend.pages = (await glob("{src/,}**/*.module.ts", { cwd: ROOT, ignore: ["**/node_modules/**", "**/dist/**"] })).length;
482
+ frontend.hooks = (await glob("{src/,}**/*.service.ts", { cwd: ROOT, ignore: ["**/node_modules/**", "**/dist/**"] })).length;
483
+ } else {
484
+ frontend.components = (await glob("{src/,}**/components/**/*.{tsx,jsx,vue}", { cwd: ROOT, ignore: ["**/node_modules/**"] })).length;
485
+ frontend.pages = (await glob("{src/,}{app,pages}/**/{page,index}.{tsx,jsx,vue}", { cwd: ROOT, ignore: ["**/node_modules/**"] })).length
486
+ + (await glob("src/*/{app,pages}/**/{page,index}.{tsx,jsx,vue}", { cwd: ROOT, ignore: ["**/node_modules/**"] })).length;
487
+ frontend.hooks = (await glob("{src/,}**/hooks/**/*.{ts,js}", { cwd: ROOT, ignore: ["**/node_modules/**"] })).length;
488
+ }
489
+ }
490
+
491
+ // App Router RSC/Client overall stats (for project-analysis.json)
492
+ if (stack.frontend === "nextjs") {
493
+ const allClientFiles = await glob("{app,src/app}/**/client.{tsx,ts,jsx,js}", { cwd: ROOT });
494
+ const allPageFiles = await glob("{app,src/app}/**/page.{tsx,ts,jsx,js}", { cwd: ROOT });
495
+ const allLayoutFiles = await glob("{app,src/app}/**/layout.{tsx,ts,jsx,js}", { cwd: ROOT });
496
+ frontend.clientComponents = allClientFiles.length;
497
+ frontend.serverPages = allPageFiles.length;
498
+ frontend.layouts = allLayoutFiles.length;
499
+ frontend.rscPattern = allClientFiles.length > 0;
500
+ }
501
+
502
+ return frontend;
503
+ }
504
+
505
+ module.exports = { scanFrontendDomains, countFrontendStats };