diff --git a/scaffold-project.py b/scaffold-project.py index 5ae5f42e..4148eab1 100755 --- a/scaffold-project.py +++ b/scaffold-project.py @@ -854,10 +854,11 @@ def _refuse_malformed_chain_links(policy, chain: tuple) -> None: The FIRST and LAST links are left to the checks that already anchor them: the first must be a `--pin` product and the last the referent the name - claims, both compared casefolded because a pin names WHICH repository, not - how it was typed. Each must casefold to a name this run already holds - (`[A-Za-z0-9_.-]`, or `open`), so neither can carry a `#`, a - quote, a bracket, a comma or a space. + claims, both compared by `pin_product_key`, the pin file's own fold + (#444), because a pin names WHICH product, not how it was typed. Each + must fold to a name this run already holds (`[A-Za-z0-9_.-]`, or + `open`), so neither can carry a `#`, a quote, a bracket, a comma + or a space. Asked before `_declaration`, so before `gh api` reads a pin's tree and before `--dry-run` returns: a dry run refuses exactly as the real run diff --git a/scripts/repo_shape.py b/scripts/repo_shape.py index 70a0c888..46ad1667 100644 --- a/scripts/repo_shape.py +++ b/scripts/repo_shape.py @@ -1374,14 +1374,28 @@ def __repr__(self) -> str: # pragma: no cover - diagnostics only def _pinned_basenames(declared_pins) -> set[str]: - """The repositories a manifest's `neutral_product_pins:` name, casefolded. + """The products a manifest's `neutral_product_pins:` name, each keyed by + `pin_product_key`. - Casefolded because every comparison made against them — a referent + Folded because every comparison made against them — a referent template's output, a chain's first link — is written in whatever case its - own file chose, and a pin is a fact about WHICH repository, not about how + own file chose, and a pin is a fact about WHICH product, not about how it was typed. - """ - return {repo_basename(str(pin)).casefold() + + ONE IDENTITY FOR A PRODUCT (#444). Every reader of a chain — this, the + referent and link compares below, `resolve_link_source`'s keyed lookup, + `link_pins_from_trees`' table and the naming validator's `--link-source` + table — keys a name by `pin_product_key`, the pin file's own fold, which + `validate-pins.py` reads by. They used `casefold()`, so `openß` and + `openss`, two pin files, were one link: a holder whose tree declares + `openss` verified a chain that says `openß`, a project pinning `openss` + verified a chain beginning at `openß`, `--link-source openss=` answered + for `openß`, and a chain naming both was refused as a cycle. Over ASCII + the two folds make the same groups, and `lower()` only ever splits one + that `casefold()` makes, so no name GitHub, `--pin` or the naming policy + can spell answers differently. + """ + return {pin_product_key(repo_basename(str(pin))) for pin in (declared_pins or ()) if pin} @@ -1397,8 +1411,9 @@ def _chain_links(referent_chain) -> list[str]: def _referent_in(referents, wanted) -> str | None: - """The first of `referents` whose casefolded name is one of `wanted`.""" - return next((r for r in referents if r.casefold() in wanted), None) + """The first of `referents` whose `pin_product_key` is one of `wanted` + (#444: the pin file's fold, as `_pinned_basenames` says).""" + return next((r for r in referents if pin_product_key(r) in wanted), None) def _link_declarations(link_pins) -> dict[str, set[str]]: @@ -1408,9 +1423,9 @@ def _link_declarations(link_pins) -> dict[str, set[str]]: broken-link message quotes what the other tree actually says. A link whose tree could not be read is ABSENT rather than empty: the two mean opposite things — unverified, and declaring nothing at all — and the caller reads - the difference. + the difference. Each link is keyed by `pin_product_key` (#444). """ - return {str(link).casefold(): + return {pin_product_key(str(link)): {repo_basename(str(pin)) for pin in (pins_of or ()) if pin} for link, pins_of in (link_pins or {}).items() if pins_of is not None} @@ -1429,6 +1444,10 @@ def _chain_link_findings(chain, link_pins) -> tuple: A `LinkPins` carries, beside the links that answered, why a tree that WAS reached could not: a warning for such a link says so, where a plain mapping's says the tree is not reachable (#338). + + A holder's tree and the next link are matched by `pin_product_key` + (#444): a tree declaring `openss` does not declare `openß`, whose pin + file is another. """ available = _link_declarations(link_pins) unreadable = getattr(link_pins, "unreadable", None) or {} @@ -1438,8 +1457,8 @@ def _chain_link_findings(chain, link_pins) -> tuple: # that variable: it would be read for both (#275). shared = pin_source_env_shared(chain) for holder, held in zip(chain, chain[1:]): - declared = available.get(holder.casefold()) - why = unreadable.get(holder.casefold()) + declared = available.get(pin_product_key(holder)) + why = unreadable.get(pin_product_key(holder)) if declared is None and why is not None: # A source REACHED this tree and could not read a declaration out # of it, so "not reachable here" would be untrue (#338). @@ -1462,7 +1481,8 @@ def _chain_link_findings(chain, link_pins) -> tuple: f"{holder}= would let it be read). The recorded " "chain still classifies.") continue - if held.casefold() not in {pin.casefold() for pin in declared}: + if pin_product_key(held) not in {pin_product_key(pin) + for pin in declared}: return (f"{holder} declares " + (", ".join(sorted(declared)) if declared else "no " "neutral-product pin at all") @@ -1769,7 +1789,7 @@ def broken(detail: str) -> ReferentResolution: problem = self._chain_shape_problem(name, chain, pins, referents) if problem is not None: return broken(problem) - final = _referent_in(referents, {chain[-1].casefold()}) + final = _referent_in(referents, {pin_product_key(chain[-1])}) contradiction, warnings, unverified = _chain_link_findings(chain, link_pins) if contradiction is not None: @@ -1792,19 +1812,23 @@ def _chain_shape_problem(self, name: str, chain: list, have. Each answer becomes the `detail` of a `broken` resolution, which is where the reader is told WHICH link broke rather than merely that one did. + + A link is one product by `pin_product_key` (#444), so `openß` and + `openss` in one chain are two links and not a repeat, and a chain + beginning at `openß` does not begin at a pin on `openss`. """ max_length = int(self.chain_rule().get("max_length") or CHAIN_MAX_LENGTH) if len(chain) > max_length: return (f"it names {len(chain)} links and the policy admits " f"at most {max_length}") - seen = [link.casefold() for link in chain] + seen = [pin_product_key(link) for link in chain] if len(set(seen)) != len(seen): return "it repeats a link, so it is a cycle rather than a chain" - if chain[0].casefold() not in pins: + if pin_product_key(chain[0]) not in pins: return (f"it begins at {chain[0]}, which is not in this " "project's `neutral_product_pins:` — the first " "entry is the pin this project actually holds") - if _referent_in(referents, {chain[-1].casefold()}) is None: + if _referent_in(referents, {pin_product_key(chain[-1])}) is None: return (f"it ends at {chain[-1]}, but {name} would need " + " or ".join(referents)) return None @@ -2787,7 +2811,9 @@ def resolve_link_source(product: str, root: Path | None = None, ) -> tuple[Path | None, str | None]: """Where `product`'s own manifest can be read, if anywhere at all. - 1. `--link-source =` (case-insensitive on the name) + 1. `--link-source =`, `keyed_sources` keyed by + `pin_product_key` (#444): case-insensitive on the name, as its pin + file is, so `openss=` answers for `openss` and never for `openß` 2. `SHAPE_PIN_SOURCE_` in the environment 3. a checkout sitting BESIDE this project (`../openXdox`) 4. a bare `--link-source ` with no `product=` prefix, which is @@ -2810,7 +2836,7 @@ def resolve_link_source(product: str, root: Path | None = None, which product it holds is not read for either (#275). """ candidates: list[tuple[str, Path, str]] = [] - keyed = (keyed_sources or {}).get(product.casefold()) + keyed = (keyed_sources or {}).get(pin_product_key(product)) if keyed is not None: candidates.append(("keyed", Path(keyed), f"--link-source {product}=...")) @@ -2836,8 +2862,9 @@ def resolve_link_source(product: str, root: Path | None = None, class LinkPins(dict): """`link_pins_from_trees`'s answer for a caller that asks it to say why: the same `{link: the pins ITS manifest declares}`, equal to a plain one, - with `unreadable` beside it, `{link casefolded: why}` for each link whose - source REACHED a manifest that could not be read as a declaration (#338). + with `unreadable` beside it, `{pin_product_key(link): why}` for each link + whose source REACHED a manifest that could not be read as a declaration + (#338, #444). A link in `unreadable` is absent from the mapping, as one that is not on the disk is. What differs is the warning `_chain_link_findings` writes for @@ -2891,6 +2918,11 @@ def link_pins_from_trees(links, root: Path | None = None, openYdox, not openInk", and a chain that holds was broken. A later link is read through rules 1 to 3 of `resolve_link_source`, or is declared-unverified. + + THE TABLE IS KEYED BY `pin_product_key` (#444), the key the variable + guard above already asks by. It was `casefold()`, so `openß` and + `openss`, each with its own tree, shared one entry and the later tree + answered for both. """ found: dict[str, set] = {} unreadable: dict[str, str] = {} @@ -2911,9 +2943,9 @@ def link_pins_from_trees(links, root: Path | None = None, if pins is None: rule, how, _path, _answered = asked[-1] asked[-1] = (rule, how, path, why) - unreadable[name.casefold()] = why + unreadable[pin_product_key(name)] = why continue - found[name.casefold()] = pins + found[pin_product_key(name)] = pins return LinkPins(found, unreadable) if with_reasons else found diff --git a/scripts/validate-repository-naming.py b/scripts/validate-repository-naming.py index d9727b5b..b3bf76ed 100755 --- a/scripts/validate-repository-naming.py +++ b/scripts/validate-repository-naming.py @@ -102,7 +102,7 @@ from repo_shape import ( # noqa: E402 CHAIN_RECORD_FIELD, LINK_SOURCE_ABSENT, LINK_SOURCE_NO_MANIFEST, UNAMBIGUOUS_FORMS, Classification, NamingPolicy, Refusal, - link_pins_from_trees, load_yaml, repo_basename, + link_pins_from_trees, load_yaml, pin_product_key, repo_basename, ) DEFAULT_POLICY = Path(__file__).resolve().parents[1] / "contracts" / "repository-naming.yaml" @@ -137,15 +137,25 @@ class LinkSources: that a source keyed to a link `a` that no chain names, which no lookup ever asked for — the link stayed declared-unverified, exit 0, under a WARNING suggesting the very `--link-source` the person had passed. So the - text before the first `=` is a LINK only when it casefolds to a link of a + text before the first `=` is a LINK only when it names a link of a referent chain this run reads (`--referent-chain`'s, or a leg's - `naming.referent_chain:` under `--project`), the casefold the lookup - uses, and any other value is a bare PATH, whole. A link's name wins even - where a directory is spelled like the whole value: the reading is decided - by the chains and never by the disk, and `./` asks for the directory. + `naming.referent_chain:` under `--project`) by `pin_product_key`, the key + the lookup uses, and any other value is a bare PATH, whole. A link's name + wins even where a directory is spelled like the whole value: the reading + is decided by the chains and never by the disk, and `./` asks for the + directory. That is `validate-pins.py`'s reading of `--pin-source` (#295), with a chain link where that file has a declared pin, so the two copied tools - read `[PRODUCT=]PATH` one way. + read `[PRODUCT=]PATH` one way OVER ASCII ONLY (#444). Outside it they part + company: `PinSources` (`templates/assembly-root/scripts/validate-pins.py` + `:831`, `:845`, `:848-849` and `:912`) still keys `--pin-source PRODUCT=` + by `casefold()`, so `--pin-source openss=` answers for a declared `openß` + where `--link-source openss=` no longer answers for the link `openß`. It + is left, and the issue leaves it as its own change, because a pin's digest + stops a wrong source from passing: `validate-pins.py` checks it, a wrong + source cannot match it, and so that reading can turn a wrong source into + a finding and never into a pass. The follow-up is on #459's checklist + (the shipped validators and the helpers they share). WHAT EACH VALUE CAME TO. Every lookup records what it asked (`link_pins_from_trees(..., tried=)`), so once every name is classified a @@ -170,6 +180,13 @@ class LinkSources: `Path("")`, which is `.`: run from a project root it read the project's own manifest as openXdox's, and a chain that holds was broken. That and an empty bare value are in `no_path`, and `no_path_refusal` says so. + + EVERY LINK IS KEYED BY `pin_product_key` (#444), the pin file's own fold, + as `repo_shape.py`'s chain readers key it. With `casefold()` here, + `openß` and `openss` were one link: `--link-source openss=` answered for + `openß`, two values for the pair replaced each other, and two chains + beginning at them counted one first link, so a bare PATH was read as both + products' trees. """ def __init__(self, values: list[str], links, firsts=()) -> None: @@ -178,18 +195,19 @@ def __init__(self, values: list[str], links, firsts=()) -> None: names: dict[str, str] = {} for link in links: name = repo_basename(str(link)) - if name and name.casefold() not in names: - names[name.casefold()] = name + if name and pin_product_key(name) not in names: + names[pin_product_key(name)] = name self.links.append(name) - #: `{casefolded: first spelling}` of the first link of every chain a - #: name here reads (#441). `link_pins` adds a chain's first link as it - #: reads it, which matters only for names read from stdin: they come - #: when no other name does, all with `--referent-chain`. + #: `{pin_product_key: first spelling}` of the first link of every + #: chain a name here reads (#441). `link_pins` adds a chain's first + #: link as it reads it, which matters only for names read from stdin: + #: they come when no other name does, all with `--referent-chain`. self.firsts: dict[str, str] = {} for link in firsts: name = repo_basename(str(link)) - self.firsts.setdefault(name.casefold(), name) - #: `{link casefolded: (typed, path)}`, the last value given wins. + self.firsts.setdefault(pin_product_key(name), name) + #: `{pin_product_key(link): (typed, path)}`, the last value given + #: wins. self.keyed: dict[str, tuple[str, Path]] = {} #: `(typed, path)` of the last bare value, if any. self.bare: tuple[str, Path] | None = None @@ -200,11 +218,11 @@ def __init__(self, values: list[str], links, firsts=()) -> None: self.replaced: dict[str, str] = {} for typed in values: key, sep, path = typed.partition("=") - link = names.get(key.casefold()) if sep else None + link = names.get(pin_product_key(key)) if sep else None self.reading[typed] = link if link is not None: - earlier = self.keyed.get(link.casefold()) - self.keyed[link.casefold()] = (typed, Path(path)) + earlier = self.keyed.get(pin_product_key(link)) + self.keyed[pin_product_key(link)] = (typed, Path(path)) else: earlier = self.bare self.bare = (typed, Path(typed)) @@ -216,7 +234,7 @@ def __init__(self, values: list[str], links, firsts=()) -> None: self.no_path: list[str] = [ typed for typed, link in self.reading.items() if not (typed.partition("=")[2] if link is not None else typed)] - #: The links some name's lookup asked about, casefolded. + #: The links some name's lookup asked about, by `pin_product_key`. self.asked: set[str] = set() #: The values a link read: each the answer for one link (#441). self.used: set[str] = set() @@ -242,7 +260,7 @@ def link_pins(self, policy: NamingPolicy, target: Target) -> dict: return {} if len(target.chain) > 1: first = repo_basename(str(target.chain[0])) - self.firsts.setdefault(first.casefold(), first) + self.firsts.setdefault(pin_product_key(first), first) tried: dict = {} found = link_pins_from_trees( target.chain, target.root, @@ -253,12 +271,12 @@ def link_pins(self, policy: NamingPolicy, target: Target) -> dict: # (`_chain_link_findings`): a source asked only for the chain's end # decided nothing, and saying it answered, or failed to, would be a # report about a question nobody asked. - holders = {repo_basename(str(link)).casefold() + holders = {pin_product_key(repo_basename(str(link))) for link in target.chain[:-1]} for link, asked in tried.items(): - if link.casefold() not in holders: + if pin_product_key(link) not in holders: continue - self.asked.add(link.casefold()) + self.asked.add(pin_product_key(link)) for rule, how, path, why in asked: self._record(link, rule, how, path, why) return found @@ -280,7 +298,7 @@ def _record(self, link: str, rule: str, how: str, path: Path, still could not answer, since one that is absent, or holds no manifest, is the ordinary case.""" if rule in ("keyed", "bare"): - typed = (self.keyed[link.casefold()] if rule == "keyed" + typed = (self.keyed[pin_product_key(link)] if rule == "keyed" else self.bare)[0] if why is None: self.used.add(typed) diff --git a/tests/test_chain_link_fold.py b/tests/test_chain_link_fold.py new file mode 100644 index 00000000..9bd11df2 --- /dev/null +++ b/tests/test_chain_link_fold.py @@ -0,0 +1,386 @@ +# SPDX-License-Identifier: Apache-2.0 +"""A chain link, a declared pin and a `--link-source` key are one product by +`pin_product_key`, the pin file's own fold, and not by `casefold()` (#444). + +#275's fix (PR #409) made a product's identity `pin_product_key`: what makes +two declarations one product is the pin file they share, +`contracts/-pin.yaml`, which `validate-pins.py` reads. +The chain readers of `scripts/repo_shape.py`, and the `--link-source` table +`validate-repository-naming.py` builds, asked the same question with +`casefold()`. The two folds agree over ASCII and part company outside it: +`casefold()` turns `openß` (open + the German sharp s) into `openss`, +which `lower()` leaves alone, so the two names are two pin files to the pin +validator and were one link to the chain. The issue's rows, each a test +below: + + C a holder whose tree declares `openss` verified a chain that says + `openß`; now the chain is broken, and the finding names the link; + D a chain naming both was refused as a cycle; now it is two links, each + read from its own tree, and verified; + E a project pinning `openss` verified a chain beginning at `openß`; + now the chain does not begin at a held pin; + F `--link-source openss=` answered for the link `openß`; now it keys + no link, `openß` is declared-unverified, and the value is noted; + G `link_pins_from_trees` kept one entry for the pair; now two. + +The same fold decided four more answers, measured beside the issue's on +main 8f453d0 and tested here: a direct pin on `openGlaß` reached +`openGlass` (H); two chains beginning at `openß` and `openss` counted +one first link, so a bare `--link-source` was read as both products' trees +(I, #441's rule); `scaffold-project.py --pin openss@ +--referent-chain openß,openInk` planned a descendant through a link no +pin file names (K); and a chain ENDING at `openGlaß` reached the referent +`openGlass` for `codexGlass`, in the naming gate and in a real scaffold +alike, which created all three repositories (L). + +The answers that do not move are held too: an ASCII chain, a chain spelled +`openß` throughout, and links that differ from a declaration by ASCII +case alone (`openXdox` and `openxdox` are one pin file). Over ASCII `lower()` +and `casefold()` make the same groups, and `lower()` only ever splits a group +`casefold()` makes, so no name GitHub, `--pin` or the naming policy can spell +answers differently. + +Every run is made from a directory of its own under `tmp_path`, and every +manifest is written as bytes. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +from conftest import REPO, SCAFFOLD, clear_ambient_pin_sources, git, run_script +from test_link_source_chain_defaults import (MORE_SPECIFIC, manifest, naming, + project, tree, two_firsts_note, + unverified) + +# `project` here is that module's manifest writer, not conftest's fixture of +# the same name, which no test below asks for. + +sys.path.insert(0, str(REPO / "scripts")) +from repo_shape import link_pins_from_trees, pin_product_key # noqa: E402 + +#: Two pin files, `contracts/openß-pin.yaml` and +#: `contracts/openss-pin.yaml`, that `casefold()` took for one product. +SHARP = "openß" +SS = "openss" +#: The referent `codexGlass` needs is `openGlass`; `openGlaß` is another pin +#: file, which `casefold()` took for it. +GLASS_SHARP = "openGlaß" +CODEX_INK_DESCENDS = "codexInk domain-descendant/assembly" +CODEX_INK_DOES_NOT = ("codexInk project-leg/assembly" + " also_matches domain-descendant") + + +def keyed(*pairs: tuple[str, Path]) -> list[str]: + """`--link-source KEY=PATH` for each pair, as arguments.""" + return [arg for key, path in pairs + for arg in ("--link-source", f"{key}={path}")] + + +def broken(chain: str, detail: str, name: str = "codexInk") -> str: + """The FINDING the naming gate prints for `name`'s broken chain.""" + return (f"FINDING {name}: the declared chain {chain} is broken " + f"({detail})\n") + + +def not_a_link(value: str, links: str) -> str: + """The NOTE for `value`, a `KEY=PATH` whose key names no chain link, so + it was read as a bare PATH that the first link, keyed, never needed.""" + return (f"NOTE --link-source {value}: not used — a bare PATH is the tree " + f"of a chain's first link alone, openXdox, which had " + f"{MORE_SPECIFIC}. It was read as a PATH: `{value.partition('=')[0]}`" + f" is not a link of a referent chain here (links: {links}).\n") + + +@pytest.fixture +def ink(tmp_path) -> dict: + """`codexInk`'s project directory and the place its links' trees go.""" + return {"root": tmp_path / "ws" / "codexInk", "trees": tmp_path / "trees"} + + +# --- the issue's rows: the naming gate -------------------------------------- + +def test_a_holder_declaring_another_pin_file_breaks_the_chain(ink): + """ROW C. The chain says `openXdox -> openß -> openInk` and + `openXdox`'s own tree declares `openss`, another pin file. It was + `[verified]`, exit 0; the chain is broken there now, and the finding + quotes what the tree says.""" + project(ink["root"], "openXdox", f"openXdox, {SHARP}, openInk") + result = naming(ink["root"], "--project", "project.yaml", *keyed( + ("openXdox", tree(ink["trees"] / "openXdox", SS)), + (SHARP, tree(ink["trees"] / SHARP, "openInk")))) + assert result.returncode == 1, result.stderr + assert CODEX_INK_DOES_NOT in result.stdout + assert result.stderr == broken(f"openXdox → {SHARP} → openInk", + f"openXdox declares {SS}, not {SHARP}") + + +def test_a_chain_naming_both_spellings_is_two_links(ink): + """ROW D. `openß` and `openss` are two products, each with its own + tree, so the chain repeats no link: it was refused as a cycle, exit 1, + and both keyed values were NOTEd as unused. Every link is read from its + own tree now, and the chain holds.""" + project(ink["root"], "openXdox", f"openXdox, {SHARP}, {SS}, openInk") + result = naming(ink["root"], "--project", "project.yaml", *keyed( + ("openXdox", tree(ink["trees"] / "openXdox", SHARP)), + (SHARP, tree(ink["trees"] / SHARP, SS)), + (SS, tree(ink["trees"] / SS, "openInk")))) + assert result.returncode == 0, result.stderr + assert CODEX_INK_DESCENDS in result.stdout + assert result.stderr == "" + explained = naming(ink["root"], "--explain", "--project", "project.yaml", + *keyed(("openXdox", ink["trees"] / "openXdox"), + (SHARP, ink["trees"] / SHARP), + (SS, ink["trees"] / SS))) + assert (f"CHAIN openXdox → {SHARP} → {SS} → openInk [verified]" + in explained.stdout), explained.stdout + + +def test_a_chain_must_begin_at_the_pin_file_the_project_holds(ink): + """ROW E. The project pins `openss`, so `contracts/openss-pin.yaml` is + its pin; a chain beginning at `openß` does not begin there. It was + `[verified]`, exit 0. The keyed source is never read, because a chain + broken in its shape reads no link (#338), and it is NOTEd so.""" + project(ink["root"], SS, f"{SHARP}, openInk") + result = naming(ink["root"], "--project", "project.yaml", *keyed( + (SHARP, tree(ink["trees"] / SHARP, "openInk")))) + assert result.returncode == 1, result.stderr + assert CODEX_INK_DOES_NOT in result.stdout + assert result.stderr == ( + f"NOTE --link-source {SHARP}={ink['trees'] / SHARP}: not used — no " + f"name classified here reads {SHARP}'s own declaration.\n" + + broken(f"{SHARP} → openInk", + f"it begins at {SHARP}, which is not in this project's " + "`neutral_product_pins:` — the first entry is the pin this " + "project actually holds")) + + +def test_a_chain_must_end_at_the_pin_file_the_name_claims(tmp_path): + """ROW L. `codexGlass` pins `openXdox`, records `openXdox -> openGlaß`, + and `openXdox`'s own tree declares `openGlaß`. `openGlass` is the + referent the name claims, `contracts/openglass-pin.yaml`, and the chain + ends at another pin file. It was `domain-descendant`, exit 0; the chain is + broken at its end now, and the finding says what the name would need. The + keyed source is never read, because a chain broken in its shape reads no + link (#338), and it is NOTEd so.""" + root = project(tmp_path / "ws" / "codexGlass", "openXdox", + f"openXdox, {GLASS_SHARP}") + held = tree(tmp_path / "trees" / "openXdox", GLASS_SHARP) + result = naming(root, "--project", "project.yaml", + *keyed(("openXdox", held))) + assert result.returncode == 1, result.stderr + assert (f"{'codexGlass':<32} project-leg/assembly also_matches " + "domain-descendant") in result.stdout, result.stdout + assert result.stderr == ( + f"NOTE --link-source openXdox={held}: not used — no name classified " + "here reads openXdox's own declaration.\n" + + broken(f"openXdox → {GLASS_SHARP}", + f"it ends at {GLASS_SHARP}, but codexGlass would need " + "openGlass or openxGlass", "codexGlass")) + + +def test_a_source_keyed_by_the_other_spelling_answers_for_no_link(ink): + """ROW F. No tree is given for `openß`; one is given as + `--link-source openss=`, which `casefold()` read as `openß`'s, and + the chain was `[verified]` from a tree filed under another product. + `openss` keys no link of this chain now: the value is a bare PATH, which + the keyed first link never needed, and `openß` is + declared-unverified, a warning, exit 0, as any link with no tree is.""" + project(ink["root"], "openXdox", f"openXdox, {SHARP}, openInk") + other = tree(ink["trees"] / SS, "openInk") + result = naming(ink["root"], "--project", "project.yaml", *keyed( + ("openXdox", tree(ink["trees"] / "openXdox", SHARP)), (SS, other))) + assert result.returncode == 0, result.stderr + assert CODEX_INK_DESCENDS in result.stdout + assert result.stderr == ( + unverified("codexInk", SHARP, "openInk") + + not_a_link(f"{SS}={other}", f"openXdox, {SHARP}, openInk")) + + +def test_the_table_keeps_one_entry_for_each_pin_file(tmp_path, monkeypatch): + """ROW G, in this process. A checkout of each beside the project: the + table kept one entry, `openss`, and the later tree won for both. It keeps + two now, each holding its own tree's declaration, keyed as + `pin_product_key` keys them.""" + clear_ambient_pin_sources(monkeypatch) + tree(tmp_path / SHARP, "openAAA") + tree(tmp_path / SS, "openBBB") + (tmp_path / "codexInk").mkdir() + found = link_pins_from_trees([SHARP, SS], tmp_path / "codexInk") + assert found == {SHARP: {"openAAA"}, SS: {"openBBB"}} + assert set(found) == {pin_product_key(SHARP), pin_product_key(SS)} + + +# --- the answers that do not move ------------------------------------------- + +@pytest.mark.parametrize("pins,chain,declares,expected_chain", [ + pytest.param("openXdox", "openXdox, openYdox, openInk", + {"openXdox": "openYdox", "openYdox": "openInk"}, + "openXdox → openYdox → openInk", id="A-ascii"), + pytest.param("openXdox", f"openXdox, {SHARP}, openInk", + {"openXdox": SHARP, SHARP: "openInk"}, + f"openXdox → {SHARP} → openInk", id="B-sharp-s-throughout"), + pytest.param("openXdox", "openxdox, openYdox, openInk", + {"OPENXDOX": "OPENYDOX", "OpenYdox": "openink"}, + "openxdox → openYdox → openInk", id="ascii-case-alone"), +]) +def test_a_chain_that_holds_still_holds(ink, pins, chain, declares, + expected_chain): + """THE CONTROLS. The issue's rows A and B, and a chain whose links differ + from the pin, the keys and the trees' declarations by ASCII case alone, + which is one pin file (`contracts/openxdox-pin.yaml`): each is + `[verified]`, exit 0, with nothing said, as on main.""" + project(ink["root"], pins, chain) + sources = [(key, tree(ink["trees"] / key, held)) + for key, held in declares.items()] + result = naming(ink["root"], "--explain", "--project", "project.yaml", + *keyed(*sources)) + assert result.returncode == 0, result.stderr + assert "codexInk: domain-descendant / assembly" in result.stdout + assert f"CHAIN {expected_chain} [verified]" in result.stdout + assert "WARNING" not in result.stdout + result.stderr + assert "NOTE" not in result.stderr + + +# --- the same fold elsewhere ------------------------------------------------ + +@pytest.mark.parametrize("pin,answer", [ + pytest.param("openGlaß", "project-leg/assembly also_matches " + "domain-descendant", id="openGla-sharp-s"), + pytest.param("openglass", "domain-descendant/assembly also_matches " + "project-leg/assembly", id="ascii-case-control"), +]) +def test_a_direct_pin_reaches_only_its_own_pin_file(tmp_path, pin, answer): + """ROW H. `codexGlass` needs a pin on `openGlass`, + `contracts/openglass-pin.yaml`. A pin on `openGlaß` is another pin + file, and it classified `codexGlass` as `openGlass`'s descendant; it no + longer does, and the role it declares still classifies it. `openglass`, + the same pin file in another case, still does.""" + result = run_script(REPO / "scripts" / "validate-repository-naming.py", + "--role", "assembly", "--pins", pin, "codexGlass", + cwd=tmp_path) + assert result.returncode == 0, result.stderr + assert result.stdout == f" {'codexGlass':<32} {answer}\n" + assert result.stderr == "" + + +def test_chains_beginning_at_both_spellings_read_a_bare_tree_for_neither( + tmp_path): + """ROW I. Two legs, `codexInk` recording `openß -> openInk` and + `codexQuill` recording `openss -> openQuill`, and one bare tree. The + first links were counted as one, so the bare tree answered for both, and + `codexQuill`'s chain was broken by a tree that is not `openss`'s: "openss + declares openInk, not openQuill", exit 1. They are two first links now, + and #441's rule reads a bare tree for neither.""" + root = manifest(tmp_path / "ws" / "codexSoft", ( + f"neutral_product_pins:\n - {SHARP}\n - {SS}\nlegs:\n" + + "".join( + f" - repository: testorg/{name}\n path: \".\"\n" + f" role: assembly\n naming:\n" + f" referent_chain: [{chain}]\n" + for name, chain in (("codexInk", f"{SHARP}, openInk"), + ("codexQuill", f"{SS}, openQuill"))))) + bare = tree(tmp_path / "trees" / "bare", "openInk") + result = naming(root, "--project", "project.yaml", + "--link-source", str(bare)) + assert result.returncode == 0, result.stderr + assert "FINDING" not in result.stderr + # Each WARNING is asserted to the link it names and no further: the + # variable it offers is #275's business, not this issue's. + lines = result.stderr.splitlines(keepends=True) + assert len(lines) == 3, result.stderr + assert lines[0].startswith(f"WARNING codexInk: declared-unverified: " + f"{SHARP} declaring a pin on openInk is a " + f"fact in {SHARP}'s own tree, which is not " + "reachable here"), result.stderr + assert lines[1].startswith("WARNING codexQuill: declared-unverified: " + f"{SS} declaring a pin on openQuill is a fact " + f"in {SS}'s own tree, which is not reachable " + "here"), result.stderr + assert lines[2] == two_firsts_note(bare, SHARP, SS) + + +# --- the scaffold ----------------------------------------------------------- + +def scaffold_pinning(tmp_path: Path, product: str, chain: str, *args: str, + name: str = "codexInk", declares: str = "openInk"): + """`scaffold-project.py` for `codexSoft/`, pinning `product` at a + commit of a checkout declaring `declares`, which is its `--pin-source`, + and recording `chain`. Remotes and the work tree go under `tmp_path`.""" + source = tree(tmp_path / "src", declares) + for step in (("init", "-q", "-b", "main"), ("add", "-A"), + ("commit", "-qm", "seed")): + git(*step, cwd=source) + commit = git("rev-parse", "HEAD", cwd=source).stdout.strip() + return run_script(SCAFFOLD, "--org", "codexSoft", "--project", name, + "--elected-by", "Test Human", "--elected-on", + "2026-09-05", "--pin", f"{product}@{commit}", + "--pin-source", str(source), "--referent-chain", chain, + "--local-remote-dir", str(tmp_path / "remotes"), + "--work-dir", str(tmp_path / "work"), *args, + cwd=tmp_path) + + +@pytest.mark.parametrize("dry_run", [True, False], ids=["dry-run", "real"]) +def test_the_scaffold_refuses_a_chain_beginning_at_another_pin_file( + tmp_path, dry_run): + """ROW K. `--pin openss@` writes `contracts/openss-pin.yaml`, and + `--referent-chain openß,openInk` begins at another pin file. The dry + run planned a descendant reaching `openInk` through `openß`, reading + `openss`'s checkout as `openß`'s tree. Both runs now refuse + `chain-not-declared` before anything is created.""" + result = scaffold_pinning(tmp_path, SS, f"{SHARP},openInk", + *(["--dry-run"] if dry_run else [])) + assert result.returncode == 2, result.stderr + result.stdout + assert result.stderr == ( + f"REFUSED chain-not-declared: --referent-chain {SHARP},openInk: the " + f"declared chain {SHARP} → openInk is broken (it begins at {SHARP}, " + "which is not in this project's `neutral_product_pins:` — the first " + "entry is the pin this project actually holds)\n" + "Remediation: the first entry is the product this project PINS " + "(pass it as --pin @<40 hex>) and the last is the " + "`open` the name claims; every link between them is " + "declared by that link's own project.yaml.\n") + assert not (tmp_path / "remotes").exists() + assert not (tmp_path / "work").exists() + + +@pytest.mark.parametrize("dry_run", [True, False], ids=["dry-run", "real"]) +def test_the_scaffold_refuses_a_chain_ending_at_another_pin_file( + tmp_path, dry_run): + """ROW L. `codexGlass` pins `openXdox`, whose `--pin-source` declares + `openGlaß`, and `--referent-chain openXdox,openGlaß` ends at a pin file + that is not `openGlass`'s. The dry run planned a descendant reaching + `openGlass` through the chain, and the REAL run created all three + repositories and recorded `referent_chain: [openXdox, openGlaß]`. Both + runs now refuse `chain-not-declared` before anything is created.""" + result = scaffold_pinning(tmp_path, "openXdox", + f"openXdox,{GLASS_SHARP}", + *(["--dry-run"] if dry_run else []), + name="codexGlass", declares=GLASS_SHARP) + assert result.returncode == 2, result.stderr + result.stdout + assert result.stderr == ( + f"REFUSED chain-not-declared: --referent-chain openXdox," + f"{GLASS_SHARP}: the declared chain openXdox → {GLASS_SHARP} is " + f"broken (it ends at {GLASS_SHARP}, but codexGlass would need " + "openGlass or openxGlass)\n" + "Remediation: the first entry is the product this project PINS " + "(pass it as --pin @<40 hex>) and the last is the " + "`open` the name claims; every link between them is " + "declared by that link's own project.yaml.\n") + assert not (tmp_path / "remotes").exists() + assert not (tmp_path / "work").exists() + + +def test_the_scaffold_still_plans_a_chain_beginning_at_its_pin(tmp_path): + """The control: the same run with the chain beginning at the pinned + `openss` itself plans, and its `--pin-source` verifies the link.""" + result = scaffold_pinning(tmp_path, SS, f"{SS},openInk", "--dry-run") + assert result.returncode == 0, result.stderr + result.stdout + assert (f"reaching openInk through the declared pin chain {SS} → openInk" + in result.stdout), result.stdout + assert "--dry-run: nothing was created." in result.stdout + assert not (tmp_path / "remotes").exists()