SLOPSHOPPER

lsp-plus

Extends Claude Code's LSP: references and definitions name the docs that use a symbol, an edit that removes a name the docs still use says so, /refs finds…

newguardcommandpromptprocess
v0.4.1MITupdated 2026-10-16HarzerHeribert/claude-modpack/lsp-plus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · lsp-plus
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /refs ⎿ lsp-plus: All 0 code references in 0 docs resolve. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

<img alt="claude-modpack: eight Claude Code mods, each measured on real tasks before it shipped, built to work together. statusline, smart-compact, delegate, lsp-plus, cells, eta, seek, route." src="assets/banner-light.svg" width="880">

<h3>Install in Claude Code</h3>

/plugin install modpack --marketplace HarzerHeribert/claude-modpack

Answer y, then run /modpack:setup to set everything up step by step.<br> Prefer doing it by hand? SETUP.md has every step.

Release License: MIT Claude Code 2.1.296 730 graded runs, all correct 8 mods Lab notes

Claude Code with fewer wasted steps, and a clear view of what it's doing.

Eight free add-ons that work on their own. One command installs them, and there is nothing new to learn.<br> Every number on this page was measured, including where it doesn't help.

Install · What you get · Honest numbers · The site: ideas and evidence · Lab notes · Questions

<img alt="Animated: the cost per job with and without the pack, as bars that grow: in a long session $0.44 against $0.29 (−33%; measured twice, −26% one day and −40% the next), bigger jobs in a fresh session $0.55 against $0.51 (−8%), quick questions in a fresh session $0.23 against $0.24 (+2%, about even: +5% one day, −4% the next); then four numbers: −33% cost per job in a long session, −21% steps for the same work, −22% cost of a feature a cheaper model builds from your plan, 224 of 224 results correct." src="assets/numbers.gif" width="880">

Why it saves

Every time Claude takes a step (reads a file, makes an edit, runs the tests), it reads your whole conversation again. That re-reading is most of what a session costs, and it grows the longer you work. So the cheapest step is the one Claude never has to take.

The pack takes out steps that need no thinking. Several edits and their test run as one step. A search for a name in your code comes back with the exact answer. And when you approve a plan, a cheaper model does the building while Claude checks the result. The work comes out the same; Claude just gets there in fewer round trips.

What you get

Always know where your plan stands

<img alt="A real Claude Code session: Claude starts the stress tests in the background; above the prompt a row shows them with a bar, a percentage and a finish time, climbing from 8% to 100%; under the prompt the status line shows the model, the context, the plan's 5-hour and weekly use and a green ready dot." src="assets/demo-band.gif" width="880">

One line under the prompt: the model, how full the conversation is, and how much of your plan's 5-hour and weekly limits you have used, with when each one resets. Long jobs get a progress bar and a finish time above the prompt. And when you step away, your session is kept ready, or tidied up before it goes cold, whichever costs less; the line tells you which.

These are the statusline, eta and smart-compact mods.

Several changes in one step, and you can still undo them

<img alt="A real Claude Code session: asked to add two docstrings and run the whole test suite, Claude runs one cell; an orange card shows the read, the two edits, the diff and the test run live, and the tests pass: 1990 passed." src="assets/demo-cells.gif" width="880">

Claude makes several edits and runs the tests as one step, and you watch each part happen in a live card. Every edit still goes through Claude Code's own edit tool: you see each change, and /rewind undoes it.

That's the cells mod.

A cheaper model builds what you approved

<img alt="A real Claude Code session: in plan mode Claude plans a new click.Duration parameter type; the plan is approved; Claude hands it to a Sonnet worker, whose orange card shows its steps live with a row above the prompt (hand-off 1, sonnet medium, requests and cost); then Claude checks the diff and reports: the new type works and the full suite passes." src="assets/demo-delegate.gif" width="880">

When you approve a plan, Claude hands the building to Sonnet, a cheaper model, and checks its work before reporting back. In our tests that made a feature 22% cheaper and new tests 26% cheaper, with every result correct (corrected 16 Oct: worker costs were undercounted; it was 27% and 33%). You can watch the helper work in its card, and /delegate shows every step it took.

That's the delegate mod.

Feature work goes to the cheaper model by itself (opt-in)

A small, fast classifier reads each prompt you send. Only when it is feature or test work is Claude told to hand it straight to delegate's worker: the worker reads the code and builds, delegate checks the result itself, and Claude reports after two steps of its own. On a TypeScript feature that made the job 51% cheaper and 42% faster than the pack alone, with every result correct (corrected 16 Oct: worker costs were undercounted; it was 57%); everything else stays with Claude. The classifier is an outside service (TypeSafe's Jev or ScaleDown), so this is off until you turn it on and give it a key: from then on, the start of each prompt goes to it, in the US, to be classified.

That's the route mod.

Straight answers about your code

When Claude searches your code for a name, it gets the exact list of places that use it, from the same engine your editor uses, instead of reading through search results. Renames and "who calls this?" questions take fewer steps. New in 0.5.0: Go, Kotlin and Ruby code, shell scripts and Terraform. Run /lsp-plus:setup once in your project to turn it on.

That's the lsp-plus mod.

Fewer, leaner steps when Claude looks around

Before Claude changes anything, it looks around your code, and everything it reads stays in the conversation, read again with every later step. Beside the first prompt you send in a session, and again as the session grows, the pack passes on your preference to look around in few, lean steps: the searches it can already name in one go, the lines that matter rather than whole files, nothing read twice. Against the pack without it, that made jobs 12% cheaper in a fresh session, 16% on a TypeScript library and 10 to 16% in long sessions, at the same quality. It runs on your machine and sends nothing anywhere.

That's the seek mod.

You stay in control

<img alt="A real Claude Code session: /mods lists the pack's mods (recorded with the six of 0.4.0, before route and seek) and opens a pane of cards; the status line is turned off and disappears from under the prompt, then turned on again." src="assets/demo-mods.gif" width="880">

