@angli/unit-test-tool 0.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 (151) hide show
  1. package/.claude/settings.local.json +14 -0
  2. package/README.md +232 -0
  3. package/dist/src/cli/commands/analyze.js +20 -0
  4. package/dist/src/cli/commands/guard.js +26 -0
  5. package/dist/src/cli/commands/init.js +39 -0
  6. package/dist/src/cli/commands/run.js +72 -0
  7. package/dist/src/cli/commands/schedule.js +29 -0
  8. package/dist/src/cli/commands/start.js +102 -0
  9. package/dist/src/cli/commands/status.js +27 -0
  10. package/dist/src/cli/commands/verify.js +15 -0
  11. package/dist/src/cli/context/create-context.js +101 -0
  12. package/dist/src/cli/index.js +23 -0
  13. package/dist/src/cli/utils/scan-dir.js +6 -0
  14. package/dist/src/core/analyzers/coverage-analyzer.js +8 -0
  15. package/dist/src/core/analyzers/dependency-complexity-analyzer.js +19 -0
  16. package/dist/src/core/analyzers/existing-test-analyzer.js +66 -0
  17. package/dist/src/core/analyzers/failure-history-analyzer.js +9 -0
  18. package/dist/src/core/analyzers/file-classifier-analyzer.js +24 -0
  19. package/dist/src/core/analyzers/index.js +37 -0
  20. package/dist/src/core/analyzers/llm-semantic-analyzer.js +3 -0
  21. package/dist/src/core/analyzers/path-priority-analyzer.js +34 -0
  22. package/dist/src/core/coverage/read-coverage-summary.js +183 -0
  23. package/dist/src/core/executor/claude-cli-executor.js +91 -0
  24. package/dist/src/core/middleware/loop-detection.js +6 -0
  25. package/dist/src/core/middleware/pre-completion-checklist.js +27 -0
  26. package/dist/src/core/middleware/silent-success-post-check.js +13 -0
  27. package/dist/src/core/planner/rank-candidates.js +3 -0
  28. package/dist/src/core/planner/rule-planner.js +41 -0
  29. package/dist/src/core/planner/score-candidate.js +49 -0
  30. package/dist/src/core/prompts/case-library.js +35 -0
  31. package/dist/src/core/prompts/edit-boundary-prompt.js +24 -0
  32. package/dist/src/core/prompts/retry-prompt.js +18 -0
  33. package/dist/src/core/prompts/system-prompt.js +27 -0
  34. package/dist/src/core/prompts/task-prompt.js +22 -0
  35. package/dist/src/core/reporter/index.js +48 -0
  36. package/dist/src/core/state-machine/index.js +12 -0
  37. package/dist/src/core/storage/defaults.js +16 -0
  38. package/dist/src/core/storage/event-store.js +16 -0
  39. package/dist/src/core/storage/lifecycle-store.js +18 -0
  40. package/dist/src/core/storage/report-store.js +16 -0
  41. package/dist/src/core/storage/state-store.js +11 -0
  42. package/dist/src/core/strategies/classify-failure.js +14 -0
  43. package/dist/src/core/strategies/switch-mock-strategy.js +9 -0
  44. package/dist/src/core/tools/analyze-baseline.js +54 -0
  45. package/dist/src/core/tools/guard.js +68 -0
  46. package/dist/src/core/tools/run-loop.js +108 -0
  47. package/dist/src/core/tools/run-with-claude-cli.js +645 -0
  48. package/dist/src/core/tools/verify-all.js +75 -0
  49. package/dist/src/core/worktrees/is-git-repo.js +10 -0
  50. package/dist/src/types/index.js +1 -0
  51. package/dist/src/types/logger.js +1 -0
  52. package/dist/src/utils/clock.js +10 -0
  53. package/dist/src/utils/command-runner.js +18 -0
  54. package/dist/src/utils/commands.js +28 -0
  55. package/dist/src/utils/duration.js +22 -0
  56. package/dist/src/utils/fs.js +53 -0
  57. package/dist/src/utils/logger.js +10 -0
  58. package/dist/src/utils/paths.js +21 -0
  59. package/dist/src/utils/process-lifecycle.js +74 -0
  60. package/dist/src/utils/prompts.js +20 -0
  61. package/dist/tests/core/create-context.test.js +41 -0
  62. package/dist/tests/core/default-state.test.js +10 -0
  63. package/dist/tests/core/failure-classification.test.js +7 -0
  64. package/dist/tests/core/loop-detection.test.js +7 -0
  65. package/dist/tests/core/paths.test.js +11 -0
  66. package/dist/tests/core/prompt-builders.test.js +33 -0
  67. package/dist/tests/core/score-candidate.test.js +28 -0
  68. package/dist/tests/core/state-machine.test.js +12 -0
  69. package/dist/tests/integration/status-report.test.js +21 -0
  70. package/docs/architecture.md +20 -0
  71. package/docs/demo.sh +266 -0
  72. package/docs/skill-integration.md +15 -0
  73. package/docs/state-machine.md +15 -0
  74. package/package.json +31 -0
  75. package/src/cli/commands/analyze.ts +22 -0
  76. package/src/cli/commands/guard.ts +28 -0
  77. package/src/cli/commands/init.ts +41 -0
  78. package/src/cli/commands/run.ts +79 -0
  79. package/src/cli/commands/schedule.ts +32 -0
  80. package/src/cli/commands/start.ts +111 -0
  81. package/src/cli/commands/status.ts +30 -0
  82. package/src/cli/commands/verify.ts +17 -0
  83. package/src/cli/context/create-context.ts +142 -0
  84. package/src/cli/index.ts +27 -0
  85. package/src/cli/utils/scan-dir.ts +5 -0
  86. package/src/core/analyzers/coverage-analyzer.ts +10 -0
  87. package/src/core/analyzers/dependency-complexity-analyzer.ts +25 -0
  88. package/src/core/analyzers/existing-test-analyzer.ts +76 -0
  89. package/src/core/analyzers/failure-history-analyzer.ts +12 -0
  90. package/src/core/analyzers/file-classifier-analyzer.ts +25 -0
  91. package/src/core/analyzers/index.ts +51 -0
  92. package/src/core/analyzers/llm-semantic-analyzer.ts +6 -0
  93. package/src/core/analyzers/path-priority-analyzer.ts +41 -0
  94. package/src/core/coverage/read-coverage-summary.ts +224 -0
  95. package/src/core/executor/claude-cli-executor.ts +94 -0
  96. package/src/core/middleware/loop-detection.ts +8 -0
  97. package/src/core/middleware/pre-completion-checklist.ts +32 -0
  98. package/src/core/middleware/silent-success-post-check.ts +16 -0
  99. package/src/core/planner/rank-candidates.ts +5 -0
  100. package/src/core/planner/rule-planner.ts +65 -0
  101. package/src/core/planner/score-candidate.ts +60 -0
  102. package/src/core/prompts/case-library.ts +36 -0
  103. package/src/core/prompts/edit-boundary-prompt.ts +26 -0
  104. package/src/core/prompts/retry-prompt.ts +22 -0
  105. package/src/core/prompts/system-prompt.ts +32 -0
  106. package/src/core/prompts/task-prompt.ts +26 -0
  107. package/src/core/reporter/index.ts +56 -0
  108. package/src/core/state-machine/index.ts +14 -0
  109. package/src/core/storage/defaults.ts +18 -0
  110. package/src/core/storage/event-store.ts +18 -0
  111. package/src/core/storage/lifecycle-store.ts +20 -0
  112. package/src/core/storage/report-store.ts +19 -0
  113. package/src/core/storage/state-store.ts +18 -0
  114. package/src/core/strategies/classify-failure.ts +9 -0
  115. package/src/core/strategies/switch-mock-strategy.ts +12 -0
  116. package/src/core/tools/analyze-baseline.ts +61 -0
  117. package/src/core/tools/guard.ts +89 -0
  118. package/src/core/tools/run-loop.ts +142 -0
  119. package/src/core/tools/run-with-claude-cli.ts +926 -0
  120. package/src/core/tools/verify-all.ts +83 -0
  121. package/src/core/worktrees/is-git-repo.ts +10 -0
  122. package/src/types/index.ts +291 -0
  123. package/src/types/logger.ts +6 -0
  124. package/src/utils/clock.ts +10 -0
  125. package/src/utils/command-runner.ts +24 -0
  126. package/src/utils/commands.ts +42 -0
  127. package/src/utils/duration.ts +20 -0
  128. package/src/utils/fs.ts +50 -0
  129. package/src/utils/logger.ts +12 -0
  130. package/src/utils/paths.ts +24 -0
  131. package/src/utils/process-lifecycle.ts +92 -0
  132. package/src/utils/prompts.ts +22 -0
  133. package/tests/core/create-context.test.ts +45 -0
  134. package/tests/core/default-state.test.ts +11 -0
  135. package/tests/core/failure-classification.test.ts +8 -0
  136. package/tests/core/loop-detection.test.ts +8 -0
  137. package/tests/core/paths.test.ts +13 -0
  138. package/tests/core/prompt-builders.test.ts +38 -0
  139. package/tests/core/score-candidate.test.ts +30 -0
  140. package/tests/core/state-machine.test.ts +14 -0
  141. package/tests/fixtures/simple-project/.openclaw-testbot/logs/events.jsonl +10 -0
  142. package/tests/fixtures/simple-project/.openclaw-testbot/plan.json +75 -0
  143. package/tests/fixtures/simple-project/.openclaw-testbot/reports/coverage-summary.json +9 -0
  144. package/tests/fixtures/simple-project/.openclaw-testbot/reports/final-report.json +14 -0
  145. package/tests/fixtures/simple-project/.openclaw-testbot/state.json +18 -0
  146. package/tests/fixtures/simple-project/coverage-summary.json +1 -0
  147. package/tests/fixtures/simple-project/package.json +8 -0
  148. package/tests/fixtures/simple-project/src/add.js +3 -0
  149. package/tests/fixtures/simple-project/test-runner.js +18 -0
  150. package/tests/integration/status-report.test.ts +24 -0
  151. package/tsconfig.json +18 -0
