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:
@@ -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> {
|
||||||
|
|||||||
Reference in New Issue
Block a user