An architecture lint that fails the build
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:
| Rule | Scope | Forbids | Gate |
|---|---|---|---|
routes↛routes | routes/ + v2/routes/ | a route importing another route (Stage-6B module exception) | exit 1 |
lib↛routes | lib/ + v2/lib/ | the lower layer importing the upper | exit 1 |
| cycle ceiling | all of apps/api/src | any file in a static import cycle; ceiling 0 | exit 1 |
| legacy-path ban | all of apps/api/src | specifiers referencing the old agentWorker or v2/lib paths | exit 1 |
raw-anthropic-fetch | all of apps/api/src | api.anthropic.com/v1/messages off a 6-entry allowlist | exit 1 |
| cycles / LOC / mixed-SCC | all of apps/api/src | nothing — visibility only | never |
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.