@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.
Files changed (53) hide show
  1. package/dist/cli.js +11182 -1559
  2. package/dist/hooks/auto-learning-pipeline.js +99 -19
  3. package/dist/hooks/classify-failure.js +99 -19
  4. package/dist/hooks/cost-tracker.js +97 -11
  5. package/dist/hooks/fix-detector.js +99 -19
  6. package/dist/hooks/incident-pipeline.js +97 -11
  7. package/dist/hooks/post-edit-context.js +97 -11
  8. package/dist/hooks/post-tool-use.js +101 -20
  9. package/dist/hooks/pre-compact.js +97 -11
  10. package/dist/hooks/pre-delete-check.js +97 -11
  11. package/dist/hooks/quality-event.js +97 -11
  12. package/dist/hooks/rule-enforcement-pipeline.js +97 -11
  13. package/dist/hooks/session-end.js +97 -11
  14. package/dist/hooks/session-start.js +8803 -782
  15. package/dist/hooks/user-prompt.js +98 -43
  16. package/package.json +13 -3
  17. package/reference/hook-execution-order.md +17 -25
  18. package/src/cli.ts +81 -2
  19. package/src/commands/config-check-drift.ts +132 -0
  20. package/src/commands/config-refresh.ts +224 -0
  21. package/src/commands/config-upgrade.ts +126 -0
  22. package/src/commands/doctor.ts +1 -29
  23. package/src/commands/init.ts +756 -216
  24. package/src/config.ts +168 -12
  25. package/src/detect/domain-inferrer.ts +142 -0
  26. package/src/detect/drift.ts +199 -0
  27. package/src/detect/framework-detector.ts +281 -0
  28. package/src/detect/index.ts +174 -0
  29. package/src/detect/migrate.ts +278 -0
  30. package/src/detect/monorepo-detector.ts +347 -0
  31. package/src/detect/package-detector.ts +728 -0
  32. package/src/detect/source-dir-detector.ts +264 -0
  33. package/src/detect/vr-command-map.ts +167 -0
  34. package/src/hooks/auto-learning-pipeline.ts +2 -2
  35. package/src/hooks/classify-failure.ts +2 -2
  36. package/src/hooks/fix-detector.ts +2 -2
  37. package/src/hooks/session-start.ts +43 -2
  38. package/src/hooks/user-prompt.ts +1 -21
  39. package/src/knowledge-indexer.ts +1 -1
  40. package/src/license.ts +1 -2
  41. package/src/memory-db.ts +0 -5
  42. package/src/memory-file-ingest.ts +6 -13
  43. package/src/tools.ts +0 -8
  44. package/templates/multi-runtime/massu.config.yaml +80 -0
  45. package/templates/python-django/massu.config.yaml +51 -0
  46. package/templates/python-fastapi/massu.config.yaml +50 -0
  47. package/templates/rust-actix/massu.config.yaml +38 -0
  48. package/templates/swift-ios/massu.config.yaml +37 -0
  49. package/templates/ts-nestjs/massu.config.yaml +43 -0
  50. package/templates/ts-nextjs/massu.config.yaml +43 -0
  51. package/README.md +0 -40
  52. package/src/claude-md-templates.ts +0 -342
  53. package/src/mcp-bridge-tools.ts +0 -458
