From 64c1053b621631d13d916217922aebcfc1a993d8 Mon Sep 17 00:00:00 2001 From: saagpatel Date: Mon, 3 Aug 2026 15:02:04 -0700 Subject: [PATCH 01/17] feat(portfolio): expose checkout authority collisions --- src/app/portfolio_truth.py | 6 + src/automation_workflow.py | 7 + src/portfolio_automation.py | 5 + src/portfolio_checkout_authority.py | 85 +++++ src/portfolio_context_recovery.py | 27 +- src/portfolio_truth_publish.py | 9 + src/portfolio_truth_reconcile.py | 21 ++ src/portfolio_truth_render.py | 80 ++++- src/portfolio_truth_sources.py | 456 +++++++++++++++++++++++++- src/portfolio_truth_types.py | 7 +- src/portfolio_truth_validate.py | 285 ++++++++++++++++ src/run_instructions_audit.py | 23 +- tests/test_automation_workflow.py | 50 ++- tests/test_operator_os_seam_linter.py | 2 +- tests/test_portfolio_automation.py | 61 +++- tests/test_portfolio_truth.py | 167 +++++++++- tests/test_portfolio_truth_sources.py | 232 ++++++++++++- tests/test_run_instructions_audit.py | 55 ++++ 18 files changed, 1540 insertions(+), 38 deletions(-) create mode 100644 src/portfolio_checkout_authority.py diff --git a/src/app/portfolio_truth.py b/src/app/portfolio_truth.py index 2c46298b..7011e84c 100644 --- a/src/app/portfolio_truth.py +++ b/src/app/portfolio_truth.py @@ -155,6 +155,12 @@ def run_portfolio_truth_mode(args: Any) -> None: f"(registry {'updated' if result.registry_changed else 'unchanged'}, " f"report {'updated' if result.report_changed else 'unchanged'})" ) + print_info( + "Checkout authority: " + f"{getattr(result, 'checkout_collision_group_count', 0)} same-origin groups, " + f"{getattr(result, 'checkout_authority_unknown_count', 0)} UNKNOWN, " + f"{getattr(result, 'discarded_checkout_count', 0)} discarded checkouts" + ) def run_portfolio_context_recovery_mode(args: Any) -> None: diff --git a/src/automation_workflow.py b/src/automation_workflow.py index 93dd7b91..609f8ec9 100644 --- a/src/automation_workflow.py +++ b/src/automation_workflow.py @@ -48,6 +48,7 @@ _suggested_catalog_seed, write_managed_context_block, ) +from src.portfolio_checkout_authority import checkout_authority_blocker from src.portfolio_truth_types import PortfolioTruthProject, PortfolioTruthSnapshot CONTRACT_VERSION = "automation_workflow_v1" @@ -87,6 +88,12 @@ def build_context_pr_plan( precedence order: explicit ``default_branch`` arg, the repo's detected default branch, then the portfolio-wide fallback. """ + authority_reason = checkout_authority_blocker( + project, + workspace_root=workspace_root, + ) + if authority_reason: + raise AutomationExecutionError(authority_reason) repo_path = workspace_root / project.identity.path resolved_branch = default_branch or project.identity.default_branch or DEFAULT_DEFAULT_BRANCH display = project.identity.display_name diff --git a/src/portfolio_automation.py b/src/portfolio_automation.py index 75cb968f..9c82f09d 100644 --- a/src/portfolio_automation.py +++ b/src/portfolio_automation.py @@ -19,6 +19,8 @@ from dataclasses import dataclass from typing import Any +from src.portfolio_checkout_authority import checkout_authority_blocker + CONTRACT_VERSION = "automation_candidates_v1" # Trust-bar thresholds. Kept as module constants so later phases (proposal @@ -86,6 +88,9 @@ def evaluate_automation_eligibility( blockers.append("path-confidence-not-high") if _text(derived.get("context_quality")) not in ELIGIBLE_CONTEXT_QUALITY: blockers.append("context-quality-too-weak") + authority_reason = checkout_authority_blocker(project) + if authority_reason: + blockers.append(authority_reason) return AutomationEligibility(eligible=not blockers, blockers=tuple(blockers)) diff --git a/src/portfolio_checkout_authority.py b/src/portfolio_checkout_authority.py new file mode 100644 index 00000000..b51dd220 --- /dev/null +++ b/src/portfolio_checkout_authority.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +from src.portfolio_truth_types import CHECKOUT_COLLISION_SCHEMA_VERSION + + +def checkout_authority_blocker( + project: Any, + *, + workspace_root: Path | None = None, +) -> str | None: + """Return a stable automation blocker for unresolved checkout authority. + + Projects without collision evidence are single-checkout/legacy inputs and keep + their existing behavior. Once collision evidence is present, malformed, + UNKNOWN, or path-mismatched selection fails closed. + """ + identity = _project_section(project, "identity") + repository_state = _project_section(project, "repository_state") + authority = repository_state.get("checkout_authority") + if authority is None: + return None + if not isinstance(authority, Mapping): + return "checkout-authority-malformed" + if authority.get("schema_version") != CHECKOUT_COLLISION_SCHEMA_VERSION: + return "checkout-authority-malformed" + selection = authority.get("selection") + if not isinstance(selection, Mapping): + return "checkout-authority-malformed" + + state = str(selection.get("state") or "unknown") + reason_code = str(selection.get("reason_code") or "unspecified") + if state != "selected": + return f"checkout-authority-unknown:{reason_code}" + + project_path = str(identity.get("path") or "") + selected_path = str(selection.get("selected_path") or "") + representative_path = str(selection.get("representative_path") or "") + if ( + not project_path + or selected_path != project_path + or representative_path != project_path + ): + return "checkout-authority-path-mismatch" + + checkouts = authority.get("checkouts") + if not isinstance(checkouts, list): + return "checkout-authority-malformed" + selected_checkouts = [ + checkout + for checkout in checkouts + if isinstance(checkout, Mapping) and checkout.get("path") == selected_path + ] + if len(selected_checkouts) != 1: + return "checkout-authority-malformed" + selected_checkout = selected_checkouts[0] + if ( + selected_checkout.get("state") != "observed" + or selected_checkout.get("relation") != "representative" + ): + return "checkout-authority-malformed" + + if workspace_root is not None: + try: + resolved_root = workspace_root.resolve() + resolved_target = (workspace_root / project_path).resolve() + resolved_target.relative_to(resolved_root) + except (OSError, ValueError): + return "checkout-authority-path-escape" + return None + + +def _project_section(project: Any, name: str) -> Mapping[str, Any]: + if isinstance(project, Mapping): + value = project.get(name) + else: + value = getattr(project, name, None) + if isinstance(value, Mapping): + return value + if value is not None and hasattr(value, "__dict__"): + return vars(value) + return {} diff --git a/src/portfolio_context_recovery.py b/src/portfolio_context_recovery.py index ee4b3d4b..32b99550 100644 --- a/src/portfolio_context_recovery.py +++ b/src/portfolio_context_recovery.py @@ -16,6 +16,7 @@ temporary_project_reason, upsert_managed_context_block, ) +from src.portfolio_checkout_authority import checkout_authority_blocker from src.portfolio_truth_types import ( PortfolioTruthProject, PortfolioTruthSnapshot, @@ -94,13 +95,21 @@ def build_context_recovery_plan( if reason: status = "excluded" else: + authority_reason = checkout_authority_blocker( + project, + workspace_root=workspace_root, + ) + if authority_reason: + status = "skipped" + reason = authority_reason if not allow_dirty: - dirty_reason = _dirty_worktree_reason( - project_path, project.identity.has_git - ) - if dirty_reason: - status = "skipped" - reason = dirty_reason + if status == "eligible": + dirty_reason = _dirty_worktree_reason( + project_path, project.identity.has_git + ) + if dirty_reason: + status = "skipped" + reason = dirty_reason if status == "eligible": ambiguous_reason = _ambiguous_primary_context_reason(project_path) if ambiguous_reason: @@ -195,6 +204,12 @@ def apply_context_recovery_plan( project = project_index[target.project_key] project_path = workspace_root / project.identity.path try: + authority_reason = checkout_authority_blocker( + project, + workspace_root=workspace_root, + ) + if authority_reason: + raise RuntimeError(authority_reason) write_managed_context_block(project, project_path) updated.append(target.project_key) if target.suggested_catalog_seed: diff --git a/src/portfolio_truth_publish.py b/src/portfolio_truth_publish.py index 9b7f66c7..13641c3a 100644 --- a/src/portfolio_truth_publish.py +++ b/src/portfolio_truth_publish.py @@ -38,6 +38,9 @@ class PortfolioTruthPublishResult: registry_changed: bool report_changed: bool project_registry_path: Path | None = None + checkout_collision_group_count: int = 0 + checkout_authority_unknown_count: int = 0 + discarded_checkout_count: int = 0 class PortfolioTruthPublishError(RuntimeError): @@ -228,6 +231,7 @@ def publish_portfolio_truth( for staged in temp_files.values(): staged.unlink(missing_ok=True) + collision_summary = build_result.snapshot.source_summary["checkout_collisions"] return PortfolioTruthPublishResult( snapshot_path=snapshot_path, latest_path=latest_path, @@ -237,6 +241,11 @@ def publish_portfolio_truth( registry_changed=changed[registry_output], report_changed=changed[portfolio_report_output], project_registry_path=project_registry_path, + checkout_collision_group_count=int(collision_summary["group_count"]), + checkout_authority_unknown_count=int( + collision_summary["ambiguous_group_count"] + ), + discarded_checkout_count=int(collision_summary["discarded_checkout_count"]), ) diff --git a/src/portfolio_truth_reconcile.py b/src/portfolio_truth_reconcile.py index 45e2b3de..100b414e 100644 --- a/src/portfolio_truth_reconcile.py +++ b/src/portfolio_truth_reconcile.py @@ -21,6 +21,7 @@ from src.portfolio_repository_state import observe_repository_state from src.portfolio_truth_sources import ( WORKSPACE_DISCOVERY_POLICY_VERSION, + checkout_collision_summary, discover_workspace_projects, load_legacy_registry_rows, load_safe_notion_project_context, @@ -263,11 +264,13 @@ def build_portfolio_truth_snapshot( ) exclusion_counts: dict[str, int] = {} + checkout_collisions: list[dict[str, Any]] = [] workspace_projects = discover_workspace_projects( workspace_root, catalog_data=catalog_data, now=now, exclusion_counts=exclusion_counts, + checkout_collisions=checkout_collisions, ) workspace_projects = _merge_supplementary_discoveries( discovered=workspace_projects, @@ -296,9 +299,11 @@ def build_portfolio_truth_snapshot( ) ) + collision_summary = checkout_collision_summary(checkout_collisions) source_summary = { "workspace_root": workspace_root.as_posix(), "project_count": len(projects), + "checkout_collisions": collision_summary, "catalog_errors": list(catalog_data.get("errors") or []), "catalog_warnings": list(catalog_data.get("warnings") or []), "legacy_registry_rows": len(legacy_rows), @@ -333,6 +338,16 @@ def build_portfolio_truth_snapshot( "Duplicate project display names require path-qualified registry labels: " + ", ".join(duplicate_display_names) ) + ambiguous_checkout_origins = [ + str(group["origin"]) + for group in collision_summary["groups"] + if group["selection"]["state"] == "unknown" + ] + if ambiguous_checkout_origins: + warnings.append( + "Checkout authority is UNKNOWN for same-origin full-clone groups: " + + ", ".join(ambiguous_checkout_origins) + ) snapshot = PortfolioTruthSnapshot( schema_version=SCHEMA_VERSION, @@ -1247,6 +1262,12 @@ def _build_truth_project( }, } ) + checkout_authority = raw_project.get("checkout_authority") + if isinstance(checkout_authority, dict): + repository_state = { + **repository_state, + "checkout_authority": checkout_authority, + } return PortfolioTruthProject( identity=identity, declared=declared, diff --git a/src/portfolio_truth_render.py b/src/portfolio_truth_render.py index 06bb5b15..eb746359 100644 --- a/src/portfolio_truth_render.py +++ b/src/portfolio_truth_render.py @@ -2,6 +2,7 @@ from collections import Counter, defaultdict from datetime import timezone +from typing import Any from src.portfolio_truth_types import ( PortfolioTruthProject, @@ -132,6 +133,7 @@ def render_portfolio_report_markdown( ) risk_tier_counts = Counter(project.risk.risk_tier for project in snapshot.projects) security_overview = _security_overview(snapshot.projects) + checkout_collisions = snapshot.source_summary.get("checkout_collisions", {}) lines = [ GENERATED_MARKDOWN_PROVENANCE_MARKER, "", @@ -145,13 +147,14 @@ def render_portfolio_report_markdown( "## Table of Contents", "", "1. [Portfolio truth summary](#portfolio-truth-summary)", - "2. [Audit Methodology](#audit-methodology)", - "3. [Canonical Portfolio Truth Table](#canonical-portfolio-truth-table)", - "4. [Coverage Summary](#coverage-summary)", - "5. [Breakdown by Portfolio Signals](#breakdown-by-portfolio-signals)", - "6. [Security Posture](#security-posture)", - "7. [Accuracy Findings](#accuracy-findings)", - "8. [Recommended Next Sync Steps](#recommended-next-sync-steps)", + "2. [Checkout Authority](#checkout-authority)", + "3. [Audit Methodology](#audit-methodology)", + "4. [Canonical Portfolio Truth Table](#canonical-portfolio-truth-table)", + "5. [Coverage Summary](#coverage-summary)", + "6. [Breakdown by Portfolio Signals](#breakdown-by-portfolio-signals)", + "7. [Security Posture](#security-posture)", + "8. [Accuracy Findings](#accuracy-findings)", + "9. [Recommended Next Sync Steps](#recommended-next-sync-steps)", "", "---", "", @@ -163,6 +166,8 @@ def render_portfolio_report_markdown( f"- Grouped sections represented: `{len(grouped)}`", f"- Canonical source path: `{latest_json_path}`", "", + *_render_checkout_authority_section(checkout_collisions), + "", "## Audit Methodology", "", "- The truth layer scans the local workspace first, using directory metadata and small allowlisted context files only.", @@ -293,6 +298,67 @@ def render_portfolio_report_markdown( return "\n".join(lines) + "\n" +def _render_checkout_authority_section(summary: dict[str, Any]) -> list[str]: + group_count = summary.get("group_count", 0) + ambiguous_count = summary.get("ambiguous_group_count", 0) + discarded_count = summary.get("discarded_checkout_count", 0) + lines = [ + "## Checkout Authority", + "", + f"- Same-origin checkout groups: `{group_count}`", + f"- Authority state UNKNOWN: `{ambiguous_count}`", + f"- Discarded checkout records retained: `{discarded_count}`", + "- Selection policy: preserve one compatibility representative per origin; " + "independent full-clone conflicts remain UNKNOWN.", + ] + groups = summary.get("groups", []) + if not groups: + lines.append("- No same-origin checkout collisions were observed.") + return lines + + lines.extend( + [ + "", + "| Origin | Authority | Representative | Selected checkout | Discarded | Reason |", + "|--------|-----------|----------------|-------------------|-----------|--------|", + ] + ) + for group in groups: + selection = group["selection"] + selected = selection.get("selected_path") or "UNKNOWN" + lines.append( + f"| `{group['origin']}` | {selection['state']} | " + f"`{selection['representative_path']}` | `{selected}` | " + f"{len(group['discarded_checkouts'])} | " + f"`{selection['reason_code']}` |" + ) + + lines.extend(["", "### Discarded Checkout Evidence", ""]) + for group in groups: + selection = group["selection"] + lines.append(f"- `{group['origin']}` selection: {selection['reason']}") + for checkout in group["discarded_checkouts"]: + head = checkout.get("head") or "UNKNOWN" + branch = checkout.get("branch") or "detached-or-UNKNOWN" + if checkout.get("dirty") is True: + dirty = f"dirty ({checkout['dirty_path_count']} paths)" + elif checkout.get("dirty") is False: + dirty = "clean" + else: + dirty = "dirty state UNKNOWN" + lines.append( + f" - `{checkout['path']}`: `{checkout['relation']}`, " + f"head `{head}`, branch `{branch}`, {dirty}." + ) + if group.get("declared_checkout_paths"): + paths = "`, `".join(group["declared_checkout_paths"]) + lines.append(f" - Declared checkout paths resolve to: `{paths}`.") + if group.get("unresolved_declared_paths"): + paths = "`, `".join(group["unresolved_declared_paths"]) + lines.append(f" - Unresolved declared checkout paths: `{paths}`.") + return lines + + def _group_projects( projects: list[PortfolioTruthProject], ) -> dict[str, list[PortfolioTruthProject]]: diff --git a/src/portfolio_truth_sources.py b/src/portfolio_truth_sources.py index 684424e5..3a082ede 100644 --- a/src/portfolio_truth_sources.py +++ b/src/portfolio_truth_sources.py @@ -13,6 +13,10 @@ from src.notion_registry import load_notion_project_context from src.portfolio_catalog import group_entry_for_path from src.portfolio_context_contract import analyze_project_context +from src.portfolio_truth_types import ( + CHECKOUT_COLLISION_SCHEMA_VERSION, + CHECKOUT_COLLISION_SUMMARY_SCHEMA_VERSION, +) from src.registry_parser import _normalize MAX_CONTEXT_DEPTH = 2 @@ -106,9 +110,15 @@ ARCHIVE_REMOTE_BASENAME_TOKENS = frozenset({"private-archive", "scrubbed-import"}) -WORKSPACE_DISCOVERY_POLICY_VERSION = "workspace_discovery.v2" +WORKSPACE_DISCOVERY_POLICY_VERSION = "workspace_discovery.v3" MAX_NOTION_SNAPSHOT_AGE_HOURS = 30 +_CANONICAL_PATHS_HEADING = re.compile( + r"^(?P#{1,6})\s+canonical\s+paths?\s*$", re.IGNORECASE +) +_MARKDOWN_HEADING = re.compile(r"^(?P#{1,6})\s+\S") +_ABSOLUTE_CODE_PATH = re.compile(r"`(?P/[^`\r\n]+)`") + class NotionProjectContext(dict[str, dict[str, str]]): def __init__( @@ -160,6 +170,7 @@ def discover_workspace_projects( catalog_data: dict[str, Any], now: datetime | None = None, exclusion_counts: dict[str, int] | None = None, + checkout_collisions: list[dict[str, Any]] | None = None, ) -> list[dict[str, Any]]: discovered: list[dict[str, Any]] = [] now = now or datetime.now(timezone.utc) @@ -186,11 +197,16 @@ def discover_workspace_projects( exclusion_counts=exclusion_counts, ) ) - return _dedupe_checkouts_by_origin(discovered) + return _dedupe_checkouts_by_origin( + discovered, + checkout_collisions=checkout_collisions, + ) def _dedupe_checkouts_by_origin( discovered: list[dict[str, Any]], + *, + checkout_collisions: list[dict[str, Any]] | None = None, ) -> list[dict[str, Any]]: """Collapse multiple on-disk checkouts of the same repo to one canonical project. @@ -199,10 +215,11 @@ def _dedupe_checkouts_by_origin( (``repo_full_name``), so without this they each count as a distinct project — inflating the portfolio count and dragging catalog-completeness toward zero. - Keep one canonical checkout per origin, preferring the directory whose name - matches the repo basename, then the shortest name, then alphabetical. Projects - without an origin are local-only and are never collapsed. Result is sorted by - name (case-insensitive), matching the prior discovery ordering. + Keep one compatibility representative per origin without claiming checkout + authority when independent full clones disagree. Every non-representative + checkout remains visible in ``CheckoutCollisionV1`` evidence. Projects without + an origin are local-only and are never collapsed. Result is sorted by name + (case-insensitive), matching the prior discovery ordering. """ by_origin: dict[str, list[dict[str, Any]]] = {} canonical: list[dict[str, Any]] = [] @@ -214,22 +231,281 @@ def _dedupe_checkouts_by_origin( canonical.append(project) for origin_key, group in by_origin.items(): - repo_base = origin_key.rsplit("/", 1)[-1] - canonical.append( - min( - group, - key=lambda p: ( - str(p.get("name", "")).lower() != repo_base, - len(str(p.get("name", ""))), - str(p.get("name", "")).lower(), - ), + representative = _checkout_representative(group, origin_key) + if len(group) > 1: + collision = _checkout_collision_record( + origin=str(representative.get("repo_full_name") or origin_key), + origin_key=origin_key, + group=group, + representative=representative, ) - ) + representative["checkout_authority"] = collision + if checkout_collisions is not None: + checkout_collisions.append(collision) + canonical.append(representative) canonical.sort(key=lambda p: str(p.get("name", "")).lower()) return canonical +def checkout_collision_summary( + collisions: list[dict[str, Any]], +) -> dict[str, Any]: + """Build the stable portfolio-level collision summary without adding projects.""" + groups = sorted(collisions, key=lambda item: str(item["origin"]).lower()) + ambiguous_group_count = sum( + item["selection"]["state"] == "unknown" for item in groups + ) + return { + "schema_version": CHECKOUT_COLLISION_SUMMARY_SCHEMA_VERSION, + "state": "unknown" if ambiguous_group_count else "observed", + "group_count": len(groups), + "full_clone_group_count": sum( + int(item["full_clone_count"] > 1) for item in groups + ), + "ambiguous_group_count": ambiguous_group_count, + "discarded_checkout_count": sum( + len(item["discarded_checkouts"]) for item in groups + ), + "groups": groups, + } + + +def _checkout_representative( + group: list[dict[str, Any]], origin_key: str +) -> dict[str, Any]: + repo_base = origin_key.rsplit("/", 1)[-1] + return min( + group, + key=lambda project: ( + str(project.get("name", "")).lower() != repo_base, + len(Path(str(project.get("path", ""))).parts), + len(str(project.get("path", ""))), + str(project.get("path", "")).lower(), + ), + ) + + +def _checkout_collision_record( + *, + origin: str, + origin_key: str, + group: list[dict[str, Any]], + representative: dict[str, Any], +) -> dict[str, Any]: + clone_groups: dict[str, list[dict[str, Any]]] = {} + for project in group: + clone_groups.setdefault(_checkout_clone_key(project), []).append(project) + + representative_clone = _checkout_clone_key(representative) + declarations, unresolved_declarations = _declared_checkout_evidence(group) + declared_checkout_paths = sorted( + {item["target_checkout_path"] for item in declarations}, key=str.lower + ) + declared_clone_keys = { + _checkout_clone_key( + next( + project + for project in group + if str(project.get("path")) == declared_path + ) + ) + for declared_path in declared_checkout_paths + } + + clone_representatives = [ + _checkout_representative(members, origin_key) + for members in clone_groups.values() + ] + clone_heads = { + str(_checkout_observation(project).get("head") or "") + for project in clone_representatives + } + observations_complete = all( + _checkout_observation(project).get("state") == "observed" + and _checkout_observation(project).get("git_common_dir") + for project in group + ) + conflicting_heads = len(clone_heads) > 1 or "" in clone_heads + + state = "selected" + reason_code = "single_clone_topology" + reason = "all discovered checkouts share one Git common directory" + if len(clone_groups) > 1: + if not observations_complete: + state = "unknown" + reason_code = "checkout_observation_failed" + reason = ( + "one or more same-origin checkouts could not be observed completely" + ) + elif len(declared_clone_keys) > 1: + state = "unknown" + reason_code = "conflicting_declared_checkout_paths" + reason = ( + "canonical path declarations resolve to multiple independent clones" + ) + elif declared_clone_keys and representative_clone not in declared_clone_keys: + state = "unknown" + reason_code = "declared_path_conflicts_with_representative" + reason = ( + "canonical path declarations resolve to a different full clone than " + "the compatibility representative" + ) + elif unresolved_declarations: + state = "unknown" + reason_code = "declared_checkout_path_unresolved" + reason = ( + "one or more canonical path declarations do not resolve to a checkout" + ) + elif conflicting_heads: + state = "unknown" + reason_code = "conflicting_full_clone_heads" + reason = ( + "independent same-origin clones have different or unavailable heads" + ) + elif any( + _checkout_observation(project).get("dirty") is True + for project in clone_representatives + ): + state = "unknown" + reason_code = "full_clone_local_work_present" + reason = "an independent same-origin clone contains local work" + else: + reason_code = "equivalent_full_clones" + reason = ( + "independent same-origin clones have equivalent observed heads; " + "the deterministic compatibility representative is selected" + ) + + checkouts = [ + _published_checkout( + project, + representative=representative, + representative_clone=representative_clone, + ) + for project in sorted(group, key=lambda item: str(item.get("path", "")).lower()) + ] + representative_path = str(representative.get("path") or "") + discarded = [ + checkout for checkout in checkouts if checkout["path"] != representative_path + ] + return { + "schema_version": CHECKOUT_COLLISION_SCHEMA_VERSION, + "origin": origin, + "checkout_count": len(group), + "full_clone_count": len(clone_groups), + "declared_checkout_paths": declared_checkout_paths, + "declared_path_evidence": declarations, + "unresolved_declared_paths": unresolved_declarations, + "selection": { + "state": state, + "reason_code": reason_code, + "reason": reason, + "representative_path": representative_path, + "selected_path": representative_path if state == "selected" else None, + "rationale": ( + "Compatibility representative prefers an origin-basename match, " + "then the shallowest, shortest, alphabetic workspace-relative path." + ), + }, + "checkouts": checkouts, + "discarded_checkouts": discarded, + } + + +def _checkout_clone_key(project: dict[str, Any]) -> str: + observation = _checkout_observation(project) + common_dir = str(observation.get("git_common_dir") or "") + if observation.get("state") == "observed" and common_dir: + return f"observed:{common_dir}" + return f"unknown:{project.get('path', '')}" + + +def _checkout_observation(project: dict[str, Any]) -> dict[str, Any]: + value = project.get("_checkout_observation") + if isinstance(value, dict): + return value + return { + "state": "unknown", + "head": None, + "branch": None, + "dirty": None, + "dirty_path_count": None, + "git_common_dir": None, + "declared_paths": [], + } + + +def _published_checkout( + project: dict[str, Any], + *, + representative: dict[str, Any], + representative_clone: str, +) -> dict[str, Any]: + observation = _checkout_observation(project) + path = str(project.get("path") or "") + representative_path = str(representative.get("path") or "") + if path == representative_path: + relation = "representative" + elif _checkout_clone_key(project) == representative_clone: + relation = "linked_worktree" + else: + relation = "independent_full_clone" + return { + "path": path, + "state": str(observation.get("state") or "unknown"), + "relation": relation, + "head": observation.get("head"), + "branch": observation.get("branch"), + "dirty": observation.get("dirty"), + "dirty_path_count": observation.get("dirty_path_count"), + } + + +def _declared_checkout_evidence( + group: list[dict[str, Any]], +) -> tuple[list[dict[str, str]], list[str]]: + evidence: list[dict[str, str]] = [] + unresolved: set[str] = set() + for source_project in group: + observation = _checkout_observation(source_project) + for declaration in observation.get("declared_paths") or []: + target = Path(str(declaration["absolute_path"])) + candidates = [ + project + for project in group + if _path_is_within(target, Path(str(project["project_path"]))) + ] + if not candidates: + unresolved.add(str(declaration["workspace_relative_path"])) + continue + target_project = max( + candidates, + key=lambda project: len(Path(str(project["project_path"])).parts), + ) + evidence.append( + { + "source_path": ( + f"{source_project['path']}/{declaration['source_file']}" + ), + "target_checkout_path": str(target_project["path"]), + } + ) + unique_evidence = { + (item["source_path"], item["target_checkout_path"]): item for item in evidence + } + return ( + sorted( + unique_evidence.values(), + key=lambda item: ( + item["source_path"].lower(), + item["target_checkout_path"].lower(), + ), + ), + sorted(unresolved, key=str.lower), + ) + + def _discover_nested_projects( root: Path, workspace_root: Path, @@ -450,6 +726,11 @@ def _inspect_project_dir( context_files = _collect_context_files(project_path) stack = _detect_stack(project_path) git_facts = _gather_git_facts(project_path) + checkout_observation = ( + _observe_checkout(project_path, workspace_root=workspace_root) + if git_facts.get("has_git") + else {} + ) last_activity = git_facts.get("last_commit_at") or _latest_meaningful_mtime(project_path) context_analysis = analyze_project_context(project_path, context_files) @@ -478,6 +759,7 @@ def _inspect_project_dir( "inferred_tool_provenance": _infer_tool_provenance( project_path, group_entry, context_files ), + "_checkout_observation": checkout_observation, "now": now, } @@ -621,6 +903,148 @@ def _gather_git_facts(project_path: Path) -> dict[str, Any]: return base +def _observe_checkout(project_path: Path, *, workspace_root: Path) -> dict[str, Any]: + """Observe one discovered checkout without fetching or exposing file names.""" + try: + bare = _git_read(project_path, "rev-parse", "--is-bare-repository") == "true" + common_dir = _git_read( + project_path, + "rev-parse", + "--path-format=absolute", + "--git-common-dir", + ) + head_candidate = _git_read_optional(project_path, "rev-parse", "HEAD") + head = ( + head_candidate + if re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", head_candidate) + else "" + ) + branch = ( + _git_read_optional(project_path, "symbolic-ref", "--short", "HEAD") + if bare + else _git_read_optional(project_path, "branch", "--show-current") + ) + if bare: + dirty: bool | None = None + dirty_path_count: int | None = None + else: + status = _git_read( + project_path, + "status", + "--porcelain", + "--untracked-files=all", + ) + dirty = bool(status) + dirty_path_count = len(status.splitlines()) if status else 0 + except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired): + return { + "state": "unknown", + "head": None, + "branch": None, + "dirty": None, + "dirty_path_count": None, + "git_common_dir": None, + "declared_paths": _declared_canonical_paths( + project_path, workspace_root=workspace_root + ), + } + return { + "state": "observed", + "head": head or None, + "branch": branch or None, + "dirty": dirty, + "dirty_path_count": dirty_path_count, + "git_common_dir": common_dir or None, + "declared_paths": _declared_canonical_paths( + project_path, workspace_root=workspace_root + ), + } + + +def _git_read(project_path: Path, *args: str) -> str: + result = subprocess.run( + ["git", "--no-optional-locks", "-C", str(project_path), *args], + capture_output=True, + text=True, + timeout=5, + check=False, + env={**os.environ, "GIT_OPTIONAL_LOCKS": "0"}, + ) + if result.returncode != 0: + raise subprocess.CalledProcessError( + result.returncode, + result.args, + output=result.stdout, + stderr=result.stderr, + ) + return result.stdout.strip() + + +def _git_read_optional(project_path: Path, *args: str) -> str: + try: + return _git_read(project_path, *args) + except subprocess.CalledProcessError: + return "" + + +def _declared_canonical_paths( + project_path: Path, *, workspace_root: Path +) -> list[dict[str, str]]: + declarations: list[dict[str, str]] = [] + resolved_workspace = workspace_root.resolve() + for source_file in ("AGENTS.md", "CLAUDE.md"): + path = project_path / source_file + try: + if not path.is_file() or path.stat().st_size > MAX_CONTEXT_BYTES: + continue + lines = path.read_text(errors="replace").splitlines() + except OSError: + continue + + section_level: int | None = None + for line in lines: + heading = _MARKDOWN_HEADING.match(line.strip()) + canonical_heading = _CANONICAL_PATHS_HEADING.match(line.strip()) + if canonical_heading: + section_level = len(canonical_heading.group("marks")) + continue + if heading and section_level is not None: + if len(heading.group("marks")) <= section_level: + section_level = None + continue + if section_level is None: + continue + for match in _ABSOLUTE_CODE_PATH.finditer(line): + candidate = Path(match.group("path")).resolve() + if not _path_is_within(candidate, resolved_workspace): + continue + declarations.append( + { + "source_file": source_file, + "absolute_path": str(candidate), + "workspace_relative_path": candidate.relative_to( + resolved_workspace + ).as_posix(), + } + ) + + unique = { + (item["source_file"], item["absolute_path"]): item for item in declarations + } + return sorted( + unique.values(), + key=lambda item: (item["source_file"], item["absolute_path"].lower()), + ) + + +def _path_is_within(candidate: Path, root: Path) -> bool: + try: + candidate.resolve().relative_to(root.resolve()) + return True + except (OSError, ValueError): + return False + + def _is_bare_repository_root(project_path: Path) -> bool: """Recognize a conventional bare repository without climbing into parents.""" try: diff --git a/src/portfolio_truth_types.py b/src/portfolio_truth_types.py index 3a2d7447..544873cb 100644 --- a/src/portfolio_truth_types.py +++ b/src/portfolio_truth_types.py @@ -7,10 +7,15 @@ from typing import Any SCHEMA_VERSION = "0.11.0" +CHECKOUT_COLLISION_SCHEMA_VERSION = "CheckoutCollisionV1" +CHECKOUT_COLLISION_SUMMARY_SCHEMA_VERSION = "CheckoutCollisionSummaryV1" # 0.11.0: provenance-bearing GitHub security receipts preserve per-provider # states and expose complete/partial/stale/unknown coverage denominators. # Additive 0.11.0 fields bind normalized provider reason codes, completed-zero # observations, and live remote default-branch/head evidence to the same receipt. +# Workspace discovery v3 adds versioned CheckoutCollisionV1 evidence under the +# generic source_summary and repository_state extension points; PCC 0.11 readers +# continue to ignore those additive keys. # 0.10.0: canonical producer receipts bind the exact checkout; coverage and # repository/worktree observation envelopes fail closed on unavailable evidence. # 0.8.0: derived.registry_status removed (was a stale->parked synonym table over @@ -460,7 +465,7 @@ class PortfolioTruthSnapshot: coverage: list[dict[str, Any]] = field(default_factory=list) exclusions: dict[str, Any] = field( default_factory=lambda: { - "policy_version": "workspace_discovery.v2", + "policy_version": "workspace_discovery.v3", "counts": {}, } ) diff --git a/src/portfolio_truth_validate.py b/src/portfolio_truth_validate.py index e192f418..60507d80 100644 --- a/src/portfolio_truth_validate.py +++ b/src/portfolio_truth_validate.py @@ -11,6 +11,8 @@ ) from src.portfolio_truth_render import registry_project_labels from src.portfolio_truth_types import ( + CHECKOUT_COLLISION_SCHEMA_VERSION, + CHECKOUT_COLLISION_SUMMARY_SCHEMA_VERSION, DERIVATION_POLICY_VERSION, SCHEMA_VERSION, VALID_ACTIVITY_STATUS, @@ -33,6 +35,7 @@ def validate_truth_snapshot(snapshot: PortfolioTruthSnapshot) -> None: f"{snapshot.derivation_policy_version}" ) _validate_contract_envelope(snapshot) + _validate_checkout_collisions(snapshot) seen_keys: set[str] = set() for project in snapshot.projects: key = project.identity.project_key @@ -98,6 +101,287 @@ def validate_truth_snapshot(snapshot: PortfolioTruthSnapshot) -> None: raise ValueError(f"Invalid doctor standard for {key}: {doctor_std}") +def _validate_checkout_collisions(snapshot: PortfolioTruthSnapshot) -> None: + summary = snapshot.source_summary.get("checkout_collisions") + if not isinstance(summary, dict): + raise ValueError("Portfolio truth checkout collision summary is required.") + required_summary = { + "schema_version", + "state", + "group_count", + "full_clone_group_count", + "ambiguous_group_count", + "discarded_checkout_count", + "groups", + } + missing = sorted(required_summary - summary.keys()) + if missing: + raise ValueError(f"Checkout collision summary is missing fields: {missing}") + if summary.get("schema_version") != CHECKOUT_COLLISION_SUMMARY_SCHEMA_VERSION: + raise ValueError("Unexpected checkout collision summary schema version.") + groups = summary.get("groups") + if not isinstance(groups, list): + raise ValueError("Checkout collision groups must be a list.") + _require_nonnegative_count(summary, "group_count") + _require_nonnegative_count(summary, "full_clone_group_count") + _require_nonnegative_count(summary, "ambiguous_group_count") + _require_nonnegative_count(summary, "discarded_checkout_count") + if summary["group_count"] != len(groups): + raise ValueError("Checkout collision group_count does not match groups.") + + project_by_origin = {} + for project in snapshot.projects: + origin_key = project.identity.repo_full_name.lower() + if not origin_key: + continue + if origin_key in project_by_origin: + raise ValueError( + "Portfolio truth must contain one canonical project per origin: " + f"{project.identity.repo_full_name}" + ) + project_by_origin[origin_key] = project + seen_origins: set[str] = set() + ambiguous = 0 + full_clone_groups = 0 + discarded_count = 0 + for group in groups: + if not isinstance(group, dict): + raise ValueError("Checkout collision group must be an object.") + required_group = { + "schema_version", + "origin", + "checkout_count", + "full_clone_count", + "declared_checkout_paths", + "declared_path_evidence", + "unresolved_declared_paths", + "selection", + "checkouts", + "discarded_checkouts", + } + group_missing = sorted(required_group - group.keys()) + if group_missing: + raise ValueError( + f"Checkout collision group is missing fields: {group_missing}" + ) + if group.get("schema_version") != CHECKOUT_COLLISION_SCHEMA_VERSION: + raise ValueError("Unexpected checkout collision schema version.") + origin = group.get("origin") + if not isinstance(origin, str) or not origin.strip(): + raise ValueError("Checkout collision origin must be non-empty.") + origin_key = origin.lower() + if origin_key in seen_origins: + raise ValueError(f"Duplicate checkout collision origin: {origin}") + seen_origins.add(origin_key) + + checkout_count = _require_nonnegative_count(group, "checkout_count") + full_clone_count = _require_nonnegative_count(group, "full_clone_count") + if checkout_count < 2: + raise ValueError( + "Checkout collision groups require at least two checkouts." + ) + if not 1 <= full_clone_count <= checkout_count: + raise ValueError("Checkout collision full_clone_count is out of range.") + full_clone_groups += int(full_clone_count > 1) + + selection = group.get("selection") + if not isinstance(selection, dict): + raise ValueError("Checkout collision selection must be an object.") + for key in ( + "state", + "reason_code", + "reason", + "representative_path", + "selected_path", + "rationale", + ): + if key not in selection: + raise ValueError(f"Checkout collision selection is missing {key}.") + state = selection.get("state") + if state not in {"selected", "unknown"}: + raise ValueError(f"Invalid checkout authority state: {state}") + representative_path = _require_relative_path( + selection.get("representative_path"), + "checkout representative_path", + ) + selected_path = selection.get("selected_path") + if state == "unknown": + ambiguous += 1 + if selected_path is not None: + raise ValueError("UNKNOWN checkout authority cannot select a path.") + elif selected_path != representative_path: + raise ValueError( + "Selected checkout path must equal the representative path." + ) + for key in ("reason_code", "reason", "rationale"): + if not isinstance(selection.get(key), str) or not selection[key].strip(): + raise ValueError( + f"Checkout collision selection {key} must be non-empty." + ) + + checkouts = group.get("checkouts") + discarded = group.get("discarded_checkouts") + if not isinstance(checkouts, list) or len(checkouts) != checkout_count: + raise ValueError( + "Checkout collision checkouts do not match checkout_count." + ) + if not isinstance(discarded, list): + raise ValueError("Discarded checkouts must be a list.") + checkout_paths: set[str] = set() + representative_count = 0 + for checkout in checkouts: + if not isinstance(checkout, dict): + raise ValueError("Checkout collision checkout must be an object.") + required_checkout = { + "path", + "state", + "relation", + "head", + "branch", + "dirty", + "dirty_path_count", + } + missing_checkout = sorted(required_checkout - checkout.keys()) + if missing_checkout: + raise ValueError( + f"Checkout collision checkout is missing fields: {missing_checkout}" + ) + path = _require_relative_path(checkout.get("path"), "checkout path") + if path in checkout_paths: + raise ValueError(f"Duplicate checkout collision path: {path}") + checkout_paths.add(path) + if checkout.get("state") not in {"observed", "unknown"}: + raise ValueError("Invalid checkout observation state.") + relation = checkout.get("relation") + if relation not in { + "representative", + "linked_worktree", + "independent_full_clone", + }: + raise ValueError("Invalid checkout relation.") + representative_count += int(relation == "representative") + head = checkout.get("head") + if head is not None and not re.fullmatch( + r"[0-9a-f]{40}|[0-9a-f]{64}", str(head) + ): + raise ValueError(f"Malformed checkout head for {path}.") + branch = checkout.get("branch") + if branch is not None and not isinstance(branch, str): + raise ValueError(f"Malformed checkout branch for {path}.") + dirty = checkout.get("dirty") + if dirty is not None and not isinstance(dirty, bool): + raise ValueError(f"Malformed checkout dirty state for {path}.") + dirty_count = checkout.get("dirty_path_count") + if dirty_count is not None and ( + isinstance(dirty_count, bool) + or not isinstance(dirty_count, int) + or dirty_count < 0 + ): + raise ValueError(f"Malformed checkout dirty_path_count for {path}.") + representative_checkout = next( + ( + checkout + for checkout in checkouts + if checkout["path"] == representative_path + ), + None, + ) + if ( + representative_count != 1 + or representative_checkout is None + or representative_checkout["relation"] != "representative" + ): + raise ValueError("Checkout collision requires one observed representative.") + expected_discarded = [ + checkout + for checkout in checkouts + if checkout["path"] != representative_path + ] + if discarded != expected_discarded: + raise ValueError( + "Discarded checkout evidence does not match the checkout set." + ) + discarded_count += len(discarded) + + declared_paths = group.get("declared_checkout_paths") + unresolved_paths = group.get("unresolved_declared_paths") + declared_evidence = group.get("declared_path_evidence") + if not isinstance(declared_paths, list) or not isinstance( + unresolved_paths, list + ): + raise ValueError("Declared checkout paths must be lists.") + for path in declared_paths + unresolved_paths: + _require_relative_path(path, "declared checkout path") + if not isinstance(declared_evidence, list): + raise ValueError("Declared path evidence must be a list.") + for item in declared_evidence: + if not isinstance(item, dict): + raise ValueError("Declared path evidence must be an object.") + _require_relative_path(item.get("source_path"), "declared source path") + target = _require_relative_path( + item.get("target_checkout_path"), "declared target checkout path" + ) + if target not in checkout_paths: + raise ValueError( + "Declared checkout target is not in the collision group." + ) + expected_declared_paths = sorted( + {item["target_checkout_path"] for item in declared_evidence}, + key=str.lower, + ) + if declared_paths != expected_declared_paths: + raise ValueError( + "Declared checkout paths do not match declared path evidence." + ) + + project = project_by_origin.get(origin_key) + if project is None: + raise ValueError(f"Checkout collision has no canonical project: {origin}") + if project.identity.path != representative_path: + raise ValueError( + "Canonical project path differs from collision representative." + ) + if project.repository_state.get("checkout_authority") != group: + raise ValueError( + "Project checkout authority differs from collision summary." + ) + + if summary["full_clone_group_count"] != full_clone_groups: + raise ValueError("Checkout full_clone_group_count does not match groups.") + if summary["ambiguous_group_count"] != ambiguous: + raise ValueError("Checkout ambiguous_group_count does not match groups.") + if summary["discarded_checkout_count"] != discarded_count: + raise ValueError("Checkout discarded_checkout_count does not match groups.") + for project in snapshot.projects: + authority = project.repository_state.get("checkout_authority") + origin_key = project.identity.repo_full_name.lower() + if authority is not None and origin_key not in seen_origins: + raise ValueError( + "Project checkout authority is missing from the collision summary." + ) + expected_state = "unknown" if ambiguous else "observed" + if summary.get("state") != expected_state: + raise ValueError( + "Checkout collision summary state does not match group authority." + ) + + +def _require_nonnegative_count(value: dict, key: str) -> int: + count = value.get(key) + if isinstance(count, bool) or not isinstance(count, int) or count < 0: + raise ValueError(f"{key} must be a non-negative integer.") + return count + + +def _require_relative_path(value: object, label: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{label} must be a non-empty string.") + path = Path(value) + if path.is_absolute() or ".." in path.parts: + raise ValueError(f"{label} must stay workspace-relative.") + return value + + def _validate_contract_envelope(snapshot: PortfolioTruthSnapshot) -> None: producer = snapshot.producer if producer: @@ -225,6 +509,7 @@ def validate_portfolio_report_markdown(markdown: str) -> None: "# Portfolio Audit Report", "canonical machine-readable artifact", "derived from the portfolio truth snapshot", + "## Checkout Authority", "## Audit Methodology", "## Canonical Portfolio Truth Table", "## Coverage Summary", diff --git a/src/run_instructions_audit.py b/src/run_instructions_audit.py index c38ce914..7abbd44b 100644 --- a/src/run_instructions_audit.py +++ b/src/run_instructions_audit.py @@ -14,6 +14,7 @@ from datetime import datetime from pathlib import Path +from src.portfolio_checkout_authority import checkout_authority_blocker from src.portfolio_context_contract import ( analyze_project_context, choose_primary_context_file, @@ -137,8 +138,24 @@ def prepare_pilot( generated_at = snapshot["generated_at"] records: list[dict] = [] errors: list[dict] = [] + authority_blocked = False for project in select_pilot(snapshot["projects"], per_tier=per_tier): record = build_record(project, workspace_root) + authority_reason = checkout_authority_blocker( + project, + workspace_root=Path(workspace_root), + ) + if authority_reason: + authority_blocked = True + errors.append( + { + "project_key": record["project_key"], + "abs_path": record["abs_path"], + "error": "checkout_authority_blocked", + "reason": authority_reason, + } + ) + continue if not Path(record["abs_path"]).is_dir(): errors.append( { @@ -152,6 +169,7 @@ def prepare_pilot( record["drifted"] = compute_drifted(record["abs_path"], generated_at) records.append(record) return { + "state": "blocked" if authority_blocked else "ready", "generated_at": generated_at, "workspace_root": workspace_root, "records": records, @@ -165,7 +183,10 @@ def main() -> None: snapshot_path = ( sys.argv[1] if len(sys.argv) > 1 else "output/portfolio-truth-latest.json" ) - print(json.dumps(prepare_pilot(snapshot_path), indent=2)) + result = prepare_pilot(snapshot_path) + print(json.dumps(result, indent=2)) + if result["state"] == "blocked": + raise SystemExit(2) if __name__ == "__main__": diff --git a/tests/test_automation_workflow.py b/tests/test_automation_workflow.py index 8da8f50d..18e39d5b 100644 --- a/tests/test_automation_workflow.py +++ b/tests/test_automation_workflow.py @@ -15,7 +15,9 @@ from pathlib import Path -from src.automation_executor import CommandResult +import pytest + +from src.automation_executor import AutomationExecutionError, CommandResult from src.automation_proposals import ( ACTION_CATALOG_SEED, ACTION_CONTEXT_PR, @@ -70,6 +72,7 @@ def _project( has_git: bool = True, primary_context_file: str = "AGENTS.md", default_branch: str = "", + repository_state: dict | None = None, ) -> PortfolioTruthProject: return PortfolioTruthProject( identity=IdentityFields( @@ -93,6 +96,7 @@ def _project( archived=False, path_confidence="high", ), + repository_state=repository_state or {}, ) @@ -178,6 +182,50 @@ def test_context_pr_plan_explicit_branch_overrides_repo_default() -> None: assert plan.default_branch == "trunk" +def test_context_pr_plan_blocks_unknown_checkout_authority() -> None: + project = _project( + repository_state={ + "checkout_authority": { + "schema_version": "CheckoutCollisionV1", + "selection": { + "state": "unknown", + "reason_code": "conflicting_full_clone_heads", + "representative_path": "MyRepo", + "selected_path": None, + } + } + } + ) + + with pytest.raises( + AutomationExecutionError, + match="checkout-authority-unknown:conflicting_full_clone_heads", + ): + build_context_pr_plan(project, workspace_root=Path("/ws")) + + +def test_context_pr_plan_blocks_selected_path_mismatch() -> None: + project = _project( + repository_state={ + "checkout_authority": { + "schema_version": "CheckoutCollisionV1", + "selection": { + "state": "selected", + "reason_code": "equivalent_full_clones", + "representative_path": "Archive/MyRepo", + "selected_path": "Archive/MyRepo", + } + } + } + ) + + with pytest.raises( + AutomationExecutionError, + match="checkout-authority-path-mismatch", + ): + build_context_pr_plan(project, workspace_root=Path("/ws")) + + def test_context_pr_plan_apply_change_writes_managed_block(tmp_path: Path) -> None: project = _project(path="MyRepo") repo_path = tmp_path / "MyRepo" diff --git a/tests/test_operator_os_seam_linter.py b/tests/test_operator_os_seam_linter.py index ac65932d..99a955e7 100644 --- a/tests/test_operator_os_seam_linter.py +++ b/tests/test_operator_os_seam_linter.py @@ -326,7 +326,7 @@ def test_contract_shadow_fails_when_excluded_backup_leaks_into_projects( "source_summary": {"attention_state_counts": {"decision-needed": 1}}, "rollups": {"decision": {"decision_needed_count": 1}}, "exclusions": { - "policy_version": "workspace_discovery.v2", + "policy_version": "workspace_discovery.v3", "counts": {}, }, "projects": [ diff --git a/tests/test_portfolio_automation.py b/tests/test_portfolio_automation.py index 2919338a..0ea38cfb 100644 --- a/tests/test_portfolio_automation.py +++ b/tests/test_portfolio_automation.py @@ -27,9 +27,14 @@ def _project( activity_status: str = "active", path_confidence: str = "high", context_quality: str = "standard", + checkout_authority: dict | None = None, ) -> dict: - return { - "identity": {"display_name": display_name, "repo_full_name": repo_full_name}, + project = { + "identity": { + "display_name": display_name, + "repo_full_name": repo_full_name, + "path": display_name, + }, "declared": {"operating_path": "maintain"}, "derived": { "activity_status": activity_status, @@ -37,6 +42,9 @@ def _project( "context_quality": context_quality, }, } + if checkout_authority is not None: + project["repository_state"] = {"checkout_authority": checkout_authority} + return project # --- evaluate_automation_eligibility --------------------------------------- @@ -137,6 +145,55 @@ def test_multiple_blockers_accumulate() -> None: } +def test_unknown_checkout_authority_blocks_automation_candidate() -> None: + result = evaluate_automation_eligibility( + _project( + checkout_authority={ + "schema_version": "CheckoutCollisionV1", + "selection": { + "state": "unknown", + "reason_code": "conflicting_full_clone_heads", + "representative_path": "Repo", + "selected_path": None, + } + } + ), + decision_quality_status="trusted", + ) + + assert result.eligible is False + assert result.blockers == ( + "checkout-authority-unknown:conflicting_full_clone_heads", + ) + + +def test_observed_selected_checkout_authority_remains_eligible() -> None: + result = evaluate_automation_eligibility( + _project( + checkout_authority={ + "schema_version": "CheckoutCollisionV1", + "selection": { + "state": "selected", + "reason_code": "single_clone_topology", + "representative_path": "Repo", + "selected_path": "Repo", + }, + "checkouts": [ + { + "path": "Repo", + "state": "observed", + "relation": "representative", + } + ], + } + ), + decision_quality_status="trusted", + ) + + assert result.eligible is True + assert result.blockers == () + + # --- select_automation_candidates ------------------------------------------ diff --git a/tests/test_portfolio_truth.py b/tests/test_portfolio_truth.py index dce179ef..683fdb40 100644 --- a/tests/test_portfolio_truth.py +++ b/tests/test_portfolio_truth.py @@ -5,6 +5,7 @@ import os import subprocess import time +from dataclasses import replace from datetime import datetime, timedelta, timezone from pathlib import Path @@ -502,13 +503,22 @@ def test_truth_snapshot_respects_declared_and_derived_fields( assert result.snapshot.inputs["catalog"]["sha256"] assert result.snapshot.inputs["notion"]["mode"] == "unavailable" assert result.snapshot.exclusions == { - "policy_version": "workspace_discovery.v2", + "policy_version": "workspace_discovery.v3", "counts": {}, } assert ( result.snapshot.source_summary["attention_state_counts"]["active-product"] == 1 ) assert result.snapshot.source_summary["attention_state_counts"]["parked"] == 1 + assert result.snapshot.source_summary["checkout_collisions"] == { + "schema_version": "CheckoutCollisionSummaryV1", + "state": "observed", + "group_count": 0, + "full_clone_group_count": 0, + "ambiguous_group_count": 0, + "discarded_checkout_count": 0, + "groups": [], + } # Derived rollups are emitted so downstream consumers (command-center) read # them instead of re-deriving the auditor's risk/security logic. @@ -563,6 +573,106 @@ def test_truth_snapshot_respects_declared_and_derived_fields( assert "open_high_critical" in snapshot_dict["projects"][0]["security"] +def test_checkout_collision_flows_through_truth_validation_and_report( + portfolio_workspace: Path, + portfolio_catalog: Path, + legacy_registry: Path, +) -> None: + root_clone = portfolio_workspace / "Widget" + nested_clone = portfolio_workspace / "Archive" / "Widget" + heads: list[str] = [] + for index, clone in enumerate((root_clone, nested_clone), start=1): + clone.mkdir(parents=True) + _write(clone / "README.md", f"# Widget {index}\n") + subprocess.run( + ["git", "init", "-q", "-b", "main"], + cwd=clone, + check=True, + ) + subprocess.run( + ["git", "remote", "add", "origin", "git@github.com:owner/Widget.git"], + cwd=clone, + check=True, + ) + subprocess.run(["git", "add", "README.md"], cwd=clone, check=True) + subprocess.run( + [ + "git", + "-c", + "user.name=Test", + "-c", + "user.email=test@example.invalid", + "commit", + "-q", + "-m", + f"fixture {index}", + ], + cwd=clone, + check=True, + ) + heads.append( + subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=clone, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + ) + + result = build_portfolio_truth_snapshot( + workspace_root=portfolio_workspace, + catalog_path=portfolio_catalog, + legacy_registry_path=legacy_registry, + include_notion=False, + now=datetime(2026, 8, 3, tzinfo=timezone.utc), + ) + + widget_projects = [ + project + for project in result.snapshot.projects + if project.identity.repo_full_name == "owner/Widget" + ] + assert len(widget_projects) == 1 + assert widget_projects[0].identity.path == "Widget" + summary = result.snapshot.source_summary["checkout_collisions"] + assert summary["group_count"] == 1 + assert summary["ambiguous_group_count"] == 1 + assert summary["discarded_checkout_count"] == 1 + group = summary["groups"][0] + assert group["selection"]["state"] == "unknown" + assert group["selection"]["reason_code"] == "conflicting_full_clone_heads" + assert widget_projects[0].repository_state["checkout_authority"] == group + validate_truth_snapshot(result.snapshot) + + markdown = render_portfolio_report_markdown(result.snapshot, "output/x.json") + assert "## Checkout Authority" in markdown + assert "`owner/Widget`" in markdown + assert "`conflicting_full_clone_heads`" in markdown + assert "`Archive/Widget`" in markdown + assert heads[1] in markdown + validate_portfolio_report_markdown(markdown) + + duplicate_project = replace( + widget_projects[0], + identity=replace( + widget_projects[0].identity, + project_key="widget-duplicate", + path="Archive/Widget", + ), + ) + duplicate_snapshot = replace( + result.snapshot, + projects=[*result.snapshot.projects, duplicate_project], + ) + with pytest.raises(ValueError, match="one canonical project per origin"): + validate_truth_snapshot(duplicate_snapshot) + + summary["discarded_checkout_count"] += 1 + with pytest.raises(ValueError, match="discarded_checkout_count"): + validate_truth_snapshot(result.snapshot) + + def test_live_catalog_produces_exact_tier_zero_attention_semantics( tmp_path: Path, ) -> None: @@ -2913,6 +3023,7 @@ def test_report_subcommand_parses_security_cohort_count() -> None: def test_portfolio_truth_app_threads_security_cohort_count( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], ) -> None: from types import SimpleNamespace @@ -2933,6 +3044,9 @@ def fake_publish(**_kwargs): project_count=0, registry_changed=False, report_changed=False, + checkout_collision_group_count=2, + checkout_authority_unknown_count=1, + discarded_checkout_count=4, ) monkeypatch.setattr( @@ -2970,6 +3084,10 @@ def fake_publish(**_kwargs): assert captured["max_age_hours"] == 12 assert captured["expected_producer_commit"] is None assert captured["repo_status_cache"] is None + output = capsys.readouterr() + assert ( + "Checkout authority: 2 same-origin groups, 1 UNKNOWN, 4 discarded checkouts" + ) in output.out + output.err def test_portfolio_truth_app_carries_security_receipt_binding_to_publisher( @@ -3310,6 +3428,53 @@ def test_context_recovery_plan_freezes_and_filters_targets( assert targets["tmp-scaffold"].reason == "temporary-or-generated" +def test_context_recovery_plan_skips_unknown_checkout_authority( + portfolio_workspace: Path, + portfolio_catalog: Path, + legacy_registry: Path, +) -> None: + target_repo = portfolio_workspace / "FreshCollision" + target_repo.mkdir() + _write(target_repo / "README.md", "# FreshCollision\n\nFresh repo.\n") + + result = build_portfolio_truth_snapshot( + workspace_root=portfolio_workspace, + catalog_path=portfolio_catalog, + legacy_registry_path=legacy_registry, + include_notion=False, + now=datetime.fromtimestamp(1_700_000_100, tz=timezone.utc), + ) + projects = [ + replace( + project, + repository_state={ + **project.repository_state, + "checkout_authority": { + "schema_version": "CheckoutCollisionV1", + "selection": { + "state": "unknown", + "reason_code": "conflicting_full_clone_heads", + "representative_path": project.identity.path, + "selected_path": None, + } + }, + }, + ) + if project.identity.project_key == "FreshCollision" + else project + for project in result.snapshot.projects + ] + snapshot = replace(result.snapshot, projects=projects) + + plan = build_context_recovery_plan(snapshot, workspace_root=portfolio_workspace) + target = next( + item for item in plan.projects if item.project_key == "FreshCollision" + ) + + assert target.status == "skipped" + assert target.reason == "checkout-authority-unknown:conflicting_full_clone_heads" + + def test_context_recovery_apply_writes_primary_context_and_catalog_seed( portfolio_workspace: Path, portfolio_catalog: Path, diff --git a/tests/test_portfolio_truth_sources.py b/tests/test_portfolio_truth_sources.py index 50765ed6..7b133e56 100644 --- a/tests/test_portfolio_truth_sources.py +++ b/tests/test_portfolio_truth_sources.py @@ -10,17 +10,47 @@ import subprocess from datetime import datetime, timezone +from pathlib import Path from src.portfolio_truth_sources import ( _dedupe_checkouts_by_origin, _is_ignored_project_dir, + checkout_collision_summary, discover_workspace_projects, workspace_exclusion_reason, ) -def _p(name: str, repo_full_name: str = "", path: str | None = None) -> dict: - return {"name": name, "repo_full_name": repo_full_name, "path": path or name} +def _p( + name: str, + repo_full_name: str = "", + path: str | None = None, + *, + head: str | None = None, + branch: str | None = "main", + common_dir: str | None = None, + dirty: bool | None = False, + dirty_path_count: int | None = 0, + declared_paths: list[dict[str, str]] | None = None, +) -> dict: + relative_path = path or name + project = { + "name": name, + "repo_full_name": repo_full_name, + "path": relative_path, + "project_path": Path("/workspace") / relative_path, + } + if head is not None or common_dir is not None: + project["_checkout_observation"] = { + "state": "observed", + "head": head, + "branch": branch, + "dirty": dirty, + "dirty_path_count": dirty_path_count, + "git_common_dir": common_dir, + "declared_paths": declared_paths or [], + } + return project def test_collapses_same_origin_checkouts_to_one() -> None: @@ -86,6 +116,134 @@ def test_result_is_sorted_by_name_case_insensitively() -> None: assert [p["name"] for p in result] == ["Alpha", "mike", "zeta"] +def test_linked_worktrees_keep_discarded_checkout_evidence() -> None: + collisions: list[dict] = [] + head = "1" * 40 + discovered = [ + _p( + "Repo", + "owner/Repo", + head=head, + common_dir="/git/Repo/.git", + ), + _p( + "Repo-fix", + "owner/Repo", + head="2" * 40, + branch="fix", + common_dir="/git/Repo/.git", + ), + ] + + result = _dedupe_checkouts_by_origin( + discovered, + checkout_collisions=collisions, + ) + + assert len(result) == 1 + assert len(collisions) == 1 + collision = collisions[0] + assert collision["selection"]["state"] == "selected" + assert collision["selection"]["reason_code"] == "single_clone_topology" + assert collision["full_clone_count"] == 1 + assert collision["discarded_checkouts"] == [ + { + "path": "Repo-fix", + "state": "observed", + "relation": "linked_worktree", + "head": "2" * 40, + "branch": "fix", + "dirty": False, + "dirty_path_count": 0, + } + ] + + +def test_conflicting_independent_full_clone_heads_are_unknown() -> None: + collisions: list[dict] = [] + discovered = [ + _p( + "Repo", + "owner/Repo", + head="1" * 40, + common_dir="/git/Repo/.git", + ), + _p( + "Archive/Repo", + "owner/Repo", + path="Archive/Repo", + head="2" * 40, + common_dir="/git/Archive/Repo/.git", + ), + ] + + result = _dedupe_checkouts_by_origin( + discovered, + checkout_collisions=collisions, + ) + + assert len(result) == 1 + collision = collisions[0] + assert collision["full_clone_count"] == 2 + assert collision["selection"]["state"] == "unknown" + assert collision["selection"]["selected_path"] is None + assert collision["selection"]["reason_code"] == "conflicting_full_clone_heads" + assert collision["discarded_checkouts"][0]["relation"] == "independent_full_clone" + + summary = checkout_collision_summary(collisions) + assert summary["state"] == "unknown" + assert summary["group_count"] == 1 + assert summary["full_clone_group_count"] == 1 + assert summary["ambiguous_group_count"] == 1 + assert summary["discarded_checkout_count"] == 1 + + +def test_declared_path_to_other_full_clone_overrides_head_reason() -> None: + nested_declaration = { + "absolute_path": "/workspace/Money/AIGCCore/src", + "workspace_relative_path": "Money/AIGCCore/src", + "source_file": "AGENTS.md", + } + collisions: list[dict] = [] + discovered = [ + _p( + "AIGCCore", + "owner/AIGCCore", + head="1" * 40, + common_dir="/git/AIGCCore/.git", + declared_paths=[nested_declaration], + ), + _p( + "AIGCCore", + "owner/AIGCCore", + path="Money/AIGCCore", + head="2" * 40, + common_dir="/git/Money/AIGCCore/.git", + declared_paths=[nested_declaration], + ), + ] + + _dedupe_checkouts_by_origin(discovered, checkout_collisions=collisions) + + collision = collisions[0] + assert collision["selection"]["state"] == "unknown" + assert ( + collision["selection"]["reason_code"] + == "declared_path_conflicts_with_representative" + ) + assert collision["declared_checkout_paths"] == ["Money/AIGCCore"] + assert collision["declared_path_evidence"] == [ + { + "source_path": "AIGCCore/AGENTS.md", + "target_checkout_path": "Money/AIGCCore", + }, + { + "source_path": "Money/AIGCCore/AGENTS.md", + "target_checkout_path": "Money/AIGCCore", + }, + ] + + # --- discovery ignore-list: transient / non-project directories --- # NoGoPRJs (operator-flagged never-pursued), `*-smoke-export` (generated # AuraForge bundles), and `*-tmp-` clones are scratch artifacts, not real @@ -195,3 +353,73 @@ def test_discovery_recognizes_conventional_bare_coordinator(tmp_path) -> None: project = next(item for item in result if item["name"] == coordinator.name) assert project["has_git"] is True assert project["project_path"] == coordinator + assert project["_checkout_observation"]["state"] == "observed" + assert project["_checkout_observation"]["head"] is None + assert project["_checkout_observation"]["git_common_dir"] == str(coordinator) + + +def test_discovery_observes_conflicting_full_clones_without_count_inflation( + tmp_path, +) -> None: + root_clone = tmp_path / "Widget" + nested_clone = tmp_path / "Archive" / "Widget" + for index, clone in enumerate((root_clone, nested_clone), start=1): + clone.mkdir(parents=True) + (clone / "README.md").write_text(f"# Widget {index}\n") + if clone == root_clone: + (clone / "AGENTS.md").write_text( + "# Instructions\n\n## Canonical Paths\n\n" + f"- Source: `{nested_clone / 'src'}`\n" + ) + subprocess.run( + ["git", "init", "-q", "-b", "main"], + cwd=clone, + check=True, + ) + subprocess.run( + ["git", "remote", "add", "origin", "git@github.com:owner/Widget.git"], + cwd=clone, + check=True, + ) + subprocess.run(["git", "add", "."], cwd=clone, check=True) + subprocess.run( + [ + "git", + "-c", + "user.name=Test", + "-c", + "user.email=test@example.invalid", + "commit", + "-q", + "-m", + f"fixture {index}", + ], + cwd=clone, + check=True, + ) + + collisions: list[dict] = [] + projects = discover_workspace_projects( + tmp_path, + catalog_data={}, + now=datetime(2026, 8, 3, tzinfo=timezone.utc), + checkout_collisions=collisions, + ) + + widget_projects = [ + project for project in projects if project["repo_full_name"] == "owner/Widget" + ] + assert len(widget_projects) == 1 + assert widget_projects[0]["path"] == "Widget" + assert len(collisions) == 1 + collision = collisions[0] + assert collision["checkout_count"] == 2 + assert collision["full_clone_count"] == 2 + assert collision["selection"]["state"] == "unknown" + assert ( + collision["selection"]["reason_code"] + == "declared_path_conflicts_with_representative" + ) + assert collision["declared_checkout_paths"] == ["Archive/Widget"] + assert all(checkout["state"] == "observed" for checkout in collision["checkouts"]) + assert all(len(checkout["head"]) == 40 for checkout in collision["checkouts"]) diff --git a/tests/test_run_instructions_audit.py b/tests/test_run_instructions_audit.py index c477601f..40001c66 100644 --- a/tests/test_run_instructions_audit.py +++ b/tests/test_run_instructions_audit.py @@ -230,6 +230,7 @@ def test_prepare_pilot_builds_records_and_reports_missing_dirs(tmp_path): result = prepare_pilot(str(snap_path), per_tier={"full": 4}) + assert result["state"] == "ready" assert result["workspace_root"] == str(workspace) assert len(result["records"]) == 1 assert len(result["errors"]) == 1 @@ -244,3 +245,57 @@ def test_prepare_pilot_builds_records_and_reports_missing_dirs(tmp_path): assert record["drifted"] is False # no git repo → not drifted assert result["errors"][0]["error"] == "missing_dir" assert result["errors"][0]["project_key"] == "GhostRepo" + + +def test_prepare_pilot_blocks_unknown_checkout_authority(tmp_path): + workspace = tmp_path / "ws" + repo = workspace / "ConflictedRepo" + repo.mkdir(parents=True) + (repo / "AGENTS.md").write_text("# ConflictedRepo\n") + snapshot = { + "workspace_root": str(workspace), + "generated_at": "2026-08-03T12:00:00+00:00", + "projects": [ + { + "identity": { + "project_key": "ConflictedRepo", + "path": "ConflictedRepo", + "display_name": "ConflictedRepo", + }, + "derived": { + "archived": False, + "context_quality": "full", + "context_files": ["AGENTS.md"], + "run_instructions_present": False, + }, + "repository_state": { + "checkout_authority": { + "schema_version": "CheckoutCollisionV1", + "selection": { + "state": "unknown", + "reason_code": "declared_path_conflicts_with_representative", + "representative_path": "ConflictedRepo", + "selected_path": None, + } + } + }, + } + ], + } + snap_path = tmp_path / "snap.json" + snap_path.write_text(json.dumps(snapshot)) + + result = prepare_pilot(str(snap_path), per_tier={"full": 1}) + + assert result["state"] == "blocked" + assert result["records"] == [] + assert result["errors"] == [ + { + "project_key": "ConflictedRepo", + "abs_path": str(repo), + "error": "checkout_authority_blocked", + "reason": ( + "checkout-authority-unknown:declared_path_conflicts_with_representative" + ), + } + ] From 19c275f7cf32b21c69d3f2c1db72d9f57295851a Mon Sep 17 00:00:00 2001 From: saagpatel Date: Mon, 3 Aug 2026 17:26:51 -0700 Subject: [PATCH 02/17] fix(portfolio): fail closed on discarded worktree changes --- src/portfolio_truth_sources.py | 2 +- tests/test_portfolio_truth_sources.py | 42 +++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/portfolio_truth_sources.py b/src/portfolio_truth_sources.py index 3a082ede..63b2539b 100644 --- a/src/portfolio_truth_sources.py +++ b/src/portfolio_truth_sources.py @@ -365,7 +365,7 @@ def _checkout_collision_record( ) elif any( _checkout_observation(project).get("dirty") is True - for project in clone_representatives + for project in group ): state = "unknown" reason_code = "full_clone_local_work_present" diff --git a/tests/test_portfolio_truth_sources.py b/tests/test_portfolio_truth_sources.py index 7b133e56..f067351e 100644 --- a/tests/test_portfolio_truth_sources.py +++ b/tests/test_portfolio_truth_sources.py @@ -198,6 +198,48 @@ def test_conflicting_independent_full_clone_heads_are_unknown() -> None: assert summary["discarded_checkout_count"] == 1 +def test_dirty_linked_worktree_in_independent_clone_is_unknown() -> None: + collisions: list[dict] = [] + head = "1" * 40 + discovered = [ + _p( + "Repo", + "owner/Repo", + head=head, + common_dir="/git/Repo/.git", + ), + _p( + "Repo", + "owner/Repo", + path="Archive/Repo", + head=head, + common_dir="/git/Archive/Repo/.git", + ), + _p( + "Repo-fix", + "owner/Repo", + path="Archive/Repo-fix", + head="2" * 40, + branch="fix", + common_dir="/git/Archive/Repo/.git", + dirty=True, + dirty_path_count=1, + ), + ] + + _dedupe_checkouts_by_origin(discovered, checkout_collisions=collisions) + + collision = collisions[0] + assert collision["full_clone_count"] == 2 + assert collision["selection"]["state"] == "unknown" + assert collision["selection"]["selected_path"] is None + assert collision["selection"]["reason_code"] == "full_clone_local_work_present" + assert any( + checkout["path"] == "Archive/Repo-fix" and checkout["dirty"] is True + for checkout in collision["discarded_checkouts"] + ) + + def test_declared_path_to_other_full_clone_overrides_head_reason() -> None: nested_declaration = { "absolute_path": "/workspace/Money/AIGCCore/src", From 704ee58aca7dac67383f381a9e98bd39f58d2245 Mon Sep 17 00:00:00 2001 From: saagpatel Date: Mon, 3 Aug 2026 17:30:27 -0700 Subject: [PATCH 03/17] fix(audit): suppress blocked pilot records --- src/run_instructions_audit.py | 2 +- tests/test_run_instructions_audit.py | 20 ++++++++++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/run_instructions_audit.py b/src/run_instructions_audit.py index 7abbd44b..33711c53 100644 --- a/src/run_instructions_audit.py +++ b/src/run_instructions_audit.py @@ -172,7 +172,7 @@ def prepare_pilot( "state": "blocked" if authority_blocked else "ready", "generated_at": generated_at, "workspace_root": workspace_root, - "records": records, + "records": [] if authority_blocked else records, "errors": errors, } diff --git a/tests/test_run_instructions_audit.py b/tests/test_run_instructions_audit.py index 40001c66..ae7bdfc0 100644 --- a/tests/test_run_instructions_audit.py +++ b/tests/test_run_instructions_audit.py @@ -252,6 +252,9 @@ def test_prepare_pilot_blocks_unknown_checkout_authority(tmp_path): repo = workspace / "ConflictedRepo" repo.mkdir(parents=True) (repo / "AGENTS.md").write_text("# ConflictedRepo\n") + safe_repo = workspace / "SafeRepo" + safe_repo.mkdir() + (safe_repo / "AGENTS.md").write_text("# SafeRepo\n") snapshot = { "workspace_root": str(workspace), "generated_at": "2026-08-03T12:00:00+00:00", @@ -279,13 +282,26 @@ def test_prepare_pilot_blocks_unknown_checkout_authority(tmp_path): } } }, - } + }, + { + "identity": { + "project_key": "SafeRepo", + "path": "SafeRepo", + "display_name": "SafeRepo", + }, + "derived": { + "archived": False, + "context_quality": "full", + "context_files": ["AGENTS.md"], + "run_instructions_present": False, + }, + }, ], } snap_path = tmp_path / "snap.json" snap_path.write_text(json.dumps(snapshot)) - result = prepare_pilot(str(snap_path), per_tier={"full": 1}) + result = prepare_pilot(str(snap_path), per_tier={"full": 2}) assert result["state"] == "blocked" assert result["records"] == [] From d72807926d48e618310d4d9b60ed7dc4dce9343d Mon Sep 17 00:00:00 2001 From: saagpatel Date: Mon, 3 Aug 2026 17:33:25 -0700 Subject: [PATCH 04/17] fix(portfolio): prefer working checkout authority --- src/portfolio_checkout_authority.py | 1 + src/portfolio_truth_render.py | 2 +- src/portfolio_truth_sources.py | 5 +++ src/portfolio_truth_validate.py | 7 ++++ tests/test_portfolio_automation.py | 1 + tests/test_portfolio_truth_sources.py | 59 +++++++++++++++++++++++++++ 6 files changed, 74 insertions(+), 1 deletion(-) diff --git a/src/portfolio_checkout_authority.py b/src/portfolio_checkout_authority.py index b51dd220..b320e9bf 100644 --- a/src/portfolio_checkout_authority.py +++ b/src/portfolio_checkout_authority.py @@ -60,6 +60,7 @@ def checkout_authority_blocker( if ( selected_checkout.get("state") != "observed" or selected_checkout.get("relation") != "representative" + or selected_checkout.get("bare") is not False ): return "checkout-authority-malformed" diff --git a/src/portfolio_truth_render.py b/src/portfolio_truth_render.py index eb746359..733acd14 100644 --- a/src/portfolio_truth_render.py +++ b/src/portfolio_truth_render.py @@ -309,7 +309,7 @@ def _render_checkout_authority_section(summary: dict[str, Any]) -> list[str]: f"- Authority state UNKNOWN: `{ambiguous_count}`", f"- Discarded checkout records retained: `{discarded_count}`", "- Selection policy: preserve one compatibility representative per origin; " - "independent full-clone conflicts remain UNKNOWN.", + + "independent full-clone conflicts remain UNKNOWN.", ] groups = summary.get("groups", []) if not groups: diff --git a/src/portfolio_truth_sources.py b/src/portfolio_truth_sources.py index 63b2539b..d00accec 100644 --- a/src/portfolio_truth_sources.py +++ b/src/portfolio_truth_sources.py @@ -278,6 +278,7 @@ def _checkout_representative( return min( group, key=lambda project: ( + _checkout_observation(project).get("bare") is True, str(project.get("name", "")).lower() != repo_base, len(Path(str(project.get("path", ""))).parts), len(str(project.get("path", ""))), @@ -432,6 +433,7 @@ def _checkout_observation(project: dict[str, Any]) -> dict[str, Any]: "dirty": None, "dirty_path_count": None, "git_common_dir": None, + "bare": None, "declared_paths": [], } @@ -459,6 +461,7 @@ def _published_checkout( "branch": observation.get("branch"), "dirty": observation.get("dirty"), "dirty_path_count": observation.get("dirty_path_count"), + "bare": observation.get("bare"), } @@ -944,6 +947,7 @@ def _observe_checkout(project_path: Path, *, workspace_root: Path) -> dict[str, "dirty": None, "dirty_path_count": None, "git_common_dir": None, + "bare": None, "declared_paths": _declared_canonical_paths( project_path, workspace_root=workspace_root ), @@ -955,6 +959,7 @@ def _observe_checkout(project_path: Path, *, workspace_root: Path) -> dict[str, "dirty": dirty, "dirty_path_count": dirty_path_count, "git_common_dir": common_dir or None, + "bare": bare, "declared_paths": _declared_canonical_paths( project_path, workspace_root=workspace_root ), diff --git a/src/portfolio_truth_validate.py b/src/portfolio_truth_validate.py index 60507d80..3b0019a8 100644 --- a/src/portfolio_truth_validate.py +++ b/src/portfolio_truth_validate.py @@ -240,6 +240,7 @@ def _validate_checkout_collisions(snapshot: PortfolioTruthSnapshot) -> None: "branch", "dirty", "dirty_path_count", + "bare", } missing_checkout = sorted(required_checkout - checkout.keys()) if missing_checkout: @@ -278,6 +279,11 @@ def _validate_checkout_collisions(snapshot: PortfolioTruthSnapshot) -> None: or dirty_count < 0 ): raise ValueError(f"Malformed checkout dirty_path_count for {path}.") + bare = checkout.get("bare") + if bare is not None and not isinstance(bare, bool): + raise ValueError(f"Malformed checkout bare state for {path}.") + if checkout.get("state") == "observed" and not isinstance(bare, bool): + raise ValueError(f"Observed checkout must declare bare state for {path}.") representative_checkout = next( ( checkout @@ -290,6 +296,7 @@ def _validate_checkout_collisions(snapshot: PortfolioTruthSnapshot) -> None: representative_count != 1 or representative_checkout is None or representative_checkout["relation"] != "representative" + or (state == "selected" and representative_checkout["bare"] is not False) ): raise ValueError("Checkout collision requires one observed representative.") expected_discarded = [ diff --git a/tests/test_portfolio_automation.py b/tests/test_portfolio_automation.py index 0ea38cfb..0ee6ea47 100644 --- a/tests/test_portfolio_automation.py +++ b/tests/test_portfolio_automation.py @@ -183,6 +183,7 @@ def test_observed_selected_checkout_authority_remains_eligible() -> None: "path": "Repo", "state": "observed", "relation": "representative", + "bare": False, } ], } diff --git a/tests/test_portfolio_truth_sources.py b/tests/test_portfolio_truth_sources.py index f067351e..bd8360b2 100644 --- a/tests/test_portfolio_truth_sources.py +++ b/tests/test_portfolio_truth_sources.py @@ -31,6 +31,7 @@ def _p( common_dir: str | None = None, dirty: bool | None = False, dirty_path_count: int | None = 0, + bare: bool = False, declared_paths: list[dict[str, str]] | None = None, ) -> dict: relative_path = path or name @@ -48,6 +49,7 @@ def _p( "dirty": dirty, "dirty_path_count": dirty_path_count, "git_common_dir": common_dir, + "bare": bare, "declared_paths": declared_paths or [], } return project @@ -155,6 +157,7 @@ def test_linked_worktrees_keep_discarded_checkout_evidence() -> None: "branch": "fix", "dirty": False, "dirty_path_count": 0, + "bare": False, } ] @@ -240,6 +243,61 @@ def test_dirty_linked_worktree_in_independent_clone_is_unknown() -> None: ) +def test_working_checkout_is_preferred_over_bare_same_origin_repo() -> None: + collisions: list[dict] = [] + head = "1" * 40 + discovered = [ + _p( + "Repo", + "owner/Repo", + head=head, + common_dir="/git/Repo.git", + bare=True, + dirty=None, + dirty_path_count=None, + ), + _p( + "Repo", + "owner/Repo", + path="Archive/Repo", + head=head, + common_dir="/git/Archive/Repo/.git", + ), + ] + + result = _dedupe_checkouts_by_origin( + discovered, + checkout_collisions=collisions, + ) + + assert result[0]["path"] == "Archive/Repo" + collision = collisions[0] + assert collision["selection"]["state"] == "selected" + assert collision["selection"]["selected_path"] == "Archive/Repo" + assert collision["checkouts"] == [ + { + "path": "Archive/Repo", + "state": "observed", + "relation": "representative", + "head": head, + "branch": "main", + "dirty": False, + "dirty_path_count": 0, + "bare": False, + }, + { + "path": "Repo", + "state": "observed", + "relation": "independent_full_clone", + "head": head, + "branch": "main", + "dirty": None, + "dirty_path_count": None, + "bare": True, + }, + ] + + def test_declared_path_to_other_full_clone_overrides_head_reason() -> None: nested_declaration = { "absolute_path": "/workspace/Money/AIGCCore/src", @@ -398,6 +456,7 @@ def test_discovery_recognizes_conventional_bare_coordinator(tmp_path) -> None: assert project["_checkout_observation"]["state"] == "observed" assert project["_checkout_observation"]["head"] is None assert project["_checkout_observation"]["git_common_dir"] == str(coordinator) + assert project["_checkout_observation"]["bare"] is True def test_discovery_observes_conflicting_full_clones_without_count_inflation( From 669369a52fc489a06c2458c3297b8ea14a623b15 Mon Sep 17 00:00:00 2001 From: saagpatel Date: Mon, 3 Aug 2026 17:42:02 -0700 Subject: [PATCH 05/17] fix(automation): gate catalog seeds on checkout authority --- src/automation_workflow.py | 8 +++++++ tests/test_automation_workflow.py | 35 +++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/src/automation_workflow.py b/src/automation_workflow.py index 609f8ec9..d80b7612 100644 --- a/src/automation_workflow.py +++ b/src/automation_workflow.py @@ -230,6 +230,14 @@ def _dispatch_proposal( if project is None: return ExecutionResult(proposal.proposal_id, "skipped", "project-not-found") + if proposal.action_type in {ACTION_CONTEXT_PR, ACTION_CATALOG_SEED}: + authority_reason = checkout_authority_blocker( + project, + workspace_root=workspace_root, + ) + if authority_reason: + raise AutomationExecutionError(authority_reason) + if proposal.action_type == ACTION_CONTEXT_PR: # A context-PR needs a real GitHub slug for the head/base refs; we never # fabricate one from the local display name (decided 2026-06-06). diff --git a/tests/test_automation_workflow.py b/tests/test_automation_workflow.py index 18e39d5b..06e50bed 100644 --- a/tests/test_automation_workflow.py +++ b/tests/test_automation_workflow.py @@ -383,6 +383,41 @@ def test_execute_catalog_seed_applies_and_persists_executed(tmp_path: Path) -> N assert persisted.execution_ref == str(catalog_path) +def test_execute_catalog_seed_blocks_unknown_checkout_authority(tmp_path: Path) -> None: + proposals_path = tmp_path / "pending-proposals.json" + _write(proposals_path, _proposal(action_type=ACTION_CATALOG_SEED)) + catalog_path = tmp_path / "catalog.yaml" + project = _project( + repository_state={ + "checkout_authority": { + "schema_version": "CheckoutCollisionV1", + "selection": { + "state": "unknown", + "reason_code": "conflicting_full_clone_heads", + "representative_path": "MyRepo", + "selected_path": None, + }, + } + } + ) + + results = execute_approved_proposals( + proposals_path=proposals_path, + snapshot=_snapshot(project), + workspace_root=tmp_path, + catalog_path=catalog_path, + executed_at=NOW, + dry_run=False, + ) + + assert results[0].outcome == "failed" + assert results[0].detail == ( + "checkout-authority-unknown:conflicting_full_clone_heads" + ) + assert not catalog_path.exists() + assert load_proposals(proposals_path)[0].status == STATUS_APPROVED + + # --- execute_approved_proposals: resolution + slug policy ------------------ From cb25ba01d044bb675be2f75bcd8a38abc81e0444 Mon Sep 17 00:00:00 2001 From: saagpatel Date: Mon, 3 Aug 2026 17:49:37 -0700 Subject: [PATCH 06/17] fix(portfolio): honor declared checkout authority --- src/portfolio_truth_sources.py | 66 +++++++++++---------------- tests/test_portfolio_truth_sources.py | 65 ++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 40 deletions(-) diff --git a/src/portfolio_truth_sources.py b/src/portfolio_truth_sources.py index d00accec..a6d30a72 100644 --- a/src/portfolio_truth_sources.py +++ b/src/portfolio_truth_sources.py @@ -278,6 +278,7 @@ def _checkout_representative( return min( group, key=lambda project: ( + _checkout_observation(project).get("state") != "observed", _checkout_observation(project).get("bare") is True, str(project.get("name", "")).lower() != repo_base, len(Path(str(project.get("path", ""))).parts), @@ -303,16 +304,7 @@ def _checkout_collision_record( declared_checkout_paths = sorted( {item["target_checkout_path"] for item in declarations}, key=str.lower ) - declared_clone_keys = { - _checkout_clone_key( - next( - project - for project in group - if str(project.get("path")) == declared_path - ) - ) - for declared_path in declared_checkout_paths - } + representative_path = str(representative.get("path") or "") clone_representatives = [ _checkout_representative(members, origin_key) @@ -332,33 +324,27 @@ def _checkout_collision_record( state = "selected" reason_code = "single_clone_topology" reason = "all discovered checkouts share one Git common directory" - if len(clone_groups) > 1: - if not observations_complete: - state = "unknown" - reason_code = "checkout_observation_failed" - reason = ( - "one or more same-origin checkouts could not be observed completely" - ) - elif len(declared_clone_keys) > 1: - state = "unknown" - reason_code = "conflicting_declared_checkout_paths" - reason = ( - "canonical path declarations resolve to multiple independent clones" - ) - elif declared_clone_keys and representative_clone not in declared_clone_keys: - state = "unknown" - reason_code = "declared_path_conflicts_with_representative" - reason = ( - "canonical path declarations resolve to a different full clone than " - "the compatibility representative" - ) - elif unresolved_declarations: - state = "unknown" - reason_code = "declared_checkout_path_unresolved" - reason = ( - "one or more canonical path declarations do not resolve to a checkout" - ) - elif conflicting_heads: + if len(clone_groups) > 1 and not observations_complete: + state = "unknown" + reason_code = "checkout_observation_failed" + reason = "one or more same-origin checkouts could not be observed completely" + elif len(declared_checkout_paths) > 1: + state = "unknown" + reason_code = "conflicting_declared_checkout_paths" + reason = "canonical path declarations resolve to multiple checkouts" + elif declared_checkout_paths and representative_path not in declared_checkout_paths: + state = "unknown" + reason_code = "declared_path_conflicts_with_representative" + reason = ( + "canonical path declarations resolve to a different checkout than " + "the compatibility representative" + ) + elif unresolved_declarations: + state = "unknown" + reason_code = "declared_checkout_path_unresolved" + reason = "one or more canonical path declarations do not resolve to a checkout" + elif len(clone_groups) > 1: + if conflicting_heads: state = "unknown" reason_code = "conflicting_full_clone_heads" reason = ( @@ -386,7 +372,6 @@ def _checkout_collision_record( ) for project in sorted(group, key=lambda item: str(item.get("path", "")).lower()) ] - representative_path = str(representative.get("path") or "") discarded = [ checkout for checkout in checkouts if checkout["path"] != representative_path ] @@ -405,8 +390,9 @@ def _checkout_collision_record( "representative_path": representative_path, "selected_path": representative_path if state == "selected" else None, "rationale": ( - "Compatibility representative prefers an origin-basename match, " - "then the shallowest, shortest, alphabetic workspace-relative path." + "Compatibility representative prefers a fully observed non-bare " + "checkout, then an origin-basename match, followed by the shallowest, " + "shortest, alphabetic workspace-relative path." ), }, "checkouts": checkouts, diff --git a/tests/test_portfolio_truth_sources.py b/tests/test_portfolio_truth_sources.py index bd8360b2..9fe2029a 100644 --- a/tests/test_portfolio_truth_sources.py +++ b/tests/test_portfolio_truth_sources.py @@ -298,6 +298,71 @@ def test_working_checkout_is_preferred_over_bare_same_origin_repo() -> None: ] +def test_observed_checkout_is_preferred_over_failed_basename_match() -> None: + collisions: list[dict] = [] + head = "1" * 40 + discovered = [ + _p("Repo", "owner/Repo"), + _p( + "Repo", + "owner/Repo", + path="Archive/Repo", + head=head, + common_dir="/git/Archive/Repo/.git", + ), + ] + + result = _dedupe_checkouts_by_origin( + discovered, + checkout_collisions=collisions, + ) + + assert result[0]["path"] == "Archive/Repo" + collision = collisions[0] + assert collision["selection"]["state"] == "unknown" + assert collision["selection"]["reason_code"] == "checkout_observation_failed" + assert collision["selection"]["representative_path"] == "Archive/Repo" + + +def test_declared_linked_worktree_conflicts_with_representative() -> None: + declaration = { + "absolute_path": "/workspace/Repo-fix/src", + "workspace_relative_path": "Repo-fix/src", + "source_file": "AGENTS.md", + } + collisions: list[dict] = [] + head = "1" * 40 + discovered = [ + _p( + "Repo", + "owner/Repo", + head=head, + common_dir="/git/Repo/.git", + declared_paths=[declaration], + ), + _p( + "Repo-fix", + "owner/Repo", + head=head, + branch="fix", + common_dir="/git/Repo/.git", + declared_paths=[declaration], + ), + ] + + _dedupe_checkouts_by_origin(discovered, checkout_collisions=collisions) + + collision = collisions[0] + assert collision["full_clone_count"] == 1 + assert collision["declared_checkout_paths"] == ["Repo-fix"] + assert collision["selection"]["state"] == "unknown" + assert collision["selection"]["selected_path"] is None + assert ( + collision["selection"]["reason_code"] + == "declared_path_conflicts_with_representative" + ) + + def test_declared_path_to_other_full_clone_overrides_head_reason() -> None: nested_declaration = { "absolute_path": "/workspace/Money/AIGCCore/src", From 8149cd99802b45ea3ce713754a63029fddeca4f2 Mon Sep 17 00:00:00 2001 From: saagpatel Date: Mon, 3 Aug 2026 18:02:17 -0700 Subject: [PATCH 07/17] fix(portfolio): observe excluded linked worktrees --- src/portfolio_checkout_authority.py | 50 +++++++++++++++++-- src/portfolio_truth_sources.py | 72 ++++++++++++++++++++++++++- tests/test_portfolio_automation.py | 66 ++++++++++++++++++++++++ tests/test_portfolio_truth_sources.py | 70 ++++++++++++++++++++++++++ 4 files changed, 252 insertions(+), 6 deletions(-) diff --git a/src/portfolio_checkout_authority.py b/src/portfolio_checkout_authority.py index b320e9bf..403c17c9 100644 --- a/src/portfolio_checkout_authority.py +++ b/src/portfolio_checkout_authority.py @@ -14,15 +14,16 @@ def checkout_authority_blocker( ) -> str | None: """Return a stable automation blocker for unresolved checkout authority. - Projects without collision evidence are single-checkout/legacy inputs and keep - their existing behavior. Once collision evidence is present, malformed, - UNKNOWN, or path-mismatched selection fails closed. + Legacy inputs without topology evidence keep their existing behavior. Fresh + repository topology fails closed when observation is unknown or multiple + worktrees lack matching collision authority. Once collision evidence is + present, malformed, UNKNOWN, or path-mismatched selection also fails closed. """ identity = _project_section(project, "identity") repository_state = _project_section(project, "repository_state") authority = repository_state.get("checkout_authority") if authority is None: - return None + return _repository_topology_blocker(repository_state, authority=None) if not isinstance(authority, Mapping): return "checkout-authority-malformed" if authority.get("schema_version") != CHECKOUT_COLLISION_SCHEMA_VERSION: @@ -71,6 +72,47 @@ def checkout_authority_blocker( resolved_target.relative_to(resolved_root) except (OSError, ValueError): return "checkout-authority-path-escape" + return _repository_topology_blocker(repository_state, authority=authority) + + +def _repository_topology_blocker( + repository_state: Mapping[str, Any], + *, + authority: Mapping[str, Any] | None, +) -> str | None: + state = str(repository_state.get("state") or "") + if state == "unknown": + reason_code = str( + repository_state.get("reason_code") or "repository_observation_failed" + ) + return f"checkout-topology-unknown:{reason_code}" + + worktrees = repository_state.get("worktrees") + if worktrees is None: + return None + if not isinstance(worktrees, list) or any( + not isinstance(item, Mapping) for item in worktrees + ): + return "checkout-topology-malformed" + if len(worktrees) <= 1: + return None + if authority is None: + return "checkout-authority-missing:multiple-worktrees" + if any(item.get("state") not in {"observed", "coordinator"} for item in worktrees): + return "checkout-topology-unknown:worktree_observation_failed" + if any(item.get("dirty") is True for item in worktrees): + return "checkout-topology-local-work-present" + + authority_checkouts = authority.get("checkouts") + if not isinstance(authority_checkouts, list): + return "checkout-authority-malformed" + representative_clone_count = sum( + isinstance(item, Mapping) + and item.get("relation") in {"representative", "linked_worktree"} + for item in authority_checkouts + ) + if representative_clone_count != len(worktrees): + return "checkout-authority-topology-mismatch" return None diff --git a/src/portfolio_truth_sources.py b/src/portfolio_truth_sources.py index a6d30a72..cbfba092 100644 --- a/src/portfolio_truth_sources.py +++ b/src/portfolio_truth_sources.py @@ -200,6 +200,7 @@ def discover_workspace_projects( return _dedupe_checkouts_by_origin( discovered, checkout_collisions=checkout_collisions, + workspace_root=workspace_root, ) @@ -207,6 +208,7 @@ def _dedupe_checkouts_by_origin( discovered: list[dict[str, Any]], *, checkout_collisions: list[dict[str, Any]] | None = None, + workspace_root: Path | None = None, ) -> list[dict[str, Any]]: """Collapse multiple on-disk checkouts of the same repo to one canonical project. @@ -232,11 +234,15 @@ def _dedupe_checkouts_by_origin( for origin_key, group in by_origin.items(): representative = _checkout_representative(group, origin_key) - if len(group) > 1: + authority_group = _checkout_topology_group( + group, + workspace_root=workspace_root, + ) + if len(authority_group) > 1: collision = _checkout_collision_record( origin=str(representative.get("repo_full_name") or origin_key), origin_key=origin_key, - group=group, + group=authority_group, representative=representative, ) representative["checkout_authority"] = collision @@ -248,6 +254,62 @@ def _dedupe_checkouts_by_origin( return canonical +def _checkout_topology_group( + group: list[dict[str, Any]], + *, + workspace_root: Path | None, +) -> list[dict[str, Any]]: + """Add in-workspace linked worktrees as authority evidence, not projects.""" + if workspace_root is None: + return list(group) + + resolved_root = workspace_root.resolve() + expanded = list(group) + known_paths = { + Path(str(project["project_path"])).resolve() for project in group + } + for source_project in group: + project_path = Path(str(source_project["project_path"])) + try: + worktree_paths = _git_worktree_paths(project_path) + except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired): + continue + for worktree_path in worktree_paths: + resolved_path = worktree_path.resolve() + if resolved_path in known_paths or not _path_is_within( + resolved_path, resolved_root + ): + continue + known_paths.add(resolved_path) + relative_path = resolved_path.relative_to(resolved_root).as_posix() + expanded.append( + { + "name": resolved_path.name, + "repo_full_name": source_project.get("repo_full_name", ""), + "path": relative_path, + "project_path": resolved_path, + "_checkout_observation": _observe_checkout( + resolved_path, + workspace_root=resolved_root, + ), + "_checkout_evidence_only": True, + } + ) + return expanded + + +def _git_worktree_paths(project_path: Path) -> list[Path]: + output = _git_read(project_path, "worktree", "list", "--porcelain") + paths = [ + Path(line.removeprefix("worktree ")) + for line in output.splitlines() + if line.startswith("worktree ") + ] + if not paths: + raise ValueError("git worktree list returned no worktrees") + return paths + + def checkout_collision_summary( collisions: list[dict[str, Any]], ) -> dict[str, Any]: @@ -363,6 +425,12 @@ def _checkout_collision_record( "independent same-origin clones have equivalent observed heads; " "the deterministic compatibility representative is selected" ) + elif any( + _checkout_observation(project).get("dirty") is True for project in group + ): + state = "unknown" + reason_code = "linked_worktree_local_work_present" + reason = "a linked same-origin worktree contains local work" checkouts = [ _published_checkout( diff --git a/tests/test_portfolio_automation.py b/tests/test_portfolio_automation.py index 0ee6ea47..0fa614d0 100644 --- a/tests/test_portfolio_automation.py +++ b/tests/test_portfolio_automation.py @@ -195,6 +195,72 @@ def test_observed_selected_checkout_authority_remains_eligible() -> None: assert result.blockers == () +def test_multiple_observed_worktrees_require_checkout_authority() -> None: + project = _project() + project["repository_state"] = { + "state": "observed", + "worktrees": [ + {"state": "observed", "path": "/workspace/Repo", "dirty": False}, + { + "state": "observed", + "path": "/workspace/_codex-worktrees/repo-feature", + "dirty": False, + }, + ], + } + + result = evaluate_automation_eligibility( + project, + decision_quality_status="trusted", + ) + + assert result.eligible is False + assert result.blockers == ("checkout-authority-missing:multiple-worktrees",) + + +def test_checkout_authority_must_cover_repository_worktree_topology() -> None: + project = _project( + checkout_authority={ + "schema_version": "CheckoutCollisionV1", + "selection": { + "state": "selected", + "reason_code": "single_clone_topology", + "representative_path": "Repo", + "selected_path": "Repo", + }, + "checkouts": [ + { + "path": "Repo", + "state": "observed", + "relation": "representative", + "bare": False, + } + ], + } + ) + project["repository_state"].update( + { + "state": "observed", + "worktrees": [ + {"state": "observed", "path": "/workspace/Repo", "dirty": False}, + { + "state": "observed", + "path": "/outside/repo-feature", + "dirty": False, + }, + ], + } + ) + + result = evaluate_automation_eligibility( + project, + decision_quality_status="trusted", + ) + + assert result.eligible is False + assert result.blockers == ("checkout-authority-topology-mismatch",) + + # --- select_automation_candidates ------------------------------------------ diff --git a/tests/test_portfolio_truth_sources.py b/tests/test_portfolio_truth_sources.py index 9fe2029a..50ad5b41 100644 --- a/tests/test_portfolio_truth_sources.py +++ b/tests/test_portfolio_truth_sources.py @@ -524,6 +524,76 @@ def test_discovery_recognizes_conventional_bare_coordinator(tmp_path) -> None: assert project["_checkout_observation"]["bare"] is True +def test_discovery_observes_dirty_worktree_inside_excluded_container( + tmp_path: Path, +) -> None: + repo = tmp_path / "Repo" + repo.mkdir() + subprocess.run(["git", "init", "-q", "-b", "main"], cwd=repo, check=True) + subprocess.run( + ["git", "remote", "add", "origin", "git@github.com:owner/Repo.git"], + cwd=repo, + check=True, + ) + (repo / "README.md").write_text("# Repo\n") + subprocess.run(["git", "add", "README.md"], cwd=repo, check=True) + subprocess.run( + [ + "git", + "-c", + "user.name=Test", + "-c", + "user.email=test@example.invalid", + "commit", + "-q", + "-m", + "fixture", + ], + cwd=repo, + check=True, + ) + linked = tmp_path / "_codex-worktrees" / "repo-feature" + linked.parent.mkdir() + subprocess.run( + ["git", "worktree", "add", "-q", "-b", "feature", str(linked), "HEAD"], + cwd=repo, + check=True, + ) + (linked / "preserve.txt").write_text("dirty\n") + + collisions: list[dict] = [] + exclusions: dict[str, int] = {} + projects = discover_workspace_projects( + tmp_path, + catalog_data={}, + now=datetime(2026, 8, 3, tzinfo=timezone.utc), + checkout_collisions=collisions, + exclusion_counts=exclusions, + ) + + repo_projects = [ + project for project in projects if project["repo_full_name"] == "owner/Repo" + ] + assert len(repo_projects) == 1 + assert repo_projects[0]["path"] == "Repo" + assert exclusions["linked-worktree-container"] == 1 + collision = collisions[0] + assert collision["checkout_count"] == 2 + assert collision["full_clone_count"] == 1 + assert collision["selection"]["state"] == "unknown" + assert ( + collision["selection"]["reason_code"] + == "linked_worktree_local_work_present" + ) + linked_evidence = next( + item + for item in collision["discarded_checkouts"] + if item["path"] == "_codex-worktrees/repo-feature" + ) + assert linked_evidence["relation"] == "linked_worktree" + assert linked_evidence["dirty"] is True + + def test_discovery_observes_conflicting_full_clones_without_count_inflation( tmp_path, ) -> None: From fcc0291a61608474ec12818fab3682bbb5da9a99 Mon Sep 17 00:00:00 2001 From: saagpatel Date: Mon, 3 Aug 2026 18:07:53 -0700 Subject: [PATCH 08/17] fix(portfolio): fail closed on unresolved declarations --- src/portfolio_truth_sources.py | 6 +++++- tests/test_portfolio_truth_sources.py | 30 +++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/portfolio_truth_sources.py b/src/portfolio_truth_sources.py index cbfba092..6abe303c 100644 --- a/src/portfolio_truth_sources.py +++ b/src/portfolio_truth_sources.py @@ -238,7 +238,11 @@ def _dedupe_checkouts_by_origin( group, workspace_root=workspace_root, ) - if len(authority_group) > 1: + has_checkout_declarations = any( + _checkout_observation(project).get("declared_paths") + for project in authority_group + ) + if len(authority_group) > 1 or has_checkout_declarations: collision = _checkout_collision_record( origin=str(representative.get("repo_full_name") or origin_key), origin_key=origin_key, diff --git a/tests/test_portfolio_truth_sources.py b/tests/test_portfolio_truth_sources.py index 50ad5b41..e338f09d 100644 --- a/tests/test_portfolio_truth_sources.py +++ b/tests/test_portfolio_truth_sources.py @@ -363,6 +363,36 @@ def test_declared_linked_worktree_conflicts_with_representative() -> None: ) +def test_declared_undiscovered_checkout_is_unknown() -> None: + declaration = { + "absolute_path": "/workspace/_codex-worktrees/repo-retired/src", + "workspace_relative_path": "_codex-worktrees/repo-retired/src", + "source_file": "AGENTS.md", + } + collisions: list[dict] = [] + discovered = [ + _p( + "Repo", + "owner/Repo", + head="1" * 40, + common_dir="/git/Repo/.git", + declared_paths=[declaration], + ) + ] + + _dedupe_checkouts_by_origin(discovered, checkout_collisions=collisions) + + assert len(collisions) == 1 + collision = collisions[0] + assert collision["checkout_count"] == 1 + assert collision["selection"]["state"] == "unknown" + assert collision["selection"]["selected_path"] is None + assert collision["selection"]["reason_code"] == "declared_checkout_path_unresolved" + assert collision["unresolved_declared_paths"] == [ + "_codex-worktrees/repo-retired/src" + ] + + def test_declared_path_to_other_full_clone_overrides_head_reason() -> None: nested_declaration = { "absolute_path": "/workspace/Money/AIGCCore/src", From 53efc4f62d9a75f34ad5993ce8de024308f8eccf Mon Sep 17 00:00:00 2001 From: saagpatel Date: Mon, 3 Aug 2026 18:19:07 -0700 Subject: [PATCH 09/17] fix(portfolio): validate expanded checkout authority --- src/portfolio_truth_sources.py | 34 ++++--- src/portfolio_truth_validate.py | 10 +- tests/test_portfolio_truth.py | 130 ++++++++++++++++++++++++++ tests/test_portfolio_truth_sources.py | 65 +++++++++++++ 4 files changed, 220 insertions(+), 19 deletions(-) diff --git a/src/portfolio_truth_sources.py b/src/portfolio_truth_sources.py index 6abe303c..aa0f7a39 100644 --- a/src/portfolio_truth_sources.py +++ b/src/portfolio_truth_sources.py @@ -201,6 +201,8 @@ def discover_workspace_projects( discovered, checkout_collisions=checkout_collisions, workspace_root=workspace_root, + catalog_data=catalog_data, + now=now, ) @@ -209,6 +211,8 @@ def _dedupe_checkouts_by_origin( *, checkout_collisions: list[dict[str, Any]] | None = None, workspace_root: Path | None = None, + catalog_data: dict[str, Any] | None = None, + now: datetime | None = None, ) -> list[dict[str, Any]]: """Collapse multiple on-disk checkouts of the same repo to one canonical project. @@ -233,11 +237,13 @@ def _dedupe_checkouts_by_origin( canonical.append(project) for origin_key, group in by_origin.items(): - representative = _checkout_representative(group, origin_key) authority_group = _checkout_topology_group( group, workspace_root=workspace_root, + catalog_data=catalog_data, + now=now, ) + representative = _checkout_representative(authority_group, origin_key) has_checkout_declarations = any( _checkout_observation(project).get("declared_paths") for project in authority_group @@ -262,10 +268,14 @@ def _checkout_topology_group( group: list[dict[str, Any]], *, workspace_root: Path | None, + catalog_data: dict[str, Any] | None, + now: datetime | None, ) -> list[dict[str, Any]]: - """Add in-workspace linked worktrees as authority evidence, not projects.""" + """Add linked worktrees to authority without duplicating logical projects.""" if workspace_root is None: return list(group) + if catalog_data is None or now is None: + raise ValueError("topology expansion requires discovery inputs") resolved_root = workspace_root.resolve() expanded = list(group) @@ -285,20 +295,14 @@ def _checkout_topology_group( ): continue known_paths.add(resolved_path) - relative_path = resolved_path.relative_to(resolved_root).as_posix() - expanded.append( - { - "name": resolved_path.name, - "repo_full_name": source_project.get("repo_full_name", ""), - "path": relative_path, - "project_path": resolved_path, - "_checkout_observation": _observe_checkout( - resolved_path, - workspace_root=resolved_root, - ), - "_checkout_evidence_only": True, - } + linked_project = _inspect_project_dir( + resolved_path, + resolved_root, + catalog_data=catalog_data, + now=now, ) + linked_project["repo_full_name"] = source_project.get("repo_full_name", "") + expanded.append(linked_project) return expanded diff --git a/src/portfolio_truth_validate.py b/src/portfolio_truth_validate.py index 3b0019a8..51c98eb6 100644 --- a/src/portfolio_truth_validate.py +++ b/src/portfolio_truth_validate.py @@ -176,10 +176,8 @@ def _validate_checkout_collisions(snapshot: PortfolioTruthSnapshot) -> None: checkout_count = _require_nonnegative_count(group, "checkout_count") full_clone_count = _require_nonnegative_count(group, "full_clone_count") - if checkout_count < 2: - raise ValueError( - "Checkout collision groups require at least two checkouts." - ) + if checkout_count < 1: + raise ValueError("Checkout authority groups require at least one checkout.") if not 1 <= full_clone_count <= checkout_count: raise ValueError("Checkout collision full_clone_count is out of range.") full_clone_groups += int(full_clone_count > 1) @@ -340,6 +338,10 @@ def _validate_checkout_collisions(snapshot: PortfolioTruthSnapshot) -> None: raise ValueError( "Declared checkout paths do not match declared path evidence." ) + if checkout_count == 1 and not (declared_evidence or unresolved_paths): + raise ValueError( + "Single-checkout authority groups require declared path evidence." + ) project = project_by_origin.get(origin_key) if project is None: diff --git a/tests/test_portfolio_truth.py b/tests/test_portfolio_truth.py index 683fdb40..fe4fc231 100644 --- a/tests/test_portfolio_truth.py +++ b/tests/test_portfolio_truth.py @@ -22,6 +22,7 @@ apply_context_recovery_plan, build_context_recovery_plan, ) +from src.portfolio_checkout_authority import checkout_authority_blocker from src.portfolio_truth_publish import ( PortfolioTruthPublishError, publish_portfolio_truth, @@ -673,6 +674,135 @@ def test_checkout_collision_flows_through_truth_validation_and_report( validate_truth_snapshot(result.snapshot) +def test_unresolved_declared_checkout_flows_through_truth_validation( + portfolio_workspace: Path, + portfolio_catalog: Path, + legacy_registry: Path, +) -> None: + repo = portfolio_workspace / "Repo" + repo.mkdir() + missing_target = portfolio_workspace / "_codex-worktrees" / "repo-retired" / "src" + _write( + repo / "AGENTS.md", + "# Repo\n\n## Canonical Paths\n\n" + f"- Source: `{missing_target}`\n", + ) + subprocess.run(["git", "init", "-q", "-b", "main"], cwd=repo, check=True) + subprocess.run( + ["git", "remote", "add", "origin", "git@github.com:owner/Repo.git"], + cwd=repo, + check=True, + ) + subprocess.run(["git", "add", "AGENTS.md"], cwd=repo, check=True) + subprocess.run( + [ + "git", + "-c", + "user.name=Test", + "-c", + "user.email=test@example.invalid", + "commit", + "-q", + "-m", + "fixture", + ], + cwd=repo, + check=True, + ) + + result = build_portfolio_truth_snapshot( + workspace_root=portfolio_workspace, + catalog_path=portfolio_catalog, + legacy_registry_path=legacy_registry, + include_notion=False, + now=datetime(2026, 8, 3, tzinfo=timezone.utc), + ) + + project = next( + item + for item in result.snapshot.projects + if item.identity.repo_full_name == "owner/Repo" + ) + authority = project.repository_state["checkout_authority"] + assert authority["checkout_count"] == 1 + assert authority["selection"]["state"] == "unknown" + assert ( + authority["selection"]["reason_code"] + == "declared_checkout_path_unresolved" + ) + assert authority["unresolved_declared_paths"] == [ + "_codex-worktrees/repo-retired/src" + ] + validate_truth_snapshot(result.snapshot) + + +def test_bare_coordinator_worktree_flows_through_truth_validation( + portfolio_workspace: Path, + portfolio_catalog: Path, + legacy_registry: Path, +) -> None: + seed = portfolio_workspace / "_backups" / "seed" + seed.mkdir(parents=True) + _write(seed / "README.md", "# Repo\n") + subprocess.run(["git", "init", "-q", "-b", "main"], cwd=seed, check=True) + subprocess.run(["git", "add", "README.md"], cwd=seed, check=True) + subprocess.run( + [ + "git", + "-c", + "user.name=Test", + "-c", + "user.email=test@example.invalid", + "commit", + "-q", + "-m", + "fixture", + ], + cwd=seed, + check=True, + ) + coordinator = portfolio_workspace / "Repo" + subprocess.run( + ["git", "clone", "-q", "--bare", str(seed), str(coordinator)], + check=True, + ) + subprocess.run( + ["git", "remote", "set-url", "origin", "git@github.com:owner/Repo.git"], + cwd=coordinator, + check=True, + ) + linked = portfolio_workspace / "_codex-worktrees" / "repo-main" + linked.parent.mkdir() + subprocess.run( + ["git", "worktree", "add", "-q", str(linked), "main"], + cwd=coordinator, + check=True, + ) + + result = build_portfolio_truth_snapshot( + workspace_root=portfolio_workspace, + catalog_path=portfolio_catalog, + legacy_registry_path=legacy_registry, + include_notion=False, + now=datetime(2026, 8, 3, tzinfo=timezone.utc), + ) + + project = next( + item + for item in result.snapshot.projects + if item.identity.repo_full_name == "owner/Repo" + ) + authority = project.repository_state["checkout_authority"] + assert project.identity.path == "_codex-worktrees/repo-main" + assert authority["selection"]["state"] == "selected" + assert authority["selection"]["selected_path"] == project.identity.path + assert checkout_authority_blocker( + project, + workspace_root=portfolio_workspace, + ) is None + validate_truth_snapshot(result.snapshot) + + def test_live_catalog_produces_exact_tier_zero_attention_semantics( tmp_path: Path, ) -> None: diff --git a/tests/test_portfolio_truth_sources.py b/tests/test_portfolio_truth_sources.py index e338f09d..def7decb 100644 --- a/tests/test_portfolio_truth_sources.py +++ b/tests/test_portfolio_truth_sources.py @@ -624,6 +624,71 @@ def test_discovery_observes_dirty_worktree_inside_excluded_container( assert linked_evidence["dirty"] is True +def test_bare_coordinator_uses_worktree_inside_excluded_container( + tmp_path: Path, +) -> None: + seed = tmp_path / "_backups" / "seed" + seed.mkdir(parents=True) + subprocess.run(["git", "init", "-q", "-b", "main"], cwd=seed, check=True) + (seed / "README.md").write_text("# Repo\n") + subprocess.run(["git", "add", "README.md"], cwd=seed, check=True) + subprocess.run( + [ + "git", + "-c", + "user.name=Test", + "-c", + "user.email=test@example.invalid", + "commit", + "-q", + "-m", + "fixture", + ], + cwd=seed, + check=True, + ) + coordinator = tmp_path / "Repo" + subprocess.run( + ["git", "clone", "-q", "--bare", str(seed), str(coordinator)], + check=True, + ) + subprocess.run( + ["git", "remote", "set-url", "origin", "git@github.com:owner/Repo.git"], + cwd=coordinator, + check=True, + ) + linked = tmp_path / "_codex-worktrees" / "repo-main" + linked.parent.mkdir() + subprocess.run( + ["git", "worktree", "add", "-q", str(linked), "main"], + cwd=coordinator, + check=True, + ) + + collisions: list[dict] = [] + projects = discover_workspace_projects( + tmp_path, + catalog_data={}, + now=datetime(2026, 8, 3, tzinfo=timezone.utc), + checkout_collisions=collisions, + ) + + repo_projects = [ + project for project in projects if project["repo_full_name"] == "owner/Repo" + ] + assert len(repo_projects) == 1 + assert repo_projects[0]["path"] == "_codex-worktrees/repo-main" + collision = collisions[0] + assert collision["selection"]["state"] == "selected" + assert collision["selection"]["representative_path"] == ( + "_codex-worktrees/repo-main" + ) + representative = next( + item for item in collision["checkouts"] if item["relation"] == "representative" + ) + assert representative["bare"] is False + + def test_discovery_observes_conflicting_full_clones_without_count_inflation( tmp_path, ) -> None: From 1b2cb07f0eb34c807123b24cbd2c9f34322db531 Mon Sep 17 00:00:00 2001 From: saagpatel Date: Mon, 3 Aug 2026 18:29:06 -0700 Subject: [PATCH 10/17] fix(portfolio): preserve unknown topology evidence --- src/portfolio_truth_sources.py | 31 +++++-- tests/test_portfolio_truth.py | 151 +++++++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+), 6 deletions(-) diff --git a/src/portfolio_truth_sources.py b/src/portfolio_truth_sources.py index aa0f7a39..0e47a322 100644 --- a/src/portfolio_truth_sources.py +++ b/src/portfolio_truth_sources.py @@ -295,12 +295,20 @@ def _checkout_topology_group( ): continue known_paths.add(resolved_path) - linked_project = _inspect_project_dir( - resolved_path, - resolved_root, - catalog_data=catalog_data, - now=now, - ) + try: + linked_project = _inspect_project_dir( + resolved_path, + resolved_root, + catalog_data=catalog_data, + now=now, + ) + except OSError: + linked_project = { + "name": resolved_path.name, + "path": resolved_path.relative_to(resolved_root).as_posix(), + "project_path": resolved_path, + "_checkout_observation": _checkout_observation({}), + } linked_project["repo_full_name"] = source_project.get("repo_full_name", "") expanded.append(linked_project) return expanded @@ -535,6 +543,9 @@ def _declared_checkout_evidence( for source_project in group: observation = _checkout_observation(source_project) for declaration in observation.get("declared_paths") or []: + if declaration.get("scope") == "outside_workspace": + unresolved.add(str(declaration["workspace_relative_path"])) + continue target = Path(str(declaration["absolute_path"])) candidates = [ project @@ -1084,6 +1095,14 @@ def _declared_canonical_paths( for match in _ABSOLUTE_CODE_PATH.finditer(line): candidate = Path(match.group("path")).resolve() if not _path_is_within(candidate, resolved_workspace): + declarations.append( + { + "source_file": source_file, + "absolute_path": "", + "workspace_relative_path": "external-checkout", + "scope": "outside_workspace", + } + ) continue declarations.append( { diff --git a/tests/test_portfolio_truth.py b/tests/test_portfolio_truth.py index fe4fc231..19de1d53 100644 --- a/tests/test_portfolio_truth.py +++ b/tests/test_portfolio_truth.py @@ -736,6 +736,157 @@ def test_unresolved_declared_checkout_flows_through_truth_validation( validate_truth_snapshot(result.snapshot) +def test_external_declared_checkout_is_opaque_unknown( + portfolio_workspace: Path, + portfolio_catalog: Path, + legacy_registry: Path, +) -> None: + repo = portfolio_workspace / "ExternalRepo" + repo.mkdir() + external_root = portfolio_workspace.parent / "outside" + external_root.mkdir() + escape = portfolio_workspace / "escape" + escape.symlink_to(external_root, target_is_directory=True) + external_target = escape / "ExternalRepo" / "src" + _write( + repo / "AGENTS.md", + "# ExternalRepo\n\n## Canonical Paths\n\n" + f"- Source: `{external_target}`\n", + ) + subprocess.run(["git", "init", "-q", "-b", "main"], cwd=repo, check=True) + subprocess.run( + [ + "git", + "remote", + "add", + "origin", + "git@github.com:owner/ExternalRepo.git", + ], + cwd=repo, + check=True, + ) + subprocess.run(["git", "add", "AGENTS.md"], cwd=repo, check=True) + subprocess.run( + [ + "git", + "-c", + "user.name=Test", + "-c", + "user.email=test@example.invalid", + "commit", + "-q", + "-m", + "fixture", + ], + cwd=repo, + check=True, + ) + + result = build_portfolio_truth_snapshot( + workspace_root=portfolio_workspace, + catalog_path=portfolio_catalog, + legacy_registry_path=legacy_registry, + include_notion=False, + now=datetime(2026, 8, 3, tzinfo=timezone.utc), + ) + + project = next( + item + for item in result.snapshot.projects + if item.identity.repo_full_name == "owner/ExternalRepo" + ) + authority = project.repository_state["checkout_authority"] + assert authority["selection"]["state"] == "unknown" + assert ( + authority["selection"]["reason_code"] + == "declared_checkout_path_unresolved" + ) + assert authority["unresolved_declared_paths"] == ["external-checkout"] + assert str(external_target) not in json.dumps(authority) + assert str(external_target.resolve()) not in json.dumps(authority) + assert checkout_authority_blocker( + project, + workspace_root=portfolio_workspace, + ) == "checkout-authority-unknown:declared_checkout_path_unresolved" + validate_truth_snapshot(result.snapshot) + + +def test_prunable_linked_worktree_is_unknown_not_publication_failure( + portfolio_workspace: Path, + portfolio_catalog: Path, + legacy_registry: Path, +) -> None: + repo = portfolio_workspace / "PrunableRepo" + repo.mkdir() + _write(repo / "README.md", "# PrunableRepo\n") + subprocess.run(["git", "init", "-q", "-b", "main"], cwd=repo, check=True) + subprocess.run( + [ + "git", + "remote", + "add", + "origin", + "git@github.com:owner/PrunableRepo.git", + ], + cwd=repo, + check=True, + ) + subprocess.run(["git", "add", "README.md"], cwd=repo, check=True) + subprocess.run( + [ + "git", + "-c", + "user.name=Test", + "-c", + "user.email=test@example.invalid", + "commit", + "-q", + "-m", + "fixture", + ], + cwd=repo, + check=True, + ) + linked = portfolio_workspace / "_codex-worktrees" / "prunable-repo" + linked.parent.mkdir() + subprocess.run( + ["git", "worktree", "add", "-q", "-b", "feature", str(linked), "HEAD"], + cwd=repo, + check=True, + ) + preserved = portfolio_workspace / "_backups" / "prunable-repo" + preserved.parent.mkdir() + linked.rename(preserved) + + result = build_portfolio_truth_snapshot( + workspace_root=portfolio_workspace, + catalog_path=portfolio_catalog, + legacy_registry_path=legacy_registry, + include_notion=False, + now=datetime(2026, 8, 3, tzinfo=timezone.utc), + ) + + project = next( + item + for item in result.snapshot.projects + if item.identity.repo_full_name == "owner/PrunableRepo" + ) + authority = project.repository_state["checkout_authority"] + assert authority["selection"]["state"] == "unknown" + assert authority["selection"]["reason_code"] == "checkout_observation_failed" + missing = next( + item + for item in authority["discarded_checkouts"] + if item["path"] == "_codex-worktrees/prunable-repo" + ) + assert missing["state"] == "unknown" + assert checkout_authority_blocker( + project, + workspace_root=portfolio_workspace, + ) == "checkout-authority-unknown:checkout_observation_failed" + validate_truth_snapshot(result.snapshot) + + def test_bare_coordinator_worktree_flows_through_truth_validation( portfolio_workspace: Path, portfolio_catalog: Path, From b55ba50078502a90b22e393fd6d59a186566e72e Mon Sep 17 00:00:00 2001 From: saagpatel Date: Mon, 3 Aug 2026 18:44:29 -0700 Subject: [PATCH 11/17] Preserve external worktree authority evidence --- src/portfolio_truth_sources.py | 50 ++++++++++++-- tests/test_portfolio_truth.py | 94 +++++++++++++++++++++++++++ tests/test_portfolio_truth_sources.py | 70 ++++++++++++++++++++ 3 files changed, 210 insertions(+), 4 deletions(-) diff --git a/src/portfolio_truth_sources.py b/src/portfolio_truth_sources.py index 0e47a322..4d849ca8 100644 --- a/src/portfolio_truth_sources.py +++ b/src/portfolio_truth_sources.py @@ -282,6 +282,8 @@ def _checkout_topology_group( known_paths = { Path(str(project["project_path"])).resolve() for project in group } + published_paths = {str(project.get("path") or "") for project in group} + external_worktree_count = 0 for source_project in group: project_path = Path(str(source_project["project_path"])) try: @@ -290,11 +292,41 @@ def _checkout_topology_group( continue for worktree_path in worktree_paths: resolved_path = worktree_path.resolve() - if resolved_path in known_paths or not _path_is_within( - resolved_path, resolved_root - ): + if resolved_path in known_paths: continue known_paths.add(resolved_path) + if not _path_is_within(resolved_path, resolved_root): + external_worktree_count += 1 + opaque_path = ( + "external-worktree" + if external_worktree_count == 1 + else f"external-worktree-{external_worktree_count}" + ) + while opaque_path in published_paths: + external_worktree_count += 1 + opaque_path = f"external-worktree-{external_worktree_count}" + published_paths.add(opaque_path) + source_observation = _checkout_observation(source_project) + expanded.append( + { + "name": opaque_path, + "path": opaque_path, + "project_path": resolved_root / opaque_path, + "repo_full_name": source_project.get("repo_full_name", ""), + "_external_worktree": True, + "_checkout_observation": { + "state": "unknown", + "head": None, + "branch": None, + "dirty": None, + "dirty_path_count": None, + "git_common_dir": source_observation.get("git_common_dir"), + "bare": None, + "declared_paths": [], + }, + } + ) + continue try: linked_project = _inspect_project_dir( resolved_path, @@ -397,6 +429,9 @@ def _checkout_collision_record( and _checkout_observation(project).get("git_common_dir") for project in group ) + has_external_worktree = any( + project.get("_external_worktree") is True for project in group + ) conflicting_heads = len(clone_heads) > 1 or "" in clone_heads state = "selected" @@ -406,6 +441,10 @@ def _checkout_collision_record( state = "unknown" reason_code = "checkout_observation_failed" reason = "one or more same-origin checkouts could not be observed completely" + elif has_external_worktree: + state = "unknown" + reason_code = "external_linked_worktree_unobserved" + reason = "one or more linked worktrees are outside the observed workspace" elif len(declared_checkout_paths) > 1: state = "unknown" reason_code = "conflicting_declared_checkout_paths" @@ -487,7 +526,10 @@ def _checkout_collision_record( def _checkout_clone_key(project: dict[str, Any]) -> str: observation = _checkout_observation(project) common_dir = str(observation.get("git_common_dir") or "") - if observation.get("state") == "observed" and common_dir: + if common_dir and ( + observation.get("state") == "observed" + or project.get("_external_worktree") is True + ): return f"observed:{common_dir}" return f"unknown:{project.get('path', '')}" diff --git a/tests/test_portfolio_truth.py b/tests/test_portfolio_truth.py index 19de1d53..d621c2fd 100644 --- a/tests/test_portfolio_truth.py +++ b/tests/test_portfolio_truth.py @@ -811,6 +811,100 @@ def test_external_declared_checkout_is_opaque_unknown( validate_truth_snapshot(result.snapshot) +def test_external_linked_worktree_flows_through_truth_validation_and_report( + portfolio_workspace: Path, + portfolio_catalog: Path, + legacy_registry: Path, +) -> None: + repo = portfolio_workspace / "ExternalWorktreeRepo" + repo.mkdir() + _write(repo / "README.md", "# ExternalWorktreeRepo\n") + subprocess.run(["git", "init", "-q", "-b", "main"], cwd=repo, check=True) + subprocess.run( + [ + "git", + "remote", + "add", + "origin", + "git@github.com:owner/ExternalWorktreeRepo.git", + ], + cwd=repo, + check=True, + ) + subprocess.run(["git", "add", "README.md"], cwd=repo, check=True) + subprocess.run( + [ + "git", + "-c", + "user.name=Test", + "-c", + "user.email=test@example.invalid", + "commit", + "-q", + "-m", + "fixture", + ], + cwd=repo, + check=True, + ) + external = ( + portfolio_workspace.parent + / "external-worktree-path-must-not-be-published" + ) + subprocess.run( + ["git", "worktree", "add", "-q", "-b", "external", str(external), "HEAD"], + cwd=repo, + check=True, + ) + + result = build_portfolio_truth_snapshot( + workspace_root=portfolio_workspace, + catalog_path=portfolio_catalog, + legacy_registry_path=legacy_registry, + include_notion=False, + now=datetime(2026, 8, 3, tzinfo=timezone.utc), + ) + + project = next( + item + for item in result.snapshot.projects + if item.identity.repo_full_name == "owner/ExternalWorktreeRepo" + ) + authority = project.repository_state["checkout_authority"] + assert authority["checkout_count"] == 2 + assert authority["full_clone_count"] == 1 + assert authority["selection"]["state"] == "unknown" + assert ( + authority["selection"]["reason_code"] + == "external_linked_worktree_unobserved" + ) + assert authority["discarded_checkouts"] == [ + { + "path": "external-worktree", + "state": "unknown", + "relation": "linked_worktree", + "head": None, + "branch": None, + "dirty": None, + "dirty_path_count": None, + "bare": None, + } + ] + assert str(external) not in json.dumps(authority) + assert checkout_authority_blocker( + project, + workspace_root=portfolio_workspace, + ) == "checkout-authority-unknown:external_linked_worktree_unobserved" + validate_truth_snapshot(result.snapshot) + + markdown = render_portfolio_report_markdown(result.snapshot, "output/x.json") + assert "`external_linked_worktree_unobserved`" in markdown + assert "`external-worktree`: `linked_worktree`" in markdown + assert "No same-origin checkout collisions were observed." not in markdown + assert str(external) not in markdown + validate_portfolio_report_markdown(markdown) + + def test_prunable_linked_worktree_is_unknown_not_publication_failure( portfolio_workspace: Path, portfolio_catalog: Path, diff --git a/tests/test_portfolio_truth_sources.py b/tests/test_portfolio_truth_sources.py index def7decb..aba9eb9a 100644 --- a/tests/test_portfolio_truth_sources.py +++ b/tests/test_portfolio_truth_sources.py @@ -624,6 +624,76 @@ def test_discovery_observes_dirty_worktree_inside_excluded_container( assert linked_evidence["dirty"] is True +def test_discovery_preserves_opaque_external_linked_worktree_evidence( + tmp_path: Path, +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + repo = workspace / "Repo" + repo.mkdir() + subprocess.run(["git", "init", "-q", "-b", "main"], cwd=repo, check=True) + subprocess.run( + ["git", "remote", "add", "origin", "git@github.com:owner/Repo.git"], + cwd=repo, + check=True, + ) + (repo / "README.md").write_text("# Repo\n") + subprocess.run(["git", "add", "README.md"], cwd=repo, check=True) + subprocess.run( + [ + "git", + "-c", + "user.name=Test", + "-c", + "user.email=test@example.invalid", + "commit", + "-q", + "-m", + "fixture", + ], + cwd=repo, + check=True, + ) + external = tmp_path / "external-worktree-path-must-not-be-published" + subprocess.run( + ["git", "worktree", "add", "-q", "-b", "external", str(external), "HEAD"], + cwd=repo, + check=True, + ) + + collisions: list[dict] = [] + projects = discover_workspace_projects( + workspace, + catalog_data={}, + now=datetime(2026, 8, 3, tzinfo=timezone.utc), + checkout_collisions=collisions, + ) + + assert len(projects) == 1 + collision = collisions[0] + assert collision["checkout_count"] == 2 + assert collision["full_clone_count"] == 1 + assert collision["selection"]["state"] == "unknown" + assert ( + collision["selection"]["reason_code"] + == "external_linked_worktree_unobserved" + ) + external_evidence = next( + item + for item in collision["discarded_checkouts"] + if item["path"] == "external-worktree" + ) + assert external_evidence["state"] == "unknown" + assert external_evidence["relation"] == "linked_worktree" + assert str(external) not in repr(collision) + + summary = checkout_collision_summary(collisions) + assert summary["state"] == "unknown" + assert summary["group_count"] == 1 + assert summary["ambiguous_group_count"] == 1 + assert summary["discarded_checkout_count"] == 1 + + def test_bare_coordinator_uses_worktree_inside_excluded_container( tmp_path: Path, ) -> None: From 28374b266417a721273d87647ef8ec8710df8040 Mon Sep 17 00:00:00 2001 From: saagpatel Date: Mon, 3 Aug 2026 18:53:10 -0700 Subject: [PATCH 12/17] Redact external repository topology --- src/portfolio_repository_state.py | 42 ++++++++++++++++++++++-- src/portfolio_truth_reconcile.py | 3 ++ tests/test_portfolio_repository_state.py | 42 ++++++++++++++++++++++++ tests/test_portfolio_truth.py | 1 + 4 files changed, 86 insertions(+), 2 deletions(-) diff --git a/src/portfolio_repository_state.py b/src/portfolio_repository_state.py index e0a11cf8..94db9c77 100644 --- a/src/portfolio_repository_state.py +++ b/src/portfolio_repository_state.py @@ -14,6 +14,7 @@ def observe_repository_state( *, observed_at: datetime, remote_default_branch: dict[str, Any] | None = None, + workspace_root: Path | None = None, ) -> dict[str, Any]: """Read local Git/worktree state without changing refs or exposing file names.""" remote = ( @@ -36,7 +37,7 @@ def observe_repository_state( } try: - worktrees = _observe_worktrees(path) + worktrees = _observe_worktrees(path, workspace_root=workspace_root) selection = _select_remote_default_worktree(worktrees, remote) topology = { "kind": repository_kind, @@ -166,10 +167,39 @@ def _observe_bare_coordinator(path: Path) -> dict[str, Any]: } -def _observe_worktrees(path: Path) -> list[dict[str, Any]]: +def _observe_worktrees( + path: Path, *, workspace_root: Path | None = None +) -> list[dict[str, Any]]: observed: list[dict[str, Any]] = [] + external_worktree_count = 0 for item in _worktrees(path): worktree_path = Path(item["path"]) + if workspace_root is not None and not _path_is_within( + worktree_path, workspace_root + ): + external_worktree_count += 1 + opaque_path = ( + "external-worktree" + if external_worktree_count == 1 + else f"external-worktree-{external_worktree_count}" + ) + observed.append( + { + "state": "unknown", + "reason_code": "external_worktree_outside_workspace", + "reason": ( + "the linked worktree is outside the observed workspace" + ), + "path": opaque_path, + "head": None, + "branch": None, + "detached": False, + "bare": bool(item.get("bare")), + "dirty": None, + "dirty_path_count": None, + } + ) + continue if item.get("bare"): observed.append( { @@ -213,6 +243,14 @@ def _observe_worktrees(path: Path) -> list[dict[str, Any]]: return observed +def _path_is_within(candidate: Path, root: Path) -> bool: + try: + candidate.resolve().relative_to(root.resolve()) + return True + except (OSError, ValueError): + return False + + def _select_remote_default_worktree( worktrees: list[dict[str, Any]], remote: dict[str, Any] ) -> dict[str, Any]: diff --git a/src/portfolio_truth_reconcile.py b/src/portfolio_truth_reconcile.py index 100b414e..996888c7 100644 --- a/src/portfolio_truth_reconcile.py +++ b/src/portfolio_truth_reconcile.py @@ -286,6 +286,7 @@ def build_portfolio_truth_snapshot( legacy_rows=legacy_rows, notion_context=notion_context, now=now, + workspace_root=workspace_root, release_count_by_name=release_count_by_name, security_alerts_by_name=security_alerts_by_name, repo_status_by_name=repo_status_by_name, @@ -855,6 +856,7 @@ def _build_truth_project( legacy_rows: dict[str, dict[str, str]], notion_context: dict[str, dict[str, str]], now: datetime, + workspace_root: Path, release_count_by_name: dict[str, int] | None = None, security_alerts_by_name: dict[str, dict] | None = None, repo_status_by_name: dict[str, dict] | None = None, @@ -1246,6 +1248,7 @@ def _build_truth_project( project_path, observed_at=now, remote_default_branch=remote_default_branch, + workspace_root=workspace_root, ) if project_path is not None and has_git else { diff --git a/tests/test_portfolio_repository_state.py b/tests/test_portfolio_repository_state.py index d841e067..90baf840 100644 --- a/tests/test_portfolio_repository_state.py +++ b/tests/test_portfolio_repository_state.py @@ -5,6 +5,7 @@ from pathlib import Path from typing import Any +import src.portfolio_repository_state as repository_state from src.portfolio_repository_state import observe_repository_state @@ -86,6 +87,47 @@ def test_observation_reports_linked_worktree_without_file_names(tmp_path: Path) assert "untracked.txt" not in str(state) +def test_external_worktree_is_opaque_and_not_observed( + tmp_path: Path, + monkeypatch, +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + repo = _repo(workspace) + external = tmp_path / "external-worktree-path-must-not-be-published" + _git(repo, "worktree", "add", "-b", "external", str(external), "HEAD") + + original_observer = repository_state._observe_working_tree + observed_paths: list[Path] = [] + + def _record_observation(path: Path) -> dict[str, Any]: + observed_paths.append(path) + return original_observer(path) + + monkeypatch.setattr( + repository_state, + "_observe_working_tree", + _record_observation, + ) + + state = observe_repository_state( + repo, + observed_at=datetime.now(UTC), + workspace_root=workspace, + ) + + assert observed_paths + assert set(observed_paths) == {repo} + assert external not in observed_paths + external_state = next( + item for item in state["worktrees"] if item["path"] == "external-worktree" + ) + assert external_state["state"] == "unknown" + assert external_state["reason_code"] == "external_worktree_outside_workspace" + assert external_state["dirty"] is None + assert str(external) not in str(state) + + def test_dangling_bare_head_uses_clean_matching_linked_worktree( tmp_path: Path, ) -> None: diff --git a/tests/test_portfolio_truth.py b/tests/test_portfolio_truth.py index d621c2fd..9ac7ca8a 100644 --- a/tests/test_portfolio_truth.py +++ b/tests/test_portfolio_truth.py @@ -891,6 +891,7 @@ def test_external_linked_worktree_flows_through_truth_validation_and_report( } ] assert str(external) not in json.dumps(authority) + assert str(external) not in json.dumps(result.snapshot.to_dict()) assert checkout_authority_blocker( project, workspace_root=portfolio_workspace, From b18f41a8a853f3582eccde198de255f7786a1188 Mon Sep 17 00:00:00 2001 From: saagpatel Date: Mon, 3 Aug 2026 19:03:04 -0700 Subject: [PATCH 13/17] Fail closed on incomplete checkout topology --- src/portfolio_truth_reconcile.py | 2 +- src/portfolio_truth_sources.py | 31 ++++- src/portfolio_truth_validate.py | 11 +- tests/test_portfolio_repository_state.py | 21 +-- tests/test_portfolio_truth.py | 167 +++++++++++++++++++++++ 5 files changed, 214 insertions(+), 18 deletions(-) diff --git a/src/portfolio_truth_reconcile.py b/src/portfolio_truth_reconcile.py index 996888c7..88fe6cfb 100644 --- a/src/portfolio_truth_reconcile.py +++ b/src/portfolio_truth_reconcile.py @@ -346,7 +346,7 @@ def build_portfolio_truth_snapshot( ] if ambiguous_checkout_origins: warnings.append( - "Checkout authority is UNKNOWN for same-origin full-clone groups: " + "Checkout authority is UNKNOWN for same-origin checkout groups: " + ", ".join(ambiguous_checkout_origins) ) diff --git a/src/portfolio_truth_sources.py b/src/portfolio_truth_sources.py index 4d849ca8..b9dd9d76 100644 --- a/src/portfolio_truth_sources.py +++ b/src/portfolio_truth_sources.py @@ -248,7 +248,15 @@ def _dedupe_checkouts_by_origin( _checkout_observation(project).get("declared_paths") for project in authority_group ) - if len(authority_group) > 1 or has_checkout_declarations: + has_topology_failure = any( + project.get("_worktree_enumeration_failed") is True + for project in authority_group + ) + if ( + len(authority_group) > 1 + or has_checkout_declarations + or has_topology_failure + ): collision = _checkout_collision_record( origin=str(representative.get("repo_full_name") or origin_key), origin_key=origin_key, @@ -288,7 +296,13 @@ def _checkout_topology_group( project_path = Path(str(source_project["project_path"])) try: worktree_paths = _git_worktree_paths(project_path) - except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired): + except ( + OSError, + ValueError, + subprocess.CalledProcessError, + subprocess.TimeoutExpired, + ): + source_project["_worktree_enumeration_failed"] = True continue for worktree_path in worktree_paths: resolved_path = worktree_path.resolve() @@ -432,19 +446,26 @@ def _checkout_collision_record( has_external_worktree = any( project.get("_external_worktree") is True for project in group ) + has_topology_failure = any( + project.get("_worktree_enumeration_failed") is True for project in group + ) conflicting_heads = len(clone_heads) > 1 or "" in clone_heads state = "selected" reason_code = "single_clone_topology" reason = "all discovered checkouts share one Git common directory" - if len(clone_groups) > 1 and not observations_complete: + if has_topology_failure: state = "unknown" - reason_code = "checkout_observation_failed" - reason = "one or more same-origin checkouts could not be observed completely" + reason_code = "worktree_enumeration_failed" + reason = "linked-worktree topology could not be enumerated" elif has_external_worktree: state = "unknown" reason_code = "external_linked_worktree_unobserved" reason = "one or more linked worktrees are outside the observed workspace" + elif not observations_complete: + state = "unknown" + reason_code = "checkout_observation_failed" + reason = "one or more same-origin checkouts could not be observed completely" elif len(declared_checkout_paths) > 1: state = "unknown" reason_code = "conflicting_declared_checkout_paths" diff --git a/src/portfolio_truth_validate.py b/src/portfolio_truth_validate.py index 51c98eb6..d1eabe85 100644 --- a/src/portfolio_truth_validate.py +++ b/src/portfolio_truth_validate.py @@ -338,9 +338,16 @@ def _validate_checkout_collisions(snapshot: PortfolioTruthSnapshot) -> None: raise ValueError( "Declared checkout paths do not match declared path evidence." ) - if checkout_count == 1 and not (declared_evidence or unresolved_paths): + topology_failure = ( + state == "unknown" + and selection.get("reason_code") == "worktree_enumeration_failed" + ) + if checkout_count == 1 and not ( + declared_evidence or unresolved_paths or topology_failure + ): raise ValueError( - "Single-checkout authority groups require declared path evidence." + "Single-checkout authority groups require declaration or " + "topology-failure evidence." ) project = project_by_origin.get(origin_key) diff --git a/tests/test_portfolio_repository_state.py b/tests/test_portfolio_repository_state.py index 90baf840..0408b2a7 100644 --- a/tests/test_portfolio_repository_state.py +++ b/tests/test_portfolio_repository_state.py @@ -6,7 +6,6 @@ from typing import Any import src.portfolio_repository_state as repository_state -from src.portfolio_repository_state import observe_repository_state def _git(path: Path, *args: str) -> str: @@ -59,7 +58,7 @@ def test_observation_reports_dirty_no_upstream_and_unknown_remote( repo = _repo(tmp_path) (repo / "dirty.txt").write_text("dirty\n") - state = observe_repository_state( + state = repository_state.observe_repository_state( repo, observed_at=datetime(2026, 7, 12, tzinfo=UTC) ) @@ -76,7 +75,9 @@ def test_observation_reports_linked_worktree_without_file_names(tmp_path: Path) _git(repo, "worktree", "add", "-b", "feature", str(linked), "HEAD") (linked / "untracked.txt").write_text("preserve\n") - state = observe_repository_state(repo, observed_at=datetime.now(UTC)) + state = repository_state.observe_repository_state( + repo, observed_at=datetime.now(UTC) + ) assert len(state["worktrees"]) == 2 linked_state = next( @@ -110,7 +111,7 @@ def _record_observation(path: Path) -> dict[str, Any]: _record_observation, ) - state = observe_repository_state( + state = repository_state.observe_repository_state( repo, observed_at=datetime.now(UTC), workspace_root=workspace, @@ -136,7 +137,7 @@ def test_dangling_bare_head_uses_clean_matching_linked_worktree( _git(bare, "worktree", "add", str(linked), "main") _git(bare, "symbolic-ref", "HEAD", "refs/heads/missing-default") - state = observe_repository_state( + state = repository_state.observe_repository_state( bare, observed_at=datetime(2026, 7, 12, tzinfo=UTC), remote_default_branch=_remote_default(head), @@ -159,7 +160,7 @@ def test_bare_coordinator_selects_unique_clean_remote_default_worktree( linked = tmp_path / "linked" _git(bare, "worktree", "add", str(linked), "main") - state = observe_repository_state( + state = repository_state.observe_repository_state( bare, observed_at=datetime(2026, 7, 12, tzinfo=UTC), remote_default_branch=_remote_default(head), @@ -182,7 +183,7 @@ def test_multiple_remote_head_candidates_use_default_branch_tiebreak( detached = tmp_path / "detached" _git(repo, "worktree", "add", "--detach", str(detached), head) - state = observe_repository_state( + state = repository_state.observe_repository_state( repo, observed_at=datetime(2026, 7, 12, tzinfo=UTC), remote_default_branch=_remote_default(head), @@ -216,7 +217,7 @@ def test_recovery_tracking_does_not_impersonate_remote_default( ) _git(repo, "branch", "--set-upstream-to=origin/recovery/repo-main", "main") - state = observe_repository_state( + state = repository_state.observe_repository_state( repo, observed_at=datetime(2026, 7, 12, tzinfo=UTC), remote_default_branch=_remote_default(remote_head), @@ -236,7 +237,7 @@ def test_bare_coordinator_missing_remote_evidence_is_precise_unknown( linked = tmp_path / "linked" _git(bare, "worktree", "add", str(linked), "main") - state = observe_repository_state( + state = repository_state.observe_repository_state( bare, observed_at=datetime(2026, 7, 12, tzinfo=UTC), ) @@ -255,7 +256,7 @@ def test_ambiguous_remote_default_worktrees_fail_closed(tmp_path: Path) -> None: _git(bare, "worktree", "add", "--detach", str(first), head) _git(bare, "worktree", "add", "--detach", str(second), head) - state = observe_repository_state( + state = repository_state.observe_repository_state( bare, observed_at=datetime(2026, 7, 12, tzinfo=UTC), remote_default_branch=_remote_default(head), diff --git a/tests/test_portfolio_truth.py b/tests/test_portfolio_truth.py index 9ac7ca8a..2d6eb665 100644 --- a/tests/test_portfolio_truth.py +++ b/tests/test_portfolio_truth.py @@ -35,6 +35,7 @@ from src.portfolio_truth_sources import ( _classify_context_quality, _extract_github_full_name, + _git_read, _git_remote_full_name, load_safe_notion_project_context, ) @@ -736,6 +737,172 @@ def test_unresolved_declared_checkout_flows_through_truth_validation( validate_truth_snapshot(result.snapshot) +def test_failed_singleton_observation_with_declaration_is_valid_unknown( + portfolio_workspace: Path, + portfolio_catalog: Path, + legacy_registry: Path, + monkeypatch, +) -> None: + repo = portfolio_workspace / "ObservationRepo" + repo.mkdir() + declared_target = repo / "src" + _write( + repo / "AGENTS.md", + "# ObservationRepo\n\n## Canonical Paths\n\n" + f"- Source: `{declared_target}`\n", + ) + subprocess.run(["git", "init", "-q", "-b", "main"], cwd=repo, check=True) + subprocess.run( + [ + "git", + "remote", + "add", + "origin", + "git@github.com:owner/ObservationRepo.git", + ], + cwd=repo, + check=True, + ) + subprocess.run(["git", "add", "AGENTS.md"], cwd=repo, check=True) + subprocess.run( + [ + "git", + "-c", + "user.name=Test", + "-c", + "user.email=test@example.invalid", + "commit", + "-q", + "-m", + "fixture", + ], + cwd=repo, + check=True, + ) + + def _timeout_status(project_path: Path, *args: str) -> str: + if args and args[0] == "status": + raise subprocess.TimeoutExpired(["git", "status"], timeout=5) + return _git_read(project_path, *args) + + monkeypatch.setattr( + "src.portfolio_truth_sources._git_read", + _timeout_status, + ) + + result = build_portfolio_truth_snapshot( + workspace_root=portfolio_workspace, + catalog_path=portfolio_catalog, + legacy_registry_path=legacy_registry, + include_notion=False, + now=datetime(2026, 8, 3, tzinfo=timezone.utc), + ) + + project = next( + item + for item in result.snapshot.projects + if item.identity.repo_full_name == "owner/ObservationRepo" + ) + authority = project.repository_state["checkout_authority"] + assert authority["checkout_count"] == 1 + assert authority["selection"]["state"] == "unknown" + assert authority["selection"]["reason_code"] == "checkout_observation_failed" + assert authority["selection"]["selected_path"] is None + assert authority["declared_checkout_paths"] == ["ObservationRepo"] + assert checkout_authority_blocker( + project, + workspace_root=portfolio_workspace, + ) == "checkout-authority-unknown:checkout_observation_failed" + validate_truth_snapshot(result.snapshot) + + +def test_worktree_enumeration_failure_is_explicit_unknown_summary( + portfolio_workspace: Path, + portfolio_catalog: Path, + legacy_registry: Path, + monkeypatch, +) -> None: + repo = portfolio_workspace / "TopologyRepo" + repo.mkdir() + _write(repo / "README.md", "# TopologyRepo\n") + subprocess.run(["git", "init", "-q", "-b", "main"], cwd=repo, check=True) + subprocess.run( + [ + "git", + "remote", + "add", + "origin", + "git@github.com:owner/TopologyRepo.git", + ], + cwd=repo, + check=True, + ) + subprocess.run(["git", "add", "README.md"], cwd=repo, check=True) + subprocess.run( + [ + "git", + "-c", + "user.name=Test", + "-c", + "user.email=test@example.invalid", + "commit", + "-q", + "-m", + "fixture", + ], + cwd=repo, + check=True, + ) + + def _timeout_worktree_enumeration(_project_path: Path) -> list[Path]: + raise subprocess.TimeoutExpired(["git", "worktree", "list"], timeout=5) + + monkeypatch.setattr( + "src.portfolio_truth_sources._git_worktree_paths", + _timeout_worktree_enumeration, + ) + + result = build_portfolio_truth_snapshot( + workspace_root=portfolio_workspace, + catalog_path=portfolio_catalog, + legacy_registry_path=legacy_registry, + include_notion=False, + now=datetime(2026, 8, 3, tzinfo=timezone.utc), + ) + + project = next( + item + for item in result.snapshot.projects + if item.identity.repo_full_name == "owner/TopologyRepo" + ) + authority = project.repository_state["checkout_authority"] + assert authority["checkout_count"] == 1 + assert authority["selection"]["state"] == "unknown" + assert authority["selection"]["reason_code"] == "worktree_enumeration_failed" + summary = result.snapshot.source_summary["checkout_collisions"] + assert summary["state"] == "unknown" + assert summary["group_count"] == 1 + assert summary["ambiguous_group_count"] == 1 + assert checkout_authority_blocker( + project, + workspace_root=portfolio_workspace, + ) == "checkout-authority-unknown:worktree_enumeration_failed" + assert any( + "same-origin checkout groups" in warning + for warning in result.snapshot.warnings + ) + assert all( + "same-origin full-clone groups" not in warning + for warning in result.snapshot.warnings + ) + validate_truth_snapshot(result.snapshot) + + markdown = render_portfolio_report_markdown(result.snapshot, "output/x.json") + assert "`worktree_enumeration_failed`" in markdown + assert "No same-origin checkout collisions were observed." not in markdown + validate_portfolio_report_markdown(markdown) + + def test_external_declared_checkout_is_opaque_unknown( portfolio_workspace: Path, portfolio_catalog: Path, From 3e5ac66b829443e5642f1afbe1e3be1806b4e99c Mon Sep 17 00:00:00 2001 From: saagpatel Date: Mon, 3 Aug 2026 19:20:18 -0700 Subject: [PATCH 14/17] Preserve canonical identity across worktrees --- src/automation_workflow.py | 7 ++- src/portfolio_checkout_authority.py | 51 +++++++++++++++++-- src/portfolio_context_recovery.py | 12 +++-- src/portfolio_truth_sources.py | 27 +++++++++- src/portfolio_truth_validate.py | 8 ++- src/run_instructions_audit.py | 7 ++- tests/test_automation_workflow.py | 34 +++++++++++++ tests/test_portfolio_truth.py | 73 +++++++++++++++++++++++++-- tests/test_portfolio_truth_sources.py | 5 +- tests/test_run_instructions_audit.py | 39 ++++++++++++++ 10 files changed, 243 insertions(+), 20 deletions(-) diff --git a/src/automation_workflow.py b/src/automation_workflow.py index d80b7612..55369f51 100644 --- a/src/automation_workflow.py +++ b/src/automation_workflow.py @@ -48,7 +48,10 @@ _suggested_catalog_seed, write_managed_context_block, ) -from src.portfolio_checkout_authority import checkout_authority_blocker +from src.portfolio_checkout_authority import ( + checkout_authority_blocker, + checkout_authority_path, +) from src.portfolio_truth_types import PortfolioTruthProject, PortfolioTruthSnapshot CONTRACT_VERSION = "automation_workflow_v1" @@ -94,7 +97,7 @@ def build_context_pr_plan( ) if authority_reason: raise AutomationExecutionError(authority_reason) - repo_path = workspace_root / project.identity.path + repo_path = workspace_root / checkout_authority_path(project) resolved_branch = default_branch or project.identity.default_branch or DEFAULT_DEFAULT_BRANCH display = project.identity.display_name commit_message = f"docs(context): refresh managed context block for {display}" diff --git a/src/portfolio_checkout_authority.py b/src/portfolio_checkout_authority.py index 403c17c9..ae395e17 100644 --- a/src/portfolio_checkout_authority.py +++ b/src/portfolio_checkout_authority.py @@ -38,12 +38,16 @@ def checkout_authority_blocker( return f"checkout-authority-unknown:{reason_code}" project_path = str(identity.get("path") or "") + declared_canonical_path = authority.get("canonical_project_path") + canonical_project_path = str(declared_canonical_path or project_path) selected_path = str(selection.get("selected_path") or "") representative_path = str(selection.get("representative_path") or "") if ( not project_path - or selected_path != project_path - or representative_path != project_path + or canonical_project_path != project_path + or not selected_path + or selected_path != representative_path + or (selected_path != project_path and not declared_canonical_path) ): return "checkout-authority-path-mismatch" @@ -68,13 +72,54 @@ def checkout_authority_blocker( if workspace_root is not None: try: resolved_root = workspace_root.resolve() - resolved_target = (workspace_root / project_path).resolve() + resolved_target = (workspace_root / selected_path).resolve() resolved_target.relative_to(resolved_root) except (OSError, ValueError): return "checkout-authority-path-escape" return _repository_topology_blocker(repository_state, authority=authority) +def checkout_authority_path(project: Any) -> str: + """Return the selected checkout path while keeping canonical project identity.""" + identity = _project_section(project, "identity") + project_path = str(identity.get("path") or "") + repository_state = _project_section(project, "repository_state") + authority = repository_state.get("checkout_authority") + if ( + not isinstance(authority, Mapping) + or authority.get("schema_version") != CHECKOUT_COLLISION_SCHEMA_VERSION + ): + return project_path + canonical_project_path = str( + authority.get("canonical_project_path") or project_path + ) + if canonical_project_path != project_path: + return project_path + selection = authority.get("selection") + if not isinstance(selection, Mapping) or selection.get("state") != "selected": + return project_path + selected_path = str(selection.get("selected_path") or "") + representative_path = str(selection.get("representative_path") or "") + checkouts = authority.get("checkouts") + if not isinstance(checkouts, list): + return project_path + selected_checkouts = [ + checkout + for checkout in checkouts + if isinstance(checkout, Mapping) and checkout.get("path") == selected_path + ] + if ( + selected_path + and selected_path == representative_path + and len(selected_checkouts) == 1 + and selected_checkouts[0].get("state") == "observed" + and selected_checkouts[0].get("relation") == "representative" + and selected_checkouts[0].get("bare") is False + ): + return selected_path + return project_path + + def _repository_topology_blocker( repository_state: Mapping[str, Any], *, diff --git a/src/portfolio_context_recovery.py b/src/portfolio_context_recovery.py index 32b99550..89b025c7 100644 --- a/src/portfolio_context_recovery.py +++ b/src/portfolio_context_recovery.py @@ -16,7 +16,10 @@ temporary_project_reason, upsert_managed_context_block, ) -from src.portfolio_checkout_authority import checkout_authority_blocker +from src.portfolio_checkout_authority import ( + checkout_authority_blocker, + checkout_authority_path, +) from src.portfolio_truth_types import ( PortfolioTruthProject, PortfolioTruthSnapshot, @@ -87,7 +90,8 @@ def build_context_recovery_plan( target_projects.sort(key=_recovery_priority) for index, project in enumerate(target_projects, start=1): - project_path = workspace_root / project.identity.path + execution_path = checkout_authority_path(project) + project_path = workspace_root / execution_path reason = temporary_project_reason( project.identity.project_key, project.identity.display_name ) @@ -121,7 +125,7 @@ def build_context_recovery_plan( priority_rank=index, project_key=project.identity.project_key, display_name=project.identity.display_name, - relative_path=project.identity.path, + relative_path=execution_path, activity_status=display_activity_status( project.derived.activity_status, archived=project.derived.archived ), @@ -202,7 +206,7 @@ def apply_context_recovery_plan( for target in eligible_targets: project = project_index[target.project_key] - project_path = workspace_root / project.identity.path + project_path = workspace_root / checkout_authority_path(project) try: authority_reason = checkout_authority_blocker( project, diff --git a/src/portfolio_truth_sources.py b/src/portfolio_truth_sources.py index b9dd9d76..507ab401 100644 --- a/src/portfolio_truth_sources.py +++ b/src/portfolio_truth_sources.py @@ -237,6 +237,7 @@ def _dedupe_checkouts_by_origin( canonical.append(project) for origin_key, group in by_origin.items(): + identity_project = _checkout_representative(group, origin_key) authority_group = _checkout_topology_group( group, workspace_root=workspace_root, @@ -244,6 +245,10 @@ def _dedupe_checkouts_by_origin( now=now, ) representative = _checkout_representative(authority_group, origin_key) + canonical_project = _canonical_checkout_project( + identity_project=identity_project, + representative=representative, + ) has_checkout_declarations = any( _checkout_observation(project).get("declared_paths") for project in authority_group @@ -262,11 +267,12 @@ def _dedupe_checkouts_by_origin( origin_key=origin_key, group=authority_group, representative=representative, + canonical_project_path=str(canonical_project.get("path") or ""), ) - representative["checkout_authority"] = collision + canonical_project["checkout_authority"] = collision if checkout_collisions is not None: checkout_collisions.append(collision) - canonical.append(representative) + canonical.append(canonical_project) canonical.sort(key=lambda p: str(p.get("name", "")).lower()) return canonical @@ -412,12 +418,28 @@ def _checkout_representative( ) +def _canonical_checkout_project( + *, + identity_project: dict[str, Any], + representative: dict[str, Any], +) -> dict[str, Any]: + """Keep discovered identity while observing the selected healthy checkout.""" + if identity_project is representative: + return representative + canonical = dict(representative) + for key in ("name", "path", "top_level_dir"): + if key in identity_project: + canonical[key] = identity_project[key] + return canonical + + def _checkout_collision_record( *, origin: str, origin_key: str, group: list[dict[str, Any]], representative: dict[str, Any], + canonical_project_path: str, ) -> dict[str, Any]: clone_groups: dict[str, list[dict[str, Any]]] = {} for project in group: @@ -522,6 +544,7 @@ def _checkout_collision_record( return { "schema_version": CHECKOUT_COLLISION_SCHEMA_VERSION, "origin": origin, + "canonical_project_path": canonical_project_path, "checkout_count": len(group), "full_clone_count": len(clone_groups), "declared_checkout_paths": declared_checkout_paths, diff --git a/src/portfolio_truth_validate.py b/src/portfolio_truth_validate.py index d1eabe85..6de53a68 100644 --- a/src/portfolio_truth_validate.py +++ b/src/portfolio_truth_validate.py @@ -202,6 +202,10 @@ def _validate_checkout_collisions(snapshot: PortfolioTruthSnapshot) -> None: selection.get("representative_path"), "checkout representative_path", ) + canonical_project_path = _require_relative_path( + group.get("canonical_project_path", representative_path), + "checkout canonical_project_path", + ) selected_path = selection.get("selected_path") if state == "unknown": ambiguous += 1 @@ -353,9 +357,9 @@ def _validate_checkout_collisions(snapshot: PortfolioTruthSnapshot) -> None: project = project_by_origin.get(origin_key) if project is None: raise ValueError(f"Checkout collision has no canonical project: {origin}") - if project.identity.path != representative_path: + if project.identity.path != canonical_project_path: raise ValueError( - "Canonical project path differs from collision representative." + "Canonical project path differs from collision identity." ) if project.repository_state.get("checkout_authority") != group: raise ValueError( diff --git a/src/run_instructions_audit.py b/src/run_instructions_audit.py index 33711c53..4384b957 100644 --- a/src/run_instructions_audit.py +++ b/src/run_instructions_audit.py @@ -14,7 +14,10 @@ from datetime import datetime from pathlib import Path -from src.portfolio_checkout_authority import checkout_authority_blocker +from src.portfolio_checkout_authority import ( + checkout_authority_blocker, + checkout_authority_path, +) from src.portfolio_context_contract import ( analyze_project_context, choose_primary_context_file, @@ -87,7 +90,7 @@ def select_pilot( def build_record(project: dict, workspace_root: str) -> dict: - path = project["identity"]["path"] + path = checkout_authority_path(project) derived = project["derived"] context_files = derived["context_files"] return { diff --git a/tests/test_automation_workflow.py b/tests/test_automation_workflow.py index 06e50bed..78f12c20 100644 --- a/tests/test_automation_workflow.py +++ b/tests/test_automation_workflow.py @@ -226,6 +226,40 @@ def test_context_pr_plan_blocks_selected_path_mismatch() -> None: build_context_pr_plan(project, workspace_root=Path("/ws")) +def test_context_pr_plan_uses_selected_checkout_without_replacing_identity() -> None: + project = _project( + display_name="Repo", + path="Repo", + repo_full_name="owner/Repo", + repository_state={ + "checkout_authority": { + "schema_version": "CheckoutCollisionV1", + "canonical_project_path": "Repo", + "selection": { + "state": "selected", + "reason_code": "single_clone_topology", + "representative_path": "_codex-worktrees/repo-main", + "selected_path": "_codex-worktrees/repo-main", + }, + "checkouts": [ + { + "path": "_codex-worktrees/repo-main", + "state": "observed", + "relation": "representative", + "bare": False, + } + ], + } + }, + ) + + plan = build_context_pr_plan(project, workspace_root=Path("/ws")) + + assert project.identity.display_name == "Repo" + assert project.identity.path == "Repo" + assert plan.repo_path == Path("/ws/_codex-worktrees/repo-main") + + def test_context_pr_plan_apply_change_writes_managed_block(tmp_path: Path) -> None: project = _project(path="MyRepo") repo_path = tmp_path / "MyRepo" diff --git a/tests/test_portfolio_truth.py b/tests/test_portfolio_truth.py index 2d6eb665..5debfc12 100644 --- a/tests/test_portfolio_truth.py +++ b/tests/test_portfolio_truth.py @@ -22,7 +22,10 @@ apply_context_recovery_plan, build_context_recovery_plan, ) -from src.portfolio_checkout_authority import checkout_authority_blocker +from src.portfolio_checkout_authority import ( + checkout_authority_blocker, + checkout_authority_path, +) from src.portfolio_truth_publish import ( PortfolioTruthPublishError, publish_portfolio_truth, @@ -1153,7 +1156,47 @@ def test_bare_coordinator_worktree_flows_through_truth_validation( portfolio_workspace: Path, portfolio_catalog: Path, legacy_registry: Path, + monkeypatch, ) -> None: + portfolio_catalog.write_text( + """ +defaults: + lifecycle_state: maintenance + criticality: medium + review_cadence: monthly + category: default-category + tool_provenance: unknown + +repos: + Repo: + owner: coordinator-owner + lifecycle_state: active + review_cadence: weekly + intended_disposition: maintain + tool_provenance: codex +""" + ) + legacy_registry.write_text( + """ +# Project Registry + +## Standalone Projects (Root Level) + +| Project | Status | Tool | Context Quality | Stack | Context Files | Category | Notes | +|---------|--------|------|-----------------|-------|---------------|----------|-------| +| Repo | parked | codex | standard | Python | README.md | legacy-category | Coordinator legacy | +""" + ) + monkeypatch.setattr( + "src.portfolio_truth_reconcile.load_safe_notion_project_context", + lambda: { + "repo": { + "portfolio_call": "Maintain", + "momentum": "Stable", + "current_state": "Coordinator identity retained", + } + }, + ) seed = portfolio_workspace / "_backups" / "seed" seed.mkdir(parents=True) _write(seed / "README.md", "# Repo\n") @@ -1196,7 +1239,7 @@ def test_bare_coordinator_worktree_flows_through_truth_validation( workspace_root=portfolio_workspace, catalog_path=portfolio_catalog, legacy_registry_path=legacy_registry, - include_notion=False, + include_notion=True, now=datetime(2026, 8, 3, tzinfo=timezone.utc), ) @@ -1206,15 +1249,37 @@ def test_bare_coordinator_worktree_flows_through_truth_validation( if item.identity.repo_full_name == "owner/Repo" ) authority = project.repository_state["checkout_authority"] - assert project.identity.path == "_codex-worktrees/repo-main" + assert project.identity.display_name == "Repo" + assert project.identity.path == "Repo" + assert project.identity.project_key == "Repo" + assert project.declared.owner == "coordinator-owner" + assert project.declared.lifecycle_state == "active" + assert project.declared.review_cadence == "weekly" + assert project.declared.category == "legacy-category" + assert project.advisory.legacy_status == "parked" + assert project.advisory.notion_portfolio_call == "Maintain" + assert project.advisory.notion_current_state == "Coordinator identity retained" assert authority["selection"]["state"] == "selected" - assert authority["selection"]["selected_path"] == project.identity.path + assert authority["canonical_project_path"] == "Repo" + assert authority["selection"]["selected_path"] == ( + "_codex-worktrees/repo-main" + ) + assert checkout_authority_path(project) == "_codex-worktrees/repo-main" + assert project.repository_state["local"]["path"] == str(linked) assert checkout_authority_blocker( project, workspace_root=portfolio_workspace, ) is None validate_truth_snapshot(result.snapshot) + plan = build_context_recovery_plan( + result.snapshot, + workspace_root=portfolio_workspace, + ) + target = next(item for item in plan.projects if item.project_key == "Repo") + assert target.relative_path == "_codex-worktrees/repo-main" + assert target.target_path.startswith(str(linked)) + def test_live_catalog_produces_exact_tier_zero_attention_semantics( tmp_path: Path, diff --git a/tests/test_portfolio_truth_sources.py b/tests/test_portfolio_truth_sources.py index aba9eb9a..9db2ccb6 100644 --- a/tests/test_portfolio_truth_sources.py +++ b/tests/test_portfolio_truth_sources.py @@ -747,9 +747,12 @@ def test_bare_coordinator_uses_worktree_inside_excluded_container( project for project in projects if project["repo_full_name"] == "owner/Repo" ] assert len(repo_projects) == 1 - assert repo_projects[0]["path"] == "_codex-worktrees/repo-main" + assert repo_projects[0]["name"] == "Repo" + assert repo_projects[0]["path"] == "Repo" + assert repo_projects[0]["project_path"] == linked collision = collisions[0] assert collision["selection"]["state"] == "selected" + assert collision["canonical_project_path"] == "Repo" assert collision["selection"]["representative_path"] == ( "_codex-worktrees/repo-main" ) diff --git a/tests/test_run_instructions_audit.py b/tests/test_run_instructions_audit.py index ae7bdfc0..81857d26 100644 --- a/tests/test_run_instructions_audit.py +++ b/tests/test_run_instructions_audit.py @@ -128,6 +128,45 @@ def test_build_record_prefers_claude_md(): assert build_record(project, "/w")["primary_file_name"] == "CLAUDE.md" +def test_build_record_uses_selected_checkout_without_replacing_identity(): + project = { + "identity": { + "project_key": "Repo", + "path": "Repo", + "display_name": "Repo", + }, + "derived": { + "context_files": ["AGENTS.md"], + "run_instructions_present": True, + }, + "repository_state": { + "checkout_authority": { + "schema_version": "CheckoutCollisionV1", + "canonical_project_path": "Repo", + "selection": { + "state": "selected", + "representative_path": "_codex-worktrees/repo-main", + "selected_path": "_codex-worktrees/repo-main", + }, + "checkouts": [ + { + "path": "_codex-worktrees/repo-main", + "state": "observed", + "relation": "representative", + "bare": False, + } + ], + } + }, + } + + record = build_record(project, "/workspace") + + assert record["project_key"] == "Repo" + assert record["display_name"] == "Repo" + assert record["abs_path"] == "/workspace/_codex-worktrees/repo-main" + + def test_is_after_compares_tz_aware_iso(): assert is_after("2026-05-25T19:25:00-07:00", "2026-05-17T05:01:39+00:00") assert not is_after("2026-05-10T00:00:00+00:00", "2026-05-17T05:01:39+00:00") From 867990b7c46c574dcb877faf3ed153807e010fb8 Mon Sep 17 00:00:00 2001 From: saagpatel Date: Mon, 3 Aug 2026 19:28:28 -0700 Subject: [PATCH 15/17] Preserve canonical checkout policy --- src/portfolio_checkout_authority.py | 9 +- src/portfolio_truth_sources.py | 2 +- tests/test_portfolio_truth.py | 171 +++++++++++++++++++++++++++ tests/test_run_instructions_audit.py | 61 ++++++++++ 4 files changed, 238 insertions(+), 5 deletions(-) diff --git a/src/portfolio_checkout_authority.py b/src/portfolio_checkout_authority.py index ae395e17..0ed68a84 100644 --- a/src/portfolio_checkout_authority.py +++ b/src/portfolio_checkout_authority.py @@ -90,10 +90,11 @@ def checkout_authority_path(project: Any) -> str: or authority.get("schema_version") != CHECKOUT_COLLISION_SCHEMA_VERSION ): return project_path - canonical_project_path = str( - authority.get("canonical_project_path") or project_path - ) - if canonical_project_path != project_path: + declared_canonical_path = authority.get("canonical_project_path") + if not isinstance(declared_canonical_path, str): + return project_path + canonical_project_path = declared_canonical_path.strip() + if not canonical_project_path or canonical_project_path != project_path: return project_path selection = authority.get("selection") if not isinstance(selection, Mapping) or selection.get("state") != "selected": diff --git a/src/portfolio_truth_sources.py b/src/portfolio_truth_sources.py index 507ab401..8e0169f8 100644 --- a/src/portfolio_truth_sources.py +++ b/src/portfolio_truth_sources.py @@ -427,7 +427,7 @@ def _canonical_checkout_project( if identity_project is representative: return representative canonical = dict(representative) - for key in ("name", "path", "top_level_dir"): + for key in ("name", "path", "top_level_dir", "group_entry", "source"): if key in identity_project: canonical[key] = identity_project[key] return canonical diff --git a/tests/test_portfolio_truth.py b/tests/test_portfolio_truth.py index 5debfc12..ff2a88d1 100644 --- a/tests/test_portfolio_truth.py +++ b/tests/test_portfolio_truth.py @@ -1281,6 +1281,119 @@ def test_bare_coordinator_worktree_flows_through_truth_validation( assert target.target_path.startswith(str(linked)) +def test_bare_coordinator_preserves_nested_canonical_group_policy( + tmp_path: Path, +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + catalog = tmp_path / "portfolio-catalog.yaml" + catalog.write_text( + """ +defaults: + lifecycle_state: maintenance + criticality: medium + review_cadence: monthly + category: default-category + tool_provenance: unknown + +groups: + canonical_infra: + section_marker: Infra/ + section_label: Canonical Infrastructure + path_prefixes: + - Infra + owner: canonical-owner + lifecycle_state: active + review_cadence: weekly + category: infrastructure + tool_provenance: codex + physical_worktrees: + section_marker: Physical Worktrees/ + section_label: Physical Worktrees + path_prefixes: + - _codex-worktrees + owner: wrong-physical-owner + lifecycle_state: parked + review_cadence: yearly + category: vanity + tool_provenance: unknown +""" + ) + registry = tmp_path / "project-registry.md" + registry.write_text("# Project Registry\n") + + seed = workspace / "_backups" / "seed" + seed.mkdir(parents=True) + _write(seed / "README.md", "# Repo\n") + subprocess.run(["git", "init", "-q", "-b", "main"], cwd=seed, check=True) + subprocess.run(["git", "add", "README.md"], cwd=seed, check=True) + subprocess.run( + [ + "git", + "-c", + "user.name=Test", + "-c", + "user.email=test@example.invalid", + "commit", + "-q", + "-m", + "fixture", + ], + cwd=seed, + check=True, + ) + coordinator = workspace / "Infra" / "Repo" + coordinator.parent.mkdir() + subprocess.run( + ["git", "clone", "-q", "--bare", str(seed), str(coordinator)], + check=True, + ) + subprocess.run( + ["git", "remote", "set-url", "origin", "git@github.com:owner/Repo.git"], + cwd=coordinator, + check=True, + ) + linked = workspace / "_codex-worktrees" / "repo-main" + linked.parent.mkdir() + subprocess.run( + ["git", "worktree", "add", "-q", str(linked), "main"], + cwd=coordinator, + check=True, + ) + + result = build_portfolio_truth_snapshot( + workspace_root=workspace, + catalog_path=catalog, + legacy_registry_path=registry, + include_notion=False, + now=datetime(2026, 8, 3, tzinfo=timezone.utc), + ) + + project = next( + item + for item in result.snapshot.projects + if item.identity.repo_full_name == "owner/Repo" + ) + authority = project.repository_state["checkout_authority"] + assert project.identity.path == "Infra/Repo" + assert project.identity.group_key == "canonical_infra" + assert project.identity.section_marker == "Infra/" + assert project.identity.section_label == "Canonical Infrastructure" + assert project.declared.owner == "canonical-owner" + assert project.declared.lifecycle_state == "active" + assert project.declared.review_cadence == "weekly" + assert project.declared.category == "infrastructure" + assert project.declared.owner != "wrong-physical-owner" + assert authority["canonical_project_path"] == "Infra/Repo" + assert authority["selection"]["selected_path"] == ( + "_codex-worktrees/repo-main" + ) + assert checkout_authority_path(project) == "_codex-worktrees/repo-main" + assert project.repository_state["local"]["path"] == str(linked) + assert checkout_authority_blocker(project, workspace_root=workspace) is None + validate_truth_snapshot(result.snapshot) + + def test_live_catalog_produces_exact_tier_zero_attention_semantics( tmp_path: Path, ) -> None: @@ -4083,6 +4196,64 @@ def test_context_recovery_plan_skips_unknown_checkout_authority( assert target.reason == "checkout-authority-unknown:conflicting_full_clone_heads" +def test_context_recovery_malformed_authority_never_redirects_target_path( + portfolio_workspace: Path, + portfolio_catalog: Path, + legacy_registry: Path, +) -> None: + target_repo = portfolio_workspace / "FreshMalformed" + target_repo.mkdir() + _write(target_repo / "README.md", "# FreshMalformed\n\nFresh repo.\n") + + result = build_portfolio_truth_snapshot( + workspace_root=portfolio_workspace, + catalog_path=portfolio_catalog, + legacy_registry_path=legacy_registry, + include_notion=False, + now=datetime.fromtimestamp(1_700_000_100, tz=timezone.utc), + ) + projects = [ + replace( + project, + repository_state={ + **project.repository_state, + "checkout_authority": { + "schema_version": "CheckoutCollisionV1", + "selection": { + "state": "selected", + "reason_code": "single_clone_topology", + "representative_path": "_codex-worktrees/malicious-target", + "selected_path": "_codex-worktrees/malicious-target", + }, + "checkouts": [ + { + "path": "_codex-worktrees/malicious-target", + "state": "observed", + "relation": "representative", + "bare": False, + } + ], + }, + }, + ) + if project.identity.project_key == "FreshMalformed" + else project + for project in result.snapshot.projects + ] + snapshot = replace(result.snapshot, projects=projects) + + plan = build_context_recovery_plan(snapshot, workspace_root=portfolio_workspace) + target = next( + item for item in plan.projects if item.project_key == "FreshMalformed" + ) + + assert target.status == "skipped" + assert target.reason == "checkout-authority-path-mismatch" + assert target.relative_path == "FreshMalformed" + assert target.target_path.startswith(str(target_repo)) + assert "malicious-target" not in target.target_path + + def test_context_recovery_apply_writes_primary_context_and_catalog_seed( portfolio_workspace: Path, portfolio_catalog: Path, diff --git a/tests/test_run_instructions_audit.py b/tests/test_run_instructions_audit.py index 81857d26..a048b4d7 100644 --- a/tests/test_run_instructions_audit.py +++ b/tests/test_run_instructions_audit.py @@ -354,3 +354,64 @@ def test_prepare_pilot_blocks_unknown_checkout_authority(tmp_path): ), } ] + + +def test_prepare_pilot_malformed_authority_never_redirects_error_path(tmp_path): + workspace = tmp_path / "ws" + repo = workspace / "CanonicalRepo" + repo.mkdir(parents=True) + (repo / "AGENTS.md").write_text("# CanonicalRepo\n") + snapshot = { + "workspace_root": str(workspace), + "generated_at": "2026-08-03T12:00:00+00:00", + "projects": [ + { + "identity": { + "project_key": "CanonicalRepo", + "path": "CanonicalRepo", + "display_name": "CanonicalRepo", + }, + "derived": { + "archived": False, + "context_quality": "full", + "context_files": ["AGENTS.md"], + "run_instructions_present": False, + }, + "repository_state": { + "checkout_authority": { + "schema_version": "CheckoutCollisionV1", + "selection": { + "state": "selected", + "reason_code": "single_clone_topology", + "representative_path": "redirected/Repo", + "selected_path": "redirected/Repo", + }, + "checkouts": [ + { + "path": "redirected/Repo", + "state": "observed", + "relation": "representative", + "bare": False, + } + ], + } + }, + } + ], + } + snap_path = tmp_path / "snap.json" + snap_path.write_text(json.dumps(snapshot)) + + result = prepare_pilot(str(snap_path), per_tier={"full": 1}) + + assert result["state"] == "blocked" + assert result["records"] == [] + assert result["errors"] == [ + { + "project_key": "CanonicalRepo", + "abs_path": str(repo), + "error": "checkout_authority_blocked", + "reason": "checkout-authority-path-mismatch", + } + ] + assert "redirected" not in result["errors"][0]["abs_path"] From 13599702aa2bc3310bc4c6468196e400a23a355b Mon Sep 17 00:00:00 2001 From: saagpatel Date: Mon, 3 Aug 2026 19:44:25 -0700 Subject: [PATCH 16/17] fix: validate checkout authority consistently --- src/portfolio_checkout_authority.py | 349 +++++++++++++++++++++------ src/portfolio_truth_validate.py | 229 +----------------- tests/test_automation_workflow.py | 155 ++++++++---- tests/test_portfolio_automation.py | 133 ++++++---- tests/test_portfolio_truth.py | 113 ++++++++- tests/test_run_instructions_audit.py | 191 +++++++++------ 6 files changed, 701 insertions(+), 469 deletions(-) diff --git a/src/portfolio_checkout_authority.py b/src/portfolio_checkout_authority.py index 0ed68a84..3def83f9 100644 --- a/src/portfolio_checkout_authority.py +++ b/src/portfolio_checkout_authority.py @@ -1,12 +1,266 @@ from __future__ import annotations +import re from collections.abc import Mapping +from dataclasses import dataclass from pathlib import Path from typing import Any from src.portfolio_truth_types import CHECKOUT_COLLISION_SCHEMA_VERSION +@dataclass(frozen=True) +class ValidatedCheckoutAuthority: + origin: str + checkout_count: int + full_clone_count: int + state: str + reason_code: str + canonical_project_path: str + representative_path: str + selected_path: str | None + discarded_count: int + + +def validate_checkout_authority_envelope( + authority: object, + *, + identity_path: str | None = None, + repo_full_name: str | None = None, +) -> ValidatedCheckoutAuthority: + """Validate the complete CheckoutCollisionV1 group and its path binding.""" + if not isinstance(authority, Mapping): + raise ValueError("Checkout collision group must be an object.") + required_group = { + "schema_version", + "origin", + "canonical_project_path", + "checkout_count", + "full_clone_count", + "declared_checkout_paths", + "declared_path_evidence", + "unresolved_declared_paths", + "selection", + "checkouts", + "discarded_checkouts", + } + missing = sorted(required_group - authority.keys()) + if missing: + raise ValueError(f"Checkout collision group is missing fields: {missing}") + if authority.get("schema_version") != CHECKOUT_COLLISION_SCHEMA_VERSION: + raise ValueError("Unexpected checkout collision schema version.") + + origin = authority.get("origin") + if not isinstance(origin, str) or not origin.strip(): + raise ValueError("Checkout collision origin must be non-empty.") + if repo_full_name and origin.lower() != repo_full_name.lower(): + raise ValueError("Checkout collision origin differs from project identity.") + canonical_project_path = _require_relative_path( + authority.get("canonical_project_path"), + "checkout canonical_project_path", + ) + if identity_path is not None and canonical_project_path != identity_path: + raise ValueError("Canonical project path differs from collision identity.") + + checkout_count = _require_nonnegative_count(authority, "checkout_count") + full_clone_count = _require_nonnegative_count(authority, "full_clone_count") + if checkout_count < 1: + raise ValueError("Checkout authority groups require at least one checkout.") + if not 1 <= full_clone_count <= checkout_count: + raise ValueError("Checkout collision full_clone_count is out of range.") + + selection = authority.get("selection") + if not isinstance(selection, Mapping): + raise ValueError("Checkout collision selection must be an object.") + for key in ( + "state", + "reason_code", + "reason", + "representative_path", + "selected_path", + "rationale", + ): + if key not in selection: + raise ValueError(f"Checkout collision selection is missing {key}.") + state = selection.get("state") + if state not in {"selected", "unknown"}: + raise ValueError(f"Invalid checkout authority state: {state}") + representative_path = _require_relative_path( + selection.get("representative_path"), + "checkout representative_path", + ) + selected_path = selection.get("selected_path") + if state == "unknown": + if selected_path is not None: + raise ValueError("UNKNOWN checkout authority cannot select a path.") + elif selected_path != representative_path: + raise ValueError("Selected checkout path must equal the representative path.") + for key in ("reason_code", "reason", "rationale"): + if not isinstance(selection.get(key), str) or not selection[key].strip(): + raise ValueError( + f"Checkout collision selection {key} must be non-empty." + ) + + checkouts = authority.get("checkouts") + discarded = authority.get("discarded_checkouts") + if not isinstance(checkouts, list) or len(checkouts) != checkout_count: + raise ValueError("Checkout collision checkouts do not match checkout_count.") + if not isinstance(discarded, list): + raise ValueError("Discarded checkouts must be a list.") + checkout_paths: set[str] = set() + representative_count = 0 + for checkout in checkouts: + if not isinstance(checkout, Mapping): + raise ValueError("Checkout collision checkout must be an object.") + required_checkout = { + "path", + "state", + "relation", + "head", + "branch", + "dirty", + "dirty_path_count", + "bare", + } + missing_checkout = sorted(required_checkout - checkout.keys()) + if missing_checkout: + raise ValueError( + f"Checkout collision checkout is missing fields: {missing_checkout}" + ) + path = _require_relative_path(checkout.get("path"), "checkout path") + if path in checkout_paths: + raise ValueError(f"Duplicate checkout collision path: {path}") + checkout_paths.add(path) + if checkout.get("state") not in {"observed", "unknown"}: + raise ValueError("Invalid checkout observation state.") + relation = checkout.get("relation") + if relation not in { + "representative", + "linked_worktree", + "independent_full_clone", + }: + raise ValueError("Invalid checkout relation.") + representative_count += int(relation == "representative") + head = checkout.get("head") + if head is not None and not re.fullmatch( + r"[0-9a-f]{40}|[0-9a-f]{64}", str(head) + ): + raise ValueError(f"Malformed checkout head for {path}.") + branch = checkout.get("branch") + if branch is not None and not isinstance(branch, str): + raise ValueError(f"Malformed checkout branch for {path}.") + dirty = checkout.get("dirty") + if dirty is not None and not isinstance(dirty, bool): + raise ValueError(f"Malformed checkout dirty state for {path}.") + dirty_count = checkout.get("dirty_path_count") + if dirty_count is not None and ( + isinstance(dirty_count, bool) + or not isinstance(dirty_count, int) + or dirty_count < 0 + ): + raise ValueError(f"Malformed checkout dirty_path_count for {path}.") + bare = checkout.get("bare") + if bare is not None and not isinstance(bare, bool): + raise ValueError(f"Malformed checkout bare state for {path}.") + if checkout.get("state") == "observed" and not isinstance(bare, bool): + raise ValueError(f"Observed checkout must declare bare state for {path}.") + + representative_checkout = next( + ( + checkout + for checkout in checkouts + if checkout["path"] == representative_path + ), + None, + ) + if ( + representative_count != 1 + or representative_checkout is None + or representative_checkout["relation"] != "representative" + or ( + state == "selected" + and ( + representative_checkout["state"] != "observed" + or representative_checkout["bare"] is not False + ) + ) + ): + raise ValueError("Checkout collision requires one observed representative.") + expected_discarded = [ + checkout + for checkout in checkouts + if checkout["path"] != representative_path + ] + if discarded != expected_discarded: + raise ValueError("Discarded checkout evidence does not match the checkout set.") + + declared_paths = authority.get("declared_checkout_paths") + unresolved_paths = authority.get("unresolved_declared_paths") + declared_evidence = authority.get("declared_path_evidence") + if not isinstance(declared_paths, list) or not isinstance( + unresolved_paths, list + ): + raise ValueError("Declared checkout paths must be lists.") + for path in declared_paths + unresolved_paths: + _require_relative_path(path, "declared checkout path") + if not isinstance(declared_evidence, list): + raise ValueError("Declared path evidence must be a list.") + for item in declared_evidence: + if not isinstance(item, Mapping): + raise ValueError("Declared path evidence must be an object.") + _require_relative_path(item.get("source_path"), "declared source path") + target = _require_relative_path( + item.get("target_checkout_path"), "declared target checkout path" + ) + if target not in checkout_paths: + raise ValueError("Declared checkout target is not in the collision group.") + expected_declared_paths = sorted( + {item["target_checkout_path"] for item in declared_evidence}, + key=str.lower, + ) + if declared_paths != expected_declared_paths: + raise ValueError("Declared checkout paths do not match declared path evidence.") + topology_failure = ( + state == "unknown" + and selection.get("reason_code") == "worktree_enumeration_failed" + ) + if checkout_count == 1 and not ( + declared_evidence or unresolved_paths or topology_failure + ): + raise ValueError( + "Single-checkout authority groups require declaration or " + "topology-failure evidence." + ) + + return ValidatedCheckoutAuthority( + origin=origin, + checkout_count=checkout_count, + full_clone_count=full_clone_count, + state=str(state), + reason_code=str(selection["reason_code"]), + canonical_project_path=canonical_project_path, + representative_path=representative_path, + selected_path=str(selected_path) if selected_path is not None else None, + discarded_count=len(discarded), + ) + + +def _require_nonnegative_count(value: Mapping[str, Any], key: str) -> int: + count = value.get(key) + if isinstance(count, bool) or not isinstance(count, int) or count < 0: + raise ValueError(f"{key} must be a non-negative integer.") + return count + + +def _require_relative_path(value: object, label: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{label} must be a non-empty string.") + path = Path(value) + if path.is_absolute() or ".." in path.parts: + raise ValueError(f"{label} must stay workspace-relative.") + return value + + def checkout_authority_blocker( project: Any, *, @@ -24,49 +278,20 @@ def checkout_authority_blocker( authority = repository_state.get("checkout_authority") if authority is None: return _repository_topology_blocker(repository_state, authority=None) - if not isinstance(authority, Mapping): - return "checkout-authority-malformed" - if authority.get("schema_version") != CHECKOUT_COLLISION_SCHEMA_VERSION: - return "checkout-authority-malformed" - selection = authority.get("selection") - if not isinstance(selection, Mapping): - return "checkout-authority-malformed" - - state = str(selection.get("state") or "unknown") - reason_code = str(selection.get("reason_code") or "unspecified") - if state != "selected": - return f"checkout-authority-unknown:{reason_code}" - project_path = str(identity.get("path") or "") - declared_canonical_path = authority.get("canonical_project_path") - canonical_project_path = str(declared_canonical_path or project_path) - selected_path = str(selection.get("selected_path") or "") - representative_path = str(selection.get("representative_path") or "") - if ( - not project_path - or canonical_project_path != project_path - or not selected_path - or selected_path != representative_path - or (selected_path != project_path and not declared_canonical_path) - ): - return "checkout-authority-path-mismatch" - - checkouts = authority.get("checkouts") - if not isinstance(checkouts, list): - return "checkout-authority-malformed" - selected_checkouts = [ - checkout - for checkout in checkouts - if isinstance(checkout, Mapping) and checkout.get("path") == selected_path - ] - if len(selected_checkouts) != 1: + repo_full_name = str(identity.get("repo_full_name") or "") + try: + validated = validate_checkout_authority_envelope( + authority, + identity_path=project_path, + repo_full_name=repo_full_name or None, + ) + except ValueError: return "checkout-authority-malformed" - selected_checkout = selected_checkouts[0] - if ( - selected_checkout.get("state") != "observed" - or selected_checkout.get("relation") != "representative" - or selected_checkout.get("bare") is not False - ): + if validated.state != "selected": + return f"checkout-authority-unknown:{validated.reason_code}" + selected_path = validated.selected_path + if selected_path is None: return "checkout-authority-malformed" if workspace_root is not None: @@ -85,39 +310,17 @@ def checkout_authority_path(project: Any) -> str: project_path = str(identity.get("path") or "") repository_state = _project_section(project, "repository_state") authority = repository_state.get("checkout_authority") - if ( - not isinstance(authority, Mapping) - or authority.get("schema_version") != CHECKOUT_COLLISION_SCHEMA_VERSION - ): - return project_path - declared_canonical_path = authority.get("canonical_project_path") - if not isinstance(declared_canonical_path, str): - return project_path - canonical_project_path = declared_canonical_path.strip() - if not canonical_project_path or canonical_project_path != project_path: - return project_path - selection = authority.get("selection") - if not isinstance(selection, Mapping) or selection.get("state") != "selected": - return project_path - selected_path = str(selection.get("selected_path") or "") - representative_path = str(selection.get("representative_path") or "") - checkouts = authority.get("checkouts") - if not isinstance(checkouts, list): + repo_full_name = str(identity.get("repo_full_name") or "") + try: + validated = validate_checkout_authority_envelope( + authority, + identity_path=project_path, + repo_full_name=repo_full_name or None, + ) + except ValueError: return project_path - selected_checkouts = [ - checkout - for checkout in checkouts - if isinstance(checkout, Mapping) and checkout.get("path") == selected_path - ] - if ( - selected_path - and selected_path == representative_path - and len(selected_checkouts) == 1 - and selected_checkouts[0].get("state") == "observed" - and selected_checkouts[0].get("relation") == "representative" - and selected_checkouts[0].get("bare") is False - ): - return selected_path + if validated.state == "selected" and validated.selected_path is not None: + return validated.selected_path return project_path diff --git a/src/portfolio_truth_validate.py b/src/portfolio_truth_validate.py index 6de53a68..e1ad94ae 100644 --- a/src/portfolio_truth_validate.py +++ b/src/portfolio_truth_validate.py @@ -3,6 +3,7 @@ import re from pathlib import Path +from src.portfolio_checkout_authority import validate_checkout_authority_envelope from src.portfolio_pathing import ( VALID_MATURITY_PROGRAMS, VALID_OPERATING_PATHS, @@ -11,7 +12,6 @@ ) from src.portfolio_truth_render import registry_project_labels from src.portfolio_truth_types import ( - CHECKOUT_COLLISION_SCHEMA_VERSION, CHECKOUT_COLLISION_SUMMARY_SCHEMA_VERSION, DERIVATION_POLICY_VERSION, SCHEMA_VERSION, @@ -147,220 +147,24 @@ def _validate_checkout_collisions(snapshot: PortfolioTruthSnapshot) -> None: for group in groups: if not isinstance(group, dict): raise ValueError("Checkout collision group must be an object.") - required_group = { - "schema_version", - "origin", - "checkout_count", - "full_clone_count", - "declared_checkout_paths", - "declared_path_evidence", - "unresolved_declared_paths", - "selection", - "checkouts", - "discarded_checkouts", - } - group_missing = sorted(required_group - group.keys()) - if group_missing: - raise ValueError( - f"Checkout collision group is missing fields: {group_missing}" - ) - if group.get("schema_version") != CHECKOUT_COLLISION_SCHEMA_VERSION: - raise ValueError("Unexpected checkout collision schema version.") origin = group.get("origin") if not isinstance(origin, str) or not origin.strip(): raise ValueError("Checkout collision origin must be non-empty.") origin_key = origin.lower() - if origin_key in seen_origins: - raise ValueError(f"Duplicate checkout collision origin: {origin}") - seen_origins.add(origin_key) - - checkout_count = _require_nonnegative_count(group, "checkout_count") - full_clone_count = _require_nonnegative_count(group, "full_clone_count") - if checkout_count < 1: - raise ValueError("Checkout authority groups require at least one checkout.") - if not 1 <= full_clone_count <= checkout_count: - raise ValueError("Checkout collision full_clone_count is out of range.") - full_clone_groups += int(full_clone_count > 1) - - selection = group.get("selection") - if not isinstance(selection, dict): - raise ValueError("Checkout collision selection must be an object.") - for key in ( - "state", - "reason_code", - "reason", - "representative_path", - "selected_path", - "rationale", - ): - if key not in selection: - raise ValueError(f"Checkout collision selection is missing {key}.") - state = selection.get("state") - if state not in {"selected", "unknown"}: - raise ValueError(f"Invalid checkout authority state: {state}") - representative_path = _require_relative_path( - selection.get("representative_path"), - "checkout representative_path", - ) - canonical_project_path = _require_relative_path( - group.get("canonical_project_path", representative_path), - "checkout canonical_project_path", - ) - selected_path = selection.get("selected_path") - if state == "unknown": - ambiguous += 1 - if selected_path is not None: - raise ValueError("UNKNOWN checkout authority cannot select a path.") - elif selected_path != representative_path: - raise ValueError( - "Selected checkout path must equal the representative path." - ) - for key in ("reason_code", "reason", "rationale"): - if not isinstance(selection.get(key), str) or not selection[key].strip(): - raise ValueError( - f"Checkout collision selection {key} must be non-empty." - ) - - checkouts = group.get("checkouts") - discarded = group.get("discarded_checkouts") - if not isinstance(checkouts, list) or len(checkouts) != checkout_count: - raise ValueError( - "Checkout collision checkouts do not match checkout_count." - ) - if not isinstance(discarded, list): - raise ValueError("Discarded checkouts must be a list.") - checkout_paths: set[str] = set() - representative_count = 0 - for checkout in checkouts: - if not isinstance(checkout, dict): - raise ValueError("Checkout collision checkout must be an object.") - required_checkout = { - "path", - "state", - "relation", - "head", - "branch", - "dirty", - "dirty_path_count", - "bare", - } - missing_checkout = sorted(required_checkout - checkout.keys()) - if missing_checkout: - raise ValueError( - f"Checkout collision checkout is missing fields: {missing_checkout}" - ) - path = _require_relative_path(checkout.get("path"), "checkout path") - if path in checkout_paths: - raise ValueError(f"Duplicate checkout collision path: {path}") - checkout_paths.add(path) - if checkout.get("state") not in {"observed", "unknown"}: - raise ValueError("Invalid checkout observation state.") - relation = checkout.get("relation") - if relation not in { - "representative", - "linked_worktree", - "independent_full_clone", - }: - raise ValueError("Invalid checkout relation.") - representative_count += int(relation == "representative") - head = checkout.get("head") - if head is not None and not re.fullmatch( - r"[0-9a-f]{40}|[0-9a-f]{64}", str(head) - ): - raise ValueError(f"Malformed checkout head for {path}.") - branch = checkout.get("branch") - if branch is not None and not isinstance(branch, str): - raise ValueError(f"Malformed checkout branch for {path}.") - dirty = checkout.get("dirty") - if dirty is not None and not isinstance(dirty, bool): - raise ValueError(f"Malformed checkout dirty state for {path}.") - dirty_count = checkout.get("dirty_path_count") - if dirty_count is not None and ( - isinstance(dirty_count, bool) - or not isinstance(dirty_count, int) - or dirty_count < 0 - ): - raise ValueError(f"Malformed checkout dirty_path_count for {path}.") - bare = checkout.get("bare") - if bare is not None and not isinstance(bare, bool): - raise ValueError(f"Malformed checkout bare state for {path}.") - if checkout.get("state") == "observed" and not isinstance(bare, bool): - raise ValueError(f"Observed checkout must declare bare state for {path}.") - representative_checkout = next( - ( - checkout - for checkout in checkouts - if checkout["path"] == representative_path - ), - None, - ) - if ( - representative_count != 1 - or representative_checkout is None - or representative_checkout["relation"] != "representative" - or (state == "selected" and representative_checkout["bare"] is not False) - ): - raise ValueError("Checkout collision requires one observed representative.") - expected_discarded = [ - checkout - for checkout in checkouts - if checkout["path"] != representative_path - ] - if discarded != expected_discarded: - raise ValueError( - "Discarded checkout evidence does not match the checkout set." - ) - discarded_count += len(discarded) - - declared_paths = group.get("declared_checkout_paths") - unresolved_paths = group.get("unresolved_declared_paths") - declared_evidence = group.get("declared_path_evidence") - if not isinstance(declared_paths, list) or not isinstance( - unresolved_paths, list - ): - raise ValueError("Declared checkout paths must be lists.") - for path in declared_paths + unresolved_paths: - _require_relative_path(path, "declared checkout path") - if not isinstance(declared_evidence, list): - raise ValueError("Declared path evidence must be a list.") - for item in declared_evidence: - if not isinstance(item, dict): - raise ValueError("Declared path evidence must be an object.") - _require_relative_path(item.get("source_path"), "declared source path") - target = _require_relative_path( - item.get("target_checkout_path"), "declared target checkout path" - ) - if target not in checkout_paths: - raise ValueError( - "Declared checkout target is not in the collision group." - ) - expected_declared_paths = sorted( - {item["target_checkout_path"] for item in declared_evidence}, - key=str.lower, - ) - if declared_paths != expected_declared_paths: - raise ValueError( - "Declared checkout paths do not match declared path evidence." - ) - topology_failure = ( - state == "unknown" - and selection.get("reason_code") == "worktree_enumeration_failed" - ) - if checkout_count == 1 and not ( - declared_evidence or unresolved_paths or topology_failure - ): - raise ValueError( - "Single-checkout authority groups require declaration or " - "topology-failure evidence." - ) - project = project_by_origin.get(origin_key) if project is None: raise ValueError(f"Checkout collision has no canonical project: {origin}") - if project.identity.path != canonical_project_path: - raise ValueError( - "Canonical project path differs from collision identity." - ) + validated = validate_checkout_authority_envelope( + group, + identity_path=project.identity.path, + repo_full_name=project.identity.repo_full_name, + ) + if origin_key in seen_origins: + raise ValueError(f"Duplicate checkout collision origin: {origin}") + seen_origins.add(origin_key) + full_clone_groups += int(validated.full_clone_count > 1) + ambiguous += int(validated.state == "unknown") + discarded_count += validated.discarded_count if project.repository_state.get("checkout_authority") != group: raise ValueError( "Project checkout authority differs from collision summary." @@ -393,15 +197,6 @@ def _require_nonnegative_count(value: dict, key: str) -> int: return count -def _require_relative_path(value: object, label: str) -> str: - if not isinstance(value, str) or not value.strip(): - raise ValueError(f"{label} must be a non-empty string.") - path = Path(value) - if path.is_absolute() or ".." in path.parts: - raise ValueError(f"{label} must stay workspace-relative.") - return value - - def _validate_contract_envelope(snapshot: PortfolioTruthSnapshot) -> None: producer = snapshot.producer if producer: diff --git a/tests/test_automation_workflow.py b/tests/test_automation_workflow.py index 78f12c20..76ed574a 100644 --- a/tests/test_automation_workflow.py +++ b/tests/test_automation_workflow.py @@ -13,6 +13,7 @@ from __future__ import annotations +from copy import deepcopy from pathlib import Path import pytest @@ -100,6 +101,63 @@ def _project( ) +def _checkout_authority( + *, + canonical_path: str = "MyRepo", + representative_path: str = "MyRepo", + origin: str = "owner/MyRepo", + state: str = "selected", + reason_code: str = "single_clone_topology", +) -> dict: + other_path = ( + f"{canonical_path}-linked" + if representative_path == canonical_path + else canonical_path + ) + representative = { + "path": representative_path, + "state": "observed", + "relation": "representative", + "head": "1" * 40, + "branch": "main", + "dirty": False, + "dirty_path_count": 0, + "bare": False, + } + other = { + "path": other_path if state == "selected" else f"Archive/{canonical_path}", + "state": "observed", + "relation": ( + "linked_worktree" if state == "selected" else "independent_full_clone" + ), + "head": ("1" if state == "selected" else "2") * 40, + "branch": "feature", + "dirty": False, + "dirty_path_count": 0, + "bare": False, + } + return { + "schema_version": "CheckoutCollisionV1", + "origin": origin, + "canonical_project_path": canonical_path, + "checkout_count": 2, + "full_clone_count": 1 if state == "selected" else 2, + "declared_checkout_paths": [], + "declared_path_evidence": [], + "unresolved_declared_paths": [], + "selection": { + "state": state, + "reason_code": reason_code, + "reason": "fixture authority", + "representative_path": representative_path, + "selected_path": representative_path if state == "selected" else None, + "rationale": "fixture selection", + }, + "checkouts": [representative, other], + "discarded_checkouts": [other], + } + + def _snapshot(*projects: PortfolioTruthProject) -> PortfolioTruthSnapshot: from datetime import datetime, timezone @@ -185,15 +243,10 @@ def test_context_pr_plan_explicit_branch_overrides_repo_default() -> None: def test_context_pr_plan_blocks_unknown_checkout_authority() -> None: project = _project( repository_state={ - "checkout_authority": { - "schema_version": "CheckoutCollisionV1", - "selection": { - "state": "unknown", - "reason_code": "conflicting_full_clone_heads", - "representative_path": "MyRepo", - "selected_path": None, - } - } + "checkout_authority": _checkout_authority( + state="unknown", + reason_code="conflicting_full_clone_heads", + ) } ) @@ -204,24 +257,20 @@ def test_context_pr_plan_blocks_unknown_checkout_authority() -> None: build_context_pr_plan(project, workspace_root=Path("/ws")) -def test_context_pr_plan_blocks_selected_path_mismatch() -> None: +def test_context_pr_plan_treats_selected_path_mismatch_as_malformed() -> None: project = _project( repository_state={ - "checkout_authority": { - "schema_version": "CheckoutCollisionV1", - "selection": { - "state": "selected", - "reason_code": "equivalent_full_clones", - "representative_path": "Archive/MyRepo", - "selected_path": "Archive/MyRepo", - } - } + "checkout_authority": _checkout_authority( + canonical_path="Archive/MyRepo", + representative_path="Archive/MyRepo", + reason_code="equivalent_full_clones", + ) } ) with pytest.raises( AutomationExecutionError, - match="checkout-authority-path-mismatch", + match="checkout-authority-malformed", ): build_context_pr_plan(project, workspace_root=Path("/ws")) @@ -232,24 +281,11 @@ def test_context_pr_plan_uses_selected_checkout_without_replacing_identity() -> path="Repo", repo_full_name="owner/Repo", repository_state={ - "checkout_authority": { - "schema_version": "CheckoutCollisionV1", - "canonical_project_path": "Repo", - "selection": { - "state": "selected", - "reason_code": "single_clone_topology", - "representative_path": "_codex-worktrees/repo-main", - "selected_path": "_codex-worktrees/repo-main", - }, - "checkouts": [ - { - "path": "_codex-worktrees/repo-main", - "state": "observed", - "relation": "representative", - "bare": False, - } - ], - } + "checkout_authority": _checkout_authority( + canonical_path="Repo", + representative_path="_codex-worktrees/repo-main", + origin="owner/Repo", + ) }, ) @@ -260,6 +296,36 @@ def test_context_pr_plan_uses_selected_checkout_without_replacing_identity() -> assert plan.repo_path == Path("/ws/_codex-worktrees/repo-main") +def test_context_pr_plan_rejects_malformed_authority_variants() -> None: + variants = [] + + missing_field = _checkout_authority() + missing_field.pop("origin") + variants.append(missing_field) + + invalid_type = _checkout_authority() + invalid_type["selection"] = "selected" + variants.append(invalid_type) + + invalid_count = _checkout_authority() + invalid_count["checkout_count"] = 3 + variants.append(invalid_count) + + malformed_record = _checkout_authority() + del malformed_record["checkouts"][0]["head"] + variants.append(malformed_record) + + for authority in variants: + project = _project( + repository_state={"checkout_authority": deepcopy(authority)} + ) + with pytest.raises( + AutomationExecutionError, + match="checkout-authority-malformed", + ): + build_context_pr_plan(project, workspace_root=Path("/ws")) + + def test_context_pr_plan_apply_change_writes_managed_block(tmp_path: Path) -> None: project = _project(path="MyRepo") repo_path = tmp_path / "MyRepo" @@ -423,15 +489,10 @@ def test_execute_catalog_seed_blocks_unknown_checkout_authority(tmp_path: Path) catalog_path = tmp_path / "catalog.yaml" project = _project( repository_state={ - "checkout_authority": { - "schema_version": "CheckoutCollisionV1", - "selection": { - "state": "unknown", - "reason_code": "conflicting_full_clone_heads", - "representative_path": "MyRepo", - "selected_path": None, - }, - } + "checkout_authority": _checkout_authority( + state="unknown", + reason_code="conflicting_full_clone_heads", + ) } ) diff --git a/tests/test_portfolio_automation.py b/tests/test_portfolio_automation.py index 0fa614d0..7725f91c 100644 --- a/tests/test_portfolio_automation.py +++ b/tests/test_portfolio_automation.py @@ -8,6 +8,8 @@ from __future__ import annotations +from copy import deepcopy + from src.portfolio_automation import ( AutomationCandidate, AutomationEligibility, @@ -47,6 +49,51 @@ def _project( return project +def _authority(*, state: str = "selected", reason_code: str = "single_clone_topology") -> dict: + representative = { + "path": "Repo", + "state": "observed", + "relation": "representative", + "head": "1" * 40, + "branch": "main", + "dirty": False, + "dirty_path_count": 0, + "bare": False, + } + other = { + "path": "Repo-linked" if state == "selected" else "Archive/Repo", + "state": "observed", + "relation": ( + "linked_worktree" if state == "selected" else "independent_full_clone" + ), + "head": ("1" if state == "selected" else "2") * 40, + "branch": "feature", + "dirty": False, + "dirty_path_count": 0, + "bare": False, + } + return { + "schema_version": "CheckoutCollisionV1", + "origin": "owner/Repo", + "canonical_project_path": "Repo", + "checkout_count": 2, + "full_clone_count": 1 if state == "selected" else 2, + "declared_checkout_paths": [], + "declared_path_evidence": [], + "unresolved_declared_paths": [], + "selection": { + "state": state, + "reason_code": reason_code, + "reason": "fixture authority", + "representative_path": "Repo", + "selected_path": "Repo" if state == "selected" else None, + "rationale": "fixture selection", + }, + "checkouts": [representative, other], + "discarded_checkouts": [other], + } + + # --- evaluate_automation_eligibility --------------------------------------- @@ -148,15 +195,10 @@ def test_multiple_blockers_accumulate() -> None: def test_unknown_checkout_authority_blocks_automation_candidate() -> None: result = evaluate_automation_eligibility( _project( - checkout_authority={ - "schema_version": "CheckoutCollisionV1", - "selection": { - "state": "unknown", - "reason_code": "conflicting_full_clone_heads", - "representative_path": "Repo", - "selected_path": None, - } - } + checkout_authority=_authority( + state="unknown", + reason_code="conflicting_full_clone_heads", + ) ), decision_quality_status="trusted", ) @@ -169,25 +211,7 @@ def test_unknown_checkout_authority_blocks_automation_candidate() -> None: def test_observed_selected_checkout_authority_remains_eligible() -> None: result = evaluate_automation_eligibility( - _project( - checkout_authority={ - "schema_version": "CheckoutCollisionV1", - "selection": { - "state": "selected", - "reason_code": "single_clone_topology", - "representative_path": "Repo", - "selected_path": "Repo", - }, - "checkouts": [ - { - "path": "Repo", - "state": "observed", - "relation": "representative", - "bare": False, - } - ], - } - ), + _project(checkout_authority=_authority()), decision_quality_status="trusted", ) @@ -219,25 +243,7 @@ def test_multiple_observed_worktrees_require_checkout_authority() -> None: def test_checkout_authority_must_cover_repository_worktree_topology() -> None: - project = _project( - checkout_authority={ - "schema_version": "CheckoutCollisionV1", - "selection": { - "state": "selected", - "reason_code": "single_clone_topology", - "representative_path": "Repo", - "selected_path": "Repo", - }, - "checkouts": [ - { - "path": "Repo", - "state": "observed", - "relation": "representative", - "bare": False, - } - ], - } - ) + project = _project(checkout_authority=_authority()) project["repository_state"].update( { "state": "observed", @@ -248,6 +254,11 @@ def test_checkout_authority_must_cover_repository_worktree_topology() -> None: "path": "/outside/repo-feature", "dirty": False, }, + { + "state": "observed", + "path": "/outside/repo-review", + "dirty": False, + }, ], } ) @@ -261,6 +272,34 @@ def test_checkout_authority_must_cover_repository_worktree_topology() -> None: assert result.blockers == ("checkout-authority-topology-mismatch",) +def test_malformed_checkout_authority_variants_block_automation() -> None: + variants = [] + + missing_field = _authority() + missing_field.pop("origin") + variants.append(missing_field) + + invalid_type = _authority() + invalid_type["selection"] = "selected" + variants.append(invalid_type) + + invalid_count = _authority() + invalid_count["checkout_count"] = 3 + variants.append(invalid_count) + + malformed_record = _authority() + del malformed_record["checkouts"][0]["head"] + variants.append(malformed_record) + + for authority in variants: + result = evaluate_automation_eligibility( + _project(checkout_authority=deepcopy(authority)), + decision_quality_status="trusted", + ) + assert result.eligible is False + assert result.blockers == ("checkout-authority-malformed",) + + # --- select_automation_candidates ------------------------------------------ diff --git a/tests/test_portfolio_truth.py b/tests/test_portfolio_truth.py index ff2a88d1..5e4c71e2 100644 --- a/tests/test_portfolio_truth.py +++ b/tests/test_portfolio_truth.py @@ -5,6 +5,7 @@ import os import subprocess import time +from copy import deepcopy from dataclasses import replace from datetime import datetime, timedelta, timezone from pathlib import Path @@ -58,11 +59,64 @@ def _write(path: Path, content: str) -> None: def _set_mtime(path: Path, timestamp: float) -> None: path.touch() path.chmod(0o644) - import os - os.utime(path, (timestamp, timestamp)) +def _checkout_authority_fixture( + *, + canonical_path: str, + origin: str, + state: str = "selected", + reason_code: str = "single_clone_topology", +) -> dict: + representative = { + "path": canonical_path, + "state": "observed", + "relation": "representative", + "head": "1" * 40, + "branch": "main", + "dirty": False, + "dirty_path_count": 0, + "bare": False, + } + other = { + "path": ( + f"{canonical_path}-linked" + if state == "selected" + else f"Archive/{canonical_path}" + ), + "state": "observed", + "relation": ( + "linked_worktree" if state == "selected" else "independent_full_clone" + ), + "head": ("1" if state == "selected" else "2") * 40, + "branch": "feature", + "dirty": False, + "dirty_path_count": 0, + "bare": False, + } + return { + "schema_version": "CheckoutCollisionV1", + "origin": origin, + "canonical_project_path": canonical_path, + "checkout_count": 2, + "full_clone_count": 1 if state == "selected" else 2, + "declared_checkout_paths": [], + "declared_path_evidence": [], + "unresolved_declared_paths": [], + "selection": { + "state": state, + "reason_code": reason_code, + "reason": "fixture authority", + "representative_path": canonical_path, + "selected_path": canonical_path if state == "selected" else None, + "rationale": "fixture selection", + }, + "checkouts": [representative, other], + "discarded_checkouts": [other], + } + + def _security_test_project( name: str, *, @@ -4170,15 +4224,12 @@ def test_context_recovery_plan_skips_unknown_checkout_authority( project, repository_state={ **project.repository_state, - "checkout_authority": { - "schema_version": "CheckoutCollisionV1", - "selection": { - "state": "unknown", - "reason_code": "conflicting_full_clone_heads", - "representative_path": project.identity.path, - "selected_path": None, - } - }, + "checkout_authority": _checkout_authority_fixture( + canonical_path=project.identity.path, + origin=project.identity.repo_full_name or "fixture/FreshCollision", + state="unknown", + reason_code="conflicting_full_clone_heads", + ), }, ) if project.identity.project_key == "FreshCollision" @@ -4248,12 +4299,50 @@ def test_context_recovery_malformed_authority_never_redirects_target_path( ) assert target.status == "skipped" - assert target.reason == "checkout-authority-path-mismatch" + assert target.reason == "checkout-authority-malformed" assert target.relative_path == "FreshMalformed" assert target.target_path.startswith(str(target_repo)) assert "malicious-target" not in target.target_path +def test_checkout_authority_path_falls_back_for_malformed_envelope_variants() -> None: + variants = [] + + missing_field = _checkout_authority_fixture( + canonical_path="Repo", origin="owner/Repo" + ) + missing_field.pop("origin") + variants.append(missing_field) + + invalid_type = _checkout_authority_fixture( + canonical_path="Repo", origin="owner/Repo" + ) + invalid_type["selection"] = "selected" + variants.append(invalid_type) + + invalid_count = _checkout_authority_fixture( + canonical_path="Repo", origin="owner/Repo" + ) + invalid_count["checkout_count"] = 3 + variants.append(invalid_count) + + malformed_record = _checkout_authority_fixture( + canonical_path="Repo", origin="owner/Repo" + ) + del malformed_record["checkouts"][0]["head"] + variants.append(malformed_record) + + for authority in variants: + project = { + "identity": {"path": "Repo", "repo_full_name": "owner/Repo"}, + "repository_state": { + "checkout_authority": deepcopy(authority), + }, + } + assert checkout_authority_path(project) == "Repo" + assert checkout_authority_blocker(project) == "checkout-authority-malformed" + + def test_context_recovery_apply_writes_primary_context_and_catalog_seed( portfolio_workspace: Path, portfolio_catalog: Path, diff --git a/tests/test_run_instructions_audit.py b/tests/test_run_instructions_audit.py index a048b4d7..24aaf6cb 100644 --- a/tests/test_run_instructions_audit.py +++ b/tests/test_run_instructions_audit.py @@ -1,3 +1,4 @@ +from copy import deepcopy import json import os import subprocess @@ -66,6 +67,64 @@ def _project(key, quality, *, archived=False, path=None): } +def _checkout_authority( + *, + canonical_path: str, + representative_path: str | None = None, + state: str = "selected", + reason_code: str = "single_clone_topology", +) -> dict: + representative_path = representative_path or canonical_path + representative = { + "path": representative_path, + "state": "observed", + "relation": "representative", + "head": "1" * 40, + "branch": "main", + "dirty": False, + "dirty_path_count": 0, + "bare": False, + } + other = { + "path": ( + canonical_path + if representative_path != canonical_path + else f"{canonical_path}-linked" + ) + if state == "selected" + else f"Archive/{canonical_path}", + "state": "observed", + "relation": ( + "linked_worktree" if state == "selected" else "independent_full_clone" + ), + "head": ("1" if state == "selected" else "2") * 40, + "branch": "feature", + "dirty": False, + "dirty_path_count": 0, + "bare": False, + } + return { + "schema_version": "CheckoutCollisionV1", + "origin": f"owner/{canonical_path.rsplit('/', 1)[-1]}", + "canonical_project_path": canonical_path, + "checkout_count": 2, + "full_clone_count": 1 if state == "selected" else 2, + "declared_checkout_paths": [], + "declared_path_evidence": [], + "unresolved_declared_paths": [], + "selection": { + "state": state, + "reason_code": reason_code, + "reason": "fixture authority", + "representative_path": representative_path, + "selected_path": representative_path if state == "selected" else None, + "rationale": "fixture selection", + }, + "checkouts": [representative, other], + "discarded_checkouts": [other], + } + + def test_select_pilot_stratifies_sorts_and_filters(): projects = ( [_project(f"b{i}", "boilerplate") for i in range(6)] @@ -140,23 +199,10 @@ def test_build_record_uses_selected_checkout_without_replacing_identity(): "run_instructions_present": True, }, "repository_state": { - "checkout_authority": { - "schema_version": "CheckoutCollisionV1", - "canonical_project_path": "Repo", - "selection": { - "state": "selected", - "representative_path": "_codex-worktrees/repo-main", - "selected_path": "_codex-worktrees/repo-main", - }, - "checkouts": [ - { - "path": "_codex-worktrees/repo-main", - "state": "observed", - "relation": "representative", - "bare": False, - } - ], - } + "checkout_authority": _checkout_authority( + canonical_path="Repo", + representative_path="_codex-worktrees/repo-main", + ) }, } @@ -311,15 +357,13 @@ def test_prepare_pilot_blocks_unknown_checkout_authority(tmp_path): "run_instructions_present": False, }, "repository_state": { - "checkout_authority": { - "schema_version": "CheckoutCollisionV1", - "selection": { - "state": "unknown", - "reason_code": "declared_path_conflicts_with_representative", - "representative_path": "ConflictedRepo", - "selected_path": None, - } - } + "checkout_authority": _checkout_authority( + canonical_path="ConflictedRepo", + state="unknown", + reason_code=( + "declared_path_conflicts_with_representative" + ), + ) }, }, { @@ -361,57 +405,58 @@ def test_prepare_pilot_malformed_authority_never_redirects_error_path(tmp_path): repo = workspace / "CanonicalRepo" repo.mkdir(parents=True) (repo / "AGENTS.md").write_text("# CanonicalRepo\n") + base_project = { + "identity": { + "project_key": "CanonicalRepo", + "path": "CanonicalRepo", + "display_name": "CanonicalRepo", + }, + "derived": { + "archived": False, + "context_quality": "full", + "context_files": ["AGENTS.md"], + "run_instructions_present": False, + }, + } + variants = [] + + missing_field = _checkout_authority(canonical_path="CanonicalRepo") + missing_field.pop("origin") + variants.append(missing_field) + + invalid_type = _checkout_authority(canonical_path="CanonicalRepo") + invalid_type["selection"] = "selected" + variants.append(invalid_type) + + invalid_count = _checkout_authority(canonical_path="CanonicalRepo") + invalid_count["checkout_count"] = 3 + variants.append(invalid_count) + + malformed_record = _checkout_authority(canonical_path="CanonicalRepo") + del malformed_record["checkouts"][0]["head"] + variants.append(malformed_record) + snapshot = { "workspace_root": str(workspace), "generated_at": "2026-08-03T12:00:00+00:00", - "projects": [ - { - "identity": { - "project_key": "CanonicalRepo", - "path": "CanonicalRepo", - "display_name": "CanonicalRepo", - }, - "derived": { - "archived": False, - "context_quality": "full", - "context_files": ["AGENTS.md"], - "run_instructions_present": False, - }, - "repository_state": { - "checkout_authority": { - "schema_version": "CheckoutCollisionV1", - "selection": { - "state": "selected", - "reason_code": "single_clone_topology", - "representative_path": "redirected/Repo", - "selected_path": "redirected/Repo", - }, - "checkouts": [ - { - "path": "redirected/Repo", - "state": "observed", - "relation": "representative", - "bare": False, - } - ], - } - }, - } - ], + "projects": [], } snap_path = tmp_path / "snap.json" - snap_path.write_text(json.dumps(snapshot)) + for authority in variants: + project = deepcopy(base_project) + project["repository_state"] = {"checkout_authority": authority} + snapshot["projects"] = [project] + snap_path.write_text(json.dumps(snapshot)) - result = prepare_pilot(str(snap_path), per_tier={"full": 1}) + result = prepare_pilot(str(snap_path), per_tier={"full": 1}) - assert result["state"] == "blocked" - assert result["records"] == [] - assert result["errors"] == [ - { - "project_key": "CanonicalRepo", - "abs_path": str(repo), - "error": "checkout_authority_blocked", - "reason": "checkout-authority-path-mismatch", - } - ] - assert "redirected" not in result["errors"][0]["abs_path"] + assert result["state"] == "blocked" + assert result["records"] == [] + assert result["errors"] == [ + { + "project_key": "CanonicalRepo", + "abs_path": str(repo), + "error": "checkout_authority_blocked", + "reason": "checkout-authority-malformed", + } + ] From 1b76bd2a37a30be961b8a98721d9bf48f78d9835 Mon Sep 17 00:00:00 2001 From: saagpatel Date: Mon, 3 Aug 2026 19:51:48 -0700 Subject: [PATCH 17/17] fix: preserve checkout identity and fail closed --- src/demo_portfolio.py | 2 + src/portfolio_checkout_authority.py | 9 ++ src/portfolio_truth_sources.py | 22 ++++- tests/test_automation_workflow.py | 19 ++++ tests/test_demo_portfolio.py | 7 ++ tests/test_portfolio_truth.py | 123 ++++++++++++++++++++++++-- tests/test_portfolio_truth_sources.py | 82 ++++++++++++++++- 7 files changed, 252 insertions(+), 12 deletions(-) diff --git a/src/demo_portfolio.py b/src/demo_portfolio.py index 9cd5fc13..7e79f61c 100644 --- a/src/demo_portfolio.py +++ b/src/demo_portfolio.py @@ -19,6 +19,7 @@ from typing import Any from src.github_security_coverage import GITHUB_SECURITY_RECEIPT_SCHEMA_VERSION +from src.portfolio_truth_sources import checkout_collision_summary from src.portfolio_truth_types import DERIVATION_POLICY_VERSION, SCHEMA_VERSION # The demo workspace is deliberately not a real filesystem path. @@ -841,6 +842,7 @@ def build_snapshot(generated_at: datetime, *, pressure: int = 0) -> dict[str, An "github_archived_count": 0, "duplicate_display_names": [], "unresolved_duplicate_display_names": [], + "checkout_collisions": checkout_collision_summary([]), }, "precedence_matrix": { "identity": ["demo fixture"], diff --git a/src/portfolio_checkout_authority.py b/src/portfolio_checkout_authority.py index 3def83f9..28679249 100644 --- a/src/portfolio_checkout_authority.py +++ b/src/portfolio_checkout_authority.py @@ -164,6 +164,15 @@ def validate_checkout_authority_envelope( raise ValueError(f"Malformed checkout bare state for {path}.") if checkout.get("state") == "observed" and not isinstance(bare, bool): raise ValueError(f"Observed checkout must declare bare state for {path}.") + if state == "selected": + if checkout.get("state") != "observed": + raise ValueError( + "Selected checkout authority requires complete observations." + ) + if bare is False and (dirty is not False or dirty_count != 0): + raise ValueError( + "Selected checkout authority cannot contain local work." + ) representative_checkout = next( ( diff --git a/src/portfolio_truth_sources.py b/src/portfolio_truth_sources.py index 8e0169f8..f5d1990d 100644 --- a/src/portfolio_truth_sources.py +++ b/src/portfolio_truth_sources.py @@ -237,7 +237,7 @@ def _dedupe_checkouts_by_origin( canonical.append(project) for origin_key, group in by_origin.items(): - identity_project = _checkout_representative(group, origin_key) + identity_project = _checkout_identity_project(group, origin_key) authority_group = _checkout_topology_group( group, workspace_root=workspace_root, @@ -418,6 +418,22 @@ def _checkout_representative( ) +def _checkout_identity_project( + group: list[dict[str, Any]], origin_key: str +) -> dict[str, Any]: + """Choose stable catalog identity independently of mutation-path health.""" + repo_base = origin_key.rsplit("/", 1)[-1] + return min( + group, + key=lambda project: ( + str(project.get("name", "")).lower() != repo_base, + len(Path(str(project.get("path", ""))).parts), + len(str(project.get("path", ""))), + str(project.get("path", "")).lower(), + ), + ) + + def _canonical_checkout_project( *, identity_project: dict[str, Any], @@ -488,6 +504,10 @@ def _checkout_collision_record( state = "unknown" reason_code = "checkout_observation_failed" reason = "one or more same-origin checkouts could not be observed completely" + elif _checkout_observation(representative).get("bare") is True: + state = "unknown" + reason_code = "bare_representative_unusable" + reason = "the only authoritative checkout is a bare repository" elif len(declared_checkout_paths) > 1: state = "unknown" reason_code = "conflicting_declared_checkout_paths" diff --git a/tests/test_automation_workflow.py b/tests/test_automation_workflow.py index 76ed574a..c2f1bee6 100644 --- a/tests/test_automation_workflow.py +++ b/tests/test_automation_workflow.py @@ -315,6 +315,25 @@ def test_context_pr_plan_rejects_malformed_authority_variants() -> None: del malformed_record["checkouts"][0]["head"] variants.append(malformed_record) + unknown_discarded = _checkout_authority() + unknown_discarded["checkouts"][1].update( + { + "state": "unknown", + "head": None, + "branch": None, + "dirty": None, + "dirty_path_count": None, + "bare": None, + } + ) + variants.append(unknown_discarded) + + dirty_discarded = _checkout_authority() + dirty_discarded["checkouts"][1].update( + {"dirty": True, "dirty_path_count": 1} + ) + variants.append(dirty_discarded) + for authority in variants: project = _project( repository_state={"checkout_authority": deepcopy(authority)} diff --git a/tests/test_demo_portfolio.py b/tests/test_demo_portfolio.py index fe077c4d..53e55d02 100644 --- a/tests/test_demo_portfolio.py +++ b/tests/test_demo_portfolio.py @@ -26,6 +26,7 @@ resolved_coverage_state, ) from src.github_security_coverage import GITHUB_SECURITY_RECEIPT_SCHEMA_VERSION +from src.portfolio_truth_sources import checkout_collision_summary from src.portfolio_truth_types import ( SCHEMA_VERSION, VALID_ACTIVITY_STATUS, @@ -47,6 +48,12 @@ def test_schema_version_tracks_the_producer_constant() -> None: assert _snapshot()["schema_version"] == SCHEMA_VERSION +def test_current_schema_includes_valid_empty_checkout_authority_summary() -> None: + assert _snapshot()["source_summary"]["checkout_collisions"] == ( + checkout_collision_summary([]) + ) + + def test_generated_at_lands_inside_the_consumer_fresh_window() -> None: generated_at = datetime.fromisoformat(_snapshot()["generated_at"]) age_hours = (NOW - generated_at).total_seconds() / 3600 diff --git a/tests/test_portfolio_truth.py b/tests/test_portfolio_truth.py index 5e4c71e2..a1771144 100644 --- a/tests/test_portfolio_truth.py +++ b/tests/test_portfolio_truth.py @@ -26,6 +26,7 @@ from src.portfolio_checkout_authority import ( checkout_authority_blocker, checkout_authority_path, + validate_checkout_authority_envelope, ) from src.portfolio_truth_publish import ( PortfolioTruthPublishError, @@ -873,6 +874,86 @@ def _timeout_status(project_path: Path, *args: str) -> str: validate_truth_snapshot(result.snapshot) +def test_declared_bare_singleton_publishes_unknown_and_blocks_consumers( + portfolio_workspace: Path, + portfolio_catalog: Path, + legacy_registry: Path, +) -> None: + seed = portfolio_workspace / "_backups" / "bare-seed" + seed.mkdir(parents=True) + _write(seed / "README.md", "# BareRepo\n") + subprocess.run(["git", "init", "-q", "-b", "main"], cwd=seed, check=True) + subprocess.run(["git", "add", "README.md"], cwd=seed, check=True) + subprocess.run( + [ + "git", + "-c", + "user.name=Test", + "-c", + "user.email=test@example.invalid", + "commit", + "-q", + "-m", + "fixture", + ], + cwd=seed, + check=True, + ) + coordinator = portfolio_workspace / "BareRepo" + subprocess.run( + ["git", "clone", "-q", "--bare", str(seed), str(coordinator)], + check=True, + ) + subprocess.run( + [ + "git", + "remote", + "set-url", + "origin", + "git@github.com:owner/BareRepo.git", + ], + cwd=coordinator, + check=True, + ) + _write( + coordinator / "AGENTS.md", + "# BareRepo\n\n## Canonical Paths\n\n" + f"- Source: `{coordinator}`\n", + ) + + result = build_portfolio_truth_snapshot( + workspace_root=portfolio_workspace, + catalog_path=portfolio_catalog, + legacy_registry_path=legacy_registry, + include_notion=False, + now=datetime(2026, 8, 3, tzinfo=timezone.utc), + ) + + project = next( + item + for item in result.snapshot.projects + if item.identity.repo_full_name == "owner/BareRepo" + ) + authority = project.repository_state["checkout_authority"] + assert authority["checkout_count"] == 1 + assert authority["selection"]["state"] == "unknown" + assert authority["selection"]["selected_path"] is None + assert authority["selection"]["reason_code"] == "bare_representative_unusable" + assert checkout_authority_blocker( + project, + workspace_root=portfolio_workspace, + ) == "checkout-authority-unknown:bare_representative_unusable" + validate_truth_snapshot(result.snapshot) + + plan = build_context_recovery_plan( + result.snapshot, + workspace_root=portfolio_workspace, + ) + target = next(item for item in plan.projects if item.project_key == "BareRepo") + assert target.status == "skipped" + assert target.reason == "checkout-authority-unknown:bare_representative_unusable" + + def test_worktree_enumeration_failure_is_explicit_unknown_summary( portfolio_workspace: Path, portfolio_catalog: Path, @@ -1206,7 +1287,7 @@ def test_prunable_linked_worktree_is_unknown_not_publication_failure( validate_truth_snapshot(result.snapshot) -def test_bare_coordinator_worktree_flows_through_truth_validation( +def test_discovered_bare_coordinator_sibling_preserves_identity_and_mutation_path( portfolio_workspace: Path, portfolio_catalog: Path, legacy_registry: Path, @@ -1281,8 +1362,7 @@ def test_bare_coordinator_worktree_flows_through_truth_validation( cwd=coordinator, check=True, ) - linked = portfolio_workspace / "_codex-worktrees" / "repo-main" - linked.parent.mkdir() + linked = portfolio_workspace / "Repo-main" subprocess.run( ["git", "worktree", "add", "-q", str(linked), "main"], cwd=coordinator, @@ -1315,10 +1395,8 @@ def test_bare_coordinator_worktree_flows_through_truth_validation( assert project.advisory.notion_current_state == "Coordinator identity retained" assert authority["selection"]["state"] == "selected" assert authority["canonical_project_path"] == "Repo" - assert authority["selection"]["selected_path"] == ( - "_codex-worktrees/repo-main" - ) - assert checkout_authority_path(project) == "_codex-worktrees/repo-main" + assert authority["selection"]["selected_path"] == "Repo-main" + assert checkout_authority_path(project) == "Repo-main" assert project.repository_state["local"]["path"] == str(linked) assert checkout_authority_blocker( project, @@ -1331,7 +1409,7 @@ def test_bare_coordinator_worktree_flows_through_truth_validation( workspace_root=portfolio_workspace, ) target = next(item for item in plan.projects if item.project_key == "Repo") - assert target.relative_path == "_codex-worktrees/repo-main" + assert target.relative_path == "Repo-main" assert target.target_path.startswith(str(linked)) @@ -4332,6 +4410,29 @@ def test_checkout_authority_path_falls_back_for_malformed_envelope_variants() -> del malformed_record["checkouts"][0]["head"] variants.append(malformed_record) + unknown_discarded = _checkout_authority_fixture( + canonical_path="Repo", origin="owner/Repo" + ) + unknown_discarded["checkouts"][1].update( + { + "state": "unknown", + "head": None, + "branch": None, + "dirty": None, + "dirty_path_count": None, + "bare": None, + } + ) + variants.append(unknown_discarded) + + dirty_discarded = _checkout_authority_fixture( + canonical_path="Repo", origin="owner/Repo" + ) + dirty_discarded["checkouts"][1].update( + {"dirty": True, "dirty_path_count": 1} + ) + variants.append(dirty_discarded) + for authority in variants: project = { "identity": {"path": "Repo", "repo_full_name": "owner/Repo"}, @@ -4339,6 +4440,12 @@ def test_checkout_authority_path_falls_back_for_malformed_envelope_variants() -> "checkout_authority": deepcopy(authority), }, } + with pytest.raises(ValueError): + validate_checkout_authority_envelope( + authority, + identity_path="Repo", + repo_full_name="owner/Repo", + ) assert checkout_authority_path(project) == "Repo" assert checkout_authority_blocker(project) == "checkout-authority-malformed" diff --git a/tests/test_portfolio_truth_sources.py b/tests/test_portfolio_truth_sources.py index 9db2ccb6..8e9d5641 100644 --- a/tests/test_portfolio_truth_sources.py +++ b/tests/test_portfolio_truth_sources.py @@ -270,7 +270,8 @@ def test_working_checkout_is_preferred_over_bare_same_origin_repo() -> None: checkout_collisions=collisions, ) - assert result[0]["path"] == "Archive/Repo" + assert result[0]["path"] == "Repo" + assert result[0]["project_path"] == Path("/workspace/Archive/Repo") collision = collisions[0] assert collision["selection"]["state"] == "selected" assert collision["selection"]["selected_path"] == "Archive/Repo" @@ -298,7 +299,7 @@ def test_working_checkout_is_preferred_over_bare_same_origin_repo() -> None: ] -def test_observed_checkout_is_preferred_over_failed_basename_match() -> None: +def test_observed_checkout_supplies_evidence_without_replacing_basename_identity() -> None: collisions: list[dict] = [] head = "1" * 40 discovered = [ @@ -317,13 +318,88 @@ def test_observed_checkout_is_preferred_over_failed_basename_match() -> None: checkout_collisions=collisions, ) - assert result[0]["path"] == "Archive/Repo" + assert result[0]["path"] == "Repo" + assert result[0]["project_path"] == Path("/workspace/Archive/Repo") collision = collisions[0] assert collision["selection"]["state"] == "unknown" assert collision["selection"]["reason_code"] == "checkout_observation_failed" assert collision["selection"]["representative_path"] == "Archive/Repo" +def test_discovered_linked_worktree_preserves_coordinator_catalog_identity() -> None: + collisions: list[dict] = [] + head = "1" * 40 + coordinator = _p( + "Repo", + "owner/Repo", + head=head, + common_dir="/git/Repo.git", + bare=True, + dirty=None, + dirty_path_count=None, + ) + coordinator.update( + { + "group_entry": {"owner": "coordinator-owner"}, + "source": "coordinator-source", + } + ) + linked = _p( + "Repo-main", + "owner/Repo", + head=head, + common_dir="/git/Repo.git", + ) + linked.update( + { + "group_entry": {"owner": "linked-owner"}, + "source": "linked-source", + } + ) + + result = _dedupe_checkouts_by_origin( + [coordinator, linked], + checkout_collisions=collisions, + ) + + assert result[0]["name"] == "Repo" + assert result[0]["path"] == "Repo" + assert result[0]["group_entry"] == {"owner": "coordinator-owner"} + assert result[0]["source"] == "coordinator-source" + assert result[0]["project_path"] == Path("/workspace/Repo-main") + assert collisions[0]["selection"]["selected_path"] == "Repo-main" + + +def test_declared_bare_singleton_is_unknown() -> None: + declaration = { + "absolute_path": "/workspace/Repo", + "workspace_relative_path": "Repo", + "source_file": "AGENTS.md", + } + collisions: list[dict] = [] + discovered = [ + _p( + "Repo", + "owner/Repo", + head="1" * 40, + common_dir="/git/Repo.git", + bare=True, + dirty=None, + dirty_path_count=None, + declared_paths=[declaration], + ) + ] + + _dedupe_checkouts_by_origin(discovered, checkout_collisions=collisions) + + assert len(collisions) == 1 + collision = collisions[0] + assert collision["checkout_count"] == 1 + assert collision["selection"]["state"] == "unknown" + assert collision["selection"]["selected_path"] is None + assert collision["selection"]["reason_code"] == "bare_representative_unusable" + + def test_declared_linked_worktree_conflicts_with_representative() -> None: declaration = { "absolute_path": "/workspace/Repo-fix/src",