Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/three-humans-stick.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@e18e/deopt-shared": minor
"@e18e/deopt": minor
---

Add markdown output to the CLI.
28 changes: 26 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -65,6 +72,23 @@ npx @e18e/deopt <options> -- 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.
Expand Down
19 changes: 14 additions & 5 deletions packages/cli/src/create-log.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 {
Expand Down Expand Up @@ -57,15 +61,19 @@ function runNode({
node = process.execPath,
execArgv,
argv,
redirectStdout = false,
}: RunNodeOptions): Promise<number | null> {
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<number | null>((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();
});
Expand Down Expand Up @@ -95,6 +103,7 @@ export async function createLog(
args: string[],
head: string,
simpleHead: string,
{ md = false }: { md?: boolean } = {},
): Promise<string> {
const { extraExecArgv, argv, nodeExecutable } = determineArgs(args);

Expand All @@ -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);
Expand All @@ -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;
}
25 changes: 0 additions & 25 deletions packages/cli/src/enrich-render-data.ts

This file was deleted.

6 changes: 6 additions & 0 deletions packages/cli/src/grouping/group-by-file-and-location.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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,
}),
});
}

Expand Down
9 changes: 6 additions & 3 deletions packages/cli/src/grouping/summarize-file.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -16,7 +19,7 @@ export function summarizeFile({
ics,
deopts,
codes,
}: RenderGroup): FileSummary {
}: Pick<Group, 'ics' | 'deopts' | 'codes'>): FileSummary {
const icSeverities = [0, 0, 0, 0];
const deoptSeverities = [0, 0, 0, 0];
const codeSeverities = [0, 0, 0, 0];
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/src/main.ts
Original file line number Diff line number Diff line change
@@ -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';
59 changes: 59 additions & 0 deletions packages/cli/src/markdown.ts
Original file line number Diff line number Diff line change
@@ -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<string, IssueGroup>();
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`;
}
26 changes: 22 additions & 4 deletions packages/cli/src/open-log.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
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).
Expand Down
76 changes: 76 additions & 0 deletions packages/cli/src/report.ts
Original file line number Diff line number Diff line change
@@ -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<string, RenderGroup>,
{ 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) };
}
Loading