@@ -0,0 +1,728 @@
1
+ // Copyright (c) 2026 Massu. All rights reserved.
2
+ // Licensed under BSL 1.1 - see LICENSE file for details.
3
+
4
+ /**
5
+ * Package Manifest Detector (P1-001)
6
+ * ==================================
7
+ *
8
+ * Scans a project root for dependency-manifest files across 9 ecosystems and
9
+ * returns a structured `PackageManifest[]`. Pure filesystem, pure function —
10
+ * no DB handles, no network, no child processes.
11
+ *
12
+ * Supported manifests:
13
+ * - `package.json` (Node.js/TypeScript)
14
+ * - `pyproject.toml` (Python — poetry, pep621, setuptools)
15
+ * - `requirements.txt` (Python — plain)
16
+ * - `Pipfile` (Python — pipenv)
17
+ * - `Cargo.toml` (Rust)
18
+ * - `Package.swift` (Swift)
19
+ * - `go.mod` (Go)
20
+ * - `pom.xml` (Java — Maven)
21
+ * - `build.gradle` / `build.gradle.kts` (Java/Kotlin — Gradle)
22
+ * - `Gemfile` (Ruby)
23
+ *
24
+ * Monorepos: walks up to 2 levels deep into conventional workspace roots
25
+ * (`apps/*`, `packages/*`, `services/*`, `libs/*`, `modules/*`) and returns
26
+ * a manifest per workspace.
27
+ *
28
+ * Malformed files log a structured warning to the returned `warnings[]` and do
29
+ * NOT throw, per CR-9.
30
+ *
31
+ * Usage:
32
+ * ```ts
33
+ * import { detectPackageManifests } from './detect/package-detector.ts';
34
+ * const { manifests, warnings } = detectPackageManifests('/repo');
35
+ * ```
36
+ */
37
+
38
+ import { readFileSync, existsSync, statSync, lstatSync, readdirSync } from 'fs';
39
+ import { join, relative } from 'path';
40
+ import { parse as parseToml } from 'smol-toml';
41
+
42
+ export type SupportedLanguage =
43
+ | 'typescript'
44
+ | 'javascript'
45
+ | 'python'
46
+ | 'rust'
47
+ | 'swift'
48
+ | 'go'
49
+ | 'java'
50
+ | 'ruby';
51
+
52
+ export interface PackageManifest {
53
+ /** Absolute path to the manifest file. */
54
+ path: string;
55
+ /** Path relative to projectRoot, forward-slash normalized. */
56
+ relativePath: string;
57
+ /** Workspace/package root directory (parent of manifest). */
58
+ directory: string;
59
+ /** Language this manifest belongs to. */
60
+ language: SupportedLanguage;
61
+ /** Runtime family (e.g., 'node', 'python3', 'cargo', 'xcode'). */
62
+ runtime: string;
63
+ /** Manifest-declared package name (best-effort; null when not present). */
64
+ name: string | null;
65
+ /** Declared version when available. */
66
+ version: string | null;
67
+ /** Runtime dependencies. */
68
+ dependencies: string[];
69
+ /** Dev / test / build dependencies. */
70
+ devDependencies: string[];
71
+ /** Script / task names declared (e.g., npm scripts, poetry scripts). */
72
+ scripts: string[];
73
+ /** Raw manifest type key. */
74
+ manifestType:
75
+ | 'package.json'
76
+ | 'pyproject.toml'
77
+ | 'requirements.txt'
78
+ | 'Pipfile'
79
+ | 'Cargo.toml'
80
+ | 'Package.swift'
81
+ | 'go.mod'
82
+ | 'pom.xml'
83
+ | 'build.gradle'
84
+ | 'Gemfile';
85
+ }
86
+
87
+ export interface DetectionWarning {
88
+ path: string;
89
+ reason: string;
90
+ }
91
+
92
+ export interface PackageDetectionResult {
93
+ manifests: PackageManifest[];
94
+ warnings: DetectionWarning[];
95
+ }
96
+
97
+ const WORKSPACE_DIRS = ['apps', 'packages', 'services', 'libs', 'modules'];
98
+
99
+ const IGNORED_DIRS = new Set([
100
+ 'node_modules',
101
+ '.venv',
102
+ 'venv',
103
+ '__pycache__',
104
+ 'dist',
105
+ 'build',
106
+ '.build',
107
+ 'target',
108
+ '.next',
109
+ '.nuxt',
110
+ 'coverage',
111
+ '.git',
112
+ '.massu',
113
+ '.turbo',
114
+ '.cache',
115
+ '.pytest_cache',
116
+ '.mypy_cache',
117
+ 'DerivedData',
118
+ 'Pods',
119
+ ]);
120
+
121
+ const MANIFEST_FILES = [
122
+ 'package.json',
123
+ 'pyproject.toml',
124
+ 'requirements.txt',
125
+ 'Pipfile',
126
+ 'Cargo.toml',
127
+ 'Package.swift',
128
+ 'go.mod',
129
+ 'pom.xml',
130
+ 'build.gradle',
131
+ 'build.gradle.kts',
132
+ 'Gemfile',
133
+ ];
134
+
135
+ function safeRead(path: string): string | null {
136
+ try {
137
+ if (!existsSync(path)) return null;
138
+ // lstatSync does NOT follow symlinks — required for accurate symlink detection.
139
+ // statSync returns the target's stat, so .isSymbolicLink() would be false on
140
+ // symlink-to-regular-file, bypassing the intended rejection.
141
+ const ls = lstatSync(path);
142
+ if (ls.isSymbolicLink()) return null;
143
+ const st = statSync(path);
144
+ if (!st.isFile()) return null;
145
+ return readFileSync(path, 'utf-8');
146
+ } catch {
147
+ return null;
148
+ }
149
+ }
150
+
151
+ function normalizeRelative(root: string, path: string): string {
152
+ const rel = relative(root, path);
153
+ return rel.split(/[/\\]/).join('/');
154
+ }
155
+
156
+ function parsePackageJson(
157
+ path: string,
158
+ directory: string,
159
+ root: string,
160
+ warnings: DetectionWarning[]
161
+ ): PackageManifest | null {
162
+ const raw = safeRead(path);
163
+ if (raw === null) return null;
164
+ let pkg: Record<string, unknown>;
165
+ try {
166
+ pkg = JSON.parse(raw) as Record<string, unknown>;
167
+ } catch (err) {
168
+ warnings.push({
169
+ path,
170
+ reason: `package.json JSON parse failed: ${(err as Error).message}`,
171
+ });
172
+ return null;
173
+ }
174
+ const deps = Object.keys(
175
+ (pkg.dependencies as Record<string, string>) ?? {}
176
+ );
177
+ const devDeps = Object.keys(
178
+ (pkg.devDependencies as Record<string, string>) ?? {}
179
+ );
180
+ const peer = Object.keys(
181
+ (pkg.peerDependencies as Record<string, string>) ?? {}
182
+ );
183
+ // Classify TypeScript vs JavaScript based on typescript dep presence or tsconfig.
184
+ const hasTs =
185
+ deps.includes('typescript') ||
186
+ devDeps.includes('typescript') ||
187
+ existsSync(join(directory, 'tsconfig.json'));
188
+ const language: SupportedLanguage = hasTs ? 'typescript' : 'javascript';
189
+ const scripts = Object.keys(
190
+ (pkg.scripts as Record<string, string>) ?? {}
191
+ );
192
+ return {
193
+ path,
194
+ relativePath: normalizeRelative(root, path),
195
+ directory,
196
+ language,
197
+ runtime: 'node',
198
+ name: typeof pkg.name === 'string' ? pkg.name : null,
199
+ version: typeof pkg.version === 'string' ? pkg.version : null,
200
+ dependencies: [...deps, ...peer],
201
+ devDependencies: devDeps,
202
+ scripts,
203
+ manifestType: 'package.json',
204
+ };
205
+ }
206
+
207
+ function parsePyproject(
208
+ path: string,
209
+ directory: string,
210
+ root: string,
211
+ warnings: DetectionWarning[]
212
+ ): PackageManifest | null {
213
+ const raw = safeRead(path);
214
+ if (raw === null) return null;
215
+ let toml: Record<string, unknown>;
216
+ try {
217
+ toml = parseToml(raw) as Record<string, unknown>;
218
+ } catch (err) {
219
+ warnings.push({
220
+ path,
221
+ reason: `pyproject.toml TOML parse failed: ${(err as Error).message}`,
222
+ });
223
+ return null;
224
+ }
225
+ const deps: string[] = [];
226
+ const devDeps: string[] = [];
227
+ const scripts: string[] = [];
228
+ let name: string | null = null;
229
+ let version: string | null = null;
230
+
231
+ // PEP 621 [project] table
232
+ const project = toml.project as Record<string, unknown> | undefined;
233
+ if (project && typeof project === 'object') {
234
+ if (typeof project.name === 'string') name = project.name;
235
+ if (typeof project.version === 'string') version = project.version;
236
+ const pd = project.dependencies;
237
+ if (Array.isArray(pd)) {
238
+ for (const d of pd) {
239
+ if (typeof d === 'string') deps.push(normalizePyDep(d));
240
+ }
241
+ }
242
+ const optDeps = project['optional-dependencies'] as
243
+ | Record<string, unknown>
244
+ | undefined;
245
+ if (optDeps && typeof optDeps === 'object') {
246
+ for (const grp of Object.values(optDeps)) {
247
+ if (Array.isArray(grp)) {
248
+ for (const d of grp) {
249
+ if (typeof d === 'string') devDeps.push(normalizePyDep(d));
250
+ }
251
+ }
252
+ }
253
+ }
254
+ const psScripts = project.scripts as Record<string, unknown> | undefined;
255
+ if (psScripts && typeof psScripts === 'object') {
256
+ scripts.push(...Object.keys(psScripts));
257
+ }
258
+ }
259
+
260
+ // Poetry [tool.poetry]
261
+ const tool = toml.tool as Record<string, unknown> | undefined;
262
+ const poetry = tool?.poetry as Record<string, unknown> | undefined;
263
+ if (poetry && typeof poetry === 'object') {
264
+ if (!name && typeof poetry.name === 'string') name = poetry.name;
265
+ if (!version && typeof poetry.version === 'string') version = poetry.version;
266
+ const pdeps = poetry.dependencies as Record<string, unknown> | undefined;
267
+ if (pdeps && typeof pdeps === 'object') {
268
+ for (const k of Object.keys(pdeps)) {
269
+ if (k !== 'python') deps.push(k);
270
+ }
271
+ }
272
+ const groups = poetry.group as Record<string, unknown> | undefined;
273
+ if (groups && typeof groups === 'object') {
274
+ for (const grp of Object.values(groups)) {
275
+ const grpObj = grp as Record<string, unknown> | undefined;
276
+ const grpDeps = grpObj?.dependencies as
277
+ | Record<string, unknown>
278
+ | undefined;
279
+ if (grpDeps && typeof grpDeps === 'object') {
280
+ for (const k of Object.keys(grpDeps)) {
281
+ if (k !== 'python') devDeps.push(k);
282
+ }
283
+ }
284
+ }
285
+ }
286
+ // Legacy poetry dev-dependencies
287
+ const legacyDev = poetry['dev-dependencies'] as
288
+ | Record<string, unknown>
289
+ | undefined;
290
+ if (legacyDev && typeof legacyDev === 'object') {
291
+ for (const k of Object.keys(legacyDev)) {
292
+ if (k !== 'python') devDeps.push(k);
293
+ }
294
+ }
295
+ const pScripts = poetry.scripts as Record<string, unknown> | undefined;
296
+ if (pScripts && typeof pScripts === 'object') {
297
+ scripts.push(...Object.keys(pScripts));
298
+ }
299
+ }
300
+
301
+ return {
302
+ path,
303
+ relativePath: normalizeRelative(root, path),
304
+ directory,
305
+ language: 'python',
306
+ runtime: 'python3',
307
+ name,
308
+ version,
309
+ dependencies: deps,
310
+ devDependencies: devDeps,
311
+ scripts,
312
+ manifestType: 'pyproject.toml',
313
+ };
314
+ }
315
+
316
+ function normalizePyDep(spec: string): string {
317
+ // Strip version specifiers, extras, markers.
318
+ // Example: "fastapi[all]>=0.110,<0.120 ; python_version>='3.10'"
319
+ // → "fastapi"
320
+ const semi = spec.split(';')[0];
321
+ const extras = semi.split('[')[0];
322
+ const name = extras.split(/[=<>!~ ]/)[0];
323
+ return name.trim();
324
+ }
325
+
326
+ function parseRequirementsTxt(
327
+ path: string,
328
+ directory: string,
329
+ root: string,
330
+ _warnings: DetectionWarning[]
331
+ ): PackageManifest | null {
332
+ const raw = safeRead(path);
333
+ if (raw === null) return null;
334
+ const deps: string[] = [];
335
+ for (const line of raw.split(/\r?\n/)) {
336
+ const trimmed = line.trim();
337
+ if (!trimmed) continue;
338
+ if (trimmed.startsWith('#')) continue;
339
+ if (trimmed.startsWith('-')) continue; // -r, -e, --index-url, etc.
340
+ const name = normalizePyDep(trimmed);
341
+ if (name) deps.push(name);
342
+ }
343
+ return {
344
+ path,
345
+ relativePath: normalizeRelative(root, path),
346
+ directory,
347
+ language: 'python',
348
+ runtime: 'python3',
349
+ name: null,
350
+ version: null,
351
+ dependencies: deps,
352
+ devDependencies: [],
353
+ scripts: [],
354
+ manifestType: 'requirements.txt',
355
+ };
356
+ }
357
+
358
+ function parsePipfile(
359
+ path: string,
360
+ directory: string,
361
+ root: string,
362
+ warnings: DetectionWarning[]
363
+ ): PackageManifest | null {
364
+ const raw = safeRead(path);
365
+ if (raw === null) return null;
366
+ let toml: Record<string, unknown>;
367
+ try {
368
+ toml = parseToml(raw) as Record<string, unknown>;
369
+ } catch (err) {
370
+ warnings.push({
371
+ path,
372
+ reason: `Pipfile TOML parse failed: ${(err as Error).message}`,
373
+ });
374
+ return null;
375
+ }
376
+ const packages =
377
+ (toml.packages as Record<string, unknown> | undefined) ?? {};
378
+ const devPackages =
379
+ (toml['dev-packages'] as Record<string, unknown> | undefined) ?? {};
380
+ return {
381
+ path,
382
+ relativePath: normalizeRelative(root, path),
383
+ directory,
384
+ language: 'python',
385
+ runtime: 'python3',
386
+ name: null,
387
+ version: null,
388
+ dependencies: Object.keys(packages),
389
+ devDependencies: Object.keys(devPackages),
390
+ scripts: [],
391
+ manifestType: 'Pipfile',
392
+ };
393
+ }
394
+
395
+ function parseCargoToml(
396
+ path: string,
397
+ directory: string,
398
+ root: string,
399
+ warnings: DetectionWarning[]
400
+ ): PackageManifest | null {
401
+ const raw = safeRead(path);
402
+ if (raw === null) return null;
403
+ let toml: Record<string, unknown>;
404
+ try {
405
+ toml = parseToml(raw) as Record<string, unknown>;
406
+ } catch (err) {
407
+ warnings.push({
408
+ path,
409
+ reason: `Cargo.toml TOML parse failed: ${(err as Error).message}`,
410
+ });
411
+ return null;
412
+ }
413
+ const pkg = toml.package as Record<string, unknown> | undefined;
414
+ const deps = toml.dependencies as Record<string, unknown> | undefined;
415
+ const devDeps = toml['dev-dependencies'] as
416
+ | Record<string, unknown>
417
+ | undefined;
418
+ return {
419
+ path,
420
+ relativePath: normalizeRelative(root, path),
421
+ directory,
422
+ language: 'rust',
423
+ runtime: 'cargo',
424
+ name: typeof pkg?.name === 'string' ? (pkg.name as string) : null,
425
+ version: typeof pkg?.version === 'string' ? (pkg.version as string) : null,
426
+ dependencies: deps ? Object.keys(deps) : [],
427
+ devDependencies: devDeps ? Object.keys(devDeps) : [],
428
+ scripts: [],
429
+ manifestType: 'Cargo.toml',
430
+ };
431
+ }
432
+
433
+ function parsePackageSwift(
434
+ path: string,
435
+ directory: string,
436
+ root: string,
437
+ _warnings: DetectionWarning[]
438
+ ): PackageManifest | null {
439
+ const raw = safeRead(path);
440
+ if (raw === null) return null;
441
+ const deps: string[] = [];
442
+ // .package(url: "https://github.com/foo/bar.git", ...) → extract "bar"
443
+ const urlRe = /\.package\s*\(\s*(?:name\s*:\s*"([^"]+)"\s*,\s*)?url\s*:\s*"([^"]+)"/g;
444
+ let m: RegExpExecArray | null;
445
+ while ((m = urlRe.exec(raw)) !== null) {
446
+ const explicitName = m[1];
447
+ if (explicitName) {
448
+ deps.push(explicitName);
449
+ continue;
450
+ }
451
+ const url = m[2];
452
+ const last = url.split('/').pop() ?? '';
453
+ const clean = last.replace(/\.git$/, '').trim();
454
+ if (clean) deps.push(clean);
455
+ }
456
+ // name: "MyLibrary"
457
+ const nameMatch = /let\s+package\s*=\s*Package\s*\(\s*name\s*:\s*"([^"]+)"/.exec(
458
+ raw
459
+ );
460
+ return {
461
+ path,
462
+ relativePath: normalizeRelative(root, path),
463
+ directory,
464
+ language: 'swift',
465
+ runtime: 'xcode',
466
+ name: nameMatch ? nameMatch[1] : null,
467
+ version: null,
468
+ dependencies: deps,
469
+ devDependencies: [],
470
+ scripts: [],
471
+ manifestType: 'Package.swift',
472
+ };
473
+ }
474
+
475
+ function parseGoMod(
476
+ path: string,
477
+ directory: string,
478
+ root: string,
479
+ _warnings: DetectionWarning[]
480
+ ): PackageManifest | null {
481
+ const raw = safeRead(path);
482
+ if (raw === null) return null;
483
+ const deps: string[] = [];
484
+ let name: string | null = null;
485
+ let inRequire = false;
486
+ for (const rawLine of raw.split(/\r?\n/)) {
487
+ const line = rawLine.trim();
488
+ if (!line || line.startsWith('//')) continue;
489
+ if (line.startsWith('module ')) {
490
+ name = line.slice('module '.length).trim();
491
+ continue;
492
+ }
493
+ if (line === 'require (') {
494
+ inRequire = true;
495
+ continue;
496
+ }
497
+ if (inRequire) {
498
+ if (line === ')') {
499
+ inRequire = false;
500
+ continue;
501
+ }
502
+ const parts = line.split(/\s+/);
503
+ if (parts.length >= 2 && !parts[0].startsWith('//')) deps.push(parts[0]);
504
+ continue;
505
+ }
506
+ if (line.startsWith('require ')) {
507
+ const parts = line.slice('require '.length).trim().split(/\s+/);
508
+ if (parts[0]) deps.push(parts[0]);
509
+ }
510
+ }
511
+ return {
512
+ path,
513
+ relativePath: normalizeRelative(root, path),
514
+ directory,
515
+ language: 'go',
516
+ runtime: 'go',
517
+ name,
518
+ version: null,
519
+ dependencies: deps,
520
+ devDependencies: [],
521
+ scripts: [],
522
+ manifestType: 'go.mod',
523
+ };
524
+ }
525
+
526
+ function parsePomXml(
527
+ path: string,
528
+ directory: string,
529
+ root: string,
530
+ _warnings: DetectionWarning[]
531
+ ): PackageManifest | null {
532
+ const raw = safeRead(path);
533
+ if (raw === null) return null;
534
+ const deps: string[] = [];
535
+ const depRe = /<dependency>[\s\S]*?<artifactId>([^<]+)<\/artifactId>[\s\S]*?<\/dependency>/g;
536
+ let m: RegExpExecArray | null;
537
+ while ((m = depRe.exec(raw)) !== null) deps.push(m[1].trim());
538
+ const nameMatch = /<artifactId>([^<]+)<\/artifactId>/.exec(raw);
539
+ const versionMatch = /<project[^>]*>[\s\S]*?<version>([^<]+)<\/version>/.exec(
540
+ raw
541
+ );
542
+ return {
543
+ path,
544
+ relativePath: normalizeRelative(root, path),
545
+ directory,
546
+ language: 'java',
547
+ runtime: 'jvm',
548
+ name: nameMatch ? nameMatch[1].trim() : null,
549
+ version: versionMatch ? versionMatch[1].trim() : null,
550
+ dependencies: deps,
551
+ devDependencies: [],
552
+ scripts: [],
553
+ manifestType: 'pom.xml',
554
+ };
555
+ }
556
+
557
+ function parseBuildGradle(
558
+ path: string,
559
+ directory: string,
560
+ root: string,
561
+ _warnings: DetectionWarning[]
562
+ ): PackageManifest | null {
563
+ const raw = safeRead(path);
564
+ if (raw === null) return null;
565
+ const deps: string[] = [];
566
+ const devDeps: string[] = [];
567
+ // implementation 'group:artifact:version' | implementation("group:artifact:version")
568
+ const re = /(implementation|api|runtimeOnly|compileOnly|testImplementation|testRuntimeOnly|androidTestImplementation)\s*[\("']+([^"'\)]+)[\)"']+/g;
569
+ let m: RegExpExecArray | null;
570
+ while ((m = re.exec(raw)) !== null) {
571
+ const scope = m[1];
572
+ const coord = m[2];
573
+ const parts = coord.split(':');
574
+ const artifact = parts.length >= 2 ? parts[1] : parts[0];
575
+ if (!artifact) continue;
576
+ if (scope.toLowerCase().startsWith('test')) devDeps.push(artifact);
577
+ else deps.push(artifact);
578
+ }
579
+ return {
580
+ path,
581
+ relativePath: normalizeRelative(root, path),
582
+ directory,
583
+ language: 'java',
584
+ runtime: 'jvm',
585
+ name: null,
586
+ version: null,
587
+ dependencies: deps,
588
+ devDependencies: devDeps,
589
+ scripts: [],
590
+ manifestType: 'build.gradle',
591
+ };
592
+ }
593
+
594
+ function parseGemfile(
595
+ path: string,
596
+ directory: string,
597
+ root: string,
598
+ _warnings: DetectionWarning[]
599
+ ): PackageManifest | null {
600
+ const raw = safeRead(path);
601
+ if (raw === null) return null;
602
+ const deps: string[] = [];
603
+ const devDeps: string[] = [];
604
+ let inDevGroup = false;
605
+ for (const rawLine of raw.split(/\r?\n/)) {
606
+ const line = rawLine.trim();
607
+ if (!line || line.startsWith('#')) continue;
608
+ if (/^group\s*:test|^group\s+:development/.test(line)) inDevGroup = true;
609
+ if (/^end\b/.test(line)) inDevGroup = false;
610
+ const gemMatch = /^gem\s+["']([^"']+)["']/.exec(line);
611
+ if (gemMatch) {
612
+ if (inDevGroup) devDeps.push(gemMatch[1]);
613
+ else deps.push(gemMatch[1]);
614
+ }
615
+ }
616
+ return {
617
+ path,
618
+ relativePath: normalizeRelative(root, path),
619
+ directory,
620
+ language: 'ruby',
621
+ runtime: 'ruby',
622
+ name: null,
623
+ version: null,
624
+ dependencies: deps,
625
+ devDependencies: devDeps,
626
+ scripts: [],
627
+ manifestType: 'Gemfile',
628
+ };
629
+ }
630
+
631
+ function detectManifestsInDir(
632
+ dir: string,
633
+ root: string,
634
+ warnings: DetectionWarning[]
635
+ ): PackageManifest[] {
636
+ const out: PackageManifest[] = [];
637
+ for (const fname of MANIFEST_FILES) {
638
+ const path = join(dir, fname);
639
+ if (!existsSync(path)) continue;
640
+ let m: PackageManifest | null = null;
641
+ switch (fname) {
642
+ case 'package.json':
643
+ m = parsePackageJson(path, dir, root, warnings);
644
+ break;
645
+ case 'pyproject.toml':
646
+ m = parsePyproject(path, dir, root, warnings);
647
+ break;
648
+ case 'requirements.txt':
649
+ m = parseRequirementsTxt(path, dir, root, warnings);
650
+ break;
651
+ case 'Pipfile':
652
+ m = parsePipfile(path, dir, root, warnings);
653
+ break;
654
+ case 'Cargo.toml':
655
+ m = parseCargoToml(path, dir, root, warnings);
656
+ break;
657
+ case 'Package.swift':
658
+ m = parsePackageSwift(path, dir, root, warnings);
659
+ break;
660
+ case 'go.mod':
661
+ m = parseGoMod(path, dir, root, warnings);
662
+ break;
663
+ case 'pom.xml':
664
+ m = parsePomXml(path, dir, root, warnings);
665
+ break;
666
+ case 'build.gradle':
667
+ case 'build.gradle.kts':
668
+ m = parseBuildGradle(path, dir, root, warnings);
669
+ break;
670
+ case 'Gemfile':
671
+ m = parseGemfile(path, dir, root, warnings);
672
+ break;
673
+ }
674
+ if (m !== null) out.push(m);
675
+ }
676
+ return out;
677
+ }
678
+
679
+ function listSubdirs(dir: string): string[] {
680
+ try {
681
+ return readdirSync(dir, { withFileTypes: true })
682
+ .filter((e) => e.isDirectory() && !IGNORED_DIRS.has(e.name))
683
+ .map((e) => join(dir, e.name));
684
+ } catch {
685
+ return [];
686
+ }
687
+ }
688
+
689
+ /**
690
+ * Scan a project root for dependency manifests.
691
+ *
692
+ * Walks projectRoot plus conventional workspace subtrees (`apps/*`,
693
+ * `packages/*`, `services/*`, `libs/*`, `modules/*`) up to 2 levels deep.
694
+ */
695
+ export function detectPackageManifests(
696
+ projectRoot: string
697
+ ): PackageDetectionResult {
698
+ const warnings: DetectionWarning[] = [];
699
+ const manifests: PackageManifest[] = [];
700
+
701
+ // Level 0: projectRoot itself
702
+ manifests.push(...detectManifestsInDir(projectRoot, projectRoot, warnings));
703
+
704
+ // Level 1: workspace roots (apps/, packages/, services/, libs/, modules/)
705
+ for (const ws of WORKSPACE_DIRS) {
706
+ const wsRoot = join(projectRoot, ws);
707
+ if (!existsSync(wsRoot)) continue;
708
+ for (const sub of listSubdirs(wsRoot)) {
709
+ manifests.push(...detectManifestsInDir(sub, projectRoot, warnings));
710
+ // Level 2 (one nesting allowed, e.g., apps/ios/<target>)
711
+ for (const sub2 of listSubdirs(sub)) {
712
+ manifests.push(...detectManifestsInDir(sub2, projectRoot, warnings));
713
+ }
714
+ }
715
+ }
716
+
717
+ // Deduplicate by manifest path (rare, but a nested workspace dir equal to its
718
+ // parent by coincidence could double-scan).
719
+ const seen = new Set<string>();
720
+ const dedup: PackageManifest[] = [];
721
+ for (const m of manifests) {
722
+ if (seen.has(m.path)) continue;
723
+ seen.add(m.path);
724
+ dedup.push(m);
725
+ }
726
+
727
+ return { manifests: dedup, warnings };
728
+ }