diff --git a/src/operator_os_seam_linter.py b/src/operator_os_seam_linter.py index ea081d9..00eba5e 100644 --- a/src/operator_os_seam_linter.py +++ b/src/operator_os_seam_linter.py @@ -13,6 +13,10 @@ from src.portfolio_truth_render import GENERATED_MARKDOWN_PROVENANCE_MARKER from src.portfolio_truth_types import LEGACY_SCHEMA_VERSIONS, SCHEMA_VERSION, truth_latest_path from src.portfolio_catalog import load_portfolio_catalog +from src.portfolio_truth_sources import ( + WORKSPACE_DISCOVERY_POLICY_VERSION, + workspace_exclusion_reason, +) from src.project_registry import ( BRIDGE_CANONICAL_KEY_DISAGREEMENTS, DEFAULT_NOTION_PROJECTION_ONLY_ROWS, @@ -427,6 +431,7 @@ def _check_contract_shadow( ) findings.extend(_check_rollup_integrity(truth, truth_path=truth_path)) + findings.extend(_check_exclusion_integrity(truth, truth_path=truth_path)) findings.extend(_check_carried_freshness(truth, truth_path=truth_path, now=now)) return findings @@ -560,6 +565,59 @@ def _check_carried_freshness( return [] +def _check_exclusion_integrity( + truth: dict[str, Any], *, truth_path: Path +) -> list[SeamLintFinding]: + exclusions = truth.get("exclusions") + if not isinstance(exclusions, dict): + return [ + SeamLintFinding( + check="CL-EXCL-001", + artifact=str(truth_path), + violation="workspace exclusion envelope is absent", + detail="Exclusion policy and counts cannot be verified.", + level="unknown", + ) + ] + policy_version = exclusions.get("policy_version") + counts = exclusions.get("counts") + if policy_version != WORKSPACE_DISCOVERY_POLICY_VERSION or not isinstance( + counts, dict + ): + return [ + SeamLintFinding( + check="CL-EXCL-001", + artifact=str(truth_path), + violation="workspace exclusion envelope is incompatible", + detail=( + f"policy_version={policy_version!r}; " + f"expected={WORKSPACE_DISCOVERY_POLICY_VERSION!r}" + ), + ) + ] + + leaked: list[str] = [] + for project in truth.get("projects", []): + if not isinstance(project, dict): + continue + identity = project.get("identity") + path = identity.get("path") if isinstance(identity, dict) else None + if not isinstance(path, str): + continue + if any(workspace_exclusion_reason(part) for part in Path(path).parts): + leaked.append(path) + if not leaked: + return [] + return [ + SeamLintFinding( + check="CL-EXCL-001", + artifact=str(truth_path), + violation="excluded workspace paths leaked into portfolio projects", + detail=f"count={len(leaked)}; sample={', '.join(sorted(leaked)[:10])}", + ) + ] + + def _check_schema_pin( truth: dict[str, Any], *, diff --git a/src/portfolio_truth_reconcile.py b/src/portfolio_truth_reconcile.py index a4876d0..87c7f9f 100644 --- a/src/portfolio_truth_reconcile.py +++ b/src/portfolio_truth_reconcile.py @@ -19,6 +19,7 @@ from src.portfolio_pathing import build_operating_path_entry from src.portfolio_risk import build_risk_entry from src.portfolio_truth_sources import ( + WORKSPACE_DISCOVERY_POLICY_VERSION, discover_workspace_projects, load_legacy_registry_rows, load_safe_notion_project_context, @@ -242,10 +243,12 @@ def build_portfolio_truth_snapshot( len(notion_context), ) + exclusion_counts: dict[str, int] = {} workspace_projects = discover_workspace_projects( workspace_root, catalog_data=catalog_data, now=now, + exclusion_counts=exclusion_counts, ) projects = [ _build_truth_project( @@ -316,6 +319,10 @@ def build_portfolio_truth_snapshot( notion_context_carried_forward=notion_context_carried_forward, prior_notion_generated_at=prior_notion_generated_at, ), + exclusions={ + "policy_version": WORKSPACE_DISCOVERY_POLICY_VERSION, + "counts": dict(sorted(exclusion_counts.items())), + }, ) return PortfolioTruthBuildResult( snapshot=snapshot, catalog_data=catalog_data, legacy_rows=legacy_rows diff --git a/src/portfolio_truth_sources.py b/src/portfolio_truth_sources.py index 7a8438f..81871c2 100644 --- a/src/portfolio_truth_sources.py +++ b/src/portfolio_truth_sources.py @@ -86,18 +86,36 @@ # nogoprjs -> operator-flagged "no-go" projects, never pursued # smoke-export -> generated AuraForge signed-smoke-export bundles (no real repo) IGNORE_PROJECT_DIR_TOKENS = frozenset({"nogoprjs", "smoke-export"}) +IGNORE_PROJECT_DIR_NAMES = frozenset({"codex backups"}) # Transient / generated working directories matched by regex on the dir name — # e.g. a `-tmp-` clone left behind by a tooling run. IGNORE_PROJECT_DIR_PATTERNS: tuple[re.Pattern[str], ...] = (re.compile(r"-tmp-\d+$"),) ARCHIVE_REMOTE_BASENAME_TOKENS = frozenset({"private-archive", "scrubbed-import"}) -def _is_ignored_project_dir(name: str) -> bool: - """True if a directory name is a transient/non-project artifact to skip.""" +WORKSPACE_DISCOVERY_POLICY_VERSION = "workspace_discovery.v1" + + +def workspace_exclusion_reason(name: str) -> str | None: + """Return the stable policy reason for a non-project directory name.""" lowered = name.lower() + if lowered in IGNORE_PROJECT_DIR_NAMES: + return "backup-container" if any(token in lowered for token in IGNORE_PROJECT_DIR_TOKENS): - return True - return any(pattern.search(name) for pattern in IGNORE_PROJECT_DIR_PATTERNS) + return "operator-excluded" if "nogoprjs" in lowered else "generated-evidence" + if any(pattern.search(name) for pattern in IGNORE_PROJECT_DIR_PATTERNS): + return "temporary-checkout" + return None + + +def _is_ignored_project_dir(name: str) -> bool: + """True if a directory name is a transient/non-project artifact to skip.""" + return workspace_exclusion_reason(name) is not None + + +def _record_exclusion(counts: dict[str, int] | None, reason: str | None) -> None: + if counts is not None and reason is not None: + counts[reason] = counts.get(reason, 0) + 1 def discover_workspace_projects( @@ -105,6 +123,7 @@ def discover_workspace_projects( *, catalog_data: dict[str, Any], now: datetime | None = None, + exclusion_counts: dict[str, int] | None = None, ) -> list[dict[str, Any]]: discovered: list[dict[str, Any]] = [] now = now or datetime.now(timezone.utc) @@ -112,7 +131,9 @@ def discover_workspace_projects( for child in sorted(workspace_root.iterdir(), key=lambda item: item.name.lower()): if child.name.startswith(".") or not child.is_dir() or child.is_symlink(): continue - if _is_ignored_project_dir(child.name): + exclusion_reason = workspace_exclusion_reason(child.name) + if exclusion_reason is not None: + _record_exclusion(exclusion_counts, exclusion_reason) continue if _is_project_dir(child): discovered.append( @@ -121,7 +142,12 @@ def discover_workspace_projects( continue discovered.extend( _discover_nested_projects( - child, workspace_root, catalog_data=catalog_data, now=now, depth=2 + child, + workspace_root, + catalog_data=catalog_data, + now=now, + depth=2, + exclusion_counts=exclusion_counts, ) ) return _dedupe_checkouts_by_origin(discovered) @@ -175,6 +201,7 @@ def _discover_nested_projects( catalog_data: dict[str, Any], now: datetime, depth: int, + exclusion_counts: dict[str, int] | None = None, ) -> list[dict[str, Any]]: if depth <= 0: return [] @@ -183,7 +210,9 @@ def _discover_nested_projects( for child in sorted(root.iterdir(), key=lambda item: item.name.lower()): if child.name.startswith(".") or not child.is_dir() or child.is_symlink(): continue - if _is_ignored_project_dir(child.name): + exclusion_reason = workspace_exclusion_reason(child.name) + if exclusion_reason is not None: + _record_exclusion(exclusion_counts, exclusion_reason) continue if _is_project_dir(child): discovered.append( @@ -192,7 +221,12 @@ def _discover_nested_projects( continue discovered.extend( _discover_nested_projects( - child, workspace_root, catalog_data=catalog_data, now=now, depth=depth - 1 + child, + workspace_root, + catalog_data=catalog_data, + now=now, + depth=depth - 1, + exclusion_counts=exclusion_counts, ) ) return discovered diff --git a/tests/test_operator_os_seam_linter.py b/tests/test_operator_os_seam_linter.py index 678b74f..cfcd30d 100644 --- a/tests/test_operator_os_seam_linter.py +++ b/tests/test_operator_os_seam_linter.py @@ -189,6 +189,7 @@ def test_contract_shadow_marks_legacy_lineage_unknown_without_failing( "CL-PROD-001", "CL-INP-001", "CL-COUNT-001", + "CL-EXCL-001", } @@ -258,6 +259,50 @@ def test_contract_shadow_warns_for_carried_notion_older_than_48_hours( ) +def test_contract_shadow_fails_when_excluded_backup_leaks_into_projects( + tmp_path: Path, +) -> None: + truth, markdown = _passing_paths(tmp_path) + payload = json.loads(truth.read_text()) + payload.update( + { + "producer": {}, + "inputs": { + "notion": {"mode": "unavailable"}, + "catalog": {"sha256": None}, + }, + "source_summary": {"attention_state_counts": {"decision-needed": 1}}, + "rollups": {"decision": {"decision_needed_count": 1}}, + "exclusions": { + "policy_version": "workspace_discovery.v1", + "counts": {}, + }, + "projects": [ + { + "identity": { + "path": "Documents/Codex Backups/Wave 2R Post-Update" + }, + "derived": {"attention_state": "decision-needed"}, + } + ], + } + ) + truth.write_text(json.dumps(payload)) + + result = lint_operator_os_seams( + truth_path=truth, + markdown_paths=markdown, + contract_shadow=True, + catalog_path=tmp_path / "missing-catalog.yaml", + now=NOW, + ) + + assert not result.passed + finding = next(item for item in result.findings if item.check == "CL-EXCL-001") + assert finding.level == "fail" + assert "Codex Backups" in finding.detail + + def test_schema_pin_mismatch_fails(tmp_path: Path) -> None: truth, markdown = _passing_paths(tmp_path) _write_truth(truth, schema_version="0.6.0") diff --git a/tests/test_portfolio_truth.py b/tests/test_portfolio_truth.py index bb1c965..f2654c3 100644 --- a/tests/test_portfolio_truth.py +++ b/tests/test_portfolio_truth.py @@ -374,6 +374,10 @@ def test_truth_snapshot_respects_declared_and_derived_fields( assert result.snapshot.derivation_policy_version == "portfolio_attention.v2" assert result.snapshot.inputs["catalog"]["sha256"] assert result.snapshot.inputs["notion"]["mode"] == "unavailable" + assert result.snapshot.exclusions == { + "policy_version": "workspace_discovery.v1", + "counts": {}, + } assert result.snapshot.source_summary["attention_state_counts"]["active-product"] == 1 assert result.snapshot.source_summary["attention_state_counts"]["parked"] == 1 diff --git a/tests/test_portfolio_truth_sources.py b/tests/test_portfolio_truth_sources.py index e2f4387..b224d76 100644 --- a/tests/test_portfolio_truth_sources.py +++ b/tests/test_portfolio_truth_sources.py @@ -14,6 +14,7 @@ _dedupe_checkouts_by_origin, _is_ignored_project_dir, discover_workspace_projects, + workspace_exclusion_reason, ) @@ -96,6 +97,8 @@ def test_ignore_predicate_matches_transient_dirs() -> None: assert _is_ignored_project_dir("NoGoPRJs") assert _is_ignored_project_dir("auraforge-signed-smoke-export") assert _is_ignored_project_dir("resume-evolver-tmp-1776063720") + assert _is_ignored_project_dir("Codex Backups") + assert workspace_exclusion_reason("Codex Backups") == "backup-container" def test_ignore_predicate_keeps_real_projects() -> None: @@ -107,6 +110,8 @@ def test_ignore_predicate_keeps_real_projects() -> None: "resume-evolver", # the real repo, sans -tmp- suffix "smoke-test-runner", # "smoke" but not "smoke-export" "tmp-tools", # "tmp" but not the -tmp- clone pattern + "CodexBackupTool", + "BackupBuddy", ): assert not _is_ignored_project_dir(name), name @@ -121,10 +126,20 @@ def _project(*parts: str) -> None: _project("NoGoPRJs", "app") # nested under ignored container -> skipped _project("auraforge-signed-smoke-export", "foo-plan") # ignored bundle -> skipped _project("resume-evolver-tmp-1776063720") # top-level tmp clone -> skipped + _project("Documents", "Codex Backups", "Wave 2R Post-Update", "README-fixture") + _project("Documents", "RealNestedProject") + exclusion_counts: dict[str, int] = {} result = discover_workspace_projects( tmp_path, catalog_data={}, now=datetime(2026, 6, 2, tzinfo=timezone.utc), + exclusion_counts=exclusion_counts, ) - assert {p["name"] for p in result} == {"LegitProject"} + assert {p["name"] for p in result} == {"LegitProject", "RealNestedProject"} + assert exclusion_counts == { + "backup-container": 1, + "generated-evidence": 1, + "operator-excluded": 1, + "temporary-checkout": 1, + }