UNPKG

@invisiblecities/sidequest-cqo

Version:

Configuration-agnostic TypeScript and ESLint orchestrator with real-time watch mode, SQLite persistence, and intelligent terminal detection

634 lines 28.9 kB
/** * @fileoverview Zod Detection Engine * * Detects Zod usage patterns and anti-patterns: * - Unused schemas (defined but never parsed) * - Over-validation (validating internal type-safe data) * - Missing validation (external APIs without schemas) */ import { BaseAuditEngine } from "./base-engine.js"; import { spawnSync } from "node:child_process"; import { PackageJsonSchema, } from "../utils/validation-schemas.js"; import { debugLog, warnLog, errorLog } from "../utils/debug-logger.js"; export class ZodDetectionEngine extends BaseAuditEngine { constructor(config = {}) { super("Zod Detection", "zod-detection", { enabled: true, priority: 3, timeout: 30_000, allowFailure: true, options: {}, ...config, }); } /** * Filter out non-Zod .parse() calls (JSON.parse, parseInt, etc.) */ isNonZodParse(schemaName, content) { // Common false positives const nonZodParsers = ["JSON", "parseInt", "parseFloat", "Date", "Number"]; if (nonZodParsers.includes(schemaName)) { return true; } // Check if it's JSON.parse() specifically if (content.includes("JSON.parse(")) { return true; } // Check if it's in a string literal (comments, error messages, etc.) if (content.includes("'") && content.includes("schema.parse(")) { return true; } if (content.includes('"') && content.includes("schema.parse(")) { return true; } if (content.includes("`") && content.includes("schema.parse(")) { return true; } // Check if it's a built-in parse method if (content.includes(`${schemaName}.parse(`) && (content.includes("parseInt") || content.includes("parseFloat"))) { return true; } return false; } async analyze(targetPath, _options = {}) { const violations = []; try { debugLog("ZodDetection", "Starting Zod analysis", { targetPath, options: _options, }); // Check if Zod is installed const hasZod = await this.hasZodDependency(); if (!hasZod) { debugLog("ZodDetection", "Zod not detected in project - skipping analysis"); console.log("[Zod Detection] Zod not detected in project - skipping analysis"); return []; } debugLog("ZodDetection", "Zod detected, analyzing usage patterns..."); console.log("[Zod Detection] Analyzing Zod usage patterns..."); const zodUsage = this.analyzeZodUsage(targetPath); debugLog("ZodDetection", "Zod usage analysis complete", { schemas: zodUsage.schemaDefinitions.length, parseUsages: zodUsage.parseUsages.length, safeParsUsages: zodUsage.safeParsUsages.length, }); // Detect unused schemas const unusedSchemas = this.findUnusedSchemas(zodUsage); if (unusedSchemas && Array.isArray(unusedSchemas)) { violations.push(...unusedSchemas); } // Detect over-validation patterns const overValidations = this.findOverValidations(zodUsage); if (overValidations && Array.isArray(overValidations)) { violations.push(...overValidations); } // Detect missing validation opportunities const missingValidations = this.findMissingValidations(targetPath); if (missingValidations && Array.isArray(missingValidations)) { violations.push(...missingValidations); } // Calculate runtime validation coverage const coverage = this.calculateValidationCoverage(zodUsage, targetPath); // Add coverage-based suggestions const coverageSuggestions = this.generateCoverageSuggestions(coverage); if (coverageSuggestions && Array.isArray(coverageSuggestions)) { violations.push(...coverageSuggestions); } debugLog("ZodDetection", "Analysis complete", { totalViolations: violations.length, }); return violations; } catch (error) { errorLog("ZodDetection", "Analysis failed", error); console.error("[Zod Detection] Analysis failed:", error); if (this.config.allowFailure) { warnLog("ZodDetection", "Analysis failed but continuing due to allowFailure setting"); console.warn("[Zod Detection] Analysis failed but continuing due to allowFailure setting"); return []; } throw error; } } async hasZodDependency() { try { const cwd = process.cwd(); debugLog("ZodDetection", `Checking for Zod dependency in: ${cwd}`); console.log(`[Zod Detection] Checking for Zod dependency in: ${cwd}`); let packageJsonData; // Try file system approach first (more reliable) try { const fs = await import("node:fs/promises"); const packageJsonPath = `${cwd}/package.json`; debugLog("ZodDetection", `Reading package.json from: ${packageJsonPath}`); console.log(`[Zod Detection] Reading package.json from: ${packageJsonPath}`); const packageJsonContent = await fs.readFile(packageJsonPath, "utf8"); packageJsonData = JSON.parse(packageJsonContent); debugLog("ZodDetection", "Successfully read package.json via fs"); console.log("[Zod Detection] Successfully read package.json via fs"); } catch (fsError) { const errorMessage = fsError instanceof Error ? fsError.message : String(fsError); debugLog("ZodDetection", `fs.readFile failed: ${errorMessage}, trying import()`); console.log(`[Zod Detection] fs.readFile failed: ${errorMessage}, trying import()`); // Fallback to import approach const packageJsonModule = await import(`${cwd}/package.json`); packageJsonData = packageJsonModule.default || packageJsonModule; debugLog("ZodDetection", "Successfully read package.json via import()"); console.log("[Zod Detection] Successfully read package.json via import()"); } // Validate package.json structure with Zod for security const packageJson = PackageJsonSchema.parse(packageJsonData); debugLog("ZodDetection", "package.json structure validated successfully"); console.log("[Security] package.json structure validated successfully"); const hasZodInDeps = !!packageJson.dependencies?.["zod"]; const hasZodInDevelopmentDeps = !!packageJson.devDependencies?.["zod"]; const hasZod = hasZodInDeps || hasZodInDevelopmentDeps; debugLog("ZodDetection", "Dependency check results", { dependencies: hasZodInDeps, devDependencies: hasZodInDevelopmentDeps, detected: hasZod, zodVersion: packageJson.dependencies?.["zod"] || packageJson.devDependencies?.["zod"], }); console.log(`[Zod Detection] Zod in dependencies: ${hasZodInDeps}`); console.log(`[Zod Detection] Zod in devDependencies: ${hasZodInDevelopmentDeps}`); console.log(`[Zod Detection] Zod detected: ${hasZod}`); return hasZod; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); errorLog("ZodDetection", "Could not validate package.json", error); console.warn("[Zod Detection] Could not validate package.json:", errorMessage); console.warn("[Zod Detection] Full error:", error); return false; } } analyzeZodUsage(baseDirectory) { debugLog("ZodDetection", "Starting Zod usage analysis", { baseDirectory }); const usage = { schemaDefinitions: [], parseUsages: [], safeParsUsages: [], }; // Find schema definitions (z.object, z.string, z.array, etc.) debugLog("ZodDetection", "Searching for Zod schema definitions with ripgrep"); const schemaResult = spawnSync("rg", [ "--type", "ts", "--line-number", String.raw `z\.(object|string|number|boolean|array|record|union|intersection|literal|enum)\(`, ".", ], { encoding: "utf8", cwd: baseDirectory, }); debugLog("ZodDetection", "Schema definition search result", { exitCode: schemaResult.status, hasStdout: !!schemaResult.stdout, hasStderr: !!schemaResult.stderr, stdoutLines: schemaResult.stdout ? schemaResult.stdout.split("\n").length : 0, }); if (schemaResult.stdout) { for (const line of schemaResult.stdout.split("\n")) { if (line.trim()) { const match = line.match(/^([^:]+):(\d+):(.*)/); if (match) { const [, file, lineNumber, content] = match; if (file && lineNumber && content) { const schemaMatch = content.match(/(\w+)\s*=.*z\.\w+/); if (schemaMatch) { usage.schemaDefinitions.push({ file: file.replace(`${baseDirectory}/`, ""), line: Number.parseInt(lineNumber, 10), name: schemaMatch[1] || "unknown", }); } } } } } } // Find .parse() usages - but only for Zod schemas, not JSON.parse() etc. debugLog("ZodDetection", "Searching for .parse() usages with ripgrep"); const parseResult = spawnSync("rg", [ "--type", "ts", "--line-number", String.raw `[a-zA-Z_][a-zA-Z0-9_]*\.parse\(`, // Only match variable.parse(), not JSON.parse() ".", ], { encoding: "utf8", cwd: baseDirectory, }); debugLog("ZodDetection", "Parse usage search result", { exitCode: parseResult.status, hasStdout: !!parseResult.stdout, hasStderr: !!parseResult.stderr, stdoutLines: parseResult.stdout ? parseResult.stdout.split("\n").length : 0, }); if (parseResult.stdout) { for (const line of parseResult.stdout.split("\n")) { if (line.trim()) { const match = line.match(/^([^:]+):(\d+):(.*)/); if (match) { const [, file, lineNumber, content] = match; if (file && lineNumber && content) { const parseMatch = content.match(/(\w+)\.parse\(/); if (parseMatch) { const schemaName = parseMatch[1]; // Filter out obvious false positives if (schemaName && !this.isNonZodParse(schemaName, content)) { usage.parseUsages.push({ file: file.replace(`${baseDirectory}/`, ""), line: Number.parseInt(lineNumber, 10), schema: schemaName, }); } } } } } } } // Find .safeParse() usages debugLog("ZodDetection", "Searching for .safeParse() usages with ripgrep"); const safeParseResult = spawnSync("rg", ["--type", "ts", "--line-number", String.raw `\.safeParse\(`, "."], { encoding: "utf8", cwd: baseDirectory, }); debugLog("ZodDetection", "SafeParse usage search result", { exitCode: safeParseResult.status, hasStdout: !!safeParseResult.stdout, hasStderr: !!safeParseResult.stderr, stdoutLines: safeParseResult.stdout ? safeParseResult.stdout.split("\n").length : 0, }); if (safeParseResult.stdout) { for (const line of safeParseResult.stdout.split("\n")) { if (line.trim()) { const match = line.match(/^([^:]+):(\d+):(.*)/); if (match) { const [, file, lineNumber, content] = match; if (file && lineNumber && content) { const safeParseMatch = content.match(/(\w+)\.safeParse\(/); if (safeParseMatch) { usage.safeParsUsages.push({ file: file.replace(`${baseDirectory}/`, ""), line: Number.parseInt(lineNumber, 10), schema: safeParseMatch[1] || "unknown", }); } } } } } } // Find safeJsonParse() utility function usages - CRITICAL FIX for missed detections debugLog("ZodDetection", "Searching for safeJsonParse() usages with ripgrep"); const safeJsonParseResult = spawnSync("rg", ["--type", "ts", "--line-number", String.raw `safeJsonParse\(`, "."], { encoding: "utf8", cwd: baseDirectory, }); debugLog("ZodDetection", "SafeJsonParse usage search result", { exitCode: safeJsonParseResult.status, hasStdout: !!safeJsonParseResult.stdout, hasStderr: !!safeJsonParseResult.stderr, stdoutLines: safeJsonParseResult.stdout ? safeJsonParseResult.stdout.split("\n").length : 0, }); if (safeJsonParseResult.stdout) { for (const line of safeJsonParseResult.stdout.split("\n")) { if (line.trim()) { const match = line.match(/^([^:]+):(\d+):(.*)/); if (match) { const [, file, lineNumber, content] = match; if (file && lineNumber && content) { // Match pattern: safeJsonParse(data, SchemaName, description) const safeJsonMatch = content.match(/safeJsonParse\([^,]+,\s*(\w+Schema)\s*,/); if (safeJsonMatch && safeJsonMatch[1]) { const schemaName = safeJsonMatch[1]; usage.safeParsUsages.push({ file: file.replace(`${baseDirectory}/`, ""), line: Number.parseInt(lineNumber, 10), schema: schemaName, }); } } } } } } debugLog("ZodDetection", "Zod usage analysis complete", { schemaDefinitions: usage.schemaDefinitions.length, parseUsages: usage.parseUsages.length, safeParsUsages: usage.safeParsUsages.length, }); return usage; } findUnusedSchemas(usage) { const violations = []; const usedSchemas = new Set([ ...usage.parseUsages.map((u) => u.schema), ...usage.safeParsUsages.map((u) => u.schema), ]); for (const schema of usage.schemaDefinitions) { if (!usedSchemas.has(schema.name)) { violations.push({ file: schema.file, line: schema.line, code: `Unused Zod schema '${schema.name}'`, category: "unused-code", severity: "warn", source: this.source, rule: "zod-unused-schema", message: `Zod schema '${schema.name}' is defined but never used with .parse(), .safeParse(), or safeJsonParse()`, fixSuggestion: `Remove unused schema '${schema.name}' or add validation calls`, }); } } return violations; } findOverValidations(usage) { const violations = []; // Look for Zod validation inside TypeScript functions with typed parameters // This is a simplified heuristic - in practice you'd want more sophisticated analysis for (const parseUsage of [...usage.parseUsages, ...usage.safeParsUsages]) { // Check if this appears to be validating already type-safe data // Heuristic: look for validation in non-boundary files if (parseUsage.file.endsWith(".ts") && !parseUsage.file.includes("api") && !parseUsage.file.includes("external") && !parseUsage.file.includes("validation") && !parseUsage.file.includes("schema") && !parseUsage.file.includes("cli") && !parseUsage.file.includes("config") && !this.isLikelyExternalDataValidation(parseUsage)) { violations.push({ file: parseUsage.file, line: parseUsage.line, code: "Potential over-validation with Zod", category: "best-practices", severity: "info", source: this.source, rule: "zod-potential-over-validation", message: "Consider if Zod validation is needed here - TypeScript may provide sufficient type safety", fixSuggestion: "Review if this data is already type-safe and consider using TypeScript types instead", }); } } return violations; } findMissingValidations(baseDirectory) { const violations = []; // Look for external API calls without Zod validation const apiCallResult = spawnSync("rg", [ "--type", "ts", "--line-number", String.raw `(fetch\(|axios\.|request\()`, ".", ], { encoding: "utf8", cwd: baseDirectory, }); if (apiCallResult.stdout) { for (const line of apiCallResult.stdout.split("\n")) { if (line.trim()) { const match = line.match(/^([^:]+):(\d+):(.*)/); if (match) { const [, file, lineNumber] = match; if (file && lineNumber) { violations.push({ file: file.replace(`${baseDirectory}/`, ""), line: Number.parseInt(lineNumber, 10), code: "External API call without Zod validation", category: "best-practices", severity: "info", source: this.source, rule: "zod-missing-validation", message: "Consider adding Zod validation for external API responses", fixSuggestion: "Add Zod schema to validate and type external API responses", }); } } } } } return violations; } /** * Calculate runtime validation coverage metrics */ calculateValidationCoverage(usage, baseDirectory) { const totalSchemas = usage.schemaDefinitions.length; const usedSchemas = new Set([ ...usage.parseUsages.map((u) => u.schema), ...usage.safeParsUsages.map((u) => u.schema), ]).size; const coverage = totalSchemas > 0 ? (usedSchemas / totalSchemas) * 100 : 0; return { totalSchemas, usedSchemas, unusedSchemas: totalSchemas - usedSchemas, coveragePercentage: Number(coverage.toFixed(1)), validationCallsTotal: usage.parseUsages.length + usage.safeParsUsages.length, parseCallsCount: usage.parseUsages.length, safeParseCallsCount: usage.safeParsUsages.length, riskLevel: this.assessRiskLevel(coverage, usage), baseline: this.getBaselineRecommendation(baseDirectory), }; } /** * Type guard to check if coverage object has the expected structure */ isValidCoverage(coverage) { return (typeof coverage === "object" && coverage !== null && "totalSchemas" in coverage && "usedSchemas" in coverage && "coveragePercentage" in coverage && "parseCallsCount" in coverage && "safeParseCallsCount" in coverage && "validationCallsTotal" in coverage && "hasUnsafeParseUsage" in coverage); } /** * Generate coverage-based suggestions and violations */ generateCoverageSuggestions(coverage) { const suggestions = []; if (!this.isValidCoverage(coverage)) { return suggestions; } // Only report coverage issues if there are actually Zod schemas defined if (coverage.totalSchemas === 0) { // No Zod schemas found - this is fine, don't report as an issue return suggestions; } // Coverage too low (only when schemas exist) if (coverage.coveragePercentage < 70) { suggestions.push({ file: "package.json", line: 1, code: `Low Zod validation coverage: ${coverage.coveragePercentage}%`, category: "code-quality", severity: "warn", source: this.source, rule: "zod-low-coverage", message: `Runtime validation coverage is ${coverage.coveragePercentage}% (${coverage.usedSchemas}/${coverage.totalSchemas} schemas used). Consider increasing validation for better type safety.`, fixSuggestion: "Target 80%+ validation coverage. Add .parse() or .safeParse() calls for unused schemas.", }); } // Too many unsafe parse() calls (only check if we have actual Zod usage) const unsafeParseRatio = coverage.parseCallsCount / Math.max(coverage.validationCallsTotal, 1); if (coverage.validationCallsTotal > 0 && unsafeParseRatio > 0.7 && coverage.parseCallsCount > 5) { suggestions.push({ file: "zod-usage", line: 1, code: `High ratio of unsafe .parse() calls: ${(unsafeParseRatio * 100).toFixed(1)}%`, category: "best-practices", severity: "info", source: this.source, rule: "zod-unsafe-parse-ratio", message: `${coverage.parseCallsCount} .parse() vs ${coverage.safeParseCallsCount} .safeParse() calls. Consider using .safeParse() for better error handling.`, fixSuggestion: "Replace .parse() with .safeParse() for external data validation to avoid throwing exceptions.", }); } // Perfect coverage recognition if (coverage.coveragePercentage === 100 && coverage.totalSchemas > 0) { suggestions.push({ file: "zod-coverage", line: 1, code: "Excellent Zod validation coverage: 100%", category: "code-quality", severity: "info", source: this.source, rule: "zod-excellent-coverage", message: `Perfect validation coverage achieved! All ${coverage.totalSchemas} Zod schemas are actively used.`, fixSuggestion: "Maintain this excellent validation discipline in future code changes.", }); } return suggestions; } /** * Assess risk level based on coverage and usage patterns */ assessRiskLevel(coverage, usage) { const hasExternalAPIs = usage.parseUsages.length + usage.safeParsUsages.length > 0; if (coverage >= 80 && hasExternalAPIs) { return "low"; } if (coverage >= 50 || !hasExternalAPIs) { return "medium"; } return "high"; } /** * Get baseline recommendation based on project characteristics */ getBaselineRecommendation(baseDirectory) { // Detect project type to provide contextual baselines const hasAPI = this.hasPattern(baseDirectory, "(api|routes|handlers)"); const hasDatabase = this.hasPattern(baseDirectory, "(prisma|sequelize|mongoose|database)"); const hasExternalServices = this.hasPattern(baseDirectory, "(fetch|axios|http)"); if (hasAPI && hasDatabase && hasExternalServices) { return "Full-stack app: Target 85%+ coverage. Focus on API inputs, DB queries, and external service responses."; } else if (hasAPI) { return "API service: Target 90%+ coverage. Validate all request/response data at boundaries."; } else if (hasExternalServices) { return "External service integration: Target 80%+ coverage. Focus on validating external API responses."; } else { return "General TypeScript project: Target 70%+ coverage for external data sources and user inputs."; } } /** * Check if baseDir contains files matching a pattern */ hasPattern(baseDirectory, pattern) { try { const result = spawnSync("rg", ["-l", "--type", "ts", pattern, "."], { cwd: baseDirectory, encoding: "utf8", }); return !!result.stdout && result.stdout.trim().length > 0; } catch { return false; } } categorizeViolation(_message, _category, _rule, code) { if (!code) { return undefined; } if (code.includes("unused")) { return "unused-validation"; } if (code.includes("over-validation")) { return "over-validation"; } if (code.includes("missing")) { return "missing-validation"; } return "zod-analysis"; } generateFixSuggestion(_message, _category, _rule, code) { if (!code) { return undefined; } if (code.includes("unused")) { return "Remove the unused Zod schema or add .parse()/.safeParse() calls where needed"; } if (code.includes("over-validation")) { return "Consider using TypeScript types instead of runtime validation for internal data"; } if (code.includes("missing")) { return "Add Zod schema validation for external data sources to ensure type safety at runtime"; } return "Review Zod usage patterns for optimal type safety"; } /** * Check if this appears to be validating external/untrusted data */ isLikelyExternalDataValidation(parseUsage) { // Common patterns that indicate external data validation const externalDataPatterns = [ "JSON.parse", "process.env", "process.argv", "arguments_", "package.json", "stdin", "http", "fetch", "request", "import(", "require(", ]; // Check if the schema name suggests external data const externalSchemaPatterns = [ "environment", "cli", "args", "package", "config", "json", "external", ]; return (externalDataPatterns.some((pattern) => parseUsage.schema.toLowerCase().includes(pattern)) || externalSchemaPatterns.some((pattern) => parseUsage.schema.toLowerCase().includes(pattern))); } } //# sourceMappingURL=zod-detection-engine.js.map