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
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
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>
7 changes: 7 additions & 0 deletions ui/src/appState.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { TypeLine } from '../../shared/src/traceData'
import type { TraceSuggestion } from '../../shared/src/messages'
import type { Tree } from '../../src/traceTree'
import * as Messages from '../../shared/src/messages'

Expand All @@ -10,6 +11,7 @@ export const projectName = ref('')
export const saveName = ref('default')
export const saveNames = ref(['default'] as string[])
export const projectNames = ref([] as string[])
export const traceSuggestions = ref([] as TraceSuggestion[])

export const files = ref([] as { fileName: string, dirName: string }[])
export const traceRunning = ref(false)
Expand Down Expand Up @@ -86,12 +88,17 @@ function handleMessage(e: MessageEvent<unknown>) {
if (data.resetFileList) {
files.value = []
nodes.value = []
traceSuggestions.value = []
}
if (parsed.data.fileName && !files.value.some(x => x.fileName === data.fileName && x.dirName === data.dirName))
files.value.push(parsed.data)
break
}

case 'traceSuggestions':
traceSuggestions.value = parsed.data.suggestions
break

case 'traceStart': {
traceRunning.value = true
break
Expand Down