@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,83 @@
1
+ import path from 'node:path'
2
+ import type { AppContext } from '../../cli/context/create-context.js'
3
+ import type { VerifyAllResult } from '../../types/index.js'
4
+ import { readCoverageSummary } from '../coverage/read-coverage-summary.js'
5
+
6
+ export async function verifyAll(ctx: AppContext): Promise<VerifyAllResult> {
7
+ const testDirs = await detectTestDirNames(ctx)
8
+ ctx.logger.info(`[verify] step=test-dirs value=${testDirs.join(',')}`)
9
+ ctx.logger.info(`[verify] step=run status=start cmd=${ctx.commands.verifyFull}`)
10
+ const verifyResult = await ctx.commandRunner.run(ctx.commands.verifyFull)
11
+
12
+ if (verifyResult.exitCode !== 0) {
13
+ if (verifyResult.stdout) console.log(`[verify] stdout:\n${verifyResult.stdout}`)
14
+ if (verifyResult.stderr) console.log(`[verify] stderr:\n${verifyResult.stderr}`)
15
+ }
16
+
17
+ const ok = verifyResult.exitCode === 0
18
+ const coverageSnapshot = ok ? await readCoverageSummary(ctx) : undefined
19
+ const coverageValue = coverageSnapshot?.lines
20
+ const coveragePassed = ok ? coverageValue !== undefined && coverageValue >= ctx.coverageTarget : false
21
+
22
+ ctx.logger.info(`[verify] step=run status=done ok=${ok} exit=${verifyResult.exitCode} coverage=${coverageValue ?? 'unknown'} target=${ctx.coverageTarget}`)
23
+
24
+ const state = await ctx.stateStore.load()
25
+ const nextState = {
26
+ ...state,
27
+ phase: 'VERIFYING_FULL' as const,
28
+ status: (ok ? 'running' : 'failed') as 'running' | 'failed',
29
+ lastAction: 'verify-all',
30
+ nextSuggestedAction: ok ? 'report' : 'manual-intervention',
31
+ updatedAt: ctx.clock.nowIso()
32
+ }
33
+ await ctx.stateStore.save(nextState)
34
+
35
+ if (ok) {
36
+ await ctx.eventStore.append({
37
+ eventType: 'verify_full_passed',
38
+ phase: 'VERIFYING_FULL',
39
+ message: `verify all passed, coverage=${coverageValue ?? 'unknown'} target=${ctx.coverageTarget}`,
40
+ data: { coverageSnapshot, coverageTarget: ctx.coverageTarget },
41
+ timestamp: ctx.clock.nowIso()
42
+ })
43
+ } else {
44
+ await ctx.eventStore.append({
45
+ eventType: 'verify_full_failed',
46
+ phase: 'VERIFYING_FULL',
47
+ message: 'verify all failed',
48
+ data: { stderr: verifyResult.stderr },
49
+ timestamp: ctx.clock.nowIso()
50
+ })
51
+ }
52
+
53
+ return {
54
+ ok,
55
+ phase: 'VERIFYING_FULL',
56
+ summary: ok ? '全量验证通过。' : '全量验证失败。',
57
+ nextAction: ok ? 'report' : 'manual-intervention',
58
+ artifacts: {
59
+ stdout: verifyResult.stdout,
60
+ stderr: verifyResult.stderr
61
+ },
62
+ coveragePassed,
63
+ testPassed: ok,
64
+ coverageSnapshot
65
+ }
66
+ }
67
+
68
+ async function detectTestDirNames(ctx: AppContext): Promise<string[]> {
69
+ if (ctx.cache.detectedTestDirNames) return ctx.cache.detectedTestDirNames
70
+
71
+ const configured = ctx.config.testDirNames?.filter(Boolean) ?? ['__test__', '__tests__']
72
+ const jestConfigPath = path.join(ctx.projectPath, 'jest.config.js')
73
+ const jestConfig = await ctx.fileSystem.readText(jestConfigPath)
74
+ if (!jestConfig) {
75
+ ctx.cache.detectedTestDirNames = configured
76
+ return configured
77
+ }
78
+
79
+ const detected = configured.filter((dir) => jestConfig.includes(dir))
80
+ const result = detected.length > 0 ? detected : configured
81
+ ctx.cache.detectedTestDirNames = result
82
+ return result
83
+ }
@@ -0,0 +1,10 @@
1
+ import { execa } from 'execa'
2
+
3
+ export async function isGitRepo(projectPath: string): Promise<boolean> {
4
+ try {
5
+ await execa('git', ['rev-parse', '--is-inside-work-tree'], { cwd: projectPath })
6
+ return true
7
+ } catch {
8
+ return false
9
+ }
10
+ }
@@ -0,0 +1,291 @@
1
+ export type WorkflowPhase = 'INIT' | 'ANALYZE_BASELINE' | 'WRITING_TESTS' | 'VERIFYING_FULL' | 'BLOCKED' | 'DONE'
2
+
3
+ export interface AppRuntimeCache {
4
+ detectedTestDirNames?: string[]
5
+ testFilesByBaseName?: Record<string, string[]>
6
+ attemptedTargets?: string[]
7
+ }
8
+
9
+ export interface RuntimePaths {
10
+ runtimeDir: string
11
+ configPath: string
12
+ statePath: string
13
+ logsPath: string
14
+ promptsDir: string
15
+ promptRunsDir: string
16
+ eventsPath: string
17
+ reportsDir: string
18
+ coverageSummaryPath: string
19
+ finalReportPath: string
20
+ lifecyclePath: string
21
+ }
22
+
23
+ export interface RuntimeLimits {
24
+ maxIterationsPerRun: number
25
+ maxRetryPerTask: number
26
+ loopThreshold: number
27
+ concurrency: number
28
+ llmTopN: number
29
+ llmAdjustRange: number
30
+ }
31
+
32
+ export interface CommandSet {
33
+ analyzeBaseline: string
34
+ verifyFull: string
35
+ incrementalTest(targetFile?: string): string
36
+ lint: string
37
+ typecheck: string
38
+ format?: string
39
+ }
40
+
41
+ export interface PromptBuilderSet {
42
+ buildSystemPrompt(): string
43
+ buildTaskPrompt(task: TaskItem): string
44
+ buildRetryPrompt(task: TaskItem, failure: string): string
45
+ buildEditBoundaryPrompt(task: TaskItem): string
46
+ }
47
+
48
+ export interface PromptOverridesConfig {
49
+ systemAppend?: string
50
+ taskAppend?: string
51
+ retryAppend?: string
52
+ }
53
+
54
+ export interface TestBotConfig {
55
+ projectPath: string
56
+ coverageTarget: number
57
+ testFramework?: 'jest' | 'vitest' | 'unknown'
58
+ include: string[]
59
+ exclude: string[]
60
+ mode: 'serial' | 'parallel'
61
+ concurrency?: number
62
+ maxIterationsPerRun?: number
63
+ maxRetryPerTask?: number
64
+ loopThreshold?: number
65
+ allowSourceEdit?: boolean
66
+ testDirNames?: string[]
67
+ commandOverrides?: Partial<Record<'analyzeBaseline' | 'verifyFull' | 'lint' | 'typecheck' | 'format' | 'incrementalTest', string>>
68
+ priorityPaths?: string[]
69
+ deferredPaths?: string[]
70
+ excludePaths?: string[]
71
+ llmTopN?: number
72
+ llmAdjustRange?: number
73
+ logLevel?: string
74
+ promptOverrides?: PromptOverridesConfig
75
+ }
76
+
77
+ export type LifecycleStatus = 'running' | 'exited' | 'crashed' | 'recovering'
78
+
79
+ export interface LifecycleFile {
80
+ runId: string
81
+ command: 'start' | 'run'
82
+ pid: number
83
+ argv: string[]
84
+ startedAt: string
85
+ lastHeartbeat: string
86
+ status: LifecycleStatus
87
+ exitCode?: number
88
+ restartCount: number
89
+ lastError?: string
90
+ updatedAt: string
91
+ }
92
+
93
+ export interface CoverageSnapshot {
94
+ lines?: number
95
+ statements?: number
96
+ functions?: number
97
+ branches?: number
98
+ rawSummary?: string
99
+ sourceFileCount?: number
100
+ testFileCount?: number
101
+ lowCoverageFiles?: string[]
102
+ coverageGap?: number
103
+ fileEntries?: Record<string, {
104
+ lines?: number
105
+ statements?: number
106
+ functions?: number
107
+ branches?: number
108
+ }>
109
+ }
110
+
111
+ export interface CandidateFile {
112
+ targetFile: string
113
+ matchedPriority?: 'priority' | 'normal' | 'deferred' | 'excluded'
114
+ }
115
+
116
+ export interface AnalyzerFacts {
117
+ targetFile: string
118
+ coverage: {
119
+ lineRate?: number
120
+ branchRate?: number
121
+ coverageGap?: number
122
+ }
123
+ pathPriority: {
124
+ label: 'priority' | 'normal' | 'deferred' | 'excluded'
125
+ score: number
126
+ matchedPath?: string
127
+ }
128
+ fileClassification: {
129
+ ruleCategory?: string
130
+ semanticCategory?: string
131
+ uiSignal?: boolean
132
+ domSignal?: boolean
133
+ frameworkSignal?: boolean
134
+ }
135
+ dependencyComplexity: {
136
+ importCount: number
137
+ externalDependencyCount: number
138
+ complexityLevel: 'low' | 'medium' | 'high'
139
+ hiddenCouplingRisk?: 'low' | 'medium' | 'high'
140
+ }
141
+ existingTest: {
142
+ hasExistingTest: boolean
143
+ testFilePaths: string[]
144
+ partialCoverageLikely?: boolean
145
+ }
146
+ failureHistory: {
147
+ previousAttemptCount: number
148
+ loopDetected: boolean
149
+ blockedBefore: boolean
150
+ failurePenaltyScore: number
151
+ lastFailureCategory?: string
152
+ }
153
+ llmSemantic?: {
154
+ priorityGroup?: 'P0' | 'P1' | 'P2'
155
+ strategySuggestion?: string
156
+ businessReason?: string
157
+ deferReason?: string
158
+ riskTag?: 'low' | 'medium' | 'high'
159
+ testabilityScoreAdjustment?: number
160
+ }
161
+ analyzerWarnings?: string[]
162
+ }
163
+
164
+ export interface TaskItem {
165
+ taskId: string
166
+ targetFile: string
167
+ testFiles: string[]
168
+ score: number
169
+ reasons: string[]
170
+ estimatedDifficulty: 'low' | 'medium' | 'high'
171
+ strategy: string
172
+ status: 'pending' | 'running' | 'passed' | 'failed' | 'blocked'
173
+ attemptCount: number
174
+ failureCategory?: string
175
+ lastError?: string
176
+ priorityGroup?: 'P0' | 'P1' | 'P2'
177
+ businessReason?: string
178
+ deferReason?: string
179
+ riskTag?: 'low' | 'medium' | 'high'
180
+ testDirNames?: string[]
181
+ analyzerFacts?: AnalyzerFacts
182
+ }
183
+
184
+ export interface RuleRankedCandidate {
185
+ taskId: string
186
+ targetFile: string
187
+ baseScore: number
188
+ pathPriority: 'priority' | 'normal' | 'deferred'
189
+ estimatedDifficulty: 'low' | 'medium' | 'high'
190
+ reasons: string[]
191
+ suggestedStrategy?: string
192
+ analyzerFacts: AnalyzerFacts
193
+ }
194
+
195
+ export interface StateFile {
196
+ phase: WorkflowPhase
197
+ status: 'idle' | 'running' | 'blocked' | 'done' | 'failed'
198
+ currentTaskId?: string
199
+ lastError?: string
200
+ coverageSnapshot?: CoverageSnapshot
201
+ totals: {
202
+ pending: number
203
+ running: number
204
+ passed: number
205
+ failed: number
206
+ blocked: number
207
+ }
208
+ loopSummary?: {
209
+ iterationCount: number
210
+ completedTargets: number
211
+ blockedTargets: number
212
+ noOpTargets: number
213
+ lastCompletedTarget?: string
214
+ }
215
+ lastAction?: string
216
+ nextSuggestedAction?: string
217
+ updatedAt: string
218
+ }
219
+
220
+ export interface EventRecord {
221
+ eventType: string
222
+ phase: WorkflowPhase
223
+ taskId?: string
224
+ message: string
225
+ data?: Record<string, unknown>
226
+ timestamp: string
227
+ }
228
+
229
+ export interface BaseToolResult {
230
+ ok: boolean
231
+ phase: WorkflowPhase
232
+ summary: string
233
+ nextAction?: string
234
+ artifacts?: Record<string, string | string[] | undefined>
235
+ errors?: Array<{ code: string; message: string }>
236
+ }
237
+
238
+ export interface AnalyzeBaselineResult extends BaseToolResult {
239
+ coverageSnapshot?: CoverageSnapshot
240
+ testFramework?: TestBotConfig['testFramework']
241
+ }
242
+
243
+ export interface VerifyAllResult extends BaseToolResult {
244
+ coveragePassed: boolean
245
+ testPassed: boolean
246
+ coverageSnapshot?: CoverageSnapshot
247
+ }
248
+
249
+ export interface ClaudeCliExecutionInput {
250
+ cwd: string
251
+ prompt: string
252
+ systemPrompt?: string
253
+ appendSystemPrompt?: string
254
+ allowedTools?: string[]
255
+ permissionMode?: string
256
+ outputFormat?: 'text' | 'json' | 'stream-json'
257
+ timeoutMs?: number
258
+ onProgress?: (elapsedMs: number) => void
259
+ taskMeta: {
260
+ taskId: string
261
+ targetFile: string
262
+ strategy: string
263
+ attemptCount: number
264
+ }
265
+ }
266
+
267
+ export interface ClaudeCliExecutionResult {
268
+ ok: boolean
269
+ exitCode: number | null
270
+ stdout: string
271
+ stderr: string
272
+ durationMs: number
273
+ failureCategory?:
274
+ | 'CLI_NOT_FOUND'
275
+ | 'CLI_EXIT_NON_ZERO'
276
+ | 'CLI_TIMEOUT'
277
+ | 'OUTPUT_PARSE_FAILED'
278
+ | 'PERMISSION_DENIED'
279
+ | 'RUNTIME_ENV_ERROR'
280
+ taskMeta: ClaudeCliExecutionInput['taskMeta']
281
+ }
282
+
283
+ export interface FinalReport {
284
+ generatedAt: string
285
+ totals: StateFile['totals']
286
+ blockedTasks: string[]
287
+ failedTasks: string[]
288
+ coveragePassed: boolean
289
+ loopSummary?: StateFile['loopSummary']
290
+ suggestedNextAction?: string
291
+ }
@@ -0,0 +1,6 @@
1
+ export interface Logger {
2
+ info(message: string, meta?: Record<string, unknown>): void
3
+ warn(message: string, meta?: Record<string, unknown>): void
4
+ error(message: string, meta?: Record<string, unknown>): void
5
+ debug(message: string, meta?: Record<string, unknown>): void
6
+ }
@@ -0,0 +1,10 @@
1
+ export function createClock() {
2
+ return {
3
+ now(): Date {
4
+ return new Date()
5
+ },
6
+ nowIso(): string {
7
+ return new Date().toISOString()
8
+ }
9
+ }
10
+ }
@@ -0,0 +1,24 @@
1
+ import { execa } from 'execa'
2
+
3
+ export interface CommandRunnerOptions {
4
+ cwd: string
5
+ }
6
+
7
+ export function createCommandRunner(options: CommandRunnerOptions) {
8
+ return {
9
+ async run(command: string, timeoutMs = 120000): Promise<{ stdout: string; stderr: string; exitCode: number }> {
10
+ const result = await execa(command, {
11
+ cwd: options.cwd,
12
+ shell: true,
13
+ reject: false,
14
+ timeout: timeoutMs
15
+ })
16
+
17
+ return {
18
+ stdout: result.stdout,
19
+ stderr: result.stderr,
20
+ exitCode: result.exitCode ?? 0
21
+ }
22
+ }
23
+ }
24
+ }
@@ -0,0 +1,42 @@
1
+ import type { CommandSet, TestBotConfig } from '../types/index.js'
2
+
3
+ export interface ScriptMap {
4
+ test?: string
5
+ lint?: string
6
+ typecheck?: string
7
+ 'test:coverage'?: string
8
+ }
9
+
10
+ export function buildCommandSet(config: TestBotConfig, scripts: ScriptMap = {}): CommandSet {
11
+ const verifyFromScript = scripts.test ? 'npm test' : undefined
12
+ const coverageScript = scripts['test:coverage'] ? 'npm run test:coverage' : undefined
13
+ const lintScript = scripts.lint ? 'npm run lint' : undefined
14
+ const typecheckScript = scripts.typecheck ? 'npm run typecheck' : undefined
15
+
16
+ return {
17
+ analyzeBaseline:
18
+ config.commandOverrides?.analyzeBaseline ??
19
+ coverageScript ??
20
+ 'npm test -- --coverage --runInBand',
21
+ verifyFull:
22
+ config.commandOverrides?.verifyFull ??
23
+ verifyFromScript ??
24
+ 'npm test -- --runInBand',
25
+ incrementalTest: (targetFile?: string) => {
26
+ const base =
27
+ config.commandOverrides?.incrementalTest ??
28
+ verifyFromScript ??
29
+ 'npm test -- --runInBand'
30
+ if (!targetFile) {
31
+ return base
32
+ }
33
+ return `${base} ${JSON.stringify(targetFile)}`
34
+ },
35
+ lint: config.commandOverrides?.lint ?? lintScript ?? 'npm run lint',
36
+ typecheck:
37
+ config.commandOverrides?.typecheck ??
38
+ typecheckScript ??
39
+ 'npx tsc -p tsconfig.json --noEmit',
40
+ format: config.commandOverrides?.format ?? 'npm run lint'
41
+ }
42
+ }
@@ -0,0 +1,20 @@
1
+ export function parseDuration(input: string): number | undefined {
2
+ const trimmed = input.trim()
3
+ if (!trimmed) return undefined
4
+ const match = trimmed.match(/^(\d+)(ms|s|m|h)?$/)
5
+ if (!match) return undefined
6
+ const value = Number(match[1])
7
+ const unit = match[2] ?? 'ms'
8
+ switch (unit) {
9
+ case 'ms':
10
+ return value
11
+ case 's':
12
+ return value * 1000
13
+ case 'm':
14
+ return value * 60 * 1000
15
+ case 'h':
16
+ return value * 60 * 60 * 1000
17
+ default:
18
+ return undefined
19
+ }
20
+ }
@@ -0,0 +1,50 @@
1
+ import { promises as fs } from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ export function createFileSystem() {
5
+ return {
6
+ async ensureDir(dirPath: string): Promise<void> {
7
+ await fs.mkdir(dirPath, { recursive: true })
8
+ },
9
+ async fileExists(filePath: string): Promise<boolean> {
10
+ try {
11
+ await fs.access(filePath)
12
+ return true
13
+ } catch {
14
+ return false
15
+ }
16
+ },
17
+ async readJson<T>(filePath: string): Promise<T | undefined> {
18
+ const exists = await this.fileExists(filePath)
19
+ if (!exists) return undefined
20
+ const content = await fs.readFile(filePath, 'utf8')
21
+ return JSON.parse(content) as T
22
+ },
23
+ async writeJson(filePath: string, value: unknown): Promise<void> {
24
+ await fs.mkdir(path.dirname(filePath), { recursive: true })
25
+ await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
26
+ },
27
+ async appendLine(filePath: string, line: string): Promise<void> {
28
+ await fs.mkdir(path.dirname(filePath), { recursive: true })
29
+ await fs.appendFile(filePath, `${line}\n`, 'utf8')
30
+ },
31
+ async appendText(filePath: string, content: string): Promise<void> {
32
+ await fs.mkdir(path.dirname(filePath), { recursive: true })
33
+ await fs.appendFile(filePath, content, 'utf8')
34
+ },
35
+ async readText(filePath: string): Promise<string | undefined> {
36
+ const exists = await this.fileExists(filePath)
37
+ if (!exists) return undefined
38
+ return fs.readFile(filePath, 'utf8')
39
+ },
40
+ async writeText(filePath: string, content: string): Promise<void> {
41
+ await fs.mkdir(path.dirname(filePath), { recursive: true })
42
+ await fs.writeFile(filePath, content, 'utf8')
43
+ },
44
+ async readDir(dirPath: string): Promise<string[]> {
45
+ const exists = await this.fileExists(dirPath)
46
+ if (!exists) return []
47
+ return fs.readdir(dirPath)
48
+ }
49
+ }
50
+ }
@@ -0,0 +1,12 @@
1
+ import type { Logger } from '../types/logger.js'
2
+ import pino from 'pino'
3
+
4
+ export function createLogger(level: string): Logger {
5
+ const logger = pino({ level })
6
+ return {
7
+ info: (message, meta) => logger.info(meta ?? {}, message),
8
+ warn: (message, meta) => logger.warn(meta ?? {}, message),
9
+ error: (message, meta) => logger.error(meta ?? {}, message),
10
+ debug: (message, meta) => logger.debug(meta ?? {}, message)
11
+ }
12
+ }
@@ -0,0 +1,24 @@
1
+ import path from 'node:path'
2
+ import type { RuntimePaths } from '../types/index.js'
3
+
4
+ export function buildPaths(projectPath: string): RuntimePaths {
5
+ const runtimeDir = path.join(projectPath, '.unit_test_tool_workspace')
6
+ const logsPath = path.join(runtimeDir, 'logs')
7
+ const promptsDir = path.join(runtimeDir, 'prompts')
8
+ const promptRunsDir = path.join(runtimeDir, 'prompt-runs')
9
+ const reportsDir = path.join(runtimeDir, 'reports')
10
+
11
+ return {
12
+ runtimeDir,
13
+ configPath: path.join(runtimeDir, 'config.json'),
14
+ statePath: path.join(runtimeDir, 'state.json'),
15
+ logsPath,
16
+ promptsDir,
17
+ promptRunsDir,
18
+ eventsPath: path.join(logsPath, 'events.jsonl'),
19
+ reportsDir,
20
+ coverageSummaryPath: path.join(reportsDir, 'coverage-summary.json'),
21
+ finalReportPath: path.join(reportsDir, 'final-report.json'),
22
+ lifecyclePath: path.join(runtimeDir, 'lifecycle.json')
23
+ }
24
+ }
@@ -0,0 +1,92 @@
1
+ import type { AppContext } from '../cli/context/create-context.js'
2
+ import type { LifecycleFile } from '../types/index.js'
3
+
4
+ const DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000
5
+
6
+ export type LifecycleOptions = {
7
+ command: 'start' | 'run'
8
+ argv: string[]
9
+ heartbeatIntervalMs?: number
10
+ }
11
+
12
+ export async function startLifecycle(ctx: AppContext, options: LifecycleOptions): Promise<{ stop: () => Promise<void> }> {
13
+ const startedAt = ctx.clock.nowIso()
14
+ const lifecycle: LifecycleFile = {
15
+ runId: ctx.runId,
16
+ command: options.command,
17
+ pid: process.pid,
18
+ argv: options.argv,
19
+ startedAt,
20
+ lastHeartbeat: startedAt,
21
+ status: 'running',
22
+ restartCount: 0,
23
+ updatedAt: startedAt
24
+ }
25
+
26
+ await ctx.lifecycleStore.save(lifecycle)
27
+ await ctx.eventStore.append({
28
+ eventType: 'lifecycle_started',
29
+ phase: 'INIT',
30
+ message: `${options.command} started`,
31
+ data: { pid: process.pid, argv: options.argv },
32
+ timestamp: startedAt
33
+ })
34
+
35
+ const interval = setInterval(async () => {
36
+ const now = ctx.clock.nowIso()
37
+ await ctx.lifecycleStore.patch({
38
+ lastHeartbeat: now,
39
+ updatedAt: now
40
+ })
41
+ }, options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS)
42
+
43
+ const stop = async (status: LifecycleFile['status'], exitCode?: number, lastError?: string) => {
44
+ clearInterval(interval)
45
+ const now = ctx.clock.nowIso()
46
+ await ctx.lifecycleStore.patch({
47
+ status,
48
+ exitCode,
49
+ lastError,
50
+ updatedAt: now
51
+ })
52
+ await ctx.eventStore.append({
53
+ eventType: 'lifecycle_stopped',
54
+ phase: 'DONE',
55
+ message: `${options.command} ${status}`,
56
+ data: { exitCode, lastError },
57
+ timestamp: now
58
+ })
59
+ }
60
+
61
+ const handleExit = async (code?: number) => {
62
+ await stop(code === 0 ? 'exited' : 'crashed', code)
63
+ }
64
+
65
+ const handleSignal = async (signal: NodeJS.Signals) => {
66
+ await stop('exited', undefined, `signal:${signal}`)
67
+ process.exit(0)
68
+ }
69
+
70
+ const handleError = async (error: unknown) => {
71
+ const message = error instanceof Error ? error.message : String(error)
72
+ await stop('crashed', 1, message)
73
+ process.exit(1)
74
+ }
75
+
76
+ process.on('exit', handleExit)
77
+ process.on('SIGINT', handleSignal)
78
+ process.on('SIGTERM', handleSignal)
79
+ process.on('uncaughtException', handleError)
80
+ process.on('unhandledRejection', handleError)
81
+
82
+ return {
83
+ stop: async () => {
84
+ process.off('exit', handleExit)
85
+ process.off('SIGINT', handleSignal)
86
+ process.off('SIGTERM', handleSignal)
87
+ process.off('uncaughtException', handleError)
88
+ process.off('unhandledRejection', handleError)
89
+ await stop('exited', 0)
90
+ }
91
+ }
92
+ }
@@ -0,0 +1,22 @@
1
+ import type { PromptBuilderSet, TaskItem, PromptOverridesConfig } from '../types/index.js'
2
+ import { buildSystemPrompt } from '../core/prompts/system-prompt.js'
3
+ import { buildTaskPrompt } from '../core/prompts/task-prompt.js'
4
+ import { buildRetryPrompt } from '../core/prompts/retry-prompt.js'
5
+ import { buildEditBoundaryPrompt } from '../core/prompts/edit-boundary-prompt.js'
6
+
7
+ export function buildPromptBuilders(overrides?: PromptOverridesConfig): PromptBuilderSet {
8
+ return {
9
+ buildSystemPrompt() {
10
+ return buildSystemPrompt(overrides)
11
+ },
12
+ buildTaskPrompt(task: TaskItem) {
13
+ return buildTaskPrompt(task, overrides)
14
+ },
15
+ buildRetryPrompt(task: TaskItem, failure: string) {
16
+ return buildRetryPrompt(task, failure, overrides)
17
+ },
18
+ buildEditBoundaryPrompt(task: TaskItem) {
19
+ return buildEditBoundaryPrompt(task)
20
+ }
21
+ }
22
+ }