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
This commit is contained in:
Danila Poyarkov 2026-05-06 00:48:57 +03:00
parent fae190a673
commit 4d3e16c0a2
7 changed files with 90 additions and 23 deletions

View file

@ -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,

View file

@ -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 }]

View file

@ -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",

View file

@ -1,5 +1,7 @@
export type ComponentType = (props: Record<string, unknown>) => ReactNode
export type ReactElement = {
type: string | Function
type: string | ComponentType
props: Record<string, unknown> & { children?: ReactNode[] }
}
@ -8,7 +10,7 @@ export type ReactNode = ReactElement | string | number | null | undefined | Reac
export type FC<P = Record<string, unknown>> = (props: P) => ReactElement
export function createElement(
type: string | Function,
type: string | ComponentType,
props: Record<string, unknown> | null,
...children: ReactNode[]
): ReactElement {

View file

@ -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 {

View file

@ -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<DrawMethod, (...args: unknown[]) => void>()
private originals = new Map<DrawMethod, DrawFunction>()
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()
}

View file

@ -61,7 +61,7 @@ describe('Fix 1: auto-layout child transforms', () => {
{ value: 100 },
graph,
blobs
) as Record<string, any>[]
) as Record<string, unknown>[]
// 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<string, any>[]
) as Record<string, unknown>[]
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<string, any>[]
) as Record<string, unknown>[]
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<string, any>[]
) as Record<string, unknown>[]
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<string, any>[]
const changes = toKiwi(node, graph) as Record<string, unknown>[]
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<string, any>[]
const changes = toKiwi(node, graph) as Record<string, unknown>[]
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<string, any>[]
const changes = toKiwi(node, graph) as Record<string, unknown>[]
expect(changes[0].frameMaskDisabled).toBe(true)
})
@ -264,7 +264,7 @@ describe('Fix 3: bordersTakeSpace serialization', () => {
strokesIncludedInLayout: true
})
const changes = toKiwi(node, graph) as Record<string, any>[]
const changes = toKiwi(node, graph) as Record<string, unknown>[]
expect(changes[0].bordersTakeSpace).toBe(true)
})
@ -281,7 +281,7 @@ describe('Fix 3: bordersTakeSpace serialization', () => {
strokesIncludedInLayout: false
})
const changes = toKiwi(node, graph) as Record<string, any>[]
const changes = toKiwi(node, graph) as Record<string, unknown>[]
expect(changes[0].bordersTakeSpace).toBe(false)
})
@ -296,7 +296,7 @@ describe('Fix 3: bordersTakeSpace serialization', () => {
strokesIncludedInLayout: true
})
const changes = toKiwi(node, graph) as Record<string, any>[]
const changes = toKiwi(node, graph) as Record<string, unknown>[]
expect(changes[0].bordersTakeSpace).toBeUndefined()
})
})
@ -321,7 +321,7 @@ describe('Fix 4: text lineHeight serialization', () => {
lineHeight: 24
})
const changes = toKiwi(node, graph) as Record<string, any>[]
const changes = toKiwi(node, graph) as Record<string, unknown>[]
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<string, any>[]
const changes = toKiwi(node, graph) as Record<string, unknown>[]
// 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<string, any>[]
const changes = toKiwi(node, graph) as Record<string, unknown>[]
// 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<string, any>[]
const changes = toKiwi(node, graph) as Record<string, unknown>[]
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<string, any>[]
) as Record<string, unknown>[]
const cardNc = changes[0]
const titleNc = changes.find((nc) => nc.name === 'Title')!