Files
stack/packages/quality-rails/src/cli.ts
T

242 lines
7.7 KiB
TypeScript

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 <path>', 'Project path')
.option('--profile <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 <path>', '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 <path>', '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 <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> {
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);
});
}