Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 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.
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