@angli/unit-test-tool 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/.claude/settings.local.json +14 -0
  2. package/README.md +232 -0
  3. package/dist/src/cli/commands/analyze.js +20 -0
  4. package/dist/src/cli/commands/guard.js +26 -0
  5. package/dist/src/cli/commands/init.js +39 -0
  6. package/dist/src/cli/commands/run.js +72 -0
  7. package/dist/src/cli/commands/schedule.js +29 -0
  8. package/dist/src/cli/commands/start.js +102 -0
  9. package/dist/src/cli/commands/status.js +27 -0
  10. package/dist/src/cli/commands/verify.js +15 -0
  11. package/dist/src/cli/context/create-context.js +101 -0
  12. package/dist/src/cli/index.js +23 -0
  13. package/dist/src/cli/utils/scan-dir.js +6 -0
  14. package/dist/src/core/analyzers/coverage-analyzer.js +8 -0
  15. package/dist/src/core/analyzers/dependency-complexity-analyzer.js +19 -0
  16. package/dist/src/core/analyzers/existing-test-analyzer.js +66 -0
  17. package/dist/src/core/analyzers/failure-history-analyzer.js +9 -0
  18. package/dist/src/core/analyzers/file-classifier-analyzer.js +24 -0
  19. package/dist/src/core/analyzers/index.js +37 -0
  20. package/dist/src/core/analyzers/llm-semantic-analyzer.js +3 -0
  21. package/dist/src/core/analyzers/path-priority-analyzer.js +34 -0
  22. package/dist/src/core/coverage/read-coverage-summary.js +183 -0
  23. package/dist/src/core/executor/claude-cli-executor.js +91 -0
  24. package/dist/src/core/middleware/loop-detection.js +6 -0
  25. package/dist/src/core/middleware/pre-completion-checklist.js +27 -0
  26. package/dist/src/core/middleware/silent-success-post-check.js +13 -0
  27. package/dist/src/core/planner/rank-candidates.js +3 -0
  28. package/dist/src/core/planner/rule-planner.js +41 -0
  29. package/dist/src/core/planner/score-candidate.js +49 -0
  30. package/dist/src/core/prompts/case-library.js +35 -0
  31. package/dist/src/core/prompts/edit-boundary-prompt.js +24 -0
  32. package/dist/src/core/prompts/retry-prompt.js +18 -0
  33. package/dist/src/core/prompts/system-prompt.js +27 -0
  34. package/dist/src/core/prompts/task-prompt.js +22 -0
  35. package/dist/src/core/reporter/index.js +48 -0
  36. package/dist/src/core/state-machine/index.js +12 -0
  37. package/dist/src/core/storage/defaults.js +16 -0
  38. package/dist/src/core/storage/event-store.js +16 -0
  39. package/dist/src/core/storage/lifecycle-store.js +18 -0
  40. package/dist/src/core/storage/report-store.js +16 -0
  41. package/dist/src/core/storage/state-store.js +11 -0
  42. package/dist/src/core/strategies/classify-failure.js +14 -0
  43. package/dist/src/core/strategies/switch-mock-strategy.js +9 -0
  44. package/dist/src/core/tools/analyze-baseline.js +54 -0
  45. package/dist/src/core/tools/guard.js +68 -0
  46. package/dist/src/core/tools/run-loop.js +108 -0
  47. package/dist/src/core/tools/run-with-claude-cli.js +645 -0
  48. package/dist/src/core/tools/verify-all.js +75 -0
  49. package/dist/src/core/worktrees/is-git-repo.js +10 -0
  50. package/dist/src/types/index.js +1 -0
  51. package/dist/src/types/logger.js +1 -0
  52. package/dist/src/utils/clock.js +10 -0
  53. package/dist/src/utils/command-runner.js +18 -0
  54. package/dist/src/utils/commands.js +28 -0
  55. package/dist/src/utils/duration.js +22 -0
  56. package/dist/src/utils/fs.js +53 -0
  57. package/dist/src/utils/logger.js +10 -0
  58. package/dist/src/utils/paths.js +21 -0
  59. package/dist/src/utils/process-lifecycle.js +74 -0
  60. package/dist/src/utils/prompts.js +20 -0
  61. package/dist/tests/core/create-context.test.js +41 -0
  62. package/dist/tests/core/default-state.test.js +10 -0
  63. package/dist/tests/core/failure-classification.test.js +7 -0
  64. package/dist/tests/core/loop-detection.test.js +7 -0
  65. package/dist/tests/core/paths.test.js +11 -0
  66. package/dist/tests/core/prompt-builders.test.js +33 -0
  67. package/dist/tests/core/score-candidate.test.js +28 -0
  68. package/dist/tests/core/state-machine.test.js +12 -0
  69. package/dist/tests/integration/status-report.test.js +21 -0
  70. package/docs/architecture.md +20 -0
  71. package/docs/demo.sh +266 -0
  72. package/docs/skill-integration.md +15 -0
  73. package/docs/state-machine.md +15 -0
  74. package/package.json +31 -0
  75. package/src/cli/commands/analyze.ts +22 -0
  76. package/src/cli/commands/guard.ts +28 -0
  77. package/src/cli/commands/init.ts +41 -0
  78. package/src/cli/commands/run.ts +79 -0
  79. package/src/cli/commands/schedule.ts +32 -0
  80. package/src/cli/commands/start.ts +111 -0
  81. package/src/cli/commands/status.ts +30 -0
  82. package/src/cli/commands/verify.ts +17 -0
  83. package/src/cli/context/create-context.ts +142 -0
  84. package/src/cli/index.ts +27 -0
  85. package/src/cli/utils/scan-dir.ts +5 -0
  86. package/src/core/analyzers/coverage-analyzer.ts +10 -0
  87. package/src/core/analyzers/dependency-complexity-analyzer.ts +25 -0
  88. package/src/core/analyzers/existing-test-analyzer.ts +76 -0
  89. package/src/core/analyzers/failure-history-analyzer.ts +12 -0
  90. package/src/core/analyzers/file-classifier-analyzer.ts +25 -0
  91. package/src/core/analyzers/index.ts +51 -0
  92. package/src/core/analyzers/llm-semantic-analyzer.ts +6 -0
  93. package/src/core/analyzers/path-priority-analyzer.ts +41 -0
  94. package/src/core/coverage/read-coverage-summary.ts +224 -0
  95. package/src/core/executor/claude-cli-executor.ts +94 -0
  96. package/src/core/middleware/loop-detection.ts +8 -0
  97. package/src/core/middleware/pre-completion-checklist.ts +32 -0
  98. package/src/core/middleware/silent-success-post-check.ts +16 -0
  99. package/src/core/planner/rank-candidates.ts +5 -0
  100. package/src/core/planner/rule-planner.ts +65 -0
  101. package/src/core/planner/score-candidate.ts +60 -0
  102. package/src/core/prompts/case-library.ts +36 -0
  103. package/src/core/prompts/edit-boundary-prompt.ts +26 -0
  104. package/src/core/prompts/retry-prompt.ts +22 -0
  105. package/src/core/prompts/system-prompt.ts +32 -0
  106. package/src/core/prompts/task-prompt.ts +26 -0
  107. package/src/core/reporter/index.ts +56 -0
  108. package/src/core/state-machine/index.ts +14 -0
  109. package/src/core/storage/defaults.ts +18 -0
  110. package/src/core/storage/event-store.ts +18 -0
  111. package/src/core/storage/lifecycle-store.ts +20 -0
  112. package/src/core/storage/report-store.ts +19 -0
  113. package/src/core/storage/state-store.ts +18 -0
  114. package/src/core/strategies/classify-failure.ts +9 -0
  115. package/src/core/strategies/switch-mock-strategy.ts +12 -0
  116. package/src/core/tools/analyze-baseline.ts +61 -0
  117. package/src/core/tools/guard.ts +89 -0
  118. package/src/core/tools/run-loop.ts +142 -0
  119. package/src/core/tools/run-with-claude-cli.ts +926 -0
  120. package/src/core/tools/verify-all.ts +83 -0
  121. package/src/core/worktrees/is-git-repo.ts +10 -0
  122. package/src/types/index.ts +291 -0
  123. package/src/types/logger.ts +6 -0
  124. package/src/utils/clock.ts +10 -0
  125. package/src/utils/command-runner.ts +24 -0
  126. package/src/utils/commands.ts +42 -0
  127. package/src/utils/duration.ts +20 -0
  128. package/src/utils/fs.ts +50 -0
  129. package/src/utils/logger.ts +12 -0
  130. package/src/utils/paths.ts +24 -0
  131. package/src/utils/process-lifecycle.ts +92 -0
  132. package/src/utils/prompts.ts +22 -0
  133. package/tests/core/create-context.test.ts +45 -0
  134. package/tests/core/default-state.test.ts +11 -0
  135. package/tests/core/failure-classification.test.ts +8 -0
  136. package/tests/core/loop-detection.test.ts +8 -0
  137. package/tests/core/paths.test.ts +13 -0
  138. package/tests/core/prompt-builders.test.ts +38 -0
  139. package/tests/core/score-candidate.test.ts +30 -0
  140. package/tests/core/state-machine.test.ts +14 -0
  141. package/tests/fixtures/simple-project/.openclaw-testbot/logs/events.jsonl +10 -0
  142. package/tests/fixtures/simple-project/.openclaw-testbot/plan.json +75 -0
  143. package/tests/fixtures/simple-project/.openclaw-testbot/reports/coverage-summary.json +9 -0
  144. package/tests/fixtures/simple-project/.openclaw-testbot/reports/final-report.json +14 -0
  145. package/tests/fixtures/simple-project/.openclaw-testbot/state.json +18 -0
  146. package/tests/fixtures/simple-project/coverage-summary.json +1 -0
  147. package/tests/fixtures/simple-project/package.json +8 -0
  148. package/tests/fixtures/simple-project/src/add.js +3 -0
  149. package/tests/fixtures/simple-project/test-runner.js +18 -0
  150. package/tests/integration/status-report.test.ts +24 -0
  151. package/tsconfig.json +18 -0
