Skip to content

Commit 687e604

Browse files
feat(authz): can_anchor_host accepts canonical name or snake_case key
E2E follow-up. Before: only canonical_name resolved in can_anchor_host; operator-natural forms ("videofoundry" vs "VideoFoundry") returned a misleading "not registered" error that masked real boundary violations. Now: every repo registers a lowercased alias map at manifest-load time. The dict-key form from PM-style YAML is registered as an alias when it differs from canonical_name. can_anchor_host normalizes input through the global alias map (case-insensitive) before lookup. Changes: - ManifestRecord.repo_aliases (lowercased alias → canonical) - AuthorizationView.repo_aliases (global, merged across manifests) - _iter_repos yields (canonical, fields, aliases); 3 call sites updated - can_anchor_host normalizes input; reasons always name canonical form - also_hosts validation resolves entries through aliases - Load-time alias-conflict check (fatal if same alias → 2 canonicals) Tests: +5 new (canonical, alias, case-insensitive, block-reason-uses- canonical, alias-conflict-fatal). 43 pass (was 38). E2E re-verified: capture(repos_touched=["videofoundry"]) from PM anchor now raises BoundaryViolation naming canonical 'VideoFoundry'. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 5a91cc5 commit 687e604

3 files changed

Lines changed: 244 additions & 17 deletions

File tree

.console/log.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,20 @@
11
# Log
2+
## 2026-05-22 — can_anchor_host accepts canonical_name OR snake_case key (alias resolution)
3+
4+
Follow-up to e2e test of ADR 0002. Operators naturally refer to repos by either form: `VideoFoundry` (canonical_name) or `videofoundry` (the dict key in PM-style YAML, or just the lowercased canonical). Before this change, only canonical_name resolved; other forms returned "not registered in any manifest", which masked real boundary violations behind a misleading error.
5+
6+
Changes:
7+
- `ManifestRecord` gains `repo_aliases: dict[str, str]` (lowercased alias → canonical).
8+
- `AuthorizationView` gains a global `repo_aliases` map merged across all registered manifests.
9+
- `_iter_repos` now yields `(canonical, fields, aliases)` — for PM-style dict YAML, the dict key is registered as an alias when it differs from canonical_name. Caller signature updated at three call sites.
10+
- `can_anchor_host` normalizes the input through the global alias map before lookup; reasons always name the canonical form.
11+
- `also_hosts` validation likewise resolves entries through aliases.
12+
- Load-time check: alias-to-canonical mapping must be consistent both within a manifest and across registered manifests (fatal if conflict).
13+
14+
5 new tests in `test_authorization.py`: canonical match, alias match, case-insensitive variants, block-reason names canonical even when called via alias, alias conflict fatal. 43/43 passing (was 38).
15+
16+
E2E re-verified: `capture(repos_touched=["videofoundry"])` from a PM anchor now raises BoundaryViolation with the canonical 'VideoFoundry' in the reason, instead of the previous misleading "not registered".
17+
218

319
## 2026-05-22 — P2: manifest registry + authorization API
420

src/repograph/authorization.py

Lines changed: 77 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,12 @@ class ManifestRecord:
6969
repo_local_paths: dict[str, Path] = field(default_factory=dict)
7070
"""Optional repo_name → local_path mapping (for anchor inference)."""
7171

72+
repo_aliases: dict[str, str] = field(default_factory=dict)
73+
"""Lowercased alias → canonical repo name. Includes the canonical name
74+
itself (lowercased) and any dict-key form from PM-style YAML. Used by
75+
:meth:`AuthorizationView.can_anchor_host` so operators can pass either
76+
form (e.g. ``"videofoundry"`` or ``"VideoFoundry"``)."""
77+
7278
@property
7379
def also_hosts_flattened(self) -> frozenset[str]:
7480
"""All repo names granted via ``also_hosts:`` across entries."""
@@ -83,9 +89,14 @@ class AuthorizationView:
8389
"""Manifest records keyed by canonicalized repo root."""
8490

8591
repo_owner: dict[str, Path]
86-
"""repo_name → owning manifest repo root."""
92+
"""canonical repo_name → owning manifest repo root."""
93+
94+
repo_aliases: dict[str, str] = field(default_factory=dict)
95+
"""Lowercased alias → canonical repo name. Spans every manifest's
96+
aliases so :meth:`can_anchor_host` accepts both ``"VideoFoundry"`` and
97+
``"videofoundry"`` (and any other registered form)."""
8798

88-
warnings: list[str]
99+
warnings: list[str] = field(default_factory=list)
89100
"""Non-fatal validation issues (e.g. redundant also_hosts grants)."""
90101

91102
# ------------------------------------------------------------------
@@ -121,7 +132,10 @@ def can_anchor_host(
121132
"Run `repograph manifest add <path>` to register it.",
122133
)
123134

