From 869c7adb7b5d95b2a120dd05285b58683e3fbd13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piti=C8=99=20Radu?= Date: Sat, 21 Feb 2026 19:50:05 +0100 Subject: [PATCH 01/17] docs: updated readmes --- .npmrc | 1 + README.md | 15 +++++++++++++-- packages/react-doctor/README.md | 30 ++++++++++++------------------ 3 files changed, 26 insertions(+), 20 deletions(-) diff --git a/.npmrc b/.npmrc index ea6643e..b4ebe17 100644 --- a/.npmrc +++ b/.npmrc @@ -1,3 +1,4 @@ shared-workspace-lockfile=true prefer-workspace-packages=true auto-install-peers=true +auth-type=web diff --git a/README.md b/README.md index a9b0bcd..4f3bfff 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,12 @@ 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 + **Svelte (direct):** - `npx -y @framework-doctor/svelte .` - run a full scan @@ -51,6 +58,8 @@ See [examples/README.md](examples/README.md) for more demo projects and commands ## Options +Svelte doctor: + ```txt Usage: svelte-doctor [directory] [options] @@ -68,6 +77,8 @@ Options: -h, --help display help for command ``` +React doctor options: `--no-lint`, `--no-dead-code`, `--verbose`, `--score`, `--project`, `--diff`. See [packages/react-doctor/README.md](packages/react-doctor/README.md). + ## Security checks Svelte Doctor includes a security scan that flags: diff --git a/packages/react-doctor/README.md b/packages/react-doctor/README.md index 45e8b71..578848e 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. 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**: @@ -29,13 +21,13 @@ Diagnostics are filtered through your config, then scored by severity (errors we Run this at your project root: ```bash -npx -y react-doctor@latest . +npx -y @framework-doctor/react . ``` Use `--verbose` to see affected files and line numbers: ```bash -npx -y react-doctor@latest . --verbose +npx -y @framework-doctor/react . --verbose ``` ## Install for your coding agent @@ -46,7 +38,7 @@ Teach your coding agent all 47+ React best practice rules: curl -fsSL https://react.doctor/install-skill.sh | bash ``` -Supports Cursor, Claude Code, Amp Code, Codex, Gemini CLI, OpenCode, Windsurf, and Antigravity. +Supports Cursor, Claude Code, Cline, Windsurf, and other AI coding agents. ## Options @@ -112,7 +104,7 @@ CLI flags always override config values. 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'); @@ -168,15 +160,17 @@ interface Diagnostic { 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 framework-doctor /path/to/your/react-project +# or directly: node packages/react-doctor/dist/cli.js /path/to/your/react-project ``` From 293d6cf5b7ac40d8d9ab9b18dc2ca8c4346ce7ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piti=C8=99=20Radu?= Date: Sat, 21 Feb 2026 19:50:27 +0100 Subject: [PATCH 02/17] docs: updated readmes --- packages/svelte-doctor/README.md | 99 ++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 packages/svelte-doctor/README.md diff --git a/packages/svelte-doctor/README.md b/packages/svelte-doctor/README.md new file mode 100644 index 0000000..d6fe004 --- /dev/null +++ b/packages/svelte-doctor/README.md @@ -0,0 +1,99 @@ +# 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 + --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 +} +``` + +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"`. + +## Contributing + +```bash +git clone https://github.com/pitis/framework-doctor +cd framework-doctor +pnpm install +pnpm build +``` + +Run locally: + +```bash +pnpm exec framework-doctor /path/to/your/svelte-project +# or directly: +node packages/svelte-doctor/dist/cli.js /path/to/your/svelte-project +``` From a67340d075785ea504c6be4114fc5793947e1508 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piti=C8=99=20Radu?= Date: Sat, 21 Feb 2026 19:52:35 +0100 Subject: [PATCH 03/17] docs: updated changelogs --- .changeset/sparkly-seas-eat.md | 6 ------ .changeset/unified-cli.md | 5 ----- packages/cli/CHANGELOG.md | 29 +++++++++++++++++++++++++++++ packages/cli/package.json | 2 +- packages/react-doctor/CHANGELOG.md | 7 +++++++ packages/react-doctor/package.json | 2 +- packages/svelte-doctor/CHANGELOG.md | 7 +++++++ packages/svelte-doctor/package.json | 2 +- 8 files changed, 46 insertions(+), 14 deletions(-) delete mode 100644 .changeset/sparkly-seas-eat.md delete mode 100644 .changeset/unified-cli.md create mode 100644 packages/cli/CHANGELOG.md 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/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md new file mode 100644 index 0000000..42a8f08 --- /dev/null +++ b/packages/cli/CHANGELOG.md @@ -0,0 +1,29 @@ +# @framework-doctor/cli + +## 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..3a88e7f 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.1.0", "description": "Auto-detect framework and run the right doctor", "author": { "name": "Pitis Radu", diff --git a/packages/react-doctor/CHANGELOG.md b/packages/react-doctor/CHANGELOG.md index 3863915..fe5a153 100644 --- a/packages/react-doctor/CHANGELOG.md +++ b/packages/react-doctor/CHANGELOG.md @@ -1,5 +1,12 @@ # react-doctor +## 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/package.json b/packages/react-doctor/package.json index 37a5218..2e47558 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.1", "description": "Diagnose and fix performance issues in your React app", "author": { "name": "Pitis Radu", diff --git a/packages/svelte-doctor/CHANGELOG.md b/packages/svelte-doctor/CHANGELOG.md index 24ded8d..3bc4cbb 100644 --- a/packages/svelte-doctor/CHANGELOG.md +++ b/packages/svelte-doctor/CHANGELOG.md @@ -1,5 +1,12 @@ # svelte-doctor +## 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/package.json b/packages/svelte-doctor/package.json index 18bcb73..ffdeced 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.1", "description": "Diagnose and improve Svelte codebase health", "author": { "name": "Pitis Radu", From 6d6cd5e1f9cff476129ffb77c9c7d4679dffcb7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piti=C8=99=20Radu?= Date: Sun, 22 Feb 2026 14:00:11 +0100 Subject: [PATCH 04/17] refactor: remove proxy fetch --- .npmrc | 1 - packages/react-doctor/README.md | 44 ++++--------- packages/react-doctor/src/cli.ts | 21 ++----- packages/react-doctor/src/constants.ts | 13 +--- packages/react-doctor/src/scan.ts | 41 +++---------- packages/react-doctor/src/types.ts | 1 - .../react-doctor/src/utils/calculate-score.ts | 54 +++++----------- .../react-doctor/src/utils/proxy-fetch.ts | 61 ------------------- 8 files changed, 40 insertions(+), 196 deletions(-) delete mode 100644 packages/react-doctor/src/utils/proxy-fetch.ts diff --git a/.npmrc b/.npmrc index b4ebe17..ea6643e 100644 --- a/.npmrc +++ b/.npmrc @@ -1,4 +1,3 @@ shared-workspace-lockfile=true prefer-workspace-packages=true auto-install-peers=true -auth-type=web diff --git a/packages/react-doctor/README.md b/packages/react-doctor/README.md index 578848e..8b4e6ff 100644 --- a/packages/react-doctor/README.md +++ b/packages/react-doctor/README.md @@ -3,7 +3,7 @@ [![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. @@ -18,27 +18,28 @@ Diagnostics are filtered through your config, then scored by severity (errors we ## Install -Run this at your project root: - ```bash -npx -y @framework-doctor/react . +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 @framework-doctor/react . --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, Cline, Windsurf, and other AI coding agents. +## 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. ## Options @@ -138,27 +139,8 @@ 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/pitis/framework-doctor cd framework-doctor @@ -169,7 +151,7 @@ pnpm build Run locally: ```bash -pnpm exec framework-doctor /path/to/your/react-project +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/src/cli.ts b/packages/react-doctor/src/cli.ts index 8fa12d4..a965296 100644 --- a/packages/react-doctor/src/cli.ts +++ b/packages/react-doctor/src/cli.ts @@ -3,7 +3,7 @@ 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 { AMI_INSTALL_URL, AMI_RELEASES_URL, AMI_WEBSITE_URL } from './constants.js'; import { scan } from './scan.js'; import type { Diagnostic, @@ -33,7 +33,6 @@ interface CliFlags { score: boolean; fix: boolean; yes: boolean; - offline: boolean; ami: boolean; project?: string; diff?: boolean | string; @@ -76,7 +75,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, }; }; @@ -127,7 +125,6 @@ const program = new Command() .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') .action(async (directory: string, flags: CliFlags) => { @@ -209,9 +206,7 @@ const program = new Command() if (!isScoreOnly && !shouldSkipAmiPrompts && !flags.fix) { await maybePromptSkillInstall(shouldSkipAmiPrompts); - const estimatedScoreResult = flags.offline - ? null - : await fetchEstimatedScore(allDiagnostics); + const estimatedScoreResult = await fetchEstimatedScore(allDiagnostics); await maybePromptFix(resolvedDirectory, allDiagnostics, estimatedScoreResult); } } catch (error) { @@ -222,7 +217,7 @@ 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')} `, ); @@ -289,13 +284,9 @@ const buildDeeplinkParams = (directory: string): URLSearchParams => { 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') { @@ -306,8 +297,6 @@ const openAmiToFix = (directory: string): void => { logger.dim(`Download at ${highlighter.info(AMI_RELEASES_URL)}`); } logger.break(); - logger.dim('Open this link to start fixing:'); - logger.info(webDeeplink); return; } @@ -318,8 +307,8 @@ const openAmiToFix = (directory: string): void => { 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); + logger.dim('Could not open Ami automatically.'); + logger.dim(`Install Ami from ${highlighter.info(AMI_WEBSITE_URL)}`); } }; diff --git a/packages/react-doctor/src/constants.ts b/packages/react-doctor/src/constants.ts index e819dc7..51a2298 100644 --- a/packages/react-doctor/src/constants.ts +++ b/packages/react-doctor/src/constants.ts @@ -18,14 +18,6 @@ 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,10 +26,7 @@ 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 OFFLINE_FLAG_MESSAGE = 'Score not available.'; export const DEFAULT_BRANCH_CANDIDATES = ['main', 'master']; diff --git a/packages/react-doctor/src/scan.ts b/packages/react-doctor/src/scan.ts index 10e8061..cfd2012 100644 --- a/packages/react-doctor/src/scan.ts +++ b/packages/react-doctor/src/scan.ts @@ -6,14 +6,12 @@ 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, @@ -208,29 +206,10 @@ 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, @@ -247,8 +226,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('')); @@ -264,8 +243,8 @@ const buildBrandingLines = ( } 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('')); @@ -333,10 +312,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 +377,6 @@ interface ResolvedScanOptions { deadCode: boolean; verbose: boolean; scoreOnly: boolean; - offline: boolean; includePaths: string[]; } @@ -414,7 +388,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 ?? [], }); @@ -550,8 +523,8 @@ 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 scoreResult = await calculateScore(diagnostics); + const noScoreMessage = OFFLINE_FLAG_MESSAGE; if (options.scoreOnly) { if (scoreResult) { diff --git a/packages/react-doctor/src/types.ts b/packages/react-doctor/src/types.ts index 7805005..287b657 100644 --- a/packages/react-doctor/src/types.ts +++ b/packages/react-doctor/src/types.ts @@ -101,7 +101,6 @@ export interface ScanOptions { deadCode?: boolean; verbose?: boolean; scoreOnly?: boolean; - offline?: boolean; includePaths?: string[]; } diff --git a/packages/react-doctor/src/utils/calculate-score.ts b/packages/react-doctor/src/utils/calculate-score.ts index 53efadd..b181585 100644 --- a/packages/react-doctor/src/utils/calculate-score.ts +++ b/packages/react-doctor/src/utils/calculate-score.ts @@ -1,16 +1,13 @@ 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'; const getScoreLabel = (score: number): string => { if (score >= SCORE_GOOD_THRESHOLD) return 'Great'; @@ -41,7 +38,18 @@ const scoreFromRuleCounts = (errorRuleCount: number, warningRuleCount: number): return Math.max(0, Math.round(PERFECT_SCORE - penalty)); }; -const estimateScoreLocally = (diagnostics: Diagnostic[]): EstimatedScoreResult => { +const calculateScoreLocally = (diagnostics: Diagnostic[]): ScoreResult => { + const { errorRuleCount, warningRuleCount } = countUniqueRules(diagnostics); + const score = scoreFromRuleCounts(errorRuleCount, warningRuleCount); + return { score, label: getScoreLabel(score) }; +}; + +export const calculateScore = async (diagnostics: Diagnostic[]): Promise => + Promise.resolve(calculateScoreLocally(diagnostics)); + +export const fetchEstimatedScore = async ( + diagnostics: Diagnostic[], +): Promise => { const { errorRuleCount, warningRuleCount } = countUniqueRules(diagnostics); const currentScore = scoreFromRuleCounts(errorRuleCount, warningRuleCount); @@ -56,44 +64,10 @@ const estimateScoreLocally = (diagnostics: Diagnostic[]): EstimatedScoreResult = estimatedUnfixedWarningRuleCount, ); - return { + return Promise.resolve({ 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 ( - 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); - } + }); }; 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); - } -}; From 03b1d88edb50c0f313e0c09be60a03fd113c8753 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piti=C8=99=20Radu?= Date: Sun, 22 Feb 2026 15:20:31 +0100 Subject: [PATCH 05/17] feat: telemetry with supabase --- packages/react-doctor/src/cli.ts | 35 ++++ packages/react-doctor/src/constants.ts | 2 + packages/react-doctor/src/scan.ts | 6 +- packages/react-doctor/src/types.ts | 2 + .../react-doctor/src/utils/global-config.ts | 38 +++++ .../react-doctor/src/utils/skill-prompt.ts | 27 +-- packages/react-doctor/src/utils/telemetry.ts | 85 ++++++++++ supabase/config.toml | 15 ++ supabase/deno.json | 9 + supabase/functions/telemetry/index.ts | 155 ++++++++++++++++++ ...20260221000000_create_telemetry_events.sql | 11 ++ .../20260222000000_add_anon_install_id.sql | 2 + 12 files changed, 360 insertions(+), 27 deletions(-) create mode 100644 packages/react-doctor/src/utils/global-config.ts create mode 100644 packages/react-doctor/src/utils/telemetry.ts create mode 100644 supabase/config.toml create mode 100644 supabase/deno.json create mode 100644 supabase/functions/telemetry/index.ts create mode 100644 supabase/migrations/20260221000000_create_telemetry_events.sql create mode 100644 supabase/migrations/20260222000000_add_anon_install_id.sql diff --git a/packages/react-doctor/src/cli.ts b/packages/react-doctor/src/cli.ts index a965296..712cb9c 100644 --- a/packages/react-doctor/src/cli.ts +++ b/packages/react-doctor/src/cli.ts @@ -23,6 +23,11 @@ import { logger } from './utils/logger.js'; import { clearSelectBanner, prompts, setSelectBanner } 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'; @@ -34,6 +39,7 @@ interface CliFlags { fix: boolean; yes: boolean; ami: boolean; + analytics: boolean; project?: string; diff?: boolean | string; } @@ -125,6 +131,7 @@ const program = new Command() .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('--no-analytics', 'disable anonymous analytics') .option('--no-ami', 'skip Ami-related prompts') .option('--fix', 'open Ami to auto-fix all issues') .action(async (directory: string, flags: CliFlags) => { @@ -171,6 +178,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; @@ -195,6 +208,28 @@ 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, + isAutomated, + ) + ) { + sendScanEvent( + telemetryUrl, + scanResult.projectInfo, + scanResult.scoreResult, + scanResult.diagnostics.length, + { + isDiffMode: Boolean(includePaths?.length), + cliVersion: VERSION, + }, + ); + } + if (!isScoreOnly) { logger.break(); } diff --git a/packages/react-doctor/src/constants.ts b/packages/react-doctor/src/constants.ts index 51a2298..2ae8381 100644 --- a/packages/react-doctor/src/constants.ts +++ b/packages/react-doctor/src/constants.ts @@ -20,6 +20,8 @@ export const SUMMARY_BOX_OUTER_INDENT_CHARS = 2; export const FETCH_TIMEOUT_MS = 10_000; +export const TELEMETRY_FETCH_TIMEOUT_MS = 3_000; + export const GIT_LS_FILES_MAX_BUFFER_BYTES = 50 * 1024 * 1024; // HACK: Windows CreateProcessW limits total command-line length to 32,767 chars. diff --git a/packages/react-doctor/src/scan.ts b/packages/react-doctor/src/scan.ts index cfd2012..5902700 100644 --- a/packages/react-doctor/src/scan.ts +++ b/packages/react-doctor/src/scan.ts @@ -532,7 +532,7 @@ export const scan = async ( } else { logger.dim(noScoreMessage); } - return { diagnostics, scoreResult, skippedChecks }; + return { diagnostics, scoreResult, skippedChecks, projectInfo }; } if (diagnostics.length === 0) { @@ -554,7 +554,7 @@ export const scan = async ( } else { logger.dim(` ${noScoreMessage}`); } - return { diagnostics, scoreResult, skippedChecks }; + return { diagnostics, scoreResult, skippedChecks, projectInfo }; } printDiagnostics(diagnostics, options.verbose); @@ -576,5 +576,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 287b657..cef369b 100644 --- a/packages/react-doctor/src/types.ts +++ b/packages/react-doctor/src/types.ts @@ -87,6 +87,7 @@ export interface ScanResult { diagnostics: Diagnostic[]; scoreResult: ScoreResult | null; skippedChecks: string[]; + projectInfo: ProjectInfo; } export interface EstimatedScoreResult { @@ -162,4 +163,5 @@ export interface ReactDoctorConfig { deadCode?: boolean; verbose?: boolean; diff?: boolean | string; + analytics?: boolean; } diff --git a/packages/react-doctor/src/utils/global-config.ts b/packages/react-doctor/src/utils/global-config.ts new file mode 100644 index 0000000..cc3d700 --- /dev/null +++ b/packages/react-doctor/src/utils/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/react-doctor/src/utils/skill-prompt.ts b/packages/react-doctor/src/utils/skill-prompt.ts index c47c1d9..cea4ce7 100644 --- a/packages/react-doctor/src/utils/skill-prompt.ts +++ b/packages/react-doctor/src/utils/skill-prompt.ts @@ -2,13 +2,12 @@ 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 { readGlobalConfig, writeGlobalConfig } from './global-config.js'; 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 +56,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 +177,7 @@ const installSkill = (): void => { }; export const maybePromptSkillInstall = async (shouldSkipPrompts: boolean): Promise => { - const config = readSkillPromptConfig(); + const config = readGlobalConfig(); if (config.skillPromptDismissed) return; if (shouldSkipPrompts) return; @@ -222,5 +201,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/telemetry.ts b/packages/react-doctor/src/utils/telemetry.ts new file mode 100644 index 0000000..1101b8c --- /dev/null +++ b/packages/react-doctor/src/utils/telemetry.ts @@ -0,0 +1,85 @@ +import { TELEMETRY_FETCH_TIMEOUT_MS } from '../constants.js'; +import type { ProjectInfo, ReactDoctorConfig, ScoreResult } from '../types.js'; +import { getOrCreateInstallId, readGlobalConfig, writeGlobalConfig } from './global-config.js'; +import { highlighter } from './highlighter.js'; +import { logger } from './logger.js'; +import { prompts } from './prompts.js'; + +interface TelemetryFlags { + analytics: boolean; + yes: boolean; +} + +export const shouldSendAnalytics = ( + flags: TelemetryFlags, + userConfig: ReactDoctorConfig | null, + 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 (userConfig?.analytics === false) return false; + const globalConfig = readGlobalConfig(); + return Boolean(globalConfig.analyticsEnabled); +}; + +export const maybePromptAnalyticsConsent = async (shouldSkipPrompts: boolean): Promise => { + if (shouldSkipPrompts) return false; + if (process.env.DO_NOT_TRACK === '1') return false; + + const config = readGlobalConfig(); + if (config.analyticsEnabled !== undefined) return config.analyticsEnabled; + + 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, + }); + + writeGlobalConfig({ ...config, analyticsEnabled }); + return Boolean(analyticsEnabled); +}; + +const getScoreBucket = (score: number): string => { + if (score < 50) return '0-50'; + if (score < 75) return '50-75'; + return '75-100'; +}; + +export const sendScanEvent = ( + telemetryUrl: string, + projectInfo: ProjectInfo, + scoreResult: ScoreResult, + diagnosticCount: number, + options: { isDiffMode: boolean; cliVersion: string }, +): void => { + const payload = { + framework: projectInfo.framework, + score: scoreResult.score, + score_bucket: getScoreBucket(scoreResult.score), + diagnostic_count: diagnosticCount, + has_typescript: projectInfo.hasTypeScript, + is_diff_mode: options.isDiffMode, + cli_version: options.cliVersion, + install_id: getOrCreateInstallId(), + }; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), TELEMETRY_FETCH_TIMEOUT_MS); + + fetch(telemetryUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + signal: controller.signal, + }) + .catch(() => {}) + .finally(() => clearTimeout(timeout)); +}; diff --git a/supabase/config.toml b/supabase/config.toml new file mode 100644 index 0000000..f19abaa --- /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 = 15 + +[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/telemetry/index.ts b/supabase/functions/telemetry/index.ts new file mode 100644 index 0000000..c78eb78 --- /dev/null +++ b/supabase/functions/telemetry/index.ts @@ -0,0 +1,155 @@ +import { createClient } from '@supabase/supabase-js'; +import { z } from 'zod'; + +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({ + 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().min(8).max(100).optional(), +}); + +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, ...rest } = 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 = { + ...rest, + 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; From 5364ab11b3303ec6aee6e0765838d24dfafb51b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piti=C8=99=20Radu?= Date: Sun, 22 Feb 2026 15:20:48 +0100 Subject: [PATCH 06/17] docs: updated readmes --- README.md | 1 + examples/README.md | 18 ++++-------------- examples/svelte/demo-app/README.md | 4 +++- packages/react-doctor/README.md | 15 ++++++++++++--- packages/svelte-doctor/README.md | 2 +- supabase/README.md | 20 ++++++++++++++++++++ 6 files changed, 41 insertions(+), 19 deletions(-) create mode 100644 supabase/README.md diff --git a/README.md b/README.md index 4f3bfff..6ad52f6 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ See [examples/README.md](examples/README.md) for more demo projects and commands - `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):** 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/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/packages/react-doctor/README.md b/packages/react-doctor/README.md index 8b4e6ff..a4eba84 100644 --- a/packages/react-doctor/README.md +++ b/packages/react-doctor/README.md @@ -37,9 +37,7 @@ pnpm react-doctor . --verbose ## 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. +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 @@ -55,6 +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-analytics disable anonymous analytics --no-ami skip Ami-related prompts --fix open Ami to auto-fix all issues -h, --help display help for command @@ -97,9 +96,19 @@ 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. +- **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: diff --git a/packages/svelte-doctor/README.md b/packages/svelte-doctor/README.md index d6fe004..05c3a3f 100644 --- a/packages/svelte-doctor/README.md +++ b/packages/svelte-doctor/README.md @@ -93,7 +93,7 @@ pnpm build Run locally: ```bash -pnpm exec framework-doctor /path/to/your/svelte-project +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/supabase/README.md b/supabase/README.md new file mode 100644 index 0000000..a159a59 --- /dev/null +++ b/supabase/README.md @@ -0,0 +1,20 @@ +# 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 + +## 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: framework, score, score_bucket, diagnostic_count, has_typescript, is_diff_mode, cli_version, anon_install_id. From 7f959d595547d209648dda2200ec495266caea81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piti=C8=99=20Radu?= Date: Sun, 22 Feb 2026 15:40:55 +0100 Subject: [PATCH 07/17] refactor: core --- packages/core/package.json | 39 +++ packages/core/src/constants.ts | 1 + .../src/utils => core/src}/global-config.ts | 0 packages/core/src/index.ts | 14 + packages/core/src/telemetry.ts | 83 ++++++ packages/core/tsconfig.json | 5 + packages/core/tsdown.config.ts | 9 + packages/react-doctor/README.md | 2 - packages/react-doctor/package.json | 1 + packages/react-doctor/src/cli.ts | 266 +----------------- packages/react-doctor/src/constants.ts | 8 - .../react-doctor/src/utils/skill-prompt.ts | 4 +- packages/react-doctor/src/utils/telemetry.ts | 110 +++----- packages/svelte-doctor/package.json | 3 + packages/svelte-doctor/src/cli.ts | 32 +++ packages/svelte-doctor/src/types.ts | 1 + packages/svelte-doctor/src/utils/telemetry.ts | 51 ++++ pnpm-lock.yaml | 33 +++ turbo.json | 2 +- 19 files changed, 322 insertions(+), 342 deletions(-) create mode 100644 packages/core/package.json create mode 100644 packages/core/src/constants.ts rename packages/{react-doctor/src/utils => core/src}/global-config.ts (100%) create mode 100644 packages/core/src/index.ts create mode 100644 packages/core/src/telemetry.ts create mode 100644 packages/core/tsconfig.json create mode 100644 packages/core/tsdown.config.ts create mode 100644 packages/svelte-doctor/src/utils/telemetry.ts diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..554949c --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,39 @@ +{ + "name": "@framework-doctor/core", + "version": "1.0.0", + "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" +} diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts new file mode 100644 index 0000000..39d327f --- /dev/null +++ b/packages/core/src/constants.ts @@ -0,0 +1 @@ +export const TELEMETRY_FETCH_TIMEOUT_MS = 3_000; diff --git a/packages/react-doctor/src/utils/global-config.ts b/packages/core/src/global-config.ts similarity index 100% rename from packages/react-doctor/src/utils/global-config.ts rename to packages/core/src/global-config.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..d8c344c --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,14 @@ +export { + getOrCreateInstallId, + readGlobalConfig, + writeGlobalConfig, + type FrameworkDoctorConfig, +} from './global-config.js'; +export { + isAutomatedEnvironment, + maybePromptAnalyticsConsent, + sendScanEvent, + shouldSendAnalytics, + type TelemetryEventPayload, + type TelemetryFlags, +} from './telemetry.js'; diff --git a/packages/core/src/telemetry.ts b/packages/core/src/telemetry.ts new file mode 100644 index 0000000..e5e8c75 --- /dev/null +++ b/packages/core/src/telemetry.ts @@ -0,0 +1,83 @@ +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 { + 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 fullPayload = { + ...payload, + score_bucket: getScoreBucket(payload.score), + install_id: getOrCreateInstallId(), + }; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), TELEMETRY_FETCH_TIMEOUT_MS); + + fetch(telemetryUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(fullPayload), + signal: controller.signal, + }) + .catch(() => {}) + .finally(() => clearTimeout(timeout)); +}; 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/README.md b/packages/react-doctor/README.md index a4eba84..7b3312a 100644 --- a/packages/react-doctor/README.md +++ b/packages/react-doctor/README.md @@ -54,8 +54,6 @@ Options: --project select workspace project (comma-separated for multiple) --diff [base] scan only files changed vs base branch --no-analytics disable anonymous analytics - --no-ami skip Ami-related prompts - --fix open Ami to auto-fix all issues -h, --help display help for command ``` diff --git a/packages/react-doctor/package.json b/packages/react-doctor/package.json index 2e47558..19aacd0 100644 --- a/packages/react-doctor/package.json +++ b/packages/react-doctor/package.json @@ -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 712cb9c..810f829 100644 --- a/packages/react-doctor/src/cli.ts +++ b/packages/react-doctor/src/cli.ts @@ -1,26 +1,14 @@ +import { isAutomatedEnvironment } 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 } 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 { @@ -36,37 +24,21 @@ interface CliFlags { deadCode: boolean; verbose: boolean; score: boolean; - fix: boolean; yes: 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, @@ -132,8 +104,6 @@ const program = new Command() .option('--project ', 'select workspace project (comma-separated for multiple)') .option('--diff [base]', 'scan only files changed vs base branch') .option('--no-analytics', 'disable anonymous analytics') - .option('--no-ami', 'skip Ami-related prompts') - .option('--fix', 'open Ami to auto-fix all issues') .action(async (directory: string, flags: CliFlags) => { const isScoreOnly = flags.score; @@ -148,7 +118,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, @@ -214,7 +183,7 @@ const program = new Command() scanResult.scoreResult && shouldSendAnalytics( { analytics: flags.analytics, yes: flags.yes }, - userConfig, + userConfig?.analytics, isAutomated, ) ) { @@ -235,14 +204,8 @@ const program = new Command() } } - if (flags.fix) { - openAmiToFix(resolvedDirectory); - } - - if (!isScoreOnly && !shouldSkipAmiPrompts && !flags.fix) { - await maybePromptSkillInstall(shouldSkipAmiPrompts); - const estimatedScoreResult = await fetchEstimatedScore(allDiagnostics); - await maybePromptFix(resolvedDirectory, allDiagnostics, estimatedScoreResult); + if (!isScoreOnly && !shouldSkipPrompts) { + await maybePromptSkillInstall(shouldSkipPrompts); } } catch (error) { handleError(error); @@ -256,217 +219,6 @@ ${highlighter.dim('Learn more:')} `, ); -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 openAmiToFix = (directory: string): void => { - const isInstalled = isAmiInstalled(); - const deeplink = buildDeeplink(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(); - 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.'); - logger.dim(`Install Ami from ${highlighter.info(AMI_WEBSITE_URL)}`); - } -}; - -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 2ae8381..9bef680 100644 --- a/packages/react-doctor/src/constants.ts +++ b/packages/react-doctor/src/constants.ts @@ -20,8 +20,6 @@ export const SUMMARY_BOX_OUTER_INDENT_CHARS = 2; export const FETCH_TIMEOUT_MS = 10_000; -export const TELEMETRY_FETCH_TIMEOUT_MS = 3_000; - export const GIT_LS_FILES_MAX_BUFFER_BYTES = 50 * 1024 * 1024; // HACK: Windows CreateProcessW limits total command-line length to 32,767 chars. @@ -45,9 +43,3 @@ 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/utils/skill-prompt.ts b/packages/react-doctor/src/utils/skill-prompt.ts index cea4ce7..4a9fb67 100644 --- a/packages/react-doctor/src/utils/skill-prompt.ts +++ b/packages/react-doctor/src/utils/skill-prompt.ts @@ -1,8 +1,8 @@ +import { 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 { readGlobalConfig, writeGlobalConfig } from './global-config.js'; import { highlighter } from './highlighter.js'; import { logger } from './logger.js'; import { prompts } from './prompts.js'; @@ -186,7 +186,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({ diff --git a/packages/react-doctor/src/utils/telemetry.ts b/packages/react-doctor/src/utils/telemetry.ts index 1101b8c..fbe3f9b 100644 --- a/packages/react-doctor/src/utils/telemetry.ts +++ b/packages/react-doctor/src/utils/telemetry.ts @@ -1,57 +1,45 @@ -import { TELEMETRY_FETCH_TIMEOUT_MS } from '../constants.js'; -import type { ProjectInfo, ReactDoctorConfig, ScoreResult } from '../types.js'; -import { getOrCreateInstallId, readGlobalConfig, writeGlobalConfig } from './global-config.js'; +import { + maybePromptAnalyticsConsent as coreMaybePromptAnalyticsConsent, + sendScanEvent as coreSendScanEvent, + shouldSendAnalytics as coreShouldSendAnalytics, + type TelemetryEventPayload, +} from '@framework-doctor/core'; +import type { ProjectInfo, ScoreResult } from '../types.js'; import { highlighter } from './highlighter.js'; import { logger } from './logger.js'; import { prompts } from './prompts.js'; -interface TelemetryFlags { - analytics: boolean; - yes: boolean; -} - -export const shouldSendAnalytics = ( - flags: TelemetryFlags, - userConfig: ReactDoctorConfig | null, - 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 (userConfig?.analytics === false) return false; - const globalConfig = readGlobalConfig(); - return Boolean(globalConfig.analyticsEnabled); -}; - -export const maybePromptAnalyticsConsent = async (shouldSkipPrompts: boolean): Promise => { - if (shouldSkipPrompts) return false; - if (process.env.DO_NOT_TRACK === '1') return false; - - const config = readGlobalConfig(); - if (config.analyticsEnabled !== undefined) return config.analyticsEnabled; - - 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, +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); }); - writeGlobalConfig({ ...config, analyticsEnabled }); - return Boolean(analyticsEnabled); -}; - -const getScoreBucket = (score: number): string => { - if (score < 50) return '0-50'; - if (score < 75) return '50-75'; - return '75-100'; -}; +const buildPayload = ( + projectInfo: ProjectInfo, + scoreResult: ScoreResult, + diagnosticCount: number, + options: { isDiffMode: boolean; cliVersion: string }, +): TelemetryEventPayload => ({ + 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, @@ -59,27 +47,5 @@ export const sendScanEvent = ( scoreResult: ScoreResult, diagnosticCount: number, options: { isDiffMode: boolean; cliVersion: string }, -): void => { - const payload = { - framework: projectInfo.framework, - score: scoreResult.score, - score_bucket: getScoreBucket(scoreResult.score), - diagnostic_count: diagnosticCount, - has_typescript: projectInfo.hasTypeScript, - is_diff_mode: options.isDiffMode, - cli_version: options.cliVersion, - install_id: getOrCreateInstallId(), - }; - - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), TELEMETRY_FETCH_TIMEOUT_MS); - - fetch(telemetryUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - signal: controller.signal, - }) - .catch(() => {}) - .finally(() => clearTimeout(timeout)); -}; +): void => + coreSendScanEvent(telemetryUrl, buildPayload(projectInfo, scoreResult, diagnosticCount, options)); diff --git a/packages/svelte-doctor/package.json b/packages/svelte-doctor/package.json index ffdeced..20e4b2b 100644 --- a/packages/svelte-doctor/package.json +++ b/packages/svelte-doctor/package.json @@ -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..a17ba1c 100644 --- a/packages/svelte-doctor/src/cli.ts +++ b/packages/svelte-doctor/src/cli.ts @@ -1,3 +1,4 @@ +import { isAutomatedEnvironment } from '@framework-doctor/core'; import { Command } from 'commander'; import path from 'node:path'; import { performance } from 'node:perf_hooks'; @@ -25,6 +26,11 @@ 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 +41,7 @@ interface CliFlags { verbose: boolean; score: boolean; yes: boolean; + analytics: boolean; project?: string; diff?: boolean | string; offline?: boolean; @@ -267,6 +274,7 @@ const main = new Command() .option('--verbose', 'show file details per rule') .option('--score', 'output only the score') .option('-y, --yes', 'skip prompts') + .option('--no-analytics', 'disable anonymous analytics') .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 +284,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); @@ -347,6 +363,22 @@ const main = new Command() const scoreResult = calculateScore(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}`); return; diff --git a/packages/svelte-doctor/src/types.ts b/packages/svelte-doctor/src/types.ts index 3f842be..0d508f2 100644 --- a/packages/svelte-doctor/src/types.ts +++ b/packages/svelte-doctor/src/types.ts @@ -54,6 +54,7 @@ export interface SvelteDoctorConfig { deadCode?: boolean; verbose?: boolean; diff?: boolean | string; + analytics?: boolean; } export interface VersionedRuleMeta { diff --git a/packages/svelte-doctor/src/utils/telemetry.ts b/packages/svelte-doctor/src/utils/telemetry.ts new file mode 100644 index 0000000..56c4c24 --- /dev/null +++ b/packages/svelte-doctor/src/utils/telemetry.ts @@ -0,0 +1,51 @@ +import { + maybePromptAnalyticsConsent as coreMaybePromptAnalyticsConsent, + sendScanEvent as coreSendScanEvent, + shouldSendAnalytics as coreShouldSendAnalytics, + type TelemetryEventPayload, +} from '@framework-doctor/core'; +import prompts from 'prompts'; +import type { ProjectInfo, ScoreResult } from '../types.js'; +import { highlighter } from '../ui/highlighter.js'; +import { logger } from '../ui/logger.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 => ({ + 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/pnpm-lock.yaml b/pnpm-lock.yaml index ebeb778..8c446c4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -145,8 +145,32 @@ importers: specifier: 'catalog:' version: 5.9.3 + packages/core: + 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 +217,9 @@ importers: packages/svelte-doctor: dependencies: + '@framework-doctor/core': + specifier: workspace:* + version: link:../core commander: specifier: 'catalog:' version: 14.0.3 @@ -208,6 +235,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 +245,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 diff --git a/turbo.json b/turbo.json index 753c554..acd8ff0 100644 --- a/turbo.json +++ b/turbo.json @@ -10,7 +10,7 @@ "persistent": true }, "typecheck": { - "dependsOn": ["^typecheck"], + "dependsOn": ["^build", "^typecheck"], "outputs": [] }, "lint": { From d3d825796d9c8aa3a75ff9a57e4e57cd968d83ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piti=C8=99=20Radu?= Date: Sun, 22 Feb 2026 15:53:50 +0100 Subject: [PATCH 08/17] feat: more core stuff --- README.md | 10 ++++++++-- packages/core/src/cli-options.ts | 12 ++++++++++++ packages/core/src/index.ts | 6 ++++++ packages/react-doctor/src/cli.ts | 9 ++++++--- packages/svelte-doctor/README.md | 12 +++++++++++- packages/svelte-doctor/src/cli.ts | 9 ++++++--- 6 files changed, 49 insertions(+), 9 deletions(-) create mode 100644 packages/core/src/cli-options.ts diff --git a/README.md b/README.md index 6ad52f6..0609327 100644 --- a/README.md +++ b/README.md @@ -72,13 +72,14 @@ 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`, `--project`, `--diff`. See [packages/react-doctor/README.md](packages/react-doctor/README.md). +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 @@ -92,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)). To disable: `--no-analytics`, `"analytics": false` in config, or `DO_NOT_TRACK=1`. + ## Configuration Create `svelte-doctor.config.json`: @@ -106,7 +111,8 @@ Create `svelte-doctor.config.json`: "jsTsLint": true, "deadCode": true, "verbose": false, - "diff": false + "diff": false, + "analytics": true } ``` 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/index.ts b/packages/core/src/index.ts index d8c344c..d706364 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,3 +1,9 @@ +export { + ANALYTICS_CONFIG_KEY, + ANALYTICS_OPTION_DESCRIPTION, + ANALYTICS_OPTION_FLAGS, + addAnalyticsOption, +} from './cli-options.js'; export { getOrCreateInstallId, readGlobalConfig, diff --git a/packages/react-doctor/src/cli.ts b/packages/react-doctor/src/cli.ts index 810f829..b53be02 100644 --- a/packages/react-doctor/src/cli.ts +++ b/packages/react-doctor/src/cli.ts @@ -1,4 +1,4 @@ -import { isAutomatedEnvironment } from '@framework-doctor/core'; +import { addAnalyticsOption, isAutomatedEnvironment } from '@framework-doctor/core'; import { Command } from 'commander'; import path from 'node:path'; import { scan } from './scan.js'; @@ -102,8 +102,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('--no-analytics', 'disable anonymous analytics') + .option('--diff [base]', 'scan only files changed vs base branch'); + +addAnalyticsOption(program); + +program .action(async (directory: string, flags: CliFlags) => { const isScoreOnly = flags.score; diff --git a/packages/svelte-doctor/README.md b/packages/svelte-doctor/README.md index 05c3a3f..f767406 100644 --- a/packages/svelte-doctor/README.md +++ b/packages/svelte-doctor/README.md @@ -34,6 +34,7 @@ Options: --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) @@ -54,7 +55,8 @@ Create `svelte-doctor.config.json`: "jsTsLint": true, "deadCode": true, "verbose": false, - "diff": false + "diff": false, + "analytics": true } ``` @@ -81,6 +83,14 @@ 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 + +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. 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 diff --git a/packages/svelte-doctor/src/cli.ts b/packages/svelte-doctor/src/cli.ts index a17ba1c..a4fcc2a 100644 --- a/packages/svelte-doctor/src/cli.ts +++ b/packages/svelte-doctor/src/cli.ts @@ -1,4 +1,4 @@ -import { isAutomatedEnvironment } from '@framework-doctor/core'; +import { addAnalyticsOption, isAutomatedEnvironment } from '@framework-doctor/core'; import { Command } from 'commander'; import path from 'node:path'; import { performance } from 'node:perf_hooks'; @@ -273,8 +273,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('--no-analytics', 'disable anonymous analytics') + .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)') From 72488354c6b3d04b8ab389846be20da84f9c1c80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piti=C8=99=20Radu?= Date: Sun, 22 Feb 2026 16:40:50 +0100 Subject: [PATCH 09/17] refactor: more from svelte doctor into core --- packages/core/package.json | 6 +- packages/core/src/calculate-score.ts | 43 ++++ packages/core/src/constants.ts | 20 ++ packages/core/src/get-diff-files.ts | 114 +++++++++++ packages/core/src/index.ts | 38 ++++ packages/core/src/load-config.ts | 37 ++++ .../core/src/security/get-files-to-scan.ts | 57 ++++++ packages/core/src/security/index.ts | 13 ++ packages/core/src/security/react-rules.ts | 12 ++ packages/core/src/security/rule.ts | 8 + .../core/src/security/run-security-scan.ts | 65 ++++++ packages/core/src/security/svelte-rules.ts | 10 + packages/core/src/security/universal-rules.ts | 46 +++++ packages/core/src/types.ts | 24 +++ .../core/src/ui/build-counts-summary-line.ts | 42 ++++ .../src/ui/colorize-by-score.ts | 0 packages/core/src/ui/doctor-face.ts | 7 + packages/core/src/ui/format-elapsed-time.ts | 8 + .../src/ui/framed-box.ts | 0 .../src/ui/highlighter.ts | 0 packages/core/src/ui/index.ts | 10 + .../{svelte-doctor => core}/src/ui/logger.ts | 14 +- packages/core/src/ui/score-bar.ts | 14 ++ .../{svelte-doctor => core}/src/ui/spinner.ts | 1 - packages/react-doctor/src/constants.ts | 2 - packages/react-doctor/src/index.ts | 12 +- packages/react-doctor/src/scan.ts | 120 ++++------- packages/react-doctor/src/types.ts | 22 +- .../react-doctor/src/utils/calculate-score.ts | 9 +- .../src/utils/combine-diagnostics.ts | 2 + .../react-doctor/src/utils/get-diff-files.ts | 102 +-------- .../react-doctor/src/utils/load-config.ts | 44 +--- .../src/utils/run-security-scan.ts | 14 ++ packages/react-doctor/src/utils/spinner.ts | 44 ---- .../tests/combine-diagnostics.test.ts | 8 +- .../react-doctor/tests/load-config.test.ts | 12 +- packages/svelte-doctor/src/cli.ts | 83 ++------ packages/svelte-doctor/src/constants.ts | 10 - packages/svelte-doctor/src/types.ts | 15 +- .../src/utils/format-elapsed-time.ts | 12 -- .../svelte-doctor/src/utils/get-diff-files.ts | 36 +--- .../svelte-doctor/src/utils/load-config.ts | 35 +--- .../src/utils/run-security-scan.ts | 193 +----------------- packages/svelte-doctor/src/utils/score.ts | 22 +- packages/svelte-doctor/src/utils/telemetry.ts | 4 +- packages/svelte-doctor/tests/score.test.ts | 2 +- pnpm-lock.yaml | 7 + 47 files changed, 715 insertions(+), 684 deletions(-) create mode 100644 packages/core/src/calculate-score.ts create mode 100644 packages/core/src/get-diff-files.ts create mode 100644 packages/core/src/load-config.ts create mode 100644 packages/core/src/security/get-files-to-scan.ts create mode 100644 packages/core/src/security/index.ts create mode 100644 packages/core/src/security/react-rules.ts create mode 100644 packages/core/src/security/rule.ts create mode 100644 packages/core/src/security/run-security-scan.ts create mode 100644 packages/core/src/security/svelte-rules.ts create mode 100644 packages/core/src/security/universal-rules.ts create mode 100644 packages/core/src/types.ts create mode 100644 packages/core/src/ui/build-counts-summary-line.ts rename packages/{svelte-doctor => core}/src/ui/colorize-by-score.ts (100%) create mode 100644 packages/core/src/ui/doctor-face.ts create mode 100644 packages/core/src/ui/format-elapsed-time.ts rename packages/{svelte-doctor => core}/src/ui/framed-box.ts (100%) rename packages/{svelte-doctor => core}/src/ui/highlighter.ts (100%) create mode 100644 packages/core/src/ui/index.ts rename packages/{svelte-doctor => core}/src/ui/logger.ts (65%) create mode 100644 packages/core/src/ui/score-bar.ts rename packages/{svelte-doctor => core}/src/ui/spinner.ts (93%) create mode 100644 packages/react-doctor/src/utils/run-security-scan.ts delete mode 100644 packages/react-doctor/src/utils/spinner.ts delete mode 100644 packages/svelte-doctor/src/constants.ts delete mode 100644 packages/svelte-doctor/src/utils/format-elapsed-time.ts diff --git a/packages/core/package.json b/packages/core/package.json index 554949c..a6dd3f9 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -35,5 +35,9 @@ "tsdown": "catalog:", "typescript": "catalog:" }, - "packageManager": "pnpm@10.30.0" + "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..dfce27f --- /dev/null +++ b/packages/core/src/calculate-score.ts @@ -0,0 +1,43 @@ +import { + ERROR_RULE_PENALTY, + PERFECT_SCORE, + SCORE_GOOD_THRESHOLD, + SCORE_OK_THRESHOLD, + WARNING_RULE_PENALTY, +} from './constants.js'; +import type { Diagnostic, 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)); +}; + +export const calculateScore = (diagnostics: Diagnostic[]): ScoreResult => { + const { errorRuleCount, warningRuleCount } = countUniqueRules(diagnostics); + const score = scoreFromRuleCounts(errorRuleCount, warningRuleCount); + return { score, label: getScoreLabel(score) }; +}; diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index 39d327f..c6ffa6e 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -1 +1,21 @@ 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 = 1.5; + +export const WARNING_RULE_PENALTY = 0.75; 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/index.ts b/packages/core/src/index.ts index d706364..ae7eff8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,15 +1,38 @@ +export { calculateScore } from './calculate-score.js'; export { ANALYTICS_CONFIG_KEY, ANALYTICS_OPTION_DESCRIPTION, ANALYTICS_OPTION_FLAGS, addAnalyticsOption, } from './cli-options.js'; +export { + PERFECT_SCORE, + SCORE_BAR_WIDTH_CHARS, + SCORE_GOOD_THRESHOLD, + SCORE_OK_THRESHOLD, +} 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, @@ -18,3 +41,18 @@ export { type TelemetryEventPayload, type TelemetryFlags, } from './telemetry.js'; +export type { Diagnostic, DiffInfo, ScoreResult } from './types.js'; +export { + buildCountsSummaryLine, + buildScoreBar, + colorizeByScore, + createFramedLine, + formatElapsedTime, + getDoctorFace, + highlighter, + logger, + printFramedBox, + renderFramedBoxString, + spinner, +} from './ui/index.js'; +export type { FramedLine } from './ui/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/types.ts b/packages/core/src/types.ts new file mode 100644 index 0000000..7b4c66b --- /dev/null +++ b/packages/core/src/types.ts @@ -0,0 +1,24 @@ +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 ScoreResult { + score: number; + label: string; +} + +export interface DiffInfo { + currentBranch: string; + baseBranch: string; + changedFiles: string[]; + isCurrentChanges?: 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/svelte-doctor/src/ui/colorize-by-score.ts b/packages/core/src/ui/colorize-by-score.ts similarity index 100% rename from packages/svelte-doctor/src/ui/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..b5dfb68 --- /dev/null +++ b/packages/core/src/ui/index.ts @@ -0,0 +1,10 @@ +export { buildCountsSummaryLine } from './build-counts-summary-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/svelte-doctor/src/ui/logger.ts b/packages/core/src/ui/logger.ts similarity index 65% rename from packages/svelte-doctor/src/ui/logger.ts rename to packages/core/src/ui/logger.ts index c195b25..21d7eda 100644 --- a/packages/svelte-doctor/src/ui/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/constants.ts b/packages/react-doctor/src/constants.ts index 9bef680..e005ecb 100644 --- a/packages/react-doctor/src/constants.ts +++ b/packages/react-doctor/src/constants.ts @@ -28,8 +28,6 @@ export const SPAWN_ARGS_MAX_LENGTH_CHARS = 24_000; export const OFFLINE_FLAG_MESSAGE = 'Score not available.'; -export const DEFAULT_BRANCH_CANDIDATES = ['main', 'master']; - export const ERROR_RULE_PENALTY = 1.5; export const WARNING_RULE_PENALTY = 0.75; diff --git a/packages/react-doctor/src/index.ts b/packages/react-doctor/src/index.ts index bf54652..989b8d3 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 }; @@ -68,10 +69,19 @@ 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, diff --git a/packages/react-doctor/src/scan.ts b/packages/react-doctor/src/scan.ts index 5902700..5b1443f 100644 --- a/packages/react-doctor/src/scan.ts +++ b/packages/react-doctor/src/scan.ts @@ -1,17 +1,25 @@ +import type { FramedLine } from '@framework-doctor/core'; +import { + buildCountsSummaryLine, + buildScoreBar, + colorizeByScore, + createFramedLine, + getDoctorFace, + highlighter, + 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, OXLINT_NODE_REQUIREMENT, OXLINT_RECOMMENDED_NODE_MAJOR, - PERFECT_SCORE, - SCORE_BAR_WIDTH_CHARS, - SCORE_GOOD_THRESHOLD, - SCORE_OK_THRESHOLD, } from './constants.js'; import type { Diagnostic, @@ -22,15 +30,11 @@ 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, @@ -39,12 +43,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, @@ -109,13 +108,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); @@ -162,41 +154,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); @@ -236,9 +203,8 @@ 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)); lines.push(createFramedLine('')); } else { lines.push( @@ -255,40 +221,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 = ( @@ -301,7 +244,7 @@ const printSummary = ( ): void => { const summaryFramedLines = [ ...buildBrandingLines(scoreResult, noScoreMessage), - buildCountsSummaryLine(diagnostics, totalSourceFileCount, elapsedMilliseconds), + toCountsFramedLine(diagnostics, totalSourceFileCount, elapsedMilliseconds), ]; printFramedBox(summaryFramedLines); @@ -507,10 +450,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, diff --git a/packages/react-doctor/src/types.ts b/packages/react-doctor/src/types.ts index cef369b..8280327 100644 --- a/packages/react-doctor/src/types.ts +++ b/packages/react-doctor/src/types.ts @@ -1,3 +1,5 @@ +import type { Diagnostic } from '@framework-doctor/core'; + export type Framework = 'nextjs' | 'vite' | 'cra' | 'remix' | 'gatsby' | 'unknown'; export interface ProjectInfo { @@ -40,18 +42,7 @@ 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 } from '@framework-doctor/core'; export interface PackageJson { name?: string; @@ -105,13 +96,6 @@ export interface ScanOptions { includePaths?: string[]; } -export interface DiffInfo { - currentBranch: string; - baseBranch: string; - changedFiles: string[]; - isCurrentChanges?: boolean; -} - export interface HandleErrorOptions { shouldExit: boolean; } diff --git a/packages/react-doctor/src/utils/calculate-score.ts b/packages/react-doctor/src/utils/calculate-score.ts index b181585..a22fb69 100644 --- a/packages/react-doctor/src/utils/calculate-score.ts +++ b/packages/react-doctor/src/utils/calculate-score.ts @@ -1,3 +1,4 @@ +import { calculateScore as calculateScoreFromCore } from '@framework-doctor/core'; import { ERROR_ESTIMATED_FIX_RATE, ERROR_RULE_PENALTY, @@ -38,14 +39,8 @@ const scoreFromRuleCounts = (errorRuleCount: number, warningRuleCount: number): return Math.max(0, Math.round(PERFECT_SCORE - penalty)); }; -const calculateScoreLocally = (diagnostics: Diagnostic[]): ScoreResult => { - const { errorRuleCount, warningRuleCount } = countUniqueRules(diagnostics); - const score = scoreFromRuleCounts(errorRuleCount, warningRuleCount); - return { score, label: getScoreLabel(score) }; -}; - export const calculateScore = async (diagnostics: Diagnostic[]): Promise => - Promise.resolve(calculateScoreLocally(diagnostics)); + Promise.resolve(calculateScoreFromCore(diagnostics)); export const fetchEstimatedScore = async ( diagnostics: Diagnostic[], 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/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/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/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/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/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/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/svelte-doctor/src/cli.ts b/packages/svelte-doctor/src/cli.ts index a4fcc2a..c21b247 100644 --- a/packages/svelte-doctor/src/cli.ts +++ b/packages/svelte-doctor/src/cli.ts @@ -1,22 +1,23 @@ -import { addAnalyticsOption, isAutomatedEnvironment } from '@framework-doctor/core'; +import { + addAnalyticsOption, + buildCountsSummaryLine, + buildScoreBar, + colorizeByScore, + createFramedLine, + getDoctorFace, + highlighter, + 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 { 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'; @@ -58,9 +59,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) { @@ -100,59 +98,6 @@ 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, 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/types.ts b/packages/svelte-doctor/src/types.ts index 0d508f2..3f6ed61 100644 --- a/packages/svelte-doctor/src/types.ts +++ b/packages/svelte-doctor/src/types.ts @@ -1,3 +1,5 @@ +import type { Diagnostic } from '@framework-doctor/core'; + export type SvelteFramework = 'sveltekit' | 'svelte'; export interface ProjectInfo { @@ -9,18 +11,7 @@ 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 type { Diagnostic } from '@framework-doctor/core'; export interface ScoreResult { score: number; 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/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 index 56c4c24..f6c6888 100644 --- a/packages/svelte-doctor/src/utils/telemetry.ts +++ b/packages/svelte-doctor/src/utils/telemetry.ts @@ -2,12 +2,12 @@ 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'; -import { highlighter } from '../ui/highlighter.js'; -import { logger } from '../ui/logger.js'; export const shouldSendAnalytics = coreShouldSendAnalytics; diff --git a/packages/svelte-doctor/tests/score.test.ts b/packages/svelte-doctor/tests/score.test.ts index cc54e10..370b143 100644 --- a/packages/svelte-doctor/tests/score.test.ts +++ b/packages/svelte-doctor/tests/score.test.ts @@ -32,7 +32,7 @@ describe('calculateScore', () => { }, ]); - expect(result.score).toBe(92); + expect(result.score).toBe(98); expect(result.label).toBe('Great'); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8c446c4..f15dd03 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -146,6 +146,13 @@ importers: 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:' From 75b110ce7b1aac2a0bced481def437ba9e9c3c61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piti=C8=99=20Radu?= Date: Sun, 22 Feb 2026 16:55:36 +0100 Subject: [PATCH 10/17] refactor: move shared utilities to core --- packages/core/src/index.ts | 1 + .../src/utils/group-by.ts | 0 .../src/utils/indent-multiline-text.ts | 0 packages/core/src/utils/index.ts | 2 + packages/react-doctor/src/cli.ts | 9 ++-- packages/react-doctor/src/scan.ts | 4 +- .../src/utils/colorize-by-score.ts | 8 --- packages/react-doctor/src/utils/framed-box.ts | 51 ------------------- .../react-doctor/src/utils/handle-error.ts | 2 +- .../react-doctor/src/utils/highlighter.ts | 9 ---- packages/react-doctor/src/utils/logger.ts | 25 --------- packages/react-doctor/src/utils/prompts.ts | 2 +- .../react-doctor/src/utils/select-projects.ts | 3 +- .../react-doctor/src/utils/skill-prompt.ts | 4 +- packages/react-doctor/src/utils/telemetry.ts | 4 +- .../tests/colorize-by-score.test.ts | 2 +- .../tests/indent-multiline-text.test.ts | 2 +- packages/svelte-doctor/src/cli.ts | 4 +- packages/svelte-doctor/src/utils/group-by.ts | 12 ----- .../src/utils/indent-multiline-text.ts | 5 -- 20 files changed, 21 insertions(+), 128 deletions(-) rename packages/{react-doctor => core}/src/utils/group-by.ts (100%) rename packages/{react-doctor => core}/src/utils/indent-multiline-text.ts (100%) create mode 100644 packages/core/src/utils/index.ts delete mode 100644 packages/react-doctor/src/utils/colorize-by-score.ts delete mode 100644 packages/react-doctor/src/utils/framed-box.ts delete mode 100644 packages/react-doctor/src/utils/highlighter.ts delete mode 100644 packages/react-doctor/src/utils/logger.ts delete mode 100644 packages/svelte-doctor/src/utils/group-by.ts delete mode 100644 packages/svelte-doctor/src/utils/indent-multiline-text.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ae7eff8..ded52d2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -56,3 +56,4 @@ export { spinner, } from './ui/index.js'; export type { FramedLine } from './ui/index.js'; +export { groupBy, indentMultilineText } from './utils/index.js'; 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..38f0b6e --- /dev/null +++ b/packages/core/src/utils/index.ts @@ -0,0 +1,2 @@ +export { groupBy } from './group-by.js'; +export { indentMultilineText } from './indent-multiline-text.js'; diff --git a/packages/react-doctor/src/cli.ts b/packages/react-doctor/src/cli.ts index b53be02..e0ec896 100644 --- a/packages/react-doctor/src/cli.ts +++ b/packages/react-doctor/src/cli.ts @@ -1,13 +1,16 @@ -import { addAnalyticsOption, isAutomatedEnvironment } from '@framework-doctor/core'; +import { + addAnalyticsOption, + highlighter, + isAutomatedEnvironment, + logger, +} from '@framework-doctor/core'; import { Command } from 'commander'; import path from 'node:path'; import { scan } from './scan.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 { prompts } from './utils/prompts.js'; import { selectProjects } from './utils/select-projects.js'; import { maybePromptSkillInstall } from './utils/skill-prompt.js'; diff --git a/packages/react-doctor/src/scan.ts b/packages/react-doctor/src/scan.ts index 5b1443f..abb0316 100644 --- a/packages/react-doctor/src/scan.ts +++ b/packages/react-doctor/src/scan.ts @@ -5,7 +5,9 @@ import { colorizeByScore, createFramedLine, getDoctorFace, + groupBy, highlighter, + indentMultilineText, logger, PERFECT_SCORE, printFramedBox, @@ -32,8 +34,6 @@ import type { import { calculateScore } from './utils/calculate-score.js'; import { combineDiagnostics, computeJsxIncludePaths } from './utils/combine-diagnostics.js'; import { discoverProject, formatFrameworkName } from './utils/discover-project.js'; -import { groupBy } from './utils/group-by.js'; -import { indentMultilineText } from './utils/indent-multiline-text.js'; import { loadConfig } from './utils/load-config.js'; import { prompts } from './utils/prompts.js'; import { diff --git a/packages/react-doctor/src/utils/colorize-by-score.ts b/packages/react-doctor/src/utils/colorize-by-score.ts deleted file mode 100644 index 7a4ccba..0000000 --- a/packages/react-doctor/src/utils/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/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/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/logger.ts b/packages/react-doctor/src/utils/logger.ts deleted file mode 100644 index c195b25..0000000 --- a/packages/react-doctor/src/utils/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/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/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 4a9fb67..a42c46e 100644 --- a/packages/react-doctor/src/utils/skill-prompt.ts +++ b/packages/react-doctor/src/utils/skill-prompt.ts @@ -1,10 +1,8 @@ -import { readGlobalConfig, writeGlobalConfig } from '@framework-doctor/core'; +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(); diff --git a/packages/react-doctor/src/utils/telemetry.ts b/packages/react-doctor/src/utils/telemetry.ts index fbe3f9b..60920f0 100644 --- a/packages/react-doctor/src/utils/telemetry.ts +++ b/packages/react-doctor/src/utils/telemetry.ts @@ -2,11 +2,11 @@ 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 { highlighter } from './highlighter.js'; -import { logger } from './logger.js'; import { prompts } from './prompts.js'; export const shouldSendAnalytics = coreShouldSendAnalytics; 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/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/svelte-doctor/src/cli.ts b/packages/svelte-doctor/src/cli.ts index c21b247..ff7954a 100644 --- a/packages/svelte-doctor/src/cli.ts +++ b/packages/svelte-doctor/src/cli.ts @@ -5,7 +5,9 @@ import { colorizeByScore, createFramedLine, getDoctorFace, + groupBy, highlighter, + indentMultilineText, isAutomatedEnvironment, logger, PERFECT_SCORE, @@ -19,8 +21,6 @@ import type { Diagnostic, ScanOptions, SvelteDoctorConfig } from './types.js'; import { discoverProject } from './utils/discover-project.js'; import { filterIgnoredDiagnostics } from './utils/filter-diagnostics.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'; 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'); From dc04bd751c6ececbae57a23143fa5a67fd5e4dba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piti=C8=99=20Radu?= Date: Sun, 22 Feb 2026 17:00:54 +0100 Subject: [PATCH 11/17] refactor: re-exports and unify readjson --- packages/core/src/index.ts | 15 +++++++++- .../src/utils/find-monorepo-root.ts | 10 +++++-- packages/core/src/utils/index.ts | 3 ++ .../src/utils/match-glob-pattern.ts | 0 .../src/utils/read-json.ts | 0 packages/react-doctor/src/constants.ts | 30 ++++++++----------- .../src/utils/discover-project.ts | 2 +- .../src/utils/filter-diagnostics.ts | 2 +- .../src/utils/read-package-json.ts | 4 +-- packages/react-doctor/src/utils/run-knip.ts | 2 +- .../tests/find-monorepo-root.test.ts | 2 +- .../tests/match-glob-pattern.test.ts | 2 +- .../src/utils/discover-project.ts | 2 +- 13 files changed, 45 insertions(+), 29 deletions(-) rename packages/{react-doctor => core}/src/utils/find-monorepo-root.ts (70%) rename packages/{react-doctor => core}/src/utils/match-glob-pattern.ts (100%) rename packages/{svelte-doctor => core}/src/utils/read-json.ts (100%) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ded52d2..6fd6549 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -6,10 +6,15 @@ export { addAnalyticsOption, } from './cli-options.js'; 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 './constants.js'; export { SOURCE_FILE_PATTERN_JS_TS, @@ -56,4 +61,12 @@ export { spinner, } from './ui/index.js'; export type { FramedLine } from './ui/index.js'; -export { groupBy, indentMultilineText } from './utils/index.js'; +export { + compileGlobPattern, + findMonorepoRoot, + groupBy, + indentMultilineText, + isMonorepoRoot, + matchGlobPattern, + readJson, +} from './utils/index.js'; 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/core/src/utils/index.ts b/packages/core/src/utils/index.ts index 38f0b6e..d588080 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -1,2 +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/react-doctor/src/constants.ts b/packages/react-doctor/src/constants.ts index e005ecb..7226018 100644 --- a/packages/react-doctor/src/constants.ts +++ b/packages/react-doctor/src/constants.ts @@ -1,23 +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 FETCH_TIMEOUT_MS = 10_000; export const GIT_LS_FILES_MAX_BUFFER_BYTES = 50 * 1024 * 1024; @@ -28,10 +26,6 @@ export const SPAWN_ARGS_MAX_LENGTH_CHARS = 24_000; export const OFFLINE_FLAG_MESSAGE = 'Score not available.'; -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; 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/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/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/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/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; From 18663836d1e757e68484e1e0015fbf8fc176c8ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piti=C8=99=20Radu?= Date: Sun, 22 Feb 2026 17:05:20 +0100 Subject: [PATCH 12/17] refactor: more types --- packages/core/src/index.ts | 2 +- packages/core/src/types.ts | 14 ++++++++++++++ packages/react-doctor/src/types.ts | 23 ++++------------------- packages/svelte-doctor/src/types.ts | 22 +++------------------- 4 files changed, 22 insertions(+), 39 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6fd6549..4f933e9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -46,7 +46,7 @@ export { type TelemetryEventPayload, type TelemetryFlags, } from './telemetry.js'; -export type { Diagnostic, DiffInfo, ScoreResult } from './types.js'; +export type { BaseDoctorConfig, Diagnostic, DiffInfo, IgnoreConfig, ScoreResult } from './types.js'; export { buildCountsSummaryLine, buildScoreBar, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 7b4c66b..cf7ad04 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -22,3 +22,17 @@ export interface DiffInfo { 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/react-doctor/src/types.ts b/packages/react-doctor/src/types.ts index 8280327..d134a93 100644 --- a/packages/react-doctor/src/types.ts +++ b/packages/react-doctor/src/types.ts @@ -1,4 +1,4 @@ -import type { Diagnostic } from '@framework-doctor/core'; +import type { BaseDoctorConfig, Diagnostic, ScoreResult } from '@framework-doctor/core'; export type Framework = 'nextjs' | 'vite' | 'cra' | 'remix' | 'gatsby' | 'unknown'; @@ -42,7 +42,7 @@ export interface OxlintOutput { number_of_rules: number; } -export type { Diagnostic, DiffInfo } from '@framework-doctor/core'; +export type { Diagnostic, DiffInfo, ScoreResult } from '@framework-doctor/core'; export interface PackageJson { name?: string; @@ -69,11 +69,6 @@ export interface KnipIssueRecords { }; } -export interface ScoreResult { - score: number; - label: string; -} - export interface ScanResult { diagnostics: Diagnostic[]; scoreResult: ScoreResult | null; @@ -136,16 +131,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; - analytics?: boolean; -} +export type { IgnoreConfig } from '@framework-doctor/core'; diff --git a/packages/svelte-doctor/src/types.ts b/packages/svelte-doctor/src/types.ts index 3f6ed61..c083238 100644 --- a/packages/svelte-doctor/src/types.ts +++ b/packages/svelte-doctor/src/types.ts @@ -1,4 +1,4 @@ -import type { Diagnostic } from '@framework-doctor/core'; +import type { BaseDoctorConfig, Diagnostic, ScoreResult } from '@framework-doctor/core'; export type SvelteFramework = 'sveltekit' | 'svelte'; @@ -11,12 +11,7 @@ export interface ProjectInfo { sourceFileCount: number; } -export type { Diagnostic } from '@framework-doctor/core'; - -export interface ScoreResult { - score: number; - label: string; -} +export type { Diagnostic, IgnoreConfig, ScoreResult } from '@framework-doctor/core'; export interface ScanOptions { lint?: boolean; @@ -33,19 +28,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; - analytics?: boolean; } export interface VersionedRuleMeta { From 2f1f2efbd6130bf7de063814a0cc303e8add1a26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piti=C8=99=20Radu?= Date: Sun, 22 Feb 2026 17:13:25 +0100 Subject: [PATCH 13/17] fix: more svelte fixes --- examples/svelte/demo-app/package.json | 2 +- examples/svelte/demo-app/svelte.config.js | 2 +- package.json | 10 +- pnpm-lock.yaml | 222 ++++++++++++++++++++-- turbo.json | 3 + 5 files changed, 220 insertions(+), 19 deletions(-) diff --git a/examples/svelte/demo-app/package.json b/examples/svelte/demo-app/package.json index 71e0b43..bac9974 100644 --- a/examples/svelte/demo-app/package.json +++ b/examples/svelte/demo-app/package.json @@ -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/pnpm-lock.yaml b/pnpm-lock.yaml index f15dd03..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)) @@ -1011,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] @@ -1157,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==} @@ -1223,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==} @@ -1344,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} @@ -1425,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==} @@ -1572,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==} @@ -1657,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'} @@ -1689,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'} @@ -1704,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==} @@ -1738,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'} @@ -1749,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'} @@ -1769,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==} @@ -1929,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==} @@ -1965,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'} @@ -2047,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} @@ -2148,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'} @@ -2313,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'} @@ -2560,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==} @@ -3242,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 @@ -3323,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: @@ -3401,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': @@ -3510,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 @@ -3574,6 +3717,8 @@ snapshots: commander@14.0.3: {} + commondir@1.0.1: {} + concat-map@0.0.1: {} convert-source-map@2.0.0: {} @@ -3746,6 +3891,8 @@ snapshots: estraverse@5.3.0: {} + estree-walker@2.0.2: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 @@ -3837,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: {} @@ -3864,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: @@ -3879,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: @@ -3904,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: {} @@ -3924,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 @@ -4083,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: {} @@ -4105,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 @@ -4220,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 @@ -4289,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 @@ -4466,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 @@ -4678,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/turbo.json b/turbo.json index acd8ff0..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/**"] From b4a23facae1cf3f84c3ae9f22e2577c28c8dd811 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piti=C8=99=20Radu?= Date: Sun, 22 Feb 2026 17:32:30 +0100 Subject: [PATCH 14/17] fix: scoring formula --- packages/core/src/calculate-score.ts | 98 ++++++++++++-- packages/core/src/constants.ts | 12 +- packages/core/src/index.ts | 15 ++- packages/core/src/types.ts | 22 +++ .../core/src/ui/build-score-breakdown-line.ts | 24 ++++ packages/core/src/ui/index.ts | 1 + packages/react-doctor/src/constants.ts | 4 - packages/react-doctor/src/index.ts | 10 +- packages/react-doctor/src/scan.ts | 20 ++- packages/react-doctor/src/types.ts | 14 +- .../react-doctor/src/utils/calculate-score.ts | 71 +--------- packages/svelte-doctor/src/cli.ts | 43 ++++-- packages/svelte-doctor/src/scan.ts | 5 +- packages/svelte-doctor/src/types.ts | 7 +- packages/svelte-doctor/tests/score.test.ts | 125 ++++++++++++++---- 15 files changed, 337 insertions(+), 134 deletions(-) create mode 100644 packages/core/src/ui/build-score-breakdown-line.ts diff --git a/packages/core/src/calculate-score.ts b/packages/core/src/calculate-score.ts index dfce27f..b046f1f 100644 --- a/packages/core/src/calculate-score.ts +++ b/packages/core/src/calculate-score.ts @@ -1,11 +1,15 @@ 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, ScoreResult } from './types.js'; +import type { Diagnostic, ScoreBreakdown, ScoreGuardrailInput, ScoreResult } from './types.js'; const getScoreLabel = (score: number): string => { if (score >= SCORE_GOOD_THRESHOLD) return 'Great'; @@ -13,13 +17,21 @@ const getScoreLabel = (score: number): string => { return 'Critical'; }; -const countUniqueRules = ( +const countMetrics = ( diagnostics: Diagnostic[], -): { errorRuleCount: number; warningRuleCount: number } => { +): { + 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); @@ -28,16 +40,78 @@ const countUniqueRules = ( } } - return { errorRuleCount: errorRules.size, warningRuleCount: warningRules.size }; -}; + const errorCount = diagnostics.filter((d) => d.severity === 'error').length; + const warningCount = diagnostics.filter((d) => d.severity === 'warning').length; -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)); + return { + errorCount, + warningCount, + uniqueErrorRules: errorRules.size, + uniqueWarningRules: warningRules.size, + filesWithDiagnostics: filesWithDiagnostics.size, + }; }; -export const calculateScore = (diagnostics: Diagnostic[]): ScoreResult => { - const { errorRuleCount, warningRuleCount } = countUniqueRules(diagnostics); - const score = scoreFromRuleCounts(errorRuleCount, warningRuleCount); - return { score, label: getScoreLabel(score) }; +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/constants.ts b/packages/core/src/constants.ts index c6ffa6e..acacd84 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -16,6 +16,14 @@ export const SUMMARY_BOX_OUTER_INDENT_CHARS = 2; export const SUMMARY_BOX_HORIZONTAL_PADDING_CHARS = 1; -export const ERROR_RULE_PENALTY = 1.5; +export const ERROR_RULE_PENALTY = 2.5; -export const WARNING_RULE_PENALTY = 0.75; +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/index.ts b/packages/core/src/index.ts index 4f933e9..5117003 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -7,14 +7,18 @@ export { } 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, @@ -46,10 +50,19 @@ export { type TelemetryEventPayload, type TelemetryFlags, } from './telemetry.js'; -export type { BaseDoctorConfig, Diagnostic, DiffInfo, IgnoreConfig, ScoreResult } from './types.js'; +export type { + BaseDoctorConfig, + Diagnostic, + DiffInfo, + IgnoreConfig, + ScoreBreakdown, + ScoreGuardrailInput, + ScoreResult, +} from './types.js'; export { buildCountsSummaryLine, buildScoreBar, + buildScoreBreakdownLines, colorizeByScore, createFramedLine, formatElapsedTime, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index cf7ad04..0c1c040 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -11,9 +11,31 @@ export interface Diagnostic { 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 { 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/core/src/ui/index.ts b/packages/core/src/ui/index.ts index b5dfb68..ec25208 100644 --- a/packages/core/src/ui/index.ts +++ b/packages/core/src/ui/index.ts @@ -1,4 +1,5 @@ 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'; diff --git a/packages/react-doctor/src/constants.ts b/packages/react-doctor/src/constants.ts index 7226018..813cdff 100644 --- a/packages/react-doctor/src/constants.ts +++ b/packages/react-doctor/src/constants.ts @@ -26,10 +26,6 @@ export const SPAWN_ARGS_MAX_LENGTH_CHARS = 24_000; export const OFFLINE_FLAG_MESSAGE = 'Score not available.'; -export const ERROR_ESTIMATED_FIX_RATE = 0.85; - -export const WARNING_ESTIMATED_FIX_RATE = 0.8; - export const MAX_KNIP_RETRIES = 5; export const OXLINT_NODE_REQUIREMENT = '^20.19.0 || >=22.12.0'; diff --git a/packages/react-doctor/src/index.ts b/packages/react-doctor/src/index.ts index 989b8d3..363a387 100644 --- a/packages/react-doctor/src/index.ts +++ b/packages/react-doctor/src/index.ts @@ -25,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 = {}, @@ -88,7 +93,10 @@ export const diagnose = async ( ); 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 abb0316..05661d9 100644 --- a/packages/react-doctor/src/scan.ts +++ b/packages/react-doctor/src/scan.ts @@ -2,6 +2,7 @@ import type { FramedLine } from '@framework-doctor/core'; import { buildCountsSummaryLine, buildScoreBar, + buildScoreBreakdownLines, colorizeByScore, createFramedLine, getDoctorFace, @@ -75,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, @@ -180,6 +186,7 @@ const printBranding = (score?: number): void => { const buildBrandingLines = ( scoreResult: ScoreResult | null, noScoreMessage: string, + verbose: boolean, ): FramedLine[] => { const lines: FramedLine[] = []; @@ -205,6 +212,10 @@ const buildBrandingLines = ( lines.push(createFramedLine('')); 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( @@ -241,9 +252,10 @@ const printSummary = ( projectName: string, totalSourceFileCount: number, noScoreMessage: string, + verbose: boolean, ): void => { const summaryFramedLines = [ - ...buildBrandingLines(scoreResult, noScoreMessage), + ...buildBrandingLines(scoreResult, noScoreMessage, verbose), toCountsFramedLine(diagnostics, totalSourceFileCount, elapsedMilliseconds), ]; printFramedBox(summaryFramedLines); @@ -475,7 +487,10 @@ export const scan = async ( if (didDeadCodeFail) skippedChecks.push('dead code'); const hasSkippedChecks = skippedChecks.length > 0; - const scoreResult = await calculateScore(diagnostics); + const totalFilesScanned = isDiffMode ? includePaths.length : projectInfo.sourceFileCount; + const scoreResult = await calculateScore(diagnostics, totalFilesScanned, { + hasHighOrCriticalSecurityFindings: hasHighOrCriticalSecurityFindings(diagnostics), + }); const noScoreMessage = OFFLINE_FLAG_MESSAGE; if (options.scoreOnly) { @@ -520,6 +535,7 @@ export const scan = async ( projectInfo.projectName, displayedSourceFileCount, noScoreMessage, + options.verbose, ); if (hasSkippedChecks) { diff --git a/packages/react-doctor/src/types.ts b/packages/react-doctor/src/types.ts index d134a93..6c4d2be 100644 --- a/packages/react-doctor/src/types.ts +++ b/packages/react-doctor/src/types.ts @@ -42,7 +42,12 @@ export interface OxlintOutput { number_of_rules: number; } -export type { Diagnostic, DiffInfo, ScoreResult } from '@framework-doctor/core'; +export type { + Diagnostic, + DiffInfo, + ScoreGuardrailInput, + ScoreResult, +} from '@framework-doctor/core'; export interface PackageJson { name?: string; @@ -76,13 +81,6 @@ export interface ScanResult { projectInfo: ProjectInfo; } -export interface EstimatedScoreResult { - currentScore: number; - currentLabel: string; - estimatedScore: number; - estimatedLabel: string; -} - export interface ScanOptions { lint?: boolean; deadCode?: boolean; diff --git a/packages/react-doctor/src/utils/calculate-score.ts b/packages/react-doctor/src/utils/calculate-score.ts index a22fb69..73548c7 100644 --- a/packages/react-doctor/src/utils/calculate-score.ts +++ b/packages/react-doctor/src/utils/calculate-score.ts @@ -1,68 +1,9 @@ import { calculateScore as calculateScoreFromCore } from '@framework-doctor/core'; -import { - ERROR_ESTIMATED_FIX_RATE, - ERROR_RULE_PENALTY, - PERFECT_SCORE, - 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 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 = ( +export const calculateScore = async ( 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)); -}; - -export const calculateScore = async (diagnostics: Diagnostic[]): Promise => - Promise.resolve(calculateScoreFromCore(diagnostics)); - -export const fetchEstimatedScore = async ( - diagnostics: Diagnostic[], -): Promise => { - 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 Promise.resolve({ - currentScore, - currentLabel: getScoreLabel(currentScore), - estimatedScore, - estimatedLabel: getScoreLabel(estimatedScore), - }); -}; + totalFilesScanned: number, + guardrailInput: ScoreGuardrailInput = {}, +): Promise => + Promise.resolve(calculateScoreFromCore(diagnostics, totalFilesScanned, guardrailInput)); diff --git a/packages/svelte-doctor/src/cli.ts b/packages/svelte-doctor/src/cli.ts index ff7954a..950283d 100644 --- a/packages/svelte-doctor/src/cli.ts +++ b/packages/svelte-doctor/src/cli.ts @@ -2,6 +2,7 @@ import { addAnalyticsOption, buildCountsSummaryLine, buildScoreBar, + buildScoreBreakdownLines, colorizeByScore, createFramedLine, getDoctorFace, @@ -17,7 +18,7 @@ import { import { Command } from 'commander'; import path from 'node:path'; import { performance } from 'node:perf_hooks'; -import type { Diagnostic, ScanOptions, SvelteDoctorConfig } from './types.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 { filterSourceFiles, getDiffInfo } from './utils/get-diff-files.js'; @@ -69,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); @@ -99,18 +105,19 @@ const printDiagnostics = (diagnostics: Diagnostic[], verbose: boolean): void => }; 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} │`)), @@ -123,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 => { @@ -309,7 +322,13 @@ main 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; @@ -333,10 +352,6 @@ main } 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!'); @@ -346,11 +361,11 @@ main logger.break(); printSummary( - scoreResult.score, - scoreResult.label, + scoreResult, diagnostics, totalSourceFileCount, elapsedMs, + Boolean(scanOptions.verbose), ); }); 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 c083238..61d9159 100644 --- a/packages/svelte-doctor/src/types.ts +++ b/packages/svelte-doctor/src/types.ts @@ -11,7 +11,12 @@ export interface ProjectInfo { sourceFileCount: number; } -export type { Diagnostic, IgnoreConfig, ScoreResult } from '@framework-doctor/core'; +export type { + Diagnostic, + IgnoreConfig, + ScoreGuardrailInput, + ScoreResult, +} from '@framework-doctor/core'; export interface ScanOptions { lint?: boolean; diff --git a/packages/svelte-doctor/tests/score.test.ts b/packages/svelte-doctor/tests/score.test.ts index 370b143..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(98); + 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']); }); }); From 97179b3fac4f6479bcddc589ab3e936abb3b839e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piti=C8=99=20Radu?= Date: Sun, 22 Feb 2026 17:45:11 +0100 Subject: [PATCH 15/17] fix: more telemetry stuff --- .env.example | 2 ++ README.md | 2 +- packages/core/src/telemetry.ts | 12 ++++++- packages/react-doctor/README.md | 1 + packages/react-doctor/src/utils/telemetry.ts | 1 + packages/svelte-doctor/README.md | 2 +- packages/svelte-doctor/src/utils/telemetry.ts | 1 + supabase/README.md | 3 +- supabase/functions/.env.example | 5 +++ supabase/functions/telemetry/index.ts | 35 +++++++++++++++++-- ...d_telemetry_event_id_and_doctor_family.sql | 21 +++++++++++ 11 files changed, 78 insertions(+), 7 deletions(-) create mode 100644 .env.example create mode 100644 supabase/functions/.env.example create mode 100644 supabase/migrations/20260222010000_add_telemetry_event_id_and_doctor_family.sql 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 0609327..bc4201a 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ To ignore a rule: `"svelte-doctor/no-at-html"`, `"svelte-doctor/no-new-function" ## 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)). To disable: `--no-analytics`, `"analytics": false` in config, or `DO_NOT_TRACK=1`. +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 diff --git a/packages/core/src/telemetry.ts b/packages/core/src/telemetry.ts index e5e8c75..660d056 100644 --- a/packages/core/src/telemetry.ts +++ b/packages/core/src/telemetry.ts @@ -54,6 +54,7 @@ const getScoreBucket = (score: number): string => { }; export interface TelemetryEventPayload { + doctor_family: string; framework: string; score: number; diagnostic_count: number; @@ -63,10 +64,19 @@ export interface TelemetryEventPayload { } 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(); @@ -74,7 +84,7 @@ export const sendScanEvent = (telemetryUrl: string, payload: TelemetryEventPaylo fetch(telemetryUrl, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers, body: JSON.stringify(fullPayload), signal: controller.signal, }) diff --git a/packages/react-doctor/README.md b/packages/react-doctor/README.md index 7b3312a..78866f0 100644 --- a/packages/react-doctor/README.md +++ b/packages/react-doctor/README.md @@ -103,6 +103,7 @@ CLI flags always override config values. 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). diff --git a/packages/react-doctor/src/utils/telemetry.ts b/packages/react-doctor/src/utils/telemetry.ts index 60920f0..33d1144 100644 --- a/packages/react-doctor/src/utils/telemetry.ts +++ b/packages/react-doctor/src/utils/telemetry.ts @@ -33,6 +33,7 @@ const buildPayload = ( diagnosticCount: number, options: { isDiffMode: boolean; cliVersion: string }, ): TelemetryEventPayload => ({ + doctor_family: 'react', framework: projectInfo.framework, score: scoreResult.score, diagnostic_count: diagnosticCount, diff --git a/packages/svelte-doctor/README.md b/packages/svelte-doctor/README.md index f767406..8d80e92 100644 --- a/packages/svelte-doctor/README.md +++ b/packages/svelte-doctor/README.md @@ -85,7 +85,7 @@ To ignore a rule: `"svelte-doctor/no-at-html"`, `"svelte-doctor/no-new-function" ## 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. Limited to framework type, score range, diagnostic count. No code or paths are collected. +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`. diff --git a/packages/svelte-doctor/src/utils/telemetry.ts b/packages/svelte-doctor/src/utils/telemetry.ts index f6c6888..4407798 100644 --- a/packages/svelte-doctor/src/utils/telemetry.ts +++ b/packages/svelte-doctor/src/utils/telemetry.ts @@ -33,6 +33,7 @@ const buildPayload = ( diagnosticCount: number, options: { isDiffMode: boolean; cliVersion: string }, ): TelemetryEventPayload => ({ + doctor_family: 'svelte', framework: projectInfo.framework, score: scoreResult.score, diagnostic_count: diagnosticCount, diff --git a/supabase/README.md b/supabase/README.md index a159a59..cebabef 100644 --- a/supabase/README.md +++ b/supabase/README.md @@ -8,6 +8,7 @@ Stores anonymous analytics from framework-doctor (react-doctor, svelte-doctor). 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 @@ -17,4 +18,4 @@ Stores anonymous analytics from framework-doctor (react-doctor, svelte-doctor). ## Data -Events are stored in `telemetry_events` with: framework, score, score_bucket, diagnostic_count, has_typescript, is_diff_mode, cli_version, anon_install_id. +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/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 index c78eb78..592538e 100644 --- a/supabase/functions/telemetry/index.ts +++ b/supabase/functions/telemetry/index.ts @@ -60,6 +60,7 @@ 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), @@ -72,9 +73,18 @@ const telemetryEventSchema = z.object({ 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().min(8).max(100).optional(), + 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 }); @@ -121,7 +131,18 @@ Deno.serve(async (req) => { ); } - const { install_id, ...rest } = parsed.data; + 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; @@ -135,7 +156,15 @@ Deno.serve(async (req) => { } const row = { - ...rest, + 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, }; 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; From 905638aefdc7819aa975d541995e1286d69be574 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piti=C8=99=20Radu?= Date: Sun, 22 Feb 2026 18:31:22 +0100 Subject: [PATCH 16/17] fix: telemetry function --- supabase/config.toml | 2 +- supabase/functions/telemetry/index.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/supabase/config.toml b/supabase/config.toml index f19abaa..b3789d2 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -9,7 +9,7 @@ max_rows = 1000 [db] port = 54322 shadow_port = 54320 -major_version = 15 +major_version = 17 [functions.telemetry] verify_jwt = false diff --git a/supabase/functions/telemetry/index.ts b/supabase/functions/telemetry/index.ts index 592538e..40c8b59 100644 --- a/supabase/functions/telemetry/index.ts +++ b/supabase/functions/telemetry/index.ts @@ -1,5 +1,5 @@ -import { createClient } from '@supabase/supabase-js'; -import { z } from 'zod'; +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': '*', From b6398ec974b0d1dd36ff24445c3dfa3a87cc789b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piti=C8=99=20Radu?= Date: Sun, 22 Feb 2026 18:34:04 +0100 Subject: [PATCH 17/17] docs: changelogs --- examples/svelte/demo-app/CHANGELOG.md | 13 +++++++++++++ examples/svelte/demo-app/package.json | 2 +- packages/cli/CHANGELOG.md | 10 ++++++++++ packages/cli/package.json | 2 +- packages/core/CHANGELOG.md | 13 +++++++++++++ packages/core/package.json | 2 +- packages/react-doctor/CHANGELOG.md | 8 ++++++++ packages/react-doctor/package.json | 2 +- packages/svelte-doctor/CHANGELOG.md | 8 ++++++++ packages/svelte-doctor/package.json | 2 +- 10 files changed, 57 insertions(+), 5 deletions(-) create mode 100644 examples/svelte/demo-app/CHANGELOG.md create mode 100644 packages/core/CHANGELOG.md 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/package.json b/examples/svelte/demo-app/package.json index bac9974..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", diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 42a8f08..ad913b3 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,15 @@ # @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 diff --git a/packages/cli/package.json b/packages/cli/package.json index 3a88e7f..5f36e2d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@framework-doctor/cli", - "version": "1.1.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 index a6dd3f9..3ab8af2 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@framework-doctor/core", - "version": "1.0.0", + "version": "1.0.2", "description": "Shared utilities for Framework Doctor (telemetry, config)", "author": { "name": "Pitis Radu", diff --git a/packages/react-doctor/CHANGELOG.md b/packages/react-doctor/CHANGELOG.md index fe5a153..93cda20 100644 --- a/packages/react-doctor/CHANGELOG.md +++ b/packages/react-doctor/CHANGELOG.md @@ -1,5 +1,13 @@ # 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 diff --git a/packages/react-doctor/package.json b/packages/react-doctor/package.json index 19aacd0..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.1", + "version": "1.0.2", "description": "Diagnose and fix performance issues in your React app", "author": { "name": "Pitis Radu", diff --git a/packages/svelte-doctor/CHANGELOG.md b/packages/svelte-doctor/CHANGELOG.md index 3bc4cbb..24fce96 100644 --- a/packages/svelte-doctor/CHANGELOG.md +++ b/packages/svelte-doctor/CHANGELOG.md @@ -1,5 +1,13 @@ # 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 diff --git a/packages/svelte-doctor/package.json b/packages/svelte-doctor/package.json index 20e4b2b..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.1", + "version": "1.0.2", "description": "Diagnose and improve Svelte codebase health", "author": { "name": "Pitis Radu",