feat(quality-rails): migrate @mosaic/quality-rails from v0 to v1 (#100)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #100.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "@mosaic/quality-rails",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
@@ -15,7 +16,11 @@
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"commander": "^12.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^2.0.0"
|
||||
}
|
||||
|
||||
202
packages/quality-rails/src/cli.ts
Normal file
202
packages/quality-rails/src/cli.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
30
packages/quality-rails/src/detect.ts
Normal file
30
packages/quality-rails/src/detect.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { constants } from 'node:fs';
|
||||
import { access } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import type { ProjectKind } from './types.js';
|
||||
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await access(filePath, constants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function detectProjectKind(projectPath: string): Promise<ProjectKind> {
|
||||
if (await fileExists(join(projectPath, 'package.json'))) {
|
||||
return 'node';
|
||||
}
|
||||
|
||||
if (await fileExists(join(projectPath, 'pyproject.toml'))) {
|
||||
return 'python';
|
||||
}
|
||||
|
||||
if (await fileExists(join(projectPath, 'Cargo.toml'))) {
|
||||
return 'rust';
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
@@ -1 +1,5 @@
|
||||
export const VERSION = '0.0.0';
|
||||
export * from './cli.js';
|
||||
export * from './detect.js';
|
||||
export * from './scaffolder.js';
|
||||
export * from './templates.js';
|
||||
export * from './types.js';
|
||||
|
||||
206
packages/quality-rails/src/scaffolder.ts
Normal file
206
packages/quality-rails/src/scaffolder.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
import {
|
||||
biomeTemplate,
|
||||
eslintTemplate,
|
||||
prChecklistTemplate,
|
||||
preCommitHookTemplate,
|
||||
pyprojectSection,
|
||||
rustfmtTemplate,
|
||||
} from './templates.js';
|
||||
import type { RailsConfig, ScaffoldResult } from './types.js';
|
||||
|
||||
const PYPROJECT_START_MARKER = '# >>> mosaic-quality-rails >>>';
|
||||
const PYPROJECT_END_MARKER = '# <<< mosaic-quality-rails <<<';
|
||||
|
||||
async function ensureDirectory(filePath: string): Promise<void> {
|
||||
await mkdir(dirname(filePath), { recursive: true });
|
||||
}
|
||||
|
||||
async function writeRelativeFile(
|
||||
projectPath: string,
|
||||
relativePath: string,
|
||||
contents: string,
|
||||
result: ScaffoldResult,
|
||||
): Promise<void> {
|
||||
const absolutePath = join(projectPath, relativePath);
|
||||
await ensureDirectory(absolutePath);
|
||||
await writeFile(absolutePath, contents, { encoding: 'utf8', mode: 0o644 });
|
||||
result.filesWritten.push(relativePath);
|
||||
}
|
||||
|
||||
async function upsertPyproject(
|
||||
projectPath: string,
|
||||
profile: RailsConfig['profile'],
|
||||
result: ScaffoldResult,
|
||||
): Promise<void> {
|
||||
const pyprojectPath = join(projectPath, 'pyproject.toml');
|
||||
const nextSection = pyprojectSection(profile);
|
||||
|
||||
let previous = '';
|
||||
try {
|
||||
previous = await readFile(pyprojectPath, 'utf8');
|
||||
} catch {
|
||||
previous = '';
|
||||
}
|
||||
|
||||
const existingStart = previous.indexOf(PYPROJECT_START_MARKER);
|
||||
const existingEnd = previous.indexOf(PYPROJECT_END_MARKER);
|
||||
|
||||
if (existingStart >= 0 && existingEnd > existingStart) {
|
||||
const before = previous.slice(0, existingStart).trimEnd();
|
||||
const after = previous.slice(existingEnd + PYPROJECT_END_MARKER.length).trimStart();
|
||||
const rebuilt = [before, nextSection.trim(), after]
|
||||
.filter((segment) => segment.length > 0)
|
||||
.join('\n\n');
|
||||
await writeRelativeFile(projectPath, 'pyproject.toml', `${rebuilt}\n`, result);
|
||||
return;
|
||||
}
|
||||
|
||||
const separator = previous.trim().length > 0 ? '\n\n' : '';
|
||||
await writeRelativeFile(
|
||||
projectPath,
|
||||
'pyproject.toml',
|
||||
`${previous.trimEnd()}${separator}${nextSection}`,
|
||||
result,
|
||||
);
|
||||
}
|
||||
|
||||
function runCommand(command: string, args: string[], cwd: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd,
|
||||
stdio: 'ignore',
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
child.on('error', reject);
|
||||
child.on('exit', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(new Error(`Command failed (${code ?? 'unknown'}): ${command} ${args.join(' ')}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function buildNodeDevDependencies(config: RailsConfig): string[] {
|
||||
const dependencies = new Set<string>();
|
||||
|
||||
if (config.linters.some((linter) => linter.toLowerCase() === 'eslint')) {
|
||||
dependencies.add('eslint');
|
||||
dependencies.add('@typescript-eslint/parser');
|
||||
dependencies.add('@typescript-eslint/eslint-plugin');
|
||||
}
|
||||
|
||||
if (config.linters.some((linter) => linter.toLowerCase() === 'biome')) {
|
||||
dependencies.add('@biomejs/biome');
|
||||
}
|
||||
|
||||
if (config.formatters.some((formatter) => formatter.toLowerCase() === 'prettier')) {
|
||||
dependencies.add('prettier');
|
||||
}
|
||||
|
||||
if (config.hooks) {
|
||||
dependencies.add('husky');
|
||||
}
|
||||
|
||||
return [...dependencies];
|
||||
}
|
||||
|
||||
async function installNodeDependencies(config: RailsConfig, result: ScaffoldResult): Promise<void> {
|
||||
const dependencies = buildNodeDevDependencies(config);
|
||||
if (dependencies.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const commandLine = `pnpm add -D ${dependencies.join(' ')}`;
|
||||
|
||||
if (process.env['MOSAIC_QUALITY_RAILS_SKIP_INSTALL'] === '1') {
|
||||
result.commandsToRun.push(commandLine);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await runCommand('pnpm', ['add', '-D', ...dependencies], config.projectPath);
|
||||
} catch (error) {
|
||||
result.warnings.push(
|
||||
`Failed to auto-install Node dependencies: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
result.commandsToRun.push(commandLine);
|
||||
}
|
||||
}
|
||||
|
||||
export async function scaffoldQualityRails(config: RailsConfig): Promise<ScaffoldResult> {
|
||||
const result: ScaffoldResult = {
|
||||
filesWritten: [],
|
||||
commandsToRun: [],
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
const normalizedLinters = new Set(config.linters.map((linter) => linter.toLowerCase()));
|
||||
|
||||
if (config.kind === 'node') {
|
||||
if (normalizedLinters.has('eslint')) {
|
||||
await writeRelativeFile(
|
||||
config.projectPath,
|
||||
'.eslintrc',
|
||||
eslintTemplate(config.profile),
|
||||
result,
|
||||
);
|
||||
}
|
||||
|
||||
if (normalizedLinters.has('biome')) {
|
||||
await writeRelativeFile(
|
||||
config.projectPath,
|
||||
'biome.json',
|
||||
biomeTemplate(config.profile),
|
||||
result,
|
||||
);
|
||||
}
|
||||
|
||||
await installNodeDependencies(config, result);
|
||||
}
|
||||
|
||||
if (config.kind === 'python') {
|
||||
await upsertPyproject(config.projectPath, config.profile, result);
|
||||
}
|
||||
|
||||
if (config.kind === 'rust') {
|
||||
await writeRelativeFile(
|
||||
config.projectPath,
|
||||
'rustfmt.toml',
|
||||
rustfmtTemplate(config.profile),
|
||||
result,
|
||||
);
|
||||
}
|
||||
|
||||
if (config.hooks) {
|
||||
await writeRelativeFile(
|
||||
config.projectPath,
|
||||
'.githooks/pre-commit',
|
||||
preCommitHookTemplate(config),
|
||||
result,
|
||||
);
|
||||
await chmod(join(config.projectPath, '.githooks/pre-commit'), 0o755);
|
||||
result.commandsToRun.push('git config core.hooksPath .githooks');
|
||||
}
|
||||
|
||||
await writeRelativeFile(
|
||||
config.projectPath,
|
||||
'PR-CHECKLIST.md',
|
||||
prChecklistTemplate(config.profile),
|
||||
result,
|
||||
);
|
||||
|
||||
if (config.kind === 'unknown') {
|
||||
result.warnings.push(
|
||||
'Unable to detect project kind. Generated generic rails only (hooks + PR checklist).',
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
181
packages/quality-rails/src/templates.ts
Normal file
181
packages/quality-rails/src/templates.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import type { QualityProfile, RailsConfig } from './types.js';
|
||||
|
||||
const PROFILE_TO_MAX_WARNINGS: Record<QualityProfile, number> = {
|
||||
strict: 0,
|
||||
standard: 10,
|
||||
minimal: 50,
|
||||
};
|
||||
|
||||
const PROFILE_TO_LINE_LENGTH: Record<QualityProfile, number> = {
|
||||
strict: 100,
|
||||
standard: 110,
|
||||
minimal: 120,
|
||||
};
|
||||
|
||||
export function eslintTemplate(profile: QualityProfile): string {
|
||||
return `${JSON.stringify(
|
||||
{
|
||||
root: true,
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['@typescript-eslint'],
|
||||
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'],
|
||||
env: {
|
||||
node: true,
|
||||
es2022: true,
|
||||
},
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
},
|
||||
rules: {
|
||||
'no-console': profile === 'strict' ? 'error' : 'warn',
|
||||
'@typescript-eslint/no-explicit-any':
|
||||
profile === 'minimal' ? 'off' : profile === 'strict' ? 'error' : 'warn',
|
||||
'@typescript-eslint/explicit-function-return-type': profile === 'strict' ? 'warn' : 'off',
|
||||
'max-lines-per-function': [
|
||||
profile === 'minimal' ? 'off' : 'warn',
|
||||
{
|
||||
max: profile === 'strict' ? 60 : 100,
|
||||
skipBlankLines: true,
|
||||
skipComments: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`;
|
||||
}
|
||||
|
||||
export function biomeTemplate(profile: QualityProfile): string {
|
||||
return `${JSON.stringify(
|
||||
{
|
||||
$schema: 'https://biomejs.dev/schemas/1.8.3/schema.json',
|
||||
formatter: {
|
||||
enabled: true,
|
||||
indentStyle: 'space',
|
||||
indentWidth: 2,
|
||||
lineWidth: PROFILE_TO_LINE_LENGTH[profile],
|
||||
},
|
||||
linter: {
|
||||
enabled: true,
|
||||
rules: {
|
||||
recommended: true,
|
||||
suspicious: {
|
||||
noConsole: profile === 'strict' ? 'error' : 'warn',
|
||||
},
|
||||
complexity: {
|
||||
noExcessiveCognitiveComplexity:
|
||||
profile === 'strict' ? 'warn' : profile === 'standard' ? 'info' : 'off',
|
||||
},
|
||||
},
|
||||
},
|
||||
javascript: {
|
||||
formatter: {
|
||||
quoteStyle: 'single',
|
||||
trailingCommas: 'all',
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`;
|
||||
}
|
||||
|
||||
export function pyprojectSection(profile: QualityProfile): string {
|
||||
const lineLength = PROFILE_TO_LINE_LENGTH[profile];
|
||||
return [
|
||||
'# >>> mosaic-quality-rails >>>',
|
||||
'[tool.ruff]',
|
||||
`line-length = ${lineLength}`,
|
||||
'target-version = "py311"',
|
||||
'',
|
||||
'[tool.ruff.lint]',
|
||||
'select = ["E", "F", "I", "UP", "B"]',
|
||||
`ignore = ${profile === 'minimal' ? '[]' : '["E501"]'}`,
|
||||
'',
|
||||
'[tool.black]',
|
||||
`line-length = ${lineLength}`,
|
||||
'',
|
||||
'# <<< mosaic-quality-rails <<<',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function rustfmtTemplate(profile: QualityProfile): string {
|
||||
const maxWidth = PROFILE_TO_LINE_LENGTH[profile];
|
||||
const useSmallHeuristics = profile === 'strict' ? 'Max' : 'Default';
|
||||
|
||||
return [
|
||||
`max_width = ${maxWidth}`,
|
||||
`use_small_heuristics = "${useSmallHeuristics}"`,
|
||||
`imports_granularity = "${profile === 'minimal' ? 'Crate' : 'Module'}"`,
|
||||
`group_imports = "${profile === 'strict' ? 'StdExternalCrate' : 'Preserve'}"`,
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function resolveHookCommands(config: RailsConfig): string[] {
|
||||
const commands: string[] = [];
|
||||
|
||||
if (config.kind === 'node') {
|
||||
if (config.linters.some((linter) => linter.toLowerCase() === 'eslint')) {
|
||||
commands.push('pnpm lint');
|
||||
}
|
||||
if (config.linters.some((linter) => linter.toLowerCase() === 'biome')) {
|
||||
commands.push('pnpm biome check .');
|
||||
}
|
||||
if (config.formatters.some((formatter) => formatter.toLowerCase() === 'prettier')) {
|
||||
commands.push('pnpm prettier --check .');
|
||||
}
|
||||
commands.push('pnpm test --if-present');
|
||||
}
|
||||
|
||||
if (config.kind === 'python') {
|
||||
commands.push('ruff check .');
|
||||
commands.push('black --check .');
|
||||
}
|
||||
|
||||
if (config.kind === 'rust') {
|
||||
commands.push('cargo fmt --check');
|
||||
commands.push('cargo clippy --all-targets --all-features -- -D warnings');
|
||||
}
|
||||
|
||||
if (commands.length === 0) {
|
||||
commands.push('echo "No quality commands configured for this project kind"');
|
||||
}
|
||||
|
||||
return commands;
|
||||
}
|
||||
|
||||
export function preCommitHookTemplate(config: RailsConfig): string {
|
||||
const commands = resolveHookCommands(config)
|
||||
.map((command) => `${command} || exit 1`)
|
||||
.join('\n');
|
||||
|
||||
return [
|
||||
'#!/usr/bin/env sh',
|
||||
'set -eu',
|
||||
'',
|
||||
'echo "[quality-rails] Running pre-commit checks..."',
|
||||
commands,
|
||||
'echo "[quality-rails] Checks passed."',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function prChecklistTemplate(profile: QualityProfile): string {
|
||||
return [
|
||||
'# Code Review Checklist',
|
||||
'',
|
||||
`Profile: **${profile}**`,
|
||||
'',
|
||||
'- [ ] Requirements mapped to tests',
|
||||
'- [ ] Error handling covers unhappy paths',
|
||||
'- [ ] Lint and typecheck are clean',
|
||||
'- [ ] Test suite passes',
|
||||
'- [ ] Security-sensitive paths reviewed',
|
||||
`- [ ] Warnings count <= ${PROFILE_TO_MAX_WARNINGS[profile]}`,
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
18
packages/quality-rails/src/types.ts
Normal file
18
packages/quality-rails/src/types.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export type ProjectKind = 'node' | 'python' | 'rust' | 'unknown';
|
||||
|
||||
export type QualityProfile = 'strict' | 'standard' | 'minimal';
|
||||
|
||||
export interface RailsConfig {
|
||||
projectPath: string;
|
||||
kind: ProjectKind;
|
||||
profile: QualityProfile;
|
||||
linters: string[];
|
||||
formatters: string[];
|
||||
hooks: boolean;
|
||||
}
|
||||
|
||||
export interface ScaffoldResult {
|
||||
filesWritten: string[];
|
||||
commandsToRun: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
102
pnpm-lock.yaml
generated
102
pnpm-lock.yaml
generated
@@ -46,10 +46,10 @@ importers:
|
||||
version: 13.0.2
|
||||
'@mariozechner/pi-ai':
|
||||
specifier: ~0.57.1
|
||||
version: 0.57.1(ws@8.19.0)(zod@4.3.6)
|
||||
version: 0.57.1(ws@8.19.0)(zod@3.25.76)
|
||||
'@mariozechner/pi-coding-agent':
|
||||
specifier: ~0.57.1
|
||||
version: 0.57.1(ws@8.19.0)(zod@4.3.6)
|
||||
version: 0.57.1(ws@8.19.0)(zod@3.25.76)
|
||||
'@mosaic/auth':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/auth
|
||||
@@ -395,7 +395,26 @@ importers:
|
||||
version: 2.1.9(@types/node@22.19.15)(lightningcss@1.31.1)
|
||||
|
||||
packages/prdy:
|
||||
dependencies:
|
||||
'@clack/prompts':
|
||||
specifier: ^0.9.0
|
||||
version: 0.9.1
|
||||
commander:
|
||||
specifier: ^12.0.0
|
||||
version: 12.1.0
|
||||
js-yaml:
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.1
|
||||
zod:
|
||||
specifier: ^3.22.0
|
||||
version: 3.25.76
|
||||
devDependencies:
|
||||
'@types/js-yaml':
|
||||
specifier: ^4.0.9
|
||||
version: 4.0.9
|
||||
'@types/node':
|
||||
specifier: ^22.0.0
|
||||
version: 22.19.15
|
||||
typescript:
|
||||
specifier: ^5.8.0
|
||||
version: 5.9.3
|
||||
@@ -404,7 +423,14 @@ importers:
|
||||
version: 2.1.9(@types/node@22.19.15)(lightningcss@1.31.1)
|
||||
|
||||
packages/quality-rails:
|
||||
dependencies:
|
||||
commander:
|
||||
specifier: ^12.0.0
|
||||
version: 12.1.0
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^22.0.0
|
||||
version: 22.19.15
|
||||
typescript:
|
||||
specifier: ^5.8.0
|
||||
version: 5.9.3
|
||||
@@ -706,6 +732,12 @@ packages:
|
||||
'@borewit/text-codec@0.2.2':
|
||||
resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==}
|
||||
|
||||
'@clack/core@0.4.1':
|
||||
resolution: {integrity: sha512-Pxhij4UXg8KSr7rPek6Zowm+5M22rbd2g1nfojHJkxp5YkFqiZ2+YLEM/XGVIzvGOcM0nqjIFxrpDwWRZYWYjA==}
|
||||
|
||||
'@clack/prompts@0.9.1':
|
||||
resolution: {integrity: sha512-JIpyaboYZeWYlyP0H+OoPPxd6nqueG/CmN6ixBiNFsIDHREevjIf0n0Ohh5gr5C8pEDknzgvz+pIJ8dMhzWIeg==}
|
||||
|
||||
'@discordjs/builders@1.13.1':
|
||||
resolution: {integrity: sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w==}
|
||||
engines: {node: '>=16.11.0'}
|
||||
@@ -2779,6 +2811,9 @@ packages:
|
||||
'@types/estree@1.0.8':
|
||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||
|
||||
'@types/js-yaml@4.0.9':
|
||||
resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==}
|
||||
|
||||
'@types/json-schema@7.0.15':
|
||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||
|
||||
@@ -3253,6 +3288,10 @@ packages:
|
||||
colorette@2.0.20:
|
||||
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
|
||||
|
||||
commander@12.1.0:
|
||||
resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
commander@13.1.0:
|
||||
resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -4658,6 +4697,9 @@ packages:
|
||||
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
sisteransi@1.0.5:
|
||||
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
|
||||
|
||||
slice-ansi@5.0.0:
|
||||
resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -5146,6 +5188,9 @@ packages:
|
||||
peerDependencies:
|
||||
zod: ^3.25 || ^4
|
||||
|
||||
zod@3.25.76:
|
||||
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
||||
|
||||
zod@4.3.6:
|
||||
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
|
||||
|
||||
@@ -5158,11 +5203,11 @@ snapshots:
|
||||
|
||||
'@alloc/quick-lru@5.2.0': {}
|
||||
|
||||
'@anthropic-ai/sdk@0.73.0(zod@4.3.6)':
|
||||
'@anthropic-ai/sdk@0.73.0(zod@3.25.76)':
|
||||
dependencies:
|
||||
json-schema-to-ts: 3.1.1
|
||||
optionalDependencies:
|
||||
zod: 4.3.6
|
||||
zod: 3.25.76
|
||||
|
||||
'@aws-crypto/crc32@5.2.0':
|
||||
dependencies:
|
||||
@@ -5594,6 +5639,17 @@ snapshots:
|
||||
|
||||
'@borewit/text-codec@0.2.2': {}
|
||||
|
||||
'@clack/core@0.4.1':
|
||||
dependencies:
|
||||
picocolors: 1.1.1
|
||||
sisteransi: 1.0.5
|
||||
|
||||
'@clack/prompts@0.9.1':
|
||||
dependencies:
|
||||
'@clack/core': 0.4.1
|
||||
picocolors: 1.1.1
|
||||
sisteransi: 1.0.5
|
||||
|
||||
'@discordjs/builders@1.13.1':
|
||||
dependencies:
|
||||
'@discordjs/formatters': 0.6.2
|
||||
@@ -6249,9 +6305,9 @@ snapshots:
|
||||
std-env: 3.10.0
|
||||
yoctocolors: 2.1.2
|
||||
|
||||
'@mariozechner/pi-agent-core@0.57.1(ws@8.19.0)(zod@4.3.6)':
|
||||
'@mariozechner/pi-agent-core@0.57.1(ws@8.19.0)(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@mariozechner/pi-ai': 0.57.1(ws@8.19.0)(zod@4.3.6)
|
||||
'@mariozechner/pi-ai': 0.57.1(ws@8.19.0)(zod@3.25.76)
|
||||
transitivePeerDependencies:
|
||||
- '@modelcontextprotocol/sdk'
|
||||
- aws-crt
|
||||
@@ -6261,9 +6317,9 @@ snapshots:
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@mariozechner/pi-ai@0.57.1(ws@8.19.0)(zod@4.3.6)':
|
||||
'@mariozechner/pi-ai@0.57.1(ws@8.19.0)(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@anthropic-ai/sdk': 0.73.0(zod@4.3.6)
|
||||
'@anthropic-ai/sdk': 0.73.0(zod@3.25.76)
|
||||
'@aws-sdk/client-bedrock-runtime': 3.1008.0
|
||||
'@google/genai': 1.45.0
|
||||
'@mistralai/mistralai': 1.14.1
|
||||
@@ -6271,11 +6327,11 @@ snapshots:
|
||||
ajv: 8.18.0
|
||||
ajv-formats: 3.0.1(ajv@8.18.0)
|
||||
chalk: 5.6.2
|
||||
openai: 6.26.0(ws@8.19.0)(zod@4.3.6)
|
||||
openai: 6.26.0(ws@8.19.0)(zod@3.25.76)
|
||||
partial-json: 0.1.7
|
||||
proxy-agent: 6.5.0
|
||||
undici: 7.24.0
|
||||
zod-to-json-schema: 3.25.1(zod@4.3.6)
|
||||
zod-to-json-schema: 3.25.1(zod@3.25.76)
|
||||
transitivePeerDependencies:
|
||||
- '@modelcontextprotocol/sdk'
|
||||
- aws-crt
|
||||
@@ -6285,11 +6341,11 @@ snapshots:
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@mariozechner/pi-coding-agent@0.57.1(ws@8.19.0)(zod@4.3.6)':
|
||||
'@mariozechner/pi-coding-agent@0.57.1(ws@8.19.0)(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@mariozechner/jiti': 2.6.5
|
||||
'@mariozechner/pi-agent-core': 0.57.1(ws@8.19.0)(zod@4.3.6)
|
||||
'@mariozechner/pi-ai': 0.57.1(ws@8.19.0)(zod@4.3.6)
|
||||
'@mariozechner/pi-agent-core': 0.57.1(ws@8.19.0)(zod@3.25.76)
|
||||
'@mariozechner/pi-ai': 0.57.1(ws@8.19.0)(zod@3.25.76)
|
||||
'@mariozechner/pi-tui': 0.57.1
|
||||
'@silvia-odwyer/photon-node': 0.3.4
|
||||
chalk: 5.6.2
|
||||
@@ -6330,8 +6386,8 @@ snapshots:
|
||||
'@mistralai/mistralai@1.14.1':
|
||||
dependencies:
|
||||
ws: 8.19.0
|
||||
zod: 4.3.6
|
||||
zod-to-json-schema: 3.25.1(zod@4.3.6)
|
||||
zod: 3.25.76
|
||||
zod-to-json-schema: 3.25.1(zod@3.25.76)
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
@@ -7659,6 +7715,8 @@ snapshots:
|
||||
|
||||
'@types/estree@1.0.8': {}
|
||||
|
||||
'@types/js-yaml@4.0.9': {}
|
||||
|
||||
'@types/json-schema@7.0.15': {}
|
||||
|
||||
'@types/memcached@2.2.10':
|
||||
@@ -8116,6 +8174,8 @@ snapshots:
|
||||
|
||||
colorette@2.0.20: {}
|
||||
|
||||
commander@12.1.0: {}
|
||||
|
||||
commander@13.1.0: {}
|
||||
|
||||
concat-map@0.0.1: {}
|
||||
@@ -9185,10 +9245,10 @@ snapshots:
|
||||
dependencies:
|
||||
mimic-function: 5.0.1
|
||||
|
||||
openai@6.26.0(ws@8.19.0)(zod@4.3.6):
|
||||
openai@6.26.0(ws@8.19.0)(zod@3.25.76):
|
||||
optionalDependencies:
|
||||
ws: 8.19.0
|
||||
zod: 4.3.6
|
||||
zod: 3.25.76
|
||||
|
||||
optionator@0.9.4:
|
||||
dependencies:
|
||||
@@ -9568,6 +9628,8 @@ snapshots:
|
||||
|
||||
signal-exit@4.1.0: {}
|
||||
|
||||
sisteransi@1.0.5: {}
|
||||
|
||||
slice-ansi@5.0.0:
|
||||
dependencies:
|
||||
ansi-styles: 6.2.3
|
||||
@@ -10021,8 +10083,10 @@ snapshots:
|
||||
|
||||
yoga-layout@3.2.1: {}
|
||||
|
||||
zod-to-json-schema@3.25.1(zod@4.3.6):
|
||||
zod-to-json-schema@3.25.1(zod@3.25.76):
|
||||
dependencies:
|
||||
zod: 4.3.6
|
||||
zod: 3.25.76
|
||||
|
||||
zod@3.25.76: {}
|
||||
|
||||
zod@4.3.6: {}
|
||||
|
||||
Reference in New Issue
Block a user