@getmikk/core 1.3.2 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/utils/fs.ts CHANGED
@@ -2,18 +2,376 @@ import * as fs from 'node:fs/promises'
2
2
  import * as path from 'node:path'
3
3
  import fg from 'fast-glob'
4
4
 
5
+ // ─── Well-known patterns for schema/config/route files ─────────────
6
+ // These are structural files an AI agent needs but aren't source code.
7
+ // Mikk auto-discovers them so the AI doesn't have to explore the filesystem.
8
+ // Patterns are language-agnostic — unused patterns simply return zero matches.
9
+ const CONTEXT_FILE_PATTERNS = [
10
+ // Data models / schemas — JS/TS
11
+ '**/prisma/schema.prisma',
12
+ '**/drizzle/**/*.ts',
13
+ '**/schema/**/*.{ts,js,graphql,gql,sql}',
14
+ '**/models/**/*.{ts,js}',
15
+ '**/*.schema.{ts,js}',
16
+ '**/*.model.{ts,js}',
17
+ // Data models / schemas — Python
18
+ '**/models.py',
19
+ '**/schemas.py',
20
+ '**/serializers.py',
21
+ '**/models/**/*.py',
22
+ // Data models / schemas — Ruby
23
+ '**/app/models/**/*.rb',
24
+ '**/db/schema.rb',
25
+ // Data models / schemas — Go / Rust / Java / PHP
26
+ '**/models/*.go',
27
+ '**/*_model.go',
28
+ '**/schema.rs',
29
+ '**/models.rs',
30
+ '**/entity/**/*.java',
31
+ '**/model/**/*.java',
32
+ '**/dto/**/*.java',
33
+ '**/Entities/**/*.php',
34
+ '**/Models/**/*.php',
35
+ // GraphQL / Proto
36
+ '**/*.graphql',
37
+ '**/*.gql',
38
+ '**/*.proto',
39
+ // API definitions
40
+ '**/openapi.{yaml,yml,json}',
41
+ '**/swagger.{yaml,yml,json}',
42
+ // Route definitions
43
+ '**/routes/**/*.{ts,js}',
44
+ '**/router.{ts,js}',
45
+ // Database migrations (latest only) — multi-language
46
+ '**/migrations/**/migration.sql',
47
+ '**/db/migrate/**/*.rb',
48
+ '**/alembic/**/*.py',
49
+ '**/migrations/**/*.sql',
50
+ // Type definitions
51
+ '**/types/**/*.{ts,js}',
52
+ '**/types.{ts,js}',
53
+ '**/interfaces/**/*.{ts,js}',
54
+ // Config files
55
+ '**/docker-compose.{yml,yaml}',
56
+ '**/Dockerfile',
57
+ '.env.example',
58
+ '.env.local.example',
59
+ // Schema definitions — general
60
+ '**/schema.{yaml,yml,json}',
61
+ '**/*.avsc',
62
+ '**/*.thrift',
63
+ ]
64
+
65
+ const CONTEXT_FILE_IGNORE = [
66
+ // JavaScript / TypeScript
67
+ '**/node_modules/**',
68
+ '**/dist/**',
69
+ '**/.next/**',
70
+ '**/.nuxt/**',
71
+ '**/.svelte-kit/**',
72
+ '**/.astro/**',
73
+ '**/*.d.ts',
74
+ '**/*.test.{ts,js,tsx,jsx}',
75
+ '**/*.spec.{ts,js,tsx,jsx}',
76
+ // General
77
+ '**/build/**',
78
+ '**/coverage/**',
79
+ '**/.mikk/**',
80
+ '**/.git/**',
81
+ // Python
82
+ '**/__pycache__/**',
83
+ '**/*.pyc',
84
+ '**/venv/**',
85
+ '**/.venv/**',
86
+ '**/.tox/**',
87
+ // Go
88
+ '**/vendor/**',
89
+ // Rust / Java
90
+ '**/target/**',
91
+ // C# / .NET
92
+ '**/bin/**',
93
+ '**/obj/**',
94
+ // Ruby / PHP
95
+ '**/vendor/**',
96
+ // Elixir
97
+ '**/deps/**',
98
+ '**/_build/**',
99
+ // Gradle
100
+ '**/.gradle/**',
101
+ ]
102
+
103
+ /** Category of a discovered context file */
104
+ export type ContextFileType = 'schema' | 'model' | 'types' | 'routes' | 'config' | 'api-spec' | 'migration' | 'docker'
105
+
106
+ /** A discovered context file with its content and inferred category */
107
+ export interface ContextFile {
108
+ /** Relative path from project root */
109
+ path: string
110
+ /** Raw content of the file */
111
+ content: string
112
+ /** Inferred category */
113
+ type: ContextFileType
114
+ /** File size in bytes */
115
+ size: number
116
+ }
117
+
118
+ /** Maximum size (in bytes) for a single context file — skip huge files */
119
+ const MAX_CONTEXT_FILE_SIZE = 50_000 // ~50KB
120
+
121
+ // ─── .mikkignore support ───────────────────────────────────────────
122
+
123
+ /**
124
+ * Read a .mikkignore file from the project root and parse it into
125
+ * fast-glob compatible ignore patterns.
126
+ *
127
+ * Syntax: gitignore-style.
128
+ * - Lines starting with # are comments
129
+ * - Blank lines are ignored
130
+ * - Patterns without / match anywhere in the path (e.g. "dist" ignores "dist/index.js" and "src/dist/util.js")
131
+ * - Patterns with / are relative to root
132
+ * - Negation (!) lines are skipped (not yet supported)
133
+ */
134
+
135
+ export async function readMikkIgnore(projectRoot: string): Promise<string[]> {
136
+ const ignorePath = path.join(projectRoot, '.mikkignore')
137
+ try {
138
+ const content = await fs.readFile(ignorePath, 'utf-8')
139
+ return parseMikkIgnore(content)
140
+ } catch {
141
+ return [] // no .mikkignore — that's fine
142
+ }
143
+ }
144
+
145
+ /** Parse .mikkignore content into fast-glob ignore patterns (exported for testing) */
146
+ export function parseMikkIgnore(content: string): string[] {
147
+ const patterns: string[] = []
148
+ for (const raw of content.split('\n')) {
149
+ const line = raw.trim()
150
+ if (!line || line.startsWith('#')) continue
151
+ if (line.startsWith('!')) continue // negations not yet supported
152
+
153
+ const isDir = line.endsWith('/')
154
+ // If pattern has no slash (ignoring trailing slash), match anywhere → prepend **/
155
+ const stripped = isDir ? line.slice(0, -1) : line
156
+ const hasSlash = stripped.includes('/')
157
+
158
+ if (!hasSlash) {
159
+ if (isDir) {
160
+ // e.g. "dist/" → "**/{dist}/**" — ignore the directory and everything within it
161
+ patterns.push(`**/${stripped}/**`)
162
+ } else {
163
+ // e.g. "*.svg" → "**/*.svg"
164
+ patterns.push(`**/${line}`)
165
+ }
166
+ } else {
167
+ if (isDir) {
168
+ // e.g. "packages/*/tests/" → "packages/*/tests/**"
169
+ patterns.push(`${stripped}/**`)
170
+ } else {
171
+ // e.g. "components/ui/**" — relative to root, already valid
172
+ patterns.push(line)
173
+ }
174
+ }
175
+ }
176
+ return patterns
177
+ }
178
+
179
+ /**
180
+ * Discover structural / schema / config files that help an AI agent understand
181
+ * the project's data models, API definitions, route structure, and config.
182
+ *
183
+ * This is technology-agnostic: it works for Prisma, Drizzle, GraphQL, SQL,
184
+ * Protobuf, Docker, OpenAPI, and more — anything with a well-known file pattern.
185
+ */
186
+ export async function discoverContextFiles(projectRoot: string): Promise<ContextFile[]> {
187
+ const mikkIgnore = await readMikkIgnore(projectRoot)
188
+ const files = await fg(CONTEXT_FILE_PATTERNS, {
189
+ cwd: projectRoot,
190
+ ignore: [...CONTEXT_FILE_IGNORE, ...mikkIgnore],
191
+ absolute: false,
192
+ onlyFiles: true,
193
+ })
194
+
195
+ const normalised = files.map(f => f.replace(/\\/g, '/'))
196
+
197
+ // Deduplicate — some patterns overlap (e.g. models/*.ts also matched by source discovery)
198
+ const unique = [...new Set(normalised)]
199
+
200
+ const results: ContextFile[] = []
201
+
202
+ for (const relPath of unique) {
203
+ const absPath = path.join(projectRoot, relPath)
204
+ try {
205
+ const stat = await fs.stat(absPath)
206
+ if (stat.size > MAX_CONTEXT_FILE_SIZE) continue // skip huge files
207
+ if (stat.size === 0) continue
208
+
209
+ const content = await fs.readFile(absPath, 'utf-8')
210
+ const type = inferContextFileType(relPath)
211
+
212
+ results.push({ path: relPath, content, type, size: stat.size })
213
+ } catch {
214
+ // File unreadable — skip
215
+ }
216
+ }
217
+
218
+ // Sort: schemas/models first, then types, routes, config
219
+ const priority: Record<ContextFileType, number> = {
220
+ schema: 0,
221
+ model: 1,
222
+ types: 2,
223
+ 'api-spec': 3,
224
+ routes: 4,
225
+ migration: 5,
226
+ docker: 6,
227
+ config: 7,
228
+ }
229
+ results.sort((a, b) => priority[a.type] - priority[b.type])
230
+
231
+ // If we have a schema file (e.g. prisma/schema.prisma), the migrations
232
+ // are redundant — they represent historical deltas, not the current state.
233
+ // Including them wastes AI tokens and can be actively misleading.
234
+ const hasSchema = results.some(f => f.type === 'schema')
235
+ if (hasSchema) {
236
+ return results.filter(f => f.type !== 'migration')
237
+ }
238
+
239
+ return results
240
+ }
241
+
242
+ /** Infer the context file's category from its path */
243
+ function inferContextFileType(filePath: string): ContextFileType {
244
+ const lower = filePath.toLowerCase()
245
+ // Schema files — multi-language
246
+ if (lower.includes('prisma/schema') || lower.endsWith('.prisma')) return 'schema'
247
+ if (lower.includes('drizzle/') || lower.includes('.schema.')) return 'schema'
248
+ if (lower.endsWith('.graphql') || lower.endsWith('.gql')) return 'schema'
249
+ if (lower.endsWith('.avsc') || lower.endsWith('.thrift')) return 'schema'
250
+ if (lower.endsWith('db/schema.rb')) return 'schema'
251
+ if (lower.endsWith('schema.rs')) return 'schema'
252
+ if (lower.endsWith('.proto')) return 'api-spec'
253
+ if (lower.includes('openapi') || lower.includes('swagger')) return 'api-spec'
254
+ // Migrations — multi-language
255
+ if (lower.endsWith('.sql') && lower.includes('migration')) return 'migration'
256
+ if (lower.includes('db/migrate/')) return 'migration'
257
+ if (lower.includes('alembic/')) return 'migration'
258
+ if (lower.endsWith('.sql')) return 'schema'
259
+ // Models — any language
260
+ if (lower.includes('/models/') || lower.includes('/model/')) return 'model'
261
+ if (lower.endsWith('.model.ts') || lower.endsWith('.model.js') || lower.endsWith('.model.go')) return 'model'
262
+ if (lower.endsWith('models.py') || lower.endsWith('serializers.py') || lower.endsWith('schemas.py')) return 'model'
263
+ if (lower.includes('/entity/') || lower.includes('/dto/') || lower.includes('/entities/')) return 'model'
264
+ if (lower.endsWith('_model.go') || lower.endsWith('models.rs')) return 'model'
265
+ // Types / Interfaces
266
+ if (lower.includes('/types/') || lower.startsWith('types/') || lower.endsWith('/types.ts') || lower.endsWith('/types.js')) return 'types'
267
+ if (lower.includes('/interfaces/') || lower.startsWith('interfaces/')) return 'types'
268
+ // Routes
269
+ if (lower.includes('/routes/') || lower.includes('router.')) return 'routes'
270
+ // Docker
271
+ if (lower.includes('docker') || lower.includes('dockerfile')) return 'docker'
272
+ // Config
273
+ if (lower.includes('.env')) return 'config'
274
+ return 'config'
275
+ }
276
+
277
+ /** Recognised project language */
278
+ export type ProjectLanguage = 'typescript' | 'javascript' | 'python' | 'go' | 'rust' | 'java' | 'ruby' | 'php' | 'csharp' | 'unknown'
279
+
280
+ /** Auto-detect the project's primary language from manifest files */
281
+ export async function detectProjectLanguage(projectRoot: string): Promise<ProjectLanguage> {
282
+ const exists = async (name: string) => {
283
+ try { await fs.access(path.join(projectRoot, name)); return true } catch { return false }
284
+ }
285
+ const hasGlob = async (pattern: string) => {
286
+ const matches = await fg(pattern, { cwd: projectRoot, onlyFiles: true, deep: 1 })
287
+ return matches.length > 0
288
+ }
289
+ // Check in priority order — most specific first
290
+ if (await exists('tsconfig.json') || await hasGlob('tsconfig.*.json')) return 'typescript'
291
+ if (await exists('Cargo.toml')) return 'rust'
292
+ if (await exists('go.mod')) return 'go'
293
+ if (await exists('pyproject.toml') || await exists('setup.py') || await exists('requirements.txt')) return 'python'
294
+ if (await exists('Gemfile')) return 'ruby'
295
+ if (await exists('pom.xml') || await exists('build.gradle') || await exists('build.gradle.kts')) return 'java'
296
+ if (await exists('composer.json')) return 'php'
297
+ if (await hasGlob('*.csproj') || await hasGlob('*.sln')) return 'csharp'
298
+ if (await exists('package.json')) return 'javascript'
299
+ return 'unknown'
300
+ }
301
+
302
+ /** Get source file glob patterns for a given language */
303
+ export function getDiscoveryPatterns(language: ProjectLanguage): { patterns: string[], ignore: string[] } {
304
+ const commonIgnore = [
305
+ '**/.mikk/**', '**/.git/**', '**/coverage/**', '**/build/**',
306
+ ]
307
+ switch (language) {
308
+ case 'typescript':
309
+ return {
310
+ patterns: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
311
+ ignore: [...commonIgnore, '**/node_modules/**', '**/dist/**', '**/.next/**', '**/.nuxt/**', '**/.svelte-kit/**', '**/*.d.ts', '**/*.test.{ts,js,tsx,jsx}', '**/*.spec.{ts,js,tsx,jsx}'],
312
+ }
313
+ case 'javascript':
314
+ return {
315
+ patterns: ['**/*.js', '**/*.jsx', '**/*.mjs', '**/*.cjs', '**/*.ts', '**/*.tsx'],
316
+ ignore: [...commonIgnore, '**/node_modules/**', '**/dist/**', '**/.next/**', '**/*.d.ts', '**/*.test.{ts,js,tsx,jsx}', '**/*.spec.{ts,js,tsx,jsx}'],
317
+ }
318
+ case 'python':
319
+ return {
320
+ patterns: ['**/*.py'],
321
+ ignore: [...commonIgnore, '**/__pycache__/**', '**/venv/**', '**/.venv/**', '**/.tox/**', '**/test_*.py', '**/*_test.py'],
322
+ }
323
+ case 'go':
324
+ return {
325
+ patterns: ['**/*.go'],
326
+ ignore: [...commonIgnore, '**/vendor/**', '**/*_test.go'],
327
+ }
328
+ case 'rust':
329
+ return {
330
+ patterns: ['**/*.rs'],
331
+ ignore: [...commonIgnore, '**/target/**'],
332
+ }
333
+ case 'java':
334
+ return {
335
+ patterns: ['**/*.java', '**/*.kt'],
336
+ ignore: [...commonIgnore, '**/target/**', '**/.gradle/**', '**/Test*.java', '**/*Test.java'],
337
+ }
338
+ case 'ruby':
339
+ return {
340
+ patterns: ['**/*.rb'],
341
+ ignore: [...commonIgnore, '**/vendor/**', '**/*_spec.rb', '**/spec/**'],
342
+ }
343
+ case 'php':
344
+ return {
345
+ patterns: ['**/*.php'],
346
+ ignore: [...commonIgnore, '**/vendor/**', '**/*Test.php'],
347
+ }
348
+ case 'csharp':
349
+ return {
350
+ patterns: ['**/*.cs'],
351
+ ignore: [...commonIgnore, '**/bin/**', '**/obj/**'],
352
+ }
353
+ default:
354
+ // Fallback: discover JS/TS (most common)
355
+ return {
356
+ patterns: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
357
+ ignore: [...commonIgnore, '**/node_modules/**', '**/dist/**', '**/*.d.ts'],
358
+ }
359
+ }
360
+ }
361
+
5
362
  /**
6
363
  * Discover all source files in a project directory.
7
- * Respects common ignore patterns (node_modules, dist, .mikk, etc.)
364
+ * Respects common ignore patterns and supports multiple languages.
8
365
  */
