diff --git a/.cursor/skills/framework-doctor/SKILL.md b/.cursor/skills/framework-doctor/SKILL.md index f6ea700..1da7bda 100644 --- a/.cursor/skills/framework-doctor/SKILL.md +++ b/.cursor/skills/framework-doctor/SKILL.md @@ -1,11 +1,11 @@ --- name: framework-doctor description: | - Framework Doctor scans Svelte and React projects for security, performance, correctness, and architecture issues. Outputs a 0-100 score with actionable diagnostics. + Framework Doctor scans Svelte, React, and Vue projects for security, performance, correctness, and architecture issues. Outputs a 0-100 score with actionable diagnostics. - Use when: reviewing code, finishing a feature, fixing bugs, handling user input or HTML rendering, using eval/dynamic code, or when the user mentions XSS, dead code, svelte-check, knip, oxlint, framework-doctor, react-doctor, svelte-doctor. + Use when: reviewing code, finishing a feature, fixing bugs, handling user input or HTML rendering, using eval/dynamic code, or when the user mentions XSS, dead code, svelte-check, knip, oxlint, framework-doctor, react-doctor, svelte-doctor, vue-doctor. - Triggers on: security patterns, {@html}, eval(), new Function(), svelte-check, knip, oxlint, Svelte 5 migration, React best practices. + Triggers on: security patterns, {@html}, v-html, eval(), new Function(), svelte-check, knip, oxlint, Svelte 5 migration, React best practices, Vue/Nuxt patterns. metadata: version: 1.0.0 --- @@ -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 coming soon) ## IMPORTANT: Run After Making Changes @@ -29,6 +29,7 @@ Scan project? ├─ Auto-detect framework → npx -y @framework-doctor/cli . --verbose --diff ├─ 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 └─ What gets checked → references/checks/RULE.md ``` @@ -44,6 +45,15 @@ Svelte guidance? └─ What doctor checks → references/checks/RULE.md ``` +### "I'm working with Vue" + +``` +Vue guidance? +├─ Run doctor → npx -y @framework-doctor/vue . --verbose --diff +├─ Security (v-html) → references/security/vue.md (if exists) +└─ What doctor checks → references/checks/RULE.md +``` + ### "I'm working with React" ``` diff --git a/README.md b/README.md index bc4201a..b4a8463 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![npm version](https://img.shields.io/npm/v/@framework-doctor/cli.svg)](https://www.npmjs.com/package/@framework-doctor/cli) [![npm downloads](https://img.shields.io/npm/dm/@framework-doctor/cli.svg)](https://www.npmjs.com/package/@framework-doctor/cli) -Framework Doctor auto-detects your framework and runs the right health check. Supports **Svelte** and **React**; Vue and Angular coming soon. +Framework Doctor auto-detects your framework and runs the right health check. Supports **Svelte**, **React**, and **Vue**; Angular coming soon. ## Quick start @@ -18,6 +18,7 @@ Or run a specific doctor directly: ```bash npx -y @framework-doctor/react . # React npx -y @framework-doctor/svelte . # Svelte +npx -y @framework-doctor/vue . # Vue ``` ## Try it @@ -47,6 +48,14 @@ See [examples/README.md](examples/README.md) for more demo projects and commands - `npx -y @framework-doctor/react . --verbose` - include file and line details - `npx -y @framework-doctor/react . --score` - print only the numeric score (CI-friendly) +**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 . --diff main` - scan only files changed against `main`. +- `npx -y @framework-doctor/vue . --project web` - select a specific workspace package. + **Svelte (direct):** - `npx -y @framework-doctor/svelte .` - run a full scan @@ -79,7 +88,7 @@ Options: -h, --help display help for command ``` -React doctor options: `--no-lint`, `--no-dead-code`, `--verbose`, `--score`, `--no-analytics`, `--project`, `--diff`. See [packages/react-doctor/README.md](packages/react-doctor/README.md). +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). ## Security checks diff --git a/examples/vue/demo-app/README.md b/examples/vue/demo-app/README.md new file mode 100644 index 0000000..31fc3cd --- /dev/null +++ b/examples/vue/demo-app/README.md @@ -0,0 +1,28 @@ +# Framework Doctor Vue Demo + +A minimal Vue 3 + Vite app with **intentional issues** for testing [Framework Doctor](https://github.com/pitis/framework-doctor). + +## Run the doctor + +From the framework-doctor repo root (after `pnpm install` and `pnpm build`): + +```bash +pnpm exec framework-doctor examples/vue/demo-app +# or directly: +pnpm exec vue-doctor examples/vue/demo-app +``` + +## Intentional issues + +- **Security** — `eval()`, `new Function()`, `setTimeout("string")` in `src/lib/SecurityTest.ts` +- **Dead code** — Unused exports in `src/lib/orphanUtils.ts` +- **v-html** — `v-html` with user content in `App.vue` +- **XSS** — `javascript:` URLs in `App.vue` +- **Accessibility** — div with `role="button"` (no keyboard support), empty anchor links + +## Develop + +```bash +pnpm install +pnpm dev +``` diff --git a/examples/vue/demo-app/index.html b/examples/vue/demo-app/index.html new file mode 100644 index 0000000..392d59c --- /dev/null +++ b/examples/vue/demo-app/index.html @@ -0,0 +1,13 @@ + + + + + + + Framework Doctor Vue Demo + + +
+ + + diff --git a/examples/vue/demo-app/package.json b/examples/vue/demo-app/package.json new file mode 100644 index 0000000..c268cdc --- /dev/null +++ b/examples/vue/demo-app/package.json @@ -0,0 +1,20 @@ +{ + "name": "framework-doctor-vue-demo", + "version": "1.0.0", + "private": true, + "description": "Demo Vue app for Framework Doctor - includes intentional issues", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc --noEmit && vite build", + "preview": "vite preview" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "typescript": "^5.0.0", + "vite": "^6.0.0", + "vue": "^3.0.0", + "vue-tsc": "^2.1.10" + }, + "packageManager": "pnpm@10.30.0" +} diff --git a/examples/vue/demo-app/public/favicon.svg b/examples/vue/demo-app/public/favicon.svg new file mode 100644 index 0000000..426c179 --- /dev/null +++ b/examples/vue/demo-app/public/favicon.svg @@ -0,0 +1 @@ + diff --git a/examples/vue/demo-app/src/App.vue b/examples/vue/demo-app/src/App.vue new file mode 100644 index 0000000..9a4d22e --- /dev/null +++ b/examples/vue/demo-app/src/App.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/examples/vue/demo-app/src/components/DoctorTestComponent.vue b/examples/vue/demo-app/src/components/DoctorTestComponent.vue new file mode 100644 index 0000000..06b6853 --- /dev/null +++ b/examples/vue/demo-app/src/components/DoctorTestComponent.vue @@ -0,0 +1,11 @@ + + + diff --git a/examples/vue/demo-app/src/lib/SecurityTest.ts b/examples/vue/demo-app/src/lib/SecurityTest.ts new file mode 100644 index 0000000..1a9208c --- /dev/null +++ b/examples/vue/demo-app/src/lib/SecurityTest.ts @@ -0,0 +1,13 @@ +/** + * INTENTIONAL SECURITY ISSUES for vue-doctor testing. + * These are dangerous patterns that should be flagged by linters. + */ + +export const dangerousEval = (userInput: string): unknown => eval(userInput); + +export const dangerousFunction = (userCode: string): (() => void) => + new Function(userCode) as () => void; + +export const dangerousTimeout = (): void => { + setTimeout("console.log('arbitrary code')", 100); +}; diff --git a/examples/vue/demo-app/src/lib/orphanUtils.ts b/examples/vue/demo-app/src/lib/orphanUtils.ts new file mode 100644 index 0000000..7c1fbe8 --- /dev/null +++ b/examples/vue/demo-app/src/lib/orphanUtils.ts @@ -0,0 +1,8 @@ +/** + * INTENTIONAL: Unused file for vue-doctor testing. + * This file is not imported anywhere - knip will report it as unused. + */ + +export const ORPHAN_CONSTANT = 42; + +export const orphanHelper = (value: number): number => value * 2; diff --git a/examples/vue/demo-app/src/main.ts b/examples/vue/demo-app/src/main.ts new file mode 100644 index 0000000..8dd6bc1 --- /dev/null +++ b/examples/vue/demo-app/src/main.ts @@ -0,0 +1,5 @@ +import { createApp } from 'vue'; +import App from './App.vue'; +import './style.css'; + +createApp(App).mount('#app'); diff --git a/examples/vue/demo-app/src/style.css b/examples/vue/demo-app/src/style.css new file mode 100644 index 0000000..2887f6a --- /dev/null +++ b/examples/vue/demo-app/src/style.css @@ -0,0 +1,13 @@ +#app { + max-width: 800px; + margin: 0 auto; + padding: 2rem; + font-family: system-ui, sans-serif; +} + +.example { + margin: 1rem 0; + padding: 1rem; + border: 1px solid #e0e0e0; + border-radius: 8px; +} diff --git a/examples/vue/demo-app/src/vite-env.d.ts b/examples/vue/demo-app/src/vite-env.d.ts new file mode 100644 index 0000000..149f067 --- /dev/null +++ b/examples/vue/demo-app/src/vite-env.d.ts @@ -0,0 +1,7 @@ +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue'; + const component: DefineComponent; + export default component; +} diff --git a/examples/vue/demo-app/tsconfig.json b/examples/vue/demo-app/tsconfig.json new file mode 100644 index 0000000..a18b191 --- /dev/null +++ b/examples/vue/demo-app/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/examples/vue/demo-app/tsconfig.node.json b/examples/vue/demo-app/tsconfig.node.json new file mode 100644 index 0000000..494bfe0 --- /dev/null +++ b/examples/vue/demo-app/tsconfig.node.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler" + }, + "include": ["vite.config.ts"] +} diff --git a/examples/vue/demo-app/vite.config.d.ts b/examples/vue/demo-app/vite.config.d.ts new file mode 100644 index 0000000..340562a --- /dev/null +++ b/examples/vue/demo-app/vite.config.d.ts @@ -0,0 +1,2 @@ +declare const _default: import("vite").UserConfig; +export default _default; diff --git a/examples/vue/demo-app/vite.config.js b/examples/vue/demo-app/vite.config.js new file mode 100644 index 0000000..2e60cbd --- /dev/null +++ b/examples/vue/demo-app/vite.config.js @@ -0,0 +1,5 @@ +import { defineConfig } from 'vite'; +import vue from '@vitejs/plugin-vue'; +export default defineConfig({ + plugins: [vue()], +}); diff --git a/examples/vue/demo-app/vite.config.ts b/examples/vue/demo-app/vite.config.ts new file mode 100644 index 0000000..1ebc4fc --- /dev/null +++ b/examples/vue/demo-app/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite'; +import vue from '@vitejs/plugin-vue'; + +export default defineConfig({ + plugins: [vue()], +}); diff --git a/package.json b/package.json index d0158d2..a5f2f52 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "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", + "demo:svelte": "pnpm build && pnpm exec framework-doctor examples/svelte/demo-app", + "demo:vue": "pnpm build && pnpm exec framework-doctor examples/vue/demo-app", "dev": "turbo run dev", "dev:doctor": "turbo run dev --filter=@framework-doctor/svelte", "format": "prettier --write .", diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index ad913b3..2ca93fb 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,22 @@ # @framework-doctor/cli +## 1.0.3 + +### Patch Changes + +- vue doctor +- Updated dependencies + - @framework-doctor/svelte@1.0.3 + - @framework-doctor/react@1.0.3 + - @framework-doctor/vue@1.0.3 + +## Unreleased + +### Minor Changes + +- Add Vue support: auto-detect vue/nuxt and run @framework-doctor/vue +- Remove Vue from "coming soon"; Angular remains coming soon + ## 1.0.2 ### Patch Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index 5f36e2d..52e9abd 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@framework-doctor/cli", - "version": "1.0.2", + "version": "1.0.3", "description": "Auto-detect framework and run the right doctor", "author": { "name": "Pitis Radu", @@ -46,7 +46,8 @@ }, "dependencies": { "@framework-doctor/svelte": "workspace:*", - "@framework-doctor/react": "workspace:*" + "@framework-doctor/react": "workspace:*", + "@framework-doctor/vue": "workspace:*" }, "devDependencies": { "@types/node": "catalog:", diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index a85a088..2ab9e55 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -57,9 +57,19 @@ const runDoctor = (framework: Framework, args: string[]): number => { return result.status ?? 1; } - if (framework === 'vue' || framework === 'angular') { + if (framework === 'vue') { + const cliPath = resolveDoctorCli('@framework-doctor/vue'); + const fullArgs = [dirArg, ...restArgs]; + const result = spawnSync(process.execPath, [cliPath, ...fullArgs], { + stdio: 'inherit', + cwd, + }); + return result.status ?? 1; + } + + if (framework === 'angular') { console.error( - `\n ${framework} Doctor is coming soon. For now, use the framework-specific package when available.\n`, + `\n Angular Doctor is coming soon. For now, use the framework-specific package when available.\n`, ); return 1; } @@ -67,7 +77,7 @@ const runDoctor = (framework: Framework, args: string[]): number => { console.error(` Could not detect a supported framework in ${dirArg}. - Supported: Svelte, React, Vue (coming soon), Angular (coming soon) + Supported: Svelte, React, Vue, Angular (coming soon) Make sure you're in a project root with a package.json that includes: - Svelte: "svelte" or "@sveltejs/kit" @@ -78,6 +88,7 @@ const runDoctor = (framework: Framework, args: string[]): number => { Or run a specific doctor directly: - npx @framework-doctor/svelte . - npx @framework-doctor/react . + - npx @framework-doctor/vue . `); return 1; }; diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 4438a9f..e483ca7 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,11 @@ # @framework-doctor/core +## 1.0.3 + +### Patch Changes + +- vue doctor + ## 1.0.2 ### Patch Changes diff --git a/packages/core/package.json b/packages/core/package.json index 3ab8af2..1c6f080 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@framework-doctor/core", - "version": "1.0.2", + "version": "1.0.3", "description": "Shared utilities for Framework Doctor (telemetry, config)", "author": { "name": "Pitis Radu", diff --git a/packages/core/src/get-diff-files.ts b/packages/core/src/get-diff-files.ts index 819a9f0..6f0313b 100644 --- a/packages/core/src/get-diff-files.ts +++ b/packages/core/src/get-diff-files.ts @@ -108,6 +108,8 @@ export const SOURCE_FILE_PATTERN_SVELTE = /\.(svelte|ts|tsx|js|jsx|mts|cts|mjs|c export const SOURCE_FILE_PATTERN_REACT = /\.(tsx?|jsx?)$/; +export const SOURCE_FILE_PATTERN_VUE = /\.(vue|ts|tsx|js|jsx|mts|cts|mjs|cjs)$/; + export const filterSourceFiles = ( filePaths: string[], pattern: RegExp = SOURCE_FILE_PATTERN_JS_TS, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5117003..c490ce2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -24,6 +24,7 @@ export { SOURCE_FILE_PATTERN_JS_TS, SOURCE_FILE_PATTERN_REACT, SOURCE_FILE_PATTERN_SVELTE, + SOURCE_FILE_PATTERN_VUE, filterSourceFiles, getDiffInfo, } from './get-diff-files.js'; @@ -37,6 +38,8 @@ export { loadConfig } from './load-config.js'; export { DANGEROUSLY_SET_INNER_HTML_RULE, NO_AT_HTML_RULE, + NO_V_HTML_RULE, + SOURCE_FILE_PATTERN_WITH_VUE, UNIVERSAL_SECURITY_RULES, getFilesToScan, runSecurityScan, diff --git a/packages/core/src/security/get-files-to-scan.ts b/packages/core/src/security/get-files-to-scan.ts index 006a677..80799b5 100644 --- a/packages/core/src/security/get-files-to-scan.ts +++ b/packages/core/src/security/get-files-to-scan.ts @@ -2,7 +2,9 @@ import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { GIT_LS_FILES_MAX_BUFFER_BYTES } from '../constants.js'; -const SOURCE_FILE_PATTERN_FULL = /\.(svelte|ts|tsx|js|jsx|mts|cts|mjs|cjs)$/; +export const SOURCE_FILE_PATTERN_FULL = /\.(svelte|ts|tsx|js|jsx|mts|cts|mjs|cjs)$/; + +export const SOURCE_FILE_PATTERN_WITH_VUE = /\.(vue|svelte|ts|tsx|js|jsx|mts|cts|mjs|cjs)$/; export const getFilesToScan = ( rootDirectory: string, diff --git a/packages/core/src/security/index.ts b/packages/core/src/security/index.ts index fed6642..aa78a72 100644 --- a/packages/core/src/security/index.ts +++ b/packages/core/src/security/index.ts @@ -1,4 +1,4 @@ -export { getFilesToScan } from './get-files-to-scan.js'; +export { SOURCE_FILE_PATTERN_WITH_VUE, getFilesToScan } from './get-files-to-scan.js'; export { DANGEROUSLY_SET_INNER_HTML_RULE } from './react-rules.js'; export type { SecurityRule } from './rule.js'; export { runSecurityScan } from './run-security-scan.js'; @@ -11,3 +11,4 @@ export { NO_NEW_FUNCTION_RULE, UNIVERSAL_SECURITY_RULES, } from './universal-rules.js'; +export { NO_V_HTML_RULE } from './vue-rules.js'; diff --git a/packages/core/src/security/run-security-scan.ts b/packages/core/src/security/run-security-scan.ts index 2101af5..c758145 100644 --- a/packages/core/src/security/run-security-scan.ts +++ b/packages/core/src/security/run-security-scan.ts @@ -1,7 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import type { Diagnostic } from '../types.js'; -import { getFilesToScan } from './get-files-to-scan.js'; +import { getFilesToScan, SOURCE_FILE_PATTERN_FULL } from './get-files-to-scan.js'; import type { SecurityRule } from './rule.js'; const findMatches = (content: string, regex: RegExp): Array<{ line: number; column: number }> => { @@ -26,6 +26,7 @@ const ruleAppliesToFile = (rule: SecurityRule, filePath: string): boolean => { export interface RunSecurityScanOptions { plugin: string; rules: SecurityRule[]; + filePattern?: RegExp; } export const runSecurityScan = async ( @@ -33,7 +34,8 @@ export const runSecurityScan = async ( includePaths: string[], options: RunSecurityScanOptions, ): Promise => { - const files = getFilesToScan(rootDirectory, includePaths); + const pattern = options.filePattern ?? SOURCE_FILE_PATTERN_FULL; + const files = getFilesToScan(rootDirectory, includePaths, pattern); const diagnostics: Diagnostic[] = []; for (const filePath of files) { diff --git a/packages/core/src/security/vue-rules.ts b/packages/core/src/security/vue-rules.ts new file mode 100644 index 0000000..34ddd36 --- /dev/null +++ b/packages/core/src/security/vue-rules.ts @@ -0,0 +1,10 @@ +import type { SecurityRule } from './rule.js'; + +export const NO_V_HTML_RULE: SecurityRule = { + id: 'no-v-html', + pattern: /v-html\s*=/g, + message: 'Raw HTML via v-html can lead to XSS if content is unsanitized.', + help: 'Sanitize user-controlled content (e.g. with DOMPurify) or avoid v-html for untrusted input.', + severity: 'error', + fileExtensions: ['.vue'], +}; diff --git a/packages/react-doctor/CHANGELOG.md b/packages/react-doctor/CHANGELOG.md index 93cda20..786a357 100644 --- a/packages/react-doctor/CHANGELOG.md +++ b/packages/react-doctor/CHANGELOG.md @@ -1,5 +1,19 @@ # react-doctor +## 1.0.3 + +### Patch Changes + +- vue doctor +- Updated dependencies + - @framework-doctor/core@1.0.3 + +## Unreleased + +### Patch Changes + +- Add --offline flag for parity with svelte-doctor + ## 1.0.2 ### Patch Changes diff --git a/packages/react-doctor/package.json b/packages/react-doctor/package.json index 1cf1b58..22be9ec 100644 --- a/packages/react-doctor/package.json +++ b/packages/react-doctor/package.json @@ -1,6 +1,6 @@ { "name": "@framework-doctor/react", - "version": "1.0.2", + "version": "1.0.3", "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 e0ec896..67411dd 100644 --- a/packages/react-doctor/src/cli.ts +++ b/packages/react-doctor/src/cli.ts @@ -31,6 +31,7 @@ interface CliFlags { analytics: boolean; project?: string; diff?: boolean | string; + offline?: boolean; } const exitWithCancelHint = () => { @@ -105,7 +106,8 @@ const program = new Command() .option('--score', 'output only the score') .option('-y, --yes', 'skip prompts, scan all workspace projects') .option('--project ', 'select workspace project (comma-separated for multiple)') - .option('--diff [base]', 'scan only files changed vs base branch'); + .option('--diff [base]', 'scan only files changed vs base branch') + .option('--offline', 'skip remote scoring (local score only)'); addAnalyticsOption(program); diff --git a/packages/react-doctor/src/scan.ts b/packages/react-doctor/src/scan.ts index 05661d9..095d2d9 100644 --- a/packages/react-doctor/src/scan.ts +++ b/packages/react-doctor/src/scan.ts @@ -179,7 +179,7 @@ const printBranding = (score?: number): void => { logger.log(colorize(` │ ${mouth} │`)); logger.log(colorize(' └─────┘')); } - logger.log(` React Doctor ${highlighter.dim('(github.com/pitis/framework-doctor)')}`); + logger.log(' React Doctor'); logger.break(); }; @@ -198,12 +198,7 @@ const buildBrandingLines = ( lines.push(createFramedLine(`│ ${eyes} │`, scoreColorizer(`│ ${eyes} │`))); lines.push(createFramedLine(`│ ${mouth} │`, scoreColorizer(`│ ${mouth} │`))); lines.push(createFramedLine('└─────┘', scoreColorizer('└─────┘'))); - lines.push( - createFramedLine( - 'React Doctor (github.com/pitis/framework-doctor)', - `React Doctor ${highlighter.dim('(github.com/pitis/framework-doctor)')}`, - ), - ); + lines.push(createFramedLine('React Doctor', 'React Doctor')); lines.push(createFramedLine('')); const scoreLinePlainText = `${scoreResult.score} / ${PERFECT_SCORE} ${scoreResult.label}`; @@ -218,12 +213,7 @@ const buildBrandingLines = ( } lines.push(createFramedLine('')); } else { - lines.push( - createFramedLine( - 'React Doctor (github.com/pitis/framework-doctor)', - `React Doctor ${highlighter.dim('(github.com/pitis/framework-doctor)')}`, - ), - ); + lines.push(createFramedLine('React Doctor', 'React Doctor')); lines.push(createFramedLine('')); lines.push(createFramedLine(noScoreMessage, highlighter.dim(noScoreMessage))); lines.push(createFramedLine('')); diff --git a/packages/svelte-doctor/CHANGELOG.md b/packages/svelte-doctor/CHANGELOG.md index 24fce96..7ddc2e1 100644 --- a/packages/svelte-doctor/CHANGELOG.md +++ b/packages/svelte-doctor/CHANGELOG.md @@ -1,5 +1,23 @@ # svelte-doctor +## 1.0.3 + +### Patch Changes + +- vue doctor +- Updated dependencies + - @framework-doctor/core@1.0.3 + +## Unreleased + +### Minor Changes + +- Add security scan to `scan()` so `diagnose()` matches CLI +- Add selectProjects, --project multi-project support +- Add maybePromptSkillInstall, handleError, checkReducedMotion +- Add writeDiagnosticsDirectory, addHelpText, resolveDiffMode prompt +- Refactor CLI to use `scan()` for single source of truth + ## 1.0.2 ### Patch Changes diff --git a/packages/svelte-doctor/package.json b/packages/svelte-doctor/package.json index c1cbd4f..3dd0342 100644 --- a/packages/svelte-doctor/package.json +++ b/packages/svelte-doctor/package.json @@ -1,6 +1,6 @@ { "name": "@framework-doctor/svelte", - "version": "1.0.2", + "version": "1.0.3", "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 950283d..c7c65f1 100644 --- a/packages/svelte-doctor/src/cli.ts +++ b/packages/svelte-doctor/src/cli.ts @@ -13,26 +13,30 @@ import { logger, PERFECT_SCORE, printFramedBox, - spinner, } from '@framework-doctor/core'; import { Command } from 'commander'; import path from 'node:path'; import { performance } from 'node:perf_hooks'; -import type { Diagnostic, ScanOptions, ScoreResult, SvelteDoctorConfig } from './types.js'; -import { discoverProject } from './utils/discover-project.js'; -import { filterIgnoredDiagnostics } from './utils/filter-diagnostics.js'; +import prompts from 'prompts'; +import { scan } from './scan.js'; +import type { + Diagnostic, + DiffInfo, + ScanOptions, + ScoreResult, + SvelteDoctorConfig, +} from './types.js'; import { filterSourceFiles, getDiffInfo } from './utils/get-diff-files.js'; +import { handleError } from './utils/handle-error.js'; import { loadConfig } from './utils/load-config.js'; -import { runKnip } from './utils/run-knip.js'; -import { runOxlint } from './utils/run-oxlint.js'; -import { runSecurityScan } from './utils/run-security-scan.js'; -import { runSvelteCheck } from './utils/run-svelte-check.js'; -import { calculateScore } from './utils/score.js'; +import { selectProjects } from './utils/select-projects.js'; +import { maybePromptSkillInstall } from './utils/skill-prompt.js'; import { maybePromptAnalyticsConsent, sendScanEvent, shouldSendAnalytics, } from './utils/telemetry.js'; +import { writeDiagnosticsDirectory } from './utils/write-diagnostics-dir.js'; const VERSION = process.env.VERSION ?? '0.0.0'; @@ -62,19 +66,14 @@ const sortBySeverity = (groups: [string, Diagnostic[]][]): [string, Diagnostic[] const buildFileLineMap = (diagnostics: Diagnostic[]): Map => { const map = new Map(); - for (const d of diagnostics) { - const lines = map.get(d.filePath) ?? []; - if (d.line > 0) lines.push(d.line); - map.set(d.filePath, lines); + for (const diagnostic of diagnostics) { + const lines = map.get(diagnostic.filePath) ?? []; + if (diagnostic.line > 0) lines.push(diagnostic.line); + map.set(diagnostic.filePath, lines); } return map; }; -const hasHighOrCriticalSecurityFindings = (diagnostics: Diagnostic[]): boolean => - diagnostics.some( - (diagnostic) => diagnostic.category === 'security' && diagnostic.severity === 'error', - ); - const printRuleGroup = (ruleDiagnostics: Diagnostic[], verbose: boolean): void => { const first = ruleDiagnostics[0]; const icon = colorizeBySeverity(first.severity === 'error' ? '✗' : '⚠', first.severity); @@ -96,7 +95,10 @@ const printRuleGroup = (ruleDiagnostics: Diagnostic[], verbose: boolean): void = }; const printDiagnostics = (diagnostics: Diagnostic[], verbose: boolean): void => { - const ruleGroups = groupBy(diagnostics, (d) => `${d.plugin}/${d.rule}`); + const ruleGroups = groupBy( + diagnostics, + (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`, + ); const sortedGroups = sortBySeverity([...ruleGroups.entries()]); for (const [, ruleDiagnostics] of sortedGroups) { @@ -122,7 +124,7 @@ const printSummary = ( createFramedLine(`│ ${eyes} │`, colorize(`│ ${eyes} │`)), createFramedLine(`│ ${mouth} │`, colorize(`│ ${mouth} │`)), createFramedLine('└─────┘', colorize('└─────┘')), - createFramedLine('Svelte Doctor (local)', `Svelte Doctor ${highlighter.dim('(local)')}`), + createFramedLine('Svelte Doctor', 'Svelte Doctor'), createFramedLine(''), createFramedLine( `${score} / ${PERFECT_SCORE} ${label}`, @@ -141,70 +143,49 @@ const printSummary = ( printFramedBox(framedLines); }; -const applyDiffMode = (rootDirectory: string, flags: CliFlags, scanOptions: ScanOptions): void => { - if (flags.diff === undefined || flags.diff === false) return; - const base = typeof flags.diff === 'string' ? flags.diff : 'main'; - const diff = getDiffInfo(rootDirectory, base); - if (!diff) return; - scanOptions.includePaths = filterSourceFiles(diff.changedFiles); -}; - -const printDetection = ( - frameworkLabel: string, - svelteVersion: string, - languageLabel: string, - sourceFileCount: number, - changedFileCount: number | undefined, - hasConfig: boolean, -): void => { - spinner('Detecting framework...') - .start() - .succeed(`Detecting framework. Found ${highlighter.info(frameworkLabel)}.`); - - const versionLabel = `Svelte ${svelteVersion}`; - spinner('Detecting Svelte version...') - .start() - .succeed(`Detecting Svelte version. Found ${highlighter.info(versionLabel)}.`); - - spinner('Detecting language...') - .start() - .succeed(`Detecting language. Found ${highlighter.info(languageLabel)}.`); - - if (typeof changedFileCount === 'number') { - spinner('Detecting scan scope...') - .start() - .succeed(`Scanning ${highlighter.info(String(changedFileCount))} changed source files.`); - } else { - spinner('Counting source files...') - .start() - .succeed(`Found ${highlighter.info(String(sourceFileCount))} source files.`); - } - - if (hasConfig) { - spinner('Loading config...') - .start() - .succeed(`Loaded ${highlighter.info('svelte-doctor config')}.`); +const resolveDiffMode = async ( + diffInfo: DiffInfo | null, + effectiveDiff: boolean | string | undefined, + shouldSkipPrompts: boolean, + isScoreOnly: boolean, +): Promise => { + if (effectiveDiff !== undefined && effectiveDiff !== false) { + if (diffInfo) return true; + if (!isScoreOnly) { + logger.warn('No feature branch or uncommitted changes detected. Running full scan.'); + logger.break(); + } + return false; } - logger.break(); -}; - -const runNonFatal = async ( - startText: string, - successText: string, - failText: string, - fn: () => Promise, -): Promise => { - const s = spinner(startText).start(); - try { - const diagnostics = await fn(); - s.succeed(successText); - return diagnostics; - } catch (error) { - s.fail(failText); - logger.dim(String(error)); - return []; - } + if (effectiveDiff === false || !diffInfo) return false; + + const changedSourceFiles = filterSourceFiles(diffInfo.changedFiles); + if (changedSourceFiles.length === 0) return false; + if (shouldSkipPrompts) return true; + if (isScoreOnly) return false; + + const promptMessage = diffInfo.isCurrentChanges + ? `Found ${changedSourceFiles.length} uncommitted changed files. Only scan current changes?` + : `On branch ${diffInfo.currentBranch} (${changedSourceFiles.length} changed files vs ${diffInfo.baseBranch}). Only scan this branch?`; + + const { shouldScanChangedOnly } = await prompts( + { + type: 'confirm', + name: 'shouldScanChangedOnly', + message: promptMessage, + initial: true, + }, + { + onCancel: () => { + logger.break(); + logger.log('Cancelled.'); + logger.break(); + process.exit(0); + }, + }, + ); + return Boolean(shouldScanChangedOnly); }; const resolveScanOptions = ( @@ -221,6 +202,16 @@ const resolveScanOptions = ( }; }; +const exitWithCancelHint = () => { + logger.break(); + logger.log('Cancelled.'); + logger.break(); + process.exit(0); +}; + +process.on('SIGINT', exitWithCancelHint); +process.on('SIGTERM', exitWithCancelHint); + const main = new Command() .name('svelte-doctor') .description('Diagnose Svelte codebase health') @@ -231,7 +222,7 @@ const main = new Command() .option('--no-dead-code', 'skip dead code detection') .option('--verbose', 'show file details per rule') .option('--score', 'output only the score') - .option('-y, --yes', 'skip prompts'); + .option('-y, --yes', 'skip prompts, scan all workspace projects'); addAnalyticsOption(main); @@ -240,133 +231,160 @@ main .option('--diff [base]', 'scan only files changed vs base branch') .option('--offline', 'skip remote scoring (local score only)') .action(async (directory: string, flags: CliFlags) => { - const startTime = performance.now(); - const resolvedDirectory = path.resolve(directory); - const config = loadConfig(resolvedDirectory); - const scanOptions = resolveScanOptions(flags, config, main); - const isScoreOnly = flags.score; - const isAutomated = isAutomatedEnvironment(); - const shouldSkipPrompts = flags.yes || isAutomated || !process.stdin.isTTY; - - logger.log(`svelte-doctor v${VERSION}`); - logger.break(); - - if (!isScoreOnly && !isAutomated && !flags.yes) { - await maybePromptAnalyticsConsent(shouldSkipPrompts); - } - - applyDiffMode(resolvedDirectory, flags, scanOptions); - const projectInfo = discoverProject(resolvedDirectory); - if (!projectInfo.svelteVersion) { - throw new Error('No Svelte dependency found in package.json'); - } - - const languageLabel = projectInfo.hasTypeScript ? 'TypeScript' : 'JavaScript'; - const frameworkLabel = projectInfo.framework === 'sveltekit' ? 'SvelteKit' : 'Svelte'; - - const includePaths = scanOptions.includePaths ?? []; - const changedCount = includePaths.length > 0 ? includePaths.length : undefined; - printDetection( - frameworkLabel, - projectInfo.svelteVersion, - languageLabel, - projectInfo.sourceFileCount, - changedCount, - Boolean(config), - ); - - const sveltePromise = scanOptions.lint - ? runNonFatal( - 'Running Svelte checks...', - 'Running Svelte checks.', - 'Svelte checks failed (non-fatal, skipping).', - () => runSvelteCheck(resolvedDirectory, includePaths, projectInfo.svelteVersion ?? ''), - ) - : Promise.resolve([]); - - const jsTsPromise = scanOptions.jsTsLint - ? runNonFatal( - 'Running JS/TS lint...', - 'Running JS/TS lint.', - 'JS/TS lint failed (non-fatal, skipping).', - () => runOxlint(resolvedDirectory, projectInfo.hasTypeScript, includePaths), - ) - : Promise.resolve([]); - - const securityPromise = scanOptions.lint - ? runNonFatal( - 'Running security checks...', - 'Running security checks.', - 'Security checks failed (non-fatal, skipping).', - () => runSecurityScan(resolvedDirectory, includePaths), - ) - : Promise.resolve([]); - - const deadCodePromise = - scanOptions.deadCode && includePaths.length === 0 - ? runNonFatal( - 'Detecting dead code...', - 'Detecting dead code.', - 'Dead code detection failed (non-fatal, skipping).', - () => runKnip(resolvedDirectory), + try { + const resolvedDirectory = path.resolve(directory); + const config = loadConfig(resolvedDirectory); + const scanOptions = resolveScanOptions(flags, config, main); + + const isAutomated = isAutomatedEnvironment(); + const shouldSkipPrompts = flags.yes || isAutomated || !process.stdin.isTTY; + + if (!isScoreOnly) { + logger.log(`svelte-doctor v${VERSION}`); + logger.break(); + } + + const projectDirectories = await selectProjects( + resolvedDirectory, + flags.project, + shouldSkipPrompts, + ); + + const isDiffCliOverride = main.getOptionValueSource('diff') === 'cli'; + const effectiveDiff = isDiffCliOverride ? flags.diff : config?.diff; + const explicitBaseBranch = typeof effectiveDiff === 'string' ? effectiveDiff : undefined; + const diffInfo = getDiffInfo(resolvedDirectory, explicitBaseBranch); + const isDiffMode = await resolveDiffMode( + diffInfo, + effectiveDiff, + shouldSkipPrompts, + isScoreOnly, + ); + + if (isDiffMode && diffInfo && !isScoreOnly) { + if (diffInfo.isCurrentChanges) { + logger.log('Scanning uncommitted changes'); + } else { + logger.log( + `Scanning changes: ${highlighter.info(diffInfo.currentBranch)} → ${highlighter.info(diffInfo.baseBranch)}`, + ); + } + logger.break(); + } + + if (!isScoreOnly && !isAutomated && !flags.yes) { + await maybePromptAnalyticsConsent(shouldSkipPrompts); + } + + const allDiagnostics: Diagnostic[] = []; + const telemetryUrl = process.env.FRAMEWORK_DOCTOR_TELEMETRY_URL ?? ''; + + for (const projectDirectory of projectDirectories) { + let includePaths: string[] | undefined; + if (isDiffMode) { + const projectDiffInfo = getDiffInfo(projectDirectory, explicitBaseBranch); + if (projectDiffInfo) { + const changedSourceFiles = filterSourceFiles(projectDiffInfo.changedFiles); + if (changedSourceFiles.length === 0) { + if (!isScoreOnly) { + logger.dim(`No changed source files in ${projectDirectory}, skipping.`); + logger.break(); + } + continue; + } + includePaths = changedSourceFiles; + } + } + + if (!isScoreOnly) { + logger.dim(`Scanning ${projectDirectory}...`); + logger.break(); + } + + const startTime = performance.now(); + const result = await scan(projectDirectory, { + ...scanOptions, + includePaths: includePaths ?? [], + }); + const elapsedMs = performance.now() - startTime; + + allDiagnostics.push(...result.diagnostics); + + if ( + telemetryUrl && + result.scoreResult && + shouldSendAnalytics( + { analytics: flags.analytics, yes: flags.yes }, + config?.analytics, + isAutomated, ) - : Promise.resolve([]); - - const [svelteDiagnostics, jsTsDiagnostics, securityDiagnostics, deadCodeDiagnostics] = - await Promise.all([sveltePromise, jsTsPromise, securityPromise, deadCodePromise]); - - const diagnostics = filterIgnoredDiagnostics( - [...svelteDiagnostics, ...jsTsDiagnostics, ...securityDiagnostics, ...deadCodeDiagnostics], - config, - ); - - const hasIncludePaths = (scanOptions.includePaths?.length ?? 0) > 0; - const totalSourceFileCount = hasIncludePaths - ? scanOptions.includePaths!.length - : projectInfo.sourceFileCount; - const scoreResult = calculateScore(diagnostics, totalSourceFileCount, { - hasHighOrCriticalSecurityFindings: hasHighOrCriticalSecurityFindings(diagnostics), - }); - - const telemetryUrl = process.env.FRAMEWORK_DOCTOR_TELEMETRY_URL ?? ''; - const isDiffMode = (scanOptions.includePaths?.length ?? 0) > 0; - if ( - telemetryUrl && - shouldSendAnalytics( - { analytics: flags.analytics, yes: flags.yes }, - config?.analytics, - isAutomated, - ) - ) { - sendScanEvent(telemetryUrl, projectInfo, scoreResult, diagnostics.length, { - isDiffMode, - cliVersion: VERSION, - }); - } - - if (flags.score) { - logger.log(`${scoreResult.score}`); - return; - } - - const elapsedMs = performance.now() - startTime; - - if (diagnostics.length === 0) { - logger.success('No issues found!'); - } else { - printDiagnostics(diagnostics, Boolean(scanOptions.verbose)); + ) { + sendScanEvent( + telemetryUrl, + result.projectInfo, + result.scoreResult, + result.diagnostics.length, + { + isDiffMode: Boolean(includePaths?.length), + cliVersion: VERSION, + }, + ); + } + + if (flags.score) { + if (result.scoreResult) { + logger.log(`${result.scoreResult.score}`); + } + continue; + } + + if (result.diagnostics.length === 0) { + logger.success('No issues found!'); + } else { + printDiagnostics(result.diagnostics, Boolean(scanOptions.verbose)); + } + + logger.break(); + const totalSourceFileCount = + (includePaths?.length ?? 0) > 0 + ? (includePaths?.length ?? 0) + : result.projectInfo.sourceFileCount; + printSummary( + result.scoreResult, + result.diagnostics, + totalSourceFileCount, + elapsedMs, + Boolean(scanOptions.verbose), + ); + + try { + const diagnosticsDirectory = writeDiagnosticsDirectory(result.diagnostics); + logger.break(); + logger.dim(` Full diagnostics written to ${diagnosticsDirectory}`); + } catch { + logger.break(); + } + + if (!isScoreOnly) { + logger.break(); + } + } + + if (!isScoreOnly && !shouldSkipPrompts) { + await maybePromptSkillInstall(shouldSkipPrompts); + } + } catch (error) { + handleError(error); } + }) + .addHelpText( + 'after', + ` +${highlighter.dim('Learn more:')} + ${highlighter.info('https://github.com/pitis/framework-doctor')} +`, + ); - logger.break(); - printSummary( - scoreResult, - diagnostics, - totalSourceFileCount, - elapsedMs, - Boolean(scanOptions.verbose), - ); - }); - -await main.parseAsync(); +main.parseAsync(); diff --git a/packages/svelte-doctor/src/scan.ts b/packages/svelte-doctor/src/scan.ts index dd97247..168bc3a 100644 --- a/packages/svelte-doctor/src/scan.ts +++ b/packages/svelte-doctor/src/scan.ts @@ -1,9 +1,11 @@ import type { Diagnostic, ScanOptions, ScanResult, SvelteDoctorConfig } from './types.js'; +import { checkReducedMotion } from './utils/check-reduced-motion.js'; import { discoverProject } from './utils/discover-project.js'; import { filterIgnoredDiagnostics } from './utils/filter-diagnostics.js'; import { loadConfig } from './utils/load-config.js'; import { runKnip } from './utils/run-knip.js'; import { runOxlint } from './utils/run-oxlint.js'; +import { runSecurityScan } from './utils/run-security-scan.js'; import { runSvelteCheck } from './utils/run-svelte-check.js'; import { calculateScore } from './utils/score.js'; @@ -69,17 +71,42 @@ export const scan = async (directory: string, options: ScanOptions = {}): Promis } } + let securityDiagnostics: Diagnostic[] = []; + if (resolved.lint) { + try { + securityDiagnostics = await runSecurityScan(directory, resolved.includePaths); + } catch { + skippedChecks.push('security'); + } + } + + const reducedMotionDiagnostics = + resolved.includePaths.length === 0 ? checkReducedMotion(directory) : []; + const diagnostics = filterIgnoredDiagnostics( - [...lintDiagnostics, ...jsTsLintDiagnostics, ...deadCodeDiagnostics], + [ + ...lintDiagnostics, + ...jsTsLintDiagnostics, + ...deadCodeDiagnostics, + ...securityDiagnostics, + ...reducedMotionDiagnostics, + ], userConfig, ); const totalFilesScanned = resolved.includePaths.length > 0 ? resolved.includePaths.length : projectInfo.sourceFileCount; + const hasHighOrCriticalSecurityFindings = diagnostics.some( + (diagnostic) => diagnostic.category === 'security' && diagnostic.severity === 'error', + ); + return { diagnostics, - scoreResult: calculateScore(diagnostics, totalFilesScanned), + scoreResult: calculateScore(diagnostics, totalFilesScanned, { + hasHighOrCriticalSecurityFindings, + }), skippedChecks, + projectInfo, }; }; diff --git a/packages/svelte-doctor/src/types.ts b/packages/svelte-doctor/src/types.ts index 61d9159..f0b2208 100644 --- a/packages/svelte-doctor/src/types.ts +++ b/packages/svelte-doctor/src/types.ts @@ -13,6 +13,7 @@ export interface ProjectInfo { export type { Diagnostic, + DiffInfo, IgnoreConfig, ScoreGuardrailInput, ScoreResult, @@ -31,6 +32,24 @@ export interface ScanResult { diagnostics: Diagnostic[]; scoreResult: ScoreResult; skippedChecks: string[]; + projectInfo: ProjectInfo; +} + +export interface WorkspacePackage { + name: string; + directory: string; +} + +export interface HandleErrorOptions { + shouldExit: boolean; +} + +export interface PackageJson { + name?: string; + dependencies?: Record; + devDependencies?: Record; + peerDependencies?: Record; + workspaces?: string[] | { packages: string[] }; } export interface SvelteDoctorConfig extends BaseDoctorConfig { diff --git a/packages/svelte-doctor/src/utils/check-reduced-motion.ts b/packages/svelte-doctor/src/utils/check-reduced-motion.ts new file mode 100644 index 0000000..d5ac3b5 --- /dev/null +++ b/packages/svelte-doctor/src/utils/check-reduced-motion.ts @@ -0,0 +1,52 @@ +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import type { Diagnostic } from '../types.js'; +import { readPackageJson } from './read-package-json.js'; + +const MOTION_LIBRARY_PACKAGES = new Set(['framer-motion', 'motion']); +const REDUCED_MOTION_GREP_PATTERN = 'prefers-reduced-motion|useReducedMotion'; +const REDUCED_MOTION_FILE_GLOBS = '"*.svelte" "*.ts" "*.tsx" "*.js" "*.jsx" "*.css" "*.scss"'; + +const MISSING_REDUCED_MOTION_DIAGNOSTIC: Diagnostic = { + filePath: 'package.json', + plugin: 'svelte-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) => + MOTION_LIBRARY_PACKAGES.has(packageName), + ); + } catch { + return []; + } + if (!hasMotionLibrary) return []; + + try { + execSync(`git grep -ql -E "${REDUCED_MOTION_GREP_PATTERN}" -- ${REDUCED_MOTION_FILE_GLOBS}`, { + cwd: rootDirectory, + stdio: 'pipe', + }); + return []; + } catch { + return [MISSING_REDUCED_MOTION_DIAGNOSTIC]; + } +}; diff --git a/packages/svelte-doctor/src/utils/discover-project.ts b/packages/svelte-doctor/src/utils/discover-project.ts index 0e718f6..e3de739 100644 --- a/packages/svelte-doctor/src/utils/discover-project.ts +++ b/packages/svelte-doctor/src/utils/discover-project.ts @@ -1,15 +1,8 @@ -import { readJson } from '@framework-doctor/core'; import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; -import type { ProjectInfo, SvelteFramework } from '../types.js'; - -interface PackageJson { - name?: string; - dependencies?: Record; - devDependencies?: Record; - peerDependencies?: Record; -} +import type { PackageJson, ProjectInfo, SvelteFramework, WorkspacePackage } from '../types.js'; +import { readPackageJson } from './read-package-json.js'; const SOURCE_FILE_PATTERN = /\.(svelte|ts|tsx|js|jsx|mts|cts|mjs|cjs)$/; @@ -37,13 +30,141 @@ const countSourceFiles = (rootDirectory: string): number => { const detectFramework = (dependencies: Record): SvelteFramework => dependencies['@sveltejs/kit'] ? 'sveltekit' : 'svelte'; +const hasSvelteDependency = (packageJson: PackageJson): boolean => { + const deps = { + ...packageJson.dependencies, + ...packageJson.devDependencies, + ...packageJson.peerDependencies, + }; + return Object.keys(deps).some((name) => name === 'svelte' || name === '@sveltejs/kit'); +}; + +const parsePnpmWorkspacePatterns = (rootDirectory: string): string[] => { + const workspacePath = path.join(rootDirectory, 'pnpm-workspace.yaml'); + if (!fs.existsSync(workspacePath)) return []; + + const content = fs.readFileSync(workspacePath, 'utf-8'); + const patterns: string[] = []; + let isInsidePackagesBlock = false; + + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (trimmed === 'packages:') { + isInsidePackagesBlock = true; + continue; + } + if (isInsidePackagesBlock && trimmed.startsWith('-')) { + patterns.push(trimmed.replace(/^-\s*/, '').replace(/["']/g, '')); + } else if (isInsidePackagesBlock && trimmed.length > 0 && !trimmed.startsWith('#')) { + isInsidePackagesBlock = false; + } + } + + return patterns; +}; + +const getWorkspacePatterns = (rootDirectory: string, packageJson: PackageJson): string[] => { + const pnpmPatterns = parsePnpmWorkspacePatterns(rootDirectory); + if (pnpmPatterns.length > 0) return pnpmPatterns; + + if (Array.isArray(packageJson.workspaces)) { + return packageJson.workspaces; + } + + if (packageJson.workspaces?.packages) { + return packageJson.workspaces.packages; + } + + return []; +}; + +const resolveWorkspaceDirectories = (rootDirectory: string, pattern: string): string[] => { + const cleanPattern = pattern.replace(/["']/g, '').replace(/\/\*\*$/, '/*'); + + if (!cleanPattern.includes('*')) { + const directoryPath = path.join(rootDirectory, cleanPattern); + if (fs.existsSync(directoryPath) && fs.existsSync(path.join(directoryPath, 'package.json'))) { + return [directoryPath]; + } + return []; + } + + const wildcardIndex = cleanPattern.indexOf('*'); + const baseDirectory = path.join(rootDirectory, cleanPattern.slice(0, wildcardIndex)); + const suffixAfterWildcard = cleanPattern.slice(wildcardIndex + 1); + + if (!fs.existsSync(baseDirectory) || !fs.statSync(baseDirectory).isDirectory()) { + return []; + } + + return fs + .readdirSync(baseDirectory) + .map((entry) => path.join(baseDirectory, entry, suffixAfterWildcard)) + .filter( + (entryPath) => + fs.existsSync(entryPath) && + fs.statSync(entryPath).isDirectory() && + fs.existsSync(path.join(entryPath, 'package.json')), + ); +}; + +export const discoverSvelteSubprojects = (rootDirectory: string): WorkspacePackage[] => { + if (!fs.existsSync(rootDirectory) || !fs.statSync(rootDirectory).isDirectory()) return []; + + const entries = fs.readdirSync(rootDirectory, { withFileTypes: true }); + const packages: WorkspacePackage[] = []; + + for (const entry of entries) { + if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') { + continue; + } + + const subdirectory = path.join(rootDirectory, entry.name); + const packageJsonPath = path.join(subdirectory, 'package.json'); + if (!fs.existsSync(packageJsonPath)) continue; + + const packageJson = readPackageJson(packageJsonPath); + if (!hasSvelteDependency(packageJson)) continue; + + const name = packageJson.name ?? entry.name; + packages.push({ name, directory: subdirectory }); + } + + return packages; +}; + +export const listWorkspacePackages = (rootDirectory: string): WorkspacePackage[] => { + const packageJsonPath = path.join(rootDirectory, 'package.json'); + if (!fs.existsSync(packageJsonPath)) return []; + + const packageJson = readPackageJson(packageJsonPath); + const patterns = getWorkspacePatterns(rootDirectory, packageJson); + if (patterns.length === 0) return []; + + const packages: WorkspacePackage[] = []; + + for (const pattern of patterns) { + const directories = resolveWorkspaceDirectories(rootDirectory, pattern); + for (const workspaceDirectory of directories) { + const workspacePackageJson = readPackageJson(path.join(workspaceDirectory, 'package.json')); + + if (!hasSvelteDependency(workspacePackageJson)) continue; + + const name = workspacePackageJson.name ?? path.basename(workspaceDirectory); + packages.push({ name, directory: workspaceDirectory }); + } + } + + return packages; +}; + export const discoverProject = (directory: string): ProjectInfo => { const packageJsonPath = path.join(directory, 'package.json'); if (!fs.existsSync(packageJsonPath)) { throw new Error(`No package.json found in ${directory}`); } - const packageJson = readJson(packageJsonPath); + const packageJson = readPackageJson(packageJsonPath); const dependencies = collectDependencies(packageJson); const svelteVersion = dependencies.svelte ?? null; const framework = detectFramework(dependencies); diff --git a/packages/svelte-doctor/src/utils/handle-error.ts b/packages/svelte-doctor/src/utils/handle-error.ts new file mode 100644 index 0000000..65da7ea --- /dev/null +++ b/packages/svelte-doctor/src/utils/handle-error.ts @@ -0,0 +1,24 @@ +import { logger } from '@framework-doctor/core'; +import type { HandleErrorOptions } from '../types.js'; + +const DEFAULT_HANDLE_ERROR_OPTIONS: HandleErrorOptions = { + shouldExit: true, +}; + +export const handleError = ( + error: unknown, + options: HandleErrorOptions = DEFAULT_HANDLE_ERROR_OPTIONS, +): void => { + logger.break(); + logger.error('Something went wrong. Please check the error below for more details.'); + logger.error('If the problem persists, please open an issue on GitHub.'); + logger.error(''); + if (error instanceof Error) { + logger.error(error.message); + } + logger.break(); + if (options.shouldExit) { + process.exit(1); + } + process.exitCode = 1; +}; diff --git a/packages/svelte-doctor/src/utils/read-package-json.ts b/packages/svelte-doctor/src/utils/read-package-json.ts new file mode 100644 index 0000000..7d4f356 --- /dev/null +++ b/packages/svelte-doctor/src/utils/read-package-json.ts @@ -0,0 +1,5 @@ +import { readJson } from '@framework-doctor/core'; +import type { PackageJson } from '../types.js'; + +export const readPackageJson = (packageJsonPath: string): PackageJson => + readJson(packageJsonPath); diff --git a/packages/svelte-doctor/src/utils/select-projects.ts b/packages/svelte-doctor/src/utils/select-projects.ts new file mode 100644 index 0000000..e5f95ec --- /dev/null +++ b/packages/svelte-doctor/src/utils/select-projects.ts @@ -0,0 +1,95 @@ +import { highlighter, logger } from '@framework-doctor/core'; +import path from 'node:path'; +import prompts from 'prompts'; +import type { WorkspacePackage } from '../types.js'; +import { discoverSvelteSubprojects, listWorkspacePackages } from './discover-project.js'; + +const onCancel = () => { + logger.break(); + logger.log('Cancelled.'); + logger.break(); + process.exit(0); +}; + +export const selectProjects = async ( + rootDirectory: string, + projectFlag: string | undefined, + skipPrompts: boolean, +): Promise => { + let packages = listWorkspacePackages(rootDirectory); + if (packages.length === 0) { + packages = discoverSvelteSubprojects(rootDirectory); + } + + if (packages.length === 0) return [rootDirectory]; + if (packages.length === 1) { + logger.log( + `${highlighter.success('✔')} Select projects to scan ${highlighter.dim('›')} ${packages[0].name}`, + ); + return [packages[0].directory]; + } + + if (projectFlag) return resolveProjectFlag(projectFlag, packages); + + if (skipPrompts) { + printDiscoveredProjects(packages); + return packages.map((workspacePackage) => workspacePackage.directory); + } + + return promptProjectSelection(packages, rootDirectory); +}; + +const resolveProjectFlag = ( + projectFlag: string, + workspacePackages: WorkspacePackage[], +): string[] => { + const requestedNames = projectFlag.split(',').map((name) => name.trim()); + const resolvedDirectories: string[] = []; + + for (const requestedName of requestedNames) { + const matched = workspacePackages.find( + (workspacePackage) => + workspacePackage.name === requestedName || + path.basename(workspacePackage.directory) === requestedName, + ); + + if (!matched) { + const availableNames = workspacePackages + .map((workspacePackage) => workspacePackage.name) + .join(', '); + throw new Error(`Project "${requestedName}" not found. Available: ${availableNames}`); + } + + resolvedDirectories.push(matched.directory); + } + + return resolvedDirectories; +}; + +const printDiscoveredProjects = (packages: WorkspacePackage[]): void => { + logger.log( + `${highlighter.success('✔')} Select projects to scan ${highlighter.dim('›')} ${packages.map((workspacePackage) => workspacePackage.name).join(', ')}`, + ); +}; + +const promptProjectSelection = async ( + workspacePackages: WorkspacePackage[], + rootDirectory: string, +): Promise => { + const { selectedDirectories } = await prompts( + { + type: 'multiselect', + name: 'selectedDirectories', + message: 'Select projects to scan', + choices: workspacePackages.map((workspacePackage) => ({ + title: workspacePackage.name, + description: path.relative(rootDirectory, workspacePackage.directory), + value: workspacePackage.directory, + })), + min: 1, + }, + { onCancel }, + ); + + return selectedDirectories ?? []; +}; diff --git a/packages/svelte-doctor/src/utils/skill-prompt.ts b/packages/svelte-doctor/src/utils/skill-prompt.ts new file mode 100644 index 0000000..d4fb781 --- /dev/null +++ b/packages/svelte-doctor/src/utils/skill-prompt.ts @@ -0,0 +1,203 @@ +import { highlighter, logger, readGlobalConfig, writeGlobalConfig } from '@framework-doctor/core'; +import { execSync } from 'node:child_process'; +import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import prompts from 'prompts'; + +const HOME_DIRECTORY = homedir(); + +const SKILL_NAME = 'svelte-doctor'; +const WINDSURF_MARKER = '# Svelte Doctor'; + +const SKILL_DESCRIPTION = + 'Run after making Svelte changes to catch issues early. Use when reviewing code, finishing a feature, or fixing bugs in a Svelte project.'; + +const SKILL_BODY = `Scans your Svelte codebase for security, performance, correctness, and architecture issues. Outputs a 0-100 score with actionable diagnostics. + +## Usage + +\`\`\`bash +npx -y @framework-doctor/svelte@latest . --verbose --diff +\`\`\` + +Or use the unified CLI (auto-detects Svelte): + +\`\`\`bash +npx -y @framework-doctor/cli . --verbose --diff +\`\`\` + +## Workflow + +Run after making changes to catch issues early. Fix errors first, then re-run to verify the score improved.`; + +const SKILL_CONTENT = `--- +name: ${SKILL_NAME} +description: ${SKILL_DESCRIPTION} +version: 1.0.0 +--- + +# Svelte Doctor + +${SKILL_BODY} +`; + +const AGENTS_CONTENT = `# Svelte Doctor + +${SKILL_DESCRIPTION} + +${SKILL_BODY} +`; + +const CODEX_AGENT_CONFIG = `interface: + display_name: "${SKILL_NAME}" + short_description: "Diagnose and fix Svelte codebase health issues" +`; + +interface SkillTarget { + name: string; + detect: () => boolean; + install: () => void; +} + +const writeSkillFiles = (directory: string): void => { + mkdirSync(directory, { recursive: true }); + writeFileSync(join(directory, 'SKILL.md'), SKILL_CONTENT); + writeFileSync(join(directory, 'AGENTS.md'), AGENTS_CONTENT); +}; + +const isCommandAvailable = (command: string): boolean => { + try { + const whichCommand = process.platform === 'win32' ? 'where' : 'which'; + execSync(`${whichCommand} ${command}`, { stdio: 'ignore' }); + return true; + } catch { + return false; + } +}; + +const SKILL_TARGETS: SkillTarget[] = [ + { + name: 'Claude Code', + detect: () => existsSync(join(HOME_DIRECTORY, '.claude')), + install: () => writeSkillFiles(join(HOME_DIRECTORY, '.claude', 'skills', SKILL_NAME)), + }, + { + name: 'Amp Code', + detect: () => existsSync(join(HOME_DIRECTORY, '.amp')), + install: () => writeSkillFiles(join(HOME_DIRECTORY, '.config', 'amp', 'skills', SKILL_NAME)), + }, + { + name: 'Cursor', + detect: () => existsSync(join(HOME_DIRECTORY, '.cursor')), + install: () => writeSkillFiles(join(HOME_DIRECTORY, '.cursor', 'skills', SKILL_NAME)), + }, + { + name: 'OpenCode', + detect: () => + isCommandAvailable('opencode') || existsSync(join(HOME_DIRECTORY, '.config', 'opencode')), + install: () => + writeSkillFiles(join(HOME_DIRECTORY, '.config', 'opencode', 'skills', SKILL_NAME)), + }, + { + name: 'Windsurf', + detect: () => + existsSync(join(HOME_DIRECTORY, '.codeium')) || + existsSync(join(HOME_DIRECTORY, 'Library', 'Application Support', 'Windsurf')), + install: () => { + const memoriesDirectory = join(HOME_DIRECTORY, '.codeium', 'windsurf', 'memories'); + mkdirSync(memoriesDirectory, { recursive: true }); + const rulesFile = join(memoriesDirectory, 'global_rules.md'); + + if (existsSync(rulesFile)) { + const existingContent = readFileSync(rulesFile, 'utf-8'); + if (existingContent.includes(WINDSURF_MARKER)) return; + appendFileSync(rulesFile, `\n${WINDSURF_MARKER}\n\n${SKILL_CONTENT}`); + } else { + writeFileSync(rulesFile, `${WINDSURF_MARKER}\n\n${SKILL_CONTENT}`); + } + }, + }, + { + name: 'Antigravity', + detect: () => + isCommandAvailable('agy') || existsSync(join(HOME_DIRECTORY, '.gemini', 'antigravity')), + install: () => + writeSkillFiles(join(HOME_DIRECTORY, '.gemini', 'antigravity', 'skills', SKILL_NAME)), + }, + { + name: 'Gemini CLI', + detect: () => isCommandAvailable('gemini') || existsSync(join(HOME_DIRECTORY, '.gemini')), + install: () => writeSkillFiles(join(HOME_DIRECTORY, '.gemini', 'skills', SKILL_NAME)), + }, + { + name: 'Codex', + detect: () => isCommandAvailable('codex') || existsSync(join(HOME_DIRECTORY, '.codex')), + install: () => { + const skillDirectory = join(HOME_DIRECTORY, '.codex', 'skills', SKILL_NAME); + writeSkillFiles(skillDirectory); + const agentsDirectory = join(skillDirectory, 'agents'); + mkdirSync(agentsDirectory, { recursive: true }); + writeFileSync(join(agentsDirectory, 'openai.yaml'), CODEX_AGENT_CONFIG); + }, + }, +]; + +const installSkill = (): void => { + let installedCount = 0; + + for (const target of SKILL_TARGETS) { + if (!target.detect()) continue; + try { + target.install(); + logger.log(` ${highlighter.success('✔')} ${target.name}`); + installedCount++; + } catch { + logger.dim(` ✗ ${target.name} (failed)`); + } + } + + try { + const projectSkillDirectory = join('.agents', SKILL_NAME); + writeSkillFiles(projectSkillDirectory); + logger.log(` ${highlighter.success('✔')} .agents/`); + installedCount++; + } catch { + logger.dim(' ✗ .agents/ (failed)'); + } + + logger.break(); + if (installedCount === 0) { + logger.dim('No supported tools detected.'); + } else { + logger.success('Done! The skill will activate when working on Svelte projects.'); + } +}; + +export const maybePromptSkillInstall = async (shouldSkipPrompts: boolean): Promise => { + const config = readGlobalConfig(); + if (config.skillPromptDismissed) return; + if (shouldSkipPrompts) return; + + logger.break(); + logger.log(`${highlighter.info('💡')} Have your coding agent fix these issues automatically?`); + logger.dim( + ` Install the ${highlighter.info('svelte-doctor')} skill to teach Cursor, Claude Code,`, + ); + logger.dim(' and other AI agents how to diagnose and fix Svelte issues.'); + logger.break(); + + const { shouldInstall } = await prompts({ + type: 'confirm', + name: 'shouldInstall', + message: 'Install skill? (recommended)', + initial: true, + }); + + if (shouldInstall) { + logger.break(); + installSkill(); + } + + writeGlobalConfig({ ...config, skillPromptDismissed: true }); +}; diff --git a/packages/svelte-doctor/src/utils/write-diagnostics-dir.ts b/packages/svelte-doctor/src/utils/write-diagnostics-dir.ts new file mode 100644 index 0000000..0a9dd23 --- /dev/null +++ b/packages/svelte-doctor/src/utils/write-diagnostics-dir.ts @@ -0,0 +1,76 @@ +import { groupBy } from '@framework-doctor/core'; +import { randomUUID } from 'node:crypto'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { Diagnostic } from '../types.js'; + +const SEVERITY_ORDER: Record = { + error: 0, + warning: 1, +}; + +const sortBySeverity = (groups: [string, Diagnostic[]][]): [string, Diagnostic[]][] => + groups.toSorted(([, diagnosticsA], [, diagnosticsB]) => { + const severityA = SEVERITY_ORDER[diagnosticsA[0].severity]; + const severityB = SEVERITY_ORDER[diagnosticsB[0].severity]; + return severityA - severityB; + }); + +const buildFileLineMap = (diagnostics: Diagnostic[]): Map => { + const fileLines = new Map(); + for (const diagnostic of diagnostics) { + const lines = fileLines.get(diagnostic.filePath) ?? []; + if (diagnostic.line > 0) { + lines.push(diagnostic.line); + } + fileLines.set(diagnostic.filePath, lines); + } + return fileLines; +}; + +const formatRuleSummary = (ruleKey: string, ruleDiagnostics: Diagnostic[]): string => { + const firstDiagnostic = ruleDiagnostics[0]; + const fileLines = buildFileLineMap(ruleDiagnostics); + + const sections = [ + `Rule: ${ruleKey}`, + `Severity: ${firstDiagnostic.severity}`, + `Category: ${firstDiagnostic.category}`, + `Count: ${ruleDiagnostics.length}`, + '', + firstDiagnostic.message, + ]; + + if (firstDiagnostic.help) { + sections.push('', `Suggestion: ${firstDiagnostic.help}`); + } + + sections.push('', 'Files:'); + for (const [filePath, lines] of fileLines) { + const lineLabel = lines.length > 0 ? `: ${lines.join(', ')}` : ''; + sections.push(` ${filePath}${lineLabel}`); + } + + return sections.join('\n') + '\n'; +}; + +export const writeDiagnosticsDirectory = (diagnostics: Diagnostic[]): string => { + const outputDirectory = join(tmpdir(), `svelte-doctor-${randomUUID()}`); + mkdirSync(outputDirectory); + + const ruleGroups = groupBy( + diagnostics, + (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`, + ); + const sortedRuleGroups = sortBySeverity([...ruleGroups.entries()]); + + for (const [ruleKey, ruleDiagnostics] of sortedRuleGroups) { + const fileName = ruleKey.replace(/\//g, '--') + '.txt'; + writeFileSync(join(outputDirectory, fileName), formatRuleSummary(ruleKey, ruleDiagnostics)); + } + + writeFileSync(join(outputDirectory, 'diagnostics.json'), JSON.stringify(diagnostics, null, 2)); + + return outputDirectory; +}; diff --git a/packages/vue-doctor/CHANGELOG.md b/packages/vue-doctor/CHANGELOG.md new file mode 100644 index 0000000..b1051be --- /dev/null +++ b/packages/vue-doctor/CHANGELOG.md @@ -0,0 +1,17 @@ +# vue-doctor + +## 1.0.3 + +### Patch Changes + +- vue doctor +- Updated dependencies + - @framework-doctor/core@1.0.3 + +## 1.0.0 + +### Major Changes + +- Initial release for Vue and Nuxt projects +- vue-tsc type checking, ESLint (vue, vuejs-accessibility, nuxt), security scan (v-html, eval), knip, checkReducedMotion +- Full CLI parity: --project, --diff, --offline, selectProjects, skill prompt, write diagnostics dir diff --git a/packages/vue-doctor/README.md b/packages/vue-doctor/README.md new file mode 100644 index 0000000..4e24d6a --- /dev/null +++ b/packages/vue-doctor/README.md @@ -0,0 +1,109 @@ +# Vue Doctor + +[![version](https://img.shields.io/npm/v/@framework-doctor/vue.svg?style=flat)](https://npmjs.com/package/@framework-doctor/vue) +[![downloads](https://img.shields.io/npm/dm/@framework-doctor/vue.svg?style=flat)](https://npmjs.com/package/@framework-doctor/vue) + +Diagnose and improve your Vue and Nuxt codebase health. + +One command scans your codebase for security, performance, correctness, dead code, and accessibility issues, then outputs a **0–100 score** with actionable diagnostics. + +## Install + +Run at your project root: + +```bash +npx -y @framework-doctor/vue . +``` + +Or use the unified CLI (auto-detects Vue): + +```bash +npx -y @framework-doctor/cli . +``` + +## Options + +``` +Usage: vue-doctor [directory] [options] + +Options: + -v, --version display the version number + --no-lint skip linting + --no-dead-code skip dead code detection + --verbose show file details per rule + --score output only the score (CI-friendly) + -y, --yes skip prompts, scan all workspace projects + --no-analytics disable anonymous analytics + --project select workspace project (comma-separated for multiple) + --diff [base] scan only files changed vs base branch + --offline skip remote scoring (local score only) + -h, --help display help for command +``` + +## Configuration + +Create `vue-doctor.config.json`: + +```json +{ + "ignore": { + "rules": ["vue/no-mutating-props", "vue-doctor/no-v-html"], + "files": ["src/generated/**"] + }, + "lint": true, + "deadCode": true, + "verbose": false, + "diff": false, + "analytics": true +} +``` + +Or use the `vueDoctor` key in `package.json`: + +```json +{ + "vueDoctor": { + "deadCode": true, + "ignore": { "rules": ["vue-doctor/no-v-html"] } + } +} +``` + +## Programmatic API + +```typescript +import { diagnose } from '@framework-doctor/vue'; + +const result = await diagnose('./my-vue-project', { + lint: true, + deadCode: true, + includePaths: [], // empty = full scan +}); + +console.log(result.diagnostics); +console.log(result.score); +console.log(result.project); +console.log(result.elapsedMilliseconds); +``` + +## Checks + +Vue Doctor runs: + +- **vue-tsc** — TypeScript type checking for `.vue` and `.ts` files +- **ESLint** — eslint-plugin-vue, eslint-plugin-vuejs-accessibility, @nuxt/eslint-plugin (Nuxt) +- **Security** — v-html, eval, new Function, implied eval +- **Knip** — Dead code detection +- **checkReducedMotion** — Accessibility (WCAG 2.3.3) when motion libraries are used + +## Security checks + +Vue Doctor flags: + +- **`v-html`** — Raw HTML can lead to XSS if content is unsanitized +- **`new Function()`** — Code injection risk +- **`setTimeout("string")` / `setInterval("string")`** — Implied eval + +## License + +MIT diff --git a/packages/vue-doctor/package.json b/packages/vue-doctor/package.json new file mode 100644 index 0000000..b0b59ae --- /dev/null +++ b/packages/vue-doctor/package.json @@ -0,0 +1,91 @@ +{ + "name": "@framework-doctor/vue", + "version": "1.0.3", + "description": "Diagnose Vue and Nuxt codebase health", + "author": { + "name": "Pitis Radu", + "url": "https://github.com/pitis" + }, + "publishConfig": { + "access": "public" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=9.0.0" + }, + "repository": { + "type": "git", + "url": "https://github.com/pitis/framework-doctor.git", + "directory": "packages/vue-doctor" + }, + "homepage": "https://github.com/pitis/framework-doctor/tree/main/packages/vue-doctor#readme", + "bugs": { + "url": "https://github.com/pitis/framework-doctor/issues" + }, + "keywords": [ + "diagnostics", + "linter", + "vue", + "nuxt", + "performance", + "cli", + "code-health", + "dead-code", + "quality" + ], + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/pitis" + }, + "type": "module", + "bin": { + "vue-doctor": "./dist/cli.js" + }, + "files": [ + "dist" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./api": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "dev": "tsdown --watch", + "build": "rimraf dist && cross-env NODE_ENV=production tsdown", + "lint": "oxlint src tests", + "lint:fix": "oxlint --fix src tests", + "typecheck": "tsc --noEmit", + "test": "pnpm build && vitest run", + "prepublishOnly": "pnpm run build" + }, + "dependencies": { + "@framework-doctor/core": "workspace:*", + "commander": "catalog:", + "eslint": "catalog:", + "eslint-plugin-vue": "catalog:", + "eslint-plugin-vuejs-accessibility": "catalog:", + "@nuxt/eslint-plugin": "catalog:", + "knip": "catalog:", + "ora": "catalog:", + "oxlint": "catalog:", + "picocolors": "catalog:", + "prompts": "^2.4.2", + "vue-tsc": "catalog:" + }, + "devDependencies": { + "@types/node": "catalog:", + "@types/prompts": "catalog:", + "cross-env": "catalog:", + "rimraf": "catalog:", + "tsdown": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "packageManager": "pnpm@10.30.0" +} diff --git a/packages/vue-doctor/src/cli.ts b/packages/vue-doctor/src/cli.ts new file mode 100644 index 0000000..5080e04 --- /dev/null +++ b/packages/vue-doctor/src/cli.ts @@ -0,0 +1,244 @@ +import { + addAnalyticsOption, + highlighter, + isAutomatedEnvironment, + logger, +} from '@framework-doctor/core'; +import { Command } from 'commander'; +import path from 'node:path'; +import prompts from 'prompts'; +import { scan } from './scan.js'; +import type { Diagnostic, DiffInfo, ScanOptions, VueDoctorConfig } from './types.js'; +import { filterSourceFiles, getDiffInfo } from './utils/get-diff-files.js'; +import { handleError } from './utils/handle-error.js'; +import { loadConfig } from './utils/load-config.js'; +import { selectProjects } from './utils/select-projects.js'; +import { maybePromptSkillInstall } from './utils/skill-prompt.js'; +import { + maybePromptAnalyticsConsent, + sendScanEvent, + shouldSendAnalytics, +} from './utils/telemetry.js'; + +const VERSION = process.env.VERSION ?? '0.0.0'; + +interface CliFlags { + lint: boolean; + deadCode: boolean; + verbose: boolean; + score: boolean; + yes: boolean; + analytics: boolean; + project?: string; + diff?: boolean | string; + offline?: boolean; +} + +const exitWithCancelHint = () => { + logger.break(); + logger.log('Cancelled.'); + logger.break(); + process.exit(0); +}; + +process.on('SIGINT', exitWithCancelHint); +process.on('SIGTERM', exitWithCancelHint); + +const resolveCliScanOptions = ( + flags: CliFlags, + userConfig: VueDoctorConfig | null, + programInstance: Command, +): ScanOptions => { + const isCliOverride = (optionName: string) => + programInstance.getOptionValueSource(optionName) === 'cli'; + + return { + lint: isCliOverride('lint') ? flags.lint : (userConfig?.lint ?? flags.lint), + deadCode: isCliOverride('deadCode') ? flags.deadCode : (userConfig?.deadCode ?? flags.deadCode), + verbose: isCliOverride('verbose') ? Boolean(flags.verbose) : (userConfig?.verbose ?? false), + scoreOnly: flags.score, + }; +}; + +const resolveDiffMode = async ( + diffInfo: DiffInfo | null, + effectiveDiff: boolean | string | undefined, + shouldSkipPrompts: boolean, + isScoreOnly: boolean, +): Promise => { + if (effectiveDiff !== undefined && effectiveDiff !== false) { + if (diffInfo) return true; + if (!isScoreOnly) { + logger.warn('No feature branch or uncommitted changes detected. Running full scan.'); + logger.break(); + } + return false; + } + + if (effectiveDiff === false || !diffInfo) return false; + + const changedSourceFiles = filterSourceFiles(diffInfo.changedFiles); + if (changedSourceFiles.length === 0) return false; + if (shouldSkipPrompts) return true; + if (isScoreOnly) return false; + + const promptMessage = diffInfo.isCurrentChanges + ? `Found ${changedSourceFiles.length} uncommitted changed files. Only scan current changes?` + : `On branch ${diffInfo.currentBranch} (${changedSourceFiles.length} changed files vs ${diffInfo.baseBranch}). Only scan this branch?`; + + const { shouldScanChangedOnly } = await prompts( + { + type: 'confirm', + name: 'shouldScanChangedOnly', + message: promptMessage, + initial: true, + }, + { + onCancel: () => { + logger.break(); + logger.log('Cancelled.'); + logger.break(); + process.exit(0); + }, + }, + ); + return Boolean(shouldScanChangedOnly); +}; + +const program = new Command() + .name('vue-doctor') + .description('Diagnose Vue codebase health') + .version(VERSION, '-v, --version', 'display the version number') + .argument('[directory]', 'project directory to scan', '.') + .option('--no-lint', 'skip linting') + .option('--no-dead-code', 'skip dead code detection') + .option('--verbose', 'show file details per rule') + .option('--score', 'output only the score') + .option('-y, --yes', 'skip prompts, scan all workspace projects') + .option('--project ', 'select workspace project (comma-separated for multiple)') + .option('--diff [base]', 'scan only files changed vs base branch') + .option('--offline', 'skip remote scoring (local score only)'); + +addAnalyticsOption(program); + +program + .action(async (directory: string, flags: CliFlags) => { + const isScoreOnly = flags.score; + + try { + const resolvedDirectory = path.resolve(directory); + const userConfig = loadConfig(resolvedDirectory); + + if (!isScoreOnly) { + logger.log(`vue-doctor v${VERSION}`); + logger.break(); + } + + const scanOptions = resolveCliScanOptions(flags, userConfig, program); + const shouldSkipPrompts = flags.yes || isAutomatedEnvironment() || !process.stdin.isTTY; + const projectDirectories = await selectProjects( + resolvedDirectory, + flags.project, + shouldSkipPrompts, + ); + + const isDiffCliOverride = program.getOptionValueSource('diff') === 'cli'; + const effectiveDiff = isDiffCliOverride ? flags.diff : userConfig?.diff; + const explicitBaseBranch = typeof effectiveDiff === 'string' ? effectiveDiff : undefined; + const diffInfo = getDiffInfo(resolvedDirectory, explicitBaseBranch); + const isDiffMode = await resolveDiffMode( + diffInfo, + effectiveDiff, + shouldSkipPrompts, + isScoreOnly, + ); + + if (isDiffMode && diffInfo && !isScoreOnly) { + if (diffInfo.isCurrentChanges) { + logger.log('Scanning uncommitted changes'); + } else { + logger.log( + `Scanning changes: ${highlighter.info(diffInfo.currentBranch)} → ${highlighter.info(diffInfo.baseBranch)}`, + ); + } + logger.break(); + } + + const allDiagnostics: Diagnostic[] = []; + const telemetryUrl = process.env.FRAMEWORK_DOCTOR_TELEMETRY_URL ?? ''; + const isAutomated = isAutomatedEnvironment(); + + if (!isScoreOnly && !isAutomated && !flags.yes) { + await maybePromptAnalyticsConsent(shouldSkipPrompts); + } + + for (const projectDirectory of projectDirectories) { + let includePaths: string[] | undefined; + if (isDiffMode) { + const projectDiffInfo = getDiffInfo(projectDirectory, explicitBaseBranch); + if (projectDiffInfo) { + const changedSourceFiles = filterSourceFiles(projectDiffInfo.changedFiles); + if (changedSourceFiles.length === 0) { + if (!isScoreOnly) { + logger.dim(`No changed source files in ${projectDirectory}, skipping.`); + logger.break(); + } + continue; + } + includePaths = changedSourceFiles; + } + } + + if (!isScoreOnly) { + logger.dim(`Scanning ${projectDirectory}...`); + logger.break(); + } + const scanResult = await scan(projectDirectory, { ...scanOptions, includePaths }); + allDiagnostics.push(...scanResult.diagnostics); + + if ( + telemetryUrl && + scanResult.scoreResult && + shouldSendAnalytics( + { analytics: flags.analytics, yes: flags.yes }, + userConfig?.analytics, + isAutomated, + ) + ) { + sendScanEvent( + telemetryUrl, + scanResult.projectInfo, + scanResult.scoreResult, + scanResult.diagnostics.length, + { + isDiffMode: Boolean(includePaths?.length), + cliVersion: VERSION, + }, + ); + } + + if (!isScoreOnly) { + logger.break(); + } + } + + if (!isScoreOnly && !shouldSkipPrompts) { + await maybePromptSkillInstall(shouldSkipPrompts); + } + } catch (error) { + handleError(error); + } + }) + .addHelpText( + 'after', + ` +${highlighter.dim('Learn more:')} + ${highlighter.info('https://github.com/pitis/framework-doctor')} +`, + ); + +const main = async () => { + await program.parseAsync(); +}; + +main(); diff --git a/packages/vue-doctor/src/constants.ts b/packages/vue-doctor/src/constants.ts new file mode 100644 index 0000000..4cddf98 --- /dev/null +++ b/packages/vue-doctor/src/constants.ts @@ -0,0 +1,23 @@ +export { + ERROR_RULE_PENALTY, + MILLISECONDS_PER_SECOND, + PERFECT_SCORE, + SCORE_BAR_WIDTH_CHARS, + SCORE_GOOD_THRESHOLD, + SCORE_OK_THRESHOLD, + SUMMARY_BOX_HORIZONTAL_PADDING_CHARS, + SUMMARY_BOX_OUTER_INDENT_CHARS, + WARNING_RULE_PENALTY, +} from '@framework-doctor/core'; + +export const SOURCE_FILE_PATTERN = /\.(vue|ts|tsx|js|jsx|mts|cts|mjs|cjs)$/; + +export const OFFLINE_FLAG_MESSAGE = 'Score not available.'; + +export const VUE_MOTION_LIBRARIES = new Set([ + '@vueuse/motion', + 'vue3-motion', + 'motion-vue', + 'motion', + 'framer-motion', +]); diff --git a/packages/vue-doctor/src/index.ts b/packages/vue-doctor/src/index.ts new file mode 100644 index 0000000..8d2a314 --- /dev/null +++ b/packages/vue-doctor/src/index.ts @@ -0,0 +1,33 @@ +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { scan } from './scan.js'; +import type { DiagnoseOptions, DiagnoseResult } from './types.js'; +import { discoverProject } from './utils/discover-project.js'; + +export type { + DiagnoseOptions, + DiagnoseResult, + Diagnostic, + ProjectInfo, + ScanOptions, + ScoreResult, + VueDoctorConfig, +} from './types.js'; +export { filterSourceFiles, getDiffInfo } from './utils/get-diff-files.js'; + +export const diagnose = async ( + directory: string, + options: DiagnoseOptions = {}, +): Promise => { + const start = performance.now(); + const root = path.resolve(directory); + const project = discoverProject(root); + const result = await scan(root, options); + + return { + diagnostics: result.diagnostics, + score: result.scoreResult, + project, + elapsedMilliseconds: performance.now() - start, + }; +}; diff --git a/packages/vue-doctor/src/scan.ts b/packages/vue-doctor/src/scan.ts new file mode 100644 index 0000000..d9a26d9 --- /dev/null +++ b/packages/vue-doctor/src/scan.ts @@ -0,0 +1,411 @@ +import type { FramedLine } from '@framework-doctor/core'; +import { + buildCountsSummaryLine, + buildScoreBar, + buildScoreBreakdownLines, + colorizeByScore, + createFramedLine, + getDoctorFace, + groupBy, + highlighter, + indentMultilineText, + logger, + PERFECT_SCORE, + printFramedBox, + spinner, +} from '@framework-doctor/core'; +import { performance } from 'node:perf_hooks'; +import { OFFLINE_FLAG_MESSAGE } from './constants.js'; +import type { + Diagnostic, + ProjectInfo, + ScanOptions, + ScanResult, + ScoreResult, + VueDoctorConfig, +} from './types.js'; +import { calculateScore } from './utils/calculate-score.js'; +import { combineDiagnostics, computeVueIncludePaths } from './utils/combine-diagnostics.js'; +import { discoverProject } from './utils/discover-project.js'; +import { loadConfig } from './utils/load-config.js'; +import { runEslint } from './utils/run-eslint.js'; +import { runKnip } from './utils/run-knip.js'; +import { runSecurityScan } from './utils/run-security-scan.js'; +import { runVueTsc } from './utils/run-vue-tsc.js'; +import { writeDiagnosticsDirectory } from './utils/write-diagnostics-dir.js'; + +const SEVERITY_ORDER: Record = { + error: 0, + warning: 1, +}; + +const colorizeBySeverity = (text: string, severity: Diagnostic['severity']): string => + severity === 'error' ? highlighter.error(text) : highlighter.warn(text); + +const sortBySeverity = (diagnosticGroups: [string, Diagnostic[]][]): [string, Diagnostic[]][] => + diagnosticGroups.toSorted(([, diagnosticsA], [, diagnosticsB]) => { + const severityA = SEVERITY_ORDER[diagnosticsA[0].severity]; + const severityB = SEVERITY_ORDER[diagnosticsB[0].severity]; + return severityA - severityB; + }); + +const buildFileLineMap = (diagnostics: Diagnostic[]): Map => { + const fileLines = new Map(); + for (const diagnostic of diagnostics) { + const lines = fileLines.get(diagnostic.filePath) ?? []; + if (diagnostic.line > 0) { + lines.push(diagnostic.line); + } + fileLines.set(diagnostic.filePath, lines); + } + return fileLines; +}; + +const hasHighOrCriticalSecurityFindings = (diagnostics: Diagnostic[]): boolean => + diagnostics.some( + (diagnostic) => diagnostic.category === 'security' && diagnostic.severity === 'error', + ); + +const formatFrameworkName = (framework: string): string => (framework === 'nuxt' ? 'Nuxt' : 'Vue'); + +const printProjectDetection = ( + projectInfo: ProjectInfo, + userConfig: VueDoctorConfig | null, + isDiffMode: boolean, + includePaths: string[], +): void => { + const frameworkLabel = formatFrameworkName(projectInfo.framework); + const languageLabel = projectInfo.hasTypeScript ? 'TypeScript' : 'JavaScript'; + + const completeStep = (message: string) => { + spinner(message).start().succeed(message); + }; + + completeStep(`Detecting framework. Found ${highlighter.info(frameworkLabel)}.`); + completeStep( + `Detecting Vue version. Found ${highlighter.info(`Vue ${projectInfo.vueVersion ?? 'unknown'}`)}.`, + ); + completeStep(`Detecting language. Found ${highlighter.info(languageLabel)}.`); + + if (isDiffMode) { + completeStep(`Scanning ${highlighter.info(`${includePaths.length}`)} changed source files.`); + } else { + completeStep(`Found ${highlighter.info(`${projectInfo.sourceFileCount}`)} source files.`); + } + + if (userConfig) { + completeStep(`Loaded ${highlighter.info('vue-doctor config')}.`); + } + + logger.break(); +}; + +const printDiagnostics = (diagnostics: Diagnostic[], isVerbose: boolean): void => { + const ruleGroups = groupBy( + diagnostics, + (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`, + ); + + const sortedRuleGroups = sortBySeverity([...ruleGroups.entries()]); + + for (const [, ruleDiagnostics] of sortedRuleGroups) { + const firstDiagnostic = ruleDiagnostics[0]; + const severitySymbol = firstDiagnostic.severity === 'error' ? '✗' : '⚠'; + const icon = colorizeBySeverity(severitySymbol, firstDiagnostic.severity); + const count = ruleDiagnostics.length; + const countLabel = count > 1 ? colorizeBySeverity(` (${count})`, firstDiagnostic.severity) : ''; + + logger.log(` ${icon} ${firstDiagnostic.message}${countLabel}`); + if (firstDiagnostic.help) { + logger.dim(indentMultilineText(firstDiagnostic.help, ' ')); + } + + if (isVerbose) { + const fileLines = buildFileLineMap(ruleDiagnostics); + + for (const [filePath, lines] of fileLines) { + const lineLabel = lines.length > 0 ? `: ${lines.join(', ')}` : ''; + logger.dim(` ${filePath}${lineLabel}`); + } + } + + logger.break(); + } +}; + +const printBranding = (score?: number): void => { + if (score !== undefined) { + const [eyes, mouth] = getDoctorFace(score); + const colorize = (text: string) => colorizeByScore(text, score); + logger.log(colorize(' ┌─────┐')); + logger.log(colorize(` │ ${eyes} │`)); + logger.log(colorize(` │ ${mouth} │`)); + logger.log(colorize(' └─────┘')); + } + logger.log(' Vue Doctor'); + logger.break(); +}; + +const printScoreGauge = (score: number, label: string): void => { + const scoreDisplay = colorizeByScore(`${score}`, score); + const labelDisplay = colorizeByScore(label, score); + const bar = buildScoreBar(score); + logger.log(` ${scoreDisplay} / ${PERFECT_SCORE} ${labelDisplay}`); + logger.break(); + logger.log(` ${bar.rendered}`); + logger.break(); +}; + +const buildBrandingLines = ( + scoreResult: ScoreResult | null, + noScoreMessage: string, + verbose: boolean, +): FramedLine[] => { + const lines: FramedLine[] = []; + + if (scoreResult) { + const [eyes, mouth] = getDoctorFace(scoreResult.score); + const scoreColorizer = (text: string): string => colorizeByScore(text, scoreResult.score); + + lines.push(createFramedLine('┌─────┐', scoreColorizer('┌─────┐'))); + lines.push(createFramedLine(`│ ${eyes} │`, scoreColorizer(`│ ${eyes} │`))); + lines.push(createFramedLine(`│ ${mouth} │`, scoreColorizer(`│ ${mouth} │`))); + lines.push(createFramedLine('└─────┘', scoreColorizer('└─────┘'))); + lines.push(createFramedLine('Vue Doctor', 'Vue Doctor')); + lines.push(createFramedLine('')); + + const scoreLinePlainText = `${scoreResult.score} / ${PERFECT_SCORE} ${scoreResult.label}`; + const scoreLineRenderedText = `${colorizeByScore(String(scoreResult.score), scoreResult.score)} / ${PERFECT_SCORE} ${colorizeByScore(scoreResult.label, scoreResult.score)}`; + lines.push(createFramedLine(scoreLinePlainText, scoreLineRenderedText)); + lines.push(createFramedLine('')); + const bar = buildScoreBar(scoreResult.score); + lines.push(createFramedLine(bar.plain, bar.rendered)); + if (verbose && scoreResult.breakdown) { + lines.push(createFramedLine('')); + lines.push(...buildScoreBreakdownLines(scoreResult.breakdown)); + } + lines.push(createFramedLine('')); + } else { + lines.push(createFramedLine('Vue Doctor', 'Vue Doctor')); + lines.push(createFramedLine('')); + lines.push(createFramedLine(noScoreMessage, highlighter.dim(noScoreMessage))); + lines.push(createFramedLine('')); + } + + return lines; +}; + +const toCountsFramedLine = ( + diagnostics: Diagnostic[], + totalSourceFileCount: number, + elapsedMilliseconds: number, +): FramedLine => { + const { plain, rendered } = buildCountsSummaryLine( + diagnostics, + totalSourceFileCount, + elapsedMilliseconds, + ); + return createFramedLine(plain, rendered); +}; + +const printSummary = ( + diagnostics: Diagnostic[], + elapsedMilliseconds: number, + scoreResult: ScoreResult | null, + projectName: string, + totalSourceFileCount: number, + noScoreMessage: string, + verbose: boolean, +): void => { + const summaryFramedLines = [ + ...buildBrandingLines(scoreResult, noScoreMessage, verbose), + toCountsFramedLine(diagnostics, totalSourceFileCount, elapsedMilliseconds), + ]; + printFramedBox(summaryFramedLines); + + try { + const diagnosticsDirectory = writeDiagnosticsDirectory(diagnostics); + logger.break(); + logger.dim(` Full diagnostics written to ${diagnosticsDirectory}`); + } catch { + logger.break(); + } +}; + +interface ResolvedScanOptions { + lint: boolean; + deadCode: boolean; + verbose: boolean; + scoreOnly: boolean; + includePaths: string[]; +} + +const mergeScanOptions = ( + inputOptions: ScanOptions, + userConfig: VueDoctorConfig | null, +): ResolvedScanOptions => ({ + lint: inputOptions.lint ?? userConfig?.lint ?? true, + deadCode: inputOptions.deadCode ?? userConfig?.deadCode ?? true, + verbose: inputOptions.verbose ?? userConfig?.verbose ?? false, + scoreOnly: inputOptions.scoreOnly ?? false, + includePaths: inputOptions.includePaths ?? [], +}); + +export const scan = async ( + directory: string, + inputOptions: ScanOptions = {}, +): Promise => { + const startTime = performance.now(); + const projectInfo = discoverProject(directory); + const userConfig = loadConfig(directory); + const options = mergeScanOptions(inputOptions, userConfig); + const { includePaths } = options; + const isDiffMode = includePaths.length > 0; + + if (!projectInfo.vueVersion) { + throw new Error('No Vue dependency found in package.json'); + } + + if (!options.scoreOnly) { + printProjectDetection(projectInfo, userConfig, isDiffMode, includePaths); + } + + const vueIncludePaths = computeVueIncludePaths(includePaths) ?? includePaths; + + let didLintFail = false; + let didDeadCodeFail = false; + + const vueTscPromise = (async () => { + const vueTscSpinner = options.scoreOnly ? null : spinner('Running Vue type check...').start(); + try { + const diagnostics = await runVueTsc(directory, vueIncludePaths); + vueTscSpinner?.succeed('Running Vue type check.'); + return diagnostics; + } catch (error) { + vueTscSpinner?.fail('Vue type check failed (non-fatal, skipping).'); + logger.error(String(error)); + return []; + } + })(); + + const lintPromise = options.lint + ? (async () => { + const lintSpinner = options.scoreOnly ? null : spinner('Running lint checks...').start(); + try { + const lintDiagnostics = await runEslint( + directory, + projectInfo.framework, + vueIncludePaths, + ); + lintSpinner?.succeed('Running lint checks.'); + return lintDiagnostics; + } catch (error) { + didLintFail = true; + lintSpinner?.fail('Lint checks failed (non-fatal, skipping).'); + logger.error(String(error)); + return []; + } + })() + : Promise.resolve([]); + + const deadCodePromise = + options.deadCode && !isDiffMode + ? (async () => { + const deadCodeSpinner = options.scoreOnly + ? null + : spinner('Detecting dead code...').start(); + try { + const knipDiagnostics = await runKnip(directory); + deadCodeSpinner?.succeed('Detecting dead code.'); + return knipDiagnostics; + } catch (error) { + didDeadCodeFail = true; + deadCodeSpinner?.fail('Dead code detection failed (non-fatal, skipping).'); + logger.error(String(error)); + return []; + } + })() + : Promise.resolve([]); + + const securityPromise = options.lint + ? runSecurityScan(directory, includePaths) + : Promise.resolve([]); + + const [vueTscDiagnostics, lintDiagnostics, deadCodeDiagnostics, securityDiagnostics] = + await Promise.all([vueTscPromise, lintPromise, deadCodePromise, securityPromise]); + + const diagnostics = combineDiagnostics( + [...vueTscDiagnostics, ...lintDiagnostics], + deadCodeDiagnostics, + securityDiagnostics, + directory, + isDiffMode, + userConfig, + ); + + const elapsedMilliseconds = performance.now() - startTime; + + const skippedChecks: string[] = []; + if (didLintFail) skippedChecks.push('lint'); + if (didDeadCodeFail) skippedChecks.push('dead code'); + const hasSkippedChecks = skippedChecks.length > 0; + + const totalFilesScanned = isDiffMode ? includePaths.length : projectInfo.sourceFileCount; + const scoreResult = await calculateScore(diagnostics, totalFilesScanned, { + hasHighOrCriticalSecurityFindings: hasHighOrCriticalSecurityFindings(diagnostics), + }); + const noScoreMessage = OFFLINE_FLAG_MESSAGE; + + if (options.scoreOnly) { + if (scoreResult) { + logger.log(`${scoreResult.score}`); + } else { + logger.dim(noScoreMessage); + } + return { diagnostics, scoreResult, skippedChecks, projectInfo }; + } + + if (diagnostics.length === 0) { + if (hasSkippedChecks) { + const skippedLabel = skippedChecks.join(' and '); + logger.warn( + `No issues detected, but ${skippedLabel} checks failed — results are incomplete.`, + ); + } else { + logger.success('No issues found!'); + } + logger.break(); + if (hasSkippedChecks) { + printBranding(); + logger.dim(' Score not shown — some checks could not complete.'); + } else if (scoreResult) { + printBranding(scoreResult.score); + printScoreGauge(scoreResult.score, scoreResult.label); + } else { + logger.dim(` ${noScoreMessage}`); + } + return { diagnostics, scoreResult, skippedChecks, projectInfo }; + } + + printDiagnostics(diagnostics, options.verbose); + + const displayedSourceFileCount = isDiffMode ? includePaths.length : projectInfo.sourceFileCount; + + printSummary( + diagnostics, + elapsedMilliseconds, + scoreResult, + projectInfo.projectName, + displayedSourceFileCount, + noScoreMessage, + options.verbose, + ); + + if (hasSkippedChecks) { + const skippedLabel = skippedChecks.join(' and '); + logger.break(); + logger.warn(` Note: ${skippedLabel} checks failed — score may be incomplete.`); + } + + return { diagnostics, scoreResult, skippedChecks, projectInfo }; +}; diff --git a/packages/vue-doctor/src/types.ts b/packages/vue-doctor/src/types.ts new file mode 100644 index 0000000..8404035 --- /dev/null +++ b/packages/vue-doctor/src/types.ts @@ -0,0 +1,67 @@ +import type { BaseDoctorConfig, Diagnostic, ScoreResult } from '@framework-doctor/core'; + +export type VueFramework = 'vue' | 'nuxt'; + +export interface ProjectInfo { + rootDirectory: string; + projectName: string; + vueVersion: string | null; + framework: VueFramework; + hasTypeScript: boolean; + sourceFileCount: number; +} + +export type { + Diagnostic, + DiffInfo, + IgnoreConfig, + ScoreGuardrailInput, + ScoreResult, +} from '@framework-doctor/core'; + +export interface ScanOptions { + lint?: boolean; + deadCode?: boolean; + verbose?: boolean; + scoreOnly?: boolean; + includePaths?: string[]; +} + +export interface ScanResult { + diagnostics: Diagnostic[]; + scoreResult: ScoreResult | null; + skippedChecks: string[]; + projectInfo: ProjectInfo; +} + +export interface VueDoctorConfig extends BaseDoctorConfig {} + +export interface WorkspacePackage { + name: string; + directory: string; +} + +export interface HandleErrorOptions { + shouldExit: boolean; +} + +export interface PackageJson { + name?: string; + dependencies?: Record; + devDependencies?: Record; + peerDependencies?: Record; + workspaces?: string[] | { packages: string[] }; +} + +export interface DiagnoseOptions { + lint?: boolean; + deadCode?: boolean; + includePaths?: string[]; +} + +export interface DiagnoseResult { + diagnostics: Diagnostic[]; + score: ScoreResult | null; + project: ProjectInfo; + elapsedMilliseconds: number; +} diff --git a/packages/vue-doctor/src/utils/calculate-score.ts b/packages/vue-doctor/src/utils/calculate-score.ts new file mode 100644 index 0000000..fd08022 --- /dev/null +++ b/packages/vue-doctor/src/utils/calculate-score.ts @@ -0,0 +1,3 @@ +import { calculateScore as calculateScoreFromCore } from '@framework-doctor/core'; + +export const calculateScore = calculateScoreFromCore; diff --git a/packages/vue-doctor/src/utils/check-reduced-motion.ts b/packages/vue-doctor/src/utils/check-reduced-motion.ts new file mode 100644 index 0000000..ad17005 --- /dev/null +++ b/packages/vue-doctor/src/utils/check-reduced-motion.ts @@ -0,0 +1,52 @@ +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { VUE_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 = '"*.vue" "*.ts" "*.tsx" "*.js" "*.jsx" "*.css" "*.scss"'; + +const MISSING_REDUCED_MOTION_DIAGNOSTIC: Diagnostic = { + filePath: 'package.json', + plugin: 'vue-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) => + VUE_MOTION_LIBRARIES.has(packageName), + ); + } catch { + return []; + } + if (!hasMotionLibrary) return []; + + try { + execSync(`git grep -ql -E "${REDUCED_MOTION_GREP_PATTERN}" -- ${REDUCED_MOTION_FILE_GLOBS}`, { + cwd: rootDirectory, + stdio: 'pipe', + }); + return []; + } catch { + return [MISSING_REDUCED_MOTION_DIAGNOSTIC]; + } +}; diff --git a/packages/vue-doctor/src/utils/combine-diagnostics.ts b/packages/vue-doctor/src/utils/combine-diagnostics.ts new file mode 100644 index 0000000..9e29b07 --- /dev/null +++ b/packages/vue-doctor/src/utils/combine-diagnostics.ts @@ -0,0 +1,26 @@ +import { SOURCE_FILE_PATTERN } from '../constants.js'; +import type { Diagnostic, VueDoctorConfig } from '../types.js'; +import { checkReducedMotion } from './check-reduced-motion.js'; +import { filterIgnoredDiagnostics } from './filter-diagnostics.js'; + +export const computeVueIncludePaths = (includePaths: string[]): string[] | undefined => + includePaths.length > 0 + ? includePaths.filter((filePath) => SOURCE_FILE_PATTERN.test(filePath)) + : undefined; + +export const combineDiagnostics = ( + lintDiagnostics: Diagnostic[], + deadCodeDiagnostics: Diagnostic[], + securityDiagnostics: Diagnostic[], + directory: string, + isDiffMode: boolean, + userConfig: VueDoctorConfig | null, +): Diagnostic[] => { + const allDiagnostics = [ + ...lintDiagnostics, + ...deadCodeDiagnostics, + ...securityDiagnostics, + ...(isDiffMode ? [] : checkReducedMotion(directory)), + ]; + return userConfig ? filterIgnoredDiagnostics(allDiagnostics, userConfig) : allDiagnostics; +}; diff --git a/packages/vue-doctor/src/utils/discover-project.ts b/packages/vue-doctor/src/utils/discover-project.ts new file mode 100644 index 0000000..a78ae27 --- /dev/null +++ b/packages/vue-doctor/src/utils/discover-project.ts @@ -0,0 +1,177 @@ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { SOURCE_FILE_PATTERN } from '../constants.js'; +import type { PackageJson, ProjectInfo, VueFramework, WorkspacePackage } from '../types.js'; +import { readPackageJson } from './read-package-json.js'; + +const collectDependencies = (packageJson: PackageJson): Record => ({ + ...packageJson.peerDependencies, + ...packageJson.dependencies, + ...packageJson.devDependencies, +}); + +const hasVueDependency = (packageJson: PackageJson): boolean => { + const allDeps = collectDependencies(packageJson); + return Object.keys(allDeps).some( + (packageName) => packageName === 'vue' || packageName === 'nuxt', + ); +}; + +const parsePnpmWorkspacePatterns = (rootDirectory: string): string[] => { + const workspacePath = path.join(rootDirectory, 'pnpm-workspace.yaml'); + if (!fs.existsSync(workspacePath)) return []; + + const content = fs.readFileSync(workspacePath, 'utf-8'); + const patterns: string[] = []; + let isInsidePackagesBlock = false; + + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (trimmed === 'packages:') { + isInsidePackagesBlock = true; + continue; + } + if (isInsidePackagesBlock && trimmed.startsWith('-')) { + patterns.push(trimmed.replace(/^-\s*/, '').replace(/["']/g, '')); + } else if (isInsidePackagesBlock && trimmed.length > 0 && !trimmed.startsWith('#')) { + isInsidePackagesBlock = false; + } + } + + return patterns; +}; + +const getWorkspacePatterns = (rootDirectory: string, packageJson: PackageJson): string[] => { + const pnpmPatterns = parsePnpmWorkspacePatterns(rootDirectory); + if (pnpmPatterns.length > 0) return pnpmPatterns; + + if (Array.isArray(packageJson.workspaces)) { + return packageJson.workspaces; + } + + if (packageJson.workspaces?.packages) { + return packageJson.workspaces.packages; + } + + return []; +}; + +const resolveWorkspaceDirectories = (rootDirectory: string, pattern: string): string[] => { + const cleanPattern = pattern.replace(/["']/g, '').replace(/\/\*\*$/, '/*'); + + if (!cleanPattern.includes('*')) { + const directoryPath = path.join(rootDirectory, cleanPattern); + if (fs.existsSync(directoryPath) && fs.existsSync(path.join(directoryPath, 'package.json'))) { + return [directoryPath]; + } + return []; + } + + const wildcardIndex = cleanPattern.indexOf('*'); + const baseDirectory = path.join(rootDirectory, cleanPattern.slice(0, wildcardIndex)); + const suffixAfterWildcard = cleanPattern.slice(wildcardIndex + 1); + + if (!fs.existsSync(baseDirectory) || !fs.statSync(baseDirectory).isDirectory()) { + return []; + } + + return fs + .readdirSync(baseDirectory) + .map((entry) => path.join(baseDirectory, entry, suffixAfterWildcard)) + .filter( + (entryPath) => + fs.existsSync(entryPath) && + fs.statSync(entryPath).isDirectory() && + fs.existsSync(path.join(entryPath, 'package.json')), + ); +}; + +export const discoverVueSubprojects = (rootDirectory: string): WorkspacePackage[] => { + if (!fs.existsSync(rootDirectory) || !fs.statSync(rootDirectory).isDirectory()) return []; + + const entries = fs.readdirSync(rootDirectory, { withFileTypes: true }); + const packages: WorkspacePackage[] = []; + + for (const entry of entries) { + if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') { + continue; + } + + const subdirectory = path.join(rootDirectory, entry.name); + const packageJsonPath = path.join(subdirectory, 'package.json'); + if (!fs.existsSync(packageJsonPath)) continue; + + const packageJson = readPackageJson(packageJsonPath); + if (!hasVueDependency(packageJson)) continue; + + const name = packageJson.name ?? entry.name; + packages.push({ name, directory: subdirectory }); + } + + return packages; +}; + +export const listWorkspacePackages = (rootDirectory: string): WorkspacePackage[] => { + const packageJsonPath = path.join(rootDirectory, 'package.json'); + if (!fs.existsSync(packageJsonPath)) return []; + + const packageJson = readPackageJson(packageJsonPath); + const patterns = getWorkspacePatterns(rootDirectory, packageJson); + if (patterns.length === 0) return []; + + const packages: WorkspacePackage[] = []; + + for (const pattern of patterns) { + const directories = resolveWorkspaceDirectories(rootDirectory, pattern); + for (const workspaceDirectory of directories) { + const workspacePackageJson = readPackageJson(path.join(workspaceDirectory, 'package.json')); + + if (!hasVueDependency(workspacePackageJson)) continue; + + const name = workspacePackageJson.name ?? path.basename(workspaceDirectory); + packages.push({ name, directory: workspaceDirectory }); + } + } + + return packages; +}; + +const countSourceFiles = (rootDirectory: string): number => { + const result = spawnSync('git', ['ls-files', '--cached', '--others', '--exclude-standard'], { + cwd: rootDirectory, + encoding: 'utf-8', + maxBuffer: 10 * 1024 * 1024, + }); + + if (result.status !== 0 || result.error) return 0; + + return result.stdout + .split('\n') + .filter((relativePath) => relativePath.length > 0 && SOURCE_FILE_PATTERN.test(relativePath)) + .length; +}; + +const detectFramework = (dependencies: Record): VueFramework => + dependencies.nuxt ? 'nuxt' : 'vue'; + +export const discoverProject = (directory: string): ProjectInfo => { + const packageJsonPath = path.join(directory, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + throw new Error(`No package.json found in ${directory}`); + } + + const packageJson = readPackageJson(packageJsonPath); + const dependencies = collectDependencies(packageJson); + const vueVersion = dependencies.vue ?? null; + const framework = detectFramework(dependencies); + + return { + rootDirectory: directory, + projectName: packageJson.name ?? path.basename(directory), + vueVersion, + framework, + hasTypeScript: fs.existsSync(path.join(directory, 'tsconfig.json')), + sourceFileCount: countSourceFiles(directory), + }; +}; diff --git a/packages/vue-doctor/src/utils/filter-diagnostics.ts b/packages/vue-doctor/src/utils/filter-diagnostics.ts new file mode 100644 index 0000000..21fc68e --- /dev/null +++ b/packages/vue-doctor/src/utils/filter-diagnostics.ts @@ -0,0 +1,28 @@ +import { compileGlobPattern } from '@framework-doctor/core'; +import type { Diagnostic, VueDoctorConfig } from '../types.js'; + +export const filterIgnoredDiagnostics = ( + diagnostics: Diagnostic[], + config: VueDoctorConfig | null, +): Diagnostic[] => { + if (!config) return diagnostics; + + const ignoredRules = new Set(Array.isArray(config.ignore?.rules) ? config.ignore.rules : []); + const ignoredFilePatterns = Array.isArray(config.ignore?.files) + ? config.ignore.files.map(compileGlobPattern) + : []; + + if (ignoredRules.size === 0 && ignoredFilePatterns.length === 0) { + return diagnostics; + } + + return diagnostics.filter((diagnostic) => { + const ruleIdentifier = `${diagnostic.plugin}/${diagnostic.rule}`; + if (ignoredRules.has(ruleIdentifier)) return false; + + const normalizedPath = diagnostic.filePath.replace(/\\/g, '/').replace(/^\.\//, ''); + if (ignoredFilePatterns.some((pattern) => pattern.test(normalizedPath))) return false; + + return true; + }); +}; diff --git a/packages/vue-doctor/src/utils/get-diff-files.ts b/packages/vue-doctor/src/utils/get-diff-files.ts new file mode 100644 index 0000000..7cbbf1f --- /dev/null +++ b/packages/vue-doctor/src/utils/get-diff-files.ts @@ -0,0 +1,10 @@ +import { + filterSourceFiles as filterSourceFilesCore, + getDiffInfo as getDiffInfoCore, + SOURCE_FILE_PATTERN_VUE, +} from '@framework-doctor/core'; + +export const getDiffInfo = getDiffInfoCore; + +export const filterSourceFiles = (files: string[]): string[] => + filterSourceFilesCore(files, SOURCE_FILE_PATTERN_VUE); diff --git a/packages/vue-doctor/src/utils/handle-error.ts b/packages/vue-doctor/src/utils/handle-error.ts new file mode 100644 index 0000000..65da7ea --- /dev/null +++ b/packages/vue-doctor/src/utils/handle-error.ts @@ -0,0 +1,24 @@ +import { logger } from '@framework-doctor/core'; +import type { HandleErrorOptions } from '../types.js'; + +const DEFAULT_HANDLE_ERROR_OPTIONS: HandleErrorOptions = { + shouldExit: true, +}; + +export const handleError = ( + error: unknown, + options: HandleErrorOptions = DEFAULT_HANDLE_ERROR_OPTIONS, +): void => { + logger.break(); + logger.error('Something went wrong. Please check the error below for more details.'); + logger.error('If the problem persists, please open an issue on GitHub.'); + logger.error(''); + if (error instanceof Error) { + logger.error(error.message); + } + logger.break(); + if (options.shouldExit) { + process.exit(1); + } + process.exitCode = 1; +}; diff --git a/packages/vue-doctor/src/utils/load-config.ts b/packages/vue-doctor/src/utils/load-config.ts new file mode 100644 index 0000000..4bba002 --- /dev/null +++ b/packages/vue-doctor/src/utils/load-config.ts @@ -0,0 +1,8 @@ +import { loadConfig as loadConfigFromCore } from '@framework-doctor/core'; +import type { VueDoctorConfig } from '../types.js'; + +const CONFIG_FILENAME = 'vue-doctor.config.json'; +const PACKAGE_JSON_CONFIG_KEY = 'vueDoctor'; + +export const loadConfig = (rootDirectory: string): VueDoctorConfig | null => + loadConfigFromCore(rootDirectory, CONFIG_FILENAME, PACKAGE_JSON_CONFIG_KEY); diff --git a/packages/vue-doctor/src/utils/read-package-json.ts b/packages/vue-doctor/src/utils/read-package-json.ts new file mode 100644 index 0000000..7d4f356 --- /dev/null +++ b/packages/vue-doctor/src/utils/read-package-json.ts @@ -0,0 +1,5 @@ +import { readJson } from '@framework-doctor/core'; +import type { PackageJson } from '../types.js'; + +export const readPackageJson = (packageJsonPath: string): PackageJson => + readJson(packageJsonPath); diff --git a/packages/vue-doctor/src/utils/run-eslint.ts b/packages/vue-doctor/src/utils/run-eslint.ts new file mode 100644 index 0000000..3e1f335 --- /dev/null +++ b/packages/vue-doctor/src/utils/run-eslint.ts @@ -0,0 +1,110 @@ +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import type { Diagnostic, VueFramework } from '../types.js'; + +interface LintMessage { + ruleId: string | null; + message: string; + line: number; + column: number; + severity: number; +} + +interface JsonOutput { + filePath: string; + messages: LintMessage[]; +} + +const parseLintResult = (result: JsonOutput, rootDirectory: string): Diagnostic[] => + result.messages.map((message) => { + const [plugin, rule] = (message.ruleId ?? 'eslint/unknown').split('/'); + return { + filePath: result.filePath, + plugin, + rule: rule ?? 'unknown', + severity: message.severity === 2 ? 'error' : 'warning', + message: message.message, + help: '', + line: message.line, + column: message.column, + category: 'correctness', + }; + }); + +const createEslintConfigContent = (hasNuxt: boolean): string => { + let content = `const pluginVue = require('eslint-plugin-vue'); +const pluginVuejsAccessibility = require('eslint-plugin-vuejs-accessibility'); + +const config = [ + ...pluginVue.configs['flat/recommended'], + ...pluginVuejsAccessibility.configs['flat/recommended'], + { + files: ['**/*.vue', '**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'], + languageOptions: { + parserOptions: { ecmaVersion: 'latest', sourceType: 'module', extraFileExtensions: ['.vue'] }, + globals: { console: 'readonly', process: 'readonly', Buffer: 'readonly', __dirname: 'readonly', __filename: 'readonly', module: 'readonly', require: 'readonly', exports: 'writable' }, + }, + }, +`; + + if (hasNuxt) { + content += ` { plugins: { nuxt: require('@nuxt/eslint-plugin') }, rules: { 'nuxt/prefer-import-meta': 'warn' } }, +`; + } + + content += `]; +module.exports = config; +`; + return content; +}; + +export const runEslint = async ( + rootDirectory: string, + framework: VueFramework, + includePaths: string[], +): Promise => { + const hasNuxt = framework === 'nuxt'; + const tempDir = mkdtempSync(path.join(tmpdir(), 'vue-doctor-eslint-')); + const configPath = path.join(tempDir, 'eslint.config.cjs'); + + try { + const configContent = createEslintConfigContent(hasNuxt); + writeFileSync(configPath, configContent, 'utf-8'); + + const require = createRequire(import.meta.url); + const eslintPackagePath = require.resolve('eslint/package.json'); + const eslintDir = path.dirname(eslintPackagePath); + const eslintBin = path.join(eslintDir, 'bin/eslint.js'); + + const targetPaths = includePaths.length > 0 ? includePaths : ['.']; + const result = spawnSync( + process.execPath, + [eslintBin, '--config', configPath, '--format', 'json', ...targetPaths], + { + cwd: rootDirectory, + encoding: 'utf-8', + }, + ); + + const output = result.stdout?.trim() || result.stderr?.trim() || '[]'; + let results: JsonOutput[] = []; + try { + results = JSON.parse(output) as JsonOutput[]; + } catch { + return []; + } + + const diagnostics: Diagnostic[] = []; + for (const lintResult of results) { + if (lintResult.messages?.length > 0) { + diagnostics.push(...parseLintResult(lintResult, rootDirectory)); + } + } + return diagnostics; + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +}; diff --git a/packages/vue-doctor/src/utils/run-knip.ts b/packages/vue-doctor/src/utils/run-knip.ts new file mode 100644 index 0000000..d66c4fe --- /dev/null +++ b/packages/vue-doctor/src/utils/run-knip.ts @@ -0,0 +1,95 @@ +import { spawnSync } from 'node:child_process'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import type { Diagnostic } from '../types.js'; + +interface KnipExport { + name: string; + line?: number; + col?: number; +} + +interface KnipIssue { + file: string; + exports?: KnipExport[]; + types?: KnipExport[]; + devDependencies?: Array<{ name: string }>; +} + +interface KnipJsonOutput { + files?: string[]; + issues?: KnipIssue[]; +} + +const asDiagnostics = ( + items: Array<{ file: string; message: string; rule: string; line?: number; column?: number }>, + rootDirectory: string, +): Diagnostic[] => + items.map((item) => ({ + filePath: path.resolve(rootDirectory, item.file), + plugin: 'knip', + rule: item.rule, + severity: 'warning', + message: item.message, + help: 'Remove dead code or keep it in an explicit public API boundary.', + line: item.line ?? 0, + column: item.column ?? 0, + category: 'maintainability', + })); + +export const runKnip = async (rootDirectory: string): Promise => { + const require = createRequire(import.meta.url); + const knipMainPath = require.resolve('knip'); + const knipBin = path.join(path.dirname(knipMainPath), '../bin/knip.js'); + + const run = spawnSync(process.execPath, [knipBin, '--reporter', 'json'], { + cwd: rootDirectory, + encoding: 'utf-8', + }); + + const stdout = run.stdout.toString().trim(); + if (!stdout) return []; + + let payload: KnipJsonOutput | null = null; + try { + payload = JSON.parse(stdout) as KnipJsonOutput; + } catch { + return []; + } + + const items: Array<{ + file: string; + message: string; + rule: string; + line?: number; + column?: number; + }> = []; + + for (const file of payload.files ?? []) { + items.push({ file, message: `Unused file: ${file}`, rule: 'files' }); + } + + for (const issue of payload.issues ?? []) { + const { file } = issue; + for (const exp of issue.exports ?? []) { + items.push({ + file, + message: `Unused export: ${exp.name}`, + rule: 'exports', + line: exp.line, + column: exp.col, + }); + } + for (const typeItem of issue.types ?? []) { + items.push({ + file, + message: `Unused type: ${typeItem.name}`, + rule: 'types', + line: typeItem.line, + column: typeItem.col, + }); + } + } + + return asDiagnostics(items, rootDirectory); +}; diff --git a/packages/vue-doctor/src/utils/run-security-scan.ts b/packages/vue-doctor/src/utils/run-security-scan.ts new file mode 100644 index 0000000..909a874 --- /dev/null +++ b/packages/vue-doctor/src/utils/run-security-scan.ts @@ -0,0 +1,17 @@ +import { + NO_V_HTML_RULE, + runSecurityScan as runSecurityScanCore, + SOURCE_FILE_PATTERN_WITH_VUE, + UNIVERSAL_SECURITY_RULES, +} from '@framework-doctor/core'; + +const VUE_PLUGIN = 'vue-doctor'; + +const VUE_SECURITY_RULES = [...UNIVERSAL_SECURITY_RULES, NO_V_HTML_RULE]; + +export const runSecurityScan = async (rootDirectory: string, includePaths: string[]) => + runSecurityScanCore(rootDirectory, includePaths, { + plugin: VUE_PLUGIN, + rules: VUE_SECURITY_RULES, + filePattern: SOURCE_FILE_PATTERN_WITH_VUE, + }); diff --git a/packages/vue-doctor/src/utils/run-vue-tsc.ts b/packages/vue-doctor/src/utils/run-vue-tsc.ts new file mode 100644 index 0000000..54fe954 --- /dev/null +++ b/packages/vue-doctor/src/utils/run-vue-tsc.ts @@ -0,0 +1,60 @@ +import { spawnSync } from 'node:child_process'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import type { Diagnostic } from '../types.js'; + +const TSC_OUTPUT_REGEX = /^(.+?)\((\d+),(\d+)\): (error|warning) (TS\d+): (.+)$/m; + +const parseOutput = (output: string, rootDirectory: string): Diagnostic[] => { + const diagnostics: Diagnostic[] = []; + const lines = output.split('\n'); + + for (const line of lines) { + const match = line.trim().match(TSC_OUTPUT_REGEX); + if (!match) continue; + + const [, filePath, lineStr, colStr, severity, rule, message] = match; + const resolvedPath = path.isAbsolute(filePath ?? '') + ? (filePath as string) + : path.resolve(rootDirectory, filePath ?? ''); + + diagnostics.push({ + filePath: resolvedPath, + plugin: 'vue-tsc', + rule: rule ?? 'unknown', + severity: severity === 'error' ? 'error' : 'warning', + message: message ?? 'Unknown vue-tsc issue', + help: '', + line: parseInt(lineStr ?? '0', 10), + column: parseInt(colStr ?? '0', 10), + category: 'correctness', + }); + } + + return diagnostics; +}; + +export const runVueTsc = async ( + rootDirectory: string, + includePaths: string[], +): Promise => { + const require = createRequire(import.meta.url); + const vueTscBin = require.resolve('vue-tsc/bin/vue-tsc.js'); + + const result = spawnSync(process.execPath, [vueTscBin, '--noEmit'], { + cwd: rootDirectory, + encoding: 'utf-8', + }); + + const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`; + const diagnostics = parseOutput(output, rootDirectory); + + if (includePaths.length > 0) { + const includeSet = new Set( + includePaths.map((filePath) => path.resolve(rootDirectory, filePath)), + ); + return diagnostics.filter((diagnostic) => includeSet.has(diagnostic.filePath)); + } + + return diagnostics; +}; diff --git a/packages/vue-doctor/src/utils/select-projects.ts b/packages/vue-doctor/src/utils/select-projects.ts new file mode 100644 index 0000000..b66e6b1 --- /dev/null +++ b/packages/vue-doctor/src/utils/select-projects.ts @@ -0,0 +1,95 @@ +import { highlighter, logger } from '@framework-doctor/core'; +import path from 'node:path'; +import prompts from 'prompts'; +import type { WorkspacePackage } from '../types.js'; +import { discoverVueSubprojects, listWorkspacePackages } from './discover-project.js'; + +const onCancel = () => { + logger.break(); + logger.log('Cancelled.'); + logger.break(); + process.exit(0); +}; + +export const selectProjects = async ( + rootDirectory: string, + projectFlag: string | undefined, + skipPrompts: boolean, +): Promise => { + let packages = listWorkspacePackages(rootDirectory); + if (packages.length === 0) { + packages = discoverVueSubprojects(rootDirectory); + } + + if (packages.length === 0) return [rootDirectory]; + if (packages.length === 1) { + logger.log( + `${highlighter.success('✔')} Select projects to scan ${highlighter.dim('›')} ${packages[0].name}`, + ); + return [packages[0].directory]; + } + + if (projectFlag) return resolveProjectFlag(projectFlag, packages); + + if (skipPrompts) { + printDiscoveredProjects(packages); + return packages.map((workspacePackage) => workspacePackage.directory); + } + + return promptProjectSelection(packages, rootDirectory); +}; + +const resolveProjectFlag = ( + projectFlag: string, + workspacePackages: WorkspacePackage[], +): string[] => { + const requestedNames = projectFlag.split(',').map((name) => name.trim()); + const resolvedDirectories: string[] = []; + + for (const requestedName of requestedNames) { + const matched = workspacePackages.find( + (workspacePackage) => + workspacePackage.name === requestedName || + path.basename(workspacePackage.directory) === requestedName, + ); + + if (!matched) { + const availableNames = workspacePackages + .map((workspacePackage) => workspacePackage.name) + .join(', '); + throw new Error(`Project "${requestedName}" not found. Available: ${availableNames}`); + } + + resolvedDirectories.push(matched.directory); + } + + return resolvedDirectories; +}; + +const printDiscoveredProjects = (packages: WorkspacePackage[]): void => { + logger.log( + `${highlighter.success('✔')} Select projects to scan ${highlighter.dim('›')} ${packages.map((workspacePackage) => workspacePackage.name).join(', ')}`, + ); +}; + +const promptProjectSelection = async ( + workspacePackages: WorkspacePackage[], + rootDirectory: string, +): Promise => { + const { selectedDirectories } = await prompts( + { + type: 'multiselect', + name: 'selectedDirectories', + message: 'Select projects to scan', + choices: workspacePackages.map((workspacePackage) => ({ + title: workspacePackage.name, + description: path.relative(rootDirectory, workspacePackage.directory), + value: workspacePackage.directory, + })), + min: 1, + }, + { onCancel }, + ); + + return selectedDirectories ?? []; +}; diff --git a/packages/vue-doctor/src/utils/skill-prompt.ts b/packages/vue-doctor/src/utils/skill-prompt.ts new file mode 100644 index 0000000..9e3a7aa --- /dev/null +++ b/packages/vue-doctor/src/utils/skill-prompt.ts @@ -0,0 +1,203 @@ +import { highlighter, logger, readGlobalConfig, writeGlobalConfig } from '@framework-doctor/core'; +import { execSync } from 'node:child_process'; +import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import prompts from 'prompts'; + +const HOME_DIRECTORY = homedir(); + +const SKILL_NAME = 'vue-doctor'; +const WINDSURF_MARKER = '# Vue Doctor'; + +const SKILL_DESCRIPTION = + 'Run after making Vue changes to catch issues early. Use when reviewing code, finishing a feature, or fixing bugs in a Vue project.'; + +const SKILL_BODY = `Scans your Vue codebase for security, performance, correctness, and architecture issues. Outputs a 0-100 score with actionable diagnostics. + +## Usage + +\`\`\`bash +npx -y @framework-doctor/vue@latest . --verbose --diff +\`\`\` + +Or use the unified CLI (auto-detects Vue): + +\`\`\`bash +npx -y @framework-doctor/cli . --verbose --diff +\`\`\` + +## Workflow + +Run after making changes to catch issues early. Fix errors first, then re-run to verify the score improved.`; + +const SKILL_CONTENT = `--- +name: ${SKILL_NAME} +description: ${SKILL_DESCRIPTION} +version: 1.0.0 +--- + +# Vue Doctor + +${SKILL_BODY} +`; + +const AGENTS_CONTENT = `# Vue Doctor + +${SKILL_DESCRIPTION} + +${SKILL_BODY} +`; + +const CODEX_AGENT_CONFIG = `interface: + display_name: "${SKILL_NAME}" + short_description: "Diagnose and fix Vue codebase health issues" +`; + +interface SkillTarget { + name: string; + detect: () => boolean; + install: () => void; +} + +const writeSkillFiles = (directory: string): void => { + mkdirSync(directory, { recursive: true }); + writeFileSync(join(directory, 'SKILL.md'), SKILL_CONTENT); + writeFileSync(join(directory, 'AGENTS.md'), AGENTS_CONTENT); +}; + +const isCommandAvailable = (command: string): boolean => { + try { + const whichCommand = process.platform === 'win32' ? 'where' : 'which'; + execSync(`${whichCommand} ${command}`, { stdio: 'ignore' }); + return true; + } catch { + return false; + } +}; + +const SKILL_TARGETS: SkillTarget[] = [ + { + name: 'Claude Code', + detect: () => existsSync(join(HOME_DIRECTORY, '.claude')), + install: () => writeSkillFiles(join(HOME_DIRECTORY, '.claude', 'skills', SKILL_NAME)), + }, + { + name: 'Amp Code', + detect: () => existsSync(join(HOME_DIRECTORY, '.amp')), + install: () => writeSkillFiles(join(HOME_DIRECTORY, '.config', 'amp', 'skills', SKILL_NAME)), + }, + { + name: 'Cursor', + detect: () => existsSync(join(HOME_DIRECTORY, '.cursor')), + install: () => writeSkillFiles(join(HOME_DIRECTORY, '.cursor', 'skills', SKILL_NAME)), + }, + { + name: 'OpenCode', + detect: () => + isCommandAvailable('opencode') || existsSync(join(HOME_DIRECTORY, '.config', 'opencode')), + install: () => + writeSkillFiles(join(HOME_DIRECTORY, '.config', 'opencode', 'skills', SKILL_NAME)), + }, + { + name: 'Windsurf', + detect: () => + existsSync(join(HOME_DIRECTORY, '.codeium')) || + existsSync(join(HOME_DIRECTORY, 'Library', 'Application Support', 'Windsurf')), + install: () => { + const memoriesDirectory = join(HOME_DIRECTORY, '.codeium', 'windsurf', 'memories'); + mkdirSync(memoriesDirectory, { recursive: true }); + const rulesFile = join(memoriesDirectory, 'global_rules.md'); + + if (existsSync(rulesFile)) { + const existingContent = readFileSync(rulesFile, 'utf-8'); + if (existingContent.includes(WINDSURF_MARKER)) return; + appendFileSync(rulesFile, `\n${WINDSURF_MARKER}\n\n${SKILL_CONTENT}`); + } else { + writeFileSync(rulesFile, `${WINDSURF_MARKER}\n\n${SKILL_CONTENT}`); + } + }, + }, + { + name: 'Antigravity', + detect: () => + isCommandAvailable('agy') || existsSync(join(HOME_DIRECTORY, '.gemini', 'antigravity')), + install: () => + writeSkillFiles(join(HOME_DIRECTORY, '.gemini', 'antigravity', 'skills', SKILL_NAME)), + }, + { + name: 'Gemini CLI', + detect: () => isCommandAvailable('gemini') || existsSync(join(HOME_DIRECTORY, '.gemini')), + install: () => writeSkillFiles(join(HOME_DIRECTORY, '.gemini', 'skills', SKILL_NAME)), + }, + { + name: 'Codex', + detect: () => isCommandAvailable('codex') || existsSync(join(HOME_DIRECTORY, '.codex')), + install: () => { + const skillDirectory = join(HOME_DIRECTORY, '.codex', 'skills', SKILL_NAME); + writeSkillFiles(skillDirectory); + const agentsDirectory = join(skillDirectory, 'agents'); + mkdirSync(agentsDirectory, { recursive: true }); + writeFileSync(join(agentsDirectory, 'openai.yaml'), CODEX_AGENT_CONFIG); + }, + }, +]; + +const installSkill = (): void => { + let installedCount = 0; + + for (const target of SKILL_TARGETS) { + if (!target.detect()) continue; + try { + target.install(); + logger.log(` ${highlighter.success('✔')} ${target.name}`); + installedCount++; + } catch { + logger.dim(` ✗ ${target.name} (failed)`); + } + } + + try { + const projectSkillDirectory = join('.agents', SKILL_NAME); + writeSkillFiles(projectSkillDirectory); + logger.log(` ${highlighter.success('✔')} .agents/`); + installedCount++; + } catch { + logger.dim(' ✗ .agents/ (failed)'); + } + + logger.break(); + if (installedCount === 0) { + logger.dim('No supported tools detected.'); + } else { + logger.success('Done! The skill will activate when working on Vue projects.'); + } +}; + +export const maybePromptSkillInstall = async (shouldSkipPrompts: boolean): Promise => { + const config = readGlobalConfig(); + if (config.skillPromptDismissed) return; + if (shouldSkipPrompts) return; + + logger.break(); + logger.log(`${highlighter.info('💡')} Have your coding agent fix these issues automatically?`); + logger.dim( + ` Install the ${highlighter.info('vue-doctor')} skill to teach Cursor, Claude Code,`, + ); + logger.dim(' and other AI agents how to diagnose and fix Vue issues.'); + logger.break(); + + const { shouldInstall } = await prompts({ + type: 'confirm', + name: 'shouldInstall', + message: 'Install skill? (recommended)', + initial: true, + }); + + if (shouldInstall) { + logger.break(); + installSkill(); + } + + writeGlobalConfig({ ...config, skillPromptDismissed: true }); +}; diff --git a/packages/vue-doctor/src/utils/telemetry.ts b/packages/vue-doctor/src/utils/telemetry.ts new file mode 100644 index 0000000..6c32e7a --- /dev/null +++ b/packages/vue-doctor/src/utils/telemetry.ts @@ -0,0 +1,52 @@ +import { + maybePromptAnalyticsConsent as coreMaybePromptAnalyticsConsent, + sendScanEvent as coreSendScanEvent, + shouldSendAnalytics as coreShouldSendAnalytics, + highlighter, + logger, + type TelemetryEventPayload, +} from '@framework-doctor/core'; +import prompts from 'prompts'; +import type { ProjectInfo, ScoreResult } from '../types.js'; + +export const shouldSendAnalytics = coreShouldSendAnalytics; + +export const maybePromptAnalyticsConsent = async (shouldSkipPrompts: boolean): Promise => + coreMaybePromptAnalyticsConsent(shouldSkipPrompts, async () => { + logger.break(); + logger.log(`${highlighter.info('?')} Help improve vue-doctor?`); + logger.dim(' Anonymous usage (framework, score range). No code or paths sent.'); + logger.break(); + + const { analyticsEnabled } = await prompts({ + type: 'confirm', + name: 'analyticsEnabled', + message: 'Share anonymous analytics?', + initial: true, + }); + return Boolean(analyticsEnabled); + }); + +const buildPayload = ( + projectInfo: ProjectInfo, + scoreResult: ScoreResult, + diagnosticCount: number, + options: { isDiffMode: boolean; cliVersion: string }, +): TelemetryEventPayload => ({ + doctor_family: 'vue', + framework: projectInfo.framework, + score: scoreResult.score, + diagnostic_count: diagnosticCount, + has_typescript: projectInfo.hasTypeScript, + is_diff_mode: options.isDiffMode, + cli_version: options.cliVersion, +}); + +export const sendScanEvent = ( + telemetryUrl: string, + projectInfo: ProjectInfo, + scoreResult: ScoreResult, + diagnosticCount: number, + options: { isDiffMode: boolean; cliVersion: string }, +): void => + coreSendScanEvent(telemetryUrl, buildPayload(projectInfo, scoreResult, diagnosticCount, options)); diff --git a/packages/vue-doctor/src/utils/write-diagnostics-dir.ts b/packages/vue-doctor/src/utils/write-diagnostics-dir.ts new file mode 100644 index 0000000..b9bfa68 --- /dev/null +++ b/packages/vue-doctor/src/utils/write-diagnostics-dir.ts @@ -0,0 +1,76 @@ +import { groupBy } from '@framework-doctor/core'; +import { randomUUID } from 'node:crypto'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { Diagnostic } from '../types.js'; + +const SEVERITY_ORDER: Record = { + error: 0, + warning: 1, +}; + +const sortBySeverity = (groups: [string, Diagnostic[]][]): [string, Diagnostic[]][] => + groups.toSorted(([, diagnosticsA], [, diagnosticsB]) => { + const severityA = SEVERITY_ORDER[diagnosticsA[0].severity]; + const severityB = SEVERITY_ORDER[diagnosticsB[0].severity]; + return severityA - severityB; + }); + +const buildFileLineMap = (diagnostics: Diagnostic[]): Map => { + const fileLines = new Map(); + for (const diagnostic of diagnostics) { + const lines = fileLines.get(diagnostic.filePath) ?? []; + if (diagnostic.line > 0) { + lines.push(diagnostic.line); + } + fileLines.set(diagnostic.filePath, lines); + } + return fileLines; +}; + +const formatRuleSummary = (ruleKey: string, ruleDiagnostics: Diagnostic[]): string => { + const firstDiagnostic = ruleDiagnostics[0]; + const fileLines = buildFileLineMap(ruleDiagnostics); + + const sections = [ + `Rule: ${ruleKey}`, + `Severity: ${firstDiagnostic.severity}`, + `Category: ${firstDiagnostic.category}`, + `Count: ${ruleDiagnostics.length}`, + '', + firstDiagnostic.message, + ]; + + if (firstDiagnostic.help) { + sections.push('', `Suggestion: ${firstDiagnostic.help}`); + } + + sections.push('', 'Files:'); + for (const [filePath, lines] of fileLines) { + const lineLabel = lines.length > 0 ? `: ${lines.join(', ')}` : ''; + sections.push(` ${filePath}${lineLabel}`); + } + + return sections.join('\n') + '\n'; +}; + +export const writeDiagnosticsDirectory = (diagnostics: Diagnostic[]): string => { + const outputDirectory = join(tmpdir(), `vue-doctor-${randomUUID()}`); + mkdirSync(outputDirectory); + + const ruleGroups = groupBy( + diagnostics, + (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`, + ); + const sortedRuleGroups = sortBySeverity([...ruleGroups.entries()]); + + for (const [ruleKey, ruleDiagnostics] of sortedRuleGroups) { + const fileName = ruleKey.replace(/\//g, '--') + '.txt'; + writeFileSync(join(outputDirectory, fileName), formatRuleSummary(ruleKey, ruleDiagnostics)); + } + + writeFileSync(join(outputDirectory, 'diagnostics.json'), JSON.stringify(diagnostics, null, 2)); + + return outputDirectory; +}; diff --git a/packages/vue-doctor/tests/placeholder.test.ts b/packages/vue-doctor/tests/placeholder.test.ts new file mode 100644 index 0000000..54f6c26 --- /dev/null +++ b/packages/vue-doctor/tests/placeholder.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from 'vitest'; + +describe('vue-doctor', () => { + it('exports diagnose', async () => { + const { diagnose } = await import('../src/index.js'); + expect(typeof diagnose).toBe('function'); + }); +}); diff --git a/packages/vue-doctor/tsconfig.json b/packages/vue-doctor/tsconfig.json new file mode 100644 index 0000000..bbbe887 --- /dev/null +++ b/packages/vue-doctor/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { "noEmit": true, "declarationMap": true }, + "include": ["src", "tests"] +} diff --git a/packages/vue-doctor/tsdown.config.ts b/packages/vue-doctor/tsdown.config.ts new file mode 100644 index 0000000..aafc961 --- /dev/null +++ b/packages/vue-doctor/tsdown.config.ts @@ -0,0 +1,33 @@ +import fs from 'node:fs'; +import { defineConfig } from 'tsdown'; + +const packageJson = JSON.parse(fs.readFileSync('package.json', 'utf8')) as { + version: string; +}; + +export default defineConfig([ + { + entry: { + cli: './src/cli.ts', + }, + external: ['knip', 'vue-tsc', 'eslint'], + dts: true, + target: 'node18', + platform: 'node', + env: { + VERSION: process.env.VERSION ?? packageJson.version, + }, + fixedExtension: false, + banner: '#!/usr/bin/env node', + }, + { + entry: { + index: './src/index.ts', + }, + external: ['knip', 'vue-tsc', 'eslint'], + dts: true, + target: 'node18', + platform: 'node', + fixedExtension: false, + }, +]); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a282a3c..c65eabe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,6 +9,9 @@ catalogs: '@changesets/cli': specifier: ^2.29.7 version: 2.29.8 + '@nuxt/eslint-plugin': + specifier: ^1.15.1 + version: 1.15.1 '@types/node': specifier: ^25.3.0 version: 25.3.0 @@ -21,6 +24,15 @@ catalogs: cross-env: specifier: ^10.1.0 version: 10.1.0 + eslint: + specifier: ^9.15.0 + version: 9.39.3 + eslint-plugin-vue: + specifier: ^9.31.0 + version: 9.33.0 + eslint-plugin-vuejs-accessibility: + specifier: ^2.2.0 + version: 2.5.0 knip: specifier: ^5.83.1 version: 5.84.1 @@ -57,6 +69,9 @@ catalogs: vitest: specifier: ^4.0.8 version: 4.0.18 + vue-tsc: + specifier: ^2.1.10 + version: 2.2.12 importers: @@ -85,7 +100,7 @@ importers: version: 3.8.1 prettier-plugin-organize-imports: specifier: ^4.3.0 - version: 4.3.0(prettier@3.8.1)(typescript@5.9.3) + version: 4.3.0(prettier@3.8.1)(typescript@5.9.3)(vue-tsc@2.2.12(typescript@5.9.3)) prettier-plugin-svelte: specifier: 'catalog:' version: 3.5.0(prettier@3.8.1)(svelte@5.53.0) @@ -120,6 +135,24 @@ importers: specifier: ^6.0.0 version: 6.4.1(@types/node@25.3.0)(jiti@2.6.1)(yaml@2.8.2) + examples/vue/demo-app: + devDependencies: + '@vitejs/plugin-vue': + specifier: ^5.0.0 + version: 5.2.4(vite@6.4.1(@types/node@25.3.0)(jiti@2.6.1)(yaml@2.8.2))(vue@3.5.28(typescript@5.9.3)) + typescript: + specifier: ^5.0.0 + version: 5.9.3 + vite: + specifier: ^6.0.0 + version: 6.4.1(@types/node@25.3.0)(jiti@2.6.1)(yaml@2.8.2) + vue: + specifier: ^3.0.0 + version: 3.5.28(typescript@5.9.3) + vue-tsc: + specifier: ^2.1.10 + version: 2.2.12(typescript@5.9.3) + packages/cli: dependencies: '@framework-doctor/react': @@ -128,6 +161,9 @@ importers: '@framework-doctor/svelte': specifier: workspace:* version: link:../svelte-doctor + '@framework-doctor/vue': + specifier: workspace:* + version: link:../vue-doctor devDependencies: '@types/node': specifier: 'catalog:' @@ -140,7 +176,7 @@ importers: version: 6.1.3 tsdown: specifier: 'catalog:' - version: 0.20.3(oxc-resolver@11.17.1)(typescript@5.9.3) + version: 0.20.3(oxc-resolver@11.17.1)(typescript@5.9.3)(vue-tsc@2.2.12(typescript@5.9.3)) typescript: specifier: 'catalog:' version: 5.9.3 @@ -168,7 +204,7 @@ importers: version: 6.1.3 tsdown: specifier: 'catalog:' - version: 0.20.3(oxc-resolver@11.17.1)(typescript@5.9.3) + version: 0.20.3(oxc-resolver@11.17.1)(typescript@5.9.3)(vue-tsc@2.2.12(typescript@5.9.3)) typescript: specifier: 'catalog:' version: 5.9.3 @@ -217,7 +253,7 @@ importers: version: 6.1.3 tsdown: specifier: 'catalog:' - version: 0.20.3(oxc-resolver@11.17.1)(typescript@5.9.3) + version: 0.20.3(oxc-resolver@11.17.1)(typescript@5.9.3)(vue-tsc@2.2.12(typescript@5.9.3)) vitest: specifier: 'catalog:' version: 4.0.18(@types/node@25.3.0)(jiti@2.6.1)(yaml@2.8.2) @@ -263,7 +299,68 @@ importers: version: 6.1.3 tsdown: specifier: 'catalog:' - version: 0.20.3(oxc-resolver@11.17.1)(typescript@5.9.3) + version: 0.20.3(oxc-resolver@11.17.1)(typescript@5.9.3)(vue-tsc@2.2.12(typescript@5.9.3)) + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 4.0.18(@types/node@25.3.0)(jiti@2.6.1)(yaml@2.8.2) + + packages/vue-doctor: + dependencies: + '@framework-doctor/core': + specifier: workspace:* + version: link:../core + '@nuxt/eslint-plugin': + specifier: 'catalog:' + version: 1.15.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + commander: + specifier: 'catalog:' + version: 14.0.3 + eslint: + specifier: 'catalog:' + version: 9.39.3(jiti@2.6.1) + eslint-plugin-vue: + specifier: 'catalog:' + version: 9.33.0(eslint@9.39.3(jiti@2.6.1)) + eslint-plugin-vuejs-accessibility: + specifier: 'catalog:' + version: 2.5.0(eslint@9.39.3(jiti@2.6.1))(globals@14.0.0) + knip: + specifier: 'catalog:' + version: 5.84.1(@types/node@25.3.0)(typescript@5.9.3) + ora: + specifier: 'catalog:' + version: 9.3.0 + oxlint: + specifier: 'catalog:' + version: 1.49.0 + picocolors: + specifier: 'catalog:' + version: 1.1.1 + prompts: + specifier: ^2.4.2 + version: 2.4.2 + vue-tsc: + specifier: 'catalog:' + version: 2.2.12(typescript@5.9.3) + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 25.3.0 + '@types/prompts': + specifier: 'catalog:' + version: 2.4.9 + cross-env: + specifier: 'catalog:' + version: 10.1.0 + rimraf: + specifier: 'catalog:' + version: 6.1.3 + tsdown: + specifier: 'catalog:' + version: 0.20.3(oxc-resolver@11.17.1)(typescript@5.9.3)(vue-tsc@2.2.12(typescript@5.9.3)) typescript: specifier: 'catalog:' version: 5.9.3 @@ -688,6 +785,11 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@nuxt/eslint-plugin@1.15.1': + resolution: {integrity: sha512-nUw2qOvo/ZqaqPWp2wPMBi6F1hgMKiDYGEJB/NHdUIYHNibgcIdq7cb9QksyudxumaPOD/M9h+62eHeUIzArIQ==} + peerDependencies: + eslint: ^9.0.0 || ^10.0.0 + '@oxc-project/types@0.112.0': resolution: {integrity: sha512-m6RebKHIRsax2iCwVpYW2ErQwa4ywHJrE4sCK3/8JK8ZZAWOKXaRJFl/uP51gaVyyXlaS4+chU1nSCdzYf6QqQ==} @@ -1265,6 +1367,50 @@ packages: '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@typescript-eslint/project-service@8.56.1': + resolution: {integrity: sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/scope-manager@8.56.1': + resolution: {integrity: sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.56.1': + resolution: {integrity: sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/types@8.56.1': + resolution: {integrity: sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.56.1': + resolution: {integrity: sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/utils@8.56.1': + resolution: {integrity: sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/visitor-keys@8.56.1': + resolution: {integrity: sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitejs/plugin-vue@5.2.4': + resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 + vue: ^3.2.25 + '@vitest/expect@4.0.18': resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} @@ -1294,6 +1440,55 @@ packages: '@vitest/utils@4.0.18': resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + '@volar/language-core@2.4.15': + resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==} + + '@volar/source-map@2.4.15': + resolution: {integrity: sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==} + + '@volar/typescript@2.4.15': + resolution: {integrity: sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==} + + '@vue/compiler-core@3.5.28': + resolution: {integrity: sha512-kviccYxTgoE8n6OCw96BNdYlBg2GOWfBuOW4Vqwrt7mSKWKwFVvI8egdTltqRgITGPsTFYtKYfxIG8ptX2PJHQ==} + + '@vue/compiler-dom@3.5.28': + resolution: {integrity: sha512-/1ZepxAb159jKR1btkefDP+J2xuWL5V3WtleRmxaT+K2Aqiek/Ab/+Ebrw2pPj0sdHO8ViAyyJWfhXXOP/+LQA==} + + '@vue/compiler-sfc@3.5.28': + resolution: {integrity: sha512-6TnKMiNkd6u6VeVDhZn/07KhEZuBSn43Wd2No5zaP5s3xm8IqFTHBj84HJah4UepSUJTro5SoqqlOY22FKY96g==} + + '@vue/compiler-ssr@3.5.28': + resolution: {integrity: sha512-JCq//9w1qmC6UGLWJX7RXzrGpKkroubey/ZFqTpvEIDJEKGgntuDMqkuWiZvzTzTA5h2qZvFBFHY7fAAa9475g==} + + '@vue/compiler-vue2@2.7.16': + resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==} + + '@vue/language-core@2.2.12': + resolution: {integrity: sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@vue/reactivity@3.5.28': + resolution: {integrity: sha512-gr5hEsxvn+RNyu9/9o1WtdYdwDjg5FgjUSBEkZWqgTKlo/fvwZ2+8W6AfKsc9YN2k/+iHYdS9vZYAhpi10kNaw==} + + '@vue/runtime-core@3.5.28': + resolution: {integrity: sha512-POVHTdbgnrBBIpnbYU4y7pOMNlPn2QVxVzkvEA2pEgvzbelQq4ZOUxbp2oiyo+BOtiYlm8Q44wShHJoBvDPAjQ==} + + '@vue/runtime-dom@3.5.28': + resolution: {integrity: sha512-4SXxSF8SXYMuhAIkT+eBRqOkWEfPu6nhccrzrkioA6l0boiq7sp18HCOov9qWJA5HML61kW8p/cB4MmBiG9dSA==} + + '@vue/server-renderer@3.5.28': + resolution: {integrity: sha512-pf+5ECKGj8fX95bNincbzJ6yp6nyzuLDhYZCeFxUNp8EBrQpPpQaLX3nNCp49+UbgbPun3CeVE+5CXVV1Xydfg==} + peerDependencies: + vue: 3.5.28 + + '@vue/shared@3.5.28': + resolution: {integrity: sha512-cfWa1fCGBxrvaHRhvV3Is0MgmrbSCxYTXCSCau2I0a1Xw1N1pHAvkWCiXPRAqjvToILvguNyEwjevUqAuBQWvQ==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1307,6 +1502,9 @@ packages: ajv@6.14.0: resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + alien-signals@1.0.13: + resolution: {integrity: sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==} + ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -1380,6 +1578,9 @@ packages: birpc@4.0.0: resolution: {integrity: sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==} + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -1489,6 +1690,17 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + de-indent@1.0.2: + resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1542,6 +1754,10 @@ packages: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + environment@1.1.0: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} @@ -1568,6 +1784,23 @@ packages: peerDependencies: eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + eslint-plugin-vue@9.33.0: + resolution: {integrity: sha512-174lJKuNsuDIlLpjeXc5E2Tss8P44uIimAfGD0b90k0NoirJqpG7stLuU9Vp/9ioTOrQdWVREc4mRd1BD+CvGw==} + engines: {node: ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.2.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 + + eslint-plugin-vuejs-accessibility@2.5.0: + resolution: {integrity: sha512-oZ2fL4tS91Cm/ezH3BueNP+FtpbbeS627OSqqgp9/lsN//glmoPcLBT6D53xwGocLtyBybaT99tX4ThBh8+ytA==} + engines: {node: '>=16.0.0'} + peerDependencies: + eslint: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 + globals: '>= 13.12.1' + + eslint-scope@7.2.2: + resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + eslint-scope@8.4.0: resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1580,6 +1813,10 @@ packages: resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + eslint@9.39.3: resolution: {integrity: sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1597,6 +1834,10 @@ packages: resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + espree@9.6.1: + resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} @@ -1748,6 +1989,10 @@ packages: engines: {node: '>=12'} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + globals@13.24.0: + resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} + engines: {node: '>=8'} + globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} @@ -1767,6 +2012,10 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + hermes-estree@0.25.1: resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} @@ -1962,6 +2211,9 @@ packages: lodash.startcase@4.4.0: resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} + lodash@4.17.23: + resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + log-symbols@7.0.1: resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==} engines: {node: '>=18'} @@ -2010,6 +2262,10 @@ packages: resolution: {integrity: sha512-FjiwU9HaHW6YB3H4a1sFudnv93lvydNjz2lmyUXR6IwKhGI+bgL3SOZrBGn6kvvX2pJvhEkGSGjyTHN47O4rqA==} engines: {node: '>=10'} + minimatch@9.0.6: + resolution: {integrity: sha512-kQAVowdR33euIqeA0+VZTDqU+qo1IeVY+hrKYtZMio3Pg0P0vuh/kwRylLUddJhB6pf3q/botcOvRtx4IN1wqQ==} + engines: {node: '>=16 || 14 >=14.17'} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} @@ -2028,6 +2284,9 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -2043,6 +2302,9 @@ packages: resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} @@ -2119,6 +2381,9 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -2165,6 +2430,10 @@ packages: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} + postcss-selector-parser@6.1.2: + resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} + engines: {node: '>=4'} + postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -2452,6 +2721,12 @@ packages: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true + ts-api-utils@2.4.0: + resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + tsdown@0.20.3: resolution: {integrity: sha512-qWOUXSbe4jN8JZEgrkc/uhJpC8VN2QpNu3eZkBWwNuTEjc/Ik1kcc54ycfcQ5QPRHeu9OQXaLfCI3o7pEJgB2w==} engines: {node: '>=20.19.0'} @@ -2518,6 +2793,10 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -2552,6 +2831,9 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vite@6.4.1: resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -2634,6 +2916,35 @@ packages: jsdom: optional: true + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + + vue-eslint-parser@10.4.0: + resolution: {integrity: sha512-Vxi9pJdbN3ZnVGLODVtZ7y4Y2kzAAE2Cm0CZ3ZDRvydVYxZ6VrnBhLikBsRS+dpwj4Jv4UCv21PTEwF5rQ9WXg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + + vue-eslint-parser@9.4.3: + resolution: {integrity: sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==} + engines: {node: ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: '>=6.0.0' + + vue-tsc@2.2.12: + resolution: {integrity: sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==} + hasBin: true + peerDependencies: + typescript: '>=5.0.0' + + vue@3.5.28: + resolution: {integrity: sha512-BRdrNfeoccSoIZeIhyPBfvWSLFP4q8J3u8Ju8Ug5vu3LdD+yTM13Sg4sKtljxozbnuMu1NB1X5HBHRYUzFocKg==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + walk-up-path@4.0.0: resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} engines: {node: 20 || >=22} @@ -2659,6 +2970,10 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + xml-name-validator@4.0.0: + resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} + engines: {node: '>=12'} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -3171,6 +3486,15 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 + '@nuxt/eslint-plugin@1.15.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/utils': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.3(jiti@2.6.1) + transitivePeerDependencies: + - supports-color + - typescript + '@oxc-project/types@0.112.0': {} '@oxc-resolver/binding-android-arm-eabi@11.17.1': @@ -3542,6 +3866,62 @@ snapshots: '@types/trusted-types@2.0.7': {} + '@typescript-eslint/project-service@8.56.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3) + '@typescript-eslint/types': 8.56.1 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.56.1': + dependencies: + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/visitor-keys': 8.56.1 + + '@typescript-eslint/tsconfig-utils@8.56.1(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/types@8.56.1': {} + + '@typescript-eslint/typescript-estree@8.56.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.56.1(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3) + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/visitor-keys': 8.56.1 + debug: 4.4.3 + minimatch: 10.2.2 + semver: 7.7.4 + tinyglobby: 0.2.15 + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.56.1 + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) + eslint: 9.39.3(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.56.1': + dependencies: + '@typescript-eslint/types': 8.56.1 + eslint-visitor-keys: 5.0.1 + + '@vitejs/plugin-vue@5.2.4(vite@6.4.1(@types/node@25.3.0)(jiti@2.6.1)(yaml@2.8.2))(vue@3.5.28(typescript@5.9.3))': + dependencies: + vite: 6.4.1(@types/node@25.3.0)(jiti@2.6.1)(yaml@2.8.2) + vue: 3.5.28(typescript@5.9.3) + '@vitest/expect@4.0.18': dependencies: '@standard-schema/spec': 1.1.0 @@ -3581,6 +3961,90 @@ snapshots: '@vitest/pretty-format': 4.0.18 tinyrainbow: 3.0.3 + '@volar/language-core@2.4.15': + dependencies: + '@volar/source-map': 2.4.15 + + '@volar/source-map@2.4.15': {} + + '@volar/typescript@2.4.15': + dependencies: + '@volar/language-core': 2.4.15 + path-browserify: 1.0.1 + vscode-uri: 3.1.0 + + '@vue/compiler-core@3.5.28': + dependencies: + '@babel/parser': 7.29.0 + '@vue/shared': 3.5.28 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.28': + dependencies: + '@vue/compiler-core': 3.5.28 + '@vue/shared': 3.5.28 + + '@vue/compiler-sfc@3.5.28': + dependencies: + '@babel/parser': 7.29.0 + '@vue/compiler-core': 3.5.28 + '@vue/compiler-dom': 3.5.28 + '@vue/compiler-ssr': 3.5.28 + '@vue/shared': 3.5.28 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.6 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.28': + dependencies: + '@vue/compiler-dom': 3.5.28 + '@vue/shared': 3.5.28 + + '@vue/compiler-vue2@2.7.16': + dependencies: + de-indent: 1.0.2 + he: 1.2.0 + + '@vue/language-core@2.2.12(typescript@5.9.3)': + dependencies: + '@volar/language-core': 2.4.15 + '@vue/compiler-dom': 3.5.28 + '@vue/compiler-vue2': 2.7.16 + '@vue/shared': 3.5.28 + alien-signals: 1.0.13 + minimatch: 9.0.6 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + optionalDependencies: + typescript: 5.9.3 + + '@vue/reactivity@3.5.28': + dependencies: + '@vue/shared': 3.5.28 + + '@vue/runtime-core@3.5.28': + dependencies: + '@vue/reactivity': 3.5.28 + '@vue/shared': 3.5.28 + + '@vue/runtime-dom@3.5.28': + dependencies: + '@vue/reactivity': 3.5.28 + '@vue/runtime-core': 3.5.28 + '@vue/shared': 3.5.28 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.28(vue@3.5.28(typescript@5.9.3))': + dependencies: + '@vue/compiler-ssr': 3.5.28 + '@vue/shared': 3.5.28 + vue: 3.5.28(typescript@5.9.3) + + '@vue/shared@3.5.28': {} + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -3594,6 +4058,8 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + alien-signals@1.0.13: {} + ansi-colors@4.1.3: {} ansi-escapes@7.3.0: @@ -3644,6 +4110,8 @@ snapshots: birpc@4.0.0: {} + boolbase@1.0.0: {} + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -3736,6 +4204,12 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + cssesc@3.0.0: {} + + csstype@3.2.3: {} + + de-indent@1.0.2: {} + debug@4.4.3: dependencies: ms: 2.1.3 @@ -3769,6 +4243,8 @@ snapshots: ansi-colors: 4.1.3 strip-ansi: 6.0.1 + entities@7.0.1: {} + environment@1.1.0: {} es-module-lexer@1.7.0: {} @@ -3817,6 +4293,34 @@ snapshots: transitivePeerDependencies: - supports-color + eslint-plugin-vue@9.33.0(eslint@9.39.3(jiti@2.6.1)): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3(jiti@2.6.1)) + eslint: 9.39.3(jiti@2.6.1) + globals: 13.24.0 + natural-compare: 1.4.0 + nth-check: 2.1.1 + postcss-selector-parser: 6.1.2 + semver: 7.7.4 + vue-eslint-parser: 9.4.3(eslint@9.39.3(jiti@2.6.1)) + xml-name-validator: 4.0.0 + transitivePeerDependencies: + - supports-color + + eslint-plugin-vuejs-accessibility@2.5.0(eslint@9.39.3(jiti@2.6.1))(globals@14.0.0): + dependencies: + aria-query: 5.3.2 + eslint: 9.39.3(jiti@2.6.1) + globals: 14.0.0 + vue-eslint-parser: 10.4.0(eslint@9.39.3(jiti@2.6.1)) + transitivePeerDependencies: + - supports-color + + eslint-scope@7.2.2: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + eslint-scope@8.4.0: dependencies: esrecurse: 4.3.0 @@ -3826,6 +4330,8 @@ snapshots: eslint-visitor-keys@4.2.1: {} + eslint-visitor-keys@5.0.1: {} + eslint@9.39.3(jiti@2.6.1): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3(jiti@2.6.1)) @@ -3875,6 +4381,12 @@ snapshots: acorn-jsx: 5.3.2(acorn@8.16.0) eslint-visitor-keys: 4.2.1 + espree@9.6.1: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 3.4.3 + esprima@4.0.1: {} esquery@1.7.0: @@ -4023,6 +4535,10 @@ snapshots: minimatch: 5.1.7 once: 1.4.0 + globals@13.24.0: + dependencies: + type-fest: 0.20.2 + globals@14.0.0: {} globby@11.1.0: @@ -4042,6 +4558,8 @@ snapshots: dependencies: function-bind: 1.1.2 + he@1.2.0: {} + hermes-estree@0.25.1: {} hermes-parser@0.25.1: @@ -4217,6 +4735,8 @@ snapshots: lodash.startcase@4.4.0: {} + lodash@4.17.23: {} + log-symbols@7.0.1: dependencies: is-unicode-supported: 2.1.0 @@ -4265,6 +4785,10 @@ snapshots: dependencies: brace-expansion: 2.0.2 + minimatch@9.0.6: + dependencies: + brace-expansion: 5.0.2 + minimist@1.2.8: {} minipass@7.1.3: {} @@ -4275,6 +4799,8 @@ snapshots: ms@2.1.3: {} + muggle-string@0.4.1: {} + nanoid@3.3.11: {} natural-compare@1.4.0: {} @@ -4285,6 +4811,10 @@ snapshots: dependencies: path-key: 4.0.0 + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + obug@2.1.1: {} once@1.4.0: @@ -4400,6 +4930,8 @@ snapshots: dependencies: callsites: 3.1.0 + path-browserify@1.0.1: {} + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -4427,6 +4959,11 @@ snapshots: pify@4.0.1: {} + postcss-selector-parser@6.1.2: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + postcss@8.5.6: dependencies: nanoid: 3.3.11 @@ -4435,10 +4972,12 @@ snapshots: prelude-ls@1.2.1: {} - prettier-plugin-organize-imports@4.3.0(prettier@3.8.1)(typescript@5.9.3): + prettier-plugin-organize-imports@4.3.0(prettier@3.8.1)(typescript@5.9.3)(vue-tsc@2.2.12(typescript@5.9.3)): dependencies: prettier: 3.8.1 typescript: 5.9.3 + optionalDependencies: + vue-tsc: 2.2.12(typescript@5.9.3) prettier-plugin-svelte@3.5.0(prettier@3.8.1)(svelte@5.53.0): dependencies: @@ -4497,7 +5036,7 @@ snapshots: glob: 13.0.6 package-json-from-dist: 1.0.1 - rolldown-plugin-dts@0.22.1(oxc-resolver@11.17.1)(rolldown@1.0.0-rc.3)(typescript@5.9.3): + rolldown-plugin-dts@0.22.1(oxc-resolver@11.17.1)(rolldown@1.0.0-rc.3)(typescript@5.9.3)(vue-tsc@2.2.12(typescript@5.9.3)): dependencies: '@babel/generator': 8.0.0-rc.1 '@babel/helper-validator-identifier': 8.0.0-rc.1 @@ -4511,6 +5050,7 @@ snapshots: rolldown: 1.0.0-rc.3 optionalDependencies: typescript: 5.9.3 + vue-tsc: 2.2.12(typescript@5.9.3) transitivePeerDependencies: - oxc-resolver @@ -4714,7 +5254,11 @@ snapshots: tree-kill@1.2.2: {} - tsdown@0.20.3(oxc-resolver@11.17.1)(typescript@5.9.3): + ts-api-utils@2.4.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + tsdown@0.20.3(oxc-resolver@11.17.1)(typescript@5.9.3)(vue-tsc@2.2.12(typescript@5.9.3)): dependencies: ansis: 4.2.0 cac: 6.7.14 @@ -4725,7 +5269,7 @@ snapshots: obug: 2.1.1 picomatch: 4.0.3 rolldown: 1.0.0-rc.3 - rolldown-plugin-dts: 0.22.1(oxc-resolver@11.17.1)(rolldown@1.0.0-rc.3)(typescript@5.9.3) + rolldown-plugin-dts: 0.22.1(oxc-resolver@11.17.1)(rolldown@1.0.0-rc.3)(typescript@5.9.3)(vue-tsc@2.2.12(typescript@5.9.3)) semver: 7.7.4 tinyexec: 1.0.2 tinyglobby: 0.2.15 @@ -4775,6 +5319,8 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-fest@0.20.2: {} + typescript@5.9.3: {} unconfig-core@7.5.0: @@ -4800,6 +5346,8 @@ snapshots: dependencies: punycode: 2.3.1 + util-deprecate@1.0.2: {} + vite@6.4.1(@types/node@25.3.0)(jiti@2.6.1)(yaml@2.8.2): dependencies: esbuild: 0.25.12 @@ -4855,6 +5403,49 @@ snapshots: - tsx - yaml + vscode-uri@3.1.0: {} + + vue-eslint-parser@10.4.0(eslint@9.39.3(jiti@2.6.1)): + dependencies: + debug: 4.4.3 + eslint: 9.39.3(jiti@2.6.1) + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + semver: 7.7.4 + transitivePeerDependencies: + - supports-color + + vue-eslint-parser@9.4.3(eslint@9.39.3(jiti@2.6.1)): + dependencies: + debug: 4.4.3 + eslint: 9.39.3(jiti@2.6.1) + eslint-scope: 7.2.2 + eslint-visitor-keys: 3.4.3 + espree: 9.6.1 + esquery: 1.7.0 + lodash: 4.17.23 + semver: 7.7.4 + transitivePeerDependencies: + - supports-color + + vue-tsc@2.2.12(typescript@5.9.3): + dependencies: + '@volar/typescript': 2.4.15 + '@vue/language-core': 2.2.12(typescript@5.9.3) + typescript: 5.9.3 + + vue@3.5.28(typescript@5.9.3): + dependencies: + '@vue/compiler-dom': 3.5.28 + '@vue/compiler-sfc': 3.5.28 + '@vue/runtime-dom': 3.5.28 + '@vue/server-renderer': 3.5.28(vue@3.5.28(typescript@5.9.3)) + '@vue/shared': 3.5.28 + optionalDependencies: + typescript: 5.9.3 + walk-up-path@4.0.0: {} which@2.0.2: @@ -4876,6 +5467,8 @@ snapshots: wrappy@1.0.2: {} + xml-name-validator@4.0.0: {} + yallist@3.1.1: {} yaml@2.8.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5bfdfc7..16d8e18 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,13 +1,18 @@ packages: - packages/* - examples/svelte/demo-app + - examples/vue/demo-app catalog: '@changesets/cli': ^2.29.7 + '@nuxt/eslint-plugin': ^1.15.1 '@types/node': ^25.3.0 '@types/prompts': ^2.4.9 commander: ^14.0.3 cross-env: ^10.1.0 + eslint: ^9.15.0 + eslint-plugin-vue: ^9.31.0 + eslint-plugin-vuejs-accessibility: ^2.2.0 knip: ^5.83.1 lint-staged: ^15.4.3 ora: ^9.3.0 @@ -20,6 +25,7 @@ catalog: turbo: ^2.5.6 typescript: ^5.9.2 vitest: ^4.0.8 + vue-tsc: ^2.1.10 onlyBuiltDependencies: - esbuild