| 1 | #!/usr/bin/env node |
| 2 | |
| 3 | /** |
| 4 | * Single-scroll-writer contract for the native transcript viewport. |
| 5 | * |
| 6 | * Only the files in ALLOWED_WRITERS may issue imperative scroll calls |
| 7 | * (scrollTop / scrollTo / scrollBy / virtualizer scroll APIs) against the |
| 8 | * transcript: the generation-aware TranscriptViewportWriter. |
| 9 | * Every other module must route through ChatScrollController's |
| 10 | * layout/jump/toBottom API. This guards the "one writer owns scrollTop" |
| 11 | * invariant that keeps user scrolls, tail-follow, and anchor recovery from |
| 12 | * fighting each other (#8657). |
| 13 | * |
| 14 | * Other virtualized surfaces (WorkspacePanel, VirtualMenu, LineNumberCode) |
| 15 | * own independent scrollers and are out of scope. |
| 16 | */ |
| 17 | |
| 18 | import { readdirSync, readFileSync } from "node:fs"; |
| 19 | import { join } from "node:path"; |
| 20 | import { fileURLToPath } from "node:url"; |
| 21 | |
| 22 | const SOURCE_ROOT = fileURLToPath(new URL("../src", import.meta.url)); |
| 23 | |
| 24 | // Every controller/adapter command routes through this one gateway. |
| 25 | const ALLOWED_WRITERS = new Set([ |
| 26 | "lib/transcriptViewportWriter.ts", |
| 27 | ]); |
| 28 | |
| 29 | // Raw `.scrollTop` writes bypass the controller entirely. The allowed |
| 30 | // set is deliberate: the Transcript writer is the sole fenced gateway; all |
| 31 | // remaining entries are non-transcript (or natively paired with the arbiter): |
| 32 | // - lib/useReasoningScrollFollow.ts: an inner reasoning pane, not Transcript. |
| 33 | // - components/SettingsPanel.tsx: the settings overlay's own scroller. |
| 34 | // - components/WorkspacePanel.tsx: the project tree's own scroller. |
| 35 | // - components/editors/LineNumberCode.tsx: the file viewer's own scroller — |
| 36 | // resets scroll when a virtual file is replaced by a non-virtual one. |
| 37 | // - custom/features/heartbeat/HeartbeatPanel.tsx: the heartbeat list's custom |
| 38 | // scrollbar thumb drag, mapped to its own scroller. |
| 39 | const ALLOWED_RAW_SCROLLTOP = new Set([ |
| 40 | "lib/transcriptViewportWriter.ts", |
| 41 | "lib/useReasoningScrollFollow.ts", |
| 42 | "components/SettingsPanel.tsx", |
| 43 | "components/RemoteConnectWizard.tsx", |
| 44 | "components/WorkspacePanel.tsx", |
| 45 | "components/editors/LineNumberCode.tsx", |
| 46 | "custom/features/heartbeat/HeartbeatPanel.tsx", |
| 47 | ]); |
| 48 | const IMPERATIVE_SCROLL_RE = /\.scroll(?:To|By)\s*\(|\.scrollTo(?:Offset|Index)\s*\(/; |
| 49 | const RAW_SCROLLTOP_WRITE_RE = /\.scrollTop\s*=(?!=)/; |
| 50 | const TRANSCRIPT_SURFACE_RE = /(?:^|\/)(?:chat[^/]*|Chat[^/]*|transcript[^/]*|useTranscript[^/]*|Transcript[^/]*|MarkdownHistory)\.(?:ts|tsx)$/; |
| 51 | |
| 52 | function sourceFiles(root) { |
| 53 | const files = []; |
| 54 | const visit = (dir) => { |
| 55 | for (const entry of readdirSync(dir, { withFileTypes: true })) { |
| 56 | const path = join(dir, entry.name); |
| 57 | if (entry.isDirectory()) { |
| 58 | if (entry.name !== "__tests__") visit(path); |
| 59 | } else if (/\.(?:ts|tsx)$/.test(entry.name) && !/\.test\.(?:ts|tsx)$/.test(entry.name)) { |
| 60 | files.push(path); |
| 61 | } |
| 62 | } |
| 63 | }; |
| 64 | visit(root); |
| 65 | return files.sort(); |
| 66 | } |
| 67 | |
| 68 | let failures = 0; |
| 69 | for (const file of sourceFiles(SOURCE_ROOT)) { |
| 70 | const relative = file.slice(SOURCE_ROOT.length + 1).replaceAll("\\", "/"); |
| 71 | const lines = readFileSync(file, "utf8").split("\n"); |
| 72 | lines.forEach((line, index) => { |
| 73 | if (TRANSCRIPT_SURFACE_RE.test(relative) && IMPERATIVE_SCROLL_RE.test(line) && !ALLOWED_WRITERS.has(relative)) { |
| 74 | failures += 1; |
| 75 | console.error( |
| 76 | `check-single-scroll-writer: ${relative}:${index + 1} issues an imperative Transcript scroll call outside the writer.\n` + |
| 77 | ` ${line.trim()}\n` + |
| 78 | " Route the write through ChatScrollController and TranscriptViewportWriter.", |
| 79 | ); |
| 80 | } |
| 81 | if (RAW_SCROLLTOP_WRITE_RE.test(line) && !ALLOWED_RAW_SCROLLTOP.has(relative)) { |
| 82 | failures += 1; |
| 83 | console.error( |
| 84 | `check-single-scroll-writer: ${relative}:${index + 1} writes scrollTop outside an explicitly non-Transcript surface.\n` + |
| 85 | ` ${line.trim()}\n` + |
| 86 | " Transcript writes must route through transcriptViewportWriter.ts.", |
| 87 | ); |
| 88 | } |
| 89 | }); |
| 90 | } |
| 91 | |
| 92 | if (failures > 0) { |
| 93 | console.error(`check-single-scroll-writer: ${failures} violation(s) found.`); |
| 94 | process.exit(1); |
| 95 | } |
| 96 | console.log("check-single-scroll-writer: OK"); |
| 97 |