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
Original file line number Diff line number Diff line change
@@ -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"
}
13 changes: 4 additions & 9 deletions eslint-typescript6.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)
}
}

Expand Down
82 changes: 82 additions & 0 deletions packages/__tests__/eslint-typescript6.test.ts
Original file line number Diff line number Diff line change
@@ -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)
29 changes: 28 additions & 1 deletion packages/eslint-plugin/README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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 `<Global styles>` 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 |
Expand Down
40 changes: 21 additions & 19 deletions packages/eslint-plugin/src/__tests__/index.test.ts
Original file line number Diff line number Diff line change
@@ -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),
},
},
})
})
})
Original file line number Diff line number Diff line change
@@ -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 <Box {...props} /> }'
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 <Box {...props} /> }'
// When linting, then no empty style schema may silently pass.
expect(() =>
new Linter().verify(source, [...recommendedTypeChecked, project], {
filename,
}),
).toThrow(StyleSchemaError)
}, 30000)
})
21 changes: 21 additions & 0 deletions packages/eslint-plugin/src/configs/recommended-type-checked.ts
Original file line number Diff line number Diff line change
@@ -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,
},
},
]
2 changes: 2 additions & 0 deletions packages/eslint-plugin/src/index.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
37 changes: 19 additions & 18 deletions packages/eslint-plugin/src/rules/__tests__/index.test.ts
Original file line number Diff line number Diff line change
@@ -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),
})
})
})
1 change: 1 addition & 0 deletions packages/eslint-plugin/src/rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Loading
Loading