@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,94 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ClaudeCliExecutionInput,
|
|
3
|
+
ClaudeCliExecutionResult
|
|
4
|
+
} from '../../types/index.js'
|
|
5
|
+
import { execa } from 'execa'
|
|
6
|
+
import path from 'node:path'
|
|
7
|
+
import fs from 'node:fs/promises'
|
|
8
|
+
|
|
9
|
+
export async function executeClaudeCli(
|
|
10
|
+
input: ClaudeCliExecutionInput
|
|
11
|
+
): Promise<ClaudeCliExecutionResult> {
|
|
12
|
+
const startedAt = Date.now()
|
|
13
|
+
const heartbeatMs = 10000
|
|
14
|
+
let heartbeat: ReturnType<typeof setInterval> | undefined
|
|
15
|
+
try {
|
|
16
|
+
if (input.onProgress) {
|
|
17
|
+
heartbeat = setInterval(() => {
|
|
18
|
+
input.onProgress?.(Date.now() - startedAt)
|
|
19
|
+
}, heartbeatMs)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const args: string[] = ['-p', input.prompt]
|
|
23
|
+
if (input.outputFormat) args.push('--output-format', input.outputFormat)
|
|
24
|
+
if (input.systemPrompt) args.push('--system-prompt', input.systemPrompt)
|
|
25
|
+
if (input.appendSystemPrompt) args.push('--append-system-prompt', input.appendSystemPrompt)
|
|
26
|
+
if (input.allowedTools?.length) args.push('--allowedTools', input.allowedTools.join(','))
|
|
27
|
+
if (input.permissionMode) args.push('--permission-mode', input.permissionMode)
|
|
28
|
+
if (input.timeoutMs) args.push('--timeout', String(input.timeoutMs))
|
|
29
|
+
|
|
30
|
+
const result = await execa('claude', args, {
|
|
31
|
+
cwd: input.cwd,
|
|
32
|
+
reject: false,
|
|
33
|
+
timeout: input.timeoutMs ?? 120000,
|
|
34
|
+
stdin: 'ignore',
|
|
35
|
+
env: await buildClaudeEnv(input.cwd)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
if (heartbeat) clearInterval(heartbeat)
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
ok: result.exitCode === 0,
|
|
42
|
+
exitCode: result.exitCode ?? null,
|
|
43
|
+
stdout: result.stdout,
|
|
44
|
+
stderr: result.stderr,
|
|
45
|
+
durationMs: Date.now() - startedAt,
|
|
46
|
+
taskMeta: input.taskMeta,
|
|
47
|
+
failureCategory: result.exitCode === 0 ? undefined : 'CLI_EXIT_NON_ZERO'
|
|
48
|
+
}
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (heartbeat) clearInterval(heartbeat)
|
|
51
|
+
|
|
52
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
53
|
+
const category = message.includes('ENOENT')
|
|
54
|
+
? 'CLI_NOT_FOUND'
|
|
55
|
+
: message.toLowerCase().includes('timed out')
|
|
56
|
+
? 'CLI_TIMEOUT'
|
|
57
|
+
: 'RUNTIME_ENV_ERROR'
|
|
58
|
+
return {
|
|
59
|
+
ok: false,
|
|
60
|
+
exitCode: null,
|
|
61
|
+
stdout: '',
|
|
62
|
+
stderr: message,
|
|
63
|
+
durationMs: Date.now() - startedAt,
|
|
64
|
+
failureCategory: category,
|
|
65
|
+
taskMeta: input.taskMeta
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function buildClaudeEnv(cwd: string): Promise<NodeJS.ProcessEnv> {
|
|
71
|
+
const projectEnv = await loadProjectClaudeEnv(cwd)
|
|
72
|
+
return {
|
|
73
|
+
...process.env,
|
|
74
|
+
...(process.env.ANTHROPIC_BASE_URL ? { ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL } : {}),
|
|
75
|
+
...(process.env.ANTHROPIC_AUTH_TOKEN ? { ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN } : {}),
|
|
76
|
+
...projectEnv
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function loadProjectClaudeEnv(cwd: string): Promise<Partial<NodeJS.ProcessEnv>> {
|
|
81
|
+
try {
|
|
82
|
+
const configPath = path.join(cwd, '.claude', 'settings.local.json')
|
|
83
|
+
const raw = await fs.readFile(configPath, 'utf-8')
|
|
84
|
+
const parsed = JSON.parse(raw) as { env?: Record<string, string> }
|
|
85
|
+
const env = parsed?.env ?? {}
|
|
86
|
+
const result: Record<string, string> = {}
|
|
87
|
+
if (env.ANTHROPIC_BASE_URL) result.ANTHROPIC_BASE_URL = env.ANTHROPIC_BASE_URL
|
|
88
|
+
if (env.ANTHROPIC_AUTH_TOKEN) result.ANTHROPIC_AUTH_TOKEN = env.ANTHROPIC_AUTH_TOKEN
|
|
89
|
+
return result
|
|
90
|
+
} catch {
|
|
91
|
+
return {}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { AppContext } from '../../cli/context/create-context.js'
|
|
2
|
+
import type { BaseToolResult } from '../../types/index.js'
|
|
3
|
+
|
|
4
|
+
export async function preCompletionChecklist(ctx: AppContext): Promise<BaseToolResult> {
|
|
5
|
+
const lint = await ctx.commandRunner.run(ctx.commands.lint)
|
|
6
|
+
if (lint.exitCode !== 0) {
|
|
7
|
+
return {
|
|
8
|
+
ok: false,
|
|
9
|
+
phase: 'VERIFYING_FULL',
|
|
10
|
+
summary: 'lint 失败',
|
|
11
|
+
nextAction: 'manual-intervention',
|
|
12
|
+
artifacts: { stderr: lint.stderr }
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const typecheck = await ctx.commandRunner.run(ctx.commands.typecheck)
|
|
17
|
+
if (typecheck.exitCode !== 0) {
|
|
18
|
+
return {
|
|
19
|
+
ok: false,
|
|
20
|
+
phase: 'VERIFYING_FULL',
|
|
21
|
+
summary: 'typecheck 失败',
|
|
22
|
+
nextAction: 'manual-intervention',
|
|
23
|
+
artifacts: { stderr: typecheck.stderr }
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
ok: true,
|
|
29
|
+
phase: 'VERIFYING_FULL',
|
|
30
|
+
summary: 'checklist passed'
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { AppContext } from '../../cli/context/create-context.js'
|
|
2
|
+
import type { BaseToolResult } from '../../types/index.js'
|
|
3
|
+
|
|
4
|
+
export async function silentSuccessPostCheck(ctx: AppContext, targetFile?: string): Promise<BaseToolResult> {
|
|
5
|
+
const result = await ctx.commandRunner.run(ctx.commands.incrementalTest(targetFile))
|
|
6
|
+
return {
|
|
7
|
+
ok: result.exitCode === 0,
|
|
8
|
+
phase: 'VERIFYING_FULL',
|
|
9
|
+
summary: result.exitCode === 0 ? 'incremental check passed' : 'incremental check failed',
|
|
10
|
+
nextAction: result.exitCode === 0 ? 'verify' : 'manual-intervention',
|
|
11
|
+
artifacts: {
|
|
12
|
+
stdout: result.stdout,
|
|
13
|
+
stderr: result.stderr
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { AppContext } from '../../cli/context/create-context.js'
|
|
2
|
+
import type { CandidateFile, CoverageSnapshot, RuleRankedCandidate } from '../../types/index.js'
|
|
3
|
+
import { runAnalyzers } from '../analyzers/index.js'
|
|
4
|
+
import { buildRuleCandidate } from './score-candidate.js'
|
|
5
|
+
import { rankCandidates } from './rank-candidates.js'
|
|
6
|
+
import { pathPriorityAnalyzer } from '../analyzers/path-priority-analyzer.js'
|
|
7
|
+
|
|
8
|
+
export async function rulePlanner(
|
|
9
|
+
ctx: AppContext,
|
|
10
|
+
candidates: CandidateFile[],
|
|
11
|
+
coverageSnapshot?: CoverageSnapshot
|
|
12
|
+
): Promise<RuleRankedCandidate[]> {
|
|
13
|
+
const startedAt = Date.now()
|
|
14
|
+
const logEvery = 200
|
|
15
|
+
|
|
16
|
+
const filtered = candidates.filter((candidate) => {
|
|
17
|
+
const pathPriority = pathPriorityAnalyzer(candidate, ctx.config)
|
|
18
|
+
return pathPriority.label !== 'excluded'
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
ctx.logger.info(`[run] step=rank status=filter total=${candidates.length} remaining=${filtered.length}`)
|
|
22
|
+
|
|
23
|
+
const ranked = await mapWithConcurrency(filtered, Math.max(1, Math.min(ctx.limits.concurrency || 8, 16)), async (candidate, index) => {
|
|
24
|
+
if (index === 0 || (index + 1) % logEvery === 0 || index + 1 === filtered.length) {
|
|
25
|
+
ctx.logger.info(
|
|
26
|
+
`[run] step=rank status=progress current=${index + 1} total=${filtered.length} elapsedMs=${Date.now() - startedAt}`
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const { facts } = await runAnalyzers(ctx, candidate, coverageSnapshot)
|
|
31
|
+
if (facts.pathPriority.label === 'excluded') return undefined
|
|
32
|
+
return buildRuleCandidate(candidate, facts)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
const selected = ranked.filter((item): item is RuleRankedCandidate => Boolean(item))
|
|
36
|
+
|
|
37
|
+
ctx.logger.info(
|
|
38
|
+
`[run] step=rank status=done total=${filtered.length} selected=${selected.length} elapsedMs=${Date.now() - startedAt}`
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
return rankCandidates(selected).map((candidate, index) => ({
|
|
42
|
+
...candidate,
|
|
43
|
+
taskId: `${ctx.runId}-${String(index + 1).padStart(3, '0')}`
|
|
44
|
+
}))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function mapWithConcurrency<T, R>(
|
|
48
|
+
items: T[],
|
|
49
|
+
concurrency: number,
|
|
50
|
+
mapper: (item: T, index: number) => Promise<R>
|
|
51
|
+
): Promise<R[]> {
|
|
52
|
+
const results: R[] = new Array(items.length)
|
|
53
|
+
let nextIndex = 0
|
|
54
|
+
|
|
55
|
+
const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
|
|
56
|
+
while (nextIndex < items.length) {
|
|
57
|
+
const currentIndex = nextIndex
|
|
58
|
+
nextIndex += 1
|
|
59
|
+
results[currentIndex] = await mapper(items[currentIndex], currentIndex)
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
await Promise.all(workers)
|
|
64
|
+
return results
|
|
65
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { AnalyzerFacts, CandidateFile, RuleRankedCandidate } from '../../types/index.js'
|
|
2
|
+
|
|
3
|
+
export function scoreCandidate(facts: AnalyzerFacts): { baseScore: number; reasons: string[] } {
|
|
4
|
+
const reasons: string[] = []
|
|
5
|
+
let score = 0
|
|
6
|
+
|
|
7
|
+
if (facts.pathPriority.label === 'priority') {
|
|
8
|
+
score += 100
|
|
9
|
+
reasons.push('priority-path')
|
|
10
|
+
}
|
|
11
|
+
if (facts.pathPriority.label === 'deferred') {
|
|
12
|
+
score -= 100
|
|
13
|
+
reasons.push('deferred-path')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (facts.coverage.coverageGap && facts.coverage.coverageGap > 0) {
|
|
17
|
+
score += Math.min(30, facts.coverage.coverageGap)
|
|
18
|
+
reasons.push('coverage-gap')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (facts.dependencyComplexity.complexityLevel === 'low') {
|
|
22
|
+
score += 10
|
|
23
|
+
reasons.push('low-complexity')
|
|
24
|
+
}
|
|
25
|
+
if (facts.dependencyComplexity.complexityLevel === 'high') {
|
|
26
|
+
score -= 10
|
|
27
|
+
reasons.push('high-complexity')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (facts.existingTest.hasExistingTest) {
|
|
31
|
+
score += 5
|
|
32
|
+
reasons.push('existing-tests')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
score += facts.failureHistory.failurePenaltyScore
|
|
36
|
+
if (facts.failureHistory.failurePenaltyScore < 0) {
|
|
37
|
+
reasons.push('failure-penalty')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return { baseScore: score, reasons }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function buildRuleCandidate(candidate: CandidateFile, facts: AnalyzerFacts): RuleRankedCandidate {
|
|
44
|
+
const { baseScore, reasons } = scoreCandidate(facts)
|
|
45
|
+
const estimatedDifficulty = facts.dependencyComplexity.complexityLevel
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
taskId: '',
|
|
49
|
+
targetFile: candidate.targetFile,
|
|
50
|
+
baseScore,
|
|
51
|
+
pathPriority:
|
|
52
|
+
facts.pathPriority.label === 'priority' || facts.pathPriority.label === 'deferred'
|
|
53
|
+
? facts.pathPriority.label
|
|
54
|
+
: 'normal',
|
|
55
|
+
estimatedDifficulty,
|
|
56
|
+
reasons,
|
|
57
|
+
suggestedStrategy: facts.fileClassification.ruleCategory,
|
|
58
|
+
analyzerFacts: facts
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
const DEFAULT_CASE_LIBRARY = [
|
|
2
|
+
{
|
|
3
|
+
title: '普通组件',
|
|
4
|
+
lines: [
|
|
5
|
+
'先覆盖组件自身公开职责:computed、methods、事件分发、关键状态切换。',
|
|
6
|
+
'子组件只做 stub 或最小替身,不断言其内部 DOM 结构。',
|
|
7
|
+
'优先断言输入输出、事件、副作用和关键分支,不为凑覆盖率补无价值 snapshot。'
|
|
8
|
+
]
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
title: 'render/HOC 包装组件',
|
|
12
|
+
lines: [
|
|
13
|
+
'把测试重点放在 render(h) 传给子组件的参数:props、attrs、on、scopedSlots。',
|
|
14
|
+
'验证包装层派生参数是否正确,例如附加的 fullHeight、stateServiceParams。',
|
|
15
|
+
'不要把最终渲染 DOM、data-* 标记、第三方包装细节当成主断言。'
|
|
16
|
+
]
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
title: '强依赖上下文组件',
|
|
20
|
+
lines: [
|
|
21
|
+
'在实例创建前准备好上下文依赖(如 provide/mocks/props/data),避免 created/mounted 后再补写。',
|
|
22
|
+
'只 mock 当前组件直接依赖的边界接口,例如 context、service、plugin,避免把整条调用链都模拟一遍。',
|
|
23
|
+
'优先断言上下文输入如何影响当前组件状态、调用参数和容错分支。'
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
] as const
|
|
27
|
+
|
|
28
|
+
export function buildCaseLibraryPrompt(): string {
|
|
29
|
+
return [
|
|
30
|
+
'典型案例约束:',
|
|
31
|
+
...DEFAULT_CASE_LIBRARY.flatMap((item) => [
|
|
32
|
+
`- ${item.title}:`,
|
|
33
|
+
...item.lines.map((line) => ` - ${line}`)
|
|
34
|
+
])
|
|
35
|
+
].join('\n')
|
|
36
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { TaskItem } from '../../types/index.js'
|
|
2
|
+
|
|
3
|
+
export function buildEditBoundaryPrompt(task: TaskItem): string {
|
|
4
|
+
const preferredTestDirs = task.testDirNames?.join(', ') || '__test__, __tests__'
|
|
5
|
+
const relatedTests = task.testFiles.length > 0 ? task.testFiles : []
|
|
6
|
+
const targetDir = task.targetFile.includes('/') ? task.targetFile.slice(0, task.targetFile.lastIndexOf('/')) : '.'
|
|
7
|
+
return [
|
|
8
|
+
'本轮允许修改的文件范围:',
|
|
9
|
+
...(relatedTests.length > 0
|
|
10
|
+
? relatedTests
|
|
11
|
+
: [
|
|
12
|
+
`优先只允许在 ${targetDir} 同级或子级创建/修改测试文件`,
|
|
13
|
+
`仅当目标模块附近已有明确测试约定时,才允许使用 ${preferredTestDirs}`
|
|
14
|
+
]),
|
|
15
|
+
'',
|
|
16
|
+
'强约束:',
|
|
17
|
+
'- 必须新增或修改与目标直接相关的测试文件,否则本轮视为失败',
|
|
18
|
+
'- 默认不要把新测试放到 src/__tests__ 这类聚合目录,除非目标模块附近已有同类约定',
|
|
19
|
+
'',
|
|
20
|
+
'禁止修改:',
|
|
21
|
+
`- 与 ${task.targetFile} 无关的源码`,
|
|
22
|
+
`- ${task.targetFile} 本身(如发现源码问题,仅记录待办)`,
|
|
23
|
+
'- package.json、tsconfig、构建配置、测试框架配置',
|
|
24
|
+
'- 其他无关测试文件'
|
|
25
|
+
].join('\n')
|
|
26
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { TaskItem, PromptOverridesConfig } from '../../types/index.js'
|
|
2
|
+
|
|
3
|
+
export function buildRetryPrompt(task: TaskItem, failure: string, overrides?: PromptOverridesConfig): string {
|
|
4
|
+
const lines = [
|
|
5
|
+
`上一次为 ${task.targetFile} 的补测尝试未通过,请只针对当前失败修正。`,
|
|
6
|
+
`失败摘要:${failure}`,
|
|
7
|
+
`上轮策略:${task.strategy}`,
|
|
8
|
+
'本轮要求:',
|
|
9
|
+
'- 只修复失败日志中暴露的问题',
|
|
10
|
+
'- 不扩大改动范围,不修改无关文件',
|
|
11
|
+
'- 优先修复断言、mock、类型、测试环境问题',
|
|
12
|
+
'- 如果失败来自 render/HOC 测试不稳定,回到组件职责重新设计断言,不要继续堆叠 DOM 或 mock 细节',
|
|
13
|
+
'- 优先收敛到最小稳定断言,而不是修补脆弱测试',
|
|
14
|
+
'- 修复后再次确保验证通过'
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
if (overrides?.retryAppend) {
|
|
18
|
+
lines.push('', '项目重试补充要求:', overrides.retryAppend)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return lines.join('\n')
|
|
22
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { PromptOverridesConfig } from '../../types/index.js'
|
|
2
|
+
import { buildCaseLibraryPrompt } from './case-library.js'
|
|
3
|
+
|
|
4
|
+
const DEFAULT_TEST_STRATEGY_GUIDELINES = [
|
|
5
|
+
'生成测试前先识别模块类型:工具函数/普通组件/交互组件/render-only/HOC/强依赖上下文。',
|
|
6
|
+
'只测试当前模块职责,不测试子组件或第三方内部行为。',
|
|
7
|
+
'render-only 或 HOC wrapper:优先测试 render 参数传递与 props/attrs/listeners/scopedSlots 透传,避免依赖最终 DOM。',
|
|
8
|
+
'禁止直接改写 Vue 只读属性(如 $attrs/$listeners/$scopedSlots)。',
|
|
9
|
+
'优先稳定断言(调用参数/事件/状态/关键分支),避免脆弱 DOM/snapshot 断言。'
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
export function buildSystemPrompt(overrides?: PromptOverridesConfig): string {
|
|
13
|
+
const base = [
|
|
14
|
+
'你是 Claude CLI 驱动的单元测试补齐执行器。',
|
|
15
|
+
'你只负责当前 target 的补测或修复,不负责全局规划。',
|
|
16
|
+
'优先复用项目现有测试风格,通过最小改动完成任务。',
|
|
17
|
+
'除非提示中明确允许,否则不要修改业务源码;只能修改目标测试文件及必要的新测试文件。',
|
|
18
|
+
'不要做与当前 target 无关的重构、批量迁移或配置改造。',
|
|
19
|
+
'完成后输出简洁摘要:修改文件列表、验证结果、剩余问题。',
|
|
20
|
+
'',
|
|
21
|
+
'通用单测策略:',
|
|
22
|
+
...DEFAULT_TEST_STRATEGY_GUIDELINES.map((line) => `- ${line}`),
|
|
23
|
+
'',
|
|
24
|
+
buildCaseLibraryPrompt()
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
if (overrides?.systemAppend) {
|
|
28
|
+
base.push('', '项目覆盖规则:', overrides.systemAppend)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return base.join('\n')
|
|
32
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { TaskItem, PromptOverridesConfig } from '../../types/index.js'
|
|
2
|
+
|
|
3
|
+
export function buildTaskPrompt(task: TaskItem, overrides?: PromptOverridesConfig): string {
|
|
4
|
+
const preferredTestDirs = task.testDirNames?.join(', ') || '__test__, __tests__'
|
|
5
|
+
const lines = [
|
|
6
|
+
`任务目标:为 ${task.targetFile} 补充或修复单元测试。`,
|
|
7
|
+
`建议优先检查的测试文件:${task.testFiles.join(', ') || '当前仓库中没有明显同名测试,请优先在目标模块附近新建测试文件'}`,
|
|
8
|
+
`新测试文件优先放置目录:优先在目标模块同级或子级的 ${preferredTestDirs} 中创建;仅当项目已有明确聚合约定且就近放置不合适时,才考虑使用聚合测试目录。`,
|
|
9
|
+
`建议策略:${task.strategy}`,
|
|
10
|
+
`任务线索:${task.reasons.join(', ') || '无'}`,
|
|
11
|
+
'完成标准:',
|
|
12
|
+
'1. 必须新增或修改与目标相关的测试文件(否则视为失败)',
|
|
13
|
+
'2. 新测试默认优先就近放在目标模块附近,不要轻易放到 src/__tests__ 这类聚合目录',
|
|
14
|
+
'3. 当前 target 的测试补充完成',
|
|
15
|
+
'4. 不引入无关文件修改',
|
|
16
|
+
'5. 全量验证通过或明确说明阻塞原因',
|
|
17
|
+
'6. 如发现源码问题,只记录待办,不修改源码',
|
|
18
|
+
'7. 输出本轮结果摘要'
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
if (overrides?.taskAppend) {
|
|
22
|
+
lines.push('', '项目补充要求:', overrides.taskAppend)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return lines.join('\n')
|
|
26
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { AppContext } from '../../cli/context/create-context.js'
|
|
2
|
+
import type { BaseToolResult, FinalReport } from '../../types/index.js'
|
|
3
|
+
|
|
4
|
+
export async function reportStatus(ctx: AppContext): Promise<BaseToolResult> {
|
|
5
|
+
const state = await ctx.stateStore.load()
|
|
6
|
+
const report = await ctx.reportStore.loadFinalReport<FinalReport>()
|
|
7
|
+
const lifecycle = await ctx.lifecycleStore.load()
|
|
8
|
+
|
|
9
|
+
const summary = [
|
|
10
|
+
`phase=${state.phase}`,
|
|
11
|
+
`status=${state.status}`,
|
|
12
|
+
`passed=${state.totals.passed}`,
|
|
13
|
+
`failed=${state.totals.failed}`,
|
|
14
|
+
`report=${report ? 'ready' : 'missing'}`,
|
|
15
|
+
lifecycle ? `lifecycle=${lifecycle.status}` : 'lifecycle=missing',
|
|
16
|
+
lifecycle?.pid ? `pid=${lifecycle.pid}` : undefined,
|
|
17
|
+
lifecycle?.restartCount !== undefined ? `restart=${lifecycle.restartCount}` : undefined,
|
|
18
|
+
lifecycle?.lastHeartbeat ? `heartbeat=${lifecycle.lastHeartbeat}` : undefined,
|
|
19
|
+
ctx.config.include?.length ? `include=${ctx.config.include.join('|')}` : undefined,
|
|
20
|
+
state.loopSummary
|
|
21
|
+
? `loop=iterations:${state.loopSummary.iterationCount},completed:${state.loopSummary.completedTargets},blocked:${state.loopSummary.blockedTargets},noop:${state.loopSummary.noOpTargets}`
|
|
22
|
+
: undefined
|
|
23
|
+
]
|
|
24
|
+
.filter(Boolean)
|
|
25
|
+
.join(', ')
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
ok: true,
|
|
29
|
+
phase: state.phase,
|
|
30
|
+
summary,
|
|
31
|
+
artifacts: {
|
|
32
|
+
statePath: ctx.paths.statePath,
|
|
33
|
+
finalReportPath: ctx.paths.finalReportPath
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function buildFinalReport(ctx: AppContext): Promise<FinalReport> {
|
|
39
|
+
const state = await ctx.stateStore.load()
|
|
40
|
+
const events = await ctx.eventStore.readAll()
|
|
41
|
+
|
|
42
|
+
const blockedTasks = events
|
|
43
|
+
.filter((event) => event.eventType === 'cli_run_failed' && event.taskId)
|
|
44
|
+
.map((event) => event.taskId as string)
|
|
45
|
+
const failedTasks = state.status === 'failed' && state.currentTaskId ? [state.currentTaskId] : []
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
generatedAt: ctx.clock.nowIso(),
|
|
49
|
+
totals: state.totals,
|
|
50
|
+
blockedTasks,
|
|
51
|
+
failedTasks,
|
|
52
|
+
coveragePassed: state.status === 'done',
|
|
53
|
+
loopSummary: state.loopSummary,
|
|
54
|
+
suggestedNextAction: state.nextSuggestedAction
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { WorkflowPhase } from '../../types/index.js'
|
|
2
|
+
|
|
3
|
+
export function deriveStatusFromPhase(phase: WorkflowPhase): 'idle' | 'running' | 'blocked' | 'done' | 'failed' {
|
|
4
|
+
switch (phase) {
|
|
5
|
+
case 'BLOCKED':
|
|
6
|
+
return 'blocked'
|
|
7
|
+
case 'DONE':
|
|
8
|
+
return 'done'
|
|
9
|
+
case 'INIT':
|
|
10
|
+
return 'idle'
|
|
11
|
+
default:
|
|
12
|
+
return 'running'
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { StateFile } from '../../types/index.js'
|
|
2
|
+
|
|
3
|
+
export function defaultState(): StateFile {
|
|
4
|
+
return {
|
|
5
|
+
phase: 'INIT',
|
|
6
|
+
status: 'idle',
|
|
7
|
+
totals: {
|
|
8
|
+
pending: 0,
|
|
9
|
+
running: 0,
|
|
10
|
+
passed: 0,
|
|
11
|
+
failed: 0,
|
|
12
|
+
blocked: 0
|
|
13
|
+
},
|
|
14
|
+
lastAction: 'init',
|
|
15
|
+
nextSuggestedAction: 'analyze',
|
|
16
|
+
updatedAt: new Date().toISOString()
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { EventRecord, RuntimePaths } from '../../types/index.js'
|
|
2
|
+
import type { createFileSystem } from '../../utils/fs.js'
|
|
3
|
+
|
|
4
|
+
export function createEventStore(paths: RuntimePaths, fileSystem: ReturnType<typeof createFileSystem>) {
|
|
5
|
+
return {
|
|
6
|
+
async append(event: EventRecord): Promise<void> {
|
|
7
|
+
await fileSystem.appendLine(paths.eventsPath, JSON.stringify(event))
|
|
8
|
+
},
|
|
9
|
+
async readAll(): Promise<EventRecord[]> {
|
|
10
|
+
const content = await fileSystem.readText(paths.eventsPath)
|
|
11
|
+
if (!content) return []
|
|
12
|
+
return content
|
|
13
|
+
.split('\n')
|
|
14
|
+
.filter(Boolean)
|
|
15
|
+
.map((line) => JSON.parse(line) as EventRecord)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { LifecycleFile, RuntimePaths } from '../../types/index.js'
|
|
2
|
+
import type { createFileSystem } from '../../utils/fs.js'
|
|
3
|
+
|
|
4
|
+
export function createLifecycleStore(paths: RuntimePaths, fileSystem: ReturnType<typeof createFileSystem>) {
|
|
5
|
+
return {
|
|
6
|
+
async load(): Promise<LifecycleFile | undefined> {
|
|
7
|
+
return fileSystem.readJson<LifecycleFile>(paths.lifecyclePath)
|
|
8
|
+
},
|
|
9
|
+
async save(lifecycle: LifecycleFile): Promise<void> {
|
|
10
|
+
await fileSystem.writeJson(paths.lifecyclePath, lifecycle)
|
|
11
|
+
},
|
|
12
|
+
async patch(partial: Partial<LifecycleFile>): Promise<LifecycleFile | undefined> {
|
|
13
|
+
const current = await fileSystem.readJson<LifecycleFile>(paths.lifecyclePath)
|
|
14
|
+
if (!current) return undefined
|
|
15
|
+
const next = { ...current, ...partial }
|
|
16
|
+
await fileSystem.writeJson(paths.lifecyclePath, next)
|
|
17
|
+
return next
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { FinalReport, RuntimePaths } from '../../types/index.js'
|
|
2
|
+
import type { createFileSystem } from '../../utils/fs.js'
|
|
3
|
+
|
|
4
|
+
export function createReportStore(paths: RuntimePaths, fileSystem: ReturnType<typeof createFileSystem>) {
|
|
5
|
+
return {
|
|
6
|
+
async saveCoverageSummary(report: unknown): Promise<void> {
|
|
7
|
+
await fileSystem.writeJson(paths.coverageSummaryPath, report)
|
|
8
|
+
},
|
|
9
|
+
async loadCoverageSummary<T>(): Promise<T | undefined> {
|
|
10
|
+
return fileSystem.readJson<T>(paths.coverageSummaryPath)
|
|
11
|
+
},
|
|
12
|
+
async saveFinalReport(report: FinalReport): Promise<void> {
|
|
13
|
+
await fileSystem.writeJson(paths.finalReportPath, report)
|
|
14
|
+
},
|
|
15
|
+
async loadFinalReport<T>(): Promise<T | undefined> {
|
|
16
|
+
return fileSystem.readJson<T>(paths.finalReportPath)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { RuntimePaths, StateFile } from '../../types/index.js'
|
|
2
|
+
import { defaultState } from './defaults.js'
|
|
3
|
+
import type { createFileSystem } from '../../utils/fs.js'
|
|
4
|
+
|
|
5
|
+
export function createStateStore(
|
|
6
|
+
paths: RuntimePaths,
|
|
7
|
+
fileSystem: ReturnType<typeof createFileSystem>,
|
|
8
|
+
initialState?: StateFile
|
|
9
|
+
) {
|
|
10
|
+
return {
|
|
11
|
+
async load(): Promise<StateFile> {
|
|
12
|
+
return (await fileSystem.readJson<StateFile>(paths.statePath)) ?? initialState ?? defaultState()
|
|
13
|
+
},
|
|
14
|
+
async save(state: StateFile): Promise<void> {
|
|
15
|
+
await fileSystem.writeJson(paths.statePath, state)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export function classifyFailure(message: string): string {
|
|
2
|
+
const lower = message.toLowerCase()
|
|
3
|
+
if (lower.includes('timeout')) return 'CLI_TIMEOUT'
|
|
4
|
+
if (lower.includes('type')) return 'TYPE_ERROR'
|
|
5
|
+
if (lower.includes('assert')) return 'ASSERTION_FAILED'
|
|
6
|
+
if (lower.includes('mock')) return 'MOCK_MISSING'
|
|
7
|
+
if (lower.includes('syntax')) return 'SYNTAX_ERROR'
|
|
8
|
+
return 'RUNTIME_ENV_ERROR'
|
|
9
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { TaskItem } from '../../types/index.js'
|
|
2
|
+
|
|
3
|
+
const STRATEGY_ORDER = ['pure-logic', 'light-mock', 'boundary-mock', 'ui-heavy']
|
|
4
|
+
|
|
5
|
+
export function switchMockStrategy(task: TaskItem): TaskItem {
|
|
6
|
+
const index = STRATEGY_ORDER.indexOf(task.strategy)
|
|
7
|
+
const next = index >= 0 && index < STRATEGY_ORDER.length - 1 ? STRATEGY_ORDER[index + 1] : task.strategy
|
|
8
|
+
return {
|
|
9
|
+
...task,
|
|
10
|
+
strategy: next
|
|
11
|
+
}
|
|
12
|
+
}
|