You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
Three constraints found by testing, the third worth care:
#/* is an illegal key. Node reserves the bare # specifier and anything beginning #/. Use a segment, for example #src/*.
The mapping target must carry its file extension even though the import specifier does not. "./src/*" fails to resolve under tsc.
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.
Priority
Low — nice to have, can wait
User story / Problem statement
Currently a module has two legal spellings and nothing decides between them.
@/components/toastand../components/toastboth 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 thetesting/boundary (biome.json:83-133); neither touches relative paths.The three TypeScript workspaces have drifted apart as a result:
dapp/frontend@/*wired in bothtsconfig.app.jsonandvite.config.tscanton-dappboostercanton-connect-kitpathsentry present, zero files use it (dead config)canton-barebones.tsextensions are load-bearingThat 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.
@/alias.#-prefixed, declared in their ownpackage.json), because@/is actively unsafe in a package consumed as source.Acceptance criteria
./*,./**,../*,../**) are a Biome error acrossdapp/frontend,canton-dappbooster,canton-connect-kit, andcanton-barebonesdapp/frontend/srcreaches every intra-srcmodule through@/canton-dappboosterdeclares animportsmap in itspackage.jsonand reaches every internal module through itcanton-connect-kitdoes the same, and its unusedpathsentry (canton-connect-kit/tsconfig.json:19) is removed@/is an error inside a library, so the app's alias cannot leak into a published packageimport ... from,export { x } from,export * from, and dynamicimport()canton-barebonesis either brought in or explicitly exempted inCLAUDE.md, with the reason recorded (see Technical notes)distoutput for both libraries contains no@/or#specifierpnpm lint,pnpm typecheck,pnpm build,pnpm test, andpnpm knipall passCLAUDE.mdunder File & Folder Organization is updated to state the per-package conventionAlternatives considered
Ban only
../, keep./siblinglegal. 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-dappboosterexports"development": "./src/index.ts", sodapp/frontendcompiles the library's source through its own Vite, where@is aliased to the app'ssrc. A library-internal@/lib/utilresolves into the consumer's tree. Reproduced as a hardMISSING_EXPORTbuild failure; had both files exported the same symbol name it would have silently bundled the wrong module.Keep tsconfig
pathsin the libraries and let the build rewrite them. Works forcanton-dappbooster, whose tsdown bundle erases internal specifiers. Does not work forcanton-connect-kit, which has no build script at all and exports./src/index.tsdirectly, so nothing would ever rewrite the specifier before a consumer saw it.Technical notes
Rule.
style/noRestrictedImportswith apatternsgroup 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 anoRestrictedImportsblock atbiome.json:103-133; appending a new override for the same paths would silently drop the extension andtesting/patterns. The new pattern has to join that existing array, or the block has to restate all of them. The broader override atbiome.json:83-102is 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 nearestpackage.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.Three constraints found by testing, the third worth care:
#/*is an illegal key. Node reserves the bare#specifier and anything beginning#/. Use a segment, for example#src/*."./src/*"fails to resolve undertsc.["./src/*.ts", "./src/*.tsx"]typechecks cleanly but rolldown does not resolve it. tsdown then exits 0 while emitting a bareimport { Widget } from "#src/components/Widget"intodist/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.jsanddist/index.d.ts. Confirmed againstcanton-dappbooster's exact tsdown config.canton-barebonesis the open question. It runs on NodeNext where the.tsextension is load-bearing, andCLAUDE.mdexempts it from the extensionless-import rule for that reason. Subpath imports would work there, but the interaction with itstsxexecution 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.
tsccatches any path written wrong, so the result is verifiable. There are currently novi.mock('../x')orrequire('../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.