diff --git a/.changeset/sparkly-seas-eat.md b/.changeset/sparkly-seas-eat.md deleted file mode 100644 index ccbf1f0..0000000 --- a/.changeset/sparkly-seas-eat.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@framework-doctor/react': patch -'@framework-doctor/svelte': patch ---- - -Initial release for Svelte and React doctors diff --git a/.changeset/unified-cli.md b/.changeset/unified-cli.md deleted file mode 100644 index 2bf98a2..0000000 --- a/.changeset/unified-cli.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@framework-doctor/cli': minor ---- - -Add unified CLI that auto-detects framework from package.json and runs the appropriate doctor. Run `npx @framework-doctor/cli .` instead of `npx @framework-doctor/svelte .`. diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8e1be2a --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +FRAMEWORK_DOCTOR_TELEMETRY_URL=https://your-project-ref.supabase.co/functions/v1/telemetry +FRAMEWORK_DOCTOR_TELEMETRY_KEY=replace-with-shared-secret-if-enabled diff --git a/README.md b/README.md index a9b0bcd..bc4201a 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![npm version](https://img.shields.io/npm/v/@framework-doctor/cli.svg)](https://www.npmjs.com/package/@framework-doctor/cli) [![npm downloads](https://img.shields.io/npm/dm/@framework-doctor/cli.svg)](https://www.npmjs.com/package/@framework-doctor/cli) -Framework Doctor auto-detects your framework and runs the right health check. Currently supports Svelte; React, Vue, and Angular coming soon. +Framework Doctor auto-detects your framework and runs the right health check. Supports **Svelte** and **React**; Vue and Angular coming soon. ## Quick start @@ -16,7 +16,8 @@ npx -y @framework-doctor/cli . Or run a specific doctor directly: ```bash -npx -y @framework-doctor/svelte . +npx -y @framework-doctor/react . # React +npx -y @framework-doctor/svelte . # Svelte ``` ## Try it @@ -39,6 +40,13 @@ See [examples/README.md](examples/README.md) for more demo projects and commands - `npx -y @framework-doctor/cli .` - auto-detect framework and run the right doctor - `npx -y @framework-doctor/cli ./path/to/project` - scan a specific project directory +**React (direct):** + +- `npx -y @framework-doctor/react .` - run a full scan +- `npx -y @framework-doctor/react ./path/to/project` - scan a specific project directory +- `npx -y @framework-doctor/react . --verbose` - include file and line details +- `npx -y @framework-doctor/react . --score` - print only the numeric score (CI-friendly) + **Svelte (direct):** - `npx -y @framework-doctor/svelte .` - run a full scan @@ -51,6 +59,8 @@ See [examples/README.md](examples/README.md) for more demo projects and commands ## Options +Svelte doctor: + ```txt Usage: svelte-doctor [directory] [options] @@ -62,12 +72,15 @@ Options: --verbose show file details per rule --score output only the score -y, --yes skip prompts + --no-analytics disable anonymous analytics --project select workspace project (comma-separated) --diff [base] scan only changed files vs base branch --offline skip remote scoring (local score only) -h, --help display help for command ``` +React doctor options: `--no-lint`, `--no-dead-code`, `--verbose`, `--score`, `--no-analytics`, `--project`, `--diff`. See [packages/react-doctor/README.md](packages/react-doctor/README.md). + ## Security checks Svelte Doctor includes a security scan that flags: @@ -80,6 +93,10 @@ Plus oxlint's `no-eval` and svelte-check's `a11y_invalid_attribute` (e.g. `javas To ignore a rule: `"svelte-doctor/no-at-html"`, `"svelte-doctor/no-new-function"`, `"svelte-doctor/no-implied-eval"`. +## Analytics + +Both doctors optionally send anonymous usage data when you opt in. Data is stored in your Supabase (see [supabase/README.md](supabase/README.md)). If your function enforces `TELEMETRY_KEY`, set `FRAMEWORK_DOCTOR_TELEMETRY_KEY` in the client environment. To disable: `--no-analytics`, `"analytics": false` in config, or `DO_NOT_TRACK=1`. + ## Configuration Create `svelte-doctor.config.json`: @@ -94,7 +111,8 @@ Create `svelte-doctor.config.json`: "jsTsLint": true, "deadCode": true, "verbose": false, - "diff": false + "diff": false, + "analytics": true } ``` diff --git a/examples/README.md b/examples/README.md index e6982eb..4c4cb4c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -4,12 +4,7 @@ Demo projects to try Framework Doctor. Each example includes **intentional issue ## Svelte: demo-app -A minimal SvelteKit app with: - -- **Security issues** — `eval()`, `new Function()`, `setTimeout("string")` in `src/lib/SecurityTest.ts` -- **Dead code** — Unused exports in `src/lib/orphanUtils.ts` (knip will flag them) -- **Legacy Svelte** — `DoctorTestComponent.svelte` uses export let, createEventDispatcher, onMount -- **XSS** — `{@html}` and `javascript:` URLs in `+page.svelte` +A minimal SvelteKit app with intentional issues. See [svelte/demo-app/README.md](svelte/demo-app/README.md) for details. ### Run from the repo @@ -29,22 +24,17 @@ pnpm exec svelte-doctor examples/svelte/demo-app ### Run with npx (no clone) ```bash -# After cloning, from repo root -cd framework-doctor -pnpm install -npx -y @framework-doctor/cli examples/svelte/demo-app +npx -y @framework-doctor/cli /path/to/framework-doctor/examples/svelte/demo-app ``` -Or from anywhere with an absolute path: +Or from repo root after `pnpm install`: ```bash -npx -y @framework-doctor/cli /path/to/framework-doctor/examples/svelte/demo-app +npx -y @framework-doctor/cli examples/svelte/demo-app ``` ### What to expect -The doctor will report: - - **Errors** — Security findings (eval, new Function, implied eval, {@html}, javascript: URLs) - **Warnings** — Dead/unused code, lint issues, legacy Svelte patterns - **Score** — A 0–100 health score for the project diff --git a/examples/svelte/demo-app/CHANGELOG.md b/examples/svelte/demo-app/CHANGELOG.md new file mode 100644 index 0000000..cd24ad0 --- /dev/null +++ b/examples/svelte/demo-app/CHANGELOG.md @@ -0,0 +1,13 @@ +# framework-doctor-svelte-demo + +## 1.0.2 + +### Patch Changes + +- Version alignment + +## 1.0.1 + +### Patch Changes + +- added telemetry and refactored core diff --git a/examples/svelte/demo-app/README.md b/examples/svelte/demo-app/README.md index 4b1ca92..bfa9aa1 100644 --- a/examples/svelte/demo-app/README.md +++ b/examples/svelte/demo-app/README.md @@ -4,10 +4,12 @@ A minimal SvelteKit app with **intentional issues** for testing [Framework Docto ## Run the doctor -From the framework-doctor repo root: +From the framework-doctor repo root (after `pnpm install` and `pnpm build`): ```bash pnpm exec framework-doctor examples/svelte/demo-app +# or directly: +pnpm exec svelte-doctor examples/svelte/demo-app ``` ## Intentional issues diff --git a/examples/svelte/demo-app/package.json b/examples/svelte/demo-app/package.json index 71e0b43..9ff6302 100644 --- a/examples/svelte/demo-app/package.json +++ b/examples/svelte/demo-app/package.json @@ -1,6 +1,6 @@ { "name": "framework-doctor-svelte-demo", - "version": "1.0.0", + "version": "1.0.2", "private": true, "description": "Demo SvelteKit app for Framework Doctor - includes intentional issues", "type": "module", @@ -11,7 +11,7 @@ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json" }, "devDependencies": { - "@sveltejs/adapter-auto": "^4.0.0", + "@sveltejs/adapter-node": "^4.0.0", "@sveltejs/kit": "^2.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0", "svelte": "^5.0.0", diff --git a/examples/svelte/demo-app/svelte.config.js b/examples/svelte/demo-app/svelte.config.js index 5853b9e..9e49a2d 100644 --- a/examples/svelte/demo-app/svelte.config.js +++ b/examples/svelte/demo-app/svelte.config.js @@ -1,4 +1,4 @@ -import adapter from '@sveltejs/adapter-auto'; +import adapter from '@sveltejs/adapter-node'; import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; /** @type {import('@sveltejs/kit').Config} */ diff --git a/package.json b/package.json index eaf9277..d0158d2 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "license": "MIT", "description": "Framework Doctor monorepo - diagnose framework project health", "scripts": { - "build": "turbo run build", + "build": "turbo run build --filter=./packages/*", "build:svelte": "turbo run build --filter=@framework-doctor/svelte", "build:cli": "turbo run build --filter=@framework-doctor/cli", "demo": "pnpm build && pnpm exec framework-doctor examples/svelte/demo-app", @@ -15,13 +15,13 @@ "dev:doctor": "turbo run dev --filter=@framework-doctor/svelte", "format": "prettier --write .", "format:check": "prettier --check .", - "lint": "turbo run lint", + "lint": "turbo run lint --filter=./packages/*", "lint:doctor": "turbo run lint --filter=@framework-doctor/svelte", "lint:fix": "turbo run lint:fix --filter=@framework-doctor/svelte", - "typecheck": "turbo run typecheck", - "test": "turbo run test", + "typecheck": "turbo run typecheck --filter=./packages/*", + "test": "turbo run test --filter=./packages/*", "test:doctor": "turbo run test --filter=@framework-doctor/svelte --concurrency=1", - "quality:check": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm test", + "quality:check": "turbo run format:check lint typecheck test --filter=!./examples/*", "changeset": "changeset", "version": "changeset version", "release": "pnpm build && changeset publish", diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md new file mode 100644 index 0000000..ad913b3 --- /dev/null +++ b/packages/cli/CHANGELOG.md @@ -0,0 +1,39 @@ +# @framework-doctor/cli + +## 1.0.2 + +### Patch Changes + +- Version alignment +- added telemetry and refactored core +- Updated dependencies + - @framework-doctor/svelte@1.0.2 + - @framework-doctor/react@1.0.2 + +## 1.1.0 + +### Minor Changes + +- b0823b0: Add unified CLI that auto-detects framework from package.json and runs the appropriate doctor. Run `npx @framework-doctor/cli .` instead of `npx @framework-doctor/svelte .`. + +### Patch Changes + +- Updated docs +- Updated dependencies [cb322c3] +- Updated dependencies + - @framework-doctor/react@1.0.1 + - @framework-doctor/svelte@1.0.1 + +## 1.1.0 + +### Minor Changes + +- Updated docs +- b0823b0: Add unified CLI that auto-detects framework from package.json and runs the appropriate doctor. Run `npx @framework-doctor/cli .` instead of `npx @framework-doctor/svelte .`. + +### Patch Changes + +- Updated dependencies +- Updated dependencies [cb322c3] + - @framework-doctor/react@1.1.0 + - @framework-doctor/svelte@1.1.0 diff --git a/packages/cli/package.json b/packages/cli/package.json index 4da34bd..5f36e2d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@framework-doctor/cli", - "version": "1.0.0", + "version": "1.0.2", "description": "Auto-detect framework and run the right doctor", "author": { "name": "Pitis Radu", diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md new file mode 100644 index 0000000..4438a9f --- /dev/null +++ b/packages/core/CHANGELOG.md @@ -0,0 +1,13 @@ +# @framework-doctor/core + +## 1.0.2 + +### Patch Changes + +- Version alignment + +## 1.0.1 + +### Patch Changes + +- added telemetry and refactored core diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..3ab8af2 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,43 @@ +{ + "name": "@framework-doctor/core", + "version": "1.0.2", + "description": "Shared utilities for Framework Doctor (telemetry, config)", + "author": { + "name": "Pitis Radu", + "url": "https://github.com/pitis" + }, + "publishConfig": { + "access": "public" + }, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "rimraf dist && cross-env NODE_ENV=production tsdown", + "lint": "oxlint src", + "lint:fix": "oxlint --fix src", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/node": "catalog:", + "cross-env": "catalog:", + "oxlint": "catalog:", + "rimraf": "catalog:", + "tsdown": "catalog:", + "typescript": "catalog:" + }, + "packageManager": "pnpm@10.30.0", + "dependencies": { + "ora": "catalog:", + "picocolors": "catalog:" + } +} diff --git a/packages/core/src/calculate-score.ts b/packages/core/src/calculate-score.ts new file mode 100644 index 0000000..b046f1f --- /dev/null +++ b/packages/core/src/calculate-score.ts @@ -0,0 +1,117 @@ +import { + ERROR_RULE_PENALTY, + ERROR_VOLUME_COEFFICIENT, + PERFECT_SCORE, + SCORE_BLOCKING_CHECK_CAP, + SCORE_GOOD_THRESHOLD, + SCORE_OK_THRESHOLD, + SPREAD_PENALTY_MAX, + WARNING_RULE_PENALTY, + WARNING_VOLUME_COEFFICIENT, +} from './constants.js'; +import type { Diagnostic, ScoreBreakdown, ScoreGuardrailInput, ScoreResult } from './types.js'; + +const getScoreLabel = (score: number): string => { + if (score >= SCORE_GOOD_THRESHOLD) return 'Great'; + if (score >= SCORE_OK_THRESHOLD) return 'Needs work'; + return 'Critical'; +}; + +const countMetrics = ( + diagnostics: Diagnostic[], +): { + errorCount: number; + warningCount: number; + uniqueErrorRules: number; + uniqueWarningRules: number; + filesWithDiagnostics: number; +} => { + const errorRules = new Set(); + const warningRules = new Set(); + const filesWithDiagnostics = new Set(); + + for (const diagnostic of diagnostics) { + filesWithDiagnostics.add(diagnostic.filePath); + const ruleKey = `${diagnostic.plugin}/${diagnostic.rule}`; + if (diagnostic.severity === 'error') { + errorRules.add(ruleKey); + } else { + warningRules.add(ruleKey); + } + } + + const errorCount = diagnostics.filter((d) => d.severity === 'error').length; + const warningCount = diagnostics.filter((d) => d.severity === 'warning').length; + + return { + errorCount, + warningCount, + uniqueErrorRules: errorRules.size, + uniqueWarningRules: warningRules.size, + filesWithDiagnostics: filesWithDiagnostics.size, + }; +}; + +export const calculateScore = ( + diagnostics: Diagnostic[], + totalFilesScanned: number, + guardrailInput: ScoreGuardrailInput = {}, +): ScoreResult => { + if (diagnostics.length === 0) { + return { + score: PERFECT_SCORE, + label: getScoreLabel(PERFECT_SCORE), + }; + } + + const { errorCount, warningCount, uniqueErrorRules, uniqueWarningRules, filesWithDiagnostics } = + countMetrics(diagnostics); + + const typesPenalty = + uniqueErrorRules * ERROR_RULE_PENALTY + uniqueWarningRules * WARNING_RULE_PENALTY; + const volumePenalty = + ERROR_VOLUME_COEFFICIENT * Math.sqrt(errorCount) + + WARNING_VOLUME_COEFFICIENT * Math.sqrt(warningCount); + const spreadPenalty = + totalFilesScanned > 0 ? SPREAD_PENALTY_MAX * (filesWithDiagnostics / totalFilesScanned) : 0; + + const totalPenalty = typesPenalty + volumePenalty + spreadPenalty; + const uncappedScore = Math.max( + 0, + Math.min(PERFECT_SCORE, Math.round(PERFECT_SCORE - totalPenalty)), + ); + const guardrailReasons: string[] = []; + if (guardrailInput.didBuildFail) { + guardrailReasons.push('build failed'); + } + if (guardrailInput.didTestsFail) { + guardrailReasons.push('tests failed'); + } + if (guardrailInput.didTypecheckFail) { + guardrailReasons.push('typecheck failed'); + } + if (guardrailInput.hasHighOrCriticalSecurityFindings) { + guardrailReasons.push('high/critical security findings'); + } + + const didApplyGuardrail = guardrailReasons.length > 0; + const score = didApplyGuardrail + ? Math.min(uncappedScore, SCORE_BLOCKING_CHECK_CAP) + : uncappedScore; + + const breakdown: ScoreBreakdown = { + typesPenalty, + volumePenalty, + spreadPenalty, + didApplyGuardrail, + guardrailReasons, + uniqueErrorRules, + uniqueWarningRules, + errorCount, + warningCount, + filesWithDiagnostics, + totalFilesScanned, + }; + + return { score, label: getScoreLabel(score), breakdown }; +}; diff --git a/packages/core/src/cli-options.ts b/packages/core/src/cli-options.ts new file mode 100644 index 0000000..7a03668 --- /dev/null +++ b/packages/core/src/cli-options.ts @@ -0,0 +1,12 @@ +export const ANALYTICS_OPTION_FLAGS = '--no-analytics'; +export const ANALYTICS_OPTION_DESCRIPTION = 'disable anonymous analytics'; + +export const ANALYTICS_CONFIG_KEY = 'analytics'; + +interface ProgramWithOption { + option(flags: string, description: string): unknown; +} + +export const addAnalyticsOption = (program: ProgramWithOption): void => { + program.option(ANALYTICS_OPTION_FLAGS, ANALYTICS_OPTION_DESCRIPTION); +}; diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts new file mode 100644 index 0000000..acacd84 --- /dev/null +++ b/packages/core/src/constants.ts @@ -0,0 +1,29 @@ +export const TELEMETRY_FETCH_TIMEOUT_MS = 3_000; + +export const GIT_LS_FILES_MAX_BUFFER_BYTES = 10 * 1024 * 1024; + +export const PERFECT_SCORE = 100; + +export const SCORE_GOOD_THRESHOLD = 75; + +export const SCORE_OK_THRESHOLD = 50; + +export const SCORE_BAR_WIDTH_CHARS = 50; + +export const MILLISECONDS_PER_SECOND = 1000; + +export const SUMMARY_BOX_OUTER_INDENT_CHARS = 2; + +export const SUMMARY_BOX_HORIZONTAL_PADDING_CHARS = 1; + +export const ERROR_RULE_PENALTY = 2.5; + +export const WARNING_RULE_PENALTY = 1; + +export const ERROR_VOLUME_COEFFICIENT = 1.8; + +export const WARNING_VOLUME_COEFFICIENT = 0.8; + +export const SPREAD_PENALTY_MAX = 12; + +export const SCORE_BLOCKING_CHECK_CAP = 59; diff --git a/packages/core/src/get-diff-files.ts b/packages/core/src/get-diff-files.ts new file mode 100644 index 0000000..819a9f0 --- /dev/null +++ b/packages/core/src/get-diff-files.ts @@ -0,0 +1,114 @@ +import { execSync } from 'node:child_process'; +import type { DiffInfo } from './types.js'; + +const DEFAULT_BRANCH_CANDIDATES = ['main', 'master']; + +const getCurrentBranch = (directory: string): string | null => { + try { + const branch = execSync('git rev-parse --abbrev-ref HEAD', { + cwd: directory, + stdio: 'pipe', + }) + .toString() + .trim(); + return branch === 'HEAD' ? null : branch; + } catch { + return null; + } +}; + +const detectDefaultBranch = (directory: string): string | null => { + try { + const reference = execSync('git symbolic-ref refs/remotes/origin/HEAD', { + cwd: directory, + stdio: 'pipe', + }) + .toString() + .trim(); + return reference.replace('refs/remotes/origin/', ''); + } catch { + for (const candidate of DEFAULT_BRANCH_CANDIDATES) { + try { + execSync(`git rev-parse --verify ${candidate}`, { + cwd: directory, + stdio: 'pipe', + }); + return candidate; + } catch { + // try next candidate + } + } + return null; + } +}; + +const getChangedFilesSinceBranch = (directory: string, baseBranch: string): string[] => { + try { + const mergeBase = execSync(`git merge-base ${baseBranch} HEAD`, { + cwd: directory, + stdio: 'pipe', + }) + .toString() + .trim(); + + const output = execSync(`git diff --name-only --diff-filter=ACMR --relative ${mergeBase}`, { + cwd: directory, + stdio: 'pipe', + }) + .toString() + .trim(); + + if (!output) return []; + return output.split('\n').filter(Boolean); + } catch { + return []; + } +}; + +const getUncommittedChangedFiles = (directory: string): string[] => { + try { + const output = execSync('git diff --name-only --diff-filter=ACMR --relative HEAD', { + cwd: directory, + stdio: 'pipe', + }) + .toString() + .trim(); + if (!output) return []; + return output.split('\n').filter(Boolean); + } catch { + return []; + } +}; + +export const getDiffInfo = (directory: string, explicitBaseBranch?: string): DiffInfo | null => { + const currentBranch = getCurrentBranch(directory); + if (!currentBranch) return null; + + const baseBranch = explicitBaseBranch ?? detectDefaultBranch(directory); + if (!baseBranch) return null; + + if (currentBranch === baseBranch) { + const uncommittedFiles = getUncommittedChangedFiles(directory); + if (uncommittedFiles.length === 0) return null; + return { + currentBranch, + baseBranch, + changedFiles: uncommittedFiles, + isCurrentChanges: true, + }; + } + + const changedFiles = getChangedFilesSinceBranch(directory, baseBranch); + return { currentBranch, baseBranch, changedFiles }; +}; + +export const SOURCE_FILE_PATTERN_JS_TS = /\.(ts|tsx|js|jsx|mts|cts|mjs|cjs)$/; + +export const SOURCE_FILE_PATTERN_SVELTE = /\.(svelte|ts|tsx|js|jsx|mts|cts|mjs|cjs)$/; + +export const SOURCE_FILE_PATTERN_REACT = /\.(tsx?|jsx?)$/; + +export const filterSourceFiles = ( + filePaths: string[], + pattern: RegExp = SOURCE_FILE_PATTERN_JS_TS, +): string[] => filePaths.filter((filePath) => pattern.test(filePath)); diff --git a/packages/core/src/global-config.ts b/packages/core/src/global-config.ts new file mode 100644 index 0000000..cc3d700 --- /dev/null +++ b/packages/core/src/global-config.ts @@ -0,0 +1,38 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +const HOME_DIRECTORY = homedir(); +const CONFIG_DIRECTORY = join(HOME_DIRECTORY, '.framework-doctor'); +const CONFIG_FILE = join(CONFIG_DIRECTORY, 'config.json'); + +export interface FrameworkDoctorConfig { + skillPromptDismissed?: boolean; + analyticsEnabled?: boolean; + installId?: string; +} + +export const getOrCreateInstallId = (): string => { + const config = readGlobalConfig(); + const existingId = config.installId; + if (existingId) return existingId; + const installId = crypto.randomUUID(); + writeGlobalConfig({ ...config, installId }); + return installId; +}; + +export const readGlobalConfig = (): FrameworkDoctorConfig => { + try { + if (!existsSync(CONFIG_FILE)) return {}; + return JSON.parse(readFileSync(CONFIG_FILE, 'utf-8')) as FrameworkDoctorConfig; + } catch { + return {}; + } +}; + +export const writeGlobalConfig = (config: FrameworkDoctorConfig): void => { + try { + mkdirSync(CONFIG_DIRECTORY, { recursive: true }); + writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)); + } catch {} +}; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..5117003 --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,85 @@ +export { calculateScore } from './calculate-score.js'; +export { + ANALYTICS_CONFIG_KEY, + ANALYTICS_OPTION_DESCRIPTION, + ANALYTICS_OPTION_FLAGS, + addAnalyticsOption, +} from './cli-options.js'; +export { + ERROR_RULE_PENALTY, + ERROR_VOLUME_COEFFICIENT, + MILLISECONDS_PER_SECOND, + PERFECT_SCORE, + SCORE_BAR_WIDTH_CHARS, + SCORE_BLOCKING_CHECK_CAP, + SCORE_GOOD_THRESHOLD, + SCORE_OK_THRESHOLD, + SPREAD_PENALTY_MAX, + SUMMARY_BOX_HORIZONTAL_PADDING_CHARS, + SUMMARY_BOX_OUTER_INDENT_CHARS, + WARNING_RULE_PENALTY, + WARNING_VOLUME_COEFFICIENT, +} from './constants.js'; +export { + SOURCE_FILE_PATTERN_JS_TS, + SOURCE_FILE_PATTERN_REACT, + SOURCE_FILE_PATTERN_SVELTE, + filterSourceFiles, + getDiffInfo, +} from './get-diff-files.js'; +export { + getOrCreateInstallId, + readGlobalConfig, + writeGlobalConfig, + type FrameworkDoctorConfig, +} from './global-config.js'; +export { loadConfig } from './load-config.js'; +export { + DANGEROUSLY_SET_INNER_HTML_RULE, + NO_AT_HTML_RULE, + UNIVERSAL_SECURITY_RULES, + getFilesToScan, + runSecurityScan, +} from './security/index.js'; +export type { RunSecurityScanOptions, SecurityRule } from './security/index.js'; +export { + isAutomatedEnvironment, + maybePromptAnalyticsConsent, + sendScanEvent, + shouldSendAnalytics, + type TelemetryEventPayload, + type TelemetryFlags, +} from './telemetry.js'; +export type { + BaseDoctorConfig, + Diagnostic, + DiffInfo, + IgnoreConfig, + ScoreBreakdown, + ScoreGuardrailInput, + ScoreResult, +} from './types.js'; +export { + buildCountsSummaryLine, + buildScoreBar, + buildScoreBreakdownLines, + colorizeByScore, + createFramedLine, + formatElapsedTime, + getDoctorFace, + highlighter, + logger, + printFramedBox, + renderFramedBoxString, + spinner, +} from './ui/index.js'; +export type { FramedLine } from './ui/index.js'; +export { + compileGlobPattern, + findMonorepoRoot, + groupBy, + indentMultilineText, + isMonorepoRoot, + matchGlobPattern, + readJson, +} from './utils/index.js'; diff --git a/packages/core/src/load-config.ts b/packages/core/src/load-config.ts new file mode 100644 index 0000000..6ec225b --- /dev/null +++ b/packages/core/src/load-config.ts @@ -0,0 +1,37 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const isPlainObject = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +export const loadConfig = ( + rootDirectory: string, + configFilename: string, + packageJsonKey: string, +): T | null => { + const configPath = path.join(rootDirectory, configFilename); + if (fs.existsSync(configPath)) { + try { + const parsed: unknown = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + if (!isPlainObject(parsed)) return null; + return parsed as T; + } catch { + return null; + } + } + + const packageJsonPath = path.join(rootDirectory, 'package.json'); + if (!fs.existsSync(packageJsonPath)) return null; + + try { + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')) as Record< + string, + unknown + >; + const config = packageJson[packageJsonKey]; + if (!isPlainObject(config)) return null; + return config as T; + } catch { + return null; + } +}; diff --git a/packages/core/src/security/get-files-to-scan.ts b/packages/core/src/security/get-files-to-scan.ts new file mode 100644 index 0000000..006a677 --- /dev/null +++ b/packages/core/src/security/get-files-to-scan.ts @@ -0,0 +1,57 @@ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { GIT_LS_FILES_MAX_BUFFER_BYTES } from '../constants.js'; +const SOURCE_FILE_PATTERN_FULL = /\.(svelte|ts|tsx|js|jsx|mts|cts|mjs|cjs)$/; + +export const getFilesToScan = ( + rootDirectory: string, + includePaths: string[], + pattern: RegExp = SOURCE_FILE_PATTERN_FULL, +): string[] => { + if (includePaths.length > 0) { + return includePaths + .map((filePath) => path.resolve(rootDirectory, filePath)) + .filter((resolvedPath) => { + const relative = path.relative(rootDirectory, resolvedPath); + return !relative.startsWith('..') && pattern.test(resolvedPath); + }); + } + + const gitResult = spawnSync('git', ['ls-files', '--cached', '--others', '--exclude-standard'], { + cwd: rootDirectory, + encoding: 'utf-8', + maxBuffer: GIT_LS_FILES_MAX_BUFFER_BYTES, + }); + + if (gitResult.status === 0 && !gitResult.error) { + return gitResult.stdout + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0 && pattern.test(line)) + .map((line) => path.resolve(rootDirectory, line)); + } + + const collectedFiles: string[] = []; + const walk = (dir: string): void => { + if (!fs.existsSync(dir)) return; + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name !== 'node_modules' && entry.name !== 'dist' && entry.name !== '.git') { + walk(fullPath); + } + } else if (entry.isFile() && pattern.test(entry.name)) { + collectedFiles.push(fullPath); + } + } + }; + + const srcPath = path.join(rootDirectory, 'src'); + if (fs.existsSync(srcPath)) { + walk(srcPath); + } + walk(rootDirectory); + return [...new Set(collectedFiles)].filter((filePath) => !filePath.includes('node_modules')); +}; diff --git a/packages/core/src/security/index.ts b/packages/core/src/security/index.ts new file mode 100644 index 0000000..fed6642 --- /dev/null +++ b/packages/core/src/security/index.ts @@ -0,0 +1,13 @@ +export { getFilesToScan } from './get-files-to-scan.js'; +export { DANGEROUSLY_SET_INNER_HTML_RULE } from './react-rules.js'; +export type { SecurityRule } from './rule.js'; +export { runSecurityScan } from './run-security-scan.js'; +export type { RunSecurityScanOptions } from './run-security-scan.js'; +export { NO_AT_HTML_RULE } from './svelte-rules.js'; +export { + NO_EVAL_RULE, + NO_IMPLIED_EVAL_SET_INTERVAL_RULE, + NO_IMPLIED_EVAL_SET_TIMEOUT_RULE, + NO_NEW_FUNCTION_RULE, + UNIVERSAL_SECURITY_RULES, +} from './universal-rules.js'; diff --git a/packages/core/src/security/react-rules.ts b/packages/core/src/security/react-rules.ts new file mode 100644 index 0000000..e39fe07 --- /dev/null +++ b/packages/core/src/security/react-rules.ts @@ -0,0 +1,12 @@ +import type { SecurityRule } from './rule.js'; + +const JSX_EXTENSIONS = ['.tsx', '.jsx']; + +export const DANGEROUSLY_SET_INNER_HTML_RULE: SecurityRule = { + id: 'no-dangerously-set-inner-html', + pattern: /dangerouslySetInnerHTML\s*=\s*\{\s*\{\s*__html\s*:/g, + message: 'dangerouslySetInnerHTML with raw content can lead to XSS if unsanitized.', + help: 'Sanitize user-controlled content (e.g. with DOMPurify) before rendering.', + severity: 'error', + fileExtensions: JSX_EXTENSIONS, +}; diff --git a/packages/core/src/security/rule.ts b/packages/core/src/security/rule.ts new file mode 100644 index 0000000..dc4d187 --- /dev/null +++ b/packages/core/src/security/rule.ts @@ -0,0 +1,8 @@ +export interface SecurityRule { + id: string; + pattern: RegExp; + message: string; + help: string; + severity: 'error' | 'warning'; + fileExtensions: string[]; +} diff --git a/packages/core/src/security/run-security-scan.ts b/packages/core/src/security/run-security-scan.ts new file mode 100644 index 0000000..2101af5 --- /dev/null +++ b/packages/core/src/security/run-security-scan.ts @@ -0,0 +1,65 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { Diagnostic } from '../types.js'; +import { getFilesToScan } from './get-files-to-scan.js'; +import type { SecurityRule } from './rule.js'; + +const findMatches = (content: string, regex: RegExp): Array<{ line: number; column: number }> => { + const results: Array<{ line: number; column: number }> = []; + const lines = content.split('\n'); + for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) { + const line = lines[lineIndex]; + const regexCopy = new RegExp(regex.source, regex.flags); + let match: RegExpExecArray | null; + while ((match = regexCopy.exec(line)) !== null) { + results.push({ line: lineIndex + 1, column: match.index }); + } + } + return results; +}; + +const ruleAppliesToFile = (rule: SecurityRule, filePath: string): boolean => { + const extension = path.extname(filePath).toLowerCase(); + return rule.fileExtensions.includes(extension); +}; + +export interface RunSecurityScanOptions { + plugin: string; + rules: SecurityRule[]; +} + +export const runSecurityScan = async ( + rootDirectory: string, + includePaths: string[], + options: RunSecurityScanOptions, +): Promise => { + const files = getFilesToScan(rootDirectory, includePaths); + const diagnostics: Diagnostic[] = []; + + for (const filePath of files) { + try { + const content = fs.readFileSync(filePath, 'utf-8'); + for (const rule of options.rules) { + if (!ruleAppliesToFile(rule, filePath)) continue; + const matches = findMatches(content, rule.pattern); + for (const { line, column } of matches) { + diagnostics.push({ + filePath, + plugin: options.plugin, + rule: rule.id, + severity: rule.severity, + message: rule.message, + help: rule.help, + line, + column, + category: 'security', + }); + } + } + } catch { + // Skip unreadable files + } + } + + return diagnostics; +}; diff --git a/packages/core/src/security/svelte-rules.ts b/packages/core/src/security/svelte-rules.ts new file mode 100644 index 0000000..326f1d8 --- /dev/null +++ b/packages/core/src/security/svelte-rules.ts @@ -0,0 +1,10 @@ +import type { SecurityRule } from './rule.js'; + +export const NO_AT_HTML_RULE: SecurityRule = { + id: 'no-at-html', + pattern: /\{@html\s+/g, + message: 'Raw HTML via {@html} can lead to XSS if content is unsanitized.', + help: 'Sanitize user-controlled content (e.g. with DOMPurify) or avoid {@html} for untrusted input.', + severity: 'error', + fileExtensions: ['.svelte'], +}; diff --git a/packages/core/src/security/universal-rules.ts b/packages/core/src/security/universal-rules.ts new file mode 100644 index 0000000..ef652eb --- /dev/null +++ b/packages/core/src/security/universal-rules.ts @@ -0,0 +1,46 @@ +import type { SecurityRule } from './rule.js'; + +const JS_TS_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts', '.mjs', '.cjs']; + +export const NO_EVAL_RULE: SecurityRule = { + id: 'no-eval', + pattern: /\beval\s*\(/g, + message: 'eval() can execute arbitrary code (code injection risk).', + help: 'Avoid dynamic code evaluation. Use static logic or safe alternatives.', + severity: 'error', + fileExtensions: JS_TS_EXTENSIONS, +}; + +export const NO_NEW_FUNCTION_RULE: SecurityRule = { + id: 'no-new-function', + pattern: /\bnew\s+Function\s*\(/g, + message: 'new Function() can execute arbitrary code (code injection risk).', + help: 'Avoid dynamic code evaluation. Use static functions or safe alternatives.', + severity: 'error', + fileExtensions: JS_TS_EXTENSIONS, +}; + +export const NO_IMPLIED_EVAL_SET_TIMEOUT_RULE: SecurityRule = { + id: 'no-implied-eval', + pattern: /\bsetTimeout\s*\(\s*["']/g, + message: 'setTimeout with string argument executes code (implied eval).', + help: 'Use a function callback instead: setTimeout(() => { ... }, delay).', + severity: 'error', + fileExtensions: JS_TS_EXTENSIONS, +}; + +export const NO_IMPLIED_EVAL_SET_INTERVAL_RULE: SecurityRule = { + id: 'no-implied-eval', + pattern: /\bsetInterval\s*\(\s*["']/g, + message: 'setInterval with string argument executes code (implied eval).', + help: 'Use a function callback instead: setInterval(() => { ... }, delay).', + severity: 'error', + fileExtensions: JS_TS_EXTENSIONS, +}; + +export const UNIVERSAL_SECURITY_RULES: SecurityRule[] = [ + NO_EVAL_RULE, + NO_NEW_FUNCTION_RULE, + NO_IMPLIED_EVAL_SET_TIMEOUT_RULE, + NO_IMPLIED_EVAL_SET_INTERVAL_RULE, +]; diff --git a/packages/core/src/telemetry.ts b/packages/core/src/telemetry.ts new file mode 100644 index 0000000..660d056 --- /dev/null +++ b/packages/core/src/telemetry.ts @@ -0,0 +1,93 @@ +import { TELEMETRY_FETCH_TIMEOUT_MS } from './constants.js'; +import { getOrCreateInstallId, readGlobalConfig, writeGlobalConfig } from './global-config.js'; + +const AUTOMATED_ENVIRONMENT_VARIABLES = [ + 'CI', + 'CLAUDECODE', + 'CURSOR_AGENT', + 'CODEX_CI', + 'OPENCODE', + 'AMP_HOME', +]; + +export const isAutomatedEnvironment = (): boolean => + AUTOMATED_ENVIRONMENT_VARIABLES.some((envVariable) => Boolean(process.env[envVariable])); + +export interface TelemetryFlags { + analytics: boolean; + yes: boolean; +} + +export const shouldSendAnalytics = ( + flags: TelemetryFlags, + userConfigAnalytics: boolean | undefined, + isAutomated: boolean, +): boolean => { + if (process.env.DO_NOT_TRACK === '1') return false; + if (isAutomated) return false; + if (flags.yes) return false; + if (!flags.analytics) return false; + if (userConfigAnalytics === false) return false; + const globalConfig = readGlobalConfig(); + return Boolean(globalConfig.analyticsEnabled); +}; + +export const maybePromptAnalyticsConsent = async ( + shouldSkipPrompts: boolean, + promptUser: () => Promise, +): Promise => { + if (shouldSkipPrompts) return false; + if (process.env.DO_NOT_TRACK === '1') return false; + + const config = readGlobalConfig(); + if (config.analyticsEnabled !== undefined) return config.analyticsEnabled; + + const enabled = await promptUser(); + writeGlobalConfig({ ...config, analyticsEnabled: enabled }); + return Boolean(enabled); +}; + +const getScoreBucket = (score: number): string => { + if (score < 50) return '0-50'; + if (score < 75) return '50-75'; + return '75-100'; +}; + +export interface TelemetryEventPayload { + doctor_family: string; + framework: string; + score: number; + diagnostic_count: number; + has_typescript: boolean; + is_diff_mode: boolean; + cli_version: string; +} + +export const sendScanEvent = (telemetryUrl: string, payload: TelemetryEventPayload): void => { + const telemetryKey = process.env.FRAMEWORK_DOCTOR_TELEMETRY_KEY; + const headers: Record = { + 'Content-Type': 'application/json', + }; + if (telemetryKey) { + headers['x-telemetry-key'] = telemetryKey; + } + + const fullPayload = { + ...payload, + score_bucket: getScoreBucket(payload.score), + install_id: getOrCreateInstallId(), + event_id: crypto.randomUUID(), + }; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), TELEMETRY_FETCH_TIMEOUT_MS); + + fetch(telemetryUrl, { + method: 'POST', + headers, + body: JSON.stringify(fullPayload), + signal: controller.signal, + }) + .catch(() => {}) + .finally(() => clearTimeout(timeout)); +}; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts new file mode 100644 index 0000000..0c1c040 --- /dev/null +++ b/packages/core/src/types.ts @@ -0,0 +1,60 @@ +export interface Diagnostic { + filePath: string; + plugin: string; + rule: string; + severity: 'error' | 'warning'; + message: string; + help: string; + line: number; + column: number; + category: string; + weight?: number; +} + +export interface ScoreBreakdown { + typesPenalty: number; + volumePenalty: number; + spreadPenalty: number; + didApplyGuardrail: boolean; + guardrailReasons: string[]; + uniqueErrorRules: number; + uniqueWarningRules: number; + errorCount: number; + warningCount: number; + filesWithDiagnostics: number; + totalFilesScanned: number; +} + +export interface ScoreGuardrailInput { + didBuildFail?: boolean; + didTestsFail?: boolean; + didTypecheckFail?: boolean; + hasHighOrCriticalSecurityFindings?: boolean; +} + +export interface ScoreResult { + score: number; + label: string; + breakdown?: ScoreBreakdown; +} + +export interface DiffInfo { + currentBranch: string; + baseBranch: string; + changedFiles: string[]; + isCurrentChanges?: boolean; +} + +export interface IgnoreConfig { + rules?: string[]; + files?: string[]; +} + +export interface BaseDoctorConfig { + ignore?: IgnoreConfig; + lint?: boolean; + deadCode?: boolean; + verbose?: boolean; + diff?: boolean | string; + analytics?: boolean; +} diff --git a/packages/core/src/ui/build-counts-summary-line.ts b/packages/core/src/ui/build-counts-summary-line.ts new file mode 100644 index 0000000..adf302f --- /dev/null +++ b/packages/core/src/ui/build-counts-summary-line.ts @@ -0,0 +1,42 @@ +import type { Diagnostic } from '../types.js'; +import { formatElapsedTime } from './format-elapsed-time.js'; +import { highlighter } from './highlighter.js'; + +const collectAffectedFiles = (diagnostics: Diagnostic[]): Set => + new Set(diagnostics.map((d) => d.filePath)); + +export const buildCountsSummaryLine = ( + diagnostics: Diagnostic[], + totalSourceFileCount: number, + elapsedMs: number, +): { plain: string; rendered: string } => { + const errorCount = diagnostics.filter((d) => d.severity === 'error').length; + const warningCount = diagnostics.filter((d) => d.severity === 'warning').length; + const affectedCount = collectAffectedFiles(diagnostics).size; + const elapsed = formatElapsedTime(elapsedMs); + + const plainParts: string[] = []; + const renderedParts: string[] = []; + + if (errorCount > 0) { + const text = `✗ ${errorCount} error${errorCount === 1 ? '' : 's'}`; + plainParts.push(text); + renderedParts.push(highlighter.error(text)); + } + if (warningCount > 0) { + const text = `⚠ ${warningCount} warning${warningCount === 1 ? '' : 's'}`; + plainParts.push(text); + renderedParts.push(highlighter.warn(text)); + } + + const fileSuffix = affectedCount === 1 ? '' : 's'; + const fileText = + totalSourceFileCount > 0 + ? `across ${affectedCount}/${totalSourceFileCount} files` + : `across ${affectedCount} file${fileSuffix}`; + const timeText = `in ${elapsed}`; + plainParts.push(fileText, timeText); + renderedParts.push(highlighter.dim(fileText), highlighter.dim(timeText)); + + return { plain: plainParts.join(' '), rendered: renderedParts.join(' ') }; +}; diff --git a/packages/core/src/ui/build-score-breakdown-line.ts b/packages/core/src/ui/build-score-breakdown-line.ts new file mode 100644 index 0000000..c57fc46 --- /dev/null +++ b/packages/core/src/ui/build-score-breakdown-line.ts @@ -0,0 +1,24 @@ +import type { ScoreBreakdown } from '../types.js'; +import type { FramedLine } from './framed-box.js'; +import { createFramedLine } from './framed-box.js'; + +export const buildScoreBreakdownLines = (breakdown: ScoreBreakdown): FramedLine[] => { + const typesPenaltyRounded = Math.round(breakdown.typesPenalty * 10) / 10; + const volumePenaltyRounded = Math.round(breakdown.volumePenalty * 10) / 10; + const spreadPenaltyRounded = Math.round(breakdown.spreadPenalty * 10) / 10; + + return [ + createFramedLine( + `Types penalty: ${typesPenaltyRounded} (${breakdown.uniqueErrorRules} error rules, ${breakdown.uniqueWarningRules} warning rules)`, + ), + createFramedLine( + `Volume penalty: ${volumePenaltyRounded} (${breakdown.errorCount} errors, ${breakdown.warningCount} warnings)`, + ), + createFramedLine( + `Spread penalty: ${spreadPenaltyRounded} (${breakdown.filesWithDiagnostics}/${breakdown.totalFilesScanned} files)`, + ), + ...(breakdown.didApplyGuardrail + ? [createFramedLine(`Guardrail applied: yes (${breakdown.guardrailReasons.join(', ')})`)] + : [createFramedLine('Guardrail applied: no')]), + ]; +}; diff --git a/packages/react-doctor/src/utils/colorize-by-score.ts b/packages/core/src/ui/colorize-by-score.ts similarity index 100% rename from packages/react-doctor/src/utils/colorize-by-score.ts rename to packages/core/src/ui/colorize-by-score.ts diff --git a/packages/core/src/ui/doctor-face.ts b/packages/core/src/ui/doctor-face.ts new file mode 100644 index 0000000..7eb8a06 --- /dev/null +++ b/packages/core/src/ui/doctor-face.ts @@ -0,0 +1,7 @@ +import { SCORE_GOOD_THRESHOLD, SCORE_OK_THRESHOLD } from '../constants.js'; + +export const getDoctorFace = (score: number): [string, string] => { + if (score >= SCORE_GOOD_THRESHOLD) return ['◠ ◠', ' ▽ ']; + if (score >= SCORE_OK_THRESHOLD) return ['• •', ' ─ ']; + return ['x x', ' ▽ ']; +}; diff --git a/packages/core/src/ui/format-elapsed-time.ts b/packages/core/src/ui/format-elapsed-time.ts new file mode 100644 index 0000000..443372d --- /dev/null +++ b/packages/core/src/ui/format-elapsed-time.ts @@ -0,0 +1,8 @@ +import { MILLISECONDS_PER_SECOND } from '../constants.js'; + +export const formatElapsedTime = (elapsedMs: number): string => { + if (elapsedMs < MILLISECONDS_PER_SECOND) { + return `${Math.round(elapsedMs)}ms`; + } + return `${(elapsedMs / MILLISECONDS_PER_SECOND).toFixed(1)}s`; +}; diff --git a/packages/svelte-doctor/src/ui/framed-box.ts b/packages/core/src/ui/framed-box.ts similarity index 100% rename from packages/svelte-doctor/src/ui/framed-box.ts rename to packages/core/src/ui/framed-box.ts diff --git a/packages/svelte-doctor/src/ui/highlighter.ts b/packages/core/src/ui/highlighter.ts similarity index 100% rename from packages/svelte-doctor/src/ui/highlighter.ts rename to packages/core/src/ui/highlighter.ts diff --git a/packages/core/src/ui/index.ts b/packages/core/src/ui/index.ts new file mode 100644 index 0000000..ec25208 --- /dev/null +++ b/packages/core/src/ui/index.ts @@ -0,0 +1,11 @@ +export { buildCountsSummaryLine } from './build-counts-summary-line.js'; +export { buildScoreBreakdownLines } from './build-score-breakdown-line.js'; +export { colorizeByScore } from './colorize-by-score.js'; +export { getDoctorFace } from './doctor-face.js'; +export { formatElapsedTime } from './format-elapsed-time.js'; +export { createFramedLine, printFramedBox, renderFramedBoxString } from './framed-box.js'; +export type { FramedLine } from './framed-box.js'; +export { highlighter } from './highlighter.js'; +export { logger } from './logger.js'; +export { buildScoreBar } from './score-bar.js'; +export { spinner } from './spinner.js'; diff --git a/packages/react-doctor/src/utils/logger.ts b/packages/core/src/ui/logger.ts similarity index 65% rename from packages/react-doctor/src/utils/logger.ts rename to packages/core/src/ui/logger.ts index c195b25..21d7eda 100644 --- a/packages/react-doctor/src/utils/logger.ts +++ b/packages/core/src/ui/logger.ts @@ -1,25 +1,25 @@ import { highlighter } from './highlighter.js'; export const logger = { - error(...args: unknown[]) { + error: (...args: unknown[]) => { console.log(highlighter.error(args.join(' '))); }, - warn(...args: unknown[]) { + warn: (...args: unknown[]) => { console.log(highlighter.warn(args.join(' '))); }, - info(...args: unknown[]) { + info: (...args: unknown[]) => { console.log(highlighter.info(args.join(' '))); }, - success(...args: unknown[]) { + success: (...args: unknown[]) => { console.log(highlighter.success(args.join(' '))); }, - dim(...args: unknown[]) { + dim: (...args: unknown[]) => { console.log(highlighter.dim(args.join(' '))); }, - log(...args: unknown[]) { + log: (...args: unknown[]) => { console.log(args.join(' ')); }, - break() { + break: () => { console.log(''); }, }; diff --git a/packages/core/src/ui/score-bar.ts b/packages/core/src/ui/score-bar.ts new file mode 100644 index 0000000..e7dbd0c --- /dev/null +++ b/packages/core/src/ui/score-bar.ts @@ -0,0 +1,14 @@ +import { PERFECT_SCORE, SCORE_BAR_WIDTH_CHARS } from '../constants.js'; +import { colorizeByScore } from './colorize-by-score.js'; +import { highlighter } from './highlighter.js'; + +export const buildScoreBar = (score: number): { plain: string; rendered: string } => { + const filledCount = Math.round((score / PERFECT_SCORE) * SCORE_BAR_WIDTH_CHARS); + const emptyCount = SCORE_BAR_WIDTH_CHARS - filledCount; + const filled = '█'.repeat(filledCount); + const empty = '░'.repeat(emptyCount); + return { + plain: filled + empty, + rendered: colorizeByScore(filled, score) + highlighter.dim(empty), + }; +}; diff --git a/packages/svelte-doctor/src/ui/spinner.ts b/packages/core/src/ui/spinner.ts similarity index 93% rename from packages/svelte-doctor/src/ui/spinner.ts rename to packages/core/src/ui/spinner.ts index 4152cef..fa9dbd4 100644 --- a/packages/svelte-doctor/src/ui/spinner.ts +++ b/packages/core/src/ui/spinner.ts @@ -16,7 +16,6 @@ const finalize = (method: 'succeed' | 'fail', originalText: string, displayText: } sharedInstance.stop(); - // Avoid printing an extra "spinner start" line for parallel tasks. ora({ text: displayText })[method](displayText); const [remainingText] = pendingTexts; diff --git a/packages/react-doctor/src/utils/find-monorepo-root.ts b/packages/core/src/utils/find-monorepo-root.ts similarity index 70% rename from packages/react-doctor/src/utils/find-monorepo-root.ts rename to packages/core/src/utils/find-monorepo-root.ts index 2fd9c3d..b7c4a8f 100644 --- a/packages/react-doctor/src/utils/find-monorepo-root.ts +++ b/packages/core/src/utils/find-monorepo-root.ts @@ -1,12 +1,18 @@ import fs from 'node:fs'; import path from 'node:path'; -import { readPackageJson } from './read-package-json.js'; + +interface PackageJsonWorkspaces { + workspaces?: string[] | { packages: string[] }; +} + +const readPackageJsonWorkspaces = (packageJsonPath: string): PackageJsonWorkspaces => + JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')) as PackageJsonWorkspaces; export const isMonorepoRoot = (directory: string): boolean => { if (fs.existsSync(path.join(directory, 'pnpm-workspace.yaml'))) return true; const packageJsonPath = path.join(directory, 'package.json'); if (!fs.existsSync(packageJsonPath)) return false; - const packageJson = readPackageJson(packageJsonPath); + const packageJson = readPackageJsonWorkspaces(packageJsonPath); return Array.isArray(packageJson.workspaces) || Boolean(packageJson.workspaces?.packages); }; diff --git a/packages/react-doctor/src/utils/group-by.ts b/packages/core/src/utils/group-by.ts similarity index 100% rename from packages/react-doctor/src/utils/group-by.ts rename to packages/core/src/utils/group-by.ts diff --git a/packages/react-doctor/src/utils/indent-multiline-text.ts b/packages/core/src/utils/indent-multiline-text.ts similarity index 100% rename from packages/react-doctor/src/utils/indent-multiline-text.ts rename to packages/core/src/utils/indent-multiline-text.ts diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts new file mode 100644 index 0000000..d588080 --- /dev/null +++ b/packages/core/src/utils/index.ts @@ -0,0 +1,5 @@ +export { findMonorepoRoot, isMonorepoRoot } from './find-monorepo-root.js'; +export { groupBy } from './group-by.js'; +export { indentMultilineText } from './indent-multiline-text.js'; +export { compileGlobPattern, matchGlobPattern } from './match-glob-pattern.js'; +export { readJson } from './read-json.js'; diff --git a/packages/react-doctor/src/utils/match-glob-pattern.ts b/packages/core/src/utils/match-glob-pattern.ts similarity index 100% rename from packages/react-doctor/src/utils/match-glob-pattern.ts rename to packages/core/src/utils/match-glob-pattern.ts diff --git a/packages/svelte-doctor/src/utils/read-json.ts b/packages/core/src/utils/read-json.ts similarity index 100% rename from packages/svelte-doctor/src/utils/read-json.ts rename to packages/core/src/utils/read-json.ts diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000..e76dd48 --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { "noEmit": true, "declarationMap": true }, + "include": ["src"] +} diff --git a/packages/core/tsdown.config.ts b/packages/core/tsdown.config.ts new file mode 100644 index 0000000..bfc0f10 --- /dev/null +++ b/packages/core/tsdown.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + entry: { index: './src/index.ts' }, + dts: true, + target: 'node18', + platform: 'node', + fixedExtension: false, +}); diff --git a/packages/react-doctor/CHANGELOG.md b/packages/react-doctor/CHANGELOG.md index 3863915..93cda20 100644 --- a/packages/react-doctor/CHANGELOG.md +++ b/packages/react-doctor/CHANGELOG.md @@ -1,5 +1,20 @@ # react-doctor +## 1.0.2 + +### Patch Changes + +- added telemetry and refactored core +- Updated dependencies + - @framework-doctor/core@1.0.2 + +## 1.0.1 + +### Patch Changes + +- cb322c3: Initial release for Svelte and React doctors +- Updated docs + ## 0.0.28 ### Patch Changes diff --git a/packages/react-doctor/README.md b/packages/react-doctor/README.md index 45e8b71..78866f0 100644 --- a/packages/react-doctor/README.md +++ b/packages/react-doctor/README.md @@ -1,20 +1,12 @@ - - - - React Doctor - +# React Doctor -[![version](https://img.shields.io/npm/v/react-doctor?style=flat&colorA=000000&colorB=000000)](https://npmjs.com/package/react-doctor) -[![downloads](https://img.shields.io/npm/dt/react-doctor.svg?style=flat&colorA=000000&colorB=000000)](https://npmjs.com/package/react-doctor) +[![version](https://img.shields.io/npm/v/@framework-doctor/react.svg?style=flat)](https://npmjs.com/package/@framework-doctor/react) +[![downloads](https://img.shields.io/npm/dm/@framework-doctor/react.svg?style=flat)](https://npmjs.com/package/@framework-doctor/react) -Let coding agents diagnose and fix your React code. +Diagnose and improve your React codebase health. One command scans your codebase for security, performance, correctness, and architecture issues, then outputs a **0–100 score** with actionable diagnostics. -### [See it in action →](https://react.doctor) - -https://github.com/user-attachments/assets/07cc88d9-9589-44c3-aa73-5d603cb1c570 - ## How it works React Doctor detects your framework (Next.js, Vite, Remix, etc.), React version, and compiler setup, then runs two analysis passes **in parallel**: @@ -26,27 +18,26 @@ Diagnostics are filtered through your config, then scored by severity (errors we ## Install -Run this at your project root: - ```bash -npx -y react-doctor@latest . +pnpm add -D @framework-doctor/react +pnpm react-doctor . ``` -Use `--verbose` to see affected files and line numbers: +Or run without installing: ```bash -npx -y react-doctor@latest . --verbose +npx @framework-doctor/react . ``` -## Install for your coding agent - -Teach your coding agent all 47+ React best practice rules: +Use `--verbose` to see affected files and line numbers: ```bash -curl -fsSL https://react.doctor/install-skill.sh | bash +pnpm react-doctor . --verbose ``` -Supports Cursor, Claude Code, Amp Code, Codex, Gemini CLI, OpenCode, Windsurf, and Antigravity. +## Cursor skill + +Add the React Doctor skill so your AI assistant knows all 47+ React best practice rules. Copy `.cursor/skills/framework-doctor` from this repo into your project or global Cursor skills (e.g. `~/.cursor/skills/`). ## Options @@ -62,8 +53,7 @@ Options: -y, --yes skip prompts, scan all workspace projects --project select workspace project (comma-separated for multiple) --diff [base] scan only files changed vs base branch - --no-ami skip Ami-related prompts - --fix open Ami to auto-fix all issues + --no-analytics disable anonymous analytics -h, --help display help for command ``` @@ -104,15 +94,26 @@ If both exist, `react-doctor.config.json` takes precedence. | `deadCode` | `boolean` | `true` | Enable/disable dead code detection (same as `--no-dead-code`) | | `verbose` | `boolean` | `false` | Show file details per rule (same as `--verbose`) | | `diff` | `boolean \| string` | — | Force diff mode (`true`) or pin a base branch (`"main"`). Set to `false` to disable auto-detection. | +| `analytics` | `boolean` | `true` | Enable/disable anonymous analytics (same as `--no-analytics`) | CLI flags always override config values. +## Analytics + +React Doctor optionally sends anonymous usage data to help improve the tool. Data is sent to your Supabase Edge Function when you opt in and is limited to: framework type, score range, diagnostic count, and similar aggregates. No code, file paths, or project names are collected. + +- **Enable** (for maintainers): Set `FRAMEWORK_DOCTOR_TELEMETRY_URL` to your Supabase Edge Function URL (e.g. `https://.supabase.co/functions/v1/telemetry`) in your publish/CI env. +- **Shared key (optional)**: If your Supabase function enforces `TELEMETRY_KEY`, set `FRAMEWORK_DOCTOR_TELEMETRY_KEY` in the client environment. +- **Opt-in**: On first run (when analytics is configured), you’ll be prompted. Your choice is stored in `~/.framework-doctor/config.json`. +- **Disable**: Use `--no-analytics`, set `"analytics": false` in config, or set `DO_NOT_TRACK=1`. +- **Skipped automatically**: CI and other non-interactive environments (e.g. Cursor Agent, Claude Code). + ## Node.js API You can also use React Doctor programmatically: ```js -import { diagnose } from 'react-doctor/api'; +import { diagnose } from '@framework-doctor/react/api'; const result = await diagnose('./path/to/your/react-project'); @@ -146,37 +147,20 @@ interface Diagnostic { } ``` -## [Scores for popular open-source projects](https://react.doctor/leaderboard) - -| Project | Score | Share | -| ------------------------------------------------------ | ------ | --------------------------------------------------------------------------------------- | -| [tldraw](https://github.com/tldraw/tldraw) | **84** | [view](https://www.react.doctor/share?p=tldraw&s=84&e=98&w=139&f=40) | -| [excalidraw](https://github.com/excalidraw/excalidraw) | **84** | [view](https://www.react.doctor/share?p=%40excalidraw%2Fexcalidraw&s=84&e=2&w=196&f=80) | -| [twenty](https://github.com/twentyhq/twenty) | **78** | [view](https://www.react.doctor/share?p=twenty-front&s=78&e=99&w=293&f=268) | -| [plane](https://github.com/makeplane/plane) | **78** | [view](https://www.react.doctor/share?p=web&s=78&e=7&w=525&f=292) | -| [formbricks](https://github.com/formbricks/formbricks) | **75** | [view](https://www.react.doctor/share?p=%40formbricks%2Fweb&s=75&e=15&w=389&f=242) | -| [posthog](https://github.com/PostHog/posthog) | **72** | [view](https://www.react.doctor/share?p=%40posthog%2Ffrontend&s=72&e=82&w=1177&f=585) | -| [supabase](https://github.com/supabase/supabase) | **69** | [view](https://www.react.doctor/share?p=studio&s=69&e=74&w=1087&f=566) | -| [onlook](https://github.com/onlook-dev/onlook) | **69** | [view](https://www.react.doctor/share?p=%40onlook%2Fweb-client&s=69&e=64&w=418&f=178) | -| [payload](https://github.com/payloadcms/payload) | **68** | [view](https://www.react.doctor/share?p=%40payloadcms%2Fui&s=68&e=139&w=408&f=298) | -| [sentry](https://github.com/getsentry/sentry) | **64** | [view](https://www.react.doctor/share?p=sentry&s=64&e=94&w=1345&f=818) | -| [cal.com](https://github.com/calcom/cal.com) | **63** | [view](https://www.react.doctor/share?p=%40calcom%2Fweb&s=63&e=31&w=558&f=311) | -| [dub](https://github.com/dubinc/dub) | **62** | [view](https://www.react.doctor/share?p=web&s=62&e=52&w=966&f=457) | - ## Contributing -Want to contribute? Check out the codebase and submit a PR. - ```bash -git clone https://github.com/millionco/react-doctor -cd react-doctor +git clone https://github.com/pitis/framework-doctor +cd framework-doctor pnpm install -pnpm -r run build +pnpm build ``` Run locally: ```bash +pnpm exec react-doctor /path/to/your/react-project +# or directly: node packages/react-doctor/dist/cli.js /path/to/your/react-project ``` diff --git a/packages/react-doctor/package.json b/packages/react-doctor/package.json index 37a5218..1cf1b58 100644 --- a/packages/react-doctor/package.json +++ b/packages/react-doctor/package.json @@ -1,6 +1,6 @@ { "name": "@framework-doctor/react", - "version": "1.0.0", + "version": "1.0.2", "description": "Diagnose and fix performance issues in your React app", "author": { "name": "Pitis Radu", @@ -67,6 +67,7 @@ "prepublishOnly": "pnpm run build" }, "dependencies": { + "@framework-doctor/core": "workspace:*", "commander": "catalog:", "eslint-plugin-react-hooks": "^7.0.1", "knip": "catalog:", diff --git a/packages/react-doctor/src/cli.ts b/packages/react-doctor/src/cli.ts index 8fa12d4..e0ec896 100644 --- a/packages/react-doctor/src/cli.ts +++ b/packages/react-doctor/src/cli.ts @@ -1,28 +1,24 @@ +import { + addAnalyticsOption, + highlighter, + isAutomatedEnvironment, + logger, +} from '@framework-doctor/core'; import { Command } from 'commander'; -import { execSync } from 'node:child_process'; -import { existsSync } from 'node:fs'; -import os from 'node:os'; import path from 'node:path'; -import { AMI_INSTALL_URL, AMI_RELEASES_URL, AMI_WEBSITE_URL, OPEN_BASE_URL } from './constants.js'; import { scan } from './scan.js'; -import type { - Diagnostic, - DiffInfo, - EstimatedScoreResult, - ReactDoctorConfig, - ScanOptions, -} from './types.js'; -import { fetchEstimatedScore } from './utils/calculate-score.js'; -import { colorizeByScore } from './utils/colorize-by-score.js'; -import { createFramedLine, renderFramedBoxString } from './utils/framed-box.js'; +import type { Diagnostic, DiffInfo, ReactDoctorConfig, ScanOptions } from './types.js'; import { filterSourceFiles, getDiffInfo } from './utils/get-diff-files.js'; import { handleError } from './utils/handle-error.js'; -import { highlighter } from './utils/highlighter.js'; import { loadConfig } from './utils/load-config.js'; -import { logger } from './utils/logger.js'; -import { clearSelectBanner, prompts, setSelectBanner } from './utils/prompts.js'; +import { prompts } from './utils/prompts.js'; import { selectProjects } from './utils/select-projects.js'; import { maybePromptSkillInstall } from './utils/skill-prompt.js'; +import { + maybePromptAnalyticsConsent, + sendScanEvent, + shouldSendAnalytics, +} from './utils/telemetry.js'; const VERSION = process.env.VERSION ?? '0.0.0'; @@ -31,37 +27,21 @@ interface CliFlags { deadCode: boolean; verbose: boolean; score: boolean; - fix: boolean; yes: boolean; - offline: boolean; - ami: boolean; + analytics: boolean; project?: string; diff?: boolean | string; } -const exitWithFixHint = () => { +const exitWithCancelHint = () => { logger.break(); logger.log('Cancelled.'); - logger.dim('Run `npx react-doctor@latest --fix` to fix issues.'); logger.break(); process.exit(0); }; -process.on('SIGINT', exitWithFixHint); -process.on('SIGTERM', exitWithFixHint); - -const AUTOMATED_ENVIRONMENT_VARIABLES = [ - 'CI', - 'CLAUDECODE', - 'CURSOR_AGENT', - 'CODEX_CI', - 'OPENCODE', - 'AMP_HOME', - 'AMI', -]; - -const isAutomatedEnvironment = (): boolean => - AUTOMATED_ENVIRONMENT_VARIABLES.some((envVariable) => Boolean(process.env[envVariable])); +process.on('SIGINT', exitWithCancelHint); +process.on('SIGTERM', exitWithCancelHint); const resolveCliScanOptions = ( flags: CliFlags, @@ -76,7 +56,6 @@ const resolveCliScanOptions = ( deadCode: isCliOverride('deadCode') ? flags.deadCode : (userConfig?.deadCode ?? flags.deadCode), verbose: isCliOverride('verbose') ? Boolean(flags.verbose) : (userConfig?.verbose ?? false), scoreOnly: flags.score, - offline: flags.offline, }; }; @@ -126,10 +105,11 @@ const program = new Command() .option('--score', 'output only the score') .option('-y, --yes', 'skip prompts, scan all workspace projects') .option('--project ', 'select workspace project (comma-separated for multiple)') - .option('--diff [base]', 'scan only files changed vs base branch') - .option('--offline', 'skip telemetry (anonymous, not stored, only used to calculate score)') - .option('--no-ami', 'skip Ami-related prompts') - .option('--fix', 'open Ami to auto-fix all issues') + .option('--diff [base]', 'scan only files changed vs base branch'); + +addAnalyticsOption(program); + +program .action(async (directory: string, flags: CliFlags) => { const isScoreOnly = flags.score; @@ -144,7 +124,6 @@ const program = new Command() const scanOptions = resolveCliScanOptions(flags, userConfig, program); const shouldSkipPrompts = flags.yes || isAutomatedEnvironment() || !process.stdin.isTTY; - const shouldSkipAmiPrompts = shouldSkipPrompts || !flags.ami; const projectDirectories = await selectProjects( resolvedDirectory, flags.project, @@ -174,6 +153,12 @@ const program = new Command() } const allDiagnostics: Diagnostic[] = []; + const telemetryUrl = process.env.FRAMEWORK_DOCTOR_TELEMETRY_URL ?? ''; + const isAutomated = isAutomatedEnvironment(); + + if (!isScoreOnly && !isAutomated && !flags.yes) { + await maybePromptAnalyticsConsent(shouldSkipPrompts); + } for (const projectDirectory of projectDirectories) { let includePaths: string[] | undefined; @@ -198,21 +183,35 @@ const program = new Command() } const scanResult = await scan(projectDirectory, { ...scanOptions, includePaths }); allDiagnostics.push(...scanResult.diagnostics); + + if ( + telemetryUrl && + scanResult.scoreResult && + shouldSendAnalytics( + { analytics: flags.analytics, yes: flags.yes }, + userConfig?.analytics, + isAutomated, + ) + ) { + sendScanEvent( + telemetryUrl, + scanResult.projectInfo, + scanResult.scoreResult, + scanResult.diagnostics.length, + { + isDiffMode: Boolean(includePaths?.length), + cliVersion: VERSION, + }, + ); + } + if (!isScoreOnly) { logger.break(); } } - if (flags.fix) { - openAmiToFix(resolvedDirectory); - } - - if (!isScoreOnly && !shouldSkipAmiPrompts && !flags.fix) { - await maybePromptSkillInstall(shouldSkipAmiPrompts); - const estimatedScoreResult = flags.offline - ? null - : await fetchEstimatedScore(allDiagnostics); - await maybePromptFix(resolvedDirectory, allDiagnostics, estimatedScoreResult); + if (!isScoreOnly && !shouldSkipPrompts) { + await maybePromptSkillInstall(shouldSkipPrompts); } } catch (error) { handleError(error); @@ -222,227 +221,10 @@ const program = new Command() 'after', ` ${highlighter.dim('Learn more:')} - ${highlighter.info('https://github.com/millionco/react-doctor')} + ${highlighter.info('https://github.com/pitis/framework-doctor')} `, ); -const DEEPLINK_FIX_PROMPT = '/{slash-command:ami:react-doctor}'; - -const isAmiInstalled = (): boolean => { - if (process.platform === 'darwin') { - return ( - existsSync('/Applications/Ami.app') || - existsSync(path.join(os.homedir(), 'Applications', 'Ami.app')) - ); - } - - if (process.platform === 'win32') { - const { LOCALAPPDATA, PROGRAMFILES } = process.env; - return ( - Boolean(LOCALAPPDATA && existsSync(path.join(LOCALAPPDATA, 'Programs', 'Ami', 'Ami.exe'))) || - Boolean(PROGRAMFILES && existsSync(path.join(PROGRAMFILES, 'Ami', 'Ami.exe'))) - ); - } - - try { - execSync('which ami', { stdio: 'ignore' }); - return true; - } catch { - return false; - } -}; - -const installAmi = (): void => { - logger.log('Installing Ami...'); - logger.break(); - try { - execSync(`curl -fsSL ${AMI_INSTALL_URL} | bash`, { stdio: 'inherit' }); - } catch { - logger.error(`Failed to install Ami. Visit ${AMI_WEBSITE_URL} to install manually.`); - process.exit(1); - } - logger.break(); -}; - -const openUrl = (url: string): void => { - if (process.platform === 'win32') { - // HACK: cmd.exe interprets %XX% as env var expansion, which mangles encoded URLs. - // Escaping % as %% produces literal % in cmd output. - const cmdEscapedUrl = url.replace(/%/g, '%%'); - execSync(`start "" "${cmdEscapedUrl}"`, { stdio: 'ignore' }); - return; - } - const openCommand = process.platform === 'darwin' ? `open "${url}"` : `xdg-open "${url}"`; - execSync(openCommand, { stdio: 'ignore' }); -}; - -const buildDeeplinkParams = (directory: string): URLSearchParams => { - const params = new URLSearchParams(); - params.set('cwd', path.resolve(directory)); - params.set('prompt', DEEPLINK_FIX_PROMPT); - params.set('mode', 'agent'); - params.set('autoSubmit', 'true'); - params.set('source', 'react-doctor'); - return params; -}; - -const buildDeeplink = (directory: string): string => - `ami://open-project?${buildDeeplinkParams(directory).toString()}`; - -const buildWebDeeplink = (directory: string): string => - `${OPEN_BASE_URL}?${buildDeeplinkParams(directory).toString()}`; - -const openAmiToFix = (directory: string): void => { - const isInstalled = isAmiInstalled(); - const deeplink = buildDeeplink(directory); - const webDeeplink = buildWebDeeplink(directory); - - if (!isInstalled) { - if (process.platform === 'darwin') { - installAmi(); - logger.success('Ami installed successfully.'); - } else { - logger.error('Ami is not installed.'); - logger.dim(`Download at ${highlighter.info(AMI_RELEASES_URL)}`); - } - logger.break(); - logger.dim('Open this link to start fixing:'); - logger.info(webDeeplink); - return; - } - - logger.log('Opening Ami...'); - - try { - openUrl(deeplink); - logger.success('Ami opened. Fixing your issues now.'); - } catch { - logger.break(); - logger.dim('Could not open Ami automatically. Open this link instead:'); - logger.info(webDeeplink); - } -}; - -const FIX_METHOD_AMI = 'ami'; -const FIX_COMMAND_HINT = 'npx react-doctor@latest --fix'; - -const buildAmiBanner = ( - issueCount: number, - currentScore: number, - estimatedScore: number, -): string => { - const currentScoreDisplay = colorizeByScore(String(currentScore), currentScore); - const estimatedScoreDisplay = colorizeByScore(`~${estimatedScore}`, estimatedScore); - const issueLabel = issueCount === 1 ? 'issue' : 'issues'; - - return renderFramedBoxString([ - createFramedLine( - `Score: ${currentScore} → ~${estimatedScore}`, - `Score: ${currentScoreDisplay} ${highlighter.dim('→')} ${estimatedScoreDisplay}`, - ), - createFramedLine(''), - createFramedLine( - `Ami is a coding agent built for React. It reads`, - `${highlighter.info('Ami')} is a coding agent built for React. It reads`, - ), - createFramedLine('your react-doctor report, understands your codebase,'), - createFramedLine( - `and fixes ${issueCount} ${issueLabel} one by one — then re-runs the`, - `and fixes ${highlighter.warn(String(issueCount))} ${issueLabel} one by one — then re-runs the`, - ), - createFramedLine('scan to verify the score improved.'), - createFramedLine(''), - createFramedLine( - `Free to use. ${AMI_WEBSITE_URL}`, - `Free to use. ${highlighter.info(AMI_WEBSITE_URL)}`, - ), - ]); -}; - -const buildSkipBanner = (issueCount: number, estimatedScore: number): string => { - const issueLabel = issueCount === 1 ? 'issue' : 'issues'; - const estimatedScoreDisplay = colorizeByScore(`~${estimatedScore}`, estimatedScore); - - return renderFramedBoxString([ - createFramedLine( - `Skip fixing ${issueCount} ${issueLabel} and reaching ~${estimatedScore}?`, - `Skip fixing ${highlighter.warn(String(issueCount))} ${issueLabel} and reaching ${estimatedScoreDisplay}?`, - ), - createFramedLine(''), - createFramedLine( - `Run ${FIX_COMMAND_HINT} anytime to come back.`, - `Run ${highlighter.info(FIX_COMMAND_HINT)} anytime to come back.`, - ), - ]); -}; - -const configureFixBanners = ( - issueCount: number, - estimatedScoreResult: EstimatedScoreResult, -): void => { - const { currentScore, estimatedScore } = estimatedScoreResult; - setSelectBanner(buildAmiBanner(issueCount, currentScore, estimatedScore), 0); - setSelectBanner(buildSkipBanner(issueCount, estimatedScore), 1); -}; - -const maybePromptFix = async ( - directory: string, - diagnostics: Diagnostic[], - estimatedScoreResult: EstimatedScoreResult | null, -): Promise => { - if (diagnostics.length === 0) return; - - logger.break(); - - if (estimatedScoreResult) { - configureFixBanners(diagnostics.length, estimatedScoreResult); - } - - const { fixMethod } = await prompts({ - type: 'select', - name: 'fixMethod', - message: 'Fix issues?', - choices: [ - { - title: 'Use Ami (recommended)', - description: 'Optimized coding agent for React Doctor', - value: FIX_METHOD_AMI, - }, - { title: 'Skip', value: 'skip' }, - ], - }); - - clearSelectBanner(); - - if (fixMethod === FIX_METHOD_AMI) { - openAmiToFix(directory); - } else { - logger.break(); - logger.dim(` Run ${highlighter.info(FIX_COMMAND_HINT)} anytime to fix issues.`); - } -}; - -const fixAction = (directory: string) => { - try { - openAmiToFix(directory); - } catch (error) { - handleError(error); - } -}; - -const fixCommand = new Command('fix') - .description('Open Ami to auto-fix react-doctor issues') - .argument('[directory]', 'project directory', '.') - .action(fixAction); - -const installAmiCommand = new Command('install-ami') - .description('Install Ami and open it to auto-fix issues') - .argument('[directory]', 'project directory', '.') - .action(fixAction); - -program.addCommand(fixCommand); -program.addCommand(installAmiCommand); - const main = async () => { await program.parseAsync(); }; diff --git a/packages/react-doctor/src/constants.ts b/packages/react-doctor/src/constants.ts index e819dc7..813cdff 100644 --- a/packages/react-doctor/src/constants.ts +++ b/packages/react-doctor/src/constants.ts @@ -1,31 +1,21 @@ +export { + ERROR_RULE_PENALTY, + MILLISECONDS_PER_SECOND, + PERFECT_SCORE, + SCORE_BAR_WIDTH_CHARS, + SCORE_GOOD_THRESHOLD, + SCORE_OK_THRESHOLD, + SUMMARY_BOX_HORIZONTAL_PADDING_CHARS, + SUMMARY_BOX_OUTER_INDENT_CHARS, + WARNING_RULE_PENALTY, +} from '@framework-doctor/core'; + export const SOURCE_FILE_PATTERN = /\.(tsx?|jsx?)$/; export const JSX_FILE_PATTERN = /\.(tsx|jsx)$/; -export const MILLISECONDS_PER_SECOND = 1000; - export const ERROR_PREVIEW_LENGTH_CHARS = 200; -export const PERFECT_SCORE = 100; - -export const SCORE_GOOD_THRESHOLD = 75; - -export const SCORE_OK_THRESHOLD = 50; - -export const SCORE_BAR_WIDTH_CHARS = 50; - -export const SUMMARY_BOX_HORIZONTAL_PADDING_CHARS = 1; - -export const SUMMARY_BOX_OUTER_INDENT_CHARS = 2; - -export const SCORE_API_URL = 'https://www.react.doctor/api/score'; - -export const ESTIMATE_SCORE_API_URL = 'https://www.react.doctor/api/estimate-score'; - -export const SHARE_BASE_URL = 'https://www.react.doctor/share'; - -export const OPEN_BASE_URL = 'https://www.react.doctor/open'; - export const FETCH_TIMEOUT_MS = 10_000; export const GIT_LS_FILES_MAX_BUFFER_BYTES = 50 * 1024 * 1024; @@ -34,29 +24,10 @@ export const GIT_LS_FILES_MAX_BUFFER_BYTES = 50 * 1024 * 1024; // Use a conservative threshold to leave room for the executable path and quoting overhead. export const SPAWN_ARGS_MAX_LENGTH_CHARS = 24_000; -export const OFFLINE_MESSAGE = - 'You are offline, could not calculate score. Reconnect to calculate.'; - -export const OFFLINE_FLAG_MESSAGE = 'Score not calculated. Remove --offline to calculate score.'; - -export const DEFAULT_BRANCH_CANDIDATES = ['main', 'master']; - -export const ERROR_RULE_PENALTY = 1.5; - -export const WARNING_RULE_PENALTY = 0.75; - -export const ERROR_ESTIMATED_FIX_RATE = 0.85; - -export const WARNING_ESTIMATED_FIX_RATE = 0.8; +export const OFFLINE_FLAG_MESSAGE = 'Score not available.'; export const MAX_KNIP_RETRIES = 5; export const OXLINT_NODE_REQUIREMENT = '^20.19.0 || >=22.12.0'; export const OXLINT_RECOMMENDED_NODE_MAJOR = 24; - -export const AMI_WEBSITE_URL = 'https://ami.dev'; - -export const AMI_INSTALL_URL = `${AMI_WEBSITE_URL}/install.sh`; - -export const AMI_RELEASES_URL = 'https://github.com/millionco/ami-releases/releases'; diff --git a/packages/react-doctor/src/index.ts b/packages/react-doctor/src/index.ts index bf54652..363a387 100644 --- a/packages/react-doctor/src/index.ts +++ b/packages/react-doctor/src/index.ts @@ -7,6 +7,7 @@ import { discoverProject } from './utils/discover-project.js'; import { loadConfig } from './utils/load-config.js'; import { runKnip } from './utils/run-knip.js'; import { runOxlint } from './utils/run-oxlint.js'; +import { runSecurityScan } from './utils/run-security-scan.js'; export { filterSourceFiles, getDiffInfo } from './utils/get-diff-files.js'; export type { Diagnostic, DiffInfo, ProjectInfo, ReactDoctorConfig, ScoreResult }; @@ -24,6 +25,11 @@ export interface DiagnoseResult { elapsedMilliseconds: number; } +const hasHighOrCriticalSecurityFindings = (diagnostics: Diagnostic[]): boolean => + diagnostics.some( + (diagnostic) => diagnostic.category === 'security' && diagnostic.severity === 'error', + ); + export const diagnose = async ( directory: string, options: DiagnoseOptions = {}, @@ -68,17 +74,29 @@ export const diagnose = async ( }) : Promise.resolve(emptyDiagnostics); - const [lintDiagnostics, deadCodeDiagnostics] = await Promise.all([lintPromise, deadCodePromise]); + const securityPromise = effectiveLint + ? runSecurityScan(resolvedDirectory, includePaths) + : Promise.resolve(emptyDiagnostics); + + const [lintDiagnostics, deadCodeDiagnostics, securityDiagnostics] = await Promise.all([ + lintPromise, + deadCodePromise, + securityPromise, + ]); const diagnostics = combineDiagnostics( lintDiagnostics, deadCodeDiagnostics, + securityDiagnostics, resolvedDirectory, isDiffMode, userConfig, ); const elapsedMilliseconds = performance.now() - startTime; - const score = await calculateScore(diagnostics); + const totalFilesScanned = isDiffMode ? includePaths.length : projectInfo.sourceFileCount; + const score = await calculateScore(diagnostics, totalFilesScanned, { + hasHighOrCriticalSecurityFindings: hasHighOrCriticalSecurityFindings(diagnostics), + }); return { diagnostics, diff --git a/packages/react-doctor/src/scan.ts b/packages/react-doctor/src/scan.ts index 10e8061..05661d9 100644 --- a/packages/react-doctor/src/scan.ts +++ b/packages/react-doctor/src/scan.ts @@ -1,19 +1,28 @@ +import type { FramedLine } from '@framework-doctor/core'; +import { + buildCountsSummaryLine, + buildScoreBar, + buildScoreBreakdownLines, + colorizeByScore, + createFramedLine, + getDoctorFace, + groupBy, + highlighter, + indentMultilineText, + logger, + PERFECT_SCORE, + printFramedBox, + spinner, +} from '@framework-doctor/core'; import { randomUUID } from 'node:crypto'; import { mkdirSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { performance } from 'node:perf_hooks'; import { - MILLISECONDS_PER_SECOND, OFFLINE_FLAG_MESSAGE, - OFFLINE_MESSAGE, OXLINT_NODE_REQUIREMENT, OXLINT_RECOMMENDED_NODE_MAJOR, - PERFECT_SCORE, - SCORE_BAR_WIDTH_CHARS, - SCORE_GOOD_THRESHOLD, - SCORE_OK_THRESHOLD, - SHARE_BASE_URL, } from './constants.js'; import type { Diagnostic, @@ -24,15 +33,9 @@ import type { ScoreResult, } from './types.js'; import { calculateScore } from './utils/calculate-score.js'; -import { colorizeByScore } from './utils/colorize-by-score.js'; import { combineDiagnostics, computeJsxIncludePaths } from './utils/combine-diagnostics.js'; import { discoverProject, formatFrameworkName } from './utils/discover-project.js'; -import { type FramedLine, createFramedLine, printFramedBox } from './utils/framed-box.js'; -import { groupBy } from './utils/group-by.js'; -import { highlighter } from './utils/highlighter.js'; -import { indentMultilineText } from './utils/indent-multiline-text.js'; import { loadConfig } from './utils/load-config.js'; -import { logger } from './utils/logger.js'; import { prompts } from './utils/prompts.js'; import { installNodeViaNvm, @@ -41,12 +44,7 @@ import { } from './utils/resolve-compatible-node.js'; import { runKnip } from './utils/run-knip.js'; import { runOxlint } from './utils/run-oxlint.js'; -import { spinner } from './utils/spinner.js'; - -interface ScoreBarSegments { - filledSegment: string; - emptySegment: string; -} +import { runSecurityScan } from './utils/run-security-scan.js'; const SEVERITY_ORDER: Record = { error: 0, @@ -78,6 +76,11 @@ const buildFileLineMap = (diagnostics: Diagnostic[]): Map => { return fileLines; }; +const hasHighOrCriticalSecurityFindings = (diagnostics: Diagnostic[]): boolean => + diagnostics.some( + (diagnostic) => diagnostic.category === 'security' && diagnostic.severity === 'error', + ); + const printDiagnostics = (diagnostics: Diagnostic[], isVerbose: boolean): void => { const ruleGroups = groupBy( diagnostics, @@ -111,13 +114,6 @@ const printDiagnostics = (diagnostics: Diagnostic[], isVerbose: boolean): void = } }; -const formatElapsedTime = (elapsedMilliseconds: number): string => { - if (elapsedMilliseconds < MILLISECONDS_PER_SECOND) { - return `${Math.round(elapsedMilliseconds)}ms`; - } - return `${(elapsedMilliseconds / MILLISECONDS_PER_SECOND).toFixed(1)}s`; -}; - const formatRuleSummary = (ruleKey: string, ruleDiagnostics: Diagnostic[]): string => { const firstDiagnostic = ruleDiagnostics[0]; const fileLines = buildFileLineMap(ruleDiagnostics); @@ -164,41 +160,16 @@ const writeDiagnosticsDirectory = (diagnostics: Diagnostic[]): string => { return outputDirectory; }; -const buildScoreBarSegments = (score: number): ScoreBarSegments => { - const filledCount = Math.round((score / PERFECT_SCORE) * SCORE_BAR_WIDTH_CHARS); - const emptyCount = SCORE_BAR_WIDTH_CHARS - filledCount; - - return { - filledSegment: '█'.repeat(filledCount), - emptySegment: '░'.repeat(emptyCount), - }; -}; - -const buildPlainScoreBar = (score: number): string => { - const { filledSegment, emptySegment } = buildScoreBarSegments(score); - return `${filledSegment}${emptySegment}`; -}; - -const buildScoreBar = (score: number): string => { - const { filledSegment, emptySegment } = buildScoreBarSegments(score); - return colorizeByScore(filledSegment, score) + highlighter.dim(emptySegment); -}; - const printScoreGauge = (score: number, label: string): void => { const scoreDisplay = colorizeByScore(`${score}`, score); const labelDisplay = colorizeByScore(label, score); + const bar = buildScoreBar(score); logger.log(` ${scoreDisplay} / ${PERFECT_SCORE} ${labelDisplay}`); logger.break(); - logger.log(` ${buildScoreBar(score)}`); + logger.log(` ${bar.rendered}`); logger.break(); }; -const getDoctorFace = (score: number): string[] => { - if (score >= SCORE_GOOD_THRESHOLD) return ['◠ ◠', ' ▽ ']; - if (score >= SCORE_OK_THRESHOLD) return ['• •', ' ─ ']; - return ['x x', ' ▽ ']; -}; - const printBranding = (score?: number): void => { if (score !== undefined) { const [eyes, mouth] = getDoctorFace(score); @@ -208,32 +179,14 @@ const printBranding = (score?: number): void => { logger.log(colorize(` │ ${mouth} │`)); logger.log(colorize(' └─────┘')); } - logger.log(` React Doctor ${highlighter.dim('(www.react.doctor)')}`); + logger.log(` React Doctor ${highlighter.dim('(github.com/pitis/framework-doctor)')}`); logger.break(); }; -const buildShareUrl = ( - diagnostics: Diagnostic[], - scoreResult: ScoreResult | null, - projectName: string, -): string => { - const errorCount = diagnostics.filter((diagnostic) => diagnostic.severity === 'error').length; - const warningCount = diagnostics.filter((diagnostic) => diagnostic.severity === 'warning').length; - const affectedFileCount = collectAffectedFiles(diagnostics).size; - - const params = new URLSearchParams(); - params.set('p', projectName); - if (scoreResult) params.set('s', String(scoreResult.score)); - if (errorCount > 0) params.set('e', String(errorCount)); - if (warningCount > 0) params.set('w', String(warningCount)); - if (affectedFileCount > 0) params.set('f', String(affectedFileCount)); - - return `${SHARE_BASE_URL}?${params.toString()}`; -}; - const buildBrandingLines = ( scoreResult: ScoreResult | null, noScoreMessage: string, + verbose: boolean, ): FramedLine[] => { const lines: FramedLine[] = []; @@ -247,8 +200,8 @@ const buildBrandingLines = ( lines.push(createFramedLine('└─────┘', scoreColorizer('└─────┘'))); lines.push( createFramedLine( - 'React Doctor (www.react.doctor)', - `React Doctor ${highlighter.dim('(www.react.doctor)')}`, + 'React Doctor (github.com/pitis/framework-doctor)', + `React Doctor ${highlighter.dim('(github.com/pitis/framework-doctor)')}`, ), ); lines.push(createFramedLine('')); @@ -257,15 +210,18 @@ const buildBrandingLines = ( const scoreLineRenderedText = `${colorizeByScore(String(scoreResult.score), scoreResult.score)} / ${PERFECT_SCORE} ${colorizeByScore(scoreResult.label, scoreResult.score)}`; lines.push(createFramedLine(scoreLinePlainText, scoreLineRenderedText)); lines.push(createFramedLine('')); - lines.push( - createFramedLine(buildPlainScoreBar(scoreResult.score), buildScoreBar(scoreResult.score)), - ); + const bar = buildScoreBar(scoreResult.score); + lines.push(createFramedLine(bar.plain, bar.rendered)); + if (verbose && scoreResult.breakdown) { + lines.push(createFramedLine('')); + lines.push(...buildScoreBreakdownLines(scoreResult.breakdown)); + } lines.push(createFramedLine('')); } else { lines.push( createFramedLine( - 'React Doctor (www.react.doctor)', - `React Doctor ${highlighter.dim('(www.react.doctor)')}`, + 'React Doctor (github.com/pitis/framework-doctor)', + `React Doctor ${highlighter.dim('(github.com/pitis/framework-doctor)')}`, ), ); lines.push(createFramedLine('')); @@ -276,40 +232,17 @@ const buildBrandingLines = ( return lines; }; -const buildCountsSummaryLine = ( +const toCountsFramedLine = ( diagnostics: Diagnostic[], totalSourceFileCount: number, elapsedMilliseconds: number, ): FramedLine => { - const errorCount = diagnostics.filter((diagnostic) => diagnostic.severity === 'error').length; - const warningCount = diagnostics.filter((diagnostic) => diagnostic.severity === 'warning').length; - const affectedFileCount = collectAffectedFiles(diagnostics).size; - const elapsed = formatElapsedTime(elapsedMilliseconds); - - const plainParts: string[] = []; - const renderedParts: string[] = []; - - if (errorCount > 0) { - const errorText = `✗ ${errorCount} error${errorCount === 1 ? '' : 's'}`; - plainParts.push(errorText); - renderedParts.push(highlighter.error(errorText)); - } - if (warningCount > 0) { - const warningText = `⚠ ${warningCount} warning${warningCount === 1 ? '' : 's'}`; - plainParts.push(warningText); - renderedParts.push(highlighter.warn(warningText)); - } - - const fileCountText = - totalSourceFileCount > 0 - ? `across ${affectedFileCount}/${totalSourceFileCount} files` - : `across ${affectedFileCount} file${affectedFileCount === 1 ? '' : 's'}`; - const elapsedTimeText = `in ${elapsed}`; - - plainParts.push(fileCountText, elapsedTimeText); - renderedParts.push(highlighter.dim(fileCountText), highlighter.dim(elapsedTimeText)); - - return createFramedLine(plainParts.join(' '), renderedParts.join(' ')); + const { plain, rendered } = buildCountsSummaryLine( + diagnostics, + totalSourceFileCount, + elapsedMilliseconds, + ); + return createFramedLine(plain, rendered); }; const printSummary = ( @@ -319,10 +252,11 @@ const printSummary = ( projectName: string, totalSourceFileCount: number, noScoreMessage: string, + verbose: boolean, ): void => { const summaryFramedLines = [ - ...buildBrandingLines(scoreResult, noScoreMessage), - buildCountsSummaryLine(diagnostics, totalSourceFileCount, elapsedMilliseconds), + ...buildBrandingLines(scoreResult, noScoreMessage, verbose), + toCountsFramedLine(diagnostics, totalSourceFileCount, elapsedMilliseconds), ]; printFramedBox(summaryFramedLines); @@ -333,10 +267,6 @@ const printSummary = ( } catch { logger.break(); } - - const shareUrl = buildShareUrl(diagnostics, scoreResult, projectName); - logger.break(); - logger.dim(` Share your results: ${highlighter.info(shareUrl)}`); }; const resolveOxlintNode = async ( @@ -402,7 +332,6 @@ interface ResolvedScanOptions { deadCode: boolean; verbose: boolean; scoreOnly: boolean; - offline: boolean; includePaths: string[]; } @@ -414,7 +343,6 @@ const mergeScanOptions = ( deadCode: inputOptions.deadCode ?? userConfig?.deadCode ?? true, verbose: inputOptions.verbose ?? userConfig?.verbose ?? false, scoreOnly: inputOptions.scoreOnly ?? false, - offline: inputOptions.offline ?? false, includePaths: inputOptions.includePaths ?? [], }); @@ -534,10 +462,19 @@ export const scan = async ( })() : Promise.resolve([]); - const [lintDiagnostics, deadCodeDiagnostics] = await Promise.all([lintPromise, deadCodePromise]); + const securityPromise = options.lint + ? runSecurityScan(directory, includePaths) + : Promise.resolve([]); + + const [lintDiagnostics, deadCodeDiagnostics, securityDiagnostics] = await Promise.all([ + lintPromise, + deadCodePromise, + securityPromise, + ]); const diagnostics = combineDiagnostics( lintDiagnostics, deadCodeDiagnostics, + securityDiagnostics, directory, isDiffMode, userConfig, @@ -550,8 +487,11 @@ export const scan = async ( if (didDeadCodeFail) skippedChecks.push('dead code'); const hasSkippedChecks = skippedChecks.length > 0; - const scoreResult = options.offline ? null : await calculateScore(diagnostics); - const noScoreMessage = options.offline ? OFFLINE_FLAG_MESSAGE : OFFLINE_MESSAGE; + const totalFilesScanned = isDiffMode ? includePaths.length : projectInfo.sourceFileCount; + const scoreResult = await calculateScore(diagnostics, totalFilesScanned, { + hasHighOrCriticalSecurityFindings: hasHighOrCriticalSecurityFindings(diagnostics), + }); + const noScoreMessage = OFFLINE_FLAG_MESSAGE; if (options.scoreOnly) { if (scoreResult) { @@ -559,7 +499,7 @@ export const scan = async ( } else { logger.dim(noScoreMessage); } - return { diagnostics, scoreResult, skippedChecks }; + return { diagnostics, scoreResult, skippedChecks, projectInfo }; } if (diagnostics.length === 0) { @@ -581,7 +521,7 @@ export const scan = async ( } else { logger.dim(` ${noScoreMessage}`); } - return { diagnostics, scoreResult, skippedChecks }; + return { diagnostics, scoreResult, skippedChecks, projectInfo }; } printDiagnostics(diagnostics, options.verbose); @@ -595,6 +535,7 @@ export const scan = async ( projectInfo.projectName, displayedSourceFileCount, noScoreMessage, + options.verbose, ); if (hasSkippedChecks) { @@ -603,5 +544,5 @@ export const scan = async ( logger.warn(` Note: ${skippedLabel} checks failed — score may be incomplete.`); } - return { diagnostics, scoreResult, skippedChecks }; + return { diagnostics, scoreResult, skippedChecks, projectInfo }; }; diff --git a/packages/react-doctor/src/types.ts b/packages/react-doctor/src/types.ts index 7805005..6c4d2be 100644 --- a/packages/react-doctor/src/types.ts +++ b/packages/react-doctor/src/types.ts @@ -1,3 +1,5 @@ +import type { BaseDoctorConfig, Diagnostic, ScoreResult } from '@framework-doctor/core'; + export type Framework = 'nextjs' | 'vite' | 'cra' | 'remix' | 'gatsby' | 'unknown'; export interface ProjectInfo { @@ -40,18 +42,12 @@ export interface OxlintOutput { number_of_rules: number; } -export interface Diagnostic { - filePath: string; - plugin: string; - rule: string; - severity: 'error' | 'warning'; - message: string; - help: string; - line: number; - column: number; - category: string; - weight?: number; -} +export type { + Diagnostic, + DiffInfo, + ScoreGuardrailInput, + ScoreResult, +} from '@framework-doctor/core'; export interface PackageJson { name?: string; @@ -78,22 +74,11 @@ export interface KnipIssueRecords { }; } -export interface ScoreResult { - score: number; - label: string; -} - export interface ScanResult { diagnostics: Diagnostic[]; scoreResult: ScoreResult | null; skippedChecks: string[]; -} - -export interface EstimatedScoreResult { - currentScore: number; - currentLabel: string; - estimatedScore: number; - estimatedLabel: string; + projectInfo: ProjectInfo; } export interface ScanOptions { @@ -101,17 +86,9 @@ export interface ScanOptions { deadCode?: boolean; verbose?: boolean; scoreOnly?: boolean; - offline?: boolean; includePaths?: string[]; } -export interface DiffInfo { - currentBranch: string; - baseBranch: string; - changedFiles: string[]; - isCurrentChanges?: boolean; -} - export interface HandleErrorOptions { shouldExit: boolean; } @@ -152,15 +129,6 @@ export interface CleanedDiagnostic { help: string; } -export interface ReactDoctorIgnoreConfig { - rules?: string[]; - files?: string[]; -} +export interface ReactDoctorConfig extends BaseDoctorConfig {} -export interface ReactDoctorConfig { - ignore?: ReactDoctorIgnoreConfig; - lint?: boolean; - deadCode?: boolean; - verbose?: boolean; - diff?: boolean | string; -} +export type { IgnoreConfig } from '@framework-doctor/core'; diff --git a/packages/react-doctor/src/utils/calculate-score.ts b/packages/react-doctor/src/utils/calculate-score.ts index 53efadd..73548c7 100644 --- a/packages/react-doctor/src/utils/calculate-score.ts +++ b/packages/react-doctor/src/utils/calculate-score.ts @@ -1,99 +1,9 @@ -import { - ERROR_ESTIMATED_FIX_RATE, - ERROR_RULE_PENALTY, - ESTIMATE_SCORE_API_URL, - PERFECT_SCORE, - SCORE_API_URL, - SCORE_GOOD_THRESHOLD, - SCORE_OK_THRESHOLD, - WARNING_ESTIMATED_FIX_RATE, - WARNING_RULE_PENALTY, -} from '../constants.js'; -import type { Diagnostic, EstimatedScoreResult, ScoreResult } from '../types.js'; -import { proxyFetch } from './proxy-fetch.js'; +import { calculateScore as calculateScoreFromCore } from '@framework-doctor/core'; +import type { Diagnostic, ScoreGuardrailInput, ScoreResult } from '../types.js'; -const getScoreLabel = (score: number): string => { - if (score >= SCORE_GOOD_THRESHOLD) return 'Great'; - if (score >= SCORE_OK_THRESHOLD) return 'Needs work'; - return 'Critical'; -}; - -const countUniqueRules = ( - diagnostics: Diagnostic[], -): { errorRuleCount: number; warningRuleCount: number } => { - const errorRules = new Set(); - const warningRules = new Set(); - - for (const diagnostic of diagnostics) { - const ruleKey = `${diagnostic.plugin}/${diagnostic.rule}`; - if (diagnostic.severity === 'error') { - errorRules.add(ruleKey); - } else { - warningRules.add(ruleKey); - } - } - - return { errorRuleCount: errorRules.size, warningRuleCount: warningRules.size }; -}; - -const scoreFromRuleCounts = (errorRuleCount: number, warningRuleCount: number): number => { - const penalty = errorRuleCount * ERROR_RULE_PENALTY + warningRuleCount * WARNING_RULE_PENALTY; - return Math.max(0, Math.round(PERFECT_SCORE - penalty)); -}; - -const estimateScoreLocally = (diagnostics: Diagnostic[]): EstimatedScoreResult => { - const { errorRuleCount, warningRuleCount } = countUniqueRules(diagnostics); - - const currentScore = scoreFromRuleCounts(errorRuleCount, warningRuleCount); - const estimatedUnfixedErrorRuleCount = Math.round( - errorRuleCount * (1 - ERROR_ESTIMATED_FIX_RATE), - ); - const estimatedUnfixedWarningRuleCount = Math.round( - warningRuleCount * (1 - WARNING_ESTIMATED_FIX_RATE), - ); - const estimatedScore = scoreFromRuleCounts( - estimatedUnfixedErrorRuleCount, - estimatedUnfixedWarningRuleCount, - ); - - return { - currentScore, - currentLabel: getScoreLabel(currentScore), - estimatedScore, - estimatedLabel: getScoreLabel(estimatedScore), - }; -}; - -export const calculateScore = async (diagnostics: Diagnostic[]): Promise => { - try { - const response = await proxyFetch(SCORE_API_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ diagnostics }), - }); - - if (!response.ok) return null; - - return (await response.json()) as ScoreResult; - } catch { - return null; - } -}; - -export const fetchEstimatedScore = async ( +export const calculateScore = async ( diagnostics: Diagnostic[], -): Promise => { - try { - const response = await proxyFetch(ESTIMATE_SCORE_API_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ diagnostics }), - }); - - if (!response.ok) return estimateScoreLocally(diagnostics); - - return (await response.json()) as EstimatedScoreResult; - } catch { - return estimateScoreLocally(diagnostics); - } -}; + totalFilesScanned: number, + guardrailInput: ScoreGuardrailInput = {}, +): Promise => + Promise.resolve(calculateScoreFromCore(diagnostics, totalFilesScanned, guardrailInput)); diff --git a/packages/react-doctor/src/utils/combine-diagnostics.ts b/packages/react-doctor/src/utils/combine-diagnostics.ts index d2ee406..e617020 100644 --- a/packages/react-doctor/src/utils/combine-diagnostics.ts +++ b/packages/react-doctor/src/utils/combine-diagnostics.ts @@ -11,6 +11,7 @@ export const computeJsxIncludePaths = (includePaths: string[]): string[] | undef export const combineDiagnostics = ( lintDiagnostics: Diagnostic[], deadCodeDiagnostics: Diagnostic[], + securityDiagnostics: Diagnostic[], directory: string, isDiffMode: boolean, userConfig: ReactDoctorConfig | null, @@ -18,6 +19,7 @@ export const combineDiagnostics = ( const allDiagnostics = [ ...lintDiagnostics, ...deadCodeDiagnostics, + ...securityDiagnostics, ...(isDiffMode ? [] : checkReducedMotion(directory)), ]; return userConfig ? filterIgnoredDiagnostics(allDiagnostics, userConfig) : allDiagnostics; diff --git a/packages/react-doctor/src/utils/discover-project.ts b/packages/react-doctor/src/utils/discover-project.ts index df3f09c..4e029c2 100644 --- a/packages/react-doctor/src/utils/discover-project.ts +++ b/packages/react-doctor/src/utils/discover-project.ts @@ -1,3 +1,4 @@ +import { findMonorepoRoot, isMonorepoRoot } from '@framework-doctor/core'; import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; @@ -9,7 +10,6 @@ import type { ProjectInfo, WorkspacePackage, } from '../types.js'; -import { findMonorepoRoot, isMonorepoRoot } from './find-monorepo-root.js'; import { readPackageJson } from './read-package-json.js'; const REACT_COMPILER_PACKAGES = new Set([ diff --git a/packages/react-doctor/src/utils/filter-diagnostics.ts b/packages/react-doctor/src/utils/filter-diagnostics.ts index 827ef4a..16eb82b 100644 --- a/packages/react-doctor/src/utils/filter-diagnostics.ts +++ b/packages/react-doctor/src/utils/filter-diagnostics.ts @@ -1,5 +1,5 @@ +import { compileGlobPattern } from '@framework-doctor/core'; import type { Diagnostic, ReactDoctorConfig } from '../types.js'; -import { compileGlobPattern } from './match-glob-pattern.js'; export const filterIgnoredDiagnostics = ( diagnostics: Diagnostic[], diff --git a/packages/react-doctor/src/utils/framed-box.ts b/packages/react-doctor/src/utils/framed-box.ts deleted file mode 100644 index 6065348..0000000 --- a/packages/react-doctor/src/utils/framed-box.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { - SUMMARY_BOX_HORIZONTAL_PADDING_CHARS, - SUMMARY_BOX_OUTER_INDENT_CHARS, -} from '../constants.js'; -import { highlighter } from './highlighter.js'; -import { logger } from './logger.js'; - -export interface FramedLine { - plainText: string; - renderedText: string; -} - -export const createFramedLine = ( - plainText: string, - renderedText: string = plainText, -): FramedLine => ({ - plainText, - renderedText, -}); - -export const renderFramedBoxString = (framedLines: FramedLine[]): string => { - if (framedLines.length === 0) return ''; - - const borderColorizer = highlighter.dim; - const outerIndent = ' '.repeat(SUMMARY_BOX_OUTER_INDENT_CHARS); - const horizontalPadding = ' '.repeat(SUMMARY_BOX_HORIZONTAL_PADDING_CHARS); - const maximumLineLength = Math.max( - ...framedLines.map((framedLine) => framedLine.plainText.length), - ); - const borderLine = '─'.repeat(maximumLineLength + SUMMARY_BOX_HORIZONTAL_PADDING_CHARS * 2); - - const lines: string[] = []; - lines.push(`${outerIndent}${borderColorizer(`┌${borderLine}┐`)}`); - - for (const framedLine of framedLines) { - const trailingSpaces = ' '.repeat(maximumLineLength - framedLine.plainText.length); - lines.push( - `${outerIndent}${borderColorizer('│')}${horizontalPadding}${framedLine.renderedText}${trailingSpaces}${horizontalPadding}${borderColorizer('│')}`, - ); - } - - lines.push(`${outerIndent}${borderColorizer(`└${borderLine}┘`)}`); - return lines.join('\n'); -}; - -export const printFramedBox = (framedLines: FramedLine[]): void => { - const rendered = renderFramedBoxString(framedLines); - if (rendered) { - logger.log(rendered); - } -}; diff --git a/packages/react-doctor/src/utils/get-diff-files.ts b/packages/react-doctor/src/utils/get-diff-files.ts index 50d6766..5bf9be7 100644 --- a/packages/react-doctor/src/utils/get-diff-files.ts +++ b/packages/react-doctor/src/utils/get-diff-files.ts @@ -1,98 +1,10 @@ -import { execSync } from 'node:child_process'; -import { DEFAULT_BRANCH_CANDIDATES, SOURCE_FILE_PATTERN } from '../constants.js'; -import type { DiffInfo } from '../types.js'; +import { + filterSourceFiles as filterSourceFilesCore, + getDiffInfo as getDiffInfoCore, + SOURCE_FILE_PATTERN_REACT, +} from '@framework-doctor/core'; -const getCurrentBranch = (directory: string): string | null => { - try { - const branch = execSync('git rev-parse --abbrev-ref HEAD', { - cwd: directory, - stdio: 'pipe', - }) - .toString() - .trim(); - return branch === 'HEAD' ? null : branch; - } catch { - return null; - } -}; - -const detectDefaultBranch = (directory: string): string | null => { - try { - const reference = execSync('git symbolic-ref refs/remotes/origin/HEAD', { - cwd: directory, - stdio: 'pipe', - }) - .toString() - .trim(); - return reference.replace('refs/remotes/origin/', ''); - } catch { - for (const candidate of DEFAULT_BRANCH_CANDIDATES) { - try { - execSync(`git rev-parse --verify ${candidate}`, { - cwd: directory, - stdio: 'pipe', - }); - return candidate; - } catch {} - } - return null; - } -}; - -const getChangedFilesSinceBranch = (directory: string, baseBranch: string): string[] => { - try { - const mergeBase = execSync(`git merge-base ${baseBranch} HEAD`, { - cwd: directory, - stdio: 'pipe', - }) - .toString() - .trim(); - - const output = execSync(`git diff --name-only --diff-filter=ACMR --relative ${mergeBase}`, { - cwd: directory, - stdio: 'pipe', - }) - .toString() - .trim(); - - if (!output) return []; - return output.split('\n').filter(Boolean); - } catch { - return []; - } -}; - -const getUncommittedChangedFiles = (directory: string): string[] => { - try { - const output = execSync('git diff --name-only --diff-filter=ACMR --relative HEAD', { - cwd: directory, - stdio: 'pipe', - }) - .toString() - .trim(); - if (!output) return []; - return output.split('\n').filter(Boolean); - } catch { - return []; - } -}; - -export const getDiffInfo = (directory: string, explicitBaseBranch?: string): DiffInfo | null => { - const currentBranch = getCurrentBranch(directory); - if (!currentBranch) return null; - - const baseBranch = explicitBaseBranch ?? detectDefaultBranch(directory); - if (!baseBranch) return null; - - if (currentBranch === baseBranch) { - const uncommittedFiles = getUncommittedChangedFiles(directory); - if (uncommittedFiles.length === 0) return null; - return { currentBranch, baseBranch, changedFiles: uncommittedFiles, isCurrentChanges: true }; - } - - const changedFiles = getChangedFilesSinceBranch(directory, baseBranch); - return { currentBranch, baseBranch, changedFiles }; -}; +export const getDiffInfo = getDiffInfoCore; export const filterSourceFiles = (filePaths: string[]): string[] => - filePaths.filter((filePath) => SOURCE_FILE_PATTERN.test(filePath)); + filterSourceFilesCore(filePaths, SOURCE_FILE_PATTERN_REACT); diff --git a/packages/react-doctor/src/utils/handle-error.ts b/packages/react-doctor/src/utils/handle-error.ts index fc10845..65da7ea 100644 --- a/packages/react-doctor/src/utils/handle-error.ts +++ b/packages/react-doctor/src/utils/handle-error.ts @@ -1,5 +1,5 @@ +import { logger } from '@framework-doctor/core'; import type { HandleErrorOptions } from '../types.js'; -import { logger } from './logger.js'; const DEFAULT_HANDLE_ERROR_OPTIONS: HandleErrorOptions = { shouldExit: true, diff --git a/packages/react-doctor/src/utils/highlighter.ts b/packages/react-doctor/src/utils/highlighter.ts deleted file mode 100644 index b993e35..0000000 --- a/packages/react-doctor/src/utils/highlighter.ts +++ /dev/null @@ -1,9 +0,0 @@ -import pc from 'picocolors'; - -export const highlighter = { - error: pc.red, - warn: pc.yellow, - info: pc.cyan, - success: pc.green, - dim: pc.dim, -}; diff --git a/packages/react-doctor/src/utils/load-config.ts b/packages/react-doctor/src/utils/load-config.ts index d49513e..8760fcb 100644 --- a/packages/react-doctor/src/utils/load-config.ts +++ b/packages/react-doctor/src/utils/load-config.ts @@ -1,46 +1,8 @@ -import fs from 'node:fs'; -import path from 'node:path'; +import { loadConfig as loadConfigFromCore } from '@framework-doctor/core'; import type { ReactDoctorConfig } from '../types.js'; const CONFIG_FILENAME = 'react-doctor.config.json'; const PACKAGE_JSON_CONFIG_KEY = 'reactDoctor'; -const isPlainObject = (value: unknown): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value); - -export const loadConfig = (rootDirectory: string): ReactDoctorConfig | null => { - const configFilePath = path.join(rootDirectory, CONFIG_FILENAME); - - if (fs.existsSync(configFilePath)) { - try { - const fileContent = fs.readFileSync(configFilePath, 'utf-8'); - const parsed: unknown = JSON.parse(fileContent); - if (!isPlainObject(parsed)) { - console.warn(`Warning: ${CONFIG_FILENAME} must be a JSON object, ignoring.`); - return null; - } - return parsed as ReactDoctorConfig; - } catch (error) { - console.warn( - `Warning: Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`, - ); - return null; - } - } - - const packageJsonPath = path.join(rootDirectory, 'package.json'); - if (fs.existsSync(packageJsonPath)) { - try { - const fileContent = fs.readFileSync(packageJsonPath, 'utf-8'); - const packageJson = JSON.parse(fileContent); - const embeddedConfig = packageJson[PACKAGE_JSON_CONFIG_KEY]; - if (isPlainObject(embeddedConfig)) { - return embeddedConfig as ReactDoctorConfig; - } - } catch { - return null; - } - } - - return null; -}; +export const loadConfig = (rootDirectory: string): ReactDoctorConfig | null => + loadConfigFromCore(rootDirectory, CONFIG_FILENAME, PACKAGE_JSON_CONFIG_KEY); diff --git a/packages/react-doctor/src/utils/prompts.ts b/packages/react-doctor/src/utils/prompts.ts index 889b47c..31307d1 100644 --- a/packages/react-doctor/src/utils/prompts.ts +++ b/packages/react-doctor/src/utils/prompts.ts @@ -1,7 +1,7 @@ +import { logger } from '@framework-doctor/core'; import { createRequire } from 'node:module'; import basePrompts, { type Answers, type PromptObject } from 'prompts'; import type { PromptMultiselectContext } from '../types.js'; -import { logger } from './logger.js'; import { shouldAutoSelectCurrentChoice } from './should-auto-select-current-choice.js'; import { shouldSelectAllChoices } from './should-select-all-choices.js'; diff --git a/packages/react-doctor/src/utils/proxy-fetch.ts b/packages/react-doctor/src/utils/proxy-fetch.ts deleted file mode 100644 index 3d26d58..0000000 --- a/packages/react-doctor/src/utils/proxy-fetch.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { execSync } from 'node:child_process'; -import { FETCH_TIMEOUT_MS } from '../constants.js'; - -const readNpmConfigValue = (key: string): string | undefined => { - try { - const value = execSync(`npm config get ${key}`, { - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'ignore'], - }).trim(); - if (value && value !== 'null' && value !== 'undefined') return value; - } catch {} - return undefined; -}; - -const resolveProxyUrl = (): string | undefined => - process.env.HTTPS_PROXY ?? - process.env.https_proxy ?? - process.env.HTTP_PROXY ?? - process.env.http_proxy ?? - readNpmConfigValue('https-proxy') ?? - readNpmConfigValue('proxy'); - -let isProxyUrlResolved = false; -let resolvedProxyUrl: string | undefined; - -const getProxyUrl = (): string | undefined => { - if (isProxyUrlResolved) return resolvedProxyUrl; - isProxyUrlResolved = true; - resolvedProxyUrl = resolveProxyUrl(); - return resolvedProxyUrl; -}; - -const createProxyDispatcher = async (proxyUrl: string): Promise => { - try { - // @ts-expect-error undici is bundled with Node.js 18+ but lacks standalone type declarations - const { ProxyAgent } = await import('undici'); - return new ProxyAgent(proxyUrl); - } catch { - return null; - } -}; - -// HACK: Node.js's global fetch (undici) accepts `dispatcher` for proxy routing, -// which isn't part of the standard RequestInit type. -export const proxyFetch = async (url: string | URL, init?: RequestInit): Promise => { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); - - try { - const proxyUrl = getProxyUrl(); - const dispatcher = proxyUrl ? await createProxyDispatcher(proxyUrl) : null; - - return await fetch(url, { - ...init, - signal: controller.signal, - ...(dispatcher ? { dispatcher } : {}), - } as RequestInit); - } finally { - clearTimeout(timeoutId); - } -}; diff --git a/packages/react-doctor/src/utils/read-package-json.ts b/packages/react-doctor/src/utils/read-package-json.ts index d02dc80..7d4f356 100644 --- a/packages/react-doctor/src/utils/read-package-json.ts +++ b/packages/react-doctor/src/utils/read-package-json.ts @@ -1,5 +1,5 @@ -import fs from 'node:fs'; +import { readJson } from '@framework-doctor/core'; import type { PackageJson } from '../types.js'; export const readPackageJson = (packageJsonPath: string): PackageJson => - JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); + readJson(packageJsonPath); diff --git a/packages/react-doctor/src/utils/run-knip.ts b/packages/react-doctor/src/utils/run-knip.ts index daa1270..d188274 100644 --- a/packages/react-doctor/src/utils/run-knip.ts +++ b/packages/react-doctor/src/utils/run-knip.ts @@ -1,10 +1,10 @@ +import { findMonorepoRoot } from '@framework-doctor/core'; import { main } from 'knip'; import { createOptions } from 'knip/session'; import fs from 'node:fs'; import path from 'node:path'; import { MAX_KNIP_RETRIES } from '../constants.js'; import type { Diagnostic, KnipIssueRecords, KnipResults } from '../types.js'; -import { findMonorepoRoot } from './find-monorepo-root.js'; const KNIP_CATEGORY_MAP: Record = { files: 'Dead Code', diff --git a/packages/react-doctor/src/utils/run-security-scan.ts b/packages/react-doctor/src/utils/run-security-scan.ts new file mode 100644 index 0000000..24a349f --- /dev/null +++ b/packages/react-doctor/src/utils/run-security-scan.ts @@ -0,0 +1,14 @@ +import { + DANGEROUSLY_SET_INNER_HTML_RULE, + runSecurityScan as runSecurityScanCore, +} from '@framework-doctor/core'; + +const REACT_PLUGIN = 'react-doctor'; + +const REACT_SECURITY_RULES = [DANGEROUSLY_SET_INNER_HTML_RULE]; + +export const runSecurityScan = async (rootDirectory: string, includePaths: string[]) => + runSecurityScanCore(rootDirectory, includePaths, { + plugin: REACT_PLUGIN, + rules: REACT_SECURITY_RULES, + }); diff --git a/packages/react-doctor/src/utils/select-projects.ts b/packages/react-doctor/src/utils/select-projects.ts index 3f188f2..d9f886b 100644 --- a/packages/react-doctor/src/utils/select-projects.ts +++ b/packages/react-doctor/src/utils/select-projects.ts @@ -1,8 +1,7 @@ +import { highlighter, logger } from '@framework-doctor/core'; import path from 'node:path'; import type { WorkspacePackage } from '../types.js'; import { discoverReactSubprojects, listWorkspacePackages } from './discover-project.js'; -import { highlighter } from './highlighter.js'; -import { logger } from './logger.js'; import { prompts } from './prompts.js'; export const selectProjects = async ( diff --git a/packages/react-doctor/src/utils/skill-prompt.ts b/packages/react-doctor/src/utils/skill-prompt.ts index c47c1d9..a42c46e 100644 --- a/packages/react-doctor/src/utils/skill-prompt.ts +++ b/packages/react-doctor/src/utils/skill-prompt.ts @@ -1,14 +1,11 @@ +import { highlighter, logger, readGlobalConfig, writeGlobalConfig } from '@framework-doctor/core'; import { execSync } from 'node:child_process'; import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; -import { highlighter } from './highlighter.js'; -import { logger } from './logger.js'; import { prompts } from './prompts.js'; const HOME_DIRECTORY = homedir(); -const CONFIG_DIRECTORY = join(HOME_DIRECTORY, '.framework-doctor'); -const CONFIG_FILE = join(CONFIG_DIRECTORY, 'config.json'); const SKILL_NAME = 'react-doctor'; const WINDSURF_MARKER = '# React Doctor'; @@ -57,32 +54,12 @@ const CODEX_AGENT_CONFIG = `interface: short_description: "Diagnose and fix React codebase health issues" `; -interface SkillPromptConfig { - skillPromptDismissed?: boolean; -} - interface SkillTarget { name: string; detect: () => boolean; install: () => void; } -const readSkillPromptConfig = (): SkillPromptConfig => { - try { - if (!existsSync(CONFIG_FILE)) return {}; - return JSON.parse(readFileSync(CONFIG_FILE, 'utf-8')); - } catch { - return {}; - } -}; - -const writeSkillPromptConfig = (config: SkillPromptConfig): void => { - try { - mkdirSync(CONFIG_DIRECTORY, { recursive: true }); - writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)); - } catch {} -}; - const writeSkillFiles = (directory: string): void => { mkdirSync(directory, { recursive: true }); writeFileSync(join(directory, 'SKILL.md'), SKILL_CONTENT); @@ -198,7 +175,7 @@ const installSkill = (): void => { }; export const maybePromptSkillInstall = async (shouldSkipPrompts: boolean): Promise => { - const config = readSkillPromptConfig(); + const config = readGlobalConfig(); if (config.skillPromptDismissed) return; if (shouldSkipPrompts) return; @@ -207,7 +184,7 @@ export const maybePromptSkillInstall = async (shouldSkipPrompts: boolean): Promi logger.dim( ` Install the ${highlighter.info('react-doctor')} skill to teach Cursor, Claude Code,`, ); - logger.dim(' Ami, and other AI agents how to diagnose and fix React issues.'); + logger.dim(' and other AI agents how to diagnose and fix React issues.'); logger.break(); const { shouldInstall } = await prompts({ @@ -222,5 +199,5 @@ export const maybePromptSkillInstall = async (shouldSkipPrompts: boolean): Promi installSkill(); } - writeSkillPromptConfig({ ...config, skillPromptDismissed: true }); + writeGlobalConfig({ ...config, skillPromptDismissed: true }); }; diff --git a/packages/react-doctor/src/utils/spinner.ts b/packages/react-doctor/src/utils/spinner.ts deleted file mode 100644 index b8499cd..0000000 --- a/packages/react-doctor/src/utils/spinner.ts +++ /dev/null @@ -1,44 +0,0 @@ -import ora from 'ora'; - -let sharedInstance: ReturnType | null = null; -let activeCount = 0; -const pendingTexts = new Set(); - -const finalize = (method: 'succeed' | 'fail', originalText: string, displayText: string) => { - pendingTexts.delete(originalText); - activeCount--; - - if (activeCount <= 0 || !sharedInstance) { - sharedInstance?.[method](displayText); - sharedInstance = null; - activeCount = 0; - return; - } - - sharedInstance.stop(); - ora(displayText).start()[method](displayText); - - const [remainingText] = pendingTexts; - if (remainingText) { - sharedInstance.text = remainingText; - } - sharedInstance.start(); -}; - -export const spinner = (text: string) => ({ - start() { - activeCount++; - pendingTexts.add(text); - - if (!sharedInstance) { - sharedInstance = ora({ text }).start(); - } else { - sharedInstance.text = text; - } - - return { - succeed: (displayText: string) => finalize('succeed', text, displayText), - fail: (displayText: string) => finalize('fail', text, displayText), - }; - }, -}); diff --git a/packages/react-doctor/src/utils/telemetry.ts b/packages/react-doctor/src/utils/telemetry.ts new file mode 100644 index 0000000..33d1144 --- /dev/null +++ b/packages/react-doctor/src/utils/telemetry.ts @@ -0,0 +1,52 @@ +import { + maybePromptAnalyticsConsent as coreMaybePromptAnalyticsConsent, + sendScanEvent as coreSendScanEvent, + shouldSendAnalytics as coreShouldSendAnalytics, + highlighter, + logger, + type TelemetryEventPayload, +} from '@framework-doctor/core'; +import type { ProjectInfo, ScoreResult } from '../types.js'; +import { prompts } from './prompts.js'; + +export const shouldSendAnalytics = coreShouldSendAnalytics; + +export const maybePromptAnalyticsConsent = async (shouldSkipPrompts: boolean): Promise => + coreMaybePromptAnalyticsConsent(shouldSkipPrompts, async () => { + logger.break(); + logger.log(`${highlighter.info('?')} Help improve react-doctor?`); + logger.dim(' Anonymous usage (framework, score range). No code or paths sent.'); + logger.break(); + + const { analyticsEnabled } = await prompts({ + type: 'confirm', + name: 'analyticsEnabled', + message: 'Share anonymous analytics?', + initial: true, + }); + return Boolean(analyticsEnabled); + }); + +const buildPayload = ( + projectInfo: ProjectInfo, + scoreResult: ScoreResult, + diagnosticCount: number, + options: { isDiffMode: boolean; cliVersion: string }, +): TelemetryEventPayload => ({ + doctor_family: 'react', + framework: projectInfo.framework, + score: scoreResult.score, + diagnostic_count: diagnosticCount, + has_typescript: projectInfo.hasTypeScript, + is_diff_mode: options.isDiffMode, + cli_version: options.cliVersion, +}); + +export const sendScanEvent = ( + telemetryUrl: string, + projectInfo: ProjectInfo, + scoreResult: ScoreResult, + diagnosticCount: number, + options: { isDiffMode: boolean; cliVersion: string }, +): void => + coreSendScanEvent(telemetryUrl, buildPayload(projectInfo, scoreResult, diagnosticCount, options)); diff --git a/packages/react-doctor/tests/colorize-by-score.test.ts b/packages/react-doctor/tests/colorize-by-score.test.ts index a1174e4..1004b52 100644 --- a/packages/react-doctor/tests/colorize-by-score.test.ts +++ b/packages/react-doctor/tests/colorize-by-score.test.ts @@ -1,5 +1,5 @@ +import { colorizeByScore } from '@framework-doctor/core'; import { describe, expect, it } from 'vitest'; -import { colorizeByScore } from '../src/utils/colorize-by-score.js'; describe('colorizeByScore', () => { it('returns a string for high scores', () => { diff --git a/packages/react-doctor/tests/combine-diagnostics.test.ts b/packages/react-doctor/tests/combine-diagnostics.test.ts index de497aa..9372f71 100644 --- a/packages/react-doctor/tests/combine-diagnostics.test.ts +++ b/packages/react-doctor/tests/combine-diagnostics.test.ts @@ -38,14 +38,14 @@ describe('combineDiagnostics', () => { const lintDiagnostics = [createDiagnostic({ rule: 'lint-rule' })]; const deadCodeDiagnostics = [createDiagnostic({ rule: 'dead-code-rule' })]; - const result = combineDiagnostics(lintDiagnostics, deadCodeDiagnostics, '/tmp', true, null); + const result = combineDiagnostics(lintDiagnostics, deadCodeDiagnostics, [], '/tmp', true, null); expect(result).toHaveLength(2); expect(result[0].rule).toBe('lint-rule'); expect(result[1].rule).toBe('dead-code-rule'); }); it('returns empty array when both inputs are empty in diff mode', () => { - const result = combineDiagnostics([], [], '/tmp', true, null); + const result = combineDiagnostics([], [], [], '/tmp', true, null); expect(result).toEqual([]); }); @@ -58,14 +58,14 @@ describe('combineDiagnostics', () => { ignore: { rules: ['react/no-danger'] }, }; - const result = combineDiagnostics(diagnostics, [], '/tmp', true, config); + const result = combineDiagnostics(diagnostics, [], [], '/tmp', true, config); expect(result).toHaveLength(1); expect(result[0].rule).toBe('no-giant-component'); }); it('skips config filtering when userConfig is null', () => { const diagnostics = [createDiagnostic(), createDiagnostic()]; - const result = combineDiagnostics(diagnostics, [], '/tmp', true, null); + const result = combineDiagnostics(diagnostics, [], [], '/tmp', true, null); expect(result).toHaveLength(2); }); }); diff --git a/packages/react-doctor/tests/find-monorepo-root.test.ts b/packages/react-doctor/tests/find-monorepo-root.test.ts index c591803..92eb91a 100644 --- a/packages/react-doctor/tests/find-monorepo-root.test.ts +++ b/packages/react-doctor/tests/find-monorepo-root.test.ts @@ -1,6 +1,6 @@ +import { findMonorepoRoot, isMonorepoRoot } from '@framework-doctor/core'; import path from 'node:path'; import { describe, expect, it } from 'vitest'; -import { findMonorepoRoot, isMonorepoRoot } from '../src/utils/find-monorepo-root.js'; const FIXTURES_DIRECTORY = path.resolve(import.meta.dirname, 'fixtures'); diff --git a/packages/react-doctor/tests/indent-multiline-text.test.ts b/packages/react-doctor/tests/indent-multiline-text.test.ts index f02ee7b..cb2bb35 100644 --- a/packages/react-doctor/tests/indent-multiline-text.test.ts +++ b/packages/react-doctor/tests/indent-multiline-text.test.ts @@ -1,5 +1,5 @@ +import { indentMultilineText } from '@framework-doctor/core'; import { describe, expect, it } from 'vitest'; -import { indentMultilineText } from '../src/utils/indent-multiline-text.js'; describe('indentMultilineText', () => { it('adds the prefix to a single line', () => { diff --git a/packages/react-doctor/tests/load-config.test.ts b/packages/react-doctor/tests/load-config.test.ts index d4fad80..81f0d48 100644 --- a/packages/react-doctor/tests/load-config.test.ts +++ b/packages/react-doctor/tests/load-config.test.ts @@ -1,7 +1,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { loadConfig } from '../src/utils/load-config.js'; const tempRootDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'react-doctor-config-test-')); @@ -168,20 +168,14 @@ describe('loadConfig', () => { ); }); - it('returns null and warns for malformed JSON', () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + it('returns null for malformed JSON', () => { const config = loadConfig(invalidJsonDirectory); expect(config).toBeNull(); - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to parse')); - warnSpy.mockRestore(); }); - it('returns null and warns when config is not an object', () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + it('returns null when config is not an object', () => { const config = loadConfig(nonObjectDirectory); expect(config).toBeNull(); - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('must be a JSON object')); - warnSpy.mockRestore(); }); it('ignores non-object reactDoctor key in package.json', () => { diff --git a/packages/react-doctor/tests/match-glob-pattern.test.ts b/packages/react-doctor/tests/match-glob-pattern.test.ts index 974b75d..1c44f66 100644 --- a/packages/react-doctor/tests/match-glob-pattern.test.ts +++ b/packages/react-doctor/tests/match-glob-pattern.test.ts @@ -1,5 +1,5 @@ +import { matchGlobPattern } from '@framework-doctor/core'; import { describe, expect, it } from 'vitest'; -import { matchGlobPattern } from '../src/utils/match-glob-pattern.js'; describe('matchGlobPattern', () => { it('matches exact file paths', () => { diff --git a/packages/svelte-doctor/CHANGELOG.md b/packages/svelte-doctor/CHANGELOG.md index 24ded8d..24fce96 100644 --- a/packages/svelte-doctor/CHANGELOG.md +++ b/packages/svelte-doctor/CHANGELOG.md @@ -1,5 +1,20 @@ # svelte-doctor +## 1.0.2 + +### Patch Changes + +- added telemetry and refactored core +- Updated dependencies + - @framework-doctor/core@1.0.2 + +## 1.0.1 + +### Patch Changes + +- cb322c3: Initial release for Svelte and React doctors +- Updated docs + ## 1.0.0 ### Major Changes diff --git a/packages/svelte-doctor/README.md b/packages/svelte-doctor/README.md new file mode 100644 index 0000000..8d80e92 --- /dev/null +++ b/packages/svelte-doctor/README.md @@ -0,0 +1,109 @@ +# Svelte Doctor + +[![version](https://img.shields.io/npm/v/@framework-doctor/svelte.svg?style=flat)](https://npmjs.com/package/@framework-doctor/svelte) +[![downloads](https://img.shields.io/npm/dm/@framework-doctor/svelte.svg?style=flat)](https://npmjs.com/package/@framework-doctor/svelte) + +Diagnose and improve your Svelte codebase health. + +One command scans your codebase for security, performance, correctness, dead code, and Svelte 5 migration issues, then outputs a **0–100 score** with actionable diagnostics. + +## Install + +Run at your project root: + +```bash +npx -y @framework-doctor/svelte . +``` + +Or use the unified CLI (auto-detects Svelte): + +```bash +npx -y @framework-doctor/cli . +``` + +## Options + +``` +Usage: svelte-doctor [directory] [options] + +Options: + -v, --version display the version number + --no-lint skip lint diagnostics + --no-js-ts-lint skip JavaScript/TypeScript lint diagnostics + --no-dead-code skip dead code detection + --verbose show file details per rule + --score output only the score (CI-friendly) + -y, --yes skip prompts + --no-analytics disable anonymous analytics + --project select workspace project (comma-separated) + --diff [base] scan only changed files vs base branch + --offline skip remote scoring (local score only) + -h, --help display help for command +``` + +## Configuration + +Create `svelte-doctor.config.json`: + +```json +{ + "ignore": { + "rules": ["svelte-check/a11y-missing-attribute", "svelte-doctor/no-at-html"], + "files": ["src/generated/**"] + }, + "lint": true, + "jsTsLint": true, + "deadCode": true, + "verbose": false, + "diff": false, + "analytics": true +} +``` + +Or use the `svelteDoctor` key in `package.json`: + +```json +{ + "svelteDoctor": { + "deadCode": true, + "ignore": { "rules": ["svelte-doctor/no-at-html"] } + } +} +``` + +## Security checks + +Svelte Doctor flags: + +- **`{@html}`** — Raw HTML can lead to XSS if content is unsanitized +- **`new Function()`** — Code injection risk +- **`setTimeout("string")` / `setInterval("string")`** — Implied eval + +Plus oxlint's `no-eval` and svelte-check's `a11y_invalid_attribute` (e.g. `javascript:` URLs in `href`). + +To ignore a rule: `"svelte-doctor/no-at-html"`, `"svelte-doctor/no-new-function"`, `"svelte-doctor/no-implied-eval"`. + +## Analytics + +Svelte Doctor optionally sends anonymous usage data when you opt in. Data is sent to your Supabase Edge Function (see [supabase/README.md](../../supabase/README.md)) when `FRAMEWORK_DOCTOR_TELEMETRY_URL` is configured. If your function enforces `TELEMETRY_KEY`, set `FRAMEWORK_DOCTOR_TELEMETRY_KEY` in the client environment. Limited to framework type, score range, diagnostic count. No code or paths are collected. + +- **Opt-in**: On first run (when analytics is configured), you’ll be prompted. Your choice is stored in `~/.framework-doctor/config.json`. +- **Disable**: Use `--no-analytics`, set `"analytics": false` in config, or `DO_NOT_TRACK=1`. +- **Skipped automatically**: CI and other non-interactive environments (e.g. Cursor Agent, Claude Code). + +## Contributing + +```bash +git clone https://github.com/pitis/framework-doctor +cd framework-doctor +pnpm install +pnpm build +``` + +Run locally: + +```bash +pnpm exec svelte-doctor /path/to/your/svelte-project +# or directly: +node packages/svelte-doctor/dist/cli.js /path/to/your/svelte-project +``` diff --git a/packages/svelte-doctor/package.json b/packages/svelte-doctor/package.json index 18bcb73..c1cbd4f 100644 --- a/packages/svelte-doctor/package.json +++ b/packages/svelte-doctor/package.json @@ -1,6 +1,6 @@ { "name": "@framework-doctor/svelte", - "version": "1.0.0", + "version": "1.0.2", "description": "Diagnose and improve Svelte codebase health", "author": { "name": "Pitis Radu", @@ -65,15 +65,18 @@ "prepublishOnly": "pnpm run build" }, "dependencies": { + "@framework-doctor/core": "workspace:*", "commander": "catalog:", "knip": "catalog:", "ora": "catalog:", "oxlint": "catalog:", "picocolors": "catalog:", + "prompts": "^2.4.2", "svelte-check": "^4.1.6" }, "devDependencies": { "@types/node": "catalog:", + "@types/prompts": "catalog:", "cross-env": "catalog:", "rimraf": "catalog:", "tsdown": "catalog:", diff --git a/packages/svelte-doctor/src/cli.ts b/packages/svelte-doctor/src/cli.ts index d1012b3..950283d 100644 --- a/packages/svelte-doctor/src/cli.ts +++ b/packages/svelte-doctor/src/cli.ts @@ -1,30 +1,38 @@ +import { + addAnalyticsOption, + buildCountsSummaryLine, + buildScoreBar, + buildScoreBreakdownLines, + colorizeByScore, + createFramedLine, + getDoctorFace, + groupBy, + highlighter, + indentMultilineText, + isAutomatedEnvironment, + logger, + PERFECT_SCORE, + printFramedBox, + spinner, +} from '@framework-doctor/core'; import { Command } from 'commander'; import path from 'node:path'; import { performance } from 'node:perf_hooks'; -import { - PERFECT_SCORE, - SCORE_BAR_WIDTH_CHARS, - SCORE_GOOD_THRESHOLD, - SCORE_OK_THRESHOLD, -} from './constants.js'; -import type { Diagnostic, ScanOptions, SvelteDoctorConfig } from './types.js'; -import { colorizeByScore } from './ui/colorize-by-score.js'; -import { createFramedLine, printFramedBox } from './ui/framed-box.js'; -import { highlighter } from './ui/highlighter.js'; -import { logger } from './ui/logger.js'; -import { spinner } from './ui/spinner.js'; +import type { Diagnostic, ScanOptions, ScoreResult, SvelteDoctorConfig } from './types.js'; import { discoverProject } from './utils/discover-project.js'; import { filterIgnoredDiagnostics } from './utils/filter-diagnostics.js'; -import { formatElapsedTime } from './utils/format-elapsed-time.js'; import { filterSourceFiles, getDiffInfo } from './utils/get-diff-files.js'; -import { groupBy } from './utils/group-by.js'; -import { indentMultilineText } from './utils/indent-multiline-text.js'; import { loadConfig } from './utils/load-config.js'; import { runKnip } from './utils/run-knip.js'; import { runOxlint } from './utils/run-oxlint.js'; import { runSecurityScan } from './utils/run-security-scan.js'; import { runSvelteCheck } from './utils/run-svelte-check.js'; import { calculateScore } from './utils/score.js'; +import { + maybePromptAnalyticsConsent, + sendScanEvent, + shouldSendAnalytics, +} from './utils/telemetry.js'; const VERSION = process.env.VERSION ?? '0.0.0'; @@ -35,6 +43,7 @@ interface CliFlags { verbose: boolean; score: boolean; yes: boolean; + analytics: boolean; project?: string; diff?: boolean | string; offline?: boolean; @@ -51,9 +60,6 @@ const colorizeBySeverity = (text: string, severity: Diagnostic['severity']): str const sortBySeverity = (groups: [string, Diagnostic[]][]): [string, Diagnostic[]][] => groups.toSorted(([, a], [, b]) => SEVERITY_ORDER[a[0].severity] - SEVERITY_ORDER[b[0].severity]); -const collectAffectedFiles = (diagnostics: Diagnostic[]): Set => - new Set(diagnostics.map((d) => d.filePath)); - const buildFileLineMap = (diagnostics: Diagnostic[]): Map => { const map = new Map(); for (const d of diagnostics) { @@ -64,6 +70,11 @@ const buildFileLineMap = (diagnostics: Diagnostic[]): Map => { return map; }; +const hasHighOrCriticalSecurityFindings = (diagnostics: Diagnostic[]): boolean => + diagnostics.some( + (diagnostic) => diagnostic.category === 'security' && diagnostic.severity === 'error', + ); + const printRuleGroup = (ruleDiagnostics: Diagnostic[], verbose: boolean): void => { const first = ruleDiagnostics[0]; const icon = colorizeBySeverity(first.severity === 'error' ? '✗' : '⚠', first.severity); @@ -93,72 +104,20 @@ const printDiagnostics = (diagnostics: Diagnostic[], verbose: boolean): void => } }; -const getDoctorFace = (score: number): [string, string] => { - if (score >= SCORE_GOOD_THRESHOLD) return ['◠ ◠', ' ▽ ']; - if (score >= SCORE_OK_THRESHOLD) return ['• •', ' ─ ']; - return ['x x', ' ▽ ']; -}; - -const buildScoreBar = (score: number): { plain: string; rendered: string } => { - const filledCount = Math.round((score / PERFECT_SCORE) * SCORE_BAR_WIDTH_CHARS); - const emptyCount = SCORE_BAR_WIDTH_CHARS - filledCount; - const filled = '█'.repeat(filledCount); - const empty = '░'.repeat(emptyCount); - return { - plain: filled + empty, - rendered: colorizeByScore(filled, score) + highlighter.dim(empty), - }; -}; - -const buildCountsSummaryLine = ( - diagnostics: Diagnostic[], - totalSourceFileCount: number, - elapsedMs: number, -): { plain: string; rendered: string } => { - const errorCount = diagnostics.filter((d) => d.severity === 'error').length; - const warningCount = diagnostics.filter((d) => d.severity === 'warning').length; - const affectedCount = collectAffectedFiles(diagnostics).size; - const elapsed = formatElapsedTime(elapsedMs); - - const plainParts: string[] = []; - const renderedParts: string[] = []; - - if (errorCount > 0) { - const text = `✗ ${errorCount} error${errorCount === 1 ? '' : 's'}`; - plainParts.push(text); - renderedParts.push(highlighter.error(text)); - } - if (warningCount > 0) { - const text = `⚠ ${warningCount} warning${warningCount === 1 ? '' : 's'}`; - plainParts.push(text); - renderedParts.push(highlighter.warn(text)); - } - - const fileSuffix = affectedCount === 1 ? '' : 's'; - const fileText = - totalSourceFileCount > 0 - ? `across ${affectedCount}/${totalSourceFileCount} files` - : `across ${affectedCount} file${fileSuffix}`; - const timeText = `in ${elapsed}`; - plainParts.push(fileText, timeText); - renderedParts.push(highlighter.dim(fileText), highlighter.dim(timeText)); - - return { plain: plainParts.join(' '), rendered: renderedParts.join(' ') }; -}; - const printSummary = ( - score: number, - label: string, + scoreResult: ScoreResult, diagnostics: Diagnostic[], totalSourceFileCount: number, elapsedMs: number, + verbose: boolean, ): void => { + const { score, label } = scoreResult; const [eyes, mouth] = getDoctorFace(score); const colorize = (text: string) => colorizeByScore(text, score); const bar = buildScoreBar(score); const counts = buildCountsSummaryLine(diagnostics, totalSourceFileCount, elapsedMs); - printFramedBox([ + const framedLines: ReturnType[] = [ createFramedLine('┌─────┐', colorize('┌─────┐')), createFramedLine(`│ ${eyes} │`, colorize(`│ ${eyes} │`)), createFramedLine(`│ ${mouth} │`, colorize(`│ ${mouth} │`)), @@ -171,9 +130,15 @@ const printSummary = ( ), createFramedLine(''), createFramedLine(bar.plain, bar.rendered), - createFramedLine(''), - createFramedLine(counts.plain, counts.rendered), - ]); + ]; + if (verbose && scoreResult.breakdown) { + framedLines.push(createFramedLine('')); + framedLines.push(...buildScoreBreakdownLines(scoreResult.breakdown)); + } + framedLines.push(createFramedLine('')); + framedLines.push(createFramedLine(counts.plain, counts.rendered)); + + printFramedBox(framedLines); }; const applyDiffMode = (rootDirectory: string, flags: CliFlags, scanOptions: ScanOptions): void => { @@ -266,7 +231,11 @@ const main = new Command() .option('--no-dead-code', 'skip dead code detection') .option('--verbose', 'show file details per rule') .option('--score', 'output only the score') - .option('-y, --yes', 'skip prompts') + .option('-y, --yes', 'skip prompts'); + +addAnalyticsOption(main); + +main .option('--project ', 'select workspace project (comma-separated)') .option('--diff [base]', 'scan only files changed vs base branch') .option('--offline', 'skip remote scoring (local score only)') @@ -276,9 +245,17 @@ const main = new Command() const config = loadConfig(resolvedDirectory); const scanOptions = resolveScanOptions(flags, config, main); + const isScoreOnly = flags.score; + const isAutomated = isAutomatedEnvironment(); + const shouldSkipPrompts = flags.yes || isAutomated || !process.stdin.isTTY; + logger.log(`svelte-doctor v${VERSION}`); logger.break(); + if (!isScoreOnly && !isAutomated && !flags.yes) { + await maybePromptAnalyticsConsent(shouldSkipPrompts); + } + applyDiffMode(resolvedDirectory, flags, scanOptions); const projectInfo = discoverProject(resolvedDirectory); @@ -345,7 +322,29 @@ const main = new Command() config, ); - const scoreResult = calculateScore(diagnostics); + const hasIncludePaths = (scanOptions.includePaths?.length ?? 0) > 0; + const totalSourceFileCount = hasIncludePaths + ? scanOptions.includePaths!.length + : projectInfo.sourceFileCount; + const scoreResult = calculateScore(diagnostics, totalSourceFileCount, { + hasHighOrCriticalSecurityFindings: hasHighOrCriticalSecurityFindings(diagnostics), + }); + + const telemetryUrl = process.env.FRAMEWORK_DOCTOR_TELEMETRY_URL ?? ''; + const isDiffMode = (scanOptions.includePaths?.length ?? 0) > 0; + if ( + telemetryUrl && + shouldSendAnalytics( + { analytics: flags.analytics, yes: flags.yes }, + config?.analytics, + isAutomated, + ) + ) { + sendScanEvent(telemetryUrl, projectInfo, scoreResult, diagnostics.length, { + isDiffMode, + cliVersion: VERSION, + }); + } if (flags.score) { logger.log(`${scoreResult.score}`); @@ -353,10 +352,6 @@ const main = new Command() } const elapsedMs = performance.now() - startTime; - const hasIncludePaths = (scanOptions.includePaths?.length ?? 0) > 0; - const totalSourceFileCount = hasIncludePaths - ? scanOptions.includePaths!.length - : projectInfo.sourceFileCount; if (diagnostics.length === 0) { logger.success('No issues found!'); @@ -366,11 +361,11 @@ const main = new Command() logger.break(); printSummary( - scoreResult.score, - scoreResult.label, + scoreResult, diagnostics, totalSourceFileCount, elapsedMs, + Boolean(scanOptions.verbose), ); }); diff --git a/packages/svelte-doctor/src/constants.ts b/packages/svelte-doctor/src/constants.ts deleted file mode 100644 index 94ed072..0000000 --- a/packages/svelte-doctor/src/constants.ts +++ /dev/null @@ -1,10 +0,0 @@ -export const PERFECT_SCORE = 100; -export const SCORE_GOOD_THRESHOLD = 75; -export const SCORE_OK_THRESHOLD = 50; - -export const SCORE_BAR_WIDTH_CHARS = 50; - -export const MILLISECONDS_PER_SECOND = 1000; - -export const SUMMARY_BOX_OUTER_INDENT_CHARS = 2; -export const SUMMARY_BOX_HORIZONTAL_PADDING_CHARS = 1; diff --git a/packages/svelte-doctor/src/scan.ts b/packages/svelte-doctor/src/scan.ts index c499e29..dd97247 100644 --- a/packages/svelte-doctor/src/scan.ts +++ b/packages/svelte-doctor/src/scan.ts @@ -74,9 +74,12 @@ export const scan = async (directory: string, options: ScanOptions = {}): Promis userConfig, ); + const totalFilesScanned = + resolved.includePaths.length > 0 ? resolved.includePaths.length : projectInfo.sourceFileCount; + return { diagnostics, - scoreResult: calculateScore(diagnostics), + scoreResult: calculateScore(diagnostics, totalFilesScanned), skippedChecks, }; }; diff --git a/packages/svelte-doctor/src/types.ts b/packages/svelte-doctor/src/types.ts index 3f842be..61d9159 100644 --- a/packages/svelte-doctor/src/types.ts +++ b/packages/svelte-doctor/src/types.ts @@ -1,3 +1,5 @@ +import type { BaseDoctorConfig, Diagnostic, ScoreResult } from '@framework-doctor/core'; + export type SvelteFramework = 'sveltekit' | 'svelte'; export interface ProjectInfo { @@ -9,23 +11,12 @@ export interface ProjectInfo { sourceFileCount: number; } -export interface Diagnostic { - filePath: string; - plugin: string; - rule: string; - severity: 'error' | 'warning'; - message: string; - help: string; - line: number; - column: number; - category: 'correctness' | 'performance' | 'maintainability' | 'accessibility' | 'security'; - weight?: number; -} - -export interface ScoreResult { - score: number; - label: string; -} +export type { + Diagnostic, + IgnoreConfig, + ScoreGuardrailInput, + ScoreResult, +} from '@framework-doctor/core'; export interface ScanOptions { lint?: boolean; @@ -42,18 +33,8 @@ export interface ScanResult { skippedChecks: string[]; } -export interface SvelteDoctorIgnoreConfig { - rules?: string[]; - files?: string[]; -} - -export interface SvelteDoctorConfig { - ignore?: SvelteDoctorIgnoreConfig; - lint?: boolean; +export interface SvelteDoctorConfig extends BaseDoctorConfig { jsTsLint?: boolean; - deadCode?: boolean; - verbose?: boolean; - diff?: boolean | string; } export interface VersionedRuleMeta { diff --git a/packages/svelte-doctor/src/ui/colorize-by-score.ts b/packages/svelte-doctor/src/ui/colorize-by-score.ts deleted file mode 100644 index 7a4ccba..0000000 --- a/packages/svelte-doctor/src/ui/colorize-by-score.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { SCORE_GOOD_THRESHOLD, SCORE_OK_THRESHOLD } from '../constants.js'; -import { highlighter } from './highlighter.js'; - -export const colorizeByScore = (text: string, score: number): string => { - if (score >= SCORE_GOOD_THRESHOLD) return highlighter.success(text); - if (score >= SCORE_OK_THRESHOLD) return highlighter.warn(text); - return highlighter.error(text); -}; diff --git a/packages/svelte-doctor/src/ui/logger.ts b/packages/svelte-doctor/src/ui/logger.ts deleted file mode 100644 index c195b25..0000000 --- a/packages/svelte-doctor/src/ui/logger.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { highlighter } from './highlighter.js'; - -export const logger = { - error(...args: unknown[]) { - console.log(highlighter.error(args.join(' '))); - }, - warn(...args: unknown[]) { - console.log(highlighter.warn(args.join(' '))); - }, - info(...args: unknown[]) { - console.log(highlighter.info(args.join(' '))); - }, - success(...args: unknown[]) { - console.log(highlighter.success(args.join(' '))); - }, - dim(...args: unknown[]) { - console.log(highlighter.dim(args.join(' '))); - }, - log(...args: unknown[]) { - console.log(args.join(' ')); - }, - break() { - console.log(''); - }, -}; diff --git a/packages/svelte-doctor/src/utils/discover-project.ts b/packages/svelte-doctor/src/utils/discover-project.ts index 892dc33..0e718f6 100644 --- a/packages/svelte-doctor/src/utils/discover-project.ts +++ b/packages/svelte-doctor/src/utils/discover-project.ts @@ -1,8 +1,8 @@ +import { readJson } from '@framework-doctor/core'; import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import type { ProjectInfo, SvelteFramework } from '../types.js'; -import { readJson } from './read-json.js'; interface PackageJson { name?: string; diff --git a/packages/svelte-doctor/src/utils/format-elapsed-time.ts b/packages/svelte-doctor/src/utils/format-elapsed-time.ts deleted file mode 100644 index 956750b..0000000 --- a/packages/svelte-doctor/src/utils/format-elapsed-time.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { MILLISECONDS_PER_SECOND } from '../constants.js'; - -/** - * Format elapsed milliseconds as human-readable string (e.g. "793ms", "1.2s"). - */ -export const formatElapsedTime = (elapsedMs: number): string => { - if (elapsedMs < MILLISECONDS_PER_SECOND) { - return `${elapsedMs}ms`; - } - const seconds = elapsedMs / MILLISECONDS_PER_SECOND; - return `${seconds.toFixed(1)}s`; -}; diff --git a/packages/svelte-doctor/src/utils/get-diff-files.ts b/packages/svelte-doctor/src/utils/get-diff-files.ts index 34aed6f..383df2c 100644 --- a/packages/svelte-doctor/src/utils/get-diff-files.ts +++ b/packages/svelte-doctor/src/utils/get-diff-files.ts @@ -1,32 +1,10 @@ -import { spawnSync } from 'node:child_process'; -import path from 'node:path'; +import { + filterSourceFiles as filterSourceFilesCore, + getDiffInfo as getDiffInfoCore, + SOURCE_FILE_PATTERN_SVELTE, +} from '@framework-doctor/core'; -export interface DiffInfo { - baseBranch: string; - changedFiles: string[]; -} - -const runGit = (cwd: string, args: string[]): string | null => { - const result = spawnSync('git', args, { cwd, encoding: 'utf-8' }); - if (result.status !== 0 || result.error) return null; - return result.stdout.trim(); -}; - -export const getDiffInfo = (rootDirectory: string, base = 'main'): DiffInfo | null => { - const insideWorkTree = runGit(rootDirectory, ['rev-parse', '--is-inside-work-tree']); - if (insideWorkTree !== 'true') return null; - - const branchDiff = runGit(rootDirectory, ['diff', '--name-only', `${base}...HEAD`]); - if (branchDiff === null) return null; - - const changedFiles = branchDiff - .split('\n') - .map((filePath) => filePath.trim()) - .filter(Boolean) - .map((filePath) => path.resolve(rootDirectory, filePath)); - - return { baseBranch: base, changedFiles }; -}; +export const getDiffInfo = getDiffInfoCore; export const filterSourceFiles = (files: string[]): string[] => - files.filter((filePath) => /\.(svelte|ts|tsx|js|jsx|mjs|cjs)$/.test(filePath)); + filterSourceFilesCore(files, SOURCE_FILE_PATTERN_SVELTE); diff --git a/packages/svelte-doctor/src/utils/group-by.ts b/packages/svelte-doctor/src/utils/group-by.ts deleted file mode 100644 index 65c1b64..0000000 --- a/packages/svelte-doctor/src/utils/group-by.ts +++ /dev/null @@ -1,12 +0,0 @@ -export const groupBy = (items: T[], keyFn: (item: T) => string): Map => { - const groups = new Map(); - - for (const item of items) { - const key = keyFn(item); - const existing = groups.get(key) ?? []; - existing.push(item); - groups.set(key, existing); - } - - return groups; -}; diff --git a/packages/svelte-doctor/src/utils/indent-multiline-text.ts b/packages/svelte-doctor/src/utils/indent-multiline-text.ts deleted file mode 100644 index fa081aa..0000000 --- a/packages/svelte-doctor/src/utils/indent-multiline-text.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const indentMultilineText = (text: string, linePrefix: string): string => - text - .split('\n') - .map((lineText) => linePrefix + lineText) - .join('\n'); diff --git a/packages/svelte-doctor/src/utils/load-config.ts b/packages/svelte-doctor/src/utils/load-config.ts index 420ab65..29e95dd 100644 --- a/packages/svelte-doctor/src/utils/load-config.ts +++ b/packages/svelte-doctor/src/utils/load-config.ts @@ -1,37 +1,8 @@ -import fs from 'node:fs'; -import path from 'node:path'; +import { loadConfig as loadConfigFromCore } from '@framework-doctor/core'; import type { SvelteDoctorConfig } from '../types.js'; const CONFIG_FILENAME = 'svelte-doctor.config.json'; const PACKAGE_JSON_CONFIG_KEY = 'svelteDoctor'; -const isPlainObject = (value: unknown): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value); - -export const loadConfig = (rootDirectory: string): SvelteDoctorConfig | null => { - const configPath = path.join(rootDirectory, CONFIG_FILENAME); - if (fs.existsSync(configPath)) { - try { - const parsed: unknown = JSON.parse(fs.readFileSync(configPath, 'utf-8')); - if (!isPlainObject(parsed)) return null; - return parsed as SvelteDoctorConfig; - } catch { - return null; - } - } - - const packageJsonPath = path.join(rootDirectory, 'package.json'); - if (!fs.existsSync(packageJsonPath)) return null; - - try { - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')) as Record< - string, - unknown - >; - const config = packageJson[PACKAGE_JSON_CONFIG_KEY]; - if (!isPlainObject(config)) return null; - return config as SvelteDoctorConfig; - } catch { - return null; - } -}; +export const loadConfig = (rootDirectory: string): SvelteDoctorConfig | null => + loadConfigFromCore(rootDirectory, CONFIG_FILENAME, PACKAGE_JSON_CONFIG_KEY); diff --git a/packages/svelte-doctor/src/utils/run-security-scan.ts b/packages/svelte-doctor/src/utils/run-security-scan.ts index 75330cc..9a84e16 100644 --- a/packages/svelte-doctor/src/utils/run-security-scan.ts +++ b/packages/svelte-doctor/src/utils/run-security-scan.ts @@ -1,186 +1,15 @@ -import { spawnSync } from 'node:child_process'; -import fs from 'node:fs'; -import path from 'node:path'; -import type { Diagnostic } from '../types.js'; +import { + NO_AT_HTML_RULE, + runSecurityScan as runSecurityScanCore, + UNIVERSAL_SECURITY_RULES, +} from '@framework-doctor/core'; -/** Match {@html ...} - raw HTML injection (XSS risk) */ -const AT_HTML_REGEX = /\{@html\s+/g; +const SVELTE_PLUGIN = 'svelte-doctor'; -/** Match new Function(...) - code injection */ -const NEW_FUNCTION_REGEX = /\bnew\s+Function\s*\(/g; +const SVELTE_SECURITY_RULES = [...UNIVERSAL_SECURITY_RULES, NO_AT_HTML_RULE]; -/** Match setTimeout("..." or setTimeout('... - implied eval */ -const SET_TIMEOUT_STRING_REGEX = /\bsetTimeout\s*\(\s*["']/g; - -/** Match setInterval("..." or setInterval('... - implied eval */ -const SET_INTERVAL_STRING_REGEX = /\bsetInterval\s*\(\s*["']/g; - -const findMatches = (content: string, regex: RegExp): Array<{ line: number; column: number }> => { - const results: Array<{ line: number; column: number }> = []; - const lines = content.split('\n'); - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - let match: RegExpExecArray | null; - const re = new RegExp(regex.source, regex.flags); - while ((match = re.exec(line)) !== null) { - results.push({ line: i + 1, column: match.index }); - } - } - return results; -}; - -const scanFile = (filePath: string, content: string, rootDirectory: string): Diagnostic[] => { - const diagnostics: Diagnostic[] = []; - const plugin = 'svelte-doctor'; - - if (filePath.endsWith('.svelte')) { - const matches = findMatches(content, AT_HTML_REGEX); - for (const { line, column } of matches) { - diagnostics.push({ - filePath, - plugin, - rule: 'no-at-html', - severity: 'error', - message: 'Raw HTML via {@html} can lead to XSS if content is unsanitized.', - help: 'Sanitize user-controlled content (e.g. with DOMPurify) or avoid {@html} for untrusted input.', - line, - column, - category: 'security', - }); - } - } - - if ( - filePath.endsWith('.ts') || - filePath.endsWith('.tsx') || - filePath.endsWith('.js') || - filePath.endsWith('.jsx') || - filePath.endsWith('.mts') || - filePath.endsWith('.cts') || - filePath.endsWith('.mjs') || - filePath.endsWith('.cjs') - ) { - const newFnMatches = findMatches(content, NEW_FUNCTION_REGEX); - for (const { line, column } of newFnMatches) { - diagnostics.push({ - filePath, - plugin, - rule: 'no-new-function', - severity: 'error', - message: 'new Function() can execute arbitrary code (code injection risk).', - help: 'Avoid dynamic code evaluation. Use static functions or safe alternatives.', - line, - column, - category: 'security', - }); - } - - const setTimeoutMatches = findMatches(content, SET_TIMEOUT_STRING_REGEX); - for (const { line, column } of setTimeoutMatches) { - diagnostics.push({ - filePath, - plugin, - rule: 'no-implied-eval', - severity: 'error', - message: 'setTimeout with string argument executes code (implied eval).', - help: 'Use a function callback instead: setTimeout(() => { ... }, delay).', - line, - column, - category: 'security', - }); - } - - const setIntervalMatches = findMatches(content, SET_INTERVAL_STRING_REGEX); - for (const { line, column } of setIntervalMatches) { - diagnostics.push({ - filePath, - plugin, - rule: 'no-implied-eval', - severity: 'error', - message: 'setInterval with string argument executes code (implied eval).', - help: 'Use a function callback instead: setInterval(() => { ... }, delay).', - line, - column, - category: 'security', - }); - } - } - - return diagnostics; -}; - -const getFilesToScan = (rootDirectory: string, includePaths: string[]): string[] => { - if (includePaths.length > 0) { - return includePaths - .map((p) => path.resolve(rootDirectory, p)) - .filter((filePath) => { - const rel = path.relative(rootDirectory, filePath); - return ( - !rel.startsWith('..') && - (filePath.endsWith('.svelte') || /\.(ts|tsx|js|jsx|mts|cts|mjs|cjs)$/.test(filePath)) - ); - }); - } - - const gitResult = spawnSync('git', ['ls-files', '--cached', '--others', '--exclude-standard'], { - cwd: rootDirectory, - encoding: 'utf-8', - maxBuffer: 10 * 1024 * 1024, +export const runSecurityScan = async (rootDirectory: string, includePaths: string[]) => + runSecurityScanCore(rootDirectory, includePaths, { + plugin: SVELTE_PLUGIN, + rules: SVELTE_SECURITY_RULES, }); - - if (gitResult.status === 0 && !gitResult.error) { - return gitResult.stdout - .split('\n') - .map((p) => p.trim()) - .filter( - (p) => - p.length > 0 && (p.endsWith('.svelte') || /\.(ts|tsx|js|jsx|mts|cts|mjs|cjs)$/.test(p)), - ) - .map((p) => path.resolve(rootDirectory, p)); - } - - const files: string[] = []; - const walk = (dir: string): void => { - if (!fs.existsSync(dir)) return; - const entries = fs.readdirSync(dir, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - if (entry.name !== 'node_modules' && entry.name !== 'dist' && entry.name !== '.git') { - walk(fullPath); - } - } else if ( - entry.isFile() && - (entry.name.endsWith('.svelte') || /\.(ts|tsx|js|jsx|mts|cts|mjs|cjs)$/.test(entry.name)) - ) { - files.push(fullPath); - } - } - }; - - const srcPath = path.join(rootDirectory, 'src'); - if (fs.existsSync(srcPath)) { - walk(srcPath); - } - walk(rootDirectory); - return [...new Set(files)].filter((f) => !f.includes('node_modules')); -}; - -export const runSecurityScan = async ( - rootDirectory: string, - includePaths: string[], -): Promise => { - const files = getFilesToScan(rootDirectory, includePaths); - const diagnostics: Diagnostic[] = []; - - for (const filePath of files) { - try { - const content = fs.readFileSync(filePath, 'utf-8'); - diagnostics.push(...scanFile(filePath, content, rootDirectory)); - } catch { - // Skip unreadable files - } - } - - return diagnostics; -}; diff --git a/packages/svelte-doctor/src/utils/score.ts b/packages/svelte-doctor/src/utils/score.ts index 98a9f7c..fd08022 100644 --- a/packages/svelte-doctor/src/utils/score.ts +++ b/packages/svelte-doctor/src/utils/score.ts @@ -1,21 +1,3 @@ -import { PERFECT_SCORE, SCORE_GOOD_THRESHOLD, SCORE_OK_THRESHOLD } from '../constants.js'; -import type { Diagnostic, ScoreResult } from '../types.js'; +import { calculateScore as calculateScoreFromCore } from '@framework-doctor/core'; -const scorePenalty = (diagnostic: Diagnostic): number => { - const severityPenalty = diagnostic.severity === 'error' ? 6 : 2; - return diagnostic.weight ?? severityPenalty; -}; - -export const calculateScore = (diagnostics: Diagnostic[]): ScoreResult => { - const penalty = diagnostics.reduce((total, diagnostic) => total + scorePenalty(diagnostic), 0); - const score = Math.max(0, PERFECT_SCORE - penalty); - let label: ScoreResult['label']; - if (score >= SCORE_GOOD_THRESHOLD) { - label = 'Great'; - } else if (score >= SCORE_OK_THRESHOLD) { - label = 'Needs work'; - } else { - label = 'Critical'; - } - return { score, label }; -}; +export const calculateScore = calculateScoreFromCore; diff --git a/packages/svelte-doctor/src/utils/telemetry.ts b/packages/svelte-doctor/src/utils/telemetry.ts new file mode 100644 index 0000000..4407798 --- /dev/null +++ b/packages/svelte-doctor/src/utils/telemetry.ts @@ -0,0 +1,52 @@ +import { + maybePromptAnalyticsConsent as coreMaybePromptAnalyticsConsent, + sendScanEvent as coreSendScanEvent, + shouldSendAnalytics as coreShouldSendAnalytics, + highlighter, + logger, + type TelemetryEventPayload, +} from '@framework-doctor/core'; +import prompts from 'prompts'; +import type { ProjectInfo, ScoreResult } from '../types.js'; + +export const shouldSendAnalytics = coreShouldSendAnalytics; + +export const maybePromptAnalyticsConsent = async (shouldSkipPrompts: boolean): Promise => + coreMaybePromptAnalyticsConsent(shouldSkipPrompts, async () => { + logger.break(); + logger.log(`${highlighter.info('?')} Help improve svelte-doctor?`); + logger.dim(' Anonymous usage (framework, score range). No code or paths sent.'); + logger.break(); + + const { analyticsEnabled } = await prompts({ + type: 'confirm', + name: 'analyticsEnabled', + message: 'Share anonymous analytics?', + initial: true, + }); + return Boolean(analyticsEnabled); + }); + +const buildPayload = ( + projectInfo: ProjectInfo, + scoreResult: ScoreResult, + diagnosticCount: number, + options: { isDiffMode: boolean; cliVersion: string }, +): TelemetryEventPayload => ({ + doctor_family: 'svelte', + framework: projectInfo.framework, + score: scoreResult.score, + diagnostic_count: diagnosticCount, + has_typescript: projectInfo.hasTypeScript, + is_diff_mode: options.isDiffMode, + cli_version: options.cliVersion, +}); + +export const sendScanEvent = ( + telemetryUrl: string, + projectInfo: ProjectInfo, + scoreResult: ScoreResult, + diagnosticCount: number, + options: { isDiffMode: boolean; cliVersion: string }, +): void => + coreSendScanEvent(telemetryUrl, buildPayload(projectInfo, scoreResult, diagnosticCount, options)); diff --git a/packages/svelte-doctor/tests/score.test.ts b/packages/svelte-doctor/tests/score.test.ts index cc54e10..83b2b4a 100644 --- a/packages/svelte-doctor/tests/score.test.ts +++ b/packages/svelte-doctor/tests/score.test.ts @@ -1,38 +1,117 @@ import { describe, expect, it } from 'vitest'; import { calculateScore } from '../src/utils/score.js'; +const baseDiagnostic = { + message: '', + help: '', + line: 1, + column: 1, + category: 'correctness', +}; + describe('calculateScore', () => { it('returns perfect score when there are no diagnostics', () => { - expect(calculateScore([])).toEqual({ score: 100, label: 'Great' }); + expect(calculateScore([], 8)).toEqual({ score: 100, label: 'Great' }); }); it('penalizes errors more than warnings', () => { - const result = calculateScore([ - { - filePath: 'src/a.svelte', + const result = calculateScore( + [ + { + ...baseDiagnostic, + filePath: 'src/a.svelte', + plugin: 'svelte-check', + rule: 'x', + severity: 'error', + }, + { + ...baseDiagnostic, + filePath: 'src/b.svelte', + plugin: 'svelte-check', + rule: 'y', + severity: 'warning', + }, + ], + 8, + ); + + expect(result.score).toBe(91); + expect(result.label).toBe('Great'); + expect(result.breakdown).toBeDefined(); + expect(result.breakdown?.typesPenalty).toBe(3.5); + expect(result.breakdown?.volumePenalty).toBeCloseTo(2.6); + expect(result.breakdown?.spreadPenalty).toBe(3); + }); + + it('uses zero spread penalty when totalFilesScanned is zero', () => { + const result = calculateScore( + [ + { + ...baseDiagnostic, + filePath: 'src/a.svelte', + plugin: 'svelte-check', + rule: 'x', + severity: 'error', + }, + ], + 0, + ); + + expect(result.breakdown?.spreadPenalty).toBe(0); + }); + + it('produces ~81 for 6 errors, 7 warnings, 2/8 files with UE=2, UW=4', () => { + const diagnostics: Parameters[0] = []; + for (let i = 0; i < 6; i++) { + diagnostics.push({ + ...baseDiagnostic, + filePath: i < 3 ? 'src/a.svelte' : 'src/b.svelte', plugin: 'svelte-check', - rule: 'x', + rule: i < 4 ? 'rule-a' : 'rule-b', severity: 'error', - message: 'e', - help: '', - line: 1, - column: 1, - category: 'correctness', - }, - { - filePath: 'src/b.svelte', - plugin: 'svelte-check', - rule: 'y', + }); + } + for (let i = 0; i < 7; i++) { + diagnostics.push({ + ...baseDiagnostic, + filePath: i < 4 ? 'src/a.svelte' : 'src/b.svelte', + plugin: 'oxlint', + rule: `rule-${i % 4}`, severity: 'warning', - message: 'w', - help: '', - line: 1, - column: 1, - category: 'correctness', - }, - ]); + }); + } - expect(result.score).toBe(92); + const result = calculateScore(diagnostics, 8); + + expect(result.score).toBe(81); expect(result.label).toBe('Great'); + expect(result.breakdown?.uniqueErrorRules).toBe(2); + expect(result.breakdown?.uniqueWarningRules).toBe(4); + expect(result.breakdown?.errorCount).toBe(6); + expect(result.breakdown?.warningCount).toBe(7); + expect(result.breakdown?.filesWithDiagnostics).toBe(2); + expect(result.breakdown?.totalFilesScanned).toBe(8); + }); + + it('caps score at 59 when guardrail input is triggered', () => { + const result = calculateScore( + [ + { + ...baseDiagnostic, + filePath: 'src/a.svelte', + plugin: 'oxlint', + rule: 'no-console', + severity: 'warning', + }, + ], + 8, + { + hasHighOrCriticalSecurityFindings: true, + }, + ); + + expect(result.score).toBe(59); + expect(result.breakdown?.didApplyGuardrail).toBe(true); + expect(result.breakdown?.guardrailReasons).toEqual(['high/critical security findings']); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ebeb778..a282a3c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -98,9 +98,9 @@ importers: examples/svelte/demo-app: devDependencies: - '@sveltejs/adapter-auto': + '@sveltejs/adapter-node': specifier: ^4.0.0 - version: 4.0.0(@sveltejs/kit@2.53.0(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.0)(vite@6.4.1(@types/node@25.3.0)(jiti@2.6.1)(yaml@2.8.2)))(svelte@5.53.0)(typescript@5.9.3)(vite@6.4.1(@types/node@25.3.0)(jiti@2.6.1)(yaml@2.8.2))) + version: 4.0.1(@sveltejs/kit@2.53.0(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.0)(vite@6.4.1(@types/node@25.3.0)(jiti@2.6.1)(yaml@2.8.2)))(svelte@5.53.0)(typescript@5.9.3)(vite@6.4.1(@types/node@25.3.0)(jiti@2.6.1)(yaml@2.8.2))) '@sveltejs/kit': specifier: ^2.0.0 version: 2.53.0(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.0)(vite@6.4.1(@types/node@25.3.0)(jiti@2.6.1)(yaml@2.8.2)))(svelte@5.53.0)(typescript@5.9.3)(vite@6.4.1(@types/node@25.3.0)(jiti@2.6.1)(yaml@2.8.2)) @@ -145,8 +145,39 @@ importers: specifier: 'catalog:' version: 5.9.3 + packages/core: + dependencies: + ora: + specifier: 'catalog:' + version: 9.3.0 + picocolors: + specifier: 'catalog:' + version: 1.1.1 + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 25.3.0 + cross-env: + specifier: 'catalog:' + version: 10.1.0 + oxlint: + specifier: 'catalog:' + version: 1.49.0 + rimraf: + specifier: 'catalog:' + version: 6.1.3 + tsdown: + specifier: 'catalog:' + version: 0.20.3(oxc-resolver@11.17.1)(typescript@5.9.3) + typescript: + specifier: 'catalog:' + version: 5.9.3 + packages/react-doctor: dependencies: + '@framework-doctor/core': + specifier: workspace:* + version: link:../core commander: specifier: 'catalog:' version: 14.0.3 @@ -193,6 +224,9 @@ importers: packages/svelte-doctor: dependencies: + '@framework-doctor/core': + specifier: workspace:* + version: link:../core commander: specifier: 'catalog:' version: 14.0.3 @@ -208,6 +242,9 @@ importers: picocolors: specifier: 'catalog:' version: 1.1.1 + prompts: + specifier: ^2.4.2 + version: 2.4.2 svelte-check: specifier: ^4.1.6 version: 4.4.3(picomatch@4.0.3)(svelte@5.53.0)(typescript@5.9.3) @@ -215,6 +252,9 @@ importers: '@types/node': specifier: 'catalog:' version: 25.3.0 + '@types/prompts': + specifier: 'catalog:' + version: 2.4.9 cross-env: specifier: 'catalog:' version: 10.1.0 @@ -971,6 +1011,42 @@ packages: '@rolldown/pluginutils@1.0.0-rc.3': resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} + '@rollup/plugin-commonjs@25.0.8': + resolution: {integrity: sha512-ZEZWTK5n6Qde0to4vS9Mr5x/0UZoqCxPVR9KRUjU4kA2sO7GEUn1fop0DAwpO6z0Nw/kJON9bDmSxdWxO/TT1A==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.68.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/plugin-json@6.1.0': + resolution: {integrity: sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/plugin-node-resolve@15.3.1': + resolution: {integrity: sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.78.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/pluginutils@5.3.0': + resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + '@rollup/rollup-android-arm-eabi@4.58.0': resolution: {integrity: sha512-mr0tmS/4FoVk1cnaeN244A/wjvGDNItZKR8hRhnmCzygyRXYtKF5jVDSIILR1U97CTzAYmbgIj/Dukg62ggG5w==} cpu: [arm] @@ -1117,10 +1193,10 @@ packages: peerDependencies: acorn: ^8.9.0 - '@sveltejs/adapter-auto@4.0.0': - resolution: {integrity: sha512-kmuYSQdD2AwThymQF0haQhM8rE5rhutQXG4LNbnbShwhMO4qQGnKaaTy+88DuNSuoQDi58+thpq8XpHc1+oEKQ==} + '@sveltejs/adapter-node@4.0.1': + resolution: {integrity: sha512-IviiTtKCDp+0QoTmmMlGGZBA1EoUNsjecU6XGV9k62S3f01SNsVhpqi2e4nbI62BLGKh/YKKfFii+Vz/b9XIxg==} peerDependencies: - '@sveltejs/kit': ^2.0.0 + '@sveltejs/kit': ^2.4.0 '@sveltejs/kit@2.53.0': resolution: {integrity: sha512-Brh/9h8QEg7rWIj+Nnz/2sC49NUeS8g3Qd9H5dTO3EbWG8vCEUl06jE+r5jQVDMHdr1swmCkwZkONFsWelGTpQ==} @@ -1183,6 +1259,9 @@ packages: '@types/prompts@2.4.9': resolution: {integrity: sha512-qTxFi6Buiu8+50/+3DGIWLHM6QuWsEKugJnnP6iv2Mc4ncxE4A/OJkjuVOA+5X0X1S/nq5VJRa8Lu+nwcvbrKA==} + '@types/resolve@1.20.2': + resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -1304,6 +1383,9 @@ packages: brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + brace-expansion@5.0.2: resolution: {integrity: sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==} engines: {node: 20 || >=22} @@ -1385,6 +1467,9 @@ packages: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} + commondir@1.0.1: + resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -1532,6 +1617,9 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -1617,11 +1705,17 @@ packages: resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} engines: {node: '>=6 <7 || >=8'} + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -1649,6 +1743,11 @@ packages: resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} engines: {node: 18 || 20 || >=22} + glob@8.1.0: + resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} + engines: {node: '>=12'} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} @@ -1664,6 +1763,10 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + hermes-estree@0.25.1: resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} @@ -1698,9 +1801,6 @@ packages: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} - import-meta-resolve@4.2.0: - resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} - import-without-cache@0.2.5: resolution: {integrity: sha512-B6Lc2s6yApwnD2/pMzFh/d5AVjdsDXjgkeJ766FmFuJELIGHNycKRj+l3A39yZPM4CchqNCB4RITEAYB1KUM6A==} engines: {node: '>=20.19.0'} @@ -1709,6 +1809,17 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -1729,10 +1840,16 @@ packages: resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} engines: {node: '>=12'} + is-module@1.0.0: + resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==} + is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-reference@1.2.1: + resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} + is-reference@3.0.3: resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} @@ -1889,6 +2006,10 @@ packages: minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + minimatch@5.1.7: + resolution: {integrity: sha512-FjiwU9HaHW6YB3H4a1sFudnv93lvydNjz2lmyUXR6IwKhGI+bgL3SOZrBGn6kvvX2pJvhEkGSGjyTHN47O4rqA==} + engines: {node: '>=10'} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} @@ -1925,6 +2046,9 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + onetime@6.0.0: resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} engines: {node: '>=12'} @@ -2007,6 +2131,9 @@ packages: resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} engines: {node: '>=12'} + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-scurry@2.0.2: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} @@ -2108,6 +2235,11 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve@1.22.11: + resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} + engines: {node: '>= 0.4'} + hasBin: true + restore-cursor@5.1.0: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} @@ -2273,6 +2405,10 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + svelte-check@4.4.3: resolution: {integrity: sha512-4HtdEv2hOoLCEsSXI+RDELk9okP/4sImWa7X02OjMFFOWeSdFF3NFy3vqpw0z+eH9C88J9vxZfUXz/Uv2A1ANw==} engines: {node: '>= 18.0.0'} @@ -2520,6 +2656,9 @@ packages: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -3202,6 +3341,41 @@ snapshots: '@rolldown/pluginutils@1.0.0-rc.3': {} + '@rollup/plugin-commonjs@25.0.8(rollup@4.58.0)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.58.0) + commondir: 1.0.1 + estree-walker: 2.0.2 + glob: 8.1.0 + is-reference: 1.2.1 + magic-string: 0.30.21 + optionalDependencies: + rollup: 4.58.0 + + '@rollup/plugin-json@6.1.0(rollup@4.58.0)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.58.0) + optionalDependencies: + rollup: 4.58.0 + + '@rollup/plugin-node-resolve@15.3.1(rollup@4.58.0)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.58.0) + '@types/resolve': 1.20.2 + deepmerge: 4.3.1 + is-module: 1.0.0 + resolve: 1.22.11 + optionalDependencies: + rollup: 4.58.0 + + '@rollup/pluginutils@5.3.0(rollup@4.58.0)': + dependencies: + '@types/estree': 1.0.8 + estree-walker: 2.0.2 + picomatch: 4.0.3 + optionalDependencies: + rollup: 4.58.0 + '@rollup/rollup-android-arm-eabi@4.58.0': optional: true @@ -3283,10 +3457,13 @@ snapshots: dependencies: acorn: 8.16.0 - '@sveltejs/adapter-auto@4.0.0(@sveltejs/kit@2.53.0(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.0)(vite@6.4.1(@types/node@25.3.0)(jiti@2.6.1)(yaml@2.8.2)))(svelte@5.53.0)(typescript@5.9.3)(vite@6.4.1(@types/node@25.3.0)(jiti@2.6.1)(yaml@2.8.2)))': + '@sveltejs/adapter-node@4.0.1(@sveltejs/kit@2.53.0(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.0)(vite@6.4.1(@types/node@25.3.0)(jiti@2.6.1)(yaml@2.8.2)))(svelte@5.53.0)(typescript@5.9.3)(vite@6.4.1(@types/node@25.3.0)(jiti@2.6.1)(yaml@2.8.2)))': dependencies: + '@rollup/plugin-commonjs': 25.0.8(rollup@4.58.0) + '@rollup/plugin-json': 6.1.0(rollup@4.58.0) + '@rollup/plugin-node-resolve': 15.3.1(rollup@4.58.0) '@sveltejs/kit': 2.53.0(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.0)(vite@6.4.1(@types/node@25.3.0)(jiti@2.6.1)(yaml@2.8.2)))(svelte@5.53.0)(typescript@5.9.3)(vite@6.4.1(@types/node@25.3.0)(jiti@2.6.1)(yaml@2.8.2)) - import-meta-resolve: 4.2.0 + rollup: 4.58.0 '@sveltejs/kit@2.53.0(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.0)(vite@6.4.1(@types/node@25.3.0)(jiti@2.6.1)(yaml@2.8.2)))(svelte@5.53.0)(typescript@5.9.3)(vite@6.4.1(@types/node@25.3.0)(jiti@2.6.1)(yaml@2.8.2))': dependencies: @@ -3361,6 +3538,8 @@ snapshots: '@types/node': 25.3.0 kleur: 3.0.3 + '@types/resolve@1.20.2': {} + '@types/trusted-types@2.0.7': {} '@vitest/expect@4.0.18': @@ -3470,6 +3649,10 @@ snapshots: balanced-match: 1.0.2 concat-map: 0.0.1 + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + brace-expansion@5.0.2: dependencies: balanced-match: 4.0.3 @@ -3534,6 +3717,8 @@ snapshots: commander@14.0.3: {} + commondir@1.0.1: {} + concat-map@0.0.1: {} convert-source-map@2.0.0: {} @@ -3706,6 +3891,8 @@ snapshots: estraverse@5.3.0: {} + estree-walker@2.0.2: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 @@ -3797,9 +3984,13 @@ snapshots: jsonfile: 4.0.0 universalify: 0.1.2 + fs.realpath@1.0.0: {} + fsevents@2.3.3: optional: true + function-bind@1.1.2: {} + gensync@1.0.0-beta.2: {} get-east-asian-width@1.5.0: {} @@ -3824,6 +4015,14 @@ snapshots: minipass: 7.1.3 path-scurry: 2.0.2 + glob@8.1.0: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 5.1.7 + once: 1.4.0 + globals@14.0.0: {} globby@11.1.0: @@ -3839,6 +4038,10 @@ snapshots: has-flag@4.0.0: {} + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + hermes-estree@0.25.1: {} hermes-parser@0.25.1: @@ -3864,12 +4067,21 @@ snapshots: parent-module: 1.0.1 resolve-from: 4.0.0 - import-meta-resolve@4.2.0: {} - import-without-cache@0.2.5: {} imurmurhash@0.1.4: {} + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + is-extglob@2.1.1: {} is-fullwidth-code-point@4.0.0: {} @@ -3884,8 +4096,14 @@ snapshots: is-interactive@2.0.0: {} + is-module@1.0.0: {} + is-number@7.0.0: {} + is-reference@1.2.1: + dependencies: + '@types/estree': 1.0.8 + is-reference@3.0.3: dependencies: '@types/estree': 1.0.8 @@ -4043,6 +4261,10 @@ snapshots: dependencies: brace-expansion: 1.1.12 + minimatch@5.1.7: + dependencies: + brace-expansion: 2.0.2 + minimist@1.2.8: {} minipass@7.1.3: {} @@ -4065,6 +4287,10 @@ snapshots: obug@2.1.1: {} + once@1.4.0: + dependencies: + wrappy: 1.0.2 + onetime@6.0.0: dependencies: mimic-fn: 4.0.0 @@ -4180,6 +4406,8 @@ snapshots: path-key@4.0.0: {} + path-parse@1.0.7: {} + path-scurry@2.0.2: dependencies: lru-cache: 11.2.6 @@ -4249,6 +4477,12 @@ snapshots: resolve-pkg-maps@1.0.0: {} + resolve@1.22.11: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + restore-cursor@5.1.0: dependencies: onetime: 7.0.0 @@ -4426,6 +4660,8 @@ snapshots: dependencies: has-flag: 4.0.0 + supports-preserve-symlinks-flag@1.0.0: {} + svelte-check@4.4.3(picomatch@4.0.3)(svelte@5.53.0)(typescript@5.9.3): dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -4638,6 +4874,8 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.1.2 + wrappy@1.0.2: {} + yallist@3.1.1: {} yaml@2.8.2: {} diff --git a/supabase/README.md b/supabase/README.md new file mode 100644 index 0000000..cebabef --- /dev/null +++ b/supabase/README.md @@ -0,0 +1,21 @@ +# Supabase telemetry + +Stores anonymous analytics from framework-doctor (react-doctor, svelte-doctor). + +## Setup + +1. Create a [Supabase project](https://supabase.com/dashboard) +2. Run migrations: `supabase db push` (or apply both files in `migrations/` via the SQL editor) +3. Deploy the Edge Function: `supabase functions deploy telemetry` +4. Set `FRAMEWORK_DOCTOR_TELEMETRY_URL` to your function URL (e.g. `https://.supabase.co/functions/v1/telemetry`) in your publish/CI env +5. If you configure `TELEMETRY_KEY` on the function, also set `FRAMEWORK_DOCTOR_TELEMETRY_KEY` for the doctor clients + +## Optional environment variables + +- `TELEMETRY_KEY` — Shared secret for `x-telemetry-key` header; if set, requests without it are rejected +- `HASH_SECRET` — HMAC secret for deriving `anon_install_id` from client `install_id`; if unset, install tracking is skipped +- `HASH_ROTATE` — `"month"` (default) or `"none"`; monthly rotation anonymizes install IDs over time + +## Data + +Events are stored in `telemetry_events` with: doctor_family, framework, score, score_bucket, diagnostic_count, has_typescript, is_diff_mode, cli_version, event_id, anon_install_id. diff --git a/supabase/config.toml b/supabase/config.toml new file mode 100644 index 0000000..b3789d2 --- /dev/null +++ b/supabase/config.toml @@ -0,0 +1,15 @@ +project_id = "framework-doctor" + +[api] +enabled = true +port = 54321 +schemas = ["public"] +max_rows = 1000 + +[db] +port = 54322 +shadow_port = 54320 +major_version = 17 + +[functions.telemetry] +verify_jwt = false diff --git a/supabase/deno.json b/supabase/deno.json new file mode 100644 index 0000000..704b218 --- /dev/null +++ b/supabase/deno.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "strict": true + }, + "imports": { + "zod": "https://esm.sh/zod@4.3.6?target=deno", + "@supabase/supabase-js": "https://esm.sh/@supabase/supabase-js@2?target=deno" + } +} diff --git a/supabase/functions/.env.example b/supabase/functions/.env.example new file mode 100644 index 0000000..af2094a --- /dev/null +++ b/supabase/functions/.env.example @@ -0,0 +1,5 @@ +SUPABASE_URL=https://your-project-ref.supabase.co +SUPABASE_SERVICE_ROLE_KEY=replace-with-service-role-key +TELEMETRY_KEY=replace-with-shared-secret-if-enabled +HASH_SECRET=replace-with-long-random-secret +HASH_ROTATE=month diff --git a/supabase/functions/telemetry/index.ts b/supabase/functions/telemetry/index.ts new file mode 100644 index 0000000..40c8b59 --- /dev/null +++ b/supabase/functions/telemetry/index.ts @@ -0,0 +1,184 @@ +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2?target=deno'; +import { z } from 'https://esm.sh/zod@4.3.6?target=deno'; + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': + 'authorization, x-client-info, apikey, content-type, x-telemetry-key', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', +}; + +const encoder = new TextEncoder(); + +// ---- HMAC hashing (cached key) ---- +let hmacKey: CryptoKey | null = null; + +async function getHmacKey(secret: string): Promise { + if (hmacKey) return hmacKey; + hmacKey = await crypto.subtle.importKey( + 'raw', + encoder.encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'], + ); + return hmacKey; +} + +async function hmacSha256Hex(secret: string, message: string): Promise { + const key = await getHmacKey(secret); + const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(message)); + return [...new Uint8Array(signature)].map((b) => b.toString(16).padStart(2, '0')).join(''); +} + +const EnvSchema = z.object({ + SUPABASE_URL: z.string().url(), + SUPABASE_SERVICE_ROLE_KEY: z.string().min(20), + TELEMETRY_KEY: z.string().min(10).optional(), + HASH_SECRET: z.string().min(16).optional(), + HASH_ROTATE: z.enum(['none', 'month']).optional().default('month'), +}); + +const envParse = EnvSchema.safeParse({ + SUPABASE_URL: Deno.env.get('SUPABASE_URL'), + SUPABASE_SERVICE_ROLE_KEY: Deno.env.get('SUPABASE_SERVICE_ROLE_KEY'), + TELEMETRY_KEY: Deno.env.get('TELEMETRY_KEY'), + HASH_SECRET: Deno.env.get('HASH_SECRET'), + HASH_ROTATE: Deno.env.get('HASH_ROTATE') ?? 'month', +}); + +if (!envParse.success) { + console.error('Invalid env:', envParse.error.flatten()); + throw new Error('Missing/invalid environment variables'); +} + +const { SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, TELEMETRY_KEY, HASH_SECRET, HASH_ROTATE } = + envParse.data; + +// ---- Supabase client (module scope) ---- +const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY); + +// ---- Body validation ---- +const telemetryEventSchema = z.object({ + doctor_family: z.string().min(1).max(50).optional(), + framework: z.string().min(1).max(50), + score: z.number(), + score_bucket: z.string().min(1).max(50), + diagnostic_count: z.number().int().min(0).max(100000), + has_typescript: z.boolean(), + is_diff_mode: z.boolean(), + cli_version: z.string().min(1).max(50), + + // Optional client-side install id; we only store anon_install_id derived from it. + install_id: z.string().min(10).max(200).optional(), + + // Optional: client-side dedupe id (uuid). If you add a unique constraint in DB, retries won't double-insert. + event_id: z.string().uuid().optional(), +}); + +const REACT_FRAMEWORK_VALUES = new Set(['nextjs', 'vite', 'cra', 'remix', 'gatsby', 'unknown']); +const SVELTE_FRAMEWORK_VALUES = new Set(['svelte', 'sveltekit']); + +const inferDoctorFamily = (framework: string): string => { + if (SVELTE_FRAMEWORK_VALUES.has(framework)) return 'svelte'; + if (REACT_FRAMEWORK_VALUES.has(framework)) return 'react'; + return 'unknown'; +}; + +Deno.serve(async (req) => { + // CORS preflight + if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders }); + + if (req.method !== 'POST') { + return new Response(JSON.stringify({ error: 'Method not allowed' }), { + status: 405, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } + + // Shared-secret guard (recommended) + if (TELEMETRY_KEY) { + const key = req.headers.get('x-telemetry-key'); + if (key !== TELEMETRY_KEY) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } + } + + let body: unknown; + try { + body = await req.json(); + } catch { + return new Response(JSON.stringify({ error: 'Invalid JSON' }), { + status: 400, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } + + const parsed = telemetryEventSchema.safeParse(body); + if (!parsed.success) { + return new Response( + JSON.stringify({ + error: 'Invalid event shape', + details: parsed.error.flatten(), // remove in prod if you want + }), + { + status: 400, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }, + ); + } + + const { + install_id, + doctor_family, + framework, + score, + score_bucket, + diagnostic_count, + has_typescript, + is_diff_mode, + cli_version, + event_id, + } = parsed.data; + + // Derive anonymous install id (optional) + let anon_install_id: string | null = null; + + // Strict-ish behavior: if install_id is provided but HASH_SECRET is not set, ignore install_id (no crash). + // If you want strict rejection instead, return 500 here. + if (install_id && HASH_SECRET) { + const rotationPrefix = + HASH_ROTATE === 'month' ? `${new Date().toISOString().slice(0, 7)}:` : ''; + anon_install_id = await hmacSha256Hex(HASH_SECRET, `${rotationPrefix}${install_id}`); + } + + const row = { + doctor_family: doctor_family ?? inferDoctorFamily(framework), + framework, + score, + score_bucket, + diagnostic_count, + has_typescript, + is_diff_mode, + cli_version, + event_id: event_id ?? null, + anon_install_id, + }; + + const { error } = await supabase.from('telemetry_events').insert(row, { returning: 'minimal' }); + + if (error) { + // If you add a unique constraint on event_id, duplicate retries can show up here. + // You can optionally treat that as success by checking error.code, but it varies by client. + console.error('Insert failed:', error); + return new Response(JSON.stringify({ error: 'Insert failed' }), { + status: 500, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } + + return new Response(null, { status: 204, headers: corsHeaders }); +}); diff --git a/supabase/migrations/20260221000000_create_telemetry_events.sql b/supabase/migrations/20260221000000_create_telemetry_events.sql new file mode 100644 index 0000000..e3b3dc2 --- /dev/null +++ b/supabase/migrations/20260221000000_create_telemetry_events.sql @@ -0,0 +1,11 @@ +create table telemetry_events ( + id uuid primary key default gen_random_uuid(), + created_at timestamptz not null default now(), + framework text not null, + score int not null, + score_bucket text not null, + diagnostic_count int not null, + has_typescript boolean not null, + is_diff_mode boolean not null, + cli_version text not null +); diff --git a/supabase/migrations/20260222000000_add_anon_install_id.sql b/supabase/migrations/20260222000000_add_anon_install_id.sql new file mode 100644 index 0000000..b9d80ef --- /dev/null +++ b/supabase/migrations/20260222000000_add_anon_install_id.sql @@ -0,0 +1,2 @@ +alter table telemetry_events + add column anon_install_id text; diff --git a/supabase/migrations/20260222010000_add_telemetry_event_id_and_doctor_family.sql b/supabase/migrations/20260222010000_add_telemetry_event_id_and_doctor_family.sql new file mode 100644 index 0000000..cdee067 --- /dev/null +++ b/supabase/migrations/20260222010000_add_telemetry_event_id_and_doctor_family.sql @@ -0,0 +1,21 @@ +alter table telemetry_events + add column if not exists doctor_family text, + add column if not exists event_id uuid; + +update telemetry_events +set doctor_family = case + when framework in ('svelte', 'sveltekit') then 'svelte' + when framework in ('nextjs', 'vite', 'cra', 'remix', 'gatsby', 'unknown') then 'react' + else 'unknown' +end +where doctor_family is null; + +alter table telemetry_events + alter column doctor_family set default 'unknown'; + +alter table telemetry_events + alter column doctor_family set not null; + +create unique index if not exists telemetry_events_event_id_key + on telemetry_events (event_id) + where event_id is not null; diff --git a/turbo.json b/turbo.json index 753c554..917f763 100644 --- a/turbo.json +++ b/turbo.json @@ -1,6 +1,9 @@ { "$schema": "https://turbo.build/schema.json", "tasks": { + "format:check": { + "outputs": [] + }, "build": { "dependsOn": ["^build"], "outputs": ["dist/**"] @@ -10,7 +13,7 @@ "persistent": true }, "typecheck": { - "dependsOn": ["^typecheck"], + "dependsOn": ["^build", "^typecheck"], "outputs": [] }, "lint": {