diff --git a/README.md b/README.md index 5e2b298..4e1329e 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,17 @@ To avoid the overhead of launching a separate tsserver, it currently queries the The `Tracer: tsc trace` command can be run to gather accurate timings. As with real-time metrics, these create diagnostics in the editor open files. It also opens an interface to browse trace files. Editor and UI commands enable navigating between locations in the editor and the trace. -If the trace is run with a version of `tsc` that includes timestamps in `types.json`, type count metrics are also displayed. A PR to include these timestamps or tooling to automatically patch them in are works in progress. See https://github.com/typeholes/TypeScript/tree/trace-data-5-4 for a version you can build yourself. +Each trace run writes a `metrics.json` file next to the generated trace files. The artifact records the command, working directory, trace directory, start/end time, wall time, exit code, output summaries, discovered trace JSON files, parse status, and any parsed `--extendedDiagnostics` counters. This makes traces easier to compare across branches and machines without reopening the full trace viewer. + +Type count metrics are displayed when a timestamped `types.json` file is available. Stock TypeScript `types.json` files are also parsed safely, so a normal `npx tsc --generateTrace` run still reports the total type count even when per-span type attribution is unavailable. + +### Focused traces and comparisons + +- `Tracer: Trace current file` runs the configured trace command from the active TypeScript file context and saves the run under a workspace-relative trace name. +- `Tracer: Compare trace metrics` opens two `metrics.json` files and renders a Markdown before/after report for wall time, exit code, trace file coverage, parse status, compiler diagnostics, and output deltas. +- `Tracer: Open trace viewer` remains the main path for inspecting trace trees and jumping from expensive spans back to source. + +For a short reproducible demo path, see [docs/challenge-demo.md](./docs/challenge-demo.md). For artifact examples, see [docs/challenge-evidence.md](./docs/challenge-evidence.md). For challenge review and submission packaging, see [docs/tsperf-submission.md](./docs/tsperf-submission.md). ### Use in mono repos diff --git a/docs/challenge-demo.md b/docs/challenge-demo.md new file mode 100644 index 0000000..2b3d861 --- /dev/null +++ b/docs/challenge-demo.md @@ -0,0 +1,43 @@ +# TSPerf Challenge Demo + +This is a short demo path for showing that Type Complexity Tracer now reports TypeScript type complexity and time-to-load evidence from repeatable trace runs. + +## Setup + +1. Open a TypeScript workspace in VS Code. +2. Install dependencies in this extension repo with `pnpm install`. +3. Run `pnpm build`. +4. Start the extension in an Extension Development Host. + +## Demo Flow + +1. Run `Tracer: tsc trace` against the workspace. +2. Open the generated trace directory and show `metrics.json`. +3. Highlight these fields: + - `wallTimeMs` + - `exitCode` + - `traceJsonFiles` + - `parse.status` + - `extendedDiagnostics.types` + - `extendedDiagnostics.instantiations` + - `extendedDiagnostics.checkTimeMs` + - `extendedDiagnostics.totalTimeMs` +4. Open the trace viewer and inspect the trace tree. +5. Use `Tracer: Trace current file` from a TypeScript source file to create a focused run with a predictable workspace-relative save name. +6. Run a second trace after changing a type-heavy code path. +7. Run `Tracer: Compare trace metrics`, select the baseline and comparison `metrics.json` files, and show the generated Markdown report. + +For example artifact shapes, see [challenge-evidence.md](./challenge-evidence.md). + +## Submission Angle + +The branch demonstrates a complete measurement loop: + +- run a TypeScript compiler trace from VS Code +- record a portable `metrics.json` artifact +- tolerate stock TypeScript trace output +- parse `--extendedDiagnostics` counters +- inspect trace tree details in the UI +- compare before/after runs in a readable report + +That maps to the challenge requirement for a VS Code plugin that shows TypeScript type complexity and load-time cost. diff --git a/docs/challenge-evidence.md b/docs/challenge-evidence.md new file mode 100644 index 0000000..fd43dc8 --- /dev/null +++ b/docs/challenge-evidence.md @@ -0,0 +1,95 @@ +# TSPerf Challenge Evidence + +This page gives reviewers a quick look at the artifacts produced by the challenge branch before they run the extension locally. + +## Metrics Artifact + +Each `Tracer: tsc trace` run writes a `metrics.json` file next to the TypeScript trace output. A representative artifact has this shape: + +```json +{ + "version": 1, + "command": "npx tsc --noEmit --generateTrace .trace/tsperf-main", + "cwd": "/workspace/example-project", + "traceDir": "/workspace/example-project/.trace/tsperf-main", + "startedAt": "2026-05-20T09:42:11.000Z", + "endedAt": "2026-05-20T09:42:14.250Z", + "wallTimeMs": 3250, + "exitCode": 0, + "stdout": { + "bytes": 0, + "truncated": false, + "text": "" + }, + "stderr": { + "bytes": 382, + "truncated": false, + "text": "Files: 42\nTypes: 1400\nInstantiations: 700\nMemory used: 78000K\nCheck time: 0.32s\nTotal time: 1.10s\n" + }, + "extendedDiagnostics": { + "types": 1400, + "instantiations": 700, + "memoryUsedKb": 78000, + "checkTimeMs": 320, + "totalTimeMs": 1100 + }, + "traceJsonFiles": [ + { + "fileName": "trace.json", + "bytes": 183224 + }, + { + "fileName": "types.json", + "bytes": 92130 + } + ], + "parse": { + "status": "ok" + } +} +``` + +The high-signal TSPerf fields are: + +- `wallTimeMs`: end-to-end trace command duration. +- `traceJsonFiles`: whether trace output was actually produced. +- `parse.status`: whether generated JSON can be parsed. +- `extendedDiagnostics.types`: total TypeScript type count. +- `extendedDiagnostics.instantiations`: generic/type instantiation pressure. +- `extendedDiagnostics.checkTimeMs`: checker cost. +- `extendedDiagnostics.totalTimeMs`: total compiler time. + +## Comparison Report + +`Tracer: Compare trace metrics` turns two `metrics.json` files into a Markdown delta report: + +```markdown +# Trace Metrics Comparison + +Exit code: 0 -> 0 +Parse status: ok -> ok + +| Metric | Before | After | Delta | +| --- | ---: | ---: | ---: | +| Wall time ms | 3250 | 2785 | -465 | +| Trace JSON files | 2 | 2 | 0 | +| Types | 1400 | 1100 | -300 | +| Instantiations | 700 | 450 | -250 | +| Memory used KB | 78000 | 69000 | -9000 | +| Parse time ms | 120 | 110 | -10 | +| Bind time ms | 80 | 78 | -2 | +| Check time ms | 320 | 210 | -110 | +| Emit time ms | 0 | 0 | 0 | +| Total time ms | 1100 | 870 | -230 | +``` + +This is the reviewable loop for the challenge: + +1. Run a baseline trace. +2. Change a type-heavy branch, file, or dependency version. +3. Run a comparison trace. +4. Use the generated report to see whether type count, instantiations, memory, check time, and total time improved or regressed. + +## Focused Current-File Trace + +`Tracer: Trace current file` gives reviewers a quicker path for a smaller trace. From a TypeScript editor, the command creates a workspace-relative trace name for the active file and runs the configured trace command in that context. That makes it easier to test a single type-heavy area without manually naming trace directories. diff --git a/docs/tsperf-submission.md b/docs/tsperf-submission.md new file mode 100644 index 0000000..bec7de5 --- /dev/null +++ b/docs/tsperf-submission.md @@ -0,0 +1,66 @@ +# TSPerf Submission Package + +This package summarizes the challenge-ready branch for Algora's TSPerf Type Challenge. + +## Candidate + +- Fork: https://github.com/jianmosier/tracer +- Branch: `codex/tsperf-team-stack` +- Challenge fit: VS Code extension support for measuring TypeScript type complexity and load-time cost from repeatable compiler traces. + +## Reviewer Path + +1. Install dependencies with `pnpm install`. +2. Run validation: + - `pnpm lint` + - `pnpm typecheck` + - `pnpm build` + - `pnpm exec vitest run` +3. Start the extension in an Extension Development Host. +4. Open a TypeScript workspace. +5. Run `Tracer: tsc trace`. +6. Inspect the generated `metrics.json` next to the trace output. +7. Run `Tracer: Trace current file` from a TypeScript editor to collect a focused run. +8. Run `Tracer: Compare trace metrics` on two `metrics.json` files and inspect the Markdown delta report. + +## What To Evaluate + +- `metrics.json` records trace command, cwd, trace directory, timestamps, wall time, exit code, output summaries, trace JSON discovery, parse status, and parsed `--extendedDiagnostics` counters. +- `docs/challenge-evidence.md` shows representative `metrics.json` and comparison-report output for quick review. +- Stock TypeScript `types.json` files are handled without requiring tracer-specific timestamp fields. +- Extended diagnostics expose high-signal type-system counters including `types`, `instantiations`, memory, parse time, bind time, check time, emit time, and total time. +- The comparison command turns two trace runs into a compact before/after report suitable for branch, commit, or code-path comparisons. +- The focused current-file trace command gives reviewers a fast way to collect a smaller trace from the active TypeScript source file. + +## Suggested Submission Text + +This branch turns Type Complexity Tracer into a repeatable TSPerf measurement loop inside VS Code. It adds a portable `metrics.json` artifact for each compiler trace, parses stock TypeScript trace/type output, captures `--extendedDiagnostics` counters, and provides a `Tracer: Compare trace metrics` command that renders before/after deltas for wall time, type counts, instantiations, memory, compiler timing, parse status, and output changes. + +The key workflow is: + +1. Run `Tracer: tsc trace`. +2. Review the generated `metrics.json` artifact. +3. Make a type-heavy code change or switch branches. +4. Run another trace. +5. Use `Tracer: Compare trace metrics` to produce a Markdown comparison report. + +This maps directly to the challenge requirement: an open-source MIT VS Code plugin that shows TypeScript type complexity and time-to-load evidence. + +## Validation Evidence + +Record the latest command output in the submission after running validation locally: + +```text +pnpm lint +pnpm typecheck +pnpm build +pnpm exec vitest run +``` + +Optional package smoke test on Node 22: + +```text +PATH="/opt/homebrew/opt/node@22/bin:$PATH" pnpm package +``` + +Node note: packaging can fail on Node 25 because a transitive package reads `SlowBuffer.prototype`; Node 22 packages successfully. diff --git a/package.json b/package.json index ddfb78b..bbabc92 100644 --- a/package.json +++ b/package.json @@ -297,6 +297,16 @@ "command": "tsperf.tracer.runTrace", "category": "Tracer" }, + { + "title": "Trace current file", + "command": "tsperf.tracer.runTraceActiveFile", + "category": "Tracer" + }, + { + "title": "Compare trace metrics", + "command": "tsperf.tracer.compareTraceMetrics", + "category": "Tracer" + }, { "title": "Send Trace to Trace Viewer", "command": "tsperf.tracer.sendTrace", diff --git a/scripts/generate-contributes.ts b/scripts/generate-contributes.ts index 59f33f1..13abf0b 100755 --- a/scripts/generate-contributes.ts +++ b/scripts/generate-contributes.ts @@ -49,6 +49,12 @@ const commandRecord: Record = { explorerContext: 'resourceFilename =~ /./', }, }, + 'tsperf.tracer.runTraceActiveFile': { + title: 'Trace current file', + }, + 'tsperf.tracer.compareTraceMetrics': { + title: 'Compare trace metrics', + }, 'tsperf.tracer.sendTrace': { title: 'Send Trace to Trace Viewer', when: { diff --git a/shared/src/traceData.ts b/shared/src/traceData.ts index 4516821..6795042 100644 --- a/shared/src/traceData.ts +++ b/shared/src/traceData.ts @@ -6,7 +6,7 @@ export const typeLine = z.object({ intrinsicName: z.string().optional(), recursionId: z.number().optional(), flags: z.array(z.string()).optional(), - ts: z.number(), + ts: z.number().optional(), dur: z.number().optional(), display: z.string().optional(), }) @@ -39,3 +39,29 @@ export type DataLine = TraceLine | TypeLine export type TraceData = z.infer export const traceData = z.array(typeLine.or(traceLine)) + +export interface TraceDataSummary { + totalTypes: number + timestampedTypes: number + untimestampedTypes: number + parseWarning?: string +} + +export function hasTypeTimestamp(line: TypeLine): line is TypeLine & { ts: number } { + return typeof line.ts === 'number' +} + +export function getTraceDataSummary(data: TraceData): TraceDataSummary { + const typeLines = data.filter((line): line is TypeLine => 'id' in line) + const timestampedTypes = typeLines.filter(hasTypeTimestamp).length + const untimestampedTypes = typeLines.length - timestampedTypes + + return { + totalTypes: typeLines.length, + timestampedTypes, + untimestampedTypes, + parseWarning: untimestampedTypes > 0 + ? `${untimestampedTypes} type entr${untimestampedTypes === 1 ? 'y is' : 'ies are'} missing timestamps and cannot be attributed to trace spans.` + : undefined, + } +} diff --git a/src/commands.ts b/src/commands.ts index 57b522b..3e689b1 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -4,6 +4,7 @@ import { promisify } from 'node:util' import { spawn } from 'node:child_process' import { createReadStream, existsSync, readdir as readdirC, statSync } from 'node:fs' import * as vscode from 'vscode' +import { compareTraceRunMetrics, formatTraceRunMetricsComparison, readTraceRunMetricsFile } from './traceRunComparison' import { getStatsFromTree, processTraceFiles, showTree, treeIdNodes } from './traceTree' import { getTracePanel, prepareWebView } from './webview' import { getCurrentConfig } from './configuration' @@ -13,6 +14,8 @@ import { addTraceFile, getWorkspacePath, openTerminal, openTraceDirectoryExterna import { addTraceDiagnostics, clearTaceDiagnostics } from './traceDiagnostics' import { setStatusBarState } from './statusBar' import { afterWatches, projectPath, saveName, state, traceFiles, traceRunning } from './appState' +import { TRACE_RUN_METRICS_FILE, buildTraceRunMetrics, discoverTraceJsonFiles, summarizeTraceParse, writeTraceRunMetrics } from './traceRunMetrics' +import { traceSaveNameForFile } from './traceCommand' const readdir = promisify(readdirC) @@ -21,6 +24,8 @@ const commandHandlers: Record< (context: vscode.ExtensionContext) => (...args: any[]) => void > = { 'tsperf.tracer.runTrace': () => (...args: unknown[]) => runTrace(args), + 'tsperf.tracer.runTraceActiveFile': () => () => runTraceActiveFile(), + 'tsperf.tracer.compareTraceMetrics': () => () => compareTraceMetrics(), 'tsperf.tracer.openInBrowser': (context: vscode.ExtensionContext) => () => prepareWebView(context), 'tsperf.tracer.gotoTracePosition': (context: vscode.ExtensionContext) => () => gotoTracePosition(context), 'tsperf.tracer.sendTrace': () => (event: unknown) => { @@ -35,6 +40,58 @@ const commandHandlers: Record< 'tsperf.tracer.openTraceDirExternal': () => () => openTraceDirectoryExternal(), } as const +async function pickMetricsFile(title: string): Promise { + const uris = await vscode.window.showOpenDialog({ + title, + canSelectFiles: true, + canSelectFolders: false, + canSelectMany: false, + filters: { JSON: ['json'] }, + }) + + return uris?.[0] +} + +async function compareTraceMetrics() { + const before = await pickMetricsFile('Select baseline metrics.json') + if (!before) + return + + const after = await pickMetricsFile('Select comparison metrics.json') + if (!after) + return + + try { + const comparison = compareTraceRunMetrics( + await readTraceRunMetricsFile(before.fsPath), + await readTraceRunMetricsFile(after.fsPath), + ) + const document = await vscode.workspace.openTextDocument({ + language: 'markdown', + content: formatTraceRunMetricsComparison(comparison), + }) + await vscode.window.showTextDocument(document) + } + catch (error) { + vscode.window.showErrorMessage(error instanceof Error ? error.message : `${error}`) + } +} + +async function runTraceActiveFile() { + const editor = vscode.window.activeTextEditor + if (!editor || editor.document.uri.scheme !== 'file') { + vscode.window.showWarningMessage('Open a TypeScript file before running a mini trace') + return + } + + const workspacePath = state.workspacePath.value || getWorkspacePath() + const targetSaveName = traceSaveNameForFile(workspacePath, editor.document.uri.fsPath) + await runTrace([], { + cwd: workspacePath, + saveName: targetSaveName, + }) +} + async function sendTrace(dirName: string, fileName: string) { const fullFileName = join(dirName, fileName) @@ -97,7 +154,7 @@ function gotoTracePosition(context: vscode.ExtensionContext) { showTree('', relativePath, startOffset - (editor.document.getText()[startOffset + 1] === '\n' ? 0 : 1)) } -async function runTrace(args?: unknown[]) { +async function runTrace(args?: unknown[], options?: { cwd?: string, saveName?: string }) { const workspacePath = state.workspacePath.value const { traceCmd } = getCurrentConfig() @@ -114,7 +171,10 @@ async function runTrace(args?: unknown[]) { } } - if (dirName) { + if (options?.saveName) { + saveName.value = options.saveName + } + else if (dirName) { log(`dirName: ${dirName}`) saveName.value = relative(workspacePath, dirName) } @@ -128,13 +188,14 @@ async function runTrace(args?: unknown[]) { return } + const traceCwd = options?.cwd ?? newDirName ?? workspacePath const quotedTraceDir = `'${traceDir}'` // eslint-disable-next-line no-template-curly-in-string - const fullCmd = `(cd '${newDirName ?? workspacePath}'; ${traceCmd.replace('${traceDir}', quotedTraceDir)})` + const fullCmd = `(cd '${traceCwd}'; ${traceCmd.replace('${traceDir}', quotedTraceDir)})` log(fullCmd) - const newProjectPath = newDirName ?? projectPath.value + const newProjectPath = traceCwd || projectPath.value if (!newProjectPath) { vscode.window.showErrorMessage('could not get project path from workspace folders') return @@ -148,21 +209,50 @@ async function runTrace(args?: unknown[]) { setStatusBarState('traceError', false) log(`shell: ${process.env.SHELL}`) + const startedAtMs = Date.now() + const startedAt = new Date(startedAtMs).toISOString() const cmdProcess = spawn(fullCmd, [], { cwd: newProjectPath, shell: process.env.SHELL }) let err = '' + let out = '' cmdProcess.stderr.on('data', data => err += data.toString()) - cmdProcess.stdout.on('data', data => log(data.toString())) + cmdProcess.stdout.on('data', (data) => { + const chunk = data.toString() + out += chunk + log(chunk) + }) + + let metricsWritten = false + async function writeMetricsOnce(exitCode: number | null) { + if (metricsWritten) + return - cmdProcess.on('error', (error) => { + metricsWritten = true + await writeRunMetrics({ + command: fullCmd, + cwd: newProjectPath, + traceDir, + startedAt, + startedAtMs, + exitCode, + stdout: out, + stderr: err, + }) + } + + cmdProcess.on('error', async (error) => { + traceRunning.value = false + setStatusBarState('traceError', true) vscode.window.showErrorMessage(error.message) + await writeMetricsOnce(null) }) cmdProcess.on('exit', async (code) => { log('---- trace stderr -----') log(err) traceRunning.value = false + await writeMetricsOnce(code) if (code) { setStatusBarState('traceError', true) vscode.window.showErrorMessage('error running trace') @@ -174,6 +264,41 @@ async function runTrace(args?: unknown[]) { }) } +async function writeRunMetrics(input: { + command: string + cwd: string + traceDir: string + startedAt: string + startedAtMs: number + exitCode: number | null + stdout: string + stderr: string +}) { + try { + const traceJsonFiles = existsSync(input.traceDir) ? await discoverTraceJsonFiles(input.traceDir) : [] + const parseSummary = existsSync(input.traceDir) + ? await summarizeTraceParse(input.traceDir, traceJsonFiles) + : { status: 'missing' as const, topLevelWarning: 'Trace directory was not created.' } + + await writeTraceRunMetrics(input.traceDir, buildTraceRunMetrics({ + command: input.command, + cwd: input.cwd, + traceDir: input.traceDir, + startedAt: input.startedAt, + endedAt: new Date().toISOString(), + wallTimeMs: Date.now() - input.startedAtMs, + exitCode: input.exitCode, + stdout: input.stdout, + stderr: input.stderr, + traceJsonFiles, + parseSummary, + })) + } + catch (error) { + log(`error writing trace metrics: ${error instanceof Error ? error.message : `${error}`}`) + } +} + export async function sendTraceDir(traceDir: string) { try { if (!existsSync(traceDir)) { @@ -182,6 +307,9 @@ export async function sendTraceDir(traceDir: string) { const fileNames = await readdir(traceDir) for (const fileName of fileNames) { + if (fileName === TRACE_RUN_METRICS_FILE) + continue + sendTrace(traceDir, fileName) } } diff --git a/src/constants.ts b/src/constants.ts index fc97b3b..5a7e1e0 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -25,6 +25,8 @@ export const commandIds = [ 'tsperf.tracer.gotoTracePosition', 'tsperf.tracer.openInBrowser', 'tsperf.tracer.runTrace', + 'tsperf.tracer.runTraceActiveFile', + 'tsperf.tracer.compareTraceMetrics', 'tsperf.tracer.sendTrace', 'tsperf.tracer.openTerminal', 'tsperf.tracer.openTraceDirExternal', diff --git a/src/extendedDiagnostics.ts b/src/extendedDiagnostics.ts new file mode 100644 index 0000000..875f31c --- /dev/null +++ b/src/extendedDiagnostics.ts @@ -0,0 +1,182 @@ +export type ExtendedDiagnosticUnit = 'count' | 'kb' | 'ms' + +export interface ExtendedDiagnosticMetric { + value: number + unit: ExtendedDiagnosticUnit + raw: string +} + +export interface ExtendedDiagnosticsSummary { + files?: number + lines?: { + total?: number + library?: number + definitions?: number + typescript?: number + javascript?: number + json?: number + other?: number + } + identifiers?: number + symbols?: number + types?: number + instantiations?: number + memoryUsedKb?: number + parseTimeMs?: number + bindTimeMs?: number + checkTimeMs?: number + emitTimeMs?: number + totalTimeMs?: number + metrics: Record +} + +const metricKeysByLabel = new Map([ + ['files', 'files'], + ['lines', 'lines'], + ['lines of library', 'linesOfLibrary'], + ['lines of definitions', 'linesOfDefinitions'], + ['lines of typescript', 'linesOfTypeScript'], + ['lines of javascript', 'linesOfJavaScript'], + ['lines of json', 'linesOfJson'], + ['lines of other', 'linesOfOther'], + ['nodes', 'nodes'], + ['identifiers', 'identifiers'], + ['symbols', 'symbols'], + ['types', 'types'], + ['instantiations', 'instantiations'], + ['memory used', 'memoryUsed'], + ['assignability cache size', 'assignabilityCacheSize'], + ['identity cache size', 'identityCacheSize'], + ['subtype cache size', 'subtypeCacheSize'], + ['strict subtype cache size', 'strictSubtypeCacheSize'], + ['i/o read time', 'ioReadTime'], + ['i/o write time', 'ioWriteTime'], + ['parse time', 'parseTime'], + ['resolvemodule time', 'resolveModuleTime'], + ['resolvetypereference time', 'resolveTypeReferenceTime'], + ['resolvelibrary time', 'resolveLibraryTime'], + ['program time', 'programTime'], + ['bind time', 'bindTime'], + ['check time', 'checkTime'], + ['transformtime time', 'transformTime'], + ['commenttime time', 'commentTime'], + ['printtime time', 'printTime'], + ['emit time', 'emitTime'], + ['total time', 'totalTime'], +]) + +const valuePattern = /^([+-]?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?)\s*([a-z]+)?$/i + +export function parseExtendedDiagnostics(text: string): ExtendedDiagnosticsSummary | undefined { + const metrics: Record = {} + + for (const line of text.split(/\r?\n/)) { + const separatorIndex = line.indexOf(':') + if (separatorIndex === -1) + continue + + const label = line.slice(0, separatorIndex).trim().toLowerCase() + const key = metricKeysByLabel.get(label) + if (!key) + continue + + const metric = parseMetricValue(line.slice(separatorIndex + 1)) + if (!metric) + continue + + metrics[key] = metric + } + + if (Object.keys(metrics).length === 0) + return undefined + + const summary: ExtendedDiagnosticsSummary = { metrics } + setSummaryNumber(summary, 'files', metrics.files) + setSummaryNumber(summary, 'identifiers', metrics.identifiers) + setSummaryNumber(summary, 'symbols', metrics.symbols) + setSummaryNumber(summary, 'types', metrics.types) + setSummaryNumber(summary, 'instantiations', metrics.instantiations) + setSummaryNumber(summary, 'memoryUsedKb', metrics.memoryUsed) + setSummaryNumber(summary, 'parseTimeMs', metrics.parseTime) + setSummaryNumber(summary, 'bindTimeMs', metrics.bindTime) + setSummaryNumber(summary, 'checkTimeMs', metrics.checkTime) + setSummaryNumber(summary, 'emitTimeMs', metrics.emitTime) + setSummaryNumber(summary, 'totalTimeMs', metrics.totalTime) + + const lines = { + total: metrics.lines?.value, + library: metrics.linesOfLibrary?.value, + definitions: metrics.linesOfDefinitions?.value, + typescript: metrics.linesOfTypeScript?.value, + javascript: metrics.linesOfJavaScript?.value, + json: metrics.linesOfJson?.value, + other: metrics.linesOfOther?.value, + } + if (Object.values(lines).some(value => value !== undefined)) + summary.lines = lines + + return summary +} + +function parseMetricValue(rawValue: string): ExtendedDiagnosticMetric | undefined { + const raw = rawValue.trim() + const match = valuePattern.exec(raw) + if (!match) + return undefined + + const value = Number(match[1].replace(/,/g, '')) + if (!Number.isFinite(value)) + return undefined + + const rawUnit = match[2]?.toLowerCase() + if (rawUnit === 's') { + return { + value: normalizeNumber(value * 1000), + unit: 'ms', + raw, + } + } + + if (rawUnit === 'ms') { + return { + value: normalizeNumber(value), + unit: 'ms', + raw, + } + } + + if (rawUnit === 'k' || rawUnit === 'kb' || rawUnit === 'kib') { + return { + value: normalizeNumber(value), + unit: 'kb', + raw, + } + } + + if (rawUnit === 'm' || rawUnit === 'mb' || rawUnit === 'mib') { + return { + value: normalizeNumber(value * 1024), + unit: 'kb', + raw, + } + } + + return { + value: normalizeNumber(value), + unit: 'count', + raw, + } +} + +function normalizeNumber(value: number) { + return Number(value.toFixed(3)) +} + +function setSummaryNumber( + summary: ExtendedDiagnosticsSummary, + key: Exclude, + metric: ExtendedDiagnosticMetric | undefined, +) { + if (metric) + summary[key] = metric.value +} diff --git a/src/traceCommand.ts b/src/traceCommand.ts new file mode 100644 index 0000000..a941578 --- /dev/null +++ b/src/traceCommand.ts @@ -0,0 +1,7 @@ +import { extname, relative } from 'node:path' + +export function traceSaveNameForFile(workspacePath: string, filePath: string): string { + const relativeFile = relative(workspacePath, filePath) + const extension = extname(relativeFile) + return extension ? relativeFile.slice(0, -extension.length) : relativeFile +} diff --git a/src/traceRunComparison.ts b/src/traceRunComparison.ts new file mode 100644 index 0000000..8aa908a --- /dev/null +++ b/src/traceRunComparison.ts @@ -0,0 +1,180 @@ +import { readFile } from 'node:fs/promises' +import type { OutputSummary, TraceParseStatus, TraceRunMetrics } from './traceRunMetrics' + +export interface NumberDelta { + before: number + after: number + delta: number +} + +export interface NullableNumberChange { + before: number | null + after: number | null + changed: boolean +} + +export interface OptionalNumberDelta { + before: number | undefined + after: number | undefined + delta: number | undefined + changed: boolean +} + +export interface BooleanChange { + before: boolean + after: boolean + changed: boolean +} + +export interface StringChange { + before: T + after: T + changed: boolean +} + +export interface OutputSummaryComparison { + bytes: NumberDelta + summaryLength: NumberDelta + truncated: BooleanChange +} + +export interface ExtendedDiagnosticsComparison { + types: OptionalNumberDelta + instantiations: OptionalNumberDelta + memoryUsedKb: OptionalNumberDelta + parseTimeMs: OptionalNumberDelta + bindTimeMs: OptionalNumberDelta + checkTimeMs: OptionalNumberDelta + emitTimeMs: OptionalNumberDelta + totalTimeMs: OptionalNumberDelta +} + +export interface TraceRunMetricsComparison { + wallTimeMs: NumberDelta + exitCode: NullableNumberChange + traceFileCount: NumberDelta + parseStatus: StringChange + extendedDiagnostics: ExtendedDiagnosticsComparison + stdout: OutputSummaryComparison + stderr: OutputSummaryComparison +} + +function compareNumbers(before: number, after: number): NumberDelta { + return { + before, + after, + delta: after - before, + } +} + +function compareNullableNumbers(before: number | null, after: number | null): NullableNumberChange { + return { + before, + after, + changed: before !== after, + } +} + +function compareOptionalNumbers(before: number | undefined, after: number | undefined): OptionalNumberDelta { + return { + before, + after, + delta: before === undefined || after === undefined ? undefined : after - before, + changed: before !== after, + } +} + +function compareBooleans(before: boolean, after: boolean): BooleanChange { + return { + before, + after, + changed: before !== after, + } +} + +function compareStrings(before: T, after: T): StringChange { + return { + before, + after, + changed: before !== after, + } +} + +function compareOutputSummary(before: OutputSummary, after: OutputSummary): OutputSummaryComparison { + return { + bytes: compareNumbers(before.bytes, after.bytes), + summaryLength: compareNumbers(before.text.length, after.text.length), + truncated: compareBooleans(before.truncated, after.truncated), + } +} + +function compareExtendedDiagnostics(before: TraceRunMetrics, after: TraceRunMetrics): ExtendedDiagnosticsComparison { + return { + types: compareOptionalNumbers(before.extendedDiagnostics?.types, after.extendedDiagnostics?.types), + instantiations: compareOptionalNumbers(before.extendedDiagnostics?.instantiations, after.extendedDiagnostics?.instantiations), + memoryUsedKb: compareOptionalNumbers(before.extendedDiagnostics?.memoryUsedKb, after.extendedDiagnostics?.memoryUsedKb), + parseTimeMs: compareOptionalNumbers(before.extendedDiagnostics?.parseTimeMs, after.extendedDiagnostics?.parseTimeMs), + bindTimeMs: compareOptionalNumbers(before.extendedDiagnostics?.bindTimeMs, after.extendedDiagnostics?.bindTimeMs), + checkTimeMs: compareOptionalNumbers(before.extendedDiagnostics?.checkTimeMs, after.extendedDiagnostics?.checkTimeMs), + emitTimeMs: compareOptionalNumbers(before.extendedDiagnostics?.emitTimeMs, after.extendedDiagnostics?.emitTimeMs), + totalTimeMs: compareOptionalNumbers(before.extendedDiagnostics?.totalTimeMs, after.extendedDiagnostics?.totalTimeMs), + } +} + +export function compareTraceRunMetrics(before: TraceRunMetrics, after: TraceRunMetrics): TraceRunMetricsComparison { + return { + wallTimeMs: compareNumbers(before.wallTimeMs, after.wallTimeMs), + exitCode: compareNullableNumbers(before.exitCode, after.exitCode), + traceFileCount: compareNumbers(before.traceJsonFiles.length, after.traceJsonFiles.length), + parseStatus: compareStrings(before.parse.status, after.parse.status), + extendedDiagnostics: compareExtendedDiagnostics(before, after), + stdout: compareOutputSummary(before.stdout, after.stdout), + stderr: compareOutputSummary(before.stderr, after.stderr), + } +} + +export async function readTraceRunMetricsFile(fileName: string): Promise { + return JSON.parse(await readFile(fileName, 'utf8')) as TraceRunMetrics +} + +function formatNumber(value: number | null | undefined): string { + return value === null || value === undefined ? '-' : `${value}` +} + +function formatDelta(value: number | undefined): string { + if (value === undefined) + return '-' + + return value > 0 ? `+${value}` : `${value}` +} + +function metricRow(name: string, comparison: NumberDelta | OptionalNumberDelta): string { + return `| ${name} | ${formatNumber(comparison.before)} | ${formatNumber(comparison.after)} | ${formatDelta(comparison.delta)} |` +} + +export function formatTraceRunMetricsComparison(comparison: TraceRunMetricsComparison): string { + const rows = [ + metricRow('Wall time ms', comparison.wallTimeMs), + metricRow('Trace JSON files', comparison.traceFileCount), + metricRow('Types', comparison.extendedDiagnostics.types), + metricRow('Instantiations', comparison.extendedDiagnostics.instantiations), + metricRow('Memory used KB', comparison.extendedDiagnostics.memoryUsedKb), + metricRow('Parse time ms', comparison.extendedDiagnostics.parseTimeMs), + metricRow('Bind time ms', comparison.extendedDiagnostics.bindTimeMs), + metricRow('Check time ms', comparison.extendedDiagnostics.checkTimeMs), + metricRow('Emit time ms', comparison.extendedDiagnostics.emitTimeMs), + metricRow('Total time ms', comparison.extendedDiagnostics.totalTimeMs), + ] + + return [ + '# Trace Metrics Comparison', + '', + `Exit code: ${formatNumber(comparison.exitCode.before)} -> ${formatNumber(comparison.exitCode.after)}${comparison.exitCode.changed ? ' (changed)' : ''}`, + `Parse status: ${comparison.parseStatus.before} -> ${comparison.parseStatus.after}${comparison.parseStatus.changed ? ' (changed)' : ''}`, + '', + '| Metric | Before | After | Delta |', + '| --- | ---: | ---: | ---: |', + ...rows, + '', + ].join('\n') +} diff --git a/src/traceRunMetrics.ts b/src/traceRunMetrics.ts new file mode 100644 index 0000000..2d41254 --- /dev/null +++ b/src/traceRunMetrics.ts @@ -0,0 +1,135 @@ +import { Buffer } from 'node:buffer' +import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { type ExtendedDiagnosticsSummary, parseExtendedDiagnostics } from './extendedDiagnostics' + +export const TRACE_RUN_METRICS_FILE = 'metrics.json' + +const DEFAULT_OUTPUT_LIMIT = 16_000 + +export interface OutputSummary { + bytes: number + truncated: boolean + text: string +} + +export interface TraceJsonFile { + fileName: string + bytes: number +} + +export type TraceParseStatus = 'missing' | 'ok' | 'warning' | 'error' + +export interface TraceParseSummary { + status: TraceParseStatus + topLevelWarning?: string +} + +export interface TraceRunMetricsInput { + command: string + cwd: string + traceDir: string + startedAt: string + endedAt: string + wallTimeMs: number + exitCode: number | null + stdout: string + stderr: string + traceJsonFiles: TraceJsonFile[] + parseSummary: TraceParseSummary +} + +export interface TraceRunMetrics { + version: 1 + command: string + cwd: string + traceDir: string + startedAt: string + endedAt: string + wallTimeMs: number + exitCode: number | null + stdout: OutputSummary + stderr: OutputSummary + extendedDiagnostics?: ExtendedDiagnosticsSummary + traceJsonFiles: TraceJsonFile[] + parse: TraceParseSummary +} + +export function summarizeOutput(text: string, limit = DEFAULT_OUTPUT_LIMIT): OutputSummary { + const bytes = Buffer.byteLength(text) + if (text.length <= limit) { + return { bytes, truncated: false, text } + } + + return { + bytes, + truncated: true, + text: text.slice(text.length - limit), + } +} + +export function buildTraceRunMetrics(input: TraceRunMetricsInput): TraceRunMetrics { + const extendedDiagnostics = parseExtendedDiagnostics(`${input.stdout}\n${input.stderr}`) + + return { + version: 1, + command: input.command, + cwd: input.cwd, + traceDir: input.traceDir, + startedAt: input.startedAt, + endedAt: input.endedAt, + wallTimeMs: input.wallTimeMs, + exitCode: input.exitCode, + stdout: summarizeOutput(input.stdout), + stderr: summarizeOutput(input.stderr), + ...(extendedDiagnostics ? { extendedDiagnostics } : {}), + traceJsonFiles: input.traceJsonFiles, + parse: input.parseSummary, + } +} + +export async function discoverTraceJsonFiles(traceDir: string): Promise { + const entries = await readdir(traceDir, { withFileTypes: true }) + const files = await Promise.all( + entries + .filter(entry => entry.isFile() && entry.name.endsWith('.json') && entry.name !== TRACE_RUN_METRICS_FILE) + .map(async (entry) => { + const fileStat = await stat(join(traceDir, entry.name)) + return { + fileName: entry.name, + bytes: fileStat.size, + } + }), + ) + + return files.sort((a, b) => a.fileName.localeCompare(b.fileName)) +} + +export async function summarizeTraceParse(traceDir: string, traceJsonFiles: TraceJsonFile[]): Promise { + if (traceJsonFiles.length === 0) { + return { + status: 'missing', + topLevelWarning: 'No trace JSON files were found.', + } + } + + for (const file of traceJsonFiles) { + try { + JSON.parse(await readFile(join(traceDir, file.fileName), 'utf8')) + } + catch (error) { + return { + status: 'error', + topLevelWarning: `${file.fileName}: ${error instanceof Error ? error.message : `${error}`}`, + } + } + } + + return { status: 'ok' } +} + +export async function writeTraceRunMetrics(traceDir: string, metrics: TraceRunMetrics): Promise { + const fileName = join(traceDir, TRACE_RUN_METRICS_FILE) + await mkdir(dirname(fileName), { recursive: true }) + await writeFile(fileName, `${JSON.stringify(metrics, null, 2)}\n`, 'utf8') +} diff --git a/src/traceTree.ts b/src/traceTree.ts index 0618d33..636518b 100644 --- a/src/traceTree.ts +++ b/src/traceTree.ts @@ -1,6 +1,6 @@ import { isAbsolute, join, relative } from 'node:path' import type { FileStat } from '../shared/src/messages' -import type { TraceData, TraceLine, TypeLine } from '../shared/src/traceData' +import { type TraceData, type TraceLine, type TypeLine, hasTypeTimestamp } from '../shared/src/traceData' import { getWorkspacePath } from './storage' import { postMessage } from './webview' import { traceFiles } from './appState' @@ -27,6 +27,10 @@ function getRoot(): Tree { } let treeIndexes: Tree[] = [] +function hasTraceTimestamp(line: TraceData[number]): line is TraceLine | TypeLine & { ts: number } { + return 'cat' in line || ('id' in line && hasTypeTimestamp(line)) +} + export function toTree(traceData: TraceData, workspacePath: string): Tree { const tree: Tree = { ...getRoot() } let endTs = Number.MAX_SAFE_INTEGER @@ -38,7 +42,7 @@ export function toTree(traceData: TraceData, workspacePath: string): Tree { treeIndexes = [tree] - const data = traceData.filter(x => 'id' in x || ('cat' in x)).sort((a, b) => a.ts - b.ts) + const data = traceData.filter(hasTraceTimestamp).sort((a, b) => a.ts - b.ts) // const data = traceData.filter(x => 'id' in x || ('cat' in x && x.cat?.startsWith('check'))).sort((a, b) => a.ts - b.ts) for (const line of data) { if ('args' in line && line.args?.path && isAbsolute(line.args?.path)) diff --git a/test/binSearch.test.ts b/test/binSearch.test.ts new file mode 100644 index 0000000..d506022 --- /dev/null +++ b/test/binSearch.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { binSearch } from '../ui/src/binSearch' + +describe('binSearch', () => { + const rows = [ + { ts: 10, label: 'a' }, + { ts: 20, label: 'b' }, + { ts: 30, label: 'c' }, + { ts: 40, label: 'd' }, + ] + + it('returns items inside the inclusive range', () => { + expect(binSearch(rows, 20, 30).map(row => row.label)).toEqual(['b', 'c']) + }) + + it('handles ranges that start before or end after the array', () => { + expect(binSearch(rows, 0, 20).map(row => row.label)).toEqual(['a', 'b']) + expect(binSearch(rows, 30, 100).map(row => row.label)).toEqual(['c', 'd']) + }) + + it('returns an empty array when the range misses the array', () => { + expect(binSearch(rows, 0, 5)).toEqual([]) + expect(binSearch(rows, 45, 100)).toEqual([]) + }) + + it('handles empty, single-item, and invalid ranges', () => { + expect(binSearch([], 0, 100)).toEqual([]) + expect(binSearch([{ ts: 0 }], 0, 0)).toEqual([{ ts: 0 }]) + expect(binSearch([{ ts: 0 }], 1, 1)).toEqual([]) + expect(binSearch(rows, 30, 20)).toEqual([]) + }) +}) diff --git a/test/extendedDiagnostics.test.ts b/test/extendedDiagnostics.test.ts new file mode 100644 index 0000000..d28bba8 --- /dev/null +++ b/test/extendedDiagnostics.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest' +import { parseExtendedDiagnostics } from '../src/extendedDiagnostics' + +describe('extended diagnostics parser', () => { + it('parses tsc --extendedDiagnostics counters, memory, and timings', () => { + const diagnostics = parseExtendedDiagnostics(` +src/index.ts(1,1): error TS2322: Type 'string' is not assignable to type 'number'. +Files: 128 +Lines of Library: 38,470 +Lines of Definitions: 1,234 +Lines of TypeScript: 456 +Lines of JavaScript: 7 +Lines of JSON: 3 +Lines of Other: 2 +Identifiers: 52,001 +Symbols: 39,120 +Types: 14,567 +Instantiations: 8,901 +Memory used: 174356K +Parse time: 0.36s +Bind time: 0.10s +Check time: 1.25s +Emit time: 0.00s +Total time: 1.71s +`) + + expect(diagnostics).toMatchObject({ + files: 128, + lines: { + library: 38470, + definitions: 1234, + typescript: 456, + javascript: 7, + json: 3, + other: 2, + }, + identifiers: 52001, + symbols: 39120, + types: 14567, + instantiations: 8901, + memoryUsedKb: 174356, + parseTimeMs: 360, + bindTimeMs: 100, + checkTimeMs: 1250, + emitTimeMs: 0, + totalTimeMs: 1710, + metrics: { + memoryUsed: { value: 174356, unit: 'kb', raw: '174356K' }, + checkTime: { value: 1250, unit: 'ms', raw: '1.25s' }, + }, + }) + }) + + it('keeps supplemental extended diagnostics in the metrics map', () => { + const diagnostics = parseExtendedDiagnostics(` +Assignability cache size: 99 +I/O Read time: 0.01s +ResolveModule time: 0.02s +printTime time: 0.03s +`) + + expect(diagnostics?.metrics).toMatchObject({ + assignabilityCacheSize: { value: 99, unit: 'count', raw: '99' }, + ioReadTime: { value: 10, unit: 'ms', raw: '0.01s' }, + resolveModuleTime: { value: 20, unit: 'ms', raw: '0.02s' }, + printTime: { value: 30, unit: 'ms', raw: '0.03s' }, + }) + }) + + it('returns undefined when no extended diagnostics are present', () => { + expect(parseExtendedDiagnostics('error TS2307: Cannot find module')).toBeUndefined() + }) +}) diff --git a/test/traceCommand.test.ts b/test/traceCommand.test.ts new file mode 100644 index 0000000..f44752e --- /dev/null +++ b/test/traceCommand.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest' +import { traceSaveNameForFile } from '../src/traceCommand' + +describe('traceSaveNameForFile', () => { + it('uses the workspace-relative file path without extension', () => { + expect(traceSaveNameForFile('/repo', '/repo/src/features/example.ts')).toBe('src/features/example') + }) + + it('preserves files without extensions', () => { + expect(traceSaveNameForFile('/repo', '/repo/tsconfig')).toBe('tsconfig') + }) +}) diff --git a/test/traceData.test.ts b/test/traceData.test.ts new file mode 100644 index 0000000..1f3bbbd --- /dev/null +++ b/test/traceData.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest' +import { getTraceDataSummary, traceData } from '../shared/src/traceData' + +describe('traceData', () => { + it('parses trace events', () => { + const result = traceData.safeParse([ + { + pid: 1, + tid: 1, + ph: 'X', + cat: 'check', + ts: 10, + name: 'checkSourceFile', + dur: 20, + args: { path: '/workspace/src/index.ts', pos: 1, end: 2 }, + }, + ]) + + expect(result.success).toBe(true) + }) + + it('parses timestamped types', () => { + const result = traceData.safeParse([ + { + id: 1, + intrinsicName: 'string', + recursionId: 1, + flags: ['String'], + ts: 12, + dur: 1, + display: 'string', + }, + ]) + + expect(result.success).toBe(true) + expect(result.success && getTraceDataSummary(result.data)).toEqual({ + totalTypes: 1, + timestampedTypes: 1, + untimestampedTypes: 0, + parseWarning: undefined, + }) + }) + + it('parses untimestamped stock TypeScript types', () => { + const result = traceData.safeParse([ + { + id: 1, + intrinsicName: 'string', + recursionId: 1, + flags: ['String'], + display: 'string', + }, + { + id: 2, + recursionId: 2, + flags: ['Object'], + display: '{ value: string }', + }, + ]) + + expect(result.success).toBe(true) + expect(result.success && getTraceDataSummary(result.data)).toEqual({ + totalTypes: 2, + timestampedTypes: 0, + untimestampedTypes: 2, + parseWarning: '2 type entries are missing timestamps and cannot be attributed to trace spans.', + }) + }) +}) diff --git a/test/traceRunComparison.test.ts b/test/traceRunComparison.test.ts new file mode 100644 index 0000000..729d9b8 --- /dev/null +++ b/test/traceRunComparison.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest' +import { compareTraceRunMetrics, formatTraceRunMetricsComparison } from '../src/traceRunComparison' +import type { TraceRunMetrics } from '../src/traceRunMetrics' + +function makeMetrics(overrides: Partial = {}): TraceRunMetrics { + return { + version: 1, + command: 'npx tsc --noEmit --generateTrace trace', + cwd: '/repo', + traceDir: '/repo/trace', + startedAt: '2026-05-18T00:00:00.000Z', + endedAt: '2026-05-18T00:00:01.000Z', + wallTimeMs: 1000, + exitCode: 0, + stdout: { bytes: 2, truncated: false, text: 'ok' }, + stderr: { bytes: 0, truncated: false, text: '' }, + traceJsonFiles: [{ fileName: 'trace.json', bytes: 2 }], + parse: { status: 'ok' }, + ...overrides, + } +} + +describe('trace run comparison', () => { + it('compares core metrics between two run artifacts', () => { + const before = makeMetrics({ + wallTimeMs: 1200, + exitCode: 0, + stdout: { bytes: 12, truncated: false, text: 'before run' }, + stderr: { bytes: 0, truncated: false, text: '' }, + traceJsonFiles: [{ fileName: 'trace.json', bytes: 2 }], + parse: { status: 'ok' }, + }) + const after = makeMetrics({ + wallTimeMs: 1575, + exitCode: 2, + stdout: { bytes: 24, truncated: true, text: 'candidate run tail' }, + stderr: { bytes: 19, truncated: false, text: 'type errors emitted' }, + traceJsonFiles: [ + { fileName: 'trace.json', bytes: 2 }, + { fileName: 'types.json', bytes: 3 }, + { fileName: 'symbols.json', bytes: 4 }, + ], + parse: { status: 'error', topLevelWarning: 'trace.json: invalid JSON' }, + }) + + expect(compareTraceRunMetrics(before, after)).toEqual({ + wallTimeMs: { before: 1200, after: 1575, delta: 375 }, + exitCode: { before: 0, after: 2, changed: true }, + traceFileCount: { before: 1, after: 3, delta: 2 }, + parseStatus: { before: 'ok', after: 'error', changed: true }, + extendedDiagnostics: { + types: { before: undefined, after: undefined, delta: undefined, changed: false }, + instantiations: { before: undefined, after: undefined, delta: undefined, changed: false }, + memoryUsedKb: { before: undefined, after: undefined, delta: undefined, changed: false }, + parseTimeMs: { before: undefined, after: undefined, delta: undefined, changed: false }, + bindTimeMs: { before: undefined, after: undefined, delta: undefined, changed: false }, + checkTimeMs: { before: undefined, after: undefined, delta: undefined, changed: false }, + emitTimeMs: { before: undefined, after: undefined, delta: undefined, changed: false }, + totalTimeMs: { before: undefined, after: undefined, delta: undefined, changed: false }, + }, + stdout: { + bytes: { before: 12, after: 24, delta: 12 }, + summaryLength: { before: 10, after: 18, delta: 8 }, + truncated: { before: false, after: true, changed: true }, + }, + stderr: { + bytes: { before: 0, after: 19, delta: 19 }, + summaryLength: { before: 0, after: 19, delta: 19 }, + truncated: { before: false, after: false, changed: false }, + }, + }) + }) + + it('reports negative deltas and unchanged nullable/status fields', () => { + const before = makeMetrics({ + wallTimeMs: 1000, + exitCode: null, + stdout: { bytes: 100, truncated: true, text: 'long stdout tail' }, + stderr: { bytes: 40, truncated: false, text: 'warnings' }, + traceJsonFiles: [ + { fileName: 'trace.json', bytes: 2 }, + { fileName: 'types.json', bytes: 3 }, + ], + parse: { status: 'warning', topLevelWarning: 'top-level trace event was not an array' }, + }) + const after = makeMetrics({ + wallTimeMs: 850, + exitCode: null, + stdout: { bytes: 20, truncated: false, text: 'short' }, + stderr: { bytes: 0, truncated: false, text: '' }, + traceJsonFiles: [], + parse: { status: 'warning', topLevelWarning: 'top-level trace event was not an array' }, + }) + + const comparison = compareTraceRunMetrics(before, after) + + expect(comparison.wallTimeMs).toEqual({ before: 1000, after: 850, delta: -150 }) + expect(comparison.exitCode).toEqual({ before: null, after: null, changed: false }) + expect(comparison.traceFileCount).toEqual({ before: 2, after: 0, delta: -2 }) + expect(comparison.parseStatus).toEqual({ before: 'warning', after: 'warning', changed: false }) + expect(comparison.stdout.summaryLength).toEqual({ before: 16, after: 5, delta: -11 }) + expect(comparison.stderr.summaryLength).toEqual({ before: 8, after: 0, delta: -8 }) + }) + + it('compares and formats extended diagnostics deltas', () => { + const before = makeMetrics({ + wallTimeMs: 1000, + extendedDiagnostics: { + types: 1400, + instantiations: 700, + memoryUsedKb: 120000, + checkTimeMs: 900, + totalTimeMs: 1200, + metrics: {}, + }, + }) + const after = makeMetrics({ + wallTimeMs: 850, + extendedDiagnostics: { + types: 1100, + instantiations: 450, + memoryUsedKb: 100000, + checkTimeMs: 650, + totalTimeMs: 950, + metrics: {}, + }, + }) + + const comparison = compareTraceRunMetrics(before, after) + const report = formatTraceRunMetricsComparison(comparison) + + expect(comparison.extendedDiagnostics.types).toEqual({ before: 1400, after: 1100, delta: -300, changed: true }) + expect(comparison.extendedDiagnostics.instantiations).toEqual({ before: 700, after: 450, delta: -250, changed: true }) + expect(report).toContain('| Types | 1400 | 1100 | -300 |') + expect(report).toContain('| Check time ms | 900 | 650 | -250 |') + }) +}) diff --git a/test/traceRunMetrics.test.ts b/test/traceRunMetrics.test.ts new file mode 100644 index 0000000..0c25052 --- /dev/null +++ b/test/traceRunMetrics.test.ts @@ -0,0 +1,128 @@ +import { Buffer } from 'node:buffer' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { afterEach, describe, expect, it } from 'vitest' +import { + TRACE_RUN_METRICS_FILE, + buildTraceRunMetrics, + discoverTraceJsonFiles, + summarizeOutput, + summarizeTraceParse, + writeTraceRunMetrics, +} from '../src/traceRunMetrics' + +const tempDirs: string[] = [] + +async function makeTempDir() { + const dir = await mkdtemp(join(tmpdir(), 'tsperf-trace-metrics-')) + tempDirs.push(dir) + return dir +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true }))) +}) + +describe('trace run metrics', () => { + it('summarizes output and keeps the tail when truncated', () => { + expect(summarizeOutput('hello', 10)).toEqual({ + bytes: 5, + truncated: false, + text: 'hello', + }) + + expect(summarizeOutput('0123456789', 4)).toEqual({ + bytes: 10, + truncated: true, + text: '6789', + }) + }) + + it('discovers trace JSON files without including metrics.json', async () => { + const traceDir = await makeTempDir() + await writeFile(join(traceDir, 'types.json'), '[]') + await writeFile(join(traceDir, 'trace.json'), '[]') + await writeFile(join(traceDir, TRACE_RUN_METRICS_FILE), '{}') + await writeFile(join(traceDir, 'notes.txt'), 'ignored') + + await expect(discoverTraceJsonFiles(traceDir)).resolves.toEqual([ + { fileName: 'trace.json', bytes: 2 }, + { fileName: 'types.json', bytes: 2 }, + ]) + }) + + it('reports parse status for missing, valid, and invalid trace files', async () => { + const traceDir = await makeTempDir() + + await expect(summarizeTraceParse(traceDir, [])).resolves.toEqual({ + status: 'missing', + topLevelWarning: 'No trace JSON files were found.', + }) + + await writeFile(join(traceDir, 'trace.json'), '[]') + const files = await discoverTraceJsonFiles(traceDir) + await expect(summarizeTraceParse(traceDir, files)).resolves.toEqual({ status: 'ok' }) + + await writeFile(join(traceDir, 'types.json'), '{') + const invalidFiles = await discoverTraceJsonFiles(traceDir) + const invalidSummary = await summarizeTraceParse(traceDir, invalidFiles) + expect(invalidSummary.status).toBe('error') + expect(invalidSummary.topLevelWarning).toContain('types.json:') + }) + + it('builds and writes a stable metrics artifact', async () => { + const traceDir = await makeTempDir() + const stdout = `Files: 8 +Types: 83 +Instantiations: 0 +Memory used: 48895K +Check time: 0.01s +Total time: 0.59s +` + const metrics = buildTraceRunMetrics({ + command: 'npx tsc --noEmit --generateTrace trace', + cwd: '/repo', + traceDir, + startedAt: '2026-05-18T00:00:00.000Z', + endedAt: '2026-05-18T00:00:01.250Z', + wallTimeMs: 1250, + exitCode: 0, + stdout, + stderr: '', + traceJsonFiles: [{ fileName: 'trace.json', bytes: 2 }], + parseSummary: { status: 'ok' }, + }) + + expect(metrics).toMatchObject({ + version: 1, + command: 'npx tsc --noEmit --generateTrace trace', + cwd: '/repo', + traceDir, + wallTimeMs: 1250, + exitCode: 0, + stdout: { + bytes: Buffer.byteLength(stdout), + truncated: false, + text: stdout, + }, + stderr: { bytes: 0, truncated: false, text: '' }, + extendedDiagnostics: { + files: 8, + types: 83, + instantiations: 0, + memoryUsedKb: 48895, + checkTimeMs: 10, + totalTimeMs: 590, + }, + traceJsonFiles: [{ fileName: 'trace.json', bytes: 2 }], + parse: { status: 'ok' }, + }) + + expect(metrics.extendedDiagnostics?.metrics.checkTime).toEqual({ value: 10, unit: 'ms', raw: '0.01s' }) + + await writeTraceRunMetrics(traceDir, metrics) + await expect(readFile(join(traceDir, TRACE_RUN_METRICS_FILE), 'utf8')) + .resolves.toBe(`${JSON.stringify(metrics, null, 2)}\n`) + }) +}) diff --git a/test/traceTree.test.ts b/test/traceTree.test.ts new file mode 100644 index 0000000..0472820 --- /dev/null +++ b/test/traceTree.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from 'vitest' +import { toTree } from '../src/traceTree' +import type { TraceData } from '../shared/src/traceData' + +vi.mock('../src/storage', () => ({ + getWorkspacePath: vi.fn(() => '/workspace'), +})) + +vi.mock('../src/webview', () => ({ + postMessage: vi.fn(), +})) + +vi.mock('../src/appState', () => ({ + traceFiles: { value: {} }, +})) + +vi.mock('vscode', () => ({ + workspace: { + workspaceFolders: [{ uri: { fsPath: '/workspace' } }], + getConfiguration: vi.fn(() => ({ + get: vi.fn(), + })), + onDidChangeConfiguration: vi.fn(), + }, + window: { + showErrorMessage: vi.fn(), + createOutputChannel: vi.fn(() => ({ + appendLine: vi.fn(), + show: vi.fn(), + })), + }, + env: { + appRoot: '/Applications/Visual Studio Code.app', + }, +})) + +describe('traceTree', () => { + it('does not crash or attribute untimestamped types', () => { + const data: TraceData = [ + { + pid: 1, + tid: 1, + ph: 'X', + cat: 'check', + ts: 10, + name: 'checkSourceFile', + dur: 20, + args: { path: '/workspace/src/index.ts', pos: 1, end: 2 }, + }, + { + id: 1, + intrinsicName: 'string', + recursionId: 1, + flags: ['String'], + display: 'string', + }, + ] + + const tree = toTree(data, '/workspace') + + expect(tree.children).toHaveLength(1) + expect(tree.children[0].types).toHaveLength(0) + expect(tree.children[0].typeCnt).toBe(0) + }) +}) diff --git a/ui/components/TreeNode.vue b/ui/components/TreeNode.vue index 997a094..13660c3 100644 --- a/ui/components/TreeNode.vue +++ b/ui/components/TreeNode.vue @@ -22,7 +22,7 @@ function fetchTypes() { function gotoPosition() { if ('name' in props.tree.line) { const { path, pos } = props.tree.line.args ?? { path: undefined, pos: undefined } - if (!path || !pos) + if (!path || pos === undefined) return sendMessage('gotoPosition', { fileName: path, pos }) diff --git a/ui/src/binSearch.ts b/ui/src/binSearch.ts index 7e60b01..683626e 100644 --- a/ui/src/binSearch.ts +++ b/ui/src/binSearch.ts @@ -1,37 +1,29 @@ // arr must be sorted by ts export function binSearch(arr: T[], from: number, to: number): T[] { if (arr.length === 0 || from > to) - return arr + return [] - let idx = Math.trunc(arr.length / 2) - let upperBound = arr.length - let lowerBound = 0 + let start = 0 + let end = arr.length - while (arr[idx].ts <= to) { - upperBound = idx - idx = Math.trunc(idx / 2) + while (start < end) { + const mid = Math.trunc((start + end) / 2) + if (arr[mid].ts < from) + start = mid + 1 + else + end = mid } - while (arr[idx].ts > to) { - lowerBound = idx - idx = Math.round(idx + (upperBound - idx) / 2) - } - - const endPos = lowerBound + const startPos = start - upperBound = lowerBound - lowerBound = 0 - while (arr[idx].ts > from) { - lowerBound = idx - idx = Math.round(idx + (upperBound - idx) / 2) + end = arr.length + while (start < end) { + const mid = Math.trunc((start + end) / 2) + if (arr[mid].ts <= to) + start = mid + 1 + else + end = mid } - while (arr[idx].ts <= from) { - upperBound = idx - idx = Math.trunc(idx / 2) - } - - const startPos = upperBound - - return arr.slice(startPos, endPos + 1) + return arr.slice(startPos, start) }