Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions knip.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export default {
'@nvidia-elements/forms',
'@nvidia-elements/lint',
'@nvidia-elements/markdown',
'@nvidia-elements/media',
'@nvidia-elements/styles',
'@semantic-release/commit-analyzer',
'@semantic-release/github',
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions projects/internals/eslint/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ Applied to `src/**/*.ts`, `src/**/*.tsx`, test files, and `*.examples.ts`.
**Source hygiene**

- **`no-dead-code`**. Flags commented-out imports, exports, declarations, control-flow, and test blocks. The project currently sets this to `warn` during cleanup.
- **`no-deep-class-inheritance`**. Limits class inheritance chains to two superclass hops by default, stopping at configured `allowedRoots` such as `HTMLElement` and `LitElement`.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Document the other two new rules.

The PR adds three new rules (no-deep-class-inheritance, no-host-managed-aria-attributes, and no-single-consumer-internal-base), but the README only documents no-deep-class-inheritance. The other two rules are registered in src/configs/lit.js but 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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@projects/internals/eslint/README.md` at line 77, The README's ESLint rules
catalog is missing entries for the two newly registered rules; add concise
entries for no-host-managed-aria-attributes and no-single-consumer-internal-base
to the README: document no-host-managed-aria-attributes under the "Component API
shape" section (describe that it forbids components from managing ARIA
attributes on host that should be managed by consumers) and document
no-single-consumer-internal-base under "Source hygiene" (describe that it
prevents creating internal base classes that are intended for a single
consumer), and ensure the rule names match the registrations in
src/configs/lit.js so readers can locate them.

- **`require-spdx-header`**. Every source file must start with the two-line SPDX header (`SPDX-FileCopyrightText` copyright + `SPDX-License-Identifier: Apache-2.0`). The rule accepts any 4-digit year; auto-fix preserves an existing year and falls back to the current year only when inserting a header from scratch.

### Example rules (plugin `local-typescript`, files `**/*.examples.ts`)
Expand Down
8 changes: 7 additions & 1 deletion projects/internals/eslint/src/configs/lit.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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
}
}
},
Expand Down Expand Up @@ -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'],
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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',
{
Expand Down
6 changes: 6 additions & 0 deletions projects/internals/eslint/src/configs/typescript.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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'] }
],
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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',
Expand Down
114 changes: 114 additions & 0 deletions projects/internals/eslint/src/local/no-deep-class-inheritance.js
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;
}
137 changes: 137 additions & 0 deletions projects/internals/eslint/src/local/no-deep-class-inheritance.test.js
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'
}
}
]
}
]
});
});
Loading