From 8dbf3e5c34c0aa155a8121ea062d64c61b40041e Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sat, 17 Oct 2026 14:25:41 +0000 Subject: [PATCH 1/4] validate-repository-naming.py, validate-manifest.py and validate-pins.py: a `legs:` that is not a list is `manifest-legs` in every gate, a chain link or a declared pin that is not a name is a finding, the naming gate never reads stdin under `--project`, and a name that is not UTF-8 reaches `naming-unclassified` (#714) `legs:` was iterated as it stood, `value or []`, in the naming gate (`_project_leg_chains`, `_targets_from_project`) and in `validate-pins.py` (`_load_legs_to_check`). `legs: 5` ended both in a `TypeError`, exit 1, where `validate-manifest.py` said `legs is missing or empty`, which 5 is not. `legs: openXdox` was walked one character at a time: the naming gate found no target and read names from stdin, so on a pipe left open it waited until killed; `validate-pins.py` refused `manifest-no-legs` with the lockstep remedy. Now the three gates say one true sentence, exit 1: `FINDING manifest-legs: legs is , expected a list of `role:`, `repository:` and `path:` mappings`. `validate-manifest.py` keeps `missing or empty` for absent, null and `[]`; `0`, `false` and `''` are not a list, as #443 reads `neutral_product_pins:`. In `validate-pins.py` the shape pin and the neutral-product pins are still checked; absent, null and `[]` are still `manifest-no-legs`. Under `--project` the naming gate never reads stdin (lane 4's ruling), so no `legs:` can make it wait. A finding with no name to classify is said, exit 1, rather than dropped under `REFUSED naming-no-target`: `legs: [5]` now says `a leg has no `repository:``. A manifest with no leg and no finding is still refused `naming-no-target`, exit 2. A chain link or a declared pin that is not a name was read with `str()`: `[5]` was the pin or link `'5'`, and `referent_chain: "\x1b[31mRED"` exited 0 in the naming gate and `validate-manifest.py`, and `--explain` printed the ESC raw. `_not_a_name`, one rule in both files: text (the type `neutral-pin-entry-malformed` asks), not blank, and `str.isprintable`. A pin entry that is not a name declares nothing and the others still do; a chain holding one records no chain, as a chain that is not a list records none. Each is said with why, under the field's own code (`manifest-neutral-product-pins`, `naming-referent-chain`). A blank string chain is the empty record it always was. The naming gate's stdout writes what it cannot encode escaped, as `validate-pins.py`'s `_emit_report` does, so `--explain` and the plain listing no longer end in a `UnicodeEncodeError` on a name that is not UTF-8 under `PYTHONIOENCODING=utf-8`; a strict stdin is read with `surrogateescape`, as argv is, so `Atl\xffas` on stdin is `Atl\udcffas` and reaches `naming-unclassified` instead of a `UnicodeDecodeError`. L4-145: a test pins the chain sentence before the same leg's declared-role finding (#443's M6b). L4-146 (`repo_shape.py`) and L4-147 (`scaffold-project.py`) are not taken: this change does not cover them. Tests: tests/test_legs_not_a_list.py, on #443's harness. Closes #714 Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Opus 5.5 --- scripts/validate-repository-naming.py | 160 ++++++- .../scripts/validate-manifest.py | 86 +++- .../assembly-root/scripts/validate-pins.py | 24 +- tests/test_legs_not_a_list.py | 393 ++++++++++++++++++ 4 files changed, 630 insertions(+), 33 deletions(-) create mode 100644 tests/test_legs_not_a_list.py diff --git a/scripts/validate-repository-naming.py b/scripts/validate-repository-naming.py index fcd5a1c7..0389ff20 100755 --- a/scripts/validate-repository-naming.py +++ b/scripts/validate-repository-naming.py @@ -86,7 +86,8 @@ the topic agreed with the classification) 1 a FINDING: at least one name matches no family, or a leg's declared role disagrees with the form of its name, or (#443) `neutral_product_pins:` - or a leg's `naming.referent_chain:` is not a list + or a leg's `naming.referent_chain:` is not a list, or (#714) `legs:` + is not a list, or an entry of the first two is not a name 2 a REFUSAL: the question could not be asked at all — the policy file is missing, unparsable, or not a naming policy, or a `--link-source` names no path @@ -138,6 +139,26 @@ def _shown(path) -> str: return text if text.isprintable() else repr(text) +def _not_a_name(entry) -> str | None: + """Why a `neutral_product_pins:` entry or a `naming.referent_chain:` + link is not a name, or None where it is one (#714). + + `validate-manifest.py`'s `_not_a_name`, word for word, so every gate + that reads the two fields says one sentence. A name is text + (`validate-pins.py`'s `neutral-pin-entry-malformed` asks the same), it + is not blank (a blank link was always no link), and every character in + it is printable (`str.isprintable`, `_shown`'s rule). `[5]` was the pin + `'5'`, and a link holding ESC was a name this gate classified with, + exit 0, and printed raw in `--explain`'s CHAIN line.""" + if not isinstance(entry, str): + return "it is not text" + if not entry.strip(): + return "it is blank" + if not entry.isprintable(): + return "it holds a character that is not printable" + return None + + def _library_takes_remedies() -> bool: """Does this root's `scripts/repo_shape.py` take `remedies=`? The skew rule `validate-manifest.py` states (#661): an older library has @@ -685,11 +706,16 @@ def _pins_from_project(data: dict) -> set[str]: ended this gate, the first command of `make validate`, in a `TypeError`; a scalar `openXdox` was read as the pins `X`, `d`, `e`, `n`, `o`, `p`, `x`, and a mapping as its keys, and the names classified, exit 0. + + AN ENTRY THAT IS NOT A NAME DECLARES NOTHING (#714), and the others are + still declared, as `validate-pins.py` checks the rest past an entry it + calls malformed. `_not_a_name` is the rule, and `_pins_field_findings` + names each such entry. """ declared = data.get("neutral_product_pins") if not isinstance(declared, list): return set() - return {str(pin) for pin in declared if pin} + return {pin for pin in declared if _not_a_name(pin) is None} def _pins_field_findings(data: dict) -> list[str]: @@ -698,12 +724,22 @@ def _pins_field_findings(data: dict) -> list[str]: names the field where it used to end in a traceback, and every validator that reads the field says the same sentence. Absent or null declares no pin and is no finding; anything else that is not a list is one, `0`, - `false` and `''` included, as that validator says.""" + `false` and `''` included, as that validator says. In a list, each + entry that is not a name is one too, with why (#714).""" declared = data.get("neutral_product_pins") - if declared is None or isinstance(declared, list): + if declared is None: return [] - return [f"manifest-neutral-product-pins: neutral_product_pins is " - f"{declared!r}, expected a list of neutral product names"] + if not isinstance(declared, list): + return [f"manifest-neutral-product-pins: neutral_product_pins is " + f"{declared!r}, expected a list of neutral product names"] + findings = [] + for pin in declared: + why = _not_a_name(pin) + if why is not None: + findings.append( + f"manifest-neutral-product-pins: neutral_product_pins entry " + f"{pin!r} is not a neutral product name: {why}") + return findings def _chain_from_leg(leg: dict) -> tuple: @@ -718,32 +754,54 @@ def _chain_from_leg(leg: dict) -> tuple: no chain, which `_chain_field_findings` reports. This copy had the string rule and not the list rule, so `5`, `true` or `1.5` ended the gate in a `TypeError` and a mapping was read as a chain of its keys. + + A CHAIN WITH A LINK THAT IS NOT A NAME RECORDS NO CHAIN (#714), as one + that is not a list records none: `_not_a_name` is the rule, and + `_chain_field_findings` names the link. `[5]` was the link `'5'`, and + `"\\x1b[31mRED"` a link this gate classified with, exit 0. Dropping the + one link would ask about a chain nobody recorded. A blank string is the + empty record it always was. """ naming = leg.get("naming") if not isinstance(naming, dict): return () recorded = naming.get(CHAIN_RECORD_FIELD) if isinstance(recorded, str): - recorded = [recorded] - if not isinstance(recorded, list): + recorded = [recorded] if recorded.strip() else [] + if not isinstance(recorded, list) or any( + _not_a_name(link) is not None for link in recorded): return () - return tuple(str(link) for link in recorded if str(link).strip()) + return tuple(recorded) def _chain_field_findings(leg: dict) -> list[str]: """`validate-manifest.py`'s finding for a leg's `naming.referent_chain:` that is neither a string nor a list, in its words (#443): the leg named by its declared `role:`, as that validator names it. Absent or null - records no chain, and a string is one link, so neither is a finding.""" + records no chain, and a string is one link, so neither is a finding. + Each link that is not a name is one, with why (#714); a blank string is + no link and no finding.""" naming = leg.get("naming") if not isinstance(naming, dict): return [] recorded = naming.get(CHAIN_RECORD_FIELD) - if recorded is None or isinstance(recorded, (str, list)): + if recorded is None: return [] - return [f"naming-referent-chain: leg {leg.get('role')!r}: " - f"{CHAIN_RECORD_FIELD} is {recorded!r}, expected a list of " - "neutral-product names"] + if isinstance(recorded, str): + recorded = [recorded] if recorded.strip() else [] + if not isinstance(recorded, list): + return [f"naming-referent-chain: leg {leg.get('role')!r}: " + f"{CHAIN_RECORD_FIELD} is {recorded!r}, expected a list of " + "neutral-product names"] + findings = [] + for link in recorded: + why = _not_a_name(link) + if why is not None: + findings.append( + f"naming-referent-chain: leg {leg.get('role')!r}: " + f"{CHAIN_RECORD_FIELD} link {link!r} is not a neutral-product " + f"name: {why}") + return findings def _leg_target(policy: NamingPolicy, path: Path, leg, root: Path, @@ -806,11 +864,36 @@ def _project_manifest(path: Path) -> dict: return data +def _project_legs(data: dict) -> list: + """The manifest's `legs:` where it is a list, and `[]` where it is not + (#714): a value that is not a list names no leg, as + `validate-manifest.py` reads it, and `_legs_field_findings` says so. + Iterated as `data.get("legs") or []`, `5` or `true` ended the gate in a + `TypeError` before any name was classified, and a scalar `openXdox` or + a mapping was walked one character, or one key, at a time: no leg was a + target, and the gate read names from stdin instead, which waited on a + pipe left open.""" + legs = data.get("legs") + return legs if isinstance(legs, list) else [] + + +def _legs_field_findings(data: dict) -> list[str]: + """`validate-manifest.py`'s finding for a `legs:` that is not a list, in + its words (#714), as `validate-pins.py` says it too. Absent, null or + `[]` names no leg and is not this finding: with no other name, the run + refuses `naming-no-target`, as it did.""" + legs = data.get("legs") + if legs is None or isinstance(legs, list): + return [] + return [f"manifest-legs: legs is {legs!r}, expected a list of `role:`, " + "`repository:` and `path:` mappings"] + + def _project_leg_chains(data: dict) -> list[tuple[str, tuple]]: """`(name, the chain it records)` for each leg `_leg_target` makes a target of, and no other.""" return [(repo_basename(str(leg["repository"])), _chain_from_leg(leg)) - for leg in data.get("legs") or [] + for leg in _project_legs(data) if isinstance(leg, dict) and "repository" in leg] @@ -852,9 +935,11 @@ def _targets_from_project(policy: NamingPolicy, path: Path, data: dict, root = path.resolve().parent targets: list[Target] = [] # Pins that are not a list are said before any leg's finding, and every - # leg is classified as declaring none (#443). + # leg is classified as declaring none (#443). Legs that are not a list + # are said next, and name no leg (#714). findings: list[str] = _pins_field_findings(data) - for leg in data.get("legs") or []: + findings.extend(_legs_field_findings(data)) + for leg in _project_legs(data): target, leg_findings = _leg_target(policy, path, leg, root, pins, sources) if target is not None: @@ -872,11 +957,41 @@ def _targets_from_stdin(role: str | None, pins: set, chain: tuple, Split from `main` for #136: reading the stdin fallback is its own question, asked only once `main` has decided no other source answered. + + A BYTE THAT IS NOT UTF-8 IS READ AS THE NAME ARGV READS (#714). A + `strict` stdin, which `PYTHONIOENCODING=utf-8` gives (the doctor sets it + for its children), ended the run in a `UnicodeDecodeError` on + `Atl\\xffas`; read with `surrogateescape`, as `os.fsdecode` reads an + argument, it is `Atl\\udcffas` and reaches `naming-unclassified`. Any + other handler, one somebody chose, is left alone. """ + if getattr(sys.stdin, "errors", None) == "strict": + try: + sys.stdin.reconfigure(errors="surrogateescape") + except (AttributeError, ValueError, OSError): + pass return [Target(repo_basename(line.strip()), role, pins, chain, here) for line in sys.stdin if line.strip()] +def _escape_what_stdout_cannot_encode() -> None: + """A character stdout cannot encode is printed escaped (#714), as + `validate-pins.py`'s `_emit_report` prints it (#295): a `strict` or + `surrogateescape` stdout, the two that raise, becomes + `backslashreplace`, the handler stderr already has, and any other + handler is left alone. A name that is not UTF-8 (`Atl\\udcffas`, from + the byte `\\xff`) ended `--explain` and the plain listing in a + `UnicodeEncodeError` under `PYTHONIOENCODING=utf-8`, which the doctor + gives its children, where `--quiet` said `naming-unclassified`. This + script runs on its own too, so the doctor setting another handler for + its child would not be enough.""" + if getattr(sys.stdout, "errors", None) in ("strict", "surrogateescape"): + try: + sys.stdout.reconfigure(errors="backslashreplace") + except (AttributeError, ValueError, OSError): + pass + + def _print_classification(name: str, found: Classification | None) -> None: """The one plain-listing line `main` prints for a name, un-`--explain`d. @@ -1045,6 +1160,7 @@ def main(argv: list[str] | None = None) -> int: "of them won, and what the others are recorded as") parser.add_argument("--quiet", action="store_true") args = parser.parse_args(argv) + _escape_what_stdout_cannot_encode() try: policy = NamingPolicy.load(args.policy) @@ -1080,9 +1196,15 @@ def main(argv: list[str] | None = None) -> int: extra, findings = _targets_from_project(policy, args.project, data, sources) targets.extend(extra) - if not targets and not sys.stdin.isatty(): + # UNDER `--project`, STDIN IS NEVER READ (#714). The manifest is the + # question; a manifest that names no leg, or whose `legs:` is not a + # list, read names from a stdin that is not a terminal, and a pipe left + # open (a parent that holds it) made the gate wait forever. + # A finding with no name to classify is still said, exit 1: the + # `no names given` refusal is for a run that found nothing to ask. + if not targets and data is None and not sys.stdin.isatty(): targets = _targets_from_stdin(args.role, cli_pins, cli_chain, here) - if not targets: + if not targets and not findings: print("REFUSED naming-no-target: no names given. Pass names, " "`--project project.yaml`, or names on stdin.", file=sys.stderr) return 2 diff --git a/templates/assembly-root/scripts/validate-manifest.py b/templates/assembly-root/scripts/validate-manifest.py index ab6344f2..c5320179 100755 --- a/templates/assembly-root/scripts/validate-manifest.py +++ b/templates/assembly-root/scripts/validate-manifest.py @@ -123,11 +123,35 @@ def _declared_pins(manifest: dict) -> set[str]: `1.5` ended the run in a `TypeError` before that finding could print, and a scalar `openXdox` was read as the pins `X`, `d`, `e`, `n`, `o`, `p`, `x` for every leg's classification. + + AN ENTRY THAT IS NOT A NAME DECLARES NOTHING (#714), and the others are + still declared, as `validate-pins.py` checks the rest past an entry it + calls malformed: `[5]` was the pin `'5'`. `_not_a_name` is the rule, + and `_neutral_product_pins_type_finding` names each such entry. """ declared = manifest.get("neutral_product_pins") if not isinstance(declared, list): return set() - return {str(pin) for pin in declared if pin} + return {pin for pin in declared if _not_a_name(pin) is None} + + +def _not_a_name(entry) -> str | None: + """Why a `neutral_product_pins:` entry or a `naming.referent_chain:` + link is not a name, or None where it is one (#714). + + The naming gate's `_not_a_name`, word for word, so every gate that reads + the two fields says one sentence. A name is text (`validate-pins.py`'s + `neutral-pin-entry-malformed` asks the same), it is not blank (a blank + link was always no link), and every character in it is printable + (`str.isprintable`). `[5]` was the pin `'5'`, and a link holding ESC a + name this validator classified with, `manifest ok`.""" + if not isinstance(entry, str): + return "it is not text" + if not entry.strip(): + return "it is blank" + if not entry.isprintable(): + return "it holds a character that is not printable" + return None def _recorded_chain(naming: dict) -> tuple: @@ -136,13 +160,19 @@ def _recorded_chain(naming: dict) -> tuple: A single string is read as a one-link chain rather than as characters, and anything else yields `()` — the caller reports the shape separately, so a malformed record never becomes a silently different question. + + A link that is not a name (`_not_a_name`) makes the record malformed + too, so it records no chain (#714): `[5]` was the link `'5'`, and + dropping the one link would be the different question. A blank string + is the empty record it always was. """ recorded = naming.get(CHAIN_RECORD_FIELD) if isinstance(recorded, str): - recorded = [recorded] - if not isinstance(recorded, list): + recorded = [recorded] if recorded.strip() else [] + if not isinstance(recorded, list) or any( + _not_a_name(link) is not None for link in recorded): return () - return tuple(str(link) for link in recorded if str(link).strip()) + return tuple(recorded) def _chain_and_links(naming, root: Path | None) -> tuple[tuple, dict]: @@ -223,14 +253,27 @@ def _library_takes_remedies() -> bool: def _naming_chain_field_finding(leg_role, naming: dict) -> list[str]: """Is `naming[CHAIN_RECORD_FIELD]`, if present, a string or a list? - Split from `_naming_findings` for #132. + Split from `_naming_findings` for #132. Each link that is not a name + (`_not_a_name`) is a finding too, with why (#714); a blank string is no + link and no finding. """ recorded = naming.get(CHAIN_RECORD_FIELD) - if recorded is not None and not isinstance(recorded, (str, list)): + if recorded is None: + return [] + if isinstance(recorded, str): + recorded = [recorded] if recorded.strip() else [] + if not isinstance(recorded, list): return [f"FINDING naming-referent-chain: leg {leg_role!r}: " f"{CHAIN_RECORD_FIELD} is {recorded!r}, expected a list of " "neutral-product names"] - return [] + out = [] + for link in recorded: + why = _not_a_name(link) + if why is not None: + out.append(f"FINDING naming-referent-chain: leg {leg_role!r}: " + f"{CHAIN_RECORD_FIELD} link {link!r} is not a " + f"neutral-product name: {why}") + return out def _naming_classification_findings(leg_role, name: str, naming: dict, @@ -680,15 +723,26 @@ def _shape_findings(manifest: dict) -> list[str]: def _neutral_product_pins_type_finding(manifest: dict) -> list[str]: """`neutral_product_pins`, OPTIONAL. - Split from `_findings` for #132. + Split from `_findings` for #132. In a list, each entry that is not a + name (`_not_a_name`) is a finding, with why (#714). """ declared_pins = manifest.get("neutral_product_pins") - if declared_pins is not None and not isinstance(declared_pins, list): + if declared_pins is None: + return [] + if not isinstance(declared_pins, list): return [_finding( "manifest-neutral-product-pins", f"neutral_product_pins is {declared_pins!r}, expected a list of " "neutral product names")] - return [] + out = [] + for pin in declared_pins: + why = _not_a_name(pin) + if why is not None: + out.append(_finding( + "manifest-neutral-product-pins", + f"neutral_product_pins entry {pin!r} is not a neutral " + f"product name: {why}")) + return out def _roles_finding(legs: list) -> list[str]: @@ -980,7 +1034,17 @@ def _findings(manifest: dict, policy: NamingPolicy, root=None) -> list[str]: out.extend(_record_findings(manifest, ANSWER_ROWS)) legs = manifest.get("legs") - if not isinstance(legs, list) or not legs: + # A `legs:` that is not a list is said as what it is (#714): `5` was + # "missing or empty", which it is not. The naming gate and + # `validate-pins.py` say this sentence too. `0`, `false` and `''` are + # not a list either, as #443 reads `neutral_product_pins:`. + if legs is not None and not isinstance(legs, list): + out.append(_finding( + "manifest-legs", + f"legs is {legs!r}, expected a list of `role:`, `repository:` " + "and `path:` mappings")) + return out + if not legs: out.append(_finding("manifest-legs", "legs is missing or empty")) return out diff --git a/templates/assembly-root/scripts/validate-pins.py b/templates/assembly-root/scripts/validate-pins.py index d628d6c2..efe49870 100755 --- a/templates/assembly-root/scripts/validate-pins.py +++ b/templates/assembly-root/scripts/validate-pins.py @@ -1493,9 +1493,20 @@ def _note_unused_pin_sources(sources: PinSources, report: Report) -> None: sources.read_as_path(typed))))) -def _load_legs_to_check(root: Path) -> tuple[dict, list[dict]]: +def _load_legs_to_check(root: Path, report: Report + ) -> tuple[dict, list[dict]]: """The manifest and its non-assembly legs, or a Refusal naming why there is nothing here to check. Split from `main` for #132. + + A `legs:` THAT IS NOT A LIST IS A FINDING, NOT THAT REFUSAL (#714), in + `validate-manifest.py`'s words for it (`manifest-legs`), and no leg is + checked. Iterated as `manifest.get("legs") or []`, `5` or `true` ended + this validator in a `TypeError`, and a scalar `openXdox` or a mapping + was walked one character, or one key, at a time, found no leg, and was + refused `manifest-no-legs` with the lockstep remedy, which says to run + `git submodule update`: nothing to do with the cause. The shape pin and + the neutral-product pins are still checked. Absent, null or `[]` is + still that refusal. """ manifest_path = root / "project.yaml" if not manifest_path.is_file(): @@ -1508,7 +1519,14 @@ def _load_legs_to_check(root: Path) -> tuple[dict, list[dict]]: manifest = load_yaml(manifest_path) if not isinstance(manifest, dict): raise Refusal("manifest-unreadable", f"{manifest_path}: not a mapping") - legs = [leg for leg in (manifest.get("legs") or []) + declared = manifest.get("legs") + if declared is not None and not isinstance(declared, list): + report.finding( + "manifest-legs", + f"legs is {declared!r}, expected a list of `role:`, " + "`repository:` and `path:` mappings") + return manifest, [] + legs = [leg for leg in (declared or []) if isinstance(leg, dict) and leg.get("role") != "assembly"] if not legs: raise Refusal("manifest-no-legs", @@ -1521,7 +1539,7 @@ def _run_all_checks(root: Path, report: Report, """Every check `main` runs inside its one `try`, in order. Split from `main` for #132 so that `try` wraps a single call. """ - manifest, legs = _load_legs_to_check(root) + manifest, legs = _load_legs_to_check(root, report) refs = _workflow_refs(root) for leg in legs: _check_leg(root, leg, report, refs) diff --git a/tests/test_legs_not_a_list.py b/tests/test_legs_not_a_list.py new file mode 100644 index 00000000..46d3f2cb --- /dev/null +++ b/tests/test_legs_not_a_list.py @@ -0,0 +1,393 @@ +# SPDX-License-Identifier: Apache-2.0 +"""A project manifest's `legs:` that is not a list is a FINDING in every gate +that reads it, a chain link or a declared pin that is not a name is a FINDING, +the naming gate never waits on stdin under `--project`, and a name that is not +UTF-8 reaches `naming-unclassified` (#714). + +`legs:` WAS ITERATED AS IT STOOD, `value or []`: the naming gate's +`_project_leg_chains` and `_targets_from_project`, and `validate-pins.py`'s +`_load_legs_to_check`. So `5` or `true` ended both in `TypeError: 'int' object +is not iterable`, exit 1, where `validate-manifest.py` said `legs is missing or +empty`, which `5` is not. A scalar `openXdox` was walked one character at a +time: no leg was a target, so the naming gate read names from stdin, and on a +pipe left open (a parent that holds it) it WAITED; `validate-pins.py` +refused `manifest-no-legs` with the lockstep remedy, `git submodule update`. +NOW the three gates say one sentence, exit 1: + + FINDING manifest-legs: legs is , expected a list of `role:`, + `repository:` and `path:` mappings + +and under `--project` the naming gate reads no stdin at all, whatever `legs:` +holds, so absent, null, `[]` and `[5]` cannot wait either. + +AN ENTRY THAT IS NOT A NAME (#443 left `[5]` and `[[a]]` unexamined) was read +with `str()`: `[5]` was the pin or the link `'5'`, and a link holding ESC, +`referent_chain: "\\x1b[31mRED"`, exited 0 in the naming gate and in +`validate-manifest.py`. NOW `_not_a_name` is one rule in both files: text +(`validate-pins.py`'s `neutral-pin-entry-malformed` asks it), not blank, and +`str.isprintable`. A pin entry that is not a name declares nothing and the +others still do; a chain holding such a link records no chain, as one that is +not a list records none. Each is said, with why: + + FINDING manifest-neutral-product-pins: neutral_product_pins entry + is not a neutral product name: + FINDING naming-referent-chain: leg '': referent_chain link + is not a neutral-product name: + +A NAME THAT IS NOT UTF-8 (`Atl\\xffas`) ended `--explain` and the plain +listing in a `UnicodeEncodeError` on a strict stdout (`PYTHONIOENCODING=utf-8`, +which the doctor gives its children), and a read from stdin in a +`UnicodeDecodeError`. NOW stdout escapes what it cannot encode, as +`validate-pins.py`'s `_emit_report` does, and stdin reads the byte as argv +does, so the name is `Atl\\udcffas` wherever it is printed and reaches +`naming-unclassified`. + +THE HARNESS is `tests/test_non_list_project_fields.py`'s (#443): the three +gates as a project runs them from its root, the field editors, and its +`readers` for the pure functions. +""" + +from __future__ import annotations + +import os +import subprocess +import sys + +import pytest + +from conftest import REPO, run_script +from test_manifest_validator import edit +from test_non_list_project_fields import ( # noqa: F401 + GATES, NOT_A_LIST, SPEC_NAMING, assert_said_once, chain_said, gate, + readers, set_chain, set_pins, spelled_as, +) + +NAMING_SCRIPT = REPO / "scripts" / "validate-repository-naming.py" + +#: The scaffold writes `legs:` as the manifest's last block. +LEGS_START = "\nlegs:\n" + +#: How long a gate may take with stdin a pipe nobody writes or closes before +#: the test calls it a wait. A run here takes well under a second. +STDIN_WAIT = 30 + +NOT_TEXT = "it is not text" +BLANK = "it is blank" +NOT_PRINTABLE = "it holds a character that is not printable" +ESC = "\x1b[31mRED" +#: The issue's name that is not UTF-8, as argv reads the byte `\xff`. +NOT_UTF8 = "Atl\udcffas" + + +def legs_said(value) -> str: + """`validate-manifest.py`'s sentence for `legs:`, whole.""" + return (f"FINDING manifest-legs: legs is {value!r}, expected a list of " + "`role:`, `repository:` and `path:` mappings\n") + + +def entry_said(field: str, entry, why: str, role: str = "assembly") -> str: + """The sentence for a pin entry or a chain link that is not a name.""" + if field == "pins": + return (f"FINDING manifest-neutral-product-pins: neutral_product_pins " + f"entry {entry!r} is not a neutral product name: {why}\n") + return (f"FINDING naming-referent-chain: leg {role!r}: referent_chain " + f"link {entry!r} is not a neutral-product name: {why}\n") + + +def set_legs(project, spelled: str | None) -> None: + """`legs: ` in place of the scaffold's three legs, or no + `legs:` key for None.""" + manifest = project / "project.yaml" + head, sep, rest = manifest.read_text().partition(LEGS_START) + assert sep, "fixture drift: no `legs:` block in project.yaml" + assert all(line.startswith(" ") or not line + for line in rest.splitlines()), \ + "fixture drift: `legs:` is not the manifest's last block" + manifest.write_text(head + "\n" + ("" if spelled is None else + spelled_as("legs", spelled) + "\n")) + + +def naming_gate_beside_an_open_pipe(project) -> subprocess.CompletedProcess: + """The naming gate as `make validate` runs it, with stdin a pipe that is + open and never written: what a parent holding the pipe gives it. A gate + that reads it waits until the pipe closes, so the test closes it only + after `STDIN_WAIT` and calls that a failure.""" + script, *args = GATES["naming"] + read_end, write_end = os.pipe() + try: + return subprocess.run([sys.executable, str(project / script), *args], + cwd=str(project), stdin=read_end, + capture_output=True, text=True, + timeout=STDIN_WAIT, check=False) + except subprocess.TimeoutExpired: + pytest.fail(f"the naming gate waited {STDIN_WAIT}s on a stdin left " + "open under --project") + finally: + os.close(read_end) + os.close(write_end) + + +# --- legs: that is not a list ----------------------------------------------- + +@pytest.mark.parametrize("which", ["naming", "manifest", "pins"]) +@pytest.mark.parametrize("spelled,value", [row[1:] for row in NOT_A_LIST], + ids=[row[0] for row in NOT_A_LIST]) +def test_each_gate_says_legs_that_are_not_a_list_in_one_sentence( + project, which, spelled, value): + """`legs: ` is `manifest-legs`, exit 1, from each of the + three gates, in words true of the value: no `TypeError`, no + `naming-no-target`, no `manifest-no-legs` and its `git submodule update` + remedy, and no "missing or empty" for `5`.""" + set_legs(project, spelled) + result = gate(project, which) + assert_said_once(result, legs_said(value)) + for wrong in ("naming-no-target", "manifest-no-legs", + "git submodule update", "missing or empty"): + assert wrong not in result.stderr, result.stderr + + +@pytest.mark.parametrize("spelled,exit_code,said", [ + pytest.param("openXdox", 1, legs_said("openXdox"), id="scalar-string"), + pytest.param("5", 1, legs_said(5), id="number"), + pytest.param("{a: b}", 1, legs_said({"a": "b"}), id="mapping"), + pytest.param(None, 2, "REFUSED naming-no-target: ", id="absent"), + pytest.param("", 2, "REFUSED naming-no-target: ", id="null"), + pytest.param("[]", 2, "REFUSED naming-no-target: ", id="empty-list"), + pytest.param("[5]", 1, "FINDING project.yaml: a leg has no `repository:`", + id="a-leg-that-is-not-a-mapping"), +]) +def test_the_naming_gate_never_waits_on_a_stdin_left_open(project, spelled, + exit_code, said): + """THE WAIT (L4-141). Under `--project` stdin is never read, so a pipe + nobody writes or closes cannot hold the gate, whatever `legs:` is. The + finding a manifest of no target still gets is said, exit 1 (`[5]` was + `naming-no-target`, which dropped it); one with nothing to say is still + refused `naming-no-target`, exit 2.""" + set_legs(project, spelled) + result = naming_gate_beside_an_open_pipe(project) + out = result.stdout + result.stderr + assert result.returncode == exit_code, out + assert "Traceback" not in out, out + assert said in result.stderr, out + if exit_code == 1: + assert "naming-no-target" not in result.stderr, out + + +def test_names_on_stdin_are_read_without_project_and_never_with_it(project): + """THE OTHER SIDE: a name piped in is classified as ever when no other + source names one, and is not read under `--project`, where a manifest + with no leg is refused `naming-no-target` and the name is not listed.""" + alone = run_script(NAMING_SCRIPT, cwd=project, input="openChart\n") + assert alone.returncode == 0, alone.stdout + alone.stderr + assert "openChart" in alone.stdout, alone.stdout + set_legs(project, "[]") + script, *args = GATES["naming"] + piped = run_script(project / script, *args, cwd=project, + input="openChart\n") + assert piped.returncode == 2, piped.stdout + piped.stderr + assert "REFUSED naming-no-target: " in piped.stderr, piped.stderr + assert "openChart" not in piped.stdout, piped.stdout + + +def test_validate_pins_still_checks_the_shape_and_neutral_pins(project): + """A `legs:` that is not a list checks no leg, and `validate-pins.py` + still checks the shape pin's copies and the neutral-product pins: a pins + field that is not a list beside it is said too, once each.""" + set_legs(project, "5") + set_pins(project, "5") + result = gate(project, "pins") + assert_said_once(result, legs_said(5)) + assert result.stderr.count( + "FINDING manifest-neutral-product-pins: neutral_product_pins is 5, " + "expected a list of neutral product names\n") == 1, result.stderr + assert "copied shape file(s) match their digests" in result.stdout, \ + result.stdout + + +# --- an entry that is not a name --------------------------------------------- + +#: `(entry, why)`: what `_not_a_name` says of each, in both files. +NOT_NAMES = [ + pytest.param(5, NOT_TEXT, id="number"), + pytest.param(True, NOT_TEXT, id="true"), + pytest.param(None, NOT_TEXT, id="null"), + pytest.param(["a"], NOT_TEXT, id="list"), + pytest.param({"a": "b"}, NOT_TEXT, id="mapping"), + pytest.param("", BLANK, id="empty"), + pytest.param(" ", BLANK, id="spaces"), + pytest.param(ESC, NOT_PRINTABLE, id="esc"), + pytest.param(NOT_UTF8, NOT_PRINTABLE, id="lone-surrogate"), + pytest.param("open‮Xdox", NOT_PRINTABLE, id="bidi-control"), + pytest.param("open\nXdox", NOT_PRINTABLE, id="newline"), +] + + +@pytest.mark.parametrize("entry,why", NOT_NAMES) +def test_an_entry_that_is_not_a_name_is_dropped_and_said_alike(readers, + entry, why): + """Both readers drop a pin entry that is not a name and keep the rest, + record no chain where a link is not a name, and say each in one + sentence, word for word.""" + naming, manifest = readers + assert naming._not_a_name(entry) == manifest._not_a_name(entry) == why + data = {"neutral_product_pins": ["openXdox", entry]} + assert naming._pins_from_project(data) == {"openXdox"} + assert manifest._declared_pins(data) == {"openXdox"} + said = ["FINDING " + line for line in naming._pins_field_findings(data)] + assert said == manifest._neutral_product_pins_type_finding(data) + assert said == [entry_said("pins", entry, why).rstrip("\n")] + leg = {"role": "assembly", + "naming": {"referent_chain": ["openXdox", entry, "openDox"]}} + assert naming._chain_from_leg(leg) == () + assert manifest._recorded_chain(leg["naming"]) == () + said = ["FINDING " + line for line in naming._chain_field_findings(leg)] + assert said == manifest._naming_chain_field_finding("assembly", + leg["naming"]) + assert said == [entry_said("chain", entry, why).rstrip("\n")] + + +@pytest.mark.parametrize("value,chain", [ + pytest.param(["openß", "öpenDox"], ("openß", "öpenDox"), + id="printable-letters-outside-ascii"), + pytest.param(" ", (), id="blank-string-is-the-empty-record"), + pytest.param(ESC, (), id="one-link-string-holding-esc"), +]) +def test_a_name_is_printable_text_and_a_blank_string_records_nothing( + readers, value, chain): + """A letter outside ASCII is printable and part of a name; a blank + string is the empty record a chain's `''` always was, no finding; and a + one-link string holding ESC is that link, refused.""" + naming, manifest = readers + leg = {"role": "assembly", "naming": {"referent_chain": value}} + assert naming._chain_from_leg(leg) == chain + assert manifest._recorded_chain(leg["naming"]) == chain + found = naming._chain_field_findings(leg) + assert ["FINDING " + line for line in found] == \ + manifest._naming_chain_field_finding("assembly", leg["naming"]) + assert bool(found) == (value == ESC), found + + +#: `(field, YAML spelling, the entry as read, why)`, the issue's own first. +ENTRIES_IN_A_GATE = [ + pytest.param("chain", '"\\x1b[31mRED"', ESC, NOT_PRINTABLE, + id="chain-one-link-string-esc"), + pytest.param("chain", '["\\x1b[31mRED"]', ESC, NOT_PRINTABLE, + id="chain-esc"), + pytest.param("chain", "[5]", 5, NOT_TEXT, id="chain-number"), + pytest.param("chain", "[[a]]", ["a"], NOT_TEXT, id="chain-list"), + pytest.param("chain", "[{a: b}]", {"a": "b"}, NOT_TEXT, id="chain-mapping"), + pytest.param("chain", "[true]", True, NOT_TEXT, id="chain-true"), + pytest.param("chain", "[openXdox, '']", "", BLANK, id="chain-empty-link"), + pytest.param("pins", '["\\x1b[31mRED"]', ESC, NOT_PRINTABLE, + id="pins-esc"), + pytest.param("pins", "[5]", 5, NOT_TEXT, id="pins-number"), + pytest.param("pins", "[[a]]", ["a"], NOT_TEXT, id="pins-list"), + pytest.param("pins", "[null]", None, NOT_TEXT, id="pins-null"), + pytest.param("pins", "['']", "", BLANK, id="pins-empty"), +] + + +@pytest.mark.parametrize("which", ["naming", "manifest"]) +@pytest.mark.parametrize("field,spelled,entry,why", ENTRIES_IN_A_GATE) +def test_each_gate_that_reads_the_field_says_an_entry_that_is_not_a_name( + project, which, field, spelled, entry, why): + """L4-142: a chain link or a declared pin that is not a name is a + finding, exit 1, from the naming gate as from the manifest gate, where + each exited 0; and an ESC in it never reaches the terminal raw.""" + if field == "pins": + set_pins(project, spelled) + else: + set_chain(project, spelled) + result = gate(project, which) + assert_said_once(result, entry_said(field, entry, why)) + assert "\x1b" not in result.stdout + result.stderr + + +@pytest.mark.parametrize("which", ["naming", "manifest"]) +@pytest.mark.parametrize("spelled", [ + pytest.param("[openß]", id="printable-letter-outside-ascii"), + pytest.param("' '", id="blank-string"), +]) +def test_a_chain_of_names_or_a_blank_string_keeps_its_answer(project, which, + spelled): + """THE OTHER SIDE, in the gates: neither is a finding, and each gate + exits 0 as it did.""" + set_chain(project, spelled) + result = gate(project, which) + out = result.stdout + result.stderr + assert result.returncode == 0, out + assert "naming-referent-chain" not in out, out + + +@pytest.mark.parametrize("spelled,said", [ + pytest.param("5", chain_said(5, "code"), id="not-a-list"), + pytest.param('"\\x1b[31mRED"', entry_said("chain", ESC, NOT_PRINTABLE, + "code"), id="link-not-a-name"), +]) +def test_the_chain_sentence_is_said_before_the_legs_other_finding( + project, spelled, said): + """L4-145, THE ORDER (#443's M6b): `_leg_target` says a leg's chain + sentence before its other findings, as `validate-manifest.py` does. Here + the spec leg is declared `code`, so the same leg has the declared-role + finding too, and the chain sentence comes first.""" + edit(project, " - role: spec\n repository: testorg/Atlas-spec", + " - role: code\n repository: testorg/Atlas-spec") + set_chain(project, spelled, SPEC_NAMING) + result = gate(project, "naming") + assert_said_once(result, said) + role = ("FINDING Atlas-spec: declared role 'code' but the name is the " + "'spec' form of the project-leg family") + assert role in result.stderr, result.stderr + assert result.stderr.index(said) < result.stderr.index(role), \ + result.stderr + + +# --- a name that is not UTF-8 ------------------------------------------------ + +#: A strict UTF-8 stdout and stdin, as the doctor gives its children. +STRICT_UTF8 = {"PYTHONIOENCODING": "utf-8"} + + +def assert_not_utf8_is_unclassified(result) -> None: + """Exit 1, no traceback, and the name spelled as `\\udcff` where it is + printed: `naming-unclassified` on stderr.""" + out = result.stdout + result.stderr + assert result.returncode == 1, out + assert "Traceback" not in out, out + assert "FINDING naming-unclassified: Atl\\udcffas\n" in result.stderr, out + + +@pytest.mark.skipif(sys.platform == "win32", + reason="a name that is not UTF-8 reaches argv only as " + "bytes, which a Windows command line does not hold") +@pytest.mark.parametrize("flags", [ + pytest.param(("--explain",), id="explain"), + pytest.param((), id="plain-listing"), +]) +def test_a_name_that_is_not_utf8_prints_escaped_to_a_strict_stdout( + tmp_path, flags): + """L4-02: `--explain` and the plain listing ended in a + `UnicodeEncodeError` on a strict stdout, where `--quiet` said + `naming-unclassified`; now stdout writes the name escaped, as + `validate-pins.py` writes what it cannot encode.""" + result = run_script(NAMING_SCRIPT, *flags, NOT_UTF8, cwd=tmp_path, + env=STRICT_UTF8) + assert_not_utf8_is_unclassified(result) + assert "Atl\\udcffas" in result.stdout, result.stdout + + +@pytest.mark.parametrize("flags", [ + pytest.param(("--quiet",), id="quiet"), + pytest.param((), id="plain-listing"), +]) +def test_a_name_on_stdin_that_is_not_utf8_reaches_naming_unclassified( + tmp_path, flags): + """L4-02: a strict stdin ended the run in a `UnicodeDecodeError`; now the + byte is read as argv reads it, so the name is the one argv gives and + reaches `naming-unclassified`.""" + names = tmp_path / "names" + names.write_bytes(b"Atl\xffas\n") + with names.open("rb") as stdin: + result = run_script(NAMING_SCRIPT, *flags, cwd=tmp_path, + env=STRICT_UTF8, stdin=stdin) + assert_not_utf8_is_unclassified(result) From cf376b7a815bf3a71acb9db75c5806d991566127 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sat, 17 Oct 2026 14:32:06 +0000 Subject: [PATCH 2/4] validate-repository-naming.py: under `--project`, a manifest that names no leg is `manifest-legs: legs is missing or empty`, exit 1, as `validate-manifest.py` says, and no longer the `naming-no-target` refusal that offered names on stdin (#714) Lane 4's ruling on the question the first commit left open. With `legs:` absent, null or `[]`, the gate refused `REFUSED naming-no-target: no names given. Pass names, `--project project.yaml`, or names on stdin.`, exit 2, to a run that had passed `--project project.yaml`. Under `--project` the gate no longer reads stdin, so two of the refusal's three offers were no way out. The manifest is what is wrong, so the gate now says `validate-manifest.py`'s finding for it, exit 1, in the same words. `_legs_field_findings` says it, beside the sentence for a `legs:` that is not a list. Under `--project` a run with no target now always has a finding. Without `--project`, `naming-no-target` is unchanged. Tests: rows for absent, null and `[]` under `--project`, each with stdin a pipe left open, each finishing with exit 1 and the finding; the same sentence from the naming and manifest gates; a name piped in under `--project` is not read; and the refusal's own words without `--project`. A mutant that restores the refusal fails the six rows and the piped-name test. Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Opus 5.5 --- scripts/validate-repository-naming.py | 28 +++++++--- tests/test_legs_not_a_list.py | 75 +++++++++++++++++++-------- 2 files changed, 74 insertions(+), 29 deletions(-) diff --git a/scripts/validate-repository-naming.py b/scripts/validate-repository-naming.py index 0389ff20..65678259 100755 --- a/scripts/validate-repository-naming.py +++ b/scripts/validate-repository-naming.py @@ -87,7 +87,8 @@ 1 a FINDING: at least one name matches no family, or a leg's declared role disagrees with the form of its name, or (#443) `neutral_product_pins:` or a leg's `naming.referent_chain:` is not a list, or (#714) `legs:` - is not a list, or an entry of the first two is not a name + is missing, empty or not a list, or an entry of the first two is not + a name 2 a REFUSAL: the question could not be asked at all — the policy file is missing, unparsable, or not a naming policy, or a `--link-source` names no path @@ -878,13 +879,21 @@ def _project_legs(data: dict) -> list: def _legs_field_findings(data: dict) -> list[str]: - """`validate-manifest.py`'s finding for a `legs:` that is not a list, in - its words (#714), as `validate-pins.py` says it too. Absent, null or - `[]` names no leg and is not this finding: with no other name, the run - refuses `naming-no-target`, as it did.""" + """`validate-manifest.py`'s finding for a `legs:` that is not a list, or + that is absent, null or `[]`, in its words (#714); `validate-pins.py` + says the first too. + + A MANIFEST THAT NAMES NO LEG IS THAT FINDING, NOT `naming-no-target` + (lane 4's ruling on #714). The refusal said "Pass names, `--project + project.yaml`, or names on stdin" to a run that had passed + `--project project.yaml`, and under `--project` this gate no longer + reads stdin, so two of its three offers were no way out. The manifest + is what is wrong, and `validate-manifest.py` says so, exit 1.""" legs = data.get("legs") - if legs is None or isinstance(legs, list): + if isinstance(legs, list) and legs: return [] + if legs is None or isinstance(legs, list): + return ["manifest-legs: legs is missing or empty"] return [f"manifest-legs: legs is {legs!r}, expected a list of `role:`, " "`repository:` and `path:` mappings"] @@ -1200,8 +1209,11 @@ def main(argv: list[str] | None = None) -> int: # question; a manifest that names no leg, or whose `legs:` is not a # list, read names from a stdin that is not a terminal, and a pipe left # open (a parent that holds it) made the gate wait forever. - # A finding with no name to classify is still said, exit 1: the - # `no names given` refusal is for a run that found nothing to ask. + # A finding with no name to classify is still said, exit 1, and under + # `--project` a run with no target always has one (`legs:` missing, + # empty or not a list, or a leg with no `repository:`): the + # `no names given` refusal is for a run without `--project` that found + # nothing to ask. if not targets and data is None and not sys.stdin.isatty(): targets = _targets_from_stdin(args.role, cli_pins, cli_chain, here) if not targets and not findings: diff --git a/tests/test_legs_not_a_list.py b/tests/test_legs_not_a_list.py index 46d3f2cb..d362c881 100644 --- a/tests/test_legs_not_a_list.py +++ b/tests/test_legs_not_a_list.py @@ -18,7 +18,11 @@ `repository:` and `path:` mappings and under `--project` the naming gate reads no stdin at all, whatever `legs:` -holds, so absent, null, `[]` and `[5]` cannot wait either. +holds, so absent, null, `[]` and `[5]` cannot wait either. A manifest that +names no leg (absent, null or `[]`) is `validate-manifest.py`'s `legs is +missing or empty` in the naming gate too, exit 1 (lane 4's ruling), and not +`REFUSED naming-no-target`, whose offers of `--project` and of names on stdin +were no way out under `--project`. Without `--project` that refusal stands. AN ENTRY THAT IS NOT A NAME (#443 left `[5]` and `[[a]]` unexamined) was read with `str()`: `[5]` was the pin or the link `'5'`, and a link holding ESC, @@ -79,6 +83,10 @@ NOT_UTF8 = "Atl\udcffas" +#: `validate-manifest.py`'s sentence for a `legs:` that names no leg. +LEGS_MISSING = "FINDING manifest-legs: legs is missing or empty\n" + + def legs_said(value) -> str: """`validate-manifest.py`'s sentence for `legs:`, whole.""" return (f"FINDING manifest-legs: legs is {value!r}, expected a list of " @@ -146,37 +154,62 @@ def test_each_gate_says_legs_that_are_not_a_list_in_one_sentence( assert wrong not in result.stderr, result.stderr -@pytest.mark.parametrize("spelled,exit_code,said", [ - pytest.param("openXdox", 1, legs_said("openXdox"), id="scalar-string"), - pytest.param("5", 1, legs_said(5), id="number"), - pytest.param("{a: b}", 1, legs_said({"a": "b"}), id="mapping"), - pytest.param(None, 2, "REFUSED naming-no-target: ", id="absent"), - pytest.param("", 2, "REFUSED naming-no-target: ", id="null"), - pytest.param("[]", 2, "REFUSED naming-no-target: ", id="empty-list"), - pytest.param("[5]", 1, "FINDING project.yaml: a leg has no `repository:`", +@pytest.mark.parametrize("spelled,said", [ + pytest.param("openXdox", legs_said("openXdox"), id="scalar-string"), + pytest.param("5", legs_said(5), id="number"), + pytest.param("{a: b}", legs_said({"a": "b"}), id="mapping"), + pytest.param(None, LEGS_MISSING, id="absent"), + pytest.param("", LEGS_MISSING, id="null"), + pytest.param("[]", LEGS_MISSING, id="empty-list"), + pytest.param("[5]", "FINDING project.yaml: a leg has no `repository:`", id="a-leg-that-is-not-a-mapping"), ]) def test_the_naming_gate_never_waits_on_a_stdin_left_open(project, spelled, - exit_code, said): + said): """THE WAIT (L4-141). Under `--project` stdin is never read, so a pipe - nobody writes or closes cannot hold the gate, whatever `legs:` is. The - finding a manifest of no target still gets is said, exit 1 (`[5]` was - `naming-no-target`, which dropped it); one with nothing to say is still - refused `naming-no-target`, exit 2.""" + nobody writes or closes cannot hold the gate, whatever `legs:` is, and + every manifest with no target is a finding, exit 1: `[5]` was + `naming-no-target`, which dropped its finding, and absent, null and `[]` + were that refusal, which offered `--project` and names on stdin to a run + that had passed `--project`. They are `validate-manifest.py`'s `legs is + missing or empty` now (lane 4's ruling).""" set_legs(project, spelled) result = naming_gate_beside_an_open_pipe(project) out = result.stdout + result.stderr - assert result.returncode == exit_code, out + assert result.returncode == 1, out assert "Traceback" not in out, out - assert said in result.stderr, out - if exit_code == 1: - assert "naming-no-target" not in result.stderr, out + assert result.stderr.count(said) == 1, out + assert "naming-no-target" not in result.stderr, out + + +@pytest.mark.parametrize("which", ["naming", "manifest"]) +@pytest.mark.parametrize("spelled", [ + pytest.param(None, id="absent"), + pytest.param("", id="null"), + pytest.param("[]", id="empty-list"), +]) +def test_a_manifest_that_names_no_leg_is_said_alike(project, which, spelled): + """`legs:` absent, null or `[]` is one sentence, exit 1, from the + naming gate as from the manifest gate.""" + set_legs(project, spelled) + assert_said_once(gate(project, which), LEGS_MISSING) + + +def test_without_project_no_name_is_still_refused_naming_no_target(tmp_path): + """THE OTHER SIDE of the ruling: with no `--project`, no name and + nothing on stdin, the run is refused `naming-no-target`, exit 2, in its + own words, as it always was.""" + result = run_script(NAMING_SCRIPT, cwd=tmp_path) + assert result.returncode == 2, result.stdout + result.stderr + assert result.stderr == ( + "REFUSED naming-no-target: no names given. Pass names, " + "`--project project.yaml`, or names on stdin.\n"), result.stderr def test_names_on_stdin_are_read_without_project_and_never_with_it(project): """THE OTHER SIDE: a name piped in is classified as ever when no other source names one, and is not read under `--project`, where a manifest - with no leg is refused `naming-no-target` and the name is not listed.""" + with no leg is `legs is missing or empty` and the name is not listed.""" alone = run_script(NAMING_SCRIPT, cwd=project, input="openChart\n") assert alone.returncode == 0, alone.stdout + alone.stderr assert "openChart" in alone.stdout, alone.stdout @@ -184,8 +217,8 @@ def test_names_on_stdin_are_read_without_project_and_never_with_it(project): script, *args = GATES["naming"] piped = run_script(project / script, *args, cwd=project, input="openChart\n") - assert piped.returncode == 2, piped.stdout + piped.stderr - assert "REFUSED naming-no-target: " in piped.stderr, piped.stderr + assert_said_once(piped, LEGS_MISSING) + assert "naming-no-target" not in piped.stderr, piped.stderr assert "openChart" not in piped.stdout, piped.stdout From 680e26ab929dfc7770b13a99924f031232eded31 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sat, 17 Oct 2026 19:11:32 +0000 Subject: [PATCH 3/4] validate-repository-naming.py and validate-pins.py: a `legs:` entry that is not a mapping is `manifest-leg: a leg is not a mapping` in every gate, the naming gate names a manifest by `_shown` relative to its root, and the adoption tests ask the validator about a bare pin that is not text (#714) Fix round 1 for RV826's review of #826, on lane 4's rulings. M1. `adopt-project.py` asks the project's own `validate-manifest.py` about the manifest it would write, and since #714 that validator refuses a `neutral_product_pins:` entry that is not text. So `--pin null`, `True` and `007`, written bare (`bare_pins`), are the validator's alone (rv181f's G1) and no longer #181's misread. RV826's patch moves them out of `REFUSED` into a test of their own (`PINS_NOT_TEXT`), at `plan`, in a plan and at `execute`, and `REFUSED`'s comment says so. M2. The bidi-control row of tests/test_legs_not_a_list.py held a raw U+202E; it is the escape `\u202e` now, the same value. S1. A row pins the `surrogateescape` half of the naming gate's stdout reconfigure: under `PYTHONIOENCODING=utf-8:surrogateescape` the listing writes `Atl\udcffas`, not the raw byte. S2, kept. `validate-pins.py` still refuses a manifest that names no leg `manifest-no-legs`, exit 2, and rows pin it, with no `manifest-legs`. S3. A `legs:` entry that is not a mapping (`[5]`, `[openXdox]`, `[[a]]`, `[null]`) was "a leg has no `repository:`" in the naming gate, untrue of `5`, and `REFUSED manifest-no-legs` with the `git submodule update` remedy in `validate-pins.py`. Each gate now says `validate-manifest.py`'s `manifest-leg: a leg is not a mapping: `, exit 1; `validate-pins.py` chooses `manifest-no-legs` only where no entry was one. A mapping with no `repository:` still says so, naming the manifest relative to the project root through `_shown`, where the `--project` path was printed raw, as given. N1. A row per gate pins its own order of the two field sentences, as it is: the naming gate says the pins first, `validate-pins.py` the legs first, and `validate-manifest.py` stops at `legs:`. Mutants MB, MC, ME, and the three reverts of S3, are each killed. Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Opus 5.5 --- scripts/validate-repository-naming.py | 15 +- .../assembly-root/scripts/validate-pins.py | 17 ++- tests/test_adopt_plan_yaml_quoting.py | 84 +++++++--- tests/test_legs_not_a_list.py | 144 +++++++++++++++++- 4 files changed, 231 insertions(+), 29 deletions(-) diff --git a/scripts/validate-repository-naming.py b/scripts/validate-repository-naming.py index 65678259..b24b29c1 100755 --- a/scripts/validate-repository-naming.py +++ b/scripts/validate-repository-naming.py @@ -815,9 +815,20 @@ def _leg_target(policy: NamingPolicy, path: Path, leg, root: Path, walks every leg in the manifest. `(None, [finding])` is a leg with no `repository:` to classify at all — the same case the loop's own `finding` list has always carried, now returned instead of appended in place. + + AN ENTRY THAT IS NOT A MAPPING IS `validate-manifest.py`'S `manifest-leg` + FINDING, in its words (#714), as `validate-pins.py` says it too: `5` + was "a leg has no `repository:`", which is not what is wrong with it. A + mapping with no `repository:` still says that, and names the manifest + relative to the project root, spelled by `_shown`: the `--project` path + was printed raw, as given, and the doctor gives it as its root joined + to `project.yaml`. """ - if not isinstance(leg, dict) or "repository" not in leg: - return None, [f"{path}: a leg has no `repository:`"] + if not isinstance(leg, dict): + return None, [f"manifest-leg: a leg is not a mapping: {leg!r}"] + if "repository" not in leg: + manifest = _shown(path.resolve().relative_to(root).as_posix()) + return None, [f"{manifest}: a leg has no `repository:`"] name = repo_basename(str(leg["repository"])) declared = leg.get("role") declared = str(declared) if declared else None diff --git a/templates/assembly-root/scripts/validate-pins.py b/templates/assembly-root/scripts/validate-pins.py index efe49870..6b09cb7a 100755 --- a/templates/assembly-root/scripts/validate-pins.py +++ b/templates/assembly-root/scripts/validate-pins.py @@ -1505,8 +1505,15 @@ def _load_legs_to_check(root: Path, report: Report was walked one character, or one key, at a time, found no leg, and was refused `manifest-no-legs` with the lockstep remedy, which says to run `git submodule update`: nothing to do with the cause. The shape pin and - the neutral-product pins are still checked. Absent, null or `[]` is - still that refusal. + the neutral-product pins are still checked. A refusal one of them makes + is printed alone, as every refusal here is, so the finding is not + printed then. Absent, null or `[]` is still that refusal. + + AN ENTRY THAT IS NOT A MAPPING IS `validate-manifest.py`'S `manifest-leg` + FINDING, in its words (#714), as the naming gate says it too, and the + refusal is chosen only where no entry was one: `legs: [5]` was refused + `manifest-no-legs` with that same remedy, where the other two gates + found the entry. """ manifest_path = root / "project.yaml" if not manifest_path.is_file(): @@ -1526,9 +1533,13 @@ def _load_legs_to_check(root: Path, report: Report f"legs is {declared!r}, expected a list of `role:`, " "`repository:` and `path:` mappings") return manifest, [] + not_mappings = [leg for leg in (declared or []) + if not isinstance(leg, dict)] + for leg in not_mappings: + report.finding("manifest-leg", f"a leg is not a mapping: {leg!r}") legs = [leg for leg in (declared or []) if isinstance(leg, dict) and leg.get("role") != "assembly"] - if not legs: + if not legs and not not_mappings: raise Refusal("manifest-no-legs", f"{manifest_path}: no non-assembly legs declared") return manifest, legs diff --git a/tests/test_adopt_plan_yaml_quoting.py b/tests/test_adopt_plan_yaml_quoting.py index dd50350e..ec309ed9 100644 --- a/tests/test_adopt_plan_yaml_quoting.py +++ b/tests/test_adopt_plan_yaml_quoting.py @@ -458,27 +458,28 @@ def test_a_value_is_asked_about_in_each_place_it_is_written( #: `(flag, value, plan key, what the project's loader reads)`: values the #: project's loader misreads on a line written bare, the branch as -#: `tracking_branch:` was before #259 (`bare_template`) and a pin's product -#: as an item of the manifest's `neutral_product_pins:` list was before #514 -#: (`bare_pins`). At 76a6153 `--tracking-branch true` and `--pin null` went -#: through `execute` to `adoption verified`, the legs pushed on one branch -#: and put on another by the first bootstrap, and the pin gone; before #514 -#: `--pin 007@` did, over a root whose own `validate-pins.py` refused -#: `entry 7`. The project's own validator, which reads the shipped -#: templates, makes no finding about any of them, and each branch is one the -#: source has (`holding`), so each is #181's, at `plan` and in a plan. Since -#: #273 a `--pin` is `@<40 hex>` and a plan's pin a whole one -#: (`flag_args`, `whole_pin`): the value is the pin's product. On the -#: templates shipped since #259, and since #514 for the pins, each reads back -#: as typed (`READ_BACK`), so these rows ask the rule's own answer, in this -#: process. +#: `tracking_branch:` was before #259 (`bare_template`). At 76a6153 +#: `--tracking-branch true` went through `execute` to `adoption verified`, +#: the legs pushed on one branch and put on another by the first bootstrap. +#: The project's own validator, which reads the shipped templates, makes no +#: finding about any of them, and each branch is one the source has +#: (`holding`), so each is #181's, at `plan` and in a plan. On the templates +#: shipped since #259 each reads back as typed (`READ_BACK`), so these rows +#: ask the rule's own answer, in this process. +#: +#: A pin's product, an item of the manifest's `neutral_product_pins:` list +#: written bare as before #514 (`bare_pins`), was a row here until #714. At +#: 76a6153 `--pin null` went through `execute` with the pin gone, and before +#: #514 `--pin 007@` did, over a root whose own `validate-pins.py` +#: refused `entry 7`. Since #714 the project's own `validate-manifest.py` +#: refuses an entry that is not text, so each is the validator's alone +#: (`PINS_NOT_TEXT`). Since #273 a `--pin` is `@<40 hex>` and a +#: plan's pin a whole one (`flag_args`, `whole_pin`): the value is the pin's +#: product. REFUSED = [ (TRACKING_FLAG, "true", TRACKING_KEY, TRUE_READ), (TRACKING_FLAG, "null", TRACKING_KEY, NO_VALUE), (TRACKING_FLAG, "007", TRACKING_KEY, SEVEN_READ), - (PIN_FLAG, "null", PINS_KEY, NO_VALUE), - (PIN_FLAG, "True", PINS_KEY, TRUE_READ), - (PIN_FLAG, "007", PINS_KEY, SEVEN_READ), ] #: #181's, as `plan` is given it, and as a plan carries it. PLANNED = [(flag, value, reading) for flag, value, _, reading in REFUSED] @@ -736,6 +737,55 @@ def test_plan_refuses_a_value_the_projects_loader_would_misread( VALIDATOR_REFUSES = "which the project's own validate-manifest.py refuses: " +#: `(--pin value, what the project's loader reads of the bare item)`. These +#: were `REFUSED`'s pin rows, #181's; since #714 the project's own +#: `validate-manifest.py` refuses a `neutral_product_pins:` entry that is not +#: text, so each is the validator's alone (rv181f's G1), at `plan`, in a plan +#: and at `execute`. +PINS_NOT_TEXT = [("null", None), ("True", True), ("007", 7)] + + +def pin_not_text(read) -> str: + """The validator's sentence for a pin entry that is not text (#714).""" + return (f"manifest-neutral-product-pins: neutral_product_pins entry " + f"{read!r} is not a neutral product name: it is not text") + + +@needs_filter_repo +@pytest.mark.parametrize("value, read", PINS_NOT_TEXT, + ids=[value for value, _ in PINS_NOT_TEXT]) +def test_a_bare_pin_that_is_not_text_is_the_validators_alone( + adopter, answered_copy, glass, bare_template, bare_pins, tmp_path, + capsys, value, read): + """`plan` refuses it `adopt-manifest-invalid`, `check` says it once as + `plan-manifest-invalid`, and `execute` refuses it before any leg + exists, in the validator's words and never as #181's misread.""" + source, text = answered_copy + plan = tmp_path / PLAN_FILE + code, _, stderr = run_here(adopter, capsys, *plan_argv( + source, plan, tmp_path, *flag_args(source, glass, PIN_FLAG, value))) + assert code == 2, stderr + assert stderr.startswith("REFUSED adopt-manifest-invalid: "), stderr + assert pin_not_text(read) in stderr, stderr + assert MISREAD_CODE not in stderr, stderr + assert not plan.exists() + carrying(plan, text, PINS_KEY, adopter.y(value)) + code, stdout, stderr = run_here(adopter, capsys, "check", PLAN_OPTION, + str(plan)) + assert code == 1, stdout + stderr + (line,) = judged_lines(stderr) + assert line == INVALID_FINDING + ( + "the manifest this run would write is one the project's own " + f"validate-manifest.py refuses: {pin_not_text(read)}."), stderr + code, stdout, stderr = run_here(adopter, capsys, + *execute_argv(plan, tmp_path)) + assert code == 2, stdout + stderr + assert stderr.startswith("REFUSED plan-manifest-invalid: "), stderr + assert MISREAD_FINDING not in stderr, stderr + assert not (tmp_path / REMOTES).exists() + assert SPLIT_BRANCH not in branches_of(source) + + @pytest.fixture def bare_template(adopter, tmp_path, monkeypatch) -> Path: """The templates #181's gate reads (`ASSEMBLY_TEMPLATE`), each line #259 diff --git a/tests/test_legs_not_a_list.py b/tests/test_legs_not_a_list.py index d362c881..2e32b9c6 100644 --- a/tests/test_legs_not_a_list.py +++ b/tests/test_legs_not_a_list.py @@ -23,6 +23,11 @@ missing or empty` in the naming gate too, exit 1 (lane 4's ruling), and not `REFUSED naming-no-target`, whose offers of `--project` and of names on stdin were no way out under `--project`. Without `--project` that refusal stands. +`validate-pins.py` still refuses a manifest that names no leg +`manifest-no-legs`, exit 2: it has nothing to check. A list holding an entry +that is not a mapping (`[5]`) is `validate-manifest.py`'s `manifest-leg: a leg +is not a mapping: 5` in all three gates, where the naming gate said "a leg has +no `repository:`" and `validate-pins.py` refused `manifest-no-legs`. AN ENTRY THAT IS NOT A NAME (#443 left `[5]` and `[[a]]` unexamined) was read with `str()`: `[5]` was the pin or the link `'5'`, and a link holding ESC, @@ -63,7 +68,7 @@ from test_manifest_validator import edit from test_non_list_project_fields import ( # noqa: F401 GATES, NOT_A_LIST, SPEC_NAMING, assert_said_once, chain_said, gate, - readers, set_chain, set_pins, spelled_as, + pins_said, readers, set_chain, set_pins, spelled_as, ) NAMING_SCRIPT = REPO / "scripts" / "validate-repository-naming.py" @@ -161,7 +166,7 @@ def test_each_gate_says_legs_that_are_not_a_list_in_one_sentence( pytest.param(None, LEGS_MISSING, id="absent"), pytest.param("", LEGS_MISSING, id="null"), pytest.param("[]", LEGS_MISSING, id="empty-list"), - pytest.param("[5]", "FINDING project.yaml: a leg has no `repository:`", + pytest.param("[5]", "FINDING manifest-leg: a leg is not a mapping: 5\n", id="a-leg-that-is-not-a-mapping"), ]) def test_the_naming_gate_never_waits_on_a_stdin_left_open(project, spelled, @@ -195,6 +200,111 @@ def test_a_manifest_that_names_no_leg_is_said_alike(project, which, spelled): assert_said_once(gate(project, which), LEGS_MISSING) +@pytest.mark.parametrize("spelled", [ + pytest.param(None, id="absent"), + pytest.param("", id="null"), + pytest.param("[]", id="empty-list"), +]) +def test_validate_pins_still_refuses_a_manifest_that_names_no_leg( + project, spelled): + """KEPT (lane 4's ruling on RV826's S2): where the other two gates say + `legs is missing or empty`, exit 1, `validate-pins.py` still refuses a + manifest that names no leg `manifest-no-legs`, exit 2, and says no + `manifest-legs` finding. Its `git submodule update` remedy cannot add a + leg; that is for #460's checklist.""" + set_legs(project, spelled) + result = gate(project, "pins") + out = result.stdout + result.stderr + assert result.returncode == 2, out + assert "Traceback" not in out, out + assert "REFUSED manifest-no-legs: " in result.stderr, out + assert "manifest-legs" not in result.stderr, out + + +#: `(YAML spelling, the entry as read)`: a `legs:` list holding an entry +#: that is not a mapping. +NOT_MAPPINGS = [ + pytest.param("[5]", 5, id="number"), + pytest.param("[openXdox]", "openXdox", id="string"), + pytest.param("[[a]]", ["a"], id="list"), + pytest.param("[null]", None, id="null"), +] + + +@pytest.mark.parametrize("which", ["naming", "manifest", "pins"]) +@pytest.mark.parametrize("spelled,entry", NOT_MAPPINGS) +def test_each_gate_says_a_leg_that_is_not_a_mapping_in_one_sentence( + project, which, spelled, entry): + """RV826's S3: `legs: [5]` was three answers. The naming gate said "a + leg has no `repository:`", untrue of `5`, and `validate-pins.py` + refused `manifest-no-legs` with the `git submodule update` remedy. Now + each gate says `validate-manifest.py`'s `manifest-leg` line, exit 1.""" + set_legs(project, spelled) + result = gate(project, which) + assert_said_once( + result, f"FINDING manifest-leg: a leg is not a mapping: {entry!r}\n") + for wrong in ("manifest-no-legs", "git submodule update", "a leg has no"): + assert wrong not in result.stderr, result.stderr + + +@pytest.mark.parametrize("named,said", [ + pytest.param("project.yaml", "project.yaml", id="as-scaffolded"), + pytest.param("pro\x1bject.yaml", "'pro\\x1bject.yaml'", id="esc", + marks=pytest.mark.skipif( + sys.platform == "win32", + reason="a Windows file name holds no control " + "character")), +]) +def test_a_leg_with_no_repository_names_the_manifest_relative_and_spelled( + project, tmp_path, named, said): + """RV826's S3: a mapping with no `repository:` names the manifest + relative to the project root, through `_shown`. The `--project` path was + printed raw, as given: in full from another directory, and with an ESC + in it reaching the terminal.""" + set_legs(project, "[{role: spec}]") + manifest = project / named + if named != "project.yaml": + (project / "project.yaml").rename(manifest) + script, *_ = GATES["naming"] + result = run_script(project / script, "--project", str(manifest), + cwd=tmp_path) + assert_said_once(result, f"FINDING {said}: a leg has no `repository:`\n") + assert str(project) not in result.stderr, result.stderr + assert "\x1b" not in result.stderr, result.stderr + + +#: `(gate, legs:, neutral_product_pins:, the sentences in the order said)`: +#: each gate's own order, pinned as it is (RV826's N1). +ORDERS = [ + pytest.param("naming", "[]", "[5]", + [entry_said("pins", 5, NOT_TEXT), LEGS_MISSING], + id="naming-pins-then-legs"), + pytest.param("pins", "5", "5", [legs_said(5), pins_said(5)], + id="pins-legs-then-pins"), + pytest.param("manifest", "[]", "[5]", [LEGS_MISSING], + id="manifest-legs-alone"), +] + + +@pytest.mark.parametrize("which,legs,pins,said", ORDERS) +def test_each_gate_says_the_two_fields_in_its_own_order( + project, which, legs, pins, said): + """THE ORDER (RV826's N1), as each gate says it: the naming gate says + the pins before the legs, `validate-pins.py` the legs before the pins, + and `validate-manifest.py` stops at `legs:`, so it gives one finding + where the naming gate gives two.""" + set_legs(project, legs) + set_pins(project, pins) + result = gate(project, which) + for line in said: + assert_said_once(result, line) + at = [result.stderr.index(line) for line in said] + assert at == sorted(at), result.stderr + if which == "manifest": + assert "manifest-neutral-product-pins" not in result.stderr + assert "\n1 finding(s) in " in result.stderr, result.stderr + + def test_without_project_no_name_is_still_refused_naming_no_target(tmp_path): """THE OTHER SIDE of the ruling: with no `--project`, no name and nothing on stdin, the run is refused `naming-no-target`, exit 2, in its @@ -250,7 +360,7 @@ def test_validate_pins_still_checks_the_shape_and_neutral_pins(project): pytest.param(" ", BLANK, id="spaces"), pytest.param(ESC, NOT_PRINTABLE, id="esc"), pytest.param(NOT_UTF8, NOT_PRINTABLE, id="lone-surrogate"), - pytest.param("open‮Xdox", NOT_PRINTABLE, id="bidi-control"), + pytest.param("open\u202eXdox", NOT_PRINTABLE, id="bidi-control"), pytest.param("open\nXdox", NOT_PRINTABLE, id="newline"), ] @@ -308,7 +418,8 @@ def test_a_name_is_printable_text_and_a_blank_string_records_nothing( id="chain-esc"), pytest.param("chain", "[5]", 5, NOT_TEXT, id="chain-number"), pytest.param("chain", "[[a]]", ["a"], NOT_TEXT, id="chain-list"), - pytest.param("chain", "[{a: b}]", {"a": "b"}, NOT_TEXT, id="chain-mapping"), + pytest.param("chain", "[{a: b}]", {"a": "b"}, NOT_TEXT, + id="chain-mapping"), pytest.param("chain", "[true]", True, NOT_TEXT, id="chain-true"), pytest.param("chain", "[openXdox, '']", "", BLANK, id="chain-empty-link"), pytest.param("pins", '["\\x1b[31mRED"]', ESC, NOT_PRINTABLE, @@ -380,6 +491,11 @@ def test_the_chain_sentence_is_said_before_the_legs_other_finding( #: A strict UTF-8 stdout and stdin, as the doctor gives its children. STRICT_UTF8 = {"PYTHONIOENCODING": "utf-8"} +NOT_UTF8_ON_ARGV = pytest.mark.skipif( + sys.platform == "win32", + reason="a name that is not UTF-8 reaches argv only as bytes, which a " + "Windows command line does not hold") + def assert_not_utf8_is_unclassified(result) -> None: """Exit 1, no traceback, and the name spelled as `\\udcff` where it is @@ -390,9 +506,7 @@ def assert_not_utf8_is_unclassified(result) -> None: assert "FINDING naming-unclassified: Atl\\udcffas\n" in result.stderr, out -@pytest.mark.skipif(sys.platform == "win32", - reason="a name that is not UTF-8 reaches argv only as " - "bytes, which a Windows command line does not hold") +@NOT_UTF8_ON_ARGV @pytest.mark.parametrize("flags", [ pytest.param(("--explain",), id="explain"), pytest.param((), id="plain-listing"), @@ -409,6 +523,22 @@ def test_a_name_that_is_not_utf8_prints_escaped_to_a_strict_stdout( assert "Atl\\udcffas" in result.stdout, result.stdout +@NOT_UTF8_ON_ARGV +def test_a_surrogateescape_stdout_writes_the_name_escaped_too(tmp_path): + """RV826's S1: a `surrogateescape` stdout, the one `C.UTF-8` gives and + here asked for by name, wrote the name's byte back raw, `\\xff`. It is + `backslashreplace` now, as `validate-pins.py`'s `_emit_report` makes it, + so the listing spells the name as stderr's `naming-unclassified` does. + Read as bytes: a raw byte is what is asked about.""" + result = subprocess.run( + [sys.executable, str(NAMING_SCRIPT), NOT_UTF8], cwd=str(tmp_path), + env={**os.environ, "PYTHONIOENCODING": "utf-8:surrogateescape"}, + stdin=subprocess.DEVNULL, capture_output=True, check=False) + assert result.returncode == 1, result.stdout + result.stderr + assert b"Atl\\udcffas" in result.stdout, result.stdout + assert b"\xff" not in result.stdout, result.stdout + + @pytest.mark.parametrize("flags", [ pytest.param(("--quiet",), id="quiet"), pytest.param((), id="plain-listing"), From 124bbd7b2a87f093715cf63750935eb5cc250f14 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sat, 17 Oct 2026 21:54:07 +0000 Subject: [PATCH 4/4] tests/test_legs_not_a_list.py: a `legs:` list holding two entries that are not mappings, `[5, openXdox]`, is said once per entry in every gate, so `validate-pins.py` saying only the first fails a test (#714) RV826's NIT 1 on #826: the mutant MK, `for leg in not_mappings[:1]:` in `validate-pins.py`'s `_load_legs_to_check`, passed all 109 tests of this file, because no row held two such entries. The row `[5, openXdox]` asserts both `manifest-leg: a leg is not a mapping` lines, each once, in the naming, manifest and pins gates. MK: `109 passed` before the row, `1 failed, 111 passed` with it (`[two-entries-pins]`). Lane: openRepoShape-4 Co-Authored-By: Claude Opus 5.5 --- tests/test_legs_not_a_list.py | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/tests/test_legs_not_a_list.py b/tests/test_legs_not_a_list.py index 2e32b9c6..168e95a3 100644 --- a/tests/test_legs_not_a_list.py +++ b/tests/test_legs_not_a_list.py @@ -221,28 +221,33 @@ def test_validate_pins_still_refuses_a_manifest_that_names_no_leg( assert "manifest-legs" not in result.stderr, out -#: `(YAML spelling, the entry as read)`: a `legs:` list holding an entry -#: that is not a mapping. +#: `(YAML spelling, the entries as read)`: a `legs:` list holding entries +#: that are not mappings, one, or two in one list (RV826's NIT 1: a gate +#: that said only the first of them passed every other row). NOT_MAPPINGS = [ - pytest.param("[5]", 5, id="number"), - pytest.param("[openXdox]", "openXdox", id="string"), - pytest.param("[[a]]", ["a"], id="list"), - pytest.param("[null]", None, id="null"), + pytest.param("[5]", [5], id="number"), + pytest.param("[openXdox]", ["openXdox"], id="string"), + pytest.param("[[a]]", [["a"]], id="list"), + pytest.param("[null]", [None], id="null"), + pytest.param("[5, openXdox]", [5, "openXdox"], id="two-entries"), ] @pytest.mark.parametrize("which", ["naming", "manifest", "pins"]) -@pytest.mark.parametrize("spelled,entry", NOT_MAPPINGS) +@pytest.mark.parametrize("spelled,entries", NOT_MAPPINGS) def test_each_gate_says_a_leg_that_is_not_a_mapping_in_one_sentence( - project, which, spelled, entry): + project, which, spelled, entries): """RV826's S3: `legs: [5]` was three answers. The naming gate said "a leg has no `repository:`", untrue of `5`, and `validate-pins.py` refused `manifest-no-legs` with the `git submodule update` remedy. Now - each gate says `validate-manifest.py`'s `manifest-leg` line, exit 1.""" + each gate says `validate-manifest.py`'s `manifest-leg` line, exit 1, + once for each entry that is not a mapping.""" set_legs(project, spelled) result = gate(project, which) - assert_said_once( - result, f"FINDING manifest-leg: a leg is not a mapping: {entry!r}\n") + for entry in entries: + assert_said_once( + result, + f"FINDING manifest-leg: a leg is not a mapping: {entry!r}\n") for wrong in ("manifest-no-legs", "git submodule update", "a leg has no"): assert wrong not in result.stderr, result.stderr