@@ -0,0 +1,926 @@
1
+ import { createHash } from 'node:crypto'
2
+ import fg from 'fast-glob'
3
+ import path from 'node:path'
4
+ import type { AppContext } from '../../cli/context/create-context.js'
5
+ import type { BaseToolResult, CandidateFile, TaskItem } from '../../types/index.js'
6
+ import { executeClaudeCli } from '../executor/claude-cli-executor.js'
7
+ import { verifyAll } from './verify-all.js'
8
+ import { rulePlanner } from '../planner/rule-planner.js'
9
+ import { buildFinalReport } from '../reporter/index.js'
10
+
11
+ export interface RunWithClaudeCliInput {
12
+ targetFile?: string
13
+ topN?: number
14
+ allowedTools?: string[]
15
+ permissionMode?: string
16
+ timeoutMs?: number
17
+ excludeTargets?: string[]
18
+ }
19
+
20
+ type TaskRunStatus = 'passed' | 'noop' | 'cli_failed' | 'verify_failed'
21
+
22
+ type TaskRunRecord = {
23
+ taskId: string
24
+ targetFile: string
25
+ attemptCount: number
26
+ promptPath?: string
27
+ changedTestFiles?: string[]
28
+ relatedTestFiles?: string[]
29
+ verifyResult?: 'passed' | 'failed'
30
+ status: TaskRunStatus
31
+ stdout?: string
32
+ stderr?: string
33
+ summary: string
34
+ }
35
+
36
+ export async function runWithClaudeCli(
37
+ ctx: AppContext,
38
+ input: RunWithClaudeCliInput = {}
39
+ ): Promise<BaseToolResult & { targetFile?: string }> {
40
+ ctx.logger.info('[run] step=select-target status=start')
41
+ const coverageSummary = await ctx.reportStore.loadCoverageSummary<{ coverageSnapshot?: Parameters<typeof pickRecommendedTask>[2] }>()
42
+ const target = input.targetFile
43
+ ? await buildTaskFromExplicitTarget(ctx, input.targetFile)
44
+ : await pickRecommendedTask(
45
+ ctx,
46
+ input.topN ?? 5,
47
+ coverageSummary?.coverageSnapshot,
48
+ input.excludeTargets
49
+ )
50
+
51
+ if (!target) {
52
+ ctx.logger.warn('[run] step=select-target status=empty')
53
+ return {
54
+ ok: false,
55
+ phase: 'WRITING_TESTS',
56
+ summary: '没有找到可执行的补测目标。请先运行 analyze 或通过 --file 指定目标文件。',
57
+ nextAction: 'analyze'
58
+ }
59
+ }
60
+
61
+ const beforeSnapshot = await captureTestSnapshot(ctx)
62
+ const firstAttemptTimestamp = ctx.clock.nowIso()
63
+ const promptText = buildExecutionPrompt(ctx, target.task)
64
+ const promptPath = await persistPrompt(
65
+ ctx,
66
+ target.task.taskId,
67
+ 1,
68
+ promptText,
69
+ target.task.targetFile,
70
+ firstAttemptTimestamp
71
+ )
72
+ const stdoutPath = buildTaskOutputPath(
73
+ ctx,
74
+ target.task.taskId,
75
+ 1,
76
+ 'stdout',
77
+ target.task.targetFile,
78
+ firstAttemptTimestamp
79
+ )
80
+ const stderrPath = buildTaskOutputPath(
81
+ ctx,
82
+ target.task.taskId,
83
+ 1,
84
+ 'stderr',
85
+ target.task.targetFile,
86
+ firstAttemptTimestamp
87
+ )
88
+ const runPromptPath = await appendRunPrompt(
89
+ ctx,
90
+ target.task.taskId,
91
+ 1,
92
+ target.task.targetFile,
93
+ firstAttemptTimestamp,
94
+ promptPath
95
+ )
96
+ await appendRunEvent(ctx, {
97
+ taskId: target.task.taskId,
98
+ targetFile: target.task.targetFile,
99
+ attemptCount: 1,
100
+ promptPath,
101
+ status: 'passed',
102
+ summary: 'prompt recorded'
103
+ }, 'cli_run_attempt', {
104
+ promptPreview: buildPreview(promptText),
105
+ stdoutPath,
106
+ stderrPath,
107
+ runPromptPath
108
+ })
109
+
110
+ ctx.logger.info(`[run] step=select-target status=ok taskId=${target.task.taskId} target=${target.task.targetFile}`)
111
+
112
+ await ctx.eventStore.append({
113
+ eventType: 'cli_run_started',
114
+ phase: 'WRITING_TESTS',
115
+ taskId: target.task.taskId,
116
+ message: `selected ${target.task.targetFile}`,
117
+ data: {
118
+ targetFile: target.task.targetFile,
119
+ recommendedTargets: target.recommendedTargets,
120
+ promptPath,
121
+ attemptCount: 1
122
+ },
123
+ timestamp: ctx.clock.nowIso()
124
+ })
125
+
126
+ await ctx.stateStore.save({
127
+ phase: 'WRITING_TESTS',
128
+ status: 'running',
129
+ currentTaskId: target.task.taskId,
130
+ coverageSnapshot: undefined,
131
+ totals: {
132
+ pending: 0,
133
+ running: 1,
134
+ passed: 0,
135
+ failed: 0,
136
+ blocked: 0
137
+ },
138
+ lastAction: 'run',
139
+ nextSuggestedAction: 'verify',
140
+ updatedAt: ctx.clock.nowIso()
141
+ })
142
+
143
+ ctx.logger.info(`[run] step=claude-cli status=start attempt=1 taskId=${target.task.taskId} target=${target.task.targetFile}`)
144
+ const firstRun = await executeClaudeCli({
145
+ cwd: ctx.projectPath,
146
+ prompt: promptText,
147
+ allowedTools: input.allowedTools,
148
+ permissionMode: input.permissionMode,
149
+ outputFormat: 'text',
150
+ timeoutMs: input.timeoutMs,
151
+ onProgress: (elapsedMs) => {
152
+ ctx.logger.info(`[run] step=claude-cli status=waiting attempt=1 taskId=${target.task.taskId} target=${target.task.targetFile} elapsedMs=${elapsedMs}`)
153
+ },
154
+ taskMeta: {
155
+ taskId: target.task.taskId,
156
+ targetFile: target.task.targetFile,
157
+ strategy: target.task.strategy,
158
+ attemptCount: 1
159
+ }
160
+ })
161
+
162
+ const firstStdoutPath = await persistOutput(
163
+ ctx,
164
+ target.task.taskId,
165
+ 1,
166
+ 'stdout',
167
+ target.task.targetFile,
168
+ firstAttemptTimestamp,
169
+ firstRun.stdout
170
+ )
171
+ const firstStderrPath = await persistOutput(
172
+ ctx,
173
+ target.task.taskId,
174
+ 1,
175
+ 'stderr',
176
+ target.task.targetFile,
177
+ firstAttemptTimestamp,
178
+ firstRun.stderr
179
+ )
180
+ if (firstRun.stdout?.trim()) {
181
+ ctx.logger.info(`[run] step=claude-cli status=stdout attempt=1 taskId=${target.task.taskId} target=${target.task.targetFile} stdout=${truncateLog(firstRun.stdout)}`)
182
+ }
183
+ if (firstRun.stderr?.trim()) {
184
+ ctx.logger.warn(`[run] step=claude-cli status=stderr attempt=1 taskId=${target.task.taskId} target=${target.task.targetFile} stderr=${truncateLog(firstRun.stderr)}`)
185
+ }
186
+
187
+ if (!firstRun.ok) {
188
+ const stderrText = firstRun.stderr?.trim() || ''
189
+ const stdoutText = firstRun.stdout?.trim() || ''
190
+ ctx.logger.error(`[run] step=claude-cli status=failed attempt=1 taskId=${target.task.taskId} target=${target.task.targetFile} category=${firstRun.failureCategory ?? 'unknown'}`)
191
+ if (stderrText) {
192
+ ctx.logger.error(`[run] step=claude-cli status=failed attempt=1 taskId=${target.task.taskId} target=${target.task.targetFile} stderr=${truncateLog(stderrText)}`)
193
+ }
194
+ if (stdoutText) {
195
+ ctx.logger.error(`[run] step=claude-cli status=failed attempt=1 taskId=${target.task.taskId} target=${target.task.targetFile} stdout=${truncateLog(stdoutText)}`)
196
+ }
197
+ const failureMessage = stderrText || stdoutText || 'unknown error'
198
+ await appendRunEvent(ctx, {
199
+ taskId: target.task.taskId,
200
+ targetFile: target.task.targetFile,
201
+ attemptCount: 1,
202
+ promptPath,
203
+ status: 'cli_failed',
204
+ stdout: stdoutText || undefined,
205
+ stderr: stderrText || undefined,
206
+ summary: `Claude CLI 执行失败:${firstRun.failureCategory ?? 'unknown'}。`
207
+ }, 'cli_run_failed', {
208
+ stdoutPath: firstStdoutPath,
209
+ stderrPath: firstStderrPath
210
+ })
211
+ await markRunFailed(ctx, target.task, failureMessage)
212
+ return {
213
+ ok: false,
214
+ phase: 'WRITING_TESTS',
215
+ summary: `Claude CLI 执行失败:${firstRun.failureCategory ?? 'unknown'}。`,
216
+ nextAction: 'manual-intervention',
217
+ targetFile: target.task.targetFile,
218
+ artifacts: {
219
+ stderr: stderrText || undefined,
220
+ stdout: stdoutText || undefined
221
+ }
222
+ }
223
+ }
224
+
225
+ ctx.logger.info(`[run] step=claude-cli status=done attempt=1 taskId=${target.task.taskId} target=${target.task.targetFile} elapsedMs=${firstRun.durationMs}`)
226
+
227
+ ctx.logger.info('[run] step=verify status=start attempt=1')
228
+ const verifyResult = await verifyAll(ctx)
229
+ if (verifyResult.ok) {
230
+ ctx.logger.info('[run] step=verify status=ok attempt=1')
231
+ const noOpResult = await validateTestChanges(ctx, target.task, beforeSnapshot)
232
+ if (!noOpResult.ok) {
233
+ await appendRunEvent(ctx, {
234
+ taskId: target.task.taskId,
235
+ targetFile: target.task.targetFile,
236
+ attemptCount: 1,
237
+ promptPath,
238
+ changedTestFiles: noOpResult.changedTestFiles,
239
+ relatedTestFiles: noOpResult.relatedTestFiles,
240
+ verifyResult: 'passed',
241
+ status: 'noop',
242
+ summary: noOpResult.summary
243
+ }, 'cli_run_failed', {
244
+ stdoutPath: firstStdoutPath,
245
+ stderrPath: firstStderrPath
246
+ })
247
+ await markRunFailed(ctx, target.task, noOpResult.summary)
248
+ return noOpResult
249
+ }
250
+ await appendRunEvent(ctx, {
251
+ taskId: target.task.taskId,
252
+ targetFile: target.task.targetFile,
253
+ attemptCount: 1,
254
+ promptPath,
255
+ changedTestFiles: noOpResult.changedTestFiles,
256
+ relatedTestFiles: noOpResult.relatedTestFiles,
257
+ verifyResult: 'passed',
258
+ status: 'passed',
259
+ summary: `Claude CLI 已完成 ${target.task.targetFile} 的补测并通过全量验证。`
260
+ }, 'cli_run_completed', {
261
+ stdoutPath: firstStdoutPath,
262
+ stderrPath: firstStderrPath
263
+ })
264
+ await markRunPassed(ctx, target.task)
265
+ return await buildSuccessResult(
266
+ ctx,
267
+ target.task.targetFile,
268
+ target.recommendedTargets,
269
+ noOpResult.changedTestFiles,
270
+ noOpResult.relatedTestFiles
271
+ )
272
+ }
273
+
274
+ ctx.logger.warn(`[run] step=verify status=failed attempt=1 taskId=${target.task.taskId} target=${target.task.targetFile}`)
275
+
276
+ ctx.logger.info(`[run] step=claude-cli status=start attempt=2 taskId=${target.task.taskId} target=${target.task.targetFile}`)
277
+ const retryAttemptTimestamp = ctx.clock.nowIso()
278
+ const retryPromptText = buildRetryExecutionPrompt(ctx, target.task, verifyResult.artifacts?.stderr)
279
+ const retryPromptPath = await persistPrompt(
280
+ ctx,
281
+ target.task.taskId,
282
+ 2,
283
+ retryPromptText,
284
+ target.task.targetFile,
285
+ retryAttemptTimestamp
286
+ )
287
+ const retryStdoutPath = buildTaskOutputPath(
288
+ ctx,
289
+ target.task.taskId,
290
+ 2,
291
+ 'stdout',
292
+ target.task.targetFile,
293
+ retryAttemptTimestamp
294
+ )
295
+ const retryStderrPath = buildTaskOutputPath(
296
+ ctx,
297
+ target.task.taskId,
298
+ 2,
299
+ 'stderr',
300
+ target.task.targetFile,
301
+ retryAttemptTimestamp
302
+ )
303
+ const retryRunPromptPath = await appendRunPrompt(
304
+ ctx,
305
+ target.task.taskId,
306
+ 2,
307
+ target.task.targetFile,
308
+ retryAttemptTimestamp,
309
+ retryPromptPath
310
+ )
311
+ await appendRunEvent(ctx, {
312
+ taskId: target.task.taskId,
313
+ targetFile: target.task.targetFile,
314
+ attemptCount: 2,
315
+ promptPath: retryPromptPath,
316
+ status: 'passed',
317
+ verifyResult: 'failed',
318
+ summary: 'retry prompt recorded'
319
+ }, 'cli_run_attempt', {
320
+ promptPreview: buildPreview(retryPromptText),
321
+ stdoutPath: retryStdoutPath,
322
+ stderrPath: retryStderrPath,
323
+ runPromptPath: retryRunPromptPath
324
+ })
325
+ const retryRun = await executeClaudeCli({
326
+ cwd: ctx.projectPath,
327
+ prompt: retryPromptText,
328
+ allowedTools: input.allowedTools,
329
+ permissionMode: input.permissionMode,
330
+ outputFormat: 'text',
331
+ timeoutMs: input.timeoutMs,
332
+ onProgress: (elapsedMs) => {
333
+ ctx.logger.info(`[run] step=claude-cli status=waiting attempt=2 taskId=${target.task.taskId} target=${target.task.targetFile} elapsedMs=${elapsedMs}`)
334
+ },
335
+ taskMeta: {
336
+ taskId: target.task.taskId,
337
+ targetFile: target.task.targetFile,
338
+ strategy: target.task.strategy,
339
+ attemptCount: 2
340
+ }
341
+ })
342
+
343
+ const retryStdoutSaved = await persistOutput(
344
+ ctx,
345
+ target.task.taskId,
346
+ 2,
347
+ 'stdout',
348
+ target.task.targetFile,
349
+ retryAttemptTimestamp,
350
+ retryRun.stdout
351
+ )
352
+ const retryStderrSaved = await persistOutput(
353
+ ctx,
354
+ target.task.taskId,
355
+ 2,
356
+ 'stderr',
357
+ target.task.targetFile,
358
+ retryAttemptTimestamp,
359
+ retryRun.stderr
360
+ )
361
+ if (retryRun.stdout?.trim()) {
362
+ ctx.logger.info(`[run] step=claude-cli status=stdout attempt=2 taskId=${target.task.taskId} target=${target.task.targetFile} stdout=${truncateLog(retryRun.stdout)}`)
363
+ }
364
+ if (retryRun.stderr?.trim()) {
365
+ ctx.logger.warn(`[run] step=claude-cli status=stderr attempt=2 taskId=${target.task.taskId} target=${target.task.targetFile} stderr=${truncateLog(retryRun.stderr)}`)
366
+ }
367
+
368
+ if (!retryRun.ok) {
369
+ const stderrText = retryRun.stderr?.trim() || ''
370
+ const stdoutText = retryRun.stdout?.trim() || ''
371
+ ctx.logger.error(`[run] step=claude-cli status=failed attempt=2 taskId=${target.task.taskId} target=${target.task.targetFile} category=${retryRun.failureCategory ?? 'unknown'}`)
372
+ if (stderrText) {
373
+ ctx.logger.error(`[run] step=claude-cli status=failed attempt=2 taskId=${target.task.taskId} target=${target.task.targetFile} stderr=${truncateLog(stderrText)}`)
374
+ }
375
+ if (stdoutText) {
376
+ ctx.logger.error(`[run] step=claude-cli status=failed attempt=2 taskId=${target.task.taskId} target=${target.task.targetFile} stdout=${truncateLog(stdoutText)}`)
377
+ }
378
+ const failureMessage = stderrText || stdoutText || 'unknown error'
379
+ await appendRunEvent(ctx, {
380
+ taskId: target.task.taskId,
381
+ targetFile: target.task.targetFile,
382
+ attemptCount: 2,
383
+ promptPath: retryPromptPath,
384
+ status: 'cli_failed',
385
+ verifyResult: 'failed',
386
+ stdout: stdoutText || undefined,
387
+ stderr: stderrText || undefined,
388
+ summary: `Claude CLI 重试失败:${retryRun.failureCategory ?? 'unknown'}。`
389
+ }, 'cli_run_failed', {
390
+ stdoutPath: retryStdoutSaved,
391
+ stderrPath: retryStderrSaved
392
+ })
393
+ await markRunFailed(ctx, target.task, failureMessage)
394
+ return {
395
+ ok: false,
396
+ phase: 'WRITING_TESTS',
397
+ summary: `Claude CLI 重试失败:${retryRun.failureCategory ?? 'unknown'}。`,
398
+ nextAction: 'manual-intervention',
399
+ targetFile: target.task.targetFile,
400
+ artifacts: {
401
+ stderr: stderrText || undefined,
402
+ stdout: stdoutText || undefined
403
+ }
404
+ }
405
+ }
406
+
407
+ ctx.logger.info(`[run] step=claude-cli status=done attempt=2 taskId=${target.task.taskId} target=${target.task.targetFile} elapsedMs=${retryRun.durationMs}`)
408
+
409
+ ctx.logger.info('[run] step=verify status=start attempt=2')
410
+ const retriedVerify = await verifyAll(ctx)
411
+ if (!retriedVerify.ok) {
412
+ ctx.logger.error(`[run] step=verify status=failed attempt=2 taskId=${target.task.taskId} target=${target.task.targetFile}`)
413
+ await appendRunEvent(ctx, {
414
+ taskId: target.task.taskId,
415
+ targetFile: target.task.targetFile,
416
+ attemptCount: 2,
417
+ promptPath: retryPromptPath,
418
+ status: 'verify_failed',
419
+ verifyResult: 'failed',
420
+ summary: 'Claude CLI 已重试一次,但全量验证仍失败。'
421
+ }, 'cli_run_failed', {
422
+ stdoutPath: retryStdoutSaved,
423
+ stderrPath: retryStderrSaved
424
+ })
425
+ await markRunFailed(ctx, target.task, retriedVerify.artifacts?.stderr)
426
+ return {
427
+ ok: false,
428
+ phase: 'VERIFYING_FULL',
429
+ summary: 'Claude CLI 已重试一次,但全量验证仍失败。',
430
+ nextAction: 'manual-intervention',
431
+ targetFile: target.task.targetFile,
432
+ artifacts: retriedVerify.artifacts
433
+ }
434
+ }
435
+
436
+ const noOpResult = await validateTestChanges(ctx, target.task, beforeSnapshot)
437
+ if (!noOpResult.ok) {
438
+ await appendRunEvent(ctx, {
439
+ taskId: target.task.taskId,
440
+ targetFile: target.task.targetFile,
441
+ attemptCount: 2,
442
+ promptPath: retryPromptPath,
443
+ changedTestFiles: noOpResult.changedTestFiles,
444
+ relatedTestFiles: noOpResult.relatedTestFiles,
445
+ verifyResult: 'passed',
446
+ status: 'noop',
447
+ summary: noOpResult.summary
448
+ }, 'cli_run_failed', {
449
+ stdoutPath: retryStdoutSaved,
450
+ stderrPath: retryStderrSaved
451
+ })
452
+ await markRunFailed(ctx, target.task, noOpResult.summary)
453
+ return noOpResult
454
+ }
455
+
456
+ await appendRunEvent(ctx, {
457
+ taskId: target.task.taskId,
458
+ targetFile: target.task.targetFile,
459
+ attemptCount: 2,
460
+ promptPath: retryPromptPath,
461
+ changedTestFiles: noOpResult.changedTestFiles,
462
+ relatedTestFiles: noOpResult.relatedTestFiles,
463
+ verifyResult: 'passed',
464
+ status: 'passed',
465
+ summary: `Claude CLI 已完成 ${target.task.targetFile} 的补测并通过全量验证。`
466
+ }, 'cli_run_completed', {
467
+ stdoutPath: retryStdoutSaved,
468
+ stderrPath: retryStderrSaved
469
+ })
470
+
471
+ await markRunPassed(ctx, target.task)
472
+ return await buildSuccessResult(
473
+ ctx,
474
+ target.task.targetFile,
475
+ target.recommendedTargets,
476
+ noOpResult.changedTestFiles,
477
+ noOpResult.relatedTestFiles
478
+ )
479
+ }
480
+
481
+ async function pickRecommendedTask(
482
+ ctx: AppContext,
483
+ topN: number,
484
+ coverageSnapshot?: Record<string, unknown>,
485
+ excludeTargets: string[] = []
486
+ ): Promise<{ task: TaskItem; recommendedTargets: string[] } | undefined> {
487
+ const testDirNames = await detectTestDirNames(ctx)
488
+ ctx.logger.info('[run] step=scan status=start')
489
+ ctx.logger.debug('[run] step=scan params', { include: ctx.config.include, exclude: ctx.config.exclude })
490
+ const files = await fg(ctx.config.include, {
491
+ cwd: ctx.projectPath,
492
+ ignore: ctx.config.exclude,
493
+ onlyFiles: true,
494
+ absolute: false
495
+ })
496
+
497
+ const candidates: CandidateFile[] = files
498
+ .filter((file) => !isTestFile(file) && !excludeTargets.includes(file))
499
+ .map((file) => ({ targetFile: file }))
500
+
501
+ ctx.logger.info(`[run] step=scan status=ok total=${files.length} candidates=${candidates.length} topN=${topN}`)
502
+
503
+ ctx.logger.info(`[run] step=rank status=start candidates=${candidates.length}`)
504
+ const ranked = await rulePlanner(ctx, candidates, coverageSnapshot)
505
+ const shortlist = ranked.slice(0, topN)
506
+ const selected = shortlist[0]
507
+ if (!selected) return undefined
508
+
509
+ return {
510
+ task: toTaskItem(selected, testDirNames),
511
+ recommendedTargets: shortlist.map((item) => item.targetFile)
512
+ }
513
+ }
514
+
515
+ async function buildTaskFromExplicitTarget(
516
+ ctx: AppContext,
517
+ targetFile: string
518
+ ): Promise<{ task: TaskItem; recommendedTargets: string[] } | undefined> {
519
+ const testDirNames = await detectTestDirNames(ctx)
520
+ const ranked = await rulePlanner(ctx, [{ targetFile }])
521
+ const selected = ranked[0]
522
+ if (!selected) return undefined
523
+
524
+ return {
525
+ task: toTaskItem(selected, testDirNames),
526
+ recommendedTargets: [selected.targetFile]
527
+ }
528
+ }
529
+
530
+ function toTaskItem(candidate: Awaited<ReturnType<typeof rulePlanner>>[number], testDirNames: string[]): TaskItem {
531
+ return {
532
+ taskId: candidate.taskId,
533
+ targetFile: candidate.targetFile,
534
+ testFiles: candidate.analyzerFacts.existingTest.testFilePaths,
535
+ score: candidate.baseScore,
536
+ reasons: candidate.reasons,
537
+ estimatedDifficulty: candidate.estimatedDifficulty,
538
+ strategy: candidate.suggestedStrategy ?? 'pure-logic',
539
+ status: 'pending',
540
+ attemptCount: 0,
541
+ testDirNames,
542
+ analyzerFacts: candidate.analyzerFacts
543
+ }
544
+ }
545
+
546
+ function buildExecutionPrompt(ctx: AppContext, task: TaskItem): string {
547
+ return [
548
+ ctx.prompts.buildSystemPrompt(),
549
+ ctx.prompts.buildTaskPrompt(task),
550
+ ctx.prompts.buildEditBoundaryPrompt(task),
551
+ '直接在当前工作区完成修改。完成后请给出简洁摘要,包括修改了哪些测试文件、验证结果和遗留问题。'
552
+ ].join('\n\n')
553
+ }
554
+
555
+ function buildRetryExecutionPrompt(ctx: AppContext, task: TaskItem, failure?: string | string[]): string {
556
+ const failureText = Array.isArray(failure) ? failure.join('\n') : failure ?? '未知验证失败'
557
+ return [
558
+ buildExecutionPrompt(ctx, task),
559
+ ctx.prompts.buildRetryPrompt(task, failureText),
560
+ '下面是上一轮全量验证失败日志,请只针对这些失败修复测试,不要扩展范围:',
561
+ failureText
562
+ ].join('\n\n')
563
+ }
564
+
565
+ function truncateLog(text: string, maxLen = 2000): string {
566
+ if (text.length <= maxLen) return JSON.stringify(text)
567
+ return JSON.stringify(`${text.slice(0, maxLen)}...<truncated>`)
568
+ }
569
+
570
+ async function persistPrompt(
571
+ ctx: AppContext,
572
+ taskId: string,
573
+ attemptCount: number,
574
+ prompt: string,
575
+ targetFile: string,
576
+ timestamp: string
577
+ ): Promise<string> {
578
+ await ctx.fileSystem.ensureDir(ctx.paths.promptsDir)
579
+ const fileName = buildRunArtifactName(taskId, attemptCount, targetFile, timestamp, 'prompt')
580
+ const promptPath = path.join(ctx.paths.promptsDir, fileName)
581
+ await ctx.fileSystem.writeText(promptPath, prompt)
582
+ return promptPath
583
+ }
584
+
585
+ async function appendRunPrompt(
586
+ ctx: AppContext,
587
+ taskId: string,
588
+ attemptCount: number,
589
+ targetFile: string,
590
+ timestamp: string,
591
+ promptPath: string
592
+ ): Promise<string> {
593
+ await ctx.fileSystem.ensureDir(ctx.paths.promptRunsDir)
594
+ const runPromptPath = path.join(ctx.paths.promptRunsDir, `run-${ctx.runId}.prompt.log`)
595
+ const attemptLabel = `a${attemptCount}`
596
+ const content = [
597
+ '===== TASK PROMPT START =====',
598
+ `taskId: ${taskId}`,
599
+ `attempt: ${attemptCount}`,
600
+ `attemptLabel: ${attemptLabel}`,
601
+ `targetFile: ${targetFile}`,
602
+ `timestamp: ${timestamp}`,
603
+ `promptPath: ${promptPath}`,
604
+ `promptFile: ${path.basename(promptPath)}`,
605
+ '===== TASK PROMPT END =====',
606
+ ''
607
+ ].join('\n')
608
+ await ctx.fileSystem.appendText(runPromptPath, content)
609
+ return runPromptPath
610
+ }
611
+
612
+ async function appendRunEvent(
613
+ ctx: AppContext,
614
+ record: TaskRunRecord,
615
+ eventType = 'cli_run_attempt',
616
+ extraData?: Record<string, unknown>
617
+ ): Promise<void> {
618
+ const targetBaseName = path.basename(record.targetFile, path.extname(record.targetFile))
619
+ const attemptLabel = `a${record.attemptCount}`
620
+ await ctx.eventStore.append({
621
+ eventType,
622
+ phase: record.status === 'verify_failed' ? 'VERIFYING_FULL' : record.status === 'passed' ? 'DONE' : 'WRITING_TESTS',
623
+ taskId: record.taskId,
624
+ message: record.summary,
625
+ data: {
626
+ runId: ctx.runId,
627
+ taskId: record.taskId,
628
+ attemptLabel,
629
+ targetBaseName,
630
+ targetFile: record.targetFile,
631
+ attemptCount: record.attemptCount,
632
+ promptPath: record.promptPath,
633
+ changedTestFiles: record.changedTestFiles,
634
+ relatedTestFiles: record.relatedTestFiles,
635
+ verifyResult: record.verifyResult,
636
+ status: record.status,
637
+ stdout: record.stdout ? truncateLog(record.stdout, 500) : undefined,
638
+ stderr: record.stderr ? truncateLog(record.stderr, 500) : undefined,
639
+ ...extraData
640
+ },
641
+ timestamp: ctx.clock.nowIso()
642
+ })
643
+ }
644
+
645
+ function buildPreview(prompt: string, maxLen = 240): string {
646
+ const compact = prompt.replace(/\s+/g, ' ').trim()
647
+ return compact.length <= maxLen ? compact : `${compact.slice(0, maxLen)}...`
648
+ }
649
+
650
+ function buildRunArtifactName(
651
+ taskId: string,
652
+ attemptCount: number,
653
+ targetFile: string,
654
+ timestamp: string,
655
+ suffix: 'prompt' | 'stdout' | 'stderr'
656
+ ): string {
657
+ const baseName = path.basename(targetFile, path.extname(targetFile))
658
+ const safeBaseName = baseName.replace(/[^a-zA-Z0-9_-]/g, '-').replace(/-+/g, '-')
659
+ const pathHash = hashContent(normalizePath(targetFile)).slice(0, 8)
660
+ const safeTimestamp = formatArtifactTimestamp(timestamp)
661
+ return `${taskId}-a${attemptCount}-${safeBaseName}-${pathHash}-${safeTimestamp}.${suffix}${suffix === 'prompt' ? '.txt' : '.log'}`
662
+ }
663
+
664
+ function formatArtifactTimestamp(timestamp: string): string {
665
+ return timestamp.replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z')
666
+ }
667
+
668
+ function buildTaskOutputPath(
669
+ ctx: AppContext,
670
+ taskId: string,
671
+ attemptCount: number,
672
+ stream: 'stdout' | 'stderr',
673
+ targetFile: string,
674
+ timestamp: string
675
+ ): string {
676
+ return path.join(ctx.paths.logsPath, buildRunArtifactName(taskId, attemptCount, targetFile, timestamp, stream))
677
+ }
678
+
679
+ async function persistOutput(
680
+ ctx: AppContext,
681
+ taskId: string,
682
+ attemptCount: number,
683
+ stream: 'stdout' | 'stderr',
684
+ targetFile: string,
685
+ timestamp: string,
686
+ content?: string
687
+ ): Promise<string | undefined> {
688
+ if (!content?.trim()) return undefined
689
+ await ctx.fileSystem.ensureDir(ctx.paths.logsPath)
690
+ const outputPath = buildTaskOutputPath(ctx, taskId, attemptCount, stream, targetFile, timestamp)
691
+ await ctx.fileSystem.writeText(outputPath, content)
692
+ return outputPath
693
+ }
694
+
695
+ type TestSnapshot = Record<string, string>
696
+
697
+ type TestChangeValidationResult = (BaseToolResult & { targetFile?: string }) & {
698
+ changedTestFiles: string[]
699
+ relatedTestFiles: string[]
700
+ }
701
+
702
+ async function captureTestSnapshot(ctx: AppContext): Promise<TestSnapshot> {
703
+ const files = await fg(buildTestSnapshotPatterns(ctx.config.testDirNames), {
704
+ cwd: ctx.projectPath,
705
+ ignore: ctx.config.exclude,
706
+ onlyFiles: true,
707
+ absolute: false
708
+ })
709
+
710
+ const snapshotEntries = await Promise.all(
711
+ files.map(async (file) => {
712
+ const content = await ctx.fileSystem.readText(path.join(ctx.projectPath, file))
713
+ return [file, hashContent(content ?? '')] as const
714
+ })
715
+ )
716
+
717
+ return Object.fromEntries(snapshotEntries)
718
+ }
719
+
720
+ async function validateTestChanges(
721
+ ctx: AppContext,
722
+ task: TaskItem,
723
+ beforeSnapshot: TestSnapshot
724
+ ): Promise<TestChangeValidationResult> {
725
+ const afterSnapshot = await captureTestSnapshot(ctx)
726
+ const changedTestFiles = Object.keys(afterSnapshot)
727
+ .filter((file) => beforeSnapshot[file] !== afterSnapshot[file])
728
+ .sort()
729
+
730
+ const relatedTestFiles = findRelatedTestFiles(task, changedTestFiles)
731
+ if (relatedTestFiles.length > 0) {
732
+ ctx.logger.info(
733
+ `[run] step=test-change status=ok taskId=${task.taskId} target=${task.targetFile} changed=${relatedTestFiles.join(',')}`
734
+ )
735
+ return {
736
+ ok: true,
737
+ phase: 'DONE',
738
+ summary: '检测到目标相关测试变更。',
739
+ targetFile: task.targetFile,
740
+ changedTestFiles,
741
+ relatedTestFiles
742
+ }
743
+ }
744
+
745
+ ctx.logger.warn(
746
+ `[run] step=test-change status=noop taskId=${task.taskId} target=${task.targetFile} changed=${changedTestFiles.join(',') || 'none'}`
747
+ )
748
+ return {
749
+ ok: false,
750
+ phase: 'WRITING_TESTS',
751
+ summary: `Claude CLI 未产出 ${task.targetFile} 的相关测试变更,本次视为 no-op。`,
752
+ nextAction: 'manual-intervention',
753
+ targetFile: task.targetFile,
754
+ artifacts: {
755
+ changedTestFiles: changedTestFiles.length > 0 ? changedTestFiles : undefined,
756
+ relatedTestFiles: relatedTestFiles.length > 0 ? relatedTestFiles : undefined
757
+ },
758
+ changedTestFiles,
759
+ relatedTestFiles
760
+ }
761
+ }
762
+
763
+ function findRelatedTestFiles(task: TaskItem, changedTestFiles: string[]): string[] {
764
+ if (changedTestFiles.length === 0) return []
765
+
766
+ const existingMatches = new Set(task.testFiles)
767
+ const baseName = path.basename(task.targetFile, path.extname(task.targetFile))
768
+ const targetDir = path.dirname(task.targetFile)
769
+ const normalizedTargetDir = normalizePath(targetDir)
770
+ const moduleDirName = path.basename(targetDir)
771
+
772
+ return changedTestFiles.filter((file) => {
773
+ if (existingMatches.has(file)) return true
774
+
775
+ const normalizedFile = normalizePath(file)
776
+ const fileName = path.basename(file)
777
+ const fileBaseName = path.basename(fileName, path.extname(fileName))
778
+ if (fileBaseName === baseName || fileBaseName === `${baseName}.test` || fileBaseName === `${baseName}.spec`) {
779
+ return true
780
+ }
781
+
782
+ if (normalizedFile.startsWith(`${normalizedTargetDir}/`)) return true
783
+ if (normalizedFile.includes(`/${moduleDirName}/`) && isTestFile(file)) return true
784
+
785
+ return false
786
+ })
787
+ }
788
+
789
+ function buildTestSnapshotPatterns(testDirNames?: string[]): string[] {
790
+ const configured = testDirNames?.filter(Boolean) ?? ['__test__', '__tests__']
791
+ return [
792
+ '**/*.test.*',
793
+ '**/*.spec.*',
794
+ ...configured.map((dir) => `**/${dir}/**/*.*`)
795
+ ]
796
+ }
797
+
798
+ function hashContent(content: string): string {
799
+ return createHash('sha1').update(content).digest('hex')
800
+ }
801
+
802
+ function normalizePath(filePath: string): string {
803
+ return filePath.replace(/\\/g, '/')
804
+ }
805
+
806
+ async function markRunFailed(
807
+ ctx: AppContext,
808
+ task: TaskItem,
809
+ error?: string | string[],
810
+ loopSummary?: {
811
+ iterationCount: number
812
+ completedTargets: number
813
+ blockedTargets: number
814
+ noOpTargets: number
815
+ lastCompletedTarget?: string
816
+ }
817
+ ) {
818
+ const message = Array.isArray(error) ? error.join('\n') : error ?? 'unknown error'
819
+ await ctx.stateStore.save({
820
+ phase: 'BLOCKED',
821
+ status: 'failed',
822
+ currentTaskId: task.taskId,
823
+ lastError: message,
824
+ loopSummary,
825
+ totals: {
826
+ pending: 0,
827
+ running: 0,
828
+ passed: 0,
829
+ failed: 1,
830
+ blocked: 0
831
+ },
832
+ lastAction: 'run',
833
+ nextSuggestedAction: 'manual-intervention',
834
+ updatedAt: ctx.clock.nowIso()
835
+ })
836
+
837
+ await ctx.eventStore.append({
838
+ eventType: 'cli_run_failed',
839
+ phase: 'BLOCKED',
840
+ taskId: task.taskId,
841
+ message,
842
+ timestamp: ctx.clock.nowIso()
843
+ })
844
+ }
845
+
846
+ async function markRunPassed(
847
+ ctx: AppContext,
848
+ task: TaskItem,
849
+ loopSummary?: {
850
+ iterationCount: number
851
+ completedTargets: number
852
+ blockedTargets: number
853
+ noOpTargets: number
854
+ lastCompletedTarget?: string
855
+ }
856
+ ) {
857
+ await ctx.stateStore.save({
858
+ phase: 'DONE',
859
+ status: 'done',
860
+ currentTaskId: task.taskId,
861
+ loopSummary,
862
+ totals: {
863
+ pending: 0,
864
+ running: 0,
865
+ passed: 1,
866
+ failed: 0,
867
+ blocked: 0
868
+ },
869
+ lastAction: 'run',
870
+ nextSuggestedAction: 'report',
871
+ updatedAt: ctx.clock.nowIso()
872
+ })
873
+
874
+ await ctx.eventStore.append({
875
+ eventType: 'cli_run_completed',
876
+ phase: 'DONE',
877
+ taskId: task.taskId,
878
+ message: `completed ${task.targetFile}`,
879
+ timestamp: ctx.clock.nowIso()
880
+ })
881
+ }
882
+
883
+ async function buildSuccessResult(
884
+ ctx: AppContext,
885
+ targetFile: string,
886
+ recommendedTargets: string[],
887
+ changedTestFiles?: string[],
888
+ relatedTestFiles?: string[]
889
+ ) {
890
+ const report = await buildFinalReport(ctx)
891
+ await ctx.reportStore.saveFinalReport(report)
892
+ return {
893
+ ok: true,
894
+ phase: 'DONE' as const,
895
+ summary: `Claude CLI 已完成 ${targetFile} 的补测并通过全量验证。`,
896
+ nextAction: 'report',
897
+ targetFile,
898
+ artifacts: {
899
+ finalReportPath: ctx.paths.finalReportPath,
900
+ recommendedTargets,
901
+ changedTestFiles,
902
+ relatedTestFiles
903
+ }
904
+ }
905
+ }
906
+
907
+ function isTestFile(file: string): boolean {
908
+ return /(\.test|\.spec)\.[jt]sx?$/.test(file) || file.includes('__test__/') || file.includes('__tests__/')
909
+ }
910
+
911
+ async function detectTestDirNames(ctx: AppContext): Promise<string[]> {
912
+ if (ctx.cache.detectedTestDirNames) return ctx.cache.detectedTestDirNames
913
+
914
+ const configured = ctx.config.testDirNames?.filter(Boolean) ?? ['__test__', '__tests__']
915
+ const jestConfigPath = path.join(ctx.projectPath, 'jest.config.js')
916
+ const jestConfig = await ctx.fileSystem.readText(jestConfigPath)
917
+ if (!jestConfig) {
918
+ ctx.cache.detectedTestDirNames = configured
919
+ return configured
920
+ }
921
+
922
+ const detected = configured.filter((dir) => jestConfig.includes(dir))
923
+ const result = detected.length > 0 ? detected : configured
924
+ ctx.cache.detectedTestDirNames = result
925
+ return result
926
+ }