diff --git a/CLAUDE.md b/CLAUDE.md index 9674270..9c18630 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,6 +8,8 @@ Turborepo monorepo (Bun workspaces). See [README.md](README.md) for the app/pack Coding standards live in [.sandcastle/CODING_STANDARDS.md](.sandcastle/CODING_STANDARDS.md) — they apply to all contributors, not just AFK agents. Read them before writing code. +**No lint suppressions.** Never write `biome-ignore` or `eslint-disable`. If a rule fires, fix the underlying code. `bun run ci` enforces this with the `check:no-suppressions` script in [package.json](package.json). + ## Commands ```bash diff --git a/apps/desktop/src/renderer/src/chat/useClaude.ts b/apps/desktop/src/renderer/src/chat/useClaude.ts index c2de0f1..ce3b63d 100644 --- a/apps/desktop/src/renderer/src/chat/useClaude.ts +++ b/apps/desktop/src/renderer/src/chat/useClaude.ts @@ -11,7 +11,7 @@ * tool callbacks — those live in `useClaudeSchemaChat`, driven by the * Agent SDK turn pipeline. */ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; export type AuthMode = 'max' | 'api-key'; export type ModelId = 'claude-haiku-4-5-20251001' | 'claude-sonnet-4-6' | 'claude-opus-4-6'; @@ -81,12 +81,14 @@ export function useClaude(): UseClaudeReturn { // Push the initial auth + model snapshot to main so the first turn // uses it without requiring the user to open the popover first. - // biome-ignore lint/correctness/useExhaustiveDependencies: one-shot initial sync + const syncedRef = useRef(false); useEffect(() => { + if (syncedRef.current) return; + syncedRef.current = true; if (authMode === 'max') pushAuth({ mode: 'max' }); else pushAuth({ mode: 'api-key', key: apiKey }); pushModelOptions(model, thinkingBudget); - }, []); + }, [authMode, apiKey, model, thinkingBudget]); const setAuthMode = useCallback( (mode: AuthMode) => { diff --git a/apps/desktop/src/renderer/src/components/chat/ChatPanel.tsx b/apps/desktop/src/renderer/src/components/chat/ChatPanel.tsx index c36ab7d..8df4a8a 100644 --- a/apps/desktop/src/renderer/src/components/chat/ChatPanel.tsx +++ b/apps/desktop/src/renderer/src/components/chat/ChatPanel.tsx @@ -84,8 +84,10 @@ export function ChatPanel({ chat }: ChatPanelProps): React.JSX.Element { const prevMessagesRef = useRef(null); // Restore the active thread's messages on first mount. - // biome-ignore lint/correctness/useExhaustiveDependencies: run once at mount + const restoredRef = useRef(false); useEffect(() => { + if (restoredRef.current) return; + restoredRef.current = true; const thread = history.getActiveThread(); if (thread && thread.messages.length > 0) { hydrate(thread.messages); @@ -98,7 +100,7 @@ export function ChatPanel({ chat }: ChatPanelProps): React.JSX.Element { } } prevMessagesRef.current = thread?.messages ?? []; - }, []); + }, [history, hydrate]); // On schema-file change, switch to the most recent thread for that // file — or clear the transcript and the SDK session if this file diff --git a/apps/desktop/src/renderer/src/components/chat/ChatThreadList.tsx b/apps/desktop/src/renderer/src/components/chat/ChatThreadList.tsx index 6338397..5d822a7 100644 --- a/apps/desktop/src/renderer/src/components/chat/ChatThreadList.tsx +++ b/apps/desktop/src/renderer/src/components/chat/ChatThreadList.tsx @@ -56,77 +56,66 @@ export function ChatThreadList({ const isActive = thread.id === activeThreadId; return ( - // biome-ignore lint/a11y/useSemanticElements: div contains nested interactive buttons, cannot use - - - ) : ( + +
+ {confirmDeleteId === thread.id ? ( +
- )} -
+ +
+ ) : ( + + )} ); diff --git a/apps/desktop/src/renderer/src/components/graph/GraphCanvas.tsx b/apps/desktop/src/renderer/src/components/graph/GraphCanvas.tsx index 9a75f35..4b332c3 100644 --- a/apps/desktop/src/renderer/src/components/graph/GraphCanvas.tsx +++ b/apps/desktop/src/renderer/src/components/graph/GraphCanvas.tsx @@ -303,8 +303,12 @@ function GraphCanvasInner({ positions, onPositionsChange }: GraphCanvasProps): R [click, setSidebarVisible, setSidebarTab], ); - const onKeyDown = useCallback( - (ev: React.KeyboardEvent) => { + const containerRef = useRef(null); + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const handler = (ev: KeyboardEvent): void => { const e: InteractionKeyEvent = { key: ev.key, metaKey: ev.metaKey, @@ -318,17 +322,15 @@ function GraphCanvasInner({ positions, onPositionsChange }: GraphCanvasProps): R if (action.kind === 'op') dispatch(action.op); else if (action.command === 'undo') undo(); else if (action.command === 'redo') redo(); - }, - [dispatch, undo, redo, selectedNodeId], - ); + }; + el.addEventListener('keydown', handler); + return () => el.removeEventListener('keydown', handler); + }, [dispatch, undo, redo, selectedNodeId]); return ( -
@@ -359,6 +361,6 @@ function GraphCanvasInner({ positions, onPositionsChange }: GraphCanvasProps): R -
+ ); } diff --git a/apps/desktop/src/renderer/src/components/schema/SchemaPanel.tsx b/apps/desktop/src/renderer/src/components/schema/SchemaPanel.tsx index f12e825..63abbe1 100644 --- a/apps/desktop/src/renderer/src/components/schema/SchemaPanel.tsx +++ b/apps/desktop/src/renderer/src/components/schema/SchemaPanel.tsx @@ -25,7 +25,7 @@ * path for user-authored source to introduce raw tags. */ import { AArrowDown, AArrowUp, Check, Copy, FileBracesCorner, FileCode } from 'lucide-react'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { Button } from '../ui/button'; import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '../ui/empty'; import { getHighlighter, SHIKI_THEMES } from './shiki-highlighter'; @@ -96,6 +96,11 @@ export function SchemaPanel({ const [fontSizeIndex, setFontSizeIndex] = useState(DEFAULT_FONT_SIZE_INDEX); const [copied, setCopied] = useState(false); const copyTimeoutRef = useRef(null); + const codeRef = useRef(null); + + useLayoutEffect(() => { + if (codeRef.current) codeRef.current.innerHTML = highlightedHtml ?? ''; + }, [highlightedHtml]); // Pre-warm shiki on first mount so it loads in the background. // By the time the user opens the Schema tab the highlighter is @@ -278,9 +283,7 @@ export function SchemaPanel({ data-testid="schema-code" > {highlightedHtml !== null ? ( - /* shiki emits pre-escaped HTML tokens; see security note in the file header. */ - /* biome-ignore lint/security/noDangerouslySetInnerHtml: shiki output is trusted, tokenised, escaped HTML */ -
+
) : (
{activeSource}
)} diff --git a/apps/desktop/src/renderer/src/hooks/useSessionPersistence.ts b/apps/desktop/src/renderer/src/hooks/useSessionPersistence.ts index 96b5404..fa2eaa9 100644 --- a/apps/desktop/src/renderer/src/hooks/useSessionPersistence.ts +++ b/apps/desktop/src/renderer/src/hooks/useSessionPersistence.ts @@ -60,7 +60,7 @@ export function useSessionPersistence({ } catch { store.removeItem(SESSION_KEY); } - }, []); // eslint-disable-line react-hooks/exhaustive-deps + }, []); // Persistence loop: schema and layout changes both trigger a debounced // write. A `pagehide` listener flushes synchronously so a dev-server @@ -133,12 +133,11 @@ export function useSessionPersistence({ window.removeEventListener('beforeunload', onPageHide); } }; - }, []); // eslint-disable-line react-hooks/exhaustive-deps + }, []); // Layout-only changes (e.g. a node drag with no schema mutation) also // need to land in storage. Schedule a debounced flush whenever the // caller-supplied layout reference changes. - // biome-ignore lint/correctness/useExhaustiveDependencies: storageRef is stable useEffect(() => { const store = storageRef.current; if (!store) return; diff --git a/apps/desktop/tests/store/ops.test.ts b/apps/desktop/tests/store/ops.test.ts index 74bcb94..3897dff 100644 --- a/apps/desktop/tests/store/ops.test.ts +++ b/apps/desktop/tests/store/ops.test.ts @@ -1,4 +1,5 @@ import type { Schema } from '@renderer/model/ir'; +import type { Op } from '@renderer/store/ops'; import { apply } from '@renderer/store/ops'; import { describe, expect, it } from 'vitest'; @@ -11,8 +12,7 @@ function ok(res: ReturnType): Schema { describe('apply', () => { it('returns an error for an unknown op kind', () => { - // biome-ignore lint/suspicious/noExplicitAny: invalid kind by design - const res = apply(empty, { kind: 'nope' } as any); + const res = apply(empty, { kind: 'nope' } as unknown as Op); expect('error' in res).toBe(true); }); diff --git a/apps/web/src/components/ui/mobile-nav.tsx b/apps/web/src/components/ui/mobile-nav.tsx index 999603f..9780b02 100644 --- a/apps/web/src/components/ui/mobile-nav.tsx +++ b/apps/web/src/components/ui/mobile-nav.tsx @@ -29,14 +29,16 @@ export function MobileNav() { {open && (
- {/* biome-ignore lint/a11y/useValidAnchor: anchor with valid href, onClick only closes menu */} - setOpen(false)} - className="py-2.5 text-sm text-muted-foreground hover:text-foreground transition-colors" + setOpen(false)} @@ -55,14 +57,16 @@ export function MobileNav() {
- {/* biome-ignore lint/a11y/useValidAnchor: anchor with valid href, onClick only closes menu */} -
setOpen(false)} - className="mt-1 bg-primary text-primary-foreground px-4 py-2.5 rounded-lg text-sm font-medium text-center hover:opacity-90 transition-opacity" +
)} diff --git a/package.json b/package.json index bf6d73b..a460f29 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "format": "biome format --write .", "format:check": "biome format .", "check": "biome check .", - "ci": "TURBO_NO_UPDATE_NOTIFIER=1 turbo typecheck test --output-logs=errors-only && biome check .", + "check:no-suppressions": "bun scripts/check-no-suppressions.ts", + "ci": "TURBO_NO_UPDATE_NOTIFIER=1 turbo typecheck test --output-logs=errors-only && biome check . && bun run check:no-suppressions", "prepare": "husky", "sandcastle": "bun .sandcastle/main.ts", "sandcastle:analyze": "bun .sandcastle/analyze.ts", diff --git a/scripts/check-no-suppressions.ts b/scripts/check-no-suppressions.ts new file mode 100644 index 0000000..6019e17 --- /dev/null +++ b/scripts/check-no-suppressions.ts @@ -0,0 +1,189 @@ +import { readdir, readFile } from 'node:fs/promises'; +import { join, relative } from 'node:path'; + +const bannedTokens = ['biome-ignore', 'eslint-disable'] as const; +const sourceExtensions = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts', '.mjs', '.cjs']); +const ignoredDirectories = new Set([ + '.git', + '.next', + '.turbo', + 'build', + 'coverage', + 'dist', + 'node_modules', + 'out', +]); + +type BannedToken = (typeof bannedTokens)[number]; + +type Finding = { + file: string; + line: number; + column: number; + token: BannedToken; + text: string; +}; + +function extensionOf(path: string): string { + const index = path.lastIndexOf('.'); + return index === -1 ? '' : path.slice(index); +} + +async function* sourceFiles(dir: string): AsyncGenerator { + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + if (!ignoredDirectories.has(entry.name)) { + yield* sourceFiles(path); + } + continue; + } + if (entry.isFile() && sourceExtensions.has(extensionOf(entry.name))) { + yield path; + } + } +} + +function lineStartsFor(source: string): number[] { + const starts = [0]; + for (let i = 0; i < source.length; i++) { + if (source[i] === '\n') starts.push(i + 1); + } + return starts; +} + +function positionFor(index: number, lineStarts: number[]): { line: number; column: number } { + let low = 0; + let high = lineStarts.length - 1; + while (low <= high) { + const mid = Math.floor((low + high) / 2); + if (lineStarts[mid] <= index) { + low = mid + 1; + } else { + high = mid - 1; + } + } + const lineIndex = Math.max(0, high); + return { + line: lineIndex + 1, + column: index - lineStarts[lineIndex] + 1, + }; +} + +function lineTextFor(index: number, source: string, lineStarts: number[]): string { + const { line } = positionFor(index, lineStarts); + const start = lineStarts[line - 1]; + const nextStart = lineStarts[line] ?? source.length + 1; + return source.slice(start, nextStart).trimEnd(); +} + +function findBannedTokensInComment( + file: string, + source: string, + lineStarts: number[], + start: number, + end: number, +): Finding[] { + const findings: Finding[] = []; + const comment = source.slice(start, end); + for (const token of bannedTokens) { + let offset = comment.indexOf(token); + while (offset !== -1) { + const index = start + offset; + const { line, column } = positionFor(index, lineStarts); + findings.push({ + file, + line, + column, + token, + text: lineTextFor(index, source, lineStarts), + }); + offset = comment.indexOf(token, offset + token.length); + } + } + return findings; +} + +function scanFile(file: string, source: string): Finding[] { + const lineStarts = lineStartsFor(source); + const findings: Finding[] = []; + let i = 0; + while (i < source.length) { + const char = source[i]; + const next = source[i + 1]; + + if (char === '"' || char === "'") { + const quote = char; + i++; + while (i < source.length) { + if (source[i] === '\\') { + i += 2; + } else if (source[i] === quote) { + i++; + break; + } else { + i++; + } + } + continue; + } + + if (char === '`') { + i++; + while (i < source.length) { + if (source[i] === '\\') { + i += 2; + } else if (source[i] === '`') { + i++; + break; + } else { + i++; + } + } + continue; + } + + if (char === '/' && next === '/') { + const start = i; + i += 2; + while (i < source.length && source[i] !== '\n') i++; + findings.push(...findBannedTokensInComment(file, source, lineStarts, start, i)); + continue; + } + + if (char === '/' && next === '*') { + const start = i; + i += 2; + while (i < source.length && !(source[i] === '*' && source[i + 1] === '/')) i++; + const end = i < source.length ? i + 2 : i; + findings.push(...findBannedTokensInComment(file, source, lineStarts, start, end)); + i = end; + continue; + } + + i++; + } + return findings; +} + +const root = process.argv[2] ?? process.cwd(); +const findings: Finding[] = []; + +for await (const file of sourceFiles(root)) { + const source = await readFile(file, 'utf8'); + findings.push(...scanFile(relative(root, file), source)); +} + +findings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.column - b.column); + +if (findings.length > 0) { + console.error('Lint suppression comments are banned. Fix the underlying lint issue instead.\n'); + for (const finding of findings) { + console.error(`${finding.file}:${finding.line}:${finding.column} ${finding.token}`); + console.error(` ${finding.text.trim()}`); + } + process.exit(1); +} + +console.log('No banned lint suppression comments found.');