diff --git a/.changeset/three-humans-stick.md b/.changeset/three-humans-stick.md new file mode 100644 index 0000000..9e9f208 --- /dev/null +++ b/.changeset/three-humans-stick.md @@ -0,0 +1,6 @@ +--- +"@e18e/deopt-shared": minor +"@e18e/deopt": minor +--- + +Add markdown output to the CLI. diff --git a/README.md b/README.md index 466fdaa..9f3ada0 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,16 @@ You can learn a lot about these optimizations on the [v8 website](https://v8.dev If you resolve all of the diagnostics, you'll generally end up with incredibly performant code **for v8** and **this version of v8**. -As new versions of v8 are released, the optimizations and deoptimizations may change. So while you may have a perfectly optimized function in Node.js 20, it may optimize differently in Node.js 22. +However, there are a few things to note: -It seems unlikely the more common optimizations would ever stop being applied, though. It is well worth doing some learning on the subject so you can make a call per diagnostic. +- v8 changes over time, and so do the optimizations +- we should raise bugs in v8 for notable slow syntax rather than working around it +- other engines (non-v8) will have different optimizations, and it is unlikely you can write code that is optimal for all of them +- different versions of v8 (and thus Node.js) may optimize code differently + +Many of the more common optimizations (e.g. consistent shapes of objects) seem unlikely to ever stop being optimized. These are the ones worth focusing on rather than the more niche ones. + +It is worth doing some reading on "crankshaftscript" to see where over-optimizing for a specific engine can go wrong. ## Install @@ -65,6 +72,23 @@ npx @e18e/deopt -- app.js --some-flag Run `npx @e18e/deopt --help` for the full list of options. +### Markdown report + +Pass `--md` to print a concise markdown report to stdout instead of opening +the browser. The diagnostics are grouped by issue, each with the affected +locations and advice on how to address them. It works with any of the input +forms above: + +```sh +npx @e18e/deopt --md app.js +npx @e18e/deopt --md v8.log +npx @e18e/deopt --md -- app.js --some-flag +``` + +This is intended for non-interactive use, such as feeding the results to an +AI agent. Status messages are written to stderr so stdout contains only the +report. + ## Prior art This was originally forked from [deoptigate](https://github.com/thlorenz/deoptigate) and heavily reworked. Huge thanks to [@thlorenz](https://github.com/thlorenz) for the original work and inspiration. diff --git a/packages/cli/src/create-log.ts b/packages/cli/src/create-log.ts index 4fa7924..e3cead2 100644 --- a/packages/cli/src/create-log.ts +++ b/packages/cli/src/create-log.ts @@ -1,4 +1,5 @@ import { spawn } from 'node:child_process'; +import type { StdioOptions } from 'node:child_process'; import { tmpdir } from 'node:os'; import { basename } from 'node:path'; import { mkdir, stat } from 'node:fs/promises'; @@ -18,6 +19,9 @@ interface RunNodeOptions { node?: string; execArgv: string[]; argv: string[]; + // In markdown mode we redirect the child's stdout so the only stdout is the + // report itself. + redirectStdout?: boolean; } function determineArgs(tokens: string[]): DeterminedArgs { @@ -57,15 +61,19 @@ function runNode({ node = process.execPath, execArgv, argv, + redirectStdout = false, }: RunNodeOptions): Promise { - const child = spawn(node, execArgv.concat(argv), { stdio: 'inherit' }); + const stdio: StdioOptions = redirectStdout + ? ['inherit', process.stderr.fd, 'inherit'] + : 'inherit'; + const child = spawn(node, execArgv.concat(argv), { stdio }); const termination = new Promise((resolve) => { let interrupted = false; process.once('SIGINT', () => { interrupted = true; - console.log(styleText('gray', '\nshutting down...')); + process.stderr.write(styleText('gray', '\nshutting down...\n')); child.kill('SIGTERM'); setTimeout(() => child.kill('SIGKILL'), 500).unref(); }); @@ -95,6 +103,7 @@ export async function createLog( args: string[], head: string, simpleHead: string, + { md = false }: { md?: boolean } = {}, ): Promise { const { extraExecArgv, argv, nodeExecutable } = determineArgs(args); @@ -111,7 +120,7 @@ export async function createLog( '--no-logfile-per-isolate', ].concat(extraExecArgv); - const spawnArgs: RunNodeOptions = { execArgv, argv }; + const spawnArgs: RunNodeOptions = { execArgv, argv, redirectStdout: md }; if (nodeExecutable !== undefined) spawnArgs.node = nodeExecutable; const code = await runNode(spawnArgs); @@ -123,8 +132,8 @@ export async function createLog( )}`, ); } - console.log( - `${simpleHead} ${styleText('gray', 'logfile written to ' + logFile)}`, + process.stderr.write( + `${simpleHead} ${styleText('gray', 'logfile written to ' + logFile)}\n`, ); return logFile; } diff --git a/packages/cli/src/enrich-render-data.ts b/packages/cli/src/enrich-render-data.ts deleted file mode 100644 index 69a2770..0000000 --- a/packages/cli/src/enrich-render-data.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { groupByFileAndLocation } from './grouping/group-by-file-and-location.js'; -import { summarizeFile } from './grouping/summarize-file.js'; -import type { FileGroup, RenderGroup } from './types.js'; - -export function buildRenderData( - groupedByFile: Map, -): Map { - const groups = groupByFileAndLocation(groupedByFile); - for (const group of groups.values()) { - group.summary = summarizeFile(group); - } - return groups; -} - -export function serializeRenderData(groups: Map) { - return Array.from(groups, ([file, group]) => [ - file, - { - ...group, - ics: Array.from(group.ics), - deopts: Array.from(group.deopts), - codes: Array.from(group.codes), - }, - ]); -} diff --git a/packages/cli/src/grouping/group-by-file-and-location.ts b/packages/cli/src/grouping/group-by-file-and-location.ts index 144acff..eeba602 100644 --- a/packages/cli/src/grouping/group-by-file-and-location.ts +++ b/packages/cli/src/grouping/group-by-file-and-location.ts @@ -1,4 +1,5 @@ import { keyLocation, byLocationKey } from './location.js'; +import { summarizeFile } from './summarize-file.js'; import { nameIcState } from '../ic-state.js'; import { severityIcState, nameOptimizationState } from '@e18e/deopt-processor'; import { @@ -117,6 +118,11 @@ export function groupByFileAndLocation( icLocations: ics.locations, deoptLocations: deopts.locations, codeLocations: codes.locations, + summary: summarizeFile({ + ics: ics.byLocation, + deopts: deopts.byLocation, + codes: codes.byLocation, + }), }); } diff --git a/packages/cli/src/grouping/summarize-file.ts b/packages/cli/src/grouping/summarize-file.ts index dc6a409..af0bc7c 100644 --- a/packages/cli/src/grouping/summarize-file.ts +++ b/packages/cli/src/grouping/summarize-file.ts @@ -1,5 +1,8 @@ -import type { ProcessedCodeUpdate, FileSummary } from '@e18e/deopt-shared'; -import type { RenderGroup } from '../types.js'; +import type { + ProcessedCodeUpdate, + FileSummary, + Group, +} from '@e18e/deopt-shared'; const SEVERITY_2_FACTOR = 10; const SEVERITY_3_FACTOR = 30; @@ -16,7 +19,7 @@ export function summarizeFile({ ics, deopts, codes, -}: RenderGroup): FileSummary { +}: Pick): FileSummary { const icSeverities = [0, 0, 0, 0]; const deoptSeverities = [0, 0, 0, 0]; const codeSeverities = [0, 0, 0, 0]; diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index 4e6856f..0d7df8a 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -1,4 +1,6 @@ import { groupByFileAndLocation } from './grouping/group-by-file-and-location.js'; import { processLog } from './log.js'; +import { buildReport } from './report.js'; -export { groupByFileAndLocation, processLog }; +export { groupByFileAndLocation, processLog, buildReport }; +export type { Report, ReportFile, ReportDiagnostic } from './report.js'; diff --git a/packages/cli/src/markdown.ts b/packages/cli/src/markdown.ts new file mode 100644 index 0000000..2aa6239 --- /dev/null +++ b/packages/cli/src/markdown.ts @@ -0,0 +1,59 @@ +import type { Report } from './report.js'; + +const severityNames = ['low', 'medium', 'high']; + +interface IssueGroup { + issue: string; + tip: string | null; + severity: number; + locations: string[]; +} + +function severityName(severity: number): string { + return severityNames[severity - 1] ?? String(severity); +} + +/** + * Renders a report as markdown grouped by issue: one section per distinct + * problem, with its tip stated once and the affected locations listed beneath. + * Aimed at being concise and unambiguous for an LLM to act on. + */ +export function renderMarkdown(report: Report): string { + const groups = new Map(); + for (const { file, diagnostics } of report.files) { + for (const diagnostic of diagnostics) { + let group = groups.get(diagnostic.issue); + if (group === undefined) { + group = { + issue: diagnostic.issue, + tip: diagnostic.tip, + severity: diagnostic.severity, + locations: [], + }; + groups.set(diagnostic.issue, group); + } + group.severity = Math.max(group.severity, diagnostic.severity); + const at = `${file}:${diagnostic.line}:${diagnostic.column}`; + group.locations.push( + diagnostic.functionName ? `${at} ${diagnostic.functionName}` : at, + ); + } + } + + if (groups.size === 0) { + return '# Deoptimization report\n\nNo deoptimizations found.\n'; + } + + const ordered = Array.from(groups.values()).sort( + (a, b) => b.severity - a.severity || a.issue.localeCompare(b.issue), + ); + + const sections = ordered.map((group) => { + const heading = `## ${group.issue} (${severityName(group.severity)})`; + const body = group.tip === null ? [] : [group.tip]; + const list = group.locations.map((location) => `- ${location}`); + return [heading, ...body, '', ...list].join('\n'); + }); + + return `# Deoptimization report\n\n${sections.join('\n\n')}\n`; +} diff --git a/packages/cli/src/open-log.ts b/packages/cli/src/open-log.ts index 2100741..9f08a0a 100644 --- a/packages/cli/src/open-log.ts +++ b/packages/cli/src/open-log.ts @@ -1,12 +1,30 @@ import open from 'tiny-open'; -import { logToJSON } from './log.js'; +import { logToJSON, processLog } from './log.js'; +import { groupByFileAndLocation } from './grouping/group-by-file-and-location.js'; +import { buildReport } from './report.js'; +import { renderMarkdown } from './markdown.js'; import { saveData } from './save-parts.js'; import { startServer } from './server.js'; -export async function openLog(v8log: string, head: string): Promise { - const json = await logToJSON(v8log, { root: process.cwd() }); - const dataFile = saveData(json); +interface OpenLogOptions { + md?: boolean; +} + +export async function openLog( + v8log: string, + head: string, + { md = false }: OpenLogOptions = {}, +): Promise { + if (md) { + const groupedByFile = await processLog(v8log, { root: process.cwd() }); + const report = buildReport(groupByFileAndLocation(groupedByFile)); + process.stdout.write(renderMarkdown(report)); + return; + } + + const data = await logToJSON(v8log, { root: process.cwd() }); + const dataFile = saveData(data); // Data-only mode writes the render data and exits without starting the // server, so the data can be generated non-interactively (e.g. in tests). diff --git a/packages/cli/src/report.ts b/packages/cli/src/report.ts new file mode 100644 index 0000000..cbec259 --- /dev/null +++ b/packages/cli/src/report.ts @@ -0,0 +1,76 @@ +import { + listDiagnostics, + issueForDiagnostic, + tipForDiagnostic, +} from '@e18e/deopt-shared'; +import type { DiagnosticKind, Update } from '@e18e/deopt-shared'; +import type { RenderGroup } from './types.js'; + +/** A single diagnostic location. */ +export interface ReportDiagnostic { + kind: DiagnosticKind; + functionName: string; + line: number; + column: number; + severity: number; + /** Stable, concise label for the problem, e.g. "deopt: wrong call target". */ + issue: string; + /** Actionable advice for the issue, when one is available. */ + tip: string | null; + /** The raw state transitions backing this diagnostic. */ + updates: Update[]; +} + +/** A file and the diagnostics found in it. */ +export interface ReportFile { + file: string; + diagnostics: ReportDiagnostic[]; +} + +export interface Report { + files: ReportFile[]; +} + +interface ReportOptions { + includeAllSeverities?: boolean; + hideNodeModules?: boolean; +} + +/** + * Builds a structured report from enriched render data. By default it matches + * the app's default view: files without critical severities and node_modules + * are omitted, only severities above the baseline are listed, and files are + * sorted from worst to least severe. + */ +export function buildReport( + groups: Map, + { includeAllSeverities = false, hideNodeModules = true }: ReportOptions = {}, +): Report { + const ranked: Array<{ score: number; file: ReportFile }> = []; + for (const group of groups.values()) { + const summary = group.summary; + if (!includeAllSeverities && !summary.hasCriticalSeverities) continue; + if (hideNodeModules && group.relativePath.includes('node_modules/')) + continue; + + const diagnostics = listDiagnostics(group, { includeAllSeverities }).map( + (diagnostic): ReportDiagnostic => ({ + kind: diagnostic.kind, + functionName: diagnostic.info.functionName, + line: diagnostic.info.line, + column: diagnostic.info.column, + severity: diagnostic.info.severity, + issue: issueForDiagnostic(diagnostic), + tip: tipForDiagnostic(diagnostic), + updates: diagnostic.info.updates, + }), + ); + + ranked.push({ + score: summary.severityScore, + file: { file: group.relativePath, diagnostics }, + }); + } + ranked.sort((a, b) => b.score - a.score); + return { files: ranked.map((entry) => entry.file) }; +} diff --git a/packages/cli/src/run.ts b/packages/cli/src/run.ts index 96a0ed8..825d81e 100644 --- a/packages/cli/src/run.ts +++ b/packages/cli/src/run.ts @@ -26,40 +26,48 @@ Open an existing v8 log: Options: -h, --help Show this help text + --md Print a concise markdown report to stdout instead of opening + the visualization in a browser `; const args = process.argv.slice(2); const dashIndex = args.indexOf('--'); -// Without `--` the whole argument list is the command to run. deopt's own -// options are only accepted before a `--` separator. -let values: { help?: boolean } = {}; +// deopt's own options precede the command to run. With a `--` separator they +// are taken from before it; otherwise they are the leading flags up to the +// first non-flag token (the runtime, script or log file). +let optionArgs: string[]; let command: string[]; if (dashIndex < 0) { - command = args; + let i = 0; + while (i < args.length && args[i].startsWith('-')) i++; + optionArgs = args.slice(0, i); + command = args.slice(i); } else { - ({ values } = parseArgs({ - args: args.slice(0, dashIndex), - options: { - help: { type: 'boolean', short: 'h' }, - }, - })); + optionArgs = args.slice(0, dashIndex); command = args.slice(dashIndex + 1); } -const userNeedsHelp = - values.help || - (dashIndex < 0 && - (command.length === 0 || command[0] === '--help' || command[0] === '-h')); +const { values } = parseArgs({ + args: optionArgs, + options: { + help: { type: 'boolean', short: 'h' }, + md: { type: 'boolean' }, + }, +}); + +const userNeedsHelp = values.help || command.length === 0; try { if (userNeedsHelp) { console.log(usage); } else if (command.length === 1 && command[0].endsWith('.log')) { - await openLog(command[0], happyHead); + await openLog(command[0], happyHead, { md: values.md }); } else { - const log = await createLog(command, happyHead, simpleHead); - await openLog(log, happyHead); + const log = await createLog(command, happyHead, simpleHead, { + md: values.md, + }); + await openLog(log, happyHead, { md: values.md }); } } catch (err) { console.error(`${errorHead}: ${String(err)}`); diff --git a/packages/cli/src/server.ts b/packages/cli/src/server.ts index 1c2ac19..d22ddca 100644 --- a/packages/cli/src/server.ts +++ b/packages/cli/src/server.ts @@ -10,8 +10,34 @@ import ts from 'shiki/langs/typescript.mjs'; import tsx from 'shiki/langs/tsx.mjs'; import dracula from 'shiki/themes/dracula.mjs'; -import { buildRenderData, serializeRenderData } from './enrich-render-data.js'; -import type { FileGroup } from './types.js'; +import { groupByFileAndLocation } from './grouping/group-by-file-and-location.js'; +import type { FileGroup, RenderGroup } from './types.js'; +import type { + ProcessedIcInfo, + ProcessedDeoptInfo, + ProcessedCodeInfo, +} from '@e18e/deopt-shared'; + +/** A `RenderGroup` with its location maps flattened to entries for JSON. */ +type SerializedRenderGroup = Omit & { + ics: Array<[string, ProcessedIcInfo]>; + deopts: Array<[string, ProcessedDeoptInfo]>; + codes: Array<[string, ProcessedCodeInfo]>; +}; + +function serializeRenderData( + groups: Map, +): Array<[string, SerializedRenderGroup]> { + return Array.from(groups, ([file, group]) => [ + file, + { + ...group, + ics: Array.from(group.ics), + deopts: Array.from(group.deopts), + codes: Array.from(group.codes), + }, + ]); +} type Highlighter = Awaited>; type Tokens = ReturnType['tokens']; @@ -52,7 +78,7 @@ export async function startServer({ dataFile }: { dataFile: string }) { const parsed = JSON.parse(await fs.readFile(dataFile, 'utf8')) as Array< [string, FileGroup] >; - const groups = buildRenderData(new Map(parsed)); + const groups = groupByFileAndLocation(new Map(parsed)); const renderData = JSON.stringify(serializeRenderData(groups)); const highlighter = await createHighlighterCore({ diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index c25e7b3..6f63579 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -2,10 +2,7 @@ import type { ParsedIcInfo, ParsedDeoptInfo, ParsedCodeInfo, - ProcessedIcInfo, - ProcessedDeoptInfo, - ProcessedCodeInfo, - FileSummary, + Group, } from '@e18e/deopt-shared'; /** The parsed diagnostics extracted from a log, before grouping. */ @@ -31,15 +28,8 @@ export interface FileGroup extends ResolvedFile { } /** - * A file's processed diagnostics keyed by location, with the sorted location - * keys for each kind and the precomputed per-file summary. + * A resolved source file paired with its processed diagnostics: the shared + * `Group` (diagnostics keyed by location, the sorted location keys for each + * kind and the per-file summary) plus the file's path and source. */ -export interface RenderGroup extends ResolvedFile { - ics: Map; - deopts: Map; - codes: Map; - icLocations: string[]; - deoptLocations: string[]; - codeLocations: string[]; - summary?: FileSummary; -} +export interface RenderGroup extends Group, ResolvedFile {} diff --git a/packages/shared/src/main.ts b/packages/shared/src/main.ts index 2b4d54b..aa386aa 100644 --- a/packages/shared/src/main.ts +++ b/packages/shared/src/main.ts @@ -115,6 +115,25 @@ export function describeDiagnostic({ kind, info }: Diagnostic): string { return `ended as ${updates[updates.length - 1].stateName}`; } +// A stable, concise label identifying the kind of problem, suitable for +// grouping diagnostics that share a cause (and the same tip). Unlike +// `describeDiagnostic` this collapses a code vector's varied end states into +// whether it dropped a tier or churned, so the label maps one-to-one onto the +// available tips. +export function issueForDiagnostic({ kind, info }: Diagnostic): string { + if (kind === 'ic') { + return `ic: ${highestSeverityUpdate(info.updates).newStateName}`; + } + if (kind === 'deopt') { + const { deoptReason, bailoutType } = highestSeverityUpdate(info.updates); + return `deopt: ${deoptReason || bailoutType}`; + } + const { churned, dropped } = analyzeCodeUpdates(info.updates); + if (dropped) return 'code: optimization dropped'; + if (churned) return 'code: optimization churn'; + return `code: ${info.updates[info.updates.length - 1].stateName}`; +} + export function tipForDiagnostic({ kind, info }: Diagnostic): string | null { if (kind === 'code') { const { churned, dropped } = analyzeCodeUpdates(info.updates);