Skip to content

Commit 7b55311

Browse files
committed
Merge branch 'stroland02/m1-static-gate'
2 parents a22ffea + 69b4136 commit 7b55311

9 files changed

Lines changed: 985 additions & 5 deletions

File tree

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
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.
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# `subprocess.run` calls that decode a child's output with no stated defence against the child
2+
# choosing its own encoding. Accepted as of 2026-07-29, when the check first ran.
3+
#
4+
# This is debt, and it is not an exemption. An exemption is for a child somebody established
5+
# cannot emit non-UTF-8; it lives in `src/` on the call, carries its reason, and is permanent.
6+
# A line here says instead that nobody has judged this call yet. Every entry names the task that
7+
# will, because debt with no owner is a permanent exemption wearing a different hat.
8+
#
9+
# This is a list and never a threshold. A count absorbs both a repair and a new defect in
10+
# silence; a named entry has to be deleted by whoever defends the call, in the commit that
11+
# defends it, because `test_no_baseline_entry_has_stopped_describing_a_violation` fails on an
12+
# entry that no longer describes anything. The file only shrinks.
13+
#
14+
# Every entry below is a call in a module M3-W98 was forbidden to edit, each owned by a live
15+
# task. The seven M3-W98 did own were opened here in the same run and closed again: four took a
16+
# measured exemption in `src/` and three took `errors="replace"`. That is the file shrinking as
17+
# designed, and it is the evidence the mechanism works rather than only compiles.
18+
#
19+
# Format: <path under src/>:<qualname of the enclosing function>[#<n> for the nth call in it]
20+
#
21+
# The key is the enclosing function and not the line, so an edit above a call does not move it.
22+
# `tests/test_decode_handlers.py` records what a positional key costs -- seven keys re-anchored
23+
# in one afternoon over added comment blocks -- and says there is no stable identity to key on
24+
# instead. For a `subprocess.run` there is one. `#<n>` counts every spawning call in the
25+
# function in line order, including ones that already satisfy the check, so `_clone#1` is the
26+
# second `subprocess.run` in `_clone` and stays `#1` while the first one stands.
27+
28+
# `git rev-parse HEAD` and an arbitrary `git` invocation in `_git`. Measured: git for Windows
29+
# converts NTFS's UTF-16 names to UTF-8, and both of these read paths or a hex SHA, so neither
30+
# is expected to bite -- but `_git` takes its argv from a caller and this task did not enumerate
31+
# them. Retired by M3-W98/a, which owns `sync/cli.py`.
32+
sync/cli.py:_clone#1
33+
sync/cli.py:_git
34+
35+
# `git diff --name-only`, whose output is a path list and so is data. The likely answer is the
36+
# same measured exemption `sync/index/shipped_tree.py` now carries, not `errors="replace"`.
37+
# Retired by M3-W98/b, which owns `sync/index/typescript.py`.
38+
sync/index/typescript.py:TypeScriptAdapter._baseline
39+
40+
# **`git diff HEAD` is the one call in `src/` whose defect is reproduced rather than reasoned
41+
# about.** `git diff` copies file content onto the pipe verbatim, so a customer source file in
42+
# cp1252 makes git emit bytes that are not UTF-8. Measured on 2026-07-29 against a repository
43+
# holding one such file: exit 0, `UnicodeDecodeError` on the reader thread, and `stdout` came
44+
# back `None` -- after which `return result.stdout` hands `None` on as the patch's diff, and the
45+
# `TypeError` arrives somewhere that does not name this call. Neither route is obviously right:
46+
# a diff is data, so `errors="replace"` would corrupt the patch, and there is nothing to exempt
47+
# because the child genuinely does emit non-UTF-8. Reading the diff as bytes, or refusing the
48+
# file the way `sync.cli._literal_call_sites` refuses one it cannot decode, is the shape of the
49+
# answer. Retired by M3-W98/c, which owns `sync/remediate/`; this is the entry to dispatch
50+
# first, and the only one of the five with a measurement rather than a suspicion behind it.
51+
sync/remediate/agent_patch.py:_git_diff
52+
sync/remediate/agent_patch.py:_unstaged_additions

src/sync/forge/github.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,8 +121,16 @@ def __init__(self, poll_interval_seconds: int = 15, timeout_seconds: int = 1800)
121121
self._timeout = timeout_seconds
122122

123123
def _run(self, args: list[str], cwd: Path) -> str:
124+
# `errors="replace"` rather than an exemption, because `args` comes from the caller and
125+
# nothing here constrains what the child is. Every caller today runs `git` or `gh`, both
126+
# of which measure as UTF-8, but the exemption would be a claim about callers not yet
127+
# written. Nothing this returns can be corrupted by a replacement character and still
128+
# matter: the load-bearing values are hex SHAs, branch names, gh's JSON numbers and
129+
# URLs, all ASCII by construction. What it buys is that `git push` refusing with a
130+
# remote message, or `gh` reporting a workflow name, reaches the `RuntimeError` below
131+
# instead of dying on a reader thread and returning `stderr` as `None`.
124132
result = subprocess.run(
125-
args, cwd=cwd, capture_output=True, text=True, encoding="utf-8"
133+
args, cwd=cwd, capture_output=True, text=True, encoding="utf-8", errors="replace"
126134
)
127135
if result.returncode != 0:
128136
raise RuntimeError(f"{' '.join(args)} failed: {result.stderr.strip()}")

src/sync/index/dependency_edits.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,12 @@ def _tracked_dependency_files(repo_path: Path, dependency_dirs: frozenset[str])
122122
wrong. The index is read whole and filtered here rather than through a
123123
pathspec, so a workspace's nested directory is found as well as the root's.
124124
"""
125-
result = subprocess.run(
125+
# Paths only, and git converts NTFS's UTF-16 names to UTF-8, so this child cannot emit what
126+
# `encoding="utf-8"` would fail on -- `sync.index.shipped_tree._status_entries` carries the
127+
# measurement. `errors=` would be a corruption rather than a defence here: a replacement
128+
# character in a path drops it out of this set, and an edit inside `node_modules` that git
129+
# tracks would then read as unshippable.
130+
result = subprocess.run( # subprocess-encoding: allow - git emits utf-8 paths; output is data
126131
["git", "ls-files", "-z"],
127132
cwd=repo_path,
128133
capture_output=True,

0 commit comments

Comments
 (0)