/mods lists the eight and turns any of them on or off, right away, with a click or by typing, such as /mods statusline off. When something can't fully work on your computer, the pack tells you once, with the command that fixes it. When another plugin's permission or notification hook hangs (Warp's does outside the Warp terminal), the pack stops waiting for it after 10 seconds, so Claude Code can't freeze, and tells you which one it was. It keeps itself up to date (or only lets you know, if you prefer).

That's the modpack plugin, which installs the other eight.

Install

In Claude Code, type:

/plugin install modpack --marketplace HarzerHeribert/claude-modpack

Answer y to add the marketplace, then pick where to install it. That installs all eight. Then, in the project you work in:

/modpack:setup

It checks your machine, asks once what you want on (route stays off unless you say yes), sets each mod up in order, including the language servers for the languages your project uses, and checks that each one works. Done. /mods shows what's on.

By hand instead: SETUP.md has every step.

Each mod installs on its own the same way, for example /plugin install smart-compact --marketplace HarzerHeribert/claude-modpack. lsp-plus, delegate and cells build on Claude Code's LSP tool, which needs a language server for each language: Language servers lists them.

Honest numbers

We measured the pack on Claude Opus 5.5 doing seven real jobs in an open-source project (click), from a one-line lookup to building a feature, on two days, 13 and 15 October. Every result was checked by hidden tests. The same jobs ran with the pack and without it, and we compared what they cost at API prices.

Cost per jobWithoutWith the pack
In a long session (Claude has already read the whole project)$0.44$0.29−33%
Bigger jobs in a long session (a feature, tests, a rename)$0.91$0.57−37%
In a fresh session$0.37$0.35−4%
Bigger jobs in a fresh session$0.55$0.51−8%, and 15% faster
Quick questions in a fresh session$0.23$0.24+2%, about even
  • Where it doesn't help: quick questions in a brand-new session. The pack's tools come along with every step, and a three-step lookup has little to save: 5% more one day, 4% less the next, about even.
  • Writing tests: the pack alone saves nothing there (−2%, 12 runs each way): every step waits on the last test run. route hands it to the worker instead (−36% to −46%, below).
  • Nothing got worse: all 224 runs behind this table produced a correct result, with the pack and without.
  • The 95% ranges: long session −39% to −27%; fresh −7% to −1%; bigger jobs fresh −12% to −3%; quick questions −1% to +6%. The long session is two runs per job each day, and the days differ: −26% on one, −40% on the other, mostly from the feature (plain needed 12 requests one day, 16 the next).
  • The same on other code: on a TypeScript library (chrono) the pack cost 7.6% less (−12% to −3%) and took fewer steps on all eight jobs. On 30 real bug reports from SWE-bench Verified it fixed 30 against 29, with 23% fewer steps; the cost, −12%, is not certain at one attempt per bug (−33% to +12%).
  • A plan handed to a cheaper model: −22% on a feature, −26% on tests, against Opus doing it alone with the pack (16 runs, all correct).
  • With route turned on: a further −14% on click's eight jobs (route 0.1). With 0.6.0's verified hand-offs, against the pack in the same batch on TypeScript: a feature −51% and 42% faster, writing tests −36%; Claude itself takes 2 steps instead of 6 to 11 (24 runs, all correct).
  • Corrected 16 Oct: worker costs were undercounted. A worker's transcript records its output as each response began, and our bench counted it from there, so every number with a hand-off in it came out too good. Corrected from Claude Code's own totals: a planned feature −22% (was −27%), tests −26% (was −33%), route on click −14% (was −17%), the TypeScript feature −51% (was −57%). Every saving is still a saving, a smaller one. The table above has no hand-offs and did not change. Each claim, old and new: CORRECTION-1016.md.
  • With seek (new in 0.6.0, measured against the pack rather than plain Claude Code, so not in the table above): −12% fresh on click, −16% on TypeScript, −10% to −16% in long sessions (seek, below).
  • Two days, two versions: the 13 October runs used the pack as of 0.3.0 and Claude Code 2.1.296, the 15 October runs 0.4.0 and 2.1.299. What changed in the pack between them (on/off switches, setup, messages shown when a worker is refused) never reached the model in these runs: its first request was 26.7k tokens plain and 30.6k with the pack on both days. The second day's plain runs were done twice, because the first try had the pack installed by mistake: lab notes 8 tells how we caught it.

<img alt="Cost per task, plain Claude Code against the modpack, in a fresh session and in a session already holding 216k tokens." src="assets/cost-light.svg" width="880">

<img alt="Model requests per task, plain Claude Code against the modpack: 3.2 against 5.6 in a session already holding 216k tokens." src="assets/requests-light.svg" width="880">

<img alt="Cost per task, Opus alone against a plan handed to a worker: feature work 22% less, tests 26% less." src="assets/delegate-light.svg" width="880">

How we measured, every run's record, and the scripts to run it all again are in benchmarks/, starting with SUITE.md. What we tried that did not make it, a 10,000-star code graph among it, is in the lab notes (as Markdown). Not measured: what smart-compact saves you while you're away (worked out from prices, not tested) and Windows.

Questions

Will Claude's work get worse? No. In every run we graded, 730 in all, the result was correct with the pack and without, and every one was graded again for this release. The pack changes how many steps Claude takes to get there, and in our tests the results came out the same.

Is it safe? It's open source (MIT), so every line is here to read. Code that Claude runs inside a cell runs in a sandbox with no network access. Edits go through Claude Code's own edit tool, so your usual permission prompts and /rewind still apply. The pack sends nothing anywhere except a once-a-day check on GitHub for a new release, which you can turn off, and, only if you turn route on, the start of each prompt to the classifier you chose (TypeSafe or ScaleDown).

Does the pack add anything to my prompts? Your prompt itself is never changed. Beside it, Claude can get a short note in your name: seek's setting to look around in few, lean steps (with the first prompt of a session, and again after a compaction), and, only if you turn route on, the order to hand feature or test work to the worker; delegate gives the same order when you approve a plan of 20 lines or more. A subagent Claude starts gets cells' and seek's settings at the end of its task, since your prompts never reach it. /mods seek off and /mods route off stop theirs; subagents: false in cells or seek stops theirs for subagents.

I'm on a Pro or Max plan. Does this help me? You don't pay per job, but every step counts against your 5-hour and weekly limits. We measured cost at API prices, not plan usage, so we can't promise a number. Fewer, lighter steps should leave more of your limits, and the status line shows exactly where you stand.

Does it work in the desktop app? Yes, cards, progress and the status line included. One limit: in auto mode the desktop app doesn't let an add-on start a helper model, so plans aren't handed off there (turn route off there too); in manual mode, or in the terminal, they are. Windows hasn't been tested yet.

How do I turn it off? /mods switches each part off or on, right away. /plugin removes them.

Were any of these numbers wrong? Yes, the hand-off ones. On 16 October we found that our bench had counted a worker's output only as each of its responses began, so every saving that involved a worker came out too large: a planned feature was −27% and is −22%. They are corrected here, the old and new number side by side in CORRECTION-1016.md. The pack-against-plain numbers had no workers and stand.

What does it cost? Nothing. It's free and open source.

The mods in depth

One line under the prompt, drawn in Claude Code's own hint row so the mode indicator and the status share it:

⏵⏵ auto mode on       Opus 5.5 xhigh   412k ━━━━━━──────   5h 34% ↺2h14   7d 12% ↺3d   ● ready · cold in 44m
  • Its own segments: the model and its effort, the context against the auto-compact point (a highlight sweeps along it while Claude works), and how much of the plan's 5-hour and 7-day windows is used, with when each resets: quiet under half, plain past it, amber from 80%.
  • Other mods' segments: a mod calls $.statusline.set({ id, priority, parts, short?, side? }) and lists statusline among its dependencies. A part is text, a countdown ({ until, prefix?, warnMs? }, counted by the line) or a spinner ({ spin: true }), so a mod sends a segment when it changes, not every second. $.statusline.remove({ id }) takes it off.
  • Narrow terminals drop the least important first: the model, then the windows' resets, the windows, the gauge, and another mod's countdown before its own words.
  • Published pages: a pill on the left lists the pages this session published with the Artifact tool.
  • Phone and IDE: where no line is drawn, the same line is the session's status text, in a session the phone or IDE hosts. A terminal session watched over Remote Control (/rc) from the desktop app, the web or the phone gets no mod drawing and no status text as of Claude Code 2.1.296.
OptionDefault
enabledtruefalse turns the mod off; /mods does it for all of the pack.
placementlinetail: appended dim to Claude Code's hint. band: above the prompt. off.
limitsalwayshalf: a plan window only once it is half used. off: never.
animatetrueThe highlight along the gauge.

An idle session's prompt cache expires after its TTL: 1 hour on a subscription, 5 minutes in overage or on an API key. The next prompt then writes the whole context to the cache again, at 1.25x (5 min) or 2x (1 h) the input price. For a 400k-token Opus 5.5 session on the 1-hour cache:

OptionCost
Let it expire, re-cache on return$3.20
One keep-alive (re-read the cached prefix; a read resets the TTL)$0.09
Compact before it expires~$0.87, plus detail lost to the summary

A few minutes before the cache expires (2.5 min on the 1-hour TTL, 45 s on the 5-minute one), the smart strategy:

  1. Sends keep-alives while they cost less than compacting would, counting lost detail as compactPenaltyUsd. It stops after maxIdleHours.
  2. Then compacts while the cache is still warm.
  3. Leaves small sessions alone: below minSavingsUsd of re-cache cost, or below minCompactTokens of context, it never compacts.

It never compacts while the prompt holds a draft or agents are running; it sends a keep-alive to hold the cache instead. If the machine sleeps past the deadline, it marks the cache expired and does nothing. A failed action is retried twice, 20 s apart. The TTL is detected from the plan and corrected from the cache hits and misses it observes.

On statusline's line it says whether the session is ready to prompt or has gone cold: ● ready, · cold in m:ss (amber in the last five minutes), · compacts in … when an idle compaction is near, ○ cold, or what it is doing (compacting, keeping warm, paused). /cache shows the live figures and plan; /cache pause, /cache resume and /cache keepalive control it.

OptionDefault
enabledtruefalse turns the mod off; /mods does it for all of the pack.
strategysmartcompact: compact at the first expiry. keepalive: never compact. off: status line only.
cacheTtlauto1h or 5m to override detection.
minSavingsUsd0.25Below this re-cache cost, the cache is left to expire.
compactPenaltyUsd0.5Higher keeps the cache warm longer before compacting.
minCompactTokens100000Never compacts a smaller context.
maxIdleHours6Stops keep-alives after this long idle.
compactInstructionsemptyPassed to idle compactions, like /compact <text>.

Prices are list prices, so on a subscription they are API-equivalent estimates, not a bill.

Every request of the main model re-reads its whole context: at 600k tokens on Opus 5.5 that is about $0.12 a step before any work is done. A worker reads only its own small context. Claude calls hand_off with a task that stands alone, its kind (read: changes no file; mechanical: the task spells out every change; build: something is left to work out), the symbols or file:line places it touches (focus), and for edits a check command. The kind picks the worker, so Claude has no model to weigh: Haiku at low effort for read and mechanical work, Sonnet at medium for build work.

  1. Where it reaches. For each focus entry the mod asks the language server for references and callers (with lsp-plus, the docs that mention it come along) and tells the worker.
  2. The worker. A subagent, read-only for read, with Edit and Write for mechanical and build. The call waits for it inside the tool call (one request of the main model) and returns the report with the evidence; with background it returns at once and the report arrives as a message. A follow-up within 50 minutes goes to the same worker, its reading still in its context.
  3. The evidence. The diff against a git snapshot taken at the start, however the worker edited; the check command, run by the mod itself; references outside the changed files; what the work cost against the same steps on the main model.
  4. The review. The main model judges the diff and the check, not the report.
  5. The log. Each hand-off goes to ~/.claude/mods/delegate/log.jsonl; /delegate sums it up and opens a side pane. While a worker runs, its card is drawn like a cell (the task, a row per step, the state and cost), and with eta loaded it is a row above the prompt too.

Measured on 120 Opus runs, all correct: a feature or tests cost 23–35% less handed off; lookups, traces, explanations and small edits cost more. Opus does not hand off unless told to, so ask for it as an instruction: "hand the tests to a worker". An order the mod gives works as well as yours (8 of 8 runs, 22–26% less), so when you approve a plan of 20 lines or more, delegate gives that order: plan in plan mode, approve, and a Sonnet worker builds it. Corrected 16 Oct: worker costs were undercounted; these were 27–37% and 27–33%.

A verified hand-off (0.3.0). The worker ends its report with a VERIFY block: the command that runs only its new tests, and what it left open. d

Source 6 files
hooks/register.ts 456 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import {
4  DOC_GLOBS,
5  codeSpanNames,
6  driftNote,
7  editedNames,
8  fencedLines,
9  identifierAt,
10  isDoc,
11  listPlaces,
12  mentionsAsCode,
13  parseGrep,
14  referencesNote,
15} from './docs'
16import type { Mention } from './docs'
17import { LANGUAGES, LSP_FILES, codeHits, definitionAt, nudge, searchPatterns, symbolSearch, usesAsCode } from './nudge'
18import type { SymbolSearch } from './nudge'
19import { answerNote, looksCold, parseIncoming, parseOutline, parseReferences, qualified } from './answer'
20import { TF_FILES, dirOf, inLocals, labelColumn, tfAddress } from './terraform'
21import { blastNote, changedSignatures, headerPattern } from './blast'
22import type { TfSearch } from './terraform'
23
24type Engine = EngineInterface
25type LspInput = { operation?: string; filePath?: string; line?: number; character?: number }
26
27// The LSP calls whose answer is about one name, and where the docs' mentions belong.
28const NAMED_OPS = new Set(['findReferences', 'goToDefinition', 'incomingCalls'])
29// The cells mod's tool: where it is loaded, the notes name its one-step calls.
30const CELLS_TOOL = 'mcp__cells__run_cell'
31const NOT_DOCS = DOC_GLOBS.map(g => `:(exclude)${g}`)
32const MAX_DOC_FILES = 20
33const MAX_REMOVED = 6
34
35let docs = true
36let drift = true
37let nudging = true
38let answering = false
39let warming = false
40let blasting = false
41// The languages the repository has enough of whose server did not answer the warm-up, as the person reads them.
42let missing: string[] = []
43// Symbols already nudged about this session: the next note is one line.
44const nudged = new Set<string>()
45// Names already reported as drifted this session: each is said once.
46const told = new Set<string>()
47
48const join = (cwd: string, path: string) => (path.startsWith('/') ? path : `${cwd}/${path}`)
49
50/** `git` in the session's folder; undefined where git fails (no repository). `git grep` exits 1 on no match. */
51async function git($: Engine, cwd: string, args: string[]): Promise<string | undefined> {
52  const r = await $.process.run(['git', ...args], { cwd, timeoutMs: 8000 }).catch(() => undefined)
53  return r !== undefined && (r.exitCode === 0 || r.exitCode === 1) ? r.stdout : undefined
54}
55
56/** The doc lines that mention `name` as code. */
57async function docMentions($: Engine, cwd: string, name: string): Promise<Mention[]> {
58  const out = await git($, cwd, ['grep', '--untracked', '-n', '-I', '-w', '-F', '-e', name, '--', ...DOC_GLOBS])
59  if (!out) return []
60  const hits = parseGrep(out)
61  const files = [...new Set(hits.map(h => h.path))].slice(0, MAX_DOC_FILES)
62  const found: Mention[] = []
63  for (const path of files) {
64    const text = await $.fs.read(join(cwd, path)).catch(() => '')
65    const fenced = fencedLines(text)
66    for (const h of hits) if (h.path === path && mentionsAsCode(h.text, name, fenced.has(h.line))) found.push(h)
67  }
68  return found
69}
70
71/** Whether any file but the docs still names `name`. */
72async function codeUses($: Engine, cwd: string, name: string): Promise<boolean> {
73  const out = await git($, cwd, ['grep', '--untracked', '-l', '-I', '-w', '-F', '-e', name, '--', '.', ...NOT_DOCS])
74  return out === undefined || out.trim() !== ''
75}
76
77/** A note for each name an edit removed from the code that the docs still mention. */
78async function driftNotes($: Engine, cwd: string, before: string, after: string): Promise<string[]> {
79  const { removed, added } = editedNames(before, after)
80  const notes: string[] = []
81  for (const name of removed.slice(0, MAX_REMOVED)) {
82    if (told.has(name) || (await codeUses($, cwd, name))) continue
83    const found = await docMentions($, cwd, name)
84    if (found.length === 0) continue
85    told.add(name)
86    notes.push(driftNote(name, found, removed.length === 1 && added.length === 1 ? added[0] : undefined))
87  }
88  return notes
89}
90
91/** /refs: every code reference in the docs that no longer resolves. */
92async function checkDocs($: Engine, cwd: string): Promise<string> {
93  const listed = await git($, cwd, ['ls-files', '--cached', '--others', '--exclude-standard', '--', ...DOC_GLOBS])
94  if (listed === undefined) return 'No git repository here, so there are no docs to check.'
95  const files = [...new Set(listed.split('\n').filter(Boolean))].slice(0, 300)
96  const byName = new Map<string, Mention[]>()
97  const badPaths: Mention[] = []
98  for (const path of files) {
99    const text = await $.fs.read(join(cwd, path)).catch(() => '')
100    for (const { name, line } of codeSpanNames(text)) {
101      const list = byName.get(name) ?? []
102      list.push({ path, line, text: '' })
103      byName.set(name, list)
104    }
105    const dir = path.includes('/') ? path.slice(0, path.lastIndexOf('/') + 1) : ''
106    for (const [i, row] of text.split('\n').entries()) {
107      for (const m of row.matchAll(/\]\(((?!https?:|mailto:|#)[^)\s#]+)(?:#[^)]*)?\)/g)) {
108        const target = m[1] as string
109        const there = await $.fs.exists(join(cwd, target.startsWith('/') ? target.slice(1) : dir + target)).catch(() => true)
110        if (!there) badPaths.push({ path, line: i + 1, text: target })
111      }
112    }
113  }
114  const names = [...byName.keys()]
115  const known = new Set<string>()
116  for (let i = 0; i < names.length; i += 100) {
117    const chunk = names.slice(i, i + 100)
118    const out = await git($, cwd, ['grep', '--untracked', '-o', '-h', '-I', '-w', '-F', ...chunk.flatMap(n => ['-e', n]), '--', '.', ...NOT_DOCS])
119    for (const hit of (out ?? '').split('\n')) if (hit) known.add(hit)
120  }
121  const stale = names.filter(n => !known.has(n))
122  const refs = [...byName.values()].reduce((n, l) => n + l.length, 0)
123  const count = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`
124  const docs = count(files.length, 'doc', 'docs')
125  if (stale.length === 0 && badPaths.length === 0) return `All ${count(refs, 'code reference', 'code references')} in ${docs} resolve.`
126  const parts = [
127    `${docs}, ${count(refs, 'code reference', 'code references')}: ${count(stale.length, 'name', 'names')} no file in the code has, ${count(badPaths.length, 'link', 'links')} to files that do not exist.`,
128  ]
129  if (stale.length > 0) parts.push('', 'Names:', ...stale.slice(0, 40).map(n => `  \`${n}\`  ${listPlaces(byName.get(n) ?? [], 4)}`))
130  if (badPaths.length > 0) parts.push('', 'Links:', ...badPaths.slice(0, 40).map(b => `  ${b.text}  ${b.path}:${b.line}`))
131  return parts.join('\n')
132}
133
134/**
135 * The note on a grep for a code symbol: which LSP call answers it exactly, and,
136 * with the cells mod loaded, the call in run_cell that needs no LSP tool loaded.
137 */
138async function symbolNote($: Engine, command: string, r: { deny?: string; text?: string }): Promise<string | undefined> {
139  const search = nudging && r.deny === undefined ? symbolSearch(command) : undefined
140  const out = r.text ?? ''
141  if (search === undefined) return undefined
142  const hits = codeHits(out)
143  if (hits.length === 0 && !out.split('\n').some(l => LSP_FILES.test(l.split(':')[0] ?? ''))) return undefined
144  if (hits.length > 0 && !hits.some(h => usesAsCode(h.text, search.name))) return undefined
145  const again = nudged.has(search.name)
146  nudged.add(search.name)
147  const cells = (await $.tool.list().catch(() => [])).some(t => t.name === CELLS_TOOL) ? { file: hits[0]?.path } : undefined
148  return nudge(search, definitionAt(hits, search.name), again, cells)
149}
150
151/** What the LSP tool says where the server has no call hierarchy for the position. */
152const NO_CALLS = /^No call hierarchy item/
153
154/** One LSP call; its text, or undefined where the server failed, is missing or was refused. */
155async function lsp($: Engine, input: Record<string, unknown>): Promise<string | undefined> {
156  const r = await $.tool.call({ tool: 'LSP', ...input } as never).catch(() => undefined)
157  if (r === undefined || r.deny !== undefined || r.isError) return undefined
158  const text = r.text ?? ''
159  return /^(Error performing|No LSP server)/.test(text) ? undefined : text
160}
161
162/** Where `name` is defined: from the grep's own hits, else the one symbol of that name the server knows. */
163async function definitionOf($: Engine, cwd: string, name: string, hits: ReturnType<typeof codeHits>): Promise<{ path: string; line: number; character: number; text: string } | undefined> {
164  const at = definitionAt(hits, name)
165  if (at) return { ...at, text: hits.find(h => h.path === at.path && h.line === at.line)?.text ?? '' }
166  const any = hits[0]
167  if (!any) return undefined
168  const listed = await lsp($, { operation: 'workspaceSymbol', filePath: join(cwd, any.path), line: 1, character: 1, query: name })
169  if (!listed) return undefined
170  const exact: Array<{ file: string; kind: string; line: number }> = []
171  let file = ''
172  for (const row of listed.split('\n')) {
173    const header = /^(\S.*):$/.exec(row)
174    if (header && !row.startsWith('Found')) file = header[1] as string
175    const m = /^\s+(\S+) \((\w+)\) - Line (\d+)/.exec(row)
176    if (m && m[1] === name && file) exact.push({ file, kind: m[2] as string, line: Number(m[3]) })
177  }
178  const named = exact.filter(s => !/^(Variable|Constant|Field|Property)$/.test(s.kind))
179  const one = named.length === 1 ? named[0] : exact.length === 1 ? exact[0] : undefined
180  if (!one) return undefined
181  const text = ((await $.fs.read(join(cwd, one.file)).catch(() => '')) ?? '').split('\n')[one.line - 1] ?? ''
182  const col = new RegExp(`(^|[^\\w$])${name.replace(/\$/g, '\\$')}(?![\\w$])`).exec(text)
183  if (!col) return undefined
184  return { path: one.file, line: one.line, character: col.index + (col[1] ?? '').length + 1, text }
185}
186
187/**
188 * The language server's answer to a symbol grep, read by the hook: for a function, who calls
189 * it (qualified by each caller's file outline); otherwise its references. A server still
190 * indexing knows only the file it was asked about: asked again for up to three seconds, and
191 * without a whole answer there is no note, never a partial one.
192 */
193async function symbolAnswer($: Engine, search: SymbolSearch, out: string): Promise<string | undefined> {
194  const hits = codeHits(out)
195  const uses = hits.filter(h => usesAsCode(h.text, search.name))
196  if (uses.length === 0) return undefined
197  const cwd = await $.session.cwd()
198  const def = await definitionOf($, cwd, search.name, hits)
199  if (!def) return undefined
200  let callable = /\b(def|function|fn|func|fun)\s/.test(def.text) || /\b(async\s+)?[A-Za-z_$][\w$]*\s*\([^)]*\)\s*[:{]/.test(def.text)
201  const grepFiles = [...new Set(uses.map(h => h.path))]
202  const ask = () => lsp($, { operation: callable ? 'incomingCalls' : 'findReferences', filePath: join(cwd, def.path), line: def.line, character: def.character })
203  let text = await ask()
204  // A server without call hierarchy (ruby-lsp, bash-language-server) still has the references.
205  if (callable && (text === undefined || NO_CALLS.test(text))) {
206    callable = false
207    text = await ask()
208  }
209  const filesOf = (t: string) => (callable ? parseIncoming(t).map(c => c.file) : parseReferences(t).map(r => r.file))
210  for (let tries = 0; text !== undefined && looksCold(filesOf(text), grepFiles, def.path) && tries < 2; tries++) {
211    await $.process.run(['sleep', '1.5'])
212    text = await ask()
213  }
214  if (text === undefined || looksCold(filesOf(text), grepFiles, def.path)) return undefined
215  if (!callable) {
216    const references = parseReferences(text)
217    return references.length === 0 ? undefined : answerNote(search.name, { references })
218  }
219  const callers = parseIncoming(text)
220  if (callers.length === 0) return undefined
221  const outlines = new Map<string, ReturnType<typeof parseOutline>>()
222  await Promise.all(
223    [...new Set(callers.map(c => c.file))].map(async f => {
224      const o = await lsp($, { operation: 'documentSymbol', filePath: join(cwd, f), line: 1, character: 1 })
225      outlines.set(f, o ? parseOutline(o) : [])
226    }),
227  )
228  return answerNote(search.name, { callers: callers.map(c => ({ ...c, qualified: qualified(outlines.get(c.file) ?? [], c.name, c.line) })) })
229}
230
231/** Functions an edit changed the parameters of, asked about at most; rows of callers shown at most. */
232const MAX_CHANGED = 2
233const MAX_BLAST_ROWS = 12
234
235/**
236 * For each function whose parameters an edit changed: who calls it, from the language server
237 * (incomingCalls, or the references where the server has no call hierarchy), as the edit
238 * leaves the file. Nothing where nobody calls it.
239 */
240async function blastNotes($: Engine, filePath: string, before: string, after: string): Promise<string[]> {
241  const changed = changedSignatures(before, after).slice(0, MAX_CHANGED)
242  if (changed.length === 0) return []
243  const lines = ((await $.fs.read(filePath).catch(() => '')) ?? '').split('\n')
244  const notes: string[] = []
245  for (const name of changed) {
246    const header = headerPattern(name.replace(/\$/g, '\\$'))
247    const i = lines.findIndex(l => header.test(l))
248    if (i < 0) continue
249    const m = header.exec(lines[i] as string) as RegExpExecArray
250    const at = { filePath, line: i + 1, character: m.index + m[0].length - name.length + 1 }
251    const calls = await lsp($, { operation: 'incomingCalls', ...at })
252    const rows: string[] = []
253    let count = 0
254    const files = new Set<string>()
255    if (calls !== undefined && !NO_CALLS.test(calls) && parseIncoming(calls).length > 0) {
256      const callers = parseIncoming(calls)
257      for (const f of [...new Set(callers.map(c => c.file))]) {
258        const here = callers.filter(c => c.file === f)
259        count += here.reduce((n, c) => n + Math.max(1, c.at.length), 0)
260        files.add(f)
261        rows.push(`${f}: ${here.map(c => `${c.name} (${c.at.join(', ') || c.line})`).join(', ')}`)
262      }
263    } else {
264      const refs = parseReferences((await lsp($, { operation: 'findReferences', ...at })) ?? '').filter(r => !(filePath.endsWith(r.file) && r.line === at.line))
265      for (const f of [...new Set(refs.map(r => r.file))]) {
266        const here = refs.filter(r => r.file === f)
267        count += here.length
268        files.add(f)
269        rows.push(`${f}: ${here.map(r => r.line).join(', ')}`)
270      }
271    }
272    if (rows.length > 0) notes.push(blastNote(name, rows.slice(0, MAX_BLAST_ROWS), count, files.size))
273  }
274  return notes
275}
276
277/** Declarations asked about at most: an address declared in more modules than this is a grep's job. */
278const MAX_TF_DECLARATIONS = 5
279
280/**
281 * terraform-ls's references for a Terraform address a grep searched for, asked at each declaration
282 * in a directory where the grep found it (a module sees only its own blocks) and merged. The server
283 * leaves the declaration out; where it finds nothing in a directory the grep found uses in, it is
284 * still indexing: asked again for up to three seconds, and without a whole answer there is none.
285 */
286async function tfAnswer($: Engine, tf: TfSearch, out: string): Promise<string | undefined> {
287  const hits = [...out.matchAll(/^([^\s:]+):(\d+):(.*)$/gm)].map(m => ({ path: m[1] as string, line: Number(m[2]) })).filter(h => TF_FILES.test(h.path))
288  if (hits.length === 0) return undefined
289  const dirs = new Set(hits.map(h => dirOf(h.path)))
290  const cwd = await $.session.cwd()
291  const listed = await git($, cwd, ['grep', '--untracked', '-n', '-E', '-e', tf.declares, '--', '*.tf'])
292  if (!listed) return undefined
293  const declarations: Array<{ path: string; line: number; character: number }> = []
294  for (const m of listed.matchAll(/^([^:\n]+):(\d+):(.*)$/gm)) {
295    const [path, line, text] = [m[1] as string, Number(m[2]), m[3] as string]
296    if (!dirs.has(dirOf(path))) continue
297    if (tf.address.startsWith('local.') && !inLocals(((await $.fs.read(join(cwd, path)).catch(() => '')) ?? '').split('\n'), line)) continue
298    const character = labelColumn(text, tf.label)
299    if (character !== undefined) declarations.push({ path, line, character })
300  }
301  if (declarations.length === 0 || declarations.length > MAX_TF_DECLARATIONS) return undefined
302  const references: ReturnType<typeof parseReferences> = []
303  for (const d of declarations) {
304    const ask = () => lsp($, { operation: 'findReferences', filePath: join(cwd, d.path), line: d.line, character: d.character })
305    const usesHere = hits.some(h => dirOf(h.path) === dirOf(d.path) && !(h.path === d.path && h.line === d.line))
306    let text = await ask()
307    for (let tries = 0; text !== undefined && usesHere && parseReferences(text).length === 0 && tries < 2; tries++) {
308      await $.process.run(['sleep', '1.5'])
309      text = await ask()
310    }
311    if (text === undefined || (usesHere && parseReferences(text).length === 0)) return undefined
312    references.push(...parseReferences(text))
313  }
314  return references.length === 0 ? undefined : answerNote(tf.address, { references })
315}
316
317/**
318 * Starts the language servers the repository will need, before anyone asks: one question about
319 * one tracked file of each kind a server reads. A server opens the project on its first
320 * question and answers whole from the second on. The file asked about is the smallest of its
321 * kind: the server reports that file's problems and Claude Code puts them into the conversation
322 * (measured: click's docs/conf.py and its missing theme import, in every run, +4.5% on small
323 * tasks), and a near-empty file has nothing to report.
324 */
325async function warmUp($: Engine) {
326  const cwd = await $.session.cwd()
327  // Each tracked file under cwd with its size: "<mode> blob <hash> <size>\t<path>", NUL-separated.
328  const listed = await git($, cwd, ['ls-tree', '-r', '-l', '-z', 'HEAD'])
329  if (!listed) return
330  const found = new Map<string, { file: string; size: number; count: number; name: string }>()
331  for (const entry of listed.split('\0')) {
332    const m = /^\d+ blob \w+\s+(\d+)\t(.+)$/.exec(entry)
333    if (!m) continue
334    const f = m[2] as string
335    const size = Number(m[1])
336    const ext = /\.([^./]+)$/.exec(f)?.[1]
337    const language = ext ? languageOf(ext) : undefined
338    if (!language || !LSP_FILES.test(f) || /(^|\/)(node_modules|vendor|dist|build)\//.test(f)) continue
339    const had = found.get(language.kind)
340    const count = (had?.count ?? 0) + 1
341    found.set(language.kind, !had || size < had.size ? { file: f, size, count, name: language.name } : { ...had, count })
342  }
343  const answers = await Promise.all(
344    [...found.values()].map(async l => ({ l, text: await lspText($, { operation: 'documentSymbol', filePath: join(cwd, l.file), line: 1, character: 1 }) })),
345  )
346  missing = answers.filter(a => a.l.count >= MIN_FILES && a.text !== undefined && NO_SERVER.test(a.text)).map(a => `${a.l.name} (${a.l.count} files)`)
347  if (missing.length > 0) $.ui.log(missingLine(missing))
348}
349
350/** Which server a file's extension needs, and the language's name as the person knows it. */
351function languageOf(ext: string): { kind: string; name: string } | undefined {
352  return LANGUAGES.find(l => l.exts.includes(ext))
353}
354
355/** A language with fewer files than this (a build script, a fixture) is not worth a server, nor a word. */
356const MIN_FILES = 5
357/** What the LSP tool answers where no plugin names a server for the file, or the server is not on the PATH. */
358const NO_SERVER = /^No LSP server|^Error performing.*(ENOENT|spawn|not found|failed to start)/is
359
360/** One LSP call's text as the tool gave it, failures included; undefined where the call was refused or threw. */
361async function lspText($: Engine, input: Record<string, unknown>): Promise<string | undefined> {
362  const r = await $.tool.call({ tool: 'LSP', ...input } as never).catch(() => undefined)
363  if (r === undefined || r.deny !== undefined) return undefined
364  return r.text ?? ''
365}
366
367/** The line the person sees where a language of theirs has no server. */
368export function missingLine(languages: readonly string[]): string {
369  return `lsp-plus: no language server answers for ${languages.join(', ')} in this repository, so symbol searches fall back to grep, and lsp-plus, cells and delegate answer less well. Run /lsp-plus:setup to set one up.`
370}
371
372/** The system-prompt section where a language has no server: Claude offers the setup once, the person decides. */
373export function missingNote(languages: readonly string[]): string {
374  return `# Language servers missing
375
376lsp-plus found no working language server for ${languages.join(', ')} in this repository: symbol searches fall back to grep, without the server's exact references, definitions and callers. Where those would have helped the person, tell them once and offer /lsp-plus:setup, which installs the servers with them. Don't bring it up again after that.`
377}
378
379export const register: Register = (on, options) => {
380  if (options.enabled === false) return
381  docs = options.docs !== false
382  drift = options.drift !== false
383  nudging = options.nudge === true // off unless asked for: never acted on in 45 benchmark notes
384  answering = options.answer !== false // measured: 25% fewer requests on a rename, even on a trace
385  blasting = options.blast === true // off until measured
386  warming = options.warm !== false
387  missing = []
388
389  on('session.start', async ($, e, next) => {
390    await $.command.register({ name: 'refs', description: 'Check the docs for code names and links that no longer resolve' })
391    if (warming) void warmUp($).catch(() => undefined)
392    return next(e)
393  })
394
395  // Where the warm-up found a language without a server, Claude is told too, so it can offer the setup. The system
396  // prompt is fixed at the session's first request: a warm-up still running then says nothing to Claude this session.
397  on('prompt.compose', async ($, e, next) => {
398    const composed = await next(e)
399    if (missing.length === 0) return composed
400    return { sections: [...composed.sections, { id: 'lsp-plus-missing', text: missingNote(missing), scope: 'session' as const }] }
401  }).catch(($, e, next) => next(e))
402
403  on('command.run', { command: 'refs' }, async $ => ({ text: await checkDocs($, await $.session.cwd()) }))
404
405  // References and definitions name places in the code; the docs that use the name join them.
406  on('tool.call', { tool: 'LSP' }, async ($, e, next) => {
407    const r = await next(e)
408    const input = e as unknown as LspInput
409    if (!docs || r.deny !== undefined || r.isError || !NAMED_OPS.has(input.operation ?? '')) return r
410    // The LSP tool reports a server that failed or is missing as a plain result.
411    if (/^(Error performing|No LSP server)/.test(r.text ?? '')) return r
412    if (typeof input.filePath !== 'string' || typeof input.line !== 'number' || typeof input.character !== 'number') return r
413    const cwd = await $.session.cwd()
414    const source = await $.fs.read(join(cwd, input.filePath)).catch(() => undefined)
415    const name = source === undefined ? undefined : identifierAt(source.split('\n')[input.line - 1] ?? '', input.character)
416    if (name === undefined || name.length < 3) return r
417    const found = await docMentions($, cwd, name)
418    if (found.length === 0) return r
419    return { ...r, context: [...(r.context ?? []), referencesNote(name, found)] }
420  }).catch(($, e, next) => next(e))
421
422  // A grep for a code symbol runs as asked; the note says which LSP call answers it exactly.
423  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
424    const r = await next(e)
425    if (r.deny !== undefined) return r
426    const search = answering ? symbolSearch(e.command) : undefined
427    let answer = search === undefined ? undefined : await symbolAnswer($, search, r.text ?? '').catch(() => undefined)
428    // A Terraform address (var.region, aws_s3_bucket.logs) is no identifier; the module's declarations answer it.
429    const tf = answering && answer === undefined ? searchPatterns(e.command).map(tfAddress).find(t => t !== undefined) : undefined
430    if (tf !== undefined) answer = await tfAnswer($, tf, r.text ?? '').catch(() => undefined)
431    const text = answer ?? (await symbolNote($, e.command, r))
432    return text === undefined ? r : { ...r, context: [...(r.context ?? []), text] }
433  }).catch(($, e, next) => next(e))
434
435  // While code is being written: a name the edit took away that the docs still use, and (blast)
436  // who calls a function whose parameters it changed.
437  on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
438    const r = await next(e)
439    const input = e as unknown as { file_path?: string; old_string?: string; new_string?: string }
440    if ((!drift && !blasting) || r.deny !== undefined || r.isError || typeof input.file_path !== 'string' || isDoc(input.file_path)) return r
441    const notes = drift ? await driftNotes($, await $.session.cwd(), input.old_string ?? '', input.new_string ?? '') : []
442    if (blasting) notes.push(...(await blastNotes($, input.file_path, input.old_string ?? '', input.new_string ?? '').catch(() => [])))
443    return notes.length === 0 ? r : { ...r, context: [...(r.context ?? []), ...notes] }
444  }).catch(($, e, next) => next(e))
445
446  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
447    const input = e as unknown as { file_path?: string; content?: string }
448    const path = typeof input.file_path === 'string' && !isDoc(input.file_path) ? input.file_path : undefined
449    const before = drift && path !== undefined ? await $.fs.read(path).catch(() => undefined) : undefined
450    const r = await next(e)
451    if (before === undefined || r.deny !== undefined || r.isError) return r
452    const notes = await driftNotes($, await $.session.cwd(), before, input.content ?? '')
453    return notes.length === 0 ? r : { ...r, context: [...(r.context ?? []), ...notes] }
454  }).catch(($, e, next) => next(e))
455}
456
hooks/docs.ts 136 lines
1// Pure helpers: which name an LSP call is about, which names an edit removed,
2// and which lines of the docs mention a name as code.
3
4/** Files read as documentation. */
5export const DOC_GLOBS = ['*.md', '*.mdx', '*.markdown', '*.rst', '*.adoc'] as const
6
7export const isDoc = (path: string) => /\.(md|mdx|markdown|rst|adoc)$/i.test(path)
8
9const WORD = /[A-Za-z0-9_$]/
10
11/** The identifier at a 1-based character of a line, or the next one to its right. */
12export function identifierAt(line: string, character: number): string | undefined {
13  let i = Math.max(0, Math.min(line.length - 1, character - 1))
14  if (!WORD.test(line[i] ?? '')) {
15    const rest = line.slice(i).search(/[A-Za-z_$]/)
16    if (rest < 0) return undefined
17    i += rest
18  }
19  let start = i
20  while (start > 0 && WORD.test(line[start - 1] ?? '')) start--
21  let end = i
22  while (end < line.length && WORD.test(line[end] ?? '')) end++
23  const name = line.slice(start, end)
24  return /^[A-Za-z_$]/.test(name) ? name : undefined
25}
26
27const DEFINITION =
28  /\b(?:function\*?|class|interface|type|enum|struct|trait|def|fn|func|const|let|var|val|module|namespace|record|macro_rules!)\s+([A-Za-z_$][\w$]*)|\bfunc\s+\([^)]*\)\s*([A-Za-z_]\w*)/g
29
30/** The names a piece of source defines, by the keywords most languages define with. */
31export function definedNames(source: string): Set<string> {
32  const names = new Set<string>()
33  for (const m of source.matchAll(DEFINITION)) {
34    const name = m[1] ?? m[2]
35    if (name !== undefined && name.length >= 3) names.add(name)
36  }
37  return names
38}
39
40export function identifiers(source: string): Set<string> {
41  return new Set(source.match(/[A-Za-z_$][\w$]*/g) ?? [])
42}
43
44/**
45 * What an edit took out of the code: names the old text defined that the new
46 * text no longer mentions at all, and the names it newly defines (a rename
47 * shows as one of each).
48 */
49export function editedNames(before: string, after: string): { removed: string[]; added: string[] } {
50  const inAfter = identifiers(after)
51  const inBefore = identifiers(before)
52  const removed = [...definedNames(before)].filter(n => !inAfter.has(n))
53  const added = [...definedNames(after)].filter(n => !inBefore.has(n))
54  return { removed, added }
55}
56
57export type Mention = { path: string; line: number; text: string }
58
59/** `git grep -n` output (`path:line:text`) as mentions. */
60export function parseGrep(out: string): Mention[] {
61  const found: Mention[] = []
62  for (const row of out.split('\n')) {
63    const m = /^(.+?):(\d+):(.*)$/.exec(row)
64    if (m) found.push({ path: m[1] as string, line: Number(m[2]), text: m[3] as string })
65  }
66  return found
67}
68
69const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
70
71/** Lines of a doc that sit inside fenced code blocks, 1-based. */
72export function fencedLines(doc: string): Set<number> {
73  const inside = new Set<number>()
74  let fence: string | undefined
75  doc.split('\n').forEach((row, i) => {
76    const m = /^\s*(```|~~~)/.exec(row)
77    if (m) {
78      if (fence === undefined) fence = m[1]
79      else if (m[1] === fence) fence = undefined
80      return
81    }
82    if (fence !== undefined) inside.add(i + 1)
83  })
84  return inside
85}
86
87/**
88 * Whether a doc line mentions `name` as code rather than as an ordinary word:
89 * inside backticks, called, reached through a dot, in a fenced block, or a name
90 * no prose would use (camelCase, snake_case, a digit, a `$`).
91 */
92export function mentionsAsCode(text: string, name: string, fenced: boolean): boolean {
93  const n = escape(name)
94  if (!new RegExp(`(^|[^\\w$])${n}($|[^\\w$])`).test(text)) return false
95  if (fenced) return true
96  if (/[a-z][A-Z]|_|\d|\$/.test(name) || /^[A-Z][a-z]+[A-Z]/.test(name)) return true
97  if (new RegExp('`[^`]*(^|[^\\w$])' + n + '($|[^\\w$])[^`]*`').test(text)) return true
98  return new RegExp(`(\\.${n}\\b|\\b${n}\\s*\\()`).test(text)
99}
100
101/** "README.md:42, docs/api.md:7, … and 3 more" */
102export function listPlaces(found: readonly Mention[], max = 8): string {
103  const shown = found.slice(0, max).map(m => `${m.path}:${m.line}`)
104  const more = found.length - shown.length
105  return shown.join(', ') + (more > 0 ? `, and ${more} more` : '')
106}
107
108export function referencesNote(name: string, found: readonly Mention[]): string {
109  const lines = found.slice(0, 8).map(m => `  ${m.path}:${m.line}  ${m.text.trim().slice(0, 140)}`)
110  const more = found.length > 8 ? `\n  … and ${found.length - 8} more` : ''
111  return `The docs mention \`${name}\` in ${found.length} place${found.length === 1 ? '' : 's'} the language server does not index:\n${lines.join('\n')}${more}`
112}
113
114export function driftNote(name: string, found: readonly Mention[], renamedTo?: string): string {
115  const rename = renamedTo === undefined ? '' : ` (renamed to \`${renamedTo}\`?)`
116  return `The docs still mention \`${name}\`${rename}, which no file in the code defines or uses any more: ${listPlaces(found)}. If they describe this code, update them in this change.`
117}
118
119/** Code spans in a doc that name a symbol: `name`, `name()`, `a.b.name`. */
120export function codeSpanNames(doc: string): { name: string; line: number }[] {
121  const out: { name: string; line: number }[] = []
122  doc.split('\n').forEach((row, i) => {
123    for (const m of row.matchAll(/`([^`\s]+)`/g)) {
124      const span = m[1] as string
125      const call = /^([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)(\(\))?$/.exec(span)
126      if (!call) continue
127      const name = (call[1] as string).split('.').at(-1) as string
128      const codeish = call[2] !== undefined || (call[1] as string).includes('.') || /[a-z][A-Z]|_|\d/.test(name)
129      if (codeish && name.length >= 3 && !STOP.has(name)) out.push({ name, line: i + 1 })
130    }
131  })
132  return out
133}
134
135const STOP = new Set(['true', 'false', 'null', 'undefined', 'None', 'True', 'False', 'self', 'this', 'README', 'TODO'])
136
hooks/nudge.ts 181 lines
1// A text search for a code symbol (grep, rg, ag, git grep, sed or awk), in code a
2// language server covers: the search runs as asked, and Claude is told which LSP
3// call answers it exactly. A nudge, never a block.
4
5/**
6 * The languages a language server reads, by file extension: the server the warm-up starts
7 * once per `kind`, and the language's name as the person knows it. The extensions are those
8 * the servers' plugins claim (the official ones, and lsp-plus's own).
9 */
10export const LANGUAGES: ReadonlyArray<{ exts: readonly string[]; kind: string; name: string }> = [
11  { exts: ['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'mts', 'cts'], kind: 'ts', name: 'TypeScript/JavaScript' },
12  { exts: ['py', 'pyi'], kind: 'py', name: 'Python' },
13  { exts: ['c', 'h', 'cc', 'cpp', 'cxx', 'hpp', 'hh', 'hxx'], kind: 'c', name: 'C/C++' },
14  { exts: ['rs'], kind: 'rs', name: 'Rust' },
15  { exts: ['go'], kind: 'go', name: 'Go' },
16  { exts: ['swift'], kind: 'swift', name: 'Swift' },
17  { exts: ['java'], kind: 'java', name: 'Java' },
18  { exts: ['kt', 'kts'], kind: 'kt', name: 'Kotlin' },
19  { exts: ['cs'], kind: 'cs', name: 'C#' },
20  { exts: ['php'], kind: 'php', name: 'PHP' },
21  { exts: ['rb', 'rake', 'gemspec', 'ru'], kind: 'rb', name: 'Ruby' },
22  { exts: ['lua'], kind: 'lua', name: 'Lua' },
23  { exts: ['sh', 'bash'], kind: 'sh', name: 'shell' },
24  { exts: ['tf', 'tfvars'], kind: 'tf', name: 'Terraform' },
25]
26
27/** Source files a language server reads. */
28export const LSP_FILES = new RegExp(`\\.(${LANGUAGES.flatMap(l => l.exts).join('|')})$`)
29
30const SEARCHERS = /^(?:grep|egrep|fgrep|rg|ag|ack)$/
31const DEF_WORDS = '(?:def|function|class|fn|func|fun|const|let|var|val|interface|type|struct|enum|trait|protocol|object|module)'
32// What may stand between the keyword and the name: Go's receiver `func (s *Server) Name`, Kotlin's
33// type parameters and receiver type `fun <T> List<T>.name`, Ruby's `def self.name`.
34const RECEIVER = '(?:\\([^)]*\\)\\s*|<[^>]*>\\s*)?(?:[\\w$]+(?:<[^>]*>)?\\??\\.)?'
35
36export type SymbolSearch = { name: string; kind: 'uses' | 'definition' }
37
38/** Splits a shell command line into words, honouring quotes; enough for a grep's arguments. */
39function words(segment: string): string[] {
40  const out: string[] = []
41  for (const m of segment.matchAll(/'([^']*)'|"((?:[^"\\]|\\.)*)"|(\S+)/g)) out.push(m[1] ?? m[2]?.replace(/\\(.)/g, '$1') ?? (m[3] as string))
42  return out
43}
44
45/** The pattern a sed or awk program searches for: `sed -n '/name/p'`, `awk '/name/ {...}'`. */
46function scriptPattern(tool: string, args: string[]): string | undefined {
47  for (const a of args) {
48    if (a.startsWith('-')) continue
49    const m = tool === 'sed' ? /^\/(.+?)\/(?:p|=)$/.exec(a) : /^\/(.+?)\/(?:\s*\{.*\})?$/s.exec(a)
50    return m?.[1]
51  }
52  return undefined
53}
54
55/** The pattern of one grep-like command, past its options and the values they take. */
56function grepPattern(args: string[]): string | undefined {
57  for (let i = 0; i < args.length; i++) {
58    const a = args[i] as string
59    if (a === '-e' || a === '--regexp') return args[i + 1]
60    if (/^--?[A-Za-z]/.test(a)) {
61      if (/^(-[ABCmfgtT]|--(include|exclude|exclude-dir|glob|type|type-add|max-count|context|after-context|before-context))$/.test(a)) i++
62      continue
63    }
64    return a
65  }
66  return undefined
67}
68
69/**
70 * The pattern of each command of the line that searches files: after `cd …&&`,
71 * behind `xargs`, in a pipeline; grep, egrep, rg, ag, ack, git grep, and sed or
72 * awk printing what matches.
73 */
74export function searchPatterns(command: string): string[] {
75  const patterns: string[] = []
76  // Each command of the line; in a pipeline only its head or an xargs stage reads files (a grep fed by a pipe filters text).
77  const stages = command
78    .split(/\s*(?:&&|\|\||;)\s*/)
79    .flatMap(cmd => cmd.split(/\s*\|\s*/).filter((stage, i) => i === 0 || /^xargs\b/.test(stage.trim())))
80  for (const raw of stages) {
81    let args = words(raw.trim())
82    if (args[0] === 'xargs') {
83      // xargs' own options (and the values of -n, -I, -P, -L, -s, -d) come before the command it runs.
84      let i = 1
85      while (i < args.length && (args[i] as string).startsWith('-')) i += /^-[nIPLsd]$/.test(args[i] as string) ? 2 : 1
86      args = args.slice(i)
87    }
88    const tool = args[0] ?? ''
89    let pattern: string | undefined
90    if (tool === 'git' && args[1] === 'grep') pattern = grepPattern(args.slice(2))
91    else if (SEARCHERS.test(tool)) pattern = grepPattern(args.slice(1))
92    else if ((tool === 'sed' && args.includes('-n')) || tool === 'awk') pattern = scriptPattern(tool, args.slice(1))
93    if (pattern !== undefined) patterns.push(pattern)
94  }
95  return patterns
96}
97
98/**
99 * Whether a shell command searches for one code symbol. Its pattern must name
100 * one identifier (bare, `-w`, `\bname\b`, `name(`, `.name`) or its definition
101 * (`def name`, `function name`); alternations and free text are not symbols.
102 */
103export function symbolSearch(command: string): SymbolSearch | undefined {
104  for (const pattern of searchPatterns(command)) {
105    const found = asSymbol(pattern)
106    if (found !== undefined) return found
107  }
108  return undefined
109}
110
111function asSymbol(pattern: string): SymbolSearch | undefined {
112  const p = pattern.replace(/\\b|\\<|\\>/g, '').replace(/^\^\s*/, '')
113  const def = new RegExp(`^(?:export\\s+)?${DEF_WORDS}\\s+(?:\\\\?\\([^)]*\\)\\s*)?(?:[\\w$]+\\\\?\\.)?([A-Za-z_$][\\w$]*)\\b`).exec(p)
114  if (def) return { name: def[1] as string, kind: 'definition' }
115  const use = /^\.?([A-Za-z_$][\w$]*)(?:\\?\(|\s*\\?\()?$/.exec(p)
116  if (use && (use[1] as string).length >= 3) return { name: use[1] as string, kind: 'uses' }
117  return undefined
118}
119
120export type Hit = { path: string; line: number; text: string }
121
122/** `path:line:text` rows of a grep's output (with -n), only in files a language server reads. */
123export function codeHits(output: string): Hit[] {
124  const hits: Hit[] = []
125  for (const row of output.split('\n')) {
126    const m = /^([^\s:]+):(\d+):(.*)$/.exec(row)
127    if (m && LSP_FILES.test(m[1] as string)) hits.push({ path: m[1] as string, line: Number(m[2]), text: m[3] as string })
128  }
129  return hits
130}
131
132/**
133 * Whether a source line uses `name` as code: outside its string literals and a
134 * trailing comment. A name met only in strings (a mod's name in a message, a
135 * key) is no symbol to ask the language server about.
136 */
137export function usesAsCode(text: string, name: string): boolean {
138  const code = text.replace(/(["'`])(?:\\.|(?!\1).)*\1/g, '""').replace(/(^|\s)(#|\/\/).*$/, '')
139  return new RegExp(`(^|[^\\w$])${name.replace(/\$/g, '\\$')}(?![\\w$])`).test(code)
140}
141
142/** Among the hits, where `name` is defined, at the column the LSP tool asks for (1-based). */
143export function definitionAt(hits: readonly Hit[], name: string): { path: string; line: number; character: number } | undefined {
144  const re = new RegExp(`\\b${DEF_WORDS}\\s+${RECEIVER}(${name.replace(/\$/g, '\\$')})\\b`)
145  for (const h of hits) {
146    const m = re.exec(h.text)
147    if (m) return { path: h.path, line: h.line, character: m.index + m[0].length - name.length + 1 }
148  }
149  return undefined
150}
151
152/**
153 * The note: what the language server answers better, the exact call, and what
154 * it cannot see, so grep keeps that part. It adds a way; it takes none away.
155 *
156 * With the cells mod's run_cell in the session, the note also names the call
157 * there: refs() asks the server without the LSP tool loaded first (it is
158 * deferred) and gives each place with its line, and rename() renames every
159 * code reference through Edit. `file` is a file that uses the name, for when
160 * the definition is not among the hits.
161 */
162export function nudge(
163  search: SymbolSearch,
164  at: { path: string; line: number; character: number } | undefined,
165  again: boolean,
166  cells: { file?: string } | undefined = undefined,
167): string {
168  const where = at ? `LSP findReferences on ${at.path}:${at.line}:${at.character}` : `LSP workspaceSymbol with query "${search.name}"`
169  const cell = cells === undefined ? undefined : at ? `refs('${at.path}', '${search.name}', { line: ${at.line} })` : cells.file ? `refs('${cells.file}', '${search.name}')` : undefined
170  if (again) {
171    const or = cell ? ` (or ${cell} in run_cell)` : ''
172    return `\`${search.name}\`: ${where}${or} gives its exact code uses; grep stays needed for strings, docs and dynamic uses.`
173  }
174  const what = at
175    ? 'lists exactly its uses in the code, without comments, strings or namesakes, and incomingCalls there lists the functions that call it'
176    : 'finds its definition, and findReferences there lists exactly its uses in the code'
177  const note = `\`${search.name}\` is a code symbol in files a language server covers: ${where} ${what}. The server does not see strings, dynamic uses or most docs, so for a rename or a complete sweep keep the grep for those.`
178  if (cell === undefined) return note
179  return `${note} In run_cell, ${cell} asks the same in one step without loading the LSP tool, each place with its line; rename(path, name, newName) there renames every code reference through Edit and lists where the name is left as text.`
180}
181
hooks/answer.ts 111 lines
1// The language server's answer to a symbol grep, attached to the grep's own result: the
2// functions that call it, or its references, read from the server by the hook itself. No
3// advice and no second step for Claude: the exact code answer is already there, and grep's
4// output stays for what the server cannot see (strings, comments, docs, namesakes).
5
6/** One place the server lists: a caller (with the lines it calls from) or a reference line. */
7export type Caller = { file: string; name: string; kind: string; line: number; at: number[] }
8
9/** `incomingCalls` text: `file:` headers, then `  name (Kind) - Line N [calls at: L:C, …]`. */
10export function parseIncoming(text: string): Caller[] {
11  const out: Caller[] = []
12  let file = ''
13  for (const row of text.split('\n')) {
14    const header = /^(\S.*):$/.exec(row)
15    if (header && !row.startsWith('Found')) {
16      file = header[1] as string
17      continue
18    }
19    const m = /^\s+(\S+) \((\w+)\) - Line (\d+)(?: \[calls at: ([^\]]*)\])?/.exec(row)
20    if (m && file) {
21      const at = (m[4] ?? '').split(',').map(s => Number(s.trim().split(':')[0])).filter(n => n > 0)
22      out.push({ file, name: m[1] as string, kind: m[2] as string, line: Number(m[3]), at })
23    }
24  }
25  return out
26}
27
28/** `findReferences` text: `file:` headers, then `  Line L:C` rows (or `file:L:C` for a single one). */
29export function parseReferences(text: string): Array<{ file: string; line: number }> {
30  const out: Array<{ file: string; line: number }> = []
31  let file = ''
32  for (const row of text.split('\n')) {
33    const single = /^\s+(\S+?):(\d+):(\d+)$/.exec(row)
34    if (single) {
35      out.push({ file: single[1] as string, line: Number(single[2]) })
36      continue
37    }
38    const header = /^(\S.*):$/.exec(row)
39    if (header && !row.startsWith('Found')) {
40      file = header[1] as string
41      continue
42    }
43    const m = /^\s+Line (\d+):\d+/.exec(row)
44    if (m && file) out.push({ file, line: Number(m[1]) })
45  }
46  return out
47}
48
49/** `documentSymbol` text, as a tree by indentation: each symbol with the names of those it sits in. */
50export function parseOutline(text: string): Array<{ name: string; kind: string; line: number; path: string[] }> {
51  const out: Array<{ name: string; kind: string; line: number; path: string[] }> = []
52  const stack: Array<{ depth: number; name: string }> = []
53  for (const row of text.split('\n')) {
54    const m = /^(\s*)(\S+) \((\w+)\) - Line (\d+)/.exec(row)
55    if (!m) continue
56    const depth = (m[1] as string).length
57    while (stack.length > 0 && (stack.at(-1) as { depth: number }).depth >= depth) stack.pop()
58    out.push({ name: m[2] as string, kind: m[3] as string, line: Number(m[4]), path: stack.map(s => s.name) })
59    stack.push({ depth, name: m[2] as string })
60  }
61  return out
62}
63
64/** A caller's qualified name from its file's outline: `Class.method`, `outer.inner`; its plain name when not found. */
65export function qualified(outline: ReturnType<typeof parseOutline>, name: string, line: number): string {
66  const hit = outline.find(s => s.name === name && s.line === line)
67  return hit ? [...hit.path, name].join('.') : name
68}
69
70const MAX_CHARS = 1600
71
72/**
73 * The note: the server's callers (qualified, with their call lines) or references, by file,
74 * compact and capped. It says what it covers, so grep's output keeps its part.
75 */
76export function answerNote(
77  name: string,
78  found: { callers: Array<Caller & { qualified: string }> } | { references: Array<{ file: string; line: number }> },
79): string {
80  const rows: string[] = []
81  let head: string
82  if ('callers' in found) {
83    const files = [...new Set(found.callers.map(c => c.file))]
84    head = `\`${name}\` per the language server: called from ${found.callers.length} function${found.callers.length === 1 ? '' : 's'} in ${files.length} file${files.length === 1 ? '' : 's'}`
85    for (const f of files) {
86      const here = found.callers.filter(c => c.file === f).map(c => `${c.qualified} (${c.at.join(', ')})`)
87      rows.push(`${f}: ${here.join(', ')}`)
88    }
89  } else {
90    const files = [...new Set(found.references.map(r => r.file))]
91    head = `\`${name}\` per the language server: ${found.references.length} reference${found.references.length === 1 ? '' : 's'} in code, in ${files.length} file${files.length === 1 ? '' : 's'}`
92    for (const f of files) rows.push(`${f}: ${found.references.filter(r => r.file === f).map(r => r.line).join(', ')}`)
93  }
94  const tail = '(code only: strings, comments, docs and namesakes are in the grep above)'
95  let body = ''
96  let shown = 0
97  for (const row of rows) {
98    if (body.length + row.length > MAX_CHARS) break
99    body += `\n${row}`
100    shown++
101  }
102  const more = shown < rows.length ? `\n… and ${rows.length - shown} more files` : ''
103  return `${head} ${tail}:${body}${more}`
104}
105
106/** Whether an answer looks like a server still indexing: it knows only the file it was asked about. */
107export function looksCold(files: readonly string[], grepFiles: readonly string[], asked: string): boolean {
108  const others = grepFiles.filter(f => f !== asked)
109  return others.length > 0 && files.every(f => f === asked)
110}
111
hooks/terraform.ts 75 lines
1// Terraform names what it declares by address: var.region, local.tags, module.vpc,
2// aws_s3_bucket.logs, data.aws_iam_policy_document.read. A grep for an address, or for the
3// block that declares it, gets terraform-ls's references. A block is seen only within its
4// module, the directory it is in, so the server is asked at each declaration in a directory
5// where the grep found the address.
6
7export type TfSearch = {
8  /** The address as the configuration writes it. */
9  address: string
10  /** The declaring line, as an extended regular expression for git grep. */
11  declares: string
12  /** The name in the declaring line the server is asked about. */
13  label: string
14}
15
16const ID = '[A-Za-z_][A-Za-z0-9_-]*'
17
18/** The address a grep's pattern names, from a use (`var.region`) or its declaration (`variable "region"`). */
19export function tfAddress(pattern: string): TfSearch | undefined {
20  const p = pattern
21    .replace(/\\b|\\<|\\>/g, '')
22    .replace(/\\([."])/g, '$1')
23    .replace(/^\^\s*/, '')
24    .trim()
25  const block = (kind: string, ...labels: string[]) => `^[[:space:]]*${kind}[[:space:]]+${labels.map(l => `"${l}"`).join('[[:space:]]+')}`
26  let m: RegExpExecArray | null
27  if ((m = new RegExp(`^var\\.(${ID})$`).exec(p)) || (m = new RegExp(`^variable\\s+"(${ID})"`).exec(p))) {
28    const name = m[1] as string
29    return { address: `var.${name}`, declares: block('variable', name), label: name }
30  }
31  if ((m = new RegExp(`^module\\.(${ID})$`).exec(p)) || (m = new RegExp(`^module\\s+"(${ID})"`).exec(p))) {
32    const name = m[1] as string
33    return { address: `module.${name}`, declares: block('module', name), label: name }
34  }
35  if ((m = new RegExp(`^local\\.(${ID})$`).exec(p))) {
36    const name = m[1] as string
37    return { address: `local.${name}`, declares: `^[[:space:]]*${name}[[:space:]]*=`, label: name }
38  }
39  if ((m = new RegExp(`^data\\.(${ID})\\.(${ID})$`).exec(p)) || (m = new RegExp(`^data\\s+"(${ID})"\\s+"(${ID})"`).exec(p))) {
40    const [type, name] = [m[1] as string, m[2] as string]
41    return { address: `data.${type}.${name}`, declares: block('data', type, name), label: name }
42  }
43  // A resource type is the provider's name, an underscore and the kind: aws_s3_bucket.
44  if ((m = new RegExp(`^([a-z][a-z0-9]*_[A-Za-z0-9_]+)\\.(${ID})$`).exec(p)) || (m = new RegExp(`^resource\\s+"(${ID})"\\s+"(${ID})"`).exec(p))) {
45    const [type, name] = [m[1] as string, m[2] as string]
46    return { address: `${type}.${name}`, declares: block('resource', type, name), label: name }
47  }
48  return undefined
49}
50
51export const TF_FILES = /\.tf$/
52
53/** The directory a repository path is in ('' at the root). */
54export function dirOf(path: string): string {
55  const i = path.lastIndexOf('/')
56  return i < 0 ? '' : path.slice(0, i)
57}
58
59/** Whether a line of a .tf file sits in a `locals` block: the block it is in starts at the last unindented line above. */
60export function inLocals(lines: readonly string[], line: number): boolean {
61  for (let i = line - 2; i >= 0; i--) {
62    const row = lines[i] as string
63    if (/^\S/.test(row)) return /^locals\s*\{/.test(row)
64  }
65  return false
66}
67
68/** The 1-based column of the declaration's label in its line: the quoted name, or a local's name. */
69export function labelColumn(text: string, label: string): number | undefined {
70  const quoted = text.lastIndexOf(`"${label}"`)
71  if (quoted >= 0) return quoted + 2
72  const bare = new RegExp(`^\\s*${label}\\s*=`).exec(text)
73  return bare ? text.indexOf(label) + 1 : undefined
74}
75
hooks/blast.ts 45 lines
1// An edit that changes a function's parameters: the callers the language server knows, which
2// may still pass the old ones. Code-graph tools print a "blast radius" after every edit from a
3// stored graph; this one asks the live server, and only when a signature changed.
4
5// What may stand between the keyword and the name: Go's receiver `func (s *Server) Name`, Kotlin's
6// type parameters and receiver type `fun <T> List<T>.name`.
7const BEFORE_NAME = '(?:\\([^)]*\\)\\s*|<[^>]*>\\s*)?(?:[\\w$]+(?:<[^>]*>)?\\??\\.)?'
8
9/** A definition's header up to its name: the keyword, what may stand before the name, the name. */
10export function headerPattern(name = '[A-Za-z_$][\\w$]*'): RegExp {
11  return new RegExp(`\\b(?:def|function|fn|func|fun)\\s+${BEFORE_NAME}(${name})\\b`)
12}
13
14/** A definition's header: up to the name, an optional type-parameter list, then `(`. */
15const HEADER = new RegExp(`${headerPattern().source}\\s*(?:<[^>]*>)?\\s*\\(`, 'g')
16
17/** Each function a piece of source defines, with its parameter list as written (balanced parentheses, whitespace dropped). */
18export function signatures(source: string): Map<string, string> {
19  const out = new Map<string, string>()
20  for (const m of source.matchAll(HEADER)) {
21    let depth = 1
22    let i = (m.index as number) + m[0].length
23    const start = i
24    for (; i < source.length && depth > 0; i++) {
25      if (source[i] === '(') depth++
26      else if (source[i] === ')') depth--
27    }
28    // A header cut off by the edit's boundary is no signature to compare.
29    if (depth === 0) out.set(m[1] as string, source.slice(start, i - 1).replace(/#.*$/gm, '').replace(/\s+/g, '').replace(/,$/, ''))
30  }
31  return out
32}
33
34/** The functions an edit kept but whose parameter list it changed. */
35export function changedSignatures(before: string, after: string): string[] {
36  const was = signatures(before)
37  const now = signatures(after)
38  return [...now].filter(([name, params]) => was.has(name) && was.get(name) !== params).map(([name]) => name)
39}
40
41/** The note: who calls it, from the server, and why that matters now. */
42export function blastNote(name: string, rows: readonly string[], count: number, files: number): string {
43  return `\`${name}\`'s parameters changed; per the language server it is called from ${count} place${count === 1 ? '' : 's'} in ${files} file${files === 1 ? '' : 's'}, which may still pass the old ones:\n${rows.join('\n')}`
44}
45