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 hidden runtime dependencies or direct transport calls — 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-ts/dev-tools/eslint-rules.
The recommended preset bans every TypeScript assertion in authored Craft code, including as const:
import craftRules from '@craft-ts/dev-tools/eslint-rules';
export default [{ files: ['**/*.ts'], ...craftRules.configs.recommended }];For a project using @craft-ts/effect, the published preset enables the Craft rules and the Effect adapter rule in one entry:
import craftRules from '@craft-ts/dev-tools/eslint-rules';
export default [
{
files: ['**/*.ts'],
...craftRules.configs.effect,
},
];Use craftRules.configs.recommended for projects that do not use Effect.
Add it to your ESLint flat config:
import craftRules from '@craft-ts/dev-tools/eslint-rules';
export default [
// keep your existing ESLint config entries
{
files: ['**/*.ts'],
plugins: {
'craft-ts': craftRules,
},
rules: {
'craft-ts/prefer-craft-template-blocks': 'error',
'craft-ts/no-render-writes': 'error',
'craft-ts/require-reactive-template-bindings': 'error',
'craft-ts/no-craft-use': 'error',
'craft-ts/no-craft-component-return-type': 'error',
'craft-ts/require-craft-component-for-exported-node-factory': 'error',
'craft-ts/no-raw-craft-router-url': 'error',
'craft-ts/no-type-assertions-in-template': 'error',
'craft-ts/no-explicit-craft-template-return-type': 'error',
'craft-ts/no-extracted-craft-component-parts': 'error',
'craft-ts/no-ephemeral-template-form-state': 'error',
'craft-ts/require-form-for-input-action': 'error',
'craft-ts/template-element-name-unique': 'error',
'craft-ts/no-craft-computed-side-effects': 'error',
'craft-ts/no-external-state-transition': 'error',
'craft-ts/require-craft-method-for-yieldable-callback': 'error',
'craft-ts/prefer-direct-yieldable-callback': 'error',
'craft-ts/prefer-deep-yieldable-for-item': 'warn',
'craft-ts/require-yieldable-reactive-read': 'error',
'craft-ts/require-yieldable-template-method': 'error',
'craft-ts/require-yieldable-insertion-write': 'error',
'craft-ts/no-craft-service-component-same-file': 'error',
'craft-ts/max-craft-declarations-per-file': 'error',
'craft-ts/max-craft-component-lines': 'warn',
'craft-ts/prefer-craft-http-transport': 'error',
'craft-ts/no-injection-token': 'error',
'craft-ts/require-primitive-derived-property': 'error',
'craft-ts/no-reused-primitive-method': 'error',
'craft-ts/no-async-await': 'error',
'craft-ts/no-throw': 'error',
'craft-ts/no-imperative-craft-resource-trigger': 'error',
'craft-ts/no-imperative-craft-method-actions': 'error',
'craft-ts/no-remote-work-in-craft-method': 'error',
'craft-ts/no-type-assertions-in-resource-loader': 'error',
'craft-ts/no-explicit-resource-loader-type': 'error',
'craft-ts/no-explicit-craft-insertion-type': 'error',
'craft-ts/no-craft-primitive-type-assertion': 'error',
'craft-ts/prefer-insert-deep-yieldable': 'error',
'craft-ts/no-imperative-template-action-chain': 'error',
'craft-ts/prefer-route-query-params-for-filter-state': 'warn',
'craft-ts/no-imperative-storage-in-craft-method': 'error',
'craft-ts/no-transition-actions': 'error',
'craft-ts/require-craft-resource-trigger-yield': 'error',
'craft-ts/require-assert-exhaustive-route-exceptions': 'error',
'craft-ts/require-craft-exception-handler': 'error',
'craft-ts/require-exception-component-di-check': 'error',
'craft-ts/require-pending-component-di-check': 'error',
'craft-ts/require-child-route-mount-check': 'error',
'craft-ts/require-lazy-load-with-retry': 'error',
'craft-ts/global-exception-registry-match': 'error',
},
},
];What each rule does:
craft-ts/prefer-craft-template-blocks: keepscraftComponent(...)templates declarative by rejecting ternaries, logical expressions, negations, and imperative control flow; useifNode(...),matchNode.exhaustive(...),forNode(...), ordeferNode(...)craft-ts/require-craft-computed-for-dynamic-template-lookup: rejects dynamic object or array lookups in a Craft template when the lookup key comes from a template parameter; move the lookup to a namedcraftComputed()in the component logic factory and bind that value directlycraft-ts/no-render-writes: rejects detectableset(),update(), andmutate()calls in component templates and render bindings while allowing DOM event andonXxxoutput callbackscraft-ts/no-external-state-transition: rejects genericreplace,set,update, orpatchcalls on a value returned by Craftstate(...)outside its state insertion. Put the transition behind a named state method that accepts intent and computes the next value internally.craft-ts/require-reactive-template-bindings: requires signals, named Craft values, and component inputs to be read inside granular binding callbacks instead of during VNode construction; static values remain validcraft-ts/no-craft-use: forbids the synchronouscraftUse(...)escape hatch in Craft TypeScript files; use a generator and delegate the reader withyield*insteadcraft-ts/require-craft-component-for-exported-node-factory: requires an exported function that directly returns a Craft node, such asbutton(...), to be declared withcraftComponent(...)so Craft directives and composition remain available
Small node factories are valid when they stay private to the file:
function filterButton(filter: TodoFilter, label: string) {
return button('todoFilterButton', { type: 'button' }, label);
}Once the function is exported, use a Craft component so directives and composition can be applied at the module boundary:
// ❌ craft-ts/require-craft-component-for-exported-node-factory
export function filterButton(filter: TodoFilter, label: string) {
return button('todoFilterButton', { type: 'button' }, label);
}
// ✅
export const FilterButton = craftComponent(
'FilterButton',
{},
(filter: Input<TodoFilter>, label: Input<string>) => ({ filter, label }),
({ label }) => button('todoFilterButton', { type: 'button' }, label),
);The rule also follows named exports such as export { filterButton } and checks exported arrow functions.
craft-ts/no-type-assertions-in-template: forbidsas ...and angle-bracket type assertions in Craft templates; fix the type in the logic factory or expose a correctly typed derived valuecraft-ts/no-explicit-craft-template-return-type: forbids explicit return annotations on render callbacks insidecraftComponent(...). A broad annotation such as(): CraftNodeChildrenwidens the concrete node type, breaks dependency and type-safe DI inference, and can surface as a runtime error. Let the callback return type be inferred:tsconst pendingStatusMessage = (message: string) => p(message); // ❌ The annotation erases the concrete node/dependency information. pendingNode({ fallback: (): CraftNodeChildren => pendingStatusMessage('Loading…'), reloading: (): CraftNodeChildren => pendingStatusMessage('Reloading…'), }); // ✅ The concrete `p(...)` node stays visible to Craft's inference. pendingNode({ fallback: () => pendingStatusMessage('Loading…'), reloading: () => pendingStatusMessage('Reloading…'), });The rule is autofixable with
eslint --fix. Return annotations on DOM event and output callbacks remain allowed because those callbacks do not produce rendered children.craft-ts/no-extracted-craft-component-parts: requires the logic factory and template passed tocraftComponent(...)to stay inline. Keeping both parts at the component boundary preserves contextual type inference and makes the component's behaviour readable in one place. The rule reports both extracted identifiers independently.Before — extracted
ReviewLogicandReviewTemplatehide the component's two halves behind names at the call site:ts// ❌ craft-ts/no-extracted-craft-component-parts const ReviewLogic = craftGen(function* () { return { review, decide }; }); const ReviewTemplate = craftTemplate(({ decide }) => div([button({ click: decide }, 'Review')]), ); export const ReviewApp = craftComponent( 'ReviewApp', {}, ReviewLogic, ReviewTemplate, );After — keep the logic and template callback in the component call:
ts// ✅ export const ReviewApp = craftComponent( 'ReviewApp', {}, craftGen(function* () { return { review, decide }; }), ({ decide }) => div([button({ click: decide }, 'Review')]), );The rule only rejects identifiers in the logic and template argument positions. Inline callbacks and inline
craftGen(...)/craftTemplate(...)expressions remain valid. A direct template callback is usually the simplest form becausecraftComponent(...)can contextually type it from the inline logic factory.craft-ts/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-ts/require-form-for-input-action: rejects a button's directmutate(...)ormethod(...)call when it consumes an input-bound value, including through a local record or variable; useinsertForm,insertFormAttributes, andinsertFormSubmitfor mutation-backed forms, then submit a nativeform(...)with atype: 'submit'buttoncraft-ts/template-element-name-unique: requires named HTML helpers to use a static, unique local name within a component; use the object-first helper form for unnamed elements such asp({ id: 'hint' }, ...)craft-ts/no-craft-computed-side-effects: forbids writes and asynchronous work insidecraftComputed; only reactive reads andsettled(...)are allowed. The graph-wide counterpart isassertCraftComputedPure.craft-ts/no-effect-outside-loaders: keepsparams, methods,craftComputed(...), andcraftEffect(...)synchronous by allowing Effect values and Effect service reads only in Effect loaders;no-effect-in-paramsremains as a compatibility aliascraft-ts/sync-effect-body: keeps a body declared synchronous (SyncOpin its requirements) free of anything that may suspend — async constructors such asEffect.sleep/Effect.promise, and members nothing declares synchronous. Type-aware: the ESLint parser must useprojectService: trueor a TypeScriptprojectcraft-ts/no-explicit-effect-type: letsEffect.geninfer its complete type instead of repeating an explicit Effect annotation; contracts declared in interfaces and type aliases remain allowedcraft-ts/prefer-inline-effect-insertion: keeps thequeryEffectinsertion factory inline so its resource and exception types are inferred without a separateInsertionParamscontext aliascraft-ts/prefer-inline-route-providers: inlines a route provider tuple used only once byloadCraftComponent(...), preserving the route-level type proofcraft-ts/prefer-craft-reactivity: rejects authored signal/computed/effect/resource APIs, explicit.subscribe()calls, and RxJSSubject/BehaviorSubject/ReplaySubject; usestate,craftComputed,craftEffect,query, and namedsource$/on$flowscraft-ts/prefer-craft-service: keeps services in thecraftService(...)modelcraft-ts/no-craft-service-component-same-file: forbids declaringcraftService(...)andcraftComponent(...)in the same file; a route-level service provider combined with a lazy-loaded component can break lazy loading, so keep them in separate filescraft-ts/max-craft-declarations-per-file: reports the third and subsequentcraftComponent(...),craftService(...), orcraftDirective(...)declaration of the same kind in a file; keep Craft entities split across focused filescraft-ts/max-craft-component-lines: reports a file that declares acraftComponent(...)once it exceeds 700 non-import lines (importstatements and blank lines are not counted, so a component with many dependencies is not penalized for its import block). A file this long usually mixes business logic, view logic, and markup that could live in separate, independently testable units:ts// ❌ craft-ts/max-craft-component-lines // review-app.ts — 3894 lines: filtering, sorting, diff computation, // pagination, and the full markup tree all inlined in one logic factory // and one template. export const ReviewApp = craftComponent( 'ReviewApp', {}, (subjects: Input<Subject[]>) => { const filtered = craftComputed(() => /* 80 lines of filtering */ []); const diff = craftComputed(() => /* 150 lines of diffing */ null); // …dozens more computeds and craftMethods… return { subjects, filtered, diff /* … */ }; }, ({ filtered, diff /* … */ }) => div( {}, /* a thousand-plus lines of markup for the filter bar, the diff viewport, the review card list, and the pagination controls */ ), ); // ✅ Business logic moves to a craftService; independent template // regions become their own craftComponent, each testable and readable // on its own. export const ReviewFilters = craftService( { name: 'ReviewFilters', scope: 'global' }, () => ({ filter: (subjects: Subject[], criteria: FilterCriteria) => /* … */ [], }), ); export const SubjectDiffViewport = craftComponent( 'SubjectDiffViewport', {}, (subject: Input<Subject>) => ({ subject }), ({ subject }) => div({} /* … */), ); export const ReviewApp = craftComponent( 'ReviewApp', {}, (subjects: Input<Subject[]>) => { const filters = injectX(ReviewFilters); const filtered = craftComputed(() => filters.filter(subjects(), criteria()), ); return { filtered /* … */ }; }, ({ filtered }) => div( {}, forNode(filtered, (subject) => SubjectDiffViewport({ subject })), ), );Set a project-specific threshold with
['warn', { max: 600 }]if 700 lines is still too generous for your team.craft-ts/no-injection-token: forbids authoredInjectionTokencontracts; declare them withcraftService({ name, providedIn: 'abstract' }, abstract<Contract>())craft-ts/prefer-craft-http-client: forbids direct transport usage in favor ofCraftHttpClientcraft-ts/prefer-craft-http-transport: forbids directfetch()andXMLHttpRequestbecause they bypass typed responses and exceptions, tracing, cancellation, and the architecture graph; usequery()for reads ormutation()for writes withCraftHttpClient, orCraftBinaryHttpClientfor raw binary bodiescraft-ts/prefer-craft-input-output: keeps component inputs and outputs in theInput/Outputmodel used bycraftComponent(...)craft-ts/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-ts/no-reused-primitive-method: requires an exposed primitive insertion method to have one call site per file, including unchanged aliases forwarded through a component template context; create a context-specific insertion method for each distinct usecraft-ts/no-async-await: forbidsasyncfunctions,await, andfor await...ofbecause native Promise suspension hides Craft dependencies and can lose cancellation or exception tracking; use generator-based Craft primitives,craftSleep, andCraftHttpClientinsteadcraft-ts/require-generator-resource-loader: requiresquery,mutation, andasyncProcessloaders to be generator functions because a plain or async return hides remote dependencies from the resource lifecycle; useyield*to keep each suspension trackedcraft-ts/no-throw: forbidsthrowin Craft code because it bypasses the typed resource exception channel, and offers a Quick Fix that returnscraftException({ _tag: 'UNEXPECTED_ERROR' }, { error: ... }); keep technical boundaries and tests outside this rule when their contracts require thrown errorscraft-ts/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-ts/no-imperative-craft-method-actions: forbids composing multiple imperative actions in acraftMethod; emit asource$event and let the affected query react withinsertReactOnMutation(...)instead. A handler such asevent.preventDefault()followed by onemutation.mutate(...)remains valid.craft-ts/no-event-only-craft-method: errors by default when acraftMethodonly callspreventDefault(),stopPropagation(), orstopImmediatePropagation()and then delegates to one action. Bind the action witheventAction(...)on the element. The default architecture check enforces the same rule across files.craft-ts/no-remote-work-in-craft-method: forbidsCraftHttpClient.*(...)insidecraftMethodbecause that action boundary does not own request loading, cancellation, exceptions, or graph dependencies; define the request directly in thequeryormutationloader.craft-ts/no-type-assertions-in-resource-loader: forbidsas ...and angle-bracket assertions insidequery,mutation, andasyncProcessloaders because assertions only silence TypeScript and can hide Promise, response, or transport mismatches; repair the request or adapter typing instead.craft-ts/no-type-assertions-in-craft-code: forbids TypeScript type assertions in authored Craft code, includingas constand angle-bracket assertions; the narrowundefined as T | undefinedseed is allowed for intentionally optional state values. Use correct API typing orsatisfiesfor shape validation. Low-level technical adapters may disable this rule locally when an explicit runtime boundary cast is unavoidable.craft-ts/no-explicit-resource-loader-type: forbids explicit parameter and return annotations onquery,mutation, andasyncProcessloaders; let the resource infer its contract fromparams,method, and the yielded operations instead of writingGenerator<...>or{ params: string }craft-ts/no-explicit-craft-insertion-type: forbids explicit parameter and return annotations on callbacks passed toinsert*Pipe; let the primitive infer the insertion context and derived outputcraft-ts/no-craft-primitive-type-assertion: forbids chained assertions such asas unknown as Generator<...>around Craft primitive generators, which can hide the inferred output and dependency contractcraft-ts/prefer-insert-deep-yieldable: rejects adapting a property of a primitive result withdeepYieldable(...); addinsertDeepYieldable()to the primitive and read the property directlycraft-ts/no-imperative-template-action-chain: forbids chaining multiple Craft actions in one template event callback; emit onesource$event and let the query, mutation, and state react throughon$.craft-ts/prefer-route-query-params-for-filter-state: warns when a localstate()is used directly or through a local derivation asparamsforquery,queryEffect,asyncProcess, orasyncProcessEffect; usequeryParams()for values that should survive reloads and be represented in the URL. The graph-wide counterpart, which also sees cross-file dependencies, isassertResourceParamsPreferQueryParams.craft-ts/no-imperative-storage-in-craft-method: forbids direct storage access and imperative location changes in acraftMethod; useinsertReactOnMutation(...)withoptimisticUpdate: () => undefinedto clear the affected query and let its persistence follow the query state.craft-ts/no-transition-actions: forbidsquery.call(...),mutation.mutate(...), andasyncProcess.method(...)insidetransitionStep(...); validate the event and emit a source, then let the resource react to that source.craft-ts/require-craft-resource-trigger-yield: requires those triggers to useyield*inside generator functions, while ordinary UI callbacks may keep imperative callscraft-ts/require-craft-method-for-yieldable-callback: requires callbacks returned by acraftComponentfactory to wrap yieldable Craft method calls incraftMethod(...)craft-ts/prefer-direct-yieldable-callback: replaces a template generator or generator method that only delegatesyield* callback()with the callback reference itself (callbackorobject.method)craft-ts/prefer-deep-yieldable-for-item: warns when aforNodeitem is read repeatedly throughyield* item()property accesses; expose a namedinsertDeepYieldable('property')collection and use direct item property readerscraft-ts/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 generatorcraft-ts/require-yieldable-template-method: requires yieldable Craft method calls in acraftComponenttemplate to be delegated withyield*, or passed as a reference (click: counter.increment)craft-ts/require-yieldable-insertion-write: requiresset(...),patch(...), andupdate(...)to be delegated withyield*when they are used inside a generator methodcraft-ts/require-assert-exhaustive-route-exceptions: adds the collection-levelassertExhaustiveRouteExceptions(...)safety netcraft-ts/require-craft-exception-handler: enforcescraftExceptionHandler(function* (...) {}); simple handlers are autofixed and ambiguous raw redirects are reported for manual migrationcraft-ts/require-exception-component-di-check: generates O(1)RouteExceptionComponentCheckedDIchecks forrenderComponent, route-levelerrorComponent,withErrorComponent,withRouteLoadError, and route-localprovideRouteLoadErrorComponentcraft-ts/require-pending-component-di-check: generates the independentRouteCheckedDIcheck for eachpendingComponentcraft-ts/no-raw-class: requires everyclass:binding — on an element, inattrs, on a componenthost— to trace back to a sheet imported from a*.stylemodule:sheet.key, aconstbound to one, an array of them, a typed input (a parameter or a member of one), or a function that only returns one. A string, a template literal, a conditional or an object of booleans is refused. A class assembled at render time is a visual state nothing recorded, so the visual matrix would enumerate what the sheets declare while the DOM shows something else; and a sheet declared outside a*.style.tsis never evaluated by the build, so its class has no CSS. Make the variation an axis and set adata-*attributecraft-ts/no-inline-style: restrictsstyle:toassign(...)from@craft-ts/style— or an array of them, an object spreading only them, a conditional whose branches are all of them, or a function that only returns them. What varies at runtime is a typed variable (cssVars+assign), read by a sheet;attrs.styleis always refusedcraft-ts/no-component-css: forbidsmeta.styles,meta.stylesUrlandmeta.contentStylesoncraftComponent/craftDirective, and every.cssimport exceptvirtual:craft-style.css. Global rules go incraftGlobalStyles, fonts indefineFontcraft-ts/no-forbidden-eslint-disable: requires a reason on every directive that disables a design-system rule —// eslint-disable-next-line craft-ts/no-raw-class -- markdown output carries its own classes. The reason is what the reviewer decides on in Review Attest. It also forbids disabling the rules listed in.craft/eslint-disable-policy.json. A blanketeslint-disablesilences this rule too, so it cannot be reported here; Review Attest lists itcraft-ts/no-raw-css-value: forbids a string or number literal as an argument to a@craft-ts/stylehelper —p('12px'),bg('red'). If the scale is missing the step, add it to the scale; if the value genuinely cannot be proven,unsafeLength('13px', reason)compiles and makes the debt countable in the graphcraft-ts/no-free-has: forbids a hand-written:has()in styles. It reaches across the component boundary, so what a component looks like depends on markup it does not own — a state the matrix cannot enumerate. Use thedescendantaxis, which is a closed set and carries its own test drivercraft-ts/style-file-boundary: restricts a*.style.tsto style-vocabulary imports. The build plugin imports the file in Node to read what it registered, so an application import would run application code at build timecraft-ts/craft-css-token-registry: reports a custom property registered with@propertyby two different components. A custom property may have only one owner; two silently fight over its syntax and initial value. Part of thelegacyComponentCsspreset (see below)craft-ts/require-effect-adapters: requires the Effect-aware adapters —queryEffect,mutationEffect,asyncProcessEffect, andtransitionGuardEffect— instead of the plain primitives andtransitionGuardin an Effect application. See Choose the right adaptercraft-ts/craft-signal-source-name-match: requiressignalSource(name, ...)to take a string literal matching the variable, class property or object property it is assigned to, so the name in a trace is the name in the source. A computed name defeats the architecture graph, which reads these names staticallycraft-ts/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-ts/require-lazy-load-with-retry: wraps routeloadComponentandloadChildrenimports with the generatedwithRetry(...)loader helper while preserving a statically analyzable import specifiercraft-ts/global-exception-registry-match: keepsCraftGlobalExceptionRegistrysynchronized with handlers delegating toglobalError()craft-ts/prefer-craft-router-link: requiresCraftRouterLinkfor internala(..., { href: ... })navigation; external URLs, fragment links, downloads,_blank, and links marked withdata-navigation: 'external'remain nativecraft-ts/no-raw-craft-router-url: rejects readingCraftRouter.url; use the typed route parameter helper generated bycraftRoutes(...)instead of parsing the URLcraft-ts/no-craft-component-return-type: rejects explicit annotations oncraftComponent(...)results so dependency and template inference remains intact
Promise and transport boundaries
These rules protect the same boundary: asynchronous work must remain visible to the Craft resource that owns it. A native Promise may eventually resolve, but it does not describe which Craft dependencies were read, where suspension occurred, or which resource should be cancelled and receive the exception.
Keep resource loaders generator-based
// Incorrect: the native Promise hides the request from the Craft lifecycle.
query('usersQuery', {
loader: async () => (await fetch('/api/users')).json(),
});
// Correct: the resource owns a tracked, yieldable request.
query('usersQuery', {
loader: function* () {
return yield* CraftHttpClient.get(({ response }) => ({
url: '/api/users',
success: response<User[]>(),
}));
},
});no-async-await rejects async, await, and for await...of in Craft code. require-generator-resource-loader additionally checks that query, mutation, and asyncProcess loaders are generators. Use yield* for Craft operations so every suspension stays tracked.
The loader signature should also stay inferred:
// Incorrect: these annotations can mask a mismatch in the resource contract.
loader: function* ({ params }: { params: string }): Generator<Yielded, Result, unknown> {
return yield* client({ token: params });
}
// Correct: infer params and the generator result from the resource and body.
loader: function* ({ params }) {
return yield* client({ token: params });
}no-explicit-resource-loader-type reports only annotations on the loader signature. Type annotations for local variables and function contracts outside the loader remain allowed.
Keep transport and types honest
// Incorrect: direct fetch bypasses Craft response/error tracking.
const result = await fetch('/api/users');
// Correct: use the Craft client in the owning resource loader.
return (
yield *
CraftHttpClient.get(({ response }) => ({
url: '/api/users',
success: response<User>(),
}))
);For a raw binary body, use CraftBinaryHttpClient.put(...); do not use a type assertion to force CraftHttpClient to accept a Blob. An assertion only silences TypeScript — it does not change the runtime value or transport. That is why prefer-craft-http-transport and no-type-assertions-in-resource-loader report these patterns.
Preserve primitive inference
The insertion callback already receives a contextual type, and the primitive already knows the complete type of its generator. Do not repeat either type at the boundary:
// ❌ craft-ts/no-explicit-craft-insertion-type
insertQueryPipe(
({ resource }): SpaceQueryView => ({
items: craftComputed(() => resource.value()),
}),
);
// ❌ craft-ts/no-craft-primitive-type-assertion
const generator = query('spaceItems', config) as unknown as Generator<
unknown,
SpaceQueryRef,
unknown
>;
// ✅
const generator = query(
'spaceItems',
config,
insertQueryPipe(({ resource }) => ({
items: craftComputed(() => resource.value()),
})),
);The assertion is especially harmful around a composed insertion pipe: it replaces the type that carries the derived properties and their dependencies.
Prefer primitive deep-yieldable insertions
When a property is read from the result of a primitive, expose the deep view at the primitive boundary. This keeps the property reader connected to the primitive and avoids an extra adapter:
// ❌ craft-ts/prefer-insert-deep-yieldable
const spaceQuery = yield * spaceQueryGenerator;
const deepItems = deepYieldable(spaceQuery.items);
// ✅ add insertDeepYieldable() to the query call, then:
const spaceQuery = yield * spaceQueryGenerator;
const items = spaceQuery.items;Expected failures should use craftException(...) so they remain typed and available through the resource's exception state. no-throw keeps technical throws limited to explicit adapter boundaries, where they can be translated into the Craft exception channel.
Accessibility (craft-ts/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, extracted factories, h('tag')), not only craftComponent argument 3.
prefer-named-html-helpers: forbidsh('img')/h('button')when a named helper existsrequire-interactive-local-name: requires a string-literal first argument on interactive helpers; the local name is the third segment ofdata-craft-name="${component}:${tag}:${localName}"img-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) — superseded by thecraft.baselayer of@craft-ts/style, which lays both once for the document; they are no longer inrecommended
See Accessibility.
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:
ifNode(
isReady,
() => p('Ready'),
() => p('Loading…'),
);
matchNode.exhaustive(query.exceptions, '_tag', {
NOT_FOUND: () => p('Not found'),
FORBIDDEN: () => p('Forbidden'),
});This rule is for Craft's TypeScript templates. It does not rewrite external template languages.
The same restriction applies to boolean expressions. A negation is still application logic, even when it is used only for a DOM property:
// Incorrect: the template derives the disabled state.
button(
{
disabled: function* () {
return !(yield* machine.canGoBack());
},
},
'Back',
);
// Correct: derive it in the logic factory and bind the result.
const backDisabled = craftComputed('backDisabled', function* () {
return !(yield* history.canGoBack());
});
return { backDisabled };Keep the template to layout and binding. Move labels, formatted values, validation state, and other decisions into state() or craftComputed().
Derived values belong to their primitive
When a computed reads only one local primitive, declare it in that primitive's insertion. This keeps the dependency visible and lets pending/exception boundaries name the actual source:
const users =
yield *
query('users', config, ({ resource }) => ({
total: craftComputed('total', function* () {
return (yield* settled(resource)).length;
}),
}));Do not create craftComputed('total', ...) beside the query when the computation depends only on users.
Keep casts and synchronous reads out of templates
Craft templates reject both as ... / angle-bracket assertions and craftUse(...). Fix the type or perform the synchronous-to-reactive conversion in the component logic, then expose a typed reader or generator to the template:
const typedStep = machine.stepState as unknown as () => { step: Step };
return { typedStep };
// Template: no cast and no craftUse.
matchNode.exhaustive(typedStep, 'step', steps);no-craft-use applies to Craft TypeScript files, not only the fourth craftComponent(...) argument. A synchronous integration boundary may opt out locally when its external API cannot consume a generator, but application state and templates should use yield*.
Form and accessibility diagnostics
The accessibility preset also checks the static structure of hyperscript:
- give every
labelanhtmlFormatching the controlid, or wrap the control; - give named controls and helpers a unique string local name;
- use
buttonorafor interactions instead of addingclickto adiv; - add a
prefers-reduced-motionbranch whenever component CSS defines an animation or transition.
These checks run on Craft TypeScript templates and extracted helper factories, so moving markup into a local function does not bypass them.
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.
Pass simple yieldable callbacks directly
prefer-direct-yieldable-callback removes a generator wrapper when the template only delegates one zero-argument callback. It handles both a value binding and a generator method:
// Before: redundant wrappers around the callbacks.
button(
{
*click() {
yield* press();
},
},
function* () {
return yield* label();
},
);
// After `eslint --fix`.
button({ click: press }, label);Member callbacks are supported as well when the access is static and has no arguments:
// Before.
span(function* () {
return yield* counter.increment();
});
// After.
span(counter.increment);The rule leaves callbacks with parameters, extra statements, or additional computation unchanged. In those cases the generator contains behavior that cannot be represented by passing the callback reference alone.
Prefer deep-yieldable forNode items
prefer-deep-yieldable-for-item detects when a component reads several properties from the same forNode item through repeated yield* item() calls. Keep the original collection available, and expose a named deep-yieldable view for the component:
import { insertDeepYieldable, state } from '@craft-ts/core';
// Before: every property read yields the whole item again.
forNode(catalog.products, { track: (product) => product.id }, (product) =>
article([
span(function* () {
return (yield* product()).category;
}),
span(function* () {
return (yield* product()).name;
}),
]),
);
// After: the named view keeps each property read lazy and reactive.
const catalog =
yield * state('catalog', { products }, insertDeepYieldable('products'));
forNode(
catalog.deepYieldableProducts,
{ track: (product) => product.id },
(product) => article([span(product.category), span(product.name)]),
);The rule is diagnostic-only because choosing the insertion belongs to the primitive that owns the collection. insertDeepYieldable('products') leaves catalog.products unchanged and adds catalog.deepYieldableProducts.
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 |
|---|---|
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 | replaces redundant generators with direct callback references |
Adopting them progressively
On an existing codebase, enable them in waves rather than all at once:
- 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 —
prefer-craft-service,no-craft-service-component-same-file,prefer-craft-http-client,require-yieldable-reactive-read,require-yieldable-template-method,require-yieldable-insertion-write. These ask for real refactors.
The design system is the only way to style a component. The style rules — no-raw-class, no-inline-style, no-component-css, no-raw-css-value, no-free-has, style-file-boundary — are in craftRules.configs.recommended at 'error', in every file: none of them waits for a file to import @craft-ts/style.
To migrate a project in steps, turn the three binding rules off in its own ESLint config, with a TODO comment the migration removes, and keep the rules that read the legacy component CSS on in the meantime:
{
// TODO: remove once this project is migrated to @craft-ts/style.
files: ['**/src/**/*.ts'],
rules: {
...craftRules.configs.legacyComponentCss.rules,
'craft-ts/no-raw-class': 'off',
'craft-ts/no-inline-style': 'off',
'craft-ts/no-component-css': 'off',
},
},legacyComponentCss groups the rules that read a component's CSS text — craft-css-vars-contract, craft-styles-scope-safe, craft-css-var-naming, craft-css-token-registry, no-hardcoded-design-values, no-important-in-component-styles, require-focus-visible, require-reduced-motion. They left recommended because no-component-css leaves them nothing to read.
A genuine bypass — a third-party widget that ships its own CSS, HTML rendered from markdown — stays possible, one line at a time, with a reason: // eslint-disable-next-line craft-ts/no-component-css -- vendor date picker ships its stylesheet.
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
- Architecture rules — graph-wide constraints ESLint cannot see
- Activating the style system — what the four style rules are guarding