|
| 1 | +# Fifteen subprocess calls against the rule written today |
| 2 | + |
| 3 | +M3-W98, 2026-07-29. `CLAUDE.md` gained a paragraph today saying that `encoding="utf-8"` on a |
| 4 | +`subprocess.run` does not choose the child's encoding. Nothing in the repository had been checked |
| 5 | +against it. This is the inventory, what measuring each call site established, and the check that |
| 6 | +now holds the answer. |
| 7 | + |
| 8 | +## The rule, reproduced |
| 9 | + |
| 10 | +The paragraph came from a measurement, and it reproduces here exactly. A Python child with no |
| 11 | +`PYTHONIOENCODING` in its environment writes the locale codepage: |
| 12 | + |
| 13 | + child emitted: b'caf\xe9 \x97\r\n' # cp1252, for the string `café —` |
| 14 | + |
| 15 | +Read the way fourteen of the fifteen calls in `src/` read their children, that comes back as: |
| 16 | + |
| 17 | + subprocess.run([...], capture_output=True, text=True, encoding="utf-8") |
| 18 | + -> returncode 0, stdout None |
| 19 | + UnicodeDecodeError raised on Thread-3 (_readerthread) |
| 20 | + |
| 21 | +Exit code zero. No exception at the call site. `stdout` is `None`, so the next line that |
| 22 | +concatenates it raises `TypeError` somewhere that does not name the subprocess. Both remedies |
| 23 | +behave as the paragraph says: `PYTHONIOENCODING=utf-8` in the child's environment returns |
| 24 | +`'café —'` faithfully, and `errors="replace"` returns `'caf� �\n'` — no crash, but the |
| 25 | +accent and the dash have collapsed into the same character. |
| 26 | + |
| 27 | +`tests/test_subprocess_encoding.py` holds all four of those as tests. The one that matters most is |
| 28 | +`test_the_old_form_loses_stdout_on_a_child_that_chooses_its_own_encoding`, which installs a |
| 29 | +`threading.excepthook` and asserts on both halves: that `stdout` is `None`, and that the |
| 30 | +`UnicodeDecodeError` was raised on a thread. The second half is the claim that makes this a defect |
| 31 | +rather than a preference — the failure happens where no caller can catch it. |
| 32 | + |
| 33 | +## The inventory |
| 34 | + |
| 35 | +Fifteen calls, eleven modules. Twelve failed the check on its first run. Three already satisfied |
| 36 | +it. **Not one of the fifteen spawns a Python process**, which is the single most consequential |
| 37 | +finding for how the check had to be written. |
| 38 | + |
| 39 | +| call site | child | Python? | can the rule bite it | disposition | |
| 40 | +|---|---|---|---|---| |
| 41 | +| `cli.py:247` `_clone` | `git clone` | no | no — reads bytes, never decodes | already satisfied | |
| 42 | +| `cli.py:248` `_clone#1` | `git rev-parse` | no | no — a hex SHA | baseline, M3-W98/a | |
| 43 | +| `cli.py:256` `_git` | `git`, argv from caller | no | not established — callers not enumerated | baseline, M3-W98/a | |
| 44 | +| `forge/github.py:124` `GitHubForge._run` | `git`/`gh`, argv from caller | no | measured UTF-8, but the child is not fixed | fixed: `errors="replace"` | |
| 45 | +| `index/dependency_edits.py:125` | `git ls-files -z` | no | no — git emits UTF-8 paths | exempt, measured | |
| 46 | +| `index/deps.py:89` | `npm`/`pnpm`/`yarn` shim | no | measured UTF-8 | fixed: `errors="replace"` | |
| 47 | +| `index/shipped_tree.py:90` | `git status -z` | no | no — git emits UTF-8 paths | exempt, measured | |
| 48 | +| `index/tsc.py:155` | `tsc` or `npx` | no | measured UTF-8 | fixed: `errors="replace"` | |
| 49 | +| `index/typescript.py:578` | `git diff --name-only` | no | no — quoted path list | baseline, M3-W98/b | |
| 50 | +| `remediate/agent_patch.py:196` | `git diff HEAD` | no | **yes, reproduced** | baseline, M3-W98/c | |
| 51 | +| `remediate/agent_patch.py:210` | `git ls-files --others` | no | no — quoted path list | baseline, M3-W98/c | |
| 52 | +| `signals/oasdiff.py:64` | `oasdiff breaking` | no | no — Go via `encoding/json` | exempt, measured | |
| 53 | +| `signals/oasdiff.py:88` | `oasdiff checks` | no | no — Go via `encoding/json` | exempt, measured | |
| 54 | +| `signals/stripe/adapter.py:55` | `gh api` | no | no — reads bytes, never decodes | already satisfied | |
| 55 | +| `verify/replay.py:337` | `node` | no | no — already passes `errors="replace"` | already satisfied | |
| 56 | + |
| 57 | +Two corrections to the brief's framing, both from counting by AST rather than by grep. Thirteen |
| 58 | +calls decode, not fourteen: `cli.py:247` and `signals/stripe/adapter.py:55` both read bytes on |
| 59 | +purpose. And `signals/oasdiff.py:88`, the call the new paragraph quotes verbatim, is the shape of |
| 60 | +the rule without being an instance of it — oasdiff is a Go binary answering through |
| 61 | +`encoding/json`, which replaces invalid byte sequences before they reach the pipe. Measured against |
| 62 | +the pinned binary, its output is UTF-8. The brief was right to make that a question. |
| 63 | + |
| 64 | +### The one call whose defect is reproduced |
| 65 | + |
| 66 | +`git diff` copies file content onto the pipe verbatim. Against a repository holding one cp1252 |
| 67 | +source file: |
| 68 | + |
| 69 | + git diff HEAD -> rc=0, NOT utf-8: can't decode byte 0xe9 in position 130 |
| 70 | + read the old way -> stdout is None |
| 71 | + |
| 72 | +That is `remediate/agent_patch.py:196` `_git_diff`, whose `return result.stdout` then hands `None` |
| 73 | +onward as the patch's diff. It is the only one of the fifteen with a measurement rather than a |
| 74 | +suspicion behind it, and it is in a module this task could not edit. It is the follow-up to |
| 75 | +dispatch first. |
| 76 | + |
| 77 | +Neither cheap route fixes it. A diff is data, so `errors="replace"` would corrupt the patch, and |
| 78 | +there is nothing to exempt because the child genuinely does emit non-UTF-8. Reading the diff as |
| 79 | +bytes, or refusing the file the way `sync.cli._literal_call_sites` refuses one it cannot decode, is |
| 80 | +the shape of the answer. |
| 81 | + |
| 82 | +### What was measured, per child |
| 83 | + |
| 84 | +- **`git`, path-output commands.** UTF-8. Windows stores NTFS names as UTF-16 and git converts |
| 85 | + them; an accented filename came back as `b'caf\xc3\xa9.ts'`. The commands without `-z` |
| 86 | + (`ls-files --others`, `diff --name-only`) are safer still, because git's `core.quotepath` |
| 87 | + octal-escapes non-ASCII into pure ASCII. |
| 88 | +- **`git`, content-output commands.** Not UTF-8. `git diff HEAD` is the only one in `src/`. |
| 89 | +- **`git commit -m`.** Echoes the message back as UTF-8, `b'... receipt_email \xe2\x80\x94 ...'`, |
| 90 | + which matters because `forge/github.py` builds that message from `patch.rationale` — model-written |
| 91 | + prose that routinely carries an em dash. |
| 92 | +- **`oasdiff`.** UTF-8. Go, through `encoding/json`. |
| 93 | +- **`npm`, `pnpm`, `npx`.** UTF-8, including their failure output. These resolve to `.CMD` shims |
| 94 | + over cmd.exe, and the console codepage here is 437, so the shim layer was the thing worth |
| 95 | + checking rather than assumed. |
| 96 | +- **`node`.** UTF-8. |
| 97 | + |
| 98 | +## What the check requires, and why not one literal spelling |
| 99 | + |
| 100 | +`PYTHONIOENCODING=utf-8` is the remedy the paragraph names, and a check demanding that literal |
| 101 | +would have been wrong in the expensive direction. No child in `src/` is a Python process, so none |
| 102 | +of them reads that variable. The check would have fired on all thirteen decoding calls, and every |
| 103 | +one would have been silenced by setting an environment variable the child ignores — a green gate |
| 104 | +over an unchanged defect, which is worse than no gate. |
| 105 | + |
| 106 | +Requiring `errors="replace"` everywhere is wrong in the other direction, because it is lossy. |
| 107 | +Both characters in the measurement above came back as the same replacement character. Where the |
| 108 | +output is data that is a silent corruption, and this repository has shipped it once already: |
| 109 | +`tests/test_decode_handlers.py::_drive_literal_call_sites` records `_literal_call_sites` reading |
| 110 | +customer sources that way until B57, where a mangled `operation_id` became a call site joined |
| 111 | +against a model nothing retires. `CLAUDE.md` scopes `errors="replace"` to output that is |
| 112 | +"diagnostic rather than data" for exactly that reason. |
| 113 | + |
| 114 | +So the check requires that a choice has been made, and accepts four routes: |
| 115 | + |
| 116 | +1. **The call does not decode** — no `text=`, `encoding=`, `errors=` or `universal_newlines=`. |
| 117 | +2. **`errors=` is passed** — the decode cannot raise. Cost: mojibake. |
| 118 | +3. **`env=` is passed and the expression names `PYTHONIOENCODING`** — the only route that keeps the |
| 119 | + output faithful, and the only one available when the output is data and the child is Python. |
| 120 | +4. **An exemption marker carrying a reason** — `# subprocess-encoding: allow - <why>` on any line |
| 121 | + the call spans. A marker with nothing after `allow` is itself reported, copying |
| 122 | + `scripts/lint_dead_links.py`: an opt-out that says nothing is how a check decays into |
| 123 | + decoration. |
| 124 | + |
| 125 | +Route 4 is what keeps the check satisfiable when the honest answer is "this child cannot emit |
| 126 | +non-UTF-8, and here is how I know". Route 2 is what keeps it satisfiable for everything else. The |
| 127 | +combination fires on no correct code and can always be answered. |
| 128 | + |
| 129 | +### `errors="replace"`: in the guidance, not in the check |
| 130 | + |
| 131 | +The brief asked whether it belongs in the check. It does not, and the reason is that the check |
| 132 | +cannot tell data from diagnostics — which is the distinction the rule turns on. What went in |
| 133 | +instead is the requirement to choose, and the choice is recorded at each of the seven call sites |
| 134 | +this task owns: |
| 135 | + |
| 136 | +- **Diagnostic, so `errors="replace"`.** `index/tsc.py` (a typecheck diagnostic string; |
| 137 | + `parse_diagnostics` matches `file(line,column): error TSxxxx:`, which is ASCII, so a replacement |
| 138 | + character can only land inside a message a human reads), `index/deps.py` (read only by the |
| 139 | + `RuntimeError` it raises), `forge/github.py` (stderr into a `RuntimeError`; the load-bearing |
| 140 | + stdout values are hex SHAs, branch names, and gh's JSON numbers and URLs, all ASCII by |
| 141 | + construction). |
| 142 | +- **Data, so a measured exemption.** `index/shipped_tree.py` and `index/dependency_edits.py` hand |
| 143 | + back paths — a replacement character there silently moves a file between shipped and unshipped, |
| 144 | + or hides an edit inside `node_modules`. `signals/oasdiff.py` hands back `kind`, `operation_id` |
| 145 | + and the whole of `raw`. |
| 146 | + |
| 147 | +`forge/github.py` is the one where the two arguments met. Its child measures as UTF-8, which would |
| 148 | +support an exemption, but `_run` takes its argv from the caller and nothing in the function |
| 149 | +constrains what the child is. An exemption there would be a claim about callers not yet written, so |
| 150 | +it took `errors="replace"` instead. |
| 151 | + |
| 152 | +## Baseline rather than fifteen fixes |
| 153 | + |
| 154 | +Five entries in `scripts/subprocess_encoding_baseline.txt`, all in modules a live task holds. It |
| 155 | +may only shrink: an entry that stops describing a violation fails the gate until it is deleted, in |
| 156 | +the commit that fixed it. The seven this task owned were opened in the same run and closed again, |
| 157 | +which is the mechanism demonstrated on real code rather than only compiled. |
| 158 | + |
| 159 | +The key is `<path>:<enclosing qualname>`, not `<path>:<line>`. |
| 160 | +`tests/test_decode_handlers.py` pays a real price for a positional key — it re-anchored seven keys |
| 161 | +in one afternoon over added comment blocks — and says in its own docstring that there is no stable |
| 162 | +identity to key on instead. For a `subprocess.run` there is one: the function that holds it. An |
| 163 | +edit above the call does not move the key, and |
| 164 | +`test_a_key_does_not_move_when_a_line_is_added_above_the_call` holds that. Two calls in one |
| 165 | +function are disambiguated by `#<n>` in line order, which is positional again but only within a |
| 166 | +single function body. |
| 167 | + |
| 168 | +The follow-ups, in dispatch order: |
| 169 | + |
| 170 | +- **M3-W98/c**, `src/sync/remediate/` — `_git_diff` and `_unstaged_additions`. The reproduced one. |
| 171 | +- **M3-W98/a**, `src/sync/cli.py` — `_clone#1` and `_git`. `_git` takes caller argv; enumerate them. |
| 172 | +- **M3-W98/b**, `src/sync/index/typescript.py` — `_baseline`. Likely the same measured exemption |
| 173 | + `shipped_tree.py` now carries. |
| 174 | + |
| 175 | +`test_every_baseline_entry_names_the_task_that_retires_it` enforces that each entry has an owner in |
| 176 | +the comment block above it, because debt with no owner is a permanent exemption wearing a different |
| 177 | +hat. |
| 178 | + |
| 179 | +## Non-vacuity |
| 180 | + |
| 181 | +**A violation constructed in real source.** A seventeenth `subprocess.run` was appended to |
| 182 | +`src/sync/signals/oasdiff.py`, decoding with no defence. The check reported it by key and line: |
| 183 | + |
| 184 | + sync/signals/oasdiff.py:229 (sync/signals/oasdiff.py:_constructed_violation) decodes the |
| 185 | + child's output with no defence against the child choosing its own encoding |
| 186 | + |
| 187 | +Giving that same call `errors="replace"` returned the suite to green, and the call was then removed. |
| 188 | +The baseline did not absorb it, which is the property that matters: a new violation is not covered |
| 189 | +by an existing entry. |
| 190 | + |
| 191 | +**The shrink-only property.** Re-adding `sync/index/tsc.py:run_tsc` to the baseline after it was |
| 192 | +fixed failed `test_no_baseline_entry_has_stopped_describing_a_violation` by name. |
| 193 | + |
| 194 | +**Mutation.** Twenty-one mutations, all killed; no survivors, nothing that failed to compile, and |
| 195 | +the tree verified green before and after the run. The first pass had **six survivors**, and every |
| 196 | +one was a missing test rather than a mutation artefact or a fault in `src/` — the fourth time on |
| 197 | +this project the fault has been outside the production code. Three of the six shared one cause, and |
| 198 | +it is the failure the brief warned about: `test_every_decoding_subprocess_call_states_a_defence`, |
| 199 | +`test_no_baseline_entry_has_stopped_describing_a_violation` and |
| 200 | +`test_every_baseline_entry_names_the_task_that_retires_it` each asserted only that `src/` as it |
| 201 | +stands is clean. A test whose sole input is the repository in its current state can fail only when |
| 202 | +the repository is dirty, so deleting the decision it tests leaves it green — `new = []` survived. |
| 203 | +The fix was to extract the three decisions into `unbaselined`, `stale_entries` and |
| 204 | +`entries_without_a_retiring_task` and drive them with synthetic input as well as the real tree. |
| 205 | +The other three survivors were untested branches the docstring had claimed: a computed `text=` |
| 206 | +flag, a marker on a line other than the call's first, and an `env=` that does not name |
| 207 | +`PYTHONIOENCODING`. |
| 208 | + |
| 209 | +The harness distinguishes four outcomes rather than two, because this task's own subject is a |
| 210 | +harness reading a false verdict. All four were demonstrated rather than asserted: a mutant with an |
| 211 | +unclosed paren reported `did-not-compile`; one raising `SystemExit` at import reported `unreadable` |
| 212 | +rather than counting as a kill or a survival; removing `errors=` from `tsc.py` before the run |
| 213 | +reported `baseline-drifted` and refused to score anything. Every child the harness spawns gets |
| 214 | +`PYTHONIOENCODING=utf-8` and is read with `errors="replace"`, and pytest is given `--color=no`, so |
| 215 | +the harness cannot suffer the defect it is measuring. |
0 commit comments