@composurecdk/eslint-plugin
v0.11.2
Published
Internal ESLint plugin encoding ComposureCDK architectural invariants (tagged builders, lifecycle context, builder copy state).
Maintainers
Readme
@composurecdk/eslint-plugin
Internal ESLint plugin encoding ComposureCDK architectural invariants — tagged builders, lifecycle context, builder copy state.
npm install --save-dev @composurecdk/eslint-pluginShips a dual ESM/CommonJS build, so it loads from either kind of flat config. Rule names, messages, preset names and option names are public API — see Versioning.
Usage
Presets
Rules are grouped by what must be true of you for the rule to be correct, so you enable the tiers that describe your package (ADR-0019):
| Preset | Enable it if |
| ---------------- | ----------------------------------------------------------------------------- |
| recommended | You write a Lifecycle or a builder — whatever shape your project is. |
| libraryAuthor | You also publish a package other people compile against. |
| dualPublishing | You also ship both an ESM and a CommonJS build from one source. |
| internal | You are composureCDK. Published for reproducibility, not as a recommendation. |
They are additive, so extend every tier that describes you. A CDK application takes recommended alone: the libraryAuthor rules guard hazards that exist only once you emit a .d.ts someone else compiles against, and one of them (lifecycle-build-must-forward-context) reports on the root-level builds an application does as a matter of course.
Each is a self-contained flat config entry that registers the plugin, so extending it is all that is needed:
import composurecdk from "@composurecdk/eslint-plugin";
export default [
{
files: ["src/**/*.ts"],
extends: [composurecdk.configs.recommended, composurecdk.configs.libraryAuthor],
},
];From a CommonJS flat config the require() result is itself the plugin — rules and configs are own properties of it, so no .default unwrapping is needed:
const composurecdk = require("@composurecdk/eslint-plugin");
module.exports = [
{
files: ["src/**/*.ts"],
extends: [composurecdk.configs.recommended],
},
];Scoping is yours
No preset declares files. The rules are written for library source — applying them unscoped will flag test, fixture and config code — but only you know where your source lives, so pick the glob as above. (This repo uses packages/*/src/**/*.ts.)
File-level overrides (e.g. disabling a rule on a specific file) likewise belong in your config, not in the preset.
Picking rules individually
configs.recommended.rules is still a plain severity map, so you can register the plugin yourself and take a subset:
import composurecdk from "@composurecdk/eslint-plugin";
export default [
{
files: ["src/**/*.ts"],
plugins: { composurecdk },
rules: { "composurecdk/lifecycle-build-context-required": "error" },
},
];Mixing the two forms is safe: the preset registers the same plugin object this plugins entry does, so ESLint sees one plugin rather than a conflicting redefinition.
Rules
Each rule has a documentation page with its rationale, examples, deliberate exclusions and known gaps. meta.docs.url points at it, so an editor can open it straight from a reported violation.
| Rule | Preset | What it flags |
| --------------------------------------------------------------------------------------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| composurecdk/builder-must-be-tagged | internal | Builder / IBuilder from @composurecdk/core in a library builder — use the tagged equivalents. |
| composurecdk/builder-must-implement-copy-state | recommended | A builder class holding private state with no [COPY_STATE] hook, so .copy() silently drops it. |
| composurecdk/constraint-metadata-required | internal | A stringConstraint({ … }) call with an empty name, allowed or source, degrading every error it produces. |
| composurecdk/lifecycle-build-context-required | recommended | Lifecycle.build() missing the context param when the class uses Resolvable<…>. |
| composurecdk/lifecycle-build-must-forward-context | libraryAuthor | A two-argument builder.build(scope, id) — the sub-builder gets no context, so refs cannot resolve. |
| composurecdk/no-cdk-api-above-floor | internal | aws-cdk-lib APIs newer than the supported peer floor — they throw on older versions in the range. Takes an option. |
| composurecdk/no-cjs-incompatible-syntax | dualPublishing | import.meta / top-level await in library src/ — neither emits to CommonJS. |
| composurecdk/no-realm-bound-instanceof | recommended | instanceof against an imported class — realm-bound, so it silently returns false across the dual-package boundary. Takes an option. |
| composurecdk/no-typescript-private-modifier | libraryAuthor | The TypeScript private modifier — it stays in keyof T and leaks through mapped types into the emitted .d.ts. |
| composurecdk/redeclared-prop-must-track-cdk-type | libraryAuthor | A prop re-declared out of an Omit<CdkProps, …> that pins a CDK interface instead of reading CDK's own prop type. |
Versioning
Rule names, messages and default severities are public API (ADR-0019):
- A new rule ships registered but in no preset, so you can enable it the day it lands. It joins a preset in a major: a caret range — what
npm installwrites — picks up minors automatically, so a new rule in a preset turns green CI red on unchanged code. - Tightening an existing rule is breaking, including a widened selector or a narrowed exemption. New code failing an unchanged rule is not.
- Loosening a rule, removing one, or renaming one is breaking. A removed or renamed rule id in your own
rules:block is a hard config error, not a silent gap. - Moving a rule between presets, or renaming a preset, is breaking. Adding a new preset is not.
- Option names and their defaults are API too. Adding an option is minor when its default preserves behaviour, and breaking otherwise. Renaming one is breaking — the schemas reject unknown keys, so an old name becomes a hard config error rather than a silent no-op.
Adding a new rule
Create
src/rules/<kebab-name>.ts:import type { Rule } from "eslint"; export const rule: Rule.RuleModule = { meta: { type: "problem", docs: { description: "..." }, schema: [], messages: { someId: "..." }, }, create(ctx) { return {/* visitor */}; }, };Register it in
src/rules/index.ts.Decide its tier in
src/configs/presets.ts— the preset whose truth condition it matches, or none if it is landing ahead of the next major. A test asserts no rule lands in two tiers, and that a rule in none is listed as deliberate — see ADR-0019.Write
docs/rules/<kebab-name>.md, leading with# composurecdk/<kebab-name>. This is where rationale, examples, deliberate exclusions and known gaps live — the rule's own source comment stays a summary plus a link.meta.docs.urlis derived from the name you registered, so there is nothing to wire up.Add a row to the table above, linking the page.
Write
test/rules/<kebab-name>.test.tsusingRuleTester(see existing tests). Cover at least one valid and one invalid case permessageId.If the rule takes options, give each schema property a
description(it is what--print-configand config inspectors show), keepadditionalProperties: falseso a typo fails loudly, and cover both settings in the tests. Option names are API — see Versioning above.
Steps 3–5 are each asserted by test/docs.test.ts and test/configs/presets.test.ts — skip one and the suite fails rather than the omission going unnoticed.
Running tests
npx nx test eslint-pluginTests use Vitest as the runner with ESLint's RuleTester driving fixtures. The shared RuleTester instance (configured with the typescript-eslint parser) lives in test/rule-tester.ts.
