SLOPSHOPPER

visual-review

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…

newguardcommandtoaststatustool
★ 4v0.3.0MITupdated 2026-10-23dougcunha/claude-mods/visual-review
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · visual-review
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /visual-review ⎿ visual-review: Analyzing the design of 1 changed file against feat/auth-refresh. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ visual-review: Analyzing the design of the change…
README

<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>

✨ What it does

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:

  1. 🔎 Collect: the command finds the base and the changed files with read-only Git commands, and answers Analyzing the design of <n> changed files against <base>. A status line under the prompt reads Analyzing the design of the change….
  2. 🤖 Analyze: the mod sends 1 prompt to the session. Claude starts 1 subagent (the 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.
  3. ✅ Check: the mod accepts the model only when each changed source file (.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.
  4. 📐 Measure: the mod reads the line changes from Git 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.
  5. 🌐 Serve: the mod starts a small Node.js server (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.
  6. 📄 Static page without Node.js: when 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>.

🗺️ The page

  • Toolbar: the base and the branch, the counts of added, modified and deleted units and tables as large figures, and a filter: units and tables, units only, or tables only. The filter redraws the diagram with no reload.
  • Containers: units sit in their namespace (C#) or folder (TypeScript, JavaScript, Python). A container that holds only unchanged neighbors starts closed. Click its header, or press Enter or Space on it, to open or close it; an arrow to a unit in a closed container goes to the container. Arrows of the same kind that meet there merge into 1, whose label counts each state, for example dependency · 2 relations (+1 =1); open the container to comment on one of them.
  • Unit cards: a state badge (+ 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.
  • Relations: inheritance and implementation end in a hollow triangle, a dependency in an open arrow, a composition starts with a filled diamond. Implementation and dependency lines are dashed. A label in the middle of each arrow names the kind and the state (added, removed, unchanged).
  • Tables: a dashed group of the database tables, each with its columns, their state, a type change (nvarchar(100) → nvarchar(200)), the primary key and the foreign keys. A dotted line links an entity unit to its table.
  • Other files: the changed files outside the diagram, with their line counts, and the generated files the analysis skipped, with the reason.
  • Comments: click a card, a changed member, an arrow label, a table or a column, or press Enter on it, to open the comment form in the side panel; an outline marks the element the form is about. Comment on the whole review comments on everything. Choose Change request or Question, write, and Save. A count shows next to each element with comments. Edit or delete a comment of this round before you submit.
  • Submit review: sends every comment of this round as 1 prompt to the session, grouped by element, with its type. Claude answers the questions, proposes a plan for the change requests, and changes no file before you confirm the plan. When Claude is busy, the prompt waits until the turn ends: the page says 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.
  • Copy review (static page): the button in place of Submit review copies the same review text to the clipboard. Paste it in the Claude Code prompt. The static page keeps your comments in the browser's storage, per branch and base commit, so a reload or the next static page of the same review shows them again.
  • Rounds: after the next /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.
  • Views: the switch over the diagram picks 1 of 3 views, and the browser keeps the choice.
  • Overview shows 1 box per container and 1 for the tables, each with its count of units and of changed states (+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.
  • Diagram is the full graph. Hover a card and click Focus to show that unit or table with its direct neighbors only; Show all or Esc shows everything again.
  • List shows 1 row per unit and per table, by container, with no layout to follow: the card content, then each relation as words (uses, extended by, owns, maps to table) with a link to the row of the other unit. The rows take comments like the cards.
  • Zoom: in Overview and Diagram, − and + zoom, Fit shows the whole graph in the window, and Ctrl+wheel (or a touchpad pinch) zooms at the pointer.
  • Move and hide: drag the diagram with the mouse to move it in any direction; a short click still opens the comment form. The diagram also scrolls with the wheel, Shift+wheel for the horizontal direction, and the arrow keys when it has the focus. Hide comments in the header gives the panel's column to the diagram; the button then reads Show comments with the count of new comments. A click on an element shows the panel again. The browser keeps the choice for the next review.
  • Keyboard and themes: every container header, card, Focus button and arrow label takes the focus with Tab, in the order container, then its cards; in List, every group head, row, member, relation and link does. Enter on a card or a row opens the comment form. The page follows the light or dark theme of the system; every state has a text badge, not only a color. In a window narrower than 900 px, the comment panel moves under the diagram.

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.

🚀 Install

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.

🎮 Use

CommandBase
/visual-reviewThe 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:

  • the folder is not in a Git repository: visual-review needs a Git repository.
  • there is nothing to compare: No changes against <base>.
  • the reference does not exist: Unknown Git reference: <ref>.
  • no main branch exists, or HEAD has no common commit with it: name a base with /visual-review <ref>.

🔧 Settings

Set them in /config, under the mod's name.

OptionDefaultWhat it does
analysisModelsonnetThe model of the subagent that reads the diff and returns the design model: sonnet, opus, haiku, or inherit for the model of the session.

🚧 Limitations

  • Cost: each run starts 1 subagent that reads the diff and the files around it. Nothing starts an analysis but the command.
  • The tool loads on demand: submit_model waits behind ToolSearch, so it costs no context until a run asks the subagent to load it.
  • One run at a time: a new /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.js: the interactive page needs 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.
  • Local and private page: the server listens on 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.
  • Server lifetime: the server runs while the session runs. /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.
  • Large changes: the layout of 100 units and 30 tables takes about 1 s on a desktop computer. Many relations across containers make a wide diagram; close containers or filter to units or tables to read it.
  • Stored comments: the comments live in the mod's store of Claude Code, under 1 key per repository and branch, so they survive a closed 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.
  • Model text on disk: 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.
  • Exact numbers, limited by the ranges: the line counts come from Git, so 2 runs over the same diff give the same file totals, and 2 runs over the same design model the same unit deltas, Ce and Ca. A unit's delta is only as right as the line range the analysis gave it; a range whose first lines do not hold the unit name is sent back as a gap. A diff larger than 4 MiB leaves every unit delta unknown and keeps the file totals.
  • Platforms: tested on Windows 11 with Chrome and Chromium. The browser launch on macOS (open) and Linux (xdg-open) is not tested yet.
  • Read-only Git: the mod runs only read-only Git commands, with the repository root as the working folder. Each untracked file costs 1 git diff --no-index run, so its line counts come from Git like the tracked files.

🔨 Development

claude plugin validate .
claude plugin test .
claude --plugin-dir .
FileRole
hooks/register.tsxThe command and the tool, the command.run, tool.call, turn.start and turn.complete hooks
hooks/git.tsThe repository, the base, the changed files and their parsers
hooks/model.tsThe submit_model schema, the narrowing of its input, and the coverage, id and range checks
hooks/metrics.tsUnit line deltas from the hunks, the lines outside the units, Ce and Ca
hooks/view.tsThe view model: cards, containers, relations, tables, entity links and other files, and its file
hooks/prompts.tsThe analysis prompt, the review prompt, the tool description, and the results and toasts the mod sends
hooks/comments.tsThe comment store: key, narrowing, merge, rounds, anchors and pruning
hooks/server.tsThe server and browser argv, the line buffer and the parser of the server's lines, the page mode, and the static page
server/server.mjsThe 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.tsPath helpers that normalize separators, and the session folder
hooks/host.tsThe engine calls the helper modules use
types/index.d.tsThe $.state contract
PRODUCT.md, DESIGN.mdThe product record and the design system of the page: tokens, components and the rules a change to web/ keeps

📄 License

MIT. The page ships elkjs 0.9.3 (web/vendor/elk.bundled.js), the Eclipse Layout Kernel, under the EPL-2.0.

Source 11 files
hooks/register.tsx 385 lines
1import { 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}
385
hooks/host.ts 17 lines
1/** 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}
17
hooks/git.ts 218 lines
1import 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}
218
hooks/model.ts 387 lines
1import 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}
387
hooks/server.ts 120 lines
1import 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}
120
hooks/paths.ts 17 lines
1/** 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}`
17
hooks/view.ts 265 lines
1import { 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}
265
hooks/comments.ts 181 lines
1import 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}
181
hooks/prompts.ts 193 lines
1import 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.`
193
hooks/metrics.ts 79 lines
1import 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)
79
types/index.d.ts 15 lines
1/**
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