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-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
new file mode 100644
index 0000000..84f7154
--- /dev/null
+++ b/.github/workflows/framework-doctor.yml
@@ -0,0 +1,33 @@
+name: Framework Doctor
+
+on:
+ push:
+ 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..1642e57 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,57 @@ 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.
+
+## 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 cf2a65c..1d45462 100644
--- a/action.yml
+++ b/action.yml
@@ -1,50 +1,95 @@
-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'
+ post-to-pr:
+ description: 'Post scan score to PR comment (pull_request only)'
+ required: false
+ default: 'false'
runs:
using: 'composite'
steps:
- - uses: actions/setup-node@v4
- with:
- node-version: ${{ inputs.node-version }}
-
- - shell: bash
+ - name: Run Framework Doctor
+ id: scan
env:
- INPUT_DIRECTORY: ${{ inputs.directory }}
- INPUT_VERBOSE: ${{ inputs.verbose }}
- INPUT_PROJECT: ${{ inputs.project }}
+ OUTPUT_FILE: ${{ runner.temp }}/framework-doctor-output.json
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
+ 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:
- INPUT_DIRECTORY: ${{ inputs.directory }}
+ OUTPUT_FILE: ${{ runner.temp }}/framework-doctor-output.json
+ THRESHOLD: ${{ inputs.score-threshold }}
run: |
- SCORE=$(npx -y @framework-doctor/svelte "$INPUT_DIRECTORY" --score 2>/dev/null || echo "")
- echo "score=$SCORE" >> "$GITHUB_OUTPUT"
+ 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
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 af9eac1..41e7338 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,18 @@ 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:
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/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/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/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/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 e15be18..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",
@@ -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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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",
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/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;
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;