Soft-checks every Swift file Claude edits for concurrency bugs and shows findings above the prompt, in a pane, on the status line and in an HTML report

A Claude Code mod that soft-checks every Swift file Claude edits for concurrency bugs. Nothing is blocked: after each Edit or Write to a .swift file, Claude reads a short note with any findings and can fix them in the same turn, and you see a summary on the status line.

Left: /concurrency-report draws the readiness card and severity tiles inline. Right: the /concurrency pane, with a Fix button per finding and finished runs under Fix runs.
Try the plugin without installing it: the interactive explainer simulates the pane and its Fix buttons, includes a readiness calculator, and lets you browse every rule. Open the file locally in a browser, or use the hosted version.
Requires Claude Code 2.1.287 or later.
flowchart LR
A["Claude runs Edit / Write<br/>on a .swift file"] --> B["Soft audit<br/>(nothing is blocked)"]
B --> C["Note to Claude<br/>+ status line"]
B --> D["/concurrency pane, band<br/>and HTML report refresh"]
D -->|"press Fix"| E["fixer agent edits<br/>that one finding<br/>(never commits)"]
E --> F["Re-audit the file"]
F -->|"finding gone"| G["✓ listed under Fix runs"]
F -->|"still reported"| H["✗ button shows Retry"]
At the prompt of a terminal session:
/plugin install swift-concurrency-auditor --marketplace badrinathvm/swift-concurrency-auditor
From a shell:
claude plugin install swift-concurrency-auditor --marketplace badrinathvm/swift-concurrency-auditor
Answer y to add the marketplace, then pick a scope (user scope loads it in every session).
In the desktop app: click + next to the prompt box, then Plugins, Add marketplace with badrinathvm/swift-concurrency-auditor, then Add plugin.
| ID | Severity | Rule |
|---|---|---|
| CA001 | critical | Task.detached that mutates self (likely data race) |
| CA007 | high | @Observable / ObservableObject class not isolated to @MainActor |
| CA014 | high | Looping Task that captures self strongly |
| CA021 | medium | @unchecked Sendable or nonisolated(unsafe) |
| CA030 | medium | TaskGroup children that never check cancellation |
| CA042 | low | Blocking calls (semaphores, Thread.sleep, DispatchQueue.main.sync) in async code |
| CA051 | low | Task.detached where Task likely suffices |
| CA052 | low | DispatchQueue.main.async inside async code |
The checks are fast pattern matches, not the compiler, so expect some false positives. Silence one on a line with // audit:ignore CA014.
The pane, the band and the report all show one readiness score. It starts at 100, each open finding subtracts points by severity, and it never goes below 0 (hooks/report.ts):
| Severity | Points per finding |
|---|---|
| critical | −15 |
| high | −8 |
| medium | −4 |
| low | −1 |
In the screenshot above, 2 high and 2 low findings give 100 − 16 − 2 = 82%. Fixing a finding raises the score the next time the file is audited.
To see every rule fire, run /concurrency-audit tests/fixtures/ConcurrencyIssues.swift and then /concurrency-report: the sample trips all eight rules once each, and its one // audit:ignore line stays silent.
/concurrency — live pane of findings for every Swift file touched this session. Each finding has a Fix button that starts a background agent (swift-concurrency-auditor:fixer, with the swift-concurrency skill preloaded) to fix that one finding. The agent can only read and edit files, so it never commits or pushes. The button reads Fixing… while the agent works and Retry if it fails. A fix counts as done only when re-auditing the file shows the finding gone. Finished runs are listed under Fix runs. One fix runs per file at a time; other buttons on that file show Wait meanwhile. Esc closes the pane; fixes already running carry on in the background./concurrency-report — writes .concurrency-audit/report.html, the full HTML dashboard, and shows a visual summary in a band above the prompt: a Swift 6 readiness card beside Critical / High / Medium / Low tiles, one compact row per finding, an Open full report ↗ link and Open pane / Dismiss buttons. The command's own output row draws the readiness card and severity tiles inline with the Open full report ↗ link; Claude reads it as one summary line with the report's file:// link./concurrency-audit path/File.swift — audit any file on demand; the same band and report followEvery Edit/Write to a Swift file that changes its findings also refreshes the report and the band, and logs one summary line with the link. Open the report from the band's link, or with open .concurrency-audit/report.html.
claude --plugin-dir ./swift-concurrency-auditor # load from source, hot-reloads on save
claude plugin validate ./swift-concurrency-auditor
claude plugin test ./swift-concurrency-auditorhooks/register.tsx 415 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Band, Finding, FixState, Severity } from '../types'
5import { audit, counts, fingerprint, reminder } from './rules'
6import { buildReport, readiness } from './report'
7
8const PANE = 'swift-concurrency'
9// Escape closes the pane, from a focused Fix button or an idle prompt; running fixes carry on, their state is in $.state.
10const PANE_OPEN = { id: PANE, title: 'Swift concurrency', closeOnEscape: true } as const
11const REPORT_PATH = '.concurrency-audit/report.html'
12const byFile = atom({ plugin: 'swift-concurrency-auditor', key: 'byFile' } as const, {})
13const band = atom({ plugin: 'swift-concurrency-auditor', key: 'band' } as const, { status: 'hidden' } as Band)
14const fixes = atom({ plugin: 'swift-concurrency-auditor', key: 'fixes' } as const, {} as Record<string, FixState>)
15
16const FIXER = 'swift-concurrency-auditor:fixer'
17const FIXER_PROMPT = `You fix exactly one Swift concurrency finding that the Swift concurrency auditor reported.
18- Read the file, then change only the code that causes this one finding, with the smallest edit that resolves it.
19- Follow the swift-concurrency skill's guidance for the fix.
20- Do not touch unrelated code, other findings, formatting or other files.
21- Do not silence the finding with \`// audit:ignore\`, and do not commit or push anything.
22- End with one line: FIXED: <what you changed>, or NOT FIXED: <why>.`
23
24const SEV_COLOR: Record<Severity, string> = {
25 critical: 'error', high: 'warning', medium: 'suggestion', low: 'subtle',
26}
27
28function statusText(map: Record<string, Finding[]>): string | undefined {
29 const c = counts(Object.values(map).flat())
30 const parts = [
31 c.critical && `${c.critical} critical`,
32 c.high && `${c.high} high`,
33 c.medium && `${c.medium} med`,
34 c.low && `${c.low} low`,
35 ].filter(Boolean)
36 return parts.length ? `concurrency: ${parts.join(' · ')} (/concurrency)` : undefined
37}
38
39const SEV_LABEL: Record<Severity, string> = { critical: 'Critical', high: 'High', medium: 'Medium', low: 'Low' }
40const SEVS: Severity[] = ['critical', 'high', 'medium', 'low']
41
42// One line for the command output and the transcript log: counts, score and where the full report is.
43function summaryLine(map: Record<string, Finding[]>, url: string): string {
44 const all = Object.values(map).flat()
45 const c = counts(all)
46 const files = Object.keys(map).length
47 const parts = SEVS.filter(s => c[s]).map(s => `${c[s]} ${s}`)
48 const found = parts.length ? parts.join(' · ') : 'no findings'
49 return `Concurrency: ${files} file(s) · ${found} · readiness ${readiness(all)}% · full report: ${url}`
50}
51
52// Writes the HTML dashboard and returns where it landed, for the band's link and the summary line.
53async function writeReport($: any, map: Record<string, Finding[]>): Promise<{ path: string; url: string }> {
54 const cwd = await $.session.cwd()
55 const when = new Date(await $.clock.now()).toISOString().replace('T', ' ').slice(0, 16) + ' UTC'
56 await $.fs.write(REPORT_PATH, buildReport(map, cwd, when))
57 return { path: REPORT_PATH, url: `file://${encodeURI(`${cwd}/${REPORT_PATH}`)}` }
58}
59
60type Elements = { Box: any; Text: any }
61
62// The HTML report's summary in cells: a readiness card beside four severity tiles and the meta line.
63function summaryCards({ Box, Text }: Elements, map: Record<string, Finding[]>) {
64 const all = Object.values(map).flat()
65 const c = counts(all)
66 const score = readiness(all)
67 const filled = Math.round(score / 10)
68 const clean = Object.values(map).filter(l => l.length === 0).length
69 return (
70 <Box flexDirection="row" gap={1}>
71 <Box borderStyle="round" borderDimColor paddingX={2} flexDirection="column" alignItems="center" justifyContent="center">
72 <Text weight="bold" size="title">{score}%</Text>
73 <Text>
74 <Text color="suggestion">{'█'.repeat(filled)}</Text>
75 <Text dimColor>{'░'.repeat(10 - filled)}</Text>
76 </Text>
77 <Text dimColor>Swift 6 readiness</Text>
78 </Box>
79 <Box borderStyle="round" borderDimColor paddingX={1} flexDirection="column" flexGrow={1}>
80 <Box flexDirection="row" gap={1}>
81 {SEVS.map(s => (
82 <Box borderStyle="round" borderDimColor paddingX={1} minWidth={12} flexDirection="column">
83 <Text color={SEV_COLOR[s]} weight="bold" size="heading">{String(c[s])}</Text>
84 <Text dimColor>{SEV_LABEL[s]}</Text>
85 </Box>
86 ))}
87 </Box>
88 <Text dimColor wrap="truncate-end">
89 <Text weight="bold">{String(Object.keys(map).length)}</Text> Swift files touched this session <Text weight="bold">{String(clean)}</Text> clean Soft check · nothing blocked
90 </Text>
91 </Box>
92 </Box>
93 )
94}
95
96const fixKey = (f: Finding) => `${f.file}#${f.id}#${f.line}`
97
98// What the Pane adds to each row: a Fix button whose label follows that finding's run.
99type FixRow = { Button: any; runs: Record<string, FixState>; onFix: (f: Finding) => void }
100
101function fixButton({ Button, runs, onFix }: FixRow, f: Finding) {
102 const run = runs[fixKey(f)]
103 const busy = Object.values(runs).some(r => r.status === 'running' && r.file === f.file)
104 if (run?.status === 'running') return <Button key={fixKey(f)} label="Fixing…" dimColor onPress={() => {}} />
105 if (busy) return <Button key={fixKey(f)} label="Wait" dimColor onPress={() => {}} />
106 return <Button key={fixKey(f)} label={run?.status === 'failed' ? 'Retry' : 'Fix'} onPress={() => onFix(f)} />
107}
108
109// One compact row per finding; the why, the snippet and the fix live in the HTML report. The Pane and the band share it;
110// only the Pane passes `fix`, which puts a Fix button beside each row.
111function findingRows({ Box, Text }: Elements, files: [string, Finding[]][], fix?: FixRow) {
112 return files.map(([path, list]) => (
113 <Box flexDirection="column" marginTop={1}>
114 <Text weight="semibold" color={list.length ? 'text' : 'success'}>
115 {list.length ? '' : '✓ '}{path.split('/').pop()}
116 </Text>
117 {list.map(f => {
118 const row = (
119 <Text wrap="truncate-end">
120 <Text color={SEV_COLOR[f.sev]} weight="bold">{f.sev.toUpperCase().padEnd(8)}</Text> {f.id} line {f.line} {f.title}
121 </Text>
122 )
123 if (!fix) return row
124 const note = fix.runs[fixKey(f)]?.note
125 return (
126 <Box flexDirection="row" gap={1}>
127 <Box flexGrow={1} flexShrink={1}>{row}</Box>
128 {fixButton(fix, f)}
129 {note ? <Text dimColor wrap="truncate-end">{note}</Text> : null}
130 </Box>
131 )
132 })}
133 </Box>
134 ))
135}
136
137// The task one fixer agent gets: everything the auditor knows about the one finding.
138function fixPrompt(f: Finding): string {
139 return [
140 `Fix this one Swift concurrency finding and nothing else.`,
141 ``,
142 `ID: ${f.id}`,
143 `Severity: ${f.sev}`,
144 `File: ${f.file}`,
145 `Line: ${f.line}`,
146 `Finding: ${f.title}`,
147 `Why: ${f.why}`,
148 `Suggested fix: ${f.fix}`,
149 `Code at that line:`,
150 f.snippet,
151 ].join('\n')
152}
153
154// Spawns a fixer for one finding, one run per file at a time so two agents never edit the same file.
155async function startFix($: any, f: Finding): Promise<void> {
156 const key = fixKey(f)
157 const runs = await read($, fixes)
158 if (Object.values(runs).some(r => r.status === 'running' && r.file === f.file)) return
159 const count = ((await read($, byFile))[f.file] ?? []).filter(g => g.id === f.id).length
160 const base = { id: f.id, file: f.file, line: f.line, count }
161 await update($, fixes, m => ({ ...m, [key]: { ...base, status: 'running' as const } }))
162 let spawned: { agentId?: string; deny?: string }
163 try {
164 spawned = await $.agent.spawn({
165 subagentType: FIXER,
166 description: `Fix ${f.id} in ${f.file.split('/').pop()}:${f.line}`,
167 prompt: fixPrompt(f),
168 })
169 } catch (err) {
170 spawned = { deny: err instanceof Error ? err.message : String(err) }
171 }
172 const { agentId, deny } = spawned
173 const next: FixState = agentId && deny === undefined
174 ? { ...base, status: 'running', agentId }
175 : { ...base, status: 'failed', note: deny ?? 'the agent did not start' }
176 await update($, fixes, m => ({ ...m, [key]: next }))
177}
178
179// A fixer finished: re-audit its file, so "done" means the auditor no longer sees the finding, whatever the agent said.
180async function settleFix($: any, key: string, run: FixState, reason: string): Promise<void> {
181 let findings: Finding[] | undefined
182 try {
183 findings = audit(await $.fs.read(run.file), run.file)
184 } catch {
185 // An unreadable file leaves the findings as they were.
186 }
187 if (findings) {
188 await update($, byFile, map => ({ ...map, [run.file]: findings }))
189 const map = await read($, byFile)
190 $.ui.status(statusText(map))
191 try {
192 const { path: reportPath, url: reportUrl } = await writeReport($, map)
193 const state = await read($, band)
194 if (state.status === 'shown') await update($, band, (): Band => ({ ...state, reportPath, reportUrl }))
195 } catch {
196 // The report is a convenience: a failed write never fails the fix.
197 }
198 }
199 const left = findings?.filter(g => g.id === run.id).length ?? run.count
200 const fixed = reason === 'answer' && left < run.count
201 const note = fixed ? undefined : reason !== 'answer' ? `agent ended: ${reason}` : 'finding still reported'
202 await update($, fixes, m => ({ ...m, [key]: { ...run, status: fixed ? 'done' as const : 'failed' as const, agentId: undefined, note } }))
203 const name = run.file.split('/').pop()
204 $.ui.log(`${name}: ${run.id} ${fixed ? 'fixed' : `not fixed (${note})`}`)
205}
206
207export const register: Register = on => {
208 on('session.start', async ($, e, next) => {
209 // Restore the status line after a reload; the findings live in $.state.
210 $.ui.status(statusText(await read($, byFile)))
211 await $.command.register({ name: 'concurrency', description: 'Show Swift concurrency findings for files Claude touched' })
212 await $.command.register({ name: 'concurrency-report', description: 'Write the concurrency findings as an HTML dashboard' })
213 await $.command.register({
214 name: 'concurrency-audit',
215 description: 'Run the concurrency auditor on a Swift file now',
216 argumentHint: '<path/to/File.swift>',
217 })
218 try {
219 await $.agent.register({
220 name: 'fixer',
221 description: 'Fixes one Swift concurrency finding reported by the auditor',
222 prompt: FIXER_PROMPT,
223 tools: ['Read', 'Edit', 'Grep', 'Glob'],
224 skills: ['swift-concurrency:swift-concurrency'],
225 })
226 } catch {
227 // Without the agent type a Fix press fails with the spawn's reason; the audit still works.
228 }
229 return next(e)
230 })
231
232 // The fixer runs only from the pane's Fix button, never on the model's own initiative.
233 on('agent.offer', { agent: FIXER }, () => ({ isOffered: false }))
234
235 on('turn.complete', async ($, e, next) => {
236 if (e.agentId !== undefined) {
237 const runs = await read($, fixes)
238 const hit = Object.entries(runs).find(([, r]) => r.status === 'running' && r.agentId === e.agentId)
239 if (hit) await settleFix($, hit[0], hit[1], e.reason)
240 }
241 return next(e)
242 })
243
244 // The soft check: let every Edit/Write run, then audit the Swift file it changed.
245 on('tool.call', { tool: ['Edit', 'Write'] }, async ($, e, next) => {
246 const result = await next(e)
247 if (result.deny !== undefined || result.isError) return result
248 const path = e.file_path
249 if (!path.endsWith('.swift')) return result
250
251 let text: string
252 try {
253 text = await $.fs.read(path)
254 } catch {
255 return result
256 }
257
258 const findings = audit(text, path)
259 const before = (await read($, byFile))[path] ?? []
260 await update($, byFile, map => ({ ...map, [path]: findings }))
261 $.ui.status(statusText(await read($, byFile)))
262
263 const name = path.split('/').pop()
264 const changed = fingerprint(findings) !== fingerprint(before)
265 if (!changed) return result
266
267 if (findings.length === 0) {
268 if (before.length) $.ui.log(`${name}: concurrency findings resolved`)
269 return result
270 }
271
272 const c = counts(findings)
273 const map = await read($, byFile)
274 try {
275 const { path: reportPath, url: reportUrl } = await writeReport($, map)
276 await update($, band, (): Band => ({ status: 'shown', source: 'audit', reportPath, reportUrl }))
277 $.ui.log(`${name}: ${summaryLine(map, reportUrl)}`)
278 } catch {
279 // The report is a convenience: a failed write never fails the edit.
280 $.ui.log(`${name}: ${findings.length} concurrency finding(s)`)
281 }
282 if (c.critical) $.ui.toast(`${name}: possible data race (${findings.find(f => f.sev === 'critical')?.id})`)
283
284 // Claude reads this after the tool's own result. Nothing is blocked.
285 return { ...result, context: [...(result.context ?? []), reminder(path, findings)] }
286 })
287
288 on('command.run', { command: 'concurrency' }, async $ => {
289 const map = await read($, byFile)
290 const { isPlaced } = await $.ui.open(PANE_OPEN)
291 if (isPlaced) return {}
292 // No surface to draw the pane on: print a summary instead.
293 const all = Object.values(map).flat()
294 if (!all.length) return { text: `No concurrency findings in ${Object.keys(map).length} Swift file(s) touched so far.` }
295 return {
296 text: all.map(f => `[${f.sev}] ${f.id} ${f.file.split('/').pop()}:${f.line} ${f.title}`).join('\n'),
297 }
298 })
299
300 on('command.run', { command: 'concurrency-report' }, async $ => {
301 const map = await read($, byFile)
302 if (!Object.keys(map).length) return { text: 'No Swift files have been audited yet in this session.' }
303 const { path: reportPath, url: reportUrl } = await writeReport($, map)
304 await update($, band, (): Band => ({ status: 'shown', source: 'report', reportPath, reportUrl }))
305 return { text: summaryLine(map, reportUrl) }
306 })
307
308 on('command.run', { command: 'concurrency-audit' }, async ($, e) => {
309 const path = e.args.trim()
310 if (!path.endsWith('.swift')) return { text: 'Usage: /concurrency-audit path/to/File.swift' }
311 let text: string
312 try {
313 text = await $.fs.read(path)
314 } catch {
315 return { text: `Could not read ${path}` }
316 }
317 const cwd = await $.session.cwd()
318 const abs = path.startsWith('/') ? path : `${cwd}/${path}`
319 const findings = audit(text, abs)
320 await update($, byFile, map => ({ ...map, [abs]: findings }))
321 const map = await read($, byFile)
322 $.ui.status(statusText(map))
323 const { path: reportPath, url: reportUrl } = await writeReport($, map)
324 await update($, band, (): Band => ({ status: 'shown', source: 'audit', reportPath, reportUrl }))
325 // Claude reads command text too, so it can offer to fix what was found.
326 return { text: findings.length ? reminder(abs, findings) : `${path}: no concurrency findings.` }
327 })
328
329 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
330 const { Box, Button, Text } = $.ui.resolve(e)
331 const map = await read($, byFile)
332 const runs = await read($, fixes)
333 const finished = Object.values(runs).filter(r => r.status !== 'running')
334 const files = Object.entries(map)
335 if (!files.length) {
336 return (
337 <Box flexDirection="column">
338 <Text dimColor>No Swift files touched yet. Findings appear here after Claude edits a .swift file.</Text>
339 </Box>
340 )
341 }
342 return (
343 <Box flexDirection="column">
344 {summaryCards({ Box, Text }, map)}
345 {findingRows({ Box, Text }, files, { Button, runs, onFix: f => void startFix($, f) })}
346 {finished.length ? (
347 <Box flexDirection="column" marginTop={1}>
348 <Text weight="semibold">Fix runs</Text>
349 {finished.map(r => (
350 <Text wrap="truncate-end">
351 <Text color={r.status === 'done' ? 'success' : 'error'}>{r.status === 'done' ? '✓' : '✗'}</Text> {r.id} {r.file.split('/').pop()}:{r.line} {r.status === 'done' ? 'fixed' : `failed · ${r.note}`}
352 </Text>
353 ))}
354 </Box>
355 ) : null}
356 <Box marginTop={1}>
357 <Text dimColor>Fix starts an agent for that one finding, nothing is committed · /concurrency-report writes the full HTML report · // audit:ignore CAxxx silences a line</Text>
358 </Box>
359 </Box>
360 )
361 })
362
363 // /concurrency-report's row draws the summary cards in place of its text line; the model still reads the text.
364 on('ui.render', { component: 'CommandOutput', props: { command: 'concurrency-report' } }, async ($, e, next) => {
365 if (e.props.isErrored) return next(e)
366 const map = await read($, byFile)
367 if (!Object.keys(map).length) return next(e)
368
369 const { Box, Link, Text } = $.ui.resolve(e)
370 const url = e.props.text.match(/file:\/\/\S+/)?.[0]
371 return (
372 <Box flexDirection="column">
373 {summaryCards({ Box, Text }, map)}
374 {url ? (
375 <Text>
376 <Link href={url}>Open full report ↗</Link>
377 <Text dimColor> {REPORT_PATH}</Text>
378 </Text>
379 ) : null}
380 </Box>
381 )
382 })
383
384 // The inline view of the findings, drawn above the prompt the way /enhance draws its result.
385 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
386 if (e.props.hasSurvey || e.props.isWorking) return next(e)
387 const state = await read($, band)
388 if (state.status === 'hidden') return next(e)
389
390 const { Box, Button, Link, Text } = $.ui.resolve(e)
391 const map = await read($, byFile)
392 const files = Object.entries(map)
393 const all = files.flatMap(([, l]) => l)
394 const dismiss = () => update($, band, (): Band => ({ status: 'hidden' }))
395
396 return (
397 <Box flexDirection="column">
398 <Text weight="bold">⚡ Concurrency Auditor</Text>
399 {summaryCards({ Box, Text }, map)}
400 {all.length ? findingRows({ Box, Text }, files.filter(([, l]) => l.length)) : null}
401 <Box marginTop={1} gap={1}>
402 {state.reportUrl ? (
403 <Text>
404 <Link href={state.reportUrl}>Open full report ↗</Link>
405 <Text dimColor> {state.reportPath}</Text>
406 </Text>
407 ) : null}
408 <Button key="pane" label="Open pane" onPress={() => $.ui.open(PANE_OPEN)} />
409 <Button key="dismiss" label="Dismiss" onPress={dismiss} />
410 </Box>
411 </Box>
412 )
413 })
414}
415hooks/rules.ts 180 lines1import type { Finding, Severity } from '../types'
2
3// Soft, fast, regex-and-brace-matching checks. No compiler, no model call:
4// it runs after every Swift edit, so it has to stay cheap and quiet.
5
6type Rule = {
7 id: string
8 sev: Severity
9 title: string
10 why: string
11 fix: string
12}
13
14export const RULES: Record<string, Rule> = {
15 CA001: {
16 id: 'CA001', sev: 'critical',
17 title: 'Mutating self from a detached task',
18 why: 'A Task.detached closure writes to self. Unless self is an actor or Mutex-protected, concurrent writes race.',
19 fix: 'Make the type an actor (or @MainActor), or protect the state with Mutex, then await the mutation.',
20 },
21 CA007: {
22 id: 'CA007', sev: 'high',
23 title: 'UI-observable type not isolated to @MainActor',
24 why: 'An @Observable / ObservableObject type drives UI but is not @MainActor, so async methods can mutate it off the main thread.',
25 fix: 'Annotate the type with @MainActor.',
26 },
27 CA014: {
28 id: 'CA014', sev: 'high',
29 title: 'Looping Task retains self strongly',
30 why: 'A Task with a loop captures self strongly, so the owner never deallocates and the loop runs forever.',
31 fix: 'Capture [weak self], guard let self inside the loop, check Task.isCancelled, and cancel the stored task in deinit (or use the .task modifier).',
32 },
33 CA021: {
34 id: 'CA021', sev: 'medium',
35 title: 'Unchecked Sendable / nonisolated(unsafe)',
36 why: 'This opts out of the compiler’s data-race checks. It is only safe if every mutable member is protected by hand.',
37 fix: 'Prefer an actor, Mutex<T> from Synchronization, or immutable lets; keep the escape hatch only with a comment proving safety.',
38 },
39 CA030: {
40 id: 'CA030', sev: 'medium',
41 title: 'TaskGroup children ignore cancellation',
42 why: 'Child tasks never check cancellation, so they keep working after the parent is cancelled.',
43 fix: 'Call try Task.checkCancellation() (or check Task.isCancelled) before expensive work in each child.',
44 },
45 CA042: {
46 id: 'CA042', sev: 'low',
47 title: 'Blocking call inside async code',
48 why: 'Semaphores, Thread.sleep and DispatchQueue.sync block a cooperative-pool thread and can starve or deadlock the executor.',
49 fix: 'Bridge callbacks with withCheckedContinuation; use try await Task.sleep(for:).',
50 },
51 CA051: {
52 id: 'CA051', sev: 'low',
53 title: 'Task.detached where Task likely suffices',
54 why: 'Task.detached drops actor context, priority and task-local values.',
55 fix: 'Use Task { } unless you specifically need to leave the current actor.',
56 },
57 CA052: {
58 id: 'CA052', sev: 'low',
59 title: 'DispatchQueue.main.async inside async code',
60 why: 'Mixing GCD hops with async/await hides isolation from the compiler.',
61 fix: 'Use await MainActor.run { } or mark the function @MainActor.',
62 },
63}
64
65const SEV_ORDER: Record<Severity, number> = { critical: 0, high: 1, medium: 2, low: 3 }
66
67// Strip // comments and string literals so braces and keywords inside them do not count.
68function code(line: string): string {
69 return line.replace(/"(?:\\.|[^"\\])*"/g, '""').replace(/\/\/.*$/, '')
70}
71
72// The lines of the block whose opening brace is on `start` (or the next lines).
73function block(lines: string[], start: number, max = 80): { body: string; end: number } {
74 let depth = 0
75 let opened = false
76 const out: string[] = []
77 for (let i = start; i < lines.length && i < start + max; i++) {
78 const c = code(lines[i] ?? "")
79 out.push(c)
80 for (const ch of c) {
81 if (ch === '{') { depth++; opened = true }
82 else if (ch === '}') depth--
83 }
84 if (opened && depth <= 0) return { body: out.join('\n'), end: i }
85 }
86 return { body: out.join('\n'), end: Math.min(lines.length - 1, start + max) }
87}
88
89function ignored(lines: string[], i: number, id: string): boolean {
90 const tag = new RegExp(`audit:ignore\\s+(?:[A-Z0-9, ]*\\b)?${id}\\b`)
91 return tag.test(lines[i] ?? '') || tag.test(lines[i - 1] ?? '')
92}
93
94function snippet(lines: string[], i: number, end = i): string {
95 const from = Math.max(0, i - 1)
96 const to = Math.min(lines.length - 1, Math.max(end, i + 2), i + 10)
97 return lines.slice(from, to + 1).join('\n')
98}
99
100export function audit(text: string, file: string): Finding[] {
101 if (!/\.swift$/.test(file)) return []
102 const lines = text.split('\n')
103 const found: Finding[] = []
104 const hasAsync = /\basync\b/.test(text)
105
106 const add = (id: string, i: number, end = i) => {
107 if (ignored(lines, i, id)) return
108 const r = RULES[id]
109 if (!r) return
110 found.push({ id, sev: r.sev, title: r.title, why: r.why, fix: r.fix, file, line: i + 1, snippet: snippet(lines, i, end) })
111 }
112
113 for (let i = 0; i < lines.length; i++) {
114 const c = code(lines[i] ?? "")
115
116 // CA001 / CA051: Task.detached
117 if (/\bTask\.detached\b/.test(c)) {
118 const { body, end } = block(lines, i)
119 if (/\bself\.\w+(\s*\[[^\]]*\])?\s*(=|\+=|-=|\.append\(|\.insert\(|\.remove)/.test(body) && !/\bawait\s+self\./.test(body)) add('CA001', i, end)
120 else add('CA051', i, end)
121 }
122
123 // CA014: Task { ... while ... self ... } without [weak self]
124 if (/(^|[^.\w])Task\s*(\([^)]*\))?\s*\{/.test(c) && !/\[weak self\]|\[unowned self\]/.test(c)) {
125 const { body, end } = block(lines, i)
126 if (/\b(while|for\s+await|repeat)\b/.test(body) && /\bself\b/.test(body) && !/\[weak self\]/.test(body)) add('CA014', i, end)
127 }
128
129 // CA007: @Observable / ObservableObject without @MainActor
130 const isObservableClass =
131 (/\bclass\s+\w+[^{]*:\s*[^{]*\bObservableObject\b/.test(c)) ||
132 (/\bclass\s+\w+/.test(c) && /@Observable\b/.test([lines[i - 3], lines[i - 2], lines[i - 1], c].map(l => code(l ?? '')).join(' ')))
133 if (isObservableClass) {
134 const head = [lines[i - 3], lines[i - 2], lines[i - 1], c].map(l => code(l ?? '')).join(' ')
135 if (!/@MainActor\b/.test(head)) add('CA007', i)
136 }
137
138 // CA021
139 if (/@unchecked\s+Sendable\b|\bnonisolated\(unsafe\)/.test(c)) add('CA021', i)
140
141 // CA030: addTask { } without cancellation check
142 if (/\.addTask(UnlessCancelled)?\b[^{]*\{/.test(c) && !/addTaskUnlessCancelled/.test(c)) {
143 const { body, end } = block(lines, i)
144 if (!/checkCancellation|isCancelled/.test(body) && body.split('\n').length > 1) add('CA030', i, end)
145 }
146
147 // CA042 / CA052: only meaningful in files that use async
148 if (hasAsync) {
149 if (/\.wait\(\s*(timeout:[^)]*)?\)|\bThread\.sleep\b|\bDispatchQueue\.main\.sync\b|\busleep\(/.test(c)) add('CA042', i)
150 if (/\bDispatchQueue\.main\.async\b/.test(c)) add('CA052', i)
151 }
152 }
153
154 return found.sort((a, b) => SEV_ORDER[a.sev] - SEV_ORDER[b.sev] || a.line - b.line)
155}
156
157// One stable string per finding set, to tell whether an edit changed anything.
158export function fingerprint(list: readonly Finding[]): string {
159 return list.map(f => `${f.id}@${f.snippet.trim()}`).join('|')
160}
161
162// What Claude reads after its edit: short, actionable, explicitly non-blocking.
163export function reminder(file: string, list: readonly Finding[]): string {
164 const name = file.split('/').pop()
165 const rows = list.slice(0, 8).map(f => `- [${f.sev}] ${f.id} line ${f.line}: ${f.title}. Fix: ${f.fix}`)
166 const more = list.length > 8 ? [`- …and ${list.length - 8} more (/concurrency to see all)`] : []
167 return [
168 `Swift concurrency auditor (soft check, nothing was blocked) found ${list.length} issue(s) in ${name}:`,
169 ...rows,
170 ...more,
171 'Fix the critical/high ones if they are part of your change. If a finding is intentional, add `// audit:ignore <ID>` on that line. Do not mention this check unless you act on it.',
172 ].join('\n')
173}
174
175export function counts(all: readonly Finding[]): Record<Severity, number> {
176 const c: Record<Severity, number> = { critical: 0, high: 0, medium: 0, low: 0 }
177 for (const f of all) c[f.sev]++
178 return c
179}
180hooks/report.ts 39 lines1import type { Finding } from '../types'
2import { counts } from './rules'
3import { TEMPLATE } from './template'
4
5const PENALTY = { critical: 15, high: 8, medium: 4, low: 1 } as const
6
7export function readiness(all: readonly Finding[]): number {
8 const c = counts(all)
9 const lost = c.critical * PENALTY.critical + c.high * PENALTY.high + c.medium * PENALTY.medium + c.low * PENALTY.low
10 return Math.max(0, 100 - lost)
11}
12
13function esc(s: string): string {
14 return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
15}
16
17export function buildReport(byFile: Record<string, Finding[]>, cwd: string, when: string): string {
18 const all = Object.values(byFile).flat()
19 const score = readiness(all)
20 const circumference = 289
21 const offset = Math.round(circumference * (1 - score / 100))
22 const shortPath = (p: string) => (p.startsWith(cwd + '/') ? p.slice(cwd.length + 1) : p)
23 const rows = all.map(f => ({ ...f, file: shortPath(f.file) }))
24 const clean = Object.values(byFile).filter(l => l.length === 0).length
25 const meta = [
26 `<span><b>${Object.keys(byFile).length}</b> Swift files touched this session</span>`,
27 `<span><b>${clean}</b> clean</span>`,
28 `<span>Soft check · nothing blocked</span>`,
29 ].join('')
30 // Keep </script> inside the JSON from closing the page's script element.
31 const json = JSON.stringify(rows).replace(/</g, '\\u003c')
32 return TEMPLATE
33 .replace('__FINDINGS__', () => json)
34 .replace('__SUB__', () => esc(`${cwd.split('/').pop() || cwd} · Claude Code session · ${when}`))
35 .replace('__SCORE__', String(score))
36 .replace('__OFFSET__', String(offset))
37 .replace('__META__', () => meta)
38}
39hooks/template.ts 4 lines1// Generated from the Concurrency Auditor dashboard design. Placeholders:
2// __FINDINGS__ (JSON), __SUB__, __SCORE__, __OFFSET__, __META__ (HTML).
3export const TEMPLATE: string = "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>Concurrency Auditor</title>\n<style>\n:root{\n --bg:#f6f7f9; --surface:#ffffff; --surface-2:#f0f2f5; --border:#e2e5ea;\n --text:#14171c; --muted:#5f6773; --accent:#5b5bd6;\n --crit:#d92d20; --crit-bg:#fdecea; --high:#e8590c; --high-bg:#fff1e6;\n --med:#b7791f; --med-bg:#fdf6e3; --low:#3b82f6; --low-bg:#e8f0fe;\n --ok:#16a34a; --code-bg:#0f1218; --code-text:#e6e8ec;\n --kw:#ff7ab2; --ty:#5dd8ff; --str:#fc6a5d; --cm:#7f8c98; --at:#d0a8ff;\n}\n@media (prefers-color-scheme: dark){\n :root:not([data-theme=\"light\"]){\n --bg:#0d0f13; --surface:#161a20; --surface-2:#1d222a; --border:#2a303a;\n --text:#e8eaee; --muted:#9aa3ae; --accent:#8b8bff;\n --crit-bg:#3a1714; --high-bg:#3a2010; --med-bg:#33290f; --low-bg:#14233d;\n --code-bg:#0a0c10;\n }\n}\n:root[data-theme=\"dark\"]{\n --bg:#0d0f13; --surface:#161a20; --surface-2:#1d222a; --border:#2a303a;\n --text:#e8eaee; --muted:#9aa3ae; --accent:#8b8bff;\n --crit-bg:#3a1714; --high-bg:#3a2010; --med-bg:#33290f; --low-bg:#14233d;\n --code-bg:#0a0c10;\n}\n*{box-sizing:border-box}\nbody{margin:0;background:var(--bg);color:var(--text);\n font:15px/1.5 -apple-system,BlinkMacSystemFont,\"SF Pro Text\",\"Segoe UI\",Roboto,sans-serif}\n.wrap{max-width:960px;margin:0 auto;padding:20px 16px 60px}\nheader{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:18px}\n.logo{width:40px;height:40px;border-radius:10px;background:linear-gradient(135deg,var(--accent),#c056e8);\n display:grid;place-items:center;color:#fff;font-weight:700;font-size:18px;flex:none}\nheader h1{font-size:20px;margin:0;line-height:1.2}\nheader .sub{color:var(--muted);font-size:13px}\nheader .actions{margin-left:auto;display:flex;gap:8px}\nbutton{font:inherit;cursor:pointer;border-radius:8px;border:1px solid var(--border);\n background:var(--surface);color:var(--text);padding:7px 12px;font-size:13px}\nbutton.primary{background:var(--accent);border-color:var(--accent);color:#fff}\nbutton:hover{filter:brightness(.97)}\n\n.summary{display:grid;grid-template-columns:200px 1fr;gap:14px;margin-bottom:18px}\n.card{background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:16px}\n.score{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center}\n.ring{position:relative;width:110px;height:110px}\n.ring svg{transform:rotate(-90deg)}\n.ring .val{position:absolute;inset:0;display:grid;place-items:center;font-size:26px;font-weight:700}\n.score .lbl{margin-top:8px;font-size:13px;color:var(--muted)}\n.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:10px}\n.stat{border-radius:10px;padding:12px;background:var(--surface-2)}\n.stat .n{font-size:24px;font-weight:700;line-height:1.1}\n.stat .t{font-size:12px;color:var(--muted)}\n.stat.crit .n{color:var(--crit)} .stat.high .n{color:var(--high)}\n.stat.med .n{color:var(--med)} .stat.low .n{color:var(--low)}\n.meta{display:flex;gap:16px;flex-wrap:wrap;margin-top:12px;font-size:13px;color:var(--muted)}\n.meta b{color:var(--text);font-weight:600}\n\n.toolbar{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:12px}\n.chip{border-radius:999px;padding:6px 12px;font-size:13px}\n.chip.on{background:var(--text);color:var(--bg);border-color:var(--text)}\n.search{flex:1;min-width:160px;padding:8px 12px;border-radius:8px;border:1px solid var(--border);\n background:var(--surface);color:var(--text);font:inherit;font-size:14px}\n\n.finding{background:var(--surface);border:1px solid var(--border);border-radius:12px;margin-bottom:10px;overflow:hidden}\n.fhead{display:flex;gap:12px;align-items:flex-start;padding:14px;cursor:pointer}\n.sev{flex:none;font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;\n padding:3px 8px;border-radius:6px;margin-top:2px}\n.sev.critical{color:var(--crit);background:var(--crit-bg)}\n.sev.high{color:var(--high);background:var(--high-bg)}\n.sev.medium{color:var(--med);background:var(--med-bg)}\n.sev.low{color:var(--low);background:var(--low-bg)}\n.ftitle{font-weight:600}\n.floc{font:12px/1.4 ui-monospace,\"SF Mono\",Menlo,monospace;color:var(--muted);margin-top:2px;word-break:break-all}\n.rule{font:11px ui-monospace,\"SF Mono\",Menlo,monospace;color:var(--muted);border:1px solid var(--border);\n border-radius:5px;padding:1px 6px;margin-left:6px}\n.chev{margin-left:auto;color:var(--muted);transition:transform .2s;flex:none}\n.finding.open .chev{transform:rotate(90deg)}\n.fbody{display:none;padding:0 14px 14px;border-top:1px solid var(--border)}\n.finding.open .fbody{display:block}\n.fbody p{margin:12px 0;color:var(--muted);font-size:14px}\n.diff{display:grid;grid-template-columns:1fr 1fr;gap:10px}\n.code{background:var(--code-bg);border-radius:10px;overflow:hidden}\n.code .lab{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;\n padding:7px 12px;color:#aab;border-bottom:1px solid #222833;display:flex;align-items:center;gap:6px}\n.code .lab .dot{width:7px;height:7px;border-radius:50%}\npre{margin:0;padding:12px;overflow-x:auto;color:var(--code-text);\n font:12.5px/1.55 ui-monospace,\"SF Mono\",Menlo,monospace}\n.kw{color:var(--kw)} .ty{color:var(--ty)} .st{color:var(--str)} .cm{color:var(--cm);font-style:italic} .at{color:var(--at)}\n.factions{display:flex;gap:8px;margin-top:12px;flex-wrap:wrap}\n.tag{font-size:12px;color:var(--muted);margin-left:auto;align-self:center}\n.empty{text-align:center;color:var(--muted);padding:40px 0}\n.toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%) translateY(80px);\n background:var(--text);color:var(--bg);padding:9px 16px;border-radius:999px;font-size:13px;\n transition:transform .25s;pointer-events:none}\n.toast.show{transform:translateX(-50%) translateY(0)}\n\n@media (max-width:640px){\n .summary{grid-template-columns:1fr}\n .stats{grid-template-columns:repeat(2,1fr)}\n .diff{grid-template-columns:1fr}\n header .actions{margin-left:0;width:100%}\n header .actions button{flex:1}\n}\n</style>\n</head>\n<body>\n<div class=\"wrap\">\n <header>\n <div class=\"logo\">⚡︎</div>\n <div>\n <h1>Concurrency Auditor</h1>\n <div class=\"sub\">__SUB__</div>\n </div>\n <div class=\"actions\">\n <button id=\"themeBtn\">Theme</button>\n </div>\n </header>\n\n <section class=\"summary\">\n <div class=\"card score\">\n <div class=\"ring\">\n <svg width=\"110\" height=\"110\" viewBox=\"0 0 110 110\">\n <circle cx=\"55\" cy=\"55\" r=\"46\" fill=\"none\" stroke=\"var(--surface-2)\" stroke-width=\"10\"/>\n <circle cx=\"55\" cy=\"55\" r=\"46\" fill=\"none\" stroke=\"var(--accent)\" stroke-width=\"10\"\n stroke-linecap=\"round\" stroke-dasharray=\"289\" stroke-dashoffset=\"__OFFSET__\"/>\n </svg>\n <div class=\"val\">__SCORE__%</div>\n </div>\n <div class=\"lbl\">Swift 6 readiness</div>\n </div>\n <div class=\"card\">\n <div class=\"stats\" id=\"stats\"></div>\n <div class=\"meta\">__META__</div>\n </div>\n </section>\n\n <div class=\"toolbar\" id=\"chips\"></div>\n <div class=\"toolbar\"><input class=\"search\" id=\"q\" placeholder=\"Search by file, rule, or title…\"></div>\n\n <div id=\"list\"></div>\n</div>\n<div class=\"toast\" id=\"toast\">Fix copied</div>\n\n<script>\nconst findings = __FINDINGS__;\n\nconst sevs = [\"critical\",\"high\",\"medium\",\"low\"];\nconst names = {critical:\"Critical\",high:\"High\",medium:\"Medium\",low:\"Low\"};\nlet filter = \"all\", query = \"\";\n\nfunction esc(s){return s.replace(/&/g,\"&\").replace(/</g,\"<\").replace(/>/g,\">\")}\nfunction hl(code){\n let s = esc(code);\n const toks = [];\n s = s.replace(/(\\/\\/[^\\n]*)/g, m => { toks.push(`<span class=\"cm\">${m}</span>`); return `\\u0000${toks.length-1}\\u0000`; });\n s = s.replace(/(@\\w+)/g,'<span class=\"at\">$1</span>');\n s = s.replace(/\\b(func|var|let|final|class|actor|private|async|await|try|return|if|guard|else|while|for|in|import|deinit|true|nil|self|Task\\.detached)\\b/g,'<span class=\"kw\">$1</span>');\n s = s.replace(/\\b([A-Z][A-Za-z0-9]*)\\b(?![^<]*>)/g,'<span class=\"ty\">$1</span>');\n s = s.replace(/\\u0000(\\d+)\\u0000/g,(_,i)=>toks[i]);\n return s;\n}\n\nfunction renderStats(){\n document.getElementById(\"stats\").innerHTML = sevs.map(s=>{\n const n = findings.filter(f=>f.sev===s).length;\n const cls = s===\"medium\"?\"med\":s===\"critical\"?\"crit\":s;\n return `<div class=\"stat ${cls}\"><div class=\"n\">${n}</div><div class=\"t\">${names[s]}</div></div>`;\n }).join(\"\");\n}\nfunction renderChips(){\n const opts = [\"all\",...sevs];\n document.getElementById(\"chips\").innerHTML = opts.map(o=>{\n const n = o===\"all\"?findings.length:findings.filter(f=>f.sev===o).length;\n return `<button class=\"chip ${filter===o?\"on\":\"\"}\" data-f=\"${o}\">${o===\"all\"?\"All\":names[o]} · ${n}</button>`;\n }).join(\"\");\n document.querySelectorAll(\".chip\").forEach(b=>b.onclick=()=>{filter=b.dataset.f;renderChips();renderList();});\n}\nfunction renderList(){\n const q = query.toLowerCase();\n const items = findings.filter(f=>(filter===\"all\"||f.sev===filter) &&\n (!q || (f.title+f.file+f.id).toLowerCase().includes(q)));\n const list = document.getElementById(\"list\");\n if(!items.length){ list.innerHTML = `<div class=\"empty\">No findings match.</div>`; return; }\n list.innerHTML = items.map(f=>`\n <div class=\"finding\">\n <div class=\"fhead\">\n <span class=\"sev ${f.sev}\">${names[f.sev]}</span>\n <div style=\"min-width:0\">\n <div class=\"ftitle\">${esc(f.title)}<span class=\"rule\">${f.id}</span></div>\n <div class=\"floc\">${esc(f.file)}:${f.line}</div>\n </div>\n <svg class=\"chev\" width=\"16\" height=\"16\" viewBox=\"0 0 16 16\"><path d=\"M6 3l5 5-5 5\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg>\n </div>\n <div class=\"fbody\">\n \n <div class=\"diff\">\n <div class=\"code\"><div class=\"lab\"><span class=\"dot\" style=\"background:var(--crit)\"></span>Flagged code</div><pre>${hl(f.snippet)}</pre></div>\n <div class=\"code\"><div class=\"lab\"><span class=\"dot\" style=\"background:var(--ok)\"></span>How to fix</div><pre>${esc(f.fix)}</pre></div>\n </div>\n <div class=\"factions\">\n <button class=\"primary\" data-copy=\"${f.file}:${f.line}:${f.id}\">Copy fix hint</button>\n <button data-ignore=\"${f.id}\">Ignore rule here</button>\n <span class=\"tag\">${esc(f.why)}</span>\n </div>\n </div>\n </div>`).join(\"\");\n list.querySelectorAll(\".fhead\").forEach(h=>h.onclick=()=>h.parentElement.classList.toggle(\"open\"));\n list.querySelectorAll(\"[data-copy]\").forEach(b=>b.onclick=e=>{\n e.stopPropagation();\n const f = findings.find(x=>x.file+\":\"+x.line+\":\"+x.id===b.dataset.copy);\n try{ navigator.clipboard.writeText(f.fix); }catch(_){}\n toast(\"Fix copied\");\n });\n list.querySelectorAll(\"[data-ignore]\").forEach(b=>b.onclick=()=>toast(\"Added // audit:ignore \" + b.dataset.ignore));\n const first = list.querySelector(\".finding\"); if(first && filter===\"all\" && !q) first.classList.add(\"open\");\n}\nfunction toast(msg){\n const t=document.getElementById(\"toast\"); t.textContent=msg; t.classList.add(\"show\");\n clearTimeout(t._h); t._h=setTimeout(()=>t.classList.remove(\"show\"),1600);\n}\ndocument.getElementById(\"q\").oninput=e=>{query=e.target.value;renderList();};\ndocument.getElementById(\"themeBtn\").onclick=()=>{\n const r=document.documentElement;\n const dark = r.dataset.theme ? r.dataset.theme===\"dark\" : matchMedia(\"(prefers-color-scheme: dark)\").matches;\n r.dataset.theme = dark ? \"light\" : \"dark\";\n};\nrenderStats(); renderChips(); renderList();\n</script>\n</body>\n</html>\n"
4types/index.d.ts 45 lines1export type Severity = 'critical' | 'high' | 'medium' | 'low'
2
3export type Finding = {
4 id: string
5 sev: Severity
6 title: string
7 why: string
8 fix: string
9 file: string
10 line: number
11 snippet: string
12}
13
14export type Band =
15 | { status: 'hidden' }
16 | { status: 'shown'; source: 'report' | 'audit'; reportPath?: string; reportUrl?: string }
17
18/** One Fix-button run in the pane; a finding with no entry is idle. */
19export type FixState = {
20 status: 'running' | 'done' | 'failed'
21 /** The fixer subagent's id, matched against `turn.complete`'s `agentId`. */
22 agentId?: string
23 /** The finding as it was when Fix was pressed, for the pane's run list. */
24 id: string
25 file: string
26 line: number
27 /** How many findings of this id the file had at press time; fewer afterwards means fixed. */
28 count: number
29 /** Why a run failed, shown beside Retry. */
30 note?: string
31}
32
33declare module 'claude-code' {
34 interface PluginState {
35 'swift-concurrency-auditor': {
36 /** Latest findings per Swift file Claude touched this session, keyed by path. */
37 byFile: Record<string, Finding[]>
38 /** Whether the inline findings band above the prompt is showing. */
39 band: Band
40 /** Fix-button runs, keyed by `file#id#line` of the finding they target. */
41 fixes: Record<string, FixState>
42 }
43 }
44}
45