242 lines
7.7 KiB
TypeScript
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);
|
|
});
|
|
}
|