diff --git a/config/portfolio-catalog.yaml b/config/portfolio-catalog.yaml index d4cd956..8c45d06 100644 --- a/config/portfolio-catalog.yaml +++ b/config/portfolio-catalog.yaml @@ -1045,6 +1045,7 @@ repos: lifecycle_state: archived review_cadence: quarterly intended_disposition: archive + maturity_program: archive category: commercial tool_provenance: gpt Nexus: diff --git a/src/cli.py b/src/cli.py index 4b046fd..6fe360c 100644 --- a/src/cli.py +++ b/src/cli.py @@ -2182,6 +2182,7 @@ def _write_control_center_artifacts( write_weekly_command_center_artifacts, ) + _filter_control_center_snapshot_for_default_view(snapshot) json_path, md_path = _latest_control_center_paths(output_dir, username, generated_at) snapshot.setdefault("operator_summary", {})["control_center_reference"] = str(json_path) portfolio_truth_path, portfolio_truth = load_latest_portfolio_truth(output_dir) @@ -4758,6 +4759,10 @@ def _should_print_control_center_item(item: dict) -> bool: intended = str(catalog.get("intended_disposition") or "").strip().lower() program = str(catalog.get("maturity_program") or "").strip().lower() operating_path = str(item.get("operating_path") or catalog.get("operating_path") or "").strip().lower() + if lifecycle in {"archived", "archive"}: + return False + if intended == "archive" or program == "archive" or operating_path == "archive": + return False if lifecycle in {"experiment", "experimental"}: return False if intended == "experiment" or program == "experiment" or operating_path == "experiment": @@ -4765,6 +4770,18 @@ def _should_print_control_center_item(item: dict) -> bool: return True +def _filter_control_center_snapshot_for_default_view(snapshot: dict) -> dict: + queue = snapshot.get("operator_queue") + if not isinstance(queue, list): + return snapshot + snapshot["operator_queue"] = [ + item + for item in queue + if isinstance(item, dict) and _should_print_control_center_item(item) + ] + return snapshot + + def _fetch_repo_metadata(args, client: GitHubClient) -> tuple[list[RepoMetadata], list[dict]]: if args.graphql and args.token: from src.graphql_client import bulk_fetch_repos diff --git a/src/portfolio_truth_reconcile.py b/src/portfolio_truth_reconcile.py index 3745e12..dc13b2f 100644 --- a/src/portfolio_truth_reconcile.py +++ b/src/portfolio_truth_reconcile.py @@ -2,6 +2,7 @@ import json import logging +import re from collections import Counter from dataclasses import dataclass from datetime import datetime, timezone @@ -787,7 +788,7 @@ def _select_with_legacy( return value legacy_value = str(legacy.get(field, "") or "").strip() if field == "notes": - legacy_value = _strip_generated_note_purpose_prefix( + legacy_value = _strip_generated_registry_note_decorations( legacy_value, repo_entry=repo_entry, group_entry=group_entry ) if legacy_value: @@ -799,7 +800,19 @@ def _select_with_legacy( return "" -def _strip_generated_note_purpose_prefix( +_GENERATED_SECURITY_NOTE_RE = re.compile(r"^(?:\[security: [^\]]+\]\s*)+", re.IGNORECASE) +_GENERATED_PATH_NOTE_PREFIXES = ( + "Stable path is ", + "No stable operating path is declared yet.", +) +_GENERATED_PATH_NOTE_MARKERS = ( + "Declared maturity program and intended disposition point at different paths.", + "Context quality is still too weak for path guidance to stand on its own.", + "Treat this repo as investigate until path confidence improves.", +) + + +def _strip_generated_registry_note_decorations( notes: str, *, repo_entry: dict[str, Any], @@ -807,11 +820,17 @@ def _strip_generated_note_purpose_prefix( ) -> str: """Keep generated registry markdown idempotent when used as legacy input.""" value = notes.strip() + value = _GENERATED_SECURITY_NOTE_RE.sub("", value).strip() + purpose = str(repo_entry.get("purpose") or group_entry.get("purpose") or "").strip() - if not purpose: - return value - while value == purpose or value.startswith(f"{purpose} "): - value = value[len(purpose) :].strip() + if purpose: + while value == purpose or value.startswith(f"{purpose} "): + value = value[len(purpose) :].strip() + + if value.startswith(_GENERATED_PATH_NOTE_PREFIXES) or any( + marker in value for marker in _GENERATED_PATH_NOTE_MARKERS + ): + return "" return value diff --git a/tests/test_cli_hardening.py b/tests/test_cli_hardening.py index cfa60e1..5ece5ec 100644 --- a/tests/test_cli_hardening.py +++ b/tests/test_cli_hardening.py @@ -591,6 +591,46 @@ def test_control_center_default_print_hides_experiment_items() -> None: ) +def test_control_center_default_view_hides_archive_items() -> None: + assert not cli._should_print_control_center_item( + { + "repo": "archive-repo", + "operating_path": "archive", + "portfolio_catalog": { + "lifecycle_state": "archived", + "intended_disposition": "archive", + "maturity_program": "archive", + }, + } + ) + + +def test_control_center_artifact_filter_drops_archive_queue_items() -> None: + snapshot = { + "operator_summary": {}, + "operator_queue": [ + { + "repo": "active-repo", + "operating_path": "maintain", + "portfolio_catalog": {"lifecycle_state": "active"}, + }, + { + "repo": "archive-repo", + "operating_path": "archive", + "portfolio_catalog": { + "lifecycle_state": "archived", + "intended_disposition": "archive", + "maturity_program": "archive", + }, + }, + ], + } + + cli._filter_control_center_snapshot_for_default_view(snapshot) + + assert [item["repo"] for item in snapshot["operator_queue"]] == ["active-repo"] + + def test_main_control_center_requires_latest_report(monkeypatch): args = _make_args(control_center=True) diff --git a/tests/test_portfolio_truth.py b/tests/test_portfolio_truth.py index 88b1043..f761f44 100644 --- a/tests/test_portfolio_truth.py +++ b/tests/test_portfolio_truth.py @@ -1281,6 +1281,59 @@ def test_generated_registry_notes_do_not_accumulate_purpose_prefix( assert alpha.declared.notes == "handoff note" +def test_generated_registry_notes_drop_security_and_path_boilerplate( + portfolio_workspace: Path, + tmp_path: Path, +) -> None: + archived = portfolio_workspace / "FreeLanceInvoice" + archived.mkdir() + _write(archived / "README.md", "# FreeLanceInvoice\n\nArchived invoice project.\n") + + catalog_path = tmp_path / "portfolio-catalog.yaml" + catalog_path.write_text( + """ +repos: + FreeLanceInvoice: + owner: d + lifecycle_state: archived + review_cadence: quarterly + intended_disposition: archive + maturity_program: archive + category: commercial + tool_provenance: gpt +""" + ) + legacy_registry_path = tmp_path / "project-registry.md" + legacy_registry_path.write_text( + """ +# Project Registry + +## Standalone Projects (Root Level) + +| Project | Status | Tool | Context Quality | Stack | Context Files | Category | Notes | +|---------|--------|------|-----------------|-------|---------------|----------|-------| +| FreeLanceInvoice | archived | gpt | weak | Python | README.md | commercial | [security: 1 critical / 2 high open Dependabot alerts] [security: 1 critical / 2 high open Dependabot alerts] Stable path is Archive from intended disposition. Declared maturity program and intended disposition point at different paths. Context quality is still too weak for path guidance to stand on its own. Treat this repo as investigate until path confidence improves. | +""" + ) + + result = build_portfolio_truth_snapshot( + workspace_root=portfolio_workspace, + catalog_path=catalog_path, + legacy_registry_path=legacy_registry_path, + include_notion=False, + ) + + invoice = next( + project + for project in result.snapshot.projects + if project.identity.display_name == "FreeLanceInvoice" + ) + assert invoice.declared.maturity_program == "archive" + assert invoice.declared.notes == "" + assert invoice.provenance["declared.maturity_program"]["source"] == "catalog_repo" + assert invoice.provenance["declared.notes"]["source"] != "legacy_registry" + + def test_publish_failure_leaves_live_files_untouched( portfolio_workspace: Path, portfolio_catalog: Path,