SLOPSHOPPER

isobar

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…

newpaneguardcommandtoastmodel
v0.3.0MITupdated 2026-10-08r3al1tym/isobar
A shopper browsing a rack in a slop shop
Preview could not run: harness produced no result (Bun v1.3.11 (af24e281) macOS Silicon macOS v26.2 CPU: fp aes crc32 atomics Args: "bun" "run" "/Users/raymondxu/slopshopper-mods/scripts/harness/run-one.ts" "/Users/raymondx
README

isobar

License: MIT version Claude Code mod CI

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.

Claude Code with the isobar pane docked on the right, over a map of Flask. Over two turns, Claude changed get_debug_flag in helpers.py and added a CHANGES.rst entry, then changed Flask.make_response in app.py. The latest edit burns deepest: the caption under app assembly reads names endpoint in error, the edit's note reads new behaviour · 11 uses in 3 files, green rain marks the files that use it, and a dotted track runs to test_basic.py two hops away. The first turn's edits have faded, captioned treats off as false under utilities & debugging and notes the off debug value under project tooling, where CHANGES.rst carries UNASKED because no request mentioned the changelog

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.

Quick start

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
  1. Start a new claude session in your repository. A session that was already running does not load the mod.
  2. Ask Claude for a change, or start in a tree that already has uncommitted changes. The first time in a repository, isobar draws its map with one model call, which takes under a minute. Then the pane opens beside the conversation, once the terminal is 144 columns or wider: at session start on a tree with changes, otherwise on Claude's first edit.
  3. At any width, /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.

What it shows

  • Where the change landed. A storm on each changed file, in its region of the map. You see at a glance whether the work landed where you expected.
  • What it did there (the gist). Once a turn ends, and when a session starts on a change, 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.
  • What it reaches. Green rain on the files that use what changed. isobar reads each edit declaration by declaration: a new signature or a new behaviour rains on the files that name it, and an edit to comments or imports stays dry. The note beside the riskiest edits (two at most, unasked ones first) says how far each reaches, as in new behaviour · 11 uses in 3 files.
  • This turn against the session. The latest turn's edits burn brightest and earlier turns fade, so a long session still reads in one look.
  • What you never asked for. Turn on the scope check and, after each turn that changes 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.
  • The same frame every time. Each repository is cut into named regions once and kept as its basemap. Every change lands on the same map, so you learn to read a change by where it falls.

Reading the map

The pane with its parts called out: the title, the storm on the edited files, badges on region names, the caption under a changed region, the edit's note, rain on the files that use the change, the dotted track to the farthest file, an earlier turn's faded edit, an unasked edit, and the map's regions, sized by code and by how much depends on them

  • Title. The repository, then how many files the latest turn changed against the rest of the session (1 this turn · 2 earlier), or last commit <hash> when nothing is uncommitted.
  • Storm. Each edited file is an eye in its region (up to eight, latest turn and riskiest first). The storm is deepest where the change is riskiest: a big edit to widely used code with no test moved beside it. Only the latest turn's riskiest edit reaches the deepest reds.
  • Badges. The region's name carries 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.
  • Caption. Under a changed region's name and badges, what the change did there, from the gist.
  • Note. The edited file and the declaration it touched, then how it reaches: 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.
  • Rain. The files that use what changed, in sage green, fading with import distance.
  • Track. The farthest file the change reaches outside the regions it sits in, along its real import chain, labelled 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.
  • Earlier turn. An earlier turn's edits fade to a light shower with a small eye, so the latest turn reads first.
  • Unasked. An edit the scope check flagged says why in its note, as in unasked: changelog entry.
  • The map. Regions named once per repository, sized by their code and by how much depends on them. A region the weather reached is named in ink; a dry region's name is a faint inscription.

Keys and commands

  • /isobar opens and focuses the pane, or closes it when it is open. /isobar map redraws the basemap.
  • 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.
  • An Edit or Write in another repository switches the pane to that repository's map; a Bash command keeps the map it has.
  • A phone, the desktop app or VS Code attached to a terminal session shows a text summary instead of the map: the headline, each changed region's caption, and a few lines on the reach, untested edits and flags.

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:

Three repositories mapped by isobar: Excalidraw, Vite and Flask, each a different arrangement of named regions with the weather of a recent commit

Install and setup

Requirements

  • Claude Code 2.1.287 or later. Check with 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.
  • An interactive session in a terminal. isobar stays off in claude -p and Agent SDK sessions.
  • git, and a git repository. Outside one, the pane says there is no change to map. In a repository with no commits yet, staged and new files are read against an empty tree, so every line counts as added.
  • Linux, macOS or WSL. Native Windows is untested; run Claude Code inside WSL.
  • Best with a terminal 144 columns or wider, so the pane opens by itself, and 24-bit colour for the warm paper (see Settings). Once you have opened the pane with /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.

Install

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.

  • Marketplace (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.
  • A clone, in every session (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"
  • One session only (isobar@inline): claude --plugin-dir ~/src/isobar, on a clone made with the git clone line above.

Verify

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.

Share one map with your team

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.

Update and uninstall

  • Update: 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).
  • Uninstall: 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.
  • Kept maps stay in ~/.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.

Troubleshooting

  • No pane. Start a new session (one open before the install never loads it), widen to 144 columns or run /isobar, and check that panel is not command.
  • Digits land in the prompt. The pane is not focused: press ctrl+x then Tab.
  • White or yellow paper. See colors under Settings.
  • No captions. 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.

Settings

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.

SettingValuesWhat it does
giston (default), offAsk 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.
scopeoff (default), onAfter 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.
smallModelsonnet (default), any model alias or idThe model the gist and the scope check ask.
mapModelempty (default), any model alias or idThe model that names the basemap's regions, once per repository; empty uses the model the session runs on.
groundpaper (default), night, autoWarm paper, the terminal's near-black, or whichever matches your Claude Code theme.
colorsauto (default), truecolor, 256How 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.
panelauto (default), commandOpen the pane once the repository has uncommitted changes (at session start or on the session's first edit), or only on /isobar.

The same change printed three ways: on warm paper, on the night ground where the storm burns amber, and in a 256-colour terminal on white paper

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.

How it works

  1. Facts from git. On each refresh isobar runs read-only git calls in parallel: the current commit, the line count of every tracked text file, every import line in JavaScript, TypeScript and Python, the last 400 non-merge commits, and the 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.
  2. The import graph. Import lines resolve to repository files: relative specifiers with extension and 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.
  3. The change, declaration by declaration. The uncommitted diff against HEAD (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.
  4. Its users. One 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.
  5. The session. Each refresh hashes the changed files (git hash-object, which stores nothing), so isobar knows which turn last changed each one.
  6. The gist. When a turn ends, and whenever else a changed region has no caption while no turn runs (at session start, or on the last commit), one call to 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.
  7. The scope check (opt-in). When a turn ends with an answer, one call to 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.
  8. The map. Once per repository, isobar cuts the tree into about 160 units (big source folders opened to their files, the rest taken a folder at a time) and asks the session's model, or 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.
  9. The picture. The storm and the rain are density fields over the map's layout, binned into 17 colour steps that move evenly in a perceptual colour space: the storm in reds, the rain in sage greens wherever it outweighs a storm, as a radar colours storm and rain. They are drawn as one grid of half-block characters.

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.

Performance

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.

RepositoryText filesOne refresh
express21133 ms
flask23067 ms
excalidraw1,018298 ms
vite2,736107 ms
django5,675437 ms
VS Code19,5471.8 s
  • Most of a refresh is git. Reading the change by declaration took a median of 29 to 276 ms over 30 to 60 recent commits per repository (1.3 s at VS Code's 95th percentile), and the weather and the drawing together under 25 ms everywhere but VS Code (300 ms).
  • Nothing waits on it. Refreshes run in the background, one at a time, and the conversation goes on while they do.
  • The basemap is one model call per repository: 36 s for Django and 48 s for Vite on Opus. While it runs, an open pane says it is drawing the map.
  • The gist and the scope check are one small call each after a turn ends, run side by side, a few seconds on Sonnet, and never hold up a refresh.

Accuracy

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.

  • The import graph matches the TypeScript compiler and grimp: recall 0.998 to 1.0 on all six, precision 0.92 to 1.0. Most of isobar's extra edges are real dependencies the reference leaves out: from pkg import submodule also runs pkg/__init__.py, and the compiler cannot resolve a workspace package without installed node_modules.
  • The rain. Over 300 recent commits (60 per repository, 30 for VS Code and Django), the files isobar rains on were checked against TypeScript's 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.
  • Less rain. The median commit rains on 0 files in express (97 when every importer within three hops is marked), 9 in Excalidraw (352), 1 in Vite (137), 12 in VS
Source 18 files
hooks/register.tsx 639 lines
1import { 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}
639
hooks/engine/basemap.ts 425 lines
1import { 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}
425
hooks/engine/git.ts 201 lines
1import { 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}
201
hooks/engine/gist.ts 68 lines
1import 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}
68
hooks/engine/graph.ts 122 lines
1import 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}
122
hooks/engine/scope.ts 135 lines
1import { 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}
135
hooks/engine/session.ts 60 lines
1import 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}
60
hooks/engine/symbols.ts 655 lines
1import { 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}
655
hooks/engine/types.ts 61 lines
1/** 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}
61
hooks/engine/weather.ts 310 lines
1import { 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}
310
hooks/render/field.ts 278 lines
1import 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}
278
hooks/render/palette.ts 266 lines
1/**
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