SLOPSHOPPER

build-output-distiller

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…

paneguardcommandtoastmodel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · build-output-distiller
│ ┃ Gradle build ✕ › fix the failing auth test and add an audit log call │ ┃ No Gradle build has run in this session yet. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /build │ ⎿ build-output-distiller: No Gradle build has run in this session │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Gradle build
No Gradle build has run in this session yet.
README

build-output-distiller

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.

What it does

  • Keeps builds in the foreground. Commands that run 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.
  • Digests the output. The mod reads the whole output, including the file Bash saves when output is too large, and parses:
  • Kotlin compiler errors (e: file:///…:L:C, plus the 1.x format)
  • javac and kapt errors
  • KSP errors
  • Hilt/Dagger errors such as [Dagger/MissingBinding], with their trace
  • Room query errors
  • AAPT resource errors
  • R8 Missing class errors
  • Manifest merger errors
  • file:line errors from lint, detekt and ktlint
  • Gradle's * Where: and * What went wrong: blocks

The model reads the digest plus two context notes: the files to open, in order, and fix hints for each kind of error found.

  • Summarizes tests. When the run included tests, the mod reads the new 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.
  • Flags repeat failures. The mod hashes the failing task and its first error. When the next run fails the same way, the model gets a "the last fix did not take" note and the UI shows a toast.
  • Leaves some commands alone. Commands whose output is the answer (dependencies, tasks, signingReport, properties, --version, --continuous and similar) pass through untouched. So do small passing builds and anything the mod doesn't recognize.
  • Adds a /build command. It opens a pane with one Code element per error. Each element shows the lines around the error.
  • Asks the model only when needed. $.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.

Developing

claude plugin validate .
claude plugin test .

tsc -p . type-checks the mod once the engine has written .claude-plugin/types/.

Source 9 files
hooks/register.ts 218 lines
1import { 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}
218
hooks/command.ts 164 lines
1// 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}
164
hooks/digest.ts 202 lines
1// 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}
202
hooks/parse.ts 266 lines
1// 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}
266
hooks/paths.ts 30 lines
1// 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}
30
hooks/pane.tsx 90 lines
1// 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}
90
hooks/test-results.ts 115 lines
1// 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}
115
hooks/junit.ts 86 lines
1// 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(/&lt;/g, '<')
81    .replace(/&gt;/g, '>')
82    .replace(/&quot;/g, '"')
83    .replace(/&apos;/g, "'")
84    .replace(/&amp;/g, '&')
85}
86
types/index.d.ts 50 lines
1export 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