-
Notifications
You must be signed in to change notification settings - Fork 1
chore(internals): add new rules for class inheritance and aria attribute management #92
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,6 +16,8 @@ import requireElementDefinitions from '../local/require-element-definitions.js'; | |
| import requireTestCompleteness from '../local/require-test-completeness.js'; | ||
| import requireComposedEvents from '../local/require-composed-events.js'; | ||
| import noMissingBundleRegistration from '../local/no-missing-bundle-registration.js'; | ||
| import noHostManagedAriaAttributes from '../local/no-host-managed-aria-attributes.js'; | ||
| import noSingleConsumerInternalBase from '../local/no-single-consumer-internal-base.js'; | ||
|
|
||
| const source = ['src/**/*.ts', 'src/**/*.tsx', 'src/**/*.d.ts']; | ||
| const tests = [ | ||
|
|
@@ -64,7 +66,9 @@ export const litConfig = [ | |
| 'require-element-definitions': requireElementDefinitions, | ||
| 'require-test-completeness': requireTestCompleteness, | ||
| 'require-composed-events': requireComposedEvents, | ||
| 'no-missing-bundle-registration': noMissingBundleRegistration | ||
| 'no-missing-bundle-registration': noMissingBundleRegistration, | ||
| 'no-host-managed-aria-attributes': noHostManagedAriaAttributes, | ||
| 'no-single-consumer-internal-base': noSingleConsumerInternalBase | ||
| } | ||
| } | ||
| }, | ||
|
|
@@ -134,6 +138,8 @@ export const litConfig = [ | |
| 'local/require-internal-host': ['error'], | ||
| 'local/require-element-definitions': ['error'], | ||
| 'local/require-composed-events': ['error'], | ||
| 'local/no-host-managed-aria-attributes': ['error'], | ||
| 'local/no-single-consumer-internal-base': ['error'], | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This rule helps prevent agents from creating unnecessary base class abstractions when there is no shared code logic between classes or components. |
||
| 'local/no-missing-bundle-registration': [ | ||
| 'error', | ||
| { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,7 @@ import tseslint from 'typescript-eslint'; | |
| import importPlugin from 'eslint-plugin-import'; | ||
| import jsdoc from 'eslint-plugin-jsdoc'; | ||
| import deadCode from '../local/dead-code.js'; | ||
| import noDeepClassInheritance from '../local/no-deep-class-inheritance.js'; | ||
| import exampleMetadata from '../local/example-metadata.js'; | ||
| import exampleNaming from '../local/example-naming.js'; | ||
| import exampleTemplateSize from '../local/example-template-size.js'; | ||
|
|
@@ -48,6 +49,7 @@ const config = { | |
| 'local-typescript': { | ||
| rules: { | ||
| 'no-dead-code': deadCode, | ||
| 'no-deep-class-inheritance': noDeepClassInheritance, | ||
| 'example-metadata': exampleMetadata, | ||
| 'example-naming': exampleNaming, | ||
| 'example-template-size': exampleTemplateSize, | ||
|
|
@@ -106,6 +108,10 @@ const config = { | |
| 'jsdoc/check-tag-names': 'error', | ||
| 'jsdoc/informative-docs': 'error', | ||
| 'local-typescript/no-dead-code': ['warn'], // todo, this should be migrated to the internal playground template config | ||
| 'local-typescript/no-deep-class-inheritance': [ | ||
| 'error', | ||
| { maxDepth: 2, allowedRoots: ['HTMLElement', 'LitElement', 'FormControlMixin', 'BaseButton'] } | ||
| ], | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is to keep agents from wanting to default to large inheritance chains instead of using composition based solutions |
||
| 'local-typescript/require-listener-cleanup': 'error', | ||
| 'local-typescript/require-observer-cleanup': 'error', | ||
| 'local-typescript/require-timer-cleanup': 'error', | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,114 @@ | ||
| const DEFAULT_MAX_DEPTH = 2; | ||
| const DEFAULT_ALLOWED_ROOTS = ['HTMLElement', 'LitElement']; | ||
|
|
||
| /** | ||
| * ESLint rule that limits class inheritance depth. The rule counts superclass | ||
| * hops until it reaches a configured allowed root class so local wrappers around | ||
| * platform/framework bases stay possible without allowing hierarchy creep. | ||
| * | ||
| * @type {import('eslint').Rule.RuleModule} | ||
| */ | ||
| export default { | ||
| meta: { | ||
| type: 'problem', | ||
| name: 'no-deep-class-inheritance', | ||
| docs: { | ||
| description: 'Disallows class inheritance chains deeper than the configured maximum.', | ||
| category: 'Best Practice', | ||
| recommended: true | ||
| }, | ||
| schema: [ | ||
| { | ||
| type: 'object', | ||
| additionalProperties: false, | ||
| properties: { | ||
| maxDepth: { | ||
| type: 'integer', | ||
| minimum: 1 | ||
| }, | ||
| allowedRoots: { | ||
| type: 'array', | ||
| items: { type: 'string' }, | ||
| uniqueItems: true | ||
| } | ||
| } | ||
| } | ||
| ], | ||
| messages: { | ||
| 'too-deep': | ||
| '`{{className}}` has inheritance depth {{depth}} (`{{chain}}`). Maximum allowed depth is {{maxDepth}}.' | ||
| } | ||
| }, | ||
| create(context) { | ||
| const options = context.options[0] ?? {}; | ||
| const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH; | ||
| const allowedRoots = new Set(options.allowedRoots ?? DEFAULT_ALLOWED_ROOTS); | ||
|
|
||
| return { | ||
| ClassDeclaration(node) { | ||
| if (!node.superClass) { | ||
| return; | ||
| } | ||
|
|
||
| const services = context.sourceCode.parserServices; | ||
| if (!services?.program || !services.esTreeNodeToTSNodeMap) { | ||
| return; | ||
| } | ||
|
|
||
| const superClass = services.esTreeNodeToTSNodeMap.get(node.superClass); | ||
| const chain = getInheritanceChain(superClass, services.program.getTypeChecker(), allowedRoots); | ||
|
|
||
| if (chain.length <= maxDepth) { | ||
| return; | ||
| } | ||
|
|
||
| context.report({ | ||
| node, | ||
| messageId: 'too-deep', | ||
| data: { | ||
| className: node.id?.name ?? '<anonymous>', | ||
| depth: String(chain.length), | ||
| maxDepth: String(maxDepth), | ||
| chain: `${node.id?.name ?? '<anonymous>'} -> ${chain.join(' -> ')}` | ||
| } | ||
| }); | ||
| } | ||
| }; | ||
| } | ||
| }; | ||
|
|
||
| function getInheritanceChain(superClass, checker, allowedRoots) { | ||
| const chain = []; | ||
| const visited = new Set(); | ||
| let expression = superClass; | ||
|
|
||
| while (expression) { | ||
| const symbol = checker.getTypeAtLocation(expression).symbol; | ||
| const className = symbol?.getName() ?? expression.getText(); | ||
|
|
||
| chain.push(className); | ||
|
|
||
| if (allowedRoots.has(className)) { | ||
| break; | ||
| } | ||
|
|
||
| const declaration = getClassDeclaration(symbol); | ||
| if (!declaration || visited.has(declaration)) { | ||
| break; | ||
| } | ||
|
|
||
| visited.add(declaration); | ||
| expression = getExtendsExpression(declaration); | ||
| } | ||
|
|
||
| return chain; | ||
| } | ||
|
|
||
| function getClassDeclaration(symbol) { | ||
| return symbol?.declarations?.find(declaration => Array.isArray(declaration.members)) ?? null; | ||
| } | ||
|
|
||
| function getExtendsExpression(classDeclaration) { | ||
| const heritageClause = classDeclaration.heritageClauses?.find(clause => clause.getText().startsWith('extends ')); | ||
| return heritageClause?.types?.[0]?.expression ?? null; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,137 @@ | ||
| import { beforeEach, test } from 'node:test'; | ||
| import assert from 'node:assert/strict'; | ||
| import { RuleTester } from 'eslint'; | ||
| import tseslint from 'typescript-eslint'; | ||
| import noDeepClassInheritance from './no-deep-class-inheritance.js'; | ||
|
|
||
| let tester; | ||
|
|
||
| beforeEach(() => { | ||
| tester = new RuleTester({ | ||
| languageOptions: { | ||
| parser: tseslint.parser, | ||
| parserOptions: { | ||
| ecmaVersion: 'latest', | ||
| sourceType: 'module', | ||
| projectService: { allowDefaultProject: ['*.ts'] }, | ||
| tsconfigRootDir: import.meta.dirname | ||
| } | ||
| } | ||
| }); | ||
| }); | ||
|
|
||
| test('defines rule metadata', () => { | ||
| assert.equal(noDeepClassInheritance.meta.type, 'problem'); | ||
| assert.equal(noDeepClassInheritance.meta.name, 'no-deep-class-inheritance'); | ||
| assert.ok(noDeepClassInheritance.meta.messages['too-deep']); | ||
| }); | ||
|
|
||
| test('valid: allows direct and depth-2 class inheritance', () => { | ||
| tester.run('no-deep-class-inheritance', noDeepClassInheritance, { | ||
| valid: [ | ||
| { | ||
| filename: 'direct-lit.ts', | ||
| code: ` | ||
| declare class LitElement {} | ||
|
|
||
| class Badge extends LitElement {} | ||
| ` | ||
| }, | ||
| { | ||
| filename: 'depth-two.ts', | ||
| code: ` | ||
| declare class LitElement {} | ||
|
|
||
| class BaseButton extends LitElement {} | ||
| class SortButton extends BaseButton {} | ||
| ` | ||
| }, | ||
| { | ||
| filename: 'event-target-root.ts', | ||
| code: ` | ||
| class VideoGroup extends EventTarget {} | ||
| class ManagedVideoGroup extends VideoGroup {} | ||
| ` | ||
| } | ||
| ], | ||
| invalid: [] | ||
| }); | ||
| }); | ||
|
|
||
| test('valid: supports custom maxDepth and allowedRoots options', () => { | ||
| tester.run('no-deep-class-inheritance', noDeepClassInheritance, { | ||
| valid: [ | ||
| { | ||
| filename: 'custom-max-depth.ts', | ||
| options: [{ maxDepth: 3 }], | ||
| code: ` | ||
| declare class LitElement {} | ||
|
|
||
| class BaseButton extends LitElement {} | ||
| class SortButton extends BaseButton {} | ||
| class ToolbarSortButton extends SortButton {} | ||
| ` | ||
| }, | ||
| { | ||
| filename: 'custom-root.ts', | ||
| options: [{ allowedRoots: ['BaseElement'] }], | ||
| code: ` | ||
| class BaseElement {} | ||
| class Control extends BaseElement {} | ||
| class Color extends Control {} | ||
| ` | ||
| } | ||
| ], | ||
| invalid: [] | ||
| }); | ||
| }); | ||
|
|
||
| test('invalid: reports classes deeper than maxDepth', () => { | ||
| tester.run('no-deep-class-inheritance', noDeepClassInheritance, { | ||
| valid: [], | ||
| invalid: [ | ||
| { | ||
| filename: 'too-deep.ts', | ||
| code: ` | ||
| declare class LitElement {} | ||
|
|
||
| class BaseButton extends LitElement {} | ||
| class SortButton extends BaseButton {} | ||
| class ToolbarSortButton extends SortButton {} | ||
| `, | ||
| errors: [ | ||
| { | ||
| messageId: 'too-deep', | ||
| data: { | ||
| className: 'ToolbarSortButton', | ||
| depth: '3', | ||
| maxDepth: '2', | ||
| chain: 'ToolbarSortButton -> SortButton -> BaseButton -> LitElement' | ||
| } | ||
| } | ||
| ] | ||
| }, | ||
| { | ||
| filename: 'custom-max-depth-invalid.ts', | ||
| options: [{ maxDepth: 1, allowedRoots: ['BaseElement'] }], | ||
| code: ` | ||
| class BaseElement {} | ||
|
|
||
| class Control extends BaseElement {} | ||
| class Radio extends Control {} | ||
| `, | ||
| errors: [ | ||
| { | ||
| messageId: 'too-deep', | ||
| data: { | ||
| className: 'Radio', | ||
| depth: '2', | ||
| maxDepth: '1', | ||
| chain: 'Radio -> Control -> BaseElement' | ||
| } | ||
| } | ||
| ] | ||
| } | ||
| ] | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Document the other two new rules.
The PR adds three new rules (
no-deep-class-inheritance,no-host-managed-aria-attributes, andno-single-consumer-internal-base), but the README only documentsno-deep-class-inheritance. The other two rules are registered insrc/configs/lit.jsbut are missing from this catalog.Please add entries for:
no-host-managed-aria-attributes(likely under "Component API shape")no-single-consumer-internal-base(likely under "Source hygiene")🤖 Prompt for AI Agents