diff --git a/.claude/skills/code-structure/SKILL.md b/.claude/skills/code-structure/SKILL.md
index 70a6c868..db1d5c53 100644
--- a/.claude/skills/code-structure/SKILL.md
+++ b/.claude/skills/code-structure/SKILL.md
@@ -11,7 +11,7 @@ The rules live in `CLAUDE.md › Code structure`; `npm run lint:structure` (`scr
1. Find where the code belongs by layer and concern: main process (`src/main//`), shared contract (`src/shared/`), renderer slice (`app → pages → widgets → features → entities → shared`, see `docs/DESIGN_SYSTEM.md`).
2. Open the target folder. If the file you'd edit is near 150 lines, or your change adds a second concern to it, split first (below), then add your code in its own file.
-3. Name the new file after the one thing it holds: `sinceInput.ts`, `useDragCursor.ts`, `ResourceTracker.ts`, `EntryRow.tsx`.
+3. Name the new file after the one thing it holds: `sinceInput.ts`, `useDragCursor.ts`, `ResourceTracker.ts`, `EntryRow.tsx`. Never a name a sibling has in another case (`actionFields.ts` beside `ActionFields.tsx`): macOS and Windows ignore case, so an import can load the wrong one there. Say what sets it apart instead (`ruleActionFields.ts`).
## How to split
diff --git a/CLAUDE.md b/CLAUDE.md
index 282e8f51..57cf13cb 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -9,7 +9,7 @@ Before pushing:
```bash
npm run typecheck
npm run lint:fsd
-npm run lint:structure # the Code structure rules below: thin files, one function each, no switch
+npm run lint:structure # the Code structure rules below: thin files, one function each, no switch, no case clashes
npm run lint # oxlint, type-aware: React's rules (hooks, refs, purity) and misused promises
npm run lint:unused # knip: no unused file, dependency or export (knip.jsonc says what counts)
npm run lint:duplicates # jscpd: no copy of code that isn't in .jscpd-baseline.json
@@ -56,6 +56,7 @@ Agents: when the environment assigns a generated branch (`claude/…`) and says
- **No magic values.** Name every literal that drives logic: ids, keys and prefixes, the app's own channel and event names (IPC channels, `AppEvent` types), durations (a step of `DURATION` in `shared/lib/motion.ts`), sizes used in more than one place, and any value that has to match another system (Monaco command ids, `execCommand` names, `KeyboardEvent.key`, env vars). The platform's own event names (`'keydown'`, `'did-navigate'`), which its typed listeners check, stay inline. A constant goes at the top of the file that uses it, or in the folder's `constants.ts` when several files do; app-wide ones go in `shared/config` (renderer) or `src/shared/constants.ts` (both processes). Display copy, Tailwind classes and identity values (`0`, `1`, `''`, `true`) stay inline.
- **No copies.** `npm run lint:duplicates` refuses code copied from elsewhere in `src` or `scripts` (jscpd, 50 tokens or more): move it to a function both use. `.jscpd-baseline.json` lists the copies there were when the check came in; it only shrinks. After removing one, run `npm run lint:duplicates -- --update-baseline` and commit the smaller file; never add a copy to it to get a commit through.
- **No `switch`.** Dispatch through a typed table (`Record`, or a mapped type when each handler takes its own member), so a new union member fails typecheck until it's handled (see `app/model/bridge/`). An if/else chain or nested ternary over one value counts as a switch.
+- **No names that differ only in case** in one folder of `src`, `scripts` or `test` (`ActionFields.tsx` beside `actionFields.ts`, or a folder `foo/` beside `Foo.ts`). macOS and Windows ignore case, so `import './ActionFields'` can load the other file there, which no Linux build shows. Name the helper after what it holds (`ruleActionFields.ts`). Files imported with their extension (`Markdown.tsx` beside `markdown.css`) are fine.
## Commits and pull requests
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index fcb6de5b..780c9fdf 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -29,7 +29,7 @@ npm run demo-site # in another terminal: pages to try it on (port 5174)
```bash
npm run typecheck
npm run lint:fsd
-npm run lint:structure # files of at most 150 lines, one function each, no switch (CLAUDE.md › Code structure)
+npm run lint:structure # files of at most 150 lines, one function each, no switch, no names differing only in case (CLAUDE.md › Code structure)
npm run lint # oxlint with type information: React's rules and misused promises (.oxlintrc.json)
npm run lint:unused # no unused files, dependencies or exports (knip; types in a slice's index.ts are its public API)
npm run lint:duplicates # no new copies of code (jscpd; .jscpd-baseline.json lists the older ones, and only shrinks)
diff --git a/docs/SPEC.md b/docs/SPEC.md
index 2d25523e..a0414c32 100644
--- a/docs/SPEC.md
+++ b/docs/SPEC.md
@@ -736,7 +736,7 @@ The site's `WebContentsView` can move to a window of its own (`PageWindow`), e.g
| Unit | Matchers, header/SRI/source-map transforms, stores (persistence, atomic concurrent writes), engine logic and navigation rules with a fake CDP transport, iframe session coordination (timeouts, sessions that go away, cascading detach), worker sessions (setup order and resuming, `Fetch` kept on, settings per worker type, which session lists and credits a worker's files, missed-override reasons, outdated service workers and `prepareReload` (unregistering by scope, letting go, retrying), a service worker's state kept for its next session, shared-worker discovery and holding), minified heuristic, version comparison, CHANGELOG parsing (and that CHANGELOG.md covers `package.json`'s version), the update service with fakes (checks, quiet failures, progress, checksums, install failures, schedule), the Linux desktop entry (quoting paths, which copies install one, a package's entry winning, removing only its own, following a renamed AppImage, writing only what changed) and the `.rpm`'s install script kept in step with electron-builder's, workspaces in the stores (migration, per-workspace tabs, drafts, overrides and actions, deletion, frame names), the action store (each workspace's actions across instances, what it refuses and why, a failed write changing nothing, changes run in order), favicon loading (recognising images, size caps), the console service with a fake CDP transport (frames across sessions, contexts, rows and their previews, format directives, running code, properties, the cap, clearing, the setting), the session observer (handed every session before it runs, never holding interception up) and where the website's and the Actions panel's windows open (its saved place while it is on a screen, another screen than the editor's, fitted to a small one, at the size each opens at) and how that is saved (Keep on top too), rules (validation, the store's persistence, entries it can't read, broken and unreadable files, the engine's stages, header edits, CORS and preflights, request types, patterns), source maps (finding the reference, loading limits, header capture), response overrides that don't send (`unsentOverrides.test.ts`: patterns, the answer and its CORS, the preflight, what goes on to the server, the delay, the setting kept) and breakpoints (`breakpoints.test.ts`: validation and storage, patterns, holding at each stage and each action, what never stops, letting go), the JSON tree (`jsonTree.test.ts`: parsing without loss, offsets, what it refuses, diffing and applying edits, giving up on a document they don't fit), patch mode (`patchMode.test.ts`: the live response patched, edits worked out once per version, the saved text when upstream fails or the edits don't fit, never a stream, the setting kept), HAR files (`har.test.ts`: entries written, sockets' messages, overrides read back and what is left out), the network speed setting (kept, checked) and its commands on every kind of session, the page stack (the React hook stand-in in every session, with `Page` on first, taken out and put back with the setting; a frame looked at once it loads, silently in its main world, once for two loads in quick succession, not after it navigated, and not kept from the document it left; a cross-site iframe's own session, dropped with it; nothing while the console doesn't record, every frame once it does; the console unaffected when the stand-in can't go in; what the page answers checked: unknown ids and signals, builds, versions, at most 40); the component inspector with a fake CDP transport (`componentInspector.test.ts`: every session into inspect mode and out, one that attaches while picking too; what is under the pointer read at most every interval, the latest node winning; a click stopping picking, keeping the node as `$0` and sending its component with each function placed; a node's frame told by its frame owner; the chain's other components; Esc in the page; the last 20 picks kept and older handles freed; highlighting, with the Overlay domain off once picking and highlights are over; a node that can't be read; a production Angular page's view registry found and handed to the adapter, and taken from the page the next time; the element's own listeners, named and placed; what the page says checked: frameworks, builds, state kinds, labels, list caps); scripts' source maps counted per frame (inline, isolated-world, evaluated and other frames' scripts left out); renders, the tree and state (`componentRenders.test.ts`: the binding in every session while recording, one attaching too, removed and its function deleted when it stops; batches sent in order, checked, framed by their context, their functions located once per document; other bindings, bad payloads and anything after stopping ignored; the page's commits checked: kinds, reasons, labels, caps; a tree level read in the frame's main world, a node opened as a pick at its depth and highlighted, a gone frame or bad path refused; a state edit taken only as a known kind, a name and JSON); source maps loaded from files (`sourceMapFileStore.test.ts`: a copy kept per workspace and bundle and read back after a restart, replaced, forgotten, removed with its workspace, its copies deleted; index entries that don't hold up, a copy named outside the folder among them, left out; a file's map handed over as found against the bundle as served); store actions (`storeTimeline.test.ts`: the hooks' one script, which parses; the binding in every session while recording, each frame asked to find its Vue stores, taken out when it stops; batches checked, numbered and framed, others' and late ones ignored, a flood of them capped; known libraries, labels, stacks in loaded files and caps), the events sent to a window as JSON (`appEventWire.test.ts`), the main process's lookups bounded (`inspectorLookups.test.ts`: only the scripts alive kept, whether each names a map, long URLs left out; at most 1,000 functions placed a read, one gone leaving the others; initiators' URLs and names), commits naming their store action and components their own render time, a request's initiator through its async parents in loaded files (`networkLog.test.ts`) | `npm test` |
| Renderer | Resource tree building and filtering (worker entries: kept across navigations for service and shared workers, dropped with their worker, filtered by worker URL), command-palette fuzzy matching, missed-override notices by reason (a nested worker's once per override version), update notifications, What's New and page tabs, session sync and workspace switching (pending drafts written, tabs closed without losing them, the other workspace's reopened), console frames (keys, labels, colours), rows, filters, Keep rows, prompt history and frame names, rules (quick rules and Undo, toggling with rollback, rule pages and their forms, applying only what changed, results that arrive after a switch, hits and recent requests), original sources (parsing, positions through pretty-printing, tabs, jumps both ways, the tree, the palette), actions (finding an action's frame by key or by name, labels of frames not on the page, runs and why one couldn't run, the form's openings, saving and deleting, copies, the palette's group, the bridge loading and following them), the Actions window's bridge (what it loads, the events it follows and those it ignores) and the editor's copy of where the panel is and of settings another window changed, the Network panel (rows kept and replaced, earlier loads dropped unless kept, groups and the text filter, status, size and time cells, which actions a request offers) and response overrides (the inferred schema, which override answers a request, opening a response and saving it with its method, operation and answer, the response row's fields and Apply), the bottom pane's tab, held requests (`held.test.ts`: their tabs, what Send does at each stage and with an edited body, fields that can't go out, Send original, Fail, Save as override, closing the tab, the page giving up, never kept in the session), breakpoints (added, toggled, removed, refused, put back when saving fails, Pause like this) and Copy as fetch, quick edits (`quickEdits.test.ts`: lists emptied, texts lengthened but links, ids and dates, the value at the cursor nulled, the rest of the text as typed) and Patch live through the response row's form, the response tree (`responseTree.test.ts`: rows for what is open with unique ids, members removed with their comma, added as the last one is written, the selection after a rename or removal), the page stack (UI libraries named once, the top page's first; findings by kind; one Page stack tab; the bridge's snapshot and `stack-changed`), the component inspector (`componentInspector.test.ts`: a function's place in the bundle mapped to its original, named by the map or read off the original's text in each way code names a function; a React component named after its original, a Vue one by itself; picking toggled as main last said; another component of the chain read, or a toast when the page moved on; the bridge following picking and showing what was picked, in one Component tab renamed in place); hook names read off an original (`hookNames.test.ts`: hook calls laid out as React keeps them, same-file custom hooks followed, an imported one stopping the naming, arrows placed by their parameters); renders, the tree and state (`componentRenders.test.ts`: why a component took part, in words, hooks named; triggers; what a frame's last commit rendered; the log's cap; the bridge following recording and tracing functions once each; tree rows as far as open; a frame's top level, a node opened once and closed, a stale frame's level dropped, a pick revealed down its path; opening a node, and a toast when it can't; setting a value, and a toast when it can't); maps loaded from files (`mapFiles.test.ts`: location keys read back; a bundle's traced places forgotten and traced again once a map is loaded or forgotten, every original and hook name forgotten with the maps and a lookup finishing after dropped, nothing when no file was picked, toasts on failures; the Explorer's menu items); store actions (`storeTimeline.test.ts`: recording followed, actions kept and capped, their calls traced once each, a failure to start said; the app's own call in a stack, past libraries); requests by component (`requestsByComponent.test.ts`: a frame's requests sent through a file, newest first, with the call); a commit's trigger with its action, a set value kept and the action written from it per framework (a hook's place, a class's key, selectors through shadow roots, none without a way in), renders by component (counts, own time summed, reasons, costliest first; kept as commits come and go the same as summed afresh; the log's rows) (`componentRenders.test.ts`) | `npm test` |
| Performance | The inspector on large apps in real Chromium (`test/perf/inspector.perf.test.ts`): a React and a Vue 3 app of some 14,000 components (a 2,000-row table, a chain 1,500 deep in React, 300 in Vue) and stores of 20,000 items (Redux Toolkit, Pinia), as production builds. It times a commit of every row with the hooks off, idle and recording renders, and after picking; hovering and picking deep in the table and at the end of the chain; the page stack; a level of the tree; a batch's size; Redux dispatches and Pinia actions idle, recording and after it stopped. And the built app's UI (`test/perf/ui.perf.test.ts`) fed what large apps record at the pace recording sends it (2,000 commits of 200 components, 1,000 store actions with 50-call stacks, 2,000 requests with 20-call initiators, their places traced through a real bundle's map): how long the window's main thread is blocked, its longest task, the memory the log takes, and how long **By component** takes to show. Each prints its medians and fails past a budget set well above what a laptop measures, so only work growing with the whole page on a hot path trips it; it isn't part of CI | `npm run test:perf` (on a display: `xvfb-run -a` on headless Linux) |
-| Architecture | Feature-Sliced Design layer rules; the code-structure rules (files of at most 150 lines, one function, component, class or store per file named after it, data-only constants/types/index files, no `switch`) | `npm run lint:fsd`, `npm run lint:structure` |
+| Architecture | Feature-Sliced Design layer rules; the code-structure rules (files of at most 150 lines, one function, component, class or store per file named after it, data-only constants/types/index files, no `switch`, no sibling names that differ only in case) | `npm run lint:fsd`, `npm run lint:structure` |
| Integration | Engine in real Chromium against the fixture site: gzip, static and runtime SRI, globs, CSS/HTML overrides, 404, redeploy detection, source maps, disable. Iframes through the session-aware WebSocket transport (`test/helpers/chromium.ts`): same-site, cross-site and nested iframes, SRI inside iframes, iframe HTML overrides, same-site navigation, removal, reload; each asserts the iframe really is a separate target. The console on a page of service iframes (same-site, and two on sites of their own): every frame's first log line on its own frame, code run in one frame and another frame's logs reacting, top-level `await` and expanding the result, uncaught errors and rejections, a frame keeping its id across a navigation, a cross-site frame's function refused from the top page but run in the frame itself (why actions run in their frame). Workers against the fixture's `/workers/` page (`workers.chromium.test.ts`, a fresh browser context per test): every kind runs while intercepted; edits reach a dedicated and a module worker's first script and imports, what a nested worker imports (its first script is served in Chromium 141, reported as missed in 152+), a shared worker, a service worker's script and imports, and an audio worklet's module; each file is listed with its worker and read through its session; a shared worker's races are forced by holding back its session's commands; workers under a cross-site iframe are served on its session and removed with it; workers are reported gone; an edit to an installed service worker applies on the next reload, and one whose page was left is reinstalled when its site loads again under other overrides (a workspace switch); the page's `registration.update()` is reported and the next reload undoes it; a service worker is listed again, and not reinstalled, when the page comes back to its site; the cache setting reaches what workers load. Rules (`rules.chromium.test.ts`): blocking before the server sees the request (a script, one inside a cross-site iframe, an iframe's document, a redirect's later hop), a URL both blocked and overridden, the page itself never blocked; a gzipped document's CSP removed and another's added, X-Frame-Options removed so a page can be framed, a document no rule changes never read, Cache-Control and Content-Type on streamed responses, headers back once a rule is off; CORS with credentials, a preflight the API refuses, redirects. Source maps named by headers, `X-SourceMap`, comments and `data:` URIs, a stylesheet's, and an override-served bundle's. The network log and response overrides (`network.chromium.test.ts`, the fixture's `/network/` page): what the log lists for fetch(), GraphQL, a failing call, an event stream and a worker's fetch, page loads counted, request and response bodies read, never a stream's (which keeps streaming); a response override answering fetch() and XHR with its body, status and header changes (the request still reaching the server) and marking the row, its delay, only its method and GraphQL operation, a dedicated worker's fetch, and an event stream left streaming or replaced as a whole. Held and unsent requests (`held.chromium.test.ts`): an override that doesn't send answering another origin's POST and its preflight with the server never hit (and, without it, the server turning the preflight away); breakpoints holding a response (its row marked) until answered edited or as it was, a request before it is sent (its row marked) sent elsewhere or answered, one failed, one the page gave up on let go, everything let go when interception stops, and nothing stopped once off. In `network.chromium.test.ts` too: patch mode keeping the live values it wasn't told to change and answering a failing upstream with the saved text; the page and its worker taken offline and slowed down; a WebSocket's handshake, text and binary messages read incrementally, and its close; the log written as HAR with its bodies and read back as overrides that answer without the server. The page stack (`stack.chromium.test.ts`): a React app the test bundles from the repo's react and react-dom, found through the hook stand-in with its version, as a production and a development build, and by its keys with **Framework hooks** off (and no hook left in the page); what Vue, Pinia, Angular, Next.js and webpack leave in a page; a cross-site iframe's React, which found the stand-in in place before it loaded; no console rows. The component inspector (`inspect.chromium.test.ts`), with real pointer input on carts in React and Vue the test bundles (`test/helpers/bundleApp.ts`), as minified production builds with source maps and as development builds: a production React component's chain, props, state, context and handler, each function traced through the map to its source line; a click the page never gets, and the element as `$0`; the chain's other components, and an element that is gone; a production Vue component through its app's vnode tree, with what the app provides, and a development build's from the element; picking inside a cross-site iframe on its own session; Esc in the page; a useState hook set and rendered, JSON and unsettable values refused; Vue's data and a setup ref set, a computed one refused; the Components tree of a React app through the hook stand-in and by its containers without it, and of a production Vue app (each function placed once however many of its components a level lists), nodes opened as picks at their depth, picks' paths; renders recorded on a React app whose buttons each commit for another reason (own state and a store, a context with a memo child skipped, a parent's state with changed props, a class's state), with triggers, frames, durations and order, nothing after stopping, and a production build's mount recorded from a document loaded while recording, its functions placed through the map. The page stack (`stack.chromium.test.ts`) also counts a frame's scripts that name a source map by comment or header. The other frameworks (`frameworks.chromium.test.ts`), each bundled by the test from the repo's own packages: a production Angular component read through its view registry (found once, then taken from the page without walking the heap again; inputs, a settable signal, a field that isn't, each class placed by the map; hosts named by their tags while hovering), a development build through `window.ng` and its tree, a production build's tree through the registry; a Vue 2 component's props, data (set), what the app provides, chain and tree; a Lit component in its shadow root, its properties and state (set), host chain and tree; a plain page's listeners with names, places and options; a Vue 3 listener's function, not Vue's invoker; store actions (`stores.chromium.test.ts`): Redux Toolkit's (the slice's type, payload, what changed, the handler's line through the map; the page stack names Redux), the React commit after one naming it and nothing once stopped, Zustand's through its devtools, Pinia's actions and direct changes (heard again once an action that never settles stops counting) and Vuex's mutations (found when recording starts and by a document loaded while recording), NgRx's through its StoreDevtools; a request keeping the stack of the handler that sent it; saved actions (`stateActions.chromium.test.ts`): a picked component's action written, the page loaded again, and the action setting the value in React (a hook), Vue 3 production and development (a ref and data), Vue 2 (data), Angular development (a signal; production gets none) and Lit (through two shadow roots) | `npm test` (skips if no Chromium; `npx playwright install chromium`) |
| End-to-end | Built Electron app driven by Playwright: open site, edit, save, page runs it, disable/enable, edit files inside a cross-site and a nested iframe, the fixture's `/workers/` page with its workers' files listed under their worker's badge and edited (imports of each kind of worker; a module worker's static import; the first script of a dedicated worker, a shared worker, a service worker and a worklet; a service worker's script edited again, turned off and on, and deleted; a worker under a cross-site iframe), the update check's toast with Bypass service workers off and its reload, what stays listed when the page leaves (its site's service worker) and when it leaves the site (nothing), each workspace running its own edit of the service worker's script (`workers.e2e.test.ts`), persistence across restart, a second launch handing over its URL, workspaces (a new one starts empty and doesn't serve another's overrides, takes its site's favicon, switching back restores the page, tabs and overrides with no history from the other, renaming, all of it across a restart), the console (each frame's rows, running code in a picked frame and seeing another react, filtering by frame, naming a frame and keeping the name across a restart, clearing), actions (`actions.e2e.test.ts`: code run in the console saved as an action for its frame, run with one click and another frame reacting, one for a frame picked from the page showing what it threw, run from the palette, changed and deleted, each workspace's own across a restart), the Actions panel in a window of its own (`actionsWindow.e2e.test.ts`: moved there, running and making actions from it, the workspace and the settings following it, Keep on top, reopened where it was after a restart, back by closing it, from the sidebar's notice and from the View menu), the website in a window of its own (`pageWindow.e2e.test.ts`: moved there from the preview's toolbar without reloading, the editor's saved edits served to it, navigating from its toolbar, back from its button, by closing it, from the title bar and from the View menu; the site's pop-ups moving with it; put back, or asked for its address bar, while its window still loads; where it was and that it was open kept across a restart). Rules: block a script from the file tree, turn it off and on, undo a quick rule, remove a page's CSP, allow CORS for an API with a preflight, keep an unapplied edit on its tab and apply it from File › Save, rules per workspace, delete, and blocking from the first load after a restart. Updates against a local feed: the automatic announcement, What's New with the release's notes, a download refused for its checksum and then accepted. The original sources behind a bundle: listed, opened read-only, jumping to the pretty-printed bundle line and back, found from the palette. The Network panel (`network.e2e.test.ts`): the page's fetch and GraphQL calls listed, Fetch/XHR first and the document under All; a response opened, edited, saved and served to the page, the row marked and the override in the Explorer with its method; another status applied from the response row; the override turned off; requests like the selected one paused and an edited response sent to the page, a paused response saved as an override that answers from then on, a breakpoint written in the menu holding a request before it is sent, and that request failed; the live response patched from the response row, a quick edit saved and served, the page taken offline and back from the network speed menu, a WebSocket's messages in its details, a response edited as a tree with the keyboard (a value retyped, a key renamed, a member removed) and saved, and the requests exported as HAR and imported back as overrides. The page stack (`stack.e2e.test.ts`): the status bar names the fixture's UI libraries and opens the Page stack, which lists each finding by kind with its version, build and evidence, and scans again. The component inspector (`inspect.e2e.test.ts`) on a production React cart framed from another site: the toolbar's button picks and the Inspect view shows what is under the pointer; a click opens the Component page, named `CartItem` and placed at its source line through the map, with props, state, context, the handler by its original name and the chain; **Open original** and **Go to bundle code** land on its definition; the View menu starts and stops picking, and Esc in the editor stops it; the Inspect view's tree lists the cart frame's components down to the one picked, selected; a state value named `qty` (from the original) is set from the Component page and the cart shows it; **Record renders** from the palette records a real click, the Renders tab says it was a click on `button#add-A1` and that `CartItem` rendered for `state qty 5 → 6`, and the Component page lists that render. A map from a file (`mapFile.e2e.test.ts`): a production React bundle that names no map, picked, given its map through **Load a source map…** (the system's dialog stubbed in the main process) and named `CartItem` at its line, then forgotten; a plain page's element shown with its listener and where it is defined; the inspected cart's renders by component, and its set value kept as an action that sets it again after a reload; a Redux Toolkit cart (`dataFlow.e2e.test.ts`): its actions recorded from the palette with what they changed and the line that dispatched them (and their stack), the commit after one naming it, and a request's **Sent by** stack traced to the handler | `npm run test:e2e` (on headless Linux: `xvfb-run npm run test:e2e`) |
| Packaged | The installed app (asar, fuses, signature) fixes the demo store's checkout through the UI, driven over `--remote-debugging-port` since the fuses disable Node's inspector. The release workflow runs it on six runners, one per architecture: macOS (from the disk image), Windows (after a silent install; the x64 runner also checks that the ARM installer refuses it) and Linux (from the installed `.deb`, with Ubuntu's user-namespace restriction left on, also checking that the desktop entry and icons are the package's and the app added none of its own) | `npm run test:packaged -- ` |
diff --git a/scripts/check-structure.ts b/scripts/check-structure.ts
index d35a418b..367d859f 100644
--- a/scripts/check-structure.ts
+++ b/scripts/check-structure.ts
@@ -2,7 +2,9 @@
* Checks the source (src/, scripts/; tests are exempt) against CLAUDE.md ›
* Code structure: files of at most 150 lines, one function, component, class
* or store per file named after it, data-only constants/types/index files, and
- * no `switch` (nor if/else chains or nested ternaries over one value).
+ * no `switch` (nor if/else chains or nested ternaries over one value). Also
+ * checks src/, scripts/ and test/ for names that differ only in case, which
+ * macOS and Windows take for one while Linux (and CI) tell them apart.
* Exits with 1 and lists what to fix when a file breaks a rule.
*
* npm run lint:structure
@@ -10,11 +12,12 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { checkFile } from './structure/checkFile.ts';
+import { findCaseClashes } from './structure/findCaseClashes.ts';
import { sourceFiles } from './structure/sourceFiles.ts';
const root = join(import.meta.dirname, '..');
const files = sourceFiles(root);
-const violations = files.flatMap((file) => checkFile(file, readFileSync(join(root, file), 'utf8')));
+const violations = [...files.flatMap((file) => checkFile(file, readFileSync(join(root, file), 'utf8'))), ...findCaseClashes(root)];
for (const v of violations) console.error(`${v.file}${v.line ? `:${v.line}` : ''} ${v.rule}: ${v.detail}`);
if (violations.length) {
diff --git a/scripts/structure/caseClashes.ts b/scripts/structure/caseClashes.ts
new file mode 100644
index 00000000..90987df4
--- /dev/null
+++ b/scripts/structure/caseClashes.ts
@@ -0,0 +1,27 @@
+import { lookupNames } from './lookupNames.ts';
+import type { FolderEntry } from './types.ts';
+
+/**
+ * The entries of one folder that a file system ignoring case (macOS, Windows) can't tell apart, in groups of names:
+ * two whose names differ only in case (`Foo.css`, `foo.css`), or two modules an import without an extension reaches
+ * by names that do (`./ActionFields` finds `actionFields.ts` there before `ActionFields.tsx`). `Markdown.tsx` beside
+ * `markdown.css` is fine: a stylesheet is imported by its whole name.
+ */
+export function caseClashes(entries: FolderEntry[]): string[][] {
+ // Each name a lookup can use, lower-cased → the entries it reaches, each with the spelling that reaches it.
+ const reached = new Map>();
+ for (const entry of entries) {
+ for (const spelling of lookupNames(entry)) {
+ const key = spelling.toLowerCase();
+ reached.set(key, (reached.get(key) ?? new Map()).set(entry.name, spelling));
+ }
+ }
+ // A lookup that reaches entries spelled alike is no clash (`Foo.ts` beside `Foo.tsx`); one pair can clash twice.
+ const groups = new Map();
+ for (const spellings of reached.values()) {
+ if (new Set(spellings.values()).size < 2) continue;
+ const names = [...spellings.keys()].sort();
+ groups.set(JSON.stringify(names), names);
+ }
+ return [...groups.values()];
+}
diff --git a/scripts/structure/constants.ts b/scripts/structure/constants.ts
index 517df9aa..9b86ddde 100644
--- a/scripts/structure/constants.ts
+++ b/scripts/structure/constants.ts
@@ -24,3 +24,12 @@ export const EQUALITY_OPERATORS = ['===', '=='];
/** A value compared with literals this many times in one if/else chain or nested ternary makes it a switch. */
export const SWITCH_LIKE_COMPARISONS = 2;
+
+/**
+ * Folders checked for names that macOS and Windows, whose file systems ignore case, can't tell apart. Tests too:
+ * they are built and run there.
+ */
+export const CASE_CHECKED_ROOTS = ['src', 'scripts', 'test'];
+
+/** What an import without an extension may find (Vite's `resolve.extensions`): `./Foo` reaches `foo.ts` there. */
+export const MODULE_EXTENSIONS = ['.mjs', '.js', '.mts', '.ts', '.jsx', '.tsx', '.json'];
diff --git a/scripts/structure/findCaseClashes.ts b/scripts/structure/findCaseClashes.ts
new file mode 100644
index 00000000..c427e5e9
--- /dev/null
+++ b/scripts/structure/findCaseClashes.ts
@@ -0,0 +1,20 @@
+import { readdirSync } from 'node:fs';
+import { join, relative } from 'node:path';
+import { caseClashes } from './caseClashes.ts';
+import { CASE_CHECKED_ROOTS } from './constants.ts';
+import type { Violation } from './types.ts';
+
+/** Every group of names under the checked folders that macOS and Windows can't tell apart, one violation each. */
+export function findCaseClashes(root: string): Violation[] {
+ const violations: Violation[] = [];
+ const visit = (dir: string) => {
+ const entries = readdirSync(dir, { withFileTypes: true });
+ for (const names of caseClashes(entries.map((entry) => ({ name: entry.name, directory: entry.isDirectory() })))) {
+ const detail = `${names.join(', ')}: macOS and Windows ignore case, so they take one for the other (an import may load the wrong one): rename one`;
+ violations.push({ file: relative(root, dir), rule: 'no case clashes', detail });
+ }
+ for (const entry of entries) if (entry.isDirectory()) visit(join(dir, entry.name));
+ };
+ for (const dir of CASE_CHECKED_ROOTS) visit(join(root, dir));
+ return violations;
+}
diff --git a/scripts/structure/lookupNames.ts b/scripts/structure/lookupNames.ts
new file mode 100644
index 00000000..e490a9f8
--- /dev/null
+++ b/scripts/structure/lookupNames.ts
@@ -0,0 +1,13 @@
+import { extname } from 'node:path';
+import { MODULE_EXTENSIONS } from './constants.ts';
+import type { FolderEntry } from './types.ts';
+
+/**
+ * The names a lookup can reach `entry` by: its own, and for a module file the one an import without the extension
+ * uses (`Foo.tsx` is also `Foo`). A folder is a module by its own name, through its index.
+ */
+export function lookupNames({ name, directory }: FolderEntry): string[] {
+ const extension = extname(name);
+ if (directory || !MODULE_EXTENSIONS.includes(extension)) return [name];
+ return [name, name.slice(0, -extension.length)];
+}
diff --git a/scripts/structure/types.ts b/scripts/structure/types.ts
index 7689a522..9fcfc58f 100644
--- a/scripts/structure/types.ts
+++ b/scripts/structure/types.ts
@@ -13,3 +13,9 @@ export interface TopLevelFunction {
name: string;
line: number;
}
+
+/** An entry of a folder, as the case check reads it. */
+export interface FolderEntry {
+ name: string;
+ directory: boolean;
+}
diff --git a/test/unit/checkStructure.test.ts b/test/unit/checkStructure.test.ts
index 81864675..ce973f9b 100644
--- a/test/unit/checkStructure.test.ts
+++ b/test/unit/checkStructure.test.ts
@@ -1,5 +1,10 @@
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { dirname, join } from 'node:path';
import { describe, expect, it } from 'vitest';
+import { caseClashes } from '../../scripts/structure/caseClashes';
import { checkFile } from '../../scripts/structure/checkFile';
+import { findCaseClashes } from '../../scripts/structure/findCaseClashes';
const rules = (file: string, text: string) => checkFile(file, text).map((v) => v.rule);
@@ -37,3 +42,52 @@ describe('code-structure check', () => {
expect(rules('src/a/f.ts', "export const f = (x: string, y: string) => (x === 'a' ? 1 : y === 'b' ? 2 : 3);\n")).toEqual([]);
});
});
+
+describe('case clash check', () => {
+ const files = (...names: string[]) => names.map((name) => ({ name, directory: false }));
+
+ it('flags modules an import without an extension reaches by names that differ only in case', () => {
+ // The pairs that broke the 0.4.0 build on macOS and Windows.
+ expect(caseClashes(files('ActionFields.tsx', 'actionFields.ts', 'RuleNotes.tsx', 'ruleNotes.ts', 'types.ts'))).toEqual([
+ ['ActionFields.tsx', 'actionFields.ts'],
+ ['RuleNotes.tsx', 'ruleNotes.ts'],
+ ]);
+ // A folder is a module by its name, through its index.
+ expect(caseClashes([...files('Foo.ts'), { name: 'foo', directory: true }])).toEqual([['Foo.ts', 'foo']]);
+ expect(caseClashes(files('data.json', 'Data.js'))).toEqual([['Data.js', 'data.json']]);
+ });
+
+ it('flags any two names that differ only in case, module or not', () => {
+ expect(caseClashes(files('logo.svg', 'Logo.svg'))).toEqual([['Logo.svg', 'logo.svg']]);
+ // One group, though the pair clashes both by its names and as modules.
+ expect(caseClashes(files('Foo.ts', 'foo.ts'))).toEqual([['Foo.ts', 'foo.ts']]);
+ });
+
+ it('passes names a lookup can tell apart', () => {
+ // A stylesheet is imported by its whole name.
+ expect(caseClashes(files('Markdown.tsx', 'markdown.css', 'index.ts'))).toEqual([]);
+ // Spelled alike: the same on every file system.
+ expect(caseClashes([...files('Foo.ts', 'Foo.tsx'), { name: 'Foo', directory: true }])).toEqual([]);
+ expect(caseClashes(files('ruleActionFields.ts', 'ActionFields.tsx', 'ruleNotesFor.ts', 'RuleNotes.tsx'))).toEqual([]);
+ });
+
+ it('walks src, scripts and test, and names the folder of each clash', () => {
+ const root = mkdtempSync(join(tmpdir(), 'case-clashes-'));
+ const write = (path: string) => {
+ mkdirSync(dirname(join(root, path)), { recursive: true });
+ writeFileSync(join(root, path), '');
+ };
+ try {
+ ['src/a/b/Row.tsx', 'src/a/b/row.ts', 'src/ui/Markdown.tsx', 'src/ui/markdown.css', 'scripts/check.ts', 'test/unit/Tree.test.ts', 'test/unit/tree.test.ts', 'docs/X.ts', 'docs/x.ts'].forEach(write);
+ expect(findCaseClashes(root).map((v) => [v.file, v.rule, v.detail.split(':')[0]])).toEqual([
+ [join('src', 'a', 'b'), 'no case clashes', 'Row.tsx, row.ts'],
+ [join('test', 'unit'), 'no case clashes', 'Tree.test.ts, tree.test.ts'],
+ ]);
+ rmSync(join(root, 'src/a/b/row.ts'));
+ rmSync(join(root, 'test/unit/tree.test.ts'));
+ expect(findCaseClashes(root)).toEqual([]);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+ });
+});