@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,41 @@
|
|
|
1
|
+
import { runAnalyzers } from '../analyzers/index.js';
|
|
2
|
+
import { buildRuleCandidate } from './score-candidate.js';
|
|
3
|
+
import { rankCandidates } from './rank-candidates.js';
|
|
4
|
+
import { pathPriorityAnalyzer } from '../analyzers/path-priority-analyzer.js';
|
|
5
|
+
export async function rulePlanner(ctx, candidates, coverageSnapshot) {
|
|
6
|
+
const startedAt = Date.now();
|
|
7
|
+
const logEvery = 200;
|
|
8
|
+
const filtered = candidates.filter((candidate) => {
|
|
9
|
+
const pathPriority = pathPriorityAnalyzer(candidate, ctx.config);
|
|
10
|
+
return pathPriority.label !== 'excluded';
|
|
11
|
+
});
|
|
12
|
+
ctx.logger.info(`[run] step=rank status=filter total=${candidates.length} remaining=${filtered.length}`);
|
|
13
|
+
const ranked = await mapWithConcurrency(filtered, Math.max(1, Math.min(ctx.limits.concurrency || 8, 16)), async (candidate, index) => {
|
|
14
|
+
if (index === 0 || (index + 1) % logEvery === 0 || index + 1 === filtered.length) {
|
|
15
|
+
ctx.logger.info(`[run] step=rank status=progress current=${index + 1} total=${filtered.length} elapsedMs=${Date.now() - startedAt}`);
|
|
16
|
+
}
|
|
17
|
+
const { facts } = await runAnalyzers(ctx, candidate, coverageSnapshot);
|
|
18
|
+
if (facts.pathPriority.label === 'excluded')
|
|
19
|
+
return undefined;
|
|
20
|
+
return buildRuleCandidate(candidate, facts);
|
|
21
|
+
});
|
|
22
|
+
const selected = ranked.filter((item) => Boolean(item));
|
|
23
|
+
ctx.logger.info(`[run] step=rank status=done total=${filtered.length} selected=${selected.length} elapsedMs=${Date.now() - startedAt}`);
|
|
24
|
+
return rankCandidates(selected).map((candidate, index) => ({
|
|
25
|
+
...candidate,
|
|
26
|
+
taskId: `${ctx.runId}-${String(index + 1).padStart(3, '0')}`
|
|
27
|
+
}));
|
|
28
|
+
}
|
|
29
|
+
async function mapWithConcurrency(items, concurrency, mapper) {
|
|
30
|
+
const results = new Array(items.length);
|
|
31
|
+
let nextIndex = 0;
|
|
32
|
+
const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
|
|
33
|
+
while (nextIndex < items.length) {
|
|
34
|
+
const currentIndex = nextIndex;
|
|
35
|
+
nextIndex += 1;
|
|
36
|
+
results[currentIndex] = await mapper(items[currentIndex], currentIndex);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
await Promise.all(workers);
|
|
40
|
+
return results;
|
|
41
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export function scoreCandidate(facts) {
|
|
2
|
+
const reasons = [];
|
|
3
|
+
let score = 0;
|
|
4
|
+
if (facts.pathPriority.label === 'priority') {
|
|
5
|
+
score += 100;
|
|
6
|
+
reasons.push('priority-path');
|
|
7
|
+
}
|
|
8
|
+
if (facts.pathPriority.label === 'deferred') {
|
|
9
|
+
score -= 100;
|
|
10
|
+
reasons.push('deferred-path');
|
|
11
|
+
}
|
|
12
|
+
if (facts.coverage.coverageGap && facts.coverage.coverageGap > 0) {
|
|
13
|
+
score += Math.min(30, facts.coverage.coverageGap);
|
|
14
|
+
reasons.push('coverage-gap');
|
|
15
|
+
}
|
|
16
|
+
if (facts.dependencyComplexity.complexityLevel === 'low') {
|
|
17
|
+
score += 10;
|
|
18
|
+
reasons.push('low-complexity');
|
|
19
|
+
}
|
|
20
|
+
if (facts.dependencyComplexity.complexityLevel === 'high') {
|
|
21
|
+
score -= 10;
|
|
22
|
+
reasons.push('high-complexity');
|
|
23
|
+
}
|
|
24
|
+
if (facts.existingTest.hasExistingTest) {
|
|
25
|
+
score += 5;
|
|
26
|
+
reasons.push('existing-tests');
|
|
27
|
+
}
|
|
28
|
+
score += facts.failureHistory.failurePenaltyScore;
|
|
29
|
+
if (facts.failureHistory.failurePenaltyScore < 0) {
|
|
30
|
+
reasons.push('failure-penalty');
|
|
31
|
+
}
|
|
32
|
+
return { baseScore: score, reasons };
|
|
33
|
+
}
|
|
34
|
+
export function buildRuleCandidate(candidate, facts) {
|
|
35
|
+
const { baseScore, reasons } = scoreCandidate(facts);
|
|
36
|
+
const estimatedDifficulty = facts.dependencyComplexity.complexityLevel;
|
|
37
|
+
return {
|
|
38
|
+
taskId: '',
|
|
39
|
+
targetFile: candidate.targetFile,
|
|
40
|
+
baseScore,
|
|
41
|
+
pathPriority: facts.pathPriority.label === 'priority' || facts.pathPriority.label === 'deferred'
|
|
42
|
+
? facts.pathPriority.label
|
|
43
|
+
: 'normal',
|
|
44
|
+
estimatedDifficulty,
|
|
45
|
+
reasons,
|
|
46
|
+
suggestedStrategy: facts.fileClassification.ruleCategory,
|
|
47
|
+
analyzerFacts: facts
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
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
|
+
];
|
|
27
|
+
export function buildCaseLibraryPrompt() {
|
|
28
|
+
return [
|
|
29
|
+
'典型案例约束:',
|
|
30
|
+
...DEFAULT_CASE_LIBRARY.flatMap((item) => [
|
|
31
|
+
`- ${item.title}:`,
|
|
32
|
+
...item.lines.map((line) => ` - ${line}`)
|
|
33
|
+
])
|
|
34
|
+
].join('\n');
|
|
35
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export function buildEditBoundaryPrompt(task) {
|
|
2
|
+
const preferredTestDirs = task.testDirNames?.join(', ') || '__test__, __tests__';
|
|
3
|
+
const relatedTests = task.testFiles.length > 0 ? task.testFiles : [];
|
|
4
|
+
const targetDir = task.targetFile.includes('/') ? task.targetFile.slice(0, task.targetFile.lastIndexOf('/')) : '.';
|
|
5
|
+
return [
|
|
6
|
+
'本轮允许修改的文件范围:',
|
|
7
|
+
...(relatedTests.length > 0
|
|
8
|
+
? relatedTests
|
|
9
|
+
: [
|
|
10
|
+
`优先只允许在 ${targetDir} 同级或子级创建/修改测试文件`,
|
|
11
|
+
`仅当目标模块附近已有明确测试约定时,才允许使用 ${preferredTestDirs}`
|
|
12
|
+
]),
|
|
13
|
+
'',
|
|
14
|
+
'强约束:',
|
|
15
|
+
'- 必须新增或修改与目标直接相关的测试文件,否则本轮视为失败',
|
|
16
|
+
'- 默认不要把新测试放到 src/__tests__ 这类聚合目录,除非目标模块附近已有同类约定',
|
|
17
|
+
'',
|
|
18
|
+
'禁止修改:',
|
|
19
|
+
`- 与 ${task.targetFile} 无关的源码`,
|
|
20
|
+
`- ${task.targetFile} 本身(如发现源码问题,仅记录待办)`,
|
|
21
|
+
'- package.json、tsconfig、构建配置、测试框架配置',
|
|
22
|
+
'- 其他无关测试文件'
|
|
23
|
+
].join('\n');
|
|
24
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function buildRetryPrompt(task, failure, overrides) {
|
|
2
|
+
const lines = [
|
|
3
|
+
`上一次为 ${task.targetFile} 的补测尝试未通过,请只针对当前失败修正。`,
|
|
4
|
+
`失败摘要:${failure}`,
|
|
5
|
+
`上轮策略:${task.strategy}`,
|
|
6
|
+
'本轮要求:',
|
|
7
|
+
'- 只修复失败日志中暴露的问题',
|
|
8
|
+
'- 不扩大改动范围,不修改无关文件',
|
|
9
|
+
'- 优先修复断言、mock、类型、测试环境问题',
|
|
10
|
+
'- 如果失败来自 render/HOC 测试不稳定,回到组件职责重新设计断言,不要继续堆叠 DOM 或 mock 细节',
|
|
11
|
+
'- 优先收敛到最小稳定断言,而不是修补脆弱测试',
|
|
12
|
+
'- 修复后再次确保验证通过'
|
|
13
|
+
];
|
|
14
|
+
if (overrides?.retryAppend) {
|
|
15
|
+
lines.push('', '项目重试补充要求:', overrides.retryAppend);
|
|
16
|
+
}
|
|
17
|
+
return lines.join('\n');
|
|
18
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { buildCaseLibraryPrompt } from './case-library.js';
|
|
2
|
+
const DEFAULT_TEST_STRATEGY_GUIDELINES = [
|
|
3
|
+
'生成测试前先识别模块类型:工具函数/普通组件/交互组件/render-only/HOC/强依赖上下文。',
|
|
4
|
+
'只测试当前模块职责,不测试子组件或第三方内部行为。',
|
|
5
|
+
'render-only 或 HOC wrapper:优先测试 render 参数传递与 props/attrs/listeners/scopedSlots 透传,避免依赖最终 DOM。',
|
|
6
|
+
'禁止直接改写 Vue 只读属性(如 $attrs/$listeners/$scopedSlots)。',
|
|
7
|
+
'优先稳定断言(调用参数/事件/状态/关键分支),避免脆弱 DOM/snapshot 断言。'
|
|
8
|
+
];
|
|
9
|
+
export function buildSystemPrompt(overrides) {
|
|
10
|
+
const base = [
|
|
11
|
+
'你是 Claude CLI 驱动的单元测试补齐执行器。',
|
|
12
|
+
'你只负责当前 target 的补测或修复,不负责全局规划。',
|
|
13
|
+
'优先复用项目现有测试风格,通过最小改动完成任务。',
|
|
14
|
+
'除非提示中明确允许,否则不要修改业务源码;只能修改目标测试文件及必要的新测试文件。',
|
|
15
|
+
'不要做与当前 target 无关的重构、批量迁移或配置改造。',
|
|
16
|
+
'完成后输出简洁摘要:修改文件列表、验证结果、剩余问题。',
|
|
17
|
+
'',
|
|
18
|
+
'通用单测策略:',
|
|
19
|
+
...DEFAULT_TEST_STRATEGY_GUIDELINES.map((line) => `- ${line}`),
|
|
20
|
+
'',
|
|
21
|
+
buildCaseLibraryPrompt()
|
|
22
|
+
];
|
|
23
|
+
if (overrides?.systemAppend) {
|
|
24
|
+
base.push('', '项目覆盖规则:', overrides.systemAppend);
|
|
25
|
+
}
|
|
26
|
+
return base.join('\n');
|
|
27
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export function buildTaskPrompt(task, overrides) {
|
|
2
|
+
const preferredTestDirs = task.testDirNames?.join(', ') || '__test__, __tests__';
|
|
3
|
+
const lines = [
|
|
4
|
+
`任务目标:为 ${task.targetFile} 补充或修复单元测试。`,
|
|
5
|
+
`建议优先检查的测试文件:${task.testFiles.join(', ') || '当前仓库中没有明显同名测试,请优先在目标模块附近新建测试文件'}`,
|
|
6
|
+
`新测试文件优先放置目录:优先在目标模块同级或子级的 ${preferredTestDirs} 中创建;仅当项目已有明确聚合约定且就近放置不合适时,才考虑使用聚合测试目录。`,
|
|
7
|
+
`建议策略:${task.strategy}`,
|
|
8
|
+
`任务线索:${task.reasons.join(', ') || '无'}`,
|
|
9
|
+
'完成标准:',
|
|
10
|
+
'1. 必须新增或修改与目标相关的测试文件(否则视为失败)',
|
|
11
|
+
'2. 新测试默认优先就近放在目标模块附近,不要轻易放到 src/__tests__ 这类聚合目录',
|
|
12
|
+
'3. 当前 target 的测试补充完成',
|
|
13
|
+
'4. 不引入无关文件修改',
|
|
14
|
+
'5. 全量验证通过或明确说明阻塞原因',
|
|
15
|
+
'6. 如发现源码问题,只记录待办,不修改源码',
|
|
16
|
+
'7. 输出本轮结果摘要'
|
|
17
|
+
];
|
|
18
|
+
if (overrides?.taskAppend) {
|
|
19
|
+
lines.push('', '项目补充要求:', overrides.taskAppend);
|
|
20
|
+
}
|
|
21
|
+
return lines.join('\n');
|
|
22
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export async function reportStatus(ctx) {
|
|
2
|
+
const state = await ctx.stateStore.load();
|
|
3
|
+
const report = await ctx.reportStore.loadFinalReport();
|
|
4
|
+
const lifecycle = await ctx.lifecycleStore.load();
|
|
5
|
+
const summary = [
|
|
6
|
+
`phase=${state.phase}`,
|
|
7
|
+
`status=${state.status}`,
|
|
8
|
+
`passed=${state.totals.passed}`,
|
|
9
|
+
`failed=${state.totals.failed}`,
|
|
10
|
+
`report=${report ? 'ready' : 'missing'}`,
|
|
11
|
+
lifecycle ? `lifecycle=${lifecycle.status}` : 'lifecycle=missing',
|
|
12
|
+
lifecycle?.pid ? `pid=${lifecycle.pid}` : undefined,
|
|
13
|
+
lifecycle?.restartCount !== undefined ? `restart=${lifecycle.restartCount}` : undefined,
|
|
14
|
+
lifecycle?.lastHeartbeat ? `heartbeat=${lifecycle.lastHeartbeat}` : undefined,
|
|
15
|
+
ctx.config.include?.length ? `include=${ctx.config.include.join('|')}` : undefined,
|
|
16
|
+
state.loopSummary
|
|
17
|
+
? `loop=iterations:${state.loopSummary.iterationCount},completed:${state.loopSummary.completedTargets},blocked:${state.loopSummary.blockedTargets},noop:${state.loopSummary.noOpTargets}`
|
|
18
|
+
: undefined
|
|
19
|
+
]
|
|
20
|
+
.filter(Boolean)
|
|
21
|
+
.join(', ');
|
|
22
|
+
return {
|
|
23
|
+
ok: true,
|
|
24
|
+
phase: state.phase,
|
|
25
|
+
summary,
|
|
26
|
+
artifacts: {
|
|
27
|
+
statePath: ctx.paths.statePath,
|
|
28
|
+
finalReportPath: ctx.paths.finalReportPath
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export async function buildFinalReport(ctx) {
|
|
33
|
+
const state = await ctx.stateStore.load();
|
|
34
|
+
const events = await ctx.eventStore.readAll();
|
|
35
|
+
const blockedTasks = events
|
|
36
|
+
.filter((event) => event.eventType === 'cli_run_failed' && event.taskId)
|
|
37
|
+
.map((event) => event.taskId);
|
|
38
|
+
const failedTasks = state.status === 'failed' && state.currentTaskId ? [state.currentTaskId] : [];
|
|
39
|
+
return {
|
|
40
|
+
generatedAt: ctx.clock.nowIso(),
|
|
41
|
+
totals: state.totals,
|
|
42
|
+
blockedTasks,
|
|
43
|
+
failedTasks,
|
|
44
|
+
coveragePassed: state.status === 'done',
|
|
45
|
+
loopSummary: state.loopSummary,
|
|
46
|
+
suggestedNextAction: state.nextSuggestedAction
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function defaultState() {
|
|
2
|
+
return {
|
|
3
|
+
phase: 'INIT',
|
|
4
|
+
status: 'idle',
|
|
5
|
+
totals: {
|
|
6
|
+
pending: 0,
|
|
7
|
+
running: 0,
|
|
8
|
+
passed: 0,
|
|
9
|
+
failed: 0,
|
|
10
|
+
blocked: 0
|
|
11
|
+
},
|
|
12
|
+
lastAction: 'init',
|
|
13
|
+
nextSuggestedAction: 'analyze',
|
|
14
|
+
updatedAt: new Date().toISOString()
|
|
15
|
+
};
|
|
16
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function createEventStore(paths, fileSystem) {
|
|
2
|
+
return {
|
|
3
|
+
async append(event) {
|
|
4
|
+
await fileSystem.appendLine(paths.eventsPath, JSON.stringify(event));
|
|
5
|
+
},
|
|
6
|
+
async readAll() {
|
|
7
|
+
const content = await fileSystem.readText(paths.eventsPath);
|
|
8
|
+
if (!content)
|
|
9
|
+
return [];
|
|
10
|
+
return content
|
|
11
|
+
.split('\n')
|
|
12
|
+
.filter(Boolean)
|
|
13
|
+
.map((line) => JSON.parse(line));
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function createLifecycleStore(paths, fileSystem) {
|
|
2
|
+
return {
|
|
3
|
+
async load() {
|
|
4
|
+
return fileSystem.readJson(paths.lifecyclePath);
|
|
5
|
+
},
|
|
6
|
+
async save(lifecycle) {
|
|
7
|
+
await fileSystem.writeJson(paths.lifecyclePath, lifecycle);
|
|
8
|
+
},
|
|
9
|
+
async patch(partial) {
|
|
10
|
+
const current = await fileSystem.readJson(paths.lifecyclePath);
|
|
11
|
+
if (!current)
|
|
12
|
+
return undefined;
|
|
13
|
+
const next = { ...current, ...partial };
|
|
14
|
+
await fileSystem.writeJson(paths.lifecyclePath, next);
|
|
15
|
+
return next;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function createReportStore(paths, fileSystem) {
|
|
2
|
+
return {
|
|
3
|
+
async saveCoverageSummary(report) {
|
|
4
|
+
await fileSystem.writeJson(paths.coverageSummaryPath, report);
|
|
5
|
+
},
|
|
6
|
+
async loadCoverageSummary() {
|
|
7
|
+
return fileSystem.readJson(paths.coverageSummaryPath);
|
|
8
|
+
},
|
|
9
|
+
async saveFinalReport(report) {
|
|
10
|
+
await fileSystem.writeJson(paths.finalReportPath, report);
|
|
11
|
+
},
|
|
12
|
+
async loadFinalReport() {
|
|
13
|
+
return fileSystem.readJson(paths.finalReportPath);
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { defaultState } from './defaults.js';
|
|
2
|
+
export function createStateStore(paths, fileSystem, initialState) {
|
|
3
|
+
return {
|
|
4
|
+
async load() {
|
|
5
|
+
return (await fileSystem.readJson(paths.statePath)) ?? initialState ?? defaultState();
|
|
6
|
+
},
|
|
7
|
+
async save(state) {
|
|
8
|
+
await fileSystem.writeJson(paths.statePath, state);
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function classifyFailure(message) {
|
|
2
|
+
const lower = message.toLowerCase();
|
|
3
|
+
if (lower.includes('timeout'))
|
|
4
|
+
return 'CLI_TIMEOUT';
|
|
5
|
+
if (lower.includes('type'))
|
|
6
|
+
return 'TYPE_ERROR';
|
|
7
|
+
if (lower.includes('assert'))
|
|
8
|
+
return 'ASSERTION_FAILED';
|
|
9
|
+
if (lower.includes('mock'))
|
|
10
|
+
return 'MOCK_MISSING';
|
|
11
|
+
if (lower.includes('syntax'))
|
|
12
|
+
return 'SYNTAX_ERROR';
|
|
13
|
+
return 'RUNTIME_ENV_ERROR';
|
|
14
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
const STRATEGY_ORDER = ['pure-logic', 'light-mock', 'boundary-mock', 'ui-heavy'];
|
|
2
|
+
export function switchMockStrategy(task) {
|
|
3
|
+
const index = STRATEGY_ORDER.indexOf(task.strategy);
|
|
4
|
+
const next = index >= 0 && index < STRATEGY_ORDER.length - 1 ? STRATEGY_ORDER[index + 1] : task.strategy;
|
|
5
|
+
return {
|
|
6
|
+
...task,
|
|
7
|
+
strategy: next
|
|
8
|
+
};
|
|
9
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { deriveStatusFromPhase } from '../state-machine/index.js';
|
|
3
|
+
import { runAndReadCoverage } from '../coverage/read-coverage-summary.js';
|
|
4
|
+
export async function analyzeBaseline(ctx) {
|
|
5
|
+
await ctx.fileSystem.ensureDir(ctx.paths.runtimeDir);
|
|
6
|
+
await ctx.fileSystem.ensureDir(ctx.paths.logsPath);
|
|
7
|
+
await ctx.fileSystem.ensureDir(ctx.paths.promptsDir);
|
|
8
|
+
await ctx.fileSystem.ensureDir(ctx.paths.promptRunsDir);
|
|
9
|
+
await ctx.fileSystem.ensureDir(ctx.paths.reportsDir);
|
|
10
|
+
const packageJson = await ctx.fileSystem.readJson(path.join(ctx.projectPath, 'package.json'));
|
|
11
|
+
const framework = detectFramework(packageJson?.scripts ?? {});
|
|
12
|
+
const coverageSnapshot = await runAndReadCoverage(ctx);
|
|
13
|
+
await ctx.reportStore.saveCoverageSummary({
|
|
14
|
+
framework,
|
|
15
|
+
coverageSnapshot
|
|
16
|
+
});
|
|
17
|
+
const state = await ctx.stateStore.load();
|
|
18
|
+
const nextState = {
|
|
19
|
+
...state,
|
|
20
|
+
phase: 'ANALYZE_BASELINE',
|
|
21
|
+
status: deriveStatusFromPhase('ANALYZE_BASELINE'),
|
|
22
|
+
coverageSnapshot,
|
|
23
|
+
lastAction: 'analyze-baseline',
|
|
24
|
+
nextSuggestedAction: 'run',
|
|
25
|
+
updatedAt: ctx.clock.nowIso()
|
|
26
|
+
};
|
|
27
|
+
await ctx.stateStore.save(nextState);
|
|
28
|
+
await ctx.eventStore.append({
|
|
29
|
+
eventType: 'baseline_analyzed',
|
|
30
|
+
phase: 'ANALYZE_BASELINE',
|
|
31
|
+
message: `baseline coverage lines=${coverageSnapshot.lines ?? 'unknown'}`,
|
|
32
|
+
data: { framework, coverageSnapshot },
|
|
33
|
+
timestamp: ctx.clock.nowIso()
|
|
34
|
+
});
|
|
35
|
+
return {
|
|
36
|
+
ok: true,
|
|
37
|
+
phase: 'ANALYZE_BASELINE',
|
|
38
|
+
summary: `完成真实覆盖率基线分析,lines=${coverageSnapshot.lines ?? 'unknown'}%。`,
|
|
39
|
+
nextAction: 'run',
|
|
40
|
+
artifacts: {
|
|
41
|
+
coverageSummaryPath: ctx.paths.coverageSummaryPath
|
|
42
|
+
},
|
|
43
|
+
coverageSnapshot,
|
|
44
|
+
testFramework: framework
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function detectFramework(scripts) {
|
|
48
|
+
const scriptValues = Object.values(scripts).join(' ');
|
|
49
|
+
if (scriptValues.includes('vitest'))
|
|
50
|
+
return 'vitest';
|
|
51
|
+
if (scriptValues.includes('jest'))
|
|
52
|
+
return 'jest';
|
|
53
|
+
return 'unknown';
|
|
54
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { execa } from 'execa';
|
|
2
|
+
const DEFAULT_STALE_TIMEOUT_MS = 2 * 60_000;
|
|
3
|
+
const DEFAULT_MAX_RESTART = 3;
|
|
4
|
+
const DEFAULT_COOLDOWN_MS = 60_000;
|
|
5
|
+
export async function runGuard(ctx, options = {}) {
|
|
6
|
+
const lifecycle = await ctx.lifecycleStore.load();
|
|
7
|
+
if (!lifecycle) {
|
|
8
|
+
return { recovered: false, summary: '未找到 lifecycle 记录。' };
|
|
9
|
+
}
|
|
10
|
+
const staleTimeoutMs = options.staleTimeoutMs ?? DEFAULT_STALE_TIMEOUT_MS;
|
|
11
|
+
const maxRestart = options.maxRestart ?? DEFAULT_MAX_RESTART;
|
|
12
|
+
const cooldownMs = options.cooldownMs ?? DEFAULT_COOLDOWN_MS;
|
|
13
|
+
const now = Date.now();
|
|
14
|
+
const lastHeartbeat = Date.parse(lifecycle.lastHeartbeat);
|
|
15
|
+
const isStale = Number.isFinite(lastHeartbeat) && now - lastHeartbeat > staleTimeoutMs;
|
|
16
|
+
const isAbnormal = (lifecycle.status === 'running' && isStale) || lifecycle.status === 'crashed';
|
|
17
|
+
if (!isAbnormal) {
|
|
18
|
+
return { recovered: false, summary: `无需恢复:status=${lifecycle.status}` };
|
|
19
|
+
}
|
|
20
|
+
if (lifecycle.command !== 'start') {
|
|
21
|
+
return { recovered: false, summary: `检测到 ${lifecycle.command} 异常,但按设计不自动恢复。` };
|
|
22
|
+
}
|
|
23
|
+
if (lifecycle.restartCount >= maxRestart) {
|
|
24
|
+
return { recovered: false, summary: `已达到最大恢复次数 ${maxRestart}。` };
|
|
25
|
+
}
|
|
26
|
+
const updatedAt = Date.parse(lifecycle.updatedAt);
|
|
27
|
+
if (Number.isFinite(updatedAt) && now - updatedAt < cooldownMs) {
|
|
28
|
+
return { recovered: false, summary: `仍处于冷却期,稍后再试。` };
|
|
29
|
+
}
|
|
30
|
+
const nextRestartCount = lifecycle.restartCount + 1;
|
|
31
|
+
const recoveringAt = ctx.clock.nowIso();
|
|
32
|
+
await ctx.lifecycleStore.patch({
|
|
33
|
+
status: 'recovering',
|
|
34
|
+
restartCount: nextRestartCount,
|
|
35
|
+
updatedAt: recoveringAt,
|
|
36
|
+
lastError: lifecycle.lastError ?? (isStale ? 'heartbeat stale' : 'crashed')
|
|
37
|
+
});
|
|
38
|
+
await ctx.eventStore.append({
|
|
39
|
+
eventType: 'lifecycle_recovering',
|
|
40
|
+
phase: 'BLOCKED',
|
|
41
|
+
message: `recover start process #${nextRestartCount}`,
|
|
42
|
+
data: { argv: lifecycle.argv },
|
|
43
|
+
timestamp: recoveringAt
|
|
44
|
+
});
|
|
45
|
+
const child = execa(process.execPath, lifecycle.argv, {
|
|
46
|
+
cwd: ctx.projectPath,
|
|
47
|
+
detached: true,
|
|
48
|
+
stdio: 'ignore'
|
|
49
|
+
});
|
|
50
|
+
child.unref();
|
|
51
|
+
return { recovered: true, summary: `已触发恢复,第 ${nextRestartCount} 次。` };
|
|
52
|
+
}
|
|
53
|
+
export async function runSchedule(ctx, intervalMs, guardOptions = {}) {
|
|
54
|
+
await runGuard(ctx, guardOptions);
|
|
55
|
+
setInterval(async () => {
|
|
56
|
+
try {
|
|
57
|
+
await runGuard(ctx, guardOptions);
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
await ctx.eventStore.append({
|
|
61
|
+
eventType: 'schedule_guard_failed',
|
|
62
|
+
phase: 'BLOCKED',
|
|
63
|
+
message: error instanceof Error ? error.message : String(error),
|
|
64
|
+
timestamp: ctx.clock.nowIso()
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}, intervalMs);
|
|
68
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { analyzeBaseline } from './analyze-baseline.js';
|
|
2
|
+
import { runWithClaudeCli } from './run-with-claude-cli.js';
|
|
3
|
+
import { buildFinalReport } from '../reporter/index.js';
|
|
4
|
+
export async function runLoop(ctx, input = {}) {
|
|
5
|
+
await analyzeBaseline(ctx);
|
|
6
|
+
const attemptedTargets = new Set();
|
|
7
|
+
let iterationCount = 0;
|
|
8
|
+
let completedTargets = 0;
|
|
9
|
+
let blockedTargets = 0;
|
|
10
|
+
let noOpTargets = 0;
|
|
11
|
+
let lastCompletedTarget;
|
|
12
|
+
let lastResult;
|
|
13
|
+
const maxIterations = input.targetFile ? 1 : Math.max(1, ctx.limits.maxIterationsPerRun);
|
|
14
|
+
let lastCoverage;
|
|
15
|
+
while (iterationCount < maxIterations) {
|
|
16
|
+
iterationCount += 1;
|
|
17
|
+
const result = await runWithClaudeCli(ctx, {
|
|
18
|
+
...input,
|
|
19
|
+
excludeTargets: input.targetFile ? undefined : [...attemptedTargets]
|
|
20
|
+
});
|
|
21
|
+
lastResult = result;
|
|
22
|
+
if (result.targetFile)
|
|
23
|
+
attemptedTargets.add(result.targetFile);
|
|
24
|
+
const coverageSummary = await ctx.reportStore.loadCoverageSummary();
|
|
25
|
+
const currentCoverage = coverageSummary?.coverageSnapshot?.lines;
|
|
26
|
+
lastCoverage = currentCoverage;
|
|
27
|
+
if (currentCoverage !== undefined && currentCoverage >= ctx.coverageTarget) {
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
if (result.ok) {
|
|
31
|
+
completedTargets += 1;
|
|
32
|
+
lastCompletedTarget = result.targetFile;
|
|
33
|
+
await syncLoopSummary(ctx, {
|
|
34
|
+
iterationCount,
|
|
35
|
+
completedTargets,
|
|
36
|
+
blockedTargets,
|
|
37
|
+
noOpTargets,
|
|
38
|
+
lastCompletedTarget
|
|
39
|
+
});
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (result.summary.includes('no-op')) {
|
|
43
|
+
noOpTargets += 1;
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
blockedTargets += 1;
|
|
47
|
+
}
|
|
48
|
+
await syncLoopSummary(ctx, {
|
|
49
|
+
iterationCount,
|
|
50
|
+
completedTargets,
|
|
51
|
+
blockedTargets,
|
|
52
|
+
noOpTargets,
|
|
53
|
+
lastCompletedTarget
|
|
54
|
+
});
|
|
55
|
+
if (input.targetFile)
|
|
56
|
+
break;
|
|
57
|
+
if (blockedTargets >= Math.max(1, ctx.limits.maxRetryPerTask))
|
|
58
|
+
break;
|
|
59
|
+
if (noOpTargets >= Math.max(1, ctx.limits.loopThreshold))
|
|
60
|
+
break;
|
|
61
|
+
if (!result.targetFile)
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
const finalLoopSummary = {
|
|
65
|
+
iterationCount,
|
|
66
|
+
completedTargets,
|
|
67
|
+
blockedTargets,
|
|
68
|
+
noOpTargets,
|
|
69
|
+
lastCompletedTarget
|
|
70
|
+
};
|
|
71
|
+
await syncLoopSummary(ctx, finalLoopSummary);
|
|
72
|
+
const report = await buildFinalReport(ctx);
|
|
73
|
+
await ctx.reportStore.saveFinalReport(report);
|
|
74
|
+
if (!lastResult) {
|
|
75
|
+
return {
|
|
76
|
+
ok: false,
|
|
77
|
+
phase: 'BLOCKED',
|
|
78
|
+
summary: '自动执行未产生任何任务结果。',
|
|
79
|
+
nextAction: 'manual-intervention'
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
const summaryParts = [
|
|
83
|
+
`completed=${completedTargets}`,
|
|
84
|
+
`blocked=${blockedTargets}`,
|
|
85
|
+
`noop=${noOpTargets}`,
|
|
86
|
+
`iterations=${iterationCount}`
|
|
87
|
+
];
|
|
88
|
+
if (lastCoverage !== undefined) {
|
|
89
|
+
summaryParts.push(`coverage=${lastCoverage}/${ctx.coverageTarget}`);
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
...lastResult,
|
|
93
|
+
artifacts: {
|
|
94
|
+
...lastResult.artifacts,
|
|
95
|
+
finalReportPath: ctx.paths.finalReportPath,
|
|
96
|
+
loopSummary: JSON.stringify(finalLoopSummary)
|
|
97
|
+
},
|
|
98
|
+
summary: `自动执行结束:${summaryParts.join(', ')}`
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
async function syncLoopSummary(ctx, loopSummary) {
|
|
102
|
+
const state = await ctx.stateStore.load();
|
|
103
|
+
await ctx.stateStore.save({
|
|
104
|
+
...state,
|
|
105
|
+
loopSummary,
|
|
106
|
+
updatedAt: ctx.clock.nowIso()
|
|
107
|
+
});
|
|
108
|
+
}
|