The /visual-review command shows the design of the changes against a base branch as a diagram in the browser: classes and modules with their relations, size…

<h1 align="center">🗺️ visual-review</h1>
A Claude Code mod that shows the design of a change as a diagram in the browser, so you review the classes, relations and tables before you read the diff.
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fdougcunha%2Fclaude-mods%2Fmain%2Fvisual-review%2F.claude-plugin%2Fplugin.json&query=%24.version&label=version&color=blue" alt="Version"> <a href="../LICENSE"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License: MIT"></a> <img src="https://img.shields.io/badge/Claude%20Code-2.1.308-d97757.svg" alt="Tested on Claude Code 2.1.308">
<a href="#-install">Install</a> · <a href="#-use">Use</a> · <a href="#-settings">Settings</a> · <a href="#-limitations">Limitations</a> · <a href="#-development">Development</a>
At the end of a task, the diff shows the implementation, not the design. /visual-review collects the changes of your branch against a base and asks the session to analyze them:
Analyzing the design of <n> changed files against <base>. A status line under the prompt reads Analyzing the design of the change….analysisModel setting) that reads the diff and sends a design model to the mod's submit_model tool: the classes and modules, their relations, size and quality notes, and the database tables..cs, .ts, .tsx, .js, .mjs, .cjs, .py, .sql, .prisma) is covered, every relation names a unit of the model, and each unit's line range starts at its name. Otherwise the tool result lists the gaps, and the subagent sends the model again.git diff -U0), not from the model: each unit gets the lines added and removed inside its range, each file the lines outside every unit, and each unit its Ce (the units it depends on) and Ca (the units that depend on it) from the relations. It writes the view model to <temp>/claude-visual-review/<session id>/view.json.server/server.mjs, no package) on 127.0.0.1 and opens the page in your default browser. A toast reads visual-review: Visual review ready: <link>. When you run /visual-review again with the page open, the open tab shows the new model and no new tab opens.node is not on the PATH, or the server stops before it gives its link, the mod writes review.html and view.js next to view.json and opens that file in the browser. A toast says why, for example visual-review: Node.js not found. The page is static: use Copy review to send the comments. Page: <link>.dependency · 2 relations (+1 =1); open the container to comment on one of them.+ added, ~ modified, − deleted, ctx an unchanged neighbor, in gray), the name and kind, the total lines, the lines added and removed, the number of methods, C 1–C 5 cohesion with its reason as a tooltip, Ce and Ca, the summary, and the changed members with their state. A card whose file could not be read for the range check says so.added, removed, unchanged).nvarchar(100) → nvarchar(200)), the primary key and the foreign keys. A dotted line links an entity unit to its table.Review handed to the session, then Review sent. once the prompt entered. When nothing is new, or a hook drops the prompt, the page says why and keeps your comments./visual-review on the same branch, the comments of earlier rounds show with an Open or Resolved switch; a status change goes with the next submit. A comment whose element is gone, such as a comment on a deleted method, shows under Unanchored.+3 ~4 −2), fitted to the window. 1 arrow joins 2 boxes for all their relations; its width grows with the count, and its tooltip names the kinds. Click a box to open it.uses, extended by, owns, maps to table) with a link to the row of the other unit. The rows take comments like the cards.− and + zoom, Fit shows the whole graph in the window, and Ctrl+wheel (or a touchpad pinch) zooms at the pointer.The subagent can run in the background, so the analysis turn can end before the model arrives: the run waits for the subagent. When the subagent ends with no accepted model, or the analysis turn ends with no subagent started, the status clears and a toast says why, for example visual-review: the design analysis failed: the turn was interrupted with no design model. Run /visual-review again.
At the Claude Code prompt:
/plugin install visual-review --marketplace dougcunha/claude-mods
Answer y to add the marketplace and choose a scope. If you already added the marketplace:
/plugin install visual-review@claude-mods
The interactive page needs Node.js on the PATH. The mod installs no package. Without Node.js, the mod opens a static page with the same diagram, and you send the review with Copy review.
| Command | Base |
|---|---|
/visual-review | The merge-base of HEAD and the main branch: the target of origin/HEAD, else main, else master |
/visual-review <ref> | Any Git reference, for example HEAD~3 or release/2.0 |
The changes are the committed, staged, unstaged and untracked (not ignored) files against the base. The command waits until the current turn ends, and it never runs by itself.
The command stops with a message when:
visual-review needs a Git repository.No changes against <base>.Unknown Git reference: <ref>.HEAD has no common commit with it: name a base with /visual-review <ref>.Set them in /config, under the mod's name.
| Option | Default | What it does |
|---|---|---|
analysisModel | sonnet | The model of the subagent that reads the diff and returns the design model: sonnet, opus, haiku, or inherit for the model of the session. |
submit_model waits behind ToolSearch, so it costs no context until a run asks the subagent to load it./visual-review replaces the run that waits; the subagent of the old run gets a result that tells it to stop. /clear cancels the run that waits.node on the PATH. Without it, or when the server stops before it gives its link, the page is static: the same diagram and comment forms, but no Submit review. Copy review copies the review text, and you paste it in the prompt. The static page keeps its comments only in that browser, does not update when you run /visual-review again (each run opens a new page), and does not send status changes to the mod's store.127.0.0.1 only, on a free port, and answers only a request that carries the secret token of the link and the Host 127.0.0.1:<port>; any other request gets HTTP 403. The page loads nothing from the internet. The link goes to you in the toast, never to the model./clear, the end of the session and /reload-plugins stop it within a second; the next run starts a new one and opens a new tab./clear and a restart. When the mod's keys pass 3 MiB, the review with the oldest change goes first. Uninstalling the mod leaves the keys; they do nothing without it.view.json (and view.js for the static page) holds the unit summaries and cohesion reasons of the analysis, in the session's temporary folder only. The page needs them, and the system cleans the folder; the mod has no way to delete a file.open) and Linux (xdg-open) is not tested yet.git diff --no-index run, so its line counts come from Git like the tracked files.claude plugin validate .
claude plugin test .
claude --plugin-dir .
| File | Role |
|---|---|
hooks/register.tsx | The command and the tool, the command.run, tool.call, turn.start and turn.complete hooks |
hooks/git.ts | The repository, the base, the changed files and their parsers |
hooks/model.ts | The submit_model schema, the narrowing of its input, and the coverage, id and range checks |
hooks/metrics.ts | Unit line deltas from the hunks, the lines outside the units, Ce and Ca |
hooks/view.ts | The view model: cards, containers, relations, tables, entity links and other files, and its file |
hooks/prompts.ts | The analysis prompt, the review prompt, the tool description, and the results and toasts the mod sends |
hooks/comments.ts | The comment store: key, narrowing, merge, rounds, anchors and pruning |
hooks/server.ts | The server and browser argv, the line buffer and the parser of the server's lines, the page mode, and the static page |
server/server.mjs | The page server: Node.js built-ins only, token and Host checks, the view model and its updates |
web/ | The page; web/review-text.js builds the text of Copy review, the same as the review prompt |
hooks/paths.ts | Path helpers that normalize separators, and the session folder |
hooks/host.ts | The engine calls the helper modules use |
types/index.d.ts | The $.state contract |
PRODUCT.md, DESIGN.md | The product record and the design system of the page: tokens, components and the rules a change to web/ keeps |
MIT. The page ships elkjs 0.9.3 (web/vendor/elk.bundled.js), the Eclipse Layout Kernel, under the EPL-2.0.
hooks/register.tsx 385 lines1import { atom, read, update } from 'claude-code'
2import type { CommandSpec, EngineInterface, HookFailure, Register, ToolSpec } from 'claude-code'
3
4import type { PendingRun } from '../types'
5
6import type { Host } from './host'
7import { changedFiles, collect, commandText } from './git'
8import type { Collection } from './git'
9import { checkModel, checkRanges, narrowModel, pathKey, SUBMIT_SCHEMA } from './model'
10import { browserArgv, pageModeOf, parseServerLine, platformOf, serverArgv, splitLines, writeStaticPage } from './server'
11import { fileUrl } from './paths'
12import { isViewModel, VIEW_FILE, withComments, writeView } from './view'
13import type { ViewNotice } from './view'
14import { isPageBehind, loadRecord, mergeComments, reviewOf, saveRecord, storeKey, submitRecord } from './comments'
15import type { ReviewRecord } from './comments'
16import {
17 ACCEPTED_RESULT,
18 ANALYZING_STATUS,
19 BROWSER_FAILED_TOAST,
20 NOTHING_TO_SEND_NOTICE,
21 NOTHING_TO_SEND_TOAST,
22 reviewDroppedNotice,
23 reviewDroppedToast,
24 reviewPrompt,
25 readyToast,
26 serverFailedToast,
27 NODE_MISSING_CAUSE,
28 STATIC_RESULT,
29 staticToast,
30 TOOL_MISSING_TEXT,
31 analysisModelOf,
32 analysisPrompt,
33 droppedToast,
34 gapsResult,
35 NO_RUN_RESULT,
36 noModelToast,
37 REPLACED_RESULT,
38 SUBMIT_DESCRIPTION,
39 SUBMIT_NAME,
40 SUBMIT_TOOL,
41} from './prompts'
42import type { AnalysisModel } from './prompts'
43
44/** The longest a Git command may run; a larger repository still answers well within it. */
45const GIT_TIMEOUT_MS = 30_000
46
47/** The wait between the command's answer and the analysis prompt; any later event is enough, so it is the shortest. */
48const SUBMIT_DELAY_MS = 0
49
50/** The longest the page server may take to print its URL; Node.js starts in well under a second. */
51const READY_TIMEOUT_MS = 10_000
52
53/** The most characters of the server's first error line that a toast shows. */
54const SERVER_ERROR_CHARS = 300
55
56/** The slash command: it waits for the current turn to end, because a review of work in progress makes no sense (DEC-10). */
57export const COMMAND: CommandSpec = {
58 name: 'visual-review',
59 description: 'Open a visual design review of the changes against a base branch or reference',
60 argumentHint: '[base]',
61}
62
63/** The tool the analysis subagent sends the design model to; behind ToolSearch, so it costs nothing until a run asks for it. */
64export const SUBMIT_SPEC: ToolSpec = { name: SUBMIT_NAME, description: SUBMIT_DESCRIPTION, inputSchema: SUBMIT_SCHEMA, isDeferred: true }
65
66const pendingRun = atom({ plugin: 'visual-review', key: 'pendingRun' } as const, null)
67
68const causeOf = (failure: HookFailure): string => failure.message ?? (failure.kind === 'timeout' ? 'it took too long' : 'unknown error')
69
70function hostOf($: EngineInterface): Host {
71 return {
72 run: (argv, cwd) => $.process.run(argv, cwd === undefined ? { timeoutMs: GIT_TIMEOUT_MS } : { cwd, timeoutMs: GIT_TIMEOUT_MS }),
73 cwd: () => $.session.cwd(),
74 readFile: path => $.fs.read(path),
75 writeFile: (path, text) => $.fs.write(path, text),
76 env: async () => ({ temp: await $.env.get('TEMP'), tmpdir: await $.env.get('TMPDIR'), os: await $.env.get('OS') }),
77 sessionId: () => $.session.id(),
78 storeGet: key => $.store.get(key),
79 storeSet: (key, value) => $.store.set(key, value),
80 storeKeys: () => $.store.keys(),
81 storeDelete: key => $.store.delete(key),
82 }
83}
84
85/** The page server of this module life. A module value by design: the child dies with the module, so neither can outlive the other. */
86type PageServer = { folder: string; url: string | null; clients: number; isStopped: boolean; review: ReviewTarget; queue: Promise<void> }
87
88/** The project and branch whose comments the page edits, and the base the review prompt names. */
89type ReviewTarget = { root: string; branch: string; baseLabel: string }
90
91let pageServer: PageServer | null = null
92
93/** Asks the running page server to stop; its read loop breaks at the next line, a tick at most a second later. */
94function stopServer(): void {
95 if (pageServer !== null) pageServer.isStopped = true
96 pageServer = null
97}
98
99/** Starts the page server for `folder` and resolves once it printed its URL; rejects when it cannot start or ends first. */
100function startServer($: EngineInterface, folder: string, review: ReviewTarget): Promise<PageServer & { url: string }> {
101 const handle: PageServer = { folder, url: null, clients: 0, isStopped: false, review, queue: Promise.resolve() }
102 pageServer = handle
103
104 return new Promise((resolve, reject) => {
105 $.clock.after(READY_TIMEOUT_MS, () => {
106 if (handle.url !== null) return
107 handle.isStopped = true
108 reject(new Error('the page server did not start in time'))
109 })
110 // The loop runs on after the hook returns: it is the child's life, and it ends with the module.
111 void (async () => {
112 let rest = ''
113 let failure = 'the page server ended'
114 try {
115 for await (const chunk of $.process.spawn({ argv: serverArgv($.plugin.root, folder) })) {
116 if (handle.isStopped) break
117 if (chunk.stream === 'stderr') {
118 failure = chunk.text.trim().split('\n')[0]?.slice(0, SERVER_ERROR_CHARS) ?? failure
119 continue
120 }
121 const split = splitLines(rest, chunk.text)
122 rest = split.rest
123 for (const message of split.lines.map(parseServerLine)) {
124 if (message?.type === 'ready') {
125 handle.url = message.url
126 resolve({ ...handle, url: message.url })
127 }
128 if (message?.type === 'clients') handle.clients = message.count
129 if (message?.type === 'error') $.ui.toast(serverFailedToast(message.message))
130 // One at a time and in order, so a submit sees the comments the page sent before it.
131 if (message?.type === 'comments') enqueue($, handle, () => saveComments($, handle, message.comments))
132 if (message?.type === 'submit') enqueue($, handle, () => submitReview($, handle))
133 }
134 }
135 } catch (error) {
136 failure = error instanceof Error ? error.message : String(error)
137 }
138 if (pageServer === handle) pageServer = null
139 reject(new Error(failure))
140 })()
141 })
142}
143
144/** Starts the browser on the page; the launcher's own output is not read, and a launcher that fails says so in a toast. */
145async function openBrowser($: EngineInterface, url: string): Promise<void> {
146 const argv = browserArgv(await platformOf(hostOf($)), url)
147 // Reading to the end lets the launcher finish; leaving the loop early would kill it.
148 void (async () => {
149 for await (const chunk of $.process.spawn({ argv })) void chunk
150 })().catch(() => $.ui.toast(BROWSER_FAILED_TOAST))
151}
152
153/**
154 * Serves the session folder and shows the page: a running server for the same folder pushes the new model to its open
155 * tab, and the browser opens only when no tab is open (FR-21). A server of an older folder (after a /clear) is replaced.
156 * Resolves with the cause instead when Node.js is missing or the server ends before it prints its URL (FR-24).
157 */
158async function openPage($: EngineInterface, folder: string, review: ReviewTarget): Promise<{ url: string; isNewTab: boolean } | { cause: string }> {
159 const running = pageServer
160 if (running !== null && running.folder === folder && running.url !== null && !running.isStopped) {
161 running.review = review
162 const isNewTab = running.clients === 0
163 if (isNewTab) await openBrowser($, running.url)
164
165 return { url: running.url, isNewTab }
166 }
167 stopServer()
168 // A missing node is a normal case: the run fails or cannot start, and both read as static.
169 const node = await hostOf($)
170 .run(['node', '--version'])
171 .catch(() => null)
172 if (pageModeOf(node) === 'static') return { cause: NODE_MISSING_CAUSE }
173 const started = await startServer($, folder, review).catch((error: unknown) => ({ cause: `the page server failed: ${error instanceof Error ? error.message : String(error)}` }))
174 if ('cause' in started) return started
175 await openBrowser($, started.url)
176
177 return { url: started.url, isNewTab: true }
178}
179
180/** Writes the static page of the view model next to `view.json` and opens it; the toast says why the page is static. */
181async function openStaticPage($: EngineInterface, written: { folder: string; json: string }, cause: string): Promise<string> {
182 const page = await writeStaticPage(hostOf($), { pluginRoot: $.plugin.root, folder: written.folder, viewJson: written.json })
183 const url = fileUrl(page)
184 await openBrowser($, url)
185 $.ui.toast(staticToast({ cause, url }))
186
187 return STATIC_RESULT
188}
189
190/** Runs the work of a server line after the work of the lines before it; a failure says so in a toast and the queue goes on. */
191function enqueue($: EngineInterface, handle: PageServer, work: () => Promise<void>): void {
192 handle.queue = handle.queue
193 .then(work)
194 .catch((error: unknown) => $.ui.toast(`visual-review: the review could not be saved: ${error instanceof Error ? error.message : String(error)}`))
195}
196
197/**
198 * Writes the view model of `folder` again with the stored comments and the notice, so the open page shows them; a
199 * missing view is left.
200 */
201async function refreshView(host: Host, { folder, record, notice = null }: { folder: string; record: ReviewRecord; notice?: ViewNotice | null }): Promise<void> {
202 const path = `${folder}/${VIEW_FILE}`
203 const view = await host.readFile(path).then(text => JSON.parse(text) as unknown, () => null)
204 if (isViewModel(view)) await host.writeFile(path, JSON.stringify(withComments(view, record, notice)))
205}
206
207/**
208 * Saves the comment set the page sent (DEC-04). The page already shows it, so the view model is written again only when
209 * the page is behind the store: a submit made its drafts open while the set was on its way.
210 */
211async function saveComments($: EngineInterface, handle: PageServer, comments: readonly unknown[]): Promise<void> {
212 const host = hostOf($)
213 const key = storeKey(handle.review.root, handle.review.branch)
214 const saved = mergeComments(await loadRecord(host, key), comments, await $.clock.now())
215 await saveRecord(host, key, saved)
216 if (isPageBehind(comments, saved)) await refreshView(host, { folder: handle.folder, record: saved })
217}
218
219/**
220 * Sends the review (FR-19, FR-20): 1 prompt with the drafts and the status changes, which waits for the idle session;
221 * once it entered, the drafts become open comments of a new round and the page gets them through view.json.
222 */
223async function submitReview($: EngineInterface, handle: PageServer): Promise<void> {
224 const host = hostOf($)
225 const key = storeKey(handle.review.root, handle.review.branch)
226 const record = await loadRecord(host, key)
227 const { drafts, changed } = reviewOf(record)
228 // The page waits for the outcome: a new round means sent, a notice says why nothing went to the session.
229 if (drafts.length === 0 && changed.length === 0) {
230 $.ui.toast(NOTHING_TO_SEND_TOAST)
231
232 return refreshView(host, { folder: handle.folder, record, notice: { text: NOTHING_TO_SEND_NOTICE, at: await $.clock.now() } })
233 }
234 const sent = await $.prompt.submit({ text: reviewPrompt({ baseLabel: handle.review.baseLabel, drafts, changed }) })
235 if (sent.drop !== undefined) {
236 $.ui.toast(reviewDroppedToast(sent.drop))
237
238 return refreshView(host, { folder: handle.folder, record, notice: { text: reviewDroppedNotice(sent.drop), at: await $.clock.now() } })
239 }
240 const submitted = submitRecord(record, await $.clock.now())
241 await saveRecord(host, key, submitted)
242 await refreshView(host, { folder: handle.folder, record: submitted })
243}
244
245/** Ends the run that waits, if it is still `runId`: clears the state and the status, and says why in a toast when given. */
246async function endRun($: EngineInterface, runId: string, toast?: string): Promise<void> {
247 let isEnded = false
248 await update($, pendingRun, run => {
249 isEnded = run?.runId === runId
250
251 return isEnded ? null : run
252 })
253 if (!isEnded) return
254 $.ui.status(undefined)
255 if (toast !== undefined) $.ui.toast(toast)
256}
257
258/** Saves the run, shows the status and submits the analysis prompt; the prompt enters once the session is idle. */
259async function startRun($: EngineInterface, collection: Collection, model: AnalysisModel): Promise<void> {
260 const runId = `vr-${(await $.clock.now()).toString(36)}`
261 const run: PendingRun = { runId, base: collection.base, baseLabel: collection.baseLabel, root: collection.root, branch: collection.branch, files: collection.files.map(file => file.path) }
262 // The prompt is built before the run is saved, so the state write is the last step that can fail.
263 const prompt = analysisPrompt({ runId, root: collection.root, base: collection.base, baseLabel: collection.baseLabel, files: collection.files, model })
264 await update($, pendingRun, () => run)
265 $.ui.status(ANALYZING_STATUS)
266 // The engine refuses a prompt.submit from a command.run hook, which would wait on the turn the hook holds, so a timer
267 // submits it once the hook has answered; the prompt then waits for the idle session.
268 $.clock.after(SUBMIT_DELAY_MS, () =>
269 void $.prompt
270 .submit({ text: prompt })
271 .then(result => (result.drop === undefined ? undefined : endRun($, runId, droppedToast(result.drop))))
272 .catch((error: unknown) => endRun($, runId, droppedToast(error instanceof Error ? error.message : String(error))))
273 .catch(() => undefined),
274 )
275}
276
277/** Checks a `submit_model` call against the run that waits, and answers the subagent with the gaps or the acceptance. */
278async function submitModel($: EngineInterface, input: unknown): Promise<string> {
279 const run = await read($, pendingRun)
280 if (run === null) return NO_RUN_RESULT
281 const narrowed = narrowModel(input)
282 if (narrowed.value.runId !== run.runId) return REPLACED_RESULT
283 const gaps = [...narrowed.problems, ...checkModel(narrowed.value, run.files)]
284 if (gaps.length > 0) return gapsResult(gaps)
285 const host = hostOf($)
286 // The run keeps only the paths, so the renames come from Git again: a renamed file's base side is at its old path.
287 const changed = await changedFiles(host, run.root, run.base)
288 const oldPaths = new Map(Array.isArray(changed) ? changed.flatMap(file => (file.oldPath === undefined ? [] : [[pathKey(file.path), pathKey(file.oldPath)] as const])) : [])
289 const ranges = await checkRanges(host, { root: run.root, base: run.base, oldPaths }, narrowed.value.units)
290 if (ranges.gaps.length > 0) return gapsResult(ranges.gaps)
291 const written = await writeView(host, { run, model: narrowed.value, unchecked: ranges.unchecked })
292 if ('cause' in written) return `visual-review failed: ${written.cause}`
293 await endRun($, run.runId)
294 const page = await openPage($, written.folder, { root: run.root, branch: run.branch, baseLabel: run.baseLabel })
295 if ('cause' in page) return openStaticPage($, written, page.cause)
296 // The URL carries the page's token, so it goes to the person in a toast, not to the model in the result.
297 $.ui.toast(readyToast(page))
298
299 return ACCEPTED_RESULT
300}
301
302export const register: Register = (on, options) => {
303 const model = analysisModelOf((options as Record<string, unknown>).analysisModel)
304 // Whether the last session start failed to register the tool; a reload registers again and sets it again.
305 let isToolMissing = false
306
307 // An observer: the command and the tool register at the start of each session, and a reload registers them again.
308 on('session.start', async ($, e, next) => {
309 await $.command.register(COMMAND)
310 isToolMissing = await $.tool.register(SUBMIT_SPEC).then(
311 () => false,
312 (error: unknown) => ($.ui.toast(`visual-review: the submit_model tool did not register: ${String(error)}`), true),
313 )
314
315 return next(e)
316 }).catch(($, e, next) => next(e))
317
318 // The person waits for the answer, so a failure answers with its cause instead of skipping. A run that failed after it
319 // was saved is cleared here, because the `.catch` handler answers from `next.error` alone and cannot call `$`.
320 on('command.run', { command: COMMAND.name }, async ($, e) => {
321 if (isToolMissing) return { text: TOOL_MISSING_TEXT }
322 const result = await collect(hostOf($), e.args.trim())
323 if (!result.isOk) return { text: commandText(result) }
324 try {
325 await startRun($, result.value, model)
326 } catch (error: unknown) {
327 await update($, pendingRun, () => null).catch(() => undefined)
328 $.ui.status(undefined)
329
330 return { text: `visual-review: ${error instanceof Error ? error.message : String(error)}` }
331 }
332
333 return { text: commandText(result) }
334 }).catch(($, e, next) => ({ text: `visual-review: ${causeOf(next.error)}` }))
335
336 // The subagent waits for a result, so a failure answers with its cause and the subagent stops; the call is never denied.
337 on('tool.call', { tool: SUBMIT_TOOL }, async ($, e) => ({ result: await submitModel($, e) })).catch(($, e, next) => ({
338 result: `visual-review failed: ${causeOf(next.error)}`,
339 }))
340
341 // An observer: the main-loop turn whose prompt carries the run id is the analysis turn.
342 on('turn.start', async ($, e, next) => {
343 if (e.agentId !== undefined) return next(e)
344 const run = await read($, pendingRun)
345 if (run !== null && run.analysisTurnId === undefined && e.text.includes(run.runId))
346 await update($, pendingRun, current => (current?.runId === run.runId ? { ...current, analysisTurnId: e.turnId } : current))
347
348 return next(e)
349 }).catch(($, e, next) => next(e))
350
351 // An observer that never denies: the main-loop spawn whose prompt carries the run id is the analysis subagent. The
352 // Agent tool runs it in the background, so the analysis turn can end before the subagent sends its model. A failure
353 // after the spawn is kept inside, so the `.catch` handler never spawns a second subagent.
354 on('agent.spawn', async ($, e, next) => {
355 const run = e.parentAgentId === undefined ? await read($, pendingRun) : null
356 const result = await next(e)
357 const agentId = result.agentId
358 if (run === null || run.analysisAgentId !== undefined || agentId === undefined || !e.prompt.includes(run.runId)) return result
359 await update($, pendingRun, current => (current?.runId === run.runId ? { ...current, analysisAgentId: agentId } : current)).catch(() => undefined)
360
361 return result
362 }).catch(($, e, next) => next(e))
363
364 // An observer: the run ends with no accepted model (FR-25) when its subagent ends while the run still waits, or when
365 // the analysis turn ends with no subagent started. An interrupted analysis turn leaves a background subagent running,
366 // so the run waits for that subagent.
367 on('turn.complete', async ($, e, next) => {
368 const run = await read($, pendingRun)
369 const isAnalysisTurn = e.agentId === undefined && run?.analysisTurnId === e.turnId && run.analysisAgentId === undefined
370 const isAnalysisAgent = e.agentId !== undefined && run?.analysisAgentId === e.agentId
371 if (run !== null && (isAnalysisTurn || isAnalysisAgent)) await endRun($, run.runId, noModelToast(e.reason))
372
373 return next(e)
374 }).catch(($, e, next) => next(e))
375
376 // An observer: a /clear resets the state, so the status of a run it cancels goes too. The server stops as well: the
377 // next session has another id and folder, and a server left running would watch the old one (NFR-06).
378 on('session.end', async ($, e, next) => {
379 stopServer()
380 $.ui.status(undefined)
381
382 return next(e)
383 }).catch(($, e, next) => next(e))
384}
385hooks/host.ts 17 lines1/** What the helper modules need from the engine; register.tsx builds it from `$`, which never crosses an import. */
2export type Host = {
3 run: (argv: string[], cwd?: string) => Promise<{ exitCode: number; stdout: string; isStdoutTruncated?: boolean }>
4 cwd: () => Promise<string>
5 /** A file's text; rejects when the file is missing or over 4 MiB. */
6 readFile: (path: string) => Promise<string>
7 /** Writes a file, creating its folders. */
8 writeFile: (path: string, text: string) => Promise<void>
9 /** The temporary directories the environment names, and `OS` (`Windows_NT` on Windows). */
10 env: () => Promise<{ temp?: string; tmpdir?: string; os?: string }>
11 sessionId: () => Promise<string>
12 storeGet: (key: string) => Promise<unknown>
13 storeSet: (key: string, value: unknown) => Promise<void>
14 storeKeys: () => Promise<string[]>
15 storeDelete: (key: string) => Promise<void>
16}
17hooks/git.ts 218 lines1import type { Host } from './host'
2import { norm } from './paths'
3
4/** One changed file against the base: the new path of a rename and its old one, and its line counts, null for a binary file. */
5export type ChangedFile = { path: string; oldPath?: string; added: number | null; removed: number | null; isUntracked: boolean }
6
7/** What a run compares: the repository root, its branch, the base commit with the name the person reads, and the changed files. */
8export type Collection = { root: string; branch: string; base: string; baseLabel: string; files: ChangedFile[] }
9
10/** Why a run stops before the analysis; each kind has the text `commandText` answers with. */
11export type CollectError =
12 | { kind: 'not-repo' }
13 | { kind: 'unknown-ref'; ref: string }
14 | { kind: 'no-main' }
15 | { kind: 'no-base'; main: string }
16 | { kind: 'git-failed'; exitCode: number }
17 | { kind: 'empty'; baseLabel: string }
18
19export type CollectResult = { isOk: true; value: Collection } | { isOk: false; error: CollectError }
20
21type Base = { commit: string; label: string }
22
23/** The branches tried for the default base after the target of `origin/HEAD`, in this order (FR-02). */
24const MAIN_CANDIDATES = ['main', 'master'] as const
25
26/** One `--numstat -z` record: added, removed, then the path, or an empty path when a rename's two paths follow. */
27const NUMSTAT_RECORD = /^(\d+|-)\t(\d+|-)\t(.*)$/s
28
29const fail = (error: CollectError): CollectResult => ({ isOk: false, error })
30
31const count = (text: string): number | null => (text === '-' ? null : Number(text))
32
33/** Parses `git diff --numstat -z -M`: one entry per file, a rename once under its new path, a binary file with null counts. */
34export function parseNumstat(stdout: string): ChangedFile[] {
35 const fields = stdout.split('\0')
36 const files: ChangedFile[] = []
37 for (let i = 0; i < fields.length; i++) {
38 const match = NUMSTAT_RECORD.exec(fields[i] ?? '')
39 if (match === null) continue
40 const [, added = '-', removed = '-', path = ''] = match
41 const counts = { added: count(added), removed: count(removed), isUntracked: false }
42 if (path !== '') {
43 files.push({ path, ...counts })
44 continue
45 }
46 const oldPath = fields[i + 1] ?? ''
47 const newPath = fields[i + 2] ?? ''
48 i += 2
49 if (newPath !== '') files.push({ path: newPath, oldPath, ...counts })
50 }
51
52 return files
53}
54
55/** Parses a NUL-separated path list, such as `git ls-files -z`. */
56export const parsePaths = (stdout: string): string[] => stdout.split('\0').filter(path => path !== '')
57
58async function isCommit(host: Host, root: string, ref: string): Promise<boolean> {
59 return (await host.run(['git', 'rev-parse', '--verify', '--quiet', `${ref}^{commit}`], root)).exitCode === 0
60}
61
62/** The main branch: the target of `origin/HEAD`, else `main`, else `master`; null when none of them is a commit. */
63export async function mainBranch(host: Host, root: string): Promise<string | null> {
64 const origin = await host.run(['git', 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], root)
65 const candidates = origin.exitCode === 0 ? [origin.stdout.trim(), ...MAIN_CANDIDATES] : [...MAIN_CANDIDATES]
66 for (const candidate of candidates) if (candidate !== '' && (await isCommit(host, root, candidate))) return candidate
67
68 return null
69}
70
71async function defaultBase(host: Host, root: string): Promise<Base | CollectError> {
72 const main = await mainBranch(host, root)
73 if (main === null) return { kind: 'no-main' }
74 const mergeBase = await host.run(['git', 'merge-base', 'HEAD', main], root)
75 if (mergeBase.exitCode !== 0) return { kind: 'no-base', main }
76
77 return { commit: mergeBase.stdout.trim(), label: main }
78}
79
80async function namedBase(host: Host, root: string, ref: string): Promise<Base | CollectError> {
81 // A leading dash would read as an option of rev-parse, not as a reference.
82 if (ref.startsWith('-')) return { kind: 'unknown-ref', ref }
83 const resolved = await host.run(['git', 'rev-parse', '--verify', '--quiet', `${ref}^{commit}`], root)
84 if (resolved.exitCode !== 0) return { kind: 'unknown-ref', ref }
85
86 return { commit: resolved.stdout.trim(), label: ref }
87}
88
89/** The line counts of a file Git does not track, from Git itself so they match the tracked files; null counts on a failure. */
90async function untrackedFile(host: Host, root: string, path: string): Promise<ChangedFile> {
91 // Git reads the literal /dev/null as an empty file on every platform, and exits with 1 when it lists a difference.
92 const diff = await host.run(['git', 'diff', '--no-index', '--numstat', '-z', '--', '/dev/null', path], root)
93 const [counts] = diff.exitCode <= 1 ? parseNumstat(diff.stdout) : []
94
95 return { path, added: counts?.added ?? null, removed: counts?.removed ?? null, isUntracked: true }
96}
97
98/**
99 * Collects what `/visual-review [ref]` compares: the base (the merge-base of HEAD and the main branch, or `ref`), and
100 * the committed, staged, unstaged and untracked changes against it. Every Git command is read-only.
101 */
102export async function collect(host: Host, ref: string): Promise<CollectResult> {
103 const top = await host.run(['git', 'rev-parse', '--show-toplevel'], await host.cwd())
104 if (top.exitCode !== 0) return fail({ kind: 'not-repo' })
105 const root = norm(top.stdout.trim())
106 const base = ref === '' ? await defaultBase(host, root) : await namedBase(host, root, ref)
107 if ('kind' in base) return fail(base)
108 const files = await changedFiles(host, root, base.commit)
109 if (!Array.isArray(files)) return fail(files)
110 if (files.length === 0) return fail({ kind: 'empty', baseLabel: base.label })
111 const branch = await host.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], root)
112
113 return { isOk: true, value: { root, branch: branch.exitCode === 0 ? branch.stdout.trim() : 'HEAD', base: base.commit, baseLabel: base.label, files } }
114}
115
116/** The committed, staged, unstaged and untracked files that changed against `base`, with their Git line counts. */
117export async function changedFiles(host: Host, root: string, base: string): Promise<ChangedFile[] | CollectError> {
118 const numstat = await host.run(['git', 'diff', '--numstat', '-z', '-M', base], root)
119 if (numstat.exitCode !== 0) return { kind: 'git-failed', exitCode: numstat.exitCode }
120 const others = await host.run(['git', 'ls-files', '--others', '--exclude-standard', '-z'], root)
121 if (others.exitCode !== 0) return { kind: 'git-failed', exitCode: others.exitCode }
122 const untracked: ChangedFile[] = []
123 for (const path of parsePaths(others.stdout)) untracked.push(await untrackedFile(host, root, path))
124
125 return [...parseNumstat(numstat.stdout), ...untracked]
126}
127
128/** One `-U0` hunk: the removed lines start at `oldStart` in the base file, the added lines at `newStart` in the current one. */
129export type Hunk = { oldStart: number; oldCount: number; newStart: number; newCount: number }
130
131/** The hunks of each changed file, by its current path (its base path when deleted); `isTruncated` when Git's output was cut. */
132export type DiffHunks = { files: Map<string, Hunk[]>; isTruncated: boolean }
133
134/** A `-U0` hunk header; a side whose count is left out has 1 line. */
135const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/
136
137/** A `---` or `+++` line: the side's path after its `a/` or `b/` prefix, or `/dev/null`. */
138const SIDE_LINE = /^(?:---|\+\+\+) (.*?)\t?$/
139
140/** Undoes Git's C-style quoting of a path with special characters (`"a/say \"hi\".ts"`). */
141function unquote(path: string): string {
142 if (!(path.startsWith('"') && path.endsWith('"'))) return path
143 const escapes: Record<string, string> = { n: '\n', t: '\t', '"': '"', '\\': '\\' }
144
145 return path.slice(1, -1).replace(/\\(["\\nt])/g, (_, char: string) => escapes[char] ?? char)
146}
147
148const sidePath = (raw: string): string | null => {
149 const path = unquote(raw)
150
151 return path === '/dev/null' ? null : path.replace(/^[ab]\//, '')
152}
153
154/** Parses `git diff -U0 --no-color -M`: the hunks per file, skipping each hunk's own lines by its counts. */
155export function parseHunks(stdout: string): Map<string, Hunk[]> {
156 const files = new Map<string, Hunk[]>()
157 let oldPath: string | null = null
158 let current: Hunk[] | undefined
159 let linesLeft = 0
160 for (const line of stdout.split('\n')) {
161 if (linesLeft > 0 && (line.startsWith('+') || line.startsWith('-'))) {
162 linesLeft--
163 continue
164 }
165 const side = SIDE_LINE.exec(line)
166 if (side !== null && line.startsWith('---')) oldPath = sidePath(side[1] ?? '')
167 if (side !== null && line.startsWith('+++')) {
168 const path = sidePath(side[1] ?? '') ?? oldPath
169 current = path === null ? undefined : (files.get(path) ?? [])
170 if (path !== null && current !== undefined) files.set(path, current)
171 continue
172 }
173 const header = HUNK_HEADER.exec(line)
174 if (header === null || current === undefined) continue
175 const [, oldStart = '0', oldCount = '1', newStart = '0', newCount = '1'] = header
176 const hunk = { oldStart: Number(oldStart), oldCount: Number(oldCount), newStart: Number(newStart), newCount: Number(newCount) }
177 current.push(hunk)
178 linesLeft = hunk.oldCount + hunk.newCount
179 }
180
181 return files
182}
183
184/**
185 * The `-U0` hunks of the change against `base`; quotePath off keeps non-ASCII names as they are, and the forced `a/` and
186 * `b/` prefixes override the person's `diff.mnemonicPrefix` or `diff.noprefix`, so `sidePath` strips a known prefix.
187 */
188export async function diffHunks(host: Host, root: string, base: string): Promise<DiffHunks> {
189 const diff = await host.run(['git', '-c', 'core.quotePath=false', 'diff', '-U0', '--no-color', '-M', '--src-prefix=a/', '--dst-prefix=b/', base], root)
190 if (diff.exitCode !== 0) return { files: new Map(), isTruncated: true }
191
192 return { files: parseHunks(diff.stdout), isTruncated: diff.isStdoutTruncated === true }
193}
194
195/** The text the command answers with: the cause of a stop, or the analysis it starts with the count of changed files. */
196export function commandText(result: CollectResult): string {
197 if (result.isOk) {
198 const { files, baseLabel } = result.value
199
200 return `Analyzing the design of ${files.length} changed ${files.length === 1 ? 'file' : 'files'} against ${baseLabel}.`
201 }
202 const { error } = result
203 switch (error.kind) {
204 case 'not-repo':
205 return 'visual-review needs a Git repository.'
206 case 'unknown-ref':
207 return `Unknown Git reference: ${error.ref}.`
208 case 'no-main':
209 return 'No main branch found (origin/HEAD, main or master). Name a base: /visual-review <ref>.'
210 case 'no-base':
211 return `No common commit with ${error.main}. Name a base: /visual-review <ref>.`
212 case 'git-failed':
213 return `visual-review: git diff failed with exit code ${error.exitCode}.`
214 case 'empty':
215 return `No changes against ${error.baseLabel}.`
216 }
217}
218hooks/model.ts 387 lines1import type { Host } from './host'
2import { norm } from './paths'
3
4/** The extensions of the changed files that the design model must cover; each other changed file goes to "Other files". */
5export const SOURCE_EXTENSIONS = ['.cs', '.ts', '.tsx', '.js', '.mjs', '.cjs', '.py', '.sql', '.prisma'] as const
6
7/** How many lines from the start of a unit's range may hold its name, for a declaration led by attributes or decorators. */
8export const RANGE_NAME_LINES = 5
9
10const UNIT_KINDS = ['class', 'interface', 'record', 'struct', 'enum', 'module'] as const
11const UNIT_STATES = ['added', 'modified', 'deleted', 'context'] as const
12const MEMBER_KINDS = ['method', 'property', 'field', 'constructor'] as const
13const MEMBER_STATES = ['added', 'modified', 'deleted'] as const
14const RELATION_KINDS = ['inheritance', 'implementation', 'composition', 'dependency'] as const
15const RELATION_STATES = ['added', 'removed', 'unchanged'] as const
16const TABLE_STATES = ['added', 'modified', 'dropped'] as const
17const COLUMN_STATES = ['added', 'modified', 'dropped', 'unchanged'] as const
18
19export type UnitKind = (typeof UNIT_KINDS)[number]
20export type UnitState = (typeof UNIT_STATES)[number]
21export type MemberKind = (typeof MEMBER_KINDS)[number]
22export type MemberState = (typeof MEMBER_STATES)[number]
23export type RelationKind = (typeof RELATION_KINDS)[number]
24export type RelationState = (typeof RELATION_STATES)[number]
25export type TableState = (typeof TABLE_STATES)[number]
26export type ColumnState = (typeof COLUMN_STATES)[number]
27
28/** Lines of a file, from 1, both ends included. */
29export type Range = { start: number; end: number }
30export type Member = { id: string; name: string; kind: MemberKind; state: MemberState }
31export type Cohesion = { score: number; reason: string }
32
33/** A class, interface, record, struct, enum or module; a context unit carries only its identity and place. */
34export type Unit = {
35 id: string
36 name: string
37 kind: UnitKind
38 state: UnitState
39 file: string
40 container: string
41 range?: Range
42 baseRange?: Range
43 methods?: number
44 members: Member[]
45 summary?: string
46 cohesion?: Cohesion
47}
48
49export type Relation = { from: string; to: string; kind: RelationKind; state: RelationState }
50export type Column = { name: string; type: string; baseType?: string; state: ColumnState; isPrimaryKey?: boolean; references?: string }
51export type Table = { id: string; state: TableState; source: string; entity?: string; columns: Column[] }
52export type SkippedFile = { file: string; reason: string }
53
54/** What the analysis subagent sends to `submit_model`, narrowed from the tool input. */
55export type DesignModel = { runId: string; units: Unit[]; relations: Relation[]; tables: Table[]; skippedFiles: SkippedFile[] }
56
57/**
58 * What a range check needs of a run: the repository root, the base commit whose files a deleted or modified unit names,
59 * and the base path of each renamed file, by its current path.
60 */
61export type RangeSource = { root: string; base: string; oldPaths?: ReadonlyMap<string, string> }
62
63const RANGE_SCHEMA = {
64 type: 'object',
65 properties: { start: { type: 'integer', minimum: 1 }, end: { type: 'integer', minimum: 1 } },
66 required: ['start', 'end'],
67}
68
69/** The JSON schema of the `submit_model` input; the rules it cannot state are in the analysis prompt and in `checkModel`. */
70export const SUBMIT_SCHEMA = {
71 type: 'object',
72 properties: {
73 runId: { type: 'string', description: 'The run id from the /visual-review prompt.' },
74 units: {
75 type: 'array',
76 items: {
77 type: 'object',
78 properties: {
79 id: { type: 'string', description: 'Fully qualified name, unique in the model.' },
80 name: { type: 'string' },
81 kind: { type: 'string', enum: [...UNIT_KINDS] },
82 state: { type: 'string', enum: [...UNIT_STATES] },
83 file: { type: 'string', description: 'Repository-relative path with forward slashes.' },
84 container: { type: 'string', description: 'The namespace for C#, the folder for TypeScript, JavaScript and Python.' },
85 range: { ...RANGE_SCHEMA, description: 'Lines of the unit in the current file; in the base file for a deleted unit. Not for a context unit.' },
86 baseRange: { ...RANGE_SCHEMA, description: 'Lines of the unit in the base file, for a modified unit only.' },
87 methods: { type: 'integer', minimum: 0 },
88 members: {
89 type: 'array',
90 items: {
91 type: 'object',
92 properties: {
93 id: { type: 'string', description: 'The signature.' },
94 name: { type: 'string' },
95 kind: { type: 'string', enum: [...MEMBER_KINDS] },
96 state: { type: 'string', enum: [...MEMBER_STATES] },
97 },
98 required: ['id', 'name', 'kind', 'state'],
99 },
100 },
101 summary: { type: 'string', description: '1 to 3 sentences.' },
102 cohesion: {
103 type: 'object',
104 properties: { score: { type: 'integer', minimum: 1, maximum: 5 }, reason: { type: 'string' } },
105 required: ['score', 'reason'],
106 },
107 },
108 required: ['id', 'name', 'kind', 'state', 'file', 'container'],
109 },
110 },
111 relations: {
112 type: 'array',
113 items: {
114 type: 'object',
115 properties: {
116 from: { type: 'string' },
117 to: { type: 'string' },
118 kind: { type: 'string', enum: [...RELATION_KINDS] },
119 state: { type: 'string', enum: [...RELATION_STATES] },
120 },
121 required: ['from', 'to', 'kind', 'state'],
122 },
123 },
124 tables: {
125 type: 'array',
126 items: {
127 type: 'object',
128 properties: {
129 id: { type: 'string', description: 'schema.table or table.' },
130 state: { type: 'string', enum: [...TABLE_STATES] },
131 source: { type: 'string', description: 'The changed file that defines the change.' },
132 entity: { type: 'string', description: 'The unit id of the entity class, when there is one.' },
133 columns: {
134 type: 'array',
135 items: {
136 type: 'object',
137 properties: {
138 name: { type: 'string' },
139 type: { type: 'string' },
140 baseType: { type: 'string' },
141 state: { type: 'string', enum: [...COLUMN_STATES] },
142 isPrimaryKey: { type: 'boolean' },
143 references: { type: 'string', description: 'table.column of a foreign key.' },
144 },
145 required: ['name', 'type', 'state'],
146 },
147 },
148 },
149 required: ['id', 'state', 'source', 'columns'],
150 },
151 },
152 skippedFiles: {
153 type: 'array',
154 items: { type: 'object', properties: { file: { type: 'string' }, reason: { type: 'string' } }, required: ['file', 'reason'] },
155 },
156 },
157 required: ['runId', 'units', 'relations', 'tables'],
158} as const
159
160type Narrowed<T> = { value: T; problems: string[] }
161
162const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value)
163
164const isOneOf = <T extends string>(options: readonly T[], value: unknown): value is T => typeof value === 'string' && (options as readonly string[]).includes(value)
165
166const isText = (value: unknown): value is string => typeof value === 'string' && value.trim() !== ''
167
168const isCount = (value: unknown): value is number => typeof value === 'number' && Number.isInteger(value) && value >= 0
169
170/** A repository-relative path as the model and Git both write it: forward slashes, no leading `./`. */
171export const pathKey = (path: string): string => norm(path).replace(/^\.\//, '')
172
173function narrowRange(value: unknown): Range | undefined {
174 if (!isRecord(value)) return undefined
175 const { start, end } = value
176 if (!isCount(start) || !isCount(end) || start < 1 || end < start) return undefined
177
178 return { start, end }
179}
180
181function narrowMembers(value: unknown, at: string, problems: string[]): Member[] {
182 if (value === undefined) return []
183 if (!Array.isArray(value)) return (problems.push(`${at}.members: expected an array`), [])
184 const members: Member[] = []
185 value.forEach((item: unknown, index) => {
186 const where = `${at}.members[${index}]`
187 if (!isRecord(item) || !isText(item.id) || !isText(item.name) || !isOneOf(MEMBER_KINDS, item.kind) || !isOneOf(MEMBER_STATES, item.state))
188 return problems.push(`${where}: needs id, name, kind (${MEMBER_KINDS.join(', ')}) and state (${MEMBER_STATES.join(', ')})`)
189 members.push({ id: item.id, name: item.name, kind: item.kind, state: item.state })
190 })
191
192 return members
193}
194
195function narrowUnit(item: unknown, at: string, problems: string[]): Unit | undefined {
196 if (!isRecord(item) || !isText(item.id) || !isText(item.name) || !isText(item.file) || typeof item.container !== 'string')
197 return (problems.push(`${at}: needs id, name, file and container`), undefined)
198 if (!isOneOf(UNIT_KINDS, item.kind)) return (problems.push(`${at} (${item.id}): kind must be one of ${UNIT_KINDS.join(', ')}`), undefined)
199 if (!isOneOf(UNIT_STATES, item.state)) return (problems.push(`${at} (${item.id}): state must be one of ${UNIT_STATES.join(', ')}`), undefined)
200 const unit: Unit = { id: item.id, name: item.name, kind: item.kind, state: item.state, file: pathKey(item.file), container: item.container, members: [] }
201 if (item.state === 'context') return unit
202 const where = `${at} (${item.id})`
203 const range = narrowRange(item.range)
204 const baseRange = narrowRange(item.baseRange)
205 const cohesion = isRecord(item.cohesion) ? item.cohesion : undefined
206 const score = cohesion?.score
207 if (range === undefined) problems.push(`${where}: range needs start and end lines, start 1 or more and end not before start`)
208 if (item.state === 'modified' && baseRange === undefined) problems.push(`${where}: a modified unit needs baseRange, its lines in the base file`)
209 if (!isCount(item.methods)) problems.push(`${where}: methods must be a whole number, 0 or more`)
210 if (!isText(item.summary)) problems.push(`${where}: summary needs 1 to 3 sentences`)
211 if (!isCount(score) || score < 1 || score > 5 || !isText(cohesion?.reason)) problems.push(`${where}: cohesion needs a score from 1 to 5 and a reason`)
212
213 return {
214 ...unit,
215 ...(range === undefined ? {} : { range }),
216 ...(item.state === 'modified' && baseRange !== undefined ? { baseRange } : {}),
217 ...(isCount(item.methods) ? { methods: item.methods } : {}),
218 members: narrowMembers(item.members, where, problems),
219 ...(isText(item.summary) ? { summary: item.summary } : {}),
220 ...(isCount(score) && isText(cohesion?.reason) ? { cohesion: { score, reason: cohesion.reason } } : {}),
221 }
222}
223
224function narrowRelation(item: unknown, at: string, problems: string[]): Relation | undefined {
225 if (isRecord(item) && isText(item.from) && isText(item.to) && isOneOf(RELATION_KINDS, item.kind) && isOneOf(RELATION_STATES, item.state))
226 return { from: item.from, to: item.to, kind: item.kind, state: item.state }
227 problems.push(`${at}: needs from, to, kind (${RELATION_KINDS.join(', ')}) and state (${RELATION_STATES.join(', ')})`)
228
229 return undefined
230}
231
232function narrowColumn(item: unknown, at: string, problems: string[]): Column | undefined {
233 if (!isRecord(item) || !isText(item.name) || typeof item.type !== 'string' || !isOneOf(COLUMN_STATES, item.state))
234 return (problems.push(`${at}: needs name, type and state (${COLUMN_STATES.join(', ')})`), undefined)
235
236 return {
237 name: item.name,
238 type: item.type,
239 state: item.state,
240 ...(typeof item.baseType === 'string' ? { baseType: item.baseType } : {}),
241 ...(typeof item.isPrimaryKey === 'boolean' ? { isPrimaryKey: item.isPrimaryKey } : {}),
242 ...(isText(item.references) ? { references: item.references } : {}),
243 }
244}
245
246function narrowTable(item: unknown, at: string, problems: string[]): Table | undefined {
247 if (!isRecord(item) || !isText(item.id) || !isText(item.source) || !isOneOf(TABLE_STATES, item.state) || !Array.isArray(item.columns))
248 return (problems.push(`${at}: needs id, state (${TABLE_STATES.join(', ')}), source and columns`), undefined)
249 const where = `${at} (${item.id})`
250 const columns = item.columns.flatMap((column: unknown, index) => narrowColumn(column, `${where}.columns[${index}]`, problems) ?? [])
251
252 return { id: item.id, state: item.state, source: pathKey(item.source), ...(isText(item.entity) ? { entity: item.entity } : {}), columns }
253}
254
255function narrowList<T>(input: Record<string, unknown>, key: string, problems: string[], narrow: (item: unknown, at: string, problems: string[]) => T | undefined): T[] {
256 const value = input[key]
257 if (value === undefined && key === 'skippedFiles') return []
258 if (!Array.isArray(value)) return (problems.push(`${key}: expected an array (it may be empty)`), [])
259
260 return value.flatMap((item: unknown, index) => narrow(item, `${key}[${index}]`, problems) ?? [])
261}
262
263function narrowSkipped(item: unknown, at: string, problems: string[]): SkippedFile | undefined {
264 if (isRecord(item) && isText(item.file) && isText(item.reason)) return { file: pathKey(item.file), reason: item.reason }
265 problems.push(`${at}: needs file and reason`)
266
267 return undefined
268}
269
270/** Narrows the `submit_model` input from `unknown`: the model that parsed, and one problem per value that did not. */
271export function narrowModel(input: unknown): Narrowed<DesignModel> {
272 const problems: string[] = []
273 const record = isRecord(input) ? input : {}
274 const runId = typeof record.runId === 'string' ? record.runId : ''
275 const value: DesignModel = {
276 runId,
277 units: narrowList(record, 'units', problems, narrowUnit),
278 relations: narrowList(record, 'relations', problems, narrowRelation),
279 tables: narrowList(record, 'tables', problems, narrowTable),
280 skippedFiles: narrowList(record, 'skippedFiles', problems, narrowSkipped),
281 }
282
283 return { value, problems }
284}
285
286const extensionOf = (path: string): string => {
287 const name = path.split('/').pop() ?? path
288 const dot = name.lastIndexOf('.')
289
290 return dot <= 0 ? '' : name.slice(dot).toLowerCase()
291}
292
293/** Whether the design model must cover a changed file, by its extension. */
294export const isSourceFile = (path: string): boolean => (SOURCE_EXTENSIONS as readonly string[]).includes(extensionOf(path))
295
296/** The gaps of a narrowed model against the changed files: an uncovered source file, a repeated id, or a reference to an unknown unit. */
297export function checkModel(model: DesignModel, changedFiles: readonly string[]): string[] {
298 const gaps: string[] = []
299 const covered = new Set([...model.units.map(unit => unit.file), ...model.tables.map(table => table.source), ...model.skippedFiles.map(skipped => skipped.file)])
300 for (const file of changedFiles.map(pathKey))
301 if (isSourceFile(file) && !covered.has(file)) gaps.push(`${file}: the changed file is not covered; add its units, the table it changes, or a skippedFiles entry`)
302 const unitIds = new Set<string>()
303 for (const unit of model.units) {
304 if (unitIds.has(unit.id)) gaps.push(`${unit.id}: the unit id is used twice`)
305 unitIds.add(unit.id)
306 }
307 const tableIds = new Set<string>()
308 for (const table of model.tables) {
309 if (tableIds.has(table.id)) gaps.push(`${table.id}: the table id is used twice`)
310 tableIds.add(table.id)
311 if (table.entity !== undefined && !unitIds.has(table.entity)) gaps.push(`${table.id}: the entity ${table.entity} is not a unit of the model`)
312 }
313 for (const relation of model.relations)
314 for (const end of [relation.from, relation.to])
315 if (!unitIds.has(end)) gaps.push(`${relation.from} → ${relation.to}: ${end} is not a unit of the model`)
316
317 return gaps
318}
319
320/** One range of a unit to check, with the text of the file it names and the side of the diff that file is on. */
321export type RangeCheck = { range: Range; text: string; side: 'current' | 'base' }
322
323/** The gap of one range against the file text, or null when the range fits and its first lines hold the unit name. */
324export function rangeGap(unit: Unit, { range, text, side }: RangeCheck): string | null {
325 const lines = text.split(/\r?\n/)
326 const where = `${unit.id}: ${side === 'base' ? 'baseRange' : 'range'} ${range.start}-${range.end} in ${side === 'base' ? 'the base file' : 'the current file'} ${unit.file}`
327 if (range.end > lines.length) return `${where} ends after the last line (${lines.length})`
328 // A module is named after its file, so its first lines need not hold the name.
329 if (unit.kind === 'module') return null
330 const head = lines.slice(range.start - 1, Math.min(range.end, range.start - 1 + RANGE_NAME_LINES))
331 if (head.some(line => line.includes(unit.name))) return null
332
333 return `${where} does not show the name ${unit.name} in its first ${RANGE_NAME_LINES} lines`
334}
335
336/** A file's text for the range check; `{ isMissing }` when the base commit has no such file, null when it could not be read in full. */
337type CheckText = string | { isMissing: true } | null
338
339/**
340 * The gaps of each range against the file it names, and the ids of the units whose file could not be read in full for
341 * the check. A renamed file is read at its base path on the base side; a base file the base commit lacks is a gap.
342 */
343export async function checkRanges(host: Host, source: RangeSource, units: readonly Unit[]): Promise<{ gaps: string[]; unchecked: string[] }> {
344 const gaps: string[] = []
345 const unchecked: string[] = []
346 const texts = new Map<string, CheckText>()
347 const textOf = async (side: 'current' | 'base', file: string): Promise<CheckText> => {
348 const key = `${side}:${file}`
349 if (!texts.has(key)) texts.set(key, side === 'base' ? await baseText(host, source, file) : await host.readFile(`${source.root}/${file}`).catch(() => null))
350
351 return texts.get(key) ?? null
352 }
353 for (const unit of units) {
354 if (unit.state === 'context' || unit.range === undefined) continue
355 const checks: [Range, 'current' | 'base'][] = [[unit.range, unit.state === 'deleted' ? 'base' : 'current']]
356 if (unit.baseRange !== undefined) checks.push([unit.baseRange, 'base'])
357 for (const [range, side] of checks) {
358 const text = await textOf(side, unit.file)
359 if (text === null) {
360 if (!unchecked.includes(unit.id)) unchecked.push(unit.id)
361 continue
362 }
363 const gap = typeof text === 'string' ? rangeGap(unit, { range, text, side }) : missingBaseGap(unit, source)
364 if (gap !== null && !gaps.includes(gap)) gaps.push(gap)
365 }
366 }
367
368 return { gaps, unchecked }
369}
370
371/** The gap of a unit whose base file is not in the base commit: a modified or deleted unit needs a file that existed. */
372function missingBaseGap(unit: Unit, source: RangeSource): string {
373 const path = source.oldPaths?.get(unit.file) ?? unit.file
374
375 return `${unit.id}: the base file ${path} is not in the base commit; use the state added for a unit of a new file`
376}
377
378/** The base text of a file, read at its base path; a Git that cannot run, or output cut at its limit, reads as null. */
379async function baseText(host: Host, source: RangeSource, file: string): Promise<CheckText> {
380 const path = source.oldPaths?.get(file) ?? file
381 const shown = await host.run(['git', 'show', `${source.base}:${path}`], source.root).catch(() => null)
382 if (shown === null || shown.isStdoutTruncated === true) return null
383 if (shown.exitCode !== 0) return { isMissing: true }
384
385 return shown.stdout
386}
387hooks/server.ts 120 lines1import type { Host } from './host'
2import { fileUrl, norm } from './paths'
3
4/** The platforms whose browser launch differs. */
5export type Platform = 'windows' | 'mac' | 'linux'
6
7/** One line the server prints on its standard output; any other line is dropped. */
8export type ServerLine =
9 | { type: 'ready'; url: string }
10 | { type: 'error'; message: string }
11 | { type: 'tick' }
12 | { type: 'clients'; count: number }
13 | { type: 'comments'; comments: unknown[] }
14 | { type: 'submit' }
15
16/** What a line buffer keeps between spawn chunks: the text after the last line break. */
17export type LineBuffer = { lines: string[]; rest: string }
18
19/** How the page opens: served by Node.js, or as a static file the browser reads from disk (FR-24). */
20export type PageMode = 'server' | 'static'
21
22/** The page URL the server prints: loopback, a port, and the token in the query. */
23const READY_URL = /^http:\/\/127\.0\.0\.1:\d{1,5}\/\?t=[0-9a-f]{64}$/
24
25/** What `node --version` prints: `v`, then the version. */
26const NODE_VERSION = /^v\d+\.\d+\.\d+/
27
28/** The page file that the server serves and the static page is made from. */
29export const PAGE_FILE = 'index.html'
30
31/** The files of the static page in the session folder: the page, and the view model as a classic script (DEC-06). */
32export const STATIC_PAGE_FILE = 'review.html'
33export const STATIC_VIEW_FILE = 'view.js'
34
35const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value)
36
37/** The argv that starts the page server: Node.js on the PATH, the server file the mod ships, the session folder and the page folder. */
38export function serverArgv(pluginRoot: string, sessionFolder: string): string[] {
39 const root = norm(pluginRoot).replace(/\/+$/, '')
40
41 return ['node', `${root}/server/server.mjs`, '--dir', sessionFolder, '--web', `${root}/web`]
42}
43
44/** Splits the text of a spawn chunk into whole lines, keeping a line that the chunk cut for the next chunk. */
45export function splitLines(rest: string, text: string): LineBuffer {
46 const parts = `${rest}${text}`.split(/\r?\n/)
47
48 return { lines: parts.slice(0, -1).filter(line => line.trim() !== ''), rest: parts.at(-1) ?? '' }
49}
50
51/** Reads one server line from `unknown` JSON; a line that does not parse or has an unknown type is null. */
52export function parseServerLine(line: string): ServerLine | null {
53 let value: unknown
54 try {
55 value = JSON.parse(line)
56 } catch {
57 return null
58 }
59 if (!isRecord(value)) return null
60 if (value.type === 'ready' && typeof value.url === 'string' && READY_URL.test(value.url)) return { type: 'ready', url: value.url }
61 if (value.type === 'error' && typeof value.message === 'string') return { type: 'error', message: value.message }
62 if (value.type === 'tick') return { type: 'tick' }
63 if (value.type === 'clients' && typeof value.count === 'number' && Number.isInteger(value.count) && value.count >= 0) return { type: 'clients', count: value.count }
64 // Each comment is narrowed where it is merged; here only the list itself is checked.
65 if (value.type === 'comments' && Array.isArray(value.comments)) return { type: 'comments', comments: value.comments }
66 if (value.type === 'submit') return { type: 'submit' }
67
68 return null
69}
70
71/** The page mode from the `node --version` run: the server needs Node.js, so a run that failed or could not start is static. */
72export function pageModeOf(nodeRun: { exitCode: number; stdout: string } | null): PageMode {
73 return nodeRun !== null && nodeRun.exitCode === 0 && NODE_VERSION.test(nodeRun.stdout.trim()) ? 'server' : 'static'
74}
75
76/**
77 * The static page from the served page: each page file loads by its `file://` URL under `webUrl`, without the token
78 * query the server fills in, and the view model loads first from `view.js` next to the page, which sets `window.VIEW`.
79 */
80export function staticPageOf(pageHtml: string, webUrl: string): string {
81 const base = webUrl.replace(/\/+$/, '')
82 const linked = pageHtml.replace(/(href|src)="([\w./-]+)\?t=__REVIEW_TOKEN__"/g, (_, attribute: string, file: string) => `${attribute}="${base}/${file}"`)
83
84 return linked.replace('<script ', `<script src="${STATIC_VIEW_FILE}"></script>\n <script `)
85}
86
87/** The classic script that hands the view model to the static page. */
88export const staticViewOf = (viewJson: string): string => `window.VIEW = ${viewJson}\n`
89
90/**
91 * Writes the static page of a view model into the session folder: `view.js` and `review.html` built from the page file
92 * the mod ships. Returns the path of the page.
93 */
94export async function writeStaticPage(host: Host, { pluginRoot, folder, viewJson }: { pluginRoot: string; folder: string; viewJson: string }): Promise<string> {
95 const web = `${norm(pluginRoot).replace(/\/+$/, '')}/web`
96 const page = `${folder}/${STATIC_PAGE_FILE}`
97 await host.writeFile(`${folder}/${STATIC_VIEW_FILE}`, staticViewOf(viewJson))
98 await host.writeFile(page, staticPageOf(await host.readFile(`${web}/${PAGE_FILE}`), fileUrl(web)))
99
100 return page
101}
102
103/** The platform, from the `OS` variable on Windows and `uname -s` elsewhere. */
104export async function platformOf(host: Host): Promise<Platform> {
105 if ((await host.env()).os === 'Windows_NT') return 'windows'
106 const uname = await host.run(['uname', '-s']).catch(() => ({ exitCode: 1, stdout: '' }))
107
108 return uname.stdout.trim() === 'Darwin' ? 'mac' : 'linux'
109}
110
111/**
112 * The argv that opens a URL in the default browser; no shell, so the token never meets a command line parser. On Windows,
113 * `explorer.exe` opens File Explorer for a URL with a query, so the URL goes to the protocol handler that the browser owns.
114 */
115export function browserArgv(platform: Platform, url: string): string[] {
116 if (platform === 'windows') return ['rundll32', 'url.dll,FileProtocolHandler', url]
117
118 return [platform === 'mac' ? 'open' : 'xdg-open', url]
119}
120hooks/paths.ts 17 lines1/** Forward slashes everywhere, so Windows and POSIX paths compare alike. */
2export const norm = (path: string): string => path.replace(/\\/g, '/')
3
4/** The temporary directory: `TEMP`, else `TMPDIR`, else `/tmp`, as the environment names it. */
5export const tempRoot = (env: { temp?: string; tmpdir?: string }): string => norm(env.temp ?? env.tmpdir ?? '/tmp').replace(/\/+$/, '')
6
7/** The `file://` URL of an absolute path, Windows (`C:\x`) or POSIX (`/x`), with each name percent-encoded but the drive. */
8export function fileUrl(path: string): string {
9 const parts = norm(path).split('/')
10 const encoded = parts.map((part, index) => (index === 0 && /^[a-zA-Z]:$/.test(part) ? part : encodeURIComponent(part))).join('/')
11
12 return encoded.startsWith('/') ? `file://${encoded}` : `file:///${encoded}`
13}
14
15/** The folder of the mod's files for one session: `<temp>/claude-visual-review/<session id>` (`mod-runtime.md`, "Files"). */
16export const sessionDir = (temp: string, sessionId: string): string => `${norm(temp).replace(/\/+$/, '')}/claude-visual-review/${sessionId}`
17hooks/view.ts 265 lines1import { loadRecord, storeKey, viewComments } from './comments'
2import type { AnchorIndex, ReviewRecord, ViewComment } from './comments'
3import { changedFiles, diffHunks } from './git'
4import type { ChangedFile, DiffHunks, Hunk } from './git'
5import type { Host } from './host'
6import { coupling, restDelta, unitDelta, unitLines, untrackedHunks } from './metrics'
7import { sessionDir, tempRoot } from './paths'
8import type { Delta } from './metrics'
9import { isSourceFile, pathKey } from './model'
10import type { Cohesion, DesignModel, Member, Relation, Table, TableState, UnitKind, UnitState } from './model'
11
12/** The version of the view model the page reads; the page ignores a file with another one. */
13export const VIEW_VERSION = 1
14
15/** The container of a unit the analysis put in no namespace or folder. */
16export const ROOT_CONTAINER = '(root)'
17
18/** A unit card: identity, size from Git, the analysis notes, and the coupling from the relations. */
19export type ViewUnit = {
20 id: string
21 name: string
22 kind: UnitKind
23 state: UnitState
24 file: string
25 container: string
26 lines: number | null
27 added: number | null
28 removed: number | null
29 methods: number | null
30 members: Member[]
31 summary: string | null
32 cohesion: Cohesion | null
33 ce: number
34 ca: number
35 /** False when the file was too large or missing for the range check; the card says "range not checked". */
36 isRangeChecked: boolean
37}
38
39/** A namespace or folder; closed by default when it holds only context units (FR-17). */
40export type ViewContainer = { id: string; units: string[]; isClosed: boolean }
41
42/** A relation with its anchor id `<from>→<to>→<kind>`. */
43export type ViewRelation = Relation & { id: string }
44
45/** The dashed line between an entity unit and its table (FR-15). */
46export type EntityLink = { id: string; unit: string; table: string }
47
48/** A changed file with its Git totals and the lines outside every unit range; rest is null for a file with no units. */
49export type ViewFile = { path: string; oldPath?: string; added: number | null; removed: number | null; rest: Delta | null }
50
51/** A changed file outside the diagram: not source code (FR-16), or a source file the analysis skipped, with its reason. */
52export type OtherFile = { path: string; oldPath?: string; added: number | null; removed: number | null; reason?: string }
53
54/** The counts the toolbar shows. */
55export type ViewCounts = { units: Record<Exclude<UnitState, 'context'>, number>; tables: Record<TableState, number> }
56
57/** Why the last submit sent nothing, for the page; `at` makes a repeated notice a new file content, so the page hears it. */
58export type ViewNotice = { text: string; at: number }
59
60/** What the page draws: written to `view.json` (DEC-03) and read again on each update. */
61export type ViewModel = {
62 version: typeof VIEW_VERSION
63 runId: string
64 base: string
65 baseLabel: string
66 branch: string
67 counts: ViewCounts
68 containers: ViewContainer[]
69 units: ViewUnit[]
70 relations: ViewRelation[]
71 tables: Table[]
72 links: EntityLink[]
73 files: ViewFile[]
74 otherFiles: OtherFile[]
75 /** The round the next submit sends, and every comment of this project and branch with its anchor checked (FR-22, FR-23). */
76 round: number
77 comments: ViewComment[]
78 /** Set by a submit that sent nothing or whose prompt was dropped; any other write clears it (FR-20). */
79 notice: ViewNotice | null
80 /** Every anchor of the model as `<kind>:<id>`, from `anchorsOf`. */
81 anchors: string[]
82}
83
84/** What the view needs of a run: the accepted model, the changed files and hunks from Git, and the units not range-checked. */
85export type ViewInput = {
86 run: { runId: string; base: string; baseLabel: string; branch: string }
87 model: DesignModel
88 files: readonly ChangedFile[]
89 hunks: DiffHunks
90 unchecked: readonly string[]
91}
92
93/** Orders text by code unit, the same on every machine, unlike a locale compare. */
94function compareText(a: string, b: string): number {
95 if (a < b) return -1
96
97 return a > b ? 1 : 0
98}
99
100const byId = <T extends { id: string }>(a: T, b: T): number => compareText(a.id, b.id)
101
102const byPath = <T extends { path: string }>(a: T, b: T): number => compareText(a.path, b.path)
103
104/** The id a comment anchors a relation by. */
105export const relationId = (relation: Relation): string => `${relation.from}→${relation.to}→${relation.kind}`
106
107/** The hunks of each changed file, untracked files included; none at all when Git cut its output, so every delta is unknown. */
108function hunksByFile(files: readonly ChangedFile[], hunks: DiffHunks): Map<string, Hunk[]> {
109 if (hunks.isTruncated) return new Map()
110 const result = new Map(hunks.files)
111 for (const file of files) if (file.isUntracked && file.added !== null) result.set(pathKey(file.path), untrackedHunks(file))
112
113 return result
114}
115
116/** Builds the view model of an accepted design model; the same input always gives the same output (NFR-04). */
117export function buildView({ run, model, files, hunks, unchecked }: ViewInput): ViewModel {
118 const fileHunks = hunksByFile(files, hunks)
119 const links = model.tables.flatMap(table => (table.entity === undefined ? [] : [{ id: `${table.entity}⇢${table.id}`, unit: table.entity, table: table.id }]))
120 const couplings = coupling(
121 model.units.map(unit => unit.id),
122 model.relations,
123 )
124 const units: ViewUnit[] = model.units
125 .map(unit => {
126 const delta = unitDelta(unit, fileHunks.get(unit.file))
127
128 return {
129 id: unit.id,
130 name: unit.name,
131 kind: unit.kind,
132 state: unit.state,
133 file: unit.file,
134 container: unit.container.trim() === '' ? ROOT_CONTAINER : unit.container,
135 lines: unitLines(unit),
136 added: delta.added,
137 removed: delta.removed,
138 methods: unit.methods ?? null,
139 members: unit.members,
140 summary: unit.summary ?? null,
141 cohesion: unit.cohesion ?? null,
142 ce: couplings.get(unit.id)?.ce ?? 0,
143 ca: couplings.get(unit.id)?.ca ?? 0,
144 isRangeChecked: unit.state === 'context' || !unchecked.includes(unit.id),
145 }
146 })
147 .sort(byId)
148 const skipped = new Map(model.skippedFiles.map(entry => [entry.file, entry.reason]))
149 const changed = files.map(file => ({ ...file, path: pathKey(file.path) })).sort(byPath)
150 const relations = model.relations.map(relation => ({ ...relation, id: relationId(relation) })).sort(byId)
151 const tables = [...model.tables].sort(byId)
152
153 return {
154 version: VIEW_VERSION,
155 runId: run.runId,
156 base: run.base,
157 baseLabel: run.baseLabel,
158 branch: run.branch,
159 counts: countsOf(units, model.tables),
160 containers: containersOf(units),
161 units,
162 relations,
163 tables,
164 links: links.sort(byId),
165 files: changed.map(file => {
166 const own = model.units.filter(unit => unit.file === file.path)
167
168 return { path: file.path, ...(file.oldPath === undefined ? {} : { oldPath: file.oldPath }), added: file.added, removed: file.removed, rest: own.length === 0 ? null : restDelta(own, fileHunks.get(file.path)) }
169 }),
170 otherFiles: changed
171 .filter(file => !isSourceFile(file.path) || skipped.has(file.path))
172 .map(file => {
173 const reason = skipped.get(file.path)
174
175 return { path: file.path, ...(file.oldPath === undefined ? {} : { oldPath: file.oldPath }), added: file.added, removed: file.removed, ...(reason === undefined ? {} : { reason }) }
176 }),
177 round: 1,
178 comments: [],
179 notice: null,
180 anchors: anchorKeys(anchorsOf({ units, relations, tables })),
181 }
182}
183
184/** The anchors of a view model: units, `<unit>#<member>`, relations, tables and `<table>.<column>`. */
185export function anchorsOf(view: Pick<ViewModel, 'units' | 'relations' | 'tables'>): AnchorIndex {
186 return {
187 unit: new Set(view.units.map(unit => unit.id)),
188 member: new Set(view.units.flatMap(unit => unit.members.map(member => `${unit.id}#${member.id}`))),
189 relation: new Set(view.relations.map(relation => relation.id)),
190 table: new Set(view.tables.map(table => table.id)),
191 column: new Set(view.tables.flatMap(table => table.columns.map(column => `${table.id}.${column.name}`))),
192 }
193}
194
195/** The anchors as `<kind>:<id>` keys in text order, for the static page to check the drafts it restores (FR-23). */
196export const anchorKeys = (index: AnchorIndex): string[] =>
197 Object.entries(index)
198 .flatMap(([kind, ids]) => [...ids].map(id => `${kind}:${id}`))
199 .sort(compareText)
200
201/** Whether a value read back from `view.json` has the shape this version writes; the mod wrote it, so the lists are enough. */
202export function isViewModel(value: unknown): value is ViewModel {
203 if (typeof value !== 'object' || value === null) return false
204 const view = value as Record<string, unknown>
205
206 return view.version === VIEW_VERSION && [view.units, view.relations, view.tables].every(Array.isArray)
207}
208
209/** The view model with the review's round and comments, each checked against the view's anchors, and the given notice. */
210export const withComments = (view: ViewModel, record: ReviewRecord, notice: ViewNotice | null = null): ViewModel => ({
211 ...view,
212 round: record.round,
213 comments: viewComments(record, anchorsOf(view)),
214 notice,
215})
216
217function countsOf(units: readonly ViewUnit[], tables: readonly Table[]): ViewCounts {
218 const count = <T extends string>(values: readonly T[], value: T): number => values.filter(one => one === value).length
219 const states = units.map(unit => unit.state)
220 const tableStates = tables.map(table => table.state)
221
222 return {
223 units: { added: count(states, 'added'), modified: count(states, 'modified'), deleted: count(states, 'deleted') },
224 tables: { added: count(tableStates, 'added'), modified: count(tableStates, 'modified'), dropped: count(tableStates, 'dropped') },
225 }
226}
227
228/** The containers in name order, each with its units in id order; a container of context units only starts closed. */
229function containersOf(units: readonly ViewUnit[]): ViewContainer[] {
230 const groups = new Map<string, ViewUnit[]>()
231 for (const unit of units) groups.set(unit.container, [...(groups.get(unit.container) ?? []), unit])
232
233 return [...groups.entries()]
234 .map(([id, members]) => ({ id, units: members.map(unit => unit.id), isClosed: members.every(unit => unit.state === 'context') }))
235 .sort(byId)
236}
237
238/** The name of the view model file in the session folder. */
239export const VIEW_FILE = 'view.json'
240
241/** What `writeView` needs of the run that waits. */
242export type ViewRun = ViewInput['run'] & { root: string }
243
244/**
245 * Reads the changed files and the hunks from Git again, builds the view model of the accepted model and writes it to
246 * `<temp>/claude-visual-review/<session id>/view.json`; returns the folder, the file's path and its JSON, or the cause
247 * when Git failed.
248 */
249export async function writeView(
250 host: Host,
251 { run, model, unchecked }: { run: ViewRun; model: DesignModel; unchecked: readonly string[] },
252): Promise<{ folder: string; path: string; json: string } | { cause: string }> {
253 const files = await changedFiles(host, run.root, run.base)
254 if (!Array.isArray(files)) return { cause: 'git diff failed' }
255 const hunks = await diffHunks(host, run.root, run.base)
256 const built = buildView({ run: { runId: run.runId, base: run.base, baseLabel: run.baseLabel, branch: run.branch }, model, files, hunks, unchecked })
257 const view = withComments(built, await loadRecord(host, storeKey(run.root, run.branch)))
258 const folder = sessionDir(tempRoot(await host.env()), await host.sessionId())
259 const path = `${folder}/${VIEW_FILE}`
260 const json = JSON.stringify(view)
261 await host.writeFile(path, json)
262
263 return { folder, path, json }
264}
265hooks/comments.ts 181 lines1import type { Host } from './host'
2import { norm } from './paths'
3
4/** What a comment is attached to: an element of the diagram, or the whole review. */
5export type AnchorKind = 'unit' | 'member' | 'relation' | 'table' | 'column' | 'review'
6
7/** An anchor id: a unit or table id, `<unit id>#<member id>`, `<table id>.<column>`, `<from>→<to>→<kind>`, or `review`. */
8export type Anchor = { kind: AnchorKind; id: string }
9
10export type CommentType = 'change' | 'question'
11
12/** A draft waits for the next submit; a submitted comment is open until the reviewer marks it resolved. */
13export type CommentStatus = 'draft' | 'open' | 'resolved'
14
15/**
16 * One comment of the reviewer; `isStatusChanged` marks a status the next review prompt reports, and `sentStatus` keeps
17 * the status of the last submit while it differs.
18 */
19export type Comment = {
20 id: string
21 anchor: Anchor
22 type: CommentType
23 text: string
24 status: CommentStatus
25 round: number
26 isStatusChanged?: true
27 sentStatus?: SubmittedStatus
28}
29
30/** The statuses of a submitted comment. */
31type SubmittedStatus = Exclude<CommentStatus, 'draft'>
32
33/** The comments of one project and branch, as `$.store` keeps them under `review:<root key>:<branch>`. */
34export type ReviewRecord = { version: 1; round: number; updatedAt: number; comments: Comment[] }
35
36/** A comment as the page shows it: `isAnchored` is false when its anchor is not in the current view model (FR-23). */
37export type ViewComment = Comment & { isAnchored: boolean }
38
39/** The anchors of a view model, by kind, to tell which comments still have their element. */
40export type AnchorIndex = Record<Exclude<AnchorKind, 'review'>, ReadonlySet<string>>
41
42/** The prefix of every store key the mod writes. */
43export const REVIEW_KEY_PREFIX = 'review:'
44
45/** The store size at which the oldest review goes first (NFR-09), below the engine's 4 MiB limit of `$.store`. */
46export const STORE_SOFT_LIMIT_BYTES = 3 * 1024 * 1024
47
48/** The longest comment text kept; the page form has the same limit. */
49export const COMMENT_TEXT_LIMIT = 10_000
50
51const ANCHOR_KINDS: readonly AnchorKind[] = ['unit', 'member', 'relation', 'table', 'column', 'review']
52const TYPES: readonly CommentType[] = ['change', 'question']
53const STATUSES: readonly CommentStatus[] = ['draft', 'open', 'resolved']
54const SUBMITTED_STATUSES: readonly SubmittedStatus[] = ['open', 'resolved']
55
56const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value)
57
58const isOneOf = <T extends string>(options: readonly T[], value: unknown): value is T => typeof value === 'string' && (options as readonly string[]).includes(value)
59
60/** The store key of the reviews of a repository and branch; a Windows drive path is case-folded, as the file system is. */
61export function storeKey(root: string, branch: string): string {
62 const path = norm(root).replace(/\/+$/, '')
63
64 return `${REVIEW_KEY_PREFIX}${/^[a-zA-Z]:\//.test(path) ? path.toLowerCase() : path}:${branch}`
65}
66
67/** A new, empty review: round 1, no comments. */
68export const emptyRecord = (): ReviewRecord => ({ version: 1, round: 1, updatedAt: 0, comments: [] })
69
70/** One comment read from `unknown` (the page or the store); a value that does not fit is dropped, not repaired. */
71export function narrowComment(value: unknown): Comment | null {
72 if (!isRecord(value) || !isRecord(value.anchor)) return null
73 const { id, type, text, status, round } = value
74 const { kind, id: anchorId } = value.anchor
75 if (typeof id !== 'string' || id === '' || !isOneOf(ANCHOR_KINDS, kind) || typeof anchorId !== 'string') return null
76 if (!isOneOf(TYPES, type) || typeof text !== 'string' || text.trim() === '' || !isOneOf(STATUSES, status)) return null
77 if (typeof round !== 'number' || !Number.isInteger(round) || round < 1) return null
78
79 const changed = value.isStatusChanged === true ? { isStatusChanged: true as const } : {}
80 const sent = isOneOf(SUBMITTED_STATUSES, value.sentStatus) ? { sentStatus: value.sentStatus } : {}
81
82 return { id, anchor: { kind, id: anchorId }, type, text: text.slice(0, COMMENT_TEXT_LIMIT), status, round, ...changed, ...sent }
83}
84
85/** A review read from the store; another version, or a broken value, reads as none. */
86export function narrowRecord(value: unknown): ReviewRecord | null {
87 if (!isRecord(value) || value.version !== 1 || !Array.isArray(value.comments)) return null
88 const round = typeof value.round === 'number' && Number.isInteger(value.round) && value.round >= 1 ? value.round : 1
89 const updatedAt = typeof value.updatedAt === 'number' ? value.updatedAt : 0
90
91 return { version: 1, round, updatedAt, comments: value.comments.flatMap((item: unknown) => narrowComment(item) ?? []) }
92}
93
94/**
95 * Applies the comment set the page sent: its drafts replace the stored drafts as a whole, so a repeated line changes
96 * nothing; a submitted comment takes only its new status, and its text stays as it was sent.
97 */
98export function mergeComments(record: ReviewRecord, incoming: readonly unknown[], now: number): ReviewRecord {
99 const sent = incoming.flatMap(item => narrowComment(item) ?? [])
100 const statusOf = new Map(sent.filter(comment => comment.status !== 'draft').map(comment => [comment.id, comment.status]))
101 const submittedIds = new Set(record.comments.filter(comment => comment.status !== 'draft').map(comment => comment.id))
102 const submitted = record.comments
103 .filter(comment => comment.status !== 'draft')
104 .map(comment => {
105 const status = statusOf.get(comment.id)
106 if (status === undefined || status === 'draft' || status === comment.status) return comment
107 // A change is measured from the status at the last submit, so a status switched back reports nothing.
108 const sentStatus: SubmittedStatus = comment.sentStatus ?? (comment.status === 'resolved' ? 'resolved' : 'open')
109 const { isStatusChanged, sentStatus: previous, ...rest } = comment
110 void isStatusChanged
111 void previous
112
113 return status === sentStatus ? { ...rest, status } : { ...rest, status, isStatusChanged: true as const, sentStatus }
114 })
115 // A draft the page still shows after a submit made it open is that open comment, not a new draft.
116 const drafts = sent.filter(comment => comment.status === 'draft' && !submittedIds.has(comment.id)).map(comment => ({ ...comment, round: record.round }))
117
118 return { ...record, updatedAt: now, comments: [...submitted, ...drafts] }
119}
120
121/** Whether the page that sent `incoming` shows another status than the stored set has, or a draft the store dropped. */
122export function isPageBehind(incoming: readonly unknown[], record: ReviewRecord): boolean {
123 const stored = new Map(record.comments.map(comment => [comment.id, comment.status]))
124
125 return incoming.flatMap(item => narrowComment(item) ?? []).some(comment => stored.get(comment.id) !== comment.status)
126}
127
128/** What a submit sends: the drafts of this round and the comments whose status changed since the last submit. */
129export function reviewOf(record: ReviewRecord): { drafts: Comment[]; changed: Comment[] } {
130 return { drafts: record.comments.filter(comment => comment.status === 'draft'), changed: record.comments.filter(comment => comment.isStatusChanged === true) }
131}
132
133/** The record after a submit: the drafts become open comments of this round, status changes are reported, and a new round starts. */
134export function submitRecord(record: ReviewRecord, now: number): ReviewRecord {
135 const comments = record.comments.map(comment => {
136 const { isStatusChanged, sentStatus, ...rest } = comment
137 void isStatusChanged
138 void sentStatus
139
140 return comment.status === 'draft' ? { ...rest, status: 'open' as const } : rest
141 })
142
143 return { ...record, round: record.round + 1, updatedAt: now, comments }
144}
145
146/** Whether a comment's anchor is still in the view model; a comment on the whole review always is. */
147export const isAnchored = (anchor: Anchor, index: AnchorIndex): boolean => anchor.kind === 'review' || index[anchor.kind].has(anchor.id)
148
149/** The comments as the page shows them, each marked anchored or not against the current view model. */
150export const viewComments = (record: ReviewRecord, index: AnchorIndex): ViewComment[] => record.comments.map(comment => ({ ...comment, isAnchored: isAnchored(comment.anchor, index) }))
151
152/** The review of a store key, or a new one when the key is unset or holds another version. */
153export async function loadRecord(host: Host, key: string): Promise<ReviewRecord> {
154 return narrowRecord(await host.storeGet(key)) ?? emptyRecord()
155}
156
157/** The keys to delete so the reviews fit the soft limit: the oldest `updatedAt` first, never the key being saved. */
158export function keysToPrune(entries: readonly { key: string; bytes: number; updatedAt: number }[], keep: string, limit = STORE_SOFT_LIMIT_BYTES): string[] {
159 let total = entries.reduce((sum, entry) => sum + entry.bytes, 0)
160 const pruned: string[] = []
161 for (const entry of [...entries].sort((a, b) => a.updatedAt - b.updatedAt)) {
162 if (total <= limit) break
163 if (entry.key === keep) continue
164 pruned.push(entry.key)
165 total -= entry.bytes
166 }
167
168 return pruned
169}
170
171/** Saves a review, then deletes the oldest other reviews while the mod's keys are above the soft limit (NFR-09). */
172export async function saveRecord(host: Host, key: string, record: ReviewRecord): Promise<void> {
173 await host.storeSet(key, record)
174 const entries = []
175 for (const other of (await host.storeKeys()).filter(name => name.startsWith(REVIEW_KEY_PREFIX))) {
176 const value = other === key ? record : await host.storeGet(other)
177 entries.push({ key: other, bytes: JSON.stringify(value ?? null).length, updatedAt: narrowRecord(value)?.updatedAt ?? 0 })
178 }
179 for (const stale of keysToPrune(entries, key)) await host.storeDelete(stale)
180}
181hooks/prompts.ts 193 lines1import type { Comment, CommentType } from './comments'
2import type { ChangedFile } from './git'
3import { RANGE_NAME_LINES, SOURCE_EXTENSIONS } from './model'
4
5/** The short name of the tool the analysis subagent sends the design model to. */
6export const SUBMIT_NAME = 'submit_model'
7
8/** The tool's full name, as the model calls it. */
9export const SUBMIT_TOOL = `mcp__visual-review__${SUBMIT_NAME}` as const
10
11/** The values of the `analysisModel` option; `inherit` starts the subagent with no model, so it runs on the session's. */
12export const ANALYSIS_MODELS = ['sonnet', 'opus', 'haiku', 'inherit'] as const
13
14export type AnalysisModel = (typeof ANALYSIS_MODELS)[number]
15
16/** The default of `analysisModel`, the same as in plugin.json. */
17export const DEFAULT_ANALYSIS_MODEL: AnalysisModel = 'sonnet'
18
19/** What the status line says while the analysis runs. */
20export const ANALYZING_STATUS = 'Analyzing the design of the change…'
21
22/** What the tool tells the model about itself. */
23export const SUBMIT_DESCRIPTION =
24 'Send the design model of a /visual-review run: the units, relations and database tables of the change. ' +
25 'Send the whole model on each call. The result says that the model is accepted, or lists the gaps to fix before you call again.'
26
27/** What the analysis prompt needs to know of a run. */
28export type AnalysisRun = { runId: string; root: string; base: string; baseLabel: string; files: readonly ChangedFile[]; model: AnalysisModel }
29
30/** The `analysisModel` option read from `unknown`, with the default for a missing or unknown value. */
31export const analysisModelOf = (value: unknown): AnalysisModel =>
32 (ANALYSIS_MODELS as readonly unknown[]).includes(value) ? (value as AnalysisModel) : DEFAULT_ANALYSIS_MODEL
33
34function fileLine(file: ChangedFile): string {
35 const counts = file.added === null || file.removed === null ? 'binary' : `+${file.added} -${file.removed}`
36 const rename = file.oldPath === undefined ? '' : `, renamed from ${file.oldPath}`
37 const untracked = file.isUntracked ? ', untracked' : ''
38
39 return `- ${file.path} (${counts}${rename}${untracked})`
40}
41
42const agentLine = (model: AnalysisModel): string =>
43 model === 'inherit'
44 ? 'Start 1 subagent with the Agent tool (subagent_type general-purpose) and no model parameter, so it uses the model of this session.'
45 : `Start 1 subagent with the Agent tool (subagent_type general-purpose, model ${model}).`
46
47/** The prompt the command submits: the main loop starts 1 subagent, which analyzes the diff and sends the model to the tool. */
48export function analysisPrompt(run: AnalysisRun): string {
49 return [
50 `visual-review run ${run.runId}: the person ran /visual-review to see the design of the change as a diagram.`,
51 '',
52 `${agentLine(run.model)} Give it the task below as its prompt, unchanged. Do not do the analysis yourself. When the subagent reports, answer the person in 1 line: the review page is ready, or why the analysis failed.`,
53 '',
54 '<task>',
55 `Analyze the design of a code change and send it to the tool ${SUBMIT_TOOL}.`,
56 '',
57 `Repository: ${run.root}`,
58 `Base: ${run.baseLabel} (commit ${run.base}). Read the change with \`git diff ${run.base}\` in the repository; untracked files are new files.`,
59 'Changed files (lines added and removed):',
60 ...run.files.map(fileLine),
61 '',
62 'Steps:',
63 `1. Load the tool: call ToolSearch with the query "select:${SUBMIT_TOOL}".`,
64 '2. Read the diff and the changed files. Read an unchanged file only when a changed unit uses it or it uses a changed unit.',
65 `3. Call ${SUBMIT_TOOL} with the whole design model and runId "${run.runId}".`,
66 '4. If the result lists gaps, fix each gap and call the tool again with the whole model. When the result says that the model is accepted, report that in 1 line.',
67 '',
68 'Rules that the tool schema cannot state:',
69 '- A unit is a class, interface, record or struct, enum, or module. A module is 1 file with code but no class; name it after the file.',
70 '- Each unit has 1 state: added, modified, deleted or context. A context unit did not change and is a direct neighbor of a changed unit; give it only id, name, kind, state, file and container.',
71 '- container is the namespace for C#, and the folder for TypeScript, JavaScript and Python.',
72 `- range is the first and the last line of the unit in the current file, or in the base file for a deleted unit. The first ${RANGE_NAME_LINES} lines of the range must contain the unit name. baseRange is the same for the base file, for a modified unit only.`,
73 '- members lists only the members that changed. methods is the number of methods of the unit.',
74 '- summary is 1 to 3 sentences on what the unit does. cohesion is a score from 1 (unrelated responsibilities) to 5 (one responsibility) with a 1-sentence reason.',
75 '- A relation is inheritance, implementation, composition (the unit owns the other) or dependency (the unit uses the other), with the state added, removed or unchanged, between unit ids of the model.',
76 '- tables lists each database table that the change adds, modifies or drops, from EF Core migrations and DbContext, Prisma, TypeORM, Drizzle, .sql files, SQLAlchemy, Alembic and Django migrations. source is the changed file that makes the change; entity is the unit id of the entity class, if there is one.',
77 `- Each changed file with the extension ${SOURCE_EXTENSIONS.join(', ')} must be the file of a unit, the source of a table, or an entry of skippedFiles. Put generated files, such as *.Designer.cs and the EF Core model snapshot, in skippedFiles with the reason.`,
78 '- Write paths relative to the repository with forward slashes, as in the list above.',
79 '</task>',
80 ].join('\n')
81}
82
83/** The tool result that sends the gaps back to the subagent. */
84export const gapsResult = (gaps: readonly string[]): string =>
85 [`The model is not accepted. Fix these ${gaps.length} gaps and call ${SUBMIT_NAME} again with the whole model:`, ...gaps.map(gap => `- ${gap}`)].join('\n')
86
87/** The tool result of a call whose run a newer /visual-review run replaced. */
88export const REPLACED_RESULT = 'This analysis was replaced by a newer /visual-review run. Stop, and report that in 1 line.'
89
90/** The command's answer when the session start could not register `submit_model`: without it no model can come back. */
91export const TOOL_MISSING_TEXT = 'visual-review: the submit_model tool is missing, so the analysis cannot send its model. Run /reload-plugins, then /visual-review again.'
92
93/** The tool result of a call with no run waiting for a model. */
94export const NO_RUN_RESULT = 'No visual review waits for a model. Stop, and tell the person to run /visual-review.'
95
96/** The tool result of an accepted model whose page is open; the URL stays out of it, because it carries the page's token. */
97export const ACCEPTED_RESULT = 'The model is accepted, and the review page is open in the browser. Report in 1 line that the review page is ready.'
98
99/** The tool result of an accepted model shown on the static page, because Node.js is missing or the page server failed. */
100export const STATIC_RESULT =
101 'The model is accepted, and a static review page is open in the browser, because the page server is not available. Report in 1 line that the review page is ready as a static page, where the person copies the review with Copy review.'
102
103/** The reason the static toast gives when `node --version` failed (FR-24). */
104export const NODE_MISSING_CAUSE = 'Node.js not found'
105
106/** The toast of a static page: why the page is static, what to do, and its link (FR-24). */
107export const staticToast = ({ cause, url }: { cause: string; url: string }): string =>
108 `visual-review: ${cause}. The page is static: use Copy review to send the comments. Page: ${url}`
109
110/** The toast of a page that opened in a new tab, or updated the open one. */
111export const readyToast = ({ url, isNewTab }: { url: string; isNewTab: boolean }): string =>
112 isNewTab ? `visual-review: Visual review ready: ${url}` : `visual-review: Visual review updated in the open tab: ${url}`
113
114/** The toast of a page server that did not start or stopped with an error. */
115export const serverFailedToast = (cause: string): string => `visual-review: the page server failed: ${cause}`
116
117/** The toast of a browser launcher that did not start. */
118export const BROWSER_FAILED_TOAST = 'visual-review: the browser did not open. Open the page from the link in the ready message.'
119
120/** The toast of an analysis turn that ended with no accepted model, by the reason the turn ended. */
121export function noModelToast(reason: 'answer' | 'aborted' | 'refusal' | 'error'): string {
122 const why = { answer: 'the turn ended', aborted: 'the turn was interrupted', refusal: 'the model refused', error: 'an API error ended the turn' }[reason]
123
124 return `visual-review: the design analysis failed: ${why} with no design model. Run /visual-review again.`
125}
126
127/** The toast of an analysis prompt that never entered the session. */
128export const droppedToast = (reason: string): string => `visual-review: the design analysis failed: the prompt did not enter the session (${reason}).`
129
130const TYPE_LABELS = { change: 'Change request', question: 'Question' } as const satisfies Record<CommentType, string>
131
132const ANCHOR_ORDER = ['review', 'unit', 'member', 'relation', 'table', 'column'] as const
133
134/** Orders text by code unit, the same on every machine. */
135function compareText(a: string, b: string): number {
136 if (a < b) return -1
137
138 return a > b ? 1 : 0
139}
140
141/** A comment's text as list content: each further line indented, so a line of the text cannot start a new item. */
142const indented = (text: string): string => text.trim().split(/\r?\n/).join('\n ')
143
144/** The comments by anchor, in anchor kind order, then anchor id, then the order the reviewer wrote them. */
145function byAnchor(comments: readonly Comment[]): Comment[][] {
146 const groups = new Map<string, Comment[]>()
147 const sorted = [...comments].sort((a, b) => ANCHOR_ORDER.indexOf(a.anchor.kind) - ANCHOR_ORDER.indexOf(b.anchor.kind) || compareText(a.anchor.id, b.anchor.id))
148 for (const comment of sorted) {
149 const key = `${comment.anchor.kind} ${comment.anchor.id}`
150 groups.set(key, [...(groups.get(key) ?? []), comment])
151 }
152
153 return [...groups.values()]
154}
155
156const anchorLine = (comment: Comment): string => (comment.anchor.kind === 'review' ? 'The whole review' : `${comment.anchor.kind} ${comment.anchor.id}`)
157
158/** What the review prompt needs: the base, the comments of this round, and the older comments whose status changed. */
159export type ReviewInput = { baseLabel: string; drafts: readonly Comment[]; changed: readonly Comment[] }
160
161/**
162 * The prompt "Submit review" sends (FR-19): each comment under its anchor with its type, then the status changes; it
163 * asks Claude to answer the questions, to plan the change requests, and to change no file before the person confirms.
164 */
165export function reviewPrompt({ baseLabel, drafts, changed }: ReviewInput): string {
166 const lines = [
167 `visual-review: the person reviewed the design of the change against ${baseLabel} and sent ${drafts.length} ${drafts.length === 1 ? 'comment' : 'comments'}.`,
168 '',
169 'Answer each question. For the change requests, propose a plan. Do not change any file until the person confirms the plan.',
170 ]
171 for (const group of byAnchor(drafts)) {
172 const [first] = group
173 if (first === undefined) continue
174 lines.push('', `## ${anchorLine(first)}`, ...group.map(comment => `- ${TYPE_LABELS[comment.type]}: ${indented(comment.text)}`))
175 }
176 if (changed.length > 0)
177 lines.push('', '## Status changes of earlier comments', ...changed.map(comment => `- ${comment.status === 'resolved' ? 'Resolved' : 'Open again'}: ${TYPE_LABELS[comment.type]} on ${anchorLine(comment)}: ${indented(comment.text)}`))
178
179 return lines.join('\n')
180}
181
182/** The toast of a submit with nothing new to send. */
183export const NOTHING_TO_SEND_TOAST = 'visual-review: the review has no new comment and no status change to send.'
184
185/** What the page says when a submit sent nothing: the toast without the mod's name. */
186export const NOTHING_TO_SEND_NOTICE = 'The review has no new comment and no status change to send.'
187
188/** What the page says when the review prompt did not enter the session. */
189export const reviewDroppedNotice = (reason: string): string => `The review was not sent: ${reason}. Your comments are kept; submit again.`
190
191/** The toast of a review prompt that did not enter the session. */
192export const reviewDroppedToast = (reason: string): string => `visual-review: the review was not sent: ${reason}. Your comments are kept; submit again.`
193hooks/metrics.ts 79 lines1import type { ChangedFile, Hunk } from './git'
2import type { Range, Relation, Unit } from './model'
3
4/** Lines added and removed; null when Git gives no count (a binary file, a cut diff). */
5export type Delta = { added: number | null; removed: number | null }
6
7/** The coupling of a unit: Ce, the distinct units it depends on, and Ca, the distinct units that depend on it. */
8export type Coupling = { ce: number; ca: number }
9
10const UNKNOWN: Delta = { added: null, removed: null }
11
12/** The lines both ranges share, both ends included. */
13const overlap = (start: number, count: number, range: Range): number => Math.max(0, Math.min(start + count - 1, range.end) - Math.max(start, range.start) + 1)
14
15/** The added lines of the hunks inside `range` of the current file. */
16export const addedIn = (hunks: readonly Hunk[], range: Range): number => hunks.reduce((sum, hunk) => sum + overlap(hunk.newStart, hunk.newCount, range), 0)
17
18/** The removed lines of the hunks inside `range` of the base file. */
19export const removedIn = (hunks: readonly Hunk[], range: Range): number => hunks.reduce((sum, hunk) => sum + overlap(hunk.oldStart, hunk.oldCount, range), 0)
20
21/**
22 * The hunks of a file Git does not track, which `git diff` leaves out: the whole file is added, so it is 1 hunk of its
23 * line count, or none for a binary file.
24 */
25export const untrackedHunks = (file: ChangedFile): Hunk[] =>
26 file.added === null || file.added === 0 ? [] : [{ oldStart: 0, oldCount: 0, newStart: 1, newCount: file.added }]
27
28/**
29 * The lines a unit added and removed (FR-10): the hunks inside its range in the current file, and inside its base range
30 * (its range for a deleted unit) in the base file. A context unit, or a file with unknown hunks, has no delta.
31 */
32export function unitDelta(unit: Unit, hunks: readonly Hunk[] | undefined): Delta {
33 if (unit.state === 'context' || unit.range === undefined || hunks === undefined) return UNKNOWN
34 if (unit.state === 'deleted') return { added: 0, removed: removedIn(hunks, unit.range) }
35
36 return { added: addedIn(hunks, unit.range), removed: unit.baseRange === undefined ? 0 : removedIn(hunks, unit.baseRange) }
37}
38
39/** The lines of a file's hunks that no unit range covers, so the units plus the rest give the file's own delta. */
40export function restDelta(units: readonly Unit[], hunks: readonly Hunk[] | undefined): Delta {
41 if (hunks === undefined) return UNKNOWN
42 const live = units.filter(unit => unit.state !== 'context' && unit.range !== undefined)
43 const isNewCovered = (line: number): boolean => live.some(unit => unit.state !== 'deleted' && unit.range !== undefined && line >= unit.range.start && line <= unit.range.end)
44 const baseRanges = live.flatMap(baseRangeOf)
45 let added = 0
46 let removed = 0
47 for (const hunk of hunks) {
48 for (let line = hunk.newStart; line < hunk.newStart + hunk.newCount; line++) if (!isNewCovered(line)) added++
49 for (let line = hunk.oldStart; line < hunk.oldStart + hunk.oldCount; line++) if (!baseRanges.some(range => line >= range.start && line <= range.end)) removed++
50 }
51
52 return { added, removed }
53}
54
55/** The lines a unit holds in the base file: its range when deleted, its base range when modified, none when added. */
56function baseRangeOf(unit: Unit): Range[] {
57 if (unit.state === 'deleted') return unit.range === undefined ? [] : [unit.range]
58
59 return unit.baseRange === undefined ? [] : [unit.baseRange]
60}
61
62/** Ce and Ca of each unit (FR-11): live relations only (not removed), no self relation, each pair of units counted once. */
63export function coupling(unitIds: readonly string[], relations: readonly Relation[]): Map<string, Coupling> {
64 const pairs = new Set(relations.filter(relation => relation.state !== 'removed' && relation.from !== relation.to).map(relation => `${relation.from}\n${relation.to}`))
65 const result = new Map<string, Coupling>(unitIds.map(id => [id, { ce: 0, ca: 0 }]))
66 for (const pair of pairs) {
67 const [from = '', to = ''] = pair.split('\n')
68 const source = result.get(from)
69 const target = result.get(to)
70 if (source !== undefined) result.set(from, { ...source, ce: source.ce + 1 })
71 if (target !== undefined) result.set(to, { ...target, ca: target.ca + 1 })
72 }
73
74 return result
75}
76
77/** The total lines of a unit: its range, or its base range for a deleted unit; null for a context unit. */
78export const unitLines = (unit: Unit): number | null => (unit.range === undefined ? null : unit.range.end - unit.range.start + 1)
79types/index.d.ts 15 lines1/**
2 * A /visual-review run that waits for its design model: the run id, the base commit and its label, the repository, its
3 * branch and the changed paths, then the main-loop turn that carries the run and the subagent it started.
4 */
5export type PendingRun = { runId: string; base: string; baseLabel: string; root: string; branch: string; files: string[]; analysisTurnId?: string; analysisAgentId?: string }
6
7declare module 'claude-code' {
8 interface PluginState {
9 'visual-review': {
10 /** The run that waits for its design model, or null; a /clear resets it, which cancels the run. */
11 pendingRun: PendingRun | null
12 }
13 }
14}
15