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
5 changes: 5 additions & 0 deletions .changeset/trim-cli-dependencies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"react-doctor": patch
---

Reduce the CLI dependency graph by replacing narrow code-frame and terminal-symbol helpers with local implementations.
3 changes: 1 addition & 2 deletions packages/evals/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@daytona/sdk": "^0.196.0",
"p-limit": "^3.1.0"
"@daytona/sdk": "^0.196.0"
},
"devDependencies": {
"@types/node": "^25.6.0",
Expand Down
5 changes: 2 additions & 3 deletions packages/evals/src/cleanup-evaluation-sandboxes.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { DaytonaNotFoundError, SandboxState } from "@daytona/sdk";
import type { Daytona, Sandbox } from "@daytona/sdk";
import pLimit from "p-limit";

import { SANDBOX_CLEANUP_CONCURRENCY, SANDBOX_DELETE_TIMEOUT_SECONDS } from "./constants.js";
import { toErrorMessage } from "./utils/to-error-message.js";
import { runBeforeDeadline } from "./utils/run-before-deadline.js";
import { createConcurrencyLimit } from "./utils/create-concurrency-limit.js";

export interface CleanupEvaluationSandboxesInput {
daytona: Daytona;
Expand All @@ -17,7 +16,7 @@ export const cleanupEvaluationSandboxes = async ({
evaluationId,
deadlineMilliseconds,
}: CleanupEvaluationSandboxesInput): Promise<void> => {
const cleanupLimit = pLimit(SANDBOX_CLEANUP_CONCURRENCY);
const cleanupLimit = createConcurrencyLimit(SANDBOX_CLEANUP_CONCURRENCY);
const remainingSandboxes = await runBeforeDeadline({
operation: async () => {
const sandboxes: Sandbox[] = [];
Expand Down
4 changes: 2 additions & 2 deletions packages/evals/src/run-corpus-evaluation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { randomUUID } from "node:crypto";
import { open } from "node:fs/promises";

import { Daytona, DaytonaNotFoundError, Image } from "@daytona/sdk";
import pLimit from "p-limit";

import { cleanupEvaluationSandboxes } from "./cleanup-evaluation-sandboxes.js";
import { deleteDaytonaSnapshotBeforeDeadline } from "./utils/delete-daytona-snapshot-before-deadline.js";
Expand Down Expand Up @@ -44,6 +43,7 @@ import type { EvaluationOptions } from "./parse-evaluation-arguments.js";
import { runEvaluationAttempts } from "./run-evaluation-attempts.js";
import { runMatrixCorpusEvaluation } from "./run-matrix-corpus-evaluation.js";
import { createPairedNdjsonWriter } from "./utils/create-paired-ndjson-writer.js";
import { createConcurrencyLimit } from "./utils/create-concurrency-limit.js";
import { getEvaluationAttemptDeadlineMilliseconds } from "./utils/get-evaluation-attempt-deadline-milliseconds.js";
import { getEvaluatorSourceHash } from "./utils/get-evaluator-source-hash.js";
import { getEvaluationTimeoutSeconds } from "./utils/get-evaluation-timeout-seconds.js";
Expand Down Expand Up @@ -196,7 +196,7 @@ export const runCorpusEvaluation = async (options: EvaluationOptions): Promise<v
Math.min(options.concurrency, concurrency),
),
];
const limitSandboxCreation = pLimit(
const limitSandboxCreation = createConcurrencyLimit(
Math.min(options.concurrency, SANDBOX_CREATE_CONCURRENCY),
);
const createSandbox = (sandboxName: string, deadlineMilliseconds: number) =>
Expand Down
5 changes: 2 additions & 3 deletions packages/evals/src/run-evaluation-attempts.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import pLimit from "p-limit";

import { EVALUATION_RETRY_REPOSITORIES_PER_SANDBOX } from "./constants.js";
import type { CorpusEvaluationRecord, CorpusRepositoryGroup } from "./corpus.js";
import { groupCorpusRepositories } from "./group-corpus-repositories.js";
import { partitionRepositoryGroups } from "./utils/partition-repository-groups.js";
import { createConcurrencyLimit } from "./utils/create-concurrency-limit.js";

export interface EvaluationRetry {
attemptNumber: number;
Expand Down Expand Up @@ -38,7 +37,7 @@ export const runEvaluationAttempts = async ({
}: RunEvaluationAttemptsInput): Promise<void> => {
let pendingRepositoryGroups = repositoryGroups;
for (const [attemptIndex, concurrency] of attemptConcurrencies.entries()) {
const limit = pLimit(concurrency);
const limit = createConcurrencyLimit(concurrency);
const repositoryBatchSize =
attemptIndex === 0 ? repositoriesPerSandbox : EVALUATION_RETRY_REPOSITORIES_PER_SANDBOX;
const repositoryBatches = partitionRepositoryGroups(
Expand Down
6 changes: 4 additions & 2 deletions packages/evals/src/run-matrix-corpus-evaluation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { fileURLToPath } from "node:url";
import { promisify } from "node:util";

import { Daytona, DaytonaNotFoundError, Image } from "@daytona/sdk";
import pLimit from "p-limit";

import { buildMatrixEvaluationPlan } from "./build-matrix-evaluation-plan.js";
import type { MatrixEvaluationLane } from "./build-matrix-evaluation-plan.js";
Expand Down Expand Up @@ -37,6 +36,7 @@ import { runMatrixEvaluationAttempts } from "./run-matrix-evaluation-attempts.js
import { abortWriters } from "./utils/abort-writers.js";
import { assertMatrixBaseRecord } from "./utils/assert-matrix-base-record.js";
import { createMatrixBaseArtifactBinding } from "./utils/matrix-base-artifact-binding.js";
import { createConcurrencyLimit } from "./utils/create-concurrency-limit.js";
import type { MatrixBaseArtifactBinding } from "./utils/matrix-base-artifact-binding.js";
import { deleteDaytonaSnapshotBeforeDeadline } from "./utils/delete-daytona-snapshot-before-deadline.js";
import { getEvaluationAttemptDeadlineMilliseconds } from "./utils/get-evaluation-attempt-deadline-milliseconds.js";
Expand Down Expand Up @@ -266,7 +266,9 @@ export const runMatrixCorpusEvaluation = async (options: EvaluationOptions): Pro
Math.min(options.concurrency, concurrency),
),
];
const limitSandboxCreation = pLimit(Math.min(options.concurrency, SANDBOX_CREATE_CONCURRENCY));
const limitSandboxCreation = createConcurrencyLimit(
Math.min(options.concurrency, SANDBOX_CREATE_CONCURRENCY),
);
const createSandbox = (sandboxName: string, deadlineMilliseconds: number) =>
limitSandboxCreation(() =>
daytona.create(
Expand Down
5 changes: 2 additions & 3 deletions packages/evals/src/run-matrix-evaluation-attempts.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import pLimit from "p-limit";

import type { CorpusRepository, CorpusRepositoryGroup } from "./corpus.js";
import type { MatrixEvaluationLane } from "./build-matrix-evaluation-plan.js";
import type { MatrixEvaluationFailure } from "./evaluate-matrix-repository-batch.js";
import { groupCorpusRepositories } from "./group-corpus-repositories.js";
import { partitionRepositoryGroups } from "./utils/partition-repository-groups.js";
import { createConcurrencyLimit } from "./utils/create-concurrency-limit.js";
import { toErrorMessage } from "./utils/to-error-message.js";

export interface RunMatrixEvaluationAttemptsInput {
Expand Down Expand Up @@ -136,7 +135,7 @@ export const runMatrixEvaluationAttempts = async ({
).map((repositoryBatch) => ({ repositoryGroups: repositoryBatch, lanes }));

for (const [attemptIndex, concurrency] of attemptConcurrencies.entries()) {
const limit = pLimit(concurrency);
const limit = createConcurrencyLimit(concurrency);
const workResults = await Promise.allSettled(
pendingWork.map((work) =>
limit(() => evaluateRepositoryBatch(work.repositoryGroups, work.lanes, attemptIndex)),
Expand Down
35 changes: 35 additions & 0 deletions packages/evals/src/utils/create-concurrency-limit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
interface ConcurrencyLimit {
<Result>(operation: () => Result | PromiseLike<Result>): Promise<Result>;
}

export const createConcurrencyLimit = (concurrency: number): ConcurrencyLimit => {
if (!Number.isInteger(concurrency) || concurrency < 1) {
throw new TypeError("Concurrency must be a positive integer");
}

const pendingOperations: Array<() => void> = [];
let activeOperationCount = 0;

const startNextOperations = (): void => {
while (activeOperationCount < concurrency) {
const startOperation = pendingOperations.shift();
if (!startOperation) return;
activeOperationCount += 1;
startOperation();
}
};

return <Result>(operation: () => Result | PromiseLike<Result>): Promise<Result> =>
new Promise<Result>((resolve, reject) => {
pendingOperations.push(() => {
Promise.resolve()
.then(operation)
.then(resolve, reject)
.finally(() => {
activeOperationCount -= 1;
startNextOperations();
});
});
startNextOperations();
});
};
5 changes: 2 additions & 3 deletions packages/evals/src/utils/create-paired-ndjson-writer.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { Writable } from "node:stream";

import pLimit from "p-limit";

import { createConcurrencyLimit } from "./create-concurrency-limit.js";
import { serializeNdjsonRecord } from "./serialize-ndjson-record.js";
import { writeWritableContents } from "./write-writable-contents.js";

Expand Down Expand Up @@ -51,7 +50,7 @@ export const createPairedNdjsonWriter = ({
baselineFileHandle,
treatmentOutput,
}: CreatePairedNdjsonWriterInput): PairedNdjsonWriter => {
const limitWrite = pLimit(1);
const limitWrite = createConcurrencyLimit(1);
let baselineOffset = 0;
let hasWriteFailed = false;
let writeFailure: unknown;
Expand Down
40 changes: 40 additions & 0 deletions packages/evals/tests/create-concurrency-limit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vite-plus/test";

import { createConcurrencyLimit } from "../src/utils/create-concurrency-limit.js";

describe("createConcurrencyLimit", () => {
it("runs queued operations in FIFO order without exceeding the limit", async () => {
const limit = createConcurrencyLimit(2);
const startedOperations: number[] = [];
let activeOperationCount = 0;
let maximumActiveOperationCount = 0;

const results = [1, 2, 3, 4].map((operationId) =>
limit(async () => {
startedOperations.push(operationId);
activeOperationCount += 1;
maximumActiveOperationCount = Math.max(maximumActiveOperationCount, activeOperationCount);
await Promise.resolve();
activeOperationCount -= 1;
return operationId;
}),
);

await expect(Promise.all(results)).resolves.toEqual([1, 2, 3, 4]);
expect(startedOperations).toEqual([1, 2, 3, 4]);
expect(maximumActiveOperationCount).toBe(2);
});

it("releases a slot when an operation rejects", async () => {
const limit = createConcurrencyLimit(1);
const rejectedOperation = limit(() => Promise.reject(new Error("failed")));
const nextOperation = limit(() => "completed");

await expect(rejectedOperation).rejects.toThrow("failed");
await expect(nextOperation).resolves.toBe("completed");
});

it("rejects invalid concurrency", () => {
expect(() => createConcurrencyLimit(0)).toThrow("positive integer");
});
});
2 changes: 0 additions & 2 deletions packages/react-doctor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,12 @@
},
"dependencies": {
"@astrojs/compiler": "^4.0.0",
"@babel/code-frame": "^7.29.0",
"@sentry/node": "^10.54.0",
"agent-install": "0.0.5",
"conf": "^15.1.0",
"confbox": "^0.2.4",
"deslop-js": "workspace:*",
"eslint-plugin-react-hooks": "^7.1.1",
"figures": "^6.1.0",
"jiti": "^2.7.0",
"magicast": "^0.5.3",
"oxc-resolver": "^11.24.2",
Expand Down
4 changes: 2 additions & 2 deletions packages/react-doctor/src/cli/ink/components/action-menu.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import figures from "figures";
import { Box, Text, useInput } from "ink";
import type { ReactNode } from "react";
import {
TUI_REPORT_ACTION_MENU_ITEM_GAP_ROWS,
TUI_REPORT_ACTION_MENU_MARGIN_ROWS,
} from "../../utils/constants.js";
import { terminalSymbols } from "../../utils/terminal-symbols.js";
import { useState } from "../react-runtime.js";

export interface ActionMenuAction {
Expand Down Expand Up @@ -59,7 +59,7 @@ export const ActionMenu = ({
}
>
<Text color={isSelected ? "cyan" : undefined} bold={isSelected}>
{isSelected ? figures.pointer : figures.pointerSmall} {action.label}
{isSelected ? terminalSymbols.pointer : terminalSymbols.pointerSmall} {action.label}
</Text>
{isSelected ? action.description : null}
</Box>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import path from "node:path";
import figures from "figures";
import { Box, Text, useInput } from "ink";
import type { ReactNode } from "react";
import type { WorkspacePackage } from "@react-doctor/core";
Expand All @@ -15,6 +14,7 @@ import { clampNumber } from "../../utils/clamp-number.js";
import { isPrintableInput } from "../../utils/is-printable-input.js";
import { recordCount } from "../../utils/record-metric.js";
import { resolveVisibleStart } from "../../utils/resolve-visible-start.js";
import { terminalSymbols } from "../../utils/terminal-symbols.js";
import { useExitOnCtrlC } from "../hooks/use-exit-on-ctrl-c.js";
import { useStdoutDimensions } from "../hooks/use-stdout-dimensions.js";
import { fuzzyMatch } from "../lib/fuzzy-match.js";
Expand Down Expand Up @@ -269,10 +269,10 @@ export const ProjectSelect = ({ packages, rootDirectory, onSubmit }: ProjectSele
return (
<Text key={matchedPackage.workspacePackage.directory} wrap="truncate-end">
<Text color={isSelected ? "cyan" : undefined}>
{isSelected ? `${figures.pointer} ` : " "}
{isSelected ? `${terminalSymbols.pointer} ` : " "}
</Text>
<Text color={isChecked ? "green" : undefined}>
{isChecked ? `${figures.radioOn} ` : `${figures.radioOff} `}
{isChecked ? `${terminalSymbols.radioOn} ` : `${terminalSymbols.radioOff} `}
</Text>
<MatchedName
name={matchedPackage.workspacePackage.name}
Expand Down
29 changes: 10 additions & 19 deletions packages/react-doctor/src/cli/utils/build-code-frame.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import * as fs from "node:fs";
import { codeFrameColumns } from "@babel/code-frame";
import {
CODE_FRAME_LINES_ABOVE,
CODE_FRAME_LINES_BELOW,
CODE_FRAME_MAX_LINE_LENGTH_CHARS,
} from "@react-doctor/core";
import { renderCodeFrame } from "./render-code-frame.js";
import { resolveAbsolutePath } from "./resolve-absolute-path.js";

interface CodeFrameInput {
Expand All @@ -16,8 +16,7 @@ interface CodeFrameInput {
// `line`..`endLine` range — used to batch several same-file sites of one
// rule into a single spanning frame instead of near-duplicate boxes.
readonly endLine?: number;
// Short label rendered inline at the caret (e.g. the rule title). Keep
// it brief — babel prints it right after the `^`.
// Short label rendered inline at the caret (e.g. the rule title).
readonly message?: string;
}

Expand All @@ -40,23 +39,15 @@ export const buildCodeFrame = (input: CodeFrameInput): string | null => {
return null;
}

// A single huge line (minified output, a giant inline data literal)
// only renders an unreadable wall of text, so skip the frame and let
// the caller fall back to the bare `file:line` reference.
const offendingLine = source.split("\n", input.line)[input.line - 1] ?? "";
if (offendingLine.length > CODE_FRAME_MAX_LINE_LENGTH_CHARS) return null;

// A spanning frame marks every line in the range and has no single
// caret column; a single-site frame points the caret at the column.
const isRange = input.endLine != null && input.endLine > input.line;
const location = isRange
? { start: { line: input.line }, end: { line: input.endLine! } }
: { start: { line: input.line, column: input.column > 0 ? input.column : undefined } };

return codeFrameColumns(source, location, {
highlightCode: true,
const endLine = input.endLine != null && input.endLine > input.line ? input.endLine : undefined;
return renderCodeFrame({
source,
line: input.line,
column: endLine === undefined && input.column > 0 ? input.column : undefined,
endLine,
message: input.message,
linesAbove: CODE_FRAME_LINES_ABOVE,
linesBelow: CODE_FRAME_LINES_BELOW,
...(input.message ? { message: input.message } : {}),
maximumLineLength: CODE_FRAME_MAX_LINE_LENGTH_CHARS,
});
};
14 changes: 14 additions & 0 deletions packages/react-doctor/src/cli/utils/highlight-code-line.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { highlighter } from "@react-doctor/core";

const CODE_TOKEN_PATTERN =
/\/\/.*|\/\*.*?\*\/|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`|\b(?:as|async|await|break|case|catch|class|const|continue|debugger|default|delete|do|else|export|extends|false|finally|for|from|function|get|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|set|static|super|switch|this|throw|true|try|type|typeof|undefined|var|void|while|with|yield)\b|\b(?:0[xob][\da-f]+|\d+(?:\.\d+)?)\b/giu;

export const highlightCodeLine = (codeLine: string): string =>
codeLine.replace(CODE_TOKEN_PATTERN, (token) => {
if (token.startsWith("/")) return highlighter.gray(token);
if (token.startsWith('"') || token.startsWith("'") || token.startsWith("`")) {
return highlighter.success(token);
}
if (/^\d|^0[xob]/i.test(token)) return highlighter.warn(token);
return highlighter.info(token);
});
Loading
Loading