From c59d50643ffa3caf2bf183c281d05348381c7441 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 22 Oct 2026 23:59:00 +0900 Subject: [PATCH 1/3] test: restore complete coverage on latest Bun Refs #683 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../changepack_log_bun_latest_coverage.json | 7 ++++ .../__tests__/index.test.ts | 39 +++++++++++++++++ .../__tests__/index.test.ts | 16 +++++++ .../rules/no-useless-tailing-nulls/index.ts | 14 ++++--- .../utils/__tests__/import-sources.test.ts | 24 +++++++++++ .../src/__tests__/coordinator-port.test.ts | 6 +++ .../next-plugin/src/__tests__/wasm.test.ts | 14 +++++++ .../__tests__/reservation-discovery.test.ts | 37 ++++++++++++++++ .../src/__tests__/reservation-errors.test.ts | 29 ++++++++++++- .../src/stores/__tests__/theme-store.test.ts | 42 +++++++++++++++++++ .../src/__tests__/config-errors.test.ts | 40 ++++++++++++++++++ .../src/__tests__/plugin.test.ts | 32 ++++++++------ 12 files changed, 280 insertions(+), 20 deletions(-) create mode 100644 .changepacks/changepack_log_bun_latest_coverage.json create mode 100644 packages/webpack-plugin/src/__tests__/config-errors.test.ts diff --git a/.changepacks/changepack_log_bun_latest_coverage.json b/.changepacks/changepack_log_bun_latest_coverage.json new file mode 100644 index 000000000..8989e4049 --- /dev/null +++ b/.changepacks/changepack_log_bun_latest_coverage.json @@ -0,0 +1,7 @@ +{ + "changes": { + "packages/eslint-plugin/package.json": "Patch" + }, + "note": "Preserve trailing-null lint fixes while removing an unreachable AST guard; restore complete behavior coverage on the latest Bun without exclusions or new build errors.", + "date": "2026-10-22T00:00:00.000Z" +} diff --git a/packages/eslint-plugin/src/rules/css-utils-literal-only/__tests__/index.test.ts b/packages/eslint-plugin/src/rules/css-utils-literal-only/__tests__/index.test.ts index fc7fe8169..d4686b857 100644 --- a/packages/eslint-plugin/src/rules/css-utils-literal-only/__tests__/index.test.ts +++ b/packages/eslint-plugin/src/rules/css-utils-literal-only/__tests__/index.test.ts @@ -18,6 +18,25 @@ describe.each(['css' /* 'globalCss', 'keyframes'*/])( }) ruleTester.run('css-utils-literal-only rule', cssUtilsLiteralOnly, { valid: [ + ...[ + "const f = ({'p': n}) => n; css({w: f({p: 1})});", + "const k = 'p'; const f = ({[k]: n}) => n; css({w: f({p: 1})});", + 'const L = [1]; unknown(L[0]); css({w: L[0]});', + 'const L = [1]; unknown(L[9]); css({w: L[0]});', + 'const L = {p: 1}; export const holder = [L]; css({w: L.p});', + 'const L = {p: 1}; module.exports.token = L; css({w: L.p});', + "const L = {p: 1}; module['exports'].token = L; css({w: L.p});", + 'const N = 4; function f() { return N } css({w: f()});', + 'const helper = () => 4; function f() { return helper() } css({w: f()});', + 'const helper = function() { return 4 }; function f() { return helper() } css({w: f()});', + ].map((use) => ({ + code: `import { css } from "@devup-ui/react";\n${use}`, + filename: 'src/app/page.tsx', + })), + { + code: `import { 'create' as create } from '@stylexjs/stylex'; create({a: {width: 1}});`, + filename: 'src/app/page.tsx', + }, { code: `import { ${code} } from "@devup-ui/react";\n${code}({w: 1})`, filename: 'src/app/page.tsx', @@ -145,6 +164,26 @@ describe.each(['css' /* 'globalCss', 'keyframes'*/])( }, ], invalid: [ + ...[ + "const f = ({'normalize': n}) => n; css({w: f({normalize: 1})});", + 'const L = [{p: 1}]; unknown(L[0]); css({w: L[0].p});', + 'const A = [{p: 1}]; const L = [1, ...A]; unknown(L[0]); css({w: A[0].p});', + 'const L = {p: 1}; unknown([L]); css({w: L.p});', + 'const L = {p: 1}; other.exports.token = L; css({w: L.p});', + 'const helper = () => Date.now(); function f() { return helper() } css({w: f()});', + 'class Token {} function f() { return Token } css({w: f()});', + ].map((use) => ({ + code: `import { css } from "@devup-ui/react";\n${use}`, + filename: 'src/app/page.tsx', + output: null, + errors: [{ messageId: 'cssUtilsLiteralOnly' as const }], + })), + { + code: `import { 'create' as create } from '@stylexjs/stylex'; let v = 1; create({a: {width: v}});`, + filename: 'src/app/page.tsx', + output: null, + errors: [{ messageId: 'cssUtilsLiteralOnly' }], + }, ...[ [ `export function C() { let fade = keyframes({ from: { opacity: 0 } }); return css({ animationName: fade }); }`, diff --git a/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/__tests__/index.test.ts b/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/__tests__/index.test.ts index 8af51b383..90decccc7 100644 --- a/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/__tests__/index.test.ts +++ b/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/__tests__/index.test.ts @@ -16,6 +16,11 @@ describe('no-useless-tailing-nulls rule', () => { }) ruleTester.run('no-useless-tailing-nulls rule', noUselessTailingNulls, { valid: [ + // Real parser holes are absent elements, not Literal null nodes. + ...['[]', '[,]', '[,null]', '[null,,]', '[1, null, 2]'].map((value) => ({ + code: `import { css } from "@devup-ui/react";\ncss({w: ${value}})`, + filename: 'src/app/page.tsx', + })), { code: 'import { Box } from "@devup-ui/react";\n', filename: 'src/app/page.tsx', @@ -44,6 +49,17 @@ describe('no-useless-tailing-nulls rule', () => { })), ], invalid: [ + ...[ + ['[null]', '[]'], + ['[null,null]', '[]'], + ['[1,null]', '[1]'], + ['[1,,null]', '[]'], + ].map(([value, fixed]) => ({ + code: `import { css } from "@devup-ui/react";\ncss({w: ${value}})`, + output: `import { css } from "@devup-ui/react";\ncss({w: ${fixed}})`, + filename: 'src/app/page.tsx', + errors: [{ messageId: 'uselessTailingNulls' as const }], + })), { code: 'import { Box } from "@devup-ui/react";\n} m={[2, null]} />', output: diff --git a/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/index.ts b/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/index.ts index 94ecb4584..d04e82840 100644 --- a/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/index.ts +++ b/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/index.ts @@ -17,8 +17,14 @@ function checkUselessTailingNulls>( node: TSESTree.ArrayExpression, context: T, ) { - let nullCount = 0 - for (let i = node.elements.length - 1; i >= 0; i--) { + const lastElement = node.elements[node.elements.length - 1] + if ( + lastElement?.type !== AST_NODE_TYPES.Literal || + lastElement.value !== null + ) + return + let nullCount = 1 + for (let i = node.elements.length - 2; i >= 0; i--) { const element = node.elements[i] if (element?.type === AST_NODE_TYPES.Literal && element.value === null) { nullCount++ @@ -26,12 +32,8 @@ function checkUselessTailingNulls>( break } } - if (nullCount === 0) return const previousElement = node.elements[node.elements.length - nullCount - 1] const firstElement = node.elements[0] - const lastElement = node.elements[node.elements.length - 1] - if (!lastElement || (node.elements.length === nullCount && !firstElement)) - return const removeStart = node.elements.length > nullCount && previousElement ? previousElement.range[1] diff --git a/packages/eslint-plugin/src/utils/__tests__/import-sources.test.ts b/packages/eslint-plugin/src/utils/__tests__/import-sources.test.ts index ad5371e35..c39c935ac 100644 --- a/packages/eslint-plugin/src/utils/__tests__/import-sources.test.ts +++ b/packages/eslint-plugin/src/utils/__tests__/import-sources.test.ts @@ -41,6 +41,30 @@ function inspect( } describe('import sources', () => { + for (const use of ['css({w: 1})', 'styled(Base, {w: 1})']) + inspect( + `import { css, styled } from "@devup-ui/react";\n${use}`, + 'page.tsx', + (storage, context) => { + const statement = context.sourceCode.ast.body[1] + if ( + statement.type !== 'ExpressionStatement' || + statement.expression.type !== 'CallExpression' + ) + throw new Error('Expected a parsed style call') + const call = statement.expression + expect(styleValueSite(call.callee, storage)).toBeNull() + const rules = call.arguments.at(-1) + if (!rules) throw new Error('Expected a rules argument') + expect(styleValueSite(rules, storage)).toEqual({ + root: call, + start: call, + }) + if (call.arguments.length === 2) + expect(styleValueSite(call.arguments[0], storage)).toBeNull() + }, + ) + inspect( [ 'import { css as a, Box as B, unknown as u } from "@devup-ui/react";', diff --git a/packages/next-plugin/src/__tests__/coordinator-port.test.ts b/packages/next-plugin/src/__tests__/coordinator-port.test.ts index 421ba1d17..08f51c22a 100644 --- a/packages/next-plugin/src/__tests__/coordinator-port.test.ts +++ b/packages/next-plugin/src/__tests__/coordinator-port.test.ts @@ -71,6 +71,12 @@ describe('coordinator port file', () => { expect(Bun.file(file).size).toBe(0) }) + it('allows first-run cleanup when no coordinator port file exists', () => { + const file = join(tmp(), 'coordinator.port') + + expect(() => removeStalePortFile(file)).not.toThrow() + }) + it('names port, owner pid, state and recovery when the coordinator is unreachable', () => { const dir = tmp() const file = join(dir, 'coordinator.port') diff --git a/packages/next-plugin/src/__tests__/wasm.test.ts b/packages/next-plugin/src/__tests__/wasm.test.ts index d3cc7068c..1b0e3bae5 100644 --- a/packages/next-plugin/src/__tests__/wasm.test.ts +++ b/packages/next-plugin/src/__tests__/wasm.test.ts @@ -132,6 +132,20 @@ describe('WASM loading', () => { ).toEqual({ isolated: true }) }) + it('resolves from the project manifest without an installed or workspace plugin', () => { + const root = mkdtempSync(join(tmpdir(), 'devup-ui-next-root-')) + tempRoots.push(root) + writeFileSync(join(root, 'package.json'), '{"name":"root-fallback"}') + writeFileSync( + join(root, 'dependency.cjs'), + 'module.exports = { root: true }', + ) + + expect( + requireFromPlugin<{ root: boolean }>('./dependency.cjs', root), + ).toEqual({ root: true }) + }) + it('loads or injects the Webpack plugin without a static dependency', () => { expect(typeof loadWebpackPlugin().DevupUIWebpackPlugin).toBe('function') setWebpackPluginForTesting(webpackPlugin) diff --git a/packages/plugin-utils/src/__tests__/reservation-discovery.test.ts b/packages/plugin-utils/src/__tests__/reservation-discovery.test.ts index 757f2b0b9..118651b87 100644 --- a/packages/plugin-utils/src/__tests__/reservation-discovery.test.ts +++ b/packages/plugin-utils/src/__tests__/reservation-discovery.test.ts @@ -169,6 +169,43 @@ it('overlays owned compiled MDX records without reading raw MDX or deduplicating ).toBe(false) }) +it('uses an explicit relative tsconfig instead of the default alias target', () => { + using fs = fixture() + fs.write( + 'tsconfig.json', + JSON.stringify({ + compilerOptions: { baseUrl: '.', paths: { '@token': ['wrong.ts'] } }, + }), + ) + fs.write( + 'tsconfig.build.json', + JSON.stringify({ + compilerOptions: { baseUrl: '.', paths: { '@token': ['right.ts'] } }, + }), + ) + fs.write( + 'src/A.tsx', + "import {Box} from '@devup-ui/react'; import {n} from '@token'; export const A=()=> ", + ) + fs.write('wrong.ts', 'export const n=1') + fs.write('right.ts', 'export const n=4') + + const inventory = collectReservationInputs({ + roots: ['src'], + cwd: fs.root, + package: '@devup-ui/react', + importAliases: {}, + tsconfigPath: 'tsconfig.build.json', + toId: (path) => relative(fs.root, path).replaceAll('\\', '/'), + }) + + expect(inventory.inputs.map(({ filename }) => filename)).toEqual([ + 'right.ts', + 'src/A.tsx', + ]) + expect(inventory.inputs[0].code).toBe('export const n=4') +}) + it('rejects conflicting prepared bytes for one physical path before producing an inventory', () => { // Given using fs = fixture() diff --git a/packages/plugin-utils/src/__tests__/reservation-errors.test.ts b/packages/plugin-utils/src/__tests__/reservation-errors.test.ts index 55e65dd31..f575a1e1a 100644 --- a/packages/plugin-utils/src/__tests__/reservation-errors.test.ts +++ b/packages/plugin-utils/src/__tests__/reservation-errors.test.ts @@ -3,7 +3,7 @@ import { join, relative } from 'node:path' import { expect, it } from 'bun:test' -import { createModuleResolver } from '../import-graph' +import { createModuleResolver, listSourceFiles } from '../import-graph' import { collectReservationInputs } from '../reservation-discovery' import { isReservationReadFailure, @@ -112,6 +112,33 @@ it('continues valid directories when an earlier source root changes from directo ]) }) +it('propagates a source root directory-read failure without an error handler', () => { + using fs = fixture() + fs.write('file-root', 'not a directory') + + expect(() => listSourceFiles(join(fs.root, 'file-root'))).toThrow( + expect.objectContaining({ code: 'ENOTDIR' }), + ) +}) + +it('rethrows the original directory-read error when its handler declines it', () => { + using fs = fixture() + fs.write('file-root', 'not a directory') + let received: unknown + let thrown: unknown + + try { + listSourceFiles(join(fs.root, 'file-root'), (error) => { + received = error + return false + }) + } catch (error) { + thrown = error + } + expect(thrown).toMatchObject({ code: 'ENOTDIR' }) + expect(thrown).toBe(received) +}) + it('leaves the normal resolver as publisher of a reachable broken manifest error', () => { // Given using fs = fixture() diff --git a/packages/react/src/stores/__tests__/theme-store.test.ts b/packages/react/src/stores/__tests__/theme-store.test.ts index 1544aa265..c3c30d7de 100644 --- a/packages/react/src/stores/__tests__/theme-store.test.ts +++ b/packages/react/src/stores/__tests__/theme-store.test.ts @@ -11,6 +11,48 @@ type ThemeValue = Parameters[0] const darkTheme = 'dark' as unknown as ThemeValue describe('themeStore ssr', () => { + it('should select the inert server store when window is undefined', async () => { + // Given: evaluate the factory without a browser window, preserving its descriptor. + const originalWindowDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + 'window', + ) + + try { + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: undefined, + }) + + // When: a fresh import selects the factory in this process. + const { createThemeStore } = await import('../theme-store?ssr-factory') + + // Then: the real server factory provides an inert subscription lifecycle. + expect(createThemeStore).toBe(createServerThemeStore) + const themeStore = createThemeStore() + const notifications: ThemeValue[] = [] + expect(themeStore.get()).toBeNull() + const unsubscribe = themeStore.subscribe((theme) => { + notifications.push(theme) + }) + expect(notifications).toEqual([]) + expect(themeStore.set(darkTheme)).toBeUndefined() + expect(themeStore.get()).toBeNull() + expect(notifications).toEqual([]) + expect(unsubscribe()).toBeUndefined() + expect(unsubscribe()).toBeUndefined() + expect(themeStore.set(darkTheme)).toBeUndefined() + expect(themeStore.get()).toBeNull() + expect(notifications).toEqual([]) + } finally { + if (originalWindowDescriptor) { + Object.defineProperty(globalThis, 'window', originalWindowDescriptor) + } else { + Reflect.deleteProperty(globalThis, 'window') + } + } + }) + it('should filter mutations by type and target', async () => { // const { createThemeStore } = await import('../theme-store') const themeStore = createServerThemeStore() diff --git a/packages/webpack-plugin/src/__tests__/config-errors.test.ts b/packages/webpack-plugin/src/__tests__/config-errors.test.ts new file mode 100644 index 000000000..266a4dbc5 --- /dev/null +++ b/packages/webpack-plugin/src/__tests__/config-errors.test.ts @@ -0,0 +1,40 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import * as wasm from '@devup-ui/wasm' +import { expect, it, spyOn } from 'bun:test' + +import { DevupUIWebpackPlugin } from '../plugin' + +it('clears the theme and creates the CSS directory when configuration cannot be read', () => { + const root = mkdtempSync(join(tmpdir(), 'devup-webpack-config-')) + const config = join(root, 'devup.json') + const cssDir = join(root, 'dist/css') + mkdirSync(config) + wasm.registerTheme({ colors: { default: { brand: '#123456' } } }) + expect(wasm.getDefaultTheme()).toBe('default') + const registered = spyOn(wasm, 'registerTheme') + const logged = spyOn(console, 'error').mockReturnValue(undefined) + try { + const plugin = new DevupUIWebpackPlugin({ + devupFile: config, + distDir: join(root, 'dist'), + cssDir, + watch: false, + }) + + plugin.writeDataFiles() + + expect(registered).toHaveBeenCalledTimes(1) + expect(registered).toHaveBeenCalledWith({}) + expect(wasm.getDefaultTheme()).toBeUndefined() + expect(logged).toHaveBeenCalledTimes(1) + expect(logged).toHaveBeenCalledWith(expect.any(Error)) + expect(existsSync(cssDir)).toBe(true) + } finally { + registered.mockRestore() + logged.mockRestore() + rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/packages/webpack-plugin/src/__tests__/plugin.test.ts b/packages/webpack-plugin/src/__tests__/plugin.test.ts index a032e8f6f..3507e4c2e 100644 --- a/packages/webpack-plugin/src/__tests__/plugin.test.ts +++ b/packages/webpack-plugin/src/__tests__/plugin.test.ts @@ -222,19 +222,25 @@ describe('devupUIWebpackPlugin', () => { } }) }) - it('W66 default regressions: should apply default options', () => { - expect(new DevupUIWebpackPlugin({}).options).toEqual({ - include: [], - package: '@devup-ui/react', - cssDir: resolve('df', 'devup-ui'), - devupFile: 'devup.json', - distDir: 'df', - watch: false, - debug: false, - singleCss: false, - atomHoist: 2, - }) - }) + it.each([true, false])( + 'W66 default regressions: should apply default options (omitted: %s)', + (omitted) => { + const plugin = omitted + ? new DevupUIWebpackPlugin() + : new DevupUIWebpackPlugin({}) + expect(plugin.options).toEqual({ + include: [], + package: '@devup-ui/react', + cssDir: resolve('df', 'devup-ui'), + devupFile: 'devup.json', + distDir: 'df', + watch: false, + debug: false, + singleCss: false, + atomHoist: 2, + }) + }, + ) it('also reserves at native watchRun when watch persistence is disabled', async () => { const manager = { From c1295d884cbb61c10d470b4dad57c2cb35d521cf Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 23 Oct 2026 00:39:10 +0900 Subject: [PATCH 2/3] fix(eslint-plugin): preserve sparse responsive values in autofixes Refs #683 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../changepack_log_bun_latest_coverage.json | 2 +- .../__tests__/index.test.ts | 23 +++++++++++++++++-- .../rules/no-useless-tailing-nulls/index.ts | 20 ++++++++-------- 3 files changed, 33 insertions(+), 12 deletions(-) diff --git a/.changepacks/changepack_log_bun_latest_coverage.json b/.changepacks/changepack_log_bun_latest_coverage.json index 8989e4049..da378968b 100644 --- a/.changepacks/changepack_log_bun_latest_coverage.json +++ b/.changepacks/changepack_log_bun_latest_coverage.json @@ -2,6 +2,6 @@ "changes": { "packages/eslint-plugin/package.json": "Patch" }, - "note": "Preserve trailing-null lint fixes while removing an unreachable AST guard; restore complete behavior coverage on the latest Bun without exclusions or new build errors.", + "note": "Fix sparse trailing-null autofixes to preserve authored values and earlier holes while removing the maximal trailing run of nulls and holes only when the last element is literal null; remove an unreachable AST guard and restore complete behavior coverage on the latest Bun without exclusions or new build errors.", "date": "2026-10-22T00:00:00.000Z" } diff --git a/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/__tests__/index.test.ts b/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/__tests__/index.test.ts index 90decccc7..289f86521 100644 --- a/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/__tests__/index.test.ts +++ b/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/__tests__/index.test.ts @@ -17,7 +17,16 @@ describe('no-useless-tailing-nulls rule', () => { ruleTester.run('no-useless-tailing-nulls rule', noUselessTailingNulls, { valid: [ // Real parser holes are absent elements, not Literal null nodes. - ...['[]', '[,]', '[,null]', '[null,,]', '[1, null, 2]'].map((value) => ({ + ...[ + '[]', + '[,]', + '[1,null,,]', + '[null,,]', + '[,,]', + '[1,,]', + '[1,,2]', + '[1, null, 2]', + ].map((value) => ({ code: `import { css } from "@devup-ui/react";\ncss({w: ${value}})`, filename: 'src/app/page.tsx', })), @@ -53,7 +62,17 @@ describe('no-useless-tailing-nulls rule', () => { ['[null]', '[]'], ['[null,null]', '[]'], ['[1,null]', '[1]'], - ['[1,,null]', '[]'], + ['[1,,null]', '[1]'], + ['[,1,null]', '[,1]'], + ['[1,,2,null]', '[1,,2]'], + ['[1,,null,null]', '[1]'], + ['[,null]', '[]'], + ['[,,null,null]', '[]'], + ['[/* keep */ null,null]', '[/* keep */ ]'], + ['[1,null,,null]', '[1]'], + ['[null,1,,null]', '[null,1]'], + ['[value,,null]', '[value]'], + ['[...values,,null]', '[...values]'], ].map(([value, fixed]) => ({ code: `import { css } from "@devup-ui/react";\ncss({w: ${value}})`, output: `import { css } from "@devup-ui/react";\ncss({w: ${fixed}})`, diff --git a/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/index.ts b/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/index.ts index d04e82840..bc7041423 100644 --- a/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/index.ts +++ b/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/index.ts @@ -23,22 +23,24 @@ function checkUselessTailingNulls>( lastElement.value !== null ) return - let nullCount = 1 + let trailingCount = 1 for (let i = node.elements.length - 2; i >= 0; i--) { const element = node.elements[i] - if (element?.type === AST_NODE_TYPES.Literal && element.value === null) { - nullCount++ + if ( + !element || + (element.type === AST_NODE_TYPES.Literal && element.value === null) + ) { + trailingCount++ } else { break } } - const previousElement = node.elements[node.elements.length - nullCount - 1] + const previousElement = + node.elements[node.elements.length - trailingCount - 1] const firstElement = node.elements[0] - const removeStart = - node.elements.length > nullCount && previousElement - ? previousElement.range[1] - : firstElement?.range[0] - if (removeStart === undefined) return + const removeStart = previousElement + ? previousElement.range[1] + : (firstElement?.range[0] ?? node.range[0] + 1) context.report({ node, From 968f0d362c7c3683713c7144bdf338b614e9b316 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 23 Oct 2026 14:57:33 +0900 Subject: [PATCH 3/3] fix(eslint-plugin): preserve authored syntax in trailing-null fixes Refs #683 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../changepack_log_bun_latest_coverage.json | 2 +- .../__tests__/index.test.ts | 105 +++++++++++++++++- .../rules/no-useless-tailing-nulls/index.ts | 43 ++++--- 3 files changed, 133 insertions(+), 17 deletions(-) diff --git a/.changepacks/changepack_log_bun_latest_coverage.json b/.changepacks/changepack_log_bun_latest_coverage.json index da378968b..aca3b06ca 100644 --- a/.changepacks/changepack_log_bun_latest_coverage.json +++ b/.changepacks/changepack_log_bun_latest_coverage.json @@ -2,6 +2,6 @@ "changes": { "packages/eslint-plugin/package.json": "Patch" }, - "note": "Fix sparse trailing-null autofixes to preserve authored values and earlier holes while removing the maximal trailing run of nulls and holes only when the last element is literal null; remove an unreachable AST guard and restore complete behavior coverage on the latest Bun without exclusions or new build errors.", + "note": "Fix sparse trailing-null autofixes to preserve authored values, complete parenthesized syntax and earlier holes using token-based edit boundaries while removing the maximal trailing run of nulls and holes only when the last element is canonical literal null; preserve regular-expression and BigInt literals without validating regex syntax, preserve existing comment and trailing-comma handling, remove an unreachable AST guard and restore complete behavior coverage on the latest Bun without exclusions or new build errors.", "date": "2026-10-22T00:00:00.000Z" } diff --git a/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/__tests__/index.test.ts b/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/__tests__/index.test.ts index 289f86521..cf90a4853 100644 --- a/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/__tests__/index.test.ts +++ b/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/__tests__/index.test.ts @@ -1,9 +1,110 @@ import { RuleTester } from '@typescript-eslint/rule-tester' -import { describe } from 'bun:test' +import { TSESLint } from '@typescript-eslint/utils' +import { describe, expect, it } from 'bun:test' +import { parser } from 'typescript-eslint' import { noUselessTailingNulls } from '../index' describe('no-useless-tailing-nulls rule', () => { + const boundaryCases = [ + ['[0,(1),,null]', '[0,(1)]'], + ['[0,((1)),,null]', '[0,((1))]'], + ['[0,(1),null]', '[0,(1)]'], + ['[0,(1),,(null)]', '[0,(1)]'], + ['[(null)]', '[]'], + ['[1,null]', '[1]'], + ['[0,((1)),,((null))]', '[0,((1))]'], + ['[((null)),(null)]', '[]'], + [ + '[0,( /* value */ 1 /* inner */ ), /* remove */ , (null)]', + '[0,( /* value */ 1 /* inner */ )]', + ], + ['[1 /* remove */, /* remove */ null /* keep */]', '[1 /* keep */]'], + ['[/* keep */ (null),null]', '[/* keep */ ]'], + ['[, /* remove */ (null)]', '[]'], + ['[0,(1),null,]', '[0,(1),]'], + ['[0,((1)),,((null)),]', '[0,((1)),]'], + ['[(null),]', '[,]'], + ['[null,]', '[,]'], + ['[1,null, /* keep */]', '[1, /* keep */]'], + ['[1,null /* keep */,]', '[1 /* keep */,]'], + [ + '[0,(1), /* remove */ null /* keep */, /* tail */]', + '[0,(1) /* keep */, /* tail */]', + ], + ['[/* keep */ ((null)) /* tail */]', '[/* keep */ /* tail */]'], + ] as const + const literalKindCases = [ + ['[/(/,null]', '[/(/]'], + ['[/a/gg,null]', '[/a/gg]'], + ['[/a/z,null]', '[/a/z]'], + ['[/a/gg,,null]', '[/a/gg]'], + ['[/a/g,null]', '[/a/g]'], + ['[/a/g,,null]', '[/a/g]'], + ['[(/(/),,(null)]', '[(/(/)]'], + ['[1n,null]', '[1n]'], + ['[0xFFn,,null]', '[0xFFn]'], + ['[1n,null,,(null),]', '[1n,]'], + ] as const + const literalKindControls = [ + '[/(/]', + '[/a/gg]', + '[/a/z]', + '[1,null,/(/]', + '[/a/g]', + '[1n]', + '[0xFFn]', + '[null,1n]', + ] as const + const literalKindValidTests = literalKindControls.flatMap((value) => + [false, true].map((jsx) => ({ + code: `import {css,Box} from '@devup-ui/react';\n${jsx ? `` : `css({w:${value}})`}`, + filename: 'src/app/page.tsx', + })), + ) + const boundaryTests = [...boundaryCases, ...literalKindCases].flatMap( + ([value, fixed]) => + [false, true].map((jsx) => ({ + code: `import {css,Box} from '@devup-ui/react';\n${jsx ? `` : `css({w:${value}})`}`, + output: `import {css,Box} from '@devup-ui/react';\n${jsx ? `` : `css({w:${fixed}})`}`, + filename: 'src/app/page.tsx', + errors: [{ messageId: 'uselessTailingNulls' as const }], + })), + ) + it.each([ + ...boundaryTests, + ...literalKindValidTests.map((test) => ({ ...test, output: test.code })), + ])( + 'preserves authored syntax and parses when fixing $code', + ({ code, output }) => { + const linter = new TSESLint.Linter() + const config = { + files: ['**/*.tsx'], + languageOptions: { + parser, + parserOptions: { ecmaFeatures: { jsx: true } }, + }, + plugins: { devup: { rules: { trailing: noUselessTailingNulls } } }, + rules: { 'devup/trailing': 'error' }, + } satisfies TSESLint.FlatConfig.Config + + const result = linter.verifyAndFix(code, config, { + filename: 'src/app/page.tsx', + }) + + expect(result.output).toBe(output) + expect( + linter.verify( + result.output, + { ...config, rules: {} }, + { + filename: 'src/app/page.tsx', + }, + ), + ).toEqual([]) + expect(result.messages).toEqual([]) + }, + ) const ruleTester = new RuleTester({ languageOptions: { ecmaVersion: 'latest', @@ -16,6 +117,7 @@ describe('no-useless-tailing-nulls rule', () => { }) ruleTester.run('no-useless-tailing-nulls rule', noUselessTailingNulls, { valid: [ + ...literalKindValidTests, // Real parser holes are absent elements, not Literal null nodes. ...[ '[]', @@ -58,6 +160,7 @@ describe('no-useless-tailing-nulls rule', () => { })), ], invalid: [ + ...boundaryTests, ...[ ['[null]', '[]'], ['[null,null]', '[]'], diff --git a/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/index.ts b/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/index.ts index bc7041423..d16f185fb 100644 --- a/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/index.ts +++ b/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/index.ts @@ -1,5 +1,6 @@ import { AST_NODE_TYPES, + ASTUtils, ESLintUtils, type TSESTree, } from '@typescript-eslint/utils' @@ -13,23 +14,22 @@ const createRule = ESLintUtils.RuleCreator( `https://github.com/dev-five-git/devup-ui/tree/main/packages/eslint-plugin/src/rules/${name}`, ) +function isNullLiteral( + node: TSESTree.Node | null | undefined, +): node is TSESTree.NullLiteral { + return node?.type === AST_NODE_TYPES.Literal && node.raw === 'null' +} + function checkUselessTailingNulls>( node: TSESTree.ArrayExpression, context: T, ) { const lastElement = node.elements[node.elements.length - 1] - if ( - lastElement?.type !== AST_NODE_TYPES.Literal || - lastElement.value !== null - ) - return + if (!isNullLiteral(lastElement)) return let trailingCount = 1 for (let i = node.elements.length - 2; i >= 0; i--) { const element = node.elements[i] - if ( - !element || - (element.type === AST_NODE_TYPES.Literal && element.value === null) - ) { + if (!element || isNullLiteral(element)) { trailingCount++ } else { break @@ -37,16 +37,29 @@ function checkUselessTailingNulls>( } const previousElement = node.elements[node.elements.length - trailingCount - 1] - const firstElement = node.elements[0] - const removeStart = previousElement - ? previousElement.range[1] - : (firstElement?.range[0] ?? node.range[0] + 1) - context.report({ node, messageId: 'uselessTailingNulls', fix(fixer) { - return fixer.removeRange([removeStart, lastElement.range[1]]) + const sourceCode = context.sourceCode + let removeStart = node.range[0] + 1 + if (previousElement) { + const [separator] = sourceCode.getFirstTokensBetween( + previousElement, + lastElement, + { count: 1, filter: ASTUtils.isCommaToken }, + ) + const [previousToken] = sourceCode.getTokensBefore(separator, 1) + removeStart = previousToken.range[1] + } else if (node.elements[0]) { + const [, firstToken] = sourceCode.getFirstTokens(node, 2) + removeStart = firstToken.range[0] + } + const [beforeLastToken, lastToken] = sourceCode.getLastTokens(node, 3) + const removeEnd = ASTUtils.isCommaToken(lastToken) + ? beforeLastToken.range[1] + : lastToken.range[1] + return fixer.removeRange([removeStart, removeEnd]) }, }) }