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

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.
| Guard | Event | Result | ||
|---|---|---|---|---|
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 file | tool.call | deny | ||
git commit while the diff against HEAD adds any of those (covers edits made through Bash or other tools) | tool.call | deny | ||
A Bash command that skips git hooks: --no-verify on commit/push/merge/rebase/cherry-pick/am, git commit -n, HUSKY=0, SKIP=, core.hooksPath | tool.call | deny (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 file | tool.call + session.append | a 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 checking | tool.check | ask |
After an Edit, Write or MultiEdit of a code file: local ESLint (npx --no-install eslint) on that file | tool.call + session.append | findings 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).
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.ask, or start with --safe-mode.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.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
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.hooks/register.js 174 lines1import {
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}
174hooks/detect.js 276 lines1// 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}
276hooks/lint.js 59 lines1// 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