chore: crush git history - reborn from consolidation on 2026-03-10

This commit is contained in:
John Dvorak
2026-03-10 00:00:00 -07:00
commit d278c4b105
313 changed files with 87549 additions and 0 deletions
+610
View File
@@ -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;
}
@@ -0,0 +1,257 @@
/**
* Code rewriter for APOPHIS migrate command.
*
* Responsibilities:
* - Rewrite legacy JS/TS plugin code patterns
* - contract() → verify({ kind: 'contract' })
* - stateful() → qualify({ kind: 'stateful' })
* - scenario() → qualify({ kind: 'scenario' })
* - Handle ambiguous patterns (stop, require manual choice)
* - Preserve code formatting and comments
* - Show surrounding context for ambiguous patterns
*
* Architecture:
* - Dependency injection: all dependencies passed explicitly
* - No optional imports
* - Inline comments for documentation
*/
import { readFileSync, writeFileSync } from 'node:fs';
import type { MigrationItem } from '../index.js';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface CodeRewriteResult {
content: string;
modified: boolean;
itemsRewritten: MigrationItem[];
itemsRemaining: MigrationItem[];
}
export interface AmbiguousCodePattern {
pattern: string;
line: number;
context: string[];
possibleResolutions: string[];
}
// ---------------------------------------------------------------------------
// Legacy code pattern mappings
// ---------------------------------------------------------------------------
/**
* Mapping of deprecated code patterns to their modern equivalents.
*
* Some patterns are marked as ambiguous because the semantic intent
* may not be clear from syntax alone (e.g., contract() could mean
* different things in different contexts).
*/
export const LEGACY_CODE_PATTERNS: Record<
string,
{ replacement: string; ambiguous?: boolean }
> = {
'contract()': { replacement: "verify({ kind: 'contract' })", ambiguous: false },
'stateful()': { replacement: "qualify({ kind: 'stateful' })", ambiguous: false },
'scenario()': { replacement: "qualify({ kind: 'scenario' })", ambiguous: false },
};
/**
* Ambiguous code patterns that require manual choice.
* These patterns could mean different things depending on context.
*/
export const AMBIGUOUS_CODE_PATTERNS: Record<
string,
{ possibleResolutions: string[]; guidance: string }
> = {
'oldApi()': {
possibleResolutions: [
"verify({ kind: 'contract' }) — if this is a contract test",
"qualify({ kind: 'stateful' }) — if this is a stateful test",
"Remove the call — if this is dead code",
],
guidance:
'The oldApi() pattern is ambiguous. It could be a contract test, stateful test, or dead code. Review the surrounding context to determine the correct replacement.',
},
'legacyPlugin()': {
possibleResolutions: [
"app.register(newPlugin()) — if migrating to a new plugin",
"Remove the call — if the plugin is no longer needed",
"// TODO: migrate plugin — if manual migration is required",
],
guidance:
'The legacyPlugin() pattern is ambiguous. Determine if the plugin has a modern equivalent or should be removed.',
},
};
// ---------------------------------------------------------------------------
// Core rewriting logic
// ---------------------------------------------------------------------------
/**
* Rewrite legacy code patterns in a JS/TS file.
*
* Strategy:
* 1. Read the raw file content
* 2. For each legacy pattern, replace occurrences
* 3. Skip ambiguous patterns unless explicitly allowed
* 4. Preserve formatting by only replacing the pattern text
* 5. Track which items were rewritten and which remain
*/
export function rewriteCodePatterns(
filePath: string,
items: MigrationItem[],
allowAmbiguous: boolean = false,
): CodeRewriteResult {
const content = readFileSync(filePath, 'utf-8');
let modifiedContent = content;
let modified = false;
const itemsRewritten: MigrationItem[] = [];
const itemsRemaining: MigrationItem[] = [];
for (const item of items) {
if (item.type !== 'code-pattern') {
itemsRemaining.push(item);
continue;
}
// Skip ambiguous items unless explicitly allowed
if (item.ambiguous && !allowAmbiguous) {
itemsRemaining.push(item);
continue;
}
const legacy = item.legacy;
const replacement = item.replacement;
// Match the exact pattern (e.g., contract())
// Need to escape the parentheses in the pattern
// Note: word boundary \b doesn't work after (), so we use a different approach
const escapedLegacy = escapeRegex(legacy);
const regex = new RegExp(
`(^|[^a-zA-Z0-9_])${escapedLegacy}($|[^a-zA-Z0-9_])`,
'g',
);
const newContent = modifiedContent.replace(
regex,
(match, prefix, suffix) => {
return prefix + replacement + suffix;
},
);
if (newContent !== modifiedContent) {
modifiedContent = newContent;
modified = true;
itemsRewritten.push(item);
} else {
itemsRemaining.push(item);
}
}
return {
content: modifiedContent,
modified,
itemsRewritten,
itemsRemaining,
};
}
/**
* Write the rewritten code file to disk.
*/
export function writeRewrittenCode(filePath: string, content: string): void {
writeFileSync(filePath, content, 'utf-8');
}
/**
* Detect legacy code patterns in raw text content.
* Returns migration items for each occurrence.
*/
export function detectLegacyCodePatterns(
content: string,
filePath: string,
): MigrationItem[] {
const items: MigrationItem[] = [];
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line === undefined) continue;
for (const [legacy, mapping] of Object.entries(LEGACY_CODE_PATTERNS)) {
// Match the pattern as a standalone call
// Escape parentheses in the legacy pattern
// Note: word boundary \b doesn't work after (), so we use a different approach
const escapedLegacy = escapeRegex(legacy);
const regex = new RegExp(
`(^|[^a-zA-Z0-9_])${escapedLegacy}($|[^a-zA-Z0-9_])`,
);
if (regex.test(line)) {
items.push({
type: 'code-pattern',
file: filePath,
line: i + 1,
legacy,
replacement: mapping.replacement,
guidance: `Replace '${legacy}' with '${mapping.replacement}'`,
ambiguous: mapping.ambiguous,
});
}
}
}
return items;
}
/**
* Detect ambiguous code patterns that require manual choice.
* Returns ambiguous patterns with surrounding context for human review.
*/
export function detectAmbiguousCodePatterns(
content: string,
filePath: string,
): MigrationItem[] {
const items: MigrationItem[] = [];
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line === undefined) continue;
for (const [pattern, info] of Object.entries(AMBIGUOUS_CODE_PATTERNS)) {
const escapedPattern = escapeRegex(pattern);
const regex = new RegExp(
`(^|[^a-zA-Z0-9_])${escapedPattern}($|[^a-zA-Z0-9_])`,
);
if (regex.test(line)) {
// Capture surrounding context (2 lines before and after)
const contextStart = Math.max(0, i - 2);
const contextEnd = Math.min(lines.length, i + 3);
const context = lines.slice(contextStart, contextEnd);
items.push({
type: 'code-pattern',
file: filePath,
line: i + 1,
legacy: pattern,
replacement: '(ambiguous — see guidance)',
guidance: `${info.guidance}\nPossible resolutions:\n${info.possibleResolutions.map((r) => ` - ${r}`).join('\n')}\n\nContext:\n${context.map((l, idx) => ` ${contextStart + idx + 1}: ${l}`).join('\n')}`,
ambiguous: true,
});
}
}
}
return items;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
@@ -0,0 +1,268 @@
/**
* Config rewriter for APOPHIS migrate command.
*
* Responsibilities:
* - Rewrite config files, replacing legacy fields with modern equivalents
* - Preserve comments and formatting where feasible
* - Handle nested object rewrites
* - Report what was changed and what remains
* - Detect mixed legacy/modern configs and report clearly
* - Emit human guidance for legacy fields with no direct equivalent
*
* Architecture:
* - Dependency injection: all dependencies passed explicitly
* - No optional imports
* - Inline comments for documentation
*/
import { readFileSync, writeFileSync } from 'node:fs';
import type { MigrationItem } from '../index.js';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface ConfigRewriteResult {
content: string;
modified: boolean;
itemsRewritten: MigrationItem[];
itemsRemaining: MigrationItem[];
}
export interface MixedFieldReport {
legacy: string;
modern: string;
line: number;
guidance: string;
}
// ---------------------------------------------------------------------------
// Legacy field mappings
// ---------------------------------------------------------------------------
/**
* Mapping of deprecated config fields to their modern equivalents.
*/
export const LEGACY_CONFIG_MAPPINGS: Record<string, string> = {
// Top-level fields
testMode: 'mode',
// Profile container
testProfiles: 'profiles',
// Profile fields
usesPreset: 'preset',
routeFilter: 'routes',
// Preset container
testPresets: 'presets',
// Preset fields
testDepth: 'depth',
maxDuration: 'timeout',
// Environment container
envPolicies: 'environments',
// Environment fields
canVerify: 'allowVerify',
};
/**
* Legacy fields with no direct equivalent — emit human guidance instead of auto-rewrite.
*/
export const LEGACY_FIELDS_NO_EQUIVALENT: Record<string, { guidance: string; severity: 'warning' | 'error' }> = {
legacyField: {
guidance: 'This field has no modern equivalent. Remove it and review your config manually.',
severity: 'warning',
},
oldApiVersion: {
guidance: 'API versioning is now handled via profiles. Remove this field and set version in each profile.',
severity: 'warning',
},
deprecatedPlugin: {
guidance: 'This plugin is no longer supported. Remove the field and migrate to the new plugin system.',
severity: 'error',
},
};
// ---------------------------------------------------------------------------
// Core rewriting logic
// ---------------------------------------------------------------------------
/**
* Rewrite a config file, replacing legacy field names with modern equivalents.
*
* Strategy:
* 1. Read the raw file content
* 2. For each legacy field mapping, replace occurrences as property keys
* 3. Preserve formatting by only replacing the key name, not surrounding whitespace
* 4. Track which items were rewritten and which remain
*/
export function rewriteConfigFile(
filePath: string,
items: MigrationItem[],
): ConfigRewriteResult {
const content = readFileSync(filePath, 'utf-8');
let modifiedContent = content;
let modified = false;
const itemsRewritten: MigrationItem[] = [];
const itemsRemaining: MigrationItem[] = [];
for (const item of items) {
if (item.type !== 'config-field') {
itemsRemaining.push(item);
continue;
}
// The legacy field name (might be a nested path like "testProfiles.quick")
const legacyKey = item.legacy.split('.').pop() || item.legacy;
const replacement = item.replacement;
// Build a regex that matches the field as a property key
// This handles: key:, "key":, 'key':, key :, etc.
const regex = new RegExp(
`([\\s{,\\[])(['"]?)(${escapeRegex(legacyKey)})\\2\\s*:(?!\\/)`,
'g',
);
const newContent = modifiedContent.replace(regex, (match, prefix, quote, _key) => {
return `${prefix}${quote}${replacement}${quote}:`;
});
if (newContent !== modifiedContent) {
modifiedContent = newContent;
modified = true;
itemsRewritten.push(item);
} else {
itemsRemaining.push(item);
}
}
return {
content: modifiedContent,
modified,
itemsRewritten,
itemsRemaining,
};
}
/**
* Write the rewritten config to disk.
*/
export function writeRewrittenConfig(filePath: string, content: string): void {
writeFileSync(filePath, content, 'utf-8');
}
/**
* Detect legacy config fields in raw text content.
* Returns migration items for each occurrence.
*/
export function detectLegacyConfigFields(
content: string,
filePath: string,
): MigrationItem[] {
const items: MigrationItem[] = [];
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line === undefined) continue;
for (const [legacy, replacement] of Object.entries(LEGACY_CONFIG_MAPPINGS)) {
// Match the field as a property key, avoiding matches inside strings/comments
const regex = new RegExp(`\\b${escapeRegex(legacy)}\\s*:`);
if (regex.test(line)) {
items.push({
type: 'config-field',
file: filePath,
line: i + 1,
legacy,
replacement,
guidance: `Replace '${legacy}' with '${replacement}'`,
});
}
}
}
return items;
}
/**
* Detect legacy fields that have no direct modern equivalent.
* These emit human guidance instead of being auto-rewritten.
*/
export function detectLegacyFieldsNoEquivalent(
content: string,
filePath: string,
): MigrationItem[] {
const items: MigrationItem[] = [];
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line === undefined) continue;
for (const [legacy, info] of Object.entries(LEGACY_FIELDS_NO_EQUIVALENT)) {
const regex = new RegExp(`\\b${escapeRegex(legacy)}\\s*:`);
if (regex.test(line)) {
items.push({
type: 'config-field',
file: filePath,
line: i + 1,
legacy,
replacement: '(removed — see guidance)',
guidance: info.guidance,
});
}
}
}
return items;
}
/**
* Detect mixed legacy and modern config fields.
* When both legacy and modern versions of the same field exist, report each clearly.
*/
export function detectMixedLegacyModernFields(
content: string,
filePath: string,
): MixedFieldReport[] {
const reports: MixedFieldReport[] = [];
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line === undefined) continue;
for (const [legacy, modern] of Object.entries(LEGACY_CONFIG_MAPPINGS)) {
// Check if this line contains the legacy field
const legacyRegex = new RegExp(`\\b${escapeRegex(legacy)}\\s*:`);
if (legacyRegex.test(line)) {
// Check if the modern equivalent also exists somewhere in the file
const modernRegex = new RegExp(`\\b${escapeRegex(modern)}\\s*:`);
if (modernRegex.test(content)) {
reports.push({
legacy,
modern,
line: i + 1,
guidance: `Both '${legacy}' (legacy) and '${modern}' (modern) found. Remove '${legacy}' to avoid conflicts.`,
});
}
}
}
}
return reports;
}
@@ -0,0 +1,216 @@
/**
* Route rewriter for APOPHIS migrate command.
*
* Responsibilities:
* - Rewrite route schema annotations (e.g., x-validate-runtime → runtime)
* - Preserve schema structure and formatting
* - Handle annotations in Fastify route definitions
* - Detect ambiguous annotations and require manual choice
*
* Architecture:
* - Dependency injection: all dependencies passed explicitly
* - No optional imports
* - Inline comments for documentation
*/
import { readFileSync, writeFileSync } from 'node:fs';
import type { MigrationItem } from '../index.js';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface RouteRewriteResult {
content: string;
modified: boolean;
itemsRewritten: MigrationItem[];
itemsRemaining: MigrationItem[];
}
export interface AmbiguousRoutePattern {
pattern: string;
line: number;
context: string[];
possibleResolutions: string[];
}
// ---------------------------------------------------------------------------
// Legacy annotation mappings
// ---------------------------------------------------------------------------
/**
* Mapping of deprecated route schema annotations to their modern equivalents.
*/
export const LEGACY_ROUTE_ANNOTATIONS: Record<string, string> = {
'x-validate-runtime': 'runtime',
};
/**
* Ambiguous route patterns that require manual choice.
* These patterns could mean different things depending on context.
*/
export const AMBIGUOUS_ROUTE_PATTERNS: Record<string, { possibleResolutions: string[]; guidance: string }> = {
'x-validate': {
possibleResolutions: [
"'runtime' — validate at runtime",
"'build' — validate at build time",
"'both' — validate at both times",
],
guidance: 'The x-validate annotation is ambiguous. Choose the validation timing explicitly.',
},
'x-check': {
possibleResolutions: [
"'runtime' — runtime check",
"'contract' — contract check",
"'schema' — schema-only check",
],
guidance: 'The x-check annotation is ambiguous. Choose the check type explicitly.',
},
};
// ---------------------------------------------------------------------------
// Core rewriting logic
// ---------------------------------------------------------------------------
/**
* Rewrite route annotations in a file.
*
* Strategy:
* 1. Read the raw file content
* 2. For each legacy annotation, replace occurrences in string literals
* 3. Preserve formatting by only replacing the annotation name
* 4. Track which items were rewritten and which remain
*/
export function rewriteRouteAnnotations(
filePath: string,
items: MigrationItem[],
): RouteRewriteResult {
const content = readFileSync(filePath, 'utf-8');
let modifiedContent = content;
let modified = false;
const itemsRewritten: MigrationItem[] = [];
const itemsRemaining: MigrationItem[] = [];
for (const item of items) {
if (item.type !== 'route-annotation') {
itemsRemaining.push(item);
continue;
}
const legacy = item.legacy;
const replacement = item.replacement;
// Match the annotation in string literals (single or double quotes)
// The legacy string might have hyphens, so we need to be careful with word boundaries
const regex = new RegExp(
`(['"])${escapeRegex(legacy)}(['"])`,
'g',
);
const newContent = modifiedContent.replace(regex, `$1${replacement}$2`);
if (newContent !== modifiedContent) {
modifiedContent = newContent;
modified = true;
itemsRewritten.push(item);
} else {
itemsRemaining.push(item);
}
}
return {
content: modifiedContent,
modified,
itemsRewritten,
itemsRemaining,
};
}
/**
* Write the rewritten route file to disk.
*/
export function writeRewrittenRoutes(filePath: string, content: string): void {
writeFileSync(filePath, content, 'utf-8');
}
/**
* Detect legacy route annotations in raw text content.
* Returns migration items for each occurrence.
*/
export function detectLegacyRouteAnnotations(
content: string,
filePath: string,
): MigrationItem[] {
const items: MigrationItem[] = [];
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line === undefined) continue;
for (const [legacy, replacement] of Object.entries(LEGACY_ROUTE_ANNOTATIONS)) {
// Match the annotation in string literals
const regex = new RegExp(`['"]${escapeRegex(legacy)}['"]`);
if (regex.test(line)) {
items.push({
type: 'route-annotation',
file: filePath,
line: i + 1,
legacy,
replacement,
guidance: `Replace '${legacy}' with '${replacement}' in route schema`,
});
}
}
}
return items;
}
/**
* Detect ambiguous route patterns that require manual choice.
* Returns ambiguous patterns with surrounding context for human review.
*/
export function detectAmbiguousRoutePatterns(
content: string,
filePath: string,
): MigrationItem[] {
const items: MigrationItem[] = [];
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line === undefined) continue;
for (const [pattern, info] of Object.entries(AMBIGUOUS_ROUTE_PATTERNS)) {
const regex = new RegExp(`['"]${escapeRegex(pattern)}['"]`);
if (regex.test(line)) {
// Capture surrounding context (2 lines before and after)
const contextStart = Math.max(0, i - 2);
const contextEnd = Math.min(lines.length, i + 3);
const context = lines.slice(contextStart, contextEnd);
items.push({
type: 'route-annotation',
file: filePath,
line: i + 1,
legacy: pattern,
replacement: '(ambiguous — see guidance)',
guidance: `${info.guidance}\nPossible resolutions:\n${info.possibleResolutions.map(r => ` - ${r}`).join('\n')}`,
ambiguous: true,
});
}
}
}
return items;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$\u0026');
}