From 0bc29c945afb8ad604dce418fa83922fb8809fab Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sat, 17 Oct 2026 10:55:49 +0000 Subject: [PATCH 1/2] repo_shape.py, shape_materialize.py and adopt-project.py: a value the YAML reader refuses for opening with an indicator is named by its line and says to double-quote it or reword it, `plan --org @x` names `--org` again under the reader's own remedy, and `check` names one bad `tracking_branch` once (#708) Three refusals named the value to change on main or in v1.0.1, and a v1.1.0 change took that away (lane 4's L4-151, L4-152 and L4-127). L4-151, from #635 (#392, #393). The reader refuses a plain value that opens with an indicator, so an adoption plan whose hand-written `resolution:` opens with a backtick, which AGENTS.md's step 2 makes likely (`` `adopt-project.py` reads it ``), ended `check` in `REFUSED yaml-unparsable: : `, exit 2, with no line among the hundreds a plan holds and no remedy, where it checked clean before. - `YamlError` carries a `line` and a `remediation`. `_scalar` and `_flow` read text and know no line, so the block reader records it: the new `_block_value` reads a block mapping's value and a block sequence's scalar item (`_parse_mapping`'s lone `-` refusal moves with it), and sets the line on a refusal raised there that carries a remedy. - `load_yaml` names it, `REFUSED yaml-unparsable: : line N: `, and passes the refusal's remedy on. The words of `parse_yaml` are unchanged: the tools that ask the reader about ONE rendered line quote them in a sentence of their own, where `line 1:` would be noise. - `_indicator_refusal` carries the remedy: write the value in double quotes, spelled by `double_quoted` (so the remedy's spelling reads back as the value), or reword it. That is #393's block-value openers and #392's flow-entry openers. The other refusals carry none (#370): the fix for an alias or an unclosed quote is no one edit. - Only a refusal with that remedy names its line. A line on every value's refusal (an alias, an unclosed flow, an empty flow entry) was measured: it changed words that the naming gate's `declared-unverified` reason and the root's `validate-pins.py` print whole, pinned by tests/test_pinned_copy_text.py, tests/test_naming_policy.py and tests/test_refusal_default_remediation.py, which is beyond this issue. - The project's own gates read their files with this copy: an indicator in a pin file is now the quoting remedy under `validate-pins.py`, where it printed the lockstep one, which is not a syntax error's remedy. L4-152, from #635. The reader refuses `repository: @x/Northwind`, so `plan --org @x` (a typo of `@opensoft`: GitHub owners never begin with `@`) stopped reaching the validator, whose `manifest-leg-repository` named `--org` (`FINDING_PLACEHOLDER`). It said "the manifest this run would write is a file the project's own YAML reader refuses", named no value, and its remedy said "the words after each value are the validator's own". - `_unread_sentences` renders each template line that carries a value of the caller's, by its own placeholder or by one `CARRIED_BY` names (each leg's `repository:` carries `ORG`, as the validator's finding about it does), reads it alone as `_misread_rows` reads one, and names the value where the reader refuses its line: `--org is '@x', which the project's own YAML reader refuses: `, main's sentence with the reader in the validator's place. The validator's own words cannot be had: it never reads a file the reader refuses. Where no such line is refused, the sentence is the reader's refusal of the file, as before. - `MANIFEST_UNREADABLE_REMEDIATION` is that case's remedy: choose another value for each one named, and where none is named, the text the reader's words quote holds it; the words are the reader's. `manifest_value_problems` gives the case the kind `unreadable`, and `refuse_manifest_values` (`plan`, the scaffold) and `check`'s `_manifest_findings` (and so `execute`) give it that remedy, under the code they gave it before, `-manifest-invalid`. `MANIFEST_INVALID_REMEDIATION` is not reworded, so `scripts/family.py`'s copy of it does not move, and `family.py init --org @x` stays refused. L4-127, from #644 (#455). The validator finds a NUL or a lone surrogate in `tracking_branch`, and `check` asks it, so `tracking_branch: "a\x00b"` was `unsafe-value`, then `plan-manifest-invalid` in the validator's words, which name no plan key. No such value is in `checked_value`'s alphabet, so `unsafe-value` names every one. `_validator_ignores` leaves the validator's `manifest-tracking-branch` out where `checked_value` refuses the branch, as `LEG_PATH_CODES` leaves the leg paths to `_leg_findings`, and `_manifest_findings` and `_validator_judged` ask it alike. It asks `checked_value` alone, which starts no process, as the validator's question must not (test_the_question_runs_no_process_at_all). The issue's other option, a `FINDING_PLACEHOLDER` row, was measured: it names the key and still prints two findings. Pinned copy. `scripts/repo_shape.py` is the one pinned shape copy this changes. No digest of it is recorded in this tree; each root takes it through `update-shape.py` as an `upstream-changed` row, with v1.1.0's other bumps. Tests: tests/test_reader_refusals_named.py (new). In tests/test_yaml_indicator_openers.py, two assertions that pinned an opener's refusal without its line expect `line N:` on its row; in tests/test_adopt_plan_yaml_quoting.py, one that pinned the validator's remedy under the reader's refusal of a whole manifest expects the reader's. Closes #708 Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Opus 5.5 --- adopt-project.py | 47 +++- scripts/repo_shape.py | 76 +++++- scripts/shape_materialize.py | 95 +++++++- tests/test_adopt_plan_yaml_quoting.py | 4 +- tests/test_reader_refusals_named.py | 336 ++++++++++++++++++++++++++ tests/test_yaml_indicator_openers.py | 10 +- 6 files changed, 541 insertions(+), 27 deletions(-) create mode 100644 tests/test_reader_refusals_named.py diff --git a/adopt-project.py b/adopt-project.py index 4e36ec55..5d8cdd5b 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -240,8 +240,9 @@ # into its manifest, and whether a repository's name can be a directory's: # asked of the plan's values by `plan`, `check` and `execute` (#261). from shape_materialize import ( # noqa: E402 - MANIFEST_INVALID_REMEDIATION, manifest_judged, manifest_value_problems, - refuse_manifest_values, refuse_unstorable_names, + MANIFEST_INVALID_REMEDIATION, MANIFEST_UNREADABLE_REMEDIATION, + manifest_judged, manifest_value_problems, refuse_manifest_values, + refuse_unstorable_names, ) # Whether a value this tool writes into a file is text a file can hold # (#255), asked beside `one_printable_line`, the trailer's question. @@ -4220,12 +4221,18 @@ def _manifest_findings(plan: Plan) -> tuple[list[str], list[str]]: if values is None: return [], [] problems = manifest_value_problems(values, naming, _named_as_read(plan), - misreads=False, ignore=LEG_PATH_CODES) + misreads=False, + ignore=_validator_ignores(plan)) if not problems: return [], [] + # Where the project's own YAML reader refuses the manifest, the words + # are the reader's, and so is the remedy (#708). + remedy = (MANIFEST_UNREADABLE_REMEDIATION + if problems[0][0] == "unreadable" + else MANIFEST_INVALID_REMEDIATION) return ([f"FINDING plan-manifest-invalid: {sentence}" for _, sentence in problems], - [f"{MANIFEST_INVALID_REMEDIATION} `execute` writes the plan's " + [f"{remedy} `execute` writes the plan's " "values into the project's `project.yaml` as they stand, so " "correct each one named here in the plan, or re-run `plan` with " "a value the validator accepts."]) @@ -4241,7 +4248,37 @@ def _validator_judged(plan: Plan) -> frozenset[str] | None: values = _manifest_values(plan, naming) if values is None: return None - return manifest_judged(values, naming, ignore=LEG_PATH_CODES) + return manifest_judged(values, naming, ignore=_validator_ignores(plan)) + + +#: The validator's finding about `tracking_branch:` (#455), which `check` +#: leaves to `unsafe-value` wherever that names the branch (#708). +TRACKING_BRANCH_CODES = ("manifest-tracking-branch",) + + +def _validator_ignores(plan: Plan) -> tuple: + """The codes of the validator's findings `check` leaves out, because a + rule of its own names the same value: a leg path's always + (`LEG_PATH_CODES`), and the tracking branch's where `checked_value` + refuses that branch, which `_branch_name_findings` reports as + `unsafe-value` (#708). + + ONE BAD `tracking_branch` IS ONE FINDING. Since #644 (#455) the + validator finds a NUL or a lone surrogate in it, and `check` asks the + validator, so `tracking_branch: "a\\x00b"` was `unsafe-value`, and + then `plan-manifest-invalid` again in the validator's words, which + name no plan key. No such value is in `checked_value`'s alphabet, so + `unsafe-value`, `execute`'s own refusal of it, names every one, and + the validator's sentence is left out where it does. `checked_value` + alone is asked, the value `execute` reads (`_branch_name_refusal`'s), + because the validator's question starts no process (git's verdict on + a name is `_branch_name_findings`' own). + """ + try: + checked_value(TRACKING_KEY, plan.get(TRACKING_KEY, "main")) + except Refusal: + return LEG_PATH_CODES + TRACKING_BRANCH_CODES + return LEG_PATH_CODES def _named_as_read(plan: Plan) -> dict[str, str]: diff --git a/scripts/repo_shape.py b/scripts/repo_shape.py index 570b7b3a..eff7dafa 100644 --- a/scripts/repo_shape.py +++ b/scripts/repo_shape.py @@ -179,8 +179,22 @@ class YamlError(Exception): Raising rather than guessing is the whole point: a validator that silently mis-reads its own contract file is worse than one that will not start. + + `line` AND `remediation` ARE FOR `load_yaml`, WHICH NAMES A FILE (#708). + Where the fix is one edit of the value, the same in every file, the + refusal carries it (`_indicator_refusal`); every other refusal carries + none. Such a refusal is raised where the value is read, which knows no + line, so the block reader records the line it read the value on + (`_block_value`), and `load_yaml` names it. It stays out of the words: + a tool that asks the reader about ONE rendered line (`k: @a`) quotes + those words in a sentence of its own, where `line 1:` would be noise. """ + def __init__(self, message: str, remediation: str = ""): + super().__init__(message) + self.line: int | None = None + self.remediation = remediation + # --------------------------------------------------------------------------- # The YAML subset reader @@ -501,13 +515,24 @@ def _quoted_scalar(text: str) -> str: def _indicator_refusal(text: str) -> YamlError: """The refusal of the plain scalar `text`, which opens with an indicator YAML does not let open one (#392, #393): the indicator, what it does, and - the scalar. One `?` opens an explicit key, refused in #252's words.""" + the scalar. One `?` opens an explicit key, refused in #252's words. + + IT SAYS WHAT TO DO (#708). The likeliest hand to meet it is an adoption + plan's `resolution:`, which AGENTS.md asks for in words, and a reason + such as `` `adopt-project.py` reads it `` read as text until #392 and + #393. Whatever the file, the value is kept by writing it between double + quotes, spelled by `double_quoted`, the writer this reader takes back + unchanged, or by rewording it, so the refusal carries that remedy.""" char = text[0] + remedy = ("Remediation: unquoted, YAML does not read this value as text, " + "so this reader refuses it rather than guess. Write it in " + f"double quotes, as {double_quoted(text)}, or reword it so that " + "it opens with another character, and run the command again.") if char == "?": - return YamlError(f"explicit keys are not supported: {text!r}") + return YamlError(f"explicit keys are not supported: {text!r}", remedy) shown = "a backtick" if char == "`" else f"`{char}`" return YamlError(f"a plain scalar cannot open with {shown}, which " - f"{_INDICATOR_ROLES[char]} in YAML: {text!r}") + f"{_INDICATOR_ROLES[char]} in YAML: {text!r}", remedy) def _scalar(raw: str) -> Any: @@ -921,7 +946,7 @@ def _parse_sequence(recs: list[_Rec], i: int, indent: int) -> tuple[list[Any], i if not rec.dash and rec.block is None \ and _split_key(rec.text) is None: # A plain or flow SCALAR item (`- openAvatar`, `- [a, b]`). - value = _flow(rec.text) if rec.text[:1] in "[{" else _scalar(rec.text) + value = _block_value(rec.text, rec.line) i += 1 else: value, i = _parse_nodes(recs, i, rec.indent) @@ -971,15 +996,41 @@ def _parse_mapping(recs: list[_Rec], i: int, indent: int) -> tuple[dict, int]: i += 1 if rest == "": out[key], i = _nested_value(recs, i, indent) - elif rest[0] in "[{": - out[key] = _flow(rest) - elif rest == "-": - raise _indicator_refusal(rest) else: - out[key] = _scalar(rest) + out[key] = _block_value(rest, rec.line) return out, i +def _block_value(text: str, line: int) -> Any: + """A block mapping's value or a block sequence's scalar item, read on + `line`: a flow collection where `text` opens one, and otherwise a + scalar. A lone `-` is `_parse_mapping`'s refusal, made here: no + sequence item is one, because `_peel_dashes` takes `- -` for two + entries. + + THE ONE PLACE A VALUE'S REFUSAL LEARNS ITS LINE (#708). `_scalar` and + `_flow` read text and know no line, so their refusals named the file + and not where in it: an adoption plan whose `resolution:` opened with + a backtick was `REFUSED yaml-unparsable: : a plain scalar cannot + open with a backtick ...`, among the hundreds of lines a plan holds. + The line is recorded on the refusal (`YamlError.line`), never put in + its words, where the refusal carries a remedy (`_indicator_refusal`'s): + one that tells a person to edit a value says where it is. The others + keep the words they had, which the naming gate's `declared-unverified` + reason and the root's `validate-pins.py` print, and tests quote, whole. + """ + try: + if text[0] in "[{": + return _flow(text) + if text == "-": + raise _indicator_refusal(text) + return _scalar(text) + except YamlError as exc: + if exc.remediation: + exc.line = line + raise + + def parse_yaml(text: str) -> Any: """Parse the supported YAML subset. Raises `YamlError` on anything else.""" recs = _tokenise(text) @@ -1016,7 +1067,12 @@ def load_yaml(path: Path) -> Any: try: return parse_yaml(text) except YamlError as exc: - raise Refusal("yaml-unparsable", f"{path}: {exc}") from exc + # The remedy a value's refusal carries, and the line it was read on + # (#708): with neither, a plan that read as text before #392 and + # #393 ended `check` with a refusal a person could not act on. + where = "" if exc.line is None else f"line {exc.line}: " + raise Refusal("yaml-unparsable", f"{path}: {where}{exc}", + exc.remediation) from exc # --------------------------------------------------------------------------- diff --git a/scripts/shape_materialize.py b/scripts/shape_materialize.py index 8e032b39..8cde6e18 100644 --- a/scripts/shape_materialize.py +++ b/scripts/shape_materialize.py @@ -4681,13 +4681,75 @@ def manifest_invalid(values: dict, policy: NamingPolicy, what: dict, asked as the project asks it. `values` must be the complete table; `policy` is the naming policy the project carries a copy of. A WARNING of the validator (a chain link it cannot check offline) is a note and never - a refusal, exactly as the project's own exit code treats it. + a refusal, exactly as the project's own exit code treats it. Where the + project's own YAML reader refuses the file before the validator can be + asked, each sentence is the reader's, naming the value on the line it + refuses (`_unread_sentences`, #708). """ + return [sentence for _, sentence in _invalid_problems( + values, policy, what, template_dir, ignore, not_text)] + + +def _invalid_problems(values: dict, policy: NamingPolicy, what: dict, + template_dir: Path, ignore: tuple, + not_text: tuple) -> list[tuple[str, str]]: + """`manifest_invalid`'s sentences, each with its kind: `invalid` for a + finding of the validator's, and `unreadable` where the project's own + YAML reader refuses the file the validator would read (#708), whose + words are the reader's and whose remedy is its own + (`MANIFEST_UNREADABLE_REMEDIATION`).""" notes = _validator_notes(values, policy, template_dir, ignore) if isinstance(notes, YamlError): + return [("unreadable", sentence) for sentence + in _unread_sentences(values, what, template_dir, notes)] + return [("invalid", sentence) + for sentence in _sentences_of(notes, values, what, not_text)] + + +#: The placeholders that carry a value of `what` into the manifest where no +#: line holds that value's own placeholder (#708): the organisation is the +#: owner half of each leg's `repository:`, so the reader's refusal of such a +#: line is `--org`'s, as the validator's finding about it is +#: (`FINDING_PLACEHOLDER`'s `manifest-leg-repository`). +CARRIED_BY = {"ASSEMBLY_REPOSITORY": "ORG", "SPEC_REPOSITORY": "ORG", + "CODE_REPOSITORY": "ORG"} + + +def _unread_sentences(values: dict, what: dict, template_dir: Path, + refusal: YamlError) -> list[str]: + """A sentence for each value of `what` that a line of the manifest the + project's own reader refuses carries, in the reader's words, and the + reader's `refusal` of the whole file where no such line is found. + + THE VALUE IS NAMED AGAIN (#708). Until #392 and #393 the reader took + `repository: @x/Atlas` for text, so `--org @x` reached the validator, + whose `manifest-leg-repository` names `--org`. Since then the reader + refuses the file first, and the sentence named nothing: `plan --org + @x` (a typo of `@opensoft`, where GitHub owners never begin with `@`) + said only that the manifest was a file the reader refuses. So each + template line that carries a value of `what` (by its own placeholder, + or by one `CARRIED_BY` names) is rendered and read alone, as + `_misread_rows` reads one, and a value is named where the reader + refuses one of its lines. + """ + said: dict[str, list[str]] = {} + template = (Path(template_dir) / MANIFEST_TEMPLATE).read_text( + encoding="utf-8") + for line in template.splitlines(): + carried = {CARRIED_BY.get(key, key) for key in _placeholders_of(line)} + named = [key for key in what if key in carried and key in values] + if not named or line.lstrip().startswith("#"): + continue + reading = _reading_of(render(line, values, MANIFEST_TEMPLATE)) + if isinstance(reading, YamlError): + for key in named: + said.setdefault(key, []).append(f"{reading}.") + if not said: return ["the manifest this run would write is a file the project's " - f"own YAML reader refuses: {notes}."] - return _sentences_of(notes, values, what, not_text) + f"own YAML reader refuses: {refusal}."] + return [f"{what[key]} is {str(values[key])!r}, which the project's own " + f"YAML reader refuses: {' '.join(words)}" + for key, words in said.items()] def _validator_notes(values: dict, policy: NamingPolicy, template_dir: Path, @@ -4765,6 +4827,16 @@ def manifest_judged(values: dict, policy: NamingPolicy, "Remediation: choose a value the project's own validate-manifest.py " "accepts; the words after each value are the validator's own, and a " "project it refuses is one whose first bootstrap would fail.") +#: Where the project's own YAML reader refuses the manifest before its +#: validator can be asked (#708), the words are the reader's, and a sentence +#: may name no value (`_unread_sentences`), so the remedy says neither that +#: they are the validator's nor that a value is always named. +MANIFEST_UNREADABLE_REMEDIATION = ( + "Remediation: choose another value for each one named; where none is " + "named, the text the reader's words quote holds it. The words are the " + "project's own YAML reader's, which refuses the manifest this run would " + "write before its validate-manifest.py can be asked, and a project " + "whose manifest it refuses is one whose first bootstrap would fail.") def manifest_value_problems(values: dict, policy: NamingPolicy, what: dict, @@ -4773,7 +4845,8 @@ def manifest_value_problems(values: dict, policy: NamingPolicy, what: dict, template_dir: Path = ASSEMBLY_TEMPLATE ) -> list[tuple[str, str]]: """`(kind, sentence)` for everything wrong with the values, `kind` being - `misread` or `invalid`; `[]` when the project would hold every one. + `misread`, `invalid` or `unreadable` (`_invalid_problems`); `[]` when + the project would hold every one. A misread comes FIRST and alone: a value the reader turns into something else makes the manifest invalid in ways that are its consequence (`id: @@ -4785,9 +4858,8 @@ def manifest_value_problems(values: dict, policy: NamingPolicy, what: dict, misread = manifest_misreads(values, what, template_dir) if misread: return [("misread", sentence) for sentence in misread] - return [("invalid", sentence) for sentence - in manifest_invalid(values, policy, what, template_dir, ignore, - not_text)] + return _invalid_problems(values, policy, what, template_dir, ignore, + not_text) def refuse_manifest_values(prefix: str, values: dict, policy: NamingPolicy, @@ -4796,14 +4868,19 @@ def refuse_manifest_values(prefix: str, values: dict, policy: NamingPolicy, """Raise `Refusal(-manifest-misread)` or `-manifest- invalid`, naming every value, when the project could not hold them; the caller's own `prefix` (`scaffold`, `adopt`) says which tool refused, and - its `tail` finishes the remediation with what only it knows.""" + its `tail` finishes the remediation with what only it knows. A manifest + the project's reader refuses is `-invalid` still, the code a value it + refuses had when the validator was the one to refuse it, with the + reader's remedy (#708).""" problems = manifest_value_problems(values, policy, what, misreads=misreads) if problems: kind = problems[0][0] remedy = (MANIFEST_MISREAD_REMEDIATION if kind == "misread" + else MANIFEST_UNREADABLE_REMEDIATION if kind == "unreadable" else MANIFEST_INVALID_REMEDIATION) - raise Refusal(f"{prefix}-manifest-{kind}", + raise Refusal(f"{prefix}-manifest-" + f"{'misread' if kind == 'misread' else 'invalid'}", " ".join(sentence for _, sentence in problems), f"{remedy} {tail}") diff --git a/tests/test_adopt_plan_yaml_quoting.py b/tests/test_adopt_plan_yaml_quoting.py index dd50350e..20f459c8 100644 --- a/tests/test_adopt_plan_yaml_quoting.py +++ b/tests/test_adopt_plan_yaml_quoting.py @@ -1694,7 +1694,9 @@ def test_a_manifest_its_reader_refuses_is_the_validators_alone( "write is a file the project's own YAML reader " "refuses: "), stderr assert stderr.count("Remediation: ") == 1, stderr - assert VALIDATOR_REMEDIATION_START in stderr, stderr + # The words are the reader's, and since #708 so is the remedy. + assert adopter.MANIFEST_UNREADABLE_REMEDIATION in stderr, stderr + assert VALIDATOR_REMEDIATION_START not in stderr, stderr #: A leg path the validator refuses as text, which its finding about the diff --git a/tests/test_reader_refusals_named.py b/tests/test_reader_refusals_named.py new file mode 100644 index 00000000..1765fc0b --- /dev/null +++ b/tests/test_reader_refusals_named.py @@ -0,0 +1,336 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Three refusals name what a person changes again (#708). + +Each named the value to change on main or in v1.0.1, and a v1.1.0 change +took that away: + +- L4-151, from #635 (#392, #393). The YAML reader now refuses a plain value + that opens with an indicator, so an adoption plan whose hand-written + `resolution:` opens with a backtick (AGENTS.md asks for the reason in + words) ended `adopt-project.py check` in `REFUSED yaml-unparsable: + : `, exit 2: no line among the hundreds a plan holds, and no + remedy. The refusal now names the line, which the block reader records + (`repo_shape._block_value`), and says to double-quote the value or reword + it; the double-quoted twin checks clean. Every refusal with that remedy + (`_indicator_refusal`'s) names its line; the others keep the words they + had, and the words of `parse_yaml` are unchanged. +- L4-152, from #635. The reader refuses `repository: @x/Northwind`, so + `plan --org @x` (a typo of `@opensoft`) no longer reached the validator, + whose finding named `--org`, and the remedy said the words were the + validator's. The value on the line the reader refuses is named again + (`shape_materialize._unread_sentences`), in the sentence main had with the + reader in the validator's place, under the reader's own remedy. + `family.py init --org @x` stays refused. +- L4-127, from #644 (#455). `check` named one bad `tracking_branch` twice: + `unsafe-value`, then the validator's `manifest-tracking-branch` with no + plan key. The validator's sentence is left out where `unsafe-value` names + the branch (`adopt-project.py`'s `_validator_ignores`). + +NO NETWORK AND NO GITHUB: local sources, `--local-remote-dir`, `--dry-run`, +strings and temporary files, and the suite's `gh` guard. +""" + +from __future__ import annotations + +import functools +import re +import sys +from pathlib import Path + +import pytest + +from conftest import ADOPT, REPO, run_script, write_plan +from test_adopt_manifest_values import ( # noqa: F401 - fixtures + INVALID, PROJECT, adopter, edited, execute, needs_filter_repo, + no_leg_was_created, world) +from test_family_init_values import init, nothing_was_created +from test_scaffold_manifest_values import ( + INVALID as SCAFFOLD_INVALID, assert_nothing_was_created, assert_refused, + scaffold) + +sys.path.insert(0, str(REPO / "scripts")) +import shape_materialize # noqa: E402 +from repo_shape import ( # noqa: E402 + REMEDIATION, Refusal, YamlError, double_quoted, load_yaml, parse_yaml) + +UTF8 = "utf-8" +OPENS = "a plain scalar cannot open with" +#: The reason a person wrote, which read as text before #392 and #393. +BACKTICKED = "`adopt-project.py` reads it" +#: How `conftest.resolve` writes an answer's reason. +ANSWERED = 'resolution: "answered by the test"' +READER_REFUSES = "which the project's own YAML reader refuses: " +NOT_THE_VALIDATORS = "the words after each value are the validator's own" + + +def remedy_for(text: str) -> str: + """The remedy a plain value that opens with an indicator is given, in + full: the value double-quoted as the reader takes it back.""" + return ("Remediation: unquoted, YAML does not read this value as text, " + "so this reader refuses it rather than guess. Write it in double " + f"quotes, as {double_quoted(text)}, or reword it so that it opens " + "with another character, and run the command again.") + + +def line_of(path: Path, text: str) -> int: + """The number of the one line of `path` that is `text`, from 1.""" + lines = path.read_bytes().decode(UTF8).splitlines() + assert lines.count(text) == 1, f"{text!r} is not one line of {path}" + return lines.index(text) + 1 + + +def check(plan: Path): + return run_script(ADOPT, "check", "--plan", str(plan)) + + +# --- L4-151: the line, and what to do -------------------------------------- + +def test_a_resolution_opening_with_a_backtick_names_its_line_and_a_remedy( + world): + """The issue's case through `check`: exit 2, the plan's line, the + reader's words as they were, and the remedy, and nothing else.""" + plan = edited(world, ANSWERED, f"resolution: {BACKTICKED}") + line = line_of(plan, f" resolution: {BACKTICKED}") + result = check(plan) + assert result.returncode == 2, result.stderr + result.stdout + assert result.stderr == ( + f"REFUSED yaml-unparsable: {plan}: line {line}: {OPENS} a backtick, " + f"which is reserved in YAML: {BACKTICKED!r}\n" + f"{remedy_for(BACKTICKED)}\n") + + +def test_the_remedys_own_spelling_checks_clean(world): + """Following the remedy is the fix: the line it spells, put in the + plan's place, is the double-quoted twin, and `check` passes it.""" + spelled = re.search(r"as (\".*\"), or reword", + remedy_for(BACKTICKED)).group(1) + assert spelled == f'"{BACKTICKED}"' + result = check(edited(world, ANSWERED, f"resolution: {spelled}")) + assert result.returncode == 0, result.stderr + result.stdout + assert "REFUSED" not in result.stderr and "FINDING" not in result.stderr + + +#: `(file, the line the refusal names, the value its remedy is spelled for)`: +#: the opener in a block value, a sequence item, a nested value, a flow +#: entry and a lone `-`. A refusal with no remedy, its fix being no one edit +#: of the value, keeps the words it had and names no line (`None`, `None`): +#: the naming gate's `declared-unverified` reason and the root's +#: `validate-pins.py` print them whole. +LINED = [ + pytest.param("id: atlas\nresolution: `a`\n", 2, "`a`", id="backtick"), + pytest.param("pins:\n - x\n - @y\n", 3, "@y", id="sequence-item"), + pytest.param("a:\n b:\n c: -\n", 3, "-", id="nested-lone-dash"), + pytest.param("x: 1\n\nk: [ok, %a]\n", 3, "%a", id="flow-entry"), + pytest.param("- k: ]\n", 1, "]", id="mapping-in-a-sequence"), + pytest.param("k: ?\n", 1, "?", id="lone-question-mark"), + pytest.param("x: 1\nk: [a,,b]\n", None, None, id="empty-flow-entry"), + pytest.param("x: 1\n\nk: *anchor\n", None, None, id="alias"), + pytest.param("commit: [unclosed\n", None, None, id="unclosed-flow"), +] + + +@pytest.mark.parametrize("body, line, opener", LINED) +def test_a_refusal_with_a_remedy_names_its_line(tmp_path, body, line, + opener): + """Read the way every caller reads a file. The words are `parse_yaml`'s, + unchanged, so a tool that asks about one line quotes no `line 1:`.""" + with pytest.raises(YamlError) as parsed: + parse_yaml(body) + words = str(parsed.value) + assert not words.startswith("line "), words + path = tmp_path / "file.yaml" + path.write_bytes(body.encode(UTF8)) + with pytest.raises(Refusal) as caught: + load_yaml(path) + assert caught.value.code == "yaml-unparsable" + where = "" if line is None else f"line {line}: " + assert caught.value.detail == f"{path}: {where}{words}" + assert caught.value.remediation == ( + "" if opener is None else remedy_for(opener)) + + +def test_a_refusal_whose_words_name_their_line_names_it_once(tmp_path): + path = tmp_path / "file.yaml" + path.write_bytes(b"a:\n\tb: 1\n") + with pytest.raises(Refusal) as caught: + load_yaml(path) + assert caught.value.detail == f"{path}: line 2: tab used for indentation" + assert caught.value.remediation == "" + + +@pytest.mark.parametrize("value", [ + "`a`", "@a", "%a", "]", "}", ",", "-", ":", "?", "`a \"b\" \\c`"]) +def test_each_spelling_a_remedy_gives_reads_back_as_the_value(value): + with pytest.raises(YamlError) as caught: + parse_yaml(f"k: {value}\n") + spelled = re.search(r"as (\".*\"), or reword", + caught.value.remediation).group(1) + assert parse_yaml(f"k: {spelled}\n") == {"k": value} + + +def test_the_roots_validate_pins_gives_the_remedy_and_not_the_lockstep( + project): + """THE COPY A PROJECT CARRIES. A pin's `commit:` edited to open with an + indicator is the line and the quoting remedy, where the lockstep remedy + `validate-pins.py` prints under a refusal that names none was printed: + a syntax error is not a stale pin.""" + pin = project / "contracts" / "spec-pin.yaml" + text = pin.read_bytes().decode(UTF8) + old = re.search(r'^commit: "?[0-9a-f]{40}"?$', text, re.M).group(0) + pin.write_bytes(text.replace(old, "commit: @x", 1).encode(UTF8)) + line = line_of(pin, "commit: @x") + checked = run_script(project / "scripts" / "validate-pins.py", "--quiet", + cwd=project) + assert checked.returncode == 2, checked.stderr + checked.stdout + assert checked.stderr.splitlines() == [ + f"REFUSED yaml-unparsable: {pin.resolve()}: line {line}: {OPENS} " + "`@`, which is reserved in YAML: '@x'", remedy_for("@x")] + assert REMEDIATION not in checked.stderr + + +# --- L4-152: the value named again, under the reader's remedy -------------- + +def reader_sentence(flag: str, org: str, project: str = PROJECT) -> str: + """What `plan`, `check` and the scaffold say of `org` on main's pattern: + one sentence naming the value, with the reader's words for each leg's + `repository:` line.""" + return (f"{flag} is {org!r}, {READER_REFUSES}" + " ".join( + f"{OPENS} `@`, which is reserved in YAML: '{org}/{name}'." + for name in (project, f"{project}-spec", f"{project}-code"))) + + +@pytest.mark.parametrize("org", ["@x", "@opensoft"]) +def test_plan_names_org_again_with_the_readers_remedy(world, org): + out = world["tmp"] / "org-plan.yaml" + result = write_plan(world["source"], out, org=org) + assert result.returncode == 2, result.stderr + result.stdout + assert result.stderr == ( + f"REFUSED {INVALID}: {reader_sentence('--org', org)}\n" + f"{shape_materialize.MANIFEST_UNREADABLE_REMEDIATION} " + "No plan was written.\n") + assert NOT_THE_VALIDATORS not in result.stderr + assert not out.exists(), "a refused `plan` wrote a plan" + + +def test_check_names_a_hand_edited_org(world): + """The same value edited into a plan: `check` names its key.""" + plan = edited(world, "org: testorg", 'org: "@x"') + said = check(plan) + assert said.returncode == 1, said.stderr + said.stdout + lines = said.stderr.splitlines() + assert [line for line in lines if line.startswith("FINDING ")] == [ + f"FINDING plan-manifest-invalid: {reader_sentence('org', '@x')}"] + assert (f"{shape_materialize.MANIFEST_UNREADABLE_REMEDIATION} `execute` " + "writes the plan's values into the project's `project.yaml` as " + "they stand, so correct each one named here in the plan, or " + "re-run `plan` with a value the validator accepts.") in lines + assert NOT_THE_VALIDATORS not in said.stderr + + +@needs_filter_repo +def test_execute_refuses_a_hand_edited_org_before_any_leg(world): + """`execute` refuses it in `check`'s words before any leg exists.""" + result = execute(world, edited(world, "org: testorg", 'org: "@x"')) + assert result.returncode == 2, result.stderr + result.stdout + assert (f" FINDING plan-manifest-invalid: " + f"{reader_sentence('org', '@x')}") in result.stderr.splitlines() + assert NOT_THE_VALIDATORS not in result.stderr + no_leg_was_created(world) + + +@pytest.mark.parametrize("dry_run", [True, False], ids=["dry-run", "run"]) +def test_the_scaffold_names_org_with_the_readers_remedy(tmp_path, dry_run): + result = scaffold(tmp_path, "--org=@x", + *(["--dry-run"] if dry_run else [])) + assert_refused(result, SCAFFOLD_INVALID) + assert (f"{SCAFFOLD_INVALID}: {reader_sentence('--org', '@x', 'Atlas')}\n" + f"{shape_materialize.MANIFEST_UNREADABLE_REMEDIATION} Nothing " + "was created, and `--dry-run` refuses the same.\n" + ) in result.stderr + assert_nothing_was_created(tmp_path) + + +def test_a_refused_line_that_carries_no_value_names_none(world, adopter, + tmp_path, + monkeypatch): + """Where the line the reader refuses carries none of the values, the + sentence is the reader's refusal of the file, as before, and its + remedy is still the reader's, never the validator's.""" + template = tmp_path / "template" + template.mkdir() + text = (shape_materialize.ASSEMBLY_TEMPLATE / "project.yaml").read_bytes() + (template / "project.yaml").write_bytes(text + b"anchored: &anchor 1\n") + naming = adopter.NamingPolicy.load(adopter.NAMING_POLICY) + values = adopter._manifest_values(adopter.Plan.load(world["plan"]), + naming) + whole = ("the manifest this run would write is a file the project's own " + "YAML reader refuses: anchors, aliases and tags are not " + "supported: '&anchor 1'.") + assert shape_materialize.manifest_value_problems( + values, naming, adopter.MANIFEST_FLAGS, misreads=False, + template_dir=template) == [("unreadable", whole)] + monkeypatch.setattr(shape_materialize, "manifest_value_problems", + functools.partial( + shape_materialize.manifest_value_problems, + template_dir=template)) + with pytest.raises(Refusal) as caught: + shape_materialize.refuse_manifest_values( + "adopt", values, naming, adopter.MANIFEST_FLAGS, + "No plan was written.", misreads=False) + assert caught.value.code == INVALID + assert caught.value.detail == whole + assert caught.value.remediation == ( + f"{shape_materialize.MANIFEST_UNREADABLE_REMEDIATION} No plan was " + "written.") + + +@pytest.mark.parametrize("dry_run", [True, False], ids=["dry-run", "run"]) +def test_family_init_still_refuses_org_at(tmp_path, dry_run): + """Not this issue's to change: refused since #635, before anything is + created, where main planned `@x/Orbit`.""" + result = init(tmp_path, "--org", "@x", dry_run=dry_run, + without=("--org",)) + assert result.returncode == 2, result.stderr + result.stdout + assert any(line.startswith("REFUSED family-manifest-invalid: ") + for line in result.stderr.splitlines()), result.stderr + nothing_was_created(tmp_path) + + +# --- L4-127: one bad `tracking_branch`, one finding ------------------------ + +@pytest.mark.parametrize("spelled, value", [ + pytest.param('"a\\ud83db"', "a\ud83db", id="lone-surrogate"), + pytest.param('"a\\x00b"', "a\x00b", id="nul"), +]) +def test_check_names_a_bad_tracking_branch_once(world, spelled, value): + result = check(edited(world, "tracking_branch: main", + f"tracking_branch: {spelled}")) + assert result.returncode == 1, result.stderr + result.stdout + assert [line for line in result.stderr.splitlines() + if line.startswith("FINDING ")] == [ + f"FINDING unsafe-value: tracking_branch is {value!r}, which is not a " + "value this tool will put on a `git` or `gh` command line"] + assert "manifest-tracking-branch" not in result.stderr + assert shape_materialize.MANIFEST_INVALID_REMEDIATION not in result.stderr + + +@pytest.mark.parametrize("line, left_out", [ + pytest.param("tracking_branch: main", False, id="a-good-branch"), + pytest.param('tracking_branch: "a\\x00b"', True, id="unsafe-value"), + # In the alphabet: git's `plan-bad-tracking-branch`, which the + # validator does not find either. + pytest.param("tracking_branch: a..b", False, id="git-refuses-it"), +]) +def test_the_validators_sentence_is_left_out_where_unsafe_value_fires( + adopter, world, line, left_out): + plan = adopter.Plan.load(edited(world, "tracking_branch: main", line)) + assert adopter._validator_ignores(plan) == adopter.LEG_PATH_CODES + ( + adopter.TRACKING_BRANCH_CODES if left_out else ()) + + +def test_adopt_gives_the_one_reader_remedy(adopter): + """`check`'s remedy is the scaffold's and `plan`'s, not a second copy.""" + assert adopter.MANIFEST_UNREADABLE_REMEDIATION is ( + shape_materialize.MANIFEST_UNREADABLE_REMEDIATION) + assert NOT_THE_VALIDATORS not in ( + shape_materialize.MANIFEST_UNREADABLE_REMEDIATION) diff --git a/tests/test_yaml_indicator_openers.py b/tests/test_yaml_indicator_openers.py index b2473bfc..c680baeb 100644 --- a/tests/test_yaml_indicator_openers.py +++ b/tests/test_yaml_indicator_openers.py @@ -416,7 +416,9 @@ def test_a_file_holding_one_is_yaml_unparsable_naming_the_file(tmp_path, body, with pytest.raises(Refusal) as caught: load_yaml(path) assert caught.value.code == "yaml-unparsable" - assert caught.value.detail == f"{path}: {words}" + # An opener's refusal names its line, the second, since #708. + where = "line 2: " if words.startswith(OPENS) else "" + assert caught.value.detail == f"{path}: {where}{words}" @pytest.mark.parametrize("old, new, words", [ @@ -444,7 +446,11 @@ def test_the_projects_own_validator_refuses_it(project, old, new, words): result = run_script(project / "scripts" / "validate-manifest.py", cwd=project) assert result.returncode == 2, result.stderr - assert f"REFUSED yaml-unparsable: {manifest}: {words}\n" in result.stderr + # An opener's refusal names its line since #708. + line = edited[:edited.index(new)].count(b"\n") + 1 + where = f"line {line}: " if words.startswith(OPENS) else "" + assert (f"REFUSED yaml-unparsable: {manifest}: {where}{words}\n" + in result.stderr) assert "Traceback" not in result.stderr assert "manifest ok" not in result.stdout assert manifest.read_bytes() == edited From 8fd2fa640f41f7daa7ec35e259aa6b10032d2660 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sat, 17 Oct 2026 12:28:22 +0000 Subject: [PATCH 2/2] repo_shape.py: a flow-mapping entry whose key opens with an indicator is refused with no remedy and so no line, because the whole entry double-quoted reads as an entry without a key, and shape_materialize.py names its problem kinds and their remedies once (#708) Fix round 1, from lane 1's review of #817 at 0bc29c94 (issuecomment-6233847800). Finding 1. `_split_key_flow` called `_refuse_entry_indicator` on the whole entry, so `k: {@a: b}` was told to write `"@a: b"` in double quotes, which reads as a flow-mapping entry without a key and is refused again. Lane 1 measured it through `adopt-project.py check` on a plan holding `notes: {@who: Test Human}`, and on the reader for `{@a: b}`, ``{`a`: b}``, `{: a}`, `{? a: b}`, `{#a: 1}` and `[x, {%a: 1}]`. Lane 1's tested fix is taken as given: `_split_key_flow` calls `_refuse_entry_indicator(text, remedy=False)`, so such a refusal carries no remedy, and so no line. A flow-sequence item and a flow-mapping value keep both. Test: test_a_flow_mapping_key_is_not_told_to_quote_the_whole_entry, lane 1's five bodies and `k: {#a: 1}`; all six fail at 0bc29c94. Finding 3 (S9073). The new test file's one composite assert is two asserts. No other `assert ... and ...` or `... or ...` is in the new or changed test code. Finding 4 (a possible S1192). The problem kinds `misread`, `invalid` and `unreadable` are named once, as `KIND_MISREAD`, `KIND_INVALID` and `KIND_UNREADABLE`, and `adopt-project.py` compares against `KIND_UNREADABLE`. No printed word changes. SonarCloud's list at 0bc29c94 held one issue more, S3358 at `refuse_manifest_values` (a conditional expression nested in another, the remedy chosen by kind). `KIND_REMEDIATIONS` maps each kind to its remedy, and the refusal takes it from there. No printed word changes. Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Opus 5.5 --- adopt-project.py | 8 ++++---- scripts/repo_shape.py | 11 ++++++++--- scripts/shape_materialize.py | 29 ++++++++++++++++++++--------- tests/test_reader_refusals_named.py | 15 ++++++++++++++- 4 files changed, 46 insertions(+), 17 deletions(-) diff --git a/adopt-project.py b/adopt-project.py index 5d8cdd5b..af3f9fac 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -240,9 +240,9 @@ # into its manifest, and whether a repository's name can be a directory's: # asked of the plan's values by `plan`, `check` and `execute` (#261). from shape_materialize import ( # noqa: E402 - MANIFEST_INVALID_REMEDIATION, MANIFEST_UNREADABLE_REMEDIATION, - manifest_judged, manifest_value_problems, refuse_manifest_values, - refuse_unstorable_names, + KIND_UNREADABLE, MANIFEST_INVALID_REMEDIATION, + MANIFEST_UNREADABLE_REMEDIATION, manifest_judged, manifest_value_problems, + refuse_manifest_values, refuse_unstorable_names, ) # Whether a value this tool writes into a file is text a file can hold # (#255), asked beside `one_printable_line`, the trailer's question. @@ -4228,7 +4228,7 @@ def _manifest_findings(plan: Plan) -> tuple[list[str], list[str]]: # Where the project's own YAML reader refuses the manifest, the words # are the reader's, and so is the remedy (#708). remedy = (MANIFEST_UNREADABLE_REMEDIATION - if problems[0][0] == "unreadable" + if problems[0][0] == KIND_UNREADABLE else MANIFEST_INVALID_REMEDIATION) return ([f"FINDING plan-manifest-invalid: {sentence}" for _, sentence in problems], diff --git a/scripts/repo_shape.py b/scripts/repo_shape.py index eff7dafa..d6892966 100644 --- a/scripts/repo_shape.py +++ b/scripts/repo_shape.py @@ -596,7 +596,7 @@ def _refuse_flow_indicator(text: str) -> None: f"which ends it in YAML: {text!r}") -def _refuse_entry_indicator(text: str) -> None: +def _refuse_entry_indicator(text: str, remedy: bool = True) -> None: """Refuse a flow ENTRY -- an item of a flow sequence, or an entry of a flow mapping, whose key it opens -- that opens with an indicator (#392). @@ -617,7 +617,10 @@ def _refuse_entry_indicator(text: str) -> None: first, after = text[:1], text[1:2] if (first in ":#@`%" or first in "-?" and after in (" ", "\t") or text == "?"): - raise _indicator_refusal(text) + refusal = _indicator_refusal(text) + if not remedy: + refusal.remediation = "" + raise refusal def _flow_value(text: str, item: bool = False) -> Any: @@ -704,7 +707,9 @@ def _split_key_flow(text: str) -> tuple[str, str] | None: refuses both. `_refuse_flow_indicator` refuses them; a quoted key is still text. """ - _refuse_entry_indicator(text) + # A MAPPING ENTRY opens its KEY: quoting the whole entry, as the + # remedy would spell it, reads as an entry without a key (#708). + _refuse_entry_indicator(text, remedy=False) kv = _split_key(text) if kv is not None and not text.startswith(('"', "'")): _refuse_flow_indicator(kv[0]) diff --git a/scripts/shape_materialize.py b/scripts/shape_materialize.py index 8cde6e18..58d62a79 100644 --- a/scripts/shape_materialize.py +++ b/scripts/shape_materialize.py @@ -4690,6 +4690,15 @@ def manifest_invalid(values: dict, policy: NamingPolicy, what: dict, values, policy, what, template_dir, ignore, not_text)] +#: The kinds of problem `manifest_value_problems` names, each spelled once +#: (#708): a value the project's reader reads back as something else, one +#: its validator refuses, and a manifest the reader refuses before the +#: validator can be asked, which `refuse_manifest_values` refuses under the +#: code `invalid` still. +KIND_MISREAD, KIND_INVALID, KIND_UNREADABLE = ( + "misread", "invalid", "unreadable") + + def _invalid_problems(values: dict, policy: NamingPolicy, what: dict, template_dir: Path, ignore: tuple, not_text: tuple) -> list[tuple[str, str]]: @@ -4700,9 +4709,9 @@ def _invalid_problems(values: dict, policy: NamingPolicy, what: dict, (`MANIFEST_UNREADABLE_REMEDIATION`).""" notes = _validator_notes(values, policy, template_dir, ignore) if isinstance(notes, YamlError): - return [("unreadable", sentence) for sentence + return [(KIND_UNREADABLE, sentence) for sentence in _unread_sentences(values, what, template_dir, notes)] - return [("invalid", sentence) + return [(KIND_INVALID, sentence) for sentence in _sentences_of(notes, values, what, not_text)] @@ -4837,6 +4846,11 @@ def manifest_judged(values: dict, policy: NamingPolicy, "project's own YAML reader's, which refuses the manifest this run would " "write before its validate-manifest.py can be asked, and a project " "whose manifest it refuses is one whose first bootstrap would fail.") +#: The remedy for each kind of problem, so a refusal names it by its kind +#: (#708), with no conditional nested in another. +KIND_REMEDIATIONS = {KIND_MISREAD: MANIFEST_MISREAD_REMEDIATION, + KIND_INVALID: MANIFEST_INVALID_REMEDIATION, + KIND_UNREADABLE: MANIFEST_UNREADABLE_REMEDIATION} def manifest_value_problems(values: dict, policy: NamingPolicy, what: dict, @@ -4857,7 +4871,7 @@ def manifest_value_problems(values: dict, policy: NamingPolicy, what: dict, if misreads: misread = manifest_misreads(values, what, template_dir) if misread: - return [("misread", sentence) for sentence in misread] + return [(KIND_MISREAD, sentence) for sentence in misread] return _invalid_problems(values, policy, what, template_dir, ignore, not_text) @@ -4876,13 +4890,10 @@ def refuse_manifest_values(prefix: str, values: dict, policy: NamingPolicy, misreads=misreads) if problems: kind = problems[0][0] - remedy = (MANIFEST_MISREAD_REMEDIATION if kind == "misread" - else MANIFEST_UNREADABLE_REMEDIATION if kind == "unreadable" - else MANIFEST_INVALID_REMEDIATION) - raise Refusal(f"{prefix}-manifest-" - f"{'misread' if kind == 'misread' else 'invalid'}", + code = KIND_MISREAD if kind == KIND_MISREAD else KIND_INVALID + raise Refusal(f"{prefix}-manifest-{code}", " ".join(sentence for _, sentence in problems), - f"{remedy} {tail}") + f"{KIND_REMEDIATIONS[kind]} {tail}") # --------------------------------------------------------------------------- diff --git a/tests/test_reader_refusals_named.py b/tests/test_reader_refusals_named.py index 1765fc0b..457f5412 100644 --- a/tests/test_reader_refusals_named.py +++ b/tests/test_reader_refusals_named.py @@ -107,7 +107,20 @@ def test_the_remedys_own_spelling_checks_clean(world): assert spelled == f'"{BACKTICKED}"' result = check(edited(world, ANSWERED, f"resolution: {spelled}")) assert result.returncode == 0, result.stderr + result.stdout - assert "REFUSED" not in result.stderr and "FINDING" not in result.stderr + assert "REFUSED" not in result.stderr + assert "FINDING" not in result.stderr + + +@pytest.mark.parametrize("body", [ + "k: {@a: b}\n", "k: {`a`: b}\n", "k: {: a}\n", "k: {? a: b}\n", + "k: [x, {%a: 1}]\n", "k: {#a: 1}\n"]) +def test_a_flow_mapping_key_is_not_told_to_quote_the_whole_entry(body): + """Lane 1's review of #817: an entry of a flow mapping opens its KEY, and + the whole entry double-quoted (`{"@a: b"}`) reads as an entry without a + key, refused again. So such a refusal carries no remedy, and no line.""" + with pytest.raises(YamlError) as caught: + parse_yaml(body) + assert caught.value.remediation == "" #: `(file, the line the refusal names, the value its remedy is spelled for)`: