From 4d3e16c0a23ca8b8f8ea3956e307618ee702a480 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 6 May 2026 00:48:57 +0300 Subject: [PATCH] chore(lint): ban broad function types - Add structural lint rules for broad Function usage and globalThis deletion outside tests - Type design JSX component factories with an explicit callable signature - Replace remaining broad Kiwi serialization test records with unknown values --- lint/plugin.js | 53 +++++++++++++++++++ oxlint.json | 2 + oxlint.structure.json | 9 ++++ packages/core/src/design-jsx/mini-react.ts | 6 ++- packages/core/src/design-jsx/render.ts | 4 +- .../core/src/profiler/draw-call-counter.ts | 9 ++-- tests/engine/kiwi-serialize-fixes.test.ts | 30 +++++------ 7 files changed, 90 insertions(+), 23 deletions(-) diff --git a/lint/plugin.js b/lint/plugin.js index 3f634c5ad..4a53289ca 100644 --- a/lint/plugin.js +++ b/lint/plugin.js @@ -805,6 +805,57 @@ const noUnknownRecordDoubleCast = { } } +const noFunctionType = { + meta: { + docs: { + description: 'Disallow the broad Function type; use an explicit callable signature' + } + }, + create(context) { + return { + TSTypeReference(node) { + if (node.typeName?.type !== 'Identifier' || node.typeName.name !== 'Function') return + context.report({ + node, + message: 'Use an explicit function signature instead of the broad Function type.' + }) + } + } + } +} + +const noReflectDeleteGlobalThisOutsideTests = { + meta: { + docs: { + description: 'Disallow Reflect.deleteProperty(globalThis, ...) outside tests' + } + }, + create(context) { + const file = normalizedFilename(context) + if (file.includes('/tests/')) return {} + + return { + CallExpression(node) { + if (node.callee?.type !== 'MemberExpression') return + if (node.callee.object?.type !== 'Identifier' || node.callee.object.name !== 'Reflect') + return + if ( + node.callee.property?.type !== 'Identifier' || + node.callee.property.name !== 'deleteProperty' + ) + return + const firstArg = node.arguments?.[0] + if (firstArg?.type !== 'Identifier' || firstArg.name !== 'globalThis') return + context.report({ + node, + message: + 'Do not mutate globalThis outside tests; isolate platform state behind a boundary.' + }) + } + } + } +} + const noTsSuppressionComments = { meta: { docs: { @@ -1342,6 +1393,8 @@ const plugin = { 'no-broad-double-cast': noBroadDoubleCast, 'no-unknown-record-double-cast': noUnknownRecordDoubleCast, 'no-ts-suppression-comments': noTsSuppressionComments, + 'no-function-type': noFunctionType, + 'no-reflect-delete-global-this-outside-tests': noReflectDeleteGlobalThisOutsideTests, 'no-core-browser-globals': noCoreBrowserGlobals, 'no-direct-graph-emitter-subscriptions': noDirectGraphEmitterSubscriptions, 'no-on-unmounted-in-composition-roots': noOnUnmountedInCompositionRoots, diff --git a/oxlint.json b/oxlint.json index 800367725..045648dc7 100644 --- a/oxlint.json +++ b/oxlint.json @@ -113,6 +113,8 @@ "open-pencil/no-broad-double-cast": "error", "open-pencil/no-unknown-record-double-cast": "error", "open-pencil/no-ts-suppression-comments": "error", + "open-pencil/no-function-type": "error", + "open-pencil/no-reflect-delete-global-this-outside-tests": "error", "open-pencil/no-core-browser-globals": "error", "open-pencil/no-function-alias-imports": "error", "open-pencil/max-composition-root-lines": ["error", { "max": 260 }] diff --git a/oxlint.structure.json b/oxlint.structure.json index c0a05bc9c..ce243fe0c 100644 --- a/oxlint.structure.json +++ b/oxlint.structure.json @@ -11,6 +11,12 @@ "rules": { "no-unused-vars": "off" } + }, + { + "files": ["packages/core/src/kiwi/kiwi-schema/**"], + "rules": { + "typescript/no-explicit-any": "off" + } } ], "rules": { @@ -39,6 +45,9 @@ "open-pencil/no-broad-double-cast": "error", "open-pencil/no-unknown-record-double-cast": "error", "open-pencil/no-ts-suppression-comments": "error", + "open-pencil/no-function-type": "error", + "open-pencil/no-reflect-delete-global-this-outside-tests": "error", + "typescript/no-explicit-any": "error", "open-pencil/no-core-browser-globals": "error", "open-pencil/no-direct-graph-emitter-subscriptions": "error", "open-pencil/no-on-unmounted-in-composition-roots": "error", diff --git a/packages/core/src/design-jsx/mini-react.ts b/packages/core/src/design-jsx/mini-react.ts index f42a19cdd..051fd9816 100644 --- a/packages/core/src/design-jsx/mini-react.ts +++ b/packages/core/src/design-jsx/mini-react.ts @@ -1,5 +1,7 @@ +export type ComponentType = (props: Record) => ReactNode + export type ReactElement = { - type: string | Function + type: string | ComponentType props: Record & { children?: ReactNode[] } } @@ -8,7 +10,7 @@ export type ReactNode = ReactElement | string | number | null | undefined | Reac export type FC

> = (props: P) => ReactElement export function createElement( - type: string | Function, + type: string | ComponentType, props: Record | null, ...children: ReactNode[] ): ReactElement { diff --git a/packages/core/src/design-jsx/render.ts b/packages/core/src/design-jsx/render.ts index c39303189..960e5dd97 100644 --- a/packages/core/src/design-jsx/render.ts +++ b/packages/core/src/design-jsx/render.ts @@ -10,7 +10,7 @@ import type { SceneGraph } from '#core/scene-graph' * Build a component function from a JSX string using sucrase. * Works in both Node/Bun and the browser (no native bindings). */ -export function buildComponent(jsxString: string): () => unknown { +export function buildComponent(jsxString: string): React.ComponentType { const trimmed = jsxString.trim() const aliases = ` @@ -37,7 +37,7 @@ export function buildComponent(jsxString: string): () => unknown { } // eslint-disable-next-line typescript-eslint/no-implied-eval -- sucrase output must be evaluated at runtime - return new Function('React', code)(React) as () => unknown + return new Function('React', code)(React) as React.ComponentType } interface RenderJSXOptions { diff --git a/packages/core/src/profiler/draw-call-counter.ts b/packages/core/src/profiler/draw-call-counter.ts index 95fc0dadf..471d3e3d4 100644 --- a/packages/core/src/profiler/draw-call-counter.ts +++ b/packages/core/src/profiler/draw-call-counter.ts @@ -6,11 +6,12 @@ const DRAW_METHODS = [ ] as const type DrawMethod = (typeof DRAW_METHODS)[number] +type DrawFunction = (...args: unknown[]) => void export class DrawCallCounter { count = 0 - private originals = new Map void>() + private originals = new Map() private gl: WebGL2RenderingContext | null constructor(gl: WebGL2RenderingContext | null) { @@ -18,10 +19,10 @@ export class DrawCallCounter { if (!gl) return for (const method of DRAW_METHODS) { - const original = gl[method].bind(gl) as (...args: unknown[]) => void + const original = gl[method].bind(gl) as DrawFunction this.originals.set(method, original) // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- monkey-patching overloaded WebGL draw methods - ;(gl[method] as Function) = (...args: unknown[]) => { + ;(gl[method] as DrawFunction) = (...args: unknown[]) => { this.count++ original(...args) } @@ -39,7 +40,7 @@ export class DrawCallCounter { if (!gl) return for (const [method, fn] of this.originals) { - ;(gl[method] as Function) = fn + ;(gl[method] as DrawFunction) = fn } this.originals.clear() } diff --git a/tests/engine/kiwi-serialize-fixes.test.ts b/tests/engine/kiwi-serialize-fixes.test.ts index ee14454ea..7ca59d52f 100644 --- a/tests/engine/kiwi-serialize-fixes.test.ts +++ b/tests/engine/kiwi-serialize-fixes.test.ts @@ -61,7 +61,7 @@ describe('Fix 1: auto-layout child transforms', () => { { value: 100 }, graph, blobs - ) as Record[] + ) as Record[] // changes[0] = parent, changes[1] = child const childNc = changes.find((nc) => nc.name === 'Child')! @@ -99,7 +99,7 @@ describe('Fix 1: auto-layout child transforms', () => { { value: 100 }, graph, blobs - ) as Record[] + ) as Record[] const absNc = changes.find((nc) => nc.name === 'AbsChild')! expect(absNc).toBeDefined() @@ -134,7 +134,7 @@ describe('Fix 1: auto-layout child transforms', () => { { value: 100 }, graph, blobs - ) as Record[] + ) as Record[] const childNc = changes.find((nc) => nc.name === 'Child')! expect(childNc).toBeDefined() @@ -170,7 +170,7 @@ describe('Fix 1: auto-layout child transforms', () => { { value: 100 }, graph, blobs - ) as Record[] + ) as Record[] const itemNc = changes.find((nc) => nc.name === 'Item')! expect(itemNc.transform.m02).toBe(0) @@ -193,7 +193,7 @@ describe('Fix 2: frameMaskDisabled is inverse of clipsContent', () => { height: 100 }) - const changes = toKiwi(node, graph) as Record[] + const changes = toKiwi(node, graph) as Record[] expect(changes[0].frameMaskDisabled).toBe(true) }) @@ -208,7 +208,7 @@ describe('Fix 2: frameMaskDisabled is inverse of clipsContent', () => { clipsContent: true }) - const changes = toKiwi(node, graph) as Record[] + const changes = toKiwi(node, graph) as Record[] expect(changes[0].frameMaskDisabled).toBe(false) }) @@ -223,7 +223,7 @@ describe('Fix 2: frameMaskDisabled is inverse of clipsContent', () => { clipsContent: false }) - const changes = toKiwi(node, graph) as Record[] + const changes = toKiwi(node, graph) as Record[] expect(changes[0].frameMaskDisabled).toBe(true) }) @@ -264,7 +264,7 @@ describe('Fix 3: bordersTakeSpace serialization', () => { strokesIncludedInLayout: true }) - const changes = toKiwi(node, graph) as Record[] + const changes = toKiwi(node, graph) as Record[] expect(changes[0].bordersTakeSpace).toBe(true) }) @@ -281,7 +281,7 @@ describe('Fix 3: bordersTakeSpace serialization', () => { strokesIncludedInLayout: false }) - const changes = toKiwi(node, graph) as Record[] + const changes = toKiwi(node, graph) as Record[] expect(changes[0].bordersTakeSpace).toBe(false) }) @@ -296,7 +296,7 @@ describe('Fix 3: bordersTakeSpace serialization', () => { strokesIncludedInLayout: true }) - const changes = toKiwi(node, graph) as Record[] + const changes = toKiwi(node, graph) as Record[] expect(changes[0].bordersTakeSpace).toBeUndefined() }) }) @@ -321,7 +321,7 @@ describe('Fix 4: text lineHeight serialization', () => { lineHeight: 24 }) - const changes = toKiwi(node, graph) as Record[] + const changes = toKiwi(node, graph) as Record[] expect(changes[0].lineHeight).toEqual({ value: 24, units: 'PIXELS' }) }) @@ -340,7 +340,7 @@ describe('Fix 4: text lineHeight serialization', () => { // lineHeight not set — defaults to null }) - const changes = toKiwi(node, graph) as Record[] + const changes = toKiwi(node, graph) as Record[] // ceil(16 * 1.2) = ceil(19.2) = 20 expect(changes[0].lineHeight).toEqual({ value: 20, units: 'PIXELS' }) }) @@ -359,7 +359,7 @@ describe('Fix 4: text lineHeight serialization', () => { fontSize: 14 }) - const changes = toKiwi(node, graph) as Record[] + const changes = toKiwi(node, graph) as Record[] // ceil(14 * 1.2) = ceil(16.8) = 17 expect(changes[0].lineHeight).toEqual({ value: 17, units: 'PIXELS' }) }) @@ -469,7 +469,7 @@ describe('Fix 5: font family normalization in derivedTextData', () => { fontSize: 14 }) - const changes = toKiwi(node, graph) as Record[] + const changes = toKiwi(node, graph) as Record[] expect(changes[0].fontName.family).toBe('DM Sans') }) }) @@ -539,7 +539,7 @@ describe('Integration: auto-layout component with all fixes', () => { { value: 100 }, graph, blobs - ) as Record[] + ) as Record[] const cardNc = changes[0] const titleNc = changes.find((nc) => nc.name === 'Title')!