Distills Gradle output for Android builds: Kotlin, KSP, Hilt/Dagger, Room, AAPT, R8 and test failures as file:line lists with fix hints, and flags a failure…

A Claude Code mod for Android and KMP projects. When Claude runs Gradle through Bash, the mod swaps the console output for a short digest, so a build no longer floods the context window.
gradlew or gradle get timeout: 600000 and run_in_background: false. The mod also appends --console=plain when the Gradle call ends the command line, but only if $.tool.check gives the new command the same permission verdict as the original.e: file:///…:L:C, plus the 1.x format)[Dagger/MissingBinding], with their traceMissing class errorsfile:line errors from lint, detekt and ktlint* Where: and * What went wrong: blocksThe model reads the digest plus two context notes: the files to open, in order, and fix hints for each kind of error found.
build/test-results/**/TEST-*.xml files and summarizes them per module, with each failure's assertion message and source line. If tests failed but no new XML appeared, the raw output passes through.dependencies, tasks, signingReport, properties, --version, --continuous and similar) pass through untouched. So do small passing builds and anything the mod doesn't recognize./build command. It opens a pane with one Code element per error. Each element shows the lines around the error.$.model.classify runs only for a failure that no pattern explains.When Bash didn't save the full output to a file, the mod writes a copy to build/build-output-distiller/last-run.log under the Gradle project. The digest names this file so the model can read the full output.
claude plugin validate .
claude plugin test .
tsc -p . type-checks the mod once the engine has written .claude-plugin/types/.
hooks/register.ts 218 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, ToolCallResult } from 'claude-code'
3
4import type { BuildError, LastBuild, LastFailure } from '../types'
5
6import { analyzeCommand, runsTests, taskModuleDir, isTestTask } from './command'
7import { CLASSIFY_HINTS, buildDigest, fileLines, fixHints, type ModuleTests } from './digest'
8import { cleanLines, failingTask, parseOutput, type ParsedOutput } from './parse'
9import { join, relativeTo, resolve } from './paths'
10import { registerPane } from './pane'
11import { collectTests } from './test-results'
12
13/** The longest a foreground Bash call may run; Gradle gets all of it. */
14const FOREGROUND_MS = 600_000
15/** A passing run this small reads fine as it is. */
16const SMALL_LINES = 60
17const SMALL_CHARS = 6_000
18const LOG = 'build/build-output-distiller/last-run.log'
19
20// Defined where they are used: `claude plugin validate` reads each module's
21// state references from that module's own source.
22const lastFailure = atom({ plugin: 'build-output-distiller', key: 'lastFailure' } as const, null as LastFailure | null)
23const lastBuild = atom({ plugin: 'build-output-distiller', key: 'lastBuild' } as const, null as LastBuild | null)
24
25type RawOutput = { text: string; isError: boolean; exitCode?: number; savedAt?: string }
26
27export const register: Register = on => {
28 registerPane(on)
29
30 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
31 const command = analyzeCommand(e.command)
32 if (command === undefined || command.isPassthrough) return next(e)
33
34 const startedAt = await $.clock.now()
35 const ran = await next({
36 ...e,
37 command: await sameVerdict($, e.command, command.rewritten),
38 timeout: FOREGROUND_MS,
39 run_in_background: false,
40 })
41 if (ran.deny !== undefined) return ran
42
43 const raw = await rawOutput($, ran)
44 if (raw === undefined) return ran
45
46 const parsed = parseOutput(raw.text)
47 const isFailed = raw.isError || parsed.outcome === 'failed'
48 const isRecognized = parsed.outcome !== 'unknown' || parsed.errors.length > 0 || parsed.wentWrong.length > 0
49 if (!isRecognized) return ran
50
51 const cwd = await $.session.cwd()
52 const root = command.workDir === undefined ? cwd : resolve(cwd, command.workDir)
53 const rel = (path: string) => relativeTo(cwd, path)
54
55 let tests: ModuleTests[] | undefined
56 if (command.tasks.some(runsTests) || parsed.testModuleDirs.length > 0 || parsed.testReportDirs.length > 0) {
57 const asked = command.tasks.filter(isTestTask).map(task => taskModuleDir(task))
58 tests = await collectTests($, {
59 root,
60 cwd,
61 since: startedAt,
62 moduleDirs: [...parsed.testModuleDirs, ...asked.filter((dir): dir is string => dir !== undefined)],
63 reportDirs: parsed.testReportDirs,
64 })
65 // Failing tests with no new XML to read: the console is all there is.
66 if (tests.length === 0 && parsed.hasFailingTests && parsed.errors.length === 0) return ran
67 }
68
69 const isSmall = parsed.lineCount <= SMALL_LINES && raw.text.length <= SMALL_CHARS
70 if (!isFailed && isSmall) return ran
71
72 const errors = [...parsed.errors.map(error => relativeError(error, rel)), ...testErrors(tests ?? [])]
73 const logPath = raw.savedAt ?? (await saveLog($, root, raw.text).then(path => path && rel(path)))
74 const digest = buildDigest({ parsed, isFailed, exitCode: raw.exitCode, errors, tests, logPath })
75
76 const context: string[] = []
77 if (isFailed) {
78 const repeated = await noteFailure($, parsed, errors)
79 if (repeated !== undefined) context.push(repeated)
80
81 const spots = fileLines(errors)
82 context.push(
83 spots.length > 0
84 ? `build-output-distiller: files to open, in order: ${spots.slice(0, 15).join(', ')}`
85 : 'build-output-distiller: the output names no source file; start from "What went wrong".',
86 )
87 const hints = fixHints(errors, parsed)
88 if (hints.length === 0 && errors.length === 0) {
89 const guess = await classify($, parsed, raw.text)
90 if (guess !== undefined) hints.push(guess)
91 }
92 if (hints.length > 0) context.push(`build-output-distiller fix hints:\n${hints.map(hint => `- ${hint}`).join('\n')}`)
93 } else if ((await read($, lastFailure)) !== null) {
94 await update($, lastFailure, () => null)
95 }
96
97 await update($, lastBuild, () => ({
98 command: e.command,
99 headline: digest.split('\n')[0] ?? '',
100 isFailed,
101 errors: errors.slice(0, 50),
102 at: startedAt,
103 }))
104
105 // A fresh record: `ran` carries core's `ref`, and core would use that
106 // run's own output in place of this one.
107 if (ran.isError) {
108 return { result: { stdout: digest, stderr: '', interrupted: false }, context }
109 }
110 const { persistedOutputPath, persistedOutputSize, rawOutputPath, ...record } = ran.result
111 return {
112 result: { ...record, stdout: digest, stderr: '' },
113 ...(context.length > 0 ? { context } : {}),
114 }
115 }).catch(($, e, next) => next(e))
116}
117
118/** The whole console output of the call, or undefined when it is not a
119 * finished foreground run (backgrounded, timed out, an image). */
120async function rawOutput($: EngineInterface, ran: ToolCallResult<'Bash'>): Promise<RawOutput | undefined> {
121 if (ran.deny !== undefined) return undefined
122 if (ran.isError) {
123 const stored = typeof ran.result === 'string' ? ran.result : ''
124 const shown = ran.text ?? ''
125 const savedAt = /Full output saved to: (\S+)/.exec(`${shown}\n${stored}`)?.[1]
126 const saved = savedAt === undefined ? undefined : await $.fs.read(savedAt).catch(() => undefined)
127 const text = typeof saved === 'string' ? saved : stored.length >= shown.length ? stored : shown
128 const exitCode = /^Exit code (\d+)/m.exec(`${shown}\n${stored}`)?.[1]
129 return { text, isError: true, exitCode: exitCode === undefined ? undefined : Number(exitCode), savedAt: typeof saved === 'string' ? savedAt : undefined }
130 }
131
132 const record = ran.result
133 if (record.backgroundTaskId !== undefined || record.timedOutAfterMs !== undefined || record.interrupted || record.isImage) {
134 return undefined
135 }
136 if (record.persistedOutputPath !== undefined) {
137 const saved = await $.fs.read(record.persistedOutputPath).catch(() => undefined)
138 if (typeof saved === 'string') return { text: saved, isError: false, savedAt: record.persistedOutputPath }
139 }
140 return { text: record.stderr ? `${record.stdout}\n${record.stderr}` : record.stdout, isError: false }
141}
142
143/** `rewritten` when the permission rules treat it as they treat `original`
144 * (an exact-match rule would not match the appended flag), else `original`.
145 * A `$.tool.check` query runs no settings hook, so asking has no side effect. */
146async function sameVerdict($: EngineInterface, original: string, rewritten: string): Promise<string> {
147 if (rewritten === original) return original
148 const verdicts = await Promise.all([
149 $.tool.check({ tool: 'Bash', input: { command: original } }),
150 $.tool.check({ tool: 'Bash', input: { command: rewritten } }),
151 ]).catch(() => undefined)
152 return verdicts !== undefined && verdicts[0].decision === verdicts[1].decision ? rewritten : original
153}
154
155/** Records this failure; the context line to add when it is the last one again. */
156async function noteFailure($: EngineInterface, parsed: ParsedOutput, errors: BuildError[]): Promise<string | undefined> {
157 const task = failingTask(parsed)
158 const first = errors[0]
159 const firstError = first
160 ? `${first.file ? `${first.file}: ` : ''}${first.message}`
161 : (parsed.wentWrong[0]?.find(line => line.trim() !== '') ?? 'no error line').trim()
162 const hash = fnv1a(`${task}\n${first?.kind ?? ''}\n${first?.file ?? ''}\n${first?.message ?? firstError}`)
163
164 const previous = await read($, lastFailure)
165 const repeats = previous !== null && previous.hash === hash ? previous.repeats + 1 : 0
166 await update($, lastFailure, () => ({ hash, task, firstError, repeats }))
167 if (repeats === 0) return undefined
168
169 $.ui.toast(`Same Gradle failure again (${repeats + 1} runs in a row): ${task}`)
170 return (
171 `build-output-distiller: the last fix did not take. This is the same failure as the previous Gradle run` +
172 `${repeats > 1 ? ` (${repeats + 1} runs in a row)` : ''}: ${task}: ${firstError.replace(/\.$/, '')}. ` +
173 `Re-read the code at that location and reconsider the cause before editing again.`
174 )
175}
176
177/** One label from the model, for a failure no pattern explains. */
178async function classify($: EngineInterface, parsed: ParsedOutput, text: string): Promise<string | undefined> {
179 const evidence = parsed.wentWrong.length > 0 ? parsed.wentWrong.flat().join('\n') : cleanLines(text).slice(-40).join('\n')
180 const label = await $.model.classify(evidence.slice(0, 4_000), Object.keys(CLASSIFY_HINTS)).catch(() => undefined)
181 return label === undefined ? undefined : CLASSIFY_HINTS[label]
182}
183
184/** Writes the whole output under the project's build directory; its path, or
185 * undefined when it could not be written. */
186async function saveLog($: EngineInterface, root: string, text: string): Promise<string | undefined> {
187 const path = join(root, LOG)
188 return $.fs.write(path, text).then(
189 () => path,
190 () => undefined,
191 )
192}
193
194function relativeError(error: BuildError, rel: (path: string) => string): BuildError {
195 return error.file === undefined ? error : { ...error, file: rel(error.file) }
196}
197
198function testErrors(tests: ModuleTests[]): BuildError[] {
199 return tests.flatMap(module =>
200 module.failures.map(failure => ({
201 kind: 'test' as const,
202 file: failure.path ?? failure.file,
203 line: failure.line,
204 message: `${failure.className} > ${failure.name}: ${failure.message}`,
205 detail: failure.trace.slice(1, 6),
206 })),
207 )
208}
209
210function fnv1a(text: string): string {
211 let hash = 0x811c9dc5
212 for (let i = 0; i < text.length; i++) {
213 hash ^= text.charCodeAt(i)
214 hash = Math.imul(hash, 0x01000193) >>> 0
215 }
216 return hash.toString(16).padStart(8, '0')
217}
218hooks/command.ts 164 lines1// Reads a Bash command line for a Gradle run: which tasks it asks for, where
2// it runs, and whether its output is the answer the model wanted.
3
4export type GradleCommand = {
5 /** The task names asked for (`:app:assembleDebug`, `test`). */
6 tasks: string[]
7 flags: string[]
8 /** A leading `cd <dir> &&`, then `-p <dir>`, joined; undefined for none. */
9 workDir?: string
10 /** True when the output is the answer (`dependencies`, `--version`) or
11 * the run never ends (`--continuous`): leave the call untouched. */
12 isPassthrough: boolean
13 /** The command to run: the original, `--console=plain` appended when the
14 * Gradle call ends the line and that cannot change what a rule matches. */
15 rewritten: string
16}
17
18const GRADLE_BINARIES = new Set(['gradlew', 'gradlew.bat', 'gradle', 'gradle.bat'])
19const WRAPPERS = new Set(['time', 'nohup', 'exec', 'command', 'env', 'sh', 'bash', 'zsh', 'caffeinate'])
20
21/** Tasks whose printed output is what the model asked for. */
22const ANSWER_TASKS = new Set([
23 'dependencies',
24 'dependencyInsight',
25 'tasks',
26 'signingReport',
27 'properties',
28 'help',
29 'projects',
30 'buildEnvironment',
31 'outgoingVariants',
32 'resolvableConfigurations',
33 'javaToolchains',
34 'androidDependencies',
35 'sourceSets',
36 'components',
37 'model',
38 'wrapper',
39 'init',
40])
41const ANSWER_FLAGS = new Set(['--version', '-v', '--help', '-h', '-?', '--status', '--stop', '--dry-run', '-m'])
42const ENDLESS_FLAGS = new Set(['--continuous', '-t'])
43
44/** Options whose value is the next word, not a task. */
45const VALUED_OPTIONS = new Set([
46 '-p',
47 '--project-dir',
48 '-x',
49 '--exclude-task',
50 '--tests',
51 '-I',
52 '--init-script',
53 '-c',
54 '--settings-file',
55 '-g',
56 '--gradle-user-home',
57 '--console',
58 '--warning-mode',
59 '--priority',
60 '--include-build',
61 '--configuration',
62 '--dependency',
63 '--task',
64 '--max-workers',
65])
66
67const SEPARATOR = /\s*(?:&&|\|\||;|\||\n)\s*/
68
69/** The Gradle call in a command line, or undefined when it runs none. */
70export function analyzeCommand(command: string): GradleCommand | undefined {
71 const segments = command.split(SEPARATOR).map(s => s.trim()).filter(Boolean)
72 const index = segments.findIndex(segment => gradleWords(segment) !== undefined)
73 if (index === -1) return undefined
74 const segment = segments[index] ?? ''
75 const words = gradleWords(segment) ?? []
76
77 const tasks: string[] = []
78 const flags: string[] = []
79 let projectDir: string | undefined
80 for (let i = 0; i < words.length; i++) {
81 const word = words[i] ?? ''
82 if (word.startsWith('-')) {
83 flags.push(word)
84 if (VALUED_OPTIONS.has(word)) {
85 const value = words[i + 1]
86 if (word === '-p' || word === '--project-dir') projectDir = value
87 i++
88 } else if (word.startsWith('--project-dir=')) {
89 projectDir = word.slice('--project-dir='.length)
90 }
91 } else if (!/^\d*[<>]/.test(word) && !word.startsWith('&')) {
92 tasks.push(word)
93 }
94 }
95
96 const cdDirs = segments
97 .slice(0, index)
98 .map(s => /^cd\s+(\S+)$/.exec(s)?.[1])
99 .filter((dir): dir is string => dir !== undefined)
100 const workDir = [...cdDirs, ...(projectDir ? [projectDir] : [])].join('/') || undefined
101
102 const isPassthrough =
103 tasks.length === 0 ||
104 tasks.some(task => ANSWER_TASKS.has(taskName(task))) ||
105 flags.some(flag => ANSWER_FLAGS.has(flag) || ENDLESS_FLAGS.has(flag))
106
107 const isLast = index === segments.length - 1
108 const isRedirected = /(^|\s)\d*[<>]|&>|#/.test(segment)
109 const isBackgrounded = /&\s*$/.test(command) && !/&&\s*$/.test(command)
110 const hasConsoleFlag = flags.some(flag => flag === '--console' || flag.startsWith('--console='))
111 const rewritten =
112 isLast && !isRedirected && !isBackgrounded && !hasConsoleFlag
113 ? `${command.trimEnd()} --console=plain`
114 : command
115
116 return { tasks, flags, workDir, isPassthrough, rewritten }
117}
118
119/** `:feature:home:testDebugUnitTest` -> `testDebugUnitTest`. */
120export function taskName(task: string): string {
121 return task.slice(task.lastIndexOf(':') + 1)
122}
123
124/** `:feature:home:testDebugUnitTest` -> `feature/home`; `test` -> undefined. */
125export function taskModuleDir(task: string): string | undefined {
126 const at = task.lastIndexOf(':')
127 if (at === -1) return undefined
128 return task
129 .slice(0, at)
130 .split(':')
131 .filter(Boolean)
132 .join('/')
133}
134
135const NOT_A_TEST_RUN =
136 /^(assemble|package|bundle|compile|process|merge|generate|pre|install|uninstall|ksp|kapt|lint|copy|extract|map|write|validate|javaPreCompile|dexBuilder|desugar|strip|create|parse|sign|check\w)/
137
138/** True for a task that runs tests: `test`, `testDebugUnitTest`, `jvmTest`,
139 * `connectedDebugAndroidTest`, `allTests`; false for `compileDebugUnitTestKotlin`. */
140export function isTestTask(task: string): boolean {
141 const name = taskName(task)
142 return /(^test|Tests?$)/.test(name) && !NOT_A_TEST_RUN.test(name) && name !== 'testClasses'
143}
144
145/** True for a task whose run includes tests (`check`, `build`). */
146export function runsTests(task: string): boolean {
147 const name = taskName(task)
148 return isTestTask(task) || name === 'check' || name === 'build'
149}
150
151function gradleWords(segment: string): string[] | undefined {
152 const words = segment.split(/\s+/).filter(Boolean)
153 let i = 0
154 while (i < words.length) {
155 const word = words[i] ?? ''
156 if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word) || WRAPPERS.has(word)) i++
157 else break
158 }
159 const binary = words[i]
160 if (binary === undefined) return undefined
161 const base = binary.replace(/^["']|["']$/g, '').split('/').pop() ?? ''
162 return GRADLE_BINARIES.has(base) ? words.slice(i + 1) : undefined
163}
164hooks/digest.ts 202 lines1// Turns a parsed Gradle run into the short text the model reads in place of
2// the console output, the file:line list, and the fix hints.
3
4import type { BuildError, BuildErrorKind } from '../types'
5
6import type { TestFailure } from './junit'
7import { missingBinding, type ParsedOutput } from './parse'
8
9export type ModuleTests = {
10 /** `:app`, `:core:data`, or the module's directory when no task named it. */
11 module: string
12 tests: number
13 failed: number
14 skipped: number
15 failures: (TestFailure & { path?: string })[]
16}
17
18export type DigestInput = {
19 parsed: ParsedOutput
20 isFailed: boolean
21 exitCode?: number
22 /** The parsed errors, paths made relative, test failures included. */
23 errors: BuildError[]
24 tests?: ModuleTests[]
25 /** Where the whole console output can still be read. */
26 logPath?: string
27}
28
29const SECTIONS: [BuildErrorKind, string][] = [
30 ['kotlin', 'Kotlin compiler errors'],
31 ['java', 'javac / kapt errors'],
32 ['dagger', 'Hilt / Dagger errors'],
33 ['room', 'Room errors'],
34 ['ksp', 'KSP errors'],
35 ['aapt', 'Android resource (AAPT) errors'],
36 ['manifest', 'Manifest merger errors'],
37 ['r8', 'R8 errors'],
38 ['lint', 'Errors reported at file:line'],
39 ['gradle', 'Build script errors'],
40]
41const PER_SECTION = 15
42const OTHER_LINES = 30
43
44export function buildDigest(input: DigestInput): string {
45 const { parsed, isFailed, exitCode, errors, tests, logPath } = input
46 const out: string[] = []
47
48 const outcome = isFailed ? 'BUILD FAILED' : 'BUILD SUCCESSFUL'
49 const head = [`Gradle: ${outcome}${parsed.duration ? ` in ${parsed.duration}` : ''}`]
50 if (exitCode !== undefined && exitCode !== 0) head.push(`exit code ${exitCode}`)
51 if (isFailed && parsed.failedTasks.length > 0) head.push(`failed: ${parsed.failedTasks.join(', ')}`)
52 else if (parsed.actionable) head.push(parsed.actionable)
53 out.push(head.join(' · '))
54 out.push(
55 `[build-output-distiller: ${parsed.lineCount.toLocaleString('en-US')} lines of console output distilled` +
56 (logPath ? `; the whole output is in ${logPath}]` : ']'),
57 )
58
59 for (const [kind, title] of SECTIONS) {
60 const list = errors.filter(error => error.kind === kind)
61 if (list.length === 0) continue
62 out.push('', `${title} (${list.length}):`)
63 for (const error of list.slice(0, PER_SECTION)) {
64 out.push(` ${location(error)}${error.message}`)
65 const room = kind === 'dagger' ? 10 : 4
66 for (const line of (error.detail ?? []).slice(0, room)) out.push(` ${line}`)
67 }
68 if (list.length > PER_SECTION) out.push(` … and ${list.length - PER_SECTION} more`)
69 if (kind === 'r8' && parsed.missingRules) out.push(` Rules R8 suggests: ${parsed.missingRules}`)
70 }
71
72 if (parsed.wentWrong.length > 0) {
73 out.push('', 'What went wrong:')
74 for (const block of parsed.wentWrong) {
75 for (const line of block.slice(0, 12)) out.push(` ${line}`)
76 }
77 }
78
79 if (tests !== undefined && tests.length > 0) {
80 out.push('', 'Tests (from build/test-results):')
81 for (const module of tests) {
82 const parts = [`${module.tests} tests`]
83 parts.push(module.failed > 0 ? `${module.failed} failed` : 'all passed')
84 if (module.skipped > 0) parts.push(`${module.skipped} skipped`)
85 out.push(` ${module.module} — ${parts.join(', ')}`)
86 for (const failure of module.failures.slice(0, 10)) {
87 out.push(` ${failure.className} > ${failure.name}`)
88 out.push(` ${failure.message}`)
89 const where = failure.path ?? failure.file
90 if (where) out.push(` at ${where}${failure.line ? `:${failure.line}` : ''}`)
91 }
92 if (module.failures.length > 10) out.push(` … and ${module.failures.length - 10} more`)
93 }
94 }
95
96 if (parsed.warnings > 0) out.push('', `${parsed.warnings} compiler warning${parsed.warnings === 1 ? '' : 's'} left out.`)
97
98 if (!isFailed && parsed.other.length > 0) {
99 const tail = parsed.other.slice(-OTHER_LINES)
100 out.push('', `Other output${parsed.other.length > OTHER_LINES ? ` (last ${OTHER_LINES} lines)` : ''}:`)
101 for (const line of tail) out.push(` ${line}`)
102 }
103
104 return out.join('\n')
105}
106
107/** `path:line` of every located error, in order, once each. */
108export function fileLines(errors: BuildError[]): string[] {
109 const seen: string[] = []
110 for (const error of errors) {
111 if (error.file === undefined) continue
112 const spot = error.line === undefined ? error.file : `${error.file}:${error.line}`
113 if (!seen.includes(spot)) seen.push(spot)
114 }
115 return seen
116}
117
118const WENT_WRONG_HINTS: [RegExp, string][] = [
119 [/There were failing tests|tests completed, \d+ failed/i, 'Tests failed: open each failing test at the line given; rerun one with --tests "<Class>.<method>" while fixing it.'],
120 [/Could not resolve|Could not find [\w.-]+:[\w.-]+|Failed to resolve/i, 'Dependency resolution failed: check the coordinates and version (libs.versions.toml), that a repository in settings.gradle(.kts) hosts it, and that the run is not --offline without a cached copy.'],
121 [/SDK location not found|ANDROID_HOME|sdk\.dir/i, 'Android SDK not found: set sdk.dir in local.properties, or ANDROID_HOME.'],
122 [/Unsupported class file major version|requires Java \d+|Android Gradle plugin requires Java|JVM target compatibility|toolchain/i, 'JDK mismatch: AGP 8+ needs JDK 17+. Check JAVA_HOME or org.gradle.java.home, and that jvmToolchain / jvmTarget agree between Java and Kotlin.'],
123 [/OutOfMemoryError|Java heap space|Metaspace|GC overhead/i, 'Out of memory: raise org.gradle.jvmargs (and kotlin.daemon.jvmargs) in gradle.properties, e.g. -Xmx4g.'],
124 [/Duplicate class/i, 'Duplicate classes: two dependencies ship the same class. Find them with ./gradlew :app:dependencies, then exclude one or align versions with a BOM.'],
125 [/Lint found (\d+ )?errors|lintVital/i, 'Lint failed the build: read the report path above and fix the issues it lists (or update the baseline), rather than turning off abortOnError.'],
126 [/Manifest merger failed/i, 'Manifest merger: add tools:replace="<attribute>" (or tools:node) on the conflicting element, or align the value with the library manifest.'],
127 [/Android resource linking failed|AAPT/i, 'Resource linking: the named resource or attribute is not defined where it is used. Check the name, the res qualifier folders, and that the module defining it is a dependency.'],
128 [/keystore|signing config|SigningConfig/i, 'Signing: the keystore path, alias or passwords for this build type are missing or wrong (often read from local.properties or the environment).'],
129 [/configuration cache problems?/i, 'Configuration cache: the report named above lists each problem; a task reading project state at execution time is the usual cause.'],
130 [/minSdk(Version)? \d+ cannot be smaller/i, 'minSdk: a library needs a higher minSdk. Raise it, or use tools:overrideLibrary if you guard the calls.'],
131 [/depend on it to compile against version|compileSdk/i, 'compileSdk: a dependency needs a newer compileSdk; raise it to the version the AAR metadata check names.'],
132 [/Plugin \[id: .+\] was not found|Plugin with id '.+' not found/i, 'Gradle plugin not found: check its id and version in libs.versions.toml [plugins] and the pluginManagement repositories.'],
133 [/Script compilation error|Unresolved reference: libs|build\.gradle/i, 'Build script error: fix the build.gradle(.kts) line named above; a version-catalog accessor typo (libs.x.y) is a common cause.'],
134]
135
136/** Labels `$.model.classify` picks from when no pattern explains a failure. */
137export const CLASSIFY_HINTS: Record<string, string> = {
138 'dependency-resolution': 'This looks like a dependency resolution failure: check coordinates, versions and repositories.',
139 'jdk-or-toolchain': 'This looks like a JDK / toolchain mismatch: check JAVA_HOME, org.gradle.java.home and jvmToolchain.',
140 'android-sdk': 'This looks like an Android SDK problem: check sdk.dir, compileSdk and installed platforms/build-tools.',
141 'out-of-memory': 'This looks like the Gradle or Kotlin daemon running out of memory: raise org.gradle.jvmargs.',
142 'build-script': 'This looks like an error in a build script or convention plugin: read the build file named in the output.',
143 'source-code': 'This looks like an error in the source code: read the files named in the output.',
144 other: 'No known cause: read the full output at the path above.',
145}
146
147/** What to do about each kind of error present, most specific first. */
148export function fixHints(errors: BuildError[], parsed: ParsedOutput): string[] {
149 const hints: string[] = []
150 const kinds = new Set(errors.map(error => error.kind))
151 const push = (hint: string) => {
152 if (!hints.includes(hint)) hints.push(hint)
153 }
154
155 for (const error of errors.filter(one => one.kind === 'dagger')) {
156 const type = missingBinding(error.message)
157 if (type) {
158 push(
159 `Hilt/Dagger: nothing provides ${type}. Give it an @Inject constructor, or add a @Provides/@Binds for it in a @Module @InstallIn(<the component that injects it>) that this module can see; a qualifier on the injection site must be on the binding too. The indented trace shows who asked for it.`,
160 )
161 } else if (/DuplicateBindings/.test(error.message)) {
162 push('Hilt/Dagger: the same type is bound twice in one component. Remove one binding or tell them apart with a qualifier.')
163 } else {
164 push('Hilt/Dagger: read the [Dagger/...] message and its trace; the first indented line names the element that needs fixing.')
165 }
166 }
167 if (kinds.has('kotlin')) {
168 push(
169 "Kotlin: fix the first error first; later ones often cascade from it. An unresolved reference usually means a missing import, a typo, or a dependency missing from this module's own build.gradle(.kts).",
170 )
171 }
172 if (kinds.has('java')) push('javac / kapt: errors in build/generated or build/tmp/kapt3 stubs point back to the annotated Kotlin source; fix that source, not the generated file.')
173 if (kinds.has('room')) {
174 push('Room checks SQL at compile time: compare the @Query table and column names with the @Entity (and @ColumnInfo(name)), and add a Migration when the schema changes.')
175 }
176 if (kinds.has('ksp')) push('KSP: the message names the element the processor rejected. If Kotlin errors are listed too, fix those first; processors often fail on code that does not compile.')
177 if (kinds.has('aapt')) {
178 push('Resource linking: the named resource or attribute is not defined where it is used. Check the name and the res qualifier folders; with non-transitive R classes, use the R of the module that defines it.')
179 }
180 if (kinds.has('manifest')) push('Manifest merger: add tools:replace="<attribute>" on the conflicting element (with xmlns:tools), or align the value with the library manifest.')
181 if (kinds.has('r8')) {
182 push(
183 `R8 missing classes: if they are compile-only annotations, add the -dontwarn lines from ${parsed.missingRules ?? 'build/outputs/mapping/<variant>/missing_rules.txt'} to proguard-rules.pro; otherwise add the dependency that provides them.`,
184 )
185 }
186 if (kinds.has('lint')) push('A checker (lint, detekt, ktlint) reported these at file:line; fix them in place or update its baseline.')
187 if (kinds.has('gradle')) push('Build script error: fix the build.gradle(.kts) line named; a version-catalog accessor typo (libs.x.y) is a common cause.')
188 if (kinds.has('test')) push('Tests failed: open each failing test at the line given; rerun one with --tests "<Class>.<method>" while fixing it.')
189
190 const wentWrong = parsed.wentWrong.flat().join('\n')
191 for (const [pattern, hint] of WENT_WRONG_HINTS) {
192 if (pattern.test(wentWrong)) push(hint)
193 }
194 return hints.slice(0, 4)
195}
196
197function location(error: BuildError): string {
198 if (error.file === undefined) return ''
199 const line = error.line === undefined ? '' : `:${error.line}${error.column === undefined ? '' : `:${error.column}`}`
200 return `${error.file}${line} `
201}
202hooks/parse.ts 266 lines1// Parses a Gradle run's console output into the errors an Android build
2// reports: Kotlin, javac/kapt, KSP, Hilt/Dagger, Room, AAPT, R8, the
3// manifest merger, file:line lint tools, and Gradle's own failure blocks.
4
5import type { BuildError, BuildErrorKind } from '../types'
6
7import { isTestTask, taskModuleDir } from './command'
8
9export type ParsedOutput = {
10 outcome: 'failed' | 'passed' | 'unknown'
11 /** `14s`, `1m 3s`, from the BUILD line. */
12 duration?: string
13 /** `45 actionable tasks: 3 executed, 42 up-to-date`. */
14 actionable?: string
15 failedTasks: string[]
16 errors: BuildError[]
17 /** Each `* What went wrong:` block, its lines trimmed. */
18 wentWrong: string[][]
19 warnings: number
20 /** The `missing_rules.txt` R8 wrote, when it did. */
21 missingRules?: string
22 /** Module directories a test report named (absolute). */
23 testReportDirs: string[]
24 /** Module directories (project-relative, `''` the root) whose test task ran. */
25 testModuleDirs: string[]
26 hasFailingTests: boolean
27 /** Lines that are neither progress nor bookkeeping, for a passing digest. */
28 other: string[]
29 lineCount: number
30}
31
32const ANSI = /\x1b\[[0-9;?]*[ -/]*[@-~]/g
33const PATH = '((?:[A-Za-z]:)?[^:\\s][^:]*?)'
34
35const KOTLIN = new RegExp(`^e: (?:file://)?${PATH}\\.(kts?):(\\d+):(\\d+):? (.+)$`)
36const KOTLIN_OLD = new RegExp(`^e: (?:file://)?${PATH}\\.(kts?): \\((\\d+), (\\d+)\\): (.+)$`)
37const KSP = new RegExp(`^e: \\[ksp\\] (?:(?:file://)?${PATH}\\.(kt|java):(\\d+): )?(.+)$`)
38const JAVAC = new RegExp(`^(?:e: )?(?:file://)?${PATH}\\.java:(\\d+): error: (.+)$`)
39const COMPILER_BARE = /^e: (?!\[ksp\])(.+)$/
40const AAPT = new RegExp(`^(?:ERROR:\\s*)?${PATH}\\.(xml|png|webp|jpg):(?:(\\d+)(?::(\\d+)(?:-\\d+)?)?:)? AAPT: (?:error: )?(.+)$`)
41const AAPT_BARE = /^(?:ERROR:\s*)?AAPT: (?:error: )?(.+)$/
42const DUPLICATE_RESOURCE = /^(?:ERROR:\s*)?\[([^\]]+)\] (\S+?\.xml)\s.*Error: Duplicate resources$/
43const R8_MISSING = /^(?:ERROR:\s*)?(?:R8:\s*)?Missing class (\S+?)(?: \(referenced from: (.+?)\))?\.?$/
44const R8_RULES = /generated in (\S+missing_rules\.txt)/
45const R8_ERROR = /^ERROR:\s*R8: (.+)$/
46const MANIFEST = new RegExp(`^${PATH}AndroidManifest\\.xml:(\\d+):(\\d+)(?:-[\\d:]+)? Error:\\s*(.*)$`)
47const LINT_LINE = /^(\/[^:\s][^:]*?\.(?:kt|kts|java|xml|gradle|toml)):(\d+)(?::(\d+))?: (?:(?:Error|error): )?(.+)$/
48const TASK_LINE = /^> Task (\S+)(?: (FAILED|UP-TO-DATE|NO-SOURCE|SKIPPED|FROM-CACHE))?$/
49const EXECUTION_FAILED = /^Execution failed for task '([^']+)'/
50const BUILD_LINE = /^BUILD (FAILED|SUCCESSFUL) in (.+)$/
51const ACTIONABLE = /^\d+ actionable tasks?: .+$/
52const REPORT = /See the report at: (file:\/\/\S+)/
53const WHERE = /^(?:Build file|Script|Settings file) '(.+)' line: (\d+)$/
54const BLOCK_END = /^(\* (Try|Exception is|Get more help|Where|Query)|BUILD (FAILED|SUCCESSFUL)|={5,}|-{5,}|\d+: Task failed|FAILURE:)/
55
56const ROOM =
57 /There is a problem with the query|Cannot figure out how to (save|read)|Room cannot|Entities and POJOs|Not sure how to convert a Cursor|Schema export directory|no such (table|column)|Cannot find (getter|setter) for field|androidx\.room|@(Entity|Dao|Database|Query)\b/i
58const DAGGER = /\[(Dagger|Hilt)[\]/]|@HiltAndroidApp|@AndroidEntryPoint|@InstallIn|dagger\.hilt/i
59
60const NOISE = [
61 /^> (Task|Configure|Transform) /,
62 /^\s*$/,
63 /^(Starting a Gradle Daemon|Reusing configuration cache|Configuration cache entry|Calculating task graph|Daemon will be stopped|Download(ing)? |Deprecated Gradle features|You can use '--warning-mode|For more on this|See https:\/\/docs\.gradle\.org|Note: )/,
64 /<[-=]+> \d+%/,
65 /^\s*\d+%\s/,
66]
67
68export function cleanLines(raw: string): string[] {
69 return raw.replace(ANSI, '').replace(/\r\n?/g, '\n').split('\n')
70}
71
72export function parseOutput(raw: string): ParsedOutput {
73 const lines = cleanLines(raw)
74 const parsed: ParsedOutput = {
75 outcome: 'unknown',
76 failedTasks: [],
77 errors: [],
78 wentWrong: [],
79 warnings: 0,
80 testReportDirs: [],
81 testModuleDirs: [],
82 hasFailingTests: false,
83 other: [],
84 lineCount: lines.length,
85 }
86 const add = (error: BuildError) => {
87 const isSeen = parsed.errors.some(
88 one => one.file === error.file && one.line === error.line && one.message === error.message,
89 )
90 if (!isSeen) parsed.errors.push(error)
91 }
92 const addTask = (list: string[], value: string) => {
93 if (!list.includes(value)) list.push(value)
94 }
95
96 for (let i = 0; i < lines.length; i++) {
97 const line = lines[i] ?? ''
98 const detail = () => indentedAfter(lines, i)
99 let m: RegExpExecArray | null
100
101 if ((m = BUILD_LINE.exec(line))) {
102 parsed.outcome = m[1] === 'FAILED' ? 'failed' : 'passed'
103 parsed.duration = m[2]
104 continue
105 }
106 if (ACTIONABLE.test(line)) {
107 parsed.actionable = line
108 continue
109 }
110 if ((m = TASK_LINE.exec(line))) {
111 const task = m[1] ?? ''
112 if (m[2] === 'FAILED') addTask(parsed.failedTasks, task)
113 if (isTestTask(task) && m[2] !== 'UP-TO-DATE' && m[2] !== 'NO-SOURCE' && m[2] !== 'SKIPPED') {
114 addTask(parsed.testModuleDirs, taskModuleDir(task) ?? '')
115 if (m[2] === 'FAILED') parsed.hasFailingTests = true
116 }
117 continue
118 }
119 if ((m = EXECUTION_FAILED.exec(line))) addTask(parsed.failedTasks, m[1] ?? '')
120 if ((m = REPORT.exec(line))) {
121 const path = safeDecode(m[1] ?? '').replace(/^file:\/\//, '')
122 const at = path.indexOf('/build/reports/')
123 if (at !== -1) addTask(parsed.testReportDirs, path.slice(0, at))
124 }
125 if (/There were failing tests|tests completed, \d+ failed/.test(line)) parsed.hasFailingTests = true
126 if (line.startsWith('w: ') || /^warning: /.test(line)) {
127 parsed.warnings++
128 continue
129 }
130
131 if (line === '* What went wrong:') {
132 const block: string[] = []
133 for (let j = i + 1; j < lines.length && block.length < 20; j++) {
134 const next = lines[j] ?? ''
135 if (BLOCK_END.test(next)) break
136 block.push(next.trimEnd())
137 }
138 while (block.length > 0 && (block[block.length - 1] ?? '').trim() === '') block.pop()
139 while (block.length > 0 && (block[0] ?? '').trim() === '') block.shift()
140 if (block.length > 0) parsed.wentWrong.push(block)
141 continue
142 }
143 if ((m = WHERE.exec(line))) {
144 const message = firstWentWrongAfter(lines, i) ?? 'Build script error'
145 add({ kind: 'gradle', file: m[1], line: Number(m[2]), message })
146 continue
147 }
148
149 if ((m = KSP.exec(line))) {
150 const message = m[4] ?? ''
151 const file = m[1] === undefined ? undefined : `${m[1]}.${m[2]}`
152 add(withDetail({ kind: kindOf(message, 'ksp'), file, line: num(m[3]), message }, detail()))
153 continue
154 }
155 if ((m = KOTLIN.exec(line)) || (m = KOTLIN_OLD.exec(line))) {
156 const message = m[5] ?? ''
157 add(
158 withDetail(
159 { kind: kindOf(message, 'kotlin'), file: `${m[1]}.${m[2]}`, line: num(m[3]), column: num(m[4]), message },
160 detail(),
161 ),
162 )
163 continue
164 }
165 if ((m = JAVAC.exec(line))) {
166 const message = m[3] ?? ''
167 add(withDetail({ kind: kindOf(message, 'java'), file: `${m[1]}.java`, line: num(m[2]), message }, detail()))
168 continue
169 }
170 if ((m = MANIFEST.exec(line))) {
171 const more = detail()
172 const message = m[4] || more[0] || 'Manifest merger error'
173 add(withDetail({ kind: 'manifest', file: `${m[1]}AndroidManifest.xml`, line: num(m[2]), column: num(m[3]), message }, m[4] ? more : more.slice(1)))
174 continue
175 }
176 if ((m = AAPT.exec(line))) {
177 add({ kind: 'aapt', file: `${m[1]}.${m[2]}`, line: num(m[3]), column: num(m[4]), message: m[5] ?? '' })
178 continue
179 }
180 if ((m = DUPLICATE_RESOURCE.exec(line))) {
181 add({ kind: 'aapt', file: m[2], message: `Duplicate resource ${m[1]}` })
182 continue
183 }
184 if ((m = AAPT_BARE.exec(line))) {
185 add({ kind: 'aapt', message: m[1] ?? '' })
186 continue
187 }
188 if ((m = R8_RULES.exec(line))) parsed.missingRules = m[1]
189 if ((m = R8_MISSING.exec(line))) {
190 add({ kind: 'r8', message: `Missing class ${m[1]}${m[2] ? ` (referenced from ${m[2]})` : ''}` })
191 continue
192 }
193 if ((m = R8_ERROR.exec(line))) {
194 add({ kind: 'r8', message: m[1] ?? '' })
195 continue
196 }
197 if ((m = COMPILER_BARE.exec(line))) {
198 const message = m[1] ?? ''
199 add(withDetail({ kind: kindOf(message, 'kotlin'), message }, detail()))
200 continue
201 }
202 if ((m = LINT_LINE.exec(line))) {
203 add({ kind: 'lint', file: m[1], line: num(m[2]), column: num(m[3]), message: m[4] ?? '' })
204 continue
205 }
206
207 if (!NOISE.some(noise => noise.test(line))) parsed.other.push(line)
208 }
209
210 if (parsed.outcome === 'unknown' && parsed.wentWrong.length > 0) parsed.outcome = 'failed'
211 return parsed
212}
213
214/** The kind a message's own words say, over the tool that printed it. */
215export function kindOf(message: string, fallback: BuildErrorKind): BuildErrorKind {
216 if (DAGGER.test(message)) return 'dagger'
217 if (ROOM.test(message)) return 'room'
218 return fallback
219}
220
221/** `[Dagger/MissingBinding] com.acme.Repo cannot be provided ...` -> `com.acme.Repo`. */
222export function missingBinding(message: string): string | undefined {
223 return /\[Dagger\/MissingBinding\]\s+(\S+?)\s+cannot be provided/.exec(message)?.[1]
224}
225
226/** The failing task a run names first: its FAILED line, else its failure block. */
227export function failingTask(parsed: ParsedOutput): string {
228 return parsed.failedTasks[0] ?? '(no task named)'
229}
230
231function withDetail(error: BuildError, detail: string[]): BuildError {
232 return detail.length > 0 ? { ...error, detail } : error
233}
234
235/** The indented lines right after line `i`: a Dagger trace, a long Kotlin message. */
236function indentedAfter(lines: string[], i: number): string[] {
237 const out: string[] = []
238 for (let j = i + 1; j < lines.length && out.length < 15; j++) {
239 const next = lines[j] ?? ''
240 if (!/^\s+\S/.test(next)) break
241 out.push(next.trim())
242 }
243 return out
244}
245
246function firstWentWrongAfter(lines: string[], i: number): string | undefined {
247 for (let j = i + 1; j < lines.length && j < i + 8; j++) {
248 if (lines[j] === '* What went wrong:') {
249 return (lines[j + 1] ?? '').trim() || undefined
250 }
251 }
252 return undefined
253}
254
255function num(text: string | undefined): number | undefined {
256 return text === undefined || text === '' ? undefined : Number(text)
257}
258
259function safeDecode(text: string): string {
260 try {
261 return decodeURI(text)
262 } catch {
263 return text
264 }
265}
266hooks/paths.ts 30 lines1// POSIX path helpers: the module has no Node `path`.
2
3export function join(...parts: string[]): string {
4 const joined = parts.filter(part => part !== '').join('/')
5 return normalize(joined)
6}
7
8/** `dir` resolved against `base` unless it is absolute. */
9export function resolve(base: string, dir: string): string {
10 return dir.startsWith('/') ? normalize(dir) : join(base, dir)
11}
12
13/** `path` relative to `base` when it lies under it, else as given. */
14export function relativeTo(base: string, path: string): string {
15 const root = base.endsWith('/') ? base : `${base}/`
16 if (path === base) return ''
17 return path.startsWith(root) ? path.slice(root.length) : path
18}
19
20function normalize(path: string): string {
21 const isAbsolute = path.startsWith('/')
22 const out: string[] = []
23 for (const part of path.split('/')) {
24 if (part === '' || part === '.') continue
25 if (part === '..' && out.length > 0 && out[out.length - 1] !== '..') out.pop()
26 else out.push(part)
27 }
28 return (isAbsolute ? '/' : '') + out.join('/')
29}
30hooks/pane.tsx 90 lines1// The /build command and its pane: the last Gradle run's errors, one Code
2// element each, showing the lines around the error where the file is readable.
3
4import { atom, read } from 'claude-code'
5import type { EngineInterface, On } from 'claude-code'
6
7import type { BuildError, LastBuild } from '../types'
8
9import { resolve } from './paths'
10
11const PANE = 'gradle-build'
12const lastBuild = atom({ plugin: 'build-output-distiller', key: 'lastBuild' } as const, null as LastBuild | null)
13const SHOWN = 30
14const AROUND = 2
15
16export function registerPane(on: On): void {
17 on('session.start', async ($, e, next) => {
18 await $.command.register({
19 name: 'build',
20 description: "Show the last Gradle build's errors in a pane",
21 })
22
23 return next(e)
24 })
25
26 on('command.run', { command: 'build' }, async $ => {
27 const build = await read($, lastBuild)
28 const opened = await $.ui.open({ id: PANE, title: 'Gradle build' })
29 const summary =
30 build === null
31 ? 'No Gradle build has run in this session yet.'
32 : `${build.headline} — ${build.errors.length} error${build.errors.length === 1 ? '' : 's'}.`
33
34 return { text: opened.isPlaced ? summary : `${summary} (The pane is not shown: ${opened.reason}.)` }
35 })
36
37 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
38 const { Box, Text, Code } = $.ui.resolve(e)
39 const build = await read($, lastBuild)
40 if (build === null) {
41 return <Text dimColor>No Gradle build has run in this session yet.</Text>
42 }
43
44 const cwd = await $.session.cwd()
45 const shown = build.errors.slice(0, SHOWN)
46 const snippets = await Promise.all(shown.map(error => snippet($, cwd, error)))
47
48 return (
49 <Box flexDirection="column">
50 <Text bold color={build.isFailed ? 'error' : 'success'}>
51 {build.headline}
52 </Text>
53 <Text dimColor>{build.command}</Text>
54 {shown.length === 0 && <Text dimColor>No errors with a location.</Text>}
55 {shown.map((error, i) => (
56 <Box key={`error-${i}`} flexDirection="column" marginTop={1}>
57 <Text>
58 <Text color="warning">{error.kind}</Text> {spot(error)}
59 {error.message}
60 </Text>
61 <Code {...(snippets[i] ?? { source: error.message })} wrap="truncate-end" />
62 </Box>
63 ))}
64 {build.errors.length > SHOWN && <Text dimColor>… and {build.errors.length - SHOWN} more</Text>}
65 </Box>
66 )
67 })
68}
69
70type Snippet = { source: string; path?: string; startLine?: number }
71
72/** The lines around the error, or its message and trace when the file is unreadable. */
73async function snippet($: EngineInterface, cwd: string, error: BuildError): Promise<Snippet> {
74 const fallback = { source: [error.message, ...(error.detail ?? [])].join('\n') }
75 if (error.file === undefined || error.line === undefined) return fallback
76 const text = await $.fs.read(resolve(cwd, error.file)).catch(() => undefined)
77 if (typeof text !== 'string') return fallback
78 const lines = text.split('\n')
79 const from = Math.max(1, error.line - AROUND)
80 const to = Math.min(lines.length, error.line + AROUND)
81 if (from > to) return fallback
82
83 return { source: lines.slice(from - 1, to).join('\n'), path: error.file, startLine: from }
84}
85
86function spot(error: BuildError): string {
87 if (error.file === undefined) return ''
88 return `${error.file}${error.line === undefined ? '' : `:${error.line}`} `
89}
90hooks/test-results.ts 115 lines1// Finds the JUnit XML a Gradle run just wrote and sums it up per module.
2
3import type { EngineInterface } from 'claude-code'
4
5import type { ModuleTests } from './digest'
6import { parseJUnit } from './junit'
7import { join, relativeTo } from './paths'
8
9export type CollectInput = {
10 /** The Gradle project's directory. */
11 root: string
12 /** The session's directory, which paths are shown relative to. */
13 cwd: string
14 /** Milliseconds; only XML written since counts. */
15 since: number
16 /** Module directories relative to `root` (`''` the root project). */
17 moduleDirs: string[]
18 /** Module directories a test report named (absolute). */
19 reportDirs: string[]
20}
21
22const MAX_FILES = 200
23const SOURCE_SETS = ['test', 'testDebug', 'androidUnitTest', 'jvmTest', 'commonTest', 'androidTest', 'androidInstrumentedTest']
24const RESULT_DIRS = ['build/test-results', 'build/outputs/androidTest-results']
25
26/** The modules whose test results are new since the run started; empty when
27 * the run wrote none. */
28export async function collectTests($: EngineInterface, input: CollectInput): Promise<ModuleTests[]> {
29 const dirs = new Set([...input.reportDirs, ...input.moduleDirs.map(dir => join(input.root, dir))])
30 if (dirs.size === 0) {
31 for (const dir of await discoverModules($, input.root)) dirs.add(dir)
32 }
33
34 const modules: ModuleTests[] = []
35 let budget = MAX_FILES
36 for (const dir of dirs) {
37 const files: string[] = []
38 for (const results of RESULT_DIRS) {
39 await walk($, join(dir, results), 3, input.since, files)
40 }
41 if (files.length === 0) continue
42 const module: ModuleTests = { module: moduleLabel(input.root, dir), tests: 0, failed: 0, skipped: 0, failures: [] }
43 for (const file of files.slice(0, budget)) {
44 const xml = await $.fs.read(file).catch(() => undefined)
45 if (typeof xml !== 'string') continue
46 const counts = parseJUnit(xml)
47 module.tests += counts.tests
48 module.failed += counts.failed
49 module.skipped += counts.skipped
50 module.failures.push(...counts.failures)
51 }
52 budget -= files.length
53 for (const failure of module.failures.slice(0, 10)) {
54 const found = await locateSource($, dir, failure.className, failure.file)
55 if (found) Object.assign(failure, { path: relativeTo(input.cwd, found) })
56 }
57 modules.push(module)
58 if (budget <= 0) break
59 }
60 return modules.sort((a, b) => b.failed - a.failed)
61}
62
63/** Collects TEST-*.xml under `dir`, `depth` levels down, written since `since`. */
64async function walk($: EngineInterface, dir: string, depth: number, since: number, into: string[]): Promise<void> {
65 const entries = await $.fs.list(dir).catch(() => [])
66 for (const entry of entries) {
67 const path = join(dir, entry.name)
68 if (entry.kind === 'dir' && depth > 0 && entry.name !== 'binary') {
69 await walk($, path, depth - 1, since, into)
70 } else if (entry.kind === 'file' && /^TEST-.*\.xml$/.test(entry.name) && entry.mtimeMs >= since - 2000) {
71 into.push(path)
72 }
73 }
74}
75
76/** The modules under `root` that have test results at all, two levels deep. */
77async function discoverModules($: EngineInterface, root: string): Promise<string[]> {
78 const found = [root]
79 const top = await $.fs.list(root).catch(() => [])
80 for (const entry of top.filter(one => one.kind === 'dir' && !one.name.startsWith('.')).slice(0, 40)) {
81 const dir = join(root, entry.name)
82 found.push(dir)
83 if (await $.fs.exists(join(dir, 'build')).catch(() => false)) continue
84 const nested = await $.fs.list(dir).catch(() => [])
85 for (const child of nested.filter(one => one.kind === 'dir' && !one.name.startsWith('.')).slice(0, 20)) {
86 found.push(join(dir, child.name))
87 }
88 }
89 return found
90}
91
92/** `<module>/src/test/kotlin/com/acme/FooTest.kt`, when it is there. */
93async function locateSource(
94 $: EngineInterface,
95 moduleDir: string,
96 className: string,
97 file: string | undefined,
98): Promise<string | undefined> {
99 if (file === undefined) return undefined
100 const pkg = className.split('.').slice(0, -1).join('/')
101 for (const set of SOURCE_SETS) {
102 for (const language of ['kotlin', 'java']) {
103 const candidate = join(moduleDir, 'src', set, language, pkg, file)
104 if (await $.fs.exists(candidate).catch(() => false)) return candidate
105 }
106 }
107 return undefined
108}
109
110function moduleLabel(root: string, dir: string): string {
111 const relative = relativeTo(root, dir)
112 if (relative === '' || relative === '.') return ':'
113 return relative.startsWith('/') ? relative : `:${relative.split('/').join(':')}`
114}
115hooks/junit.ts 86 lines1// Reads the JUnit XML Gradle writes under build/test-results/<task>/TEST-*.xml.
2
3export type TestFailure = {
4 className: string
5 name: string
6 message: string
7 trace: string[]
8 /** The test's own frame: `LoginViewModelTest.kt` and its line. */
9 file?: string
10 line?: number
11}
12
13export type SuiteCounts = {
14 tests: number
15 failed: number
16 skipped: number
17 failures: TestFailure[]
18}
19
20const SUITE = /<testsuite\b([^>]*)>/g
21const CASE = /<testcase\b([^>]*?)(?:\/>|>([\s\S]*?)<\/testcase>)/g
22const PROBLEM = /<(failure|error)\b([^>]*?)(?:\/>|>([\s\S]*?)<\/(?:failure|error)>)/
23// Kotlin test names may hold spaces: `FooTest$emits error on bad input$1.invokeSuspend`.
24const FRAME = /^at (.+?)\.([^.(]+)\(([\w$.-]+\.(?:kt|java)):(\d+)\)/
25
26export function parseJUnit(xml: string): SuiteCounts {
27 const counts: SuiteCounts = { tests: 0, failed: 0, skipped: 0, failures: [] }
28 for (const suite of xml.matchAll(SUITE)) {
29 const attrs = suite[1] ?? ''
30 counts.tests += Number(attr(attrs, 'tests') ?? 0)
31 counts.failed += Number(attr(attrs, 'failures') ?? 0) + Number(attr(attrs, 'errors') ?? 0)
32 counts.skipped += Number(attr(attrs, 'skipped') ?? 0)
33 }
34 for (const testCase of xml.matchAll(CASE)) {
35 const problem = PROBLEM.exec(testCase[2] ?? '')
36 if (problem === null) continue
37 const caseAttrs = testCase[1] ?? ''
38 const className = decode(attr(caseAttrs, 'classname') ?? '')
39 const body = decode(stripCdata(problem[3] ?? ''))
40 const trace = body.split('\n').map(line => line.trim()).filter(Boolean)
41 const message = decode(attr(problem[2] ?? '', 'message') ?? '') || trace[0] || problem[1] || 'failed'
42 counts.failures.push({
43 className,
44 name: decode(attr(caseAttrs, 'name') ?? ''),
45 message: (message.split('\n')[0] ?? '').slice(0, 300),
46 trace: trace.slice(0, 12),
47 ...ownFrame(className, trace),
48 })
49 }
50 return counts
51}
52
53/** The first frame in the test class itself (or one of its lambdas). */
54function ownFrame(className: string, trace: string[]): { file?: string; line?: number } {
55 const simple = className.split('.').pop() ?? className
56 let fallback: { file?: string; line?: number } = {}
57 for (const line of trace) {
58 const frame = FRAME.exec(line)
59 if (frame === null) continue
60 const frameClass = frame[1] ?? ''
61 const found = { file: frame[3], line: Number(frame[4]) }
62 if (frameClass === className || frameClass.startsWith(`${className}$`)) return found
63 if (fallback.file === undefined && frame[3]?.startsWith(`${simple}.`)) fallback = found
64 }
65 return fallback
66}
67
68function attr(attrs: string, name: string): string | undefined {
69 return new RegExp(`\\b${name}="([^"]*)"`).exec(attrs)?.[1]
70}
71
72function stripCdata(text: string): string {
73 return text.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
74}
75
76function decode(text: string): string {
77 return text
78 .replace(/&#(\d+);/g, (_, code: string) => String.fromCodePoint(Number(code)))
79 .replace(/&#x([0-9a-f]+);/gi, (_, code: string) => String.fromCodePoint(parseInt(code, 16)))
80 .replace(/</g, '<')
81 .replace(/>/g, '>')
82 .replace(/"/g, '"')
83 .replace(/'/g, "'")
84 .replace(/&/g, '&')
85}
86types/index.d.ts 50 lines1export type BuildErrorKind =
2 | 'kotlin'
3 | 'java'
4 | 'ksp'
5 | 'dagger'
6 | 'room'
7 | 'aapt'
8 | 'r8'
9 | 'manifest'
10 | 'lint'
11 | 'test'
12 | 'gradle'
13
14export type BuildError = {
15 kind: BuildErrorKind
16 /** As the output named it: absolute, or relative to the session's directory. */
17 file?: string
18 line?: number
19 column?: number
20 message: string
21 /** Indented lines that followed it (a Dagger trace, a test's stack). */
22 detail?: string[]
23}
24
25/** The last failed Gradle run, kept to notice the same failure coming back. */
26export type LastFailure = {
27 hash: string
28 task: string
29 firstError: string
30 repeats: number
31}
32
33/** What the /build pane draws. */
34export type LastBuild = {
35 command: string
36 headline: string
37 isFailed: boolean
38 errors: BuildError[]
39 at: number
40}
41
42declare module 'claude-code' {
43 interface PluginState {
44 'build-output-distiller': {
45 lastFailure: LastFailure | null
46 lastBuild: LastBuild | null
47 }
48 }
49}
50