Add strict lint rules to catch AI-generated slop at compile time

New rules:
- typescript/no-unnecessary-condition: catches redundant ?., ??, and
  always-true/false branches (22 violations fixed)
- typescript/consistent-return: mixed return/no-return in functions
- typescript/no-unnecessary-type-parameters: unused generic params
- typescript/prefer-for-of: use for-of instead of indexed loops
- unicorn/no-nested-ternary: forbid nested ternaries
- unicorn/consistent-existence-index-check: !== -1 over >= 0
- no-empty-function: no empty function bodies

Fixed all 32 violations: removed unnecessary optional chaining on
non-nullish values, tightened Record types to Partial<Record> where
index access can return undefined, replaced nested ternary with
lookup table, added exhaustive default cases.
This commit is contained in:
Danila Poyarkov 2026-03-09 09:16:07 +03:00
parent f96fffb83f
commit 17faf4df93
14 changed files with 68 additions and 56 deletions

View file

@ -19,13 +19,19 @@
}]
}],
"no-empty-function": "error",
"typescript/no-explicit-any": "warn",
"typescript/no-non-null-assertion": "warn",
"typescript/no-floating-promises": "error",
"typescript/no-misused-promises": "error",
"typescript/consistent-type-imports": "error",
"typescript/consistent-return": "error",
"typescript/no-unnecessary-condition": "error",
"typescript/no-unnecessary-type-assertion": "error",
"typescript/no-unnecessary-type-parameters": "error",
"typescript/prefer-optional-chain": "error",
"typescript/prefer-for-of": "error",
"import/no-cycle": "error",
"import/no-self-import": "error",
@ -34,6 +40,8 @@
"unicorn/no-instanceof-array": "error",
"unicorn/no-typeof-undefined": "error",
"unicorn/no-nested-ternary": "error",
"unicorn/consistent-existence-index-check": "error",
"vue/no-arrow-functions-in-watch": "error",
"vue/no-deprecated-destroyed-lifecycle": "error",

View file

