nix build ≠ typecheck
A talk-mode diagnosis of #1049 — a green `nix build` exits 0 with broken server types; the fix is a cached `checks.typecheck` flake derivation that closes the local false-confidence gap and deletes a CI node.
Talk-mode response · diagnosis confirmed against source · design reviewed (Lowy + Hickey) and revised.
What I verified
| Claim in the issue | Status | Source |
|---|---|---|
nix build only runs the Vite client bundle, no tsc | true | default.nix:87-95 — buildPhase = pnpm --filter kolu-client build only |
Server ships as .ts, run under tsx (transpile-only) | true | default.nix:154-155 — tsx … server/src/index.ts |
typescript pruned from the production closure | true | default.nix:109 — in installPhase (after build, before cp -r . $out) |
No checkPhase / doCheck on the derivation | true | default.nix:59-126 |
The real gate is just check / CI | partly | CI did gate: the DAG then included a standalone typecheck → pnpm typecheck node (deleted by
#1056 in favour of the flake check). just check (justfile:67-68) is the local inner loop. |
So the path that gives false confidence:
.ts→nix build .#default→no tsc runs→exit 0→“safe to deploy” ✗Recommendation — a checks.typecheck derivation
Add a typecheck derivation to the root flake.nix that reuses pnpmDeps, runs pnpm -r typecheck, and touch $out on success. Three things fall out of this:
- It becomes a hard, un-skippable gate. The CI
nixnode builds all flake outputs via devour-flake (ci/mod.just:63-64). A newchecks.*output is picked up automatically — no new CI recipe needed. - It’s content-addressed. The derivation only re-runs when its source changes, and the result is shared through
cache.nixos.asia/oss. The currentci::typecheckrecipe re-runspnpm typecheckunconditionally every pipeline. - It lets us delete a node. Once typecheck is a flake output covered by the
nixnode, the standalonetypecheckrecipe and its DAG edge are redundant → remove them. Net: 3 typecheck loci → 3 (package.json script,just check, nix derivation), one fewer CI node, all still delegating to the one sharedpnpm -r typecheckmechanism.
The trap to avoid — fileset drift must-fix
The build’s src fileset (default.nix:15-38) is a hand-maintained allowlist of package dirs — and it omits packages/tests. If the typecheck derivation reused that fileset, it would typecheck a different, smaller set than just check (which sees the whole working tree). A package added to pnpm-workspace.yaml but forgotten in the fileset would type-error while the derivation exits 0 — reintroducing the exact false-green #1049 is about, just relocated.
Pin to one platform cost
devour-flake builds checks.${system} for all three systems. TypeScript typecheck is platform-independent, so attaching it to all three triples CI cost for identical results. Pin it to one — following the existing precedent nix/home/example/flake.nix:70 (checks.${linuxSystem}.vm-test) and nix/home/example/flake.nix:114 (checks.${darwinSystem}).
As built, this was reversed too.
#1056 runs the type gates on every system, deliberately: the build environment (nodejs/pnpm and the platform-resolved deps pnpmConfigHook installs) differs per platform, so each platform’s tsc/astro check is its own proof — a darwin-only type error wouldn’t surface from a linux-only check (rationale comment in flake.nix:66-72).
Concrete change (single PR — infra, not user-visible, no phasing)
1 · flake.nix — add the check output
+# typecheck the whole workspace as a cached, devour-flake-gated proof.
+# Own broad fileset (all of ./packages) — NOT default.nix's narrow shipping
+# allowlist — so a new package can never silently escape the gate (#1049).
+# Pinned to one system: tsc is platform-independent (cf. nix/home/example).
+checks.${linuxSystem}.typecheck = import ./nix/typecheck.nix { inherit pkgs; };
A small nix/typecheck.nix leaf: mkDerivation reusing pnpmDeps + pnpmConfigHook, buildPhase = "pnpm -r typecheck", installPhase = "touch $out". No node-gyp rebuild — tsc --noEmit needs .d.ts, not the compiled .node.
2 · ci/mod.just — delete the now-redundant recipe
-default: nix home-manager e2e smoke fmt typecheck biome unit surface-example-build pnpm-hash-fresh
+default: nix home-manager e2e smoke fmt biome unit surface-example-build pnpm-hash-fresh
-typecheck: install
- {{ nix_shell }} pnpm typecheck
And one comment at the nix: recipe (ci/mod.just:63) noting that checks.typecheck (built here via devour-flake) replaced the standalone recipe — so the temporal coupling (“the nix node is now load-bearing for typecheck”) is documented, not folklore.
3 · default.nix — one comment at the buildPhase
buildPhase = ''
runHook preBuild
...
+ # NOTE: this does NOT typecheck — only the Vite client bundle is built and
+ # the server runs under tsx. The type gate is `nix build .#checks…typecheck`
+ # (run by CI's `nix` node). A green `nix build .#default` is not a type-proof.
pnpm --filter kolu-client build
runHook postBuild
'';
Leave just check (justfile:67-68) exactly as-is — it stays the fast local inner loop. No pre-push hook needed: making nix build a type-proof and keeping the CI gate is sufficient; a hook would be a fourth locus for the same concept.
What I deliberately rejected
- Folding
tscinto.#default. Complects artifact-production with type-proof; taxesnix run/ smoke / e2e; busts the build cache on type-only edits. - A separate
checks.fileset-completenessguard. Hickey’s fix for the drift trap is valid, but the broad-fileset approach above kills the same failure mode by construction with no new abstraction — preferred unless a narrow fileset must be kept for caching (it needn’t be). - Docs-only (“just rely on the existing CI
typechecknode”). Honest and zero-abstraction, but it leavesnix builda non-proof — which is the issue’s actual ask — and forgoes the cache win. The buildPhase comment ships regardless; the derivation is the part that closes the gap.