SLOPSHOPPER

prose-lint

Lints the prose Claude writes (Markdown, text, code comments) with Vale and hands the alerts back to Claude

newrowsguardcommandtoastprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prose-lint
› fix the failing auth test and add an audit log call ⏺ 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 › /prose-lint ⎿ prose-lint: Usage: /prose-lint <file or folder>… | stats [project] | coach ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

prose-lint

A Claude Code plugin that runs Vale over the prose Claude writes: Markdown, text, and comments in code.

  • After each Write or Edit, it lints the file and keeps the alerts on the lines Claude added. Claude reads them and rewrites what it got wrong. The transcript shows them under the diff, with each match underlined. When the turn ends, the alerts that got fixed, by Claude or by you, show struck through.
  • /prose-lint <file or folder>… lints whole files. Claude reads the output too, so "fix these" works after it.
  • /prose-lint stats [project] shows Claude's writing in this session and the last 30 days: alerts per 1,000 words by week, the rules it trips most with how often it kept their alerts, and docs against code comments. The plugin keeps these counts on your machine, in its own store, with no text but a few example matches per rule.
  • /prose-lint coach turns those stats into changes. It shows this session's line and the five rules Claude writes the most alerts of, each with how often Claude kept them, a few examples and the config that ran it. Claude then proposes up to three changes, such as turning a noisy rule off in its config or adding a line to CLAUDE.md, and applies only the ones you pick. It needs 1,000 words of writing in the last 30 days first.

What it looks like

The same file, written in the terminal and in the Code tab of the Claude Desktop app:

<!-- vale off --> A Write row in the Claude Code terminal: the new file's six lines, then "prose-lint · 3 alerts · bundled rules". Line 3 has its em dash underlined in red, with the Dash message boxed under it. Line 6 has "really" and "serves as a" underlined in blue, with the Adverb and CopulaInflation messages boxed under it. <!-- vale on -->

The same file in the Claude Desktop app: the diff of the new file, then the same three alerts on lines 3 and 6, underlined and boxed the same way.

When the turn ends, the row strikes out what got fixed. Here Claude fixed the dash in a second edit, which raised no alerts of its own:

The same Write row after a follow-up edit: the header reads "3 alerts · 1 fixed", and the Dash message on line 3 has a green check and a line through it, while line 6 keeps its two open alerts. Below it, the Update row replaces the dash with a comma and shows no prose-lint block.

Install

It needs Claude Code v2.1.287 or later, which runs mods, and Vale 3.18 or later on your PATH (brew install vale). Then, in your shell:

claude plugin marketplace add andrezzoid/newsroom
claude plugin install prose-lint@newsroom

Which rules run

The plugin uses the first config it finds:

  1. A .vale.ini in the edited file's folder or a folder above it: the project's own rules.
  2. Your global ~/.config/vale/.vale.ini.
  3. The bundled rules in vale/.vale.ini: the Newsroom style in vale/styles/Newsroom, which this repo keeps, so the plugin downloads nothing. Each rule targets one sign in Wikipedia's Signs of AI writing or one line of its author's writing rules, and each alert's message names it before the fix.

<!-- The table quotes what each rule flags, so Vale skips it. --> <!-- vale off -->

RuleLevelFlags
EmDasheserrorAn em dash, or an en dash with a space on each side. "1–3" passes.
NeverTagerrorA ", never X" tag: "keeps the hash, never the account number".
NegativeParallelismswarning"Not just X, but Y", "it's not X, it's Y", "is X, not Y", "no X, no Y".
AIVocabularywarningWikipedia's AI vocabulary for each model era ("delve", "pivotal", "crucial"), plus "genuine", "meaningful", "actionable" and a few more.
Copulativeswarning"Serves as a", "stands as a", "boasts a" in place of "is" or "has".
AdverbswarningFiller adverbs: "really", "just", "very" and 33 more.
BoldLabelswarningA label and a colon in place of a sentence: "The tell: …".
PassivewarningPassive voice, in documents but not in code comments.

<!-- vale on -->

vale/styles/Newsroom/NOTICE names the package each rule comes from. Words in vale/styles/config/vocabularies/Newsroom/accept.txt, such as "leverage", don't raise AIVocabulary, Adverbs, Copulatives or Passive. The bundled rules need Vale 3.18 or later: older versions read the *.test.yml files beside the rules as rules and fail.

The transcript row and Claude's note name the config that ran. A project's styles are the project's to download: run vale sync in the repo if Vale reports them missing.

To keep the bundled rules and add your own, copy vale/.vale.ini and its styles folder into your repo and edit from there.

Catch what the plugin can't see

The plugin sees only the Write and Edit tools, so a file Claude writes through Bash (cat >, sed) goes unchecked. A pre-commit hook with the same .vale.ini covers every staged file. Save this as .git/hooks/pre-commit and make it executable:

