Option C Tools
Loading...
Searching...
No Matches
test_contract_parity.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# tests/tests_mcp/test_contract_parity.py
4
5"""
6Purpose
7-------
8OI-502 / FS-502 — MCP contract parity harness. The validator's
9``_TOOL_MODEL_MAP`` and the executor's ``_ARGV_BUILDERS`` must register
10the same set of tool names, and each registered tool must be executable
11against a realistic validated-args dict without tripping the executor's
12``.get()`` defaults into producing a CLI flag that the real ``oct`` CLI
13does not accept.
14
15The test exercises three invariants:
16
171. **Registry parity** — ``set(_TOOL_MODEL_MAP) == set(_ARGV_BUILDERS)``.
182. **Per-tool argv well-formed** — for every tool, instantiate the model
19 with sensible defaults, call its argv builder, and check the first
20 four elements match ``[sys.executable, "-m", "oct", <subcommand>]``.
213. **No spurious flags** — for a curated list of previously-drifting
22 flags (``--output-dir``, ``--check``, ``--jobs`` on ``oct test``,
23 ``--json`` on ``oct diag``) the argv must *not* contain the flag.
24 Regression coverage for the bugs this harness was introduced to fix.
25
26The harness is intentionally static — it does not spawn the ``oct`` CLI
27or compare against ``--help`` output, so it runs fast and works even in
28environments where click or pytest are missing.
29
30Responsibilities
31----------------
32- Verify registry parity between ``_TOOL_MODEL_MAP`` and
33 ``_ARGV_BUILDERS``.
34- Verify per-tool argv starts with
35 ``[sys.executable, "-m", "oct", <subcommand>]``.
36- Verify previously-drifting flags do not appear in builder output.
37
38Diagnostics
39-----------
40Domain: MCP-TESTS
41Levels:
42 L2 — test lifecycle
43 L3 — assertion details
44 L4 — deep tracing
45
46Contracts
47---------
48- The parity test must fail if a new tool is added to one registry and
49 not the other.
50- The spurious-flag checks must fail if a previously-fixed executor
51 regression is re-introduced.
52"""
53
54from __future__ import annotations
55
56import sys
57from pathlib import Path
58
59import pytest
60
61from oct.mcp.executor import _ARGV_BUILDERS
62from oct.mcp.validator import _TOOL_MODEL_MAP, validate_tool_args
63
64
65#: Minimal (model-valid) args dict per tool. Keys that the executor
66#: needs are provided; anything omitted falls back to the model default.
67_MINIMAL_ARGS: dict[str, dict] = {
68 "oct_lint": {},
69 "oct_health": {},
70 "oct_skeleton": {},
71 "oct_deps": {},
72 "oct_test": {},
73 "oct_typecheck": {},
74 "oct_diag": {},
75 "oct_git_status": {},
76 "oct_git_check": {},
77 "oct_format": {"apply": False, "confirm": False},
78 "oct_docs": {},
79 "oct_export_source": {},
80 "oct_new": {"path": "src/foo.py"},
81 "oct_scaffold": {"name": "example"},
82 "oct_clean": {},
83 "oct_install_hooks": {},
84 "oct_git_commit": {"message": "feat(core): add parity test"},
85 "oct_git_hooks_install": {},
86 "oct_git_init": {},
87 "oct_git_changelog": {},
88}
89
90
91#: Expected CLI subcommand path per tool (first non-sys-executable argv
92#: segment after ``-m oct``).
93_EXPECTED_SUBCOMMAND: dict[str, list[str]] = {
94 "oct_lint": ["lint"],
95 "oct_health": ["health"],
96 "oct_skeleton": ["export-skeleton"],
97 "oct_deps": ["deps"],
98 "oct_test": ["test"],
99 "oct_typecheck": ["typecheck"],
100 "oct_diag": ["diag", "validate-config"],
101 "oct_git_status": ["git", "status"],
102 "oct_git_check": ["git", "check"],
103 "oct_format": ["format"],
104 "oct_docs": ["docs"],
105 "oct_export_source": ["export-source"],
106 "oct_new": ["new"],
107 "oct_scaffold": ["scaffold"],
108 "oct_clean": ["clean"],
109 "oct_install_hooks": ["git", "hooks", "install"],
110 "oct_git_commit": ["git", "commit"],
111 "oct_git_hooks_install": ["git", "hooks", "install"],
112 "oct_git_init": ["git", "init"],
113 "oct_git_changelog": ["git", "changelog"],
114}
115
116
118 """Every validator model must have a matching executor argv builder."""
119 model_names = set(_TOOL_MODEL_MAP)
120 builder_names = set(_ARGV_BUILDERS)
121 assert model_names == builder_names, (
122 f"Validator/executor drift: "
123 f"only in validator={model_names - builder_names!r}, "
124 f"only in executor={builder_names - model_names!r}"
125 )
126
127
129 """_MINIMAL_ARGS must cover every registered tool."""
130 assert set(_MINIMAL_ARGS) == set(_TOOL_MODEL_MAP), (
131 "Update _MINIMAL_ARGS to mirror validator._TOOL_MODEL_MAP"
132 )
133
134
135@pytest.mark.parametrize("tool_name", sorted(_TOOL_MODEL_MAP))
136def test_model_validates_minimal_args(tool_name: str, tmp_path: Path):
137 """Minimal args dict must validate against the registered model."""
138 raw = _MINIMAL_ARGS[tool_name]
139 model = validate_tool_args(tool_name, raw, project_root=tmp_path)
140 assert model is not None
141
142
143@pytest.mark.parametrize("tool_name", sorted(_TOOL_MODEL_MAP))
144def test_argv_starts_with_expected_subcommand(tool_name: str, tmp_path: Path):
145 """Builder output must begin with python -m oct <subcommand>."""
146 raw = _MINIMAL_ARGS[tool_name]
147 model = validate_tool_args(tool_name, raw, project_root=tmp_path)
148 cmd = _ARGV_BUILDERS[tool_name](model.model_dump(), tmp_path)
149
150 assert cmd[0] == sys.executable
151 assert cmd[1] == "-m"
152 assert cmd[2] == "oct"
153
154 expected_tail = _EXPECTED_SUBCOMMAND[tool_name]
155 assert cmd[3 : 3 + len(expected_tail)] == expected_tail, (
156 f"{tool_name}: expected subcommand tail {expected_tail!r}, "
157 f"got {cmd[3 : 3 + len(expected_tail)]!r} (full cmd={cmd!r})"
158 )
159
160
161# ---------------------------------------------------------------------
162# Regression guards for previously-drifting flags.
163# ---------------------------------------------------------------------
164
165
166def _build_cmd(tool_name: str, tmp_path: Path) -> list[str]:
167 model = validate_tool_args(
168 tool_name, _MINIMAL_ARGS[tool_name], project_root=tmp_path
169 )
170 return _ARGV_BUILDERS[tool_name](model.model_dump(), tmp_path)
171
172
174 """OI-502: CLI ``oct export-skeleton`` does not accept ``--output-dir``."""
175 cmd = _build_cmd("oct_skeleton", tmp_path)
176 assert "--output-dir" not in cmd
177
178
180 """OI-502: CLI ``oct deps`` does not accept ``--check``."""
181 cmd = _build_cmd("oct_deps", tmp_path)
182 assert "--check" not in cmd
183
184
186 """OI-502: CLI ``oct test`` does not accept ``--jobs``."""
187 cmd = _build_cmd("oct_test", tmp_path)
188 assert "--jobs" not in cmd
189
190
192 """OI-502: no ``oct diag`` subcommand accepts ``--json`` (yet)."""
193 cmd = _build_cmd("oct_diag", tmp_path)
194 assert "--json" not in cmd
195
196
198 """OI-502: ``oct format`` argparser does not register ``--jobs``."""
199 cmd = _build_cmd("oct_format", tmp_path)
200 assert "--jobs" not in cmd
201
202
203# ---------------------------------------------------------------------
204# Headline behaviour: scoped + breaking Conventional Commits validate.
205# ---------------------------------------------------------------------
206
207
208@pytest.mark.parametrize(
209 "message",
210 [
211 "feat(core): add parity harness",
212 "fix(mcp): close OI-502",
213 "feat!: drop legacy config",
214 "refactor(git)!: rename internal helper",
215 ],
216)
218 message: str, tmp_path: Path,
219):
220 """OI-502 / FS-502 — scoped and breaking commits must round-trip."""
221 model = validate_tool_args(
222 "oct_git_commit", {"message": message}, project_root=tmp_path,
223 )
224 assert model.message == message
225 cmd = _ARGV_BUILDERS["oct_git_commit"](model.model_dump(), tmp_path)
226 # git commit receives the message as a single argv element, never
227 # via a shell.
228 assert message in cmd
229 assert "-m" in cmd
test_model_validates_minimal_args(str tool_name, Path tmp_path)
test_argv_starts_with_expected_subcommand(str tool_name, Path tmp_path)
list[str] _build_cmd(str tool_name, Path tmp_path)
test_git_commit_accepts_valid_conventional_messages(str message, Path tmp_path)
test_skeleton_does_not_emit_output_dir(Path tmp_path)