SLOPSHOPPER

lint-guard

Deterministic guards that stop an agent from hiding lint and type diagnostics instead of fixing them.

newguardtoastprocess
v0.4.0MITupdated 2026-10-16lodado/shareds/packages/lint-guard
A shopper browsing a rack in a slop shop
README

lint-guard

Claude Code mod (needs Claude Code 2.1.287+). It enforces, with regexes and git diff instead of model judgment, the rule that an agent fixes a lint or type diagnostic rather than hiding it.

GuardEventResult
Edit, Write or MultiEdit adds eslint-disable, @ts-ignore, @ts-nocheck, as any, as unknown as, void <promise>, it.skip/.only/xit to a code file, or waitForTimeout to a test filetool.calldeny
git commit while the diff against HEAD adds any of those (covers edits made through Bash or other tools)tool.calldeny
A Bash command that skips git hooks: --no-verify on commit/push/merge/rebase/cherry-pick/am, git commit -n, HUSKY=0, SKIP=, core.hooksPathtool.calldeny (approving a prompt would still ship code the hooks never checked)
New waitFor, waitUntil, expect.poll, networkidle, setTimeout/sleep or a raised test timeout in a test filetool.call + session.appenda hint to use findBy*, a deferred barrier or fake timers is appended to the tool result Claude reads; nothing is asked
Edit of ESLint, Vitest/Jest, lint-staged, commitlint or .husky/ configuration that adds a loosening line (off/warn, ignores, exclude, `\\true, exit 0, --no-*, a raised timeout) or removes any other line; a tsconfig` change that loosens checkingtool.checkask
After an Edit, Write or MultiEdit of a code file: local ESLint (npx --no-install eslint) on that filetool.call + session.appendfindings on the lines the call wrote are appended to the tool result Claude reads

Edits that only add or tighten checks in that configuration pass without asking. A Write is compared with the file it replaces; if that file exists but cannot be read, the user is asked.

Only lines the call adds are scanned, so an existing match that an edit leaves in place is not blamed on the agent. Markdown and other non-code files are ignored.

The hooks fail closed: if the edit guard throws or times out on an Edit, Write, MultiEdit or Bash call, that call is denied; if the permission review fails, the user is asked (a deny from the rules still stands).

Limits

  • Tools that write files without a file_path/new_string shape (MCP editors, shell redirects) bypass the edit guard. The commit guard still catches what they add to tracked files; when the same command runs git add, untracked code files are scanned too. A file created by one command and committed by a later one without git add in it is only seen once staged.
  • A regex match inside a string or comment is still a hit. Approved exceptions: approve the ask, or start with --safe-mode.
  • Post-edit lint runs npx --no-install eslint on the edited file from the session directory, with a 45 second timeout. A project without ESLint or an ESLint config is skipped silently; any other failure is reported to Claude. It never runs --fix. Only findings on the lines the call wrote are shown, so existing problems do not become the agent's. Whole-repo lint stays in pnpm lint and the pre-push gate.
  • The edit lint blocks the next step while it runs, so a slow typed config makes every edit slower.

Develop

pnpm --filter @lodado/lint-guard-plugin test   # pure detectors, node --test
claude plugin test                             # mod-level tests, run in this directory
claude plugin validate .
claude --plugin-dir packages/lint-guard        # try it in one session

Install at user scope only, like every plugin in this repository:

claude plugin marketplace update my-vibe-coding-helper
claude plugin install lint-guard@my-vibe-coding-helper --scope user
  • Regexes, not a parser: a pattern built at run time (eval, variables assembled into a command) or an alias such as git ci is not recognised. Writing a whole existing file with Write counts every line as added, so a pre-existing match in it is reported.
Source 3 files
hooks/register.js 174 lines
1import {
2  describeHits,
3  evasionsInDiff,
4  evasionsInToolCall,
5  hookBypass,
6  isCodeFile,
7  isGateConfig,
8  isGitCommit,
9  slowTestReason,
10  stagesInSameCommand,
11  weakeningReason,
12} from './detect.js'
13import { diagnosticsInRanges, eslintUnavailable, formatDiagnostics, writtenRanges } from './lint.js'
14
15const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit'])
16const LINT_TIMEOUT_MS = 45_000
17
18const EVASION_MESSAGE =
19  'lint-guard: do not hide a diagnostic. Fix its cause. If an exception is truly needed, ask the user and let them approve it.'
20
21const bypassMessage = (id) =>
22  [
23    `lint-guard: the command skips git hooks (${id}). Run it without that, and let the hooks check it.`,
24    'If a hook fails, fix what it reports, even a failure that predates your change, then commit or push again.',
25    'If a hook is slow because it checks the whole repo, change it to check only the changed files instead of skipping it.',
26  ].join('\n')
27
28// `git diff HEAD` also covers `git add . && git commit` in one command, where nothing is staged yet.
29async function diffAgainstHead($) {
30  const head = await $.process.run(['git', 'diff', 'HEAD', '-U0', '--no-color'])
31  if (head.exitCode === 0) return head.stdout
32  // First commit of a repository has no HEAD.
33  const staged = await $.process.run(['git', 'diff', '--cached', '-U0', '--no-color'])
34  if (staged.exitCode === 0) return staged.stdout
35  throw new Error(staged.stderr.trim() || 'git diff failed')
36}
37
38const MAX_UNTRACKED = 200
39
40// New files `git add` is about to stage are not in `git diff HEAD`; read them as all-added.
41async function untrackedAsDiff($) {
42  // NUL-separated: without -z git quotes unusual names, which would not open.
43  const listed = await $.process.run(['git', 'ls-files', '-z', '--others', '--exclude-standard'])
44  if (listed.exitCode !== 0) throw new Error(listed.stderr.trim() || 'git ls-files failed')
45  const files = listed.stdout.split('\0').filter(isCodeFile)
46  if (files.length > MAX_UNTRACKED) {
47    throw new Error(
48      `${files.length} untracked code files exceed the ${MAX_UNTRACKED} the guard reads; commit in smaller steps`,
49    )
50  }
51  const parts = []
52  for (const file of files) {
53    const lines = (await $.fs.read(file)).split('\n')
54    parts.push(`+++ b/${file}`, `@@ -0,0 +1,${lines.length} @@`, ...lines.map((line) => `+${line}`))
55  }
56  return parts.join('\n')
57}
58
59async function guardToolCall($, e, next) {
60  const hits = evasionsInToolCall(e)
61  if (hits.length > 0) return { deny: `${EVASION_MESSAGE}\n${describeHits(hits)}` }
62
63  // Denied, not asked: an approved prompt still ships code the hooks never checked.
64  const bypass = e.tool === 'Bash' ? hookBypass(e.command) : null
65  if (bypass) return { deny: bypassMessage(bypass) }
66
67  if (e.tool === 'Bash' && isGitCommit(e.command)) {
68    let diff
69    try {
70      diff = await diffAgainstHead($)
71      if (stagesInSameCommand(e.command)) diff += `\n${await untrackedAsDiff($)}`
72    } catch (error) {
73      return { deny: `lint-guard: cannot inspect the diff before commit (${error.message})` }
74    }
75    const committed = evasionsInDiff(diff)
76    if (committed.length > 0) return { deny: `${EVASION_MESSAGE}\n${describeHits(committed)}` }
77  }
78  const result = await next(e)
79  if (!EDIT_TOOLS.has(e.tool)) return result
80  // A clock wait has legitimate uses (fake timers, a negative assertion), so Claude gets the hint
81  // and decides; the user is not asked.
82  const wait = slowTestReason(e)
83  if (wait) addNote($, e, `lint-guard: ${wait}. Keep it only when no element, event or barrier marks the moment.`)
84  return withLintFeedback($, e, result)
85}
86
87// Queues ESLint findings on the lines this call wrote for what Claude reads back. Never blocks:
88// the edit already happened, and a lint that cannot run must not look like a clean file.
89async function withLintFeedback($, e, result) {
90  const path = e.file_path
91  if (!isCodeFile(path)) return result
92  try {
93    const content = await $.fs.read(path)
94    const ranges = writtenRanges(e, content)
95    if (!ranges) return result
96    const run = await $.process.run(['npx', '--no-install', 'eslint', '--format', 'json', '--no-warn-ignored', path], {
97      timeoutMs: LINT_TIMEOUT_MS,
98    })
99    const messages = diagnosticsInRanges(run.stdout, ranges)
100    if (!messages) {
101      if (!eslintUnavailable(run.stderr)) addNote($, e, `lint-guard: ESLint did not run (${firstLine(run.stderr)})`)
102      return result
103    }
104    if (messages.length > 0) addNote($, e, formatDiagnostics(path, messages))
105    return result
106  } catch (error) {
107    addNote($, e, `lint-guard: lint feedback failed (${error.message})`)
108    return result
109  }
110}
111
112const firstLine = (text) => text.trim().split('\n')[0] || 'no output'
113
114// Claude reads a tool's result from the `tool_result` row the session stores, not from what
115// tool.call returns. A note is parked here by tool_use_id and joined to that row by session.append.
116const pendingNotes = new Map()
117
118function addNote($, e, note) {
119  if (!e.tool_use_id) return $.ui.toast(note)
120  const earlier = pendingNotes.get(e.tool_use_id)
121  pendingNotes.set(e.tool_use_id, earlier === undefined ? note : `${earlier}\n\n${note}`)
122}
123
124async function joinNotes($, e, next) {
125  if (e.door !== 'tool-result' || pendingNotes.size === 0) return next(e)
126  const content = e.message.content.map((block) => {
127    const note = block.type === 'tool_result' ? pendingNotes.get(block.tool_use_id) : undefined
128    if (note === undefined) return block
129    pendingNotes.delete(block.tool_use_id)
130    return Array.isArray(block.content)
131      ? { ...block, content: [...block.content, { type: 'text', text: note }] }
132      : { ...block, content: `${block.content}\n\n${note}` }
133  })
134  return next({ ...e, message: { ...e.message, content } })
135}
136
137async function reviewPermission($, e, next) {
138  const decision = await next(e)
139  // Unlike tool.call, tool.check keeps the tool's arguments under `input`.
140  const call = { tool: e.tool, ...e.input }
141  if (decision?.decision === 'deny') return decision
142  const reason = weakeningReason(call, await replacedContent($, call))
143  if (!reason) return decision
144  $.ui.toast(`lint-guard: ${reason}`)
145  return { decision: 'ask' }
146}
147
148// The configuration a Write is about to replace: '' when the file does not exist yet, undefined
149// when it exists but cannot be read (the guard then asks, since it cannot see what is dropped).
150async function replacedContent($, call) {
151  if (call.tool !== 'Write' || !isGateConfig(call.file_path)) return undefined
152  try {
153    return await $.fs.read(call.file_path)
154  } catch (error) {
155    return /ENOENT|no such file/i.test(String(error?.message)) ? '' : undefined
156  }
157}
158
159export function register(on) {
160  // A throwing hook is skipped by default, which would silently switch the guard off.
161  // Fail closed for the calls it guards; other tools are never held up by a guard bug.
162  on('tool.call', { tool: ['Edit', 'Write', 'MultiEdit', 'Bash'] }, guardToolCall).catch(async ($, e, next) => {
163    if (next.called) return next(e)
164    return { deny: `lint-guard failed (${next.error.kind}), so this ${e.tool} call was not run` }
165  })
166  // Same reasoning as above: if the review fails, ask the user rather than keep the earlier decision.
167  on('tool.check', { tool: ['Edit', 'Write', 'MultiEdit', 'Bash'] }, reviewPermission).catch(async ($, e, next) => {
168    // The review may fail after next(e) already answered; a deny from the rules still stands.
169    const decided = await next(e)
170    return decided?.decision === 'deny' ? decided : { decision: 'ask' }
171  })
172  on('session.append', joinNotes)
173}
174
hooks/detect.js 276 lines
1// Pure detectors for lint-guard. No Claude Code API here, so `node --test` can exercise them.
2
3const CODE_FILE = /\.(?:[cm]?[jt]sx?|vue|svelte|astro)$/i
4
5// A diagnostic hidden instead of fixed. Each pattern runs against one added line.
6const EVASIONS = [
7  { id: 'eslint-disable', re: /eslint-disable/, hint: 'fix the cause of the diagnostic' },
8  { id: 'ts-ignore', re: /@ts-(?:ignore|nocheck)\b/, hint: 'fix the type error' },
9  { id: 'as-any', re: /\bas\s+any\b/, hint: 'give the value a real type' },
10  { id: 'as-unknown-as', re: /\bas\s+unknown\s+as\b/, hint: 'narrow the value instead of asserting it' },
11  { id: 'void-promise', re: /^\s*void\s+(?!0\b)[A-Za-z_$]/, hint: 'await or handle the promise' },
12  {
13    id: 'test-skip',
14    re: /\b(?:it|test|describe)(?:\.\w+)*\.(?:skip|only|fixme)\b|\bx(?:it|describe)\s*\(/,
15    hint: 'fix or delete the test only when its behavior is confirmed wrong',
16  },
17]
18
19const MULTILINE = new Set(['as-any', 'as-unknown-as'])
20
21// Waiting on the clock in a test guesses when state settles. `waitForTimeout` is always a guess.
22// The rest have legitimate uses (fake timers, a negative assertion), so the user decides.
23const TEST_FILE = /\.(?:test|spec)\.[cm]?[jt]sx?$|(?:^|\/)(?:e2e|e2e-live|__tests__)\//i
24const TEST_WAIT_DENIED = [
25  {
26    id: 'wait-for-timeout',
27    re: /\bwaitForTimeout\s*\(/,
28    hint: 'wait for an element or network condition, or release a deferred barrier',
29  },
30]
31const TEST_WAIT_ASKED = [
32  { id: 'wait-for', re: /\bwaitFor(?:ElementToBeRemoved)?\s*\(|\bwaitUntil\s*\(|expect\.poll\s*\(/ },
33  { id: 'network-idle', re: /networkidle/ },
34  { id: 'clock-wait', re: /\bsetTimeout\s*\(|\bsleep\s*\(/ },
35  { id: 'raised-timeout', re: /\btestTimeout\b|\b(?:jest|vi|test)\.setTimeout\s*\(|\bvi\.setConfig\s*\(/ },
36]
37
38// Lint, test and hook configuration. Weakening these hides diagnostics for every file.
39const GATE_CONFIG =
40  /(?:^|\/)(?:eslint\.config\.[cm]?[jt]s|\.eslintrc(?:\.[\w]+)?|\.eslintignore|(?:vitest|jest)\.(?:config|workspace)[\w.-]*|\.lintstagedrc[\w.]*|lint-staged\.config[\w.]*|commitlint\.config[\w.]*|\.husky\/[^\s'"]+)$/
41const GATE_CONFIG_IN_COMMAND =
42  /eslint\.config\.[cm]?[jt]s|\.eslintrc|\.eslintignore|(?:vitest|jest)\.(?:config|workspace)|\.lintstagedrc|lint-staged\.config|commitlint\.config|\.husky\//
43// A line that loosens a gate: a rule turned off or down, a path ignored, a step that cannot fail.
44// Adding one asks the user; removing one tightens the gate. Adding a check never asks.
45// Rule options (`['error', { allow: [...] }]`, `max: 900`) can loosen in ways no word list covers,
46// so any severity followed by an options object counts.
47const GATE_LOOSENING =
48  /["'](?:off|warn)["']|:\s*\[?\s*[01]\b|\[\s*(?:["'](?:error|warn)["']|[012])\s*,\s*\{|\b(?:ignore|allow|skip|except)\w*|\bexclude\b|passWithNoTests|\bbail\b|testTimeout|\.(?:skip|only)\b|--no-[\w-]+|\|\|\s*(?:true|:)|\bexit\s+0\b/
49const TSCONFIG = /(?:^|\/)tsconfig[\w.-]*\.json$/
50const TSCONFIG_WEAKENING =
51  /"(?:strict|noImplicit\w+|strict\w+|noUnchecked\w+|noUnused\w+)"\s*:\s*false|"(?:exclude|skipLibCheck|checkJs)"/
52
53const BYPASSES = [
54  { id: 'no-verify', re: /\bgit\s+(?:commit|push|merge|rebase|cherry-pick|am)\b[^|;&\n]*--no-ve\w*/ },
55  { id: 'commit-n', re: /\bgit\s+commit\b[^|;&\n]*\s-[A-Za-z]*n[A-Za-z]*(?=\s|$)/ },
56  { id: 'husky-off', re: /\bHUSKY\s*=\s*0\b/ },
57  { id: 'skip-hooks', re: /\b(?:SKIP|HUSKY_SKIP_HOOKS)\s*=/ },
58  { id: 'hooks-path', re: /\bgit\b[^|;&\n]*\bcore\.hooksPath\b/ },
59]
60const SHELL_WRITE = /\bsed\s+-[A-Za-z]*i|\bperl\s+-[A-Za-z]*i|\btee\b|>>?\s*\S|\b(?:mv|cp|rm)\s/
61
62// The id of the git hook bypass in a shell command, or null.
63export const hookBypass = (command) => BYPASSES.find((entry) => entry.re.test(command ?? ''))?.id ?? null
64
65export const isCodeFile = (path) => CODE_FILE.test(path ?? '')
66export const isGateConfig = (path) => GATE_CONFIG.test(path ?? '')
67export const isTestFile = (path) => TEST_FILE.test(path ?? '')
68
69// Lines of `next` that `previous` does not already contain, so an untouched
70// pre-existing match in a large edit is not blamed on the agent. Duplicates count.
71export function addedLines(previous, next) {
72  const budget = new Map()
73  for (const line of (previous ?? '').split('\n')) budget.set(line, (budget.get(line) ?? 0) + 1)
74  const added = []
75  for (const line of (next ?? '').split('\n')) {
76    const left = budget.get(line) ?? 0
77    if (left > 0) budget.set(line, left - 1)
78    else added.push(line)
79  }
80  return added
81}
82
83export function findEvasions(lines, path = '') {
84  const hits = []
85  const rules = isTestFile(path) ? [...EVASIONS, ...TEST_WAIT_DENIED] : EVASIONS
86  lines.forEach((text, index) => {
87    for (const evasion of rules) {
88      if (evasion.re.test(text)) hits.push({ id: evasion.id, hint: evasion.hint, line: index + 1, text: text.trim() })
89    }
90  })
91  // A type assertion may break across lines (`as\n  any`), which no single-line pattern sees.
92  const joined = lines.join('\n')
93  for (const evasion of EVASIONS.filter((entry) => MULTILINE.has(entry.id))) {
94    for (const match of joined.matchAll(new RegExp(evasion.re.source, 'g'))) {
95      const line = joined.slice(0, match.index).split('\n').length
96      if (!hits.some((hit) => hit.id === evasion.id && hit.line === line)) {
97        hits.push({ id: evasion.id, hint: evasion.hint, line, text: lines[line - 1].trim() })
98      }
99    }
100  }
101  return hits
102}
103
104// Added lines per edit tool call; null when the call writes nothing this guard reads.
105export function addedByToolCall(e) {
106  const path = e.file_path ?? e.notebook_path
107  if (e.tool === 'Write') return { path, lines: addedLines('', e.content) }
108  if (e.tool === 'Edit') return { path, lines: addedLines(e.old_string, e.new_string) }
109  if (e.tool === 'MultiEdit') {
110    const lines = (e.edits ?? []).flatMap((edit) => addedLines(edit.old_string, edit.new_string))
111    return { path, lines }
112  }
113  return null
114}
115
116export function evasionsInToolCall(e) {
117  const written = addedByToolCall(e)
118  if (!written || !isCodeFile(written.path)) return []
119  return findEvasions(written.lines, written.path).map((hit) => ({ ...hit, file: written.path }))
120}
121
122const SIMPLE_ESCAPES = { a: 7, b: 8, t: 9, n: 10, v: 11, f: 12, r: 13, '"': 34, '\\': 92 }
123
124// git quotes a path with non-ASCII bytes, tabs or quotes as a C string: "b/\303\251.ts".
125function diffPath(target) {
126  const raw = target.endsWith('\t') ? target.slice(0, -1) : target
127  if (!raw.startsWith('"')) return raw.replace(/^b\//, '')
128  const inner = raw.slice(1, -1)
129  const bytes = []
130  for (let at = 0; at < inner.length; at += 1) {
131    const octal = /^\\([0-3][0-7]{2})/.exec(inner.slice(at, at + 4))
132    if (octal) {
133      bytes.push(parseInt(octal[1], 8))
134      at += 3
135    } else if (inner[at] === '\\' && inner[at + 1] in SIMPLE_ESCAPES) {
136      bytes.push(SIMPLE_ESCAPES[inner[at + 1]])
137      at += 1
138    } else {
139      bytes.push(...Buffer.from(inner[at]))
140    }
141  }
142  return Buffer.from(bytes).toString('utf8').replace(/^b\//, '')
143}
144
145// Added lines per file from `git diff -U0`, with the new-file line number of each.
146export function parseDiff(diff) {
147  const files = []
148  let file = null
149  let line = 0
150  // Rows still owed to the current hunk. Inside a hunk every `+`/`-` row is content, even one
151  // that reads `+++ b/other` (an added line `++ b/other`), so headers are only read outside it.
152  let added = 0
153  let removed = 0
154  for (const row of diff.split('\n')) {
155    if (added > 0 && row.startsWith('+')) {
156      file?.added.push({ line, text: row.slice(1) })
157      line += 1
158      added -= 1
159      continue
160    }
161    if (removed > 0 && row.startsWith('-')) {
162      removed -= 1
163      continue
164    }
165    if (row.startsWith('\\')) continue
166    added = 0
167    removed = 0
168    if (row.startsWith('+++ ')) {
169      const target = row.slice(4)
170      file = target === '/dev/null' ? null : { path: diffPath(target), added: [] }
171      if (file) files.push(file)
172      continue
173    }
174    const hunk = /^@@ -\d+(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(row)
175    if (hunk) {
176      removed = hunk[1] === undefined ? 1 : Number(hunk[1])
177      line = Number(hunk[2])
178      added = hunk[3] === undefined ? 1 : Number(hunk[3])
179    }
180  }
181  return files
182}
183
184export function evasionsInDiff(diff) {
185  return parseDiff(diff)
186    .filter((file) => isCodeFile(file.path))
187    .flatMap((file) =>
188      findEvasions(
189        file.added.map((entry) => entry.text),
190        file.path,
191      ).map((hit) => ({
192        ...hit,
193        file: file.path,
194        line: file.added[hit.line - 1].line,
195      })),
196    )
197}
198
199// `FOO=1 git commit`, `env`, `sudo`, `time` and the like in front of the command.
200const ASSIGNMENT = /^(?:[A-Za-z_]\w*=\S*\s+)+/
201const WRAPPER = /^(?:(?:env|command|time|sudo|nice|nohup|exec|builtin)(?:\s+-\S+(?:\s+\d+)?)*\s+)+/
202const GIT_COMMIT = /^git\s+(?:-{1,2}[\w-]+(?:=\S*)?(?:\s+(?!commit\b)\S+)?\s+)*commit\b/
203const SHELL_C = /-c\s+(['"])([\s\S]*?)\1/g
204
205export function isGitCommit(command = '') {
206  const inner = [...command.matchAll(SHELL_C)].map((match) => match[2])
207  return [command, ...inner].some((text) =>
208    text.split(/[;&|()\n]+/).some((segment) => {
209      let rest = segment.trim()
210      for (let next = ''; next !== rest; ) {
211        next = rest
212        rest = rest.replace(ASSIGNMENT, '').replace(WRAPPER, '')
213      }
214      return GIT_COMMIT.test(rest)
215    }),
216  )
217}
218
219// `git add . && git commit` stages after the guard runs, so new untracked files must be read too.
220export const stagesInSameCommand = (command = '') => /\bgit\b[^;&|\n]*\badd\b/.test(command)
221
222// Lines an edit tool call takes out. A Write needs the file it replaces: `previous` is '' for a
223// new file and undefined when it could not be read. null when the removed lines are unknown.
224function removedByToolCall(e, previous) {
225  if (e.tool === 'Edit') return addedLines(e.new_string, e.old_string)
226  if (e.tool === 'MultiEdit') return (e.edits ?? []).flatMap((edit) => addedLines(edit.new_string, edit.old_string))
227  if (e.tool === 'Write' && typeof previous === 'string') return addedLines(e.content, previous)
228  return null
229}
230
231function gateLoosening(e, added, previous) {
232  const loose = added.find((line) => GATE_LOOSENING.test(line))
233  if (loose) return `adds "${loose.trim()}"`
234  const removed = removedByToolCall(e, previous)
235  if (!removed) return 'replaces a file it could not read first'
236  // Brackets and blank lines move when a block is reshaped; they hold no check.
237  const dropped = removed.find((line) => /\w/.test(line) && !GATE_LOOSENING.test(line))
238  return dropped ? `removes "${dropped.trim()}"` : null
239}
240
241// Why a tool call should wait for the user, or null. Evasion is handled separately and denied.
242export function weakeningReason(e, previous) {
243  if (e.tool === 'Bash') {
244    const command = e.command ?? ''
245    const touched = GATE_CONFIG_IN_COMMAND.exec(command)
246    if (touched && SHELL_WRITE.test(command)) return `shell command writes lint/test/hook configuration (${touched[0]})`
247    return null
248  }
249  const written = addedByToolCall(e)
250  if (!written?.path) return null
251  if (GATE_CONFIG.test(written.path)) {
252    const loosening = gateLoosening(e, written.lines, previous)
253    return loosening ? `loosens lint/test/hook configuration (${written.path}): ${loosening}` : null
254  }
255  if (TSCONFIG.test(written.path) && written.lines.some((line) => TSCONFIG_WEAKENING.test(line))) {
256    return `loosens TypeScript checking (${written.path})`
257  }
258  return null
259}
260
261// Waits in a test that the user should confirm: new lines only, test files only.
262export function slowTestReason(e) {
263  const written = addedByToolCall(e)
264  if (!written || !isTestFile(written.path)) return null
265  for (const line of written.lines) {
266    const rule = TEST_WAIT_ASKED.find((entry) => entry.re.test(line))
267    if (rule)
268      return `test waits on the clock or raises a timeout (${rule.id}) in ${written.path}: prefer findBy*, a deferred barrier or fake timers`
269  }
270  return null
271}
272
273export function describeHits(hits) {
274  return hits.map((hit) => `${hit.file}:${hit.line} ${hit.id} -> ${hit.hint}`).join('\n')
275}
276
hooks/lint.js 59 lines
1// Pure helpers for post-edit lint feedback. The Claude Code calls live in register.js.
2
3const MAX_LINES = 15
4
5// 1-based inclusive line ranges the call wrote, found in the file as it is now.
6// null when the call cannot be located (it failed, or the file changed again).
7export function writtenRanges(e, content) {
8  if (e.tool === 'Write') return [[1, Infinity]]
9  const texts = e.tool === 'MultiEdit' ? (e.edits ?? []).map((edit) => edit.new_string) : [e.new_string]
10  const ranges = []
11  for (const text of texts) {
12    if (!text) continue
13    const at = content.indexOf(text)
14    if (at < 0) return null
15    const start = content.slice(0, at).split('\n').length
16    ranges.push([start, start + text.split('\n').length - 1])
17  }
18  return ranges.length > 0 ? ranges : null
19}
20
21const overlaps = (message, ranges) => {
22  const first = message.line ?? 1
23  const last = message.endLine ?? first
24  return ranges.some(([from, to]) => first <= to && last >= from)
25}
26
27// ESLint JSON output -> diagnostics on the lines this call wrote. A parse error (no ruleId)
28// breaks the whole file, so it is always kept.
29export function diagnosticsInRanges(stdout, ranges) {
30  let report
31  try {
32    report = JSON.parse(stdout)
33  } catch {
34    return null
35  }
36  if (!Array.isArray(report)) return null
37  return report.flatMap((file) => file.messages ?? []).filter((message) => message.fatal || overlaps(message, ranges))
38}
39
40export function formatDiagnostics(path, messages) {
41  const rows = messages.slice(0, MAX_LINES).map((message) => {
42    const level = message.severity === 2 || message.fatal ? 'error' : 'warn'
43    return `  ${path}:${message.line ?? 1}:${message.column ?? 1} ${level} ${message.message} (${
44      message.ruleId ?? 'parse'
45    })`
46  })
47  const hidden = messages.length - rows.length
48  const more = hidden > 0 ? `\n  ...and ${hidden} more` : ''
49  return `lint-guard: ESLint found ${
50    messages.length
51  } problem(s) on the lines you just wrote. Fix the cause, do not hide it.\n${rows.join('\n')}${more}`
52}
53
54// No ESLint or no config in this project is not a failure worth reporting.
55export const eslintUnavailable = (stderr) =>
56  /could not determine executable|not found|no eslint configuration|couldn't find an eslint\.config|could not find config|enoent|no such file/i.test(
57    stderr,
58  )
59