diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be83c8d..2b80beb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,3 +22,5 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm quality:check + + - run: pnpm coverage:check diff --git a/.github/workflows/framework-doctor-scan.yml b/.github/workflows/framework-doctor-scan.yml index fae5201..e2e1c92 100644 --- a/.github/workflows/framework-doctor-scan.yml +++ b/.github/workflows/framework-doctor-scan.yml @@ -34,7 +34,7 @@ jobs: - uses: actions/checkout@v4 - name: Run Framework Doctor - uses: pitis/framework-doctor@main + uses: pitis/framework-doctor@v1 with: directory: ${{ inputs.directory }} fail-on-low-score: ${{ inputs.fail-on-low-score }} diff --git a/action.yml b/action.yml index 1d45462..d04c3ef 100644 --- a/action.yml +++ b/action.yml @@ -13,6 +13,10 @@ inputs: description: 'Output format: text or json' required: false default: 'text' + cli-version: + description: 'Version of @framework-doctor/cli to run' + required: false + default: '1.1.0' fail-on-low-score: description: 'Fail the step if score is below threshold' required: false @@ -36,9 +40,9 @@ runs: run: | USE_JSON=${{ inputs.post-to-pr == 'true' || inputs.fail-on-low-score == 'true' }} if [ "$USE_JSON" = "true" ]; then - npx -y @framework-doctor/cli "${{ inputs.directory }}" --format json -y 2>&1 | tee "$OUTPUT_FILE" + npx -y @framework-doctor/cli@${{ inputs.cli-version }} "${{ inputs.directory }}" --format json -y 2>&1 | tee "$OUTPUT_FILE" else - npx -y @framework-doctor/cli "${{ inputs.directory }}" --format ${{ inputs.format }} -y + npx -y @framework-doctor/cli@${{ inputs.cli-version }} "${{ inputs.directory }}" --format ${{ inputs.format }} -y fi shell: bash diff --git a/package.json b/package.json index f805cd2..2156cac 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,8 @@ "release": "pnpm build && changeset publish", "prepare": "husky", "demo:angular": "pnpm build && pnpm exec framework-doctor examples/angular/demo-app", - "demo:react": "pnpm build && pnpm exec framework-doctor examples/react/demo-app" + "demo:react": "pnpm build && pnpm exec framework-doctor examples/react/demo-app", + "coverage:check": "turbo run coverage --filter=./packages/*" }, "devDependencies": { "@framework-doctor/cli": "workspace:*", diff --git a/packages/angular-doctor/package.json b/packages/angular-doctor/package.json index 3cec575..f0d9d28 100644 --- a/packages/angular-doctor/package.json +++ b/packages/angular-doctor/package.json @@ -61,7 +61,8 @@ "lint:fix": "oxlint --fix src tests", "typecheck": "tsc --noEmit", "test": "pnpm build && vitest run", - "prepublishOnly": "pnpm run build" + "prepublishOnly": "pnpm run build", + "coverage": "pnpm build && vitest run --coverage" }, "dependencies": { "@framework-doctor/core": "workspace:*", @@ -83,7 +84,8 @@ "rimraf": "catalog:", "tsdown": "catalog:", "typescript": "catalog:", - "vitest": "catalog:" + "vitest": "catalog:", + "@vitest/coverage-v8": "catalog:" }, "packageManager": "pnpm@10.30.0" } diff --git a/packages/angular-doctor/src/types.ts b/packages/angular-doctor/src/types.ts index 8485119..0299015 100644 --- a/packages/angular-doctor/src/types.ts +++ b/packages/angular-doctor/src/types.ts @@ -40,10 +40,6 @@ export interface WorkspacePackage { directory: string; } -export interface HandleErrorOptions { - shouldExit: boolean; -} - export interface PackageJson { name?: string; dependencies?: Record; diff --git a/packages/angular-doctor/src/utils/check-reduced-motion.ts b/packages/angular-doctor/src/utils/check-reduced-motion.ts index e6a6632..31c79e0 100644 --- a/packages/angular-doctor/src/utils/check-reduced-motion.ts +++ b/packages/angular-doctor/src/utils/check-reduced-motion.ts @@ -1,9 +1,9 @@ +import { readPackageJson } from '@framework-doctor/core'; import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { ANGULAR_MOTION_LIBRARIES } from '../constants.js'; import type { Diagnostic } from '../types.js'; -import { readPackageJson } from './read-package-json.js'; const REDUCED_MOTION_GREP_PATTERN = 'prefers-reduced-motion|useReducedMotion'; const REDUCED_MOTION_FILE_GLOBS = [ diff --git a/packages/angular-doctor/src/utils/discover-project.ts b/packages/angular-doctor/src/utils/discover-project.ts index 6424b6c..b2e5430 100644 --- a/packages/angular-doctor/src/utils/discover-project.ts +++ b/packages/angular-doctor/src/utils/discover-project.ts @@ -1,9 +1,9 @@ +import { readPackageJson } from '@framework-doctor/core'; 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, WorkspacePackage } from '../types.js'; -import { readPackageJson } from './read-package-json.js'; const collectDependencies = (packageJson: PackageJson): Record => ({ ...packageJson.peerDependencies, diff --git a/packages/angular-doctor/src/utils/handle-error.ts b/packages/angular-doctor/src/utils/handle-error.ts index 65da7ea..a657371 100644 --- a/packages/angular-doctor/src/utils/handle-error.ts +++ b/packages/angular-doctor/src/utils/handle-error.ts @@ -1,24 +1 @@ -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; -}; +export { handleError } from '@framework-doctor/core'; diff --git a/packages/angular-doctor/src/utils/read-package-json.ts b/packages/angular-doctor/src/utils/read-package-json.ts deleted file mode 100644 index 7d4f356..0000000 --- a/packages/angular-doctor/src/utils/read-package-json.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { readJson } from '@framework-doctor/core'; -import type { PackageJson } from '../types.js'; - -export const readPackageJson = (packageJsonPath: string): PackageJson => - readJson(packageJsonPath); diff --git a/packages/angular-doctor/src/utils/run-knip.ts b/packages/angular-doctor/src/utils/run-knip.ts index d66c4fe..cf7d921 100644 --- a/packages/angular-doctor/src/utils/run-knip.ts +++ b/packages/angular-doctor/src/utils/run-knip.ts @@ -1,95 +1,5 @@ -import { spawnSync } from 'node:child_process'; -import { createRequire } from 'node:module'; -import path from 'node:path'; +import { runKnipJson } from '@framework-doctor/core'; 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); -}; +export const runKnip = async (rootDirectory: string): Promise => + runKnipJson(rootDirectory); diff --git a/packages/angular-doctor/tests/diagnose.test.ts b/packages/angular-doctor/tests/diagnose.test.ts new file mode 100644 index 0000000..cf7b125 --- /dev/null +++ b/packages/angular-doctor/tests/diagnose.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it, vi } from 'vitest'; + +const mockedProjectInfo = { + rootDirectory: '/mock/project', + projectName: 'mock-angular-project', + angularVersion: '^19.0.0', + hasTypeScript: true, + sourceFileCount: 36, +}; + +const mockedScanResult = { + diagnostics: [], + scoreResult: { score: 98, label: 'Great' }, + skippedChecks: [], + projectInfo: mockedProjectInfo, +}; + +vi.mock('../src/scan.js', () => ({ + scan: vi.fn(async () => mockedScanResult), +})); + +vi.mock('../src/utils/discover-project.js', () => ({ + discoverProject: vi.fn(() => mockedProjectInfo), +})); + +import { diagnose } from '../src/index.js'; + +describe('diagnose', () => { + it('returns mapped diagnose response', async () => { + const result = await diagnose('/mock/project', { + lint: false, + deadCode: false, + }); + + expect(result.project).toEqual(mockedProjectInfo); + expect(result.score).toEqual(mockedScanResult.scoreResult); + expect(result.diagnostics).toEqual([]); + expect(result.elapsedMilliseconds).toBeGreaterThanOrEqual(0); + }); +}); diff --git a/packages/angular-doctor/tests/placeholder.test.ts b/packages/angular-doctor/tests/placeholder.test.ts deleted file mode 100644 index 73033b7..0000000 --- a/packages/angular-doctor/tests/placeholder.test.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -describe('angular-doctor', () => { - it('exports diagnose', async () => { - const { diagnose } = await import('../src/index.js'); - expect(typeof diagnose).toBe('function'); - }); -}); diff --git a/packages/angular-doctor/vitest.config.ts b/packages/angular-doctor/vitest.config.ts index 092b1b3..6d396d1 100644 --- a/packages/angular-doctor/vitest.config.ts +++ b/packages/angular-doctor/vitest.config.ts @@ -3,5 +3,14 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { testTimeout: 30_000, + coverage: { + provider: 'v8', + thresholds: { + lines: 50, + functions: 50, + branches: 40, + statements: 50, + }, + }, }, }); diff --git a/packages/cli/package.json b/packages/cli/package.json index 5b67f6e..da3aafc 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -41,7 +41,11 @@ ], "scripts": { "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", + "coverage": "pnpm build && vitest run --coverage", "prepublishOnly": "pnpm run build" }, "dependencies": { @@ -52,11 +56,13 @@ "chokidar": "^4.0.0" }, "devDependencies": { + "@vitest/coverage-v8": "catalog:", "@types/node": "catalog:", "cross-env": "catalog:", "rimraf": "catalog:", "tsdown": "catalog:", - "typescript": "catalog:" + "typescript": "catalog:", + "vitest": "catalog:" }, "packageManager": "pnpm@10.30.0" } diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 5207d28..9f3edbb 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,278 +1,3 @@ -import type { FSWatcher } from 'chokidar'; -import chokidar from 'chokidar'; -import { spawnSync } from 'node:child_process'; -import { createRequire } from 'node:module'; -import path from 'node:path'; -import { WATCH_DEBOUNCE_MS } from './constants.js'; - -type Framework = 'svelte' | 'react' | 'vue' | 'angular'; - -interface FrameworkDefinition { - doctorPackage: string; - frameworkLabel: string; - dependencyIndicators: string[]; -} - -interface FrameworkDetectionResult { - detectedFramework: Framework | null; - matchedFrameworks: Framework[]; -} - -interface ParsedCliArguments { - directoryPath: string; - doctorArguments: string[]; - shouldWatch: boolean; - forcedFramework: Framework | null; -} - -const FRAMEWORK_DEFINITIONS: Record = { - svelte: { - doctorPackage: '@framework-doctor/svelte', - frameworkLabel: 'Svelte', - dependencyIndicators: ['svelte', '@sveltejs/kit'], - }, - react: { - doctorPackage: '@framework-doctor/react', - frameworkLabel: 'React', - dependencyIndicators: ['react', 'next', 'remix'], - }, - vue: { - doctorPackage: '@framework-doctor/vue', - frameworkLabel: 'Vue', - dependencyIndicators: ['vue', 'nuxt'], - }, - angular: { - doctorPackage: '@framework-doctor/angular', - frameworkLabel: 'Angular', - dependencyIndicators: ['@angular/core'], - }, -}; - -const readPackageDependencies = (directoryPath: string): Record | null => { - const packagePath = path.join(directoryPath, 'package.json'); - try { - const require = createRequire(import.meta.url); - const packageJson = require(packagePath) as Record; - return { - ...(packageJson.dependencies as Record), - ...(packageJson.devDependencies as Record), - ...(packageJson.peerDependencies as Record), - }; - } catch { - return null; - } -}; - -const detectFramework = (directoryPath: string): FrameworkDetectionResult => { - const dependencyMap = readPackageDependencies(directoryPath); - if (!dependencyMap) { - return { detectedFramework: null, matchedFrameworks: [] }; - } - - const matchedFrameworks = (Object.keys(FRAMEWORK_DEFINITIONS) as Framework[]).filter( - (frameworkName) => - FRAMEWORK_DEFINITIONS[frameworkName].dependencyIndicators.some( - (dependencyName) => dependencyMap[dependencyName] !== undefined, - ), - ); - - if (matchedFrameworks.length !== 1) { - return { detectedFramework: null, matchedFrameworks }; - } - - return { - detectedFramework: matchedFrameworks[0], - matchedFrameworks, - }; -}; - -const resolveDoctorCli = (pkg: string): string => { - const require = createRequire(import.meta.url); - const indexPath = require.resolve(pkg); - return path.join(path.dirname(indexPath), 'cli.js'); -}; - -const runDoctor = (framework: Framework, doctorArguments: string[]): number => { - const cwd = process.cwd(); - const frameworkDefinition = FRAMEWORK_DEFINITIONS[framework]; - const cliPath = resolveDoctorCli(frameworkDefinition.doctorPackage); - const result = spawnSync(process.execPath, [cliPath, ...doctorArguments], { - stdio: 'inherit', - cwd, - }); - return result.status ?? 1; -}; - -const isFramework = (frameworkValue: string): frameworkValue is Framework => - (Object.keys(FRAMEWORK_DEFINITIONS) as Framework[]).includes(frameworkValue as Framework); - -const parseFrameworkArgument = (rawArguments: string[]): string | null => { - for (let argumentIndex = 0; argumentIndex < rawArguments.length; argumentIndex += 1) { - const currentArgument = rawArguments[argumentIndex]; - if (currentArgument === '--framework') { - return rawArguments[argumentIndex + 1] ?? null; - } - if (currentArgument.startsWith('--framework=')) { - return currentArgument.slice('--framework='.length); - } - } - return null; -}; - -const parseCliArguments = (rawArguments: string[]): ParsedCliArguments => { - const forcedFrameworkValue = parseFrameworkArgument(rawArguments); - const forcedFramework = - forcedFrameworkValue && isFramework(forcedFrameworkValue) ? forcedFrameworkValue : null; - - const shouldWatch = rawArguments.includes('--watch') || rawArguments.includes('-w'); - - const argumentsWithoutCliFlags: string[] = []; - for (let argumentIndex = 0; argumentIndex < rawArguments.length; argumentIndex += 1) { - const currentArgument = rawArguments[argumentIndex]; - if (currentArgument === '--watch' || currentArgument === '-w') { - continue; - } - if (currentArgument === '--framework') { - argumentIndex += 1; - continue; - } - if (currentArgument.startsWith('--framework=')) { - continue; - } - argumentsWithoutCliFlags.push(currentArgument); - } - - const directoryIndex = argumentsWithoutCliFlags.findIndex( - (currentArgument) => !currentArgument.startsWith('-'), - ); - const directoryPath = - directoryIndex >= 0 - ? path.resolve(process.cwd(), argumentsWithoutCliFlags[directoryIndex]) - : process.cwd(); - const doctorOptions = - directoryIndex >= 0 - ? [ - ...argumentsWithoutCliFlags.slice(0, directoryIndex), - ...argumentsWithoutCliFlags.slice(directoryIndex + 1), - ] - : argumentsWithoutCliFlags; - - return { - directoryPath, - doctorArguments: [directoryPath, ...doctorOptions], - shouldWatch, - forcedFramework, - }; -}; - -const printUnsupportedFrameworkValue = (frameworkValue: string): void => { - const supportedFrameworkNames = (Object.keys(FRAMEWORK_DEFINITIONS) as Framework[]).join(', '); - console.error( - `Unsupported framework "${frameworkValue}". Supported values: ${supportedFrameworkNames}.`, - ); -}; - -const printFrameworkDetectionError = ( - directoryPath: string, - matchedFrameworks: Framework[], -): void => { - const hasAmbiguousMatch = matchedFrameworks.length > 1; - const matchedFrameworkLabels = matchedFrameworks - .map((frameworkName) => FRAMEWORK_DEFINITIONS[frameworkName].frameworkLabel) - .join(', '); - - const detectionMessage = hasAmbiguousMatch - ? `Detected multiple frameworks in ${directoryPath}: ${matchedFrameworkLabels}.` - : `Could not detect a supported framework in ${directoryPath}.`; - - console.error(` - ${detectionMessage} - - Supported: Svelte, React, Vue, Angular - - Make sure you're in a project root with a package.json that includes: - - Svelte: "svelte" or "@sveltejs/kit" - - React: "react", "next", or "remix" - - Vue: "vue" or "nuxt" - - Angular: "@angular/core" - - You can also force a framework: - - npx @framework-doctor/cli . --framework svelte - - npx @framework-doctor/cli . --framework react - - npx @framework-doctor/cli . --framework vue - - npx @framework-doctor/cli . --framework angular - - Or run a specific doctor directly: - - npx @framework-doctor/svelte . - - npx @framework-doctor/react . - - npx @framework-doctor/vue . - - npx @framework-doctor/angular . -`); -}; - -const wireWatchMode = (directoryPath: string, runScan: () => void): FSWatcher => { - const watcher = chokidar.watch(directoryPath, { - ignored: [ - /(^|[/\\])\../, - /node_modules/, - /dist/, - /\.git/, - /\.turbo/, - /coverage/, - /\.next/, - /\.svelte-kit/, - ], - }); - - let debounceTimer: ReturnType | null = null; - watcher.on('all', (eventName) => { - if (eventName !== 'add' && eventName !== 'change' && eventName !== 'unlink') { - return; - } - - if (debounceTimer) { - clearTimeout(debounceTimer); - } - - debounceTimer = setTimeout(() => { - debounceTimer = null; - runScan(); - }, WATCH_DEBOUNCE_MS); - }); - - return watcher; -}; - -const main = (): number => { - const rawArgs = process.argv.slice(2); - const forcedFrameworkValue = parseFrameworkArgument(rawArgs); - if (forcedFrameworkValue && !isFramework(forcedFrameworkValue)) { - printUnsupportedFrameworkValue(forcedFrameworkValue); - return 1; - } - - const { directoryPath, doctorArguments, forcedFramework, shouldWatch } = - parseCliArguments(rawArgs); - const detectionResult = detectFramework(directoryPath); - const frameworkToRun = forcedFramework ?? detectionResult.detectedFramework; - - if (!frameworkToRun) { - printFrameworkDetectionError(directoryPath, detectionResult.matchedFrameworks); - return 1; - } - - if (!shouldWatch) { - return runDoctor(frameworkToRun, doctorArguments); - } - - const runScan = () => { - runDoctor(frameworkToRun, doctorArguments); - }; - - runScan(); - console.log('\nWatching for changes... (Ctrl+C to stop)\n'); - wireWatchMode(directoryPath, runScan); - return 0; -}; +import { main } from './run-cli.js'; process.exit(main()); diff --git a/packages/cli/src/run-cli.ts b/packages/cli/src/run-cli.ts new file mode 100644 index 0000000..ad80337 --- /dev/null +++ b/packages/cli/src/run-cli.ts @@ -0,0 +1,299 @@ +import chokidar from 'chokidar'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { WATCH_DEBOUNCE_MS } from './constants.js'; + +type Framework = 'svelte' | 'react' | 'vue' | 'angular'; + +interface FrameworkDefinition { + doctorPackage: string; + frameworkLabel: string; + dependencyIndicators: string[]; +} + +interface FrameworkDetectionResult { + detectedFramework: Framework | null; + matchedFrameworks: Framework[]; +} + +interface ParsedCliArguments { + directoryPath: string; + doctorArguments: string[]; + shouldWatch: boolean; + forcedFramework: Framework | null; +} + +interface CliRuntime { + log: (message: string) => void; + error: (message: string) => void; + runDoctor: (framework: Framework, doctorArguments: string[]) => number; + wireWatchMode: (directoryPath: string, runScan: () => void) => void; +} + +const FRAMEWORK_DEFINITIONS: Record = { + svelte: { + doctorPackage: '@framework-doctor/svelte', + frameworkLabel: 'Svelte', + dependencyIndicators: ['svelte', '@sveltejs/kit'], + }, + react: { + doctorPackage: '@framework-doctor/react', + frameworkLabel: 'React', + dependencyIndicators: ['react', 'next', 'remix'], + }, + vue: { + doctorPackage: '@framework-doctor/vue', + frameworkLabel: 'Vue', + dependencyIndicators: ['vue', 'nuxt'], + }, + angular: { + doctorPackage: '@framework-doctor/angular', + frameworkLabel: 'Angular', + dependencyIndicators: ['@angular/core'], + }, +}; + +const parsePackageDependencies = (directoryPath: string): Record | null => { + const packagePath = path.join(directoryPath, 'package.json'); + if (!fs.existsSync(packagePath)) { + return null; + } + try { + const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf-8')) as Record< + string, + unknown + >; + return { + ...(packageJson.dependencies as Record), + ...(packageJson.devDependencies as Record), + ...(packageJson.peerDependencies as Record), + }; + } catch { + return null; + } +}; + +const detectFramework = (directoryPath: string): FrameworkDetectionResult => { + const dependencyMap = parsePackageDependencies(directoryPath); + if (!dependencyMap) { + return { detectedFramework: null, matchedFrameworks: [] }; + } + + const matchedFrameworks = (Object.keys(FRAMEWORK_DEFINITIONS) as Framework[]).filter( + (frameworkName) => + FRAMEWORK_DEFINITIONS[frameworkName].dependencyIndicators.some( + (dependencyName) => dependencyMap[dependencyName] !== undefined, + ), + ); + + if (matchedFrameworks.length !== 1) { + return { detectedFramework: null, matchedFrameworks }; + } + + return { + detectedFramework: matchedFrameworks[0], + matchedFrameworks, + }; +}; + +const resolveDoctorCli = (doctorPackage: string): string => { + const require = createRequire(import.meta.url); + const indexPath = require.resolve(doctorPackage); + return path.join(path.dirname(indexPath), 'cli.js'); +}; + +const runDoctor = (framework: Framework, doctorArguments: string[]): number => { + const frameworkDefinition = FRAMEWORK_DEFINITIONS[framework]; + const cliPath = resolveDoctorCli(frameworkDefinition.doctorPackage); + const result = spawnSync(process.execPath, [cliPath, ...doctorArguments], { + stdio: 'inherit', + cwd: process.cwd(), + }); + return result.status ?? 1; +}; + +const isFramework = (frameworkValue: string): frameworkValue is Framework => + (Object.keys(FRAMEWORK_DEFINITIONS) as Framework[]).includes(frameworkValue as Framework); + +const parseFrameworkArgument = (rawArguments: string[]): string | null => { + for (let argumentIndex = 0; argumentIndex < rawArguments.length; argumentIndex += 1) { + const currentArgument = rawArguments[argumentIndex]; + if (currentArgument === '--framework') { + return rawArguments[argumentIndex + 1] ?? null; + } + if (currentArgument.startsWith('--framework=')) { + return currentArgument.slice('--framework='.length); + } + } + return null; +}; + +const parseCliArguments = (rawArguments: string[]): ParsedCliArguments => { + const forcedFrameworkValue = parseFrameworkArgument(rawArguments); + const forcedFramework = + forcedFrameworkValue && isFramework(forcedFrameworkValue) ? forcedFrameworkValue : null; + + const shouldWatch = rawArguments.includes('--watch') || rawArguments.includes('-w'); + + const argumentsWithoutCliFlags: string[] = []; + for (let argumentIndex = 0; argumentIndex < rawArguments.length; argumentIndex += 1) { + const currentArgument = rawArguments[argumentIndex]; + if (currentArgument === '--watch' || currentArgument === '-w') { + continue; + } + if (currentArgument === '--framework') { + argumentIndex += 1; + continue; + } + if (currentArgument.startsWith('--framework=')) { + continue; + } + argumentsWithoutCliFlags.push(currentArgument); + } + + const directoryIndex = argumentsWithoutCliFlags.findIndex( + (currentArgument) => !currentArgument.startsWith('-'), + ); + const directoryPath = + directoryIndex >= 0 + ? path.resolve(process.cwd(), argumentsWithoutCliFlags[directoryIndex]) + : process.cwd(); + const doctorOptions = + directoryIndex >= 0 + ? [ + ...argumentsWithoutCliFlags.slice(0, directoryIndex), + ...argumentsWithoutCliFlags.slice(directoryIndex + 1), + ] + : argumentsWithoutCliFlags; + + return { + directoryPath, + doctorArguments: [directoryPath, ...doctorOptions], + shouldWatch, + forcedFramework, + }; +}; + +const printUnsupportedFrameworkValue = (frameworkValue: string, runtime: CliRuntime): void => { + const supportedFrameworkNames = (Object.keys(FRAMEWORK_DEFINITIONS) as Framework[]).join(', '); + runtime.error( + `Unsupported framework "${frameworkValue}". Supported values: ${supportedFrameworkNames}.`, + ); +}; + +const printFrameworkDetectionError = ( + directoryPath: string, + matchedFrameworks: Framework[], + runtime: CliRuntime, +): void => { + const hasAmbiguousMatch = matchedFrameworks.length > 1; + const matchedFrameworkLabels = matchedFrameworks + .map((frameworkName) => FRAMEWORK_DEFINITIONS[frameworkName].frameworkLabel) + .join(', '); + + const detectionMessage = hasAmbiguousMatch + ? `Detected multiple frameworks in ${directoryPath}: ${matchedFrameworkLabels}.` + : `Could not detect a supported framework in ${directoryPath}.`; + + runtime.error(` + ${detectionMessage} + + Supported: Svelte, React, Vue, Angular + + Make sure you're in a project root with a package.json that includes: + - Svelte: "svelte" or "@sveltejs/kit" + - React: "react", "next", or "remix" + - Vue: "vue" or "nuxt" + - Angular: "@angular/core" + + You can also force a framework: + - npx @framework-doctor/cli . --framework svelte + - npx @framework-doctor/cli . --framework react + - npx @framework-doctor/cli . --framework vue + - npx @framework-doctor/cli . --framework angular + + Or run a specific doctor directly: + - npx @framework-doctor/svelte . + - npx @framework-doctor/react . + - npx @framework-doctor/vue . + - npx @framework-doctor/angular . +`); +}; + +const wireWatchMode = (directoryPath: string, runScan: () => void): void => { + const watcher = chokidar.watch(directoryPath, { + ignored: [ + /(^|[/\\])\../, + /node_modules/, + /dist/, + /\.git/, + /\.turbo/, + /coverage/, + /\.next/, + /\.svelte-kit/, + ], + }); + + let debounceTimer: ReturnType | null = null; + watcher.on('all', (eventName) => { + if (eventName !== 'add' && eventName !== 'change' && eventName !== 'unlink') { + return; + } + + if (debounceTimer) { + clearTimeout(debounceTimer); + } + + debounceTimer = setTimeout(() => { + debounceTimer = null; + runScan(); + }, WATCH_DEBOUNCE_MS); + }); +}; + +const DEFAULT_CLI_RUNTIME: CliRuntime = { + log: (message: string) => { + console.log(message); + }, + error: (message: string) => { + console.error(message); + }, + runDoctor, + wireWatchMode, +}; + +export const main = ( + rawArguments: string[] = process.argv.slice(2), + runtime: CliRuntime = DEFAULT_CLI_RUNTIME, +): number => { + const forcedFrameworkValue = parseFrameworkArgument(rawArguments); + if (forcedFrameworkValue && !isFramework(forcedFrameworkValue)) { + printUnsupportedFrameworkValue(forcedFrameworkValue, runtime); + return 1; + } + + const { directoryPath, doctorArguments, forcedFramework, shouldWatch } = + parseCliArguments(rawArguments); + const detectionResult = detectFramework(directoryPath); + const frameworkToRun = forcedFramework ?? detectionResult.detectedFramework; + + if (!frameworkToRun) { + printFrameworkDetectionError(directoryPath, detectionResult.matchedFrameworks, runtime); + return 1; + } + + if (!shouldWatch) { + return runtime.runDoctor(frameworkToRun, doctorArguments); + } + + const runScan = () => { + runtime.runDoctor(frameworkToRun, doctorArguments); + }; + + runScan(); + runtime.log('\nWatching for changes... (Ctrl+C to stop)\n'); + runtime.wireWatchMode(directoryPath, runScan); + return 0; +}; diff --git a/packages/cli/tests/cli.test.ts b/packages/cli/tests/cli.test.ts new file mode 100644 index 0000000..20efebb --- /dev/null +++ b/packages/cli/tests/cli.test.ts @@ -0,0 +1,141 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { main } from '../src/run-cli.js'; + +interface RuntimeCall { + framework: 'svelte' | 'react' | 'vue' | 'angular'; + doctorArguments: string[]; +} + +interface RuntimeHarness { + calls: RuntimeCall[]; + logs: string[]; + errors: string[]; +} + +const createProjectDirectory = (packageJson: Record): string => { + const directoryPath = fs.mkdtempSync(path.join(os.tmpdir(), 'framework-doctor-cli-')); + fs.writeFileSync(path.join(directoryPath, 'package.json'), JSON.stringify(packageJson)); + return directoryPath; +}; + +const cleanupDirectories = (directoryPaths: string[]): void => { + for (const directoryPath of directoryPaths) { + fs.rmSync(directoryPath, { recursive: true, force: true }); + } +}; + +const createRuntimeHarness = (): RuntimeHarness => ({ + calls: [], + logs: [], + errors: [], +}); + +const createRuntime = ( + runtimeHarness: RuntimeHarness, + watchCalls: Array<{ directoryPath: string }> = [], +) => ({ + log: (message: string) => { + runtimeHarness.logs.push(message); + }, + error: (message: string) => { + runtimeHarness.errors.push(message); + }, + runDoctor: ( + framework: 'svelte' | 'react' | 'vue' | 'angular', + doctorArguments: string[], + ): number => { + runtimeHarness.calls.push({ framework, doctorArguments }); + return 0; + }, + wireWatchMode: (directoryPath: string) => { + watchCalls.push({ directoryPath }); + return { + close: () => Promise.resolve(), + on: () => undefined, + add: () => undefined, + unwatch: () => undefined, + removeAllListeners: () => undefined, + getWatched: () => ({}), + ref: () => undefined, + unref: () => undefined, + }; + }, +}); + +describe('main', () => { + const directoryPathsToCleanup: string[] = []; + + afterEach(() => { + cleanupDirectories(directoryPathsToCleanup); + directoryPathsToCleanup.length = 0; + vi.restoreAllMocks(); + }); + + it('runs detected framework doctor for React project', () => { + const reactProjectDirectory = createProjectDirectory({ + name: 'react-project', + dependencies: { react: '^19.0.0' }, + }); + directoryPathsToCleanup.push(reactProjectDirectory); + const runtimeHarness = createRuntimeHarness(); + const runtime = createRuntime(runtimeHarness); + + const exitCode = main([reactProjectDirectory, '--score'], runtime); + + expect(exitCode).toBe(0); + expect(runtimeHarness.calls).toHaveLength(1); + expect(runtimeHarness.calls[0]?.framework).toBe('react'); + expect(runtimeHarness.calls[0]?.doctorArguments[0]).toBe(reactProjectDirectory); + expect(runtimeHarness.calls[0]?.doctorArguments[1]).toBe('--score'); + }); + + it('fails when framework detection is ambiguous', () => { + const ambiguousProjectDirectory = createProjectDirectory({ + name: 'ambiguous-project', + dependencies: { react: '^19.0.0', vue: '^3.0.0' }, + }); + directoryPathsToCleanup.push(ambiguousProjectDirectory); + const runtimeHarness = createRuntimeHarness(); + const runtime = createRuntime(runtimeHarness); + + const exitCode = main([ambiguousProjectDirectory], runtime); + + expect(exitCode).toBe(1); + expect(runtimeHarness.calls).toHaveLength(0); + expect(runtimeHarness.errors[0]).toContain('Detected multiple frameworks'); + }); + + it('fails on unsupported framework value', () => { + const runtimeHarness = createRuntimeHarness(); + const runtime = createRuntime(runtimeHarness); + + const exitCode = main(['--framework', 'solid'], runtime); + + expect(exitCode).toBe(1); + expect(runtimeHarness.calls).toHaveLength(0); + expect(runtimeHarness.errors[0]).toContain('Unsupported framework "solid"'); + }); + + it('runs in watch mode and wires watcher', () => { + const angularProjectDirectory = createProjectDirectory({ + name: 'angular-project', + dependencies: { '@angular/core': '^19.0.0' }, + }); + directoryPathsToCleanup.push(angularProjectDirectory); + const runtimeHarness = createRuntimeHarness(); + const watchCalls: Array<{ directoryPath: string }> = []; + const runtime = createRuntime(runtimeHarness, watchCalls); + + const exitCode = main([angularProjectDirectory, '--watch'], runtime); + + expect(exitCode).toBe(0); + expect(runtimeHarness.calls).toHaveLength(1); + expect(runtimeHarness.calls[0]?.framework).toBe('angular'); + expect(watchCalls).toHaveLength(1); + expect(watchCalls[0]?.directoryPath).toBe(angularProjectDirectory); + expect(runtimeHarness.logs[0]).toContain('Watching for changes'); + }); +}); diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts new file mode 100644 index 0000000..62fc35b --- /dev/null +++ b/packages/cli/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + testTimeout: 5_000, + coverage: { + provider: 'v8', + thresholds: { + lines: 50, + functions: 50, + branches: 40, + statements: 50, + }, + }, + }, +}); diff --git a/packages/core/package.json b/packages/core/package.json index 004a283..21c397c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -26,7 +26,8 @@ "lint": "oxlint src", "lint:fix": "oxlint --fix src", "typecheck": "tsc --noEmit", - "test": "pnpm build && vitest run" + "test": "pnpm build && vitest run", + "coverage": "pnpm build && vitest run --coverage" }, "devDependencies": { "@types/node": "catalog:", @@ -35,7 +36,8 @@ "rimraf": "catalog:", "tsdown": "catalog:", "typescript": "catalog:", - "vitest": "catalog:" + "vitest": "catalog:", + "@vitest/coverage-v8": "catalog:" }, "packageManager": "pnpm@10.30.0", "dependencies": { diff --git a/packages/core/src/handle-error.ts b/packages/core/src/handle-error.ts new file mode 100644 index 0000000..48b2667 --- /dev/null +++ b/packages/core/src/handle-error.ts @@ -0,0 +1,27 @@ +import { logger } from './ui/logger.js'; + +export interface HandleErrorOptions { + shouldExit: boolean; +} + +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/core/src/index.ts b/packages/core/src/index.ts index e5bf064..c01e93b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -35,8 +35,11 @@ export { writeGlobalConfig, type FrameworkDoctorConfig, } from './global-config.js'; +export { handleError, type HandleErrorOptions } from './handle-error.js'; export { loadConfig, loadConfigWithUnified, loadUnifiedConfig } from './load-config.js'; +export { readPackageJson, type PackageJsonLike } from './read-package-json.js'; export { runAudit, type AuditResult } from './run-audit.js'; +export { runKnipJson } from './run-knip-json.js'; export { DANGEROUSLY_SET_INNER_HTML_RULE, HARDCODED_SECRET_RULES, diff --git a/packages/core/src/read-package-json.ts b/packages/core/src/read-package-json.ts new file mode 100644 index 0000000..91dadc7 --- /dev/null +++ b/packages/core/src/read-package-json.ts @@ -0,0 +1,12 @@ +import { readJson } from './utils/read-json.js'; + +export interface PackageJsonLike { + name?: string; + dependencies?: Record; + devDependencies?: Record; + peerDependencies?: Record; + workspaces?: string[] | { packages: string[] }; +} + +export const readPackageJson = (packageJsonPath: string): PackageJsonLike => + readJson(packageJsonPath); diff --git a/packages/core/src/run-knip-json.ts b/packages/core/src/run-knip-json.ts new file mode 100644 index 0000000..a0afa3e --- /dev/null +++ b/packages/core/src/run-knip-json.ts @@ -0,0 +1,97 @@ +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[]; +} + +interface KnipJsonOutput { + files?: string[]; + issues?: KnipIssue[]; +} + +interface KnipDiagnosticItem { + file: string; + message: string; + rule: string; + line?: number; + column?: number; +} + +const mapKnipDiagnostics = ( + diagnosticItems: KnipDiagnosticItem[], + rootDirectory: string, +): Diagnostic[] => + diagnosticItems.map((diagnosticItem) => ({ + filePath: path.resolve(rootDirectory, diagnosticItem.file), + plugin: 'knip', + rule: diagnosticItem.rule, + severity: 'warning', + message: diagnosticItem.message, + help: 'Remove dead code or keep it in an explicit public API boundary.', + line: diagnosticItem.line ?? 0, + column: diagnosticItem.column ?? 0, + category: 'maintainability', + })); + +export const runKnipJson = 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 runResult = spawnSync(process.execPath, [knipBin, '--reporter', 'json'], { + cwd: rootDirectory, + encoding: 'utf-8', + }); + + const standardOutput = runResult.stdout.toString().trim(); + if (!standardOutput) { + return []; + } + + let parsedPayload: KnipJsonOutput | null = null; + try { + parsedPayload = JSON.parse(standardOutput) as KnipJsonOutput; + } catch { + return []; + } + + const diagnosticItems: KnipDiagnosticItem[] = []; + + for (const filePath of parsedPayload.files ?? []) { + diagnosticItems.push({ file: filePath, message: `Unused file: ${filePath}`, rule: 'files' }); + } + + for (const issue of parsedPayload.issues ?? []) { + for (const issueExport of issue.exports ?? []) { + diagnosticItems.push({ + file: issue.file, + message: `Unused export: ${issueExport.name}`, + rule: 'exports', + line: issueExport.line, + column: issueExport.col, + }); + } + for (const issueType of issue.types ?? []) { + diagnosticItems.push({ + file: issue.file, + message: `Unused type: ${issueType.name}`, + rule: 'types', + line: issueType.line, + column: issueType.col, + }); + } + } + + return mapKnipDiagnostics(diagnosticItems, rootDirectory); +}; diff --git a/packages/core/tests/run-security-scan.test.ts b/packages/core/tests/run-security-scan.test.ts index 8e9a2c8..e86c222 100644 --- a/packages/core/tests/run-security-scan.test.ts +++ b/packages/core/tests/run-security-scan.test.ts @@ -5,6 +5,7 @@ import { afterAll, beforeEach, describe, expect, it } from 'vitest'; import { HARDCODED_SECRET_RULES, runSecurityScan } from '../src/security/index.js'; describe('runSecurityScan', () => { + const MAX_SECURITY_SCAN_DURATION_MS = 5_000; const tempDirectory = fs.mkdtempSync( path.join(os.tmpdir(), 'framework-doctor-security-regex-test-'), ); @@ -41,4 +42,20 @@ describe('runSecurityScan', () => { expect(stripeDiagnostics.length).toBe(1); expect(stripeDiagnostics[0].line).toBe(3); }); + + it('completes regex security scan within performance budget', async () => { + const filePath = path.join(tempDirectory, 'security-large.ts'); + const lines = Array.from({ length: 2_500 }, (_, lineIndex) => `const safe${lineIndex} = true;`); + fs.writeFileSync(filePath, lines.join('\n')); + + const startTime = performance.now(); + const diagnostics = await runSecurityScan(tempDirectory, [], { + plugin: 'react-doctor', + rules: HARDCODED_SECRET_RULES, + }); + const elapsedMilliseconds = performance.now() - startTime; + + expect(diagnostics).toHaveLength(0); + expect(elapsedMilliseconds).toBeLessThan(MAX_SECURITY_SCAN_DURATION_MS); + }); }); diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts index 154643f..62fc35b 100644 --- a/packages/core/vitest.config.ts +++ b/packages/core/vitest.config.ts @@ -3,5 +3,14 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { testTimeout: 5_000, + coverage: { + provider: 'v8', + thresholds: { + lines: 50, + functions: 50, + branches: 40, + statements: 50, + }, + }, }, }); diff --git a/packages/react-doctor/package.json b/packages/react-doctor/package.json index 44733d6..da40e21 100644 --- a/packages/react-doctor/package.json +++ b/packages/react-doctor/package.json @@ -64,7 +64,8 @@ "lint:fix": "oxlint --fix src tests", "typecheck": "tsc --noEmit", "test": "pnpm build && vitest run", - "prepublishOnly": "pnpm run build" + "prepublishOnly": "pnpm run build", + "coverage": "pnpm build && vitest run --coverage" }, "dependencies": { "@framework-doctor/core": "workspace:*", @@ -83,7 +84,8 @@ "cross-env": "catalog:", "rimraf": "catalog:", "tsdown": "catalog:", - "vitest": "catalog:" + "vitest": "catalog:", + "@vitest/coverage-v8": "catalog:" }, "packageManager": "pnpm@10.30.0" } diff --git a/packages/react-doctor/src/types.ts b/packages/react-doctor/src/types.ts index 596fa2f..1a9081b 100644 --- a/packages/react-doctor/src/types.ts +++ b/packages/react-doctor/src/types.ts @@ -92,10 +92,6 @@ export interface ScanOptions { includePaths?: string[]; } -export interface HandleErrorOptions { - shouldExit: boolean; -} - export interface WorkspacePackage { name: string; directory: string; diff --git a/packages/react-doctor/src/utils/check-reduced-motion.ts b/packages/react-doctor/src/utils/check-reduced-motion.ts index 2ff9b60..9367130 100644 --- a/packages/react-doctor/src/utils/check-reduced-motion.ts +++ b/packages/react-doctor/src/utils/check-reduced-motion.ts @@ -1,9 +1,9 @@ +import { readPackageJson } from '@framework-doctor/core'; import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { MOTION_LIBRARY_PACKAGES } from '../plugin/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 = ['*.ts', '*.tsx', '*.js', '*.jsx', '*.css', '*.scss'] as const; diff --git a/packages/react-doctor/src/utils/discover-project.ts b/packages/react-doctor/src/utils/discover-project.ts index 4e029c2..5fd2636 100644 --- a/packages/react-doctor/src/utils/discover-project.ts +++ b/packages/react-doctor/src/utils/discover-project.ts @@ -1,4 +1,4 @@ -import { findMonorepoRoot, isMonorepoRoot } from '@framework-doctor/core'; +import { findMonorepoRoot, isMonorepoRoot, readPackageJson } from '@framework-doctor/core'; import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; @@ -10,7 +10,6 @@ import type { ProjectInfo, WorkspacePackage, } from '../types.js'; -import { readPackageJson } from './read-package-json.js'; const REACT_COMPILER_PACKAGES = new Set([ 'babel-plugin-react-compiler', diff --git a/packages/react-doctor/src/utils/handle-error.ts b/packages/react-doctor/src/utils/handle-error.ts index 65da7ea..a657371 100644 --- a/packages/react-doctor/src/utils/handle-error.ts +++ b/packages/react-doctor/src/utils/handle-error.ts @@ -1,24 +1 @@ -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; -}; +export { handleError } from '@framework-doctor/core'; diff --git a/packages/react-doctor/src/utils/read-package-json.ts b/packages/react-doctor/src/utils/read-package-json.ts deleted file mode 100644 index 7d4f356..0000000 --- a/packages/react-doctor/src/utils/read-package-json.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { readJson } from '@framework-doctor/core'; -import type { PackageJson } from '../types.js'; - -export const readPackageJson = (packageJsonPath: string): PackageJson => - readJson(packageJsonPath); diff --git a/packages/react-doctor/tests/scan-performance.test.ts b/packages/react-doctor/tests/scan-performance.test.ts new file mode 100644 index 0000000..de9bf2f --- /dev/null +++ b/packages/react-doctor/tests/scan-performance.test.ts @@ -0,0 +1,21 @@ +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { scan } from '../src/scan.js'; + +const FIXTURES_DIRECTORY = path.resolve(import.meta.dirname, 'fixtures'); +const MAX_SCAN_DURATION_MS = 30_000; + +describe('scan performance', () => { + it('completes React scan within budget for fixture project', async () => { + const startTime = performance.now(); + + await scan(path.join(FIXTURES_DIRECTORY, 'basic-react'), { + lint: true, + deadCode: true, + audit: false, + }); + + const elapsedMilliseconds = performance.now() - startTime; + expect(elapsedMilliseconds).toBeLessThan(MAX_SCAN_DURATION_MS); + }); +}); diff --git a/packages/react-doctor/vitest.config.ts b/packages/react-doctor/vitest.config.ts index 092b1b3..6d396d1 100644 --- a/packages/react-doctor/vitest.config.ts +++ b/packages/react-doctor/vitest.config.ts @@ -3,5 +3,14 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { testTimeout: 30_000, + coverage: { + provider: 'v8', + thresholds: { + lines: 50, + functions: 50, + branches: 40, + statements: 50, + }, + }, }, }); diff --git a/packages/svelte-doctor/package.json b/packages/svelte-doctor/package.json index f3dadc2..ed2944a 100644 --- a/packages/svelte-doctor/package.json +++ b/packages/svelte-doctor/package.json @@ -62,7 +62,8 @@ "lint:fix": "oxlint --fix src tests", "typecheck": "tsc --noEmit", "test": "pnpm build && vitest run", - "prepublishOnly": "pnpm run build" + "prepublishOnly": "pnpm run build", + "coverage": "pnpm build && vitest run --coverage" }, "dependencies": { "@framework-doctor/core": "workspace:*", @@ -81,7 +82,8 @@ "rimraf": "catalog:", "tsdown": "catalog:", "typescript": "catalog:", - "vitest": "catalog:" + "vitest": "catalog:", + "@vitest/coverage-v8": "catalog:" }, "packageManager": "pnpm@10.30.0" } diff --git a/packages/svelte-doctor/src/types.ts b/packages/svelte-doctor/src/types.ts index e0107f8..053d320 100644 --- a/packages/svelte-doctor/src/types.ts +++ b/packages/svelte-doctor/src/types.ts @@ -42,10 +42,6 @@ export interface WorkspacePackage { directory: string; } -export interface HandleErrorOptions { - shouldExit: boolean; -} - export interface PackageJson { name?: string; dependencies?: Record; diff --git a/packages/svelte-doctor/src/utils/check-reduced-motion.ts b/packages/svelte-doctor/src/utils/check-reduced-motion.ts index b2d9eed..8c29692 100644 --- a/packages/svelte-doctor/src/utils/check-reduced-motion.ts +++ b/packages/svelte-doctor/src/utils/check-reduced-motion.ts @@ -1,8 +1,8 @@ +import { readPackageJson } from '@framework-doctor/core'; import { spawnSync } 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'; diff --git a/packages/svelte-doctor/src/utils/discover-project.ts b/packages/svelte-doctor/src/utils/discover-project.ts index e3de739..6431224 100644 --- a/packages/svelte-doctor/src/utils/discover-project.ts +++ b/packages/svelte-doctor/src/utils/discover-project.ts @@ -1,8 +1,8 @@ +import { readPackageJson } from '@framework-doctor/core'; import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; 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)$/; diff --git a/packages/svelte-doctor/src/utils/handle-error.ts b/packages/svelte-doctor/src/utils/handle-error.ts index 65da7ea..a657371 100644 --- a/packages/svelte-doctor/src/utils/handle-error.ts +++ b/packages/svelte-doctor/src/utils/handle-error.ts @@ -1,24 +1 @@ -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; -}; +export { handleError } from '@framework-doctor/core'; diff --git a/packages/svelte-doctor/src/utils/read-package-json.ts b/packages/svelte-doctor/src/utils/read-package-json.ts deleted file mode 100644 index 7d4f356..0000000 --- a/packages/svelte-doctor/src/utils/read-package-json.ts +++ /dev/null @@ -1,5 +0,0 @@ -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/tests/scan-performance.test.ts b/packages/svelte-doctor/tests/scan-performance.test.ts new file mode 100644 index 0000000..44dc298 --- /dev/null +++ b/packages/svelte-doctor/tests/scan-performance.test.ts @@ -0,0 +1,22 @@ +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { scan } from '../src/scan.js'; + +const FIXTURES_DIRECTORY = path.resolve(import.meta.dirname, 'fixtures'); +const MAX_SCAN_DURATION_MS = 30_000; + +describe('scan performance', () => { + it('completes Svelte scan within budget for fixture project', async () => { + const startTime = performance.now(); + + await scan(path.join(FIXTURES_DIRECTORY, 'basic-svelte'), { + lint: true, + jsTsLint: true, + deadCode: true, + audit: false, + }); + + const elapsedMilliseconds = performance.now() - startTime; + expect(elapsedMilliseconds).toBeLessThan(MAX_SCAN_DURATION_MS); + }); +}); diff --git a/packages/svelte-doctor/vitest.config.ts b/packages/svelte-doctor/vitest.config.ts index 092b1b3..6d396d1 100644 --- a/packages/svelte-doctor/vitest.config.ts +++ b/packages/svelte-doctor/vitest.config.ts @@ -3,5 +3,14 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { testTimeout: 30_000, + coverage: { + provider: 'v8', + thresholds: { + lines: 50, + functions: 50, + branches: 40, + statements: 50, + }, + }, }, }); diff --git a/packages/vue-doctor/package.json b/packages/vue-doctor/package.json index 37f308b..58da872 100644 --- a/packages/vue-doctor/package.json +++ b/packages/vue-doctor/package.json @@ -62,7 +62,8 @@ "lint:fix": "oxlint --fix src tests", "typecheck": "tsc --noEmit", "test": "pnpm build && vitest run", - "prepublishOnly": "pnpm run build" + "prepublishOnly": "pnpm run build", + "coverage": "pnpm build && vitest run --coverage" }, "dependencies": { "@framework-doctor/core": "workspace:*", @@ -85,7 +86,8 @@ "rimraf": "catalog:", "tsdown": "catalog:", "typescript": "catalog:", - "vitest": "catalog:" + "vitest": "catalog:", + "@vitest/coverage-v8": "catalog:" }, "packageManager": "pnpm@10.30.0" } diff --git a/packages/vue-doctor/src/types.ts b/packages/vue-doctor/src/types.ts index 997b2c0..4c00935 100644 --- a/packages/vue-doctor/src/types.ts +++ b/packages/vue-doctor/src/types.ts @@ -43,10 +43,6 @@ export interface WorkspacePackage { directory: string; } -export interface HandleErrorOptions { - shouldExit: boolean; -} - export interface PackageJson { name?: string; dependencies?: Record; diff --git a/packages/vue-doctor/src/utils/check-reduced-motion.ts b/packages/vue-doctor/src/utils/check-reduced-motion.ts index 625c021..37f35cd 100644 --- a/packages/vue-doctor/src/utils/check-reduced-motion.ts +++ b/packages/vue-doctor/src/utils/check-reduced-motion.ts @@ -1,9 +1,9 @@ +import { readPackageJson } from '@framework-doctor/core'; import { spawnSync } 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 = [ diff --git a/packages/vue-doctor/src/utils/discover-project.ts b/packages/vue-doctor/src/utils/discover-project.ts index a78ae27..bfe50fd 100644 --- a/packages/vue-doctor/src/utils/discover-project.ts +++ b/packages/vue-doctor/src/utils/discover-project.ts @@ -1,9 +1,9 @@ +import { readPackageJson } from '@framework-doctor/core'; 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, diff --git a/packages/vue-doctor/src/utils/handle-error.ts b/packages/vue-doctor/src/utils/handle-error.ts index 65da7ea..a657371 100644 --- a/packages/vue-doctor/src/utils/handle-error.ts +++ b/packages/vue-doctor/src/utils/handle-error.ts @@ -1,24 +1 @@ -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; -}; +export { handleError } from '@framework-doctor/core'; diff --git a/packages/vue-doctor/src/utils/read-package-json.ts b/packages/vue-doctor/src/utils/read-package-json.ts deleted file mode 100644 index 7d4f356..0000000 --- a/packages/vue-doctor/src/utils/read-package-json.ts +++ /dev/null @@ -1,5 +0,0 @@ -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-knip.ts b/packages/vue-doctor/src/utils/run-knip.ts index d66c4fe..cf7d921 100644 --- a/packages/vue-doctor/src/utils/run-knip.ts +++ b/packages/vue-doctor/src/utils/run-knip.ts @@ -1,95 +1,5 @@ -import { spawnSync } from 'node:child_process'; -import { createRequire } from 'node:module'; -import path from 'node:path'; +import { runKnipJson } from '@framework-doctor/core'; 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); -}; +export const runKnip = async (rootDirectory: string): Promise => + runKnipJson(rootDirectory); diff --git a/packages/vue-doctor/tests/diagnose.test.ts b/packages/vue-doctor/tests/diagnose.test.ts new file mode 100644 index 0000000..56f3f27 --- /dev/null +++ b/packages/vue-doctor/tests/diagnose.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from 'vitest'; + +const mockedProjectInfo = { + rootDirectory: '/mock/project', + projectName: 'mock-vue-project', + vueVersion: '^3.5.0', + framework: 'vue', + hasTypeScript: true, + sourceFileCount: 42, +}; + +const mockedScanResult = { + diagnostics: [], + scoreResult: { score: 100, label: 'Great' }, + skippedChecks: [], + projectInfo: mockedProjectInfo, +}; + +vi.mock('../src/scan.js', () => ({ + scan: vi.fn(async () => mockedScanResult), +})); + +vi.mock('../src/utils/discover-project.js', () => ({ + discoverProject: vi.fn(() => mockedProjectInfo), +})); + +import { diagnose } from '../src/index.js'; + +describe('diagnose', () => { + it('returns mapped diagnose response', async () => { + const result = await diagnose('/mock/project', { + lint: false, + deadCode: false, + }); + + expect(result.project).toEqual(mockedProjectInfo); + expect(result.score).toEqual(mockedScanResult.scoreResult); + expect(result.diagnostics).toEqual([]); + expect(result.elapsedMilliseconds).toBeGreaterThanOrEqual(0); + }); +}); diff --git a/packages/vue-doctor/tests/placeholder.test.ts b/packages/vue-doctor/tests/placeholder.test.ts deleted file mode 100644 index 54f6c26..0000000 --- a/packages/vue-doctor/tests/placeholder.test.ts +++ /dev/null @@ -1,8 +0,0 @@ -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/vitest.config.ts b/packages/vue-doctor/vitest.config.ts index 092b1b3..6d396d1 100644 --- a/packages/vue-doctor/vitest.config.ts +++ b/packages/vue-doctor/vitest.config.ts @@ -3,5 +3,14 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { testTimeout: 30_000, + coverage: { + provider: 'v8', + thresholds: { + lines: 50, + functions: 50, + branches: 40, + statements: 50, + }, + }, }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e862dd..6088a7c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,9 @@ catalogs: '@types/prompts': specifier: ^2.4.9 version: 2.4.9 + '@vitest/coverage-v8': + specifier: ^4.0.8 + version: 4.0.18 angular-eslint: specifier: ^19.4.0 version: 19.8.1 @@ -275,6 +278,9 @@ importers: '@types/prompts': specifier: 'catalog:' version: 2.4.9 + '@vitest/coverage-v8': + specifier: 'catalog:' + version: 4.0.18(vitest@4.0.18(@types/node@25.3.0)(jiti@2.6.1)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(yaml@2.8.2)) cross-env: specifier: 'catalog:' version: 10.1.0 @@ -312,6 +318,9 @@ importers: '@types/node': specifier: 'catalog:' version: 25.3.0 + '@vitest/coverage-v8': + specifier: 'catalog:' + version: 4.0.18(vitest@4.0.18(@types/node@25.3.0)(jiti@2.6.1)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(yaml@2.8.2)) cross-env: specifier: 'catalog:' version: 10.1.0 @@ -324,6 +333,9 @@ importers: typescript: specifier: 'catalog:' version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 4.0.18(@types/node@25.3.0)(jiti@2.6.1)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(yaml@2.8.2) packages/core: dependencies: @@ -337,6 +349,9 @@ importers: '@types/node': specifier: 'catalog:' version: 25.3.0 + '@vitest/coverage-v8': + specifier: 'catalog:' + version: 4.0.18(vitest@4.0.18(@types/node@25.3.0)(jiti@2.6.1)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(yaml@2.8.2)) cross-env: specifier: 'catalog:' version: 10.1.0 @@ -392,6 +407,9 @@ importers: '@types/prompts': specifier: 'catalog:' version: 2.4.9 + '@vitest/coverage-v8': + specifier: 'catalog:' + version: 4.0.18(vitest@4.0.18(@types/node@25.3.0)(jiti@2.6.1)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(yaml@2.8.2)) cross-env: specifier: 'catalog:' version: 10.1.0 @@ -438,6 +456,9 @@ importers: '@types/prompts': specifier: 'catalog:' version: 2.4.9 + '@vitest/coverage-v8': + specifier: 'catalog:' + version: 4.0.18(vitest@4.0.18(@types/node@25.3.0)(jiti@2.6.1)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(yaml@2.8.2)) cross-env: specifier: 'catalog:' version: 10.1.0 @@ -499,6 +520,9 @@ importers: '@types/prompts': specifier: 'catalog:' version: 2.4.9 + '@vitest/coverage-v8': + specifier: 'catalog:' + version: 4.0.18(vitest@4.0.18(@types/node@25.3.0)(jiti@2.6.1)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(yaml@2.8.2)) cross-env: specifier: 'catalog:' version: 10.1.0 @@ -1291,6 +1315,10 @@ packages: resolution: {integrity: sha512-ubmJ6TShyaD69VE9DQrlXcdkvJbmwWPB8qYj0H2kaJi29O7vJT9ajSdBd2W8CG34pwL9pYA74fi7RHC1qbLoVQ==} engines: {node: ^20.19.0 || >=22.12.0} + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@changesets/apply-release-plan@7.0.14': resolution: {integrity: sha512-ddBvf9PHdy2YY0OUiEl3TV78mH9sckndJR14QAt87KLEbIov81XO0q0QAmvooBxXlqRRP8I9B7XOzZwQG7JkWA==} @@ -3257,6 +3285,15 @@ packages: vite: ^5.0.0 || ^6.0.0 vue: ^3.2.25 + '@vitest/coverage-v8@4.0.18': + resolution: {integrity: sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==} + peerDependencies: + '@vitest/browser': 4.0.18 + vitest: 4.0.18 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@4.0.18': resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} @@ -3520,6 +3557,9 @@ packages: resolution: {integrity: sha512-trmleAnZ2PxN/loHWVhhx1qeOHSRXq4TDsBBxq3GqeJitfk3+jTQ+v/C1km/KYq9M7wKqCewMh+/NAvVH7m+bw==} engines: {node: '>=20.19.0'} + ast-v8-to-istanbul@0.3.12: + resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==} + autoprefixer@10.4.20: resolution: {integrity: sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==} engines: {node: ^10 || ^12 || >=14} @@ -4460,6 +4500,9 @@ packages: hpack.js@2.1.6: resolution: {integrity: sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + htmlparser2@10.1.0: resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} @@ -4725,6 +4768,14 @@ packages: resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==} engines: {node: '>=10'} + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} @@ -4740,6 +4791,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -4940,10 +4994,17 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.5.2: + resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} + make-dir@2.1.0: resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==} engines: {node: '>=6'} + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + make-fetch-happen@14.0.3: resolution: {integrity: sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ==} engines: {node: ^18.17.0 || >=20.5.0} @@ -7752,6 +7813,8 @@ snapshots: '@babel/helper-string-parser': 8.0.0-rc.2 '@babel/helper-validator-identifier': 8.0.0-rc.1 + '@bcoe/v8-coverage@1.0.2': {} + '@changesets/apply-release-plan@7.0.14': dependencies: '@changesets/config': 3.1.2 @@ -9436,6 +9499,20 @@ snapshots: vite: 6.4.1(@types/node@25.3.0)(jiti@2.6.1)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(yaml@2.8.2) vue: 3.5.28(typescript@5.9.3) + '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@types/node@25.3.0)(jiti@2.6.1)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(yaml@2.8.2))': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.0.18 + ast-v8-to-istanbul: 0.3.12 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.2 + obug: 2.1.1 + std-env: 3.10.0 + tinyrainbow: 3.0.3 + vitest: 4.0.18(@types/node@25.3.0)(jiti@2.6.1)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(yaml@2.8.2) + '@vitest/expect@4.0.18': dependencies: '@standard-schema/spec': 1.1.0 @@ -9757,6 +9834,12 @@ snapshots: estree-walker: 3.0.3 pathe: 2.0.3 + ast-v8-to-istanbul@0.3.12: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + autoprefixer@10.4.20(postcss@8.5.2): dependencies: browserslist: 4.28.1 @@ -10783,6 +10866,8 @@ snapshots: readable-stream: 2.3.8 wbuf: 1.7.3 + html-escaper@2.0.2: {} + htmlparser2@10.1.0: dependencies: domelementtype: 2.3.0 @@ -11014,6 +11099,17 @@ snapshots: transitivePeerDependencies: - supports-color + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + jackspeak@3.4.3: dependencies: '@isaacs/cliui': 8.0.2 @@ -11030,6 +11126,8 @@ snapshots: jiti@2.6.1: {} + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} js-yaml@3.14.2: @@ -11251,12 +11349,22 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.5.2: + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + make-dir@2.1.0: dependencies: pify: 4.0.1 semver: 5.7.2 optional: true + make-dir@4.0.0: + dependencies: + semver: 7.7.4 + make-fetch-happen@14.0.3: dependencies: '@npmcli/agent': 3.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1ce7171..7af2725 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -10,6 +10,7 @@ catalog: '@nuxt/eslint-plugin': ^1.15.1 '@types/node': ^25.3.0 '@types/prompts': ^2.4.9 + '@vitest/coverage-v8': ^4.0.8 angular-eslint: ^19.4.0 commander: ^14.0.3 cross-env: ^10.1.0 diff --git a/tsconfig.json b/tsconfig.json index 3dade09..e05651f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,15 @@ "target": "ESNext", "module": "ESNext", "moduleResolution": "bundler", + "baseUrl": ".", + "paths": { + "@framework-doctor/angular": ["packages/angular-doctor/src/index.ts"], + "@framework-doctor/cli": ["packages/cli/src/cli.ts"], + "@framework-doctor/core": ["packages/core/src/index.ts"], + "@framework-doctor/react": ["packages/react-doctor/src/index.ts"], + "@framework-doctor/svelte": ["packages/svelte-doctor/src/index.ts"], + "@framework-doctor/vue": ["packages/vue-doctor/src/index.ts"] + }, "declaration": true, "esModuleInterop": true, "skipLibCheck": true, diff --git a/turbo.json b/turbo.json index 917f763..e108c98 100644 --- a/turbo.json +++ b/turbo.json @@ -27,6 +27,10 @@ "test": { "dependsOn": ["build"], "outputs": [] + }, + "coverage": { + "dependsOn": ["build"], + "outputs": ["coverage/**"] } } }