Skip to content

Commit 7fff439

Browse files
jdclaude
andauthored
test: add port-inventory guard to catch un-ported Python commands (#1296)
During the Rust port, any new click command added to the Python CLI risks being silently missed if the Rust dispatch isn't updated in parallel. This adds a CI-enforced inventory at the repo root plus a pytest that walks the click tree and compares against it. ## How it works ``PORT_STATUS.toml`` lists every click subcommand with an explicit ``status`` of either ``native`` (handled by Rust's dispatch) or ``shimmed`` (forwarded to Python by the py-shim crate). ``mergify_cli/tests/test_port_status.py`` walks ``mergify_cli.cli.cli`` and fires four assertions: - Every discovered click command has an entry. - Every entry corresponds to a live click command (no stale rows). - Every entry uses a valid ``status`` value. - No entry carries extra keys (catches typos like ``stats``). Forgetting to update the file when adding a new Python command becomes a CI failure at test-time rather than a "why is this missing from the binary?" bug report months later. ## Current baseline All 30 click subcommands are listed. Only ``config validate`` is ``native`` today (from Phase 1.3 in the same stack). The remaining 29 are ``shimmed`` — each subsequent port PR flips its entry from ``shimmed`` to ``native`` in the same commit that adds the Rust dispatch, keeping the file and the code in lockstep. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 33353b2 commit 7fff439

2 files changed

Lines changed: 306 additions & 0 deletions

File tree

