diff --git a/AGENTS.md b/AGENTS.md index b0947da0c2f..f123bf1e95c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,6 +33,14 @@ Never use vague names like `helpers`, `utils`, `common`, `misc`, or `shared-stuf ## Type Declarations: Prefer `.ts` Over `.d.ts` +## Type Assertions: Prefer Checked Types + +Avoid type assertions except `as const`. Unavoidable casts need a line-specific `SAFETY:` explanation: + +```ts +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Explain the verified invariant here. +``` + # Verifying Changes - **Typecheck**: `pnpm tc` (or `tc:node` / `tc:cli` / `tc:web`) diff --git a/config/oxlint-code-quality-casting.json b/config/oxlint-code-quality-casting.json new file mode 100644 index 00000000000..3db01c3a04d --- /dev/null +++ b/config/oxlint-code-quality-casting.json @@ -0,0 +1,17 @@ +{ + "$schema": "../node_modules/oxlint/configuration_schema.json", + "plugins": ["typescript"], + "categories": { + "correctness": "off", + "suspicious": "off", + "pedantic": "off", + "perf": "off", + "style": "off", + "restriction": "off", + "nursery": "off" + }, + "rules": { + "typescript/consistent-type-assertions": ["error", { "assertionStyle": "never" }] + }, + "ignorePatterns": ["**/node_modules", "**/dist", "**/out"] +} diff --git a/config/scripts/casting-code-quality.test.mjs b/config/scripts/casting-code-quality.test.mjs new file mode 100644 index 00000000000..fe38ccf80a1 --- /dev/null +++ b/config/scripts/casting-code-quality.test.mjs @@ -0,0 +1,64 @@ +import { spawnSync } from 'node:child_process' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { expect, it } from 'vitest' +import { OXLINT_SCANS, diagnosticTouchesAddedLines } from './check-changed-code-quality.mjs' +import { resolveOxlintInvocation } from './oxlint-cli-invocation.mjs' + +const root = path.resolve(import.meta.dirname, '..', '..') +const oxlint = resolveOxlintInvocation(root) +const rule = 'typescript(consistent-type-assertions)' + +function lint(file, args = []) { + const result = spawnSync( + oxlint.command, + [...oxlint.prefixArgs, ...args, '--format', 'json', file], + { cwd: root, encoding: 'utf8', windowsHide: true } + ) + expect(result.error).toBeUndefined() + return { status: result.status, diagnostics: JSON.parse(result.stdout).diagnostics } +} + +it.each(['config', 'mobile'])('enforces new casts without changing full lint in %s', (parent) => { + const directory = mkdtempSync(path.join(root, parent, 'casting-lint-test-')) + const file = path.join(directory, 'fixture.test.ts') + try { + writeFileSync( + file, + [ + "export const oldCast = { current: '⌘N' as string | null }", + 'export const doubleCast = undefined as unknown as string', + "export const annotated: { current: string | null } = { current: '⌘N' }", + "export const constant = { current: '⌘N' } as const", + "export const checked = { current: '⌘N' } satisfies { current: string | null }", + '// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Exercise the explicit exception.', + 'export const justified = undefined as unknown' + ].join('\n') + ) + + const full = lint(file) + expect(full.status).toBe(0) + expect(full.diagnostics.filter((diagnostic) => diagnostic.code === rule)).toEqual([]) + + const scan = OXLINT_SCANS.find((candidate) => candidate.label === 'casting code quality') + expect(scan).toBeDefined() + const casting = lint(file, scan.args) + expect(casting.status).toBe(1) + expect(casting.diagnostics).toHaveLength(3) + expect(casting.diagnostics.every((diagnostic) => diagnostic.code === rule)).toBe(true) + + const relative = path.relative(root, file).split(path.sep).join('/') + const changed = new Map([[relative, [{ start: 2, end: 2 }]]]) + const findings = casting.diagnostics.filter((diagnostic) => + diagnosticTouchesAddedLines(diagnostic, changed, root) + ) + expect(findings).toHaveLength(2) + expect(findings.every((diagnostic) => diagnostic.severity === 'error')).toBe(true) + + writeFileSync(file, 'export const angle = undefined\n') + expect(lint(file).status).toBe(1) + expect(lint(file, scan.args).diagnostics.map((diagnostic) => diagnostic.code)).toEqual([rule]) + } finally { + rmSync(directory, { recursive: true, force: true }) + } +}) diff --git a/config/scripts/check-changed-code-quality.mjs b/config/scripts/check-changed-code-quality.mjs index af4e9e82776..ed5ec201dd3 100644 --- a/config/scripts/check-changed-code-quality.mjs +++ b/config/scripts/check-changed-code-quality.mjs @@ -15,6 +15,10 @@ export const OXLINT_SCANS = [ label: 'code quality', args: ['--report-unused-disable-directives-severity', 'warn'] }, + { + label: 'casting code quality', + args: ['--config', 'config/oxlint-code-quality-casting.json'] + }, { label: 'type-aware code quality', args: ['--type-aware', '--config', 'config/oxlint-code-quality-type-aware.json']