refacil-sdd-ai 5.2.2 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/NOTICE.md +46 -0
  2. package/README.md +209 -42
  3. package/agents/auditor.md +46 -0
  4. package/agents/debugger.md +41 -1
  5. package/agents/implementer.md +76 -10
  6. package/agents/investigator.md +36 -0
  7. package/agents/proposer.md +46 -2
  8. package/agents/tester.md +45 -8
  9. package/agents/validator.md +67 -13
  10. package/bin/cli.js +428 -83
  11. package/bin/postinstall.js +20 -0
  12. package/lib/bus/broker.js +121 -3
  13. package/lib/bus/spawn.js +189 -121
  14. package/lib/check-review.js +102 -0
  15. package/lib/codegraph-telemetry.js +135 -0
  16. package/lib/codegraph.js +273 -0
  17. package/lib/commands/autopilot.js +120 -0
  18. package/lib/commands/bus.js +29 -36
  19. package/lib/commands/compact.js +185 -46
  20. package/lib/commands/read-spec.js +352 -0
  21. package/lib/commands/sdd.js +429 -44
  22. package/lib/compact-guidance.js +122 -77
  23. package/lib/config.js +136 -0
  24. package/lib/global-paths.js +56 -20
  25. package/lib/hooks.js +32 -4
  26. package/lib/ide-detection.js +1 -1
  27. package/lib/ignore-files.js +5 -1
  28. package/lib/installer.js +202 -19
  29. package/lib/kapso.js +241 -0
  30. package/lib/methodology-migration-pending.js +13 -0
  31. package/lib/open-browser.js +32 -0
  32. package/lib/opencode-migrate.js +148 -0
  33. package/lib/opencode-plugin/index.js +84 -104
  34. package/lib/opencode-plugin/rules.js +236 -0
  35. package/lib/project-root.js +154 -0
  36. package/lib/repo-ide-sync.js +5 -0
  37. package/lib/spec-reader/lang.js +72 -0
  38. package/lib/spec-reader/md-parser.js +299 -0
  39. package/lib/spec-reader/session.js +139 -0
  40. package/lib/spec-reader/ui/app.js +685 -0
  41. package/lib/spec-reader/ui/index.html +59 -0
  42. package/lib/spec-reader/ui/mixed-lang.js +200 -0
  43. package/lib/spec-reader/ui/model-cache.js +117 -0
  44. package/lib/spec-reader/ui/style.css +294 -0
  45. package/lib/spec-reader/ui/supertonic-helper.js +565 -0
  46. package/lib/spec-sync.js +258 -0
  47. package/lib/test-scope.js +713 -0
  48. package/lib/testing-policy-sync.js +14 -2
  49. package/package.json +6 -3
  50. package/skills/apply/SKILL.md +39 -64
  51. package/skills/archive/SKILL.md +74 -48
  52. package/skills/ask/SKILL.md +43 -8
  53. package/skills/autopilot/SKILL.md +476 -0
  54. package/skills/bug/SKILL.md +52 -53
  55. package/skills/explore/SKILL.md +48 -1
  56. package/skills/guide/SKILL.md +31 -13
  57. package/skills/inbox/SKILL.md +9 -0
  58. package/skills/join/SKILL.md +1 -1
  59. package/skills/prereqs/BUS-CROSS-REPO.md +33 -16
  60. package/skills/prereqs/METHODOLOGY-CONTRACT.md +96 -17
  61. package/skills/prereqs/SKILL.md +1 -1
  62. package/skills/propose/SKILL.md +74 -19
  63. package/skills/read-spec/SKILL.md +76 -0
  64. package/skills/reply/SKILL.md +42 -9
  65. package/skills/review/SKILL.md +63 -25
  66. package/skills/review/checklist.md +2 -2
  67. package/skills/say/SKILL.md +40 -4
  68. package/skills/setup/SKILL.md +59 -5
  69. package/skills/setup/troubleshooting.md +11 -3
  70. package/skills/stats/SKILL.md +157 -0
  71. package/skills/test/SKILL.md +35 -10
  72. package/skills/up-code/SKILL.md +20 -13
  73. package/skills/update/SKILL.md +32 -1
  74. package/skills/verify/SKILL.md +78 -41
  75. package/templates/compact-guidance.md +10 -0
  76. package/templates/methodology-guide.md +5 -0
@@ -0,0 +1,713 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * test-scope.js — Multi-language test file selection logic.
5
+ *
6
+ * Encapsulates stack detection and scoped test file discovery so LLM
7
+ * skills do not have to implement this logic themselves.
8
+ *
9
+ * Public API:
10
+ * detectStack(projectRoot)
11
+ * findTestFileByConvention(sourceFile, stack, projectRoot)
12
+ * findTestFilesByImport(sourceFile, stack, projectRoot)
13
+ * testScope({ files, stack, baseline, projectRoot })
14
+ * findModuleRoot(filePath, projectRoot)
15
+ * isTestFile(filePath, stack, projectRoot?)
16
+ * affectedComponents({ files, projectRoot })
17
+ */
18
+
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Known stacks — used to validate caller-supplied hints (Fix #3).
24
+ // An unrecognized hint is treated as 'unknown' to trigger the fallback path
25
+ // rather than silently building a wrong command.
26
+ // ---------------------------------------------------------------------------
27
+
28
+ const KNOWN_STACKS = ['node', 'python', 'go', 'rust', 'java', 'dotnet'];
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Planning-only file patterns — never justify a test run on their own.
32
+ // ---------------------------------------------------------------------------
33
+
34
+ const PLANNING_PATTERNS = [
35
+ /refacil-sdd[\\/].+\.md$/i,
36
+ /openspec[\\/].+\.md$/i,
37
+ /^AGENTS\.md$/i,
38
+ /^CLAUDE\.md$/i,
39
+ /^\.cursorrules$/i,
40
+ /^README\.md$/i,
41
+ /^CHANGELOG\.md$/i,
42
+ ];
43
+
44
+ function isPlanningFile(filePath) {
45
+ const normalized = filePath.replace(/\\/g, '/');
46
+ return PLANNING_PATTERNS.some((re) => re.test(normalized));
47
+ }
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Stack detection
51
+ // ---------------------------------------------------------------------------
52
+
53
+ /**
54
+ * Detect the project stack from well-known config files.
55
+ *
56
+ * Returns one of: 'node', 'python', 'go', 'rust', 'java', 'dotnet', or 'unknown'.
57
+ *
58
+ * @param {string} projectRoot
59
+ * @returns {string}
60
+ */
61
+ function detectStack(projectRoot) {
62
+ if (!projectRoot || !fs.existsSync(projectRoot)) return 'unknown';
63
+
64
+ // Node.js
65
+ if (fs.existsSync(path.join(projectRoot, 'package.json'))) return 'node';
66
+
67
+ // Python
68
+ if (
69
+ fs.existsSync(path.join(projectRoot, 'pyproject.toml')) ||
70
+ fs.existsSync(path.join(projectRoot, 'setup.py')) ||
71
+ fs.existsSync(path.join(projectRoot, 'pytest.ini')) ||
72
+ fs.existsSync(path.join(projectRoot, 'setup.cfg'))
73
+ ) {
74
+ return 'python';
75
+ }
76
+
77
+ // Go
78
+ if (fs.existsSync(path.join(projectRoot, 'go.mod'))) return 'go';
79
+
80
+ // Rust
81
+ if (fs.existsSync(path.join(projectRoot, 'Cargo.toml'))) return 'rust';
82
+
83
+ // Java/Kotlin (Maven or Gradle)
84
+ if (
85
+ fs.existsSync(path.join(projectRoot, 'pom.xml')) ||
86
+ fs.existsSync(path.join(projectRoot, 'build.gradle')) ||
87
+ fs.existsSync(path.join(projectRoot, 'build.gradle.kts'))
88
+ ) {
89
+ return 'java';
90
+ }
91
+
92
+ // .NET
93
+ if (
94
+ fs.existsSync(path.join(projectRoot, 'global.json')) ||
95
+ fs.existsSync(path.join(projectRoot, 'Directory.Build.props'))
96
+ ) {
97
+ return 'dotnet';
98
+ }
99
+
100
+ return 'unknown';
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Convention-based test file lookup
105
+ // ---------------------------------------------------------------------------
106
+
107
+ /**
108
+ * Naming conventions per stack: given a source file, return candidate test
109
+ * file paths (absolute) to check on disk.
110
+ *
111
+ * @param {string} sourceFile - absolute or relative path to source file
112
+ * @param {string} stack
113
+ * @param {string} projectRoot
114
+ * @returns {string[]} existing test file paths
115
+ */
116
+ function findTestFileByConvention(sourceFile, stack, projectRoot) {
117
+ const abs = path.isAbsolute(sourceFile)
118
+ ? sourceFile
119
+ : path.resolve(projectRoot, sourceFile);
120
+
121
+ const dir = path.dirname(abs);
122
+ const ext = path.extname(abs);
123
+ const base = path.basename(abs, ext);
124
+ const root = projectRoot || '';
125
+
126
+ const candidates = [];
127
+
128
+ if (stack === 'node') {
129
+ // Common Node.js patterns
130
+ // 1. Sibling test dir at same level: lib/foo.js → test/foo.test.js
131
+ const relDir = root ? path.relative(root, dir) : dir;
132
+ const relBase = path.join(relDir, base);
133
+
134
+ // Direct sibling: lib/foo.js → lib/foo.test.js or lib/__tests__/foo.test.js
135
+ candidates.push(path.join(dir, `${base}.test${ext}`));
136
+ candidates.push(path.join(dir, `${base}.spec${ext}`));
137
+ candidates.push(path.join(dir, '__tests__', `${base}.test${ext}`));
138
+
139
+ // Parallel test directory (replace leading segment with 'test')
140
+ if (root) {
141
+ const segments = relBase.split(path.sep);
142
+ if (segments.length >= 2) {
143
+ // e.g. lib/foo → test/foo.test.js
144
+ const withoutFirst = segments.slice(1).join(path.sep);
145
+ candidates.push(path.join(root, 'test', `${withoutFirst}.test${ext}`));
146
+ candidates.push(path.join(root, 'test', `${withoutFirst}.spec${ext}`));
147
+ // e.g. lib/sub/foo → test/sub/foo.test.js
148
+ candidates.push(
149
+ path.join(root, 'test', path.dirname(withoutFirst), `${base}.test${ext}`),
150
+ );
151
+ // Direct test dir match
152
+ candidates.push(path.join(root, 'test', `${base}.test${ext}`));
153
+ candidates.push(path.join(root, 'test', `${base}.spec${ext}`));
154
+ }
155
+ }
156
+ } else if (stack === 'python') {
157
+ candidates.push(path.join(dir, `test_${base}.py`));
158
+ candidates.push(path.join(dir, `${base}_test.py`));
159
+ if (root) {
160
+ candidates.push(path.join(root, 'tests', `test_${base}.py`));
161
+ candidates.push(path.join(root, 'test', `test_${base}.py`));
162
+ }
163
+ } else if (stack === 'go') {
164
+ // Go tests live alongside the source
165
+ candidates.push(path.join(dir, `${base}_test.go`));
166
+ } else if (stack === 'rust') {
167
+ // Rust unit tests are inline; integration tests in tests/
168
+ if (root) {
169
+ candidates.push(path.join(root, 'tests', `${base}.rs`));
170
+ }
171
+ } else if (stack === 'java') {
172
+ // Maven/Gradle: src/main/... → src/test/...
173
+ const absNorm = abs.replace(/\\/g, '/');
174
+ const testPath = absNorm.replace('/src/main/', '/src/test/');
175
+ if (testPath !== absNorm) {
176
+ const noExt = testPath.slice(0, -ext.length);
177
+ candidates.push(`${noExt}Test${ext}`);
178
+ candidates.push(`${noExt}Spec${ext}`);
179
+ }
180
+ } else if (stack === 'dotnet') {
181
+ // .NET: look for <BaseName>Tests.cs / <BaseName>Test.cs in *.Tests/ or *.Test/ dirs at project root
182
+ candidates.push(path.join(dir, `${base}Tests.cs`));
183
+ candidates.push(path.join(dir, `${base}Test.cs`));
184
+ if (root) {
185
+ try {
186
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
187
+ if (!entry.isDirectory()) continue;
188
+ if (!/\.(Tests?|Specs?)$/i.test(entry.name)) continue;
189
+ const testProjectDir = path.join(root, entry.name);
190
+ candidates.push(path.join(testProjectDir, `${base}Tests.cs`));
191
+ candidates.push(path.join(testProjectDir, `${base}Test.cs`));
192
+ // One level deeper (e.g. *.Tests/Services/PaymentServiceTests.cs)
193
+ try {
194
+ for (const sub of fs.readdirSync(testProjectDir, { withFileTypes: true })) {
195
+ if (sub.isDirectory()) {
196
+ candidates.push(path.join(testProjectDir, sub.name, `${base}Tests.cs`));
197
+ candidates.push(path.join(testProjectDir, sub.name, `${base}Test.cs`));
198
+ }
199
+ }
200
+ } catch (_) {}
201
+ }
202
+ } catch (_) {}
203
+ }
204
+ }
205
+
206
+ // Deduplicate and return only existing files
207
+ const seen = new Set();
208
+ const result = [];
209
+ for (const c of candidates) {
210
+ if (!seen.has(c)) {
211
+ seen.add(c);
212
+ if (fs.existsSync(c)) result.push(c);
213
+ }
214
+ }
215
+ return result;
216
+ }
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // Import-based test file lookup (grep for require/import of source file)
220
+ // ---------------------------------------------------------------------------
221
+
222
+ /**
223
+ * Search the test directory for files that import/require the given source file.
224
+ *
225
+ * @param {string} sourceFile - absolute path to source file
226
+ * @param {string} stack
227
+ * @param {string} projectRoot
228
+ * @returns {string[]} absolute paths of test files that import sourceFile
229
+ */
230
+ function findTestFilesByImport(sourceFile, stack, projectRoot) {
231
+ if (!projectRoot || !fs.existsSync(projectRoot)) return [];
232
+
233
+ const abs = path.isAbsolute(sourceFile)
234
+ ? sourceFile
235
+ : path.resolve(projectRoot, sourceFile);
236
+
237
+ // Compute a relative require path fragment (without extension) to search for
238
+ const relNoExt = path
239
+ .relative(projectRoot, abs)
240
+ .replace(/\\/g, '/')
241
+ .replace(/\.[^.]+$/, '');
242
+
243
+ // Also compute the basename without extension for local requires
244
+ const baseNoExt = path.basename(abs, path.extname(abs));
245
+
246
+ const testDirs = [];
247
+ for (const d of ['test', 'tests', '__tests__', 'spec', 'specs']) {
248
+ const full = path.join(projectRoot, d);
249
+ if (fs.existsSync(full) && fs.statSync(full).isDirectory()) {
250
+ testDirs.push(full);
251
+ }
252
+ }
253
+ // For .NET, also scan *.Tests/ and *.Test/ project dirs at the root
254
+ if (stack === 'dotnet') {
255
+ try {
256
+ for (const entry of fs.readdirSync(projectRoot, { withFileTypes: true })) {
257
+ if (entry.isDirectory() && /\.(Tests?|Specs?)$/i.test(entry.name)) {
258
+ testDirs.push(path.join(projectRoot, entry.name));
259
+ }
260
+ }
261
+ } catch (_) {}
262
+ }
263
+
264
+ if (testDirs.length === 0) return [];
265
+
266
+ // Determine file extensions to look for based on stack
267
+ const exts = stack === 'python'
268
+ ? ['.py']
269
+ : stack === 'go'
270
+ ? ['.go']
271
+ : stack === 'rust'
272
+ ? ['.rs']
273
+ : stack === 'dotnet'
274
+ ? ['.cs']
275
+ : ['.js', '.ts', '.mjs', '.cjs'];
276
+
277
+ const testExtPredicate = (f) => {
278
+ const ext = path.extname(f);
279
+ return exts.includes(ext);
280
+ };
281
+
282
+ function walkDir(dir) {
283
+ let files = [];
284
+ try {
285
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
286
+ for (const e of entries) {
287
+ if (e.isDirectory()) {
288
+ files = files.concat(walkDir(path.join(dir, e.name)));
289
+ } else if (e.isFile() && testExtPredicate(e.name)) {
290
+ files.push(path.join(dir, e.name));
291
+ }
292
+ }
293
+ } catch (_) {}
294
+ return files;
295
+ }
296
+
297
+ const result = [];
298
+ for (const testDir of testDirs) {
299
+ const testFiles = walkDir(testDir);
300
+ for (const tf of testFiles) {
301
+ try {
302
+ const content = fs.readFileSync(tf, 'utf8');
303
+ // Primary: match full relative path (e.g. require('../lib/installer'))
304
+ // Fallback: match quoted basename (e.g. require('./installer') or import ... from 'installer')
305
+ // The quote-bounded regex prevents false positives from comments or unrelated strings.
306
+ const quotedBase = new RegExp("['\"]" + baseNoExt.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + "['\"]");
307
+ // For C#: match `using <ns>.<ClassName>` or direct class reference (e.g. `new PaymentService(`)
308
+ const csClassRef = stack === 'dotnet'
309
+ ? new RegExp('\\b' + baseNoExt.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b')
310
+ : null;
311
+ if (content.includes(relNoExt) || quotedBase.test(content) || (csClassRef && csClassRef.test(content))) {
312
+ result.push(tf);
313
+ }
314
+ } catch (_) {}
315
+ }
316
+ }
317
+
318
+ return result;
319
+ }
320
+
321
+ // ---------------------------------------------------------------------------
322
+ // Stack manifest filenames — used to detect a module root when walking up
323
+ // ---------------------------------------------------------------------------
324
+
325
+ const STACK_MANIFESTS = [
326
+ 'package.json', // node
327
+ 'go.mod', // go
328
+ 'Cargo.toml', // rust
329
+ 'pyproject.toml', // python
330
+ 'setup.py', // python
331
+ 'pytest.ini', // python
332
+ 'setup.cfg', // python
333
+ 'pom.xml', // java (maven)
334
+ 'build.gradle', // java (gradle)
335
+ 'build.gradle.kts', // java (gradle kotlin dsl)
336
+ 'global.json', // dotnet
337
+ 'Directory.Build.props', // dotnet
338
+ ];
339
+
340
+ /**
341
+ * Find the nearest enclosing "module root" for a given source file.
342
+ *
343
+ * Walks up the directory tree from `fileDir`, stopping when it finds a
344
+ * directory that contains any of the known stack manifests. Never goes above
345
+ * `projectRoot`. If no manifest is found, returns `projectRoot` as fallback
346
+ * (preserves single-package-repo behaviour).
347
+ *
348
+ * @param {string} fileDir - absolute path to the directory of the source file
349
+ * @param {string} projectRoot - absolute project/git root — walking stops here
350
+ * @returns {string} absolute path to the module root
351
+ */
352
+ function findModuleRoot(fileDir, projectRoot) {
353
+ const normalizedRoot = path.resolve(projectRoot);
354
+ let current = path.resolve(fileDir);
355
+
356
+ // Safety: if fileDir is somehow above projectRoot, just return projectRoot.
357
+ if (!current.startsWith(normalizedRoot)) return normalizedRoot;
358
+
359
+ while (true) {
360
+ const hasManifest = STACK_MANIFESTS.some((m) => fs.existsSync(path.join(current, m)));
361
+ if (hasManifest) return current;
362
+ // Stop at (and including) the project root — don't go above it.
363
+ if (current === normalizedRoot) return normalizedRoot;
364
+ const parent = path.dirname(current);
365
+ // Guard against path.dirname returning the same dir at filesystem root.
366
+ if (parent === current) return normalizedRoot;
367
+ current = parent;
368
+ }
369
+ }
370
+
371
+ // ---------------------------------------------------------------------------
372
+ // Test-file naming predicates per stack
373
+ // ---------------------------------------------------------------------------
374
+
375
+ /**
376
+ * Returns true if the given filename/path looks like a test file for the stack.
377
+ *
378
+ * @param {string} filePath - absolute or relative path
379
+ * @param {string} stack
380
+ * @param {string} [projectRoot] - used only for rust (files under tests/)
381
+ * @returns {boolean}
382
+ */
383
+ function isTestFile(filePath, stack, projectRoot) {
384
+ const base = path.basename(filePath);
385
+ if (stack === 'node') {
386
+ return /\.(test|spec)\.(js|ts|mjs|cjs)$/.test(base);
387
+ }
388
+ if (stack === 'python') {
389
+ return /^test_/.test(base) || /_test\.py$/.test(base);
390
+ }
391
+ if (stack === 'go') {
392
+ return base.endsWith('_test.go');
393
+ }
394
+ if (stack === 'rust') {
395
+ if (!projectRoot) return false;
396
+ const abs = path.isAbsolute(filePath) ? filePath : path.resolve(projectRoot, filePath);
397
+ // Integration tests live under <moduleRoot>/tests/
398
+ const normalized = abs.replace(/\\/g, '/');
399
+ return /\/tests\/[^/]+\.rs$/.test(normalized);
400
+ }
401
+ if (stack === 'java') {
402
+ return /Test\.|Spec\./.test(base);
403
+ }
404
+ if (stack === 'dotnet') {
405
+ return /Tests?\.cs$/.test(base);
406
+ }
407
+ return false;
408
+ }
409
+
410
+ // ---------------------------------------------------------------------------
411
+ // Build the scoped test command for a given (moduleRoot, detectedStack, testFiles)
412
+ // ---------------------------------------------------------------------------
413
+
414
+ /**
415
+ * Build the stack-specific test command string for a set of test files,
416
+ * ensuring the command runs from the correct module root.
417
+ *
418
+ * When `moduleRoot !== projectRoot`, the command is prefixed with a `cd`
419
+ * directive so it executes from the subpackage directory. Test file paths
420
+ * in the command are expressed relative to `moduleRoot`.
421
+ *
422
+ * @param {string[]} absTestFiles - absolute paths to test files
423
+ * @param {string} detectedStack
424
+ * @param {string} moduleRoot - absolute module root (may be a subdir of projectRoot)
425
+ * @param {string} projectRoot - absolute project/git root
426
+ * @param {string} baseline - the project baseline test command
427
+ * @returns {string}
428
+ */
429
+ function buildScopedCommand(absTestFiles, detectedStack, moduleRoot, projectRoot, baseline) {
430
+ const base = baseline || '';
431
+ const relTestFiles = absTestFiles.map((tf) =>
432
+ path.relative(moduleRoot, tf).replace(/\\/g, '/'),
433
+ );
434
+
435
+ let innerCommand;
436
+ if (detectedStack === 'node') {
437
+ if (base.includes('node --test') || base.includes('node:test')) {
438
+ innerCommand = `node --test --test-concurrency=1 ${relTestFiles.join(' ')}`;
439
+ } else if (base.includes('jest')) {
440
+ innerCommand = `${base} ${relTestFiles.join(' ')}`;
441
+ } else if (base.includes('vitest')) {
442
+ innerCommand = `vitest run ${relTestFiles.join(' ')}`;
443
+ } else {
444
+ innerCommand = `node --test --test-concurrency=1 ${relTestFiles.join(' ')}`;
445
+ }
446
+ } else if (detectedStack === 'python') {
447
+ innerCommand = `pytest ${relTestFiles.join(' ')}`;
448
+ } else if (detectedStack === 'go') {
449
+ const pkgs = new Set(relTestFiles.map((f) => `./${path.dirname(f)}`));
450
+ innerCommand = `go test ${Array.from(pkgs).join(' ')}`;
451
+ } else if (detectedStack === 'rust') {
452
+ innerCommand = `cargo test`;
453
+ } else if (detectedStack === 'java') {
454
+ innerCommand = `./gradlew test`;
455
+ } else if (detectedStack === 'dotnet') {
456
+ const filter = relTestFiles
457
+ .map((f) => `FullyQualifiedName~${path.basename(f, '.cs')}`)
458
+ .join('|');
459
+ innerCommand = `dotnet test --filter "${filter}"`;
460
+ } else {
461
+ innerCommand = base;
462
+ }
463
+
464
+ // Prefix with `cd <subdir>` when running from a sub-package
465
+ const normalizedRoot = path.resolve(projectRoot);
466
+ const normalizedModule = path.resolve(moduleRoot);
467
+ if (normalizedModule !== normalizedRoot) {
468
+ const relModule = path.relative(normalizedRoot, normalizedModule).replace(/\\/g, '/');
469
+ return `cd ${relModule} && ${innerCommand}`;
470
+ }
471
+
472
+ return innerCommand;
473
+ }
474
+
475
+ // ---------------------------------------------------------------------------
476
+ // Main testScope function
477
+ // ---------------------------------------------------------------------------
478
+
479
+ /**
480
+ * Resolve the scoped test command for a set of changed source files.
481
+ *
482
+ * Monorepo-aware: for each input file, the enclosing module root is detected
483
+ * independently so that files inside a sub-package resolve test conventions
484
+ * relative to that sub-package (e.g. `refacil-sdd-ai/test/`) rather than
485
+ * the git root.
486
+ *
487
+ * @param {object} opts
488
+ * @param {string[]} opts.files - source files changed (relative or absolute)
489
+ * @param {string} opts.stack - stack hint (optional; auto-detected if omitted)
490
+ * @param {string} opts.baseline - fallback test command (optional)
491
+ * @param {string} opts.projectRoot - project root (optional; uses cwd if omitted)
492
+ * @returns {{ testCommand: string, files: string[], fallback: boolean, fallbackReason: string|null }}
493
+ */
494
+ function testScope({ files = [], stack, baseline = '', projectRoot } = {}) {
495
+ const root = projectRoot || process.cwd();
496
+ const base = baseline || '';
497
+
498
+ // Fallback: empty files input
499
+ if (!files || files.length === 0) {
500
+ return {
501
+ testCommand: base,
502
+ files: [],
503
+ fallback: true,
504
+ fallbackReason: 'No source files provided — falling back to baseline.',
505
+ };
506
+ }
507
+
508
+ // Filter out planning-only files
509
+ const sourceFiles = files.filter((f) => !isPlanningFile(f));
510
+ if (sourceFiles.length === 0) {
511
+ return {
512
+ testCommand: base,
513
+ files: [],
514
+ fallback: true,
515
+ fallbackReason: 'All provided files are planning-only (markdown/SDD artifacts) — falling back to baseline.',
516
+ };
517
+ }
518
+
519
+ // Validate caller-supplied stack hint: if the hint is not in KNOWN_STACKS,
520
+ // treat it as 'unknown' to trigger fallback rather than silently building a
521
+ // wrong command.
522
+ const stackHintValid = stack && KNOWN_STACKS.includes(stack);
523
+ const stackHintUnknown = stack && !KNOWN_STACKS.includes(stack);
524
+ if (stackHintUnknown) {
525
+ return {
526
+ testCommand: base,
527
+ files: [],
528
+ fallback: true,
529
+ fallbackReason: 'Stack could not be determined — falling back to baseline.',
530
+ };
531
+ }
532
+
533
+ // Process each source file independently, resolving its module root so that
534
+ // subpackages inside a monorepo narrow correctly.
535
+ //
536
+ // We group results by moduleRoot so that multi-module changes can produce
537
+ // chained per-root commands. In the common (single-module) case this is
538
+ // transparent — the result is identical to the previous behaviour.
539
+
540
+ const byModule = new Map(); // moduleRoot -> { stack, testFiles: Set<absPath> }
541
+
542
+ for (const f of sourceFiles) {
543
+ const absSource = path.isAbsolute(f) ? f : path.resolve(root, f);
544
+
545
+ // Determine the enclosing module root for this file.
546
+ const fileDir = path.dirname(absSource);
547
+ const moduleRoot = findModuleRoot(fileDir, root);
548
+
549
+ // Detect stack for this module root (or use caller-supplied hint if valid).
550
+ const fileStack = stackHintValid
551
+ ? stack
552
+ : detectStack(moduleRoot);
553
+
554
+ if (fileStack === 'unknown') {
555
+ // This file's stack is unknown — skip it but continue with others.
556
+ continue;
557
+ }
558
+
559
+ if (!byModule.has(moduleRoot)) {
560
+ byModule.set(moduleRoot, { stack: fileStack, testFiles: new Set() });
561
+ }
562
+ const entry = byModule.get(moduleRoot);
563
+
564
+ // If this file is itself a test file, include it directly.
565
+ if (isTestFile(absSource, fileStack, root)) {
566
+ if (fs.existsSync(absSource)) {
567
+ entry.testFiles.add(absSource);
568
+ }
569
+ continue;
570
+ }
571
+
572
+ // Convention-based lookup relative to the module root.
573
+ const byConvention = findTestFileByConvention(absSource, fileStack, moduleRoot);
574
+ for (const tf of byConvention) entry.testFiles.add(tf);
575
+
576
+ // Import-based lookup relative to the module root.
577
+ const byImport = findTestFilesByImport(absSource, fileStack, moduleRoot);
578
+ for (const tf of byImport) entry.testFiles.add(tf);
579
+ }
580
+
581
+ // Remove modules with no test files found.
582
+ for (const [mod, entry] of byModule.entries()) {
583
+ if (entry.testFiles.size === 0) byModule.delete(mod);
584
+ }
585
+
586
+ // Fallback: no test files found at all (either stack unknown or no test files).
587
+ if (byModule.size === 0) {
588
+ // Distinguish "stack unknown" from "test files not found".
589
+ // If stack hint was valid but no test files → "not found".
590
+ // If stack was auto-detected and all files were 'unknown' stack → "stack unknown".
591
+ const allAbsSources = sourceFiles.map((f) =>
592
+ path.isAbsolute(f) ? f : path.resolve(root, f),
593
+ );
594
+ const anyKnownStack = allAbsSources.some((abs) => {
595
+ const moduleRoot = findModuleRoot(path.dirname(abs), root);
596
+ return detectStack(moduleRoot) !== 'unknown';
597
+ });
598
+
599
+ if (!anyKnownStack && !stackHintValid) {
600
+ return {
601
+ testCommand: base,
602
+ files: [],
603
+ fallback: true,
604
+ fallbackReason: 'Stack could not be determined — falling back to baseline.',
605
+ };
606
+ }
607
+
608
+ return {
609
+ testCommand: base,
610
+ files: [],
611
+ fallback: true,
612
+ fallbackReason: 'No test files found for the given source files — falling back to baseline.',
613
+ };
614
+ }
615
+
616
+ // Build per-module commands and collect relative file paths (relative to projectRoot
617
+ // for the returned `files` field — for observability; the command itself uses
618
+ // module-relative paths).
619
+ const commands = [];
620
+ const allRelFiles = [];
621
+
622
+ for (const [moduleRoot, entry] of byModule.entries()) {
623
+ const absTestFiles = Array.from(entry.testFiles);
624
+ const cmd = buildScopedCommand(absTestFiles, entry.stack, moduleRoot, root, base);
625
+ commands.push(cmd);
626
+
627
+ // For the `files` field, emit paths relative to projectRoot.
628
+ for (const tf of absTestFiles) {
629
+ allRelFiles.push(path.relative(root, tf).replace(/\\/g, '/'));
630
+ }
631
+ }
632
+
633
+ const testCommand = commands.join(' && ');
634
+
635
+ return {
636
+ testCommand,
637
+ files: allRelFiles,
638
+ fallback: false,
639
+ fallbackReason: null,
640
+ };
641
+ }
642
+
643
+ // ---------------------------------------------------------------------------
644
+ // affectedComponents — identify the distinct components touched by a file set
645
+ // ---------------------------------------------------------------------------
646
+
647
+ /**
648
+ * Given a list of changed files, return the distinct "components" (enclosing
649
+ * module roots) that contain real (non-planning) source files.
650
+ *
651
+ * A component is the nearest ancestor directory (up from each changed file,
652
+ * bounded by projectRoot) that contains a stack manifest. This is
653
+ * language-agnostic: the same manifest list used by findModuleRoot() is used
654
+ * here, so the result is correct for Node, Python, Go, Rust, Java, .NET, etc.
655
+ *
656
+ * Returns an array of objects in deterministic order (sorted by root):
657
+ * [{ root: '<relative-path-from-projectRoot>', stack: '<detected stack>' }, ...]
658
+ *
659
+ * Files whose module root IS projectRoot are returned with root '' (empty string)
660
+ * rather than '.' so callers can test `if (component.root)` for "is a subdir".
661
+ *
662
+ * Planning-only files (isPlanningFile) are excluded before computing components.
663
+ * Files with an unknown stack are included with stack 'unknown' so the caller
664
+ * can decide whether to skip or warn.
665
+ *
666
+ * @param {object} opts
667
+ * @param {string[]} opts.files - changed/new file paths (relative or absolute)
668
+ * @param {string} opts.projectRoot - project/git root (optional; uses cwd if omitted)
669
+ * @returns {Array<{ root: string, stack: string }>}
670
+ */
671
+ function affectedComponents({ files = [], projectRoot } = {}) {
672
+ const root = path.resolve(projectRoot || process.cwd());
673
+
674
+ if (!files || files.length === 0) return [];
675
+
676
+ // Filter planning-only files first.
677
+ const sourceFiles = files.filter((f) => !isPlanningFile(f));
678
+ if (sourceFiles.length === 0) return [];
679
+
680
+ // Collect distinct module roots (keyed by absolute path for dedup).
681
+ const seen = new Map(); // absModuleRoot -> stack
682
+
683
+ for (const f of sourceFiles) {
684
+ const abs = path.isAbsolute(f) ? f : path.resolve(root, f);
685
+ const fileDir = path.dirname(abs);
686
+ const absModuleRoot = findModuleRoot(fileDir, root);
687
+ if (!seen.has(absModuleRoot)) {
688
+ seen.set(absModuleRoot, detectStack(absModuleRoot));
689
+ }
690
+ }
691
+
692
+ // Convert to the public shape, sorting by relative root for determinism.
693
+ const components = [];
694
+ for (const [absModuleRoot, stack] of seen.entries()) {
695
+ const rel = path.relative(root, absModuleRoot).replace(/\\/g, '/');
696
+ // Files at the project root → root = '' (not '.')
697
+ components.push({ root: rel === '.' ? '' : rel, stack });
698
+ }
699
+
700
+ components.sort((a, b) => a.root.localeCompare(b.root));
701
+ return components;
702
+ }
703
+
704
+ module.exports = {
705
+ detectStack,
706
+ findTestFileByConvention,
707
+ findTestFilesByImport,
708
+ testScope,
709
+ isPlanningFile,
710
+ findModuleRoot,
711
+ isTestFile,
712
+ affectedComponents,
713
+ };