AZ
All writing
4 min read

An architecture lint that fails the build

typescriptfastifymonorepoarchitecturepostgres
Part of the projectAisolverA collaborative task/project platform with built-in AI agents that act on your data — a pnpm monorepo whose ~320-line architecture lint fails the build on any boundary or cycle violation.View project

AISolver (package taskwise-v2) is easy to undersell as "a rebuilt task manager." It's really a collaborative task/project platform with built-in AI agents that act on the user's own data — Todoist plus Notion plus a team of assistants that can do the work — and the task-manager surface (lists, nested tasks, calendar, alarms, trash) is the substrate the agents operate on. A codebase that large needs its layers defended, and it defends them with a ~320-line script that isn't allowed to be optional.

A lint that fails the build

tools/arch-check.ts is a zero-dependency (node:fs + node:path only) boundary guard, run as pnpm arch:check, that exits 1 on any violation so CI and pre-push fail. "Please don't import the database from the UI layer" is a code-review plea that erodes under deadline; a red check does not get tired. It enforces five distinct classes of decay and — importantly — three reports that never touch the exit code:

RuleScopeForbidsGate
routes↛routesroutes/ + v2/routes/a route importing another route (Stage-6B module exception)exit 1
lib↛routeslib/ + v2/lib/the lower layer importing the upperexit 1
cycle ceilingall of apps/api/srcany file in a static import cycle; ceiling 0exit 1
legacy-path banall of apps/api/srcspecifiers referencing the old agentWorker or v2/lib pathsexit 1
raw-anthropic-fetchall of apps/api/srcapi.anthropic.com/v1/messages off a 6-entry allowlistexit 1
cycles / LOC / mixed-SCCall of apps/api/srcnothing — visibility onlynever

The cycle rule, and why dynamic import() is not an edge

The cycle rule builds the full intra-repo static import graph over all of apps/api/src, runs an iterative Tarjan SCC (iterative to avoid a stack overflow on a deep graph), and fails if any file sits in a strongly-connected component of size ≥ 2. The ceiling is literally 0, and a comment logs the burn-down as the cuts landed: 106 → 43 → 13 → 10 → 0.

Only static edges count. That's a deliberate choice, spelled out in the source: a dynamic import() defers to runtime, so it creates no module load-order / TDZ hazard — which is the actual thing an import cycle threatens. Dynamic import is therefore the sanctioned way to break a static cycle, and counting it as an edge would over-report an intentionally-broken back-edge as a violation.

// STATIC edges ONLY. Dynamic import() is the sanctioned way to break a static
// cycle — it defers to runtime and creates no module load-order / TDZ hazard …
// Counting dynamic edges would over-report (e.g. agentEvents↔jobProgress-
// Notifications is intentionally dynamic-broken).

The legacy-path ban and the raw-fetch guard scan all import kinds (static, dynamic, and side-effect import '…') — because a rename or a forbidden call is wrong however it's spelled — but the cycle graph is static-only.

Allowing some route→route imports without opening the floodgates

A pure "no route imports a route" rule fights a legitimate refactor: decomposing a route god-file into a co-located routes/modules/<name>/ subtree. The Stage-6B exception permits exactly two edges — a sibling within the same module dir, and the thin composer routes/<name>.ts importing its own module — and nothing else. Cross-module and arbitrary top-level route→route imports stay violations:

function isSanctionedModuleImport(file: string, target: string): boolean {
  const tm = moduleNameOf(target);
  if (!tm) return false;
  if (moduleNameOf(file) === tm) return true; // sibling within the module
  return file.replace(/\\/g, '/').endsWith(`/routes/${tm}.ts`); // the module's composer
}

A domain invariant, not just layering

The fifth rule isn't generic architecture — it's a product invariant. Every non-streaming Claude call must go through the resilient callClaude wrapper (credit accounting, 429/retry, telemetry), so any file that hits the Anthropic messages endpoint and isn't one of six intentional raw callers is a violation. The allowlist is documented inline with why each entry is exempt — the streaming turn has its own retry, the skills path needs Files-API hosts callClaude can't serve — so extending it is a deliberate, reviewed act.

Visibility without gating

Three reports run every check but never fail it: an SCC summary, a list of files ≥ 1200 lines (split candidates, so growth is observed rather than rediscovered by the next audit), and a mixed static+dynamic SCC report — because dynamic import breaks the static cycle but the resulting runtime SCC is real layering debt the 0-static ceiling makes invisible. The header is candid that this whole script is the interim guard until a fuller kernel/platform/contracts/modules structure lands, at which point it's swapped for dependency-cruiser. Encoding the layering as an executable check — one that even carries a documented exception for legitimate decomposition — is how the structure defends itself instead of relying on everyone remembering the plan.

Back to the projectAisolverSee the full case study and related write-ups.View project