Skip to content

Web lint boundaries

The web lint gate rejects new dependencies against the existing layer direction, cross-feature implementation imports and production imports of test code. It also checks three Angular reactivity and injection-context mistakes. This is development tooling; no feature flag is needed.

Delivery boundary

The minimal slice is executable policy in the current standalone app, with fixtures and CI enforcement. It preserves existing runtime code and the existing ESLint suppression file. Acceptance requires valid imports to pass, representative invalid imports to fail, legacy exceptions to be exact and necessary, and every PR/fork/release web test job to run lint before unit tests. Remediating old dependencies or migrating supported Angular APIs is follow-up work.

The critical path is: inspect the actual graph → configure a maintained resolver/rule → review existing edges → exercise valid/invalid fixtures → run the same lint command locally and in CI.

Evidence and sources

Audited on the PR's base a73ccd60af, rather than the older local main checkout:

Tool Installed version
Angular / TypeScript 22.2.3 / 6.0.3
angular-eslint 22.6.0
ESLint / typescript-eslint 10.8.1 / 8.67.0
NgRx / NgRx ESLint plugin 22.0.1
Added boundaries plugin / TypeScript resolver 7.4.1 / 4.4.5

The actual policies below are SyRF-specific choices inferred from its source and documented direction. They are not Angular requirements. Their implementation follows the installed plugin's element descriptors and dependency policies, with the TypeScript resolver reading the existing tsconfig.json aliases. Plugin peer ranges accept ESLint 10; executable fixtures verify compatibility. The Angular rules are present in the installed angular-eslint 22.6.0 package, rather than assumed from the latest upstream configuration.

What is enforced

src/services/web/eslint.config.boundaries.js uses boundaries/dependencies. A resolved target determines the boundary: changing @app/ to @core/, a relative path or an equivalent path does not evade it. Static imports, type imports, re-exports, literal dynamic imports, require and TypeScript import() types are checked. Runtime-computed module paths and external packages are outside this rule's scope; TypeScript/Angular still resolve and compile the application.

