mirror of
https://github.com/millionco/react-doctor.git
synced 2026-09-14 20:00:24 +08:00
76cd6bea69
* perf: reduce cold scan startup and add V8 profiling Add repeatable CPU and heap profiling so cold-run bottlenecks can be measured, then remove profile-proven startup, traversal, and security-scan overhead. * perf: add deterministic stress benchmark Exercise cold scans with reproducible diagnostics and remove redundant visitor-map allocations so regressions are measurable and behavior stays hash-verifiable. * fix(tests): normalize benchmark paths cross-platform Resolve the absolute fixture path through Node so the harness expectation matches Windows path semantics. * perf: avoid effect analysis parent traversal Reuse Oxc visitor keys with a parent-safe fallback so effect scope analysis no longer strips and restores every AST parent reference. * perf: reduce repeated semantic traversal Reuse host visitors and shared analysis caches to avoid redundant cold-scan AST passes while preserving diagnostic parity. * fix: harden performance regression coverage * fix(perf): support profiling on Node 20 * fix(plugin): skip CFGs for bodyless functions * fix: harden profiling and semantic compatibility Prevent benchmark artifacts and host differences from corrupting comparisons, while preserving host AST and React Compiler compatibility across optimized scan paths. * fix(plugin): preserve rule correctness in fast scan paths Keep security prefilters comment-tolerant and resolve React HOC wrappers by binding provenance so scan optimizations cannot hide valid diagnostics. * fix: address review findings and consolidate duplicated code - wrapWithSemanticContext copies the rule's visitors instead of mutating a possibly shared object; walkAst regains its null-root guard - no-multi-comp HOC identity accepts React-compat runtimes via REACT_RUNTIME_MODULE_SOURCES (now incl. @wordpress/element) across ESM, require(), and TS import-equals, with regression coverage in both directions - shared traversal core (forEachChildNode) replaces the walkChildren and containsJsx copies; isImportedFromReact deduped into is-react-api-call - performance harness consolidated (27 -> 22 files): shared commander options, shared profile-frame accumulation, record shape guards, dead BenchmarkSample.profileDirectory removed - CLI-spawning harness tests skip without a built dist; dead and tautological test assertions removed Net -282 LOC against the branch head. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(plugin): capture the Program root for every rule Rules can reach context.scopes through shared helpers and factories (createDeprecatedReactImportRule resolves namespace aliases via resolveConstIdentifierAlias), so the codegen'd requiresSemanticContext flag — a regex over the rule's own file — silently left factory-based rules on stub scope analyses: no-react-dom-deprecated-apis and no-react19-deprecated-apis stopped reporting namespace usages through the host wrapper. Delete the flag and its regex detector entirely and install the root-capture Program visitor on every rule. The analyses stay lazy and memoized per Program, so rules that never read them still pay only one call per rule per file, and no future helper-routed consumer can be missed. Regression test runs the real host-wrapped rule and fails against the gated wrapper. Fixes cursor bugbot review finding on PR #1114. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(scripts): reject cyclic heap-profile node graphs during collection collectNodes flattened the parsed tree with no visited guard, so a cyclic or shared-node object graph would loop before reaching the duplicate-ID check. Unreachable through analyzeHeapProfiles (JSON.parse output is always a strict tree), but guard and throw like the CPU analyzer so a synthetic graph fails deterministically. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Rayhan Noufal Arayilakath <me@rayhanadev.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
183 lines
6.4 KiB
TypeScript
183 lines
6.4 KiB
TypeScript
import * as fs from "node:fs";
|
|
import * as path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { BYTES_PER_MEBIBYTE, PROFILE_TOP_FRAME_COUNT } from "./constants.ts";
|
|
import { isRecord, isRecordWithFields } from "./is-record-with-fields.ts";
|
|
import {
|
|
addFrameChainTotals,
|
|
collectProfilePaths,
|
|
aggregateFrameValues,
|
|
getFrameValue,
|
|
isCallFrame,
|
|
profileFrameKey,
|
|
resolveProfileProcessRole,
|
|
toRankedFrames,
|
|
} from "./profile-frames.ts";
|
|
import type { MutableFrameValue } from "./profile-frames.ts";
|
|
import { runProfileAnalysisMain } from "./run-commander-main.ts";
|
|
import type {
|
|
HeapProfile,
|
|
HeapProfileAnalysis,
|
|
HeapProfileFrameSummary,
|
|
HeapProfileNode,
|
|
HeapProfileProcessSummary,
|
|
} from "./types.ts";
|
|
|
|
interface AnalyzedHeapProfile {
|
|
processSummary: HeapProfileProcessSummary;
|
|
allocations: Map<string, MutableFrameValue>;
|
|
}
|
|
|
|
const isHeapProfileNode = (value: unknown): value is HeapProfileNode =>
|
|
isRecordWithFields(value, { selfSize: "number", id: "number" }) &&
|
|
isCallFrame(value.callFrame) &&
|
|
Array.isArray(value.children) &&
|
|
value.children.every(isHeapProfileNode);
|
|
|
|
const isHeapProfile = (value: unknown): value is HeapProfile =>
|
|
isRecord(value) && isHeapProfileNode(value.head);
|
|
|
|
// JSON.parse output is always a strict tree, but guard against cyclic or
|
|
// shared nodes anyway so a synthetic graph is rejected like the CPU path
|
|
// instead of looping forever.
|
|
const collectNodes = (rootNode: HeapProfileNode, profilePath: string): HeapProfileNode[] => {
|
|
const nodes: HeapProfileNode[] = [];
|
|
const visitedNodes = new Set<HeapProfileNode>();
|
|
const pendingNodes = [rootNode];
|
|
while (pendingNodes.length > 0) {
|
|
const node = pendingNodes.pop();
|
|
if (node === undefined) continue;
|
|
if (visitedNodes.has(node)) {
|
|
throw new Error(`Invalid heap profile with cyclic nodes: ${profilePath}`);
|
|
}
|
|
visitedNodes.add(node);
|
|
nodes.push(node);
|
|
pendingNodes.push(...node.children);
|
|
}
|
|
return nodes;
|
|
};
|
|
|
|
const toFrameSummaries = (
|
|
allocations: Map<string, MutableFrameValue>,
|
|
sampledBytes: number,
|
|
): HeapProfileFrameSummary[] =>
|
|
toRankedFrames(allocations, sampledBytes).map(({ self, total, ...frame }) => ({
|
|
...frame,
|
|
selfBytes: self,
|
|
totalBytes: total,
|
|
}));
|
|
|
|
const analyzeProfile = (profilePath: string): AnalyzedHeapProfile => {
|
|
const parsedProfile: unknown = JSON.parse(fs.readFileSync(profilePath, "utf8"));
|
|
if (!isHeapProfile(parsedProfile)) throw new Error(`Invalid heap profile: ${profilePath}`);
|
|
const nodes = collectNodes(parsedProfile.head, profilePath);
|
|
const nodesById = new Map(nodes.map((node) => [node.id, node]));
|
|
if (nodesById.size !== nodes.length) {
|
|
throw new Error(`Invalid heap profile with duplicate node IDs: ${profilePath}`);
|
|
}
|
|
const frameKeysByNodeId = new Map(
|
|
nodes.map((node) => [node.id, profileFrameKey(node.callFrame)]),
|
|
);
|
|
const parentById = new Map<number, number>();
|
|
for (const node of nodes) {
|
|
for (const child of node.children) {
|
|
const existingParentId = parentById.get(child.id);
|
|
if (existingParentId !== undefined && existingParentId !== node.id) {
|
|
throw new Error(`Invalid heap profile with multiple parents: ${profilePath}`);
|
|
}
|
|
parentById.set(child.id, node.id);
|
|
}
|
|
}
|
|
const allocations = new Map<string, MutableFrameValue>();
|
|
let sampledBytes = 0;
|
|
for (const node of nodes) {
|
|
if (node.selfSize <= 0) continue;
|
|
sampledBytes += node.selfSize;
|
|
const selfKey = frameKeysByNodeId.get(node.id);
|
|
if (selfKey === undefined) continue;
|
|
getFrameValue(allocations, selfKey, node.callFrame).self += node.selfSize;
|
|
addFrameChainTotals({
|
|
startNode: node,
|
|
amount: node.selfSize,
|
|
nodesById,
|
|
parentById,
|
|
frameKeysByNodeId,
|
|
frames: allocations,
|
|
profilePath,
|
|
});
|
|
}
|
|
return {
|
|
processSummary: {
|
|
file: profilePath,
|
|
role: resolveProfileProcessRole(nodes.map((node) => node.callFrame)),
|
|
sampledBytes,
|
|
topFrames: toFrameSummaries(allocations, sampledBytes).slice(0, PROFILE_TOP_FRAME_COUNT),
|
|
},
|
|
allocations,
|
|
};
|
|
};
|
|
|
|
const renderAnalysisMarkdown = (analysis: HeapProfileAnalysis): string => {
|
|
const lines = [
|
|
"# V8 heap profile analysis",
|
|
"",
|
|
`Profiles: ${analysis.processes.length}`,
|
|
`Sampled allocations: ${(analysis.sampledBytes / BYTES_PER_MEBIBYTE).toFixed(2)} MiB`,
|
|
"",
|
|
"## Aggregate sampled allocations",
|
|
"",
|
|
"| Function | Source | Self | Total |",
|
|
"| --- | --- | ---: | ---: |",
|
|
];
|
|
for (const frame of analysis.aggregateTopFrames) {
|
|
const source = frame.url ? `${frame.url}:${frame.lineNumber}` : "(native)";
|
|
lines.push(
|
|
`| ${frame.functionName.replaceAll("|", "\\|")} | ${source.replaceAll("|", "\\|")} | ${(frame.selfBytes / BYTES_PER_MEBIBYTE).toFixed(2)} MiB (${frame.selfPercent.toFixed(2)}%) | ${(frame.totalBytes / BYTES_PER_MEBIBYTE).toFixed(2)} MiB (${frame.totalPercent.toFixed(2)}%) |`,
|
|
);
|
|
}
|
|
lines.push("", "## Processes", "");
|
|
for (const processSummary of analysis.processes) {
|
|
lines.push(
|
|
`- ${processSummary.role}: ${(processSummary.sampledBytes / BYTES_PER_MEBIBYTE).toFixed(2)} MiB — ${path.basename(processSummary.file)}`,
|
|
);
|
|
}
|
|
return `${lines.join("\n")}\n`;
|
|
};
|
|
|
|
export const analyzeHeapProfiles = (profileDirectory: string): HeapProfileAnalysis => {
|
|
const analyzedProfiles = collectProfilePaths({
|
|
directory: profileDirectory,
|
|
extension: ".heapprofile",
|
|
}).map(analyzeProfile);
|
|
if (analyzedProfiles.length === 0) {
|
|
throw new Error(`No .heapprofile files found in ${profileDirectory}`);
|
|
}
|
|
const sampledBytes = analyzedProfiles.reduce(
|
|
(total, analyzedProfile) => total + analyzedProfile.processSummary.sampledBytes,
|
|
0,
|
|
);
|
|
const aggregateAllocations = aggregateFrameValues(
|
|
analyzedProfiles.map((analyzedProfile) => analyzedProfile.allocations),
|
|
);
|
|
return {
|
|
generatedAt: new Date().toISOString(),
|
|
profileDirectory,
|
|
sampledBytes,
|
|
processes: analyzedProfiles.map((analyzedProfile) => analyzedProfile.processSummary),
|
|
aggregateTopFrames: toFrameSummaries(aggregateAllocations, sampledBytes).slice(
|
|
0,
|
|
PROFILE_TOP_FRAME_COUNT,
|
|
),
|
|
};
|
|
};
|
|
|
|
if (path.resolve(process.argv[1] ?? "") === fileURLToPath(import.meta.url)) {
|
|
runProfileAnalysisMain({
|
|
name: "react-doctor-performance-memory",
|
|
description: "Aggregate V8 heap profiles captured by the performance harness",
|
|
defaultOutputName: "memory",
|
|
analyze: analyzeHeapProfiles,
|
|
renderMarkdown: renderAnalysisMarkdown,
|
|
});
|
|
}
|