From c64e85fea28ee9fb54581e93abb0e09abdba1d48 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 20 Oct 2026 05:43:14 +0000 Subject: [PATCH] validate-repository-naming.py: a `--link-source` key is the longest chain link before an `=` that leaves a path after it, so the `--link-source open=y=` the gate offers a link holding `=` verifies that link when typed as printed, and `open=y=` is refused as naming no path (#980) The gate offered a declared-unverified chain link whose name holds `=` the source `--link-source open=y=`, and read every `--link-source` key at the value's first `=`. Typed as printed, the key was `open`, which no chain names, so the whole value was a bare PATH for the chain's first link: a `link-source-unreadable` WARNING, and `open=y` stayed declared-unverified under the same offer, exit 0. In a chain naming `open` and `open=y` both, `open=y=` was a source for `open`, with the path `y=`, and it replaced `open=`. `LinkSources._read_source` now reads a key the way `_read_remedy` reads a remedy's link since #949, the issue's direction (a): the longest text before an `=` that names a chain link, by `pin_product_key`, and leaves a path after it. The key is read whole, never stripped or cut at a `/`, so `./PATH` still asks for a directory spelled like the whole value, the keyed reading still wins over such a directory (with the `./` line), and `=./` gives a link a path that begins like a longer link's name. A value that ends at a link, `open=y=`, is that link's with no path, and is refused as `link-source-no-path`, as `openXdox=` is (#442). For a chain whose links hold no `=`, every value is read as before. The module and class docstrings, the `--link-source` help and docs/cli.md say so, and so does setup-project.py's note on why step (4)'s absolute `--pin-source` is one path to the gate. `validate-pins.py` still ends a `--pin-source` key at the first `=`; no scaffold writes a pin holding one, and the docstring says it is left. What a project sees: `update-shape.py check` lists `upstream-changed scripts/validate-repository-naming.py` (sha256 ec167fb0e259f907...), and `apply` re-pins it. Closes #980 Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 --- docs/cli.md | 27 ++-- scripts/validate-repository-naming.py | 109 +++++++++++---- setup-project.py | 13 +- tests/test_chain_remedy_callers.py | 183 ++++++++++++++++++++++++++ 4 files changed, 290 insertions(+), 42 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 0fc1cc84..68585086 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1103,19 +1103,20 @@ options: where a chain LINK's own tree is, so its `neutral_product_pins:` can be read and the link VERIFIED — e.g. --link-source openXdox=../openXdox. - The text before the first `=` is a PRODUCT only when - it names a chain link (an entry of --referent-chain, - or of a leg's naming.referent_chain: under --project), - and then that reading wins even over a directory - spelled like the whole value (./PATH reads that - directory); any other value is a bare PATH: the tree - of the chain's FIRST link (the pin the project holds), - read when that link has no more specific answer, and - never a later link's, which needs its own - PRODUCT=PATH, its SHAPE_PIN_SOURCE_ or a - checkout beside the project. When the chains read here - begin at two different links, a bare PATH is read for - neither. A value that names no path (PRODUCT=, or + The text before an `=` is a PRODUCT only when it names + a chain link (an entry of --referent-chain, or of a + leg's naming.referent_chain: under --project), and the + longest such text that leaves a path after it is the + one read, so a link may hold `=`; that reading wins + even over a directory spelled like the whole value + (./PATH reads that directory); any other value is a + bare PATH: the tree of the chain's FIRST link (the pin + the project holds), read when that link has no more + specific answer, and never a later link's, which needs + its own PRODUCT=PATH, its SHAPE_PIN_SOURCE_ + or a checkout beside the project. When the chains read + here begin at two different links, a bare PATH is read + for neither. A value that names no path (PRODUCT=, or empty) is refused. Repeatable; a value no link used is noted. Every link is tried against PRODUCT=PATH, SHAPE_PIN_SOURCE_ and a checkout beside the diff --git a/scripts/validate-repository-naming.py b/scripts/validate-repository-naming.py index d19d1a70..2a3b2119 100755 --- a/scripts/validate-repository-naming.py +++ b/scripts/validate-repository-naming.py @@ -65,6 +65,17 @@ empty one — is refused (#442): an empty path is the current directory, and `.` is how to mean that. +A KEY IS THE LONGEST LINK A VALUE NAMES (#980). The text before an `=` is a +link only where it names a link of a chain this run reads, and of those +texts the longest that leaves a path after it is the key, so a link holding +`=` is one: `open=y=`, the source the `declared-unverified` WARNING +offers `open=y`, is that link's tree, where the first `=` made it a bare +PATH. The key is the whole text before its `=`, never stripped or cut at a +`/`, so `./` before a value still asks for the directory it spells, and +`=./` gives a link a path that begins like a longer link's +name. A value that ends at a link, `open=y=`, names no path and is refused, +as `PRODUCT=` is. + A CALLER THAT PASSES A SOURCE FOR SOMEONE ELSE SAYS IT IN ITS OWN WORDS (#661). `--link-source-label=FLAG` is the flag the person typed for the bare PATH (setup's step (4) passes `--pin-source`): the `link-source-unreadable` @@ -80,8 +91,8 @@ before its last `/`, holds no `=` (#991): no owner is spelled with one, so `openXdox=a/openYdox=b` gives `openXdox` the words `a/openYdox=b`, and `opensoft/open=y=TEXT` still gives `open=y` the words. A `--link-source` -key is not read so: it is the text before the value's first `=`, so a link -holding `=` is never one. A `--link-remedy` no holder used is a NOTE saying +key is read the same way (#980), but whole, as the paragraph above says. +A `--link-remedy` no holder used is a NOTE saying why, except where the caller gave a label: its words are its own, not the person's. A project's own runs pass neither. Every `--link-source` and `--link-remedy` value and path this prints, and @@ -192,8 +203,8 @@ def _link_remedy(value: str) -> str: WHICH `=` ENDS THE LINK is decided once the chains this run reads are known (`LinkSources._read_remedy`, #907): the last one that leaves a link of theirs before it, with no `=` in its owner (#991), and words - after it. A `--link-source` key ends at the value's first `=`, whatever - the chains name.""" + after it. A `--link-source` key is found the same way once the chains + are known, but whole (`LinkSources._read_source`, #980).""" link, sep, text = value.partition("=") if not (sep and repo_basename(link.strip()) and text.strip()): raise argparse.ArgumentTypeError( @@ -243,16 +254,22 @@ 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 names a link of a - referent chain this run reads (`--referent-chain`'s, or a leg's + text before an `=` 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`) by `pin_product_key`, the key - the lookup uses, and any other value is a bare PATH, whole. A link's name + the lookup uses, and any other value is a bare PATH, whole. Of the texts + that do, the longest that leaves a path after it is the key + (`_read_source`, #980), so a link may hold an `=`. 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 OVER ASCII ONLY (#444). Outside it they part + read `[PRODUCT=]PATH` one way OVER ASCII ONLY (#444), and for a name that + holds no `=` (#980). That file still ends a key at the first `=`, so a + declared pin holding one is never a `--pin-source` key there; no + scaffold writes such a pin, since `--pin` names are `[A-Za-z0-9_.-]`, + and it is left as its own change. Outside ASCII they part company: `templates/assembly-root/scripts/validate-pins.py` still keys `--pin-source PRODUCT=` by `casefold()`, in `PinSources.__init__`, which builds the table, and in `resolve_neutral_pin_source`, which looks a pin @@ -350,8 +367,7 @@ def __init__(self, values: list[str], links, firsts=(), #: `{typed: the later value that took its place}`. self.replaced: dict[str, str] = {} for typed in values: - key, sep, path = typed.partition("=") - link = names.get(pin_product_key(key)) if sep else None + link, path = self._read_source(typed) self.reading[typed] = link if link is not None: earlier = self.keyed.get(pin_product_key(link)) @@ -363,10 +379,10 @@ def __init__(self, values: list[str], links, firsts=(), if earlier is not None and earlier[0] != typed: self.replaced[earlier[0]] = typed #: The values that name no path (#442), in the order given: a keyed - #: value with nothing after its `=`, and an empty bare one. + #: value with nothing after its key's `=`, and an empty bare one. 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)] + typed for typed in self.reading + if not self._read_source(typed)[1]] #: 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). @@ -382,6 +398,51 @@ def __init__(self, values: list[str], links, firsts=(), #: lookup passed over (#990), in the order first met. self.shared_variables: dict[str, list[str]] = {} + def _read_source(self, typed: str) -> tuple[str | None, str]: + """`(link, path)` for one `--link-source` value (#980): the link it + is keyed to, as the chain spells it, and the text of its path; or + `(None, typed)` for a bare PATH, which is the whole value. + + THE LONGEST LINK THAT LEAVES A PATH, as `_read_remedy` finds a + remedy's link (#907). The text before an `=` is a link only where + it names a link of a chain this run reads, by `pin_product_key`, + and the LAST `=` whose text does, with a path after it, ends the + key. Read at the first `=`, `open=y=`, the very source the + `declared-unverified` WARNING offers `open=y`, was a bare PATH for + the chain's first link, since no chain names `open`: typed as + printed, it never verified `open=y`. In a chain naming `open` and + `open=y` both, `open=y=` is `open=y`'s and `open=` is + `open`'s, where the first `=` gave both to `open`, and the later + value replaced the earlier one. + + WHOLE, where a remedy's link is not: the key is the text before its + `=` as typed, never stripped and never cut at a `/`. A link's name + holds no `/` (`repo_basename`), so a text holding one names no + link, and `./open=y=x` is the directory it spells, as `./` asks for + one under the reading's documented rule (`_also_a_directory` + offers it). `open=./y=dir` gives `open` the path `./y=dir` in a + chain that names `open=y` too. A blank is a path's, as it always + was: ` open=y=x` names no link. + + A VALUE THAT ENDS AT A LINK NAMES NO PATH. Only the last `=` can + leave nothing after it. Where its text names a link and no shorter + link leaves a path, the value is that link's, with an empty path, + and `no_path` refuses it (#442), as it refuses `openXdox=`: read as + a bare PATH, `open=y=` would be a tree for the chain's first link, + a link the person did not name. A remedy has no path to refuse, so + `_read_remedy` reads such a value at its first `=` instead.""" + at = typed.rfind("=") + while at >= 0: + link = self.names.get(pin_product_key(typed[:at])) + if link is not None and typed[at + 1:]: + return link, typed[at + 1:] + at = typed.rfind("=", 0, at) + if typed.endswith("="): + link = self.names.get(pin_product_key(typed[:-1])) + if link is not None: + return link, "" + return None, typed + def _read_remedy(self, typed: str) -> tuple[str, str]: """`(link, words)` for one `--link-remedy` (#907): the text before an `=` is a LINK only where it names a link of a referent chain this @@ -394,8 +455,8 @@ def _read_remedy(self, typed: str) -> tuple[str, str]: its own words, and from the first `=` `openXdox=y=` was `y=` for `openXdox`. Where no `=` does, the text before the first one is the link, which no chain here names, and `unused_lines` - says so. A `--link-source` key is not read so: it is the text before - its first `=` alone (`__init__`). The link is spelled as the chain + says so. A `--link-source` key is read so too, but whole + (`_read_source`, #980). The link is spelled as the chain spells it, and the words are stripped: `openXdox= padded` offered ` padded`. @@ -1348,14 +1409,16 @@ def main(argv: list[str] | None = None) -> int: help="where a chain LINK's own tree is, so its " "`neutral_product_pins:` can be read and the " "link VERIFIED — e.g. --link-source " - "openXdox=../openXdox. The text before the " - "first `=` is a PRODUCT only when it names a " - "chain link (an entry of --referent-chain, or " - "of a leg's naming.referent_chain: under " - "--project), and then that reading wins even " - "over a directory spelled like the whole value " - "(./PATH reads that directory); any other value " - "is a bare PATH: the tree of the chain's FIRST " + "openXdox=../openXdox. The text before an `=` " + "is a PRODUCT only when it names a chain link " + "(an entry of --referent-chain, or of a leg's " + "naming.referent_chain: under --project), and " + "the longest such text that leaves a path " + "after it is the one read, so a link may hold " + "`=`; that reading wins even over a directory " + "spelled like the whole value (./PATH reads " + "that directory); any other value is a bare " + "PATH: the tree of the chain's FIRST " "link (the pin the project holds), read when " "that link has no more specific answer, and " "never a later link's, which needs its own " diff --git a/setup-project.py b/setup-project.py index 172d6cb6..87aae848 100755 --- a/setup-project.py +++ b/setup-project.py @@ -2675,12 +2675,13 @@ def as_the_plan_reads_it(shape_root: Path, path: str) -> str: (`check_names`), not from the checkout a relative path is read from. AND THE NAMING CHECK CANNOT READ IT AS `LINK=PATH`. It takes the text - before a `--link-source`'s first `=` for a product when that is a link - of the chain, so `openXdox=layer`, which the scaffold reads as the one - path it is, would be read as `layer` for `openXdox`. An absolute path - has a separator or a drive before its first `=`, which no link's name - holds (a link is read at its last `/`), so it is one path to the naming - check too, as `./openXdox=layer` was. + before an `=` of a `--link-source` for a product when that is a link of + the chain, the longest such text that leaves a path after it (#980), so + `openXdox=layer`, which the scaffold reads as the one path it is, would + be read as `layer` for `openXdox`. An absolute path has a separator or a + drive before its first `=`, and so before every one, which no link's + name holds (a link is read at its last `/`), so it is one path to the + naming check too, as `./openXdox=layer` was. AN EMPTY PATH IS NEVER GIVEN TO IT. Since #570 the scaffold takes `--pin-source` as text and refuses an exactly empty value as diff --git a/tests/test_chain_remedy_callers.py b/tests/test_chain_remedy_callers.py index 4a17fefa..edce2769 100644 --- a/tests/test_chain_remedy_callers.py +++ b/tests/test_chain_remedy_callers.py @@ -22,6 +22,11 @@ blanks around its words, and a remedy no holder used was dropped with no NOTE (U173). And no row gave a labelled run a KEYED `--link-source`, so naming that by the label too passed every row (U174). + * #980. A `--link-source` key ended at the value's first `=`, so the + `--link-source open=y=` the gate offers a link holding `=` was a + bare PATH for the chain's first link: typed as printed, it never + verified `open=y`. The key is the longest link a chain here names that + leaves a path after it, as a remedy's link is, but read whole. No network and no GitHub. """ @@ -30,6 +35,7 @@ import subprocess import sys +from pathlib import Path import pytest @@ -470,6 +476,183 @@ def test_a_remedy_for_a_holder_whose_tree_answered_is_not_noted(tmp_path, assert "NOTE" not in result.stderr, result.stderr +# --- #980: a --link-source key is the longest link a chain here names ------- + +#: A chain naming `open` and `open=y` both: the shorter link's name is where +#: the longer one's begins. +PREFIX = ("--pins", "openXdox", "--referent-chain", + "openXdox,open,open=y,openInk") + +#: The gate's own offer for `open=y`, whose variable is `..._OPEN_Y`. +EQUALS_OFFER = ("a sibling checkout, SHAPE_PIN_SOURCE_OPEN_Y, or " + "--link-source open=y= would let it be read") + + +def offered_source(result, holder: str) -> str: + """The `--link-source` value the gate's WARNING offers `holder`, as + printed: `=`.""" + said = [line for line in gate_warnings(result) + if line.startswith(f"declared-unverified: {holder} declaring ")] + assert len(said) == 1, result.stderr + return said[0].partition(" or --link-source ")[2].partition( + " would let it be read")[0] + + +def chain_lines(result) -> list[str]: + """The `--explain` CHAIN lines, stripped.""" + return [line.strip() for line in result.stdout.splitlines() + if line.strip().startswith("CHAIN ")] + + +@pytest.mark.parametrize("key, where", [ + pytest.param("open=y", "trees", id="as-printed"), + pytest.param("OPEN=Y", "trees", id="in-capitals"), + pytest.param("open=y", "a=b", id="a-path-holding-an-equals"), +]) +def test_the_offer_for_a_link_holding_an_equals_verifies_it( + tmp_path, monkeypatch, key, where): + """#980, THE ISSUE'S ROW. The gate offers `open=y` the source + `--link-source open=y=`, and typed as printed it was read at its + first `=`: `open` names no link, so the whole value was a bare PATH for + `openXdox`, `link-source-unreadable`, and `open=y` stayed + declared-unverified under the same offer, exit 0. Now the longest link + the value names with a path after it is its key, found by + `pin_product_key` as every key is, so the offer verifies `open=y`, and + a path that holds an `=` of its own is still that link's path. The one + WARNING left is for `openXdox`, which no source here names.""" + clear_ambient_pin_sources(monkeypatch) + before = naming(tmp_path, *EQUALS, "codexInk") + printed = offered_source(before, "open=y") + assert printed == "open=y=", before.stderr + linked = tree(tmp_path / where / "linked", "openInk") + typed = printed.replace("open=y", key, 1).replace("", str(linked)) + result = naming(tmp_path, *EQUALS, "--link-source", typed, "codexInk") + assert result.returncode == 0, result.stderr + assert gate_warnings(result) == [UNREACHED.format( + holder="openXdox", held="open=y", + offer=offer(GATE_OFFER, "openXdox"))], result.stderr + assert "link-source-unreadable" not in result.stderr, result.stderr + assert "NOTE" not in result.stderr, result.stderr + + +@pytest.mark.parametrize("open_value, order", [ + pytest.param("open={o}", "open-first", id="open-first"), + pytest.param("open={o}", "open=y-first", id="open=y-first"), + # A path that begins like the longer link's name is given after `./`: + # `open=./y` holds a `/`, so it names no link, and `open` is the key. + pytest.param("open=./y=dir", "open-first", + id="a-path-that-begins-like-the-longer-link"), +]) +def test_each_link_reads_its_own_source_where_one_name_begins_another( + tmp_path, monkeypatch, open_value, order): + """`open` and `open=y` in one chain, each given its own tree. Read at + the first `=`, `open=y=` was a source for `open` with the path + `y=`, so it and `open=` were two values for one link, the + later replacing the earlier, and `open=y` read none: declared-unverified + whichever came first. The longest link the value names is its key, so + each link reads its own tree and the chain verifies, with nothing + warned or noted.""" + clear_ambient_pin_sources(monkeypatch) + work = tmp_path / "work" + trees = {"x": tree(tmp_path / "trees" / "x", "open"), + "o": tree(tmp_path / "trees" / "o", "open=y"), + "y": tree(tmp_path / "trees" / "y", "openInk")} + tree(work / "y=dir", "open=y") + sources = [open_value.format(o=trees["o"]), f"open=y={trees['y']}"] + if order == "open=y-first": + sources.reverse() + result = naming(work, *PREFIX, "--explain", + "--link-source", f"openXdox={trees['x']}", + *(arg for value in sources + for arg in ("--link-source", value)), "codexInk") + assert result.returncode == 0, result.stderr + assert chain_lines(result) == [ + "CHAIN openXdox → open → open=y → openInk [verified]"], ( + result.stdout + result.stderr) + assert "WARNING" not in result.stdout + result.stderr, result.stderr + assert "NOTE" not in result.stderr, result.stderr + + +def test_a_value_that_ends_at_a_link_holding_an_equals_names_no_path( + tmp_path, monkeypatch): + """`open=y=` is `PRODUCT=` for `open=y`, refused by name as + `openXdox=` is (#442), exit 2. Read at its first `=`, it was a bare + PATH, `open=y=`, asked for `openXdox`: a tree for a link the person did + not name.""" + clear_ambient_pin_sources(monkeypatch) + result = naming(tmp_path, *EQUALS, "--link-source", "open=y=", "codexInk") + assert result.returncode == 2, result.stderr + assert result.stderr == ( + "REFUSED link-source-no-path: `--link-source open=y=` names no path " + "for open=y. An empty path would be read as the directory this runs " + "in. Give the tree's path (`--link-source =`), or `.` to " + "mean this directory.\n"), result.stderr + + +def test_a_shorter_link_that_leaves_a_path_is_the_key(tmp_path, monkeypatch): + """In a chain naming `open` too, `open=y=` holds a link that leaves a + path: `open`, with the path `y=`, as `--link-remedy openXdox=y=` gives + `openXdox` the words `y=` where a chain names `openXdox=y`. The longest + link that leaves a path is the key, and `open=y` leaves none.""" + clear_ambient_pin_sources(monkeypatch) + result = naming(tmp_path, *PREFIX, "--link-source", "open=y=", "codexInk") + assert result.returncode == 0, result.stderr + assert ("WARNING link-source-unreadable: y= (--link-source open=...) " + "could not answer for open: no such file or directory\n" + ) in result.stderr, result.stderr + assert "NOTE" not in result.stderr, result.stderr + + +def test_a_link_holding_an_equals_wins_over_a_directory_spelled_like_it( + tmp_path, monkeypatch): + """The documented rule, for a link holding `=`: `open=y=x` beside a + directory `open=y=x`, holding `openXdox`'s tree, and no `x`. `open=y` + is a link of the chain, so the keyed reading wins, decided by the + chain and not by the disk: the missing `x` is named, with the other + reading and how to ask for it. `./open=y=x` asks for it: a bare PATH, + the first link's tree, so `openXdox` verifies and `open=y` keeps its + WARNING.""" + clear_ambient_pin_sources(monkeypatch) + work = tmp_path / "work" + tree(work / "open=y=x", "open=y") + keyed = naming(work, *EQUALS, "--link-source", "open=y=x", "codexInk") + assert keyed.returncode == 0, keyed.stderr + assert ("WARNING link-source-unreadable: x (--link-source open=y=...) " + "could not answer for open=y: no such file or directory\n" + " `open=y=x` is also a directory; `--link-source ./open=y=x` " + "reads it as one.\n") in keyed.stderr, keyed.stderr + whole = naming(work, *EQUALS, "--link-source", "./open=y=x", "codexInk") + assert whole.returncode == 0, whole.stderr + assert gate_warnings(whole) == [UNREACHED.format( + holder="open=y", held="openInk", offer=EQUALS_OFFER)], whole.stderr + assert "link-source-unreadable" not in whole.stderr, whole.stderr + assert "NOTE" not in whole.stderr, whole.stderr + + +@pytest.mark.parametrize("before, key", [ + pytest.param(" ", " open", id="a-blank"), + pytest.param("opensoft/", "opensoft/open", id="an-owner"), +]) +def test_a_key_is_read_whole(tmp_path, monkeypatch, before, key): + """A remedy's link is stripped and read at its last `/`; a key is the + text before its `=` as typed. ` open=y` and `opensoft/open=y` name no + link, so the value is a bare PATH for the first link, as it was, and + the run says how it was read.""" + clear_ambient_pin_sources(monkeypatch) + linked = tree(tmp_path / "trees" / "linked", "openInk") + typed = f"{before}open=y={linked}" + result = naming(tmp_path, *EQUALS, "--link-source", typed, "codexInk") + assert result.returncode == 0, result.stderr + assert (f"WARNING link-source-unreadable: {Path(typed)} (--link-source) " + "could not answer for openXdox: no such file or directory\n" + f" It was read as a PATH: `{key}` is not a link of a referent " + "chain here (links: openXdox, open=y, openInk).\n" + ) in result.stderr, result.stderr + assert UNREACHED.format(holder="open=y", held="openInk", + offer=EQUALS_OFFER) in gate_warnings(result), ( + result.stderr) + + # --- L2-69: every --link-source value and path the gate prints is spelled --- def assert_clean(result) -> None: