From 15c8c42fe5c0699481ad1d414796f92f9d063655 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piti=C8=99=20Radu?= Date: Sun, 1 Mar 2026 02:45:27 +0200 Subject: [PATCH 1/5] feat: more feature parity between doctors --- .cursor/skills/framework-doctor/SKILL.md | 29 +++--- .../framework-doctor/references/cli/RULE.md | 2 +- .../references/cli/commands.md | 24 +++-- .github/workflows/framework-doctor.yml | 35 +++++++ README.md | 63 ++++++++++++- packages/angular-doctor/README.md | 7 ++ packages/angular-doctor/src/cli.ts | 64 +++++++++++-- packages/angular-doctor/src/constants.ts | 7 ++ packages/angular-doctor/src/scan.ts | 31 ++++-- packages/angular-doctor/src/types.ts | 2 + .../src/utils/check-reduced-motion.ts | 62 ++++++++++++ .../src/utils/combine-diagnostics.ts | 11 ++- .../angular-doctor/src/utils/load-config.ts | 10 +- packages/angular-doctor/vitest.config.ts | 7 ++ packages/cli/package.json | 3 +- packages/cli/src/cli.ts | 94 ++++++++++++++++++- packages/cli/src/constants.ts | 1 + packages/core/src/index.ts | 4 +- packages/core/src/load-config.ts | 40 ++++++++ packages/core/src/run-audit.ts | 57 +++++++++++ packages/core/src/types.ts | 11 +++ packages/react-doctor/README.md | 7 +- packages/react-doctor/src/cli.ts | 67 +++++++++++-- packages/react-doctor/src/scan.ts | 39 +++++--- packages/react-doctor/src/types.ts | 3 + .../src/utils/combine-diagnostics.ts | 3 + .../react-doctor/src/utils/load-config.ts | 10 +- packages/react-doctor/src/utils/run-oxlint.ts | 2 + packages/svelte-doctor/README.md | 8 ++ packages/svelte-doctor/src/cli.ts | 61 ++++++++++-- packages/svelte-doctor/src/scan.ts | 10 ++ packages/svelte-doctor/src/types.ts | 2 + .../svelte-doctor/src/utils/load-config.ts | 10 +- .../svelte-doctor/src/utils/run-oxlint.ts | 2 + packages/svelte-doctor/vitest.config.ts | 7 ++ packages/vue-doctor/README.md | 6 ++ packages/vue-doctor/src/cli.ts | 64 +++++++++++-- packages/vue-doctor/src/scan.ts | 36 ++++--- packages/vue-doctor/src/types.ts | 2 + .../src/utils/combine-diagnostics.ts | 3 + packages/vue-doctor/src/utils/load-config.ts | 10 +- packages/vue-doctor/vitest.config.ts | 7 ++ pnpm-lock.yaml | 3 + pnpm-workspace.yaml | 7 +- ...0225000000_enable_rls_telemetry_events.sql | 1 + 45 files changed, 832 insertions(+), 102 deletions(-) create mode 100644 .github/workflows/framework-doctor.yml create mode 100644 packages/angular-doctor/src/utils/check-reduced-motion.ts create mode 100644 packages/angular-doctor/vitest.config.ts create mode 100644 packages/cli/src/constants.ts create mode 100644 packages/core/src/run-audit.ts create mode 100644 packages/svelte-doctor/vitest.config.ts create mode 100644 packages/vue-doctor/vitest.config.ts create mode 100644 supabase/migrations/20260225000000_enable_rls_telemetry_events.sql diff --git a/.cursor/skills/framework-doctor/SKILL.md b/.cursor/skills/framework-doctor/SKILL.md index 1da7bda..49b2a90 100644 --- a/.cursor/skills/framework-doctor/SKILL.md +++ b/.cursor/skills/framework-doctor/SKILL.md @@ -14,7 +14,7 @@ metadata: Scans your frontend codebase for security, performance, correctness, and architecture issues. Auto-detects Svelte or React from `package.json`. Outputs a 0-100 score with actionable diagnostics. -**Supported:** Svelte, React, Vue (Angular coming soon) +**Supported:** Svelte, React, Vue, Angular ## IMPORTANT: Run After Making Changes @@ -30,7 +30,10 @@ Scan project? ├─ Svelte only → npx -y @framework-doctor/svelte . --verbose --diff ├─ React only → npx -y @framework-doctor/react . --verbose --diff ├─ Vue only → npx -y @framework-doctor/vue . --verbose --diff -├─ Flags (verbose, diff, score) → references/cli/commands.md +├─ CI / tooling → npx -y @framework-doctor/cli . --format json -y +├─ Watch mode → npx -y @framework-doctor/cli . --watch +├─ Auto-fix (Svelte, React) → npx -y @framework-doctor/svelte . --fix +├─ Flags (verbose, diff, score, format, fix, audit) → references/cli/commands.md └─ What gets checked → references/checks/RULE.md ``` @@ -113,14 +116,14 @@ setTimeout(refreshData, 5000); ## Reference Index -| Topic | Purpose | -| --------------------------------------------------------- | ------------------------------------------------------------- | -| [cli/RULE.md](./references/cli/RULE.md) | Usage overview, unified vs framework-specific CLI | -| [cli/commands.md](./references/cli/commands.md) | Flags: --verbose, --diff, --score | -| [checks/RULE.md](./references/checks/RULE.md) | What the doctor checks (security, svelte-check, knip, oxlint) | -| [security/RULE.md](./references/security/RULE.md) | Security patterns overview | -| [security/svelte.md](./references/security/svelte.md) | Svelte-specific security ({@html}, javascript: URLs) | -| [security/patterns.md](./references/security/patterns.md) | WRONG/CORRECT patterns (eval, URLs, sanitization) | -| [svelte/RULE.md](./references/svelte/RULE.md) | Svelte guidance overview | -| [svelte/migration.md](./references/svelte/migration.md) | Svelte 5 migration ($props, $effect, {@render}) | -| [react/RULE.md](./references/react/RULE.md) | React guidance overview | +| Topic | Purpose | +| --------------------------------------------------------- | ---------------------------------------------------------------------------- | +| [cli/RULE.md](./references/cli/RULE.md) | Usage overview, unified vs framework-specific CLI | +| [cli/commands.md](./references/cli/commands.md) | Flags: --verbose, --diff, --score, --format json, --watch, --fix, --no-audit | +| [checks/RULE.md](./references/checks/RULE.md) | What the doctor checks (security, svelte-check, knip, oxlint) | +| [security/RULE.md](./references/security/RULE.md) | Security patterns overview | +| [security/svelte.md](./references/security/svelte.md) | Svelte-specific security ({@html}, javascript: URLs) | +| [security/patterns.md](./references/security/patterns.md) | WRONG/CORRECT patterns (eval, URLs, sanitization) | +| [svelte/RULE.md](./references/svelte/RULE.md) | Svelte guidance overview | +| [svelte/migration.md](./references/svelte/migration.md) | Svelte 5 migration ($props, $effect, {@render}) | +| [react/RULE.md](./references/react/RULE.md) | React guidance overview | diff --git a/.cursor/skills/framework-doctor/references/cli/RULE.md b/.cursor/skills/framework-doctor/references/cli/RULE.md index d8ea751..f211653 100644 --- a/.cursor/skills/framework-doctor/references/cli/RULE.md +++ b/.cursor/skills/framework-doctor/references/cli/RULE.md @@ -32,4 +32,4 @@ npx -y @framework-doctor/react . ## Flags -See [commands.md](./commands.md) for --verbose, --diff, --score, and other options. +See [commands.md](./commands.md) for --verbose, --diff, --score, --format json, --watch, --fix, --no-audit, and other options. diff --git a/.cursor/skills/framework-doctor/references/cli/commands.md b/.cursor/skills/framework-doctor/references/cli/commands.md index 190ae94..380328f 100644 --- a/.cursor/skills/framework-doctor/references/cli/commands.md +++ b/.cursor/skills/framework-doctor/references/cli/commands.md @@ -2,21 +2,31 @@ ## Common Flags -| Flag | Description | Example | -| --------------- | ---------------------------------------- | --------------------------------------------------- | -| `--verbose` | Show file-level details per rule | `npx -y @framework-doctor/cli . --verbose` | -| `--diff` | Scan only changed files (vs base branch) | `npx -y @framework-doctor/cli . --diff` | -| `--diff ` | Scan only changed files vs specific base | `npx -y @framework-doctor/cli . --diff origin/main` | -| `--score` | Output only the score (no details) | `npx -y @framework-doctor/cli . --score` | +| Flag | Description | Example | +| --------------- | ----------------------------------------- | --------------------------------------------------- | +| `--verbose` | Show file-level details per rule | `npx -y @framework-doctor/cli . --verbose` | +| `--diff` | Scan only changed files (vs base branch) | `npx -y @framework-doctor/cli . --diff` | +| `--diff ` | Scan only changed files vs specific base | `npx -y @framework-doctor/cli . --diff origin/main` | +| `--score` | Output only the score (no details) | `npx -y @framework-doctor/cli . --score` | +| `--format json` | Machine-readable JSON output (CI/tooling) | `npx -y @framework-doctor/cli . --format json -y` | +| `--watch` | Re-scan on file changes | `npx -y @framework-doctor/cli . --watch` | +| `--fix` | Auto-fix lint issues (Svelte, React) | `npx -y @framework-doctor/svelte . --fix` | +| `--no-audit` | Skip dependency vulnerability audit | `npx -y @framework-doctor/cli . --no-audit` | ## Recommended Usage ```bash # Full scan with details npx -y @framework-doctor/cli . --verbose --diff + +# CI: machine-readable output +npx -y @framework-doctor/cli . --format json -y + +# Development: watch and re-scan on changes +npx -y @framework-doctor/cli . --watch ``` -Use `--diff` to speed up scans by only checking changed files. Use `--verbose` to see which files trigger each rule. +Use `--diff` to speed up scans by only checking changed files. Use `--verbose` to see which files trigger each rule. Use `--format json` for CI or scripted parsing. ## Exit Codes diff --git a/.github/workflows/framework-doctor.yml b/.github/workflows/framework-doctor.yml new file mode 100644 index 0000000..376b1ba --- /dev/null +++ b/.github/workflows/framework-doctor.yml @@ -0,0 +1,35 @@ +name: Framework Doctor + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + workflow_dispatch: + +jobs: + scan-examples: + runs-on: ubuntu-latest + strategy: + matrix: + example: [svelte/demo-app, react/demo-app, vue/demo-app, angular/demo-app] + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 10 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm build + + - name: Run Framework Doctor (${{ matrix.example }}) + run: pnpm exec framework-doctor examples/${{ matrix.example }} --format json -y diff --git a/README.md b/README.md index 622a786..18b3b6f 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ 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 +- `npx -y @framework-doctor/cli . --watch` - re-scan on file changes **React (direct):** @@ -48,12 +49,15 @@ See [examples/README.md](examples/README.md) for more demo projects and commands - `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) +- `npx -y @framework-doctor/react . --format json` - machine-readable output +- `npx -y @framework-doctor/react . --fix` - auto-fix lint issues **Vue (direct):** - `npx -y @framework-doctor/vue .` - run a full scan - `npx -y @framework-doctor/vue . --verbose` - include file and line details - `npx -y @framework-doctor/vue . --score` - print only the numeric score (CI-friendly) +- `npx -y @framework-doctor/vue . --format json` - machine-readable output - `npx -y @framework-doctor/vue . --diff main` - scan only files changed against `main`. - `npx -y @framework-doctor/vue . --project web` - select a specific workspace package. @@ -63,6 +67,8 @@ See [examples/README.md](examples/README.md) for more demo projects and commands - `npx -y @framework-doctor/svelte ./path/to/project` - scan a specific project directory - `npx -y @framework-doctor/svelte . --verbose` - include file and line details. - `npx -y @framework-doctor/svelte . --score` - print only the numeric score (CI-friendly). +- `npx -y @framework-doctor/svelte . --format json` - machine-readable output. +- `npx -y @framework-doctor/svelte . --fix` - auto-fix JS/TS lint issues. - `npx -y @framework-doctor/svelte . --no-js-ts-lint` - only run Svelte checks (skip JS/TS linting). - `npx -y @framework-doctor/svelte . --diff main` - scan only files changed against `main`. - `npx -y @framework-doctor/svelte . --project web` - select a specific workspace package. @@ -73,6 +79,7 @@ See [examples/README.md](examples/README.md) for more demo projects and commands - `npx -y @framework-doctor/angular ./path/to/project` - scan a specific project directory - `npx -y @framework-doctor/angular . --verbose` - include file and line details - `npx -y @framework-doctor/angular . --score` - print only the numeric score (CI-friendly) +- `npx -y @framework-doctor/angular . --format json` - machine-readable output - `npx -y @framework-doctor/angular . --diff main` - scan only files changed against `main` - `npx -y @framework-doctor/angular . --project my-app` - select a specific workspace project @@ -88,6 +95,9 @@ Options: --no-lint skip lint diagnostics --no-js-ts-lint skip JavaScript/TypeScript lint diagnostics --no-dead-code skip dead code detection + --no-audit skip dependency vulnerability audit + --fix auto-fix lint issues where possible + --format output format: text or json --verbose show file details per rule --score output only the score -y, --yes skip prompts @@ -98,9 +108,11 @@ Options: -h, --help display help for command ``` -React doctor options: `--no-lint`, `--no-dead-code`, `--verbose`, `--score`, `--no-analytics`, `--project`, `--diff`, `--offline`. See [packages/react-doctor/README.md](packages/react-doctor/README.md). +React doctor options: `--no-lint`, `--no-dead-code`, `--no-audit`, `--fix`, `--format json`, `--verbose`, `--score`, `--no-analytics`, `--project`, `--diff`, `--offline`. See [packages/react-doctor/README.md](packages/react-doctor/README.md). -Angular doctor options: `--no-lint`, `--no-dead-code`, `--verbose`, `--score`, `--no-analytics`, `--project`, `--diff`, `--offline`. See [packages/angular-doctor/README.md](packages/angular-doctor/README.md). +Vue doctor options: `--no-lint`, `--no-dead-code`, `--no-audit`, `--format json`, `--verbose`, `--score`, `--no-analytics`, `--project`, `--diff`, `--offline`. See [packages/vue-doctor/README.md](packages/vue-doctor/README.md). + +Angular doctor options: `--no-lint`, `--no-dead-code`, `--no-audit`, `--format json`, `--verbose`, `--score`, `--no-analytics`, `--project`, `--diff`, `--offline`. See [packages/angular-doctor/README.md](packages/angular-doctor/README.md). ## Security checks @@ -120,7 +132,27 @@ The doctors optionally send anonymous usage data when you opt in. Data is stored ## Configuration -Create `svelte-doctor.config.json`: +### Unified config (`framework-doctor.config.json`) + +Shared config for monorepos with multiple frameworks. Supports top-level shared options and framework sections: + +```json +{ + "ignore": { + "files": ["src/generated/**"] + }, + "verbose": false, + "analytics": true, + "svelteDoctor": { "jsTsLint": false }, + "reactDoctor": { "lint": true }, + "vueDoctor": {}, + "angularDoctor": {} +} +``` + +### Framework-specific config + +Create `svelte-doctor.config.json` (or `vue-doctor.config.json`, etc.): ```json { @@ -131,6 +163,7 @@ Create `svelte-doctor.config.json`: "lint": true, "jsTsLint": true, "deadCode": true, + "audit": true, "verbose": false, "diff": false, "analytics": true @@ -146,3 +179,27 @@ Or use `package.json`: } } ``` + +Framework-specific config overrides the unified config. + +## Machine-readable output + +Use `--format json` for CI or tooling integration: + +```bash +npx -y @framework-doctor/cli . --format json -y +``` + +Output includes: `doctor`, `version`, `diagnostics`, `scoreResult`, `totalFilesScanned`, `elapsedMilliseconds`, `skippedChecks`. + +## Watch mode + +Re-scan on file changes during development: + +```bash +npx -y @framework-doctor/cli . --watch +``` + +## Dependency audit + +By default, the doctor runs `pnpm audit` and reports high or critical vulnerabilities. Use `--no-audit` to skip. diff --git a/packages/angular-doctor/README.md b/packages/angular-doctor/README.md index af9eac1..a34d911 100644 --- a/packages/angular-doctor/README.md +++ b/packages/angular-doctor/README.md @@ -30,6 +30,8 @@ Options: -v, --version display the version number --no-lint skip linting --no-dead-code skip dead code detection + --no-audit skip dependency vulnerability audit + --format output format: text or json --verbose show file details per rule --score output only the score (CI-friendly) -y, --yes skip prompts, scan all workspace projects @@ -52,6 +54,7 @@ Create `angular-doctor.config.json`: }, "lint": true, "deadCode": true, + "audit": true, "verbose": false, "diff": false, "analytics": true @@ -69,6 +72,8 @@ Or use the `angularDoctor` key in `package.json`: } ``` +Angular Doctor also supports unified config via `framework-doctor.config.json` with an `angularDoctor` section. Framework-specific config overrides unified options. + ## Security checks Angular Doctor flags: @@ -79,6 +84,8 @@ Angular Doctor flags: - **`innerHTML` binding** — Raw HTML can lead to XSS if content is unsanitized - **`bypassSecurityTrust*`** — Bypassing Angular’s sanitizer can lead to XSS +Angular Doctor also runs a dependency audit (`pnpm audit`) and reports high/critical vulnerabilities. Use `--no-audit` to skip. + ## Analytics Angular 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. diff --git a/packages/angular-doctor/src/cli.ts b/packages/angular-doctor/src/cli.ts index f13cc99..103965b 100644 --- a/packages/angular-doctor/src/cli.ts +++ b/packages/angular-doctor/src/cli.ts @@ -1,11 +1,13 @@ import { addAnalyticsOption, + calculateScore, highlighter, isAutomatedEnvironment, logger, } from '@framework-doctor/core'; import { Command } from 'commander'; import path from 'node:path'; +import { performance } from 'node:perf_hooks'; import prompts from 'prompts'; import { scan } from './scan.js'; import type { AngularDoctorConfig, Diagnostic, DiffInfo, ScanOptions } from './types.js'; @@ -25,10 +27,12 @@ const VERSION = process.env.VERSION ?? '0.0.0'; interface CliFlags { lint: boolean; deadCode: boolean; + audit: boolean; verbose: boolean; score: boolean; yes: boolean; analytics: boolean; + format: string; project?: string; diff?: boolean | string; offline?: boolean; @@ -55,8 +59,10 @@ const resolveCliScanOptions = ( return { lint: isCliOverride('lint') ? flags.lint : (userConfig?.lint ?? flags.lint), deadCode: isCliOverride('deadCode') ? flags.deadCode : (userConfig?.deadCode ?? flags.deadCode), + audit: isCliOverride('audit') ? flags.audit : (userConfig?.audit ?? flags.audit), verbose: isCliOverride('verbose') ? Boolean(flags.verbose) : (userConfig?.verbose ?? false), scoreOnly: flags.score, + format: (flags.format === 'json' ? 'json' : 'text') as 'text' | 'json', }; }; @@ -112,8 +118,10 @@ const program = new Command() .argument('[directory]', 'project directory to scan', '.') .option('--no-lint', 'skip linting') .option('--no-dead-code', 'skip dead code detection') + .option('--no-audit', 'skip dependency vulnerability audit') .option('--verbose', 'show file details per rule') .option('--score', 'output only the score') + .option('--format ', 'output format: text or json', 'text') .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') @@ -124,12 +132,13 @@ addAnalyticsOption(program); program .action(async (directory: string, flags: CliFlags) => { const isScoreOnly = flags.score; + const isJsonFormat = flags.format === 'json'; try { const resolvedDirectory = path.resolve(directory); const userConfig = loadConfig(resolvedDirectory); - if (!isScoreOnly) { + if (!isScoreOnly && !isJsonFormat) { logger.log(`angular-doctor v${VERSION}`); logger.break(); } @@ -153,7 +162,7 @@ program isScoreOnly, ); - if (isDiffMode && diffInfo && !isScoreOnly) { + if (isDiffMode && diffInfo && !isScoreOnly && !isJsonFormat) { if (diffInfo.isCurrentChanges) { logger.log('Scanning uncommitted changes'); } else { @@ -165,10 +174,13 @@ program } const allDiagnostics: Diagnostic[] = []; + let totalElapsedMs = 0; + let totalFilesScanned = 0; + const allSkippedChecks = new Set(); const telemetryUrl = process.env.FRAMEWORK_DOCTOR_TELEMETRY_URL ?? ''; const isAutomated = isAutomatedEnvironment(); - if (!isScoreOnly && !isAutomated && !flags.yes) { + if (!isScoreOnly && !isAutomated && !flags.yes && !isJsonFormat) { await maybePromptAnalyticsConsent(shouldSkipPrompts); } @@ -179,7 +191,7 @@ program if (projectDiffInfo) { const changedSourceFiles = filterSourceFiles(projectDiffInfo.changedFiles); if (changedSourceFiles.length === 0) { - if (!isScoreOnly) { + if (!isScoreOnly && !isJsonFormat) { logger.dim(`No changed source files in ${projectDirectory}, skipping.`); logger.break(); } @@ -189,12 +201,27 @@ program } } - if (!isScoreOnly) { + if (!isScoreOnly && !isJsonFormat) { logger.dim(`Scanning ${projectDirectory}...`); logger.break(); } - const scanResult = await scan(projectDirectory, { ...scanOptions, includePaths }); + const scanStart = performance.now(); + const scanResult = await scan(projectDirectory, { + ...scanOptions, + includePaths, + format: isJsonFormat ? 'json' : 'text', + }); + const scanElapsed = performance.now() - scanStart; + allDiagnostics.push(...scanResult.diagnostics); + totalElapsedMs += scanElapsed; + totalFilesScanned += + (includePaths?.length ?? 0) > 0 + ? (includePaths?.length ?? 0) + : scanResult.projectInfo.sourceFileCount; + for (const skipped of scanResult.skippedChecks) { + allSkippedChecks.add(skipped); + } if ( telemetryUrl && @@ -217,11 +244,34 @@ program ); } - if (!isScoreOnly) { + if (!isScoreOnly && !isJsonFormat) { logger.break(); } } + if (isJsonFormat) { + const hasHighOrCriticalSecurityFindings = allDiagnostics.some( + (d) => d.category === 'security' && d.severity === 'error', + ); + const scoreResult = + totalFilesScanned > 0 + ? calculateScore(allDiagnostics, totalFilesScanned, { + hasHighOrCriticalSecurityFindings, + }) + : { score: 100, label: 'Great', breakdown: undefined }; + const output = { + doctor: 'angular-doctor', + version: VERSION, + diagnostics: allDiagnostics, + scoreResult, + totalFilesScanned, + elapsedMilliseconds: totalElapsedMs, + skippedChecks: [...allSkippedChecks], + }; + logger.log(JSON.stringify(output, null, 2)); + return; + } + if (!isScoreOnly && !shouldSkipPrompts) { await maybePromptSkillInstall(shouldSkipPrompts); } diff --git a/packages/angular-doctor/src/constants.ts b/packages/angular-doctor/src/constants.ts index fb4d2fd..a8f9ffa 100644 --- a/packages/angular-doctor/src/constants.ts +++ b/packages/angular-doctor/src/constants.ts @@ -13,3 +13,10 @@ export { export const SOURCE_FILE_PATTERN = /\.(html|ts|mts|cts|mjs|cjs)$/; export const OFFLINE_FLAG_MESSAGE = 'Score not available.'; + +export const ANGULAR_MOTION_LIBRARIES = new Set([ + '@angular/animations', + 'framer-motion', + 'motion', + 'ng-animate', +]); diff --git a/packages/angular-doctor/src/scan.ts b/packages/angular-doctor/src/scan.ts index 95e6ff5..0601899 100644 --- a/packages/angular-doctor/src/scan.ts +++ b/packages/angular-doctor/src/scan.ts @@ -231,8 +231,10 @@ const printSummary = ( interface ResolvedScanOptions { lint: boolean; deadCode: boolean; + audit: boolean; verbose: boolean; scoreOnly: boolean; + format: 'text' | 'json'; includePaths: string[]; } @@ -242,8 +244,10 @@ const mergeScanOptions = ( ): ResolvedScanOptions => ({ lint: inputOptions.lint ?? userConfig?.lint ?? true, deadCode: inputOptions.deadCode ?? userConfig?.deadCode ?? true, + audit: inputOptions.audit ?? userConfig?.audit ?? true, verbose: inputOptions.verbose ?? userConfig?.verbose ?? false, scoreOnly: inputOptions.scoreOnly ?? false, + format: inputOptions.format ?? 'text', includePaths: inputOptions.includePaths ?? [], }); @@ -262,7 +266,7 @@ export const scan = async ( throw new Error('No Angular dependency found in package.json'); } - if (!options.scoreOnly) { + if (!options.scoreOnly && options.format !== 'json') { printProjectDetection(projectInfo, userConfig, isDiffMode, includePaths); } @@ -273,7 +277,10 @@ export const scan = async ( const lintPromise = options.lint ? (async () => { - const lintSpinner = options.scoreOnly ? null : spinner('Running lint checks...').start(); + const lintSpinner = + options.scoreOnly || options.format === 'json' + ? null + : spinner('Running lint checks...').start(); try { const lintDiagnostics = await runEslint(directory, angularIncludePaths); lintSpinner?.succeed('Running lint checks.'); @@ -290,9 +297,10 @@ export const scan = async ( const deadCodePromise = options.deadCode && !isDiffMode ? (async () => { - const deadCodeSpinner = options.scoreOnly - ? null - : spinner('Detecting dead code...').start(); + const deadCodeSpinner = + options.scoreOnly || options.format === 'json' + ? null + : spinner('Detecting dead code...').start(); try { const knipDiagnostics = await runKnip(directory); deadCodeSpinner?.succeed('Detecting dead code.'); @@ -323,6 +331,7 @@ export const scan = async ( directory, isDiffMode, userConfig, + options.audit, ); const elapsedMilliseconds = performance.now() - startTime; @@ -338,11 +347,13 @@ export const scan = async ( }); const noScoreMessage = OFFLINE_FLAG_MESSAGE; - if (options.scoreOnly) { - if (scoreResult) { - logger.log(`${scoreResult.score}`); - } else { - logger.dim(noScoreMessage); + if (options.scoreOnly || options.format === 'json') { + if (options.scoreOnly && options.format !== 'json') { + if (scoreResult) { + logger.log(`${scoreResult.score}`); + } else { + logger.dim(noScoreMessage); + } } return { diagnostics, scoreResult, skippedChecks, projectInfo }; } diff --git a/packages/angular-doctor/src/types.ts b/packages/angular-doctor/src/types.ts index b033ca9..8485119 100644 --- a/packages/angular-doctor/src/types.ts +++ b/packages/angular-doctor/src/types.ts @@ -19,8 +19,10 @@ export type { export interface ScanOptions { lint?: boolean; deadCode?: boolean; + audit?: boolean; verbose?: boolean; scoreOnly?: boolean; + format?: 'text' | 'json'; includePaths?: string[]; } diff --git a/packages/angular-doctor/src/utils/check-reduced-motion.ts b/packages/angular-doctor/src/utils/check-reduced-motion.ts new file mode 100644 index 0000000..e6a6632 --- /dev/null +++ b/packages/angular-doctor/src/utils/check-reduced-motion.ts @@ -0,0 +1,62 @@ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { ANGULAR_MOTION_LIBRARIES } from '../constants.js'; +import type { Diagnostic } from '../types.js'; +import { readPackageJson } from './read-package-json.js'; + +const REDUCED_MOTION_GREP_PATTERN = 'prefers-reduced-motion|useReducedMotion'; +const REDUCED_MOTION_FILE_GLOBS = [ + '*.html', + '*.ts', + '*.tsx', + '*.js', + '*.jsx', + '*.css', + '*.scss', +] as const; + +const MISSING_REDUCED_MOTION_DIAGNOSTIC: Diagnostic = { + filePath: 'package.json', + plugin: 'angular-doctor', + rule: 'require-reduced-motion', + severity: 'error', + message: + 'Project uses a motion library but has no prefers-reduced-motion handling — required for accessibility (WCAG 2.3.3)', + help: 'Add `useReducedMotion()` from your animation library, or a `@media (prefers-reduced-motion: reduce)` CSS query', + line: 0, + column: 0, + category: 'Accessibility', +}; + +export const checkReducedMotion = (rootDirectory: string): Diagnostic[] => { + const packageJsonPath = path.join(rootDirectory, 'package.json'); + if (!fs.existsSync(packageJsonPath)) return []; + + let hasMotionLibrary = false; + try { + const packageJson = readPackageJson(packageJsonPath); + const allDependencies = { + ...packageJson.dependencies, + ...packageJson.devDependencies, + }; + hasMotionLibrary = Object.keys(allDependencies).some((packageName) => + ANGULAR_MOTION_LIBRARIES.has(packageName), + ); + } catch { + return []; + } + if (!hasMotionLibrary) return []; + + try { + const result = spawnSync( + 'git', + ['grep', '-ql', '-E', REDUCED_MOTION_GREP_PATTERN, '--', ...REDUCED_MOTION_FILE_GLOBS], + { cwd: rootDirectory, encoding: 'utf-8', stdio: 'pipe' }, + ); + if (result.status === 0 && !result.error) return []; + return [MISSING_REDUCED_MOTION_DIAGNOSTIC]; + } catch { + return [MISSING_REDUCED_MOTION_DIAGNOSTIC]; + } +}; diff --git a/packages/angular-doctor/src/utils/combine-diagnostics.ts b/packages/angular-doctor/src/utils/combine-diagnostics.ts index 01f2ffb..8490110 100644 --- a/packages/angular-doctor/src/utils/combine-diagnostics.ts +++ b/packages/angular-doctor/src/utils/combine-diagnostics.ts @@ -1,5 +1,7 @@ +import { runAudit } from '@framework-doctor/core'; import { SOURCE_FILE_PATTERN } from '../constants.js'; import type { AngularDoctorConfig, Diagnostic } from '../types.js'; +import { checkReducedMotion } from './check-reduced-motion.js'; import { filterIgnoredDiagnostics } from './filter-diagnostics.js'; export const computeAngularIncludePaths = (includePaths: string[]): string[] | undefined => @@ -14,7 +16,14 @@ export const combineDiagnostics = ( directory: string, isDiffMode: boolean, userConfig: AngularDoctorConfig | null, + audit: boolean = true, ): Diagnostic[] => { - const allDiagnostics = [...lintDiagnostics, ...deadCodeDiagnostics, ...securityDiagnostics]; + const allDiagnostics = [ + ...lintDiagnostics, + ...deadCodeDiagnostics, + ...securityDiagnostics, + ...(isDiffMode ? [] : checkReducedMotion(directory)), + ...(audit && !isDiffMode ? runAudit(directory).diagnostics : []), + ]; return userConfig ? filterIgnoredDiagnostics(allDiagnostics, userConfig) : allDiagnostics; }; diff --git a/packages/angular-doctor/src/utils/load-config.ts b/packages/angular-doctor/src/utils/load-config.ts index c504c12..1d26bc1 100644 --- a/packages/angular-doctor/src/utils/load-config.ts +++ b/packages/angular-doctor/src/utils/load-config.ts @@ -1,8 +1,14 @@ -import { loadConfig as loadConfigFromCore } from '@framework-doctor/core'; +import { loadConfigWithUnified } from '@framework-doctor/core'; import type { AngularDoctorConfig } from '../types.js'; const CONFIG_FILENAME = 'angular-doctor.config.json'; const PACKAGE_JSON_CONFIG_KEY = 'angularDoctor'; +const UNIFIED_FRAMEWORK_KEY = 'angularDoctor'; export const loadConfig = (rootDirectory: string): AngularDoctorConfig | null => - loadConfigFromCore(rootDirectory, CONFIG_FILENAME, PACKAGE_JSON_CONFIG_KEY); + loadConfigWithUnified( + rootDirectory, + CONFIG_FILENAME, + PACKAGE_JSON_CONFIG_KEY, + UNIFIED_FRAMEWORK_KEY, + ); diff --git a/packages/angular-doctor/vitest.config.ts b/packages/angular-doctor/vitest.config.ts new file mode 100644 index 0000000..092b1b3 --- /dev/null +++ b/packages/angular-doctor/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + testTimeout: 30_000, + }, +}); diff --git a/packages/cli/package.json b/packages/cli/package.json index e15be18..f443d5e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -48,7 +48,8 @@ "@framework-doctor/angular": "workspace:*", "@framework-doctor/react": "workspace:*", "@framework-doctor/svelte": "workspace:*", - "@framework-doctor/vue": "workspace:*" + "@framework-doctor/vue": "workspace:*", + "chokidar": "^4.0.0" }, "devDependencies": { "@types/node": "catalog:", diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index f6f9202..594a62a 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,6 +1,8 @@ +import chokidar from 'chokidar'; import { spawnSync } from 'node:child_process'; import { createRequire } from 'node:module'; import path from 'node:path'; +import { WATCH_DEBOUNCE_MS } from './constants.js'; type Framework = 'svelte' | 'react' | 'vue' | 'angular' | null; @@ -80,7 +82,7 @@ const runDoctor = (framework: Framework, args: string[]): number => { console.error(` Could not detect a supported framework in ${dirArg}. - Supported: Svelte, React, Vue, Angular (coming soon) + Supported: Svelte, React, Vue, Angular Make sure you're in a project root with a package.json that includes: - Svelte: "svelte" or "@sveltejs/kit" @@ -96,15 +98,97 @@ const runDoctor = (framework: Framework, args: string[]): number => { return 1; }; +const filterWatchArgs = (args: string[]): { args: string[]; watch: boolean } => { + const watchIndex = args.findIndex((a) => a === '--watch' || a === '-w'); + const watch = watchIndex >= 0; + const argsWithoutWatch = + watchIndex >= 0 ? [...args.slice(0, watchIndex), ...args.slice(watchIndex + 1)] : args; + return { args: argsWithoutWatch, watch }; +}; + const main = (): number => { const rawArgs = process.argv.slice(2); - const dirIndex = rawArgs.findIndex((a) => !a.startsWith('-')); - const dirArg = dirIndex >= 0 ? path.resolve(process.cwd(), rawArgs[dirIndex]) : process.cwd(); + const { args: processedArgs, watch } = filterWatchArgs(rawArgs); + const dirIndex = processedArgs.findIndex((a) => !a.startsWith('-')); + const dirArg = + dirIndex >= 0 ? path.resolve(process.cwd(), processedArgs[dirIndex]) : process.cwd(); const restArgs = - dirIndex >= 0 ? [...rawArgs.slice(0, dirIndex), ...rawArgs.slice(dirIndex + 1)] : rawArgs; + dirIndex >= 0 + ? [...processedArgs.slice(0, dirIndex), ...processedArgs.slice(dirIndex + 1)] + : processedArgs; + const doctorArgs = restArgs.length > 0 ? [dirArg, ...restArgs] : [dirArg]; const framework = detectFramework(dirArg); - return runDoctor(framework, restArgs.length > 0 ? [dirArg, ...restArgs] : [dirArg]); + if (!framework) { + console.error(` + Could not detect a supported framework in ${dirArg}. + + Supported: Svelte, React, Vue, Angular + + Make sure you're in a project root with a package.json that includes: + - Svelte: "svelte" or "@sveltejs/kit" + - React: "react", "next", or "remix" + - Vue: "vue" or "nuxt" + - Angular: "@angular/core" + + Or run a specific doctor directly: + - npx @framework-doctor/svelte . + - npx @framework-doctor/react . + - npx @framework-doctor/vue . +`); + return 1; + } + + if (!watch) { + return runDoctor(framework, doctorArgs); + } + + let debounceTimer: ReturnType | null = null; + const runScan = () => { + runDoctor(framework, doctorArgs); + }; + + runScan(); + console.log('\nWatching for changes... (Ctrl+C to stop)\n'); + + const watcher = chokidar.watch(dirArg, { + ignored: [ + /(^|[/\\])\../, + /node_modules/, + /dist/, + /\.git/, + /\.turbo/, + /coverage/, + /\.next/, + /\.svelte-kit/, + ], + }); + + watcher.on('change', () => { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + debounceTimer = null; + runScan(); + }, WATCH_DEBOUNCE_MS); + }); + + watcher.on('add', () => { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + debounceTimer = null; + runScan(); + }, WATCH_DEBOUNCE_MS); + }); + + watcher.on('unlink', () => { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + debounceTimer = null; + runScan(); + }, WATCH_DEBOUNCE_MS); + }); + + return 0; }; process.exit(main()); diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts new file mode 100644 index 0000000..8482dfc --- /dev/null +++ b/packages/cli/src/constants.ts @@ -0,0 +1 @@ +export const WATCH_DEBOUNCE_MS = 500; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b558b66..f0fbf4b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -35,7 +35,8 @@ export { writeGlobalConfig, type FrameworkDoctorConfig, } from './global-config.js'; -export { loadConfig } from './load-config.js'; +export { loadConfig, loadConfigWithUnified, loadUnifiedConfig } from './load-config.js'; +export { runAudit, type AuditResult } from './run-audit.js'; export { DANGEROUSLY_SET_INNER_HTML_RULE, NO_AT_HTML_RULE, @@ -61,6 +62,7 @@ export type { BaseDoctorConfig, Diagnostic, DiffInfo, + FrameworkDoctorJsonOutput, IgnoreConfig, ScoreBreakdown, ScoreGuardrailInput, diff --git a/packages/core/src/load-config.ts b/packages/core/src/load-config.ts index 6ec225b..186915b 100644 --- a/packages/core/src/load-config.ts +++ b/packages/core/src/load-config.ts @@ -35,3 +35,43 @@ export const loadConfig = ( return null; } }; + +const UNIFIED_CONFIG_FILENAME = 'framework-doctor.config.json'; +const UNIFIED_PACKAGE_JSON_KEY = 'frameworkDoctor'; + +const SHARED_OPTIONS = ['ignore', 'verbose', 'diff', 'analytics'] as const; + +export const loadUnifiedConfig = (rootDirectory: string): Record | null => { + const unified = loadConfig>( + rootDirectory, + UNIFIED_CONFIG_FILENAME, + UNIFIED_PACKAGE_JSON_KEY, + ); + return unified; +}; + +export const loadConfigWithUnified = ( + rootDirectory: string, + configFilename: string, + packageJsonKey: string, + unifiedFrameworkKey: string, +): T | null => { + const frameworkConfig = loadConfig(rootDirectory, configFilename, packageJsonKey); + const unified = loadUnifiedConfig(rootDirectory); + if (!frameworkConfig && !unified) return null; + + const merged: Record = {}; + if (unified) { + for (const key of SHARED_OPTIONS) { + if (key in unified) merged[key] = unified[key]; + } + const frameworkSection = unified[unifiedFrameworkKey]; + if (isPlainObject(frameworkSection)) { + Object.assign(merged, frameworkSection); + } + } + if (frameworkConfig && typeof frameworkConfig === 'object') { + Object.assign(merged, frameworkConfig as Record); + } + return Object.keys(merged).length > 0 ? (merged as T) : null; +}; diff --git a/packages/core/src/run-audit.ts b/packages/core/src/run-audit.ts new file mode 100644 index 0000000..bfaf139 --- /dev/null +++ b/packages/core/src/run-audit.ts @@ -0,0 +1,57 @@ +import { spawnSync } from 'node:child_process'; +import type { Diagnostic } from './types.js'; + +export interface AuditResult { + diagnostics: Diagnostic[]; + hasHighOrCritical: boolean; +} + +export const runAudit = (rootDirectory: string): AuditResult => { + try { + const result = spawnSync('pnpm', ['audit', '--json'], { + cwd: rootDirectory, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + + if (result.error || result.status === null) { + return { diagnostics: [], hasHighOrCritical: false }; + } + + let parsed: { vulnerabilities?: Record }; + try { + parsed = JSON.parse(result.stdout ?? '{}') as { + vulnerabilities?: Record; + }; + } catch { + return { diagnostics: [], hasHighOrCritical: false }; + } + + const vulns = parsed.vulnerabilities ?? {}; + const highOrCritical = Object.values(vulns).filter( + (v) => v.severity === 'high' || v.severity === 'critical', + ); + + if (highOrCritical.length === 0) { + return { diagnostics: [], hasHighOrCritical: false }; + } + + const diagnostics: Diagnostic[] = [ + { + filePath: 'package.json', + plugin: 'framework-doctor', + rule: 'dependency-audit', + severity: 'warning', + message: `Found ${highOrCritical.length} high or critical vulnerability(ies). Run: pnpm audit`, + help: 'Run `pnpm audit` to see details and `pnpm audit --fix` to fix automatically where possible.', + line: 0, + column: 0, + category: 'security', + }, + ]; + + return { diagnostics, hasHighOrCritical: true }; + } catch { + return { diagnostics: [], hasHighOrCritical: false }; + } +}; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 0c1c040..5748e30 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -54,7 +54,18 @@ export interface BaseDoctorConfig { ignore?: IgnoreConfig; lint?: boolean; deadCode?: boolean; + audit?: boolean; verbose?: boolean; diff?: boolean | string; analytics?: boolean; } + +export interface FrameworkDoctorJsonOutput { + doctor: string; + version: string; + diagnostics: Diagnostic[]; + scoreResult: ScoreResult | null; + totalFilesScanned: number; + elapsedMilliseconds: number; + skippedChecks: string[]; +} diff --git a/packages/react-doctor/README.md b/packages/react-doctor/README.md index 78866f0..248c4af 100644 --- a/packages/react-doctor/README.md +++ b/packages/react-doctor/README.md @@ -13,6 +13,7 @@ React Doctor detects your framework (Next.js, Vite, Remix, etc.), React version, 1. **Lint**: Checks 60+ rules across state & effects, performance, architecture, bundle size, security, correctness, accessibility, and framework-specific categories (Next.js, React Native). Rules are toggled automatically based on your project setup. 2. **Dead code**: Detects unused files, exports, types, and duplicates. +3. **Dependency audit**: Runs `pnpm audit` and reports high/critical vulnerabilities (use `--no-audit` to skip). Diagnostics are filtered through your config, then scored by severity (errors weigh more than warnings) to produce a **0–100 health score** (75+ Great, 50–74 Needs work, <50 Critical). @@ -48,6 +49,9 @@ Options: -v, --version display the version number --no-lint skip linting --no-dead-code skip dead code detection + --no-audit skip dependency vulnerability audit + --fix auto-fix lint issues where possible + --format output format: text or json --verbose show file details per rule --score output only the score -y, --yes skip prompts, scan all workspace projects @@ -82,7 +86,7 @@ You can also use the `"reactDoctor"` key in your `package.json` instead: } ``` -If both exist, `react-doctor.config.json` takes precedence. +If both exist, `react-doctor.config.json` takes precedence. React Doctor also supports unified config via `framework-doctor.config.json` with a `reactDoctor` section. Framework-specific config overrides unified options. ### Config options @@ -92,6 +96,7 @@ If both exist, `react-doctor.config.json` takes precedence. | `ignore.files` | `string[]` | `[]` | File paths to exclude, supports glob patterns (`src/generated/**`, `**/*.test.tsx`) | | `lint` | `boolean` | `true` | Enable/disable lint checks (same as `--no-lint`) | | `deadCode` | `boolean` | `true` | Enable/disable dead code detection (same as `--no-dead-code`) | +| `audit` | `boolean` | `true` | Enable/disable dependency vulnerability audit (same as `--no-audit`) | | `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`) | diff --git a/packages/react-doctor/src/cli.ts b/packages/react-doctor/src/cli.ts index 67411dd..803de68 100644 --- a/packages/react-doctor/src/cli.ts +++ b/packages/react-doctor/src/cli.ts @@ -1,11 +1,13 @@ import { addAnalyticsOption, + calculateScore, highlighter, isAutomatedEnvironment, logger, } from '@framework-doctor/core'; import { Command } from 'commander'; import path from 'node:path'; +import { performance } from 'node:perf_hooks'; import { scan } from './scan.js'; import type { Diagnostic, DiffInfo, ReactDoctorConfig, ScanOptions } from './types.js'; import { filterSourceFiles, getDiffInfo } from './utils/get-diff-files.js'; @@ -25,10 +27,13 @@ const VERSION = process.env.VERSION ?? '0.0.0'; interface CliFlags { lint: boolean; deadCode: boolean; + audit: boolean; verbose: boolean; score: boolean; yes: boolean; analytics: boolean; + format: string; + fix: boolean; project?: string; diff?: boolean | string; offline?: boolean; @@ -55,8 +60,11 @@ const resolveCliScanOptions = ( return { lint: isCliOverride('lint') ? flags.lint : (userConfig?.lint ?? flags.lint), deadCode: isCliOverride('deadCode') ? flags.deadCode : (userConfig?.deadCode ?? flags.deadCode), + audit: isCliOverride('audit') ? flags.audit : (userConfig?.audit ?? flags.audit), verbose: isCliOverride('verbose') ? Boolean(flags.verbose) : (userConfig?.verbose ?? false), scoreOnly: flags.score, + format: (flags.format === 'json' ? 'json' : 'text') as 'text' | 'json', + fix: flags.fix, }; }; @@ -102,8 +110,11 @@ const program = new Command() .argument('[directory]', 'project directory to scan', '.') .option('--no-lint', 'skip linting') .option('--no-dead-code', 'skip dead code detection') + .option('--no-audit', 'skip dependency vulnerability audit') + .option('--fix', 'auto-fix lint issues where possible') .option('--verbose', 'show file details per rule') .option('--score', 'output only the score') + .option('--format ', 'output format: text or json', 'text') .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') @@ -114,12 +125,13 @@ addAnalyticsOption(program); program .action(async (directory: string, flags: CliFlags) => { const isScoreOnly = flags.score; + const isJsonFormat = flags.format === 'json'; try { const resolvedDirectory = path.resolve(directory); const userConfig = loadConfig(resolvedDirectory); - if (!isScoreOnly) { + if (!isScoreOnly && !isJsonFormat) { logger.log(`react-doctor v${VERSION}`); logger.break(); } @@ -143,7 +155,7 @@ program isScoreOnly, ); - if (isDiffMode && diffInfo && !isScoreOnly) { + if (isDiffMode && diffInfo && !isScoreOnly && !isJsonFormat) { if (diffInfo.isCurrentChanges) { logger.log('Scanning uncommitted changes'); } else { @@ -155,10 +167,13 @@ program } const allDiagnostics: Diagnostic[] = []; + let totalElapsedMs = 0; + let totalFilesScanned = 0; + const allSkippedChecks = new Set(); const telemetryUrl = process.env.FRAMEWORK_DOCTOR_TELEMETRY_URL ?? ''; const isAutomated = isAutomatedEnvironment(); - if (!isScoreOnly && !isAutomated && !flags.yes) { + if (!isScoreOnly && !isAutomated && !flags.yes && !isJsonFormat) { await maybePromptAnalyticsConsent(shouldSkipPrompts); } @@ -169,7 +184,7 @@ program if (projectDiffInfo) { const changedSourceFiles = filterSourceFiles(projectDiffInfo.changedFiles); if (changedSourceFiles.length === 0) { - if (!isScoreOnly) { + if (!isScoreOnly && !isJsonFormat) { logger.dim(`No changed source files in ${projectDirectory}, skipping.`); logger.break(); } @@ -179,12 +194,27 @@ program } } - if (!isScoreOnly) { + if (!isScoreOnly && !isJsonFormat) { logger.dim(`Scanning ${projectDirectory}...`); logger.break(); } - const scanResult = await scan(projectDirectory, { ...scanOptions, includePaths }); + const scanStart = performance.now(); + const scanResult = await scan(projectDirectory, { + ...scanOptions, + includePaths, + format: isJsonFormat ? 'json' : 'text', + }); + const scanElapsed = performance.now() - scanStart; + allDiagnostics.push(...scanResult.diagnostics); + totalElapsedMs += scanElapsed; + totalFilesScanned += + (includePaths?.length ?? 0) > 0 + ? (includePaths?.length ?? 0) + : scanResult.projectInfo.sourceFileCount; + for (const skipped of scanResult.skippedChecks) { + allSkippedChecks.add(skipped); + } if ( telemetryUrl && @@ -207,11 +237,34 @@ program ); } - if (!isScoreOnly) { + if (!isScoreOnly && !isJsonFormat) { logger.break(); } } + if (isJsonFormat) { + const hasHighOrCriticalSecurityFindings = allDiagnostics.some( + (d) => d.category === 'security' && d.severity === 'error', + ); + const scoreResult = + totalFilesScanned > 0 + ? calculateScore(allDiagnostics, totalFilesScanned, { + hasHighOrCriticalSecurityFindings, + }) + : { score: 100, label: 'Great', breakdown: undefined }; + const output = { + doctor: 'react-doctor', + version: VERSION, + diagnostics: allDiagnostics, + scoreResult, + totalFilesScanned, + elapsedMilliseconds: totalElapsedMs, + skippedChecks: [...allSkippedChecks], + }; + logger.log(JSON.stringify(output, null, 2)); + return; + } + if (!isScoreOnly && !shouldSkipPrompts) { await maybePromptSkillInstall(shouldSkipPrompts); } diff --git a/packages/react-doctor/src/scan.ts b/packages/react-doctor/src/scan.ts index 095d2d9..d98161c 100644 --- a/packages/react-doctor/src/scan.ts +++ b/packages/react-doctor/src/scan.ts @@ -320,8 +320,11 @@ const resolveOxlintNode = async ( interface ResolvedScanOptions { lint: boolean; deadCode: boolean; + audit: boolean; verbose: boolean; scoreOnly: boolean; + format: 'text' | 'json'; + fix: boolean; includePaths: string[]; } @@ -331,8 +334,11 @@ const mergeScanOptions = ( ): ResolvedScanOptions => ({ lint: inputOptions.lint ?? userConfig?.lint ?? true, deadCode: inputOptions.deadCode ?? userConfig?.deadCode ?? true, + audit: inputOptions.audit ?? userConfig?.audit ?? true, verbose: inputOptions.verbose ?? userConfig?.verbose ?? false, scoreOnly: inputOptions.scoreOnly ?? false, + format: inputOptions.format ?? 'text', + fix: inputOptions.fix ?? false, includePaths: inputOptions.includePaths ?? [], }); @@ -386,7 +392,7 @@ export const scan = async ( throw new Error('No React dependency found in package.json'); } - if (!options.scoreOnly) { + if (!options.scoreOnly && options.format !== 'json') { printProjectDetection(projectInfo, userConfig, isDiffMode, includePaths); } @@ -395,12 +401,18 @@ export const scan = async ( let didLintFail = false; let didDeadCodeFail = false; - const resolvedNodeBinaryPath = await resolveOxlintNode(options.lint, options.scoreOnly); + const resolvedNodeBinaryPath = await resolveOxlintNode( + options.lint, + options.scoreOnly || options.format === 'json', + ); if (options.lint && !resolvedNodeBinaryPath) didLintFail = true; const lintPromise = resolvedNodeBinaryPath ? (async () => { - const lintSpinner = options.scoreOnly ? null : spinner('Running lint checks...').start(); + const lintSpinner = + options.scoreOnly || options.format === 'json' + ? null + : spinner('Running lint checks...').start(); try { const lintDiagnostics = await runOxlint( directory, @@ -409,6 +421,7 @@ export const scan = async ( projectInfo.hasReactCompiler, jsxIncludePaths, resolvedNodeBinaryPath, + options.fix, ); lintSpinner?.succeed('Running lint checks.'); return lintDiagnostics; @@ -436,9 +449,10 @@ export const scan = async ( const deadCodePromise = options.deadCode && !isDiffMode ? (async () => { - const deadCodeSpinner = options.scoreOnly - ? null - : spinner('Detecting dead code...').start(); + const deadCodeSpinner = + options.scoreOnly || options.format === 'json' + ? null + : spinner('Detecting dead code...').start(); try { const knipDiagnostics = await runKnip(directory); deadCodeSpinner?.succeed('Detecting dead code.'); @@ -468,6 +482,7 @@ export const scan = async ( directory, isDiffMode, userConfig, + options.audit, ); const elapsedMilliseconds = performance.now() - startTime; @@ -483,11 +498,13 @@ export const scan = async ( }); const noScoreMessage = OFFLINE_FLAG_MESSAGE; - if (options.scoreOnly) { - if (scoreResult) { - logger.log(`${scoreResult.score}`); - } else { - logger.dim(noScoreMessage); + if (options.scoreOnly || options.format === 'json') { + if (options.scoreOnly && options.format !== 'json') { + if (scoreResult) { + logger.log(`${scoreResult.score}`); + } else { + logger.dim(noScoreMessage); + } } return { diagnostics, scoreResult, skippedChecks, projectInfo }; } diff --git a/packages/react-doctor/src/types.ts b/packages/react-doctor/src/types.ts index 6c4d2be..596fa2f 100644 --- a/packages/react-doctor/src/types.ts +++ b/packages/react-doctor/src/types.ts @@ -84,8 +84,11 @@ export interface ScanResult { export interface ScanOptions { lint?: boolean; deadCode?: boolean; + audit?: boolean; verbose?: boolean; scoreOnly?: boolean; + format?: 'text' | 'json'; + fix?: boolean; includePaths?: string[]; } diff --git a/packages/react-doctor/src/utils/combine-diagnostics.ts b/packages/react-doctor/src/utils/combine-diagnostics.ts index e617020..d46d572 100644 --- a/packages/react-doctor/src/utils/combine-diagnostics.ts +++ b/packages/react-doctor/src/utils/combine-diagnostics.ts @@ -1,3 +1,4 @@ +import { runAudit } from '@framework-doctor/core'; import { JSX_FILE_PATTERN } from '../constants.js'; import type { Diagnostic, ReactDoctorConfig } from '../types.js'; import { checkReducedMotion } from './check-reduced-motion.js'; @@ -15,12 +16,14 @@ export const combineDiagnostics = ( directory: string, isDiffMode: boolean, userConfig: ReactDoctorConfig | null, + audit: boolean = true, ): Diagnostic[] => { const allDiagnostics = [ ...lintDiagnostics, ...deadCodeDiagnostics, ...securityDiagnostics, ...(isDiffMode ? [] : checkReducedMotion(directory)), + ...(audit && !isDiffMode ? runAudit(directory).diagnostics : []), ]; return userConfig ? filterIgnoredDiagnostics(allDiagnostics, userConfig) : allDiagnostics; }; diff --git a/packages/react-doctor/src/utils/load-config.ts b/packages/react-doctor/src/utils/load-config.ts index 8760fcb..8c0a987 100644 --- a/packages/react-doctor/src/utils/load-config.ts +++ b/packages/react-doctor/src/utils/load-config.ts @@ -1,8 +1,14 @@ -import { loadConfig as loadConfigFromCore } from '@framework-doctor/core'; +import { loadConfigWithUnified } from '@framework-doctor/core'; import type { ReactDoctorConfig } from '../types.js'; const CONFIG_FILENAME = 'react-doctor.config.json'; const PACKAGE_JSON_CONFIG_KEY = 'reactDoctor'; +const UNIFIED_FRAMEWORK_KEY = 'reactDoctor'; export const loadConfig = (rootDirectory: string): ReactDoctorConfig | null => - loadConfigFromCore(rootDirectory, CONFIG_FILENAME, PACKAGE_JSON_CONFIG_KEY); + loadConfigWithUnified( + rootDirectory, + CONFIG_FILENAME, + PACKAGE_JSON_CONFIG_KEY, + UNIFIED_FRAMEWORK_KEY, + ); diff --git a/packages/react-doctor/src/utils/run-oxlint.ts b/packages/react-doctor/src/utils/run-oxlint.ts index e7f6f53..0d63fe3 100644 --- a/packages/react-doctor/src/utils/run-oxlint.ts +++ b/packages/react-doctor/src/utils/run-oxlint.ts @@ -354,6 +354,7 @@ export const runOxlint = async ( hasReactCompiler: boolean, includePaths?: string[], nodeBinaryPath: string = process.execPath, + fix: boolean = false, ): Promise => { if (includePaths !== undefined && includePaths.length === 0) { return []; @@ -369,6 +370,7 @@ export const runOxlint = async ( const oxlintBinary = resolveOxlintBinary(); const baseArgs = [oxlintBinary, '-c', configPath, '--format', 'json']; + if (fix) baseArgs.push('--fix'); if (hasTypeScript) { baseArgs.push('--tsconfig', './tsconfig.json'); diff --git a/packages/svelte-doctor/README.md b/packages/svelte-doctor/README.md index 8d80e92..9536fc8 100644 --- a/packages/svelte-doctor/README.md +++ b/packages/svelte-doctor/README.md @@ -31,6 +31,9 @@ Options: --no-lint skip lint diagnostics --no-js-ts-lint skip JavaScript/TypeScript lint diagnostics --no-dead-code skip dead code detection + --no-audit skip dependency vulnerability audit + --fix auto-fix JS/TS lint issues where possible + --format output format: text or json --verbose show file details per rule --score output only the score (CI-friendly) -y, --yes skip prompts @@ -54,12 +57,15 @@ Create `svelte-doctor.config.json`: "lint": true, "jsTsLint": true, "deadCode": true, + "audit": true, "verbose": false, "diff": false, "analytics": true } ``` +Svelte Doctor also supports unified config via `framework-doctor.config.json` with a `svelteDoctor` section. Framework-specific config overrides unified options. + Or use the `svelteDoctor` key in `package.json`: ```json @@ -83,6 +89,8 @@ 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"`. +Svelte Doctor also runs a dependency audit (`pnpm audit`) and reports high/critical vulnerabilities. Use `--no-audit` to skip. + ## Analytics Svelte Doctor optionally sends anonymous usage data when you opt in. Data is sent to your Supabase Edge Function (see [supabase/README.md](../../supabase/README.md)) when `FRAMEWORK_DOCTOR_TELEMETRY_URL` is configured. If your function enforces `TELEMETRY_KEY`, set `FRAMEWORK_DOCTOR_TELEMETRY_KEY` in the client environment. Limited to framework type, score range, diagnostic count. No code or paths are collected. diff --git a/packages/svelte-doctor/src/cli.ts b/packages/svelte-doctor/src/cli.ts index c7c65f1..a921ab5 100644 --- a/packages/svelte-doctor/src/cli.ts +++ b/packages/svelte-doctor/src/cli.ts @@ -3,6 +3,7 @@ import { buildCountsSummaryLine, buildScoreBar, buildScoreBreakdownLines, + calculateScore, colorizeByScore, createFramedLine, getDoctorFace, @@ -44,10 +45,13 @@ interface CliFlags { lint: boolean; jsTsLint: boolean; deadCode: boolean; + audit: boolean; verbose: boolean; score: boolean; yes: boolean; analytics: boolean; + format: string; + fix: boolean; project?: string; diff?: boolean | string; offline?: boolean; @@ -198,7 +202,9 @@ const resolveScanOptions = ( lint: fromCli('lint') ? flags.lint : (config?.lint ?? flags.lint), jsTsLint: fromCli('jsTsLint') ? flags.jsTsLint : (config?.jsTsLint ?? flags.jsTsLint), deadCode: fromCli('deadCode') ? flags.deadCode : (config?.deadCode ?? flags.deadCode), + audit: fromCli('audit') ? flags.audit : (config?.audit ?? flags.audit), verbose: fromCli('verbose') ? flags.verbose : (config?.verbose ?? flags.verbose), + fix: flags.fix, }; }; @@ -220,6 +226,8 @@ const main = new Command() .option('--no-lint', 'skip lint diagnostics') .option('--no-js-ts-lint', 'skip JavaScript/TypeScript lint diagnostics') .option('--no-dead-code', 'skip dead code detection') + .option('--no-audit', 'skip dependency vulnerability audit') + .option('--fix', 'auto-fix lint issues where possible') .option('--verbose', 'show file details per rule') .option('--score', 'output only the score') .option('-y, --yes', 'skip prompts, scan all workspace projects'); @@ -227,6 +235,7 @@ const main = new Command() addAnalyticsOption(main); main + .option('--format ', 'output format: text or json', 'text') .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)') @@ -241,7 +250,7 @@ main const isAutomated = isAutomatedEnvironment(); const shouldSkipPrompts = flags.yes || isAutomated || !process.stdin.isTTY; - if (!isScoreOnly) { + if (!isScoreOnly && flags.format !== 'json') { logger.log(`svelte-doctor v${VERSION}`); logger.break(); } @@ -263,7 +272,7 @@ main isScoreOnly, ); - if (isDiffMode && diffInfo && !isScoreOnly) { + if (isDiffMode && diffInfo && !isScoreOnly && flags.format !== 'json') { if (diffInfo.isCurrentChanges) { logger.log('Scanning uncommitted changes'); } else { @@ -274,11 +283,16 @@ main logger.break(); } - if (!isScoreOnly && !isAutomated && !flags.yes) { + const isJsonFormat = flags.format === 'json'; + + if (!isScoreOnly && !isAutomated && !flags.yes && !isJsonFormat) { await maybePromptAnalyticsConsent(shouldSkipPrompts); } const allDiagnostics: Diagnostic[] = []; + let totalElapsedMs = 0; + let totalFilesScanned = 0; + const allSkippedChecks = new Set(); const telemetryUrl = process.env.FRAMEWORK_DOCTOR_TELEMETRY_URL ?? ''; for (const projectDirectory of projectDirectories) { @@ -288,7 +302,7 @@ main if (projectDiffInfo) { const changedSourceFiles = filterSourceFiles(projectDiffInfo.changedFiles); if (changedSourceFiles.length === 0) { - if (!isScoreOnly) { + if (!isScoreOnly && !isJsonFormat) { logger.dim(`No changed source files in ${projectDirectory}, skipping.`); logger.break(); } @@ -298,7 +312,7 @@ main } } - if (!isScoreOnly) { + if (!isScoreOnly && !isJsonFormat) { logger.dim(`Scanning ${projectDirectory}...`); logger.break(); } @@ -311,6 +325,14 @@ main const elapsedMs = performance.now() - startTime; allDiagnostics.push(...result.diagnostics); + totalElapsedMs += elapsedMs; + totalFilesScanned += + (includePaths?.length ?? 0) > 0 + ? (includePaths?.length ?? 0) + : result.projectInfo.sourceFileCount; + for (const skipped of result.skippedChecks) { + allSkippedChecks.add(skipped); + } if ( telemetryUrl && @@ -333,13 +355,17 @@ main ); } - if (flags.score) { + if (flags.score && !isJsonFormat) { if (result.scoreResult) { logger.log(`${result.scoreResult.score}`); } continue; } + if (isJsonFormat) { + continue; + } + if (result.diagnostics.length === 0) { logger.success('No issues found!'); } else { @@ -372,6 +398,29 @@ main } } + if (isJsonFormat) { + const hasHighOrCriticalSecurityFindings = allDiagnostics.some( + (d) => d.category === 'security' && d.severity === 'error', + ); + const scoreResult = + totalFilesScanned > 0 + ? calculateScore(allDiagnostics, totalFilesScanned, { + hasHighOrCriticalSecurityFindings, + }) + : { score: 100, label: 'Great', breakdown: undefined }; + const output = { + doctor: 'svelte-doctor', + version: VERSION, + diagnostics: allDiagnostics, + scoreResult, + totalFilesScanned, + elapsedMilliseconds: totalElapsedMs, + skippedChecks: [...allSkippedChecks], + }; + logger.log(JSON.stringify(output, null, 2)); + return; + } + if (!isScoreOnly && !shouldSkipPrompts) { await maybePromptSkillInstall(shouldSkipPrompts); } diff --git a/packages/svelte-doctor/src/scan.ts b/packages/svelte-doctor/src/scan.ts index 168bc3a..cf0a25b 100644 --- a/packages/svelte-doctor/src/scan.ts +++ b/packages/svelte-doctor/src/scan.ts @@ -1,3 +1,4 @@ +import { runAudit } from '@framework-doctor/core'; import type { Diagnostic, ScanOptions, ScanResult, SvelteDoctorConfig } from './types.js'; import { checkReducedMotion } from './utils/check-reduced-motion.js'; import { discoverProject } from './utils/discover-project.js'; @@ -13,6 +14,8 @@ interface ResolvedScanOptions { lint: boolean; jsTsLint: boolean; deadCode: boolean; + audit: boolean; + fix: boolean; includePaths: string[]; } @@ -23,6 +26,8 @@ const resolveOptions = ( lint: options.lint ?? userConfig?.lint ?? true, jsTsLint: options.jsTsLint ?? userConfig?.jsTsLint ?? true, deadCode: options.deadCode ?? userConfig?.deadCode ?? true, + audit: options.audit ?? userConfig?.audit ?? true, + fix: options.fix ?? false, includePaths: options.includePaths ?? [], }); @@ -56,6 +61,7 @@ export const scan = async (directory: string, options: ScanOptions = {}): Promis directory, projectInfo.hasTypeScript, resolved.includePaths, + resolved.fix, ); } catch { skippedChecks.push('js/ts lint'); @@ -83,6 +89,9 @@ export const scan = async (directory: string, options: ScanOptions = {}): Promis const reducedMotionDiagnostics = resolved.includePaths.length === 0 ? checkReducedMotion(directory) : []; + const auditDiagnostics = + resolved.audit && resolved.includePaths.length === 0 ? runAudit(directory).diagnostics : []; + const diagnostics = filterIgnoredDiagnostics( [ ...lintDiagnostics, @@ -90,6 +99,7 @@ export const scan = async (directory: string, options: ScanOptions = {}): Promis ...deadCodeDiagnostics, ...securityDiagnostics, ...reducedMotionDiagnostics, + ...auditDiagnostics, ], userConfig, ); diff --git a/packages/svelte-doctor/src/types.ts b/packages/svelte-doctor/src/types.ts index f0b2208..e0107f8 100644 --- a/packages/svelte-doctor/src/types.ts +++ b/packages/svelte-doctor/src/types.ts @@ -23,8 +23,10 @@ export interface ScanOptions { lint?: boolean; jsTsLint?: boolean; deadCode?: boolean; + audit?: boolean; verbose?: boolean; scoreOnly?: boolean; + fix?: boolean; includePaths?: string[]; } diff --git a/packages/svelte-doctor/src/utils/load-config.ts b/packages/svelte-doctor/src/utils/load-config.ts index 29e95dd..607ba25 100644 --- a/packages/svelte-doctor/src/utils/load-config.ts +++ b/packages/svelte-doctor/src/utils/load-config.ts @@ -1,8 +1,14 @@ -import { loadConfig as loadConfigFromCore } from '@framework-doctor/core'; +import { loadConfigWithUnified } from '@framework-doctor/core'; import type { SvelteDoctorConfig } from '../types.js'; const CONFIG_FILENAME = 'svelte-doctor.config.json'; const PACKAGE_JSON_CONFIG_KEY = 'svelteDoctor'; +const UNIFIED_FRAMEWORK_KEY = 'svelteDoctor'; export const loadConfig = (rootDirectory: string): SvelteDoctorConfig | null => - loadConfigFromCore(rootDirectory, CONFIG_FILENAME, PACKAGE_JSON_CONFIG_KEY); + loadConfigWithUnified( + rootDirectory, + CONFIG_FILENAME, + PACKAGE_JSON_CONFIG_KEY, + UNIFIED_FRAMEWORK_KEY, + ); diff --git a/packages/svelte-doctor/src/utils/run-oxlint.ts b/packages/svelte-doctor/src/utils/run-oxlint.ts index d390de0..71df480 100644 --- a/packages/svelte-doctor/src/utils/run-oxlint.ts +++ b/packages/svelte-doctor/src/utils/run-oxlint.ts @@ -57,6 +57,7 @@ export const runOxlint = async ( rootDirectory: string, hasTypeScript: boolean, includePaths: string[], + fix: boolean = false, ): Promise => { const selectedPaths = includePaths.length > 0 @@ -66,6 +67,7 @@ export const runOxlint = async ( if (selectedPaths.length === 0) return []; const args = ['oxlint', '--format', 'json']; + if (fix) args.push('--fix'); if (hasTypeScript) { args.push('--tsconfig', './tsconfig.json'); } diff --git a/packages/svelte-doctor/vitest.config.ts b/packages/svelte-doctor/vitest.config.ts new file mode 100644 index 0000000..092b1b3 --- /dev/null +++ b/packages/svelte-doctor/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + testTimeout: 30_000, + }, +}); diff --git a/packages/vue-doctor/README.md b/packages/vue-doctor/README.md index 4e24d6a..1106437 100644 --- a/packages/vue-doctor/README.md +++ b/packages/vue-doctor/README.md @@ -30,6 +30,8 @@ Options: -v, --version display the version number --no-lint skip linting --no-dead-code skip dead code detection + --no-audit skip dependency vulnerability audit + --format output format: text or json --verbose show file details per rule --score output only the score (CI-friendly) -y, --yes skip prompts, scan all workspace projects @@ -52,6 +54,7 @@ Create `vue-doctor.config.json`: }, "lint": true, "deadCode": true, + "audit": true, "verbose": false, "diff": false, "analytics": true @@ -69,6 +72,8 @@ Or use the `vueDoctor` key in `package.json`: } ``` +Vue Doctor also supports unified config via `framework-doctor.config.json` with a `vueDoctor` section. Framework-specific config overrides unified options. + ## Programmatic API ```typescript @@ -95,6 +100,7 @@ Vue Doctor runs: - **Security** — v-html, eval, new Function, implied eval - **Knip** — Dead code detection - **checkReducedMotion** — Accessibility (WCAG 2.3.3) when motion libraries are used +- **Dependency audit** — High/critical vulnerabilities via `pnpm audit` (use `--no-audit` to skip) ## Security checks diff --git a/packages/vue-doctor/src/cli.ts b/packages/vue-doctor/src/cli.ts index 5080e04..ad9559f 100644 --- a/packages/vue-doctor/src/cli.ts +++ b/packages/vue-doctor/src/cli.ts @@ -1,11 +1,13 @@ import { addAnalyticsOption, + calculateScore, highlighter, isAutomatedEnvironment, logger, } from '@framework-doctor/core'; import { Command } from 'commander'; import path from 'node:path'; +import { performance } from 'node:perf_hooks'; import prompts from 'prompts'; import { scan } from './scan.js'; import type { Diagnostic, DiffInfo, ScanOptions, VueDoctorConfig } from './types.js'; @@ -25,10 +27,12 @@ const VERSION = process.env.VERSION ?? '0.0.0'; interface CliFlags { lint: boolean; deadCode: boolean; + audit: boolean; verbose: boolean; score: boolean; yes: boolean; analytics: boolean; + format: string; project?: string; diff?: boolean | string; offline?: boolean; @@ -55,8 +59,10 @@ const resolveCliScanOptions = ( return { lint: isCliOverride('lint') ? flags.lint : (userConfig?.lint ?? flags.lint), deadCode: isCliOverride('deadCode') ? flags.deadCode : (userConfig?.deadCode ?? flags.deadCode), + audit: isCliOverride('audit') ? flags.audit : (userConfig?.audit ?? flags.audit), verbose: isCliOverride('verbose') ? Boolean(flags.verbose) : (userConfig?.verbose ?? false), scoreOnly: flags.score, + format: (flags.format === 'json' ? 'json' : 'text') as 'text' | 'json', }; }; @@ -112,8 +118,10 @@ const program = new Command() .argument('[directory]', 'project directory to scan', '.') .option('--no-lint', 'skip linting') .option('--no-dead-code', 'skip dead code detection') + .option('--no-audit', 'skip dependency vulnerability audit') .option('--verbose', 'show file details per rule') .option('--score', 'output only the score') + .option('--format ', 'output format: text or json', 'text') .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') @@ -124,12 +132,13 @@ addAnalyticsOption(program); program .action(async (directory: string, flags: CliFlags) => { const isScoreOnly = flags.score; + const isJsonFormat = flags.format === 'json'; try { const resolvedDirectory = path.resolve(directory); const userConfig = loadConfig(resolvedDirectory); - if (!isScoreOnly) { + if (!isScoreOnly && !isJsonFormat) { logger.log(`vue-doctor v${VERSION}`); logger.break(); } @@ -153,7 +162,7 @@ program isScoreOnly, ); - if (isDiffMode && diffInfo && !isScoreOnly) { + if (isDiffMode && diffInfo && !isScoreOnly && !isJsonFormat) { if (diffInfo.isCurrentChanges) { logger.log('Scanning uncommitted changes'); } else { @@ -165,10 +174,13 @@ program } const allDiagnostics: Diagnostic[] = []; + let totalElapsedMs = 0; + let totalFilesScanned = 0; + const allSkippedChecks = new Set(); const telemetryUrl = process.env.FRAMEWORK_DOCTOR_TELEMETRY_URL ?? ''; const isAutomated = isAutomatedEnvironment(); - if (!isScoreOnly && !isAutomated && !flags.yes) { + if (!isScoreOnly && !isAutomated && !flags.yes && !isJsonFormat) { await maybePromptAnalyticsConsent(shouldSkipPrompts); } @@ -179,7 +191,7 @@ program if (projectDiffInfo) { const changedSourceFiles = filterSourceFiles(projectDiffInfo.changedFiles); if (changedSourceFiles.length === 0) { - if (!isScoreOnly) { + if (!isScoreOnly && !isJsonFormat) { logger.dim(`No changed source files in ${projectDirectory}, skipping.`); logger.break(); } @@ -189,12 +201,27 @@ program } } - if (!isScoreOnly) { + if (!isScoreOnly && !isJsonFormat) { logger.dim(`Scanning ${projectDirectory}...`); logger.break(); } - const scanResult = await scan(projectDirectory, { ...scanOptions, includePaths }); + const scanStart = performance.now(); + const scanResult = await scan(projectDirectory, { + ...scanOptions, + includePaths, + format: isJsonFormat ? 'json' : 'text', + }); + const scanElapsed = performance.now() - scanStart; + allDiagnostics.push(...scanResult.diagnostics); + totalElapsedMs += scanElapsed; + totalFilesScanned += + (includePaths?.length ?? 0) > 0 + ? (includePaths?.length ?? 0) + : scanResult.projectInfo.sourceFileCount; + for (const skipped of scanResult.skippedChecks) { + allSkippedChecks.add(skipped); + } if ( telemetryUrl && @@ -217,11 +244,34 @@ program ); } - if (!isScoreOnly) { + if (!isScoreOnly && !isJsonFormat) { logger.break(); } } + if (isJsonFormat) { + const hasHighOrCriticalSecurityFindings = allDiagnostics.some( + (d) => d.category === 'security' && d.severity === 'error', + ); + const scoreResult = + totalFilesScanned > 0 + ? calculateScore(allDiagnostics, totalFilesScanned, { + hasHighOrCriticalSecurityFindings, + }) + : { score: 100, label: 'Great', breakdown: undefined }; + const output = { + doctor: 'vue-doctor', + version: VERSION, + diagnostics: allDiagnostics, + scoreResult, + totalFilesScanned, + elapsedMilliseconds: totalElapsedMs, + skippedChecks: [...allSkippedChecks], + }; + logger.log(JSON.stringify(output, null, 2)); + return; + } + if (!isScoreOnly && !shouldSkipPrompts) { await maybePromptSkillInstall(shouldSkipPrompts); } diff --git a/packages/vue-doctor/src/scan.ts b/packages/vue-doctor/src/scan.ts index d9a26d9..b21350f 100644 --- a/packages/vue-doctor/src/scan.ts +++ b/packages/vue-doctor/src/scan.ts @@ -235,8 +235,10 @@ const printSummary = ( interface ResolvedScanOptions { lint: boolean; deadCode: boolean; + audit: boolean; verbose: boolean; scoreOnly: boolean; + format: 'text' | 'json'; includePaths: string[]; } @@ -246,8 +248,10 @@ const mergeScanOptions = ( ): ResolvedScanOptions => ({ lint: inputOptions.lint ?? userConfig?.lint ?? true, deadCode: inputOptions.deadCode ?? userConfig?.deadCode ?? true, + audit: inputOptions.audit ?? userConfig?.audit ?? true, verbose: inputOptions.verbose ?? userConfig?.verbose ?? false, scoreOnly: inputOptions.scoreOnly ?? false, + format: inputOptions.format ?? 'text', includePaths: inputOptions.includePaths ?? [], }); @@ -266,7 +270,7 @@ export const scan = async ( throw new Error('No Vue dependency found in package.json'); } - if (!options.scoreOnly) { + if (!options.scoreOnly && options.format !== 'json') { printProjectDetection(projectInfo, userConfig, isDiffMode, includePaths); } @@ -276,7 +280,10 @@ export const scan = async ( let didDeadCodeFail = false; const vueTscPromise = (async () => { - const vueTscSpinner = options.scoreOnly ? null : spinner('Running Vue type check...').start(); + const vueTscSpinner = + options.scoreOnly || options.format === 'json' + ? null + : spinner('Running Vue type check...').start(); try { const diagnostics = await runVueTsc(directory, vueIncludePaths); vueTscSpinner?.succeed('Running Vue type check.'); @@ -290,7 +297,10 @@ export const scan = async ( const lintPromise = options.lint ? (async () => { - const lintSpinner = options.scoreOnly ? null : spinner('Running lint checks...').start(); + const lintSpinner = + options.scoreOnly || options.format === 'json' + ? null + : spinner('Running lint checks...').start(); try { const lintDiagnostics = await runEslint( directory, @@ -311,9 +321,10 @@ export const scan = async ( const deadCodePromise = options.deadCode && !isDiffMode ? (async () => { - const deadCodeSpinner = options.scoreOnly - ? null - : spinner('Detecting dead code...').start(); + const deadCodeSpinner = + options.scoreOnly || options.format === 'json' + ? null + : spinner('Detecting dead code...').start(); try { const knipDiagnostics = await runKnip(directory); deadCodeSpinner?.succeed('Detecting dead code.'); @@ -341,6 +352,7 @@ export const scan = async ( directory, isDiffMode, userConfig, + options.audit, ); const elapsedMilliseconds = performance.now() - startTime; @@ -356,11 +368,13 @@ export const scan = async ( }); const noScoreMessage = OFFLINE_FLAG_MESSAGE; - if (options.scoreOnly) { - if (scoreResult) { - logger.log(`${scoreResult.score}`); - } else { - logger.dim(noScoreMessage); + if (options.scoreOnly || options.format === 'json') { + if (options.scoreOnly && options.format !== 'json') { + if (scoreResult) { + logger.log(`${scoreResult.score}`); + } else { + logger.dim(noScoreMessage); + } } return { diagnostics, scoreResult, skippedChecks, projectInfo }; } diff --git a/packages/vue-doctor/src/types.ts b/packages/vue-doctor/src/types.ts index 8404035..997b2c0 100644 --- a/packages/vue-doctor/src/types.ts +++ b/packages/vue-doctor/src/types.ts @@ -22,8 +22,10 @@ export type { export interface ScanOptions { lint?: boolean; deadCode?: boolean; + audit?: boolean; verbose?: boolean; scoreOnly?: boolean; + format?: 'text' | 'json'; includePaths?: string[]; } diff --git a/packages/vue-doctor/src/utils/combine-diagnostics.ts b/packages/vue-doctor/src/utils/combine-diagnostics.ts index 9e29b07..8de0bb3 100644 --- a/packages/vue-doctor/src/utils/combine-diagnostics.ts +++ b/packages/vue-doctor/src/utils/combine-diagnostics.ts @@ -1,3 +1,4 @@ +import { runAudit } from '@framework-doctor/core'; import { SOURCE_FILE_PATTERN } from '../constants.js'; import type { Diagnostic, VueDoctorConfig } from '../types.js'; import { checkReducedMotion } from './check-reduced-motion.js'; @@ -15,12 +16,14 @@ export const combineDiagnostics = ( directory: string, isDiffMode: boolean, userConfig: VueDoctorConfig | null, + audit: boolean = true, ): Diagnostic[] => { const allDiagnostics = [ ...lintDiagnostics, ...deadCodeDiagnostics, ...securityDiagnostics, ...(isDiffMode ? [] : checkReducedMotion(directory)), + ...(audit && !isDiffMode ? runAudit(directory).diagnostics : []), ]; return userConfig ? filterIgnoredDiagnostics(allDiagnostics, userConfig) : allDiagnostics; }; diff --git a/packages/vue-doctor/src/utils/load-config.ts b/packages/vue-doctor/src/utils/load-config.ts index 4bba002..946099b 100644 --- a/packages/vue-doctor/src/utils/load-config.ts +++ b/packages/vue-doctor/src/utils/load-config.ts @@ -1,8 +1,14 @@ -import { loadConfig as loadConfigFromCore } from '@framework-doctor/core'; +import { loadConfigWithUnified } from '@framework-doctor/core'; import type { VueDoctorConfig } from '../types.js'; const CONFIG_FILENAME = 'vue-doctor.config.json'; const PACKAGE_JSON_CONFIG_KEY = 'vueDoctor'; +const UNIFIED_FRAMEWORK_KEY = 'vueDoctor'; export const loadConfig = (rootDirectory: string): VueDoctorConfig | null => - loadConfigFromCore(rootDirectory, CONFIG_FILENAME, PACKAGE_JSON_CONFIG_KEY); + loadConfigWithUnified( + rootDirectory, + CONFIG_FILENAME, + PACKAGE_JSON_CONFIG_KEY, + UNIFIED_FRAMEWORK_KEY, + ); diff --git a/packages/vue-doctor/vitest.config.ts b/packages/vue-doctor/vitest.config.ts new file mode 100644 index 0000000..092b1b3 --- /dev/null +++ b/packages/vue-doctor/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + testTimeout: 30_000, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f80eea..2f463e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -305,6 +305,9 @@ importers: '@framework-doctor/vue': specifier: workspace:* version: link:../vue-doctor + chokidar: + specifier: ^4.0.0 + version: 4.0.3 devDependencies: '@types/node': specifier: 'catalog:' diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 06bc502..1ce7171 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -8,10 +8,9 @@ packages: catalog: '@changesets/cli': ^2.29.7 '@nuxt/eslint-plugin': ^1.15.1 - angular-eslint: ^19.4.0 - typescript-eslint: ^8.32.0 '@types/node': ^25.3.0 '@types/prompts': ^2.4.9 + angular-eslint: ^19.4.0 commander: ^14.0.3 cross-env: ^10.1.0 eslint: ^9.15.0 @@ -28,8 +27,12 @@ catalog: tsdown: ^0.20.3 turbo: ^2.5.6 typescript: ^5.9.2 + typescript-eslint: ^8.32.0 vitest: ^4.0.8 vue-tsc: ^2.1.10 onlyBuiltDependencies: + - '@parcel/watcher' - esbuild + - lmdb + - msgpackr-extract diff --git a/supabase/migrations/20260225000000_enable_rls_telemetry_events.sql b/supabase/migrations/20260225000000_enable_rls_telemetry_events.sql new file mode 100644 index 0000000..d864f94 --- /dev/null +++ b/supabase/migrations/20260225000000_enable_rls_telemetry_events.sql @@ -0,0 +1 @@ +alter table telemetry_events enable row level security; From 944c726b0a445e21038c7715c8c50cd31ec89659 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piti=C8=99=20Radu?= Date: Sun, 1 Mar 2026 02:45:49 +0200 Subject: [PATCH 2/5] feat: github action --- action.yml | 54 +++++++++++++++++------------------------------------- 1 file changed, 17 insertions(+), 37 deletions(-) diff --git a/action.yml b/action.yml index cf2a65c..fa31173 100644 --- a/action.yml +++ b/action.yml @@ -1,50 +1,30 @@ -name: 'Svelte Doctor' -description: 'Scan Svelte codebases for security, performance, and correctness issues' +name: 'Framework Doctor' +description: 'Scan your Svelte, React, Vue, or Angular project for health issues' branding: icon: 'activity' color: 'blue' inputs: directory: - description: 'Project directory to scan' + description: 'Project directory to scan (default: .)' + required: false default: '.' - verbose: - description: 'Show file details per rule' - default: 'true' - project: - description: 'Workspace project(s) to scan (comma-separated)' + format: + description: 'Output format: text or json' required: false - node-version: - description: 'Node.js version to use' - default: '20' - -outputs: - score: - description: 'Health score (0-100)' - value: ${{ steps.score.outputs.score }} + default: 'text' + fail-on-low-score: + description: 'Fail the step if score is below threshold' + required: false + default: 'false' + score-threshold: + description: 'Minimum score required when fail-on-low-score is true' + required: false + default: '0' runs: using: 'composite' steps: - - uses: actions/setup-node@v4 - with: - node-version: ${{ inputs.node-version }} - - - shell: bash - env: - INPUT_DIRECTORY: ${{ inputs.directory }} - INPUT_VERBOSE: ${{ inputs.verbose }} - INPUT_PROJECT: ${{ inputs.project }} - run: | - ARGS=("$INPUT_DIRECTORY") - if [ "$INPUT_VERBOSE" = "true" ]; then ARGS+=(--verbose); fi - if [ -n "$INPUT_PROJECT" ]; then ARGS+=(--project "$INPUT_PROJECT"); fi - npx -y @framework-doctor/svelte "${ARGS[@]}" - - - id: score + - name: Run Framework Doctor + run: npx -y @framework-doctor/cli ${{ inputs.directory }} --format ${{ inputs.format }} -y shell: bash - env: - INPUT_DIRECTORY: ${{ inputs.directory }} - run: | - SCORE=$(npx -y @framework-doctor/svelte "$INPUT_DIRECTORY" --score 2>/dev/null || echo "") - echo "score=$SCORE" >> "$GITHUB_OUTPUT" From b5e492134a88e0058de1f63b2d57205a0e1b865e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piti=C8=99=20Radu?= Date: Sun, 1 Mar 2026 02:50:24 +0200 Subject: [PATCH 3/5] tests: updated issues --- packages/angular-doctor/tests/scan.test.ts | 20 ++------ packages/react-doctor/tests/scan.test.ts | 20 ++------ packages/svelte-doctor/tests/scan.test.ts | 58 +++++++--------------- packages/vue-doctor/tests/scan.test.ts | 20 ++------ 4 files changed, 34 insertions(+), 84 deletions(-) diff --git a/packages/angular-doctor/tests/scan.test.ts b/packages/angular-doctor/tests/scan.test.ts index 5600354..84ec060 100644 --- a/packages/angular-doctor/tests/scan.test.ts +++ b/packages/angular-doctor/tests/scan.test.ts @@ -31,24 +31,12 @@ afterAll(() => { }); describe('scan', () => { - it('completes without throwing on a valid Angular project', async () => { - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - try { - await scan(path.join(FIXTURES_DIRECTORY, 'basic-angular'), { - lint: true, - deadCode: false, - }); - } finally { - consoleSpy.mockRestore(); - } - }); - it('throws when Angular dependency is missing', async () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); try { - await expect(scan(noAngularTempDirectory, { lint: true, deadCode: false })).rejects.toThrow( - 'No Angular dependency found in package.json', - ); + await expect( + scan(noAngularTempDirectory, { lint: true, deadCode: false, audit: false }), + ).rejects.toThrow('No Angular dependency found in package.json'); } finally { consoleSpy.mockRestore(); } @@ -60,6 +48,7 @@ describe('scan', () => { await scan(path.join(FIXTURES_DIRECTORY, 'basic-angular'), { lint: false, deadCode: false, + audit: false, }); } finally { consoleSpy.mockRestore(); @@ -73,6 +62,7 @@ describe('scan', () => { await scan(path.join(FIXTURES_DIRECTORY, 'basic-angular'), { lint: true, deadCode: true, + audit: false, }); const elapsedMilliseconds = performance.now() - startTime; diff --git a/packages/react-doctor/tests/scan.test.ts b/packages/react-doctor/tests/scan.test.ts index aa8f19b..8ebf946 100644 --- a/packages/react-doctor/tests/scan.test.ts +++ b/packages/react-doctor/tests/scan.test.ts @@ -31,24 +31,12 @@ afterAll(() => { }); describe('scan', () => { - it('completes without throwing on a valid React project', async () => { - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - try { - await scan(path.join(FIXTURES_DIRECTORY, 'basic-react'), { - lint: true, - deadCode: false, - }); - } finally { - consoleSpy.mockRestore(); - } - }); - it('throws when React dependency is missing', async () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); try { - await expect(scan(noReactTempDirectory, { lint: true, deadCode: false })).rejects.toThrow( - 'No React dependency found', - ); + await expect( + scan(noReactTempDirectory, { lint: true, deadCode: false, audit: false }), + ).rejects.toThrow('No React dependency found'); } finally { consoleSpy.mockRestore(); } @@ -60,6 +48,7 @@ describe('scan', () => { await scan(path.join(FIXTURES_DIRECTORY, 'basic-react'), { lint: false, deadCode: false, + audit: false, }); } finally { consoleSpy.mockRestore(); @@ -73,6 +62,7 @@ describe('scan', () => { await scan(path.join(FIXTURES_DIRECTORY, 'basic-react'), { lint: true, deadCode: true, + audit: false, }); const elapsedMilliseconds = performance.now() - startTime; diff --git a/packages/svelte-doctor/tests/scan.test.ts b/packages/svelte-doctor/tests/scan.test.ts index 119f354..a49567d 100644 --- a/packages/svelte-doctor/tests/scan.test.ts +++ b/packages/svelte-doctor/tests/scan.test.ts @@ -30,31 +30,13 @@ afterAll(() => { fs.rmSync(noSvelteTempDirectory, { recursive: true, force: true }); }); -const SCAN_TIMEOUT_MS = 20_000; - describe('scan', () => { - it( - 'completes without throwing on a valid Svelte project', - async () => { - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - try { - await scan(path.join(FIXTURES_DIRECTORY, 'basic-svelte'), { - lint: true, - deadCode: false, - }); - } finally { - consoleSpy.mockRestore(); - } - }, - SCAN_TIMEOUT_MS, - ); - it('throws when Svelte dependency is missing', async () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); try { - await expect(scan(noSvelteTempDirectory, { lint: true, deadCode: false })).rejects.toThrow( - 'No Svelte dependency found in package.json', - ); + await expect( + scan(noSvelteTempDirectory, { lint: true, deadCode: false, audit: false }), + ).rejects.toThrow('No Svelte dependency found in package.json'); } finally { consoleSpy.mockRestore(); } @@ -66,29 +48,27 @@ describe('scan', () => { await scan(path.join(FIXTURES_DIRECTORY, 'basic-svelte'), { lint: false, deadCode: false, + audit: false, }); } finally { consoleSpy.mockRestore(); } }); - it( - 'runs lint and dead code in parallel when both enabled', - async () => { - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - try { - const startTime = performance.now(); - await scan(path.join(FIXTURES_DIRECTORY, 'basic-svelte'), { - lint: true, - deadCode: true, - }); - const elapsedMilliseconds = performance.now() - startTime; + it('runs lint and dead code in parallel when both enabled', async () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + try { + const startTime = performance.now(); + await scan(path.join(FIXTURES_DIRECTORY, 'basic-svelte'), { + lint: true, + deadCode: true, + audit: false, + }); + const elapsedMilliseconds = performance.now() - startTime; - expect(elapsedMilliseconds).toBeLessThan(30_000); - } finally { - consoleSpy.mockRestore(); - } - }, - SCAN_TIMEOUT_MS, - ); + expect(elapsedMilliseconds).toBeLessThan(30_000); + } finally { + consoleSpy.mockRestore(); + } + }); }); diff --git a/packages/vue-doctor/tests/scan.test.ts b/packages/vue-doctor/tests/scan.test.ts index cdb6de6..ece7aec 100644 --- a/packages/vue-doctor/tests/scan.test.ts +++ b/packages/vue-doctor/tests/scan.test.ts @@ -31,24 +31,12 @@ afterAll(() => { }); describe('scan', () => { - it('completes without throwing on a valid Vue project', async () => { - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - try { - await scan(path.join(FIXTURES_DIRECTORY, 'basic-vue'), { - lint: true, - deadCode: false, - }); - } finally { - consoleSpy.mockRestore(); - } - }); - it('throws when Vue dependency is missing', async () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); try { - await expect(scan(noVueTempDirectory, { lint: true, deadCode: false })).rejects.toThrow( - 'No Vue dependency found in package.json', - ); + await expect( + scan(noVueTempDirectory, { lint: true, deadCode: false, audit: false }), + ).rejects.toThrow('No Vue dependency found in package.json'); } finally { consoleSpy.mockRestore(); } @@ -60,6 +48,7 @@ describe('scan', () => { await scan(path.join(FIXTURES_DIRECTORY, 'basic-vue'), { lint: false, deadCode: false, + audit: false, }); } finally { consoleSpy.mockRestore(); @@ -73,6 +62,7 @@ describe('scan', () => { await scan(path.join(FIXTURES_DIRECTORY, 'basic-vue'), { lint: true, deadCode: true, + audit: false, }); const elapsedMilliseconds = performance.now() - startTime; From 059b3df33c53ea5d978502c04fa885aa9abc9039 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piti=C8=99=20Radu?= Date: Sun, 1 Mar 2026 02:57:15 +0200 Subject: [PATCH 4/5] docs: changesets --- packages/angular-doctor/CHANGELOG.md | 20 ++++++++++++++++++++ packages/angular-doctor/README.md | 12 ++++++++++-- packages/angular-doctor/package.json | 2 +- packages/cli/CHANGELOG.md | 23 +++++++++++++++++++++++ packages/cli/package.json | 2 +- packages/core/CHANGELOG.md | 15 +++++++++++++++ packages/core/package.json | 2 +- packages/react-doctor/CHANGELOG.md | 20 ++++++++++++++++++++ packages/react-doctor/package.json | 2 +- packages/svelte-doctor/CHANGELOG.md | 20 ++++++++++++++++++++ packages/svelte-doctor/package.json | 2 +- packages/vue-doctor/CHANGELOG.md | 20 ++++++++++++++++++++ packages/vue-doctor/package.json | 2 +- 13 files changed, 134 insertions(+), 8 deletions(-) diff --git a/packages/angular-doctor/CHANGELOG.md b/packages/angular-doctor/CHANGELOG.md index 855c00c..4a5e98c 100644 --- a/packages/angular-doctor/CHANGELOG.md +++ b/packages/angular-doctor/CHANGELOG.md @@ -1,5 +1,25 @@ # @framework-doctor/angular +## 1.1.0 + +### Minor Changes + +- - Fix stale "coming soon" messaging: Angular is now listed as supported alongside Svelte, React, Vue + - Add `--format json` for machine-readable output (score, diagnostics, projectInfo, elapsedMs) + - Add `--watch` to re-scan on file changes (debounced) + - Add `--fix` for auto-fixable lint issues (Svelte, React) + - Add `--no-audit` to skip dependency vulnerability audit (default: audit enabled) + - Add optional dependency audit integration (reports high/critical vulns via `pnpm audit`) + - Add unified config via `framework-doctor.config.json` with shared options and framework sections + - Add Angular reduced motion check (WCAG 2.3.3, motion libraries detection) + - Add GitHub Action for CI (`action.yml` + workflow) + - Optimize scan test suite (audit disabled in tests, fewer redundant scans)z + +### Patch Changes + +- Updated dependencies + - @framework-doctor/core@1.1.0 + ## 1.0.4 ### Patch Changes diff --git a/packages/angular-doctor/README.md b/packages/angular-doctor/README.md index a34d911..41e7338 100644 --- a/packages/angular-doctor/README.md +++ b/packages/angular-doctor/README.md @@ -74,6 +74,16 @@ Or use the `angularDoctor` key in `package.json`: Angular Doctor also supports unified config via `framework-doctor.config.json` with an `angularDoctor` section. Framework-specific config overrides unified options. +## Checks + +Angular Doctor runs: + +- **ESLint** — angular-eslint with recommended rules +- **Knip** — Dead code detection +- **Security** — eval, new Function, implied eval, innerHTML, bypassSecurityTrust\* +- **checkReducedMotion** — Accessibility (WCAG 2.3.3) when motion libraries are used +- **Dependency audit** — High/critical vulnerabilities via `pnpm audit` (use `--no-audit` to skip) + ## Security checks Angular Doctor flags: @@ -84,8 +94,6 @@ Angular Doctor flags: - **`innerHTML` binding** — Raw HTML can lead to XSS if content is unsanitized - **`bypassSecurityTrust*`** — Bypassing Angular’s sanitizer can lead to XSS -Angular Doctor also runs a dependency audit (`pnpm audit`) and reports high/critical vulnerabilities. Use `--no-audit` to skip. - ## Analytics Angular 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. diff --git a/packages/angular-doctor/package.json b/packages/angular-doctor/package.json index 439d3c3..204368d 100644 --- a/packages/angular-doctor/package.json +++ b/packages/angular-doctor/package.json @@ -1,6 +1,6 @@ { "name": "@framework-doctor/angular", - "version": "1.0.4", + "version": "1.1.0", "description": "Diagnose Angular codebase health", "author": { "name": "Pitis Radu", diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 47bd233..137d3e2 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,28 @@ # @framework-doctor/cli +## 1.1.0 + +### Minor Changes + +- - Fix stale "coming soon" messaging: Angular is now listed as supported alongside Svelte, React, Vue + - Add `--format json` for machine-readable output (score, diagnostics, projectInfo, elapsedMs) + - Add `--watch` to re-scan on file changes (debounced) + - Add `--fix` for auto-fixable lint issues (Svelte, React) + - Add `--no-audit` to skip dependency vulnerability audit (default: audit enabled) + - Add optional dependency audit integration (reports high/critical vulns via `pnpm audit`) + - Add unified config via `framework-doctor.config.json` with shared options and framework sections + - Add Angular reduced motion check (WCAG 2.3.3, motion libraries detection) + - Add GitHub Action for CI (`action.yml` + workflow) + - Optimize scan test suite (audit disabled in tests, fewer redundant scans)z + +### Patch Changes + +- Updated dependencies + - @framework-doctor/svelte@1.1.0 + - @framework-doctor/react@1.1.0 + - @framework-doctor/vue@1.1.0 + - @framework-doctor/angular@1.1.0 + ## 1.0.4 ### Patch Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index f443d5e..cc5c551 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@framework-doctor/cli", - "version": "1.0.4", + "version": "1.1.0", "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 index d985731..4f3bb3c 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,20 @@ # @framework-doctor/core +## 1.1.0 + +### Minor Changes + +- - Fix stale "coming soon" messaging: Angular is now listed as supported alongside Svelte, React, Vue + - Add `--format json` for machine-readable output (score, diagnostics, projectInfo, elapsedMs) + - Add `--watch` to re-scan on file changes (debounced) + - Add `--fix` for auto-fixable lint issues (Svelte, React) + - Add `--no-audit` to skip dependency vulnerability audit (default: audit enabled) + - Add optional dependency audit integration (reports high/critical vulns via `pnpm audit`) + - Add unified config via `framework-doctor.config.json` with shared options and framework sections + - Add Angular reduced motion check (WCAG 2.3.3, motion libraries detection) + - Add GitHub Action for CI (`action.yml` + workflow) + - Optimize scan test suite (audit disabled in tests, fewer redundant scans)z + ## 1.0.4 ### Patch Changes diff --git a/packages/core/package.json b/packages/core/package.json index 2e291c1..0a0c936 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@framework-doctor/core", - "version": "1.0.4", + "version": "1.1.0", "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 a87d63b..e8102c8 100644 --- a/packages/react-doctor/CHANGELOG.md +++ b/packages/react-doctor/CHANGELOG.md @@ -1,5 +1,25 @@ # react-doctor +## 1.1.0 + +### Minor Changes + +- - Fix stale "coming soon" messaging: Angular is now listed as supported alongside Svelte, React, Vue + - Add `--format json` for machine-readable output (score, diagnostics, projectInfo, elapsedMs) + - Add `--watch` to re-scan on file changes (debounced) + - Add `--fix` for auto-fixable lint issues (Svelte, React) + - Add `--no-audit` to skip dependency vulnerability audit (default: audit enabled) + - Add optional dependency audit integration (reports high/critical vulns via `pnpm audit`) + - Add unified config via `framework-doctor.config.json` with shared options and framework sections + - Add Angular reduced motion check (WCAG 2.3.3, motion libraries detection) + - Add GitHub Action for CI (`action.yml` + workflow) + - Optimize scan test suite (audit disabled in tests, fewer redundant scans)z + +### Patch Changes + +- Updated dependencies + - @framework-doctor/core@1.1.0 + ## 1.0.4 ### Patch Changes diff --git a/packages/react-doctor/package.json b/packages/react-doctor/package.json index 4e0284f..a0d8139 100644 --- a/packages/react-doctor/package.json +++ b/packages/react-doctor/package.json @@ -1,6 +1,6 @@ { "name": "@framework-doctor/react", - "version": "1.0.4", + "version": "1.1.0", "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 372809e..e8a2ad7 100644 --- a/packages/svelte-doctor/CHANGELOG.md +++ b/packages/svelte-doctor/CHANGELOG.md @@ -1,5 +1,25 @@ # svelte-doctor +## 1.1.0 + +### Minor Changes + +- - Fix stale "coming soon" messaging: Angular is now listed as supported alongside Svelte, React, Vue + - Add `--format json` for machine-readable output (score, diagnostics, projectInfo, elapsedMs) + - Add `--watch` to re-scan on file changes (debounced) + - Add `--fix` for auto-fixable lint issues (Svelte, React) + - Add `--no-audit` to skip dependency vulnerability audit (default: audit enabled) + - Add optional dependency audit integration (reports high/critical vulns via `pnpm audit`) + - Add unified config via `framework-doctor.config.json` with shared options and framework sections + - Add Angular reduced motion check (WCAG 2.3.3, motion libraries detection) + - Add GitHub Action for CI (`action.yml` + workflow) + - Optimize scan test suite (audit disabled in tests, fewer redundant scans)z + +### Patch Changes + +- Updated dependencies + - @framework-doctor/core@1.1.0 + ## 1.0.4 ### Patch Changes diff --git a/packages/svelte-doctor/package.json b/packages/svelte-doctor/package.json index 0ac798c..81f2850 100644 --- a/packages/svelte-doctor/package.json +++ b/packages/svelte-doctor/package.json @@ -1,6 +1,6 @@ { "name": "@framework-doctor/svelte", - "version": "1.0.4", + "version": "1.1.0", "description": "Diagnose and improve Svelte codebase health", "author": { "name": "Pitis Radu", diff --git a/packages/vue-doctor/CHANGELOG.md b/packages/vue-doctor/CHANGELOG.md index 6ac24ba..24d9ef7 100644 --- a/packages/vue-doctor/CHANGELOG.md +++ b/packages/vue-doctor/CHANGELOG.md @@ -1,5 +1,25 @@ # vue-doctor +## 1.1.0 + +### Minor Changes + +- - Fix stale "coming soon" messaging: Angular is now listed as supported alongside Svelte, React, Vue + - Add `--format json` for machine-readable output (score, diagnostics, projectInfo, elapsedMs) + - Add `--watch` to re-scan on file changes (debounced) + - Add `--fix` for auto-fixable lint issues (Svelte, React) + - Add `--no-audit` to skip dependency vulnerability audit (default: audit enabled) + - Add optional dependency audit integration (reports high/critical vulns via `pnpm audit`) + - Add unified config via `framework-doctor.config.json` with shared options and framework sections + - Add Angular reduced motion check (WCAG 2.3.3, motion libraries detection) + - Add GitHub Action for CI (`action.yml` + workflow) + - Optimize scan test suite (audit disabled in tests, fewer redundant scans)z + +### Patch Changes + +- Updated dependencies + - @framework-doctor/core@1.1.0 + ## 1.0.4 ### Patch Changes diff --git a/packages/vue-doctor/package.json b/packages/vue-doctor/package.json index 2ae4555..3801ca8 100644 --- a/packages/vue-doctor/package.json +++ b/packages/vue-doctor/package.json @@ -1,6 +1,6 @@ { "name": "@framework-doctor/vue", - "version": "1.0.4", + "version": "1.1.0", "description": "Diagnose Vue and Nuxt codebase health", "author": { "name": "Pitis Radu", From e62397a0544d2aa99c110634d7c6f5a5cfe0a4de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piti=C8=99=20Radu?= Date: Sun, 1 Mar 2026 03:07:32 +0200 Subject: [PATCH 5/5] fix: workflows --- .github/workflows/framework-doctor-scan.yml | 42 +++++++++++++ .github/workflows/framework-doctor.yml | 2 - README.md | 30 +++++++++ action.yml | 67 ++++++++++++++++++++- 4 files changed, 138 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/framework-doctor-scan.yml diff --git a/.github/workflows/framework-doctor-scan.yml b/.github/workflows/framework-doctor-scan.yml new file mode 100644 index 0000000..fae5201 --- /dev/null +++ b/.github/workflows/framework-doctor-scan.yml @@ -0,0 +1,42 @@ +name: Framework Doctor Scan + +on: + workflow_call: + inputs: + directory: + type: string + description: 'Project directory to scan' + required: false + default: '.' + fail-on-low-score: + type: string + description: 'Fail if score below threshold' + required: false + default: 'false' + score-threshold: + type: string + description: 'Minimum score when fail-on-low-score is true' + required: false + default: '0' + post-to-pr: + type: string + description: 'Post score to PR comment' + required: false + default: 'false' + +jobs: + scan: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v4 + + - name: Run Framework Doctor + uses: pitis/framework-doctor@main + with: + directory: ${{ inputs.directory }} + fail-on-low-score: ${{ inputs.fail-on-low-score }} + score-threshold: ${{ inputs.score-threshold }} + post-to-pr: ${{ inputs.post-to-pr }} diff --git a/.github/workflows/framework-doctor.yml b/.github/workflows/framework-doctor.yml index 376b1ba..84f7154 100644 --- a/.github/workflows/framework-doctor.yml +++ b/.github/workflows/framework-doctor.yml @@ -3,8 +3,6 @@ name: Framework Doctor on: push: branches: [main, master] - pull_request: - branches: [main, master] workflow_dispatch: jobs: diff --git a/README.md b/README.md index 18b3b6f..1642e57 100644 --- a/README.md +++ b/README.md @@ -203,3 +203,33 @@ npx -y @framework-doctor/cli . --watch ## Dependency audit By default, the doctor runs `pnpm audit` and reports high or critical vulnerabilities. Use `--no-audit` to skip. + +## GitHub Action + +Add Framework Doctor to your CI. Other projects can use the action or reusable workflow on their PRs: + +**Action** (checkout required beforehand): + +```yaml +- uses: actions/checkout@v4 +- uses: pitis/framework-doctor@main + with: + directory: . + fail-on-low-score: 'true' + score-threshold: '80' + post-to-pr: 'true' +``` + +**Reusable workflow** (checks out repo, runs `npx @framework-doctor/cli .`): + +```yaml +jobs: + framework-doctor: + uses: pitis/framework-doctor/.github/workflows/framework-doctor-scan.yml@main + with: + post-to-pr: 'true' + fail-on-low-score: 'true' + score-threshold: '80' +``` + +Options: `directory`, `fail-on-low-score`, `score-threshold`, `post-to-pr`. diff --git a/action.yml b/action.yml index fa31173..1d45462 100644 --- a/action.yml +++ b/action.yml @@ -21,10 +21,75 @@ inputs: description: 'Minimum score required when fail-on-low-score is true' required: false default: '0' + post-to-pr: + description: 'Post scan score to PR comment (pull_request only)' + required: false + default: 'false' runs: using: 'composite' steps: - name: Run Framework Doctor - run: npx -y @framework-doctor/cli ${{ inputs.directory }} --format ${{ inputs.format }} -y + id: scan + env: + OUTPUT_FILE: ${{ runner.temp }}/framework-doctor-output.json + run: | + USE_JSON=${{ inputs.post-to-pr == 'true' || inputs.fail-on-low-score == 'true' }} + if [ "$USE_JSON" = "true" ]; then + npx -y @framework-doctor/cli "${{ inputs.directory }}" --format json -y 2>&1 | tee "$OUTPUT_FILE" + else + npx -y @framework-doctor/cli "${{ inputs.directory }}" --format ${{ inputs.format }} -y + fi + shell: bash + + - name: Post score to PR + if: inputs.post-to-pr == 'true' && github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const path = '${{ runner.temp }}/framework-doctor-output.json'; + let data; + try { + data = JSON.parse(fs.readFileSync(path, 'utf8')); + } catch { + core.warning('Could not read Framework Doctor output'); + return; + } + const scoreResult = data.scoreResult; + const score = scoreResult ? scoreResult.score : 'N/A'; + const label = scoreResult ? scoreResult.label : '-'; + const body = `## Framework Doctor + + **Score:** ${score}/100 (${label}) + **Framework:** ${data.doctor || 'unknown'} + **Files scanned:** ${data.totalFilesScanned ?? '-'} + **Time:** ${data.elapsedMilliseconds ? (data.elapsedMilliseconds / 1000).toFixed(1) + 's' : '-'} + `; + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: body.replace(/^ /gm, '') + }); + + - name: Check score threshold + if: inputs.fail-on-low-score == 'true' + env: + OUTPUT_FILE: ${{ runner.temp }}/framework-doctor-output.json + THRESHOLD: ${{ inputs.score-threshold }} + run: | + if [ ! -f "$OUTPUT_FILE" ]; then + echo "Framework Doctor did not produce JSON output" + exit 1 + fi + SCORE=$(node -e " + const d = require(process.env.OUTPUT_FILE); + const s = d?.scoreResult?.score; + console.log(s != null ? s : 0); + ") + if [ "$SCORE" -lt "$THRESHOLD" ]; then + echo "Score $SCORE is below threshold $THRESHOLD" + exit 1 + fi shell: bash