diff --git a/config/notion-projection-policy.schema.json b/config/notion-projection-policy.schema.json index 23aa906..c9b7d1c 100644 --- a/config/notion-projection-policy.schema.json +++ b/config/notion-projection-policy.schema.json @@ -1,18 +1,19 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://local.invalid/github-repo-auditor/notion-projection-policy.v1.schema.json", + "$id": "https://local.invalid/github-repo-auditor/notion-projection-policy.v2.schema.json", "title": "Notion projection policy", - "description": "Explains Local Portfolio rows that are aliases of, or intentional projections outside, GithubRepoAuditor portfolio truth.", + "description": "Explains Local Portfolio rows that are aliases of, intentional projections outside, or canonical shadows for GithubRepoAuditor portfolio truth.", "type": "object", "additionalProperties": false, "required": [ "schema_version", "notion_title_aliases", - "notion_projection_only_rows" + "notion_projection_only_rows", + "notion_truth_shadow_rows" ], "properties": { "schema_version": { - "const": "notion_projection_policy.v1" + "const": "notion_projection_policy.v2" }, "notion_title_aliases": { "type": "object", @@ -27,6 +28,13 @@ "type": "string", "minLength": 1 } + }, + "notion_truth_shadow_rows": { + "type": "object", + "additionalProperties": { + "type": "string", + "minLength": 1 + } } } } diff --git a/config/project-registry-overrides.json b/config/project-registry-overrides.json index 615188d..4e746bd 100644 --- a/config/project-registry-overrides.json +++ b/config/project-registry-overrides.json @@ -1,5 +1,5 @@ { - "_comment": "Operator-editable enrollment for the canonical project registry. 'overrides' pins hard normalization failures (drifted identifier -> canonical project_key). 'supplementary' enrolls real operator-OS projects the auditor does not track as git repos. 'memory_meta' maps ~/.claude memory note-slugs to their parent project (or '' for pure meta-notes). 'notion_projection_policy_schema_version', 'notion_title_aliases', and 'notion_projection_only_rows' explain Local Portfolio projection differences that are not distinct GithubRepoAuditor truth projects. Consumed by src/project_registry.py and cross-system smoke checks; falls back to built-in defaults if this file is absent.", + "_comment": "Operator-editable enrollment for the canonical project registry. 'overrides' pins hard normalization failures (drifted identifier -> canonical project_key). 'supplementary' enrolls real operator-OS projects the auditor does not track as git repos. 'memory_meta' maps ~/.claude memory note-slugs to their parent project (or '' for pure meta-notes). 'notion_projection_policy_schema_version', 'notion_title_aliases', 'notion_projection_only_rows', and 'notion_truth_shadow_rows' explain Local Portfolio projection differences that are not distinct GithubRepoAuditor truth projects. Consumed by src/project_registry.py and cross-system smoke checks; falls back to built-in defaults if this file is absent.", "overrides": { "jcc": "JobCommandCenter", "jsm_export": "JSMTicketAnalyticsExport", @@ -34,7 +34,7 @@ "skill_library_port_2026-05": "", "skill_eval_harness_2026-05": "" }, - "notion_projection_policy_schema_version": "notion_projection_policy.v1", + "notion_projection_policy_schema_version": "notion_projection_policy.v2", "notion_title_aliases": { "DesktopTerrarium": "desktop_terrarium", "DesktopPEt-ready": "DesktopPEt", @@ -59,5 +59,9 @@ "claude-code-harness": "local agent harness projection; outside repo-root truth", "Sandbox Local Portfolio Project": "actuation sandbox fixture row", "SecondBrain": "knowledge vault under /Users/d/Documents; not a /Users/d/Projects repo" + }, + "notion_truth_shadow_rows": { + "agent-bridge-launch": "agent-bridge", + "PortfolioCommandCenter-public": "PortfolioCommandCenter" } } diff --git a/docs/plans/2026-06-19-notion-projection-reconciliation.md b/docs/plans/2026-06-19-notion-projection-reconciliation.md new file mode 100644 index 0000000..de5145e --- /dev/null +++ b/docs/plans/2026-06-19-notion-projection-reconciliation.md @@ -0,0 +1,209 @@ +# Notion Projection Reconciliation - 2026-06-19 + +## Verified Current State + +- Canonical truth source: `output/portfolio-truth-latest.json` + - `generated_at`: `2026-06-19T05:48:13.055063+00:00` + - project count: 136 + - Notion context rows: 142 +- Legacy audit report: `output/audit-report-saagpatel-2026-06-19.json` + - `generated_at`: `2026-06-19T07:46:41.857483+00:00` + - audited repo count: 149 +- Local Notion projection snapshot after approved row creation: `/Users/d/.local/share/notion-os/project-snapshot.json` + - generated on 2026-06-19 + - project count: 143 +- `cross-system-smoke` `contract-health` now passes. + - Latest verified run: `2026-06-19T07-47-01Z-81813` + - `C2-truth-artifact-parity`: PASS, audit report and truth are both current + on 2026-06-19; truth-only divergence is now 0. + - `C7-notion-projection`: PASS, snapshot 143 projects vs truth 136, + projection policy explains 4 Notion-only rows and 2 truth-shadow rows. + - `C8-projection-policy-contract`: PASS on `notion_projection_policy.v2`. +- Remaining unexpected projection drift: none. + +## Completed Live Row Creation + +Approved and created in `Local Portfolio Projects` on 2026-06-19: + +- `agent-bridge` +- `cross-provider-egress-guard` +- `machine-control-tower` +- `mcp-trust` +- `fable-outputs` + +The local Notion snapshot refresh then moved C7 from seven truth-only rows to +two deferred/policy rows. + +## Row Classification + +| Truth row | Verified truth state | Notion evidence | Recommended disposition | +| --- | --- | --- | --- | +| `agent-bridge` | active, high criticality, `saagpatel/agent-bridge` | created 2026-06-19 | resolved | +| `cross-provider-egress-guard` | active, high criticality, `saagpatel/cross-provider-egress-guard` | created 2026-06-19 | resolved | +| `machine-control-tower` | active, high criticality, `saagpatel/machine-control-tower` | created 2026-06-19 | resolved | +| `mcp-trust` | active, high criticality, `saagpatel/mcp-trust` | created 2026-06-19 | resolved | +| `fable-outputs` | experimental, medium criticality, active through June 10-22, 2026 Fable window | created 2026-06-19 | resolved for projection; revisit after campaign window | +| `agent-bridge-launch` | no repo, weak catalog contract; launch-material folder for `agent-bridge` | represented by `agent-bridge` Notion row | resolved as `notion_truth_shadow_rows["agent-bridge-launch"] = "agent-bridge"` | +| `PortfolioCommandCenter-public` | archived public/demo mirror of `PortfolioCommandCenter` | exact `PortfolioCommandCenter` Notion row exists | resolved as `notion_truth_shadow_rows["PortfolioCommandCenter-public"] = "PortfolioCommandCenter"` | + +## Projection Policy Upgrade + +Implemented `notion_projection_policy.v2` with a required +`notion_truth_shadow_rows` map. This map is for truth-side rows that should not +receive their own Local Portfolio Project row because an existing canonical row +already represents them. + +Current v2 shadow rows: + +- `agent-bridge-launch` -> `agent-bridge` +- `PortfolioCommandCenter-public` -> `PortfolioCommandCenter` + +The generated local `output/project-registry.json` policy block was refreshed +from `config/project-registry-overrides.json` after the full portfolio-truth +publish command refused to overwrite the snapshot without Notion context. + +## Artifact Refresh + +Completed the next strong lane after the projection repair: + +- Loaded Notion context from the local Notion repo environment without printing + the token. +- Refreshed portfolio truth with Notion context intact. The publish guard did + not fire; the resulting snapshot carries 142 Notion context rows. +- Refreshed the legacy GithubRepoAuditor audit report for all 149 repos. +- Reran `contract-health`; C2 now has no truth-only repos. The remaining + audit-only repos are a legacy-audit superset difference, not missing truth. +- Repaired portfolio truth remote identity selection so public/canonical + remotes win over private archive/import origins for the verified local + mismatch cases. The refreshed truth snapshot was generated at + `2026-06-19T07:57:10.669963+00:00` and still contains 136 projects. +- Verified the repaired identities: + - `ApplyKit` now maps to `saagpatel/ApplyKit`. + - `GithubRepoAuditor` now maps to `saagpatel/GithubRepoAuditor`. +- Reran `contract-health`; latest passing run: + `2026-06-19T07-57-47Z-59581`. + +Current audit-only repos after the June 19 refresh: + +- `saagpatel/ApplyKit-private-archive-20260517` +- `saagpatel/GithubRepoAuditor-private-archive-20260518` +- `saagpatel/GithubRepoAuditor-scrubbed-import-20260518` +- `saagpatel/SecondBrain` +- `saagpatel/agent-harness-hardening` +- `saagpatel/ai-workstation-bootstrap` +- `saagpatel/app` +- `saagpatel/claude-code-workstation-bootstrap` +- `saagpatel/codexkit` +- `saagpatel/hermes-agent` +- `saagpatel/personal-ops` +- `saagpatel/portfolio-actuation-sandbox` +- `saagpatel/renovate-config` +- `saagpatel/saagpatel` + +## Audit-Only Classification + +These rows are present in the refreshed legacy GitHub audit report but absent +from portfolio truth's repo set. They are not all the same kind of drift. + +| Audit-only repo | Verified evidence | Classification | Recommended disposition | +| --- | --- | --- | --- | +| `saagpatel/ApplyKit-private-archive-20260517` | Local `/Users/d/Projects/ApplyKit` exists. Local `origin` points to `ApplyKit-private-archive-20260517`; `legacy-origin` points to `ApplyKit`. Portfolio truth now tracks `saagpatel/ApplyKit`, so the remaining audit-only repo is the private archive identity. | private archive mirror | Keep outside portfolio truth. Optional future repo-config cleanup: rename or demote the archive remote so local git configuration is less misleading, but do not create a Local Portfolio Project row. | +| `saagpatel/GithubRepoAuditor-private-archive-20260518` | Local `/Users/d/Projects/GithubRepoAuditor` exists. Local `canonical` points to `GithubRepoAuditor`; `origin` points to `GithubRepoAuditor-private-archive-20260518`. Portfolio truth now tracks `saagpatel/GithubRepoAuditor`, so the remaining audit-only repo is the private archive identity. | private archive mirror | Keep outside portfolio truth. Optional future repo-config cleanup: make the public canonical remote the less surprising local default if approved, but no truth or Notion row action is needed. | +| `saagpatel/GithubRepoAuditor-scrubbed-import-20260518` | No direct local path. Private repo, no explicit catalog entry, same description/topics as GithubRepoAuditor. | import/scrub mirror | Keep outside portfolio truth. Consider upstream archive after confirming it is no longer needed for import provenance. | +| `saagpatel/SecondBrain` | Local path is `/Users/d/Documents/SecondBrain`, outside `/Users/d/Projects`. Project registry already has `supp:SecondBrain` and Notion projection-only treatment. | supplementary non-Projects knowledge vault | Keep outside portfolio truth unless the operator explicitly widens truth beyond `/Users/d/Projects`. Optional future registry cleanup: add repo metadata to supplementary evidence without making it first-class truth. | +| `saagpatel/personal-ops` | Local path is `/Users/d/.local/share/personal-ops`, outside `/Users/d/Projects`. Prior reconciliation explicitly keeps it supplementary unless approved. | supplementary non-Projects control plane | Keep outside portfolio truth without explicit operator approval. It can remain supplementary registry evidence. | +| `saagpatel/codexkit` | Local path is `/Users/d/.codex/codexkit`, outside `/Users/d/Projects`; dirty local operating-layer repo. | supplementary Codex operating surface | Keep outside portfolio truth by default. If promoted later, treat as local operating-system state, not normal product work. | +| `saagpatel/claude-code-workstation-bootstrap` | Local path is `/Users/d/claude-code-workstation-bootstrap`, outside `/Users/d/Projects`; bootstrap branch. | workstation bootstrap/support repo | Keep outside portfolio truth. Archive or mark manual-only upstream if it should stop appearing in legacy audit attention. | +| `saagpatel/portfolio-actuation-sandbox` | Local path is `/Users/d/portfolio-actuation-sandbox`, outside `/Users/d/Projects`; sandbox fixture repo with local dirty files. | actuation sandbox fixture | Keep outside portfolio truth. It is already represented as a Notion projection-only sandbox row, not a portfolio project. | +| `saagpatel/app` | GitHub repo is private and archived; no local direct path. Name collides with existing projection-only Local Portfolio placeholder `app`, but audit description is an archived SwiftUI app. | archived generic app repo | Keep outside portfolio truth. No Local Portfolio row; no action unless upstream archive hygiene is being cleaned. | +| `saagpatel/hermes-agent` | GitHub audit marks it as a fork; no local direct path; no explicit catalog entry. | fork/upstream-derived repo | Keep outside portfolio truth. Archive/delete decision is upstream GitHub hygiene, not portfolio truth work. | +| `saagpatel/agent-harness-hardening` | Public repo, no direct local path, no explicit catalog entry; sanitized hardening artifact. | published support artifact | Keep outside portfolio truth unless it becomes an active infra project with a local operating root. | +| `saagpatel/ai-workstation-bootstrap` | Private repo, no direct local path, no explicit catalog entry; sanitized portable bootstrap. | workstation bootstrap/support repo | Keep outside portfolio truth. Consider archive/manual-only upstream if no longer active. | +| `saagpatel/renovate-config` | Public readme-only shared Renovate config, no direct local path, no explicit catalog entry. | shared config support repo | Keep outside portfolio truth unless dependency-governance work promotes it to active infra. | +| `saagpatel/saagpatel` | Public profile repo, no direct local path, no explicit catalog entry; readme-only profile surface. | GitHub profile repo | Keep outside portfolio truth. Treat as profile/public-presence hygiene, not portfolio project work. | + +Net classification after the canonical remote repair: + +- First-class truth bug candidates: none currently verified. +- Private archive/import mirrors: `ApplyKit-private-archive-20260517`, + `GithubRepoAuditor-private-archive-20260518`, + `GithubRepoAuditor-scrubbed-import-20260518`. +- Supplementary local operating surfaces: `SecondBrain`, `personal-ops`, + `codexkit`, `claude-code-workstation-bootstrap`, + `portfolio-actuation-sandbox`. +- Keep-out/default archive/manual-only surfaces: `app`, `hermes-agent`, + `agent-harness-hardening`, `ai-workstation-bootstrap`, `renovate-config`, + `saagpatel`. + +Do not silently add these 14 repos to portfolio truth. The only high-leverage +repair candidates in this set have now been handled; the rest need an explicit +operator decision before truth scope expands. + +## Canonical Remote Repair + +Implemented in `src/portfolio_truth_sources.py`: + +- Portfolio truth now inspects all configured GitHub fetch remotes instead of + only `origin`. +- An explicit `canonical` remote wins as the portfolio identity. +- A normal `origin` still wins by default. +- If `origin` looks like a private archive/import identity, a non-archive remote + whose repo basename matches the local checkout directory can become the + portfolio identity. + +Focused regression tests were added in `tests/test_portfolio_truth.py` for: + +- `canonical` beating a private archive `origin`. +- `legacy-origin` public `ApplyKit` beating an archive `origin` when the + basename matches the checkout. +- normal `origin` behavior staying unchanged. + +Verification: + +- `uv run ruff check src/portfolio_truth_sources.py tests/test_portfolio_truth.py tests/test_portfolio_truth_sources.py` +- `PYTHONDONTWRITEBYTECODE=1 uv run pytest tests/test_portfolio_truth.py::test_git_remote_full_name_prefers_canonical_remote tests/test_portfolio_truth.py::test_git_remote_full_name_prefers_matching_public_remote_for_archive_origin tests/test_portfolio_truth.py::test_git_remote_full_name_keeps_normal_origin tests/test_portfolio_truth.py::test_extract_github_full_name_uses_exact_github_host tests/test_portfolio_truth.py::test_git_default_branch_reads_local_origin_head tests/test_portfolio_truth.py::test_git_default_branch_keeps_multi_segment_branch tests/test_portfolio_truth.py::test_git_default_branch_empty_when_origin_head_unset -q` +- `PYTHONDONTWRITEBYTECODE=1 uv run pytest tests/test_portfolio_truth.py tests/test_portfolio_truth_sources.py -q` +- `PYTHONDONTWRITEBYTECODE=1 python3 scripts/verify.py --run --id cross-system-smoke:contract-conformance --agent codex` +- `./scripts/run-cross-system-smoke.sh --profile contract-health` + +## Mutation Boundary + +Safe without live-write approval: + +- Refresh local/generated evidence. +- Update repo-local docs or tests. +- Update GithubRepoAuditor projection policy for aliases, projection-only rows, + and truth-shadow rows already representable by `notion_projection_policy.v2`. + +Approval-gated: + +- Creating or editing Local Portfolio Project rows in Notion. +- Removing rows from GithubRepoAuditor truth. + +## Recommended Next Execution + +1. Revisit `fable-outputs` after the June 22, 2026 campaign window and decide + whether to archive/park upstream. +2. Decide separately whether any supplementary non-Projects surfaces should + gain repo metadata in project-registry supplementary evidence. Do not promote + them into first-class truth without explicit approval. +3. If more approved rows are needed later, use the scoped Notion command: + +```bash +cd /Users/d/Projects/Notion +npm run portfolio-audit:create-local-project-rows-from-truth -- \ + --today 2026-06-19 \ + --project-title +``` + +Add `--live` only after row-level approval. + +## Done Condition + +`contract-health` should retain a fresh C7 snapshot and either: + +- no longer list active-infra truth-only rows, or +- list only explicitly deferred/projected rows with an approved policy or upstream truth disposition. + +Current done state: achieved. `contract-health` passed with no unexpected C7 +projection drift. diff --git a/src/portfolio_truth_sources.py b/src/portfolio_truth_sources.py index 25371a2..7a8438f 100644 --- a/src/portfolio_truth_sources.py +++ b/src/portfolio_truth_sources.py @@ -89,6 +89,7 @@ # 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: @@ -489,20 +490,74 @@ def _git_default_branch(project_path: Path) -> str: def _git_remote_full_name(project_path: Path) -> str: + remotes = _git_github_remotes(project_path) + if not remotes: + return "" + return _select_portfolio_identity_remote(project_path.name, remotes) + + +def _git_github_remotes(project_path: Path) -> list[tuple[str, str]]: try: result = subprocess.run( - ["git", "-C", str(project_path), "remote", "get-url", "origin"], + ["git", "-C", str(project_path), "remote", "-v"], capture_output=True, text=True, timeout=5, check=False, ) except (FileNotFoundError, subprocess.TimeoutExpired): - return "" + return [] if result.returncode != 0: - return "" - return _extract_github_full_name(result.stdout.strip()) + return [] + + seen: set[tuple[str, str]] = set() + remotes: list[tuple[str, str]] = [] + for line in result.stdout.splitlines(): + parts = line.split() + if len(parts) < 3 or parts[2] != "(fetch)": + continue + remote_name = parts[0].strip() + full_name = _extract_github_full_name(parts[1]) + if not remote_name or not full_name: + continue + key = (remote_name, full_name.lower()) + if key in seen: + continue + seen.add(key) + remotes.append((remote_name, full_name)) + return remotes + + +def _select_portfolio_identity_remote(checkout_name: str, remotes: list[tuple[str, str]]) -> str: + """Choose the GitHub repo identity used by portfolio truth. + + ``origin`` remains the normal source of truth. An explicit ``canonical`` + remote wins, and archive/import origins can yield to a remote whose repo + basename matches the local checkout directory. + """ + for remote_name, full_name in remotes: + if remote_name == "canonical": + return full_name + + origin = next((full_name for remote_name, full_name in remotes if remote_name == "origin"), "") + if not origin: + return remotes[0][1] + if not _is_archive_repo_identity(origin): + return origin + + checkout_key = checkout_name.lower() + for remote_name, full_name in remotes: + if remote_name == "origin" or _is_archive_repo_identity(full_name): + continue + if full_name.rsplit("/", 1)[-1].lower() == checkout_key: + return full_name + return origin + + +def _is_archive_repo_identity(full_name: str) -> bool: + repo_name = full_name.rsplit("/", 1)[-1].lower() + return any(token in repo_name for token in ARCHIVE_REMOTE_BASENAME_TOKENS) def _extract_github_full_name(remote_url: str) -> str: diff --git a/src/project_registry.py b/src/project_registry.py index 0235547..ea925e3 100644 --- a/src/project_registry.py +++ b/src/project_registry.py @@ -26,7 +26,7 @@ from pathlib import Path SCHEMA_VERSION = "1.0" -NOTION_PROJECTION_POLICY_SCHEMA_VERSION = "notion_projection_policy.v1" +NOTION_PROJECTION_POLICY_SCHEMA_VERSION = "notion_projection_policy.v2" # Built-in fallbacks, mirrored by config/project-registry-overrides.json. # Hard normalization failures: drifted identifier -> canonical project_key. @@ -93,6 +93,11 @@ "SecondBrain": "knowledge vault under /Users/d/Documents; not a /Users/d/Projects repo", } +DEFAULT_NOTION_TRUTH_SHADOW_ROWS: dict[str, str] = { + "agent-bridge-launch": "agent-bridge", + "PortfolioCommandCenter-public": "PortfolioCommandCenter", +} + # Operator-machine source locations (overridable via the "sources" block of # config/project-registry-overrides.json). Every source is optional. DEFAULT_SOURCES: dict[str, str] = { @@ -132,6 +137,7 @@ def load_overrides_config( str, dict[str, str], dict[str, str], + dict[str, str], ]: """Load overrides + supplementary + memory-meta, falling back to defaults.""" if config_path is None or not config_path.exists(): @@ -142,6 +148,7 @@ def load_overrides_config( NOTION_PROJECTION_POLICY_SCHEMA_VERSION, dict(DEFAULT_NOTION_TITLE_ALIASES), dict(DEFAULT_NOTION_PROJECTION_ONLY_ROWS), + dict(DEFAULT_NOTION_TRUTH_SHADOW_ROWS), ) data = json.loads(config_path.read_text()) overrides = data.get("overrides", DEFAULT_OVERRIDES) @@ -155,6 +162,9 @@ def load_overrides_config( projection_only = data.get( "notion_projection_only_rows", DEFAULT_NOTION_PROJECTION_ONLY_ROWS ) + truth_shadow = data.get( + "notion_truth_shadow_rows", DEFAULT_NOTION_TRUTH_SHADOW_ROWS + ) return ( dict(overrides), [dict(s) for s in supplementary], @@ -162,6 +172,7 @@ def load_overrides_config( str(projection_policy_schema_version), dict(title_aliases), dict(projection_only), + dict(truth_shadow), ) @@ -331,6 +342,7 @@ def build_project_registry( notion_projection_policy_schema_version, notion_title_aliases, notion_projection_only_rows, + notion_truth_shadow_rows, ) = load_overrides_config(overrides_config_path) generated_at = generated_at or datetime.now(timezone.utc) @@ -481,6 +493,7 @@ def resolve_entry(raw: str) -> _Entry | None: "schema_version": notion_projection_policy_schema_version, "notion_title_aliases": notion_title_aliases, "notion_projection_only_rows": notion_projection_only_rows, + "notion_truth_shadow_rows": notion_truth_shadow_rows, }, "entries": [e.to_dict() for e in entries], "projection_only": { diff --git a/tests/test_portfolio_truth.py b/tests/test_portfolio_truth.py index 0428106..f9ff0f7 100644 --- a/tests/test_portfolio_truth.py +++ b/tests/test_portfolio_truth.py @@ -23,6 +23,7 @@ from src.portfolio_truth_sources import ( _classify_context_quality, _extract_github_full_name, + _git_remote_full_name, load_safe_notion_project_context, ) from src.portfolio_truth_validate import validate_portfolio_report_markdown @@ -89,6 +90,92 @@ def test_extract_github_full_name_uses_exact_github_host() -> None: assert _extract_github_full_name("https://evil.example/github.com/octo/repo.git") == "" +def test_git_remote_full_name_prefers_canonical_remote(tmp_path: Path) -> None: + repo = tmp_path / "GithubRepoAuditor" + repo.mkdir() + subprocess.run(["git", "init"], cwd=repo, capture_output=True, check=True) + subprocess.run( + [ + "git", + "remote", + "add", + "origin", + "https://github.com/saagpatel/GithubRepoAuditor-private-archive-20260518.git", + ], + cwd=repo, + capture_output=True, + check=True, + ) + subprocess.run( + [ + "git", + "remote", + "add", + "canonical", + "https://github.com/saagpatel/GithubRepoAuditor.git", + ], + cwd=repo, + capture_output=True, + check=True, + ) + + assert _git_remote_full_name(repo) == "saagpatel/GithubRepoAuditor" + + +def test_git_remote_full_name_prefers_matching_public_remote_for_archive_origin( + tmp_path: Path, +) -> None: + repo = tmp_path / "ApplyKit" + repo.mkdir() + subprocess.run(["git", "init"], cwd=repo, capture_output=True, check=True) + subprocess.run( + [ + "git", + "remote", + "add", + "origin", + "https://github.com/saagpatel/ApplyKit-private-archive-20260517.git", + ], + cwd=repo, + capture_output=True, + check=True, + ) + subprocess.run( + [ + "git", + "remote", + "add", + "legacy-origin", + "https://github.com/saagpatel/ApplyKit.git", + ], + cwd=repo, + capture_output=True, + check=True, + ) + + assert _git_remote_full_name(repo) == "saagpatel/ApplyKit" + + +def test_git_remote_full_name_keeps_normal_origin(tmp_path: Path) -> None: + repo = tmp_path / "NormalProject" + repo.mkdir() + subprocess.run(["git", "init"], cwd=repo, capture_output=True, check=True) + subprocess.run( + ["git", "remote", "add", "origin", "https://github.com/saagpatel/NormalProject.git"], + cwd=repo, + capture_output=True, + check=True, + ) + subprocess.run( + ["git", "remote", "add", "mirror", "https://github.com/saagpatel/OtherProject.git"], + cwd=repo, + capture_output=True, + check=True, + ) + + assert _git_remote_full_name(repo) == "saagpatel/NormalProject" + + def test_notion_context_uses_configured_title_aliases( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/test_project_registry.py b/tests/test_project_registry.py index 2f5e092..2c0fb9c 100644 --- a/tests/test_project_registry.py +++ b/tests/test_project_registry.py @@ -130,9 +130,10 @@ def test_resolve_supplementary_from_each_spelling(): def test_projection_policy_is_published_from_defaults(): registry = build_project_registry(SNAPSHOT, overrides_config_path=None) policy = registry["projection_policy"] - assert policy["schema_version"] == "notion_projection_policy.v1" + assert policy["schema_version"] == "notion_projection_policy.v2" assert policy["notion_title_aliases"]["DesktopPEt-ready"] == "DesktopPEt" assert "SecondBrain" in policy["notion_projection_only_rows"] + assert policy["notion_truth_shadow_rows"]["agent-bridge-launch"] == "agent-bridge" def test_resolve_returns_none_for_non_projects():