Skip to content

Systematically fix typechecking - #332

Merged
sderickson merged 14 commits into
mainfrom
2026-08-07-references-cycles
Aug 8, 2026
Merged

sderickson merged 14 commits into
mainfrom
2026-08-07-references-cycles

Conversation

@sderickson

@sderickson sderickson commented Aug 8, 2026 •

Copy link
Copy Markdown
Owner

I'm doing an overhaul of SAF based on scaling issues I've run into. A big one is typechecking, which has gone over memory limits easily. Here's the summary of changes (other milestones referenced are further improvements to overall code structure):

M1 PR Summary: TypeScript Project References for Incremental Typechecking

Problem

Before M1, the repo typechecked as one monolithic program: root tsconfig.json had no references, so vue-tsc -b at the root pulled in ~448k lines across ~107 packages with no incrementality. That meant slow rebuilds, high memory use, and frequent TS6305 / composite emit failures when packages weren't wired as a proper reference graph.

Solution (high level)

Turn the monorepo into a TypeScript solution graph: every workspace package is a composite project with its own references, declaration emit goes to dist/types/, and a generator keeps the graph in sync with package.json workspace deps. Root npm run typecheck is now vue-tsc -b over that graph instead of one giant compile.


1. Shared tsconfig presets (saflib)

Split shared presets from package-specific config — the main structural fix.

Previously, shared presets like @saflib/monorepo/tsconfig.json and @saflib/vue/tsconfig.app.json contained outDir, include, and references. TypeScript resolves those relative to the preset file, not the consuming package, which caused declarations to emit next to source files and broke composite boundaries.

Changes:

  • saflib/monorepo/tsconfig.base.json — shared compiler options only (composite, declaration, emitDeclarationOnly, strictness). No outDir.
  • saflib/monorepo/tsconfig.json — monorepo package's own solution root.
  • saflib/vue/tsconfig.app.base.json — exported Vue preset (no package-specific include/references). Overrides @vue/tsconfig's noEmit: true, adds types: ["vite/client"].
  • saflib/vue/tsconfig.app.json — @saflib/vue's own compile config with outDir: ./dist/types.
  • saflib/vue/package.json — exports ./tsconfig.app.json → ./tsconfig.app.base.json so consumers extend the preset, not the package's leaf config.

Per-package pattern (applied across ~109 packages via generator):

{
  "extends": "@saflib/monorepo/tsconfig.json",
  "compilerOptions": {
    "outDir": "./dist/types",
    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo",
    "noEmit": false,
    "emitDeclarationOnly": true
  },
  "references": [ /* generated from workspace deps */ ]
}

Vue packages keep the tsconfig.app.json / tsconfig.node.json split; external references always point at a package's root tsconfig.json, never the .app leaf.


2. Reference graph generator (@saflib/imports)

New saf-imports references commands:

Command Purpose
references cycles Detect package-level dependency cycles
references generate --write Patch every package's references + solution roots
references check CI gate — fail on drift or cycles

