chore: crush git history - reborn from consolidation on 2026-03-10
This commit is contained in:
@@ -0,0 +1,610 @@
|
||||
/**
|
||||
* S9: Migrate thread - Config migration command
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Detect legacy config patterns and deprecated API usage
|
||||
* - Support --check (detect only, don't write)
|
||||
* - Support --dry-run (show rewrites without writing) - DEFAULT
|
||||
* - Support --write (perform rewrites)
|
||||
* - Map legacy fields to new fields with exact replacements
|
||||
* - Preserve comments/formatting where feasible
|
||||
* - Handle ambiguous rewrites (stop, require manual choice)
|
||||
* - Report completed and remaining items separately
|
||||
* - Exit 0 if nothing to migrate, 2 if issues found, 1 if --write performed
|
||||
* - Mixed legacy/modern config detection with clear reporting
|
||||
* - Exact dry-run output with file path, line number, legacy text, replacement text
|
||||
* - Ambiguous rewrite handling with surrounding context and possible resolutions
|
||||
* - Safe by default: dry-run is the default mode
|
||||
*
|
||||
* Architecture:
|
||||
* - Dependency injection: all dependencies passed explicitly
|
||||
* - No optional imports — everything is required or injected
|
||||
* - Inline comments for documentation
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import type { CliContext } from '../../core/context.js';
|
||||
import { loadConfig, discoverConfig } from '../../core/config-loader.js';
|
||||
import { SUCCESS, USAGE_ERROR, BEHAVIORAL_FAILURE } from '../../core/exit-codes.js';
|
||||
import type { CommandResult } from '../../core/types.js';
|
||||
import {
|
||||
rewriteConfigFile,
|
||||
detectLegacyConfigFields,
|
||||
detectLegacyFieldsNoEquivalent,
|
||||
detectMixedLegacyModernFields,
|
||||
} from './rewriters/config-rewriter.js';
|
||||
import {
|
||||
rewriteRouteAnnotations,
|
||||
detectLegacyRouteAnnotations,
|
||||
detectAmbiguousRoutePatterns,
|
||||
} from './rewriters/route-rewriter.js';
|
||||
import {
|
||||
rewriteCodePatterns,
|
||||
detectLegacyCodePatterns,
|
||||
detectAmbiguousCodePatterns,
|
||||
} from './rewriters/code-rewriter.js';
|
||||
import { renderJson } from '../../renderers/json.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface MigrateOptions {
|
||||
check?: boolean;
|
||||
dryRun?: boolean;
|
||||
write?: boolean;
|
||||
config?: string;
|
||||
cwd?: string;
|
||||
format?: 'human' | 'json' | 'ndjson';
|
||||
quiet?: boolean;
|
||||
verbose?: boolean;
|
||||
}
|
||||
|
||||
export interface MigrationItem {
|
||||
type: 'config-field' | 'route-annotation' | 'code-pattern';
|
||||
file: string;
|
||||
line?: number;
|
||||
legacy: string;
|
||||
replacement: string;
|
||||
guidance?: string;
|
||||
ambiguous?: boolean;
|
||||
}
|
||||
|
||||
export interface MigrationResult {
|
||||
exitCode: number;
|
||||
items: MigrationItem[];
|
||||
completed: MigrationItem[];
|
||||
remaining: MigrationItem[];
|
||||
message?: string;
|
||||
filesModified?: string[];
|
||||
filesWouldBeModified?: string[];
|
||||
totalRewrites?: number;
|
||||
manualChoicesRequired?: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File discovery
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Discover files that may contain legacy patterns.
|
||||
* Scans the working directory for config files, route files, and code files.
|
||||
*/
|
||||
export async function discoverMigrationFiles(
|
||||
cwd: string,
|
||||
configPath?: string,
|
||||
): Promise<{ configFile: string | null; appFiles: string[] }> {
|
||||
const configFile = configPath
|
||||
? resolve(cwd, configPath)
|
||||
: discoverConfig(cwd);
|
||||
|
||||
const appFiles: string[] = [];
|
||||
const candidates = [
|
||||
'app.js',
|
||||
'app.ts',
|
||||
'src/app.js',
|
||||
'src/app.ts',
|
||||
'routes.js',
|
||||
'routes.ts',
|
||||
'src/routes.js',
|
||||
'src/routes.ts',
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const fullPath = resolve(cwd, candidate);
|
||||
if (existsSync(fullPath)) {
|
||||
appFiles.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return { configFile, appFiles };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pattern detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Detect all legacy patterns in a set of files.
|
||||
* Includes: legacy fields, route annotations, code patterns,
|
||||
* ambiguous patterns, fields with no equivalent, and mixed legacy/modern fields.
|
||||
*/
|
||||
export async function detectAllLegacyPatterns(
|
||||
configFile: string | null,
|
||||
appFiles: string[],
|
||||
): Promise<MigrationItem[]> {
|
||||
const items: MigrationItem[] = [];
|
||||
|
||||
// Detect config fields
|
||||
if (configFile && existsSync(configFile)) {
|
||||
const configContent = readFileSync(configFile, 'utf-8');
|
||||
items.push(...detectLegacyConfigFields(configContent, configFile));
|
||||
items.push(...detectLegacyFieldsNoEquivalent(configContent, configFile));
|
||||
items.push(...detectLegacyRouteAnnotations(configContent, configFile));
|
||||
items.push(...detectAmbiguousRoutePatterns(configContent, configFile));
|
||||
items.push(...detectLegacyCodePatterns(configContent, configFile));
|
||||
items.push(...detectAmbiguousCodePatterns(configContent, configFile));
|
||||
}
|
||||
|
||||
// Detect patterns in app files
|
||||
for (const appFile of appFiles) {
|
||||
const content = readFileSync(appFile, 'utf-8');
|
||||
items.push(...detectLegacyRouteAnnotations(content, appFile));
|
||||
items.push(...detectAmbiguousRoutePatterns(content, appFile));
|
||||
items.push(...detectLegacyCodePatterns(content, appFile));
|
||||
items.push(...detectAmbiguousCodePatterns(content, appFile));
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Migration execution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Run the migration process.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Discover config files in the working directory
|
||||
* 2. Detect legacy patterns in all relevant files
|
||||
* 3. If --check, report findings and exit
|
||||
* 4. If --dry-run (default), show exact rewrites without writing
|
||||
* 5. If --write, perform rewrites
|
||||
* 6. Report completed and remaining items separately
|
||||
* 7. Return appropriate exit code
|
||||
*
|
||||
* Safety: dry-run is the default mode. No files are modified unless --write is explicitly passed.
|
||||
*/
|
||||
export async function migrateCommand(
|
||||
options: MigrateOptions,
|
||||
ctx: CliContext,
|
||||
): Promise<MigrationResult> {
|
||||
const { check, dryRun, write, config: configPath, cwd } = options;
|
||||
const workingDir = cwd || ctx.cwd;
|
||||
|
||||
// Determine mode: check < dry-run < write
|
||||
// Default is dry-run (safe by default)
|
||||
const mode = write ? 'write' : check ? 'check' : 'dry-run';
|
||||
|
||||
try {
|
||||
// 1. Discover files
|
||||
const { configFile, appFiles } = await discoverMigrationFiles(
|
||||
workingDir,
|
||||
configPath,
|
||||
);
|
||||
|
||||
if (!configFile && appFiles.length === 0) {
|
||||
return {
|
||||
exitCode: USAGE_ERROR,
|
||||
items: [],
|
||||
completed: [],
|
||||
remaining: [],
|
||||
message: 'No config or app files found. Run "apophis init" to create one.',
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Detect legacy patterns
|
||||
const allItems = await detectAllLegacyPatterns(configFile, appFiles);
|
||||
|
||||
// 3. If no legacy patterns found, report success
|
||||
if (allItems.length === 0) {
|
||||
return {
|
||||
exitCode: SUCCESS,
|
||||
items: [],
|
||||
completed: [],
|
||||
remaining: [],
|
||||
message: 'No legacy patterns detected. Config is up to date.',
|
||||
};
|
||||
}
|
||||
|
||||
// Separate ambiguous items
|
||||
const ambiguousItems = allItems.filter((item) => item.ambiguous);
|
||||
const unambiguousItems = allItems.filter((item) => !item.ambiguous);
|
||||
|
||||
// Calculate files that would be modified
|
||||
const filesWouldBeModified = new Set<string>();
|
||||
for (const item of allItems) {
|
||||
filesWouldBeModified.add(item.file);
|
||||
}
|
||||
|
||||
// If ambiguous items exist and we're writing, stop and require manual choice
|
||||
if (ambiguousItems.length > 0 && mode === 'write') {
|
||||
return {
|
||||
exitCode: USAGE_ERROR,
|
||||
items: allItems,
|
||||
completed: [],
|
||||
remaining: ambiguousItems,
|
||||
message: formatAmbiguousOutput(ambiguousItems),
|
||||
filesWouldBeModified: Array.from(filesWouldBeModified),
|
||||
totalRewrites: allItems.length,
|
||||
manualChoicesRequired: ambiguousItems.length,
|
||||
};
|
||||
}
|
||||
|
||||
// 4. Check mode: detect only
|
||||
if (mode === 'check') {
|
||||
return {
|
||||
exitCode: BEHAVIORAL_FAILURE,
|
||||
items: allItems,
|
||||
completed: [],
|
||||
remaining: allItems,
|
||||
message: formatCheckOutput(allItems),
|
||||
filesWouldBeModified: Array.from(filesWouldBeModified),
|
||||
totalRewrites: allItems.length,
|
||||
manualChoicesRequired: ambiguousItems.length,
|
||||
};
|
||||
}
|
||||
|
||||
// 5. Dry-run mode: show exact rewrites without writing
|
||||
if (mode === 'dry-run') {
|
||||
return {
|
||||
exitCode: BEHAVIORAL_FAILURE,
|
||||
items: allItems,
|
||||
completed: [],
|
||||
remaining: allItems,
|
||||
message: formatDryRunOutput(allItems),
|
||||
filesWouldBeModified: Array.from(filesWouldBeModified),
|
||||
totalRewrites: allItems.length,
|
||||
manualChoicesRequired: ambiguousItems.length,
|
||||
};
|
||||
}
|
||||
|
||||
// 6. Write mode: perform rewrites
|
||||
const filesModified: string[] = [];
|
||||
const completed: MigrationItem[] = [];
|
||||
const remaining: MigrationItem[] = [];
|
||||
|
||||
// Rewrite config file
|
||||
if (configFile && existsSync(configFile)) {
|
||||
const configItems = unambiguousItems.filter(
|
||||
(item) => item.file === configFile && item.type === 'config-field',
|
||||
);
|
||||
|
||||
if (configItems.length > 0) {
|
||||
const result = rewriteConfigFile(configFile, configItems);
|
||||
if (result.modified) {
|
||||
writeFileSync(configFile, result.content, 'utf-8');
|
||||
filesModified.push(configFile);
|
||||
completed.push(...result.itemsRewritten);
|
||||
remaining.push(...result.itemsRemaining);
|
||||
} else {
|
||||
remaining.push(...configItems);
|
||||
}
|
||||
}
|
||||
|
||||
// Route annotations in config file
|
||||
const routeItems = unambiguousItems.filter(
|
||||
(item) => item.file === configFile && item.type === 'route-annotation',
|
||||
);
|
||||
|
||||
if (routeItems.length > 0) {
|
||||
const result = rewriteRouteAnnotations(configFile, routeItems);
|
||||
if (result.modified) {
|
||||
writeFileSync(configFile, result.content, 'utf-8');
|
||||
if (!filesModified.includes(configFile)) {
|
||||
filesModified.push(configFile);
|
||||
}
|
||||
completed.push(...result.itemsRewritten);
|
||||
remaining.push(...result.itemsRemaining);
|
||||
} else {
|
||||
remaining.push(...routeItems);
|
||||
}
|
||||
}
|
||||
|
||||
// Code patterns in config file
|
||||
const codeItems = unambiguousItems.filter(
|
||||
(item) => item.file === configFile && item.type === 'code-pattern',
|
||||
);
|
||||
|
||||
if (codeItems.length > 0) {
|
||||
const result = rewriteCodePatterns(configFile, codeItems);
|
||||
if (result.modified) {
|
||||
writeFileSync(configFile, result.content, 'utf-8');
|
||||
if (!filesModified.includes(configFile)) {
|
||||
filesModified.push(configFile);
|
||||
}
|
||||
completed.push(...result.itemsRewritten);
|
||||
remaining.push(...result.itemsRemaining);
|
||||
} else {
|
||||
remaining.push(...codeItems);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rewrite app files
|
||||
for (const appFile of appFiles) {
|
||||
const fileItems = unambiguousItems.filter((item) => item.file === appFile);
|
||||
|
||||
const routeItems = fileItems.filter(
|
||||
(item) => item.type === 'route-annotation',
|
||||
);
|
||||
const codeItems = fileItems.filter(
|
||||
(item) => item.type === 'code-pattern',
|
||||
);
|
||||
|
||||
let fileModified = false;
|
||||
let currentContent = readFileSync(appFile, 'utf-8');
|
||||
|
||||
if (routeItems.length > 0) {
|
||||
const result = rewriteRouteAnnotations(appFile, routeItems);
|
||||
if (result.modified) {
|
||||
currentContent = result.content;
|
||||
fileModified = true;
|
||||
completed.push(...result.itemsRewritten);
|
||||
remaining.push(...result.itemsRemaining);
|
||||
} else {
|
||||
remaining.push(...routeItems);
|
||||
}
|
||||
}
|
||||
|
||||
if (codeItems.length > 0) {
|
||||
const result = rewriteCodePatterns(appFile, codeItems);
|
||||
if (result.modified) {
|
||||
currentContent = result.content;
|
||||
fileModified = true;
|
||||
completed.push(...result.itemsRewritten);
|
||||
remaining.push(...result.itemsRemaining);
|
||||
} else {
|
||||
remaining.push(...codeItems);
|
||||
}
|
||||
}
|
||||
|
||||
if (fileModified) {
|
||||
writeFileSync(appFile, currentContent, 'utf-8');
|
||||
filesModified.push(appFile);
|
||||
}
|
||||
}
|
||||
|
||||
// Ambiguous items always remain
|
||||
remaining.push(...ambiguousItems);
|
||||
|
||||
return {
|
||||
exitCode: completed.length > 0 ? BEHAVIORAL_FAILURE : SUCCESS,
|
||||
items: allItems,
|
||||
completed,
|
||||
remaining,
|
||||
message: formatWriteOutput(completed, remaining),
|
||||
filesModified,
|
||||
filesWouldBeModified: Array.from(filesWouldBeModified),
|
||||
totalRewrites: allItems.length,
|
||||
manualChoicesRequired: ambiguousItems.length,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
exitCode: USAGE_ERROR,
|
||||
items: [],
|
||||
completed: [],
|
||||
remaining: [],
|
||||
message: `Migration failed: ${message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Output formatting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatCheckOutput(items: MigrationItem[]): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push('Legacy config patterns detected:');
|
||||
lines.push('');
|
||||
|
||||
for (const item of items) {
|
||||
const location = item.line ? `${item.file}:${item.line}` : item.file;
|
||||
lines.push(` ${location}`);
|
||||
lines.push(` Legacy: ${item.legacy}`);
|
||||
lines.push(` Replace: ${item.replacement}`);
|
||||
if (item.guidance) {
|
||||
lines.push(` Guidance: ${item.guidance}`);
|
||||
}
|
||||
if (item.ambiguous) {
|
||||
lines.push(` ⚠ Ambiguous — requires manual choice`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
lines.push(`Found ${items.length} item(s) to migrate.`);
|
||||
lines.push('');
|
||||
lines.push('Run "apophis migrate --dry-run" to preview rewrites.');
|
||||
lines.push('Run "apophis migrate --write" to apply rewrites.');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function formatDryRunOutput(items: MigrationItem[]): string {
|
||||
const lines: string[] = [];
|
||||
const files = new Set<string>();
|
||||
const ambiguousCount = items.filter((item) => item.ambiguous).length;
|
||||
|
||||
lines.push('Dry run — the following rewrites would be applied:');
|
||||
lines.push('');
|
||||
|
||||
for (const item of items) {
|
||||
const location = item.line ? `${item.file}:${item.line}` : item.file;
|
||||
files.add(item.file);
|
||||
lines.push(` ${location}`);
|
||||
lines.push(` - ${item.legacy}`);
|
||||
lines.push(` + ${item.replacement}`);
|
||||
if (item.guidance) {
|
||||
lines.push(` # ${item.guidance}`);
|
||||
}
|
||||
if (item.ambiguous) {
|
||||
lines.push(` ⚠ Skipped (ambiguous — requires manual choice)`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
lines.push(`Total: ${items.length} item(s) to migrate.`);
|
||||
lines.push(`Files that would be modified: ${files.size}`);
|
||||
if (ambiguousCount > 0) {
|
||||
lines.push(`Items requiring manual choice: ${ambiguousCount}`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('Run "apophis migrate --write" to apply these rewrites.');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function formatWriteOutput(
|
||||
completed: MigrationItem[],
|
||||
remaining: MigrationItem[],
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push('Migration complete:');
|
||||
lines.push('');
|
||||
|
||||
if (completed.length > 0) {
|
||||
lines.push(` Completed (${completed.length}):`);
|
||||
for (const item of completed) {
|
||||
const location = item.line
|
||||
? `${item.file}:${item.line}`
|
||||
: item.file;
|
||||
lines.push(
|
||||
` ✓ ${location} — ${item.legacy} → ${item.replacement}`,
|
||||
);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (remaining.length > 0) {
|
||||
lines.push(` Remaining (${remaining.length}):`);
|
||||
for (const item of remaining) {
|
||||
const location = item.line
|
||||
? `${item.file}:${item.line}`
|
||||
: item.file;
|
||||
lines.push(` - ${location} — ${item.legacy}`);
|
||||
if (item.ambiguous) {
|
||||
lines.push(` ⚠ Ambiguous — requires manual choice`);
|
||||
} else if (item.guidance) {
|
||||
lines.push(` # ${item.guidance}`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (remaining.length === 0) {
|
||||
lines.push('All items migrated successfully.');
|
||||
} else {
|
||||
lines.push(`Run "apophis migrate --check" to review remaining items.`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function formatAmbiguousOutput(items: MigrationItem[]): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push('Ambiguous rewrites detected — migration stopped:');
|
||||
lines.push('');
|
||||
|
||||
for (const item of items) {
|
||||
const location = item.line ? `${item.file}:${item.line}` : item.file;
|
||||
lines.push(` ${location}`);
|
||||
lines.push(` ${item.legacy}`);
|
||||
lines.push(` ⚠ This pattern is ambiguous and requires manual choice.`);
|
||||
if (item.guidance) {
|
||||
lines.push(` Consider: ${item.guidance}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
lines.push('Please resolve these items manually, then re-run migrate.');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI adapter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Adapter that bridges the CLI framework (cac) to the migrate command handler.
|
||||
* This function signature matches what the CLI core expects.
|
||||
*
|
||||
* Safety: dry-run is the default mode. No files are modified unless --write is explicitly passed.
|
||||
*/
|
||||
export async function handleMigrate(
|
||||
_args: string[],
|
||||
ctx: CliContext,
|
||||
): Promise<number> {
|
||||
const options: MigrateOptions = {
|
||||
config: ctx.options.config || undefined,
|
||||
cwd: ctx.cwd,
|
||||
format: ctx.options.format as MigrateOptions['format'],
|
||||
quiet: ctx.options.quiet,
|
||||
verbose: ctx.options.verbose,
|
||||
};
|
||||
|
||||
// Parse command-specific flags from process.argv
|
||||
const argv = process.argv.slice(2);
|
||||
if (argv.includes('--check')) {
|
||||
options.check = true;
|
||||
}
|
||||
if (argv.includes('--dry-run')) {
|
||||
options.dryRun = true;
|
||||
}
|
||||
if (argv.includes('--write')) {
|
||||
options.write = true;
|
||||
}
|
||||
|
||||
const result = await migrateCommand(options, ctx);
|
||||
|
||||
// Output result based on format
|
||||
if (!ctx.options.quiet && result.message) {
|
||||
const format = options.format || ctx.options.format || 'human';
|
||||
if (format === 'json') {
|
||||
console.log(renderJson({
|
||||
exitCode: result.exitCode,
|
||||
items: result.items,
|
||||
completed: result.completed,
|
||||
remaining: result.remaining,
|
||||
filesModified: result.filesModified,
|
||||
filesWouldBeModified: result.filesWouldBeModified,
|
||||
totalRewrites: result.totalRewrites,
|
||||
manualChoicesRequired: result.manualChoicesRequired,
|
||||
}));
|
||||
} else if (format === 'ndjson') {
|
||||
process.stdout.write(JSON.stringify({
|
||||
type: 'run.completed',
|
||||
command: 'migrate',
|
||||
exitCode: result.exitCode,
|
||||
items: result.items,
|
||||
completed: result.completed,
|
||||
remaining: result.remaining,
|
||||
filesModified: result.filesModified,
|
||||
filesWouldBeModified: result.filesWouldBeModified,
|
||||
totalRewrites: result.totalRewrites,
|
||||
manualChoicesRequired: result.manualChoicesRequired,
|
||||
}) + '\n');
|
||||
} else {
|
||||
console.log(result.message);
|
||||
}
|
||||
}
|
||||
|
||||
return result.exitCode;
|
||||
}
|
||||
Reference in New Issue
Block a user