Contracts
deprecation_warning.md
Contract: Deprecation Warning Format and Suppression
Mission: 077-mission-terminology-cleanup Surface: stderr output of src/specify_cli/cli/selector_resolution.py::_emit_deprecation_warning Owner: Scope A (#241), specifically WPA4
Warning Text Format
The deprecation warning is emitted to stderr via rich.console.Console(stderr=True) and uses the existing precedent at src/specify_cli/cli/commands/agent/mission.py:604 exactly:
Warning: <alias_flag> is deprecated; use <canonical_flag>. See: docs/migration/feature-flag-deprecation.md
When rendered through Rich with [yellow]Warning:[/yellow], the visible output is:
Warning: --feature is deprecated; use --mission. See: docs/migration/feature-flag-deprecation.md
(The "Warning:" prefix is yellow; the rest is default.)
For the inverse direction (FR-021), the same format applies:
Warning: --mission is deprecated; use --mission-type. See: docs/migration/mission-type-flag-deprecation.md
Implementation
import os
from rich.console import Console
_warned: set[tuple[str, str]] = set()
_err_console = Console(stderr=True)
def _emit_deprecation_warning(
canonical_flag: str,
alias_flag: str,
suppress_env_var: str,
) -> bool:
"""Emit a single yellow stderr deprecation warning unless suppressed.
Returns True if a warning was actually emitted, False if suppressed
or if a warning has already been emitted for this (canonical, alias)
pair in the current process.
"""
pair = (canonical_flag, alias_flag)
if pair in _warned:
return False
if os.environ.get(suppress_env_var) == "1":
return False
_warned.add(pair)
doc_path = _doc_path_for(alias_flag)
_err_console.print(
f"[yellow]Warning:[/yellow] {alias_flag} is deprecated; "
f"use {canonical_flag}. See: {doc_path}"
)
return True
def _doc_path_for(alias_flag: str) -> str:
"""Map an alias flag to its migration doc path."""
return {
"--feature": "docs/migration/feature-flag-deprecation.md",
"--mission": "docs/migration/mission-type-flag-deprecation.md",
}[alias_flag]
Suppression Env Vars
| Env var | Direction | Set to | Effect |
|---|---|---|---|
SPEC_KITTY_SUPPRESS_FEATURE_DEPRECATION | --feature → --mission | "1" | Skips the warning emit. The helper still resolves the value and still raises BadParameter on conflict. |
SPEC_KITTY_SUPPRESS_MISSION_TYPE_DEPRECATION | --mission → --mission-type | "1" | Same. |
Default behavior: when the env var is not set or is set to anything other than "1", the warning is emitted normally. There is no 0 or false value handling — the env var is strictly opt-in to suppression.
Visibility: both env vars are documented in the same migration doc that the warning links to. They are intentionally verbose and direction-specific so a CI maintainer who needs to suppress one cannot accidentally suppress the other.
Single-Warning-Per-Invocation Guarantee (NFR-002)
The _warned set is module-level. It accumulates (canonical_flag, alias_flag) pairs across all calls within a single Python process. A long-running CLI session that invokes the same deprecated alias multiple times sees the warning only once per direction.
Verified by tests/specify_cli/cli/commands/test_selector_resolution.py::test_warning_emitted_only_once_per_pair.
The set is reset between test cases by the autouse fixture defined in data-model.md.
Migration Documentation
This mission must publish two short migration doc pages:
1. docs/migration/feature-flag-deprecation.md — explains why --feature is being removed, when it will be removed (named conditions only, per spec §15 Q1), how to migrate scripts, and how to suppress the warning during cutover. 2. docs/migration/mission-type-flag-deprecation.md — same shape for the inverse-drift case.
Both docs link back to:
- The spec at
kitty-specs/077-mission-terminology-cleanup/spec.md - The ADR at
architecture/2.x/adr/2026-04-04-2-mission-type-mission-and-mission-run-terminology-boundary.md - The initiative at
architecture/2.x/initiatives/2026-04-mission-nomenclature-reconciliation/README.md
The exact content of these docs is finalized in WPA9.
What This Contract Does NOT Specify
- The Rich color (yellow) is fixed by the
agent/mission.py:604precedent. Implementers should not change it. - The exact migration doc paths above are committed contracts. If they need to change, both this contract and the warning text must be updated together in the same PR.
- The warning does not include a date for removal. Per spec §15 Q1, the removal is gated on named conditions, not a date. Adding a date would violate that decision.
Test Coverage
| Test case | What it asserts |
|---|---|
test_warning_text_format | The exact stderr line matches the format above for both directions. |
test_warning_emitted_only_once_per_pair | Two calls with the same pair produce one stderr line. |
test_warning_emitted_again_for_different_pair | Two calls with different pairs produce two stderr lines. |
test_suppression_env_var_skips_warning | With env var set to "1", no stderr line is produced. |
test_suppression_env_var_only_responds_to_one | With env var set to "0" or "false" or "true" or unset, the warning IS emitted. Only "1" suppresses. |
test_inverse_suppression_env_var_independent | Setting SPEC_KITTY_SUPPRESS_FEATURE_DEPRECATION=1 does not suppress the inverse direction's warning. |
grep_guards.md
Contract: CI Grep Guards for Terminology Drift
Mission: 077-mission-terminology-cleanup Surface: tests/contract/test_terminology_guards.py (new file) Owner: Scope A (#241), specifically WPA6 + WPA7 Authority: FR-022, C-011, spec §12.2
Purpose
These grep guards exist to prevent the canonical terminology drift from returning. They are not historical-artifact rewriters: they must not scan kitty-specs/ (mission specs and history) or architecture/ (ADRs and initiative records). Historical artifacts are append-only history; rewriting them is forbidden by C-011.
The guards run in CI as part of the standard pytest suite. When a future PR re-introduces a forbidden pattern in a live first-party surface, the guard fires and the build fails.
Scope
Per spec FR-022, the guards scan live first-party surfaces including the top-level project files. Historical artifacts and runtime state are explicitly excluded.
| Path | Scanned? | Why |
|---|---|---|
src/specify_cli/cli/commands/*/.py | Yes | Live CLI command surface |
src/specify_cli/orchestrator_api/*/.py | Yes (read-only verification) | Verifies orchestrator-api stays canonical (C-010) |
src/doctrine/skills/*/.md | Yes | Live doctrine skills |
docs/*/.md | Yes | Live first-party docs of every kind; docs/migration/** is excluded separately |
README.md (top-level) | Yes | Live primary user-facing doc — explicitly in scope per FR-022 |
CONTRIBUTING.md (top-level) | Yes | Live contributor doc — explicitly in scope per FR-022 |
CHANGELOG.md (top-level) | Partial — only the "Unreleased" section above the first ## [<version>] heading | Historical version entries are excluded by FR-022's "CHANGELOG-style historical entries" carve-out. The "Unreleased" section is live; everything below the first version heading is frozen history. |
docs/migration/*/.md | No | Migration docs are required to mention the deprecated flags by name (this is where the warning text points) |
kitty-specs/** | No | Historical mission artifacts; FR-022, C-011 |
architecture/** | No | Historical ADRs and initiatives; FR-022, C-011 |
.kittify/** | No | Runtime state |
tests/** | No | Tests legitimately mention forbidden patterns to assert against them |
kitty-specs/077-mission-terminology-cleanup/** | No | This mission's own artifacts mention forbidden patterns by necessity |
Guard Definitions
Guard 1: No --mission-run for tracked-mission selection in live CLI command files
def test_no_mission_run_alias_in_tracked_mission_selectors():
"""Live CLI command files must not declare --mission-run as an alias for tracked-mission selection.
Authority: FR-002, FR-003, spec §12.2.
Excludes runtime/session command files (which may legitimately accept --mission-run).
"""
# Pseudocode:
for path in glob("src/specify_cli/cli/commands/**/*.py"):
if "runtime" in path or "session" in path:
continue # Runtime/session commands legitimately use --mission-run
content = read(path)
# Match: typer.Option(..., "--mission-run", ...) where "--mission-run" is not the canonical primary
# Allow only if the surrounding parameter's name implies runtime/session context.
for match in re.finditer(r'typer\.Option\([^)]*"--mission-run"[^)]*\)', content):
param_context = _surrounding_param_name(content, match)
if not _is_runtime_session_param(param_context):
fail(f"{path}: --mission-run used as tracked-mission selector alias at offset {match.start()}")
Guard 2: No "Mission run slug" in tracked-mission CLI help text
def test_no_mission_run_slug_help_text_in_cli_commands():
"""Live CLI command help text must not say 'Mission run slug' for tracked-mission selectors.
Authority: FR-008, spec §12.2.
"""
for path in glob("src/specify_cli/cli/commands/**/*.py"):
content = read(path)
if "Mission run slug" in content:
fail(f"{path}: contains 'Mission run slug' help text; tracked-mission selectors must say 'Mission slug'")
Guard 3: No visible --feature declarations in CLI command files
def test_no_visible_feature_alias_in_cli_commands():
"""--feature is acceptable only as a hidden=True alias.
Authority: Charter §Terminology Canon hyper-vigilance rules,
spec §11.1 (hidden deprecated alias), Charter Reconciliation in plan.md.
"""
for path in glob("src/specify_cli/cli/commands/**/*.py"):
content = read(path)
for option_block in _iter_typer_option_blocks(content):
if '"--feature"' not in option_block:
continue
if "hidden=True" not in option_block:
fail(f"{path}: --feature declared without hidden=True; charter requires hidden secondary alias only")
Guard 4: No --mission-run instructions in live doctrine skills
def test_no_mission_run_instructions_in_doctrine_skills():
"""Doctrine skills must teach --mission for tracked-mission selection.
Authority: FR-009, spec §12.2.
Scope: src/doctrine/skills/**/*.md only.
Excludes: kitty-specs/**, architecture/**, .kittify/**, this mission's own artifacts.
"""
forbidden_patterns = [
r"--mission-run\s+\d{3}", # --mission-run 077-foo (tracked-mission slug pattern)
r"--mission-run\s+<slug>",
r"--mission-run\s+<mission",
]
for path in glob("src/doctrine/skills/**/*.md"):
content = read(path)
for pattern in forbidden_patterns:
for match in re.finditer(pattern, content):
fail(f"{path}: doctrine skill instructs --mission-run for tracked-mission selection at offset {match.start()}")
Guard 5: No --mission-run instructions in live agent-facing docs
def test_no_mission_run_instructions_in_agent_facing_docs():
"""Live docs must teach --mission for tracked-mission selection.
Authority: FR-010, FR-022, spec §12.2.
Scope: docs/**, top-level README.md, top-level CONTRIBUTING.md, and the
Unreleased section of top-level CHANGELOG.md.
EXCLUDES: docs/migration/** (migration docs may name --feature and
--mission-run by necessity).
"""
forbidden_patterns = [
r"--mission-run\s+\d{3}",
r"--mission-run\s+<slug>",
r"--mission-run\s+<mission",
]
scan_targets: list[Path] = []
for glob_pattern in ["docs/**/*.md"]:
scan_targets.extend(_glob(glob_pattern))
scan_targets = [
path for path in scan_targets
if not path.relative_to(REPO_ROOT).as_posix().startswith("docs/migration/")
]
for top_level in ["README.md", "CONTRIBUTING.md"]:
path = REPO_ROOT / top_level
if path.exists():
scan_targets.append(path)
# CHANGELOG.md: only the Unreleased section above the first version header.
changelog_path = REPO_ROOT / "CHANGELOG.md"
if changelog_path.exists():
scan_targets.append(("changelog-unreleased", changelog_path))
for target in scan_targets:
if isinstance(target, tuple) and target[0] == "changelog-unreleased":
content = _extract_changelog_unreleased(target[1])
path = target[1]
else:
path = target
content = _read(path)
for pattern in forbidden_patterns:
for match in re.finditer(pattern, content):
fail(f"{path}: instructs --mission-run for tracked-mission selection at offset {match.start()}")
Guard 5b: No live --feature instructions in first-party docs
def test_no_feature_flag_in_live_first_party_docs():
"""Live first-party docs must not document --feature as a
current/canonical CLI option.
Authority: FR-005, FR-022, charter §Terminology Canon hyper-vigilance.
Scope: docs/**, top-level README.md, top-level CONTRIBUTING.md, and the
Unreleased section of top-level CHANGELOG.md.
EXCLUDES: docs/migration/** (migration docs name --feature by necessity)
and historical version sections of CHANGELOG.md.
A future PR that legitimately needs to mention --feature in a live top-level
doc (e.g., to point users at the migration doc) must do so by linking to
docs/migration/feature-flag-deprecation.md, not by documenting --feature
as a usable option. The guard fails on raw `--feature <slug>`,
`--feature ` followed by a slug-like token, and `--feature` inside an
Options table column.
"""
forbidden_patterns = [
r"--feature\s+<slug>",
r"--feature\s+\d{3}",
r"--feature\s+[a-z][a-z0-9-]*", # `--feature` followed by a slug-like token
r"\|\s*`--feature[\s|<>`]", # `--feature` cell in a markdown options table
]
scan_targets: list[Path | tuple[str, Path]] = []
for path in _glob("docs/**/*.md"):
if path.relative_to(REPO_ROOT).as_posix().startswith("docs/migration/"):
continue
scan_targets.append(path)
for top_level in ["README.md", "CONTRIBUTING.md"]:
path = REPO_ROOT / top_level
if path.exists():
scan_targets.append(path)
changelog_path = REPO_ROOT / "CHANGELOG.md"
if changelog_path.exists():
scan_targets.append(("changelog-unreleased", changelog_path))
for target in scan_targets:
if isinstance(target, tuple) and target[0] == "changelog-unreleased":
content = _extract_changelog_unreleased(target[1])
path = target[1]
else:
path = target
content = _read(path)
for pattern in forbidden_patterns:
for match in re.finditer(pattern, content):
snippet = content[max(0, match.start() - 30):match.end() + 30]
fail(
f"{path}: documents --feature as a live CLI option at "
f"offset {match.start()}: {snippet!r}\n"
f"Authority: spec §11.1, charter §Terminology Canon. "
f"Replace with --mission or link to docs/migration/feature-flag-deprecation.md."
)
Helper: extract the Unreleased section from CHANGELOG.md
def _extract_changelog_unreleased(path: Path) -> str:
"""Return the portion of CHANGELOG.md above the first `## [<version>]` heading.
The `## [Unreleased]` section (if present) and any preamble are returned;
historical version entries are excluded per FR-022.
"""
content = _read(path)
# Match `## [<version>]` where <version> looks like 1.2.3 or 1.2.3a4
match = re.search(r"^## \[\d+\.\d+\.\d+", content, flags=re.MULTILINE)
if match is None:
# No version headings yet — the whole file is "unreleased"
return content
return content[: match.start()]
Guard 6: Inverse drift — --mission not used to mean blueprint/template
def test_no_mission_used_to_mean_mission_type_in_cli_commands():
"""CLI command files must not declare --mission with help text that names a mission type.
Authority: FR-021, spec §8.1.2, spec §12.2.
The check is heuristic: if a typer.Option declares --mission and its help string
contains 'mission type' or 'mission key', that is the inverse-drift bug. Such
sites must instead declare --mission-type as canonical with --mission as a
hidden alias.
"""
for path in glob("src/specify_cli/cli/commands/**/*.py"):
content = read(path)
for option_block in _iter_typer_option_blocks(content):
if '"--mission"' not in option_block:
continue
help_text = _extract_help(option_block)
if any(phrase in help_text.lower() for phrase in ["mission type", "mission key"]):
if '"--mission-type"' not in option_block:
fail(f"{path}: --mission declared with mission-type semantics but no --mission-type canonical parameter present")
Guard 7: Orchestrator-api envelope width unchanged (read-only verification)
def test_orchestrator_api_envelope_width_unchanged():
"""The orchestrator-api 7-key envelope must not be widened by this or any future mission.
Authority: C-010, spec §10.1 item 10.
The expected key set is verified against the canonical implementation at
src/specify_cli/orchestrator_api/envelope.py::make_envelope at HEAD
35d43a25 (validated baseline 54269f7c). If make_envelope is intentionally
changed in a future PR, this guard's expected_keys must be updated in the
SAME PR after a documented C-010 amendment.
"""
from specify_cli.orchestrator_api.envelope import make_envelope
envelope = make_envelope("test-cmd", success=True, data={})
expected_keys = {
"contract_version",
"command",
"timestamp",
"correlation_id",
"success",
"error_code",
"data",
}
assert set(envelope.keys()) == expected_keys, (
f"Orchestrator-api envelope keys must remain exactly {expected_keys}; "
f"got {set(envelope.keys())}. C-010 forbids widening."
)
# Reinforce the count check too, in case a future change replaces a key
# rather than adding one (still a contract change that needs review).
assert len(envelope) == 7, (
f"Orchestrator-api envelope must remain exactly 7 keys; got {len(envelope)}."
)
Guard 8: Historical-artifact safety check (the meta-guard)
def test_grep_guards_do_not_scan_historical_artifacts():
"""Verify the grep guards' scope excludes historical artifacts.
Authority: FR-022, C-011.
This test fails if any of the guard functions in this file accidentally
scan kitty-specs/, architecture/, .kittify/, or CHANGELOG.md.
Implementation: introspect the path globs declared at the top of each guard
function and assert none of them resolve into the forbidden roots.
"""
forbidden_roots = ["kitty-specs/", "architecture/", ".kittify/", "CHANGELOG.md"]
# Inspect each guard's declared globs and fail if any glob would match a forbidden path.
File Structure
# tests/contract/test_terminology_guards.py
"""CI grep guards for canonical terminology drift.
These guards prevent the Mission Type / Mission / Mission Run terminology
boundary from drifting back to legacy selector vocabulary. They are scoped
to LIVE first-party surfaces only.
EXPLICITLY DOES NOT SCAN (per FR-022, C-011):
- kitty-specs/** (historical mission artifacts)
- architecture/** (historical ADRs and initiative records)
- .kittify/** (runtime state)
- tests/** (tests legitimately mention forbidden patterns)
- CHANGELOG.md (historical change log)
- docs/migration/** (migration docs explain the deprecation by name)
Authority:
- spec.md FR-022, C-010, C-011
- spec.md §12.2 Documentation and Skill Tests
- charter.md §Terminology Canon hyper-vigilance rules
"""
import re
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
# ---------- Helpers ----------
def _glob(pattern: str) -> list[Path]:
return list(REPO_ROOT.glob(pattern))
def _read(path: Path) -> str:
return path.read_text(encoding="utf-8")
def _iter_typer_option_blocks(content: str):
"""Yield each typer.Option(...) call's text."""
# ... implementation ...
# ---------- Guards ----------
def test_no_mission_run_alias_in_tracked_mission_selectors():
...
def test_no_mission_run_slug_help_text_in_cli_commands():
...
def test_no_visible_feature_alias_in_cli_commands():
...
def test_no_mission_run_instructions_in_doctrine_skills():
...
def test_no_mission_run_instructions_in_agent_facing_docs():
...
def test_no_feature_flag_in_live_top_level_docs():
...
def test_no_mission_used_to_mean_mission_type_in_cli_commands():
...
def test_orchestrator_api_envelope_width_unchanged():
...
def test_grep_guards_do_not_scan_historical_artifacts():
...
The full guard count is 9 test functions (the original 8 plus Guard 5b for top-level project docs).
Failure Messages
Every guard's failure message must: 1. Name the file path that triggered the failure. 2. Name the offset or line number of the offending content. 3. Cite the FR or C number from the spec that the failure violates. 4. Suggest the canonical replacement.
Example:
FAILED tests/contract/test_terminology_guards.py::test_no_visible_feature_alias_in_cli_commands
src/specify_cli/cli/commands/agent/tasks.py:842: --feature declared without hidden=True
Authority: spec.md FR-005, charter §Terminology Canon hyper-vigilance.
Fix: declare --feature as a separate typer.Option with hidden=True, then call
resolve_selector() from the function body. See contracts/selector_resolver.md
for the canonical pattern.
What These Guards Do NOT Do
- They do not modify any file. They are read-only.
- They do not auto-fix. A human (or a future agent under explicit instruction) must fix flagged sites.
- They do not enforce documentation completeness. They only enforce that if a doc mentions tracked-mission selection, it does so with canonical vocabulary.
- They do not scan historical artifacts. C-011 forbids this. Guard 8 verifies the scope is correct.
Maintenance
Future PRs that legitimately need to introduce a new pattern (e.g., a new alias for a different reason) must update both the guard and the contract test in the same PR. Reviewers should treat any PR that adds an # noqa or skip marker on these guards with extreme suspicion — the whole point is to prevent silent drift.
selector_resolver.md
Contract: Selector Resolution Helper
Mission: 077-mission-terminology-cleanup Surface: src/specify_cli/cli/selector_resolution.py (new module) Owner: Scope A (#241) Test file: tests/specify_cli/cli/commands/test_selector_resolution.py (new file)
Module Structure
src/specify_cli/cli/selector_resolution.py
├── SelectorResolution (frozen dataclass; see data-model.md)
├── resolve_selector(...) (public helper)
├── _emit_deprecation_warning(...) (private sub-helper)
└── _warned: set[tuple[str, str]] (module-level state for NFR-002)
Public API
resolve_selector
def resolve_selector(
*,
canonical_value: str | None,
canonical_flag: str,
alias_value: str | None,
alias_flag: str,
suppress_env_var: str,
command_hint: str | None = None,
) -> SelectorResolution:
...
Behavioral contract:
| Input case | Returns | Side effects | Raises |
|---|---|---|---|
Both None or empty | — | — | typer.BadParameter via require_explicit_feature(None, command_hint=command_hint) |
Only canonical_value set | SelectorResolution(canonical_value, canonical_flag, alias_used=False, alias_flag=None, warning_emitted=False) | None | — |
Only alias_value set | SelectorResolution(canonical_value=alias_value.strip(), canonical_flag, alias_used=True, alias_flag, warning_emitted=<actual>) | At most one yellow stderr deprecation warning per process per (canonical_flag, alias_flag) pair, suppressible via os.environ.get(suppress_env_var) == "1" | — |
Both set, equal (after .strip()) | SelectorResolution(canonical_value=canonical_value.strip(), canonical_flag, alias_used=True, alias_flag, warning_emitted=<actual>) | One deprecation warning (same suppression rule) | — |
Both set, not equal (after .strip()) | — | None | typer.BadParameter with the conflict message format defined below |
Conflict Error Format
Conflicting selectors: <canonical_flag>=<canonical_value!r> and <alias_flag>=<alias_value!r> were both provided with different values. <alias_flag> is a hidden deprecated alias for <canonical_flag>; pass only <canonical_flag>.
Example for mission current --mission A --feature B:
Conflicting selectors: --mission='A' and --feature='B' were both provided with different values. --feature is a hidden deprecated alias for --mission; pass only --mission.
Example for agent mission create new-thing --mission-type software-dev --mission research (inverse direction):
Conflicting selectors: --mission-type='software-dev' and --mission='research' were both provided with different values. --mission is a hidden deprecated alias for --mission-type; pass only --mission-type.
The format is verified by tests/specify_cli/cli/commands/test_selector_resolution.py::test_conflict_error_format.
Call-Site Pattern
Every tracked-mission command in the main CLI follows this pattern. Two separate typer parameters; one helper call in the function body.
Tracked-Mission Command (Scope A — direction: --feature is the alias)
import typer
from specify_cli.cli.selector_resolution import resolve_selector
@app.command("current")
def current_cmd(
mission: Annotated[str | None, typer.Option(
"--mission",
help="Mission slug",
)] = None,
feature: Annotated[str | None, typer.Option(
"--feature",
hidden=True, # ← KEY: charter compliance
help="(deprecated) Use --mission",
)] = None,
) -> None:
"""Show the active mission type for a mission."""
resolved = resolve_selector(
canonical_value=mission,
canonical_flag="--mission",
alias_value=feature,
alias_flag="--feature",
suppress_env_var="SPEC_KITTY_SUPPRESS_FEATURE_DEPRECATION",
command_hint="--mission <slug>",
)
mission_slug = resolved.canonical_value
# ... rest of command logic uses mission_slug ...
Inverse-Drift Command (Scope A — direction: --mission is the alias)
import typer
from specify_cli.cli.selector_resolution import resolve_selector
@app.command(name="create")
def create_mission(
mission_slug: Annotated[str, typer.Argument(help="Mission slug (e.g., 'user-auth')")],
mission_type: Annotated[str | None, typer.Option(
"--mission-type",
help="Mission type (e.g., 'documentation', 'software-dev')",
)] = None,
mission: Annotated[str | None, typer.Option(
"--mission",
hidden=True,
help="(deprecated) Use --mission-type",
)] = None,
json_output: Annotated[bool, typer.Option("--json", help="Output JSON format")] = False,
target_branch: Annotated[str | None, typer.Option("--target-branch", help="Target branch (defaults to current branch)")] = None,
) -> None:
"""Create new mission directory structure in the project root checkout."""
resolved = resolve_selector(
canonical_value=mission_type,
canonical_flag="--mission-type",
alias_value=mission,
alias_flag="--mission",
suppress_env_var="SPEC_KITTY_SUPPRESS_MISSION_TYPE_DEPRECATION",
command_hint="--mission-type <name>",
)
mission_type_value = resolved.canonical_value
# ... rest of command logic uses mission_type_value ...
Required Test Coverage
The contract test file tests/specify_cli/cli/commands/test_selector_resolution.py must cover all of the following cases for resolve_selector directly, plus one integration test per direction via typer.testing.CliRunner:
Unit Tests (helper, in isolation)
1. test_canonical_only_returns_value — only canonical_value set, returns it, alias_used=False, no warning. 2. test_alias_only_returns_canonical_value — only alias_value set, returns it, alias_used=True, warning emitted. 3. test_both_equal_returns_value_with_warning — both set, equal, returns value, alias_used=True, warning emitted. 4. test_both_different_raises_bad_parameter — both set, different, raises typer.BadParameter with the exact conflict message format. 5. test_neither_raises_bad_parameter — both None, raises via require_explicit_feature. 6. test_both_empty_strings_raise_bad_parameter — both "", raises via require_explicit_feature (after .strip()). 7. test_canonical_whitespace_only_treated_as_none — canonical_value=" ", alias_value="x", returns "x" with warning. 8. test_warning_emitted_only_once_per_pair — call helper twice with the same (canonical_flag, alias_flag), verify only one stderr line. 9. test_warning_emitted_again_for_different_pair — call with --feature then with --mission (different alias), verify two stderr lines. 10. test_suppression_env_var_skips_warning — set SPEC_KITTY_SUPPRESS_FEATURE_DEPRECATION=1, call with alias, verify no stderr line, but warning_emitted=False. 11. test_inverse_direction_works_identically — call with canonical_flag="--mission-type", alias_flag="--mission", suppress_env_var="SPEC_KITTY_SUPPRESS_MISSION_TYPE_DEPRECATION", verify all behaviors. 12. test_conflict_error_format — verify exact message text matches the format in this contract.
Integration Tests (full typer command, via CliRunner)
13. test_mission_current_canonical_succeeds — mission current --mission 077-x resolves correctly. 14. test_mission_current_alias_succeeds_with_warning — mission current --feature 077-x resolves and emits warning. 15. test_mission_current_dual_flag_conflict_fails — mission current --mission A --feature B exits non-zero with conflict message. This is the regression test for the verified bug from spec §8.2. 16. test_agent_mission_create_canonical_succeeds — agent mission create new --mission-type software-dev resolves correctly. 17. test_agent_mission_create_alias_succeeds_with_warning — agent mission create new --mission software-dev resolves and emits warning. 18. test_agent_mission_create_dual_flag_conflict_fails — agent mission create new --mission-type software-dev --mission research exits non-zero with conflict message.
Coverage Requirement
Per NFR-005, line coverage on src/specify_cli/cli/selector_resolution.py must be ≥ 90%. With the case list above, the helper should reach 100% line coverage.
Module-Level State Reset
The autouse fixture from data-model.md (the _reset_selector_resolution_state fixture) must be installed in tests/specify_cli/cli/commands/test_selector_resolution.py and in any other test file that calls the helper directly. Without it, test order affects warning emission and CI is flaky.
Constraints (from spec §7)
- C-008: The helper must not introduce any abstraction that would make a future
Mission → MissionRunrename "easier". Specifically: no aggregate-type indirection, no parallel field shadows, no abstract base classes for selector resolution. - C-010: The helper must live in
src/specify_cli/cli/, not insrc/specify_cli/orchestrator_api/. The orchestrator-api is not wired through this helper. - C-011: The helper's tests must not scan
kitty-specs/orarchitecture/.
Backwards Compatibility for Existing Call Sites
The existing function require_explicit_feature at src/specify_cli/core/paths.py:273 is not modified. It continues to be called by the new helper in the "missing value" path. All existing call sites of require_explicit_feature continue to work unchanged. WPA1 may identify call sites that should additionally be wired through resolve_selector, but the existing helper itself is unchanged.