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.
- package/NOTICE.md +46 -0
- package/README.md +209 -42
- package/agents/auditor.md +46 -0
- package/agents/debugger.md +41 -1
- package/agents/implementer.md +76 -10
- package/agents/investigator.md +36 -0
- package/agents/proposer.md +46 -2
- package/agents/tester.md +45 -8
- package/agents/validator.md +67 -13
- package/bin/cli.js +428 -83
- package/bin/postinstall.js +20 -0
- package/lib/bus/broker.js +121 -3
- package/lib/bus/spawn.js +189 -121
- package/lib/check-review.js +102 -0
- package/lib/codegraph-telemetry.js +135 -0
- package/lib/codegraph.js +273 -0
- package/lib/commands/autopilot.js +120 -0
- package/lib/commands/bus.js +29 -36
- package/lib/commands/compact.js +185 -46
- package/lib/commands/read-spec.js +352 -0
- package/lib/commands/sdd.js +429 -44
- package/lib/compact-guidance.js +122 -77
- package/lib/config.js +136 -0
- package/lib/global-paths.js +56 -20
- package/lib/hooks.js +32 -4
- package/lib/ide-detection.js +1 -1
- package/lib/ignore-files.js +5 -1
- package/lib/installer.js +202 -19
- package/lib/kapso.js +241 -0
- package/lib/methodology-migration-pending.js +13 -0
- package/lib/open-browser.js +32 -0
- package/lib/opencode-migrate.js +148 -0
- package/lib/opencode-plugin/index.js +84 -104
- package/lib/opencode-plugin/rules.js +236 -0
- package/lib/project-root.js +154 -0
- package/lib/repo-ide-sync.js +5 -0
- package/lib/spec-reader/lang.js +72 -0
- package/lib/spec-reader/md-parser.js +299 -0
- package/lib/spec-reader/session.js +139 -0
- package/lib/spec-reader/ui/app.js +685 -0
- package/lib/spec-reader/ui/index.html +59 -0
- package/lib/spec-reader/ui/mixed-lang.js +200 -0
- package/lib/spec-reader/ui/model-cache.js +117 -0
- package/lib/spec-reader/ui/style.css +294 -0
- package/lib/spec-reader/ui/supertonic-helper.js +565 -0
- package/lib/spec-sync.js +258 -0
- package/lib/test-scope.js +713 -0
- package/lib/testing-policy-sync.js +14 -2
- package/package.json +6 -3
- package/skills/apply/SKILL.md +39 -64
- package/skills/archive/SKILL.md +74 -48
- package/skills/ask/SKILL.md +43 -8
- package/skills/autopilot/SKILL.md +476 -0
- package/skills/bug/SKILL.md +52 -53
- package/skills/explore/SKILL.md +48 -1
- package/skills/guide/SKILL.md +31 -13
- package/skills/inbox/SKILL.md +9 -0
- package/skills/join/SKILL.md +1 -1
- package/skills/prereqs/BUS-CROSS-REPO.md +33 -16
- package/skills/prereqs/METHODOLOGY-CONTRACT.md +96 -17
- package/skills/prereqs/SKILL.md +1 -1
- package/skills/propose/SKILL.md +74 -19
- package/skills/read-spec/SKILL.md +76 -0
- package/skills/reply/SKILL.md +42 -9
- package/skills/review/SKILL.md +63 -25
- package/skills/review/checklist.md +2 -2
- package/skills/say/SKILL.md +40 -4
- package/skills/setup/SKILL.md +59 -5
- package/skills/setup/troubleshooting.md +11 -3
- package/skills/stats/SKILL.md +157 -0
- package/skills/test/SKILL.md +35 -10
- package/skills/up-code/SKILL.md +20 -13
- package/skills/update/SKILL.md +32 -1
- package/skills/verify/SKILL.md +78 -41
- package/templates/compact-guidance.md +10 -0
- 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
|
+
};
|