Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
FRAMEWORK_DOCTOR_TELEMETRY_URL=https://your-project-ref.supabase.co/functions/v1/telemetry
FRAMEWORK_DOCTOR_TELEMETRY_KEY=replace-with-shared-secret-if-enabled
1 change: 0 additions & 1 deletion .npmrc
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
shared-workspace-lockfile=true
prefer-workspace-packages=true
auto-install-peers=true
auth-type=web
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ See [examples/README.md](examples/README.md) for more demo projects and commands
- `npx -y @framework-doctor/react .` - run a full scan
- `npx -y @framework-doctor/react ./path/to/project` - scan a specific project directory
- `npx -y @framework-doctor/react . --verbose` - include file and line details
- `npx -y @framework-doctor/react . --score` - print only the numeric score (CI-friendly)

**Svelte (direct):**

Expand All @@ -71,13 +72,14 @@ Options:
--verbose show file details per rule
--score output only the score
-y, --yes skip prompts
--no-analytics disable anonymous analytics
--project <name> select workspace project (comma-separated)
--diff [base] scan only changed files vs base branch
--offline skip remote scoring (local score only)
-h, --help display help for command
```

React doctor options: `--no-lint`, `--no-dead-code`, `--verbose`, `--score`, `--project`, `--diff`. See [packages/react-doctor/README.md](packages/react-doctor/README.md).
React doctor options: `--no-lint`, `--no-dead-code`, `--verbose`, `--score`, `--no-analytics`, `--project`, `--diff`. See [packages/react-doctor/README.md](packages/react-doctor/README.md).

## Security checks

Expand All @@ -91,6 +93,10 @@ Plus oxlint's `no-eval` and svelte-check's `a11y_invalid_attribute` (e.g. `javas

To ignore a rule: `"svelte-doctor/no-at-html"`, `"svelte-doctor/no-new-function"`, `"svelte-doctor/no-implied-eval"`.

## Analytics

Both doctors optionally send anonymous usage data when you opt in. Data is stored in your Supabase (see [supabase/README.md](supabase/README.md)). If your function enforces `TELEMETRY_KEY`, set `FRAMEWORK_DOCTOR_TELEMETRY_KEY` in the client environment. To disable: `--no-analytics`, `"analytics": false` in config, or `DO_NOT_TRACK=1`.

## Configuration

