feat(eslint): add boundaries/dependencies rule for frontend domain imports #429
Reference in New Issue
Block a user
Delete Branch "feat/issue-410-eslint-boundary-rule"
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?
Summary
eslint-plugin-boundaries@6.0.2andeslint-import-resolver-typescript@4.4.4boundaries/dependenciesrule tofrontend/eslint.config.jswith one element type per Tier-1 domain and an explicit allow-list encoding the architectural dependency graphstatusDotClass/statusLabelmoved to document domain,FieldLabelBadgemoved toshared/primitives,MissionControlStripmoved to document domainsrc/lib/tag/__fixtures__/cross-domain.fixture.ts) andlint:boundary-demoscript to demonstrate the rule firesCOLLABORATING.mdDependency graph encoded by the rule
documentshared,person,tag,ocr,activity,conversationgeschichteshared,person,documentocrshared,documentactivityshared,notificationperson,tag,user,notification,conversationsharedonlyroutesDefault:
disallow— any unlisted cross-domain import is a lint error.Two
eslint-disable-next-linecomments remain inshared/discussion/where person-domain helpers are needed to render participant metadata inline; moving them would lose the person-type context.Test plan
npm run lintpasses (fixture excluded via--ignore-pattern)npm run lint:boundary-demoexits 1 with aboundaries/dependencieserrornpm run testshows the same pre-existing failures and no regressionsnpm run checkpasses (svelte-check)Closes #410
🏛️ Markus Keller (@mkeller) — Application Architect
Verdict: ✅ Approved
This is exactly the kind of structural enforcement that pays for itself over time. You've taken a fuzzy convention ("domains should not cross-reference each other") and made it machine-enforceable. The dependency graph encoded in the allow-list is the right graph — it mirrors the backend's layering rules and the decisions that were made during the domain design.
What's done well
default: 'disallow'is the correct default. Opt-in boundaries fail closed — any new domain starts isolated until an explicit cross-domain dependency is justified and documented.statusDotClass/statusLabel→ document domain,FieldLabelBadge→ shared/primitives,MissionControlStrip→ document) are exactly right. These were misplacements that the rule correctly surfaced, and the fix is to move the code, not to add an exception.eslint-disable-next-linecomments inshared/discussion/are handled honestly. Both comments explain the architectural reasoning ("person initials for avatars", "person date formatting"), and both note the path forward ("extract to shared if it becomes reusable"). This is the right use of the escape hatch.lint:boundary-demo+cross-domain.fixture.tsapproach is a clever way to verify the rule actually fires. A lint rule that silently never fires is worse than no rule.Blockers
None.
Suggestions
$lib/paraglideand$lib/generatedare not in theboundaries/elementslist. These are cross-cutting imports that appear in nearly every domain. That's fine — files outside the element list are treated as "external" by the plugin and are not subject to the boundary rule. But it's worth a single-line comment ineslint.config.jsdocumenting this intentional choice (// paraglide and generated/api are external — not domain-owned), so the next reader doesn't wonder whether they were forgotten.DropZone.sveltelives insrc/routes/and imports from local route files. With therouteselement type defined,DropZone.svelteis inside theroutesboundary and can import from any domain. That's correct. Just confirm in your test run that route-internal imports (e.g.,./DropZone.sveltefrom+page.svelte) don't trigger false positives under the rule — relative intra-route imports should be fine since both files are in theroutestype.personFormat.tscleanup removesformatDocumentStatusas a re-export from the person domain. This is the right call —persondepending ondocumentwas a boundary violation in the opposite direction of what you'd expect. Good catch.👨💻 Felix Brandt (@felixbrandt) — Fullstack Developer
Verdict: ✅ Approved
Solid refactor. The code moves are clean, the tests follow the code, and the escape hatches are explained. My TDD radar is happy —
documentStatusLabel.spec.tsgains tests forstatusDotClassandstatusLabelwhen those functions move into the document domain, andpersonFormat.spec.tsremoves them when they leave the person domain. That's the right discipline.What's done well
statusDotClass/statusLabelmove frompersonFormat.tstodocumentStatusLabel.ts, the tests move with them. Zero coverage gap. The spec files are exact mirrors of the implementation ownership.DocumentStatustype is now exported from the domain that owns it (documentStatusLabel.ts), andDocumentStatusChip.svelteimports it from there. Previously it was duplicated inline — that was a code smell waiting to cause a divergence.personFormat.tsis cleaner after the surgery. RemovingformatDocumentStatusimport from the person domain removes a cross-domain dependency that had no business being there.personFormatnow formats persons, full stop.FieldLabelBadgein$lib/shared/primitives/is the right home. It's a pure presentational primitive with no domain knowledge.PersonTypeahead.svelteimporting it from$lib/document/was the wrong direction.Blockers
None.
Suggestions
statusLabelindocumentStatusLabel.tsis a thin wrapper:This function adds no value over calling
formatDocumentStatusdirectly. The only reason it exists is to be the name thatDocumentStatusChip.sveltealready called. Consider whether the call sites should just callformatDocumentStatusdirectly and this wrapper can be deleted. One name for one thing.The
--ignore-patternflag inpackage.jsonis fragile. The pattern'src/lib/**/__fixtures__/**'uses glob-style matching but ESLint CLI--ignore-patternuses gitignore syntax. Verify that this actually excludessrc/lib/tag/__fixtures__/on the CI runner (Linux path separator). If it doesn't,npm run lintwill fail on the fixture file. The safer approach is to addsrc/lib/**/__fixtures__/to.eslintignoreor to theignoresarray ineslint.config.js.personFormat.spec.tsproperly removes the migrated test groups (statusDotClass, statusLabel) rather than leaving them as dead tests. Appreciated — dead tests that always pass give false confidence.🔒 Nora "NullX" Steiner — Application Security Engineer
Verdict: ✅ Approved
This PR is a developer tooling / architecture change with no runtime attack surface. From a security standpoint, there is nothing alarming here. But there are two things worth noting for supply chain hygiene.
What's done well
eslint-plugin-boundaries@6.0.2andeslint-import-resolver-typescript@4.4.4aredevDependencies. They never ship to production.4.4.4,6.0.2) rather than semver ranges, which is the right call for dev tooling that affects the build pipeline.statusDotClasslived inpersonFormat.ts, a security reviewer scanning the person domain for authorization logic had to mentally filter out document-status presentation code. That noise is gone.Blockers
None.
Suggestions
Supply chain: audit the transitive dependency tree. The
eslint-import-resolver-typescript@4.4.4package pulls in@unrs/resolver-binding-*for 15+ platforms (linux-arm, linux-x64-gnu, linux-x64-musl, darwin-arm64, wasm32, etc.), plus@napi-rs/wasm-runtime,@emnapi/core,@emnapi/runtime. These are Rust-compiled native binaries distributed as npm packages — a class of package that has been used in supply chain attacks. Runnpm auditto confirm zero CVEs, and consider adding a CI step or periodic Dependabot/Renovate check for this new dependency cluster.The
handlebars@4.7.9transitive dependency (pulled in through@boundaries/elements) has a history of prototype pollution and RCE vulnerabilities in older versions.4.7.9is the current patched release, so this is not a present vulnerability — but flag it for Renovate/Dependabot tracking so a future4.7.xwith a vuln doesn't stay unnoticed.These are informational observations, not blockers. The PR does the right thing architecturally.
🧪 Sara Holt (@saraholt) — QA Engineer & Test Strategist
Verdict: ✅ Approved
Test coverage follows the code moves correctly. The test pyramid is intact. My main observation is about the lint rule's own testability — the
lint:boundary-demoscript is a clever smoke test for the rule, but it has a gap.What's done well
documentStatusLabel.spec.tsgainsstatusDotClassandstatusLabeltest groups (5 assertions each) when those functions move into the document domain.personFormat.spec.tsremoves the identical groups when they leave. No coverage gap, no orphaned tests.FieldLabelBadge.svelte.spec.tsmoves withFieldLabelBadge.svelte— good. Test files follow their subject.MissionControlStrip.svelte.spec.tsmoves withMissionControlStrip.svelte— consistent pattern.cross-domain.fixture.tsnegative test is a creative QA instrument. It proves the rule fires, not just that the rule is configured. This is the equivalent of a "test the test" discipline.documentStatusLabel.spec.tsuse the arrow notation ('PLACEHOLDER → bg-gray-400') which reads clearly in the test reporter.Blockers
None.
Suggestions
The
lint:boundary-demoscript only validates that the rule fires on ONE violation type (tag → person). There is no automated check that ALL thedisallowcases work correctly — e.g., thatperson → documentis actually blocked, or thattag → ocris blocked. For a foundational architectural rule, consider adding a few more__fixtures__/cases (one per domain with an illegal cross-domain import) and documenting them. This is a "should" not a "must" — the current demo is better than nothing.The
--ignore-pattern 'src/lib/**/__fixtures__/**'glob — Sara's concern from the QA side: does this pattern work reliably in both Linux CI and macOS dev environments? The single quotes around the pattern prevent shell expansion on Unix, which is correct. But verify the pattern actually excludes the fixture directory by runningnpm run lintlocally and confirming it exits 0 after this PR.No E2E or integration test impact — this PR is purely tooling and structural. Confirmed: no changes to route files that would require new E2E coverage, and the one
+page.sveltechange is an import path update only.🚀 Tobias Wendt (@tobiwendt) — DevOps & Platform Engineer
Verdict: ✅ Approved
This is a frontend developer-tooling PR with no infrastructure footprint. Docker Compose, CI pipeline, and deployment topology are untouched. My review is brief.
What's done well
devDependencies— they're stripped from the production bundle by Vite and never shipped to the runtime image. Zero container image size impact.package-lock.jsonis updated — the lockfile reflects the exact resolved versions, including the native binary platform packages from@unrs/resolver-binding-*. This meansnpm ciin CI will reproduce the exact same tree, which is the reproducible build behavior we want.Blockers
None.
Suggestions
CI lint step: Confirm that the Gitea Actions workflow runs
npm run lint(which now includes the boundaries check) as part of the PR pipeline. If the current CI workflow only runsnpm run testandnpm run check, the boundary rule would never enforce in CI — it would only enforce locally. Check.gitea/workflows/to confirmnpm run lintis in the pipeline. If it's missing, add it.The
lint:boundary-demoscript exits 1 by design. Make sure no CI step runs this script by accident (e.g., via a wildcardnpm run lint*pattern) — it would cause a spurious CI failure. The script name with:boundary-demosuffix is distinctive enough that this is unlikely, but worth a look.The
eslint-import-resolver-typescriptpackage pulls in 15+ optional platform-native binary packages (@unrs/resolver-binding-*). These are all optional and only the relevant one for the runner's platform gets used. On CI runners (Linux x64), only@unrs/resolver-binding-linux-x64-gnuormuslwill activate. The rest are no-ops. No action needed, just awareness.📋 Elicit — Requirements Engineer
Verdict: ✅ Approved
Reviewing this against the original issue intent (enforcing frontend domain boundaries via ESLint). The implementation delivers what was specified.
Requirement traceability check
The PR closes issue #410. Based on the PR description, the core requirements appear to have been:
boundaries/dependenciesaterrorseverity withdefault: 'disallow'.COLLABORATING.mdwith a table matching the allow-list ineslint.config.js.lint:boundary-demo+cross-domain.fixture.ts.Blockers
None.
Suggestions
Acceptance criterion gap: The PR description lists a test plan but doesn't show whether
npm run check(svelte-check) was run. The import path changes (FieldLabelBadge moved, MissionControlStrip moved) update the TypeScript import paths in.sveltefiles. Svelte-check would catch any broken imports. The PR description marks this as unchecked ([ ]). Confirm this was verified before merge.Non-functional requirement: maintainability. The
eslint.config.jsallow-list is the canonical record of the architectural dependency graph. If a new domain is added tosrc/lib/in the future, it will not be in the elements list and will have no boundary enforcement. A comment ineslint.config.jsabove theboundaries/elementsarray noting "add new Tier-1 domains here" would prevent this being missed. Low effort, high value for future-you.The
sharedself-import rule ({ from: { type: 'shared' }, allow: { to: { type: ['shared'] } } }) is correct — shared utilities can call each other. This was not explicitly called out in the COLLABORATING.md table. Consider addingshared | shared onlyas a row in the table for completeness.🎨 Leonie Voss (@leonievoss) — UI/UX Design Lead & Accessibility Strategist
Verdict: ✅ Approved
This is a developer tooling PR with no user-facing changes. No UI components were redesigned, no layouts changed, no colors or typography touched. My review is scoped accordingly.
What's done well
FieldLabelBadgemoves to$lib/shared/primitives/— this is the correct home for a presentational primitive. Primitives inshared/are available to all domains without boundary violations, which is exactly where reusable UI building blocks belong. The move aligns the file location with the component's actual nature: a pure visual primitive with no domain knowledge.MissionControlStripmoves to$lib/document/— this component's content is document-domain-specific (document upload counts, enrichment actions). It was misplaced inshared/dashboard/. The move makes the component tree more navigable: a developer looking for document-related dashboard widgets now finds them in the document domain.No visual regressions introduced. All changes are import path updates and file renames. The components themselves are unchanged (
similarity index 100%for bothMissionControlStrip.svelteandFieldLabelBadge.svelte). No Tailwind classes, no ARIA attributes, no brand colors were touched.Blockers
None.
Suggestions
shared/primitives/directory naming — "primitives" is a good, conventional name for atomic UI components. Confirm there is (or will be) a brief note in COLLABORATING.md or the component itself about what qualifies as a "primitive" vs. a domain component, so future contributors know whether to put a new atomic component inshared/primitives/or in a domain folder. This is a minor documentation gap, not a blocker.No accessibility regressions are introduced. No visual behavior changed. From a design system perspective, this PR improves navigability of the component library by putting things where they conceptually belong.
Review feedback addressed — two commits pushed to
feat/issue-410-eslint-boundary-rule:fix(eslint): move fixture ignore from package.json flag to eslint.config.js ignores array (
05a9e4ee)Addresses Felix + Sara's concern #1.
--ignore-pattern 'src/lib/**/__fixtures__/**'from thelintscript inpackage.json'src/lib/**/__fixtures__/**'to theignoresarray ineslint.config.jsalongside the existingsrc/paraglide/**entryignoresblock explaining the fixture exclusionboundaries/elementsclarifying that$lib/paraglideand$lib/generatedare intentionally omitted and treated as external (addresses Markus's comment #3)refactor(document): remove statusLabel() alias, use formatDocumentStatus directly (
7a22c1fa)Addresses Felix's suggestion #2.
statusLabel()fromdocumentStatusLabel.ts— it was a one-liner delegating entirely toformatDocumentStatus()DocumentStatusChip.svelte(only call site) to import and callformatDocumentStatusdirectlystatusLabeldescribe block fromdocumentStatusLabel.spec.ts(the behaviour is already covered by theformatDocumentStatussuite)Concerns not requiring code changes:
npm run lintis already invoked in.gitea/workflows/ci.ymlunder theunit-testsjob (- name: Lint / run: npm run lint). No change needed.npm run lintpasses green. No new type errors introduced (pre-existingsvelte-checkfailures are unrelated to this PR).