Skip to content

Commit 8d7716f

Browse files
authored
Break the 9 server import cycles; promote noImportCycles to error (#1007)
**Nine `lint/suspicious/noImportCycles` violations in `packages/server/src/` all routed through one bidirectional edge**: `surface.ts` and four domain modules importing each other in a tangle that biome had been merely _warning_ about for months. Extract a `surfaceCtx.ts` Proxy-fronted holder so `surface.ts` registers the typed mutation map once at startup; domain modules import the ctx from the holder instead. The bidirectional arrow collapses to a one-way arrow plus a one-way registration — same shape that PR [#998 cycle 1](#998) prototyped. With zero cycles remaining, promote the rule from biome's default `warn` to `error` in `biome.jsonc`. _The TDZ-on-import-reorder hazard that crashed production in [#1003](#1003) — biome's alphabetical sort reshuffled imports, the cycle converged via a different path, `localTerminalBackend` ended up in TDZ at module load, only `just smoke` caught it on CI — becomes structurally impossible._ ### The shape that changed ``` Before After surface.ts ◀──▶ session.ts surface.ts ──▶ session.ts ──┐ ◀──▶ activity.ts (via local.ts) surface.ts ──▶ activity.ts ─┤ ◀──▶ terminalBackend/local.ts surface.ts ──▶ local.ts ────┼──▶ surfaceCtx.ts ◀──▶ terminalBackend/metadata.ts surface.ts ──▶ metadata.ts ─┘ ▲ │ │ └────── setSurfaceCtx ─────────┘ (local.ts also pulled surface.ts via unwrapGit; local.ts ──▶ unwrapGit.ts router.ts pulled it for the same.) router.ts ──▶ unwrapGit.ts ``` `unwrapGit` had to come out alongside `surfaceCtx` — `terminalBackend/local.ts` imported _both_ from `surface.ts`. Pulling only the ctx out would leave `local.ts → surface.ts → terminalBackend/index.ts → local.ts` cycling via `unwrapGit`. It's a pure `GitResult → ORPCError` adapter; its own neutral file is the right boundary, and `router.ts` picks up the new import too. ### Refinements during review | Lens | Finding | Resolution | |---|---|---| | **hickey** | `setSurfaceCtx` silently overwrites `held` on a second call — _"write once" contract is convention, not constraint_ | Guard added | | **lowy** _(cross-validate)_ | Strict `held !== undefined` would block harmless same-ctx re-registration (future test isolation / HMR) | Refined predicate to `held !== undefined && held !== ctx` — throws only on a _genuinely different_ ctx | | **code-police** | `session.test.ts` + `metadata.test.ts` previously inherited a populated ctx via `surface.ts`'s import side-effect; with the holder, the Proxy throws when not initialized | Added `__resetSurfaceCtxForTest()` + `noopSurfaceCtxForTest()` and wired them into the affected tests | | **code-police** | Stale doc comment in `router.ts` still pointed at `./surface.ts` for `surfaceCtx` | Updated | ### Validation - `just check` — clean (zero `noImportCycles`, zero type errors, pre-existing 40 warnings unrelated). - `pnpm vitest run` in `packages/server` — **46/46 unit tests pass** (the 14 that needed the new test-helper plumbing now have it). - CI `just smoke` covers the original production-loader hazard end-to-end. > _Reviewer note_: also retires the `biome-ignore-start assist/source/organizeImports` markers in `surface.ts`. With no cycle for the alphabetical sort to converge along, the load-order constraint they protected is gone. Closes #1005. ### Try it locally ```sh nix run github:juspay/kolu/noImportCycles ``` _Generated by [`/do`](https://github.com/srid/agency) on Claude Code (model `claude-opus-4-7`)._
1 parent 2a4fe2c commit 8d7716f

11 files changed

Lines changed: 201 additions & 61 deletions

File tree

biome.jsonc

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,12 @@
100100
"suspicious": {
101101
// Terminal code legitimately parses ANSI escape sequences —
102102
// the rule is a permanent false positive against xterm diagnostics.
103-
"noControlCharactersInRegex": "off"
103+
"noControlCharactersInRegex": "off",
104+
// Promoted from the default `warn` after #1005 broke the 9 server
105+
// cycles via the `surfaceCtx.ts` holder pattern. As `error`, any
106+
// PR that re-introduces a cycle fails `just check` immediately —
107+
// the TDZ-on-import-reorder class becomes structurally impossible.
108+
"noImportCycles": "error"
104109
},
105110
"complexity": {
106111
// Tailwind `!important` overrides are intentional in kolu's

packages/server/src/activity.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import type {
1515
RecentRepo,
1616
} from "kolu-common/surface";
1717
import { log } from "./log.ts";
18-
import { surfaceCtx } from "./surface.ts";
18+
import { surfaceCtx } from "./surfaceCtx.ts";
1919

2020
const MAX_RECENT_REPOS = 20;
2121
const MAX_RECENT_AGENTS = 10;

packages/server/src/router.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33
* hand-listed raw oRPC handlers (terminal lifecycle, attach, git
44
* mutations, server info).
55
*
6-
* The typed reactive layer goes through `surfaceRouter` / `surfaceCtx`
7-
* (see `./surface.ts`). Domain mutations import `surfaceCtx` directly
8-
* from there. This file is just the glue between the surface fragment
9-
* and the raw RPCs.
6+
* The typed reactive layer goes through `surfaceRouter` (from `./surface.ts`)
7+
* and `surfaceCtx` (from `./surfaceCtx.ts`). Domain mutations import
8+
* `surfaceCtx` from `./surfaceCtx.ts` directly. This file is just the glue
9+
* between the surface fragment and the raw RPCs.
1010
*/
1111

1212
import { ORPCError } from "@orpc/server";
@@ -23,10 +23,11 @@ import { match } from "ts-pattern";
2323
import { serverHostname, serverProcessId } from "./hostname.ts";
2424
import { log } from "./log.ts";
2525
import { pwaIdentityForHostname } from "./pwaIdentity.ts";
26-
import { surfaceRouter, t, unwrapGit } from "./surface.ts";
26+
import { surfaceRouter, t } from "./surface.ts";
2727
import { getTerminal, type TerminalProcess } from "./terminal-registry.ts";
2828
import { getTerminalBackendFor } from "./terminalBackend/index.ts";
2929
import { saveTerminalFile } from "./terminalScratch.ts";
30+
import { unwrapGit } from "./unwrapGit.ts";
3031
import {
3132
createTerminal,
3233
killAllTerminals,

packages/server/src/session.test.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import * as assert from "node:assert";
2-
import type { SavedTerminal } from "kolu-common/surface";
3-
import { afterAll, describe, expect, it } from "vitest";
2+
import type { SavedSession, SavedTerminal } from "kolu-common/surface";
3+
import { confStore } from "@kolu/surface/server";
4+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
5+
import { __resetSurfaceCtxForTest, setSurfaceCtx } from "./surfaceCtx.ts";
6+
import { store } from "./state.ts";
47
import {
58
clearSavedSession,
69
getSavedSession,
@@ -27,8 +30,34 @@ const terminal: SavedTerminal = {
2730
};
2831

2932
describe("session persistence", () => {
33+
beforeAll(() => {
34+
// surface.ts is not imported by this test module (no full backend init),
35+
// so we supply a minimal ctx where cells.session is backed by the real
36+
// confStore. This makes writeSession → surfaceCtx.cells.session.set(v)
37+
// actually persist to the conf store, which getSavedSession() reads back.
38+
const sessionStore = confStore<SavedSession | null>(store, "session");
39+
setSurfaceCtx({
40+
cells: new Proxy({} as never, {
41+
get: (_, key) =>
42+
key === "session"
43+
? sessionStore
44+
: { get: () => undefined, set: () => {}, patch: () => {} },
45+
}),
46+
collections: new Proxy({} as never, {
47+
get: () => ({
48+
upsert: () => {},
49+
remove: () => {},
50+
readAll: () => new Map(),
51+
readOne: () => undefined,
52+
}),
53+
}),
54+
events: new Proxy({} as never, { get: () => ({ publish: () => {} }) }),
55+
} as never);
56+
});
57+
3058
afterAll(() => {
3159
clearSavedSession();
60+
__resetSurfaceCtxForTest();
3261
});
3362

3463
it("returns null when no session is saved", () => {

packages/server/src/session.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import type { SavedSession, SavedTerminal } from "kolu-common/surface";
1313
import { log } from "./log.ts";
1414
import { terminalsDirtyChannel } from "./publisher.ts";
1515
import { store } from "./state.ts";
16-
import { surfaceCtx } from "./surface.ts";
16+
import { surfaceCtx } from "./surfaceCtx.ts";
1717

1818
/** Pending autosave timer — declared at module top so `setSavedSession`
1919
* and the surface cell's `store.set` adapter can cancel it (see comment

packages/server/src/surface.ts

Lines changed: 14 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,16 @@
55
* - `surfaceRouter` — `oc.router({ surface: {...} })` fragment for the
66
* host router; spread alongside hand-listed raw oRPC procedures in
77
* `router.ts`.
8-
* - `surfaceCtx` — typed `cells / collections / events` mutation map.
9-
* Domain modules (`activity.ts`, `session.ts`, `terminals.ts`,
10-
* `terminalBackend/metadata.ts`) import this and call `surfaceCtx.cells.X.set(...)`,
11-
* `surfaceCtx.collections.X.upsert(k, v)`, `surfaceCtx.events.X.publish(i, p)`.
12-
* The framework owns the apply+publish chain (and per-input event
13-
* channel); domain code never sees a channel name string.
8+
* - The typed `cells / collections / events` mutation map (`surfaceCtx`)
9+
* is built here and registered into `./surfaceCtx.ts` via
10+
* `setSurfaceCtx(...)`. Domain modules (`activity.ts`, `session.ts`,
11+
* `terminalBackend/local.ts`, `terminalBackend/metadata.ts`) import
12+
* `surfaceCtx` from `./surfaceCtx.ts` — not from here — and call
13+
* `surfaceCtx.cells.X.set(...)`, `.collections.X.upsert(k, v)`,
14+
* `.events.X.publish(i, p)`. The framework owns the apply+publish
15+
* chain; domain code never sees a channel name string. Routing the
16+
* ctx through `./surfaceCtx.ts` is what breaks the bidirectional
17+
* import cycle that would otherwise form (#1005).
1418
*
1519
* Publisher channel names are framework-derived: `<surface-key>:changed`
1620
* for cells, `<surface-key>:keys` + `<surface-key>:<key>` for collections,
@@ -28,7 +32,7 @@ import {
2832
implementSurface,
2933
publisherChannel,
3034
} from "@kolu/surface/server";
31-
import { implement, ORPCError } from "@orpc/server";
35+
import { implement } from "@orpc/server";
3236
import { contract } from "kolu-common/contract";
3337
import { TerminalNotFoundError } from "kolu-common/errors";
3438
import type {
@@ -42,11 +46,9 @@ import {
4246
type FsReadFileOutput,
4347
fsListAllOutputEqual,
4448
fsReadFileOutputEqual,
45-
type GitResult,
4649
gitDiffOutputEqual,
4750
gitStatusOutputEqual,
4851
} from "kolu-git";
49-
import { match } from "ts-pattern";
5052
import {
5153
buildIframePreviewUrl,
5254
isIframePreviewable,
@@ -55,17 +57,9 @@ import { log } from "./log.ts";
5557
import { publisher } from "./publisher.ts";
5658
import { cancelPendingAutosave, getSavedSession } from "./session.ts";
5759
import { store } from "./state.ts";
58-
// Load-order is cycle-sensitive: `terminalBackend/index.ts` must finish
59-
// loading (so `localTerminalBackend` is initialized) before line 61 below
60-
// calls `getTerminalBackendFor`. `terminal-registry.ts` participates in the
61-
// surface cycle via `local.ts`; if its import runs before
62-
// `terminalBackend/index.ts`, the cycle reaches line 61 in a state where
63-
// `localTerminalBackend` is still in TDZ. Biome's alphabetical sort would
64-
// swap these two lines and break the production build.
65-
// biome-ignore-start assist/source/organizeImports: cycle-sensitive load order
66-
import { getTerminalBackendFor } from "./terminalBackend/index.ts";
60+
import { setSurfaceCtx } from "./surfaceCtx.ts";
6761
import { getTerminal, listTerminals } from "./terminal-registry.ts";
68-
// biome-ignore-end assist/source/organizeImports: cycle-sensitive load order
62+
import { getTerminalBackendFor } from "./terminalBackend/index.ts";
6963

7064
const localBackend = getTerminalBackendFor({ kind: "local" });
7165

@@ -89,35 +83,6 @@ const savedSessionStore: CellStore<SavedSession | null> =
8983

9084
// ── Surface implementation ─────────────────────────────────────────────
9185

92-
/** Unwrap a `GitResult` or throw an `ORPCError` for the client. Shared
93-
* with the raw git handlers in `router.ts`. */
94-
export function unwrapGit<T>(result: GitResult<T>): T {
95-
if (result.ok) return result.value;
96-
const { status, message } = match(result.error)
97-
.with({ code: "BASE_BRANCH_NOT_FOUND" }, (e) => ({
98-
status: "PRECONDITION_FAILED" as const,
99-
message: e.message,
100-
}))
101-
.with({ code: "WORKTREE_NAME_COLLISION" }, (e) => ({
102-
status: "CONFLICT" as const,
103-
message: e.message,
104-
}))
105-
.with({ code: "PATH_ESCAPES_ROOT" }, (e) => ({
106-
status: "INTERNAL_SERVER_ERROR" as const,
107-
message: `path escapes root: ${e.child}`,
108-
}))
109-
.with({ code: "GIT_FAILED" }, (e) => ({
110-
status: "INTERNAL_SERVER_ERROR" as const,
111-
message: e.message,
112-
}))
113-
.with({ code: "NOT_A_REPO" }, () => ({
114-
status: "INTERNAL_SERVER_ERROR" as const,
115-
message: "Not a git repository",
116-
}))
117-
.exhaustive();
118-
throw new ORPCError(status, { message });
119-
}
120-
12186
const { router: surfaceRouterFragment, ctx: surfaceCtxBuilt } =
12287
implementSurface(surface, {
12388
channel: <T>(name: string) => publisherChannel<T>(publisher, name),
@@ -288,4 +253,4 @@ const { router: surfaceRouterFragment, ctx: surfaceCtxBuilt } =
288253
});
289254

290255
export const surfaceRouter = surfaceRouterFragment;
291-
export const surfaceCtx = surfaceCtxBuilt;
256+
setSurfaceCtx(surfaceCtxBuilt);

packages/server/src/surfaceCtx.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/**
2+
* Late-bound holder for the typed surface mutation context.
3+
*
4+
* `surface.ts` calls `setSurfaceCtx(built)` once at startup, right after
5+
* `implementSurface(...)` returns its `{ router, ctx }` pair. Domain
6+
* modules — `activity.ts`, `session.ts`, `terminalBackend/local.ts`,
7+
* `terminalBackend/metadata.ts` — import `surfaceCtx` from here instead
8+
* of from `surface.ts`. The bidirectional edge that used to form between
9+
* `surface.ts` and every domain module collapses to a one-way arrow
10+
* (`surface.ts → domain`) plus a one-way registration
11+
* (`surface.ts → surfaceCtx.ts`).
12+
*
13+
* Without this holder, biome's `noImportCycles` flags every domain
14+
* module's `surfaceCtx` import, and the production Node ESM loader can
15+
* land on an import order where `surface.ts`'s top-level
16+
* `getTerminalBackendFor({ kind: "local" })` runs while
17+
* `localTerminalBackend` is still in TDZ — production crashes that
18+
* vite-node's evaluation order does not reproduce in unit tests (#1005).
19+
*
20+
* The Proxy throws on access before `setSurfaceCtx` has been called.
21+
* If a domain module ever moves a `surfaceCtx.X` access from a function
22+
* body to the module top level, the throw surfaces it at startup rather
23+
* than yielding `undefined` and crashing later.
24+
*/
25+
26+
import type { SurfaceCtx } from "@kolu/surface/server";
27+
import type { surface } from "kolu-common/surface";
28+
29+
type Ctx = SurfaceCtx<(typeof surface)["spec"]>;
30+
31+
let held: Ctx | undefined;
32+
33+
export function setSurfaceCtx(ctx: Ctx): void {
34+
// Process singleton — `surface.ts` calls this exactly once at startup.
35+
// Throwing on a *different* ctx (rather than on any second call) keeps
36+
// the invariant honest while staying tolerant of an accidental same-ctx
37+
// re-registration from a future test or hot-reload scenario.
38+
if (held !== undefined && held !== ctx) {
39+
throw new Error(
40+
"setSurfaceCtx called twice with different contexts — surface.ts must call this exactly once",
41+
);
42+
}
43+
held = ctx;
44+
}
45+
46+
/** Reset the held ctx to `undefined`. Only for use in unit tests that
47+
* need to supply a fresh mock ctx per test without hitting the
48+
* double-call guard. Production code must never call this. */
49+
export function __resetSurfaceCtxForTest(): void {
50+
held = undefined;
51+
}
52+
53+
/** A no-op ctx for unit tests that exercise code paths touching
54+
* `surfaceCtx` but don't care about surface publish side-effects
55+
* (e.g. metadata publish-routing tests, session persistence tests).
56+
* Cast via `unknown` because the full spec-typed Ctx would require
57+
* listing every cell/collection/event; the cast is localised here
58+
* so callers stay readable. */
59+
export function noopSurfaceCtxForTest(): Ctx {
60+
const noop = () => {};
61+
const noopReadAll = () => new Map();
62+
const noopReadOne = () => undefined;
63+
return {
64+
cells: new Proxy({} as Ctx["cells"], {
65+
get: () => ({ get: noopReadOne, set: noop, patch: noop }),
66+
}),
67+
collections: new Proxy({} as Ctx["collections"], {
68+
get: () => ({
69+
upsert: noop,
70+
remove: noop,
71+
readAll: noopReadAll,
72+
readOne: noopReadOne,
73+
}),
74+
}),
75+
events: new Proxy({} as Ctx["events"], {
76+
get: () => ({ publish: noop }),
77+
}),
78+
} as unknown as Ctx;
79+
}
80+
81+
export const surfaceCtx: Ctx = new Proxy({} as Ctx, {
82+
get(_, prop) {
83+
if (!held) {
84+
throw new Error(
85+
`surfaceCtx accessed before surface.ts initialized it (.${String(prop)})`,
86+
);
87+
}
88+
return Reflect.get(held, prop);
89+
},
90+
});

packages/server/src/terminalBackend/local.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ import { trackRecentAgent, trackRecentRepo } from "../activity.ts";
5151
import { koluShellDir } from "../koluRoot.ts";
5252
import { log } from "../log.ts";
5353
import { terminalChannels, terminalsDirtyChannel } from "../publisher.ts";
54-
import { surfaceCtx, unwrapGit } from "../surface.ts";
54+
import { surfaceCtx } from "../surfaceCtx.ts";
5555
import {
5656
drainTerminals,
5757
getTerminal,
@@ -61,6 +61,7 @@ import {
6161
unregisterTerminal,
6262
} from "../terminal-registry.ts";
6363
import { cleanupTerminalScratch } from "../terminalScratch.ts";
64+
import { unwrapGit } from "../unwrapGit.ts";
6465
import {
6566
createMetadata,
6667
updateServerLiveMetadata,

packages/server/src/terminalBackend/metadata.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@
1111
import { afterEach, beforeEach, describe, expect, it } from "vitest";
1212
import { terminalsDirtyChannel } from "../publisher.ts";
1313
import type { TerminalProcess } from "../terminal-registry.ts";
14+
import {
15+
__resetSurfaceCtxForTest,
16+
noopSurfaceCtxForTest,
17+
setSurfaceCtx,
18+
} from "../surfaceCtx.ts";
1419
import {
1520
updateClientMetadata,
1621
updateServerLiveMetadata,
@@ -48,6 +53,9 @@ async function settle(): Promise<void> {
4853
}
4954

5055
beforeEach(async () => {
56+
// surface.ts is not imported by this test module; supply a no-op ctx
57+
// so calls to publishSnapshot (via surfaceCtx.collections…upsert) don't throw.
58+
setSurfaceCtx(noopSurfaceCtxForTest());
5159
dirtyCount = 0;
5260
stopWatch = terminalsDirtyChannel.consume({
5361
onEvent: () => {
@@ -65,6 +73,7 @@ afterEach(() => {
6573
// keep firing into the shared `dirtyCount` and bystander tests see
6674
// inflated counts.
6775
stopWatch?.();
76+
__resetSurfaceCtxForTest();
6877
});
6978

7079
describe("metadata publish routing", () => {

packages/server/src/terminalBackend/metadata.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ import type {
3737
import { prUnavailableReason, prValue } from "kolu-github/schemas";
3838
import { log } from "../log.ts";
3939
import { terminalsDirtyChannel } from "../publisher.ts";
40-
import { surfaceCtx } from "../surface.ts";
40+
import { surfaceCtx } from "../surfaceCtx.ts";
4141
import type { TerminalProcess } from "../terminal-registry.ts";
4242

4343
/** Create initial metadata state for a new terminal. `lastActivityAt: 0`

0 commit comments

Comments
 (0)