Compare commits
2 Commits
feat/wave3
...
f8637d68ff
| Author | SHA1 | Date | |
|---|---|---|---|
| f8637d68ff | |||
| 95eed0739d |
@@ -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,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"]
|
||||
}
|
||||
29
packages/prdy/package.json
Normal file
29
packages/prdy/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@mosaic/prdy",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Mosaic PRD wizard — TypeScript rewrite",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src/",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mosaic/types": "workspace:*",
|
||||
"@clack/prompts": "^0.9",
|
||||
"commander": "^13",
|
||||
"js-yaml": "^4",
|
||||
"zod": "^3.24"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22",
|
||||
"@types/js-yaml": "^4",
|
||||
"typescript": "^5",
|
||||
"vitest": "^2"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://git.mosaicstack.dev/api/packages/mosaic/npm",
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
103
packages/prdy/src/cli.ts
Normal file
103
packages/prdy/src/cli.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { Command } from 'commander';
|
||||
|
||||
import { createPrd, listPrds, loadPrd } from './prd.js';
|
||||
import { runPrdWizard } from './wizard.js';
|
||||
|
||||
interface InitCommandOptions {
|
||||
readonly name: string;
|
||||
readonly project: string;
|
||||
readonly template?: 'software' | 'feature' | 'spike';
|
||||
}
|
||||
|
||||
interface ListCommandOptions {
|
||||
readonly project: string;
|
||||
}
|
||||
|
||||
interface ShowCommandOptions {
|
||||
readonly project: string;
|
||||
readonly id?: string;
|
||||
}
|
||||
|
||||
export function buildPrdyCli(): Command {
|
||||
const program = new Command();
|
||||
program
|
||||
.name('mosaic')
|
||||
.description('Mosaic CLI')
|
||||
.exitOverride();
|
||||
|
||||
const prdy = program.command('prdy').description('PRD wizard commands');
|
||||
|
||||
prdy
|
||||
.command('init')
|
||||
.description('Create a PRD document')
|
||||
.requiredOption('--name <name>', 'PRD name')
|
||||
.requiredOption('--project <path>', 'Project path')
|
||||
.option('--template <template>', 'Template (software|feature|spike)')
|
||||
.action(async (options: InitCommandOptions) => {
|
||||
const doc = process.stdout.isTTY
|
||||
? await runPrdWizard({
|
||||
name: options.name,
|
||||
projectPath: options.project,
|
||||
template: options.template,
|
||||
interactive: true,
|
||||
})
|
||||
: await createPrd({
|
||||
name: options.name,
|
||||
projectPath: options.project,
|
||||
template: options.template,
|
||||
interactive: false,
|
||||
});
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
status: doc.status,
|
||||
projectPath: doc.projectPath,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
prdy
|
||||
.command('list')
|
||||
.description('List PRD documents for a project')
|
||||
.requiredOption('--project <path>', 'Project path')
|
||||
.action(async (options: ListCommandOptions) => {
|
||||
const docs = await listPrds(options.project);
|
||||
console.log(JSON.stringify(docs, null, 2));
|
||||
});
|
||||
|
||||
prdy
|
||||
.command('show')
|
||||
.description('Show a PRD document')
|
||||
.requiredOption('--project <path>', 'Project path')
|
||||
.option('--id <id>', 'PRD document id')
|
||||
.action(async (options: ShowCommandOptions) => {
|
||||
if (options.id !== undefined) {
|
||||
const docs = await listPrds(options.project);
|
||||
const match = docs.find((doc) => doc.id === options.id);
|
||||
|
||||
if (match === undefined) {
|
||||
throw new Error(`PRD id not found: ${options.id}`);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(match, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const doc = await loadPrd(options.project);
|
||||
console.log(JSON.stringify(doc, null, 2));
|
||||
});
|
||||
|
||||
return program;
|
||||
}
|
||||
|
||||
export async function runPrdyCli(argv: readonly string[] = process.argv): Promise<void> {
|
||||
const program = buildPrdyCli();
|
||||
await program.parseAsync(argv);
|
||||
}
|
||||
20
packages/prdy/src/index.ts
Normal file
20
packages/prdy/src/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export {
|
||||
createPrd,
|
||||
loadPrd,
|
||||
savePrd,
|
||||
listPrds,
|
||||
} from './prd.js';
|
||||
export { runPrdWizard } from './wizard.js';
|
||||
export { buildPrdyCli, runPrdyCli } from './cli.js';
|
||||
export {
|
||||
BUILTIN_PRD_TEMPLATES,
|
||||
resolveTemplate,
|
||||
} from './templates.js';
|
||||
export type {
|
||||
PrdStatus,
|
||||
PrdTemplate,
|
||||
PrdTemplateSection,
|
||||
PrdSection,
|
||||
PrdDocument,
|
||||
CreatePrdOptions,
|
||||
} from './types.js';
|
||||
199
packages/prdy/src/prd.ts
Normal file
199
packages/prdy/src/prd.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { promises as fs } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import yaml from 'js-yaml';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { resolveTemplate } from './templates.js';
|
||||
import type { CreatePrdOptions, PrdDocument } from './types.js';
|
||||
|
||||
const PRD_DIRECTORY = path.join('docs', 'prdy');
|
||||
const PRD_FILE_EXTENSIONS = new Set(['.yaml', '.yml']);
|
||||
|
||||
const prdSectionSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
title: z.string().min(1),
|
||||
fields: z.record(z.string(), z.string()),
|
||||
});
|
||||
|
||||
const prdDocumentSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
title: z.string().min(1),
|
||||
status: z.enum(['draft', 'review', 'approved', 'archived']),
|
||||
projectPath: z.string().min(1),
|
||||
template: z.string().min(1),
|
||||
sections: z.array(prdSectionSchema),
|
||||
createdAt: z.string().datetime(),
|
||||
updatedAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
function expandHome(projectPath: string): string {
|
||||
if (!projectPath.startsWith('~')) {
|
||||
return projectPath;
|
||||
}
|
||||
|
||||
if (projectPath === '~') {
|
||||
return os.homedir();
|
||||
}
|
||||
|
||||
if (projectPath.startsWith('~/')) {
|
||||
return path.join(os.homedir(), projectPath.slice(2));
|
||||
}
|
||||
|
||||
return projectPath;
|
||||
}
|
||||
|
||||
function resolveProjectPath(projectPath: string): string {
|
||||
return path.resolve(expandHome(projectPath));
|
||||
}
|
||||
|
||||
function toSlug(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.replace(/-{2,}/g, '-');
|
||||
}
|
||||
|
||||
function buildTimestamp(date: Date): { datePart: string; timePart: string } {
|
||||
const iso = date.toISOString();
|
||||
return {
|
||||
datePart: iso.slice(0, 10).replace(/-/g, ''),
|
||||
timePart: iso.slice(11, 19).replace(/:/g, ''),
|
||||
};
|
||||
}
|
||||
|
||||
function buildPrdId(name: string): string {
|
||||
const slug = toSlug(name);
|
||||
const { datePart, timePart } = buildTimestamp(new Date());
|
||||
return `${slug || 'prd'}-${datePart}-${timePart}`;
|
||||
}
|
||||
|
||||
function prdDirectory(projectPath: string): string {
|
||||
return path.join(projectPath, PRD_DIRECTORY);
|
||||
}
|
||||
|
||||
function prdFilePath(projectPath: string, id: string): string {
|
||||
return path.join(prdDirectory(projectPath), `${id}.yaml`);
|
||||
}
|
||||
|
||||
function isNodeErrorWithCode(error: unknown, code: string): boolean {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
(error as { code?: string }).code === code
|
||||
);
|
||||
}
|
||||
|
||||
async function writeFileAtomic(filePath: string, content: string): Promise<void> {
|
||||
const directory = path.dirname(filePath);
|
||||
await fs.mkdir(directory, { recursive: true });
|
||||
|
||||
const tempPath = path.join(
|
||||
directory,
|
||||
`.${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}-${Math.random()
|
||||
.toString(16)
|
||||
.slice(2)}`,
|
||||
);
|
||||
|
||||
await fs.writeFile(tempPath, content, 'utf8');
|
||||
await fs.rename(tempPath, filePath);
|
||||
}
|
||||
|
||||
export async function createPrd(options: CreatePrdOptions): Promise<PrdDocument> {
|
||||
const resolvedProjectPath = resolveProjectPath(options.projectPath);
|
||||
const template = resolveTemplate(options.template);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const document: PrdDocument = {
|
||||
id: buildPrdId(options.name),
|
||||
title: options.name.trim(),
|
||||
status: 'draft',
|
||||
projectPath: resolvedProjectPath,
|
||||
template: template.id,
|
||||
sections: template.sections.map((section) => ({
|
||||
id: section.id,
|
||||
title: section.title,
|
||||
fields: Object.fromEntries(section.fields.map((field) => [field, ''])),
|
||||
})),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await savePrd(document);
|
||||
return document;
|
||||
}
|
||||
|
||||
export async function loadPrd(projectPath: string): Promise<PrdDocument> {
|
||||
const documents = await listPrds(projectPath);
|
||||
if (documents.length === 0) {
|
||||
const resolvedProjectPath = resolveProjectPath(projectPath);
|
||||
throw new Error(`No PRD documents found in ${prdDirectory(resolvedProjectPath)}`);
|
||||
}
|
||||
|
||||
return documents[0]!;
|
||||
}
|
||||
|
||||
export async function savePrd(doc: PrdDocument): Promise<void> {
|
||||
const normalized = prdDocumentSchema.parse({
|
||||
...doc,
|
||||
projectPath: resolveProjectPath(doc.projectPath),
|
||||
});
|
||||
|
||||
const filePath = prdFilePath(normalized.projectPath, normalized.id);
|
||||
const serialized = yaml.dump(normalized, {
|
||||
noRefs: true,
|
||||
sortKeys: false,
|
||||
lineWidth: 120,
|
||||
});
|
||||
|
||||
const content = serialized.endsWith('\n') ? serialized : `${serialized}\n`;
|
||||
await writeFileAtomic(filePath, content);
|
||||
}
|
||||
|
||||
export async function listPrds(projectPath: string): Promise<PrdDocument[]> {
|
||||
const resolvedProjectPath = resolveProjectPath(projectPath);
|
||||
const directory = prdDirectory(resolvedProjectPath);
|
||||
|
||||
let entries: import('node:fs').Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(directory, { withFileTypes: true, encoding: 'utf8' });
|
||||
} catch (error) {
|
||||
if (isNodeErrorWithCode(error, 'ENOENT')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
const documents: PrdDocument[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const ext = path.extname(entry.name);
|
||||
if (!PRD_FILE_EXTENSIONS.has(ext)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const filePath = path.join(directory, entry.name);
|
||||
const raw = await fs.readFile(filePath, 'utf8');
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = yaml.load(raw);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to parse PRD file ${filePath}: ${String(error)}`);
|
||||
}
|
||||
|
||||
const document = prdDocumentSchema.parse(parsed);
|
||||
documents.push(document);
|
||||
}
|
||||
|
||||
documents.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
||||
return documents;
|
||||
}
|
||||
86
packages/prdy/src/templates.ts
Normal file
86
packages/prdy/src/templates.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { PrdTemplate } from './types.js';
|
||||
|
||||
export const BUILTIN_PRD_TEMPLATES: Record<string, PrdTemplate> = {
|
||||
software: {
|
||||
id: 'software',
|
||||
name: 'Software Project',
|
||||
fields: ['owner', 'status', 'scopeVersion', 'successMetrics'],
|
||||
sections: [
|
||||
{ id: 'introduction', title: 'Introduction', fields: ['context', 'objective'] },
|
||||
{ id: 'problem-statement', title: 'Problem Statement', fields: ['painPoints'] },
|
||||
{ id: 'scope-non-goals', title: 'Scope / Non-Goals', fields: ['inScope', 'outOfScope'] },
|
||||
{ id: 'user-stories', title: 'User Stories / Requirements', fields: ['stories'] },
|
||||
{ id: 'functional-requirements', title: 'Functional Requirements', fields: ['requirements'] },
|
||||
{
|
||||
id: 'non-functional-requirements',
|
||||
title: 'Non-Functional Requirements',
|
||||
fields: ['performance', 'reliability', 'security'],
|
||||
},
|
||||
{ id: 'acceptance-criteria', title: 'Acceptance Criteria', fields: ['criteria'] },
|
||||
{
|
||||
id: 'technical-considerations',
|
||||
title: 'Technical Considerations',
|
||||
fields: ['constraints', 'dependencies'],
|
||||
},
|
||||
{
|
||||
id: 'risks-open-questions',
|
||||
title: 'Risks / Open Questions',
|
||||
fields: ['risks', 'openQuestions'],
|
||||
},
|
||||
{
|
||||
id: 'milestones-delivery',
|
||||
title: 'Milestones / Delivery',
|
||||
fields: ['milestones', 'timeline'],
|
||||
},
|
||||
],
|
||||
},
|
||||
feature: {
|
||||
id: 'feature',
|
||||
name: 'Feature PRD',
|
||||
fields: ['owner', 'status', 'releaseTarget'],
|
||||
sections: [
|
||||
{ id: 'problem-statement', title: 'Problem Statement', fields: ['problem'] },
|
||||
{ id: 'goals', title: 'Goals', fields: ['goals'] },
|
||||
{ id: 'scope', title: 'Scope', fields: ['inScope', 'outOfScope'] },
|
||||
{ id: 'user-stories', title: 'User Stories', fields: ['stories'] },
|
||||
{ id: 'requirements', title: 'Requirements', fields: ['functional', 'nonFunctional'] },
|
||||
{ id: 'acceptance-criteria', title: 'Acceptance Criteria', fields: ['criteria'] },
|
||||
{ id: 'technical-considerations', title: 'Technical Considerations', fields: ['constraints'] },
|
||||
{ id: 'risks-open-questions', title: 'Risks / Open Questions', fields: ['risks', 'questions'] },
|
||||
{ id: 'milestones', title: 'Milestones', fields: ['milestones'] },
|
||||
{ id: 'success-metrics', title: 'Success Metrics / Testing', fields: ['metrics', 'testing'] },
|
||||
],
|
||||
},
|
||||
spike: {
|
||||
id: 'spike',
|
||||
name: 'Research Spike',
|
||||
fields: ['owner', 'status', 'decisionDeadline'],
|
||||
sections: [
|
||||
{ id: 'background', title: 'Background', fields: ['context'] },
|
||||
{ id: 'research-questions', title: 'Research Questions', fields: ['questions'] },
|
||||
{ id: 'constraints', title: 'Constraints', fields: ['constraints'] },
|
||||
{ id: 'options', title: 'Options Considered', fields: ['options'] },
|
||||
{ id: 'evaluation', title: 'Evaluation Criteria', fields: ['criteria'] },
|
||||
{ id: 'findings', title: 'Findings', fields: ['findings'] },
|
||||
{ id: 'recommendation', title: 'Recommendation', fields: ['recommendation'] },
|
||||
{ id: 'risks', title: 'Risks / Unknowns', fields: ['risks', 'unknowns'] },
|
||||
{ id: 'next-steps', title: 'Next Steps', fields: ['nextSteps'] },
|
||||
{ id: 'milestones', title: 'Milestones / Delivery', fields: ['milestones'] },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export function resolveTemplate(templateName?: string): PrdTemplate {
|
||||
if (templateName === undefined || templateName.trim().length === 0) {
|
||||
return BUILTIN_PRD_TEMPLATES.software;
|
||||
}
|
||||
|
||||
const template = BUILTIN_PRD_TEMPLATES[templateName];
|
||||
if (template === undefined) {
|
||||
throw new Error(
|
||||
`Unknown PRD template: ${templateName}. Expected one of: ${Object.keys(BUILTIN_PRD_TEMPLATES).join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
return template;
|
||||
}
|
||||
38
packages/prdy/src/types.ts
Normal file
38
packages/prdy/src/types.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export type PrdStatus = 'draft' | 'review' | 'approved' | 'archived';
|
||||
|
||||
export interface PrdTemplateSection {
|
||||
id: string;
|
||||
title: string;
|
||||
fields: string[];
|
||||
}
|
||||
|
||||
export interface PrdTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
sections: PrdTemplateSection[];
|
||||
fields: string[];
|
||||
}
|
||||
|
||||
export interface PrdSection {
|
||||
id: string;
|
||||
title: string;
|
||||
fields: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface PrdDocument {
|
||||
id: string;
|
||||
title: string;
|
||||
status: PrdStatus;
|
||||
projectPath: string;
|
||||
template: string;
|
||||
sections: PrdSection[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreatePrdOptions {
|
||||
name: string;
|
||||
projectPath: string;
|
||||
template?: string;
|
||||
interactive?: boolean;
|
||||
}
|
||||
121
packages/prdy/src/wizard.ts
Normal file
121
packages/prdy/src/wizard.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
cancel,
|
||||
intro,
|
||||
isCancel,
|
||||
outro,
|
||||
select,
|
||||
text,
|
||||
} from '@clack/prompts';
|
||||
|
||||
import { createPrd, savePrd } from './prd.js';
|
||||
import type { CreatePrdOptions, PrdDocument } from './types.js';
|
||||
|
||||
interface WizardAnswers {
|
||||
goals: string;
|
||||
constraints: string;
|
||||
milestones: string;
|
||||
}
|
||||
|
||||
function updateSectionField(
|
||||
doc: PrdDocument,
|
||||
sectionKeyword: string,
|
||||
value: string,
|
||||
): void {
|
||||
const section = doc.sections.find((candidate) =>
|
||||
candidate.id.includes(sectionKeyword),
|
||||
);
|
||||
|
||||
if (section === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fieldName =
|
||||
Object.keys(section.fields).find((field) =>
|
||||
field.toLowerCase().includes(sectionKeyword),
|
||||
) ?? Object.keys(section.fields)[0];
|
||||
|
||||
if (fieldName !== undefined) {
|
||||
section.fields[fieldName] = value;
|
||||
}
|
||||
}
|
||||
|
||||
async function promptText(message: string, initialValue = ''): Promise<string> {
|
||||
const response = await text({
|
||||
message,
|
||||
initialValue,
|
||||
});
|
||||
|
||||
if (isCancel(response)) {
|
||||
cancel('PRD wizard cancelled.');
|
||||
throw new Error('PRD wizard cancelled');
|
||||
}
|
||||
|
||||
return response.trim();
|
||||
}
|
||||
|
||||
async function promptTemplate(template?: string): Promise<string> {
|
||||
if (template !== undefined && template.trim().length > 0) {
|
||||
return template;
|
||||
}
|
||||
|
||||
const choice = await select({
|
||||
message: 'PRD type',
|
||||
options: [
|
||||
{ value: 'software', label: 'Software project' },
|
||||
{ value: 'feature', label: 'Feature' },
|
||||
{ value: 'spike', label: 'Research spike' },
|
||||
],
|
||||
});
|
||||
|
||||
if (isCancel(choice)) {
|
||||
cancel('PRD wizard cancelled.');
|
||||
throw new Error('PRD wizard cancelled');
|
||||
}
|
||||
|
||||
return choice;
|
||||
}
|
||||
|
||||
function applyWizardAnswers(doc: PrdDocument, answers: WizardAnswers): PrdDocument {
|
||||
updateSectionField(doc, 'goal', answers.goals);
|
||||
updateSectionField(doc, 'constraint', answers.constraints);
|
||||
updateSectionField(doc, 'milestone', answers.milestones);
|
||||
|
||||
doc.updatedAt = new Date().toISOString();
|
||||
return doc;
|
||||
}
|
||||
|
||||
export async function runPrdWizard(options: CreatePrdOptions): Promise<PrdDocument> {
|
||||
intro('Mosaic PRD wizard');
|
||||
|
||||
const name =
|
||||
options.name.trim().length > 0
|
||||
? options.name.trim()
|
||||
: await promptText('Project name');
|
||||
const template = await promptTemplate(options.template);
|
||||
const goals = await promptText('Primary goals');
|
||||
const constraints = await promptText('Key constraints');
|
||||
const milestones = await promptText('Planned milestones');
|
||||
|
||||
const doc = await createPrd({
|
||||
...options,
|
||||
name,
|
||||
template,
|
||||
interactive: true,
|
||||
});
|
||||
|
||||
const updated = applyWizardAnswers(doc, {
|
||||
goals,
|
||||
constraints,
|
||||
milestones,
|
||||
});
|
||||
|
||||
await savePrd(updated);
|
||||
|
||||
outro(
|
||||
`PRD created: ${path.join(updated.projectPath, 'docs', 'prdy', `${updated.id}.yaml`)}`,
|
||||
);
|
||||
|
||||
return updated;
|
||||
}
|
||||
36
packages/prdy/tests/prd.test.ts
Normal file
36
packages/prdy/tests/prd.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { promises as fs } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { createPrd, listPrds, loadPrd } from '../src/prd.js';
|
||||
|
||||
describe('prd document lifecycle', () => {
|
||||
it('creates and loads PRD documents', async () => {
|
||||
const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'prdy-project-'));
|
||||
|
||||
try {
|
||||
const created = await createPrd({
|
||||
name: 'User Authentication',
|
||||
projectPath: projectDir,
|
||||
template: 'feature',
|
||||
});
|
||||
|
||||
expect(created.title).toBe('User Authentication');
|
||||
expect(created.status).toBe('draft');
|
||||
expect(created.id).toMatch(/^user-authentication-\d{8}-\d{6}$/);
|
||||
|
||||
const loaded = await loadPrd(projectDir);
|
||||
expect(loaded.id).toBe(created.id);
|
||||
expect(loaded.title).toBe(created.title);
|
||||
expect(loaded.sections.length).toBeGreaterThan(0);
|
||||
|
||||
const listed = await listPrds(projectDir);
|
||||
expect(listed).toHaveLength(1);
|
||||
expect(listed[0]?.id).toBe(created.id);
|
||||
} finally {
|
||||
await fs.rm(projectDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
22
packages/prdy/tests/templates.test.ts
Normal file
22
packages/prdy/tests/templates.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { BUILTIN_PRD_TEMPLATES } from '../src/templates.js';
|
||||
|
||||
describe('built-in PRD templates', () => {
|
||||
it('includes software, feature, and spike templates with required fields', () => {
|
||||
expect(BUILTIN_PRD_TEMPLATES.software).toBeDefined();
|
||||
expect(BUILTIN_PRD_TEMPLATES.feature).toBeDefined();
|
||||
expect(BUILTIN_PRD_TEMPLATES.spike).toBeDefined();
|
||||
|
||||
for (const template of Object.values(BUILTIN_PRD_TEMPLATES)) {
|
||||
expect(template.sections.length).toBeGreaterThan(0);
|
||||
expect(template.fields.length).toBeGreaterThan(0);
|
||||
|
||||
for (const section of template.sections) {
|
||||
expect(section.id.length).toBeGreaterThan(0);
|
||||
expect(section.title.length).toBeGreaterThan(0);
|
||||
expect(section.fields.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
5
packages/prdy/tsconfig.json
Normal file
5
packages/prdy/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
|
||||
"include": ["src"]
|
||||
}
|
||||
20
packages/quality-rails/eslint.config.js
Normal file
20
packages/quality-rails/eslint.config.js
Normal file
@@ -0,0 +1,20 @@
|
||||
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,11 +1,8 @@
|
||||
{
|
||||
"name": "@mosaic/cli",
|
||||
"name": "@mosaic/quality-rails",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Mosaic unified CLI — the mosaic command",
|
||||
"bin": {
|
||||
"mosaic": "./dist/bin/mosaic.js"
|
||||
},
|
||||
"description": "Mosaic quality rails - TypeScript code quality scaffolder",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc --noEmit",
|
||||
@@ -14,13 +11,13 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@mosaic/types": "workspace:*",
|
||||
"@mosaic/coord": "workspace:*",
|
||||
"@mosaic/queue": "workspace:*",
|
||||
"commander": "^13",
|
||||
"picocolors": "^1.1"
|
||||
"js-yaml": "^4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22",
|
||||
"@types/js-yaml": "^4",
|
||||
"@typescript-eslint/eslint-plugin": "^8",
|
||||
"@typescript-eslint/parser": "^8",
|
||||
"eslint": "^9",
|
||||
"typescript": "^5",
|
||||
193
packages/quality-rails/src/cli.ts
Normal file
193
packages/quality-rails/src/cli.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
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';
|
||||
}
|
||||
5
packages/quality-rails/src/index.ts
Normal file
5
packages/quality-rails/src/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './cli.js';
|
||||
export * from './detect.js';
|
||||
export * from './scaffolder.js';
|
||||
export * from './templates.js';
|
||||
export * from './types.js';
|
||||
201
packages/quality-rails/src/scaffolder.ts
Normal file
201
packages/quality-rails/src/scaffolder.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
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;
|
||||
}
|
||||
182
packages/quality-rails/src/templates.ts
Normal file
182
packages/quality-rails/src/templates.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
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[];
|
||||
}
|
||||
0
packages/quality-rails/templates/.gitkeep
Normal file
0
packages/quality-rails/templates/.gitkeep
Normal file
40
packages/quality-rails/tests/detect.test.ts
Normal file
40
packages/quality-rails/tests/detect.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
57
packages/quality-rails/tests/scaffolder.test.ts
Normal file
57
packages/quality-rails/tests/scaffolder.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
5
packages/quality-rails/tsconfig.json
Normal file
5
packages/quality-rails/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
|
||||
"include": ["src"]
|
||||
}
|
||||
166
pnpm-lock.yaml
generated
166
pnpm-lock.yaml
generated
@@ -21,40 +21,6 @@ importers:
|
||||
specifier: ^5
|
||||
version: 5.9.3
|
||||
|
||||
packages/cli:
|
||||
dependencies:
|
||||
'@mosaic/coord':
|
||||
specifier: workspace:*
|
||||
version: link:../coord
|
||||
'@mosaic/queue':
|
||||
specifier: workspace:*
|
||||
version: link:../queue
|
||||
'@mosaic/types':
|
||||
specifier: workspace:*
|
||||
version: link:../types
|
||||
commander:
|
||||
specifier: ^13
|
||||
version: 13.1.0
|
||||
picocolors:
|
||||
specifier: ^1.1
|
||||
version: 1.1.1
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^22
|
||||
version: 22.19.15
|
||||
'@typescript-eslint/parser':
|
||||
specifier: ^8
|
||||
version: 8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
|
||||
eslint:
|
||||
specifier: ^9
|
||||
version: 9.39.4(jiti@2.6.1)
|
||||
typescript:
|
||||
specifier: ^5
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^2
|
||||
version: 2.1.9(@types/node@22.19.15)
|
||||
|
||||
packages/coord:
|
||||
dependencies:
|
||||
'@mosaic/queue':
|
||||
@@ -117,6 +83,71 @@ importers:
|
||||
specifier: ^2
|
||||
version: 2.1.9(@types/node@22.19.15)
|
||||
|
||||
packages/prdy:
|
||||
dependencies:
|
||||
'@clack/prompts':
|
||||
specifier: ^0.9
|
||||
version: 0.9.1
|
||||
'@mosaic/types':
|
||||
specifier: workspace:*
|
||||
version: link:../types
|
||||
commander:
|
||||
specifier: ^13
|
||||
version: 13.1.0
|
||||
js-yaml:
|
||||
specifier: ^4
|
||||
version: 4.1.1
|
||||
zod:
|
||||
specifier: ^3.24
|
||||
version: 3.25.76
|
||||
devDependencies:
|
||||
'@types/js-yaml':
|
||||
specifier: ^4
|
||||
version: 4.0.9
|
||||
'@types/node':
|
||||
specifier: ^22
|
||||
version: 22.19.15
|
||||
typescript:
|
||||
specifier: ^5
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^2
|
||||
version: 2.1.9(@types/node@22.19.15)
|
||||
|
||||
packages/quality-rails:
|
||||
dependencies:
|
||||
'@mosaic/types':
|
||||
specifier: workspace:*
|
||||
version: link:../types
|
||||
commander:
|
||||
specifier: ^13
|
||||
version: 13.1.0
|
||||
js-yaml:
|
||||
specifier: ^4
|
||||
version: 4.1.1
|
||||
devDependencies:
|
||||
'@types/js-yaml':
|
||||
specifier: ^4
|
||||
version: 4.0.9
|
||||
'@types/node':
|
||||
specifier: ^22
|
||||
version: 22.19.15
|
||||
'@typescript-eslint/eslint-plugin':
|
||||
specifier: ^8
|
||||
version: 8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/parser':
|
||||
specifier: ^8
|
||||
version: 8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
|
||||
eslint:
|
||||
specifier: ^9
|
||||
version: 9.39.4(jiti@2.6.1)
|
||||
typescript:
|
||||
specifier: ^5
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^2
|
||||
version: 2.1.9(@types/node@22.19.15)
|
||||
|
||||
packages/queue:
|
||||
dependencies:
|
||||
'@modelcontextprotocol/sdk':
|
||||
@@ -915,6 +946,14 @@ packages:
|
||||
'@types/node@22.19.15':
|
||||
resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==}
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.56.1':
|
||||
resolution: {integrity: sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
'@typescript-eslint/parser': ^8.56.1
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/parser@8.56.1':
|
||||
resolution: {integrity: sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@@ -938,6 +977,13 @@ packages:
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/type-utils@8.56.1':
|
||||
resolution: {integrity: sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/types@8.56.1':
|
||||
resolution: {integrity: sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@@ -948,6 +994,13 @@ packages:
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/utils@8.56.1':
|
||||
resolution: {integrity: sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: '>=4.8.4 <6.0.0'
|
||||
|
||||
'@typescript-eslint/visitor-keys@8.56.1':
|
||||
resolution: {integrity: sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@@ -1516,6 +1569,10 @@ packages:
|
||||
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
ignore@7.0.5:
|
||||
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
import-fresh@3.3.1:
|
||||
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -2973,6 +3030,22 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
'@typescript-eslint/parser': 8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/scope-manager': 8.56.1
|
||||
'@typescript-eslint/type-utils': 8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/visitor-keys': 8.56.1
|
||||
eslint: 9.39.4(jiti@2.6.1)
|
||||
ignore: 7.0.5
|
||||
natural-compare: 1.4.0
|
||||
ts-api-utils: 2.4.0(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/scope-manager': 8.56.1
|
||||
@@ -3003,6 +3076,18 @@ snapshots:
|
||||
dependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
'@typescript-eslint/type-utils@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.56.1
|
||||
'@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
|
||||
debug: 4.4.3
|
||||
eslint: 9.39.4(jiti@2.6.1)
|
||||
ts-api-utils: 2.4.0(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/types@8.56.1': {}
|
||||
|
||||
'@typescript-eslint/typescript-estree@8.56.1(typescript@5.9.3)':
|
||||
@@ -3020,6 +3105,17 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/utils@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
|
||||
'@typescript-eslint/scope-manager': 8.56.1
|
||||
'@typescript-eslint/types': 8.56.1
|
||||
'@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3)
|
||||
eslint: 9.39.4(jiti@2.6.1)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/visitor-keys@8.56.1':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.56.1
|
||||
@@ -3658,6 +3754,8 @@ snapshots:
|
||||
|
||||
ignore@5.3.2: {}
|
||||
|
||||
ignore@7.0.5: {}
|
||||
|
||||
import-fresh@3.3.1:
|
||||
dependencies:
|
||||
parent-module: 1.0.1
|
||||
|
||||
Reference in New Issue
Block a user