diff --git a/scripts/validate-repository-naming.py b/scripts/validate-repository-naming.py index fcd5a1c7..b24b29c1 100755 --- a/scripts/validate-repository-naming.py +++ b/scripts/validate-repository-naming.py @@ -86,7 +86,9 @@ 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 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 @@ -138,6 +140,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 +707,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 +725,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 +755,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, @@ -756,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 @@ -806,11 +876,44 @@ 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, 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 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"] + + 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 +955,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 +977,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 +1180,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 +1216,18 @@ 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, 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: + 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 aec816a9..0fde1257 100755 --- a/templates/assembly-root/scripts/validate-pins.py +++ b/templates/assembly-root/scripts/validate-pins.py @@ -1748,13 +1748,31 @@ def _note_unused_pin_sources(sources: PinSources, report: Report, 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. `project.yaml` is named relative to the root, as every file this validator names is, where three refusals named its absolute path raw, and it is refused by name where it cannot be reached or read (#712). + + 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. 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 _is_file(root, manifest_path): @@ -1767,9 +1785,20 @@ def _load_legs_to_check(root: Path) -> tuple[dict, list[dict]]: manifest = _load(root, manifest_path) if not isinstance(manifest, dict): raise Refusal("manifest-unreadable", "project.yaml: 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, [] + 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", "project.yaml: no non-assembly legs declared") return manifest, legs @@ -1780,7 +1809,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_adopt_plan_yaml_quoting.py b/tests/test_adopt_plan_yaml_quoting.py index a3a51178..a1a29102 100644 --- a/tests/test_adopt_plan_yaml_quoting.py +++ b/tests/test_adopt_plan_yaml_quoting.py @@ -460,27 +460,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] @@ -738,6 +739,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 new file mode 100644 index 00000000..168e95a3 --- /dev/null +++ b/tests/test_legs_not_a_list.py @@ -0,0 +1,561 @@ +# 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. 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. +`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, +`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, + pins_said, 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" + + +#: `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 " + "`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,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 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, + 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, 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 == 1, out + assert "Traceback" not in out, 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) + + +@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 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, openXdox]", [5, "openXdox"], id="two-entries"), +] + + +@pytest.mark.parametrize("which", ["naming", "manifest", "pins"]) +@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, 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, + once for each entry that is not a mapping.""" + set_legs(project, spelled) + result = gate(project, which) + 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 + + +@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 + 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 `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 + set_legs(project, "[]") + script, *args = GATES["naming"] + piped = run_script(project / script, *args, cwd=project, + input="openChart\n") + assert_said_once(piped, LEGS_MISSING) + assert "naming-no-target" not 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\u202eXdox", 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"} + +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 + 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 + + +@NOT_UTF8_ON_ARGV +@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 + + +@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"), +]) +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)