schmonz.com is a Fediverse instance that uses the ActivityPub protocol. In other words, users at this host can communicate with people that use software like Mastodon, Pleroma, Friendica, etc. all around the world.

This server runs the snac software and there is no automatic sign-up process.

Search results for tag #Refactoring

#refactoring boosted

[?]Hack a Day (unofficial) » 🤖 🌐
@hackaday@www.urbanmind.net

You search for a component. There are imports. There are tests. Nothing looks broken. Then you...
Can I Delete This Code? A Practical Dead Code Audit with AI

    #refactoring boosted

    [?]Hack a Day (unofficial) » 🤖 🌐
    @hackaday@www.urbanmind.net

    The big bang rewrite is the most confident decision an engineering team can make and one of the worst. The pitch is always reasonable. The current system is slow to change, everybody agrees it is slow to change, so we pause feature work, rebuild it properly, and come back in six months with something maintainable. What actually happens is that month four arrives, the business needs a feature the old system can still deliver and the new one cannot, and the rewrite is either shipped half finished or quietly abandoned. Now you have two systems to maintain instead of one.

    That risk goes up when the thing you are replacing is not on an older framework. Migrating Symfony 2 to Laravel is unpleasant but legible. There are controllers, there is routing, there is a service container, and you can map concepts across. A procedural monolith gives you none of that. You get global state read straight out of $_GET and $_POST, mysqli_query calls interleaved with HTML, header('Location: ...') scattered through business logic, and includes that pull in half the application as a side effect. There is no seam to grab hold of, which is exactly why people conclude the only option is to start again.

    There is another option. Put Laravel in front of the old application, migrate one route at a time, and let the procedural code carry on serving everything you have not reached yet. This is the strangler fig pattern, named after the vine that grows around a host tree, gradually takes over its structural role, and leaves a hollow shell behind. The important detail is that at no point during that process is the tree unable to stand.

    Laravel as the front door


    The first move is deciding who owns the incoming request. It has to be Laravel, and it has to be Laravel from day one, even when Laravel is handling exactly zero routes.

    The web server serves Laravel's public directory as the document root. Anything Laravel does not recognise falls through to the legacy entry point. As you migrate an endpoint, it stops falling through, and nothing about the client's URL changes.

    Request
    |
    v
    Nginx -> Laravel public/index.php
    |
    +-- route matched? yes -> Laravel handles it
    |
    +-- no -> fallback to legacy/index.php

    In Nginx that looks roughly like this:

    server {
    listen 80;
    server_name my-app.test;
    root /var/www/my-app/laravel/public;

    index index.php;

    location / {
    try_files $uri $uri/ /index.php?$query_string;
    }

    location /legacy/ {
    root /var/www/my-app;
    try_files $uri $uri/ @legacy;

    location ~ ^/legacy/.+\.php$ {
    root /var/www/my-app;
    try_files $fastcgi_script_name =404;

    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
    }
    }

    location @legacy {
    rewrite ^/legacy/(.*)$ /legacy/index.php?page=$1 last;
    }

    location ~ ^/index\.php(/|$) {
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
    }
    }

    Two details in there are worth calling out, because both of them have cost me time.

    The first is that the legacy block uses root rather than alias. Reaching for alias is the obvious move when you are mapping a URL prefix onto a directory that sits outside your document root, and it does not work with try_files. This is nginx ticket 97, open since 2010. The value of $uri is not rewritten by alias, so try_files $uri re-appends the location prefix and goes looking for /var/www/my-app/legacy/legacy/whatever. It fails, silently, in a way that looks like the fallback is broken rather than the path. If you can arrange your directories so the legacy application sits under a shared parent, root sidesteps the whole thing. If you cannot, use a regex location with a capture and build the path yourself rather than relying on $uri.

    The second is try_files $fastcgi_script_name =404 in the PHP block, and the narrowed ^/index\.php(/|$) pattern on the Laravel side. A bare location ~ \.php$ that passes anything ending in .php to FPM is the configuration behind a long history of remote code execution reports, because a request for /uploads/avatar.jpg/x.php can end up executing an uploaded file. Legacy applications tend to have upload directories inside the web root, which is exactly the situation where this matters.

    The alternative approach, and the one I reach for when the legacy URL structure is chaotic enough that server config becomes unreadable, is to do the fallback inside Laravel itself using a catch all route that proxies to the old application. It is slower, because every legacy request now boots the framework before being handed off, but it puts the routing table in version control where you can read it, test it, and log it. On a system with meaningful traffic that trade is usually worth making for the first few months and worth reversing later.

    Either way, the property you want is the same. There is one place that decides where a request goes, and migrating an endpoint is a one line change in that place.

    The part that actually blocks people: sessions


    Routing is straightforward. Authentication is where these migrations stall.

    Procedural applications call session_start() and write to $_SESSION['user_id']. Laravel has its own session handling, its own cookie, its own encryption, and its own idea of what an authenticated user is. If you do nothing, a user who logs in through the old system hits a migrated Laravel route and gets bounced to a login form. Twice logged in is not a migration, it is an outage with extra steps.

    The advice you will find most often is to make both applications share a session store. I want to talk you out of it, because it is harder than it sounds and the reason is not obvious.

    PHP does not ship a database session handler. The values session.save_handler accepts are files, redis, memcached, and user, and user means you write a class implementing SessionHandlerInterface and register it with session_set_save_handler(). So step one is already a bespoke class in the codebase you were hoping not to touch.

    Step two is worse. Laravel's database session driver does not store a native PHP session blob. It stores base64_encode(serialize($attributes)) in a payload column, alongside id, user_id, ip_address, user_agent, and last_activity. And Laravel does not consider you logged in because a user_id exists somewhere in that payload. SessionGuard looks for a key named login_ plus the guard name plus a SHA1 of the guard class, which for the default web guard is a string like login_web_59ba36addc2b2f9401580f014c7f58ea4e30989d. For your legacy handler to produce a session Laravel accepts, it has to reproduce that format and that key exactly. It can be done. It is not the small mechanical change it appears to be, and it couples the old application to Laravel internals that are not part of the public API.

    So there are really two honest options.

    The first, and the one I now reach for by default, is to invert the problem. Make authentication the very first thing you migrate. Laravel owns login, logout, and the session, and the legacy application is changed to read whatever Laravel writes. That is a smaller change to the old codebase than a custom save handler, because a procedural app checking $_SESSION['user_id'] can be pointed at a single shared function that reads Laravel's session or a signed cookie instead. You do the awkward work once, at the start, and every subsequent migration inherits a working auth context.

    The second, for when login genuinely cannot move yet, is to read the legacy session yourself in middleware:

    namespace App\Http\Middleware;

    use App\Models\User;
    use Closure;
    use Illuminate\Http\Request;
    use Illuminate\Support\Facades\Auth;
    use Illuminate\Support\Facades\DB;
    use Symfony\Component\HttpFoundation\Response;

    final class BridgeLegacySession
    {
    public function handle(Request $request, Closure $next): Response
    {
    if (Auth::check()) {
    return $next($request);
    }

    $cookie = config('legacy.session_cookie', 'PHPSESSID');

    if (! $request->hasCookie($cookie)) {
    return $next($request);
    }

    $session = DB::connection('legacy')
    ->table('sessions')
    ->where('id', $request->cookie($cookie))
    ->first();

    if (! $session) {
    return $next($request);
    }

    $userId = $this->extractUserId($session->data);

    if ($userId && $user = User::find($userId)) {
    Auth::login($user);
    }

    return $next($request);
    }

    private function extractUserId(string $data): ?int
    {
    $vars = [];
    $offset = 0;

    while ($offset < strlen($data)) {
    $delimiter = strpos($data, '|', $offset);

    if ($delimiter === false) {
    break;
    }

    $name = substr($data, $offset, $delimiter - $offset);
    $offset = $delimiter + 1;

    $value = unserialize(substr($data, $offset));

    $vars[$name] = $value;
    $offset += strlen(serialize($value));
    }

    return $vars['user_id'] ?? null;
    }
    }

    That parser exists because PHP's default session serialisation format is not the same as serialize() output. It is a sequence of name|serialized_value pairs, and crucially the name carries no length prefix, which is why you cannot hand the whole thing to unserialize() and have to walk it byte by byte instead.

    Be clear about where this is fragile, because the obvious guess is wrong. A pipe character inside a session value is fine, since values are properly serialised and the loop advances by strlen(serialize($value)) rather than searching for the next delimiter. What breaks it is a pipe character in a session key, which is legal and which nothing in the old codebase is stopping anyone from writing. The other soft spot is calling unserialize() on a substring that has trailing data after the value you want. That works, but how loudly PHP complains about it has changed across versions, so check the behaviour on the version you are actually deploying rather than trusting mine or anyone else's.

    There is a third failure mode with no workaround: if the session stores a serialised object whose class does not exist inside your Laravel application, unserialize() hands you a __PHP_Incomplete_Class and anything downstream that touches it throws.

    So write this if you must, wrap the unserialize() call in error handling, log every failure loudly with the session id, and treat it as a temporary bridge you intend to delete rather than infrastructure you intend to keep. Register it in bootstrap/app.php on the web group and every migrated route gets a populated $request->user() for free.

    Also set session.serialize_handler explicitly in the legacy application rather than assuming it. The default is php, which is what the parser above expects, but a codebase old enough to need this migration is old enough for someone to have changed it to php_binary for reasons nobody remembers.

    Pointing Eloquent at a schema you did not design


    Procedural codebases have their own naming conventions, and those conventions are usually somebody's personal system from 2011. Tables prefixed tbl_, primary keys called usr_id_pk, timestamps stored as unix integers in a column named dt_created, and a deleted flag that is sometimes 0 and sometimes NULL depending on which script wrote the row.

    The instinct is to fix the schema first. Do not. Schema migration is the last thing you do, not the first, because for the entire migration period both applications are reading and writing the same tables. Eloquent is perfectly happy to be told what the columns are called:

    namespace App\Models;

    use Illuminate\Foundation\Auth\User as Authenticatable;

    final class User extends Authenticatable
    {
    protected $connection = 'legacy';

    protected $table = 'tbl_users';

    protected $primaryKey = 'usr_id_pk';

    public const CREATED_AT = 'usr_created_dt';

    public const UPDATED_AT = 'usr_updated_dt';

    protected $fillable = [
    'usr_email',
    'usr_password',
    'usr_name',
    ];

    protected function casts(): array
    {
    return [
    'usr_created_dt' => 'timestamp',
    'usr_updated_dt' => 'timestamp',
    ];
    }

    public function getAuthPassword(): string
    {
    return $this->attributes['usr_password'];
    }
    }

    The casts are not optional decoration. Naming the columns through CREATED_AT and UPDATED_AT tells Eloquent which columns to touch on write, but it does not tell it how to read them. If the legacy application stores unix integers rather than a datetime type, you need the timestamp cast or every date comparison you write will be doing string maths against an integer and quietly producing nonsense.

    Two other things to check on a legacy table. If the primary key is a string or a UUID stored as a char column, set $incrementing to false and $keyType to 'string', because Eloquent assumes an auto incrementing integer and will cast your key out from under you. And if the table has no timestamp columns at all, which is common on lookup and join tables, set $timestamps to false or every write will fail on a column that does not exist.

    You can go further and hide the naming entirely behind accessors, so the rest of your Laravel code never learns that usr_email exists:

    use Illuminate\Database\Eloquent\Casts\Attribute;

    protected function email(): Attribute
    {
    return Attribute::make(
    get: fn (): string => $this->usr_email,
    );
    }

    That is worth doing for the columns you touch most, because it means the day you finally rename the underlying column, you change one accessor rather than three hundred call sites. The model becomes a translation layer between the schema you inherited and the domain language you actually want.

    One caveat on passwords, and it is not solved by getAuthPassword() alone. Legacy applications hash with whatever was fashionable at the time, and that is frequently MD5 or an unsalted SHA1. Laravel's Hash::check() will not return false against those, it will fail outright, because it is handing a bcrypt hash string to password_verify(). You need to detect the legacy format and compare it yourself:

    if (str_starts_with($user->getAuthPassword(), '$2y$')) {
    $valid = Hash::check($plain, $user->getAuthPassword());
    } else {
    $valid = hash_equals($user->getAuthPassword(), md5($plain));
    }

    if ($valid && Hash::needsRehash($user->getAuthPassword())) {
    $user->forceFill([
    'usr_password' => Hash::make($plain),
    ])->save();
    }

    Use hash_equals() rather than a plain comparison so you are not leaking timing information, and rehash on every successful login so users migrate to bcrypt transparently as they come back. After a few months the legacy branch covers almost nobody, and you can force a reset for the stragglers and delete it. Do note that this rewrites the password column that the old application is still reading, so the legacy login code has to understand bcrypt before you turn this on. That ordering is easy to get backwards and locks people out when you do.

    Choosing what to migrate, in what order


    With ingress, sessions, and models in place, the strangling can begin. The order matters more than the pace.

    Start with things that have no user interface. Scheduled jobs, webhook receivers, report exports, anything that runs on a cron and writes to a table. These have clear inputs and outputs, no session state, and no visual regression risk. Rewriting one as an Artisan command is a contained afternoon's work, and it proves the plumbing works before anything user facing depends on it.

    Then take read only pages. A listing screen or a detail view is low risk because getting it wrong shows up immediately and reverts cleanly. This is also where you establish the patterns the rest of the migration will follow, so spend the time getting the structure right rather than racing.

    Write paths come next, and they come one at a time. A form that creates an order, a checkout flow, a settings update. These are where the business logic lives, which means they are also where the undocumented behaviour lives. Before you rewrite one, read the old code properly and write down every side effect you find, including the ones that look like bugs. Some of them are bugs. Some of them are load bearing, and the difference is a conversation with the business, not a judgement call you make alone.

    Views come last, or rather views come alongside whatever you are already migrating. Replacing include 'header.php' with a Blade layout is not a project in itself, it is a thing that happens naturally as each page moves.

    The step everybody skips is the final one. When a route is fully covered by Laravel, delete the legacy file and remove its fallback directive. If you do not delete it, it stays there, and eighteen months later nobody is sure whether it is still reachable. Deletion is the only proof that a migration finished.

    Knowing whether it is working


    A strangler migration that has no way of measuring progress will run forever, because there is always something more urgent than the next slice. Two numbers keep it honest.

    The first is the count of requests still hitting the legacy fallback. Log it, graph it, and put the graph somewhere the team sees it. A line trending towards zero is the most persuasive migration status report you will ever write.

    The second is the number of legacy files remaining. It is crude, and file size varies wildly, but it moves in one direction and it is impossible to argue with.

    The reason this pattern works is not technical elegance. It works because it never asks the business to accept a period where nothing ships. Every week you deliver something, and every week a little more of the old system is gone. Nobody has to hold their nerve for six months, because nobody is ever more than a week from the next visible improvement.

    That is the whole trick. Not a heroic rewrite, just a slow, boring, relentless narrowing of what the old code is still allowed to do.
    Strangling Procedural Legacy PHP into Laravel

      #refactoring boosted

      [?]Hack a Day (unofficial) » 🤖 🌐
      @hackaday@www.urbanmind.net

      #refactoring boosted

      [?]PHPBoyScout » 🌐
      @phpboyscout@hachyderm.io

      I wrote the extraction playbook before I'd done the hard extraction. Said out loud, that sounds a bit daft.

      Eight deliberately boring steps, written by a man who had extracted exactly one module, and an easy one at that.

      Then chat went through it: the package that scored 10 for downstream value and 4 for ease of decoupling, the widest gap in the table. It made it out. go/chat is on its own release line and its go.mod mentions go-tool-base zero times.

      And then it broke step six.

      Step six says: add the dependency, delete the in-tree package. Open go-tool-base today and pkg/chat is still there. Ten files of it, all importing the extracted module.

      Not a failed cut-over, more a category the playbook didn't have. Something has to turn the framework's resolved config into the typed settings the module takes, and that has to live on the framework's side of the line. So step six really means: delete the implementation, keep the border post.

      Steps one to five held perfectly, and I'd write it that early again. A process document is a hypothesis. It looks like a rule and gets followed like a rule, but until something difficult has run all the way through it, you're reading a description of the easy case with the confidence of a standard.

      phpboyscout.uk/the-extraction-

      A hand-drawn set of eight numbered steps on lined paper, with step six struck through and rewritten in the margin as delete the implementation, keep the border post.

      Alt...A hand-drawn set of eight numbered steps on lined paper, with step six struck through and rewritten in the margin as delete the implementation, keep the border post.

        #refactoring boosted

        [?]Hack a Day (unofficial) » 🤖 🌐
        @hackaday@www.urbanmind.net

        I've been bitten by the same bug more times than I'd like to admit. I change a "small" helper in a...
        How I find what breaks before I change a Python function

          #refactoring boosted

          [?]🏳️‍⚧️ Christin Löhner 🏳️‍🌈 » 🌐
          @christin@lsbt.me

          FediSuite 1.7.2 released!

          komplette Worker Logiken umgebaut und sehr viel Refactoring

          Dieses ist ein sehr wichtiges Release. Die Logik mit der die FediSuite Worker die Beiträge der verbundenen Fediverse Accounts abarbeiten und dann wieder in die Queue packen für erneute Berechnungen, etc. wurde sehr stark umgebaut und extrem verbessert. Sie sind deutlich performanter als vorher. Sie müssen nur noch ungefähr 15% der Arbeit tun, die sie vorher leisten mussten, einfach weil es bei der vorherigen Logik einige Probleme gab, die jetzt beseitigt sind.

          Ganz neu:

          • In der Übersicht gibt es jetzt neu den Tab für "Interaktionen".
          • Und grundsätzlich beziehen sich Top-Listen von Beiträgen jetzt viel mehr auf die berechnete (Netto-)Reichweite, als auf Likes oder Bosts.
          • Das Refactoring ist im Gange und im Grunde erkennt man die FediSuite im Code, im Vergleich zu den 1.6.x Releases nicht mehr wieder :-)

          Mehr Infos zu FediSuite, was sie ist, was sie macht und wie sie dir hilft, im Fediverse zu wachsen und die richtigen Menschen zu erreichen, findest du hier:
          fedisuite.com

            #refactoring boosted

            [?]Australia News Beep » 🌐
            @au@newsbeep.org

            Agents Refactor 300K Lines in Three Weeks, and Practitioners Ask What It Proves

            CodeScene has published a case study in which coding agents refactored a 300,000-line C codebase over three weeks,…
            &Design &DataEngineering
            newsbeep.com/au/910901/

              #refactoring boosted

              [?]United Kingdom News Beep » 🌐
              @uk@newsbeep.org

              Agents Refactor 300K Lines in Three Weeks, and Practitioners Ask What It Proves

              CodeScene has published a case study in which coding agents refactored a 300,000-line C codebase over three weeks,…
              &Design &DataEngineering
              newsbeep.com/uk/787296/

                #refactoring boosted

                [?]🏳️‍⚧️ Christin Löhner 🏳️‍🌈 » 🌐
                @christin@lsbt.me

                FediSuite 1.7.1 released!

                Jetzt auch mit PDF Upload für vutuv!

                Dieses Release war vor allem ein Refactoring Release. Es wurden Hunderte Dateien, Funktionen, Methoden umgebaut, gesplittet, in kleine Module aufgeteilt. Es gibt einige Hundert neue Tests, wie typecheck, eslint und unit tests, coverage und Vieles, Vieles mehr.

                Dies ist erst der erste Schritt einer großen Refactoring Aktion, die noch lange nicht abgeschlossen ist. Aber mit den aktuellen Änderungen sind wir da auf einem sehr guten Weg.

                Mehr Infos: fedisuite.com

                  #refactoring boosted

                  [?]Hack a Day (unofficial) » 🤖 🌐
                  @hackaday@www.urbanmind.net

                  Every tuning change needed a code review, a release, and a week of store rollout. So we stopped...
                  Your thresholds do not belong in constants

                    #refactoring boosted

                    [?]Hack a Day (unofficial) » 🤖 🌐
                    @hackaday@www.urbanmind.net

                    Extract one mutator only after tests pin object identity. Value equality alone hides alias bugs in shared containers. A copied list can match every item and still break a later caller.

                    That constraint fits a messy module with shared lists and dicts. The smallest safe change is one leaf extract with stable ids. Wider splits wait until those identity contracts stay green.

                    Why value checks miss the bug


                    Many helpers mutate a list that another function still holds. One path sorts that list during a report build. A later path expects the caller's original order to remain.

                    A value assertion can pass on the returned rows alone. The caller then reads the same object and sees a new order. The failure is an identity change, not a wrong aggregate.

                    Scope of this pin


                    This note covers in-place container identity and nothing else. It does not cover clocks, argv arrays, env diffs, or stream bytes. Those other pins answer different risks during an extract.

                    Pin three facts at each public entry point you still call. Capture object ids for each mutable argument before the call. Capture whether those same ids still match after return.

                    Also capture the set of changed mapping keys. Capture whether the return value is a new object. Leave file paths and process status codes out of this harness.

                    Decision table for one leaf


                    Use the table below before any code move. A leaf may be extracted only when its row says pass. A fail row means you keep that code in place.

                    Observed leaf behavior

                    Identity result

                    Smallest safe move

                    Reads inputs and returns a new list

                    Input ids unchanged

                    Extract that leaf alone

                    Sorts a caller list in place

                    Same id, new order

                    Keep it and pin the order

                    Copies a dict, then updates the copy

                    Input id unchanged

                    Extract and assert a new id

                    Writes through a nested dict alias

                    Nested id changed

                    Do not extract until alias is named

                    Rebinds a local name only

                    Caller id unchanged

                    Extract; the rebind stays local

                    Replaces one list element object

                    Same list id, new item id

                    Extract only if item ids are pinned


                    The table is a review proposal, not a measured benchmark. It is not a product score and not a quota claim. Your repository rows may differ after the first local run.

                    Step 1: Name one candidate leaf


                    Pick the smallest function that touches one container. Prefer a leaf with no further calls into the same module. Reject a candidate that opens files or starts processes.

                    Write the current name and line range in a short note. State each mutable argument on one following line. Stop if you cannot name a single leaf yet.

                    Step 2: Record ids around the entry point


                    Wrap the public function with a thin local probe. Store the id of each mutable argument before the call. Store the id of each argument again after return.

                    Compare those pairs inside the test, not in production logs. Fail the test when a pinned id changes unexpectedly. Fail it when a new key appears in a watched dict.

                    Step 3: Add a local probe module


                    Keep the probe outside the messy production module. Pass the entry point in as a plain callable. Do not import the probe from production code paths.

                    The sample below is an unexecuted local proposal. Run it only inside a disposable checkout you can delete. Adapt every name to your module before you trust a result.

                    """Proposal harness for container identity. Not executed in this article."""

                    def snapshot(args):
                    rows =
                    [] for arg in args:
                    if isinstance(arg, dict):
                    keys = tuple(sorted(repr(key) for key in arg))
                    rows.append(("dict", id(arg), keys))
                    elif isinstance(arg, list):
                    rows.append(("list", id(arg), len(arg)))
                    else:
                    rows.append(("other", id(arg), type(arg).__name__))
                    return tuple(rows)

                    def changed_keys(before, after):
                    lost = set(before) - set(after)
                    gained = set(after) - set(before)
                    shared = before.keys() & after.keys()
                    edited = {key for key in shared if before[key] != after[key]}
                    names = (repr(key) for key in lost | gained | edited)
                    return tuple(sorted(names))

                    def pin_call(func, args, dict_index):
                    before = snapshot(args)
                    watched = args
                    [dict_index] keys_before = dict(watched)
                    result = func(*args)
                    after = snapshot(args)
                    same_ids = all(before[i][1] == after[i][1] for i in range(len(args)))
                    return {
                    "same_ids": same_ids,
                    "changed_keys": changed_keys(keys_before, watched),
                    "result_id": id(result),
                    "result_is_arg": any(result is arg for arg in args),
                    }

                    Step 4: Score the leaf with fixed rules


                    Treat the probe output as data, not as a hunch. Allow an extract only when the same ids flag stays true. Require the result object to differ from every input object.

                    If changed keys are non-empty, keep the mutator in place. Name that mutation in the test before any move. Extract only after the test expects those exact keys.

                    The return shape below is a schema example, not a captured run. Use it to name fields before you write assertions. Replace the placeholder id when you run the probe locally.

                    # Schema example only. Not a captured run from any repository.
                    {
                    "same_ids": True,
                    "changed_keys": ("status",),
                    "result_id": 0,
                    "result_is_arg": False,
                    }

                    Step 5: Apply the smallest edit


                    Move one passing leaf into a new function body. Keep the old name as a one-line wrapper call. Do not rename callers in that same change.

                    Preserve argument order and every existing default value. Do not clean up nearby branches in the same patch. A second edit hides which line broke object identity.

                    Step 6: Re-run the same probe


                    Run the probe on the wrapper after the move. Compare the same ids flag, changed keys, and result aliasing. Accept the change only when those three fields match.

                    If any field flips, revert the extract immediately. Add a tighter pin for the field that moved. Retry with a smaller leaf, or stop the split.

                    Commands to run locally


                    Use the test runner your repository already trusts. The commands below are a pattern, not a timed result. They do not claim a pass rate or a duration.

                    python -m pytest tests/test_identity_pin.py -q --tb=short
                    python -m compileall -q src

                    Review the failure list before you edit production code. A red identity pin is a hard stop sign. Do not silence it with a broader value assertion.

                    Where a draft assistant can sit


                    Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode provides free model access and a free server option. Those two availability facts are the only product claims here.

                    Model names, quotas, hardware, and duration stay out of scope. A free model can draft probe comments and empty table rows. Keep that draft off the repository when a free server is available.

                    Keep the repository and the test run on your machine. Do not let the draft choose which leaf to extract. Alias rules are easy for a generated draft to miss.

                    The score function remains the only merge gate. If a second table draft would help, use the free server option there. Paste probe output only, and do not paste secrets or private paths.

                    Merge nothing until the local re-run matches every pin. A generated row is a suggestion, not a passing characterization. Your local probe output remains the record you keep.

                    Limits of the probe


                    An object id is meaningful only during that object's life. A collected object can have its id reused later. Compare ids inside one call, not across separate process runs.

                    The probe misses mutations that happen inside C extensions. It misses memory changes made through ctypes views. It misses list edits that keep length and equal values.

                    Nested containers need their own separate identity snapshots. A shallow key check ignores inner list order. Add a nested walk only for the leaf you plan to move.

                    This method assumes a single thread during the probe. Another thread can mutate a container between the two snapshots. Do not use these pins to bless a concurrent extract.

                    Who should not use this


                    Skip the method when every function already returns new objects. You would spend time pinning a contract you do not break. A smaller diff review is enough in that pure module.

                    Skip it when you cannot call the entry point in a test. A probe that never runs is not evidence of safety. Build a caller harness first, then return to identity pins.

                    Skip it when fresh objects are the intended contract. Caches, pools, and factories mint new ids on purpose. Pinning stable ids there would freeze the wrong rule.

                    Skip it for permission checks and other security boundaries. Identity pins do not prove authorization behavior at all. Use dedicated tests for those sensitive paths instead.

                    Close


                    Start from the identity conclusion, not from a broad rewrite. Pin object ids, changed keys, and result aliasing first. Extract one passing leaf, then re-pin those same fields.

                    Leave every other cleanup for a later, separate change. The decision table tells you when to stop moving code. A green value test is not permission to move a mutator.
                    Freeze Object Identity Before One Mutator Extract

                      #refactoring boosted

                      [?]Hack a Day (unofficial) » 🤖 🌐
                      @hackaday@www.urbanmind.net

                      A pricing function still folds region rules, bulk surcharges, and coupon stacking into one nested block. A teammate asks an assistant to tidy that module before a tax change lands next week. The first generated patch rewrites four helpers, renames two exceptions, and flips a surcharge for twelve-item carts. Review then spends more time reconstructing prior behavior than evaluating the one extract that was actually needed.

                      This walkthrough treats that failure as a process problem rather than a taste debate about clean code. The useful unit of work is one nested decision on one hot path, recorded before any symbol moves. After the outcomes are frozen, the only permitted edit is a single predicate extract that preserves those outcomes. The fixture below is labeled as an unexecuted example; adapt the recorder to your language and runner.

                      Why broad cleanup patches fail on nested pricing logic


                      Messy pricing code is usually a decision tree that hides inside mutation, logging, and ad-hoc rounding. Assistants trained to improve readability optimize for local style, not for the sparse matrix of inputs that production actually hits. A four-hundred-line rewrite can look coherent in diff view while changing only one compound condition that finance already depends on.

                      Three failure patterns show up repeatedly in review, even when the generated code is syntactically nicer:

                      • Silent branch collapse. Two region checks get merged, and a cart that used to skip surcharge now pays it.
                      • Exception reshaping. A ValueError becomes a custom type, and an upstream retry path stops matching.
                      • Rounding drift. Intermediate round(..., 2) calls move, so a twelve-item cart differs by one cent.

                      None of those failures are visible if the first test you add is an assertion against the new design. Characterization has to lock the old outcomes first, before any helper is renamed or moved. Only then does a one-predicate extract become a reviewable change rather than a behavior lottery.

                      Freeze the nested decision, not the surrounding module


                      Pick the hottest path through the function, not the whole file, before anyone starts renaming symbols. In this fixture, that path is whether a cart receives a bulk surcharge and which reason code is attached. Coupon stacking and tax remain inside the messy function on purpose, because moving them would expand the blast radius past a single commit.

                      Define a record as a triple: canonical input, outcome tuple, and a stable hash of that pair. The hash is a review signal, not a cryptographic control, and a mismatch should stop the extract immediately. If two consecutive runs disagree, the path is still too noisy to touch.

                      A compact recorder for one hot path


                      The module under test is intentionally awkward. It mutates a dict, appends a log line, and buries the surcharge predicate among unrelated branches.

                      # pricing.py — unexecuted fixture, not production code
                      from typing import Any

                      def price_order(cart: dict[str, Any]) -> dict[str, Any]:
                      items = cart.get("items") or
                      [] region = (cart.get("region") or "US").upper()
                      coupon = cart.get("coupon")
                      subtotal = sum(
                      float(i.get("unit_cents", 0)) * int(i.get("qty", 0)) for i in items
                      )
                      log = list(cart.get("_log") or [])

                      surcharge = 0.0
                      reason = "none"
                      count = sum(int(i.get("qty", 0)) for i in items)

                      if region in {"EU", "UK"} and coupon == "VATZERO":
                      reason = "vat_exempt"
                      elif count >= 12 and region != "EU":
                      surcharge = round(subtotal * 0.04, 2)
                      reason = "bulk_surcharge"
                      log.append(f"bulk:{count}:{region}")
                      elif count >= 12 and region == "EU":
                      reason = "eu_bulk_skipped"
                      log.append(f"skip_eu:{count}")
                      else:
                      log.append(f"std:{count}:{region}")

                      if coupon == "SAVE10" and reason != "vat_exempt":
                      subtotal = round(subtotal * 0.9, 2)

                      cart["subtotal"] = subtotal
                      cart["surcharge"] = surcharge
                      cart["reason"] = reason
                      cart["_log"] = log
                      cart["total"] = round(subtotal + surcharge, 2)
                      return cart

                      The recorder ignores style and stores only the decision surface planned for extract: item count, region, coupon, reason, surcharge, and total.

                      # characterize_price_order.py — unexecuted fixture
                      import hashlib
                      import json
                      from copy import deepcopy

                      from pricing import price_order

                      CASES = [
                      {"items": [{"unit_cents": 199, "qty": 12}], "region": "US", "coupon": None},
                      {"items": [{"unit_cents": 199, "qty": 12}], "region": "EU", "coupon": None},
                      {"items": [{"unit_cents": 199, "qty": 11}], "region": "US", "coupon": None},
                      {"items": [{"unit_cents": 500, "qty": 12}], "region": "UK", "coupon": "VATZERO"},
                      {"items": [{"unit_cents": 199, "qty": 12}], "region": "US", "coupon": "SAVE10"},
                      {"items": [{"unit_cents": 50, "qty": 0}], "region": "US", "coupon": None},
                      {"items": [{"unit_cents": 199, "qty": 12}], "region": "eu", "coupon": None},
                      ]

                      def outcome(cart):
                      result = price_order(deepcopy(cart))
                      return {
                      "count": sum(int(i.get("qty", 0)) for i in cart.get("items") or []),
                      "region": (cart.get("region") or "US").upper(),
                      "coupon": cart.get("coupon"),
                      "reason": result["reason"],
                      "surcharge": result["surcharge"],
                      "total": result["total"],
                      }

                      def ledger_hash(rows):
                      blob = json.dumps(rows, sort_keys=True, separators=(",", ":")).encode()
                      return hashlib.sha256(blob).hexdigest()[:16]

                      if __name__ == "__main__":
                      rows = [outcome(c) for c in CASES]
                      print(json.dumps(rows, indent=2, sort_keys=True))
                      print("ledger", ledger_hash(rows))

                      Commands that keep the freeze honest


                      Run the recorder twice before anyone edits pricing.py, and treat a hash mismatch as a stop sign. Store the printed ledger in the review notes, or as a checked-in JSON snapshot if that habit already exists. The second run must match the first hash; if it does not, shrink the recorded surface before extracting anything.

                      python characterize_price_order.py | tee /tmp/ledger1.txt
                      python characterize_price_order.py | tee /tmp/ledger2.txt
                      diff -u /tmp/ledger1.txt /tmp/ledger2.txt
                      python -m pytest tests/test_price_order_characterization.py -q
                      git add characterize_price_order.py tests/test_price_order_characterization.py
                      git commit -m "Characterize bulk-surcharge decision before any extract"

                      A minimal pytest pin is enough for CI. It should fail on reason-code drift, surcharge drift, or total drift, and it should ignore log-line wording.

                      # tests/test_price_order_characterization.py — unexecuted fixture
                      from characterize_price_order import CASES, ledger_hash, outcome

                      # Captured from the first honest run of characterize_price_order.py
                      PINNED_HASH = "replace_me_after_first_run"

                      def test_bulk_surcharge_decision_is_frozen():
                      rows = [outcome(c) for c in CASES]
                      assert ledger_hash(rows) == PINNED_HASH

                      Replace PINNED_HASH with the value from the first run, then keep that commit separate from the extract commit. Mixing the pin and the extract in one diff reintroduces the original review problem, because reviewers cannot tell a captured baseline from a behavior change.

                      A decision table the extract must not violate


                      The table is the contract for the extract commit. If an assistant proposes a prettier predicate that disagrees with any row, the extract is rejected, regardless of naming quality.

                      qty

                      region

                      coupon

                      reason

                      surcharge rule

                      12

                      US

                      none

                      bulk_surcharge

                      4% of pre-coupon subtotal

                      12

                      EU

                      none

                      eu_bulk_skipped

                      0

                      11

                      US

                      none

                      none

                      0

                      12

                      UK

                      VATZERO

                      vat_exempt

                      0

                      12

                      US

                      SAVE10

                      bulk_surcharge

                      4% first; coupon then cuts subtotal

                      0

                      US

                      none

                      none

                      0

                      12

                      eu

                      none

                      eu_bulk_skipped

                      0, because region is uppercased


                      The SAVE10 row is the interesting collision in this fixture. The current function applies bulk surcharge against the pre-coupon subtotal, then discounts the subtotal. A cleanup that computes surcharge after the coupon looks cleaner and is wrong relative to today's ledger. Characterization exists to make that disagreement boring and automatic, instead of a late finance incident.

                      The smallest safe change: one predicate, one commit


                      After the hash is pinned, the only allowed production edit is extracting the condition that decides bulk_surcharge versus eu_bulk_skipped. Coupon handling, logging, and totals stay in price_order during this commit. VAT exemption stays inline as well, because it is a different decision and deserves a later extract.

                      def bulk_surcharge_reason(count: int, region: str) -> str | None:
                      """Return a bulk-related reason, or None when the bulk branch does not apply."""
                      if count < 12:
                      return None
                      if region == "EU":
                      return "eu_bulk_skipped"
                      return "bulk_surcharge"

                      Wire it in with the smallest possible splice. Do not reorder coupon math in the same commit, even if the new order reads more linearly.

                      bulk_reason = bulk_surcharge_reason(count, region)
                      if region in {"EU", "UK"} and coupon == "VATZERO":
                      reason = "vat_exempt"
                      elif bulk_reason == "bulk_surcharge":
                      surcharge = round(subtotal * 0.04, 2)
                      reason = bulk_reason
                      log.append(f"bulk:{count}:{region}")
                      elif bulk_reason == "eu_bulk_skipped":
                      reason = bulk_reason
                      log.append(f"skip_eu:{count}")
                      else:
                      log.append(f"std:{count}:{region}")

                      Then re-run the recorder and the pinned test before opening the review. If PINNED_HASH still matches, the extract changed structure without changing the nested decision. If it does not match, revert and shrink the splice; do not fix forward by editing cases.

                      A patch-size budget you can enforce in review


                      Use this checklist on the extract commit only, not on the earlier characterization commit:

                      1. Production diff stays under roughly forty lines, including the new function.
                      2. Exactly one new symbol is introduced, and no existing exception type is renamed.
                      3. Coupon, tax, and logging branches are untouched except for the call-site splice.
                      4. Characterization hash is unchanged; any hash delta blocks merge.
                      5. No formatter-only edits in unrelated functions ride along in the same commit.

                      git diff --stat HEAD~1
                      git diff -U0 HEAD~1 -- pricing.py
                      git diff --name-only HEAD~1

                      If an assistant or a colleague cannot stay inside that budget, split the work instead of raising the budget. The next extract might be is_vat_exempt(region, coupon), and it gets its own pin if those cases are not already covered. Sequential extracts are slower to write and much cheaper to review than one impressive cleanup.

                      Where a coding assistant belongs in this sequence


                      Assistants are useful after the ledger exists, because the task becomes proposing a predicate that preserves these rows, not making the file look clean. They are much less useful as the first author of a module-wide rewrite, which is how the twelve-item surcharge usually flips. Feed the model the decision table and the current function, then reject any patch that also reformats coupon math.

                      Disclosure: This article was prepared as part of MonkeyCode's product outreach. If the recorder needs a scratch environment instead of a laptop checkout, MonkeyCode's free model access and free server option can draft extra cases and rerun the hash loop without pointing the assistant at production secrets. Accept only a predicate-sized patch that keeps PINNED_HASH stable, and keep the same pytest pin in CI regardless of which editor wrote the function.

                      The method still holds if pytest runs on a workstation and the predicate is written by hand. The assistant is optional infrastructure around a frozen decision, not a substitute for the freeze.

                      Limitations


                      • Characterization records what the function does today, including bugs that product may later want to change on purpose.
                      • A seven-row table does not cover concurrent carts, currency conversion, or coupons that expire mid-request.
                      • Hashing JSON rows will churn if logs, timestamps, or unordered keys are included without sort_keys.
                      • Extracting a predicate does not improve observability; existing logs still carry production incidents.
                      • Assistants can memorize the table and still reorder surcharge math unless the pin is enforced in CI.


                      Who should skip this approach


                      Skip it if the change is an intentional price-policy update rather than a structure-only extract. Skip it if the pipeline cannot run even a single-file pytest target on every patch. Skip it for cryptographic, access-control, or tax-engine code that needs a formal spec, not a snapshot of yesterday's behavior. Skip it when the hot path is not identifiable, because freezing a random nested if teaches the team the wrong boundary.

                      The durable habit is small and slightly boring. Record one nested decision until its hash is dull, then move one predicate, then stop. The cleanup still happens; it happens as a sequence of reviewable extracts instead of one impressive diff that finance cannot reconstruct.
                      Record One Nested Decision, Then Extract a Single Predicate

                        #refactoring boosted

                        [?]Hack a Day (unofficial) » 🤖 🌐
                        @hackaday@www.urbanmind.net

                        A library is a basis. App code is coefficients. Refactoring is a change of basis. I use compression and sparse coding to pin down what actually counts as a "new idea" — and why frequency, not drama, earns one.
                        A library is a basis. App code is coefficients. Refactoring is a change of basis. I use compression and sparse coding to pin down what actually counts as a "new idea" — and why frequency, not drama, earns one.

                          #refactoring boosted

                          [?]Hack a Day (unofficial) » 🤖 🌐
                          @hackaday@www.urbanmind.net

                          A tangled pricing function still mixes tax rules, discounts, and rounding in one seven-hundred-line Python module. An agent then opens a pull request that rewrites helpers, renames locals, and claims the cleanup is behavior-preserving. The existing tests remain green because they only assert a final integer total for two happy-path invoices. This walkthrough freezes one entry point as a contract tape, then allows only the smallest extract that keeps that tape identical.

                          The method is intentionally narrow. It does not certify the whole service, and it does not bless a large rewrite just because unit tests still pass.

                          Why green tests still miss the extract


                          Messy modules usually have tests that pin outcomes, not contracts. An agent can change control flow, drop a rare branch, or replace None with {} while those outcome tests stay green. Reviewers then debate naming while the silent behavior change hides in a helper that used to skip missing keys. A useful freeze therefore records more than the final number.

                          Record four things for a single entry point, not for the entire package:

                          • argument type trees and sorted mapping keys
                          • return type trees, including None versus empty containers
                          • exception class names on known failure inputs
                          • a short digest of the canonical JSON result

                          That combination is a contract tape. It is cheaper than a full-suite dump and stricter than one assertion on a total. The tape is the gate; the extract is allowed only after the gate is red-green on the current tree.

                          The lab fixture


                          The example below is a proposed fixture, not a production service. Treat every snippet as unexecuted sample code for this walkthrough.

                          # messy_pricing.py — proposed lab fixture
                          from decimal import Decimal, ROUND_HALF_UP

                          def price_invoice(payload):
                          items = payload.get("items") or
                          [] subtotal = Decimal("0")
                          for item in items:
                          qty = Decimal(str(item.get("qty") or 0))
                          unit = Decimal(str(item.get("unit") or 0))
                          subtotal += qty * unit
                          discount = Decimal(str(payload.get("discount") or 0))
                          if payload.get("kind") == "wholesale" and subtotal > 100:
                          discount += Decimal("5")
                          taxable = subtotal - discount
                          if taxable < 0:
                          taxable = Decimal("0")
                          rate = Decimal("0.08") if payload.get("region") == "west" else Decimal("0.06")
                          tax = (taxable * rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
                          total = (taxable + tax).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
                          return {
                          "subtotal": str(subtotal),
                          "discount": str(discount),
                          "tax": str(tax),
                          "total": str(total),
                          "flags": payload.get("flags") or {},
                          }

                          The function looks small, yet it mixes defaults, regional tax, wholesale extras, and stringified decimals. An agent can split it into several helpers and still keep total stable on the two cases a sparse test file already covers. The missing risk is a changed flags default, a dropped wholesale bonus, or a different empty-items path.

                          Build the contract tape


                          The recorder walks JSON-like values, stores a shape tree, and stores a short digest. Run it against one function only. Do not start by taping every private helper, because that freeze would block the extract you actually want.

                          # contract_tape.py — proposed walkthrough code
                          from __future__ import annotations

                          import hashlib
                          import json
                          from pathlib import Path
                          from typing import Any

                          TAPE_DIR = Path("tapes")

                          def shape_of(value: Any) -> Any:
                          if value is None:
                          return {"kind": "none"}
                          if isinstance(value, bool):
                          return {"kind": "bool"}
                          if isinstance(value, int) and not isinstance(value, bool):
                          return {"kind": "int"}
                          if isinstance(value, float):
                          return {"kind": "float"}
                          if isinstance(value, str):
                          return {"kind": "str", "len": len(value)}
                          if isinstance(value, (list, tuple)):
                          return {
                          "kind": "list",
                          "len": len(value),
                          "items": [shape_of(v) for v in list(value)[:8]],
                          }
                          if isinstance(value, dict):
                          keys = sorted(value.keys(), key=lambda k: str(k))
                          return {
                          "kind": "dict",
                          "keys": [str(k) for k in keys],
                          "fields": {str(k): shape_of(value[k]) for k in keys},
                          }
                          return {"kind": type(value).__name__}

                          def canonical(value: Any) -> Any:
                          if isinstance(value, dict):
                          return {str(k): canonical(value[k]) for k in sorted(value, key=lambda x: str(x))}
                          if isinstance(value, (list, tuple)):
                          return [canonical(v) for v in value]
                          return value

                          def digest(value: Any) -> str:
                          blob = json.dumps(canonical(value), separators=(",", ":"), ensure_ascii=True)
                          return hashlib.sha256(blob.encode("utf-8")).hexdigest()[:16]

                          def record_call(fn, payload):
                          row = {"input_shape": shape_of(payload)}
                          try:
                          result = fn(payload)
                          except Exception as exc:
                          row["exception"] = type(exc).__name__
                          row["result_shape"] = None
                          row["digest"] = None
                          return row
                          row["exception"] = None
                          row["result_shape"] = shape_of(result)
                          row["digest"] = digest(result)
                          return row

                          Seed the tape with cases that miss the happy path, not only the two invoices already in unit tests. Wholesale bonus, missing items, and a discount that drives taxables below zero are the usual silent diffs.

                          # record_tape.py — proposed walkthrough code
                          import json
                          import sys
                          from pathlib import Path

                          from contract_tape import TAPE_DIR, record_call
                          from messy_pricing import price_invoice

                          CASES = [
                          {
                          "name": "retail_east_empty_flags",
                          "payload": {
                          "items": [{"qty": 2, "unit": "10.00"}],
                          "discount": "1.00",
                          "region": "east",
                          },
                          },
                          {
                          "name": "wholesale_west_bonus",
                          "payload": {
                          "items": [{"qty": 12, "unit": "9.50"}],
                          "kind": "wholesale",
                          "region": "west",
                          "flags": {"rush": True},
                          },
                          },
                          {
                          "name": "negative_after_discount",
                          "payload": {"items": [{"qty": 1, "unit": "3"}], "discount": "9.00"},
                          },
                          {
                          "name": "missing_items",
                          "payload": {"region": "west"},
                          },
                          ]

                          def build_tape():
                          return {
                          "entry": "price_invoice",
                          "cases": [
                          {"name": case["name"], **record_call(price_invoice, case["payload"])}
                          for case in CASES
                          ],
                          }

                          def main(mode: str) -> int:
                          TAPE_DIR.mkdir(exist_ok=True)
                          path = TAPE_DIR / "price_invoice.json"
                          fresh = build_tape()
                          if mode == "--write":
                          path.write_text(json.dumps(fresh, indent=2, sort_keys=True) + "\n")
                          print(f"wrote {path}")
                          return 0
                          if not path.exists():
                          print("missing tape; run with --write first", file=sys.stderr)
                          return 2
                          pinned = json.loads(path.read_text())
                          if pinned != fresh:
                          print("contract tape drift")
                          print(json.dumps({"pinned": pinned, "fresh": fresh}, indent=2))
                          return 1
                          print("contract tape matched")
                          return 0

                          if __name__ == "__main__":
                          raise SystemExit(main(sys.argv[1] if len(sys.argv) > 1 else "--check"))

                          Commands stay boring on purpose. Write once from the known-messy tree, then check after every extract. If the check is not in CI yet, run it locally before you even open the diff.

                          python record_tape.py --write
                          python record_tape.py --check
                          git add tapes/price_invoice.json contract_tape.py record_tape.py
                          git commit -m "Pin price_invoice contract tape before extract"

                          A matched tape means the entry point still accepts the same shapes and still emits the same canonical result. It does not mean the internals are pretty, and it does not mean every caller is covered.

                          Allow only the smallest safe change


                          After the tape is pinned, the next move is one extract, not a module rewrite. The candidate in this fixture is the discount block, because it is a closed rule with one extra wholesale branch. Keep price_invoice as the public entry so callers do not move in the same commit.

                          def apply_discount(subtotal, payload):
                          discount = Decimal(str(payload.get("discount") or 0))
                          if payload.get("kind") == "wholesale" and subtotal > 100:
                          discount += Decimal("5")
                          return discount

                          That is the whole change budget for the first patch. Do not rename flags, do not switch Decimal to float, and do not introduce a pricing class in the same diff. If an agent returns a four-file cleanup, reject it before reading the prose in the pull request.

                          Use a diff gate so the budget is mechanical. The script below is proposed local tooling, not a required platform hook.

                          # extract_gate.py — proposed walkthrough code
                          import subprocess
                          import sys

                          MAX_FILES = 2
                          MAX_NET_LINES = 40

                          def main() -> int:
                          raw = subprocess.check_output(
                          ["git", "diff", "--numstat", "HEAD"],
                          text=True,
                          ).strip()
                          if not raw:
                          print("no unstaged diff against HEAD")
                          return 0
                          files = 0
                          net = 0
                          for line in raw.splitlines():
                          added, deleted, path = line.split("\t", 2)
                          if path.startswith("tapes/"):
                          continue
                          files += 1
                          if added != "-" and deleted != "-":
                          net += abs(int(added) - int(deleted)) + min(int(added), int(deleted))
                          if files > MAX_FILES or net > MAX_NET_LINES:
                          print(f"extract budget exceeded: files={files} net_lines~={net}")
                          return 1
                          print(f"extract budget ok: files={files} net_lines~={net}")
                          return 0

                          if __name__ == "__main__":
                          raise SystemExit(main())

                          Accept or reject the agent patch

                          Observation

                          Decision

                          Next step

                          Tape matches and the diff touches one helper plus the entry function

                          Accept

                          Commit, then pick the next closed block

                          Tape matches but three unrelated files moved

                          Reject

                          Ask for a single-function extract

                          Tape drifts on missing_items only

                          Reject

                          Restore the empty-items default before any rename

                          Tape drifts on digest but not on shapes

                          Reject

                          A value changed; do not treat it as a style cleanup

                          Tests pass while the tape is missing

                          Reject

                          The suite is too coarse to review an agent rewrite


                          The table is the review script. It keeps the discussion on contracts and budgets instead of on whether the generated names look tidy.

                          Where a disposable coding environment fits


                          Once the tape and the gate exist, an agent is useful only as a proposer of the next one-function extract. It should not be the source of truth for behavior. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode's free model access and free server option can host that record-and-check loop when you want a scratch machine, without turning the tape into a marketing demo.

                          Keep the workflow local-first either way:

                          1. Write the tape from the messy tree.
                          2. Ask for one extract that preserves price_invoice.
                          3. Run python record_tape.py --check and python extract_gate.py.
                          4. Reject any patch that fails either command, even when unit tests pass.

                          The product mention is optional. The tape still works if you run the same commands on a laptop and ignore every coding agent.

                          Limitations, and who should skip this


                          The tape hashes canonical JSON of return values, so unordered sets, timestamps, and randomly allocated identifiers will thrash the digest. Do not use this recorder on those outputs without first stripping volatile fields. Shape trees also stop at eight list items, which is enough for invoice lines in this fixture and too weak for bulk imports.

                          Skip the method when the entry point is a long-lived process, a GUI loop, or a network client with live clocks. Skip it when you do not own the module, because pinning a tape is still a behavior freeze and can conflict with an active feature branch. Skip it when the real bug is numeric policy, such as rounding mode, because a digest match can still hide a business change if your cases never hit that branch.

                          The approach also fails closed on purpose. A missing tape is a failed gate, not a reason to trust a large agent rewrite. If the team cannot name four cases that miss the happy path, the extract is not the current problem; the missing cases are.

                          What this walkthrough actually settles


                          A messy module becomes safer to touch when one entry point has a replayable contract, not when an agent restyles the file. The smallest safe change is then a single closed helper, reviewed against a tape and a diff budget. If those two checks pass, you earned the next extract. If they fail, the rewrite was a story about cleanliness, not a proof about behavior.
                          Tape One Entry Point Before You Extract Anything From a Messy Module

                            #refactoring boosted

                            [?]Hack a Day (unofficial) » 🤖 🌐
                            @hackaday@www.urbanmind.net

                            Deduplicating shared maths across 150 scorers, when every function feeds a number a real person reads as their result and no output may move.
                            38 clamps, four probits, and one coefficient rounded to 15 digits

                              #refactoring boosted

                              [?]Hack a Day (unofficial) » 🤖 🌐
                              @hackaday@www.urbanmind.net

                              There is a lot of literature about how to refactor code and what techniques to apply, but sometimes...
                              Building a Culture of Continuous Refactoring

                                #refactoring boosted

                                [?]Hack a Day (unofficial) » 🤖 🌐
                                @hackaday@www.urbanmind.net

                                Pin the query-string contract before any helper extract. Extract one encoder only after those bytes...
                                Pin urlencode doseq and quote_via Before One Query Extract

                                  #refactoring boosted

                                  [?]Hack a Day (unofficial) » 🤖 🌐
                                  @hackaday@www.urbanmind.net

                                  I have a small agent that handles one piece of routine work at a time. It looks
                                  at what needs doing, picks the thing most worth doing, shows me the plan, and
                                  does it if I say yes. Underneath, it is glue: it drives a few command-line
                                  tools, calls a model, reshapes a lot of JSON, and prints a readable summary.

                                  It was 2150 lines of bash across seven files. It is now Python.

                                  So the answer looks like yes. I don't think it is, and why I don't is most of
                                  the reason I'm writing this down.

                                  The argument for staying was sound. Its inputs weren't.


                                  I had been through this question before and decided to stay — carefully enough
                                  that the reasoning became a section of the project README titled Why this is
                                  still bash
                                  . Nine tenths of the program is subprocess orchestration, which is
                                  bash's home ground. Rewriting 2000 lines with no test coverage is the standard
                                  way to lose behavior silently. And the bug ledger said the expensive bugs were
                                  design errors that any language would have permitted.

                                  I still think all of that is true.

                                  What moved was a requirement. I had been treating runs with no build step as
                                  hard, which made "python3 is already installed" the load-bearing argument. Then
                                  the requirement got clarified: no build step isn't a rule, it just shouldn't be
                                  complicated to start.

                                  That one sentence killed my best argument. So I measured what was actually at
                                  stake — 89ms for Python plus every standard library module it needs, against
                                  3ms for bash. Eighty-six milliseconds, in a program that waits thirty to a
                                  hundred seconds on a model.

                                  My reasoning was valid; its inputs weren't, and I had spent almost no effort
                                  checking them.
                                  That ratio was backwards, and I don't think that's unusual.

                                  What decided it was 162 calls to jq


                                  Every list length, every filter, forking a process to handle data that should
                                  have been sitting in memory.

                                  The cost was not performance. 162 forks are nothing next to a minute of model
                                  latency. The cost was expressiveness. Every structure in the program either
                                  fit in a one-line jq expression or got split into three pieces. What I wrote
                                  was never the structure I wanted; it was the structure jq could state on one
                                  line. That cost is invisible in any single line of code and shows up in the
                                  designs you never consider.

                                  I had three lists: the files a change actually touched, the files the agent
                                  itself had written, and the files I had approved in advance. I needed two
                                  differences between them.

                                  later="$(jq -nc --argjson a "$actual" --argjson w "$written" \
                                  'if $w == null then [] else ($a - $w) end')"
                                  extra="$(jq -nr --argjson w "$written" --argjson p "$planned" \
                                  '($w - $p) | join(", ")')"

                                  later = [] if written is None else [f for f in actual if f not in written]
                                  extra = [f for f in written if f not in planned]

                                  The second one is barely shorter. It is what I would have written on the first
                                  attempt; the first took me several tries to get right.

                                  There was also a run that died on line 567: 1: command not found, which I
                                  never located. It went away when that section was rewritten for unrelated
                                  reasons. A bug you can't find after the fact doesn't just go unfixed — it
                                  tells you the next one of its kind will too.

                                  The line count did not go down


                                  The port took a day.

                                  bash 2150 lines
                                  Python 2258 lines ← up 108

                                  of which:
                                  code 1278 ← down 40%
                                  comments 980

                                  Most people expect the opposite, so it's worth being blunt about. The code
                                  shrank by forty percent; the difference is comments and docstrings — the notes
                                  recording which specific incident each safety check exists to prevent, which
                                  were exactly what I'd been afraid of losing.

                                  Evaluate this rewrite by total line count and it accomplished nothing.

                                  What made it cheap: two seams built for other reasons


                                  This is the part I actually wanted to write down.

                                  The prompts are files, not strings. Every prompt lives in its own Markdown
                                  file and the code fills {{placeholder}} holes in it. I did that for unrelated
                                  reasons: prompts get edited constantly, they want to be read as prose, and a
                                  stray $ or backtick has to stay inert instead of being eaten by the shell.

                                  The result was that the migration did not touch one word of any prompt. I
                                  checked modification times afterward to be sure. Everything that determines
                                  this program's behavior — the criteria I've tuned over and over, the order
                                  judgments get made in — lives in those files. Changing languages only replaced
                                  the glue that assembles them.

                                  Each kind of task is a separate executable that speaks JSON. Verb on argv,
                                  JSON on stdin, JSON on stdout. I built it that way because bash has no modules,
                                  and putting them behind a process boundary beat having them scribble on each
                                  other's variables. Pure coping.

                                  The result: there was no big-bang rewrite to choose. The orchestrator could
                                  be Python while the task types were still bash, or the reverse. I moved one
                                  file at a time and ran each one on its own afterward. (That boundary is
                                  probably also why this never hit the wall bash projects hit — the largest bash
                                  agent I know of reached 4700 lines as a single file assembled by cat src/*.sh,
                                  and what broke was module structure, not correctness.)

                                  Neither seam was built with portability in mind. Both were built to solve
                                  something annoying at the time.

                                  Whether a rewrite will be cheap is decided before you start it.

                                  I kept the process boundary afterward, by the way. Folding the task types into
                                  Python imports would save a JSON round trip, and it is the best structural
                                  decision in the project.

                                  The real risk of Python is not the build step


                                  With a shebang and standard library only, it is invoked exactly the way it was
                                  before. No virtualenv, no install.

                                  The risk is that Python invites dependencies, and bash's poverty was itself
                                  a form of protection. requests when urllib is right there; a schema
                                  validator when the model CLI already enforces the schema; an argument parser
                                  for 25 lines of parsing; a formatting library for a display layer that exists.
                                  Each has a plausible case, and after all four "quick to start" is gone.

                                  So there is one rule in the README now: standard library only, and say why it
                                  can't be done with it before adding anything.
                                  The whole program needs six
                                  modules.

                                  What I did not verify


                                  I exercised every path after the port, including a full dry run of the
                                  expensive one — isolated checkout, model writes the code, formatter, vet,
                                  build, tests, commit — stopping short of pushing. Fifteen tests on the pure
                                  functions pass.

                                  The two lines that push a branch and open a change request never ran,
                                  because running them means actually opening one. And the safety checks inside
                                  the task types I translated by hand, one at a time. I believe I got them right,
                                  and this project still has no test that can prove it.

                                  I don't think bash was the wrong choice. It carried this to 2150 lines, and for
                                  all of that time I was changing judgment logic rather than fighting the
                                  language. Its problem was never that it couldn't do the job. Its problem was
                                  that its expressiveness had started deciding my designs.

                                  Was bash the wrong language for my agent?

                                    #refactoring boosted

                                    [?]Hack a Day (unofficial) » 🤖 🌐
                                    @hackaday@www.urbanmind.net

                                    Messy repos hide json.dumps flags in dozens of call sites. A later helper extract then changes wire bytes without a failing test. Pin those bytes first, then extract one serializer function.

                                    Reviewers rarely catch ensure_ascii flipping from True to False. sort_keys and separators also rewrite objects that look equal in Python. default handlers change datetime and Decimal encoding on the first deploy.

                                    This workflow freezes dumps() outputs as UTF-8 bytes. It then allows one function extract and nothing else. Parsed dict equality is not a pin and must not gate the change.

                                    The failure mode in a tangled module


                                    Inline dumps() calls look harmless until a client parses key order. Some gateways hash the raw body and reject reordered objects. Some logs treat escaped Unicode as a new event class.

                                    A typical messy module mixes three dumps dialects in one file. One call sorts keys for cache stability. Another omits spaces for a compact queue payload. A third ships ensure_ascii=True for an old HTTP stack.

                                    Extracting to_json(data) without a byte pin merges those dialects. The merge often lands as a silent default of json.dumps. Downstream tests still pass because they decode JSON and compare Python objects.

                                    What the pin must freeze


                                    A useful pin records exact UTF-8 bytes, not parsed dicts. It also records the exception type dumps() raises on bad values. It records whether default= was present at each call site.

                                    Capture these fields for every representative payload. Skip fields that the call site never observed in production traffic.

                                    Field

                                    Why it drifts

                                    Pin as

                                    sort_keys

                                    Dict order is not JSON order

                                    bytes

                                    ensure_ascii

                                    é versus \\u00e9

                                    bytes

                                    separators

                                    Compact versus spaced bodies

                                    bytes

                                    default handler

                                    datetime, Decimal, set

                                    bytes or error type

                                    allow_nan

                                    NaN becomes non-JSON text

                                    bytes or ValueError

                                    skipkeys

                                    Non-str keys vanish or raise

                                    bytes or TypeError


                                    Do not pin pretty-print indent unless a call site uses it. Do not pin Python dict equality after json.loads. Do not pin wall-clock timestamps inside payloads.

                                    Artifact: a dumps pin harness


                                    The harness below is a local example, not a measured production run. Place it next to the messy module. Keep fixtures in a committed directory so diffs stay reviewable.

                                    # pin_json_bytes.py
                                    from __future__ import annotations

                                    import json
                                    from dataclasses import dataclass
                                    from datetime import datetime, timezone
                                    from decimal import Decimal
                                    from pathlib import Path
                                    from typing import Any, Callable

                                    FIXTURE_DIR = Path(__file__).parent / "json_pins"

                                    @dataclass(frozen=True)
                                    class DumpCase:
                                    name: str
                                    payload: Any
                                    dumps_kwargs: dict[str, Any]

                                    def _default(value: Any) -> Any:
                                    if isinstance(value, datetime):
                                    return value.isoformat()
                                    if isinstance(value, Decimal):
                                    return str(value)
                                    raise TypeError(f"unpinned type: {type(value)!r}")

                                    CASES = [
                                    DumpCase(
                                    "cache_key_sorted",
                                    {"b": 1, "a": 2},
                                    {"sort_keys": True, "separators": (",", ":")},
                                    ),
                                    DumpCase(
                                    "queue_compact_ascii",
                                    {"title": "café", "ok": True},
                                    {"ensure_ascii": True, "separators": (",", ":")},
                                    ),
                                    DumpCase(
                                    "audit_spaced",
                                    {"n": Decimal("1.50"), "at": datetime(2026, 9, 16, tzinfo=timezone.utc)},
                                    {"ensure_ascii": False, "default": _default},
                                    ),
                                    ]

                                    def dump_bytes(case: DumpCase) -> bytes:
                                    text = json.dumps(case.payload, **case.dumps_kwargs)
                                    return text.encode("utf-8")

                                    def write_pins() -> None:
                                    FIXTURE_DIR.mkdir(exist_ok=True)
                                    for case in CASES:
                                    path = FIXTURE_DIR / f"{case.name}.json.bin"
                                    path.write_bytes(dump_bytes(case))

                                    def assert_pins(dumps_fn: Callable[..., str]) -> None:
                                    for case in CASES:
                                    path = FIXTURE_DIR / f"{case.name}.json.bin"
                                    expected = path.read_bytes()
                                    kwargs = dict(case.dumps_kwargs)
                                    got = dumps_fn(case.payload, **kwargs).encode("utf-8")
                                    if got != expected:
                                    raise AssertionError(
                                    f"{case.name}: pin drift {got!r} != {expected!r}"
                                    )

                                    Record pins once from the current call sites. Commit the .json.bin files as binary fixtures. Later extracts must match those bytes with no whitespace drift.

                                    # test_json_pins.py
                                    import json
                                    from pin_json_bytes import assert_pins, write_pins

                                    def test_write_pins_is_manual_only() -> None:
                                    # Run write_pins() from a shell when capturing, not in CI.
                                    assert callable(write_pins)

                                    def test_current_dumps_matches_committed_pins() -> None:
                                    assert_pins(json.dumps)

                                    Capture command for the first pin set:

                                    python -c "from pin_json_bytes import write_pins; write_pins()"
                                    pytest test_json_pins.py -q
                                    xxd json_pins/queue_compact_ascii.json.bin | head

                                    The xxd check exists to catch UTF-8 versus escaped ASCII by eye. Do not trust a terminal print of the decoded object. Two payloads can loads() equal and still differ in bytes.

                                    Numbered workflow


                                    Follow the steps in order. Stop when a step fails. Do not extract during inventory.

                                    1. Inventory dumps() call sites


                                    Search the messy module with a single pattern. Record kwargs, not only the function name. Note any wrapper that already calls dumps().

                                    rg -n "json\.dumps\(|dumps\(" -g "*.py" app/

                                    Group sites that share identical kwargs into one candidate extract. Leave mixed-kwargs sites out of the first extract. Mixed kwargs are a second change and a second pin set.

                                    2. Build one payload per dialect


                                    Pick the smallest object that still trips each flag. Include Unicode, Decimal, datetime, True, and empty dict. Exclude live secrets and customer records from fixtures.

                                    Name each case after the caller, not after the flag. Caller names survive later file moves. Flag names hide which product path broke.

                                    3. Commit binary pins before any edit


                                    Run write_pins() on the current tree only. Commit fixtures in the same branch as the tests. Do not regenerate pins after the extract lands.

                                    If a pin file changes in git, the extract is too large. Revert the helper and split the dialect instead. Byte drift is a failed gate, not a fixture update.

                                    4. Prove the pin fails on a known dialect mix


                                    Break one flag on purpose before the real extract. This is a labeled probe, not a production patch.

                                    # labeled probe: expect test_current_dumps_matches_committed_pins to fail
                                    import json
                                    from pin_json_bytes import assert_pins

                                    def mixed_dumps(payload, **kwargs):
                                    kwargs.pop("sort_keys", None)
                                    kwargs["ensure_ascii"] = True
                                    return json.dumps(payload, **kwargs)

                                    assert_pins(mixed_dumps)

                                    The probe must fail on cache_key_sorted or queue_compact_ascii. If it stays green, the pin is comparing decoded objects. Fix the harness before touching production code.

                                    5. Extract one serializer for one dialect


                                    Move a single kwargs set into one function. Keep the function in the same module for the first patch. Do not rename keys inside payloads during this step.

                                    def dumps_cache_key(payload: dict) -> str:
                                    return json.dumps(payload, sort_keys=True, separators=(",", ":"))

                                    Point only matching call sites at dumps_cache_key. Leave queue and audit sites on raw json.dumps. Re-run pytest and the xxd spot check.

                                    6. Diff the branch against the pin set


                                    The allowed diff is the new function plus call-site swaps. Fixture files must stay binary-identical. Test files may grow assertions but must not rewrite pins.

                                    git diff --stat
                                    git diff -- json_pins/
                                    pytest test_json_pins.py -q

                                    A non-empty diff under json_pins/ means the extract changed bytes. Restore the helper and reduce the move. Do not refresh pins to match the new helper.

                                    Where a free remote runner fits


                                    Laptop Python builds can hide dumps() drift across versions. A second runtime is useful after the pin suite exists. It is not a substitute for committed fixtures.

                                    Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode offers free model access and a free server option, which can draft the one-dialect helper after pins are green and run the same pytest suite off the laptop. Skip both if local pytest already isolates dumps() bytes.

                                    Do not ask a model to regenerate pin files. Do not ask a model to merge dialects in one patch. Feed only the green pin tests and the single-kwargs extract goal.

                                    Limits of a byte pin


                                    Byte pins do not prove the JSON schema is correct. They only prove this extract did not change encodings. Schema drift needs a separate contract test.

                                    They also fail on intentional pretty-print changes. If a human-readable admin dump must gain indent=2, that is a new dialect. Give it a new case name and a new extract.

                                    Floating time fields will thrash binary fixtures. Freeze clocks in payloads before recording pins. Naive datetime objects are a dialect, not an accident to ignore.

                                    Python version gaps can change nothing except implementation details. json.dumps output for these flags is stable on current CPython for the cases above. Still rerun pins when the runtime changes.

                                    Who should not use this approach


                                    Do not use byte pins for streaming JSON lines with timestamps. Do not use them when the payload includes unordered set iteration. Do not use them as a substitute for an HTTP contract test.

                                    Skip this extract if every dumps() site already shares one kwargs dict. Skip it if the module ships only debug logs and no wire format. Skip it if legal review forbids committed payload shapes.

                                    Teams without pytest or another byte-level runner should not extract yet. Install the runner and record pins first. An untested helper extract is still a dialect merge.

                                    Close


                                    Wire clients consume bytes, not Python dicts. Pin dumps() bytes for one dialect, then extract that dialect only. Leave every other json.dumps call untouched until its own pin exists.
                                    Pin JSON Bytes and Default Handlers Before One Serializer Extract

                                      #refactoring boosted

                                      [?]Hack a Day (unofficial) » 🤖 🌐
                                      @hackaday@www.urbanmind.net

                                      A characterization test that cannot fail is decoration, not a safety net.

                                      Most messy-repo refactors fail the same way. You record golden values, they all pass, and you feel safe. Then you extract a function and ship a silent behavior change. The goldens never noticed, because they were never able to notice.

                                      Here is a workflow that fixes that. Record behavior first. Then prove each recorded case can fail. Only then make the smallest safe change.

                                      The rule: a golden you have not falsified is a guess


                                      Golden values freeze what the code does today, including its bugs. That is the point. But a passing test proves nothing on its own.

                                      A test only earns trust when you can make it fail on purpose. Without that step, your suite may be asserting on an empty result, a swallowed exception, or a stub that never runs.

                                      So the gate is simple. Every characterization case must survive one deliberate, minimal mutation of the code under test.

                                      Step 1: Inject the seams before you capture anything


                                      Messy functions hide their collaborators. Time, randomness, network calls, and global writers make goldens flaky.

                                      Do not refactor yet. Patch those seams in the harness instead. Notice the patch is temporary and lives in test code.

                                      # golden_capture.py
                                      import json
                                      from app.legacy import settle_order # the messy 90-line function under test
                                      import app.legacy as legacy

                                      CASES = [
                                      {"id": "empty_cart", "args": [[], "US"]},
                                      {"id": "one_item", "args": [[{"sku": "A1", "cents": 500, "qty": 1}], "US"]},
                                      {"id": "qty_zero", "args": [[{"sku": "A1", "cents": 500, "qty": 0}], "US"]},
                                      {"id": "unknown_region", "args": [[{"sku": "A1", "cents": 500, "qty": 2}], "ZZ"]},
                                      ]

                                      LEDGER =

                                      []def spy(name):
                                      def wrap(*a, **kw):
                                      LEDGER.append((name, [repr(x) for x in a], tuple(sorted(kw.items()))))
                                      return 0
                                      return wrap

                                      def snapshot(case):
                                      LEDGER.clear()
                                      legacy.charge_card = spy("charge_card")
                                      legacy.send_receipt = spy("send_receipt")
                                      try:
                                      return {"status": "returned", "value": settle_order(*case["args"])}
                                      except Exception as exc:
                                      return {"status": "raised", "type": type(exc).__name__, "msg": str(exc)}
                                      finally:
                                      pass

                                      def full_snapshot(case):
                                      out = snapshot(case)
                                      out["calls"] = [{"fn": n, "args": a, "kwargs": dict(k)} for n, a, k in LEDGER]
                                      return out

                                      The call ledger matters more than the return value here. Extracting a writer often preserves the result and reorders the side effects.

                                      Step 2: Capture the corpus into a file you can diff


                                      Write goldens to disk. Commit them. A reviewable diff beats a magic assertion.

                                      if __name__ == "__main__":
                                      goldens = {c["id"]: full_snapshot(c) for c in CASES}
                                      with open("goldens.json", "w") as fh:
                                      json.dump(goldens, fh, indent=2, sort_keys=True)
                                      print(f"recorded {len(goldens)} cases")

                                      Run it once against the untouched file. Inspect the JSON by hand. Delete any case whose recorded behavior looks like an artifact of your harness.

                                      Step 3: Replay the goldens as a test


                                      Keep the replay boring. One parametrized test, exact equality, no fuzzy matching.

                                      # tests/test_goldens.py
                                      import json
                                      import pytest
                                      from golden_capture import full_snapshot, CASES

                                      GOLDENS = json.load(open("goldens.json"))

                                      @pytest.mark.parametrize("case", CASES, ids=lambda c: c["id"])
                                      def test_behavior_is_frozen(case):
                                      assert full_snapshot(case) == GOLDENS[case["id"]]

                                      At this point every test passes. That is expected and meaningless. Step 4 is the one people skip.

                                      Step 4: Mutation gate — prove the suite can fail


                                      Mutate the target file in a scratch copy. If the suite still passes, that golden is untested for that branch.

                                      # mutation_gate.py
                                      import pathlib, re, shutil, subprocess, sys, tempfile

                                      MUTANTS = [
                                      (r"if qty <= 0:", "if qty < 0:"),
                                      (r"total = 0\b", "total = 1"),
                                      (r"return total", "return total + 1"),
                                      ]

                                      def run_gate(target="app/legacy.py"):
                                      src = pathlib.Path(target).read_text()
                                      survivors =
                                      [] for pattern, repl in MUTANTS:
                                      mutated, hits = re.subn(pattern, repl, src)
                                      if hits == 0:
                                      continue
                                      with tempfile.TemporaryDirectory() as td:
                                      copy = pathlib.Path(td) / "repo"
                                      shutil.copytree(".", copy, ignore=shutil.ignore_patterns(".git", ".venv", "__pycache__"))
                                      (copy / target).write_text(mutated)
                                      r = subprocess.run(
                                      [sys.executable, "-m", "pytest", "tests/test_goldens.py", "-q", "-x"],
                                      cwd=copy, capture_output=True, text=True,
                                      )
                                      if r.returncode == 0:
                                      survivors.append(pattern)
                                      return survivors

                                      if __name__ == "__main__":
                                      survivors = run_gate()
                                      print("survivors:", survivors)
                                      sys.exit(1 if survivors else 0)

                                      A survivor means one of two things. Either no case exercises that branch, or your spy layer hides the effect. Add a case, not a comment.

                                      Treat the counts in that output as illustrative. The real number depends on your function.

                                      Where a free model actually helps


                                      The expensive part is enumerating branches, not writing asserts. This is the narrow job I hand to a model: read the messy function and propose input classes I forgot.

                                      Disclosure: This article was prepared as part of MonkeyCode's product outreach.

                                      I use MonkeyCode's free model access and free server option, as described by the operator, to draft candidate cases and a first-pass mutation list. The model's output is raw material only. Every case still has to pass capture, replay, and the mutation gate before it earns a line in the corpus. The gate is the anti-hallucination step, since a model cannot verify its own tests.

                                      Step 5: Make the smallest safe change


                                      The gate is green when every mutant dies. Now touch production code once.

                                      Extract one function. Change nothing else. No renames, no formatting, no logging tweaks in the same commit.

                                      python mutation_gate.py && pytest -q && git diff --stat

                                      If the golden diff is empty and the gate still fails every mutant, you have a real safety net. If a golden changes, stop and explain why before continuing.

                                      Decision table: characterize, or walk away

                                      Situation

                                      Characterize first?

                                      Why

                                      Function you touch weekly

                                      Yes, full corpus plus gate

                                      The investment pays back fast

                                      One-off script, deleted next sprint

                                      No

                                      Goldens outlive their value

                                      Heavy nondeterminism, no injectable seam

                                      Patch seams first, then yes

                                      Flaky goldens teach nothing

                                      Function with 40+ branches, no tests

                                      Yes, but time-box it

                                      Gate tells you when coverage is enough

                                      Behavior you are about to delete

                                      No, add deletion tests after

                                      Freezing a bug is counterproductive

                                      Limitations and who should skip this


                                      Goldens freeze existing bugs, not correct behavior. That is intentional, and it is also a trap if you never revisit them.

                                      Seam patching gets fragile. If collaborators are imported deeply, the harness grows faster than the refactor.

                                      The mutation gate needs a fast test run. On a suite that takes minutes per case, this loop becomes unusable.

                                      Skip this approach for prototypes, generated code, or any file scheduled for replacement. Also skip it if your team will not review the golden JSON in pull requests. Unreviewed goldens turn into noise nobody trusts.

                                      Run the capture, run the gate, then make one small change. Ship the diff you can explain line by line. If you want a place to draft those first-pass cases, the free model access and free server option in MonkeyCode are a reasonable starting point.
                                      Make Each Characterization Test Fail Once Before You Refactor

                                        #refactoring boosted

                                        [?]Hack a Day (unofficial) » 🤖 🌐
                                        @hackaday@www.urbanmind.net

                                        There is an uncomfortable truth about most non-tech enterprises: a lot of their software is insanely shit.

                                        I don't mean that the engineers are shit. In many cases, the opposite is true. I've worked with plenty of smart engineers who were perfectly capable of building good software. The problem is that good software is expensive, and for a long time the economics of building it simply didn't make sense.

                                        Think about the incentives inside a large enterprise. You have an internal system that employees have to use every day. The interface is confusing, the workflows are clunky, the codebase is a mess, and everyone knows it needs a serious refactor. Maybe there are thousands of lines of code that should be deleted. Maybe the architecture was designed ten years ago around assumptions that haven't been true for years. Maybe adding a simple feature now takes three weeks because nobody really understands how the thing works anymore.

                                        The engineers know this. The users know this. Everyone complains about it.

                                        And then someone proposes spending three months fixing it.

                                        Why?

                                        What exactly does the business get at the end of those three months?

                                        There probably isn't a new customer. There isn't a new revenue stream. There isn't a shiny feature that can go into a quarterly presentation. Nobody in sales can put the refactor into a demo. The business has simply spent three months making something that already worked—technically speaking—less shit.

                                        From the perspective of an executive looking at a roadmap, that's a difficult proposition to approve.

                                        And in many enterprises, the incentives are even worse than that. The users aren't going anywhere. If you work for a bank, an airline, a government department, an insurance company, or some giant industrial company, you don't get to decide that you're going to use a competitor's internal software instead. You're using whatever system your employer gives you.

                                        So why obsess over UX?

                                        If your customers can leave, product quality matters enormously. If your customers are employees who have no choice, the incentive is very different.

                                        This is one of the reasons I think a lot of terrible enterprise software actually makes sense.

                                        Not to me, necessarily. But within the incentive structure of the organization, it can make perfect sense.

                                        The company needs feature X by September. The team has four engineers. The existing system technically works. There is a giant backlog. Nobody has allocated time for a rewrite. The person responsible for the budget doesn't personally use the software. The person who understands the architecture is worried about hitting the deadline. And the consequences of making the code slightly worse won't show up on anyone's quarterly report.

                                        So you ship it.

                                        Then you ship the next thing.

                                        Then the next thing.

                                        And eventually you have a system that everyone hates.

                                        The hidden cost of software everyone hates


                                        The problem is that "the software technically works" is an incredibly low bar.

                                        A piece of software can work perfectly well and still make thousands of people's lives slightly worse every single day.

                                        A workflow that should take two minutes takes ten. A page that should load instantly takes thirty seconds. An employee has to enter the same information into three different systems. An error message tells them absolutely nothing. A form resets itself for no apparent reason. A button is hidden behind some bizarre legacy navigation. Nobody knows why the process works this way, but changing it feels too risky.

                                        Each individual annoyance is tiny.

                                        The problem is that these tiny annoyances compound.

                                        If 5,000 employees waste ten minutes a day fighting with internal software, that's more than 200,000 hours of human time every year. And even that calculation misses something important: the psychological cost.

                                        People don't just lose ten minutes.

                                        They become frustrated.

                                        They lose momentum. They have to remember workarounds. They start distrusting the systems around them. They learn that doing their job means fighting with the tools provided to them.

                                        And eventually, they start hating the job itself.

                                        We talk endlessly about employee engagement and morale. Companies spend fortunes on offsites, leadership programmes, wellness initiatives and motivational speakers. But there is something deeply absurd about trying to improve employee morale while forcing people to spend eight hours a day using software they hate.

                                        Everyone knows that teams with low morale don't win championships.

                                        We understand this intuitively in sports. Nobody looks at a dysfunctional football team and says, "It's fine, the players are still technically capable of running around."

                                        We care about the environment in which people perform.

                                        Software is part of that environment.

                                        And yet, in many companies, the quality of the software employees use every day is treated as an engineering concern rather than a business concern.

                                        The dinosaur problem


                                        There is also a generational problem here.

                                        A lot of people making technology decisions in large organizations didn't grow up thinking of software as a product. They grew up thinking of it as infrastructure: something the IT department provides so that the business can function.

                                        You don't ask whether the electricity in the office has a delightful user experience.

                                        You don't care whether the database has beautiful onboarding.

                                        You just need it to work.

                                        That mentality made a lot more sense when software was something employees occasionally interacted with. It makes considerably less sense when software is effectively the workplace itself.

                                        But organizational thinking moves slowly.

                                        Companies are enormous machines with long memories. The incentives, processes and mental models that made sense twenty years ago can survive long after the world that created them has disappeared.

                                        And this is where I think something genuinely exciting is happening.

                                        Coding agents are changing the economics of software quality.

                                        The three-month refactor


                                        Imagine that you have a horrible codebase.

                                        For years, the engineering team has known that it should be cleaned up. Everyone has a list of things they would change if they had the time. But the time never comes.

                                        The reason is not necessarily that management doesn't care. The reason is that the opportunity cost is enormous.

                                        A traditional refactor might require one or two engineers to spend weeks or months understanding the existing system, designing the changes, implementing them, testing everything, fixing regressions and gradually migrating the code.

                                        Three months is a long time.

                                        And three months of engineering time is expensive.

                                        Now imagine that instead of taking three months, an extremely capable coding agent can do a large part of the work in a day.

                                        Not every refactor, obviously. Not every codebase. And certainly not without human oversight, testing and engineering judgement.

                                        But the economic threshold has changed.

                                        Something that previously required a three-month business case might now require an afternoon.

                                        That is a completely different proposition.

                                        AI makes quality economically viable


                                        This is the part of AI-assisted programming that excites me much more than the promise of simply writing code faster.

                                        I don't think the most interesting consequence of coding agents is that developers can produce more lines of code.

                                        Frankly, the world already has enough lines of code.

                                        The interesting consequence is that code quality itself can become economically viable in places where it wasn't before.

                                        For a long time, software engineering involved an uncomfortable tradeoff. You could build the right thing, properly, but it would take longer. Or you could ship something that was good enough and move on.

                                        There were always engineers arguing for the former, and there were always business pressures pushing toward the latter.

                                        Sometimes the business won for perfectly rational reasons.

                                        But what happens when the cost of doing the right thing falls by an order of magnitude?

                                        Suddenly, the argument changes.

                                        The refactor doesn't have to compete with the feature roadmap in the same way. The cleanup doesn't necessarily require taking a team off delivery for a quarter. The ugly piece of legacy code doesn't have to remain ugly simply because nobody can justify spending six weeks fixing it.

                                        And perhaps most importantly, engineers don't have to spend as much time convincing people that quality matters.

                                        This is a subtle but profound shift.

                                        In the old world, if I wanted to spend three months improving a codebase, I had to convince someone that three months of engineering time was worth spending on something that users might not immediately notice.

                                        I needed a business case.

                                        I needed projections about future productivity.

                                        I needed to explain how technical debt compounds.

                                        I needed to convince someone to spend money today to avoid costs that might appear six months or two years from now.

                                        That's hard.

                                        But if the same improvement can be done in eight hours with an agent, the conversation becomes very different.

                                        Maybe I don't need a business case.

                                        Maybe I can just do it.

                                        The software we already have


                                        This is why I think the biggest impact of coding agents might not be that they allow us to build more software.

                                        We already build an enormous amount of software.

                                        It might be that they finally allow us to make the software we already have good.

                                        There are millions of applications sitting inside organizations that are not fundamentally broken. They are just mediocre. They are slow, awkward, overcomplicated, badly structured and unpleasant to maintain.

                                        And for years, that was the equilibrium.

                                        The software was bad, but fixing it was too expensive.

                                        So everyone learned to live with it.

                                        Engineers learned the workarounds. Employees learned the weird workflows. Managers learned to accept the complaints. Executives learned that the system "worked."

                                        The organization adapted itself around the limitations of its software.

                                        That is a very strange thing when you step back and look at it.

                                        We built computers to make people more productive, and then spent decades making people adapt their behaviour to accommodate the computers.

                                        Coding agents could start reversing that relationship.

                                        If an engineer can take a horrible codebase and dramatically improve it without blowing up the delivery schedule, the default assumption no longer has to be that technical debt is permanent.

                                        Maybe the ugly code doesn't have to stay.

                                        Maybe the terrible internal tool can actually become pleasant.

                                        Maybe the three-month refactor becomes an afternoon.

                                        And if that happens at scale, the consequences aren't just technical.

                                        They are organizational.

                                        Because when the software gets better, the people using it get better tools. When people have better tools, they waste less time. They get less frustrated. They can move faster. They spend less mental energy fighting the system and more energy doing the thing the system was supposed to help them do.

                                        Good software isn't just a technical luxury.

                                        It is part of the working environment.

                                        And for the first time, we may be entering a world where making it good is cheap enough that even enterprises have less of an excuse not to.

                                        Maybe the future of coding agents isn't that we'll write ten times as much code.

                                        Maybe it's that we finally get to stop writing quite so much shit code.
                                        Maye Enterprise Software Doesn’t Have to Suck Anymore

                                          #refactoring boosted

                                          [?]Hack a Day (unofficial) » 🤖 🌐
                                          @hackaday@www.urbanmind.net

                                          Characterization Tests First, Then the Smallest Safe Change


                                          Stop refactoring. Start recording. The smallest safe change wins only if you can prove nothing else moved.

                                          That is the whole method. You freeze the hidden inputs, snapshot the current output, then change one line. The snapshot decides whether you were safe.

                                          This article walks through a runnable loop on a small legacy stand-in module. Swap in your real module and the steps stay the same.

                                          1. Turn the ticket into a behavior, not a design


                                          The ticket here is vague: unknown PLAN breaks invoice generation. Do not translate that into an architecture plan yet.

                                          Translate it into one observable statement. "Unknown plan raises KeyError before any row is processed."

                                          Now the statement is testable. It also tells you the bug lives in one lookup, not in the whole loop.

                                          2. Freeze the hidden globals before you assert anything


                                          Legacy modules read two hidden inputs constantly: the clock and the environment. Both change between your laptop and CI.

                                          Here is the stand-in under test. It is deliberately small and deliberately dirty.

                                          # billing/cycle.py
                                          import datetime
                                          import os

                                          RATES = {"standard": 1.0, "pro": 0.8, "legacy": 1.25}

                                          def invoice_lines(rows):
                                          today = datetime.date.today()
                                          plan = os.environ.get("PLAN", "standard")
                                          rate = RATES
                                          [plan] out =
                                          [] for row in rows:
                                          days = (today - row["start"]).days
                                          if days < 0:
                                          days = 0
                                          amount = round(row["units"] * rate * days, 2)
                                          out.append({"id": row["id"], "days": days, "amount": amount})
                                          return out

                                          Two hidden inputs sit in four lines. datetime.date.today() reads the machine clock. os.environ.get reads the process environment.

                                          Freeze both by patching the names inside the module under test. Never patch the stdlib module globally.

                                          # tests/test_cycle_char.py
                                          import datetime
                                          import json
                                          import os
                                          import pathlib
                                          import types

                                          import pytest

                                          from billing import cycle

                                          GOLDEN = pathlib.Path(__file__).parent / "golden" / "invoice_lines.json"

                                          class FixedDate(datetime.date):
                                          @classmethod
                                          def today(cls):
                                          return cls(2026, 3, 1)

                                          @pytest.fixture
                                          def frozen(monkeypatch):
                                          monkeypatch.setenv("PLAN", "pro")
                                          monkeypatch.setattr(cycle, "datetime", types.SimpleNamespace(date=FixedDate))

                                          This works because the module calls datetime.date.today() by attribute access. A module that does from datetime import date needs a different patch point.

                                          That detail matters. Note it, or you will fight a passing test that proves nothing.

                                          3. Record a golden, then assert against it


                                          Write the rows once, run them, and save the output. The recorder is the source of truth, not your memory of the old behavior.

                                          ROWS = [
                                          {"id": "a1", "start": datetime.date(2026, 2, 1), "units": 3},
                                          {"id": "a2", "start": datetime.date(2026, 3, 4), "units": 2},
                                          {"id": "a3", "start": datetime.date(2026, 3, 1), "units": 0},
                                          ]

                                          def test_invoice_lines_matches_golden(frozen):
                                          got = cycle.invoice_lines(ROWS)
                                          if os.environ.get("RECORD_GOLDEN") == "1":
                                          GOLDEN.parent.mkdir(parents=True, exist_ok=True)
                                          GOLDEN.write_text(json.dumps(got, indent=2))
                                          assert got == json.loads(GOLDEN.read_text())

                                          Record once with RECORD_GOLDEN=1 python -m pytest tests/test_cycle_char.py -q. The first run always passes, and that is expected.

                                          Read the recorded file by hand before you commit it. A snapshot is not a truth claim.

                                          [
                                          {"id": "a1", "days": 28, "amount": 67.2},
                                          {"id": "a2", "days": 0, "amount": 0.0},
                                          {"id": "a3", "days": 0, "amount": 0.0}
                                          ]

                                          Pin the error path too. Empty input still hits the lookup, so the exception fires before the loop.

                                          def test_unknown_plan_raises_keyerror(monkeypatch, frozen):
                                          monkeypatch.setenv("PLAN", "platinum")
                                          with pytest.raises(KeyError):
                                          cycle.invoice_lines([])

                                          4. Prove the harness bites before you trust it


                                          A test that cannot fail is decoration. Break a copy of the code and confirm the suite goes red.

                                          rsync -a --exclude .git ./ /tmp/char-mut/
                                          sed -i 's/if days < 0:/if days < -1:/' /tmp/char-mut/billing/cycle.py
                                          cd /tmp/char-mut && python -m pytest tests/test_cycle_char.py -q; echo "exit=$?"

                                          Expect exit=1. The clamped future date now leaks a negative days value, and the golden catches it.

                                          If the suite still passes, your cases do not cover the branch. Fix that before touching the real module.

                                          5. Take the smallest change, then edit the pin on purpose


                                          Smallest here is one expression. Replace the strict lookup with a guarded one.

                                          - rate = RATES
                                          [plan]+ rate = RATES.get(plan, RATES["standard"])

                                          This changes observable behavior, so the pinned error test must change with it. Edit it deliberately, in the same commit.

                                          -def test_unknown_plan_raises_keyerror(monkeypatch, frozen):
                                          - monkeypatch.setenv("PLAN", "platinum")
                                          - with pytest.raises(KeyError):
                                          - cycle.invoice_lines([])
                                          +def test_unknown_plan_falls_back_to_standard(monkeypatch, frozen):
                                          + monkeypatch.setenv("PLAN", "platinum")
                                          + assert cycle.invoice_lines([]) ==

                                          []Now the diff shows one intended behavior edit. Everything else stays green, which is the only proof you have.

                                          Use Python 3.11 or newer here. Python 3.9 is already past its end-of-life date, so new test tooling should not target it.

                                          6. Use a change ladder instead of judgment calls


                                          Rank candidate changes by blast radius. Take rank 0 first, then climb one rung per commit.

                                          Rank

                                          Change

                                          Lines touched

                                          Gate to pass

                                          0

                                          Rename a local variable

                                          1

                                          goldens unchanged

                                          1

                                          Add a guard clause

                                          1-3

                                          goldens unchanged

                                          2

                                          Extract one pure helper

                                          5-15

                                          goldens unchanged, call order identical

                                          3

                                          Change observable behavior

                                          1-5

                                          exactly one pinned test edited on purpose

                                          4

                                          Move the module

                                          many

                                          old import path still pinned

                                          5

                                          Rewrite the module

                                          all

                                          stop, split into ranks 0-4


                                          Rank 3 is the one people skip. They mix a behavior fix with a structure change and lose the ability to review either.

                                          7. Know when to stop the loop


                                          Set stop rules before you start. Seam hunting expands without limits otherwise.

                                          • Stop if freezing the hidden globals takes more than about twenty minutes.
                                          • Stop if the golden output contains wall-clock timestamps or process IDs you cannot stub.
                                          • Stop if behavior depends on network calls, random seeds, or thread interleaving you cannot reproduce.
                                          • Stop if you cannot run the code at all. Characterization without execution is fiction.

                                          Each stop is a signal to shrink scope, not to lower standards.

                                          8. Where model assistance fits, and where it does not


                                          Drafting the edge-case rows in step 3 is the slow part. That is the part worth delegating.

                                          I use MonkeyCode's free model access to propose candidate input rows and hidden-global guesses. Disclosure: This article was prepared as part of MonkeyCode's product outreach. The free server option lets me run the mutation check on a repo copy without provisioning a machine of my own.

                                          The model proposes. The recorder decides. Never let a model write the expected output from memory.

                                          Free access and a free server option are the only two availability claims I make. I have not measured throughput or uptime, and a free tier is not an SLA.

                                          9. Limitations and who should not use this


                                          Characterization tests pin current behavior, including bugs. They do not prove correctness and they do not replace intent-based tests.

                                          Exact JSON equality is brittle with floats. Compare amounts with pytest.approx when your golden is hand-edited.

                                          Clock patching only works when the module reads time by attribute access. Import styles, C extensions, and third-party clients may refuse the patch.

                                          Skip this method if you are writing greenfield code, deleting the module next sprint, or have no way to execute it. Skip it if your team cannot review a behavior diff honestly.

                                          If you run this loop on a repo copy, start with the recorder and keep it in charge of the truth.
                                          Characterization Tests First, Then the Smallest Safe Change

                                            #refactoring boosted

                                            [?]Hack a Day (unofficial) » 🤖 🌐
                                            @hackaday@www.urbanmind.net

                                            Deciding when to modernise a legacy software system is one of the most consequential architectural...
                                            Legacy Software Modernisation: Rewrite vs Refactor Guide

                                              #refactoring boosted

                                              [?]Hack a Day (unofficial) » 🤖 🌐
                                              @hackaday@www.urbanmind.net

                                              Mainframe modernisation is rarely a single decision. It is a choice between several distinct...
                                              Mainframe Modernisation: Rewrite, Refactor or Replatform

                                                #refactoring boosted

                                                [?]Hack a Day (unofficial) » 🤖 🌐
                                                @hackaday@www.urbanmind.net

                                                Mixed None-and-raise APIs fail under model rewrites. The error taxonomy is the real product surface. Pin that taxonomy with characterization tests first, always.

                                                Change one except clause only after the pin stays green. Happy-path unit tests will not save this refactor. Callers already branch on None, dict keys, and types.

                                                Collapse any one of those shapes and production breaks. The defect is an unrecorded taxonomy, not missing types.

                                                Why model rewrites miss the contract


                                                AI coding threads keep offering full-file cleanups. Those cleanups prefer one error type. They also prefer raising over returning None.

                                                That preference is style, not evidence. A messy dispatcher often has three outcomes. Some inputs raise ValueError for bad payloads.

                                                Some inputs return None after a send failure. Some inputs return {"ok": False, "status": 429}. Downstream code already checks all three shapes.

                                                Loop-style agent edits make the same cut. They unify handlers because duplication looks sloppy. Duplication here is the published contract.

                                                Four observables to freeze


                                                Record these four fields for every fixture. Skip prose messages on the first pass. Message strings drift across harmless edits.

                                                1. Escaping exception type, or a sentinel if none.
                                                2. Return shape: None, mapping keys, or other.
                                                3. Integer status when a mapping returns.
                                                4. Count of WARNING-or-higher log records.

                                                Types and keys usually stay stable. Status integers also stay stable. Log counts need a named logger, not the root.

                                                Decision table for one dispatcher


                                                The table below is the spec. It is a labeled example. It is not a production trace.

                                                Fixture

                                                Escapes

                                                Return

                                                Status

                                                WARN+

                                                empty body

                                                RuntimeError

                                                n/a

                                                n/a

                                                0

                                                invalid JSON

                                                ValueError

                                                n/a

                                                n/a

                                                0

                                                JSON list

                                                ValueError

                                                n/a

                                                n/a

                                                0

                                                missing id

                                                none

                                                None

                                                n/a

                                                1

                                                send TypeError

                                                none

                                                None

                                                n/a

                                                1

                                                send TimeoutError

                                                none

                                                None

                                                n/a

                                                1

                                                downstream 429

                                                none

                                                dict

                                                429

                                                1

                                                downstream 200

                                                none

                                                dict

                                                200

                                                0


                                                Keep the send TypeError row. That row is the trap. A cleaner except often drops it.

                                                Artifact: characterization harness


                                                The code is a worked example. Run it locally before any extract. Do not treat it as measured field data.

                                                # events.py — labeled example, not a live service
                                                from __future__ import annotations

                                                import json
                                                import logging
                                                from typing import Any, Callable

                                                log = logging.getLogger("events")

                                                SendFn = Callable[[dict], tuple[int, Any]]

                                                def dispatch_event(raw: str | None, send: SendFn):
                                                """Messy contract: None, dict, or raise."""
                                                if raw is None or raw == "":
                                                raise RuntimeError("empty body")
                                                try:
                                                payload = json.loads(raw)
                                                except Exception:
                                                raise ValueError("bad json")
                                                if not isinstance(payload, dict):
                                                raise ValueError("bad json")
                                                if "id" not in payload:
                                                log.warning("missing id")
                                                return None
                                                try:
                                                status, body = send(payload)
                                                except Exception:
                                                log.warning("send failed")
                                                return None
                                                if status >= 400:
                                                log.warning("downstream %s", status)
                                                return {"ok": False, "status": status, "body": body}
                                                return {"ok": True, "status": status, "body": body}

                                                # test_events_contract.py — worked example
                                                import json
                                                import logging

                                                import pytest

                                                from events import dispatch_event

                                                def _records(caplog):
                                                return [r for r in caplog.records if r.name == "events" and r.levelno >= logging.WARNING]

                                                def _run(raw, send, caplog):
                                                caplog.set_level(logging.WARNING, logger="events")
                                                try:
                                                value = dispatch_event(raw, send)
                                                return None, value, _records(caplog)
                                                except Exception as exc:
                                                return type(exc), None, _records(caplog)

                                                def test_empty_body_raises_runtime_error(caplog):
                                                exc, value, recs = _run("", lambda p: (200, "ok"), caplog)
                                                assert exc is RuntimeError
                                                assert value is None
                                                assert len(recs) == 0

                                                @pytest.mark.parametrize("raw", ["{", "[]", "null"])
                                                def test_bad_json_raises_value_error(raw, caplog):
                                                exc, value, recs = _run(raw, lambda p: (200, "ok"), caplog)
                                                assert exc is ValueError
                                                assert value is None
                                                assert len(recs) == 0

                                                def test_missing_id_returns_none(caplog):
                                                exc, value, recs = _run('{"name": "x"}', lambda p: (200, "ok"), caplog)
                                                assert exc is None
                                                assert value is None
                                                assert len(recs) == 1

                                                def test_send_type_error_returns_none(caplog):
                                                def send(_payload):
                                                raise TypeError("broken client")

                                                exc, value, recs = _run('{"id": 1}', send, caplog)
                                                assert exc is None
                                                assert value is None
                                                assert len(recs) == 1

                                                def test_send_timeout_returns_none(caplog):
                                                def send(_payload):
                                                raise TimeoutError("late")

                                                exc, value, recs = _run('{"id": 1}', send, caplog)
                                                assert exc is None
                                                assert value is None
                                                assert len(recs) == 1

                                                def test_downstream_429_is_dict(caplog):
                                                exc, value, recs = _run('{"id": 1}', lambda p: (429, "slow"), caplog)
                                                assert exc is None
                                                assert value == {"ok": False, "status": 429, "body": "slow"}
                                                assert len(recs) == 1

                                                def test_downstream_200_is_dict(caplog):
                                                exc, value, recs = _run('{"id": 1}', lambda p: (200, {"n": 1}), caplog)
                                                assert exc is None
                                                assert value == {"ok": True, "status": 200, "body": {"n": 1}}
                                                assert len(recs) == 0

                                                Run the file before editing handlers. Use a short traceback during the first pin.

                                                python -m pytest test_events_contract.py -q --tb=short

                                                Prove the suite catches a collapsed taxonomy


                                                A green suite with no mutation check is weak. You must see a failed unified-error fork. Keep that fork out of the merge.

                                                # test_events_mutation.py — must fail against a unified raise rewrite
                                                import events

                                                def test_unified_raise_would_break_send_type_error(monkeypatch, caplog):
                                                def rewritten(raw, send):
                                                payload = __import__("json").loads(raw)
                                                status, body = send(payload) # TypeError now escapes
                                                return {"ok": status < 400, "status": status, "body": body}

                                                monkeypatch.setattr(events, "dispatch_event", rewritten)
                                                with pytest.raises(TypeError):
                                                events.dispatch_event('{"id": 1}', lambda p: (_ for _ in ()).throw(TypeError("x")))

                                                Expect this mutation test to fail on the rewrite. Restore the messy handler after that check. The failure is the evidence, not a vibe.

                                                Numbered workflow


                                                1. Copy the messy module into a branch. Do not edit handlers yet.
                                                2. Inventory callers that check is None or catch types.
                                                3. Fill the four-column table from those callers.
                                                4. Encode each row as one test function.
                                                5. Add one mutation test that unifies errors.
                                                6. Confirm the mutation test fails on purpose.
                                                7. Restore the original handler body.
                                                8. Apply one extract or one except edit.
                                                9. Re-run the same characterization file.
                                                10. Revert if any row changes shape.

                                                Step two is not optional. Tests invented from the callee miss caller branches. Caller branches are the contract.

                                                rg -n "dispatch_event\(" -g "*.py"
                                                rg -n "is None|except ValueError|except RuntimeError" -g "*.py"

                                                Record each hit as a fixture name. Missing hits become missing rows. Missing rows make unsafe extracts look safe.

                                                The smallest safe change


                                                Do not narrow except Exception on send first. The table says TypeError becomes None. Narrowing that clause raises TypeError instead.

                                                That raise is a contract break. Extract the block without narrowing. Keep the same log line and None return.

                                                def _send_or_none(payload, send):
                                                try:
                                                return send(payload)
                                                except Exception:
                                                log.warning("send failed")
                                                return None

                                                def dispatch_event(raw, send):
                                                if raw is None or raw == "":
                                                raise RuntimeError("empty body")
                                                payload = _parse_object(raw)
                                                if "id" not in payload:
                                                log.warning("missing id")
                                                return None
                                                result = _send_or_none(payload, send)
                                                if result is None:
                                                return None
                                                status, body = result
                                                if status >= 400:
                                                log.warning("downstream %s", status)
                                                return {"ok": False, "status": status, "body": body}
                                                return {"ok": True, "status": status, "body": body}

                                                That extract is boring. Boring is the point. The taxonomy stays in the table.

                                                A later change can narrow exceptions. Do that only with a version note. Add a row that expects TypeError to escape. Tell callers before you merge.

                                                Parse-side except narrowing


                                                The JSON branch is different. json.loads should raise json.JSONDecodeError. Re-raising ValueError is the published type.

                                                Narrowing except Exception to except json.JSONDecodeError can be safe. Prove it with the invalid JSON row. Prove it with the JSON list row too.

                                                def _parse_object(raw):
                                                try:
                                                payload = json.loads(raw)
                                                except json.JSONDecodeError:
                                                raise ValueError("bad json") from None
                                                if not isinstance(payload, dict):
                                                raise ValueError("bad json")
                                                return payload

                                                Both bad-JSON fixtures must still raise ValueError. A leaked JSONDecodeError is a taxonomy change. Do not ship that leak without a caller audit.

                                                from None also drops __cause__. Add a cause fixture if any caller reads it. Skip that fixture when no caller inspects causes.

                                                Where a free coding model belongs


                                                Disclosure: This article was prepared as part of MonkeyCode's product outreach.

                                                A model can draft the extract. It must not invent a new taxonomy. MonkeyCode provides free model access and a free server option.

                                                Use either only after the characterization file is green. Feed the model the table and the test file. Ask for one extract, not a rewrite.

                                                Reject a patch that removes a row. Reject a patch that changes None into a raise. This is not an agent loop.

                                                One prompt. One diff. Same tests.

                                                Limitations


                                                The harness does not prove semantic equality. It pins types, keys, status, and log counts. It misses timing, retry storms, and byte identity.

                                                Log counts break if a library logs extra warnings. Pin the logger name events. Cap propagation on that logger.

                                                Do not pin the root logger. Root pins go red on unrelated imports. That noise hides a real taxonomy drift.

                                                The table is only as good as the fixtures. One unlisted caller path is an untested shape. Untested shapes are how "safe" extracts land in incident channels.

                                                Who should not use this


                                                Do not use this flow for greenfield APIs. Design one error shape there. Do not preserve None plus raises on purpose.

                                                Do not use this flow for security boundaries. Characterization will pin insecure behavior. Pinning is not hardening.

                                                Do not use this flow without tests you can run offline. A model server cannot replace the table. If pytest cannot collect locally, stop.

                                                Teams with a published OpenAPI error schema may skip dict-key rows. Use the schema as the table instead. Still pin process-local exception escapes.

                                                HTTP schemas often omit those local raises. Omitting them is how ValueError turns into a 500. Keep the escape column anyway.

                                                Checklist before merge


                                                1. Characterization file is the only new proof.
                                                2. Diff touches one handler extract or one except.
                                                3. Mutation test still fails against a unified-error fork.
                                                4. No fixture row changed shape.
                                                5. None callers still exist, or a version note ships.

                                                If a model patch violates any line, drop the patch. Rewrite pressure is not evidence. The table is.

                                                If the characterization file is already green, one extract prompt on the free server is enough. Skip that prompt when the table still has empty cells.
                                                Freeze the Error Contract Before One except Change

                                                  #refactoring boosted

                                                  [?]Hack a Day (unofficial) » 🤖 🌐
                                                  @hackaday@www.urbanmind.net

                                                  Do not extract file helpers from a messy module yet. Snapshot every resolved absolute path before the first edit. Relative opens form a hidden contract across three roots.

                                                  Most failed extracts start as a path-root mismatch. The helper looks cleaner after the extract lands. The files then land in a different directory tree.

                                                  Three roots, one unpublished API


                                                  A typical messy module mixes three path roots. None of them appear in the public function signature.

                                                  os.getcwd() follows the process, not the source file. Pytest, systemd, and cron each change that value.

                                                  Path(__file__).resolve() follows the module on disk. A later package move silently retargets every relative open.

                                                  An env var such as DATA_DIR may override both. Empty, relative, and absolute values all behave differently.

                                                  The public function still accepts only a basename. Callers believe the output path stays stable. That belief does not survive a chdir.

                                                  Teaching example: three writes, three roots


                                                  The listing below is a teaching example, not production code. It writes one report beside three different roots.

                                                  # report_kit.py — messy on purpose
                                                  from __future__ import annotations

                                                  import json
                                                  import os
                                                  from pathlib import Path

                                                  HERE = Path(__file__).resolve().parent

                                                  def write_daily_report(name: str) -> dict[str, str]:
                                                  data_dir = os.environ.get("DATA_DIR", "data")
                                                  cwd_out = Path("out") / name
                                                  here_out = HERE / "out" / name
                                                  env_out = Path(data_dir) / name

                                                  payload = {"name": name, "pid": os.getpid()}
                                                  text = json.dumps(payload, sort_keys=True) + "\n"

                                                  cwd_out.parent.mkdir(parents=True, exist_ok=True)
                                                  here_out.parent.mkdir(parents=True, exist_ok=True)
                                                  env_out.parent.mkdir(parents=True, exist_ok=True)

                                                  cwd_out.write_text(text, encoding="utf-8")
                                                  here_out.write_text(text, encoding="utf-8")
                                                  env_out.write_text(text, encoding="utf-8")

                                                  return {
                                                  "cwd": str(cwd_out),
                                                  "here": str(here_out),
                                                  "env": str(env_out),
                                                  }

                                                  A naive extract wraps the three write_text calls. It often introduces Path.cwd() in one place. One root then silently absorbs the other two.

                                                  Return values still look like relative strings. Tests that assert those strings stay green. The bytes move anyway.

                                                  Artifact: a path ledger


                                                  Build a ledger before any helper extract. Record caller, raw argument, and resolved absolute path. Hash the sorted ledger. Treat that hash as a characterization oracle.

                                                  # path_ledger.py — teaching harness
                                                  from __future__ import annotations

                                                  import hashlib
                                                  import json
                                                  import os
                                                  import traceback
                                                  from pathlib import Path

                                                  LEDGER: list[dict[str, str]] =

                                                  []def _caller() -> str:
                                                  frames = traceback.extract_stack()
                                                  for frame in reversed(frames[:-1]):
                                                  if "path_ledger.py" not in frame.filename:
                                                  return f"{frame.filename}:{frame.lineno}:{frame.name}"
                                                  return "unknown"

                                                  def record(kind: str, raw: str, resolved: Path) -> None:
                                                  LEDGER.append(
                                                  {
                                                  "kind": kind,
                                                  "caller": _caller(),
                                                  "raw": raw,
                                                  "cwd": os.getcwd(),
                                                  "resolved": str(resolved.resolve()),
                                                  }
                                                  )

                                                  def ledger_hash() -> str:
                                                  blob = json.dumps(LEDGER, sort_keys=True, indent=2)
                                                  return hashlib.sha256(blob.encode("utf-8")).hexdigest()

                                                  def dump(path: Path) -> str:
                                                  path.write_text(
                                                  json.dumps(LEDGER, indent=2, sort_keys=True) + "\n",
                                                  encoding="utf-8",
                                                  )
                                                  return ledger_hash()

                                                  Wrap writes at the test boundary only. Do not patch production code for this measurement. Run the same invocation the module already trusts.

                                                  # test_path_ledger.py — characterization, not a unit test
                                                  from __future__ import annotations

                                                  from pathlib import Path
                                                  from unittest.mock import patch

                                                  import path_ledger
                                                  import report_kit

                                                  GOLDEN = Path(__file__).parent / "goldens" / "report_kit_paths.json"
                                                  GOLDEN_HASH = Path(__file__).parent / "goldens" / "report_kit_paths.sha256"

                                                  def _traced_write_text(self: Path, *args, **kwargs):
                                                  path_ledger.record("Path.write_text", str(self), self)
                                                  return Path.write_text(self, *args, **kwargs)

                                                  def test_write_daily_report_path_ledger(tmp_path, monkeypatch):
                                                  monkeypatch.chdir(tmp_path)
                                                  monkeypatch.setenv("DATA_DIR", str(tmp_path / "env-data"))
                                                  monkeypatch.setattr(report_kit, "HERE", tmp_path / "pkg")
                                                  (tmp_path / "pkg").mkdir()

                                                  with patch.object(Path, "write_text", _traced_write_text):
                                                  report_kit.write_daily_report("daily.json")

                                                  digest = path_ledger.dump(tmp_path / "ledger.json")
                                                  if not GOLDEN.exists():
                                                  GOLDEN.parent.mkdir(parents=True, exist_ok=True)
                                                  GOLDEN.write_text(
                                                  (tmp_path / "ledger.json").read_text(encoding="utf-8"),
                                                  encoding="utf-8",
                                                  )
                                                  GOLDEN_HASH.write_text(digest + "\n", encoding="utf-8")
                                                  raise AssertionError("golden created; rerun to pin")

                                                  assert digest == GOLDEN_HASH.read_text(encoding="utf-8").strip()

                                                  Label this pin as an oracle, not as coverage. The first run writes goldens on purpose. The second run fails on any resolved-path drift.

                                                  Trace mkdir in the same harness when directories matter. A helper can reuse a folder the original code created. That reuse still retargets later writes.

                                                  Decision table for one extract


                                                  Use the table before any patch is accepted. Each row is a veto, not a preference.

                                                  Signal in the ledger

                                                  Safe extract?

                                                  Required pin

                                                  cwd-relative out/name

                                                  Not yet

                                                  chdir plus expected absolute

                                                  __file__-relative out/name

                                                  Not yet

                                                  frozen HERE

                                                  env-relative DATA_DIR/name

                                                  Not yet

                                                  empty, relative, absolute env

                                                  mixed roots in one function

                                                  No

                                                  split by root, not call shape

                                                  only basenames change

                                                  Yes

                                                  hash still matches


                                                  A model often groups the three writes together. They share write_text, so the grouping looks obvious. The ledger groups them by root instead.

                                                  Root grouping is the correct split. Call-shape grouping is the usual defect.

                                                  Numbered workflow


                                                  Follow these steps in order and skip none.

                                                  1. Record one real invocation with cwd, env, and argv stored together.
                                                  2. Trace every open, write_text, mkdir, and Path constructor you might move.
                                                  3. Resolve each raw path against the recorded cwd, storing absolutes only.
                                                  4. Hash the sorted ledger, then commit both the hash and JSON.
                                                  5. Add three extra invocations: new cwd, unset DATA_DIR, absolute DATA_DIR.
                                                  6. Refuse every extract until all four hashes stay stable across reruns.
                                                  7. Extract one root only, and keep the other two writes inline.
                                                  8. Diff the ledger JSON, not the source, before any merge.

                                                  Relative goldens will lie after a machine change. Absolute goldens survive a different checkout path. That is the entire point of the pin.

                                                  Commands for the first pin:

                                                  mkdir -p goldens
                                                  python -m pytest test_path_ledger.py -q
                                                  # first run creates goldens and fails
                                                  python -m pytest test_path_ledger.py -q
                                                  # second run must pass before any extract

                                                  Commands after a proposed extract:

                                                  python -m pytest test_path_ledger.py -q
                                                  git diff -- goldens/report_kit_paths.json
                                                  # any resolved-path line change is a rejected patch

                                                  A smoking-gun diff looks like this fragment:

                                                  - "resolved": "/tmp/pytest-of-dev/test0/out/daily.json"
                                                  + "resolved": "/home/ci/project/out/daily.json"

                                                  The source diff can still look like a tidy helper. The ledger line is the reject signal.

                                                  Smallest safe change


                                                  The smallest change moves one root. It does not introduce a generic writer yet.

                                                  def _write_cwd_report(name: str, payload: str) -> str:
                                                  target = Path("out") / name
                                                  target.parent.mkdir(parents=True, exist_ok=True)
                                                  target.write_text(payload, encoding="utf-8")
                                                  return str(target)

                                                  Leave __file__ and DATA_DIR writes in the original function. A later extract can take the second root. Each extract must keep the ledger hash unchanged.

                                                  Do not normalize paths inside the new helper. Normalization is a behavior change, not cleanup. Record it as a new ledger when you truly need it.

                                                  Avoid .resolve() in the extracted helper. resolve() follows symlinks and can rewrite goldens. Prefer the same construction the messy module already used.

                                                  Mixed open() and Path modules


                                                  Some messy modules still call open() directly. Trace that path with a thin wrapper. Keep the same ledger schema for both styles.

                                                  import builtins
                                                  from pathlib import Path

                                                  _real_open = builtins.open

                                                  def traced_open(file, mode="r", *args, **kwargs):
                                                  raw = str(file)
                                                  path_ledger.record("open", raw, Path(file))
                                                  return _real_open(file, mode, *args, **kwargs)

                                                  Install the wrapper only inside the characterization test. Production code should keep its original open. The oracle lives in the test process.

                                                  If both open and Path.write_text appear, keep both kinds. Dropping one kind hides a root. Hidden roots are how extracts ship broken CI writes.

                                                  Where a free model fits


                                                  A free coding model can draft the one-root helper. It cannot choose the root. The ledger already chose.

                                                  Disclosure: This article was prepared as part of MonkeyCode's product outreach.

                                                  MonkeyCode provides free model access and a free server option. Use the server to run the four pinned invocations. Feed the model the messy function, the ledger JSON, and the decision table. Reject any patch that alters a resolved field.

                                                  The model is a diff generator in this workflow. The hash remains the reviewer for every patch. Do not skip that reviewer for a cleaner diff.

                                                  Failure analysis


                                                  Hash changes but the source looks equivalent. A Path constructor started calling .resolve() early. Absolute strings then diverge on symlinks.

                                                  Hash stays stable on a laptop and fails in CI. The golden cwd was never isolated. Rerun the pin under tmp_path.

                                                  Only two of three writes appear in the ledger. mkdir created a directory the extract later reuses. Trace mkdir as a first-class event.

                                                  Env values look absolute in every golden row. The test set an absolute DATA_DIR only. Add the unset and relative cases before review.

                                                  Returned relative strings still match after the extract. Callers never saw the absolute target. Assert the ledger, not the return map.

                                                  Limitations


                                                  This ledger does not prove functional correctness of the report. File contents can still rot under this pin. Pair it with a payload hash when bytes matter.

                                                  It misses networked I/O by design. HTTP and object-store clients need a different oracle. Do not reuse this hash for those calls.

                                                  It is weak against symlink farms in deploy trees. resolve() follows links; absolute() does not. Pick one rule and keep it fixed.

                                                  Race conditions remain outside the ledger. Two processes can share one cwd. The ledger is per-process and will not serialize them.

                                                  Windows drive letters and UNC paths need extra goldens. Do not copy a POSIX hash onto Windows runners. Split those hashes by platform.

                                                  Who should not use this


                                                  Do not use this workflow on a greenfield module. Write explicit path arguments first in new code. There is nothing useful to characterize there.

                                                  Do not use it when output locations must change on purpose. Update the golden in the same commit as the move. Do not treat the hash as sacred then.

                                                  Do not use it as a substitute for backup policy. Characterization does not recover overwritten files. Keep real backups for destructive jobs.

                                                  Skip it for one-off notebooks and scratch CLIs. The process cwd is the product in those tools. A ledger mostly adds noise there.

                                                  Checklist before merge


                                                  1. Four invocations produced four hashes, and all four stayed green.
                                                  2. One root was extracted, and two roots remained inline.
                                                  3. The ledger JSON diff is empty after the helper lands.
                                                  4. The helper introduced no new .resolve() calls on write paths.
                                                  5. DATA_DIR cases include unset, relative, and absolute values.

                                                  If any box is still open, keep the helper on the branch. Ship the path pin first. The extract can wait for a green hash.

                                                  Run the ledger on a free server if you already have one. Keep the extract behind that green hash.
                                                  Characterize Path Resolution Before You Move One open()

                                                    [?]Rafael Pérez 🍁 » 🌐
                                                    @rperezrosario@mastodon.social

                                                    River Engineering writer Vivian Mathews retells their company's 16-month-long saga to replace an aging accounting ledger system with a refactored, highly optimized new version. They go through the nitty-gritty of how they successfully pulled-off a zero-downtime migration including code examples and key takeaways for the TL;DR crowd.

                                                    "We replaced our ledger with two functions"

                                                    river.com/content/we-replaced-

                                                    A detailed black-and-white line-art scene contrasts a tangled, aging ledger system with a clean modern code system, connected by a bridge representing a careful zero-downtime migration. Image designed and executed by GPT-5.6 Luna.

                                                    Alt...A detailed black-and-white line-art scene contrasts a tangled, aging ledger system with a clean modern code system, connected by a bridge representing a careful zero-downtime migration. Image designed and executed by GPT-5.6 Luna.

                                                      #agile boosted

                                                      [?]Richard Griffiths » 🌐
                                                      @dectentoo@mastodon.ie

                                                      The best refactoring doesn't necessarily produce the cleverest code.

                                                      It produces better code.

                                                      Those aren't always the same thing.

                                                      A new abstraction might reduce duplication while making the system harder to understand.

                                                      A design pattern might look elegant while adding machinery nobody needs.

                                                      A shorter solution might become cryptic.

                                                      Technical excellence requires judgement.

                                                      Not commandments.

                                                        #refactoring boosted

                                                        [?]Hack a Day (unofficial) » 🤖 🌐
                                                        @hackaday@www.urbanmind.net

                                                        Rust is an increasingly popular COBOL migration target for organisations that want both memory safety...
                                                        COBOL to Rust Migration - A UK Enterprise Guide 2026

                                                          #refactoring boosted

                                                          [?]Leanpub » 🌐
                                                          @leanpub@mastodon.social

                                                          A Short Guide to Naming by Tim Ottinger is free with a Leanpub Reader membership! Or you can buy it for $6.50! leanpub.com/naming_shortguide

                                                            Back to top - More...