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 1/2] 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 From 37feb0da818734dd5e0d1c61b5e41aa7cf6c6548 Mon Sep 17 00:00:00 2001 From: liurenfeng94-ops <279598012+liurenfeng94-ops@users.noreply.github.com> Date: Tue, 28 Jul 2026 20:34:43 +0800 Subject: [PATCH 2/2] Label trace diagnostics explicitly --- src/traceDiagnosticMessage.ts | 26 ++++++++++++++++++++++++++ src/traceDiagnostics.ts | 23 +++++++++++------------ test/trace-diagnostic-message.test.ts | 18 ++++++++++++++++++ 3 files changed, 55 insertions(+), 12 deletions(-) create mode 100644 src/traceDiagnosticMessage.ts create mode 100644 test/trace-diagnostic-message.test.ts diff --git a/src/traceDiagnosticMessage.ts b/src/traceDiagnosticMessage.ts new file mode 100644 index 0000000..bb361f7 --- /dev/null +++ b/src/traceDiagnosticMessage.ts @@ -0,0 +1,26 @@ +import type { FileStat } from '../shared/src/messages' + +type TraceDiagnosticStats = Pick + +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() +} diff --git a/src/traceDiagnostics.ts b/src/traceDiagnostics.ts index 48e5601..f7ea845 100644 --- a/src/traceDiagnostics.ts +++ b/src/traceDiagnostics.ts @@ -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 @@ -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) } @@ -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) { @@ -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 diff --git a/test/trace-diagnostic-message.test.ts b/test/trace-diagnostic-message.test.ts new file mode 100644 index 0000000..6c331be --- /dev/null +++ b/test/trace-diagnostic-message.test.ts @@ -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('%') + }) +})