From a894ffcca3c96f69f004112b48d9ddea59e43473 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Tue, 27 Oct 2026 07:12:38 +0900 Subject: [PATCH] fix(eslint-plugin): guard runtime style spreads with type information Refs #684, #695; follows #756 JSX-02 type guard policy Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- ..._log_type_aware_unknown_style_spreads.json | 7 + eslint-typescript6.cjs | 13 +- packages/__tests__/eslint-typescript6.test.ts | 82 ++++++++ packages/eslint-plugin/README.md | 29 ++- .../eslint-plugin/src/__tests__/index.test.ts | 40 ++-- .../recommended-type-checked.test.ts | 80 +++++++ .../src/configs/recommended-type-checked.ts | 21 ++ packages/eslint-plugin/src/index.ts | 2 + .../src/rules/__tests__/index.test.ts | 37 ++-- packages/eslint-plugin/src/rules/index.ts | 1 + .../rules/no-unknown-style-spread/README.md | 128 +++++++++++ .../__tests__/agreement-cases.ts | 99 +++++++++ .../__tests__/build-agreement.test.ts | 125 +++++++++++ .../__tests__/fixtures/case.tsx | 1 + .../__tests__/fixtures/imported.ts | 1 + .../__tests__/fixtures/key-domain/case.tsx | 1 + .../fixtures/key-domain/tsconfig.json | 9 + .../__tests__/fixtures/known.ts | 1 + .../__tests__/fixtures/no-intrinsics.d.ts | 7 + .../__tests__/fixtures/tsconfig.json | 20 ++ .../__tests__/index.test.ts | 158 ++++++++++++++ .../__tests__/key-domain.test.ts | 118 +++++++++++ .../__tests__/oracle-cases.test.ts | 199 ++++++++++++++++++ .../__tests__/runtime-cases.ts | 51 +++++ .../__tests__/schema-inputs.test.ts | 57 +++++ .../__tests__/typed-rule-tester.ts | 10 + .../no-unknown-style-spread/dom-properties.ts | 64 ++++++ .../rules/no-unknown-style-spread/index.ts | 98 +++++++++ .../no-unknown-style-spread/key-domain.ts | 156 ++++++++++++++ .../no-unknown-style-spread/runtime-source.ts | 84 ++++++++ .../style-capability.ts | 135 ++++++++++++ 31 files changed, 1787 insertions(+), 47 deletions(-) create mode 100644 .changepacks/changepack_log_type_aware_unknown_style_spreads.json create mode 100644 packages/__tests__/eslint-typescript6.test.ts create mode 100644 packages/eslint-plugin/src/configs/__tests__/recommended-type-checked.test.ts create mode 100644 packages/eslint-plugin/src/configs/recommended-type-checked.ts create mode 100644 packages/eslint-plugin/src/rules/no-unknown-style-spread/README.md create mode 100644 packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/agreement-cases.ts create mode 100644 packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/build-agreement.test.ts create mode 100644 packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/fixtures/case.tsx create mode 100644 packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/fixtures/imported.ts create mode 100644 packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/fixtures/key-domain/case.tsx create mode 100644 packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/fixtures/key-domain/tsconfig.json create mode 100644 packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/fixtures/known.ts create mode 100644 packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/fixtures/no-intrinsics.d.ts create mode 100644 packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/fixtures/tsconfig.json create mode 100644 packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/index.test.ts create mode 100644 packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/key-domain.test.ts create mode 100644 packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/oracle-cases.test.ts create mode 100644 packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/runtime-cases.ts create mode 100644 packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/schema-inputs.test.ts create mode 100644 packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/typed-rule-tester.ts create mode 100644 packages/eslint-plugin/src/rules/no-unknown-style-spread/dom-properties.ts create mode 100644 packages/eslint-plugin/src/rules/no-unknown-style-spread/index.ts create mode 100644 packages/eslint-plugin/src/rules/no-unknown-style-spread/key-domain.ts create mode 100644 packages/eslint-plugin/src/rules/no-unknown-style-spread/runtime-source.ts create mode 100644 packages/eslint-plugin/src/rules/no-unknown-style-spread/style-capability.ts diff --git a/.changepacks/changepack_log_type_aware_unknown_style_spreads.json b/.changepacks/changepack_log_type_aware_unknown_style_spreads.json new file mode 100644 index 000000000..6ef54ba7a --- /dev/null +++ b/.changepacks/changepack_log_type_aware_unknown_style_spreads.json @@ -0,0 +1,7 @@ +{ + "changes": { + "packages/eslint-plugin/package.json": "Patch" + }, + "note": "Add opt-in type-aware diagnostics for provably runtime-only Devup style spreads and repair TypeScript 6 compatibility subpath resolution for typed project services. No new build errors or styling runtime.", + "date": "2026-10-27T00:00:00.000Z" +} diff --git a/eslint-typescript6.cjs b/eslint-typescript6.cjs index 2e9c67c83..908f565bd 100644 --- a/eslint-typescript6.cjs +++ b/eslint-typescript6.cjs @@ -2,20 +2,15 @@ // API yet. ESLint's TypeScript parser still requires that API, so route only // its `typescript` imports to the official side-by-side TypeScript 6 package. const Module = require('node:module') -const path = require('node:path') const { pathToFileURL } = require('node:url') const originalResolveFilename = Module._resolveFilename -const typescript6Package = - require.resolve('@typescript/typescript6/package.json') -const typescript6Directory = path.dirname(typescript6Package) function resolveTypeScript6(request) { - if (request === 'typescript') { - return path.join(typescript6Directory, 'lib', 'typescript.js') - } - if (request.startsWith('typescript/')) { - return path.join(typescript6Directory, request.slice('typescript/'.length)) + if (request === 'typescript' || request.startsWith('typescript/')) { + return require.resolve( + '@typescript/typescript6' + request.slice('typescript'.length), + ) } } diff --git a/packages/__tests__/eslint-typescript6.test.ts b/packages/__tests__/eslint-typescript6.test.ts new file mode 100644 index 000000000..dc09b9742 --- /dev/null +++ b/packages/__tests__/eslint-typescript6.test.ts @@ -0,0 +1,82 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +import { expect, it } from 'bun:test' + +const root = resolve(import.meta.dir, '../..') +const script = ` +const assert = require('node:assert/strict'); +const { createRequire } = require('node:module'); +const path = require('node:path'); +const root = process.argv[1]; +const directory = process.argv[2]; +const fromRoot = createRequire(path.join(root, 'package.json')); +const fromPlugin = createRequire(path.join(root, 'packages/eslint-plugin/package.json')); +async function main() { + for (const suffix of ['', '/lib/tsserverlibrary', '/lib/typescript.js', '/package.json']) { + const actual = fromRoot.resolve('typescript' + suffix); + const expected = fromRoot.resolve('@typescript/typescript6' + suffix); + assert.equal(actual, expected); + assert.ok(fromRoot('typescript' + suffix)); + } + assert.equal(fromRoot.resolve('node:path'), 'node:path'); + assert.equal(fromRoot('node:path'), path); + const server = await import('typescript/lib/tsserverlibrary'); + assert.equal(typeof server.default.createProgram, 'function'); + const { Linter } = fromRoot('eslint'); + const tseslint = fromPlugin('typescript-eslint'); + const code = 'async function run() { await 123; }'; + const messages = new Linter({ cwd: directory }).verify(code, [{ + files: ['**/*.ts'], + languageOptions: { + parser: tseslint.parser, + parserOptions: { projectService: true, tsconfigRootDir: directory }, + }, + plugins: { '@typescript-eslint': tseslint.plugin }, + rules: { '@typescript-eslint/await-thenable': 'error' }, + }], { filename: path.join(directory, 'probe.ts') }); + assert.deepEqual(messages.map(({ ruleId, messageId, fatal }) => ({ ruleId, messageId, fatal })), [ + { ruleId: '@typescript-eslint/await-thenable', messageId: 'await', fatal: undefined }, + ]); +} +main().catch((error) => { console.error(error); process.exitCode = 1; }); +` + +it('resolves compatibility subpaths and runs a typed project-service rule through the Node shim', () => { + const directory = mkdtempSync(join(tmpdir(), 'devup-eslint-typescript6-')) + try { + writeFileSync( + join(directory, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + strict: true, + target: 'ESNext', + lib: ['ESNext'], + types: [], + }, + include: ['probe.ts'], + }), + ) + writeFileSync( + join(directory, 'probe.ts'), + 'async function run() { await 123; }', + ) + const run = Bun.spawnSync( + [ + 'node', + '--require', + join(root, 'eslint-typescript6.cjs'), + '-e', + script, + root, + directory, + ], + { cwd: root, timeout: 60000 }, + ) + expect(run.stderr.toString()).toBe('') + expect(run.exitCode).toBe(0) + } finally { + rmSync(directory, { recursive: true, force: true }) + } +}, 65000) diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md index 9782f521d..4eb27b864 100644 --- a/packages/eslint-plugin/README.md +++ b/packages/eslint-plugin/README.md @@ -1,6 +1,6 @@ # @devup-ui/eslint-plugin -ESLint rules that check code the way the Devup UI build reads it. A rule reports and fixes only what the build reads as styles, and reports what the build rejects before the build does. +ESLint checks for Devup UI. Syntax-only rules follow the build's style positions and refusals. The opt-in typed spread guard additionally warns when style-capable runtime props would be passed through rather than styled. ## Usage @@ -11,12 +11,39 @@ import devupUi from '@devup-ui/eslint-plugin' export default [...devupUi.configs.recommended] ``` +For the opt-in [type-aware unknown-spread guard](src/rules/no-unknown-style-spread/README.md), +use `configs.recommendedTypeChecked` and configure your lint project's parser services: + +```js +export default [ + ...devupUi.configs.recommendedTypeChecked, + { + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + }, +] +``` + +The typed rule uses the separate `@devup-ui/type-checked` namespace so the presets +compose without redefining the existing plugin. `recommended` remains syntax-only. + +The guard follows verified build value flow: style-capable runtime defaults and +runtime-first `rest || fallback` / `rest ?? fallback` spreads report because they +pass through without extracting styles. Known-first imported controls compose and +remain allowed. Mixed conditionals already refused by the build receive no extra +typed-spread diagnostic; unclassified expressions remain the build's decision. + ## Rules | Rule | Checks | Fix | | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --- | | [`css-utils-literal-only`](src/rules/css-utils-literal-only/README.md) | values of `css`, `globalCss`, `keyframes`, `createGlobalStyle` and `` are known at build time, and `.css.ts` stylesheets read no global that does not exist there | no | | [`no-runtime-read`](src/rules/no-runtime-read/README.md) | what the build compiles away (`css`, `keyframes`, `styled`, `Box`...) is read only where it is called or rendered | no | +| [`no-unknown-style-spread`](src/rules/no-unknown-style-spread/README.md) | opt-in typed guard for runtime-rooted spreads whose types can carry Devup styles; concrete DOM-only props remain allowed | no | | [`no-duplicate-value`](src/rules/no-duplicate-value/README.md) | consecutive duplicate values of a responsive array | yes | | [`no-useless-responsive`](src/rules/no-useless-responsive/README.md) | responsive arrays with a single value | yes | | [`no-useless-tailing-nulls`](src/rules/no-useless-tailing-nulls/README.md) | trailing `null`s of a responsive array | yes | diff --git a/packages/eslint-plugin/src/__tests__/index.test.ts b/packages/eslint-plugin/src/__tests__/index.test.ts index d0cd2eb67..3fc978fe9 100644 --- a/packages/eslint-plugin/src/__tests__/index.test.ts +++ b/packages/eslint-plugin/src/__tests__/index.test.ts @@ -1,19 +1,21 @@ -import { describe, expect, it } from 'bun:test' - -import * as index from '../index' - -describe('export index', () => { - it('export', () => { - expect({ ...index }).toEqual({ - rules: expect.any(Object), - configs: { - recommended: expect.any(Object), - }, - default: { - configs: { - recommended: expect.any(Object), - }, - }, - }) - }) -}) +import { describe, expect, it } from 'bun:test' + +import * as index from '../index' + +describe('export index', () => { + it('export', () => { + expect({ ...index }).toEqual({ + rules: expect.any(Object), + configs: { + recommended: expect.any(Object), + recommendedTypeChecked: expect.any(Array), + }, + default: { + configs: { + recommended: expect.any(Object), + recommendedTypeChecked: expect.any(Array), + }, + }, + }) + }) +}) diff --git a/packages/eslint-plugin/src/configs/__tests__/recommended-type-checked.test.ts b/packages/eslint-plugin/src/configs/__tests__/recommended-type-checked.test.ts new file mode 100644 index 000000000..8b5605ed0 --- /dev/null +++ b/packages/eslint-plugin/src/configs/__tests__/recommended-type-checked.test.ts @@ -0,0 +1,80 @@ +import { join } from 'node:path' + +import { describe, expect, it } from 'bun:test' +import { Linter } from 'eslint' +import { parser } from 'typescript-eslint' + +import { StyleSchemaError } from '../../rules/no-unknown-style-spread/style-capability' +import recommended from '../recommended' +import { recommendedTypeChecked } from '../recommended-type-checked' + +const root = join( + import.meta.dir, + '../../rules/no-unknown-style-spread/__tests__/fixtures', +) +const filename = join(root, 'case.tsx') +const code = + 'import { Box, type DevupProps } from "@devup-ui/react"; function f(props: DevupProps) { return }' +const project = { + languageOptions: { + parserOptions: { projectService: true, tsconfigRootDir: root }, + }, +} + +describe('recommendedTypeChecked', () => { + for (const configs of [ + recommendedTypeChecked, + [...recommended, ...recommendedTypeChecked], + [...recommendedTypeChecked, ...recommendedTypeChecked], + ]) { + it('reports runtime styles when typed configurations compose', () => { + // Given a real TS project and composed presets. + const linter = new Linter() + // When linting through ESLint's flat-config surface. + const messages = linter.verify(code, [...configs, project], { filename }) + // Then the typed rule reports once, without plugin redefinition. + expect( + messages.map(({ ruleId, messageId }) => ({ ruleId, messageId })), + ).toEqual([ + { + ruleId: '@devup-ui/type-checked/no-unknown-style-spread', + messageId: 'unknownStyleSpread', + }, + ]) + }, 30000) + } + + it('keeps syntax-only linting when only recommended is enabled', () => { + // Given the old preset without a program. + const linter = new Linter() + // When linting the same runtime spread with syntax-only TS parsing. + const messages = linter.verify( + code, + [...recommended, { files: ['**/*.tsx'], languageOptions: { parser } }], + { filename }, + ) + // Then no typed rule is enabled. + expect(messages).toEqual([]) + }) + + it('fails configuration when typed services are missing', () => { + // Given the typed rule enabled without project/projectService. + const linter = new Linter() + // When linting, then parser-services acquisition must fail even for an empty file. + expect(() => + linter.verify('export {}', recommendedTypeChecked, { filename }), + ).toThrow(Error) + }) + + it('fails resolution when a recognized primitive has no declared schema', () => { + // Given an invalid primitive import from the real compatibility entry. + const source = + 'import { Box } from "@devup-ui/react/compat"; function f(props: unknown) { return }' + // When linting, then no empty style schema may silently pass. + expect(() => + new Linter().verify(source, [...recommendedTypeChecked, project], { + filename, + }), + ).toThrow(StyleSchemaError) + }, 30000) +}) diff --git a/packages/eslint-plugin/src/configs/recommended-type-checked.ts b/packages/eslint-plugin/src/configs/recommended-type-checked.ts new file mode 100644 index 000000000..8fff27c05 --- /dev/null +++ b/packages/eslint-plugin/src/configs/recommended-type-checked.ts @@ -0,0 +1,21 @@ +import { parser } from 'typescript-eslint' + +import { noUnknownStyleSpread } from '../rules/no-unknown-style-spread' +import recommended from './recommended' + +const typedPlugin = { + rules: { 'no-unknown-style-spread': noUnknownStyleSpread }, +} + +export const recommendedTypeChecked = [ + ...recommended, + { + name: '@devup-ui/recommended-type-checked', + files: ['**/*.{ts,tsx,mts,cts}'], + languageOptions: { parser }, + plugins: { '@devup-ui/type-checked': typedPlugin }, + rules: { + '@devup-ui/type-checked/no-unknown-style-spread': 'error' as const, + }, + }, +] diff --git a/packages/eslint-plugin/src/index.ts b/packages/eslint-plugin/src/index.ts index 6342f5aca..c72bcaab0 100644 --- a/packages/eslint-plugin/src/index.ts +++ b/packages/eslint-plugin/src/index.ts @@ -1,8 +1,10 @@ import recommended from './configs/recommended' +import { recommendedTypeChecked } from './configs/recommended-type-checked' export * as rules from './rules' export const configs = { recommended, + recommendedTypeChecked, } export default { diff --git a/packages/eslint-plugin/src/rules/__tests__/index.test.ts b/packages/eslint-plugin/src/rules/__tests__/index.test.ts index ec99ed2e0..070ad8659 100644 --- a/packages/eslint-plugin/src/rules/__tests__/index.test.ts +++ b/packages/eslint-plugin/src/rules/__tests__/index.test.ts @@ -1,18 +1,19 @@ -import { describe, expect, it } from 'bun:test' - -import * as index from '../index' - -describe('export index', () => { - it('export', () => { - expect({ ...index }).toEqual({ - noUselessTailingNulls: expect.any(Object), - cssUtilsLiteralOnly: expect.any(Object), - noDuplicateValue: expect.any(Object), - noRuntimeRead: expect.any(Object), - noUselessResponsive: expect.any(Object), - styleOrderRange: expect.any(Object), - noTypographyTokenPrefix: expect.any(Object), - preferMediaShorthand: expect.any(Object), - }) - }) -}) +import { describe, expect, it } from 'bun:test' + +import * as index from '../index' + +describe('export index', () => { + it('export', () => { + expect({ ...index }).toEqual({ + noUselessTailingNulls: expect.any(Object), + cssUtilsLiteralOnly: expect.any(Object), + noDuplicateValue: expect.any(Object), + noRuntimeRead: expect.any(Object), + noUnknownStyleSpread: expect.any(Object), + noUselessResponsive: expect.any(Object), + styleOrderRange: expect.any(Object), + noTypographyTokenPrefix: expect.any(Object), + preferMediaShorthand: expect.any(Object), + }) + }) +}) diff --git a/packages/eslint-plugin/src/rules/index.ts b/packages/eslint-plugin/src/rules/index.ts index dea8ce5d4..aab63d5e3 100644 --- a/packages/eslint-plugin/src/rules/index.ts +++ b/packages/eslint-plugin/src/rules/index.ts @@ -2,6 +2,7 @@ export * from './css-utils-literal-only' export * from './no-duplicate-value' export * from './no-runtime-read' export * from './no-typography-token-prefix' +export * from './no-unknown-style-spread' export * from './no-useless-responsive' export * from './no-useless-tailing-nulls' export * from './prefer-media-shorthand' diff --git a/packages/eslint-plugin/src/rules/no-unknown-style-spread/README.md b/packages/eslint-plugin/src/rules/no-unknown-style-spread/README.md new file mode 100644 index 000000000..1b759068b --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-unknown-style-spread/README.md @@ -0,0 +1,128 @@ +# no-unknown-style-spread + +Opt-in, type-aware guard for JSX spreads on Devup primitives. The build still +decides what it can extract; this rule does not change runtime pass-through, +`className`, or `style` merging. + +The rule reports only when **both** are true: + +1. The expression is provably runtime-rooted: a function/arrow/method parameter + (including destructured/rest parameters), catch parameter, `arguments`, `this`, + or a never-reassigned local alias of one. Members preserve that origin. + An object with a runtime-rooted spread also qualifies. Runtime-first `||` and + `??` remain runtime-rooted even with a known fallback: the build passes the + spread through instead of extracting that fallback's styles. `&&` and + conditional expressions require **every value branch** to qualify. +2. Its TypeScript type can carry Devup styles. The schema comes from the real + primitive's public props, including selector/theme/custom shorthand types. + Named, union, intersection, constrained generic and index types are inspected; + `any`, `unknown`, `{}`, `object`, and unconstrained generics can carry styles. + +```tsx +import { Box, type DevupProps } from '@devup-ui/react' + +function unsafe(styles: DevupProps) { + return // report: runtime origin and style-capable type +} + +function explicit(color: string) { + return // allowed: known keys, dynamic value +} + +function dom(props: React.ImgHTMLAttributes) { + return // allowed, including DOM color/width +} +``` + +React DOM-origin properties do not count as Devup styles, including overlapping +`color`/`width` attributes and mapped DOM types (`Pick`, `Omit`, `Partial`, ...). +An authored `{ color?: string }` property is not proven DOM-origin and does count. +Only actual intrinsic attribute declarations are exempt: an event-value property +such as `React.PointerEvent.width` is not a DOM attribute just because it is +declared by React. Intersections use their effective properties, so an +undefined-only style property does not emit a diagnostic. +Generic intersections retain open constraints: `HTMLAttributes & T` reports when +`T` is unconstrained or constrained to `object`, `{}`, or `unknown`. Concrete +`HTMLAttributes & object` and `HTMLAttributes & {}` remain DOM-only, as do generics +constrained to concrete DOM/nonstyle shapes. Finite constrained keys eliminated +by an effective undefined-only intersection are not revived. +Adding a style-bearing property to a DOM type via intersection or augmentation +does not make that property exempt. `className`, `style`, event handlers, +data/ARIA attributes and other pass-through props do not count as style keys. + +An object containing a runtime spread is classified using the object's resulting +type: `{ ...runtimeDomProps, p: 2 }` qualifies if that type includes `p`. In +contrast, `{ bg: runtimeColor }` alone is not a runtime-rooted spread. + +The real build/lint agreement tests cover both shared and per-file CSS modes: + +- Local destructuring defaults, parameter destructuring defaults, and plain + parameter defaults still pass runtime styles through, even when the default + is an imported constant. Style-capable spreads of these bindings report. +- `rest ?? knownImport` and `rest || knownImport` pass through without extracting + the fallback's styles, so style-capable runtime-first spreads report. Literal + and computed fallbacks have the same verified runtime-first behavior. +- `knownImport ?? rest` and `knownImport || rest` compose the known object and do + not report. A known imported object by itself also composes normally. +- The two mixed conditional directions (`condition ? knownImport : rest` and + `condition ? rest : knownImport`) already produce located build errors. This + rule adds no extra diagnostic for those mixed conditional cases. + +Literals, imports, standalone calls (which may be build-evaluable), const/import +members, reassigned bindings, and otherwise unclassified mixed expressions are +left to the build. No evaluator is duplicated; the logical change does not infer +whether a call or import by itself is runtime-only. + +The target must be an imported Devup primitive (named/renamed or namespace/default +package access); shadowed names and unrelated components are excluded. The rule +does not infer styled-created components or variable component aliases. +Invalid/unresolved primitive declarations produce a type-resolution error, +not an empty-schema pass. There is no autofix: rewriting spreads can change +evaluation order. + +For a runtime spread intended to forward attributes, constrain its type to the +concrete DOM/nonstyle shape it actually carries, or pass style props explicitly. +Partially overlapping string-template index domains still count as style-capable; +disjoint data/ARIA, numeric and symbol key domains do not. + +Effective nullish/never-only properties and indexes exclude only the style keys +they eliminate. Finite keys use public checker queries; infinite string-template +domains use exact string-pattern intersection/subtraction, matching UTF-16 code +units as TypeScript does. Style keys are not all finite: at-rule and theme index +domains also come from the primitive's public schema. + +For unsupported template domains or masks (including numeric placeholders), a +bounded fallback fills the represented domain texts with a few concrete seeds +(`''`, `screen`, `(display: grid)`, `0`, `1`). Each candidate must be verified by +the actual checker as belonging to **both** positive domains and **none** of the +exclusions before it proves style capability. For example, a numeric mask cannot +hide the surviving `@media screen` key. Whole-domain masks still suppress reports. +This is not numeric-template grammar or algebra: if neither the supported matcher +nor a verified witness proves carriage, only that narrow query is left +unreported. Other finite keys and index domains are still checked normally. + +## Configuration + +```js +import devupUi from '@devup-ui/eslint-plugin' + +export default [ + ...devupUi.configs.recommendedTypeChecked, + { + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + }, +] +``` + +The preset includes `recommended` plus this rule for TS/TSX/MTS/CTS files. It +registers a stable separate namespace: +`@devup-ui/type-checked/no-unknown-style-spread`. This lets it compose with the +unchanged syntax-only preset without redefining `@devup-ui`. An existing typed +`parserOptions.project` setup is also supported. Enabling the rule without a +program throws the standard typescript-eslint configuration error, even in a +file without spreads. The named rule export is `rules.noUnknownStyleSpread`. diff --git a/packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/agreement-cases.ts b/packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/agreement-cases.ts new file mode 100644 index 000000000..8723debdb --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/agreement-cases.ts @@ -0,0 +1,99 @@ +type AgreementCase = { + readonly name: string + readonly body: string + readonly outcome: 'pass-through' | 'composed' | 'refused' + readonly reports: 0 | 1 +} + +export const agreementCases = [ + { + name: 'local destructure default', + body: 'export function render(props: { styles?: DevupProps }) { const { styles = knownImport } = props; return }', + outcome: 'pass-through', + reports: 1, + }, + { + name: 'parameter destructure default', + body: 'export function render({ styles = knownImport }: { styles?: DevupProps }) { return }', + outcome: 'pass-through', + reports: 1, + }, + { + name: 'parameter default', + body: 'export function render(styles: DevupProps = knownImport) { return }', + outcome: 'pass-through', + reports: 1, + }, + { + name: 'known-first mixed conditional', + body: 'export function render(cond: boolean, rest: DevupProps) { return }', + outcome: 'refused', + reports: 0, + }, + { + name: 'runtime-first mixed conditional', + body: 'export function render(cond: boolean, rest: DevupProps) { return }', + outcome: 'refused', + reports: 0, + }, + { + name: 'runtime-first nullish import fallback', + body: 'export function render(rest: DevupProps | undefined) { return }', + outcome: 'pass-through', + reports: 1, + }, + { + name: 'runtime-first or import fallback', + body: 'export function render(rest: DevupProps | undefined) { return }', + outcome: 'pass-through', + reports: 1, + }, + { + name: 'known import control', + body: 'export function render() { return }', + outcome: 'composed', + reports: 0, + }, + { + name: 'runtime control', + body: 'export function render(rest: DevupProps) { return }', + outcome: 'pass-through', + reports: 1, + }, + { + name: 'known-first nullish control', + body: 'export function render(rest: DevupProps) { return }', + outcome: 'composed', + reports: 0, + }, + { + name: 'known-first or control', + body: 'export function render(rest: DevupProps) { return }', + outcome: 'composed', + reports: 0, + }, + { + name: 'runtime-first nullish literal fallback', + body: 'export function render(rest: DevupProps | undefined) { return }', + outcome: 'pass-through', + reports: 1, + }, + { + name: 'runtime-first or literal fallback', + body: 'export function render(rest: DevupProps | undefined) { return }', + outcome: 'pass-through', + reports: 1, + }, + { + name: 'runtime-first or empty literal fallback', + body: 'export function render(rest: DevupProps) { return }', + outcome: 'pass-through', + reports: 1, + }, + { + name: 'runtime-first or computed fallback', + body: 'function make() { return { bg: "red" } } export function render(rest: DevupProps | undefined) { return }', + outcome: 'pass-through', + reports: 1, + }, +] as const satisfies readonly AgreementCase[] diff --git a/packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/build-agreement.test.ts b/packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/build-agreement.test.ts new file mode 100644 index 000000000..c1611dea2 --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/build-agreement.test.ts @@ -0,0 +1,125 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { Linter } from 'eslint' +import { parser } from 'typescript-eslint' + +import { + codeExtract, + getCss, + resetStateForTesting, + setDebug, + setModuleResolver, +} from '../../../../../../bindings/devup-ui-wasm/pkg/index.js' +import { noUnknownStyleSpread } from '../index' +import { agreementCases } from './agreement-cases' + +type BuildOutcome = + | { readonly kind: 'extracted'; readonly code: string; readonly css: string } + | { readonly kind: 'refused'; readonly error: Error } + +const root = join(import.meta.dir, 'fixtures') +const filename = join(root, 'case.tsx') +const knownFile = join(root, 'known.ts').replaceAll('\\', '/') +const knownCode = readFileSync(knownFile, 'utf8') + +function build(source: string, singleCss: boolean): BuildOutcome { + try { + using output = codeExtract( + filename.replaceAll('\\', '/'), + source, + '@devup-ui/react', + 'devup-ui', + singleCss, + false, + true, + {}, + ) + return { + kind: 'extracted', + code: output.code, + css: singleCss ? getCss(null, false) : (output.css ?? ''), + } + } catch (error) { + if (!(error instanceof Error)) throw error + return { kind: 'refused', error } + } +} + +describe('real build and typed spread lint agreement', () => { + beforeEach(() => { + resetStateForTesting() + setDebug(true) + setModuleResolver((specifier: string) => + specifier === './known' + ? { path: knownFile, code: knownCode } + : undefined, + ) + }) + afterEach(resetStateForTesting) + + for (const singleCss of [false, true]) { + for (const entry of agreementCases) { + it(`${entry.name} agrees when singleCss=${singleCss}`, () => { + // Given the real fixture import and unchanged built WASM, with isolated build state. + const source = `import { Box, type DevupProps } from '@devup-ui/react'; import { styles as knownImport } from './known';\n${entry.body}` + // When extracting and linting the identical source through their real adapters. + const outcome = build(source, singleCss) + const messages = new Linter().verify( + source, + [ + { + files: ['**/*.tsx'], + languageOptions: { + parser, + parserOptions: { projectService: true, tsconfigRootDir: root }, + }, + plugins: { + agreement: { rules: { spread: noUnknownStyleSpread } }, + }, + rules: { 'agreement/spread': 'error' }, + }, + ], + { filename }, + ) + // Then retained/composed/refused build behavior and the exact lint result agree with the independent case contract. + switch (entry.outcome) { + case 'pass-through': + expect(outcome).toMatchObject({ + kind: 'extracted', + code: expect.stringMatching(/ ({ ruleId, messageId })), + ).toEqual( + entry.reports === 1 + ? [{ ruleId: 'agreement/spread', messageId: 'unknownStyleSpread' }] + : [], + ) + }, 30000) + } + } +}) diff --git a/packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/fixtures/case.tsx b/packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/fixtures/case.tsx new file mode 100644 index 000000000..336ce12bb --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/fixtures/case.tsx @@ -0,0 +1 @@ +export {} diff --git a/packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/fixtures/imported.ts b/packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/fixtures/imported.ts new file mode 100644 index 000000000..07c3feb23 --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/fixtures/imported.ts @@ -0,0 +1 @@ +export const styles = { bg: 'red', p: 2 } diff --git a/packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/fixtures/key-domain/case.tsx b/packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/fixtures/key-domain/case.tsx new file mode 100644 index 000000000..336ce12bb --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/fixtures/key-domain/case.tsx @@ -0,0 +1 @@ +export {} diff --git a/packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/fixtures/key-domain/tsconfig.json b/packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/fixtures/key-domain/tsconfig.json new file mode 100644 index 000000000..dcb5a061e --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/fixtures/key-domain/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "strict": true, + "target": "ESNext", + "lib": ["ES5"], + "types": [] + }, + "files": ["case.tsx"] +} diff --git a/packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/fixtures/known.ts b/packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/fixtures/known.ts new file mode 100644 index 000000000..a8de4f103 --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/fixtures/known.ts @@ -0,0 +1 @@ +export const styles = { bg: 'red' } diff --git a/packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/fixtures/no-intrinsics.d.ts b/packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/fixtures/no-intrinsics.d.ts new file mode 100644 index 000000000..c22a3b989 --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/fixtures/no-intrinsics.d.ts @@ -0,0 +1,7 @@ +declare namespace Missing { + interface Element { + readonly kind: 'non-dom' + } +} + +export = Missing diff --git a/packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/fixtures/tsconfig.json b/packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/fixtures/tsconfig.json new file mode 100644 index 000000000..578e14ef4 --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/fixtures/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "strict": true, + "noEmit": true, + "jsx": "react-jsx", + "target": "ESNext", + "lib": ["ESNext", "DOM"], + "types": [], + "module": "ESNext", + "moduleResolution": "Bundler", + "skipLibCheck": true, + "paths": { + "@devup-ui/react": ["../../../../../../react/src/index.ts"], + "@devup-ui/react/compat": ["../../../../../../react/src/compat/index.ts"], + "react": ["../../../../../../react/node_modules/@types/react/index.d.ts"], + "react/*": ["../../../../../../react/node_modules/@types/react/*"] + } + }, + "files": ["case.tsx"] +} diff --git a/packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/index.test.ts b/packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/index.test.ts new file mode 100644 index 000000000..23fe95209 --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/index.test.ts @@ -0,0 +1,158 @@ +import { join } from 'node:path' + +import { describe } from 'bun:test' + +import { noUnknownStyleSpread } from '../index' +import { potentiallyKnownCases, runtimeCases } from './runtime-cases' +import { TypedRuleTester } from './typed-rule-tester' + +describe('no-unknown-style-spread', () => { + const root = join(import.meta.dir, 'fixtures') + const filename = join(root, 'case.tsx') + const tester = new TypedRuleTester({ + languageOptions: { + parserOptions: { projectService: true, tsconfigRootDir: root }, + }, + }) + const imports = + 'import { Box, Flex, type DevupProps } from "@devup-ui/react"; import type * as React from "react";\n' + const invalidTypes = [ + 'Pick', + '{ p?: number }', + '{ color?: string }', + '{ width?: string }', + 'React.HTMLAttributes | { p?: number }', + '{ bg?: string } | React.HTMLAttributes', + 'React.HTMLAttributes & { bg?: string }', + 'React.HTMLAttributes & DevupProps', + 'Record', + 'Record', + '{ _hover?: DevupProps }', + '{ selectors?: Record }', + '{ [key: `@media ${string}`]: DevupProps }', + '{ [key: `_theme${string}`]: DevupProps }', + 'any', + 'unknown', + 'object', + '{}', + ] + const domTypes = [ + 'React.HTMLAttributes', + 'React.ImgHTMLAttributes', + 'React.SVGProps', + 'React.ComponentProps<"div">', + 'React.ComponentProps<"img">', + 'React.ComponentProps<"svg">', + 'Pick, "color">', + 'Pick, "width">', + 'Partial>', + 'Readonly>', + 'Required, "color">>', + 'Omit, "id">', + 'React.HTMLAttributes & { trackingId?: string }', + 'React.HTMLAttributes | React.ImgHTMLAttributes', + '{ className?: string; style?: React.CSSProperties }', + '{ id?: string; onClick?: () => void; "data-x"?: string; "aria-label"?: string }', + '{ [key: `data-${string}`]: string }', + '{ [key: `aria-${string}`]: string }', + 'Record', + '{ [key: symbol]: unknown }', + 'React.HTMLAttributes & object', + 'React.HTMLAttributes & {}', + '{ bg?: undefined }', + '{ p: never }', + 'never', + ] + + // Given real Devup/React types; When linting each source; Then only proven runtime style spreads report. + tester.run('no-unknown-style-spread', noUnknownStyleSpread, { + valid: [ + ...potentiallyKnownCases, + ...domTypes.map( + (type) => `function f(props: ${type}) { return }`, + ), + 'type Named = React.HTMLAttributes; function f(props: Named) { return }', + 'interface Named extends React.HTMLAttributes {}; function f(props: Named) { return }', + 'function f>(props: T) { return }', + 'function f>(props: React.HTMLAttributes & T) { return }', + 'function f(props: React.HTMLAttributes & T) { return }', + 'function f(props: DevupProps) { return
}', + 'function f(Box: React.ElementType, props: DevupProps) { return }', + 'import * as UI from "@devup-ui/react"; function f(UI: { Box: React.ElementType }, props: DevupProps) { return }', + 'import { Box as Other } from "other-package"; function f(props: DevupProps) { return }', + 'import { ThemeScript } from "@devup-ui/react"; function f(props: DevupProps) { return }', + 'import type { Box as Typed } from "@devup-ui/react"; function f(props: DevupProps) { return }', + 'import type * as UI from "@devup-ui/react"; function f(props: DevupProps) { return }', + 'function f() { return }', + 'function f() { return }', + 'function f(props: React.HTMLAttributes) { return }', + 'function f(props: React.HTMLAttributes) { return }', + ].map((code) => ({ code: imports + code, filename })), + invalid: [ + ...runtimeCases, + ...invalidTypes.map( + (type) => `function f(props: ${type}) { return }`, + ), + 'interface Named { bg?: string }; function f(props: Named) { return }', + 'interface Named extends React.HTMLAttributes { color?: string }; function f(props: Named) { return }', + 'function f(props: T) { return }', + 'function f>(props: T) { return }', + 'function f(props: T) { return }', + 'function f(props: T) { return }', + 'function f(props: React.HTMLAttributes & T) { return }', + 'function f(props: React.HTMLAttributes & T) { return }', + 'function f(props: React.HTMLAttributes & T) { return }', + 'function f(props: React.HTMLAttributes & T) { return }', + 'function f(props: React.HTMLAttributes & T) { return }', + 'import { Box as B } from "@devup-ui/react"; function f(props: DevupProps) { return }', + 'import UI from "@devup-ui/react"; function f(props: DevupProps) { return }', + 'function f(props: DevupProps) { return }', + ...[ + 'Box', + 'Button', + 'Center', + 'Flex', + 'Grid', + 'Image', + 'Input', + 'Text', + 'VStack', + ].map( + (component) => + `import * as UI from "@devup-ui/react"; function f(props: DevupProps) { return }`, + ), + 'declare module "react" { interface HTMLAttributes { bg?: string } }; function f(props: React.HTMLAttributes) { return }', + 'declare module "@devup-ui/react" { interface DevupCustomShorthands { insetX?: number } }; function f(props: { insetX?: number }) { return }', + ] + .map((code) => ({ + code: imports + code, + filename, + errors: [ + { + messageId: 'unknownStyleSpread' as const, + type: 'JSXSpreadAttribute' as const, + }, + ], + output: null, + })) + .concat([ + { + code: + imports + + 'function f(props: DevupProps) { return <> }', + filename, + errors: [ + { + messageId: 'unknownStyleSpread' as const, + type: 'JSXSpreadAttribute' as const, + }, + { + messageId: 'unknownStyleSpread' as const, + type: 'JSXSpreadAttribute' as const, + }, + ], + output: null, + }, + ]), + }) +}) diff --git a/packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/key-domain.test.ts b/packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/key-domain.test.ts new file mode 100644 index 000000000..3a35abf11 --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/key-domain.test.ts @@ -0,0 +1,118 @@ +import { join } from 'node:path' + +import { ESLintUtils, type TSESLint } from '@typescript-eslint/utils' +import { describe, expect, it } from 'bun:test' +import { Linter } from 'eslint' +import type { Type } from 'typescript' +import { parser } from 'typescript-eslint' + +import { keyDomainsOverlap, keyDomainsOverlapExcept } from '../key-domain' + +describe('key domain overlap', () => { + const cases: readonly (readonly [string, string, boolean, string?])[] = [ + ['"a"', '"b"', false], + ['never', 'string', false], + ['string', 'never', false], + ['"a"', '`a${string}b`', false], + ['`a${string}`', '`${string}b`', true], + ['`\\uD83D\\uDE00${string}`', '`\\uD83D${string}a`', true], + ['`\\uD83D${string}a`', '`\\uD83D\\uDE00${string}`', true], + [ + '`\\uD83D\\uDE00${string}`', + '`\\uD83D${string}a`', + false, + '`\\uD83D\\uDE00${string}a`', + ], + ['`a${string}x`', '`a${string}y`', false], + ['`@${string} (${string})`', '`@media ${string}`', true], + ['`@${number} foo`', '`@media ${string}`', false], + ['`a${string}` | `b${string}`', '`${string}b`', true], + ['`${string}z`', '`a${string}` | `b${string}`', true], + ['number', 'symbol', false], + ['string', 'number', false], + ['`@media ${string}`', '`@media ${string}`', true, '`@media ${number}`'], + ['`@media ${string}`', '`@media ${string}`', false, '`@media ${string}`'], + ['"bg"', 'string', true, '`${number}`'], + ['string', '"bg"', true, '`${number}`'], + ['`@media ${number}`', '`@media ${string}`', true, '"@media 0"'], + ['`@media ${string}`', '`@media ${number}`', true, '"@media 0"'], + ['`data-${number}`', '`@media ${string}`', false, '"@media 0"'], + [ + '`@media ${number}px`', + '`@media ${string}`', + false, + '`@media ${number}em`', + ], + ['`${number}px`', '`${number}em`', false, '"0"'], + ['`a${string}`', '`${string}b`', false, '`a${string}b`'], + ['`a${string}`', '`${string}b`', true, '"ab"'], + ['`a${string}`', '`${string}b`', true, '`a${string}c${string}b`'], + ['`a${string}a${string}`', '`${string}b`', false, '`a${string}b`'], + ['`a${string}`', '`${string}b`', true, 'number | symbol'], + ['""', 'string', true, '`a${string}`'], + ['"a"', 'string', false, '`a${string}`'], + ['"ab" | "ac"', '`a${string}`', false, '"ab" | "ac"'], + ['"ab" | "ac"', '`a${string}`', true, '"ab"'], + [ + '`@${string} (min-width: 1px)`', + '`@media ${string}`', + false, + '`@media ${string}(min-width: 1px)`', + ], + [ + '`@${string} (min-width: 1px)`', + '`@media ${string}`', + true, + '`@media ${string}(min-width: 1px)x`', + ], + ] + for (const [left, right, expected, excluded = 'never'] of cases) { + it(`returns ${expected} when intersecting ${left} with ${right} excluding ${excluded}`, () => { + // Given two actual compiler key types, not reconstructed type objects. + const root = join(import.meta.dir, 'fixtures/key-domain') + const results: boolean[] = [] + const inspect: TSESLint.RuleModule<'unused', []> = { + defaultOptions: [], + meta: { schema: [], messages: { unused: 'unused' }, type: 'problem' }, + create(context) { + const services = ESLintUtils.getParserServices(context) + const types: Type[] = [] + return { + VariableDeclarator(node) { + types.push(services.getTypeAtLocation(node.id)) + }, + 'Program:exit'() { + const [a, b, mask] = types + if (a && b && mask) { + const checker = services.program.getTypeChecker() + results.push( + mask === checker.getNeverType() + ? keyDomainsOverlap(checker, a, b) + : keyDomainsOverlapExcept(checker, [a, b], [mask]), + ) + } + }, + } + }, + } + // When querying overlap through the checker-backed helper. + new Linter().verify( + `declare const left: ${left}; declare const right: ${right}; declare const excluded: ${excluded}`, + [ + { + files: ['**/*.tsx'], + languageOptions: { + parser, + parserOptions: { projectService: true, tsconfigRootDir: root }, + }, + plugins: { inspect: { rules: { domain: inspect } } }, + rules: { 'inspect/domain': 'error' }, + }, + ], + { filename: join(root, 'case.tsx') }, + ) + // Then exactly one overlap answer matches the independently chosen contract. + expect(results).toEqual([expected]) + }, 15000) + } +}) diff --git a/packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/oracle-cases.test.ts b/packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/oracle-cases.test.ts new file mode 100644 index 000000000..b83deae71 --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/oracle-cases.test.ts @@ -0,0 +1,199 @@ +import { join } from 'node:path' + +import { describe } from 'bun:test' + +import { noUnknownStyleSpread } from '../index' +import { TypedRuleTester } from './typed-rule-tester' + +describe('runtime style capability regressions', () => { + const root = join(import.meta.dir, 'fixtures') + const filename = join(root, 'case.tsx') + const tester = new TypedRuleTester({ + languageOptions: { + parserOptions: { projectService: true, tsconfigRootDir: root }, + }, + }) + const code = (type: string) => + `import { Box, type DevupProps } from '@devup-ui/react'; import type * as React from 'react'; function f(props: ${type}) { return }` + + // Given merged React declarations; When forwarding runtime props; Then only original intrinsic attributes are trusted. + const elementAugmentation = + 'interface ReactElement

= string | React.JSXElementConstructor> { trace?: true }' + tester.run('DOM origin survives paired augmentation', noUnknownStyleSpread, { + valid: [ + { + code: + `declare module 'react' { ${elementAugmentation} };` + + code( + 'Pick, "color" | "width">', + ), + filename, + }, + { + code: + 'declare module "react" { namespace JSX { interface IntrinsicElements { input: React.DetailedHTMLProps, HTMLInputElement> } } };' + + code('Pick, "width">'), + filename, + }, + ], + invalid: [ + { + code: + `declare module 'react' { ${elementAugmentation}; interface HTMLAttributes { bg?: string } };` + + code('React.HTMLAttributes'), + filename, + errors: [{ messageId: 'unknownStyleSpread' }], + output: null, + }, + { + code: + `declare module 'react' { ${elementAugmentation}; namespace JSX { interface IntrinsicElements { workerCustom: React.PointerEvent } } };` + + code('Pick, "width">'), + filename, + errors: [{ messageId: 'unknownStyleSpread' }], + output: null, + }, + ], + }) + + // Given actual React/Devup types; When linting runtime inputs; Then effective DOM/key domains determine diagnostics. + tester.run('no-unknown-style-spread', noUnknownStyleSpread, { + valid: [ + '{ bg?: string } & { bg?: undefined }', + 'React.HTMLAttributes & { bg?: string } & { bg?: undefined }', + '{ [key: `@mediaX${string}`]: DevupProps }', + '{ [key: `@screen ${string}`]: DevupProps }', + '{ [key: `data-${string} (min-width: 1px)`]: DevupProps }', + '{ [key: `aria-${string}`]: DevupProps }', + '{ [key: `@${number} (min-width: 1px)`]: DevupProps }', + 'Record', + '{ [key: symbol]: DevupProps }', + 'Record', + 'Record', + 'Record', + '{ [key: `@media ${string}`]: undefined }', + '{ bg?: string } & Record', + '{ [key: `@media ${number}`]: unknown } & { [key: `@media ${string}`]: undefined }', + '{ [key: `data-${number}`]: unknown } & { [key: `@media ${number}`]: undefined }', + ].map((type) => ({ code: code(type), filename })), + invalid: [ + 'Pick, "width">', + '{ [key: `@${string} (min-width: 1px)`]: DevupProps }', + '{ [key: `${string}media (min-width: 1px)`]: DevupProps }', + '{ [key: `@${string} (min-width: ${string})`]: DevupProps }', + '{ [key: `@media ${string}`]: unknown } & { [key: `@media ${number}`]: undefined }', + '{ [key: `@supports ${string}`]: unknown } & { [key: `@supports ${number}`]: undefined }', + '{ [key: `@container ${string}`]: unknown } & { [key: `@container ${number}`]: undefined }', + '{ [key: `@media ${number}`]: unknown } & { "@media 0"?: undefined }', + '{ bg?: string } & { [key: `${number}`]: undefined }', + ] + .map((type) => ({ + code: code(type), + filename, + errors: [{ messageId: 'unknownStyleSpread' as const }], + output: null, + })) + .concat([ + { + code: + 'declare module "react" { namespace JSX { interface IntrinsicElements { workerCustom: React.PointerEvent } } };' + + code('Pick, "width">'), + filename, + errors: [{ messageId: 'unknownStyleSpread' as const }], + output: null, + }, + ]), + }) + + // Given finite generic constraints; When intersected; Then only surviving style capabilities report. + const genericCode = (constraint: string, type: string) => + `import { Box, type DevupProps } from '@devup-ui/react'; import type * as React from 'react'; function render(rest: ${type}) { return }` + tester.run('finite generic intersections', noUnknownStyleSpread, { + valid: [ + 'T & { bg?: undefined }', + 'React.HTMLAttributes & T & { bg?: undefined }', + ].map((type) => ({ + code: genericCode('Pick', type), + filename, + })), + invalid: ( + [ + ['Pick', 'T & { id?: string }'], + [ + 'Pick', + 'React.HTMLAttributes & T & { bg?: undefined }', + ], + ['Pick', 'T & { bg?: undefined; p?: number }'], + ['Record', 'T & { bg?: undefined }'], + ] as const + ).map(([constraint, type]) => ({ + code: genericCode(constraint, type), + filename, + errors: [{ messageId: 'unknownStyleSpread' as const }], + output: null, + })), + }) + + // Given constrained union/index capabilities; When intersected; Then effective absence masks only eliminated capabilities. + const dom = 'React.HTMLAttributes' + const union = `(${dom} | { bg?: string })` + const media = '{ [key: `@media ${string}`]: unknown }' + const query = '{ [key: `@${string} (min-width: 1px)`]: unknown }' + const maskedQueries = + '{ [key: `@media ${string}(min-width: 1px)` | `@supports ${string}(min-width: 1px)` | `@container ${string}(min-width: 1px)`]: undefined }' + tester.run('generic capability composition', noUnknownStyleSpread, { + valid: [ + ['Record', 'T & Record'], + ['Record', 'T & Record'], + ['Record', 'T & Record'], + ['Record', `${dom} & T & Record`], + [media, 'T & { [key: `@media ${string}`]: undefined }'], + [media, `${dom} & T & { [key: \`@media \${string}\`]: undefined }`], + [media, 'T & Record'], + ['{ bg?: string }', 'T & Record'], + [union, `${dom} & T & { bg?: undefined }`], + [union, 'T & { id?: string; bg?: undefined }'], + [union, 'T & { bg?: null }'], + [union, 'T & { bg?: never }'], + [union, 'T & Record'], + [`(${dom} | { trackingId?: string })`, `${dom} & T`], + [`(${dom} | { bg?: undefined })`, `${dom} & T`], + [ + `(${dom} | { [key: \`@media \${string}\`]: unknown })`, + 'T & Record', + ], + [query, `T & ${maskedQueries}`], + [query, `T & ${maskedQueries} & ${dom}`], + ].map(([constraint, type]) => ({ + code: genericCode(constraint, type), + filename, + })), + invalid: [ + [union, `${dom} & T`], + [union, 'T & { id?: string }'], + [union, 'T'], + [`({ bg?: string } | ${dom})`, `${dom} & T`], + [ + `(${dom} | { bg?: string; p?: number })`, + `${dom} & T & { bg?: undefined }`, + ], + [`(${dom} | { bg?: string } | { p?: number })`, 'T & { bg?: undefined }'], + [union, 'T & { bg?: undefined; p?: number }'], + ['Record', 'T & Record'], + [media, 'T & { [key: `@media ${string}`]: DevupProps }'], + [ + 'Record', + 'T & { [key: `@media ${string}`]: undefined }', + ], + [media, 'T & { bg?: undefined }'], + [media, 'T & { [key: `@media print${string}`]: undefined }'], + [`(${dom} | { [key: \`@media \${string}\`]: unknown })`, `${dom} & T`], + [query, 'T & { [key: `@media ${string}(min-width: 1px)`]: undefined }'], + ].map(([constraint, type]) => ({ + code: genericCode(constraint, type), + filename, + errors: [{ messageId: 'unknownStyleSpread' as const }], + output: null, + })), + }) +}) diff --git a/packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/runtime-cases.ts b/packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/runtime-cases.ts new file mode 100644 index 000000000..ec1be29d2 --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/runtime-cases.ts @@ -0,0 +1,51 @@ +export const runtimeCases = [ + 'function f(props: DevupProps) { return }', + 'const f = (props: DevupProps) => ', + 'const object = { render(props: DevupProps) { return } }', + 'function f({ styles }: { styles: DevupProps }) { return }', + 'function f({ id, ...rest }: DevupProps & { id: string }) { return }', + 'function f([styles]: DevupProps[]) { return }', + 'try {} catch (error) { }', + 'function f() { return }', + 'function f(this: DevupProps) { return }', + 'class View { styles: DevupProps = {}; render() { return } }', + 'function f(props: DevupProps) { const styles = props; return }', + 'function f(props: DevupProps) { let styles = props; return }', + 'function f(props: { styles: DevupProps }) { const { styles } = props; return }', + 'function f(props: DevupProps) { const { id, ...styles } = props; return }', + 'function f(props: { inner: { styles: DevupProps } }) { return }', + 'function f(props: { styles: DevupProps }) { return }', + 'function f(props: { styles: DevupProps }) { return }', + 'function f(props: DevupProps) { return }', + 'function f(props: DevupProps) { return }', + 'function f(a: DevupProps, b: DevupProps, on: boolean) { return }', + 'function f(a: DevupProps, b: DevupProps) { return }', + 'function f(a: DevupProps, b: DevupProps) { return }', + 'function f(a: DevupProps, b: DevupProps) { return }', + 'function f(props: DevupProps) { return }', + 'function f(a: DevupProps, b: DevupProps, on: boolean) { return }', + 'function f(a: DevupProps) { return }', + 'function f(a: DevupProps | undefined) { return }', +] as const + +export const potentiallyKnownCases = [ + '', + 'function f(color: string) { return }', + 'function f(color: string) { const styles = { bg: color }; return }', + 'const styles: DevupProps = { bg: "red" }; ', + 'const styles = { bg: "red" }; ', + 'import { styles } from "./imported"; ', + 'import { styles } from "./imported"; ', + 'declare function make(): DevupProps; ', + 'function make() { return { bg: "red" } }; ', + 'function f(props: DevupProps) { const styles = make(props); return }', + 'function f(props: DevupProps) { let styles = props; styles = {}; return }', + 'function f(props: DevupProps) { props = {}; return }', + 'function f(props: DevupProps) { let styles = {}; styles = props; return }', + 'function f(props: DevupProps) { return }', + 'function f(props: DevupProps, on: boolean) { return }', + 'function f(props: DevupProps, on: boolean) { return }', + 'function f(props: DevupProps, on: boolean) { return }', + 'function f(props: DevupProps) { return }', + 'declare const other: DevupProps; ', +] as const diff --git a/packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/schema-inputs.test.ts b/packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/schema-inputs.test.ts new file mode 100644 index 000000000..7ac441848 --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/schema-inputs.test.ts @@ -0,0 +1,57 @@ +import { join } from 'node:path' + +import { ESLintUtils, type TSESLint } from '@typescript-eslint/utils' +import { describe, expect, it } from 'bun:test' +import { Linter } from 'eslint' +import { parser } from 'typescript-eslint' + +import { styleCapability, StyleSchemaError } from '../style-capability' + +describe('invalid style schema inputs', () => { + const root = join(import.meta.dir, 'fixtures') + const filename = join(root, 'case.tsx') + const inspect: TSESLint.RuleModule<'unused', []> = { + defaultOptions: [], + meta: { schema: [], messages: { unused: 'unused' }, type: 'problem' }, + create(context) { + const services = ESLintUtils.getParserServices(context) + return { + VariableDeclarator(node) { + styleCapability( + services.program.getTypeChecker(), + services.getTypeAtLocation(node.id), + context.filename, + ) + }, + } + }, + } + for (const type of [ + 'string', + '() => React.ReactElement', + '(props: any) => React.ReactElement', + '(props: { bg?: string }) => string', + '(props: { bg?: string }) => Local.Element', + '(props: { bg?: string }) => Missing.Element', + ]) { + it(`rejects an invalid schema when the checker resolves ${type}`, () => { + // Given real checker types with an invalid callable/props/element contract. + const code = `import type * as React from 'react'; import type * as Missing from './no-intrinsics'; declare namespace Local { interface Element {} }; declare const candidate: ${type}` + const configs = [ + { + files: ['**/*.tsx'], + languageOptions: { + parser, + parserOptions: { projectService: true, tsconfigRootDir: root }, + }, + plugins: { inspect: { rules: { schema: inspect } } }, + rules: { 'inspect/schema': 'error' as const }, + }, + ] + // When deriving the schema, then a typed resolution error must propagate. + expect(() => new Linter().verify(code, configs, { filename })).toThrow( + StyleSchemaError, + ) + }, 30000) + } +}) diff --git a/packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/typed-rule-tester.ts b/packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/typed-rule-tester.ts new file mode 100644 index 000000000..da6dcc1f0 --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-unknown-style-spread/__tests__/typed-rule-tester.ts @@ -0,0 +1,10 @@ +import { RuleTester } from '@typescript-eslint/rule-tester' +import { it } from 'bun:test' + +export class TypedRuleTester extends RuleTester { + static override get it(): typeof RuleTester.it { + return (name: string, callback: () => void): void => { + it(name, callback, 30000) + } + } +} diff --git a/packages/eslint-plugin/src/rules/no-unknown-style-spread/dom-properties.ts b/packages/eslint-plugin/src/rules/no-unknown-style-spread/dom-properties.ts new file mode 100644 index 000000000..bad4ab761 --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-unknown-style-spread/dom-properties.ts @@ -0,0 +1,64 @@ +import type { + Declaration, + ExportAssignment, + Type, + TypeChecker, +} from 'typescript' + +export function domProperties(checker: TypeChecker, element: Type) { + const declarations = element.getSymbol()?.getDeclarations() ?? [] + const parent = declarations + .map((node) => node.parent?.parent) + .find((candidate) => + candidate + ?.getSourceFile() + .statements.filter( + (statement): statement is ExportAssignment => + 'isExportEquals' in statement && statement.isExportEquals === true, + ) + .some((statement) => + checker + .getSymbolAtLocation(statement.expression) + ?.getDeclarations() + ?.some((declaration) => declaration === candidate), + ), + ) + if (!parent) return undefined + const originalFile = parent.getSourceFile() + const namespace = parent + .getChildren() + .map((node) => checker.getSymbolAtLocation(node)) + .find((symbol) => + symbol?.getDeclarations()?.some((declaration) => declaration === parent), + ) + const jsx = + namespace && + checker + .getExportsOfModule(namespace) + .find((symbol) => symbol.name === 'JSX') + const intrinsic = + jsx && + checker + .getExportsOfModule(jsx) + .find((symbol) => symbol.name === 'IntrinsicElements') + if (!intrinsic) return undefined + const result = new Set() + for (const tag of checker.getPropertiesOfType( + checker.getDeclaredTypeOfSymbol(intrinsic), + )) { + const origins = tag.getDeclarations() ?? [] + if ( + !origins.length || + !origins.some((node) => node.getSourceFile() === originalFile) + ) + continue + const props = checker.getTypeOfSymbolAtLocation(tag, parent) + for (const property of checker.getPropertiesOfType(props)) { + for (const declaration of property.getDeclarations() ?? []) { + if (declaration.getSourceFile() === originalFile) + result.add(declaration) + } + } + } + return result +} diff --git a/packages/eslint-plugin/src/rules/no-unknown-style-spread/index.ts b/packages/eslint-plugin/src/rules/no-unknown-style-spread/index.ts new file mode 100644 index 000000000..c5fc4fd1f --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-unknown-style-spread/index.ts @@ -0,0 +1,98 @@ +import { + AST_NODE_TYPES, + ESLintUtils, + type TSESTree, +} from '@typescript-eslint/utils' +import type { Symbol } from 'typescript' + +import { ImportStorage, STYLE_COMPONENTS } from '../../utils/import-storage' +import { componentName } from '../../utils/style-position' +import { findBinding, runtimeSource } from './runtime-source' +import { styleCapability, StyleSchemaError } from './style-capability' + +const createRule = ESLintUtils.RuleCreator<{ requiresTypeChecking: boolean }>( + (name) => + `https://github.com/dev-five-git/devup-ui/tree/main/packages/eslint-plugin/src/rules/${name}`, +) + +export const noUnknownStyleSpread = createRule({ + name: 'no-unknown-style-spread', + defaultOptions: [], + meta: { + type: 'problem', + schema: [], + docs: { + description: + 'Disallow provably runtime spreads whose types can carry Devup styles.', + requiresTypeChecking: true, + }, + messages: { + unknownStyleSpread: + 'This runtime spread can carry Devup style props. Constrain the type or pass style props explicitly; use build-time-known composition or forward DOM-only props separately.', + }, + }, + create(context) { + const services = ESLintUtils.getParserServices(context) + const checker = services.program.getTypeChecker() + const storage = new ImportStorage(context) + const unknown = runtimeSource(context.sourceCode) + const spreads: TSESTree.JSXSpreadAttribute[] = [] + const capabilities = new Map>() + return { + ImportDeclaration(node) { + storage.addImportByDeclaration(node) + }, + JSXSpreadAttribute(node) { + spreads.push(node) + }, + 'Program:exit'() { + for (const node of spreads) { + const name = node.parent.name + const exportedName = componentName(name, storage) ?? '' + if (!STYLE_COMPONENTS.has(exportedName) || !unknown(node.argument)) + continue + const root = + name.type === AST_NODE_TYPES.JSXMemberExpression + ? name.object + : name + if (root.type !== AST_NODE_TYPES.JSXIdentifier) continue + const variable = findBinding(context.sourceCode, root, root.name) + const definition = variable?.defs[0] + if (definition?.type !== 'ImportBinding') continue + const declaration = definition.parent + if (declaration.type !== AST_NODE_TYPES.ImportDeclaration) continue + if ( + declaration.importKind === 'type' || + (definition.node.type === AST_NODE_TYPES.ImportSpecifier && + definition.node.importKind === 'type') + ) + continue + const module = checker.getSymbolAtLocation( + services.esTreeNodeToTSNodeMap.get(declaration.source), + ) + const exported = + module && + checker + .getExportsOfModule(module) + .find((symbol) => symbol.name === exportedName) + if (!exported) throw new StyleSchemaError(context.filename) + let capable = capabilities.get(exported) + if (!capable) { + capable = styleCapability( + checker, + checker.getTypeOfSymbolAtLocation( + exported, + services.esTreeNodeToTSNodeMap.get(declaration), + ), + context.filename, + ) + capabilities.set(exported, capable) + } + if (capable(services.getTypeAtLocation(node.argument))) { + context.report({ node, messageId: 'unknownStyleSpread' }) + } + } + }, + } + }, +}) diff --git a/packages/eslint-plugin/src/rules/no-unknown-style-spread/key-domain.ts b/packages/eslint-plugin/src/rules/no-unknown-style-spread/key-domain.ts new file mode 100644 index 000000000..d4cf4d697 --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-unknown-style-spread/key-domain.ts @@ -0,0 +1,156 @@ +import type { TemplateLiteralType, Type, TypeChecker } from 'typescript' + +function isTemplate(type: Type): type is TemplateLiteralType { + return 'texts' in type +} + +type Token = string | null + +function pattern( + checker: TypeChecker, + type: Type, +): readonly Token[] | undefined { + // TypeScript template matching consumes UTF-16 code units, not code points. + if (type.isStringLiteral()) return type.value.split('') + if (checker.isTypeAssignableTo(checker.getStringType(), type)) return [null] + if (!isTemplate(type)) return undefined + const tokens: Token[] = [] + for (const [index, text] of type.texts.entries()) { + tokens.push(...text.split('')) + const part = type.types[index] + if (!part) continue + const inner = pattern(checker, part) + if (!inner) return undefined + tokens.push(...inner) + } + return tokens +} + +function closure(tokens: readonly Token[], positions: readonly number[]) { + const result = new Set(positions) + for (const position of result) { + if (tokens[position] === null) result.add(position + 1) + } + return [...result].sort((a, b) => a - b) +} + +function advance( + tokens: readonly Token[], + positions: readonly number[], + character: Token, +) { + const next = positions.flatMap((position) => { + const token = tokens[position] + if (token === null) return [position] + return token !== undefined && token === character ? [position + 1] : [] + }) + return closure(tokens, next) +} + +/** Subset-product reachability: accept both domains and reject every excluded domain. */ +function patternsOverlap( + left: readonly Token[], + right: readonly Token[], + excluded: readonly (readonly Token[])[], +): boolean { + const patterns = [left, right, ...excluded] + const pending = [ + patterns.map((tokens) => ({ tokens, positions: closure(tokens, [0]) })), + ] + const alphabet = new Set(patterns.flat()) + alphabet.add(null) + const visited = new Set() + for (let state = pending.pop(); state; state = pending.pop()) { + const key = state.map(({ positions }) => positions.join(',')).join(';') + if (visited.has(key)) continue + visited.add(key) + if ( + state.every(({ tokens, positions }, index) => + index < 2 + ? positions.includes(tokens.length) + : !positions.includes(tokens.length), + ) + ) + return true + for (const character of alphabet) { + const next = state.map(({ tokens, positions }) => ({ + tokens, + positions: advance(tokens, positions, character), + })) + if (next.slice(0, 2).every(({ positions }) => positions.length)) + pending.push(next) + } + } + return false +} + +export function keyDomainsOverlap( + checker: TypeChecker, + left: Type, + right: Type, +): boolean { + return keyDomainsOverlapExcept(checker, [left, right], []) +} + +export function keyDomainsOverlapExcept( + checker: TypeChecker, + [left, right]: readonly [Type, Type], + excluded: readonly Type[], +): boolean { + if (left === checker.getNeverType() || right === checker.getNeverType()) + return false + const blockers = excluded.flatMap((type) => + type.isUnion() ? type.types : [type], + ) + if ( + blockers.some( + (type) => + checker.isTypeAssignableTo(left, type) || + checker.isTypeAssignableTo(right, type), + ) + ) + return false + if ( + !blockers.length && + (checker.isTypeAssignableTo(left, right) || + checker.isTypeAssignableTo(right, left)) + ) + return true + if (left.isUnion()) + return left.types.some((part) => + keyDomainsOverlapExcept(checker, [part, right], blockers), + ) + if (right.isUnion()) + return right.types.some((part) => + keyDomainsOverlapExcept(checker, [left, part], blockers), + ) + const a = pattern(checker, left) + const b = pattern(checker, right) + const witnessed = () => + [a, b].some( + (tokens) => + tokens !== undefined && + ['', 'screen', '(display: grid)', '0', '1'].some((filler) => { + const literal = checker.getStringLiteralType( + tokens.map((token) => token ?? filler).join(''), + ) + return ( + checker.isTypeAssignableTo(literal, left) && + checker.isTypeAssignableTo(literal, right) && + blockers.every((type) => !checker.isTypeAssignableTo(literal, type)) + ) + }), + ) + if (!a || !b) return witnessed() + const masks: (readonly Token[])[] = [] + for (const blocker of blockers) { + const mask = pattern(checker, blocker) + if (!mask) { + if (checker.isTypeAssignableTo(blocker, checker.getStringType())) + return witnessed() + continue + } + masks.push(mask) + } + return patternsOverlap(a, b, masks) +} diff --git a/packages/eslint-plugin/src/rules/no-unknown-style-spread/runtime-source.ts b/packages/eslint-plugin/src/rules/no-unknown-style-spread/runtime-source.ts new file mode 100644 index 000000000..e54ef2f4e --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-unknown-style-spread/runtime-source.ts @@ -0,0 +1,84 @@ +import { + AST_NODE_TYPES, + type TSESLint, + type TSESTree, +} from '@typescript-eslint/utils' + +export function findBinding( + source: TSESLint.SourceCode, + node: TSESTree.Node, + name: string, +): TSESLint.Scope.Variable | undefined { + for ( + let scope: TSESLint.Scope.Scope | null = source.getScope(node); + scope; + scope = scope.upper + ) { + const variable = scope.set.get(name) + if (variable) return variable + } + return undefined +} + +/** A proof of runtime origin, deliberately not an evaluator of build-known code. */ +export function runtimeSource(source: TSESLint.SourceCode) { + const visiting = new Set() + const unknown = (node: TSESTree.Node): boolean => { + switch (node.type) { + case AST_NODE_TYPES.ThisExpression: + return true + case AST_NODE_TYPES.TSAsExpression: + case AST_NODE_TYPES.TSTypeAssertion: + case AST_NODE_TYPES.TSSatisfiesExpression: + case AST_NODE_TYPES.TSNonNullExpression: + case AST_NODE_TYPES.ChainExpression: + return unknown(node.expression) + case AST_NODE_TYPES.MemberExpression: + return unknown(node.object) + case AST_NODE_TYPES.ConditionalExpression: + return unknown(node.consequent) && unknown(node.alternate) + case AST_NODE_TYPES.LogicalExpression: + return ( + unknown(node.left) && (node.operator !== '&&' || unknown(node.right)) + ) + case AST_NODE_TYPES.ObjectExpression: + return node.properties.some( + (property) => + property.type === AST_NODE_TYPES.SpreadElement && + unknown(property.argument), + ) + case AST_NODE_TYPES.Identifier: { + const variable = findBinding(source, node, node.name) + if (!variable || visiting.has(variable)) return false + if ( + variable.references.some( + (reference) => reference.isWrite() && !reference.init, + ) + ) + return false + const [definition] = variable.defs + if (!definition) + return node.name === 'arguments' && variable.scope.type === 'function' + switch (definition.type) { + case 'Parameter': + case 'CatchClause': + return true + case 'Variable': { + const init = definition.node.init + if (!init) return false + visiting.add(variable) + const result = unknown(init) + visiting.delete(variable) + return result + } + default: + return false + } + } + // All other expressions might be build-evaluable, including calls/imports. + default: + return false + } + } + return unknown +} diff --git a/packages/eslint-plugin/src/rules/no-unknown-style-spread/style-capability.ts b/packages/eslint-plugin/src/rules/no-unknown-style-spread/style-capability.ts new file mode 100644 index 000000000..cc402da6d --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-unknown-style-spread/style-capability.ts @@ -0,0 +1,135 @@ +import type { Type, TypeChecker } from 'typescript' + +import { isPassThroughProp } from '../../utils/style-position' +import { domProperties } from './dom-properties' +import { keyDomainsOverlapExcept } from './key-domain' + +export class StyleSchemaError extends Error { + readonly name = 'StyleSchemaError' + + constructor(readonly filename: string) { + super( + `Cannot resolve Devup component prop types in ${filename}. Check @devup-ui/react and React type resolution in the lint project.`, + ) + } +} + +/** The component's public props supply the schema, including theme/custom augmentation. */ +export function styleCapability( + checker: TypeChecker, + component: Type, + filename: string, +) { + const signatures = checker.getSignaturesOfType(component, 0) + const signature = signatures[0] + const parameter = signature?.getParameters()[0] + const declaration = signature?.getDeclaration() + if (!signature || !parameter || !declaration) + throw new StyleSchemaError(filename) + const props = checker.getTypeOfSymbolAtLocation(parameter, declaration) + const domDeclarations = domProperties( + checker, + checker.getReturnTypeOfSignature(signature), + ) + if (props.flags & checker.getAnyType().flags || !domDeclarations?.size) + throw new StyleSchemaError(filename) + const domOrigin = ( + property: ReturnType[number], + ) => { + const declarations = property.getDeclarations() + return Boolean( + declarations?.length && + declarations.every((node) => domDeclarations.has(node)), + ) + } + const keys = new Set( + checker + .getPropertiesOfType(props) + .filter( + (property) => !isPassThroughProp(property.name) && !domOrigin(property), + ) + .map((property) => property.name), + ) + const indexes = checker.getIndexInfosOfType(props) + const styleKey = (name: string) => + !isPassThroughProp(name) && + (keys.has(name) || + indexes.some((index) => + checker.isTypeAssignableTo( + checker.getStringLiteralType(name), + index.keyType, + ), + )) + const visiting = new Set() + const emits = (type: Type) => + checker.getNonNullableType(type) !== checker.getNeverType() + const capable = (type: Type, excluded: readonly Type[] = []): boolean => { + if ( + type.flags & + (checker.getAnyType().flags | checker.getUnknownType().flags) + ) + return true + if (visiting.has(type)) return false + visiting.add(type) + const intersection = type.isIntersection() ? type : undefined + let result: boolean + if (type.isUnion()) { + result = type.types.some((part) => capable(part, excluded)) + } else if (type.isTypeParameter()) { + const constraint = checker.getBaseConstraintOfType(type) + result = constraint ? capable(constraint, excluded) : true + } else { + const properties = checker.getPropertiesOfType(type) + const typeIndexes = checker.getIndexInfosOfType(type) + const blocked = [ + ...excluded, + ...properties + .filter( + (property) => + !emits(checker.getTypeOfSymbolAtLocation(property, declaration)), + ) + .map((property) => checker.getStringLiteralType(property.name)), + ...typeIndexes + .filter((index) => !emits(index.type)) + .map((index) => index.keyType), + ] + const available = (key: Type) => + !blocked.some((domain) => checker.isTypeAssignableTo(key, domain)) + result = + (properties.length === 0 && + typeIndexes.length === 0 && + checker.isTypeAssignableTo(props, type)) || + properties.some( + (property) => + styleKey(property.name) && + !domOrigin(property) && + available(checker.getStringLiteralType(property.name)), + ) || + typeIndexes.some( + (candidate) => + emits(candidate.type) && + ([...keys].some((key) => { + const literal = checker.getStringLiteralType(key) + return ( + checker.isTypeAssignableTo(literal, candidate.keyType) && + available(literal) + ) + }) || + indexes.some((index) => + keyDomainsOverlapExcept( + checker, + [index.keyType, candidate.keyType], + blocked, + ), + )), + ) || + (intersection?.types.some( + (part) => part.isTypeParameter() && capable(part, blocked), + ) ?? + false) + } + visiting.delete(type) + return result + } + return capable +}