|
| 1 | +--- |
| 2 | +summary: extends.py drops its private copy of keys.py's list-normalizing helper and its inline concat, using the promoted keys.concat_list instead; the two byte-identical helpers with swapped parameters are gone. |
| 3 | +--- |
| 4 | + |
| 5 | +# Design: Collapse extends' duplicate merge helpers onto keys.py |
| 6 | + |
| 7 | +## Summary |
| 8 | + |
| 9 | +`extends.py` carries a private `_as_list` that is byte-identical to `keys.py`'s |
| 10 | +`_as_list` — same body, same error message — **with the first two parameters |
| 11 | +swapped**. Delete the copy, promote the `keys.py` original to the module's |
| 12 | +public primitive set, and have `extends` use it. |
| 13 | + |
| 14 | +## Motivation |
| 15 | + |
| 16 | +The two functions today: |
| 17 | + |
| 18 | +```python |
| 19 | +# compose2pod/extends.py |
| 20 | +def _as_list(key: str, name: str, value: Any) -> list[Any]: |
| 21 | + if isinstance(value, list): |
| 22 | + return list(value) |
| 23 | + if isinstance(value, str): |
| 24 | + return [value] |
| 25 | + msg = f"service {name!r}: cannot merge {key!r} across incompatible forms" |
| 26 | + raise UnsupportedComposeError(msg) |
| 27 | + |
| 28 | +# compose2pod/keys.py |
| 29 | +def _as_list(name: str, key: str, value: Any) -> list[Any]: |
| 30 | + ...identical body, identical message... |
| 31 | +``` |
| 32 | + |
| 33 | +Same name, same behavior, opposite parameter order. Each call site is correct |
| 34 | +only because it happens to match its own module's local signature — nothing |
| 35 | +catches a mix-up but the error message coming out with `name` and `key` |
| 36 | +transposed, which no test asserts on. This is a latent footgun sitting in the |
| 37 | +`extends` merge path, and `extends` runs *ahead of the gate* |
| 38 | +(`cli.py` calls `resolve_extends()` before `validate()`), which is precisely |
| 39 | +where this codebase has already been bitten. |
| 40 | + |
| 41 | +`extends._merge` also open-codes the same two merge policies `keys.py` already |
| 42 | +names: `_as_list(base) + _as_list(local)` is `keys._concat_list`. |
| 43 | + |
| 44 | +## Design |
| 45 | + |
| 46 | +`keys.py` is already the home of the cross-module primitives — `key_value_pairs`, |
| 47 | +`pairs_to_mapping`, `validate_map`, `require_string_keys`, `extra_host_pairs`, |
| 48 | +`is_number` — all on the same `(name, key, value)` signature |
| 49 | +(`2026-07-13.07-public-keys-primitives`). Two more join them: |
| 50 | + |
| 51 | +- `_as_list` → **`as_list(name, key, value)`** |
| 52 | +- `_concat_list` → **`concat_list(name, key, base, local)`** |
| 53 | + |
| 54 | +`extends.py` then deletes its `_as_list` and calls `concat_list` for its |
| 55 | +sequence-concatenate keys. One definition, one parameter order. |
| 56 | + |
| 57 | +**`extends._as_mapping` stays.** It is *not* duplication: it is deliberately |
| 58 | +stricter than `keys.pairs_to_mapping`, accepting list form only for |
| 59 | +`environment` and `depends_on` and refusing it for `extra_hosts`/`healthcheck` |
| 60 | +rather than coercing. Collapsing it onto `pairs_to_mapping` would silently start |
| 61 | +coercing list-form `extra_hosts` on a merged side — a behavior change, not a |
| 62 | +dedup. Its parameter order is corrected to `(name, key, value)` to match every |
| 63 | +other helper, removing the second half of the footgun. |
| 64 | + |
| 65 | +No structural-key registry (`decisions/2026-07-12-reject-structural-key-registry.md` |
| 66 | +stands): this moves two helpers, it does not build a dispatch table. |
| 67 | + |
| 68 | +## Non-goals |
| 69 | + |
| 70 | +- **No behavior change.** Every accepted document still merges identically and |
| 71 | + every rejected one still raises the same message. This is a pure refactor; if |
| 72 | + a test needs changing, the refactor is wrong. |
| 73 | +- Not unifying structural-key merge policy — the asymmetry where a registry key |
| 74 | + (`labels`) coerces list form on merge while a structural key (`extra_hosts`) |
| 75 | + refuses it is real, but it is a *policy* question, not a duplication one, and |
| 76 | + it stays deferred behind `decisions/2026-07-12`'s revisit trigger. |
| 77 | + |
| 78 | +## Testing |
| 79 | + |
| 80 | +`just test-ci` at 100%, unchanged. The existing `tests/test_extends.py` merge |
| 81 | +suite is the regression net: a pure refactor must leave all of it green without |
| 82 | +edits. Add one test pinning the error message's `name`/`key` order, so a future |
| 83 | +transposition fails loudly rather than silently producing a garbled message. |
| 84 | + |
| 85 | +## Risk |
| 86 | + |
| 87 | +- **A silent parameter transposition during the edit** — exactly the bug being |
| 88 | + removed. Mitigated by the message-order test above and by the existing merge |
| 89 | + suite, which covers both the concat and the incompatible-form paths. |
0 commit comments