From e73491f2862ceb4faa226ab470f041eaf0ebe8b4 Mon Sep 17 00:00:00 2001 From: liurenfeng94-ops <279598012+liurenfeng94-ops@users.noreply.github.com> Date: Tue, 28 Jul 2026 20:28:27 +0800 Subject: [PATCH] Add trace analysis suggestions --- shared/src/messages.ts | 19 +++++++ src/traceSuggestions.ts | 90 ++++++++++++++++++++++++++++++ src/traceTree.ts | 2 + test/trace-suggestions.test.ts | 67 ++++++++++++++++++++++ ui/app.vue | 5 +- ui/components/TraceSuggestions.vue | 52 +++++++++++++++++ ui/src/appState.ts | 7 +++ 7 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 src/traceSuggestions.ts create mode 100644 test/trace-suggestions.test.ts create mode 100644 ui/components/TraceSuggestions.vue diff --git a/shared/src/messages.ts b/shared/src/messages.ts index 70c04e7..472eb17 100644 --- a/shared/src/messages.ts +++ b/shared/src/messages.ts @@ -72,6 +72,18 @@ export const fileStats = z.object({ }) export type FileStats = z.infer +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 + export const traceStart = z.object({ message: z.literal('traceStart'), }) @@ -156,6 +168,12 @@ export const typesById = z.object({ }) export type TypesById = z.infer +export const traceSuggestions = z.object({ + message: z.literal('traceSuggestions'), + suggestions: z.array(traceSuggestion), +}) +export type TraceSuggestions = z.infer + export type Message = z.infer export const message = z.union([ ping, @@ -176,6 +194,7 @@ export const message = z.union([ traceStart, traceStop, traceFileLoaded, + traceSuggestions, typesById, log, ]) diff --git a/src/traceSuggestions.ts b/src/traceSuggestions.ts new file mode 100644 index 0000000..2076b70 --- /dev/null +++ b/src/traceSuggestions.ts @@ -0,0 +1,90 @@ +import type { TraceSuggestion } from '../shared/src/messages' +import type { Tree } from './traceTree' + +function pushSuggestion(suggestions: TraceSuggestion[], seen: Set, 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() + + 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) : [] + + 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 +} diff --git a/src/traceTree.ts b/src/traceTree.ts index 0618d33..f5905e2 100644 --- a/src/traceTree.ts +++ b/src/traceTree.ts @@ -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 { @@ -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[] { diff --git a/test/trace-suggestions.test.ts b/test/trace-suggestions.test.ts new file mode 100644 index 0000000..1af3e17 --- /dev/null +++ b/test/trace-suggestions.test.ts @@ -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 + 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) + }) +}) diff --git a/ui/app.vue b/ui/app.vue index bbd026b..0cbd8a3 100644 --- a/ui/app.vue +++ b/ui/app.vue @@ -1,5 +1,5 @@ + + diff --git a/ui/src/appState.ts b/ui/src/appState.ts index 2ca7851..f0961af 100644 --- a/ui/src/appState.ts +++ b/ui/src/appState.ts @@ -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' @@ -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) @@ -86,12 +88,17 @@ function handleMessage(e: MessageEvent) { 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