Import boundaries
Three rules about what may import what. They are the architecture, and two of the three fail your build rather than relying on you to remember them.
Architecture that lives in a document drifts. Architecture that fails npm run lint
does not. TwinScope keeps three import boundaries, and the two most dangerous to
break are enforced mechanically.
1. The renderer cannot import node or electron#
// --- Renderer: React rules, and NO access to node or Electron. ---
{
files: ['src/renderer/**/*.{ts,tsx}'],
rules: {
'no-restricted-imports': ['error', {
patterns: [{
group: ['node:*', 'fs', 'path', 'os', 'child_process', 'electron'],
message:
'The renderer must not touch node or Electron. Go through the preload bridge (window.twinscope).',
}],
}],
},
}Why#
A compromised renderer is the threat model context isolation exists for. The
renderer displays file contents, and file contents are attacker-controlled input —
a crafted diff, a hostile JSON blob, a malformed image. If that process could reach
fs or child_process, a rendering bug would become arbitrary file access rather
than a visual glitch.
So the renderer talks to main through exactly one object, window.twinscope, exposed
by the sandboxed preload. Main zod-parses every payload that arrives, because
"the renderer sent it" is not evidence of anything.
2. Engines cannot import electron#
// --- Engines: pure logic. No Electron, so the CLI can reuse them. ---
{
files: ['src/engines/**/*.ts'],
rules: {
'no-restricted-imports': ['error', {
patterns: [{
group: ['electron'],
message:
'Engines must stay host-agnostic. Filesystem access is injected via EngineCtx.fs.',
}],
}],
},
}Why#
So a CLI can reuse them unchanged. The comparison logic is the valuable part of
the app and it has nothing to do with windows: a text diff is a function from two
strings to a row model. The moment an engine imports electron, it can only ever run
inside a desktop app.
Everything an engine needs from its host is injected through EngineCtx instead —
filesystem access as ctx.fs, image decoding as ctx.image, a yield point as
ctx.yieldNow. The Electron app supplies a Node-backed implementation; a CLI would
supply its own; a test supplies a fake.
3. src/shared/channels.ts takes no runtime dependency#
import type { InputKind, InputRef, Summary } from '../engines/types';
/**
- The contract between processes: channel names, wire shapes, and the bridge.
-
- No _runtime_ dependency may enter this file — the sandboxed preload imports
- it, and anything heavier lands in its bundle. Type-only imports are fine,
- since they erase at build time. Runtime validation lives in `./schemas`,
- which only main imports.
*/Why#
The sandboxed preload imports it, and anything heavier lands in its bundle. The
preload is the single most security-sensitive file in the app: it runs with more
privilege than the renderer and it is what the renderer talks to. Every runtime
dependency pulled into channels.ts is pulled into that bundle too — more code in the
privileged position, for a file whose entire job is to declare names and shapes.
The whole file is therefore channel-name constants, TypeScript types, and one
type-only import, which erases at build time. Runtime validation lives next door
in schemas.ts, where the zod schemas are — and only main imports that.
Checking your work#
npm run lint # the boundary rules, plus the rest of ESLint
npm run typecheck # three projects: node / web / e2e
npm run gate # typecheck · lint · format:check · test · verify, in CI orderA renderer file importing node:fs fails npm run lint. That is the point: the
architecture is not a thing you have to remember, it is a thing the build checks.