@yeison.restrepo.r/code-conductor 1.23.1 → 1.23.2

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.
@@ -0,0 +1,679 @@
1
+ // scripts/detect-stack.mjs
2
+ import { readdir, readFile, stat, realpath } from 'node:fs/promises';
3
+ import { join, resolve, basename, sep } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ // ── Constants ────────────────────────────────────────────────────────────────
7
+ export const GLOBAL_IGNORE = new Set([
8
+ 'node_modules', '.git', '.svn', '.hg', '.expo', '.dart_tool', '.pub-cache',
9
+ '__pycache__', '.gradle', '.m2', 'vendor', 'dist', 'build', 'out',
10
+ '.next', '.nuxt', '.cache', '.parcel-cache', '.venv', '.turbo',
11
+ ]);
12
+ const MAX_FILE_SIZE = 512 * 1024;
13
+ const MAX_DIRS = 200;
14
+ const READDIR_TIMEOUT = 10_000;
15
+ const DEFAULT_DEPTH = 5;
16
+ const MAX_DEPTH = 20;
17
+
18
+ export const globDepth = (() => {
19
+ const v = parseInt(process.env.CC_GLOB_DEPTH ?? '', 10);
20
+ if (!Number.isFinite(v)) return DEFAULT_DEPTH;
21
+ return Math.max(1, Math.min(MAX_DEPTH, v));
22
+ })();
23
+
24
+ // ── Low-level helpers ─────────────────────────────────────────────────────────
25
+ export function stripBOM(s) { return s.replace(/^/, ''); }
26
+
27
+ export function warn(msg) { process.stderr.write(`WARN: ${msg}\n`); }
28
+
29
+ export async function readdirSafe(dir) {
30
+ return Promise.race([
31
+ readdir(dir, { withFileTypes: true }),
32
+ new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), READDIR_TIMEOUT)),
33
+ ]).catch(err => {
34
+ warn(`readdir ${err.message === 'timeout' ? 'timeout' : 'error'} — skipped: ${dir}`);
35
+ return [];
36
+ });
37
+ }
38
+
39
+ export async function readManifest(filepath) {
40
+ try {
41
+ const s = await stat(filepath);
42
+ if (s.size === 0) { warn(`zero-byte manifest skipped: ${filepath}`); return null; }
43
+ if (s.size > MAX_FILE_SIZE) { warn(`manifest too large (>512KB), skipped: ${filepath}`); return null; }
44
+ const raw = JSON.parse(stripBOM(await readFile(filepath, 'utf8')));
45
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return null;
46
+ if (Object.keys(raw).length === 0) return null;
47
+ return raw;
48
+ } catch { return null; }
49
+ }
50
+
51
+ export async function readText(filepath, maxLines = Infinity) {
52
+ try {
53
+ const s = await stat(filepath);
54
+ if (s.size === 0 || s.size > MAX_FILE_SIZE) return null;
55
+ const raw = stripBOM(await readFile(filepath, 'utf8'));
56
+ return maxLines === Infinity ? raw : raw.split('\n').slice(0, maxLines).join('\n');
57
+ } catch { return null; }
58
+ }
59
+
60
+ export function pkgDep(pkg, name) {
61
+ return !!(pkg?.dependencies?.[name] || pkg?.devDependencies?.[name]);
62
+ }
63
+ export function pkgProdDep(pkg, name) { return !!(pkg?.dependencies?.[name]); }
64
+
65
+ // ── Glob expansion ─────────────────────────────────────────────────────────────
66
+ export function matchWild(name, pattern) {
67
+ const re = '^' + pattern
68
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
69
+ .replace(/\*\*/g, '*')
70
+ .replace(/\*/g, '[^/]*')
71
+ .replace(/\?/g, '[^/]') + '$';
72
+ return new RegExp(re).test(name);
73
+ }
74
+
75
+ export async function safeAddDir(abs, rootReal, visited) {
76
+ try {
77
+ const real = await realpath(abs);
78
+ if (!real.startsWith(rootReal + sep) && real !== rootReal) {
79
+ warn(`symlink target outside project root — skipped: ${abs}`);
80
+ return false;
81
+ }
82
+ if (visited.has(real)) return false;
83
+ visited.add(real);
84
+ return true;
85
+ } catch { return false; }
86
+ }
87
+
88
+ export async function expandGlob(pattern, rootReal, visited, depth = 0) {
89
+ if (depth >= globDepth) return [];
90
+ if (pattern.startsWith('/') || /^[A-Za-z]:[\\/]/.test(pattern)) {
91
+ warn(`absolute path in workspace entry rejected: ${pattern}`);
92
+ return [];
93
+ }
94
+ const segs = pattern.split('/').filter(Boolean);
95
+ const wildIdx = segs.findIndex(s => s.includes('*') || s.includes('?'));
96
+ if (wildIdx === -1) {
97
+ const abs = resolve(rootReal, pattern);
98
+ return (await safeAddDir(abs, rootReal, visited)) ? [abs] : [];
99
+ }
100
+ const base = resolve(rootReal, ...segs.slice(0, wildIdx));
101
+ const wcSeg = segs[wildIdx];
102
+ const rest = segs.slice(wildIdx + 1).join('/');
103
+ const entries = await readdirSafe(base);
104
+ const results = [];
105
+ for (const e of entries) {
106
+ if (!e.isDirectory()) continue;
107
+ if (GLOBAL_IGNORE.has(e.name) || e.name.startsWith('.')) continue;
108
+ if (!matchWild(e.name, wcSeg)) continue;
109
+ if (rest) {
110
+ results.push(...await expandGlob(rest, join(base, e.name), visited, depth + 1));
111
+ } else {
112
+ const abs = join(base, e.name);
113
+ if (await safeAddDir(abs, rootReal, visited)) results.push(abs);
114
+ }
115
+ }
116
+ return results;
117
+ }
118
+
119
+ export async function expandPatterns(patterns, rootReal, visited) {
120
+ const positive = patterns.filter(p => !p.startsWith('!'));
121
+ const negative = patterns.filter(p => p.startsWith('!')).map(p => resolve(rootReal, p.slice(1)));
122
+ const dirs = [];
123
+ for (const p of positive) dirs.push(...await expandGlob(p, rootReal, visited));
124
+ return [...new Set(dirs)].filter(d => !negative.some(n => d === n || d.startsWith(n + sep)));
125
+ }
126
+
127
+ // ── YAML line-scanners ─────────────────────────────────────────────────────────
128
+ export function normalizeMelosLine(raw) {
129
+ let s = raw.trim();
130
+ if (!s || s.startsWith('#')) return null;
131
+ s = s.replace(/^-\s+/, '');
132
+ s = s.replace(/^(["'])(.*)\1$/, '$2');
133
+ s = s.replace(/#.*$/, '').trim();
134
+ s = s.replace(/&\w+/g, '').replace(/\*\w+/g, '').trim();
135
+ if (!s || s.startsWith('#')) return null;
136
+ return s;
137
+ }
138
+
139
+ export function parseMelosPackages(content) {
140
+ const lines = content.split('\n');
141
+ const results = [];
142
+ let inPkgs = false;
143
+ for (const line of lines) {
144
+ const col0NotComment = line.length > 0 && line[0] !== '#' && line[0] !== ' ' && line[0] !== '\t';
145
+ if (col0NotComment && /^packages\s*:/.test(line)) {
146
+ const flow = line.match(/:\s*\[([^\]]*)\]/);
147
+ if (flow) {
148
+ flow[1].split(',').map(normalizeMelosLine).filter(Boolean).forEach(p => results.push(p));
149
+ inPkgs = false;
150
+ } else { inPkgs = true; }
151
+ continue;
152
+ }
153
+ if (inPkgs) {
154
+ if (col0NotComment && !/^[-\s]/.test(line)) break;
155
+ const n = normalizeMelosLine(line);
156
+ if (n) results.push(n);
157
+ }
158
+ }
159
+ return results;
160
+ }
161
+
162
+ export function parseYamlList(content, key) {
163
+ const lines = content.split('\n');
164
+ const results = [];
165
+ let inSection = false;
166
+ for (const line of lines) {
167
+ const col0NC = line.length > 0 && line[0] !== '#' && line[0] !== ' ' && line[0] !== '\t';
168
+ if (col0NC && new RegExp('^' + key + '\\s*:').test(line)) {
169
+ const flow = line.match(/:\s*\[([^\]]*)\]/);
170
+ if (flow) {
171
+ flow[1].split(',').map(normalizeMelosLine).filter(Boolean).forEach(p => results.push(p));
172
+ inSection = false;
173
+ } else { inSection = true; }
174
+ continue;
175
+ }
176
+ if (inSection) {
177
+ if (col0NC && !/^[-\s]/.test(line)) break;
178
+ const n = normalizeMelosLine(line);
179
+ if (n) results.push(n);
180
+ }
181
+ }
182
+ return results;
183
+ }
184
+
185
+ // ── Package manager ───────────────────────────────────────────────────────────
186
+ export function detectPackageManager(names) {
187
+ if (names.has('bun.lockb')) return 'bun';
188
+ if (names.has('pnpm-lock.yaml')) return 'pnpm';
189
+ if (names.has('yarn.lock')) return 'yarn';
190
+ if (names.has('package-lock.json')) return 'npm';
191
+ return undefined;
192
+ }
193
+
194
+ // ── Angular version extraction ────────────────────────────────────────────────
195
+ export function extractMajorVersion(v) {
196
+ if (!v) return null;
197
+ let s = v.trim();
198
+ if (s.startsWith('workspace:')) s = s.slice('workspace:'.length).trim();
199
+ s = s.replace(/^[~^>=<]+/, '');
200
+ const major = s.split('.')[0];
201
+ return (major && /^\d+$/.test(major)) ? major : null;
202
+ }
203
+
204
+ // ── Detectors (return partial result objects) ──────────────────────────────────
205
+ export function detectIonic(names) {
206
+ if (!names.has('ionic.config.json')) return {};
207
+ return { stack: 'ionic', build: 'ionic build', test: 'npm test', setup: 'npm install' };
208
+ }
209
+
210
+ export function detectCapacitor(names) {
211
+ if (names.has('ionic.config.json')) return {};
212
+ if (!names.has('capacitor.config.json') && !names.has('capacitor.config.ts')) return {};
213
+ return { stack: 'capacitor', build: 'npx cap build', setup: 'npm install && npx cap sync' };
214
+ }
215
+
216
+ export function detectRNExpo(pkg) {
217
+ if (!pkg || !pkgDep(pkg, 'react-native') || !pkgDep(pkg, 'expo')) return {};
218
+ return { stack: 'react-native-expo', build: 'expo build', test: 'jest', setup: 'npm install' };
219
+ }
220
+
221
+ export function detectRNBare(pkg, names) {
222
+ if (!pkg || !pkgDep(pkg, 'react-native')) return {};
223
+ if (names.has('ionic.config.json') || names.has('capacitor.config.json') || names.has('capacitor.config.ts')) return {};
224
+ return { stack: 'react-native', build: 'react-native bundle', test: 'jest', setup: 'npm install' };
225
+ }
226
+
227
+ export async function detectFlutter(names, root) {
228
+ if (!names.has('pubspec.yaml')) return {};
229
+ const c = await readText(join(root, 'pubspec.yaml'));
230
+ if (!c || !/^flutter\s*:/m.test(c)) return {};
231
+ return { stack: 'flutter', build: 'flutter build', test: 'flutter test', lint: 'flutter analyze', format: 'dart format .', setup: 'flutter pub get' };
232
+ }
233
+
234
+ export async function detectAngular(pkg, workspacePkgs) {
235
+ for (const p of [pkg, ...(workspacePkgs ?? [])].filter(Boolean)) {
236
+ const v = p?.dependencies?.['@angular/core'] || p?.devDependencies?.['@angular/core'];
237
+ const major = extractMajorVersion(v);
238
+ if (!major) continue;
239
+ const s = p.scripts ?? {};
240
+ return { stack: `Angular ${major}`, build: s.build ?? 'ng build', test: s.test ?? 'ng test', lint: s.lint ?? 'ng lint', format: 'prettier --write .', setup: 'npm install' };
241
+ }
242
+ return {};
243
+ }
244
+
245
+ export function detectNextjs(pkg) {
246
+ if (!pkg || !pkgDep(pkg, 'next')) return {};
247
+ const s = pkg.scripts ?? {};
248
+ return { stack: 'typescript', build: s.build ?? 'next build', test: s.test ?? 'jest', lint: s.lint ?? 'next lint', format: 'prettier --write .', setup: 'npm install' };
249
+ }
250
+
251
+ export function detectNestjs(pkg) {
252
+ if (!pkg || !pkgDep(pkg, '@nestjs/core')) return {};
253
+ const s = pkg.scripts ?? {};
254
+ return { stack: 'typescript', build: s.build ?? 'nest build', test: s.test ?? 'jest', lint: s.lint ?? 'eslint .', format: 'prettier --write .', setup: 'npm install' };
255
+ }
256
+
257
+ export function detectReact(pkg) {
258
+ if (!pkg || !pkgProdDep(pkg, 'react')) return {};
259
+ const s = pkg.scripts ?? {};
260
+ return { stack: 'typescript', build: s.build, test: s.test ?? 'jest', lint: s.lint, format: 'prettier --write .', setup: 'npm install' };
261
+ }
262
+
263
+ export function detectVue(pkg) {
264
+ if (!pkg || !pkgProdDep(pkg, 'vue')) return {};
265
+ const s = pkg.scripts ?? {};
266
+ return { stack: 'typescript', build: s.build, test: s.test ?? 'vitest', lint: s.lint, format: 'prettier --write .', setup: 'npm install' };
267
+ }
268
+
269
+ export function detectTSNode(pkg) {
270
+ if (!pkg) return {};
271
+ const s = pkg.scripts ?? {};
272
+ const hasDep = pkgDep(pkg, 'typescript') || pkgDep(pkg, 'ts-node');
273
+ if (!hasDep && !pkg.name) return {};
274
+ return { stack: 'typescript', build: s.build, test: s.test, lint: s.lint, format: s.format, setup: 'npm install' };
275
+ }
276
+
277
+ export async function detectGo(names, root) {
278
+ if (!names.has('go.mod')) return {};
279
+ const c = await readText(join(root, 'go.mod'), 20);
280
+ const m = c?.match(/^go\s+(\d+\.\d+)/m);
281
+ const result = { stack: 'go', build: 'go build ./...', test: 'go test ./...', format: 'gofmt -w .', setup: 'go mod download' };
282
+ if (m) result.goVersion = m[1];
283
+ return result;
284
+ }
285
+
286
+ export async function detectPython(names, root) {
287
+ if (!names.has('pyproject.toml') && !names.has('requirements.txt') &&
288
+ !names.has('Pipfile') && !names.has('uv.lock') && !names.has('poetry.lock') && !names.has('hatch.toml')) return {};
289
+ if (names.has('uv.lock')) return { stack: 'python', test: 'uv run pytest', lint: 'uv run ruff check .', format: 'uv run ruff format .', setup: 'uv sync' };
290
+ const py = names.has('pyproject.toml') ? await readText(join(root, 'pyproject.toml')) : null;
291
+ if (py && /^\[tool\.uv\]/m.test(py)) return { stack: 'python', test: 'uv run pytest', lint: 'uv run ruff check .', format: 'uv run ruff format .', setup: 'uv sync' };
292
+ if ((py && /^\[tool\.poetry\]/m.test(py)) || names.has('poetry.lock')) return { stack: 'python', test: 'poetry run pytest', lint: 'poetry run ruff check .', format: 'poetry run ruff format .', setup: 'poetry install' };
293
+ if (names.has('Pipfile')) return { stack: 'python', test: 'pipenv run pytest', setup: 'pipenv install' };
294
+ if ((py && /^\[tool\.hatch\]/m.test(py)) || names.has('hatch.toml')) return { stack: 'python', test: 'hatch run test', setup: 'hatch env create' };
295
+ if (names.has('requirements.txt')) return { stack: 'python', test: 'pytest', setup: 'pip install -r requirements.txt' };
296
+ if (py) return { stack: 'python', test: 'pytest', setup: 'pip install -e .' };
297
+ return {};
298
+ }
299
+
300
+ export async function detectRust(names, root) {
301
+ if (!names.has('Cargo.toml')) return {};
302
+ const c = await readText(join(root, 'Cargo.toml'));
303
+ let name;
304
+ if (c) {
305
+ const blocks = c.split(/\n(?=\[)/);
306
+ for (const b of blocks) {
307
+ if (!/^\[package\]/.test(b)) continue;
308
+ const m = b.match(/^name\s*=\s*"([^"]+)"/m);
309
+ if (m) { name = m[1]; break; }
310
+ }
311
+ }
312
+ return { stack: 'rust', name, build: 'cargo build', test: 'cargo test', lint: 'cargo clippy -- -D warnings', format: 'cargo fmt', setup: 'cargo fetch' };
313
+ }
314
+
315
+ export function detectScala(names) {
316
+ if (!names.has('build.sbt')) return {};
317
+ return { stack: 'scala', build: 'sbt compile', test: 'sbt test', lint: 'sbt scalafmt --check', format: 'sbt scalafmt', setup: 'sbt update' };
318
+ }
319
+
320
+ export async function detectSpringBoot(names, root) {
321
+ const hasPom = names.has('pom.xml');
322
+ const hasBuild = names.has('build.gradle') || names.has('build.gradle.kts');
323
+ if (!hasPom && !hasBuild) return {};
324
+ if (hasPom) {
325
+ const c = await readText(join(root, 'pom.xml'));
326
+ if (c?.includes('spring-boot-starter')) {
327
+ const w = names.has('mvnw') ? './mvnw' : 'mvn';
328
+ return { stack: 'spring-boot', build: `${w} package -DskipTests`, test: `${w} test`, setup: `${w} dependency:resolve` };
329
+ }
330
+ }
331
+ if (hasBuild) {
332
+ const fname = names.has('build.gradle.kts') ? 'build.gradle.kts' : 'build.gradle';
333
+ const c = await readText(join(root, fname));
334
+ if (c?.includes('spring-boot-starter')) {
335
+ const w = names.has('gradlew') ? './gradlew' : 'gradle';
336
+ return { stack: 'spring-boot', build: `${w} build`, test: `${w} test`, setup: `${w} dependencies` };
337
+ }
338
+ }
339
+ return {};
340
+ }
341
+
342
+ export async function detectQuarkus(names, root) {
343
+ const hasPom = names.has('pom.xml');
344
+ const hasBuild = names.has('build.gradle') || names.has('build.gradle.kts');
345
+ if (!hasPom && !hasBuild) return {};
346
+ const fname = hasPom ? 'pom.xml' : (names.has('build.gradle.kts') ? 'build.gradle.kts' : 'build.gradle');
347
+ const c = await readText(join(root, fname));
348
+ if (!c?.includes('quarkus')) return {};
349
+ const w = hasBuild ? (names.has('gradlew') ? './gradlew' : 'gradle') : (names.has('mvnw') ? './mvnw' : 'mvn');
350
+ return { stack: 'java', build: `${w} package`, test: `${w} test`, setup: hasBuild ? `${w} quarkusDev` : `${w} quarkus:dev` };
351
+ }
352
+
353
+ export async function detectJava(names, root) {
354
+ const hasPom = names.has('pom.xml');
355
+ const hasBuild = names.has('build.gradle') || names.has('build.gradle.kts');
356
+ if (!hasPom && !hasBuild) return {};
357
+ if (hasPom) {
358
+ const w = names.has('mvnw') ? './mvnw' : 'mvn';
359
+ return { stack: 'java', build: `${w} package`, test: `${w} test`, setup: `${w} dependency:resolve` };
360
+ }
361
+ const w = names.has('gradlew') ? './gradlew' : 'gradle';
362
+ return { stack: 'java', build: `${w} build`, test: `${w} test`, setup: `${w} dependencies` };
363
+ }
364
+
365
+ export function detectDotNet(names) {
366
+ const csproj = [...names].find(n => n.endsWith('.csproj'));
367
+ const sln = [...names].find(n => n.endsWith('.sln'));
368
+ if (!csproj && !sln) return {};
369
+ return { stack: 'csharp', build: 'dotnet build', test: 'dotnet test', lint: 'dotnet format --verify-no-changes', format: 'dotnet format', setup: sln ? `dotnet restore ${sln}` : 'dotnet restore' };
370
+ }
371
+
372
+ export async function detectIOS(names, root) {
373
+ const xcw = [...names].find(n => n.endsWith('.xcworkspace'));
374
+ const xcp = [...names].find(n => n.endsWith('.xcodeproj'));
375
+ if (!xcw && !xcp) return {};
376
+ const target = xcw ?? xcp;
377
+ const scheme = target.replace(/\.(xcworkspace|xcodeproj)$/, '');
378
+ return { stack: 'swift', build: `xcodebuild -workspace ${target} -scheme ${scheme} -sdk iphonesimulator build`, test: 'xcodebuild test', setup: names.has('Podfile') ? 'pod install' : 'swift package resolve' };
379
+ }
380
+
381
+ export async function detectAndroid(names, root) {
382
+ const aEntries = await readdirSafe(join(root, 'android'));
383
+ if (!aEntries.length) return {};
384
+ const aNames = new Set(aEntries.map(e => e.name));
385
+ if (!aNames.has('build.gradle') && !aNames.has('build.gradle.kts')) return {};
386
+ const w = names.has('gradlew') ? './gradlew' : 'gradle';
387
+ let isKotlin = false;
388
+ try {
389
+ const srcE = await readdirSafe(join(root, 'android', 'app', 'src'));
390
+ isKotlin = srcE.some(e => e.name.endsWith('.kt'));
391
+ } catch {}
392
+ return { stack: isKotlin ? 'kotlin' : 'java', build: `${w} assembleDebug`, test: `${w} test`, lint: `${w} lint`, setup: `${w} dependencies` };
393
+ }
394
+
395
+ // ── DB signals ────────────────────────────────────────────────────────────────
396
+ export async function detectDb(names, root, pkg, stack) {
397
+ if (names.has('schema.prisma')) return 'prisma';
398
+ try {
399
+ const pD = await readdirSafe(join(root, 'prisma'));
400
+ if (pD.some(e => e.name === 'schema.prisma')) return 'prisma';
401
+ } catch {}
402
+ if (names.has('migrations') || names.has('db')) return 'migrations';
403
+ if (pkg) {
404
+ const d = { ...pkg.dependencies, ...pkg.devDependencies };
405
+ if (d['@prisma/client'] || d['prisma']) return 'prisma';
406
+ if (d['pg']) return 'postgres';
407
+ if (d['mysql2'] || d['mysql']) return 'mysql';
408
+ if (d['mongodb'] || d['mongoose']) return 'mongodb';
409
+ if (d['sqlite3'] || d['better-sqlite3']) return 'sqlite';
410
+ }
411
+ if (stack === 'go') {
412
+ const c = await readText(join(root, 'go.mod'));
413
+ if (c && (c.includes('gorm.io/gorm') || c.includes('entgo.io/ent') || c.includes('database/sql'))) return 'go-orm';
414
+ }
415
+ if (stack === 'rust') {
416
+ const c = await readText(join(root, 'Cargo.toml'));
417
+ if (c && (c.includes('diesel') || c.includes('sqlx'))) return 'rust-db';
418
+ }
419
+ if (stack === 'java' || stack === 'spring-boot') {
420
+ const fname = names.has('pom.xml') ? 'pom.xml' : (names.has('build.gradle.kts') ? 'build.gradle.kts' : 'build.gradle');
421
+ const c = await readText(join(root, fname));
422
+ if (c && (c.includes('spring-data') || c.includes('hibernate'))) return 'jpa';
423
+ }
424
+ return undefined;
425
+ }
426
+
427
+ // ── Infra / CI ────────────────────────────────────────────────────────────────
428
+ export async function detectInfraCI(names, root) {
429
+ let infra, ci;
430
+ try {
431
+ const gw = await readdirSafe(join(root, '.github', 'workflows'));
432
+ if (gw.length > 0) ci = 'github-actions';
433
+ } catch {}
434
+ const hasK8s = names.has('k8s') || names.has('kubernetes') || [...names].some(n => /^(deployment|service|ingress).*\.ya?ml$/.test(n));
435
+ if (hasK8s) infra = 'kubernetes';
436
+ else if ([...names].some(n => n.endsWith('.tf'))) infra = 'terraform';
437
+ else if (names.has('Dockerfile') || [...names].some(n => n.startsWith('Dockerfile.')) || names.has('docker-compose.yml') || names.has('docker-compose.yaml') || names.has('compose.yml')) infra = 'docker';
438
+ return { infra, ci };
439
+ }
440
+
441
+ // ── Architecture ──────────────────────────────────────────────────────────────
442
+ export async function detectArchitecture(names, root) {
443
+ const check = async (targets) => {
444
+ if (targets.some(d => names.has(d))) return true;
445
+ if (!names.has('src')) return false;
446
+ const srcE = await readdirSafe(join(root, 'src'));
447
+ return targets.some(d => srcE.some(e => e.name === d && e.isDirectory()));
448
+ };
449
+ if (await check(['domain', 'ports', 'adapters', 'application', 'infrastructure'])) return 'clean';
450
+ if (await check(['controllers', 'models', 'views'])) return 'mvc';
451
+ if (await check(['service', 'repository', 'dao'])) return 'layered';
452
+ return undefined;
453
+ }
454
+
455
+ // ── Project classification ─────────────────────────────────────────────────────
456
+ const MOBILE_STACKS = new Set(['react-native', 'react-native-expo', 'flutter', 'swift', 'kotlin', 'ionic', 'capacitor']);
457
+
458
+ export function classifyProject(stack, infra, db, allDeps, dirEntries) {
459
+ if (stack && MOBILE_STACKS.has(stack)) return { projectType: 'mobile', layeredArchitecture: 'N/A' };
460
+ let FE = 0, BE = 0;
461
+ ['react','vue','svelte'].forEach(d => { if (allDeps[d]) FE += 3; });
462
+ ['express','fastify','hono','koa'].forEach(d => { if (allDeps[d]) BE += 3; });
463
+ if (allDeps['@nestjs/core']) BE += 3;
464
+ if (['go','java','spring-boot','scala','rust','csharp','python'].includes(stack)) BE += 3;
465
+ const dirNames = new Set(dirEntries.filter(e => e.isDirectory()).map(e => e.name));
466
+ if (['src','public','static','assets'].some(d => dirNames.has(d))) FE += 1;
467
+ if (['api','server','cmd','internal'].some(d => dirNames.has(d))) BE += 2;
468
+ if (db) BE += 1;
469
+ if (infra && FE < 2 && BE < 2) return { projectType: 'infra', layeredArchitecture: 'N/A' };
470
+ if (FE >= 2 && BE >= 2) return { projectType: 'fullstack', layeredArchitecture: 'fullstack' };
471
+ if (FE >= 2) return { projectType: 'frontend', layeredArchitecture: 'fe-only' };
472
+ if (BE >= 2) return { projectType: 'backend', layeredArchitecture: 'be-only' };
473
+ return { projectType: 'library', layeredArchitecture: 'unknown' };
474
+ }
475
+
476
+ // ── Main ──────────────────────────────────────────────────────────────────────
477
+ async function main() {
478
+ const root = process.argv[2] ?? process.cwd();
479
+ let rootReal;
480
+ try { rootReal = await realpath(root); } catch (err) {
481
+ process.stderr.write(JSON.stringify({ error: err.message, code: err.code ?? 'UNKNOWN' }) + '\n');
482
+ process.stdout.write('{}\n');
483
+ return;
484
+ }
485
+
486
+ let rootEntries;
487
+ try { rootEntries = await readdir(rootReal, { withFileTypes: true }); }
488
+ catch (err) {
489
+ process.stderr.write(JSON.stringify({ error: err.message, code: err.code ?? 'UNKNOWN' }) + '\n');
490
+ process.stdout.write('{}\n');
491
+ return;
492
+ }
493
+ const rootNames = new Set(rootEntries.map(e => e.name));
494
+ const visited = new Set([rootReal]);
495
+
496
+ const packageManager = detectPackageManager(rootNames);
497
+
498
+ const rootPkg = rootNames.has('package.json') ? await readManifest(join(rootReal, 'package.json')) : null;
499
+
500
+ let workspaceDirs = [];
501
+ let monorepo = false;
502
+ let melosMonorepo = false;
503
+
504
+ if (rootNames.has('pnpm-workspace.yaml')) {
505
+ monorepo = true;
506
+ const c = await readText(join(rootReal, 'pnpm-workspace.yaml'));
507
+ const patterns = c ? parseYamlList(c, 'packages') : ['apps/*', 'packages/*'];
508
+ workspaceDirs = await expandPatterns(patterns.length ? patterns : ['apps/*', 'packages/*'], rootReal, visited);
509
+ } else if (rootNames.has('melos.yaml')) {
510
+ monorepo = true; melosMonorepo = true;
511
+ const c = await readText(join(rootReal, 'melos.yaml'));
512
+ const patterns = c ? parseMelosPackages(c) : [];
513
+ workspaceDirs = await expandPatterns(patterns.length ? patterns : ['apps/*', 'packages/*'], rootReal, visited);
514
+ } else if (rootPkg) {
515
+ const ws = rootPkg.workspaces;
516
+ let patterns = [];
517
+ if (Array.isArray(ws) && ws.length > 0) { patterns = ws; monorepo = true; }
518
+ else if (ws && !Array.isArray(ws) && Array.isArray(ws.packages)) { patterns = ws.packages; monorepo = true; }
519
+ if (monorepo) workspaceDirs = await expandPatterns(patterns, rootReal, visited);
520
+ }
521
+
522
+ if (rootNames.has('go.work')) {
523
+ const c = await readText(join(rootReal, 'go.work'));
524
+ if (c) {
525
+ for (const m of c.matchAll(/^\s*use\s+(\S+)/gm)) {
526
+ const rel = m[1];
527
+ if (rel.startsWith('../')) { warn(`go.work use directive points outside project root — skipped: ${rel}`); continue; }
528
+ const abs = resolve(rootReal, rel);
529
+ if (await safeAddDir(abs, rootReal, visited)) workspaceDirs.push(abs);
530
+ }
531
+ }
532
+ }
533
+
534
+ workspaceDirs = [...new Set(workspaceDirs)].sort();
535
+ if (workspaceDirs.length > MAX_DIRS) {
536
+ warn(`workspace dir count exceeds ${MAX_DIRS} — truncating`);
537
+ workspaceDirs = workspaceDirs.slice(0, MAX_DIRS);
538
+ }
539
+
540
+ const workspacePkgs = [];
541
+ for (const dir of workspaceDirs) {
542
+ const entries = await readdirSafe(dir);
543
+ const names = new Set(entries.map(e => e.name));
544
+ if (names.has('package.json')) workspacePkgs.push(await readManifest(join(dir, 'package.json')));
545
+ else workspacePkgs.push(null);
546
+ }
547
+
548
+ const allDeps = {};
549
+ [rootPkg, ...workspacePkgs].filter(Boolean).forEach(p => {
550
+ Object.keys(p.dependencies ?? {}).forEach(k => { allDeps[k] = true; });
551
+ });
552
+
553
+ const detectorResults = [
554
+ detectIonic(rootNames),
555
+ detectCapacitor(rootNames),
556
+ detectRNExpo(rootPkg),
557
+ detectRNBare(rootPkg, rootNames),
558
+ await detectFlutter(rootNames, rootReal),
559
+ await detectAngular(rootPkg, workspacePkgs),
560
+ detectNextjs(rootPkg),
561
+ detectNestjs(rootPkg),
562
+ detectReact(rootPkg),
563
+ detectVue(rootPkg),
564
+ detectTSNode(rootPkg),
565
+ await detectGo(rootNames, rootReal),
566
+ await detectPython(rootNames, rootReal),
567
+ await detectRust(rootNames, rootReal),
568
+ detectScala(rootNames),
569
+ await detectSpringBoot(rootNames, rootReal),
570
+ await detectQuarkus(rootNames, rootReal),
571
+ await detectJava(rootNames, rootReal),
572
+ detectDotNet(rootNames),
573
+ await detectIOS(rootNames, rootReal),
574
+ await detectAndroid(rootNames, rootReal),
575
+ ];
576
+
577
+ const merged = {};
578
+ const MERGE_FIELDS = ['stack','build','test','lint','format','setup','name','goVersion'];
579
+ for (const d of detectorResults) {
580
+ for (const f of MERGE_FIELDS) {
581
+ if (merged[f] === undefined && d[f] !== undefined) merged[f] = d[f];
582
+ }
583
+ }
584
+
585
+ const langSignals = [];
586
+ if (rootNames.has('package.json') && rootPkg) langSignals.push('typescript');
587
+ if (rootNames.has('go.mod')) langSignals.push('go');
588
+ if (rootNames.has('Cargo.toml')) langSignals.push('rust');
589
+ if (rootNames.has('pom.xml') || rootNames.has('build.gradle')) langSignals.push('java');
590
+ if (langSignals.length > 1) merged.stack = langSignals.join('/');
591
+
592
+ if (rootPkg?.scripts) {
593
+ const s = rootPkg.scripts;
594
+ if (!merged.build && s.build) merged.build = s.build.trim();
595
+ if (!merged.test && s.test) merged.test = s.test.trim();
596
+ if (!merged.lint && s.lint) merged.lint = s.lint.trim();
597
+ if (!merged.format && s.format) merged.format = s.format.trim();
598
+ }
599
+
600
+ if (monorepo) {
601
+ if (packageManager === 'pnpm') {
602
+ if (!merged.build || merged.build === 'npm run build') merged.build = 'pnpm -r build';
603
+ if (!merged.test || merged.test === 'npm test') merged.test = 'pnpm -r test';
604
+ } else if (melosMonorepo) {
605
+ merged.setup = 'melos bootstrap';
606
+ }
607
+ }
608
+
609
+ if (!merged.name) {
610
+ if (rootPkg?.name) merged.name = rootPkg.name;
611
+ else if (rootNames.has('go.mod')) {
612
+ const c = await readText(join(rootReal, 'go.mod'), 5);
613
+ const m = c?.match(/^module\s+(\S+)/m);
614
+ if (m) merged.name = m[1].split('/').pop();
615
+ }
616
+ if (!merged.name) merged.name = basename(rootReal);
617
+ }
618
+
619
+ if (rootPkg?.description) merged.description = rootPkg.description.trim().replace(/\r?\n/g, ' ');
620
+
621
+ const db = await detectDb(rootNames, rootReal, rootPkg, merged.stack);
622
+ const { infra, ci } = await detectInfraCI(rootNames, rootReal);
623
+ const arch = await detectArchitecture(rootNames, rootReal);
624
+ const cls = classifyProject(merged.stack, infra, db, allDeps, rootEntries);
625
+
626
+ const raw = {
627
+ name: merged.name,
628
+ description: merged.description,
629
+ stack: merged.stack,
630
+ build: merged.build,
631
+ test: merged.test,
632
+ lint: merged.lint,
633
+ format: merged.format,
634
+ setup: merged.setup,
635
+ packageManager: packageManager,
636
+ monorepo: monorepo || undefined,
637
+ workspaceDirs: workspaceDirs.length ? workspaceDirs : undefined,
638
+ goVersion: merged.goVersion,
639
+ db: db,
640
+ projectType: cls.projectType,
641
+ layeredArchitecture: cls.layeredArchitecture,
642
+ architecture: arch,
643
+ infra: infra,
644
+ ci: ci,
645
+ };
646
+
647
+ const output = {};
648
+ for (const [k, v] of Object.entries(raw)) {
649
+ if (v === undefined || v === null) continue;
650
+ if (typeof v === 'string' && v.trim() === '') continue;
651
+ if (Array.isArray(v) && v.length === 0) continue;
652
+ output[k] = typeof v === 'string' ? v.trim() : v;
653
+ }
654
+
655
+ process.stdout.write(JSON.stringify(output, null, 2) + '\n');
656
+ process.exit(0); // Required: pending readdirSafe timeouts keep the event loop alive without this
657
+ }
658
+
659
+ process.on('unhandledRejection', err => {
660
+ process.stderr.write(JSON.stringify({ error: String(err), code: 'UNHANDLED_REJECTION' }) + '\n');
661
+ process.stdout.write('{}\n');
662
+ process.exit(0);
663
+ });
664
+
665
+ process.on('uncaughtException', err => {
666
+ process.stderr.write(JSON.stringify({ error: err.message ?? String(err), code: err.code ?? 'UNCAUGHT_EXCEPTION' }) + '\n');
667
+ process.stdout.write('{}\n');
668
+ process.exit(0);
669
+ });
670
+
671
+ // Guard: run main() only when this file is the entry point
672
+ const __filename = fileURLToPath(import.meta.url);
673
+ if (process.argv[1] === __filename) {
674
+ main().catch(err => {
675
+ process.stderr.write(JSON.stringify({ error: err.message, code: err.code ?? 'UNKNOWN' }) + '\n');
676
+ process.stdout.write('{}\n');
677
+ process.exit(0);
678
+ });
679
+ }