Too many legal ways is the bug.
A code-generating agent doesn't fail React because a linter is missing. It fails because Zustand, Context, Redux and Query are all correct answers to one question, so it picks at random and the codebase drifts. A boundary plugin can't delete that choice. A runtime designed around one answer can.
Async state lives outside the core
Every app rebuilds loading, errors, race cancellation and retry from a third-party library.
Causality isn't observable
"Why did this update?" still takes a browser extension and a guess.
Layers are a convention
Frameworks organise code. Nothing stops I/O inside a component.
A build step is mandatory
Hundreds of megabytes of tooling before the first hello world.
The rule set fits in one table.
Sheratan enforces an import matrix statically, plus a few rules the matrix can't express. When code crosses a boundary, the checker says which boundary it crossed, what's allowed instead, and how to fix it. The error is written for an agent to act on in one turn.
| file ↓ | lib | ui | services | own module | other module |
|---|---|---|---|---|---|
| lib/ | · | · | · | · | · |
| ui/ | ✓ | ✓ | · | · | · |
| services/ | ✓ | · | ✓ | · | · |
| *.state.ts | ✓ | · | · | ✓ | · |
| *.effects.ts | ✓ | · | ✓contracts | ✓state | ✓index.ts |
| *.view.ts | ✓ | ✓ | · | ✓state | · |
| app.ts | ✓ | ✓ | ✓ | ✓ | ✓ |
Plus: no I/O globals in views, file set matches module kind, acyclic module graph. Writing state from effects isn’t a checker rule at all — a state module exports accessors and transitions, never the signals themselves, so state.rows.set(…) is a type error in any editor.
$ sheratan check --json
{
"code": "SHR-L001",
"severity": "error",
"file": "modules/todo/todo.view.ts",
"range": { "line": 3, "column": 1 },
"message": "view cannot import effects;
allowed: lib, ui, own state.",
"fix": "Move the call into todo.effects.ts
and expose the result via todo.state.ts.",
"docs": "https://sheratan.dev/errors/L001"
}
A stable code, a location and a fix. The JSON shape is a versioned public API.
Four files. One direction.
A module's file set is fixed by its kind. State holds signals and pure transitions, effects hold everything impure, and the view is a pure function of state that emits intents and never acts on them. Dependencies arrive as factory parameters, with no container and no event bus.
modules/orders/orders.state.tssignals and pure transitionsorders.effects.tsfetch, storage, timers, streamsorders.view.ts(state) ⇒ templateindex.tsthe only public surfaceorders.state.test.tsgenerated — pure, no mocksorders.effects.test.tsgenerated — one fake transportAn effect that gathers several responses commits them in one transition, so the view never renders a half-applied state.
Async and streams are primitives, not a stack.
resource() gives you status, data and error, aborts on key change and on unmount, de-duplicates, discards out-of-order responses, revalidates stale data and retries with backoff. stream() takes WebSockets or Server-Sent Events and folds messages into one commit per frame. Views update per hole with no virtual DOM, and everything a module creates is disposed with it.
const user = resource({
key: () => ['user', userId()],
fetch: ({ signal, key }) =>
api.getUser(key[1], { signal }),
staleAfter: 30_000,
retry: { attempts: 3, backoff: 'exponential' },
});
user.status(); // idle · loading · ready · error · refreshing
The MVP performance target, not a published result. The benchmark ships with the release, against React, Vue, Svelte and Solid on the same feed.
No bundler, no config. Type stripping only.
The core ships as plain ESM with zero runtime dependencies and runs from a <script type="module">. Routing, async state, layer enforcement and debugging are all in that one package. The checker is a dev tool with typescript as a peer dependency.
// inside an existing React or Vue app
const dispose = render(view,
document.querySelector('#live-table'));
Widget mode: mount one live table into someone else's dashboard. It owns only its subtree and disposes cleanly on route change.
What it builds on, and what it won't do.
None of the parts are new. Elm enforced architecture through a compiler, Solid has signals and resources, Lit has no-build templates, and boundary linters exist for every stack. What's new is a runtime designed so that an import matrix is enough: one state model, one async model, one place for I/O.
- Server renderingA stated no. Sheratan is for authenticated, high-frequency UIs: ops consoles, trading screens, internal tools. Content sites should use Next or Nuxt.
- React compatibilityNot a goal. Widget mode is the bridge.
- A UI kit
createcopies a few stateless primitives into your project. They're yours to edit. - Nested layoutsFlat routes over
URLPatternare in the core. Nested routing is a separate optional package later.