#!/usr/bin/env bun import type { ErrorObject } from "ajv"; import chalk from "chalk"; import { Command, InvalidArgumentError } from "commander"; import { discoverAllRulesets } from "./lib/index.js"; import type { ValidationResult } from "./types.js"; import validate from "./validate.js"; const program = new Command(); /** * Formats AJV validation errors into human-readable messages. * Groups errors by their property path and removes duplicate messages. * * @param errorsJson - JSON string containing an array of AJV ErrorObject instances * @returns Array of formatted error messages, one per property path * * @example * ```typescript * const errors = '[{"instancePath": "/name", "message": "must be string"}]'; * const formatted = formatAjvErrors(errors); * // Returns: ["name: must be string"] * ``` */ function formatAjvErrors(errorsJson: string): string[] { try { const errors = JSON.parse(errorsJson) as ErrorObject[]; const errorsByPath = new Map>(); for (const error of errors) { const path = error.instancePath || "root"; const message = error.message || "validation failed"; if (!errorsByPath.has(path)) { errorsByPath.set(path, new Set()); } // Use optional chaining for safety errorsByPath.get(path)?.add(message); } return Array.from(errorsByPath.entries()).map(([path, messages]) => { const property = path === "root" ? "file" : path.replace("/", ""); const messageList = Array.from(messages).join(", "); return `${property}: ${messageList}`; }); } catch { // Fallback if JSON parsing fails return [errorsJson]; } } /** * Formats validation results for terminal output with color coding. * Displays failed validations first, followed by passed validations, * and ends with a summary. In verbose mode, shows additional context * including file contents and validation rules. * * @param results - Array of validation results to format * @param verbose - Whether to show detailed information including file contents and rules * * @example * ```typescript * const results = [ * { rule: "package.json", passed: true }, * { rule: "biome.json", passed: false, message: "File not found" } * ]; * formatTerminalOutput(results, true); * ``` */ function formatTerminalOutput( results: ValidationResult[], verbose = false, ): void { console.log(); // Empty line for spacing const passedResults = results.filter((r) => r.passed); const failedResults = results.filter((r) => !r.passed); if (failedResults.length > 0) { console.log(chalk.red.bold("✗ FAILED VALIDATIONS")); console.log(chalk.gray("─".repeat(50))); for (const result of failedResults) { console.log(chalk.red(`✗ ${result.rule}`)); if (verbose && result.context) { console.log(chalk.gray(` Target: ${result.context.target}`)); if (result.context.description) { console.log(chalk.gray(` Rule: ${result.context.description}`)); } } if (result.message) { // Check if message contains JSON (AJV errors) if (result.message.includes("Validation failed: [")) { const jsonStart = result.message.indexOf("["); const prefix = result.message.substring(0, jsonStart).trim(); const jsonPart = result.message.substring(jsonStart); if (prefix) { console.log(chalk.gray(` ${prefix}`)); } const formattedErrors = formatAjvErrors(jsonPart); for (const error of formattedErrors) { console.log(chalk.gray(` • ${error}`)); } } else { console.log(chalk.gray(` ${result.message}`)); } } if ( verbose && result.context?.value && result.context.value !== "[File not found]" ) { // Use regular string instead of template literal where no interpolation needed console.log(chalk.gray(" Found:")); if (typeof result.context.value === "object") { const preview = JSON.stringify(result.context.value, null, 2) .split("\n") .slice(0, 10) // Show first 10 lines .map((line) => ` ${line}`) .join("\n"); console.log(chalk.gray(preview)); if (JSON.stringify(result.context.value).split("\n").length > 10) { console.log(chalk.gray(" ... (truncated)")); } } else { console.log(chalk.gray(` ${result.context.value}`)); } } console.log(); // Empty line between failures } } if (passedResults.length > 0) { console.log(chalk.green.bold("✓ PASSED VALIDATIONS")); console.log(chalk.gray("─".repeat(50))); for (const result of passedResults) { console.log(chalk.green(`✓ ${result.rule}`)); if (verbose && result.context) { console.log(chalk.gray(` Target: ${result.context.target}`)); if (result.context.description) { console.log(chalk.gray(` Rule: ${result.context.description}`)); } if (result.context.value && typeof result.context.value === "object") { // Use regular string instead of template literal console.log(chalk.gray(" Validated content:")); const preview = JSON.stringify(result.context.value, null, 2) .split("\n") .slice(0, 5) // Show fewer lines for passed validations .map((line) => ` ${line}`) .join("\n"); console.log(chalk.gray(preview)); if (JSON.stringify(result.context.value).split("\n").length > 5) { console.log(chalk.gray(" ... (content validated successfully)")); } } } } console.log(); } // Summary const total = results.length; const failed = failedResults.length; const passed = passedResults.length; if (failed > 0) { console.log( chalk.red.bold( `Summary: ${failed}/${total} validations failed (${passed} passed)`, ), ); } else { console.log(chalk.green.bold(`Summary: All ${total} validations passed`)); } } /** * Formats validation results as JSON for programmatic consumption. * Outputs a structured object with summary statistics and detailed results. * * @param results - Array of validation results to format * * @example * ```typescript * const results = [ * { rule: "package.json", passed: true }, * { rule: "biome.json", passed: false, message: "File not found" } * ]; * formatJsonOutput(results); * // Outputs: { * // "summary": { "total": 2, "passed": 1, "failed": 1 }, * // "results": [...] * // } * ``` */ function formatJsonOutput(results: ValidationResult[]): void { const output = { summary: { total: results.length, passed: results.filter((r) => r.passed).length, failed: results.filter((r) => !r.passed).length, }, results: results.map((result) => ({ rule: result.rule, passed: result.passed, ...(result.message && { message: result.message }), })), }; console.log(JSON.stringify(output, null, 2)); } /** * Displays available rulesets in a tree-like format. * Groups rulesets by their location (bundled vs current directory). */ async function displayRulesetTree(): Promise { const { bundled, local } = await discoverAllRulesets(); if (bundled.length === 0 && local.length === 0) { console.log(chalk.yellow("No rulesets found")); return; } console.log(chalk.bold("\nAvailable Webarchitect Rulesets:")); console.log(chalk.gray("─".repeat(50))); // Display bundled rulesets if (bundled.length > 0) { console.log(chalk.cyan("Bundled Rulesets:")); console.log(chalk.gray(`└── ${bundled[0].path.replace(/\/[^/]+$/, "")}/`)); for (let i = 0; i < bundled.length; i++) { const isLast = i === bundled.length - 1; const prefix = isLast ? "└──" : "├──"; const filename = `${bundled[i].name}.ruleset.json`; console.log( ` ${prefix} ${chalk.bold(bundled[i].name)} ${chalk.gray(`(${filename})`)}`, ); } } // Display current directory rulesets if (bundled.length > 0) console.log(); // Add spacing between sections console.log(chalk.green("Current Directory:")); console.log(chalk.gray(`└── ${process.cwd()}/`)); if (local.length > 0) { for (let i = 0; i < local.length; i++) { const isLast = i === local.length - 1; const prefix = isLast ? "└──" : "├──"; const filename = `${local[i].name}.ruleset.json`; console.log( ` ${prefix} ${chalk.bold(local[i].name)} ${chalk.gray(`(${filename})`)}`, ); } } else { console.log(chalk.gray(` └── (no rulesets found at this path)`)); } } /** * Collects repeatable `--var key=value` options into a single record. * Commander invokes this once per occurrence, threading the accumulator. * * @param raw - Raw `key=value` string from a single `--var` occurrence * @param acc - Accumulator of previously parsed variable overrides * @returns The accumulator with the new override added * @throws Will throw if the value is missing an `=` separator */ function collectVariable( raw: string, acc: Record, ): Record { const separator = raw.indexOf("="); if (separator === -1) { // InvalidArgumentError lets Commander format and exit cleanly, rather than // surfacing a raw stack trace from inside the option parser. throw new InvalidArgumentError(`Expected key=value, received '${raw}'.`); } acc[raw.slice(0, separator)] = raw.slice(separator + 1); return acc; } program .name("webarchitect") .argument("[ruleset]", "ruleset identifier, local path, or URL") .option("-v, --verbose", "show all validation results") .option("--json", "output results in JSON format") .option("--list", "list all available rulesets") .option( "--var ", "override a ruleset template variable (repeatable)", collectVariable, {}, ) .option("--prefix ", "shortcut for --var prefix=") .action(async (schemaArg, options) => { // Handle --list option if (options.list) { await displayRulesetTree(); return; } // Require ruleset argument if not listing if (!schemaArg) { console.error(chalk.red("Error: Missing required argument 'ruleset'")); console.error( chalk.gray("Use 'webarchitect --list' to see available rulesets"), ); process.exit(1); } try { // Merge variable overrides; --prefix is sugar for --var prefix=. const variableOverrides: Record = { ...(options.var ?? {}), }; if (options.prefix !== undefined) { variableOverrides.prefix = options.prefix; } const results = await validate( process.cwd(), schemaArg, variableOverrides, ); if (options.json) { formatJsonOutput(results); } else { formatTerminalOutput(results, options.verbose); // Exit with error code if any validations failed const hasFailures = results.some((r) => !r.passed); if (hasFailures) { process.exit(1); } } } catch (e) { if (options.json) { console.log( JSON.stringify( { error: (e as Error).message, success: false, }, null, 2, ), ); } else { console.error(chalk.red(`Error: ${(e as Error).message}`)); } process.exit(1); } }); program.parse();