@codexo/eslint-plugin-exojs
v0.1.0
Published
ESLint rules for ExoJS projects: lifecycle hooks the frame loop calls synchronously, render pass and filter contracts, teardown chaining, never-registered systems, and deprecated engine imports.
Downloads
148
Maintainers
Readme
@codexo/eslint-plugin-exojs
ESLint rules for the mistakes ExoJS cannot reject at compile time and will not report at runtime either: a lifecycle hook made async that the frame loop never awaits, a render pass that second-guesses the enabled flag its pipeline already checked, a destroy() override that leaves the base's GPU resources resident, and a system that is built but never registered.
npm install --save-dev @codexo/eslint-plugin-exojsNo runtime dependencies. No type information: every rule works on the parsed file alone, so adding this plugin does not put the TypeScript checker in your lint path.
Setup
import { exoRulesConfig } from '@codexo/eslint-plugin-exojs';
import { defineConfig } from 'eslint/config';
export default defineConfig([
//
...exoRulesConfig({ files: ['src/**/*.ts'] }),
]);exoRulesConfig registers the plugin under the exojs prefix and turns on one tier of rules. Register it by hand instead if you would rather choose rules individually:
import { exoPlugin } from '@codexo/eslint-plugin-exojs';
export default [
{
files: ['src/**/*.ts'],
plugins: { exojs: exoPlugin },
rules: { 'exojs/no-async-update': 'error' },
},
];Tiers
| Tier | Rules |
| ----------------------- | --------------------------------------------------------------------------------------------------------------------- |
| recommended (default) | no-async-update, no-async-render-hook, no-self-enabled-check, require-super-destroy, no-unregistered-system |
| strict | everything in recommended, plus no-deprecated-api |
Both tiers set every rule to error. The split is about what a rule is for, not about how much it matters: recommended describes code that does not do what it says, strict adds the migration check that reports imports of APIs the engine has deprecated. Deprecation is opt-in across the ecosystem - eslint:recommended carries no deprecation rule at all - so it is a tier you choose, and when you do, it fails the build like everything else.
...exoRulesConfig({ files: ['src/**/*.ts'], tier: 'strict', deprecatedApi });no-deprecated-api needs a table of deprecated names, which collectDeprecatedExports builds by reading the @deprecated JSDoc tags in the @codexo/exojs declaration files you have installed:
import { globSync } from 'node:fs';
import { createRequire } from 'node:module';
import { dirname, resolve } from 'node:path';
import { collectDeprecatedExports } from '@codexo/eslint-plugin-exojs';
const engineDist = dirname(createRequire(import.meta.url).resolve('@codexo/exojs'));
const deprecatedApi = collectDeprecatedExports(globSync('**/*.d.ts', { cwd: engineDist }).map(file => resolve(engineDist, file)));Rules
exojs/no-async-update
Flags async preFrame / fixedUpdate / update / draw / postFrame / render on a class, a class field or an object literal.
The frame loop calls these synchronously and drops the promise: the work lands a frame or more later, out of order, and a rejection surfaces as an unhandled rejection instead of through the application's error pipeline. Start the asynchronous work in the hook and await it elsewhere.
Matched by name alone, with no check that the object really is a system or a scene - these six names are distinctive enough that an unrelated async fixedUpdate() does not occur in practice. load(), unload() and destroy() are genuinely asynchronous or genuinely teardown, and are not in the set. A computed key (async ['update']()) is not checked.
exojs/no-async-render-hook
Flags async execute in a class that extends RenderPass, and async apply or async getOutputBounds in a class that extends Filter.
Same defect, opposite trade-off: execute and apply are ordinary words, so this rule pays for its specificity with the extends clause instead of trusting the name. A pass that returns a promise draws after the frame it belonged to, into a render target the pool may already have handed to someone else.
Configure other bases with hooks:
rules: { 'exojs/no-async-render-hook': ['error', { hooks: { RenderPass: ['execute'], MyEffectBase: ['run'] } }] }Only a literal extends <Identifier> is matched, so a subclass that reaches RenderPass through an intermediate class in another file is not seen.
exojs/no-self-enabled-check
Flags any read of this.enabled inside execute of a class that extends RenderPass.
enabled is the containing pipeline's decision, not the pass's: the pipeline skips a disabled pass, and executing a pass directly is meant to run it regardless. A self-check gives the same flag two authorities and silently overrides the direct call.
This is a check on the pattern, not the position - a guard on the last line counts exactly as much as one on the first. Reading enabled on another pass (child.enabled) is untouched; that is how a pipeline skips its own children. Writing this.enabled = false is untouched too. Reaching the field through a destructuring (const { enabled } = this) is not seen.
Configure with baseClasses and hook.
exojs/require-super-destroy
Flags a destroy() override that never calls super.destroy(), in a class extending one of a configured list of base classes.
GPU lifetime in ExoJS is deterministic and not tied to garbage collection, so the base's destroy() is what actually releases the buffers a render root owns, unlinks the node from its parent, and raises the flag that makes a second call a no-op. The call may sit anywhere in the override - the rule looks for it, not for it being first.
The base list is configuration on purpose: without it the rule would report every subclass of every base, including the majority whose base has no destroy() at all. It defaults to the ExoJS types that own something a subclass cannot release for it, exported as EXO_DESTROY_BASE_CLASSES. Scene is not among them - its destroy() is an empty hook for your own cleanup, and the scene's engine-owned teardown runs separately.
rules: { 'exojs/require-super-destroy': ['error', { baseClasses: [...EXO_DESTROY_BASE_CLASSES, 'MyEntityBase'] }] }exojs/no-unregistered-system
Flags const system = new SomethingSystem() when nothing in the file passes it to systems.add(...) and nothing calls a phase on it by hand.
A system does nothing until something drives it, and the symptom is silence rather than an error.
The rule reports only when it can see the whole life of the binding. If the binding is exported, returned, passed to a function, or stored on an object, registration may happen somewhere no single-file check can see, and the rule stays quiet. System-shaped is a name test (System$), not a type test.
Configure with pattern, registry, registerMethod and lifecycleMethods.
exojs/no-deprecated-api
Flags an import of a name the engine's own JSDoc marks @deprecated, with that tag's replacement note as the message. In the strict tier only.
Import specifiers only, matched against the imported name rather than the local binding, so an aliased import is caught too. A namespace import (import * as Exo) is not tracked.
Engine-internal rules
exojs-engine/* enforces contracts the ExoJS engine's own source makes - written promises in its doc comments, not promises made on your behalf. They ship in a second plugin object, exoEnginePlugin, registered under its own exojs-engine key, so they are unreachable from a config that has not asked for them: ESLint rejects the rule id outright rather than silently ignoring it. They are in no tier, and exoEngineRulesConfig is the supported entry point if you want one anyway.
exojs-engine/no-allocation-in-hot-hook
Flags object and array literals, new, per-call closures, and array-returning standard-library calls inside a method named in its methods option. It cannot see an allocation behind a helper call and does not try.
License
MIT
