Sync(srcDir, { recursive: true }).filter(f => f.endsWith('.ts'));
files.forEach(file => {
const content = readFileSync(join(srcDir, file), 'utf-8');
const routeMatch = content.match(/(?:app|router).(get|post|put|delete)('"['"]/g);
const dbMatch = content.match(/(?:INSERT|UPDATE|DELETE|SELECT)\s+.*?\s+FROM|INTO|SET/gi);
const apiMatch = content.match(/(?:fetch|axios.|http.request)(/g);
if (routeMatch) boundaries.routes.push(...routeMatch);
if (dbMatch) boundaries.dbMutations.push(...dbMatch);
if (apiMatch) boundaries.externalCalls.push(...apiMatch);
});
return boundaries;
}
Document the output. This map becomes your navigation chart. You cannot safely modify internals until you know exactly where data enters and leaves the system.
**Step 2: Construct an Execution Harness**
The highest-leverage action is building a minimal, isolated loop that runs the code with a single input and captures a single output. This harness replaces guesswork with empirical validation.
For a Python-based data processor, create a dedicated harness directory:
```bash
mkdir -p legacy_harness/fixtures
cat > legacy_harness/verify.sh <<'SCRIPT'
#!/bin/bash
set -e
cd "$(dirname "$0")/.."
python3 src/data_pipeline.py --config fixtures/minimal.yaml --input fixtures/sample.json > /tmp/pipeline_output.json
diff --brief /tmp/pipeline_output.json fixtures/expected_baseline.json
echo "Harness verification passed."
SCRIPT
chmod +x legacy_harness/verify.sh
Run this script after every modification. If it fails, you broke a contract. If it passes, you have a safe foundation for refactoring. The harness does not need to be elegant; it needs to be reliable. Treat it as a first-class citizen in your repository.
Phase 2: Structural Discovery & Typing
Step 3: Bisection to Load-Bearing Code
Reading top-to-bottom is inefficient. Use bisection to isolate the 10% of files that contain actual business logic. Analyze version control history, import frequency, and file complexity.
# Identify files modified most frequently in the last 18 months
git log --since="18 months ago" --name-only --pretty=format: | \
grep -E "\.(ts|py|java)$" | sort | uniq -c | sort -rn | head -15
# Locate heavily imported modules (high coupling indicates core logic)
grep -rh "^import\|^from" src/ --include="*.ts" | \
awk '{print $2}' | sort | uniq -c | sort -rn | head -15
Cross-reference these results. Files that appear in both lists are your primary targets. Ignore configuration files, adapters, and utility wrappers until the core logic is understood.
Step 4: Systematic Renaming
Understanding emerges through naming. Every time you decipher a function or variable, rename it immediately. Do not defer this step. Context decays rapidly; renaming locks comprehension into the codebase.
// Before: opaque identifiers
function transform(d, c) {
const t = d.filter(i => i.v > c.m).map(i => i.k);
return store.fetch(t);
}
// After: explicit domain language
function retrieveQualifiedCustomerIds(dataset: CustomerRecord[], config: FilteringConfig): string[] {
const threshold = config.minimumScore;
const qualifyingRecords = dataset.filter(record => record.value > threshold);
const targetIds = qualifyingRecords.map(record => record.customerId);
return customerRepository.fetchByIds(targetIds);
}
If you cannot assign a meaningful name, you do not understand the logic yet. Continue tracing until the purpose is clear. Commit renaming changes frequently with messages that document the discovery.
Step 5: Introduce Structural Types
Dynamic languages accumulate implicit contracts that break during refactoring. Add type definitions to convert hidden assumptions into compiler-enforced rules. Types are executable documentation; they fail fast when violated, unlike comments.
// Define explicit contracts before modifying logic
interface PricingRule {
baseRate: number;
multiplier: number;
capLimit: number;
}
interface TransactionLine {
itemId: string;
quantity: number;
appliedRules: PricingRule[];
}
interface CalculationResult {
grossTotal: number;
adjustedTotal: number;
ruleBreakdown: Record<string, number>;
}
function computeAdjustedTotal(lines: TransactionLine[]): CalculationResult {
return lines.reduce((acc, line) => {
const lineTotal = line.quantity * line.appliedRules[0].baseRate;
acc.grossTotal += lineTotal;
acc.adjustedTotal += Math.min(lineTotal * line.appliedRules[0].multiplier, line.appliedRules[0].capLimit);
return acc;
}, { grossTotal: 0, adjustedTotal: 0, ruleBreakdown: {} });
}
Start with loose interfaces. Tighten them as you verify behavior. The compiler will immediately flag mismatches, preventing silent data corruption during refactoring.
Phase 3: Behavioral Locking & Documentation
Step 6: Pin Observed Behavior with Tests
Write tests that capture current behavior before making changes. This includes edge cases, workarounds, and apparent bugs. Legacy systems often contain intentional deviations that satisfy downstream dependencies or regulatory requirements.
import { describe, it, expect } from 'vitest';
import { computeAdjustedTotal } from '../src/pricing';
describe('Legacy Pricing Module', () => {
it('returns zero total when input array is empty', () => {
const result = computeAdjustedTotal([]);
expect(result.grossTotal).toBe(0);
expect(result.adjustedTotal).toBe(0);
});
it('preserves historical rounding behavior for fractional quantities', () => {
const input = [{
itemId: 'SKU-9921',
quantity: 1.5,
appliedRules: [{ baseRate: 10, multiplier: 1.2, capLimit: 100 }]
}];
const result = computeAdjustedTotal(input);
// Historical note: downstream accounting system expects floor rounding
expect(result.adjustedTotal).toBe(18);
});
});
Run these tests against the harness. They serve as a behavioral contract. If a refactor breaks a test, you either introduced a regression or discovered a bug that requires stakeholder approval to fix. Never modify behavior without updating the corresponding test first.
Step 7: Document Decision Context, Not Syntax
Code explains what happens. Comments must explain why it happens that way. Focus on constraints, historical incidents, and business negotiations. Future maintainers will encounter the same oddities you see today; your comments prevent them from repeating your discovery process.
// β Redundant: states the obvious
const MAX_RETRIES = 3;
// β
Valuable: captures the constraint and origin
// Set to 3 retries because the payment gateway enforces a hard 45-second timeout.
// Load balancer jitter averages 2 seconds, making 4 attempts trigger circuit breakers.
// Reference: Incident #INC-2023-0891. Do not increase without payment team approval.
const MAX_RETRIES = 3;
Scan the codebase for magic numbers, exception swallowing, and conditional branches. Each one represents a negotiation with reality. Document the rationale once you uncover it.
Pitfall Guide
| Pitfall Name | Explanation | Fix |
|---|
| Top-Down Reading Trap | Developers open main.ts or index.py and trace execution linearly. Legacy systems use event loops, dependency injection, and dynamic routing that break linear flow. | Start with boundary mapping. Use the harness to trigger execution paths rather than following static call graphs. |
| Refactoring Before Pinning | Modifying logic without behavioral tests guarantees regressions. Legacy code often contains hidden contracts that only surface in production. | Write snapshot or behavioral tests first. Lock observed output before changing implementation. |
| Over-Typing Too Early | Attempting to create perfect type definitions before understanding data flow leads to fragile interfaces and wasted effort. | Start with any or loose interfaces. Tighten types incrementally as tests validate data shapes. |
| Harness Neglect | Treating the execution harness as a temporary script rather than a permanent safety net. The harness degrades as dependencies update. | Version control the harness. Add it to CI pipelines. Treat harness failures as build blockers. |
| Commenting the Obvious | Writing comments that restate code logic (// loops through users). These drift from implementation and create maintenance overhead. | Comment constraints, historical decisions, and non-obvious trade-offs. Delete comments that add no context. |
| Bisection by File Size Alone | Assuming the largest files contain core logic. Large files are often configuration dumps, generated code, or abandoned modules. | Cross-reference file size with import frequency, version control activity, and test coverage. |
| Skipping Boundary Mapping | Jumping into internals without knowing inputs/outputs leads to modifying functions that appear isolated but actually trigger side effects. | Always map routes, DB queries, and external calls first. Use static analysis to generate the boundary map. |
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Zero existing tests, tight deadline | Build harness + pin behavioral tests only | Fastest path to safe modification without rewriting test infrastructure | Low initial cost, prevents expensive production rollbacks |
| High test coverage, failing modernization | Audit test assertions vs. actual behavior | Tests may encode incorrect assumptions; verify against harness first | Medium cost, avoids false confidence in broken test suites |
| Dynamically typed monolith | Incremental type introduction + loose interfaces | Prevents breaking changes while enabling compiler validation | Low cost, scales with team comprehension |
| Microservices with shared legacy module | Boundary map + contract tests per service | Isolates service-specific expectations from shared logic | Medium cost, reduces cross-service regression risk |
| Regulated industry (finance/healthcare) | Behavioral pinning + decision documentation | Audit trails require explicit rationale for every constraint | High compliance value, reduces legal/audit risk |
Configuration Template
Copy this structure to establish a repeatable archaeology workflow. Place it in your repository root.
legacy-audit/
βββ harness/
β βββ fixtures/
β β βββ minimal_input.json
β β βββ expected_baseline.json
β βββ run_verification.sh
β βββ Dockerfile
βββ analysis/
β βββ boundary_mapper.ts
β βββ bisection_report.sh
β βββ type_introduction_guide.md
βββ tests/
β βββ behavioral_pinning.test.ts
β βββ snapshot_registry.json
βββ Makefile
Makefile
.PHONY: verify types tests clean
verify:
@echo "Running execution harness..."
@bash legacy-audit/harness/run_verification.sh
types:
@echo "Running type validation..."
@npx tsc --noEmit --project tsconfig.audit.json
tests:
@echo "Executing behavioral pinning suite..."
@npx vitest run legacy-audit/tests/behavioral_pinning.test.ts
clean:
@rm -rf /tmp/pipeline_output.json
@echo "Temporary artifacts cleared."
Quick Start Guide
- Initialize the harness: Create
legacy-audit/harness/, add a minimal input file, and write a shell script that executes the target module and diffs output against a known baseline.
- Run boundary analysis: Execute the static mapper script to extract routes, database queries, and external calls. Save the output as
boundary_map.md.
- Execute bisection: Run the version control and import frequency scripts. Cross-reference results to identify your top 15 load-bearing files.
- Lock behavior: Write three to five tests that capture current output for your target files. Run them against the harness to confirm stability.
- Begin refactoring: Rename one function, add type definitions, and verify the harness and tests still pass. Commit. Repeat incrementally.
This workflow transforms legacy comprehension from a speculative exercise into a repeatable engineering process. By enforcing isolation, validating behavior, and documenting constraints, teams modernize systems without introducing regressions or losing institutional knowledge.