From ae95e7b8533d4a372aa09da48f9307d7532c82eb Mon Sep 17 00:00:00 2001 From: fargo Date: Tue, 18 Aug 2026 11:17:23 -0500 Subject: [PATCH] feat(quality-rails): absorb QC-19 presence loop into evaluator; add typed evaluate/check/doctor (#1275) - check (QC-19) is now implemented by the evaluator's typed qc-19-rails-files-present definition; keeps fail-closed exit and gains --json - doctor stays advisory but reports typed states - new evaluate subcommand is the canonical CLI entry point (--probe-path feeds QC-20; shell probes remain thin adapters with TS-owned verdict parsing) --- packages/quality-rails/src/cli.ts | 135 ++++++++++++++++++------------ 1 file changed, 81 insertions(+), 54 deletions(-) diff --git a/packages/quality-rails/src/cli.ts b/packages/quality-rails/src/cli.ts index 0c5908f3..2ab38b72 100644 --- a/packages/quality-rails/src/cli.ts +++ b/packages/quality-rails/src/cli.ts @@ -1,5 +1,3 @@ -import { constants } from 'node:fs'; -import { access } from 'node:fs/promises'; import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -8,18 +6,12 @@ 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']; -async function fileExists(filePath: string): Promise { - try { - await access(filePath, constants.F_OK); - return true; - } catch { - return false; - } -} - function parseProfile(rawProfile: string): QualityProfile { if (VALID_PROFILES.includes(rawProfile as QualityProfile)) { return rawProfile as QualityProfile; @@ -59,22 +51,6 @@ function defaultFormatters(kind: ProjectKind): string[] { return []; } -function expectedFilesForKind(kind: ProjectKind): string[] { - if (kind === 'node') { - return ['.eslintrc', 'biome.json', '.githooks/pre-commit', 'PR-CHECKLIST.md']; - } - - if (kind === 'python') { - return ['pyproject.toml', '.githooks/pre-commit', 'PR-CHECKLIST.md']; - } - - if (kind === 'rust') { - return ['rustfmt.toml', '.githooks/pre-commit', 'PR-CHECKLIST.md']; - } - - return ['.githooks/pre-commit', 'PR-CHECKLIST.md']; -} - function printScaffoldResult( config: RailsConfig, filesWritten: string[], @@ -106,6 +82,20 @@ function printScaffoldResult( } } +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 @@ -148,56 +138,93 @@ function buildQualityRailsCommand(qualityRails: Command): void { 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') - .action(async (options: { project: string }) => { + .option('--json', 'print the typed evaluation report as JSON') + .action(async (options: { project: string; json?: boolean }) => { const projectPath = resolve(options.project); - const kind = await detectProjectKind(projectPath); - const expected = expectedFilesForKind(kind); - const missing: string[] = []; + const report = await evaluateSubject({ + subjectPath: projectPath, + checkIds: [QC_19_RAILS_FILES_PRESENT.id], + }); - for (const relativePath of expected) { - const exists = await fileExists(resolve(projectPath, relativePath)); - if (!exists) { - missing.push(relativePath); - } + if (options.json) { + console.log(JSON.stringify(report)); + } else { + printEvaluationReport(report); } - if (missing.length > 0) { - console.error('[quality-rails] missing files:'); - for (const relativePath of missing) { - console.error(` - ${relativePath}`); - } - process.exitCode = 1; - return; - } - - console.log(`[quality-rails] all expected files present for ${kind} project`); + 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 kind = await detectProjectKind(projectPath); - const expected = expectedFilesForKind(kind); + const report = await evaluateSubject({ subjectPath: projectPath }); console.log(`[quality-rails] doctor for ${projectPath}`); - console.log(`detected project kind: ${kind}`); - - for (const relativePath of expected) { - const exists = await fileExists(resolve(projectPath, relativePath)); - console.log(` - ${exists ? 'ok' : 'missing'}: ${relativePath}`); + 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 (kind === 'unknown') { + 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 {