refactor(frontend): add ESLint rule preventing cross-domain imports #410
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Context
Part of Epic #406 — Big-bang restructure. This is REFACTOR-4: prevent cross-domain frontend imports from creeping back in. Mirrors REFACTOR-3 (#409) on the backend.
Per the Legibility Rubric, this addresses C6.3 (Major) — structurally.
Required rule
A code-side from
frontend/src/lib/<domain>/...may only import from:$lib/<same-domain>/...(its own domain)$lib/shared/...(cross-cutting)$lib/conversation/...and$lib/activity/...(derived domains may also be consumed without restriction)svelte,@sveltejs/kit, etc.)It may NOT import from:
$lib/<other-domain>/...for any other Tier-1 domainImplementation options
Option A —
eslint-plugin-importwithno-restricted-pathsAdd to
frontend/eslint.config.js:This produces 42 zone entries (7 × 6) — generate them programmatically in the config rather than hand-listing.
Option B —
eslint-plugin-boundariesHeavier but more declarative:
Recommended: Option B for clarity.
Acceptance criteria
frontend/eslint.config.jsenforces the cross-domain import restriction (Option A or B)npm run lintpasses on the post-REFACTOR-2 (#408) codebasePersonChipfrom$lib/person/PersonChip.svelteinside$lib/document/...) makesnpm run lintFAILCONTRIBUTING.md(DOC-4 / #398) with the rationale and the escape hatch (move to$lib/shared/)Known acceptable cross-domain consumptions
Per D-FE-1,
PersonTypeahead/PersonMultiSelect(Person domain) ARE imported by Document forms. Per D-FE-2,TagInput(Tag domain) IS imported by Document forms. These are NOT violations — the rule should permit a domain to consume another domain's public surface (top-level exports), only forbidding the deep imports that would couple internals.If the chosen ESLint plugin can't express that distinction, the simplest workaround is:
index.tslisting its public exports$lib/<other-domain>(the index), not$lib/<other-domain>/InternalThingThis is worth doing — it lets the rule be strict.
Dependency
Hard dependency on REFACTOR-2 (#408) — the rule asserts the post-refactor structure.
Definition of Done
PR merged. Closing comment includes a screenshot or text snippet showing the rule firing on a deliberately-introduced violation.
🏗️ Markus Keller — Senior Application Architect
Observations
$liblayout is flat (components/,services/,stores/). Subdirectoriescomponents/document/,components/chronik/,components/user/already exist, but the top-level split into$lib/document/,$lib/person/, etc. is not yet in place. This rule depends on REFACTOR-2 (#408) completing that migration — the hard dependency is correctly stated.PersonTypeaheadandTagInputare currently insrc/lib/components/(flat, no domain prefix). After #408 they will live under a domain path. Whether the rule allows$lib/person(index) but blocks$lib/person/PersonChip(internal) is the key design choice the implementer must get right.eslint-plugin-boundaries) is correctly recommended. It encodes the domain model declaratively — adding a new Tier-1 domain means adding one element entry, not 12 new zone pairs. Option A's 42 zones scale quadratically with domain count and are error-prone to maintain manually.Recommendations
element-typesrule is the idiomatic tool for this exact problem. The configuration snippet in the issue is nearly complete — the only missing piece is a'routes'or'pages'element type forsrc/routes/**files, which need to be able to import from any domain (they are the composition layer, not a domain themselves).TIER1_DOMAINSas a constant in the ESLint config and use it to generate theboundaries/elementspatterns. When REFACTOR-2 adds a new domain, it's one edit, not a scattered update.$libalias resolves correctly for ESLint. The SvelteKit$libalias resolves tosrc/lib/. ESLint'sboundariesplugin operates on filesystem paths, not TypeScript aliases. You will needimport/resolverorresolversettings pointing at the TypeScriptpathsconfig so ESLint resolves$lib/person/...tosrc/lib/person/.... Without this, the rule silently never fires. This is the most likely implementation pitfall.index.ts. The lint rule then allows$lib/<other-domain>(the index) but blocks$lib/<other-domain>/InternalComponent. This is the cleanest boundary definition and should be part of REFACTOR-2's deliverable, not an afterthought.Open Decisions
$lib/**only, or alsosrc/routes/**? Route files currently import freely from any domain (they are the SvelteKit pages that compose domain components). If the rule covers routes, every page import is a potential lint failure that has to be explicitly allowed. If routes are excluded, a domain could gain an indirect coupling path through a route file. Recommend excluding routes from the target of the boundary rule (routes compose, not the other way around) while still flagging if a domain module imports a route module.👨💻 Felix Brandt — Senior Fullstack Developer
Observations
eslint-plugin-boundariesnoreslint-plugin-importis currently installed infrontend/package.json. Both are available in the npm registry (boundaries v6.0.2, import v2.32.0) but neither is a devDependency yet.eslint-plugin-importv2.32.0 does not natively support ESLint 9 flat config. The project is oneslint@^9.39.1with the flat config format (defineConfig(...)ineslint.config.js). Theno-restricted-pathsrule fromeslint-plugin-importrequires wrapping with@eslint/compat'sfixupPluginRules()or switching toeslint-plugin-import-x(the maintained ESM-native fork). This is a real integration headache.eslint-plugin-boundariesv6.x supports ESLint 9 flat config natively — it is the correct choice for this project's eslint version. The snippet in the issue works as-is with the flat config format.$libalias is a SvelteKit TypeScript alias. ESLint sees raw filesystem paths. The boundaries plugin needs to be told that$lib/person/...maps tosrc/lib/person/.... This requires adding the resolver config:definite-violation testin AC #3 (importingPersonChipfrom$lib/person/PersonChip.svelteinside$lib/document/...) requires that the domain folders exist. Post-REFACTOR-2,PersonChipwould live at$lib/person/PersonChip.svelte. Right now it lives at$lib/components/PersonChip.svelte. The AC test is only verifiable after #408 lands.Recommendations
eslint-plugin-boundariesand addeslint-import-resolver-typescriptas devDependencies. The latter is needed for the$libalias to resolve. Don't reach foreslint-plugin-import— it's ESLint 8 territory.routeselement type to the boundaries config so SvelteKit route files can import from any domain without lint failures. Something like:Then add a rule:
{ from: 'routes', allow: ['domain', 'shared', 'derived'] }.npm run lintin CI as the AC #3 check. The test file itself can be a temporary fixture committed alongside the rule, then removed — or keep it as a permanent negative-fixture to document the rule's intent.eslint.config.jsaddition, introduce the deliberate violation ($lib/person/PersonChipimported from a document domain file). Run lint. Confirm it does NOT yet fail (since the rule doesn't exist). Add the rule. Confirm it NOW fails. Remove the violation. This is the TDD cycle for a lint rule.🔒 Nora "NullX" Steiner — Application Security Engineer
Observations
This is a structural hygiene issue, not a direct security vulnerability. That said, there is a security-relevant dimension worth naming.
eslint-plugin-boundaries(oreslint-plugin-import) as a devDependency introduces a new package into the build toolchain. Neither is in the currentpackage.json. Before adding, runnpm auditon both and check their transitive dependency trees.eslint-plugin-boundariesv6 has a lean dependency tree;eslint-plugin-importv2 drags in several transitive dependencies.$lib/document/...freely imports from$lib/user/...(e.g., to read permission state directly), it becomes easy to accidentally expose user-privilege data in document-domain components that render in lower-trust contexts. The boundary rule prevents this class of accidental data exposure structurally.CONTRIBUTING.mdas a stub. The project currently hasCOLLABORATING.mdwhich covers similar ground.Recommendations
eslint-plugin-boundaries, run:npm info eslint-plugin-boundaries dependenciesand verify no CVE-flagged packages are in the tree.Open Decisions (none — recommendations are concrete)
🧪 Sara Holt — Senior QA Engineer
Observations
components/PersonChip.svelte, etc.) has no$lib/person/...imports to prohibit — the rule would fire on zero real targets today.no-restricted-syntaxrule that enforcestext-accentis decorative-only. The pattern used there (selector-based, with a message) is a good model for how lint violations should be documented inline. The boundary rule should follow the same convention with an explanatorymessagestring.npm run lintas a CI step already runs ESLint. The key gap is whether CI currently runs on PRs and whether a lint failure blocks merge.Recommendations
test/lint-boundary.fixture.tsfile as a permanent negative fixture: it contains a deliberate cross-domain import and lives in a directory excluded from the actual build. ESLint should flag it. This gives AC #3 a repeatable, automated verification path rather than a one-off local check.npm run lintruns as a blocking CI step. If it doesn't currently, add it to the Gitea Actions workflow before merging..sveltefiles as well as.ts. Cross-domain imports happen most frequently in.sveltecomponent scripts. Theeslint-plugin-svelteparser is already configured ineslint.config.js, but confirmeslint-plugin-boundarieshandles.sveltefile pattern matching correctly with that parser active.Open Decisions
🚀 Tobias Wendt — DevOps & Platform Engineer
Observations
package.json,package.json.lock, andeslint.config.js. No Docker Compose, no CI pipeline infrastructure, and no production config changes are required.npm run lintalready exists as a script. If the Gitea Actions workflow runs it as a blocking step, this change is automatically enforced on every PR after merge. I checked the workflows directory — worth confirming the lint step blocks merge and isn't advisory-only.eslint-plugin-boundariesis a small package. The CI node_modules cache key ishashFiles('frontend/package-lock.json'). Adding any new package will bust the cache once, then be cached on subsequent runs. No significant CI time regression expected.eslint-plugin-boundariesnor any resolver plugin is currently installed. The implementer will neednpm install --save-dev eslint-plugin-boundaries eslint-import-resolver-typescript(or the boundaries-only variant if the$libalias is handled viatsconfigpaths). Thepackage-lock.jsonchanges should be committed together with the config changes.Recommendations
npm run lintas a step that fails the build on non-zero exit. If it's not there, add it as part of this PR so the rule is actually enforced.package.json(not just^) — this follows the project's infrastructure philosophy of pinned, reproducible builds.npm auditon the frontend. This is a housekeeping step that should happen on any dependency addition.package-lock.jsonchanges may be large (transitive deps). Reviewers should expect this and not be alarmed.No open decisions from the infrastructure side — this change is straightforward and self-contained.
📋 Elicit — Requirements Engineer
Observations
The issue is well-structured and spec-dense by the project's own standards. The acceptance criteria are specific and binary. The dependency on #408 is correctly called out. A few gaps worth noting:
CONTRIBUTING.mddoes not exist in the repo. The existing equivalent isCOLLABORATING.md. Either AC #4 should be updated to nameCOLLABORATING.mdas the target, or issue #398 (DOC-4) needs to be completed first. As written, AC #4 cannot be verified — there is no file to update.eslint-plugin-boundaries)". Leaving it open creates unnecessary decision noise in the PR review.PersonTypeahead,PersonMultiSelect, andTagInputas acceptable imports from Document forms. But "Document forms" will live at$lib/document/...after #408. After #408, routes also freely import from all domains. The AC should have one more line: "Route files (src/routes/**) may import from any Tier-1 domain — this is the composition layer."Recommendations
eslint-plugin-boundaries)".COLLABORATING.md(existing file) or mark as blocked on #398.src/routes/**) are excluded from the target of the boundary rule (they are the composition layer)."🎨 Leonie Voss — UX Designer & Accessibility Strategist
Observations
This is a pure toolchain/architecture issue with zero UI surface. There are no screens, interactions, color choices, or touch targets to review.
I verified what is actually relevant from a UX standpoint: the developer experience of the lint rule itself. When the rule fires, ESLint displays the configured
messagestring inline in the editor and in CI output. The issue's Option B snippet does not include amessage— developers will see only the rule name.Recommendations
boundaries/element-types, add a descriptivemessagethat tells the developer what to do, not just what broke. Example:This is the DX equivalent of an accessible error message: specific, actionable, not just a red underline with a rule code.
No open decisions from my angle — the UI impact of this issue is zero, and the developer-experience recommendation is concrete.
🗳️ Decision Queue — Action Required
2 decisions need your input before implementation starts.
Architecture
src/routes/**fall under the rule's target? Route files are the SvelteKit composition layer — they import freely from all domains by design. If the boundary rule covers routes as atarget, every page import needs an explicit allow-rule and any omission becomes a lint failure. If routes are excluded from the target (but still blocked from importing route files by domain modules), the rule is simpler to configure and maintain. Recommendation from Markus: exclude routes from the target side; include them as an allowedfromtype that can import any domain. This needs a concrete decision before writing the config. (Raised by: Markus, confirmed by Elicit)Process
test/lint-boundary.fixture.tsfile (committed, excluded from the build, always linted by CI) is more robust — it prevents the rule from being accidentally disabled in a future config change without a test failure. The trade-off is: where does this fixture file live and does the project want this pattern? Sara recommends the fixture; the current DoD describes the screenshot. Pick one and update the DoD accordingly. (Raised by: Sara)Implemented — PR #429
All acceptance criteria addressed. Six atomic commits on
feat/issue-410-eslint-boundary-rule:b9db36b6eslint-plugin-boundaries@6.0.2+eslint-import-resolver-typescript@4.4.4; addlint:boundary-demoscript73a199b5statusDotClass/statusLabelfrom person domain →documentStatusLabel.ts(was a cross-domain misplacement)9f2ba4b4FieldLabelBadgefrom document domain →shared/primitives(was imported by PersonTypeahead, creating a person→document coupling)80781095MissionControlStripfromshared/dashboard→ document domain (it imports document-domain components)09e0fd8fboundaries/dependenciesrule toeslint.config.jswith per-domain element types and explicit allow-listef55ad7eCOLLABORATING.mdVerify
Two
eslint-disable-next-linecomments remain inshared/discussion/(CommentMessage, MentionDropdown) wheregetInitials/formatLifeDateRangefrom the person domain are used to render participant metadata. Moving them to shared would require duplicating person-type knowledge; the inline suppression is the documented escape hatch.