PORT_STATUS.toml

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
# Port inventory for the mergify CLI Rust port.
2+
#
3+
# Every click subcommand exposed by the Python CLI must appear here.
4+
# The inventory test in mergify_cli/tests/test_port_status.py walks
5+
# click's command tree and fails if it finds a command that isn't
6+
# listed, or an entry here that doesn't match any Python command.
7+
#
8+
# Status values:
9+
# "native" — handled by the Rust binary's native dispatch.
10+
# "shimmed" — handled by Python via the py-shim crate.
11+
#
12+
# Workflow
13+
# --------
14+
#
15+
# When adding a new Python subcommand:
16+
# Add an entry here with status = "shimmed" in the same PR.
17+
#
18+
# When porting a command to Rust:
19+
# Flip status from "shimmed" to "native" in the same PR that adds
20+
# the Rust dispatch + tests.
21+
#
22+
# When removing a command:
23+
# Drop the entry here in the same PR that removes the Python
24+
# implementation.
25+
#
26+
# The guard fires before anything else, so forgetting to update
27+
# this file surfaces as a CI failure rather than a silent unshipped
28+
# port.
29+
30+
[[command]]
31+
path = ["ci", "git-refs"]
32+
status = "shimmed"
33+
34+
[[command]]
35+
path = ["ci", "junit-process"]
36+
status = "shimmed"
37+
38+
[[command]]
39+
path = ["ci", "junit-upload"]
40+
status = "shimmed"
41+
42+
[[command]]
43+
path = ["ci", "queue-info"]
44+
status = "shimmed"
45+
46+
[[command]]
47+
path = ["ci", "scopes"]
48+
status = "shimmed"
49+
50+
[[command]]
51+
path = ["ci", "scopes-send"]
52+
status = "shimmed"
53+
54+
[[command]]
55+
path = ["config", "simulate"]
56+
status = "shimmed"
57+
58+
[[command]]
59+
path = ["config", "validate"]
60+
status = "native"
61+
62+
[[command]]
63+
path = ["freeze", "create"]
64+
status = "shimmed"
65+
66+
[[command]]
67+
path = ["freeze", "delete"]
68+
status = "shimmed"
69+
70+
[[command]]
71+
path = ["freeze", "list"]
72+
status = "shimmed"
73+
74+
[[command]]
75+
path = ["freeze", "update"]
76+
status = "shimmed"
77+
78+
[[command]]
79+
path = ["queue", "pause"]
80+
status = "shimmed"
81+
82+
[[command]]
83+
path = ["queue", "show"]
84+
status = "shimmed"
85+
86+
[[command]]
87+
path = ["queue", "status"]
88+
status = "shimmed"
89+
90+
[[command]]
91+
path = ["queue", "unpause"]
92+
status = "shimmed"
93+
94+
[[command]]
95+
path = ["stack", "checkout"]
96+
status = "shimmed"
97+
98+
[[command]]
99+
path = ["stack", "edit"]
100+
status = "shimmed"
101+
102+
[[command]]
103+
path = ["stack", "fixup"]
104+
status = "shimmed"
105+
106+
[[command]]
107+
path = ["stack", "hooks"]
108+
status = "shimmed"
109+
110+
[[command]]
111+
path = ["stack", "list"]
112+
status = "shimmed"
113+
114+
[[command]]
115+
path = ["stack", "move"]
116+
status = "shimmed"
117+
118+
[[command]]
119+
path = ["stack", "new"]
120+
status = "shimmed"
121+
122+
[[command]]
123+
path = ["stack", "note"]
124+
status = "shimmed"
125+
126+
[[command]]
127+
path = ["stack", "open"]
128+
status = "shimmed"
129+
130+
[[command]]
131+
path = ["stack", "push"]
132+
status = "shimmed"
133+
134+
[[command]]
135+
path = ["stack", "reorder"]
136+
status = "shimmed"
137+
138+
[[command]]
139+
path = ["stack", "setup"]
140+
status = "shimmed"
141+
142+
[[command]]
143+
path = ["stack", "squash"]
144+
status = "shimmed"
145+
146+
[[command]]
147+
path = ["stack", "sync"]
148+
status = "shimmed"
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
#
2+
# Copyright © 2021-2026 Mergify SAS
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
5+
# not use this file except in compliance with the License. You may obtain
6+
# a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
# License for the specific language governing permissions and limitations
14+
# under the License.
15+
"""Port inventory guard.
16+
17+
Walks the click command tree exposed by ``mergify_cli.cli.cli`` and
18+
compares it to the inventory in ``PORT_STATUS.toml``. Any mismatch
19+
is a CI failure.
20+
21+
The intent is to prevent a Python command from being added while
22+
the Rust port is in flight without someone explicitly deciding
23+
whether it ships via the shim (``status = "shimmed"``) or via a
24+
native Rust implementation (``status = "native"``). Forgetting to
25+
port a new command therefore surfaces immediately rather than
26+
getting noticed months later when users report missing
27+
functionality in the static binary.
28+
"""
29+
30+
from __future__ import annotations
31+
32+
import pathlib
33+
import tomllib
34+
35+
import click
36+
37+
from mergify_cli.cli import cli as _cli
38+
39+
40+
_VALID_STATUSES: frozenset[str] = frozenset({"native", "shimmed"})
41+
_PORT_STATUS_PATH = (
42+
pathlib.Path(__file__).resolve().parent.parent.parent / "PORT_STATUS.toml"
43+
)
44+
45+
46+
def _walk_commands(
47+
cmd: click.Command,
48+
prefix: tuple[str, ...] = (),
49+
) -> list[tuple[str, ...]]:
50+
"""Collect the path of every leaf command reachable from ``cmd``.
51+
52+
Groups contribute nothing themselves — only their leaf
53+
subcommands appear. Empty prefixes mean "the root `mergify`
54+
command invoked without a subcommand", which we don't track.
55+
"""
56+
if isinstance(cmd, click.Group):
57+
paths: list[tuple[str, ...]] = []
58+
for name, child in sorted(cmd.commands.items()):
59+
paths.extend(_walk_commands(child, (*prefix, name)))
60+
return paths
61+
return [prefix] if prefix else []
62+
63+
64+
def _discovered_commands() -> set[tuple[str, ...]]:
65+
return set(_walk_commands(_cli))
66+
67+
68+
def _load_port_status() -> list[dict[str, object]]:
69+
text = _PORT_STATUS_PATH.read_text(encoding="utf-8")
70+
data = tomllib.loads(text)
71+
commands = data.get("command", [])
72+
assert isinstance(commands, list), (
73+
"PORT_STATUS.toml must define `command` as an array of tables "
74+
"using `[[command]]`, not a single table `[command]`."
75+
)
76+
assert all(isinstance(entry, dict) for entry in commands), (
77+
"PORT_STATUS.toml `command` entries must each be tables defined "
78+
"with `[[command]]`."
79+
)
80+
return commands
81+
82+
83+
def _declared_commands() -> set[tuple[str, ...]]:
84+
return {tuple(entry["path"]) for entry in _load_port_status()} # type: ignore[arg-type]
85+
86+
87+
def test_every_python_command_is_in_port_status() -> None:
88+
"""Every click command exposed by the Python CLI must appear in
89+
PORT_STATUS.toml."""
90+
discovered = _discovered_commands()
91+
declared = _declared_commands()
92+
93+
missing = discovered - declared
94+
assert not missing, (
95+
"\nThese click commands exist in mergify_cli but are not listed "
96+
"in PORT_STATUS.toml:\n"
97+
+ "\n".join(f" - {' '.join(path)}" for path in sorted(missing))
98+
+ '\n\nAdd each as `status = "shimmed"` (or `status = "native"` '
99+
"if already ported) so the Rust port doesn't forget them."
100+
)
101+
102+
103+
def test_no_stale_entries_in_port_status() -> None:
104+
"""Every entry in PORT_STATUS.toml must correspond to a live
105+
click command."""
106+
discovered = _discovered_commands()
107+
declared = _declared_commands()
108+
109+
extra = declared - discovered
110+
assert not extra, (
111+
"\nThese entries in PORT_STATUS.toml do not match any "
112+
"click command:\n"
113+
+ "\n".join(f" - {' '.join(path)}" for path in sorted(extra))
114+
+ "\n\nRemove the stale entries (the command was renamed or "
115+
"deleted)."
116+
)
117+
118+
119+
def test_port_status_uses_only_valid_status_values() -> None:
120+
"""Every entry must use a known status value."""
121+
for entry in _load_port_status():
122+
# Validate required keys here so a typo in `path` or `status`
123+
# surfaces with a targeted assertion message instead of a
124+
# bare KeyError traceback.
125+
assert "path" in entry, (
126+
f"PORT_STATUS.toml entry {entry!r} is missing required key 'path'"
127+
)
128+
assert "status" in entry, (
129+
f"PORT_STATUS.toml entry {entry!r} is missing required key 'status'"
130+
)
131+
path = entry["path"]
132+
assert isinstance(path, list), (
133+
f"PORT_STATUS.toml entry {entry!r}: 'path' must be a list"
134+
)
135+
assert all(isinstance(p, str) for p in path), (
136+
f"PORT_STATUS.toml entry {entry!r}: every 'path' segment must be a string"
137+
)
138+
status = entry["status"]
139+
assert status in _VALID_STATUSES, (
140+
f"PORT_STATUS.toml entry for {path!r} uses invalid "
141+
f"status {status!r}; valid values are "
142+
f"{sorted(_VALID_STATUSES)}"
143+
)
144+
145+
146+
def test_port_status_entries_have_exactly_path_and_status_keys() -> None:
147+
"""Catches typos like `stats` or accidentally adding a third
148+
undocumented key."""
149+
allowed = {"path", "status"}
150+
for entry in _load_port_status():
151+
actual = set(entry.keys())
152+
missing = allowed - actual
153+
extras = actual - allowed
154+
assert actual == allowed, (
155+
f"PORT_STATUS.toml entry {entry!r} must have exactly keys "
156+
f"{sorted(allowed)}; missing keys: {sorted(missing)}, "
157+
f"unexpected keys: {sorted(extras)}."
158+
)

0 commit comments

Comments
 (0)