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