diff --git a/README.md b/README.md index 31e784dc..36453f70 100644 --- a/README.md +++ b/README.md @@ -686,15 +686,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 bf6cf366..2bc93189 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -904,11 +904,19 @@ 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. + directory); any other value is a bare PATH: the tree + of the chain's FIRST link (the pin the project holds), + read when that link has no more specific answer, and + never a later link's, which needs its own + PRODUCT=PATH, its SHAPE_PIN_SOURCE_ or a + checkout beside the project. When the chains read here + begin at two different links, a bare PATH is read for + neither. A value that names no path (PRODUCT=, or + empty) is refused. Repeatable; a value no link used is + noted. Every link is tried against PRODUCT=PATH, SHAPE_PIN_SOURCE_ and a checkout beside the - project are tried too. A link no source answers for is + 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. diff --git a/docs/handbook.html b/docs/handbook.html index a41dfb6d..a1add8af 100644 --- a/docs/handbook.html +++ b/docs/handbook.html @@ -474,7 +474,7 @@ - + @@ -1064,7 +1064,7 @@

If it fails part-way

- +

Setup

Reading private legs in CI

@@ -1122,7 +1122,7 @@

Reading private legs in CI

- +

Reference

The three legs, and naming

diff --git a/scaffold-project.py b/scaffold-project.py index 6458429f..108d4df5 100755 --- a/scaffold-project.py +++ b/scaffold-project.py @@ -885,9 +885,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..36c5089a 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,22 @@ 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 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 + 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 +180,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 +210,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 +239,14 @@ def link_pins(self, policy: NamingPolicy, target: Target) -> dict: if not policy.reads_link_declarations(target.name, target.pins, target.chain): return {} + 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, {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 +262,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 +337,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 +391,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 +660,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 +676,27 @@ 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. 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 len(chain) > 1 + and policy.reads_link_declarations(name, pins, chain)] def _targets_from_project(policy: NamingPolicy, path: Path, data: dict, @@ -751,11 +857,21 @@ 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 " - "value no link used is noted. " - "SHAPE_PIN_SOURCE_ and a checkout " - "beside the project are tried too. A link no " + "is a bare PATH: the tree of the chain's FIRST " + "link (the pin the project holds), read when " + "that link has no more specific answer, and " + "never a later link's, which needs its own " + "PRODUCT=PATH, its SHAPE_PIN_SOURCE_ " + "or a checkout beside the project. When the " + "chains read here begin at two different " + "links, a bare PATH is read for neither. A " + "value that names no path (PRODUCT=, or " + "empty) is refused. Repeatable; a value no " + "link used is noted. Every link is tried " + "against PRODUCT=PATH, SHAPE_PIN_SOURCE_" + " and a checkout beside the 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 " @@ -788,10 +904,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 f5d4eb36..ce2c2c68 100755 --- a/templates/assembly-root/scripts/validate-pins.py +++ b/templates/assembly-root/scripts/validate-pins.py @@ -818,6 +818,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: @@ -848,6 +853,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..d214e654 --- /dev/null +++ b/tests/test_link_source_chain_defaults.py @@ -0,0 +1,417 @@ +# 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 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) + + +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 + `--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 = 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 " + "tree of a chain's first link alone, openXdox, which had " + 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 + 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") + + 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 ------------------------------- + +@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)