@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,17 @@
1
+ import { Command } from 'commander'
2
+ import { createContext } from '../context/create-context.js'
3
+ import { verifyAll } from '../../core/tools/verify-all.js'
4
+
5
+ export function registerVerifyCommand(program: Command) {
6
+ program
7
+ .command('verify')
8
+ .option('--project <path>', 'project path')
9
+ .action(async (options) => {
10
+ const ctx = await createContext({
11
+ projectPath: options.project ?? process.cwd(),
12
+ coverageTarget: 80
13
+ })
14
+ const result = await verifyAll(ctx)
15
+ console.log(result.summary)
16
+ })
17
+ }
@@ -0,0 +1,142 @@
1
+ import path from 'node:path'
2
+ import type { TestBotConfig, RuntimePaths, RuntimeLimits, CommandSet, PromptBuilderSet, StateFile, AppRuntimeCache } from '../../types/index.js'
3
+ import type { Logger } from '../../types/logger.js'
4
+ import { buildPaths } from '../../utils/paths.js'
5
+ import { createFileSystem } from '../../utils/fs.js'
6
+ import { createClock } from '../../utils/clock.js'
7
+ import { createCommandRunner } from '../../utils/command-runner.js'
8
+ import { createStateStore } from '../../core/storage/state-store.js'
9
+ import { createReportStore } from '../../core/storage/report-store.js'
10
+ import { createEventStore } from '../../core/storage/event-store.js'
11
+ import { createLifecycleStore } from '../../core/storage/lifecycle-store.js'
12
+ import { createLogger } from '../../utils/logger.js'
13
+ import { buildCommandSet } from '../../utils/commands.js'
14
+ import { buildPromptBuilders } from '../../utils/prompts.js'
15
+
16
+ export interface CreateContextInput {
17
+ projectPath: string
18
+ coverageTarget: number
19
+ configOverrides?: Partial<TestBotConfig>
20
+ }
21
+
22
+ export interface AppContext {
23
+ projectPath: string
24
+ coverageTarget: number
25
+ mode: 'serial' | 'parallel'
26
+ logger: Logger
27
+ clock: ReturnType<typeof createClock>
28
+ fileSystem: ReturnType<typeof createFileSystem>
29
+ commandRunner: ReturnType<typeof createCommandRunner>
30
+ config: TestBotConfig
31
+ paths: RuntimePaths
32
+ commands: CommandSet
33
+ prompts: PromptBuilderSet
34
+ limits: RuntimeLimits
35
+ cache: AppRuntimeCache
36
+ runId: string
37
+ stateStore: ReturnType<typeof createStateStore>
38
+ eventStore: ReturnType<typeof createEventStore>
39
+ reportStore: ReturnType<typeof createReportStore>
40
+ lifecycleStore: ReturnType<typeof createLifecycleStore>
41
+ }
42
+
43
+ export async function createContext(input: CreateContextInput): Promise<AppContext> {
44
+ const projectPath = input.projectPath
45
+ const paths = buildPaths(projectPath)
46
+ const fileSystem = createFileSystem()
47
+ const persistedConfig = await fileSystem.readJson<TestBotConfig>(paths.configPath)
48
+ const config = await loadConfig(projectPath, input.coverageTarget, persistedConfig, input.configOverrides)
49
+ const projectPackageJson = await fileSystem.readJson<{ scripts?: Record<string, string> }>(path.join(projectPath, 'package.json'))
50
+ const logger = createLogger(config.logLevel ?? 'info')
51
+ const clock = createClock()
52
+ const commandRunner = createCommandRunner({ cwd: projectPath })
53
+ const commands = buildCommandSet(config, projectPackageJson?.scripts ?? {})
54
+ const prompts = buildPromptBuilders(config.promptOverrides)
55
+ const limits: RuntimeLimits = {
56
+ maxIterationsPerRun: config.maxIterationsPerRun ?? 5,
57
+ maxRetryPerTask: config.maxRetryPerTask ?? 2,
58
+ loopThreshold: config.loopThreshold ?? 2,
59
+ concurrency: config.concurrency ?? 8,
60
+ llmTopN: config.llmTopN ?? 20,
61
+ llmAdjustRange: config.llmAdjustRange ?? 10
62
+ }
63
+
64
+ const initialState: StateFile = {
65
+ phase: 'INIT',
66
+ status: 'idle',
67
+ totals: {
68
+ pending: 0,
69
+ running: 0,
70
+ passed: 0,
71
+ failed: 0,
72
+ blocked: 0
73
+ },
74
+ lastAction: 'init',
75
+ nextSuggestedAction: 'analyze',
76
+ updatedAt: clock.nowIso()
77
+ }
78
+
79
+ const cache: AppRuntimeCache = {}
80
+ const runId = buildRunId(clock.nowIso())
81
+
82
+ return {
83
+ projectPath,
84
+ coverageTarget: config.coverageTarget,
85
+ mode: config.mode,
86
+ logger,
87
+ clock,
88
+ fileSystem,
89
+ commandRunner,
90
+ config,
91
+ paths,
92
+ commands,
93
+ prompts,
94
+ limits,
95
+ cache,
96
+ runId,
97
+ stateStore: createStateStore(paths, fileSystem, initialState),
98
+ reportStore: createReportStore(paths, fileSystem),
99
+ eventStore: createEventStore(paths, fileSystem),
100
+ lifecycleStore: createLifecycleStore(paths, fileSystem)
101
+ }
102
+ }
103
+
104
+ function buildRunId(timestamp: string): string {
105
+ const compact = timestamp.replace(/[-:]/g, '').replace(/\..+$/, '')
106
+ return `r${compact}Z`
107
+ }
108
+
109
+ async function loadConfig(
110
+ projectPath: string,
111
+ coverageTarget: number,
112
+ persisted?: Partial<TestBotConfig>,
113
+ overrides?: Partial<TestBotConfig>
114
+ ): Promise<TestBotConfig> {
115
+ const defaultConfig: TestBotConfig = {
116
+ projectPath,
117
+ coverageTarget,
118
+ include: ['src/**/*'],
119
+ exclude: ['node_modules/**', 'dist/**', 'coverage/**'],
120
+ mode: 'serial',
121
+ concurrency: 1,
122
+ maxIterationsPerRun: 5,
123
+ maxRetryPerTask: 2,
124
+ loopThreshold: 2,
125
+ allowSourceEdit: false,
126
+ testDirNames: ['__test__', '__tests__'],
127
+ commandOverrides: {},
128
+ priorityPaths: [],
129
+ deferredPaths: [],
130
+ excludePaths: [],
131
+ llmTopN: 20,
132
+ llmAdjustRange: 10
133
+ }
134
+
135
+ return {
136
+ ...defaultConfig,
137
+ ...persisted,
138
+ ...overrides,
139
+ projectPath,
140
+ coverageTarget: overrides?.coverageTarget ?? persisted?.coverageTarget ?? coverageTarget
141
+ }
142
+ }
@@ -0,0 +1,27 @@
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
+
11
+ const program = new Command()
12
+
13
+ program
14
+ .name('testbot')
15
+ .description('Unit test auto-completion tool')
16
+ .version('0.1.0')
17
+
18
+ registerInitCommand(program)
19
+ registerAnalyzeCommand(program)
20
+ registerRunCommand(program)
21
+ registerVerifyCommand(program)
22
+ registerStatusCommand(program)
23
+ registerStartCommand(program)
24
+ registerGuardCommand(program)
25
+ registerScheduleCommand(program)
26
+
27
+ program.parseAsync(process.argv)
@@ -0,0 +1,5 @@
1
+ export function buildIncludeFromScanDir(scanDir?: string): string[] | undefined {
2
+ if (!scanDir) return undefined
3
+ const normalized = scanDir.replace(/\\/g, '/').replace(/\/+$/, '')
4
+ return normalized ? [`${normalized}/**/*`] : undefined
5
+ }
@@ -0,0 +1,10 @@
1
+ import type { CandidateFile, CoverageSnapshot } from '../../types/index.js'
2
+
3
+ export function coverageAnalyzer(candidate: CandidateFile, coverageSnapshot?: CoverageSnapshot) {
4
+ const gap = coverageSnapshot?.coverageGap ?? 0
5
+ return {
6
+ lineRate: coverageSnapshot?.lines,
7
+ branchRate: coverageSnapshot?.branches,
8
+ coverageGap: gap
9
+ }
10
+ }
@@ -0,0 +1,25 @@
1
+ import type { AppContext } from '../../cli/context/create-context.js'
2
+ import type { CandidateFile } from '../../types/index.js'
3
+
4
+ export async function dependencyComplexityAnalyzer(ctx: AppContext, candidate: CandidateFile) {
5
+ const content = await ctx.fileSystem.readText(`${ctx.projectPath}/${candidate.targetFile}`)
6
+ if (!content) {
7
+ return {
8
+ importCount: 0,
9
+ externalDependencyCount: 0,
10
+ complexityLevel: 'low' as const
11
+ }
12
+ }
13
+
14
+ const importLines = content.split('\n').filter((line) => line.startsWith('import') || line.includes('require('))
15
+ const importCount = importLines.length
16
+ const externalDependencyCount = importLines.filter((line) => line.includes("from '") || line.includes('require(')).length
17
+
18
+ const complexityLevel = importCount > 15 ? 'high' : importCount > 5 ? 'medium' : 'low'
19
+
20
+ return {
21
+ importCount,
22
+ externalDependencyCount,
23
+ complexityLevel: complexityLevel as 'low' | 'medium' | 'high'
24
+ }
25
+ }
@@ -0,0 +1,76 @@
1
+ import path from 'node:path'
2
+ import type { AppContext } from '../../cli/context/create-context.js'
3
+ import type { CandidateFile } from '../../types/index.js'
4
+ import fg from 'fast-glob'
5
+
6
+ export async function existingTestAnalyzer(ctx: AppContext, candidate: CandidateFile) {
7
+ const base = path.basename(candidate.targetFile, path.extname(candidate.targetFile))
8
+ const testFilesByBaseName = await getTestFilesByBaseName(ctx)
9
+ const matches = testFilesByBaseName[base] ?? []
10
+ const relatedMatches = matches.filter((file) => isRelatedTestPath(candidate.targetFile, file))
11
+
12
+ return {
13
+ hasExistingTest: relatedMatches.length > 0,
14
+ testFilePaths: relatedMatches,
15
+ partialCoverageLikely: relatedMatches.length > 0
16
+ }
17
+ }
18
+
19
+ function isRelatedTestPath(targetFile: string, testFile: string): boolean {
20
+ const normalizedTarget = normalizePath(targetFile)
21
+ const normalizedTest = normalizePath(testFile)
22
+ const targetDir = path.dirname(normalizedTarget)
23
+ const targetDirName = path.basename(targetDir)
24
+ const targetBase = path.basename(normalizedTarget, path.extname(normalizedTarget))
25
+ const testBase = path.basename(normalizedTest, path.extname(normalizedTest))
26
+
27
+ if (testBase === targetBase || testBase === `${targetBase}.test` || testBase === `${targetBase}.spec`) return true
28
+ if (normalizedTest.startsWith(`${targetDir}/`)) return true
29
+ if (normalizedTest.includes(`/${targetDirName}/`)) return true
30
+
31
+ return false
32
+ }
33
+
34
+ function normalizePath(filePath: string): string {
35
+ return filePath.replace(/\\/g, '/')
36
+ }
37
+
38
+ async function getTestFilesByBaseName(ctx: AppContext): Promise<Record<string, string[]>> {
39
+ if (ctx.cache.testFilesByBaseName) return ctx.cache.testFilesByBaseName
40
+
41
+ const testDirNames = await detectTestDirNames(ctx)
42
+ const patterns = [
43
+ '**/*.test.*',
44
+ '**/*.spec.*',
45
+ ...testDirNames.map((dir) => `**/${dir}/**/*.*`)
46
+ ]
47
+ const files = await fg(patterns, { cwd: ctx.projectPath, ignore: ctx.config.exclude, onlyFiles: true })
48
+
49
+ const index: Record<string, string[]> = {}
50
+
51
+ for (const file of files) {
52
+ const base = path.basename(file, path.extname(file))
53
+ if (!index[base]) index[base] = []
54
+ index[base].push(file)
55
+ }
56
+
57
+ ctx.cache.testFilesByBaseName = index
58
+ return index
59
+ }
60
+
61
+ async function detectTestDirNames(ctx: AppContext): Promise<string[]> {
62
+ if (ctx.cache.detectedTestDirNames) return ctx.cache.detectedTestDirNames
63
+
64
+ const configured = ctx.config.testDirNames?.filter(Boolean) ?? ['__test__', '__tests__']
65
+ const jestConfigPath = path.join(ctx.projectPath, 'jest.config.js')
66
+ const jestConfig = await ctx.fileSystem.readText(jestConfigPath)
67
+ if (!jestConfig) {
68
+ ctx.cache.detectedTestDirNames = configured
69
+ return configured
70
+ }
71
+
72
+ const detected = configured.filter((dir) => jestConfig.includes(dir))
73
+ const result = detected.length > 0 ? detected : configured
74
+ ctx.cache.detectedTestDirNames = result
75
+ return result
76
+ }
@@ -0,0 +1,12 @@
1
+ import type { AppContext } from '../../cli/context/create-context.js'
2
+ import type { CandidateFile } from '../../types/index.js'
3
+
4
+ export async function failureHistoryAnalyzer(_ctx: AppContext, _candidate: CandidateFile) {
5
+ return {
6
+ previousAttemptCount: 0,
7
+ loopDetected: false,
8
+ blockedBefore: false,
9
+ failurePenaltyScore: 0,
10
+ lastFailureCategory: undefined
11
+ }
12
+ }
@@ -0,0 +1,25 @@
1
+ import path from 'node:path'
2
+ import type { AppContext } from '../../cli/context/create-context.js'
3
+ import type { CandidateFile } from '../../types/index.js'
4
+
5
+ export async function fileClassifierAnalyzer(_ctx: AppContext, candidate: CandidateFile) {
6
+ const ext = path.extname(candidate.targetFile)
7
+ const lower = candidate.targetFile.toLowerCase()
8
+ const uiSignal = /component|view|widget/.test(lower)
9
+ const domSignal = /dom|browser/.test(lower)
10
+ const frameworkSignal = /tsx?$/.test(ext)
11
+
12
+ let ruleCategory = 'unknown'
13
+ if (/util|helper|math|logic|transform/.test(lower)) ruleCategory = 'pure-logic'
14
+ else if (/service|api/.test(lower)) ruleCategory = 'business-logic'
15
+ else if (uiSignal) ruleCategory = 'ui-heavy'
16
+ else if (/model|mapper|serializer/.test(lower)) ruleCategory = 'data-transform'
17
+
18
+ return {
19
+ ruleCategory,
20
+ semanticCategory: ruleCategory,
21
+ uiSignal,
22
+ domSignal,
23
+ frameworkSignal
24
+ }
25
+ }
@@ -0,0 +1,51 @@
1
+ import type { AnalyzerFacts, CandidateFile, CoverageSnapshot } from '../../types/index.js'
2
+ import type { AppContext } from '../../cli/context/create-context.js'
3
+ import { coverageAnalyzer } from './coverage-analyzer.js'
4
+ import { pathPriorityAnalyzer } from './path-priority-analyzer.js'
5
+ import { fileClassifierAnalyzer } from './file-classifier-analyzer.js'
6
+ import { dependencyComplexityAnalyzer } from './dependency-complexity-analyzer.js'
7
+ import { existingTestAnalyzer } from './existing-test-analyzer.js'
8
+ import { failureHistoryAnalyzer } from './failure-history-analyzer.js'
9
+ import { llmSemanticAnalyzer } from './llm-semantic-analyzer.js'
10
+
11
+ export interface AnalyzerResult {
12
+ facts: AnalyzerFacts
13
+ warnings: string[]
14
+ }
15
+
16
+ export async function runAnalyzers(
17
+ ctx: AppContext,
18
+ candidate: CandidateFile,
19
+ coverageSnapshot?: CoverageSnapshot
20
+ ): Promise<AnalyzerResult> {
21
+ const warnings: string[] = []
22
+
23
+ const coverage = coverageAnalyzer(candidate, coverageSnapshot)
24
+ const pathPriority = pathPriorityAnalyzer(candidate, ctx.config)
25
+ const existingTest = await existingTestAnalyzer(ctx, candidate)
26
+ const fileClassification = await fileClassifierAnalyzer(ctx, candidate)
27
+ const dependencyComplexity = await dependencyComplexityAnalyzer(ctx, candidate)
28
+ const failureHistory = await failureHistoryAnalyzer(ctx, candidate)
29
+
30
+ let llmSemantic: AnalyzerFacts['llmSemantic'] | undefined
31
+ try {
32
+ llmSemantic = await llmSemanticAnalyzer(ctx, candidate)
33
+ } catch (error) {
34
+ warnings.push(`llmSemanticAnalyzer failed: ${String(error)}`)
35
+ }
36
+
37
+ return {
38
+ facts: {
39
+ targetFile: candidate.targetFile,
40
+ coverage,
41
+ pathPriority,
42
+ existingTest,
43
+ fileClassification,
44
+ dependencyComplexity,
45
+ failureHistory,
46
+ llmSemantic,
47
+ analyzerWarnings: warnings
48
+ },
49
+ warnings
50
+ }
51
+ }
@@ -0,0 +1,6 @@
1
+ import type { AppContext } from '../../cli/context/create-context.js'
2
+ import type { CandidateFile } from '../../types/index.js'
3
+
4
+ export async function llmSemanticAnalyzer(_ctx: AppContext, _candidate: CandidateFile) {
5
+ return undefined
6
+ }
@@ -0,0 +1,41 @@
1
+ import type { CandidateFile, TestBotConfig } from '../../types/index.js'
2
+
3
+ export function pathPriorityAnalyzer(candidate: CandidateFile, config: TestBotConfig) {
4
+ const priorityPaths = config.priorityPaths ?? []
5
+ const deferredPaths = config.deferredPaths ?? []
6
+ const excludePaths = config.excludePaths ?? []
7
+
8
+ const target = candidate.targetFile
9
+
10
+ const matchedExclude = excludePaths.find((p) => target.startsWith(p))
11
+ if (matchedExclude) {
12
+ return {
13
+ label: 'excluded' as const,
14
+ score: -999,
15
+ matchedPath: matchedExclude
16
+ }
17
+ }
18
+
19
+ const matchedPriority = priorityPaths.find((p) => target.startsWith(p))
20
+ if (matchedPriority) {
21
+ return {
22
+ label: 'priority' as const,
23
+ score: 100,
24
+ matchedPath: matchedPriority
25
+ }
26
+ }
27
+
28
+ const matchedDeferred = deferredPaths.find((p) => target.startsWith(p))
29
+ if (matchedDeferred) {
30
+ return {
31
+ label: 'deferred' as const,
32
+ score: -100,
33
+ matchedPath: matchedDeferred
34
+ }
35
+ }
36
+
37
+ return {
38
+ label: 'normal' as const,
39
+ score: 0
40
+ }
41
+ }
@@ -0,0 +1,224 @@
1
+ import path from 'node:path'
2
+ import type { AppContext } from '../../cli/context/create-context.js'
3
+ import type { CoverageSnapshot } from '../../types/index.js'
4
+
5
+ const DEFAULT_COVERAGE_FILES = [
6
+ path.join('coverage', 'coverage-summary.json'),
7
+ path.join('coverage', 'coverage-final.json'),
8
+ 'coverage-summary.json',
9
+ 'coverage-final.json'
10
+ ]
11
+
12
+ const EXCLUDED_DIRS = new Set(['node_modules', '.git', '.unit_test_tool_workspace'])
13
+
14
+ type CoverageSummaryTotals = {
15
+ total?: {
16
+ lines?: { pct?: number }
17
+ statements?: { pct?: number }
18
+ functions?: { pct?: number }
19
+ branches?: { pct?: number }
20
+ }
21
+ lines?: { pct?: number }
22
+ statements?: { pct?: number }
23
+ functions?: { pct?: number }
24
+ branches?: { pct?: number }
25
+ }
26
+
27
+ type CoverageSummaryFileEntry = {
28
+ lines?: { pct?: number }
29
+ statements?: { pct?: number }
30
+ functions?: { pct?: number }
31
+ branches?: { pct?: number }
32
+ }
33
+
34
+ type CoverageFinalEntry = {
35
+ s?: Record<string, number>
36
+ f?: Record<string, number>
37
+ b?: Record<string, number[]>
38
+ l?: Record<string, number>
39
+ }
40
+
41
+ export async function runAndReadCoverage(ctx: AppContext): Promise<CoverageSnapshot> {
42
+ ctx.logger.info(`[coverage] step=run status=start cmd=${ctx.commands.analyzeBaseline}`)
43
+ const result = await ctx.commandRunner.run(ctx.commands.analyzeBaseline)
44
+ ctx.logger.info(`[coverage] step=run status=done exit=${result.exitCode}`)
45
+ if (result.exitCode !== 0) {
46
+ const stderr = result.stderr?.trim()
47
+ const stdout = result.stdout?.trim()
48
+ const message = stderr || stdout || 'coverage command failed'
49
+ throw new Error(message)
50
+ }
51
+
52
+ const summary = await readCoverageSummary(ctx)
53
+ if (!summary) {
54
+ const searched = await listCoverageSearchPaths(ctx)
55
+ throw new Error(
56
+ `coverage summary not found after command: ${ctx.commands.analyzeBaseline}. searched=${searched.join(', ') || 'none'}`
57
+ )
58
+ }
59
+ return summary
60
+ }
61
+
62
+ export async function readCoverageSummary(ctx: AppContext): Promise<CoverageSnapshot | undefined> {
63
+ const searchPaths = await listCoverageSearchPaths(ctx)
64
+ for (const relativePath of searchPaths) {
65
+ const fullPath = path.join(ctx.projectPath, relativePath)
66
+ const data = await ctx.fileSystem.readJson<Record<string, CoverageSummaryFileEntry | CoverageSummaryTotals | CoverageFinalEntry>>(fullPath)
67
+ if (!data) continue
68
+ const snapshot = parseCoverageSummary(data as Record<string, CoverageSummaryFileEntry | CoverageSummaryTotals>)
69
+ if (snapshot) return snapshot
70
+ const finalSnapshot = parseCoverageFinal(data as Record<string, CoverageFinalEntry>)
71
+ if (finalSnapshot) return finalSnapshot
72
+ }
73
+ return undefined
74
+ }
75
+
76
+ async function listCoverageSearchPaths(ctx: AppContext): Promise<string[]> {
77
+ const discovered = await findCoverageFiles(ctx.projectPath, '.')
78
+ return Array.from(new Set([...DEFAULT_COVERAGE_FILES, ...discovered]))
79
+ }
80
+
81
+ async function findCoverageFiles(projectPath: string, relativeDir: string): Promise<string[]> {
82
+ const fs = await import('node:fs/promises')
83
+ const dirPath = path.join(projectPath, relativeDir)
84
+ let entries: Array<{ name: string; isDirectory(): boolean; isFile(): boolean }>
85
+ try {
86
+ entries = await fs.readdir(dirPath, { withFileTypes: true })
87
+ } catch {
88
+ return []
89
+ }
90
+
91
+ const matches: string[] = []
92
+ for (const entry of entries) {
93
+ if (entry.isDirectory()) {
94
+ if (EXCLUDED_DIRS.has(entry.name)) continue
95
+ matches.push(...await findCoverageFiles(projectPath, path.join(relativeDir, entry.name)))
96
+ continue
97
+ }
98
+ if (!entry.isFile()) continue
99
+ if (entry.name !== 'coverage-summary.json' && entry.name !== 'coverage-final.json') continue
100
+ matches.push(path.join(relativeDir, entry.name).replace(/^\.\//, ''))
101
+ }
102
+ return matches
103
+ }
104
+
105
+ function parseCoverageSummary(
106
+ summary: Record<string, CoverageSummaryFileEntry | CoverageSummaryTotals>
107
+ ): CoverageSnapshot | undefined {
108
+ const totalEntry = summary.total as CoverageSummaryTotals | undefined
109
+ if (!totalEntry) return undefined
110
+
111
+ const totals = totalEntry.total ?? totalEntry
112
+ const snapshot: CoverageSnapshot = {
113
+ lines: totals.lines?.pct,
114
+ statements: totals.statements?.pct,
115
+ functions: totals.functions?.pct,
116
+ branches: totals.branches?.pct,
117
+ rawSummary: JSON.stringify(totals)
118
+ }
119
+
120
+ const fileEntries: CoverageSnapshot['fileEntries'] = {}
121
+ for (const [key, entry] of Object.entries(summary)) {
122
+ if (key === 'total') continue
123
+ const fileEntry = entry as CoverageSummaryFileEntry
124
+ fileEntries[key] = {
125
+ lines: fileEntry.lines?.pct,
126
+ statements: fileEntry.statements?.pct,
127
+ functions: fileEntry.functions?.pct,
128
+ branches: fileEntry.branches?.pct
129
+ }
130
+ }
131
+
132
+ snapshot.fileEntries = fileEntries
133
+ return snapshot
134
+ }
135
+
136
+ function parseCoverageFinal(finalReport: Record<string, CoverageFinalEntry>): CoverageSnapshot | undefined {
137
+ const summary = summarizeCoverageFinal(finalReport)
138
+ if (!summary) return undefined
139
+
140
+ return {
141
+ lines: summary.lines,
142
+ statements: summary.statements,
143
+ functions: summary.functions,
144
+ branches: summary.branches,
145
+ rawSummary: JSON.stringify(summary),
146
+ fileEntries: summary.fileEntries
147
+ }
148
+ }
149
+
150
+ function summarizeCoverageFinal(finalReport: Record<string, CoverageFinalEntry>) {
151
+ const files = Object.entries(finalReport).filter(([key]) => key !== 'total')
152
+ if (files.length === 0) return undefined
153
+
154
+ let totalStatements = 0
155
+ let coveredStatements = 0
156
+ let totalFunctions = 0
157
+ let coveredFunctions = 0
158
+ let totalBranches = 0
159
+ let coveredBranches = 0
160
+ let totalLines = 0
161
+ let coveredLines = 0
162
+
163
+ const fileEntries: CoverageSnapshot['fileEntries'] = {}
164
+
165
+ for (const [filePath, entry] of files) {
166
+ const statement = summarizeRecord(entry.s)
167
+ const fn = summarizeRecord(entry.f)
168
+ const branch = summarizeBranches(entry.b)
169
+ const line = summarizeRecord(entry.l)
170
+
171
+ totalStatements += statement.total
172
+ coveredStatements += statement.covered
173
+ totalFunctions += fn.total
174
+ coveredFunctions += fn.covered
175
+ totalBranches += branch.total
176
+ coveredBranches += branch.covered
177
+ totalLines += line.total
178
+ coveredLines += line.covered
179
+
180
+ fileEntries[filePath] = {
181
+ statements: toPct(statement.covered, statement.total),
182
+ functions: toPct(fn.covered, fn.total),
183
+ branches: toPct(branch.covered, branch.total),
184
+ lines: toPct(line.covered, line.total)
185
+ }
186
+ }
187
+
188
+ return {
189
+ statements: toPct(coveredStatements, totalStatements),
190
+ functions: toPct(coveredFunctions, totalFunctions),
191
+ branches: toPct(coveredBranches, totalBranches),
192
+ lines: toPct(coveredLines, totalLines),
193
+ fileEntries
194
+ }
195
+ }
196
+
197
+ function summarizeRecord(record?: Record<string, number>) {
198
+ if (!record) return { total: 0, covered: 0 }
199
+ let total = 0
200
+ let covered = 0
201
+ for (const value of Object.values(record)) {
202
+ total += 1
203
+ if (value > 0) covered += 1
204
+ }
205
+ return { total, covered }
206
+ }
207
+
208
+ function summarizeBranches(record?: Record<string, number[]>) {
209
+ if (!record) return { total: 0, covered: 0 }
210
+ let total = 0
211
+ let covered = 0
212
+ for (const values of Object.values(record)) {
213
+ total += values.length
214
+ for (const value of values) {
215
+ if (value > 0) covered += 1
216
+ }
217
+ }
218
+ return { total, covered }
219
+ }
220
+
221
+ function toPct(covered: number, total: number): number | undefined {
222
+ if (total === 0) return undefined
223
+ return Math.round((covered / total) * 10000) / 100
224
+ }