feat(quality-rails): typed evaluator absorbs QC-19/QC-20; verify-release wiring (RI-3-002, #1275) (#1308)
ci/woodpecker/push/publish Pipeline failed

This commit was merged in pull request #1308.
This commit is contained in:
2026-08-18 17:54:47 +00:00
parent ff45f7b5d0
commit 245e0c427d
13 changed files with 1661 additions and 58 deletions
+81 -54
View File
@@ -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<boolean> {
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 <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 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 <path>', '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 <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> {