返回 DeepSeek-Reasonix
check-single-scroll-writer.mjs
根目录 / desktop / frontend / scripts / check-single-scroll-writer.mjs
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
97 lines Plain Text