|
21 | 21 | SourceFile, |
22 | 22 | build_inventory, |
23 | 23 | load_sources, |
| 24 | + merge_candidate_groups, |
24 | 25 | python_facts, |
| 26 | + registered_owner_symbol_sets, |
25 | 27 | render_inventory, |
26 | 28 | ) |
27 | 29 |
|
@@ -180,6 +182,122 @@ def test_module_local_convention_names_stay_out_of_semantic_budgets(collision_re |
180 | 182 | assert summary["multi_value_twins"] == 1 |
181 | 183 |
|
182 | 184 |
|
| 185 | +@pytest.fixture |
| 186 | +def merge_candidate_repo(tmp_path: Path) -> Path: |
| 187 | + """Three name pairs carry one value set each; only one pair is registered. |
| 188 | +
|
| 189 | + ``Kind``/``KINDS`` are the two owners of a single registered vocabulary, so |
| 190 | + their equal value set is a naming convention across runtimes rather than |
| 191 | + duplication. ``ALPHA_STAGES``/``MIRROR_STAGES`` is unregistered and lives |
| 192 | + only in Python; ``OTHER_SIDES``/``ZED_SIDES`` is unregistered and spans both |
| 193 | + runtimes, which is the registry-gap shape. ``MIRROR_STAGES`` is a registered |
| 194 | + owner with no TypeScript counterpart, so its vocabulary explains no pair. |
| 195 | + """ |
| 196 | + _write( |
| 197 | + tmp_path, |
| 198 | + "loopx/a.py", |
| 199 | + 'from enum import Enum\n' |
| 200 | + 'class Kind(str, Enum):\n ONE = "one"\n TWO = "two"\n' |
| 201 | + 'ALPHA_STAGES = ("draft", "final")\n', |
| 202 | + ) |
| 203 | + _write(tmp_path, "loopx/b.ts", 'export const KINDS = ["one", "two"] as const;\n') |
| 204 | + _write( |
| 205 | + tmp_path, |
| 206 | + "loopx/c.py", |
| 207 | + 'MIRROR_STAGES = ("draft", "final")\nOTHER_SIDES = ("left", "right")\n', |
| 208 | + ) |
| 209 | + _write(tmp_path, "loopx/d.ts", 'export const ZED_SIDES = ["left", "right"] as const;\n') |
| 210 | + _write( |
| 211 | + tmp_path, |
| 212 | + "loopx/semantics/vocabulary_v0.json", |
| 213 | + json.dumps( |
| 214 | + { |
| 215 | + "vocabularies": { |
| 216 | + "kind": { |
| 217 | + "owners": { |
| 218 | + "python": "loopx/a.py::Kind", |
| 219 | + "typescript": "loopx/b.ts::KINDS", |
| 220 | + } |
| 221 | + }, |
| 222 | + "mirror_stages": { |
| 223 | + "owners": {"python": "loopx/c.py::MIRROR_STAGES", "typescript": None} |
| 224 | + }, |
| 225 | + } |
| 226 | + } |
| 227 | + ), |
| 228 | + ) |
| 229 | + subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) |
| 230 | + subprocess.run( |
| 231 | + ["git", "-C", str(tmp_path), "add", "loopx/a.py", "loopx/b.ts", "loopx/c.py", "loopx/d.ts"], |
| 232 | + check=True, |
| 233 | + ) |
| 234 | + return tmp_path |
| 235 | + |
| 236 | + |
| 237 | +def _registry(repo: Path) -> dict: |
| 238 | + return json.loads((repo / "loopx/semantics/vocabulary_v0.json").read_text(encoding="utf-8")) |
| 239 | + |
| 240 | + |
| 241 | +def test_one_vocabulary_owning_two_spellings_explains_no_merge_candidate( |
| 242 | + merge_candidate_repo: Path, |
| 243 | +) -> None: |
| 244 | + """A registered owner pair is a naming convention, not a merge candidate. |
| 245 | +
|
| 246 | + Without the registry the advisory list mixed each cross-runtime vocabulary's |
| 247 | + own two owner symbols in with the groups nobody has ruled on, so the list |
| 248 | + read as duplication it was not. Filtering only hides the settled pairs; it |
| 249 | + retires nothing and classifies none of the groups that stay. |
| 250 | + """ |
| 251 | + inventory = build_inventory(merge_candidate_repo) |
| 252 | + unfiltered = {tuple(group["names"]) for group in merge_candidate_groups(inventory)} |
| 253 | + assert ("KINDS", "Kind") in unfiltered, "the unfiltered audit still sees every value-set collision" |
| 254 | + filtered = merge_candidate_groups(inventory, _registry(merge_candidate_repo)) |
| 255 | + assert {tuple(group["names"]) for group in filtered} == { |
| 256 | + ("ALPHA_STAGES", "MIRROR_STAGES"), |
| 257 | + ("OTHER_SIDES", "ZED_SIDES"), |
| 258 | + }, "an unregistered pair with the same value set is still reported" |
| 259 | + assert registered_owner_symbol_sets(_registry(merge_candidate_repo)) == { |
| 260 | + frozenset({"Kind", "KINDS"}) |
| 261 | + }, "a vocabulary with one owner symbol explains nothing" |
| 262 | + |
| 263 | + |
| 264 | +def test_merge_candidates_mark_the_pairs_that_span_both_runtimes( |
| 265 | + merge_candidate_repo: Path, |
| 266 | +) -> None: |
| 267 | + """Cross-runtime is the registry gap; Python-only is a human question.""" |
| 268 | + groups = merge_candidate_groups( |
| 269 | + build_inventory(merge_candidate_repo), _registry(merge_candidate_repo) |
| 270 | + ) |
| 271 | + assert {tuple(group["names"]): group["cross_runtime"] for group in groups} == { |
| 272 | + ("ALPHA_STAGES", "MIRROR_STAGES"): False, |
| 273 | + ("OTHER_SIDES", "ZED_SIDES"): True, |
| 274 | + } |
| 275 | + |
| 276 | + |
| 277 | +def test_report_prints_the_unexplained_merge_candidates_in_a_stable_order( |
| 278 | + merge_candidate_repo: Path, monkeypatch, capsys |
| 279 | +) -> None: |
| 280 | + """``--report`` is where a reviewer sees the list, so it must not churn.""" |
| 281 | + from scripts import generate_semantic_inventory as generator |
| 282 | + |
| 283 | + monkeypatch.setattr(generator, "ROOT", merge_candidate_repo) |
| 284 | + monkeypatch.setattr(sys, "argv", ["generate_semantic_inventory", "--report"]) |
| 285 | + assert generator.main() == 0 |
| 286 | + first = capsys.readouterr().out |
| 287 | + assert generator.main() == 0 |
| 288 | + assert capsys.readouterr().out == first, "the same tree must print the same advisory list" |
| 289 | + listing = first.split("merge candidates", 1)[1] |
| 290 | + assert "2 to review, 1 explained by a registered vocabulary's own owner symbols, 3 raw groups" in listing |
| 291 | + assert "Kind" not in listing, "the explained owner pair is not printed" |
| 292 | + assert " [cross-runtime] OTHER_SIDES, ZED_SIDES" in listing |
| 293 | + assert " values: left, right" in listing |
| 294 | + assert " modules: loopx/c.py, loopx/d.ts" in listing |
| 295 | + assert " [python-only] ALPHA_STAGES, MIRROR_STAGES" in listing |
| 296 | + assert listing.index("[cross-runtime]") < listing.index("[python-only]"), ( |
| 297 | + "registry gaps sort ahead of the Python-only groups regardless of name order" |
| 298 | + ) |
| 299 | + |
| 300 | + |
183 | 301 | def test_render_is_deterministic_valid_json(repo: Path) -> None: |
184 | 302 | inventory = build_inventory(repo) |
185 | 303 | rendered = render_inventory(inventory) |
|
0 commit comments