chore: remove legacy config rewriting, dead code branches, and unused public exports

This commit is contained in:
John Dvorak
2026-05-22 12:47:40 -07:00
parent 6e4656add5
commit edc2989900
13 changed files with 35 additions and 1055 deletions
-4
View File
@@ -36,10 +36,6 @@
"./extensions/*": {
"import": "./dist/extensions/*.js",
"types": "./dist/extensions/*.d.ts"
},
"./quality/*": {
"import": "./dist/quality/*.js",
"types": "./dist/quality/*.d.ts"
}
},
"files": [
+6 -198
View File
@@ -10,13 +10,11 @@
import {
loadConfig,
loadConfigFile,
discoverConfig,
ConfigValidationError,
type Config,
type LoadConfigResult,
} from '../../../core/config-loader.js';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
@@ -35,93 +33,10 @@ export interface ConfigCheckOptions {
configPath?: string;
}
// ---------------------------------------------------------------------------
// Legacy field detection
// ---------------------------------------------------------------------------
/**
* Map of deprecated field names to their modern equivalents.
* Legacy config detection removed — always passes.
*/
const LEGACY_FIELDS: Record<string, string> = {
testMode: 'mode',
testProfiles: 'profiles',
testPresets: 'presets',
envPolicies: 'environments',
usesPreset: 'preset',
routeFilter: 'routes',
testDepth: 'depth',
maxDuration: 'timeout',
canVerify: 'allowVerify',
};
/**
* Recursively scan an object for legacy field names.
* Returns array of { path, legacyKey, modernKey } tuples.
*/
function findLegacyFields(
value: unknown,
path: string = '',
): Array<{ path: string; legacyKey: string; modernKey: string }> {
const results: Array<{ path: string; legacyKey: string; modernKey: string }> = [];
if (value === null || typeof value !== 'object') {
return results;
}
const obj = value as Record<string, unknown>;
for (const key of Object.keys(obj)) {
const currentPath = path ? `${path}.${key}` : key;
// Check if this key is legacy
if (LEGACY_FIELDS[key]) {
results.push({
path: currentPath,
legacyKey: key,
modernKey: LEGACY_FIELDS[key],
});
}
// Recurse into nested objects
const fieldValue = obj[key];
if (fieldValue !== null && typeof fieldValue === 'object' && !Array.isArray(fieldValue)) {
results.push(...findLegacyFields(fieldValue, currentPath));
}
}
return results;
}
/**
* Check if config contains legacy field names.
*/
export function checkLegacyConfig(config: Config | null): ConfigCheckResult {
if (!config) {
return {
name: 'legacy-config',
status: 'pass',
message: 'No config to check for legacy fields.',
mode: 'all',
};
}
const legacyFields = findLegacyFields(config);
if (legacyFields.length > 0) {
const details = legacyFields
.map(f => ` ${f.path}: "${f.legacyKey}" → "${f.modernKey}"`)
.join('\n');
return {
name: 'legacy-config',
status: 'warn',
message: `Found ${legacyFields.length} legacy field(s) in config.`,
detail: `Run "apophis migrate" to update these fields:\n${details}`,
remediation: 'Run "apophis migrate --dry-run" to preview rewrites.',
mode: 'all',
};
}
export function checkLegacyConfig(_config: Config | null): ConfigCheckResult {
return {
name: 'legacy-config',
status: 'pass',
@@ -130,73 +45,7 @@ export function checkLegacyConfig(config: Config | null): ConfigCheckResult {
};
}
/**
* Check for mixed legacy and new config styles.
* This happens when some fields use old names and others use new names.
*/
export function checkMixedConfig(config: Config | null): ConfigCheckResult {
if (!config) {
return {
name: 'mixed-config',
status: 'pass',
message: 'No config to check for mixed styles.',
mode: 'all',
};
}
const legacyFields = findLegacyFields(config);
const hasLegacy = legacyFields.length > 0;
// Check if config also has modern fields at the same level as legacy ones
const hasModern = Object.keys(config).some(key => !LEGACY_FIELDS[key] && key !== 'name');
if (hasLegacy && hasModern) {
const legacyTopLevel = Object.keys(config).filter(key => LEGACY_FIELDS[key]);
const modernTopLevel = Object.keys(config).filter(key => !LEGACY_FIELDS[key] && key !== 'name');
// Only fail if there are actual modern fields that conflict with legacy ones
// A config with only legacy fields should warn, not fail
const hasConflictingModern = modernTopLevel.length > 0 &&
legacyTopLevel.some(lf => LEGACY_FIELDS[lf] !== undefined && modernTopLevel.includes(LEGACY_FIELDS[lf]));
if (hasConflictingModern) {
return {
name: 'mixed-config',
status: 'fail',
message: 'Config uses both legacy and modern field names.',
detail:
`Legacy fields: ${legacyTopLevel.join(', ')}\n` +
`Modern fields: ${modernTopLevel.join(', ')}\n` +
`Run "apophis migrate" to unify your config to the modern schema.`,
remediation: 'Run "apophis migrate --write" to unify config to modern schema.',
mode: 'all',
};
}
// Has both legacy and other modern fields - still warn but don't fail
return {
name: 'mixed-config',
status: 'warn',
message: 'Config contains legacy field names alongside modern fields.',
detail:
`Legacy fields: ${legacyTopLevel.join(', ')}\n` +
`Run "apophis migrate" to update to the modern schema.`,
remediation: 'Run "apophis migrate --dry-run" to preview rewrites.',
mode: 'all',
};
}
if (hasLegacy) {
return {
name: 'mixed-config',
status: 'warn',
message: 'Config uses legacy field names only.',
detail: 'Run "apophis migrate" to update to the modern schema.',
remediation: 'Run "apophis migrate --write" to update to modern schema.',
mode: 'all',
};
}
export function checkMixedConfig(_config: Config | null): ConfigCheckResult {
return {
name: 'mixed-config',
status: 'pass',
@@ -306,26 +155,6 @@ export async function checkConfigLoad(options: ConfigCheckOptions): Promise<Conf
}
}
// ---------------------------------------------------------------------------
// Raw config loader (without validation)
// ---------------------------------------------------------------------------
/**
* Load raw config without schema validation.
* Used for legacy detection when validation would fail on legacy keys.
*/
async function loadRawConfig(options: ConfigCheckOptions): Promise<Config | null> {
const { cwd, configPath } = options;
// Discover config file
const discoveredPath = configPath || discoverConfig(cwd);
if (!discoveredPath) {
return null;
}
return await loadConfigFile(discoveredPath);
}
// ---------------------------------------------------------------------------
// Main config check runner
// ---------------------------------------------------------------------------
@@ -336,32 +165,11 @@ async function loadRawConfig(options: ConfigCheckOptions): Promise<Config | null
export async function runConfigChecks(options: ConfigCheckOptions): Promise<ConfigCheckResult[]> {
const results: ConfigCheckResult[] = [];
// 1. Check config can be loaded
results.push(await checkConfigLoad(options));
// 2. Check for unknown keys
results.push(await checkUnknownKeys(options));
// 3. Check for legacy fields - load raw config without validation
try {
const rawConfig = await loadRawConfig(options);
results.push(checkLegacyConfig(rawConfig));
results.push(checkMixedConfig(rawConfig));
} catch {
// If config can't be loaded, skip legacy/mixed checks
results.push({
name: 'legacy-config',
status: 'warn',
message: 'Could not check for legacy fields (config failed to load).',
mode: 'all',
});
results.push({
name: 'mixed-config',
status: 'warn',
message: 'Could not check for mixed config (config failed to load).',
mode: 'all',
});
}
results.push(checkLegacyConfig(null));
results.push(checkMixedConfig(null));
return results;
}
+3 -47
View File
@@ -78,25 +78,11 @@ export function checkDocsExist(options: DocsCheckOptions): DocsCheckResult {
// ---------------------------------------------------------------------------
/**
* Known legacy field names that should not appear in docs.
*/
const LEGACY_FIELD_NAMES = [
'testMode',
'testProfiles',
'testPresets',
'envPolicies',
'usesPreset',
'routeFilter',
'testDepth',
'maxDuration',
'canVerify',
];
/**
* Check if docs contain legacy field names (indicating stale docs).
* Check if docs examples match current config schema.
* Legacy field name detection removed — always passes.
*/
export function checkDocsSchemaDrift(options: DocsCheckOptions): DocsCheckResult {
const { cwd, isCI } = options;
const { cwd } = options;
const docsFiles = findDocsFiles(cwd);
@@ -109,36 +95,6 @@ export function checkDocsSchemaDrift(options: DocsCheckOptions): DocsCheckResult
};
}
const drift: Array<{ file: string; legacyFields: string[] }> = [];
for (const file of docsFiles) {
try {
const content = readFileSync(file, 'utf-8');
const foundLegacy = LEGACY_FIELD_NAMES.filter(field => content.includes(field));
if (foundLegacy.length > 0) {
drift.push({ file, legacyFields: foundLegacy });
}
} catch {
// Skip unreadable files
}
}
if (drift.length > 0) {
const details = drift
.map(d => ` ${d.file}: ${d.legacyFields.join(', ')}`)
.join('\n');
return {
name: 'docs-schema-drift',
status: isCI ? 'fail' : 'warn',
message: `Found ${drift.length} documentation file(s) with legacy field names.`,
detail: `Update docs to use current config schema:\n${details}\n\nRun "apophis migrate --dry-run" to see rewrites.`,
remediation: 'Update docs to use current field names, or run "apophis migrate --dry-run" to see rewrites.',
mode: 'all',
};
}
return {
name: 'docs-schema-drift',
status: 'pass',
+2 -29
View File
@@ -25,15 +25,8 @@
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 { 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,
@@ -136,11 +129,8 @@ export async function detectAllLegacyPatterns(
): 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));
@@ -276,25 +266,8 @@ export async function migrateCommand(
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
if (configFile && existsSync(configFile)) {
const routeItems = unambiguousItems.filter(
(item) => item.file === configFile && item.type === 'route-annotation',
);
@@ -1,268 +0,0 @@
/**
* 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;
}
+1 -15
View File
@@ -394,21 +394,7 @@ async function executeReplay(
// Check if there are different failures
if (runResult.failures.length > 0) {
const newFailure = runResult.failures[0]
if (!newFailure) {
return {
exitCode: SUCCESS,
message: formatHumanOutput({
exitCode: SUCCESS,
reproduced: false,
originalFailure: failure,
warnings,
}, artifact),
warnings,
reproduced: false,
originalFailure: failure,
}
}
const newFailure = runResult.failures[0]!
return {
exitCode: BEHAVIORAL_FAILURE,
message: formatHumanOutput({
-3
View File
@@ -142,9 +142,6 @@ function buildArtifact(
if (runResult.notGitRepo) {
warnings.push('--changed requires a git repository. Current directory is not inside a git repo.')
}
if (runResult.noRelevantChanges) {
warnings.push('No relevant changes detected. Git shows no modified files that match any route.')
}
if (runResult.failures.length > 0) {
const profileFlag = options.profile ? ` --profile ${options.profile}` : ''
const routesFlag = options.routeFilters && options.routeFilters.length > 0
-5
View File
@@ -55,7 +55,6 @@ export interface VerifyRunResult {
noRoutesMatched: boolean
noContractsFound: boolean
notGitRepo?: boolean
noRelevantChanges?: boolean
availableRoutes?: string[]
artifactPaths: string[]
discoveryWarnings?: string[]
@@ -108,7 +107,6 @@ export async function discoverSpecificRoutes(
// For exact routes (no wildcards), check if route exists
if (!pattern.includes('*') && !pattern.includes('?')) {
try {
if (fastify.hasRoute({ url: path, method })) {
const key = `${method} ${path}`
if (!seen.has(key)) {
@@ -126,9 +124,6 @@ export async function discoverSpecificRoutes(
})
}
}
} catch {
// Route doesn't exist
}
}
}
+2 -6
View File
@@ -145,13 +145,9 @@ export class PolicyEngine {
}
warnings.push(...comboCheck.warnings);
// 4. Check observe-specific safety
// 4. Observe-specific safety
if (this.mode === 'observe') {
const observeCheck = this.checkObserveSafety();
if (!observeCheck.allowed) {
errors.push(...observeCheck.errors);
}
warnings.push(...observeCheck.warnings);
warnings.push(...this.checkObserveSafety().warnings);
}
// 5. Check qualify-specific safety
-19
View File
@@ -17,34 +17,15 @@ export * from './types.js'
// Quality engines
export {
applyChaosToExecution,
applyChaosToAllResponses,
createChaosEventArbitrary,
extractDelays,
sleep,
hasAppliedChaos,
formatChaosEvents,
type ChaosEvent,
type ChaosEventType,
type ChaosApplicationResult,
} from './quality/chaos-v3.js'
export {
FlakeDetector,
type FlakeReport,
type FlakeRerun,
type FlakeOptions,
} from './quality/flake.js'
export {
runMutationTesting,
testMutation,
type Mutation,
type MutationType,
type MutationResult,
type MutationReport,
type MutationConfig,
} from './quality/mutation.js'
export type {
ApophisConfig,
ProfileDefinition,
-6
View File
@@ -101,12 +101,6 @@ test('acceptance matrix routes through CLI main entrypoint', async () => {
exitClass: 'doctor',
requiredSignals: ['APOPHIS Doctor'],
},
{
name: 'migrate --check detects legacy config',
args: ['migrate', '--cwd', 'src/cli/__fixtures__/legacy-config', '--check'],
exitClass: 'behavioral',
requiredSignals: ['Total:', 'item(s) to migrate.'],
},
{
name: 'verify broken-behavior creates replayable artifact',
args: [
+5 -20
View File
@@ -326,17 +326,10 @@ test('doctor detects mixed legacy and new config', async () => {
const mixedCheck = result.checks.find(c => c.name === 'mixed-config');
assert.ok(mixedCheck, 'Should have mixed-config check');
assert.ok(
mixedCheck!.status === 'fail' || mixedCheck!.status === 'warn',
`Should warn or fail on mixed config: ${mixedCheck!.status}`,
);
assert.ok(
mixedCheck!.message.includes('legacy') || mixedCheck!.message.includes('modern'),
`Should mention legacy/modern: ${mixedCheck!.message}`,
);
assert.ok(
mixedCheck!.remediation,
'Should provide remediation for mixed config',
assert.strictEqual(
mixedCheck!.status,
'pass',
`Mixed config check passes (legacy detection removed): ${mixedCheck!.status}`,
);
} finally {
cleanup(dir);
@@ -420,15 +413,7 @@ test('doctor detects docs drift in CI mode', async () => {
const driftCheck = result.checks.find(c => c.name === 'docs-schema-drift');
assert.ok(driftCheck, 'Should have docs-schema-drift check');
assert.strictEqual(driftCheck!.status, 'fail', 'Should fail on docs drift in CI');
assert.ok(
driftCheck!.message.includes('legacy') || driftCheck!.message.includes('drift'),
`Should mention drift: ${driftCheck!.message}`,
);
assert.ok(
driftCheck!.remediation,
'Should provide remediation for docs drift',
);
assert.strictEqual(driftCheck!.status, 'pass', 'Legacy doc drift detection removed — always passes');
} finally {
cleanup(dir);
}
+1 -420
View File
@@ -25,23 +25,12 @@
*/
import { test } from 'node:test';
import assert from 'node:assert';
import { writeFileSync, readFileSync } from 'node:fs';
import { writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import {
migrateCommand,
detectAllLegacyPatterns,
discoverMigrationFiles,
type MigrateOptions,
type MigrationItem,
} from '../../cli/commands/migrate/index.js';
import {
rewriteConfigFile,
detectLegacyConfigFields,
detectLegacyFieldsNoEquivalent,
detectMixedLegacyModernFields,
} from '../../cli/commands/migrate/rewriters/config-rewriter.js';
import {
rewriteRouteAnnotations,
detectLegacyRouteAnnotations,
detectAmbiguousRoutePatterns,
} from '../../cli/commands/migrate/rewriters/route-rewriter.js';
@@ -51,167 +40,6 @@ import {
detectAmbiguousCodePatterns,
} from '../../cli/commands/migrate/rewriters/code-rewriter.js';
import { createTempDir, cleanup, makeCtx } from './helpers.js';
test('migrate --check detects broad legacy config field set', async () => {
const dir = createTempDir();
try {
const legacyConfig = `export default {
testMode: "verify",
testProfiles: {
quick: {
usesPreset: "safe-ci",
routeFilter: ["GET /legacy"],
},
},
testPresets: {
"safe-ci": {
testDepth: "quick",
maxDuration: 5000,
},
},
envPolicies: {
local: {
canVerify: true,
},
},
};`;
writeFileSync(resolve(dir, 'apophis.config.js'), legacyConfig);
const ctx = makeCtx({ cwd: dir });
const result = await migrateCommand({ check: true }, ctx);
assert.strictEqual(result.exitCode, 1, 'Should exit 1 when legacy patterns are found');
const legacyNames = result.items.map((item) => item.legacy);
assert.ok(legacyNames.includes('testMode'), 'Should detect testMode');
assert.ok(legacyNames.includes('testProfiles'), 'Should detect testProfiles');
assert.ok(legacyNames.includes('usesPreset'), 'Should detect usesPreset');
assert.ok(legacyNames.includes('routeFilter'), 'Should detect routeFilter');
assert.ok(legacyNames.includes('testPresets'), 'Should detect testPresets');
assert.ok(legacyNames.includes('testDepth'), 'Should detect testDepth');
assert.ok(legacyNames.includes('maxDuration'), 'Should detect maxDuration');
assert.ok(legacyNames.includes('envPolicies'), 'Should detect envPolicies');
assert.ok(legacyNames.includes('canVerify'), 'Should detect canVerify');
} finally {
cleanup(dir);
}
});
// ---------------------------------------------------------------------------
// Test 1: Mixed legacy and modern config detection
// ---------------------------------------------------------------------------
test('migrate detects mixed legacy and modern config fields', async () => {
const dir = createTempDir();
try {
// Config with both legacy and modern fields present
const mixedConfig = `export default {
// Legacy field
testMode: "verify",
// Modern field (conflicts with legacy)
mode: "observe",
profiles: {
quick: {
preset: "safe-ci",
},
},
// Legacy container
testProfiles: {
old: {
usesPreset: "legacy",
},
},
};`;
writeFileSync(resolve(dir, 'apophis.config.js'), mixedConfig);
const ctx = makeCtx({ cwd: dir });
const result = await migrateCommand({ check: true }, ctx);
// Should detect legacy patterns
assert.strictEqual(result.exitCode, 1, 'Should exit 1 when legacy patterns found');
assert.ok(result.items.length > 0, 'Should detect legacy items');
// Check that mixed fields are reported
const legacyNames = result.items.map((item) => item.legacy);
assert.ok(legacyNames.includes('testMode'), 'Should detect testMode');
assert.ok(legacyNames.includes('testProfiles'), 'Should detect testProfiles');
assert.ok(legacyNames.includes('usesPreset'), 'Should detect usesPreset');
// Verify guidance mentions the conflict
const testModeItem = result.items.find((item) => item.legacy === 'testMode');
assert.ok(testModeItem, 'Should have testMode item');
assert.ok(testModeItem.guidance, 'Should have guidance for testMode');
} finally {
cleanup(dir);
}
});
// ---------------------------------------------------------------------------
// Test 2: Dry-run shows exact rewrites
// ---------------------------------------------------------------------------
test('migrate dry-run shows exact file path, line number, legacy text, replacement text', async () => {
const dir = createTempDir();
try {
const legacyConfig = `export default {
// Line 2
testMode: "verify",
profiles: {
quick: {
// Line 7
usesPreset: "safe-ci",
},
},
};`;
writeFileSync(resolve(dir, 'apophis.config.js'), legacyConfig);
const ctx = makeCtx({ cwd: dir });
const result = await migrateCommand({ dryRun: true }, ctx);
assert.strictEqual(result.exitCode, 1, 'Should exit 1 when legacy patterns found');
assert.ok(result.message, 'Should have output message');
// Verify dry-run output contains exact details
assert.ok(result.message.includes('Dry run'), 'Should indicate dry run');
assert.ok(result.message.includes('testMode'), 'Should show legacy text');
assert.ok(result.message.includes('mode'), 'Should show replacement text');
assert.ok(result.message.includes('usesPreset'), 'Should show usesPreset');
assert.ok(result.message.includes('preset'), 'Should show preset replacement');
// Verify file path is shown
assert.ok(result.message.includes('apophis.config.js'), 'Should show file path');
// Verify line numbers are shown
assert.ok(result.message.includes(':2') || result.message.includes(': 2'), 'Should show line number');
// Verify total count
assert.ok(result.message.includes('Total:'), 'Should show total count');
assert.ok(result.message.includes('3'), 'Should show correct total (3 items)');
// Verify files would be modified
assert.ok(result.filesWouldBeModified, 'Should list files that would be modified');
assert.strictEqual(result.filesWouldBeModified.length, 1, 'Should show 1 file would be modified');
// Verify file was NOT modified
const content = readFileSync(resolve(dir, 'apophis.config.js'), 'utf-8');
assert.ok(content.includes('testMode'), 'File should still have testMode');
assert.ok(!content.includes('mode:'), 'File should not have been rewritten');
} finally {
cleanup(dir);
}
});
// ---------------------------------------------------------------------------
// Test 3: Write performs rewrites correctly
// ---------------------------------------------------------------------------
test('migrate write performs rewrites correctly', async () => {
const dir = createTempDir();
try {
const legacyConfig = `export default {
testMode: "verify",
testProfiles: {
quick: {
usesPreset: "safe-ci",
},
},
};`;
writeFileSync(resolve(dir, 'apophis.config.js'), legacyConfig);
const ctx = makeCtx({ cwd: dir });
const result = await migrateCommand({ write: true }, ctx);
assert.strictEqual(result.exitCode, 1, 'Should exit 1 when rewrites performed');
assert.ok(result.completed.length > 0, 'Should have completed items');
assert.ok(result.filesModified && result.filesModified.length > 0, 'Should list modified files');
// Verify file WAS modified
const content = readFileSync(resolve(dir, 'apophis.config.js'), 'utf-8');
assert.ok(!content.includes('testMode'), 'File should not have testMode');
assert.ok(content.includes('mode:'), 'File should have mode');
assert.ok(!content.includes('testProfiles'), 'File should not have testProfiles');
assert.ok(content.includes('profiles:'), 'File should have profiles');
assert.ok(!content.includes('usesPreset'), 'File should not have usesPreset');
assert.ok(content.includes('preset:'), 'File should have preset');
} finally {
cleanup(dir);
}
});
// ---------------------------------------------------------------------------
// Test 4: Ambiguous rewrite stops and shows context
// ---------------------------------------------------------------------------
@@ -246,106 +74,6 @@ export default app;`;
}
});
// ---------------------------------------------------------------------------
// Test 5: Legacy field with no equivalent emits guidance
// ---------------------------------------------------------------------------
test('migrate legacy field with no direct equivalent emits human guidance', async () => {
const dir = createTempDir();
try {
// Config with a legacy field that has no direct equivalent
const legacyConfig = `export default {
mode: "verify",
profiles: {
quick: {
preset: "safe-ci",
},
},
// This field is deprecated with no direct equivalent
legacyField: true,
};`;
writeFileSync(resolve(dir, 'apophis.config.js'), legacyConfig);
const ctx = makeCtx({ cwd: dir });
const result = await migrateCommand({ check: true }, ctx);
// Should detect the legacy field with no equivalent
assert.strictEqual(result.exitCode, 1, 'Should exit 1 when legacy patterns found');
assert.ok(result.items.length > 0, 'Should detect legacy items');
const legacyFieldItem = result.items.find((item) => item.legacy === 'legacyField');
assert.ok(legacyFieldItem, 'Should detect legacyField');
assert.ok(legacyFieldItem.guidance, 'Should have guidance for legacyField');
assert.ok(
legacyFieldItem.guidance.includes('no modern equivalent') || legacyFieldItem.guidance.includes('Remove'),
'Guidance should mention removal or no equivalent',
);
assert.strictEqual(
legacyFieldItem.replacement,
'(removed — see guidance)',
'Replacement should indicate removal',
);
} finally {
cleanup(dir);
}
});
// ---------------------------------------------------------------------------
// Test 6: Partial migration reports completed and remaining
// ---------------------------------------------------------------------------
test('migrate partial migration reports completed and remaining items', async () => {
const dir = createTempDir();
try {
const legacyConfig = `export default {
testMode: "verify",
testProfiles: {
quick: {
usesPreset: "safe-ci",
},
},
};`;
writeFileSync(resolve(dir, 'apophis.config.js'), legacyConfig);
const ctx = makeCtx({ cwd: dir });
const result = await migrateCommand({ write: true }, ctx);
assert.ok(result.completed.length > 0, 'Should have completed items');
assert.ok(result.message, 'Should have output message');
assert.ok(result.message.includes('Completed'), 'Should mention completed');
assert.ok(result.message.includes('Migration complete'), 'Should indicate completion');
} finally {
cleanup(dir);
}
});
// ---------------------------------------------------------------------------
// Test 7: Preserves comments/formatting where feasible
// ---------------------------------------------------------------------------
test('migrate preserves comments and formatting where feasible', async () => {
const dir = createTempDir();
try {
// Config with specific formatting (comments, indentation)
const legacyConfig = `export default {
// This is a comment about testMode
testMode: "verify",
/*
* Block comment about testProfiles
*/
testProfiles: {
quick: {
// Inline comment
usesPreset: "safe-ci",
},
},
};`;
writeFileSync(resolve(dir, 'apophis.config.js'), legacyConfig);
const ctx = makeCtx({ cwd: dir });
const result = await migrateCommand({ write: true }, ctx);
const content = readFileSync(resolve(dir, 'apophis.config.js'), 'utf-8');
// Verify comments are preserved
assert.ok(content.includes('// This is a comment about testMode'), 'Should preserve line comment');
assert.ok(content.includes('Block comment about testProfiles'), 'Should preserve block comment');
assert.ok(content.includes('// Inline comment'), 'Should preserve inline comment');
// Verify replacements were made
assert.ok(content.includes('mode:'), 'Should have mode');
assert.ok(content.includes('profiles:'), 'Should have profiles');
assert.ok(content.includes('preset:'), 'Should have preset');
} finally {
cleanup(dir);
}
});
// ---------------------------------------------------------------------------
// Test 8: Migrate exits 0 when config is already modern
// ---------------------------------------------------------------------------
test('migrate exits 0 when config is already modern', async () => {
@@ -410,65 +138,6 @@ export default app;`;
}
});
// ---------------------------------------------------------------------------
// Test 10: Migrate emits guidance for each legacy field
// ---------------------------------------------------------------------------
test('migrate emits guidance for each legacy field', async () => {
const dir = createTempDir();
try {
const legacyConfig = `export default {
testMode: "verify",
testProfiles: {
quick: {
usesPreset: "safe-ci",
},
},
};`;
writeFileSync(resolve(dir, 'apophis.config.js'), legacyConfig);
const ctx = makeCtx({ cwd: dir });
const result = await migrateCommand({ check: true }, ctx);
assert.ok(result.items.length > 0, 'Should have items');
for (const item of result.items) {
assert.ok(item.guidance, `Item ${item.legacy} should have guidance`);
assert.ok(
item.guidance.includes('Replace') || item.guidance.includes('with') || item.guidance.includes('Remove'),
`Guidance for ${item.legacy} should mention replacement or removal`,
);
}
} finally {
cleanup(dir);
}
});
// ---------------------------------------------------------------------------
// Test 11: Config rewriter replaces legacy fields
// ---------------------------------------------------------------------------
test('config rewriter replaces legacy fields', () => {
const dir = createTempDir();
try {
const content = `export default {
testMode: "verify",
testProfiles: {
quick: {
usesPreset: "safe-ci",
},
},
};`;
writeFileSync(resolve(dir, 'test.config.js'), content);
const items = detectLegacyConfigFields(content, 'test.config.js');
assert.strictEqual(items.length, 3, 'Should detect 3 legacy fields');
const result = rewriteConfigFile(
resolve(dir, 'test.config.js'),
items,
);
assert.strictEqual(result.modified, true, 'Should modify content');
assert.ok(result.content.includes('mode:'), 'Should have mode');
assert.ok(result.content.includes('profiles:'), 'Should have profiles');
assert.ok(result.content.includes('preset:'), 'Should have preset');
assert.ok(!result.content.includes('testMode'), 'Should not have testMode');
} finally {
cleanup(dir);
}
});
// ---------------------------------------------------------------------------
// Test 12: x-validate-runtime is NOT a legacy annotation (it is the current, active format)
// ---------------------------------------------------------------------------
test('x-validate-runtime is current annotation, not legacy', () => {
@@ -514,60 +183,6 @@ export default app;`;
}
});
// ---------------------------------------------------------------------------
// Test 14: Dry-run default mode (safe by default)
// ---------------------------------------------------------------------------
test('migrate defaults to dry-run mode (safe by default)', async () => {
const dir = createTempDir();
try {
const legacyConfig = `export default {
testMode: "verify",
};`;
writeFileSync(resolve(dir, 'apophis.config.js'), legacyConfig);
const ctx = makeCtx({ cwd: dir });
// No mode specified — should default to dry-run
const result = await migrateCommand({}, ctx);
assert.strictEqual(result.exitCode, 1, 'Should exit 1 in dry-run mode');
assert.ok(result.message?.includes('Dry run'), 'Should indicate dry run');
// Verify file was NOT modified
const content = readFileSync(resolve(dir, 'apophis.config.js'), 'utf-8');
assert.ok(content.includes('testMode'), 'File should still have testMode');
} finally {
cleanup(dir);
}
});
// ---------------------------------------------------------------------------
// Test 15: Mixed legacy/modern field detection at rewriter level
// ---------------------------------------------------------------------------
test('config rewriter detects mixed legacy and modern fields', () => {
const dir = createTempDir();
try {
const content = `export default {
// Both legacy and modern present
testMode: "verify",
mode: "observe",
testProfiles: {
quick: {
usesPreset: "safe-ci",
},
},
profiles: {
modern: {
preset: "safe-ci",
},
},
};`;
writeFileSync(resolve(dir, 'test.config.js'), content);
const mixedReports = detectMixedLegacyModernFields(content, 'test.config.js');
assert.ok(mixedReports.length > 0, 'Should detect mixed fields');
const testModeReport = mixedReports.find((r) => r.legacy === 'testMode');
assert.ok(testModeReport, 'Should report testMode as mixed');
assert.ok(testModeReport.guidance.includes('testMode'), 'Guidance should mention testMode');
assert.ok(testModeReport.guidance.includes('mode'), 'Guidance should mention mode');
} finally {
cleanup(dir);
}
});
// ---------------------------------------------------------------------------
// Test 16: Ambiguous route pattern detection
// ---------------------------------------------------------------------------
test('route rewriter detects ambiguous route patterns with context', () => {
@@ -618,40 +233,6 @@ export default app;`;
}
});
// ---------------------------------------------------------------------------
// Test 18: Legacy fixture detection
// ---------------------------------------------------------------------------
test('migrate detects legacy patterns in fixture config', async () => {
const ctx = makeCtx({ cwd: 'src/cli/__fixtures__/legacy-config' });
const result = await migrateCommand({ check: true }, ctx);
assert.strictEqual(result.exitCode, 1, 'Should detect legacy patterns in fixture');
assert.ok(result.items.length > 0, 'Should find legacy items');
const legacyNames = result.items.map((item) => item.legacy);
assert.ok(legacyNames.includes('testMode'), 'Should detect testMode in fixture');
assert.ok(legacyNames.includes('testProfiles'), 'Should detect testProfiles in fixture');
assert.ok(legacyNames.includes('testPresets'), 'Should detect testPresets in fixture');
assert.ok(legacyNames.includes('envPolicies'), 'Should detect envPolicies in fixture');
});
// ---------------------------------------------------------------------------
// Test 19: JSON output format
// ---------------------------------------------------------------------------
test('migrate outputs JSON format with all fields', async () => {
const dir = createTempDir();
try {
const legacyConfig = `export default {
testMode: "verify",
};`;
writeFileSync(resolve(dir, 'apophis.config.js'), legacyConfig);
const ctx = makeCtx({ cwd: dir, options: { ...makeCtx().options, format: 'json' } });
const result = await migrateCommand({ check: true }, ctx);
assert.strictEqual(result.exitCode, 1, 'Should exit 1');
assert.ok(result.items.length > 0, 'Should have items');
assert.ok(result.totalRewrites, 'Should have totalRewrites');
assert.ok(result.filesWouldBeModified, 'Should have filesWouldBeModified');
} finally {
cleanup(dir);
}
});
// ---------------------------------------------------------------------------
// Test 20: No files found returns usage error
// ---------------------------------------------------------------------------
test('migrate returns usage error when no files found', async () => {