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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
Reference in New Issue
Block a user