Generator rules (saflib/imports/src/references/generate.ts):

  • Build graph from workspace dependencies ∪ devDependencies
  • ensurePackageEmitOptions() — per-package outDir, noEmit: false, emitDeclarationOnly: true, exclude dist/types and workflow templates
  • Don't patch exported presets (tsconfig.base.json, tsconfig.app.base.json)
  • Solution roots:
    • pathclerk root → { "./saflib" } hub + daemon/deploy leaves
    • saflib root → all saflib leaf packages (submodule standalone CI)
  • Monolith special-case: @pathclerk/daemon-monolith references every daemon/service/* package
  • Skip workflow templates and published dist/ artifacts (e.g. saflib-workflows) from the graph
  • Idempotent — second run produces no diff

3. Breaking dependency cycles (Phase 3.5)

composite forbids reference cycles. ~11 cycles across 4 clusters were broken with behavior-preserving refactors:

  • org-context ↔ sdk — demoted imports, moved shared types
  • PDF/forms ↔ service-common — moved helpers to @pathclerk/daemon-forms
  • saflib tooling cycles — express/kratos split, vue/sdk dep demotions
  • anthropic ↔ common — completeProposedFormDataChanges aliased to emptyProposedFormDataChanges in @pathclerk/daemon-spec/empties

Result: 0 reference cycles.


4. Cross-package import fixes

Composite + emitDeclarationOnly requires package-boundary imports. Relative imports like ../../common/foo.ts pull source into the wrong compilation unit → TS6059/TS6307/TS7056.

Pattern applied across daemon clients, sdk, saflib/vue:

  • Client main.ts/strings.ts → @pathclerk/daemon-clients-common/...
  • Form-editor → @pathclerk/daemon-admin-clients/processing-forms-bundle-plugin (deleted shim)
  • Anthropic → @pathclerk/daemon-spec/empties
  • Auth e2e fixtures → @pathclerk/daemon-account-spa/fixtures
  • saflib/vue/src/events.ts → @saflib/links

package.json exports were added/updated where needed; references generate --write run after export changes.


5. Declaration emit edge cases

Composite emit surfaced typing issues hidden by the monolithic build:

  • SdkMutation<T> helper (daemon/service/sdk/mutation-types.ts) — avoids TS7056 on large mutation hook exports; impl/export + cast pattern for hooks that exceed serializer limits
  • Query return types — removed QueryOptions<unknown, Error> annotations that broke useQuery() overloads; used proper wire types (MatterDossier, ListPacketReviewContextMattersData) or : any on exports that hit TS7056
  • Loader return types — explicit UseQueryReturnType<any, any> on page loaders for AsyncPage compatibility
  • assets.d.ts — restored at saflib/vue/src/assets.d.ts and daemon/clients/root/assets.d.ts for CSS module declarations
  • vitest-config.d.ts — UserConfig instead of UserConfigExport for mergeConfig compatibility

6. Emit hygiene (.gitignore)

Composite emit initially wrote .d.ts next to source (bad outDir resolution). After fixing presets:

  • Cleaned ~147 stale co-located .d.ts files
  • .gitignore (pathclerk + saflib): **/dist/types/, *.tsbuildinfo, **/*.d.ts.map, co-located **/*.d.ts with exceptions for hand-authored assets.d.ts and vitest-config.d.ts
  • Stale build note: deleting dist/types/ without clearing node_modules/.tmp/*.tsbuildinfo causes false "up to date" skips → mass TS6305. Fix: --force after clean.

7. Scaffolding & CI

  • Scaffolded missing tsconfig.json for daemon/dev, workflow template dev package
  • Removed empty tsconfig.node.json refs from packages without a vite.config.ts (saflib/vue, daemon/clients/app)
  • CI: references check added before npm run typecheck in pathclerk and saflib workflows
  • Docs: saflib/imports/docs/03-project-references.md — dev loop, generate/check, cycle troubleshooting, stale incremental builds
  • Baseline: extended saf-imports baseline generate with rootBuildWallMs and warmSinglePackageWallMs; updated baseline.json and spec success metrics

Results

Metric Before After
Root typecheck (warm) ~200s serial / crash ~1.3s
Single package warm (http) n/a ~3.4s
Reference cycles unknown 0 (CI enforced)
Typecheckable packages 0 composite 109 with generated refs
Peak RSS (warm root) 1.93 GB (serial) ~230 MB

Import graph module counts unchanged (M1 scope was wiring only, not barrel deletion — that's M2).


What this enables

  • Cheap typecheck gate on every commit (~1–3s warm) for M2–M9 non-regression checks
  • Package boundaries enforced by TypeScript — cross-package imports must go through package.json exports
  • Mechanical hygiene — references generate --write + CI references check keeps the graph from rotting

Out of scope (deferred to M2+)

  • Root barrel deletion / deep imports
  • sideEffects audit
  • Import budget CI mode change (still warn)
  • Per-resource spec/SDK export granularity

sderickson and others added 14 commits August 7, 2026 15:49
Package-level reference graph from workspace deps and devDeps, with
tsconfig entry resolution and a generate preview stub (--write no-op).

Pathclerk cycle inventory (resolve before phase 5 CI gate):
1. @pathclerk/daemon-clients-common -> @pathclerk/daemon-sdk -> ...
2. @saflib/env -> @saflib/commander -> @saflib/node -> ...
3. @saflib/express -> @saflib/ory-kratos -> ...
4. @saflib/sdk -> @saflib/vue -> @saflib/ory-kratos-sdk -> ...
5. @saflib/sdk -> @saflib/vue -> ...
6. @saflib/vue -> @saflib/ory-kratos-sdk -> ...

Also refresh list-importers spot-check baseline 1071 -> 1151.

Co-authored-by: Cursor <cursoragent@cursor.com>
Add composite/declaration/emitDeclarationOnly to the monorepo and Vue
presets, scaffold packages with dist/types emit + tsc -b, and ignore
generated declaration/incremental artifacts.

Co-authored-by: Cursor <cursoragent@cursor.com>
@sderickson
sderickson merged commit 126ce66 into main Aug 8, 2026
8 checks passed
@sderickson
sderickson deleted the 2026-08-07-references-cycles branch September 10, 2026 20:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant