diff --git a/docs/cli.md b/docs/cli.md
index 6211f0d4..369c4781 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -940,7 +940,9 @@ usage: validate-repository-naming.py [-h] [--policy POLICY]
[--pins PINS]
[--referent-chain REFERENT_CHAIN]
[--link-source [PRODUCT=]PATH]
- [--explain] [--quiet] [names ...]
+ [--link-source-label FLAG]
+ [--link-remedy LINK=TEXT] [--explain]
+ [--quiet] [names ...]
Classify repository names against `contracts/repository-naming.yaml`.
@@ -993,6 +995,17 @@ options:
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.
+ --link-source-label FLAG
+ the caller's own name for the bare --link-source it
+ passes, such as --pin-source (a FLAG that starts with
+ - is given after an =): the link-source-unreadable
+ WARNING and the not-used NOTE name that value by FLAG,
+ and neither says a value holding = was read as a PATH
+ --link-remedy LINK=TEXT
+ the caller's words for what would let LINK's tree be
+ read, offered in place of this script's own where that
+ link is declared-unverified. Repeatable; the last
+ value for a link wins.
--explain print every family each name satisfies, why one of
them won, and what the others are recorded as
--quiet
diff --git a/scaffold-project.py b/scaffold-project.py
index cf72e7af..2806c24d 100755
--- a/scaffold-project.py
+++ b/scaffold-project.py
@@ -88,11 +88,6 @@
VISIBILITY_CHOICES, NamingPolicy, Refusal, accepts_role, checked_value,
git_out, link_pins_from_trees, repo_basename, shell_line, tree_digest,
)
-# The variable a chain link's tree is read through, and which links share
-# one, for the sources the scaffold offers a link it could not read (#610).
-from repo_shape import ( # noqa: E402
- pin_product_key, pin_source_env_name, pin_source_env_shared,
-)
from shape_materialize import ( # noqa: E402
RULESET_HINT, SHAPE_REPOSITORY, CommandFailed,
copy_tree, default_reference, descendant_note, election_date, env_commit,
@@ -117,6 +112,11 @@
# Whether `--elected-by`, `--reference` and `--name` are text a file can
# hold: the question `adopt-project.py` asks of the same values (#282).
from shape_materialize import unwritable_reason # noqa: E402
+# The words a chain link that is not reachable is offered: what THIS tool
+# reads (#610), which setup's step (4) hands the naming gate too (#661).
+from shape_materialize import ( # noqa: E402
+ scaffold_link_remedies as _link_remedies,
+)
# The shape half of the leg-path collision rule, which `adopt-project.py`
# asks of its own mount paths (#172), imported as one unit (#197), with the
# one spelling of the file a `--pin` adds to what the shape writes.
@@ -1054,48 +1054,6 @@ def _declaration(args) -> tuple[dict, set, tuple, dict]:
return pins, declared_pins, chain, link_pins
-def _link_remedies(chain: tuple) -> dict[str, str]:
- """What would let each chain link's own tree be read, in the scaffold's
- own sources: `{pin_product_key(link): the words}` for the
- `declared-unverified` WARNING about a tree that is not reachable (#610).
-
- That warning offered "a sibling checkout, SHAPE_PIN_SOURCE_, or
- --link-source =": the naming validator's three, and two of
- them the scaffold does not have. It has no `--link-source`, and it reads
- no checkout beside anything (`link_pins_from_trees(chain, None, ...)`).
- It reads `SHAPE_PIN_SOURCE_` for every link, unless another link
- of the chain shares the variable (#275), and its `--pin-source` for the
- FIRST link alone (#441), the pinned product's clone. A later link that
- shares its variable has neither, which only a hand-written chain can
- hold, and the warning says so.
-
- Keyed by `pin_product_key`, the key `_chain_link_findings` finds a
- holder's entry by (#444). Keyed by `casefold()`, `openß` and `openss`,
- two links of one chain, wrote one entry, and the first link was offered
- the later one's words.
- """
- links = [repo_basename(link) for link in chain]
- shared = pin_source_env_shared(links)
- remedies: dict[str, str] = {}
- for index, link in enumerate(links[:-1]):
- key = pin_product_key(link)
- sources = [] if key in shared else [pin_source_env_name(link)]
- if not index:
- sources.append(f"a --pin-source clone of {link} that holds its "
- "project.yaml")
- if not sources:
- remedies[key] = (
- "the scaffold reads no source for it: "
- f"{pin_source_env_name(link)} is another link's too, and "
- "--pin-source is the first link's tree alone")
- elif len(sources) == 1:
- remedies[key] = f"{sources[0]} would let it be read"
- else:
- remedies[key] = (f"{sources[0]}, or {sources[1]}, "
- "would let it be read")
- return remedies
-
-
def _check_referent_chain(policy, assembly_name: str, declared_pins: set,
chain: tuple, link_pins: dict) -> None:
"""(d) A declared `--referent-chain` reaches the referent the name claims,
diff --git a/scripts/shape_materialize.py b/scripts/shape_materialize.py
index 053554eb..e17f6580 100644
--- a/scripts/shape_materialize.py
+++ b/scripts/shape_materialize.py
@@ -64,9 +64,13 @@
# which products share a `SHAPE_PIN_SOURCE_` variable, the one answer
# the project's own `validate-pins.py` reports on (#275), with two
# declarations ONE product when they share a pin file (`pin_product_key`).
+# `scaffold_link_remedies` reads a chain with the same names: each link's
+# variable (`pin_source_env_name`), whether another link shares it
+# (`pin_source_env_shared`), and the link's name (`repo_basename`) (#661).
from repo_shape import ( # noqa: E402
GH_TREE_API, NEUTRAL_PRODUCT_OWNER, PIN_SOURCE_ENV_PREFIX,
- pin_product_key, pin_source_env_collisions, tree_digest,
+ pin_product_key, pin_source_env_collisions, pin_source_env_name,
+ pin_source_env_shared, repo_basename, tree_digest,
)
# How a `--pin-source` that is not printable is named, the one spelling the
# scaffold's chain WARNING gives it too (#610).
@@ -2926,6 +2930,48 @@ def pin_digest_from_source(source: Path, commit: str, repository: str) -> str:
) from exc
+def scaffold_link_remedies(chain) -> dict[str, str]:
+ """What would let each chain link's own tree be read, in the scaffold's
+ own sources: `{pin_product_key(link): the words}` for the
+ `declared-unverified` WARNING about a tree that is not reachable (#610).
+
+ That warning offered "a sibling checkout, SHAPE_PIN_SOURCE_, or
+ --link-source =": the naming validator's three, and two of
+ them the scaffold does not have. It has no `--link-source`, and it reads
+ no checkout beside anything (`link_pins_from_trees(chain, None, ...)`).
+ It reads `SHAPE_PIN_SOURCE_` for every link, unless another link
+ of the chain shares the variable (#275), and its `--pin-source` for the
+ FIRST link alone (#441), the pinned product's clone. A later link that
+ shares its variable has neither, which only a hand-written chain can
+ hold, and the warning says so.
+
+ Keyed by `pin_product_key`, the key `_chain_link_findings` finds a
+ holder's entry by (#444). Keyed by `casefold()`, `openß` and `openss`,
+ two links of one chain, wrote one entry, and the first link was offered
+ the later one's words.
+ """
+ links = [repo_basename(link) for link in chain]
+ shared = pin_source_env_shared(links)
+ remedies: dict[str, str] = {}
+ for index, link in enumerate(links[:-1]):
+ key = pin_product_key(link)
+ sources = [] if key in shared else [pin_source_env_name(link)]
+ if not index:
+ sources.append(f"a --pin-source clone of {link} that holds its "
+ "project.yaml")
+ if not sources:
+ remedies[key] = (
+ "the scaffold reads no source for it: "
+ f"{pin_source_env_name(link)} is another link's too, and "
+ "--pin-source is the first link's tree alone")
+ elif len(sources) == 1:
+ remedies[key] = f"{sources[0]} would let it be read"
+ else:
+ remedies[key] = (f"{sources[0]}, or {sources[1]}, "
+ "would let it be read")
+ return remedies
+
+
def shown_source(source) -> str:
"""A `--pin-source` path as a refusal names it: as it is, unless a
character in it is not printable (`str.isprintable`), and then as
diff --git a/scripts/validate-repository-naming.py b/scripts/validate-repository-naming.py
index cd16aa5e..fcd5a1c7 100755
--- a/scripts/validate-repository-naming.py
+++ b/scripts/validate-repository-naming.py
@@ -65,6 +65,22 @@
empty one — is refused (#442): an empty path is the current directory, and
`.` is how to mean that.
+A CALLER THAT PASSES A SOURCE FOR SOMEONE ELSE SAYS IT IN ITS OWN WORDS
+(#661). `--link-source-label=FLAG` is the flag the person typed for the bare
+PATH (setup's step (4) passes `--pin-source`): the `link-source-unreadable`
+WARNING and the "not used" NOTE name that value by FLAG, and neither says a
+value holding `=` was read as a PATH. The `link-source-no-path` REFUSED
+line, and the NOTE's advice to name the link (`--link-source =PATH`)
+where the chains begin at more than one link, keep this script's own flag.
+`--link-remedy LINK=TEXT` is what would let LINK's tree be read in that
+caller's sources, offered in place of this script's own for a link that is
+declared-unverified. A project's own runs pass neither. Every `--link-source`
+value and path this prints, and the path of a `SHAPE_PIN_SOURCE_`
+or of a checkout beside the project it names, is named as it is, or as
+Python's `repr` spells it where a character in it is not printable
+(`_shown`). A caller's FLAG and the words it offers for a link, a chain
+link's name, and the `--project` and `--policy` paths are printed as given.
+
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)
@@ -94,6 +110,7 @@
from __future__ import annotations
import argparse
+import inspect
import os
import sys
from pathlib import Path
@@ -121,6 +138,30 @@ def _shown(path) -> str:
return text if text.isprintable() else repr(text)
+def _library_takes_remedies() -> bool:
+ """Does this root's `scripts/repo_shape.py` take `remedies=`? The
+ skew rule `validate-manifest.py` states (#661): an older library has
+ none, and passing it would end the gate in a `TypeError`."""
+ try:
+ return "remedies" in inspect.signature(
+ link_pins_from_trees).parameters
+ except (TypeError, ValueError):
+ return False
+
+
+def _link_remedy(value: str) -> tuple[str, str]:
+ """One `--link-remedy LINK=TEXT`: the link, by `repo_basename`, and
+ the words. The text before the first `=` is the link; a value with no
+ `=`, no link or no words is refused, exit 2."""
+ link, sep, text = value.partition("=")
+ link = repo_basename(link.strip())
+ if not (sep and link and text.strip()):
+ raise argparse.ArgumentTypeError(
+ f"{_shown(value)} is not LINK=TEXT: name a chain link, then `=`, "
+ "then the words for what would let its tree be read")
+ return link, text
+
+
class Target:
"""One name to classify, with everything DECLARED about it.
@@ -203,7 +244,15 @@ class LinkSources:
products' trees.
"""
- def __init__(self, values: list[str], links, firsts=()) -> None:
+ def __init__(self, values: list[str], links, firsts=(),
+ label: str | None = None, remedies=()) -> None:
+ #: The caller's own name for the bare PATH it passes, or None
+ #: (`--link-source-label`, #661).
+ self.label = label
+ #: `{pin_product_key(link): the caller's words}` from
+ #: `--link-remedy`, the last value given for a link wins (#661).
+ self.remedies: dict[str, str] = {
+ pin_product_key(link): text for link, text in remedies}
#: Every link of every chain this run reads, in its first spelling.
self.links: list[str] = []
names: dict[str, str] = {}
@@ -276,10 +325,12 @@ def link_pins(self, policy: NamingPolicy, target: Target) -> dict:
first = repo_basename(str(target.chain[0]))
self.firsts.setdefault(pin_product_key(first), first)
tried: dict = {}
+ offered = ({"remedies": self.remedies}
+ if self.remedies and _library_takes_remedies() else {})
found = link_pins_from_trees(
target.chain, target.root,
{key: path for key, (_typed, path) in self.keyed.items()},
- self._bare_path(), tried=tried)
+ self._bare_path(), tried=tried, **offered)
# 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
@@ -317,11 +368,15 @@ def _record(self, link: str, rule: str, how: str, path: Path,
if why is None:
self.used.add(typed)
return
+ if rule == "bare" and self.label:
+ how = self.label
links = self.unread.setdefault(typed, (path, how, why, []))[3]
elif why is None or (rule == "sibling" and why in (
LINK_SOURCE_ABSENT, LINK_SOURCE_NO_MANIFEST)):
return
else:
+ # A checkout beside the project is named in `how` too (#661).
+ how = how.replace(str(path), _shown(path))
links = self.unanswered.setdefault((path, how, why), [])
if link not in links:
links.append(link)
@@ -331,10 +386,10 @@ def read_as_path(self, typed: str) -> str:
or `""` — the only trace in the run of a mistyped link name. Nothing
when no chain names a link: the note has already said so."""
if self.reading.get(typed) is not None or "=" not in typed \
- or not self.links:
+ or not self.links or self.label:
return ""
key = typed.partition("=")[0]
- what = f"`{key}` is not" if key else "nothing before its `=` is"
+ what = f"`{_shown(key)}` is not" if key else "nothing before its `=` is"
return (f"It was read as a PATH: {what} a link of a referent chain "
f"here (links: {', '.join(self.links)}).")
@@ -345,7 +400,8 @@ def _also_a_directory(self, typed: str) -> str:
`OSError` on every Python."""
if self.reading.get(typed) is None or not os.path.isdir(typed):
return ""
- return (f"`{typed}` is also a directory; `--link-source ./{typed}` "
+ named = _shown(typed)
+ return (f"`{named}` is also a directory; `--link-source ./{named}` "
"reads it as one.")
def unread_lines(self) -> list[str]:
@@ -366,8 +422,8 @@ def unread_lines(self) -> list[str]:
self.read_as_path(typed), self._also_a_directory(typed))
if line))
for (path, how, why), links in self.unanswered.items():
- lines.append(f"link-source-unreadable: {path} ({how}) could not "
- f"answer for {', '.join(links)}: {why}")
+ lines.append(f"link-source-unreadable: {_shown(path)} ({how}) "
+ f"could not answer for {', '.join(links)}: {why}")
return lines
def no_path_refusal(self) -> str:
@@ -375,7 +431,7 @@ def no_path_refusal(self) -> str:
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 "
+ said = [f"`--link-source {_shown(typed)}` names no path for "
f"{self.reading[typed]}" if typed
else "`--link-source ''` names no path"
for typed in self.no_path]
@@ -394,7 +450,8 @@ def _not_asked_why(self) -> str:
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, "
+ keyed = "" if self.label else "a keyed --link-source, "
+ sources = (f"a more specific source ({keyed}"
"SHAPE_PIN_SOURCE_, or a checkout beside the "
"project)")
if self.asked <= set(self.firsts):
@@ -413,9 +470,10 @@ def unused_lines(self) -> list[str]:
for typed, link in self.reading.items():
if typed in self.used or typed in self.unread:
continue
+ flag = self._flag(link)
later = self.replaced.get(typed)
if later is not None:
- why = f"the later `--link-source {later}` replaced it"
+ why = f"the later `{flag} {_shown(later)}` replaced it"
elif link is not None:
why = f"no name classified here reads {link}'s own declaration"
elif not self.links:
@@ -426,10 +484,15 @@ def unused_lines(self) -> list[str]:
else:
why = self._not_asked_why()
lines.append(" ".join(filter(None, (
- f"--link-source {typed}: not used — {why}.",
+ f"{flag} {_shown(typed)}: not used — {why}.",
self.read_as_path(typed)))))
return lines
+ def _flag(self, link: str | None) -> str:
+ """The flag a value is named by: `--link-source`, or for a bare
+ PATH the caller's label where it gave one (#661)."""
+ return self.label if link is None and self.label else "--link-source"
+
def _no_family_lines(policy: NamingPolicy, name: str) -> list[str]:
"""The `--explain` lines for a name that matches no family at all.
@@ -961,6 +1024,22 @@ def main(argv: list[str] | None = None) -> int:
"a source that cannot be read is named with the "
"system's reason: both are warnings and not "
"findings, an explicit source included.")
+ parser.add_argument("--link-source-label", default=None,
+ metavar="FLAG",
+ help="the caller's own name for the bare "
+ "--link-source it passes, such as --pin-source "
+ "(a FLAG that starts with - is given after an "
+ "=): the link-source-unreadable WARNING and the "
+ "not-used NOTE name that value by FLAG, and "
+ "neither says a value holding = was read as a "
+ "PATH")
+ parser.add_argument("--link-remedy", action="append", default=[],
+ type=_link_remedy, metavar="LINK=TEXT",
+ help="the caller's words for what would let LINK's "
+ "tree be read, offered in place of this "
+ "script's own where that link is "
+ "declared-unverified. Repeatable; the last "
+ "value for a link wins.")
parser.add_argument("--explain", action="store_true",
help="print every family each name satisfies, why one "
"of them won, and what the others are recorded as")
@@ -992,7 +1071,8 @@ def main(argv: list[str] | None = None) -> int:
# 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 ())], _first_links_read(policy, targets, data))
+ else ())], _first_links_read(policy, targets, data),
+ args.link_source_label, args.link_remedy)
if sources.no_path:
print(sources.no_path_refusal(), file=sys.stderr)
return 2
diff --git a/setup-project.py b/setup-project.py
index 0150046d..1b2cab7f 100755
--- a/setup-project.py
+++ b/setup-project.py
@@ -1542,6 +1542,18 @@ def shape_scripts(shape_root: Path):
return repo_shape
+def scaffold_link_remedies(shape_root: Path):
+ """`scripts/shape_materialize.py`'s `scaffold_link_remedies` out of
+ the checkout this run is using: what the scaffold's plan offers a chain
+ link whose tree it cannot read (#610), so step (4) can hand the naming
+ check the same words (#661) and one run says one fact one way.
+ Deferred for the reason `shape_scripts` gives, from the same directory.
+ """
+ shape_scripts(shape_root)
+ import shape_materialize # noqa: E402 (deferred; see shape_scripts)
+ return shape_materialize.scaffold_link_remedies
+
+
def family_landing(parent, family: str):
"""The clone's PARENT once `--family` has had its say. A PURE path.
@@ -2033,7 +2045,7 @@ def as_a_bare_link_source(path: str) -> str:
return "./" + path if re.match(r"[^/\\:=]+=", path) else path
-def naming_declarations(passthrough: list) -> list:
+def naming_declarations(passthrough: list, link_remedies=None) -> list:
"""The naming validator's arguments for what the passthrough declares
about the project (#538): the products of its `--pin`s, and its
`--referent-chain`, with the tree its links are read from (#608).
@@ -2070,19 +2082,37 @@ def naming_declarations(passthrough: list) -> list:
BLANK value is a path and goes as it is, and the rule reads the LAST
value alone: `--pin-source src --pin-source ''` is empty,
`--pin-source '' --pin-source src` is not.
+
+ THE NAMING CHECK SPEAKS IN THIS RUN'S WORDS (#661). Its own name for a
+ bare source is `--link-source`, and for a link it cannot read it offers
+ "a sibling checkout, SHAPE_PIN_SOURCE_, or --link-source
+ =": a flag this file does not take, which typed after `--`
+ ends step (5) in the scaffold's `unrecognized arguments`. So, with a
+ chain of two links at least and `link_remedies` (`_main` passes the
+ scaffold's own, `scaffold_link_remedies`), it is told
+ `--link-source-label=--pin-source` and, for each holder,
+ `--link-remedy==`: what step (5) reads, said as step (5)
+ says it. The chain is read as the scaffold reads it
+ (`referent_chain_links`: split on commas, each stripped, an empty one
+ dropped).
"""
products = [name for name in (
pin.strip().split("@", 1)[0].rsplit("/", 1)[-1]
for pin in passed_values(passthrough, "--pin")) if name]
chains = passed_values(passthrough, "--referent-chain")
sources = passed_values(passthrough, "--pin-source")
- has_links = bool(chains) and sum(
- 1 for link in chains[-1].split(",") if link.strip()) > 1
+ chain = tuple(link.strip() for link in chains[-1].split(",")
+ if link.strip()) if chains else ()
+ has_links = len(chain) > 1
names_a_source = bool(sources) and bool(str(sources[-1]))
+ spoken = link_remedies(chain) if has_links and link_remedies else None
return ((["--pins=" + ",".join(products)] if products else [])
+ (["--referent-chain=" + chains[-1]] if chains else [])
+ (["--link-source=" + as_a_bare_link_source(sources[-1])]
- if has_links and names_a_source else []))
+ if has_links and names_a_source else [])
+ + (["--link-source-label=--pin-source"]
+ + ["--link-remedy=%s=%s" % pair for pair in spoken.items()]
+ if spoken is not None else []))
# ---------------------------------------------------------------------------
@@ -2667,7 +2697,8 @@ def _main(argv, invocation_dir: str) -> int:
clone = landing.clone_path(opts.project)
refuse_a_taken_clone_path(clone, opts.project)
check_names(shape_root, opts.project, opts.family,
- naming_declarations(opts.passthrough))
+ naming_declarations(opts.passthrough,
+ scaffold_link_remedies(shape_root)))
args = scaffold_args(opts, org)
if not plan_and_confirm(opts, shape_root, org, args, clone):
return 0
diff --git a/templates/assembly-root/scripts/validate-manifest.py b/templates/assembly-root/scripts/validate-manifest.py
index e7d2603f..ab6344f2 100755
--- a/templates/assembly-root/scripts/validate-manifest.py
+++ b/templates/assembly-root/scripts/validate-manifest.py
@@ -89,6 +89,7 @@
from __future__ import annotations
import argparse
+import inspect
import re
import sys
from pathlib import Path
@@ -98,7 +99,8 @@
CHAIN_RECORD_FIELD, COMMIT_RE, PATH_NOT_CANONICAL, PROJECT_ID_RE,
SHA256_RE, TREE_DIGEST_DEFINITION, VISIBILITY_CHOICES, NamingPolicy,
Refusal, accepts_role, find_repo_root, link_pins_from_trees, load_yaml,
- path_spelling_fault, repo_basename,
+ path_spelling_fault, pin_product_key, pin_source_env_name,
+ pin_source_env_shared, repo_basename,
)
DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
@@ -154,9 +156,68 @@ def _chain_and_links(naming, root: Path | None) -> tuple[tuple, dict]:
pin on `openDox` is a fact in openXdox's tree; a checkout beside this root
(or `SHAPE_PIN_SOURCE_`) is read when there is one, and its
absence is a warning, never a failure. Nothing here goes to a host.
+
+ THE WARNING OFFERS WHAT THIS READS (#661): `_link_remedies`,
+ handed over only where `link_pins_from_trees` takes `remedies=`
+ (`_library_takes_remedies`).
"""
chain = _recorded_chain(naming) if isinstance(naming, dict) else ()
- return chain, (link_pins_from_trees(chain, root) if chain else {})
+ if not chain:
+ return chain, {}
+ offered = ({"remedies": _link_remedies(chain)}
+ if _library_takes_remedies() else {})
+ return chain, link_pins_from_trees(chain, root, **offered)
+
+
+def _link_remedies(chain) -> dict[str, str]:
+ """What would let each chain link's own tree be read, in THIS
+ validator's sources: `{pin_product_key(link): the words}`, for the
+ `declared-unverified` WARNING about a tree that is not reachable
+ (#661).
+
+ The library's own words are the naming gate's: "a sibling checkout,
+ SHAPE_PIN_SOURCE_, or --link-source =". This file
+ takes `--root`, `--policy` and `--quiet` and no `--link-source`, so the
+ offer was a flag it refuses as `unrecognized arguments`. It reads
+ `SHAPE_PIN_SOURCE_`, unless another link of the chain shares the
+ variable (#275), and a checkout beside this root (`resolve_link_source`'s
+ rules 2 and 3, as `_chain_and_links` asks), so it offers those. Every
+ holder is offered both, or the checkout alone where another link shares
+ its variable; the chain's end holds no pin and has no entry.
+
+ Keyed by `pin_product_key`, the key `_chain_link_findings` looks a
+ holder's entry up by (#444), and the links are read as that function
+ reads them: by `repo_basename`, a blank entry dropped.
+ """
+ links = [repo_basename(str(link)) for link in chain if str(link).strip()]
+ shared = pin_source_env_shared(links)
+ remedies: dict[str, str] = {}
+ for link in links[:-1]:
+ beside = f"a checkout of {link} beside this root"
+ if pin_product_key(link) in shared:
+ words = f"{beside} would let it be read"
+ else:
+ words = (f"{pin_source_env_name(link)}, or {beside}, would let "
+ "it be read")
+ remedies[pin_product_key(link)] = words
+ return remedies
+
+
+def _library_takes_remedies() -> bool:
+ """Does this root's `scripts/repo_shape.py` take `remedies=`?
+
+ THE SKEW RULE (#661). This file is copied into every project and
+ may run beside a `repo_shape.py` older than itself (a half-applied
+ update, `update-shape.py apply --accept-local`). Before #610 the library
+ had no `remedies=`, and passing it ended this validator in a `TypeError`
+ before it checked a leg. So it is passed only where the function's own
+ signature names it, and an older library's warning keeps that library's
+ words."""
+ try:
+ return "remedies" in inspect.signature(
+ link_pins_from_trees).parameters
+ except (TypeError, ValueError):
+ return False
def _naming_chain_field_finding(leg_role, naming: dict) -> list[str]:
diff --git a/tests/conftest.py b/tests/conftest.py
index 33cbf476..63655f76 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1769,9 +1769,9 @@ def assert_an_empty_pin_source_is_the_plans_to_refuse(run,
it, made the naming check WARN about `--link-source`, `.` and a folder the
plan never reads. The naming check is given no source, so it says of the
chain what it says of any it cannot verify, `[declared-unverified]`, and
- its hint there, `--link-source =`, one per holder it
- cannot verify, is the one spelling of the flag in a run that never typed
- it."""
+ its hint there offers the sources the plan reads, in the plan's words
+ (#661, L2-75): no line names `--link-source`, a flag the run never typed
+ and setup does not take."""
result = run("--project", "codexDox", "--org", "demoorg",
"--local-remote-dir", str(tmp_path / "remotes"),
"--into", str(tmp_path / "work"),
@@ -1783,14 +1783,12 @@ def assert_an_empty_pin_source_is_the_plans_to_refuse(run,
assert result.returncode == 2, heard
assert "REFUSED pin-source-no-path:" in result.stderr, result.stderr
assert "link-source-no-path" not in heard, heard
- # The validator's own hint for a chain it cannot verify names the flag,
- # `--link-source openXdox=`, whatever the run typed, so it is the
- # one spelling left out; a `--link-source` that is a REPORT is not.
- assert "--link-source" not in re.sub(r"--link-source \w+=", "",
- heard), heard
+ assert "--link-source" not in heard, heard
step_four = result.stdout.split("(4) naming policy", 1)[-1].split(
"(5) the plan", 1)[0]
assert "[declared-unverified]" in step_four, step_four
+ assert f"not reachable here ({FIRST_LINK_WORDS})." in step_four, (
+ step_four)
assert "do not satisfy the naming policy" not in result.stderr, (
result.stderr)
assert not (tmp_path / "remotes").exists()
@@ -1823,3 +1821,94 @@ def assert_a_chain_of_one_link_has_no_source_to_note(run,
assert "NOTE --link-source" not in step_four + result.stderr, (
step_four + result.stderr)
assert not (tmp_path / "remotes").exists()
+
+
+#: What step (4) is asked to say of a chain (#661, L2-75): each case's
+#: passthrough after `--`, as `(--pin-source, --referent-chain, what the
+#: link tree declares, the variable set for the first link)`.
+STEP_FOUR_WORDS = {
+ "three-links-first-tree": ("tree", "openXdox,openYdox,openDox",
+ "openYdox", False),
+ "three-links-no-source": ("", "openXdox,openYdox,openDox", None, False),
+ "blank": (" ", "openXdox,openDox", None, False),
+ "holds-an-equals": ("openXdox=layer", "openXdox,openDox", None, False),
+ "variable-first": ("tree", "openXdox,openDox", "openDox", True),
+}
+
+#: The scaffold's own words for the first link and a later one (#610).
+FIRST_LINK_WORDS = ("SHAPE_PIN_SOURCE_OPENXDOX, or a --pin-source clone of "
+ "openXdox that holds its project.yaml, would let it be "
+ "read")
+LATER_LINK_WORDS = "SHAPE_PIN_SOURCE_OPENYDOX would let it be read"
+
+
+def assert_step_four_speaks_in_the_plans_words(run, tmp_path: Path,
+ monkeypatch, case: str
+ ) -> None:
+ """Step (4) names no `--link-source`, a flag setup does not take (#661,
+ L2-75): a holder it cannot read is offered the sources step (5) reads,
+ in step (5)'s words, one offer per holder; a `--pin-source` it could not
+ read, or did not need, is named `--pin-source`; and a value holding
+ `=` is not said to have been read as a PATH. On main step (4) offered
+ `--link-source =` and labelled the source `(--link-source)`;
+ typed as printed, the offer ended step (5) in `unrecognized
+ arguments`."""
+ clear_ambient_pin_sources(monkeypatch)
+ source, chain, declares, variable = STEP_FOUR_WORDS[case]
+ commit = "0123456789abcdef" * 2 + "01234567"
+ if declares is not None:
+ tree = tmp_path / "openXdox"
+ tree.mkdir()
+ git("init", "-q", "-b", "main", cwd=tree)
+ (tree / "project.yaml").write_bytes(
+ ("schema_version: 1\nkind: project-manifest\n"
+ f"neutral_product_pins:\n - {declares}\n").encode("utf-8"))
+ git("add", "project.yaml", cwd=tree)
+ git("commit", "-q", "-m", "seed", cwd=tree)
+ commit = git("rev-parse", "HEAD", cwd=tree).stdout.strip()
+ source = str(tree)
+ if variable:
+ monkeypatch.setenv("SHAPE_PIN_SOURCE_OPENXDOX", source)
+ result = run("--project", "codexDox", "--org", "demoorg",
+ "--local-remote-dir", str(tmp_path / "remotes"),
+ "--into", str(tmp_path / "work"),
+ "--", "--pin", f"openXdox@{commit}",
+ "--referent-chain", chain, "--pin-source", source)
+ heard = result.stdout + result.stderr
+ step_four = result.stdout.split("(4) naming policy", 1)[-1].split(
+ "(5) the plan", 1)[0]
+ assert "(5) the plan" in result.stdout, heard
+ assert result.returncode == 2, heard
+ assert "--link-source" not in step_four + result.stderr, heard
+ offered = [line.strip() for line in step_four.splitlines()
+ if line.strip().startswith("WARNING declared-unverified: ")]
+ if case == "three-links-first-tree":
+ assert offered == [
+ "WARNING declared-unverified: openYdox declaring a pin on openDox "
+ "is a fact in openYdox's own tree, which is not reachable here "
+ f"({LATER_LINK_WORDS}). The recorded chain still classifies."]
+ # One run says one fact one way: the plan's own WARNING.
+ assert offered[0] + "\n" in result.stderr, result.stderr
+ elif case == "three-links-no-source":
+ assert [line.partition("not reachable here (")[2] for line in offered
+ ] == [f"{FIRST_LINK_WORDS}). The recorded chain still "
+ "classifies.",
+ f"{LATER_LINK_WORDS}). The recorded chain still "
+ "classifies."]
+ assert "REFUSED pin-source-no-path:" in result.stderr
+ elif case == "variable-first":
+ assert "[verified]" in step_four, step_four
+ assert (f"NOTE --pin-source {source}: not used — each link whose own "
+ "declaration is read had a more specific source "
+ "(SHAPE_PIN_SOURCE_, or a checkout beside the "
+ "project).") in result.stderr, result.stderr
+ else:
+ assert (f"WARNING link-source-unreadable: {source} (--pin-source) "
+ "could not answer for openXdox: no such file or directory"
+ ) in result.stderr, result.stderr
+ assert "read as a PATH" not in result.stderr, result.stderr
+ assert offered, step_four
+ assert offered[0].endswith(
+ f"({FIRST_LINK_WORDS}). The recorded chain still classifies."
+ ), offered
+ assert not (tmp_path / "remotes").exists()
diff --git a/tests/test_chain_remedy_callers.py b/tests/test_chain_remedy_callers.py
new file mode 100644
index 00000000..343ee7a9
--- /dev/null
+++ b/tests/test_chain_remedy_callers.py
@@ -0,0 +1,389 @@
+# SPDX-License-Identifier: Apache-2.0
+"""What a chain link that is not reachable is offered, by each caller in its
+own sources, and how the naming gate spells what it prints (#661).
+
+ * L2-68. The root's `validate-manifest.py` printed the naming gate's
+ words, "a sibling checkout, SHAPE_PIN_SOURCE_, or --link-source
+ = would let it be read", and it takes no `--link-source`:
+ typed as printed, `unrecognized arguments`. It offers the variable and
+ a checkout beside the root, the two it reads, and passes them as
+ `remedies=` only where its root's `scripts/repo_shape.py` takes the
+ keyword, so it still runs beside an older library.
+ * L2-75. Setup's step (4) showed the gate's offer and its `(--link-source)`
+ label for a value typed as `--pin-source`. The gate takes the caller's
+ label for a bare source (`--link-source-label`) and the caller's words
+ for each holder (`--link-remedy`); setup's own half is in
+ `tests/conftest.py` (`assert_step_four_speaks_in_the_plans_words`).
+ * L2-69. The gate named `--link-source` values and paths raw, so an ESC
+ reached the terminal. One row per print, each with an ESC.
+
+No network and no GitHub.
+"""
+
+from __future__ import annotations
+
+import subprocess
+import sys
+
+import pytest
+
+from conftest import (REPO, clear_ambient_pin_sources, load_without_bytecode,
+ run_script)
+from test_link_source_chain_defaults import ( # noqa: F401
+ VALIDATOR, manifest, naming, tree,
+)
+from test_manifest_validator import as_a_descendant_by_chain, edit, link_tree
+from test_pinned_copy_text import CONTROL_ARGV, CONTROL_NAMES
+
+sys.path.insert(0, str(REPO / "scripts"))
+from repo_shape import shown_path # noqa: E402
+
+TEMPLATE_MANIFEST = (REPO / "templates" / "assembly-root" / "scripts" /
+ "validate-manifest.py")
+
+#: The sentence a holder's `declared-unverified` WARNING ends with, and what
+#: the root's `validate-manifest.py` offers in it (L2-68).
+UNREACHED = ("declared-unverified: {holder} declaring a pin on {held} is a "
+ "fact in {holder}'s own tree, which is not reachable here "
+ "({offer}). The recorded chain still classifies.")
+OWN_OFFER = ("SHAPE_PIN_SOURCE_{var}, or a checkout of {link} beside this "
+ "root, would let it be read")
+#: The naming gate's own words, which an older library still prints.
+GATE_OFFER = ("a sibling checkout, SHAPE_PIN_SOURCE_{var}, or --link-source "
+ "{link}= would let it be read")
+
+#: Libraries older than the copies: `remedies=` is new with #610 (#634).
+#: `pre-634` is main just before #634's squash, the first parent of
+#: `30676c90`; `v1.0.1` is the release every project pins today.
+OLDER_LIBRARIES = {
+ "pre-634": "de926142e69dcd4fcb986f57573f4f35c276c966",
+ "v1.0.1": "v1.0.1",
+}
+
+
+def offer(words: str, link: str) -> str:
+ return words.format(var=link.upper(), link=link)
+
+
+def manifest_warnings(result) -> list[str]:
+ return [line.partition("leg 'assembly': ")[2]
+ for line in result.stderr.splitlines()
+ if line.startswith("WARNING naming-referent-chain: ")]
+
+
+def a_chain_root(project, chain=("openXdox", "openDox")):
+ """The scaffolded root recast as `codexDox`, pinning `openXdox` with its
+ pin file, and recording `chain`."""
+ as_a_descendant_by_chain(project)
+ (project / "contracts" / "openxdox-pin.yaml").write_text(
+ "schema_version: 1\nkind: pinned_contract_manifest\n")
+ if tuple(chain) != ("openXdox", "openDox"):
+ # The leg's own record, on a line of its own: the manifest's comment
+ # block above it spells a chain the same way.
+ edit(project, "\n referent_chain: [openXdox, openDox]\n",
+ f"\n referent_chain: [{', '.join(chain)}]\n")
+ return project
+
+
+# --- L2-68: the root's validate-manifest.py offers what it reads ------------
+
+@pytest.mark.parametrize("chain", [
+ pytest.param(("openXdox", "openDox"), id="two-links"),
+ pytest.param(("openXdox", "openYdox", "openDox"), id="three-links"),
+ pytest.param(("opensoft/openXdox", "openDox"), id="qualified"),
+])
+def test_the_manifest_gate_offers_the_sources_it_reads(project, monkeypatch,
+ chain):
+ """Each holder is offered its variable and a checkout beside the root,
+ in its own words, and nothing names `--link-source`. A link recorded
+ `/` is the link ``, as the chain's own reader takes it
+ (`_chain_links`): its entry, and the words, go by the name alone."""
+ clear_ambient_pin_sources(monkeypatch)
+ root = a_chain_root(project, chain)
+ result = run_script(root / "scripts" / "validate-manifest.py", cwd=root)
+ assert result.returncode == 0, result.stderr
+ assert "manifest ok" in result.stdout
+ names = [link.rpartition("/")[2] for link in chain]
+ assert manifest_warnings(result) == [
+ UNREACHED.format(holder=holder, held=held,
+ offer=offer(OWN_OFFER, holder))
+ for holder, held in zip(names, names[1:])]
+ assert "--link-source" not in result.stderr
+
+
+@pytest.mark.parametrize("where", ["variable", "beside-the-root"])
+def test_each_source_the_manifest_gate_offers_reads_the_link(project,
+ monkeypatch,
+ tmp_path, where):
+ """The offer is one the validator HAS: either source it names, given
+ the link's tree, verifies the chain."""
+ clear_ambient_pin_sources(monkeypatch)
+ root = a_chain_root(project)
+ env = {}
+ if where == "variable":
+ env["SHAPE_PIN_SOURCE_OPENXDOX"] = str(
+ link_tree(tmp_path / "elsewhere" / "openXdox", "openDox"))
+ else:
+ link_tree(root.parent / "openXdox", "openDox")
+ result = run_script(root / "scripts" / "validate-manifest.py", cwd=root,
+ env=env)
+ assert result.returncode == 0, result.stderr
+ assert "WARNING" not in result.stderr, result.stderr
+
+
+@pytest.mark.parametrize("first, keyed", [
+ pytest.param("open-x", "open-x", id="lowercase"),
+ pytest.param("Open-X", "open-x", id="mixed-case"),
+])
+def test_a_shared_variable_is_not_offered_by_the_manifest_gate(first, keyed):
+ """Two holders that share one variable (#275): the validator reads it
+ for neither, so neither is offered it, and the entry is keyed by
+ `pin_product_key`, the key `_chain_link_findings` looks it up by."""
+ module = load_without_bytecode(TEMPLATE_MANIFEST, "chain_remedy_manifest")
+ assert module._link_remedies((first, "open_x", "openDox")) == {
+ keyed: f"a checkout of {first} beside this root would let it be read",
+ "open_x": "a checkout of open_x beside this root would let it be read"}
+
+
+def older_library(ref: str) -> bytes:
+ """`scripts/repo_shape.py` at `ref`, or a skip where this checkout does
+ not hold that history. It must be a library with no `remedies` at all,
+ or the row would test nothing older than the copies."""
+ shown = subprocess.run(["git", "show", f"{ref}:scripts/repo_shape.py"],
+ cwd=str(REPO), capture_output=True, check=False)
+ if shown.returncode != 0:
+ pytest.skip(f"needs {ref} in this checkout's history")
+ assert b"def link_pins_from_trees(" in shown.stdout, ref
+ assert b"remedies" not in shown.stdout, ref
+ return shown.stdout
+
+
+@pytest.mark.parametrize("ref", list(OLDER_LIBRARIES.values()),
+ ids=list(OLDER_LIBRARIES))
+def test_the_new_copies_run_beside_an_older_library(project, monkeypatch, ref):
+ """THE SKEW RULE. A root may keep a `scripts/repo_shape.py` older than its
+ copies (`update-shape.py apply --accept-local`), and before #610 the
+ library had no `remedies=`: passing it ended both validators in a
+ `TypeError`. Beside such a library each runs, and its WARNING keeps that
+ library's words."""
+ clear_ambient_pin_sources(monkeypatch)
+ root = a_chain_root(project)
+ (root / "scripts" / "repo_shape.py").write_bytes(older_library(ref))
+ own = run_script(root / "scripts" / "validate-manifest.py", cwd=root)
+ assert own.returncode == 0, own.stderr
+ assert "Traceback" not in own.stderr, own.stderr
+ assert manifest_warnings(own) == [UNREACHED.format(
+ holder="openXdox", held="openDox", offer=offer(GATE_OFFER,
+ "openXdox"))]
+ gate = run_script(root / "scripts" / "validate-repository-naming.py",
+ "--project", "project.yaml", "--link-remedy",
+ "openXdox=OURS would do", cwd=root)
+ assert gate.returncode == 0, gate.stderr
+ assert "Traceback" not in gate.stderr, gate.stderr
+ assert offer(GATE_OFFER, "openXdox") in gate.stderr, gate.stderr
+
+
+# --- L2-75: the gate takes the caller's label and words ----------------------
+
+THREE = ("--pins", "openXdox", "--referent-chain", "openXdox,openYdox,openInk")
+
+
+def gate_warnings(result) -> list[str]:
+ return [line.partition("codexInk: ")[2]
+ for line in result.stderr.splitlines()
+ if line.startswith("WARNING codexInk: ")]
+
+
+def test_the_gate_offers_each_holder_the_callers_words(tmp_path, monkeypatch):
+ """One offer per holder, each in its own words, found by
+ `pin_product_key` (the second is typed in capitals)."""
+ clear_ambient_pin_sources(monkeypatch)
+ result = naming(tmp_path, *THREE, "--link-remedy",
+ "openXdox=FIRST would do", "--link-remedy",
+ "OPENYDOX=LATER would do", "codexInk")
+ assert result.returncode == 0, result.stderr
+ assert gate_warnings(result) == [
+ UNREACHED.format(holder="openXdox", held="openYdox",
+ offer="FIRST would do"),
+ UNREACHED.format(holder="openYdox", held="openInk",
+ offer="LATER would do")]
+ assert "--link-source" not in result.stderr
+
+
+def test_a_holder_given_no_words_keeps_the_gates_own(tmp_path, monkeypatch):
+ clear_ambient_pin_sources(monkeypatch)
+ result = naming(tmp_path, *THREE, "--link-remedy",
+ "openYdox=LATER would do", "codexInk")
+ assert gate_warnings(result) == [
+ UNREACHED.format(holder="openXdox", held="openYdox",
+ offer=offer(GATE_OFFER, "openXdox")),
+ UNREACHED.format(holder="openYdox", held="openInk",
+ offer="LATER would do")]
+
+
+def test_the_last_words_for_a_link_win(tmp_path, monkeypatch):
+ clear_ambient_pin_sources(monkeypatch)
+ result = naming(tmp_path, *THREE, "--link-remedy", "openXdox=A",
+ "--link-remedy", "openXdox=B", "codexInk")
+ assert gate_warnings(result)[0].endswith(
+ "not reachable here (B). The recorded chain still classifies.")
+
+
+@pytest.mark.parametrize("value", [
+ pytest.param("openXdox", id="no-equals"),
+ pytest.param("=words", id="no-link"),
+ pytest.param("openXdox= ", id="no-words"),
+])
+def test_words_that_are_not_link_equals_text_are_refused(tmp_path, value):
+ result = naming(tmp_path, *THREE, "--link-remedy", value, "codexInk")
+ assert result.returncode == 2, result.stderr
+ assert "argument --link-remedy:" in result.stderr, result.stderr
+ assert "is not LINK=TEXT" in result.stderr, result.stderr
+
+
+def test_a_labelled_bare_source_is_named_by_its_label(tmp_path, monkeypatch):
+ """A source the gate could not read, named by the caller's flag."""
+ clear_ambient_pin_sources(monkeypatch)
+ nowhere = tmp_path / "nowhere"
+ result = naming(tmp_path, *THREE, "--link-source", str(nowhere),
+ "--link-source-label=--pin-source", "codexInk")
+ assert (f"WARNING link-source-unreadable: {nowhere} (--pin-source) could "
+ "not answer for openXdox: no such file or directory\n"
+ ) in result.stderr, result.stderr
+
+
+def test_a_labelled_source_holding_an_equals_is_not_said_to_be_a_path(
+ tmp_path, monkeypatch):
+ """The caller's flag has no `LINK=` form, so "It was read as a PATH"
+ would be about a reading nobody asked for."""
+ clear_ambient_pin_sources(monkeypatch)
+ result = naming(tmp_path, *THREE, "--link-source", "./openXdox=layer",
+ "--link-source-label=--pin-source", "codexInk")
+ assert ("WARNING link-source-unreadable: openXdox=layer (--pin-source) "
+ "could not answer for openXdox") in result.stderr, result.stderr
+ assert "read as a PATH" not in result.stderr, result.stderr
+
+
+def test_an_unused_labelled_source_is_noted_by_its_label(tmp_path,
+ monkeypatch):
+ """The first link's variable answered, so the bare source was not asked:
+ the NOTE names it by the caller's flag, and lists only the sources such
+ a caller has."""
+ clear_ambient_pin_sources(monkeypatch)
+ first = tree(tmp_path / "trees" / "openXdox", "openInk")
+ spare = tree(tmp_path / "spare", "openInk")
+ result = run_script(VALIDATOR, "--pins", "openXdox", "--referent-chain",
+ "openXdox,openInk", "--link-source", str(spare),
+ "--link-source-label=--pin-source", "codexInk",
+ cwd=tmp_path,
+ env={"SHAPE_PIN_SOURCE_OPENXDOX": str(first)})
+ assert (f"NOTE --pin-source {spare}: not used — each link whose own "
+ "declaration is read had a more specific source "
+ "(SHAPE_PIN_SOURCE_, or a checkout beside the project)."
+ "\n") in result.stderr, result.stderr
+ assert "--link-source" not in result.stderr
+
+
+# --- L2-69: every --link-source value and path the gate prints is spelled ---
+
+def assert_clean(result) -> None:
+ said = result.stdout + result.stderr
+ assert "\x1b" not in said, said
+ assert not [line for line in said.splitlines()
+ if line.startswith("WARNING forged-line")], said
+
+
+@CONTROL_ARGV
+@pytest.mark.parametrize("name", CONTROL_NAMES)
+def test_a_key_read_as_a_path_is_spelled(tmp_path, monkeypatch, name):
+ """`read_as_path`: the text before `=`, which names no link."""
+ clear_ambient_pin_sources(monkeypatch)
+ result = naming(tmp_path, *THREE, "--link-source", f"{name}=b/openXdox",
+ "codexInk")
+ assert_clean(result)
+ assert f"It was read as a PATH: `{shown_path(name)}` is not a link" in (
+ result.stderr), result.stderr
+
+
+@CONTROL_ARGV
+@pytest.mark.parametrize("name", CONTROL_NAMES)
+def test_a_keyed_value_that_is_also_a_directory_is_spelled(tmp_path,
+ monkeypatch, name):
+ """`_also_a_directory`: the value, in the sentence and in the command."""
+ clear_ambient_pin_sources(monkeypatch)
+ typed = f"openXdox={name}"
+ (tmp_path / typed).mkdir()
+ result = naming(tmp_path, *THREE, "--link-source", typed, "codexInk")
+ assert_clean(result)
+ named = shown_path(typed)
+ assert (f"`{named}` is also a directory; `--link-source ./{named}` reads "
+ "it as one.") in result.stderr, result.stderr
+
+
+@CONTROL_ARGV
+@pytest.mark.parametrize("name", CONTROL_NAMES)
+def test_a_variable_the_gate_could_not_read_is_spelled(tmp_path, name):
+ """`unread_lines`' second loop, for `SHAPE_PIN_SOURCE_`."""
+ folder = tmp_path / name
+ result = run_script(VALIDATOR, *THREE, "codexInk", cwd=tmp_path,
+ env={"SHAPE_PIN_SOURCE_OPENXDOX": str(folder)})
+ assert_clean(result)
+ assert (f"WARNING link-source-unreadable: {shown_path(str(folder))} "
+ "($SHAPE_PIN_SOURCE_OPENXDOX) could not answer for openXdox"
+ ) in result.stderr, result.stderr
+
+
+@CONTROL_ARGV
+@pytest.mark.parametrize("name", CONTROL_NAMES)
+def test_a_checkout_beside_the_project_the_gate_could_not_read_is_spelled(
+ tmp_path, monkeypatch, name):
+ """`unread_lines`' second loop, for a checkout beside the project: its
+ path, and the path inside its `how`."""
+ clear_ambient_pin_sources(monkeypatch)
+ parent = tmp_path / name
+ (parent / "codexInk").mkdir(parents=True)
+ beside = manifest(parent / "openXdox", "neutral_product_pins: 5\n")
+ result = naming(parent / "codexInk", *THREE, "codexInk")
+ assert_clean(result)
+ shown = shown_path(str(beside))
+ assert (f"WARNING link-source-unreadable: {shown} (sibling checkout "
+ f"({shown})) could not answer for openXdox") in result.stderr, (
+ result.stderr)
+
+
+@CONTROL_ARGV
+def test_a_value_that_names_no_path_is_spelled(tmp_path):
+ """`no_path_refusal`: a keyed value names a link of the chain, so the
+ value holds a control character only where the link does; the value is
+ spelled, and the link, a chain's name, is printed as the chain gives
+ it, as every chain line is."""
+ link = "openX\x1b[31mdox"
+ result = naming(tmp_path, "--pins", link, "--referent-chain",
+ f"{link},openInk", "--link-source", f"{link}=",
+ "codexInk")
+ assert result.returncode == 2, result.stderr
+ assert (f"`--link-source {shown_path(link + '=')}` names no path for "
+ ) in result.stderr, result.stderr
+ assert f"--link-source {link}=" not in result.stderr
+
+
+@CONTROL_ARGV
+@pytest.mark.parametrize("name", CONTROL_NAMES)
+def test_an_unused_link_source_and_its_replacement_are_spelled(tmp_path,
+ name):
+ """`unused_lines`: the value, and the later value that replaced it."""
+ result = naming(tmp_path, "--link-source", "plain", "--link-source",
+ name, "codexInk")
+ assert_clean(result)
+ assert (f"NOTE --link-source plain: not used — the later `--link-source "
+ f"{shown_path(name)}` replaced it.") in result.stderr
+ assert (f"NOTE --link-source {shown_path(name)}: not used — no name "
+ "classified here records a referent chain.") in result.stderr
+
+
+def test_a_printable_value_is_named_as_typed(tmp_path):
+ """The control: a printable value and its replacement, byte for byte."""
+ result = naming(tmp_path, "--link-source", "a", "--link-source",
+ "café", "codexInk")
+ assert ("NOTE --link-source a: not used — the later `--link-source "
+ "café` replaced it.") in result.stderr, result.stderr
diff --git a/tests/test_naming_policy.py b/tests/test_naming_policy.py
index 8ebf37be..e3133209 100644
--- a/tests/test_naming_policy.py
+++ b/tests/test_naming_policy.py
@@ -2071,8 +2071,10 @@ def test_the_projects_own_manifest_gate_keeps_its_warning_for_a_tree_it_reached(
own = run_script(project / "scripts" / "validate-manifest.py",
cwd=project)
assert "Traceback" not in own.stderr, own.stderr
- assert "which is not reachable here (a sibling checkout, " in (
- own.stdout + own.stderr), own.stdout + own.stderr
+ assert ("which is not reachable here (SHAPE_PIN_SOURCE_OPENXDOX, or a "
+ "checkout of openXdox beside this root, would let it be read)"
+ ) in own.stdout + own.stderr, own.stdout + own.stderr
+ assert "--link-source" not in own.stdout + own.stderr
assert "was reached" not in own.stdout + own.stderr
diff --git a/tests/test_setup_project_py.py b/tests/test_setup_project_py.py
index 5b8bb65b..535aa995 100644
--- a/tests/test_setup_project_py.py
+++ b/tests/test_setup_project_py.py
@@ -68,6 +68,12 @@
assert_a_chain_of_one_link_has_no_source_to_note,
assert_an_empty_pin_source_is_the_plans_to_refuse)
+from conftest import (STEP_FOUR_WORDS, load_without_bytecode,
+ assert_step_four_speaks_in_the_plans_words)
+
+sys.path.insert(0, str(REPO / "scripts"))
+import shape_materialize # noqa: E402
+
SETUP_PROJECT = REPO / "setup-project.py"
SETUP_SH = REPO / "setup.sh"
DEGRADE_LINE = "authority is not wallet-carried in this org"
@@ -2905,3 +2911,56 @@ def test_a_terminal_whose_read_fails_is_the_end_of_the_answer(monkeypatch):
monkeypatch.setattr(module, "os", hung_up)
assert module.ask("Type yes to continue: ") == ""
assert hung_up.closed == [7]
+
+
+# --- #661 (L2-75): step (4) speaks in the plan's words ----------------------
+
+@pytest.mark.parametrize("passthrough,spoken", [
+ # A chain of two links at least: the label, and each HOLDER's words, in
+ # the scaffold's own (`shape_materialize.scaffold_link_remedies`).
+ (["--referent-chain", "openXdox,openDox"],
+ ["--referent-chain=openXdox,openDox", "--link-source-label=--pin-source",
+ "--link-remedy=openxdox=SHAPE_PIN_SOURCE_OPENXDOX, or a --pin-source "
+ "clone of openXdox that holds its project.yaml, would let it be read"]),
+ (["--referent-chain", "openXdox,openYdox,openDox", "--pin-source", "src"],
+ ["--referent-chain=openXdox,openYdox,openDox", "--link-source=src",
+ "--link-source-label=--pin-source",
+ "--link-remedy=openxdox=SHAPE_PIN_SOURCE_OPENXDOX, or a --pin-source "
+ "clone of openXdox that holds its project.yaml, would let it be read",
+ "--link-remedy=openydox=SHAPE_PIN_SOURCE_OPENYDOX would let it be "
+ "read"]),
+ # ...read as the scaffold reads the chain: blanks dropped, the last kept.
+ (["--referent-chain", "openXdox, ,openDox"],
+ ["--referent-chain=openXdox, ,openDox",
+ "--link-source-label=--pin-source",
+ "--link-remedy=openxdox=SHAPE_PIN_SOURCE_OPENXDOX, or a --pin-source "
+ "clone of openXdox that holds its project.yaml, would let it be read"]),
+ # A chain of one link has no holder, so nothing is said for it.
+ (["--referent-chain", "openDox", "--pin-source", "src"],
+ ["--referent-chain=openDox"]),
+ (["--referent-chain", "openXdox,openDox", "--referent-chain", " , "],
+ ["--referent-chain= , "]),
+ ([], [])])
+def test_each_holder_is_given_the_plans_words(passthrough, spoken):
+ """#661, L2-75: with the scaffold's words, step (4) tells the naming
+ check `--pin-source` is the flag and what each holder is offered."""
+ module = entry_point_module()
+ assert module.naming_declarations(
+ passthrough, shape_materialize.scaffold_link_remedies) == spoken
+
+
+def test_step_four_and_the_plan_share_one_set_of_words():
+ """ONE definition: setup asks the checkout's `shape_materialize` for the
+ words the scaffold's own `_link_remedies` is."""
+ module = entry_point_module()
+ assert module.scaffold_link_remedies(REPO) is (
+ shape_materialize.scaffold_link_remedies)
+ scaffold = load_without_bytecode(REPO / "scaffold-project.py",
+ "scaffold_for_setup_words")
+ assert scaffold._link_remedies is shape_materialize.scaffold_link_remedies
+
+
+@pytest.mark.parametrize("case", STEP_FOUR_WORDS)
+def test_step_four_speaks_in_the_plans_words(tmp_path, monkeypatch, case):
+ assert_step_four_speaks_in_the_plans_words(run_entry, tmp_path,
+ monkeypatch, case)
diff --git a/tests/test_setup_sh.py b/tests/test_setup_sh.py
index 1256c698..312e2b71 100644
--- a/tests/test_setup_sh.py
+++ b/tests/test_setup_sh.py
@@ -51,6 +51,9 @@
assert_a_chain_of_one_link_has_no_source_to_note,
assert_an_empty_pin_source_is_the_plans_to_refuse)
+from conftest import (STEP_FOUR_WORDS,
+ assert_step_four_speaks_in_the_plans_words)
+
SETUP = REPO / "setup.sh"
DEGRADE_LINE = "authority is not wallet-carried in this org"
@@ -1145,3 +1148,9 @@ def test_the_one_liners_preflight_offers_are_asked_at_the_terminal(tmp_path,
calls = (stub / "calls.log").read_text(encoding="utf-8").splitlines()
logins = [call for call in calls if call.startswith("auth login|")]
assert logins == ([] if typed == "no" else ["auth login|terminal"]), calls
+
+
+@pytest.mark.parametrize("case", STEP_FOUR_WORDS)
+def test_step_four_speaks_in_the_plans_words(tmp_path, monkeypatch, case):
+ assert_step_four_speaks_in_the_plans_words(run_setup, tmp_path,
+ monkeypatch, case)