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