diff --git a/scaffold-project.py b/scaffold-project.py
index 5a73dd13..08444981 100755
--- a/scaffold-project.py
+++ b/scaffold-project.py
@@ -89,6 +89,11 @@
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,
@@ -988,15 +993,61 @@ def _declaration(args) -> tuple[dict, set, tuple, dict]:
too, and the warning says that tree could not be READ, with why, rather
than that it "is not reachable here" (#338): the scaffold prints no other
line about a source, so `with_reasons` is asked for here.
+
+ A tree that is not reachable is offered the sources THIS TOOL reads
+ (`_link_remedies`, #610), never the naming validator's.
"""
pins = declared_pin_values(args)
declared_pins: set[str] = set(pins)
chain = referent_chain_links(args.referent_chain)
- link_pins = link_pins_from_trees(chain, None, {}, args.pin_source,
- with_reasons=True) if chain else {}
+ link_pins = link_pins_from_trees(
+ chain, None, {}, args.pin_source, with_reasons=True,
+ remedies=_link_remedies(chain)) if chain else {}
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/repo_shape.py b/scripts/repo_shape.py
index f5177840..570b7b3a 100644
--- a/scripts/repo_shape.py
+++ b/scripts/repo_shape.py
@@ -1032,6 +1032,23 @@ def git_out(args: list[str], cwd: Path, binary: bool = False,
own. A git that cannot be STARTED (none on PATH) raises `OSError` from
here, never a refusal: whether that is a question the caller cannot
answer is the caller's to say (#368).
+
+ THE REFUSAL'S TWO UNTRUSTED PARTS ARE SPELLED (#610), because it is
+ printed as a line, and a root's `validate-pins.py` prints it inside the
+ `neutral-pin-source-unreadable` FINDING. `cwd` is a person's path, named
+ with `shown_path`: as it is, unless a character in it is not printable.
+ What git printed is `spelled`, as ONE line. git echoes the paths it
+ reads (`invalid gitfile format: /.git`, `not a git repository:
+ `, `detected dubious ownership in repository at ''`) and
+ passes a line break through, so a folder named with a line break, then
+ `WARNING forged-line: ...`, began a line the tool never printed; git
+ 2.43 replaces an ESC, a CR or a BEL that it echoes with `?` but passes
+ a line break and a C1 control (U+0080 to U+009F) through, and another
+ git may replace nothing, so everything git printed is spelled. A line
+ break git itself printed between two lines of its message is `\\n`
+ here, because it cannot be told from a path's. Printable text, UTF-8
+ included, is unchanged. `args`, which the caller wrote, are named as
+ they were.
"""
proc = subprocess.run(
["git", *args], cwd=str(cwd), capture_output=True, check=False,
@@ -1041,8 +1058,8 @@ def git_out(args: list[str], cwd: Path, binary: bool = False,
raise Refusal(
"git-failed",
"`git {}` in {} exited {}: {}".format(
- " ".join(args), cwd, proc.returncode,
- proc.stderr.decode("utf-8", "replace").strip(),
+ " ".join(args), shown_path(cwd), proc.returncode,
+ spelled(proc.stderr.decode("utf-8", "replace").strip()),
),
)
return proc.stdout if binary else proc.stdout.decode("utf-8").strip()
@@ -1570,12 +1587,19 @@ def _chain_link_findings(chain, link_pins) -> tuple:
reached could not: a warning for such a link says so, where a plain
mapping's says the tree is not reachable (#338).
+ A tree that is not reachable is offered the sources that would let it be
+ read: the caller's own, where its `LinkPins` has `remedies` for the link
+ (the scaffold's, #610), and otherwise the naming validator's, which
+ reads a checkout beside the project and takes `--link-source`.
+
A holder's tree and the next link are matched by `pin_product_key`
(#444): a tree declaring `openss` does not declare `openß`, whose pin
- file is another.
+ file is another. A holder's `remedies` entry is found by it too, so in a
+ chain naming both, each is offered its own sources and not the other's.
"""
available = _link_declarations(link_pins)
unreadable = getattr(link_pins, "unreadable", None) or {}
+ remedies = getattr(link_pins, "remedies", None) or {}
warnings: list[str] = []
unverified: list[str] = []
# A link whose variable another link of the chain shares is never offered
@@ -1596,15 +1620,17 @@ def _chain_link_findings(chain, link_pins) -> tuple:
continue
if declared is None:
unverified.append(holder)
- alone = pin_product_key(holder) not in shared
- offered = (f"a sibling checkout, {pin_source_env_name(holder)}, or"
- if alone else "a sibling checkout or")
+ remedy = remedies.get(pin_product_key(holder))
+ if remedy is None:
+ variable = ("" if pin_product_key(holder) in shared
+ else f", {pin_source_env_name(holder)},")
+ remedy = (f"a sibling checkout{variable} or --link-source "
+ f"{holder}= would let it be read")
warnings.append(
f"{CHAIN_UNVERIFIED}: {holder} declaring a pin on {held} "
f"is a fact in {holder}'s own tree, which is not "
- f"reachable here ({offered} --link-source "
- f"{holder}= would let it be read). The recorded "
- "chain still classifies.")
+ f"reachable here ({remedy}). The recorded chain still "
+ "classifies.")
continue
if pin_product_key(held) not in {pin_product_key(pin)
for pin in declared}:
@@ -2846,6 +2872,22 @@ def _os_reason(exc: BaseException) -> str:
return getattr(exc, "strerror", None) or str(exc)
+def shown_path(path) -> str:
+ """A path as a line about it names it: as it is, unless a character in
+ it is not printable (`str.isprintable`), and then as Python's `repr`
+ spells it -- `\\x1b`, `\\n`, `\\r`, and `\\udcff` for a byte that is not
+ UTF-8, as `sys.argv` keeps one (#610).
+
+ THE SPELLING `pin-source-unreadable` GIVES A `--pin-source` (#339):
+ `shape_materialize.shown_source` is this function. Printed raw, an ESC
+ in a path reached the terminal, and a line break or a carriage return
+ began a line of the path's choosing. A printable path is named byte for
+ byte as it always was.
+ """
+ text = str(path)
+ return text if text.isprintable() else repr(text)
+
+
def link_manifest_lookup(path) -> tuple[Path | None, str | None]:
"""`(manifest, None)` where `path` can answer as a link's tree, else
`(None, why)`. `` -> `/project.yaml`; a file is taken as the
@@ -2898,32 +2940,39 @@ def link_declarations(manifest_path, key: str = CHAIN_LINK_DECLARED_BY
iterating it as one read `openDox` as the characters `D`, `e`, `n`, `o`,
`p`, `x` and ended the run in a `TypeError` for `5` (#338). An absent or
empty `key`, and a blank entry in the list, declare nothing, as ever.
+
+ THE MANIFEST'S PATH IN `why` IS `shown_path`'S (#610). It is the
+ scaffold's `--pin-source`, or a `--link-source`, with `/project.yaml`
+ after it, and the scaffold prints `why` in its `declared-unverified`
+ WARNING: a `--pin-source` holding an ESC put a raw `\\x1b[31m` on
+ stderr. A printable path is the bytes it always was.
"""
path, why = link_manifest_lookup(manifest_path)
if path is None:
return None, why
+ named = shown_path(path)
try:
data = load_yaml(path)
except Refusal as exc:
cause = exc.__cause__
if isinstance(cause, OSError):
- return None, f"{path}: {_os_reason(cause)}"
- return None, exc.detail
+ return None, f"{named}: {_os_reason(cause)}"
+ return None, exc.detail.replace(str(path), named)
except (YamlError, OSError, UnicodeDecodeError) as exc:
- return None, f"{path}: {exc}"
+ return None, f"{named}: {exc}"
if not isinstance(data, dict):
- return None, f"{path}: not a mapping"
+ return None, f"{named}: not a mapping"
declared = data.get(key)
if declared is None:
return set(), None
if not isinstance(declared, list):
- return None, f"{path}: {key} is not a list"
+ return None, f"{named}: {key} is not a list"
pins: set[str] = set()
for pin in declared:
if pin is None or pin == "":
continue
if not isinstance(pin, str):
- return None, f"{path}: {key} holds an entry that is not text"
+ return None, f"{named}: {key} holds an entry that is not text"
pins.add(repo_basename(pin))
return pins, None
@@ -2996,11 +3045,22 @@ class LinkPins(dict):
it, which names the reason instead of saying the tree is "not reachable
here". Only a caller that asks for one gets one, so every other
reader's warning is exactly the text it always was.
+
+ `remedies`, `{pin_product_key(link): what would let its tree be read}`,
+ is the sources a caller that reads OTHER sources than the naming
+ validator's has to offer for a tree that is not reachable (#610, #444).
+ That warning offers "a sibling checkout, SHAPE_PIN_SOURCE_, or
+ --link-source =", and the scaffold reads no sibling checkout
+ and has no `--link-source`, so it names its own here. A link with no
+ entry, and every link for a caller that gives none, gets the naming
+ validator's words, as ever.
"""
- def __init__(self, found=(), unreadable: dict | None = None) -> None:
+ def __init__(self, found=(), unreadable: dict | None = None,
+ remedies: dict | None = None) -> None:
super().__init__(found)
self.unreadable: dict[str, str] = dict(unreadable or {})
+ self.remedies: dict[str, str] = dict(remedies or {})
def link_pins_from_trees(links, root: Path | None = None,
@@ -3008,7 +3068,8 @@ def link_pins_from_trees(links, root: Path | None = None,
default_source=None,
key: str = CHAIN_LINK_DECLARED_BY, *,
tried: dict | None = None,
- with_reasons: bool = False) -> dict[str, set]:
+ with_reasons: bool = False,
+ remedies: dict | None = None) -> dict[str, set]:
"""`{link: the pins ITS manifest declares}` for the links that answered.
A link whose tree is not on the disk is simply ABSENT from the mapping,
@@ -3026,6 +3087,11 @@ def link_pins_from_trees(links, root: Path | None = None,
line of its own for it (the scaffold) can have the warning say so. It is
off for the callers that print the reason themselves, and for the rest.
+ `remedies` returns a `LinkPins` too, carrying them: what would let each
+ link's tree be read, in the caller's own sources, for the warning about
+ a tree that is not reachable (#610). Given without `with_reasons`, the
+ `LinkPins` remembers no reason, as the plain mapping does not.
+
A link whose `SHAPE_PIN_SOURCE_` another of `links` names too is
not read through it (#275): one tree would answer for two links, and a
chain verified against the wrong tree is worse than one left
@@ -3071,7 +3137,9 @@ def link_pins_from_trees(links, root: Path | None = None,
unreadable[pin_product_key(name)] = why
continue
found[pin_product_key(name)] = pins
- return LinkPins(found, unreadable) if with_reasons else found
+ if not (with_reasons or remedies):
+ return found
+ return LinkPins(found, unreadable if with_reasons else None, remedies)
def find_repo_root(start: Path) -> Path:
diff --git a/scripts/shape_materialize.py b/scripts/shape_materialize.py
index 351ab603..1ac10ea9 100644
--- a/scripts/shape_materialize.py
+++ b/scripts/shape_materialize.py
@@ -68,6 +68,9 @@
GH_TREE_API, NEUTRAL_PRODUCT_OWNER, PIN_SOURCE_ENV_PREFIX,
pin_product_key, pin_source_env_collisions, tree_digest,
)
+# How a `--pin-source` that is not printable is named, the one spelling the
+# scaffold's chain WARNING gives it too (#610).
+from repo_shape import shown_path # noqa: E402
SHAPE_REPOSITORY = "opensoft/openRepoShape"
@@ -2749,9 +2752,12 @@ def shown_source(source) -> str:
Python's `repr` spells it -- `\\x1b`, `\\n`, `\\r`, and `\\udcff` for a
byte that is not UTF-8, as `sys.argv` keeps one -- which is how every
other value a refusal here names is spelled (`--pin {raw!r}`). The
- question `adopt-project.py`'s `_shown` asks of a path it prints."""
- text = str(source)
- return text if text.isprintable() else repr(text)
+ question `adopt-project.py`'s `_shown` asks of a path it prints.
+
+ It IS `repo_shape.shown_path` (#610), which the scaffold's
+ `declared-unverified` WARNING spells the same `--pin-source` with, so
+ the two lines about one path cannot spell it two ways."""
+ return shown_path(source)
def _digest_from_pin_source(source: Path, commit: str) -> str:
diff --git a/scripts/validate-repository-naming.py b/scripts/validate-repository-naming.py
index b3bf76ed..cd16aa5e 100755
--- a/scripts/validate-repository-naming.py
+++ b/scripts/validate-repository-naming.py
@@ -108,6 +108,19 @@
DEFAULT_POLICY = Path(__file__).resolve().parents[1] / "contracts" / "repository-naming.yaml"
+def _shown(path) -> str:
+ """A path as a line about it names it: as it is, unless a character in
+ it is not printable (`str.isprintable`), and then as Python's `repr`
+ spells it (#610). It is `repo_shape.shown_path`, which spells the
+ manifest in a `link-source-unreadable` reason, so one line holds one
+ spelling. Written here rather than imported: this file is copied into
+ every project and may meet a `scripts/repo_shape.py` older than itself,
+ which has no such name, and the gate would end in an `ImportError` before
+ it classified a name."""
+ text = str(path)
+ return text if text.isprintable() else repr(text)
+
+
class Target:
"""One name to classify, with everything DECLARED about it.
@@ -147,9 +160,10 @@ class LinkSources:
That is `validate-pins.py`'s reading of `--pin-source` (#295), with a
chain link where that file has a declared pin, so the two copied tools
read `[PRODUCT=]PATH` one way OVER ASCII ONLY (#444). Outside it they part
- company: `PinSources` (`templates/assembly-root/scripts/validate-pins.py`
- `:831`, `:845`, `:848-849` and `:912`) still keys `--pin-source PRODUCT=`
- by `casefold()`, so `--pin-source openss=` answers for a declared `openß`
+ company: `templates/assembly-root/scripts/validate-pins.py` still keys
+ `--pin-source PRODUCT=` by `casefold()`, in `PinSources.__init__`, which
+ builds the table, and in `resolve_neutral_pin_source`, which looks a pin
+ up in it, so `--pin-source openss=` answers for a declared `openß`
where `--link-source openss=` no longer answers for the link `openß`. It
is left, and the issue leaves it as its own change, because a pin's digest
stops a wrong source from passing: `validate-pins.py` checks it, a wrong
@@ -346,8 +360,8 @@ def unread_lines(self) -> list[str]:
continue
path, how, why, links = self.unread[typed]
lines.append(
- f"link-source-unreadable: {path} ({how}) could not answer "
- f"for {', '.join(links)}: {why}"
+ f"link-source-unreadable: {_shown(path)} ({how}) could not "
+ f"answer for {', '.join(links)}: {why}"
+ "".join(f"\n {line}" for line in (
self.read_as_path(typed), self._also_a_directory(typed))
if line))
diff --git a/templates/assembly-root/AGENTS-shape.md b/templates/assembly-root/AGENTS-shape.md
index 4d8ce5e9..cb23566e 100644
--- a/templates/assembly-root/AGENTS-shape.md
+++ b/templates/assembly-root/AGENTS-shape.md
@@ -77,8 +77,13 @@ row: they are this project's own content, and yours to edit.
A contract the code READS but does not OWN lives in the SPEC leg. That was
ruled and measured on the MedxEHR conversion: `contracts/` moved to the spec
leg, and the code leg's tooling now takes `CONTRACTS_DIR` from the environment
-— the root Makefile exports `$(CURDIR)/spec/contracts` once — rather than each
-script guessing at `../`.
+rather than each script guessing at `../`. Where this root was ADOPTED, the
+shape's Makefile exports `CONTRACTS_DIR` once, as the `contracts/` of the spec
+leg at the path `project.yaml` mounts it, and `make contracts-dir` prints it.
+Where the source kept a Makefile of its own, that block waits in
+`shape/Makefile` until the two are merged. A SCAFFOLDED root's Makefile exports
+none: its legs start from the scaffold's seed, with no `contracts/`, so
+nothing reads across them yet.
## The five commands
diff --git a/templates/assembly-root/scripts/validate-pins.py b/templates/assembly-root/scripts/validate-pins.py
index f4000465..7556b621 100755
--- a/templates/assembly-root/scripts/validate-pins.py
+++ b/templates/assembly-root/scripts/validate-pins.py
@@ -898,6 +898,19 @@ def _check_shape_pin(root: Path, manifest: dict, report: Report) -> None:
report.note(f"{rel}: {len(files)} copied shape file(s) match their digests")
+def _shown(typed: str) -> str:
+ """A `--pin-source` value as a note names it: as typed, unless a
+ character in it is not printable (`str.isprintable`), and then as
+ Python's `repr` spells it, `'x\\x1b[31mRED'` (#610). It is the spelling
+ the scaffold's `pin-source-unreadable` gives a `--pin-source`
+ (`repo_shape.shown_path`), written here rather than imported: a root
+ whose `scripts/repo_shape.py` is older than this file has no such name,
+ and this gate would end in an `ImportError` before it checked a pin.
+ Printed raw, an ESC in the value reached the terminal as the control it
+ is."""
+ return typed if typed.isprintable() else repr(typed)
+
+
class PinSources:
"""Every `--pin-source` value AS TYPED, read against the neutral-product
pins `project.yaml` declares, and which of them a pin actually read.
@@ -972,12 +985,13 @@ def read_as_path(self, typed: str | None) -> str:
"""The sentence saying a value holding `=` was read as a bare PATH,
or `""` — what a person who meant `PRODUCT=PATH` and mistyped the
product needs told, since nothing else in the run names it. Nothing
- when no pin is declared: the note has already said so."""
+ when no pin is declared: the note has already said so. The key is
+ spelled as `_shown` spells the value (#610)."""
if typed is None or self.reading.get(typed) is not None \
or "=" not in typed or not self.declared:
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 declared neutral-product "
f"pin (declared: {', '.join(self.declared)}).")
@@ -1150,11 +1164,16 @@ def _also_a_directory(typed: str | None, sources: PinSources) -> str:
existing directory whole (#295): the keyed reading won, and this is how
to ask for the other one. `os.path.isdir`, which is False for every
`OSError` on every Python, where `Path.is_dir` before 3.14 raised a name
- too long out of the finding it was adding a line to."""
+ too long out of the finding it was adding a line to.
+
+ THE VALUE IS NAMED AS `_shown` NAMES IT, in the sentence and in the
+ command (#610): a value that is not printable is `repr`'d, as the "not
+ used" note names it."""
if typed is None or sources.reading.get(typed) is None \
or not os.path.isdir(typed):
return ""
- return (f"`{typed}` is also a directory; `--pin-source ./{typed}` "
+ named = _shown(typed)
+ return (f"`{named}` is also a directory; `--pin-source ./{named}` "
"reads it as one.")
@@ -1182,7 +1201,7 @@ def _recompute_neutral_pin_digest(root: Path, product: str, repository: str,
except Refusal as exc:
report.finding(
"neutral-pin-source-unreadable",
- f"{rel}: {source} ({how}) could not answer for "
+ f"{rel}: {_shown(str(source))} ({how}) could not answer for "
f"{repository} @ {commit}: {exc.detail}"
+ "".join(f"\n {line}" for line in (
sources.read_as_path(typed),
@@ -1340,13 +1359,16 @@ def _note_unused_pin_sources(sources: PinSources, report: Report) -> None:
recomputed, nothing is declared, or every pin had a more specific
answer. A value holding `=` that was read as a PATH says so, because
that is the mistyped key's only trace in the run.
+
+ "As typed" is `_shown`'s (#610): a value that is not printable, and the
+ later value that replaced it, are named as `repr` spells them.
"""
for typed, product in sources.reading.items():
if typed in sources.used:
continue
later = sources.replaced.get(typed)
if later is not None:
- why = f"the later `--pin-source {later}` replaced it"
+ why = f"the later `--pin-source {_shown(later)}` replaced it"
elif product is not None:
why = f"{product}'s digest was not recomputed from any source"
elif not sources.declared:
@@ -1356,7 +1378,7 @@ def _note_unused_pin_sources(sources: PinSources, report: Report) -> None:
"--pin-source, SHAPE_PIN_SOURCE_, or a checkout "
"beside this assembly root) or was not recomputed")
report.unused(" ".join(filter(None, (
- f"--pin-source {typed}: not used — {why}.",
+ f"--pin-source {_shown(typed)}: not used — {why}.",
sources.read_as_path(typed)))))
diff --git a/templates/family-root/scripts/validate-family.py b/templates/family-root/scripts/validate-family.py
index aa1aa5dd..519b71fc 100755
--- a/templates/family-root/scripts/validate-family.py
+++ b/templates/family-root/scripts/validate-family.py
@@ -115,12 +115,38 @@
#: `ls-tree` spell its mode.
GITLINK_MODE = b"160000"
-LOCKSTEP = (
- "THE LOCKSTEP RULE: a member's gitlink and its `members[].pin.commit` in "
- "`family.yaml` are ONE invariant. `scripts/family.py bump --member "
- " --to ` moves both in one commit; moving one alone is "
- "what this refuses."
-)
+
+def _lockstep(root: Path, project: str | None = None,
+ member_id: str | None = None) -> str:
+ """THE LOCKSTEP RULE, with the `bump` that keeps it, for this holder.
+
+ THE `bump` IT NAMED COULD NOT RUN (#610). The rule said `scripts/
+ family.py bump --member --to `, with no `--family-root`,
+ which `bump` requires, and no branch, though since #487 a real `bump`
+ refuses the holder's tracking branch, where a person stands after
+ `init`. So it names this holder by its path, as `member-gitlink-absent`'s
+ `add` does, and the `git -C switch -c family/bump-` that
+ makes a branch first, as #539's `_switch_line` gives `add` and `remove`.
+
+ `project` is the member a finding is about, its `--member`; the branch
+ word is its row's `id:`, or the project where the row records none, as
+ `family.py bump` suggests its own (which adds the commit's first twelve,
+ unknown here: the `--to` is the person's to choose). The closing count
+ names no member, since more than one may be out of step, and says
+ `PROJECT_PLACEHOLDER` in both places, as `_switch_line` does.
+ """
+ member = (PROJECT_PLACEHOLDER if project is None
+ else shlex.quote(project))
+ word = None if project is None else (member_id or project)
+ return (
+ "THE LOCKSTEP RULE: a member's gitlink and its `members[].pin.commit` "
+ f"in `{MANIFEST}` are ONE invariant. From a checkout of openRepoShape, "
+ f"`{PYTHON} scripts/family.py bump --family-root "
+ f"{shlex.quote(str(root))} --member {member} --to ` moves both "
+ "in one commit, on a branch other than the holder's tracking branch, "
+ f"which it refuses (`{_switch_line(root, 'bump', word)}` makes one); "
+ f"moving one alone is what this refuses. {BRANCH_LEFT}")
+
#: The findings THE LOCKSTEP RULE is about (#539): a member's gitlink and its
#: pin out of step, and the pin's digest of the commit they name. The closing
@@ -418,7 +444,8 @@ def _pin_commit(pin: dict, project: str, report: Report) -> str:
def _check_member_gitlink(root: Path, path: str, commit: str, project: str,
- gitlinks: dict[str, str], report: Report) -> None:
+ gitlinks: dict[str, str], report: Report,
+ rule: str) -> None:
"""The submodule gitlink this repository records at `path`, against the
pin — THE LOCKSTEP RULE. Split from `_check_member` for #137. Raises
`Refusal` when there is no gitlink at all: a row naming a member this
@@ -452,7 +479,7 @@ def _check_member_gitlink(root: Path, path: str, commit: str, project: str,
report.finding(
"member-gitlink-mismatch",
f"{path}: gitlink {gitlink} != {MANIFEST} pin {commit}\n "
- + LOCKSTEP)
+ + rule)
else:
report.note(f"{path}: gitlink == pin {commit[:12]}")
@@ -521,7 +548,7 @@ def _gitlink_in_another_case(path: str, commit: str, project: str,
def _check_member_tree(member: Path, pin: dict, path: str, commit: str,
- project: str, report: Report) -> None:
+ project: str, report: Report, rule: str) -> None:
"""Is `member`'s own object store readable at `commit`, and does its
digest match the pin? Split from `_check_member` for #137 — the two
questions were already one comment block ("the digest and the identity")
@@ -568,7 +595,7 @@ def _check_member_tree(member: Path, pin: dict, path: str, commit: str,
f"{project}: pin.tree_sha256 {recorded}\n"
f" recomputed at {commit[:12]} {actual}\n"
" The gitlink and the pin may agree on a commit whose bytes "
- f"are not the bytes the pin was written against.\n {LOCKSTEP}")
+ f"are not the bytes the pin was written against.\n {rule}")
else:
report.note(f"{path}: tree digest recomputes ({recorded[:12]}…)")
@@ -614,8 +641,11 @@ def _check_member(root: Path, row: dict, seen: dict, report: Report,
return
# ---- the gitlink -------------------------------------------------------
+ # THE LOCKSTEP RULE each finding below ends at names this member (#610).
+ rule = _lockstep(root, project, str(row.get("id") or project))
with _git_runs(f"the gitlink recorded at {path} could not be read"):
- _check_member_gitlink(root, path, commit, project, gitlinks, report)
+ _check_member_gitlink(root, path, commit, project, gitlinks, report,
+ rule)
# ---- the digest and the identity --------------------------------------
member = root / path
@@ -623,7 +653,7 @@ def _check_member(root: Path, row: dict, seen: dict, report: Report,
return
with _git_runs(f"{project}'s pinned commit and its tree digest could not "
f"be read in {path}"):
- _check_member_tree(member, pin, path, commit, project, report)
+ _check_member_tree(member, pin, path, commit, project, report, rule)
_check_member_identity(member, row, project, report)
@@ -1483,14 +1513,16 @@ def _check_members(root: Path, members: list, no_members: bool,
"wrong")
-def _run_checks(args: argparse.Namespace, report: Report) -> tuple[dict, list]:
+def _run_checks(args: argparse.Namespace,
+ report: Report) -> tuple[Path, dict, list]:
"""Everything `main` used to run inside its own single `try`.
Split from `main` for #137: lifting this whole body out from under
`main`'s `try` removes a level of nesting from every branch inside it,
which is where most of `main`'s cognitive complexity was. Raises
- `Refusal` exactly as `main` always caught it; returns `(manifest,
- members)`, which `main` still needs afterward for the final summary line.
+ `Refusal` exactly as `main` always caught it; returns `(root, manifest,
+ members)`, which `main` still needs afterward for the final summary
+ line, whose THE LOCKSTEP RULE names the holder (#610).
"""
root = find_repo_root(args.root or Path(__file__).resolve().parents[1])
manifest_path = root / MANIFEST
@@ -1523,7 +1555,7 @@ def _run_checks(args: argparse.Namespace, report: Report) -> tuple[dict, list]:
_check_members(root, members, args.no_members, report)
if not args.pins:
_check_shape_pin(root, manifest, report)
- return manifest, members
+ return root, manifest, members
def main(argv: list[str] | None = None) -> int:
@@ -1554,7 +1586,7 @@ def main(argv: list[str] | None = None) -> int:
report = Report()
try:
- manifest, members = _run_checks(args, report)
+ root, manifest, members = _run_checks(args, report)
except Refusal as exc:
print(str(exc), file=sys.stderr)
return 2
@@ -1570,7 +1602,7 @@ def main(argv: list[str] | None = None) -> int:
# alone.
rule = any(code in LOCKSTEP_CODES for code in report.codes)
print(f"\n{len(report.findings)} finding(s) in {MANIFEST}."
- + (f" {LOCKSTEP}" if rule else ""), file=sys.stderr)
+ + (f" {_lockstep(root)}" if rule else ""), file=sys.stderr)
return 1
print(f"family ok: {manifest.get('name')} ({manifest.get('id')}), "
f"{len(members)} member(s)")
diff --git a/tests/test_adopt_contracts_dir.py b/tests/test_adopt_contracts_dir.py
index aac4808f..34fe8298 100644
--- a/tests/test_adopt_contracts_dir.py
+++ b/tests/test_adopt_contracts_dir.py
@@ -50,6 +50,7 @@
sys.path.insert(0, str(REPO / "scripts"))
import shape_materialize # noqa: E402
from repo_shape import load_yaml # noqa: E402
+from test_pinned_copy_text import ADOPTED_SENTENCES # noqa: E402
needs_filter_repo = pytest.mark.skipif(
shutil.which("git-filter-repo") is None,
@@ -223,17 +224,18 @@ def test_plan_names_the_read_across_by_the_two_keys(
SPEC_PATH, CODE_PATH = "legs/spec", "impl/code"
-def adopt_with_legs_mounted_away(base: Path, *, by_hand: bool) -> dict:
- """One real adoption with both legs mounted away from their defaults, at
- SPEC_PATH and CODE_PATH, and a recursive clone of its split branch. Its
- own source: `execute` pushes the split branch into the repository it
- adopts.
+def adopt_with_legs_mounted_away(base: Path, *, by_hand: bool,
+ tree: dict = TREE) -> dict:
+ """One real adoption of `tree` with both legs mounted away from their
+ defaults, at SPEC_PATH and CODE_PATH, and a recursive clone of its split
+ branch. Its own source: `execute` pushes the split branch into the
+ repository it adopts.
`by_hand` plans with the DEFAULT paths and then edits `legs.spec_path` and
`legs.code_path` in the plan, as `MOUNT_REMEDIATION` and
`plan-leg-path-collides` ask a person to. Otherwise `plan` is given them.
"""
- source = make_source_repo(base / "Thing", TREE, edits=EDITS)
+ source = make_source_repo(base / "Thing", tree, edits=EDITS)
plan = base / "adoption-plan.yaml"
planned_follow_ups(
source, plan, *(("spec", "code") if by_hand else (SPEC_PATH, CODE_PATH)))
@@ -279,6 +281,19 @@ def adopted_by_hand(tmp_path_factory) -> dict:
tmp_path_factory.mktemp("contracts-dir-by-hand"), by_hand=True)
+#: A root `Makefile` the source keeps: the `root-project-makefile` rule leaves
+#: it in the root, and the shape's Makefile lands beside it as `shape/Makefile`.
+OWN_MAKEFILE = "build:\n\t@echo the source's own build\n"
+
+
+@pytest.fixture(scope="module")
+def adopted_with_its_own_makefile(tmp_path_factory) -> dict:
+ """The adoption of a source that keeps a root Makefile of its own."""
+ return adopt_with_legs_mounted_away(
+ tmp_path_factory.mktemp("contracts-dir-own-makefile"), by_hand=False,
+ tree={**TREE, "Makefile": OWN_MAKEFILE})
+
+
@needs_filter_repo
def test_the_adopted_makefile_exports_the_spec_legs_contracts(adopted):
"""The split's Makefile (the shape's, at the root: the source kept none)
@@ -308,6 +323,65 @@ def test_make_contracts_dir_prints_a_directory_that_exists(adopted):
assert (printed / POLICY).is_file()
+@needs_filter_repo
+def test_the_pinned_agent_file_is_true_of_the_adopted_makefile(adopted):
+ """L2-33 (#610). The adopted root's pinned `AGENTS-shape.md` said "the
+ root Makefile exports `$(CURDIR)/spec/contracts` once", while this
+ root's Makefile exports `$(CURDIR)/legs/spec/contracts`. It names no
+ path now: the `contracts/` of the spec leg at the path `project.yaml`
+ mounts it, and that is the path the Makefile exports."""
+ said = " ".join((adopted["clone"] / "AGENTS-shape.md").read_text(
+ encoding="utf-8").split())
+ assert "$(CURDIR)" not in said
+ assert "spec/contracts" not in said
+ assert ADOPTED_SENTENCES in said
+ (mounted,) = [str(leg["path"]) for leg in load_yaml(
+ adopted["clone"] / "project.yaml")["legs"] if leg["role"] == "spec"]
+ assert mounted == SPEC_PATH
+ assert contracts_dir_line(adopted["makefile"].decode("utf-8")) == (
+ f"CONTRACTS_DIR ?= $(CURDIR)/{mounted}/contracts")
+
+
+@needs_filter_repo
+@pytest.mark.skipif(shutil.which("make") is None or sys.platform == "win32",
+ reason="needs make, and a POSIX `$(CURDIR)` spelling")
+def test_the_pinned_agent_file_says_where_a_kept_makefile_leaves_the_block(
+ adopted_with_its_own_makefile):
+ """F1 (rv610, #634). A source that keeps a root Makefile of its own keeps
+ it AT THE ROOT, with no `CONTRACTS_DIR`, so `make contracts-dir` there
+ has no rule: the block waits in `shape/Makefile`. The pinned
+ `AGENTS-shape.md` said the root's Makefile exports `CONTRACTS_DIR` and
+ `make contracts-dir` prints it. It now says the SHAPE's Makefile does,
+ and that the block waits in `shape/Makefile` where the source kept its
+ own."""
+ clone = adopted_with_its_own_makefile["clone"]
+ # The source's own bytes, whatever line ending `write_text` gave them.
+ assert adopted_with_its_own_makefile["makefile"].decode(
+ "utf-8").splitlines() == OWN_MAKEFILE.splitlines()
+ block = (clone / "shape" / "Makefile").read_text(encoding="utf-8")
+ assert contracts_dir_line(block) == (
+ f"CONTRACTS_DIR ?= $(CURDIR)/{SPEC_PATH}/contracts")
+ at_the_root = subprocess.run(
+ ["make", "--no-print-directory", "-s", "contracts-dir"],
+ cwd=str(clone), capture_output=True, text=True, encoding="utf-8",
+ errors="replace", check=False)
+ assert at_the_root.returncode != 0, at_the_root.stdout
+ assert "No rule to make target 'contracts-dir'" in at_the_root.stderr, (
+ at_the_root.stderr)
+ # Where the block waits, the target prints the spec leg's `contracts/`.
+ in_the_shapes = subprocess.run(
+ ["make", "--no-print-directory", "-s", "-f", "shape/Makefile",
+ "contracts-dir"],
+ cwd=str(clone), capture_output=True, text=True, encoding="utf-8",
+ errors="replace", check=False)
+ assert in_the_shapes.returncode == 0, in_the_shapes.stderr
+ assert Path(in_the_shapes.stdout.strip()) == (
+ clone.resolve() / SPEC_PATH / "contracts")
+ said = " ".join((clone / "AGENTS-shape.md").read_text(
+ encoding="utf-8").split())
+ assert ADOPTED_SENTENCES in said
+
+
@needs_filter_repo
def test_the_pin_digests_the_rendered_makefile(adopted):
"""The block is the adoption's own append, so the shape pin records the
diff --git a/tests/test_naming_policy.py b/tests/test_naming_policy.py
index c229287c..023715d9 100644
--- a/tests/test_naming_policy.py
+++ b/tests/test_naming_policy.py
@@ -1988,19 +1988,25 @@ def test_scaffold_says_a_pin_source_whose_manifest_cannot_be_read_was_reached(
assert "--dry-run: nothing was created." in result.stdout
-def test_scaffold_keeps_its_old_warning_for_a_pin_source_with_no_manifest(
+def test_scaffold_offers_its_own_sources_for_a_pin_source_with_no_manifest(
tmp_path):
"""A `--pin-source` clone that holds no `project.yaml` is the ordinary
- absence, as it always was, and its warning is the bytes it always was:
- the scaffold changes only for a tree it reached and could not READ."""
+ absence, as it always was: the tree is "not reachable here", never
+ "reached". What would let it be read is the scaffold's own (#610). It
+ offered "a sibling checkout, SHAPE_PIN_SOURCE_OPENXDOX, or --link-source
+ openXdox=", the naming validator's three, and the scaffold has no
+ `--link-source` and reads no sibling checkout. It reads the variable,
+ and, for the first link, its `--pin-source`."""
result, _manifest = _scaffold_with_link_tree(tmp_path, None)
assert result.returncode == 0, result.stderr + result.stdout
assert ("WARNING declared-unverified: openXdox declaring a pin on openInk "
- "is a fact in openXdox's own tree, which is not reachable here (a "
- "sibling checkout, SHAPE_PIN_SOURCE_OPENXDOX, or --link-source "
- "openXdox= would let it be read). The recorded chain still "
- "classifies.\n") in result.stderr, result.stderr
+ "is a fact in openXdox's own tree, which is not reachable here "
+ "(SHAPE_PIN_SOURCE_OPENXDOX, or a --pin-source clone of openXdox "
+ "that holds its project.yaml, would let it be read). The recorded "
+ "chain still classifies.\n") in result.stderr, result.stderr
assert "was reached" not in result.stderr
+ assert "--link-source" not in result.stderr
+ assert "sibling checkout" not in result.stderr
def test_the_naming_validator_keeps_its_own_two_lines_for_a_tree_it_reached(
diff --git a/tests/test_pinned_copy_text.py b/tests/test_pinned_copy_text.py
new file mode 100644
index 00000000..344e71c2
--- /dev/null
+++ b/tests/test_pinned_copy_text.py
@@ -0,0 +1,790 @@
+# SPDX-License-Identifier: Apache-2.0
+"""The pinned shape copies' text, moved in ONE shape bump (#610, the
+checklist triage's D6), so every project re-pins once for v1.1.0.
+
+Four of the five lines are here; THE LOCKSTEP RULE's `bump` (L2-39) is in
+`tests/test_validate_family_branch_step.py`, beside the family holder it
+runs against, and the adopted half of L2-33 in
+`tests/test_adopt_contracts_dir.py`, beside the adoption it reads.
+
+ * L2-33. `AGENTS-shape.md` said "the root Makefile exports
+ `$(CURDIR)/spec/contracts` once". No scaffolded root's Makefile exports
+ anything (`make contracts-dir` has no rule), and an adoption exports the
+ spec leg where its plan mounts it (#522). The sentence is path-free now,
+ and the file stays free of placeholders: it is a VERBATIM copy.
+ * 576/2. The scaffold's `declared-unverified` WARNING offered "a sibling
+ checkout, SHAPE_PIN_SOURCE_, or --link-source =": the
+ naming validator's sources. The scaffold has no `--link-source` and
+ reads no sibling checkout, so it now offers what it reads, the variable
+ and, for the first link, its `--pin-source`. The naming validator keeps
+ its own words (`tests/test_link_source_chain_defaults.py`'s
+ `UNVERIFIED`, `tests/test_naming_policy.py`'s `NOT_REACHED`).
+ * L2-30. That WARNING named the `--pin-source` tree it reached raw, so an
+ ESC in the path reached stderr. It is spelled as `pin-source-unreadable`
+ spells it: `repr` where the path is not printable.
+ * L2-31. The root's `validate-pins.py` named an unused `--pin-source` as
+ typed in its "not used" note. It is spelled the same way.
+ * rv634r's R1 on #634. `repo_shape.git_out`'s `git-failed` detail, which a
+ root's `validate-pins.py` prints inside `neutral-pin-source-unreadable`,
+ named the folder git ran in as it was and passed git's own stderr
+ through, and git echoes the paths it reads. Both are spelled, in the
+ library, so no pinned script changes.
+
+No network and no GitHub: scaffolds dry-run, or into local bare remotes.
+"""
+
+from __future__ import annotations
+
+import ast
+import os
+import shutil
+import subprocess
+import sys
+
+import pytest
+
+from conftest import (REPO, SCAFFOLD, clear_ambient_pin_sources, git,
+ load_without_bytecode, run_script)
+from test_link_source_chain_defaults import ( # noqa: F401
+ VALIDATOR, manifest, naming, pinned, scaffold, tree, validate_pins,
+)
+
+sys.path.insert(0, str(REPO / "scripts"))
+from repo_shape import ( # noqa: E402
+ LinkPins, NamingPolicy, Refusal, git_out, link_declarations,
+ link_pins_from_trees, shown_path,
+)
+from shape_materialize import PLACEHOLDER_RE, shown_source # noqa: E402
+
+TEMPLATE_AGENTS = REPO / "templates" / "assembly-root" / "AGENTS-shape.md"
+TEMPLATE_PINS = (REPO / "templates" / "assembly-root" / "scripts" /
+ "validate-pins.py")
+TEMPLATE_FAMILY = (REPO / "templates" / "family-root" / "scripts" /
+ "validate-family.py")
+POLICY = REPO / "contracts" / "repository-naming.yaml"
+
+#: What the pinned `AGENTS-shape.md` says of `CONTRACTS_DIR` (L2-33), with its
+#: hard wrapping undone: of an ADOPTED root, whose Makefile is the shape's
+#: unless the source kept one of its own (rv610's F1 on #634: then the block
+#: waits in `shape/Makefile`, and `make contracts-dir` has no rule at the
+#: root), and of a SCAFFOLDED one, whose legs are the scaffold's seed (F5).
+ADOPTED_SENTENCES = (
+ "Where this root was ADOPTED, the shape's Makefile exports "
+ "`CONTRACTS_DIR` once, as the `contracts/` of the spec leg at the path "
+ "`project.yaml` mounts it, and `make contracts-dir` prints it. Where the "
+ "source kept a Makefile of its own, that block waits in `shape/Makefile` "
+ "until the two are merged.")
+SCAFFOLDED_SENTENCE = (
+ "A SCAFFOLDED root's Makefile exports none: its legs start from the "
+ "scaffold's seed, with no `contracts/`, so nothing reads across them "
+ "yet.")
+
+#: A Windows file name holds no control character, so neither does a path
+#: one could mean there (`tests/test_pin_source_unreadable.py`'s mark).
+CONTROL_ARGV = pytest.mark.skipif(
+ os.name == "nt",
+ reason="needs a path holding a control character, which a Windows file "
+ "name cannot hold")
+
+#: Names that print as something other than themselves: an ESC, and an ESC
+#: beginning a forged line.
+CONTROL_NAMES = [
+ pytest.param("x\x1b[31mRED", id="esc"),
+ pytest.param("x\x1b[31mRED\nWARNING forged-line: this line was not "
+ "printed by the tool\r", id="forged-line"),
+]
+
+
+def flat(path) -> str:
+ """`path`'s text with its hard wrapping undone."""
+ return " ".join(path.read_text(encoding="utf-8").split())
+
+
+def scaffold_dry_run(cwd, name: str, source, chain: str):
+ """`scaffold(...)` as a dry run into local remotes, recording `chain`."""
+ return scaffold(cwd, name, source, "--referent-chain", chain,
+ "--dry-run", "--local-remote-dir", str(cwd / "remotes"))
+
+
+def warnings_of(result) -> list[str]:
+ """The `declared-unverified` WARNING lines the scaffold printed."""
+ return [line for line in result.stderr.splitlines()
+ if line.startswith("WARNING declared-unverified: ")]
+
+
+# --- L2-33: the CONTRACTS_DIR sentence --------------------------------------
+
+def test_the_pinned_agent_file_names_no_leg_path_and_no_placeholder():
+ """The sentence is true whatever the spec leg's mount is: it names no
+ path, so neither `spec/contracts` nor `$(CURDIR)`, and the file is still
+ a verbatim copy with no `{{PLACEHOLDER}}` to render."""
+ text = TEMPLATE_AGENTS.read_text(encoding="utf-8")
+ assert "$(CURDIR)" not in text
+ assert "spec/contracts" not in text
+ assert PLACEHOLDER_RE.findall(text) == []
+ said = flat(TEMPLATE_AGENTS)
+ assert ("the code leg's tooling now takes `CONTRACTS_DIR` from the "
+ "environment rather than each script guessing at `../`.") in said
+
+
+def test_a_scaffolded_root_is_told_its_makefile_exports_none(project):
+ """THE ISSUE'S MEASUREMENT. A scaffolded root's Makefile has no
+ `CONTRACTS_DIR`, and its pinned `AGENTS-shape.md` said the root Makefile
+ exported `$(CURDIR)/spec/contracts`. It now says the scaffolded root's
+ exports none, and why."""
+ assert "CONTRACTS_DIR" not in (project / "Makefile").read_text(
+ encoding="utf-8")
+ said = flat(project / "AGENTS-shape.md")
+ assert "the root Makefile exports" not in said
+ assert SCAFFOLDED_SENTENCE in said
+ assert ADOPTED_SENTENCES in said
+
+
+def test_a_scaffolded_roots_legs_start_from_the_seed_with_no_contracts(
+ project):
+ """F5 (rv610, #634). The sentence said the legs "start empty", and the
+ scaffold seeds both: a README, the assistant instructions, a `.gitignore`
+ and the leg's own folder. What it does not seed is a `contracts/`, which
+ is what "nothing reads across them yet" rests on."""
+ for leg, folder in (("spec", "requirements"), ("code", "src")):
+ seeded = {entry.name for entry in (project / leg).iterdir()}
+ assert {"README.md", "AGENTS.md", "CLAUDE.md", ".gitignore",
+ folder} <= seeded, (leg, sorted(seeded))
+ assert "contracts" not in seeded, (leg, sorted(seeded))
+
+
+@pytest.mark.skipif(shutil.which("make") is None or sys.platform == "win32",
+ reason="needs make")
+def test_make_has_no_contracts_dir_in_a_scaffolded_root(project):
+ """What the sentence says of a scaffolded root, asked of `make`."""
+ made = subprocess.run(["make", "--no-print-directory", "contracts-dir"],
+ cwd=str(project), capture_output=True, text=True,
+ encoding="utf-8", errors="replace", check=False)
+ assert made.returncode != 0, made.stdout
+ assert "No rule to make target 'contracts-dir'" in made.stderr, (
+ made.stderr)
+
+
+# --- 576/2: the scaffold offers the sources it reads -------------------------
+
+def test_a_later_link_is_offered_its_variable_alone(tmp_path):
+ """`--pin-source` is the first link's tree alone (#441), so for a later
+ link the scaffold reads `SHAPE_PIN_SOURCE_` and nothing else: the
+ WARNING offers that, and neither `--link-source`, which the scaffold
+ does not take, nor a sibling checkout, which it never reads."""
+ result = scaffold_dry_run(tmp_path, "codexInk",
+ tree(tmp_path / "openXdox", "openYdox"),
+ "openXdox,openYdox,openInk")
+ assert result.returncode == 0, result.stderr + result.stdout
+ assert warnings_of(result) == [
+ "WARNING declared-unverified: openYdox declaring a pin on openInk is "
+ "a fact in openYdox's own tree, which is not reachable here "
+ "(SHAPE_PIN_SOURCE_OPENYDOX would let it be read). The recorded "
+ "chain still classifies."]
+ assert "--link-source" not in result.stderr
+ assert "sibling checkout" not in result.stderr
+
+
+def test_the_variable_the_warning_offers_reads_the_later_link(tmp_path):
+ """The remedy is one the scaffold HAS: the variable it offers, set to
+ `openYdox`'s tree, verifies the link, and no WARNING is printed."""
+ later = tree(tmp_path / "trees" / "openYdox", "openInk")
+ source = tree(tmp_path / "openXdox", "openYdox")
+ result = run_script(
+ SCAFFOLD, "--org", "codexSoft", "--project", "codexInk",
+ "--elected-by", "Test Human", "--elected-on", "2026-09-05",
+ "--pin", f"openXdox@{_committed(source)}", "--pin-source",
+ str(source), "--referent-chain", "openXdox,openYdox,openInk",
+ "--dry-run", "--local-remote-dir", str(tmp_path / "remotes"),
+ cwd=tmp_path, env={"SHAPE_PIN_SOURCE_OPENYDOX": str(later)})
+ assert result.returncode == 0, result.stderr + result.stdout
+ assert warnings_of(result) == []
+ assert "declared pin chain openXdox → openYdox → openInk →" in (
+ result.stdout), result.stdout
+
+
+def _committed(source) -> str:
+ """`source` made a repository with one commit, and that commit."""
+ git("init", "-q", "-b", "main", cwd=source)
+ git("add", "-A", cwd=source)
+ git("commit", "-qm", "seed", cwd=source)
+ return git("rev-parse", "HEAD", cwd=source).stdout.strip()
+
+
+@pytest.fixture(scope="module")
+def scaffold_module():
+ """`scaffold-project.py`, loaded for `_link_remedies`."""
+ return load_without_bytecode(SCAFFOLD, "scaffold_pinned_copy_text")
+
+
+def test_each_holder_is_offered_the_scaffolds_own_sources(scaffold_module):
+ """In this process, a chain of three: the first link is offered its
+ variable and the `--pin-source` clone, a later holder its variable,
+ and the chain's end, which holds no pin, nothing."""
+ assert scaffold_module._link_remedies(
+ ("openXdox", "openYdox", "openInk")) == {
+ "openxdox": "SHAPE_PIN_SOURCE_OPENXDOX, or a --pin-source clone of "
+ "openXdox that holds its project.yaml, would let it be "
+ "read",
+ "openydox": "SHAPE_PIN_SOURCE_OPENYDOX would let it be read"}
+
+
+@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_offered_to_neither_link(scaffold_module, first,
+ keyed):
+ """A hand-written chain whose two holders share one variable (#275):
+ the scaffold reads it for neither, so the first is offered its
+ `--pin-source` alone, and the second, which has no other source here,
+ is told so rather than offered one the scaffold would not read.
+
+ The MIXED-CASE pair is rv610's F4 on #634 (mutant B1): whether a link
+ shares its variable is asked of its `pin_product_key`, the name its pin
+ file carries, and not of the link as typed. Every name here was
+ lowercase, so `link in shared` passed for `pin_product_key(link) in
+ shared`, and `Open-X` was offered a variable the scaffold does not read
+ for it. The remedy is keyed by `pin_product_key` too, as the chain's
+ own lookup is (#444)."""
+ assert scaffold_module._link_remedies(
+ (first, "open_x", "openInk")) == {
+ keyed: f"a --pin-source clone of {first} that holds its "
+ "project.yaml would let it be read",
+ "open_x": "the scaffold reads no source for it: SHAPE_PIN_SOURCE_"
+ "OPEN_X is another link's too, and --pin-source is the "
+ "first link's tree alone"}
+
+
+def test_a_caller_s_remedies_reach_the_warning_and_no_one_else_s(
+ tmp_path, monkeypatch):
+ """`link_pins_from_trees(..., remedies=)` hands them to the WARNING for
+ the link they name; a link they do not name, and a reader that passes
+ none, keep the naming validator's words."""
+ clear_ambient_pin_sources(monkeypatch)
+ policy = NamingPolicy.load(POLICY)
+ chain = ("openXdox", "openYdox", "openInk")
+ found = link_pins_from_trees(chain, None,
+ remedies={"openydox": "OURS would do"})
+ assert type(found) is LinkPins
+ assert found == {}
+ assert found.unreadable == {}
+ said = policy.resolve_referent("codexInk", {"openXdox"}, chain,
+ found).warnings
+ assert said[0].endswith(
+ "which is not reachable here (a sibling checkout, SHAPE_PIN_SOURCE_"
+ "OPENXDOX, or --link-source openXdox= would let it be read). "
+ "The recorded chain still classifies.")
+ assert said[1].endswith(
+ "which is not reachable here (OURS would do). The recorded chain "
+ "still classifies.")
+ plain = link_pins_from_trees(chain, None)
+ assert type(plain) is dict
+ assert "OURS" not in " ".join(policy.resolve_referent(
+ "codexInk", {"openXdox"}, chain, plain).warnings)
+
+
+def test_two_pin_files_one_casefold_are_each_offered_their_own(
+ scaffold_module, monkeypatch):
+ """`openß` and `openss` are two pin files, so a chain may name both
+ (#444), and the variable they share, `SHAPE_PIN_SOURCE_OPENSS`, is read
+ for neither (#275). Keyed by `casefold()` (rv610's fix-list item 7 on
+ #634), `_link_remedies` wrote ONE entry for the pair, the later link's,
+ and `openß`, the first link, whose `--pin-source` the scaffold does
+ read, was told the scaffold reads no source for it. Keyed by
+ `pin_product_key`, as the chain's lookup is, each holder's WARNING
+ offers its own sources."""
+ clear_ambient_pin_sources(monkeypatch)
+ sharp, ss = "openß", "openss"
+ chain = (sharp, ss, "openInk")
+ remedies = scaffold_module._link_remedies(chain)
+ assert remedies == {
+ sharp: f"a --pin-source clone of {sharp} that holds its "
+ "project.yaml would let it be read",
+ ss: "the scaffold reads no source for it: SHAPE_PIN_SOURCE_OPENSS "
+ "is another link's too, and --pin-source is the first link's "
+ "tree alone"}
+ found = link_pins_from_trees(chain, None, remedies=remedies)
+ resolution = NamingPolicy.load(POLICY).resolve_referent(
+ "codexInk", {sharp}, chain, found)
+ assert resolution.status == "declared-unverified"
+ assert [line.partition("which is not reachable here ")[2]
+ for line in resolution.warnings] == [
+ f"({remedies[sharp]}). The recorded chain still classifies.",
+ f"({remedies[ss]}). The recorded chain still classifies."]
+
+
+def test_remedies_without_reasons_remember_no_reason(tmp_path, monkeypatch):
+ """A tree that WAS reached and cannot be read: with `with_reasons` the
+ `LinkPins` keeps why, and with `remedies` alone it does not, as a plain
+ mapping does not."""
+ clear_ambient_pin_sources(monkeypatch)
+ source = manifest(tmp_path / "openXdox", "neutral_product_pins: 5\n")
+ chain = ("openXdox", "openInk")
+ asked = link_pins_from_trees(chain, None, None, source,
+ with_reasons=True, remedies={"x": "y"})
+ assert set(asked.unreadable) == {"openxdox"}
+ assert asked.remedies == {"x": "y"}
+ alone = link_pins_from_trees(chain, None, None, source,
+ remedies={"x": "y"})
+ assert alone.unreadable == {}
+ assert alone.remedies == {"x": "y"}
+
+
+# --- L2-30: the tree the WARNING names is spelled ----------------------------
+
+@CONTROL_ARGV
+@pytest.mark.parametrize("name", CONTROL_NAMES)
+def test_the_scaffold_spells_a_pin_source_it_could_not_read(tmp_path, name):
+ """THE ISSUE'S MEASUREMENT: `--pin-source` at `x[31mRED`, whose
+ `project.yaml` holds `neutral_product_pins: 5`, exited 0 with a raw ESC
+ in the WARNING on stderr. The tree is named as `pin-source-unreadable`
+ names one, so no ESC and no line of the path's own reach stderr."""
+ source = manifest(tmp_path / "trees" / name,
+ "schema_version: 1\nneutral_product_pins: 5\n")
+ result = scaffold_dry_run(tmp_path, "codexInk", source,
+ "openXdox,openInk")
+ assert result.returncode == 0, result.stderr + result.stdout
+ assert "\x1b" not in result.stderr + result.stdout
+ assert not [line for line in result.stderr.splitlines()
+ if line.startswith("WARNING forged-line")], result.stderr
+ shown = repr(str(source / "project.yaml"))
+ assert warnings_of(result) == [
+ "WARNING declared-unverified: openXdox declaring a pin on openInk is "
+ "a fact in openXdox's own tree, which was reached but could not be "
+ f"read as a declaration ({shown}: neutral_product_pins is not a "
+ "list). The recorded chain still classifies."]
+
+
+#: Each way a manifest the lookup reached cannot answer, and the words after
+#: its path.
+UNANSWERED = [
+ pytest.param(b"- openInk\n", "not a mapping", id="not-a-mapping"),
+ pytest.param(b"neutral_product_pins: 5\n",
+ "neutral_product_pins is not a list", id="not-a-list"),
+ pytest.param(b"neutral_product_pins:\n - 5\n",
+ "neutral_product_pins holds an entry that is not text",
+ id="not-text"),
+ pytest.param(b"neutral_product_pins: [openInk\n",
+ "not a flow collection: '[openInk'", id="does-not-parse"),
+ pytest.param(b"neutral_product_pins: \xff\n",
+ "not UTF-8 at byte 22 (invalid start byte)",
+ id="not-utf-8"),
+]
+
+
+@CONTROL_ARGV
+@pytest.mark.parametrize("body,said", UNANSWERED)
+@pytest.mark.parametrize("name", ["plain", *CONTROL_NAMES])
+def test_every_reason_names_the_manifest_spelled(tmp_path, body, said, name):
+ """`link_declarations` names the manifest the same way in every reason
+ it gives: as it is where it is printable, and as `repr` spells it where
+ it is not, including the reason the YAML reader wrote itself."""
+ tree_path = tmp_path / name
+ tree_path.mkdir()
+ (tree_path / "project.yaml").write_bytes(body)
+ pins, why = link_declarations(tree_path)
+ assert pins is None
+ path = str(tree_path / "project.yaml")
+ assert why == f"{shown_path(path)}: {said}"
+ assert why.startswith(path if name == "plain" else repr(path))
+ assert "\x1b" not in why
+
+
+#: A file mode denies nothing to root, and Windows has no mode bits for
+#: `chmod` to clear.
+UNOPENABLE = pytest.mark.skipif(
+ os.name == "nt" or getattr(os, "geteuid", lambda: 1)() == 0,
+ reason="needs a POSIX file mode that denies reading, which root is not "
+ "stopped by")
+
+
+@UNOPENABLE
+@pytest.mark.parametrize("name", ["plain", *CONTROL_NAMES])
+def test_a_manifest_it_cannot_open_is_named_spelled(tmp_path, name):
+ """The one reason that is the system's own: a manifest this user may not
+ read is named the same way, beside `strerror`."""
+ tree_path = tmp_path / name
+ tree_path.mkdir()
+ path = tree_path / "project.yaml"
+ path.write_bytes(b"neutral_product_pins: []\n")
+ path.chmod(0)
+ try:
+ pins, why = link_declarations(tree_path)
+ finally:
+ path.chmod(0o600)
+ assert pins is None
+ assert why == f"{shown_path(str(path))}: Permission denied"
+ assert "\x1b" not in why
+
+
+@pytest.mark.parametrize("path", [
+ "plain/dir", "x\x1b[31mRED", "a\nb", "a\rb", "café", "\udcff",
+ "tab\there", "",
+])
+def test_the_scaffolds_two_lines_spell_a_pin_source_one_way(path):
+ """`pin-source-unreadable` and the chain WARNING name one `--pin-source`
+ with one function: printable as it is, anything else as `repr`."""
+ assert shown_source(path) == shown_path(path)
+ assert shown_path(path) == (path if path.isprintable() else repr(path))
+
+
+# --- L2-31: validate-pins.py's "not used" note -------------------------------
+
+#: Why the root's gate did not use a bare value a keyed one answered for.
+MORE_SPECIFIC = ("each declared pin had a more specific source (a keyed "
+ "--pin-source, SHAPE_PIN_SOURCE_, or a checkout "
+ "beside this assembly root) or was not recomputed")
+
+
+def notes(result) -> list[str]:
+ """The `--pin-source` notes the root's gate printed."""
+ return [line.strip() for line in result.stdout.splitlines()
+ if line.strip().startswith("note --pin-source ")]
+
+
+@CONTROL_ARGV
+@pytest.mark.parametrize("name", CONTROL_NAMES)
+def test_an_unused_pin_source_is_named_spelled(pinned, tmp_path, name):
+ """A bare value every pin had a more specific source for: the note named
+ it as typed, and an ESC in it reached the terminal (the issue's
+ measurement on a freshly scaffolded root)."""
+ typed = str(tmp_path / name)
+ result = validate_pins(pinned, "--pin-source",
+ f"openXdox={pinned['product']}",
+ "--pin-source", typed)
+ assert result.returncode == 0, result.stdout + result.stderr
+ assert "\x1b" not in result.stdout + result.stderr
+ assert notes(result) == [
+ f"note --pin-source {typed!r}: not used — {MORE_SPECIFIC}."]
+
+
+@CONTROL_ARGV
+def test_a_replaced_pin_source_and_its_replacement_are_spelled(pinned,
+ tmp_path):
+ """The note for a value a later one replaced names both."""
+ first = str(tmp_path / "x\x1b[31mRED")
+ later = str(tmp_path / "y\x1b[32mGREEN")
+ result = validate_pins(pinned, "--pin-source", first,
+ "--pin-source", later,
+ "--pin-source", str(pinned["product"]))
+ assert result.returncode == 0, result.stdout + result.stderr
+ assert "\x1b" not in result.stdout + result.stderr
+ assert (f"note --pin-source {first!r}: not used — the later "
+ f"`--pin-source {later!r}` replaced it.") in notes(result), (
+ result.stdout)
+
+
+def test_a_key_read_as_a_path_is_spelled(pinned):
+ """A value holding `=` whose key is no declared pin is read as a PATH,
+ and the note's second sentence names the key: spelled too."""
+ key = "o\x1b[1m"
+ typed = f"{key}=x"
+ result = validate_pins(pinned, "--pin-source",
+ f"openXdox={pinned['product']}",
+ "--pin-source", typed)
+ assert result.returncode == 0, result.stdout + result.stderr
+ assert "\x1b" not in result.stdout + result.stderr
+ assert notes(result) == [
+ f"note --pin-source {typed!r}: not used — {MORE_SPECIFIC}. It was "
+ "read as a PATH: "
+ f"`{key!r}` is not a declared neutral-product pin "
+ "(declared: openXdox)."]
+
+
+def test_a_printable_pin_source_is_named_as_typed(pinned, tmp_path):
+ """The control: a printable value is the bytes it always was."""
+ typed = str(tmp_path / "unused")
+ result = validate_pins(pinned, "--pin-source",
+ f"openXdox={pinned['product']}",
+ "--pin-source", typed)
+ assert result.returncode == 0, result.stdout + result.stderr
+ assert notes(result) == [
+ f"note --pin-source {typed}: not used — {MORE_SPECIFIC}."]
+
+
+# --- rv610's extra find (a): validate-pins.py's two other raw prints ---------
+
+@CONTROL_ARGV
+@pytest.mark.parametrize("name", CONTROL_NAMES)
+def test_an_unreadable_pin_source_and_its_directory_twin_are_spelled(
+ pinned, tmp_path, name):
+ """The `neutral-pin-source-unreadable` FINDING named the source it asked
+ as typed, and the line after it, for a keyed value that is ALSO a
+ directory whole (#295), named the value as typed twice: in the sentence
+ and in the `./` command. An ESC in either reached the terminal, and a
+ line break began a line of the value's choosing. Both are spelled as
+ L2-31's note spells a value, by the same local `_shown`.
+
+ Run from a directory where `openXdox=` exists whole, so the keyed
+ reading wins and the other one is named."""
+ typed = f"openXdox={name}"
+ (tmp_path / typed).mkdir()
+ result = run_script(pinned["root"] / "scripts" / "validate-pins.py",
+ "--pin-source", typed, cwd=tmp_path)
+ assert result.returncode == 1, result.stdout + result.stderr
+ everything = result.stdout + result.stderr
+ assert "\x1b" not in everything, everything
+ assert not [line for line in everything.splitlines()
+ if line.startswith("WARNING forged-line")], everything
+ assert ("FINDING neutral-pin-source-unreadable: contracts/openxdox-pin."
+ f"yaml: {name!r} (--pin-source openXdox=...) could not answer "
+ "for ") in result.stderr, result.stderr
+ assert (f"`{typed!r}` is also a directory; `--pin-source ./{typed!r}` "
+ "reads it as one.") in result.stderr, result.stderr
+
+
+def test_a_printable_unreadable_pin_source_is_named_as_typed(pinned,
+ tmp_path):
+ """The control: a printable value is the bytes it always was."""
+ typed = "openXdox=x/openXdox"
+ (tmp_path / typed).mkdir(parents=True)
+ result = run_script(pinned["root"] / "scripts" / "validate-pins.py",
+ "--pin-source", typed, cwd=tmp_path)
+ assert result.returncode == 1, result.stdout + result.stderr
+ assert (f"FINDING neutral-pin-source-unreadable: contracts/openxdox-pin."
+ f"yaml: {os.path.join('x', 'openXdox')} (--pin-source "
+ "openXdox=...) could not answer for ") in result.stderr
+ assert ("`openXdox=x/openXdox` is also a directory; `--pin-source "
+ "./openXdox=x/openXdox` reads it as one.") in result.stderr
+
+
+# --- rv634r's R1 (#634): the git detail validate-pins.py prints -------------
+
+#: What a `--pin-source` can be NAMED, each a way text reaches a terminal that
+#: a plain name does not: an ESC, a line break beginning a forged line, a
+#: carriage return, and an OSC title sequence ended by BEL.
+SOURCE_NAMES = [
+ pytest.param("x\x1b[31mRED", id="esc"),
+ pytest.param("a\nWARNING forged-line: pwned\nb", id="forged-line"),
+ pytest.param("a\rWARNING forged-line: pwned", id="cr"),
+ pytest.param("t\x1b]0;owned\x07z", id="bel"),
+]
+
+#: A commit no repository here holds, for the stub git's line.
+NO_SUCH_COMMIT = "0123456789abcdef0123456789abcdef01234567"
+
+#: A `git` that is not on this machine: it answers every command with the
+#: bytes beside it on stderr and exit 128. git 2.43 replaces an ESC, a CR
+#: and a BEL in what it echoes with `?`, and passes a line break and a C1
+#: control (U+0080 to U+009F) through, so only a stub holds the rest to the
+#: library, whatever git the machine has.
+STUB_GIT = pytest.mark.skipif(
+ os.name == "nt", reason="the stub git is a `sh` script")
+
+
+def reached_the_terminal(result) -> list[str]:
+ """What a run printed that a terminal would not show as it is, and what a
+ person would read as a line the tool did not print: each character but a
+ line break that `str.isprintable` refuses, and each line that begins
+ with the forged text. `run_script` reads text with universal newlines,
+ so a raw CR arrives as a line break and the forged line it began is what
+ shows it."""
+ everything = result.stdout + result.stderr
+ return [
+ *(repr(char) for char in sorted(set(everything))
+ if char != "\n" and not char.isprintable()),
+ *(line for line in everything.splitlines()
+ if line.lstrip().startswith("WARNING forged-line")),
+ ]
+
+
+def stub_git(tmp_path, monkeypatch, stderr: bytes):
+ """A `git` first on PATH that prints `stderr` and exits 128."""
+ stub = tmp_path / "stub-git-bin"
+ stub.mkdir()
+ (stub / "git").write_text(
+ '#!/bin/sh\ncat "$(dirname "$0")/stderr.bin" >&2\nexit 128\n',
+ encoding="utf-8")
+ (stub / "git").chmod(0o755)
+ (stub / "stderr.bin").write_bytes(stderr)
+ monkeypatch.setenv("PATH", f"{stub}{os.pathsep}{os.environ['PATH']}")
+
+
+@CONTROL_ARGV
+@pytest.mark.parametrize("name", SOURCE_NAMES)
+def test_a_pin_source_that_is_not_a_repository_is_named_spelled(pinned,
+ tmp_path, name):
+ """THE FINDING'S LAST WORD. `--pin-source `, where `` exists and
+ is no repository, printed `exc.detail`, and `git_out` built that with the
+ folder it ran in as it was: an ESC reached stderr, and a line break
+ began a line of the folder's choosing. rv634r measured it on the root
+ this fixture scaffolds."""
+ source = tmp_path / f"bare-{name}"
+ source.mkdir()
+ commit = git("rev-parse", "HEAD", cwd=pinned["product"]).stdout.strip()
+ result = validate_pins(pinned, "--pin-source", str(source))
+ assert result.returncode == 1, result.stdout + result.stderr
+ assert reached_the_terminal(result) == [], result.stderr
+ assert (f"@ {commit}: `git ls-tree -r -z {commit}` in "
+ f"{shown_path(str(source))} exited 128: fatal: ") in (
+ result.stderr), result.stderr
+
+
+@CONTROL_ARGV
+@pytest.mark.parametrize("name", SOURCE_NAMES)
+def test_what_git_says_of_a_pin_source_is_spelled_too(pinned, tmp_path, name):
+ """A `.git` that is not a gitfile: git answers `invalid gitfile format:
+ /.git`, so what it printed holds the folder's name, and a git
+ passes a line break through. Spelling the folder where `git_out` names
+ it is not enough: the second copy, in git's own words, began the forged
+ line."""
+ source = tmp_path / f"broken-{name}"
+ source.mkdir()
+ (source / ".git").write_text("this is not a gitfile\n", encoding="utf-8")
+ result = validate_pins(pinned, "--pin-source", str(source))
+ assert result.returncode == 1, result.stdout + result.stderr
+ assert reached_the_terminal(result) == [], result.stderr
+ assert "exited 128: fatal: invalid gitfile format: " in result.stderr, (
+ result.stderr)
+
+
+@pytest.mark.parametrize("name", ["plain", "caf\u00e9"])
+def test_a_printable_pin_source_that_is_not_a_repository_is_named_as_typed(
+ pinned, tmp_path, name):
+ """The control: a printable folder, UTF-8 included, is named byte for
+ byte, with no quotes."""
+ source = tmp_path / name
+ source.mkdir()
+ commit = git("rev-parse", "HEAD", cwd=pinned["product"]).stdout.strip()
+ result = validate_pins(pinned, "--pin-source", str(source))
+ assert result.returncode == 1, result.stdout + result.stderr
+ assert (f"@ {commit}: `git ls-tree -r -z {commit}` in {source} "
+ "exited 128: fatal: ") in result.stderr, result.stderr
+
+
+@STUB_GIT
+@pytest.mark.parametrize("name", SOURCE_NAMES)
+def test_git_out_names_the_folder_it_ran_in_spelled(tmp_path, monkeypatch,
+ name):
+ """`git_out`'s `git-failed` detail, for every caller: the folder is named
+ with `shown_path`, as `repr` where it is not printable."""
+ stub_git(tmp_path, monkeypatch, b"fatal: no\n")
+ folder = tmp_path / name
+ folder.mkdir()
+ with pytest.raises(Refusal) as caught:
+ git_out(["ls-tree", "-r", "-z", NO_SUCH_COMMIT], cwd=folder)
+ assert caught.value.code == "git-failed"
+ assert caught.value.detail == (
+ f"`git ls-tree -r -z {NO_SUCH_COMMIT}` in {shown_path(str(folder))} "
+ "exited 128: fatal: no")
+ assert caught.value.detail.isprintable(), caught.value.detail
+
+
+#: What git can print, and the one line the detail holds it as: a control
+#: character, a line break and a C1 control as the escape Python spells
+#: them, and every printable character as it was.
+STDERR_SPELLINGS = [
+ pytest.param(b"fatal: bad \x1b[31mRED\x1b[0m",
+ "fatal: bad \\x1b[31mRED\\x1b[0m", id="esc"),
+ pytest.param(b"fatal: invalid gitfile format: /x/a\nWARNING forged-line: "
+ b"pwned\nb/.git\n",
+ "fatal: invalid gitfile format: /x/a\\nWARNING forged-line: "
+ "pwned\\nb/.git", id="forged-line"),
+ pytest.param(b"fatal: a\rWARNING forged-line: pwned",
+ "fatal: a\\rWARNING forged-line: pwned", id="cr"),
+ pytest.param(b"fatal: t\x1b]0;owned\x07z",
+ "fatal: t\\x1b]0;owned\\x07z", id="bel"),
+ pytest.param("fatal: x\u009b31my".encode("utf-8"),
+ "fatal: x\\x9b31my", id="c1-csi"),
+ pytest.param(b"error: first line\nfatal: second line\n",
+ "error: first line\\nfatal: second line", id="two-lines"),
+ pytest.param("fatal: caf\u00e9 is not a repository (C:\\x\\y)"
+ .encode("utf-8"),
+ "fatal: caf\u00e9 is not a repository (C:\\x\\y)",
+ id="printable-unchanged"),
+]
+
+
+@STUB_GIT
+@pytest.mark.parametrize("stderr,spelled_as", STDERR_SPELLINGS)
+def test_git_out_spells_what_git_printed_as_one_line(tmp_path, monkeypatch,
+ stderr, spelled_as):
+ """git echoes the paths it reads and passes a line break through, and a
+ git that does not replace a control character passes that through too.
+ The detail names what git printed as one line, whatever git is on the
+ machine."""
+ stub_git(tmp_path, monkeypatch, stderr)
+ with pytest.raises(Refusal) as caught:
+ git_out(["ls-tree", "-r", "-z", NO_SUCH_COMMIT], cwd=tmp_path)
+ assert caught.value.detail == (
+ f"`git ls-tree -r -z {NO_SUCH_COMMIT}` in {tmp_path} exited 128: "
+ f"{spelled_as}")
+
+
+# --- F6 (rv610, #634): the naming gate's line holds ONE spelling -------------
+
+def unreadable_link_source(tmp_path, name: str):
+ """The naming gate asked to read `openXdox` from a tree called `name`
+ whose `project.yaml` cannot be read as a declaration."""
+ bad = tmp_path / name
+ bad.mkdir()
+ (bad / "project.yaml").write_bytes(b"neutral_product_pins: 5\n")
+ result = naming(tmp_path, "--pins", "openXdox", "--referent-chain",
+ "openXdox,openInk", "--link-source", f"openXdox={bad}",
+ "codexInk")
+ return bad, result
+
+
+@CONTROL_ARGV
+@pytest.mark.parametrize("name", CONTROL_NAMES)
+def test_the_naming_gate_spells_the_link_source_as_it_spells_the_manifest(
+ tmp_path, name):
+ """`link-source-unreadable` named the tree it asked, then the reason,
+ which since #610 names the manifest with `shown_path`: one line, two
+ spellings, and a raw ESC still on stderr. The path before the reason is
+ spelled the same way now."""
+ bad, result = unreadable_link_source(tmp_path, name)
+ assert result.returncode == 0, result.stdout + result.stderr
+ assert "\x1b" not in result.stdout + result.stderr, result.stderr
+ assert not [line for line in result.stderr.splitlines()
+ if line.startswith("WARNING forged-line")], result.stderr
+ assert ("WARNING link-source-unreadable: "
+ f"{shown_path(str(bad))} (--link-source openXdox=...) could not "
+ f"answer for openXdox: {shown_path(str(bad / 'project.yaml'))}: "
+ "neutral_product_pins is not a list\n") in result.stderr, (
+ result.stderr)
+ assert shown_path(str(bad)) == repr(str(bad))
+
+
+def test_the_naming_gate_names_a_printable_link_source_as_typed(tmp_path):
+ """The control: a printable tree is named byte for byte as it was."""
+ bad, result = unreadable_link_source(tmp_path, "plain")
+ assert result.returncode == 0, result.stdout + result.stderr
+ assert ("WARNING link-source-unreadable: "
+ f"{bad} (--link-source openXdox=...) could not answer for "
+ f"openXdox: {bad / 'project.yaml'}: neutral_product_pins is not "
+ "a list\n") in result.stderr, result.stderr
+
+
+@pytest.mark.parametrize("path", [
+ "plain/dir", "x\x1b[31mRED", "a\nb", "a\rb", "caf\u00e9", "\udcff",
+ "tab\there", "",
+])
+def test_the_pinned_scripts_spell_a_path_as_the_library_does(path):
+ """`validate-pins.py` and `validate-repository-naming.py` each write
+ their own `_shown`, because a root whose `scripts/repo_shape.py` is
+ older than they are has no `shown_path` to import. Each must spell as
+ `shown_path` spells."""
+ for script, name in ((TEMPLATE_PINS, "pins"), (VALIDATOR, "naming")):
+ module = load_without_bytecode(script, f"pinned_copy_text_{name}")
+ assert module._shown(path) == shown_path(path), script
+
+
+@pytest.mark.parametrize("script", [TEMPLATE_PINS, VALIDATOR, TEMPLATE_FAMILY],
+ ids=["validate-pins", "validate-repository-naming",
+ "validate-family"])
+def test_no_pinned_script_imports_what_an_older_library_lacks(script):
+ """THE SKEW RULE. A root re-copies its pinned files one row at a time
+ (`update-shape.py apply --accept-local` keeps a copy), so a script may
+ meet a `repo_shape.py` older than itself. `shown_path` is new with this
+ bump (#610): a script that imported it would end in an `ImportError`
+ before it checked a pin, a name or a member."""
+ imported = {alias.name
+ for node in ast.walk(ast.parse(script.read_text(
+ encoding="utf-8")))
+ if isinstance(node, ast.ImportFrom)
+ and node.module == "repo_shape"
+ for alias in node.names}
+ assert imported, f"{script.name} imports nothing from repo_shape?"
+ assert "shown_path" not in imported
diff --git a/tests/test_validate_family_branch_step.py b/tests/test_validate_family_branch_step.py
index 2b611edf..33d67cae 100644
--- a/tests/test_validate_family_branch_step.py
+++ b/tests/test_validate_family_branch_step.py
@@ -174,13 +174,13 @@ def copy_edited(holder) -> None:
target.write_bytes(target.read_bytes() + b"\n# edit\n")
-def gitlink_moved(holder) -> None:
- """IRRS's gitlink moved alone, staged at an EMPTY commit past its pin,
- so the pin's digest still recomputes: a lone
+def gitlink_moved(holder, member: str = "IRRS") -> None:
+ """`member`'s gitlink (IRRS's) moved alone, staged at an EMPTY commit
+ past its pin, so the pin's digest still recomputes: a lone
`member-gitlink-mismatch`."""
git("commit", "-q", "--allow-empty", "-m", "The member moved alone",
- cwd=holder / "members" / "IRRS")
- git("add", "--", "members/IRRS", cwd=holder)
+ cwd=holder / "members" / member)
+ git("add", "--", f"members/{member}", cwd=holder)
def digest_written_wrong(holder) -> None:
@@ -222,3 +222,138 @@ def test_the_count_ends_at_the_rule_only_where_it_applies(family, on_main,
else:
assert count_line(result) == "1 finding(s) in family.yaml."
assert "LOCKSTEP RULE" not in result.stdout + result.stderr
+
+
+# --- THE LOCKSTEP RULE's `bump`: the holder, and the branch first (#610) ----
+
+def rule_commands(text: str) -> tuple[str, str]:
+ """`(bump, switch)`: the two commands THE LOCKSTEP RULE in `text` names,
+ in the order it names them."""
+ rule = text[text.index(RULE):]
+ bump, switch = [command for command in re.findall(r"`([^`]*)`", rule)
+ if command.startswith((PYTHON, "git -C"))]
+ return bump, switch
+
+
+def test_the_rule_s_bump_runs_as_printed_from_the_tracking_branch(on_main):
+ """L2-39. The rule named `scripts/family.py bump --member --to
+ `: no `--family-root`, which `bump` requires, and no branch,
+ though a real `bump` refuses the holder's tracking branch (#487). It
+ names this holder by its path and the `switch` that makes `family.py`'s
+ branch; run as printed, from a checkout of openRepoShape, with the
+ pinned commit as `--to`, the `switch` and then the `bump` put the
+ gitlink back in step, where the `bump` alone is refused."""
+ holder = on_main
+ pinned = git("rev-parse", "HEAD:members/IRRS", cwd=holder).stdout.strip()
+ gitlink_moved(holder)
+ git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm",
+ "Move the gitlink and not the pin", cwd=holder)
+ found = validate(holder)
+ assert found.returncode == 1, found.stdout + found.stderr
+ finding = found.stderr.split("FINDING member-gitlink-mismatch: ", 1)[1]
+ bump, switch = rule_commands(finding.split("\n\n", 1)[0])
+ assert shlex.split(switch) == switch_argv(holder, "bump", "irrs")
+ assert shlex.split(bump) == [PYTHON, "scripts/family.py", "bump",
+ "--family-root", str(holder), "--member",
+ "IRRS", "--to", ""], bump
+ argv = shlex.split(bump.replace("", pinned))
+ refused = run_script(REPO / argv[1], *argv[2:], cwd=REPO)
+ assert refused.returncode == 2, refused.stdout + refused.stderr
+ assert "REFUSED family-on-tracking-branch: " in refused.stderr
+ git(*shlex.split(switch)[1:], cwd=holder.parent)
+ bumped = run_script(REPO / argv[1], *argv[2:], cwd=REPO)
+ assert bumped.returncode == 0, bumped.stdout + bumped.stderr
+ assert git("symbolic-ref", "--short", "HEAD",
+ cwd=holder).stdout.strip() == "family/bump-irrs"
+ assert validate(holder).returncode == 0
+
+
+def test_a_digest_s_rule_names_its_own_member(on_main):
+ """`member-digest-mismatch` ends at the rule too, naming its member,
+ IRSS, in the `--member` and in the branch."""
+ holder = on_main
+ digest_written_wrong(holder)
+ found = validate(holder)
+ assert found.returncode == 1, found.stdout + found.stderr
+ finding = found.stderr.split("FINDING member-digest-mismatch: ", 1)[1]
+ bump, switch = rule_commands(finding.split("\n\n", 1)[0])
+ assert shlex.split(switch) == switch_argv(holder, "bump", "irss")
+ assert shlex.split(bump)[3:7] == ["--family-root", str(holder),
+ "--member", "IRSS"], bump
+
+
+def test_the_count_s_rule_names_the_holder_and_no_member(on_main):
+ """The closing count may follow more than one member out of step, so its
+ rule names this holder and ``, in the `--member` and in the
+ branch alike, as `_switch_line` says a member it cannot name."""
+ holder = on_main
+ gitlink_moved(holder)
+ found = validate(holder)
+ assert found.returncode == 1, found.stdout + found.stderr
+ line = count_line(found)
+ assert line.startswith("1 finding(s) in family.yaml. " + RULE), line
+ bump, switch = rule_commands(line)
+ assert bump == (f"{PYTHON} scripts/family.py bump --family-root "
+ f"{shlex.quote(str(holder))} --member --to "
+ "")
+ assert switch == (f"git -C {shlex.quote(str(holder))} switch -c "
+ "family/bump-")
+
+
+@pytest.mark.parametrize("drop_id, word", [
+ pytest.param(False, "irrs", id="the-rows-id"),
+ pytest.param(True, "irrs2", id="no-id-so-the-project"),
+])
+def test_the_rule_names_the_rows_id_through_the_gate(family, on_main,
+ drop_id, word):
+ """rv610's F3 on #634 (mutant A1: `_lockstep(root, project, project)`
+ at the call site). `test_the_rule_quotes_what_it_names` hands `_lockstep`
+ an id; this goes through the gate, which reads it from the row. A
+ member's `id:` is its own `project.yaml` id, which `--id` can make
+ anything, and every other row here says the project lowercased, so a
+ call site that passed the PROJECT passed the right word by accident.
+
+ `mount_twice`'s second row is the project `IRRS2` recording the id
+ `irrs`, and its gitlink is moved alone: the rule's `--member` is the
+ project, and its branch is the row's `id:` -- `family/bump-irrs`, not
+ `family/bump-irrs2`. Where the row records no `id:`, the project is the
+ word."""
+ holder = on_main
+ mount_twice(holder, family["remotes"])
+ if drop_id:
+ # IRRS2's row is the last in the file, so its `id:` is the last.
+ line = " id: irrs\n"
+ head, dropped, tail = (holder / "family.yaml").read_text(
+ encoding="utf-8").rpartition(line)
+ assert dropped == line and line not in tail
+ (holder / "family.yaml").write_bytes((head + tail).encode("utf-8"))
+ gitlink_moved(holder, "IRRS2")
+ found = validate(holder)
+ assert found.returncode == 1, found.stdout + found.stderr
+ finding = found.stderr.split("FINDING member-gitlink-mismatch: ", 1)[1]
+ assert finding.startswith("members/IRRS2: "), finding
+ bump, switch = rule_commands(finding.split("\n\n", 1)[0])
+ assert shlex.split(switch) == switch_argv(holder, "bump", word)
+ assert shlex.split(bump)[3:7] == ["--family-root", str(holder),
+ "--member", "IRRS2"], bump
+
+
+@pytest.mark.parametrize("project,member_id,word", [
+ ("IRRS", "irrs", "irrs"), ("IRRS", None, "irrs"),
+ ("My Proj", "my-proj", "my-proj"), ("O'Brien", None, "member"),
+])
+def test_the_rule_quotes_what_it_names(monkeypatch, tmp_path, project,
+ member_id, word):
+ """In this process: a holder whose path a shell would split, and a
+ member whose name it would, are each one word; the branch is the row's
+ `id:`, or the project where the row records none, said as `member`
+ where no branch word spells it."""
+ holder = tmp_path / "it's a holder"
+ text = gate_module(monkeypatch)._lockstep(holder, project, member_id)
+ assert text.startswith(RULE)
+ bump, switch = rule_commands(text)
+ assert shlex.split(bump) == [PYTHON, "scripts/family.py", "bump",
+ "--family-root", str(holder), "--member",
+ project, "--to", ""], bump
+ assert shlex.split(switch) == ["git", "-C", str(holder), "switch", "-c",
+ f"family/bump-{word}"], switch