9
366
  export async function discoverFiles(
10
367
  projectRoot: string,
11
- patterns: string[] = ['**/*.ts', '**/*.tsx'],
12
- ignore: string[] = ['**/node_modules/**', '**/dist/**', '**/.mikk/**', '**/coverage/**', '**/*.d.ts', '**/*.test.ts', '**/*.spec.ts']
368
+ patterns: string[] = ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
369
+ ignore: string[] = ['**/node_modules/**', '**/dist/**', '**/.mikk/**', '**/coverage/**', '**/*.d.ts', '**/*.test.{ts,js,tsx,jsx}', '**/*.spec.{ts,js,tsx,jsx}']
13
370
  ): Promise<string[]> {
371
+ const mikkIgnore = await readMikkIgnore(projectRoot)
14
372
  const files = await fg(patterns, {
15
373
  cwd: projectRoot,
16
- ignore,
374
+ ignore: [...ignore, ...mikkIgnore],
17
375
  absolute: false,
18
376
  onlyFiles: true,
19
377
  })
@@ -73,3 +431,227 @@ export async function setupMikkDirectory(projectRoot: string): Promise<void> {
73
431
  await fs.writeFile(impactKeep, '', 'utf-8')
74
432
  }
75
433
  }
434
+
435
+ // ─── .mikkignore auto-generation ────────────────────────────────────
436
+
437
+ /** Default ignore patterns shared across all languages */
438
+ const COMMON_IGNORE_PATTERNS = [
439
+ '# Build outputs',
440
+ 'dist/',
441
+ 'build/',
442
+ 'out/',
443
+ 'coverage/',
444
+ '',
445
+ '# Mikk internal',
446
+ '.mikk/',
447
+ '',
448
+ '# IDE / OS',
449
+ '.idea/',
450
+ '.vscode/',
451
+ '*.log',
452
+ '',
453
+ ]
454
+
455
+ /** Language-specific ignore templates */
456
+ const LANGUAGE_IGNORE_TEMPLATES: Record<ProjectLanguage, string[]> = {
457
+ typescript: [
458
+ '# Test files',
459
+ '**/*.test.ts',
460
+ '**/*.test.tsx',
461
+ '**/*.spec.ts',
462
+ '**/*.spec.tsx',
463
+ '__tests__/',
464
+ '**/tests/fixtures/',
465
+ '**/test-utils/',
466
+ '',
467
+ '# Generated / declaration files',
468
+ '*.d.ts',
469
+ '',
470
+ '# Node.js',
471
+ 'node_modules/',
472
+ '.next/',
473
+ '.nuxt/',
474
+ '.svelte-kit/',
475
+ '.astro/',
476
+ '',
477
+ ],
478
+ javascript: [
479
+ '# Test files',
480
+ '**/*.test.js',
481
+ '**/*.test.jsx',
482
+ '**/*.spec.js',
483
+ '**/*.spec.jsx',
484
+ '__tests__/',
485
+ '**/tests/fixtures/',
486
+ '**/test-utils/',
487
+ '',
488
+ '# Generated / declaration files',
489
+ '*.d.ts',
490
+ '',
491
+ '# Node.js',
492
+ 'node_modules/',
493
+ '.next/',
494
+ '',
495
+ ],
496
+ python: [
497
+ '# Test files',
498
+ 'test_*.py',
499
+ '*_test.py',
500
+ 'conftest.py',
501
+ 'tests/',
502
+ '**/tests/fixtures/',
503
+ '',
504
+ '# Python artifacts',
505
+ '__pycache__/',
506
+ '*.pyc',
507
+ 'venv/',
508
+ '.venv/',
509
+ '.tox/',
510
+ '*.egg-info/',
511
+ '',
512
+ ],
513
+ go: [
514
+ '# Test files',
515
+ '*_test.go',
516
+ 'testdata/',
517
+ '',
518
+ '# Go artifacts',
519
+ 'vendor/',
520
+ '',
521
+ ],
522
+ rust: [
523
+ '# Test files (inline tests are kept — only test binaries excluded)',
524
+ 'target/',
525
+ 'tests/fixtures/',
526
+ '',
527
+ ],
528
+ java: [
529
+ '# Test files',
530
+ '**/src/test/**',
531
+ 'Test*.java',
532
+ '*Test.java',
533
+ '*Tests.java',
534
+ '',
535
+ '# Build artifacts',
536
+ 'target/',
537
+ '.gradle/',
538
+ 'gradle/',
539
+ '',
540
+ ],
541
+ ruby: [
542
+ '# Test files',
543
+ '*_spec.rb',
544
+ 'spec/',
545
+ 'test/',
546
+ '',
547
+ '# Ruby artifacts',
548
+ 'vendor/',
549
+ '',
550
+ ],
551
+ php: [
552
+ '# Test files',
553
+ '*Test.php',
554
+ 'tests/',
555
+ '',
556
+ '# PHP artifacts',
557
+ 'vendor/',
558
+ '',
559
+ ],
560
+ csharp: [
561
+ '# Test files',
562
+ '*.Tests/',
563
+ '*.Test/',
564
+ '**/Tests/**',
565
+ '',
566
+ '# Build artifacts',
567
+ 'bin/',
568
+ 'obj/',
569
+ '',
570
+ ],
571
+ unknown: [
572
+ '# Test files (add your patterns here)',
573
+ 'tests/',
574
+ 'test/',
575
+ '__tests__/',
576
+ '',
577
+ ],
578
+ }
579
+
580
+ /**
581
+ * Generate a .mikkignore file with smart defaults for the detected language.
582
+ * Only creates the file if it doesn't already exist.
583
+ * Returns true if a file was created, false if one already exists.
584
+ */
585
+ export async function generateMikkIgnore(projectRoot: string, language: ProjectLanguage): Promise<boolean> {
586
+ const ignorePath = path.join(projectRoot, '.mikkignore')
587
+
588
+ // Don't overwrite an existing .mikkignore
589
+ if (await fileExists(ignorePath)) return false
590
+
591
+ const lines: string[] = [
592
+ '# .mikkignore — files/directories Mikk should skip during analysis',
593
+ '# Syntax: gitignore-style patterns. Lines starting with # are comments.',
594
+ '# Paths without / match anywhere. Paths with / are relative to project root.',
595
+ '',
596
+ ...COMMON_IGNORE_PATTERNS,
597
+ ...LANGUAGE_IGNORE_TEMPLATES[language],
598
+ ]
599
+
600
+ // Monorepo detection: if there are workspace definitions, add common
601
+ // monorepo patterns (e.g. packages/*/tests/, apps/*/tests/)
602
+ try {
603
+ const pkgRaw = await fs.readFile(path.join(projectRoot, 'package.json'), 'utf-8')
604
+ const pkg = JSON.parse(pkgRaw)
605
+ const workspaces: string[] | undefined = Array.isArray(pkg.workspaces)
606
+ ? pkg.workspaces
607
+ : pkg.workspaces?.packages
608
+
609
+ if (workspaces && workspaces.length > 0) {
610
+ lines.push('# Monorepo — test/fixture directories across all packages')
611
+ for (const ws of workspaces) {
612
+ // ws is like "packages/*" or "apps/*"
613
+ const base = ws.replace(/\/?\*$/, '')
614
+ lines.push(`${base}/*/tests/`)
615
+ lines.push(`${base}/*/__tests__/`)
616
+ lines.push(`${base}/*/test/`)
617
+ }
618
+ lines.push('')
619
+ }
620
+ } catch {
621
+ // No package.json or not JSON — skip monorepo detection
622
+ }
623
+
624
+ // Turbo / pnpm workspace detection
625
+ try {
626
+ const turboRaw = await fs.readFile(path.join(projectRoot, 'turbo.json'), 'utf-8')
627
+ // turbo.json exists — likely a monorepo already handled above
628
+ void turboRaw
629
+ } catch {
630
+ // not a turbo project
631
+ }
632
+
633
+ // pnpm-workspace.yaml detection
634
+ try {
635
+ const pnpmWs = await fs.readFile(path.join(projectRoot, 'pnpm-workspace.yaml'), 'utf-8')
636
+ // Extract package paths from "packages:" section
637
+ const packageLines = pnpmWs.split('\n')
638
+ .filter(l => l.trim().startsWith('-'))
639
+ .map(l => l.replace(/^\s*-\s*['"]?/, '').replace(/['"]?\s*$/, '').trim())
640
+
641
+ if (packageLines.length > 0 && !lines.some(l => l.includes('Monorepo'))) {
642
+ lines.push('# Monorepo (pnpm) — test/fixture directories across all packages')
643
+ for (const ws of packageLines) {
644
+ const base = ws.replace(/\/?\*$/, '')
645
+ lines.push(`${base}/*/tests/`)
646
+ lines.push(`${base}/*/__tests__/`)
647
+ lines.push(`${base}/*/test/`)
648
+ }
649
+ lines.push('')
650
+ }
651
+ } catch {
652
+ // no pnpm-workspace.yaml
653
+ }
654
+
655
+ await fs.writeFile(ignorePath, lines.join('\n'), 'utf-8')
656
+ return true
657
+ }