Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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
9 changes: 9 additions & 0 deletions .changeset/tall-adults-refuse.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"react-doctor": patch
"deslop-cli": patch
"deslop-js": patch
"eslint-plugin-react-doctor": patch
"oxlint-plugin-react-doctor": patch
---

Harden scan orchestration and cache persistence, modernize the Effect runtime, simplify package boundaries and analyzers, share cycle and suppression analysis, keep workflow paths inside the repository, and remove unused internals.
6 changes: 5 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,13 @@ jobs:
- run: pnpm install --frozen-lockfile --prefer-offline

- name: Run tests
if: ${{ matrix.os != 'blacksmith-8vcpu-windows-2025' }}
if: ${{ matrix.os != 'blacksmith-8vcpu-windows-2025' && matrix.node-version != '20.19.0' }}
run: pnpm test

- name: Run Node 20 tests serially
if: ${{ matrix.node-version == '20.19.0' }}
run: pnpm test --concurrency=1

- name: Run Windows tests serially
if: ${{ matrix.os == 'blacksmith-8vcpu-windows-2025' }}
run: pnpm test --concurrency=1
Expand Down
58 changes: 15 additions & 43 deletions .github/workflows/terminal-recording.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ jobs:
timeout-minutes: 15
outputs:
artifact-url: ${{ steps.upload.outputs.artifact-url }}
recording-url: ${{ steps.publish.outputs.recording-url }}
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
with:
Expand All @@ -32,59 +31,36 @@ jobs:
node-version: "22.18.0"
cache: pnpm

- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
with:
go-version: "1.25.8"
cache: false
- name: Install package runner
run: npm install --global @antfu/ni@30.3.0

- name: Install dependencies
run: pnpm install --frozen-lockfile --prefer-offline
run: ni --frozen-lockfile --prefer-offline

- name: Build packages
run: pnpm build

- name: Create recording directory
run: mkdir -p artifacts
run: nr build

- name: Install VHS runtime
- name: Install Terminal Control runtime
run: |
sudo apt-get update
sudo apt-get install --yes ffmpeg
mkdir -p "$RUNNER_TEMP/vhs-bin"
curl --fail --location --silent --show-error \
https://github.com/tsl0922/ttyd/releases/download/1.7.7/ttyd.x86_64 \
--output "$RUNNER_TEMP/vhs-bin/ttyd"
echo "8a217c968aba172e0dbf3f34447218dc015bc4d5e59bf51db2f2cd12b7be4f55 $RUNNER_TEMP/vhs-bin/ttyd" \
| sha256sum --check
chmod +x "$RUNNER_TEMP/vhs-bin/ttyd"
go install github.com/charmbracelet/vhs@v0.11.0
echo "$RUNNER_TEMP/vhs-bin" >> "$GITHUB_PATH"
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
ni --global @kitlangton/terminal-control-linux-x64-gnu@0.6.0
termctrl --version

- name: Render terminal recording
run: vhs scripts/terminal-recording.tape
- name: Run interactive terminal E2E
run: bash scripts/terminal-e2e.sh

- name: Upload recording artifact
id: upload
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: terminal-recording-${{ github.event.pull_request.number }}
path: |
artifacts/react-doctor-terminal.gif
artifacts/react-doctor-terminal.mp4
artifacts/react-doctor-terminal.png
if-no-files-found: error
retention-days: 7

- name: Publish GIF preview
id: publish
run: |
recording_url="$(vhs publish artifacts/react-doctor-terminal.gif)"
case "$recording_url" in
https://vhs.charm.sh/*) ;;
*) echo "Unexpected VHS recording URL: $recording_url" >&2; exit 1 ;;
esac
echo "recording-url=$recording_url" >> "$GITHUB_OUTPUT"

comment:
name: Post recording to PR
needs: record
Expand All @@ -99,24 +75,20 @@ jobs:
env:
ARTIFACT_URL: ${{ needs.record.outputs.artifact-url }}
COMMIT_SHA: ${{ github.event.pull_request.head.sha }}
RECORDING_URL: ${{ needs.record.outputs.recording-url }}
with:
script: |
const marker = "<!-- react-doctor:terminal-recording -->";
const recordingUrl = process.env.RECORDING_URL;
if (!recordingUrl.startsWith("https://vhs.charm.sh/")) {
core.setFailed(`Unexpected VHS recording URL: ${recordingUrl}`);
return;
}
const body = [
marker,
"## Interactive terminal E2E",
"",
`![React Doctor interactive terminal recording](${recordingUrl})`,
`Terminal Control verified the built CLI at \`${process.env.COMMIT_SHA.slice(0, 7)}\` in a real PTY:`,
"",
`Recorded from the built CLI at \`${process.env.COMMIT_SHA.slice(0, 7)}\` in a real terminal. The fixture holds Git busy for three seconds, so \`Scanning...\` must appear immediately after project selection, then exercises the compact interactive report.`,
"- selected a project interactively and observed `Scanning...` before the three-second Git delay completed",
"- waited for the clean result and exercised the compact report",
"- opened copy context and the GitHub Actions confirmation, then cancelled safely",
"",
`[Download the GIF and MP4 artifact](${process.env.ARTIFACT_URL})`,
`[Download the edited MP4 and PNG evidence](${process.env.ARTIFACT_URL})`,
].join("\n");
const { owner, repo } = context.repo;
const issueNumber = context.payload.pull_request.number;
Expand Down
6 changes: 2 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ packages/

## Effect v4 Conventions

Built on `effect@4.0.0-beta.70`. See `tmp/effect/.patterns/effect.md` (cloned reference)
Built on `effect@4.0.0-beta.102`. See `tmp/effect/.patterns/effect.md` (cloned reference)
and `~/Developer/react-doctor-evals/src/` (the application that pioneered these patterns
for this codebase) for canonical examples.

Expand Down Expand Up @@ -158,9 +158,7 @@ for this codebase) for canonical examples.
sibling `*Capture` service (e.g. `ReporterCapture`, `ProgressCapture`).
- `layerNoop` for the production layer that has void-return / discard semantics
(Reporter, Progress). Analyzers (Linter, DeadCode) use `layerOf([])` instead.
- `layerComposite(backends)` for the slot a future second backend plugs into.
- Implementation-specific names: `layerOxlint`, `layerHttp`, `layerNdjson(path)`,
`layerOra(factory)`.
- Implementation-specific names: `layerOxlint`, `layerHttp`, `layerOra(factory)`.

### Schemas

Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@
"smoke:tty-prompt": "python3 scripts/smoke-tty-prompt.py",
"fn-mining": "tsx scripts/fn-mining/run-fn-mining.ts"
},
"dependencies": {
"effect": "4.0.0-beta.102"
},
"devDependencies": {
"@changesets/changelog-github": "^0.7.0",
"@changesets/cli": "^2.31.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
},
"dependencies": {
"@react-doctor/core": "workspace:*",
"effect": "4.0.0-beta.70"
"effect": "4.0.0-beta.102"
},
"devDependencies": {
"@types/node": "^25.6.0"
Expand Down
6 changes: 3 additions & 3 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,12 @@
},
"dependencies": {
"@astrojs/compiler": "^4.0.0",
"@effect/platform-node-shared": "4.0.0-beta.70",
"@effect/platform-node-shared": "4.0.0-beta.102",
"@jridgewell/trace-mapping": "^0.3.31",
"browserslist": "^4.28.1",
"confbox": "^0.2.4",
"deslop-js": "workspace:*",
"effect": "4.0.0-beta.70",
"effect": "4.0.0-beta.102",
"eslint-plugin-react-hooks": "^7.1.1",
"jiti": "^2.7.0",
"oxc-resolver": "^11.24.2",
Expand All @@ -39,7 +39,7 @@
"typescript": ">=5.0.4 <7"
},
"devDependencies": {
"@effect/vitest": "4.0.0-beta.70",
"@effect/vitest": "4.0.0-beta.102",
"@types/node": "^25.6.0",
"@types/picomatch": "^4.0.3",
"@types/semver": "^7.7.1"
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/build-json-report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ interface BuildJsonReportInput {
directory: string;
mode: JsonReportMode;
diff: DiffInfo | null;
scans: Array<{ directory: string; result: InspectResult }>;
scans: ReadonlyArray<{ directory: string; result: InspectResult }>;
skippedProjects?: ReadonlyArray<JsonReportSkippedProject>;
totalElapsedMilliseconds: number;
/**
Expand Down
134 changes: 17 additions & 117 deletions packages/core/src/calculate-score.ts
Original file line number Diff line number Diff line change
@@ -1,121 +1,21 @@
import { gzipSync } from "node:zlib";
import * as Option from "effect/Option";
import * as Schema from "effect/Schema";
import { FETCH_TIMEOUT_MS, SCORE_API_URL } from "./constants.js";
import type { Diagnostic, ProjectInfo, ScoreResult } from "./types/index.js";
import { redactSensitiveText } from "./utils/redact-sensitive-text.js";
import { scrubSensitivePaths } from "./utils/scrub-sensitive-paths.js";
import * as Effect from "effect/Effect";
import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
import * as HttpClient from "effect/unstable/http/HttpClient";
import type { CalculateScoreOptions, Diagnostic, ScoreResult } from "./types/index.js";
import { requestScore } from "./request-score.js";

// Score API response shape, including the optional per-rule `priority`/`tier`
// payload. `Schema.Struct` ignores unknown fields, so extra keys (e.g.
// `stored`) pass through harmlessly.
const RulePrioritySchema = Schema.Struct({
priority: Schema.NullOr(Schema.Number),
tier: Schema.Literals(["P0", "P1", "P2", "P3"]),
});
export type { CalculateScoreOptions, ScoreRequestMetadata } from "./types/score.js";

const ScoreApiResponseSchema = Schema.Struct({
score: Schema.Number,
label: Schema.String,
rules: Schema.optional(Schema.Record(Schema.String, RulePrioritySchema)),
});

// Decode the score API response; any shape mismatch drops the whole result to
// null, so a malformed payload simply falls back to "no score" (and severity
// ordering at render time) rather than throwing.
const parseScoreResult = (value: unknown): ScoreResult | null =>
Option.getOrNull(Schema.decodeUnknownOption(ScoreApiResponseSchema)(value));

const sanitizeScoreDiagnostics = (
diagnostics: Diagnostic[],
): Omit<Diagnostic, "fileContext" | "fixGroupId">[] =>
diagnostics.map(({ filePath, fileContext: _fileContext, fixGroupId: _fixGroupId, ...rest }) => ({
...rest,
filePath: redactSensitiveText(scrubSensitivePaths(filePath)),
}));

const isAbortError = (error: unknown): boolean =>
error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");

const describeFailure = (error: unknown): string => {
if (isAbortError(error)) return `timed out after ${FETCH_TIMEOUT_MS / 1000}s`;
if (error instanceof Error && error.message) return error.message;
return String(error);
};

export interface CalculateScoreOptions {
/** Marks the run as CI-originated. */
isCi?: boolean;
metadata?: ScoreRequestMetadata;
}

export interface ScoreRequestMetadata {
repo?: string;
sha?: string;
framework?: ProjectInfo["framework"];
reactVersion?: string;
sourceFileCount?: number;
defaultBranch?: string;
doctorVersion?: string;
runId?: string;
githubEventName?: string;
githubActorAssociation?: string;
githubViewerPermission?: string;
}

export const calculateScore = async (
export const calculateScore = (
diagnostics: Diagnostic[],
options: CalculateScoreOptions = {},
): Promise<ScoreResult | null> => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
const requestUrl = options.isCi ? `${SCORE_API_URL}?ci=1` : SCORE_API_URL;

try {
const requestBody = JSON.stringify({
diagnostics: sanitizeScoreDiagnostics(diagnostics),
...(options.metadata?.repo ? { repo: options.metadata.repo } : {}),
...(options.metadata?.sha ? { sha: options.metadata.sha } : {}),
...(options.metadata?.framework ? { framework: options.metadata.framework } : {}),
...(options.metadata?.reactVersion ? { reactVersion: options.metadata.reactVersion } : {}),
...(typeof options.metadata?.sourceFileCount === "number"
? { sourceFileCount: options.metadata.sourceFileCount }
: {}),
...(options.metadata?.defaultBranch ? { defaultBranch: options.metadata.defaultBranch } : {}),
...(options.metadata?.doctorVersion ? { doctorVersion: options.metadata.doctorVersion } : {}),
...(options.metadata?.runId ? { runId: options.metadata.runId } : {}),
...(options.metadata?.githubEventName
? { githubEventName: options.metadata.githubEventName }
: {}),
...(options.metadata?.githubActorAssociation
? { githubActorAssociation: options.metadata.githubActorAssociation }
: {}),
...(options.metadata?.githubViewerPermission
? { githubViewerPermission: options.metadata.githubViewerPermission }
: {}),
});
const compressedBody = gzipSync(requestBody);

const response = await fetch(requestUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Encoding": "gzip",
},
body: compressedBody,
signal: controller.signal,
});

if (!response.ok) {
console.warn(`[react-doctor] Score API returned ${response.status} ${response.statusText}`);
return null;
}

return parseScoreResult(await response.json());
} catch (error) {
console.warn(`[react-doctor] Score API unreachable (${describeFailure(error)})`);
return null;
} finally {
clearTimeout(timeoutId);
}
};
): Promise<ScoreResult | null> =>
Effect.runPromise(
Effect.gen(function* () {
const httpClient = yield* HttpClient.HttpClient;
return yield* requestScore(httpClient, diagnostics, options);
}).pipe(
Effect.provide(FetchHttpClient.layer),
Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch),
),
);
6 changes: 1 addition & 5 deletions packages/core/src/check-dead-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
import * as fs from "node:fs";
import * as path from "node:path";
import type { Diagnostic } from "./types/index.js";
import type { DeadCodeSummaryCacheStats } from "./types/dead-code.js";
import { collectDeadCodePatterns } from "./dead-code/collect-dead-code-patterns.js";
import {
collectAnalyzedFileStats,
Expand Down Expand Up @@ -91,11 +92,6 @@ interface CheckDeadCodeOptions {
readonly onSummaryCacheStats?: (stats: DeadCodeSummaryCacheStats) => void;
}

interface DeadCodeSummaryCacheStats {
readonly hits: number;
readonly misses: number;
}

interface DeadCodeWorkerInput {
readonly rootDirectory: string;
readonly entryPatterns: ReadonlyArray<string>;
Expand Down
12 changes: 1 addition & 11 deletions packages/core/src/check-reduced-motion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as path from "node:path";
import { MOTION_LIBRARY_PACKAGES } from "oxlint-plugin-react-doctor/core";
import ts from "typescript";
import type { Diagnostic } from "./types/index.js";
import { getImportModuleSource } from "./utils/get-import-module-source.js";
import { getTypescriptScriptKind } from "./utils/get-typescript-script-kind.js";
import { unwrapTypescriptExpression } from "./utils/unwrap-typescript-expression.js";
import { walkSourceTreeFiles } from "./utils/walk-source-tree-files.js";
Expand Down Expand Up @@ -90,17 +91,6 @@ const classifyMotionExport = (exportName: string): MotionExpressionEvidence => (
isReducedMotionHook: exportName === REDUCED_MOTION_HOOK_EXPORT_NAME,
});

const getImportModuleSource = (node: ts.Node): string | null => {
let currentNode: ts.Node | undefined = node;
while (currentNode) {
if (ts.isImportDeclaration(currentNode) && ts.isStringLiteral(currentNode.moduleSpecifier)) {
return currentNode.moduleSpecifier.text;
}
currentNode = currentNode.parent;
}
return null;
};

const getImportedBindingEvidence = (
declaration: ts.Declaration,
typeChecker: ts.TypeChecker,
Expand Down
Loading
Loading