124-
owner_root = self.repo_owner.get(repo_name)
135+
# Normalize input: accept canonical name OR any registered alias
136+
# (case-insensitive). E.g. both "VideoFoundry" and "videofoundry" resolve.
137+
canonical = self.repo_aliases.get(repo_name.lower(), repo_name)
138+
owner_root = self.repo_owner.get(canonical)
125139
if owner_root is None:
126140
return (
127141
False,
@@ -131,25 +145,30 @@ def can_anchor_host(
131145
owner = self.manifests[owner_root]
132146

133147
if owner.root == anchor.root:
134-
return True, f"anchor {anchor.name!r} owns repo {repo_name!r}"
148+
return True, f"anchor {anchor.name!r} owns repo {canonical!r}"
135149

136150
if owner.visibility_scope == "public":
137151
return (
138152
True,
139-
f"repo {repo_name!r} is owned by public manifest {owner.name!r}",
153+
f"repo {canonical!r} is owned by public manifest {owner.name!r}",
140154
)
141155

142-
if repo_name in anchor.also_hosts_flattened:
156+
# also_hosts grants are stored as the names the operator wrote;
157+
# normalize both sides through the global alias map to compare.
158+
also_canonical = {
159+
self.repo_aliases.get(r.lower(), r) for r in anchor.also_hosts_flattened
160+
}
161+
if canonical in also_canonical:
143162
return (
144163
True,
145164
f"anchor {anchor.name!r} has explicit also_hosts grant for "
146-
f"{repo_name!r} (owned by private manifest {owner.name!r})",
165+
f"{canonical!r} (owned by private manifest {owner.name!r})",
147166
)
148167

149168
return (
150169
False,
151170
f"anchor {anchor.name!r} (scope={anchor.visibility_scope}) cannot host "
152-
f"cognition about {repo_name!r}: owned by private manifest {owner.name!r} "
171+
f"cognition about {canonical!r}: owned by private manifest {owner.name!r} "
153172
"with no `also_hosts` grant to this anchor.",
154173
)
155174

@@ -171,6 +190,7 @@ def build_authorization_view(manifest_roots: list[Path]) -> AuthorizationView:
171190
"""
172191
records_by_root: dict[Path, ManifestRecord] = {}
173192
repo_owner: dict[str, Path] = {}
193+
repo_aliases: dict[str, str] = {}
174194
warnings: list[str] = []
175195

176196
# First pass: build per-manifest records.
@@ -183,7 +203,7 @@ def build_authorization_view(manifest_roots: list[Path]) -> AuthorizationView:
183203
record = _parse_manifest_root(root)
184204
records_by_root[root] = record
185205

186-
# Second pass: enforce exactly-one-owner.
206+
# Second pass: enforce exactly-one-owner + merge alias maps.
187207
for root, record in records_by_root.items():
188208
for repo_name in record.repos:
189209
prior = repo_owner.get(repo_name)
@@ -194,8 +214,18 @@ def build_authorization_view(manifest_roots: list[Path]) -> AuthorizationView:
194214
f"{prior_name!r} and {record.name!r}. Exactly one owner required."
195215
)
196216
repo_owner[repo_name] = root
217+
for alias, canonical in record.repo_aliases.items():
218+
prior_canonical = repo_aliases.get(alias)
219+
if prior_canonical is not None and prior_canonical != canonical:
220+
raise RepoGraphConfigError(
221+
f"alias {alias!r} maps to both {prior_canonical!r} and "
222+
f"{canonical!r} across registered manifests. Aliases must "
223+
"be globally unique."
224+
)
225+
repo_aliases[alias] = canonical
197226

198227
# Third pass: validate also_hosts refs + redundant-grant warnings.
228+
# also_hosts.repos may use canonical or alias forms — resolve via target's aliases.
199229
name_to_root: dict[str, Path] = {r.name.lower(): root for root, r in records_by_root.items()}
200230
for root, record in records_by_root.items():
201231
for entry in record.also_hosts:
@@ -208,7 +238,9 @@ def build_authorization_view(manifest_roots: list[Path]) -> AuthorizationView:
208238
)
209239
target_record = records_by_root[target_root]
210240
for repo_name in entry.repos:
211-
if repo_name not in target_record.repos:
241+
# Resolve via target's aliases (accepts canonical or key form).
242+
canonical = target_record.repo_aliases.get(repo_name.lower(), repo_name)
243+
if canonical not in target_record.repos:
212244
raise RepoGraphConfigError(
213245
f"manifest {record.name!r} also_hosts references "
214246
f"repo {repo_name!r} not owned by {entry.manifest!r}. "
@@ -224,6 +256,7 @@ def build_authorization_view(manifest_roots: list[Path]) -> AuthorizationView:
224256
return AuthorizationView(
225257
manifests=records_by_root,
226258
repo_owner=repo_owner,
259+
repo_aliases=repo_aliases,
227260
warnings=warnings,
228261
)
229262

@@ -252,20 +285,35 @@ def _parse_manifest_root(root: Path) -> ManifestRecord:
252285
repos_seen: set[str] = set()
253286
also_hosts: list[AlsoHostsEntry] = []
254287
repo_local_paths: dict[str, Path] = {}
288+
repo_aliases: dict[str, str] = {}
289+
290+
def _register_alias(alias: str, canonical: str) -> None:
291+
lower = alias.lower()
292+
prior = repo_aliases.get(lower)
293+
if prior is not None and prior != canonical:
294+
raise RepoGraphConfigError(
295+
f"manifest at {root}: alias {alias!r} maps to both "
296+
f"{prior!r} and {canonical!r}. Aliases must be unique."
297+
)
298+
repo_aliases[lower] = canonical
255299

256300
for path, raw in docs:
257301
scope = _extract_scope(raw, path)
258302
if scope is not None:
259303
scopes.add(scope)
260304

261-
for repo_name, fields in _iter_repos(raw, path):
305+
for repo_name, fields, aliases in _iter_repos(raw, path):
262306
if repo_name in repos_seen:
263307
continue
264308
repos_seen.add(repo_name)
265309
repos.append(repo_name)
266310
local = _opt_local_path(fields)
267311
if local is not None:
268312
repo_local_paths[repo_name] = local
313+
# Register both canonical and any aliases (lowercased).
314+
_register_alias(repo_name, repo_name)
315+
for alias in aliases:
316+
_register_alias(alias, repo_name)
269317

270318
for entry in _extract_also_hosts(raw, path):
271319
also_hosts.append(entry)
@@ -297,7 +345,7 @@ def _parse_manifest_root(root: Path) -> ManifestRecord:
297345

298346
# Per-repo visibility must agree with manifest scope (when declared).
299347
for path, raw in docs:
300-
for repo_name, fields in _iter_repos(raw, path):
348+
for repo_name, fields, _aliases in _iter_repos(raw, path):
301349
vis = fields.get("visibility") if isinstance(fields, dict) else None
302350
if vis is None:
303351
continue
@@ -317,6 +365,7 @@ def _parse_manifest_root(root: Path) -> ManifestRecord:
317365
repos=tuple(repos),
318366
also_hosts=tuple(also_hosts),
319367
repo_local_paths=repo_local_paths,
368+
repo_aliases=repo_aliases,
320369
)
321370

322371

@@ -335,7 +384,7 @@ def _derive_scope_from_repos(docs: list[tuple[Path, dict]]) -> str | None:
335384
saw_any = False
336385
all_public = True
337386
for _path, raw in docs:
338-
for _, fields in _iter_repos(raw, _path):
387+
for _name, fields, _aliases in _iter_repos(raw, _path):
339388
saw_any = True
340389
vis = fields.get("visibility") if isinstance(fields, dict) else None
341390
if vis != "public":
@@ -346,6 +395,12 @@ def _derive_scope_from_repos(docs: list[tuple[Path, dict]]) -> str | None:
346395

347396

348397
def _iter_repos(raw: dict, path: Path):
398+
"""Yield ``(canonical_name, fields, aliases)`` for every repo entry.
399+
400+
``aliases`` is a list of additional names that operators commonly
401+
use to refer to this repo (the dict-key form for PM-style YAML, etc).
402+
Empty if no additional aliases.
403+
"""
349404
repos_raw = raw.get("repos")
350405
if repos_raw is None:
351406
return
@@ -355,17 +410,22 @@ def _iter_repos(raw: dict, path: Path):
355410
if not isinstance(fields, dict):
356411
# Skip ill-typed entries; upstream loaders will reject.
357412
continue
358-
name = fields.get("canonical_name") or str(repo_id)
359-
yield str(name), fields
413+
canonical = fields.get("canonical_name") or str(repo_id)
414+
aliases = [str(repo_id)] if str(repo_id) != str(canonical) else []
415+
yield str(canonical), fields, aliases
360416
elif isinstance(repos_raw, list):
361417
# P0.4-style: [{name: X}, ...]
362418
for item in repos_raw:
363419
if isinstance(item, dict):
364420
name = item.get("name") or item.get("canonical_name")
365421
if name:
366-
yield str(name), item
422+
aliases = []
423+
alt = item.get("canonical_name") if item.get("name") else None
424+
if alt and alt != name:
425+
aliases.append(str(alt))
426+
yield str(name), item, aliases
367427
elif isinstance(item, str):
368-
yield item, {}
428+
yield item, {}, []
369429
else:
370430
raise RepoGraphConfigError(
371431
f"{path}: 'repos' must be a mapping or a list"

0 commit comments

Comments
 (0)