#!/bin/sh
# Lints the staged files with the repo's .vale.ini; error-level alerts block the commit.
git diff --cached --name-only --diff-filter=ACM -z | xargs -0 sh -c '[ $# -eq 0 ] || exec vale "$@"' vale
Source 2 files
hooks/register.tsx 1007 lines
1import { atom, memberOf, read, update } from 'claude-code'
2import type { EngineInterface, ProcessRunResult, Register, RenderElement, RenderInput } from 'claude-code'
3
4import type { Counts, DayStats, Kind, OpenAlert, ProseAlert, ProseReport } from '../types'
5
6// After each Write or Edit, runs Vale over the file and keeps the alerts on
7// the lines Claude added. Claude reads them as context on the tool result, and
8// the call's transcript row shows them under the diff. A project's own
9// .vale.ini decides the rules when there is one; otherwise vale/.vale.ini
10// does. Each edit also feeds the day's stats: words written, alerts written,
11// and, when the turn ends, which alerts Claude fixed or kept.
12// types/index.d.ts declares what the rows and the stats keep.
13
14type Hunk = { oldStart: number; newStart: number; lines: readonly string[] }
15
16// One alert as `vale --output JSON` reports it. Line is 1-based; Span is the
17// 1-based, inclusive character range of the match within that line.
18type ValeAlert = {
19  Check: string
20  Line: number
21  Span: [number, number]
22  Message: string
23  Severity: ProseAlert['severity']
24  Match: string
25}
26
27// What lint() found: the alerts per file, and the config that produced them
28// as people read it ("bundled rules", or a .vale.ini path with ~ for home).
29// configArgs are the arguments that picked the config: none when Vale found
30// the project's or the user's own, else --config with the bundled one.
31type Linted = { configLabel: string; configArgs: string[]; byFile: Record<string, ValeAlert[]> }
32
33// Caps the alerts /prose-lint prints, so a folder of old prose stays readable.
34const MAX_REPORTED = 200
35
36// The label of the plugin's own rules, which also tells parse() that a failure
37// isn't a team's unsynced styles.
38const BUNDLED = 'bundled rules'
39
40// What /prose-lint takes, for its hint and its usage line.
41const USAGE = '<file or folder>… | stats [project] | coach'
42
43// The report of each flagged Edit or Write, for its transcript row.
44const reports = atom({ plugin: 'prose-lint', key: 'reports' } as const, { configLabel: '', alerts: [] })
45
46// The alerts Claude wrote in the current turn, given a verdict when it ends.
47const open = atom({ plugin: 'prose-lint', key: 'open' } as const, [] as OpenAlert[])
48
49// The window /prose-lint stats reads, in days, today included.
50const STATS_DAYS = 30
51
52// How long the store keeps a day's stats: a year of trends, at about a
53// kilobyte per day and project, far below the store's 4 MiB limit.
54const KEEP_DAYS = 365
55
56// The rules /prose-lint stats lists, by written alerts.
57const STATS_RULES = 8
58
59// The rules /prose-lint coach lists, by written alerts.
60const COACH_RULES = 5
61
62// The words the coach needs before it proposes anything: the rates are per
63// 1,000 words, and below that one alert moves a rule's rate a lot.
64const COACH_WORDS = 1000
65
66export const register: Register = on => {
67  // A reload fires session.start again, so the command survives one; and the
68  // engine awaits this hook, so /prose-lint exists before the first prompt.
69  on('session.start', async ($, e, next) => {
70    await fold($).catch(() => {})
71    await $.command.register({
72      name: 'prose-lint',
73      description: 'Lint files or folders with Vale, `stats [project]` for your writing stats, or `coach` for changes to make from them',
74      argumentHint: USAGE,
75    })
76    return next(e)
77  })
78
79  // Once the main loop's turn has answered, so every fix Claude made in it
80  // counts. A subagent's turn ends mid-turn, and an interrupted or failed
81  // turn gave Claude no chance to fix: their alerts stay open for the next
82  // answered turn.
83  on('turn.complete', async ($, e, next) => {
84    const ended = await next(e)
85    if (!e.agentId && e.reason === 'answer') await lintOpen($).catch(() => {})
86    return ended
87  })
88
89  // Lints whole files, where the edit hooks only check the lines an edit
90  // added. Each path gets the config an edit in its folder would, so paths
91  // from two repos each get their own. Claude reads the output row too, so
92  // "fix these" can follow.
93  on('command.run', { command: 'prose-lint' }, async ($, e) => {
94    const paths = e.args.split(/\s+/).filter(Boolean)
95    if (paths.length === 0) return { text: `Usage: /prose-lint ${USAGE}` }
96    // `stats` alone, or with a project name (no slash), is the stats view;
97    // ./stats lints a folder by that name, and `stats docs/` lints both.
98    if (paths[0] === 'stats' && paths.length <= 2 && !paths[1]?.includes('/')) {
99      return { text: await statsReport($, paths[1]) }
100    }
101    // `coach` alone is the coach; ./coach lints a folder by that name.
102    if (paths[0] === 'coach' && paths.length === 1) return coachReport($)
103
104    const cwd = await $.session.cwd()
105    const results: (Linted | undefined)[] = []
106    for (const path of paths) {
107      const absolute = path.startsWith('/') ? path : `${cwd}/${path}`
108      const isDir = (await $.fs.stat(absolute).catch(() => undefined))?.kind === 'dir'
109      results.push(await lint($, [absolute], isDir ? absolute : dirname(absolute)))
110    }
111
112    const linted = results.filter(result => result !== undefined)
113    if (linted.length === 0) return { text: 'prose-lint: Vale did not run; the toast says why.' }
114    const failed = linted.length < results.length ? '\n\n(Vale failed on some paths; the toast says why.)' : ''
115    return { text: report(linted, cwd) + failed }
116  })
117
118  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
119    const ran = await next(e)
120    return ran.deny !== undefined || ran.isError || ran.result.staged ? ran : withAlerts($, ran, e.tool_use_id, ran.result)
121  })
122
123  on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
124    const ran = await next(e)
125    return ran.deny !== undefined || ran.isError || ran.result.staged ? ran : withAlerts($, ran, e.tool_use_id, ran.result)
126  })
127
128  // Each surface draws an Edit's or Write's diff in a different place: the
129  // terminal as a ToolResult block under the call's row, the desktop app
130  // inside the ToolUse row, with no ToolResult. The docs say neither; both
131  // come from trying it. Each hook adds the alerts under the diff where its
132  // surface draws it.
133  on('ui.render', { component: 'ToolResult' }, ($, e, next) =>
134    e.surface === 'terminal' ? annotate($, e, () => next(e)) : next(e),
135  )
136  on('ui.render', { component: 'ToolUse' }, ($, e, next) =>
137    e.surface === 'terminal' ? next(e) : annotate($, e, () => next(e)),
138  )
139}
140
141// Claude Code's own drawing (`engine`), then each flagged line of the call
142// with its matches underlined and the messages boxed under it. The engine's
143// drawing alone when the call has no alerts.
144const annotate = async (
145  $: EngineInterface,
146  e: RenderInput<'ToolUse'> | RenderInput<'ToolResult'>,
147  engine: () => Promise<RenderElement>,
148): Promise<RenderElement> => {
149  const isRunning = 'isRunning' in e.props && e.props.isRunning
150  if ((e.props.tool !== 'Edit' && e.props.tool !== 'Write') || isRunning || e.props.isErrored) return engine()
151
152  const { configLabel, alerts: list } = await read($, memberOf(reports, e))
153  if (list.length === 0) return engine()
154
155  const { Box, Text } = $.ui.resolve(e)
156  const lines = [...new Set(list.map(a => a.line))].sort((a, b) => a - b)
157  const gutter = String(lines.at(-1)).length
158  // Where a line's text starts, past the "12 │ " gutter; its box aligns to it.
159  const textColumn = gutter + 3
160  const fixed = list.filter(a => a.status === 'fixed').length
161
162  // Indented to line up with the text of Claude Code's own result block. A
163  // fixed alert stays in view, struck through, so the row tells the whole
164  // story of the edit: what the lint found, and what got fixed.
165  return (
166    <Box flexDirection="column">
167      {await engine()}
168      <Box flexDirection="column" marginTop={1} marginLeft={5}>
169        <Text>
170          <Text bold>prose-lint</Text>
171          <Text dimColor>
172            {' '}
173            · {plural(list.length, 'alert')}
174            {fixed > 0 ? ` · ${fixed === list.length ? 'all' : fixed} fixed` : ''} · {configLabel}
175          </Text>
176        </Text>
177        {lines.map(line => {
178          const onLine = list.filter(a => a.line === line).sort((a, b) => a.start - b.start)
179          return (
180            <Box flexDirection="column" marginTop={1}>
181              <Text>
182                <Text dimColor>{String(line).padStart(gutter)} │ </Text>
183                {runs(onLine[0]?.text ?? '', onLine).map(run =>
184                  !run.severity ? (
185                    run.text
186                  ) : run.isFixed ? (
187                    <Text strikethrough dimColor>
188                      {run.text}
189                    </Text>
190                  ) : (
191                    <Text underline color={COLOR[run.severity]}>
192                      {run.text}
193                    </Text>
194                  ),
195                )}
196              </Text>
197              <Box
198                flexDirection="column"
199                borderStyle="round"
200                borderColor="gray"
201                borderDimColor
202                marginLeft={textColumn}
203                paddingX={1}
204              >
205                {onLine.map(a =>
206                  a.status === 'fixed' ? (
207                    <Text dimColor>
208                      <Text color="green">✓ </Text>
209                      <Text strikethrough>{a.message}</Text> {a.check}
210                    </Text>
211                  ) : (
212                    <Text>
213                      <Text color={COLOR[a.severity]}>● </Text>
214                      {a.message} <Text dimColor>{a.check}</Text>
215                    </Text>
216                  ),
217                )}
218              </Box>
219            </Box>
220          )
221        })}
222      </Box>
223    </Box>
224  )
225}
226
227const RANK = { suggestion: 1, warning: 2, error: 3 } as const
228const COLOR = { suggestion: 'blue', warning: 'yellow', error: 'red' } as const
229
230type Run = { text: string; severity?: ProseAlert['severity']; isFixed?: boolean }
231
232// Splits a line into runs of plain and flagged characters. Where alerts
233// overlap, an unfixed one wins over a fixed one, then the most severe one
234// colours the run; a run only fixed alerts cover draws as fixed.
235const runs = (text: string, onLine: ProseAlert[]): Run[] => {
236  const out: Run[] = []
237  Array.from(text).forEach((char, i) => {
238    const covering = onLine
239      .filter(a => i + 1 >= a.start && i + 1 <= a.end)
240      .sort((a, b) => Number(a.status === 'fixed') - Number(b.status === 'fixed') || RANK[b.severity] - RANK[a.severity])
241    const severity = covering[0]?.severity
242    const isFixed = covering[0]?.status === 'fixed'
243    const last = out.at(-1)
244    if (last && last.severity === severity && last.isFixed === isFixed) last.text += char
245    else out.push({ text: char, severity, isFixed })
246  })
247  return out
248}
249
250// Lints the written file and hands Claude the alerts on the lines the write
251// added, so it fixes its own prose and leaves the prose that was already there
252// alone. With no original to diff against (a new file, or an old one too large
253// for Claude Code to keep), every line counts. An edit that adds no line adds
254// no alerts or words, so it lints nothing, but it still counts a fix edit when
255// it deleted an open alert's words; an empty diff (nothing changed, or the diff
256// timed out) is one such edit. Also keeps the alerts under the call's id for
257// its transcript row. Returns `ran` unchanged when no alert lands.
258const withAlerts = async <R extends { context?: readonly string[] }>(
259  $: EngineInterface,
260  ran: R,
261  id: string,
262  written: {
263    filePath: string
264    originalFile: string | null
265    structuredPatch: readonly Hunk[]
266    // A Write's; an Edit names none.
267    type?: 'create' | 'update'
268  },
269): Promise<R> => {
270  const { filePath: path, originalFile, structuredPatch } = written
271  const lines = originalFile === null ? undefined : addedLines(structuredPatch)
272  const removed = structuredPatch.flatMap(hunk => hunk.lines.filter(l => l.startsWith('-')).map(l => l.slice(1)))
273  if (lines?.size === 0) {
274    // Stats must never fail Claude's edit, so this drops a failure there.
275    await countFixEdit($, path, removed).catch(() => {})
276    return ran
277  }
278
279  const linted = await lint($, [path], dirname(path))
280  if (!linted) return ran
281  const found = await newAlerts(
282    $,
283    (Object.values(linted.byFile)[0] ?? []).filter(a => lines === undefined || lines.has(a.Line)),
284    written,
285    linted.configArgs,
286  )
287
288  const text = (await $.fs.read(path).catch(() => '')).split(/\r?\n/)
289  // An old file too large to diff would count all its words and the alerts it
290  // already had as Claude's; only a new one can count whole.
291  if (lines !== undefined || written.type === 'create') {
292    const added = lines === undefined ? text : [...lines].map(line => text[line - 1] ?? '')
293    // Stats must never cost Claude its alerts, so this drops a failure there.
294    await recordEdit($, id, path, linted.configLabel, { added, removed }, found).catch(() => {})
295  }
296  if (found.length === 0) return ran
297
298  const kept: ProseReport = {
299    configLabel: linted.configLabel,
300    alerts: found.map(a => ({
301      line: a.Line,
302      start: a.Span[0],
303      end: a.Span[1],
304      match: a.Match,
305      check: a.Check,
306      message: a.Message,
307      severity: a.Severity,
308      text: text[a.Line - 1] ?? '',
309    })),
310  }
311  await update($, memberOf(reports, { requestId: id }), () => kept)
312
313  $.ui.toast(`prose-lint: ${plural(found.length, 'alert')} in ${basename(path)}`)
314  return { ...ran, context: [...(ran.context ?? []), describe(found, linted.configLabel)] }
315}
316
317// Every alert Vale reports for the paths (files or folders), keyed by file;
318// Vale omits files without alerts. Vale itself picks the config, from `dir`:
319// a .vale.ini there or in a folder above, else the user's global one, the
320// same search a team's own `vale` runs make. Only when it finds neither do
321// the bundled rules run. A project config wins because it states the team's
322// goals for the prose in their repo, and their git hook or CI can run the same
323// file over what this mod never sees, such as files Claude writes through Bash.
324// Never throws: a failure (Vale missing, a project config with unsynced
325// styles) shows a toast and returns undefined, so a lint problem never fails
326// Claude's edit.
327const lint = async ($: EngineInterface, paths: string[], dir: string): Promise<Linted | undefined> => {
328  const root = pluginRoot($)
329  const bundled = bundledConfig($)
330
331  // Vale would find the bundled config above the plugin's own files and label
332  // it as a project's.
333  const isOwnFile = dir === root || dir.startsWith(`${root}/`)
334  if (!isOwnFile) {
335    const run = await vale($, ['--output', 'JSON', ...paths], dir)
336    if (!run) return undefined
337    if (!isNoConfig(run)) return parse($, run, await labelFor($, dir), [])
338  }
339
340  const run = await vale($, ['--config', bundled, '--output', 'JSON', ...paths], dir)
341  return run && parse($, run, BUNDLED, ['--config', bundled])
342}
343
344// The docs say `root` is the folder holding plugin.json, which could mean
345// .claude-plugin/ itself; the plugin's files sit beside .claude-plugin/ either way.
346const pluginRoot = ($: EngineInterface): string => $.plugin.root.replace(/\/\.claude-plugin\/?$/, '')
347
348const bundledConfig = ($: EngineInterface): string => `${pluginRoot($)}/vale/.vale.ini`
349
350// The alerts in `text`, linted as the file at `path` from `dir` with the
351// config arguments an earlier lint() settled on, so no config search or label
352// runs again. Vale matches a config's sections against the path string as it
353// is given, so `path` and `dir` must be the ones that lint got for the same
354// sections to apply. Undefined, with no toast, when Vale fails: the caller
355// treats that as "no alerts known".
356const lintText = async (
357  $: EngineInterface,
358  text: string,
359  path: string,
360  dir: string,
361  configArgs: string[],
362): Promise<ValeAlert[] | undefined> => {
363  const run = await $.process
364    .run(['vale', ...configArgs, `--path=${path}`, '--output', 'JSON'], { cwd: dir, timeoutMs: 60_000, stdin: text })
365    .catch(() => undefined)
366  if (!run || run.exitCode > 1) return undefined
367  try {
368    return Object.values(JSON.parse(run.stdout) as Record<string, ValeAlert[]>)[0] ?? []
369  } catch {
370    return undefined
371  }
372}
373
374// Runs vale from `dir`; undefined, after a toast, when it can't start. Vale
375// lints one file in well under a second; the timeout only guards against a
376// hang holding Claude's edit, and leaves room for a folder.
377const vale = ($: EngineInterface, args: string[], dir: string): Promise<ProcessRunResult | undefined> =>
378  $.process.run(['vale', ...args], { cwd: dir, timeoutMs: 60_000 }).catch(err => {
379    $.ui.toast(`prose-lint: vale did not run (${String(err).slice(0, 120)}). Is it installed? (brew install vale)`)
380    return undefined
381  })
382
383// True when Vale found no .vale.ini at all, the one failure that means "use
384// the bundled rules" rather than "this config is broken".
385const isNoConfig = (run: ProcessRunResult): boolean =>
386  run.exitCode === 2 && run.stderr.includes('"no config file found"')
387
388// Vale's report, or undefined after a toast when Vale failed. Vale exits 1
389// when it finds an error-level alert and 2 when it fails; with --output JSON
390// it reports the failure as JSON on stderr, with the message under Text.
391const parse = (
392  $: EngineInterface,
393  run: ProcessRunResult,
394  configLabel: string,
395  configArgs: string[],
396): Linted | undefined => {
397  if (run.exitCode > 1) {
398    let reason = (run.stderr || run.stdout).trim()
399    try {
400      reason = (JSON.parse(run.stderr) as { Text?: string }).Text ?? reason
401    } catch {}
402    const hint = configLabel === BUNDLED ? '' : ` (${configLabel}: does it need \`vale sync\`?)`
403    $.ui.toast(`prose-lint: vale failed: ${reason.slice(0, 160)}${hint}`)
404    return undefined
405  }
406
407  try {
408    return { configLabel, configArgs, byFile: JSON.parse(run.stdout) as Record<string, ValeAlert[]> }
409  } catch {
410    $.ui.toast(`prose-lint: could not read vale's report${run.isStdoutTruncated ? ' (too long)' : ''}`)
411    return undefined
412  }
413}
414
415// The config Vale used for `dir`, as people read it. For display only: it
416// looks for the file names Vale looks for, nearest folder first, but Vale's
417// own search decided the run.
418const labelFor = async ($: EngineInterface, dir: string): Promise<string> => {
419  for (let folder = dir; ; folder = dirname(folder)) {
420    for (const name of ['.vale.ini', '_vale.ini']) {
421      const path = `${folder}/${name}`.replace(/^\/\//, '/')
422      if (await $.fs.stat(path).then(() => true, () => false)) return shorten($, path)
423    }
424    if (dirname(folder) === folder) return 'your global .vale.ini'
425  }
426}
427
428// A config path as the row shows it, with the home folder as ~.
429const shorten = async ($: EngineInterface, path: string): Promise<string> => {
430  const home = await $.env.get('HOME')
431  return home && path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path
432}
433
434// The new alerts of an edit, without its existing ones: a diff shows a
435// reworded line as removed and added whole, so the alerts it already had land
436// on an added line too. Vale lints the whole pre-edit file, so code blocks and
437// comment delimiters read as they did, and each alert on a
438// removed line cancels one of the same alert on an added line of the same
439// hunk. A line that goes from one filler word to two still reports the new
440// one, and a word removed in one section never hides the same word added in
441// another.
442// When that lint fails, nothing cancels: better a repeated alert than a hidden
443// one.
444const newAlerts = async (
445  $: EngineInterface,
446  found: ValeAlert[],
447  written: { filePath: string; originalFile: string | null; structuredPatch: readonly Hunk[] },
448  configArgs: string[],
449): Promise<ValeAlert[]> => {
450  if (found.length === 0 || written.originalFile === null) return found
451  const before = await lintText($, written.originalFile, written.filePath, dirname(written.filePath), configArgs)
452  if (!before) return found
453
454  // Which hunk each removed line (old numbering) and added line (new) is in.
455  const removedIn = new Map<number, number>()
456  const addedIn = new Map<number, number>()
457  written.structuredPatch.forEach((hunk, index) => {
458    let oldLine = hunk.oldStart
459    let newLine = hunk.newStart
460    for (const line of hunk.lines) {
461      if (line.startsWith('-')) removedIn.set(oldLine++, index)
462      else if (line.startsWith('+')) addedIn.set(newLine++, index)
463      else if (line.startsWith(' ')) {
464        oldLine++
465        newLine++
466      }
467    }
468  })
469
470  const existing = new Map<string, number>()
471  for (const alert of before) {
472    const hunk = removedIn.get(alert.Line)
473    if (hunk === undefined) continue
474    const key = `${hunk}\n${alertKey(alert.Check, alert.Match)}`
475    existing.set(key, (existing.get(key) ?? 0) + 1)
476  }
477  return found.filter(alert => {
478    const key = `${addedIn.get(alert.Line)}\n${alertKey(alert.Check, alert.Match)}`
479    const left = existing.get(key) ?? 0
480    if (left === 0) return true
481    existing.set(key, left - 1)
482    return false
483  })
484}
485
486// What makes two alerts, in two versions of a file, the same alert: the rule
487// and the words it matched, ignoring case and spacing, since a reworded
488// sentence can capitalize its first word ("it's" becomes "It's") or wrap
489// differently. Every comparison between versions keys on this.
490const alertKey = (check: string, match: string): string => `${check}\n${sameWords(match)}`
491
492const sameWords = (text: string): string => text.toLowerCase().replace(/\s+/g, ' ').trim()
493
494// The 1-based line numbers, in the new file, of the lines a patch added.
495const addedLines = (patch: readonly Hunk[]): Set<number> => {
496  const added = new Set<number>()
497  for (const hunk of patch) {
498    let line = hunk.newStart
499    for (const text of hunk.lines) {
500      if (text.startsWith('+')) added.add(line++)
501      else if (text.startsWith(' ')) line++
502    }
503  }
504  return added
505}
506
507// Adds one Write or Edit to today's stats: the prose words it added, every
508// alert on them, and a fix edit when it removed words an open alert matched
509// in that file. Opens every alert for the turn's end to give a verdict.
510// Files whose kind it can't tell don't count.
511const recordEdit = async (
512  $: EngineInterface,
513  id: string,
514  path: string,
515  configLabel: string,
516  change: { added: string[]; removed: string[] },
517  found: ValeAlert[],
518): Promise<void> => {
519  const kind = kindOf(path)
520  if (!kind) return
521
522  const isFixEdit = await removesOpen($, path, change.removed)
523  await updateStats($, stats => {
524    if (!stats.configs.includes(configLabel)) stats.configs.push(configLabel)
525    stats.words[kind] = (stats.words[kind] ?? 0) + countWords(change.added, path)
526    if (isFixEdit) stats.fixEdits++
527    for (const a of found) {
528      countsOf(stats, a.Check, kind).written++
529      const examples = (stats.examples[a.Check] ??= [])
530      if (examples.length < 3 && !examples.includes(a.Match)) examples.push(a.Match)
531    }
532  })
533  const opened: OpenAlert[] = found.map((a, index) => ({ id, index, file: path, check: a.Check, match: a.Match, kind }))
534  if (opened.length > 0) await update($, open, list => [...list, ...opened])
535}
536
537// Adds a fix edit to today's stats for an edit that added no line, when it
538// removed an open alert's words. No config linted it, so `configs` stays as is.
539const countFixEdit = async ($: EngineInterface, path: string, removed: string[]): Promise<void> => {
540  if (await removesOpen($, path, removed)) {
541    await updateStats($, stats => {
542      stats.fixEdits++
543    })
544  }
545}
546
547// True when the removed lines of `path` hold the words of an alert still open
548// in that file. Removing no words fixes nothing, even an alert that matched
549// only spaces.
550const removesOpen = async ($: EngineInterface, path: string, removed: string[]): Promise<boolean> => {
551  const removedWords = sameWords(removed.join(' '))
552  if (removedWords === '') return false
553  return (await read($, open)).some(alert => alert.file === path && removedWords.includes(sameWords(alert.match)))
554}
555
556// Lints each file with open alerts again and counts how often each alert
557// (rule and words) still shows up there: open alerts of that rule and words
558// take those as kept, in the order Claude wrote them, and the rest count as
559// fixed. Matching by rule and words, not line, survives the line shifts edits
560// cause; the cost is that a match the file already had before counts a new
561// one as kept too.
562//
563// An alert counts in the stats once, at its first verdict. A kept one stays
564// open, so a later fix still strikes it out on its row; its file's
565// modification time skips the lint while the file hasn't changed. Alerts in a
566// file that no longer exists, or that Vale failed on, wait deferred for the
567// next turn.
568const lintOpen = async ($: EngineInterface): Promise<void> => {
569  // Taken and cleared in one step, so an edit that lands meanwhile opens its
570  // alerts for the next turn rather than losing them to this one.
571  let opened: OpenAlert[] = []
572  await update($, open, list => ((opened = list), []))
573  // Alerts stored by an older version of the module carry no row to update.
574  opened = opened.filter(alert => alert.id !== undefined)
575  if (opened.length === 0) return
576
577  const verdicts = new Map<OpenAlert, { status: 'fixed' | 'kept'; lintedMs: number }>()
578  const deferred: OpenAlert[] = []
579  await Promise.all(
580    [...new Set(opened.map(alert => alert.file))].map(async file => {
581      const ofFile = opened.filter(alert => alert.file === file)
582      const mtimeMs = await $.fs.stat(file).then(stat => stat.mtimeMs, () => undefined)
583      if (mtimeMs !== undefined && ofFile.every(alert => alert.status === 'kept' && alert.lintedMs === mtimeMs)) {
584        deferred.push(...ofFile)
585        return
586      }
587      const linted = mtimeMs === undefined ? undefined : await lint($, [file], dirname(file))
588      if (!linted || mtimeMs === undefined) {
589        deferred.push(...ofFile)
590        return
591      }
592      const left = new Map<string, number>()
593      for (const a of Object.values(linted.byFile)[0] ?? []) {
594        left.set(alertKey(a.Check, a.Match), (left.get(alertKey(a.Check, a.Match)) ?? 0) + 1)
595      }
596      for (const alert of ofFile) {
597        const key = alertKey(alert.check, alert.match)
598        const count = left.get(key) ?? 0
599        left.set(key, count - 1)
600        verdicts.set(alert, { status: count > 0 ? 'kept' : 'fixed', lintedMs: mtimeMs })
601      }
602    }),
603  )
604
605  const firstVerdicts = [...verdicts].filter(([alert]) => alert.status === undefined)
606  if (firstVerdicts.length > 0) {
607    await updateStats($, stats => {
608      for (const [alert, { status }] of firstVerdicts) countsOf(stats, alert.check, alert.kind)[status]++
609    })
610  }
611
612  const stillKept = [...verdicts]
613    .filter(([, verdict]) => verdict.status === 'kept')
614    .map(([alert, verdict]): OpenAlert => ({ ...alert, status: 'kept', lintedMs: verdict.lintedMs }))
615  if (stillKept.length > 0 || deferred.length > 0) await update($, open, list => [...deferred, ...stillKept, ...list])
616
617  // Each row whose alerts got a new verdict: a first one, or kept turned fixed.
618  const changed = [...verdicts].filter(([alert, verdict]) => alert.status !== verdict.status)
619  for (const id of new Set(changed.map(([alert]) => alert.id))) {
620    const byIndex = new Map(changed.filter(([alert]) => alert.id === id).map(([alert, verdict]) => [alert.index, verdict.status]))
621    await update($, memberOf(reports, { requestId: id }), report => ({
622      ...report,
623      alerts: report.alerts.map((alert, index) => {
624        const status = byIndex.get(index)
625        return status ? { ...alert, status } : alert
626      }),
627    }))
628  }
629}
630
631// Today's stats for this project, under a key of this session's own, so two
632// open sessions never overwrite each other. Updates run one at a time, since
633// parallel edits in one session would otherwise lose each other's counts.
634let statsQueue: Promise<void> = Promise.resolve()
635
636const updateStats = ($: EngineInterface, change: (stats: DayStats) => void): Promise<void> => {
637  const run = statsQueue.then(async () => {
638    const key = `stats/${day(await $.clock.now())}/${basename(await $.session.root())}/${await $.session.id()}`
639    const stats = ((await $.store.get(key)) as DayStats | undefined) ?? emptyStats()
640    change(stats)
641    await $.store.set(key, stats)
642  })
643  // One failed update must not stop the ones queued after it.
644  statsQueue = run.catch(() => {})
645  return run
646}
647
648// Folds each session's record from an earlier day into that day's record for
649// its project, so the store grows by the day, not by the session, and drops
650// days older than KEEP_DAYS, so it stops growing. Two sessions starting at
651// the same moment could fold one record twice; rare enough to accept.
652const fold = async ($: EngineInterface): Promise<void> => {
653  const now = await $.clock.now()
654  const today = day(now)
655  const oldest = day(now - (KEEP_DAYS - 1) * DAY_MS)
656  for (const key of await $.store.keys()) {
657    const [prefix, date, project, session] = key.split('/')
658    if (prefix !== 'stats' || !date) continue
659    if (date < oldest) {
660      await $.store.delete(key)
661      continue
662    }
663    if (!session || date >= today) continue
664
665    const target = `stats/${date}/${project}`
666    const into = ((await $.store.get(target)) as DayStats | undefined) ?? emptyStats()
667    await $.store.set(target, merge(into, (await $.store.get(key)) as DayStats))
668    await $.store.delete(key)
669  }
670}
671
672// /prose-lint stats: the last STATS_DAYS days, for every project or one: the
673// rate across all rules by week, the rules Claude trips most, and docs
674// against comments, led by this session's line when the session wrote within
675// the view. A rule's kept rate is a hint about the rule more than the
676// writing: a high one points at noise worth tuning in the config.
677const statsReport = async ($: EngineInterface, project?: string): Promise<string> => {
678  const scope = project ?? 'all projects'
679  const { total, current, weeks } = await gather($, project)
680
681  const words = wordsOf(total)
682  if (words === 0) return `prose-lint: no stats for ${scope} in the last ${STATS_DAYS} days yet.`
683
684  const sessionLine = wordsOf(current) > 0 ? [sessionSummary(current), ''] : []
685
686  const rules = topRules(total, STATS_RULES)
687
688  return [
689    ...sessionLine,
690    `**prose-lint stats** · last ${STATS_DAYS} days · ${scope}`,
691    '',
692    `${thousands(words)} words · ${per1000(writtenOf(total), words)} alerts and ` +
693      `${per1000(total.fixEdits, words)} fix edits per 1,000 words · rules: ${total.configs.join(', ')}`,
694    '',
695    '| Week of | Words | Alerts per 1,000 |',
696    '| - | - | - |',
697    ...[...weeks]
698      .sort(([a], [b]) => a.localeCompare(b))
699      .map(([week, stats]) => `| ${week} | ${thousands(wordsOf(stats))} | ${per1000(writtenOf(stats), wordsOf(stats))} |`),
700    '',
701    '| Rule | Written | Kept | Examples |',
702    '| - | - | - | - |',
703    ...rules.map(
704      rule => `| ${ruleCells(rule, total).join(' | ')} |`,
705    ),
706    '',
707    '| Where | Words | Alerts per 1,000 |',
708    '| - | - | - |',
709    ...(['docs', 'comments'] as const).map(kind => {
710      const written = sumCounts(Object.values(total.rules).map(byKind => byKind[kind])).written
711      return `| ${kind} | ${thousands(total.words[kind] ?? 0)} | ${per1000(written, total.words[kind] ?? 0)} |`
712    }),
713  ].join('\n')
714}
715
716// /prose-lint coach: this session's line and the rules Claude writes most, for
717// the user; for Claude alone, how to turn them into proposals.
718const coachReport = async ($: EngineInterface): Promise<{ text: string; context?: string[] }> => {
719  const { total, current, configs } = await gather($)
720  const words = wordsOf(total)
721  if (words < COACH_WORDS) {
722    return {
723      text:
724        `prose-lint: not enough writing yet to coach from: ${thousands(words)} words in the last ${STATS_DAYS} days, ` +
725        `and the coach needs ${thousands(COACH_WORDS)}.`,
726    }
727  }
728  // A command's context waits for the user's next prompt, and a command.run
729  // hook can't submit one (it would wait on the turn the hook holds). A local
730  // command ends no turn either, so a timer submits it once the hook returns,
731  // and Claude answers the table without the user asking.
732  $.clock.after(0, () => void $.prompt.submit({ text: 'Propose changes from the /prose-lint coach table.' }).catch(() => {}))
733  return {
734    context: [coaching(bundledConfig($))],
735    text: [
736      sessionSummary(current),
737      '',
738      `**prose-lint coach** · last ${STATS_DAYS} days · all projects · ${thousands(words)} words`,
739      '',
740      '| Rule | Written | Kept | Examples | Config |',
741      '| - | - | - | - | - |',
742      ...topRules(total, COACH_RULES).map(
743        rule => `| ${[...ruleCells(rule, total), [...(configs.get(rule.check) ?? [])].join(', ')].join(' | ')} |`,
744      ),
745    ].join('\n'),
746  }
747}
748
749// What Claude reads after /prose-lint coach. The evidence decides the kind of
750// change: a rule Claude keeps is noise in the config, a rule Claude writes and
751// fixes is a habit for its instructions. `bundled` is the plugin's own config,
752// which an update overwrites.
753const coaching = (bundled: string): string =>
754  [
755    `The user ran /prose-lint coach. Its table lists the rules you wrote the most alerts of in the last ${STATS_DAYS} days, ` +
756      'across projects. Propose up to three changes the table supports, each with its evidence: the rule, its written ' +
757      'count, its kept rate and the examples. Each change is one of these:',
758    '- A high kept rate means the rule is noise: turn it off or down in the config that ran it.',
759    "- Many written alerts with a low kept rate mean a habit: add a line to your instructions (CLAUDE.md) that stops it.",
760    "- Examples that are the project's own terms: add them to the vocabulary of the config that ran the rule.",
761    'Find CLAUDE.md and the config files yourself. ' +
762      `"${BUNDLED}" is the plugin's own config, ${bundled}, which a plugin update overwrites, so a change to it ` +
763      "starts by copying it and its styles folder to Vale's global config folder: `vale ls-dirs` names it. " +
764      'Tell the user what the copy costs: it then runs in every project without its own config, and later ' +
765      "updates to the plugin's rules stop reaching them. " +
766      "A project's .vale.ini belongs to its team: say so when you propose a change to one.",
767    'Number the proposals and ask the user which to apply. Apply only the changes the user picks, and change no file ' +
768      'before they pick.',
769  ].join('\n')
770
771// A rule's row in the stats and coach tables: the rule, its written alerts,
772// its kept rate (the share of its alerts with a verdict that Claude kept) and
773// its example matches.
774const ruleCells = (rule: { check: string } & Counts, stats: DayStats): string[] => {
775  const judged = rule.kept + rule.fixed
776  return [
777    rule.check,
778    String(rule.written),
779    judged > 0 ? `${Math.round((100 * rule.kept) / judged)}%` : '–',
780    (stats.examples[rule.check] ?? []).map(match => `"${match}"`).join(', '),
781  ]
782}
783
784// The `count` rules Claude wrote the most alerts of, with their counts summed
785// across kinds.
786const topRules = (stats: DayStats, count: number): ({ check: string } & Counts)[] =>
787  Object.entries(stats.rules)
788    .map(([check, byKind]) => ({ check, ...sumCounts(Object.values(byKind)) }))
789    .sort((a, b) => b.written - a.written)
790    .slice(0, count)
791
792// The stats of the last STATS_DAYS days, for every project or one: summed,
793// by week, and this session's own records, which a project's view counts
794// only when the session wrote in that project. A record keeps its configs but
795// not which rule each ran, so `configs` gives a rule every config of each
796// record it appears in: exact while a record ran one config.
797const gather = async ($: EngineInterface, project?: string) => {
798  const since = day((await $.clock.now()) - (STATS_DAYS - 1) * DAY_MS)
799  const sessionId = await $.session.id()
800  const total = emptyStats()
801  const current = emptyStats()
802  const weeks = new Map<string, DayStats>()
803  const configs = new Map<string, Set<string>>()
804  for (const key of await $.store.keys()) {
805    const [prefix, date, from, session] = key.split('/')
806    if (prefix !== 'stats' || !date || !from || (project && from !== project)) continue
807    if (date < since && session !== sessionId) continue
808    const stats = (await $.store.get(key)) as DayStats
809    if (session === sessionId) merge(current, stats)
810    if (date < since) continue
811    merge(total, stats)
812    const week = weekOf(date)
813    if (!weeks.has(week)) weeks.set(week, emptyStats())
814    merge(weeks.get(week) as DayStats, stats)
815    for (const check of Object.keys(stats.rules)) {
816      const ran = configs.get(check) ?? new Set()
817      for (const config of stats.configs) ran.add(config)
818      configs.set(check, ran)
819    }
820  }
821  return { total, current, weeks, configs }
822}
823
824// This session's line: its words, its rates per 1,000 words, and its alerts'
825// verdicts.
826const sessionSummary = (current: DayStats): string => {
827  const words = wordsOf(current)
828  if (words === 0) return '**This session** · no prose written yet'
829  const session = sumCounts(Object.values(current.rules).flatMap(byKind => Object.values(byKind)))
830  return (
831    `**This session** · ${thousands(words)} words · ${per1000(session.written, words)} alerts and ` +
832    `${per1000(current.fixEdits, words)} fix edits per 1,000 words · ` +
833    `${session.written} written, ${session.fixed} fixed, ${session.kept} kept`
834  )
835}
836
837const DAY_MS = 86_400_000
838
839// The Monday that starts the week of a YYYY-MM-DD day.
840const weekOf = (date: string): string => {
841  const [year, month, dayOfMonth] = date.split('-').map(Number)
842  const at = new Date(year ?? 0, (month ?? 1) - 1, dayOfMonth ?? 1)
843  at.setDate(at.getDate() - ((at.getDay() + 6) % 7))
844  return day(at.getTime())
845}
846
847const sumCounts = (all: (Counts | undefined)[]): Counts =>
848  all.reduce<Counts>(
849    (sum, c) => ({ written: sum.written + (c?.written ?? 0), fixed: sum.fixed + (c?.fixed ?? 0), kept: sum.kept + (c?.kept ?? 0) }),
850    { written: 0, fixed: 0, kept: 0 },
851  )
852
853const wordsOf = (stats: DayStats): number => Object.values(stats.words).reduce((sum, n) => sum + n, 0)
854const writtenOf = (stats: DayStats): number =>
855  sumCounts(Object.values(stats.rules).flatMap(byKind => Object.values(byKind))).written
856const per1000 = (count: number, words: number): string => (words > 0 ? ((1000 * count) / words).toFixed(1) : '–')
857// 1234567 as "1,234,567" whatever the machine's locale, unlike toLocaleString.
858const thousands = (n: number): string => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
859// 1 as "1 alert", 3 as "3 alerts".
860const plural = (n: number, noun: string): string => `${n} ${noun}${n === 1 ? '' : 's'}`
861
862const emptyStats = (): DayStats => ({ configs: [], words: {}, rules: {}, fixEdits: 0, examples: {} })
863
864const countsOf = (stats: DayStats, check: string, kind: Kind): Counts =>
865  ((stats.rules[check] ??= {})[kind] ??= { written: 0, fixed: 0, kept: 0 })
866
867// Adds `from` into `into` and returns `into`, which it changes; `from` stays
868// untouched. Examples stay capped at three per rule.
869const merge = (into: DayStats, from: DayStats): DayStats => {
870  for (const config of from.configs) if (!into.configs.includes(config)) into.configs.push(config)
871  for (const [kind, words] of Object.entries(from.words) as [Kind, number][]) {
872    into.words[kind] = (into.words[kind] ?? 0) + words
873  }
874  for (const [check, byKind] of Object.entries(from.rules)) {
875    for (const [kind, counts] of Object.entries(byKind) as [Kind, Counts][]) {
876      const total = countsOf(into, check, kind)
877      total.written += counts.written
878      total.fixed += counts.fixed
879      total.kept += counts.kept
880    }
881  }
882  into.fixEdits += from.fixEdits
883  for (const [check, examples] of Object.entries(from.examples)) {
884    into.examples[check] = [...new Set([...(into.examples[check] ?? []), ...examples])].slice(0, 3)
885  }
886  return into
887}
888
889// A timestamp's local calendar day, as YYYY-MM-DD, which sorts by date.
890const day = (ms: number): string => {
891  const date = new Date(ms)
892  const pad = (n: number) => String(n).padStart(2, '0')
893  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
894}
895
896// Documents count all their prose; the code types the bundled .vale.ini lints
897// count their comments. Keep these lists in step with its section glob.
898const DOCS = new Set(['md', 'mdx', 'markdown', 'txt', 'org', 'html'])
899const CODE = new Set(
900  'c h cc cpp hpp cs css go java kt js jsx mjs cjs ts tsx lua php py rb rs swift scala sh bash zsh'.split(' '),
901)
902
903// The file name's extension, lowercased; '' for a name without one, such as
904// CODEOWNERS, or a dotted folder above it.
905const extOf = (path: string): string => {
906  const name = basename(path)
907  return name.includes('.') ? name.slice(name.lastIndexOf('.') + 1).toLowerCase() : ''
908}
909
910const kindOf = (path: string): Kind | undefined =>
911  DOCS.has(extOf(path)) ? 'docs' : CODE.has(extOf(path)) ? 'comments' : undefined
912
913// Where a line comment starts, by the code type's own markers, so a Rust
914// attribute (#[derive]) or a shell flag (--output) never reads as one.
915const COMMENT_START: Record<string, RegExp> = {
916  py: /^#(?!!)/,
917  rb: /^#(?!!)/,
918  sh: /^#(?!!)/,
919  bash: /^#(?!!)/,
920  zsh: /^#(?!!)/,
921  lua: /^--/,
922  php: /^(?:\/\/+|#(?!\[)|\/?\*+)/,
923}
924const C_STYLE = /^(?:\/\/+|\/?\*+)/
925
926const wordCount = (text: string): number => text.match(/\p{L}[\p{L}'’-]*/gu)?.length ?? 0
927
928// Words in the added lines of `path`: a document's prose, without code
929// blocks, inline code, HTML tags or link targets; or the comment lines of
930// code, found by their leading marker. Close enough for a rate, not exact:
931// the fence state starts at each edit's first added line, and trailing
932// comments after code and Python docstrings go uncounted.
933const countWords = (lines: string[], path: string): number => {
934  const commentStart = COMMENT_START[extOf(path)] ?? C_STYLE
935  let inFence = false
936  let words = 0
937  for (const line of lines) {
938    const trimmed = line.trim()
939    if (kindOf(path) === 'docs') {
940      if (/^(```|~~~)/.test(trimmed)) inFence = !inFence
941      else if (!inFence) {
942        words += wordCount(trimmed.replace(/`[^`]*`|<[^>]*>/g, ' ').replace(/\]\([^)]*\)/g, ']'))
943      }
944    } else {
945      const marker = trimmed.match(commentStart)
946      if (marker) words += wordCount(trimmed.slice(marker[0].length))
947    }
948  }
949  return words
950}
951
952const byPosition = (a: ValeAlert, b: ValeAlert): number => a.Line - b.Line || a.Span[0] - b.Span[0]
953
954// The output of /prose-lint: a count and the configs that ran, then each file
955// (relative to `cwd` when inside it) with its alerts in file order, cut at
956// MAX_REPORTED alerts.
957const report = (linted: Linted[], cwd: string): string => {
958  const configs = [...new Set(linted.map(one => one.configLabel))].join(', ')
959  const files = linted
960    .flatMap(one => Object.entries(one.byFile))
961    .filter(([, found]) => found.length > 0)
962  const total = files.reduce((sum, [, found]) => sum + found.length, 0)
963  if (total === 0) return `prose-lint: no alerts found (${configs}).`
964
965  const lines = [
966    `prose-lint · ${plural(total, 'alert')} in ${plural(files.length, 'file')} · ${configs}`,
967  ]
968  let shown = 0
969  for (const [file, found] of files) {
970    if (shown === MAX_REPORTED) break
971    lines.push('', `\`${file.startsWith(`${cwd}/`) ? file.slice(cwd.length + 1) : file}\``)
972    for (const a of [...found].sort(byPosition).slice(0, MAX_REPORTED - shown)) {
973      lines.push(`- ${a.Line}:${a.Span[0]} ${a.Check}: ${a.Message}`)
974      shown++
975    }
976  }
977  if (total > shown) lines.push('', `(${total - shown} more not shown)`)
978  return lines.join('\n')
979}
980
981// The note Claude reads after a flagged edit, terse because it costs context
982// each time. It lists every alert, in file order within each section: later
983// edits report only the lines they add, so an alert left out would never
984// reach Claude. Errors encode hard rules, so they have no way out; Claude
985// keeps any other alert only by telling the user why, so a quiet reply means
986// it fixed them all.
987const describe = (found: ValeAlert[], configLabel: string): string => {
988  const inOrder = [...found].sort(byPosition)
989  const row = (a: ValeAlert) => `- ${a.Line}:${a.Span[0]} ${a.Message}`
990  const errors = inOrder.filter(a => a.Severity === 'error').map(row)
991  const others = inOrder.filter(a => a.Severity !== 'error').map(row)
992
993  return [
994    `Vale (${configLabel}). Don't mention fixes.`,
995    ...(errors.length > 0 ? ['Must fix:', ...errors] : []),
996    ...(others.length > 0 ? ['Fix, or tell the user why not:', ...others] : []),
997  ].join('\n')
998}
999
1000const basename = (path: string): string => path.slice(path.lastIndexOf('/') + 1)
1001
1002// The folder holding `path`: "/" for a file at the root, "." for a bare name.
1003const dirname = (path: string): string => {
1004  const slash = path.lastIndexOf('/')
1005  return slash < 0 ? '.' : path.slice(0, slash) || '/'
1006}
1007
types/index.d.ts 91 lines
1// What one flagged Edit or Write keeps for its transcript row.
2export type ProseReport = {
3  // Which Vale config produced the alerts, as the row names it: "bundled
4  // rules", or the path of a project's or the user's own .vale.ini.
5  configLabel: string
6  alerts: ProseAlert[]
7}
8
9// One Vale alert on a line Claude added, as the call's transcript row draws it.
10export type ProseAlert = {
11  // 1-based, in the file as the edit left it.
12  line: number
13  // The match within `text`: 1-based, inclusive, in characters.
14  start: number
15  end: number
16  // The words Vale matched, whole even when they run onto the next line.
17  match: string
18  // Vale's rule as Style.Rule, such as Newsroom.EmDashes.
19  check: string
20  // Vale's message, which quotes the match.
21  message: string
22  // The rule's Vale level; picks the colour of the underline and the dot.
23  severity: 'error' | 'warning' | 'suggestion'
24  // The whole line at the time of the edit; a later edit doesn't change it.
25  text: string
26  // Set when the turn that wrote it ends: gone from the file (by Claude's
27  // hand or the user's) or still there. A kept one turns fixed when a later
28  // turn's end finds it gone. Unset until the first verdict.
29  status?: 'fixed' | 'kept'
30}
31
32// Where the prose sits: a document's text, or the comments in code.
33export type Kind = 'docs' | 'comments'
34
35// What became of the alerts on prose Claude wrote. A written alert counts as
36// fixed or kept at the end of the next turn Claude answers, except when the
37// file no longer exists or Vale failed on it then: those count in neither.
38export type Counts = { written: number; fixed: number; kept: number }
39
40// One day's writing in one project: a session's own record until a later
41// session folds it into the day's, in $.store under stats/<day>/<project>
42// (/<session id> while unfolded).
43export type DayStats = {
44  // The configs that ran, as their labels: where this list changes, a jump in
45  // the all-rules rate comes from the rules, not the writing.
46  configs: string[]
47  // Prose words Claude added: lines of documents outside code blocks, and
48  // comment lines in code (counted by their comment marker, so approximate).
49  words: Partial<Record<Kind, number>>
50  // By Vale rule, such as Newsroom.EmDashes, then by kind.
51  rules: Record<string, Partial<Record<Kind, Counts>>>
52  // Writes and Edits to a file that still had open alerts: the round trips
53  // the alerts cost.
54  fixEdits: number
55  // Up to three matched words or phrases per rule, for the stats view.
56  examples: Record<string, string[]>
57}
58
59// An alert Claude wrote this session that isn't fixed yet: waiting for its
60// turn to end, or kept then and watched for a later fix.
61export type OpenAlert = {
62  // The tool_use_id of the Write or Edit that wrote it, whose transcript row
63  // shows it as fixed or kept once it has a verdict.
64  id: string
65  // Its position among that row's alerts.
66  index: number
67  // 'kept' once a turn's end found it still there; the stats count it then
68  // and never again, while the row can still turn it fixed.
69  status?: 'kept'
70  // The file's modification time at that lint, in Unix milliseconds: an
71  // unchanged file can't have fixed it, so the next turn's end skips the lint.
72  lintedMs?: number
73  // The written file's absolute path, as the Write or Edit named it.
74  file: string
75  // Vale's rule as Style.Rule.
76  check: string
77  // The words the rule matched; the turn's end looks for them again.
78  match: string
79  kind: Kind
80}
81
82declare module 'claude-code' {
83  interface PluginState {
84    'prose-lint': {
85      // Keyed by the Edit or Write call's tool_use_id.
86      reports: StateFamily<ProseReport>
87      open: OpenAlert[]
88    }
89  }
90}
91