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/no-craft-use-in-template': 'error',
'craft-ng/no-ephemeral-template-form-state': 'error',
'craft-ng/no-craft-computed-side-effects': 'error',
'craft-ng/require-craft-method-for-yieldable-callback': 'error',
'craft-ng/prefer-direct-yieldable-callback': 'error',
'craft-ng/require-yieldable-reactive-read': 'error',
'craft-ng/require-yieldable-template-method': 'error',
'craft-ng/require-yieldable-insertion-write': '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/no-craft-use-in-template: forbids the synchronouscraftUse(...)escape hatch in Craft templates; pass the reactive reader directly, such asstatus: usersQuery.currentPageStatuscraft-ng/no-ephemeral-template-form-state: forbidslet/const/varin the fourth argument ofcraftComponent(...)andcraftDirective(...)(inline or a same-file identifier). Declare that state in the logic factory withstate()orcraftComputed()insteadcraft-ng/no-craft-computed-side-effects: forbids writes and asynchronous work insidecraftComputed; only reactive reads andsettled(...)are allowed. The graph-wide counterpart isassertCraftComputedPure.craft-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(...). The graph-wide counterpart, includingstate/source$writes, isassertCraftEffectNoImperativeSync.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-craft-method-for-yieldable-callback: requires callbacks returned by acraftComponentfactory to wrap yieldable Craft method calls incraftMethod(...)craft-ng/prefer-direct-yieldable-callback: replaces a template generator that only returnsyield* callback()with the callback reference itselfcraft-ng/require-yieldable-reactive-read: requires Craft reactive readers to be delegated withyield*inside generator functions; a function that reads a Craft reader must itself be a generator (craftUseremains the synchronous boundary)craft-ng/require-yieldable-template-method: requires yieldable Craft method calls in acraftComponenttemplate to be delegated withyield*, or passed as a reference (click: counter.increment)craft-ng/require-yieldable-insertion-write: requiresset(...),patch(...), andupdate(...)to be delegated withyield*when they are used inside a generator methodcraft-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()
Accessibilité (craft-ng/a11y)
Spread craftRules.configs.a11y.rules to enable the WCAG 2.2 AA preset as error. The rules walk all hyperscript in the file (craftTemplate, factories extraites, h('tag')), not only craftComponent argument 3.
prefer-named-html-helpers: forbidsh('img')/h('button')when a named helper existsimg-has-alt,iframe-has-title,button-has-type,anchor-has-hrefcontrol-has-accessible-name,label-has-associated-control,heading-has-contentno-noninteractive-element-interactions,no-positive-tabindexvalid-aria,role-has-required-aria,target-blank-noopenerprefer-relative-heading,require-route-heading-outline,require-outlet-heading-section,no-heading-level-skiprequire-focus-visible,require-reduced-motion(CSS ofcraftComponent)
See Accessibilité.
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. Pass a yieldable reader, or use a generator when the binding must format:
p(count);
p(function* () {
return `Count: ${yield* count()}`;
});
button({ disabled: isDisabled }, 'Save');
div({ class: isActiveClass });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.
Yield insertion writes from generator methods
require-yieldable-insertion-write requires set(...), patch(...), and update(...) calls to be delegated with yield* when they are used inside a generator method:
nextPage: function* () {
const current = yield* state();
return yield* patch({ page: current.page + 1 });
},Insertion callbacks that are not generators may return a write directly; the insertion wrapper consumes that result for them.
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 |
prefer-direct-yieldable-callback | removes redundant template generators |
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. They generate the proofs; architecture tests (assertRouteDiProofs) fail CI if a proof is later removed or left unarmed. - The architecture rules last —
no-angular-inject,prefer-craft-service,prefer-craft-http-client,require-yieldable-reactive-read,require-yieldable-template-method,require-yieldable-insertion-write. 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
- Architecture rules — graph-wide constraints ESLint cannot see