feat(s53): CLI mode-exclusivity refusals (portfolio vs single-project partition)
After parse_args, validate mode consistency with structured refusals (rc 1, not argparse.error): --portfolio + any single-project-only flag (--docs-dir/--bundle-dir/--verdict-dir/--outbox-dir/ --run-id/--live-dry-run) is refused naming the offending flag; --goals/--ledger outside --portfolio is refused. --decision/--rationale are EXCLUDED (non-None defaults make explicit-vs-default indistinguishable — Pass-2 #2; inert in portfolio mode, README says so). --dimension-config is valid in both modes. Validation precedes the portfolio dispatch, so refusals fire before any load. RED-first: (a)/(a')/(c) failed offline (rc 0 fall-through) before the check, green after; (b)/(b') single-project guard + legacy backward-compat pin were already green post-Step-3. All refusal-arm RED fall-throughs held OFFLINE (met-goal / --live-dry-run) — no socket, per brief NFR. 15 passed, ruff + mypy clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KNNiJRk1sSwxgVLS5AobT1
This commit is contained in:
parent
905b2f9a43
commit
1b990f0887
2 changed files with 143 additions and 0 deletions
|
|
@ -677,6 +677,36 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
)
|
)
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
# Step 4: mode-exclusivity validation (structured refusal, NOT argparse.error — keeps the rc 1
|
||||||
|
# refusal contract). The two CLI modes are a documented partition: single-project-only flags are
|
||||||
|
# refused in portfolio mode, and --goals/--ledger are refused outside it — never silently ignored.
|
||||||
|
# --decision/--rationale are excluded: their non-None argparse defaults make an explicit value
|
||||||
|
# indistinguishable from the default, so an honest refusal is unimplementable (they are inert in
|
||||||
|
# portfolio mode; the README documents that). --dimension-config is valid in BOTH modes.
|
||||||
|
if args.portfolio:
|
||||||
|
single_only = {
|
||||||
|
"--docs-dir": args.docs_dir,
|
||||||
|
"--bundle-dir": args.bundle_dir,
|
||||||
|
"--verdict-dir": args.verdict_dir,
|
||||||
|
"--outbox-dir": args.outbox_dir,
|
||||||
|
"--run-id": args.run_id,
|
||||||
|
"--live-dry-run": args.live_dry_run,
|
||||||
|
}
|
||||||
|
offending = [name for name, value in single_only.items() if value]
|
||||||
|
if offending:
|
||||||
|
print(
|
||||||
|
f"portfolio run refused: {', '.join(offending)} belong to single-project mode, "
|
||||||
|
"not --portfolio (the two CLI modes are a documented partition)",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
elif args.goals is not None or args.ledger is not None:
|
||||||
|
print(
|
||||||
|
"run refused: --goals/--ledger require --portfolio mode",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
if args.portfolio:
|
if args.portfolio:
|
||||||
# Portfolio mode (Step 3): dispatch to the EXISTING run_portfolio via the fail-fast loaders
|
# Portfolio mode (Step 3): dispatch to the EXISTING run_portfolio via the fail-fast loaders
|
||||||
# (run_portfolio itself is unchanged). Loader/ValueError failures surface through the same
|
# (run_portfolio itself is unchanged). Loader/ValueError failures surface through the same
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import pytest
|
||||||
|
|
||||||
from portfolio_optimiser import run
|
from portfolio_optimiser import run
|
||||||
from portfolio_optimiser.dimension import Dimension
|
from portfolio_optimiser.dimension import Dimension
|
||||||
|
from portfolio_optimiser.ledger import LedgerEntry, SavingsLedger
|
||||||
|
|
||||||
BUNDLE_DIR = Path(__file__).resolve().parents[1] / "shared" / "examples" / "bygg-energi-mikro"
|
BUNDLE_DIR = Path(__file__).resolve().parents[1] / "shared" / "examples" / "bygg-energi-mikro"
|
||||||
_PID = "BYGG-KONTOR-NORD"
|
_PID = "BYGG-KONTOR-NORD"
|
||||||
|
|
@ -119,3 +120,115 @@ def test_dimension_config_missing_file_refuses(capsys) -> None:
|
||||||
)
|
)
|
||||||
assert rc == 1
|
assert rc == 1
|
||||||
assert "refused" in capsys.readouterr().err.lower()
|
assert "refused" in capsys.readouterr().err.lower()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Step 4: mode-exclusivity refusals + backward-compat pin -------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _met_portfolio_goal(tmp_path: Path) -> tuple[Path, Path]:
|
||||||
|
"""A portfolio-hard goal (1 øre) already met by a 1-øre ledger — so any RED (pre-refusal)
|
||||||
|
fall-through into the portfolio dispatch stops OFFLINE at the goal check (no client, no socket)."""
|
||||||
|
goals = tmp_path / "goals.json"
|
||||||
|
goals.write_text('{"portfolio": {"absolute_ore": 1, "mode": "hard"}}', encoding="utf-8")
|
||||||
|
led = SavingsLedger()
|
||||||
|
led.add_realized(
|
||||||
|
LedgerEntry(
|
||||||
|
project_id="FV42-GSV-E1",
|
||||||
|
dimension="energi",
|
||||||
|
candidate_identity="c1",
|
||||||
|
amount_ore=1,
|
||||||
|
verdict_id="v1",
|
||||||
|
provenance="x",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ledger = tmp_path / "ledger.json"
|
||||||
|
led.save(str(ledger))
|
||||||
|
return goals, ledger
|
||||||
|
|
||||||
|
|
||||||
|
def test_goals_without_portfolio_refuses(tmp_path, capsys) -> None:
|
||||||
|
"""(a) ``--goals`` without ``--portfolio`` → rc 1 refusal (the flag belongs to portfolio mode).
|
||||||
|
``--live-dry-run`` keeps the RED (pre-refusal) fall-through offline."""
|
||||||
|
goals = tmp_path / "goals.json"
|
||||||
|
goals.write_text('{"portfolio": {"absolute_ore": 1, "mode": "hard"}}', encoding="utf-8")
|
||||||
|
rc = run.main(
|
||||||
|
[
|
||||||
|
_PID,
|
||||||
|
"--docs-dir",
|
||||||
|
str(BUNDLE_DIR),
|
||||||
|
"--bundle-dir",
|
||||||
|
str(BUNDLE_DIR),
|
||||||
|
"--goals",
|
||||||
|
str(goals),
|
||||||
|
"--live-dry-run",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert rc == 1
|
||||||
|
assert "refused" in capsys.readouterr().err.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_ledger_without_portfolio_refuses(tmp_path, capsys) -> None:
|
||||||
|
"""(a') ``--ledger`` without ``--portfolio`` → rc 1 refusal."""
|
||||||
|
ledger = tmp_path / "ledger.json"
|
||||||
|
ledger.write_text("[]", encoding="utf-8")
|
||||||
|
rc = run.main(
|
||||||
|
[
|
||||||
|
_PID,
|
||||||
|
"--docs-dir",
|
||||||
|
str(BUNDLE_DIR),
|
||||||
|
"--bundle-dir",
|
||||||
|
str(BUNDLE_DIR),
|
||||||
|
"--ledger",
|
||||||
|
str(ledger),
|
||||||
|
"--live-dry-run",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert rc == 1
|
||||||
|
assert "refused" in capsys.readouterr().err.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_portfolio_with_single_project_flag_refuses(tmp_path, capsys) -> None:
|
||||||
|
"""(c) ``--portfolio`` combined with a single-project-only flag (``--outbox-dir``) → rc 1 refusal
|
||||||
|
naming the offending flag. The met portfolio goal keeps the RED fall-through offline."""
|
||||||
|
goals, ledger = _met_portfolio_goal(tmp_path)
|
||||||
|
rc = run.main(
|
||||||
|
[
|
||||||
|
"--portfolio",
|
||||||
|
"--goals",
|
||||||
|
str(goals),
|
||||||
|
"--ledger",
|
||||||
|
str(ledger),
|
||||||
|
"--outbox-dir",
|
||||||
|
str(tmp_path / "ob"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert rc == 1
|
||||||
|
err = capsys.readouterr().err.lower()
|
||||||
|
assert "refused" in err
|
||||||
|
assert "--outbox-dir" in err
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_project_mode_without_docs_dir_refuses(capsys) -> None:
|
||||||
|
"""(b) single-project mode with a pid but no ``--docs-dir`` → rc 1 refusal (the Step-3
|
||||||
|
compensating guard for the relaxed argparse ``required=``)."""
|
||||||
|
rc = run.main([_PID])
|
||||||
|
assert rc == 1
|
||||||
|
assert "refused" in capsys.readouterr().err.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_project_mode_without_pid_refuses(capsys) -> None:
|
||||||
|
"""(b') single-project mode with ``--docs-dir`` but no PROJECT_ID → rc 1 refusal."""
|
||||||
|
rc = run.main(["--docs-dir", str(BUNDLE_DIR)])
|
||||||
|
assert rc == 1
|
||||||
|
assert "refused" in capsys.readouterr().err.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_single_project_invocation_still_succeeds(capsys) -> None:
|
||||||
|
"""Backward-compat pin: the legacy invocation (positional pid + ``--docs-dir`` + ``--bundle-dir``
|
||||||
|
+ ``--live-dry-run``) still returns rc 0 — the existing CLI contract survives Step 3's
|
||||||
|
``nargs='?'``/``required`` relaxation."""
|
||||||
|
rc = run.main(
|
||||||
|
[_PID, "--docs-dir", str(BUNDLE_DIR), "--bundle-dir", str(BUNDLE_DIR), "--live-dry-run"]
|
||||||
|
)
|
||||||
|
assert rc == 0
|
||||||
|
assert "LIVE-DRY-RUN OK" in capsys.readouterr().out
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue