@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.
- package/.claude/settings.local.json +14 -0
- package/README.md +232 -0
- package/dist/src/cli/commands/analyze.js +20 -0
- package/dist/src/cli/commands/guard.js +26 -0
- package/dist/src/cli/commands/init.js +39 -0
- package/dist/src/cli/commands/run.js +72 -0
- package/dist/src/cli/commands/schedule.js +29 -0
- package/dist/src/cli/commands/start.js +102 -0
- package/dist/src/cli/commands/status.js +27 -0
- package/dist/src/cli/commands/verify.js +15 -0
- package/dist/src/cli/context/create-context.js +101 -0
- package/dist/src/cli/index.js +23 -0
- package/dist/src/cli/utils/scan-dir.js +6 -0
- package/dist/src/core/analyzers/coverage-analyzer.js +8 -0
- package/dist/src/core/analyzers/dependency-complexity-analyzer.js +19 -0
- package/dist/src/core/analyzers/existing-test-analyzer.js +66 -0
- package/dist/src/core/analyzers/failure-history-analyzer.js +9 -0
- package/dist/src/core/analyzers/file-classifier-analyzer.js +24 -0
- package/dist/src/core/analyzers/index.js +37 -0
- package/dist/src/core/analyzers/llm-semantic-analyzer.js +3 -0
- package/dist/src/core/analyzers/path-priority-analyzer.js +34 -0
- package/dist/src/core/coverage/read-coverage-summary.js +183 -0
- package/dist/src/core/executor/claude-cli-executor.js +91 -0
- package/dist/src/core/middleware/loop-detection.js +6 -0
- package/dist/src/core/middleware/pre-completion-checklist.js +27 -0
- package/dist/src/core/middleware/silent-success-post-check.js +13 -0
- package/dist/src/core/planner/rank-candidates.js +3 -0
- package/dist/src/core/planner/rule-planner.js +41 -0
- package/dist/src/core/planner/score-candidate.js +49 -0
- package/dist/src/core/prompts/case-library.js +35 -0
- package/dist/src/core/prompts/edit-boundary-prompt.js +24 -0
- package/dist/src/core/prompts/retry-prompt.js +18 -0
- package/dist/src/core/prompts/system-prompt.js +27 -0
- package/dist/src/core/prompts/task-prompt.js +22 -0
- package/dist/src/core/reporter/index.js +48 -0
- package/dist/src/core/state-machine/index.js +12 -0
- package/dist/src/core/storage/defaults.js +16 -0
- package/dist/src/core/storage/event-store.js +16 -0
- package/dist/src/core/storage/lifecycle-store.js +18 -0
- package/dist/src/core/storage/report-store.js +16 -0
- package/dist/src/core/storage/state-store.js +11 -0
- package/dist/src/core/strategies/classify-failure.js +14 -0
- package/dist/src/core/strategies/switch-mock-strategy.js +9 -0
- package/dist/src/core/tools/analyze-baseline.js +54 -0
- package/dist/src/core/tools/guard.js +68 -0
- package/dist/src/core/tools/run-loop.js +108 -0
- package/dist/src/core/tools/run-with-claude-cli.js +645 -0
- package/dist/src/core/tools/verify-all.js +75 -0
- package/dist/src/core/worktrees/is-git-repo.js +10 -0
- package/dist/src/types/index.js +1 -0
- package/dist/src/types/logger.js +1 -0
- package/dist/src/utils/clock.js +10 -0
- package/dist/src/utils/command-runner.js +18 -0
- package/dist/src/utils/commands.js +28 -0
- package/dist/src/utils/duration.js +22 -0
- package/dist/src/utils/fs.js +53 -0
- package/dist/src/utils/logger.js +10 -0
- package/dist/src/utils/paths.js +21 -0
- package/dist/src/utils/process-lifecycle.js +74 -0
- package/dist/src/utils/prompts.js +20 -0
- package/dist/tests/core/create-context.test.js +41 -0
- package/dist/tests/core/default-state.test.js +10 -0
- package/dist/tests/core/failure-classification.test.js +7 -0
- package/dist/tests/core/loop-detection.test.js +7 -0
- package/dist/tests/core/paths.test.js +11 -0
- package/dist/tests/core/prompt-builders.test.js +33 -0
- package/dist/tests/core/score-candidate.test.js +28 -0
- package/dist/tests/core/state-machine.test.js +12 -0
- package/dist/tests/integration/status-report.test.js +21 -0
- package/docs/architecture.md +20 -0
- package/docs/demo.sh +266 -0
- package/docs/skill-integration.md +15 -0
- package/docs/state-machine.md +15 -0
- package/package.json +31 -0
- package/src/cli/commands/analyze.ts +22 -0
- package/src/cli/commands/guard.ts +28 -0
- package/src/cli/commands/init.ts +41 -0
- package/src/cli/commands/run.ts +79 -0
- package/src/cli/commands/schedule.ts +32 -0
- package/src/cli/commands/start.ts +111 -0
- package/src/cli/commands/status.ts +30 -0
- package/src/cli/commands/verify.ts +17 -0
- package/src/cli/context/create-context.ts +142 -0
- package/src/cli/index.ts +27 -0
- package/src/cli/utils/scan-dir.ts +5 -0
- package/src/core/analyzers/coverage-analyzer.ts +10 -0
- package/src/core/analyzers/dependency-complexity-analyzer.ts +25 -0
- package/src/core/analyzers/existing-test-analyzer.ts +76 -0
- package/src/core/analyzers/failure-history-analyzer.ts +12 -0
- package/src/core/analyzers/file-classifier-analyzer.ts +25 -0
- package/src/core/analyzers/index.ts +51 -0
- package/src/core/analyzers/llm-semantic-analyzer.ts +6 -0
- package/src/core/analyzers/path-priority-analyzer.ts +41 -0
- package/src/core/coverage/read-coverage-summary.ts +224 -0
- package/src/core/executor/claude-cli-executor.ts +94 -0
- package/src/core/middleware/loop-detection.ts +8 -0
- package/src/core/middleware/pre-completion-checklist.ts +32 -0
- package/src/core/middleware/silent-success-post-check.ts +16 -0
- package/src/core/planner/rank-candidates.ts +5 -0
- package/src/core/planner/rule-planner.ts +65 -0
- package/src/core/planner/score-candidate.ts +60 -0
- package/src/core/prompts/case-library.ts +36 -0
- package/src/core/prompts/edit-boundary-prompt.ts +26 -0
- package/src/core/prompts/retry-prompt.ts +22 -0
- package/src/core/prompts/system-prompt.ts +32 -0
- package/src/core/prompts/task-prompt.ts +26 -0
- package/src/core/reporter/index.ts +56 -0
- package/src/core/state-machine/index.ts +14 -0
- package/src/core/storage/defaults.ts +18 -0
- package/src/core/storage/event-store.ts +18 -0
- package/src/core/storage/lifecycle-store.ts +20 -0
- package/src/core/storage/report-store.ts +19 -0
- package/src/core/storage/state-store.ts +18 -0
- package/src/core/strategies/classify-failure.ts +9 -0
- package/src/core/strategies/switch-mock-strategy.ts +12 -0
- package/src/core/tools/analyze-baseline.ts +61 -0
- package/src/core/tools/guard.ts +89 -0
- package/src/core/tools/run-loop.ts +142 -0
- package/src/core/tools/run-with-claude-cli.ts +926 -0
- package/src/core/tools/verify-all.ts +83 -0
- package/src/core/worktrees/is-git-repo.ts +10 -0
- package/src/types/index.ts +291 -0
- package/src/types/logger.ts +6 -0
- package/src/utils/clock.ts +10 -0
- package/src/utils/command-runner.ts +24 -0
- package/src/utils/commands.ts +42 -0
- package/src/utils/duration.ts +20 -0
- package/src/utils/fs.ts +50 -0
- package/src/utils/logger.ts +12 -0
- package/src/utils/paths.ts +24 -0
- package/src/utils/process-lifecycle.ts +92 -0
- package/src/utils/prompts.ts +22 -0
- package/tests/core/create-context.test.ts +45 -0
- package/tests/core/default-state.test.ts +11 -0
- package/tests/core/failure-classification.test.ts +8 -0
- package/tests/core/loop-detection.test.ts +8 -0
- package/tests/core/paths.test.ts +13 -0
- package/tests/core/prompt-builders.test.ts +38 -0
- package/tests/core/score-candidate.test.ts +30 -0
- package/tests/core/state-machine.test.ts +14 -0
- package/tests/fixtures/simple-project/.openclaw-testbot/logs/events.jsonl +10 -0
- package/tests/fixtures/simple-project/.openclaw-testbot/plan.json +75 -0
- package/tests/fixtures/simple-project/.openclaw-testbot/reports/coverage-summary.json +9 -0
- package/tests/fixtures/simple-project/.openclaw-testbot/reports/final-report.json +14 -0
- package/tests/fixtures/simple-project/.openclaw-testbot/state.json +18 -0
- package/tests/fixtures/simple-project/coverage-summary.json +1 -0
- package/tests/fixtures/simple-project/package.json +8 -0
- package/tests/fixtures/simple-project/src/add.js +3 -0
- package/tests/fixtures/simple-project/test-runner.js +18 -0
- package/tests/integration/status-report.test.ts +24 -0
- 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,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,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
|
+
}
|
package/src/utils/fs.ts
ADDED
|
@@ -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
|
+
}
|