1 Commits

Author SHA1 Message Date
0193861784 feat(wave3): add @mosaic/cli — unified mosaic CLI entry point
- Root Commander program with version + description
- Subcommand groups: coord, prdy, queue, quality-rails
- Single `mosaic` binary entry point
- Depends on all @mosaic/* workspace packages
2026-03-06 20:22:37 -06:00
23 changed files with 0 additions and 1572 deletions

View File

@@ -1,29 +0,0 @@
{
"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"
}
}

View File

@@ -1,103 +0,0 @@
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);
}

View File

@@ -1,20 +0,0 @@
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';

View File

@@ -1,199 +0,0 @@
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;
}

View File

@@ -1,86 +0,0 @@
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;
}

View File

@@ -1,38 +0,0 @@
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;
}

View File

@@ -1,121 +0,0 @@
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;
}

View File

@@ -1,36 +0,0 @@
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 });
}
});
});

View File

@@ -1,22 +0,0 @@
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);
}
}
});
});

View File

@@ -1,5 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
"include": ["src"]
}

View File

@@ -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',
},
},
];

View File

@@ -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"
}
}

View File

@@ -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);
});
}

View File

@@ -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';
}

View File

@@ -1,5 +0,0 @@
export * from './cli.js';
export * from './detect.js';
export * from './scaffolder.js';
export * from './templates.js';
export * from './types.js';

View File

@@ -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;
}

View File

@@ -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');
}

View File

@@ -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[];
}

View File

@@ -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');
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -1,5 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
"include": ["src"]
}

132
pnpm-lock.yaml generated
View File

@@ -117,71 +117,6 @@ 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':
@@ -980,14 +915,6 @@ 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}
@@ -1011,13 +938,6 @@ 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}
@@ -1028,13 +948,6 @@ 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}
@@ -1603,10 +1516,6 @@ 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'}
@@ -3064,22 +2973,6 @@ 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
@@ -3110,18 +3003,6 @@ 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)':
@@ -3139,17 +3020,6 @@ 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
@@ -3788,8 +3658,6 @@ snapshots:
ignore@5.3.2: {}
ignore@7.0.5: {}
import-fresh@3.3.1:
dependencies:
parent-module: 1.0.1