From 1b1245100cb85bda1bf33c337d248468bf146e74 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Wed, 14 Oct 2026 22:57:06 +0000 Subject: [PATCH 1/3] validate-repository-naming.py, repo_shape.py and validate-pins.py: a bare `--link-source` is the tree of the chain's first link and no other's, so one tree no longer answers for two links and calls a three-link chain that holds broken, and `--link-source openXdox=` or `--pin-source openXdox=` is refused instead of reading the current directory (#441, #442) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #441. `link_pins_from_trees` offered its `default_source` (the naming gate's bare `--link-source`, the scaffold's `--pin-source`) to every link of a recorded chain. In `openXdox → openYdox → openInk` with one bare tree, `openYdox` was read from `openXdox`'s `project.yaml`, which declares `openYdox` and not `openInk`, so `codexInk` got a FINDING, exit 1, for a chain that holds, and the scaffold refused it as `chain-not-declared`. Now the bare value is offered to the first link alone: the pin the project holds, and the one tree a person who typed one path has to hand. A later link is read from its own `LINK=PATH`, `SHAPE_PIN_SOURCE_` or a checkout beside the project, or is declared-unverified. The two-link case reads exactly as before. In the naming gate, `LinkSources` finds the first link of every chain a name will read before any is read, and offers the bare value only while there is one, so it is never counted as the answer for two links; a bare value no link took is a NOTE saying why. `--help`, the module docstring, `resolve_link_source`'s rule 4, the scaffold's `_declaration` docstring and README's lookup order say so; `docs/cli.md` is regenerated, and the handbook footer names the README change and its new blob (the page does not carry that sentence). #442. `openXdox=` with nothing after the `=` was `Path("")`, which is `.`. From a project root the naming gate read the project's own manifest as `openXdox`'s and called the chain broken; `validate-pins.py` asked the project root for the pinned commit and blamed `.`. Both tools now refuse a keyed value with no path, and an empty value, naming the key: `REFUSED link-source-no-path` and `REFUSED pin-source-no-path`, exit 2. A typed `.` still means the current directory. Tests: tests/test_link_source_chain_defaults.py (20), 16 of which fail on bfed143. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 --- README.md | 18 +- docs/cli.md | 20 +- docs/handbook.html | 6 +- scaffold-project.py | 8 +- scripts/repo_shape.py | 19 +- scripts/validate-repository-naming.py | 144 +++++++- .../assembly-root/scripts/validate-pins.py | 16 + tests/test_link_source_chain_defaults.py | 334 ++++++++++++++++++ 8 files changed, 522 insertions(+), 43 deletions(-) create mode 100644 tests/test_link_source_chain_defaults.py diff --git a/README.md b/README.md index f3b378dc..beac7030 100644 --- a/README.md +++ b/README.md @@ -685,15 +685,15 @@ is an addition, and `MedxChart` answers exactly as it did on 2026-09-02. Each link past the first is a declaration in ANOTHER tree — `openXdox` declaring `neutral_product_pins: [openDox]` — and it is read where that tree happens to be on the disk: `--link-source PRODUCT=`, `SHAPE_PIN_SOURCE_`, a -checkout beside the root, then a bare `--link-source `: the same lookup -order `validate-pins.py` uses below for the same checkout. A link no tree -answers for is reported `declared-unverified` — a warning, never a finding, and -the name still classifies — because the recorded declaration is the offline fact -and the tree is the stronger check, available or not. A link that IS readable -and declares something else BREAKS the chain: the residual assembly-root reading -wins, the overlap is recorded, and the message names the link that broke. -`LedgerxWallet` -> `openXwallet` -> `openWallet` is the same shape, and -`openWallet` need not exist anywhere for that to be the answer. +checkout beside the root, then a bare `--link-source `, for the first +link's tree only: the order `validate-pins.py` uses below for the same checkout. +A link no tree answers for is reported `declared-unverified` — a warning, never +a finding, and the name still classifies — because the recorded declaration is +the offline fact and the tree is the stronger check, available or not. A link +that IS readable and declares something else BREAKS the chain: the residual +assembly-root reading wins, the overlap is recorded, and the message names the +link that broke. `LedgerxWallet` -> `openXwallet` -> `openWallet` is the same +shape, and `openWallet` need not exist anywhere for that to be the answer. **`validate-pins.py` rechecks the referent too, not only the declaration.** Each `neutral_product_pins:` entry's `contracts/-pin.yaml` diff --git a/docs/cli.md b/docs/cli.md index c793a3e0..d8c020cd 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -884,14 +884,18 @@ options: 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, which - applies to whichever link has no more specific answer. - Repeatable; a value no link used is noted. - SHAPE_PIN_SOURCE_ and a checkout beside the - project are tried too. A link no source answers for is - declared-unverified, and a source that cannot be read - is named with the system's reason: both are warnings - and not findings, an explicit source included. + 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. A value that names no path (PRODUCT=, or + empty) is refused. Repeatable; a value no link used is + noted. SHAPE_PIN_SOURCE_ and a checkout + beside the project are tried too. A link no source + answers for is declared-unverified, and a source that + cannot be read is named with the system's reason: both + are warnings and not findings, an explicit source + included. --explain print every family each name satisfies, why one of them won, and what the others are recorded as --quiet diff --git a/docs/handbook.html b/docs/handbook.html index 4e01a52b..7e9529a5 100644 --- a/docs/handbook.html +++ b/docs/handbook.html @@ -1886,9 +1886,11 @@

For AI assistants

adoption’s --project as the source’s own name, added (#499); the merged-away verdict, whose row apply drops, added (#502); the line-ending block an adoption appends to a root .gitattributes the source keeps, - and its verification as the source’s bytes plus that block, added (#471) — a page + and its verification as the source’s bytes plus that block, added (#471); a bare + --link-source as the first chain link’s tree alone, a README sentence this + page does not carry, amended there (#441) — a page cannot name the commit that contains it, and the blob below is the fact a test checks. - README.md blob f3b378dc5704de79ee63972d0ea0be14dc693209. Every command, flag, exit code + README.md blob beac703052c6f8a65bb53bbb411a4d65d71b46b3. Every command, flag, exit code and file name on this page is quoted from that README and from AGENTS.md.

diff --git a/scaffold-project.py b/scaffold-project.py index 98bb95c5..27373507 100755 --- a/scaffold-project.py +++ b/scaffold-project.py @@ -781,9 +781,11 @@ def _declaration(args) -> tuple[dict, set, tuple, dict]: `codexDox` pins `openXdox` and reaches `openDox` through it; the chain is a DECLARATION, so it is checked by `_check_declared_names` below and written into the manifest rather than re-derived by every reader. A link is - verified against its own tree when `--pin-source` (or - `SHAPE_PIN_SOURCE_`) points at one, and is declared-unverified - otherwise — a warning, never a refusal, because the check is offline. + verified against its own tree when `SHAPE_PIN_SOURCE_` points + at one, or `--pin-source` for the FIRST link alone (#441: it is the + pinned product's checkout, never a later link's tree), and is + declared-unverified otherwise — a warning, never a refusal, because the + check is offline. A tree `--pin-source` reached whose declaration could not be read (a `neutral_product_pins:` that is not a list of names) is declared-unverified diff --git a/scripts/repo_shape.py b/scripts/repo_shape.py index 6ed49ad2..fef433fb 100644 --- a/scripts/repo_shape.py +++ b/scripts/repo_shape.py @@ -2722,7 +2722,9 @@ def resolve_link_source(product: str, root: Path | None = None, 1. `--link-source =` (case-insensitive on the name) 2. `SHAPE_PIN_SOURCE_` in the environment 3. a checkout sitting BESIDE this project (`../openXdox`) - 4. a bare `--link-source ` with no `product=` prefix + 4. a bare `--link-source ` with no `product=` prefix, which is + asked only when `default_source` is passed: `link_pins_from_trees` + passes it for a chain's FIRST link and for no other (#441) The first that holds a manifest answers, and one that does not is passed over whatever the reason: one that cannot be entered no longer raises @@ -2810,16 +2812,27 @@ def link_pins_from_trees(links, root: Path | None = None, declared-unverified. Only a hand-written chain can hold two such links: `scaffold-project.py` takes every link but the first from the naming policy's `open` form, which has no `-`, `.` or `_` to fold. + + `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 + one tree a person who typed one path has to hand. It used to be offered + to every link, so in a chain of three or more links a later link with + no other source was read from the same tree: `openXdox`'s manifest, + declaring `openYdox`, read again as `openYdox`'s, which "declares + 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. """ found: dict[str, set] = {} unreadable: dict[str, str] = {} links = list(links) shared = pin_source_env_shared(repo_basename(str(link)) for link in links) - for link in links: + for index, link in enumerate(links): name = repo_basename(str(link)) asked: list = [] path, _how = resolve_link_source(name, root, keyed_sources, - default_source, + None if index else default_source, pin_product_key(name) in shared, tried=asked) if tried is not None: diff --git a/scripts/validate-repository-naming.py b/scripts/validate-repository-naming.py index 3cebd4b8..585898e2 100755 --- a/scripts/validate-repository-naming.py +++ b/scripts/validate-repository-naming.py @@ -54,13 +54,25 @@ classifies as a descendant: the check is offline, and a recorded declaration is the fact it reads. +A BARE PATH IS THE FIRST LINK'S TREE AND NO OTHER'S (#441). A `--link-source` +with no `PRODUCT=` is read for the chain's first link, the pin the project +holds, when nothing more specific answers for it, and never for a later link: +one tree cannot be two links' declarations, and reading `openXdox`'s +`project.yaml` again as `openYdox`'s called a chain that holds broken. A later +link needs its own `PRODUCT=PATH`, its variable or a checkout beside the +project. While the chains read in one run begin at more than one link, a bare +PATH is read for none of them. A value that names no path — `PRODUCT=`, or an +empty one — is refused (#442): an empty path is the current directory, and +`.` is how to mean that. + EXIT CODES, the same three in every validator this standard ships: 0 every name classified (and, with `--project`, every declared role and the topic agreed with the classification) 1 a FINDING: at least one name matches no family, or a leg's declared role disagrees with the form of its name 2 a REFUSAL: the question could not be asked at all — the policy file is - missing, unparsable, or not a naming policy + missing, unparsable, or not a naming policy, or a `--link-source` + names no path A chain link whose tree could not be read prints a WARNING and changes no exit code, deliberately: see `--link-source` below. A SOURCE that could not answer @@ -144,9 +156,21 @@ class LinkSources: finding, and none ends the run: a link whose tree could not be read is a warning in this validator, deliberately, and an explicit source is no exception — the module docstring says why. + + ONE VALUE, ONE LINK (#441). A keyed value is asked for its own link + alone. The bare PATH is asked for the FIRST link of a chain a name here + reads (`link_pins_from_trees` offers it to no later link), and only + while every such chain begins at that one link, so it is never counted + as the answer for two. `firsts` are those first links, found before any + chain is read (`_first_links_read`). + + A VALUE THAT NAMES NO PATH IS REFUSED (#442). `openXdox=` is + `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. """ - def __init__(self, values: list[str], links) -> None: + def __init__(self, values: list[str], links, firsts=()) -> None: #: Every link of every chain this run reads, in its first spelling. self.links: list[str] = [] names: dict[str, str] = {} @@ -155,6 +179,14 @@ def __init__(self, values: list[str], links) -> None: if name and name.casefold() not in names: names[name.casefold()] = 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`. + 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.keyed: dict[str, tuple[str, Path]] = {} #: `(typed, path)` of the last bare value, if any. @@ -177,9 +209,14 @@ def __init__(self, values: list[str], links) -> None: self.replaced.pop(typed, None) 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. + 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. self.asked: set[str] = set() - #: The values a link read. + #: The values a link read: each the answer for one link (#441). self.used: set[str] = set() #: `{typed: (path, how, why, [links])}` for a value asked and never #: answering. @@ -201,11 +238,13 @@ def link_pins(self, policy: NamingPolicy, target: Target) -> dict: if not policy.reads_link_declarations(target.name, target.pins, target.chain): return {} + first = repo_basename(str(target.chain[0])) + self.firsts.setdefault(first.casefold(), first) tried: dict = {} found = link_pins_from_trees( target.chain, target.root, {key: path for key, (_typed, path) in self.keyed.items()}, - self.bare[1] if self.bare is not None else None, tried=tried) + self._bare_path(), tried=tried) # ONLY A LINK THAT HOLDS A PIN COUNTS. The lookup asks for every link, # the last one too, but only a holder's own declaration is ever read # (`_chain_link_findings`): a source asked only for the chain's end @@ -221,6 +260,15 @@ def link_pins(self, policy: NamingPolicy, target: Target) -> dict: self._record(link, rule, how, path, why) return found + def _bare_path(self) -> Path | None: + """The bare PATH for `link_pins_from_trees`, which asks it for a + chain's first link alone (#441), or None: none was given, or the + chains read here begin at more than one link, and one tree read as + two first links' would be the defect over again.""" + if self.bare is None or len(self.firsts) > 1: + return None + return self.bare[1] + def _record(self, link: str, rule: str, how: str, path: Path, why: str | None) -> None: """One source a holder's lookup asked. A value typed here is @@ -287,12 +335,45 @@ def unread_lines(self) -> list[str]: f"answer for {', '.join(links)}: {why}") return lines + def no_path_refusal(self) -> str: + """The `REFUSED` line naming every value that names no path (#442), + each by the link it is keyed to, or `""` when there is none.""" + if not self.no_path: + return "" + said = [f"`--link-source {typed}` names no path for " + f"{self.reading[typed]}" if typed + else "`--link-source ''` names no path" + for typed in self.no_path] + return ("REFUSED link-source-no-path: " + "; ".join(said) + + ". An empty path would be read as the directory this runs " + "in. Give the tree's path (`--link-source =`), " + "or `.` to mean this directory.") + + def _not_asked_why(self) -> str: + """Why the bare PATH was never asked, where a chain link's own + declaration was read: it is a chain's first link's tree alone + (#441), and that link had a more specific source, or the chains read + here begin at more than one link. Where only first links are read, + the sentence is the one it always was.""" + if len(self.firsts) > 1: + return ("a bare PATH is one first link's tree, and the chains " + "read here begin at " + ", ".join(self.firsts.values()) + + ": name the link it is, as --link-source =PATH") + sources = ("a more specific source (a keyed --link-source, " + "SHAPE_PIN_SOURCE_, or a checkout beside the " + "project)") + if self.asked <= set(self.firsts): + return f"each link whose own declaration is read had {sources}" + return ("a bare PATH is the tree of a chain's first link alone, " + f"{', '.join(self.firsts.values())}, which had {sources}") + def unused_lines(self) -> list[str]: """A NOTE for each value no lookup asked, named as typed, with why: a later value replaced it, no name reads its link's declaration, no - name records a chain, none reads a link's declaration, or every link - that is read had a more specific source. A value holding `=` that - was read as a PATH says so too.""" + name records a chain, none reads a link's declaration, or the one + link a bare PATH answers for had a more specific source + (`_not_asked_why`). A value holding `=` that was read as a PATH says + so too.""" lines = [] for typed, link in self.reading.items(): if typed in self.used or typed in self.unread: @@ -308,10 +389,7 @@ def unused_lines(self) -> list[str]: why = ("no name classified here reads a chain link's own " "declaration") else: - why = ("each link whose own declaration is read had a more " - "specific source (a keyed --link-source, " - "SHAPE_PIN_SOURCE_, or a checkout beside the " - "project)") + why = self._not_asked_why() lines.append(" ".join(filter(None, ( f"--link-source {typed}: not used — {why}.", self.read_as_path(typed))))) @@ -580,6 +658,14 @@ def _project_manifest(path: Path) -> dict: return data +def _project_leg_chains(data: dict) -> list[tuple[str, tuple]]: + """`(name, the chain it records)` for each leg `_leg_target` makes a + target of, and no other.""" + return [(repo_basename(str(leg["repository"])), _chain_from_leg(leg)) + for leg in data.get("legs") or [] + if isinstance(leg, dict) and "repository" in leg] + + def _project_chain_links(data: dict) -> list[str]: """Every link every leg's `naming.referent_chain:` records. @@ -588,9 +674,24 @@ def _project_chain_links(data: dict) -> list[str]: name (#338), and the first leg is classified with the sources already read. The same legs `_leg_target` makes targets of, and no others. """ - return [link for leg in data.get("legs") or [] - if isinstance(leg, dict) and "repository" in leg - for link in _chain_from_leg(leg)] + return [link for _name, chain in _project_leg_chains(data) + for link in chain] + + +def _first_links_read(policy: NamingPolicy, targets: list, + data: dict | None) -> list[str]: + """The first link of every chain a name here READS, found before any is + read (#441): a bare `--link-source` is that link's tree, and only while + the chains begin at one link. The names on the command line and the legs + `_leg_target` makes targets of; names on stdin come only when there are + neither, all with `--referent-chain`, so they cannot add a second.""" + readers = [(target.name, target.pins, target.chain) for target in targets] + if data is not None: + pins = _pins_from_project(data) + readers.extend((name, pins, chain) + for name, chain in _project_leg_chains(data)) + return [chain[0] for name, pins, chain in readers + if policy.reads_link_declarations(name, pins, chain)] def _targets_from_project(policy: NamingPolicy, path: Path, data: dict, @@ -751,8 +852,12 @@ def main(argv: list[str] | None = None) -> int: "--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, which applies to whichever link " - "has no more specific answer. Repeatable; a " + "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. A value that names no path " + "(PRODUCT=, or empty) is refused. Repeatable; a " "value no link used is noted. " "SHAPE_PIN_SOURCE_ and a checkout " "beside the project are tried too. A link no " @@ -788,10 +893,13 @@ def main(argv: list[str] | None = None) -> int: print(str(exc), file=sys.stderr) return 2 # The `--link-source` values are READ here, once every chain this run - # reads is known and before any name is classified (#338). + # reads is known and before any name is classified (#338, #441). sources = LinkSources(args.link_source, [ *cli_chain, *(_project_chain_links(data) if data is not None - else ())]) + else ())], _first_links_read(policy, targets, data)) + if sources.no_path: + print(sources.no_path_refusal(), file=sys.stderr) + return 2 if data is not None: extra, findings = _targets_from_project(policy, args.project, data, sources) diff --git a/templates/assembly-root/scripts/validate-pins.py b/templates/assembly-root/scripts/validate-pins.py index d387fcf1..85a90f66 100755 --- a/templates/assembly-root/scripts/validate-pins.py +++ b/templates/assembly-root/scripts/validate-pins.py @@ -668,6 +668,11 @@ class PinSources: later one replaced — is said once the pins are checked, by `_note_unused_pin_sources`. Saying nothing is how a mistyped key read as a pass that never looked. + + A VALUE THAT NAMES NO PATH IS REFUSED (#442), `pin-source-no-path`: + `openGlass=` with nothing after its `=`, or an empty value. Either was + `Path("")`, which is `.`, so the directory this ran in was asked for the + pinned commit and the finding blamed `.`. `.` typed still means it. """ def __init__(self, values: list[str], declared) -> None: @@ -698,6 +703,17 @@ def __init__(self, values: list[str], declared) -> None: self.replaced.pop(typed, None) if earlier is not None and earlier[0] != typed: self.replaced[earlier[0]] = typed + no_path = [f"`--pin-source {typed}` names no path for {product}" + if typed else "`--pin-source ''` names no path" + for typed, product in self.reading.items() + if not (typed.partition("=")[2] if product else typed)] + if no_path: + raise Refusal( + "pin-source-no-path", + "; ".join(no_path) + ". An empty path would be read as the " + "directory this runs in.", + "Remediation: give the checkout's path (`--pin-source " + "=`), or `.` to mean this directory.") def read_as_path(self, typed: str | None) -> str: """The sentence saying a value holding `=` was read as a bare PATH, diff --git a/tests/test_link_source_chain_defaults.py b/tests/test_link_source_chain_defaults.py new file mode 100644 index 00000000..4a574fe8 --- /dev/null +++ b/tests/test_link_source_chain_defaults.py @@ -0,0 +1,334 @@ +# SPDX-License-Identifier: Apache-2.0 +"""A bare `--link-source PATH` is the FIRST chain link's tree and no other's +(#441), and a source that names no path is refused (#442). + +#441. `link_pins_from_trees` asked the one bare value for every link no more +specific source answered for. In the chain `openXdox → openYdox → openInk`, +with one bare tree that is `openXdox`'s, `openYdox` was read from that same +`project.yaml`, which declares `openYdox` and not `openInk`: "openYdox +declares openYdox, not openInk", and for `codexInk`, a name with no direct pin +on its referent, a FINDING, exit 1, for a chain that holds. The scaffold +reads its `--pin-source` the same way, so it refused such a chain as +`chain-not-declared`. Now the bare value is asked for the first link alone, a +later link is read from its own source or is declared-unverified, and a run +whose chains begin at two links reads a bare value for neither. + +#442. `openXdox=` with nothing after the `=` was `Path("")`, which is `.`: +from a project root the naming gate read the project's own manifest as +`openXdox`'s, and `validate-pins.py` asked `.` for the pinned commit. Both +tools now refuse it, and an empty value, by name, exit 2. + +Every run is made from a directory of its own under `tmp_path`, and every +manifest is written as bytes, so no checkout beside it answers and no line +ending differs between systems. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +from conftest import (REPO, SCAFFOLD, clear_ambient_pin_sources, git, + run_script) + +sys.path.insert(0, str(REPO / "scripts")) +from repo_shape import link_pins_from_trees # noqa: E402 + +VALIDATOR = REPO / "scripts" / "validate-repository-naming.py" +UNVERIFIED = ("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, SHAPE_PIN_SOURCE_{var}, or " + "--link-source {holder}= would let it be read). The " + "recorded chain still classifies.\n") +MORE_SPECIFIC = ("a more specific source (a keyed --link-source, " + "SHAPE_PIN_SOURCE_, or a checkout beside the " + "project)") + + +def unverified(name: str, holder: str, held: str) -> str: + """The WARNING the naming gate prints for a link nothing answered for.""" + return UNVERIFIED.format(name=name, holder=holder, held=held, + var=holder.upper()) + + +def manifest(path: Path, body: str) -> Path: + """`path/project.yaml` holding `body`, written as bytes.""" + path.mkdir(parents=True, exist_ok=True) + (path / "project.yaml").write_bytes(body.encode("utf-8")) + return path + + +def tree(path: Path, *pins: str) -> Path: + """A stand-in for a chain link's checkout, declaring `pins`.""" + return manifest(path, "neutral_product_pins:\n" + + "".join(f" - {pin}\n" for pin in pins)) + + +def project(path: Path, pin: str, chain: str) -> Path: + """A project whose assembly leg, named for `path`, pins `pin` and + records `chain`.""" + return manifest(path, ( + f"neutral_product_pins:\n - {pin}\nlegs:\n" + f" - repository: testorg/{path.name}\n path: \".\"\n" + f" role: assembly\n naming:\n referent_chain: [{chain}]\n")) + + +def naming(cwd: Path, *args: str): + return run_script(VALIDATOR, *args, cwd=cwd) + + +def scaffold(cwd: Path, name: str, source: Path, *args: str): + """`scaffold-project.py` for `codexSoft/`, pinning `openXdox` at a + commit of `source`, which is made a repository here and is the + `--pin-source`.""" + git("init", "-q", "-b", "main", cwd=source) + git("add", "-A", cwd=source) + git("commit", "-qm", "seed", 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"openXdox@{commit}", + "--pin-source", str(source), *args, cwd=cwd) + + +@pytest.fixture +def ink(tmp_path) -> dict: + """#441's own layout: `codexInk` pinning `openXdox`, recording the chain + `openXdox → openYdox → openInk`, and each link's tree away from it.""" + return { + "root": project(tmp_path / "ws" / "codexInk", "openXdox", + "openXdox, openYdox, openInk"), + "x": tree(tmp_path / "trees" / "openXdox", "openYdox"), + "y": tree(tmp_path / "trees" / "openYdox", "openInk"), + } + + +# --- #441: the naming gate ------------------------------------------------- + +def test_one_bare_tree_answers_for_the_first_link_of_a_long_chain(ink): + """THE ISSUE'S FIRST ROW. The bare tree is `openXdox`'s and verifies + `openXdox` alone; `openYdox` is declared-unverified, and the WARNING + names the keyed value that would read it. It was a FINDING, the chain + "broken" by a tree nobody gave for `openYdox`, exit 1.""" + result = naming(ink["root"], "--project", "project.yaml", + "--link-source", str(ink["x"])) + assert result.returncode == 0, result.stderr + assert "codexInk domain-descendant/assembly" \ + in result.stdout + assert result.stderr == unverified("codexInk", "openYdox", "openInk") + + +def test_each_link_keyed_still_verifies_the_whole_chain(ink): + """The issue's second row, unchanged: every link has its own tree.""" + result = naming(ink["root"], "--project", "project.yaml", + "--link-source", f"openXdox={ink['x']}", + "--link-source", f"openYdox={ink['y']}") + assert result.returncode == 0, result.stderr + assert result.stderr == "" + assert "domain-descendant/assembly" in result.stdout + + +def test_a_bare_tree_is_never_read_for_a_later_link(ink): + """`openXdox` keyed and `openYdox`'s tree given bare: the bare value + used to answer `openYdox`. It is the first link's tree alone, so + `openYdox` is declared-unverified and the value is a NOTE saying why, + never counted as the answer for a link it was not asked for.""" + result = naming(ink["root"], "--project", "project.yaml", + "--link-source", f"openXdox={ink['x']}", + "--link-source", str(ink["y"])) + assert result.returncode == 0, result.stderr + assert result.stderr == ( + unverified("codexInk", "openYdox", "openInk") + + f"NOTE --link-source {ink['y']}: not used — a bare PATH is the " + "tree of a chain's first link alone, openXdox, which had " + f"{MORE_SPECIFIC}.\n") + + +def test_names_read_from_stdin_note_the_first_link_they_read(ink): + """Names on stdin come only when no other name does, so their chain's + first link is found as it is read: the NOTE names it as it does for a + name on the command line.""" + result = run_script(VALIDATOR, "--role", "assembly", "--pins", "openXdox", + "--referent-chain", "openXdox,openYdox,openInk", + "--link-source", f"openXdox={ink['x']}", + "--link-source", str(ink["y"]), + cwd=ink["root"], input="codexInk\n") + assert result.returncode == 0, result.stderr + assert result.stderr.endswith( + f"NOTE --link-source {ink['y']}: not used — a bare PATH is the " + "tree of a chain's first link alone, openXdox, which had " + f"{MORE_SPECIFIC}.\n"), result.stderr + + +def test_chains_that_begin_at_two_links_read_a_bare_tree_for_neither( + tmp_path): + """A name on the command line reads `openXdox → openInk` and the + project's leg reads `openYdox → openQuill`. One tree cannot be both + first links': it answered both, and `codexQuill`'s chain was broken by + a tree that declares `openInk`. Now neither reads it, and it is noted.""" + root = project(tmp_path / "ws" / "codexQuill", "openYdox", + "openYdox, openQuill") + bare = tree(tmp_path / "trees" / "openXdox", "openInk") + result = naming(root, "--role", "assembly", "--pins", "openXdox", + "--referent-chain", "openXdox,openInk", + "--project", "project.yaml", "--link-source", str(bare), + "codexInk") + assert result.returncode == 0, result.stderr + assert result.stderr == ( + unverified("codexInk", "openXdox", "openInk") + + unverified("codexQuill", "openYdox", "openQuill") + + f"NOTE --link-source {bare}: not used — a bare PATH is one first " + "link's tree, and the chains read here begin at openXdox, openYdox: " + "name the link it is, as --link-source =PATH.\n") + + +# --- #441: the lookup itself, and the scaffold ------------------------------- + +@pytest.mark.parametrize("chain", [ + ("openXdox",), ("openXdox", "openYdox"), + ("openXdox", "openYdox", "openInk")], ids=["one", "two", "three"]) +def test_the_lookup_asks_the_bare_source_for_the_first_link_alone( + tmp_path, monkeypatch, chain): + """In this process: a chain of any length reads `default_source` for its + first link, and asks it for no later one.""" + clear_ambient_pin_sources(monkeypatch) + source = tree(tmp_path / "trees" / "openXdox", "openYdox") + tried: dict = {} + found = link_pins_from_trees(chain, tmp_path / "ws" / "codexInk", + default_source=source, tried=tried) + assert found == {"openxdox": {"openYdox"}} + assert [rule for rule, *_ in tried["openXdox"]] == ["sibling", "bare"] + assert {link: [rule for rule, *_ in tried[link]] + for link in chain[1:]} == {link: ["sibling"] for link in chain[1:]} + + +def test_the_scaffold_reads_its_pin_source_for_the_first_link_alone( + tmp_path): + """`--pin-source` is the pinned `openXdox`'s checkout. It was read as + `openYdox`'s too, and the scaffold refused `chain-not-declared`; now + `openYdox` is declared-unverified, a warning, and the dry run plans.""" + result = scaffold(tmp_path, "codexInk", + tree(tmp_path / "openXdox", "openYdox"), + "--referent-chain", "openXdox,openYdox,openInk", + "--dry-run", + "--local-remote-dir", str(tmp_path / "remotes")) + assert result.returncode == 0, result.stderr + result.stdout + assert "chain-not-declared" not in result.stderr + assert ("WARNING declared-unverified: openYdox declaring a pin on openInk" + in result.stderr) + assert "--dry-run: nothing was created." in result.stdout + + +# --- #442: the naming gate --------------------------------------------------- + +@pytest.fixture +def two(tmp_path) -> dict: + """#442's own layout: `codexInk` recording `openXdox → openInk`.""" + return {"root": project(tmp_path / "ws" / "codexInk", "openXdox", + "openXdox, openInk"), + "x": tree(tmp_path / "trees" / "openXdox", "openInk"), + "elsewhere": tmp_path / "trees"} + + +@pytest.mark.parametrize("value,said", [ + pytest.param("openXdox=", "`--link-source openXdox=` names no path for " + "openXdox", id="keyed"), + pytest.param("OPENXDOX=", "`--link-source OPENXDOX=` names no path for " + "openXdox", id="keyed-in-capitals"), + pytest.param("", "`--link-source ''` names no path", id="bare"), +]) +@pytest.mark.parametrize("where", ["project-root", "elsewhere"]) +def test_a_link_source_that_names_no_path_is_refused(two, value, said, + where): + """From the project root it read the project's own manifest as + `openXdox`'s and called the chain broken, exit 1; anywhere else it was a + warning about `.`. Both are now one refusal, before anything is + classified, naming the link.""" + cwd, manifest_arg = ((two["root"], "project.yaml") if where == + "project-root" else + (two["elsewhere"], str(two["root"] / "project.yaml"))) + result = naming(cwd, "--project", manifest_arg, "--link-source", value) + assert result.returncode == 2, result.stderr + assert result.stderr == ( + f"REFUSED link-source-no-path: {said}. 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") + assert result.stdout == "" + + +def test_a_path_typed_after_the_equals_sign_is_still_read(two): + """The control, and the edges of the refusal: the real tree verifies; a + typed `.` is still the current directory; and `openZdox=` keys no link + of this chain, so it is the bare PATH `openZdox=`, which is not empty.""" + keyed = naming(two["root"], "--project", "project.yaml", + "--link-source", f"openXdox={two['x']}") + assert keyed.returncode == 0, keyed.stderr + assert keyed.stderr == "" + dot = naming(two["elsewhere"], "--project", + str(two["root"] / "project.yaml"), + "--link-source", "openXdox=.") + assert dot.returncode == 0, dot.stderr + assert ("WARNING link-source-unreadable: . (--link-source openXdox=...) " + "could not answer for openXdox: it holds no project.yaml\n" + in dot.stderr), dot.stderr + other = naming(two["root"], "--project", "project.yaml", + "--link-source", "openZdox=") + assert other.returncode == 0, other.stderr + assert "REFUSED" not in other.stderr + assert ("It was read as a PATH: `openZdox` is not a link of a referent " + "chain here" in other.stderr), other.stderr + + +# --- #442: validate-pins.py -------------------------------------------------- + +@pytest.fixture(scope="module") +def pinned(tmp_path_factory) -> dict: + """One `codexDox` scaffolded with a declared pin on `openXdox`, and a + clone of the product away from it.""" + base = tmp_path_factory.mktemp("link441-pins") + product = tree(base / "src" / "openXdox", "openDox") + result = scaffold(base, "codexDox", product, + "--local-remote-dir", str(base / "remotes"), + "--work-dir", str(base / "work")) + assert result.returncode == 0, result.stderr + result.stdout + return {"root": base / "work" / "codexDox", "product": product} + + +def validate_pins(pinned: dict, *args: str): + root = pinned["root"] + return run_script(root / "scripts" / "validate-pins.py", *args, cwd=root) + + +@pytest.mark.parametrize("value,said", [ + pytest.param("openXdox=", "`--pin-source openXdox=` names no path for " + "openXdox", id="keyed"), + pytest.param("OPENXDOX=", "`--pin-source OPENXDOX=` names no path for " + "openXdox", id="keyed-in-capitals"), + pytest.param("", "`--pin-source ''` names no path", id="bare"), +]) +def test_a_pin_source_that_names_no_path_is_refused(pinned, value, said): + """It asked the project root for the pinned commit, and the + `neutral-pin-source-unreadable` FINDING named `.`. Now it is refused, by + the pin it is keyed to, with its own remedy and not the lockstep one.""" + result = validate_pins(pinned, "--pin-source", value) + assert result.returncode == 2, result.stderr + result.stdout + assert result.stderr == ( + f"REFUSED pin-source-no-path: {said}. An empty path would be read as " + "the directory this runs in.\nRemediation: give the checkout's path " + "(`--pin-source =`), or `.` to mean this directory.\n") + assert result.stdout == "" + + +def test_a_pin_source_path_after_the_equals_sign_is_still_read(pinned): + """The control: the product's clone recomputes the digest, and a typed + `.` is still the project root, asked and named.""" + keyed = validate_pins(pinned, "--pin-source", + f"openXdox={pinned['product']}") + assert keyed.returncode == 0, keyed.stderr + keyed.stdout + assert "pins ok" in keyed.stdout + dot = validate_pins(pinned, "--pin-source", "openXdox=.") + assert dot.returncode == 1, dot.stderr + dot.stdout + assert ("FINDING neutral-pin-source-unreadable: contracts/openxdox-pin.yaml" + ": . (--pin-source openXdox=...) could not answer" in dot.stderr) From 31a6f2142ceb5dbb785b6094b6a535ab042457b2 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 15 Oct 2026 11:00:42 +0000 Subject: [PATCH 2/3] validate-repository-naming.py: a chain of one link is no first link read, so a bare `--link-source` still verifies the chain beside it, and `--link-source`'s help says a bare PATH is read for neither link when the chains begin at two (#441) A chain of one link (`--referent-chain openInk`) has no holder and reads no declaration, but `_first_links_read` and `LinkSources.link_pins` counted its only link as a first link. A run that also read `openXdox -> openQuill` then had two first links, the bare `--link-source ` went to neither, and `codexQuill`, which bfed143 verified through it, was declared-unverified with a NOTE that named `openInk` as a first link. Both places now count `chain[0]` only when the chain has more than one link. Two tests: that run, which is verified with no WARNING and no NOTE; and one project whose two legs begin at `openXdox` and `openYdox`, which gets the two WARNINGs and the two-first-links NOTE. The second guards the project half of `_first_links_read`: without it, the first classification pass counted the bare value used while the pass that prints said it was unverified, and the NOTE went missing. `--link-source`'s help now says a bare PATH is read for neither link when the chains read begin at two different links, and that a later link is read from its own PRODUCT=PATH, its SHAPE_PIN_SOURCE_ or a checkout beside the project. docs/cli.md is regenerated. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Sonnet 5.5 --- docs/cli.md | 18 ++++--- scripts/validate-repository-naming.py | 35 ++++++++----- tests/test_link_source_chain_defaults.py | 63 ++++++++++++++++++++++-- 3 files changed, 94 insertions(+), 22 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index d8c020cd..d476ba4e 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -888,14 +888,18 @@ options: 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. A value that names no path (PRODUCT=, or + 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. SHAPE_PIN_SOURCE_ and a checkout - beside the project are tried too. A link no source - answers for is declared-unverified, and a source that - cannot be read is named with the system's reason: both - are warnings and not findings, an explicit source - included. + noted. Every link is tried against PRODUCT=PATH, + SHAPE_PIN_SOURCE_ and a checkout beside the + project, in that order, and a bare PATH last, for the + first link alone. A link no source answers for is + declared-unverified, and a source that cannot be read + is named with the system's reason: both are warnings + and not findings, an explicit source included. --explain print every family each name satisfies, why one of them won, and what the others are recorded as --quiet diff --git a/scripts/validate-repository-naming.py b/scripts/validate-repository-naming.py index 585898e2..36c5089a 100755 --- a/scripts/validate-repository-naming.py +++ b/scripts/validate-repository-naming.py @@ -162,7 +162,8 @@ class LinkSources: reads (`link_pins_from_trees` offers it to no later link), and only while every such chain begins at that one link, so it is never counted as the answer for two. `firsts` are those first links, found before any - chain is read (`_first_links_read`). + chain is read (`_first_links_read`). A chain of one link has no holder, + reads no declaration, and so adds none. A VALUE THAT NAMES NO PATH IS REFUSED (#442). `openXdox=` is `Path("")`, which is `.`: run from a project root it read the project's @@ -238,8 +239,9 @@ def link_pins(self, policy: NamingPolicy, target: Target) -> dict: if not policy.reads_link_declarations(target.name, target.pins, target.chain): return {} - first = repo_basename(str(target.chain[0])) - self.firsts.setdefault(first.casefold(), first) + if len(target.chain) > 1: + first = repo_basename(str(target.chain[0])) + self.firsts.setdefault(first.casefold(), first) tried: dict = {} found = link_pins_from_trees( target.chain, target.root, @@ -682,16 +684,19 @@ def _first_links_read(policy: NamingPolicy, targets: list, data: dict | None) -> list[str]: """The first link of every chain a name here READS, found before any is read (#441): a bare `--link-source` is that link's tree, and only while - the chains begin at one link. The names on the command line and the legs - `_leg_target` makes targets of; names on stdin come only when there are - neither, all with `--referent-chain`, so they cannot add a second.""" + the chains begin at one link. A chain of one link has no holder, reads + no declaration, and so has no first link to count. The names on the + command line and the legs `_leg_target` makes targets of; names on stdin + come only when there are neither, all with `--referent-chain`, so they + cannot add a second.""" readers = [(target.name, target.pins, target.chain) for target in targets] if data is not None: pins = _pins_from_project(data) readers.extend((name, pins, chain) for name, chain in _project_leg_chains(data)) return [chain[0] for name, pins, chain in readers - if policy.reads_link_declarations(name, pins, chain)] + if len(chain) > 1 + and policy.reads_link_declarations(name, pins, chain)] def _targets_from_project(policy: NamingPolicy, path: Path, data: dict, @@ -856,11 +861,17 @@ def main(argv: list[str] | None = None) -> int: "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. A value that names no path " - "(PRODUCT=, or empty) is refused. Repeatable; a " - "value no link used is noted. " - "SHAPE_PIN_SOURCE_ and a checkout " - "beside the project are tried too. A link no " + "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 project, " + "in that order, and a bare PATH last, for the " + "first link alone. A link no " "source answers for is declared-unverified, and " "a source that cannot be read is named with the " "system's reason: both are warnings and not " diff --git a/tests/test_link_source_chain_defaults.py b/tests/test_link_source_chain_defaults.py index 4a574fe8..8af5efb0 100644 --- a/tests/test_link_source_chain_defaults.py +++ b/tests/test_link_source_chain_defaults.py @@ -75,6 +75,28 @@ def project(path: Path, pin: str, chain: str) -> Path: f" role: assembly\n naming:\n referent_chain: [{chain}]\n")) +def two_legs(path: Path) -> Path: + """A project pinning `openXdox` and `openYdox`, whose legs record + `openXdox → openInk` (`codexInk`) and `openYdox → openQuill` + (`codexQuill`): its chains begin at two links.""" + return manifest(path, ( + "neutral_product_pins:\n - openXdox\n - openYdox\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", "openXdox, openInk"), + ("codexQuill", "openYdox, openQuill"))))) + + +def two_firsts_note(bare: Path, first: str, second: str) -> str: + """The NOTE for a bare value that chains beginning at two links left + unread.""" + return (f"NOTE --link-source {bare}: not used — a bare PATH is one first " + f"link's tree, and the chains read here begin at {first}, " + f"{second}: name the link it is, as --link-source =PATH.\n") + + def naming(cwd: Path, *args: str): return run_script(VALIDATOR, *args, cwd=cwd) @@ -179,9 +201,44 @@ def test_chains_that_begin_at_two_links_read_a_bare_tree_for_neither( assert result.stderr == ( unverified("codexInk", "openXdox", "openInk") + unverified("codexQuill", "openYdox", "openQuill") - + f"NOTE --link-source {bare}: not used — a bare PATH is one first " - "link's tree, and the chains read here begin at openXdox, openYdox: " - "name the link it is, as --link-source =PATH.\n") + + two_firsts_note(bare, "openXdox", "openYdox")) + + +def test_a_project_whose_legs_begin_at_two_links_reads_a_bare_tree_for_neither( + tmp_path): + """One `--project`, two legs: `codexInk` records `openXdox → openInk` and + `codexQuill` records `openYdox → openQuill`, and the bare tree is + `openXdox`'s. It is found before either leg is classified that the legs + begin at two links. Were it not, the first classification pass would read + the bare tree for `openXdox` and count it used, while the pass that + prints says `openXdox` is unverified: two WARNINGs and no NOTE.""" + root = two_legs(tmp_path / "ws" / "codexSoft") + bare = tree(tmp_path / "trees" / "openXdox", "openInk") + result = naming(root, "--project", "project.yaml", + "--link-source", str(bare)) + assert result.returncode == 0, result.stderr + assert result.stderr == ( + unverified("codexInk", "openXdox", "openInk") + + unverified("codexQuill", "openYdox", "openQuill") + + two_firsts_note(bare, "openXdox", "openYdox")) + + +def test_a_chain_of_one_link_is_no_first_link_read(tmp_path): + """`codexInk` records the chain `openInk`: one link, so no holder, so no + declaration read, so no first link. Beside `codexQuill`'s + `openXdox → openQuill`, the chains read begin at `openXdox` alone, and + the bare tree, which is `openXdox`'s, verifies it: no WARNING, no NOTE. + A one-link chain was counted as a second first link, the bare tree went + to neither, and `codexQuill` was declared-unverified.""" + root = project(tmp_path / "ws" / "codexQuill", "openXdox", + "openXdox, openQuill") + bare = tree(tmp_path / "trees" / "openXdox", "openQuill") + result = naming(root, "--role", "assembly", "--pins", "openInk", + "--referent-chain", "openInk", "--project", + "project.yaml", "--link-source", str(bare), "codexInk") + assert result.returncode == 0, result.stderr + assert result.stderr == "" + assert "codexQuill" in result.stdout # --- #441: the lookup itself, and the scaffold ------------------------------- From a707d4ba3f54baa8043a4bd700645f55b91bc2cb Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 15 Oct 2026 11:33:26 +0000 Subject: [PATCH 3/3] tests/test_link_source_chain_defaults.py: a two-link chain read from stdin with its first link keyed and a bare value unused is held at the one-link boundary of `link_pins`, so counting only chains of three links or more no longer passes the tests (#441) `LinkSources.link_pins` counts a chain's first link only when the chain has more than one link. Changing that `> 1` to `> 2` passed the whole new file and tests/test_naming_policy.py, yet it changes the output for a name read from stdin whose chain has TWO links: the first-link list is empty, and the NOTE for an unused bare value reads `a bare PATH is the tree of a chain's first link alone, , which had a more specific source`. New test `test_a_two_link_chain_read_from_stdin_counts_its_first_link`: stdin `codexInk`, `--referent-chain openXdox,openInk`, `openXdox=` keyed and a second tree bare. It asserts the whole stderr, the one NOTE saying each link whose own declaration is read had a more specific source. The existing three-link stdin test and the new one share a helper, `stdin_naming`. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Sonnet 5.5 --- tests/test_link_source_chain_defaults.py | 36 ++++++++++++++++++++---- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/tests/test_link_source_chain_defaults.py b/tests/test_link_source_chain_defaults.py index 8af5efb0..d214e654 100644 --- a/tests/test_link_source_chain_defaults.py +++ b/tests/test_link_source_chain_defaults.py @@ -101,6 +101,16 @@ def naming(cwd: Path, *args: str): return run_script(VALIDATOR, *args, cwd=cwd) +def stdin_naming(cwd: Path, chain: str, *sources: str): + """`codexInk`, read from stdin with `--referent-chain `, and each + of `sources` as a `--link-source`.""" + return run_script(VALIDATOR, "--role", "assembly", "--pins", "openXdox", + "--referent-chain", chain, + *(arg for source in sources + for arg in ("--link-source", source)), + cwd=cwd, input="codexInk\n") + + def scaffold(cwd: Path, name: str, source: Path, *args: str): """`scaffold-project.py` for `codexSoft/`, pinning `openXdox` at a commit of `source`, which is made a repository here and is the @@ -172,11 +182,8 @@ def test_names_read_from_stdin_note_the_first_link_they_read(ink): """Names on stdin come only when no other name does, so their chain's first link is found as it is read: the NOTE names it as it does for a name on the command line.""" - result = run_script(VALIDATOR, "--role", "assembly", "--pins", "openXdox", - "--referent-chain", "openXdox,openYdox,openInk", - "--link-source", f"openXdox={ink['x']}", - "--link-source", str(ink["y"]), - cwd=ink["root"], input="codexInk\n") + result = stdin_naming(ink["root"], "openXdox,openYdox,openInk", + f"openXdox={ink['x']}", str(ink["y"])) assert result.returncode == 0, result.stderr assert result.stderr.endswith( f"NOTE --link-source {ink['y']}: not used — a bare PATH is the " @@ -184,6 +191,25 @@ def test_names_read_from_stdin_note_the_first_link_they_read(ink): f"{MORE_SPECIFIC}.\n"), result.stderr +def test_a_two_link_chain_read_from_stdin_counts_its_first_link(tmp_path): + """A chain of TWO links is a first link read: `openXdox` is keyed, so the + bare tree goes unused, and the NOTE says each link whose declaration is + read had a more specific source. Were a two-link chain not counted (the + one-link rule taken one link too far), the first-link list would be empty + and the sentence would name no link: `first link alone, , which had`.""" + work = tmp_path / "ws" + work.mkdir() + bare = tree(tmp_path / "trees" / "other", "openInk") + result = stdin_naming( + work, "openXdox,openInk", + f"openXdox={tree(tmp_path / 'trees' / 'openXdox', 'openInk')}", + str(bare)) + assert result.returncode == 0, result.stderr + assert result.stderr == ( + f"NOTE --link-source {bare}: not used — each link whose own " + f"declaration is read had {MORE_SPECIFIC}.\n") + + def test_chains_that_begin_at_two_links_read_a_bare_tree_for_neither( tmp_path): """A name on the command line reads `openXdox → openInk` and the