Puts tsc, linter and secret-scan diagnostics in the result of every Edit and Write, so Claude fixes what it broke in the same turn

A Claude Code mod that checks every file Claude edits. After each Edit or Write, it runs tsc and the linters your project uses, then puts what the edit broke into that same tool result. Claude sees a type error in the turn it caused it, not three tool calls later when a test fails.
It is the Claude Code version of pi-lens.
After an edit that changes a function signature:
[lens] src/a.ts: nothing new introduced in this file (biome, oxlint, tsc).
(1 pre-existing in this file, not shown)
Errors this turn introduced in other files:
error src/b.ts:2:25 Argument of type 'string' is not assignable to parameter of type 'number'. [tsc TS2345] #4429ff
Fix what this edit introduced before moving on. If a finding is wrong, call lens_diagnostic_mark with its #id.
After a clean edit: [lens] src/b.ts: no new issues (biome, oxlint, tsc).
Reports list only what the current turn introduced. Every tool runs once before Claude's first edit of a file in a turn, so the report can compare. Problems that were already there are counted, not listed.
tsconfig.json gets one tsc --noEmit --watch for the session, so an edit costs an incremental check instead of a cold tsc run. A root tsconfig.json is warmed at session start. The watcher reports errors across the whole project, so an edit that breaks another file shows up as a cascade. A watcher with no TypeScript edit for 10 minutes is stopped.| Files | Tools |
|---|---|
| JS/TS | eslint (with an eslint config), biome, oxlint (when in node_modules) |
| CSS/SCSS/Less | stylelint (with a config) |
| Python | ruff, pyright, mypy (with a mypy config); also found in .venv/bin |
| Go | go vet on the package (with go.mod) |
| Rust | cargo clippy on the crate (with Cargo.toml) |
| Shell | shellcheck |
| Ruby | rubocop (with .rubocop.yml) |
| YAML / GitHub workflows | yamllint (with a config), actionlint |
| Dockerfile | hadolint |
You can add any other tool to .lens.json (see Config below).
.env files are skipped.eslint --fix, biome check --write, prettier, ruff check --fix, ruff format, gofmt, rustfmt. Each one runs only if the project configures it. This runs once at the end, never between edits, because a formatter running mid-change fights the edits still being made.git commit and git push while files the session edited still have errors.lens ✗2 ⚠5 at the right of the prompt footer for current errors and warnings, or lens ✓.lens_diagnostics: with a path, checks that file now and lists every finding, old and new. Without one, lists everything the session has found.lens_diagnostic_mark: marks a finding by its #id as false-positive (hidden in this project from then on) or defer (hidden for this session)./lens lists every finding in the session. /lens clear-marks removes all false-positive and defer marks./lens-health shows the tsc watchers, how often each runner ran and failed and its average time, tools that aren't installed, and recent degradations (crashes, timeouts, stopped watchers).Three switches appear in /config under the plugin's options:
| Option | Default | What it does |
|---|---|---|
stopGuard | on | Send Claude back once when a turn leaves errors it introduced |
tidy | on | Run the configured fixers and formatters at turn end |
gitGuard | off | Deny git commit and git push while edited files have errors |
Per project, add .lens.json at the root:
{
"disable": ["oxlint", "prettier", "tsc"],
"runners": [
{ "id": "vale", "match": "\\.mdx?$", "argv": ["vale", "--output=line", "{file}"], "format": "gcc" }
]
}
disable takes runner ids. Checkers: tsc eslint biome oxlint stylelint ruff pyright mypy go-vet clippy shellcheck rubocop yamllint actionlint hadolint. Fixers: eslint-fix biome-fix prettier ruff-fix ruff-format gofmt rustfmt.format is one of:gcc: lines like file:line:col: messagegithub: ::error file=…,line=…::messageeslint: ESLint's JSON outputcwd is "dir" to run in the file's folder. Otherwise it runs in the project root.A runner runs the command you give it, just as a hook in .claude/settings.json does. So only trust a .lens.json you would also trust as settings.
| pi-lens | here |
|---|---|
| Per-edit LSP, linter and type-check diagnostics | Warm tsc --watch, plus the linters above, injected into the Edit/Write result |
| Delta mode (new diagnostics only) | Same: compared with the file before this turn's first edit |
| Cascade diagnostics | Errors tsc, go vet or clippy find in other files |
| Secrets in the write pipeline | Same, using token-shape patterns |
| Deferred autofix and format | Same: at turn end |
| Turn-end findings and blockers | One follow-up prompt per turn |
| Git guard | Same, off by default |
lens_diagnostics, lens_diagnostic_mark | Same names; dispositions are false-positive and defer |
/lens-health and its degradation ledger | Same |
| Widget and footer tally | Footer tally, at the right of the prompt |
| Read-before-edit guard | Not needed: Claude Code's Edit already refuses a file Claude hasn't read |
lsp_navigation | Not included: Claude Code has an LSP tool |
ast-grep and tree-sitter rules, module_report, read_symbol, /lens-map, session-wide gitleaks/trivy/knip scans | Not included |
/plugin install lens --marketplace PedroLaRosa/claude-code
Answer y to add the marketplace, then pick the user scope so it loads in every session.
Requirements: Claude Code with mods (built on 2.1.291), plus whichever of the tools above your projects use.
git clone https://github.com/PedroLaRosa/claude-code ~/claude-code
claude --plugin-dir ~/claude-code/mods/lens
To load it in every session, add the folder to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json.
The tool table, output parsers and secret patterns live in hooks/lens.ts. The hooks, tsc watchers and turn state live in hooks/register.ts. Run the tests with claude plugin test ..
To type-check, run tsc with a bigger heap, because the engine's declarations are about 1.3 MB:
NODE_OPTIONS=--max-old-space-size=12288 tsc -p .
tsconfig.json. A solution-style tsconfig that only has references isn't followed. A file the tsconfig doesn't include gets no tsc check, but the report still lists tsc as having run.go vet, clippy) and a symlinked project path, findings in the edited file may be shown as findings in other files.sed -i) aren't checked directly, but the tsc watcher still sees their effect.hooks/register.ts 609 lines1import type { EngineInterface, FsEntry, Register, ToolCallResult } from 'claude-code'
2import { type Diag, type Tool, TOOLS, TS, bySeverity, customTool, idOf, keyOf, lineOf, parse, resolvePath, scanSecrets, tally, tscLine } from './lens'
3
4type Plan = { tool: Tool; bin: string; cwd: string; scope: string }
5type Config = { disable: string[]; runners: Tool[] }
6type Watch = {
7 tsconfig: string
8 dir: string
9 state: 'starting' | 'ready' | 'stopped'
10 started: number // compile cycles begun
11 finished: number // compile cycles done
12 cycleAt: number
13 lastMs: number
14 pending: Diag[]
15 wake: (() => void)[]
16 late: Set<string> // files whose edit report went out before this watcher's verdict
17 idle?: { cancel: () => void }
18 stop: () => void
19}
20
21const NAME = 'lens'
22const IDLE_MS = 10 * 60_000 // a tsc watcher with no TypeScript edit for this long is stopped
23const NOTICE_MS = 1_500 // how long tsc gets to notice an edit (its file watcher debounces ~250 ms)
24const RESERVE_MS = 1_500 // kept back from the hook's 10 s budget
25const RUN_TIMEOUT_MS = 120_000
26const SHOW = 20
27const CASCADE = 10
28const LISTED = 60
29const LEDGER = 30
30const ENV = { NO_COLOR: '1', FORCE_COLOR: '0' }
31const GIT_WRITE = /\bgit\s+(?:-C\s+\S+\s+|-\S+\s+)*(?:commit|push)\b/
32
33// Session state. A scope is one run's coverage (`eslint:/a.ts`, `go-vet:/pkg`, `tsc:/tsconfig.json`) and holds
34// its latest full set, LSP style; `baseline` is each scope's set before this turn first changed it.
35let root = '/'
36let falsePositives: Record<string, string> = {}
37let recheck: 'queued' | 'running' | undefined // the guard's one follow-up turn
38let label = '' // the tally drawn at the right of the prompt footer
39const known = new Map<string, Diag[]>()
40const baseline = new Map<string, Set<string>>()
41const checkedAt = new Map<string, number>() // file -> mtime its diagnostics were taken at
42const turnFiles = new Set<string>()
43const sessionFiles = new Set<string>()
44const deferred = new Set<string>()
45const watches = new Map<string, Watch>()
46const late: string[] = []
47const stats = new Map<string, { runs: number; fails: number; ms: number }>()
48const missing = new Set<string>()
49const ledger: string[] = []
50// Lookups, dropped when a person's prompt opens a turn so tools installed meanwhile are found.
51const listings = new Map<string, Promise<Set<string>>>()
52const texts = new Map<string, Promise<string>>()
53const bins = new Map<string, Promise<string | null>>()
54
55const dirname = (p: string) => p.slice(0, p.lastIndexOf('/')) || '/'
56const join = (dir: string, name: string) => (dir === '/' ? `/${name}` : `${dir}/${name}`)
57const rel = (p: string) => (p.startsWith(`${root}/`) ? p.slice(root.length + 1) : p)
58const firstLine = (s: string) => s.trim().split('\n')[0]!.slice(0, 200)
59const muted = (d: Diag) => deferred.has(keyOf(d)) || keyOf(d) in falsePositives
60
61// Keeps `text` for /lens-health and answers the debug log's line for it.
62function note(text: string) {
63 ledger.push(`${new Date().toTimeString().slice(0, 8)} ${text}`)
64 if (ledger.length > LEDGER) ledger.shift()
65 return `${NAME}: ${text}`
66}
67
68function cached<T>(map: Map<string, Promise<T>>, key: string, make: () => Promise<T>): Promise<T> {
69 let hit = map.get(key)
70 if (!hit) map.set(key, (hit = make()))
71 return hit
72}
73function names(dir: string, list: (dir: string) => Promise<FsEntry[]>) {
74 return cached(listings, dir, () => list(dir).then(entries => new Set(entries.map(e => e.name)), () => new Set<string>()))
75}
76function textOf($: EngineInterface, path: string) {
77 return cached(texts, path, () => $.fs.read(path).catch(() => ''))
78}
79
80/** The nearest folder from `dir` up that holds one of `files` (`name#text`: one holding `text`). */
81async function findUp($: EngineInterface, dir: string, files: readonly string[]): Promise<string | undefined> {
82 for (let d = dir; ; d = dirname(d)) {
83 const here = await names(d, p => $.fs.list(p))
84 for (const f of files) {
85 const [name, text] = f.split('#') as [string, string?]
86 if (here.has(name) && (!text || (await textOf($, join(d, name))).includes(text))) return d
87 }
88 if (d === '/') return
89 }
90}
91
92/** Where `bin` lives for a file in `dir`: a project-local install first, then PATH unless `local`. */
93function resolveBin($: EngineInterface, bin: string, dir: string, local?: true): Promise<string | null> {
94 return cached(bins, `${bin}\0${dir}\0${local ?? ''}`, async () => {
95 if (bin.includes('/')) return resolvePath(root, bin)
96 for (let d = dir; ; d = dirname(d)) {
97 for (const sub of ['node_modules/.bin', '.venv/bin', 'venv/bin'])
98 if ((await names(join(d, sub), p => $.fs.list(p))).has(bin)) return join(d, `${sub}/${bin}`)
99 if (d === '/') break
100 }
101 if (local) return null
102 const found = await $.process.run(['which', bin]).catch(() => undefined)
103 return found?.exitCode === 0 ? found.stdout.trim() || null : null
104 })
105}
106
107/** `.lens.json` at the project root: runners to `disable`, and `runners` of the project's own. */
108async function config($: EngineInterface): Promise<Config> {
109 const text = await $.fs.read(join(root, '.lens.json')).catch(() => undefined)
110 if (text === undefined) return { disable: [], runners: [] }
111 try {
112 const raw = JSON.parse(text) as { disable?: unknown; runners?: unknown }
113 return {
114 disable: Array.isArray(raw.disable) ? raw.disable.filter((s): s is string => typeof s === 'string') : [],
115 runners: Array.isArray(raw.runners) ? raw.runners.map(customTool) : [],
116 }
117 } catch (err) {
118 $.ui.log(note(`.lens.json ignored: ${(err as Error).message}`), { to: 'debug' })
119 return { disable: [], runners: [] }
120 }
121}
122
123/** The tools that apply to `file`, installed and gated: its checkers, or with `fixers` its fixers. */
124async function plan($: EngineInterface, file: string, fixers: boolean, cfg: Config): Promise<Plan[]> {
125 const dir = dirname(file)
126 const plans: Plan[] = []
127 for (const tool of [...cfg.runners, ...TOOLS]) {
128 if (!tool.format !== fixers || !tool.match.test(file) || cfg.disable.includes(tool.id)) continue
129 const gate = tool.gate && (await findUp($, dir, tool.gate))
130 if (tool.gate && !gate) continue
131 const bin = await resolveBin($, tool.bin, dir, tool.local)
132 if (!bin) {
133 if (!tool.local) missing.add(tool.bin)
134 continue
135 }
136 const cwd = tool.cwd === 'gate' && gate ? gate : tool.cwd === 'dir' ? dir : root
137 plans.push({ tool, bin, cwd, scope: `${tool.id}:${tool.wide ? cwd : file}` })
138 }
139 return plans
140}
141
142/** Replaces a scope's diagnostics. `before`: they are the picture ahead of this turn's edit. */
143function record(scope: string, diags: Diag[], before = false) {
144 const prev = known.get(scope)
145 if (before) baseline.set(scope, new Set(diags.map(keyOf)))
146 else if (prev && !baseline.has(scope)) baseline.set(scope, new Set(prev.map(keyOf)))
147 known.set(scope, diags)
148}
149
150/** Whether this turn introduced `d`: not in its scope's baseline or, with none, in a file the turn edited. */
151function isNew(d: Diag, scope: string) {
152 const before = baseline.get(scope)
153 return before ? !before.has(keyOf(d)) : turnFiles.has(d.file)
154}
155
156function* live() {
157 for (const [scope, diags] of known) for (const d of diags) if (!muted(d)) yield { d, scope }
158}
159
160async function runCheck($: EngineInterface, p: Plan, file: string, before = false): Promise<boolean> {
161 const s = stats.get(p.tool.id) ?? { runs: 0, fails: 0, ms: 0 }
162 stats.set(p.tool.id, s)
163 s.runs++
164 const t0 = Date.now()
165 try {
166 const r = await $.process.run([p.bin, ...p.tool.argv([file])], { cwd: p.cwd, env: ENV, timeoutMs: RUN_TIMEOUT_MS })
167 const out = p.tool.format === 'eslint' ? r.stdout : `${r.stdout}\n${r.stderr}`
168 const diags = (() => {
169 try {
170 return parse(p.tool.format!, out, p.cwd, p.tool.id, p.tool.severity)
171 } catch {
172 return undefined
173 }
174 })()
175 if (!diags || (!diags.length && r.exitCode > 1)) throw new Error(`exit ${r.exitCode}: ${firstLine(r.stderr || r.stdout) || 'no output'}`)
176 // A one-file tool speaks of that file, however it spells the path.
177 record(p.scope, p.tool.wide ? diags : diags.map(d => ({ ...d, file })), before)
178 return true
179 } catch (err) {
180 s.fails++
181 $.ui.log(note(`${p.tool.id} on ${rel(file)} failed: ${(err as Error).message}`), { to: 'debug' })
182 return false
183 } finally {
184 s.ms += Date.now() - t0
185 }
186}
187
188async function scanFile($: EngineInterface, file: string, before = false) {
189 if (/(?:^|\/)\.env(?:\.|$)/.test(file)) return
190 const text = await $.fs.read(file).catch(() => undefined) // over 4 MiB: not scanned
191 if (text !== undefined) record(`secrets:${file}`, scanSecrets(text, file), before)
192}
193
194/** Takes the "before" picture of an existing file this turn has not edited yet: reused when nothing moved since. */
195async function beforeEdit($: EngineInterface, file: string, checks: Plan[]) {
196 const stat = await $.fs.stat(file).catch(() => undefined)
197 if (!stat) return // a new file has no before
198 const isUnchanged = checkedAt.get(file) === stat.mtimeMs
199 await Promise.all([
200 ...checks
201 .filter(p => !baseline.has(p.scope))
202 .map(p =>
203 isUnchanged && !p.tool.wide && known.has(p.scope)
204 ? baseline.set(p.scope, new Set(known.get(p.scope)!.map(keyOf)))
205 : runCheck($, p, file, true)),
206 baseline.has(`secrets:${file}`) ? undefined : scanFile($, file, true),
207 ])
208}
209
210function wakeAll(w: Watch) {
211 for (const wake of w.wake.splice(0)) wake()
212}
213
214/** Resolves true once `done()` holds, false if `ms` pass first; asked again whenever the watcher moves. */
215function until($: EngineInterface, w: Watch, done: () => boolean, ms: number): Promise<boolean> {
216 if (done()) return Promise.resolve(true)
217 if (ms <= 0) return Promise.resolve(false)
218 return new Promise(resolve => {
219 let isSettled = false
220 const timer = $.clock.after(ms, () => {
221 isSettled = true
222 resolve(done())
223 })
224 const check = () => {
225 if (isSettled) return
226 if (!done()) return void w.wake.push(check)
227 isSettled = true
228 timer.cancel()
229 resolve(true)
230 }
231 w.wake.push(check)
232 })
233}
234
235function onTscLine($: EngineInterface, w: Watch, raw: string) {
236 const line = raw.trimEnd()
237 if (/Starting (?:incremental )?compilation/.test(line)) {
238 w.started++
239 w.cycleAt = Date.now()
240 w.pending = []
241 } else if (/Found \d+ errors?\. Watching for file changes/.test(line)) {
242 w.finished = w.started
243 w.lastMs = Date.now() - w.cycleAt
244 w.state = 'ready'
245 record(`tsc:${w.tsconfig}`, w.pending)
246 for (const file of w.late) {
247 const text = report(file, ['tsc'], [`tsc:${w.tsconfig}`], true)
248 if (text) late.push(`${text.replace(`[${NAME}]`, `[${NAME}, tsc's late verdict]`)}`)
249 }
250 w.late.clear()
251 status($)
252 } else {
253 const d = tscLine(line, w.dir)
254 if (d) w.pending.push(d)
255 else if (/^\s+\S/.test(line) && w.pending.length) w.pending.at(-1)!.message += `\n${line.trim()}`
256 else if (/^\s*error TS\d+/.test(line)) $.ui.log(note(`tsc ${rel(w.tsconfig)}: ${firstLine(line)}`), { to: 'debug' })
257 return
258 }
259 wakeAll(w)
260}
261
262/** The warm `tsc --watch` for `tsconfig`: started on first use, stopped after IDLE_MS without a TypeScript edit. */
263function watch($: EngineInterface, tsconfig: string, bin: string): Watch {
264 const running = watches.get(tsconfig)
265 if (running && running.state !== 'stopped') return keepWarm($, running)
266 const dir = dirname(tsconfig)
267 const stream = $.process.spawn({ argv: [bin, '--noEmit', '--watch', '--preserveWatchOutput', '--pretty', 'false', '-p', tsconfig], cwd: dir, env: ENV })
268 const w: Watch = { tsconfig, dir, state: 'starting', started: 0, finished: 0, cycleAt: 0, lastMs: 0, pending: [], wake: [], late: new Set(),
269 stop: () => void stream.return({ code: null, signal: 'SIGTERM' }) }
270 watches.set(tsconfig, w)
271 void (async () => {
272 let partial = ''
273 try {
274 for await (const { text } of stream) {
275 const lines = (partial + text).split('\n')
276 partial = lines.pop()!
277 for (const line of lines) onTscLine($, w, line)
278 }
279 } catch (err) {
280 $.ui.log(note(`tsc watcher for ${rel(tsconfig)} failed: ${(err as Error).message}`), { to: 'debug' })
281 }
282 if (w.state !== 'stopped') $.ui.log(note(`tsc watcher for ${rel(tsconfig)} exited`), { to: 'debug' })
283 w.state = 'stopped'
284 w.idle?.cancel()
285 wakeAll(w)
286 })()
287 return keepWarm($, w)
288}
289
290function keepWarm($: EngineInterface, w: Watch): Watch {
291 w.idle?.cancel()
292 w.idle = $.clock.after(IDLE_MS, () => {
293 $.ui.log(note(`tsc watcher for ${rel(w.tsconfig)} stopped after ${IDLE_MS / 60_000} idle minutes`), { to: 'debug' })
294 w.state = 'stopped'
295 w.stop()
296 })
297 return w
298}
299
300async function tscFor($: EngineInterface, file: string, cfg: Config): Promise<Watch | undefined> {
301 if (!TS.test(file) || cfg.disable.includes('tsc')) return
302 const dir = await findUp($, dirname(file), ['tsconfig.json'])
303 if (!dir) return
304 const bin = await resolveBin($, 'tsc', dir)
305 if (!bin) return void missing.add('tsc')
306 return watch($, join(dir, 'tsconfig.json'), bin)
307}
308
309/** Waits, inside `ms`, for tsc to finish checking an edit made after cycle `since` began. False: it is late. */
310async function settled($: EngineInterface, w: Watch, since: number, ms: number): Promise<boolean> {
311 // ponytail: a cold first compile is not waited on, its verdict rides a later tool result; a big repo can take a minute
312 if (w.state !== 'ready') return w.state === 'stopped'
313 const t0 = Date.now()
314 if (!(await until($, w, () => w.started > since || w.state !== 'ready', Math.min(NOTICE_MS, ms)))) return true // tsc saw no change
315 return until($, w, () => w.finished >= w.started || w.state === 'stopped', ms - (Date.now() - t0))
316}
317
318/** What the model reads after editing `file`: what the edit introduced there, and errors the turn caused elsewhere. */
319function report(file: string, ran: string[], scopes: string[], isQuiet: boolean, isPending = false): string | undefined {
320 if (!ran.length) return
321 const mine: Diag[] = []
322 const old: Diag[] = []
323 const elsewhere: Diag[] = []
324 for (const scope of scopes)
325 for (const d of known.get(scope) ?? []) {
326 if (muted(d)) continue
327 if (d.file === file) (isNew(d, scope) ? mine : old).push(d)
328 else if (d.severity === 'error' && isNew(d, scope)) elsewhere.push(d)
329 }
330 const head = `[${NAME}] ${rel(file)}`
331 const wait = isPending ? 'tsc is still checking; its verdict comes with a later tool result.' : ''
332 if (!mine.length && !elsewhere.length)
333 return isQuiet ? undefined : [`${head}: no new issues (${ran.join(', ')})${old.length ? `; ${old.length} pre-existing` : ''}.`, wait].filter(Boolean).join(' ')
334 const lines = [`${head}: ${mine.length ? tally(mine) : 'nothing new'} introduced in this file (${ran.join(', ')}).`]
335 lines.push(...mine.sort(bySeverity).slice(0, SHOW).map(d => lineOf(d, rel)))
336 if (mine.length > SHOW) lines.push(` ...and ${mine.length - SHOW} more (lens_diagnostics lists them all)`)
337 if (old.length) lines.push(` (${old.length} pre-existing in this file, not shown)`)
338 if (elsewhere.length) {
339 lines.push('Errors this turn introduced in other files:', ...elsewhere.sort(bySeverity).slice(0, CASCADE).map(d => lineOf(d, rel)))
340 if (elsewhere.length > CASCADE) lines.push(` ...and ${elsewhere.length - CASCADE} more`)
341 }
342 if (wait) lines.push(wait)
343 lines.push('Fix what this edit introduced before moving on. If a finding is wrong, call lens_diagnostic_mark with its #id.')
344 return lines.join('\n')
345}
346
347/** Errors still standing: introduced this turn or, for `session`, in any file this session edited. */
348function blockers(span: 'turn' | 'session'): Diag[] {
349 const held: Diag[] = []
350 for (const { d, scope } of live())
351 if (d.severity === 'error' && (isNew(d, scope) || (span === 'session' && sessionFiles.has(d.file)))) held.push(d)
352 return held.sort(bySeverity)
353}
354
355function status($: EngineInterface) {
356 let errors = 0
357 let warnings = 0
358 for (const { d } of live()) {
359 if (d.severity === 'error') errors++
360 else if (d.severity === 'warning') warnings++
361 }
362 label = errors || warnings ? `lens ✗${errors} ⚠${warnings}` : 'lens ✓'
363 $.ui.invalidate('ui.render')
364}
365
366/** `result` with lens notes after it, and any tsc verdict that arrived late. */
367function withNotes<R extends ToolCallResult>(result: R, ...notes: (string | undefined)[]): R {
368 if (result.deny) return result
369 const add = [...late.splice(0), ...notes].filter((n): n is string => !!n)
370 return add.length ? ({ ...result, context: [...(result.context ?? []), ...add] } as R) : result
371}
372
373/** Checks `file` now and lists everything in it, old and new. */
374async function checkNow($: EngineInterface, file: string, ms: number): Promise<string> {
375 if (!(await $.fs.exists(file))) return `${rel(file)} does not exist.`
376 const cfg = await config($)
377 const checks = await plan($, file, false, cfg)
378 const w = await tscFor($, file, cfg)
379 const [ran] = await Promise.all([
380 Promise.all(checks.map(async p => ((await runCheck($, p, file)) ? p.tool.id : ''))),
381 scanFile($, file),
382 w && until($, w, () => w.state !== 'starting' && w.finished >= w.started, ms),
383 ])
384 const names = [...ran.filter(Boolean), ...(w ? ['tsc'] : [])]
385 if (!names.length) return `No checker applies to ${rel(file)}; /lens-health lists tools that are not installed.`
386 const diags = [...live()].filter(x => x.d.file === file).map(x => x.d).sort(bySeverity)
387 return [`${rel(file)}: ${tally(diags)} (${names.join(', ')})`, ...diags.map(d => lineOf(d, rel))].join('\n')
388}
389
390function summary(): string {
391 const all = [...live()].map(x => x.d).sort(bySeverity)
392 const hidden = [...known.values()].flat().filter(muted).length
393 const files = new Set(all.map(d => d.file)).size
394 const lines = [`${tally(all)} across ${files} file${files === 1 ? '' : 's'}${hidden ? ` (${hidden} marked, hidden)` : ''}`]
395 lines.push(...all.slice(0, LISTED).map(d => lineOf(d, rel)))
396 if (all.length > LISTED) lines.push(` ...and ${all.length - LISTED} more`)
397 return lines.join('\n')
398}
399
400function health(): string {
401 const lines = ["tsc watchers:"]
402 if (!watches.size) lines.push(' none yet (no tsconfig.json at the root and no TypeScript edited)')
403 for (const w of watches.values())
404 lines.push(` ${rel(w.tsconfig)}: ${w.state}, ${w.finished} checks, last took ${w.lastMs} ms, ${known.get(`tsc:${w.tsconfig}`)?.length ?? 0} diagnostics`)
405 lines.push('runners:')
406 if (!stats.size) lines.push(' none run yet')
407 for (const [id, s] of stats) lines.push(` ${id}: ${s.runs} runs, ${s.fails} failed, ${Math.round(s.ms / s.runs)} ms average`)
408 if (missing.size) lines.push(`not installed, so their files go unchecked: ${[...missing].join(', ')}`)
409 lines.push(`marks: ${Object.keys(falsePositives).length} false positives (this project), ${deferred.size} deferred (this session)`)
410 lines.push('recent degradations:', ...(ledger.length ? ledger.map(l => ` ${l}`) : [' none']))
411 return lines.join('\n')
412}
413
414/** Runs the project's fixers, then its formatters, over the files the turn edited. */
415async function tidy($: EngineInterface, files: string[]) {
416 const cfg = await config($)
417 const groups = new Map<string, { plan: Plan; files: string[] }>()
418 const before = new Map<string, number>()
419 for (const file of files) {
420 const stat = await $.fs.stat(file).catch(() => undefined)
421 if (!stat) continue
422 before.set(file, stat.mtimeMs)
423 for (const p of await plan($, file, true, cfg)) {
424 const key = `${p.tool.id}\0${p.cwd}`
425 const group = groups.get(key)
426 if (group) group.files.push(file)
427 else groups.set(key, { plan: p, files: [file] })
428 }
429 }
430 const rank = (p: Plan) => TOOLS.indexOf(p.tool)
431 const used = new Set<string>()
432 for (const { plan: p, files: batch } of [...groups.values()].sort((a, b) => rank(a.plan) - rank(b.plan))) {
433 const r = await $.process
434 .run([p.bin, ...p.tool.argv(batch)], { cwd: p.cwd, env: ENV, timeoutMs: RUN_TIMEOUT_MS })
435 .catch((err: Error) => ({ exitCode: -1, stdout: '', stderr: err.message }))
436 if (r.exitCode > 1 || r.exitCode < 0) $.ui.log(note(`${p.tool.id} failed: ${firstLine(r.stderr || r.stdout)}`), { to: 'debug' })
437 else used.add(p.tool.id)
438 }
439 let changed = 0
440 for (const [file, mtime] of before) if ((await $.fs.stat(file).catch(() => undefined))?.mtimeMs !== mtime) changed++
441 if (changed) $.ui.toast(`${NAME} tidied ${changed} file${changed === 1 ? '' : 's'} (${[...used].join(', ')})`)
442}
443
444export const register: Register = (on, options) => {
445 const isTidy = options.tidy !== false
446 const isStopGuard = options.stopGuard !== false
447 const isGitGuard = options.gitGuard === true
448
449 // Every tool call: the git guard ahead of Bash, the check after Edit and Write, late tsc verdicts after any.
450 on('tool.call', async ($, e, next) => {
451 if (e.tool === 'Bash' && isGitGuard && GIT_WRITE.test(e.command)) {
452 const held = blockers('session')
453 if (held.length)
454 return {
455 deny: [`${NAME} is holding this commit/push: files this session edited still have ${tally(held)}.`,
456 ...held.slice(0, SHOW).map(d => lineOf(d, rel)),
457 'Fix them first, or mark findings that should not block with lens_diagnostic_mark. The user can turn this guard off in /config.'].join('\n'),
458 }
459 }
460 if (e.tool !== 'Edit' && e.tool !== 'Write') return withNotes(await next(e))
461
462 const file = resolvePath(root, e.file_path)
463 const cfg = await config($)
464 const checks = await plan($, file, false, cfg)
465 const w = await tscFor($, file, cfg)
466 const since = w?.started ?? 0
467 await beforeEdit($, file, checks)
468 const result = await next(e)
469 if (result.deny || result.isError) return withNotes(result)
470
471 turnFiles.add(file)
472 sessionFiles.add(file)
473 listings.delete(dirname(file)) // it may be a config file a gate looks for
474 texts.delete(file)
475 const [ran, isFresh] = await Promise.all([
476 Promise.all(checks.map(async p => ((await runCheck($, p, file)) ? p.tool.id : ''))),
477 w ? settled($, w, since, Math.min(next.budget.remainingMs, 60_000) - RESERVE_MS) : true,
478 scanFile($, file),
479 ])
480 if (w && !isFresh) w.late.add(file)
481 const stat = await $.fs.stat(file).catch(() => undefined)
482 if (stat) checkedAt.set(file, stat.mtimeMs)
483 const scopes = [...checks.map(p => p.scope), `secrets:${file}`, ...(w ? [`tsc:${w.tsconfig}`] : [])]
484 const hasSecrets = !!known.get(`secrets:${file}`)?.length
485 const names = [...ran.filter(Boolean), ...(w ? ['tsc'] : []), ...(hasSecrets ? ['secrets'] : [])]
486 status($)
487 return withNotes(result, report(file, names, scopes, false, !!w && !isFresh))
488 }).catch(($, e, next) => next(e))
489
490 on('tool.call', { tool: 'mcp__lens__lens_diagnostics' }, async ($, e, next) => {
491 const path = typeof e.path === 'string' && e.path ? resolvePath(root, e.path) : undefined
492 return { result: path ? await checkNow($, path, Math.min(next.budget.remainingMs, 60_000) - RESERVE_MS) : summary() }
493 })
494
495 on('tool.call', { tool: 'mcp__lens__lens_diagnostic_mark' }, async ($, e) => {
496 const id = String(e.id ?? '').replace(/^#/, '')
497 const hit = [...live()].find(x => idOf(x.d) === id)?.d
498 if (!hit) return { result: `No current finding has id #${id}; lens_diagnostics lists the live ones.` }
499 const isFalsePositive = e.disposition === 'false-positive'
500 if (isFalsePositive) {
501 falsePositives[keyOf(hit)] = String(e.reason ?? '')
502 await $.store.set(`fp:${root}`, falsePositives)
503 } else deferred.add(keyOf(hit))
504 status($)
505 return {
506 result: `Marked #${id} (${hit.source}${hit.rule ? ` ${hit.rule}` : ''} in ${rel(hit.file)}) as ` +
507 (isFalsePositive ? 'a false positive for this project.' : 'deferred for this session.'),
508 }
509 })
510
511 on('command.run', { command: 'lens' }, async ($, e) => {
512 if (e.args.trim() !== 'clear-marks') return { text: summary() }
513 falsePositives = {}
514 deferred.clear()
515 await $.store.delete(`fp:${root}`)
516 status($)
517 return { text: "marks cleared." }
518 })
519
520 on('command.run', { command: 'lens-health' }, () => ({ text: health() }))
521
522 // The tally joins the footer's mode labels, at the right of the prompt.
523 on('ui.render', { component: 'SessionMode' }, ($, e, next) =>
524 next(label ? { ...e, props: { ...e.props, modes: [...e.props.modes, label] } } : e))
525
526 on('session.start', async ($, e, next) => {
527 const started = await next(e)
528 root = await $.session.root()
529 falsePositives = ((await $.store.get(`fp:${root}`)) ?? {}) as Record<string, string>
530 await Promise.all([
531 $.tool.register({
532 name: 'lens_diagnostics',
533 description:
534 'Diagnostics from lens: tsc and the linters the project uses (eslint, biome, ruff, pyright, go vet, clippy, ' +
535 'shellcheck, ...). Your edits are checked automatically and the result follows each Edit/Write. With `path`, checks ' +
536 'that file now and lists every finding in it, old and new. Without, lists everything this session holds.',
537 inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'A file to check now, relative to the project root or absolute.' } } },
538 }),
539 $.tool.register({
540 name: 'lens_diagnostic_mark',
541 description:
542 'Triage a lens finding by the #id its report shows. `false-positive`: the rule misfired here; hidden in this ' +
543 'project from now on. `defer`: real, but out of scope now; hidden for this session. Prefer fixing; mark only what should ' +
544 'not be fixed, and say why.',
545 inputSchema: {
546 type: 'object',
547 properties: {
548 id: { type: 'string', description: 'The id after # in the report, e.g. 3fa2c1.' },
549 disposition: { type: 'string', enum: ['false-positive', 'defer'] },
550 reason: { type: 'string', description: 'One sentence on why.' },
551 },
552 required: ['id', 'disposition', 'reason'],
553 },
554 }),
555 $.command.register({ name: 'lens', description: 'Diagnostics lens holds for this session', argumentHint: '[clear-marks]' }),
556 $.command.register({ name: 'lens-health', description: 'lens runners, tsc watchers and recent degradations' }),
557 ])
558 // Warm the root project's tsc now, so the first edit already has a "before".
559 const cfg = await config($)
560 if (!cfg.disable.includes('tsc') && (await names(root, p => $.fs.list(p))).has('tsconfig.json')) {
561 const bin = await resolveBin($, 'tsc', root)
562 if (bin) watch($, join(root, 'tsconfig.json'), bin)
563 }
564 return started
565 }).catch(($, e, next) => next(e))
566
567 on('turn.start', ($, e, next) => {
568 // The guard's own follow-up continues the turn it checks, so the "before" stays the one Claude started from.
569 if (recheck === 'queued') recheck = 'running'
570 else if (e.text) {
571 // A person's prompt opens a turn; a continuation (no text) stays in the one it continues.
572 recheck = undefined
573 turnFiles.clear()
574 baseline.clear()
575 late.length = 0
576 listings.clear()
577 texts.clear()
578 bins.clear()
579 }
580 return next(e)
581 })
582
583 // The end of a turn: if it left errors it introduced, send Claude back once; otherwise tidy what it edited.
584 // ponytail: a follow-up prompt, not a classic.Stop block: the built-in security plugin skips user-tier Stop hooks.
585 on('turn.complete', async ($, e, next) => {
586 const result = await next(e)
587 if (e.agentId !== undefined || e.reason !== 'answer') return result
588 if (isStopGuard && recheck === undefined) {
589 const ms = Math.min(next.budget.remainingMs, 60_000) - RESERVE_MS
590 await Promise.all([...watches.values()].map(w => until($, w, () => w.state !== 'starting' && w.finished >= w.started, ms)))
591 const held = blockers('turn')
592 if (held.length) {
593 recheck = 'queued'
594 $.ui.toast(`${NAME}: the turn left ${tally(held)}; sending Claude back once to fix them`)
595 void $.prompt.submit({
596 text: [`[${NAME}] Your last turn left ${tally(held)} it introduced:`, ...held.slice(0, SHOW).map(d => lineOf(d, rel)),
597 ...(held.length > SHOW ? [` ...and ${held.length - SHOW} more (lens_diagnostics)`] : []),
598 'Fix them, then finish. If one is wrong or out of scope, call lens_diagnostic_mark with its #id ' +
599 '(false-positive or defer) instead.'].join('\n'),
600 })
601 return result // tidy waits for the fix: a formatter between the edits of one change fights the editor
602 }
603 }
604 recheck = undefined
605 if (isTidy && turnFiles.size) void tidy($, [...turnFiles])
606 return result
607 }).catch(($, e, next) => next(e))
608}
609hooks/lens.ts 224 lines1// The pure half of lens: which tools run on which files, how their output parses,
2// the secret patterns, and how a diagnostic is named. No `$` here, so the tests call it directly.
3
4export type Severity = 'error' | 'warning' | 'info'
5export type Diag = { file: string; line: number; col: number; severity: Severity; message: string; rule?: string; source: string }
6export type Format = 'gcc' | 'github' | 'eslint'
7
8export type Tool = {
9 id: string
10 match: RegExp
11 /** The executable, looked up in node_modules/.bin, .venv/bin and venv/bin from the file's folder up, then on PATH. */
12 bin: string
13 /** Only a project-local install counts: the project chose this tool. */
14 local?: true
15 /** Config files, one of which must sit in the file's folder or above; `name#text` also needs `text` inside. */
16 gate?: readonly string[]
17 /** Where it runs: the gate file's folder, or the file's own; the session root otherwise. */
18 cwd?: 'gate' | 'dir'
19 /** Its diagnostics cover the whole folder it runs in (a Go package, a crate), not just the file. */
20 wide?: true
21 /** How its output reads. A tool without one is a fixer, run on the turn's files when the turn ends. */
22 format?: Format
23 /** The severity of a finding whose line names none. */
24 severity?: Severity
25 argv: (files: string[]) => string[]
26}
27
28const JS = /\.[cm]?[jt]sx?$/
29const PY = /\.pyi?$/
30const BIOME_EXT = /\.(?:[cm]?[jt]sx?|jsonc?|css|graphql)$/
31const PRETTIER_EXT = /\.(?:[cm]?[jt]sx?|jsonc?|json5|css|scss|less|html|vue|mdx?|ya?ml|graphql)$/
32export const TS = /\.(?:[cm]?ts|tsx)$/
33
34const ESLINT = ['eslint.config.js', 'eslint.config.mjs', 'eslint.config.cjs', 'eslint.config.ts', 'eslint.config.mts',
35 'eslint.config.cts', '.eslintrc', '.eslintrc.js', '.eslintrc.cjs', '.eslintrc.json', '.eslintrc.yml', '.eslintrc.yaml',
36 'package.json#"eslintConfig"']
37const PRETTIER = ['.prettierrc', '.prettierrc.json', '.prettierrc.json5', '.prettierrc.yml', '.prettierrc.yaml', '.prettierrc.toml',
38 '.prettierrc.js', '.prettierrc.cjs', '.prettierrc.mjs', 'prettier.config.js', 'prettier.config.cjs', 'prettier.config.mjs']
39const STYLELINT = ['.stylelintrc', '.stylelintrc.json', '.stylelintrc.yml', '.stylelintrc.yaml', '.stylelintrc.js',
40 '.stylelintrc.cjs', '.stylelintrc.mjs', 'stylelint.config.js', 'stylelint.config.cjs', 'stylelint.config.mjs']
41const RUFF = ['ruff.toml', '.ruff.toml', 'pyproject.toml#[tool.ruff']
42const BIOME = ['biome.json', 'biome.jsonc']
43
44// tsc is not in the table: it runs as one warm `tsc --watch` per tsconfig (register.ts).
45// ponytail: ~15 common tools; anything else goes in .lens.json `runners`.
46export const TOOLS: readonly Tool[] = [
47 { id: 'eslint', match: JS, bin: 'eslint', local: true, gate: ESLINT, cwd: 'gate', format: 'eslint', argv: f => ['--format', 'json', ...f] },
48 { id: 'biome', match: BIOME_EXT, bin: 'biome', local: true, format: 'github', argv: f => ['lint', '--reporter=github', ...f] },
49 { id: 'oxlint', match: JS, bin: 'oxlint', local: true, format: 'github', argv: f => ['--format=github', ...f] },
50 { id: 'stylelint', match: /\.(?:css|scss|less)$/, bin: 'stylelint', local: true, gate: STYLELINT, cwd: 'gate', format: 'gcc', argv: f => ['--formatter', 'unix', ...f] },
51 { id: 'ruff', match: PY, bin: 'ruff', format: 'gcc', argv: f => ['check', '--output-format=concise', '--quiet', ...f] },
52 { id: 'pyright', match: PY, bin: 'pyright', format: 'gcc', argv: f => f },
53 { id: 'mypy', match: PY, bin: 'mypy', gate: ['mypy.ini', '.mypy.ini', 'pyproject.toml#[tool.mypy', 'setup.cfg#[mypy'], cwd: 'gate', format: 'gcc', severity: 'error',
54 argv: f => ['--show-column-numbers', '--no-error-summary', '--no-color-output', ...f] },
55 { id: 'go-vet', match: /\.go$/, bin: 'go', gate: ['go.mod'], cwd: 'dir', wide: true, format: 'gcc', severity: 'error', argv: () => ['vet', '.'] },
56 { id: 'clippy', match: /\.rs$/, bin: 'cargo', gate: ['Cargo.toml'], cwd: 'gate', wide: true, format: 'gcc', argv: () => ['clippy', '--message-format=short', '--quiet'] },
57 { id: 'shellcheck', match: /\.(?:sh|bash)$/, bin: 'shellcheck', format: 'gcc', argv: f => ['-f', 'gcc', ...f] },
58 { id: 'rubocop', match: /\.rb$/, bin: 'rubocop', gate: ['.rubocop.yml'], cwd: 'gate', format: 'gcc', argv: f => ['--format', 'emacs', ...f] },
59 { id: 'yamllint', match: /\.ya?ml$/, bin: 'yamllint', gate: ['.yamllint', '.yamllint.yml', '.yamllint.yaml'], cwd: 'gate', format: 'gcc', argv: f => ['-f', 'parsable', ...f] },
60 { id: 'actionlint', match: /\/\.github\/workflows\/[^/]+\.ya?ml$/, bin: 'actionlint', format: 'gcc', severity: 'error', argv: f => ['-oneline', ...f] },
61 { id: 'hadolint', match: /(?:^|\/)(?:Dockerfile|Containerfile)[^/]*$|\.dockerfile$/i, bin: 'hadolint', format: 'gcc', argv: f => ['--no-color', ...f] },
62 // Fixers, in this order: safe fixes before formatting, so the formatter has the last word.
63 { id: 'eslint-fix', match: JS, bin: 'eslint', local: true, gate: ESLINT, cwd: 'gate', argv: f => ['--fix', ...f] },
64 { id: 'biome-fix', match: BIOME_EXT, bin: 'biome', local: true, gate: BIOME, cwd: 'gate', argv: f => ['check', '--write', ...f] },
65 { id: 'prettier', match: PRETTIER_EXT, bin: 'prettier', local: true, gate: PRETTIER, cwd: 'gate', argv: f => ['--write', '--log-level', 'warn', ...f] },
66 { id: 'ruff-fix', match: PY, bin: 'ruff', gate: RUFF, cwd: 'gate', argv: f => ['check', '--fix', '--quiet', ...f] },
67 { id: 'ruff-format', match: PY, bin: 'ruff', gate: RUFF, cwd: 'gate', argv: f => ['format', '--quiet', ...f] },
68 { id: 'gofmt', match: /\.go$/, bin: 'gofmt', argv: f => ['-w', ...f] },
69 // ponytail: edition pinned to 2021; read it from Cargo.toml if a 2024-only syntax ever trips it
70 { id: 'rustfmt', match: /\.rs$/, bin: 'rustfmt', gate: ['Cargo.toml'], cwd: 'gate', argv: f => ['--edition', '2021', ...f] },
71]
72
73/** A runner from `.lens.json`; throws, naming what is wrong, on a malformed one. */
74export function customTool(raw: unknown): Tool {
75 const { id, match, argv, format = 'gcc', cwd } = (raw ?? {}) as Record<string, unknown>
76 const isArgv = Array.isArray(argv) && argv.length > 0 && argv.every(a => typeof a === 'string')
77 if (typeof id !== 'string' || typeof match !== 'string' || !isArgv || (format !== 'gcc' && format !== 'github' && format !== 'eslint'))
78 throw new Error(`runner ${JSON.stringify(id)} needs an id, a match regex, an argv of strings and a format of gcc, github or eslint`)
79 const [bin, ...rest] = argv as string[]
80 return { id, match: new RegExp(match), bin: bin!, format, cwd: cwd === 'dir' ? 'dir' : undefined,
81 argv: f => rest.map(a => a.replaceAll('{file}', f[0]!)) }
82}
83
84/** `p` made absolute against `base`, `.` and `..` resolved (POSIX paths). */
85export function resolvePath(base: string, p: string): string {
86 const parts: string[] = []
87 for (const seg of (p.startsWith('/') ? p : `${base}/${p}`).split('/')) {
88 if (seg === '..') parts.pop()
89 else if (seg && seg !== '.') parts.push(seg)
90 }
91 return `/${parts.join('/')}`
92}
93
94const ANSI = /\x1b\[[0-9;]*[A-Za-z]/g
95const SEV: Record<string, Severity> = {
96 fatal: 'error', error: 'error', e: 'error', f: 'error',
97 warning: 'warning', warn: 'warning', w: 'warning', c: 'warning', r: 'warning',
98 note: 'info', info: 'info', information: 'info', hint: 'info', style: 'info',
99}
100// `path:line[:col]` then `: `, ` - ` (pyright) or a space (hadolint); go vet prefixes `vet: `.
101const GCC = /^\s*(?:vet: )?(.+?):(\d+)(?::(\d+))?(?::\s*|\s+-\s+|\s+)(\S.*)$/
102const TAIL = /\s+[[(]([^\]()\s]+)[\])]$/ // `[SC2086]`, `[assignment]`, `(document-start)`, stylelint's `[error]`
103const LEAD_CODE = /^([A-Z]{1,8}\d{2,5})\s+/ // ruff `F401`, hadolint `DL3006`
104const LEAD_SEV = /^\[?(fatal|error|warning|warn|note|info|information|hint|style)(?:\[([^\]]+)\])?\]?:?\s+/i // `error[E0308]:`, `[warning]`
105const RUBOCOP = /^([CWREF]):\s+(?:([\w/]+):\s+)?/
106const GITHUB = /^::(error|warning|notice)\s+(.*?)::(.*)$/
107const TSC = /^(.+)\((\d+),(\d+)\): (error|warning|message) (TS\d+): (.*)$/
108
109/** Peels a rule id and a severity off a gcc-style message, wherever the tool put them. */
110export function splitMessage(text: string, fallback: Severity): { message: string; rule?: string; severity: Severity } {
111 let message = text.trim()
112 let rule: string | undefined
113 let severity: Severity | undefined
114 let m: RegExpExecArray | null
115 for (let i = 0; i < 2 && (m = TAIL.exec(message)); i++) {
116 const s = SEV[m[1]!.toLowerCase()]
117 if (s) severity ??= s
118 else rule ??= m[1]
119 message = message.slice(0, m.index)
120 }
121 if ((m = LEAD_CODE.exec(message))) {
122 rule ??= m[1]
123 message = message.slice(m[0].length)
124 }
125 if ((m = LEAD_SEV.exec(message) ?? RUBOCOP.exec(message))) {
126 severity ??= SEV[m[1]!.toLowerCase()]
127 rule ??= m[2]
128 message = message.slice(m[0].length)
129 }
130 if (/^SyntaxError\b/.test(message)) severity = 'error'
131 return { message: message.replace(/^\[\*\]\s*/, ''), rule, severity: severity ?? fallback }
132}
133
134const unescapeGithub = (s: string) =>
135 s.replace(/%0D/g, '\r').replace(/%0A/g, '\n').replace(/%3A/g, ':').replace(/%2C/g, ',').replace(/%25/g, '%')
136
137/** The diagnostics in one tool's output; paths made absolute against `cwd`. Throws on output that is not `format`. */
138export function parse(format: Format, out: string, cwd: string, source: string, fallback: Severity = 'warning'): Diag[] {
139 if (format === 'eslint') {
140 type File = { filePath: string; messages: { ruleId: string | null; severity: number; message: string; line?: number; column?: number }[] }
141 return (JSON.parse(out) as File[]).flatMap(f =>
142 f.messages
143 .filter(m => !m.message.startsWith('File ignored'))
144 .map(m => ({ file: resolvePath(cwd, f.filePath), line: m.line ?? 1, col: m.column ?? 1,
145 severity: m.severity === 2 ? 'error' as const : 'warning' as const, message: m.message, rule: m.ruleId ?? undefined, source })))
146 }
147 const diags: Diag[] = []
148 for (const line of out.replace(ANSI, '').split(/\r?\n/)) {
149 if (format === 'github') {
150 const m = GITHUB.exec(line)
151 if (!m) continue
152 const props = Object.fromEntries(m[2]!.split(',').map(kv => [kv.slice(0, kv.indexOf('=')), unescapeGithub(kv.slice(kv.indexOf('=') + 1))]))
153 if (!props.file || !props.line) continue
154 diags.push({ file: resolvePath(cwd, props.file), line: Number(props.line), col: Number(props.col ?? 1),
155 severity: m[1] === 'error' ? 'error' : m[1] === 'warning' ? 'warning' : 'info',
156 message: unescapeGithub(m[3]!).replace(/^\S+:\d+:\d+: /, ''), rule: props.title, source })
157 continue
158 }
159 const m = GCC.exec(line)
160 if (m) diags.push({ file: resolvePath(cwd, m[1]!), line: Number(m[2]), col: Number(m[3] ?? 1), source, ...splitMessage(m[4]!, fallback) })
161 }
162 return diags
163}
164
165/** One `tsc --pretty false` diagnostic line, or undefined for any other line. */
166export function tscLine(line: string, cwd: string): Diag | undefined {
167 const m = TSC.exec(line.replace(ANSI, ''))
168 if (!m) return
169 return { file: resolvePath(cwd, m[1]!), line: Number(m[2]), col: Number(m[3]),
170 severity: m[4] === 'error' ? 'error' : m[4] === 'warning' ? 'warning' : 'info', message: m[6]!, rule: m[5], source: 'tsc' }
171}
172
173// ponytail: high-precision token shapes only; no entropy scan, gitleaks covers that if the project wants it
174const SECRETS: [string, RegExp][] = [
175 ['aws-access-key', /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/],
176 ['github-token', /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{60,})/],
177 ['slack-token', /\bxox[abprs]-[A-Za-z0-9-]{10,}/],
178 ['stripe-live-key', /\b[rs]k_live_[A-Za-z0-9]{20,}/],
179 ['google-api-key', /\bAIza[0-9A-Za-z_-]{35}\b/],
180 ['anthropic-key', /\bsk-ant-[A-Za-z0-9_-]{20,}/],
181 ['openai-key', /\bsk-(?:proj-)?[A-Za-z0-9_-]{40,}/],
182 ['npm-token', /\bnpm_[A-Za-z0-9]{36}\b/],
183 ['private-key', /-----BEGIN (?:[A-Z]+ )?PRIVATE KEY-----/],
184]
185
186/** Credentials written into `text`; the message carries only the first four characters of one. */
187export function scanSecrets(text: string, file: string): Diag[] {
188 const diags: Diag[] = []
189 text.split('\n').forEach((line, i) => {
190 for (const [rule, re] of SECRETS) {
191 const m = re.exec(line)
192 if (!m) continue
193 diags.push({ file, line: i + 1, col: m.index + 1, severity: 'error', rule, source: 'secrets',
194 message: `possible ${rule.replaceAll('-', ' ')} (${m[0].slice(0, 4)}…) in source; load it from the environment instead` })
195 break
196 }
197 })
198 return diags
199}
200
201/** What a diagnostic is across edits: line numbers move, the rest does not. */
202export const keyOf = (d: Diag) => `${d.file}\0${d.source}\0${d.rule ?? ''}\0${d.message}`
203
204/** A short stable id (FNV-1a of the key) the model quotes to triage a diagnostic. */
205export function idOf(d: Diag): string {
206 let h = 0x811c9dc5
207 for (const c of keyOf(d)) h = Math.imul(h ^ c.charCodeAt(0), 0x01000193)
208 return (h >>> 0).toString(16).padStart(8, '0').slice(0, 6)
209}
210
211const RANK: Record<Severity, number> = { error: 0, warning: 1, info: 2 }
212export const bySeverity = (a: Diag, b: Diag) => RANK[a.severity] - RANK[b.severity] || a.file.localeCompare(b.file) || a.line - b.line
213
214/** "2 errors, 1 warning". */
215export function tally(diags: readonly Diag[]): string {
216 const n = (s: Severity) => diags.filter(d => d.severity === s).length
217 const part = (k: number, word: string) => (k ? `${k} ${word}${k === 1 ? '' : 's'}` : '')
218 return [part(n('error'), 'error'), part(n('warning'), 'warning'), part(n('info'), 'note')].filter(Boolean).join(', ') || 'nothing'
219}
220
221/** One diagnostic as the model reads it. */
222export const lineOf = (d: Diag, rel: (p: string) => string) =>
223 ` ${d.severity} ${rel(d.file)}:${d.line}:${d.col} ${d.message.replace(/\s*\n\s*/g, ' ').slice(0, 300)} [${d.source}${d.rule ? ` ${d.rule}` : ''}] #${idOf(d)}`
224types/index.d.ts 9 lines1// The agent tools this mod registers at session start, so `tool.call` matchers on them type-check.
2declare module 'claude-code' {
3 interface McpToolInputs {
4 'mcp__lens__lens_diagnostics': { path?: string }
5 'mcp__lens__lens_diagnostic_mark': { id: string; disposition: 'false-positive' | 'defer'; reason: string }
6 }
7}
8export {}
9