From ac5b53073c84d4e2d8211367e14982aef3df4cd0 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 18 Oct 2026 18:32:02 +0000 Subject: [PATCH 1/2] validate-manifest.py, validate-repository-naming.py, repo_shape.py and scaffold-project.py: a chain source that is there and cannot answer is named with why instead of offered, a `SHAPE_PIN_SOURCE_*` two products of one run share answers for no chain, and a first chain link that is a `--pin` is recorded in the pin's spelling (#909) U172. The root's validate-manifest.py asks the lookup what it tried and why a tree it reached could not be read (`tried=`, `with_reasons=`), behind the `remedies=` guard. A checkout beside the root holding no project.yaml, or a SHAPE_PIN_SOURCE_ naming no folder, is named with why it could not answer, and only a source that is not there is offered. It imports nothing new from repo_shape.py: `_shown` and SOURCE_ABSENT are its own, held to the library's by tests. U113, U145. `link_pins_from_trees` takes `run_products`, every product the run reads (the declared pins and every chain's links), and asks #275's guard over them, so a variable two of them share is read for no chain and offered to none (`LinkPins.env_shared`). The naming gate and validate-manifest.py pass it where their library takes it. U114, U146. `referent_chain_links` spells a first link that is a `--pin` under `pin_product_key` as that pin, so `opendox` beside `--pin openKdox@` is planned and recorded as `openKdox`. Co-Authored-By: Claude Opus 5.5 --- scaffold-project.py | 25 +- scripts/repo_shape.py | 54 ++- scripts/validate-repository-naming.py | 19 + .../scripts/validate-manifest.py | 153 +++++- tests/test_chain_pin_sources.py | 450 ++++++++++++++++++ tests/test_naming_policy.py | 19 +- tests/test_pinned_copy_text.py | 13 +- 7 files changed, 681 insertions(+), 52 deletions(-) create mode 100644 tests/test_chain_pin_sources.py diff --git a/scaffold-project.py b/scaffold-project.py index c382fa87..bdc78fa6 100755 --- a/scaffold-project.py +++ b/scaffold-project.py @@ -1068,11 +1068,28 @@ def _refuse_an_unwritable_clone_url(local: bool, url: str) -> None: CLONE_URL_REMEDIATION) -def referent_chain_links(raw: str) -> tuple: +def referent_chain_links(raw: str, pins=()) -> tuple: """`--referent-chain`'s links: split on commas, each stripped, an empty one dropped. ONE reading, for `_declaration` and for the check below, so - the links that are judged are the links that are recorded.""" - return tuple(link.strip() for link in raw.split(",") if link.strip()) + the links that are judged are the links that are recorded. + + A FIRST LINK THAT IS A `--pin` IS SPELLED AS THAT PIN (#909). The first + link is matched to the `--pin` products, `pins`, by `pin_product_key`, + the pin file's own fold (#444), so `opendox` is the + pin `openKdox`: `lower()` folds the Kelvin sign to `k`. It was recorded + as typed, and the plan printed that spelling, beside one pin file, + `contracts/openkdox-pin.yaml`. It is recorded in the `--pin`'s spelling + now, and so is a first link differing from it by case alone. An owner + typed before the name is kept: the manifest records the name alone.""" + # The fold `resolve_referent` matches the first link to a pin by. + from repo_shape import pin_product_key + links = tuple(link.strip() for link in raw.split(",") if link.strip()) + if not links: + return links + head, slash, first = links[0].rpartition("/") + pinned = next((pin for pin in pins + if pin_product_key(pin) == pin_product_key(first)), None) + return links if pinned is None else (head + slash + pinned, *links[1:]) def _refuse_malformed_chain_links(policy, chain: tuple) -> None: @@ -1161,7 +1178,7 @@ def _declaration(args) -> tuple[dict, set, tuple, dict]: """ pins = declared_pin_values(args) declared_pins: set[str] = set(pins) - chain = referent_chain_links(args.referent_chain) + chain = referent_chain_links(args.referent_chain, declared_pins) link_pins = link_pins_from_trees( chain, None, {}, args.pin_source, with_reasons=True, remedies=_link_remedies(chain)) if chain else {} diff --git a/scripts/repo_shape.py b/scripts/repo_shape.py index 5e72d358..22ef69d6 100644 --- a/scripts/repo_shape.py +++ b/scripts/repo_shape.py @@ -1512,7 +1512,10 @@ def recorded_gitlink(repo: Path, path: str) -> str | None: #: files. Every existing name is kept, because an operator may have set it and #: the docs name it. What is done instead (`pin_source_env_collisions`): a #: variable two declared products share answers for NEITHER, wherever it is -#: read; `scaffold-project.py --pin` refuses to create such a pair; and +#: read, and a chain reader asks that over every product its run reads, the +#: declared pins and every chain's links (#909, `link_pins_from_trees`' +#: `run_products`); `scaffold-project.py --pin` refuses to create such a +#: pair; and #: `validate-pins.py` NOTES a pair a project already declares, and never fails #: it, because the project did nothing wrong that an update could undo. PIN_SOURCE_ENV_PREFIX = "SHAPE_PIN_SOURCE_" @@ -1762,15 +1765,25 @@ def _chain_link_findings(chain, link_pins) -> tuple: (#444): a tree declaring `openss` does not declare `openß`, whose pin file is another. A holder's `remedies` entry is found by it too, so in a chain naming both, each is offered its own sources and not the other's. + + THE VARIABLE IS OFFERED AS IT WAS READ (#909). A `LinkPins` carries + `env_shared`, the links whose variable was not read because another + product of the RUN shares it: a declared pin, or a link of this chain or + of another one the same run reads (`link_pins_from_trees`' + `run_products`). Asked over this chain alone, two legs whose chains + begin at `openß` and `openss` were each offered + `SHAPE_PIN_SOURCE_OPENSS`, which would have answered for both. """ available = _link_declarations(link_pins) unreadable = getattr(link_pins, "unreadable", None) or {} remedies = getattr(link_pins, "remedies", None) or {} warnings: list[str] = [] unverified: list[str] = [] - # A link whose variable another link of the chain shares is never offered - # that variable: it would be read for both (#275). - shared = pin_source_env_shared(chain) + # A link whose variable another link of the chain, or another product of + # the run, shares is never offered that variable: it would be read for + # both (#275, #909). + shared = (pin_source_env_shared(chain) + | set(getattr(link_pins, "env_shared", None) or ())) for holder, held in zip(chain, chain[1:]): declared = available.get(pin_product_key(holder)) why = unreadable.get(pin_product_key(holder)) @@ -3220,13 +3233,20 @@ class LinkPins(dict): and has no `--link-source`, so it names its own here. A link with no entry, and every link for a caller that gives none, gets the naming validator's words, as ever. + + `env_shared`, a set of `pin_product_key(link)`, is the links whose + `SHAPE_PIN_SOURCE_` was not read because another product the + run reads shares it (#275, #909), so that `_chain_link_findings` offers + that variable to none of them. """ def __init__(self, found=(), unreadable: dict | None = None, - remedies: dict | None = None) -> None: + remedies: dict | None = None, + env_shared=None) -> None: super().__init__(found) self.unreadable: dict[str, str] = dict(unreadable or {}) self.remedies: dict[str, str] = dict(remedies or {}) + self.env_shared: set[str] = set(env_shared or ()) def link_pins_from_trees(links, root: Path | None = None, @@ -3235,7 +3255,8 @@ def link_pins_from_trees(links, root: Path | None = None, key: str = CHAIN_LINK_DECLARED_BY, *, tried: dict | None = None, with_reasons: bool = False, - remedies: dict | None = None) -> dict[str, set]: + remedies: dict | None = None, + run_products=None) -> dict[str, set]: """`{link: the pins ITS manifest declares}` for the links that answered. A link whose tree is not on the disk is simply ABSENT from the mapping, @@ -3265,6 +3286,19 @@ def link_pins_from_trees(links, root: Path | None = None, `scaffold-project.py` takes every link but the first from the naming policy's `open` form, which has no `-`, `.` or `_` to fold. + ONE VARIABLE, ONE PRODUCT OF THE RUN (#909). `run_products` is every + product the caller's run reads: the project's declared pins, and every + link of every chain it reads. The variable guard is asked over them as + well as over `links`, so it gives every chain of the run the same + answer, and a variable two declared pins share is read for no link + (#275's rule). A caller that reads several chains calls this once per + chain, and the guard was asked over that chain alone: in a project whose + two legs' chains begin at `openß` and `openss`, + `SHAPE_PIN_SOURCE_OPENSS` was read for both, so one tree answered for two + products and broke the chain of the one it is not. Given, it returns a + `LinkPins` whose `env_shared` says which variables were not read, so the + warning does not offer them. + `default_source` IS THE FIRST LINK'S TREE AND NO OTHER'S (#441). It is one tree — the naming validator's bare `--link-source`, the scaffold's `--pin-source` — and the first link is the pin this project holds, the @@ -3284,7 +3318,8 @@ def link_pins_from_trees(links, root: Path | None = None, found: dict[str, set] = {} unreadable: dict[str, str] = {} links = list(links) - shared = pin_source_env_shared(repo_basename(str(link)) for link in links) + shared = pin_source_env_shared(repo_basename(str(link)) for link in + [*links, *(run_products or ())]) for index, link in enumerate(links): name = repo_basename(str(link)) asked: list = [] @@ -3303,9 +3338,10 @@ def link_pins_from_trees(links, root: Path | None = None, unreadable[pin_product_key(name)] = why continue found[pin_product_key(name)] = pins - if not (with_reasons or remedies): + if not (with_reasons or remedies or run_products is not None): return found - return LinkPins(found, unreadable if with_reasons else None, remedies) + return LinkPins(found, unreadable if with_reasons else None, remedies, + shared) def find_repo_root(start: Path) -> Path: diff --git a/scripts/validate-repository-naming.py b/scripts/validate-repository-naming.py index b24b29c1..7f01ff4e 100755 --- a/scripts/validate-repository-naming.py +++ b/scripts/validate-repository-naming.py @@ -203,6 +203,18 @@ def __init__(self, name: str, role: str | None, pins: set[str], self.root = root +def _library_takes(keyword: str) -> bool: + """Does this root's `link_pins_from_trees` take `keyword`? The skew rule + `_library_takes_remedies` asks of `remedies=`, asked of any keyword: + `run_products=` is new with #909, and an older library ends a call passing + it in a `TypeError`.""" + try: + return keyword in inspect.signature( + link_pins_from_trees).parameters + except (TypeError, ValueError): + return False + + class LinkSources: """Every `--link-source` value AS TYPED, read against the chain links this run names, and what each lookup made of it (#338). @@ -349,6 +361,13 @@ def link_pins(self, policy: NamingPolicy, target: Target) -> dict: tried: dict = {} offered = ({"remedies": self.remedies} if self.remedies and _library_takes_remedies() else {}) + # ONE VARIABLE, ONE PRODUCT OF THE RUN (#909). The variable guard is + # asked over the declared pins and every chain this run reads, and + # not over this target's chain alone: two legs whose chains begin at + # `openß` and `openss` read `SHAPE_PIN_SOURCE_OPENSS` for both, and + # its one tree broke the chain of the product it is not. + if _library_takes("run_products"): + offered["run_products"] = [*target.pins, *self.links] found = link_pins_from_trees( target.chain, target.root, {key: path for key, (_typed, path) in self.keyed.items()}, diff --git a/templates/assembly-root/scripts/validate-manifest.py b/templates/assembly-root/scripts/validate-manifest.py index fe9cb2f7..6a3beaf0 100755 --- a/templates/assembly-root/scripts/validate-manifest.py +++ b/templates/assembly-root/scripts/validate-manifest.py @@ -175,7 +175,8 @@ def _recorded_chain(naming: dict) -> tuple: return tuple(recorded) -def _chain_and_links(naming, root: Path | None) -> tuple[tuple, dict]: +def _chain_and_links(naming, root: Path | None, + run_products=()) -> tuple[tuple, dict]: """The chain a leg RECORDS, and what its READABLE links declare. Read ONCE and handed to both the leg's own classification and the check of @@ -190,16 +191,90 @@ def _chain_and_links(naming, root: Path | None) -> tuple[tuple, dict]: THE WARNING OFFERS WHAT THIS READS (#661): `_link_remedies`, handed over only where `link_pins_from_trees` takes `remedies=` (`_library_takes_remedies`). + + AND SAYS WHAT A SOURCE THAT IS THERE IS (#909). The lookup is asked what + it tried (`tried=`) and why a tree it reached could not be read + (`with_reasons=`), as the naming gate asks, behind the same guard: every + library that takes `remedies=` takes both. Without them, a checkout of + openXdox beside this root holding no `project.yaml`, or a + `SHAPE_PIN_SOURCE_OPENXDOX` naming no folder, was offered as what would + let the tree be read while it was there, in the words said when nothing + was, and a manifest beside the root that is not a declaration was "not + reachable here". The offer is made once the lookup has said what it + tried, so it is filled in on the answer's `remedies`. + + `run_products` is every product this run reads (`_run_products`). A + variable two of them share answers for none of them, wherever they are, + where the library takes `run_products=` (#909), and it is offered to + none of them. """ chain = _recorded_chain(naming) if isinstance(naming, dict) else () if not chain: return chain, {} - offered = ({"remedies": _link_remedies(chain)} - if _library_takes_remedies() else {}) - return chain, link_pins_from_trees(chain, root, **offered) - - -def _link_remedies(chain) -> dict[str, str]: + if not _library_takes_remedies(): + return chain, link_pins_from_trees(chain, root) + tried: dict = {} + run = ({"run_products": run_products} + if _library_takes("run_products") else {}) + found = link_pins_from_trees(chain, root, tried=tried, with_reasons=True, + **run) + found.remedies.update(_link_remedies(chain, tried, run_products)) + return chain, found + + +def _run_products(pins, legs) -> list[str]: + """Every product this run reads (#909): the declared pins, and every + link of every chain a leg records, read as `_leg_findings` reads it + (`_recorded_chain`), as the naming gate's run reads every leg's + (`_project_chain_links`).""" + products = sorted(pins) + for leg in legs: + naming = leg.get("naming") if isinstance(leg, dict) else None + if isinstance(naming, dict): + products.extend(str(link) for link in _recorded_chain(naming)) + return products + + +#: Why the lookup passed over a source with nothing at its path: +#: `repo_shape.LINK_SOURCE_ABSENT`, word for word. This file imports nothing +#: newer than the library a root may have kept (#610), and +#: `tests/test_chain_pin_sources.py` holds the two equal. +SOURCE_ABSENT = "no such file or directory" + + +def _shown(path) -> str: + """A path as it is, or as Python's `repr` spells it where a character in + it is not printable: `repo_shape.shown_path`, which a root's library + may predate (#610), as `validate-pins.py` and the naming gate spell it + for themselves.""" + text = str(path) + return text if text.isprintable() else repr(text) + + +def _sources_there(link: str, asked) -> tuple[list[str], set[str]]: + """The sources the lookup found THERE for `link` and could not read, + each said with why, and which of the two rules they were: `env` and + `sibling` (#909). `asked` is the lookup's `(rule, how, path, why)` rows + for the link (`link_pins_from_trees(..., tried=)`). + + A variable that is set is there, whatever it names. A checkout beside + this root is there unless nothing is at its path (`SOURCE_ABSENT`). A + source that answered is not said: its tree was read, or was reached and + is said as the library says it. A path is spelled (`_shown`), so an ESC + in a variable's value is not printed raw.""" + said: list[str] = [] + there: set[str] = set() + for rule, _how, path, why in asked: + if why is None or (rule == "sibling" and why == SOURCE_ABSENT): + continue + source = (pin_source_env_name(link) if rule == "env" + else f"the {link} beside this root") + said.append(f"{source}, {_shown(path)}, could not answer: {why}") + there.add(rule) + return said, there + + +def _link_remedies(chain, tried=None, run_products=()) -> dict[str, str]: """What would let each chain link's own tree be read, in THIS validator's sources: `{pin_product_key(link): the words}`, for the `declared-unverified` WARNING about a tree that is not reachable @@ -218,21 +293,47 @@ def _link_remedies(chain) -> dict[str, str]: Keyed by `pin_product_key`, the key `_chain_link_findings` looks a holder's entry up by (#444), and the links are read as that function reads them: by `repo_basename`, a blank entry dropped. + + WHAT IS THERE IS SAID, AND NOT OFFERED (#909). Given `tried`, the + lookup's own record, a variable that is set and a checkout beside this + root that is there are each named with why they could not answer + (`_sources_there`), and only a source that is not there is offered: + "SHAPE_PIN_SOURCE_OPENXDOX, /x/openXdox, could not answer: no such file + or directory; a checkout of openXdox beside this root would let it be + read". A variable another product of the RUN shares (`run_products`) is + not read, and is not offered either. """ links = [repo_basename(str(link)) for link in chain if str(link).strip()] - shared = pin_source_env_shared(links) + shared = pin_source_env_shared( + [*links, *(repo_basename(str(link)) for link in run_products)]) remedies: dict[str, str] = {} for link in links[:-1]: - beside = f"a checkout of {link} beside this root" - if pin_product_key(link) in shared: - words = f"{beside} would let it be read" - else: - words = (f"{pin_source_env_name(link)}, or {beside}, would let " - "it be read") - remedies[pin_product_key(link)] = words + said, there = _sources_there(link, (tried or {}).get(link, ())) + offers = [] + if pin_product_key(link) not in shared and "env" not in there: + offers.append(pin_source_env_name(link)) + if "sibling" not in there: + offers.append(f"a checkout of {link} beside this root") + if len(offers) == 2: + said.append(f"{offers[0]}, or {offers[1]}, would let it be read") + elif offers: + said.append(f"{offers[0]} would let it be read") + remedies[pin_product_key(link)] = "; ".join(said) return remedies +def _library_takes(keyword: str) -> bool: + """Does this root's `link_pins_from_trees` take `keyword`? The skew rule + `_library_takes_remedies` states, for any keyword: `run_products=` is new + with #909, and a library older than that ends a call passing it in a + `TypeError`.""" + try: + return keyword in inspect.signature( + link_pins_from_trees).parameters + except (TypeError, ValueError): + return False + + def _library_takes_remedies() -> bool: """Does this root's `scripts/repo_shape.py` take `remedies=`? @@ -241,13 +342,9 @@ def _library_takes_remedies() -> bool: update, `update-shape.py apply --accept-local`). Before #610 the library had no `remedies=`, and passing it ended this validator in a `TypeError` before it checked a leg. So it is passed only where the function's own - signature names it, and an older library's warning keeps that library's - words.""" - try: - return "remedies" in inspect.signature( - link_pins_from_trees).parameters - except (TypeError, ValueError): - return False + signature names it (`_library_takes`), and an older library's warning + keeps that library's words.""" + return _library_takes("remedies") def _naming_chain_field_finding(leg_role, naming: dict) -> list[str]: @@ -830,7 +927,8 @@ def _leg_path_findings(role, path, paths: dict[str, str]) -> list[str]: def _leg_findings(leg, policy: NamingPolicy, pins: set[str], root, - owners: set[str], paths: dict[str, str]) -> list[str]: + owners: set[str], paths: dict[str, str], + run_products=()) -> list[str]: """One `legs:` entry, start to finish: its repository name against the naming policy, its optional `naming:` record, and its path. @@ -838,7 +936,8 @@ def _leg_findings(leg, policy: NamingPolicy, pins: set[str], root, call per entry. Mutates `owners` and `paths` exactly as the original loop body did, so the path-collision check and the owners-span check after the loop see the same accumulated state; every `continue` in the - original loop is a `return` here. + original loop is a `return` here. `run_products` is every product the + run reads (`_run_products`, #909). """ if not isinstance(leg, dict): return [_finding("manifest-leg", f"a leg is not a mapping: {leg!r}")] @@ -858,7 +957,7 @@ def _leg_findings(leg, policy: NamingPolicy, pins: set[str], root, # declaring `assembly` over `-spec` still lands in # naming-role-mismatch below. naming = leg.get("naming") - chain, link_pins = _chain_and_links(naming, root) + chain, link_pins = _chain_and_links(naming, root, run_products) found = policy.classify(name, str(role) if role else None, pins, chain, link_pins) out = list(_leg_role_classification_finding(role, name, found)) @@ -1066,8 +1165,10 @@ def _findings(manifest: dict, policy: NamingPolicy, root=None) -> list[str]: owners: set[str] = set() paths: dict[str, str] = {} + run_products = _run_products(pins, legs) for leg in legs: - out.extend(_leg_findings(leg, policy, pins, root, owners, paths)) + out.extend(_leg_findings(leg, policy, pins, root, owners, paths, + run_products)) out.extend(_owners_span_finding(owners)) return out diff --git a/tests/test_chain_pin_sources.py b/tests/test_chain_pin_sources.py new file mode 100644 index 00000000..a826a5d5 --- /dev/null +++ b/tests/test_chain_pin_sources.py @@ -0,0 +1,450 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The chain readers ask each pin source once per run and say why one that is +there could not answer, and a chain link is recorded in the spelling its pin +file uses (#909, from #459's lines U172, U113, U145, U114 and U146). + + * U172. The root's `validate-manifest.py` asked the lookup neither what it + tried nor why a tree it reached could not be read. A checkout of + `openXdox` beside the root holding no `project.yaml`, or a + `SHAPE_PIN_SOURCE_OPENXDOX` naming no folder, was offered as what would + let the tree be read while it was there, in the words said when nothing + was, and a manifest beside the root that is not a declaration was "not + reachable here". Each source that is there is named now, with why it + could not answer, and only a source that is not there is offered. The + copies still run beside a library older than the keywords they pass. + * U113, U145. #275's variable guard was asked over one chain's links, and + the naming gate and `validate-manifest.py` read one chain per leg. In a + project whose two legs' chains begin at `openß` and `openss`, + `SHAPE_PIN_SOURCE_OPENSS` was read for both, and its one tree broke the + chain of the product it is not, exit 1; with nothing set, each leg was + offered that variable. The guard is asked over the declared pins and + every chain the run reads now (`link_pins_from_trees`' `run_products`), + and the variable is read for neither and offered to neither. + * U114, U146. `scaffold-project.py` recorded the first `--referent-chain` + link as typed, though it is matched to its `--pin` by `pin_product_key`, + which folds U+212A KELVIN SIGN to `k`: `opendox` beside + `--pin openKdox@` was planned and recorded in that spelling, beside + the one pin file `contracts/openkdox-pin.yaml`. It is recorded in the + `--pin`'s spelling now. + +No network and no GitHub: scaffolds dry-run, or into local bare remotes. +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import pytest + +from conftest import (REPO, SCAFFOLD, clear_ambient_pin_sources, + load_without_bytecode, run_script) +from test_chain_link_fold import SHARP, SS, scaffold_pinning +from test_chain_remedy_callers import (UNREACHED, a_chain_root, + manifest_warnings) +from test_link_source_chain_defaults import VALIDATOR, manifest, tree +from test_manifest_validator import as_a_descendant_by_chain + +sys.path.insert(0, str(REPO / "scripts")) +from repo_shape import ( # noqa: E402 + LINK_SOURCE_ABSENT, LinkPins, link_pins_from_trees, load_yaml, shown_path, +) + +TEMPLATE_MANIFEST = (REPO / "templates" / "assembly-root" / "scripts" / + "validate-manifest.py") + +VARIABLE = "SHAPE_PIN_SOURCE_OPENXDOX" +VARIABLE_OFFER = f"{VARIABLE} would let it be read" +CHECKOUT_OFFER = "a checkout of openXdox beside this root would let it be read" +ABSENT = "no such file or directory" +NO_MANIFEST = "it holds no project.yaml" + +#: The v1.1.1 release commit. Its library takes `remedies=`, `tried=` and +#: `with_reasons=`, and no `run_products=`: a root that kept it under copies +#: from this change (`update-shape.py apply --accept-local`) runs both. +V1_1_1 = "50178305e4229275d18e71c2152403b4e9d3004e" + +KELVIN = chr(0x212A) +KDOX = f"open{KELVIN}dox" + + +def beside(root: Path) -> Path: + """Where `resolve_link_source` looks for `openXdox` beside `root`.""" + return root.resolve().parent / "openXdox" + + +def said(source: str, path, why: str) -> str: + """The clause naming a source that is there and could not answer.""" + return f"{source}, {path}, could not answer: {why}" + + +def unreached(*clauses: str) -> str: + """The root's WARNING for `openXdox`, whose tree was not read.""" + return UNREACHED.format(holder="openXdox", held="openDox", + offer="; ".join(clauses)) + + +def validate(root: Path, env: dict | None = None): + return run_script(root / "scripts" / "validate-manifest.py", cwd=root, + env=env) + + +# --- U172: the root's validate-manifest.py says what is there --------------- + +def _checkout_with_no_manifest(root, _tmp_path): + beside(root).mkdir() + return {}, [said("the openXdox beside this root", beside(root), + NO_MANIFEST), VARIABLE_OFFER] + + +def _variable_naming_nothing(_root, tmp_path): + nowhere = tmp_path / "nowhere" + return {VARIABLE: str(nowhere)}, [said(VARIABLE, nowhere, ABSENT), + CHECKOUT_OFFER] + + +def _variable_naming_a_folder_with_no_manifest(_root, tmp_path): + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + return {VARIABLE: str(elsewhere)}, [said(VARIABLE, elsewhere, NO_MANIFEST), + CHECKOUT_OFFER] + + +def _both_there(root, tmp_path): + nowhere = tmp_path / "nowhere" + beside(root).mkdir() + return {VARIABLE: str(nowhere)}, [ + said(VARIABLE, nowhere, ABSENT), + said("the openXdox beside this root", beside(root), NO_MANIFEST)] + + +@pytest.mark.parametrize("there", [ + pytest.param(_checkout_with_no_manifest, id="checkout-with-no-manifest"), + pytest.param(_variable_naming_nothing, id="variable-naming-nothing"), + pytest.param(_variable_naming_a_folder_with_no_manifest, + id="variable-naming-a-folder-with-no-manifest"), + pytest.param(_both_there, id="both"), +]) +def test_a_source_that_is_there_is_said_and_not_offered(project, monkeypatch, + tmp_path, there): + """Lane 2's three measurements (L2-101): the WARNING was the same with + nothing beside the root, with an empty `openXdox/` beside it, and with + the variable naming a folder that is not there. Each source that is + there is named with why it could not answer, and only what is not there + is offered; where both are there, nothing is.""" + clear_ambient_pin_sources(monkeypatch) + root = a_chain_root(project) + env, clauses = there(root, tmp_path) + result = validate(root, env) + assert result.returncode == 0, result.stderr + assert "manifest ok" in result.stdout + assert manifest_warnings(result) == [unreached(*clauses)] + + +def test_with_nothing_there_both_sources_are_offered(project, monkeypatch): + """The control: nothing beside the root and no variable, the words + #661 gave the WARNING.""" + clear_ambient_pin_sources(monkeypatch) + root = a_chain_root(project) + result = validate(root) + assert result.returncode == 0, result.stderr + assert manifest_warnings(result) == [unreached( + f"{VARIABLE}, or a checkout of openXdox beside this root, would let " + "it be read")] + + +def test_a_manifest_beside_the_root_that_is_not_a_declaration_says_why( + project, monkeypatch): + """A tree that was REACHED and could not be read as a declaration was + "not reachable here": the lookup now keeps why (`with_reasons`), as the + scaffold's does (#338).""" + clear_ambient_pin_sources(monkeypatch) + root = a_chain_root(project) + manifest(beside(root), "- one\n") + result = validate(root) + assert result.returncode == 0, result.stderr + assert manifest_warnings(result) == [ + "declared-unverified: openXdox declaring a pin on openDox is a fact " + "in openXdox's own tree, which was reached but could not be read as " + f"a declaration ({beside(root) / 'project.yaml'}: not a mapping). " + "The recorded chain still classifies."] + + +def test_a_variable_holding_a_control_character_is_spelled(project, + monkeypatch, + tmp_path): + """The variable's value is named as `shown_path` spells a path, so an + ESC in it does not reach the terminal raw.""" + clear_ambient_pin_sources(monkeypatch) + root = a_chain_root(project) + value = str(tmp_path / f"no{chr(27)}[31mwhere") + result = validate(root, {VARIABLE: value}) + assert result.returncode == 0, result.stderr + assert chr(27) not in result.stdout + result.stderr + assert manifest_warnings(result) == [unreached( + said(VARIABLE, shown_path(value), ABSENT), CHECKOUT_OFFER)] + + +def test_the_copy_reads_absence_in_the_librarys_words(): + """`validate-manifest.py` spells `LINK_SOURCE_ABSENT` for itself, since + it imports nothing a kept library may lack, and tells a checkout that is + not there from one that is by it.""" + module = load_without_bytecode(TEMPLATE_MANIFEST, + "chain_pin_sources_manifest") + assert module.SOURCE_ABSENT == LINK_SOURCE_ABSENT + + +def library_at(ref: str) -> bytes: + """`scripts/repo_shape.py` at `ref`, or a skip where this checkout does + not hold that history. It takes the keywords the reasons need and not + `run_products=`, or the row would test nothing.""" + shown = subprocess.run(["git", "show", f"{ref}:scripts/repo_shape.py"], + cwd=str(REPO), capture_output=True, check=False) + if shown.returncode != 0: + pytest.skip(f"needs {ref} in this checkout's history") + assert b"remedies: dict | None = None" in shown.stdout, ref + assert b"with_reasons: bool = False" in shown.stdout, ref + assert b"run_products" not in shown.stdout, ref + return shown.stdout + + +def test_both_copies_run_beside_a_library_with_no_run_products(project, + monkeypatch): + """THE SKEW RULE (#661). Beside v1.1.1's library each copy runs, with + no `TypeError` for `run_products=`, and the root's WARNING says what is + there, because every library that takes `remedies=` says what it + tried.""" + clear_ambient_pin_sources(monkeypatch) + root = a_chain_root(project) + (root / "scripts" / "repo_shape.py").write_bytes(library_at(V1_1_1)) + beside(root).mkdir() + own = validate(root) + assert own.returncode == 0, own.stderr + assert "Traceback" not in own.stderr, own.stderr + assert manifest_warnings(own) == [unreached( + said("the openXdox beside this root", beside(root), NO_MANIFEST), + VARIABLE_OFFER)] + gate = run_script(root / "scripts" / "validate-repository-naming.py", + "--project", "project.yaml", cwd=root) + assert gate.returncode == 0, gate.stderr + assert "Traceback" not in gate.stderr, gate.stderr + + +# --- U113, U145: one variable, one product of the run ---------------------- + +#: The naming gate's WARNING for a holder whose variable another product of +#: the run shares: no variable is offered. +GATE_UNREACHED = ("WARNING {name}: declared-unverified: {holder} declaring a " + "pin on {held} is a fact in {holder}'s own tree, which is " + "not reachable here (a sibling checkout or --link-source " + "{holder}= would let it be read). The recorded chain " + "still classifies.\n") + + +def two_firsts(path: Path) -> Path: + """R444's project: pins on `openß` and `openss`, which share + `SHAPE_PIN_SOURCE_OPENSS` and are two pin files, and two legs whose + chains begin at each.""" + return manifest(path, ( + 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"))))) + + +def one_chain(path: Path, *pins: str) -> Path: + """`codexQuill` recording `openss -> openQuill`, pinning `pins`.""" + return manifest(path, ( + "neutral_product_pins:\n" + "".join(f" - {pin}\n" for pin in pins) + + "legs:\n - repository: testorg/codexQuill\n path: \".\"\n" + " role: assembly\n naming:\n" + f" referent_chain: [{SS}, openQuill]\n")) + + +def sharp_tree(tmp_path: Path, declares: str) -> dict: + """`SHAPE_PIN_SOURCE_OPENSS` set to `openß`'s tree, declaring + `declares`.""" + return {"SHAPE_PIN_SOURCE_OPENSS": str( + tree(tmp_path / "trees" / "sharp", declares))} + + +@pytest.mark.parametrize("variable", [True, False], ids=["set", "unset"]) +def test_the_gate_reads_a_shared_variable_for_no_chain(tmp_path, variable): + """R444. Set to `openß`'s tree, the variable was read for both chains, + and the gate said "FINDING codexQuill: the declared chain openss -> + openQuill is broken (openss declares openInk, not openQuill)", exit 1; + unset, each leg was offered it. It is read for neither now, and offered + to neither, and both chains are declared-unverified, exit 0.""" + root = two_firsts(tmp_path / "ws" / "codexSoft") + env = sharp_tree(tmp_path, "openInk") if variable else {} + result = run_script(VALIDATOR, "--project", "project.yaml", cwd=root, + env=env) + assert result.returncode == 0, result.stderr + assert result.stderr == ( + GATE_UNREACHED.format(name="codexInk", holder=SHARP, held="openInk") + + GATE_UNREACHED.format(name="codexQuill", holder=SS, + held="openQuill")) + + +def test_a_variable_two_declared_pins_share_is_read_for_no_chain(tmp_path): + """#275's rule, that a variable two declared products share answers for + neither, holds for a chain too. One chain begins at `openss`, and the + project pins `openß` as well, on no chain: the variable was read for + `openss`, the one link of the one chain that names it, and `openß`'s + tree broke the chain, exit 1.""" + root = one_chain(tmp_path / "ws" / "codexSoft", SHARP, SS) + result = run_script(VALIDATOR, "--project", "project.yaml", cwd=root, + env=sharp_tree(tmp_path, "openInk")) + assert result.returncode == 0, result.stderr + assert result.stderr == GATE_UNREACHED.format( + name="codexQuill", holder=SS, held="openQuill") + + +def test_a_variable_a_link_of_another_chain_shares_is_read_for_no_chain( + tmp_path): + """The chains half. The project pins `openss` alone, and its spec leg + records a chain beginning at `openß`: a link of the run, though no + classification consults that chain. The variable was read for `openss`, + and `openß`'s tree broke `codexQuill`'s chain, exit 1.""" + root = manifest(tmp_path / "ws" / "codexSoft", ( + f"neutral_product_pins:\n - {SS}\nlegs:\n" + " - repository: testorg/codexQuill\n path: \".\"\n" + " role: assembly\n naming:\n" + f" referent_chain: [{SS}, openQuill]\n" + " - repository: testorg/codexQuill-spec\n path: spec\n" + " role: spec\n naming:\n" + f" referent_chain: [{SHARP}, openInk]\n")) + result = run_script(VALIDATOR, "--project", "project.yaml", cwd=root, + env=sharp_tree(tmp_path, "openInk")) + assert result.returncode == 0, result.stderr + assert result.stderr == GATE_UNREACHED.format( + name="codexQuill", holder=SS, held="openQuill") + + +def test_a_variable_one_product_of_the_run_names_is_still_read(tmp_path): + """The control: one pin, one chain, and one link naming the variable, + which is read and verifies the chain.""" + root = one_chain(tmp_path / "ws" / "codexSoft", SS) + env = {"SHAPE_PIN_SOURCE_OPENSS": str( + tree(tmp_path / "trees" / SS, "openQuill"))} + result = run_script(VALIDATOR, "--explain", "--project", "project.yaml", + cwd=root, env=env) + assert result.returncode == 0, result.stderr + assert f"CHAIN {SS} → openQuill [verified]" in result.stdout, ( + result.stdout) + assert "WARNING" not in result.stderr, result.stderr + + +def a_root_sharing_one_variable(project: Path, where: str) -> Path: + """The scaffolded root as `codexDox`, pinning `openss` with its pin file + and recording `openss -> openDox`, and naming `openß` too: as the first + link of a chain its spec leg records (this validator reads every chain a + leg records, whatever that leg's name claims), or as a pin on no + chain.""" + as_a_descendant_by_chain(project, first=SS) + (project / "contracts" / f"{SS}-pin.yaml").write_text( + "schema_version: 1\nkind: pinned_contract_manifest\n", + encoding="utf-8") + if where == "chain": + anchor = " role: spec\n also_matches: []\n" + added = f"{anchor} referent_chain: [{SHARP}, openQuill]\n" + else: + anchor = f"neutral_product_pins:\n - {SS}\n" + added = f"{anchor} - {SHARP}\n" + written = project / "project.yaml" + text = written.read_text(encoding="utf-8") + assert anchor in text, f"fixture drift: {anchor!r} not in project.yaml" + written.write_text(text.replace(anchor, added, 1), encoding="utf-8") + return project + + +@pytest.mark.parametrize("variable", [True, False], ids=["set", "unset"]) +@pytest.mark.parametrize("where", ["chain", "pin"]) +def test_the_manifest_gate_reads_a_shared_variable_for_no_chain( + project, monkeypatch, tmp_path, where, variable): + """The same defect in the root's own validator, which reads every leg's + chain: set to `openß`'s tree, the variable was read for `openss` and + broke `codexDox`'s chain, exit 1; unset, it was offered. It is read for + neither now, and the holder is offered a checkout beside the root + alone.""" + clear_ambient_pin_sources(monkeypatch) + root = a_root_sharing_one_variable(project, where) + result = validate(root, sharp_tree(tmp_path, "openQuill") + if variable else {}) + assert result.returncode == 0, result.stderr + assert "manifest ok" in result.stdout + assert manifest_warnings(result) == [UNREACHED.format( + holder=SS, held="openDox", + offer=f"a checkout of {SS} beside this root would let it be read")] + + +def test_the_library_asks_the_guard_over_the_run(tmp_path, monkeypatch): + """`run_products` widens the guard to the run; without it, the guard is + the chain's own, as it was.""" + clear_ambient_pin_sources(monkeypatch) + monkeypatch.setenv("SHAPE_PIN_SOURCE_OPENSS", + str(tree(tmp_path / SS, "openQuill"))) + chain = [SS, "openQuill"] + assert link_pins_from_trees(chain) == {SS: {"openQuill"}} + found = link_pins_from_trees(chain, run_products=[SHARP, *chain]) + assert isinstance(found, LinkPins) + assert found == {} + assert found.env_shared == {SHARP, SS} + + +# --- U114, U146: the first link in its pin's spelling ------------------------ + +@pytest.fixture(scope="module") +def scaffold_module(): + """`scaffold-project.py` loaded as a module.""" + return load_without_bytecode(SCAFFOLD, "scaffold_chain_pin_sources") + + +@pytest.mark.parametrize("raw,pins,links", [ + pytest.param(f"{KDOX},openInk", {"openKdox"}, ("openKdox", "openInk"), + id="kelvin-sign"), + pytest.param("OPENKDOX, openInk", {"openKdox"}, ("openKdox", "openInk"), + id="ascii-case"), + pytest.param(f"opensoft/{KDOX},openInk", {"openKdox"}, + ("opensoft/openKdox", "openInk"), id="owner-kept"), + pytest.param(f"{KDOX},openInk", (), (KDOX, "openInk"), id="no-pin"), + pytest.param("openXdox,openkdox,openInk", {"openXdox", "openKdox"}, + ("openXdox", "openkdox", "openInk"), id="first-link-alone"), + pytest.param(f"{SHARP},openInk", {SS}, (SHARP, "openInk"), + id="another-pin-file"), + pytest.param(" , ", {"openKdox"}, (), id="no-link"), +]) +def test_a_first_link_that_is_a_pin_is_spelled_as_the_pin(scaffold_module, + raw, pins, links): + """The first link alone, and only where `pin_product_key` makes it the + `--pin`'s product; `openß` is another pin file than `openss`.""" + assert scaffold_module.referent_chain_links(raw, pins) == links + + +def test_the_plan_names_the_chain_in_the_pins_spelling(tmp_path): + """The issue's probe: `--pin openKdox@ --referent-chain + 'opendox,openInk' --dry-run` planned the pin file + `contracts/openkdox-pin.yaml` and printed the chain with the Kelvin + sign.""" + result = scaffold_pinning(tmp_path, "openKdox", f"{KDOX},openInk", + "--dry-run") + assert result.returncode == 0, result.stderr + result.stdout + assert ("reaching openInk through the declared pin chain openKdox → " + "openInk") in result.stdout, result.stdout + assert "contracts/openkdox-pin.yaml" in result.stdout, result.stdout + assert KELVIN not in result.stdout + result.stderr + + +def test_the_manifest_records_the_chain_in_the_pins_spelling(tmp_path): + """A real run recorded the Kelvin sign in `project.yaml`.""" + result = scaffold_pinning(tmp_path, "openKdox", f"{KDOX},openInk") + assert result.returncode == 0, result.stderr + result.stdout + written = tmp_path / "work" / "codexInk" / "project.yaml" + assert KELVIN not in written.read_text(encoding="utf-8") + assembly = next(leg for leg in load_yaml(written)["legs"] + if leg["role"] == "assembly") + assert assembly["naming"]["referent_chain"] == ["openKdox", "openInk"] diff --git a/tests/test_naming_policy.py b/tests/test_naming_policy.py index e3133209..eef53fa1 100644 --- a/tests/test_naming_policy.py +++ b/tests/test_naming_policy.py @@ -2058,11 +2058,13 @@ def test_the_naming_validator_keeps_its_own_two_lines_for_a_tree_it_reached( result.stderr) -def test_the_projects_own_manifest_gate_keeps_its_warning_for_a_tree_it_reached( +def test_the_projects_own_manifest_gate_says_why_a_tree_it_reached_was_unread( project): """`validate-manifest.py` reads the same link tree through the same - lookup and prints no reason of its own, so it is the text it always was; - this change reaches the scaffold alone.""" + lookup and prints no reason of its own. It kept the text it always had, + "not reachable here", for a tree that is there, until #909: it asks the + lookup for the reason now, as the scaffold does, and its WARNING says + it.""" _codexdox(project) sibling = project.parent / "openXdox" sibling.mkdir() @@ -2070,12 +2072,13 @@ def test_the_projects_own_manifest_gate_keeps_its_warning_for_a_tree_it_reached( b"schema_version: 1\nneutral_product_pins: openDox\n") own = run_script(project / "scripts" / "validate-manifest.py", cwd=project) + said = own.stdout + own.stderr assert "Traceback" not in own.stderr, own.stderr - assert ("which is not reachable here (SHAPE_PIN_SOURCE_OPENXDOX, or a " - "checkout of openXdox beside this root, would let it be read)" - ) in own.stdout + own.stderr, own.stdout + own.stderr - assert "--link-source" not in own.stdout + own.stderr - assert "was reached" not in own.stdout + own.stderr + assert ("which was reached but could not be read as a declaration " + f"({sibling.resolve() / 'project.yaml'}: neutral_product_pins is " + "not a list)") in said, said + assert "not reachable here" not in said + assert "--link-source" not in said # --- the ruling: a NEUTRAL PRODUCT MAY ELECT THE SHAPE --------------------- diff --git a/tests/test_pinned_copy_text.py b/tests/test_pinned_copy_text.py index 52575499..0c88ff7a 100644 --- a/tests/test_pinned_copy_text.py +++ b/tests/test_pinned_copy_text.py @@ -61,6 +61,8 @@ "validate-pins.py") TEMPLATE_FAMILY = (REPO / "templates" / "family-root" / "scripts" / "validate-family.py") +TEMPLATE_MANIFEST = (REPO / "templates" / "assembly-root" / "scripts" / + "validate-manifest.py") POLICY = REPO / "contracts" / "repository-naming.yaml" #: What the pinned `AGENTS-shape.md` says of `CONTRACTS_DIR` (L2-33), with its @@ -762,11 +764,12 @@ def test_the_naming_gate_names_a_printable_link_source_as_typed(tmp_path): "tab\there", "", ]) def test_the_pinned_scripts_spell_a_path_as_the_library_does(path): - """`validate-pins.py` and `validate-repository-naming.py` each write - their own `_shown`, because a root whose `scripts/repo_shape.py` is - older than they are has no `shown_path` to import. Each must spell as - `shown_path` spells.""" - for script, name in ((TEMPLATE_PINS, "pins"), (VALIDATOR, "naming")): + """`validate-pins.py`, `validate-repository-naming.py` and, since #909, + `validate-manifest.py` each write their own `_shown`, because a root + whose `scripts/repo_shape.py` is older than they are has no + `shown_path` to import. Each must spell as `shown_path` spells.""" + for script, name in ((TEMPLATE_PINS, "pins"), (VALIDATOR, "naming"), + (TEMPLATE_MANIFEST, "manifest")): module = load_without_bytecode(script, f"pinned_copy_text_{name}") assert module._shown(path) == shown_path(path), script From 499f7baa5e4f6ecaf0bd4f557fa2fadd956e7457 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Mon, 19 Oct 2026 11:40:45 +0000 Subject: [PATCH 2/2] tests/test_chain_pin_sources.py and validate-manifest.py: two dry-run rows hold that the scaffold reads the first chain link's own `--pin` variable, not SHAPE_PIN_SOURCE_OPEN_DOX, an owner-qualified link is held to the run's variable guard, and the SOURCE_ABSENT and `_run_products` comments say why (#909) Fix round 1 on RV981's review. S1. The Kelvin fix also changes which variable is read for the first link: as typed, `opendox` was read through SHAPE_PIN_SOURCE_OPEN_DOX, and it is the pin `openKdox` now, read through SHAPE_PIN_SOURCE_OPENKDOX. Two dry-run rows hold both directions. S2. A spec leg's chain beginning at `opensoft/open` is a row of test_the_manifest_gate_reads_a_shared_variable_for_no_chain, so the library's `repo_basename` on `run_products` is held. N1. SOURCE_ABSENT's comment says the copy keeps the import block clear of #958. N3. `_run_products` says it counts a leg with a bad or missing `repository:`, which the naming gate's `_project_chain_links` leaves out. Co-Authored-By: Claude Opus 5.5 --- .../scripts/validate-manifest.py | 11 +-- tests/test_chain_pin_sources.py | 68 +++++++++++++++++-- 2 files changed, 69 insertions(+), 10 deletions(-) diff --git a/templates/assembly-root/scripts/validate-manifest.py b/templates/assembly-root/scripts/validate-manifest.py index 6a3beaf0..0c43f4ab 100755 --- a/templates/assembly-root/scripts/validate-manifest.py +++ b/templates/assembly-root/scripts/validate-manifest.py @@ -225,8 +225,10 @@ def _chain_and_links(naming, root: Path | None, def _run_products(pins, legs) -> list[str]: """Every product this run reads (#909): the declared pins, and every link of every chain a leg records, read as `_leg_findings` reads it - (`_recorded_chain`), as the naming gate's run reads every leg's - (`_project_chain_links`).""" + (`_recorded_chain`). That counts the chain of a leg whose `repository:` + is bad or missing, which the naming gate's `_project_chain_links` + leaves out; such a leg is a finding in both gates, so the run fails + either way.""" products = sorted(pins) for leg in legs: naming = leg.get("naming") if isinstance(leg, dict) else None @@ -236,8 +238,9 @@ def _run_products(pins, legs) -> list[str]: #: Why the lookup passed over a source with nothing at its path: -#: `repo_shape.LINK_SOURCE_ABSENT`, word for word. This file imports nothing -#: newer than the library a root may have kept (#610), and +#: `repo_shape.LINK_SOURCE_ABSENT`, word for word. Every tagged library has +#: it (cac6bb20, before v1.0.0), and the naming gate imports it; this copy +#: keeps this file's import block clear of #958, which edits it. #: `tests/test_chain_pin_sources.py` holds the two equal. SOURCE_ABSENT = "no such file or directory" diff --git a/tests/test_chain_pin_sources.py b/tests/test_chain_pin_sources.py index a826a5d5..c48ea284 100644 --- a/tests/test_chain_pin_sources.py +++ b/tests/test_chain_pin_sources.py @@ -32,12 +32,14 @@ from __future__ import annotations +import functools import subprocess import sys from pathlib import Path import pytest +import test_chain_link_fold from conftest import (REPO, SCAFFOLD, clear_ambient_pin_sources, load_without_bytecode, run_script) from test_chain_link_fold import SHARP, SS, scaffold_pinning @@ -343,15 +345,18 @@ def a_root_sharing_one_variable(project: Path, where: str) -> Path: """The scaffolded root as `codexDox`, pinning `openss` with its pin file and recording `openss -> openDox`, and naming `openß` too: as the first link of a chain its spec leg records (this validator reads every chain a - leg records, whatever that leg's name claims), or as a pin on no - chain.""" + leg records, whatever that leg's name claims), there with its owner, + `opensoft/`, before it, which is still that link (`repo_basename`), or + as a pin on no chain.""" as_a_descendant_by_chain(project, first=SS) (project / "contracts" / f"{SS}-pin.yaml").write_text( "schema_version: 1\nkind: pinned_contract_manifest\n", encoding="utf-8") - if where == "chain": + owners = {"chain": "", "qualified-chain": "opensoft/"} + if where in owners: anchor = " role: spec\n also_matches: []\n" - added = f"{anchor} referent_chain: [{SHARP}, openQuill]\n" + added = (f"{anchor} referent_chain: " + f"[{owners[where]}{SHARP}, openQuill]\n") else: anchor = f"neutral_product_pins:\n - {SS}\n" added = f"{anchor} - {SHARP}\n" @@ -363,14 +368,17 @@ def a_root_sharing_one_variable(project: Path, where: str) -> Path: @pytest.mark.parametrize("variable", [True, False], ids=["set", "unset"]) -@pytest.mark.parametrize("where", ["chain", "pin"]) +@pytest.mark.parametrize("where", ["chain", "qualified-chain", "pin"]) def test_the_manifest_gate_reads_a_shared_variable_for_no_chain( project, monkeypatch, tmp_path, where, variable): """The same defect in the root's own validator, which reads every leg's chain: set to `openß`'s tree, the variable was read for `openss` and broke `codexDox`'s chain, exit 1; unset, it was offered. It is read for neither now, and the holder is offered a checkout beside the root - alone.""" + alone. The products are read by `repo_basename` (RV981's S2): this + validator hands them over as its manifest spells them, and read whole, + the owner-qualified link names `SHAPE_PIN_SOURCE_OPENSOFT_OPENSS` and + shares nothing.""" clear_ambient_pin_sources(monkeypatch) root = a_root_sharing_one_variable(project, where) result = validate(root, sharp_tree(tmp_path, "openQuill") @@ -439,6 +447,54 @@ def test_the_plan_names_the_chain_in_the_pins_spelling(tmp_path): assert KELVIN not in result.stdout + result.stderr +def scaffold_reading(monkeypatch, tmp_path: Path, env: dict, *args: str): + """The issue's probe, `--pin openKdox@ --referent-chain + 'opendox,openInk'`, run by `scaffold_pinning` with `env` + given to the scaffold: that helper passes none, and `run_script` blanks + every `SHAPE_PIN_SOURCE_` its caller does not name.""" + monkeypatch.setattr(test_chain_link_fold, "run_script", + functools.partial(run_script, env=env)) + return scaffold_pinning(tmp_path, "openKdox", f"{KDOX},openInk", *args) + + +def test_the_kelvin_link_is_not_read_through_another_products_variable( + monkeypatch, tmp_path): + """RV981's S1: the fix changes which variable is read for that link. + Read as typed, `opendox` was read through + `SHAPE_PIN_SOURCE_OPEN_DOX`: the Kelvin sign is upper case and not in + `[A-Z0-9_]`, so `pin_source_env_name` turns it into `_`, and that is the + variable of `open-dox`, `open_dox` and `open.dox`. Set to a tree + declaring `openQuill`, it refused a chain that holds, + `chain-not-declared`, exit 2. The link is the pin `openKdox` now, so + that variable is not its own, and the `--pin-source` verifies it.""" + other = tree(tmp_path / "trees" / "open_dox", "openQuill") + result = scaffold_reading(monkeypatch, tmp_path, + {"SHAPE_PIN_SOURCE_OPEN_DOX": str(other)}, + "--dry-run") + assert result.returncode == 0, result.stderr + result.stdout + assert ("reaching openInk through the declared pin chain openKdox → " + "openInk") in result.stdout, result.stdout + assert "declared-unverified" not in result.stderr, result.stderr + assert KELVIN not in result.stdout + result.stderr + + +def test_the_kelvin_link_is_read_through_its_pins_variable(monkeypatch, + tmp_path): + """The reverse: `SHAPE_PIN_SOURCE_OPENKDOX`, the pin's own variable, is + read for that link before its `--pin-source` (`resolve_link_source`'s + rule 2), so a tree there declaring `openQuill` refuses the chain.""" + other = tree(tmp_path / "trees" / "openKdox", "openQuill") + result = scaffold_reading(monkeypatch, tmp_path, + {"SHAPE_PIN_SOURCE_OPENKDOX": str(other)}, + "--dry-run") + assert result.returncode == 2, result.stderr + result.stdout + assert ("REFUSED chain-not-declared: --referent-chain openKdox,openInk: " + "the declared chain openKdox → openInk is broken (openKdox " + "declares openQuill, not openInk)\n") in result.stderr, ( + result.stderr) + assert KELVIN not in result.stdout + result.stderr + + def test_the_manifest_records_the_chain_in_the_pins_spelling(tmp_path): """A real run recorded the Kelvin sign in `project.yaml`.""" result = scaffold_pinning(tmp_path, "openKdox", f"{KDOX},openInk")