@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,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
|
+
}
|
package/src/cli/index.ts
ADDED
|
@@ -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,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,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
|
+
}
|