Create `svelte-doctor.config.json`:
Expand All @@ -105,7 +111,8 @@ Create `svelte-doctor.config.json`:
"jsTsLint": true,
"deadCode": true,
"verbose": false,
"diff": false
"diff": false,
"analytics": true
}
```

Expand Down
18 changes: 4 additions & 14 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,7 @@ Demo projects to try Framework Doctor. Each example includes **intentional issue

## Svelte: demo-app

A minimal SvelteKit app with:

- **Security issues** — `eval()`, `new Function()`, `setTimeout("string")` in `src/lib/SecurityTest.ts`
- **Dead code** — Unused exports in `src/lib/orphanUtils.ts` (knip will flag them)
- **Legacy Svelte** — `DoctorTestComponent.svelte` uses export let, createEventDispatcher, onMount
- **XSS** — `{@html}` and `javascript:` URLs in `+page.svelte`
A minimal SvelteKit app with intentional issues. See [svelte/demo-app/README.md](svelte/demo-app/README.md) for details.

### Run from the repo

Expand All @@ -29,22 +24,17 @@ pnpm exec svelte-doctor examples/svelte/demo-app
### Run with npx (no clone)

```bash
# After cloning, from repo root
cd framework-doctor
pnpm install
npx -y @framework-doctor/cli examples/svelte/demo-app
npx -y @framework-doctor/cli /path/to/framework-doctor/examples/svelte/demo-app
```

Or from anywhere with an absolute path:
Or from repo root after `pnpm install`:

```bash
npx -y @framework-doctor/cli /path/to/framework-doctor/examples/svelte/demo-app
npx -y @framework-doctor/cli examples/svelte/demo-app
```

### What to expect

The doctor will report:

- **Errors** — Security findings (eval, new Function, implied eval, {@html}, javascript: URLs)
- **Warnings** — Dead/unused code, lint issues, legacy Svelte patterns
- **Score** — A 0–100 health score for the project
Expand Down
13 changes: 13 additions & 0 deletions examples/svelte/demo-app/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# framework-doctor-svelte-demo

## 1.0.2

### Patch Changes

- Version alignment

## 1.0.1

### Patch Changes

- added telemetry and refactored core
4 changes: 3 additions & 1 deletion examples/svelte/demo-app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ A minimal SvelteKit app with **intentional issues** for testing [Framework Docto

## Run the doctor

From the framework-doctor repo root:
From the framework-doctor repo root (after `pnpm install` and `pnpm build`):

```bash
pnpm exec framework-doctor examples/svelte/demo-app
# or directly:
pnpm exec svelte-doctor examples/svelte/demo-app
```

## Intentional issues
Expand Down
4 changes: 2 additions & 2 deletions examples/svelte/demo-app/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "framework-doctor-svelte-demo",
"version": "1.0.0",
"version": "1.0.2",
"private": true,
"description": "Demo SvelteKit app for Framework Doctor - includes intentional issues",
"type": "module",
Expand All @@ -11,7 +11,7 @@
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^4.0.0",
"@sveltejs/adapter-node": "^4.0.0",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"svelte": "^5.0.0",
Expand Down
2 changes: 1 addition & 1 deletion examples/svelte/demo-app/svelte.config.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import adapter from '@sveltejs/adapter-auto';
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';

/** @type {import('@sveltejs/kit').Config} */
Expand Down
10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,21 @@
"license": "MIT",
"description": "Framework Doctor monorepo - diagnose framework project health",
"scripts": {
"build": "turbo run build",
"build": "turbo run build --filter=./packages/*",
"build:svelte": "turbo run build --filter=@framework-doctor/svelte",
"build:cli": "turbo run build --filter=@framework-doctor/cli",
"demo": "pnpm build && pnpm exec framework-doctor examples/svelte/demo-app",
"dev": "turbo run dev",
"dev:doctor": "turbo run dev --filter=@framework-doctor/svelte",
"format": "prettier --write .",
"format:check": "prettier --check .",
"lint": "turbo run lint",
"lint": "turbo run lint --filter=./packages/*",
"lint:doctor": "turbo run lint --filter=@framework-doctor/svelte",
"lint:fix": "turbo run lint:fix --filter=@framework-doctor/svelte",
"typecheck": "turbo run typecheck",
"test": "turbo run test",
"typecheck": "turbo run typecheck --filter=./packages/*",
"test": "turbo run test --filter=./packages/*",
"test:doctor": "turbo run test --filter=@framework-doctor/svelte --concurrency=1",
"quality:check": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm test",
"quality:check": "turbo run format:check lint typecheck test --filter=!./examples/*",
"changeset": "changeset",
"version": "changeset version",
"release": "pnpm build && changeset publish",
Expand Down
10 changes: 10 additions & 0 deletions packages/cli/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# @framework-doctor/cli

## 1.0.2

### Patch Changes

- Version alignment
- added telemetry and refactored core
- Updated dependencies
- @framework-doctor/svelte@1.0.2
- @framework-doctor/react@1.0.2

## 1.1.0

### Minor Changes
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@framework-doctor/cli",
"version": "1.1.0",
"version": "1.0.2",
"description": "Auto-detect framework and run the right doctor",
"author": {
"name": "Pitis Radu",
Expand Down
13 changes: 13 additions & 0 deletions packages/core/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# @framework-doctor/core

## 1.0.2

### Patch Changes

- Version alignment

## 1.0.1

### Patch Changes

- added telemetry and refactored core
43 changes: 43 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
{
"name": "@framework-doctor/core",
"version": "1.0.2",
"description": "Shared utilities for Framework Doctor (telemetry, config)",
"author": {
"name": "Pitis Radu",
"url": "https://github.com/pitis"
},
"publishConfig": {
"access": "public"
},
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "rimraf dist && cross-env NODE_ENV=production tsdown",
"lint": "oxlint src",
"lint:fix": "oxlint --fix src",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@types/node": "catalog:",
"cross-env": "catalog:",
"oxlint": "catalog:",
"rimraf": "catalog:",
"tsdown": "catalog:",
"typescript": "catalog:"
},
"packageManager": "pnpm@10.30.0",
"dependencies": {
"ora": "catalog:",
"picocolors": "catalog:"
}
}
117 changes: 117 additions & 0 deletions packages/core/src/calculate-score.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import {
ERROR_RULE_PENALTY,
ERROR_VOLUME_COEFFICIENT,
PERFECT_SCORE,
SCORE_BLOCKING_CHECK_CAP,
SCORE_GOOD_THRESHOLD,
SCORE_OK_THRESHOLD,
SPREAD_PENALTY_MAX,
WARNING_RULE_PENALTY,
WARNING_VOLUME_COEFFICIENT,
} from './constants.js';
import type { Diagnostic, ScoreBreakdown, ScoreGuardrailInput, ScoreResult } from './types.js';

const getScoreLabel = (score: number): string => {
if (score >= SCORE_GOOD_THRESHOLD) return 'Great';
if (score >= SCORE_OK_THRESHOLD) return 'Needs work';
return 'Critical';
};

const countMetrics = (
diagnostics: Diagnostic[],
): {
errorCount: number;
warningCount: number;
uniqueErrorRules: number;
uniqueWarningRules: number;
filesWithDiagnostics: number;
} => {
const errorRules = new Set<string>();
const warningRules = new Set<string>();
const filesWithDiagnostics = new Set<string>();

for (const diagnostic of diagnostics) {
filesWithDiagnostics.add(diagnostic.filePath);
const ruleKey = `${diagnostic.plugin}/${diagnostic.rule}`;
if (diagnostic.severity === 'error') {
errorRules.add(ruleKey);
} else {
warningRules.add(ruleKey);
}
}

const errorCount = diagnostics.filter((d) => d.severity === 'error').length;
const warningCount = diagnostics.filter((d) => d.severity === 'warning').length;

return {
errorCount,
warningCount,
uniqueErrorRules: errorRules.size,
uniqueWarningRules: warningRules.size,
filesWithDiagnostics: filesWithDiagnostics.size,
};
};

export const calculateScore = (
diagnostics: Diagnostic[],
totalFilesScanned: number,
guardrailInput: ScoreGuardrailInput = {},
): ScoreResult => {
if (diagnostics.length === 0) {
return {
score: PERFECT_SCORE,
label: getScoreLabel(PERFECT_SCORE),
};
}

const { errorCount, warningCount, uniqueErrorRules, uniqueWarningRules, filesWithDiagnostics } =
countMetrics(diagnostics);

const typesPenalty =
uniqueErrorRules * ERROR_RULE_PENALTY + uniqueWarningRules * WARNING_RULE_PENALTY;
const volumePenalty =
ERROR_VOLUME_COEFFICIENT * Math.sqrt(errorCount) +
WARNING_VOLUME_COEFFICIENT * Math.sqrt(warningCount);
const spreadPenalty =
totalFilesScanned > 0 ? SPREAD_PENALTY_MAX * (filesWithDiagnostics / totalFilesScanned) : 0;

const totalPenalty = typesPenalty + volumePenalty + spreadPenalty;
const uncappedScore = Math.max(
0,
Math.min(PERFECT_SCORE, Math.round(PERFECT_SCORE - totalPenalty)),
);
const guardrailReasons: string[] = [];
if (guardrailInput.didBuildFail) {
guardrailReasons.push('build failed');
}
if (guardrailInput.didTestsFail) {
guardrailReasons.push('tests failed');
}
if (guardrailInput.didTypecheckFail) {
guardrailReasons.push('typecheck failed');
}
if (guardrailInput.hasHighOrCriticalSecurityFindings) {
guardrailReasons.push('high/critical security findings');
}

const didApplyGuardrail = guardrailReasons.length > 0;
const score = didApplyGuardrail
? Math.min(uncappedScore, SCORE_BLOCKING_CHECK_CAP)
: uncappedScore;

const breakdown: ScoreBreakdown = {
typesPenalty,
volumePenalty,
spreadPenalty,
didApplyGuardrail,
guardrailReasons,
uniqueErrorRules,
uniqueWarningRules,
errorCount,
warningCount,
filesWithDiagnostics,
totalFilesScanned,
};

return { score, label: getScoreLabel(score), breakdown };
};
12 changes: 12 additions & 0 deletions packages/core/src/cli-options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export const ANALYTICS_OPTION_FLAGS = '--no-analytics';
export const ANALYTICS_OPTION_DESCRIPTION = 'disable anonymous analytics';

export const ANALYTICS_CONFIG_KEY = 'analytics';

interface ProgramWithOption {
option(flags: string, description: string): unknown;
}

export const addAnalyticsOption = (program: ProgramWithOption): void => {
program.option(ANALYTICS_OPTION_FLAGS, ANALYTICS_OPTION_DESCRIPTION);
};
Loading