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)
This commit is contained in:
fargo
2026-08-18 11:17:23 -05:00
parent 367cb27591
commit ae95e7b853
+84 -57
View File
@@ -1,5 +1,3 @@
import { constants } from 'node:fs';
import { access } from 'node:fs/promises';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
@@ -8,18 +6,12 @@ import { Command } from 'commander';
import { detectProjectKind } from './detect.js'; import { detectProjectKind } from './detect.js';
import { scaffoldQualityRails } from './scaffolder.js'; import { scaffoldQualityRails } from './scaffolder.js';
import type { ProjectKind, QualityProfile, RailsConfig } from './types.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']; const VALID_PROFILES: readonly QualityProfile[] = ['strict', 'standard', 'minimal'];
async function fileExists(filePath: string): Promise<boolean> {
try {
await access(filePath, constants.F_OK);
return true;
} catch {
return false;
}
}
function parseProfile(rawProfile: string): QualityProfile { function parseProfile(rawProfile: string): QualityProfile {
if (VALID_PROFILES.includes(rawProfile as QualityProfile)) { if (VALID_PROFILES.includes(rawProfile as QualityProfile)) {
return rawProfile as QualityProfile; return rawProfile as QualityProfile;
@@ -59,22 +51,6 @@ function defaultFormatters(kind: ProjectKind): string[] {
return []; 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( function printScaffoldResult(
config: RailsConfig, config: RailsConfig,
filesWritten: string[], 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. * Register quality-rails subcommands on an existing Commander program.
* This avoids cross-package Commander version mismatches by using the * 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); 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 qualityRails
.command('check') .command('check')
.requiredOption('--project <path>', 'Project path') .requiredOption('--project <path>', '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 projectPath = resolve(options.project);
const kind = await detectProjectKind(projectPath); const report = await evaluateSubject({
const expected = expectedFilesForKind(kind); subjectPath: projectPath,
const missing: string[] = []; 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 (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`);
}); });
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 qualityRails
.command('doctor') .command('doctor')
.requiredOption('--project <path>', 'Project path') .requiredOption('--project <path>', 'Project path')
.action(async (options: { project: string }) => { .action(async (options: { project: string }) => {
const projectPath = resolve(options.project); const projectPath = resolve(options.project);
const kind = await detectProjectKind(projectPath); const report = await evaluateSubject({ subjectPath: projectPath });
const expected = expectedFilesForKind(kind);
console.log(`[quality-rails] doctor for ${projectPath}`); console.log(`[quality-rails] doctor for ${projectPath}`);
console.log(`detected project kind: ${kind}`); console.log(`detected project kind: ${report.subject.kind}`);
for (const result of report.results) {
for (const relativePath of expected) { const reason = result.reason === undefined ? '' : `${result.reason}`;
const exists = await fileExists(resolve(projectPath, relativePath)); console.log(` - ${result.status}: ${result.checkId}${reason}`);
console.log(` - ${exists ? 'ok' : 'missing'}: ${relativePath}`);
} }
if (kind === 'unknown') { if (report.subject.kind === 'unknown') {
console.log( console.log(
'recommendation: add package.json, pyproject.toml, or Cargo.toml for better defaults.', '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 <path>', 'Project path')
.option('--check <id...>', 'restrict evaluation to these check ids')
.option(
'--probe-path <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<void> { export async function runQualityRailsCli(argv: string[] = process.argv): Promise<void> {