import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { Command } from 'commander'; import { detectProjectKind } from './detect.js'; import { scaffoldQualityRails } from './scaffolder.js'; import type { ProjectKind, QualityProfile, RailsConfig } from './types.js'; import { QC_19_RAILS_FILES_PRESENT } from './evaluator/definitions.js'; import { evaluateSubject } from './evaluator/runner.js'; import type { EvaluationReport } from './evaluator/types.js'; const VALID_PROFILES: readonly QualityProfile[] = ['strict', 'standard', 'minimal']; function parseProfile(rawProfile: string): QualityProfile { if (VALID_PROFILES.includes(rawProfile as QualityProfile)) { return rawProfile as QualityProfile; } throw new Error(`Invalid profile: ${rawProfile}. Use one of ${VALID_PROFILES.join(', ')}.`); } function defaultLinters(kind: ProjectKind): string[] { if (kind === 'node') { return ['eslint', 'biome']; } if (kind === 'python') { return ['ruff']; } if (kind === 'rust') { return ['clippy']; } return []; } function defaultFormatters(kind: ProjectKind): string[] { if (kind === 'node') { return ['prettier']; } if (kind === 'python') { return ['black']; } if (kind === 'rust') { return ['rustfmt']; } return []; } function printScaffoldResult( config: RailsConfig, filesWritten: string[], warnings: string[], commandsToRun: string[], ): void { console.log(`[quality-rails] initialized at ${config.projectPath}`); console.log(`kind=${config.kind} profile=${config.profile}`); if (filesWritten.length > 0) { console.log('files written:'); for (const filePath of filesWritten) { console.log(` - ${filePath}`); } } if (commandsToRun.length > 0) { console.log('run next:'); for (const command of commandsToRun) { console.log(` - ${command}`); } } if (warnings.length > 0) { console.log('warnings:'); for (const warning of warnings) { console.log(` - ${warning}`); } } } function printEvaluationReport(report: EvaluationReport): void { console.log( `[quality-rails] evaluation for ${report.subject.path} (kind=${report.subject.kind}, check-set v${report.checkSetVersion})`, ); for (const result of report.results) { const reason = result.reason === undefined ? '' : ` — ${result.reason}`; const digest = report.definitionDigests[result.checkId] ?? 'no digest'; console.log( ` - ${result.status}: ${result.checkId} (v${result.checkVersion} [${digest}])${reason}`, ); } console.log(`[quality-rails] aggregate: ${report.state}`); } /** * Register quality-rails subcommands on an existing Commander program. * This avoids cross-package Commander version mismatches by using the * caller's Command instance directly. */ export function registerQualityRails(parent: Command): void { buildQualityRailsCommand( parent.command('quality-rails').description('Manage quality rails scaffolding'), ); } export function createQualityRailsCli(): Command { const program = new Command('mosaic'); buildQualityRailsCommand( program.command('quality-rails').description('Manage quality rails scaffolding'), ); return program; } function buildQualityRailsCommand(qualityRails: Command): void { qualityRails .command('init') .requiredOption('--project ', 'Project path') .option('--profile ', 'strict|standard|minimal', 'standard') .action(async (options: { project: string; profile: string }) => { const profile = parseProfile(options.profile); const projectPath = resolve(options.project); const kind = await detectProjectKind(projectPath); const config: RailsConfig = { projectPath, kind, profile, linters: defaultLinters(kind), formatters: defaultFormatters(kind), hooks: true, }; const result = await scaffoldQualityRails(config); printScaffoldResult(config, result.filesWritten, result.warnings, result.commandsToRun); }); // `check` (QC-19) is ABSORBED by the RI-N4 evaluator: the presence loop // that lived here is now the versioned, digested, typed check definition // `qc-19-rails-files-present`. The CLI keeps its human surface (missing // files listed, exit 1) and gains `--json` for the typed verdicts. Exit // code is fail-closed: any non-green aggregate (failed/blocked/error) is 1. qualityRails .command('check') .requiredOption('--project ', 'Project path') .option('--json', 'print the typed evaluation report as JSON') .action(async (options: { project: string; json?: boolean }) => { const projectPath = resolve(options.project); const report = await evaluateSubject({ subjectPath: projectPath, checkIds: [QC_19_RAILS_FILES_PRESENT.id], }); if (options.json) { console.log(JSON.stringify(report)); } else { printEvaluationReport(report); } process.exitCode = report.state === 'passed' ? 0 : 1; }); // `doctor` (QC-19) stays advisory (documented contract: a doctor that // cannot fail), but now reports TYPED states — a blocked or failing rail is // visible instead of silently printed as `ok`/`missing`. qualityRails .command('doctor') .requiredOption('--project ', 'Project path') .action(async (options: { project: string }) => { const projectPath = resolve(options.project); const report = await evaluateSubject({ subjectPath: projectPath }); console.log(`[quality-rails] doctor for ${projectPath}`); console.log(`detected project kind: ${report.subject.kind}`); for (const result of report.results) { const reason = result.reason === undefined ? '' : ` — ${result.reason}`; console.log(` - ${result.status}: ${result.checkId}${reason}`); } if (report.subject.kind === 'unknown') { console.log( 'recommendation: add package.json, pyproject.toml, or Cargo.toml for better defaults.', ); } }); // `evaluate` is the canonical RI-N4 evaluator entry point: typed verdicts // for the subject's full per-kind check set, same results as the // programmatic API (evaluateSubject). qualityRails .command('evaluate') .description('Run the typed quality-rails evaluator against a subject project') .requiredOption('--project ', 'Project path') .option('--check ', 'restrict evaluation to these check ids') .option( '--probe-path ', 'path to the QC-20 behavioral probe script (framework verify.sh)', ) .option('--json', 'print the typed evaluation report as JSON') .action( async (options: { project: string; check?: string[]; probePath?: string; json?: boolean; }) => { const projectPath = resolve(options.project); const report = await evaluateSubject({ subjectPath: projectPath, checkIds: options.check, inputs: options.probePath ? { 'qc-20-enforcement-verify': { probePath: options.probePath } } : undefined, }); if (options.json) { console.log(JSON.stringify(report)); } else { printEvaluationReport(report); } process.exitCode = report.state === 'passed' ? 0 : 1; }, ); } export async function runQualityRailsCli(argv: string[] = process.argv): Promise { const program = createQualityRailsCli(); await program.parseAsync(argv); } const entryPath = process.argv[1] ? resolve(process.argv[1]) : ''; if (entryPath.length > 0 && entryPath === fileURLToPath(import.meta.url)) { runQualityRailsCli().catch((error: unknown) => { console.error(error instanceof Error ? error.message : String(error)); process.exit(1); }); }