Enforce TypeScript coverage across repository

This commit is contained in:
Brennan Benson
2026-08-28 03:09:31 -07:00
parent 65dd06a870
commit 3c58b774c6
16 changed files with 4568 additions and 63 deletions
@@ -72,6 +72,10 @@ function failBootstrapWithBanner(options: {
return processMock
}
function invokeScheduledCallback(callback: (() => void) | null): void {
callback?.()
}
describe('Electron Vite output contract', () => {
it('keeps main-process and plain-Node entries at stable CommonJS paths', () => {
const output = electronViteConfig.main?.build?.rollupOptions?.output
@@ -98,12 +102,20 @@ describe('Electron Vite output contract', () => {
expect(external('@xterm/addon-serialize', undefined, false)).toBe(false)
expect(external('psl', undefined, false)).toBe(false)
expect(external('zod', undefined, false)).toBe(false)
expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('psl')
expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('zod')
const externalizeDeps = electronViteConfig.main?.build?.externalizeDeps
if (!externalizeDeps || typeof externalizeDeps === 'boolean') {
throw new Error('Expected main-process externalizeDeps options')
}
expect(externalizeDeps.exclude).toContain('psl')
expect(externalizeDeps.exclude).toContain('zod')
})
it('bundles validation dependencies used by the sandboxed preload', () => {
expect(electronViteConfig.preload?.build?.externalizeDeps?.exclude).toContain('zod')
const externalizeDeps = electronViteConfig.preload?.build?.externalizeDeps
if (!externalizeDeps || typeof externalizeDeps === 'boolean') {
throw new Error('Expected preload externalizeDeps options')
}
expect(externalizeDeps.exclude).toContain('zod')
})
it('exits when a static import fails before source error guards load', () => {
@@ -136,7 +148,7 @@ describe('Electron Vite output contract', () => {
expect(processMock.exitCode).toBe(1)
expect(scheduledExit).not.toBeNull()
scheduledExit?.()
invokeScheduledCallback(scheduledExit)
expect(exitedWith).toBe(1)
expect(context).toHaveProperty(BOOTSTRAP_FATAL_EXIT_GUARD_KEY)
expect(stderrWrites.join('')).toContain("Cannot find module 'zod'")
@@ -33,12 +33,12 @@ let checksum = 0
let expectedChecksum = 0
let validatedCases = 0
let cappedCalibrations = 0
let sessionSink: NativeChatSession | null = null
let messageArraySink: NativeChatMessage[] | null = null
let contentSink = ''
let pendingMatchSink: Set<number> | null = null
let userRowSink: readonly NativeChatUserRow[] | null = null
const benchmarkStartedAt = performance.now()
const getPendingSink = (): Set<number> | null => pendingMatchSink
function proseFixture(count: number, bytes: number, withTurnId: boolean): NativeChatMessage[] {
const payload = 'Ab Cd '.repeat(Math.ceil(bytes / 7)).slice(0, bytes)
@@ -127,7 +127,6 @@ function messageWeight(message: NativeChatMessage, content: string): number {
}
function consumeSession(session: NativeChatSession, index: number): number {
sessionSink = session
messageArraySink = session.messages
const message = session.messages[index % session.messages.length]
if (!message) {
@@ -326,10 +325,9 @@ benchmark(
)
strictEqual(checksum, expectedChecksum, 'benchmark checksum accounting drifted')
strictEqual(sessionSink?.sessionId, 'benchmark', 'session outputs did not escape')
strictEqual(Array.isArray(messageArraySink), true, 'message arrays did not escape')
strictEqual(contentSink.length > 0, true, 'message content did not escape')
strictEqual(pendingMatchSink instanceof Set, true, 'pending baseline scan did not escape')
strictEqual(getPendingSink() instanceof Set, true, 'pending baseline scan did not escape')
strictEqual(Array.isArray(userRowSink), true, 'user row scan did not escape')
console.log(
`validated=${validatedCases} cases, checksum=${checksum}, capped calibrations=${cappedCalibrations}, runtime=${(
+30 -27
View File
@@ -22,17 +22,36 @@ function createOutputDir(): string {
return outputDir
}
function outputChunk(
name: string,
code: string,
imports: string[] = [],
isEntry = true
): Rollup.OutputChunk {
const fileName = `${name}.js`
// Rolldown brands chunks with a runtime-only symbol that fixture objects cannot construct.
return {
type: 'chunk',
code,
dynamicImports: [],
exports: [],
facadeModuleId: null,
fileName,
imports,
isDynamicEntry: false,
isEntry,
map: null,
modules: {},
moduleIds: [],
name,
preliminaryFileName: fileName,
sourcemapFileName: null
} as unknown as Rollup.OutputChunk
}
function createBundle(code = ''): Rollup.OutputBundle {
return {
'daemon-entry.js': {
type: 'chunk',
code,
dynamicImports: [],
fileName: 'daemon-entry.js',
imports: [],
isEntry: true,
name: 'daemon-entry'
} as Rollup.OutputChunk
'daemon-entry.js': outputChunk('daemon-entry', code)
}
}
@@ -172,15 +191,7 @@ describe('worker thread entry guard', () => {
}
function workerChunk(name: string, code: string, imports: string[] = []): Rollup.OutputChunk {
return {
type: 'chunk',
code,
dynamicImports: [],
fileName: `${name}.js`,
imports,
isEntry: true,
name
} as Rollup.OutputChunk
return outputChunk(name, code, imports)
}
it('rejects an Electron require reachable from a worker entry', () => {
@@ -214,15 +225,7 @@ describe('worker thread entry guard', () => {
'require("./chunks/shared.js")',
['chunks/shared.js']
),
'chunks/shared.js': {
type: 'chunk',
code: 'require("electron")',
dynamicImports: [],
fileName: 'chunks/shared.js',
imports: [],
isEntry: false,
name: 'shared'
} as Rollup.OutputChunk
'chunks/shared.js': outputChunk('chunks/shared', 'require("electron")', [], false)
} as Rollup.OutputBundle
expect(() => runWorkerWriteBundle(plugin, bundle)).toThrow('chunks/shared.js')
@@ -2,18 +2,45 @@ import { spawn } from 'node:child_process'
import { availableParallelism } from 'node:os'
import { fileURLToPath } from 'node:url'
// The three projects overlap heavily in src/shared but have no build dependency on
// each other, so tsc can check them concurrently instead of in a `&&` chain.
const projects = ['tsconfig.node.json', 'tsconfig.tc.cli.json', 'tsconfig.tc.web.json']
const repoRoot = fileURLToPath(new URL('../..', import.meta.url))
const tsc = fileURLToPath(new URL('../../node_modules/typescript/bin/tsc', import.meta.url))
// Why serialize on a single-core runner: three tsc processes there thrash rather than overlap.
const concurrent = availableParallelism() > 1
// The projects overlap but have no build dependency, so they can check concurrently.
const projects = [
{
name: 'tsconfig.node.json',
args: [tsc, '--noEmit', '-p', 'config/tsconfig.node.json']
},
{
name: 'tsconfig.tc.cli.json',
args: [tsc, '--noEmit', '-p', 'config/tsconfig.tc.cli.json']
},
{
name: 'tsconfig.tc.web.json',
args: [tsc, '--noEmit', '-p', 'config/tsconfig.tc.web.json']
},
{
name: 'tsconfig.tooling.json',
args: [tsc, '--noEmit', '-p', 'config/tsconfig.tooling.json']
},
{
name: 'tsconfig.e2e.json',
args: [
'config/scripts/typecheck-diagnostic-baseline.mjs',
'--project',
'config/tsconfig.e2e.json',
'--baseline',
'config/typecheck-e2e-diagnostics.json'
]
}
]
// Keep the original three-process ceiling; each additional project holds a full compiler graph.
const maxConcurrent = Math.min(availableParallelism(), 3)
function checkProject(project) {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [tsc, '--noEmit', '-p', `config/${project}`], {
const child = spawn(process.execPath, project.args, {
cwd: repoRoot,
stdio: 'inherit'
})
@@ -21,9 +48,9 @@ function checkProject(project) {
child.on('error', reject)
child.on('exit', (code, signal) => {
if (signal) {
reject(new Error(`tsc ${project} exited with signal ${signal}`))
reject(new Error(`${project.name} exited with signal ${signal}`))
} else if (code !== 0) {
reject(new Error(`tsc ${project} exited with code ${code}`))
reject(new Error(`${project.name} exited with code ${code}`))
} else {
resolve()
}
@@ -31,12 +58,12 @@ function checkProject(project) {
})
}
let failures = []
if (concurrent) {
const results = await Promise.allSettled(projects.map(checkProject))
failures = results.filter((result) => result.status === 'rejected').map((result) => result.reason)
} else {
for (const project of projects) {
const failures = []
let nextProject = 0
async function runProjectWorker() {
while (nextProject < projects.length) {
const project = projects[nextProject]
nextProject += 1
try {
await checkProject(project)
} catch (error) {
@@ -44,6 +71,9 @@ if (concurrent) {
}
}
}
await Promise.all(
Array.from({ length: Math.min(maxConcurrent, projects.length) }, runProjectWorker)
)
if (failures.length > 0) {
for (const failure of failures) {
@@ -0,0 +1,170 @@
import { spawnSync } from 'node:child_process'
import { readFile, writeFile } from 'node:fs/promises'
import { relative, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const repoRoot = fileURLToPath(new URL('../..', import.meta.url))
function parseArgs(argv) {
const options = { write: false }
for (let index = 0; index < argv.length; index += 1) {
const value = argv[index]
if (value === '--write') {
options.write = true
continue
}
const next = argv[index + 1]
if (!next) {
throw new Error(`Missing value for ${value}`)
}
if (value === '--project') {
options.project = next
} else if (value === '--baseline') {
options.baseline = next
} else if (value === '--tsc') {
options.tsc = next
} else {
throw new Error(`Unknown option: ${value}`)
}
index += 1
}
if (!options.project || !options.baseline) {
throw new Error('Usage: --project <tsconfig> --baseline <json> [--tsc <binary>] [--write]')
}
return options
}
function sortDiagnostics(diagnostics) {
return diagnostics.sort((left, right) =>
JSON.stringify(left).localeCompare(JSON.stringify(right), 'en')
)
}
function parseDiagnostics(output) {
const diagnostics = []
let current = null
for (const line of output.split(/\r?\n/)) {
const match = line.match(/^(.*?)\((\d+),(\d+)\): error TS(\d+): (.*)$/)
if (match) {
current = {
file: match[1].replaceAll('\\', '/'),
line: Number(match[2]),
column: Number(match[3]),
code: Number(match[4]),
message: match[5]
}
diagnostics.push(current)
continue
}
const configMatch = line.match(/^error TS(\d+): (.*)$/)
if (configMatch) {
current = {
file: null,
line: null,
column: null,
code: Number(configMatch[1]),
message: configMatch[2]
}
diagnostics.push(current)
continue
}
if (current && line.trim().length > 0) {
current.message += `\n${line.trimEnd()}`
}
}
return sortDiagnostics(diagnostics)
}
function collectDiagnostics(projectPath, tscPath) {
const result = spawnSync(
process.execPath,
[tscPath, '--pretty', 'false', '--noEmit', '-p', projectPath],
{
cwd: repoRoot,
encoding: 'utf8',
maxBuffer: 64 * 1024 * 1024
}
)
if (result.error) {
throw result.error
}
if (result.signal) {
throw new Error(`TypeScript exited with signal ${result.signal}`)
}
const output = `${result.stdout ?? ''}${result.stderr ?? ''}`
const diagnostics = parseDiagnostics(output)
if (result.status !== 0 && diagnostics.length === 0) {
throw new Error(`TypeScript exited ${result.status} without parseable diagnostics:\n${output}`)
}
return diagnostics
}
function diagnosticCounts(diagnostics) {
const counts = new Map()
for (const diagnostic of diagnostics) {
const key = JSON.stringify(diagnostic)
counts.set(key, (counts.get(key) ?? 0) + 1)
}
return counts
}
function subtractDiagnostics(left, right) {
const remaining = diagnosticCounts(right)
return left.filter((diagnostic) => {
const key = JSON.stringify(diagnostic)
const count = remaining.get(key) ?? 0
if (count === 0) {
return true
}
remaining.set(key, count - 1)
return false
})
}
function formatDiagnostic(diagnostic) {
const location = diagnostic.file
? `${diagnostic.file}:${diagnostic.line}:${diagnostic.column}`
: '<config>'
return `${location} TS${diagnostic.code}: ${diagnostic.message}`
}
async function main() {
const options = parseArgs(process.argv.slice(2))
const projectPath = resolve(repoRoot, options.project)
const baselinePath = resolve(repoRoot, options.baseline)
const tscPath = resolve(repoRoot, options.tsc ?? 'node_modules/typescript/bin/tsc')
const diagnostics = collectDiagnostics(projectPath, tscPath)
if (options.write) {
const baseline = { version: 1, diagnostics }
await writeFile(baselinePath, `${JSON.stringify(baseline, null, 2)}\n`)
console.log(`Wrote ${diagnostics.length} diagnostics to ${relative(repoRoot, baselinePath)}`)
return
}
const baseline = JSON.parse(await readFile(baselinePath, 'utf8'))
if (baseline.version !== 1 || !Array.isArray(baseline.diagnostics)) {
throw new Error(`Invalid diagnostic baseline: ${relative(repoRoot, baselinePath)}`)
}
const unexpected = subtractDiagnostics(diagnostics, baseline.diagnostics)
const stale = subtractDiagnostics(baseline.diagnostics, diagnostics)
if (unexpected.length === 0 && stale.length === 0) {
console.log(`${options.project}: ${diagnostics.length} known diagnostics, no drift`)
return
}
if (unexpected.length > 0) {
console.error(`Unexpected diagnostics (${unexpected.length}):`)
for (const diagnostic of unexpected) {
console.error(formatDiagnostic(diagnostic))
}
}
if (stale.length > 0) {
console.error(`Stale allowlist entries (${stale.length}); remove fixed diagnostics:`)
for (const diagnostic of stale) {
console.error(formatDiagnostic(diagnostic))
}
}
process.exitCode = 1
}
await main()
@@ -0,0 +1,64 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
const repoRoot = fileURLToPath(new URL('../..', import.meta.url))
const script = join(repoRoot, 'config/scripts/typecheck-diagnostic-baseline.mjs')
const tsc = join(repoRoot, 'node_modules/typescript/bin/tsc')
let fixtureDir
afterEach(() => {
if (fixtureDir) {
rmSync(fixtureDir, { recursive: true, force: true })
}
fixtureDir = undefined
})
function createFixture(source) {
fixtureDir = mkdtempSync(join(tmpdir(), 'orca-typecheck-baseline-'))
writeFileSync(
join(fixtureDir, 'tsconfig.json'),
JSON.stringify({ compilerOptions: { strict: true, noEmit: true }, include: ['source.ts'] })
)
writeFileSync(join(fixtureDir, 'source.ts'), source)
return {
baseline: join(fixtureDir, 'baseline.json'),
project: join(fixtureDir, 'tsconfig.json'),
source: join(fixtureDir, 'source.ts')
}
}
function runBaseline(paths, extraArgs = []) {
return spawnSync(
process.execPath,
[script, '--project', paths.project, '--baseline', paths.baseline, '--tsc', tsc, ...extraArgs],
{ cwd: repoRoot, encoding: 'utf8' }
)
}
describe('typecheck diagnostic baseline', () => {
it('accepts only the exact recorded diagnostics', () => {
const paths = createFixture("const value: number = 'bad'\n")
expect(runBaseline(paths, ['--write']).status).toBe(0)
expect(JSON.parse(readFileSync(paths.baseline, 'utf8')).diagnostics).toHaveLength(1)
expect(runBaseline(paths).status).toBe(0)
writeFileSync(paths.source, 'const value: number = 1\n')
const stale = runBaseline(paths)
expect(stale.status).toBe(1)
expect(stale.stderr).toContain('Stale allowlist entries (1)')
})
it('rejects a diagnostic added after a clean baseline', () => {
const paths = createFixture('const value: number = 1\n')
expect(runBaseline(paths, ['--write']).status).toBe(0)
writeFileSync(paths.source, "const value: number = 'bad'\n")
const unexpected = runBaseline(paths)
expect(unexpected.status).toBe(1)
expect(unexpected.stderr).toContain('Unexpected diagnostics (1)')
})
})
+4 -6
View File
@@ -1,10 +1,6 @@
{
// NOT ENFORCED YET, and deliberately so: tests/ has never been typechecked, so this currently
// reports ~198 errors across ~94 files. Wiring it into `typecheck` or CI today would just paint
// the whole build red. It earns its place anyway — pointing it at the SSH reconnect spec found
// two real bugs a passing suite had hidden (a targetId read off the wrong type, and a required
// argument omitted), because that spec had never actually executed. Run `pnpm typecheck:e2e`,
// fix a file, repeat; when the count reaches zero, add it to the `typecheck` chain.
// Enforced by `pnpm typecheck`; the exact pre-existing diagnostics are ratcheted in
// typecheck-e2e-diagnostics.json so new errors fail while the backlog shrinks.
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
// Why: specs import renderer and main modules directly, so the ambient declarations those
// modules rely on (vite client types, build-time defines) have to be in the program too.
@@ -16,6 +12,8 @@
],
"exclude": ["../tests/e2e/.cross-version-checkouts/**"],
"compilerOptions": {
"incremental": true,
"tsBuildInfoFile": "./tsconfig.e2e.tsbuildinfo",
// Why: specs run browser code inside page.evaluate, so the DOM lib has to be visible here
// even though the harness itself is Node.
"lib": ["ESNext", "DOM", "DOM.Iterable"],
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
"include": ["../config/**/*.ts", "../*.ts", "../src/types/**/*"],
"compilerOptions": {
"incremental": true,
"tsBuildInfoFile": "./tsconfig.tooling.tsbuildinfo",
"paths": {
"@renderer/*": ["../src/renderer/src/*"],
"@/*": ["../src/renderer/src/*"]
},
"types": ["electron-vite/node"]
}
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -9,7 +9,7 @@
"ios": "expo run:ios",
"postinstall": "node scripts/build-terminal-webview-engine.mjs && node scripts/build-mermaid-webview-engine.mjs",
"test": "vitest run",
"typecheck": "tsc --noEmit",
"typecheck": "node ../config/scripts/typecheck-diagnostic-baseline.mjs --project mobile/tsconfig.json --baseline mobile/typecheck-test-diagnostics.json --tsc mobile/node_modules/typescript/bin/tsc",
"lint": "oxlint",
"format": "oxfmt --write .",
"format:check": "oxfmt --check .",
@@ -1,4 +1,5 @@
import { createElement } from 'react'
import { Text } from 'react-native'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ConnectionVerdict } from '../transport/connection-health'
@@ -36,7 +37,7 @@ const loaded: HostWorktreeInfo = {
countsProvenAt: Date.now()
}
describe('MobileHostCard', () => {
describe('MobileHostCard actions', () => {
let renderer: ReactTestRenderer | null = null
afterEach(() => {
@@ -69,7 +70,7 @@ describe('MobileHostCard', () => {
)
})
return renderer!.root
.findAllByType('Text')
.findAllByType(Text)
.flatMap((node) => node.children.filter((child) => typeof child === 'string'))
}
+1 -2
View File
@@ -13,6 +13,5 @@
"zod": ["./node_modules/zod"]
}
},
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["**/*.test.ts", "**/*.test.tsx"]
"include": ["**/*.ts", "**/*.tsx"]
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -48,7 +48,7 @@
"typecheck:node": "tsc --noEmit -p config/tsconfig.node.json",
"typecheck:cli": "tsc --noEmit -p config/tsconfig.tc.cli.json",
"typecheck:web": "tsc --noEmit -p config/tsconfig.tc.web.json",
"typecheck:e2e": "tsc --noEmit -p config/tsconfig.e2e.json",
"typecheck:e2e": "node config/scripts/typecheck-diagnostic-baseline.mjs --project config/tsconfig.e2e.json --baseline config/typecheck-e2e-diagnostics.json",
"typecheck": "node config/scripts/run-typecheck-projects-in-parallel.mjs",
"typecheck:tsc:node": "tsc --noEmit -p config/tsconfig.node.json --composite false",
"typecheck:tsc:cli": "tsc --noEmit -p config/tsconfig.cli.json --composite false",
@@ -37,7 +37,7 @@ function gitUsernamePrefixSettings(): GlobalSettings {
}
}
describe('GitPane branch prefix feedback', () => {
describe('GitPane branch-prefix feedback', () => {
it('previews the resulting branch name and drops a redundant trailing slash', () => {
const html = renderGitPane(customPrefixSettings('team/'))
expect(html).toContain('team/feature')
@@ -35,7 +35,7 @@ afterEach(() => {
document.body.replaceChildren()
})
describe('useWorktreeListScrollToTop', () => {
describe('useWorktreeListScrollToTop pointer drag', () => {
it('ignores fast programmatic scrolling', () => {
const element = createScroller()
let now = 0