Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions shared/src/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,18 @@ export const fileStats = z.object({
})
export type FileStats = z.infer<typeof fileStats>

export const traceSuggestion = z.object({
kind: z.string(),
title: z.string(),
detail: z.string(),
severity: z.enum(['info', 'warning', 'error']),
fileName: z.string().optional(),
pos: z.number().optional(),
traceName: z.string().optional(),
nodeId: z.number().optional(),
})
export type TraceSuggestion = z.infer<typeof traceSuggestion>

export const traceStart = z.object({
message: z.literal('traceStart'),
})
Expand Down Expand Up @@ -156,6 +168,12 @@ export const typesById = z.object({
})
export type TypesById = z.infer<typeof typesById>

export const traceSuggestions = z.object({
message: z.literal('traceSuggestions'),
suggestions: z.array(traceSuggestion),
})
export type TraceSuggestions = z.infer<typeof traceSuggestions>

export type Message = z.infer<typeof message>
export const message = z.union([
ping,
Expand All @@ -176,6 +194,7 @@ export const message = z.union([
traceStart,
traceStop,
traceFileLoaded,
traceSuggestions,
typesById,
log,
])
Expand Down
26 changes: 26 additions & 0 deletions src/traceDiagnosticMessage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { FileStat } from '../shared/src/messages'

type TraceDiagnosticStats = Pick<FileStat, 'dur' | 'types' | 'totalTypes'>

function relativeString(value: number) {
return `(${value > 0 ? '+' : ''}${Math.round(10000 * value) / 100}%)`
}

export function formatTraceDiagnosticMessage({ dur, types, totalTypes }: TraceDiagnosticStats) {
const typeStr = types || totalTypes ? ` Types: ${types} / ${totalTypes}` : ''
return `Trace check ms: ${Math.round(dur) / 1000} ${typeStr}`.trim()
}

export function formatRelativeTraceDiagnosticMessage({ dur, types, totalTypes }: TraceDiagnosticStats, averages: { dur: number, types: number, totalTypes: number }) {
const relative = {
dur: dur / (averages.dur || 1),
types: types / (averages.types || 1),
totalTypes: totalTypes / (averages.totalTypes || 1),
}

const typeStr = types || totalTypes
? ` Types: ${types} / ${totalTypes} ${relativeString(relative.types)} / ${relativeString(relative.totalTypes)}`
: ''

return `Trace check ms: ${Math.round(dur) / 1000} ${relativeString(relative.dur)} ${typeStr}`.trim()
}
23 changes: 11 additions & 12 deletions src/traceDiagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as vscode from 'vscode'
import type { FileStat } from '../shared/src/messages'
import { getStatsFromTree } from './traceTree'
import { afterConfigUpdate, getCurrentConfig } from './configuration'
import { formatRelativeTraceDiagnosticMessage, formatTraceDiagnosticMessage } from './traceDiagnosticMessage'

let diagnosticCollection: vscode.DiagnosticCollection

Expand Down Expand Up @@ -81,10 +82,6 @@ export async function addTraceDiagnostics(fileName: string, stats: FileStat[]) {
diagnosticCollection.set(uri, diagnostics)
}

function relativeString(value: number) {
return `(${value > 0 ? '+' : ''}${Math.round(10000 * value) / 100}%)`
}

function relativeValue(value: number, average: number, averageThreshold: number) {
return value / (average || averageThreshold || 1)
}
Expand All @@ -99,13 +96,14 @@ function fileStatToRelativeDiagnostic({ pos, dur, types, totalTypes }: FileStat,
if (severity > vscode.DiagnosticSeverity.Information)
return

const typeStr = types || totalTypes ? ` Types: ${types} / ${totalTypes} ${relativeString(relative.types)} / ${relativeString(relative.totalTypes)}` : ''

const msg = `Check ms: ${Math.round(dur) / 1000} ${relativeString(relative.dur)} ${typeStr}`
const msg = formatRelativeTraceDiagnosticMessage({ dur, types, totalTypes }, averages)
const startPos = document.positionAt(pos + 1)
const range = new vscode.Range(startPos, startPos)

return new vscode.Diagnostic(range, msg, severity)
const diagnostic = new vscode.Diagnostic(range, msg, severity)
diagnostic.source = 'trace'
diagnostic.code = 'trace-metric'
return diagnostic
}

function fileStatToDiagnostic({ pos, dur, types, totalTypes }: FileStat, document: vscode.TextDocument) {
Expand All @@ -116,13 +114,14 @@ function fileStatToDiagnostic({ pos, dur, types, totalTypes }: FileStat, documen
if (severity > vscode.DiagnosticSeverity.Information)
return

const typeStr = types || totalTypes ? ` Types: ${types} / ${totalTypes}` : ''

const msg = `Check ms: ${Math.round(dur) / 1000} ${typeStr}`
const msg = formatTraceDiagnosticMessage({ dur, types, totalTypes })
const startPos = document.positionAt(pos + 1)
const range = new vscode.Range(startPos, startPos)

return new vscode.Diagnostic(range, msg, severity)
const diagnostic = new vscode.Diagnostic(range, msg, severity)
diagnostic.source = 'trace'
diagnostic.code = 'trace-metric'
return diagnostic
}

// yes, I should be using the vscode.DiagnosticSeverity but that's much more painful and they are unlikely to change
Expand Down
90 changes: 90 additions & 0 deletions src/traceSuggestions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import type { TraceSuggestion } from '../shared/src/messages'
import type { Tree } from './traceTree'

function pushSuggestion(suggestions: TraceSuggestion[], seen: Set<string>, suggestion: TraceSuggestion) {
const key = `${suggestion.kind}:${suggestion.fileName ?? ''}:${suggestion.pos ?? -1}:${suggestion.title}`
if (seen.has(key))
return
seen.add(key)
suggestions.push(suggestion)
}

function sourceLocation(node: Tree) {
return {
fileName: node.line.args?.path,
pos: node.line.args?.pos,
}
}

export function getTraceSuggestions(tree: Tree): TraceSuggestion[] {
const suggestions: TraceSuggestion[] = []
const seen = new Set<string>()

function add(node: Tree, kind: string, severity: TraceSuggestion['severity'], title: string, detail: string) {
const { fileName, pos } = sourceLocation(node)
pushSuggestion(suggestions, seen, {
kind,
severity,
title,
detail,
fileName,
pos,
traceName: node.line.name,
nodeId: node.id,
})
}

function visit(node: Tree) {
if (node.id !== 0) {
const name = node.line.name
const dur = node.line.dur ?? 0
const totalTypes = node.typeCnt + node.childTypeCnt
const argKeys = node.line.args ? Object.keys(node.line.args as Record<string, unknown>) : []

if (name.includes('DepthLimit')) {
add(
node,
'depth-limit',
'warning',
'TypeScript hit a depth limit',
'This trace shows a depth-limit event. The type graph likely contains recursion or a self-referential alias; simplify the recursive edge or break the cycle if possible.',
)
}

if ((name === 'structuredTypeRelatedTo' || name === 'typeArgumentsRelatedTo' || name === 'isRelatedTo') && dur >= 10_000) {
add(
node,
'expensive-relation',
'warning',
'Expensive type relation check',
'This relation took a long time. If the value does not need contextual typing, try simplifying the union or replacing the contextual position with `satisfies`.',
)
}

if (dur >= 50_000 && totalTypes >= 50) {
add(
node,
'hot-file',
'info',
'This location is a hot spot',
'The trace shows high duration with lots of type work nearby. Try reducing generic fan-out, splitting a large type alias, or narrowing the local expression.',
)
}

if (name.startsWith('check') && totalTypes >= 100 && argKeys.includes('path')) {
add(
node,
'dense-check',
'info',
'This file is driving lots of type work',
'A file-level check produced a lot of type activity. Consider whether the file can be split, simplified, or given a narrower public type surface.',
)
}
}

node.children.forEach(visit)
}

visit(tree)
return suggestions
}
2 changes: 2 additions & 0 deletions src/traceTree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { TraceData, TraceLine, TypeLine } from '../shared/src/traceData'
import { getWorkspacePath } from './storage'
import { postMessage } from './webview'
import { traceFiles } from './appState'
import { getTraceSuggestions } from './traceSuggestions'

export interface Tree { id: number, line: TraceLine, children: Tree[], types: TypeLine[], childCnt: number, childTypeCnt: number, typeCnt: number }
function getRoot(): Tree {
Expand Down Expand Up @@ -78,6 +79,7 @@ let traceTree: Tree | undefined
export async function processTraceFiles() {
const workspacePath = await getWorkspacePath()
traceTree = toTree(Object.values(traceFiles.value).flat(1), workspacePath)
postMessage({ message: 'traceSuggestions', suggestions: getTraceSuggestions(traceTree) })
}

export function filterTree(startsWith: string, sourceFileName: string, position: number | '', tree = traceTree): Tree[] {
Expand Down
18 changes: 18 additions & 0 deletions test/trace-diagnostic-message.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { formatRelativeTraceDiagnosticMessage, formatTraceDiagnosticMessage } from '../src/traceDiagnosticMessage'

describe('trace diagnostic messages', () => {
it('labels trace metrics clearly', () => {
expect(formatTraceDiagnosticMessage({ dur: 12345, types: 2, totalTypes: 7 })).toContain('Trace check ms:')
})

it('labels relative trace metrics clearly', () => {
const msg = formatRelativeTraceDiagnosticMessage(
{ dur: 1000, types: 3, totalTypes: 8 },
{ dur: 500, types: 2, totalTypes: 4 },
)

expect(msg).toContain('Trace check ms:')
expect(msg).toContain('%')
})
})
67 changes: 67 additions & 0 deletions test/trace-suggestions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest'
import { getTraceSuggestions } from '../src/traceSuggestions'
import type { Tree } from '../src/traceTree'

function makeNode(overrides: {
id?: number
name: string
line?: Partial<Tree['line']>
children?: Tree[]
types?: Tree['types']
childCnt?: number
childTypeCnt?: number
typeCnt?: number
}): Tree {
return {
id: overrides.id ?? 1,
line: {
pid: 1,
tid: 1,
ph: 'X',
cat: overrides.line?.cat ?? 'checkTypes',
ts: overrides.line?.ts ?? 0,
name: overrides.name,
dur: overrides.line?.dur,
args: overrides.line?.args,
},
children: overrides.children ?? [],
types: overrides.types ?? [],
childCnt: overrides.childCnt ?? 0,
childTypeCnt: overrides.childTypeCnt ?? 0,
typeCnt: overrides.typeCnt ?? 0,
}
}

describe('getTraceSuggestions', () => {
it('suggests simplifying depth-limit traces', () => {
const tree = makeNode({
name: 'check_DepthLimit',
line: {
dur: 12_000,
args: { path: 'src/foo.ts', pos: 9 },
},
})

const suggestions = getTraceSuggestions(tree)

expect(suggestions.some(suggestion => suggestion.kind === 'depth-limit')).toBe(true)
expect(suggestions.some(suggestion => suggestion.fileName === 'src/foo.ts')).toBe(true)
})

it('suggests simplifying expensive structured type comparisons', () => {
const tree = makeNode({
name: 'structuredTypeRelatedTo',
line: {
dur: 20_000,
args: { path: 'src/bar.ts', pos: 17 },
},
typeCnt: 12,
childTypeCnt: 50,
})

const suggestions = getTraceSuggestions(tree)

expect(suggestions.some(suggestion => suggestion.kind === 'expensive-relation')).toBe(true)
expect(suggestions.some(suggestion => suggestion.title.includes('Expensive type relation check'))).toBe(true)
})
})
5 changes: 4 additions & 1 deletion ui/app.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { sortBy } from './src/appState'
import { sortBy, traceSuggestions } from './src/appState'

const Messages = useNuxtApp().$Messages

Expand Down Expand Up @@ -75,6 +75,9 @@ onMounted(() => {
</div>
</div>
<hr class="m-2">
<div class="px-2 pb-2">
<TraceSuggestions :suggestions="traceSuggestions" />
</div>
<div>
<tree-root />
</div>
Expand Down
52 changes: 52 additions & 0 deletions ui/components/TraceSuggestions.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<script setup lang="ts">
import type { TraceSuggestion } from '../../shared/src/messages'

const props = defineProps<{ suggestions: TraceSuggestion[] }>()

const sendMessage = useNuxtApp().$sendMessage

function gotoSuggestion(suggestion: TraceSuggestion) {
if (!suggestion.fileName || suggestion.pos === undefined)
return

sendMessage('gotoPosition', { fileName: suggestion.fileName, pos: suggestion.pos })
}
</script>

<template>
<UExpand v-if="props.suggestions.length" class="w-full" :expandable="true">
<template #label>
<span class="pl-1">{{ `Suggestions: ${props.suggestions.length}` }}</span>
</template>
<div class="flex flex-col gap-2 rounded border border-dashed border-[var(--vscode-editorWidget-border)] p-2">
<div v-for="suggestion in props.suggestions" :key="`${suggestion.kind}:${suggestion.nodeId}`" class="flex flex-row gap-2 rounded bg-[var(--vscode-editor-background)] px-2 py-1">
<button
v-if="suggestion.fileName && suggestion.pos !== undefined"
class="h-6 rounded bg-[var(--vscode-button-background, green)] px-1 text-[var(--vscode-button-foreground, white)] focus:outline-none focus:ring-1 focus:ring-[var(--vscode-focusBorder, blue)]"
:title="`Go to ${suggestion.fileName}:${suggestion.pos}`"
@click="gotoSuggestion(suggestion)"
>
<UIcon name="i-heroicons-arrow-left-on-rectangle" class="relative top-0.5" />
</button>
<div v-else class="w-6" />

<div class="min-w-0 flex-1">
<div class="flex flex-row items-center gap-2">
<span class="rounded px-1 text-[11px] uppercase tracking-wide opacity-70">
{{ suggestion.severity }}
</span>
<span class="font-medium">
{{ suggestion.title }}
</span>
</div>
<div class="text-xs opacity-80">
{{ suggestion.detail }}
</div>
<div class="text-[11px] opacity-60">
{{ suggestion.traceName }}<span v-if="suggestion.fileName"> · {{ suggestion.fileName }}<span v-if="suggestion.pos !== undefined">:{{ suggestion.pos }}</span></span>
</div>
</div>
</div>
</div>
</UExpand>
</template>
Loading