/review-map [PR#]: opens an interactive diagram of uncommitted changes or a pull request in the browser: themes, links, a review order, repo checks and…

A Claude Code mod that turns a pull request, or your uncommitted changes, into an interactive page that is easier to review than a plain diff.
/review-map 1234 maps the change with a model, runs a few local checks, writes one self-contained HTML page and opens it in your browser.
/plugin marketplace add nvtdev/review-map
/plugin install review-map@nvtdev-mods
/reload-plugins
You need the GitHub CLI (gh), logged in with gh auth login, for pull requests. Uncommitted changes only need git.
/review-map # uncommitted changes in the current folder
/review-map 1234 # a PR of the default repository
/review-map 1234 --repo healee-md # another repository (a bare name belongs to Heal8)
/review-map 1234 --repo owner/name # any repository
/review-map https://github.com/owner/name/pull/1234
/review-map 1234 --refresh # map again, even if this diff was mapped before
/review-map config # list the settings
/review-map config model opus # change one
The same request again reopens the saved map without calling the model.
| Setting | Default | What it does |
|---|---|---|
model | sonnet | The model that groups the files: sonnet, opus or haiku |
defaultOrder | theme | The review order the page opens on |
repoChecks | on | Convention checks (tabs, double quotes, file names, localization, migrations, tests) |
lineChecks | on | Checks on the added lines |
sensitiveAreas | on | Flag sensitive areas |
openBrowser | on | Open the page in the browser; off just prints its path |
repo | Heal8/healee-platform | The repository PRs are read from when --repo is not given |
The repository checks and the default repository are written for the Healee platform. Switch repoChecks off, and set repo, to use it elsewhere.
The mod is a folder of TypeScript hooks. To try changes, point CLAUDE_CODE_PLUGIN_DIRS at the folder and run /reload-plugins. Check and test with claude plugin validate . and claude plugin test ..
hooks/register.ts 532 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register } from 'claude-code'
3
4import type { DiffMapData, DiffMapFile, DiffMapSensitive, DiffMapThemes } from '../types'
5import { repoContext, runChecks } from './checks'
6import { PART_BUDGET, mergeThemes, needsParts, planParts } from './chunks'
7import { buildDiagramHtml } from './diagram'
8import { isGenerated, reviewPaths } from './model'
9import { PROMPT_VERSION, buildPrompt, buildStitchPrompt, parseDiff, parseStitch, parseThemes } from './parse'
10import type { ParsedDiff } from './parse'
11import type { StrategyKey } from './review-order'
12import { sensitiveTags } from './sensitive'
13import { loadCachedThemes, saveCachedThemes, sha256 } from './store'
14
15const MAX_UNTRACKED = 60
16const STRATEGY_KEYS: readonly string[] = ['theme', 'bottom-up', 'top-down', 'model']
17
18/** What the manifest's `userConfig` says, read once per load of the module. */
19type Settings = {
20 model: string
21 defaultOrder: StrategyKey
22 repoChecks: boolean
23 lineChecks: boolean
24 sensitiveAreas: boolean
25 openBrowser: boolean
26 /** owner/name to read pull requests from when none is named. */
27 repo: string
28}
29
30/** The repository pull requests are read from when none is named. */
31const DEFAULT_REPO = 'Heal8/healee-platform'
32const DEFAULT_OWNER = 'Heal8'
33
34const DEFAULTS: Settings = { model: 'sonnet', defaultOrder: 'theme', repoChecks: true, lineChecks: true, sensitiveAreas: true, openBrowser: true, repo: DEFAULT_REPO }
35
36/** owner/name, or just a name, which belongs to the default owner. */
37const REPO_PATTERN = /^(?:[\w.-]+\/)?[\w.-]+$/
38const normalizeRepo = (value: string) => (value.includes('/') ? value : `${DEFAULT_OWNER}/${value}`)
39
40let settings: Settings = DEFAULTS
41
42function readSettings(options: PluginOptions): Settings {
43 const text = (key: string, fallback: string) => (typeof options[key] === 'string' ? (options[key] as string) : fallback)
44 const flag = (key: string, fallback: boolean) => (typeof options[key] === 'boolean' ? (options[key] as boolean) : fallback)
45 const order = text('defaultOrder', DEFAULTS.defaultOrder)
46 return {
47 model: text('model', DEFAULTS.model),
48 defaultOrder: STRATEGY_KEYS.includes(order) ? (order as StrategyKey) : DEFAULTS.defaultOrder,
49 repoChecks: flag('repoChecks', DEFAULTS.repoChecks),
50 lineChecks: flag('lineChecks', DEFAULTS.lineChecks),
51 sensitiveAreas: flag('sensitiveAreas', DEFAULTS.sensitiveAreas),
52 openBrowser: flag('openBrowser', DEFAULTS.openBrowser),
53 repo: REPO_PATTERN.test(text('repo', '').trim()) ? normalizeRepo(text('repo', '').trim()) : DEFAULTS.repo,
54 }
55}
56
57/** The settings as `/review-map config` names them; `values` is every value a text setting takes. */
58/** `values` is every value a text setting takes; `pattern` and `hint` stand in for a free-text one. */
59const SETTING_ROWS: { key: keyof Settings; title: string; values?: readonly string[]; pattern?: RegExp; hint?: string }[] = [
60 { key: 'model', title: 'Model', values: ['sonnet', 'opus', 'haiku'] },
61 { key: 'defaultOrder', title: 'Default review order', values: STRATEGY_KEYS },
62 { key: 'repoChecks', title: 'Repo convention checks' },
63 { key: 'lineChecks', title: 'Added-line checks' },
64 { key: 'sensitiveAreas', title: 'Sensitive-area flags' },
65 { key: 'openBrowser', title: 'Open the diagram in the browser' },
66 { key: 'repo', title: 'Default repository for pull requests', pattern: REPO_PATTERN, hint: 'owner/name, or just a name for a repository of Heal8' },
67]
68
69const showSetting = (value: string | boolean) => (typeof value === 'boolean' ? (value ? 'on' : 'off') : value === '' ? 'none' : value)
70
71const choicesOf = (row: (typeof SETTING_ROWS)[number]) => {
72 if (row.hint) return row.hint
73 const values = row.values ?? ['on', 'off']
74 return `${values.slice(0, -1).join(', ')} or ${values[values.length - 1]}`
75}
76
77const settingLine = (row: (typeof SETTING_ROWS)[number], current: Settings) => {
78 const value = current[row.key]
79 const note = value === DEFAULTS[row.key] ? ' (default)' : ` (default: ${showSetting(DEFAULTS[row.key])})`
80 return ` ${row.title} [${row.key}]: ${showSetting(value)}${note}. ${choicesOf(row)}.`
81}
82
83const SETTINGS_JSON = (key: string, value: string | boolean) =>
84 ` "pluginConfigs": { "review-map@inline": { "options": { ${JSON.stringify(key)}: ${JSON.stringify(value)} } } }`
85
86/** What `/review-map config` prints: each setting as it stands now, and how to change them. */
87function configText(current: Settings): string {
88 return [
89 'review-map settings',
90 ...SETTING_ROWS.map(row => settingLine(row, current)),
91 '',
92 'Change one here: /review-map config <name> <value>, for example /review-map config model opus. The word default puts it back.',
93 'Or in the /config menu, where each one is a row named as above, or in ~/.claude/settings.json (then run /reload-plugins):',
94 SETTINGS_JSON('model', 'opus'),
95 ].join('\n')
96}
97
98/** Reads what was typed as a setting's value; undefined when the setting does not take it. */
99function parseSettingValue(row: (typeof SETTING_ROWS)[number], raw: string): string | boolean | undefined {
100 const text = raw.trim().toLowerCase()
101 if (text === 'default' || text === 'reset') return DEFAULTS[row.key]
102 if (row.pattern) return row.pattern.test(raw.trim()) ? normalizeRepo(raw.trim()) : undefined
103 if (row.values) return row.values.includes(text) ? text : undefined
104 if (['on', 'true', 'yes', '1'].includes(text)) return true
105 if (['off', 'false', 'no', '0'].includes(text)) return false
106 return undefined
107}
108
109/** `/review-map config <name> [value]`: shows one setting, or changes it through the /config row it belongs to. */
110async function changeSetting($: EngineInterface, args: readonly string[]): Promise<string> {
111 const [name = '', ...rest] = args
112 const row = SETTING_ROWS.find(r => r.key.toLowerCase() === name.toLowerCase())
113 if (!row) return `Unknown setting "${name}". The settings are: ${SETTING_ROWS.map(r => r.key).join(', ')}.`
114 if (rest.length === 0) return `${settingLine(row, settings).trim()}\nChange it with /review-map config ${row.key} <value>.`
115
116 const value = parseSettingValue(row, rest.join(' '))
117 if (value === undefined) return `"${rest.join(' ')}" is not a value for ${row.key}. Choose ${choicesOf(row)}, or default.`
118
119 const before = settings[row.key]
120 const fallback = `Set it in the /config menu instead, or in ~/.claude/settings.json (then run /reload-plugins):\n${SETTINGS_JSON(row.key, value)}`
121 const rows = await $.config.list().catch(() => [])
122 const found = rows.find(r => r.key.startsWith('review-map') && r.key.endsWith(`.${row.key}`))
123 if (found?.isLocked) return `${row.title} is locked by your organisation's settings and cannot be changed here.`
124
125 const result = await $.config.set({ key: found?.key ?? `review-map.${row.key}`, value }).catch(error => ({ failed: String((error as Error).message ?? error) }))
126 if ('failed' in result) return `Could not change ${row.key} from the command (${result.failed}). ${fallback}`
127 if ('deny' in result) return `Could not change ${row.key} (${String(result.deny)}). ${fallback}`
128
129 settings = { ...settings, [row.key]: value }
130 return `${row.title} [${row.key}] is now ${showSetting(value)} (it was ${showSetting(before)}). It applies to the next /review-map.`
131}
132
133/** The last mapped change, kept so the same request can reopen its diagram. */
134const data = atom({ plugin: 'review-map', key: 'data' } as const, null)
135
136type Source = { kind: 'local' } | { kind: 'pr'; number: string; repo?: string }
137
138const keyOf = (source: Source) => (source.kind === 'pr' ? `pr:${source.repo && source.repo !== DEFAULT_REPO ? `${source.repo}#` : ''}${source.number}` : 'local')
139
140/** Each run gets a number; only the latest one writes, so a newer run supersedes an older one. */
141let runId = 0
142
143/** True while a run started by the command should open the diagram once it has finished. */
144let openWhenDone = false
145
146type Collected = { title: string; label: string; diff: string; scope: string }
147
148async function collectDiff($: EngineInterface, source: Source): Promise<Collected> {
149 const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'])
150 const scope = top.exitCode === 0 ? top.stdout.trim() : ''
151
152 if (source.kind === 'pr') {
153 const repoArgs = source.repo ? ['--repo', source.repo] : []
154 // gh finds the repository from the folder; outside a clone it cannot, and its own message does not say what to do
155 const explain = (stderr: string, what: string) =>
156 !source.repo && /not a git repository|could not determine base repo/i.test(stderr)
157 ? `gh cannot tell which repository PR #${source.number} is in, because this folder is not a git clone. Run /review-map from inside a clone, add --repo owner/name (for example --repo Heal8/healee-platform), or set a default with /review-map config repo owner/name.`
158 : stderr.trim() || `${what} failed`
159 const info = await $.process.run(
160 ['gh', 'pr', 'view', source.number, ...repoArgs, '--json', 'title,number,baseRefName,headRefName,author'],
161 { timeoutMs: 60_000 },
162 )
163 if (info.exitCode !== 0) throw new Error(explain(info.stderr, `gh pr view ${source.number}`))
164 const pr = JSON.parse(info.stdout) as {
165 title: string
166 number: number
167 baseRefName: string
168 headRefName: string
169 author?: { login?: string }
170 }
171 const diff = await $.process.run(['gh', 'pr', 'diff', source.number, ...repoArgs], { timeoutMs: 120_000 })
172 if (diff.exitCode !== 0) throw new Error(explain(diff.stderr, `gh pr diff ${source.number}`))
173 return {
174 title: `PR #${pr.number}: ${pr.title}`,
175 label: `${pr.headRefName} → ${pr.baseRefName}${pr.author?.login ? ` · @${pr.author.login}` : ''}`,
176 diff: diff.stdout,
177 scope,
178 }
179 }
180
181 if (top.exitCode !== 0) throw new Error('This folder is not a git repository, so there are no uncommitted changes to map. Run /review-map from inside a clone, or give a PR number with --repo owner/name.')
182 const cwd = scope
183 const branch = (await $.process.run(['git', 'branch', '--show-current'], { cwd })).stdout.trim() || 'detached HEAD'
184
185 const tracked = await $.process.run(['git', 'diff', 'HEAD', '-M', '--no-color', '--no-ext-diff'], { cwd, timeoutMs: 60_000 })
186 if (tracked.exitCode !== 0) throw new Error(tracked.stderr.trim() || 'git diff failed')
187
188 const untrackedList = await $.process.run(['git', 'ls-files', '--others', '--exclude-standard', '-z'], { cwd })
189 const untracked = untrackedList.stdout.split('\0').filter(Boolean)
190 const pieces = [tracked.stdout]
191 for (const file of untracked.slice(0, MAX_UNTRACKED)) {
192 // exit code 1 is git's "files differ", the normal answer here
193 const one = await $.process.run(['git', 'diff', '--no-index', '--no-color', '--', '/dev/null', file], { cwd })
194 pieces.push(one.stdout)
195 }
196 const skipped = untracked.length - MAX_UNTRACKED
197 return {
198 title: `Uncommitted changes on ${branch}`,
199 label: skipped > 0 ? `working tree vs HEAD (${skipped} untracked files not shown)` : 'working tree vs HEAD, untracked included',
200 diff: pieces.join('\n'),
201 scope,
202 }
203}
204
205
206type Mapped = { themes?: DiffMapThemes; rawThemes?: string; error?: string }
207
208const describeFailure = (reply: { reason: string; status?: number | null }) =>
209 reply.reason === 'api-error' ? `API error ${reply.status ?? ''}`.trim() : reply.reason
210
211/** Runs `fn` over the items, at most `limit` at a time, keeping the results in order. */
212async function mapLimit<T, R>(items: readonly T[], limit: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]> {
213 const out = new Array<R>(items.length)
214 let next = 0
215 const worker = async () => {
216 while (next < items.length) {
217 const i = next++
218 out[i] = await fn(items[i] as T, i)
219 }
220 }
221 await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))
222 return out
223}
224
225/** A change that fits one call: one question to the model. */
226async function mapWhole($: EngineInterface, title: string, parsed: ParsedDiff): Promise<Mapped> {
227 const reply = await $.model.complete({
228 model: settings.model,
229 prompt: buildPrompt(title, parsed, repoContext(parsed.files)),
230 maxTokens: 8_000,
231 timeoutMs: 240_000,
232 })
233 if (!reply.isAnswered) return { error: `Could not group the changes: ${describeFailure(reply)}` }
234 const themes = parseThemes(reply.text, parsed.files)
235 return themes ? { themes } : { rawThemes: reply.text }
236}
237
238/**
239 * A change too big for one call: each part (folders kept together) is mapped by itself, three at a
240 * time, then one more call relates the parts to each other. A part that fails leaves its files under
241 * "Not mapped" instead of failing the whole map.
242 */
243async function mapInParts($: EngineInterface, title: string, parsed: ParsedDiff): Promise<Mapped> {
244 const plan = planParts(parsed.files, Object.fromEntries(parsed.patches))
245 $.ui.toast(`Large change: mapping it in ${plan.parts.length} parts…`)
246 const context = repoContext(parsed.files)
247
248 const results = await mapLimit(plan.parts, 3, async (files, i) => {
249 const part: ParsedDiff = { files, patches: new Map(files.map(f => [f.path, parsed.patches.get(f.path) ?? ''])) }
250 const reply = await $.model.complete({
251 model: settings.model,
252 prompt: buildPrompt(title, part, context, { budget: PART_BUDGET, perFileCap: plan.perFileCap, part: { index: i + 1, total: plan.parts.length } }),
253 maxTokens: 8_000,
254 timeoutMs: 240_000,
255 })
256 if (!reply.isAnswered) return { files, error: describeFailure(reply) }
257 const themes = parseThemes(reply.text, files)
258 return themes ? { files, themes } : { files, error: 'the reply could not be read' }
259 })
260
261 const mapped = results.flatMap(r => (r.themes ? [r.themes] : []))
262 const failed = results.filter(r => r.themes === undefined)
263 if (mapped.length === 0) return { error: `Could not group the changes: ${failed[0]?.error ?? 'no reply'}` }
264 if (failed.length > 0) {
265 mapped.push({
266 summary: '',
267 groups: [{ title: 'Not mapped', kind: 'other', risk: 'low', why: 'the model call for these files failed', files: failed.flatMap(r => r.files).map(f => ({ path: f.path, notes: [] })) }],
268 reviewOrder: [],
269 concerns: [],
270 links: [],
271 })
272 }
273
274 // the links and the flow take room: a reply cut short would lose the summary with them
275 const stitched = await $.model.complete({ model: settings.model, prompt: buildStitchPrompt(title, mapped), maxTokens: 8_000, timeoutMs: 180_000 })
276 const stitch = stitched.isAnswered ? parseStitch(stitched.text, parsed.files) : undefined
277 if (!stitched.isAnswered) $.ui.toast(`Joining the parts failed (${describeFailure(stitched)}), so the diagram has no links between the parts and no run-time flow.`)
278 else if (!stitch) $.ui.toast("The model's reply that joins the parts could not be read, so the diagram has no links between the parts and no run-time flow.")
279 return { themes: mergeThemes(mapped, stitch) }
280}
281
282/** Reads the change, maps it, and keeps the result; the finishing step opens the diagram when asked to. */
283async function generate($: EngineInterface, source: Source, id: number, isForced: boolean) {
284 await mapChange($, source, id, isForced)
285 if (openWhenDone) await autoOpen($, id)
286}
287
288async function mapChange($: EngineInterface, source: Source, id: number, isForced: boolean) {
289 const write = async (fn: (current: DiffMapData | null) => DiffMapData | null) => {
290 if (id === runId) await update($, data, fn)
291 }
292
293 let collected: Collected
294 try {
295 collected = await collectDiff($, source)
296 } catch (error) {
297 const message = String((error as Error).message ?? error)
298 await write(current => current && { ...current, phase: 'error', error: message })
299 if (id === runId && openWhenDone) {
300 openWhenDone = false
301 $.ui.log(`review-map: ${message}`)
302 $.ui.toast(`Could not map the changes: ${message}`)
303 }
304 return
305 }
306
307 const key = keyOf(source)
308 const parsed = parseDiff(collected.diff)
309 const patches = Object.fromEntries(parsed.patches)
310 const sensitive: Record<string, DiffMapSensitive[]> = {}
311 if (settings.sensitiveAreas) {
312 for (const file of parsed.files) {
313 const tags = sensitiveTags(file.path, patches[file.path] ?? '')
314 if (tags.length > 0) sensitive[file.path] = tags
315 }
316 }
317 const base: DiffMapData = {
318 key,
319 ...(source.kind === 'pr' && source.repo ? { repo: source.repo } : {}),
320 storeKey: `${collected.scope}|${key}`,
321 source: collected.label,
322 title: collected.title,
323 files: parsed.files,
324 phase: 'thinking',
325 patches,
326 checks: runChecks(parsed.files, patches, { repo: settings.repoChecks, lines: settings.lineChecks }),
327 sensitive,
328 }
329
330 if (parsed.files.length === 0) {
331 await write(() => ({ ...base, phase: 'done' }))
332 return
333 }
334
335 // the same diff mapped before needs no model call
336 const cacheKey = await sha256(`${PROMPT_VERSION}|${settings.model}|${collected.diff}`)
337 const cached = isForced ? undefined : await loadCachedThemes($, cacheKey).catch(() => undefined)
338 if (cached) {
339 await write(() => ({ ...base, phase: 'done', themes: { ...cached, concerns: cached.concerns ?? [], links: cached.links ?? [] }, isCached: true }))
340 return
341 }
342 await write(() => base)
343
344 const mapped = needsParts(parsed.files, patches) ? await mapInParts($, collected.title, parsed) : await mapWhole($, collected.title, parsed)
345 if (mapped.error) {
346 const message = mapped.error
347 await write(current => current && { ...current, phase: 'error', error: message })
348 return
349 }
350 const { themes, rawThemes } = mapped
351 await write(current => current && { ...current, phase: 'done', ...(themes ? { themes } : { rawThemes }) })
352 if (themes) await saveCachedThemes($, cacheKey, themes).catch(() => undefined)
353}
354
355function start($: EngineInterface, source: Source, isForced = false, shouldOpen = false) {
356 const id = ++runId
357 openWhenDone = shouldOpen
358 const title = source.kind === 'pr' ? `PR #${source.number}` : 'Uncommitted changes'
359 const loading: DiffMapData = { key: keyOf(source), storeKey: '', source: '', title, files: [], phase: 'loading' }
360 return update($, data, () => loading).then(() => {
361 // detach from the command's dispatch so the prompt is free while the model works
362 $.clock.after(1, () => void generate($, source, id, isForced))
363 })
364}
365
366/** `--refresh` maps again even when the same diff was mapped before; the rest names the pull request. */
367function parseArgs(args: string): { source: Source | { error: string }; wantsRefresh: boolean; repo?: string } {
368 const tokens = args.trim().split(/\s+/).filter(Boolean)
369 const rest: string[] = []
370 let wantsRefresh = false
371 let repo: string | undefined
372 for (let i = 0; i < tokens.length; i++) {
373 const token = tokens[i] as string
374 if (token === '--refresh') wantsRefresh = true
375 else if (token === '--repo' || token.startsWith('--repo=')) {
376 const value = token === '--repo' ? (tokens[++i] ?? '') : token.slice('--repo='.length)
377 if (!REPO_PATTERN.test(value)) return { source: { error: `--repo takes owner/name, or just a name for a repository of ${DEFAULT_OWNER}, for example --repo healee-md. Got "${value}".` }, wantsRefresh }
378 repo = normalizeRepo(value)
379 } else if (token.startsWith('--')) {
380 return { source: { error: `Unknown option ${token}. The command takes an optional PR number or URL, --refresh, --repo [owner/]name, or config.` }, wantsRefresh }
381 } else rest.push(token)
382 }
383 return { source: parseSource(rest.join(' ')), wantsRefresh, repo }
384}
385
386function parseSource(args: string): Source | { error: string } {
387 const trimmed = args.trim()
388 if (trimmed === '') return { kind: 'local' }
389 // a pull request's own address says which repository it is in
390 const url = trimmed.match(/^https?:\/\/github\.com\/([\w.-]+\/[\w.-]+)\/pull\/(\d+)/)
391 if (url?.[1] && url[2]) return { kind: 'pr', number: url[2], repo: url[1] }
392 const match = trimmed.match(/^(?:#|.*\/pull\/)?(\d+)\/?$/)
393 return match?.[1] ? { kind: 'pr', number: match[1] } : { error: `Expected a PR number or URL, got "${trimmed}"` }
394}
395
396/** Opens the diagram of the kept map, as the finishing step of a run or on its own. */
397async function autoOpen($: EngineInterface, id?: number) {
398 if (id !== undefined && id !== runId) return
399 const map = await read($, data)
400 if (!map) return
401 openWhenDone = false
402 if (map.files.length === 0) {
403 $.ui.toast('No changes to map.')
404 return
405 }
406 if (map.phase === 'error' && map.error) {
407 $.ui.log(`review-map: ${map.error}`)
408 $.ui.toast(`${map.error}. The diagram shows the files without themes.`)
409 }
410 await openDiagram($, map).catch(error => $.ui.toast(`Could not open the diagram: ${String((error as Error).message ?? error)}`))
411}
412
413const FULL_FILE_LIMIT = 60
414const FULL_FILE_MAX_CHARS = 150_000
415const FULL_FILES_TOTAL_CHARS = 3_000_000
416
417/**
418 * The files as they stand after the change, for the diagram's "Full file" view: read from disk for
419 * local changes, from GitHub at the PR's head commit for a pull request. Best effort, bounded.
420 */
421async function collectFullFiles($: EngineInterface, map: DiffMapData): Promise<Record<string, string>> {
422 const wanted = reviewPaths(map)
423 .map(path => map.files.find(f => f.path === path))
424 .filter((f): f is DiffMapFile => f !== undefined && f.status !== 'deleted' && f.status !== 'binary' && !isGenerated(f.path))
425 .slice(0, FULL_FILE_LIMIT)
426 if (wanted.length === 0) return {}
427
428 let load: (path: string) => Promise<string | undefined>
429 if (map.key.startsWith('pr:')) {
430 // `{owner}/{repo}` is gh's own stand-in for the repository of the folder it runs in
431 const repoPath = map.repo ?? '{owner}/{repo}'
432 const head = await $.process.run(['gh', 'api', `repos/${repoPath}/pulls/${/(\d+)$/.exec(map.key)?.[1] ?? ''}`, '--jq', '.head.sha'], { timeoutMs: 30_000 }).catch(() => undefined)
433 const sha = head?.exitCode === 0 ? head.stdout.trim() : ''
434 if (!/^[0-9a-f]{7,64}$/.test(sha)) return {}
435 load = async path => {
436 const url = `repos/${repoPath}/contents/${path.split('/').map(encodeURIComponent).join('/')}?ref=${sha}`
437 const run = await $.process.run(['gh', 'api', '-H', 'Accept: application/vnd.github.raw', url], { timeoutMs: 30_000 }).catch(() => undefined)
438 return run?.exitCode === 0 && !run.isStdoutTruncated ? run.stdout : undefined
439 }
440 } else {
441 const root = map.storeKey.split('|')[0] ?? ''
442 if (root === '') return {}
443 load = async path => {
444 const text = await $.fs.read(`${root}/${path}`).catch(() => undefined)
445 return typeof text === 'string' ? text : undefined
446 }
447 }
448
449 const out: Record<string, string> = {}
450 let total = 0
451 for (let i = 0; i < wanted.length; i += 5) {
452 const batch = wanted.slice(i, i + 5)
453 const texts = await Promise.all(batch.map(f => load(f.path)))
454 batch.forEach((f, j) => {
455 const text = texts[j]
456 if (text === undefined || text.length > FULL_FILE_MAX_CHARS || total + text.length > FULL_FILES_TOTAL_CHARS) return
457 out[f.path] = text
458 total += text.length
459 })
460 }
461 return out
462}
463
464/** Writes the diagram page to the temp folder and opens it in the browser. */
465async function openDiagram($: EngineInterface, map: DiffMapData) {
466 $.ui.toast('Building the diagram, collecting the full files…')
467 const fullFiles = await collectFullFiles($, map).catch(() => ({}))
468 const html = buildDiagramHtml(map, fullFiles, { defaultStrategy: settings.defaultOrder })
469 const tmp = ((await $.env.get('TMPDIR')) ?? '/tmp').replace(/\/+$/, '')
470 const file = `${tmp}/review-map/${map.key.replace(/[^a-z0-9]+/gi, '-')}.html`
471 try {
472 await $.fs.write(file, html)
473 } catch (error) {
474 $.ui.toast(`Could not write the diagram: ${String((error as Error).message ?? error)}`)
475 return
476 }
477 const complete = Object.keys(fullFiles).length
478 const noFlow = map.themes && !map.themes.flows?.length && !map.themes.stateCharts?.length && !map.themes.flow && !map.themes.states ? '; the model drew no run-time flow for this change' : ''
479 const detail = complete > 0 ? ` (${complete} full files${noFlow})` : noFlow ? ` (${noFlow.slice(2)})` : ''
480 if (!settings.openBrowser) {
481 $.ui.toast(`Diagram saved to ${file}${detail}`)
482 return
483 }
484 const uname = await $.process.run(['uname']).catch(() => undefined)
485 const opener = uname?.stdout.trim() === 'Darwin' ? 'open' : 'xdg-open'
486 const opened = await $.process.run([opener, file]).catch(() => undefined)
487 $.ui.toast(opened?.exitCode === 0 ? `Diagram opened in your browser${detail}` : `Diagram saved to ${file}${detail}`)
488}
489
490export const register: Register = (on, options) => {
491 settings = readSettings(options)
492
493 on('session.start', async ($, e, next) => {
494 await $.command.register({
495 name: 'review-map',
496 description: 'Open a diagram of uncommitted changes, or of a pull request (pass its number), in the browser',
497 argumentHint: '[PR number] [--refresh] | config [name value]',
498 })
499
500 return next(e)
501 })
502
503 on('command.run', { command: 'review-map' }, async ($, e) => {
504 const words = e.args.trim().split(/\s+/).filter(Boolean)
505 if (words[0]?.toLowerCase() === 'config') {
506 return { text: words.length === 1 ? configText(settings) : await changeSetting($, words.slice(1)) }
507 }
508
509 const parsed = parseArgs(e.args)
510 if ('error' in parsed.source) return { text: parsed.source.error }
511 // the repository: named in the address or with --repo, else the default setting (healee-platform unless changed)
512 const given = parsed.source
513 const source: Source = given.kind === 'pr' ? { ...given, repo: given.repo ?? parsed.repo ?? settings.repo } : given
514 const where = source.kind === 'pr' ? `PR #${source.number}${source.repo ? ` of ${source.repo}` : ''}` : 'uncommitted changes'
515
516 // the same request again reopens the kept map instead of mapping twice
517 const kept = await read($, data)
518 const isKept = !parsed.wantsRefresh && kept !== null && kept.key === keyOf(source) && kept.phase !== 'error'
519 if (!isKept) {
520 await start($, source, parsed.wantsRefresh, true)
521 return { text: `Mapping ${where}. The diagram opens in your browser when it is ready.` }
522 }
523 if (kept.phase === 'loading' || kept.phase === 'thinking') {
524 openWhenDone = true
525 return { text: `Still mapping ${where}. The diagram opens in your browser when it is ready.` }
526 }
527 // detach from the command's dispatch: collecting the full files takes a moment
528 $.clock.after(1, () => void autoOpen($))
529 return { text: `Reopening the diagram of ${where} in your browser.` }
530 })
531}
532hooks/checks.ts 116 lines1import type { DiffMapCheck, DiffMapFile } from '../types'
2import { isDocs, isGenerated, isTest } from './model'
3
4const AREAS = ['healee', 'healee-md', 'shared', 'shared-api'] as const
5const SOURCE_RE = /\.(js|jsx|ts|tsx|mjs|cjs|pug)$/
6const CODE_RE = /\.(js|jsx|ts|tsx|mjs|cjs)$/
7const KEBAB_RE = /^[a-z0-9]+(?:[-.][a-z0-9]+)*\.(js|ts|mjs|cjs)$/
8
9export const areaOf = (path: string) => path.split('/')[0] ?? ''
10export const isHealeeRepo = (files: readonly DiffMapFile[]) => files.some(f => (AREAS as readonly string[]).includes(areaOf(f.path)))
11
12/** What the model is told about this repo when the change touches it. */
13export function repoContext(files: readonly DiffMapFile[]): string {
14 if (!isHealeeRepo(files)) return ''
15 return `Repository context (Healee telehealth platform monorepo): healee/ is the patient app (Express + Pug/Kendo/React), healee-md/ the specialist app and its Panel Admin (healee-md/public/js/panel/), shared/ code used by both apps, shared-api/ the NestJS API services, white/ the white-label overlay. Migrations live in */migrations/, localization in */modules/localization/*.js (en and bg must stay in step). Conventions reviewers enforce: tabs for indentation, double quotes, kebab-case file names, one module.exports at the bottom of a file, HTTP handlers suffixed Handler, DB indexes declared after the schema, React dialogs built on BaseDialog. Name themes by these layers where it helps (e.g. "patient app UI", "shared-api endpoint") and call out cross-app impact: a change under shared/ reaches both apps.\n\n`
16}
17
18const addedLines = (patch: string) => patch.split('\n').filter(l => l.startsWith('+') && !l.startsWith('+++'))
19
20const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`
21
22/** Repo-convention checks on the diff itself: deterministic, no model involved. */
23export type CheckOptions = { repo: boolean; lines: boolean }
24
25export function runChecks(files: readonly DiffMapFile[], patches: Record<string, string>, options: CheckOptions = { repo: true, lines: true }): DiffMapCheck[] {
26 if (!isHealeeRepo(files) || !options.repo) return options.lines ? runGenericChecks(files, patches) : []
27 const out: DiffMapCheck[] = []
28 const paths = new Set(files.map(f => f.path))
29
30 // localization: en and bg move together
31 for (const file of files) {
32 const match = file.path.match(/^(.*\/modules\/localization\/)(en|bg)\.js$/)
33 if (!match?.[1] || !match[2]) continue
34 const other = `${match[1]}${match[2] === 'en' ? 'bg' : 'en'}.js`
35 if (!paths.has(other)) {
36 out.push({ severity: 'warn', text: `${match[2]}.js changed but ${other.split('/').pop()} did not (the localization check enforces en and bg parity)`, path: file.path })
37 }
38 }
39
40 // migrations: reversible
41 for (const file of files) {
42 if (file.status === 'added' && /(^|\/)migrations\/[^/]+\.js$/.test(file.path) && !/\bdown\b/.test(patches[file.path] ?? '')) {
43 out.push({ severity: 'warn', text: 'New migration has no down(), so make migrations-down cannot roll it back', path: file.path })
44 }
45 }
46
47 // tests: an area whose source changed with no test in the same area
48 for (const area of AREAS) {
49 const inArea = files.filter(f => areaOf(f.path) === area)
50 const source = inArea.filter(f => SOURCE_RE.test(f.path) && !isTest(f.path) && !isDocs(f.path) && !isGenerated(f.path) && !/(^|\/)migrations\//.test(f.path))
51 if (source.length > 0 && !inArea.some(f => isTest(f.path))) {
52 out.push({ severity: 'info', text: `No tests touched in ${area}/ (${plural(source.length, 'source file')} changed)` })
53 }
54 }
55
56 // Superdoc contract
57 const superdoc = files.find(f => /superdoc/i.test(f.path) && !isTest(f.path) && !isDocs(f.path))
58 if (superdoc && !paths.has('docs/SUPERDOC-HEALEE-API.md')) {
59 out.push({ severity: 'info', text: 'Superdoc-facing code changed; docs/SUPERDOC-HEALEE-API.md is the contract the Superdoc team reads', path: superdoc.path })
60 }
61
62 for (const file of files) {
63 if (file.status === 'deleted' || file.status === 'binary') continue
64 const patch = patches[file.path] ?? ''
65 if (!CODE_RE.test(file.path) || isGenerated(file.path) || file.path.startsWith('white/')) continue
66 const added = addedLines(patch)
67
68 const spaced = added.filter(l => /^\+ {2,}\S/.test(l)).length
69 if (spaced > 0) out.push({ severity: 'warn', text: `${plural(spaced, 'added line')} indented with spaces (the repo uses tabs)`, path: file.path })
70
71 const single = added.filter(l => /\bfrom '|\brequire\('/.test(l)).length
72 if (single > 0) out.push({ severity: 'warn', text: `${plural(single, 'import')} with single quotes (the repo uses double quotes)`, path: file.path })
73
74 if (file.status === 'added') {
75 const name = file.path.split('/').pop() ?? file.path
76 const isComponent = /\.tsx?$/.test(name) && /^[A-Z]/.test(name)
77 const isHook = /^use[A-Z]/.test(name)
78 if (!isComponent && !isHook && /\.(js|ts|mjs|cjs)$/.test(name) && !KEBAB_RE.test(name)) {
79 out.push({ severity: 'warn', text: `File name ${name} is not kebab-case`, path: file.path })
80 }
81 }
82
83 if (options.lines) out.push(...runLineChecks(file, added))
84 }
85 return out
86}
87
88/** Checks that need no repo knowledge: left-in debugging and focused tests. */
89function runGenericChecks(files: readonly DiffMapFile[], patches: Record<string, string>): DiffMapCheck[] {
90 const out: DiffMapCheck[] = []
91 for (const file of files) {
92 if (file.status === 'deleted' || file.status === 'binary' || !CODE_RE.test(file.path) || isGenerated(file.path)) continue
93 out.push(...runLineChecks(file, addedLines(patches[file.path] ?? '')))
94 }
95 const source = files.filter(f => SOURCE_RE.test(f.path) && !isTest(f.path) && !isDocs(f.path) && !isGenerated(f.path))
96 if (source.length > 0 && !files.some(f => isTest(f.path))) {
97 out.push({ severity: 'info', text: `No test files in this change (${plural(source.length, 'source file')} changed)` })
98 }
99 return out
100}
101
102function runLineChecks(file: DiffMapFile, added: readonly string[]): DiffMapCheck[] {
103 const out: DiffMapCheck[] = []
104 const debuggers = added.filter(l => /\bdebugger\b/.test(l)).length
105 if (debuggers > 0) out.push({ severity: 'warn', text: 'debugger statement added', path: file.path })
106
107 const focused = added.filter(l => /\b(describe|it|test)\.only\(/.test(l)).length
108 if (focused > 0) out.push({ severity: 'warn', text: '.only( left in a test, the rest of the suite would be skipped', path: file.path })
109
110 if (!isTest(file.path)) {
111 const logs = added.filter(l => /\bconsole\.(log|debug)\(/.test(l)).length
112 if (logs > 0) out.push({ severity: 'info', text: `${plural(logs, 'console.log call')} added`, path: file.path })
113 }
114 return out
115}
116hooks/chunks.ts 126 lines1import type { DiffMapFile, DiffMapFlow, DiffMapGroup, DiffMapLink, DiffMapStateChart, DiffMapThemes } from '../types'
2import { isGenerated } from './model'
3
4/** What one model call carries before a change is split up. */
5export const SINGLE_BUDGET = 150_000
6
7/** What each part of a split change may carry. */
8export const PART_BUDGET = 110_000
9
10const FILE_CAPS = [12_000, 6_000, 2_500]
11const MAX_PARTS = 10
12const LIST_LINE = 200
13
14type Patches = Readonly<Record<string, string>>
15
16const weight = (file: DiffMapFile, patches: Patches, cap: number) =>
17 (isGenerated(file.path) ? 0 : Math.min(patches[file.path]?.length ?? 0, cap)) + LIST_LINE
18
19const total = (files: readonly DiffMapFile[], patches: Patches, cap: number) => files.reduce((sum, f) => sum + weight(f, patches, cap), 0)
20
21/** True when the whole diff would not fit one call without cutting files short. */
22export function needsParts(files: readonly DiffMapFile[], patches: Patches): boolean {
23 return total(files, patches, Number.POSITIVE_INFINITY) > SINGLE_BUDGET
24}
25
26const dirKey = (path: string, depth: number) => path.split('/').slice(0, -1).slice(0, depth).join('/')
27
28/** Splits a folder's files, going a level deeper into the tree for as long as the folder is too big. */
29function split(files: DiffMapFile[], patches: Patches, cap: number, budget: number, depth: number): DiffMapFile[][] {
30 if (total(files, patches, cap) <= budget) return [files]
31 if (depth > 6) return slices(files, patches, cap, budget)
32 const groups = new Map<string, DiffMapFile[]>()
33 for (const file of files) groups.set(dirKey(file.path, depth), [...(groups.get(dirKey(file.path, depth)) ?? []), file])
34 if (groups.size === 1) return split(files, patches, cap, budget, depth + 1)
35 return [...groups.values()].flatMap(group => split(group, patches, cap, budget, depth + 1))
36}
37
38function slices(files: DiffMapFile[], patches: Patches, cap: number, budget: number): DiffMapFile[][] {
39 const out: DiffMapFile[][] = []
40 let current: DiffMapFile[] = []
41 let size = 0
42 for (const file of files) {
43 const w = weight(file, patches, cap)
44 if (current.length > 0 && size + w > budget) {
45 out.push(current)
46 current = []
47 size = 0
48 }
49 current.push(file)
50 size += w
51 }
52 if (current.length > 0) out.push(current)
53 return out
54}
55
56/** Folders that are small enough to share a part are packed together, in path order. */
57function pack(parts: DiffMapFile[][], patches: Patches, cap: number, budget: number): DiffMapFile[][] {
58 const out: DiffMapFile[][] = []
59 let current: DiffMapFile[] = []
60 for (const part of parts) {
61 if (current.length > 0 && total([...current, ...part], patches, cap) > budget) {
62 out.push(current)
63 current = []
64 }
65 current = [...current, ...part]
66 }
67 if (current.length > 0) out.push(current)
68 return out
69}
70
71export type PartPlan = { parts: DiffMapFile[][]; perFileCap: number }
72
73/**
74 * Splits a big change into parts that each fit one call, keeping files of a folder together.
75 * A change that would need too many parts is planned again with each file's diff cut shorter.
76 */
77export function planParts(files: readonly DiffMapFile[], patches: Patches, budget = PART_BUDGET): PartPlan {
78 const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path))
79 let plan: PartPlan = { parts: [sorted], perFileCap: FILE_CAPS[0] ?? 12_000 }
80 for (const cap of FILE_CAPS) {
81 plan = { parts: pack(split(sorted, patches, cap, budget, 1), patches, cap, budget), perFileCap: cap }
82 if (plan.parts.length <= MAX_PARTS) return plan
83 }
84 return plan
85}
86
87const RISK_RANK = { low: 0, medium: 1, high: 2 } as const
88
89const unique = <T>(items: readonly T[]) => [...new Set(items)]
90
91/** One set of themes from the parts' own: themes of the same title are joined, links and notes kept. */
92export function mergeThemes(parts: readonly DiffMapThemes[], extra?: { summary?: string; links?: readonly DiffMapLink[]; flows?: readonly DiffMapFlow[]; stateCharts?: readonly DiffMapStateChart[] }): DiffMapThemes {
93 const groups = new Map<string, DiffMapGroup>()
94 for (const group of parts.flatMap(p => p.groups)) {
95 const key = group.title.trim().toLowerCase()
96 const have = groups.get(key)
97 if (!have) {
98 groups.set(key, { ...group, files: [...group.files] })
99 continue
100 }
101 const known = new Set(have.files.map(f => f.path))
102 have.files.push(...group.files.filter(f => !known.has(f.path)))
103 if (RISK_RANK[group.risk] > RISK_RANK[have.risk]) have.risk = group.risk
104 if (have.why === '') have.why = group.why
105 }
106
107 const links: DiffMapLink[] = []
108 const seen = new Set<string>()
109 for (const link of [...parts.flatMap(p => p.links ?? []), ...(extra?.links ?? [])]) {
110 const key = `${link.from}>${link.to}`
111 if (seen.has(key)) continue
112 seen.add(key)
113 links.push(link)
114 }
115
116 return {
117 summary: extra?.summary || parts.map(p => p.summary).filter(Boolean).join(' ').slice(0, 400),
118 groups: [...groups.values()],
119 reviewOrder: unique(parts.flatMap(p => p.reviewOrder)),
120 concerns: unique(parts.flatMap(p => p.concerns)).slice(0, 12),
121 links: links.slice(0, 40),
122 ...(extra?.flows?.length ? { flows: [...extra.flows] } : {}),
123 ...(extra?.stateCharts?.length ? { stateCharts: [...extra.stateCharts] } : {}),
124 }
125}
126hooks/diagram.ts 1797 lines1import type { DiffMapData } from '../types'
2import { buildDiagramData } from './diagram-data'
3import type { DiagramOptions } from './diagram-data'
4
5const esc = (text: string) =>
6 text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''')
7
8const HIDE_CHIPS: { name: string; label: string }[] = [
9 { name: 'tests', label: 'Tests' },
10 { name: 'docs', label: 'Docs' },
11 { name: 'generated', label: 'Generated files' },
12 { name: 'lowRisk', label: 'Low-risk lanes' },
13 { name: 'reviewed', label: 'Reviewed files' },
14]
15
16/**
17 * A self-contained HTML page: the change as lanes of files joined by their links, with the diffs inside.
18 * The page lays itself out in the browser, so lanes collapse, filters apply and search dims in place.
19 */
20export function buildDiagramHtml(map: DiffMapData, fullFiles: Readonly<Record<string, string>> = {}, options: DiagramOptions = {}): string {
21 const data = buildDiagramData(map, fullFiles, options)
22 // `<` is escaped so no diff text can close the script element; the two line separators would end a JS string
23 const json = JSON.stringify(data)
24 .replace(/</g, '\\u003c')
25 .split(String.fromCharCode(0x2028))
26 .join('\\u2028')
27 .split(String.fromCharCode(0x2029))
28 .join('\\u2029')
29
30 const totals = map.files.reduce((s, f) => ({ a: s.a + f.added, r: s.r + f.removed }), { a: 0, r: 0 })
31 const items = HIDE_CHIPS.map(c => `<label><input type="checkbox" data-hide="${c.name}"> ${c.label}</label>`).join('\n ')
32 const chips = `<span class="dd" id="hidedd"><button type="button" class="tog dd-btn" id="hidebtn" aria-haspopup="true" aria-expanded="false">Hide: <span id="hidesum">nothing</span> ▾</button>
33 <div class="dd-menu" id="hidemenu" hidden>
34 ${items}
35 <div class="dd-foot"><button type="button" class="linkbtn" id="hideclear">Show everything</button></div>
36 </div></span>`
37
38 return `<!doctype html>
39<html lang="en">
40<head>
41<meta charset="utf-8">
42<meta name="viewport" content="width=device-width, initial-scale=1">
43<title>${esc(map.title.slice(0, 80))} · Review map</title>
44<style>${STYLE}</style>
45</head>
46<body>
47<header>
48 <h1>${esc(map.title)}</h1>
49 <p class="sub">${esc(map.source)}</p>
50 <p class="stats"><span>${map.files.length} files</span><span class="add">+${totals.a}</span><span class="del">−${totals.r}</span><span id="reviewedStat">reviewed 0/${map.files.length}</span><span id="shown"></span></p>
51 ${map.themes?.summary ? `<p class="summary">${esc(map.themes.summary)}</p>` : ''}
52 <!-- the controls stay in the page, out of sight, at their defaults: the script reads and sets them -->
53 <div class="toolbar" hidden>
54 ${chips}
55 <span class="grp"><label class="lbl" for="strategies">Review order</label><select class="sel" id="strategies"></select></span>
56 <span class="grp"><label class="lbl map-only" for="pathmodes">Path</label><select class="sel map-only" id="pathmodes"><option value="off">Off</option><option value="next" selected>Next steps</option><option value="all">Whole path</option></select><button type="button" class="tog go" id="gonext">Go to next step</button></span>
57 <span class="zoom map-only" role="group" aria-label="Zoom"><button type="button" id="zout" aria-label="Zoom out">−</button><span id="zval">100%</span><button type="button" id="zin" aria-label="Zoom in">+</button><button type="button" id="fit">Fit</button><button type="button" id="z100">100%</button></span>
58 <button type="button" class="tog map-only" id="labels" aria-pressed="true">Link labels</button>
59 <span class="hint" id="blurb"></span>
60 </div>
61 <div class="stages" id="stages" aria-label="Review stages"></div>
62</header>
63<main>
64 <div class="leftcol">
65 <div class="canvas-wrap" id="canvaswrap">
66 <div class="viewbar" id="viewbar" hidden><span class="seg" id="viewtabs" role="group" aria-label="What to show"></span><span class="hint" id="viewtitle"></span></div>
67 <div class="canvas" id="canvas"><svg id="map" role="group" aria-label="Changed files grouped by theme, joined by their relationships"></svg></div>
68 <div class="flowview" id="flowview" hidden>
69 <div class="flowpick" id="flowpick" role="group" aria-label="Which flow to show" hidden></div>
70 <div class="flegend"><span class="fl fl-new">new in this change</span><span class="fl fl-chg">changed</span><span class="fl fl-same">existing behaviour</span><span class="hint">Click a step or a box to open its file in the panel. Drag to pan, Ctrl or Cmd + wheel to zoom, double-click to fit.</span></div>
71 <div class="flowhost" id="flowhost"></div>
72 </div>
73 <div class="mini" id="mini" hidden><svg id="miniSvg" aria-hidden="true"></svg></div>
74 </div>
75 <div class="below">
76 <div class="risks" id="risks" hidden></div>
77 <p class="legend"><span class="chip risk-low">low risk</span><span class="chip risk-medium">medium risk</span><span class="chip risk-high">high risk</span><span class="hint">${data.links.length > 0 ? 'Numbers are the suggested review order. Hover a file for details, click to read the diff, click a lane title to fold it. Drag the background to pan, Ctrl or Cmd + wheel to zoom.' : 'No links between files were detected. Numbers are the suggested review order. Click a file to read the diff.'}</span></p>
78 </div>
79 </div>
80 <div class="splitter" id="splitter" role="separator" aria-orientation="vertical" aria-label="Resize the panel"></div>
81 <aside id="panel">
82 <div class="ptabs"><button type="button" id="tablist" aria-pressed="true">Review list</button><button type="button" id="tabfile" aria-pressed="false">File</button></div>
83 <div id="pbody" aria-live="polite"><p class="empty">Select a file to see what changed, why it matters, and its diff.</p></div>
84 </aside>
85</main>
86<div id="tip" hidden></div>
87<script>
88var DATA = ${json};
89${SCRIPT}
90</script>
91</body>
92</html>
93`
94}
95
96const STYLE = String.raw`
97:root{--bg:#f6f7f9;--fg:#1c2330;--muted:#64708a;--card:#fff;--border:#d9dee8;--low:#2f9e6b;--medium:#d99a1e;--high:#d6453d;--add:#1a7f45;--del:#c0392b;--accent:#3b6ef5;--warn:#b7791f;--code:#f0f2f6;--tok-k:#8250df;--tok-s:#0a7a4b;--tok-c:#7b8499;--tok-n:#b45309}
98@media (prefers-color-scheme:dark){:root{--bg:#10141c;--fg:#e4e8f1;--muted:#8d99b3;--card:#171c27;--border:#2a3242;--low:#46c48a;--medium:#e8b447;--high:#f06b62;--add:#5ed48b;--del:#f2776b;--accent:#7da0ff;--warn:#e8b447;--code:#0d1118;--tok-k:#c4a1ff;--tok-s:#7fd6a8;--tok-c:#7d879c;--tok-n:#f0b878}}
99*{box-sizing:border-box}
100[hidden]{display:none!important}
101body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif}
102header{padding:20px 24px 8px}
103h1{margin:0 0 2px;font-size:20px;line-height:1.25}
104.sub{margin:0;color:var(--muted)}
105.stats{display:flex;gap:14px;margin:8px 0 0;flex-wrap:wrap}
106.add{color:var(--add)}.del{color:var(--del)}
107.summary{margin:10px 0 0;max-width:80ch}
108.toolbar{display:flex;gap:8px 10px;align-items:center;flex-wrap:wrap;margin:12px 0 0}
109.grp{display:inline-flex;align-items:center;gap:6px;flex-wrap:nowrap}
110.tog{font:inherit;font-size:12px;color:var(--fg);background:var(--card);border:1px solid var(--border);border-radius:99px;padding:3px 11px;cursor:pointer;display:inline-flex;gap:5px;align-items:center}
111.tog:hover{border-color:var(--accent);color:var(--accent)}
112.tog[aria-pressed="true"]{background:var(--accent);border-color:var(--accent);color:#fff}
113.legend{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:12px 0 0}
114.chip{border:1px solid var(--border);border-radius:99px;padding:1px 10px;font-size:12px;background:var(--card)}
115.chip.risk-low{border-color:var(--low);color:var(--low)}.chip.risk-medium{border-color:var(--medium);color:var(--medium)}.chip.risk-high{border-color:var(--high);color:var(--high)}
116.hint{color:var(--muted);font-size:12px}
117main{display:grid;grid-template-columns:minmax(0,1fr) 14px var(--panel-w,560px);padding:12px 24px 32px;align-items:start}
118.canvas-wrap{position:relative;border:1px solid var(--border);border-radius:14px;background:var(--card);overflow:hidden}
119.viewbar{display:flex;align-items:center;gap:12px;padding:8px 12px;border-bottom:1px solid var(--border)}
120.viewbar .hint{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;color:var(--fg);font-weight:600}
121.flowview{cursor:grab;overflow:auto;max-height:calc(100vh - 230px);min-height:240px}
122.has-bar .canvas,.has-bar .flowview{max-height:calc(100vh - 282px)}
123.flowpick{display:flex;flex-wrap:wrap;gap:6px;padding:8px 12px 0}
124.flowpick button{font:inherit;font-size:12px;color:var(--fg);background:var(--card);border:1px solid var(--border);border-radius:99px;padding:3px 12px;cursor:pointer;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
125.flowpick button[aria-pressed=true]{background:var(--accent);border-color:var(--accent);color:#fff}
126.flowhost{padding:16px;width:max-content;min-width:100%;box-sizing:border-box}
127.flowview.panning{cursor:grabbing;user-select:none}
128.flowhost svg{display:block;margin:0 auto;max-width:100%;height:auto}
129body.flow-view .map-only{opacity:.38;pointer-events:none}
130.canvas{overflow:auto;background:var(--card);max-height:calc(100vh - 230px);min-height:240px}
131.canvas{cursor:grab}
132.canvas.panning{cursor:grabbing;user-select:none}
133.mini{position:absolute;right:14px;bottom:14px;background:color-mix(in srgb,var(--card) 90%,transparent);border:1px solid var(--border);border-radius:8px;padding:4px;box-shadow:0 2px 10px rgba(0,0,0,.18);cursor:crosshair;touch-action:none}
134.mini[hidden]{display:none}
135.mini svg{display:block}
136.mini-lane{fill:var(--bg);stroke:var(--border);stroke-width:1.5}
137.mini-node{fill:var(--muted);opacity:.7}.mini-node.sel{fill:var(--accent);opacity:1}
138.mini-view{fill:color-mix(in srgb,var(--accent) 14%,transparent);stroke:var(--accent);stroke-width:3}
139#panel{position:sticky;top:12px;border:1px solid var(--border);border-radius:14px;background:var(--card);padding:16px;max-height:calc(100vh - 40px);overflow:auto}
140#panel h2{margin:0 0 4px;font-size:15px;word-break:break-all}
141#panel h3{margin:14px 0 4px;font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:var(--muted)}
142#panel ul{margin:0;padding-left:18px}
143#panel p{margin:4px 0}
144#panel .empty{color:var(--muted);margin:0}
145#panel button.jump{all:unset;cursor:pointer;color:var(--accent);word-break:break-all}
146#panel button.jump:hover{text-decoration:underline}
147.explain{border-left:3px solid var(--accent);padding-left:10px}
148.explain code,#panel code{background:var(--code);border-radius:4px;padding:0 4px;font:12px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
149.warn{color:var(--warn)}
150.tabs{display:flex;gap:4px;margin:6px 0}
151.tabs button{font:inherit;font-size:12px;color:var(--fg);background:var(--card);border:1px solid var(--border);border-radius:6px;padding:2px 10px;cursor:pointer}
152.tabs button[aria-pressed="true"]{background:var(--accent);border-color:var(--accent);color:#fff}
153.tabs button:disabled{opacity:.4;cursor:not-allowed}
154.diffhost{background:var(--code);border:1px solid var(--border);border-radius:8px;overflow:auto;max-height:62vh}
155.diffnote{color:var(--muted);font-size:12px;margin:4px 8px}
156table.diff{border-collapse:collapse;width:100%;font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
157table.diff td{padding:0 8px;vertical-align:top}
158table.diff td.ln{color:var(--muted);text-align:right;user-select:none;min-width:38px;width:1%;white-space:nowrap;opacity:.8}
159table.diff td.code{white-space:pre;width:100%}
160table.diff.split td.code{width:50%;white-space:pre-wrap;word-break:break-all;border-left:1px solid var(--border)}
161table.diff tr.hunk td{color:var(--accent);background:color-mix(in srgb,var(--accent) 8%,transparent);padding:2px 8px}
162table.diff tr.a td{background:color-mix(in srgb,var(--add) 16%,transparent)}
163table.diff tr.d td{background:color-mix(in srgb,var(--del) 16%,transparent)}
164table.diff td.a{background:color-mix(in srgb,var(--add) 16%,transparent)}
165table.diff td.d{background:color-mix(in srgb,var(--del) 16%,transparent)}
166table.diff td.e{background:color-mix(in srgb,var(--muted) 8%,transparent)}
167table.diff tr.m td{color:var(--muted);font-style:italic}
168.sign{display:inline-block;width:1.4ch;color:var(--muted);user-select:none}
169.k{color:var(--tok-k)}.s{color:var(--tok-s)}.c{color:var(--tok-c);font-style:italic}.n{color:var(--tok-n)}
170svg text{font-family:inherit;fill:var(--fg)}
171.lane-box{fill:var(--bg);stroke:var(--border)}
172.lane-stripe{fill:var(--low)}.lane.risk-medium .lane-stripe{fill:var(--medium)}.lane.risk-high .lane-stripe{fill:var(--high)}
173.lane-head{cursor:pointer;outline:none}
174.lane-head:hover .lane-title,.lane-head:focus-visible .lane-title{fill:var(--accent)}
175.lane-hit{fill:transparent}
176.lane-title{font-size:15px;font-weight:600}
177.lane-meta{font-size:12px;fill:var(--muted)}
178.chev{font-size:12px;fill:var(--muted)}
179.node{cursor:pointer;outline:none}
180.node-box{fill:var(--card);stroke:var(--border);stroke-width:1.2}
181.node-stripe{fill:var(--low)}.node.risk-medium .node-stripe{fill:var(--medium)}.node.risk-high .node-stripe{fill:var(--high)}
182.node:hover .node-box,.node:focus-visible .node-box,.node.sel .node-box{stroke:var(--accent);stroke-width:2}
183.node.sel .node-box{fill:color-mix(in srgb,var(--accent) 10%,var(--card))}
184.node.dim{opacity:.22}
185.node-name{font-size:13px;font-weight:600}
186.node-dir{font-size:11px;fill:var(--muted)}
187.stat{font-size:12px;font-weight:600}.stat.add{fill:var(--add)}.stat.del{fill:var(--del)}
188.status{font-size:11px;font-weight:700;fill:var(--muted)}.status.s-added{fill:var(--add)}.status.s-deleted{fill:var(--del)}.status.s-renamed{fill:var(--accent)}
189.warn-badge{font-size:11px;fill:var(--warn)}
190.done .node-box{opacity:.7}.done-dot{fill:var(--add)}.done-tick{fill:#fff;font-size:12px;font-weight:700}
191.ord-dot{fill:var(--accent)}.ord-num{fill:#fff;font-size:10px;font-weight:700}
192.rpath{fill:none;stroke:var(--accent);stroke-width:2;stroke-dasharray:6 5;opacity:.55}
193.edge-line{fill:none;stroke:var(--muted);stroke-width:1.4;opacity:.45}
194.edge-hit{fill:none;stroke:transparent;stroke-width:12;cursor:help}
195.arrow-head{fill:var(--muted)}
196svg.focus .edge:not(.on) .edge-line{opacity:.08}
197svg.focus .node:not(.on){opacity:.35}
198.edge.on .edge-line{stroke:var(--accent);opacity:1;stroke-width:2}
199.edge:hover .edge-line{stroke:var(--accent);opacity:1}
200@media (max-width:980px){main{grid-template-columns:1fr}.splitter{display:none}#panel{position:static;max-height:none;margin-top:16px}.canvas{max-height:70vh}}
201.zoom{display:inline-flex;align-items:center;gap:2px;border:1px solid var(--border);border-radius:99px;background:var(--card);padding:1px}
202.zoom button{font:inherit;font-size:13px;line-height:1;color:var(--fg);background:transparent;border:0;border-radius:99px;padding:4px 9px;cursor:pointer}
203.zoom button:hover{background:color-mix(in srgb,var(--accent) 14%,transparent);color:var(--accent)}
204#zval{min-width:46px;text-align:center;font-size:12px;color:var(--muted)}
205.splitter{cursor:col-resize;border-radius:6px;position:relative;touch-action:none;align-self:stretch}
206.splitter::after{content:"";position:absolute;top:50%;left:50%;width:3px;height:44px;margin:-22px 0 0 -1.5px;border-radius:3px;background:var(--border)}
207.splitter:hover::after,.splitter.drag::after{background:var(--accent)}
208#tip{position:fixed;z-index:20;max-width:380px;pointer-events:none;background:var(--card);color:var(--fg);border:1px solid var(--border);border-radius:10px;padding:8px 11px;box-shadow:0 6px 24px rgba(0,0,0,.22);font-size:12px;line-height:1.45}
209#tip[hidden]{display:none}
210.tip-path{font-weight:600;word-break:break-all;font-size:12.5px}
211.tip-meta{color:var(--muted);margin-top:2px}
212.tip-group{margin-top:6px;font-weight:600}
213.tip-note{opacity:.85}
214.tip-warn{color:var(--warn);margin-top:4px}
215.bar-track{fill:var(--border);opacity:.7}.bar-add{fill:var(--add)}.bar-del{fill:var(--del)}
216.edge-halo{fill:none;stroke:var(--card);stroke-width:5;opacity:.9}
217.elabel{opacity:0;pointer-events:none;transition:opacity .12s}
218.labels-on .elabel{opacity:1}
219svg.focus .elabel:not(.on){opacity:0}
220.elabel.on{opacity:1}
221.elabel.hot{opacity:1!important}
222.elabel-bg{fill:var(--card);stroke:var(--border);stroke-width:1;filter:drop-shadow(0 1px 2px rgba(15,23,42,.12))}
223.elabel.on .elabel-bg{stroke:var(--accent)}
224.elabel-text{font-size:11px;fill:var(--accent)}
225.lane.risk-high .lane-box{fill:color-mix(in srgb,var(--high) 5%,var(--bg))}
226.lane.risk-medium .lane-box{fill:color-mix(in srgb,var(--medium) 4%,var(--bg))}
227.lane.risk-low .lane-stripe{opacity:.55}.lane.risk-low .lane-title{fill:var(--muted)}
228.node-box{filter:drop-shadow(0 1px 2px rgba(15,23,42,.14))}
229.node.sel .node-box{filter:drop-shadow(0 2px 8px color-mix(in srgb,var(--accent) 45%,transparent))}
230mark.wd{color:inherit;border-radius:2px;background:color-mix(in srgb,var(--add) 42%,transparent)}
231tr.d mark.wd,td.d mark.wd{background:color-mix(in srgb,var(--del) 42%,transparent)}
232tr[hidden]{display:none}
233table.diff tr.fold td{text-align:center;color:var(--accent);background:color-mix(in srgb,var(--accent) 7%,transparent);cursor:pointer;font-size:11px;padding:2px 8px}
234table.diff tr.fold:hover td{background:color-mix(in srgb,var(--accent) 16%,transparent)}
235.cd{display:inline}
236.lbl{white-space:nowrap}
237#blurb{flex:1 1 160px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
238.lbl{font-size:12px;color:var(--muted);margin-left:4px}
239.seg{display:inline-flex;border:1px solid var(--border);border-radius:99px;background:var(--card);overflow:hidden}
240.seg button{font:inherit;font-size:12px;color:var(--fg);background:transparent;border:0;padding:3px 11px;cursor:pointer}
241.seg button+button{border-left:1px solid var(--border)}
242.seg button:hover{color:var(--accent)}
243.seg button[aria-pressed="true"]{background:var(--accent);color:#fff}
244.stages{display:flex;flex-wrap:wrap;gap:6px;margin:10px 0 0}
245.stage{position:relative;flex:1 1 150px;min-width:150px;text-align:left;font:inherit;color:var(--fg);background:var(--card);border:1px solid var(--border);border-radius:10px;padding:6px 10px;cursor:pointer;overflow:hidden}
246.stage:hover{border-color:var(--accent)}
247.stage.focus{border-color:var(--accent);box-shadow:0 0 0 2px color-mix(in srgb,var(--accent) 35%,transparent)}
248.stage-bar{position:absolute;left:0;bottom:0;height:3px;background:var(--add)}
249.stage.complete .stage-bar{height:100%;opacity:.12}
250.stage-name{display:block;font-size:12.5px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;position:relative}
251.stage-meta{display:block;font-size:11px;color:var(--muted);position:relative}
252.ptabs{display:flex;gap:4px;position:sticky;top:-16px;z-index:2;margin:-16px -16px 12px;padding:10px 16px;background:var(--card);border-bottom:1px solid var(--border)}
253.ptabs button{font:inherit;font-size:12px;color:var(--fg);background:var(--card);border:1px solid var(--border);border-radius:99px;padding:3px 12px;cursor:pointer}
254.ptabs button[aria-pressed="true"]{background:var(--accent);border-color:var(--accent);color:#fff}
255.reason{font-style:italic;color:var(--muted);margin:2px 0 6px}
256.tog.mark{margin-top:6px}
257.tip-reason{font-style:italic;color:var(--muted);margin-top:4px}
258.ltitle{font-weight:600;font-size:14px}
259.lprog{height:6px;border-radius:4px;background:var(--border);margin:8px 0 4px;overflow:hidden}
260.lprog span{display:block;height:100%;background:var(--add)}
261.lhead .tog{margin-top:8px}
262.lstage h3{margin-top:16px!important}
263.lrow{position:relative;border:1px solid var(--border);border-radius:10px;padding:6px 10px 6px 16px;margin:6px 0;background:var(--card);overflow:hidden}
264.lrow::before{content:"";position:absolute;left:0;top:0;bottom:0;width:5px;background:var(--stripe,transparent)}
265.lrow.risk-low{--stripe:var(--low)}.lrow.risk-medium{--stripe:var(--medium)}.lrow.risk-high{--stripe:var(--high)}
266.lrow.sel{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent)}
267.lrow.next{border:2px dashed var(--accent)}
268.lrisk{flex:none;font-size:11px;border:1px solid var(--border);border-radius:99px;padding:0 8px;color:var(--muted)}
269.lrisk.risk-low{color:var(--low);border-color:var(--low)}.lrisk.risk-medium{color:var(--medium);border-color:var(--medium)}.lrisk.risk-high{color:var(--high);border-color:var(--high)}
270.lflag{flex:none;color:var(--high);font-size:13px}
271.lrow.done{opacity:.6}
272.ltop{display:flex;align-items:center;gap:8px}
273.lnum{flex:none;min-width:22px;height:22px;border-radius:11px;background:var(--accent);color:#fff;font-size:11px;font-weight:700;display:inline-flex;align-items:center;justify-content:center;padding:0 5px}
274.lrow.done .lnum{background:var(--add)}
275.lname{all:unset;flex:1;min-width:0;cursor:pointer;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
276.lname:hover{color:var(--accent)}
277.lstat{flex:none;font-size:12px}
278.lreason{font-size:12px;color:var(--muted);font-style:italic;margin:2px 0 0 30px}
279.ldetail{margin:8px 0 2px 30px;font-size:12.5px}
280.ldetail .dir{color:var(--muted);word-break:break-all}
281.ldetail ul{margin:4px 0}
282.ldetail button.open{margin-top:6px}
283.rseg{fill:none;stroke-width:3;stroke-linecap:round}
284.rseg.next{stroke:var(--accent);stroke-dasharray:7 5}
285.rseg.rest{stroke:var(--accent);stroke-dasharray:3 6;opacity:.35}
286.rseg.done{stroke:var(--add);opacity:.75}
287.parrow{fill:var(--accent)}.parrow-done{fill:var(--add)}
288.node.done .ord-dot{fill:var(--add)}
289.node.next .node-box{stroke:var(--accent);stroke-width:2;stroke-dasharray:5 3}
290.tag-pill{fill:var(--accent)}.tag-pill.start{fill:var(--add)}
291.tag-text{fill:#fff;font-size:9px;font-weight:700;letter-spacing:.04em}
292.dd{position:relative;display:inline-block}
293.dd-btn.active{border-color:var(--accent);color:var(--accent)}
294.dd-menu{position:absolute;z-index:30;top:calc(100% + 6px);left:0;min-width:220px;background:var(--card);border:1px solid var(--border);border-radius:12px;padding:6px;box-shadow:0 8px 28px rgba(0,0,0,.22)}
295.dd-menu[hidden]{display:none}
296.dd-menu label{display:flex;gap:9px;align-items:center;padding:6px 9px;border-radius:8px;cursor:pointer;font-size:13px}
297.dd-menu label:hover{background:color-mix(in srgb,var(--accent) 10%,transparent)}
298.dd-foot{border-top:1px solid var(--border);margin-top:4px;padding:6px 9px 2px}
299.linkbtn{all:unset;cursor:pointer;color:var(--accent);font-size:12px}
300.linkbtn:hover{text-decoration:underline}
301select.sel{font:inherit;font-size:12.5px;color:var(--fg);background:var(--card);border:1px solid var(--border);border-radius:99px;padding:3px 10px;cursor:pointer}
302select.sel:hover{border-color:var(--accent)}
303.tog.go{border-color:var(--accent);color:var(--accent)}
304.tog.go:hover{background:var(--accent);color:#fff}
305.flash-ring{fill:none;stroke:var(--accent);stroke-width:3;pointer-events:none;animation:ringpulse .8s ease-out 3}
306@keyframes ringpulse{0%{opacity:1;stroke-width:3}100%{opacity:0;stroke-width:18}}
307.node.flashing .node-box{filter:drop-shadow(0 0 10px var(--accent))}
308@media (prefers-reduced-motion:reduce){.flash-ring{animation:none;opacity:.9}}
309.leftcol{min-width:0}
310.below{padding:0 2px}
311.risks{display:flex;flex-wrap:wrap;gap:6px;margin:12px 0 0}
312.risks[hidden]{display:none}
313.riskinfo{flex-basis:100%;font-size:12px;color:var(--muted)}
314.linkbtn{font:inherit;color:var(--accent,#6ea8fe);background:none;border:0;padding:0;cursor:pointer;text-decoration:underline}
315.lrow.dim{opacity:.35}
316.riskchip{font:inherit;font-size:12px;color:var(--high);background:color-mix(in srgb,var(--high) 8%,var(--card));border:1px solid color-mix(in srgb,var(--high) 45%,var(--border));border-radius:99px;padding:2px 11px;cursor:pointer}
317.riskchip:hover,.riskchip.focus{background:var(--high);border-color:var(--high);color:#fff}
318.risk-dot{fill:var(--high)}.risk-flag{fill:#fff;font-size:10px;font-weight:700}
319.tip-risk{color:var(--high);margin-top:4px;font-weight:600}
320.flegend{display:flex;flex-wrap:wrap;align-items:center;gap:14px;padding:8px 16px;border-bottom:1px solid var(--border);font-size:12px}
321.fl::before{content:"";display:inline-block;width:18px;height:0;border-top:3px solid var(--muted);margin-right:6px;vertical-align:middle}
322.fl-new::before{border-color:var(--accent)}.fl-chg::before{border-color:var(--medium)}.fl-same::before{border-color:var(--muted);opacity:.6}
323.fseq-head{fill:var(--card);stroke:var(--border);stroke-width:1.2}
324.fseq-head.file{cursor:pointer}
325.fseq-name{font-size:13px;font-weight:600}
326.fseq-life{stroke:var(--border);stroke-width:1.4;stroke-dasharray:4 5}
327.fstep{cursor:default}.fstep.file{cursor:pointer}
328.fstep .fline{fill:none;stroke-width:2;stroke:var(--muted);opacity:.65}
329.fstep.added .fline{stroke:var(--accent);stroke-width:3;opacity:1}
330.fstep.changed .fline{stroke:var(--medium);stroke-width:2.6;opacity:1}
331.fstep.return .fline{stroke-dasharray:7 5}.fstep.async .fline{stroke-dasharray:2 5;stroke-linecap:round}
332.fstep .fnum{fill:var(--muted)}.fstep.added .fnum{fill:var(--accent)}.fstep.changed .fnum{fill:var(--medium)}
333.fstep .fnumt{fill:#fff;font-size:10px;font-weight:700}
334.fstep .fbg{fill:var(--card);stroke:var(--border);stroke-width:1;opacity:.96}
335.fstep .ftext{font-size:12px}
336.fstep.added .fbg{stroke:var(--accent)}.fstep.changed .fbg{stroke:var(--medium)}
337.fstep:hover .fbg{stroke:var(--accent)}
338.fm-added{fill:var(--accent)}.fm-changed{fill:var(--medium)}.fm-same{fill:var(--muted)}
339.fstate rect{fill:var(--card);stroke:var(--border);stroke-width:1.4}
340.fstate.added rect{stroke:var(--accent);stroke-width:2.4}.fstate.changed rect{stroke:var(--medium);stroke-width:2.4}
341.fstate text{font-size:13px;font-weight:600}
342.fstate .tagnew{fill:var(--accent)}.fstate .tagtext{fill:#fff;font-size:9px;font-weight:700;letter-spacing:.04em}
343.ftrans .fline{fill:none;stroke:var(--muted);stroke-width:1.8;opacity:.7}
344.ftrans.added .fline{stroke:var(--accent);stroke-width:3;opacity:1}
345.ftrans.changed .fline{stroke:var(--medium);stroke-width:2.6;opacity:1}
346.ftrans .fbg{fill:var(--card);stroke:var(--border);stroke-width:1}
347.ftrans.added .fbg{stroke:var(--accent)}.ftrans.changed .fbg{stroke:var(--medium)}
348.ftrans .ftext{font-size:11.5px}
349.ftrans.file{cursor:pointer}
350.fempty{color:var(--muted);padding:24px;text-align:center}
351@media print{.toolbar,.mini,.legend .hint{display:none}.canvas,#panel{max-height:none;overflow:visible;position:static}main{display:block}}
352`
353
354const SCRIPT = String.raw`
355(function () {
356 "use strict";
357 var NS = "http://www.w3.org/2000/svg";
358 var COL_W = 270, COL_GAP = 96, NODE_W = 240, NODE_H = 52, NODE_C = 32, GAP = 12, PAD = 32, HEAD_H = 66;
359 var BT = String.fromCharCode(96);
360 var $ = function (id) { return document.getElementById(id); };
361 var svg = $("map"), canvas = $("canvas"), panel = $("pbody"), pscroll = $("panel"), mini = $("mini"), miniSvg = $("miniSvg");
362 var lanes = DATA.lanes, files = DATA.files, links = DATA.links;
363 var hasLinks = links.length > 0;
364 /* link labels wrap to two short lines; their width decides how wide the gap between lanes must be */
365 function wrapLabel(text) {
366 var words = String(text || "").split(/\s+/).filter(Boolean), lines = [], cur = "";
367 words.forEach(function (w) {
368 if (cur && (cur + " " + w).length > 22) { lines.push(cur); cur = w; } else cur = cur ? cur + " " + w : w;
369 });
370 if (cur) lines.push(cur);
371 if (lines.length > 3) lines = [lines[0], lines[1], clip(lines.slice(2).join(" "), 24)];
372 return lines.map(function (l) { return clip(l, 26); });
373 }
374 var LINES = links.map(function (l) { return wrapLabel(l.label); });
375 var LW = LINES.map(function (ls) { return ls.reduce(function (m, t) { return Math.max(m, t.length); }, 0) * 6.2; });
376 var maxTotal = 1;
377 Object.keys(files).forEach(function (p) { maxTotal = Math.max(maxTotal, files[p].added + files[p].removed); });
378 var S = { pick: { sequence: 0, states: 0 }, flowZoom: 1, collapsed: {}, hide: { tests: false, docs: false, generated: false, lowRisk: false, reviewed: false }, pathMode: "next", panelMode: "list", stageFocus: null, riskFocus: null, mainView: "map", done: {}, strategy: DATA.strategy, selected: null, hover: null, view: "unified", zoom: 1, fitMode: false, labels: links.length <= 20 };
379 var tip = $("tip"), tipPath = null, pan = null;
380
381 /* the review order: which strategy is chosen decides every number, reason and stage on the page */
382 var ORD = null, rank = {}, reasonOf = {}, stageOf = {}, expanded = null, curNext = null, curDone = 0, flashPath = null, flashUntil = 0;
383 function computeOrder() {
384 ORD = DATA.orders.filter(function (o) { return o.key === S.strategy; })[0] || DATA.orders[0];
385 rank = {}; reasonOf = {}; stageOf = {};
386 ORD.steps.forEach(function (st, i) { rank[st.path] = i + 1; reasonOf[st.path] = st.reason; });
387 ORD.stages.forEach(function (sg, si) { for (var i = sg.from; i <= sg.to; i++) stageOf[ORD.steps[i].path] = si; });
388 }
389 /* what was ticked off: the exported marks, then whatever this browser remembers */
390 var doneKey = "diffmap-done:" + DATA.storeKey;
391 try {
392 var savedDone = JSON.parse(localStorage.getItem(doneKey) || "{}");
393 Object.keys(savedDone).forEach(function (p) { if (files[p]) S.done[p] = !!savedDone[p]; });
394 } catch (err) { /* private mode or damaged value */ }
395 function isDone(p) { return !!S.done[p]; }
396 function doneCount() { return Object.keys(files).filter(isDone).length; }
397 function nextUpPath() {
398 for (var i = 0; i < ORD.steps.length; i++) if (!isDone(ORD.steps[i].path)) return ORD.steps[i].path;
399 return null;
400 }
401 function stageOk(p) { return S.stageFocus === null || stageOf[p] === S.stageFocus; }
402 function riskOk(p) { return S.riskFocus === null || files[p].risks.some(function (r) { return r.id === S.riskFocus; }); }
403 function focusOk(p) { return stageOk(p) && riskOk(p); }
404 computeOrder();
405 var L = null, nodeEls = {}, edgeEls = [], labelEls = [], miniView = null;
406
407 function h(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined) e.textContent = text; return e; }
408 function s(tag, attrs, text) {
409 var e = document.createElementNS(NS, tag);
410 for (var k in attrs) e.setAttribute(k, attrs[k]);
411 if (text !== undefined) e.textContent = text;
412 return e;
413 }
414 function clip(t, n) { return t.length > n ? t.slice(0, n - 1) + "…" : t; }
415 function base(p) { return p.split("/").pop(); }
416 function dir(p) { return p.split("/").slice(0, -1).slice(-2).join("/"); }
417
418 /* ------------------------------------------------------------------ layout */
419 function fileHidden(p) {
420 var f = files[p];
421 return (S.hide.tests && f.isTest) || (S.hide.docs && f.isDocs) || (S.hide.generated && f.isGenerated) || (S.hide.reviewed && isDone(p));
422 }
423
424 function layout() {
425 var out = { nodes: {}, lanes: [], width: 0, height: 0, visible: 0, colX: [], gapAfter: [] }, tall = HEAD_H;
426 var vis = [];
427 lanes.forEach(function (lane, li) {
428 if (S.hide.lowRisk && lane.risk === "low") return;
429 var vf = lane.files.filter(function (p) { return !fileHidden(p); });
430 if (vf.length) vis.push({ li: li, lane: lane, files: vf });
431 });
432 var colOf = {};
433 vis.forEach(function (v, c) { v.files.forEach(function (p) { colOf[p] = c; }); });
434
435 /* each gap between lanes is as wide as the widest label that sits in it */
436 var need = vis.map(function () { return 0; });
437 links.forEach(function (link, i) {
438 var ca = colOf[link.from], cb = colOf[link.to];
439 if (ca === undefined || cb === undefined || !LINES[i].length) return;
440 if (ca === cb && S.collapsed[vis[ca].li]) return;
441 var g = ca < cb ? ca : ca > cb ? ca - 1 : ca;
442 need[g] = Math.max(need[g], LW[i] + (ca === cb ? 50 : 0));
443 });
444 var x = PAD;
445 vis.forEach(function (v, c) {
446 out.colX.push(x);
447 var gap = need[c] ? Math.max(COL_GAP, need[c] + 44) : COL_GAP;
448 out.gapAfter.push(gap);
449 x += COL_W + gap;
450 });
451
452 vis.forEach(function (v, col) {
453 var collapsed = !!S.collapsed[v.li];
454 var lx = out.colX[col], cy = PAD + HEAD_H, nx = lx + (COL_W - NODE_W) / 2;
455 v.files.forEach(function (p) {
456 out.visible++;
457 if (collapsed) {
458 out.nodes[p] = { path: p, x: nx, y: PAD + HEAD_H / 2 - 4, w: NODE_W, h: 8, col: col, li: v.li, anchor: true };
459 return;
460 }
461 var compact = hasLinks && !files[p].linked;
462 var hgt = compact ? NODE_C : NODE_H;
463 out.nodes[p] = { path: p, x: nx, y: cy, w: NODE_W, h: hgt, col: col, li: v.li, compact: compact };
464 cy += hgt + GAP;
465 });
466 var h2 = collapsed ? HEAD_H : cy - GAP + 12 - PAD;
467 out.lanes.push({ li: v.li, lane: v.lane, x: lx, y: PAD, w: COL_W, h: h2, collapsed: collapsed, count: v.files.length, flagged: v.files.filter(function (p) { return files[p].risks.length; }).length, col: col });
468 tall = Math.max(tall, h2);
469 });
470 var last = vis.length - 1;
471 out.width = vis.length ? out.colX[last] + COL_W + (need[last] ? out.gapAfter[last] : PAD) + PAD : PAD * 2 + COL_W;
472 out.height = PAD * 2 + tall;
473 return out;
474 }
475
476 function edgePath(a, b) {
477 var same = a.col === b.col, fwd = a.col < b.col;
478 var x1 = same || fwd ? a.x + a.w : a.x;
479 var x2 = same ? b.x + b.w : fwd ? b.x : b.x + b.w;
480 var y1 = a.y + a.h / 2, y2 = b.y + b.h / 2;
481 var sign = same || fwd ? 1 : -1;
482 var pull = same ? 70 : Math.max(60, Math.abs(x2 - x1) / 2);
483 var c1 = x1 + sign * pull, c2 = same ? x2 + pull : x2 - sign * pull;
484 return { d: "M" + x1 + "," + y1 + " C" + c1 + "," + y1 + " " + c2 + "," + y2 + " " + x2 + "," + y2, my: (y1 + y2) / 2, same: same, x1: x1, x2: x2, y1: y1, y2: y2, c1: c1, c2: c2 };
485 }
486
487 /* the height of the curve where it crosses the middle of its gap, so the label sits on the arrow */
488 function labelY(p, cx) {
489 if (p.same) return p.my;
490 var lo = 0, hi = 1, t = 0.5, k;
491 for (k = 0; k < 24; k++) {
492 t = (lo + hi) / 2;
493 var u = 1 - t, x = u * u * u * p.x1 + 3 * u * u * t * p.c1 + 3 * u * t * t * p.c2 + t * t * t * p.x2;
494 if ((x < cx) === (p.x2 > p.x1)) lo = t; else hi = t;
495 }
496 var v = 1 - t;
497 return v * v * v * p.y1 + 3 * v * v * t * p.y1 + 3 * v * t * t * p.y2 + t * t * t * p.y2;
498 }
499
500 /* ------------------------------------------------------------------ drawing */
501
502 function drawNode(n) {
503 var f = files[n.path];
504 var risk = lanes[n.li].risk;
505 var cls = "node risk-" + risk + (isDone(n.path) ? " done" : "") + (n.path === curNext ? " next" : "") + (n.compact ? " compact" : "") + (S.selected === n.path ? " sel" : "") + (flashPath === n.path && Date.now() < flashUntil ? " flashing" : "") + (focusOk(n.path) ? "" : " dim");
506 var g = s("g", { "class": cls, "data-path": n.path, tabindex: "0", role: "button", "aria-label": n.path, transform: "translate(" + n.x + "," + n.y + ")" });
507 g.appendChild(s("rect", { "class": "node-box", width: NODE_W, height: n.h, rx: 9 }));
508 g.appendChild(s("rect", { "class": "node-stripe", width: 5, height: n.h, rx: 2.5 }));
509 var y1 = n.compact ? 20 : 21;
510 g.appendChild(s("text", { "class": "status s-" + f.status, x: 16, y: y1 }, f.status.charAt(0).toUpperCase()));
511 g.appendChild(s("text", { "class": "node-name", x: 32, y: y1 }, clip(base(n.path), n.compact ? 15 : 17)));
512 if (n.compact) {
513 g.appendChild(s("text", { "class": "stat add", x: NODE_W - 56, y: y1, "text-anchor": "end" }, "+" + f.added));
514 g.appendChild(s("text", { "class": "stat del", x: NODE_W - 12, y: y1, "text-anchor": "end" }, "−" + f.removed));
515 } else {
516 g.appendChild(s("text", { "class": "node-dir", x: 16, y: 40 }, clip(dir(n.path), 20)));
517 g.appendChild(s("text", { "class": "stat add", x: NODE_W - 12, y: 21, "text-anchor": "end" }, "+" + f.added));
518 g.appendChild(s("text", { "class": "stat del", x: NODE_W - 12, y: 40, "text-anchor": "end" }, "−" + f.removed));
519 var warns = f.checks.filter(function (c) { return c.severity === "warn"; }).length;
520 if (warns) g.appendChild(s("text", { "class": "warn-badge", x: NODE_W - 62, y: 40, "text-anchor": "end" }, "⚠ " + warns));
521 }
522 var tot = f.added + f.removed, bw = tot ? Math.max(6, Math.round((NODE_W - 28) * Math.sqrt(tot / maxTotal))) : 0, addW = tot ? Math.round(bw * f.added / tot) : 0, by = n.h - 6;
523 g.appendChild(s("rect", { "class": "bar-track", x: 14, y: by, width: NODE_W - 28, height: 3, rx: 1.5 }));
524 if (addW) g.appendChild(s("rect", { "class": "bar-add", x: 14, y: by, width: addW, height: 3, rx: 1.5 }));
525 if (bw - addW > 0) g.appendChild(s("rect", { "class": "bar-del", x: 14 + addW, y: by, width: bw - addW, height: 3, rx: 1.5 }));
526 if (isDone(n.path)) {
527 g.appendChild(s("circle", { "class": "done-dot", cx: NODE_W - 10, cy: -4, r: 9 }));
528 g.appendChild(s("text", { "class": "done-tick", x: NODE_W - 10, y: 0, "text-anchor": "middle" }, "✓"));
529 }
530 if (f.risks.length) {
531 g.appendChild(s("circle", { "class": "risk-dot", cx: NODE_W - 31, cy: -4, r: 8 }));
532 g.appendChild(s("text", { "class": "risk-flag", x: NODE_W - 31, y: -0.5, "text-anchor": "middle" }, "\u2691"));
533 }
534 if (rank[n.path]) {
535 var rk = String(rank[n.path]);
536 g.appendChild(s("circle", { "class": "ord-dot", cx: 0, cy: 0, r: rk.length > 2 ? 11 : 9 }));
537 g.appendChild(s("text", { "class": "ord-num", x: 0, y: 3.5, "text-anchor": "middle" }, rk));
538 }
539 if (flashPath === n.path && Date.now() < flashUntil) g.appendChild(s("rect", { "class": "flash-ring", x: -5, y: -5, width: NODE_W + 10, height: n.h + 10, rx: 13 }));
540 if (n.path === curNext) {
541 var first = curDone === 0, tagText = first ? "START" : "NEXT";
542 g.appendChild(s("rect", { "class": "tag-pill" + (first ? " start" : ""), x: 16, y: -8, width: first ? 46 : 40, height: 14, rx: 7 }));
543 g.appendChild(s("text", { "class": "tag-text", x: first ? 39 : 36, y: 2, "text-anchor": "middle" }, tagText));
544 }
545 return g;
546 }
547
548 /* the review path: step i to step i+1, an arrow each; where you are decides which stretch is drawn how */
549 function stepPath(a, b) {
550 var x1, y1, x2, y2;
551 if (a.col === b.col) {
552 var dy = b.y - a.y;
553 if (Math.abs(dy) <= Math.max(a.h, b.h) + GAP + 1) {
554 x1 = a.x + NODE_W / 2; x2 = x1;
555 if (dy > 0) { y1 = a.y + a.h; y2 = b.y; } else { y1 = a.y; y2 = b.y + b.h; }
556 return "M" + x1 + "," + y1 + " L" + x2 + "," + y2;
557 }
558 x1 = a.x + a.w; x2 = b.x + b.w; y1 = a.y + a.h / 2; y2 = b.y + b.h / 2;
559 var bulge = x1 + 34;
560 return "M" + x1 + "," + y1 + " C" + bulge + "," + y1 + " " + bulge + "," + y2 + " " + x2 + "," + y2;
561 }
562 var fwd = a.col < b.col;
563 x1 = fwd ? a.x + a.w : a.x; x2 = fwd ? b.x : b.x + b.w; y1 = a.y + a.h / 2; y2 = b.y + b.h / 2;
564 var mx = (x1 + x2) / 2;
565 return "M" + x1 + "," + y1 + " C" + mx + "," + y1 + " " + mx + "," + y2 + " " + x2 + "," + y2;
566 }
567 function currentIndex(seq) {
568 if (S.selected) { var i = seq.indexOf(S.selected); if (i >= 0) return i; }
569 for (var k = 0; k < seq.length; k++) if (!isDone(seq[k])) return k;
570 return seq.length - 1;
571 }
572 function drawPath(seq) {
573 if (S.pathMode === "off" || seq.length < 2) return;
574 var cur = currentIndex(seq), from = 0, to = seq.length - 1;
575 if (S.pathMode === "next") { from = cur; to = Math.min(seq.length - 1, cur + 3); }
576 for (var i = from; i < to; i++) {
577 var cls = "rseg", finished = isDone(seq[i]) && isDone(seq[i + 1]);
578 if (S.pathMode === "all") cls += finished ? " done" : (i >= cur && i < cur + 3 ? " next" : " rest");
579 else cls += " next";
580 svg.appendChild(s("path", { "class": cls, d: stepPath(L.nodes[seq[i]], L.nodes[seq[i + 1]]), "marker-end": "url(#" + (finished && S.pathMode === "all" ? "parrow-done" : "parrow") + ")" }));
581 }
582 }
583
584 function drawLabel(m) {
585 var lines = LINES[m.i], g = s("g", { "class": "elabel", "data-i": m.i });
586 g.appendChild(s("rect", { "class": "elabel-bg", x: m.cx - m.w / 2 - 6, y: m.y - m.hgt / 2, width: m.w + 12, height: m.hgt, rx: 7 }));
587 lines.forEach(function (ln, k) { g.appendChild(s("text", { "class": "elabel-text", x: m.cx, y: m.y - m.hgt / 2 + 13 + k * 13, "text-anchor": "middle" }, ln)); });
588 return g;
589 }
590
591 function render() {
592 L = layout();
593 curNext = nextUpPath();
594 curDone = doneCount();
595 nodeEls = {};
596 edgeEls = [];
597 labelEls = [];
598 svg.textContent = "";
599 svg.setAttribute("viewBox", "0 0 " + L.width + " " + L.height);
600 if (S.fitMode) S.zoom = fitZoom();
601 svg.setAttribute("width", L.width * S.zoom);
602 svg.setAttribute("height", L.height * S.zoom);
603 $("zval").textContent = Math.round(S.zoom * 100) + "%";
604 $("gonext").title = curNext ? "Step " + rank[curNext] + ": " + curNext : "Every step is reviewed";
605 svg.classList.toggle("labels-on", S.labels);
606
607 var defs = s("defs");
608 var marker = s("marker", { id: "arrow", viewBox: "0 0 10 10", refX: 9, refY: 5, markerWidth: 7, markerHeight: 7, orient: "auto-start-reverse" });
609 marker.appendChild(s("path", { d: "M0,0 L10,5 L0,10 z", "class": "arrow-head" }));
610 defs.appendChild(marker);
611 ["parrow", "parrow-done"].forEach(function (name) {
612 var mk = s("marker", { id: name, viewBox: "0 0 10 10", refX: 8, refY: 5, markerWidth: 5, markerHeight: 5, orient: "auto" });
613 mk.appendChild(s("path", { d: "M0,0 L10,5 L0,10 z", "class": name }));
614 defs.appendChild(mk);
615 });
616 svg.appendChild(defs);
617
618 L.lanes.forEach(function (l) {
619 var g = s("g", { "class": "lane risk-" + l.lane.risk });
620 g.appendChild(s("rect", { "class": "lane-box", x: l.x, y: l.y, width: l.w, height: l.h, rx: 14 }));
621 g.appendChild(s("rect", { "class": "lane-stripe", x: l.x, y: l.y, width: l.w, height: 5, rx: 2.5 }));
622 var head = s("g", { "class": "lane-head", "data-li": l.li, tabindex: "0", role: "button", "aria-expanded": l.collapsed ? "false" : "true", "aria-label": "Toggle " + l.lane.title });
623 head.appendChild(s("rect", { "class": "lane-hit", x: l.x, y: l.y, width: l.w, height: HEAD_H }));
624 head.appendChild(s("text", { "class": "chev", x: l.x + 14, y: l.y + 28 }, l.collapsed ? "▸" : "▾"));
625 var title = s("text", { "class": "lane-title", x: l.x + 30, y: l.y + 28 }, clip(l.lane.title, 21));
626 title.appendChild(s("title", {}, l.lane.title));
627 head.appendChild(title);
628 head.appendChild(s("text", { "class": "lane-meta", x: l.x + 16, y: l.y + 48 }, l.lane.kind + " \u00b7 " + l.lane.risk + " risk \u00b7 " + l.count + " file" + (l.count === 1 ? "" : "s") + (l.flagged ? " \u00b7 \u2691 " + l.flagged : "")));
629 g.appendChild(head);
630 svg.appendChild(g);
631 });
632
633 var spots = {};
634 links.forEach(function (link, i) {
635 var a = L.nodes[link.from], b = L.nodes[link.to];
636 if (!a || !b || (a.anchor && b.anchor && a.li === b.li)) return;
637 var p = edgePath(a, b);
638 var g = s("g", { "class": "edge", "data-from": link.from, "data-to": link.to, "data-i": i });
639 var hit = s("path", { "class": "edge-hit", d: p.d });
640 hit.appendChild(s("title", {}, link.label || "related"));
641 g.appendChild(hit);
642 g.appendChild(s("path", { "class": "edge-halo", d: p.d }));
643 g.appendChild(s("path", { "class": "edge-line", d: p.d, "marker-end": "url(#arrow)" }));
644 svg.appendChild(g);
645 edgeEls.push(g);
646 if (!LINES[i].length) return;
647 var gap = a.col < b.col ? a.col : a.col > b.col ? a.col - 1 : a.col;
648 var xr = L.colX[gap] + COL_W, w = LW[i];
649 var cx = a.col === b.col ? xr + 50 + w / 2 : xr + L.gapAfter[gap] / 2;
650 (spots[gap] = spots[gap] || []).push({ i: i, link: link, cx: cx, y: labelY(p, cx), w: w, hgt: LINES[i].length * 13 + 8 });
651 });
652
653 L.seq = ORD.steps.map(function (st) { return st.path; }).filter(function (p) { return L.nodes[p] && !L.nodes[p].anchor; });
654 drawPath(L.seq);
655
656 Object.keys(L.nodes).forEach(function (p) {
657 var n = L.nodes[p];
658 if (n.anchor) return;
659 var g = drawNode(n);
660 nodeEls[p] = g;
661 svg.appendChild(g);
662 });
663
664 Object.keys(spots).forEach(function (gap) {
665 var list = spots[gap].sort(function (m, n) { return m.y - n.y; });
666 for (var k = 1; k < list.length; k++) {
667 var minY = list[k - 1].y + list[k - 1].hgt / 2 + list[k].hgt / 2 + 4;
668 if (list[k].y < minY) list[k].y = minY;
669 }
670 list.forEach(function (m) {
671 var el = drawLabel(m);
672 svg.appendChild(el);
673 labelEls.push({ el: el, i: m.i, from: m.link.from, to: m.link.to });
674 });
675 });
676
677 var total = Object.keys(files).length;
678 $("shown").textContent = L.visible === total ? "" : "showing " + L.visible + " of " + total;
679 applyFocus();
680 applySearch(false);
681 drawMini();
682 }
683
684 function applyFocus() {
685 var path = S.hover || S.selected;
686 if (!path) {
687 svg.classList.remove("focus");
688 edgeEls.forEach(function (e) { e.classList.remove("on"); });
689 labelEls.forEach(function (l) { l.el.classList.remove("on"); });
690 Object.keys(nodeEls).forEach(function (p) { nodeEls[p].classList.remove("on"); });
691 return;
692 }
693 var touched = {};
694 touched[path] = true;
695 edgeEls.forEach(function (e) {
696 var on = e.getAttribute("data-from") === path || e.getAttribute("data-to") === path;
697 e.classList.toggle("on", on);
698 if (on) { touched[e.getAttribute("data-from")] = true; touched[e.getAttribute("data-to")] = true; }
699 });
700 labelEls.forEach(function (l) { l.el.classList.toggle("on", l.from === path || l.to === path); });
701 Object.keys(nodeEls).forEach(function (p) { nodeEls[p].classList.toggle("on", !!touched[p]); });
702 svg.classList.add("focus");
703 }
704
705 function fitZoom() { return Math.max(0.25, Math.min(1.5, (canvas.clientWidth - 16) / L.width)); }
706
707 function scrollToNode(path) {
708 var n = L && L.nodes[path];
709 if (!n) return;
710 var k = svg.getBoundingClientRect().width / L.width || 1;
711 canvas.scrollTo({ left: Math.max(0, n.x * k - 80), top: Math.max(0, n.y * k - 100), behavior: "smooth" });
712 }
713
714 /* ------------------------------------------------------------------ search */
715 function applySearch() {
716 Object.keys(nodeEls).forEach(function (p) { nodeEls[p].classList.toggle("dim", !focusOk(p)); });
717 }
718
719 /* ------------------------------------------------------------------ minimap */
720 function drawMini() {
721 var r = svg.getBoundingClientRect();
722 var need = r.width > canvas.clientWidth + 4 || r.height > canvas.clientHeight + 4;
723 mini.hidden = !need;
724 if (!need) return;
725 var k = Math.min(200 / L.width, 120 / L.height);
726 miniSvg.setAttribute("width", L.width * k);
727 miniSvg.setAttribute("height", L.height * k);
728 miniSvg.setAttribute("viewBox", "0 0 " + L.width + " " + L.height);
729 miniSvg.textContent = "";
730 L.lanes.forEach(function (l) { miniSvg.appendChild(s("rect", { "class": "mini-lane", x: l.x, y: l.y, width: l.w, height: l.h, rx: 14 })); });
731 Object.keys(L.nodes).forEach(function (p) {
732 var n = L.nodes[p];
733 if (n.anchor) return;
734 miniSvg.appendChild(s("rect", { "class": "mini-node" + (S.selected === p ? " sel" : ""), x: n.x, y: n.y, width: n.w, height: n.h, rx: 6 }));
735 });
736 miniView = s("rect", { "class": "mini-view" });
737 miniSvg.appendChild(miniView);
738 updateMini();
739 }
740 function updateMini() {
741 if (mini.hidden || !L || !miniView) return;
742 var k = L.width / svg.getBoundingClientRect().width;
743 miniView.setAttribute("x", canvas.scrollLeft * k);
744 miniView.setAttribute("y", canvas.scrollTop * k);
745 miniView.setAttribute("width", Math.min(L.width, canvas.clientWidth * k));
746 miniView.setAttribute("height", Math.min(L.height, canvas.clientHeight * k));
747 }
748 var dragging = false;
749 function miniMove(e) {
750 var r = miniSvg.getBoundingClientRect();
751 var ux = (e.clientX - r.left) / r.width * L.width, uy = (e.clientY - r.top) / r.height * L.height;
752 var k = svg.getBoundingClientRect().width / L.width;
753 canvas.scrollLeft = ux * k - canvas.clientWidth / 2;
754 canvas.scrollTop = uy * k - canvas.clientHeight / 2;
755 }
756 miniSvg.addEventListener("pointerdown", function (e) { dragging = true; miniSvg.setPointerCapture(e.pointerId); miniMove(e); });
757 miniSvg.addEventListener("pointermove", function (e) { if (dragging) miniMove(e); });
758 miniSvg.addEventListener("pointerup", function () { dragging = false; });
759 canvas.addEventListener("scroll", updateMini);
760 window.addEventListener("resize", function () { if (L) drawMini(); });
761
762 /* ------------------------------------------------------------------ selection */
763 function reveal(path) {
764 var f = files[path];
765 if (!f) return;
766 var changed = false;
767 if (S.hide.tests && f.isTest) { S.hide.tests = false; changed = true; }
768 if (S.hide.docs && f.isDocs) { S.hide.docs = false; changed = true; }
769 if (S.hide.generated && f.isGenerated) { S.hide.generated = false; changed = true; }
770 if (S.hide.reviewed && isDone(path)) { S.hide.reviewed = false; changed = true; }
771 lanes.forEach(function (lane, li) {
772 if (lane.files.indexOf(path) < 0) return;
773 if (S.collapsed[li]) { delete S.collapsed[li]; changed = true; }
774 if (S.hide.lowRisk && lane.risk === "low") { S.hide.lowRisk = false; changed = true; }
775 });
776 if (changed) { syncChips(); render(); }
777 }
778 function select(path, scroll) {
779 if (!files[path]) return;
780 reveal(path);
781 S.selected = path;
782 Object.keys(nodeEls).forEach(function (p) { nodeEls[p].classList.toggle("sel", p === path); });
783 applyFocus();
784 drawMini();
785 if (S.pathMode === "next") render();
786 if (S.panelMode === "list") { expanded = path; renderList(path); } else renderPanel(path);
787 if (scroll) scrollToNode(path);
788 }
789 function clearSelection() {
790 S.selected = null;
791 Object.keys(nodeEls).forEach(function (p) { nodeEls[p].classList.remove("sel"); });
792 applyFocus();
793 drawMini();
794 if (S.pathMode === "next") render();
795 refreshPanel();
796 }
797
798 /* ------------------------------------------------------------------ highlighting */
799 var KW = {};
800 ("const let var function return if else for while do switch case break continue new this class extends import export from default async await try catch finally throw typeof instanceof in of null undefined true false void delete yield static get set interface type enum implements public private protected readonly as").split(" ").forEach(function (w) { KW[w] = true; });
801 function langOf(path) {
802 var m = /\.([a-z0-9]+)$/i.exec(path);
803 var ext = m ? m[1].toLowerCase() : "";
804 if (/^(js|jsx|mjs|cjs|ts|tsx)$/.test(ext)) return "js";
805 if (ext === "json") return "json";
806 if (/^(css|less|scss)$/.test(ext)) return "css";
807 return null;
808 }
809 function hl(code, lang, st) {
810 var frag = document.createDocumentFragment();
811 if (!lang || code === "") { frag.appendChild(document.createTextNode(code === "" ? " " : code)); return frag; }
812 var i = 0, n = code.length, buf = "";
813 function flush() { if (buf) { frag.appendChild(document.createTextNode(buf)); buf = ""; } }
814 function tok(cls, text) { flush(); frag.appendChild(h("span", cls, text)); }
815 if (st.block) {
816 var e0 = code.indexOf("*/");
817 if (e0 < 0) { tok("c", code); return frag; }
818 tok("c", code.slice(0, e0 + 2)); i = e0 + 2; st.block = false;
819 }
820 while (i < n) {
821 var ch = code.charAt(i), two = code.substr(i, 2);
822 if (two === "//" && lang !== "json") { tok("c", code.slice(i)); i = n; break; }
823 if (two === "/*") {
824 var e1 = code.indexOf("*/", i + 2);
825 if (e1 < 0) { tok("c", code.slice(i)); st.block = true; i = n; break; }
826 tok("c", code.slice(i, e1 + 2)); i = e1 + 2; continue;
827 }
828 if (ch === '"' || ch === "'" || ch === BT) {
829 var j = i + 1;
830 while (j < n && code.charAt(j) !== ch) { if (code.charAt(j) === "\\") j++; j++; }
831 tok("s", code.slice(i, Math.min(j + 1, n))); i = j + 1; continue;
832 }
833 if (/[0-9]/.test(ch) && !/[A-Za-z_$]/.test(code.charAt(i - 1) || " ")) {
834 var j2 = i;
835 while (j2 < n && /[0-9a-fA-FxX._]/.test(code.charAt(j2))) j2++;
836 tok("n", code.slice(i, j2)); i = j2; continue;
837 }
838 if (/[A-Za-z_$]/.test(ch)) {
839 var j3 = i;
840 while (j3 < n && /[\w$]/.test(code.charAt(j3))) j3++;
841 var w = code.slice(i, j3);
842 if (KW[w] && lang === "js") tok("k", w); else buf += w;
843 i = j3; continue;
844 }
845 buf += ch; i++;
846 }
847 flush();
848 return frag;
849 }
850
851 /* ------------------------------------------------------------------ diff views */
852 function parsePatch(text) {
853 var hunks = [], cur = null, oldNo = 0, newNo = 0, lines = text.split("\n");
854 lines.forEach(function (line, idx) {
855 var m = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@ ?(.*)$/.exec(line);
856 if (m) { cur = { head: line, rows: [] }; oldNo = +m[1]; newNo = +m[2]; hunks.push(cur); return; }
857 if (!cur) return;
858 if (idx === lines.length - 1 && line === "") return;
859 var c = line.charAt(0);
860 if (c === "+") cur.rows.push({ t: "a", n: newNo++, code: line.slice(1) });
861 else if (c === "-") cur.rows.push({ t: "d", o: oldNo++, code: line.slice(1) });
862 else if (c === "\\") cur.rows.push({ t: "m", code: line });
863 else cur.rows.push({ t: "c", o: oldNo++, n: newNo++, code: line.slice(1) });
864 });
865 return { hunks: hunks };
866 }
867 function ln(v) { return h("td", "ln", v === undefined ? "" : String(v)); }
868 function codeCell(cls, row, lang, st, sign) {
869 var td = h("td", "code " + cls);
870 if (sign !== undefined) td.appendChild(h("span", "sign", sign));
871 if (row) {
872 var wrap = h("span", "cd");
873 wrap.appendChild(hl(row.code, lang, st));
874 if (row.marks) applyMarks(wrap, row.marks);
875 td.appendChild(wrap);
876 }
877 return td;
878 }
879
880 /* word-level marks inside a changed line: tokens, longest common subsequence, the rest is marked */
881 function tokens(text) { return text.match(/\s+|[A-Za-z0-9_$]+|[^\sA-Za-z0-9_$]/g) || []; }
882 function wordDiff(a, b) {
883 if (a.length > 600 || b.length > 600) return null;
884 var ta = tokens(a), tb = tokens(b), n = ta.length, m = tb.length;
885 if (!n || !m || n * m > 40000) return null;
886 var dp = [], i, j;
887 for (i = 0; i <= n; i++) dp.push(new Uint16Array(m + 1));
888 for (i = n - 1; i >= 0; i--) for (j = m - 1; j >= 0; j--) dp[i][j] = ta[i] === tb[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
889 var ra = [], rb = [], pa = 0, pb = 0, same = 0;
890 function push(arr, from, to) { var last = arr[arr.length - 1]; if (last && last.e === from) last.e = to; else arr.push({ s: from, e: to }); }
891 i = 0; j = 0;
892 while (i < n && j < m) {
893 if (ta[i] === tb[j]) { same += ta[i].length; pa += ta[i].length; pb += tb[j].length; i++; j++; }
894 else if (dp[i + 1][j] >= dp[i][j + 1]) { push(ra, pa, pa + ta[i].length); pa += ta[i].length; i++; }
895 else { push(rb, pb, pb + tb[j].length); pb += tb[j].length; j++; }
896 }
897 while (i < n) { push(ra, pa, pa + ta[i].length); pa += ta[i].length; i++; }
898 while (j < m) { push(rb, pb, pb + tb[j].length); pb += tb[j].length; j++; }
899 if (same < 0.3 * Math.max(a.length, b.length)) return null;
900 return { a: ra, b: rb };
901 }
902 function markWords(parsed) {
903 parsed.hunks.forEach(function (hk) {
904 var rows = hk.rows, i = 0;
905 while (i < rows.length) {
906 if (rows[i].t !== "d") { i++; continue; }
907 var ds = [], as = [];
908 while (i < rows.length && rows[i].t === "d") ds.push(rows[i++]);
909 while (i < rows.length && rows[i].t === "a") as.push(rows[i++]);
910 for (var k = 0; k < Math.min(ds.length, as.length); k++) {
911 var wd = wordDiff(ds[k].code, as[k].code);
912 if (wd) { ds[k].marks = wd.a; as[k].marks = wd.b; }
913 }
914 }
915 });
916 }
917 function applyMarks(root, ranges) {
918 var walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT), found = [], node, off = 0;
919 while ((node = walker.nextNode())) found.push(node);
920 found.forEach(function (tn) {
921 var text = tn.nodeValue, start = off, end = off + text.length, parts = [], cur = 0;
922 off = end;
923 ranges.forEach(function (r) {
924 var from = Math.max(r.s, start), to = Math.min(r.e, end);
925 if (to <= from) return;
926 if (from - start > cur) parts.push(document.createTextNode(text.slice(cur, from - start)));
927 parts.push(h("mark", "wd", text.slice(from - start, to - start)));
928 cur = to - start;
929 });
930 if (!parts.length) return;
931 if (cur < text.length) parts.push(document.createTextNode(text.slice(cur)));
932 var frag = document.createDocumentFragment();
933 parts.forEach(function (x) { frag.appendChild(x); });
934 tn.parentNode.replaceChild(frag, tn);
935 });
936 }
937
938 /* long runs of unchanged lines fold into one clickable row, three lines kept at each end */
939 function foldRows(table, items, cols) {
940 var KEEP = 3, MIN = 5, i = 0;
941 while (i < items.length) {
942 if (!items[i].ctx) { i++; continue; }
943 var j = i;
944 while (j < items.length && items[j].ctx) j++;
945 if (j - i - 2 * KEEP >= MIN) {
946 var run = items.slice(i + KEEP, j - KEEP).map(function (x) { return x.tr; });
947 run.forEach(function (tr) { tr.hidden = true; });
948 var fold = h("tr", "fold"), td = h("td", "", "↕ " + run.length + " unchanged lines");
949 td.colSpan = cols;
950 fold.appendChild(td);
951 table.insertBefore(fold, run[0]);
952 fold.onclick = (function (f, r) { return function () { r.forEach(function (tr) { tr.hidden = false; }); if (f.parentNode) f.parentNode.removeChild(f); }; })(fold, run);
953 }
954 i = j;
955 }
956 }
957
958 function unified(parsed, lang, host) {
959 var table = h("table", "diff");
960 parsed.hunks.forEach(function (hk) {
961 var hr = h("tr", "hunk"), hd = h("td", "", hk.head);
962 hd.colSpan = 3; hr.appendChild(hd); table.appendChild(hr);
963 var st = { block: false }, items = [];
964 hk.rows.forEach(function (r) {
965 var tr = h("tr", r.t);
966 tr.appendChild(ln(r.o)); tr.appendChild(ln(r.n));
967 tr.appendChild(codeCell("", r, r.t === "m" ? null : lang, st, r.t === "a" ? "+" : r.t === "d" ? "-" : " "));
968 table.appendChild(tr);
969 items.push({ tr: tr, ctx: r.t === "c" });
970 });
971 foldRows(table, items, 3);
972 });
973 host.appendChild(table);
974 }
975
976 function split(parsed, lang, host) {
977 var table = h("table", "diff split");
978 parsed.hunks.forEach(function (hk) {
979 var hr = h("tr", "hunk"), hd = h("td", "", hk.head);
980 hd.colSpan = 4; hr.appendChild(hd); table.appendChild(hr);
981 var stOld = { block: false }, stNew = { block: false }, items = [];
982 function add(o, n) {
983 var tr = h("tr");
984 items.push({ tr: tr, ctx: !!o && o === n });
985 tr.appendChild(ln(o ? o.o : undefined));
986 tr.appendChild(codeCell(o ? (o.t === "d" ? "d" : "") : "e", o, lang, stOld));
987 tr.appendChild(ln(n ? n.n : undefined));
988 tr.appendChild(codeCell(n ? (n.t === "a" ? "a" : "") : "e", n, lang, stNew));
989 table.appendChild(tr);
990 }
991 var rows = hk.rows, i = 0;
992 while (i < rows.length) {
993 var r = rows[i];
994 if (r.t === "c") { add(r, r); i++; }
995 else if (r.t === "d" || r.t === "a") {
996 var ds = [], as = [];
997 while (i < rows.length && rows[i].t === "d") ds.push(rows[i++]);
998 while (i < rows.length && rows[i].t === "a") as.push(rows[i++]);
999 for (var k = 0; k < Math.max(ds.length, as.length); k++) add(ds[k], as[k]);
1000 } else i++;
1001 }
1002 foldRows(table, items, 4);
1003 });
1004 host.appendChild(table);
1005 }
1006
1007 function fullFile(path, parsed, lang, host) {
1008 var f = files[path], added = {}, firstRow = null;
1009 parsed.hunks.forEach(function (hk) { hk.rows.forEach(function (r) { if (r.t === "a") added[r.n] = true; }); });
1010 var lines = f.full.split("\n");
1011 if (lines.length && lines[lines.length - 1] === "") lines.pop();
1012 host.appendChild(h("p", "diffnote", "The whole file after the change. Added or changed lines are highlighted; removed lines are not shown here."));
1013 var table = h("table", "diff"), st = { block: false }, items = [];
1014 lines.forEach(function (text, i) {
1015 var tr = h("tr", added[i + 1] ? "a" : "");
1016 tr.appendChild(ln(i + 1));
1017 tr.appendChild(codeCell("", { code: text }, lang, st));
1018 if (added[i + 1] && !firstRow) firstRow = tr;
1019 table.appendChild(tr);
1020 items.push({ tr: tr, ctx: !added[i + 1] });
1021 });
1022 foldRows(table, items, 2);
1023 host.appendChild(table);
1024 if (firstRow) setTimeout(function () { host.scrollTop = Math.max(0, firstRow.offsetTop - 40); }, 0);
1025 }
1026
1027 function drawDiff(path, host) {
1028 var f = files[path];
1029 host.textContent = "";
1030 if (!f.patch) { host.appendChild(h("p", "diffnote", "No diff kept for this file.")); return; }
1031 var parsed = parsePatch(f.patch), lang = langOf(path);
1032 if (parsed.hunks.length === 0) { host.appendChild(h("pre", "diffnote", f.patch)); return; }
1033 var view = S.view === "full" && !f.full ? "unified" : S.view;
1034 if (view !== "full") markWords(parsed);
1035 if (view === "split") split(parsed, lang, host);
1036 else if (view === "full") fullFile(path, parsed, lang, host);
1037 else unified(parsed, lang, host);
1038 }
1039
1040 /* ------------------------------------------------------------------ panel */
1041 function section(title, child) { panel.appendChild(h("h3", "", title)); panel.appendChild(child); }
1042 function list(items, build) {
1043 var ul = h("ul");
1044 items.forEach(function (item) { var li = h("li"); build(li, item); ul.appendChild(li); });
1045 return ul;
1046 }
1047 function checkLine(li, c) { li.className = c.severity === "warn" ? "warn" : ""; li.textContent = (c.severity === "warn" ? "⚠ " : "ℹ ") + c.text; }
1048
1049 function renderIntro() {
1050 panel.textContent = "";
1051 panel.appendChild(h("p", "empty", "Select a file to see what changed, why it matters, and its diff."));
1052 }
1053
1054 function renderPanel(path) {
1055 var f = files[path];
1056 panel.textContent = "";
1057 panel.appendChild(h("h2", "", path));
1058 if (f.oldPath) panel.appendChild(h("div", "empty", "renamed from " + f.oldPath));
1059 var line = h("div", "empty");
1060 line.appendChild(document.createTextNode(f.status + " · "));
1061 line.appendChild(h("span", "add", "+" + f.added));
1062 line.appendChild(document.createTextNode(" "));
1063 line.appendChild(h("span", "del", "−" + f.removed));
1064 panel.appendChild(line);
1065 if (rank[path]) {
1066 panel.appendChild(h("div", "empty", "Step " + rank[path] + " of " + ORD.steps.length + " \u00b7 " + ORD.stages[stageOf[path]].label));
1067 panel.appendChild(h("div", "reason", reasonOf[path]));
1068 }
1069 var mark = h("label", "tog mark"), box = h("input");
1070 box.type = "checkbox";
1071 box.checked = isDone(path);
1072 box.onchange = function () { setDone(path, box.checked); };
1073 mark.appendChild(box);
1074 mark.appendChild(document.createTextNode(" Mark as reviewed"));
1075 panel.appendChild(mark);
1076 if (f.group) section(f.group + (f.risk ? " · " + f.risk + " risk" : ""), h("div", "", f.why));
1077 if (f.notes.length) section("What changed", list(f.notes, function (li, n) { li.textContent = n; }));
1078 if (f.checks.length) section("Checks", list(f.checks, checkLine));
1079 if (f.risks.length) section("Sensitive areas", list(f.risks, function (li, r) { li.textContent = r.label + ": " + r.why; }));
1080 if (f.links.length) section("Connected to", list(f.links, function (li, l) {
1081 li.appendChild(document.createTextNode(l.direction === "out" ? "→ " : "← "));
1082 var b = h("button", "jump", l.other);
1083 b.type = "button";
1084 b.onclick = function () { select(l.other, true); };
1085 li.appendChild(b);
1086 if (l.label) li.appendChild(document.createTextNode(" — " + l.label));
1087 }));
1088
1089 panel.appendChild(h("h3", "", "Diff"));
1090 var tabs = h("div", "tabs"), host = h("div", "diffhost");
1091 var modes = ["unified", "split", "full"];
1092 [["unified", "Unified"], ["split", "Split"], ["full", "Full file"]].forEach(function (t) {
1093 var b = h("button", "", t[1]);
1094 b.type = "button";
1095 b.disabled = t[0] === "full" && !f.full;
1096 if (b.disabled) b.title = "The full file was not collected for this export";
1097 b.onclick = function () {
1098 S.view = t[0];
1099 syncTabs();
1100 drawDiff(path, host);
1101 };
1102 tabs.appendChild(b);
1103 });
1104 function syncTabs() {
1105 var effective = S.view === "full" && !f.full ? "unified" : S.view;
1106 Array.prototype.forEach.call(tabs.children, function (x, i) { x.setAttribute("aria-pressed", String(modes[i] === effective)); });
1107 }
1108 syncTabs();
1109 panel.appendChild(tabs);
1110 panel.appendChild(host);
1111 drawDiff(path, host);
1112 }
1113
1114 /* ------------------------------------------------------------------ controls */
1115 var HIDE_NAMES = { tests: "tests", docs: "docs", generated: "generated", lowRisk: "low-risk lanes", reviewed: "reviewed" };
1116 function syncChips() {
1117 var on = [];
1118 Array.prototype.forEach.call(document.querySelectorAll("[data-hide]"), function (b) {
1119 var key = b.getAttribute("data-hide");
1120 b.checked = !!S.hide[key];
1121 if (S.hide[key]) on.push(HIDE_NAMES[key]);
1122 });
1123 $("hidesum").textContent = on.length === 0 ? "nothing" : on.length <= 2 ? on.join(", ") : on.length + " kinds";
1124 $("hidebtn").classList.toggle("active", on.length > 0);
1125 }
1126 Array.prototype.forEach.call(document.querySelectorAll("[data-hide]"), function (b) {
1127 b.addEventListener("change", function () {
1128 S.hide[b.getAttribute("data-hide")] = b.checked;
1129 syncChips();
1130 render();
1131 if (S.selected && !L.nodes[S.selected]) clearSelection();
1132 });
1133 });
1134 function setMenu(open) { $("hidemenu").hidden = !open; $("hidebtn").setAttribute("aria-expanded", String(open)); }
1135 $("hidebtn").onclick = function (e) { e.stopPropagation(); setMenu($("hidemenu").hidden); };
1136 $("hideclear").onclick = function () {
1137 Object.keys(S.hide).forEach(function (k) { S.hide[k] = false; });
1138 syncChips();
1139 render();
1140 };
1141 document.addEventListener("click", function (e) { if (!$("hidemenu").hidden && !e.target.closest("#hidedd")) setMenu(false); });
1142
1143 /* review order: strategies, path modes, stages, ticks */
1144 function syncStrategy() {
1145 $("strategies").value = ORD.key;
1146 $("blurb").textContent = ORD.blurb;
1147 $("blurb").title = ORD.blurb;
1148 $("strategies").title = ORD.blurb;
1149 }
1150 DATA.orders.forEach(function (o) {
1151 var opt = h("option", "", o.label);
1152 opt.value = o.key;
1153 opt.title = o.blurb;
1154 $("strategies").appendChild(opt);
1155 });
1156 $("strategies").onchange = function () {
1157 S.strategy = this.value;
1158 S.stageFocus = null;
1159 computeOrder();
1160 syncStrategy();
1161 renderStages();
1162 render();
1163 refreshPanel();
1164 if (curNext) scrollToNode(curNext);
1165 };
1166 function syncPathModes() { $("pathmodes").value = S.pathMode; }
1167 $("pathmodes").onchange = function () { S.pathMode = this.value; render(); };
1168
1169 /* "Go to next step": slide to the step to take next and pulse it; the first step when nothing is reviewed yet */
1170 var goTimer = null;
1171 function goNext() {
1172 var p = nextUpPath(), btn = $("gonext");
1173 if (!p) {
1174 btn.textContent = "All steps reviewed ✓";
1175 clearTimeout(goTimer);
1176 goTimer = setTimeout(function () { btn.textContent = "Go to next step"; }, 2200);
1177 return;
1178 }
1179 if (S.mainView !== "map") setMainView("map");
1180 if (S.stageFocus !== null && stageOf[p] !== S.stageFocus) { S.stageFocus = null; renderStages(); }
1181 reveal(p);
1182 flashPath = p;
1183 flashUntil = Date.now() + 2600;
1184 render();
1185 scrollToNode(p);
1186 setTimeout(function () {
1187 if (flashPath !== p || Date.now() < flashUntil - 50) return;
1188 flashPath = null;
1189 var ring = svg.querySelector(".flash-ring");
1190 if (ring) ring.remove();
1191 if (nodeEls[p]) nodeEls[p].classList.remove("flashing");
1192 }, 2650);
1193 }
1194 $("gonext").onclick = goNext;
1195 function updateStats() { $("reviewedStat").textContent = "reviewed " + doneCount() + "/" + Object.keys(files).length; }
1196 function renderStages() {
1197 var box = $("stages");
1198 box.textContent = "";
1199 ORD.stages.forEach(function (sg, si) {
1200 var total = sg.to - sg.from + 1, done = 0, i;hooks/model.ts 42 lines1import type { DiffMapData, DiffMapLink } from '../types'
2
3const TEST_RE = /(^|\/)(tests?|__tests__|specs?|e2e|appium)\/|\.(spec|test)\.[cm]?[jt]sx?$/i
4const DOCS_RE = /(^|\/)docs?\/|\.(md|mdx|txt|rst)$|(^|\/)(README|CHANGELOG|LICENSE)/i
5const GENERATED_RE = /(^|\/)(pnpm-lock\.yaml|package-lock\.json|yarn\.lock)$|\.min\.(js|css)$|\.map$|(^|\/)(dist|build|\.dist)\//
6
7export const isTest = (path: string) => TEST_RE.test(path)
8export const isDocs = (path: string) => DOCS_RE.test(path) && !isTest(path)
9export const isGenerated = (path: string) => GENERATED_RE.test(path)
10
11/** The model's review order, then whatever it left out: every changed file once. */
12export function reviewPaths(map: DiffMapData): string[] {
13 const known = new Set(map.files.map(f => f.path))
14 const out: string[] = []
15 const add = (path: string) => {
16 if (known.has(path) && !out.includes(path)) out.push(path)
17 }
18 for (const path of map.themes?.reviewOrder ?? []) add(path)
19 for (const group of map.themes?.groups ?? []) for (const file of group.files) add(file.path)
20 for (const file of map.files) add(file.path)
21 return out
22}
23
24export type LinkView = { other: string; label: string; direction: 'in' | 'out' }
25
26/** The links that touch one file, as `{ other, label, direction }` seen from it. */
27export function linksFor(links: readonly DiffMapLink[] | undefined, path: string): LinkView[] {
28 return (links ?? []).flatMap((link): LinkView[] => {
29 if (link.from === path) return [{ other: link.to, label: link.label, direction: 'out' }]
30 if (link.to === path) return [{ other: link.from, label: link.label, direction: 'in' }]
31 return []
32 })
33}
34
35export function groupOf(map: DiffMapData, path: string) {
36 for (const group of map.themes?.groups ?? []) {
37 const file = group.files.find(f => f.path === path)
38 if (file) return { group, notes: file.notes }
39 }
40 return undefined
41}
42hooks/parse.ts 358 lines1import type { DiffMapChange, DiffMapFile, DiffMapFlow, DiffMapGroup, DiffMapLink, DiffMapStateChart, DiffMapThemes } from '../types'
2
3/**
4 * The JSON object in a model reply. A reply that was cut off at its token limit is not valid JSON, so
5 * the text is closed after the last value that was finished and what came before the cut is kept.
6 */
7export function readJson(text: string): unknown {
8 const start = text.indexOf('{')
9 if (start < 0) return undefined
10 const body = text.slice(start)
11 const end = body.lastIndexOf('}')
12 if (end > 0) {
13 try {
14 return JSON.parse(body.slice(0, end + 1))
15 } catch {
16 // cut off inside a value, or words after it: fall through to closing it by hand
17 }
18 }
19
20 const open: string[] = []
21 const candidates: { at: number; closers: string }[] = []
22 let inString = false
23 let escaped = false
24 for (let i = 0; i < body.length; i++) {
25 const c = body[i]
26 if (inString) {
27 if (escaped) escaped = false
28 else if (c === '\\') escaped = true
29 else if (c === '"') inString = false
30 continue
31 }
32 if (c === '"') inString = true
33 else if (c === '{') open.push('}')
34 else if (c === '[') open.push(']')
35 else if (c === '}' || c === ']') {
36 open.pop()
37 candidates.push({ at: i + 1, closers: [...open].reverse().join('') })
38 }
39 }
40 for (let k = candidates.length - 1; k >= Math.max(0, candidates.length - 400); k--) {
41 const candidate = candidates[k]
42 if (!candidate) continue
43 try {
44 return JSON.parse(body.slice(0, candidate.at) + candidate.closers)
45 } catch {
46 // the cut left a dangling key or comma after this value: try an earlier one
47 }
48 }
49 return undefined
50}
51
52/** Bump when the prompt or reply shape changes, so cached themes are not reused. */
53export const PROMPT_VERSION = 5
54
55export type ParsedDiff = { files: DiffMapFile[]; patches: Map<string, string> }
56
57const unquote = (path: string) =>
58 path.startsWith('"') && path.endsWith('"') ? path.slice(1, -1) : path
59
60const stripPrefix = (path: string) => unquote(path).replace(/^[ab]\//, '')
61
62/** Splits a unified diff (git diff / gh pr diff) into per-file stats and patch text. */
63export function parseDiff(text: string): ParsedDiff {
64 const files: DiffMapFile[] = []
65 const patches = new Map<string, string>()
66 const chunks = text.split(/^(?=diff --git )/m).filter(c => c.startsWith('diff --git '))
67
68 for (const chunk of chunks) {
69 const lines = chunk.split('\n')
70 const first = lines[0] ?? ''
71 const header = first.match(/^diff --git (\S+|"[^"]+") (\S+|"[^"]+")$/)
72 let oldPath = header?.[1] ? stripPrefix(header[1]) : ''
73 let path = header?.[2] ? stripPrefix(header[2]) : first.slice('diff --git '.length)
74 let status: DiffMapFile['status'] = 'modified'
75 let added = 0
76 let removed = 0
77 let inHunk = false
78 const contexts: string[] = []
79
80 for (const line of lines.slice(1)) {
81 if (!inHunk) {
82 if (line.startsWith('new file mode')) status = 'added'
83 else if (line.startsWith('deleted file mode')) status = 'deleted'
84 else if (line.startsWith('rename from ')) { oldPath = line.slice(12); status = 'renamed' }
85 else if (line.startsWith('rename to ')) path = line.slice(10)
86 else if (line.startsWith('Binary files') || line.startsWith('GIT binary patch')) status = 'binary'
87 else if (line.startsWith('+++ ') && line !== '+++ /dev/null') path = stripPrefix(line.slice(4))
88 }
89 if (line.startsWith('@@')) {
90 inHunk = true
91 const context = line.replace(/^@@[^@]*@@\s?/, '').trim()
92 if (context && !contexts.includes(context)) contexts.push(context)
93 continue
94 }
95 if (!inHunk) continue
96 if (line.startsWith('+')) added++
97 else if (line.startsWith('-')) removed++
98 }
99
100 files.push({
101 path,
102 ...(status === 'renamed' && oldPath !== path ? { oldPath } : {}),
103 status,
104 added,
105 removed,
106 contexts: contexts.slice(0, 8),
107 })
108 patches.set(path, chunk)
109 }
110
111 files.sort((a, b) => a.path.localeCompare(b.path))
112 return { files, patches }
113}
114
115/** Builds the model prompt: file list plus patches, each patch capped, the whole capped. */
116const CHANGES: readonly string[] = ['added', 'changed', 'unchanged']
117const KINDS = ['call', 'return', 'async'] as const
118
119const asChange = (value: unknown): DiffMapChange => (CHANGES.includes(value as string) ? (value as DiffMapChange) : 'unchanged')
120const clean = (value: unknown, max: number) => (typeof value === 'string' ? value.trim().slice(0, max) : '')
121const knownFile = (value: unknown, known: ReadonlySet<string>) => (typeof value === 'string' && known.has(value) ? value : undefined)
122const objects = (value: unknown): Record<string, unknown>[] =>
123 (Array.isArray(value) ? value : []).filter((v): v is Record<string, unknown> => typeof v === 'object' && v !== null)
124
125/** Reads the run-time sequence the model drew; anything that does not hold together is dropped. */
126export function parseFlow(raw: unknown, known: ReadonlySet<string>): DiffMapFlow | undefined {
127 if (typeof raw !== 'object' || raw === null) return undefined
128 const value = raw as Record<string, unknown>
129 const seen = new Set<string>()
130 const participants = objects(value.participants)
131 .flatMap(p => {
132 const id = clean(p.id, 24)
133 if (id === '' || seen.has(id)) return []
134 seen.add(id)
135 const file = knownFile(p.file, known)
136 return [{ id, label: clean(p.label, 40) || id, ...(file ? { file } : {}) }]
137 })
138 .slice(0, 8)
139 const steps = objects(value.steps)
140 .flatMap(s => {
141 const from = clean(s.from, 24)
142 const to = clean(s.to, 24)
143 if (!seen.has(from) || !seen.has(to) || !participants.some(p => p.id === from) || !participants.some(p => p.id === to)) return []
144 const file = knownFile(s.file, known)
145 const kind = KINDS.find(k => k === s.kind) ?? 'call'
146 return [{ from, to, label: clean(s.label, 80), kind, change: asChange(s.change), ...(file ? { file } : {}) }]
147 })
148 .slice(0, 16)
149 if (participants.length < 2 || steps.length < 2) return undefined
150 return { title: clean(value.title, 80) || 'How it runs', participants, steps }
151}
152
153const MAX_FLOWS = 4
154
155/** The model may answer with one chart or a list of them; the first few that hold together are kept. */
156const listOf = (raw: unknown) => (Array.isArray(raw) ? raw : raw ? [raw] : []).slice(0, MAX_FLOWS + 2)
157
158export function parseFlows(raw: unknown, known: ReadonlySet<string>): DiffMapFlow[] {
159 return listOf(raw).flatMap(r => parseFlow(r, known) ?? []).slice(0, MAX_FLOWS)
160}
161
162export function parseStateCharts(raw: unknown, known: ReadonlySet<string>): DiffMapStateChart[] {
163 return listOf(raw).flatMap(r => parseStates(r, known) ?? []).slice(0, MAX_FLOWS)
164}
165
166/** Reads the chart of states the model drew; undefined when it is not a chart (two states, one move). */
167export function parseStates(raw: unknown, known: ReadonlySet<string>): DiffMapStateChart | undefined {
168 if (typeof raw !== 'object' || raw === null) return undefined
169 const value = raw as Record<string, unknown>
170 const seen = new Set<string>()
171 const states = objects(value.states)
172 .flatMap(s => {
173 const id = clean(s.id, 32)
174 if (id === '' || seen.has(id)) return []
175 seen.add(id)
176 return [{ id, label: clean(s.label, 40) || id, change: asChange(s.change) }]
177 })
178 .slice(0, 10)
179 const transitions = objects(value.transitions)
180 .flatMap(t => {
181 const from = clean(t.from, 32)
182 const to = clean(t.to, 32)
183 if (!states.some(s => s.id === from) || !states.some(s => s.id === to)) return []
184 const file = knownFile(t.file, known)
185 return [{ from, to, label: clean(t.label, 70), change: asChange(t.change), ...(file ? { file } : {}) }]
186 })
187 .slice(0, 20)
188 if (states.length < 2 || transitions.length < 1) return undefined
189 return { title: clean(value.title, 80) || 'States', states, transitions }
190}
191
192const FLOW_INSTRUCTIONS = `Then describe how the change behaves at run time. "flows" lists up to 4 paths, the most important first, each as a sequence of up to 14 calls between up to 7 participants (apps, services, modules, queues, external systems), in the order they happen. Give a participant's or a step's main changed file as "file" when it has one, using an exact path from the list. Mark each step "added" (new in this change), "changed" (existing behaviour this change alters) or "unchanged" (existing behaviour the change relies on); "kind" is call, return or async (an event, queue message or webhook). Give a second, third or fourth flow only when it is a genuinely separate path (another trigger or another outcome, such as a refund next to a payment), never to pad; most changes have one. Use an empty "flows" list when the change has no run-time path worth drawing (docs, refactors, configuration). If the change adds or alters statuses or lifecycle states (an enum, a status field, a state machine), also fill "stateCharts" with one chart per separate state machine (up to 4), each with up to 9 states and 18 transitions marked added, changed or unchanged; otherwise use an empty list.`
193
194const FLOW_SHAPE = `, "flows": [{"title": "...", "participants": [{"id": "a", "label": "Patient app", "file": "path or omit"}], "steps": [{"from": "a", "to": "b", "label": "requests the charge", "kind": "call|return|async", "change": "added|changed|unchanged", "file": "path or omit"}]}], "stateCharts": [{"title": "...", "states": [{"id": "AUTHORIZED", "label": "Authorized", "change": "added|changed|unchanged"}], "transitions": [{"from": "AUTHORIZED", "to": "PAID", "label": "capture succeeds", "change": "added|changed|unchanged", "file": "path or omit"}]}]`
195
196export type PromptOptions = {
197 /** Characters of diff the prompt may carry. */
198 budget?: number
199 /** Characters of any one file's diff, where the caller has already sized the parts. */
200 perFileCap?: number
201 /** Set when the change is mapped in parts: which part this is. */
202 part?: { index: number; total: number }
203}
204
205export function buildPrompt(title: string, parsed: ParsedDiff, context = '', options: PromptOptions = {}): string {
206 const budget = options.budget ?? 150_000
207 const perFile = options.perFileCap ?? Math.max(2_000, Math.floor(budget / Math.max(1, parsed.files.length)))
208 const list = parsed.files
209 .map(f => `- ${f.path}${f.oldPath ? ` (renamed from ${f.oldPath})` : ''} [${f.status}] +${f.added} -${f.removed}`)
210 .join('\n')
211 let body = ''
212 for (const file of parsed.files) {
213 const patch = parsed.patches.get(file.path) ?? ''
214 const isNoise = /(^|\/)(pnpm-lock\.yaml|package-lock\.json|yarn\.lock)$|\.min\.(js|css)$|\.map$/.test(file.path)
215 const piece = isNoise
216 ? `diff --git a/${file.path} b/${file.path}\n(lockfile/generated, omitted)\n`
217 : patch.length > perFile
218 ? `${patch.slice(0, perFile)}\n... (${patch.length - perFile} more characters of this file's diff omitted)\n`
219 : patch
220 if (body.length + piece.length > budget) {
221 body += `\n... (remaining files' diffs omitted for size)\n`
222 break
223 }
224 body += piece
225 }
226
227 const askFlow = options.part === undefined
228 const partNote = options.part
229 ? `\nThis change is large, so it is mapped in ${options.part.total} parts, and this is part ${options.part.index} of ${options.part.total}. Group ONLY the files listed below, give every one of them a group, and relate them only to each other.\n`
230 : ''
231
232 return `${context}You are helping a developer review a code change: ${title}
233${partNote}
234Group the changed files into a small number of themes (the logical changes a reviewer would think in, e.g. "new search endpoint", "schema field", "tests", "docs"), not by directory. Every file must appear in exactly one group. For each file give 1-3 terse notes on WHAT changed in it (behaviour, not line counts). Rate each group's review risk. Then give the order a reviewer should read the files in (the core change first, then its callers, then tests/docs), and any concerns worth a closer look (possible bugs, missing tests, breaking changes). Also list the meaningful relationships between changed files (a calls or imports b, a schema field is read by b, a migration backs a model change, a test covers b, a view renders b): up to 12, each with a label under 50 characters; skip trivial or purely directory-level links. Be concise: notes under 90 characters.${askFlow ? ` ${FLOW_INSTRUCTIONS}` : ''}
235
236Reply with JSON only, no prose, no code fence, in exactly this shape:
237{"summary": "one sentence", "groups": [{"title": "...", "kind": "feature|fix|refactor|test|docs|config|chore", "risk": "low|medium|high", "why": "one short line on what this group does", "files": [{"path": "exact path from the list", "notes": ["..."]}]}], "reviewOrder": ["path", ...], "concerns": ["..."], "links": [{"from": "path", "to": "path", "label": "calls the new endpoint"}]${askFlow ? FLOW_SHAPE : ''}}
238
239Changed files:
240${list}
241
242Diff:
243${body}`
244}
245
246const RISKS = ['low', 'medium', 'high'] as const
247
248/** Reads the model's reply as themes; undefined when it is not the expected JSON. */
249export function parseThemes(text: string, files: DiffMapFile[]): DiffMapThemes | undefined {
250 const raw = readJson(text)
251 if (typeof raw !== 'object' || raw === null || !Array.isArray((raw as { groups?: unknown }).groups)) return undefined
252 const value = raw as Record<string, unknown>
253 const strings = (v: unknown) => (Array.isArray(v) ? v.filter((s): s is string => typeof s === 'string') : [])
254
255 const groups: DiffMapGroup[] = (value.groups as unknown[]).flatMap(g => {
256 if (typeof g !== 'object' || g === null) return []
257 const group = g as Record<string, unknown>
258 const risk = RISKS.find(r => r === group.risk) ?? 'low'
259 const groupFiles = Array.isArray(group.files)
260 ? group.files.flatMap(f => {
261 if (typeof f !== 'object' || f === null) return []
262 const file = f as Record<string, unknown>
263 return typeof file.path === 'string' ? [{ path: file.path, notes: strings(file.notes) }] : []
264 })
265 : []
266 return [{
267 title: typeof group.title === 'string' ? group.title : 'Changes',
268 kind: typeof group.kind === 'string' ? group.kind : 'change',
269 risk,
270 why: typeof group.why === 'string' ? group.why : '',
271 files: groupFiles,
272 }]
273 })
274
275 const covered = new Set(groups.flatMap(g => g.files.map(f => f.path)))
276 const missing = files.filter(f => !covered.has(f.path))
277 if (missing.length > 0) {
278 groups.push({
279 title: 'Not grouped',
280 kind: 'other',
281 risk: 'low',
282 why: 'Files the model left out',
283 files: missing.map(f => ({ path: f.path, notes: [] })),
284 })
285 }
286
287 const known = new Set(files.map(f => f.path))
288 const links: DiffMapLink[] = (Array.isArray(value.links) ? value.links : []).flatMap((l: unknown) => {
289 if (typeof l !== 'object' || l === null) return []
290 const link = l as Record<string, unknown>
291 const { from, to } = link
292 if (typeof from !== 'string' || typeof to !== 'string' || from === to || !known.has(from) || !known.has(to)) return []
293 return [{ from, to, label: typeof link.label === 'string' ? link.label : '' }]
294 })
295
296 // an older reply, or a model that kept to the old shape, gives one flow and one chart
297 const flows = parseFlows(value.flows ?? value.flow, known)
298 const stateCharts = parseStateCharts(value.stateCharts ?? value.states, known)
299
300 return {
301 summary: typeof value.summary === 'string' ? value.summary : '',
302 groups,
303 reviewOrder: strings(value.reviewOrder),
304 concerns: strings(value.concerns),
305 links: links.slice(0, 20),
306 ...(flows.length > 0 ? { flows } : {}),
307 ...(stateCharts.length > 0 ? { stateCharts } : {}),
308 }
309}
310
311/** The second step of a large change: one summary and the links between the parts, from the parts' themes alone. */
312export function buildStitchPrompt(title: string, parts: readonly DiffMapThemes[], budget = 60_000): string {
313 let themes = ''
314 for (const part of parts) {
315 for (const group of part.groups) {
316 const block = `- ${group.title} (${group.kind}, ${group.risk} risk): ${group.why}\n${group.files.map(f => ` ${f.path}${f.notes[0] ? ` - ${f.notes[0]}` : ''}`).join('\n')}\n`
317 if (themes.length + block.length > budget) return finishStitch(title, `${themes}- ... (more themes omitted for size)\n`)
318 themes += block
319 }
320 }
321 return finishStitch(title, themes)
322}
323
324const finishStitch = (title: string, themes: string) => `You are helping a developer review a large code change: ${title}
325
326It was mapped in separate parts. Below are the themes found, each with its files and a note on what changed. Write one sentence that summarises the whole change, and list the meaningful relationships BETWEEN files that belong to different themes (a calls or imports b, a schema field is read by b, a migration backs a model change, a test covers b, a view renders b): up to 15, each with a label under 50 characters, using the exact paths below. Skip trivial links. ${FLOW_INSTRUCTIONS.replace('Then describe', 'Finally describe').replace('from the list', 'from the themes')} You only have the themes, not the diffs, so keep the flow high-level.
327
328Reply with JSON only, no prose, no code fence, in exactly this shape:
329{"summary": "one sentence", "links": [{"from": "path", "to": "path", "label": "calls the new endpoint"}]${FLOW_SHAPE}}
330
331Themes:
332${themes}`
333
334export type Stitch = { summary: string; links: DiffMapLink[]; flows?: DiffMapFlow[]; stateCharts?: DiffMapStateChart[] }
335
336/** Reads the second step's reply; undefined when it is not the expected JSON. */
337export function parseStitch(text: string, files: readonly DiffMapFile[]): Stitch | undefined {
338 const raw = readJson(text)
339 if (typeof raw !== 'object' || raw === null) return undefined
340 const value = raw as Record<string, unknown>
341 const known = new Set(files.map(f => f.path))
342 const links: DiffMapLink[] = (Array.isArray(value.links) ? value.links : []).flatMap((l: unknown) => {
343 if (typeof l !== 'object' || l === null) return []
344 const link = l as Record<string, unknown>
345 const { from, to } = link
346 if (typeof from !== 'string' || typeof to !== 'string' || from === to || !known.has(from) || !known.has(to)) return []
347 return [{ from, to, label: typeof link.label === 'string' ? link.label : '' }]
348 })
349 const flows = parseFlows(value.flows ?? value.flow, known)
350 const stateCharts = parseStateCharts(value.stateCharts ?? value.states, known)
351 return {
352 summary: typeof value.summary === 'string' ? value.summary : '',
353 links: links.slice(0, 15),
354 ...(flows.length > 0 ? { flows } : {}),
355 ...(stateCharts.length > 0 ? { stateCharts } : {}),
356 }
357}
358hooks/review-order.ts 178 lines1import type { DiffMapData, DiffMapLink } from '../types'
2import { isDocs, isGenerated, isTest, reviewPaths } from './model'
3
4export type StrategyKey = 'bottom-up' | 'top-down' | 'theme' | 'model'
5
6export const STRATEGIES: { key: StrategyKey; label: string; blurb: string }[] = [
7 { key: 'theme', label: 'By theme', blurb: "The model's themes, one after another." },
8 { key: 'bottom-up', label: 'Bottom-up', blurb: 'Definitions before the code that uses them, then tests, then docs.' },
9 { key: 'top-down', label: 'Top-down', blurb: 'Entry points first, then what they call, then tests, then docs.' },
10 { key: 'model', label: "Model's order", blurb: "The model's own suggested order, untouched." },
11]
12
13export type DiagramStep = { path: string; reason: string; stage: string }
14export type DiagramStage = { label: string; from: number; to: number }
15export type DiagramOrder = { key: StrategyKey; label: string; blurb: string; steps: DiagramStep[]; stages: DiagramStage[] }
16
17type Category = 'code' | 'tests' | 'docs' | 'generated'
18const categoryOf = (path: string): Category => (isGenerated(path) ? 'generated' : isTest(path) ? 'tests' : isDocs(path) ? 'docs' : 'code')
19const CATEGORY_LABEL: Record<Category, string> = { code: 'Code', tests: 'Tests', docs: 'Docs', generated: 'Generated, skim' }
20const CATEGORY_RANK: Record<Category, number> = { code: 0, tests: 1, docs: 2, generated: 3 }
21
22type CodeStage = 'foundations' | 'core' | 'entry' | 'independent'
23const CODE_STAGE_LABEL: Record<CodeStage, string> = {
24 foundations: 'Foundations',
25 core: 'Core logic',
26 entry: 'Callers and entry points',
27 independent: 'Independent changes',
28}
29const BOTTOM_UP_STAGES: CodeStage[] = ['foundations', 'core', 'entry', 'independent']
30const TOP_DOWN_STAGES: CodeStage[] = ['entry', 'core', 'foundations', 'independent']
31
32const baseName = (path: string) => path.split('/').pop() ?? path
33
34/**
35 * Kahn's algorithm over `edges` (a pair means: first before second), ties and cycles broken by `tie`.
36 * Always returns every path exactly once.
37 */
38function topological(paths: readonly string[], edges: readonly [string, string][], tie: (p: string) => number): string[] {
39 const set = new Set(paths)
40 const out = new Map<string, string[]>()
41 const indeg = new Map<string, number>(paths.map(p => [p, 0]))
42 for (const [a, b] of edges) {
43 if (!set.has(a) || !set.has(b) || a === b) continue
44 out.set(a, [...(out.get(a) ?? []), b])
45 indeg.set(b, (indeg.get(b) ?? 0) + 1)
46 }
47 const left = new Set(paths)
48 const result: string[] = []
49 while (left.size > 0) {
50 const pool = [...left].sort((x, y) => tie(x) - tie(y))
51 const ready = pool.filter(p => (indeg.get(p) ?? 0) === 0)
52 const next = ready[0] ?? pool.reduce((best, p) => ((indeg.get(p) ?? 0) < (indeg.get(best) ?? 0) ? p : best), pool[0] as string)
53 left.delete(next)
54 result.push(next)
55 for (const b of out.get(next) ?? []) indeg.set(b, (indeg.get(b) ?? 0) - 1)
56 }
57 return result
58}
59
60function runsOf(steps: readonly DiagramStep[]): DiagramStage[] {
61 const stages: DiagramStage[] = []
62 steps.forEach((step, i) => {
63 const last = stages[stages.length - 1]
64 if (last && last.label === step.stage) last.to = i
65 else stages.push({ label: step.stage, from: i, to: i })
66 })
67 return stages
68}
69
70const more = (n: number) => (n > 1 ? ` and ${n - 1} more` : '')
71
72/** Every reading order the diagram offers, each with a reason per step and the stages it falls into. */
73export function buildOrders(map: DiffMapData, links: readonly DiffMapLink[]): DiagramOrder[] {
74 const model = reviewPaths(map)
75 const modelIndex = new Map(model.map((p, i): [string, number] => [p, i]))
76 const tie = (p: string) => modelIndex.get(p) ?? Number.MAX_SAFE_INTEGER
77 const groupTitle = (p: string) => map.themes?.groups.find(g => g.files.some(f => f.path === p))?.title
78 const groupWhy = (p: string) => map.themes?.groups.find(g => g.files.some(f => f.path === p))?.why ?? ''
79
80 const dependents = (p: string) => links.filter(l => l.to === p) // they depend on p
81 const dependencies = (p: string) => links.filter(l => l.from === p) // p depends on them
82 const byCategory = (c: Category) => model.filter(p => categoryOf(p) === c)
83
84 /** Code, tests, docs, generated: one dependency-ordered sequence, `bottomUp` choosing which way the links read. */
85 const layered = (bottomUp: boolean): DiagramStep[] => {
86 const code = byCategory('code')
87 const codeSet = new Set(code)
88 const inCode = (l: DiffMapLink) => codeSet.has(l.from) && codeSet.has(l.to)
89 const codeLinks = links.filter(inCode)
90 const stageOf = (p: string): CodeStage => {
91 const deps = codeLinks.filter(l => l.from === p).length
92 const users = codeLinks.filter(l => l.to === p).length
93 if (deps === 0 && users === 0) return 'independent'
94 if (deps === 0) return 'foundations'
95 if (users === 0) return 'entry'
96 return 'core'
97 }
98 // a file that is called must be read first going bottom-up, last going top-down
99 const edges: [string, string][] = codeLinks.map(l => (bottomUp ? [l.to, l.from] : [l.from, l.to]))
100 const position = new Map(topological(code, edges, tie).map((p, i): [string, number] => [p, i]))
101 const stageOrder = bottomUp ? BOTTOM_UP_STAGES : TOP_DOWN_STAGES
102 const codeOrdered = [...code].sort(
103 (a, b) => stageOrder.indexOf(stageOf(a)) - stageOrder.indexOf(stageOf(b)) || (position.get(a) ?? 0) - (position.get(b) ?? 0),
104 )
105
106 const codeStep = (p: string): DiagramStep => {
107 const stage = stageOf(p)
108 const used = dependents(p)
109 const uses = dependencies(p)
110 let reason: string
111 if (stage === 'independent') {
112 const title = groupTitle(p)
113 reason = title ? `independent change in ${title}` : 'independent change'
114 } else if (bottomUp) {
115 reason =
116 used.length > 0
117 ? `read before ${baseName((used[0] as DiffMapLink).from)} (${(used[0] as DiffMapLink).label || 'it depends on this'})${more(used.length)}`
118 : `builds on ${baseName((uses[0] as DiffMapLink).to)} (${(uses[0] as DiffMapLink).label || 'it depends on it'})${more(uses.length)}`
119 } else {
120 reason =
121 uses.length > 0 && used.length === 0
122 ? `entry point: ${(uses[0] as DiffMapLink).label || 'calls'} ${baseName((uses[0] as DiffMapLink).to)}${more(uses.length)}`
123 : used.length > 0
124 ? `called by ${baseName((used[0] as DiffMapLink).from)} (${(used[0] as DiffMapLink).label || 'it uses this'})${more(used.length)}`
125 : `builds on ${baseName((uses[0] as DiffMapLink).to)}`
126 }
127 return { path: p, reason, stage: CODE_STAGE_LABEL[stage] }
128 }
129
130 // tests follow the code they cover, in the order that code is read
131 const at = new Map(codeOrdered.map((p, i): [string, number] => [p, i]))
132 const testOrder = (p: string) =>
133 Math.min(...links.filter(l => l.from === p || l.to === p).map(l => at.get(l.from === p ? l.to : l.from) ?? Infinity), Infinity)
134 const tests = [...byCategory('tests')].sort((a, b) => testOrder(a) - testOrder(b) || tie(a) - tie(b))
135 const testStep = (p: string): DiagramStep => {
136 const link = links.find(l => (l.from === p || l.to === p) && codeSet.has(l.from === p ? l.to : l.from))
137 const target = link ? baseName(link.from === p ? link.to : link.from) : ''
138 return { path: p, reason: target ? `covers ${target}: read after the code` : 'test: read after the code', stage: CATEGORY_LABEL.tests }
139 }
140
141 return [
142 ...codeOrdered.map(codeStep),
143 ...tests.map(testStep),
144 ...byCategory('docs').map((p): DiagramStep => ({ path: p, reason: 'documentation: read last', stage: CATEGORY_LABEL.docs })),
145 ...byCategory('generated').map((p): DiagramStep => ({ path: p, reason: 'generated: skim only', stage: CATEGORY_LABEL.generated })),
146 ]
147 }
148
149 const byTheme = (): DiagramStep[] => {
150 const seen = new Set<string>()
151 const steps: DiagramStep[] = []
152 for (const group of map.themes?.groups ?? []) {
153 const inGroup = group.files.filter(f => modelIndex.has(f.path) && !seen.has(f.path)).sort((a, b) => tie(a.path) - tie(b.path))
154 for (const { path: p, notes } of inGroup) {
155 seen.add(p)
156 // the stage heading already names the theme, so a row says what is particular to its file
157 steps.push({ path: p, reason: notes[0] ?? (group.why ? `${group.title}: ${group.why}` : group.title), stage: group.title })
158 }
159 }
160 for (const p of model) if (!seen.has(p)) steps.push({ path: p, reason: 'not part of a theme', stage: 'Other files' })
161 return steps
162 }
163
164 const modelOrder = (): DiagramStep[] =>
165 model.map(p => ({ path: p, reason: groupWhy(p) ? `the model's suggestion (${groupWhy(p)})` : "the model's suggestion", stage: CATEGORY_LABEL[categoryOf(p)] }))
166
167 const build: Record<StrategyKey, () => DiagramStep[]> = {
168 'bottom-up': () => layered(true),
169 'top-down': () => layered(false),
170 theme: byTheme,
171 model: modelOrder,
172 }
173 return STRATEGIES.map(s => {
174 const steps = build[s.key]()
175 return { key: s.key, label: s.label, blurb: s.blurb, steps, stages: runsOf(steps) }
176 })
177}
178hooks/sensitive.ts 80 lines1import { isDocs, isGenerated, isTest } from './model'
2
3export type SensitiveTag = { id: string; label: string; why: string }
4
5type Rule = SensitiveTag & { path?: RegExp; added?: RegExp }
6
7/** Areas where a mistake costs more than usual: found from the path, or from what the change adds. */
8const RULES: Rule[] = [
9 {
10 id: 'rbac',
11 label: 'RBAC',
12 why: 'access control: check who can reach this and what each role may do',
13 path: /(^|\/)rbac(\/|-)|access-manager|db-access|permission/i,
14 },
15 {
16 id: 'auth',
17 label: 'Auth',
18 why: 'authentication and sessions: check token, session and login handling',
19 path: /(^|\/)(auth|authentication|cognito)(\/|[-.])|login|(^|[/-])session/i,
20 },
21 {
22 id: 'crypto',
23 label: 'Crypto',
24 why: 'encryption and keys: check key handling, and that nothing sensitive is logged or sent in the clear',
25 path: /encrypt|decrypt|crypto|ecies|private-?key/i,
26 },
27 {
28 id: 'payments',
29 label: 'Payments',
30 why: 'money: check amounts, currency, idempotency and the refund paths',
31 path: /payment|stripe|unzer|invoice|prepay|refund|voucher|billing|subscription/i,
32 },
33 {
34 id: 'nhis',
35 label: 'NHIS',
36 why: 'medical records sent to the national system: check the mappings and required fields',
37 path: /nhis|e-?prescription|e-?referral|medical-notice/i,
38 },
39 {
40 id: 'migration',
41 label: 'Migration',
42 why: 'database migration: check it is reversible and safe on existing data',
43 path: /(^|\/)migrations\//,
44 },
45 {
46 id: 'config',
47 label: 'Config',
48 why: 'environment or deploy configuration: check for secrets and per-environment values',
49 path: /(^|\/)(\.env[^/]*|docker-compose[^/]*|deploy\/|Dockerfile|Jenkinsfile)/,
50 },
51 {
52 id: 'dynamic-code',
53 label: 'Dynamic code',
54 why: 'code run from a string or a shell: check that the input cannot be controlled by an attacker',
55 added: /\beval\(|new Function\(|child_process|\b(exec|execSync|spawnSync)\(/,
56 },
57 {
58 id: 'html',
59 label: 'Raw HTML',
60 why: 'raw HTML insertion: check that the content is escaped or sanitised',
61 added: /dangerouslySetInnerHTML|\.innerHTML\s*=/,
62 },
63 {
64 id: 'secret',
65 label: 'Secret?',
66 why: 'looks like a credential in added code: check it is not a real one',
67 added: /(api[_-]?key|secret|password|token)\s*[:=]\s*["'][A-Za-z0-9_-]{16,}["']/i,
68 },
69]
70
71/** The sensitive areas one changed file touches. Tests, docs and generated files never count. */
72export function sensitiveTags(path: string, patch: string): SensitiveTag[] {
73 if (isTest(path) || isDocs(path) || isGenerated(path)) return []
74 const added = patch
75 .split('\n')
76 .filter(l => l.startsWith('+') && !l.startsWith('+++'))
77 .join('\n')
78 return RULES.filter(r => (r.path?.test(path) ?? false) || (r.added?.test(added) ?? false)).map(({ id, label, why }) => ({ id, label, why }))
79}
80hooks/store.ts 35 lines1import type { EngineInterface } from 'claude-code'
2
3import type { DiffMapThemes } from '../types'
4
5const MAX_ENTRIES = 40
6
7export async function sha256(text: string): Promise<string> {
8 const bytes = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
9 return [...new Uint8Array(bytes)].map(b => b.toString(16).padStart(2, '0')).join('')
10}
11
12/** Writes `value` under `key` and keeps the newest MAX_ENTRIES keys of that kind, dropping the oldest. */
13async function remember($: EngineInterface, kind: string, key: string, value: unknown) {
14 const indexKey = `diffmap:index:${kind}`
15 const raw = await $.store.get(indexKey)
16 const index = Array.isArray(raw) ? raw.filter((k): k is string => typeof k === 'string') : []
17 const next = [...index.filter(k => k !== key), key]
18 const dropped = next.splice(0, Math.max(0, next.length - MAX_ENTRIES))
19 for (const old of dropped) await $.store.delete(`diffmap:${kind}:${old}`)
20 await $.store.set(`diffmap:${kind}:${key}`, value)
21 await $.store.set(indexKey, next)
22}
23
24const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
25
26export async function loadCachedThemes($: EngineInterface, key: string): Promise<DiffMapThemes | undefined> {
27 const raw = await $.store.get(`diffmap:themes:${key}`)
28 if (!isObject(raw) || !Array.isArray(raw.groups)) return undefined
29 return raw as unknown as DiffMapThemes
30}
31
32export async function saveCachedThemes($: EngineInterface, key: string, themes: DiffMapThemes) {
33 await remember($, 'themes', key, themes)
34}
35hooks/diagram-data.ts 199 lines1import type { DiffMapData, DiffMapFile, DiffMapFlow, DiffMapLink, DiffMapSensitive, DiffMapStateChart } from '../types'
2import { groupOf, isDocs, isGenerated, isTest, linksFor } from './model'
3import { buildOrders } from './review-order'
4import type { DiagramOrder, StrategyKey } from './review-order'
5
6export const MAX_PATCH = 200_000
7
8export type Lane = { title: string; kind: string; risk: 'low' | 'medium' | 'high'; why: string; files: string[] }
9
10/** Lanes are the model's themes; with none readable the top-level folders stand in. */
11export function lanesOf(map: DiffMapData): Lane[] {
12 const known = new Set(map.files.map(f => f.path))
13 const lanes: Lane[] = (map.themes?.groups ?? [])
14 .map(g => ({ title: g.title, kind: g.kind, risk: g.risk, why: g.why, files: g.files.map(f => f.path).filter(p => known.has(p)) }))
15 .filter(l => l.files.length > 0)
16 if (lanes.length > 0) return lanes
17
18 const folders = new Map<string, string[]>()
19 for (const file of map.files) {
20 const top = file.path.includes('/') ? (file.path.split('/')[0] ?? '.') : '.'
21 folders.set(top, [...(folders.get(top) ?? []), file.path])
22 }
23 return [...folders.entries()].map(([title, files]) => ({ title: title === '.' ? 'Top level' : `${title}/`, kind: 'folder', risk: 'low' as const, why: '', files }))
24}
25
26const laneIndex = (lanes: readonly Lane[]) => new Map(lanes.flatMap((l, i) => l.files.map((p): [string, number] => [p, i])))
27
28/**
29 * Callers to the left, what they call to the right: a link runs from the file that depends
30 * on the other, so lanes are taken in dependency order (Kahn), the original order breaking ties
31 * and cycles.
32 */
33export function orderLanes(lanes: readonly Lane[], links: readonly DiffMapLink[]): Lane[] {
34 const at = laneIndex(lanes)
35 const edges = new Set<string>()
36 for (const link of links) {
37 const a = at.get(link.from)
38 const b = at.get(link.to)
39 if (a !== undefined && b !== undefined && a !== b) edges.add(`${a}>${b}`)
40 }
41 const remaining = new Set(lanes.map((_, i) => i))
42 const incoming = (i: number) => [...edges].filter(e => {
43 const [from, to] = e.split('>').map(Number)
44 return to === i && from !== undefined && remaining.has(from)
45 }).length
46
47 const out: Lane[] = []
48 while (remaining.size > 0) {
49 const candidates = [...remaining].sort((a, b) => a - b)
50 const next = candidates.find(i => incoming(i) === 0) ?? candidates.reduce((best, i) => (incoming(i) < incoming(best) ? i : best))
51 remaining.delete(next)
52 const lane = lanes[next]
53 if (lane) out.push(lane)
54 }
55 return out
56}
57
58/** Reorders files inside each lane by the average height of what they link to, to cut crossings. */
59export function orderFiles(lanes: readonly Lane[], links: readonly DiffMapLink[]): Lane[] {
60 const result = lanes.map(l => ({ ...l, files: [...l.files] }))
61 const at = laneIndex(result)
62 const pos = new Map<string, number>()
63 const place = (lane: Lane) => lane.files.forEach((p, i) => pos.set(p, (i + 0.5) / lane.files.length))
64 result.forEach(place)
65
66 const neighbours = new Map<string, string[]>()
67 for (const { from, to } of links) {
68 if (at.get(from) === undefined || at.get(to) === undefined || at.get(from) === at.get(to)) continue
69 neighbours.set(from, [...(neighbours.get(from) ?? []), to])
70 neighbours.set(to, [...(neighbours.get(to) ?? []), from])
71 }
72
73 const sweep = (order: number[]) => {
74 for (const li of order) {
75 const lane = result[li]
76 if (!lane) continue
77 const score = (p: string) => {
78 const near = neighbours.get(p)
79 if (!near || near.length === 0) return pos.get(p) ?? 0
80 return near.reduce((sum, n) => sum + (pos.get(n) ?? 0), 0) / near.length
81 }
82 const scored = lane.files.map((p, i) => ({ p, i, s: score(p) }))
83 scored.sort((a, b) => a.s - b.s || a.i - b.i)
84 lane.files = scored.map(x => x.p)
85 place(lane)
86 }
87 }
88 const forward = result.map((_, i) => i)
89 sweep(forward)
90 sweep([...forward].reverse())
91 return result
92}
93
94export type DiagramFile = {
95 status: DiffMapFile['status']
96 oldPath: string | null
97 added: number
98 removed: number
99 group: string | null
100 risk: 'low' | 'medium' | 'high' | null
101 why: string
102 notes: string[]
103 checks: { severity: 'warn' | 'info'; text: string }[]
104 /** Sensitive areas this file touches. */
105 risks: DiffMapSensitive[]
106 links: ReturnType<typeof linksFor>
107 isTest: boolean
108 isDocs: boolean
109 isGenerated: boolean
110 linked: boolean
111 patch: string
112 full: string | null
113}
114
115export type DiagramRiskTag = DiffMapSensitive & { count: number }
116
117export type DiagramData = {
118 title: string
119 /** Where the page keeps what was ticked off in the browser. */
120 storeKey: string
121 strategy: StrategyKey
122 orders: DiagramOrder[]
123 lanes: Lane[]
124 files: Record<string, DiagramFile>
125 links: DiffMapLink[]
126 /** Every sensitive area the change touches, with how many files each covers. */
127 riskTags: DiagramRiskTag[]
128 /** The run-time sequences the model drew, the main one first; none when the change has no run-time path. */
129 flows: DiffMapFlow[]
130 /** The lifecycle states the change adds or alters, a chart per state machine. */
131 stateCharts: DiffMapStateChart[]
132 concerns: string[]
133 general: { severity: 'warn' | 'info'; text: string }[]
134}
135
136export type DiagramOptions = {
137 /** The review order the page opens on; the computed one when there are no themes to read by. */
138 defaultStrategy?: StrategyKey
139}
140
141export function buildDiagramData(map: DiffMapData, fullFiles: Readonly<Record<string, string>> = {}, options: DiagramOptions = {}): DiagramData {
142 const links = (map.themes?.links ?? []).filter(l => map.files.some(f => f.path === l.from) && map.files.some(f => f.path === l.to))
143 const linked = new Set(links.flatMap(l => [l.from, l.to]))
144 const lanes = orderFiles(orderLanes(lanesOf(map), links), links)
145 const hasThemes = (map.themes?.groups.length ?? 0) > 0
146 const wanted = options.defaultStrategy ?? 'theme'
147
148 const files = Object.fromEntries(
149 map.files.map((file): [string, DiagramFile] => {
150 const found = groupOf(map, file.path)
151 const patch = map.patches?.[file.path] ?? ''
152 return [
153 file.path,
154 {
155 status: file.status,
156 oldPath: file.oldPath ?? null,
157 added: file.added,
158 removed: file.removed,
159 group: found?.group.title ?? null,
160 risk: found?.group.risk ?? null,
161 why: found?.group.why ?? '',
162 notes: found?.notes ?? [],
163 checks: (map.checks ?? []).filter(c => c.path === file.path).map(c => ({ severity: c.severity, text: c.text })),
164 risks: map.sensitive?.[file.path] ?? [],
165 links: linksFor(links, file.path),
166 isTest: isTest(file.path),
167 isDocs: isDocs(file.path),
168 isGenerated: isGenerated(file.path),
169 linked: linked.has(file.path),
170 patch: patch.length > MAX_PATCH ? `${patch.slice(0, MAX_PATCH)}\n… (diff cut at ${MAX_PATCH} characters)` : patch,
171 full: fullFiles[file.path] ?? null,
172 },
173 ]
174 }),
175 )
176
177 const tags = new Map<string, DiagramRiskTag>()
178 for (const file of Object.values(files)) {
179 for (const risk of file.risks) tags.set(risk.id, { ...risk, count: (tags.get(risk.id)?.count ?? 0) + 1 })
180 }
181
182 return {
183 title: map.title,
184 storeKey: map.storeKey,
185 // the model's themes are the natural reading order; without any there is nothing to read by
186 strategy: wanted === 'theme' && !hasThemes ? 'bottom-up' : wanted,
187 orders: buildOrders(map, links),
188 lanes,
189 files,
190 links,
191 riskTags: [...tags.values()].sort((a, b) => b.count - a.count || a.label.localeCompare(b.label)),
192 // a map kept before there could be several holds a single flow and a single chart
193 flows: map.themes?.flows ?? (map.themes?.flow ? [map.themes.flow] : []),
194 stateCharts: map.themes?.stateCharts ?? (map.themes?.states ? [map.themes.states] : []),
195 concerns: map.themes?.concerns ?? [],
196 general: (map.checks ?? []).filter(c => c.path === undefined).map(c => ({ severity: c.severity, text: c.text })),
197 }
198}
199types/index.d.ts 117 lines1export type DiffMapStatus = 'added' | 'deleted' | 'renamed' | 'modified' | 'binary'
2
3export type DiffMapFile = {
4 path: string
5 oldPath?: string
6 status: DiffMapStatus
7 added: number
8 removed: number
9 /** Function/section names from the hunk headers, deduplicated. */
10 contexts: string[]
11}
12
13export type DiffMapGroup = {
14 title: string
15 kind: string
16 risk: 'low' | 'medium' | 'high'
17 why: string
18 files: { path: string; notes: string[] }[]
19}
20
21/** A relationship between two changed files, as the model read it from the diff. */
22export type DiffMapLink = {
23 from: string
24 to: string
25 label: string
26}
27
28/** What this change does to a step, state or transition. */
29export type DiffMapChange = 'added' | 'changed' | 'unchanged'
30
31/** One call in the run-time sequence the change takes part in. */
32export type DiffMapFlowStep = {
33 from: string
34 to: string
35 label: string
36 kind: 'call' | 'return' | 'async'
37 change: DiffMapChange
38 /** The changed file that implements the step, when there is one. */
39 file?: string
40}
41
42/** The main run-time path as a sequence of calls between the parts of the system. */
43export type DiffMapFlow = {
44 title: string
45 participants: { id: string; label: string; file?: string }[]
46 steps: DiffMapFlowStep[]
47}
48
49/** The statuses a change adds or alters, as a chart of states and the moves between them. */
50export type DiffMapStateChart = {
51 title: string
52 states: { id: string; label: string; change: DiffMapChange }[]
53 transitions: { from: string; to: string; label: string; change: DiffMapChange; file?: string }[]
54}
55
56export type DiffMapThemes = {
57 summary: string
58 groups: DiffMapGroup[]
59 reviewOrder: string[]
60 concerns: string[]
61 links: DiffMapLink[]
62 /** How the change behaves at run time, the main path first; absent when it has none worth drawing. */
63 flows?: DiffMapFlow[]
64 /** The lifecycle states the change adds or alters, one chart per state machine; absent when it touches none. */
65 stateCharts?: DiffMapStateChart[]
66 /** Maps kept before there could be several: a single flow and a single chart of states. */
67 flow?: DiffMapFlow
68 states?: DiffMapStateChart
69}
70
71/** A finding of the repo-convention checks (computed locally, no model). */
72export type DiffMapCheck = {
73 severity: 'warn' | 'info'
74 text: string
75 path?: string
76}
77
78/** A sensitive area a changed file touches (access control, payments, migrations, ...). */
79export type DiffMapSensitive = {
80 id: string
81 label: string
82 why: string
83}
84
85export type DiffMapData = {
86 /** What was mapped: `local`, `pr:<number>` or `pr:<owner/name>#<number>`; a repeat of the same request reopens it. */
87 key: string
88 /** The repository a pull request was read from, when it was named rather than taken from the folder. */
89 repo?: string
90 /** Where per-map values are kept: the repo folder plus `key`. */
91 storeKey: string
92 source: string
93 title: string
94 files: DiffMapFile[]
95 phase: 'loading' | 'thinking' | 'done' | 'error'
96 error?: string
97 themes?: DiffMapThemes
98 /** The model's raw reply, when it could not be read as JSON. */
99 rawThemes?: string
100 /** Each changed file's unified diff, by path. */
101 patches?: Record<string, string>
102 checks?: DiffMapCheck[]
103 /** The sensitive areas each file touches, by path; only files that touch any are listed. */
104 sensitive?: Record<string, DiffMapSensitive[]>
105 /** True when the themes came from the cross-session cache, not the model. */
106 isCached?: boolean
107}
108
109declare module 'claude-code' {
110 interface PluginState {
111 'review-map': {
112 /** The last mapped change, kept so the same request can reopen the diagram. */
113 data: DiffMapData | null
114 }
115 }
116}
117