Do not start a messy-repo refactor with a rewrite. Pin a public resolver call matrix before any edit. Then extract one scheme splitter and stop.
A tangled resolver mixes schemes, environment, and cache. A full rewrite can drift silent path outputs. A frozen call matrix will catch that drift.
The failure mode
Messy modules hide three behaviors in one function. Scheme parsing sits next to the path-join logic. A process-wide cache mutates across repeated calls.
One cleanup commit can change all three. Reviewers then argue about naming and style. Shallow tests still pass on type checks alone.
Users can see different paths on Windows. Row equality does not care about taste. It only cares about the recorded contract.
What you freeze
Freeze inputs, outputs, and cache keys together. Do not freeze source layout or private names. The public contract is the recorded matrix.
Store the matrix as a list of rows. Each row is a structured record. Compare records with ordinary equality, not vibes.
This suite is characterization, not a design proof. It records current behavior in durable form. It does not claim the behavior is ideal.
Worked example: a messy resolver
The module below is a lab fixture. Treat every path as a proposal. Do not paste it into production code.
# resolver.py
from __future__ import annotations
import os
from typing import Dict, List
_cache: Dict[str, str] = {}
def reset_cache() -> None:
_cache.clear()
def cache_keys() -> List[str]:
return sorted(_cache.keys())
def resolve(spec: str, root: str | None = None) -> str:
if root is None:
root = os.environ.get("APP_ROOT", ".")
root = str(root)
if spec.startswith("file://"):
spec = spec[7:]
elif spec.startswith("env://"):
key = spec[6:]
spec = os.environ.get(key, "")
if os.path.isabs(spec):
out = spec
else:
out = os.path.join(root, spec)
out = os.path.normpath(out).replace("\\", "/")
if out not in _cache:
_cache[out] = out
return _cache[out]
The function handles four concerns at once. Scheme stripping. Environment lookup. Path joining. In-process memoization.
Step 1 — Define the row type
Name the fields before writing cases. Keep names boring and stable. Changing field names is an API change.
# matrix_types.py
from __future__ import annotations
from typing import NamedTuple, Tuple
class ResolverRow(NamedTuple):
case: str
spec: str
root: str | None
env: Tuple[Tuple[str, str], ...]
result: str
keys: Tuple[str, ...]
The env field is a sorted tuple. Dictionary order will not leak. Cache keys are also sorted tuples.
Step 2 — Drive the module through fixtures
Create a tiny environment for each case. Reset the cache before every row. Six cases cover the current branches.
# characterize_resolver.py
from __future__ import annotations
import json
import os
from contextlib import contextmanager
from pathlib import Path
from typing import Dict, Iterator, List
from matrix_types import ResolverRow
from resolver import cache_keys, reset_cache, resolve
CASES = [
("relative", "data/a.txt", None, {"APP_ROOT": "/app"}),
("absolute", "/tmp/b.txt", None, {"APP_ROOT": "/app"}),
("file_scheme", "file://rel/c.txt", None, {"APP_ROOT": "/app"}),
("env_scheme", "env://SRC", None, {"APP_ROOT": "/app", "SRC": "d.txt"}),
("root_arg", "e.txt", "/override", {"APP_ROOT": "/app"}),
("repeat_relative", "data/a.txt", None, {"APP_ROOT": "/app"}),
]
@contextmanager
def env_ctx(pairs: Dict[str, str]) -> Iterator[None]:
old = os.environ.copy()
os.environ.clear()
os.environ.update(pairs)
try:
yield
finally:
os.environ.clear()
os.environ.update(old)
def collect_rows() -> List[ResolverRow]:
reset_cache()
rows: List[ResolverRow] = []
for case, spec, root, env in CASES:
with env_ctx(env):
result = resolve(spec, root=root)
keys = tuple(cache_keys())
env_t = tuple(sorted(env.items()))
rows.append(
ResolverRow(case, spec, root, env_t, result, keys)
)
return rows
def dump_failure(path: Path, rows: List[ResolverRow]) -> None:
payload = [row._asdict() for row in rows]
path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
if __name__ == "__main__":
for row in collect_rows():
print(row)
Run the collector once on a fixture tree. Read the printed rows with care. Those rows become the pinned contract.
python characterize_resolver.py
Step 3 — Pin the expected rows
Copy the collected rows into a test module. Keep them as literals, not generators. A reader must see the contract.
# test_resolver_matrix.py
from __future__ import annotations
from pathlib import Path
from characterize_resolver import collect_rows, dump_failure
from matrix_types import ResolverRow
EXPECTED = [
ResolverRow(
case="relative",
spec="data/a.txt",
root=None,
env=(("APP_ROOT", "/app"),),
result="/app/data/a.txt",
keys=("/app/data/a.txt",),
),
ResolverRow(
case="absolute",
spec="/tmp/b.txt",
root=None,
env=(("APP_ROOT", "/app"),),
result="/tmp/b.txt",
keys=("/app/data/a.txt", "/tmp/b.txt"),
),
ResolverRow(
case="file_scheme",
spec="file://rel/c.txt",
root=None,
env=(("APP_ROOT", "/app"),),
result="/app/rel/c.txt",
keys=("/app/data/a.txt", "/app/rel/c.txt", "/tmp/b.txt"),
),
ResolverRow(
case="env_scheme",
spec="env://SRC",
root=None,
env=(("APP_ROOT", "/app"), ("SRC", "d.txt")),
result="/app/d.txt",
keys=(
"/app/d.txt",
"/app/data/a.txt",
"/app/rel/c.txt",
"/tmp/b.txt",
),
),
ResolverRow(
case="root_arg",
spec="e.txt",
root="/override",
env=(("APP_ROOT", "/app"),),
result="/override/e.txt",
keys=(
"/app/d.txt",
"/app/data/a.txt",
"/app/rel/c.txt",
"/override/e.txt",
"/tmp/b.txt",
),
),
ResolverRow(
case="repeat_relative",
spec="data/a.txt",
root=None,
env=(("APP_ROOT", "/app"),),
result="/app/data/a.txt",
keys=(
"/app/d.txt",
"/app/data/a.txt",
"/app/rel/c.txt",
"/override/e.txt",
"/tmp/b.txt",
),
),
]
def test_resolver_call_matrix() -> None:
actual = collect_rows()
if actual != EXPECTED:
dump_failure(Path("resolver_matrix.actual.json"), actual)
assert actual == EXPECTED
Run pytest after the pin. The suite must fail on any row change. Cache growth across cases is part of the contract.
pytest -q test_resolver_matrix.py
Dump JSON only when equality fails. Diff the JSON, not your memory. The test still asserts on structured rows.
Step 4 — Extract one splitter only
Do not extract the cache in this commit. Do not extract path joins in this commit. Extract scheme parsing and nothing else.
# proposed extract inside resolver.py
from typing import Tuple
def _split_scheme(spec: str) -> Tuple[str, str]:
if spec.startswith("file://"):
return "file", spec[7:]
if spec.startswith("env://"):
return "env", spec[6:]
return "path", spec
def resolve(spec: str, root: str | None = None) -> str:
if root is None:
root = os.environ.get("APP_ROOT", ".")
root = str(root)
kind, rest = _split_scheme(spec)
if kind == "file":
spec = rest
elif kind == "env":
spec = os.environ.get(rest, "")
else:
spec = rest
if os.path.isabs(spec):
out = spec
else:
out = os.path.join(root, spec)
out = os.path.normpath(out).replace("\\", "/")
if out not in _cache:
_cache[out] = out
return _cache[out]
Re-run the matrix after the extract. Every row must match the pinned literals. If one row drifts, revert the extract.
Step 5 — Decide with a change table
Use the table before you open a second diff. If a change sits outside row one, stop. That work belongs in another commit.
| Change | Matrix should | Put in this commit |
|---|---|---|
Extract _split_scheme
|
Stay equal | Yes |
| Replace the global cache | Likely change | No |
Switch joins to pathlib
|
Likely change | No |
Rename public resolve
|
Fail on purpose | No |
Drop file:// support |
Fail on purpose | No |
Only the first row is a safe extract. The other rows change the contract. Changing the contract needs new expected rows first.
Where a coding model may help
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
A model can draft _split_scheme after the matrix is green. It should not invent a new cache. It should not rewrite path joins in the same diff.
MonkeyCode offers free model access and a free server option. Feed the model the characterization file and the table. Ask for one helper that keeps every row equal.
If the matrix already passes locally, run it on that free server and compare rows. A second machine will expose path-separator surprises. Reject any diff that touches _cache.
Limitations
This method ignores performance and allocation cost. It ignores thread safety around the global cache. The cache is still a process-wide dict.
Do not use this when the public contract must change. Do not use this for security-sensitive path sandboxing. Characterization can freeze a path traversal bug.
Windows and POSIX strings can still diverge. The fixture forces slash normalization in one place. Per-OS expected files are safer for native separators.
The matrix does not explain a failure by itself. Keep the JSON dump on disk after a mismatch. Read the first differing case field, then revert.
Repeated cases share cache state on purpose. That is a feature of this fixture. It is also a reason to reset between unrelated tests.
Who should skip this
Skip this if the module has no remaining callers. Skip this if you can delete the module today. Skip this if outputs are non-deterministic by design.
Skip this if you need a semantic rewrite now. A matrix will fight that larger change. Write new tests for the new contract instead.
Skip this if scheme parsing is already a pure function. There is nothing to extract in that case. Spend the time on a real boundary.
After the extract
Keep the matrix until the module is boring. Then replace rows with explicit unit tests. Characterization is a bridge, not the destination.
One extract per commit remains the rule. One row list per extract remains the check. That is the whole method.
Top comments (0)