Compare commits
1 Commits
feat/wave3
...
feat/wave3
| Author | SHA1 | Date | |
|---|---|---|---|
| 3106ca8cf8 |
@@ -1,5 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
import { rootCommand } from '../src/root-command.js';
|
|
||||||
|
|
||||||
rootCommand.parseAsync(process.argv);
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import tsParser from '@typescript-eslint/parser';
|
|
||||||
|
|
||||||
export default [
|
|
||||||
{
|
|
||||||
files: ['src/**/*.ts', 'bin/**/*.ts', 'tests/**/*.ts'],
|
|
||||||
languageOptions: {
|
|
||||||
parser: tsParser,
|
|
||||||
parserOptions: {
|
|
||||||
ecmaVersion: 'latest',
|
|
||||||
sourceType: 'module',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
rules: {},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@mosaic/cli",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"type": "module",
|
|
||||||
"description": "Mosaic unified CLI — the mosaic command",
|
|
||||||
"bin": {
|
|
||||||
"mosaic": "./dist/bin/mosaic.js"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsc -p tsconfig.json",
|
|
||||||
"typecheck": "tsc --noEmit",
|
|
||||||
"lint": "eslint src/",
|
|
||||||
"test": "vitest run"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@mosaic/types": "workspace:*",
|
|
||||||
"@mosaic/coord": "workspace:*",
|
|
||||||
"@mosaic/queue": "workspace:*",
|
|
||||||
"commander": "^13",
|
|
||||||
"picocolors": "^1.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^22",
|
|
||||||
"@typescript-eslint/parser": "^8",
|
|
||||||
"eslint": "^9",
|
|
||||||
"typescript": "^5",
|
|
||||||
"vitest": "^2"
|
|
||||||
},
|
|
||||||
"publishConfig": {
|
|
||||||
"registry": "https://git.mosaicstack.dev/api/packages/mosaic/npm",
|
|
||||||
"access": "public"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { Command } from 'commander';
|
|
||||||
|
|
||||||
import { buildCoordCli } from '@mosaic/coord/dist/cli.js';
|
|
||||||
|
|
||||||
const COMMAND_NAME = 'coord';
|
|
||||||
|
|
||||||
export function registerCoordCommand(program: Command): void {
|
|
||||||
const coordCommand = buildCoordCli().commands.find((command) => command.name() === COMMAND_NAME);
|
|
||||||
|
|
||||||
if (coordCommand === undefined) {
|
|
||||||
throw new Error('Expected @mosaic/coord to expose a "coord" command.');
|
|
||||||
}
|
|
||||||
|
|
||||||
program.addCommand(coordCommand);
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { Command } from 'commander';
|
|
||||||
import pc from 'picocolors';
|
|
||||||
|
|
||||||
// TODO(wave3): Replace this temporary shim once @mosaic/prdy lands in main.
|
|
||||||
export function registerPrdyCommand(program: Command): void {
|
|
||||||
program
|
|
||||||
.command('prdy')
|
|
||||||
.description('PRD workflow commands')
|
|
||||||
.action(() => {
|
|
||||||
console.error(pc.yellow('@mosaic/prdy CLI is not available in this workspace yet.'));
|
|
||||||
process.exitCode = 1;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { Command } from 'commander';
|
|
||||||
import pc from 'picocolors';
|
|
||||||
|
|
||||||
// TODO(wave3): Replace this temporary shim once @mosaic/quality-rails lands in main.
|
|
||||||
export function registerQualityRailsCommand(program: Command): void {
|
|
||||||
program
|
|
||||||
.command('quality-rails')
|
|
||||||
.description('Quality rail commands')
|
|
||||||
.action(() => {
|
|
||||||
console.error(
|
|
||||||
pc.yellow('@mosaic/quality-rails CLI is not available in this workspace yet.'),
|
|
||||||
);
|
|
||||||
process.exitCode = 1;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { Command } from 'commander';
|
|
||||||
|
|
||||||
import { buildQueueCli } from '@mosaic/queue';
|
|
||||||
|
|
||||||
const COMMAND_NAME = 'queue';
|
|
||||||
|
|
||||||
export function registerQueueCommand(program: Command): void {
|
|
||||||
const queueCommand = buildQueueCli().commands.find((command) => command.name() === COMMAND_NAME);
|
|
||||||
|
|
||||||
if (queueCommand === undefined) {
|
|
||||||
throw new Error('Expected @mosaic/queue to expose a "queue" command.');
|
|
||||||
}
|
|
||||||
|
|
||||||
program.addCommand(queueCommand);
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { rootCommand } from './root-command.js';
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { Command } from 'commander';
|
|
||||||
|
|
||||||
import { registerCoordCommand } from './commands/coord.js';
|
|
||||||
import { registerPrdyCommand } from './commands/prdy.js';
|
|
||||||
import { registerQualityRailsCommand } from './commands/quality-rails.js';
|
|
||||||
import { registerQueueCommand } from './commands/queue.js';
|
|
||||||
import { VERSION } from './version.js';
|
|
||||||
|
|
||||||
export const rootCommand = new Command()
|
|
||||||
.name('mosaic')
|
|
||||||
.version(VERSION)
|
|
||||||
.description('Mosaic — AI agent orchestration platform');
|
|
||||||
|
|
||||||
registerCoordCommand(rootCommand);
|
|
||||||
registerPrdyCommand(rootCommand);
|
|
||||||
registerQueueCommand(rootCommand);
|
|
||||||
registerQualityRailsCommand(rootCommand);
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export const VERSION = '0.1.0';
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import { rootCommand } from '../src/root-command.js';
|
|
||||||
|
|
||||||
describe('rootCommand', () => {
|
|
||||||
it('registers all top-level subcommand groups', () => {
|
|
||||||
const registeredSubcommands = rootCommand.commands
|
|
||||||
.map((command) => command.name())
|
|
||||||
.sort((left, right) => left.localeCompare(right));
|
|
||||||
|
|
||||||
expect(registeredSubcommands).toEqual([
|
|
||||||
'coord',
|
|
||||||
'prdy',
|
|
||||||
'quality-rails',
|
|
||||||
'queue',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../../tsconfig.base.json",
|
|
||||||
"compilerOptions": { "outDir": "dist", "rootDir": "." },
|
|
||||||
"include": ["src", "bin"]
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import tsEslintPlugin from '@typescript-eslint/eslint-plugin';
|
|
||||||
import tsEslintParser from '@typescript-eslint/parser';
|
|
||||||
|
|
||||||
export default [
|
|
||||||
{
|
|
||||||
files: ['src/**/*.ts'],
|
|
||||||
languageOptions: {
|
|
||||||
parser: tsEslintParser,
|
|
||||||
sourceType: 'module',
|
|
||||||
ecmaVersion: 'latest',
|
|
||||||
},
|
|
||||||
plugins: {
|
|
||||||
'@typescript-eslint': tsEslintPlugin,
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
'no-console': 'off',
|
|
||||||
'@typescript-eslint/no-explicit-any': 'off',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@mosaic/quality-rails",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"type": "module",
|
|
||||||
"description": "Mosaic quality rails - TypeScript code quality scaffolder",
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsc -p tsconfig.json",
|
|
||||||
"typecheck": "tsc --noEmit",
|
|
||||||
"lint": "eslint src/",
|
|
||||||
"test": "vitest run"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@mosaic/types": "workspace:*",
|
|
||||||
"commander": "^13",
|
|
||||||
"js-yaml": "^4"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^22",
|
|
||||||
"@types/js-yaml": "^4",
|
|
||||||
"@typescript-eslint/eslint-plugin": "^8",
|
|
||||||
"@typescript-eslint/parser": "^8",
|
|
||||||
"eslint": "^9",
|
|
||||||
"typescript": "^5",
|
|
||||||
"vitest": "^2"
|
|
||||||
},
|
|
||||||
"publishConfig": {
|
|
||||||
"registry": "https://git.mosaicstack.dev/api/packages/mosaic/npm",
|
|
||||||
"access": "public"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
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,5 +0,0 @@
|
|||||||
export * from './cli.js';
|
|
||||||
export * from './detect.js';
|
|
||||||
export * from './scaffolder.js';
|
|
||||||
export * from './templates.js';
|
|
||||||
export * from './types.js';
|
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
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');
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
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[];
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
|
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import { detectProjectKind } from '../src/detect.js';
|
|
||||||
|
|
||||||
async function withTempDir(run: (directory: string) => Promise<void>): Promise<void> {
|
|
||||||
const directory = await mkdtemp(join(tmpdir(), 'quality-rails-detect-'));
|
|
||||||
try {
|
|
||||||
await run(directory);
|
|
||||||
} finally {
|
|
||||||
await rm(directory, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('detectProjectKind', () => {
|
|
||||||
it('returns node when package.json exists', async () => {
|
|
||||||
await withTempDir(async (directory) => {
|
|
||||||
await writeFile(join(directory, 'package.json'), '{"name":"fixture"}\n', 'utf8');
|
|
||||||
|
|
||||||
await expect(detectProjectKind(directory)).resolves.toBe('node');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns python when pyproject.toml exists and package.json does not', async () => {
|
|
||||||
await withTempDir(async (directory) => {
|
|
||||||
await writeFile(join(directory, 'pyproject.toml'), '[project]\nname = "fixture"\n', 'utf8');
|
|
||||||
|
|
||||||
await expect(detectProjectKind(directory)).resolves.toBe('python');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns unknown when no known project files exist', async () => {
|
|
||||||
await withTempDir(async (directory) => {
|
|
||||||
await expect(detectProjectKind(directory)).resolves.toBe('unknown');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
|
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import { scaffoldQualityRails } from '../src/scaffolder.js';
|
|
||||||
import type { RailsConfig } from '../src/types.js';
|
|
||||||
|
|
||||||
async function withTempDir(run: (directory: string) => Promise<void>): Promise<void> {
|
|
||||||
const directory = await mkdtemp(join(tmpdir(), 'quality-rails-scaffold-'));
|
|
||||||
try {
|
|
||||||
await run(directory);
|
|
||||||
} finally {
|
|
||||||
await rm(directory, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('scaffoldQualityRails', () => {
|
|
||||||
it('writes expected node quality rails files', async () => {
|
|
||||||
await withTempDir(async (directory) => {
|
|
||||||
await writeFile(join(directory, 'package.json'), '{"name":"fixture"}\n', 'utf8');
|
|
||||||
|
|
||||||
const previous = process.env.MOSAIC_QUALITY_RAILS_SKIP_INSTALL;
|
|
||||||
process.env.MOSAIC_QUALITY_RAILS_SKIP_INSTALL = '1';
|
|
||||||
|
|
||||||
const config: RailsConfig = {
|
|
||||||
projectPath: directory,
|
|
||||||
kind: 'node',
|
|
||||||
profile: 'strict',
|
|
||||||
linters: ['eslint', 'biome'],
|
|
||||||
formatters: ['prettier'],
|
|
||||||
hooks: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await scaffoldQualityRails(config);
|
|
||||||
|
|
||||||
process.env.MOSAIC_QUALITY_RAILS_SKIP_INSTALL = previous;
|
|
||||||
|
|
||||||
await expect(readFile(join(directory, '.eslintrc'), 'utf8')).resolves.toContain('parser');
|
|
||||||
await expect(readFile(join(directory, 'biome.json'), 'utf8')).resolves.toContain('"formatter"');
|
|
||||||
await expect(readFile(join(directory, '.githooks', 'pre-commit'), 'utf8')).resolves.toContain('pnpm lint');
|
|
||||||
await expect(readFile(join(directory, 'PR-CHECKLIST.md'), 'utf8')).resolves.toContain('Code Review Checklist');
|
|
||||||
|
|
||||||
expect(result.filesWritten).toEqual(
|
|
||||||
expect.arrayContaining([
|
|
||||||
'.eslintrc',
|
|
||||||
'biome.json',
|
|
||||||
'.githooks/pre-commit',
|
|
||||||
'PR-CHECKLIST.md',
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
expect(result.commandsToRun).toContain('git config core.hooksPath .githooks');
|
|
||||||
expect(result.warnings).toHaveLength(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../../tsconfig.base.json",
|
|
||||||
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
|
||||||
807
pnpm-lock.yaml
generated
807
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user