DEV Community

Dakota Huang
Dakota Huang

Posted on

Pin a Resolver Call Matrix Before One Extract

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]
Enter fullscreen mode Exit fullscreen mode

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, ...]
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Run the collector once on a fixture tree. Read the printed rows with care. Those rows become the pinned contract.

python characterize_resolver.py
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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)