Files
mosaic/packages/quality-rails/src/cli.ts
Jason Woltje 494da37cb8 feat(wave3): add @mosaic/quality-rails — TypeScript code quality scaffolder
- Project detection: detectProjectKind (node/python/rust/unknown)
- Scaffolder: writes ESLint/Biome/pre-commit configs based on profile
- Embedded templates for strict/standard/minimal profiles
- CLI: quality-rails init | check | doctor
- Depends on @mosaic/types workspace:*
2026-03-06 20:24:20 -06:00

194 lines
5.5 KiB
TypeScript

import { constants } from 'node:fs';
import { access } from 'node:fs/promises';
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';
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;
}
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 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[], 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}`);
}
}
}
export function createQualityRailsCli(): Command {
const program = new Command('mosaic');
const qualityRails = program.command('quality-rails').description('Manage quality rails scaffolding');
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);
});
qualityRails
.command('check')
.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 missing: string[] = [];
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`);
});
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);
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}`);
}
if (kind === 'unknown') {
console.log('recommendation: add package.json, pyproject.toml, or Cargo.toml for better defaults.');
}
});
return program;
}
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);
});
}