@ -156,7 +156,7 @@ export function startAutomationBridge(server: ViteServer) {
if (provided !== authToken) {
return c.json({ error: 'Unauthorized' }, 401)
}
await next()
return next()
})
app.post('/rpc', async (c) => {

View file

@ -39,10 +39,10 @@ export function connectAutomation(getStore: () => EditorStore) {
const store = getStore()
if (command === 'eval') {
const code = (args as { code?: string })?.code
const code = (args as { code?: string }).code
if (!code) throw new Error('Missing "code" in args')
const figma = makeFigma()
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor
const AsyncFunction = Object.getPrototypeOf(async function () { /* noop */ }).constructor
const wrappedCode = code.trim().startsWith('return')
? code
: `return (async () => { ${code} })()`
@ -53,14 +53,14 @@ export function connectAutomation(getStore: () => EditorStore) {
}
if (command === 'tool') {
const toolName = (args as { name?: string })?.name
const toolArgs = (args as { args?: Record<string, unknown> })?.args ?? {}
const toolName = (args as { name?: string }).name
const toolArgs = (args as { args?: Record<string, unknown> }).args ?? {}
if (!toolName) throw new Error('Missing "name" in args')
if (toolName === 'render' && toolArgs.tree) {
const tree = toolArgs.tree as Parameters<typeof renderTreeNode>[1]
const result = renderTreeNode(store.graph, tree, {
parentId: (toolArgs.parent_id as string) ?? store.state.currentPageId,
parentId: (toolArgs.parent_id as string | undefined) ?? store.state.currentPageId,
x: toolArgs.x as number | undefined,
y: toolArgs.y as number | undefined
})
@ -96,7 +96,7 @@ export function connectAutomation(getStore: () => EditorStore) {
)
if (!data) throw new Error('Export failed')
let binary = ''
for (let i = 0; i < data.length; i++) binary += String.fromCharCode(data[i])
for (const byte of data) binary += String.fromCharCode(byte)
const base64 = btoa(binary)
return {
ok: true,
@ -113,7 +113,7 @@ export function connectAutomation(getStore: () => EditorStore) {
nodeIds.length === 1
? sceneNodeToJSX(nodeIds[0], store.graph, style)
: selectionToJSX(nodeIds, store.graph, style)
return { ok: true, result: { jsx: jsx ?? '' } }
return { ok: true, result: { jsx } }
}
const result = executeRpcCommand(store.graph, command, args ?? {})

View file

@ -31,7 +31,7 @@ function handleSubmit(text: string) {
const c = ensureChat()
if (c) chat.value = markRaw(c)
}
chat.value?.sendMessage({ text }).catch(() => {})
chat.value?.sendMessage({ text }).catch(() => { /* user-facing error handled by UI */ })
}
function handleStop() {

View file

@ -117,7 +117,7 @@ function onLayerRightClick(e: MouseEvent) {
function toggleExpand(id: string) {
const idx = expanded.value.indexOf(id)
if (idx >= 0) {
if (idx !== -1) {
expanded.value = expanded.value.filter((e) => e !== id)
} else {
expanded.value = [...expanded.value, id]

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, watch, nextTick, h } from 'vue'
import { ref, computed, watch, nextTick, h, type Component } from 'vue'
import {
DialogRoot,
DialogPortal,
@ -273,14 +273,13 @@ const columns = computed<ColumnDef<Variable>[]>(() => {
cell: ({ row }) => {
const v = row.original
const iconClass = 'size-3.5 shrink-0 text-muted'
const iconComponent =
v.type === 'COLOR'
? IconPalette
: v.type === 'FLOAT'
? IconHash
: v.type === 'STRING'
? IconType
: IconToggleLeft
const VARIABLE_TYPE_ICONS: Record<string, Component> = {
COLOR: IconPalette,
FLOAT: IconHash,
STRING: IconType,
BOOLEAN: IconToggleLeft
}
const iconComponent = VARIABLE_TYPE_ICONS[v.type] ?? IconToggleLeft
const icon = h(iconComponent, { class: iconClass })
return h('div', { class: 'flex items-center gap-2' }, [

View file

@ -27,7 +27,7 @@ const hasCornerRadius = computed(() => {
const visibilityState = computed(() => {
const v = merged('visible')
return v === MIXED ? 'mixed' : v ? 'visible' : 'hidden'
return v === MIXED ? 'mixed' : (v ? 'visible' : 'hidden')
})
function toggleVisibility() {

View file

@ -494,7 +494,7 @@ export function useCanvasInput(
// Check proximity to first vertex for closing
const first = store.state.penState.vertices[0]
if (store.state.penState.vertices.length > 2 && first) {
if (store.state.penState.vertices.length > 2) {
const dist = Math.hypot(cx - first.x, cy - first.y)
store.penSetClosingToFirst(dist < PEN_CLOSE_THRESHOLD)
}
@ -746,7 +746,6 @@ export function useCanvasInput(
return
}
if (d.type === 'marquee') {
const minX = Math.min(d.startX, cx)
const minY = Math.min(d.startY, cy)
const maxX = Math.max(d.startX, cx)
@ -766,7 +765,6 @@ export function useCanvasInput(
store.select(hits)
store.setMarquee({ x: minX, y: minY, width: maxX - minX, height: maxY - minY })
}
}
function applyResize(d: DragResize, cx: number, cy: number, constrain: boolean) {
const { handle, origRect } = d
@ -1067,9 +1065,9 @@ export function useCanvasInput(
const allChildren = store.graph.getChildren(parent.id)
let realIndex = 0
let filteredCount = 0
for (let i = 0; i < allChildren.length; i++) {
if (store.state.selectedIds.has(allChildren[i].id)) continue
if (allChildren[i].layoutPositioning === 'ABSOLUTE') {
for (const child of allChildren) {
if (store.state.selectedIds.has(child.id)) continue
if (child.layoutPositioning === 'ABSOLUTE') {
realIndex++
continue
}
@ -1272,8 +1270,8 @@ export function useCanvasInput(
const rect = canvas.getBoundingClientRect()
pendingGesture = {
scale: ge.scale,
sx: (ge.clientX ?? rect.width / 2) - rect.left,
sy: (ge.clientY ?? rect.height / 2) - rect.top
sx: ge.clientX - rect.left,
sy: ge.clientY - rect.top
}
if (!gestureRafId) {
gestureRafId = requestAnimationFrame(flushGesture)

View file

@ -31,6 +31,7 @@ async function initWebGPU(ck: CanvasKit): Promise<WebGPUContext | null> {
const adapter = await navigator.gpu.requestAdapter()
if (!adapter) return null
const device = await adapter.requestDevice()
// oxlint-disable-next-line typescript/no-unnecessary-condition -- WebGPU CanvasKit API may not exist at runtime
const deviceContext = asWebGPU(ck).MakeGPUDeviceContext?.(device)
if (!deviceContext) return null
return { device, deviceContext }
@ -51,6 +52,7 @@ export function useCanvas(canvasRef: Ref<HTMLCanvasElement | null>, store: Edito
if (!canvas || destroyed) return
ck = await getCanvasKit()
// oxlint-disable-next-line typescript/no-unnecessary-condition -- async race: destroyed may change during await
if (destroyed) return
if (getGpuBackend() === 'webgpu') {

View file

@ -126,6 +126,10 @@ function createModel(): LanguageModel {
})
return custom(effectiveModelID)
}
default: {
const _exhaustive: never = providerID.value
throw new Error(`Unknown provider: ${String(_exhaustive)}`)
}
}
}

View file

@ -49,7 +49,7 @@ export async function openFileDialog() {
const store = useEditorStore()
const MENU_ACTIONS: Record<string, () => void> = {
const MENU_ACTIONS: Partial<Record<string, () => void>> = {
new: () => createTab(),
open: () => void openFileDialog(),
close: () => {

View file

@ -13,7 +13,7 @@ export function useMultiProps() {
const nodes = computed(() => store.selectedNodes.value)
const isMulti = computed(() => nodes.value.length > 1)
const active = computed(() => node.value || isMulti.value)
const activeNode = computed(() => node.value ?? nodes.value[0] ?? null)
const activeNode = computed(() => node.value ?? (nodes.value[0] as SceneNode | undefined) ?? null)
function merged<K extends keyof SceneNode>(key: K): MixedValue<SceneNode[K]> {
const all = nodes.value
@ -36,7 +36,7 @@ export function useMultiProps() {
store.requestRender()
}
function isArrayMixed<K extends keyof SceneNode>(key: K): boolean {
function isArrayMixed(key: keyof SceneNode): boolean {
const all = nodes.value
if (all.length <= 1) return false
const first = JSON.stringify(all[0][key])

View file

@ -85,7 +85,7 @@ export const TOOLS: ToolDef[] = [
{ key: 'HAND', label: 'Hand', shortcut: 'H' }
]
export const TOOL_SHORTCUTS: Record<string, Tool> = {
export const TOOL_SHORTCUTS: Partial<Record<string, Tool>> = {
v: 'SELECT',
f: 'FRAME',
s: 'SECTION',
@ -612,13 +612,14 @@ export function createEditorStore() {
state.documentName = file.name.replace(/\.fig$/i, '')
downloadName = file.name
state.selectedIds = new Set()
const firstPage = graph.getPages()[0]
state.currentPageId = firstPage?.id ?? graph.rootId
const firstPage = graph.getPages()[0] as SceneNode | undefined
const pageId = firstPage?.id ?? graph.rootId
state.currentPageId = pageId
state.panX = 0
state.panY = 0
state.zoom = 1
state.pageColor = { ...CANVAS_BG_COLOR }
await loadFontsForNodes(graph.getChildren(firstPage?.id ?? graph.rootId).map((n) => n.id))
await loadFontsForNodes(graph.getChildren(pageId).map((n) => n.id))
requestRender()
void startWatchingFile()
} catch (e) {
@ -1091,7 +1092,7 @@ export function createEditorStore() {
const parentAbs = isTopLevel(parentId) ? { x: 0, y: 0 } : graph.getAbsolutePosition(parentId)
const direction: LayoutMode =
nodes.length <= 1 ? 'VERTICAL' : maxX - minX >= maxY - minY ? 'HORIZONTAL' : 'VERTICAL'
nodes.length <= 1 ? 'VERTICAL' : (maxX - minX >= maxY - minY ? 'HORIZONTAL' : 'VERTICAL')
const frame = graph.createNode('FRAME', parentId, {
name: 'Frame',
@ -2085,8 +2086,8 @@ export function createEditorStore() {
const w = maxX - minX + padding * 2
const h = maxY - minY + padding * 2
const viewW = window.innerWidth ?? 800
const viewH = window.innerHeight ?? 600
const viewW = window.innerWidth
const viewH = window.innerHeight
const zoom = Math.min(viewW / w, viewH / h, 1)
state.zoom = zoom
@ -2114,8 +2115,8 @@ export function createEditorStore() {
}
function zoomTo100() {
const viewW = window.innerWidth ?? 800
const viewH = window.innerHeight ?? 600
const viewW = window.innerWidth
const viewH = window.innerHeight
const centerX = (-state.panX + viewW / 2) / state.zoom
const centerY = (-state.panY + viewH / 2) / state.zoom

View file

@ -56,7 +56,7 @@ export function switchTab(tabId: string) {
export function closeTab(tabId: string) {
const idx = tabsRef.value.findIndex((t) => t.id === tabId)
if (idx < 0) return
if (idx === -1) return
const wasActive = activeTabId.value === tabId
tabsRef.value = tabsRef.value.filter((t) => t.id !== tabId)