@massu/core 0.9.2 → 1.1.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/dist/cli.js +11182 -1559
- package/dist/hooks/auto-learning-pipeline.js +99 -19
- package/dist/hooks/classify-failure.js +99 -19
- package/dist/hooks/cost-tracker.js +97 -11
- package/dist/hooks/fix-detector.js +99 -19
- package/dist/hooks/incident-pipeline.js +97 -11
- package/dist/hooks/post-edit-context.js +97 -11
- package/dist/hooks/post-tool-use.js +101 -20
- package/dist/hooks/pre-compact.js +97 -11
- package/dist/hooks/pre-delete-check.js +97 -11
- package/dist/hooks/quality-event.js +97 -11
- package/dist/hooks/rule-enforcement-pipeline.js +97 -11
- package/dist/hooks/session-end.js +97 -11
- package/dist/hooks/session-start.js +8803 -782
- package/dist/hooks/user-prompt.js +98 -43
- package/package.json +13 -3
- package/reference/hook-execution-order.md +17 -25
- package/src/cli.ts +81 -2
- package/src/commands/config-check-drift.ts +132 -0
- package/src/commands/config-refresh.ts +224 -0
- package/src/commands/config-upgrade.ts +126 -0
- package/src/commands/doctor.ts +1 -29
- package/src/commands/init.ts +756 -216
- package/src/config.ts +168 -12
- package/src/detect/domain-inferrer.ts +142 -0
- package/src/detect/drift.ts +199 -0
- package/src/detect/framework-detector.ts +281 -0
- package/src/detect/index.ts +174 -0
- package/src/detect/migrate.ts +278 -0
- package/src/detect/monorepo-detector.ts +347 -0
- package/src/detect/package-detector.ts +728 -0
- package/src/detect/source-dir-detector.ts +264 -0
- package/src/detect/vr-command-map.ts +167 -0
- package/src/hooks/auto-learning-pipeline.ts +2 -2
- package/src/hooks/classify-failure.ts +2 -2
- package/src/hooks/fix-detector.ts +2 -2
- package/src/hooks/session-start.ts +43 -2
- package/src/hooks/user-prompt.ts +1 -21
- package/src/knowledge-indexer.ts +1 -1
- package/src/license.ts +1 -2
- package/src/memory-db.ts +0 -5
- package/src/memory-file-ingest.ts +6 -13
- package/src/tools.ts +0 -8
- package/templates/multi-runtime/massu.config.yaml +80 -0
- package/templates/python-django/massu.config.yaml +51 -0
- package/templates/python-fastapi/massu.config.yaml +50 -0
- package/templates/rust-actix/massu.config.yaml +38 -0
- package/templates/swift-ios/massu.config.yaml +37 -0
- package/templates/ts-nestjs/massu.config.yaml +43 -0
- package/templates/ts-nextjs/massu.config.yaml +43 -0
- package/README.md +0 -40
- package/src/claude-md-templates.ts +0 -342
- package/src/mcp-bridge-tools.ts +0 -458
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
// Copyright (c) 2026 Massu. All rights reserved.
|
|
2
|
+
// Licensed under BSL 1.1 - see LICENSE file for details.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Config migration logic (P7-003).
|
|
6
|
+
*
|
|
7
|
+
* Pure functions that upgrade legacy v1-shaped `massu.config.yaml` objects to
|
|
8
|
+
* schema_version=2. Driven by the Phase 1 `DetectionResult` so the v2 output
|
|
9
|
+
* reflects the actual runtime stack.
|
|
10
|
+
*
|
|
11
|
+
* User overrides ALWAYS win — this migrator never overwrites fields that the
|
|
12
|
+
* user set explicitly in v1. The only thing that gets replaced is content
|
|
13
|
+
* whose v1 value is a schema default ('typescript', 'none', 'src', etc.) AND
|
|
14
|
+
* detection discovered a different value.
|
|
15
|
+
*
|
|
16
|
+
* Idempotence: passing a v2 config back in should be a no-op (beyond `schema_version`
|
|
17
|
+
* normalization). The test suite asserts this.
|
|
18
|
+
*
|
|
19
|
+
* No filesystem I/O, no network, no child processes. Pure data in, pure data
|
|
20
|
+
* out. Consumers (future `massu config upgrade` CLI, drift-repair flow) are
|
|
21
|
+
* responsible for reading/writing the YAML.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { DetectionResult, SupportedLanguage, VRCommandSet } from './index.ts';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Shape accepted for input. We intentionally use `Record<string, unknown>`
|
|
28
|
+
* rather than the full `Config` Zod type — legacy configs may contain fields
|
|
29
|
+
* that don't parse through the current schema (e.g., invalid enum values for
|
|
30
|
+
* `framework.router` from eko-ultra-automations), and a strict shape would
|
|
31
|
+
* reject the very configs we're trying to fix.
|
|
32
|
+
*/
|
|
33
|
+
export type AnyConfig = Record<string, unknown>;
|
|
34
|
+
|
|
35
|
+
/** Fields the migrator preserves verbatim when present on the v1 input. */
|
|
36
|
+
const PRESERVED_FIELDS: readonly string[] = [
|
|
37
|
+
'rules',
|
|
38
|
+
'domains',
|
|
39
|
+
'canonical_paths',
|
|
40
|
+
'verification_types',
|
|
41
|
+
'detection',
|
|
42
|
+
'accessScopes',
|
|
43
|
+
'knownMismatches',
|
|
44
|
+
'dbAccessPattern',
|
|
45
|
+
'analytics',
|
|
46
|
+
'governance',
|
|
47
|
+
'security',
|
|
48
|
+
'team',
|
|
49
|
+
'regression',
|
|
50
|
+
'cloud',
|
|
51
|
+
'conventions',
|
|
52
|
+
'autoLearning',
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
function getRecord(obj: unknown): Record<string, unknown> {
|
|
56
|
+
if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
|
|
57
|
+
return obj as Record<string, unknown>;
|
|
58
|
+
}
|
|
59
|
+
return {};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isNoneOrDefault(v: unknown): boolean {
|
|
63
|
+
if (v === undefined || v === null) return true;
|
|
64
|
+
if (typeof v !== 'string') return false;
|
|
65
|
+
return v === 'none' || v === '';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function chooseString(user: unknown, detected: string | null | undefined): string {
|
|
69
|
+
// User wins unless it's falsey / 'none' and the detector has something concrete.
|
|
70
|
+
if (typeof user === 'string' && !isNoneOrDefault(user)) return user;
|
|
71
|
+
if (typeof detected === 'string' && detected !== '') return detected;
|
|
72
|
+
if (typeof user === 'string') return user;
|
|
73
|
+
return 'none';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function buildLanguageEntries(
|
|
77
|
+
detection: DetectionResult
|
|
78
|
+
): Record<string, Record<string, unknown>> {
|
|
79
|
+
const languages = Array.from(
|
|
80
|
+
new Set(detection.manifests.map((m) => m.language))
|
|
81
|
+
) as SupportedLanguage[];
|
|
82
|
+
const entries: Record<string, Record<string, unknown>> = {};
|
|
83
|
+
for (const lang of languages) {
|
|
84
|
+
const fw = detection.frameworks[lang];
|
|
85
|
+
const dirInfo = detection.sourceDirs[lang];
|
|
86
|
+
const sourceDirs = dirInfo?.source_dirs ?? [];
|
|
87
|
+
const entry: Record<string, unknown> = {};
|
|
88
|
+
if (fw?.framework) entry.framework = fw.framework;
|
|
89
|
+
if (fw?.test_framework) entry.test_framework = fw.test_framework;
|
|
90
|
+
if (fw?.orm) entry.orm = fw.orm;
|
|
91
|
+
if (fw?.router) entry.router = fw.router;
|
|
92
|
+
if (fw?.ui_library) entry.ui = fw.ui_library;
|
|
93
|
+
if (sourceDirs.length > 0) entry.source_dirs = sourceDirs;
|
|
94
|
+
if (Object.keys(entry).length > 0) {
|
|
95
|
+
entries[lang] = entry;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return entries;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function pickPrimary(detection: DetectionResult): SupportedLanguage | null {
|
|
102
|
+
const counts = new Map<SupportedLanguage, number>();
|
|
103
|
+
for (const m of detection.manifests) {
|
|
104
|
+
counts.set(m.language, (counts.get(m.language) ?? 0) + 1);
|
|
105
|
+
}
|
|
106
|
+
const sorted = [...counts.entries()].sort((a, b) => {
|
|
107
|
+
if (b[1] !== a[1]) return b[1] - a[1];
|
|
108
|
+
return a[0].localeCompare(b[0]);
|
|
109
|
+
});
|
|
110
|
+
return sorted.length > 0 ? sorted[0][0] : null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function buildVerificationBlock(
|
|
114
|
+
detection: DetectionResult,
|
|
115
|
+
userVerification: Record<string, Record<string, string>> | undefined
|
|
116
|
+
): Record<string, Record<string, string>> {
|
|
117
|
+
const ver: Record<string, Record<string, string>> = {};
|
|
118
|
+
const languages = Array.from(
|
|
119
|
+
new Set(detection.manifests.map((m) => m.language))
|
|
120
|
+
) as SupportedLanguage[];
|
|
121
|
+
for (const lang of languages) {
|
|
122
|
+
const cmds: VRCommandSet | undefined = detection.verificationCommands[lang];
|
|
123
|
+
if (!cmds) continue;
|
|
124
|
+
const entry: Record<string, string> = {};
|
|
125
|
+
if (cmds.test) entry.test = cmds.test;
|
|
126
|
+
if (cmds.type) entry.type = cmds.type;
|
|
127
|
+
if (cmds.build) entry.build = cmds.build;
|
|
128
|
+
if (cmds.syntax) entry.syntax = cmds.syntax;
|
|
129
|
+
if (cmds.lint) entry.lint = cmds.lint;
|
|
130
|
+
if (Object.keys(entry).length > 0) ver[lang] = entry;
|
|
131
|
+
}
|
|
132
|
+
// Overlay user overrides — any user verification entry wins over detected.
|
|
133
|
+
if (userVerification) {
|
|
134
|
+
for (const [lang, userEntry] of Object.entries(userVerification)) {
|
|
135
|
+
if (typeof userEntry !== 'object' || userEntry === null) continue;
|
|
136
|
+
ver[lang] = { ...(ver[lang] ?? {}), ...userEntry };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return ver;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Migrate a v1-shaped (or malformed) config to v2, using `detection` as the
|
|
144
|
+
* source of truth for the stack layout. User-authored overrides (rules,
|
|
145
|
+
* domains, canonical_paths, etc.) are preserved unchanged.
|
|
146
|
+
*
|
|
147
|
+
* Idempotent: passing a valid v2 config back in returns an equivalent v2 config.
|
|
148
|
+
*/
|
|
149
|
+
export function migrateV1ToV2(
|
|
150
|
+
v1Config: AnyConfig,
|
|
151
|
+
detection: DetectionResult
|
|
152
|
+
): AnyConfig {
|
|
153
|
+
const v1 = getRecord(v1Config);
|
|
154
|
+
const v1Framework = getRecord(v1.framework);
|
|
155
|
+
const v1Paths = getRecord(v1.paths);
|
|
156
|
+
const v1Project = getRecord(v1.project);
|
|
157
|
+
const v1Verification = v1.verification as
|
|
158
|
+
| Record<string, Record<string, string>>
|
|
159
|
+
| undefined;
|
|
160
|
+
|
|
161
|
+
const languages = Array.from(
|
|
162
|
+
new Set(detection.manifests.map((m) => m.language))
|
|
163
|
+
) as SupportedLanguage[];
|
|
164
|
+
const languageEntries = buildLanguageEntries(detection);
|
|
165
|
+
const primary = pickPrimary(detection);
|
|
166
|
+
|
|
167
|
+
const frameworkType = languages.length > 1
|
|
168
|
+
? 'multi'
|
|
169
|
+
: (languages[0] ?? (typeof v1Framework.type === 'string' ? v1Framework.type : 'typescript'));
|
|
170
|
+
|
|
171
|
+
// Legacy top-level router/orm/ui: prefer user value; fall back to primary lang.
|
|
172
|
+
const primaryEntry = primary ? languageEntries[primary] : undefined;
|
|
173
|
+
const primaryRouter = primaryEntry?.router as string | undefined;
|
|
174
|
+
const primaryOrm = primaryEntry?.orm as string | undefined;
|
|
175
|
+
const primaryUi = primaryEntry?.ui as string | undefined;
|
|
176
|
+
|
|
177
|
+
const legacyRouter = chooseString(v1Framework.router, primaryRouter);
|
|
178
|
+
const legacyOrm = chooseString(v1Framework.orm, primaryOrm);
|
|
179
|
+
const legacyUi = chooseString(v1Framework.ui, primaryUi);
|
|
180
|
+
|
|
181
|
+
const framework: Record<string, unknown> = {
|
|
182
|
+
type: frameworkType,
|
|
183
|
+
router: legacyRouter,
|
|
184
|
+
orm: legacyOrm,
|
|
185
|
+
ui: legacyUi,
|
|
186
|
+
};
|
|
187
|
+
if (languages.length > 1 && primary) {
|
|
188
|
+
framework.primary = primary;
|
|
189
|
+
}
|
|
190
|
+
if (Object.keys(languageEntries).length > 0) {
|
|
191
|
+
framework.languages = languageEntries;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Paths: preserve user-set fields; fill `source` from detection if user had 'src' default.
|
|
195
|
+
let pathsSource: string = typeof v1Paths.source === 'string' ? v1Paths.source : 'src';
|
|
196
|
+
if (pathsSource === 'src' && primary) {
|
|
197
|
+
const primaryDirs = detection.sourceDirs[primary]?.source_dirs ?? [];
|
|
198
|
+
if (primaryDirs.length > 0) pathsSource = primaryDirs[0];
|
|
199
|
+
}
|
|
200
|
+
const aliases = v1Paths.aliases && typeof v1Paths.aliases === 'object'
|
|
201
|
+
? (v1Paths.aliases as Record<string, string>)
|
|
202
|
+
: { '@': pathsSource };
|
|
203
|
+
const paths: Record<string, unknown> = {
|
|
204
|
+
source: pathsSource,
|
|
205
|
+
aliases,
|
|
206
|
+
};
|
|
207
|
+
for (const k of ['routers', 'routerRoot', 'pages', 'middleware', 'schema', 'components', 'hooks']) {
|
|
208
|
+
if (typeof v1Paths[k] === 'string') paths[k] = v1Paths[k];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const verification = buildVerificationBlock(detection, v1Verification);
|
|
212
|
+
|
|
213
|
+
// Construct v2 output.
|
|
214
|
+
const v2: AnyConfig = {
|
|
215
|
+
schema_version: 2,
|
|
216
|
+
project: {
|
|
217
|
+
name: typeof v1Project.name === 'string' ? v1Project.name : 'my-project',
|
|
218
|
+
root: typeof v1Project.root === 'string' ? v1Project.root : 'auto',
|
|
219
|
+
},
|
|
220
|
+
framework,
|
|
221
|
+
paths,
|
|
222
|
+
toolPrefix: typeof v1.toolPrefix === 'string' ? v1.toolPrefix : 'massu',
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// Preserve user-authored collections verbatim.
|
|
226
|
+
for (const field of PRESERVED_FIELDS) {
|
|
227
|
+
if (field in v1 && v1[field] !== undefined) {
|
|
228
|
+
v2[field] = v1[field];
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Ensure domains / rules exist as arrays (v2 requires them).
|
|
233
|
+
if (!Array.isArray(v2.domains)) {
|
|
234
|
+
v2.domains = [];
|
|
235
|
+
}
|
|
236
|
+
if (!Array.isArray(v2.rules)) {
|
|
237
|
+
v2.rules = [];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (Object.keys(verification).length > 0) {
|
|
241
|
+
v2.verification = verification;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Preserve/update the v1 `python` sub-block when python is detected.
|
|
245
|
+
if (languages.includes('python')) {
|
|
246
|
+
const existing = getRecord(v1.python);
|
|
247
|
+
const pyFw = detection.frameworks.python;
|
|
248
|
+
const pySourceDirs = detection.sourceDirs.python?.source_dirs ?? [];
|
|
249
|
+
const pyRoot =
|
|
250
|
+
typeof existing.root === 'string' && existing.root !== ''
|
|
251
|
+
? existing.root
|
|
252
|
+
: (pySourceDirs.length > 0 ? pySourceDirs[0] : '.');
|
|
253
|
+
const pythonBlock: Record<string, unknown> = {
|
|
254
|
+
root: pyRoot,
|
|
255
|
+
exclude_dirs: Array.isArray(existing.exclude_dirs)
|
|
256
|
+
? existing.exclude_dirs
|
|
257
|
+
: ['__pycache__', '.venv', 'venv', '.mypy_cache', '.pytest_cache'],
|
|
258
|
+
};
|
|
259
|
+
if (existing.domains !== undefined) pythonBlock.domains = existing.domains;
|
|
260
|
+
if (existing.alembic_dir !== undefined) pythonBlock.alembic_dir = existing.alembic_dir;
|
|
261
|
+
if (pyFw?.framework && existing.framework === undefined) {
|
|
262
|
+
pythonBlock.framework = pyFw.framework;
|
|
263
|
+
} else if (existing.framework !== undefined) {
|
|
264
|
+
pythonBlock.framework = existing.framework;
|
|
265
|
+
}
|
|
266
|
+
if (pyFw?.orm && existing.orm === undefined) {
|
|
267
|
+
pythonBlock.orm = pyFw.orm;
|
|
268
|
+
} else if (existing.orm !== undefined) {
|
|
269
|
+
pythonBlock.orm = existing.orm;
|
|
270
|
+
}
|
|
271
|
+
v2.python = pythonBlock;
|
|
272
|
+
} else if (v1.python !== undefined) {
|
|
273
|
+
// Preserve even if detection didn't find python (e.g. non-monorepo-with-python).
|
|
274
|
+
v2.python = v1.python;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return v2;
|
|
278
|
+
}
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
// Copyright (c) 2026 Massu. All rights reserved.
|
|
2
|
+
// Licensed under BSL 1.1 - see LICENSE file for details.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Monorepo Detector (P1-004)
|
|
6
|
+
* ==========================
|
|
7
|
+
*
|
|
8
|
+
* Detects the presence and kind of monorepo layout at `projectRoot`:
|
|
9
|
+
* - turbo (turbo.json)
|
|
10
|
+
* - nx (nx.json)
|
|
11
|
+
* - lerna (lerna.json)
|
|
12
|
+
* - pnpm (pnpm-workspace.yaml)
|
|
13
|
+
* - yarn (package.json `workspaces`)
|
|
14
|
+
* - bazel (WORKSPACE / WORKSPACE.bazel / MODULE.bazel)
|
|
15
|
+
* - generic (any of apps/*, packages/*, services/*, libs/*, modules/*
|
|
16
|
+
* subdirs with their own manifests)
|
|
17
|
+
* - single (no monorepo signals)
|
|
18
|
+
*
|
|
19
|
+
* Nested schemes: when the outer has a primary manager AND an inner scheme is
|
|
20
|
+
* present (e.g., turbo-outer + pnpm-inner), `nested[]` lists the inner scheme.
|
|
21
|
+
*
|
|
22
|
+
* Usage:
|
|
23
|
+
* ```ts
|
|
24
|
+
* import { detectMonorepo } from './detect/monorepo-detector.ts';
|
|
25
|
+
* const info = detectMonorepo('/repo');
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { readFileSync, existsSync, statSync, lstatSync, readdirSync } from 'fs';
|
|
30
|
+
import { join, relative } from 'path';
|
|
31
|
+
import { parse as parseYaml } from 'yaml';
|
|
32
|
+
import { parse as parseToml } from 'smol-toml';
|
|
33
|
+
|
|
34
|
+
export type MonorepoKind =
|
|
35
|
+
| 'turbo'
|
|
36
|
+
| 'nx'
|
|
37
|
+
| 'lerna'
|
|
38
|
+
| 'pnpm'
|
|
39
|
+
| 'yarn'
|
|
40
|
+
| 'bazel'
|
|
41
|
+
| 'generic'
|
|
42
|
+
| 'single';
|
|
43
|
+
|
|
44
|
+
export interface WorkspacePackage {
|
|
45
|
+
/** Path relative to projectRoot, forward-slash normalized. */
|
|
46
|
+
path: string;
|
|
47
|
+
/** Name from the manifest (null if not present). */
|
|
48
|
+
name: string | null;
|
|
49
|
+
/** The manifest file that declares the workspace. */
|
|
50
|
+
manifest:
|
|
51
|
+
| 'package.json'
|
|
52
|
+
| 'pyproject.toml'
|
|
53
|
+
| 'Cargo.toml'
|
|
54
|
+
| 'go.mod'
|
|
55
|
+
| 'build.gradle'
|
|
56
|
+
| 'pom.xml'
|
|
57
|
+
| 'Gemfile'
|
|
58
|
+
| 'Package.swift'
|
|
59
|
+
| 'unknown';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface MonorepoInfo {
|
|
63
|
+
type: MonorepoKind;
|
|
64
|
+
/** Workspace packages discovered. Empty for 'single'. */
|
|
65
|
+
packages: WorkspacePackage[];
|
|
66
|
+
/** Secondary monorepo schemes nested inside the primary. */
|
|
67
|
+
nested: MonorepoInfo[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const MANIFEST_PRIORITY: WorkspacePackage['manifest'][] = [
|
|
71
|
+
'package.json',
|
|
72
|
+
'pyproject.toml',
|
|
73
|
+
'Cargo.toml',
|
|
74
|
+
'go.mod',
|
|
75
|
+
'build.gradle',
|
|
76
|
+
'pom.xml',
|
|
77
|
+
'Gemfile',
|
|
78
|
+
'Package.swift',
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
const IGNORED_DIRS = new Set([
|
|
82
|
+
'node_modules',
|
|
83
|
+
'.venv',
|
|
84
|
+
'venv',
|
|
85
|
+
'__pycache__',
|
|
86
|
+
'dist',
|
|
87
|
+
'build',
|
|
88
|
+
'.build',
|
|
89
|
+
'target',
|
|
90
|
+
'.next',
|
|
91
|
+
'.nuxt',
|
|
92
|
+
'coverage',
|
|
93
|
+
'.git',
|
|
94
|
+
'.massu',
|
|
95
|
+
'.turbo',
|
|
96
|
+
'.cache',
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
const CONVENTIONAL_WORKSPACE_PARENTS = [
|
|
100
|
+
'apps',
|
|
101
|
+
'packages',
|
|
102
|
+
'services',
|
|
103
|
+
'libs',
|
|
104
|
+
'modules',
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
function safeReadText(path: string): string | null {
|
|
108
|
+
try {
|
|
109
|
+
if (!existsSync(path)) return null;
|
|
110
|
+
// Reject symlinks — defence in depth against manifest-forged escape paths
|
|
111
|
+
// or credential exposure via symlinked files (parity with package-detector.ts).
|
|
112
|
+
// lstatSync does NOT follow symlinks; statSync would return the target's stat.
|
|
113
|
+
const ls = lstatSync(path);
|
|
114
|
+
if (ls.isSymbolicLink()) return null;
|
|
115
|
+
const st = statSync(path);
|
|
116
|
+
if (!st.isFile()) return null;
|
|
117
|
+
return readFileSync(path, 'utf-8');
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function firstManifestIn(dir: string): WorkspacePackage['manifest'] | null {
|
|
124
|
+
for (const m of MANIFEST_PRIORITY) {
|
|
125
|
+
if (existsSync(join(dir, m))) return m;
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function manifestName(dir: string, manifest: WorkspacePackage['manifest']): string | null {
|
|
131
|
+
try {
|
|
132
|
+
if (manifest === 'package.json') {
|
|
133
|
+
const raw = safeReadText(join(dir, 'package.json'));
|
|
134
|
+
if (!raw) return null;
|
|
135
|
+
const pkg = JSON.parse(raw) as { name?: unknown };
|
|
136
|
+
return typeof pkg.name === 'string' ? pkg.name : null;
|
|
137
|
+
}
|
|
138
|
+
if (manifest === 'pyproject.toml') {
|
|
139
|
+
const raw = safeReadText(join(dir, 'pyproject.toml'));
|
|
140
|
+
if (!raw) return null;
|
|
141
|
+
const toml = parseToml(raw) as Record<string, unknown>;
|
|
142
|
+
const project = toml.project as Record<string, unknown> | undefined;
|
|
143
|
+
if (project && typeof project.name === 'string') return project.name;
|
|
144
|
+
const tool = toml.tool as Record<string, unknown> | undefined;
|
|
145
|
+
const poetry = tool?.poetry as Record<string, unknown> | undefined;
|
|
146
|
+
if (poetry && typeof poetry.name === 'string') return poetry.name;
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
if (manifest === 'Cargo.toml') {
|
|
150
|
+
const raw = safeReadText(join(dir, 'Cargo.toml'));
|
|
151
|
+
if (!raw) return null;
|
|
152
|
+
const toml = parseToml(raw) as Record<string, unknown>;
|
|
153
|
+
const pkg = toml.package as Record<string, unknown> | undefined;
|
|
154
|
+
if (pkg && typeof pkg.name === 'string') return pkg.name;
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
if (manifest === 'go.mod') {
|
|
158
|
+
const raw = safeReadText(join(dir, 'go.mod'));
|
|
159
|
+
if (!raw) return null;
|
|
160
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
161
|
+
const trimmed = line.trim();
|
|
162
|
+
if (trimmed.startsWith('module ')) return trimmed.slice(7).trim();
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
return null;
|
|
167
|
+
} catch {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function pkgFromDir(root: string, dir: string): WorkspacePackage | null {
|
|
173
|
+
const m = firstManifestIn(dir);
|
|
174
|
+
if (!m) return null;
|
|
175
|
+
return {
|
|
176
|
+
path: relative(root, dir).split(/[/\\]/).join('/'),
|
|
177
|
+
name: manifestName(dir, m),
|
|
178
|
+
manifest: m,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function listSubdirs(dir: string): string[] {
|
|
183
|
+
try {
|
|
184
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
185
|
+
.filter((e) => e.isDirectory() && !IGNORED_DIRS.has(e.name))
|
|
186
|
+
.map((e) => join(dir, e.name));
|
|
187
|
+
} catch {
|
|
188
|
+
return [];
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Collect generic conventional workspaces (any manifested package under
|
|
194
|
+
* apps/*, packages/*, services/*, libs/*, modules/*).
|
|
195
|
+
*/
|
|
196
|
+
function genericWorkspaces(root: string): WorkspacePackage[] {
|
|
197
|
+
const out: WorkspacePackage[] = [];
|
|
198
|
+
for (const parent of CONVENTIONAL_WORKSPACE_PARENTS) {
|
|
199
|
+
const p = join(root, parent);
|
|
200
|
+
if (!existsSync(p)) continue;
|
|
201
|
+
for (const sub of listSubdirs(p)) {
|
|
202
|
+
const pkg = pkgFromDir(root, sub);
|
|
203
|
+
if (pkg) out.push(pkg);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function detectYarnWorkspaces(root: string): WorkspacePackage[] | null {
|
|
210
|
+
const raw = safeReadText(join(root, 'package.json'));
|
|
211
|
+
if (!raw) return null;
|
|
212
|
+
let pkg: { workspaces?: unknown };
|
|
213
|
+
try {
|
|
214
|
+
pkg = JSON.parse(raw) as { workspaces?: unknown };
|
|
215
|
+
} catch {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
const ws = pkg.workspaces;
|
|
219
|
+
if (!ws) return null;
|
|
220
|
+
// Can be array of strings OR { packages: [...] }
|
|
221
|
+
const globs: string[] = Array.isArray(ws)
|
|
222
|
+
? (ws.filter((x) => typeof x === 'string') as string[])
|
|
223
|
+
: typeof ws === 'object' && ws !== null &&
|
|
224
|
+
Array.isArray((ws as { packages?: unknown }).packages)
|
|
225
|
+
? (((ws as { packages: unknown[] }).packages).filter((x) => typeof x === 'string') as string[])
|
|
226
|
+
: [];
|
|
227
|
+
if (globs.length === 0) return null;
|
|
228
|
+
return expandWorkspaceGlobs(root, globs);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function detectPnpmWorkspaces(root: string): WorkspacePackage[] | null {
|
|
232
|
+
const raw = safeReadText(join(root, 'pnpm-workspace.yaml'));
|
|
233
|
+
if (!raw) return null;
|
|
234
|
+
try {
|
|
235
|
+
const parsed = parseYaml(raw) as { packages?: unknown } | null;
|
|
236
|
+
const list = Array.isArray(parsed?.packages)
|
|
237
|
+
? (parsed!.packages as unknown[]).filter((x) => typeof x === 'string') as string[]
|
|
238
|
+
: [];
|
|
239
|
+
return expandWorkspaceGlobs(root, list);
|
|
240
|
+
} catch {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function expandWorkspaceGlobs(root: string, globs: string[]): WorkspacePackage[] {
|
|
246
|
+
// Minimal brace/star expansion: support `foo/*` and `foo/**` by one level only.
|
|
247
|
+
// For simplicity and no-dep, expand each pattern manually.
|
|
248
|
+
const out: WorkspacePackage[] = [];
|
|
249
|
+
const seen = new Set<string>();
|
|
250
|
+
for (const pattern of globs) {
|
|
251
|
+
// Handle `apps/*`, `packages/*`, `services/*`, `apps/**`
|
|
252
|
+
const parts = pattern.split('/');
|
|
253
|
+
if (parts.length === 2 && (parts[1] === '*' || parts[1] === '**')) {
|
|
254
|
+
const parent = join(root, parts[0]);
|
|
255
|
+
if (!existsSync(parent)) continue;
|
|
256
|
+
for (const sub of listSubdirs(parent)) {
|
|
257
|
+
const pkg = pkgFromDir(root, sub);
|
|
258
|
+
if (pkg && !seen.has(pkg.path)) {
|
|
259
|
+
seen.add(pkg.path);
|
|
260
|
+
out.push(pkg);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
// Direct path (no glob)
|
|
266
|
+
const direct = join(root, pattern);
|
|
267
|
+
if (existsSync(direct)) {
|
|
268
|
+
const pkg = pkgFromDir(root, direct);
|
|
269
|
+
if (pkg && !seen.has(pkg.path)) {
|
|
270
|
+
seen.add(pkg.path);
|
|
271
|
+
out.push(pkg);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return out;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function hasTurbo(root: string): boolean {
|
|
279
|
+
return existsSync(join(root, 'turbo.json'));
|
|
280
|
+
}
|
|
281
|
+
function hasNx(root: string): boolean {
|
|
282
|
+
return existsSync(join(root, 'nx.json'));
|
|
283
|
+
}
|
|
284
|
+
function hasLerna(root: string): boolean {
|
|
285
|
+
return existsSync(join(root, 'lerna.json'));
|
|
286
|
+
}
|
|
287
|
+
function hasBazel(root: string): boolean {
|
|
288
|
+
return (
|
|
289
|
+
existsSync(join(root, 'WORKSPACE')) ||
|
|
290
|
+
existsSync(join(root, 'WORKSPACE.bazel')) ||
|
|
291
|
+
existsSync(join(root, 'MODULE.bazel'))
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Detect the monorepo layout (or lack thereof) at `projectRoot`.
|
|
297
|
+
*/
|
|
298
|
+
export function detectMonorepo(projectRoot: string): MonorepoInfo {
|
|
299
|
+
const nested: MonorepoInfo[] = [];
|
|
300
|
+
|
|
301
|
+
const pnpm = detectPnpmWorkspaces(projectRoot);
|
|
302
|
+
const yarn = detectYarnWorkspaces(projectRoot);
|
|
303
|
+
|
|
304
|
+
// Determine primary by priority: turbo > nx > lerna > pnpm > yarn > bazel > generic > single
|
|
305
|
+
let primary: MonorepoKind = 'single';
|
|
306
|
+
let primaryPackages: WorkspacePackage[] = [];
|
|
307
|
+
|
|
308
|
+
if (hasTurbo(projectRoot)) {
|
|
309
|
+
primary = 'turbo';
|
|
310
|
+
// Turbo reuses an underlying manager's workspaces list.
|
|
311
|
+
primaryPackages = pnpm ?? yarn ?? genericWorkspaces(projectRoot);
|
|
312
|
+
// Record nested inner scheme (the actual workspace manager)
|
|
313
|
+
if (pnpm && pnpm.length) {
|
|
314
|
+
nested.push({ type: 'pnpm', packages: pnpm, nested: [] });
|
|
315
|
+
} else if (yarn && yarn.length) {
|
|
316
|
+
nested.push({ type: 'yarn', packages: yarn, nested: [] });
|
|
317
|
+
}
|
|
318
|
+
} else if (hasNx(projectRoot)) {
|
|
319
|
+
primary = 'nx';
|
|
320
|
+
primaryPackages = yarn ?? pnpm ?? genericWorkspaces(projectRoot);
|
|
321
|
+
if (pnpm && pnpm.length) nested.push({ type: 'pnpm', packages: pnpm, nested: [] });
|
|
322
|
+
else if (yarn && yarn.length) nested.push({ type: 'yarn', packages: yarn, nested: [] });
|
|
323
|
+
} else if (hasLerna(projectRoot)) {
|
|
324
|
+
primary = 'lerna';
|
|
325
|
+
primaryPackages = yarn ?? pnpm ?? genericWorkspaces(projectRoot);
|
|
326
|
+
} else if (pnpm && pnpm.length) {
|
|
327
|
+
primary = 'pnpm';
|
|
328
|
+
primaryPackages = pnpm;
|
|
329
|
+
} else if (yarn && yarn.length) {
|
|
330
|
+
primary = 'yarn';
|
|
331
|
+
primaryPackages = yarn;
|
|
332
|
+
} else if (hasBazel(projectRoot)) {
|
|
333
|
+
primary = 'bazel';
|
|
334
|
+
primaryPackages = genericWorkspaces(projectRoot);
|
|
335
|
+
} else {
|
|
336
|
+
const gen = genericWorkspaces(projectRoot);
|
|
337
|
+
if (gen.length > 0) {
|
|
338
|
+
primary = 'generic';
|
|
339
|
+
primaryPackages = gen;
|
|
340
|
+
} else {
|
|
341
|
+
primary = 'single';
|
|
342
|
+
primaryPackages = [];
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return { type: primary, packages: primaryPackages, nested };
|
|
347
|
+
}
|