diff --git a/apps/desktop/CLAUDE.md b/apps/desktop/CLAUDE.md index 926f9fc6..74ea067c 100644 --- a/apps/desktop/CLAUDE.md +++ b/apps/desktop/CLAUDE.md @@ -11,6 +11,38 @@ For cross-cutting rules (git workflow, subagent dispatch, Bash discipline), see --- +## Electron is ONE backend, not the design + +Peek targets other window backends — `apps/tauri-desktop/` already exists, and more are +intended. **Electron is an implementation of the window port, never the shape of the thing +above it.** Anything above that port stays neutral about who implements it: + +- **No platform method names, no SDK argument names.** `skipTransformProcessType`, + `visibleOnFullScreen`, `setVisibleOnAllSpaces`, `moveTop`, `LSUIElement` — these are Electron + and AppKit vocabulary and they belong in `main/electron-window-backend.ts`, not in + `main/window-kernel.ts`, `main/window-backend.ts`, or anything that reads them. +- **No platform's workaround shape.** Where macOS needs an ordered pair of native calls to + achieve one intention, that ordering is the Electron backend's business. The machine emits + ONE effect for ONE intention and never a platform's steps. A sequence that only makes sense + on one OS is not a sequence the core gets to know about. +- **No assuming a concept exists everywhere.** macOS Spaces, native fullscreen, key-vs-main + windows, activation policy — a backend on a platform without the concept answers with + nothing, and that is a correct answer rather than a special case for callers to carry. +- **Apple's words are not our words.** "Space" is Apple's name for a virtual desktop; the port + and the machine describe the intention (does this window follow the user across desktops, or + belong to the one it was opened on) rather than naming one vendor's feature. + +**The two tests, both cheap to apply:** could a second backend implement this signature without +one of its arguments being meaningless to it, and does a platform lacking the concept get to +no-op without the caller special-casing it. If either answer is no, the detail is in the wrong +layer. + +**Enforced, not just written:** `scripts/check-platform-leak.mjs` greps the layers above the +port for Electron/AppKit vocabulary and fails on a new one. Full rule and its rationale: +`docs/design/window-manager.md` → "0. The governing rule set". + +--- + ## Testing > **ALWAYS ASK BEFORE RUNNING THE FULL GATE.** Never launch `yarn test:desktop:electron` diff --git a/scripts/check-platform-leak.mjs b/scripts/check-platform-leak.mjs new file mode 100644 index 00000000..bff38646 --- /dev/null +++ b/scripts/check-platform-leak.mjs @@ -0,0 +1,196 @@ +#!/usr/bin/env node +/** + * Platform-leak check — docs/design/window-manager.md §0, "The rules and the + * machine are platform-neutral; every platform workaround lives behind the + * port." + * + * Electron on macOS is ONE backend. `apps/tauri-desktop/` already exists and + * more are intended, so nothing above the window port may name a platform's + * method, carry its argument names, or encode the shape of its workarounds. + * The machine says WHAT it intends; a backend decides which native calls + * achieve it there, including how many. A leak is a build failure, not a + * review comment. + * + * WHY MECHANICAL. This boundary is the kind that erodes one convenient + * argument at a time, and each individual leak looks harmless in review — an + * option threaded through "just for now", a vendor's word reused because it + * was the word to hand. The cost only shows up at the second backend, when + * every borrowed name has to be implemented or ignored. A grep that runs every + * loop is the only form of this rule that survives. + * + * Mechanics, deliberately mechanical: + * - Scans the layers ABOVE the port (SCANNED below), not the whole tree. + * The backends and the composition root are exempt by construction: that + * is where platform detail is SUPPOSED to live. + * - Reuses `check-r2-window-ops.mjs`'s `stripNonCode`, so prose in comments + * and text in string literals never counts. Explaining a macOS hazard in a + * comment is correct and stays legal; typing its argument name in code + * does not. + * - Matches whole-word identifiers only, so a longer name that merely + * contains a banned word is not a hit. + * + * The allowlist only ever SHRINKS, same contract as the R2 check: + * - a hit not covered by an entry -> FAIL (new leak) + * - an entry whose (file, token) matches 0x -> FAIL (stale — delete it) + * - actual count != recorded count -> FAIL (growth is a leak; + * shrinkage must be recorded so the list provably shrinks) + * + * Usage: node scripts/check-platform-leak.mjs (from repo root) + * Exit 0 = clean, 1 = leaks/stale entries. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { stripNonCode } from './lib/strip-non-code.mjs'; + +const REPO_ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const MAIN = 'apps/desktop/main'; + +/** + * The layers above the port. Everything here must read the same whichever + * backend is underneath it. + * + * NOT scanned, and deliberately so: `electron-window-backend.ts` (the Electron + * backend — platform detail is its whole job), `window-os-adapter.ts` (the old + * executor, which composes the Electron backend by default and is being + * deleted), and every module that legitimately speaks to Electron directly + * (`ipc.ts`, `main.ts`, `windows.ts`, the renderer, …). This check is about + * the machine and its port, not about the app. + */ +const SCANNED = [ + `${MAIN}/window-kernel.ts`, + `${MAIN}/window-kernel-executor.ts`, + `${MAIN}/window-kernel-singleton.ts`, + `${MAIN}/window-kernel-shadow.ts`, + `${MAIN}/window-backend.ts`, + `${MAIN}/window-escape.ts`, + `${MAIN}/izui-roles.ts`, +]; + +/** + * Vendor vocabulary that must not appear in code above the port. + * + * Each is an Electron API name, an Electron argument name, or Apple's word for + * a concept the port should describe by intention instead. `Space`/`Spaces` is + * Apple's name for a virtual desktop; the neutral question is whether a window + * follows the user across desktops or belongs to the one it was opened on. + */ +const FORBIDDEN = [ + // Electron / AppKit type and API names + 'BrowserWindow', + 'BaseWindow', + 'WebContentsView', + 'webContents', + 'ipcMain', + 'ipcRenderer', + 'nativeTheme', + 'globalShortcut', + 'moveTop', + 'setVisibleOnAllWorkspaces', + 'setAlwaysOnTop', + 'setFullScreen', + 'showInactive', + 'orderFront', + 'NSPanel', + 'NSWorkspace', + 'LSUIElement', + 'performKeyEquivalent', + // Electron argument names that only mean something to Electron + 'skipTransformProcessType', + 'visibleOnFullScreen', + 'transformProcessType', + // Apple's word for a virtual desktop + 'Space', + 'Spaces', +]; + +/** + * Recorded leaks, with the reason each is still here. Entries are keyed + * file + token + count — symbol-anchored justifications, never line numbers, + * which rot. This list only ever shrinks. + */ +const ALLOWLIST = [ + // (empty — every entry added here needs a justification and a plan) +]; + +function scanFile(rel) { + const abs = path.join(REPO_ROOT, rel); + let src; + try { + src = fs.readFileSync(abs, 'utf8'); + } catch { + return { missing: true, hits: [] }; + } + const code = stripNonCode(src); + const hits = []; + for (const token of FORBIDDEN) { + // Whole-word only: a longer identifier that merely contains the token is + // not a leak (`spacesPolicyForRole` is not `Spaces`). + const re = new RegExp(`\\b${token}\\b`, 'g'); + const count = (code.match(re) ?? []).length; + if (count > 0) hits.push({ token, count }); + } + return { missing: false, hits }; +} + +function main() { + const failures = []; + const seen = new Map(); // "file\0token" -> count + let scanned = 0; + + for (const rel of SCANNED) { + const { missing, hits } = scanFile(rel); + if (missing) { + // A scanned file that no longer exists is a rename the list did not + // follow — silently scanning nothing is exactly how this check would + // rot into decoration. + failures.push(`scanned file is missing (renamed or deleted?): ${rel}`); + continue; + } + scanned += 1; + for (const { token, count } of hits) seen.set(`${rel}\0${token}`, count); + } + + // Every hit must be covered by an allowlist entry with a matching count. + for (const [key, count] of seen) { + const [file, token] = key.split('\0'); + const entry = ALLOWLIST.find((e) => e.file === file && e.token === token); + if (!entry) { + failures.push( + `platform leak: ${file} uses \`${token}\` (${count}x) above the port. ` + + `Move it into the backend, or describe the intention instead. ` + + `See apps/desktop/CLAUDE.md -> "Electron is ONE backend, not the design".`, + ); + continue; + } + if (entry.count !== count) { + failures.push( + `count changed: ${file} \`${token}\` recorded ${entry.count}x, found ${count}x. ` + + `Growth is a new leak; a shrink must be recorded so the list provably shrinks.`, + ); + } + } + + // Stale entries: recorded but no longer present. + for (const entry of ALLOWLIST) { + if (!seen.has(`${entry.file}\0${entry.token}`)) { + failures.push(`stale allowlist entry (matches nothing — delete it): ${entry.file} \`${entry.token}\``); + } + } + + if (failures.length > 0) { + console.error('✖ platform-leak check failed:\n'); + for (const f of failures) console.error(` ${f}`); + console.error( + `\n ${SCANNED.length} files above the port scanned. The rule: ` + + `docs/design/window-manager.md -> "0. The governing rule set".`, + ); + process.exit(1); + } + + console.log(`✔ platform-leak: ${scanned} files above the port scanned; no vendor vocabulary`); + console.log(` allowlisted leaks: ${ALLOWLIST.length} (should trend to zero)`); +} + +main(); diff --git a/scripts/check-r2-window-ops.mjs b/scripts/check-r2-window-ops.mjs index 1edd16ec..f3646b0e 100644 --- a/scripts/check-r2-window-ops.mjs +++ b/scripts/check-r2-window-ops.mjs @@ -48,6 +48,10 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +// The comment/string stripper lives in its own side-effect-free module so +// `check-platform-leak.mjs` can share it. Importing it from THIS file would +// run this whole check as an invisible side effect of that import. +import { stripNonCode } from './lib/strip-non-code.mjs'; // Anchor to the repo root this script lives in (scripts/..), not the cwd — // the check must scan the checkout it is part of no matter where it's run from. @@ -273,96 +277,6 @@ function collectFiles(dir) { return out; } -/** - * Can a `/` at this point start a regex literal (vs. be a division operator)? - * Heuristic: after an operator / opening bracket / separator (or at file - * start) a regex can start; after an identifier char, `)` or `]` it is - * division. Known residual blind spot: `return /re/` (prev char is a letter) - * reads as division, leaving the regex body unstripped. - */ -function regexCanStart(prevSig) { - if (prevSig === '') return true; - return '([{=,:;!&|?+-*%^~<>'.includes(prevSig); -} - -/** - * Blank out comments, string/template contents and regex-literal contents, - * preserving offsets (replaced chars become spaces) and code inside `${...}` - * interpolations. - */ -function stripNonCode(src) { - const n = src.length; - const out = src.split(''); - let state = 'code'; - const braceDepths = []; // one depth counter per nested template interpolation - let prevSig = ''; // last significant (non-whitespace) code char, for the regex-vs-division call - let inClass = false; // inside a regex [...] character class - for (let i = 0; i < n; i++) { - const c = src[i]; - const d = i + 1 < n ? src[i + 1] : ''; - switch (state) { - case 'code': - if (c === '/' && d === '/') { state = 'line'; out[i] = ' '; } - else if (c === '/' && d === '*') { state = 'block'; out[i] = ' '; } - else if (c === '/' && regexCanStart(prevSig)) { state = 'regex'; inClass = false; out[i] = ' '; } - else if (c === "'") { state = 'single'; out[i] = ' '; } - else if (c === '"') { state = 'double'; out[i] = ' '; } - else if (c === '`') { state = 'template'; out[i] = ' '; } - else if (c === '{' && braceDepths.length) { braceDepths[braceDepths.length - 1]++; } - else if (c === '}' && braceDepths.length) { - if (braceDepths[braceDepths.length - 1] === 0) { - braceDepths.pop(); - state = 'template'; - out[i] = ' '; - } else { - braceDepths[braceDepths.length - 1]--; - } - } - if (state === 'code' && !/\s/.test(c)) prevSig = c; - break; - case 'regex': - if (c === '\\') { out[i] = ' '; if (d) { out[i + 1] = ' '; i++; } } - else if (c === '[') { inClass = true; out[i] = ' '; } - else if (c === ']') { inClass = false; out[i] = ' '; } - else if (c === '/' && !inClass) { state = 'code'; out[i] = ' '; prevSig = ')'; /* regex value ~ operand */ } - else if (c === '\n') { state = 'code'; /* not a regex after all (unterminated) — resync */ } - else out[i] = ' '; - break; - case 'line': - if (c === '\n') state = 'code'; - else out[i] = ' '; - break; - case 'block': - if (c === '*' && d === '/') { state = 'code'; out[i] = ' '; out[i + 1] = ' '; i++; } - else if (c !== '\n') out[i] = ' '; - break; - case 'single': - if (c === '\\') { out[i] = ' '; if (d) { out[i + 1] = ' '; i++; } } - else if (c === "'") { state = 'code'; out[i] = ' '; } - else if (c !== '\n') out[i] = ' '; - break; - case 'double': - if (c === '\\') { out[i] = ' '; if (d) { out[i + 1] = ' '; i++; } } - else if (c === '"') { state = 'code'; out[i] = ' '; } - else if (c !== '\n') out[i] = ' '; - break; - case 'template': - if (c === '\\') { out[i] = ' '; if (d) { out[i + 1] = ' '; i++; } } - else if (c === '$' && d === '{') { - braceDepths.push(0); - state = 'code'; - out[i] = ' '; - out[i + 1] = ' '; - i++; - } - else if (c === '`') { state = 'code'; out[i] = ' '; } - else if (c !== '\n') out[i] = ' '; - break; - } - } - return out.join(''); -} - // Longest-first so `showInactive` never half-matches as `show`. const namesAlt = [...FORBIDDEN].sort((a, b) => b.length - a.length).join('|'); const CALL_RE = new RegExp(`\\.\\s*(${namesAlt})\\s*\\(`, 'g'); diff --git a/scripts/lib/strip-non-code.mjs b/scripts/lib/strip-non-code.mjs new file mode 100644 index 00000000..47f3f68e --- /dev/null +++ b/scripts/lib/strip-non-code.mjs @@ -0,0 +1,109 @@ +/** + * strip-non-code.mjs — blank out comments, string/template contents and + * regex-literal contents from JS/TS source, so a mechanical check greps CODE + * and never prose. + * + * Shared by `scripts/check-r2-window-ops.mjs` and + * `scripts/check-platform-leak.mjs`. It lives in its own module with NO + * side effects at import time, because both callers are scripts that run their + * check on load — importing one from the other would run that check as an + * invisible side effect and make either script's exit code ambiguous. + * + * Both checks depend on the same distinction: explaining a platform hazard in + * a comment is correct and must stay legal, while writing its call or argument + * name in code is the violation. A drifting second copy of this stripper would + * silently change what either check can see, so there is exactly one. + * + * Not a JS lexer, and deliberately so — it preserves offsets (replaced + * characters become spaces) and keeps code inside `${...}` interpolations. + */ + +/** + * Can a `/` at this point start a regex literal (vs. be a division operator)? + * Heuristic: after an operator / opening bracket / separator (or at file + * start) a regex can start; after an identifier char, `)` or `]` it is + * division. Known residual blind spot: `return /re/` (prev char is a letter) + * reads as division, leaving the regex body unstripped. + */ +function regexCanStart(prevSig) { + if (prevSig === '') return true; + return '([{=,:;!&|?+-*%^~<>'.includes(prevSig); +} + +/** + * Blank out comments, string/template contents and regex-literal contents, + * preserving offsets (replaced chars become spaces) and code inside `${...}` + * interpolations. + */ +export function stripNonCode(src) { + const n = src.length; + const out = src.split(''); + let state = 'code'; + const braceDepths = []; // one depth counter per nested template interpolation + let prevSig = ''; // last significant (non-whitespace) code char, for the regex-vs-division call + let inClass = false; // inside a regex [...] character class + for (let i = 0; i < n; i++) { + const c = src[i]; + const d = i + 1 < n ? src[i + 1] : ''; + switch (state) { + case 'code': + if (c === '/' && d === '/') { state = 'line'; out[i] = ' '; } + else if (c === '/' && d === '*') { state = 'block'; out[i] = ' '; } + else if (c === '/' && regexCanStart(prevSig)) { state = 'regex'; inClass = false; out[i] = ' '; } + else if (c === "'") { state = 'single'; out[i] = ' '; } + else if (c === '"') { state = 'double'; out[i] = ' '; } + else if (c === '`') { state = 'template'; out[i] = ' '; } + else if (c === '{' && braceDepths.length) { braceDepths[braceDepths.length - 1]++; } + else if (c === '}' && braceDepths.length) { + if (braceDepths[braceDepths.length - 1] === 0) { + braceDepths.pop(); + state = 'template'; + out[i] = ' '; + } else { + braceDepths[braceDepths.length - 1]--; + } + } + if (state === 'code' && !/\s/.test(c)) prevSig = c; + break; + case 'regex': + if (c === '\\') { out[i] = ' '; if (d) { out[i + 1] = ' '; i++; } } + else if (c === '[') { inClass = true; out[i] = ' '; } + else if (c === ']') { inClass = false; out[i] = ' '; } + else if (c === '/' && !inClass) { state = 'code'; out[i] = ' '; prevSig = ')'; /* regex value ~ operand */ } + else if (c === '\n') { state = 'code'; /* not a regex after all (unterminated) — resync */ } + else out[i] = ' '; + break; + case 'line': + if (c === '\n') state = 'code'; + else out[i] = ' '; + break; + case 'block': + if (c === '*' && d === '/') { state = 'code'; out[i] = ' '; out[i + 1] = ' '; i++; } + else if (c !== '\n') out[i] = ' '; + break; + case 'single': + if (c === '\\') { out[i] = ' '; if (d) { out[i + 1] = ' '; i++; } } + else if (c === "'") { state = 'code'; out[i] = ' '; } + else if (c !== '\n') out[i] = ' '; + break; + case 'double': + if (c === '\\') { out[i] = ' '; if (d) { out[i + 1] = ' '; i++; } } + else if (c === '"') { state = 'code'; out[i] = ' '; } + else if (c !== '\n') out[i] = ' '; + break; + case 'template': + if (c === '\\') { out[i] = ' '; if (d) { out[i + 1] = ' '; i++; } } + else if (c === '$' && d === '{') { + braceDepths.push(0); + state = 'code'; + out[i] = ' '; + out[i + 1] = ' '; + i++; + } + else if (c === '`') { state = 'code'; out[i] = ' '; } + else if (c !== '\n') out[i] = ' '; + break; + } + } + return out.join(''); +}