ESLint rules
The rule set is not decoration: several checks in this documentation only work because a rule generated or maintained the code they read. Others enforce the architecture — no raw inject, no Angular HttpClient — and most of them autofix.
Install them once when you set up routing and type-safe DI. Then lean on the quick fixes rather than writing the boilerplate by hand.
An ESLint error is not a compile error
A missing autofix does not break the build. If you skip the quick fix after changing a component's DI shape, main.ts keeps reading a stale GenDeps_* and can miss a real DI error. Run eslint --fix in CI.
The plugin is exposed from @craft-ng/dev-tools/eslint-rules.
Add it to your ESLint flat config:
import craftRules from '@craft-ng/dev-tools/eslint-rules';
export default [
// keep your existing ESLint config entries
{
files: ['**/*.ts'],
plugins: {
'craft-ng': craftRules,
},
rules: {
'craft-ng/brand-angular-gen-deps-required': 'error',
'craft-ng/brand-angular-deps-match': 'error',
'craft-ng/component-test-gen-deps-match': 'error',
'craft-ng/no-angular-inject': 'error',
'craft-ng/prefer-craft-template-blocks': 'error',
'craft-ng/no-render-writes': 'error',
'craft-ng/require-reactive-template-bindings': 'error',
'craft-ng/prefer-craft-reactivity': 'error',
'craft-ng/prefer-craft-service': 'error',
'craft-ng/prefer-craft-http-client': 'error',
'craft-ng/prefer-craft-http-transport': 'error',
'craft-ng/prefer-craft-input-output': 'error',
'craft-ng/require-primitive-derived-property': 'error',
'craft-ng/no-async-await': 'error',
'craft-ng/no-throw': 'error',
'craft-ng/no-imperative-craft-resource-trigger': 'error',
'craft-ng/require-craft-resource-trigger-yield': 'error',
'craft-ng/require-assert-exhaustive-route-exceptions': 'error',
'craft-ng/require-craft-exception-handler': 'error',
'craft-ng/require-exception-component-di-check': 'error',
'craft-ng/require-pending-component-di-check': 'error',
'craft-ng/require-child-route-mount-check': 'error',
'craft-ng/require-lazy-load-with-retry': 'error',
'craft-ng/require-cascade-route-di-check': 'error',
'craft-ng/global-exception-registry-match': 'error',
},
},
];What each rule does:
craft-ng/brand-angular-gen-deps-required: generates a missingGenDeps_*alias for Angular components, directives, and pipes through the ESLint Quick Fixcraft-ng/brand-angular-deps-match: keeps existingGenDeps_*aliases in sync through the same ESLint Quick Fix flowcraft-ng/component-test-gen-deps-match: checkssetupCraftComponentTestingByRegister(Component, {} as GenDeps_Component, ...)pairs in testscraft-ng/no-angular-inject: forbids raw Angularinject()usage so dependencies go throughcraftService(...)ortoCraftService(...)craft-ng/prefer-craft-template-blocks: keepscraftComponent(...)templates declarative by rejecting ternaries, logical expressions, and imperative control flow; useifBlock(...),matchBlock.exhaustive(...),each(...), ordefer(...)craft-ng/no-render-writes: rejects detectableset(),update(), andmutate()calls in component templates and render bindings while allowing DOM event andonXxxoutput callbackscraft-ng/require-reactive-template-bindings: requires Angular Signals, named Craft values, and component inputs to be read inside granular binding callbacks instead of during VNode construction; static values remain validcraft-ng/prefer-craft-reactivity: rejects authored Angular signal/computed/effect/resource APIs, explicit.subscribe()calls, and RxJSSubject/BehaviorSubject/ReplaySubject; usestate,craftComputed,craftEffect,query, and namedsource$/on$flowscraft-ng/prefer-craft-service: forbids authored Angular@Injectable()/@Service()services in favor ofcraftService(...)andtoCraftService(...)craft-ng/prefer-craft-http-client: forbids AngularHttpClientusage in favor ofCraftHttpClientcraft-ng/prefer-craft-http-transport: forbids directfetch()andXMLHttpRequest; usequery()for reads ormutation()for writes withCraftHttpClientcraft-ng/prefer-craft-input-output: forbids Angularinput()/output()and@Input/@Output; useInput/Outputfrom@craft-ng/componentincraftComponent(...)craft-ng/require-primitive-derived-property: requires acomputedorcraftComputedthat only depends on one primitive in the same component/service to be exposed by that primitive's insertion; simple cases are autofixedcraft-ng/no-async-await: forbidsasyncfunctions,await, andfor await...of; use generator-based Craft primitives,craftSleep, andCraftHttpClientinsteadcraft-ng/no-throw: forbidsthrowin Craft code and offers a Quick Fix that returnscraftException({ code: 'UNEXPECTED_ERROR' }, { error: ... }); keep technical boundaries and tests outside this rule when their contracts require thrown errorscraft-ng/no-imperative-craft-resource-trigger: forbidsquery.call(...),mutation.mutate(...), andasyncProcess.method(...)in acraftEffectdependency graph, including throughcraftGen(...)craft-ng/require-craft-resource-trigger-yield: requires those triggers to useyield*inside generator functions, while ordinary UI callbacks may keep imperative callscraft-ng/require-assert-exhaustive-route-exceptions: adds the collection-levelassertExhaustiveRouteExceptions(...)safety netcraft-ng/require-craft-exception-handler: enforcescraftExceptionHandler(function* (...) {}); simple handlers are autofixed and ambiguous raw redirects are reported for manual migrationcraft-ng/require-exception-component-di-check: generates O(1)RouteExceptionComponentCheckedDIchecks forrenderComponent, route-levelerrorComponent,withErrorComponent,withRouteLoadError, and route-localprovideRouteLoadErrorComponentcraft-ng/require-pending-component-di-check: generates the independentRouteCheckedDIcheck for eachpendingComponentcraft-ng/require-child-route-mount-check: adds the missingassertChildRouteMounts(...)call + import (Quick Fix) for anycraftRoutes(...)collection that mounts lazyloadChildren, so a.withParent-pinned child mounted under the wrong path is a compile errorcraft-ng/require-lazy-load-with-retry: wraps routeloadComponentandloadChildrenimports with the generatedwithRetry(...)loader helper while preserving a statically analyzable import specifiercraft-ng/require-cascade-route-di-check: rejects anycraftRoutes(...)collection without a same-fileValidateCascadeRoutesFile + CanRunproof; its autofix adds the conservative<never, Router>context, which should be adjusted when the mount inherits providerscraft-ng/global-exception-registry-match: keepsCraftGlobalExceptionRegistrysynchronized with handlers delegating toglobalError()
The two migration rules also expose a VS Code ESLint Quick Fix suggestion that inserts a temporary local disable comment with the intended migration note when you need to unblock a file before doing the full refactor.
The template and reactivity rules are intentionally diagnostic-only: replacing a resource or subscription can change lifecycle and error semantics, so the rule points at the Craft primitive without applying a potentially unsafe rewrite.
Why templates use blocks
Craft template blocks preserve the branch structure in the type-level render contract. A ternary or condition && node produces only a computed value, so the type checker cannot assert which branch renders which content. Keep derived values and business decisions in the component's state/query layer, then make the template express visibility explicitly:
ifBlock(
isReady,
() => p('Ready'),
() => p('Loading…'),
);
matchBlock.exhaustive(query.exceptions, 'code', {
NOT_FOUND: () => p('Not found'),
FORBIDDEN: () => p('Forbidden'),
});This rule is for Craft's TypeScript templates. Angular HTML templates are not rewritten by it.
Reactive values belong in binding callbacks
require-reactive-template-bindings uses TypeScript type information to find reactive reads. Reading a signal while constructing a VNode would make it a dependency of the structural component render, so the rule rejects this form:
// Incorrect: count is read by the component template.
p(`Count: ${count()}`);
button({ disabled: isDisabled() }, 'Save');
div({ class: { active: isActive() } });Keep each read inside the callback owned by its DOM binding:
p(() => `Count: ${count()}`);
button({ disabled: () => isDisabled() }, 'Save');
div({ class: () => ({ active: isActive() }) });Literal and otherwise static values are still allowed, as are reads performed from DOM events and onXxx output callbacks. Because the rule is type-aware, the ESLint parser must use projectService: true or a TypeScript project.
If your project is adopting this progressively, enable both craft-ng/brand-angular-gen-deps-required and craft-ng/brand-angular-deps-match so the same Quick Fix can generate missing aliases and refresh existing ones. craft-ng/no-angular-inject is an architecture-enforcement rule and may require a broader migration.
What generates what
Three rules do more than complain — they write code you would otherwise maintain by hand:
| Rule | Generates |
|---|---|
brand-angular-gen-deps-required | the missing GenDeps_* alias for an Angular component |
brand-angular-deps-match | keeps an existing GenDeps_* in sync |
require-cascade-route-di-check | the same-file DI proof for a craftRoutes(...) collection |
require-assert-exhaustive-route-exceptions | the collection-level exhaustiveness assert |
require-child-route-mount-check | the assertChildRouteMounts(...) call and its import |
require-lazy-load-with-retry | the withRetry(...) wrapper on lazy route imports |
Adopting them progressively
On an existing codebase, enable them in waves rather than all at once:
- The generators first —
brand-angular-gen-deps-requiredandbrand-angular-deps-match. They only add code. - The route safety nets — the
require-*rules. Mostly autofixable. - The architecture rules last —
no-angular-inject,prefer-craft-service,prefer-craft-http-client. These ask for real refactors.
The two migration rules also expose a VS Code quick fix that inserts a temporary local disable comment with the intended migration note, so you can unblock a file before doing the full refactor.
See Also
- Routing setup — where these rules are installed
- CLI automation — the codemods they complement
- Angular brand config