Skip to content

Enforce alias-only imports across every TypeScript workspace #53

Description

@gabitoesmiapodo

Priority

Low — nice to have, can wait

User story / Problem statement

Currently a module has two legal spellings and nothing decides between them. @/components/toast and ../components/toast both resolve, both pass lint, both pass CI, so which one lands depends on who or what wrote the file. Biome's only import rules today are the file-extension ban and the testing/ boundary (biome.json:83-133); neither touches relative paths.

The three TypeScript workspaces have drifted apart as a result:

Workspace Alias state Relative imports
dapp/frontend @/* wired in both tsconfig.app.json and vite.config.ts 57 in 31 files
canton-dappbooster no alias at all 32 in 14 files
canton-connect-kit paths entry present, zero files use it (dead config) 37 in 13 files
canton-barebones no alias; NodeNext, so .ts extensions are load-bearing 26 in 12 files

That is 152 relative specifiers across 70 files, and one workspace whose alias config is a trap waiting to fail open.

Expected outcome

Exactly one legal way to reference a module, enforced by lint rather than convention, so neither a human nor an agent can choose otherwise.

  • Applications import through the @/ alias.
  • Libraries import through Node subpath imports (#-prefixed, declared in their own package.json), because @/ is actively unsafe in a package consumed as source.
  • Relative specifiers are a lint error everywhere, with narrow documented exceptions where no alias can express the target.
  • Built output contains no alias specifier of either kind.

Acceptance criteria

  • Relative import specifiers (./*, ./**, ../*, ../**) are a Biome error across dapp/frontend, canton-dappbooster, canton-connect-kit, and canton-barebones
  • dapp/frontend/src reaches every intra-src module through @/
  • canton-dappbooster declares an imports map in its package.json and reaches every internal module through it
  • canton-connect-kit does the same, and its unused paths entry (canton-connect-kit/tsconfig.json:19) is removed
  • Biome enforces which prefix belongs where: @/ is an error inside a library, so the app's alias cannot leak into a published package
  • The rule is verified to catch all four specifier positions: import ... from, export { x } from, export * from, and dynamic import()
  • Suppressions exist only where an alias genuinely cannot express the target, each carrying a reason
  • canton-barebones is either brought in or explicitly exempted in CLAUDE.md, with the reason recorded (see Technical notes)
  • dist output for both libraries contains no @/ or # specifier
  • Root pnpm lint, pnpm typecheck, pnpm build, pnpm test, and pnpm knip all pass
  • The import rule in CLAUDE.md under File & Folder Organization is updated to state the per-package convention

Alternatives considered

Ban only ../, keep ./sibling legal. Costs zero rewrites and removes the cross-folder ambiguity. Rejected because a sibling module would still have two spellings depending on where it is imported from, which is the ambiguity being removed.

Use @/ in the libraries too. Verified broken. canton-dappbooster exports "development": "./src/index.ts", so dapp/frontend compiles the library's source through its own Vite, where @ is aliased to the app's src. A library-internal @/lib/util resolves into the consumer's tree. Reproduced as a hard MISSING_EXPORT build failure; had both files exported the same symbol name it would have silently bundled the wrong module.

Keep tsconfig paths in the libraries and let the build rewrite them. Works for canton-dappbooster, whose tsdown bundle erases internal specifiers. Does not work for canton-connect-kit, which has no build script at all and exports ./src/index.ts directly, so nothing would ever rewrite the specifier before a consumer saw it.

Technical notes

Rule. style/noRestrictedImports with a patterns group matching the literal specifier text, the same mechanism the existing extension ban already uses. Verified on the pinned Biome 2.5.5 to catch all four specifier positions listed in the acceptance criteria.

Override merge semantics. Biome replaces a rule's options on the last matching override rather than deep-merging them. dapp/frontend/src/** already carries a noRestrictedImports block at biome.json:103-133; appending a new override for the same paths would silently drop the extension and testing/ patterns. The new pattern has to join that existing array, or the block has to restate all of them. The broader override at biome.json:83-102 is the wrong home, since it also spans the two libraries.

Subpath imports. Declared in the library's own package.json, and bound by spec to the nearest package.json, so a consumer alias cannot capture them. Verified: a library file and a consumer file with the same path both resolved to their own copy in a single bundle.

"imports": {
  "#src/*": "./src/*.ts",
  "#components/*": "./src/components/*.tsx"
}

Three constraints found by testing, the third worth care:

  1. #/* is an illegal key. Node reserves the bare # specifier and anything beginning #/. Use a segment, for example #src/*.
  2. The mapping target must carry its file extension even though the import specifier does not. "./src/*" fails to resolve under tsc.
  3. A fallback array such as ["./src/*.ts", "./src/*.tsx"] typechecks cleanly but rolldown does not resolve it. tsdown then exits 0 while emitting a bare import { Widget } from "#src/components/Widget" into dist/index.js, which is a broken published artifact from a green build. Use one key per extension instead.

No build changes needed. tsdown bundling already inlines every internal module, so once a specifier resolves at build time it disappears from both dist/index.js and dist/index.d.ts. Confirmed against canton-dappbooster's exact tsdown config.

canton-barebones is the open question. It runs on NodeNext where the .ts extension is load-bearing, and CLAUDE.md exempts it from the extensionless-import rule for that reason. Subpath imports would work there, but the interaction with its tsx execution path needs checking before committing. Exempting it explicitly is an acceptable outcome; leaving it undecided is not.

Effort. Biome offers no autofix for this rule, so the 152 rewrites are a codemod or a manual pass. tsc catches any path written wrong, so the result is verifiable. There are currently no vi.mock('../x') or require('../x') call sites anywhere in the repo; the rule cannot see those, so they would remain a blind spot if introduced later.

Additional context

Findings above were verified locally against the pinned toolchain: Biome 2.5.5, TypeScript 5.9.3, tsdown 0.22.14 on rolldown 1.2.0, Vite 8.1.5.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions