Skip to content
Open
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
46 changes: 33 additions & 13 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,16 @@ runs:
with:
node-version: ${{ inputs.node-version }}

- name: Install pnpm and build react-doctor
shell: bash
env:
GITHUB_ACTION_PATH: ${{ github.action_path }}
run: |
npm install -g pnpm@10
cd "$GITHUB_ACTION_PATH"
pnpm install --no-frozen-lockfile
pnpm build

- shell: bash
env:
INPUT_DIRECTORY: ${{ inputs.directory }}
Expand All @@ -47,25 +57,30 @@ runs:
INPUT_DIFF: ${{ inputs.diff }}
INPUT_GITHUB_TOKEN: ${{ inputs.github-token }}
INPUT_FAIL_ON: ${{ inputs.fail-on }}
GITHUB_ACTION_PATH: ${{ github.action_path }}
run: |
DOCTOR="node $GITHUB_ACTION_PATH/packages/react-doctor/dist/cli.js"

FLAGS="--fail-on $INPUT_FAIL_ON"
if [ "$INPUT_VERBOSE" = "true" ]; then FLAGS="$FLAGS --verbose"; fi
if [ -n "$INPUT_PROJECT" ]; then FLAGS="$FLAGS --project $INPUT_PROJECT"; fi
if [ -n "$INPUT_DIFF" ]; then FLAGS="$FLAGS --diff $INPUT_DIFF"; fi

if [ -n "$INPUT_GITHUB_TOKEN" ]; then
npx -y react-doctor@latest "$INPUT_DIRECTORY" $FLAGS | tee /tmp/react-doctor-output.txt
$DOCTOR "$INPUT_DIRECTORY" $FLAGS --hide-branding | tee /tmp/react-doctor-output.txt
else
npx -y react-doctor@latest "$INPUT_DIRECTORY" $FLAGS
$DOCTOR "$INPUT_DIRECTORY" $FLAGS
fi

- id: score
if: always()
shell: bash
env:
INPUT_DIRECTORY: ${{ inputs.directory }}
GITHUB_ACTION_PATH: ${{ github.action_path }}
run: |
SCORE=$(npx -y react-doctor@latest "$INPUT_DIRECTORY" --score 2>/dev/null | tail -1 | tr -d '[:space:]')
DOCTOR="node $GITHUB_ACTION_PATH/packages/react-doctor/dist/cli.js"
SCORE=$($DOCTOR "$INPUT_DIRECTORY" --score 2>/dev/null | tail -1 | tr -d '[:space:]')
if [[ -n "$SCORE" && "$SCORE" =~ ^[0-9]+$ ]]; then
echo "score=$SCORE" >> "$GITHUB_OUTPUT"
fi
Expand All @@ -78,26 +93,31 @@ runs:
const fs = require("fs");
const path = "/tmp/react-doctor-output.txt";
if (!fs.existsSync(path)) return;
const output = fs.readFileSync(path, "utf8").trim();
if (!output) return;
const raw = fs.readFileSync(path, "utf8").trim();
if (!raw) return;

const stripped = raw.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, "");
const htmlStart = stripped.indexOf("<h3>");
const clean = htmlStart !== -1 ? stripped.substring(htmlStart).trim() : stripped.trim();

const marker = "<!-- react-doctor -->";
const body = `${marker}\n## 🩺 React Doctor\n\n\`\`\`\n${output}\n\`\`\``;
const body = `${marker}\n## 🩺 React Doctor\n\n${clean}`;

const { data: comments } = await github.rest.issues.listComments({
...context.repo,
issue_number: context.issue.number,
});
const prev = comments.find((c) => c.body?.startsWith(marker));
if (prev) {
await github.rest.issues.deleteComment({
await github.rest.issues.updateComment({
...context.repo,
comment_id: prev.id,
body,
});
} else {
await github.rest.issues.createComment({
...context.repo,
issue_number: context.issue.number,
body,
});
}

await github.rest.issues.createComment({
...context.repo,
issue_number: context.issue.number,
body,
});
1,142 changes: 1,142 additions & 0 deletions bun.lock

Large diffs are not rendered by default.

8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
"dependencies": {
"commander": "^14.0.3",
"knip": "^5.83.1",
"picocolors": "^1.1.1"
"picocolors": "^1.1.1",
"pnpm": "^10.33.0"
},
"devDependencies": {
"@changesets/cli": "^2.27.0",
Expand All @@ -35,5 +36,8 @@
"typescript": "^5.7.0",
"vitest": "^4.0.18"
},
"packageManager": "[email protected]"
"packageManager": "[email protected]",
"workspaces": [
"packages/*"
]
}
11 changes: 11 additions & 0 deletions packages/react-doctor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ Options:
--diff [base] scan only files changed vs base branch
--ami enable Ami-related prompts
--fix open Ami to auto-fix all issues
--hide-branding suppress ASCII branding, output clean HTML report
-h, --help display help for command
```

Expand Down Expand Up @@ -153,6 +154,16 @@ const result = await diagnose(".", {
});
```

To generate a clean HTML report (the same output as `--hide-branding`) from the API:

```js
import { diagnose, buildNoBrandingReport } from "react-doctor/api";

const result = await diagnose("./path/to/your/react-project");
const html = buildNoBrandingReport(result.diagnostics, result.score);
// Returns GitHub-compatible HTML with score, counts, and a collapsible diagnostics table
```

Each diagnostic has the following shape:

```ts
Expand Down
13 changes: 8 additions & 5 deletions packages/react-doctor/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ interface CliFlags {
project?: string;
diff?: boolean | string;
failOn: string;
hideBranding: boolean;
}

const VALID_FAIL_ON_LEVELS = new Set<FailOnLevel>(["error", "warning", "none"]);
Expand Down Expand Up @@ -90,6 +91,7 @@ const resolveCliScanOptions = (
verbose: isCliOverride("verbose") ? Boolean(flags.verbose) : (userConfig?.verbose ?? false),
scoreOnly: flags.score,
offline: flags.offline,
noBranding: flags.hideBranding,
};
};

Expand Down Expand Up @@ -146,14 +148,15 @@ const program = new Command()
.option("--ami", "enable Ami-related prompts")
.option("--fail-on <level>", "exit with error code on diagnostics: error, warning, none", "none")
.option("--fix", "open Ami to auto-fix all issues")
.option("--hide-branding", "suppress branding for clean CI/PR output")
.action(async (directory: string, flags: CliFlags) => {
const isScoreOnly = flags.score;

try {
const resolvedDirectory = path.resolve(directory);
const userConfig = loadConfig(resolvedDirectory);

if (!isScoreOnly) {
if (!isScoreOnly && !flags.hideBranding) {
logger.log(`react-doctor v${VERSION}`);
logger.break();
}
Expand All @@ -178,7 +181,7 @@ const program = new Command()
isScoreOnly,
);

if (isDiffMode && diffInfo && !isScoreOnly) {
if (isDiffMode && diffInfo && !isScoreOnly && !flags.hideBranding) {
if (diffInfo.isCurrentChanges) {
logger.log("Scanning uncommitted changes");
} else {
Expand All @@ -198,7 +201,7 @@ const program = new Command()
if (projectDiffInfo) {
const changedSourceFiles = filterSourceFiles(projectDiffInfo.changedFiles);
if (changedSourceFiles.length === 0) {
if (!isScoreOnly) {
if (!isScoreOnly && !flags.hideBranding) {
logger.dim(`No changed source files in ${projectDirectory}, skipping.`);
logger.break();
}
Expand All @@ -208,13 +211,13 @@ const program = new Command()
}
}

if (!isScoreOnly) {
if (!isScoreOnly && !flags.hideBranding) {
logger.dim(`Scanning ${projectDirectory}...`);
logger.break();
}
const scanResult = await scan(projectDirectory, { ...scanOptions, includePaths });
allDiagnostics.push(...scanResult.diagnostics);
if (!isScoreOnly) {
if (!isScoreOnly && !flags.hideBranding) {
logger.break();
}
}
Expand Down
1 change: 1 addition & 0 deletions packages/react-doctor/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { runOxlint } from "./utils/run-oxlint.js";

export type { Diagnostic, DiffInfo, ProjectInfo, ReactDoctorConfig, ScoreResult };
export { getDiffInfo, filterSourceFiles } from "./utils/get-diff-files.js";
export { buildNoBrandingReport } from "./utils/no-branding-diagnostics.js";

export interface DiagnoseOptions {
lint?: boolean;
Expand Down
43 changes: 33 additions & 10 deletions packages/react-doctor/src/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
isNvmInstalled,
resolveNodeForOxlint,
} from "./utils/resolve-compatible-node.js";
import { buildNoBrandingReport } from "./utils/no-branding-diagnostics.js";
import { runKnip } from "./utils/run-knip.js";
import { runOxlint } from "./utils/run-oxlint.js";
import { spinner } from "./utils/spinner.js";
Expand Down Expand Up @@ -319,7 +320,13 @@ const printSummary = (
totalSourceFileCount: number,
noScoreMessage: string,
isOffline: boolean,
noBranding: boolean,
): void => {
if (noBranding) {
logger.log(buildNoBrandingReport(diagnostics, scoreResult));
return;
}

const summaryFramedLines = [
...buildBrandingLines(scoreResult, noScoreMessage),
buildCountsSummaryLine(diagnostics, totalSourceFileCount, elapsedMilliseconds),
Expand Down Expand Up @@ -405,18 +412,27 @@ interface ResolvedScanOptions {
verbose: boolean;
scoreOnly: boolean;
offline: boolean;
noBranding: boolean;
includePaths: string[];
}

const SCAN_DEFAULTS: ResolvedScanOptions = {
lint: true,
deadCode: true,
verbose: false,
scoreOnly: false,
offline: false,
noBranding: false,
includePaths: [],
};

const mergeScanOptions = (
inputOptions: ScanOptions,
userConfig: ReactDoctorConfig | null,
): ResolvedScanOptions => ({
lint: inputOptions.lint ?? userConfig?.lint ?? true,
deadCode: inputOptions.deadCode ?? userConfig?.deadCode ?? true,
verbose: inputOptions.verbose ?? userConfig?.verbose ?? false,
scoreOnly: inputOptions.scoreOnly ?? false,
offline: inputOptions.offline ?? false,
...SCAN_DEFAULTS,
...userConfig,
...inputOptions,
includePaths: inputOptions.includePaths ?? [],
});

Expand Down Expand Up @@ -571,6 +587,10 @@ export const scan = async (
}

if (diagnostics.length === 0) {
if (options.noBranding) {
logger.log(buildNoBrandingReport(diagnostics, scoreResult));
return { diagnostics, scoreResult, skippedChecks };
}
if (hasSkippedChecks) {
const skippedLabel = skippedChecks.join(" and ");
logger.warn(
Expand All @@ -581,18 +601,20 @@ export const scan = async (
}
logger.break();
if (hasSkippedChecks) {
printBranding();
if (!options.noBranding) printBranding();
logger.dim(" Score not shown — some checks could not complete.");
} else if (scoreResult) {
printBranding(scoreResult.score);
printScoreGauge(scoreResult.score, scoreResult.label);
if (!options.noBranding) {
printBranding(scoreResult.score);
printScoreGauge(scoreResult.score, scoreResult.label);
}
} else {
logger.dim(` ${noScoreMessage}`);
}
return { diagnostics, scoreResult, skippedChecks };
}

printDiagnostics(diagnostics, options.verbose);
if (!options.noBranding) printDiagnostics(diagnostics, options.verbose);

const displayedSourceFileCount = isDiffMode ? includePaths.length : projectInfo.sourceFileCount;

Expand All @@ -604,9 +626,10 @@ export const scan = async (
displayedSourceFileCount,
noScoreMessage,
options.offline,
options.noBranding,
);

if (hasSkippedChecks) {
if (hasSkippedChecks && !options.noBranding) {
const skippedLabel = skippedChecks.join(" and ");
logger.break();
logger.warn(` Note: ${skippedLabel} checks failed — score may be incomplete.`);
Expand Down
1 change: 1 addition & 0 deletions packages/react-doctor/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ export interface ScanOptions {
verbose?: boolean;
scoreOnly?: boolean;
offline?: boolean;
noBranding?: boolean;
includePaths?: string[];
}

Expand Down
Loading