Perl Part II · Module 28 · Legacy Perl and Python Migration
Choosing a Migration Strategy
Chapter 28.3 showed that rewriting a working script is often the expensive choice. It did not show what to do instead, and the answer depends on the script. There are four sensible strategies: leave the script alone, wrap it behind a cleaner interface, convert it one function at a time, or rewrite it. Each fits a different kind of script. This chapter chooses between them using four facts you can measure: how big the script is, whether it has tests, how often it changes, and how many other programs read what it produces. It measures those facts from a repository's history and its flow tree, applies an explicit rule, shows how close each decision is to changing, and builds the wrapper that one of the scripts turns out to need.
Advanced27 min readPerlPart IIEDA Flows
Part II · Module 28 · Chapter 28.4 · Choosing a Migration Strategy
1. Four Strategies, Defined by What They Change
The four strategies are often talked about loosely. Define each one by what it changes and what it leaves alone, because that is what decides its risk.
| Strategy | What changes | What stays | Main risk |
|---|---|---|---|
| Leave alone | nothing; the inventory is written down | the script and all its behaviour | the knowledge decays if nobody records it |
| Wrap | a new, documented interface for new callers | the script, and every existing caller | two interfaces to keep consistent |
| Incremental | one function at a time, each checked against the old one | the flow, at every step | slow; the half-converted state lasts a while |
| Rewrite | everything at once, against the inventory | nothing, after cutover | every behaviour difference lands at once |
Two things in that table are easy to get wrong.
"Leave alone" is not "do nothing". It means: take the inventory from Chapter 28.2, write down the consumers, and then change nothing until there is a reason. A script left alone with an inventory is one that can be changed safely on the day it must be. A script left alone without one is the problem the next engineer inherits.
"Wrap" does not touch the old script. A wrapper is a small new program that calls the old one and offers a better interface: structured output, a clear exit status, no side effects. Existing callers keep calling the old script directly and see no change. New callers use the wrapper. The old script's behaviour stays frozen, and its circle of consumers stops growing. Section 9 builds one.
2. Four Facts
The canonical criteria are four facts about a script. Each one answers a different question about risk and cost.
- Size (non-blank, non-comment code lines): how much would have to be rewritten? It sets the cost of a rewrite, and almost nothing else.
- Tests (does the script have them, or an inventory that serves as one?): could a change be checked at all? Without tests, every strategy except "leave alone" starts by writing them.
- Change rate (commits that touched the script in the last 12 months): how often does somebody pay the cost of the current code? A script nobody edits costs almost nothing to keep, however ugly it is.
- Blast radius (how many files in the flow read the script's name or one of its outputs): how many people does a behaviour change reach?
Notice what is missing: how ugly the code is, what language it is in, and line count as a measure of difficulty. Chapter 16.2 measured that a script's interface, not its length, decides how hard it is to replace, and Chapter 16.7 used the same idea to order a migration. Here, size has one narrow job: telling a small rewrite from a big one.
3. The Decision Rule
The rule asks about change rate first, then about either consumers or size:
Figure 28.4.1 — Four facts, one strategy
Why change rate comes first. If a script is not changing, the cost of its ugliness is not being paid by anyone, so neither its size nor its lack of tests creates any pressure to act. The only question left is whether its circle of readers is growing; if it is, wrap it. Asking about size first gives a different and worse answer, as section 8 measures.
Why "small and tested" for a rewrite. A rewrite replaces every behaviour at once, so it is only safe when the inventory is short (small) and checkable (tested). Everything else that changes often is converted incrementally: one function at a time, each one compared with the original, which is Chapter 28.5's subject.
4. Measuring the Facts
Opinions about these four facts are usually wrong in a predictable direction: the script someone dislikes is "constantly changing", and the one nobody looks at "has no users". Measure them instead. To keep this page runnable anywhere, make_repo.sh builds a throwaway git repository holding five sample flow scripts, three test files, eight consumer files and a backdated history: 24 commits, with dates set explicitly so that "the last 12 months" means the same thing on every machine. Only sta_summary.pl is a real script (the case study); the other four are padded with straight-line filler to the sizes being tested.
#!/bin/bash
# make_repo.sh - build a throwaway git repository holding five SAMPLE flow
# scripts, their tests, their consumers and a backdated history, so that
# every fact the strategy needs can be MEASURED rather than asserted.
# The scripts are padded samples; only their sizes, tests, histories and
# consumers matter here.
set -e
repo=$1
mkdir -p "$repo"/{tools,t,flow/bin}
cp sta_summary.pl "$repo/tools/"
cd "$repo"
git init -q
git config user.name "sample"
git config user.email "sample@example.invalid"
git config commit.gpgsign false
pad() { # pad FILE N : append N lines of plausible straight-line code
for i in $(seq 1 "$2"); do echo "\$cfg{opt_$i} = $i;" >> "$1"; done
}
printf '#!/usr/bin/perl\nuse strict; use warnings;\nmy %%cfg;\nwhile (<>) { chomp; printf "%%-24s %%8s\\n", split /\\s+/ }\n' > tools/fmt_table.pl
printf '#!/usr/bin/perl\nuse strict; use warnings;\nmy %%cfg;\nopen my $out, ">", "rtl.f" or die;\n' > tools/gen_filelist.pl
pad tools/gen_filelist.pl 56
printf '#!/usr/bin/perl\nmy %%cfg;\nopen(LOG, ">regress.log");\nsystem("bsub -q normal sim");\n' > tools/run_regress.pl
pad tools/run_regress.pl 410
printf '#!/usr/bin/perl\nuse strict; use warnings;\nmy %%cfg;\nopen my $out, ">", "coverage.csv" or die;\n' > tools/parse_cov.pl
pad tools/parse_cov.pl 226
for s in fmt_table gen_filelist parse_cov; do
printf 'use Test::More;\nok(1, "%s");\ndone_testing;\n' "$s" > "t/$s.t"
done
# consumers: files in the flow that name a script, or a file it writes
printf 'rtl.f:\n\tperl tools/gen_filelist.pl\nsignoff:\n\tperl tools/sta_summary.pl reports\nregress:\n\tperl tools/run_regress.pl\n' > flow/Makefile
echo 'cut -d, -f3 run/tmp.csv' > flow/bin/post_wns.sh
echo 'perl ../tools/sta_summary.pl -v run/reports' > flow/bin/weekly_digest.pl
echo 'grep FAIL regress.log | mail -s fails team' > flow/bin/mail_fails.sh
echo 'tail -1 regress.log' > flow/bin/status.sh
echo 'perl tools/run_regress.pl --rerun' > flow/bin/rerun.sh
echo 'column -t coverage.csv' > flow/bin/cov_view.sh
echo 'perl tools/fmt_table.pl < slack.txt' > flow/bin/pretty.sh
commit() { # commit DATE FILE... : one backdated commit touching FILEs
local d=$1; shift
for f in "$@"; do echo "# touched $d" >> "$f"; done
git add -A
GIT_AUTHOR_DATE="$d 10:00:00" GIT_COMMITTER_DATE="$d 10:00:00" \
git commit -q -m "change $* on $d"
}
commit 2016-03-01 tools/sta_summary.pl tools/fmt_table.pl tools/run_regress.pl tools/gen_filelist.pl tools/parse_cov.pl
commit 2025-12-02 tools/fmt_table.pl
for d in 2025-11-04 2025-12-09 2026-01-13 2026-02-10 2026-03-10 2026-04-14 2026-05-12 2026-06-09 2026-07-14 2026-08-11 2026-09-08 2026-09-29; do
commit "$d" tools/run_regress.pl
done
for d in 2026-01-20 2026-03-17 2026-05-19 2026-07-21 2026-09-15; do
commit "$d" tools/gen_filelist.pl
done
for d in 2025-11-18 2026-02-24 2026-04-28 2026-06-23 2026-08-25; do
commit "$d" tools/parse_cov.pl
donemeasure_scripts.pl then measures each fact directly from the repository:
use strict;
use warnings;
use File::Basename qw(basename);
use File::Find;
# measure_scripts.pl - the four facts a migration strategy needs, MEASURED.
# usage: perl measure_scripts.pl REPO SINCE UNTIL
# Prints one tab-separated row per tools/*.pl:
# script code_lines tested changes consumers (consumer files)
my ($repo, $since, $until) = @ARGV;
die "usage: measure_scripts.pl REPO SINCE UNTIL\n" unless defined $until;
my @flow; # every file in the flow
find({ no_chdir => 1, wanted => sub { push @flow, $_ if -f } }, "$repo/flow");
print join("\t", qw(script code_lines tested changes consumers)), "\n";
for my $path (sort glob "$repo/tools/*.pl") {
my $name = basename($path, '.pl');
open my $fh, '<', $path or die "cannot read $path: $!\n";
my ($code, @outputs) = (0);
while (my $line = <$fh>) {
next if $line =~ /^\s*(?:#|$)/; # blank or comment
$code++;
# a file it writes: two-argument ">name" or three-argument ">", "name"
push @outputs, $1 if $line =~ /\bopen\b[^;]*?>+\s*(?:["']\s*,\s*["'])?([\w.\/]+)/;
}
close $fh;
my $tested = -e "$repo/t/$name.t" ? 'yes' : 'no';
# change rate: commits touching the file inside the window
my @log = `git -C "$repo" log --oneline --since="$since" --until="$until" -- "tools/$name.pl"`;
die "git log failed for $name\n" if $?;
# blast radius: flow files that name the script OR a file it writes
my $pattern = join '|', map { quotemeta } $name, @outputs;
my @readers;
for my $f (@flow) {
open my $in, '<', $f or next;
local $/;
push @readers, basename($f) if <$in> =~ /$pattern/;
}
print join("\t", "$name.pl", $code, $tested, scalar @log, scalar @readers,
join(',', sort @readers)), "\n";
}- Code lines skip blank lines and comments. POD and here-documents would need Chapter 28.1's survey logic; these samples have neither.
- Tested means a test file named after the script exists. That is a crude proxy, and in your own repository you should replace it with whatever your team actually runs, or with "an inventory exists".
- Changes come from
git log --since --until -- FILE, counted one line per commit. The window is given explicitly rather than as "12 months ago", so the answer does not drift with the date you run it. - Consumers are flow files that mention the script's name or any file it writes. The output files are found from the script's own
openstatements: the same idea as the OUTPUTS section of Chapter 28.1's survey.
5. The Measurement Had a Bug
The first version of measure_scripts.pl found a script's output files with this pattern:
push @outputs, $1 if $line =~ /\bopen\b.*?>\s*"?([\w.]+)/;It handles the two-argument form open(CSV, ">tmp.csv"). It does not handle the three-argument form open my $out, ">", "coverage.csv", where a quote and a comma sit between the > and the name. With that version, parse_cov.pl was reported with zero consumers, because nothing found coverage.csv, so nothing found cov_view.sh, the program that reads it.
That one missing match would have made parse_cov.pl look like the safest script in the tree to change. The fixed pattern accepts both forms and finds the consumer. The lesson is the same as the 996-line __END__ in Chapter 28.1: a tool that measures risk can understate it, and the direction of the error matters. Undercounting consumers is the dangerous direction, so check any "zero" against a quick search by hand.
6. The Decisions
choose_strategy.pl applies the rule. The thresholds are named constants at the top, with a comment that says they are judgement:
use strict;
use warnings;
# choose_strategy.pl - turn four measured facts into a migration strategy.
# usage: perl choose_strategy.pl [--size-first] facts.tsv
#
# The THRESHOLDS are judgement, not measurement. They are named constants so
# that a team can argue about them in one place; section 6 shows how far
# each decision is from changing.
use constant {
STABLE_MAX => 2, # at most this many changes in 12 months = stable
MANY_READERS => 3, # this many consumer files or more = load-bearing
SMALL_MAX => 100, # at most this many code lines = small
};
my $size_first = @ARGV && $ARGV[0] eq '--size-first' ? shift : 0;
sub decide {
my ($f) = @_;
my $stable = $f->{changes} <= STABLE_MAX;
my $small_and_tested = $f->{code_lines} <= SMALL_MAX && $f->{tested} eq 'yes';
return 'REWRITE' if $size_first && $small_and_tested; # the WRONG order
if ($stable) {
return $f->{consumers} >= MANY_READERS ? 'WRAP' : 'LEAVE ALONE';
}
return $small_and_tested ? 'REWRITE' : 'INCREMENTAL';
}
sub first_step {
my ($strategy, $f) = @_;
return $strategy eq 'INCREMENTAL' && $f->{tested} eq 'no'
? 'write tests first'
: { 'LEAVE ALONE' => 'inventory it, change nothing',
'WRAP' => 'inventory it, give new readers a wrapper',
'REWRITE' => 'inventory it, rewrite against the inventory',
'INCREMENTAL' => 'convert one function at a time' }->{$strategy};
}
# what single fact, moved just across its threshold, would change the answer?
my @nudge = (
[ changes => sub { $_[0] <= STABLE_MAX ? STABLE_MAX + 1 : STABLE_MAX } ],
[ consumers => sub { $_[0] >= MANY_READERS ? MANY_READERS - 1 : MANY_READERS } ],
[ code_lines => sub { $_[0] <= SMALL_MAX ? SMALL_MAX + 1 : SMALL_MAX } ],
[ tested => sub { $_[0] eq 'yes' ? 'no' : 'yes' } ],
);
my ($header, @rows) = <>;
chomp $header;
my @col = split /\t/, $header;
my (@decided, @margin);
for my $row (@rows) {
chomp $row;
my %f;
@f{@col} = split /\t/, $row;
my $s = decide(\%f);
push @decided, sprintf "%-16s %-12s %s\n", $f{script}, $s, first_step($s, \%f);
for my $n (@nudge) {
my ($fact, $move) = @$n;
my %g = (%f, $fact => $move->($f{$fact}));
my $t = decide(\%g);
push @margin, sprintf "%-16s %-12s %-20s %s\n", $f{script}, $s,
"$fact $f{$fact}->$g{$fact}", $t if $t ne $s;
}
}
printf "%-16s %-12s %s\n", 'SCRIPT', 'STRATEGY', 'FIRST STEP';
print @decided;
printf "\n%-16s %-12s %-20s %s\n", 'SCRIPT', 'STRATEGY', 'WOULD CHANGE IF', 'TO';
print @margin;The case-study script, with its two reports, is part of the repository and of the wrapper in section 9:
#!/usr/bin/perl
# sta_summary.pl - summarise per-corner timing reports
# usage: sta_summary.pl [-v] [report_dir]
# prints one line per corner: paths, violations, WNS and TNS
#
# (A SAMPLE legacy script written for this module, in the style of
# scripts that have run unchanged in a flow for a decade.)
use Getopt::Long;
$verbose = 0;
GetOptions("v" => \$verbose);
$dir = shift @ARGV;
$dir = "." unless $dir;
opendir(D, $dir) || die "cannot open $dir: $!\n";
@files = grep(/\.rpt$/, readdir(D));
closedir(D);
if (!@files) {
print "No reports found in $dir\n";
exit 0;
}
foreach $f (@files) {
($corner = $f) =~ s/\.rpt$//;
open(F, "$dir/$f") || next;
while (<F>) {
if (/^Endpoint\s*:\s*(\S+)/) {
$ep = $1;
}
if (/slack \((\w+)\)\s+(\S+)/) {
$paths{$corner}++;
if ($1 eq "VIOLATED") {
$viol{$corner}++;
$tns{$corner} += $2;
push(@{$eps{$corner}}, $ep);
}
$wns{$corner} = $2 if $2 < $wns{$corner};
}
}
close(F);
}
open(CSV, ">tmp.csv");
$bad = 0;
foreach $c (sort keys %paths) {
printf("%-16s paths=%-3d viol=%-3d wns=%.3f tns=%.3f\n",
$c, $paths{$c}, $viol{$c}, $wns{$c}, $tns{$c});
print CSV "$c,$viol{$c},$wns{$c}\n";
if ($verbose) {
foreach $e (@{$eps{$c}}) { print " $e\n"; }
}
$bad++ if $viol{$c};
}
close(CSV);
exit $bad;Report : timing (synthetic teaching format)
Design : chip_top
Corner : ss_0p72v_125c
Startpoint: u_core/r_reg[3]
Endpoint : u_core/acc_reg[7]
slack (VIOLATED) -0.043
Startpoint: u_core/r_reg[1]
Endpoint : u_core/acc_reg[2]
slack (VIOLATED) -0.011
Startpoint: u_core/cnt_reg[0]
Endpoint : u_core/cnt_reg[5]
slack (MET) 0.120
End of reportReport : timing (synthetic teaching format)
Design : chip_top
Corner : tt_0p80v_25c
Startpoint: u_core/r_reg[3]
Endpoint : u_core/acc_reg[7]
slack (MET) 0.087
Startpoint: u_core/cnt_reg[0]
Endpoint : u_core/cnt_reg[5]
slack (MET) 0.240
End of report#!/bin/bash
# Measure the five sample scripts, then choose a strategy for each.
repo=$(mktemp -d)/flowrepo
bash make_repo.sh "$repo"
echo "--- measured facts, 12 months to 2026-10-13"
perl measure_scripts.pl "$repo" 2025-10-13 2026-10-13 > facts.tsv
column -t -s $'\t' facts.tsv
echo
perl choose_strategy.pl facts.tsv
echo
echo "--- the same facts with the size rule checked FIRST"
diff <(perl choose_strategy.pl facts.tsv | sed -n '2,6p') \
<(perl choose_strategy.pl --size-first facts.tsv | sed -n '2,6p')--- measured facts, 12 months to 2026-10-13
script code_lines tested changes consumers
fmt_table.pl 3 yes 1 1 pretty.sh
gen_filelist.pl 59 yes 5 1 Makefile
parse_cov.pl 229 yes 5 1 cov_view.sh
run_regress.pl 413 no 12 4 Makefile,mail_fails.sh,rerun.sh,status.sh
sta_summary.pl 44 no 0 3 Makefile,post_wns.sh,weekly_digest.pl
SCRIPT STRATEGY FIRST STEP
fmt_table.pl LEAVE ALONE inventory it, change nothing
gen_filelist.pl REWRITE inventory it, rewrite against the inventory
parse_cov.pl INCREMENTAL convert one function at a time
run_regress.pl INCREMENTAL write tests first
sta_summary.pl WRAP inventory it, give new readers a wrapper
SCRIPT STRATEGY WOULD CHANGE IF TO
fmt_table.pl LEAVE ALONE changes 1->3 REWRITE
fmt_table.pl LEAVE ALONE consumers 1->3 WRAP
gen_filelist.pl REWRITE changes 5->2 LEAVE ALONE
gen_filelist.pl REWRITE code_lines 59->101 INCREMENTAL
gen_filelist.pl REWRITE tested yes->no INCREMENTAL
parse_cov.pl INCREMENTAL changes 5->2 LEAVE ALONE
parse_cov.pl INCREMENTAL code_lines 229->100 REWRITE
run_regress.pl INCREMENTAL changes 12->2 WRAP
sta_summary.pl WRAP changes 0->3 INCREMENTAL
sta_summary.pl WRAP consumers 3->2 LEAVE ALONE
--- the same facts with the size rule checked FIRST
1c1
< fmt_table.pl LEAVE ALONE inventory it, change nothing
---
> fmt_table.pl REWRITE inventory it, rewrite against the inventoryRead the five decisions as five different situations.
fmt_table.pl: LEAVE ALONE. Tiny and tested, so a rewrite would be easy, but it changed once in a year and one program reads it. There is nothing to gain. Easy to rewrite is not the same as worth rewriting.gen_filelist.pl: REWRITE. It changes about every two months, it is 59 lines, and it has tests. This is the case Chapter 28.3 described where a rewrite is right: the change is coming anyway, and the inventory is small enough to check.parse_cov.pl: INCREMENTAL. Tested and actively changing, but 229 lines. Converting it one function at a time keeps every intermediate state working.run_regress.pl: INCREMENTAL, tests first. Twelve changes a year, four consumers, 413 lines and no tests. This is the regression driver of Chapter 16.7, and the first step is not converting anything. It is building the test harness that makes conversion checkable.sta_summary.pl: WRAP. It has not changed in the window at all, but three programs read its outputs. Leave its behaviour exactly as it is, and stop the circle of readers growing by giving new callers a wrapper.
7. How Far Each Decision Is From Changing
A decision that would flip if one fact moved a little is a weak decision. The second table in the output checks this. For each script it moves each fact just across its threshold and reports any move that changes the answer.
Two of the rows matter most.
sta_summary.pl is one consumer away from LEAVE ALONE (consumers 3->2). If weekly_digest.pl were retired, the recommendation would be to leave the script entirely alone. That is worth knowing before spending a week on a wrapper: it may be cheaper to remove a consumer than to wrap the producer.
run_regress.pl changes only if its change rate falls from 12 to 2. No size or test fact flips it, because it is large and untested and changing constantly. That is a robust decision, and the one to defend most firmly when someone proposes "just leaving the regression driver alone".
Two rows are close for a different reason. fmt_table.pl would become a REWRITE at three changes a year, and gen_filelist.pl a LEAVE ALONE at two. Those are scripts near the stable/changing line. For them, the right question is not "which strategy?" but "is that change rate going to continue?", and the git log alone cannot answer it.
8. Rule Order Matters
The last part of run_strategy.sh runs the same facts through a version of the rule that checks "small and tested" first, before change rate, and shows which decisions change. One does. fmt_table.pl goes from LEAVE ALONE to REWRITE.
That is the mistake behind many unnecessary rewrites. "It is small and it has tests, so let's rewrite it" sounds responsible. But the script changed once in a year and has one reader, so the rewrite buys nothing and still carries every risk of Chapter 28.3. Asking "does anyone pay for this script's current shape?" first is what prevents it.
9. What Wrap Looks Like on Day One
sta_summary.pl came out as WRAP. Here is that strategy as code: a Python wrapper that calls the legacy script, unchanged, and offers new consumers a documented interface. It returns JSON with an explicit schema (the hand-off discipline of Chapter 26.3), a three-valued exit status, and no side effects in the caller's directory.
#!/usr/bin/env python3
"""sta_summary_json.py - a documented interface IN FRONT OF sta_summary.pl.
The legacy script is not changed, and its existing consumers keep calling it
directly. NEW consumers call this wrapper instead and get:
STDOUT one JSON object: {"schema": 1, "corners": [...]}
wns is null when the legacy script did not record one
exit 0 every corner met timing
1 at least one corner violated
2 no summary could be produced (missing or empty directory)
The legacy script runs in a private scratch directory, so its tmp.csv side
effect can never land in the caller's working directory.
"""
import json
import os
import re
import subprocess
import sys
import tempfile
LEGACY = os.path.join(os.path.dirname(os.path.abspath(__file__)), "sta_summary.pl")
LINE = re.compile(r"^(\S+)\s+paths=(\d+)\s+viol=(\d+)\s+wns=\S+\s+tns=(\S+)$")
def main():
report_dir = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else ".")
with tempfile.TemporaryDirectory() as scratch:
run = subprocess.run(["perl", LEGACY, report_dir], cwd=scratch,
capture_output=True, text=True)
csv_path = os.path.join(scratch, "tmp.csv")
csv = {}
if os.path.exists(csv_path): # absent = no summary was made
with open(csv_path) as fh:
for row in fh.read().splitlines():
corner, viol, wns = row.split(",")
csv[corner] = wns
# The legacy exit status is ambiguous: 2 means "two corners violated" OR
# "could not open the directory" (errno 2). Decide from the files instead.
if not csv:
msg = run.stderr.strip() or run.stdout.strip() or "no summary produced"
print(f"sta_summary_json: {msg}", file=sys.stderr)
return 2
corners = []
for line in run.stdout.splitlines():
m = LINE.match(line)
if m:
name, paths, viol, tns = m.groups()
wns = csv.get(name, "")
corners.append({"corner": name, "paths": int(paths),
"violations": int(viol),
"wns": float(wns) if wns else None,
"tns": float(tns)})
print(json.dumps({"schema": 1, "corners": corners}, indent=1))
return 1 if any(c["violations"] for c in corners) else 0
if __name__ == "__main__":
sys.exit(main())Three design decisions in it come straight from the inventory of Chapter 28.2.
It runs the legacy script in a private scratch directory. That contains B5: the tmp.csv side effect lands in the wrapper's temporary directory and is read from there. It can never overwrite a file in the caller's directory, and it can never be yesterday's file.
It reports wns as null when the legacy script did not record one. B4 means a clean corner's real worst slack is unknown to the old script. The wrapper does not invent 0.0 for it. It says "not recorded", which is the truth, and which a JSON consumer can test for.
It does not trust the legacy exit status. exit $bad is a count, and a missing directory exits with errno 2, so exit 2 means either "two corners violated" or "the directory does not exist". The wrapper cannot tell those apart from the number. It decides from the files instead: no tmp.csv means no summary was produced.
#!/bin/bash
# The wrapper against the legacy script, side by side, in a scratch caller dir.
here=$(pwd)
work=$(mktemp -d)
cp -R reports "$work"/
cd "$work" || exit 1
echo "=== new consumer: the wrapper"
python3 "$here/sta_summary_json.py" reports; echo "exit=$?"
echo "tmp.csv in the caller's directory: $([ -e tmp.csv ] && echo yes || echo no)"
echo "=== old consumer: the legacy script, unchanged"
perl "$here/sta_summary.pl" reports > /dev/null; echo "exit=$?"
echo "tmp.csv in the caller's directory: $([ -e tmp.csv ] && echo yes || echo no)"
echo "=== the ambiguous status: two violating corners vs a missing directory"
mkdir two && cp reports/ss_0p72v_125c.rpt two/a.rpt && cp reports/ss_0p72v_125c.rpt two/b.rpt
for d in two nosuch; do
perl "$here/sta_summary.pl" "$d" > /dev/null 2>&1; legacy=$?
python3 "$here/sta_summary_json.py" "$d" > /dev/null 2>&1; wrapped=$?
echo " $d: legacy exit $legacy, wrapper exit $wrapped"
done
echo "=== 256 failing corners"
mkdir many
for i in $(seq -w 1 256); do cp reports/ss_0p72v_125c.rpt many/c$i.rpt; done
perl "$here/sta_summary.pl" many > /dev/null; echo " legacy exit $?"
python3 "$here/sta_summary_json.py" many > /dev/null; echo " wrapper exit $?"=== new consumer: the wrapper
{
"schema": 1,
"corners": [
{
"corner": "ss_0p72v_125c",
"paths": 3,
"violations": 2,
"wns": -0.043,
"tns": -0.054
},
{
"corner": "tt_0p80v_25c",
"paths": 2,
"violations": 0,
"wns": null,
"tns": 0.0
}
]
}
exit=1
tmp.csv in the caller's directory: no
=== old consumer: the legacy script, unchanged
exit=1
tmp.csv in the caller's directory: yes
=== the ambiguous status: two violating corners vs a missing directory
two: legacy exit 2, wrapper exit 1
nosuch: legacy exit 2, wrapper exit 2
=== 256 failing corners
legacy exit 0
wrapper exit 1The demonstration shows the point of the strategy. New consumers get structured data, an honest null, a clean working directory, and an exit status that means one thing: 1 for two violating corners, 2 for a missing directory, and 1 (not 0) for 256 failing corners. Old consumers get exactly what they had before, tmp.csv included, because nothing they call has changed. The legacy behaviour is frozen, and every new reader is added to the wrapper instead of to the script's blast radius.
The cost is real, and it belongs in the decision. There are now two interfaces, and the wrapper depends on the legacy STDOUT format (its LINE pattern). That dependency is exactly why the inventory matters: B1 is now load-bearing for the wrapper too, and any later change to the old script must be checked against both.
10. Mistakes When Choosing
Choosing by how much you dislike the code. Ugliness is not one of the four facts. sta_summary.pl is the ugliest script in the repository and the decision was to keep its behaviour exactly.
Estimating instead of measuring. "It changes all the time" and "nobody uses that" are both checkable in a minute with git log and a search. The measurement in section 4 took less time than the argument usually does.
Trusting a zero. The first measurement reported zero consumers for a script that had one. Check the "safe" readings by hand.
Asking about size first. It turned one LEAVE ALONE into an unnecessary REWRITE.
Ignoring the margin. A decision one consumer away from changing deserves a different conversation from one that would need ten fewer commits a year.
Treating the strategy as permanent. The facts change. A wrapped script that starts changing every month crosses into INCREMENTAL. Re-run the measurement when you plan each quarter.
11. Exercises
1. Your own five. Point measure_scripts.pl at a real repository (adjust the tools/, flow/ and t/ paths) and run choose_strategy.pl on the result. Which decision surprised you, and which fact caused it? Check that fact by hand.
2. Retire a consumer. In the sample repository, delete flow/bin/weekly_digest.pl and re-run. Confirm that sta_summary.pl moves to LEAVE ALONE. Then argue whether retiring the digest or writing the wrapper is the cheaper action, and what you would need to know to decide.
3. A fifth strategy? Some teams add "delete it": the script's output has no remaining consumers. Add a rule for it to choose_strategy.pl. Where in the order must it go, and what fact would you need that the four do not give you?
4. Break the wrapper. Change the legacy script's printf format by one space and run wrap_demo.sh again. What does the wrapper report, and what should it report? Change the wrapper so that a format it does not recognise produces exit 2 and a message, instead of a JSON object with fewer corners.
5. Weighting the facts. Replace the rule with a weighted score (like Chapter 16.7's) and compare its ranking with the rule's decisions for the five scripts. Which do you trust more, and why is a rule with explicit thresholds easier to argue about than a score?
12. What To Remember
- Four strategies: leave alone, wrap, incremental, rewrite. Define each by what it changes and what it leaves alone.
- Leave alone still means taking the inventory. Wrap means a new interface for new callers, with the old script untouched.
- Four facts decide: size, tests, change rate and blast radius, all measurable.
- Change rate is asked first. A script nobody edits is cheap to keep whatever it looks like.
- Stable and read by many → wrap. Changing, small and tested → rewrite. Changing otherwise → incremental, tests first if there are none.
- The thresholds are judgement; keep them as named constants in one place.
- Measure the facts from git and the flow tree. Here they took one script and a few seconds.
- Measured: the first version undercounted consumers by missing three-argument
open, and made a risky script look safe. - Look at the margin:
sta_summary.plwas one consumer from LEAVE ALONE, andrun_regress.plwould need its change rate to fall from 12 to 2. - Measured: asking about size first turned 1 of 5 decisions into an unnecessary rewrite.
- A wrapper contains side effects, refuses to invent values, and resolves ambiguous exit statuses, while every old caller sees no change.
- The facts change, so re-measure when planning, not once.
Chapter 28.5 takes the strategy that two of the five scripts landed on, incremental conversion, and does it: one function at a time, with the Perl and Python versions running side by side against the same input until they agree.
Where this fits
Part of the Perl curriculum.
