diff --git a/src/app/portfolio_truth.py b/src/app/portfolio_truth.py index ab3414f..68f4125 100644 --- a/src/app/portfolio_truth.py +++ b/src/app/portfolio_truth.py @@ -6,6 +6,7 @@ from src.cache import ResponseCache from src.cli_output import print_info +from src.github_security_coverage import DEFAULT_EXPECTED_GITHUB_COHORT_COUNT from src.portfolio_context_recovery import ( apply_context_recovery_plan, build_context_recovery_plan, @@ -62,6 +63,11 @@ def run_portfolio_truth_mode(args: Any) -> None: max_age_hours=getattr( args, "portfolio_truth_security_max_age_hours", 24 ), + expected_cohort_count=getattr( + args, + "portfolio_truth_security_cohort_count", + DEFAULT_EXPECTED_GITHUB_COHORT_COUNT, + ), ) if loaded_security is not None: security_alerts_by_name = loaded_security.entries_by_full_name diff --git a/src/cli.py b/src/cli.py index 20e944a..44091c9 100644 --- a/src/cli.py +++ b/src/cli.py @@ -56,6 +56,7 @@ ) from src.app.improvement_application import _run_apply_improvements_mode from src.app.semantic_search import run_semantic_search_mode +from src.github_security_coverage import DEFAULT_EXPECTED_GITHUB_COHORT_COUNT # Emitted at most once per process when legacy flat invocation is used. @@ -459,6 +460,16 @@ def _build_report_subparser(subparsers: argparse._SubParsersAction) -> None: # metavar="HOURS", help="Freshness window for GitHub security observations (default: 24)", ) + p.add_argument( + "--portfolio-truth-security-cohort-count", + type=int, + default=DEFAULT_EXPECTED_GITHUB_COHORT_COUNT, + metavar="COUNT", + help=( + "Expected security cohort size; must match the receipt collection " + f"contract (default: {DEFAULT_EXPECTED_GITHUB_COHORT_COUNT})" + ), + ) p.add_argument( "--portfolio-truth-allow-empty-notion", action="store_true", @@ -723,6 +734,16 @@ def build_parser() -> argparse.ArgumentParser: metavar="HOURS", help="Freshness window for GitHub security observations (default: 24)", ) + parser.add_argument( + "--portfolio-truth-security-cohort-count", + type=int, + default=DEFAULT_EXPECTED_GITHUB_COHORT_COUNT, + metavar="COUNT", + help=( + "Expected security cohort size; must match the receipt collection " + f"contract (default: {DEFAULT_EXPECTED_GITHUB_COHORT_COUNT})" + ), + ) parser.add_argument( "--portfolio-truth-allow-empty-notion", action="store_true", diff --git a/src/portfolio_truth_status.py b/src/portfolio_truth_status.py index 09359cb..8e18aef 100644 --- a/src/portfolio_truth_status.py +++ b/src/portfolio_truth_status.py @@ -10,6 +10,7 @@ from src.cache import ResponseCache from src.github_client import GitHubClient from src.github_security_coverage import ( + DEFAULT_EXPECTED_GITHUB_COHORT_COUNT, GITHUB_SECURITY_RECEIPT_FILENAME, LoadedSecurityCoverage, SecurityCoverageError, @@ -159,6 +160,7 @@ def load_security_coverage_by_full_name( output_dir: Path, receipt_path: Path | None = None, max_age_hours: int = 24, + expected_cohort_count: int = DEFAULT_EXPECTED_GITHUB_COHORT_COUNT, now: datetime | None = None, ) -> LoadedSecurityCoverage | None: """Load the canonical provenance-bearing security receipt. @@ -173,6 +175,7 @@ def load_security_coverage_by_full_name( return load_security_coverage_receipt( selected, max_age_hours=max_age_hours, + expected_cohort_count=expected_cohort_count, now=now, ) except SecurityCoverageError as exc: diff --git a/tests/test_cli_subcommands.py b/tests/test_cli_subcommands.py index faee5f8..b32facf 100644 --- a/tests/test_cli_subcommands.py +++ b/tests/test_cli_subcommands.py @@ -305,8 +305,8 @@ def test_triage_help_flag_count(self): def test_report_help_flag_count(self): text = _help_text("report") count = _count_flags_in_help(text) - assert count <= 44, ( - f"audit report --help shows {count} non-global flags (limit 44, including the explicit security receipt path, freshness, and opt-in controls).\n" + assert count <= 45, ( + f"audit report --help shows {count} non-global flags (limit 45, including the explicit security receipt path, freshness, cohort-count, and opt-in controls).\n" f"Flags found: {sorted(set(re.findall(r' (--[a-z][a-z0-9-]*)', text)))}" ) diff --git a/tests/test_github_security_coverage.py b/tests/test_github_security_coverage.py index 1c02761..f413e55 100644 --- a/tests/test_github_security_coverage.py +++ b/tests/test_github_security_coverage.py @@ -529,6 +529,24 @@ def test_receipt_loader_uses_embedded_provenance_not_newer_mtime( ) +def test_receipt_loader_honors_explicit_nondefault_cohort_count( + tmp_path: Path, +) -> None: + receipt = _collect(cohort_count=3) + canonical = tmp_path / GITHUB_SECURITY_RECEIPT_FILENAME + canonical.write_text(json.dumps(receipt)) + + assert load_security_coverage_by_full_name(output_dir=tmp_path, now=NOW) is None + loaded = load_security_coverage_by_full_name( + output_dir=tmp_path, + expected_cohort_count=3, + now=NOW, + ) + + assert loaded is not None + assert len(loaded.cohort_repositories) == 3 + + def test_receipt_provenance_and_provider_timestamps_fail_closed(tmp_path: Path) -> None: receipt = _collect() receipt["producer"]["commit"] = "short" diff --git a/tests/test_portfolio_truth.py b/tests/test_portfolio_truth.py index ce0d567..ae38c57 100644 --- a/tests/test_portfolio_truth.py +++ b/tests/test_portfolio_truth.py @@ -2413,6 +2413,87 @@ def test_report_subcommand_parses_allow_empty_notion_flag() -> None: assert default.portfolio_truth_allow_empty_notion is False +def test_report_subcommand_parses_security_cohort_count() -> None: + from src.cli import build_subcommand_parser + from src.github_security_coverage import DEFAULT_EXPECTED_GITHUB_COHORT_COUNT + + parser = build_subcommand_parser() + explicit = parser.parse_args( + [ + "report", + "testuser", + "--portfolio-truth", + "--portfolio-truth-security-cohort-count", + "3", + ] + ) + default = parser.parse_args(["report", "testuser", "--portfolio-truth"]) + + assert explicit.portfolio_truth_security_cohort_count == 3 + assert ( + default.portfolio_truth_security_cohort_count + == DEFAULT_EXPECTED_GITHUB_COHORT_COUNT + ) + + +def test_portfolio_truth_app_threads_security_cohort_count( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from types import SimpleNamespace + + from src.app.portfolio_truth import run_portfolio_truth_mode + + captured: dict[str, object] = {} + + def fake_security_loader(**kwargs): + captured.update(kwargs) + return None + + def fake_publish(**_kwargs): + return SimpleNamespace( + latest_path=tmp_path / "latest.json", + snapshot_path=tmp_path / "history.json", + registry_output=tmp_path / "registry.md", + portfolio_report_output=tmp_path / "report.md", + project_count=0, + registry_changed=False, + report_changed=False, + ) + + monkeypatch.setattr( + "src.app.portfolio_truth.load_security_coverage_by_full_name", + fake_security_loader, + ) + monkeypatch.setattr("src.app.portfolio_truth.publish_portfolio_truth", fake_publish) + monkeypatch.setattr( + "src.app.portfolio_truth.load_live_repo_status_by_name", lambda **_kwargs: {} + ) + monkeypatch.setenv("GHRA_REQUIRE_PRODUCER_EVIDENCE", "0") + args = SimpleNamespace( + output_dir=str(tmp_path / "output"), + workspace_root=str(tmp_path), + registry_output=str(tmp_path / "registry.md"), + portfolio_report_output=str(tmp_path / "report.md"), + registry=None, + catalog=None, + username="testuser", + token=None, + no_cache=True, + portfolio_truth_include_release_count=False, + portfolio_truth_include_security=True, + portfolio_truth_security_receipt=None, + portfolio_truth_security_max_age_hours=12, + portfolio_truth_security_cohort_count=3, + portfolio_truth_allow_empty_notion=False, + ) + + run_portfolio_truth_mode(args) + + assert captured["expected_cohort_count"] == 3 + assert captured["max_age_hours"] == 12 + + def test_portfolio_truth_app_passes_validated_producer_receipt_to_publisher( tmp_path: Path, monkeypatch: pytest.MonkeyPatch,