mirror of
https://github.com/millionco/react-doctor.git
synced 2026-09-14 20:00:24 +08:00
8c2f03aea9
* feat: make React cleanup first-class * refactor: remove editor integrations * fix: harden React cleanup analysis * fix: detect default export duplication roots * fix: unwrap typed duplication roots * feat: add opt-in project analysis rules * fix: canonicalize project analysis paths * fix: harden project analysis precision * fix: recognize cross-platform project entries * fix: eliminate project analysis false positives * fix: harden project analysis reachability * fix: canonicalize project analysis inputs * fix: resolve project analysis review findings * fix: eliminate residual project analysis false positives * fix: ignore commented registry previews * fix: eliminate project analysis false positives * fix: normalize project analysis paths across platforms * fix: normalize Nextra theme path identity * test: canonicalize convention fixture paths * fix: preserve project analysis provenance * fix: harden project analysis precision * fix: honor project analysis boundaries * fix: recognize conditional config plugins * fix: recognize executable project references * fix: recognize Stencil tool contracts * fix: recognize nested tool references * fix: recognize project setup contracts * fix: recognize generated and local package consumers * fix: recognize static template package references * fix: recognize nested package runtime contracts * fix: close project analysis parser gaps * fix: parse project conventions structurally * refactor: replace structural scanners with parsers * fix: recognize functional Next CSS config * fix: close remaining project analysis gaps * fix: apply tag filters to project analysis * fix: preserve embedded source positions * fix: validate static config helper bindings * fix: bound runtime directory discovery * fix: close final dependency analysis gaps * fix: preserve declaration dependency references * chore: refresh generated rule metadata * fix: make project analysis portable and bounded * test: stabilize cleanup scaling guard * refactor: parse project syntax with oxc * fix: normalize native filesystem paths * fix: separate path identity from report paths * fix: match project files by filesystem identity * fix: match build glob files by package identity * fix: use native path keys for file identity * fix: canonicalize Windows file identities * fix: canonicalize package ownership paths * test: inspect Windows path identities * test: trace Windows package ownership * fix: keep Windows path identities consistent * fix: classify test contracts by normalized path * fix: scope test contracts by canonical package path * fix: keep test package graphs conservative * test: keep React complexity advisory
310 lines
10 KiB
TypeScript
310 lines
10 KiB
TypeScript
import { spawnSync } from "node:child_process";
|
|
import { fileURLToPath } from "node:url";
|
|
import * as Schema from "effect/Schema";
|
|
import { JsonReport } from "@react-doctor/core/schemas";
|
|
import * as fs from "node:fs";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
|
|
interface CommandInput {
|
|
readonly command: string;
|
|
readonly args: readonly string[];
|
|
readonly cwd: string;
|
|
readonly allowedStatuses?: readonly number[];
|
|
readonly needsShell?: boolean;
|
|
}
|
|
|
|
interface StringRecord {
|
|
readonly [key: string]: unknown;
|
|
}
|
|
|
|
const SCRIPT_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
|
|
const REPOSITORY_ROOT = path.resolve(SCRIPT_DIRECTORY, "..");
|
|
const FIXTURE_DIRECTORY = path.resolve(REPOSITORY_ROOT, "packages/core/tests/fixtures/basic-react");
|
|
const PACKED_PACKAGE_NAMES: readonly string[] = ["react-doctor", "oxlint-plugin-react-doctor"];
|
|
const FORBIDDEN_INSTALLED_PACKAGES: readonly string[] = [
|
|
"ini",
|
|
"effect",
|
|
"@effect/platform-node-shared",
|
|
"deslop-cli",
|
|
"deslop-js",
|
|
"ink",
|
|
"ink-link",
|
|
"ink-spinner",
|
|
"vscode-jsonrpc",
|
|
"vscode-languageserver",
|
|
"vscode-languageserver-protocol",
|
|
"vscode-languageserver-textdocument",
|
|
"vscode-languageserver-types",
|
|
"vscode-uri",
|
|
"react-devtools-core",
|
|
"react-reconciler",
|
|
];
|
|
const COMMAND_OUTPUT_MAX_BYTES = 50 * 1024 * 1024;
|
|
|
|
const isRecord = (value: unknown): value is StringRecord =>
|
|
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
|
|
const runCommand = (input: CommandInput) => {
|
|
const result = spawnSync(input.command, [...input.args], {
|
|
cwd: input.cwd,
|
|
encoding: "utf-8",
|
|
maxBuffer: COMMAND_OUTPUT_MAX_BYTES,
|
|
shell: input.needsShell === true,
|
|
});
|
|
const status = result.status ?? 1;
|
|
const allowedStatuses = input.allowedStatuses ?? [0];
|
|
if (result.error !== undefined || !allowedStatuses.includes(status)) {
|
|
console.error(`Command failed: ${[input.command, ...input.args].join(" ")}`);
|
|
console.error(`cwd: ${input.cwd}`);
|
|
console.error(`status: ${status}`);
|
|
if (result.error !== undefined) console.error(result.error);
|
|
if (result.stdout.trim() !== "") console.error("stdout:", result.stdout);
|
|
if (result.stderr.trim() !== "") console.error("stderr:", result.stderr);
|
|
process.exit(1);
|
|
}
|
|
return result;
|
|
};
|
|
|
|
const readPackageName = (packageDirectory: string): string | null => {
|
|
const packageJsonPath = path.join(packageDirectory, "package.json");
|
|
if (!fs.existsSync(packageJsonPath)) return null;
|
|
const parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
|
|
if (!isRecord(parsed) || typeof parsed.name !== "string") return null;
|
|
return parsed.name;
|
|
};
|
|
|
|
const collectInstalledPackageNames = (nodeModulesDirectory: string): Set<string> => {
|
|
const packageNames = new Set<string>();
|
|
if (!fs.existsSync(nodeModulesDirectory)) return packageNames;
|
|
|
|
const visitPackageDirectory = (packageDirectory: string): void => {
|
|
const packageName = readPackageName(packageDirectory);
|
|
if (packageName !== null) packageNames.add(packageName);
|
|
visitNodeModules(path.join(packageDirectory, "node_modules"));
|
|
};
|
|
|
|
const visitNodeModules = (directory: string): void => {
|
|
if (!fs.existsSync(directory)) return;
|
|
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
if (!entry.isDirectory() || entry.name === ".bin") continue;
|
|
const entryPath = path.join(directory, entry.name);
|
|
if (entry.name.startsWith("@")) {
|
|
for (const scopedEntry of fs.readdirSync(entryPath, { withFileTypes: true })) {
|
|
if (scopedEntry.isDirectory()) {
|
|
visitPackageDirectory(path.join(entryPath, scopedEntry.name));
|
|
}
|
|
}
|
|
} else {
|
|
visitPackageDirectory(entryPath);
|
|
}
|
|
}
|
|
};
|
|
|
|
visitNodeModules(nodeModulesDirectory);
|
|
return packageNames;
|
|
};
|
|
|
|
const assertFixtureExists = (): void => {
|
|
if (fs.existsSync(FIXTURE_DIRECTORY)) return;
|
|
console.error(`Fixture missing at ${FIXTURE_DIRECTORY}.`);
|
|
process.exit(1);
|
|
};
|
|
|
|
const main = (): void => {
|
|
assertFixtureExists();
|
|
|
|
const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-packed-cli-"));
|
|
const packDirectory = path.join(temporaryDirectory, "pack");
|
|
const installDirectory = path.join(temporaryDirectory, "install");
|
|
|
|
try {
|
|
fs.mkdirSync(packDirectory);
|
|
fs.mkdirSync(installDirectory);
|
|
fs.writeFileSync(
|
|
path.join(installDirectory, "package.json"),
|
|
`${JSON.stringify(
|
|
{
|
|
name: "react-doctor-packed-cli-smoke",
|
|
private: true,
|
|
dependencies: { react: "18.3.1" },
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
);
|
|
|
|
// Pack the CLI with its unbundled rule plugin so the install mirrors the
|
|
// version-pinned packages that Changesets publishes together.
|
|
runCommand({
|
|
command: "pnpm",
|
|
args: [
|
|
...PACKED_PACKAGE_NAMES.flatMap((packageName) => ["--filter", packageName]),
|
|
"pack",
|
|
"--pack-destination",
|
|
packDirectory,
|
|
],
|
|
cwd: REPOSITORY_ROOT,
|
|
needsShell: process.platform === "win32",
|
|
});
|
|
|
|
const tarballs = fs.readdirSync(packDirectory).filter((fileName) => fileName.endsWith(".tgz"));
|
|
if (tarballs.length !== PACKED_PACKAGE_NAMES.length) {
|
|
console.error(
|
|
`Expected exactly ${PACKED_PACKAGE_NAMES.length} packed tarballs in ${packDirectory}, found ${tarballs.length}.`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
const tarballPaths = tarballs.map((tarball) => path.join(packDirectory, tarball));
|
|
|
|
runCommand({
|
|
command: "npm",
|
|
args: ["install", "--omit=dev", ...tarballPaths],
|
|
cwd: installDirectory,
|
|
needsShell: process.platform === "win32",
|
|
});
|
|
|
|
const installedPackages = collectInstalledPackageNames(
|
|
path.join(installDirectory, "node_modules"),
|
|
);
|
|
const missingPackedPackages = PACKED_PACKAGE_NAMES.filter(
|
|
(packageName) => !installedPackages.has(packageName),
|
|
);
|
|
if (missingPackedPackages.length > 0) {
|
|
console.error(
|
|
`Packed install is missing expected package(s): ${missingPackedPackages.join(", ")}`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
const forbiddenPackages = FORBIDDEN_INSTALLED_PACKAGES.filter((packageName) =>
|
|
installedPackages.has(packageName),
|
|
);
|
|
if (forbiddenPackages.length > 0) {
|
|
console.error(
|
|
`Packed install unexpectedly installed forbidden package(s): ${forbiddenPackages.join(", ")}`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
const binaryPath = path.join(
|
|
installDirectory,
|
|
"node_modules",
|
|
"react-doctor",
|
|
"bin",
|
|
"react-doctor.js",
|
|
);
|
|
const projectAnalysisWorkerPath = path.join(
|
|
installDirectory,
|
|
"node_modules",
|
|
"react-doctor",
|
|
"dist",
|
|
"project-analysis-worker.js",
|
|
);
|
|
if (!fs.existsSync(projectAnalysisWorkerPath)) {
|
|
console.error(`Packed install is missing ${projectAnalysisWorkerPath}.`);
|
|
process.exit(1);
|
|
}
|
|
const versionResult = runCommand({
|
|
command: process.execPath,
|
|
args: [binaryPath, "--version"],
|
|
cwd: installDirectory,
|
|
});
|
|
const version = versionResult.stdout.trim();
|
|
if (version === "" || version === "0.0.0") {
|
|
console.error(`Installed CLI version is missing or invalid: "${version}"`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const projectAnalysisFixtureDirectory = path.join(installDirectory, "project-analysis");
|
|
const projectAnalysisSourceDirectory = path.join(projectAnalysisFixtureDirectory, "src");
|
|
fs.mkdirSync(projectAnalysisSourceDirectory, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(projectAnalysisFixtureDirectory, "package.json"),
|
|
`${JSON.stringify({ name: "project-analysis-smoke", private: true, main: "src/index.ts", dependencies: { react: "19.2.5" } })}\n`,
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(projectAnalysisFixtureDirectory, "doctor.config.json"),
|
|
`${JSON.stringify({ rules: { "react-doctor/unused-export": "warn" } })}\n`,
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(projectAnalysisSourceDirectory, "index.ts"),
|
|
'export { usedValue } from "./library.js";\n',
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(projectAnalysisSourceDirectory, "library.ts"),
|
|
"export const usedValue = 1;\nexport const unusedValue = 2;\n",
|
|
);
|
|
const projectAnalysisResult = runCommand({
|
|
command: process.execPath,
|
|
args: [
|
|
binaryPath,
|
|
projectAnalysisFixtureDirectory,
|
|
"--no-score",
|
|
"--no-dead-code",
|
|
"--blocking",
|
|
"none",
|
|
"--json",
|
|
],
|
|
cwd: installDirectory,
|
|
allowedStatuses: [0, 1],
|
|
});
|
|
const projectAnalysisReport = Schema.decodeUnknownSync(JsonReport)(
|
|
JSON.parse(projectAnalysisResult.stdout),
|
|
);
|
|
if (
|
|
!projectAnalysisReport.diagnostics.some((diagnostic) => diagnostic.rule === "unused-export")
|
|
) {
|
|
console.error("Packed CLI did not run the opt-in project analysis worker.");
|
|
console.error(
|
|
`Received rules: ${projectAnalysisReport.diagnostics.map((diagnostic) => diagnostic.rule).join(", ")}`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
const scanResult = runCommand({
|
|
command: process.execPath,
|
|
args: [
|
|
binaryPath,
|
|
FIXTURE_DIRECTORY,
|
|
"--no-score",
|
|
"--no-dead-code",
|
|
"--blocking",
|
|
"none",
|
|
"--json",
|
|
],
|
|
cwd: installDirectory,
|
|
allowedStatuses: [0, 1],
|
|
});
|
|
|
|
let decoded: ReturnType<typeof Schema.decodeUnknownSync<typeof JsonReport>>;
|
|
try {
|
|
decoded = Schema.decodeUnknownSync(JsonReport)(JSON.parse(scanResult.stdout));
|
|
} catch (cause) {
|
|
console.error("Installed CLI did not produce a schema-valid JsonReport.");
|
|
console.error("stdout:", scanResult.stdout.slice(0, 2_000));
|
|
console.error("cause:", cause);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (process.platform !== "win32") {
|
|
runCommand({
|
|
command: "python3",
|
|
args: [
|
|
path.join(REPOSITORY_ROOT, "scripts/smoke-tty-prompt.py"),
|
|
"--cli-binary",
|
|
binaryPath,
|
|
],
|
|
cwd: installDirectory,
|
|
});
|
|
}
|
|
|
|
console.log(
|
|
`Packed install smoke OK: version=${version} diagnostics=${decoded.diagnostics.length} forbiddenPackages=0`,
|
|
);
|
|
} finally {
|
|
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
|
|
}
|
|
};
|
|
|
|
main();
|