SLOPSHOPPER

swift-concurrency-auditor

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

panebandrowsguardcommandtoast
v0.4.1no licenseupdated 2026-10-18badrinathvm/swift-concurrency-auditor
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · swift-concurrency-auditor
│ ┃ Swift concurrency ✕ › fix the failing auth test and add an audit log call │ ┃ No Swift files touched yet. Findings appear │ ┃ here after Claude edits a .swift file. ⏺ 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 │ │ › /concurrency │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Swift concurrency
No Swift files touched yet. Findings appear here after Claude edits a .swift file.
README

Swift Concurrency Auditor

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.

/concurrency-report summary cards on the left and the /concurrency pane with Fix buttons on the right

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.

How it works

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"]

Install

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.

What it checks

IDSeverityRule
CA001criticalTask.detached that mutates self (likely data race)
CA007high@Observable / ObservableObject class not isolated to @MainActor
CA014highLooping Task that captures self strongly
CA021medium@unchecked Sendable or nonisolated(unsafe)
CA030mediumTaskGroup children that never check cancellation
CA042lowBlocking calls (semaphores, Thread.sleep, DispatchQueue.main.sync) in async code
CA051lowTask.detached where Task likely suffices
CA052lowDispatchQueue.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.

Swift 6 readiness score

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):

SeverityPoints 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.

Commands

  • /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 follow

Every 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.

Develop

claude --plugin-dir ./swift-concurrency-auditor   # load from source, hot-reloads on save
claude plugin validate ./swift-concurrency-auditor
claude plugin test ./swift-concurrency-auditor
Source 5 files
hooks/register.tsx 415 lines
1import { 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}
415
hooks/rules.ts 180 lines
1import 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}
180
hooks/report.ts 39 lines
1import 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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}
39
hooks/template.ts 4 lines
1// 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,\"&amp;\").replace(/</g,\"&lt;\").replace(/>/g,\"&gt;\")}\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"
4
types/index.d.ts 45 lines
1export 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