isobar, a Claude Code mod: a weather map for the session's change, drawn over a fixed map of the codebase, to read a complex change at a glance. A red storm…

A weather map for every change your agent makes, from a broad refactor to a one-line fix. See where it landed, what it did there and what else it reaches, on a map of your repo that stays the same from session to session.
isobar is a Claude Code mod (a plugin built on Claude Code's early-access function hooks, which let it draw its own pane) that docks a pane beside the conversation. Read it like a weather radar: a red storm on the code that changed, a few words under each changed region on what the change did there, and green rain on the code that uses it. You see the shape of a change before you read its diff, and a part you did not expect it to reach stands out at once. Read the diff for the lines; read the pane for where they landed and what they touch.
The change is everything uncommitted against HEAD, your own edits included, plus files created during the session; on a clean tree, the last commit.

Watch it work in a 26-second film: isobar.mp4.
The rain is selective. Across 300 recent commits in six public repositories, the median commit rains on 0 to 12 files where marking every importer within three hops would mark 59 to 352, and it still covers 88 to 98 percent of the files a language server (TypeScript's findReferences, or jedi for Python) says use the change. Accuracy has the precision and the misses.
You need Claude Code 2.1.287 or later, run interactively in a terminal, inside a git repository.
claude plugin marketplace add r3al1tym/isobar
claude plugin install isobar@isobar
claude session in your repository. A session that was already running does not load the mod./isobar opens the pane and focuses it, so its number keys toggle layers. A pane that opened by itself leaves focus on the prompt: press ctrl+x then Tab, or click it, to focus it. Esc returns focus to the prompt.To hand the setup to your coding agent, paste the prompt below. An agent reading this page should follow AGENTS.md: checks, settings, verification and troubleshooting, each with exact commands and expected output.
Set up the isobar Claude Code plugin by following https://raw.githubusercontent.com/r3al1tym/isobar/main/AGENTS.md. Ask me before changing any setting, verify the install, then tell me how to open the pane.
smallModel (Sonnet by default) reads the diff of each changed region and captions it under the region's name in 2 to 4 words, such as names endpoint in error and treats off as false in the picture above. The caption is the diff's own account, written without your requests, so you can set it against what you meant.new behaviour · 11 uses in 3 files.smallModel (Sonnet by default) compares your requests with the turn's diff. An edit nobody asked for carries UNASKED and a few words on what it did.
1 this turn · 2 earlier), or last commit <hash> when nothing is uncommitted.CHANGED +a −d, NO TESTS when source code changed what it does and no test moved with it, UNASKED when the scope check flagged an edit, and, with history on (key 4), EXPECTED on a region where a dashed ring marks a file history says should have changed. A changed test counts when it imports the file, shares its name, or its new lines name what the edit touched or introduced, such as a new config key.new signature or new behaviour with its uses (11 uses in 3 files, or no uses elsewhere), comments only, imports only, new file, or, for a file isobar reads whole, how many files depend on it. The line turns red when the edit reaches other files and no test moved with it.file · N hops. A file is named by as much of its path as tells it apart from the others on the pane: sansio/app.py beside flask/app.py.unasked: changelog entry./isobar opens and focuses the pane, or closes it when it is open. /isobar map redraws the basemap.With the pane focused, these keys work:
1 change: the eye on each edited file.2 reach: the green rain and the dotted track.3 risk: the red storm.4 history: dashed rings on files that usually change with these and did not; off at first.p submits a prompt as you: Claude reads the import chain to the farthest file and runs its tests if it has any. In a repository you do not trust, ask that question yourself (SECURITY.md).m redraws the basemap.Every repository gets its own map:

claude --version; run claude update if it is older. Function hooks are an early-access surface, and this release is checked on 2.1.287.claude -p and Agent SDK sessions./isobar, it opens by itself from 110 columns, until you close it by hand. Inside tmux, Claude Code's main-screen layout places the pane above the prompt; elsewhere its fullscreen layout docks it beside the conversation from 110 columns.Languages other than JavaScript, TypeScript and Python get their edits, history and the map, but no rain.
Pick one method. Each loads isobar under its own plugin id, with its own settings and its own kept maps. Paths below use ~/.claude; if you set CLAUDE_CONFIG_DIR, Claude Code reads that folder instead.
isobar@isobar): the two commands in Quick start. Every setting has a default, so a notice that options are not set yet needs no action.isobar@skills-dir). Claude Code reloads it when you save a file. Running it needs no pnpm install. An installed isobar@isobar takes precedence over the clone even when disabled, so uninstall it first. claude plugin uninstall isobar@isobar # only if you installed from the marketplace
git clone https://github.com/r3al1tym/isobar ~/src/isobar
mkdir -p "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills"
ln -sfn ~/src/isobar "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills/isobar"
isobar@inline): claude --plugin-dir ~/src/isobar, on a clone made with the git clone line above.claude plugin list
claude plugin details isobar@isobar | head -1
The first lists isobar among any other plugins:
❯ isobar@isobar
Version: 0.3.0
Scope: user
Status: ✔ enabled
The second prints isobar 0.3.0. A linked clone is listed under Skills-directory plugins as isobar@skills-dir, with Status: ✔ loaded.
claude plugin details lists Hooks (0) for any function-hook mod; that is expected. claude -p never runs isobar, so the last check is yours: start a new interactive session, ask for an edit, and look for the pane. AGENTS.md has the full check.
To give a team one shared map, commit it as .isobar/map.json. To draw one, run pnpm install once in a clone of isobar, then, from the clone and with absolute paths:
mkdir -p /path/to/repo/.isobar
pnpm preview --repo /path/to/repo --build model --map /path/to/repo/.isobar/map.json
It names the regions with claude -p on Opus (--model picks another) and writes the map first, then renders out/preview.png in the clone. The render needs python3 with Pillow and any monospace font; if it fails, the map is still written. The tool needs Node 20.11 or later and pnpm. When claude -p gives no usable answer, it writes nothing and exits 1. Commit .isobar/map.json.
claude plugin marketplace update isobar && claude plugin update isobar@isobar, then restart Claude Code. A clone updates with git -C ~/src/isobar pull. 0.3.0 renamed scopeModel to smallModel and moved its default from haiku to sonnet; if you had set scopeModel, set smallModel to the same value (CHANGELOG.md).claude plugin uninstall isobar@isobar && claude plugin marketplace remove isobar, or rm "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills/isobar" for a linked clone (it removes the link and keeps the clone). For --plugin-dir, start sessions without the flag.~/.claude/plugins/store/isobar_*.json after an uninstall. Delete those files to forget every map, or run /isobar map to redraw one repository's. A committed .isobar/map.json belongs to the team; leave it in place./isobar, and check that panel is not command.colors under Settings.gist is off, the turn is still running, or your account cannot use smallModel.Every message the pane can show, with its cause and fix: AGENTS.md § 10.
Set these with /plugin configure isobar@isobar in Claude Code, at install with claude plugin install isobar@isobar --config scope=on, or from a shell with echo '{"scope":"on"}' | claude plugin configure isobar@isobar --values-stdin. A change applies when Claude Code restarts. claude plugin configure isobar@isobar --json reads them back under inputs, where a blank smallModel or mapModel means its default. If you installed from a clone, configure isobar@skills-dir instead of isobar@isobar.
| Setting | Values | What it does |
|---|---|---|
gist | on (default), off | Ask smallModel to caption what the change does in each region, whenever a changed region has no caption and no turn is running: once a turn ends, at session start, and on the last commit when nothing is uncommitted. |
scope | off (default), on | After each turn that changes files and ends with an answer, ask smallModel which changes your requests never called for. A turn you interrupt is not checked. |
smallModel | sonnet (default), any model alias or id | The model the gist and the scope check ask. |
mapModel | empty (default), any model alias or id | The model that names the basemap's regions, once per repository; empty uses the model the session runs on. |
ground | paper (default), night, auto | Warm paper, the terminal's near-black, or whichever matches your Claude Code theme. |
colors | auto (default), truecolor, 256 | How many colours the terminal paints. On Linux, auto reads it from the environment Claude Code started in; elsewhere it assumes 24-bit unless inside tmux or Apple's Terminal, so set 256 if the paper looks yellow. |
panel | auto (default), command | Open the pane once the repository has uncommitted changes (at session start or on the session's first edit), or only on /isobar. |

On the night ground the storm burns up from red through amber, and the rain stays sage.
The warm paper needs 24-bit colour. Claude Code paints 24-bit where COLORTERM=truecolor is set (and in kitty, Ghostty and iTerm) and never inside tmux; everywhere else it paints xterm's 256 colours, where the cream would turn yellow, so isobar switches to white paper and xterm's own colours there. Inside tmux, leave colors on auto: forcing truecolor turns the cream yellow. Windows Terminal draws 24-bit but never sets COLORTERM; add export COLORTERM=truecolor to your shell profile to get the paper.
tsconfig, jsconfig, package.json and pnpm-workspace.yaml files that say where imports point. Commits that touch more than 40 files are dropped as bulk moves.index probing, tsconfig paths and baseUrl, workspace packages by name, package.json #imports, and Python 3's absolute and relative modules, multi-line imports included. Imports of outside packages are left out.git diff -U0), your own edits included, and each changed file before and after are read into declarations: functions, classes, methods, fields, types and constants. A declaration whose header changed has a new signature; one whose code changed otherwise has a new behaviour; one whose code is the same changed only comments. New untracked files count too: those Claude wrote with the Write tool, and any that appeared after the session first read the repository. With nothing uncommitted, the last commit. Before the first commit, the change is read against an empty tree.git grep -w finds the files that depend on a changed file, at any import distance, and name a touched declaration, or, for a private one, a function in the same file that calls it. Those are the rain. A file isobar cannot read by declaration (another language, a new or deleted file, module-level code, a file over 20,000 lines, or any past the first 40 changed files) rains on every importer, three hops out.git hash-object, which stores nothing), so isobar knows which turn last changed each one.smallModel carries each changed region's name and blurb, its files with their added and deleted line counts and touched declarations, and up to 400 lines of their diff in all, split evenly across the files with at least 12 each until the 400 run out. It asks for a caption of 2 to 4 words per region. Your requests stay out of it. A caption holds until its region's change changes; while a turn runs, the last caption stays.smallModel carries your last four requests and, for each file the turn changed, its whole uncommitted diff against HEAD (earlier turns' and your own edits to it included), capped at 400 lines (60 per file). It asks which changes no request called for. A flag holds until the file changes again.mapModel, to group them into at most 20 regions in 3 to 6 bands, from where work enters down to the foundations. Every file lands in exactly one region. When the model's answer is unusable, the folders themselves become the regions for that session, the frame says folders only · /isobar map retries, and the next session asks again. A region's area grows with the square root of its lines of code and with how many files outside it import it, on a scale of 1 to 10. The map is kept in Claude Code's plugin store (~/.claude/plugins/store/), per install and per repository path, and new files join a region already there (a tracked file the region its imports point to, a file just created its folder's region), so new files never grow or move the frame.Refreshes run when a session or turn starts, when a turn ends (with gist or scope on), and 500 ms after Claude's last edit or shell command (Edit, Write, MultiEdit, NotebookEdit, Bash), one at a time.
One refresh at each repository's latest commit, median of 5 after a warm-up, on a laptop (Intel Core Ultra 7 265H, WSL2). A refresh is the facts from git, the change read by declaration, the weather and the drawing; bench/results.md times each part.
| Repository | Text files | One refresh |
|---|---|---|
| express | 211 | 33 ms |
| flask | 230 | 67 ms |
| excalidraw | 1,018 | 298 ms |
| vite | 2,736 | 107 ms |
| django | 5,675 | 437 ms |
| VS Code | 19,547 | 1.8 s |
The import graph and the rain are measured against outside tools on six public repositories, the frame on three by redrawing it, and history by backtest; bench/ reproduces it and bench/results.md breaks it down.
from pkg import submodule also runs pkg/__init__.py, and the compiler cannot resolve a workspace package without installed node_modules.findReferences and jedi. The rain covers 88 to 98 percent of the files that reference a changed declaration, or a function in the same file that calls a changed private one. Of the files it rains on, 47 to 86 percent are such files, and 25 percent on VS Code, where a common member name such as setActive matches declarations of the same name elsewhere.hooks/register.tsx 639 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { IsobarLayers } from '../types'
5import { basemapPrompt, completeRegions, finishBasemap, heuristicRegions, parseBasemapReply, sanitizeBasemap, unitsOf } from './engine/basemap'
6import { currentChange, gatherFacts, refsOf, repoRoot, type Refs } from './engine/git'
7import { gistLines, gistPrompt, parseGistReply } from './engine/gist'
8import { graphOf } from './engine/graph'
9import { excerptOf, parseScopeReply, scopePrompt } from './engine/scope'
10import { attribute, hashesOf, type Ledger } from './engine/session'
11import { readChange } from './engine/symbols'
12import type { Basemap, Facts, Run } from './engine/types'
13import { weatherOf, type Weather } from './engine/weather'
14import { DEFAULT_LAYERS } from './render/field'
15import { colorsOf, type Colors } from './render/palette'
16import { packGrid } from './render/raster'
17import { sheetOf } from './render/sheet'
18
19const PANE = 'isobar'
20const tick = atom({ plugin: 'isobar', key: 'tick' } as const, 0)
21const layersAtom = atom({ plugin: 'isobar', key: 'layers' } as const, DEFAULT_LAYERS as IsobarLayers)
22/** Whether the pane has opened this session: kept by the host, so a reload never reopens a pane the person closed. */
23const openedAtom = atom({ plugin: 'isobar', key: 'opened' } as const, false)
24
25/** Tools whose calls can change the working tree. */
26const WRITES = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash'])
27const LAYER_DIGITS = { '1': 'code', '2': 'impact', '3': 'risk', '4': 'history' } as const
28
29/** What the pane draws from. Rebuilt from git on every reload, so a module variable is enough. */
30const sky = {
31 cwd: '',
32 /** The repository mapped now. */
33 root: null as string | null,
34 /** The repository the next refresh maps: the last edited file's (a Bash command leaves it be). */
35 target: null as string | null,
36 /** folder → the repository it sits in, so an edit asks git once per folder */
37 roots: new Map<string, string>(),
38 repo: '',
39 facts: null as Facts | null,
40 map: null as Basemap | null,
41 weather: null as Weather | null,
42 status: undefined as string | undefined,
43 /** What the map is printed on, from the `ground` option or else the Claude Code theme. */
44 ground: 'paper' as 'paper' | 'night',
45 /** How many colours the terminal paints, from the `colors` option or else the environment Claude Code started in. */
46 colors: 'truecolor' as Colors,
47 /** repository → the untracked files this session wrote there, relative to it */
48 created: new Map<string, Set<string>>(),
49 /** repository → the files this session's own edits named, relative to it */
50 edited: new Map<string, Set<string>>(),
51 /** repository → each changed file's content when last seen, and the turn that wrote it */
52 ledgers: new Map<string, Ledger>(),
53 /** repository → file → what the scope check said of it, and the content it said it of */
54 unasked: new Map<string, Map<string, { why: string; hash: string }>>(),
55 /** repository → region → the gist's caption for the change there, and the change it was written of */
56 gists: new Map<string, Map<string, { what: string; key: string }>>(),
57 /** region → the change it carries now, as the gist keys it: each file's content, or the commit */
58 gistKeys: new Map<string, string>(),
59 /** the changes the gist has been asked of, so a reply that fails is never asked for again in a loop */
60 gistAsked: new Set<string>(),
61 /** whether the gist runs: for any region it has not captioned while no turn runs (at session start, after a turn, on a commit shown) */
62 isGistOn: true,
63 /** what the mapped repository's change is measured from, as the last refresh found it */
64 refs: { head: 'HEAD', parent: 'HEAD~1', isBorn: true } as Refs,
65 /** repository → its untracked files when the session first looked: one missing from it, the session made */
66 untracked: new Map<string, ReadonlySet<string>>(),
67 /** why the repository's own `.isobar/map.json` is set aside, when it is */
68 mapNote: undefined as string | undefined,
69 /** prompts this session has started: the turn the next edit belongs to */
70 turn: 0,
71 /** a turn is running: the gist waits for it to end, so it reads the turn whole */
72 isTurnRunning: false,
73 /** a turn ended and the scope check runs once the refresh it awaits is done */
74 isScopeDue: false,
75 /** the turn the scope check reads: the one that ended, even once the next has started */
76 scopeTurn: 0,
77 /** the small model the gist and the scope check ask */
78 smallModel: 'sonnet',
79 isBusy: false,
80 /** a refresh asked for while one ran: it runs next, rebuilding if any asker wanted that, for the earliest turn asked */
81 again: null as { rebuild: boolean; turn: number } | null,
82 timer: null as { cancel: () => void } | null,
83}
84let drawn: { key: string; cells: string } | null = null
85
86export const register: Register = (on, options) => {
87 const panel = options.panel === 'command' ? 'command' : 'auto'
88 // empty: the model the session runs on
89 const mapModel = typeof options.mapModel === 'string' ? options.mapModel.trim() : ''
90 const scope = options.scope === 'on' ? 'on' : 'off'
91 sky.isGistOn = options.gist !== 'off'
92 sky.smallModel = typeof options.smallModel === 'string' && options.smallModel.trim() !== '' ? options.smallModel.trim() : 'sonnet'
93 const ground = options.ground === 'night' || options.ground === 'auto' ? options.ground : 'paper'
94 const colors = options.colors === 'truecolor' || options.colors === '256' ? options.colors : 'auto'
95
96 on('session.start', async ($, e, next) => {
97 const started = await next(e)
98
99 if (!e.isInteractive) return started
100 sky.cwd = e.cwd
101 sky.ground = ground === 'auto' ? await groundOfTheme($) : ground
102 sky.colors = colors === 'auto' ? await colorsOfTerminal($) : colors
103 await $.command.register({ name: 'isobar', description: 'Show where this change reaches, as weather over a map of the codebase', argumentHint: '[map]' })
104 void refresh($, mapModel, panel)
105
106 return started
107 })
108
109 // a theme switched mid-session reprints the map on the new ground
110 on('config.set', { key: 'theme' }, async ($, e, next) => {
111 const written = await next(e)
112
113 if (ground === 'auto') {
114 sky.ground = typeof e.value === 'string' && e.value.startsWith('light') ? 'paper' : 'night'
115 await update($, tick, n => (n ?? 0) + 1)
116 }
117 return written
118 })
119
120 // a prompt starts a turn: edits from here on are its own, and anything changed since belongs to the one before
121 on('turn.start', async ($, e, next) => {
122 const started = await next(e)
123
124 if (sky.cwd !== '') {
125 const before = sky.turn
126
127 sky.turn++
128 sky.isTurnRunning = true
129 void refresh($, mapModel, panel, false, before)
130 }
131 return started
132 })
133
134 // the main loop's end ends a turn: once the last refresh lands, the gist captions what changed, and on
135 // an answer the scope check reads what it changed
136 on('turn.complete', async ($, e, next) => {
137 const done = await next(e)
138
139 if (e.agentId === undefined && sky.cwd !== '') {
140 sky.isTurnRunning = false
141 if (scope === 'on' && e.reason === 'answer') {
142 sky.isScopeDue = true
143 sky.scopeTurn = sky.turn
144 }
145 if (scope === 'on' || sky.isGistOn) void refresh($, mapModel, panel)
146 }
147 return done
148 })
149
150 on('tool.call', async ($, e, next) => {
151 const result = await next(e)
152
153 if (!WRITES.has(String(e.tool)) || sky.cwd === '') return result
154 const input = e as unknown as Record<string, unknown>
155 const raw = typeof input.file_path === 'string' ? input.file_path : typeof input.notebook_path === 'string' ? input.notebook_path : undefined
156
157 // an edit maps the repository its file sits in; a Bash command keeps the map it has
158 if (raw !== undefined) {
159 const path = raw.startsWith('/') || /^[A-Za-z]:[\\/]/.test(raw) ? raw : `${sky.cwd}/${raw}`
160 // git names the repository by its physical path, so a symlinked checkout is resolved first
161 const real = (await $.fs.stat(path, { resolve: true }).catch(() => undefined))?.realPath ?? path
162 const root = await rootOfFile($, real)
163
164 if (root !== null) {
165 sky.target = root
166 if (real.startsWith(`${root}/`)) {
167 if (String(e.tool) === 'Write') createdIn(root).add(real.slice(root.length + 1))
168 setOf(sky.edited, root).add(real.slice(root.length + 1))
169 }
170 }
171 }
172 sky.timer?.cancel()
173 sky.timer = $.clock.after(500, () => {
174 void refresh($, mapModel, panel)
175 })
176
177 return result
178 })
179
180 on('command.run', { command: 'isobar' }, async ($, e) => {
181 const arg = e.args.trim()
182
183 if (arg === 'map') {
184 void refresh($, mapModel, panel, true)
185 return { text: 'Redrawing the basemap of this codebase. The pane updates when it is ready.' }
186 }
187 // the host knows which panes are open, across reloads; a pane behind another tab comes forward
188 const pane = (await $.ui.panes()).find(p => p.id === PANE)
189
190 if (pane?.isShown === true && pane.isPlaced) {
191 await $.ui.close({ id: PANE })
192 return { text: 'Isobar pane closed.' }
193 }
194 await openPane($, true)
195 return { text: sky.weather?.headline ?? 'Isobar pane opened.' }
196 })
197
198 // The keys are hidden buttons: their hotkeys work, Tab never lands on them.
199 on('ui.focus', { requestId: PANE }, ($, e, next) => (e.element?.startsWith('key-') ? { deny: 'isobar keys are hotkeys only' } : next(e)))
200
201 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
202 const version = await read($, tick)
203 const layers = await read($, layersAtom)
204
205 if (e.surface !== 'terminal') {
206 const { Box, Text } = $.ui.resolve(e)
207 const w = sky.weather
208 // names, captions and file names come from the repository and the model: drawn as plain text, never as markdown
209 const plain = (s: string) => s.replace(/\p{C}/gu, '')
210 const captions = Object.entries(w?.regions ?? {}).flatMap(([id, r]) => (r.what === undefined ? [] : [`${sky.map?.regions.find(x => x.id === id)?.name ?? id}: ${r.what}`]))
211 const note = noteOf()
212
213 return (
214 <Box flexDirection="column" gap={1}>
215 <Text bold>{plain(sky.status ?? w?.headline ?? 'Reading the repository…')}</Text>
216 {note === undefined ? null : <Text dimColor>{plain(note)}</Text>}
217 {captions.length > 0 ? (
218 <Box flexDirection="column">
219 {captions.map((c, i) => (
220 <Text key={`caption-${i}`}>{plain(c)}</Text>
221 ))}
222 </Box>
223 ) : null}
224 {(w?.lines ?? []).map((l, i) => (
225 <Text key={`line-${i}`}>{plain(l)}</Text>
226 ))}
227 </Box>
228 )
229 }
230
231 const { Box, Button, Raster } = $.ui.resolve(e)
232 // the pane's own size: a sheet too small for the map says so instead of clipping it
233 const cols = Math.max(1, Math.min(512, e.props.bodyColumns))
234 const rows = Math.max(1, Math.min(256, e.props.scroll.bodyRows))
235 const note = noteOf()
236 const key = `${version}:${cols}:${rows}:${sky.ground}:${sky.colors}:${layers.code}${layers.impact}${layers.risk}${layers.history}:${note ?? ''}`
237
238 if (drawn?.key !== key) {
239 const grid = sheetOf(
240 {
241 repo: sky.repo || 'isobar',
242 map: sky.map,
243 files: sky.facts === null ? [] : [...sky.facts.lines.keys()],
244 lines: sky.facts?.lines ?? new Map(),
245 weather: sky.weather,
246 layers,
247 status: sky.status,
248 ...(note === undefined ? {} : { note }),
249 ground: sky.ground,
250 colors: sky.colors,
251 },
252 cols,
253 rows,
254 )
255
256 drawn = { key, cells: packGrid(grid) }
257 }
258
259 return (
260 <Box flexDirection="column">
261 <Raster key="sheet" columns={cols} rows={rows} cells={drawn.cells} />
262 <Box display="none">
263 <Button key="key-1" hotkey="1" onPress={() => toggleLayer($, '1')}>code</Button>
264 <Button key="key-2" hotkey="2" onPress={() => toggleLayer($, '2')}>impact</Button>
265 <Button key="key-3" hotkey="3" onPress={() => toggleLayer($, '3')}>risk</Button>
266 <Button key="key-4" hotkey="4" onPress={() => toggleLayer($, '4')}>history</Button>
267 <Button key="key-p" hotkey="p" onPress={() => probe($)}>probe</Button>
268 <Button key="key-m" hotkey="m" onPress={() => redrawMap($, mapModel, panel)}>map</Button>
269 </Box>
270 </Box>
271 )
272 })
273}
274
275type Dollar = EngineInterface
276
277function setOf(by: Map<string, Set<string>>, root: string): Set<string> {
278 const set = by.get(root) ?? new Set<string>()
279
280 by.set(root, set)
281 return set
282}
283
284const createdIn = (root: string) => setOf(sky.created, root)
285
286/** What the map is drawn from, when that is worth knowing: folders alone, or a shared map set aside. */
287function noteOf(): string | undefined {
288 const notes = [
289 sky.map?.source === 'heuristic' ? 'folders only · /isobar map retries' : '',
290 sky.mapNote ?? '',
291 ].filter(n => n !== '')
292
293 return notes.length === 0 ? undefined : notes.join(' · ')
294}
295
296/** The repository a file sits in, from git in its folder (or the nearest one that exists); null outside any. */
297async function rootOfFile($: Dollar, path: string): Promise<string | null> {
298 const run = runOf($)
299 let dir = path.slice(0, path.lastIndexOf('/')) || '/'
300
301 for (let i = 0; i < 12; i++) {
302 const known = sky.roots.get(dir)
303
304 if (known !== undefined) return known
305 const root = await repoRoot(run, dir)
306
307 if (root !== null) {
308 sky.roots.set(dir, root)
309 return root
310 }
311 if (dir === '/') return null
312 dir = dir.slice(0, dir.lastIndexOf('/')) || '/'
313 }
314 return null
315}
316
317/** Night on a dark theme, paper on a light one: the map takes the ground the transcript beside it sits on. */
318async function groundOfTheme($: Dollar): Promise<'paper' | 'night'> {
319 try {
320 const theme = (await $.config.list()).find(row => row.key === 'theme')?.value
321
322 return typeof theme === 'string' && theme.startsWith('light') ? 'paper' : 'night'
323 } catch {
324 return 'night'
325 }
326}
327
328/**
329 * How many colours Claude Code paints in this terminal. It sets COLORTERM for itself once it
330 * starts, so the environment it started with is read from /proc; where there is none (macOS),
331 * tmux and Apple's Terminal are the 256-colour ones.
332 */
333async function colorsOfTerminal($: Dollar): Promise<Colors> {
334 try {
335 // only the four variables colorsOf reads leave the shell; ISOBAR=1 says /proc was there to read
336 const started = await $.process.run(
337 ['sh', '-c', '[ -r /proc/$PPID/environ ] || exit 1; echo ISOBAR=1; tr "\\0" "\\n" < /proc/$PPID/environ | grep -E "^(COLORTERM|TERM|TERM_PROGRAM|TMUX)="; exit 0'],
338 { timeoutMs: 5_000 },
339 )
340
341 if (started.exitCode === 0 && started.stdout.includes('=')) {
342 return colorsOf(Object.fromEntries(started.stdout.split('\n').map(line => [line.slice(0, line.indexOf('=')), line.slice(line.indexOf('=') + 1)])))
343 }
344 return (await $.env.get('TMUX')) || (await $.env.get('TERM_PROGRAM')) === 'Apple_Terminal' ? '256' : 'truecolor'
345 } catch {
346 return 'truecolor'
347 }
348}
349
350async function isPaneOpen($: Dollar): Promise<boolean> {
351 return (await $.ui.panes()).some(p => p.id === PANE)
352}
353
354/** Opens the pane; asked for by the person, it takes the keys so 1–4, p and m work at once (Esc hands them back). */
355async function openPane($: Dollar, isAsked = false) {
356 await update($, openedAtom, () => true)
357 await $.ui.open(isAsked ? { id: PANE, title: 'Isobar', columns: 96, focus: true } : { id: PANE, title: 'Isobar', columns: 96 })
358}
359
360async function toggleLayer($: Dollar, digit: keyof typeof LAYER_DIGITS) {
361 const layer = LAYER_DIGITS[digit]
362
363 await update($, layersAtom, l => ({ ...(l ?? DEFAULT_LAYERS), [layer]: !(l ?? DEFAULT_LAYERS)[layer] }))
364}
365
366/** A repository-controlled string as the question quotes it: as data in quotes, control and format characters out. */
367const inert = (s: string) => JSON.stringify(s.replace(/\p{C}/gu, ''))
368
369/** Hands the farthest reach to the model as the person's own question, every path and name in it quoted as data. */
370async function probe($: Dollar) {
371 const w = sky.weather
372 const far = w?.offshoots[0]
373
374 if (w === null || far === undefined) {
375 $.ui.toast('Nothing reaches past the regions this change sits in.')
376 return
377 }
378 const region = sky.map?.regions.find(r => r.id === far.region)?.name ?? far.region
379
380 await $.prompt.submit({
381 text: `Check the farthest reach of my current change: ${far.chain.map(inert).join(' → ')} (${far.hop} hops, into ${inert(region)}). Read ${inert(far.path)} and the files on that chain and tell me whether the change to ${inert(far.chain[0] ?? '')} can break it. Run its tests if it has any. Keep the answer short.`,
382 asUser: true,
383 })
384}
385
386async function redrawMap($: Dollar, mapModel: string, panel: string) {
387 await refresh($, mapModel, panel, true)
388}
389
390/** How long one git call may run before it is stopped and the refresh fails. */
391const RUN_MS = 20_000
392
393/**
394 * Runs a command and reads its whole output. `$.process.run` keeps only the first 4 MiB, which a
395 * large repository's grep passes; a spawned child's stream drops nothing. Past RUN_MS the
396 * stream is closed, which kills the child, and the call rejects.
397 */
398const runOf =
399 ($: Dollar): Run =>
400 argv => {
401 const child = $.process.spawn({ argv })
402 const read = (async () => {
403 let stdout = ''
404
405 for await (const piece of child) if (piece.stream === 'stdout') stdout += piece.text
406 return { exitCode: (await child.result).code ?? 1, stdout }
407 })()
408 let timer: { cancel: () => void } | undefined
409 const late = new Promise<never>((_, reject) => {
410 timer = $.clock.after(RUN_MS, () => {
411 void child.return({ code: null, signal: 'SIGTERM' }).catch(() => undefined)
412 reject(new Error(`${argv.slice(0, 4).join(' ')} ran past ${RUN_MS / 1000} s`))
413 })
414 })
415
416 read.catch(() => undefined)
417 late.catch(() => undefined)
418 return Promise.race([read, late]).finally(() => timer?.cancel())
419 }
420
421/**
422 * Reads git, loads or draws the basemap, works out the weather, then redraws. One at a time.
423 * Files whose content moved since the last refresh were written in `turn`.
424 */
425async function refresh($: Dollar, mapModel: string, panel: string, rebuild = false, turn = sky.turn) {
426 if (sky.isBusy) {
427 sky.again = { rebuild: (sky.again?.rebuild ?? false) || rebuild, turn: Math.min(sky.again?.turn ?? turn, turn) }
428 return
429 }
430 sky.isBusy = true
431 try {
432 const run = runOf($)
433
434 const root = sky.target ?? sky.root ?? (await repoRoot(run, sky.cwd))
435
436 // a new repository gets its own map, kept or drawn once, and its own weather
437 if (root !== sky.root) {
438 sky.root = root
439 sky.map = null
440 sky.weather = null
441 }
442 if (sky.root === null) {
443 sky.status = 'This folder is not a git repository, so there is no change to map.'
444 return
445 }
446 sky.repo = sky.root.split('/').pop() ?? sky.root
447 sky.facts = await gatherFacts(run, sky.root)
448 sky.refs = await refsOf(run, sky.root)
449 const refs = sky.refs
450 const before = sky.untracked.get(sky.root)
451 const change = await currentChange(run, sky.root, [...createdIn(sky.root)], { refs, ...(before === undefined ? {} : { before }) })
452
453 if (before === undefined) sky.untracked.set(sky.root, new Set(change.untracked))
454 const isEditing = change.base.kind === 'uncommitted'
455 // the session's ledger: which turn wrote each changed file; a clean tree starts an empty one,
456 // so whatever changes from here on is this session's
457 const hashes = isEditing ? await hashesOf(run, sky.root, change.changes) : new Map<string, string>()
458 const ledger = isEditing ? attribute(sky.ledgers.get(sky.root), hashes, turn, setOf(sky.edited, sky.root)) : { hashes: new Map<string, string>(), turns: new Map<string, number>() }
459
460 sky.ledgers.set(sky.root, ledger)
461
462 // The map is drawn once per repository, and only once there is something to show on it.
463 if (sky.map === null && !isEditing && !rebuild && !(await isPaneOpen($))) {
464 sky.map = await keptMap($, sky.root, sky.facts)
465 if (sky.map === null) return
466 }
467 if (sky.map === null || rebuild) sky.map = await mapFor($, sky.root, sky.facts, mapModel, rebuild)
468 const map = sky.map
469
470 sky.status = undefined
471 const changeRead = await readChange(run, sky.root, sky.facts, change.changes, isEditing ? { from: refs.head } : { from: refs.parent, to: refs.head }).catch(() => undefined)
472 // what the scope check said, while the file is as it was when it said it
473 const flagged = new Map([...(sky.unasked.get(sky.root) ?? [])].filter(([path, u]) => hashes.get(path) === u.hash).map(([path, u]) => [path, u.why]))
474
475 // the gist's captions, held while the turn that changes them runs
476 const held = sky.gists.get(sky.root) ?? new Map<string, { what: string; key: string }>()
477
478 sky.weather = weatherOf(map, sky.facts, change.base, change.changes, changeRead, { ...(isEditing ? { turns: ledger.turns } : {}), unasked: flagged, gists: new Map([...held].map(([id, g]) => [id, g.what])) })
479 // each region's change as the gist keys it: its files' contents while editing, the commit once committed
480 const cells = sky.weather.cells
481 const sha = change.base.label.split(' ')[0] ?? ''
482
483 sky.gistKeys = new Map([...new Set(cells.map(c => c.region))].map(id => [id, isEditing ? cells.filter(c => c.region === id).map(c => `${c.path}:${hashes.get(c.path) ?? ''}`).sort().join('|') : `commit:${sha}`]))
484 if (panel === 'auto' && isEditing && !(await read($, openedAtom))) await openPane($)
485 } catch (err) {
486 sky.status = `Isobar could not read the repository: ${err instanceof Error ? err.message : String(err)}`
487 } finally {
488 sky.isBusy = false
489 await update($, tick, n => (n ?? 0) + 1)
490 const again = sky.again
491
492 if (again !== null) {
493 sky.again = null
494 void refresh($, mapModel, panel, again.rebuild, again.turn)
495 } else {
496 const isScopeDue = sky.isScopeDue
497 const regions = staleGists()
498
499 sky.isScopeDue = false
500 if (isScopeDue || regions.length > 0) void readTurn($, mapModel, panel, isScopeDue, regions)
501 }
502 }
503}
504
505/** The regions whose change the gist has not captioned yet; none while a turn runs. */
506function staleGists(): string[] {
507 const root = sky.root
508
509 if (!sky.isGistOn || sky.isTurnRunning || root === null || sky.weather === null) return []
510 const held = sky.gists.get(root)
511
512 return [...sky.gistKeys].filter(([id, key]) => held?.get(id)?.key !== key && !sky.gistAsked.has(`${root}:${id}@${key}`)).map(([id]) => id)
513}
514
515/** After a turn: the gist and the scope check read it side by side, and the pane redraws once with both. */
516async function readTurn($: Dollar, mapModel: string, panel: string, isScopeDue: boolean, regions: readonly string[]) {
517 const [flagged, captioned] = await Promise.all([isScopeDue ? checkScope($) : false, regions.length > 0 ? writeGists($, regions) : false])
518
519 if (flagged || captioned) await refresh($, mapModel, panel)
520}
521
522/**
523 * The gist: a small model reads the diff of each region the change sits in and captions what it
524 * does there in a few words, so the map says what changed as well as where. A caption holds until
525 * its region's change changes.
526 */
527async function writeGists($: Dollar, regions: readonly string[]): Promise<boolean> {
528 const root = sky.root
529 const w = sky.weather
530 const map = sky.map
531
532 if (root === null || w === null || map === null) return false
533 const keys = new Map(regions.map(id => [id, sky.gistKeys.get(id) ?? '']))
534
535 for (const [id, key] of keys) sky.gistAsked.add(`${root}:${id}@${key}`)
536 const cells = w.cells.filter(c => keys.has(c.region))
537
538 try {
539 const range = w.base.kind === 'commit' ? [sky.refs.parent, sky.refs.head] : [sky.refs.head]
540 const excerpts = await excerptOf(runOf($), root, cells.filter(c => !c.isNew).map(c => c.path), range, gistLines(cells.length), cells.filter(c => c.isNew).map(c => c.path))
541 const reply = await $.model.complete({ model: sky.smallModel, prompt: gistPrompt(map, cells, excerpts), maxTokens: 1500, effort: 'low', timeoutMs: 90_000 })
542 const gists = reply.isAnswered ? parseGistReply(reply.text, new Set(keys.keys())) : null
543
544 if (gists === null || gists.size === 0) return false
545 const held = sky.gists.get(root) ?? new Map<string, { what: string; key: string }>()
546
547 for (const [id, what] of gists) held.set(id, { what, key: keys.get(id) ?? '' })
548 sky.gists.set(root, held)
549 return true
550 } catch {
551 // the gist is a caption: when it cannot run, the map stays as it was
552 return false
553 }
554}
555
556/**
557 * The scope check: after a turn that changed files, a small model reads the person's requests and
558 * the turn's diff and names the changes nobody asked for. A flag holds until the file changes again.
559 */
560async function checkScope($: Dollar): Promise<boolean> {
561 const root = sky.root
562 const w = sky.weather
563 const ledger = root === null ? undefined : sky.ledgers.get(root)
564
565 if (root === null || w === null || ledger === undefined) return false
566 const cells = w.cells.filter(c => c.turn === sky.scopeTurn)
567
568 if (cells.length === 0) return false
569 try {
570 const asks = (await $.session.messages()).filter(m => m.role === 'user' && m.text.trim() !== '' && (m.toolResults?.length ?? 0) === 0).map(m => m.text.trim()).slice(-4)
571 const excerpts = await excerptOf(runOf($), root, cells.filter(c => !c.isNew).map(c => c.path), [sky.refs.head], undefined, cells.filter(c => c.isNew).map(c => c.path))
572 const reply = await $.model.complete({ model: sky.smallModel, prompt: scopePrompt(asks, cells, excerpts), maxTokens: 1500, effort: 'low', timeoutMs: 90_000 })
573 const flags = reply.isAnswered ? parseScopeReply(reply.text, new Set(cells.map(c => c.path))) : null
574
575 if (flags === null) return false
576 const kept = sky.unasked.get(root) ?? new Map<string, { why: string; hash: string }>()
577
578 // the turn's files are judged afresh: a flag the model dropped is dropped
579 for (const c of cells) kept.delete(c.path)
580 for (const [path, why] of flags) kept.set(path, { why, hash: ledger.hashes.get(path) ?? '' })
581 sky.unasked.set(root, kept)
582 return true
583 } catch {
584 // the check is advice: when it cannot run, the map stays as it was
585 return false
586 }
587}
588
589/**
590 * The repo's own `.isobar/map.json`, else the one kept from an earlier session; new files placed by
591 * their imports. A shared map is read as untrusted input, clipped as the model's answer is; one
592 * that is no valid map is set aside with a note on the pane.
593 */
594async function keptMap($: Dollar, root: string, facts: Facts): Promise<Basemap | null> {
595 const graph = graphOf(facts.edges)
596 const keep = (map: Basemap): Basemap => ({ ...map, regions: completeRegions(map.regions, map.layers, facts, graph, true) })
597 const path = `${root}/.isobar/map.json`
598 const hasShared = await $.fs.exists(path).catch(() => false)
599 const shared = hasShared ? sanitizeBasemap(await readJson($, path)) : null
600
601 sky.mapNote = hasShared && shared === null ? 'ignoring .isobar/map.json: not a valid map' : undefined
602 if (shared !== null) return keep({ ...shared, source: 'repo-file' })
603 const kept = sanitizeBasemap(await $.store.get(`map:${root}`))
604
605 return kept === null ? null : keep(kept)
606}
607
608/**
609 * The kept map, else a new one drawn once by the model. When the model cannot answer, the map is
610 * drawn from folders for this session only and kept nowhere, so the next session asks again.
611 */
612async function mapFor($: Dollar, root: string, facts: Facts, mapModel: string, rebuild: boolean): Promise<Basemap> {
613 if (!rebuild) {
614 const kept = await keptMap($, root, facts)
615
616 if (kept !== null) return kept
617 }
618
619 sky.status = 'Drawing the map of this codebase. This happens once per repository.'
620 await update($, tick, n => (n ?? 0) + 1)
621 const units = unitsOf(facts)
622 // the map is named by the model the session runs on, unless the mapModel setting names another
623 const model = mapModel !== '' ? mapModel : await $.session.model().catch(() => 'opus')
624 const reply = await $.model.complete({ model, prompt: basemapPrompt(sky.repo, units), maxTokens: 8000, timeoutMs: 240_000 })
625 const named = reply.isAnswered ? parseBasemapReply(reply.text, units) : null
626 const map = finishBasemap(sky.repo, facts, named ?? heuristicRegions(units), named === null ? 'heuristic' : 'model', new Date(await $.clock.now()).toISOString())
627
628 if (named !== null) await $.store.set(`map:${root}`, map)
629 return map
630}
631
632async function readJson($: Dollar, path: string): Promise<unknown> {
633 try {
634 return JSON.parse(await $.fs.read(path))
635 } catch {
636 return null
637 }
638}
639hooks/engine/basemap.ts 425 lines1import { graphOf, type Graph } from './graph'
2import type { Basemap, Facts, Layer, Region } from './types'
3
4export const MAX_REGIONS = 20
5export const MAX_LAYERS = 6
6
7/** The region a path falls in: the longest matching prefix or exact file. */
8export function regionFinder(regions: readonly Region[]): (path: string) => Region | undefined {
9 const rules = regions
10 .flatMap(r => r.paths.map(p => ({ p: p.replace(/^\.\//, ''), r })))
11 .sort((a, b) => b.p.length - a.p.length)
12
13 return path => rules.find(({ p }) => p === '' || path === p || (p.endsWith('/') ? path.startsWith(p) : path.startsWith(`${p}/`)))?.r
14}
15
16/**
17 * The region a file outside every rule belongs to by its folder: the one holding most of the
18 * files in its nearest folder that has any mapped, so a new test joins the tests beside it.
19 */
20export function folderRegion(find: (path: string) => Region | undefined, files: Iterable<string>, path: string): Region | undefined {
21 const known = [...files]
22
23 for (let dir = dirOf(path); dir !== ''; dir = dirOf(dir.slice(0, -1))) {
24 const votes = new Map<Region, number>()
25
26 for (const f of known) {
27 const r = f.startsWith(dir) ? find(f) : undefined
28
29 if (r !== undefined) votes.set(r, (votes.get(r) ?? 0) + 1)
30 }
31 const best = [...votes].sort((a, b) => b[1] - a[1] || a[0].id.localeCompare(b[0].id))[0]?.[0]
32
33 if (best !== undefined) return best
34 }
35 return undefined
36}
37
38/** A unit the model groups into regions: one file, or a folder taken whole. */
39export type Unit = { path: string; files: number; lines: number; imports: string[] }
40
41const dirOf = (p: string) => (p.includes('/') ? p.slice(0, p.lastIndexOf('/') + 1) : '')
42const CODE = /\.(ts|tsx|js|jsx|mjs|cjs|mts|py|vue|svelte|go|rs|java|kt|rb|swift|c|cc|cpp|h)$/
43
44/**
45 * The repo cut into at most ~`budget` units for the model to read: big code folders are
46 * opened to their files, everything else is taken a folder at a time.
47 */
48export function unitsOf(facts: Facts, budget = 160): Unit[] {
49 const files = [...facts.lines.keys()].sort()
50 const total = files.reduce((s, f) => s + (facts.lines.get(f) ?? 0), 0) || 1
51 const graph = graphOf(facts.edges)
52 const units: Unit[] = []
53
54 const visit = (dir: string, members: string[], depth: number) => {
55 const lines = members.reduce((s, f) => s + (facts.lines.get(f) ?? 0), 0)
56 const code = members.filter(f => CODE.test(f)).length
57 const isLeaf = members.length <= 12 || depth >= 3 || lines / total < 0.03 || code / members.length < 0.4
58
59 if (isLeaf && dir !== '') {
60 units.push({ path: dir, files: members.length, lines, imports: [] })
61 return
62 }
63
64 const direct = members.filter(f => dirOf(f) === dir)
65 const sub = new Map<string, string[]>()
66
67 for (const f of members) {
68 if (dirOf(f) === dir) continue
69 const child = dir + f.slice(dir.length).split('/')[0] + '/'
70
71 sub.set(child, [...(sub.get(child) ?? []), f])
72 }
73 for (const f of direct) {
74 const imports = (graph.out.get(f) ?? []).map(t => t.split('/').pop() ?? t).slice(0, 4)
75
76 units.push({ path: f, files: 1, lines: facts.lines.get(f) ?? 0, imports })
77 }
78 for (const [child, list] of [...sub].sort()) visit(child, list, depth + 1)
79 }
80
81 visit('', files, 0)
82
83 // Over budget: merge sibling units into their parent folder, smallest and least code-like first,
84 // so the main source folder keeps its files listed one by one the longest.
85 const parentOf = (u: Unit) => dirOf(u.path.endsWith('/') ? u.path.slice(0, -1) : u.path)
86
87 while (units.length > budget) {
88 const groups = new Map<string, Unit[]>()
89
90 for (const u of units) if (parentOf(u) !== '') groups.set(parentOf(u), [...(groups.get(parentOf(u)) ?? []), u])
91 const score = (list: Unit[]) => list.reduce((s, u) => s + u.lines * (u.files === 1 && CODE.test(u.path) ? 4 : 1), 0)
92 const pick = [...groups].filter(([, l]) => l.length >= 2).sort((a, b) => score(a[1]) - score(b[1]) || a[0].localeCompare(b[0]))[0]
93
94 if (pick === undefined) break
95 const [dir, list] = pick
96
97 for (const u of list) units.splice(units.indexOf(u), 1)
98 units.push({ path: dir, files: list.reduce((s, u) => s + u.files, 0), lines: list.reduce((s, u) => s + u.lines, 0), imports: [] })
99 }
100
101 return units.sort((a, b) => a.path.localeCompare(b.path))
102}
103
104/** The question the model answers to name the basemap. */
105export function basemapPrompt(repo: string, units: readonly Unit[]): string {
106 const rows = units.map(u => `${u.path} (${u.files} file${u.files === 1 ? '' : 's'}, ${u.lines} lines)${u.imports.length ? ` imports: ${u.imports.join(', ')}` : ''}`)
107
108 return [
109 `You are drawing the fixed basemap of the codebase "${repo}": a treemap of its high-level capabilities that an engineer will learn by shape and see on every change.`,
110 '',
111 'Group the units below into at most 20 capability regions, arranged in 3 to 6 layer bands ordered top to bottom from where work enters the system to its foundations, with tests, tooling and docs in the last band.',
112 'Rules:',
113 '- Name each region by what it does for the product (2-3 words, e.g. "Request routing", "Billing", "Search index"), never by a folder name alone. Blurb: 3-6 plain words.',
114 '- Every unit belongs to exactly one region. Copy unit paths into "paths" exactly as listed. Never write a folder that is not itself a listed unit: where a folder\'s files are listed one by one, assign each file by its role, so the main source folder is split across several regions.',
115 `- Balance: no region holds more than a quarter of the ${units.reduce((s, u) => s + u.lines, 0)} lines.`,
116 '- Put units that import each other in the same region or the same band.',
117 '- Each band holds 2 to 5 regions. Layer names are 1-2 words; layer blurbs 3-5 words.',
118 'Answer with JSON only, no prose:',
119 '{"layers":[{"id":"intake","name":"Intake","blurb":"how work enters"}],"regions":[{"id":"request-routing","name":"Request routing","blurb":"matches each request to a handler","layer":"intake","paths":["src/router.ts"]}]}',
120 '',
121 'Units:',
122 ...rows,
123 ].join('\n')
124}
125
126const slug = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || 'region'
127/** A string cut to `n` characters, its control and format characters out (a tab or newline becomes a space), whitespace folded. */
128const clip = (s: unknown, n: number) => (typeof s === 'string' ? s.replace(/\p{C}/gu, c => (/\s/.test(c) ? ' ' : '')).trim().replace(/\s+/g, ' ').slice(0, n) : '')
129
130/** The model's answer as layers and regions, or null when it is not usable. */
131export function parseBasemapReply(text: string, units?: readonly Unit[]): { layers: Layer[]; regions: Region[] } | null {
132 const start = text.indexOf('{')
133 const end = text.lastIndexOf('}')
134
135 if (start < 0 || end <= start) return null
136 let raw: unknown
137
138 try {
139 raw = JSON.parse(text.slice(start, end + 1))
140 } catch {
141 return null
142 }
143 const obj = raw as { layers?: unknown[]; regions?: unknown[] }
144
145 if (!Array.isArray(obj.layers) || !Array.isArray(obj.regions)) return null
146 const layers: Layer[] = obj.layers.slice(0, MAX_LAYERS).map(l => {
147 const o = l as Record<string, unknown>
148
149 return { id: slug(clip(o.id, 40) || clip(o.name, 40)), name: clip(o.name, 22), blurb: clip(o.blurb, 40) }
150 })
151 const ids = new Set(layers.map(l => l.id))
152 // a unit, or a file inside a folder unit: both name real code outright
153 const isUnit = (p: string) => units === undefined || units.some(u => u.path === p || (u.path.endsWith('/') && p.startsWith(u.path) && !p.endsWith('/')))
154 const asked = obj.regions.slice(0, MAX_REGIONS).map(r => {
155 const o = r as Record<string, unknown>
156
157 return { o, named: Array.isArray(o.paths) ? o.paths.filter((p): p is string => typeof p === 'string') : [] }
158 })
159 // A unit named outright belongs to that region; a folder that is not a unit only takes the units nobody named.
160 const claimed = new Set(asked.flatMap(a => a.named.filter(isUnit)))
161 const regions: Region[] = asked.flatMap(({ o, named }) => {
162 const paths = named.flatMap(p => {
163 if (isUnit(p)) return [p]
164 const prefix = p.endsWith('/') ? p : `${p}/`
165
166 return (units ?? []).filter(u => u.path.startsWith(prefix) && !claimed.has(u.path)).map(u => u.path)
167 })
168 const layer = slug(clip(o.layer, 40))
169
170 if (paths.length === 0 || !ids.has(layer)) return []
171 return [{ id: slug(clip(o.id, 40) || clip(o.name, 40)), name: clip(o.name, 24), blurb: clip(o.blurb, 48), layer, paths, weight: 1 }]
172 })
173
174 return regions.length >= 2 ? { layers: layers.filter(l => regions.some(r => r.layer === l.id)), regions } : null
175}
176
177/**
178 * A basemap from outside the model, such as a repository's own `.isobar/map.json`, held to what
179 * the model's answer is held to: ids slugged, names and blurbs clipped, weights 1 to 10, order
180 * kept. A region on no known layer or with no paths is dropped; null when fewer than two remain
181 * or the file is no basemap at all.
182 */
183export function sanitizeBasemap(x: unknown): Basemap | null {
184 const m = x as Record<string, unknown> | null
185
186 if (m === null || typeof m !== 'object' || m.version !== 1 || !Array.isArray(m.layers) || !Array.isArray(m.regions)) return null
187 const fields = (o: unknown): Record<string, unknown> => (o !== null && typeof o === 'object' ? (o as Record<string, unknown>) : {})
188 const layers: Layer[] = m.layers.slice(0, MAX_LAYERS).map(fields).map(l => ({ id: slug(clip(l.id, 40) || clip(l.name, 40)), name: clip(l.name, 22), blurb: clip(l.blurb, 40) }))
189 const ids = new Set(layers.map(l => l.id))
190 // room for "Everything else" past the model's limit
191 const regions: Region[] = m.regions
192 .slice(0, MAX_REGIONS + 1)
193 .map(fields)
194 .map(r => ({
195 id: slug(clip(r.id, 40) || clip(r.name, 40)),
196 name: clip(r.name, 24),
197 blurb: clip(r.blurb, 48),
198 layer: slug(clip(r.layer, 40)),
199 paths: Array.isArray(r.paths) ? r.paths.filter((p): p is string => typeof p === 'string') : [],
200 weight: Math.max(1, Math.min(10, Math.round(Number(r.weight)) || 1)),
201 }))
202 .filter(r => ids.has(r.layer) && r.paths.length > 0)
203
204 if (regions.length < 2) return null
205 const source = m.source === 'model' || m.source === 'heuristic' ? m.source : 'repo-file'
206
207 return { version: 1, repo: clip(m.repo, 80), head: clip(m.head, 64), builtAt: clip(m.builtAt, 40), source, layers, regions }
208}
209
210const title = (s: string) => s.replace(/^\./, '').replace(/[-_]+/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) || 'Root'
211const isDoc = (p: string) => /\.(md|mdx|txt|rst|html)$/.test(p) || /(^|\/)docs?\//.test(p)
212const isTestPath = (p: string) => /(^|\/)(test|tests|__tests__|spec)\b/.test(p) || /\.(test|spec)\./.test(p)
213
214/** A basemap from folders alone, for when no model is reachable. */
215export function heuristicRegions(units: readonly Unit[]): { layers: Layer[]; regions: Region[] } {
216 const total = units.reduce((s, u) => s + u.lines, 0) || 1
217 const top = (p: string) => (p.includes('/') ? p.split('/')[0] + '/' : '')
218 const groups = new Map<string, Unit[]>()
219
220 for (const u of units) groups.set(top(u.path), [...(groups.get(top(u.path)) ?? []), u])
221 for (const [key, list] of [...groups]) {
222 const lines = list.reduce((s, u) => s + u.lines, 0)
223
224 if (key === '' || lines / total < 0.3 || list.length < 3) continue
225 groups.delete(key)
226 for (const u of list) {
227 const rest = u.path.slice(key.length)
228 const sub = rest.includes('/') ? key + rest.split('/')[0] + '/' : key
229
230 groups.set(sub, [...(groups.get(sub) ?? []), u])
231 }
232 }
233
234 let ranked = [...groups].sort((a, b) => b[1].reduce((s, u) => s + u.lines, 0) - a[1].reduce((s, u) => s + u.lines, 0))
235
236 if (ranked.length > MAX_REGIONS) {
237 const rest = ranked.slice(MAX_REGIONS - 1).flatMap(([, l]) => l)
238
239 ranked = [...ranked.slice(0, MAX_REGIONS - 1), ['*', rest]]
240 }
241
242 const kindOf = (list: Unit[]) => {
243 if (list.every(u => isTestPath(u.path))) return 'checks'
244 if (list.filter(u => isDoc(u.path)).length > list.length / 2) return 'docs'
245 if (list.some(u => CODE.test(u.path) || u.files > 1)) return 'source'
246 return 'tooling'
247 }
248 const regions: Region[] = ranked.map(([key, list]) => {
249 const name = key === '*' ? 'Everything else' : key === '' ? 'Root files' : title(key.split('/').filter(Boolean).pop() ?? key)
250
251 return { id: slug(key === '*' ? 'everything-else' : key || 'root'), name, blurb: `${list.length} parts`, layer: kindOf(list), paths: list.map(u => u.path), weight: 1 }
252 })
253 const layers: Layer[] = [
254 { id: 'source', name: 'Source', blurb: 'the running code' },
255 { id: 'tooling', name: 'Tooling', blurb: 'config and scripts' },
256 { id: 'checks', name: 'Checks', blurb: 'tests that hold it' },
257 { id: 'docs', name: 'Docs', blurb: 'what is written down' },
258 ].filter(l => regions.some(r => r.layer === l.id))
259
260 return { layers, regions }
261}
262
263/**
264 * Gives every tracked file a region: by its imports' regions, else its folder's. A map being drawn
265 * gathers what is left in "Everything else"; a kept map never grows a region, so the frame stays
266 * as learned, and a new file at the root joins the region holding most of the root's other files.
267 */
268export function completeRegions(regions: Region[], layers: Layer[], facts: Facts, graph: Graph, isKept = false): Region[] {
269 const find = regionFinder(regions)
270 const out = regions.map(r => ({ ...r, paths: [...r.paths] }))
271 const byId = new Map(out.map(r => [r.id, r]))
272 const orphans = [...facts.lines.keys()].filter(f => find(f) === undefined).sort()
273
274 for (const file of orphans) {
275 const votes = new Map<string, number>()
276
277 for (const n of [...(graph.out.get(file) ?? []), ...(graph.in.get(file) ?? [])]) {
278 const r = find(n)
279
280 if (r !== undefined) votes.set(r.id, (votes.get(r.id) ?? 0) + 1)
281 }
282 let target: string | undefined = [...votes].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0]?.[0]
283
284 target ??= folderRegion(find, facts.lines.keys(), file)?.id
285 if (target === undefined && isKept) {
286 const roots = new Map<string, number>()
287
288 for (const f of facts.lines.keys()) {
289 const r = f.includes('/') || f === file ? undefined : find(f)
290
291 if (r !== undefined) roots.set(r.id, (roots.get(r.id) ?? 0) + 1)
292 }
293 const heaviest = [...out].filter(r => r.layer === layers[layers.length - 1]?.id).sort((a, b) => b.weight - a.weight || a.id.localeCompare(b.id))[0]
294
295 target = [...roots].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0]?.[0] ?? heaviest?.id ?? out[0]?.id
296 }
297 if (target === undefined) {
298 if (!byId.has('everything-else')) {
299 const last = layers[layers.length - 1]?.id ?? out[0]?.layer ?? 'source'
300 const extra: Region = { id: 'everything-else', name: 'Everything else', blurb: 'files outside the map', layer: last, paths: [], weight: 1 }
301
302 out.push(extra)
303 byId.set(extra.id, extra)
304 }
305 target = 'everything-else'
306 }
307 byId.get(target)?.paths.push(file)
308 }
309
310 return out
311}
312
313/** Static consequence weight, 1 to 10: lines of code, times how much outside the region depends on it. */
314export function weigh(regions: readonly Region[], facts: Facts, graph: Graph): Region[] {
315 const find = regionFinder(regions)
316 const raw = regions.map(region => {
317 const members = [...facts.lines.keys()].filter(f => find(f) === region)
318 const lines = members.reduce((s, f) => s + (facts.lines.get(f) ?? 0), 0)
319 const outside = new Set<string>()
320
321 for (const m of members) for (const imp of graph.in.get(m) ?? []) if (find(imp) !== region) outside.add(imp)
322 return Math.sqrt(lines) * (1 + Math.log(1 + outside.size))
323 })
324 const max = Math.max(1, ...raw)
325
326 return regions.map((r, i) => ({ ...r, weight: Math.max(1, Math.min(10, Math.round(1 + 9 * Math.pow((raw[i] ?? 0) / max, 0.8)))) }))
327}
328
329/** How strongly two regions are tied: imports between them, plus files that change together. */
330export function couplingOf(regions: readonly Region[], facts: Facts): (a: string, b: string) => number {
331 const find = regionFinder(regions)
332 const tie = new Map<string, number>()
333 const key = (a: string, b: string) => (a < b ? `${a}|${b}` : `${b}|${a}`)
334 const bump = (a: string | undefined, b: string | undefined, by: number) => {
335 if (a === undefined || b === undefined || a === b) return
336 tie.set(key(a, b), (tie.get(key(a, b)) ?? 0) + by)
337 }
338
339 for (const { from, to } of facts.edges) bump(find(from)?.id, find(to)?.id, 1)
340 for (const commit of facts.commits) {
341 const ids = [...new Set(commit.map(f => find(f)?.id).filter((x): x is string => x !== undefined))]
342
343 for (let i = 0; i < ids.length; i++) for (let j = i + 1; j < ids.length; j++) bump(ids[i], ids[j], 1 / ids.length)
344 }
345
346 return (a, b) => tie.get(key(a, b)) ?? 0
347}
348
349/**
350 * Orders cells so distance means coupling: the first band is chained greedily by its
351 * strongest ties, and each later band sits under the cells it is most tied to.
352 */
353export function arrange(layers: readonly Layer[], regions: readonly Region[], tie: (a: string, b: string) => number): Region[] {
354 const placed: { r: Region; x: number }[] = []
355 const out: Region[] = []
356
357 for (const layer of layers) {
358 const band = regions.filter(r => r.layer === layer.id).sort((a, b) => b.weight - a.weight || a.id.localeCompare(b.id))
359 let ordered: Region[]
360
361 if (placed.length === 0) {
362 ordered = band.length ? [band[0] as Region] : []
363 const left = band.slice(1)
364
365 while (left.length > 0) {
366 const ends = [ordered[0] as Region, ordered[ordered.length - 1] as Region]
367 let pick = 0
368 let side = 1
369 let best = -1
370
371 left.forEach((r, i) => ends.forEach((e, s) => {
372 const t = tie(r.id, e.id)
373
374 if (t > best) [best, pick, side] = [t, i, s]
375 }))
376 const [r] = left.splice(pick, 1)
377
378 if (r !== undefined) side === 0 ? ordered.unshift(r) : ordered.push(r)
379 }
380 } else {
381 const center = (r: Region, i: number) => {
382 let sum = 0
383 let mass = 0
384
385 for (const p of placed) {
386 const t = tie(r.id, p.r.id)
387
388 sum += t * p.x
389 mass += t
390 }
391 return mass > 0 ? sum / mass : (i + 0.5) / band.length
392 }
393 ordered = band.map((r, i) => ({ r, c: center(r, i) })).sort((a, b) => a.c - b.c || a.r.id.localeCompare(b.r.id)).map(o => o.r)
394 }
395
396 const total = ordered.reduce((s, r) => s + r.weight, 0) || 1
397 let x = 0
398
399 for (const r of ordered) {
400 placed.push({ r, x: (x + r.weight / 2) / total })
401 x += r.weight
402 out.push(r)
403 }
404 }
405
406 return out
407}
408
409/** The finished basemap: every file placed, weighed, and arranged so neighbours are tied. */
410export function finishBasemap(
411 repo: string,
412 facts: Facts,
413 named: { layers: Layer[]; regions: Region[] },
414 source: Basemap['source'],
415 builtAt: string,
416): Basemap {
417 const graph = graphOf(facts.edges)
418 const complete = completeRegions(named.regions, named.layers, facts, graph)
419 const layers = named.layers.filter(l => complete.some(r => r.layer === l.id))
420 const weighed = weigh(complete, facts, graph)
421 const regions = arrange(layers, weighed, couplingOf(weighed, facts))
422
423 return { version: 1, repo, head: facts.head, builtAt, source, layers, regions }
424}
425hooks/engine/git.ts 201 lines1import { edgesOf, IMPORTABLE, joinedPython, jsRulesOf, type Row } from './imports'
2import type { Base, Change, Facts, Run } from './types'
3
4const JS_PATHSPEC = ['*.ts', '*.tsx', '*.js', '*.jsx', '*.mjs', '*.cjs', '*.mts', '*.cts', '*.vue', '*.svelte']
5const JS_PATTERN = `from[[:space:]]*['"]|require\\([[:space:]]*['"]|import[[:space:]]*\\(?[[:space:]]*['"]`
6const PY_PATTERN = '^[[:space:]]*(from[[:space:]]+[.A-Za-z_]|import[[:space:]]+[A-Za-z_])'
7/** A line that may continue a Python import over several lines: names, each maybe `as` another, ending in `,` or `)`. */
8const PY_NAMES = '^[[:space:]]*[A-Za-z_][A-Za-z0-9_]*([[:space:]]+as[[:space:]]+[A-Za-z_][A-Za-z0-9_]*)?([[:space:]]*,[[:space:]]*[A-Za-z_][A-Za-z0-9_]*([[:space:]]+as[[:space:]]+[A-Za-z_][A-Za-z0-9_]*)?)*[[:space:]]*,?[[:space:]]*[,)][[:space:]]*(#.*)?$'
9/** The files a bare JS/TS specifier resolves through, filtered by name in `jsRulesOf`. */
10const CONFIG_PATHSPEC = ['*tsconfig*.json', '*jsconfig*.json', '*package.json', '*pnpm-workspace.yaml']
11/** Git's id for an empty file, in SHA-1 and in SHA-256 repositories. */
12const EMPTY_BLOB = new Set(['e69de29bb2d1d6434b8b29ae775ad8c2e48c5391', '473a0f4c3be8a93681a267e3b1e9a7dcda1185436fe141f7749120a303721813'])
13
14/** Commits touching more files than this are bulk moves and say nothing about coupling. */
15export const BULK_COMMIT = 40
16
17/**
18 * Git in `root`, read-only. A porcelain `git diff` refreshes the index when a tracked file is
19 * stat-dirty, which writes `.git/index` and takes its lock; `diff.autoRefreshIndex=false` stops it.
20 */
21export const GIT_READ_ONLY = ['-c', 'diff.autoRefreshIndex=false'] as const
22
23const git = (run: Run, root: string, ...args: string[]) => run(['git', '-C', root, ...GIT_READ_ONLY, ...args])
24
25export async function repoRoot(run: Run, cwd: string): Promise<string | null> {
26 const r = await run(['git', '-C', cwd, 'rev-parse', '--show-toplevel'])
27
28 return r.exitCode === 0 ? r.stdout.trim() : null
29}
30
31/** `path\0count\n` rows of `git grep -z -c`. */
32export function parseCounts(stdout: string): Map<string, number> {
33 const out = new Map<string, number>()
34
35 for (const row of stdout.split('\n')) {
36 const at = row.indexOf('\0')
37
38 if (at > 0) out.set(row.slice(0, at), Number(row.slice(at + 1)) || 0)
39 }
40
41 return out
42}
43
44/** Commits separated by \x1e, one file per line; bulk commits dropped. */
45export function parseLog(stdout: string): string[][] {
46 return stdout
47 .split('\x1e')
48 .map(block => block.split('\n').map(s => s.trim()).filter(Boolean))
49 .filter(files => files.length > 0 && files.length <= BULK_COMMIT)
50}
51
52/** `path\0line\0text\n` rows of `git grep -z -n`, line numbers kept. */
53export function parseRows(stdout: string): Row[] {
54 const out: Row[] = []
55
56 for (const row of stdout.split('\n')) {
57 const a = row.indexOf('\0')
58 const b = a < 0 ? -1 : row.indexOf('\0', a + 1)
59
60 if (b > 0) out.push({ path: row.slice(0, a), line: Number(row.slice(a + 1, b)), text: row.slice(b + 1) })
61 }
62 return out
63}
64
65/** Empty tracked files an import can name, from `git ls-files -z -s` rows; `git grep -c` never lists them. */
66export function parseEmpty(stdout: string): string[] {
67 const out: string[] = []
68
69 for (const row of stdout.split('\0')) {
70 const m = /^\d+ ([0-9a-f]+) \d\t(.+)$/s.exec(row)
71
72 if (m !== null && EMPTY_BLOB.has(m[1] ?? '') && IMPORTABLE.some(e => m[2]?.endsWith(e))) out.push(m[2] ?? '')
73 }
74 return out
75}
76
77/**
78 * Every import line of the repo, a Python import over several lines joined into its first,
79 * and the text of each tracked tsconfig, jsconfig, package.json and pnpm-workspace.yaml, in three git calls.
80 */
81export async function importSources(run: Run, root: string): Promise<{ hits: Row[]; configs: Map<string, string> }> {
82 const [js, py, configs] = await Promise.all([
83 git(run, root, 'grep', '-z', '-n', '-I', '-E', '-e', JS_PATTERN, '--', ...JS_PATHSPEC),
84 git(run, root, 'grep', '-z', '-n', '-I', '-E', '-e', `${PY_PATTERN}|${PY_NAMES}`, '--', '*.py'),
85 git(run, root, 'grep', '-z', '-I', '-e', '', '--', ...CONFIG_PATHSPEC),
86 ])
87 const texts = new Map<string, string[]>()
88
89 for (const row of configs.stdout.split('\n')) {
90 const at = row.indexOf('\0')
91
92 if (at < 1) continue
93 const path = row.slice(0, at)
94 const lines = texts.get(path) ?? []
95
96 lines.push(row.slice(at + 1))
97 texts.set(path, lines)
98 }
99 return {
100 hits: [...parseRows(js.stdout), ...joinedPython(parseRows(py.stdout))],
101 configs: new Map([...texts].map(([path, lines]) => [path, lines.join('\n')])),
102 }
103}
104
105/** Every fact the basemap and the weather read, in seven git calls. */
106export async function gatherFacts(run: Run, root: string, commits = 400): Promise<Facts> {
107 const [head, counts, index, sources, log] = await Promise.all([
108 git(run, root, 'rev-parse', 'HEAD'),
109 git(run, root, 'grep', '-z', '-c', '-I', '-e', ''),
110 git(run, root, 'ls-files', '-z', '-s'),
111 importSources(run, root),
112 // unquoted, so a path with non-ASCII bytes matches its key in `lines`
113 git(run, root, '-c', 'core.quotePath=false', 'log', '-n', String(commits), '--no-merges', '--name-only', '--format=%x1e'),
114 ])
115 const counted = parseCounts(counts.stdout)
116 const empty = parseEmpty(index.stdout).filter(p => !counted.has(p))
117 // kept in git's path order, as `git grep` lists them
118 const lines = empty.length === 0 ? counted : new Map([...counted, ...empty.map(p => [p, 0] as const)].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
119 const files = new Set(lines.keys())
120 const edges = edgesOf(sources.hits, files, jsRulesOf(sources.configs))
121
122 return { root, head: head.stdout.trim(), lines, edges, commits: parseLog(log.stdout) }
123}
124
125/** `git diff --numstat -z` rows: `a\td\tpath\0`, or `a\td\t\0old\0new\0` for a rename. */
126export function parseNumstat(stdout: string): Change[] {
127 const parts = stdout.split('\0')
128 const out: Change[] = []
129
130 for (let i = 0; i < parts.length; i++) {
131 const m = /^\n*(-|\d+)\t(-|\d+)\t(.*)$/.exec(parts[i] ?? '')
132
133 if (m === null) continue
134 let path = m[3] ?? ''
135
136 if (path === '') {
137 path = parts[i + 2] ?? ''
138 i += 2
139 }
140 if (path !== '') out.push({ path, added: Number(m[1]) || 0, deleted: Number(m[2]) || 0, isNew: false, isDeleted: false })
141 }
142
143 return out
144}
145
146/**
147 * What a change is measured from: `head` for the uncommitted edits and `parent` for the last
148 * commit. A repository with no commits yet has neither, and its first commit has no parent:
149 * those are the empty tree, so every file in them reads as added.
150 */
151export type Refs = { head: string; parent: string; isBorn: boolean }
152
153/** HEAD and its parent, with the empty tree standing in for either where it is missing. */
154export async function refsOf(run: Run, root: string): Promise<Refs> {
155 const [head, parent] = await Promise.all([git(run, root, 'rev-parse', '--verify', '-q', 'HEAD'), git(run, root, 'rev-parse', '--verify', '-q', 'HEAD~1')])
156
157 if (head.exitCode === 0 && parent.exitCode === 0) return { head: 'HEAD', parent: 'HEAD~1', isBorn: true }
158 // the empty tree's id, in SHA-1 or SHA-256 as the repository names objects; hashed, never stored
159 const empty = (await git(run, root, 'hash-object', '-t', 'tree', '/dev/null')).stdout.trim()
160
161 return { head: head.exitCode === 0 ? 'HEAD' : empty, parent: empty, isBorn: head.exitCode === 0 }
162}
163
164/**
165 * The change the weather shows: uncommitted edits to tracked files plus the untracked files
166 * this session made, against `refs.head`. An untracked file counts when `created` names it (what
167 * the session's Write tool wrote) or when `before` (the untracked files when the session first
168 * looked) lacks it. With none, the last commit; with no commit either, nothing.
169 */
170export async function currentChange(
171 run: Run,
172 root: string,
173 created: readonly string[] = [],
174 { refs = { head: 'HEAD', parent: 'HEAD~1', isBorn: true }, before }: { refs?: Refs; before?: ReadonlySet<string> } = {},
175): Promise<{ base: Base; changes: Change[]; untracked: string[] }> {
176 const [diff, deleted, listed] = await Promise.all([
177 // a submodule with local edits but an unmoved pointer is no change of this repository's
178 git(run, root, 'diff', '--numstat', '-z', '--ignore-submodules=dirty', refs.head),
179 git(run, root, 'diff', '--name-only', '-z', '--diff-filter=D', refs.head),
180 git(run, root, 'ls-files', '-z', '--others', '--exclude-standard'),
181 ])
182 const gone = new Set(deleted.stdout.split('\0').filter(Boolean))
183 const changes = parseNumstat(diff.stdout).map(c => ({ ...c, isDeleted: gone.has(c.path) }))
184 const wanted = new Set(created)
185 const untracked = listed.stdout.split('\0').filter(p => p !== '')
186 const fresh = untracked.filter(p => wanted.has(p) || (before !== undefined && !before.has(p)))
187
188 if (fresh.length > 0) {
189 const counted = parseCounts((await git(run, root, 'grep', '-z', '-c', '-I', '--untracked', '-e', '', '--', ...fresh)).stdout)
190
191 for (const path of fresh) changes.push({ path, added: counted.get(path) ?? 0, deleted: 0, isNew: true, isDeleted: false })
192 }
193 if (changes.length > 0) return { base: { kind: 'uncommitted', label: 'uncommitted' }, changes, untracked }
194 if (!refs.isBorn) return { base: { kind: 'none', label: 'no commits yet' }, changes: [], untracked }
195
196 const last = await git(run, root, 'show', '--numstat', '-z', '--format=%h %s', 'HEAD')
197 const subject = last.stdout.split(/[\0\n]/)[0] ?? ''
198
199 return { base: { kind: 'commit', label: subject.trim() }, changes: parseNumstat(last.stdout), untracked }
200}
201hooks/engine/gist.ts 68 lines1import type { Cell } from './weather'
2import type { Basemap } from './types'
3
4/** The most diff lines the gist sends the model in all, shared across the files. */
5const ALL_LINES = 400
6/** The fewest diff lines one file may take while the whole lasts; files past it get none. */
7const FILE_FLOOR = 12
8/** The most characters of a caption: the question asks for 24, and a longer answer is cut. */
9const WHAT_CHARS = 40
10
11/** Each file's share of the diff the gist sends: an even split of the whole, at least the floor while it lasts. */
12export const gistLines = (files: number) => ({ file: Math.max(FILE_FLOOR, Math.floor(ALL_LINES / Math.max(1, files))), all: ALL_LINES })
13
14/**
15 * The gist's question: each region the change sits in, with its files, the declarations they
16 * touched and their diff. It asks what the change does there, in a few words a person reads at a
17 * glance. The requests are left out on purpose: the gist is the diff's own account, so a person
18 * can set it against what they meant.
19 */
20export function gistPrompt(map: Basemap, cells: readonly Cell[], excerpts: ReadonlyMap<string, string>): string {
21 const regions = [...new Set(cells.map(c => c.region))].map(id => {
22 const r = map.regions.find(x => x.id === id)
23 const files = cells.filter(c => c.region === id).map(c => {
24 const touched = c.touches.filter(t => t.kind !== 'comments').map(t => `${t.kind} ${t.owner === undefined ? '' : `${t.owner}.`}${t.name}`)
25 const head = `${c.path} (${c.isNew ? 'new file' : c.isDeleted ? 'deleted' : `+${c.added} −${c.deleted}`}${touched.length > 0 ? `; ${touched.join(', ')}` : ''})`
26 const diff = excerpts.get(c.path)
27
28 return diff === undefined || diff === '' ? head : `${head}\n\`\`\`diff\n${diff}\n\`\`\``
29 })
30
31 return `<region id="${id}" name="${r?.name ?? id}">\n${r?.blurb ?? ''}\n\n${files.join('\n\n')}\n</region>`
32 })
33
34 return [
35 'You write the captions on a map of a codebase. Each region below is one part of the product, and a coding agent has just changed files in it.',
36 '',
37 regions.join('\n\n'),
38 '',
39 'For each region, caption what the change does there in 2 to 4 lowercase words, at most 24 characters, read from the diff alone: what it adds, fixes or changes for someone using or maintaining that part, such as "retries failed uploads", "documents the new flag", "changelog entry" or "covers the empty cart". Say what it does, never how and name the specific thing that changed (never "implements", "updates", "adds logic for" or "refactors code"); for docs, a changelog or tests, say what it records or covers. Never repeat the region\'s name or a file name, and end without a period.',
40 '',
41 'Answer with JSON only: {"regions": [{"id": "<the id as given>", "what": "<the caption>"}]}.',
42 ].join('\n')
43}
44
45/** Each region's caption from the model's reply, kept to the regions it was shown; null when the reply is not the JSON asked for. */
46export function parseGistReply(text: string, shown: ReadonlySet<string>): Map<string, string> | null {
47 const json = /\{[\s\S]*\}/.exec(text)?.[0]
48
49 if (json === undefined) return null
50 try {
51 const reply = JSON.parse(json) as { regions?: unknown }
52
53 if (!Array.isArray(reply.regions)) return null
54 const out = new Map<string, string>()
55
56 for (const row of reply.regions as { id?: unknown; what?: unknown }[]) {
57 if (typeof row?.id !== 'string' || !shown.has(row.id) || typeof row.what !== 'string') continue
58 // a caption reads in lowercase on the map; an acronym or a quoted name keeps its capitals
59 const what = row.what.trim().replace(/^["']|["']$/g, '').replace(/\.$/, '').replace(/\s+/g, ' ').slice(0, WHAT_CHARS).trim()
60
61 if (what !== '') out.set(row.id, /^[A-Z][a-z]/.test(what) ? what[0]!.toLowerCase() + what.slice(1) : what)
62 }
63 return out
64 } catch {
65 return null
66 }
67}
68hooks/engine/graph.ts 122 lines1import type { Edge } from './types'
2
3export type Graph = {
4 /** file → the files it imports */
5 out: Map<string, string[]>
6 /** file → the files that import it */
7 in: Map<string, string[]>
8}
9
10export function graphOf(edges: readonly Edge[]): Graph {
11 const out = new Map<string, string[]>()
12 const into = new Map<string, string[]>()
13 const push = (m: Map<string, string[]>, k: string, v: string) => {
14 const list = m.get(k)
15
16 if (list === undefined) m.set(k, [v])
17 else list.push(v)
18 }
19
20 for (const { from, to } of edges) {
21 push(out, from, to)
22 push(into, to, from)
23 }
24 for (const list of [...out.values(), ...into.values()]) list.sort()
25
26 return { out, in: into }
27}
28
29const TEST_PATH = /(^|\/)(test|tests|__tests__|spec|specs)\/|\.(test|spec)\.[cm]?[jt]sx?$|(^|\/)test_[^/]+\.py$|_test\.py$/
30
31export const isTest = (path: string): boolean => TEST_PATH.test(path)
32
33/** One file the change reaches: `hop` imports away, through `via` (the file it imports). */
34export type Reach = { path: string; hop: number; via: string }
35
36/** Breadth-first over importers from the changed files, up to `maxHops`, nearest hop first. */
37export function reachOf(graph: Graph, changed: readonly string[], maxHops = 3): Reach[] {
38 const seen = new Set(changed)
39 const out: Reach[] = []
40 let frontier = [...changed].sort()
41
42 for (let hop = 1; hop <= maxHops && frontier.length > 0; hop++) {
43 const next: string[] = []
44
45 for (const file of frontier) {
46 for (const importer of graph.in.get(file) ?? []) {
47 if (seen.has(importer)) continue
48 seen.add(importer)
49 out.push({ path: importer, hop, via: file })
50 next.push(importer)
51 }
52 }
53 frontier = next.sort()
54 }
55
56 return out
57}
58
59/** How many files depend on `path`, at any distance. */
60export function dependentsOf(graph: Graph, path: string, cap = 10_000): number {
61 return reachOf(graph, [path], cap).length
62}
63
64/** The chain of files from a reached file back to the changed file it came from. */
65export function chainOf(reach: readonly Reach[], path: string): string[] {
66 const by = new Map(reach.map(r => [r.path, r]))
67 const chain = [path]
68 let at = by.get(path)
69
70 while (at !== undefined && chain.length < 12) {
71 chain.push(at.via)
72 at = by.get(at.via)
73 }
74
75 return chain.reverse()
76}
77
78/** A file history says changes with this change, left out of it: `lift` times as often as it changes at all. */
79export type Expected = { path: string; with: string; together: number; of: number; lift: number }
80
81/**
82 * Absence of expected change: files that changed in at least `minShare` of the commits
83 * touching a changed file (and at least `minTogether` times) but sit outside this change.
84 * `minLift` keeps only files that change with it at least that many times more often than
85 * they change overall, so a file that changes in every commit (a changelog, a lockfile, the
86 * app's root) never rings.
87 */
88export function expectedOf(
89 commits: readonly string[][],
90 changed: readonly string[],
91 exists: (path: string) => boolean,
92 minShare = 0.5,
93 minTogether = 3,
94 minLift = 1,
95): Expected[] {
96 const inChange = new Set(changed)
97 const best = new Map<string, Expected>()
98 const everywhere = new Map<string, number>()
99
100 for (const commit of commits) for (const file of commit) everywhere.set(file, (everywhere.get(file) ?? 0) + 1)
101 for (const file of changed) {
102 const touching = commits.filter(c => c.includes(file))
103
104 if (touching.length < minTogether) continue
105 const counts = new Map<string, number>()
106
107 for (const commit of touching) for (const other of commit) if (other !== file) counts.set(other, (counts.get(other) ?? 0) + 1)
108 for (const [other, together] of counts) {
109 const lift = together / touching.length / ((everywhere.get(other) ?? together) / commits.length)
110
111 if (inChange.has(other) || together < minTogether || together / touching.length < minShare || lift < minLift || !exists(other)) continue
112 const prior = best.get(other)
113
114 if (prior === undefined || together / touching.length > prior.together / prior.of) {
115 best.set(other, { path: other, with: file, together, of: touching.length, lift })
116 }
117 }
118 }
119
120 return [...best.values()].sort((a, b) => b.together / b.of - a.together / a.of || b.lift - a.lift || a.path.localeCompare(b.path))
121}
122hooks/engine/scope.ts 135 lines1import { GIT_READ_ONLY } from './git'
2import { headerPath } from './symbols'
3import type { Cell } from './weather'
4import type { Run } from './types'
5
6/** The most diff lines one file, and the whole check, sends the model. */
7const FILE_LINES = 60
8const ALL_LINES = 400
9/** The most characters of one request the check quotes. */
10const ASK_CHARS = 1500
11/** The most characters of what the check says of a flagged file. */
12const WHY_CHARS = 40
13
14/**
15 * The diff of `paths` over `range` (the working tree against HEAD by default) with one line of
16 * context, cut per file and overall; a file past the cut says so. `fresh` are untracked files,
17 * which no commit has: each one's diff is from nothing, its content, inside the same cut.
18 */
19export async function excerptOf(
20 run: Run,
21 root: string,
22 paths: readonly string[],
23 range: readonly string[] = ['HEAD'],
24 limits = { file: FILE_LINES, all: ALL_LINES },
25 fresh: readonly string[] = [],
26): Promise<Map<string, string>> {
27 const out = new Map<string, string>()
28 const left = { lines: limits.all }
29 const diff = (...args: string[]) => run(['git', '-C', root, ...GIT_READ_ONLY, '-c', 'core.quotePath=false', 'diff', '-U1', '--no-color', '--no-ext-diff', ...args])
30
31 if (paths.length > 0) cutDiff((await diff(...range, '--', ...paths)).stdout, limits.file, left, out)
32 for (const path of fresh) {
33 if (left.lines <= 0) break
34 // exit 1 means the two sides differ, which they always do here
35 cutDiff((await diff('--no-index', '--', '/dev/null', path)).stdout, limits.file, left, out)
36 }
37 return out
38}
39
40/**
41 * Each file's hunk lines from a `git diff`, at most `file` of them and `left.lines` in all.
42 * Headers are read only between `diff --git` and the first `@@`: the `+++` path, or the `---`
43 * one for a deleted file.
44 */
45function cutDiff(stdout: string, file: number, left: { lines: number }, out: Map<string, string>) {
46 let path = ''
47 let from = ''
48 let lines: string[] = []
49 let isHeader = false
50 const flush = () => {
51 if (path === '') return
52 const kept = lines.slice(0, Math.min(file, Math.max(0, left.lines)))
53
54 left.lines -= kept.length
55 out.set(path, kept.length < lines.length ? [...kept, `… ${lines.length - kept.length} more lines`].join('\n') : kept.join('\n'))
56 }
57
58 for (const line of stdout.split('\n')) {
59 if (line.startsWith('diff --git ')) {
60 flush()
61 path = ''
62 from = ''
63 lines = []
64 isHeader = true
65 } else if (isHeader && line.startsWith('--- ')) from = headerPath(line)
66 else if (isHeader && line.startsWith('+++ ')) {
67 const to = headerPath(line)
68
69 path = to === '/dev/null' ? from : to
70 } else if (isHeader && line.startsWith('@@')) {
71 isHeader = false
72 if (path !== '') lines.push(line)
73 } else if (!isHeader && path !== '' && /^[-+ @]/.test(line)) lines.push(line)
74 }
75 flush()
76}
77
78/**
79 * The scope check's question: the person's requests, then each file the latest turn changed with
80 * the declarations it touched and its diff. It asks which changes no request called for.
81 */
82export function scopePrompt(asks: readonly string[], cells: readonly Cell[], excerpts: ReadonlyMap<string, string>): string {
83 const requests = asks.map((a, i) => `${i + 1}. ${a.length > ASK_CHARS ? `${a.slice(0, ASK_CHARS)}…` : a}`).join('\n')
84 const files = cells.map(c => {
85 const touched = c.touches.filter(t => t.kind !== 'comments').map(t => `${t.kind} ${t.owner === undefined ? '' : `${t.owner}.`}${t.name}`)
86 const head = `${c.path} (${c.isNew ? 'new file' : c.isDeleted ? 'deleted' : `+${c.added} −${c.deleted}`}${touched.length > 0 ? `; ${touched.join(', ')}` : ''})`
87 const diff = excerpts.get(c.path)
88
89 return diff === undefined || diff === '' ? head : `${head}\n\`\`\`diff\n${diff}\n\`\`\``
90 })
91
92 return [
93 "You check whether a coding agent's edits stayed inside what the person asked for.",
94 '',
95 'The person\'s requests this session, oldest first; the last one started this turn:',
96 '<requests>',
97 requests || '(none written; the turn continued earlier work)',
98 '</requests>',
99 '',
100 'The files the agent changed this turn, each with the declarations it touched and its diff:',
101 '<changes>',
102 files.join('\n\n'),
103 '</changes>',
104 '',
105 'For each file, decide whether a request called for that change, directly or as a step the request plainly needs (a test for it, a type it must widen, a caller it must update, an import it uses). Flag a file only when nothing the person said calls for it: an unrelated refactor or reformat, a fix or feature nobody mentioned, a dependency or config change nobody mentioned. When unsure, it was asked for.',
106 '',
107 'Answer with JSON only: {"unasked": [{"path": "<the path as given>", "why": "<what the change does, 2 to 4 lowercase words, such as changelog entry or renamed a helper>"}]}, with an empty list when every change was asked for.',
108 ].join('\n')
109}
110
111/** The flagged files from the model's reply, kept to the paths it was shown; null when the reply is not the JSON asked for. */
112export function parseScopeReply(text: string, shown: ReadonlySet<string>): Map<string, string> | null {
113 const json = /\{[\s\S]*\}/.exec(text)?.[0]
114
115 if (json === undefined) return null
116 try {
117 const reply = JSON.parse(json) as { unasked?: unknown }
118
119 if (!Array.isArray(reply.unasked)) return null
120 const out = new Map<string, string>()
121
122 for (const row of reply.unasked as { path?: unknown; why?: unknown }[]) {
123 if (typeof row?.path !== 'string' || !shown.has(row.path)) continue
124 // the badge already says nobody asked, so a trailing "not requested" is cut
125 const why = typeof row.why === 'string' ? row.why.trim().replace(/\.$/, '').replace(/[\s,;:-]+(?:was\s+|is\s+)?(?:not|never)\s+(?:requested|asked(?:\s+for)?|mentioned)$/i, '').slice(0, WHY_CHARS).trim() : ''
126
127 // a note reads in lowercase after "unasked:"; an acronym keeps its capitals
128 out.set(row.path, why === '' ? 'not in the request' : /^[A-Z][a-z]/.test(why) ? why[0]!.toLowerCase() + why.slice(1) : why)
129 }
130 return out
131 } catch {
132 return null
133 }
134}
135hooks/engine/session.ts 60 lines1import type { Change, Run } from './types'
2
3/** What the session has seen of one repository: each changed file's last content, and the turn that wrote it. */
4export type Ledger = { hashes: Map<string, string>; turns: Map<string, number> }
5
6/** The hash of a changed path git cannot hash: a submodule, a symlink to a folder or to nothing, a file gone since the diff. */
7export const UNHASHABLE = 'unhashable'
8
9/**
10 * Each changed file's content hash, read-only (`git hash-object` without `-w`); a deleted file
11 * hashes as `deleted`, and a path git cannot hash as UNHASHABLE.
12 */
13export async function hashesOf(run: Run, root: string, changes: readonly Change[]): Promise<Map<string, string>> {
14 const present = changes.filter(c => !c.isDeleted).map(c => c.path)
15 const out = new Map(changes.filter(c => c.isDeleted).map(c => [c.path, 'deleted']))
16 const hash = (paths: readonly string[]) => run(['git', '-C', root, 'hash-object', '--', ...paths])
17
18 if (present.length === 0) return out
19 const r = await hash(present)
20 const hashes = r.stdout.split('\n').filter(Boolean)
21
22 // one hash a line, in order
23 if (r.exitCode === 0 && hashes.length === present.length) {
24 present.forEach((p, i) => out.set(p, hashes[i]!))
25 return out
26 }
27 // one path git cannot hash fails the whole call: hashed one by one, it costs only its own hash
28 for (let i = 0; i < present.length; i += 8) {
29 const batch = present.slice(i, i + 8)
30 const each = await Promise.all(batch.map(p => hash([p])))
31
32 batch.forEach((p, k) => {
33 const h = each[k]!.stdout.trim()
34
35 out.set(p, each[k]!.exitCode === 0 && h !== '' ? h : UNHASHABLE)
36 })
37 }
38 return out
39}
40
41/**
42 * Brings the ledger up to the change: a file whose content moved since the ledger last saw it was
43 * written in `turn`; a file seen for the first time was written before the session (turn 0)
44 * unless this session's own edits named it (a repository first seen with a clean tree starts an
45 * empty ledger, so nothing in it predates the session). Files no longer changed leave the ledger.
46 */
47export function attribute(ledger: Ledger | undefined, hashes: ReadonlyMap<string, string>, turn: number, edited: ReadonlySet<string>): Ledger {
48 const next: Ledger = { hashes: new Map(), turns: new Map() }
49
50 for (const [path, hash] of hashes) {
51 const seen = ledger?.hashes.get(path)
52 const prior = ledger?.turns.get(path)
53 const written = seen === hash && prior !== undefined ? prior : ledger === undefined && !edited.has(path) ? 0 : turn
54
55 next.hashes.set(path, hash)
56 next.turns.set(path, written)
57 }
58 return next
59}
60hooks/engine/symbols.ts 655 lines1import { GIT_READ_ONLY } from './git'
2import { graphOf, reachOf, type Graph, type Reach } from './graph'
3import { isPython } from './imports'
4import type { Change, Facts, Run } from './types'
5
6/**
7 * How a change touches what other files use. `signature` changes a declaration's header (its
8 * name, parameters or type, or adds or removes it); `body` changes only what it does;
9 * `comments` touches only comments, docstrings and blank lines; `imports` only the file's own
10 * imports. `file` is a change read file-wide: module-level code, a new or deleted file, or a
11 * language the reader does not parse.
12 */
13export type Kind = 'signature' | 'body' | 'comments' | 'imports' | 'file'
14
15/** One declaration a change touched: a function, class, method, field, type or constant. */
16export type Touch = { name: string; kind: 'signature' | 'body' | 'comments'; owner?: string }
17
18/** A changed file read declaration by declaration. */
19export type Shape = {
20 path: string
21 /** the strongest kind among its touches; `file` when it must be read whole */
22 kind: Kind
23 touches: Touch[]
24 /** what another file mentions to use what changed: the touched names, and the names here that call them */
25 words: string[]
26 /** the words among them that name a method or field: used through an object, from any distance */
27 members: string[]
28}
29
30/** A file that uses what a change touched: it depends on the changed file and names a touched word. */
31export type User = Reach & {
32 /** lines that name a touched word, its imports left out */
33 uses: number
34 /** the changed file whose words it names */
35 of: string
36}
37
38/** One source line as the reader sees it: its code with comments cut, and what kind of line it is. */
39type Line = { code: string; indent: number; isQuiet: boolean; isImport: boolean; isInString: boolean }
40
41/** A declaration's span, its header (what callers see) and its code (what it does), whitespace folded. */
42type Decl = { name: string; owner?: string; start: number; end: number; header: string; code: string; members: Decl[]; isClass: boolean; isPrivate: boolean }
43
44/** The most lines a file may have and still be read by declaration; past it the file is read whole. */
45const MAX_LINES = 20_000
46/** The most changed files read declaration by declaration in one refresh. */
47const MAX_FILES = 40
48/** The most words one change asks git about. */
49const MAX_WORDS = 60
50
51const isJs = (path: string) => /\.(m|c)?[jt]sx?$/.test(path) && !/\.d\.(m|c)?ts$/.test(path)
52const fold = (s: string) => s.replace(/\s+/g, ' ').trim()
53const KIND_ORDER: Kind[] = ['imports', 'comments', 'body', 'signature', 'file']
54export const strongest = (kinds: readonly Kind[]): Kind => kinds.reduce<Kind>((a, b) => (KIND_ORDER.indexOf(b) > KIND_ORDER.indexOf(a) ? b : a), 'imports')
55
56const JS_IMPORT = /^\s*(import\b(?!\s*\()|export\s+(?:type\s+)?(?:\*|\{[^}]*\})\s*from\b|(?:const|let|var)\s+[\w${},\s:]+=\s*require\()/
57const PY_IMPORT = /^\s*(from\s+[.\w]+\s+import\b|import\s+[\w.])/
58
59/** Reads `text` line by line: comments cut, docstrings and blank lines quiet, string and import lines marked. */
60export function linesOf(text: readonly string[], lang: 'js' | 'py'): Line[] {
61 const out: Line[] = []
62 let inBlock = false
63 let inTemplate = false
64 let triple: string | null = null
65 let isDoc = false
66 let inImport = false
67
68 for (const raw of text) {
69 const indent = /^\s*/.exec(raw)?.[0].length ?? 0
70
71 if (lang === 'py') {
72 if (triple !== null) {
73 const closes = raw.includes(triple)
74
75 out.push({ code: isDoc ? '' : raw, indent, isQuiet: isDoc, isImport: false, isInString: true })
76 if (closes) triple = null
77 continue
78 }
79 const code = raw.replace(/(^|\s)#.*$/, '').trimEnd()
80 const opens = /("""|''')/.exec(code)
81
82 if (opens !== null && !code.slice(opens.index + 3).includes(opens[1]!)) {
83 // a string left open: a docstring when it is the whole statement, else a string inside code
84 triple = opens[1]!
85 isDoc = /^\s*[rRbBuUfF]{0,2}("""|''')/.test(code)
86 }
87 const isDocLine = /^\s*[rRbBuUfF]{0,2}("""|''')/.test(code) && (triple === null || isDoc)
88 const isImport = inImport || PY_IMPORT.test(code)
89
90 if (isImport) inImport = (inImport || code.includes('(')) && !code.includes(')')
91 out.push({ code: isDocLine ? '' : code, indent, isQuiet: isDocLine || code.trim() === '', isImport, isInString: false })
92 continue
93 }
94
95 let code = raw
96 const wasInTemplate = inTemplate
97
98 if (inBlock) {
99 const end = code.indexOf('*/')
100
101 if (end < 0) {
102 out.push({ code: '', indent, isQuiet: true, isImport: false, isInString: false })
103 continue
104 }
105 code = code.slice(end + 2)
106 inBlock = false
107 }
108 code = code.replace(/\/\*.*?\*\//g, '')
109 if (!wasInTemplate && code.includes('/*')) {
110 code = code.slice(0, code.indexOf('/*'))
111 inBlock = true
112 }
113 code = code.replace(/(^|[^:'"`\\])\/\/.*$/, '$1').trimEnd()
114 if ((code.match(/(?<!\\)`/g) ?? []).length % 2 === 1) inTemplate = !inTemplate
115 const isImport = inImport || (!wasInTemplate && JS_IMPORT.test(code))
116
117 if (isImport) inImport = !/\bfrom\s*['"]|require\(|^\s*import\s*['"]/.test(code) && !/^\s*import\b.*;\s*$/.test(code)
118 out.push({ code, indent, isQuiet: code.trim() === '', isImport, isInString: wasInTemplate })
119 }
120 return out
121}
122
123const JS_DECL = /^(?:export\s+)?(?:default\s+)?(?:declare\s+)?(?:abstract\s+)?(?:async\s+)?(function\*?|class|interface|type|enum|const|let|var|namespace)\b\s*([A-Za-z_$][\w$]*)?/
124const JS_ASSIGN = /^(?:module\.)?exports\.([A-Za-z_$][\w$]*)\s*=(?!=)|^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*\.([A-Za-z_$][\w$]*)\s*=(?![=>])/
125const JS_TYPE_ONLY = /^(?:export\s+)?(?:default\s+)?(?:declare\s+)?(?:interface|type|enum|namespace|declare)\b/
126const JS_MEMBER = /^(?:(?:public|private|protected|static|readonly|async|override|abstract|declare|accessor|get|set)\s+)*(#?[A-Za-z_$][\w$]*)\s*[?!]?\s*(<[^>]*>\s*)?([(:=;])/
127const JS_NOT_MEMBER = new Set(['if', 'for', 'while', 'switch', 'return', 'await', 'yield', 'throw', 'new', 'super', 'this'])
128const PY_DECL = /^(?:async\s+)?(def|class)\s+(\w+)/
129const PY_ASSIGN = /^([A-Za-z_]\w*)\s*(?::[^=]*)?=(?!=)|^([A-Za-z_]\w*)\s*:\s*\S/
130const CLOSER = /^[\])}]/
131
132/** The name a top-level line declares, or null for a statement; `export default` without a name is `default`. */
133function nameAt(code: string, lang: 'js' | 'py'): string | null {
134 if (lang === 'py') {
135 const m = PY_DECL.exec(code) ?? PY_ASSIGN.exec(code)
136
137 return m === null ? null : m[2] ?? m[1] ?? null
138 }
139 const m = JS_DECL.exec(code)
140
141 if (m !== null && (m[2] !== undefined || /^export\s+default\b/.test(code))) return m[2] ?? 'default'
142 if (/^export\s+default\b/.test(code)) return 'default'
143 const a = JS_ASSIGN.exec(code)
144
145 return a === null ? null : a[1] ?? a[2] ?? null
146}
147
148/** The name a class-body line declares (a method or field), or null. */
149function memberAt(code: string, lang: 'js' | 'py'): string | null {
150 if (lang === 'py') {
151 const m = PY_DECL.exec(code) ?? PY_ASSIGN.exec(code)
152
153 return m === null ? null : m[2] ?? m[1] ?? null
154 }
155 const m = JS_MEMBER.exec(code)
156
157 return m === null || JS_NOT_MEMBER.has(m[1]!) ? null : m[1]!
158}
159
160/** The first index of `token` at bracket depth 0 from `from`, strings skipped; `=` alone, never `==`, `=>`, `<=`. */
161function depth0(text: string, token: string, from = 0): number {
162 let depth = 0
163 let quote: string | null = null
164
165 for (let i = from; i < text.length; i++) {
166 const ch = text[i]!
167
168 if (quote !== null) {
169 if (ch === '\\') i++
170 else if (ch === quote) quote = null
171 continue
172 }
173 if (ch === '"' || ch === "'" || ch === '`') quote = ch
174 else if (ch === '(' || ch === '[') depth++
175 else if (ch === ')' || ch === ']') depth = Math.max(0, depth - 1)
176 else if (depth === 0 && text.startsWith(token, i)) {
177 if (token !== '=' || (!'=>'.includes(text[i + 1] ?? '') && !'=!<>+-*/%&|^'.includes(text[i - 1] ?? ''))) return i
178 }
179 }
180 return -1
181}
182
183/** What callers see of a declaration: its decorators and signature, a type whole, a value's name and type. */
184function headerOf(text: string, lang: 'js' | 'py'): string {
185 if (lang === 'py') {
186 if (/^(@.*\n)*\s*(async\s+)?(def|class)\b/.test(text)) {
187 const colon = /\)\s*(->[^:]*)?:|^\s*class\s+\w+\s*:|^\s*(async\s+)?def\s+\w+\s*:/m.exec(text)
188
189 return colon === null ? text : text.slice(0, colon.index + colon[0].length)
190 }
191 const eq = depth0(text, '=')
192
193 return eq < 0 ? text : text.slice(0, eq)
194 }
195 if (JS_TYPE_ONLY.test(text.replace(/^(@.*\n)*/, ''))) return text
196 const eq = depth0(text, '=')
197 const isValue = /^(?:export\s+)?(?:default\s+)?(?:declare\s+)?(const|let|var)\b/.test(text) || JS_ASSIGN.test(text) || /^#?[\w$]+\s*[?!]?\s*(:[^=]*)?=/.test(text)
198
199 if (isValue && eq >= 0) {
200 const right = text.slice(eq + 1).trimStart()
201
202 if (/^(async\s+)?function\b/.test(right)) {
203 const brace = depth0(text, '{', eq + 1)
204
205 return brace < 0 ? text : text.slice(0, brace)
206 }
207 if (/^(async\s+)?(\(|<|[A-Za-z_$][\w$]*\s*=>)/.test(right)) {
208 const arrow = depth0(text, '=>', eq + 1)
209
210 if (arrow >= 0) return text.slice(0, arrow)
211 }
212 return text.slice(0, eq)
213 }
214 const brace = depth0(text, '{')
215
216 return brace < 0 ? text : text.slice(0, brace)
217}
218
219/** The code of lines `start` to `end`, quiet lines left out, each line's whitespace folded. */
220const codeOf = (lines: readonly Line[], start: number, end: number) =>
221 lines.slice(start, end + 1).filter(l => !l.isQuiet).map(l => fold(l.code)).join('\n')
222
223/**
224 * The declarations of a file: top-level ones begin on an unindented line that names something,
225 * decorators included, and run until the next top-level statement; a class's members begin on
226 * the class body's first indent.
227 */
228function declsOf(lines: readonly Line[], lang: 'js' | 'py'): Decl[] {
229 const decls: Decl[] = []
230 const exported = lang === 'js' ? exportsOf(lines) : new Set<string>()
231 let open: { name: string; start: number } | null = null
232 let decorated: number | null = null
233 const close = (end: number) => {
234 if (open !== null) decls.push(declOf(lines, lang, open.name, open.start, end, undefined, exported))
235 open = null
236 }
237
238 lines.forEach((l, i) => {
239 if (l.isQuiet || l.isInString || l.indent > 0) return
240 const code = l.code.trim()
241
242 if (code.startsWith('@')) {
243 if (decorated === null) {
244 close(i - 1)
245 decorated = i
246 }
247 return
248 }
249 // a bracket closing the open declaration, or the end of a signature split over lines
250 if (open !== null && CLOSER.test(code)) return
251 const name = l.isImport ? null : nameAt(code, lang)
252
253 close(i - 1)
254 if (name !== null) open = { name, start: decorated ?? i }
255 decorated = null
256 })
257 close(lines.length - 1)
258 return decls
259}
260
261const isClassText = (text: string, lang: 'js' | 'py') =>
262 lang === 'py' ? /^(@.*\n)*\s*class\b/.test(text) : /^(@.*\n)*\s*(?:export\s+)?(?:default\s+)?(?:declare\s+)?(?:abstract\s+)?class\b/.test(text)
263
264/**
265 * Whether a declaration is private to its file: a leading underscore or `#`, a TypeScript
266 * `private` or `protected` member, or a JS top-level declaration its file never exports.
267 */
268function isPrivateDecl(name: string, first: string, lang: 'js' | 'py', owner: string | undefined, exported: ReadonlySet<string>): boolean {
269 if (name.startsWith('#') || (name.startsWith('_') && !/^__\w+__$/.test(name))) return true
270 if (lang === 'py') return false
271 if (owner !== undefined) return /^\s*(?:(?:readonly|static|override|abstract|async)\s+)*(?:private|protected)\b/.test(first)
272 return !/^\s*export\b/.test(first) && !exported.has(name)
273}
274
275/** The names a JS file exports by name: `export { a, b as c }`, `exports.a =`, `module.exports = { a }` or `= a`. */
276function exportsOf(lines: readonly Line[]): Set<string> {
277 const out = new Set<string>()
278
279 for (const l of lines) {
280 for (const m of l.code.matchAll(/\b(?:module\.)?exports\.([A-Za-z_$][\w$]*)/g)) out.add(m[1]!)
281 const list = /^\s*export\s*\{([^}]*)\}/.exec(l.code) ?? /\bmodule\.exports\s*=\s*\{([^}]*)\}/.exec(l.code)
282
283 if (list !== null) for (const part of list[1]!.split(',')) out.add(part.trim().split(/\s+/)[0] ?? '')
284 const one = /\bmodule\.exports\s*=\s*([A-Za-z_$][\w$]*)\s*;?\s*$/.exec(l.code)
285
286 if (one !== null) out.add(one[1]!)
287 }
288 return out
289}
290
291/** One declaration from `start` to `end`; a top-level class reads its members too, and its own code leaves them out. */
292function declOf(lines: readonly Line[], lang: 'js' | 'py', name: string, start: number, end: number, owner?: string, exported: ReadonlySet<string> = new Set()): Decl {
293 const text = codeOf(lines, start, end)
294 const first = lines.slice(start, end + 1).find(l => !l.isQuiet && !l.code.trim().startsWith('@'))?.code ?? ''
295 const members = owner === undefined && isClassText(text, lang) ? membersOf(lines, lang, name, start, end, exported) : []
296 const inMember = (i: number) => members.some(m => i >= m.start && i <= m.end)
297 const own = members.length === 0 ? text : lines.slice(start, end + 1).filter((l, k) => !l.isQuiet && !inMember(start + k)).map(l => fold(l.code)).join('\n')
298
299 return {
300 name,
301 ...(owner === undefined ? {} : { owner }),
302 start,
303 end,
304 header: fold(headerOf(text, lang)),
305 code: own,
306 members,
307 isClass: owner === undefined && isClassText(text, lang),
308 isPrivate: isPrivateDecl(name, first, lang, owner, exported),
309 }
310}
311
312/** A class's methods and fields: each begins on the body's first indent and runs to the next. */
313function membersOf(lines: readonly Line[], lang: 'js' | 'py', owner: string, start: number, end: number, exported: ReadonlySet<string>): Decl[] {
314 const body = lines.slice(start + 1, end + 1).find(l => !l.isQuiet && !l.isInString && l.indent > 0)?.indent
315
316 if (body === undefined) return []
317 const out: Decl[] = []
318 let open: { name: string; start: number } | null = null
319 let decorated: number | null = null
320 const close = (to: number) => {
321 if (open !== null) out.push(declOf(lines, lang, open.name, open.start, to, owner, exported))
322 open = null
323 }
324
325 for (let i = start + 1; i <= end; i++) {
326 const l = lines[i]!
327
328 if (l.isQuiet || l.isInString || l.indent > body) continue
329 const code = l.code.trim()
330
331 if (l.indent < body) {
332 close(i - 1)
333 continue
334 }
335 if (code.startsWith('@')) {
336 if (decorated === null) {
337 close(i - 1)
338 decorated = i
339 }
340 continue
341 }
342 if (open !== null && CLOSER.test(code)) continue
343 const name = memberAt(code, lang)
344
345 close(i - 1)
346 if (name !== null) open = { name, start: decorated ?? i }
347 decorated = null
348 }
349 close(end)
350 return out
351}
352
353const keyOf = (d: { name: string; owner?: string }) => (d.owner === undefined ? d.name : `${d.owner}.${d.name}`)
354
355/** A file read for its declarations: each line's innermost one, and every one by its name. */
356function readOf(text: readonly string[], lang: 'js' | 'py') {
357 const lines = linesOf(text, lang)
358 const decls = declsOf(lines, lang)
359 const byKey = new Map<string, Decl>()
360
361 for (const d of decls.flatMap(d => [d, ...d.members])) {
362 const prior = byKey.get(keyOf(d))
363
364 // overloads and property setters share a name: they are read as one
365 byKey.set(keyOf(d), prior === undefined ? d : { ...prior, header: `${prior.header}\n${d.header}`, code: `${prior.code}\n${d.code}` })
366 }
367 const at = (n: number): Decl | undefined => {
368 const top = decls.find(d => n >= d.start && n <= d.end)
369
370 return top?.members.find(m => n >= m.start && n <= m.end) ?? top
371 }
372 return { lines, decls, byKey, at }
373}
374
375/** A name that stands for its class: a constructor, or a dunder method Python calls on the class's own behalf. */
376const isClassHook = (name: string) => name === 'constructor' || /^__\w+__$/.test(name)
377
378const escaped = (w: string) => w.replace(/[$]/g, '\\$')
379/** Whether `text` names `word` as a whole identifier. */
380export const names = (text: string, words: readonly string[]): boolean =>
381 words.length > 0 && new RegExp(`(?<![\\w$])(?:${words.map(escaped).join('|')})(?![\\w$])`).test(text)
382
383/**
384 * Whether `text` uses a method or field among `words`: through an object (`app.run`, `this?.run`),
385 * or by defining it again in Python, as a subclass's override does. A bare `run(` is another function.
386 */
387export const namesMember = (text: string, words: readonly string[]): boolean =>
388 words.length > 0 &&
389 new RegExp(`(?:\\.|\\bdef\\s+)(?:${words.map(escaped).join('|')})(?![\\w$])`).test(text)
390
391/**
392 * A changed file read declaration by declaration: `before` and `after` are its lines, `removed`
393 * and `added` the 1-based lines the diff touched on each side. A declaration whose header changed,
394 * or which appeared or went, changed its signature; one whose code changed otherwise changed its
395 * body; one whose code is the same changed only comments.
396 */
397export function shapeOf(path: string, before: readonly string[] | null, after: readonly string[] | null, removed: readonly number[], added: readonly number[]): Shape {
398 const lang = isPython(path) ? 'py' : isJs(path) ? 'js' : null
399
400 if (lang === null || before === null || after === null) return { path, kind: 'file', touches: [], words: [], members: [] }
401 const old = readOf(before, lang)
402 const now = readOf(after, lang)
403 const touched = new Map<string, Decl>()
404 const kinds: Kind[] = []
405 const visit = (side: ReturnType<typeof readOf>, n: number) => {
406 const d = side.at(n)
407 const l = side.lines[n]
408
409 if (d !== undefined) touched.set(keyOf(d), d)
410 else kinds.push(l === undefined || l.isQuiet ? 'comments' : l.isImport ? 'imports' : 'file')
411 }
412
413 for (const n of removed) visit(old, n - 1)
414 for (const n of added) visit(now, n - 1)
415 const touches: Touch[] = [...touched].map(([key, d]) => {
416 const o = old.byKey.get(key)
417 const n = now.byKey.get(key)
418 const kind = o === undefined || n === undefined ? 'signature' : o.code === n.code ? 'comments' : o.header !== n.header ? 'signature' : 'body'
419
420 return { name: d.name, kind, ...(d.owner === undefined ? {} : { owner: d.owner }) }
421 })
422 // a default export is used under any name its importer picks, so only the file can say who uses it
423 if (touches.some(t => t.name === 'default' && t.owner === undefined && t.kind !== 'comments')) kinds.push('file')
424 const kind = strongest([...kinds, ...touches.map(t => t.kind)])
425
426 const isPrivate = (t: Touch) => (now.byKey.get(keyOf(t)) ?? old.byKey.get(keyOf(t)))?.isPrivate === true
427
428 return { path, kind, touches, ...wordsOf(touches, now.decls, isPrivate) }
429}
430
431/**
432 * The words another file names to use what changed: each touched declaration's name (its class's
433 * for a constructor). A private declaration has no users of its own, so the functions and methods
434 * here that call it stand in for it, and theirs in turn while they are private too, two calls
435 * deep. A public one's users are found directly; a class is never added for calling one.
436 */
437function wordsOf(touches: readonly Touch[], decls: readonly Decl[], isPrivate: (t: Touch) => boolean): { words: string[]; members: string[] } {
438 const words = new Set<string>()
439 const members = new Set<string>()
440 const add = (d: { name: string; owner?: string }) => {
441 const isHook = d.owner !== undefined && isClassHook(d.name)
442 const word = isHook ? d.owner! : d.name
443
444 if (word.length < 2 || word === 'default' || word.startsWith('#')) return
445 words.add(word)
446 if (d.owner !== undefined && !isHook) members.add(word)
447 }
448 const live = touches.filter(t => t.kind !== 'comments')
449 let seeds = live.filter(isPrivate).map(t => t.name)
450
451 for (const t of live) add(t)
452 const all = decls.flatMap(d => [d, ...d.members]).filter(d => !d.isClass)
453
454 for (let depth = 0; depth < 2 && seeds.length > 0 && words.size < MAX_WORDS; depth++) {
455 const callers = all.filter(d => !words.has(d.name) && !seeds.includes(d.name) && names(d.code, seeds))
456
457 callers.forEach(add)
458 seeds = callers.filter(d => d.isPrivate).map(d => d.name)
459 }
460 const kept = [...words].slice(0, MAX_WORDS)
461
462 return { words: kept, members: kept.filter(w => members.has(w)) }
463}
464
465/**
466 * A `---` or `+++` header's path, its side's `a/` or `b/` taken off; `/dev/null` for a side that
467 * is not there. Git ends the name with a tab when it holds a space, and the tab is cut first.
468 */
469export function headerPath(line: string): string {
470 const name = line.slice(4).replace(/\t$/, '')
471
472 return name === '/dev/null' ? name : name.replace(line.startsWith('---') ? /^a\// : /^b\//, '')
473}
474
475/**
476 * `git diff -U0` hunks: per file, the 1-based lines removed from the old side and added on the new.
477 * Headers are read only between `diff --git` and the first `@@`, so a removed `-- ` line or an
478 * added `++ ` line in a hunk stays a line.
479 */
480export function hunksOf(diff: string): Map<string, { removed: number[]; added: number[] }> {
481 const out = new Map<string, { removed: number[]; added: number[] }>()
482 let at: { removed: number[]; added: number[] } | null = null
483 let from = ''
484 let isHeader = false
485
486 for (const line of diff.split('\n')) {
487 if (line.startsWith('diff --git ')) {
488 isHeader = true
489 at = null
490 from = ''
491 } else if (isHeader && line.startsWith('--- ')) from = headerPath(line)
492 else if (isHeader && line.startsWith('+++ ')) {
493 const to = headerPath(line)
494 const path = to === '/dev/null' ? from : to
495
496 at = out.get(path) ?? { removed: [], added: [] }
497 out.set(path, at)
498 } else if (line.startsWith('@@') && at !== null) {
499 isHeader = false
500 const m = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line)
501
502 if (m === null) continue
503 const [a, b, c, d] = [Number(m[1]), m[2] === undefined ? 1 : Number(m[2]), Number(m[3]), m[4] === undefined ? 1 : Number(m[4])]
504
505 for (let i = 0; i < b; i++) at.removed.push(a + i)
506 for (let i = 0; i < d; i++) at.added.push(c + i)
507 }
508 }
509 return out
510}
511
512/** `git grep -z -n -e ''` rows, `[ref:]path\0line\0text`: each file's lines in order. */
513export function textsOf(stdout: string, ref = ''): Map<string, string[]> {
514 const out = new Map<string, string[]>()
515 const prefix = ref === '' ? '' : `${ref}:`
516
517 for (const row of stdout.split('\n')) {
518 const a = row.indexOf('\0')
519 const b = a < 0 ? -1 : row.indexOf('\0', a + 1)
520
521 if (b < 0) continue
522 const path = row.slice(0, a).startsWith(prefix) ? row.slice(prefix.length, a) : row.slice(0, a)
523 const lines = out.get(path) ?? []
524
525 lines[Number(row.slice(a + 1, b)) - 1] = row.slice(b + 1).replace(/\r$/, '')
526 out.set(path, lines)
527 }
528 for (const lines of out.values()) for (let i = 0; i < lines.length; i++) lines[i] ??= ''
529 return out
530}
531
532/** A line that only imports or lists a name (an import, or one name on an import's continuation line): no use of it. */
533const isListing = (text: string) => JS_IMPORT.test(text) || PY_IMPORT.test(text) || /^\s*[\w$]+(\s+as\s+[\w$]+)?,?\s*$/.test(text)
534/** A comment line: a name in it is no use of the name. */
535const isComment = (text: string) => /^\s*(\/\/|\/\*|\*|#)/.test(text)
536/** A line with its string literals emptied, so a name inside one is no use of it. */
537const unquoted = (text: string) => text.replace(/(["'`])(?:\\.|(?!\1).)*?\1/g, '""')
538/** A file that passes its neighbours' names on: an index module or a package's `__init__.py`. */
539const isBarrel = (path: string) => /(^|\/)(index\.[cm]?[jt]sx?|__init__\.py)$/.test(path)
540
541/**
542 * What a change reads as, declaration by declaration, and the files that use what it touched;
543 * `added` holds the lines it added to each file read, and `coined` the names those lines brought
544 * into a file that never had them before.
545 */
546export type ChangeRead = { shapes: Map<string, Shape>; users: User[]; added: Map<string, string[]>; coined: Map<string, string[]> }
547
548/** A name with the shape of an identifier, in code or in a string: snake_case, CONSTANT_CASE, camelCase or PascalCase. */
549const COINED = /(?<![\w$])[A-Za-z_$][\w$]*(?:_[A-Za-z0-9]|[a-z0-9][A-Z])[\w$]*(?![\w$])/g
550
551/**
552 * Reads the change in three git calls (the diff, the files before, the files after) and finds
553 * its users in a fourth: files that depend on a changed file, at any distance, and name one of
554 * its touched words. The change runs from the commit `from` to the commit `to`, or to the
555 * working tree when `to` is absent.
556 */
557export async function readChange(
558 run: Run,
559 root: string,
560 facts: Facts,
561 changes: readonly Change[],
562 refs: { from: string; to?: string },
563 graph: Graph = graphOf(facts.edges),
564): Promise<ChangeRead> {
565 const git = (...args: string[]) => run(['git', '-C', root, ...GIT_READ_ONLY, ...args])
566 const from = refs.from
567 const to = refs.to === undefined ? [] : [refs.to]
568 const readable = changes
569 .filter(c => !c.isNew && !c.isDeleted && (isJs(c.path) || isPython(c.path)) && (facts.lines.get(c.path) ?? 0) <= MAX_LINES)
570 .slice(0, MAX_FILES)
571 .map(c => c.path)
572 const shapes = new Map<string, Shape>(changes.map(c => [c.path, { path: c.path, kind: 'file', touches: [], words: [], members: [] }]))
573 const added = new Map<string, string[]>()
574 const coined = new Map<string, string[]>()
575
576 if (readable.length === 0) return { shapes, users: [], added, coined }
577 const [diff, before, after] = await Promise.all([
578 git('-c', 'core.quotePath=false', 'diff', '-U0', '--no-color', '--no-ext-diff', '--no-renames', from, ...to, '--', ...readable),
579 git('grep', '-z', '-n', '-I', '-e', '', from, '--', ...readable),
580 git('grep', '-z', '-n', '-I', '-e', '', ...to, '--', ...readable),
581 ])
582
583 if (diff.exitCode !== 0) return { shapes, users: [], added, coined }
584 const hunks = hunksOf(diff.stdout)
585 const old = textsOf(before.stdout, from)
586 const now = textsOf(after.stdout, to[0] ?? '')
587
588 for (const path of readable) {
589 const h = hunks.get(path)
590
591 if (h === undefined) continue
592 shapes.set(path, shapeOf(path, old.get(path) ?? null, now.get(path) ?? null, h.removed, h.added))
593 const lines = h.added.map(n => now.get(path)?.[n - 1] ?? '')
594 const had = new Set((old.get(path) ?? []).flatMap(l => l.match(COINED) ?? []))
595
596 added.set(path, lines)
597 coined.set(path, [...new Set(lines.flatMap(l => l.match(COINED) ?? []))].filter(w => !had.has(w)))
598 }
599
600 // the files each changed file reaches at any distance: its users are among them
601 const used = [...shapes.values()].filter(s => (s.kind === 'signature' || s.kind === 'body') && s.words.length > 0)
602 const reached = new Map(used.map(s => [s.path, new Map(reachOf(graph, [s.path], 64).map(r => [r.path, r]))]))
603 const within = new Set([...reached.values()].flatMap(m => [...m.keys()]))
604 const words = [...new Set(used.flatMap(s => s.words))]
605
606 if (within.size === 0 || words.length === 0) return { shapes, users: [], added, coined }
607 // past a few thousand files a pathspec costs more than the whole tree
608 const hits = await git('grep', '-z', '-n', '-w', '-I', '-F', ...words.flatMap(w => ['-e', w]), ...to, '--', ...(within.size <= 4000 ? within : []))
609 const rows = hitsOf(hits.stdout, to[0] ?? '')
610 const users: User[] = []
611
612 for (const s of used) {
613 const reach = reached.get(s.path)!
614 // a function, class or constant is named where it is in scope: in the files that import its
615 // file, directly or through barrels; a method or field, wherever an object of it can travel
616 const near = new Set<string>()
617
618 for (const r of reach.values()) if (r.hop === 1 || (isBarrel(r.via) && near.has(r.via))) near.add(r.path)
619 const tops = s.words.filter(w => !s.members.includes(w))
620 const counts = new Map<string, number>()
621
622 for (const r of rows) {
623 if (!reach.has(r.path) || isComment(r.text)) continue
624 const code = unquoted(r.text)
625
626 if (!namesMember(code, s.members) && !(near.has(r.path) && names(code, tops))) continue
627 counts.set(r.path, (counts.get(r.path) ?? 0) + (isListing(r.text) ? 0 : 1))
628 }
629 // each user, and the files its import chain passes through on the way (barrels, re-exports)
630 const rowsOf = new Map<string, User>()
631
632 for (const [path, uses] of counts) {
633 for (let at = reach.get(path); at !== undefined && !rowsOf.has(at.path); at = reach.get(at.via)) {
634 rowsOf.set(at.path, { ...at, uses: at.path === path ? uses : counts.get(at.path) ?? 0, of: s.path })
635 }
636 }
637 users.push(...rowsOf.values())
638 }
639 return { shapes, users, added, coined }
640}
641
642/** `git grep -z -n` rows, `[ref:]path\0line\0text`, as `{ path, text }`. */
643function hitsOf(stdout: string, ref: string): { path: string; text: string }[] {
644 const prefix = ref === '' ? '' : `${ref}:`
645 const out: { path: string; text: string }[] = []
646
647 for (const row of stdout.split('\n')) {
648 const a = row.indexOf('\0')
649 const b = a < 0 ? -1 : row.indexOf('\0', a + 1)
650
651 if (b > 0) out.push({ path: row.slice(0, a).startsWith(prefix) ? row.slice(prefix.length, a) : row.slice(0, a), text: row.slice(b + 1) })
652 }
653 return out
654}
655hooks/engine/types.ts 61 lines1/** What a host command answers, its output whole: the engine's `$.process.spawn`, or Node's in the preview script. */
2export type RunResult = { exitCode: number; stdout: string }
3
4/** Runs a command by argv with no shell. */
5export type Run = (argv: readonly string[]) => Promise<RunResult>
6
7/** One import: `from` imports `to`, both repo-relative paths. */
8export type Edge = { from: string; to: string }
9
10/** A layer band of the basemap, top to bottom. */
11export type Layer = { id: string; name: string; blurb: string }
12
13/** One capability cell of the basemap. */
14export type Region = {
15 id: string
16 name: string
17 blurb: string
18 layer: string
19 /** Path prefixes ("src/curator/") or exact files ("src/app.ts"); longest match wins. */
20 paths: string[]
21 /** Static consequence weight, 1 to 10: the cell's area. */
22 weight: number
23}
24
25/**
26 * The fixed map of the codebase. Built once and kept; only `/isobar map` rebuilds it,
27 * so its shape becomes muscle memory.
28 */
29export type Basemap = {
30 version: 1
31 repo: string
32 head: string
33 builtAt: string
34 source: 'model' | 'heuristic' | 'repo-file'
35 layers: Layer[]
36 regions: Region[]
37}
38
39/** One changed file. */
40export type Change = {
41 path: string
42 added: number
43 deleted: number
44 isNew: boolean
45 isDeleted: boolean
46}
47
48/** What the weather is measured against: the uncommitted edits, the last commit, or nothing in a repository with no commits yet. */
49export type Base = { kind: 'uncommitted' | 'commit' | 'none'; label: string }
50
51/** The repository facts a basemap and the weather are computed from. */
52export type Facts = {
53 root: string
54 head: string
55 /** Tracked text files and their line counts. */
56 lines: Map<string, number>
57 edges: Edge[]
58 /** Recent commits, each the files it touched (bulk commits dropped). */
59 commits: string[][]
60}
61hooks/engine/weather.ts 310 lines1import { folderRegion, regionFinder } from './basemap'
2import { chainOf, dependentsOf, expectedOf, graphOf, isTest, reachOf, type Expected, type Reach } from './graph'
3import { names, type ChangeRead, type Kind, type Touch } from './symbols'
4import type { Base, Basemap, Change, Facts } from './types'
5
6/** A changed file as the storm reads it. */
7export type Cell = Change & {
8 region: string
9 dependents: number
10 risk: number
11 isTested: boolean
12 isTest: boolean
13 /** how the edit touches what other files use; `file` when it is read file-wide */
14 kind: Kind
15 touches: Touch[]
16 /** files that name what it touched, and the lines where they do; null when read file-wide */
17 users: number | null
18 uses: number
19 /** the session turn that last changed it, 0 before the session; absent outside a session */
20 turn?: number
21 /** whether it changed in the session's latest turn that changed anything (always, outside a session) */
22 isLatest: boolean
23 /** why the scope check says nobody asked for it */
24 unasked?: string
25}
26
27/** A file the change reaches: `uses` lines name what changed there, null when the edit is read file-wide; `of` is that edit. */
28export type ReachRow = Reach & { region: string; uses: number | null; of: string }
29
30/**
31 * What a session adds to a change: when each file last changed, what the scope check flagged, and
32 * the gist's caption for each region the change sits in.
33 */
34export type Session = { turns?: ReadonlyMap<string, number>; unasked?: ReadonlyMap<string, string>; gists?: ReadonlyMap<string, string> }
35
36/** Rings need a file to change with these this many times more often than it changes at all (bench/history.mts). */
37export const MIN_LIFT = 4
38
39/** A reach that leaves the regions the change sits in: the offshoot. */
40export type Offshoot = { path: string; region: string; hop: number; chain: string[] }
41
42/** The reach into one region: how many files, how near, and the import chain to its nearest file. */
43export type Arm = { region: string; count: number; hop: number; chain: string[] }
44
45/** One region's share of the weather. */
46export type RegionWeather = {
47 /** 0 to 1 per layer */
48 change: number
49 impact: number
50 risk: number
51 history: number
52 added: number
53 deleted: number
54 files: number
55 reached: number
56 tags: string[]
57 /** what the change does here, in the gist's few words */
58 what?: string
59}
60
61export type Weather = {
62 base: Base
63 cells: Cell[]
64 reach: ReachRow[]
65 offshoots: Offshoot[]
66 arms: Arm[]
67 expected: (Expected & { region: string })[]
68 regions: Record<string, RegionWeather>
69 headline: string
70 lines: string[]
71}
72
73const short = (p: string) => {
74 const parts = p.split('/')
75 const base = parts.pop() ?? p
76
77 return /^index\.|^__init__\.py$|^mod\.rs$/.test(base) && parts.length > 0 ? `${parts.pop()}/${base}` : base
78}
79const clamp = (x: number) => Math.max(0, Math.min(1, x))
80const SOURCE = /\.(py|[cm]?[jt]sx?|vue|svelte|go|rs|java|kt|kts|swift|rb|php|cs|fs|c|cc|cpp|cxx|h|hh|hpp|m|mm|scala|ex|exs|erl|clj|dart|lua|zig|sh)$/
81/** Source code: a file whose edit could owe a test. */
82const isSource = (path: string) => SOURCE.test(path)
83/** How many files an edit reaches: its users when read by declaration, its dependents when read whole. */
84const spreadOf = (c: Cell) => c.users ?? c.dependents
85
86/** An edit's reach in words: "9 uses in 4 files", or "68 files depend on it" for an edit read whole. */
87export function reachOfCell(c: Cell): string {
88 if (c.users === null) return `${plural(c.dependents, 'file')} ${c.dependents === 1 ? 'depends' : 'depend'} on it`
89 return c.users === 0 ? 'no uses elsewhere' : `${plural(c.uses, 'use')} in ${plural(c.users, 'file')}`
90}
91const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`
92
93/**
94 * The weather of `change` over `map`: what it touched, how far it reaches, what history expected.
95 * With `read`, an edit rains only on the files that use what it touched; without, on every importer.
96 */
97export function weatherOf(map: Basemap, facts: Facts, base: Base, changes: readonly Change[], read?: ChangeRead, session: Session = {}): Weather {
98 const graph = graphOf(facts.edges)
99 const find = regionFinder(map.regions)
100 // a file no rule maps (one the session just created) joins the region its folder's files are in
101 const regionOf = (p: string) => (find(p) ?? folderRegion(find, facts.lines.keys(), p))?.id ?? map.regions[map.regions.length - 1]?.id ?? ''
102 const fileCount = Math.max(2, facts.lines.size)
103 const changedPaths = changes.map(c => c.path)
104 const inChange = new Set(changedPaths)
105 const changedTests = changes.filter(c => isTest(c.path)).map(c => c.path)
106 const testedBy = new Set(changedTests.flatMap(t => [t, ...chainTargets(graph.out, t, 2)]))
107 // a test written against an edit names it: the declarations the edit touched, or a name it brought
108 // into its file (a config key, a new helper), on the lines the change added to the test
109 const testLines = changedTests.map(t => (read?.added.get(t) ?? []).join('\n')).filter(t => t !== '')
110 const isNamedByTest = (path: string) => {
111 const touched = (read?.shapes.get(path)?.touches ?? []).map(t => t.name).filter(n => n.length >= 4 && !/^__\w+__$/.test(n))
112 const words = [...new Set([...touched, ...(read?.coined.get(path) ?? [])])]
113
114 return words.length > 0 && testLines.some(t => names(t, words))
115 }
116 const turns = changes.map(c => session.turns?.get(c.path)).filter((t): t is number => t !== undefined)
117 const latest = turns.length === 0 ? undefined : Math.max(...turns)
118
119 // each edit's own reach: its users when the edit was read declaration by declaration, else its importers
120 const rowsOf = new Map<string, ReachRow[]>()
121
122 for (const c of changes) {
123 const kind = read?.shapes.get(c.path)?.kind ?? 'file'
124 const rows: ReachRow[] =
125 isTest(c.path) || kind === 'comments' || kind === 'imports'
126 ? []
127 : kind === 'file'
128 ? reachOf(graph, [c.path], 3).map(r => ({ ...r, region: regionOf(r.path), uses: null, of: c.path }))
129 : (read?.users ?? []).filter(u => u.of === c.path).map(u => ({ path: u.path, hop: u.hop, via: u.via, region: regionOf(u.path), uses: u.uses, of: c.path }))
130
131 rowsOf.set(c.path, rows.filter(r => !inChange.has(r.path)))
132 }
133
134 const cells: Cell[] = changes.map(c => {
135 const shape = read?.shapes.get(c.path)
136 const kind = shape?.kind ?? 'file'
137 const rows = rowsOf.get(c.path) ?? []
138 const dependents = dependentsOf(graph, c.path)
139 const users = kind === 'file' ? null : rows.filter(r => (r.uses ?? 0) > 0).length
140 const test = isTest(c.path)
141 const stem = short(c.path).replace(/\.[^.]+$/, '')
142 const isTested = test || testedBy.has(c.path) || changedTests.some(t => short(t).includes(stem)) || isNamedByTest(c.path)
143 const size = clamp(Math.log2(1 + c.added + c.deleted) / Math.log2(1 + 400))
144 // how far it spreads: its users when read by declaration, every dependent when read whole
145 const spread = kind === 'comments' || kind === 'imports' ? 0 : users ?? dependents
146 const reachShare = clamp(Math.log2(1 + spread) / Math.log2(fileCount))
147 const quiet = kind === 'comments' || kind === 'imports' ? 0.4 : 1
148 const risk = test ? 0.15 + 0.2 * size : clamp((0.2 + 0.35 * size + 0.45 * reachShare) * (isTested ? 0.85 : 1.15) * quiet)
149 const turn = session.turns?.get(c.path)
150 const unasked = session.unasked?.get(c.path)
151
152 return {
153 ...c,
154 region: regionOf(c.path),
155 dependents,
156 risk,
157 isTested,
158 isTest: test,
159 kind,
160 touches: shape?.touches ?? [],
161 users,
162 uses: rows.reduce((n, r) => n + (r.uses ?? 0), 0),
163 ...(turn === undefined ? {} : { turn }),
164 isLatest: latest === undefined || turn === latest,
165 ...(unasked === undefined ? {} : { unasked }),
166 }
167 })
168
169 // the nearest reach of any edit wins a file, so each file is reached once
170 const nearest = new Map<string, ReachRow>()
171
172 for (const rows of rowsOf.values()) for (const r of rows) if ((nearest.get(r.path)?.hop ?? Infinity) > r.hop) nearest.set(r.path, r)
173 const reachRows = [...nearest.values()].sort((a, b) => a.hop - b.hop || a.path.localeCompare(b.path))
174 const touched = new Set(cells.map(c => c.region))
175 const offshoots = farthest(reachRows, touched)
176 const arms = armsOf(reachRows)
177 const expected = expectedOf(facts.commits, changedPaths, p => facts.lines.has(p), 0.6, 3, MIN_LIFT).slice(0, 3).map(e => ({ ...e, region: regionOf(e.path) }))
178
179 const regions: Record<string, RegionWeather> = {}
180 const at = (id: string) => (regions[id] ??= { change: 0, impact: 0, risk: 0, history: 0, added: 0, deleted: 0, files: 0, reached: 0, tags: [] })
181
182 for (const c of cells) {
183 const w = at(c.region)
184
185 w.files++
186 w.added += c.added
187 w.deleted += c.deleted
188 w.change = clamp(w.change + 0.35 + 0.65 * clamp(Math.log2(1 + c.added + c.deleted) / Math.log2(1 + 400)))
189 w.risk = Math.max(w.risk, c.risk)
190 }
191 for (const r of reachRows) {
192 const w = at(r.region)
193
194 w.reached++
195 w.impact = clamp(w.impact + ([0, 0.32, 0.18, 0.1][Math.min(3, r.hop)] ?? 0))
196 }
197 for (const e of expected) at(e.region).history = Math.max(at(e.region).history, e.together / e.of)
198 // a test is owed to source code whose edit changes what it does; docs, config and comments owe none
199 const owesTest = (c: Cell) => !c.isTest && !c.isTested && !c.isDeleted && isSource(c.path) && c.kind !== 'comments' && c.kind !== 'imports'
200
201 for (const [id, w] of Object.entries(regions)) {
202 const here = cells.filter(c => c.region === id)
203
204 if (w.files > 0) w.tags.push(`CHANGED +${w.added} −${w.deleted}`)
205 if (here.some(owesTest)) w.tags.push('NO TESTS')
206 if (here.some(c => c.unasked !== undefined)) w.tags.push('UNASKED')
207 if (w.files === 0 && w.history > 0) w.tags.push('EXPECTED')
208 const what = w.files > 0 ? session.gists?.get(id) : undefined
209
210 if (what !== undefined) w.what = what
211 }
212
213 const { headline, lines } = forecast(map, cells, reachRows, offshoots, expected)
214
215 return { base, cells, reach: reachRows, offshoots, arms, expected, regions, headline, lines }
216}
217
218function chainTargets(out: Map<string, string[]>, from: string, hops: number): string[] {
219 const seen = new Set<string>()
220 let frontier = [from]
221
222 for (let h = 0; h < hops; h++) {
223 const next: string[] = []
224
225 for (const f of frontier) {
226 for (const t of out.get(f) ?? []) {
227 if (seen.has(t)) continue
228 seen.add(t)
229 next.push(t)
230 }
231 }
232 frontier = next
233 }
234 return [...seen]
235}
236
237/** One arm per reached region, along the chain to its nearest reached file. */
238function armsOf(reach: readonly ReachRow[]): Arm[] {
239 const by = new Map<string, ReachRow[]>()
240
241 for (const r of reach) by.set(r.region, [...(by.get(r.region) ?? []), r])
242 return [...by]
243 .map(([region, list]) => {
244 const near = [...list].sort((a, b) => a.hop - b.hop || a.path.localeCompare(b.path))[0] as Reach
245
246 return { region, count: list.length, hop: near.hop, chain: chainOf(reach, near.path) }
247 })
248 .sort((a, b) => b.count - a.count || a.region.localeCompare(b.region))
249}
250
251/** The farthest reach into each region the change does not sit in, deepest first. */
252function farthest(reach: readonly ReachRow[], touched: ReadonlySet<string>): Offshoot[] {
253 const best = new Map<string, ReachRow>()
254
255 for (const r of reach) {
256 if (touched.has(r.region)) continue
257 const prior = best.get(r.region)
258
259 if (prior === undefined || r.hop > prior.hop || (r.hop === prior.hop && r.path < prior.path)) best.set(r.region, r)
260 }
261 return [...best.values()]
262 .sort((a, b) => b.hop - a.hop || a.path.localeCompare(b.path))
263 .map(r => ({ path: r.path, region: r.region, hop: r.hop, chain: chainOf(reach, r.path) }))
264}
265
266/** The forecast in words: one headline, then at most three plain lines, all from the data. */
267function forecast(
268 map: Basemap,
269 cells: readonly Cell[],
270 reach: readonly ReachRow[],
271 offshoots: readonly Offshoot[],
272 expected: readonly (Expected & { region: string })[],
273): { headline: string; lines: string[] } {
274 const name = (id: string) => map.regions.find(r => r.id === id)?.name ?? id
275 const lines: string[] = []
276
277 if (cells.length === 0) return { headline: 'Clear skies. Nothing has changed yet.', lines }
278
279 const byRisk = [...new Set([...cells].sort((a, b) => b.risk - a.risk).map(c => c.region))]
280 const named = byRisk.slice(0, 3).map(name)
281 const where = named.length === 1 ? named[0] : `${named.slice(0, -1).join(', ')} and ${named[named.length - 1]}`
282 const headline = `${plural(cells.length, 'file')} changed in ${where}${byRisk.length > 3 ? `, plus ${plural(byRisk.length - 3, 'more region')}` : ''}.`
283 const reachedRegions = new Set(reach.map(r => r.region))
284
285 if (reach.length === 0) lines.push('Contained: nothing imports the changed files.')
286 else if (offshoots.length === 0) lines.push(`Reach stays home: ${plural(reach.length, 'file')} ${reach.length === 1 ? 'imports' : 'import'} it, all in the regions it sits in.`)
287 else {
288 const far = offshoots[0] as Offshoot
289 const via = far.chain.length > 2 ? ` via ${far.chain.slice(1, -1).map(short).join(' → ')}` : ''
290
291 lines.push(`Reaches ${plural(reach.length, 'file')} in ${plural(reachedRegions.size, 'region')}; farthest ${name(far.region)}, ${plural(far.hop, 'hop')}${via}.`)
292 }
293
294 const untested = cells.filter(c => !c.isTest && !c.isTested && !c.isDeleted && isSource(c.path) && c.kind !== 'comments' && c.kind !== 'imports').sort((a, b) => spreadOf(b) - spreadOf(a))
295
296 if (untested.length > 0) {
297 const top = untested[0] as Cell
298
299 lines.push(`${short(top.path)} changed with no test beside it${spreadOf(top) > 0 ? `; ${reachOfCell(top)}` : ''}.`)
300 }
301 for (const c of cells.filter(c => c.unasked !== undefined).slice(0, 2)) lines.push(`Unasked: ${short(c.path)}, ${c.unasked}.`)
302 if (expected.length > 0) {
303 const e = expected[0] as Expected
304
305 lines.push(`History expects ${short(e.path)}: it changed with ${short(e.with)} in ${e.together} of ${e.of} commits, and not this time.`)
306 }
307
308 return { headline, lines }
309}
310hooks/render/field.ts 278 lines1import type { Weather } from '../engine/weather'
2import type { Layout } from './layout'
3import { fbm, hashOf, lattice, mulberry, valueNoise } from './noise'
4
5/** Which weather layers are drawn; each toggles on its own and they add. */
6export type Layers = { code: boolean; impact: boolean; risk: boolean; history: boolean }
7
8export const ALL_LAYERS: Layers = { code: true, impact: true, risk: true, history: true }
9
10/**
11 * The layers a session starts with. History's rings are off until `4` turns them on: backtested,
12 * about half of them name a file the change left out (bench/history.mts).
13 */
14export const DEFAULT_LAYERS: Layers = { ...ALL_LAYERS, history: false }
15
16/** The weather field in half-block pixels: x in columns, y in half rows, RGB 0..255. */
17export type Field = {
18 w: number
19 h: number
20 /** radar bin per pixel, 0 (dry) to 9 (the eye); the palette colours it */
21 level: Uint8Array
22 /** 1 where the reach's rain sets the pixel's bin rather than a storm: it takes the rain's own hue */
23 wet: Uint8Array
24 /** where each edit's eye is drawn, as a glyph over the weather */
25 eyes: { path: string; x: number; y: number; risk: number; isLatest: boolean }[]
26 /** where history expected a change: drawn as dashed braille contours over the weather */
27 rings: { path: string; region: string; x: number; y: number; share: number }[]
28 /** the offshoot's track: evenly spaced stops from the storm to the farthest file, drawn as ink dots */
29 track: { x: number; y: number }[]
30}
31
32import { BINS, COOL_TOP, RAIN_TOP, STORM } from './palette'
33
34type Puff = { x: number; y: number; sx: number; sy: number; amp: number }
35
36/** The storm, rain and marks of `weather` over `layout`, as radar bins any ground can colour. */
37export function fieldOf(layout: Layout, weather: Weather | null, layers: Layers, seedText: string): Field {
38 const w = layout.cols
39 const h = layout.rows * 2
40 const level = new Uint8Array(w * h)
41 const wet = new Uint8Array(w * h)
42 const seed = hashOf(seedText)
43
44 if (weather === null) return { w, h, level, wet, eyes: [], rings: [], track: [] }
45
46 const rnd = mulberry(seed)
47 const pointOf = placer(layout, seed)
48 const storm: Puff[] = []
49 const rain: Puff[] = []
50
51 // the riskiest edit of the latest turn is the lead storm and alone reaches the hottest bins;
52 // the rest burn a step cooler, and an earlier turn's edits fade to a light shower
53 const lead: Puff[] = []
54 const latest = weather.cells.filter(c => c.isLatest)
55 const riskiest = [...latest].sort((a, b) => b.risk - a.risk)[0]?.path
56 const faded = new Set(weather.cells.filter(c => !c.isLatest).map(c => c.path))
57
58 if (layers.risk) {
59 // a region carrying many edits pools them into one system: each burns and spreads a little less, so
60 // the storm keeps its eyes and bands instead of filling the region flat
61 const many = new Map<string, number>()
62
63 for (const c of weather.cells) many.set(c.region, (many.get(c.region) ?? 0) + 1)
64 for (const c of weather.cells) {
65 const p = pointOf(c.path, c.region)
66 const I = faded.has(c.path) ? c.risk * 0.4 : c.risk
67 const into = c.path === riskiest ? lead : storm
68 const crowd = c.path === riskiest ? 1 : 1 / Math.sqrt(Math.max(1, (many.get(c.region) ?? 1) / 2))
69 const S = 0.55 + 0.45 * crowd
70
71 cloud(into, rnd, p.x, p.y, (3 + 10 * I) * S, (2.4 + 7 * I) * S, Math.round(14 * (0.6 + 0.8 * I)), (0.45 + 0.95 * I) * crowd)
72 cloud(into, rnd, p.x + 1.5 + 3 * I, p.y - 1 - 1.5 * I, (2 + 5 * I) * S, (1.6 + 3.5 * I) * S, Math.round(6 * (0.5 + I)), (0.14 + 0.22 * I) * crowd)
73 }
74 }
75 if (layers.impact) {
76 // a soft rain over each file that uses what changed (or, for an edit read whole, imports it
77 // directly), pooling where many sit together; files further out are carried by the arms and
78 // the track, so the map stays clear
79 const near = weather.reach.filter(r => (r.uses === null ? r.hop === 1 : r.uses > 0))
80 const every = Math.max(1, Math.ceil(near.length / 320))
81 const crowd = 1 / Math.sqrt(Math.max(1, near.length / 40))
82 const soft = [0, 0.5, 0.34, 0.22].map(a => a * crowd)
83
84 near.forEach((r, i) => {
85 if (i % every !== 0) return
86 const p = pointOf(r.path, r.region)
87
88 cloud(rain, rnd, p.x, p.y, 2.8, 2.3, 3, (soft[Math.min(3, r.hop)] ?? 0.1) * Math.sqrt(every) * (faded.has(r.of) ? 0.45 : 1))
89 })
90 // one arm per reached region, blown along the import chain from the edit
91 // (the offshoot's region is reached by its dotted track instead, so the track crosses dry ground)
92 const far = weather.offshoots[0]?.region
93 for (const arm of weather.arms.slice(0, 6)) {
94 const strength = Math.min(1, Math.log2(1 + arm.count) / Math.log2(41))
95 const points = arm.chain.map(f => pointOf(f, regionOfPath(weather, f) ?? arm.region))
96
97 if (arm.region !== far) band(rain, rnd, points, 1.3 + 1.6 * strength, (0.42 + 0.55 * strength) * (arm.hop === 1 ? 1 : arm.hop === 2 ? 0.8 : 0.65), seed)
98 const end = points[points.length - 1]
99
100 if (end !== undefined) cloud(rain, rnd, end.x, end.y, 2 + 4 * strength, 1.7 + 3 * strength, Math.round(4 + 6 * strength), 0.5 + 0.7 * strength)
101 }
102 }
103 // one sweep from the edit to the farthest file; the forecast names the hops between
104 const track = layers.impact ? weather.offshoots.slice(0, 1).flatMap(o => {
105 const ends = [o.chain[0], o.chain[o.chain.length - 1]].filter(f => f !== undefined)
106
107 return trackOf(ends.map(f => pointOf(f, regionOfPath(weather, f) ?? o.region)), seed)
108 }) : []
109
110 const leadDensity = new Float32Array(w * h)
111 const stormDensity = new Float32Array(w * h)
112 const rainDensity = new Float32Array(w * h)
113 const warp = warpOf(w, h, seed)
114
115 splat(leadDensity, w, h, lead, warp, 2.2)
116 splat(stormDensity, w, h, storm, warp, 2.2)
117 splat(rainDensity, w, h, rain, warp, 1.6)
118 radar(level, wet, leadDensity, stormDensity, rainDensity, w, h, seed)
119 // at most two rings, each on open ground: a ring inside the storm or over another reads as a tangle
120 const rings: Field['rings'] = []
121
122 for (const e of layers.history ? weather.expected : []) {
123 const p = pointOf(e.path, e.region)
124 const isInStorm = (level[Math.round(p.y) * w + Math.round(p.x)] ?? 0) >= STORM
125 const isOverRing = rings.some(r => Math.abs(r.x - p.x) < 12 && Math.abs(r.y - p.y) < 11)
126
127 if (rings.length < 2 && !isInStorm && !isOverRing) rings.push({ path: e.path, region: e.region, ...p, share: e.together / e.of })
128 }
129 const eyes = layers.code
130 ? [...weather.cells].sort((a, b) => Number(b.isLatest) - Number(a.isLatest) || b.risk - a.risk).slice(0, 8).map(c => ({ path: c.path, ...pointOf(c.path, c.region), risk: c.risk, isLatest: c.isLatest }))
131 : []
132
133 return { w, h, level, wet, eyes, rings, track }
134}
135
136/** A file's point; a file the layout has no point for (new, untracked) gets a stable spot in its cell. */
137function placer(layout: Layout, seed: number) {
138 return (path: string, region: string) => {
139 const known = layout.points.get(path)
140
141 if (known !== undefined) return known
142 const cell = layout.cells.find(c => c.region.id === region)?.rect ?? { x: 0, y: 0, w: layout.cols, h: layout.rows }
143 const k = hashOf(path) ^ seed
144
145 return { x: cell.x + 1 + lattice(k, 1, 3) * Math.max(1, cell.w - 2), y: (cell.y + 1) * 2 + lattice(k, 2, 5) * Math.max(1, cell.h * 2 - 3) }
146 }
147}
148
149/** A cloud of puffs, dense and hot at the middle, scattering outward: one cell of precipitation. */
150function cloud(out: Puff[], rnd: () => number, cx: number, cy: number, sx: number, sy: number, n: number, amp: number) {
151 for (let i = 0; i < n; i++) {
152 const t = Math.pow(rnd(), 1.6)
153 const a = rnd() * Math.PI * 2
154 const r = 0.7 + 0.6 * rnd()
155
156 out.push({
157 x: cx + Math.cos(a) * sx * t * r,
158 y: cy + Math.sin(a) * sy * t * r,
159 sx: Math.max(0.9, sx * (0.22 + 0.42 * (1 - t)) * (0.7 + 0.6 * rnd())),
160 sy: Math.max(0.8, sy * (0.22 + 0.42 * (1 - t)) * (0.7 + 0.6 * rnd())),
161 amp: (amp * (0.55 + 0.9 * (1 - t))) / Math.sqrt(n / 6),
162 })
163 }
164}
165
166function regionOfPath(weather: Weather, path: string): string | undefined {
167 return weather.cells.find(c => c.path === path)?.region ?? weather.reach.find(r => r.path === path)?.region
168}
169
170/** Points along `chain`, each leg bowed a little, `step` pixels apart. */
171function along(chain: readonly { x: number; y: number }[], step: number, seed: number, each: (x: number, y: number, t: number) => void) {
172 const legs = chain.length - 1
173
174 for (let k = 0; k < legs; k++) {
175 const a = chain[k]!
176 const b = chain[k + 1]!
177 const dist = Math.hypot(b.x - a.x, b.y - a.y)
178
179 if (dist < 1) continue
180 const bow = Math.min(8, dist * 0.18) * (lattice(k, Math.round(a.x + b.y), seed) > 0.5 ? 1 : -1)
181 const cx = (a.x + b.x) / 2 - ((b.y - a.y) / dist) * bow
182 const cy = (a.y + b.y) / 2 + ((b.x - a.x) / dist) * bow
183 const steps = Math.max(2, Math.round(dist / step))
184
185 for (let i = 1; i <= steps; i++) {
186 const t = i / (steps + 1)
187
188 each((1 - t) * (1 - t) * a.x + 2 * (1 - t) * t * cx + t * t * b.x, (1 - t) * (1 - t) * a.y + 2 * (1 - t) * t * cy + t * t * b.y, (k + t) / legs)
189 }
190 }
191}
192
193/** An arm of rain: overlapping puffs along the chain, thinning as it travels. */
194function band(out: Puff[], rnd: () => number, chain: readonly { x: number; y: number }[], width: number, amp: number, seed: number) {
195 along(chain, 1.6, seed, (x, y, t) => {
196 const s = width * (1 - 0.35 * t) * (0.75 + 0.5 * rnd())
197
198 out.push({ x: x + (rnd() - 0.5) * width, y: y + (rnd() - 0.5) * width, sx: s, sy: s * 0.85, amp: (amp * (1 - 0.45 * t)) / 2 })
199 })
200}
201
202/** The offshoot's dotted track: even stops along `chain`, from just past the storm to its end. */
203function trackOf(chain: readonly { x: number; y: number }[], seed: number): { x: number; y: number }[] {
204 const stops: { x: number; y: number }[] = []
205
206 along(chain, 0.25, seed + 1, (x, y) => {
207 const last = stops[stops.length - 1]
208
209 // a pixel is half a row, about a column across, so distance in pixels is distance as seen
210 if (last === undefined ? Math.hypot(x - chain[0]!.x, y - chain[0]!.y) > 6 : Math.hypot(x - last.x, y - last.y) >= 2.2) stops.push({ x, y })
211 })
212 return stops
213}
214
215/** A turbulent displacement per pixel, -1..1 on each axis, shared by every puff. */
216function warpOf(w: number, h: number, seed: number): { x: Float32Array; y: Float32Array } {
217 const fx = 9 / w
218 const fy = 7 / h
219 const x = new Float32Array(w * h)
220 const y = new Float32Array(w * h)
221
222 for (let j = 0; j < h; j++) {
223 for (let i = 0; i < w; i++) {
224 x[j * w + i] = (fbm(i * fx, j * fy, seed) - 0.5) * 2
225 y[j * w + i] = (fbm(i * fx + 31, j * fy + 17, seed) - 0.5) * 2
226 }
227 }
228 return { x, y }
229}
230
231/** Adds each puff's Gaussian to `density`, sampled through the warp so the edges go ragged. */
232function splat(density: Float32Array, w: number, h: number, puffs: readonly Puff[], warp: { x: Float32Array; y: Float32Array }, scale: number) {
233 for (const p of puffs) {
234 const rx = Math.ceil(p.sx * 3 + scale)
235 const ry = Math.ceil(p.sy * 3 + scale)
236
237 for (let y = Math.max(0, Math.floor(p.y - ry)); y < Math.min(h, Math.ceil(p.y + ry)); y++) {
238 for (let x = Math.max(0, Math.floor(p.x - rx)); x < Math.min(w, Math.ceil(p.x + rx)); x++) {
239 const k = y * w + x
240 const dx = (x + 0.5 + scale * (warp.x[k] ?? 0) - p.x) / p.sx
241 const dy = (y + 0.5 + scale * (warp.y[k] ?? 0) - p.y) / p.sy
242
243 density[k] = (density[k] ?? 0) + p.amp * Math.exp(-0.5 * (dx * dx + dy * dy))
244 }
245 }
246 }
247}
248
249/**
250 * Density to radar bins, stepped like isobands: a slow noise breathes the edges so no two
251 * bands run parallel. Only the lead storm reaches the top bins, the other edits stop below
252 * them, and the rain stops below both; the faintest rain is left as dry ground so a wide
253 * reach never hazes the map.
254 */
255function radar(level: Uint8Array, wet: Uint8Array, lead: Float32Array, storm: Float32Array, rain: Float32Array, w: number, h: number, seed: number) {
256 const top = BINS - 1
257 const binOf = (d: number, k: number, lift = 0.5) => Math.max(0, Math.floor(top * (1 - Math.exp(-k * d)) + lift))
258
259 for (let y = 0; y < h; y++) {
260 for (let x = 0; x < w; x++) {
261 const i = y * w + x
262 const l = lead[i] ?? 0
263 const s = storm[i] ?? 0
264 const r = rain[i] ?? 0
265
266 if (l < 0.05 && s < 0.05 && r < 0.05) continue
267 const jitter = 0.82 + 0.36 * valueNoise(x * 0.11, y * 0.16, seed + 5)
268
269 const fire = Math.max(binOf(l * jitter, 0.3), Math.min(COOL_TOP, binOf(s * jitter * 0.55, 0.3)))
270 const wash = Math.min(RAIN_TOP, binOf(r * jitter, 0.16, -1.6))
271
272 level[i] = Math.min(top, Math.max(fire, wash))
273 // the rain takes its own hue wherever it, and no storm, sets the bin
274 wet[i] = wash > fire ? 1 : 0
275 }
276 }
277}
278hooks/render/palette.ts 266 lines1/**
2 * Every colour the pane paints. The terminal's Raster paints 4 bits a channel
3 * (each channel a multiple of 0x11), so each set is chosen on that grid: a colour
4 * off it would be snapped channel by channel and drift in hue. The radar and rain ramps
5 * walk that grid in OKLab steps of 0.02 to 0.08, mostly 0.04 to 0.06, lightness always
6 * moving one way; the night radar's step from bin 5 to bin 6 (0.11) is the one wider.
7 * As on a weather radar, the change storms red and its reach falls as green rain, here a
8 * sage that sits with the sepia inks, so what changed and what it touches read apart at a
9 * glance.
10 *
11 * Where Claude Code paints 256 colours (inside tmux, or where COLORTERM never says
12 * truecolor) it moves each colour onto xterm's palette, and the cream paper lands on
13 * a yellow. The 256 sets are drawn from that palette itself, each colour one that lands
14 * on the same xterm colour whether it is rounded or matched to its nearest, so what
15 * they name is what the terminal shows.
16 */
17
18/** A badge's ink and its ground. */
19export type Chip = { fg: number; bg: number }
20
21/** One printing of the chart: its ground, the radar bins over it, and the inks set on both. */
22export type Inks = {
23 /** the ground the pane and its map are printed on */
24 paper: number
25 /** radar bins, 0 = dry ground to BINS - 1 = the storm's core */
26 radar: readonly number[]
27 /** the hairline over each bin, two bins on, so a line tints the weather under it */
28 line: readonly number[]
29 /** the reach's rain, bin by bin in its own cooler hue; bin 0 is the same dry ground */
30 rain: readonly number[]
31 /** the hairline over each bin of rain */
32 rainLine: readonly number[]
33 /** the frame around the map */
34 frame: number
35 /** changed regions' names, the edit's note and the legend's words */
36 ink: number
37 /** reached regions' names, secondary notes and the title */
38 muted: number
39 /** the title's repo, the legend's keys, a layer switched off */
40 faint: number
41 /** a dry region's name: an inscription two steps off the ground, a step past its hairlines */
42 nameDry: number
43 /** the track, its note and the untested-risk line */
44 red: number
45 /** red ink set on rain, a step further from the ground so it never sinks into its own colour */
46 redOnRain: number
47 /** history's dashed contours and their notes */
48 history: number
49 /** an edit's eye: the calm point at the storm's core */
50 eye: number
51 /** any ink set on the storm itself */
52 onStorm: number
53 changed: Chip
54 untested: Chip
55 expected: Chip
56 /** an edit the scope check says nobody asked for */
57 unasked: Chip
58 /** a ground darker than its inks */
59 isNight: boolean
60}
61
62const paperRadar = [0xffeedd, 0xffddbb, 0xffccaa, 0xffbb99, 0xffaa88, 0xee9977, 0xee8866, 0xdd7755, 0xdd6644, 0xcc5533, 0xbb4433, 0xbb3322, 0xaa3322, 0x993322, 0x992211, 0x881111, 0x771111]
63const nightRadar = [0x111111, 0x221111, 0x331111, 0x441111, 0x552211, 0x662211, 0x884422, 0x995522, 0xaa5522, 0xbb6633, 0xcc7733, 0xdd8844, 0xee9955, 0xffaa66, 0xffbb77, 0xffcc88, 0xffddaa]
64// the reach's rain: a radar's green, grounded to sage, a step lighter than the storm bin for bin, so the change stays the
65// loudest thing on the map
66const paperRain = [0xffeedd, 0xeeeedd, 0xddeecc, 0xccddbb, 0xbbccaa, 0xaabb99, 0x99aa88, 0x889977, 0x778866, 0x667755, 0x556644, 0x445533, 0x334422, 0x223311, 0x112211, 0x112200, 0x001100]
67const nightRain = [0x111111, 0x112211, 0x222211, 0x223311, 0x334422, 0x445533, 0x556644, 0x667755, 0x778866, 0x889977, 0x99aa88, 0xaabb99, 0xbbccaa, 0xccddbb, 0xddeecc, 0xeeeedd, 0xeeffee]
68
69/**
70 * Each bin's hairline: two bins on, the dry ground's a neutral step off it. A 256 ramp repeats
71 * a colour across bins, so its hairline is the first colour on that shows darker.
72 */
73const linesOf = (radar: readonly number[], dry: number, shows = (c: number) => c) =>
74 radar.map((c, k) => (k === 0 ? dry : (radar.slice(k + 2).find(next => shows(next) !== shows(c)) ?? radar[radar.length - 1]!)))
75
76/** Warm cream paper, apricot rain deepening through terracotta to a deep-red core, sepia inks. */
77export const PAPER_INKS: Inks = {
78 paper: 0xffeedd,
79 radar: paperRadar,
80 line: linesOf(paperRadar, 0xeeddcc),
81 rain: paperRain,
82 rainLine: linesOf(paperRain, 0xeeddcc),
83 frame: 0xddccbb,
84 ink: 0x332211,
85 muted: 0x776655,
86 faint: 0xaa9988,
87 nameDry: 0xddccbb,
88 red: 0xaa3322,
89 redOnRain: 0x661111,
90 history: 0x993311,
91 eye: 0xffeedd,
92 onStorm: 0xffeedd,
93 changed: { fg: 0xffeedd, bg: 0xcc5533 },
94 untested: { fg: 0x776655, bg: 0xeeddcc },
95 expected: { fg: 0x774411, bg: 0xffdd99 },
96 unasked: { fg: 0xffeedd, bg: 0x332211 },
97 isNight: false,
98}
99
100/** The terminal's own near-black with the weather burning up through it: the same chart at night. */
101export const NIGHT_INKS: Inks = {
102 paper: 0x111111,
103 radar: nightRadar,
104 line: linesOf(nightRadar, 0x222222),
105 rain: nightRain,
106 rainLine: linesOf(nightRain, 0x222222),
107 frame: 0x333333,
108 ink: 0xeeddcc,
109 muted: 0xaa9988,
110 faint: 0x776655,
111 nameDry: 0x443333,
112 red: 0xff7755,
113 redOnRain: 0xffaa88,
114 history: 0x997744,
115 eye: 0xffffee,
116 onStorm: 0x111111,
117 changed: { fg: 0x111111, bg: 0xcc6633 },
118 untested: { fg: 0xaa9988, bg: 0x333333 },
119 expected: { fg: 0xddaa55, bg: 0x443311 },
120 unasked: { fg: 0x111111, bg: 0xeeddcc },
121 isNight: true,
122}
123
124/** How many radar bins every set of inks colours. */
125export const BINS = paperRadar.length
126
127/** The first bin of the storm proper: inks set on it turn to `onStorm`. */
128export const STORM = 10
129
130/** Bins every edit but the riskiest may reach: the top four belong to the lead storm. */
131export const COOL_TOP = 12
132
133/** Bins the reach's rain may reach: the brightest and deepest bins belong to the edit. */
134export const RAIN_TOP = 7
135
136/** How many colours the terminal paints: 24-bit, or xterm's 256. */
137export type Colors = 'truecolor' | '256'
138
139/** The six levels of each channel in xterm's colour cube. */
140const CUBE = [0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff]
141
142/** The colour xterm colour `i` (16 to 255) shows. */
143export function xtermColour(i: number): number {
144 if (i >= 232) return 0x010101 * (8 + 10 * (i - 232))
145 const k = i - 16
146
147 return (CUBE[Math.floor(k / 36)]! << 16) | (CUBE[Math.floor(k / 6) % 6]! << 8) | CUBE[k % 6]!
148}
149
150const distance = (p: number, q: number) => (((p >> 16) & 255) - ((q >> 16) & 255)) ** 2 + (((p >> 8) & 255) - ((q >> 8) & 255)) ** 2 + ((p & 255) - (q & 255)) ** 2
151
152/** The xterm colour nearest `c`: how the Raster paints `c` in 256 colours (0xbb7744 shows as 0xaf875f). */
153export function xtermOf(c: number): number {
154 let best = 16
155
156 for (let i = 17; i < 256; i++) if (distance(c, xtermColour(i)) < distance(c, xtermColour(best))) best = i
157 return best
158}
159
160/**
161 * The xterm colour Claude Code's own text takes for `c` in 256 colours: each channel rounds to
162 * one of six even steps of 0x33 (0xbb7744 shows as 0xd7875f), an even grey to the grey ramp.
163 */
164export function roundedXtermOf(c: number): number {
165 const [r, g, b] = [(c >> 16) & 255, (c >> 8) & 255, c & 255]
166
167 if (r === g && g === b) return r < 8 ? 16 : r > 248 ? 231 : Math.round(((r - 8) / 247) * 24) + 232
168 return 16 + 36 * Math.round(r / 51) + 6 * Math.round(g / 51) + Math.round(b / 51)
169}
170
171/** What a 256-colour terminal shows for `c` in the Raster. */
172export const shown256 = (c: number): number => xtermColour(xtermOf(c))
173
174// every 4-bit colour that lands on one xterm colour both ways, by that colour, the nearest to it first
175const LANDINGS = new Map<number, number[]>()
176
177for (let n = 0; n < 4096; n++) {
178 const c = 0x11 * (((n >> 8) << 16) | (((n >> 4) & 15) << 8) | (n & 15))
179 const i = xtermOf(c)
180
181 if (roundedXtermOf(c) === i) LANDINGS.set(i, [...(LANDINGS.get(i) ?? []), c])
182}
183for (const [i, cs] of LANDINGS) cs.sort((p, q) => distance(p, xtermColour(i)) - distance(q, xtermColour(i)))
184
185/**
186 * A 4-bit colour the terminal paints as xterm colour `i`; the `nth` gives another of them, so
187 * a ramp can repeat a colour across bins under names the sheet still tells apart.
188 */
189function xterm(i: number, nth = 0): number {
190 const cs = LANDINGS.get(i)
191
192 if (cs === undefined || cs[nth] === undefined) throw new Error(`no 4-bit colour lands on xterm ${i} (${nth})`)
193 return cs[nth]!
194}
195
196/** A ramp of xterm colours, each repeat under its next name. */
197const rampOf = (indices: readonly number[]) => indices.map((i, k) => xterm(i, indices.slice(0, k).filter(j => j === i).length))
198
199/** White paper, apricot rain deepening through terracotta to a deep-red core: the paper chart in xterm's colours. */
200const paperRadar256 = rampOf([231, 223, 223, 216, 216, 173, 173, 173, 167, 167, 167, 88, 88, 88, 88, 52, 52])
201/** Near-black with the weather burning up through red and amber: the night chart in xterm's colours. */
202const nightRadar256 = rampOf([233, 52, 52, 52, 88, 88, 88, 131, 131, 173, 173, 180, 180, 216, 216, 223, 223])
203/** The reach's sage rain in xterm's colours, on each ground. */
204const paperRain256 = rampOf([231, 194, 194, 151, 151, 108, 107, 107, 101, 101, 65, 64, 64, 58, 58, 22, 22])
205const nightRain256 = rampOf([233, 22, 22, 22, 58, 58, 65, 101, 101, 107, 107, 108, 151, 151, 194, 194, 194])
206
207export const PAPER_INKS_256: Inks = {
208 paper: paperRadar256[0]!,
209 radar: paperRadar256,
210 line: linesOf(paperRadar256, xterm(253), shown256),
211 rain: paperRain256,
212 rainLine: linesOf(paperRain256, xterm(253), shown256),
213 frame: xterm(253),
214 ink: xterm(236),
215 muted: xterm(243),
216 faint: xterm(248),
217 nameDry: xterm(253),
218 red: xterm(124),
219 redOnRain: xterm(52),
220 history: xterm(130),
221 eye: xterm(231),
222 onStorm: xterm(231),
223 changed: { fg: xterm(231), bg: xterm(167) },
224 untested: { fg: xterm(243), bg: xterm(253) },
225 expected: { fg: xterm(94), bg: xterm(222) },
226 unasked: { fg: xterm(231), bg: xterm(236) },
227 isNight: false,
228}
229
230export const NIGHT_INKS_256: Inks = {
231 paper: nightRadar256[0]!,
232 radar: nightRadar256,
233 line: linesOf(nightRadar256, xterm(235), shown256),
234 rain: nightRain256,
235 rainLine: linesOf(nightRain256, xterm(235), shown256),
236 frame: xterm(236),
237 ink: xterm(253),
238 muted: xterm(248),
239 faint: xterm(243),
240 nameDry: xterm(238),
241 red: xterm(209),
242 redOnRain: xterm(216),
243 history: xterm(137),
244 eye: xterm(231),
245 onStorm: xterm(233),
246 changed: { fg: xterm(233), bg: xterm(173) },
247 untested: { fg: xterm(248), bg: xterm(236) },
248 expected: { fg: xterm(179), bg: xterm(236) },
249 unasked: { fg: xterm(233), bg: xterm(253) },
250 isNight: true,
251}
252
253/** The inks for a ground on a terminal that paints `colors`. */
254export const inksOf = (ground: 'paper' | 'night', colors: Colors): Inks =>
255 colors === '256' ? (ground === 'night' ? NIGHT_INKS_256 : PAPER_INKS_256) : ground === 'night' ? NIGHT_INKS : PAPER_INKS
256
257/**
258 * How many colours Claude Code paints for a terminal's environment, decided as it decides:
259 * 256 inside tmux; 24-bit where COLORTERM is truecolor or the terminal is kitty, Ghostty or
260 * iTerm; 256 everywhere else.
261 */
262export function colorsOf(env: Readonly<Record<string, string | undefined>>): Colors {
263 if (env.TMUX) return '256'
264 return env.COLORTERM === 'truecolor' || env.TERM === 'xterm-kitty' || env.TERM === 'xterm-ghostty' || env.TERM_PROGRAM === 'iTerm.app' ? 'truecolor' : '256'
265}
266