@@ -0,0 +1,101 @@
1
+ import path from 'node:path';
2
+ import { buildPaths } from '../../utils/paths.js';
3
+ import { createFileSystem } from '../../utils/fs.js';
4
+ import { createClock } from '../../utils/clock.js';
5
+ import { createCommandRunner } from '../../utils/command-runner.js';
6
+ import { createStateStore } from '../../core/storage/state-store.js';
7
+ import { createReportStore } from '../../core/storage/report-store.js';
8
+ import { createEventStore } from '../../core/storage/event-store.js';
9
+ import { createLifecycleStore } from '../../core/storage/lifecycle-store.js';
10
+ import { createLogger } from '../../utils/logger.js';
11
+ import { buildCommandSet } from '../../utils/commands.js';
12
+ import { buildPromptBuilders } from '../../utils/prompts.js';
13
+ export async function createContext(input) {
14
+ const projectPath = input.projectPath;
15
+ const paths = buildPaths(projectPath);
16
+ const fileSystem = createFileSystem();
17
+ const persistedConfig = await fileSystem.readJson(paths.configPath);
18
+ const config = await loadConfig(projectPath, input.coverageTarget, persistedConfig, input.configOverrides);
19
+ const projectPackageJson = await fileSystem.readJson(path.join(projectPath, 'package.json'));
20
+ const logger = createLogger(config.logLevel ?? 'info');
21
+ const clock = createClock();
22
+ const commandRunner = createCommandRunner({ cwd: projectPath });
23
+ const commands = buildCommandSet(config, projectPackageJson?.scripts ?? {});
24
+ const prompts = buildPromptBuilders(config.promptOverrides);
25
+ const limits = {
26
+ maxIterationsPerRun: config.maxIterationsPerRun ?? 5,
27
+ maxRetryPerTask: config.maxRetryPerTask ?? 2,
28
+ loopThreshold: config.loopThreshold ?? 2,
29
+ concurrency: config.concurrency ?? 8,
30
+ llmTopN: config.llmTopN ?? 20,
31
+ llmAdjustRange: config.llmAdjustRange ?? 10
32
+ };
33
+ const initialState = {
34
+ phase: 'INIT',
35
+ status: 'idle',
36
+ totals: {
37
+ pending: 0,
38
+ running: 0,
39
+ passed: 0,
40
+ failed: 0,
41
+ blocked: 0
42
+ },
43
+ lastAction: 'init',
44
+ nextSuggestedAction: 'analyze',
45
+ updatedAt: clock.nowIso()
46
+ };
47
+ const cache = {};
48
+ const runId = buildRunId(clock.nowIso());
49
+ return {
50
+ projectPath,
51
+ coverageTarget: config.coverageTarget,
52
+ mode: config.mode,
53
+ logger,
54
+ clock,
55
+ fileSystem,
56
+ commandRunner,
57
+ config,
58
+ paths,
59
+ commands,
60
+ prompts,
61
+ limits,
62
+ cache,
63
+ runId,
64
+ stateStore: createStateStore(paths, fileSystem, initialState),
65
+ reportStore: createReportStore(paths, fileSystem),
66
+ eventStore: createEventStore(paths, fileSystem),
67
+ lifecycleStore: createLifecycleStore(paths, fileSystem)
68
+ };
69
+ }
70
+ function buildRunId(timestamp) {
71
+ const compact = timestamp.replace(/[-:]/g, '').replace(/\..+$/, '');
72
+ return `r${compact}Z`;
73
+ }
74
+ async function loadConfig(projectPath, coverageTarget, persisted, overrides) {
75
+ const defaultConfig = {
76
+ projectPath,
77
+ coverageTarget,
78
+ include: ['src/**/*'],
79
+ exclude: ['node_modules/**', 'dist/**', 'coverage/**'],
80
+ mode: 'serial',
81
+ concurrency: 1,
82
+ maxIterationsPerRun: 5,
83
+ maxRetryPerTask: 2,
84
+ loopThreshold: 2,
85
+ allowSourceEdit: false,
86
+ testDirNames: ['__test__', '__tests__'],
87
+ commandOverrides: {},
88
+ priorityPaths: [],
89
+ deferredPaths: [],
90
+ excludePaths: [],
91
+ llmTopN: 20,
92
+ llmAdjustRange: 10
93
+ };
94
+ return {
95
+ ...defaultConfig,
96
+ ...persisted,
97
+ ...overrides,
98
+ projectPath,
99
+ coverageTarget: overrides?.coverageTarget ?? persisted?.coverageTarget ?? coverageTarget
100
+ };
101
+ }
@@ -0,0 +1,23 @@
1
+ import { Command } from 'commander';
2
+ import { registerInitCommand } from './commands/init.js';
3
+ import { registerAnalyzeCommand } from './commands/analyze.js';
4
+ import { registerVerifyCommand } from './commands/verify.js';
5
+ import { registerStatusCommand } from './commands/status.js';
6
+ import { registerStartCommand } from './commands/start.js';
7
+ import { registerRunCommand } from './commands/run.js';
8
+ import { registerGuardCommand } from './commands/guard.js';
9
+ import { registerScheduleCommand } from './commands/schedule.js';
10
+ const program = new Command();
11
+ program
12
+ .name('testbot')
13
+ .description('Unit test auto-completion tool')
14
+ .version('0.1.0');
15
+ registerInitCommand(program);
16
+ registerAnalyzeCommand(program);
17
+ registerRunCommand(program);
18
+ registerVerifyCommand(program);
19
+ registerStatusCommand(program);
20
+ registerStartCommand(program);
21
+ registerGuardCommand(program);
22
+ registerScheduleCommand(program);
23
+ program.parseAsync(process.argv);
@@ -0,0 +1,6 @@
1
+ export function buildIncludeFromScanDir(scanDir) {
2
+ if (!scanDir)
3
+ return undefined;
4
+ const normalized = scanDir.replace(/\\/g, '/').replace(/\/+$/, '');
5
+ return normalized ? [`${normalized}/**/*`] : undefined;
6
+ }
@@ -0,0 +1,8 @@
1
+ export function coverageAnalyzer(candidate, coverageSnapshot) {
2
+ const gap = coverageSnapshot?.coverageGap ?? 0;
3
+ return {
4
+ lineRate: coverageSnapshot?.lines,
5
+ branchRate: coverageSnapshot?.branches,
6
+ coverageGap: gap
7
+ };
8
+ }
@@ -0,0 +1,19 @@
1
+ export async function dependencyComplexityAnalyzer(ctx, candidate) {
2
+ const content = await ctx.fileSystem.readText(`${ctx.projectPath}/${candidate.targetFile}`);
3
+ if (!content) {
4
+ return {
5
+ importCount: 0,
6
+ externalDependencyCount: 0,
7
+ complexityLevel: 'low'
8
+ };
9
+ }
10
+ const importLines = content.split('\n').filter((line) => line.startsWith('import') || line.includes('require('));
11
+ const importCount = importLines.length;
12
+ const externalDependencyCount = importLines.filter((line) => line.includes("from '") || line.includes('require(')).length;
13
+ const complexityLevel = importCount > 15 ? 'high' : importCount > 5 ? 'medium' : 'low';
14
+ return {
15
+ importCount,
16
+ externalDependencyCount,
17
+ complexityLevel: complexityLevel
18
+ };
19
+ }
@@ -0,0 +1,66 @@
1
+ import path from 'node:path';
2
+ import fg from 'fast-glob';
3
+ export async function existingTestAnalyzer(ctx, candidate) {
4
+ const base = path.basename(candidate.targetFile, path.extname(candidate.targetFile));
5
+ const testFilesByBaseName = await getTestFilesByBaseName(ctx);
6
+ const matches = testFilesByBaseName[base] ?? [];
7
+ const relatedMatches = matches.filter((file) => isRelatedTestPath(candidate.targetFile, file));
8
+ return {
9
+ hasExistingTest: relatedMatches.length > 0,
10
+ testFilePaths: relatedMatches,
11
+ partialCoverageLikely: relatedMatches.length > 0
12
+ };
13
+ }
14
+ function isRelatedTestPath(targetFile, testFile) {
15
+ const normalizedTarget = normalizePath(targetFile);
16
+ const normalizedTest = normalizePath(testFile);
17
+ const targetDir = path.dirname(normalizedTarget);
18
+ const targetDirName = path.basename(targetDir);
19
+ const targetBase = path.basename(normalizedTarget, path.extname(normalizedTarget));
20
+ const testBase = path.basename(normalizedTest, path.extname(normalizedTest));
21
+ if (testBase === targetBase || testBase === `${targetBase}.test` || testBase === `${targetBase}.spec`)
22
+ return true;
23
+ if (normalizedTest.startsWith(`${targetDir}/`))
24
+ return true;
25
+ if (normalizedTest.includes(`/${targetDirName}/`))
26
+ return true;
27
+ return false;
28
+ }
29
+ function normalizePath(filePath) {
30
+ return filePath.replace(/\\/g, '/');
31
+ }
32
+ async function getTestFilesByBaseName(ctx) {
33
+ if (ctx.cache.testFilesByBaseName)
34
+ return ctx.cache.testFilesByBaseName;
35
+ const testDirNames = await detectTestDirNames(ctx);
36
+ const patterns = [
37
+ '**/*.test.*',
38
+ '**/*.spec.*',
39
+ ...testDirNames.map((dir) => `**/${dir}/**/*.*`)
40
+ ];
41
+ const files = await fg(patterns, { cwd: ctx.projectPath, ignore: ctx.config.exclude, onlyFiles: true });
42
+ const index = {};
43
+ for (const file of files) {
44
+ const base = path.basename(file, path.extname(file));
45
+ if (!index[base])
46
+ index[base] = [];
47
+ index[base].push(file);
48
+ }
49
+ ctx.cache.testFilesByBaseName = index;
50
+ return index;
51
+ }
52
+ async function detectTestDirNames(ctx) {
53
+ if (ctx.cache.detectedTestDirNames)
54
+ return ctx.cache.detectedTestDirNames;
55
+ const configured = ctx.config.testDirNames?.filter(Boolean) ?? ['__test__', '__tests__'];
56
+ const jestConfigPath = path.join(ctx.projectPath, 'jest.config.js');
57
+ const jestConfig = await ctx.fileSystem.readText(jestConfigPath);
58
+ if (!jestConfig) {
59
+ ctx.cache.detectedTestDirNames = configured;
60
+ return configured;
61
+ }
62
+ const detected = configured.filter((dir) => jestConfig.includes(dir));
63
+ const result = detected.length > 0 ? detected : configured;
64
+ ctx.cache.detectedTestDirNames = result;
65
+ return result;
66
+ }
@@ -0,0 +1,9 @@
1
+ export async function failureHistoryAnalyzer(_ctx, _candidate) {
2
+ return {
3
+ previousAttemptCount: 0,
4
+ loopDetected: false,
5
+ blockedBefore: false,
6
+ failurePenaltyScore: 0,
7
+ lastFailureCategory: undefined
8
+ };
9
+ }
@@ -0,0 +1,24 @@
1
+ import path from 'node:path';
2
+ export async function fileClassifierAnalyzer(_ctx, candidate) {
3
+ const ext = path.extname(candidate.targetFile);
4
+ const lower = candidate.targetFile.toLowerCase();
5
+ const uiSignal = /component|view|widget/.test(lower);
6
+ const domSignal = /dom|browser/.test(lower);
7
+ const frameworkSignal = /tsx?$/.test(ext);
8
+ let ruleCategory = 'unknown';
9
+ if (/util|helper|math|logic|transform/.test(lower))
10
+ ruleCategory = 'pure-logic';
11
+ else if (/service|api/.test(lower))
12
+ ruleCategory = 'business-logic';
13
+ else if (uiSignal)
14
+ ruleCategory = 'ui-heavy';
15
+ else if (/model|mapper|serializer/.test(lower))
16
+ ruleCategory = 'data-transform';
17
+ return {
18
+ ruleCategory,
19
+ semanticCategory: ruleCategory,
20
+ uiSignal,
21
+ domSignal,
22
+ frameworkSignal
23
+ };
24
+ }
@@ -0,0 +1,37 @@
1
+ import { coverageAnalyzer } from './coverage-analyzer.js';
2
+ import { pathPriorityAnalyzer } from './path-priority-analyzer.js';
3
+ import { fileClassifierAnalyzer } from './file-classifier-analyzer.js';
4
+ import { dependencyComplexityAnalyzer } from './dependency-complexity-analyzer.js';
5
+ import { existingTestAnalyzer } from './existing-test-analyzer.js';
6
+ import { failureHistoryAnalyzer } from './failure-history-analyzer.js';
7
+ import { llmSemanticAnalyzer } from './llm-semantic-analyzer.js';
8
+ export async function runAnalyzers(ctx, candidate, coverageSnapshot) {
9
+ const warnings = [];
10
+ const coverage = coverageAnalyzer(candidate, coverageSnapshot);
11
+ const pathPriority = pathPriorityAnalyzer(candidate, ctx.config);
12
+ const existingTest = await existingTestAnalyzer(ctx, candidate);
13
+ const fileClassification = await fileClassifierAnalyzer(ctx, candidate);
14
+ const dependencyComplexity = await dependencyComplexityAnalyzer(ctx, candidate);
15
+ const failureHistory = await failureHistoryAnalyzer(ctx, candidate);
16
+ let llmSemantic;
17
+ try {
18
+ llmSemantic = await llmSemanticAnalyzer(ctx, candidate);
19
+ }
20
+ catch (error) {
21
+ warnings.push(`llmSemanticAnalyzer failed: ${String(error)}`);
22
+ }
23
+ return {
24
+ facts: {
25
+ targetFile: candidate.targetFile,
26
+ coverage,
27
+ pathPriority,
28
+ existingTest,
29
+ fileClassification,
30
+ dependencyComplexity,
31
+ failureHistory,
32
+ llmSemantic,
33
+ analyzerWarnings: warnings
34
+ },
35
+ warnings
36
+ };
37
+ }
@@ -0,0 +1,3 @@
1
+ export async function llmSemanticAnalyzer(_ctx, _candidate) {
2
+ return undefined;
3
+ }
@@ -0,0 +1,34 @@
1
+ export function pathPriorityAnalyzer(candidate, config) {
2
+ const priorityPaths = config.priorityPaths ?? [];
3
+ const deferredPaths = config.deferredPaths ?? [];
4
+ const excludePaths = config.excludePaths ?? [];
5
+ const target = candidate.targetFile;
6
+ const matchedExclude = excludePaths.find((p) => target.startsWith(p));
7
+ if (matchedExclude) {
8
+ return {
9
+ label: 'excluded',
10
+ score: -999,
11
+ matchedPath: matchedExclude
12
+ };
13
+ }
14
+ const matchedPriority = priorityPaths.find((p) => target.startsWith(p));
15
+ if (matchedPriority) {
16
+ return {
17
+ label: 'priority',
18
+ score: 100,
19
+ matchedPath: matchedPriority
20
+ };
21
+ }
22
+ const matchedDeferred = deferredPaths.find((p) => target.startsWith(p));
23
+ if (matchedDeferred) {
24
+ return {
25
+ label: 'deferred',
26
+ score: -100,
27
+ matchedPath: matchedDeferred
28
+ };
29
+ }
30
+ return {
31
+ label: 'normal',
32
+ score: 0
33
+ };
34
+ }
@@ -0,0 +1,183 @@
1
+ import path from 'node:path';
2
+ const DEFAULT_COVERAGE_FILES = [
3
+ path.join('coverage', 'coverage-summary.json'),
4
+ path.join('coverage', 'coverage-final.json'),
5
+ 'coverage-summary.json',
6
+ 'coverage-final.json'
7
+ ];
8
+ const EXCLUDED_DIRS = new Set(['node_modules', '.git', '.unit_test_tool_workspace']);
9
+ export async function runAndReadCoverage(ctx) {
10
+ ctx.logger.info(`[coverage] step=run status=start cmd=${ctx.commands.analyzeBaseline}`);
11
+ const result = await ctx.commandRunner.run(ctx.commands.analyzeBaseline);
12
+ ctx.logger.info(`[coverage] step=run status=done exit=${result.exitCode}`);
13
+ if (result.exitCode !== 0) {
14
+ const stderr = result.stderr?.trim();
15
+ const stdout = result.stdout?.trim();
16
+ const message = stderr || stdout || 'coverage command failed';
17
+ throw new Error(message);
18
+ }
19
+ const summary = await readCoverageSummary(ctx);
20
+ if (!summary) {
21
+ const searched = await listCoverageSearchPaths(ctx);
22
+ throw new Error(`coverage summary not found after command: ${ctx.commands.analyzeBaseline}. searched=${searched.join(', ') || 'none'}`);
23
+ }
24
+ return summary;
25
+ }
26
+ export async function readCoverageSummary(ctx) {
27
+ const searchPaths = await listCoverageSearchPaths(ctx);
28
+ for (const relativePath of searchPaths) {
29
+ const fullPath = path.join(ctx.projectPath, relativePath);
30
+ const data = await ctx.fileSystem.readJson(fullPath);
31
+ if (!data)
32
+ continue;
33
+ const snapshot = parseCoverageSummary(data);
34
+ if (snapshot)
35
+ return snapshot;
36
+ const finalSnapshot = parseCoverageFinal(data);
37
+ if (finalSnapshot)
38
+ return finalSnapshot;
39
+ }
40
+ return undefined;
41
+ }
42
+ async function listCoverageSearchPaths(ctx) {
43
+ const discovered = await findCoverageFiles(ctx.projectPath, '.');
44
+ return Array.from(new Set([...DEFAULT_COVERAGE_FILES, ...discovered]));
45
+ }
46
+ async function findCoverageFiles(projectPath, relativeDir) {
47
+ const fs = await import('node:fs/promises');
48
+ const dirPath = path.join(projectPath, relativeDir);
49
+ let entries;
50
+ try {
51
+ entries = await fs.readdir(dirPath, { withFileTypes: true });
52
+ }
53
+ catch {
54
+ return [];
55
+ }
56
+ const matches = [];
57
+ for (const entry of entries) {
58
+ if (entry.isDirectory()) {
59
+ if (EXCLUDED_DIRS.has(entry.name))
60
+ continue;
61
+ matches.push(...await findCoverageFiles(projectPath, path.join(relativeDir, entry.name)));
62
+ continue;
63
+ }
64
+ if (!entry.isFile())
65
+ continue;
66
+ if (entry.name !== 'coverage-summary.json' && entry.name !== 'coverage-final.json')
67
+ continue;
68
+ matches.push(path.join(relativeDir, entry.name).replace(/^\.\//, ''));
69
+ }
70
+ return matches;
71
+ }
72
+ function parseCoverageSummary(summary) {
73
+ const totalEntry = summary.total;
74
+ if (!totalEntry)
75
+ return undefined;
76
+ const totals = totalEntry.total ?? totalEntry;
77
+ const snapshot = {
78
+ lines: totals.lines?.pct,
79
+ statements: totals.statements?.pct,
80
+ functions: totals.functions?.pct,
81
+ branches: totals.branches?.pct,
82
+ rawSummary: JSON.stringify(totals)
83
+ };
84
+ const fileEntries = {};
85
+ for (const [key, entry] of Object.entries(summary)) {
86
+ if (key === 'total')
87
+ continue;
88
+ const fileEntry = entry;
89
+ fileEntries[key] = {
90
+ lines: fileEntry.lines?.pct,
91
+ statements: fileEntry.statements?.pct,
92
+ functions: fileEntry.functions?.pct,
93
+ branches: fileEntry.branches?.pct
94
+ };
95
+ }
96
+ snapshot.fileEntries = fileEntries;
97
+ return snapshot;
98
+ }
99
+ function parseCoverageFinal(finalReport) {
100
+ const summary = summarizeCoverageFinal(finalReport);
101
+ if (!summary)
102
+ return undefined;
103
+ return {
104
+ lines: summary.lines,
105
+ statements: summary.statements,
106
+ functions: summary.functions,
107
+ branches: summary.branches,
108
+ rawSummary: JSON.stringify(summary),
109
+ fileEntries: summary.fileEntries
110
+ };
111
+ }
112
+ function summarizeCoverageFinal(finalReport) {
113
+ const files = Object.entries(finalReport).filter(([key]) => key !== 'total');
114
+ if (files.length === 0)
115
+ return undefined;
116
+ let totalStatements = 0;
117
+ let coveredStatements = 0;
118
+ let totalFunctions = 0;
119
+ let coveredFunctions = 0;
120
+ let totalBranches = 0;
121
+ let coveredBranches = 0;
122
+ let totalLines = 0;
123
+ let coveredLines = 0;
124
+ const fileEntries = {};
125
+ for (const [filePath, entry] of files) {
126
+ const statement = summarizeRecord(entry.s);
127
+ const fn = summarizeRecord(entry.f);
128
+ const branch = summarizeBranches(entry.b);
129
+ const line = summarizeRecord(entry.l);
130
+ totalStatements += statement.total;
131
+ coveredStatements += statement.covered;
132
+ totalFunctions += fn.total;
133
+ coveredFunctions += fn.covered;
134
+ totalBranches += branch.total;
135
+ coveredBranches += branch.covered;
136
+ totalLines += line.total;
137
+ coveredLines += line.covered;
138
+ fileEntries[filePath] = {
139
+ statements: toPct(statement.covered, statement.total),
140
+ functions: toPct(fn.covered, fn.total),
141
+ branches: toPct(branch.covered, branch.total),
142
+ lines: toPct(line.covered, line.total)
143
+ };
144
+ }
145
+ return {
146
+ statements: toPct(coveredStatements, totalStatements),
147
+ functions: toPct(coveredFunctions, totalFunctions),
148
+ branches: toPct(coveredBranches, totalBranches),
149
+ lines: toPct(coveredLines, totalLines),
150
+ fileEntries
151
+ };
152
+ }
153
+ function summarizeRecord(record) {
154
+ if (!record)
155
+ return { total: 0, covered: 0 };
156
+ let total = 0;
157
+ let covered = 0;
158
+ for (const value of Object.values(record)) {
159
+ total += 1;
160
+ if (value > 0)
161
+ covered += 1;
162
+ }
163
+ return { total, covered };
164
+ }
165
+ function summarizeBranches(record) {
166
+ if (!record)
167
+ return { total: 0, covered: 0 };
168
+ let total = 0;
169
+ let covered = 0;
170
+ for (const values of Object.values(record)) {
171
+ total += values.length;
172
+ for (const value of values) {
173
+ if (value > 0)
174
+ covered += 1;
175
+ }
176
+ }
177
+ return { total, covered };
178
+ }
179
+ function toPct(covered, total) {
180
+ if (total === 0)
181
+ return undefined;
182
+ return Math.round((covered / total) * 10000) / 100;
183
+ }
@@ -0,0 +1,91 @@
1
+ import { execa } from 'execa';
2
+ import path from 'node:path';
3
+ import fs from 'node:fs/promises';
4
+ export async function executeClaudeCli(input) {
5
+ const startedAt = Date.now();
6
+ const heartbeatMs = 10000;
7
+ let heartbeat;
8
+ try {
9
+ if (input.onProgress) {
10
+ heartbeat = setInterval(() => {
11
+ input.onProgress?.(Date.now() - startedAt);
12
+ }, heartbeatMs);
13
+ }
14
+ const args = ['-p', input.prompt];
15
+ if (input.outputFormat)
16
+ args.push('--output-format', input.outputFormat);
17
+ if (input.systemPrompt)
18
+ args.push('--system-prompt', input.systemPrompt);
19
+ if (input.appendSystemPrompt)
20
+ args.push('--append-system-prompt', input.appendSystemPrompt);
21
+ if (input.allowedTools?.length)
22
+ args.push('--allowedTools', input.allowedTools.join(','));
23
+ if (input.permissionMode)
24
+ args.push('--permission-mode', input.permissionMode);
25
+ if (input.timeoutMs)
26
+ args.push('--timeout', String(input.timeoutMs));
27
+ const result = await execa('claude', args, {
28
+ cwd: input.cwd,
29
+ reject: false,
30
+ timeout: input.timeoutMs ?? 120000,
31
+ stdin: 'ignore',
32
+ env: await buildClaudeEnv(input.cwd)
33
+ });
34
+ if (heartbeat)
35
+ clearInterval(heartbeat);
36
+ return {
37
+ ok: result.exitCode === 0,
38
+ exitCode: result.exitCode ?? null,
39
+ stdout: result.stdout,
40
+ stderr: result.stderr,
41
+ durationMs: Date.now() - startedAt,
42
+ taskMeta: input.taskMeta,
43
+ failureCategory: result.exitCode === 0 ? undefined : 'CLI_EXIT_NON_ZERO'
44
+ };
45
+ }
46
+ catch (error) {
47
+ if (heartbeat)
48
+ clearInterval(heartbeat);
49
+ const message = error instanceof Error ? error.message : String(error);
50
+ const category = message.includes('ENOENT')
51
+ ? 'CLI_NOT_FOUND'
52
+ : message.toLowerCase().includes('timed out')
53
+ ? 'CLI_TIMEOUT'
54
+ : 'RUNTIME_ENV_ERROR';
55
+ return {
56
+ ok: false,
57
+ exitCode: null,
58
+ stdout: '',
59
+ stderr: message,
60
+ durationMs: Date.now() - startedAt,
61
+ failureCategory: category,
62
+ taskMeta: input.taskMeta
63
+ };
64
+ }
65
+ }
66
+ async function buildClaudeEnv(cwd) {
67
+ const projectEnv = await loadProjectClaudeEnv(cwd);
68
+ return {
69
+ ...process.env,
70
+ ...(process.env.ANTHROPIC_BASE_URL ? { ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL } : {}),
71
+ ...(process.env.ANTHROPIC_AUTH_TOKEN ? { ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN } : {}),
72
+ ...projectEnv
73
+ };
74
+ }
75
+ async function loadProjectClaudeEnv(cwd) {
76
+ try {
77
+ const configPath = path.join(cwd, '.claude', 'settings.local.json');
78
+ const raw = await fs.readFile(configPath, 'utf-8');
79
+ const parsed = JSON.parse(raw);
80
+ const env = parsed?.env ?? {};
81
+ const result = {};
82
+ if (env.ANTHROPIC_BASE_URL)
83
+ result.ANTHROPIC_BASE_URL = env.ANTHROPIC_BASE_URL;
84
+ if (env.ANTHROPIC_AUTH_TOKEN)
85
+ result.ANTHROPIC_AUTH_TOKEN = env.ANTHROPIC_AUTH_TOKEN;
86
+ return result;
87
+ }
88
+ catch {
89
+ return {};
90
+ }
91
+ }
@@ -0,0 +1,6 @@
1
+ export function loopDetection(task, threshold) {
2
+ if (task.attemptCount >= threshold) {
3
+ return { loopDetected: true };
4
+ }
5
+ return { loopDetected: false };
6
+ }
@@ -0,0 +1,27 @@
1
+ export async function preCompletionChecklist(ctx) {
2
+ const lint = await ctx.commandRunner.run(ctx.commands.lint);
3
+ if (lint.exitCode !== 0) {
4
+ return {
5
+ ok: false,
6
+ phase: 'VERIFYING_FULL',
7
+ summary: 'lint 失败',
8
+ nextAction: 'manual-intervention',
9
+ artifacts: { stderr: lint.stderr }
10
+ };
11
+ }
12
+ const typecheck = await ctx.commandRunner.run(ctx.commands.typecheck);
13
+ if (typecheck.exitCode !== 0) {
14
+ return {
15
+ ok: false,
16
+ phase: 'VERIFYING_FULL',
17
+ summary: 'typecheck 失败',
18
+ nextAction: 'manual-intervention',
19
+ artifacts: { stderr: typecheck.stderr }
20
+ };
21
+ }
22
+ return {
23
+ ok: true,
24
+ phase: 'VERIFYING_FULL',
25
+ summary: 'checklist passed'
26
+ };
27
+ }
@@ -0,0 +1,13 @@
1
+ export async function silentSuccessPostCheck(ctx, targetFile) {
2
+ const result = await ctx.commandRunner.run(ctx.commands.incrementalTest(targetFile));
3
+ return {
4
+ ok: result.exitCode === 0,
5
+ phase: 'VERIFYING_FULL',
6
+ summary: result.exitCode === 0 ? 'incremental check passed' : 'incremental check failed',
7
+ nextAction: result.exitCode === 0 ? 'verify' : 'manual-intervention',
8
+ artifacts: {
9
+ stdout: result.stdout,
10
+ stderr: result.stderr
11
+ }
12
+ };
13
+ }
@@ -0,0 +1,3 @@
1
+ export function rankCandidates(candidates) {
2
+ return [...candidates].sort((a, b) => b.baseScore - a.baseScore);
3
+ }