Source Permitted local targets, before legacy exceptions
core/ Core, generated declarations, assets and environments
shared/ and reusable subsystems Core, shared, reusable subsystems, generated declarations and platform files
A feature area Its own area, the common layers, and another area's root *.routes.ts
App shell (src/app/*.ts, bootstrap and other source outside the classified areas) Common layers, shell code, area root routes/components/guards
Generated/platform code Core, generated/platform code; generated feature selectors legitimately depend on core
*.spec.ts, src/app/testing/ Any application or test code, for integration fixtures
Production code, including an import inside the same area Never a spec or test helper

pdf-tools/, pdf-viewer/ and study-presence/ are explicitly recognized reusable subsystems. They already serve multiple hosts; classifying them as isolated routed features would misrepresent the app. Their internal APIs remain open to common-layer consumers in this slice. All other top-level app folders automatically become feature areas, including new folders. Feature areas here are the current route folders, not a claim that each is a fully isolated domain or bounded context.

Concrete examples:

  • project/project-statistics.store.ts can import core selectors. A new import of that store from a core service fails, because global infrastructure cannot depend on a feature's state.
  • A feature can lazy-import admin/admin.routes.ts. Importing stage/stage-review/stage-review.component.ts from another feature fails unless that exact old edge is recorded. Area routes are the current public composition entry points; this does not require barrels.
  • The app shell can lazy-load study-source-window/study-source-window.component.ts as it does today. That exception to route-file composition applies only to a component or guard at the area's root, not arbitrary nested feature code. The rule does not prove that an allowed import is lazy.
  • core/actions/project-detail.actions.ts already imports AddSearchData from the create-search component. That exact file pair is temporarily allowed. Adding an admin-component import in the same action file fails. Another core file importing the create-search component also fails.
  • A component spec can import @testing/mock-config. A production component cannot import it, nor can it import a sibling *.spec.ts to obtain a mock. Internal imports are checked explicitly.

Three additional maintained Angular rules run on hand-written production src/app/**/*.ts, excluding specs, *.generated.ts and src/app/generated/. They do not rewrite constructor DI, inputs, outputs, forms or change detection:

Rule Concrete invalid/valid pattern and codebase relevance
@angular-eslint/no-uncalled-signals if (this.scanning) tests a signal function; if (this.scanning()) reads its value. shared/keyword-highlight/keyword-panel/keyword-panel.component.ts uses the valid form.
@angular-eslint/computed-must-return computed(() => { this.active(); }) omits its value; computed(() => this.active()) returns it. project/project-statistics.store.ts has block computations returning coherent summaries.
@angular-eslint/no-implicit-take-until-destroyed start() { stream.pipe(takeUntilDestroyed()) } lacks an injection context; pass an injected DestroyRef. Constructor use remains valid, as in notifications/notification-inbox-button.component.ts.

These follow Angular's signal getter semantics and DestroyRef requirements, and the tagged angular-eslint rule docs. The computed rule detects an absent return, not every missing branch. These checks supplement compiler diagnostics and tests; they do not establish correctness of an entire reactive flow.

The existing recommended Angular/template rules, NgRx all configuration, naming conventions, animation-frame-scheduler restriction and Handsontable hook selectors remain active. External HTML and inline templates retain angular-eslint's recommended control-flow checks and template processor. The existing NgRx all configuration is retained to avoid silently dropping checks during this change; it is not a recommendation to enable every rule in other plugins.

Incremental enforcement and exceptions

The initial strict architecture scan reported 122 declarations / 120 distinct file pairs. Eight pairs are legitimate app-shell route entries covered by the public-entry policy. It initially recorded 112 legacy pairs. Studies Activity (#4723) also landed on main before this gate; 12 additional pairs remain after pruning a removed route-guard import: the project route's workspace guard and three Activity files' processing/report contracts. The first-enforcement allowlist contains 124 pairs in 68 source files, recorded in eslint-boundary-exceptions.json with reasons. These additional pairs preserve already-shipped behavior; they do not widen any layer or public-entry policy. This includes core UI composition, feature-owned models used by global state, cross-area UI reuse and the detached-window protocol. These are grandfathered paths, not proof that those dependencies are good architecture.

The fixture suite removes all exception policies and checks those actual source files. Its findings must exactly equal the recorded pairs, so a removed or newly legitimate dependency requires pruning its exception. File-wide disabling and wildcard exceptions are not used. A pair allowance covers the existing two files, not individual imported symbols; tightening symbol-level contracts is deferred. Do not add an exception just to silence a new dependency. Move the shared contract downward or explicitly review a new public API. Never regenerate eslint-suppressions.json to absorb new violations.

The three Angular checks found zero pre-existing violations, so they need no legacy exceptions or new suppressions. Existing generated-code lint behavior and the suppression file are preserved.

Commands and CI

From src/services/web:

pnpm run test:lint  # Node tests of ESLint configuration and real typed fixtures
pnpm run lint       # fixture suite, then ng lint with committed suppressions

pnpm exec ng lint still runs all the configured rules, but does not run configuration fixtures. The Node fixture suite tests tooling; application specs still run only through Angular's test builder. The fixtures cover aliases/relative paths, type/re-export/dynamic imports, same-feature imports, public routes, new areas, subsystem consumers, test isolation, narrow exceptions, real Angular signal types and CI wiring. They also assert the normal effective config enables the rules.

Previously neither PR web lane nor the release web job invoked lint. Both PR lanes (trusted and fork) and the release web job now run pnpm run lint after install and before unit tests. Lint errors fail the job; there is no conditional skip or continue-on-error. Existing web change detection includes the configuration, fixtures and package files under src/services/web/. Runner routing is unchanged.

Ordered follow-ups

  1. Extract feature-owned DTOs/models used by core actions and selectors. Examples: AddSearchData, LibraryFileType, membership-table and review-progress types. This removes dependency reversals without moving screens; retain behavioral tests and remove exact exceptions as each slice lands.
  2. Move shell UI composition out of core and separate root-service dialog orchestration. Preserve current navigation, confirmation and error-recovery behavior before deleting its exception groups.
  3. Establish deliberate public contracts for detached-window protocols and shared study-management UI. Then retire cross-feature exceptions in small PRs. Audit cycles before proposing deeper domain isolation or an entry-point contract inside every subsystem. No circular-dependency rule was added here: the legacy graph is coupled and needs a separate measured migration.
  4. Resolve duplicate template attributes (46 reports in 18 files) and async lifecycle methods (four hooks) with focused behavior tests, then enable their maintained rules. Duplicated classes may need merging; merely deleting an attribute can alter styles. Angular does not await async hooks, so those fixes need explicit loading/error behavior rather than mechanical void wrappers.
  5. Incrementally restore prefer-inject and adopt signal inputs/outputs/queries and host metadata where safe. Constructor injection and decorator inputs remain supported; see Angular's style guide and input guide. Avoid blanket bans, selector renames or automatic OnPush/zoneless changes in this tooling PR.
  6. Review the existing 53 warning/unused-disable findings, stale disabled rule names, and broad NgRx preset. Then consider a warning budget, targeted RxJS/lifetime rules and template accessibility rules with measured migration scope. Neither --max-warnings=0 nor a large new recommended preset is needed to make this boundary gate useful.