From 76a615319d7f5ca89b9f613497835d60b6bc68dc Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:50:32 +0000 Subject: [PATCH 01/24] adopt-project.py: the plan writer quotes any string its own loader would read back as something else, so `plan --spec-path null` no longer mounts the leg at `spec` and `--tracking-branch true` no longer reaches `execute` as a boolean (#181) `y()` wrote a string bare whenever it looked like a plain word, and `repo_shape`'s reader takes `null`, `Null`, `NULL`, `true`, `True`, `TRUE`, `false`, `False` and `FALSE` for a null and a boolean. On main `plan --spec-path null` wrote `spec_path: null`, which loads as no value, so `check` said "mount the legs at spec/" while the plan's own follow-up said `null/contracts` and `execute` registered the gitlink at `spec`; `--tracking-branch true` was read back as a boolean and the legs were created on a branch called `True`; and a source file named `null` was a plan entry with no path. `y()` now asks the loader instead of a list of words. A candidate is written bare only if `parse_yaml` gives the same string back in both places the writer puts one, after `key: ` and as a `- ` item (`- a:` is a one-key mapping, `k: a:` is not), and a line the reader refuses is a value it does not read back. The trailing-colon rule is that second question, so nothing else changed for a string that round-trips: a default plan on a fixed source is byte-identical to main's, and so is a plan of this repository. `yes`, `on`, `.inf` stay bare because this loader reads them as text. The same defect was in the one list of strings the plan writes without `y`: `--pin null` was written `pins: [null]`, which `Plan.load` drops, so the pin vanished. It goes through `y` too; `openGlass` and `opensoft/openGlass` are unchanged. `null` is a canonical one-segment leg path and not a Windows device name, so #169's check accepts it and `execute` mounts the leg at a directory called `null`: what was typed. Closes #181 Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Sonnet 5.5 --- adopt-project.py | 35 +- tests/test_adopt_plan_yaml_quoting.py | 475 ++++++++++++++++++++++++++ 2 files changed, 507 insertions(+), 3 deletions(-) create mode 100644 tests/test_adopt_plan_yaml_quoting.py diff --git a/adopt-project.py b/adopt-project.py index 7102dfde..5ba49409 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -194,7 +194,8 @@ def _checked_pin_name(raw: str) -> str: # mappings, block sequences and single-line scalars. Everything it emits is # read back by `check`, and `tests/test_adopt_plan.py` round-trips a plan # through both, which is what keeps the writer and the reader honest about -# each other. +# each other. A string is written plain only where that reader gives the same +# string back, and `y` asks the reader rather than a list of words (#181). _PLAIN_RE = re.compile(r"^[A-Za-z_.][A-Za-z0-9_./@:+-]*$") @@ -204,6 +205,31 @@ def _checked_pin_name(raw: str) -> str: WINDOWS_ABSOLUTE_RE = re.compile(r"^[A-Za-z]:[\\/]") +def _reads_back_as_text(text: str) -> bool: + """Does the plan's own loader take `text`, written plain, back as `text`? + + ASKED OF THE LOADER, NEVER OF A LIST OF WORDS (#181). `null`, `Null`, + `true` and `False` were written bare, so `plan --spec-path null` wrote + `spec_path: null`, which loads as "no value" and mounted the leg at the + default `spec`, and `--tracking-branch true` came back as a boolean. A + second, hand-kept list of the words `repo_shape._scalar` treats as + keywords is a list that goes stale the day that reader learns another one + (it reads no `yes`, `on` or `.inf` today, and a list copied from YAML 1.1 + would quote them for nothing), so the candidate is round-tripped instead + and whatever the loader reads as something else gets quoted. + + BOTH PLACES A VALUE IS WRITTEN are asked: after `key: `, and as a `- ` + item, which the reader splits differently (`- a:` is a one-key mapping, + `k: a:` is not). A line the reader refuses is a value it does not read + back. + """ + try: + return (parse_yaml(f"k: {text}\n") == {"k": text} + and parse_yaml(f"- {text}\n") == [text]) + except YamlError: + return False + + def y(value) -> str: if value is None: return "null" @@ -214,7 +240,7 @@ def y(value) -> str: if isinstance(value, float): return f"{value:.4f}" text = str(value) - if text and _PLAIN_RE.match(text) and not text.endswith(":"): + if text and _PLAIN_RE.match(text) and _reads_back_as_text(text): return text escaped = text.replace("\\", "\\\\").replace('"', '\\"') return f'"{escaped}"' @@ -543,11 +569,14 @@ def render_plan(args, source: Source, entries: list[Entry], names: dict, emit(lines, "code", names["code"], 2) emit(lines, "spec_path", args.spec_path, 2) emit(lines, "code_path", args.code_path, 2) + # A pin goes through `y` like every other string here: `--pin null` was + # written `pins: [null]`, which loads as a null that `Plan.load` drops, so + # the pin the human asked for vanished without a word (#181). lines += ["", "# Neutral products this project declares a pin on. A", "# `` assembly root is a DESCENDANT only when", "# its `open` is listed here (2026-09-02).", - "pins: [" + ", ".join(pins) + "]", ""] + "pins: [" + ", ".join(y(pin) for pin in pins) + "]", ""] seeded = seeded_legs(assigned_paths(entries)) lines += [ diff --git a/tests/test_adopt_plan_yaml_quoting.py b/tests/test_adopt_plan_yaml_quoting.py new file mode 100644 index 00000000..fa8cbd12 --- /dev/null +++ b/tests/test_adopt_plan_yaml_quoting.py @@ -0,0 +1,475 @@ +# SPDX-License-Identifier: Apache-2.0 +"""A string the plan writer emits is read back by the plan's loader as that +same string (#181). + +`y()` wrote `null`, `Null`, `true` and `False` bare, and `repo_shape`'s reader +takes those for a null and a boolean. So `plan --spec-path null` wrote +`spec_path: null`, which loads as "no value", and the leg was mounted at the +default `spec` while the plan's own follow-ups said `null/contracts`; +`--tracking-branch true` came back as a boolean, and a source file named +`null` came back as a plan entry with no path at all. + +THE RULE IS ASKED OF THE LOADER, NOT OF A LIST. Every test here round-trips +through `repo_shape.parse_yaml` itself, in each place the writer puts a +string: after `key: ` (`emit`), as a `- ` item, and as `Entry.write`'s +`- path: `. None of them names the words the loader treats as keywords, which +is how a second list of them would drift; the one test that does names a +word the loader does NOT read today, to show the writer follows the reader +when the reader changes. + +A LINE BREAK IS NOT IN THE GENERATED ALPHABET, on purpose. The reader takes a +plan line by line (`str.splitlines`, which also splits on `\\r`, `\\x0b`, +`\\x0c`, `\\x1c`-`\\x1e`, `\\x85`, `\\u2028` and `\\u2029`), a double-quoted scalar +cannot span lines, and the loader resolves an escape for `\\n` and `\\t` only. +A value carrying one is therefore not a question of quoting, #181 does not +make one read back, and putting it in a generator would assert a rule this +change does not make. (The one visible difference: a name whose ONLY line +break is a final one matched `_PLAIN_RE`, because `$` matches before it, and +was written bare; it is quoted now. Both spellings split the entry over two +lines and `check` refuses either plan as `yaml-unparsable`.) +""" + +from __future__ import annotations + +import importlib.util +import itertools +import random +import shutil +import sys +from pathlib import Path + +import pytest + +from conftest import (ADOPT, REPO, git, make_source_repo, resolve, run_script, + write_plan) + +sys.path.insert(0, str(REPO / "scripts")) +import repo_shape # noqa: E402 +from repo_shape import load_yaml, parse_yaml # noqa: E402 + +DOCTOR = REPO / "shape-doctor.py" + +#: What the one `execute` test here needs and nothing else does. +needs_filter_repo = pytest.mark.skipif( + shutil.which("git-filter-repo") is None, + reason="git filter-repo is not installed: `pip install git-filter-repo`") + +#: Spelled once each: the same word in a table, a loop and an assertion is one +#: fact written three times, and `python:S1192` counts it. +FALSE_WORD = "false" +OPEN_GLASS = "openGlass" +SPLIT_BRANCH = "adopt/three-repo-shape" +QUOTED_YES = '"yes"' +PATHS = "paths" + +#: Answered the way `test_adopt_plan.py` answers them, so `check` can pass. +ANSWERS = (("examples/", "spec"), (".claude/", "root"), + ("release.yaml", "root")) + + +def text_of(plan: Path) -> str: + return plan.read_text(encoding="utf-8") + + +def casings(word: str) -> list[str]: + """Every spelling of `word` in upper and lower case: `null` is sixteen.""" + return sorted({"".join(letters) for letters in itertools.product( + *[{c.lower(), c.upper()} for c in word])}) + + +#: The brief's classes, each spelled the way a person or a path might. +NULLS = ["null", "Null", "NULL", "~", "", *casings("null")] +BOOLEANS = [*casings("true"), *casings(FALSE_WORD)] +#: YAML 1.1's other booleans. The loader reads NONE of them today, so they are +#: candidates and not a list of what is quoted: whichever way the loader +#: reads one, the writer has to give it back unchanged. +YAML_1_1_BOOLEANS = [spelling for word in ("yes", "no", "on", "off", "y", "n") + for spelling in casings(word)] +NUMBERS = ["0", "1", "-1", "+1", "007", "1_000", "3.14", "-0.5", ".5", "1.", + "1e3", "1E-3", "0o17", "017", "0x1F", "0b101", ".inf", "-.inf", + ".Inf", ".INF", ".nan", ".NaN", "inf", "nan", "NaN"] +DATES = ["2026-10-09", "2026-10-09T12:30:00Z", "2026-10-09 12:30", "12:30", + "1:30:00"] +#: Every YAML indicator, leading a string and inside one. +INDICATORS = [f"{mark}{rest}" for mark in "-?:,[]{}#&*!|>'\"%@`~=" + for rest in ("", "x", " x")] +SYNTAX = ["a: b", "a:b", "key:", "a:", ": a", "a #b", "a#b", "a # b", " a", + "a ", " ", " ", "a b", "\ta", "a\t", "a,b", "[a]", "{a: b}", + "a [b]", "- a", "-", "--", "---", "...", "<<", "? a", "a: [b]"] +ESCAPES = ['a"b', "a'b", '"q"', "'q'", "a\\b", "a\\\\b", "\\n", "\\t", '\\"', + "a\\", "\\", '"', '""', "''", "a\\\"b", "\\x41", "\\u00e9"] +NON_ASCII = ["é", "日本", "emoji \U0001F600", " x"] +#: What the plan is mostly made of. These were written bare before and are +#: written bare now. +ORDINARY = ["spec", "code", "legs/spec", "Northwind", "Northwind-spec", + SPLIT_BRANCH, "main", OPEN_GLASS, "opensoft/openGlass", "xf-project-northwind", "README.md", + ".gitignore", ".github/", "a:b", "a@b", "a+b", "nullable", + "trueish", "nul", "Nonesuch", "falsey", "notnull"] + +#: One of each, in order: `casings` and the lists above overlap. +CANDIDATES = list(dict.fromkeys([ + *NULLS, *BOOLEANS, *YAML_1_1_BOOLEANS, *NUMBERS, *DATES, *INDICATORS, + *SYNTAX, *ESCAPES, *NON_ASCII, *ORDINARY])) + +#: The loader's keywords as it reads them today. NOT used to decide anything +#: in the writer, and not used to decide what these tests expect either except +#: in `test_a_value_the_loader_reads_as_a_keyword_is_quoted`, whose point is +#: that these exact strings are the ones that were wrong. +READ_AS_A_KEYWORD = ["null", "Null", "NULL", "~", "", "true", "True", "TRUE", + FALSE_WORD, "False", "FALSE"] + +SHAPES_OF_A_STRING = ["a value (`emit`)", "a list item", "an entry's path"] + + +@pytest.fixture(scope="module") +def adopter(): + """`adopt-project.py` loaded as a module: `y` has no command line.""" + module_spec = importlib.util.spec_from_file_location( + "adopt_plan_yaml_quoting", ADOPT) + module = importlib.util.module_from_spec(module_spec) + module_spec.loader.exec_module(module) + return module + + +def readings(adopter, value: str) -> list: + """What the loader makes of `value`, written by the plan's own writers + in each of the three places a string goes: `emit`, a follow-up's `- ` + item (written inline by `render_plan`, so spelled the same here), and + `Entry.write`'s path. Order is `SHAPES_OF_A_STRING`.""" + emitted: list[str] = [] + adopter.emit(emitted, "key", value, 2) + item = f" - {adopter.y(value)}" + entry: list[str] = [] + adopter.Entry(value, adopter.Verdict("spec", "r", "why", "high"), 1, + 1).write(entry) + return [ + parse_yaml("top:\n" + "\n".join(emitted) + "\n")["top"]["key"], + parse_yaml("top:\n" + item + "\n")["top"][0], + parse_yaml("paths:\n" + "\n".join(entry) + "\n")[PATHS][0]["path"], + ] + + +def assert_reads_back(adopter, value: str) -> None: + for shape, got in zip(SHAPES_OF_A_STRING, readings(adopter, value)): + assert isinstance(got, str) and got == value, ( + f"{value!r} was written as {adopter.y(value)} and read back " + f"as {got!r} ({type(got).__name__}) as {shape}") + + +# --- the round trip, through the real loader ------------------------------- + +@pytest.mark.parametrize("value", CANDIDATES, + ids=[repr(c) for c in CANDIDATES]) +def test_every_candidate_reads_back_as_the_same_string(adopter, value): + assert_reads_back(adopter, value) + + +def test_a_value_the_loader_reads_as_a_keyword_is_quoted(adopter): + """The strings that were wrong, said out loud: `plan --spec-path null` + wrote `spec_path: null` and `--tracking-branch true` wrote `true`.""" + for value in READ_AS_A_KEYWORD: + assert adopter.y(value) == f'"{value}"', value + + +def test_every_casing_of_a_keyword_reads_back_as_text(adopter): + """`NuLl` is text to this loader and `NULL` is not; whichever it is, the + writer has to give back what it was handed.""" + for word in ("null", "true", FALSE_WORD, "yes", "no", "on", "off", "y", "n", + "nan", "inf", "none"): + for spelling in casings(word): + assert_reads_back(adopter, spelling) + + +def test_a_keyword_with_something_on_either_side_reads_back_as_text(adopter): + for word in ("null", "true", "FALSE", "~", "yes"): + for prefix, suffix in itertools.product( + ("", ".", "_", "-", "x", " ", ":"), ("", ".", "_", ":", " ", + "-", " #", "x")): + assert_reads_back(adopter, f"{prefix}{word}{suffix}") + + +#: `(alphabet, longest string)`. Exhaustive, not sampled. The first is made of +#: the letters of `null` and `true` in both cases, to the length of the +#: keywords themselves, so every spelling of those two is among them; the +#: second is the characters the loader treats specially. +EXHAUSTIVE = [("nulNULtreTRE", 4), ("ab:# -.0'\"\\", 3)] + + +@pytest.mark.parametrize("alphabet, longest", EXHAUSTIVE) +def test_every_short_string_over_an_alphabet_reads_back(adopter, alphabet, + longest): + for length in range(1, longest + 1): + for chars in itertools.product(alphabet, repeat=length): + assert_reads_back(adopter, "".join(chars)) + + +def test_generated_strings_read_back(adopter): + """A seeded sample of longer strings over the whole printable ASCII range + and a few non-ASCII letters, so a case nobody thought of still has to come + back. No line break: see the module docstring.""" + rng = random.Random(181) + alphabet = [chr(code) for code in range(32, 127)] + ["\t", "é", + "日"] + words = ["null", "true", FALSE_WORD, "yes", "~", "on"] + for _ in range(4000): + if rng.random() < 0.35: + value = rng.choice(words) + "".join( + rng.choice(alphabet) for _ in range(rng.randint(0, 3))) + else: + value = "".join(rng.choice(alphabet) + for _ in range(rng.randint(0, 14))) + assert_reads_back(adopter, value) + + +def test_a_float_a_bool_an_int_and_none_are_still_typed_values(adopter): + """What is NOT a string is not quoted: the plan's counts and flags are + numbers and booleans on purpose, and `None` is how `leg: null` says an + entry is unresolved.""" + assert adopter.y(None) == "null" + assert adopter.y(True) == "true" and adopter.y(False) == FALSE_WORD + assert adopter.y(0) == "0" and adopter.y(1234) == "1234" + assert adopter.y(0.5) == "0.5000" + + +# --- what round-trips is written as it always was -------------------------- + +#: `(value, how `main` wrote it)`. Ordinary plans must not change, so these +#: are the spellings that existed before #181. `yes`, `on` and `y` are bare +#: because THIS loader reads them as text; the guard is for a writer that +#: quotes every word that looks like a keyword somewhere. +#: Bare before and bare now: the plan's ordinary values, and the words that +#: only look like keywords to a YAML 1.1 reader. +BARE_AS_ALWAYS = [*ORDINARY, "yes", "No", "on", "OFF", "y", "N", ".inf", + ".nan", "nUll", "tRUE"] +UNCHANGED = [(word, word) for word in BARE_AS_ALWAYS] + [ + # Never bare, with or without #181: + ("", '""'), ("~", '"~"'), ("1abc", '"1abc"'), ("007", '"007"'), + ("1.5", '"1.5"'), ("-1", '"-1"'), ("a b", '"a b"'), ("a:", '"a:"'), + (" a", '" a"'), ("a ", '"a "'), ("#x", '"#x"'), ("a #b", '"a #b"'), + ("2026-10-09", '"2026-10-09"'), ("a\\b", '"a\\\\b"'), + ('a"b', '"a\\"b"'), +] + + +@pytest.mark.parametrize("value, written", UNCHANGED, + ids=[repr(v) for v, _ in UNCHANGED]) +def test_a_value_the_loader_reads_as_text_is_written_as_it_always_was( + adopter, value, written): + assert adopter.y(value) == written + + +# --- the writer asks the loader --------------------------------------------- + +def test_the_writer_follows_the_loader_when_the_loader_learns_a_keyword( + adopter, monkeypatch): + """No list in `adopt-project.py` knows `yes`: it is bare today because + the loader reads it as text. Teach the REAL reader that `yes` and `on` are + booleans and the writer quotes them without an edit; everything the reader + still reads as text stays bare.""" + reads = repo_shape._scalar + + def learned(raw): + return True if raw.strip() in ("yes", "on") else reads(raw) + + assert adopter.y("yes") == "yes" and adopter.y("on") == "on" + monkeypatch.setattr(repo_shape, "_scalar", learned) + assert adopter.y("yes") == QUOTED_YES + assert adopter.y("on") == '"on"' + assert adopter.y("spec") == "spec" + assert adopter.y("off") == "off" + + +def test_a_word_the_loader_refuses_is_quoted_not_raised(adopter, monkeypatch): + """A line the reader will not read is a value it does not read back: when + the loader RAISES on a bare word, the writer quotes it instead of letting + `plan` die with a `YamlError` that names a line of a plan nobody has seen. + No plain string raises today (the reader refuses `&x`, which `_PLAIN_RE` + never lets through), so the loader is taught to.""" + reads = repo_shape._scalar + + def refusing(raw): + if raw.strip() == "yes": + raise repo_shape.YamlError("a word this reader does not take") + return reads(raw) + + monkeypatch.setattr(repo_shape, "_scalar", refusing) + assert adopter.y("yes") == QUOTED_YES + assert adopter.y("spec") == "spec" + + +@pytest.mark.parametrize("place, wrong", [ + ("k: yes\n", {"k": True}), ("- yes\n", [True])], + ids=["after a key", "as a list item"]) +def test_a_value_is_asked_about_in_each_place_it_is_written( + adopter, monkeypatch, place, wrong): + """The two places read a string differently (`- a:` is a one-key mapping, + `k: a:` is not), and today the second is the stricter, so removing the + first changes no output. A loader that misreads the word in ONE of them + still has to get it quoted, so each question is pinned here.""" + reads = adopter.parse_yaml + monkeypatch.setattr(adopter, "parse_yaml", + lambda text: wrong if text == place else reads(text)) + assert adopter.y("yes") == QUOTED_YES + assert adopter.y("spec") == "spec" + + +# --- end to end, through `plan` and `check` -------------------------------- + +def checked(plan: Path): + return run_script(ADOPT, "check", "--plan", str(plan)) + + +def planned(tmp_path: Path, *extra: str, tree: dict | None = None) -> Path: + source = make_source_repo(tmp_path / "Thing", tree=tree) + plan = tmp_path / "plan.yaml" + written = write_plan(source, plan, extra=extra) + assert written.returncode == 0, written.stderr + written.stdout + return plan + + +def answer(plan: Path, path: str, leg: str) -> None: + """`resolve` for an entry whose path the writer had to QUOTE, which + `conftest.resolve` cannot find because it looks for the bare spelling.""" + text = text_of(plan) + needle = f' - path: "{path}"\n leg: null\n' + assert needle in text, f"{path} is not a quoted, unresolved entry" + plan.write_text(text.replace( + needle, f' - path: "{path}"\n leg: {leg}\n' + ' resolution: "answered by the test"\n', 1), + encoding="utf-8") + + +def test_a_spec_path_of_null_is_the_string_null_and_check_mounts_it_there( + adopter, tmp_path): + """The repro of #181. On `main` the plan said `spec_path: null`, which + loaded as no value, so `execute` mounted the leg at `spec` while `check` + said so in the one sentence that names the mount. + + WHAT #169'S CANONICAL CHECK THEN DECIDES: `null` is one plain segment, + neither a name Git keeps (`.git`) nor a Windows device (`nul` is one, + `null` is not), so it is a path a leg can be mounted at and `check` + accepts it -- as a directory literally called `null`, which is what was + typed. Refusing it would be refusing a legal name for being spelled like a + YAML keyword, and rewriting it to `spec` is the silent change #169 refuses. + """ + plan = planned(tmp_path, "--spec-path", "null") + assert 'spec_path: "null"' in text_of(plan) + loaded = load_yaml(plan) + assert loaded["legs"]["spec_path"] == "null" + assert isinstance(loaded["legs"]["spec_path"], str) + for path, leg in ANSWERS: + resolve(plan, path, leg) + spec_path, code_path, _, _ = adopter._checked_plan_values( + adopter.Plan.load(plan)) + assert (spec_path, code_path) == ("null", "code"), ( + "what `execute` mounts the legs at") + result = checked(plan) + assert result.returncode == 0, result.stdout + result.stderr + assert "plan ok" in result.stdout + assert "mount the legs at null/ and code/" in result.stdout + assert "at spec/" not in result.stdout + + +def test_a_tracking_branch_of_true_is_the_string_true(adopter, tmp_path): + """`--tracking-branch true` was written `tracking_branch: true`, read back + as a boolean, and reached `execute` as `True`.""" + plan = planned(tmp_path, "--tracking-branch", "true") + assert 'tracking_branch: "true"' in text_of(plan) + loaded = load_yaml(plan) + assert loaded["tracking_branch"] == "true" + assert isinstance(loaded["tracking_branch"], str) + for path, leg in ANSWERS: + resolve(plan, path, leg) + assert adopter._checked_plan_values(adopter.Plan.load(plan))[3] == "true" + assert checked(plan).returncode == 0 + + +@needs_filter_repo +def test_execute_mounts_the_leg_at_null_and_tracks_the_branch_true(tmp_path): + """The whole chain, to the split branch. On `main` `execute` read + `spec_path: null` as no value and registered the mount at `spec`, and + read `tracking_branch: true` as a boolean; now the gitlink is at `null` + -- the directory the person typed, and the one `check` said it would use + -- and the branch the legs track is the one called `true`.""" + source = make_source_repo(tmp_path / "Thing") + plan = tmp_path / "plan.yaml" + written = write_plan(source, plan, extra=("--spec-path", "null", + "--tracking-branch", "true")) + assert written.returncode == 0, written.stderr + written.stdout + for path, leg in ANSWERS: + resolve(plan, path, leg) + remotes = tmp_path / "remotes" + result = run_script(ADOPT, "execute", "--plan", str(plan), "--yes", + "--local-remote-dir", str(remotes), + "--work-dir", str(tmp_path / "work")) + assert result.returncode == 0, result.stderr + result.stdout + assert "adoption verified" in result.stdout + gitlinks = [line.split()[-1] for line in git( + "ls-tree", SPLIT_BRANCH, cwd=source).stdout.splitlines() + if line.startswith("160000")] + assert sorted(gitlinks) == ["code", "null"], ( + "no gitlink at `spec`: the leg is mounted where `check` said") + for leg_repository in sorted(remotes.glob("*.git")): + assert git("branch", "--list", cwd=leg_repository).stdout.split() == [ + "*", "true"], leg_repository.name + + +@pytest.mark.parametrize("pin", ["null", "Null", "NULL", "true", "False"]) +def test_a_pin_named_like_a_keyword_is_that_pin(adopter, tmp_path, pin): + """`pins: [` is the one list of strings the plan writes without `y`, and + `--pin null` was written `pins: [null]`: a null the plan's reader drops, + so the pin the person asked for vanished (and `--pin true` came back as + the pin `True`).""" + plan = planned(tmp_path, "--pin", pin) + assert f'\npins: ["{pin}"]\n' in text_of(plan) + assert load_yaml(plan)["pins"] == [pin] + assert adopter.Plan.load(plan).pins == [pin] + + +@pytest.mark.parametrize("pins, written", [ + ((OPEN_GLASS,), "pins: [openGlass]"), + (("opensoft/openGlass",), "pins: [opensoft/openGlass]"), + ((OPEN_GLASS, "opensoft/openCode"), "pins: [openGlass, opensoft/openCode]"), + ((), "pins: []"), +], ids=["one", "owned", "two", "none"]) +def test_an_ordinary_pin_is_written_as_it_always_was(tmp_path, pins, written): + """`openGlass` and `opensoft/openGlass` are the pins that exist; they stay + bare, singly and together, and no pin is still `pins: []`.""" + extra = tuple(part for pin in pins for part in ("--pin", pin)) + text = text_of(planned(tmp_path, *extra)) + assert f"\n{written}\n" in text + + +def test_a_source_file_named_like_a_keyword_is_one_entry_with_that_path( + tmp_path): + """The writer's other caller: an entry's `path:`. A file called `null` or + `True` was an entry with no path (or a boolean one), and `check` reported + the file uncovered and the entry empty on a plan the tool had just + written.""" + tree = {"null": "x\n", "True": "x\n", "README.md": "# Thing\n", + "src/app/main.py": "VALUE = 1\n", "specs/001/spec.md": "# s\n"} + plan = planned(tmp_path, tree=tree) + paths = [e["path"] for e in load_yaml(plan)[PATHS]] + assert "null" in paths and "True" in paths, paths + assert all(isinstance(path, str) for path in paths), paths + for path in ("null", "True"): + answer(plan, path, "root") + result = checked(plan) + assert result.returncode == 0, result.stdout + result.stderr + assert "FINDING" not in result.stdout + result.stderr + + +def test_the_doctors_placement_plan_quotes_a_path_the_same_way(adopter): + """`shape-doctor.py --placement-plan` writes its entries with this `y`, so + a misplaced file called `null` was the same defect there. It reads back as + the string too.""" + module_spec = importlib.util.spec_from_file_location( + "shape_doctor_yaml_quoting", DOCTOR) + doctor = importlib.util.module_from_spec(module_spec) + module_spec.loader.exec_module(doctor) + entry = {"path": "null", "leg": "spec", "classified_as": "code", + "confidence": "medium", "rule": "extension-majority", + "reason": "x", "files": 1, "bytes": 2, "review_required": False} + lines = doctor.placement_plan_entry(adopter, entry) + loaded = parse_yaml("paths:\n" + "\n".join(lines) + "\n") + assert loaded[PATHS][0]["path"] == "null" From b26841b85e264663af5e2d8d2873c525468916ad Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:38:16 +0000 Subject: [PATCH 02/24] adopt-project.py: `plan`, `check` and `execute` refuse a value `execute` would write into `project.yaml` or a leg pin that the project's own loader reads back as something else, so `--spec-path null`, `--id true` and `--tracking-branch true` no longer end `adoption verified` before a bootstrap that fails or puts the legs on another branch (#181) 76a6153 made the PLAN say what was typed: `y()` quotes any string the plan's loader would misread. But `execute` writes the same values into the assembly root's templates as they stand, and `project.yaml` and `contracts/{spec,code}-pin.yaml` carry most of them bare (`id: {{PROJECT_ID}}`, `path: {{SPEC_PATH}}`, `tracking_branch: {{TRACKING_BRANCH}}`). So once the plan kept `null` as text, `--spec-path null` ended `adoption verified` and the first bootstrap failed on `path is None`; `--id true` and `--project True` on `id is True`; `--pin null` on `neutral-pin-entry-malformed`; and `--tracking-branch true` pushed the legs on `true` while bootstrap put them on a local `True` with no upstream (lane 2's review of 76a6153, P2-1). `2026`, `1.5` and `~` did the same on main already (#187's review). THE LOADER DECIDES. `loader_reading(line)` is the one question, asked by `y()`'s round trip (`_reads_back_as_text`, rewritten onto it; old and new bodies agree on 260,880 strings) and by the new gate `manifest_misreads`. The gate puts each value into every line of a templated YAML file (`shape_materialize.TEMPLATED`, the `.yaml` ones) that carries its placeholder, exactly as `execute` would, and asks the loader to read the line back; a pin is asked as the ` - ` item `_template_values` writes. There is no list of words and no list of which placeholders are quoted: `elected_by: "{{ELECTED_BY}}"` is asked as the quoted line it is, so `Null` passes there and `CORP\tom` does not (the loader makes `\t` a tab). The values asked about: the id (named as derived when `plan` lowercased `--project`, or when the plan has no `id:`), the tracking branch, visibility, the elector, the election date, the reference, both leg paths and every pin. - `plan`: `REFUSED adopt-manifest-misread`, exit 2, before the plan is written; each sentence names the flag (and where `plan` took a value it filled in itself), the value, the line `execute` would write, what the loader reads it as, and "choose another value". - `check`: `FINDING plan-manifest-misread`, exit 1, with the remediation once after. - `execute`: refused through `_entry_findings` and #168's `_refuse_what_check_finds`, before any leg repository exists. Quoting the template placeholders instead was considered and not done: the same templates render every scaffolded project, and quoting is not enough on its own (`elected_by:` is quoted today and still misreads `CORP\tom`). THE REVIEWER'S QUESTION, TAKEN. A plan carrying an explicit null leg path (`spec_path: null`, `~`, or the key with nothing after it, which is what a plan written by main's `plan --spec-path null` says) was read as the default and mounted at `spec` while its own follow-ups said `null/contracts`. `_plan_leg_path` now hands back the None it holds, as #169 already does for an explicit empty value, and `check` and `execute` report `plan-bad-leg-path: legs.spec_path is not text: its value was read as no value at all`. `check`'s mount line leaves out a path that is not text instead of printing `None/`. The not-text sentence no longer says "so quote it": quoted, every such spelling is text the gate now refuses. P3-2: the test module's docstring said main refused a name ending in a line break as unparsable; on main 20e13f0 that plan reads the entry back as `trail` and `check` reports `plan-uncovered` and `plan-empty-entry`. Corrected. Tests: tests/test_adopt_plan_yaml_quoting.py replaces the old end-to-end block. Each refused value is refused at `plan` (18 flag values, plus an id `plan` derived from `--project` and an elector it took from `git config`), at `check` (20 plan values, plus an id derived from `legs.assembly`) and at `execute` before any leg exists (the same 20, needs_filter_repo). What is still accepted (`--spec-path legs/null`, `--tracking-branch 2026-q4`, `--id yes`, `--elected-by Null`) goes through `execute`, the recursive clone and `bootstrap.py`, and each leg is on `2026-q4` with `origin/2026-q4` as its upstream. The gate follows the loader when the loader learns a keyword, reads the template line for its quoting, and agrees with the three files rendered whole by `render` on every candidate. An explicit null leg path is a finding for `null`, `~` and nothing. tests/test_adopt_leg_paths.py: the not-text sentence has no "quote" and covers None. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- adopt-project.py | 282 ++++++++++++-- tests/test_adopt_leg_paths.py | 19 +- tests/test_adopt_plan_yaml_quoting.py | 514 +++++++++++++++++++++----- 3 files changed, 701 insertions(+), 114 deletions(-) diff --git a/adopt-project.py b/adopt-project.py index ea6ab10c..29895bb3 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -28,9 +28,12 @@ (`spec`, `legs/spec`; never `spec/` or `./spec`, a name Git or Windows keeps for itself such as `.git` or `CON`, or a value `execute` would refuse; the two never equal or nested, in any - case), and the source's own submodules kept on one leg with - their `.gitmodules`, registered there and never in the root. It - prints what will happen and changes nothing. + case), the source's own submodules kept on one leg with their + `.gitmodules`, registered there and never in the root, and every + value `execute` writes into the project's YAML (`id`, the + branch, the leg paths, the pins, the election record) one its + loader reads back as that same text, never `null`, `true` or + `2026` (#181). It prints what will happen and changes nothing. execute creates the two legs, extracts them with `git filter-repo`, makes the one split commit on a branch of the source, sets the `xf-project-` topic on all three (skipped for local remotes), @@ -92,7 +95,7 @@ accepts_role, checked_value, git_out, load_yaml, parse_yaml, tree_digest, ) from shape_materialize import ( # noqa: E402 - ADOPT_MAKEFILE_BLOCK, RULESET_HINT, SHAPE_REPOSITORY, + ADOPT_MAKEFILE_BLOCK, RULESET_HINT, SHAPE_REPOSITORY, TEMPLATED, CommandFailed, Materialized, collision_follow_up, copy_tree, default_reference, election_date, env_commit, git_init_commit, materialize_assembly_root, naming_block, run, write_lf, @@ -212,6 +215,26 @@ def _checked_pin_name(raw: str) -> str: WINDOWS_ABSOLUTE_RE = re.compile(r"^[A-Za-z]:[\\/]") +def loader_reading(line: str): + """What the loader makes of the one value on `line`: `key: ` or + `- `, as a plan or a project's YAML spells it. + + THE ONE QUESTION, asked by the plan writer (`_reads_back_as_text`) and by + the gate on what `execute` writes into the project (`manifest_misreads`), + of the one loader both are read with: `repo_shape`, which every assembly + root carries a copy of for its `bootstrap.py` and validators. The answer + is the value under the key, or the one item, as that loader reads it; a + line it will not read gives back the `YamlError` it raised, which is no + string at all. + """ + try: + parsed = parse_yaml(line + "\n") + except YamlError as exc: + return exc + held = list(parsed.values()) if isinstance(parsed, dict) else parsed + return held[0] if isinstance(held, list) and len(held) == 1 else parsed + + def _reads_back_as_text(text: str) -> bool: """Does the plan's own loader take `text`, written plain, back as `text`? @@ -230,11 +253,8 @@ def _reads_back_as_text(text: str) -> bool: `k: a:` is not). A line the reader refuses is a value it does not read back. """ - try: - return (parse_yaml(f"k: {text}\n") == {"k": text} - and parse_yaml(f"- {text}\n") == [text]) - except YamlError: - return False + return all(loader_reading(f"{place}{text}") == text + for place in ("k: ", "- ")) def y(value) -> str: @@ -993,11 +1013,13 @@ def render_plan(args, source: Source, entries: list[Entry], names: dict, re.IGNORECASE) #: What a plan's non-text `spec_path` was read as. The YAML reader keeps no raw -#: text (`007` is read as 7, `true` and `True` as one boolean, `1.50` as 1.5), -#: so a sentence that echoed the parsed value would show something the file -#: never said; it names the KIND the reader made of it instead. +#: text (`007` is read as 7, `true` and `True` as one boolean, `1.50` as 1.5, +#: and `null`, `~` and nothing at all as one None), so a sentence that echoed +#: the parsed value would show something the file never said; it names the +#: KIND the reader made of it instead. NOT_TEXT_KINDS = {bool: "true or false", int: "a number", float: "a number", - list: "a list", dict: "a mapping"} + list: "a list", dict: "a mapping", + type(None): "no value at all"} LEG_PATH_REMEDIATION = ( "Remediation: nothing here rewrites a path for you, so correct the value " @@ -1100,9 +1122,14 @@ def leg_path_problem(what: str, value) -> str | None: the thing the person has in front of them. """ if not isinstance(value, str): + # NOT "SO QUOTE IT" (#181). Quoted, the plan reads the same spelling + # back as text, and `execute` writes that text into `project.yaml` + # and the leg's pin as it stands, where `manifest_misreads` refuses + # every spelling the loader reads as one of these kinds. kind = NOT_TEXT_KINDS.get(type(value), "something that is not text") return (f"{what} is not text: its value was read as {kind}, and a " - "path is written as text, so quote it") + "leg path is text: write the path you mean, or remove the key " + "to mount the leg at its role's own name") spelling = _spelling_reason(value) if spelling: offered = _canonical_spelling(value) @@ -1165,7 +1192,11 @@ def _plan_leg_path(plan: Plan, role: str): A key the plan does not carry is the default, the role's own name, as `execute` has always read it. An EXPLICIT empty value is not a missing one: it comes back as it stands and is refused, where it used to be read - quietly as the default -- a value nobody chose. + quietly as the default -- a value nobody chose. So is an explicit NULL + (`spec_path: null`, `~`, or the key with nothing after it), which comes + back as None (#181): before `y` quoted keywords, `plan --spec-path null` + wrote exactly that, the plan's own follow-ups said `null/contracts`, and + `check` passed it as a mount at `spec`. `legs:` is read only when it is a mapping. The doctor's PLACEMENT plan (`shape-doctor.py --placement-plan`) writes `legs:` as a LIST of the @@ -1176,8 +1207,8 @@ def _plan_leg_path(plan: Plan, role: str): a key the plan does not carry. """ legs = plan.legs - value = legs.get(f"{role}_path") if isinstance(legs, dict) else None - return role if value is None else value + key = f"{role}_path" + return legs[key] if isinstance(legs, dict) and key in legs else role def _plan_leg_paths(plan: Plan) -> tuple[tuple, tuple]: @@ -1188,6 +1219,190 @@ def _plan_leg_paths(plan: Plan) -> tuple[tuple, tuple]: ("legs.code_path", _plan_leg_path(plan, "code"))) +# --------------------------------------------------------------------------- +# What `execute` writes into the project is read back as written (#181) +# --------------------------------------------------------------------------- +# +# `y` makes the PLAN say what was typed. `execute` then puts the same values +# into the assembly root's templates, and `project.yaml` and the leg pins +# carry most of them bare: `id: {{PROJECT_ID}}`, `path: {{SPEC_PATH}}`, +# `tracking_branch: {{TRACKING_BRANCH}}`. So once the plan kept `null` as the +# text it was, `--spec-path null` ended `adoption verified` and the first +# bootstrap failed on `path is None`, `--id true` on `id is True`, and +# `--tracking-branch true` pushed the legs on `true` while bootstrap put them +# on a local `True` with no upstream. `2026`, `1.5` and `~` already did the +# same on `main` (#187's review). +# +# THE FIX IS TO REFUSE. `plan` (before the plan is written), `check` (a +# FINDING) and `execute` (through `_entry_findings`, before any leg exists) +# ask the ONE question below and refuse a value the project's loader would +# read back as anything but that same text. Quoting the placeholders instead +# was considered and not done: the same templates render every scaffolded +# project as well as every adopted one. +# +# THE LOADER DECIDES, AND THE TEMPLATE SAYS WHERE. Each value is put into +# every line of a templated YAML file that carries its placeholder, exactly +# as `execute` would put it there, and `loader_reading` -- the question `y` +# asks -- reads that line back. No list of words, and no list of which +# placeholders are quoted: `elected_by: "{{ELECTED_BY}}"` is asked as the +# quoted line it is, so `Null` reads back there and `CORP\tom` does not +# (the loader makes the `\t` a tab). + +#: The assembly root's templated files that are YAML: what the project's own +#: loader reads back out of the split commit. The materializer's list, so a +#: templated YAML file added there is asked about here without an edit. +ASSEMBLY_TEMPLATE = SHAPE_ROOT / "templates" / "assembly-root" +TEMPLATED_YAML = tuple(rel for rel in TEMPLATED if rel.endswith(".yaml")) +#: The manifest, which holds the one list `_template_values` writes itself. +MANIFEST_YAML = "project.yaml" + +#: Each plan value `execute` writes into those files, by its key in the plan +#: (and `plan`'s flag of the same name, dashed) and the `_template_values` +#: placeholder that carries it. The id is asked about apart from these, +#: because `execute` derives it when the plan has none; the leg paths are +#: `legs.` keys; the pins are a list. +MANIFEST_FIELDS = {"tracking_branch": "TRACKING_BRANCH", + "visibility": "VISIBILITY", "elected_by": "ELECTED_BY", + "elected_on": "ELECTED_ON", "reference": "REFERENCE"} +LEG_PATH_PLACEHOLDERS = {"spec": "SPEC_PATH", "code": "CODE_PATH"} +#: How `plan` fills a flag nobody passed, where the value it fills in can be +#: one the loader misreads: a sentence about it names where it came from. +FLAG_DEFAULTS = {"id": "--project lowercased", + "elected_by": "`git config user.name`"} + +MANIFEST_MISREAD_REMEDIATION = ( + "Remediation: choose another value for each one named. `execute` writes " + "these values into the project's `project.yaml` and leg pins as they " + "stand, and the loader the project's own `bootstrap.py` and validators " + "read those files with would take each one named here as something else: " + "the run would still end `adoption verified`, and the project would then " + "be bootstrapped and validated against what was read instead. Quoting it " + "in the plan does not help, because the plan is not what is misread.") + + +def _pin_item(pin: str) -> str: + """One pin as `_template_values` writes it under `neutral_product_pins:`, + and as `manifest_misreads` asks about it.""" + return f" - {pin}" + + +def _written(placeholder: str, value: str) -> list[tuple[str, str]]: + """`(file, line)` for each line of a templated YAML file that carries + `{{placeholder}}`: with `value` in its place, as `execute` writes it, and + without its indent, which says nothing about how the value is read.""" + token = "{{" + placeholder + "}}" + return [(rel, line.strip().replace(token, value)) + for rel in TEMPLATED_YAML + for line in (ASSEMBLY_TEMPLATE / rel).read_text( + encoding="utf-8").splitlines() + if token in line] + + +def _field(what: str, placeholder: str, value) -> tuple[str, str, list]: + """`(what, value, written)` for a value `_template_values` writes as + `str(value)`, which is how a plan's non-text `elected_on` reaches it.""" + text = str(value) + return what, text, _written(placeholder, text) + + +def _pin_field(what: str, pin: str) -> tuple[str, str, list]: + return what, pin, [(f"{MANIFEST_YAML}'s neutral_product_pins", + _pin_item(pin).strip())] + + +def _the_loader_made(reading) -> str: + """What `loader_reading` gave back, in words. The parsed value is shown, + because the sentence it ends also shows the text it was read from.""" + if isinstance(reading, YamlError): + return f"nothing: it refuses the line ({reading})" + if reading is None: + return "no value at all" + if isinstance(reading, bool): + return "the boolean " + ("true" if reading else "false") + if isinstance(reading, (int, float)): + return f"the number {reading!r}" + if isinstance(reading, str): + return f"the text {reading!r}" + return NOT_TEXT_KINDS.get(type(reading), "something that is not text") + + +def _shown(line: str) -> str: + """`line` as a sentence quotes it: in backticks, or as Python spells it + when it holds a character a terminal shows as something else (a line + break, a tab), so that one FINDING stays one line.""" + return f"`{line}`" if line.isprintable() else repr(line) + + +def manifest_misreads(fields) -> list[str]: + """A sentence for each `(what, value, written)` whose `value` the + project's loader does not read back out of every line in `written`; + `[]` when it does for all of them. + + `what` is how the caller names the value -- `--tracking-branch` to + `plan`, `tracking_branch` read out of a plan -- so the sentence points at + the thing the person has in front of them. + """ + problems = [] + for what, value, written in fields: + wrong = [(rel, line, loader_reading(line)) for rel, line in written] + wrong = [row for row in wrong if row[2] != value] + if wrong: + where = " and ".join(f"into {rel} as {_shown(line)}" + for rel, line, _ in wrong) + problems.append( + f"{what} is {value!r}, which `execute` writes {where}, where " + "the project's own YAML loader reads it back as " + f"{_the_loader_made(wrong[0][2])} and not as that text: " + "choose another value") + return problems + + +def _flag_fields(args, given: set, pins: list[str]) -> list[tuple]: + """`plan`'s values as it is about to record them, each named by its flag, + and by where `plan` took it from when `given` says it was not passed.""" + def flag(key: str) -> str: + spelled = "--" + key.replace("_", "-") + if key in FLAG_DEFAULTS and key not in given: + spelled += f" (not given, so {FLAG_DEFAULTS[key]})" + return spelled + + fields = [_field(flag("id"), "PROJECT_ID", args.id)] + fields += [_field(flag(key), placeholder, getattr(args, key)) + for key, placeholder in MANIFEST_FIELDS.items()] + fields += [_field(f"--{role}-path", placeholder, + getattr(args, f"{role}_path")) + for role, placeholder in LEG_PATH_PLACEHOLDERS.items()] + return fields + [_pin_field("--pin", pin) for pin in pins] + + +def _plan_fields(plan: Plan) -> list[tuple]: + """A plan's values as `_template_values` would write them, each named by + its key in the plan. + + A key the plan does not carry is left out: `execute` fills it with a + default of this tool's own (`main`, `private`, today's date), and none of + those is misread. A leg path is asked about only when it is text with no + `plan-bad-leg-path` of its own, so one wrong value is one finding. An id + `Plan.project_id` refuses, or a refused leg name it is derived from, is + refused on its own account by `check` and `execute` alike. + """ + fields: list[tuple] = [] + try: + what = ("id" if plan.get("id") is not None else + "id (not in the plan, so legs.assembly lowercased)") + fields.append(_field(what, "PROJECT_ID", plan.project_id)) + except Refusal: + pass + fields += [_field(key, placeholder, plan.get(key)) + for key, placeholder in MANIFEST_FIELDS.items() + if plan.get(key) is not None] + for (what, value), placeholder in zip(_plan_leg_paths(plan), + LEG_PATH_PLACEHOLDERS.values()): + if isinstance(value, str) and leg_path_problem(what, value) is None: + fields.append(_field(what, placeholder, value)) + return fields + [_pin_field("pins", pin) for pin in plan.pins] + + def _checked_plan_inputs(args, source: Source, naming: NamingPolicy) -> tuple[dict, list]: """Every value the plan will RECORD, defaulted and checked before one of @@ -1198,6 +1413,9 @@ def _checked_plan_inputs(args, source: Source, refused an unparseable `--elected-on` would send a reader looking in the wrong place for the line that was wrong. """ + # Which of the flags `plan` fills in by itself were passed: a value it + # filled in is named by where it came from, should it be refused below. + given = {key for key in FLAG_DEFAULTS if getattr(args, key)} args.project = checked_value("--project", args.project) args.tracking_branch = checked_value("--tracking-branch", args.tracking_branch) @@ -1232,6 +1450,14 @@ def _checked_plan_inputs(args, source: Source, names = _names(args.project) pins = [_checked_pin_name(p) for p in (args.pin or []) if p.strip()] _check_names(naming, names, set(pins)) + # LAST, over every value as the plan will record it: `execute` writes + # them into the project's own YAML, and one its loader would read as + # something else is refused before the plan exists (#181). + misread = manifest_misreads(_flag_fields(args, given, pins)) + if misread: + raise Refusal("adopt-manifest-misread", + " ".join(f"{problem}." for problem in misread), + f"{MANIFEST_MISREAD_REMEDIATION} No plan was written.") return names, pins @@ -1870,7 +2096,9 @@ def _submodule_findings(plan: Plan, source: Source, def _entry_findings(plan: Plan, source: Source ) -> tuple[list[str], list[str]]: """Every FINDING `check` prints about the plan's ENTRIES, in its order, - and what it prints once after them: coverage, then each entry's `leg:`, + and what it prints once after them: coverage, then each entry's `leg:` + and the leg paths, then each value `execute` would write into the + project's YAML for its loader to read back as something else (#181), then the source's own submodules. ONE LIST, read by `check` and by `execute`'s `_refuse_what_check_finds`. @@ -1885,8 +2113,12 @@ def _entry_findings(plan: Plan, source: Source findings = (_coverage_findings([str(e.get("path")) for e in plan.entries], [path for path, _, _, _ in tree]) + _leg_findings(plan)) + misread = [f"FINDING plan-manifest-misread: {problem}." + for problem in manifest_misreads(_plan_fields(plan))] submodule_findings, remediation = _submodule_findings(plan, source, tree) - return findings + submodule_findings, remediation + return (findings + misread + submodule_findings, + ([MANIFEST_MISREAD_REMEDIATION] if misread else []) + + remediation) def _topics_line(topic: str, local: bool) -> str: @@ -1915,10 +2147,14 @@ def _print_what_will_happen(plan: Plan, source: Source, names: dict, for role in seeded: print(f" SEED the {role} leg from {SEED_TEMPLATE[role]}/ — no path is " "assigned to it") + # A leg path that is not text is a FINDING of its own, and its Python + # spelling (`None/`, `True/`) names no directory a leg would be mounted at. + mounts = [_plan_leg_path(plan, role) for role in EXTRACTED_LEGS] + where = (f" at {mounts[0]}/ and {mounts[1]}/" + if all(isinstance(mount, str) for mount in mounts) else "") print(f" ONE split commit on branch {plan.get('adopt_branch', ADOPT_BRANCH)} " - f"of {names['assembly']}: `git rm -r` those paths, mount the legs at " - f"{_plan_leg_path(plan, 'spec')}/ and " - f"{_plan_leg_path(plan, 'code')}/") + f"of {names['assembly']}: `git rm -r` those paths, mount the " + f"legs{where}") print(f" {len(stays)} path(s) stay in the assembly root; " f"{len(drops)} dropped") print(" the source is never deleted, never renamed, never force-pushed") @@ -2687,7 +2923,7 @@ def _template_values(plan: Plan, names, repositories, urls, spec_path, "SPEC_TREE_SHA256": leg_digests.get("spec", ""), "CODE_TREE_SHA256": leg_digests.get("code", ""), "NEUTRAL_PRODUCT_PINS": ("[]" if not pins else - "\n" + "\n".join(f" - {p}" + "\n" + "\n".join(_pin_item(p) for p in sorted(pins))), "ASSEMBLY_NAMING": naming_block(naming, names["assembly"], "assembly", pins), diff --git a/tests/test_adopt_leg_paths.py b/tests/test_adopt_leg_paths.py index da47e80f..3bb9c7b7 100644 --- a/tests/test_adopt_leg_paths.py +++ b/tests/test_adopt_leg_paths.py @@ -243,25 +243,34 @@ def test_a_non_canonical_path_is_named_with_its_canonical_spelling( assert problem.endswith(f"Write {spelling!r}.") -#: What the YAML reader makes of `spec_path: 5`, `true`, `1.50`, `[spec]` and a -#: block mapping. The reader keeps no raw text, so the sentence names the KIND -#: and never echoes Python's repr of the parsed value: `007` is read as 7, -#: `true` as True and `1.50` as 1.5, none of which the file ever said. +#: What the YAML reader makes of `spec_path: 5`, `true`, `1.50`, `[spec]`, a +#: block mapping and `null`. The reader keeps no raw text, so the sentence +#: names the KIND and never echoes Python's repr of the parsed value: `007` is +#: read as 7, `true` as True, `1.50` as 1.5 and `~` as None, none of which the +#: file ever said. NOT_TEXT = [ pytest.param(5, "a number", id="int"), pytest.param(True, "true or false", id="bool"), pytest.param(1.5, "a number", id="float"), pytest.param(["spec"], "a list", id="list"), pytest.param({"a": "b"}, "a mapping", id="mapping"), + pytest.param(None, "no value at all", id="null"), ] @pytest.mark.parametrize("value, kind", NOT_TEXT) def test_a_value_that_is_not_text_is_a_problem_that_does_not_echo_it( adopter, value, kind): + """And it does not say "quote it" (#181): quoted, every one of these + spellings is text `execute` writes bare into `project.yaml`, where the + loader reads the same kind again and `plan-manifest-misread` refuses + it.""" problem = adopter.leg_path_problem("legs.spec_path", value) assert problem.startswith("legs.spec_path is not text: ") - assert f"was read as {kind}," in problem and "so quote it" in problem + assert problem.endswith( + f"was read as {kind}, and a leg path is text: write the path you " + "mean, or remove the key to mount the leg at its role's own name") + assert "quote" not in problem assert repr(value) not in problem, "the parsed value is not what was written" diff --git a/tests/test_adopt_plan_yaml_quoting.py b/tests/test_adopt_plan_yaml_quoting.py index fa8cbd12..a04d11f7 100644 --- a/tests/test_adopt_plan_yaml_quoting.py +++ b/tests/test_adopt_plan_yaml_quoting.py @@ -23,10 +23,23 @@ cannot span lines, and the loader resolves an escape for `\\n` and `\\t` only. A value carrying one is therefore not a question of quoting, #181 does not make one read back, and putting it in a generator would assert a rule this -change does not make. (The one visible difference: a name whose ONLY line -break is a final one matched `_PLAIN_RE`, because `$` matches before it, and -was written bare; it is quoted now. Both spellings split the entry over two -lines and `check` refuses either plan as `yaml-unparsable`.) +change does not make (#199 is the issue for it). The one visible difference: +a name whose ONLY line break is a final one matched `_PLAIN_RE`, because `$` +matches before it, and was written bare. On `main` the plan then read the +entry back as the name WITHOUT its line break (`trail` for `trail\\n`), and +`check` reported the file uncovered (`plan-uncovered`) and the entry empty +(`plan-empty-entry`). It is quoted now, which splits the entry over two lines, +and `check` refuses the plan as `yaml-unparsable`. Neither reads it back. + +WHAT `execute` WRITES IS ASKED ABOUT TOO (lane 2's review of 76a6153). Once +the plan said `null` and meant it, `execute` put it into `project.yaml` and +the leg pins as it stands -- `path: null`, `id: true`, `tracking_branch: +true` -- and the run ended `adoption verified` before a bootstrap that failed, +or that put the legs on a branch other than the one they were pushed on. So +`plan`, `check` and `execute` refuse a value the project's own loader would +read back as something else, by the same question `y` asks +(`loader_reading`), put into each template line that carries the value; and +the end-to-end test clones and bootstraps what is still accepted. """ from __future__ import annotations @@ -34,14 +47,15 @@ import importlib.util import itertools import random +import re import shutil import sys from pathlib import Path import pytest -from conftest import (ADOPT, REPO, git, make_source_repo, resolve, run_script, - write_plan) +from conftest import (ADOPT, FILE_PROTOCOL, REPO, git, make_source_repo, + resolve, run_script, write_plan) sys.path.insert(0, str(REPO / "scripts")) import repo_shape # noqa: E402 @@ -61,6 +75,8 @@ SPLIT_BRANCH = "adopt/three-repo-shape" QUOTED_YES = '"yes"' PATHS = "paths" +PLAN_FILE = "plan.yaml" +UTF8 = "utf-8" #: Answered the way `test_adopt_plan.py` answers them, so `check` can pass. ANSWERS = (("examples/", "spec"), (".claude/", "root"), @@ -68,7 +84,7 @@ def text_of(plan: Path) -> str: - return plan.read_text(encoding="utf-8") + return plan.read_text(encoding=UTF8) def casings(word: str) -> list[str]: @@ -313,15 +329,88 @@ def test_a_value_is_asked_about_in_each_place_it_is_written( assert adopter.y("spec") == "spec" -# --- end to end, through `plan` and `check` -------------------------------- +# --- what `execute` writes into the project, asked of the same loader ------- + +#: How `manifest_misreads` says the loader read a null, spelled once. +NO_VALUE = "no value at all" +TRUE_READ = "the boolean true" +#: How `check` and `execute` begin the line about each one. +MISREAD_FINDING = "FINDING plan-manifest-misread: " +#: The plan keys the rows below name, and the flag `plan` takes each as. +SPEC_PATH_KEY = "legs.spec_path" +TRACKING_KEY = "tracking_branch" +PINS_KEY = "pins" +ELECTED_BY_KEY = "elected_by" +SPEC_FLAG = "--spec-path" +TRACKING_FLAG = "--tracking-branch" +PIN_FLAG = "--pin" +ELECTED_BY_FLAG = "--elected-by" +MANIFEST = "project.yaml" +#: The leg path and the branch the accepted end-to-end run is given. +LEGS_NULL = "legs/null" +Q4_BRANCH = "2026-q4" +#: A Windows `DOMAIN\user` name: between double quotes, the loader makes the +#: `\t` a tab (`DOUBLE_QUOTED_ESCAPES`). +DOMAIN_USER = "CORP\\tom" + +#: `(flag, value, plan key, what the project's loader reads)`. At 76a6153 +#: each of the first fifteen went through `execute` to `adoption verified` +#: and then to a bootstrap that failed, or that put the legs on another branch +#: than the one they were pushed on (lane 2's review, and this fix's own repro +#: of it); `2026`, `1.5` and `~` did so on `main` too (#187's review). `007` +#: is a branch bootstrap would read as `7`. The last two are values +#: `project.yaml` writes between quotes, which the loader still does not give +#: back: it makes the `\t` and the `\n` a tab and a line break. +REFUSED = [ + (SPEC_FLAG, "null", SPEC_PATH_KEY, NO_VALUE), + (SPEC_FLAG, "Null", SPEC_PATH_KEY, NO_VALUE), + (SPEC_FLAG, "NULL", SPEC_PATH_KEY, NO_VALUE), + (SPEC_FLAG, "~", SPEC_PATH_KEY, NO_VALUE), + (SPEC_FLAG, "true", SPEC_PATH_KEY, TRUE_READ), + (SPEC_FLAG, "False", SPEC_PATH_KEY, "the boolean false"), + (SPEC_FLAG, "2026", SPEC_PATH_KEY, "the number 2026"), + (SPEC_FLAG, "1.5", SPEC_PATH_KEY, "the number 1.5"), + ("--code-path", "null", "legs.code_path", NO_VALUE), + (TRACKING_FLAG, "true", TRACKING_KEY, TRUE_READ), + (TRACKING_FLAG, "null", TRACKING_KEY, NO_VALUE), + ("--id", "true", "id", TRUE_READ), + ("--id", "null", "id", NO_VALUE), + (PIN_FLAG, "null", PINS_KEY, NO_VALUE), + (PIN_FLAG, "True", PINS_KEY, TRUE_READ), + (TRACKING_FLAG, "007", TRACKING_KEY, "the number 7"), + (ELECTED_BY_FLAG, DOMAIN_USER, ELECTED_BY_KEY, "the text 'CORP\\tom'"), + ("--reference", "docs\\new.md", "reference", "the text 'docs\\new.md'"), +] +#: `(plan key, value, what the loader reads)` that only an edited plan can +#: carry here: `plan` takes `--visibility` from a fixed choice, and reads +#: `--elected-on 20260902` as a date only on Python 3.11 and later. +EDITED_ONLY = [ + ("elected_on", "20260902", "the number 20260902"), + ("visibility", "true", TRUE_READ), +] +#: REFUSED as `plan` is given it, and as a plan carries it. +PLANNED = [(flag, value, reading) for flag, value, _, reading in REFUSED] +CARRIED = [(key, value, reading) for _, value, key, reading in REFUSED] \ + + EDITED_ONLY + + +def ids_of(rows, key: int, value: int) -> list[str]: + return [f"{row[key]}={row[value]!r}" for row in rows] + def checked(plan: Path): return run_script(ADOPT, "check", "--plan", str(plan)) +def executed(plan: Path, tmp_path: Path): + return run_script(ADOPT, "execute", "--plan", str(plan), "--yes", + "--local-remote-dir", str(tmp_path / "remotes"), + "--work-dir", str(tmp_path / "work")) + + def planned(tmp_path: Path, *extra: str, tree: dict | None = None) -> Path: source = make_source_repo(tmp_path / "Thing", tree=tree) - plan = tmp_path / "plan.yaml" + plan = tmp_path / PLAN_FILE written = write_plan(source, plan, extra=extra) assert written.returncode == 0, written.stderr + written.stdout return plan @@ -336,94 +425,347 @@ def answer(plan: Path, path: str, leg: str) -> None: plan.write_text(text.replace( needle, f' - path: "{path}"\n leg: {leg}\n' ' resolution: "answered by the test"\n', 1), - encoding="utf-8") - - -def test_a_spec_path_of_null_is_the_string_null_and_check_mounts_it_there( - adopter, tmp_path): - """The repro of #181. On `main` the plan said `spec_path: null`, which - loaded as no value, so `execute` mounted the leg at `spec` while `check` - said so in the one sentence that names the mount. - - WHAT #169'S CANONICAL CHECK THEN DECIDES: `null` is one plain segment, - neither a name Git keeps (`.git`) nor a Windows device (`nul` is one, - `null` is not), so it is a path a leg can be mounted at and `check` - accepts it -- as a directory literally called `null`, which is what was - typed. Refusing it would be refusing a legal name for being spelled like a - YAML keyword, and rewriting it to `spec` is the silent change #169 refuses. - """ - plan = planned(tmp_path, "--spec-path", "null") - assert 'spec_path: "null"' in text_of(plan) - loaded = load_yaml(plan) - assert loaded["legs"]["spec_path"] == "null" - assert isinstance(loaded["legs"]["spec_path"], str) - for path, leg in ANSWERS: - resolve(plan, path, leg) - spec_path, code_path, _, _ = adopter._checked_plan_values( - adopter.Plan.load(plan)) - assert (spec_path, code_path) == ("null", "code"), ( - "what `execute` mounts the legs at") - result = checked(plan) - assert result.returncode == 0, result.stdout + result.stderr - assert "plan ok" in result.stdout - assert "mount the legs at null/ and code/" in result.stdout - assert "at spec/" not in result.stdout - - -def test_a_tracking_branch_of_true_is_the_string_true(adopter, tmp_path): - """`--tracking-branch true` was written `tracking_branch: true`, read back - as a boolean, and reached `execute` as `True`.""" - plan = planned(tmp_path, "--tracking-branch", "true") - assert 'tracking_branch: "true"' in text_of(plan) - loaded = load_yaml(plan) - assert loaded["tracking_branch"] == "true" - assert isinstance(loaded["tracking_branch"], str) + encoding=UTF8) + + +def misread(what: str, value: str, reading: str) -> tuple[str, str]: + """The two halves of the sentence `manifest_misreads` writes: who, and + what the loader made of it, with the fix.""" + return (f"{what} is {value!r}, which `execute` writes into ", + f"reads it back as {reading} and not as that text: choose " + "another value.") + + +@pytest.fixture(scope="module") +def answered(tmp_path_factory) -> tuple[Path, str]: + """One source and one answered plan of it, which `check` passes, for the + tests below to copy and edit. Nothing below writes into the source: a + refused `check` or `execute` never reaches it, and `plan` only reads it.""" + base = tmp_path_factory.mktemp("misread") + source = make_source_repo(base / "Thing") + plan = base / PLAN_FILE + written = write_plan(source, plan) + assert written.returncode == 0, written.stderr + written.stdout for path, leg in ANSWERS: resolve(plan, path, leg) - assert adopter._checked_plan_values(adopter.Plan.load(plan))[3] == "true" assert checked(plan).returncode == 0 + return source, text_of(plan) + + +def carrying(plan: Path, text: str, key: str, spelled: str) -> None: + """Write `text`, an answered plan, to `plan` with `key` set to `spelled` + exactly as given: a plan edited by hand, or written by a `plan` older + than this check. LF, as `plan` writes it.""" + if key == PINS_KEY: + edited = text.replace("\npins: []\n", f"\npins: [{spelled}]\n", 1) + else: + leaf = key.split(".")[-1] + indent = " " if key.startswith("legs.") else "" + line = re.compile(rf"^{indent}{leaf}: .*$", re.MULTILINE) + assert line.search(text), f"no `{key}` line in the plan" + edited = line.sub(lambda _match: f"{indent}{leaf}: {spelled}", text, + count=1) + assert edited != text, key + with plan.open("w", encoding=UTF8, newline="\n") as handle: + handle.write(edited) + + +@pytest.mark.parametrize("flag, value, reading", PLANNED, + ids=ids_of(PLANNED, 0, 1)) +def test_plan_refuses_a_value_the_projects_loader_would_misread( + answered, tmp_path, flag, value, reading): + """Before the plan is written: the flag, the value, what the project's + loader would read, and the fix.""" + source, _ = answered + plan = tmp_path / PLAN_FILE + result = write_plan(source, plan, extra=(flag, value)) + assert result.returncode == 2, result.stdout + result.stderr + assert result.stderr.startswith("REFUSED adopt-manifest-misread: ") + who, what = misread(flag, value, reading) + assert who in result.stderr and what in result.stderr, result.stderr + assert "No plan was written." in result.stderr + assert not plan.exists() + + +@pytest.mark.parametrize("project, value, reading", [ + ("True", "true", TRUE_READ), ("Null", "null", NO_VALUE)], + ids=["True", "Null"]) +def test_plan_names_an_id_it_derived_from_the_project( + answered, tmp_path, project, value, reading): + """`--project True` passes no `--id`, and `plan` lowercases the project + into one: the refusal says where the value came from, so the person + knows the flag that chooses another.""" + source, _ = answered + result = write_plan(source, tmp_path / PLAN_FILE, project=project) + assert result.returncode == 2, result.stdout + result.stderr + who, what = misread("--id (not given, so --project lowercased)", value, + reading) + assert who in result.stderr and what in result.stderr, result.stderr + + +def test_plan_names_an_elector_it_took_from_git(answered, tmp_path): + """No `--elected-by`: `plan` asks `git config user.name`, and a + `CORP\\tom` there is a name the manifest's quotes turn into a tab.""" + source, _ = answered + plan = tmp_path / PLAN_FILE + result = run_script( + ADOPT, "plan", "--source", str(source), "--project", "Northwind", + "--org", "testorg", "--elected-on", "2026-09-02", "--out", str(plan), + env={"GIT_CONFIG_COUNT": "1", "GIT_CONFIG_KEY_0": "user.name", + "GIT_CONFIG_VALUE_0": DOMAIN_USER}) + assert result.returncode == 2, result.stdout + result.stderr + who, what = misread("--elected-by (not given, so `git config " + "user.name`)", DOMAIN_USER, "the text 'CORP\\tom'") + assert who in result.stderr and what in result.stderr, result.stderr + assert not plan.exists() + + +@pytest.mark.parametrize("key, value, reading", CARRIED, + ids=ids_of(CARRIED, 0, 1)) +def test_check_reports_a_plan_carrying_one(adopter, answered, tmp_path, key, + value, reading): + """A plan carrying the value -- spelled by the plan writer, so the plan + itself reads it back -- is a FINDING, with the remediation once after.""" + _, text = answered + plan = tmp_path / PLAN_FILE + carrying(plan, text, key, adopter.y(value)) + result = checked(plan) + assert result.returncode == 1, result.stdout + result.stderr + who, what = misread(key, value, reading) + assert f"{MISREAD_FINDING}{who}" in result.stderr + assert what in result.stderr, result.stderr + assert result.stderr.count(adopter.MANIFEST_MISREAD_REMEDIATION) == 1 + assert "plan ok" not in result.stdout + + +def test_check_names_an_id_it_derived_from_the_assembly(adopter, answered, + tmp_path): + """A plan with no `id:` gets one from `execute`, which lowercases + `legs.assembly`: the finding says so, because the line to change is one + the plan does not have.""" + _, text = answered + without_id = re.sub(r"^id: .*\n", "", text, count=1, flags=re.MULTILINE) + assert without_id != text, "no `id:` line in the plan" + plan = tmp_path / PLAN_FILE + carrying(plan, without_id, "legs.assembly", adopter.y("True")) + result = checked(plan) + assert result.returncode == 1, result.stdout + result.stderr + who, what = misread("id (not in the plan, so legs.assembly lowercased)", + "true", TRUE_READ) + assert f"{MISREAD_FINDING}{who}" in result.stderr + assert what in result.stderr, result.stderr @needs_filter_repo -def test_execute_mounts_the_leg_at_null_and_tracks_the_branch_true(tmp_path): - """The whole chain, to the split branch. On `main` `execute` read - `spec_path: null` as no value and registered the mount at `spec`, and - read `tracking_branch: true` as a boolean; now the gitlink is at `null` - -- the directory the person typed, and the one `check` said it would use - -- and the branch the legs track is the one called `true`.""" +@pytest.mark.parametrize("key, value, reading", CARRIED, + ids=ids_of(CARRIED, 0, 1)) +def test_execute_refuses_a_plan_carrying_one_before_any_leg_exists( + adopter, answered, tmp_path, key, value, reading): + """The refused half of the end-to-end run: `execute` says no through + #168's one list of `check`'s findings, before it creates a leg or + touches the source.""" + source, text = answered + plan = tmp_path / PLAN_FILE + carrying(plan, text, key, adopter.y(value)) + result = executed(plan, tmp_path) + assert result.returncode == 2, result.stdout + result.stderr + assert result.stderr.startswith("REFUSED plan-manifest-misread: ") + who, what = misread(key, value, reading) + assert f"{MISREAD_FINDING}{who}" in result.stderr + assert what in result.stderr + assert adopter.MANIFEST_MISREAD_REMEDIATION in result.stderr + assert not (tmp_path / "remotes").exists() + assert SPLIT_BRANCH not in git("branch", "--list", cwd=source).stdout + + +#: What the gate still lets through, each spelled like a keyword somewhere: +#: a leg path with `null` as a segment, a branch the PLAN quotes for its +#: leading digit and `project.yaml` writes bare, an id that is a YAML 1.1 +#: boolean this loader reads as text, and a person whose name is a null +#: anywhere `project.yaml` does not quote it (`elected_by:` it does). +ACCEPTED = (SPEC_FLAG, LEGS_NULL, TRACKING_FLAG, Q4_BRANCH, "--id", "yes", + ELECTED_BY_FLAG, "Null") + + +@needs_filter_repo +def test_what_is_still_accepted_adopts_clones_and_bootstraps(tmp_path): + """The accepted half of the end-to-end run, to the step lane 2 found it + stopped short of: the recursive clone and the first bootstrap, which put + each leg on the branch `execute` pushed it on, with that branch as its + upstream, and read back every value the plan was given.""" source = make_source_repo(tmp_path / "Thing") - plan = tmp_path / "plan.yaml" - written = write_plan(source, plan, extra=("--spec-path", "null", - "--tracking-branch", "true")) + plan = tmp_path / PLAN_FILE + written = write_plan(source, plan, extra=ACCEPTED) assert written.returncode == 0, written.stderr + written.stdout + for line in ("id: yes", f'tracking_branch: "{Q4_BRANCH}"', + 'elected_by: "Null"', f" spec_path: {LEGS_NULL}"): + assert f"\n{line}\n" in text_of(plan), line for path, leg in ANSWERS: resolve(plan, path, leg) - remotes = tmp_path / "remotes" - result = run_script(ADOPT, "execute", "--plan", str(plan), "--yes", - "--local-remote-dir", str(remotes), - "--work-dir", str(tmp_path / "work")) + result = checked(plan) + assert result.returncode == 0, result.stdout + result.stderr + assert "mount the legs at legs/null/ and code/" in result.stdout + result = executed(plan, tmp_path) assert result.returncode == 0, result.stderr + result.stdout assert "adoption verified" in result.stdout - gitlinks = [line.split()[-1] for line in git( - "ls-tree", SPLIT_BRANCH, cwd=source).stdout.splitlines() - if line.startswith("160000")] - assert sorted(gitlinks) == ["code", "null"], ( - "no gitlink at `spec`: the leg is mounted where `check` said") - for leg_repository in sorted(remotes.glob("*.git")): - assert git("branch", "--list", cwd=leg_repository).stdout.split() == [ - "*", "true"], leg_repository.name - - -@pytest.mark.parametrize("pin", ["null", "Null", "NULL", "true", "False"]) -def test_a_pin_named_like_a_keyword_is_that_pin(adopter, tmp_path, pin): - """`pins: [` is the one list of strings the plan writes without `y`, and - `--pin null` was written `pins: [null]`: a null the plan's reader drops, - so the pin the person asked for vanished (and `--pin true` came back as - the pin `True`).""" - plan = planned(tmp_path, "--pin", pin) - assert f'\npins: ["{pin}"]\n' in text_of(plan) - assert load_yaml(plan)["pins"] == [pin] - assert adopter.Plan.load(plan).pins == [pin] + + clone = tmp_path / "clone" + cloned = git(*FILE_PROTOCOL, "clone", "-q", "--recurse-submodules", "-b", + SPLIT_BRANCH, str(source), str(clone), cwd=tmp_path, + check=False) + assert cloned.returncode == 0, cloned.stderr + bootstrapped = run_script(clone / "scripts/bootstrap.py", cwd=clone) + assert bootstrapped.returncode == 0, (bootstrapped.stderr + + bootstrapped.stdout) + assert "bootstrap ok" in bootstrapped.stdout + manifest = load_yaml(clone / MANIFEST) + assert (manifest["id"], manifest[TRACKING_KEY], + manifest[ELECTED_BY_KEY]) == ("yes", Q4_BRANCH, "Null") + assert [leg["path"] for leg in manifest["legs"]] == [".", LEGS_NULL, + "code"] + assert load_yaml(clone / "contracts/spec-pin.yaml")["submodule_path"] \ + == LEGS_NULL + for mount in (LEGS_NULL, "code"): + branches = [git("rev-parse", "--abbrev-ref", ref, + cwd=clone / mount).stdout.strip() + for ref in ("HEAD", "@{upstream}")] + assert branches == [Q4_BRANCH, f"origin/{Q4_BRANCH}"], mount + + +# --- the gate asks the loader, where the template writes the value ---------- + +def test_the_gate_follows_the_loader_when_the_loader_learns_a_keyword( + adopter, monkeypatch): + """ONE QUESTION, `loader_reading`, for the plan writer and the gate. Teach + the real reader that `yes` is a boolean and both change with no edit: + `y` quotes it, and a branch or an id called `yes` is refused.""" + reads = repo_shape._scalar + fields = [adopter._field(TRACKING_KEY, "TRACKING_BRANCH", "yes"), + adopter._field("id", "PROJECT_ID", "yes")] + assert adopter.manifest_misreads(fields) == [] + monkeypatch.setattr(repo_shape, "_scalar", + lambda raw: True if raw.strip() == "yes" + else reads(raw)) + assert adopter.y("yes") == QUOTED_YES + assert [problem.split(" is ")[0] for problem + in adopter.manifest_misreads(fields)] == [TRACKING_KEY, "id"] + + +def test_the_template_line_says_whether_a_value_is_quoted(adopter): + """No list in `adopt-project.py` says which placeholders the templates + quote: the line is read. `Null` is a person's name between the quotes of + `elected_by:`, and no branch at all after `tracking_branch:`.""" + assert adopter._written("ELECTED_BY", "Null") == [ + (MANIFEST, 'elected_by: "Null"')] + assert adopter.manifest_misreads( + [adopter._field(ELECTED_BY_KEY, "ELECTED_BY", "Null")]) == [] + assert adopter.manifest_misreads( + [adopter._field(TRACKING_KEY, "TRACKING_BRANCH", "Null")]) != [] + + +#: Where each placeholder lands in the RENDERED files, as their loader reads +#: them: `(file, path into what it loads)`. The test's own oracle, written +#: from the templates, never from `adopt-project.py`. +LANDS = { + "TRACKING_BRANCH": [(MANIFEST, (TRACKING_KEY,))], + "ELECTED_BY": [(MANIFEST, (ELECTED_BY_KEY,))], + "SPEC_PATH": [(MANIFEST, ("legs", 1, "path")), + ("contracts/spec-pin.yaml", ("submodule_path",))], + "NEUTRAL_PRODUCT_PINS": [(MANIFEST, ("neutral_product_pins", 0))], +} + + +def rendered_readings(adopter, placeholder: str, value: str) -> list: + """What the project's loader reads where `value` lands, in the three + templated YAML files rendered WHOLE by the materializer's own `render`, + every other placeholder filled with plain text.""" + from shape_materialize import PLACEHOLDER_RE, render + template = REPO / "templates" / "assembly-root" + # A naming block is lines of its own, and an empty one is no lines. + values = {"NEUTRAL_PRODUCT_PINS": "[]", "ASSEMBLY_NAMING": "", + "SPEC_NAMING": "", "CODE_NAMING": ""} + if placeholder == "NEUTRAL_PRODUCT_PINS": + values[placeholder] = "\n" + adopter._pin_item(value) + else: + values[placeholder] = value + readings = [] + for name, path in LANDS[placeholder]: + text = (template / name).read_text(encoding=UTF8) + filled = {key.strip("{}"): "filler" + for key in PLACEHOLDER_RE.findall(text)} + try: + loaded = parse_yaml(render(text, {**filled, **values}, name)) + for step in path: + loaded = loaded[step] + except (repo_shape.YamlError, LookupError, TypeError) as exc: + loaded = exc + readings.append(loaded) + return readings + + +@pytest.mark.parametrize("placeholder", sorted(LANDS)) +def test_the_gate_agrees_with_the_rendered_files_on_every_candidate( + adopter, placeholder): + """The gate asks one template line at a time. Rendered whole, as + `execute` renders them, the files must agree on every candidate this + module generates: a value the gate passes comes back out of each place + it lands, and one it refuses does not.""" + for value in CANDIDATES: + if placeholder == "NEUTRAL_PRODUCT_PINS": + field = adopter._pin_field(PINS_KEY, value) + else: + field = adopter._field("x", placeholder, value) + refused = bool(adopter.manifest_misreads([field])) + lands = rendered_readings(adopter, placeholder, value) + assert refused == any(got != value for got in lands), (value, lands) + + +# --- an explicit null leg path, the reviewer's question on 76a6153 ---------- + +@pytest.mark.parametrize("spelled", ["null", "~", ""], + ids=["null", "tilde", "nothing"]) +def test_an_explicit_null_leg_path_is_a_finding_not_the_default( + adopter, answered, tmp_path, spelled): + """A plan `main` wrote for `--spec-path null` says `spec_path: null`, + and `check` called it a mount at `spec` while that plan's own follow-ups + said `null/contracts`. A key that is there with no value is a value + nobody chose, as #169 rules an explicit empty one: a finding, with no + `None/` in the line that names the mounts.""" + _, text = answered + plan = tmp_path / PLAN_FILE + carrying(plan, text, SPEC_PATH_KEY, spelled) + result = checked(plan) + assert result.returncode == 1, result.stdout + result.stderr + assert ("FINDING plan-bad-leg-path: legs.spec_path is not text: its value " + "was read as no value at all, and a leg path is text: write the " + "path you mean, or remove the key to mount the leg at its role's " + "own name") in result.stderr + assert "mount the legs at" not in result.stdout + assert "None" not in result.stdout + result.stderr + loaded = adopter.Plan.load(plan) + with pytest.raises(adopter.Refusal) as refused: + adopter._checked_plan_values(loaded) + assert refused.value.code == "plan-bad-leg-path" + + +def test_the_plan_writer_quotes_a_pin_whatever_refuses_it_later( + adopter, answered, tmp_path, monkeypatch): + """`pins: [` was the one list of strings the plan wrote without `y`, so + `--pin null` was `pins: [null]`: a null `Plan.load` drops. `plan` now + refuses such a pin before writing, so the writer is asked with the + gate taken away: the pin is a string like any other in the plan.""" + source, _ = answered + monkeypatch.setattr(adopter, "manifest_misreads", lambda _fields: []) + for pin in ("null", "Null", "NULL", "true", "False"): + plan = tmp_path / f"{pin}.yaml" + assert adopter.main([ + "plan", "--source", str(source), "--project", "Northwind", + "--org", "testorg", ELECTED_BY_FLAG, "Test Human", + "--elected-on", "2026-09-02", PIN_FLAG, pin, + "--work-dir", str(tmp_path / "work"), "--out", str(plan)]) == 0 + assert f'\npins: ["{pin}"]\n' in text_of(plan) + assert load_yaml(plan)[PINS_KEY] == [pin] + assert adopter.Plan.load(plan).pins == [pin] @pytest.mark.parametrize("pins, written", [ @@ -435,7 +777,7 @@ def test_a_pin_named_like_a_keyword_is_that_pin(adopter, tmp_path, pin): def test_an_ordinary_pin_is_written_as_it_always_was(tmp_path, pins, written): """`openGlass` and `opensoft/openGlass` are the pins that exist; they stay bare, singly and together, and no pin is still `pins: []`.""" - extra = tuple(part for pin in pins for part in ("--pin", pin)) + extra = tuple(part for pin in pins for part in (PIN_FLAG, pin)) text = text_of(planned(tmp_path, *extra)) assert f"\n{written}\n" in text From 5aee63a559b208cbc5c2b1a9212483284aad8bf6 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:35:40 +0000 Subject: [PATCH 03/24] adopt-project.py: a `manifest-misread` sentence names each value the way it was given and the way it was read, so `id: null` is no longer called "not in the plan", `CORP\tom` no longer looks like its own misreading, and a plan's `tracking_branch: 007` is no longer echoed as `'7'` (#181) The NITs of review181 (PR-READY at a0ff15b). Nothing is refused that was accepted, or accepted that was refused: only the sentences change. N3. A plan with `id: null` (what `main`'s `plan --project True --id null` writes) was named "id (not in the plan, so legs.assembly lowercased)". It is in the plan and sets nothing, and `Plan.get` reads it exactly as a missing key, so the derived id is now "not set in the plan" (`DERIVED_ID`, built by the new `_id_field`). N1. `--elected-by 'CORP\tom'` was refused as "is 'CORP\\tom' ... reads it back as the text 'CORP\tom'": `repr` doubled the backslash the person typed and spelled the loader's tab as the `\t` they typed, so each looked like the other. A value is now shown between backticks as it is typed, and a text reading names what a terminal shows as something else: "is `CORP\tom` ... reads it back as the text `CORP`, then a tab, then `om`" (`_worded`). The helper is not named `_spelled`: that is #166's path spelling, which escapes backslashes on purpose and is unchanged. N2. A hand-edited `tracking_branch: 007` or `TRUE` was named by `str` of what the loader read (`'7'`, `'True'`), which the plan never said. A plan value that is not text is now named by what was read, with its kind, then the text `execute` writes: "tracking_branch was read out of the plan as the number 7, ... and not as the text `7`". The same holds for an `id: 007` (now asked as read, not as `Plan.project_id`) and for a pin, which is taken as read rather than from `Plan.pins` and named "a pin in pins": the list was not what was read as true. N5. The import comment says `_the_loader_made` uses `NOT_TEXT_KINDS` only for a list or a mapping; the module docstring says `null`, `true` and `2026` misread where a template writes them bare, which `elected_by:` does not. The section comment gives the true reason the placeholders stay unquoted: it is left to a follow-up, because the same templates render every scaffolded project and quoting them changes both tools. N6. `'elected_by'`, `'elected_on'`, `'reference'`, `'PROJECT_ID'` and `' and '` reached three uses in this file: they are constants now, read by `_template_values` and `_refuse_unconsented_seeding` too (same text). The test file's repeats are constants as well. Tests: 405 in tests/test_adopt_plan_yaml_quoting.py (was 390). Each change was reverted alone in a scratch clone and the whole file run: N3 fails 4, the value as `repr` 69, a text reading as `repr` 11, a plan value made text first 8, the id as `Plan.project_id` 1, the pins from `Plan.pins` 1, a pin named `pins` 5. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- adopt-project.py | 157 +++++++++++++++------ tests/test_adopt_plan_yaml_quoting.py | 192 ++++++++++++++++++++------ 2 files changed, 269 insertions(+), 80 deletions(-) diff --git a/adopt-project.py b/adopt-project.py index 85aad1bf..99caca0d 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -36,8 +36,9 @@ `.gitmodules`, registered there and never in the root, and every value `execute` writes into the project's YAML (`id`, the branch, the leg paths, the pins, the election record) one its - loader reads back as that same text, never `null`, `true` or - `2026` (#181). It prints what will happen and changes nothing. + loader reads back as that same text, which `null`, `true` and + `2026` are not where a template writes them bare (#181). It + prints what will happen and changes nothing. execute creates the two legs, extracts them with `git filter-repo`, makes the one split commit on a branch of the source, sets the `xf-project-` topic on all three (skipped for local remotes), @@ -83,6 +84,7 @@ import argparse import datetime as _dt +import itertools import re import shutil import subprocess @@ -110,7 +112,8 @@ # `tests/test_adopt_leg_paths.py` asks `leg_path_problem` through this module, # as it did when the rule was defined in it, which is what shows the move # changed no behaviour; `_plan_fields` asks it too, and `_the_loader_made` -# names what the loader read in `NOT_TEXT_KINDS`'s words (#181). +# names a list or a mapping the loader read in `NOT_TEXT_KINDS`'s words, the +# two kinds it has no words of its own for (#181). from shape_materialize import ( # noqa: E402 NOT_TEXT_KINDS, WINDOWS_ABSOLUTE_RE, leg_path_problem, leg_path_problems, refuse_bad_leg_paths as _refuse_bad_leg_paths, @@ -1249,9 +1252,10 @@ def _kept_collision_notes(args, entries: list, tree: list) -> list[str]: # THE FIX IS TO REFUSE. `plan` (before the plan is written), `check` (a # FINDING) and `execute` (through `_entry_findings`, before any leg exists) # ask the ONE question below and refuse a value the project's loader would -# read back as anything but that same text. Quoting the placeholders instead -# was considered and not done: the same templates render every scaffolded -# project as well as every adopted one. +# read back as anything but that same text. Quoting the placeholders is left +# to a follow-up rather than done here: the same templates render every +# scaffolded project, and `scaffold-project.py` writes these values bare +# with no such gate, so quoting them changes both tools at once. # # THE LOADER DECIDES, AND THE TEMPLATE SAYS WHERE. Each value is put into # every line of a templated YAML file that carries its placeholder, exactly @@ -1268,6 +1272,13 @@ def _kept_collision_notes(args, entries: list, tree: list) -> list[str]: TEMPLATED_YAML = tuple(rel for rel in TEMPLATED if rel.endswith(".yaml")) #: The manifest, which holds the one list `_template_values` writes itself. MANIFEST_YAML = "project.yaml" +#: The plan's keys for the election record, as `_template_values` reads them +#: out of the plan and as the tables below ask about them: one spelling. +ELECTED_BY_KEY = "elected_by" +ELECTED_ON_KEY = "elected_on" +REFERENCE_KEY = "reference" +#: The placeholder `execute` writes the project's id through. +ID_PLACEHOLDER = "PROJECT_ID" #: Each plan value `execute` writes into those files, by its key in the plan #: (and `plan`'s flag of the same name, dashed) and the `_template_values` @@ -1275,13 +1286,25 @@ def _kept_collision_notes(args, entries: list, tree: list) -> list[str]: #: because `execute` derives it when the plan has none; the leg paths are #: `legs.` keys; the pins are a list. MANIFEST_FIELDS = {"tracking_branch": "TRACKING_BRANCH", - "visibility": "VISIBILITY", "elected_by": "ELECTED_BY", - "elected_on": "ELECTED_ON", "reference": "REFERENCE"} + "visibility": "VISIBILITY", ELECTED_BY_KEY: "ELECTED_BY", + ELECTED_ON_KEY: "ELECTED_ON", REFERENCE_KEY: "REFERENCE"} LEG_PATH_PLACEHOLDERS = {"spec": "SPEC_PATH", "code": "CODE_PATH"} #: How `plan` fills a flag nobody passed, where the value it fills in can be #: one the loader misreads: a sentence about it names where it came from. FLAG_DEFAULTS = {"id": "--project lowercased", - "elected_by": "`git config user.name`"} + ELECTED_BY_KEY: "`git config user.name`"} +#: How a sentence names the id `execute` derives when the plan sets none: +#: when it has no `id:`, or an `id:` with no value (`null`, `~` or nothing, +#: as `main`'s `plan --id null` wrote it), which `Plan.get` reads alike. +DERIVED_ID = "id (not set in the plan, so legs.assembly lowercased)" +#: How a sentence names one of a plan's `pins:`: the key holds a list, and a +#: sentence about what one item was read as is about that item. +PIN_IN_PLAN = "a pin in pins" +#: What a sentence calls a character a terminal shows as something else, or +#: not at all, which `_worded` names instead of printing. +CHARACTER_NAMES = {"\t": "a tab", "\n": "a line break"} +#: How a sentence joins the last of the things it names, spelled once. +AND = " and " MANIFEST_MISREAD_REMEDIATION = ( "Remediation: choose another value for each one named. `execute` writes " @@ -1311,21 +1334,23 @@ def _written(placeholder: str, value: str) -> list[tuple[str, str]]: if token in line] -def _field(what: str, placeholder: str, value) -> tuple[str, str, list]: +def _field(what: str, placeholder: str, value) -> tuple[str, object, list]: """`(what, value, written)` for a value `_template_values` writes as - `str(value)`, which is how a plan's non-text `elected_on` reaches it.""" - text = str(value) - return what, text, _written(placeholder, text) + `str(value)`. `value` is kept as it was read: out of a plan it need not + be text (`tracking_branch: 007` is read as 7), and a sentence about it + says what was read, never `str` of it, which the plan does not say.""" + return what, value, _written(placeholder, str(value)) -def _pin_field(what: str, pin: str) -> tuple[str, str, list]: +def _pin_field(what: str, pin) -> tuple[str, object, list]: return what, pin, [(f"{MANIFEST_YAML}'s neutral_product_pins", - _pin_item(pin).strip())] + _pin_item(str(pin)).strip())] def _the_loader_made(reading) -> str: - """What `loader_reading` gave back, in words. The parsed value is shown, - because the sentence it ends also shows the text it was read from.""" + """What the loader made of a value, in words: a scalar as it was READ, + with its kind (`the number 7`, for a line or a plan that says `007`), + and a list or a mapping by its kind alone.""" if isinstance(reading, YamlError): return f"nothing: it refuses the line ({reading})" if reading is None: @@ -1335,10 +1360,49 @@ def _the_loader_made(reading) -> str: if isinstance(reading, (int, float)): return f"the number {reading!r}" if isinstance(reading, str): - return f"the text {reading!r}" + return f"the text {_worded(reading)}" return NOT_TEXT_KINDS.get(type(reading), "something that is not text") +def _character_name(char: str) -> str: + return CHARACTER_NAMES.get(char, f"the character U+{ord(char):04X}") + + +def _worded(text: str) -> str: + """`text` as a sentence shows it: between backticks as it is typed, with + each character a terminal shows as something else named outside them, + in order; `""` when it is empty. + + NOT `repr`, and not `_spelled`, which spells a path so that it reads one + way only: both show the tab the loader makes of `"CORP\\tom"` as `\\t`, + the two characters the person typed, and show what they typed with its + backslash doubled, so each looks like the other. Named, the tab is + plainly a tab. + """ + parts: list[str] = [] + for printable, run in itertools.groupby(text, str.isprintable): + if printable: + parts.append(f"`{''.join(run)}`") + else: + parts += map(_character_name, run) + return ", then ".join(parts) or '""' + + +def _named(what: str, value) -> tuple[str, str]: + """`(subject, text)` for a sentence about `value`: who and what it is, + and the text the loader does not read back. + + A value read out of a plan that is not text is named by what its loader + READ, with its kind: `str` of it is not what the plan says + (`tracking_branch: 007` is read as 7, and `TRUE` as true), so the text is + then the one `execute` writes, named as such. + """ + if isinstance(value, str): + return f"{what} is {_worded(value)}", "that text" + return (f"{what} was read out of the plan as {_the_loader_made(value)}", + f"the text {_worded(str(value))}") + + def _shown(line: str) -> str: """`line` as a sentence quotes it: in backticks, or as Python spells it when it holds a character a terminal shows as something else (a line @@ -1347,9 +1411,9 @@ def _shown(line: str) -> str: def manifest_misreads(fields) -> list[str]: - """A sentence for each `(what, value, written)` whose `value` the - project's loader does not read back out of every line in `written`; - `[]` when it does for all of them. + """A sentence for each `(what, value, written)` whose value, written as + `str(value)`, the project's loader does not read back out of every line + in `written`; `[]` when it does for all of them. `what` is how the caller names the value -- `--tracking-branch` to `plan`, `tracking_branch` read out of a plan -- so the sentence points at @@ -1357,15 +1421,17 @@ def manifest_misreads(fields) -> list[str]: """ problems = [] for what, value, written in fields: + text = str(value) wrong = [(rel, line, loader_reading(line)) for rel, line in written] - wrong = [row for row in wrong if row[2] != value] + wrong = [row for row in wrong if row[2] != text] if wrong: - where = " and ".join(f"into {rel} as {_shown(line)}" - for rel, line, _ in wrong) + where = AND.join(f"into {rel} as {_shown(line)}" + for rel, line, _ in wrong) + subject, expected = _named(what, value) problems.append( - f"{what} is {value!r}, which `execute` writes {where}, where " - "the project's own YAML loader reads it back as " - f"{_the_loader_made(wrong[0][2])} and not as that text: " + f"{subject}, which `execute` writes {where}, where the " + "project's own YAML loader reads it back as " + f"{_the_loader_made(wrong[0][2])} and not as {expected}: " "choose another value") return problems @@ -1379,7 +1445,7 @@ def flag(key: str) -> str: spelled += f" (not given, so {FLAG_DEFAULTS[key]})" return spelled - fields = [_field(flag("id"), "PROJECT_ID", args.id)] + fields = [_field(flag("id"), ID_PLACEHOLDER, args.id)] fields += [_field(flag(key), placeholder, getattr(args, key)) for key, placeholder in MANIFEST_FIELDS.items()] fields += [_field(f"--{role}-path", placeholder, @@ -1388,9 +1454,21 @@ def flag(key: str) -> str: return fields + [_pin_field("--pin", pin) for pin in pins] +def _id_field(plan: Plan) -> tuple: + """The id as `execute` writes it, named for where it comes from: the + plan's `id:` as its loader read it, or `legs.assembly` lowercased when + the plan sets none (`DERIVED_ID`). `Plan.project_id` raises the + `Refusal` of an id it refuses, which is reported on its own account.""" + project_id = plan.project_id + set_id = plan.get("id") + if set_id is None: + return _field(DERIVED_ID, ID_PLACEHOLDER, project_id) + return _field("id", ID_PLACEHOLDER, set_id) + + def _plan_fields(plan: Plan) -> list[tuple]: - """A plan's values as `_template_values` would write them, each named by - its key in the plan. + """A plan's values as its loader read them, each named by its key in the + plan, with the lines `_template_values` would write them into. A key the plan does not carry is left out: `execute` fills it with a default of this tool's own (`main`, `private`, today's date), and none of @@ -1401,9 +1479,7 @@ def _plan_fields(plan: Plan) -> list[tuple]: """ fields: list[tuple] = [] try: - what = ("id" if plan.get("id") is not None else - "id (not in the plan, so legs.assembly lowercased)") - fields.append(_field(what, "PROJECT_ID", plan.project_id)) + fields.append(_id_field(plan)) except Refusal: pass fields += [_field(key, placeholder, plan.get(key)) @@ -1413,7 +1489,10 @@ def _plan_fields(plan: Plan) -> list[tuple]: LEG_PATH_PLACEHOLDERS.values()): if isinstance(value, str) and leg_path_problem(what, value) is None: fields.append(_field(what, placeholder, value)) - return fields + [_pin_field("pins", pin) for pin in plan.pins] + # Each pin as the loader read it, which `Plan.pins` has already made text + # (`TRUE` would be named as `True`); the same items, in the same order. + return fields + [_pin_field(PIN_IN_PLAN, pin) + for pin in (plan.data.get("pins") or []) if pin] def _checked_plan_inputs(args, source: Source, @@ -2815,9 +2894,9 @@ def _refuse_unconsented_seeding(args, plan: Plan, seeded: list) -> None: raise Refusal( "adopt-empty-leg-unconsented", "no path is assigned to the " - + " and ".join(f"{role} leg" for role in unconsented) + + AND.join(f"{role} leg" for role in unconsented) + ", so it would be SEEDED from " - + " and ".join(f"{SEED_TEMPLATE[role]}/" for role in unconsented) + + AND.join(f"{SEED_TEMPLATE[role]}/" for role in unconsented) + " instead of extracted, and no `--allow-empty-leg` says that is " "intended", "Remediation: a repository that genuinely has no " @@ -3092,7 +3171,7 @@ def _template_values(plan: Plan, names, repositories, urls, spec_path, shape_commit, pins, naming) -> dict[str, str]: project = names["assembly"] project_id = plan.project_id - elected_on = str(plan.get("elected_on", _dt.date.today().isoformat())) + elected_on = str(plan.get(ELECTED_ON_KEY, _dt.date.today().isoformat())) # `.get(role, "")` because this table is built BEFORE the legs exist, so # that a SEEDED leg's template can be rendered from it. The four values # are filled in by the caller as soon as each leg has a commit, and the @@ -3109,9 +3188,9 @@ def _template_values(plan: Plan, names, repositories, urls, spec_path, # older than this rule — is resolved from ITS OWN `elected_on`, not # from the day `execute` happens to run. The plan carries the human's # act; the calendar of the machine running the split does not. - "REFERENCE": str(plan.get("reference") + "REFERENCE": str(plan.get(REFERENCE_KEY) or default_reference(elected_on)), - "ELECTED_BY": str(plan.get("elected_by", "")), + "ELECTED_BY": str(plan.get(ELECTED_BY_KEY, "")), "ELECTED_ON": elected_on, "TRACKING_BRANCH": tracking, "SPEC_PATH": spec_path, diff --git a/tests/test_adopt_plan_yaml_quoting.py b/tests/test_adopt_plan_yaml_quoting.py index a04d11f7..b604bb54 100644 --- a/tests/test_adopt_plan_yaml_quoting.py +++ b/tests/test_adopt_plan_yaml_quoting.py @@ -71,12 +71,20 @@ #: Spelled once each: the same word in a table, a loop and an assertion is one #: fact written three times, and `python:S1192` counts it. FALSE_WORD = "false" +FALSE_TITLE = "False" OPEN_GLASS = "openGlass" SPLIT_BRANCH = "adopt/three-repo-shape" QUOTED_YES = '"yes"' +QUOTED_TRUE = '"True"' PATHS = "paths" PLAN_FILE = "plan.yaml" UTF8 = "utf-8" +#: The project the plans below adopt, and the directory of its source. +PROJECT_NAME = "Northwind" +SOURCE_NAME = "Thing" +#: The reader's scalar function, which the tests that teach it a keyword +#: replace. +SCALAR_READER = "_scalar" #: Answered the way `test_adopt_plan.py` answers them, so `check` can pass. ANSWERS = (("examples/", "spec"), (".claude/", "root"), @@ -117,7 +125,7 @@ def casings(word: str) -> list[str]: NON_ASCII = ["é", "日本", "emoji \U0001F600", " x"] #: What the plan is mostly made of. These were written bare before and are #: written bare now. -ORDINARY = ["spec", "code", "legs/spec", "Northwind", "Northwind-spec", +ORDINARY = ["spec", "code", "legs/spec", PROJECT_NAME, "Northwind-spec", SPLIT_BRANCH, "main", OPEN_GLASS, "opensoft/openGlass", "xf-project-northwind", "README.md", ".gitignore", ".github/", "a:b", "a@b", "a+b", "nullable", "trueish", "nul", "Nonesuch", "falsey", "notnull"] @@ -132,7 +140,7 @@ def casings(word: str) -> list[str]: #: in `test_a_value_the_loader_reads_as_a_keyword_is_quoted`, whose point is #: that these exact strings are the ones that were wrong. READ_AS_A_KEYWORD = ["null", "Null", "NULL", "~", "", "true", "True", "TRUE", - FALSE_WORD, "False", "FALSE"] + FALSE_WORD, FALSE_TITLE, "FALSE"] SHAPES_OF_A_STRING = ["a value (`emit`)", "a list item", "an entry's path"] @@ -288,7 +296,7 @@ def learned(raw): return True if raw.strip() in ("yes", "on") else reads(raw) assert adopter.y("yes") == "yes" and adopter.y("on") == "on" - monkeypatch.setattr(repo_shape, "_scalar", learned) + monkeypatch.setattr(repo_shape, SCALAR_READER, learned) assert adopter.y("yes") == QUOTED_YES assert adopter.y("on") == '"on"' assert adopter.y("spec") == "spec" @@ -308,7 +316,7 @@ def refusing(raw): raise repo_shape.YamlError("a word this reader does not take") return reads(raw) - monkeypatch.setattr(repo_shape, "_scalar", refusing) + monkeypatch.setattr(repo_shape, SCALAR_READER, refusing) assert adopter.y("yes") == QUOTED_YES assert adopter.y("spec") == "spec" @@ -334,6 +342,9 @@ def test_a_value_is_asked_about_in_each_place_it_is_written( #: How `manifest_misreads` says the loader read a null, spelled once. NO_VALUE = "no value at all" TRUE_READ = "the boolean true" +SEVEN_READ = "the number 7" +#: An election date in the basic ISO form, which the loader reads as a number. +DATE_DIGITS = "20260902" #: How `check` and `execute` begin the line about each one. MISREAD_FINDING = "FINDING plan-manifest-misread: " #: The plan keys the rows below name, and the flag `plan` takes each as. @@ -346,12 +357,20 @@ def test_a_value_is_asked_about_in_each_place_it_is_written( PIN_FLAG = "--pin" ELECTED_BY_FLAG = "--elected-by" MANIFEST = "project.yaml" +#: How a sentence names a plan key, where that is not the key itself: one of +#: `pins:` is an item of a list, and what it was read as is the item's. +NAMED = {PINS_KEY: "a pin in pins"} +#: The `_template_values` placeholders the tests below fill. +TRACKING_PLACEHOLDER = "TRACKING_BRANCH" +ELECTED_BY_PLACEHOLDER = "ELECTED_BY" +PINS_PLACEHOLDER = "NEUTRAL_PRODUCT_PINS" #: The leg path and the branch the accepted end-to-end run is given. LEGS_NULL = "legs/null" Q4_BRANCH = "2026-q4" #: A Windows `DOMAIN\user` name: between double quotes, the loader makes the -#: `\t` a tab (`DOUBLE_QUOTED_ESCAPES`). +#: `\t` a tab (`DOUBLE_QUOTED_ESCAPES`), which the sentence names as one. DOMAIN_USER = "CORP\\tom" +DOMAIN_USER_READ = "the text `CORP`, then a tab, then `om`" #: `(flag, value, plan key, what the project's loader reads)`. At 76a6153 #: each of the first fifteen went through `execute` to `adoption verified` @@ -367,7 +386,7 @@ def test_a_value_is_asked_about_in_each_place_it_is_written( (SPEC_FLAG, "NULL", SPEC_PATH_KEY, NO_VALUE), (SPEC_FLAG, "~", SPEC_PATH_KEY, NO_VALUE), (SPEC_FLAG, "true", SPEC_PATH_KEY, TRUE_READ), - (SPEC_FLAG, "False", SPEC_PATH_KEY, "the boolean false"), + (SPEC_FLAG, FALSE_TITLE, SPEC_PATH_KEY, "the boolean false"), (SPEC_FLAG, "2026", SPEC_PATH_KEY, "the number 2026"), (SPEC_FLAG, "1.5", SPEC_PATH_KEY, "the number 1.5"), ("--code-path", "null", "legs.code_path", NO_VALUE), @@ -377,15 +396,16 @@ def test_a_value_is_asked_about_in_each_place_it_is_written( ("--id", "null", "id", NO_VALUE), (PIN_FLAG, "null", PINS_KEY, NO_VALUE), (PIN_FLAG, "True", PINS_KEY, TRUE_READ), - (TRACKING_FLAG, "007", TRACKING_KEY, "the number 7"), - (ELECTED_BY_FLAG, DOMAIN_USER, ELECTED_BY_KEY, "the text 'CORP\\tom'"), - ("--reference", "docs\\new.md", "reference", "the text 'docs\\new.md'"), + (TRACKING_FLAG, "007", TRACKING_KEY, SEVEN_READ), + (ELECTED_BY_FLAG, DOMAIN_USER, ELECTED_BY_KEY, DOMAIN_USER_READ), + ("--reference", "docs\\new.md", "reference", + "the text `docs`, then a line break, then `ew.md`"), ] #: `(plan key, value, what the loader reads)` that only an edited plan can #: carry here: `plan` takes `--visibility` from a fixed choice, and reads #: `--elected-on 20260902` as a date only on Python 3.11 and later. EDITED_ONLY = [ - ("elected_on", "20260902", "the number 20260902"), + ("elected_on", DATE_DIGITS, f"the number {DATE_DIGITS}"), ("visibility", "true", TRUE_READ), ] #: REFUSED as `plan` is given it, and as a plan carries it. @@ -409,7 +429,7 @@ def executed(plan: Path, tmp_path: Path): def planned(tmp_path: Path, *extra: str, tree: dict | None = None) -> Path: - source = make_source_repo(tmp_path / "Thing", tree=tree) + source = make_source_repo(tmp_path / SOURCE_NAME, tree=tree) plan = tmp_path / PLAN_FILE written = write_plan(source, plan, extra=extra) assert written.returncode == 0, written.stderr + written.stdout @@ -430,8 +450,12 @@ def answer(plan: Path, path: str, leg: str) -> None: def misread(what: str, value: str, reading: str) -> tuple[str, str]: """The two halves of the sentence `manifest_misreads` writes: who, and - what the loader made of it, with the fix.""" - return (f"{what} is {value!r}, which `execute` writes into ", + what the loader made of it, with the fix. The value is shown as it is + typed, between backticks, never as `repr` spells it: `CORP\\tom` is not + `'CORP\\\\tom'` (review181's N1). Every value here is printable, which + is what lets this oracle be the backticks alone.""" + assert value.isprintable(), value + return (f"{what} is `{value}`, which `execute` writes into ", f"reads it back as {reading} and not as that text: choose " "another value.") @@ -442,7 +466,7 @@ def answered(tmp_path_factory) -> tuple[Path, str]: tests below to copy and edit. Nothing below writes into the source: a refused `check` or `execute` never reaches it, and `plan` only reads it.""" base = tmp_path_factory.mktemp("misread") - source = make_source_repo(base / "Thing") + source = make_source_repo(base / SOURCE_NAME) plan = base / PLAN_FILE written = write_plan(source, plan) assert written.returncode == 0, written.stderr + written.stdout @@ -509,13 +533,13 @@ def test_plan_names_an_elector_it_took_from_git(answered, tmp_path): source, _ = answered plan = tmp_path / PLAN_FILE result = run_script( - ADOPT, "plan", "--source", str(source), "--project", "Northwind", + ADOPT, "plan", "--source", str(source), "--project", PROJECT_NAME, "--org", "testorg", "--elected-on", "2026-09-02", "--out", str(plan), env={"GIT_CONFIG_COUNT": "1", "GIT_CONFIG_KEY_0": "user.name", "GIT_CONFIG_VALUE_0": DOMAIN_USER}) assert result.returncode == 2, result.stdout + result.stderr who, what = misread("--elected-by (not given, so `git config " - "user.name`)", DOMAIN_USER, "the text 'CORP\\tom'") + "user.name`)", DOMAIN_USER, DOMAIN_USER_READ) assert who in result.stderr and what in result.stderr, result.stderr assert not plan.exists() @@ -531,31 +555,117 @@ def test_check_reports_a_plan_carrying_one(adopter, answered, tmp_path, key, carrying(plan, text, key, adopter.y(value)) result = checked(plan) assert result.returncode == 1, result.stdout + result.stderr - who, what = misread(key, value, reading) + who, what = misread(NAMED.get(key, key), value, reading) assert f"{MISREAD_FINDING}{who}" in result.stderr assert what in result.stderr, result.stderr assert result.stderr.count(adopter.MANIFEST_MISREAD_REMEDIATION) == 1 assert "plan ok" not in result.stdout -def test_check_names_an_id_it_derived_from_the_assembly(adopter, answered, - tmp_path): - """A plan with no `id:` gets one from `execute`, which lowercases - `legs.assembly`: the finding says so, because the line to change is one - the plan does not have.""" +#: How a plan sets no id, each beside how it spells the assembly the id is +#: then derived from. The first pair is what `main`'s +#: `plan --project True --id null` writes: `id: null`, and a bare `True` +#: its loader reads as a boolean. The rest are this branch's quoted `True` +#: with `~`, nothing, and no `id:` line at all. +NO_ID = [ + pytest.param("id: null\n", "True", id="main-plan-project-True-id-null"), + pytest.param("id: ~\n", QUOTED_TRUE, id="tilde"), + pytest.param("id:\n", QUOTED_TRUE, id="nothing"), + pytest.param("", QUOTED_TRUE, id="no-id-line"), +] + + +@pytest.mark.parametrize("id_line, assembly", NO_ID) +def test_check_names_an_id_it_derived_from_the_assembly( + answered, tmp_path, id_line, assembly): + """A plan that sets no id gets one from `execute`, which lowercases + `legs.assembly`: the finding says so, because the line to change is not + the one with the value. It says "not set in the plan" and not "not in + the plan": an `id: null` is in the plan and sets nothing (review181's + N3), and `Plan.get` reads it exactly as a missing key.""" _, text = answered - without_id = re.sub(r"^id: .*\n", "", text, count=1, flags=re.MULTILINE) + without_id = re.sub(r"^id: .*\n", id_line, text, count=1, + flags=re.MULTILINE) assert without_id != text, "no `id:` line in the plan" plan = tmp_path / PLAN_FILE - carrying(plan, without_id, "legs.assembly", adopter.y("True")) + carrying(plan, without_id, "legs.assembly", assembly) result = checked(plan) assert result.returncode == 1, result.stdout + result.stderr - who, what = misread("id (not in the plan, so legs.assembly lowercased)", - "true", TRUE_READ) + who, what = misread("id (not set in the plan, so legs.assembly " + "lowercased)", "true", TRUE_READ) assert f"{MISREAD_FINDING}{who}" in result.stderr assert what in result.stderr, result.stderr +#: `(plan key, as a hand-edited plan spells it, what the plan's loader reads +#: it as, the text `execute` writes for it)`. The loader reads each one as +#: something that is not text, so the plan does not say what `str` of it +#: says: `007` is read as 7, and `TRUE` as true. The sentence names what was +#: READ, with its kind, and then the text `execute` writes (review181's N2). +#: The project's loader reads that text back as the same kind again. +READ_AS_NOT_TEXT = [ + (TRACKING_KEY, "007", SEVEN_READ, "7"), + (TRACKING_KEY, "TRUE", TRUE_READ, "True"), + (TRACKING_KEY, "1.50", "the number 1.5", "1.5"), + (TRACKING_KEY, "[a]", "a list", "['a']"), + ("visibility", "{a: b}", "a mapping", "{'a': 'b'}"), + ("id", "007", SEVEN_READ, "7"), + (PINS_KEY, "TRUE", TRUE_READ, "True"), + ("elected_on", DATE_DIGITS, f"the number {DATE_DIGITS}", DATE_DIGITS), +] + + +@pytest.mark.parametrize("key, spelled, read_as, written", READ_AS_NOT_TEXT, + ids=ids_of(READ_AS_NOT_TEXT, 0, 1)) +def test_check_names_a_plan_value_that_is_not_text_by_what_was_read( + answered, tmp_path, key, spelled, read_as, written): + """The finding said `tracking_branch is '7'` for a plan that says `007`, + and `'True'` for `TRUE`: `str` of what was read, which the plan never + said. It says what the plan's loader read, and the text `execute` + writes for it, which is `str` of that and is named as such.""" + _, text = answered + plan = tmp_path / PLAN_FILE + carrying(plan, text, key, spelled) + result = checked(plan) + assert result.returncode == 1, result.stdout + result.stderr + named = NAMED.get(key, key) + assert (f"{MISREAD_FINDING}{named} was read out of the plan as {read_as}, " + "which `execute` writes into ") in result.stderr, result.stderr + assert (f"reads it back as {read_as} and not as the text `{written}`: " + "choose another value.") in result.stderr, result.stderr + assert f"{named} is " not in result.stderr, "`str` of it was echoed" + + +#: `(placeholder, value, the manifest line `execute` writes, what the loader +#: reads it back as)`: values the loader reads back as OTHER TEXT, through +#: the double quotes the template writes around them. +UNESCAPED = [ + (ELECTED_BY_PLACEHOLDER, DOMAIN_USER, 'elected_by: "CORP\\tom"', + DOMAIN_USER_READ), + ("REFERENCE", "docs\\new.md", 'reference: "docs\\new.md"', + "the text `docs`, then a line break, then `ew.md`"), + (ELECTED_BY_PLACEHOLDER, "a\\\\b", 'elected_by: "a\\\\b"', + "the text `a\\b`"), + (ELECTED_BY_PLACEHOLDER, 'a\\"b', 'elected_by: "a\\"b"', + 'the text `a"b`'), +] + + +@pytest.mark.parametrize("placeholder, value, line, reading", UNESCAPED, + ids=ids_of(UNESCAPED, 0, 1)) +def test_text_the_loader_reads_as_other_text_is_shown_as_typed_and_read( + adopter, placeholder, value, line, reading): + """`repr` showed `CORP\\tom` with its backslash doubled, and the tab the + loader makes of it as `\\t`, so each looked like the other (review181's + N1). The value is shown as it is typed, and a character a terminal shows + as something else is named, so the difference is the one there is.""" + assert adopter.manifest_misreads( + [adopter._field("x", placeholder, value)]) == [ + f"x is `{value}`, which `execute` writes into {MANIFEST} as " + f"`{line}`, where the project's own YAML loader reads it back as " + f"{reading} and not as that text: choose another value"] + + @needs_filter_repo @pytest.mark.parametrize("key, value, reading", CARRIED, ids=ids_of(CARRIED, 0, 1)) @@ -570,7 +680,7 @@ def test_execute_refuses_a_plan_carrying_one_before_any_leg_exists( result = executed(plan, tmp_path) assert result.returncode == 2, result.stdout + result.stderr assert result.stderr.startswith("REFUSED plan-manifest-misread: ") - who, what = misread(key, value, reading) + who, what = misread(NAMED.get(key, key), value, reading) assert f"{MISREAD_FINDING}{who}" in result.stderr assert what in result.stderr assert adopter.MANIFEST_MISREAD_REMEDIATION in result.stderr @@ -593,7 +703,7 @@ def test_what_is_still_accepted_adopts_clones_and_bootstraps(tmp_path): stopped short of: the recursive clone and the first bootstrap, which put each leg on the branch `execute` pushed it on, with that branch as its upstream, and read back every value the plan was given.""" - source = make_source_repo(tmp_path / "Thing") + source = make_source_repo(tmp_path / SOURCE_NAME) plan = tmp_path / PLAN_FILE written = write_plan(source, plan, extra=ACCEPTED) assert written.returncode == 0, written.stderr + written.stdout @@ -640,10 +750,10 @@ def test_the_gate_follows_the_loader_when_the_loader_learns_a_keyword( the real reader that `yes` is a boolean and both change with no edit: `y` quotes it, and a branch or an id called `yes` is refused.""" reads = repo_shape._scalar - fields = [adopter._field(TRACKING_KEY, "TRACKING_BRANCH", "yes"), + fields = [adopter._field(TRACKING_KEY, TRACKING_PLACEHOLDER, "yes"), adopter._field("id", "PROJECT_ID", "yes")] assert adopter.manifest_misreads(fields) == [] - monkeypatch.setattr(repo_shape, "_scalar", + monkeypatch.setattr(repo_shape, SCALAR_READER, lambda raw: True if raw.strip() == "yes" else reads(raw)) assert adopter.y("yes") == QUOTED_YES @@ -655,23 +765,23 @@ def test_the_template_line_says_whether_a_value_is_quoted(adopter): """No list in `adopt-project.py` says which placeholders the templates quote: the line is read. `Null` is a person's name between the quotes of `elected_by:`, and no branch at all after `tracking_branch:`.""" - assert adopter._written("ELECTED_BY", "Null") == [ + assert adopter._written(ELECTED_BY_PLACEHOLDER, "Null") == [ (MANIFEST, 'elected_by: "Null"')] assert adopter.manifest_misreads( - [adopter._field(ELECTED_BY_KEY, "ELECTED_BY", "Null")]) == [] + [adopter._field(ELECTED_BY_KEY, ELECTED_BY_PLACEHOLDER, "Null")]) == [] assert adopter.manifest_misreads( - [adopter._field(TRACKING_KEY, "TRACKING_BRANCH", "Null")]) != [] + [adopter._field(TRACKING_KEY, TRACKING_PLACEHOLDER, "Null")]) != [] #: Where each placeholder lands in the RENDERED files, as their loader reads #: them: `(file, path into what it loads)`. The test's own oracle, written #: from the templates, never from `adopt-project.py`. LANDS = { - "TRACKING_BRANCH": [(MANIFEST, (TRACKING_KEY,))], - "ELECTED_BY": [(MANIFEST, (ELECTED_BY_KEY,))], + TRACKING_PLACEHOLDER: [(MANIFEST, (TRACKING_KEY,))], + ELECTED_BY_PLACEHOLDER: [(MANIFEST, (ELECTED_BY_KEY,))], "SPEC_PATH": [(MANIFEST, ("legs", 1, "path")), ("contracts/spec-pin.yaml", ("submodule_path",))], - "NEUTRAL_PRODUCT_PINS": [(MANIFEST, ("neutral_product_pins", 0))], + PINS_PLACEHOLDER: [(MANIFEST, ("neutral_product_pins", 0))], } @@ -682,9 +792,9 @@ def rendered_readings(adopter, placeholder: str, value: str) -> list: from shape_materialize import PLACEHOLDER_RE, render template = REPO / "templates" / "assembly-root" # A naming block is lines of its own, and an empty one is no lines. - values = {"NEUTRAL_PRODUCT_PINS": "[]", "ASSEMBLY_NAMING": "", + values = {PINS_PLACEHOLDER: "[]", "ASSEMBLY_NAMING": "", "SPEC_NAMING": "", "CODE_NAMING": ""} - if placeholder == "NEUTRAL_PRODUCT_PINS": + if placeholder == PINS_PLACEHOLDER: values[placeholder] = "\n" + adopter._pin_item(value) else: values[placeholder] = value @@ -711,7 +821,7 @@ def test_the_gate_agrees_with_the_rendered_files_on_every_candidate( module generates: a value the gate passes comes back out of each place it lands, and one it refuses does not.""" for value in CANDIDATES: - if placeholder == "NEUTRAL_PRODUCT_PINS": + if placeholder == PINS_PLACEHOLDER: field = adopter._pin_field(PINS_KEY, value) else: field = adopter._field("x", placeholder, value) @@ -756,10 +866,10 @@ def test_the_plan_writer_quotes_a_pin_whatever_refuses_it_later( gate taken away: the pin is a string like any other in the plan.""" source, _ = answered monkeypatch.setattr(adopter, "manifest_misreads", lambda _fields: []) - for pin in ("null", "Null", "NULL", "true", "False"): + for pin in ("null", "Null", "NULL", "true", FALSE_TITLE): plan = tmp_path / f"{pin}.yaml" assert adopter.main([ - "plan", "--source", str(source), "--project", "Northwind", + "plan", "--source", str(source), "--project", PROJECT_NAME, "--org", "testorg", ELECTED_BY_FLAG, "Test Human", "--elected-on", "2026-09-02", PIN_FLAG, pin, "--work-dir", str(tmp_path / "work"), "--out", str(plan)]) == 0 From 34d683f51789e85210c3a81d32d3e2260b8c7fa0 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:44:05 +0000 Subject: [PATCH 04/24] adopt-project.py: this branch's `_shown(line)` is `_line_shown` now, so after the merge of #199 it no longer replaces #199's `_shown(path)` for the plan report, and the comments say what the merged writer and reader do (#181) The merge of main 4835fb4 had no textual conflict and three semantic ones, named in its message; this commit resolves them. - `_shown` was defined twice. #199's `_shown(path)` prints a name in `plan`'s and `check`'s report, raw unless it is not printable; this branch's `_shown(line)` quotes a template line in a misread sentence, between backticks. The later definition, this branch's, won for all four of #199's callers: at the merge commit 6b104cf five tests of tests/test_adopt_plan_escapes.py fail (the four names `plan` then `check`, and the source directory that is not UTF-8). The misread helper is `_line_shown` now; no top-level name is defined twice. - #199's comment above `y` said a bare `null`, `true` or `false` is written unquoted, "#181's to fix". `y` quotes them, asking the reader; the comment says so. - tests/test_adopt_plan_yaml_quoting.py's docstring said the loader resolves `\n` and `\t` only, and that `trail\n` splits over two lines. Since #199 the reader resolves `\r` and the code-point escapes too, and `y` writes `"trail\n"` on one line; the docstring says so. The gate needed no change to follow #199's reader: it asks the loader, so `--elected-by 'CORP\ralph'` and `--reference 'docs\x41.md'` are now refused, with the carriage return named (`CHARACTER_NAMES` gains it) and `docsA.md` shown as read. Both are test rows. `_print_what_will_happen` held two locals named `where`, #199's source and this branch's mount line; the second is `mounted`. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- adopt-project.py | 31 +++++++++++++----------- tests/test_adopt_plan_yaml_quoting.py | 35 ++++++++++++++++----------- 2 files changed, 38 insertions(+), 28 deletions(-) diff --git a/adopt-project.py b/adopt-project.py index cc03cc27..46d017fa 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -228,13 +228,14 @@ def _checked_pin_name(raw: str) -> str: # reader gives back unchanged, and `check` can name it (#199); # `tests/test_adopt_plan_escapes.py` proves that round trip for every code # point to U+02FF and a sample above it, and for a seeded sample of strings -# of them, not only for the strings a plan happens to hold; not for a bare -# YAML keyword (`null`, `true`, `false`), which `y` writes unquoted and the -# reader takes as None or a bool, #181's to fix. Everything it emits is -# read back by `check`, and `tests/test_adopt_plan.py` round-trips a plan -# through both, which is what keeps the writer and the reader honest about -# each other. A string is written plain only where that reader gives the same -# string back, and `y` asks the reader rather than a list of words (#181). +# of them, not only for the strings a plan happens to hold. A string is +# written plain only where that reader gives the same string back, and `y` +# asks the reader rather than a list of words, so a bare YAML keyword the +# reader takes as None or a bool (`null`, `True`) is quoted as well (#181); +# `tests/test_adopt_plan_yaml_quoting.py` proves that through the reader +# too. Everything it emits is read back by `check`, and +# `tests/test_adopt_plan.py` round-trips a plan through both, which is what +# keeps the writer and the reader honest about each other. #: Ends at `\Z`, not `$`: `$` also matches before a FINAL newline, and wrote a #: file named `Icon\n` bare, over two lines of the plan (#199). @@ -1331,7 +1332,8 @@ def _kept_collision_notes(args, entries: list, tree: list) -> list[str]: PIN_IN_PLAN = "a pin in pins" #: What a sentence calls a character a terminal shows as something else, or #: not at all, which `_worded` names instead of printing. -CHARACTER_NAMES = {"\t": "a tab", "\n": "a line break"} +CHARACTER_NAMES = {"\t": "a tab", "\n": "a line break", + "\r": "a carriage return"} #: How a sentence joins the last of the things it names, spelled once. AND = " and " @@ -1432,10 +1434,11 @@ def _named(what: str, value) -> tuple[str, str]: f"the text {_worded(str(value))}") -def _shown(line: str) -> str: +def _line_shown(line: str) -> str: """`line` as a sentence quotes it: in backticks, or as Python spells it when it holds a character a terminal shows as something else (a line - break, a tab), so that one FINDING stays one line.""" + break, a tab), so that one FINDING stays one line. Not `_shown`, which + is #199's spelling of a PATH in the plan report, with no backticks.""" return f"`{line}`" if line.isprintable() else repr(line) @@ -1454,7 +1457,7 @@ def manifest_misreads(fields) -> list[str]: wrong = [(rel, line, loader_reading(line)) for rel, line in written] wrong = [row for row in wrong if row[2] != text] if wrong: - where = AND.join(f"into {rel} as {_shown(line)}" + where = AND.join(f"into {rel} as {_line_shown(line)}" for rel, line, _ in wrong) subject, expected = _named(what, value) problems.append( @@ -2309,11 +2312,11 @@ def _print_what_will_happen(plan: Plan, source: Source, names: dict, # A leg path that is not text is a FINDING of its own, and its Python # spelling (`None/`, `True/`) names no directory a leg would be mounted at. mounts = [_plan_leg_path(plan, role) for role in EXTRACTED_LEGS] - where = (f" at {mounts[0]}/ and {mounts[1]}/" - if all(isinstance(mount, str) for mount in mounts) else "") + mounted = (f" at {mounts[0]}/ and {mounts[1]}/" + if all(isinstance(mount, str) for mount in mounts) else "") print(f" ONE split commit on branch {plan.get('adopt_branch', ADOPT_BRANCH)} " f"of {names['assembly']}: `git rm -r` those paths, mount the " - f"legs{where}") + f"legs{mounted}") print(f" {len(stays)} path(s) stay in the assembly root; " f"{len(drops)} dropped") print(" the source is never deleted, never renamed, never force-pushed") diff --git a/tests/test_adopt_plan_yaml_quoting.py b/tests/test_adopt_plan_yaml_quoting.py index b604bb54..a87d8041 100644 --- a/tests/test_adopt_plan_yaml_quoting.py +++ b/tests/test_adopt_plan_yaml_quoting.py @@ -17,19 +17,19 @@ word the loader does NOT read today, to show the writer follows the reader when the reader changes. -A LINE BREAK IS NOT IN THE GENERATED ALPHABET, on purpose. The reader takes a -plan line by line (`str.splitlines`, which also splits on `\\r`, `\\x0b`, -`\\x0c`, `\\x1c`-`\\x1e`, `\\x85`, `\\u2028` and `\\u2029`), a double-quoted scalar -cannot span lines, and the loader resolves an escape for `\\n` and `\\t` only. -A value carrying one is therefore not a question of quoting, #181 does not -make one read back, and putting it in a generator would assert a rule this -change does not make (#199 is the issue for it). The one visible difference: -a name whose ONLY line break is a final one matched `_PLAIN_RE`, because `$` -matches before it, and was written bare. On `main` the plan then read the -entry back as the name WITHOUT its line break (`trail` for `trail\\n`), and -`check` reported the file uncovered (`plan-uncovered`) and the entry empty -(`plan-empty-entry`). It is quoted now, which splits the entry over two lines, -and `check` refuses the plan as `yaml-unparsable`. Neither reads it back. +A LINE BREAK IS NOT IN THE GENERATED ALPHABET, because it is #199's and not +this change's. The reader takes a plan line by line (`str.splitlines`, which +also splits on `\\r`, `\\x0b`, `\\x0c`, `\\x1c`-`\\x1e`, `\\x85`, `\\u2028` and +`\\u2029`), so `y` quotes such a name with `repo_shape.double_quoted`, which +spells every character `str.isprintable` refuses from the escape table the +reader resolves, and the name is one line that reads back; +`tests/test_adopt_plan_escapes.py` proves that for every code point to +U+02FF and more. Before #199 a name whose ONLY line break was a final one +matched `_PLAIN_RE`, because `$` matches before it, and was written bare: +the plan read the entry back as the name WITHOUT its line break (`trail` for +`trail\\n`), and `check` reported the file uncovered (`plan-uncovered`) and +the entry empty (`plan-empty-entry`). `_PLAIN_RE` ends at `\\Z` now, and the +name is written `"trail\\n"`, on one line. WHAT `execute` WRITES IS ASKED ABOUT TOO (lane 2's review of 76a6153). Once the plan said `null` and meant it, `execute` put it into `project.yaml` and @@ -638,7 +638,10 @@ def test_check_names_a_plan_value_that_is_not_text_by_what_was_read( #: `(placeholder, value, the manifest line `execute` writes, what the loader #: reads it back as)`: values the loader reads back as OTHER TEXT, through -#: the double quotes the template writes around them. +#: the double quotes the template writes around them. The last two are +#: escapes the loader resolves since #199 (`\\r`, and a code point's +#: `\\xNN`), which the gate follows with no edit of its own: it asks the +#: loader. UNESCAPED = [ (ELECTED_BY_PLACEHOLDER, DOMAIN_USER, 'elected_by: "CORP\\tom"', DOMAIN_USER_READ), @@ -648,6 +651,10 @@ def test_check_names_a_plan_value_that_is_not_text_by_what_was_read( "the text `a\\b`"), (ELECTED_BY_PLACEHOLDER, 'a\\"b', 'elected_by: "a\\"b"', 'the text `a"b`'), + (ELECTED_BY_PLACEHOLDER, "CORP\\ralph", 'elected_by: "CORP\\ralph"', + "the text `CORP`, then a carriage return, then `alph`"), + ("REFERENCE", "docs\\x41.md", 'reference: "docs\\x41.md"', + "the text `docsA.md`"), ] From 4bf2cc4263df53ccf10308655fad115d30eada7b Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:38:06 +0000 Subject: [PATCH 05/24] adopt-project.py: the misread gate asks `elected_by:` and `reference:` as the materializer's `render` writes them since #284, the value spelled by `double_quoted` between the manifest's quotes, so `--elected-by 'CORP\tom'` and a name holding a `"` are no longer refused for a line `execute` does not write (#181) The merge of main be2790e brought #284, whose `render` spells a value between a YAML template's double quotes with `double_quoted`, the writer `repo_shape`'s reader takes back unchanged. `_written` still built that line with the value pasted in raw, so the gate asked about a line `execute` no longer writes. It refused `CORP\tom`, `docs\new.md` and `docs\x41.md` with a sentence about that line. It also newly refused `Robert "Bob" Smith` and a name ending in `\`, which the previous head accepted, because #284's reader refuses the raw line. - `_written` takes `render`'s two steps for one placeholder, in order: `"{{KEY}}"` becomes `double_quoted(value)`, then any other `{{KEY}}` the value as it is. Every templated file it reads is `.yaml`, which is what makes `render` take the first step. Since #284, no value between the manifest's quotes is misread; the bare placeholders are asked as before. - `FLAG_DEFAULTS` no longer names the elector `plan` takes from `git config user.name`: the manifest writes it between quotes, so no elector can be misread and the entry could not be reached. - The section comment says what the quoted line now reads back as. Tests (tests/test_adopt_plan_yaml_quoting.py), each where #284 changed what is true: - `REFUSED` loses its two rows `project.yaml` writes between quotes (`--elected-by 'CORP\tom'`, `--reference 'docs\new.md'`): 16 rows, so 16 refused at `plan` and 18 at `check` and `execute`. - `BETWEEN_THE_QUOTES`, new: the values the quotes misread before #284, `"`, a last `\` and a carriage return among them, are each asked as `render` writes them (`double_quoted`) and passed, for `elected_by` and `reference` (20 cases). - The `git config user.name` elector `CORP\tom` is taken by `plan`, written `"CORP\\tom"` into the plan, and passed by `check`. - `UNESCAPED`, which pins how a sentence shows a value the loader reads as OTHER TEXT (review181's N1), moves to the bare `tracking_branch:` placeholder with the value's own double quotes (a hand-edited plan's `'"CORP\tom"'`), where the loader still resolves the escape. It gains `a #b`, read as `a`. Its six escapes, the tab, the line break and the carriage return a sentence names among them, are the same. - The drift guard that renders the files whole with `render` and compares every candidate with the gate covers `reference` too. Revert lines, each measured alone on this commit's bytes, running test_adopt_plan_yaml_quoting, test_adopt_plan_escapes, test_adopt_leg_paths and test_template_quoted_values (900 tests): - `_written` pasting a quoted placeholder's value raw again: 23 fail (the 20 `BETWEEN_THE_QUOTES` cases, the `git config` elector, and the drift guard for `elected_by` and `reference`). - `FLAG_DEFAULTS` naming the elector again: 0 fail. No elector reaches a misread sentence since #284, which is why the entry goes. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- adopt-project.py | 31 ++++++--- tests/test_adopt_plan_yaml_quoting.py | 97 +++++++++++++++++++-------- 2 files changed, 92 insertions(+), 36 deletions(-) diff --git a/adopt-project.py b/adopt-project.py index 996a8cb4..9109867e 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -1575,8 +1575,8 @@ def _kept_collision_notes(args, entries: list, tree: list) -> list[str]: # THE FIX IS TO REFUSE. `plan` (before the plan is written), `check` (a # FINDING) and `execute` (through `_entry_findings`, before any leg exists) # ask the ONE question below and refuse a value the project's loader would -# read back as anything but that same text. Quoting the placeholders is left -# to a follow-up rather than done here: the same templates render every +# read back as anything but that same text. Quoting the bare placeholders is +# left to a follow-up rather than done here: the same templates render every # scaffolded project, and `scaffold-project.py` writes these values bare # with no such gate, so quoting them changes both tools at once. # @@ -1585,8 +1585,9 @@ def _kept_collision_notes(args, entries: list, tree: list) -> list[str]: # as `execute` would put it there, and `loader_reading` -- the question `y` # asks -- reads that line back. No list of words, and no list of which # placeholders are quoted: `elected_by: "{{ELECTED_BY}}"` is asked as the -# quoted line it is, so `Null` reads back there and `CORP\tom` does not -# (the loader makes the `\t` a tab). +# line `render` writes, the value spelled by `double_quoted` (#284), so +# `Null` and `CORP\tom` both read back there, and a bare `Null` after +# `tracking_branch:` does not. #: The assembly root's templated files that are YAML: what the project's own #: loader reads back out of the split commit. The materializer's list, so a @@ -1614,8 +1615,10 @@ def _kept_collision_notes(args, entries: list, tree: list) -> list[str]: LEG_PATH_PLACEHOLDERS = {"spec": "SPEC_PATH", "code": "CODE_PATH"} #: How `plan` fills a flag nobody passed, where the value it fills in can be #: one the loader misreads: a sentence about it names where it came from. -FLAG_DEFAULTS = {"id": "--project lowercased", - ELECTED_BY_KEY: "`git config user.name`"} +#: Not the elector it takes from `git config user.name`: the manifest writes +#: that between double quotes, which `render` spells with `double_quoted` +#: (#284), and no elector is misread there. +FLAG_DEFAULTS = {"id": "--project lowercased"} #: How a sentence names the id `execute` derives when the plan sets none: #: when it has no `id:`, or an `id:` with no value (`null`, `~` or nothing, #: as `main`'s `plan --id null` wrote it), which `Plan.get` reads alike. @@ -1649,9 +1652,21 @@ def _pin_item(pin: str) -> str: def _written(placeholder: str, value: str) -> list[tuple[str, str]]: """`(file, line)` for each line of a templated YAML file that carries `{{placeholder}}`: with `value` in its place, as `execute` writes it, and - without its indent, which says nothing about how the value is read.""" + without its indent, which says nothing about how the value is read. + + AS `execute` WRITES IT is as the materializer's `render` writes it, in + its two steps: between a YAML template's double quotes, + `"{{ELECTED_BY}}"`, the value is spelled by `double_quoted`, which the + loader reads back as typed; anywhere else it stands as it is (#284). + Pasted raw between the quotes, as `render` once did, the gate refused + `--elected-by 'CORP\\tom'` for a line `execute` no longer writes, and a + name holding a `"`. Every file here is `.yaml`, which is what makes + `render` take the first step. + """ token = "{{" + placeholder + "}}" - return [(rel, line.strip().replace(token, value)) + quoted = f'"{token}"' + return [(rel, line.strip().replace(quoted, double_quoted(value)) + .replace(token, value)) for rel in TEMPLATED_YAML for line in (ASSEMBLY_TEMPLATE / rel).read_text( encoding="utf-8").splitlines() diff --git a/tests/test_adopt_plan_yaml_quoting.py b/tests/test_adopt_plan_yaml_quoting.py index a87d8041..63b88a4c 100644 --- a/tests/test_adopt_plan_yaml_quoting.py +++ b/tests/test_adopt_plan_yaml_quoting.py @@ -352,6 +352,7 @@ def test_a_value_is_asked_about_in_each_place_it_is_written( TRACKING_KEY = "tracking_branch" PINS_KEY = "pins" ELECTED_BY_KEY = "elected_by" +REFERENCE_KEY = "reference" SPEC_FLAG = "--spec-path" TRACKING_FLAG = "--tracking-branch" PIN_FLAG = "--pin" @@ -363,12 +364,15 @@ def test_a_value_is_asked_about_in_each_place_it_is_written( #: The `_template_values` placeholders the tests below fill. TRACKING_PLACEHOLDER = "TRACKING_BRANCH" ELECTED_BY_PLACEHOLDER = "ELECTED_BY" +REFERENCE_PLACEHOLDER = "REFERENCE" PINS_PLACEHOLDER = "NEUTRAL_PRODUCT_PINS" #: The leg path and the branch the accepted end-to-end run is given. LEGS_NULL = "legs/null" Q4_BRANCH = "2026-q4" -#: A Windows `DOMAIN\user` name: between double quotes, the loader makes the -#: `\t` a tab (`DOUBLE_QUOTED_ESCAPES`), which the sentence names as one. +#: A Windows `DOMAIN\user` name. Pasted raw between double quotes, the loader +#: makes the `\t` a tab (`DOUBLE_QUOTED_ESCAPES`), which a sentence names as +#: one; between the manifest's own quotes `render` spells it `"CORP\\tom"` +#: since #284, which reads back as typed. DOMAIN_USER = "CORP\\tom" DOMAIN_USER_READ = "the text `CORP`, then a tab, then `om`" @@ -377,9 +381,10 @@ def test_a_value_is_asked_about_in_each_place_it_is_written( #: and then to a bootstrap that failed, or that put the legs on another branch #: than the one they were pushed on (lane 2's review, and this fix's own repro #: of it); `2026`, `1.5` and `~` did so on `main` too (#187's review). `007` -#: is a branch bootstrap would read as `7`. The last two are values -#: `project.yaml` writes between quotes, which the loader still does not give -#: back: it makes the `\t` and the `\n` a tab and a line break. +#: is a branch bootstrap would read as `7`. No value `project.yaml` writes +#: between its quotes is here: `render` spells those with `double_quoted` +#: since #284, and `BETWEEN_THE_QUOTES` below holds the ones that misread +#: before it. REFUSED = [ (SPEC_FLAG, "null", SPEC_PATH_KEY, NO_VALUE), (SPEC_FLAG, "Null", SPEC_PATH_KEY, NO_VALUE), @@ -397,9 +402,6 @@ def test_a_value_is_asked_about_in_each_place_it_is_written( (PIN_FLAG, "null", PINS_KEY, NO_VALUE), (PIN_FLAG, "True", PINS_KEY, TRUE_READ), (TRACKING_FLAG, "007", TRACKING_KEY, SEVEN_READ), - (ELECTED_BY_FLAG, DOMAIN_USER, ELECTED_BY_KEY, DOMAIN_USER_READ), - ("--reference", "docs\\new.md", "reference", - "the text `docs`, then a line break, then `ew.md`"), ] #: `(plan key, value, what the loader reads)` that only an edited plan can #: carry here: `plan` takes `--visibility` from a fixed choice, and reads @@ -527,9 +529,13 @@ def test_plan_names_an_id_it_derived_from_the_project( assert who in result.stderr and what in result.stderr, result.stderr -def test_plan_names_an_elector_it_took_from_git(answered, tmp_path): - """No `--elected-by`: `plan` asks `git config user.name`, and a - `CORP\\tom` there is a name the manifest's quotes turn into a tab.""" +def test_plan_takes_an_elector_from_git_the_manifests_quotes_read_back( + answered, tmp_path): + """No `--elected-by`: `plan` asks `git config user.name`. A `CORP\\tom` + there was refused while `render` pasted it raw between the manifest's + quotes, where the loader made its `\\t` a tab. Since #284 `execute` + writes it `"CORP\\\\tom"`, which reads back as typed, so `plan` writes + the plan and `check` passes it.""" source, _ = answered plan = tmp_path / PLAN_FILE result = run_script( @@ -537,11 +543,13 @@ def test_plan_names_an_elector_it_took_from_git(answered, tmp_path): "--org", "testorg", "--elected-on", "2026-09-02", "--out", str(plan), env={"GIT_CONFIG_COUNT": "1", "GIT_CONFIG_KEY_0": "user.name", "GIT_CONFIG_VALUE_0": DOMAIN_USER}) - assert result.returncode == 2, result.stdout + result.stderr - who, what = misread("--elected-by (not given, so `git config " - "user.name`)", DOMAIN_USER, DOMAIN_USER_READ) - assert who in result.stderr and what in result.stderr, result.stderr - assert not plan.exists() + assert result.returncode == 0, result.stdout + result.stderr + assert '\nelected_by: "CORP\\\\tom"\n' in text_of(plan) + for path, leg in ANSWERS: + resolve(plan, path, leg) + passed = checked(plan) + assert passed.returncode == 0, passed.stdout + passed.stderr + assert "manifest-misread" not in passed.stderr @pytest.mark.parametrize("key, value, reading", CARRIED, @@ -637,24 +645,28 @@ def test_check_names_a_plan_value_that_is_not_text_by_what_was_read( #: `(placeholder, value, the manifest line `execute` writes, what the loader -#: reads it back as)`: values the loader reads back as OTHER TEXT, through -#: the double quotes the template writes around them. The last two are -#: escapes the loader resolves since #199 (`\\r`, and a code point's -#: `\\xNN`), which the gate follows with no edit of its own: it asks the -#: loader. +#: reads it back as)`: values the loader reads back as OTHER TEXT, each +#: through a placeholder the template writes bare. The double quotes in the +#: first six are the VALUE's own, as a hand-edited plan's +#: `tracking_branch: '"CORP\tom"'` carries them: the loader takes them for +#: YAML's and resolves the escape between them. The manifest's OWN quotes no +#: longer do that, since #284 (`BETWEEN_THE_QUOTES`). `\\r` and a code +#: point's `\\xNN` are escapes the loader resolves since #199, which the gate +#: follows with no edit of its own: it asks the loader. UNESCAPED = [ - (ELECTED_BY_PLACEHOLDER, DOMAIN_USER, 'elected_by: "CORP\\tom"', - DOMAIN_USER_READ), - ("REFERENCE", "docs\\new.md", 'reference: "docs\\new.md"', + (TRACKING_PLACEHOLDER, f'"{DOMAIN_USER}"', + f'tracking_branch: "{DOMAIN_USER}"', DOMAIN_USER_READ), + (TRACKING_PLACEHOLDER, '"docs\\new.md"', 'tracking_branch: "docs\\new.md"', "the text `docs`, then a line break, then `ew.md`"), - (ELECTED_BY_PLACEHOLDER, "a\\\\b", 'elected_by: "a\\\\b"', + (TRACKING_PLACEHOLDER, '"a\\\\b"', 'tracking_branch: "a\\\\b"', "the text `a\\b`"), - (ELECTED_BY_PLACEHOLDER, 'a\\"b', 'elected_by: "a\\"b"', + (TRACKING_PLACEHOLDER, '"a\\"b"', 'tracking_branch: "a\\"b"', 'the text `a"b`'), - (ELECTED_BY_PLACEHOLDER, "CORP\\ralph", 'elected_by: "CORP\\ralph"', + (TRACKING_PLACEHOLDER, '"CORP\\ralph"', 'tracking_branch: "CORP\\ralph"', "the text `CORP`, then a carriage return, then `alph`"), - ("REFERENCE", "docs\\x41.md", 'reference: "docs\\x41.md"', + (TRACKING_PLACEHOLDER, '"docs\\x41.md"', 'tracking_branch: "docs\\x41.md"', "the text `docsA.md`"), + (TRACKING_PLACEHOLDER, "a #b", "tracking_branch: a #b", "the text `a`"), ] @@ -673,6 +685,34 @@ def test_text_the_loader_reads_as_other_text_is_shown_as_typed_and_read( f"{reading} and not as that text: choose another value"] +#: Values the manifest's own double quotes misread while `render` pasted +#: them raw between them: an escape the loader resolved, a `"` that ended +#: the scalar early, a last `\\` that left it unclosed, and a carriage return +#: that split the line. Since #284 `render` spells each with `double_quoted`, +#: which the loader reads back as typed. +BETWEEN_THE_QUOTES = [DOMAIN_USER, "docs\\new.md", "CORP\\ralph", + "docs\\x41.md", "a\\\\b", 'a\\"b', 'Robert "Bob" Smith', + "trail\\", '"', "a\rb"] + + +@pytest.mark.parametrize("placeholder, key", [ + (ELECTED_BY_PLACEHOLDER, ELECTED_BY_KEY), + (REFERENCE_PLACEHOLDER, REFERENCE_KEY)], + ids=[ELECTED_BY_KEY, REFERENCE_KEY]) +@pytest.mark.parametrize("value", BETWEEN_THE_QUOTES) +def test_a_value_between_the_manifests_quotes_is_asked_as_render_writes_it( + adopter, placeholder, key, value): + """`project.yaml` writes `elected_by:` and `reference:` between double + quotes, where `render` spells the value with `double_quoted` since #284. + The gate asked the line with the value pasted raw, so it refused + `CORP\\tom` for a line `execute` no longer writes, and a name holding a + `"`. It asks the line `render` writes, and passes each value.""" + assert adopter._written(placeholder, value) == [ + (MANIFEST, f"{key}: {repo_shape.double_quoted(value)}")] + assert adopter.manifest_misreads( + [adopter._field(key, placeholder, value)]) == [] + + @needs_filter_repo @pytest.mark.parametrize("key, value, reading", CARRIED, ids=ids_of(CARRIED, 0, 1)) @@ -786,6 +826,7 @@ def test_the_template_line_says_whether_a_value_is_quoted(adopter): LANDS = { TRACKING_PLACEHOLDER: [(MANIFEST, (TRACKING_KEY,))], ELECTED_BY_PLACEHOLDER: [(MANIFEST, (ELECTED_BY_KEY,))], + REFERENCE_PLACEHOLDER: [(MANIFEST, (REFERENCE_KEY,))], "SPEC_PATH": [(MANIFEST, ("legs", 1, "path")), ("contracts/spec-pin.yaml", ("submodule_path",))], PINS_PLACEHOLDER: [(MANIFEST, ("neutral_product_pins", 0))], From a230c82fc0fb4a22afb4e409f97246de19827df4 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:08:50 +0000 Subject: [PATCH 06/24] adopt-project.py: a misread sentence names a boolean in the words `y` writes it into a plan, so the merge of #215's `BOOLEAN_WORDS` no longer leaves the literal `"false"` written three times (S1192) (#181) The merge of main be2790e (3352be4) brought #215's `BOOLEAN_WORDS`, which spells `"false"` beside `y`'s own. `_the_loader_made`, this branch's, spelled it a third time: `"the boolean " + ("true" if reading else "false")`. The hand count of Sonar's rules was not made again after that merge, so it went unseen until the merge of 71b58a1. Counted on 2405670 against main 71b58a1: `'false'` 3x, main 2x. `y` writes a boolean as `true` or `false`, so every sentence is the same as before: `_the_loader_made` gives "the boolean true" and "the boolean false", which the REFUSED, CARRIED and READ_AS_NOT_TEXT rows assert. Counted again: in adopt-project.py the only literals of five or more characters used three times or more are the three main already used that often (`'utf-8'`, `'visibility'`, `'tracking_branch'`), and no function is over 15. Revert line: reverting this commit makes no test fail, because no sentence changes. It brings back `'false'` 3x, main 2x (S1192). Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- adopt-project.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/adopt-project.py b/adopt-project.py index 3a7201ef..37d602f9 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -1699,7 +1699,8 @@ def _the_loader_made(reading) -> str: if reading is None: return "no value at all" if isinstance(reading, bool): - return "the boolean " + ("true" if reading else "false") + # In the words `y` writes it into a plan, which are YAML's own. + return "the boolean " + y(reading) if isinstance(reading, (int, float)): return f"the number {reading!r}" if isinstance(reading, str): From 57103dd9fdfceff6b1e09f4583a1b67a17e06ac3 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:43:06 +0000 Subject: [PATCH 07/24] adopt-project.py: a misread sentence about a pin names the item `execute` writes, whitespace after the pin and all, so a hand-edited `pins: ["openGlass "]` is no longer said to be written as `- openGlass` (#181) rv181f's F1, found at e337f44 and there since b26841b. `_pin_field` stripped the item line after the pin was in it, so the pin's own trailing whitespace was gone from the line the sentence names. `execute` writes the item as it stands (`write_lf` writes the text as given), so `check` said: a pin in pins is `openGlass `, which `execute` writes into project.yaml's neutral_product_pins as `- openGlass`, ... `_written` strips a template line before the value goes in, which keeps the value whole. `_pin_field` now strips only the item's indent, the same way. The decision does not change: the loader drops the whitespace either way, and that is why the pin is refused (rv181f: 0 of 87 decisions differ; the line shown was wrong in 29 of 29). Test: `test_a_pin_is_shown_in_the_line_execute_writes_whitespace_and_all` runs `check` on a hand-edited plan, for a pin with a space after it (the line shown between backticks as `- openGlass `) and one with a tab (shown as Python spells it, '- openGlass\t'). No test covered this before: rv181f's PINSTRIP revert row failed nothing. Revert line: `.lstrip()` back to `.strip()` fails both cases (2 failed). Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- adopt-project.py | 5 ++++- tests/test_adopt_plan_yaml_quoting.py | 29 +++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/adopt-project.py b/adopt-project.py index 37d602f9..3d28f79a 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -1686,8 +1686,11 @@ def _field(what: str, placeholder: str, value) -> tuple[str, object, list]: def _pin_field(what: str, pin) -> tuple[str, object, list]: + # Only the item's indent goes: `execute` writes the pin as it stands, + # whitespace after it included, and the sentence names that line. The + # loader drops the whitespace, which is the misreading it reports. return what, pin, [(f"{MANIFEST_YAML}'s neutral_product_pins", - _pin_item(str(pin)).strip())] + _pin_item(str(pin)).lstrip())] def _the_loader_made(reading) -> str: diff --git a/tests/test_adopt_plan_yaml_quoting.py b/tests/test_adopt_plan_yaml_quoting.py index 63b88a4c..9cd50481 100644 --- a/tests/test_adopt_plan_yaml_quoting.py +++ b/tests/test_adopt_plan_yaml_quoting.py @@ -685,6 +685,35 @@ def test_text_the_loader_reads_as_other_text_is_shown_as_typed_and_read( f"{reading} and not as that text: choose another value"] +#: `(a hand-edited plan's pin, the item `execute` writes for it, as a +#: sentence shows that line)`. `execute` writes a pin into its ` - ` item as +#: it stands, so the whitespace after it stays in the line, where the loader +#: drops it. A space shows between the backticks; a tab makes the line one a +#: terminal would not show as it is, so Python spells it. +PIN_WITH_A_TRAIL = [ + pytest.param('"openGlass "', "`- openGlass `", id="space"), + pytest.param('"openGlass\\t"', "'- openGlass\\t'", id="tab"), +] + + +@pytest.mark.parametrize("spelled, shown", PIN_WITH_A_TRAIL) +def test_a_pin_is_shown_in_the_line_execute_writes_whitespace_and_all( + answered, tmp_path, spelled, shown): + """The finding said `execute` writes `- openGlass` for a pin + `"openGlass "`: the line was stripped after the pin was put in it, so + the space `execute` keeps was gone from the line the sentence named + (rv181f's F1). The loader drops that space, which is why the pin is + refused, and the line named is the one `execute` writes.""" + _, text = answered + plan = tmp_path / PLAN_FILE + carrying(plan, text, PINS_KEY, spelled) + result = checked(plan) + assert result.returncode == 1, result.stdout + result.stderr + assert (f"{MANIFEST}'s neutral_product_pins as {shown}, where the " + "project's own YAML loader reads it back as the text `openGlass` " + "and not as that text") in result.stderr, result.stderr + + #: Values the manifest's own double quotes misread while `render` pasted #: them raw between them: an escape the loader resolved, a `"` that ended #: the scalar early, a last `\\` that left it unclosed, and a carriage return From cba6d3ff1e1cb08ace87f5cefd6b28e4a4e58f27 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:43:16 +0000 Subject: [PATCH 08/24] adopt-project.py: `_worded` names each stretch of one kind of character `chars`, so its loop no longer shadows the module's `run()` (#181) rv181f's F4. `itertools.groupby`'s groups were bound to `run`, the name of adopt-project.py's own subprocess helper. `_worded` calls no `run`, so nothing broke; the next edit to the function could have. A rename only: every sentence is the same. No test can fail with it reverted, and none is added. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- adopt-project.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/adopt-project.py b/adopt-project.py index 3d28f79a..fd849343 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -1727,11 +1727,11 @@ def _worded(text: str) -> str: plainly a tab. """ parts: list[str] = [] - for printable, run in itertools.groupby(text, str.isprintable): + for printable, chars in itertools.groupby(text, str.isprintable): if printable: - parts.append(f"`{''.join(run)}`") + parts.append(f"`{''.join(chars)}`") else: - parts += map(_character_name, run) + parts += map(_character_name, chars) return ", then ".join(parts) or '""' From d38e14313999dd4345a0e152fff6bcb45578d7e9 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:48:16 +0000 Subject: [PATCH 09/24] adopt-project.py: the misread gate asks the materializer's own `render` for the line `execute` writes, so since #260's one-pass `render` an `--elected-by` or `--reference` holding its own placeholder, such as `"{{ELECTED_BY}}"`, is no longer refused for a line `execute` never writes (#181) The merge of main a1b3a4b (bf6d656) brought #260 (34ecbc2): `render` puts each value in once and never searches it again. `_written` copied #284's rules in two steps, first the quoted placeholder and then the bare one, and the second ran over the first's output, which held the value. So a value holding its own token was put in twice, and the gate refused it, though `render` writes it as typed. At bf6d656, main's own `test_adopt_records_each_free_text_value_as_typed[every-key]` failed: `plan` refused with `adopt-manifest-misread`. - `_written` hands each template line that carries the placeholder to `render` itself, the same way the gate hands the line it builds to `loader_reading`. No copy of the materializer's rules is left to go stale. The table is the value, plus `_as_they_stand`: every other placeholder on the line given back as its own token, which `render` writes as it was, `double_quoted` spelling `{{KEY}}` as `"{{KEY}}"`. So the line differs from the template's by the one value, and `render` is never asked for a key it would refuse. - `render` and `PLACEHOLDER_RE` are imported from the materializer. Test (tests/test_adopt_plan_yaml_quoting.py): `HOLDING_A_TOKEN`, five values (each field's own token after a letter and between double quotes, and `{{NOPE}}`). Each is asked as `render` writes it, for `elected_by:` and for `reference:`, and passes (10 cases). Revert line: `_written` back to the two steps fails 5. These are the four own-token cases, `x{{ELECTED_BY}}` and `"{{ELECTED_BY}}"` for `elected_by` and the same two for `reference`, and main's `test_adopt_records_each_free_text_value_as_typed[every-key]`. With this commit, tests/test_template_values_single_pass.py is 164 passed. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- adopt-project.py | 50 ++++++++++++++++----------- tests/test_adopt_plan_yaml_quoting.py | 25 ++++++++++++++ 2 files changed, 55 insertions(+), 20 deletions(-) diff --git a/adopt-project.py b/adopt-project.py index 116a4ce8..5a1dbe38 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -108,11 +108,11 @@ parse_yaml, tree_digest, ) from shape_materialize import ( # noqa: E402 - ADOPT_MAKEFILE_BLOCK, RULESET_HINT, SHAPE_REPOSITORY, TEMPLATED, - CommandFailed, Materialized, assembly_root_paths, collision_follow_up, - copy_tree, default_reference, election_date, env_commit, - escape_what_stdout_cannot_encode, git_init_commit, - materialize_assembly_root, naming_block, run, write_lf, + ADOPT_MAKEFILE_BLOCK, PLACEHOLDER_RE, RULESET_HINT, SHAPE_REPOSITORY, + TEMPLATED, CommandFailed, Materialized, assembly_root_paths, + collision_follow_up, copy_tree, default_reference, election_date, + env_commit, escape_what_stdout_cannot_encode, git_init_commit, + materialize_assembly_root, naming_block, render, run, write_lf, write_paths_listing, ) # The leg-path rule (#179) is `scaffold-project.py`'s as much as adopt's, so it @@ -1700,23 +1700,33 @@ def _written(placeholder: str, value: str) -> list[tuple[str, str]]: `{{placeholder}}`: with `value` in its place, as `execute` writes it, and without its indent, which says nothing about how the value is read. - AS `execute` WRITES IT is as the materializer's `render` writes it, in - its two steps: between a YAML template's double quotes, - `"{{ELECTED_BY}}"`, the value is spelled by `double_quoted`, which the - loader reads back as typed; anywhere else it stands as it is (#284). - Pasted raw between the quotes, as `render` once did, the gate refused - `--elected-by 'CORP\\tom'` for a line `execute` no longer writes, and a - name holding a `"`. Every file here is `.yaml`, which is what makes - `render` take the first step. + AS `execute` WRITES IT is what the materializer's own `render` makes of + the line, asked here as the loader is asked, so no copy of its rules can + go stale. Between a YAML template's double quotes, `"{{ELECTED_BY}}"`, + `render` spells the value with `double_quoted`, which the loader reads + back as typed (#284), and it puts each value in once, never searching + it again (#260). A copy of those rules in two steps, the second run over + the first's output, asked `--elected-by '"{{ELECTED_BY}}"'` about a line + `execute` never writes, and refused it. Every file here is `.yaml`, + which is what makes `render` take the quoted step. """ token = "{{" + placeholder + "}}" - quoted = f'"{token}"' - return [(rel, line.strip().replace(quoted, double_quoted(value)) - .replace(token, value)) - for rel in TEMPLATED_YAML - for line in (ASSEMBLY_TEMPLATE / rel).read_text( - encoding="utf-8").splitlines() - if token in line] + lines = [(rel, line.strip()) + for rel in TEMPLATED_YAML + for line in (ASSEMBLY_TEMPLATE / rel).read_text( + encoding="utf-8").splitlines() + if token in line] + return [(rel, render(line, {**_as_they_stand(line), placeholder: value}, + rel)) + for rel, line in lines] + + +def _as_they_stand(line: str) -> dict[str, str]: + """A `render` table that gives each placeholder on `line` back as its own + token. Bare, the token is written as it was; between quotes, + `double_quoted` spells `{{KEY}}` as `"{{KEY}}"`, as it was. So the line + `_written` asks about differs from the template's by one value only.""" + return {token[2:-2]: token for token in PLACEHOLDER_RE.findall(line)} def _field(what: str, placeholder: str, value) -> tuple[str, object, list]: diff --git a/tests/test_adopt_plan_yaml_quoting.py b/tests/test_adopt_plan_yaml_quoting.py index 9cd50481..c6cd5c20 100644 --- a/tests/test_adopt_plan_yaml_quoting.py +++ b/tests/test_adopt_plan_yaml_quoting.py @@ -742,6 +742,31 @@ def test_a_value_between_the_manifests_quotes_is_asked_as_render_writes_it( [adopter._field(key, placeholder, value)]) == [] +#: Free text holding a placeholder's token, as #260's values may: each +#: field's own, bare after a letter and between double quotes, and one that +#: no table names. +HOLDING_A_TOKEN = ["x{{ELECTED_BY}}", '"{{ELECTED_BY}}"', "x{{REFERENCE}}", + '"{{REFERENCE}}"', "{{NOPE}}"] + + +@pytest.mark.parametrize("placeholder, key", [ + (ELECTED_BY_PLACEHOLDER, ELECTED_BY_KEY), + (REFERENCE_PLACEHOLDER, REFERENCE_KEY)], + ids=[ELECTED_BY_KEY, REFERENCE_KEY]) +@pytest.mark.parametrize("text", HOLDING_A_TOKEN) +def test_a_value_holding_a_placeholder_is_asked_as_render_puts_it_in_once( + adopter, placeholder, key, text): + """Since #260 `render` puts each value in once and never searches it + again. The gate built the line in two steps, the quoted placeholder and + then the bare one over that output, so a value holding its own token + was put in twice: `"x{{ELECTED_BY}}"` became `"xx{{ELECTED_BY}}"`, and + the gate refused a value `execute` writes as typed. It asks `render`.""" + assert adopter._written(placeholder, text) == [ + (MANIFEST, f"{key}: {repo_shape.double_quoted(text)}")] + assert adopter.manifest_misreads( + [adopter._field(key, placeholder, text)]) == [] + + @needs_filter_repo @pytest.mark.parametrize("key, value, reading", CARRIED, ids=ids_of(CARRIED, 0, 1)) From e391b184b3240a7dfe8f40241313f67a7214f701 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:40:40 +0000 Subject: [PATCH 10/24] adopt-project.py: the #181 gate asks only about a value `execute` gets as far as writing, so a `tracking_branch` it refuses as `unsafe-value`, or a `visibility` gh takes no flag for, is no longer called misread under a remediation saying the run would end `adoption verified` (rv181f's F2, F2b) `_execute_gets_to_write(key, value)`, rv181f's prototype, is asked before `_plan_fields` builds a field for a MANIFEST_FIELDS key: - A `tracking_branch` must pass `checked_value`. `_checked_plan_values` refuses one it refuses as `unsafe-value` before `_refuse_what_check_finds` asks the gate (F2: 44 of rv181f's 540 sentences, all `tracking_branch`). - A `visibility` must be one of VISIBILITY_CHOICES. `_create_leg_remotes` hands any other to `gh repo create` as a flag before anything is written (F2b). `check` is not told whether `execute` will run with `--local-remote-dir`, so such a visibility is asked about in neither mode. TRACKING_KEY and VISIBILITY_KEY name the two keys once. tests/test_adopt_plan_yaml_quoting.py: - READ_AS_NOT_TEXT's list and mapping rows move from `tracking_branch` and `visibility` to `elected_on`, which `execute` writes as it stands. - UNESCAPED's 7 rows move from the TRACKING_BRANCH placeholder to ELECTED_ON (lines `elected_on: ...`), which a plan edited by hand carries as far as the gate. - EDITED_ONLY drops its `visibility: "true"` row (CARRIED 18 -> 17). - New, no marker: `check` names neither `tracking_branch: '"CORP\tom"'` nor `visibility: "true"` as misread, and prints no remediation for them. - New, with filter-repo: `execute` refuses that branch as `unsafe-value` before any leg exists, and `check` says nothing misread of it. - REMOTES names the local remotes directory once (S1192). The quoting file: 436 passed (435, less 2 CARRIED rows, plus 3 new). Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- adopt-project.py | 49 ++++++++++-- tests/test_adopt_plan_yaml_quoting.py | 109 ++++++++++++++++++++------ 2 files changed, 128 insertions(+), 30 deletions(-) diff --git a/adopt-project.py b/adopt-project.py index 5a1dbe38..3d8e2809 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -1647,6 +1647,9 @@ def _kept_collision_notes(args, entries: list, tree: list) -> list[str]: ELECTED_BY_KEY = "elected_by" ELECTED_ON_KEY = "elected_on" REFERENCE_KEY = "reference" +#: The two other plan keys `_execute_gets_to_write` asks about by name. +TRACKING_KEY = "tracking_branch" +VISIBILITY_KEY = "visibility" #: The placeholder `execute` writes the project's id through. ID_PLACEHOLDER = "PROJECT_ID" @@ -1655,8 +1658,8 @@ def _kept_collision_notes(args, entries: list, tree: list) -> list[str]: #: placeholder that carries it. The id is asked about apart from these, #: because `execute` derives it when the plan has none; the leg paths are #: `legs.` keys; the pins are a list. -MANIFEST_FIELDS = {"tracking_branch": "TRACKING_BRANCH", - "visibility": "VISIBILITY", ELECTED_BY_KEY: "ELECTED_BY", +MANIFEST_FIELDS = {TRACKING_KEY: "TRACKING_BRANCH", + VISIBILITY_KEY: "VISIBILITY", ELECTED_BY_KEY: "ELECTED_BY", ELECTED_ON_KEY: "ELECTED_ON", REFERENCE_KEY: "REFERENCE"} LEG_PATH_PLACEHOLDERS = {"spec": "SPEC_PATH", "code": "CODE_PATH"} #: How `plan` fills a flag nobody passed, where the value it fills in can be @@ -1866,16 +1869,47 @@ def _id_field(plan: Plan) -> tuple: return _field("id", ID_PLACEHOLDER, set_id) +#: The plan values `execute` hands to `git` before it writes anything: +#: `_checked_plan_values` checks each with `checked_value`, and refuses one it +#: refuses (`unsafe-value`) before `_refuse_what_check_finds` asks the gate. +GIT_ARGUMENT_KEYS = (TRACKING_KEY,) + + +def _execute_gets_to_write(key: str, value) -> bool: + """Does `execute` get as far as writing `value` into the project? Not for + a `git` argument `checked_value` refuses: `_checked_plan_values` refuses + it as `unsafe-value` before any leg exists, so a sentence saying + `execute` writes it, and that the run would end `adoption verified`, + would be false (rv181f's F2). Nor, outside `--local-remote-dir`, for a + visibility that is not one of `VISIBILITY_CHOICES`: `_create_leg_remotes` + hands it to `gh repo create` as `--` before anything is + written, and no other is a visibility gh creates a repository with + (rv181f's F2b). `check` is not told which of the two modes `execute` will + run in, so such a visibility is asked about in neither. None of the + three choices is misread.""" + if key == VISIBILITY_KEY: + return str(value) in VISIBILITY_CHOICES + if key not in GIT_ARGUMENT_KEYS: + return True + try: + checked_value(key, value) + except Refusal: + return False + return True + + def _plan_fields(plan: Plan) -> list[tuple]: """A plan's values as its loader read them, each named by its key in the plan, with the lines `_template_values` would write them into. A key the plan does not carry is left out: `execute` fills it with a default of this tool's own (`main`, `private`, today's date), and none of - those is misread. A leg path is asked about only when it is text with no - `plan-bad-leg-path` of its own, so one wrong value is one finding. An id - `Plan.project_id` refuses, or a refused leg name it is derived from, is - refused on its own account by `check` and `execute` alike. + those is misread. So is a value `execute` never gets as far as writing + (`_execute_gets_to_write`): it refuses it, or `gh` does, first. A leg + path is asked about only when it is text with no `plan-bad-leg-path` of + its own, so one wrong value is one finding. An id `Plan.project_id` + refuses, or a refused leg name it is derived from, is refused on its own + account by `check` and `execute` alike. """ fields: list[tuple] = [] try: @@ -1884,7 +1918,8 @@ def _plan_fields(plan: Plan) -> list[tuple]: pass fields += [_field(key, placeholder, plan.get(key)) for key, placeholder in MANIFEST_FIELDS.items() - if plan.get(key) is not None] + if plan.get(key) is not None + and _execute_gets_to_write(key, plan.get(key))] for (what, value), placeholder in zip(_plan_leg_paths(plan), LEG_PATH_PLACEHOLDERS.values()): if isinstance(value, str) and leg_path_problem(what, value) is None: diff --git a/tests/test_adopt_plan_yaml_quoting.py b/tests/test_adopt_plan_yaml_quoting.py index c6cd5c20..bfe2f5ef 100644 --- a/tests/test_adopt_plan_yaml_quoting.py +++ b/tests/test_adopt_plan_yaml_quoting.py @@ -352,6 +352,7 @@ def test_a_value_is_asked_about_in_each_place_it_is_written( TRACKING_KEY = "tracking_branch" PINS_KEY = "pins" ELECTED_BY_KEY = "elected_by" +ELECTED_ON_KEY = "elected_on" REFERENCE_KEY = "reference" SPEC_FLAG = "--spec-path" TRACKING_FLAG = "--tracking-branch" @@ -364,6 +365,7 @@ def test_a_value_is_asked_about_in_each_place_it_is_written( #: The `_template_values` placeholders the tests below fill. TRACKING_PLACEHOLDER = "TRACKING_BRANCH" ELECTED_BY_PLACEHOLDER = "ELECTED_BY" +ELECTED_ON_PLACEHOLDER = "ELECTED_ON" REFERENCE_PLACEHOLDER = "REFERENCE" PINS_PLACEHOLDER = "NEUTRAL_PRODUCT_PINS" #: The leg path and the branch the accepted end-to-end run is given. @@ -375,6 +377,9 @@ def test_a_value_is_asked_about_in_each_place_it_is_written( #: since #284, which reads back as typed. DOMAIN_USER = "CORP\\tom" DOMAIN_USER_READ = "the text `CORP`, then a tab, then `om`" +#: The same name with double quotes of its own, as a plan edited by hand +#: carries it: `'"CORP\tom"'`. +QUOTED_DOMAIN_USER = f"'\"{DOMAIN_USER}\"'" #: `(flag, value, plan key, what the project's loader reads)`. At 76a6153 #: each of the first fifteen went through `execute` to `adoption verified` @@ -404,11 +409,12 @@ def test_a_value_is_asked_about_in_each_place_it_is_written( (TRACKING_FLAG, "007", TRACKING_KEY, SEVEN_READ), ] #: `(plan key, value, what the loader reads)` that only an edited plan can -#: carry here: `plan` takes `--visibility` from a fixed choice, and reads -#: `--elected-on 20260902` as a date only on Python 3.11 and later. +#: carry here: `plan` reads `--elected-on 20260902` as a date only on Python +#: 3.11 and later. No visibility is here: `plan` takes one from a fixed +#: choice, and `execute` hands any other to `gh repo create` before it +#: writes anything (`NEVER_WRITTEN`). EDITED_ONLY = [ - ("elected_on", DATE_DIGITS, f"the number {DATE_DIGITS}"), - ("visibility", "true", TRUE_READ), + (ELECTED_ON_KEY, DATE_DIGITS, f"the number {DATE_DIGITS}"), ] #: REFUSED as `plan` is given it, and as a plan carries it. PLANNED = [(flag, value, reading) for flag, value, _, reading in REFUSED] @@ -424,9 +430,14 @@ def checked(plan: Path): return run_script(ADOPT, "check", "--plan", str(plan)) +#: The directory `executed` names for the local remotes, which a run that is +#: refused before any leg exists never creates. +REMOTES = "remotes" + + def executed(plan: Path, tmp_path: Path): return run_script(ADOPT, "execute", "--plan", str(plan), "--yes", - "--local-remote-dir", str(tmp_path / "remotes"), + "--local-remote-dir", str(tmp_path / REMOTES), "--work-dir", str(tmp_path / "work")) @@ -610,16 +621,20 @@ def test_check_names_an_id_it_derived_from_the_assembly( #: something that is not text, so the plan does not say what `str` of it #: says: `007` is read as 7, and `TRUE` as true. The sentence names what was #: READ, with its kind, and then the text `execute` writes (review181's N2). -#: The project's loader reads that text back as the same kind again. +#: The project's loader reads that text back as the same kind again. The +#: list and the mapping are an `elected_on`'s, which `execute` writes as it +#: stands: as a `tracking_branch` it refuses either as `unsafe-value`, and +#: as a `visibility` `gh` takes neither, before anything is written +#: (`NEVER_WRITTEN`). READ_AS_NOT_TEXT = [ (TRACKING_KEY, "007", SEVEN_READ, "7"), (TRACKING_KEY, "TRUE", TRUE_READ, "True"), (TRACKING_KEY, "1.50", "the number 1.5", "1.5"), - (TRACKING_KEY, "[a]", "a list", "['a']"), - ("visibility", "{a: b}", "a mapping", "{'a': 'b'}"), + (ELECTED_ON_KEY, "[a]", "a list", "['a']"), + (ELECTED_ON_KEY, "{a: b}", "a mapping", "{'a': 'b'}"), ("id", "007", SEVEN_READ, "7"), (PINS_KEY, "TRUE", TRUE_READ, "True"), - ("elected_on", DATE_DIGITS, f"the number {DATE_DIGITS}", DATE_DIGITS), + (ELECTED_ON_KEY, DATE_DIGITS, f"the number {DATE_DIGITS}", DATE_DIGITS), ] @@ -644,29 +659,77 @@ def test_check_names_a_plan_value_that_is_not_text_by_what_was_read( assert f"{named} is " not in result.stderr, "`str` of it was echoed" +#: `(plan key, as a plan edited by hand spells it)`: values the project's +#: loader would misread, which `execute` never writes. It refuses the branch +#: as `unsafe-value` (`_checked_plan_values`), and `gh repo create` takes no +#: `--true`, before anything is written. A finding would say the value is +#: written and the run ends `adoption verified`: for these neither is true +#: (rv181f's F2, F2b). +NEVER_WRITTEN = [ + pytest.param(TRACKING_KEY, QUOTED_DOMAIN_USER, id=TRACKING_KEY), + pytest.param("visibility", '"true"', id="visibility"), +] + + +@pytest.mark.parametrize("key, spelled", NEVER_WRITTEN) +def test_check_says_nothing_misread_of_a_value_execute_never_writes( + adopter, answered, tmp_path, key, spelled): + """`check` asked the loader about every value, and named these two as + misread, with a remediation saying the run would still end `adoption + verified`. It asks only about a value `execute` gets as far as + writing.""" + _, text = answered + plan = tmp_path / PLAN_FILE + carrying(plan, text, key, spelled) + result = checked(plan) + assert result.returncode in (0, 1), result.stdout + result.stderr + assert MISREAD_FINDING not in result.stderr, result.stderr + assert adopter.MANIFEST_MISREAD_REMEDIATION not in result.stderr + + +@needs_filter_repo +def test_execute_refuses_that_branch_as_unsafe_before_any_leg_exists( + answered, tmp_path): + """What `execute` does with the branch `check` no longer calls misread: + it refuses it as `unsafe-value`, before it creates a leg or touches the + source, so no sentence of `check`'s says it gets further.""" + source, text = answered + plan = tmp_path / PLAN_FILE + carrying(plan, text, TRACKING_KEY, QUOTED_DOMAIN_USER) + assert MISREAD_FINDING not in checked(plan).stderr + result = executed(plan, tmp_path) + assert result.returncode == 2, result.stdout + result.stderr + assert result.stderr.startswith( + f"REFUSED unsafe-value: {TRACKING_KEY} is "), result.stderr + assert not (tmp_path / REMOTES).exists() + assert SPLIT_BRANCH not in git("branch", "--list", cwd=source).stdout + + #: `(placeholder, value, the manifest line `execute` writes, what the loader #: reads it back as)`: values the loader reads back as OTHER TEXT, each #: through a placeholder the template writes bare. The double quotes in the #: first six are the VALUE's own, as a hand-edited plan's -#: `tracking_branch: '"CORP\tom"'` carries them: the loader takes them for -#: YAML's and resolves the escape between them. The manifest's OWN quotes no -#: longer do that, since #284 (`BETWEEN_THE_QUOTES`). `\\r` and a code -#: point's `\\xNN` are escapes the loader resolves since #199, which the gate -#: follows with no edit of its own: it asks the loader. +#: `elected_on: '"CORP\tom"'` carries them: the loader takes them for YAML's +#: and resolves the escape between them. The manifest's OWN quotes no longer +#: do that, since #284 (`BETWEEN_THE_QUOTES`). `\\r` and a code point's +#: `\\xNN` are escapes the loader resolves since #199, which the gate follows +#: with no edit of its own: it asks the loader. Not a `tracking_branch`'s: +#: `execute` refuses a quote, a backslash or a space in one as +#: `unsafe-value` before it writes anything (`NEVER_WRITTEN`). UNESCAPED = [ - (TRACKING_PLACEHOLDER, f'"{DOMAIN_USER}"', - f'tracking_branch: "{DOMAIN_USER}"', DOMAIN_USER_READ), - (TRACKING_PLACEHOLDER, '"docs\\new.md"', 'tracking_branch: "docs\\new.md"', + (ELECTED_ON_PLACEHOLDER, f'"{DOMAIN_USER}"', + f'elected_on: "{DOMAIN_USER}"', DOMAIN_USER_READ), + (ELECTED_ON_PLACEHOLDER, '"docs\\new.md"', 'elected_on: "docs\\new.md"', "the text `docs`, then a line break, then `ew.md`"), - (TRACKING_PLACEHOLDER, '"a\\\\b"', 'tracking_branch: "a\\\\b"', + (ELECTED_ON_PLACEHOLDER, '"a\\\\b"', 'elected_on: "a\\\\b"', "the text `a\\b`"), - (TRACKING_PLACEHOLDER, '"a\\"b"', 'tracking_branch: "a\\"b"', + (ELECTED_ON_PLACEHOLDER, '"a\\"b"', 'elected_on: "a\\"b"', 'the text `a"b`'), - (TRACKING_PLACEHOLDER, '"CORP\\ralph"', 'tracking_branch: "CORP\\ralph"', + (ELECTED_ON_PLACEHOLDER, '"CORP\\ralph"', 'elected_on: "CORP\\ralph"', "the text `CORP`, then a carriage return, then `alph`"), - (TRACKING_PLACEHOLDER, '"docs\\x41.md"', 'tracking_branch: "docs\\x41.md"', + (ELECTED_ON_PLACEHOLDER, '"docs\\x41.md"', 'elected_on: "docs\\x41.md"', "the text `docsA.md`"), - (TRACKING_PLACEHOLDER, "a #b", "tracking_branch: a #b", "the text `a`"), + (ELECTED_ON_PLACEHOLDER, "a #b", "elected_on: a #b", "the text `a`"), ] @@ -785,7 +848,7 @@ def test_execute_refuses_a_plan_carrying_one_before_any_leg_exists( assert f"{MISREAD_FINDING}{who}" in result.stderr assert what in result.stderr assert adopter.MANIFEST_MISREAD_REMEDIATION in result.stderr - assert not (tmp_path / "remotes").exists() + assert not (tmp_path / REMOTES).exists() assert SPLIT_BRANCH not in git("branch", "--list", cwd=source).stdout From dae165d050d309843d5f259b3b3cd4139fdc83a7 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:52:13 +0000 Subject: [PATCH 11/24] adopt-project.py: the #181 gate leaves a value no file can hold to #255, so a hand-edited `elected_on: "2026-09-02\nx"` or pin `"a\nb"` is one `plan-unwritable-value` finding, and `execute` refuses it under that code instead of `plan-manifest-misread` At the merge of main 1f90a0f (e8e0026), `check` named such a value twice. One finding was #255's `plan-unwritable-value`. The other was #181's `plan-manifest-misread`, under a remediation saying the run would still end `adoption verified`. That claim was false: `execute` refuses the plan before any leg exists. `execute` also refused it as `plan-manifest-misread`, the code of the first finding. tests/test_adopt_template_values.py did not show this (514 passed at the merge, with the quoting file): none of #255's rows is a value the loader also misreads. - `_execute_gets_to_write` returns False for a value `unwritable_reason` refuses. A `tracking_branch` goes through `checked_value` as before, which refuses every such value. A visibility goes through VISIBILITY_CHOICES as before, which hold no such value. - `_plan_fields` now asks `_execute_gets_to_write` about each pin too. - TRACKING_KEY in `_checked_plan_values`, and the four key constants in WRITTEN_PLAN_VALUES, replace their literals (S1192). tests/test_adopt_plan_yaml_quoting.py: - New, no marker: each of these is one `plan-unwritable-value` finding, with no misread finding and no misread remediation: - `elected_on: "2026-09-02\nx"`; - `visibility: "private\nx"`; - a pin `"a\nb"`; - a pin `"openGlass\t"`. At e8e0026 the date and both pins failed this test. - New, with filter-repo: `execute` refuses the date as `plan-unwritable-value`, with no misread finding, before any leg exists. At e8e0026 it refused the date as `plan-manifest-misread`. - PIN_WITH_A_TRAIL's tab row (rv181f's F1) is now a no-break space. Since #255, a pin holding a tab is #255's finding alone, so the tab moved to the table above. #255 writes a no-break space, the loader drops it, and the line is shown as Python spells it, just as the tab's line was. - `branches_of` names the split-branch check once (S1192). The quoting file and tests/test_adopt_template_values.py: 519 passed. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- adopt-project.py | 35 +++++++------ tests/test_adopt_plan_yaml_quoting.py | 75 ++++++++++++++++++++++++--- 2 files changed, 89 insertions(+), 21 deletions(-) diff --git a/adopt-project.py b/adopt-project.py index c3bc1a0c..6d0b636e 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -1922,11 +1922,14 @@ def _execute_gets_to_write(key: str, value) -> bool: written, and no other is a visibility gh creates a repository with (rv181f's F2b). `check` is not told which of the two modes `execute` will run in, so such a visibility is asked about in neither. None of the - three choices is misread.""" + three choices is misread. Nor for any other value no file can hold + (`unwritable_reason`): `check` names it as `plan-unwritable-value`, and + `execute` refuses a plan holding it before any leg exists (#255), so one + wrong value is one finding, and none says the run gets further.""" if key == VISIBILITY_KEY: return str(value) in VISIBILITY_CHOICES if key not in GIT_ARGUMENT_KEYS: - return True + return unwritable_reason(str(value)) is None try: checked_value(key, value) except Refusal: @@ -1941,11 +1944,12 @@ def _plan_fields(plan: Plan) -> list[tuple]: A key the plan does not carry is left out: `execute` fills it with a default of this tool's own (`main`, `private`, today's date), and none of those is misread. So is a value `execute` never gets as far as writing - (`_execute_gets_to_write`): it refuses it, or `gh` does, first. A leg - path is asked about only when it is text with no `plan-bad-leg-path` of - its own, so one wrong value is one finding. An id `Plan.project_id` - refuses, or a refused leg name it is derived from, is refused on its own - account by `check` and `execute` alike. + (`_execute_gets_to_write`): it refuses it first, as `unsafe-value` or as + #255's `plan-unwritable-value`, or `gh` does. A leg path is asked about + only when it is text with no `plan-bad-leg-path` of its own, so one + wrong value is one finding. An id `Plan.project_id` refuses, or a + refused leg name it is derived from, is refused on its own account by + `check` and `execute` alike. """ fields: list[tuple] = [] try: @@ -1961,9 +1965,11 @@ def _plan_fields(plan: Plan) -> list[tuple]: if isinstance(value, str) and leg_path_problem(what, value) is None: fields.append(_field(what, placeholder, value)) # Each pin as the loader read it, which `Plan.pins` has already made text - # (`TRUE` would be named as `True`); the same items, in the same order. + # (`TRUE` would be named as `True`); the same items, in the same order, + # less one no file can hold, which is #255's finding alone. return fields + [_pin_field(PIN_IN_PLAN, pin) - for pin in (plan.data.get("pins") or []) if pin] + for pin in (plan.data.get("pins") or []) + if pin and _execute_gets_to_write("pins", pin)] def _checked_plan_inputs(args, source: Source, @@ -4065,8 +4071,7 @@ def _checked_plan_values(plan: Plan) -> tuple[str, str, str, str]: code_path = checked_value(*code_leg) branch = checked_value("adopt_branch", plan.get("adopt_branch", ADOPT_BRANCH)) - tracking = checked_value("tracking_branch", - plan.get("tracking_branch", "main")) + tracking = checked_value(TRACKING_KEY, plan.get(TRACKING_KEY, "main")) return spec_path, code_path, branch, tracking @@ -4642,10 +4647,10 @@ def cmd_execute(args) -> int: #: Each plan value `_template_values` substitutes as it stands, by its key, #: and where it lands. `pins` and `follow_ups` are lists, asked item by item. -WRITTEN_PLAN_VALUES = {"elected_by": ROOT_MANIFEST_AND_README, - "elected_on": ROOT_MANIFEST_AND_README, - "reference": ROOT_MANIFEST_AND_README, - "visibility": ROOT_MANIFEST} +WRITTEN_PLAN_VALUES = {ELECTED_BY_KEY: ROOT_MANIFEST_AND_README, + ELECTED_ON_KEY: ROOT_MANIFEST_AND_README, + REFERENCE_KEY: ROOT_MANIFEST_AND_README, + VISIBILITY_KEY: ROOT_MANIFEST} #: Why such a value must be text, said after every refusal of one. WRITTEN_AS_TEXT = ( diff --git a/tests/test_adopt_plan_yaml_quoting.py b/tests/test_adopt_plan_yaml_quoting.py index bfe2f5ef..2fb076c2 100644 --- a/tests/test_adopt_plan_yaml_quoting.py +++ b/tests/test_adopt_plan_yaml_quoting.py @@ -347,6 +347,8 @@ def test_a_value_is_asked_about_in_each_place_it_is_written( DATE_DIGITS = "20260902" #: How `check` and `execute` begin the line about each one. MISREAD_FINDING = "FINDING plan-manifest-misread: " +#: How `check` begins #255's line about a value no file can hold. +UNWRITABLE_FINDING = "FINDING plan-unwritable-value: " #: The plan keys the rows below name, and the flag `plan` takes each as. SPEC_PATH_KEY = "legs.spec_path" TRACKING_KEY = "tracking_branch" @@ -354,6 +356,7 @@ def test_a_value_is_asked_about_in_each_place_it_is_written( ELECTED_BY_KEY = "elected_by" ELECTED_ON_KEY = "elected_on" REFERENCE_KEY = "reference" +VISIBILITY_KEY = "visibility" SPEC_FLAG = "--spec-path" TRACKING_FLAG = "--tracking-branch" PIN_FLAG = "--pin" @@ -441,6 +444,11 @@ def executed(plan: Path, tmp_path: Path): "--work-dir", str(tmp_path / "work")) +def branches_of(source: Path) -> str: + """The source's branches: no split branch until `execute` commits one.""" + return git("branch", "--list", cwd=source).stdout + + def planned(tmp_path: Path, *extra: str, tree: dict | None = None) -> Path: source = make_source_repo(tmp_path / SOURCE_NAME, tree=tree) plan = tmp_path / PLAN_FILE @@ -667,7 +675,7 @@ def test_check_names_a_plan_value_that_is_not_text_by_what_was_read( #: (rv181f's F2, F2b). NEVER_WRITTEN = [ pytest.param(TRACKING_KEY, QUOTED_DOMAIN_USER, id=TRACKING_KEY), - pytest.param("visibility", '"true"', id="visibility"), + pytest.param(VISIBILITY_KEY, '"true"', id=VISIBILITY_KEY), ] @@ -702,7 +710,59 @@ def test_execute_refuses_that_branch_as_unsafe_before_any_leg_exists( assert result.stderr.startswith( f"REFUSED unsafe-value: {TRACKING_KEY} is "), result.stderr assert not (tmp_path / REMOTES).exists() - assert SPLIT_BRANCH not in git("branch", "--list", cwd=source).stdout + assert SPLIT_BRANCH not in branches_of(source) + + +#: A date with a line break in it, as a plan edited by hand spells it. +UNWRITABLE_DATE = '"2026-09-02\\nx"' +#: `(plan key, as a plan edited by hand spells it)`: values no file +#: `execute` writes can hold, which #255 names as `plan-unwritable-value`: +#: a line break, in a value read back as one line, and a control character +#: such as a tab. The date and the pins are written bare, where the +#: project's loader would read each as something else too; the visibility +#: is one `gh` takes no flag for (`NEVER_WRITTEN`). +UNWRITABLE = [ + pytest.param(ELECTED_ON_KEY, UNWRITABLE_DATE, id=ELECTED_ON_KEY), + pytest.param(VISIBILITY_KEY, '"private\\nx"', id=VISIBILITY_KEY), + pytest.param(PINS_KEY, '"a\\nb"', id="pins-line-break"), + pytest.param(PINS_KEY, '"openGlass\\t"', id="pins-tab"), +] + + +@pytest.mark.parametrize("key, spelled", UNWRITABLE) +def test_check_names_a_value_no_file_can_hold_once_as_unwritable( + adopter, answered, tmp_path, key, spelled): + """At the merge of #255 the gate also called the date and the pin + misread, under a remediation saying the run would still end `adoption + verified`: one wrong value was two findings, and the second was false, + because `execute` refuses the value before it writes anything. It is + #255's finding alone.""" + _, text = answered + plan = tmp_path / PLAN_FILE + carrying(plan, text, key, spelled) + result = checked(plan) + assert result.returncode == 1, result.stdout + result.stderr + assert result.stderr.count(UNWRITABLE_FINDING) == 1, result.stderr + assert MISREAD_FINDING not in result.stderr, result.stderr + assert adopter.MANIFEST_MISREAD_REMEDIATION not in result.stderr + + +@needs_filter_repo +def test_execute_refuses_a_date_no_file_can_hold_as_unwritable_not_misread( + answered, tmp_path): + """`execute` refuses a plan under the code of `check`'s first finding, + which for this date was the misread one: it is refused as what it is, + before any leg exists.""" + source, text = answered + plan = tmp_path / PLAN_FILE + carrying(plan, text, ELECTED_ON_KEY, UNWRITABLE_DATE) + result = executed(plan, tmp_path) + assert result.returncode == 2, result.stdout + result.stderr + assert result.stderr.startswith( + "REFUSED plan-unwritable-value: "), result.stderr + assert MISREAD_FINDING not in result.stderr, result.stderr + assert not (tmp_path / REMOTES).exists() + assert SPLIT_BRANCH not in branches_of(source) #: `(placeholder, value, the manifest line `execute` writes, what the loader @@ -751,11 +811,14 @@ def test_text_the_loader_reads_as_other_text_is_shown_as_typed_and_read( #: `(a hand-edited plan's pin, the item `execute` writes for it, as a #: sentence shows that line)`. `execute` writes a pin into its ` - ` item as #: it stands, so the whitespace after it stays in the line, where the loader -#: drops it. A space shows between the backticks; a tab makes the line one a -#: terminal would not show as it is, so Python spells it. +#: drops it. A space shows between the backticks; a no-break space makes the +#: line one a terminal would not show as it is, so Python spells it. Not a +#: tab since #255: no file `execute` writes holds a control character, and a +#: pin holding one is #255's finding alone (`UNWRITABLE`). PIN_WITH_A_TRAIL = [ pytest.param('"openGlass "', "`- openGlass `", id="space"), - pytest.param('"openGlass\\t"', "'- openGlass\\t'", id="tab"), + pytest.param('"openGlass\\xa0"', "'- openGlass\\xa0'", + id="no-break-space"), ] @@ -849,7 +912,7 @@ def test_execute_refuses_a_plan_carrying_one_before_any_leg_exists( assert what in result.stderr assert adopter.MANIFEST_MISREAD_REMEDIATION in result.stderr assert not (tmp_path / REMOTES).exists() - assert SPLIT_BRANCH not in git("branch", "--list", cwd=source).stdout + assert SPLIT_BRANCH not in branches_of(source) #: What the gate still lets through, each spelled like a keyword somewhere: From 7ac23587ce02f481a07f1e95ad7500bb8028fa95 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Mon, 12 Oct 2026 19:56:11 +0000 Subject: [PATCH 12/24] adopt-project.py: #181's misread gate and #261's validator judge each plan value once: the validator is not asked about a value #181 reported, and is asked about every other, so `id: "007"` is one finding and not two, and `visibility: true` or `[secret]` is one and not none At the merge of main dd8d0ea (4c6e856), `_entry_findings` called `_manifest_findings(plan)` as main does. The two rules then disagreed: - **Two findings for one value.** `id: "007"`, `id: "null"` and `elected_on: "20260902"` were both #181's `plan-manifest-misread` and the validator's `plan-manifest-invalid`. Main's own `test_a_text_value_the_reader_misreads_is_one_finding_in_the_list_check_prints` failed for two of its rows. - **No finding for one value.** #181 does not judge a `visibility` outside the three choices (rv181f's F2b). Main's validator left every value the plan reads as not text to "the misread rule". So `visibility: true`, `[secret]` and `{a: b}` got no finding at all. - **The trap.** Main's own hook, `misreads_elsewhere=True`, would have dropped `visibility: "true"` too: it leaves out every value the reader misreads, not the values #181 reported. The composition: - `_plan_fields` returns each field with the placeholder it is about. - `_misread_findings` returns #181's FINDINGs and the placeholders they report. - `_manifest_findings(plan, reported)` leaves out exactly those placeholders and asks the validator about every other value. - A value the plan reads as not text is named by what was read (`_named_as_read`): "visibility, read out of the plan as the boolean true, is 'True', which the project's own validate-manifest.py refuses: manifest-visibility: visibility is True, ...". The validator's sentence quotes `str` of the value, which the plan never said (review181's N2). - `_execute_gets_to_write`'s docstring now says the validator names such a visibility in either mode. `--local-remote-dir` no longer writes it unasked. - `misread_placeholders` is no longer imported. - MANIFEST_PLAN_KEYS and MANIFEST_FLAGS are derived from #181's ID_PLACEHOLDER and MANIFEST_FIELDS, so each placeholder and each key is spelled once (S1192: the merge had made each placeholder 4 uses against main's 3). Probe of `check` over 26 edited values, before and after: - Every misread or invalid value is now exactly one finding. - A value #255 refuses (a line break) is still listed by #255 and the validator both, as main's `_entry_findings` docstring rules (#255 before #261). - A `tracking_branch` that `checked_value` refuses is still no finding in `check`, and `execute` refuses it as `unsafe-value`, as on main. Tests: - tests/test_adopt_plan_yaml_quoting.py - New, no marker: JUDGED_ONCE, 8 values. Each is one finding from the rule that is true of it. - New, no marker: NOT_TEXT_VISIBILITY, 2 cases, covering the naming. - New, with filter-repo: `execute` refuses `visibility: "true"` under the validator's code before any leg exists. - CARRIED's `check` and `execute` tests and READ_AS_NOT_TEXT now also assert no `plan-manifest-invalid`. - EDITED_ONLY's comment: since #261, `plan` refuses `--elected-on 20260902` on every Python. - tests/test_adopt_manifest_values.py (main's) - Three calls move from `misreads_elsewhere=True` to `reported`. - `visibility: [secret]` leaves `test_a_value_the_reader_reads_as_another_kind_is_not_this_questions`: it is the validator's now, named by what was read (JUDGED_ONCE). The quoting file, tests/test_adopt_manifest_values.py and tests/test_cli_reference.py: 543 passed. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- adopt-project.py | 158 ++++++++++++++++---------- tests/test_adopt_manifest_values.py | 43 +++---- tests/test_adopt_plan_yaml_quoting.py | 118 +++++++++++++++++-- 3 files changed, 233 insertions(+), 86 deletions(-) diff --git a/adopt-project.py b/adopt-project.py index abf6de73..05dc90d2 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -165,7 +165,7 @@ # asked of the plan's values by `plan`, `check` and `execute` (#261). from shape_materialize import ( # noqa: E402 MANIFEST_INVALID_REMEDIATION, manifest_value_problems, - misread_placeholders, refuse_manifest_values, refuse_unstorable_names, + 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. @@ -1830,8 +1830,10 @@ def _kept_collision_notes(args, entries: list, tree: list) -> list[str]: #: The two other plan keys `_execute_gets_to_write` asks about by name. TRACKING_KEY = "tracking_branch" VISIBILITY_KEY = "visibility" -#: The placeholder `execute` writes the project's id through. +#: The placeholder `execute` writes the project's id through, and the one it +#: writes the pins' list through. ID_PLACEHOLDER = "PROJECT_ID" +PINS_PLACEHOLDER = "NEUTRAL_PRODUCT_PINS" #: Each plan value `execute` writes into those files, by its key in the plan #: (and `plan`'s flag of the same name, dashed) and the `_template_values` @@ -2060,13 +2062,16 @@ def _execute_gets_to_write(key: str, value) -> bool: a `git` argument `checked_value` refuses: `_checked_plan_values` refuses it as `unsafe-value` before any leg exists, so a sentence saying `execute` writes it, and that the run would end `adoption verified`, - would be false (rv181f's F2). Nor, outside `--local-remote-dir`, for a - visibility that is not one of `VISIBILITY_CHOICES`: `_create_leg_remotes` - hands it to `gh repo create` as `--` before anything is - written, and no other is a visibility gh creates a repository with - (rv181f's F2b). `check` is not told which of the two modes `execute` will - run in, so such a visibility is asked about in neither. None of the - three choices is misread. Nor for any other value no file can hold + would be false (rv181f's F2). Nor for a visibility that is not one of + `VISIBILITY_CHOICES`, text or not. Outside `--local-remote-dir` + `_create_leg_remotes` would hand it to `gh repo create` as + `--` before anything is written, and no other is a + visibility gh creates a repository with (rv181f's F2b); `check` is not + told which mode `execute` will run in. It is the project's own + validator's to name instead, in either mode (`manifest-visibility`, + #261, `_manifest_findings`), and `execute` refuses a plan holding it + before anything exists. None of the three choices is misread. Nor for + any other value no file can hold (`unwritable_reason`): `check` names it as `plan-unwritable-value`, and `execute` refuses a plan holding it before any leg exists (#255), so one wrong value is one finding, and none says the run gets further.""" @@ -2081,9 +2086,11 @@ def _execute_gets_to_write(key: str, value) -> bool: return True -def _plan_fields(plan: Plan) -> list[tuple]: +def _plan_fields(plan: Plan) -> list[tuple[str, tuple]]: """A plan's values as its loader read them, each named by its key in the - plan, with the lines `_template_values` would write them into. + plan, with the lines `_template_values` would write them into: `(the + placeholder that carries it, the field)`, so that what a sentence is + about can be told to the validator (`_misread_findings`). A key the plan does not carry is left out: `execute` fills it with a default of this tool's own (`main`, `private`, today's date), and none of @@ -2095,27 +2102,43 @@ def _plan_fields(plan: Plan) -> list[tuple]: refused leg name it is derived from, is refused on its own account by `check` and `execute` alike. """ - fields: list[tuple] = [] + fields: list[tuple[str, tuple]] = [] try: - fields.append(_id_field(plan)) + fields.append((ID_PLACEHOLDER, _id_field(plan))) except Refusal: pass - fields += [_field(key, placeholder, plan.get(key)) + fields += [(placeholder, _field(key, placeholder, plan.get(key))) for key, placeholder in MANIFEST_FIELDS.items() if plan.get(key) is not None and _execute_gets_to_write(key, plan.get(key))] for (what, value), placeholder in zip(_plan_leg_paths(plan), LEG_PATH_PLACEHOLDERS.values()): if isinstance(value, str) and leg_path_problem(what, value) is None: - fields.append(_field(what, placeholder, value)) + fields.append((placeholder, _field(what, placeholder, value))) # Each pin as the loader read it, which `Plan.pins` has already made text # (`TRUE` would be named as `True`); the same items, in the same order, # less one no file can hold, which is #255's finding alone. - return fields + [_pin_field(PIN_IN_PLAN, pin) + return fields + [(PINS_PLACEHOLDER, _pin_field(PIN_IN_PLAN, pin)) for pin in (plan.data.get("pins") or []) if pin and _execute_gets_to_write("pins", pin)] +def _misread_findings(plan: Plan) -> tuple[list[str], tuple[str, ...]]: + """`check`'s FINDING for each plan value the project's loader would + misread (#181), and the placeholders they are about, so that the + project's own validator, asked in the same list, is not asked about + those again (`_manifest_findings`): one wrong value is one finding.""" + found: list[str] = [] + reported: list[str] = [] + for placeholder, field in _plan_fields(plan): + problems = manifest_misreads([field]) + found += [f"FINDING plan-manifest-misread: {problem}." + for problem in problems] + if problems: + reported.append(placeholder) + return found, tuple(reported) + + def _checked_plan_inputs(args, source: Source, naming: NamingPolicy) -> tuple[dict, list]: """Every value the plan will RECORD, defaulted and checked before one of @@ -2196,9 +2219,11 @@ def _checked_plan_inputs(args, source: Source, names = _names(args.project) pins = [_checked_pin_name(p) for p in (args.pin or []) if p.strip()] _check_names(naming, names, set(pins)) - # LAST, over every value as the plan will record it: `execute` writes - # them into the project's own YAML, and one its loader would read as - # something else is refused before the plan exists (#181). + # LAST here, over every value as the plan will record it: `execute` + # writes them into the project's own YAML, and one its loader would read + # as something else is refused before the plan exists (#181). The + # project's own validator is asked next (`_refuse_unholdable_values`, + # #261), of values that read back. misread = manifest_misreads(_flag_fields(args, given, pins)) if misread: raise Refusal("adopt-manifest-misread", @@ -2232,27 +2257,26 @@ def _checked_plan_inputs(args, source: Source, # `--id true`, `--tracking-branch 007` -- is #181's, and the scaffold asks # both. -#: How `plan` names each value the manifest records: the flag a person typed. -MANIFEST_FLAGS = { - "PROJECT_ID": "--id", "ELECTED_BY": "--elected-by", - "ELECTED_ON": "--elected-on", "REFERENCE": "--reference", - "VISIBILITY": "--visibility", "TRACKING_BRANCH": "--tracking-branch", - "ORG": "--org", +#: How `check` and `execute` name each value the manifest records: its key +#: in the plan. The id's placeholder and `MANIFEST_FIELDS` are #181's, so +#: that each placeholder and each key is spelled once; the organisation is a +#: value only the validator is asked about. +MANIFEST_PLAN_KEYS = { + ID_PLACEHOLDER: "id", + **{placeholder: key for key, placeholder in MANIFEST_FIELDS.items()}, + "ORG": "org", } +#: And how `plan` names the same values: the flag a person typed, which is +#: the key, dashed, as `_flag_fields` spells #181's. +MANIFEST_FLAGS = {placeholder: "--" + key.replace("_", "-") + for placeholder, key in MANIFEST_PLAN_KEYS.items()} + #: The validator's findings about a leg's mount path, which `check` already #: reports as `plan-bad-leg-path` and `plan-leg-path-collides` from the same #: list (`_leg_findings`): said once, in the words of the rule that judges it. LEG_PATH_CODES = ("manifest-leg-path", "manifest-leg-path-collision") -#: And how `check` and `execute` name the same value: its key in the plan. -MANIFEST_PLAN_KEYS = { - "PROJECT_ID": "id", "ELECTED_BY": "elected_by", - "ELECTED_ON": "elected_on", "REFERENCE": "reference", - "VISIBILITY": "visibility", "TRACKING_BRANCH": "tracking_branch", - "ORG": "org", -} - def _flag_plan(args, names: dict, pins: list) -> "Plan": """The plan `plan` is about to write, as `Plan` reads one: the values it @@ -2326,40 +2350,36 @@ def _manifest_values(plan: Plan, naming: NamingPolicy) -> dict | None: _VALIDATION_SHAPE_COMMIT, _VALIDATION_SHAPE_DIGEST, pins, naming) -def _manifest_findings(plan: Plan, misreads_elsewhere: bool = False +def _manifest_findings(plan: Plan, reported: tuple[str, ...] = () ) -> tuple[list[str], list[str]]: """What the project's own validator would refuse of this plan's values, as `check` prints them (one FINDING each), and what it prints once after them. `[]` and `[]` when it would pass, or when the plan's names are refused in their own words (`_manifest_values`). - `misreads_elsewhere` is for a caller whose list ALSO holds a misread - rule's finding for each value the project's reader reads back as another - type (`elected_on: "20261011"`, `visibility: "true"`, `id: "007"`: text in - the plan, a number or a boolean to the reader once the template writes it - bare), so that such a value is judged once and not twice. It is False - wherever nothing else asks that question, so that this rule alone still - refuses the value, with the validator's finding as the only one. Who is - left out is the reader's answer (`shape_materialize.manifest_misreads`, - the rule the scaffold applies) and not a list. + `reported` is the placeholders whose value a misread rule in the same + list has ALREADY reported (#181's `plan-manifest-misread`, which + `_misread_findings` returns them with): the validator's finding about + such a value is left out, so that it is judged once and not twice + (`elected_on: "20261011"`, `id: "007"`). Empty wherever nothing else + asks that question, so that this rule alone still refuses the value. + + EVERY OTHER VALUE IS ASKED, ONE THE PLAN'S READER READ AS NOT TEXT + INCLUDED, so that no value is left to a rule that does not judge it. The + misread rule judges what the loader would misread of what `execute` + gets as far as writing, and nothing else (`_execute_gets_to_write`): a + `visibility` outside the three choices, `true` and `[secret]` among + them, is this rule's. Such a value is named by what was read + (`_named_as_read`), because the sentence goes on to quote `str` of it, + the text `execute` would write and not what the plan says. """ naming = NamingPolicy.load(NAMING_POLICY) values = _manifest_values(plan, naming) if values is None: return [], [] - # A value the plan's reader read as a number, a boolean or a collection is - # a MISREAD (`id: 007` is 7), and what the template would then write is - # `str` of what was read, which the plan never said: it is not asked about - # here, in words that would echo that text. - not_text = tuple( - placeholder for placeholder, key in MANIFEST_PLAN_KEYS.items() - if plan.get(key) is not None - and not isinstance(plan.get(key), str)) - if misreads_elsewhere: - not_text += misread_placeholders(values, MANIFEST_PLAN_KEYS) - problems = manifest_value_problems(values, naming, MANIFEST_PLAN_KEYS, + problems = manifest_value_problems(values, naming, _named_as_read(plan), misreads=False, ignore=LEG_PATH_CODES, - not_text=not_text) + not_text=tuple(reported)) if not problems: return [], [] return ([f"FINDING plan-manifest-invalid: {sentence}" @@ -2370,6 +2390,21 @@ def _manifest_findings(plan: Plan, misreads_elsewhere: bool = False "a value the validator accepts."]) +def _named_as_read(plan: Plan) -> dict[str, str]: + """How a sentence of the validator's names each value: by its key in the + plan, and a value the plan's reader read as something that is not text + by what was read too (review181's N2), as in `visibility, read out of + the plan as the boolean true, is 'True'`. A value the plan holds as + text, or as no value at all, is named by its key alone.""" + named = {} + for placeholder, key in MANIFEST_PLAN_KEYS.items(): + value = plan.get(key) + named[placeholder] = ( + key if value is None or isinstance(value, str) else + f"{key}, read out of the plan as {_the_loader_made(value)},") + return named + + def _refuse_unholdable_values(args, names: dict, pins: list, naming: NamingPolicy) -> None: """`plan` refuses, before it writes a file, what `check` would find in the @@ -3862,6 +3897,14 @@ def _entry_findings(plan: Plan, source: Source, which is the value's consequence; the byte, the break or the control is what the person retypes. Both are listed, and both refuse before any leg exists. The relative urls of #190 still come after both, last. + + A VALUE #181 CALLS MISREAD IS #181'S ALONE. Its `plan-manifest-misread` + names what the loader would read back, and the validator is not asked + about that value again: `id: "007"` is not also `manifest-id: id is 7`. + The validator is asked about every other value, a `visibility` outside + the three choices among them, text or not, which #181 does not judge + (`_execute_gets_to_write`). So the two never both name one value + (`_manifest_findings`). """ tree = source.tree() unsafe, unsafe_remediation = _unsafe_path_findings(plan) @@ -3870,12 +3913,11 @@ def _entry_findings(plan: Plan, source: Source, for e in plan.entries], [path for path, _, _, _ in tree]) + _leg_findings(plan, tree)) - misread = [f"FINDING plan-manifest-misread: {problem}." - for problem in manifest_misreads(_plan_fields(plan))] + misread, reported = _misread_findings(plan) (split, split_remediation), (moved, moved_remediation), notes = \ _submodule_findings(plan, source, tree, local_remote_dir) unwritable_findings, unwritable_remediation = _value_findings(plan) - value_findings, value_remediation = _manifest_findings(plan) + value_findings, value_remediation = _manifest_findings(plan, reported) return (findings + misread + split + unwritable_findings + value_findings + moved, unsafe_remediation diff --git a/tests/test_adopt_manifest_values.py b/tests/test_adopt_manifest_values.py index 491ada1d..11355f49 100644 --- a/tests/test_adopt_manifest_values.py +++ b/tests/test_adopt_manifest_values.py @@ -448,8 +448,8 @@ def no_process(*args, **kwargs): monkeypatch.setattr(os, "system", no_process) assert adopter._manifest_values(good, naming) is not None assert adopter._manifest_findings(good) == ([], []) - assert adopter._manifest_findings(good, misreads_elsewhere=True) == ( - [], []) + assert adopter._manifest_findings( + good, tuple(adopter.MANIFEST_PLAN_KEYS)) == ([], []) (finding,), remediation = adopter._manifest_findings(refused) assert "visibility is 'secret'" in finding assert remediation @@ -641,18 +641,18 @@ def test_a_bad_leg_path_is_one_finding_in_the_words_of_its_own_rule(world, @pytest.mark.parametrize("old, new", [ - ("visibility: private", "visibility: [secret]"), ('elected_on: "2026-09-02"', "elected_on: 20260902"), ("id: northwind", "id: 007"), ]) def test_a_value_the_reader_reads_as_another_kind_is_not_this_questions( world, old, new): - """`id: 007` is the number 7, `elected_on: 20260902` another number and - `visibility: [secret]` a list: what the template would write is `str` of - what was read, which the plan never said, so a finding about it would - echo text nobody typed. That is a misread and is judged as one; the - validator is asked about the values the plan holds as text, and as no - value at all.""" + """`id: 007` is the number 7 and `elected_on: 20260902` another number: + what the template would write is `str` of what was read, which the plan + never said. That is a misread, #181's `plan-manifest-misread` names it + by what was read, and the validator is not asked about it again. A + `visibility` outside the three choices is not here: #181 does not judge + it, so it is the validator's, text or not, named by what was read + (tests/test_adopt_plan_yaml_quoting.py, `JUDGED_ONCE`).""" plan = edited(world, old, new) result = run_script(ADOPT, "check", "--plan", str(plan)) assert FINDING not in result.stderr, result.stderr @@ -723,13 +723,15 @@ def test_a_text_value_the_reader_misreads_is_one_finding_on_this_rule_alone( @pytest.mark.parametrize("old, new, named", TEXT_THE_READER_MISREADS) def test_where_a_misread_rule_judges_it_the_validator_is_not_asked_again( adopter, world, old, new, named): - """`misreads_elsewhere`: a list that holds the misread rule's finding for - such a value (#181's `plan-manifest-misread`) is not given a second one - about the same value, in the validator's words and with the retyped text + """`reported`: a list that holds the misread rule's finding for such a + value (#181's `plan-manifest-misread`) is not given a second one about + the same value, in the validator's words and with the retyped text (`id is 7`) that the misread finding exists to avoid.""" plan = adopter.Plan.load(edited(world, old, new)) - assert adopter._manifest_findings(plan, misreads_elsewhere=True) == ( - [], []) + key = new.split(":", 1)[0] + placeholder = next(placeholder for placeholder, named + in adopter.MANIFEST_PLAN_KEYS.items() if named == key) + assert adopter._manifest_findings(plan, (placeholder,)) == ([], []) def test_a_value_that_is_not_a_misread_is_still_asked_where_one_judges_the_rest( @@ -745,7 +747,7 @@ def test_a_value_that_is_not_a_misread_is_still_asked_where_one_judges_the_rest( handle.write(text) plan = adopter.Plan.load(path) alone, _ = adopter._manifest_findings(plan) - shared, _ = adopter._manifest_findings(plan, misreads_elsewhere=True) + shared, _ = adopter._manifest_findings(plan, ("PROJECT_ID",)) assert len(alone) == 3, alone assert len(shared) == 2, shared assert any("visibility is 'secret'" in f for f in shared) @@ -757,10 +759,13 @@ def test_a_value_that_is_not_a_misread_is_still_asked_where_one_judges_the_rest( def test_a_text_value_the_reader_misreads_is_one_finding_in_the_list_check_prints( adopter, world, old, new, named): """NOT ZERO AND NOT TWO, whatever rules the list holds. This rule alone, - `_entry_findings` does not pass `misreads_elsewhere`, and the validator's - finding is the one. With #181 in the list as well, the call there must pass - it, or this is the pair the two rules each make of one value: this test - fails on a merge that has not.""" + `_entry_findings` passes no `reported`, and the validator's finding is + the one. With #181 in the list as well, the call there passes the + placeholders #181 reported, or this is the pair the two rules each make + of one value: this test fails on a merge that has not. `visibility: + "true"` is #181's to leave alone (it does not judge a visibility outside + the three choices), so it must not be passed for it either, or it is + zero.""" plan = adopter.Plan.load(edited(world, old, new)) source = plan.open_source(str(world["source"]), world["tmp"] / "read") findings, _, _ = adopter._entry_findings(plan, source) diff --git a/tests/test_adopt_plan_yaml_quoting.py b/tests/test_adopt_plan_yaml_quoting.py index 2fb076c2..638bed63 100644 --- a/tests/test_adopt_plan_yaml_quoting.py +++ b/tests/test_adopt_plan_yaml_quoting.py @@ -349,6 +349,11 @@ def test_a_value_is_asked_about_in_each_place_it_is_written( MISREAD_FINDING = "FINDING plan-manifest-misread: " #: How `check` begins #255's line about a value no file can hold. UNWRITABLE_FINDING = "FINDING plan-unwritable-value: " +#: How `check` begins #261's line about a value the project's own +#: validate-manifest.py refuses. +INVALID_FINDING = "FINDING plan-manifest-invalid: " +#: `true` as text, as a plan edited by hand quotes it. +TRUE_TEXT = '"true"' #: The plan keys the rows below name, and the flag `plan` takes each as. SPEC_PATH_KEY = "legs.spec_path" TRACKING_KEY = "tracking_branch" @@ -412,10 +417,12 @@ def test_a_value_is_asked_about_in_each_place_it_is_written( (TRACKING_FLAG, "007", TRACKING_KEY, SEVEN_READ), ] #: `(plan key, value, what the loader reads)` that only an edited plan can -#: carry here: `plan` reads `--elected-on 20260902` as a date only on Python -#: 3.11 and later. No visibility is here: `plan` takes one from a fixed -#: choice, and `execute` hands any other to `gh repo create` before it -#: writes anything (`NEVER_WRITTEN`). +#: carry here: `plan` refuses `--elected-on 20260902` as +#: `election-date-malformed` on every Python since #261, which holds the date +#: to the validator's own `YYYY-MM-DD`. The validator refuses it too +#: (`manifest-elected-on`), and `check` names it once, as the misread it is +#: (`JUDGED_ONCE`). No visibility is here: `plan` takes one from a fixed +#: choice, and any other is the validator's (`JUDGED_ONCE`). EDITED_ONLY = [ (ELECTED_ON_KEY, DATE_DIGITS, f"the number {DATE_DIGITS}"), ] @@ -576,7 +583,9 @@ def test_plan_takes_an_elector_from_git_the_manifests_quotes_read_back( def test_check_reports_a_plan_carrying_one(adopter, answered, tmp_path, key, value, reading): """A plan carrying the value -- spelled by the plan writer, so the plan - itself reads it back -- is a FINDING, with the remediation once after.""" + itself reads it back -- is a FINDING, with the remediation once after. + It is #181's alone: the project's own validator is not asked about it + again (#261), though it refuses `id: "true"` and the date too.""" _, text = answered plan = tmp_path / PLAN_FILE carrying(plan, text, key, adopter.y(value)) @@ -586,6 +595,7 @@ def test_check_reports_a_plan_carrying_one(adopter, answered, tmp_path, key, assert f"{MISREAD_FINDING}{who}" in result.stderr assert what in result.stderr, result.stderr assert result.stderr.count(adopter.MANIFEST_MISREAD_REMEDIATION) == 1 + assert INVALID_FINDING not in result.stderr, result.stderr assert "plan ok" not in result.stdout @@ -665,6 +675,7 @@ def test_check_names_a_plan_value_that_is_not_text_by_what_was_read( assert (f"reads it back as {read_as} and not as the text `{written}`: " "choose another value.") in result.stderr, result.stderr assert f"{named} is " not in result.stderr, "`str` of it was echoed" + assert INVALID_FINDING not in result.stderr, result.stderr #: `(plan key, as a plan edited by hand spells it)`: values the project's @@ -672,10 +683,11 @@ def test_check_names_a_plan_value_that_is_not_text_by_what_was_read( #: as `unsafe-value` (`_checked_plan_values`), and `gh repo create` takes no #: `--true`, before anything is written. A finding would say the value is #: written and the run ends `adoption verified`: for these neither is true -#: (rv181f's F2, F2b). +#: (rv181f's F2, F2b). Since #261 the visibility is the project's own +#: validator's finding instead (`JUDGED_ONCE`). NEVER_WRITTEN = [ pytest.param(TRACKING_KEY, QUOTED_DOMAIN_USER, id=TRACKING_KEY), - pytest.param(VISIBILITY_KEY, '"true"', id=VISIBILITY_KEY), + pytest.param(VISIBILITY_KEY, TRUE_TEXT, id=VISIBILITY_KEY), ] @@ -720,7 +732,9 @@ def test_execute_refuses_that_branch_as_unsafe_before_any_leg_exists( #: a line break, in a value read back as one line, and a control character #: such as a tab. The date and the pins are written bare, where the #: project's loader would read each as something else too; the visibility -#: is one `gh` takes no flag for (`NEVER_WRITTEN`). +#: is not one #181 judges (`NEVER_WRITTEN`). The project's own validator +#: lists the manifest such a value makes beside #255's finding, as main has +#: it (#255 before #261, `_entry_findings`). UNWRITABLE = [ pytest.param(ELECTED_ON_KEY, UNWRITABLE_DATE, id=ELECTED_ON_KEY), pytest.param(VISIBILITY_KEY, '"private\\nx"', id=VISIBILITY_KEY), @@ -736,7 +750,7 @@ def test_check_names_a_value_no_file_can_hold_once_as_unwritable( misread, under a remediation saying the run would still end `adoption verified`: one wrong value was two findings, and the second was false, because `execute` refuses the value before it writes anything. It is - #255's finding alone.""" + #255's finding, once, and never #181's.""" _, text = answered plan = tmp_path / PLAN_FILE carrying(plan, text, key, spelled) @@ -765,6 +779,91 @@ def test_execute_refuses_a_date_no_file_can_hold_as_unwritable_not_misread( assert SPLIT_BRANCH not in branches_of(source) +#: `(plan key, as a plan edited by hand spells it, the one finding `check` +#: prints about it)`: a value of each kind #181's gate and #261's validator +#: (`plan-manifest-invalid`) divide between them. Text the loader misreads is +#: #181's, though the validator refuses it too; text it reads back is the +#: validator's; a value read as not text is #181's where #181 asks about it, +#: and the validator's where it does not, as for a visibility outside the +#: three choices, text or not (`_execute_gets_to_write`). +JUDGED_ONCE = [ + pytest.param(ELECTED_ON_KEY, f'"{DATE_DIGITS}"', MISREAD_FINDING, + id="date-text-misread"), + pytest.param("id", '"007"', MISREAD_FINDING, id="id-text-misread"), + pytest.param(ELECTED_ON_KEY, DATE_DIGITS, MISREAD_FINDING, + id="date-read-as-a-number"), + pytest.param(ELECTED_BY_KEY, '" "', INVALID_FINDING, id="elector-blank"), + pytest.param(VISIBILITY_KEY, "secret", INVALID_FINDING, + id="visibility-secret"), + pytest.param(VISIBILITY_KEY, TRUE_TEXT, INVALID_FINDING, + id="visibility-text-true"), + pytest.param(VISIBILITY_KEY, "true", INVALID_FINDING, + id="visibility-boolean"), + pytest.param(VISIBILITY_KEY, "[secret]", INVALID_FINDING, + id="visibility-list"), +] + + +@pytest.mark.parametrize("key, spelled, finding", JUDGED_ONCE) +def test_check_judges_each_value_once_by_the_rule_true_of_it( + answered, tmp_path, key, spelled, finding): + """At the merge of main dd8d0ea, `id: "007"` and `elected_on: + "20260902"` were two findings each, #181's and the validator's, and a + visibility that is not text was none: #181 does not judge it, and the + validator left every value read as not text to #181. Each is one.""" + _, text = answered + plan = tmp_path / PLAN_FILE + carrying(plan, text, key, spelled) + result = checked(plan) + assert result.returncode == 1, result.stdout + result.stderr + judged = [line for line in result.stderr.splitlines() + if line.startswith((MISREAD_FINDING, INVALID_FINDING))] + assert len(judged) == 1, result.stderr + assert judged[0].startswith(finding), result.stderr + + +#: `(a visibility as a plan edited by hand spells it, how the validator's +#: sentence names it)`: read as not text, it is named by what was read, as +#: #181 names such a value, before the text `execute` would write for it. +NOT_TEXT_VISIBILITY = [ + pytest.param("true", "visibility, read out of the plan as the boolean " + "true, is 'True', which", id="boolean"), + pytest.param("[secret]", "visibility, read out of the plan as a list, " + "is \"['secret']\", which", id="list"), +] + + +@pytest.mark.parametrize("spelled, named", NOT_TEXT_VISIBILITY) +def test_the_validator_names_a_value_read_as_not_text_by_what_was_read( + answered, tmp_path, spelled, named): + """Its sentence quotes `str` of the value, the text `execute` would + write: `visibility is 'True'` alone, for a plan that says `true`, would + name text the plan never said (review181's N2).""" + _, text = answered + plan = tmp_path / PLAN_FILE + carrying(plan, text, VISIBILITY_KEY, spelled) + result = checked(plan) + assert (f"{INVALID_FINDING}{named} the project's own validate-manifest.py " + "refuses: manifest-visibility: ") in result.stderr, result.stderr + + +@needs_filter_repo +def test_execute_refuses_a_visibility_it_never_writes_as_the_validators( + answered, tmp_path): + """`visibility: "true"`: #181 leaves it to the validator, and `execute` + refuses it under the validator's code before any leg exists.""" + source, text = answered + plan = tmp_path / PLAN_FILE + carrying(plan, text, VISIBILITY_KEY, TRUE_TEXT) + result = executed(plan, tmp_path) + assert result.returncode == 2, result.stdout + result.stderr + assert result.stderr.startswith( + "REFUSED plan-manifest-invalid: "), result.stderr + assert MISREAD_FINDING not in result.stderr, result.stderr + assert not (tmp_path / REMOTES).exists() + assert SPLIT_BRANCH not in branches_of(source) + + #: `(placeholder, value, the manifest line `execute` writes, what the loader #: reads it back as)`: values the loader reads back as OTHER TEXT, each #: through a placeholder the template writes bare. The double quotes in the @@ -911,6 +1010,7 @@ def test_execute_refuses_a_plan_carrying_one_before_any_leg_exists( assert f"{MISREAD_FINDING}{who}" in result.stderr assert what in result.stderr assert adopter.MANIFEST_MISREAD_REMEDIATION in result.stderr + assert INVALID_FINDING not in result.stderr, result.stderr assert not (tmp_path / REMOTES).exists() assert SPLIT_BRANCH not in branches_of(source) From c87c45fab0b1811365e0b9d4a12363feedfaeee3 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Mon, 12 Oct 2026 21:26:55 +0000 Subject: [PATCH 13/24] tests/test_adopt_plan_yaml_quoting.py: each test that runs `execute` runs it on a copy of the answered source of its own, so a regression that lets one run through no longer fails every later `execute` test at its last assertion (#181) The module-scoped `answered` fixture gave every test one source, on the premise that nothing writes into it: `plan` and `check` only read it, and a refused `execute` never reaches it. A regression that lets `execute` run on breaks that premise. Measured at 7ac2358 under revert row R21 (main's `misreads_elsewhere=True` in place of `reported`): nothing refused `visibility: "true"`, so `test_execute_refuses_a_visibility_it_never_writes_as_the_validators` ran on and committed `adopt/three-repo-shape` into the shared source. Each of the 17 `CARRIED` `execute` cases after it then failed at `SPLIT_BRANCH not in branches_of(source)`, though each passed run alone. `answered_copy` gives a test that runs `execute` its own copy of the source (`shutil.copytree`, symlinks kept) and the plan's text with its one `local_path:` line naming that copy, spelled by the plan writer's own `y`. The four tests that run `execute` on the answered plan take it: - test_execute_refuses_that_branch_as_unsafe_before_any_leg_exists; - test_execute_refuses_a_date_no_file_can_hold_as_unwritable_not_misread; - test_execute_refuses_a_visibility_it_never_writes_as_the_validators; - test_execute_refuses_a_plan_carrying_one_before_any_leg_exists. `answered` keeps serving `plan` and `check`, which only read it, and its docstring says so. The fifth test that runs `execute` already builds its own source. No assertion changes. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- tests/test_adopt_plan_yaml_quoting.py | 43 ++++++++++++++++++++------- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/tests/test_adopt_plan_yaml_quoting.py b/tests/test_adopt_plan_yaml_quoting.py index 638bed63..1af3c29f 100644 --- a/tests/test_adopt_plan_yaml_quoting.py +++ b/tests/test_adopt_plan_yaml_quoting.py @@ -491,8 +491,9 @@ def misread(what: str, value: str, reading: str) -> tuple[str, str]: @pytest.fixture(scope="module") def answered(tmp_path_factory) -> tuple[Path, str]: """One source and one answered plan of it, which `check` passes, for the - tests below to copy and edit. Nothing below writes into the source: a - refused `check` or `execute` never reaches it, and `plan` only reads it.""" + tests below to copy and edit. No test below writes into this source: + `plan` and `check` only read it, and a test that runs `execute` runs it + on a copy of its own (`answered_copy`).""" base = tmp_path_factory.mktemp("misread") source = make_source_repo(base / SOURCE_NAME) plan = base / PLAN_FILE @@ -504,6 +505,28 @@ def answered(tmp_path_factory) -> tuple[Path, str]: return source, text_of(plan) +#: The plan's line naming its source, which `answered_copy` points at a copy. +SOURCE_LINE = re.compile(r"^ local_path: .*$", re.MULTILINE) + + +@pytest.fixture +def answered_copy(adopter, answered, tmp_path) -> tuple[Path, str]: + """`answered` for a test that runs `execute`: a copy of the source of its + own, and the plan's text with `local_path:` naming that copy. + + `execute` commits its split branch into the source it is given. Were the + source shared, a regression that let one `execute` through would leave + that branch in the source of every test after it, and each later + `execute` test would fail at its last assertion for a reason that is + not its own: one test's run must never change another's input.""" + source, text = answered + assert len(SOURCE_LINE.findall(text)) == 1, "one `local_path:` line" + copy = tmp_path / SOURCE_NAME + shutil.copytree(source, copy, symlinks=True) + return copy, SOURCE_LINE.sub( + lambda _match: f" local_path: {adopter.y(str(copy))}", text, count=1) + + def carrying(plan: Path, text: str, key: str, spelled: str) -> None: """Write `text`, an answered plan, to `plan` with `key` set to `spelled` exactly as given: a plan edited by hand, or written by a `plan` older @@ -709,11 +732,11 @@ def test_check_says_nothing_misread_of_a_value_execute_never_writes( @needs_filter_repo def test_execute_refuses_that_branch_as_unsafe_before_any_leg_exists( - answered, tmp_path): + answered_copy, tmp_path): """What `execute` does with the branch `check` no longer calls misread: it refuses it as `unsafe-value`, before it creates a leg or touches the source, so no sentence of `check`'s says it gets further.""" - source, text = answered + source, text = answered_copy plan = tmp_path / PLAN_FILE carrying(plan, text, TRACKING_KEY, QUOTED_DOMAIN_USER) assert MISREAD_FINDING not in checked(plan).stderr @@ -763,11 +786,11 @@ def test_check_names_a_value_no_file_can_hold_once_as_unwritable( @needs_filter_repo def test_execute_refuses_a_date_no_file_can_hold_as_unwritable_not_misread( - answered, tmp_path): + answered_copy, tmp_path): """`execute` refuses a plan under the code of `check`'s first finding, which for this date was the misread one: it is refused as what it is, before any leg exists.""" - source, text = answered + source, text = answered_copy plan = tmp_path / PLAN_FILE carrying(plan, text, ELECTED_ON_KEY, UNWRITABLE_DATE) result = executed(plan, tmp_path) @@ -849,10 +872,10 @@ def test_the_validator_names_a_value_read_as_not_text_by_what_was_read( @needs_filter_repo def test_execute_refuses_a_visibility_it_never_writes_as_the_validators( - answered, tmp_path): + answered_copy, tmp_path): """`visibility: "true"`: #181 leaves it to the validator, and `execute` refuses it under the validator's code before any leg exists.""" - source, text = answered + source, text = answered_copy plan = tmp_path / PLAN_FILE carrying(plan, text, VISIBILITY_KEY, TRUE_TEXT) result = executed(plan, tmp_path) @@ -996,11 +1019,11 @@ def test_a_value_holding_a_placeholder_is_asked_as_render_puts_it_in_once( @pytest.mark.parametrize("key, value, reading", CARRIED, ids=ids_of(CARRIED, 0, 1)) def test_execute_refuses_a_plan_carrying_one_before_any_leg_exists( - adopter, answered, tmp_path, key, value, reading): + adopter, answered_copy, tmp_path, key, value, reading): """The refused half of the end-to-end run: `execute` says no through #168's one list of `check`'s findings, before it creates a leg or touches the source.""" - source, text = answered + source, text = answered_copy plan = tmp_path / PLAN_FILE carrying(plan, text, key, adopter.y(value)) result = executed(plan, tmp_path) From f38d19a7155fd9de2f0ce486a878cd589567635b Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Mon, 12 Oct 2026 23:07:34 +0000 Subject: [PATCH 14/24] adopt-project.py: the project's own validator is asked before #181's misread rule, which names a value only where the validator accepts what the loader reads, so `id: "007"`, `--id true` and `--spec-path null` are the validator's finding alone, as on main, and no misread remediation says `execute` writes a value it refuses (#181) rv181f's G1-G3 at 7ac2358, under the coordinator's rulings (direction (A), G2 as on main, G3-b): - G1. #181 names a value only where the loader misreads it, the validator (asked as the command asks it) makes no finding about what the loader reads, and `execute` writes it in every mode. - `check`/`execute`: `_misread_findings` asks `_validator_judged` first and leaves to the validator every placeholder it judges (`shape_materialize.manifest_judged`, `FINDING_PLACEHOLDER`). - `_manifest_findings` asks the validator about every value again (no `reported`), named as read. - `plan`: `_refuse_unholdable_values` runs inside `_checked_plan_inputs`, before #181's gate, so `--id true` and `--spec-path null` are `adopt-manifest-invalid`, as on main. - A leg path the loader reads as no value stays #181's at `check`: check leaves the validator's leg-path codes to `_leg_findings`, and `execute` writes it and ends `adoption verified`, as on main. - G2. Where the validator's reader refuses the whole manifest, or it makes a finding about no one value, `manifest_judged` is None and #181 names nothing, so `elected_on: "!"` is the one YAML-refusal finding, as on main. - G3-b. e391b18's gh guard stays: a visibility outside the three choices is never #181's, because in gh mode `gh repo create` stops on `--` before any leg exists. `_execute_gets_to_write`'s docstring now says what holds in both modes, and names the texts the validator accepts as read (`"null"`, `""`, `"'private'"`) as a residual shared with main. Tests: REFUSED keeps the five #181 rows; LEG_PATHS (9) are the validator's at `plan` and #181's in a plan; VALIDATORS (`--id true`/`null`) and the basic-format date are the validator's everywhere. New: the `plan` and `check`/`execute` tests of the validator's rows, JUDGED_ONCE's G2 and #181 rows, NOT_TEXT_INVALID, JUDGED_BY_THE_VALIDATOR (`manifest_judged`), the names-not-classified case, and NEVER_WRITTEN's 14 visibility rows. Main's test_adopt_manifest_values.py asserts the new order: the misread rule says nothing where the validator judges. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- adopt-project.py | 174 ++++++---- scripts/shape_materialize.py | 44 ++- tests/test_adopt_manifest_values.py | 73 ++-- tests/test_adopt_plan_yaml_quoting.py | 459 ++++++++++++++++++++------ 4 files changed, 547 insertions(+), 203 deletions(-) diff --git a/adopt-project.py b/adopt-project.py index 4a4c4012..a97f6e20 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -164,7 +164,7 @@ # 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_value_problems, + MANIFEST_INVALID_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 @@ -2064,23 +2064,33 @@ def _id_field(plan: Plan) -> tuple: def _execute_gets_to_write(key: str, value) -> bool: - """Does `execute` get as far as writing `value` into the project? Not for - a `git` argument `checked_value` refuses: `_checked_plan_values` refuses - it as `unsafe-value` before any leg exists, so a sentence saying + """Does `execute` get as far as writing `value` into the project, in + EVERY mode it runs in? `check` is not told which one it will be. + + Not for a `git` argument `checked_value` refuses: `_checked_plan_values` + refuses it as `unsafe-value` before any leg exists, so a sentence saying `execute` writes it, and that the run would end `adoption verified`, - would be false (rv181f's F2). Nor for a visibility that is not one of - `VISIBILITY_CHOICES`, text or not. Outside `--local-remote-dir` - `_create_leg_remotes` would hand it to `gh repo create` as - `--` before anything is written, and no other is a - visibility gh creates a repository with (rv181f's F2b); `check` is not - told which mode `execute` will run in. It is the project's own - validator's to name instead, in either mode (`manifest-visibility`, - #261, `_manifest_findings`), and `execute` refuses a plan holding it - before anything exists. None of the three choices is misread. Nor for - any other value no file can hold - (`unwritable_reason`): `check` names it as `plan-unwritable-value`, and - `execute` refuses a plan holding it before any leg exists (#255), so one - wrong value is one finding, and none says the run gets further.""" + would be false (rv181f's F2). + + Nor for a visibility that is not one of `VISIBILITY_CHOICES`, text or + not. In gh mode, `execute`'s default, `_create_leg_remotes` hands it to + `gh repo create` as `--` before anything is written, and gh + creates a repository with no other (rv181f's F2b), so such a sentence + would be false there. Where the project's own validator refuses what + the loader reads (`secret`, `"true"`, `[secret]`), `check` names it in + the validator's words (`manifest-visibility`, #261, `_manifest_findings`) + and `execute` refuses the plan before anything exists, in either mode. + Where it accepts what is read -- a text the loader reads as no value + (`"null"`, `""`, `"~"`), or as one of the three (`"'private'"`) -- + nothing names it, as on main: with `--local-remote-dir` the run ends + `adoption verified` with the field read as that, and in gh mode it stops + at the first `gh repo create`, before any leg exists. None of the three + choices is misread. + + Nor for any other value no file can hold (`unwritable_reason`): `check` + names it as `plan-unwritable-value`, and `execute` refuses a plan + holding it before any leg exists (#255), so one wrong value is one + finding, and none says the run gets further.""" if key == VISIBILITY_KEY: return str(value) in VISIBILITY_CHOICES if key not in GIT_ARGUMENT_KEYS: @@ -2095,8 +2105,8 @@ def _execute_gets_to_write(key: str, value) -> bool: def _plan_fields(plan: Plan) -> list[tuple[str, tuple]]: """A plan's values as its loader read them, each named by its key in the plan, with the lines `_template_values` would write them into: `(the - placeholder that carries it, the field)`, so that what a sentence is - about can be told to the validator (`_misread_findings`). + placeholder that carries it, the field)`, so that `_misread_findings` + can leave to the project's own validator a value it judges. A key the plan does not carry is left out: `execute` fills it with a default of this tool's own (`main`, `private`, today's date), and none of @@ -2129,20 +2139,30 @@ def _plan_fields(plan: Plan) -> list[tuple[str, tuple]]: if pin and _execute_gets_to_write("pins", pin)] -def _misread_findings(plan: Plan) -> tuple[list[str], tuple[str, ...]]: +def _misread_findings(plan: Plan) -> list[str]: """`check`'s FINDING for each plan value the project's loader would - misread (#181), and the placeholders they are about, so that the - project's own validator, asked in the same list, is not asked about - those again (`_manifest_findings`): one wrong value is one finding.""" - found: list[str] = [] - reported: list[str] = [] - for placeholder, field in _plan_fields(plan): - problems = manifest_misreads([field]) - found += [f"FINDING plan-manifest-misread: {problem}." - for problem in problems] - if problems: - reported.append(placeholder) - return found, tuple(reported) + misread (#181) and the project's own validator would NOT refuse as the + loader reads it, of the values `execute` writes in every mode + (`_execute_gets_to_write`). + + THE VALIDATOR IS ASKED FIRST, as `check` asks it (`_manifest_findings`, + the same `ignore`), and a value it makes a finding about is its alone: + `id: "007"` is `manifest-id: id is 7`, as on main, and not also this. + Where it refuses the whole file (`elected_on: "!"`, which its reader + cannot read) or makes a finding about no one value, no value can be + said to pass it, so every value is its, as on main (rv181f's G1, G2). + That is what makes this rule's remediation true of every value it + follows: `execute` writes it as it stands, and the run would still end + `adoption verified`. A leg path the loader reads as no value is still + this rule's: `check` leaves the validator's leg-path codes to + `_leg_findings` (`LEG_PATH_CODES`), and `execute` writes it.""" + judged = _validator_judged(plan) + if judged is None: + return [] + return [f"FINDING plan-manifest-misread: {problem}." + for placeholder, field in _plan_fields(plan) + if placeholder not in judged + for problem in manifest_misreads([field])] def _checked_plan_inputs(args, source: Source, @@ -2226,10 +2246,14 @@ def _checked_plan_inputs(args, source: Source, pins = [_checked_pin_name(p) for p in (args.pin or []) if p.strip()] _check_names(naming, names, set(pins)) # LAST here, over every value as the plan will record it: `execute` - # writes them into the project's own YAML, and one its loader would read - # as something else is refused before the plan exists (#181). The - # project's own validator is asked next (`_refuse_unholdable_values`, - # #261), of values that read back. + # writes them into the project's own YAML. The project's own validator + # is asked FIRST (#261): a value it refuses is refused in its words + # alone, as on main, `--id true` and `--spec-path null` among them + # (rv181f's G1). Then one its loader would read as something else that + # the validator accepts is refused before the plan exists (#181), so + # every value that refusal names is one `execute` would write and end + # `adoption verified` with, as its remediation says. + _refuse_unholdable_values(args, names, pins, naming) misread = manifest_misreads(_flag_fields(args, given, pins)) if misread: raise Refusal("adopt-manifest-misread", @@ -2258,10 +2282,11 @@ def _checked_plan_inputs(args, source: Source, # template ships, through `shape_materialize.manifest_value_problems`, which # loads that validator rather than copying a rule of it. The table is built by # `_template_table`, which `execute`'s `_template_values` also calls, so it is -# the one `execute` renders from. Only the validator's question is asked here: -# whether the project's YAML reader reads a value back as the text it is -- -# `--id true`, `--tracking-branch 007` -- is #181's, and the scaffold asks -# both. +# the one `execute` renders from. Only the validator's question is asked here, +# and it is asked FIRST: a value it refuses is its alone, `--id true` among +# them. Whether the project's YAML reader reads back as the text it is a value +# the validator accepts -- `--tracking-branch 007` -- is #181's, asked after +# (`_misread_findings`), and the scaffold asks both. #: How `check` and `execute` name each value the manifest records: its key #: in the plan. The id's placeholder and `MANIFEST_FIELDS` are #181's, so @@ -2356,36 +2381,28 @@ def _manifest_values(plan: Plan, naming: NamingPolicy) -> dict | None: _VALIDATION_SHAPE_COMMIT, _VALIDATION_SHAPE_DIGEST, pins, naming) -def _manifest_findings(plan: Plan, reported: tuple[str, ...] = () - ) -> tuple[list[str], list[str]]: +def _manifest_findings(plan: Plan) -> tuple[list[str], list[str]]: """What the project's own validator would refuse of this plan's values, as `check` prints them (one FINDING each), and what it prints once after them. `[]` and `[]` when it would pass, or when the plan's names are refused in their own words (`_manifest_values`). - `reported` is the placeholders whose value a misread rule in the same - list has ALREADY reported (#181's `plan-manifest-misread`, which - `_misread_findings` returns them with): the validator's finding about - such a value is left out, so that it is judged once and not twice - (`elected_on: "20261011"`, `id: "007"`). Empty wherever nothing else - asks that question, so that this rule alone still refuses the value. - - EVERY OTHER VALUE IS ASKED, ONE THE PLAN'S READER READ AS NOT TEXT - INCLUDED, so that no value is left to a rule that does not judge it. The - misread rule judges what the loader would misread of what `execute` - gets as far as writing, and nothing else (`_execute_gets_to_write`): a - `visibility` outside the three choices, `true` and `[secret]` among - them, is this rule's. Such a value is named by what was read - (`_named_as_read`), because the sentence goes on to quote `str` of it, - the text `execute` would write and not what the plan says. + EVERY VALUE IS ASKED, ONE THE PLAN'S READER READ AS NOT TEXT INCLUDED, + and one #181's misread rule would also call misread: a value this rule + refuses is this rule's alone, and the misread rule leaves it here + (`_misread_findings`, rv181f's G1). So `id: "007"` is `manifest-id: id + is 7`, as on main, `elected_on: 20260902` and `visibility: true` are + this rule's too, and one wrong value is one finding. A value read as not + text is named by what was read (`_named_as_read`), because the sentence + goes on to quote `str` of it, the text `execute` would write and not + what the plan says. """ naming = NamingPolicy.load(NAMING_POLICY) values = _manifest_values(plan, naming) if values is None: return [], [] problems = manifest_value_problems(values, naming, _named_as_read(plan), - misreads=False, ignore=LEG_PATH_CODES, - not_text=tuple(reported)) + misreads=False, ignore=LEG_PATH_CODES) if not problems: return [], [] return ([f"FINDING plan-manifest-invalid: {sentence}" @@ -2396,6 +2413,19 @@ def _manifest_findings(plan: Plan, reported: tuple[str, ...] = () "a value the validator accepts."]) +def _validator_judged(plan: Plan) -> frozenset[str] | None: + """The placeholders whose value the project's own validator makes a + finding about, asked as `_manifest_findings` asks it; None where no + value can be said to pass it (`manifest_judged`), or where the plan's + names are refused in their own words and there is no manifest to ask + about (`_manifest_values`).""" + naming = NamingPolicy.load(NAMING_POLICY) + values = _manifest_values(plan, naming) + if values is None: + return None + return manifest_judged(values, naming, ignore=LEG_PATH_CODES) + + def _named_as_read(plan: Plan) -> dict[str, str]: """How a sentence of the validator's names each value: by its key in the plan, and a value the plan's reader read as something that is not text @@ -2512,8 +2542,8 @@ def cmd_plan(args) -> int: policy = PathPolicy.load(args.path_policy or PATH_POLICY) naming = NamingPolicy.load(NAMING_POLICY) + # The project's own validator is asked inside, before #181's question. names, pins = _checked_plan_inputs(args, source, naming) - _refuse_unholdable_values(args, names, pins, naming) tree = source.tree() # WHAT THE RULES IMPLY is read from `walk`'s entries, BEFORE @@ -3904,13 +3934,17 @@ def _entry_findings(plan: Plan, source: Source, what the person retypes. Both are listed, and both refuse before any leg exists. The relative urls of #190 still come after both, last. - A VALUE #181 CALLS MISREAD IS #181'S ALONE. Its `plan-manifest-misread` - names what the loader would read back, and the validator is not asked - about that value again: `id: "007"` is not also `manifest-id: id is 7`. - The validator is asked about every other value, a `visibility` outside - the three choices among them, text or not, which #181 does not judge - (`_execute_gets_to_write`). So the two never both name one value - (`_manifest_findings`). + A VALUE THE VALIDATOR REFUSES IS THE VALIDATOR'S ALONE (rv181f's G1, + G2). The validator is asked about every value (`_manifest_findings`), + and #181's `plan-manifest-misread` names a value only where the loader + misreads it, the validator makes no finding about what the loader + reads, and `execute` gets to write it in every mode + (`_execute_gets_to_write`, `_misread_findings`): `id: "007"` is + `manifest-id: id is 7` alone, as on main. A manifest the validator's + own reader refuses, or a finding of its about no one value, leaves + every value to it. So the two never both name one value, and every + value #181 names is one `execute` writes as it stands, with the run + still ending `adoption verified`, as its remediation says. """ tree = source.tree() unsafe, unsafe_remediation = _unsafe_path_findings(plan) @@ -3919,11 +3953,13 @@ def _entry_findings(plan: Plan, source: Source, for e in plan.entries], [path for path, _, _, _ in tree]) + _leg_findings(plan, tree)) - misread, reported = _misread_findings(plan) (split, split_remediation), (moved, moved_remediation), notes = \ _submodule_findings(plan, source, tree, local_remote_dir) unwritable_findings, unwritable_remediation = _value_findings(plan) - value_findings, value_remediation = _manifest_findings(plan, reported) + value_findings, value_remediation = _manifest_findings(plan) + # Asked after the validator, whose judgement it follows; listed where + # it always was, after the mount paths. + misread = _misread_findings(plan) return (findings + misread + split + unwritable_findings + value_findings + moved, unsafe_remediation diff --git a/scripts/shape_materialize.py b/scripts/shape_materialize.py index 73e325b7..379735f9 100644 --- a/scripts/shape_materialize.py +++ b/scripts/shape_materialize.py @@ -2472,13 +2472,24 @@ def manifest_invalid(values: dict, policy: NamingPolicy, what: dict, 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. """ + notes = _validator_notes(values, policy, template_dir, ignore) + if isinstance(notes, YamlError): + 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) + + +def _validator_notes(values: dict, policy: NamingPolicy, template_dir: Path, + ignore: tuple) -> list[str] | YamlError: + """The FINDINGs the project's own validator makes of the `project.yaml` + these `values` render, less its WARNINGs and the codes in `ignore`; or + the `YamlError` its reader raised, where it cannot read that file.""" template = (Path(template_dir) / MANIFEST_TEMPLATE).read_text( encoding="utf-8") try: manifest = parse_yaml(render(template, values, MANIFEST_TEMPLATE)) except YamlError as exc: - return ["the manifest this run would write is a file the project's " - f"own YAML reader refuses: {exc}."] + return exc validator = manifest_validator(template_dir) try: notes = validator._findings(manifest, policy, None) @@ -2486,9 +2497,32 @@ def manifest_invalid(values: dict, policy: NamingPolicy, what: dict, raise _validator_unusable( "raised when it was asked", f"{type(exc).__name__}: {exc}" ) from exc - return _sentences_of( - [note for note in notes if not note.startswith("WARNING") - and _code_of(note) not in ignore], values, what, not_text) + return [note for note in notes if not note.startswith("WARNING") + and _code_of(note) not in ignore] + + +def manifest_judged(values: dict, policy: NamingPolicy, + template_dir: Path = ASSEMBLY_TEMPLATE, + ignore: tuple = ()) -> frozenset[str] | None: + """The placeholders whose value the project's own validator makes a + finding about, in the `project.yaml` these `values` render (the + question `manifest_invalid` asks, with the same `ignore`); an empty set + when it makes none. + + None where it cannot be told of ANY value that the validator passes it: + its reader refuses the whole file, or it makes a finding that is about + no one placeholder (`FINDING_PLACEHOLDER`), whatever value caused it. + A caller with a rule of its own about the same values (`adopt-project.py` + #181's misread) leaves to the validator every value it judges, and every + value at all where this is None, so that one value is never two + findings and no sentence of that rule's says the validator passes a + value it refuses. + """ + notes = _validator_notes(values, policy, template_dir, ignore) + if isinstance(notes, YamlError): + return None + judged = {FINDING_PLACEHOLDER.get(_code_of(note)) for note in notes} + return None if None in judged else frozenset(judged) #: The remediation, without the sentence only the caller can write (what was diff --git a/tests/test_adopt_manifest_values.py b/tests/test_adopt_manifest_values.py index 11355f49..a3f3e5e7 100644 --- a/tests/test_adopt_manifest_values.py +++ b/tests/test_adopt_manifest_values.py @@ -448,8 +448,7 @@ def no_process(*args, **kwargs): monkeypatch.setattr(os, "system", no_process) assert adopter._manifest_values(good, naming) is not None assert adopter._manifest_findings(good) == ([], []) - assert adopter._manifest_findings( - good, tuple(adopter.MANIFEST_PLAN_KEYS)) == ([], []) + assert adopter._misread_findings(good) == [] (finding,), remediation = adopter._manifest_findings(refused) assert "visibility is 'secret'" in finding assert remediation @@ -644,18 +643,20 @@ def test_a_bad_leg_path_is_one_finding_in_the_words_of_its_own_rule(world, ('elected_on: "2026-09-02"', "elected_on: 20260902"), ("id: northwind", "id: 007"), ]) -def test_a_value_the_reader_reads_as_another_kind_is_not_this_questions( +def test_a_value_the_reader_reads_as_another_kind_is_named_as_read( world, old, new): """`id: 007` is the number 7 and `elected_on: 20260902` another number: what the template would write is `str` of what was read, which the plan - never said. That is a misread, #181's `plan-manifest-misread` names it - by what was read, and the validator is not asked about it again. A - `visibility` outside the three choices is not here: #181 does not judge - it, so it is the validator's, text or not, named by what was read - (tests/test_adopt_plan_yaml_quoting.py, `JUDGED_ONCE`).""" + never said. The validator refuses what is read, so the finding is its + alone, and names the value by what was read before the text `execute` + would write (tests/test_adopt_plan_yaml_quoting.py, `NOT_TEXT_INVALID`); + #181's misread rule leaves to it every value it refuses (rv181f's G1).""" plan = edited(world, old, new) result = run_script(ADOPT, "check", "--plan", str(plan)) - assert FINDING not in result.stderr, result.stderr + (finding,) = [line for line in result.stderr.splitlines() + if line.startswith(FINDING)] + assert "1 finding(s)" in result.stderr, result.stderr + assert ", read out of the plan as the number " in finding, finding # --- a text value the reader misreads: judged once, not twice --------------- @@ -721,24 +722,26 @@ def test_a_text_value_the_reader_misreads_is_one_finding_on_this_rule_alone( @pytest.mark.parametrize("old, new, named", TEXT_THE_READER_MISREADS) -def test_where_a_misread_rule_judges_it_the_validator_is_not_asked_again( +def test_where_the_validator_judges_it_the_misread_rule_says_nothing( adopter, world, old, new, named): - """`reported`: a list that holds the misread rule's finding for such a - value (#181's `plan-manifest-misread`) is not given a second one about - the same value, in the validator's words and with the retyped text - (`id is 7`) that the misread finding exists to avoid.""" + """The validator is asked first, and a value it refuses is its alone: + #181's misread rule, in the same list, says nothing of it. Its + remediation would say `execute` writes the value and the run would + still end `adoption verified`, where `execute` refuses it as the + validator's before anything exists (rv181f's G1).""" plan = adopter.Plan.load(edited(world, old, new)) - key = new.split(":", 1)[0] - placeholder = next(placeholder for placeholder, named - in adopter.MANIFEST_PLAN_KEYS.items() if named == key) - assert adopter._manifest_findings(plan, (placeholder,)) == ([], []) + assert adopter._misread_findings(plan) == [] + (finding,), _ = adopter._manifest_findings(plan) + assert named in finding -def test_a_value_that_is_not_a_misread_is_still_asked_where_one_judges_the_rest( +def test_every_value_is_asked_and_the_misread_rule_leaves_each_it_refuses( adopter, world): - """Only what the reader misreads is left out. `visibility: secret` is text - to the reader and the validator refuses it, so it is still a finding, and - so is a blank `elected_by`; `id: "007"` beside them is not.""" + """Every value is asked, one the reader misreads included. + `visibility: secret` is text to the reader and the validator refuses + it, and so does a blank `elected_by`; it refuses `id: "007"` beside + them as the number the reader makes of it, and #181's misread rule + names none of the three.""" text = world["text"].replace("id: northwind", 'id: "007"') text = text.replace("visibility: private", "visibility: secret") text = text.replace('elected_by: "Test Human"', 'elected_by: " "') @@ -746,32 +749,30 @@ def test_a_value_that_is_not_a_misread_is_still_asked_where_one_judges_the_rest( with path.open("w", encoding=UTF8, newline="\n") as handle: handle.write(text) plan = adopter.Plan.load(path) - alone, _ = adopter._manifest_findings(plan) - shared, _ = adopter._manifest_findings(plan, ("PROJECT_ID",)) - assert len(alone) == 3, alone - assert len(shared) == 2, shared - assert any("visibility is 'secret'" in f for f in shared) - assert any("elected_by is ' '" in f for f in shared) - assert not any("manifest-id" in f for f in shared) + findings, _ = adopter._manifest_findings(plan) + assert len(findings) == 3, findings + assert any("visibility is 'secret'" in f for f in findings) + assert any("elected_by is ' '" in f for f in findings) + assert any("manifest-id: id is 7" in f for f in findings) + assert adopter._misread_findings(plan) == [] @pytest.mark.parametrize("old, new, named", TEXT_THE_READER_MISREADS) def test_a_text_value_the_reader_misreads_is_one_finding_in_the_list_check_prints( adopter, world, old, new, named): """NOT ZERO AND NOT TWO, whatever rules the list holds. This rule alone, - `_entry_findings` passes no `reported`, and the validator's finding is - the one. With #181 in the list as well, the call there passes the - placeholders #181 reported, or this is the pair the two rules each make - of one value: this test fails on a merge that has not. `visibility: - "true"` is #181's to leave alone (it does not judge a visibility outside - the three choices), so it must not be passed for it either, or it is - zero.""" + the validator's finding is the one. With #181 in the list as well, the + validator is asked first and #181 leaves to it every value it refuses, + or this is the pair the two rules each make of one value: this test + fails on a merge that has not. It is the validator's finding (rv181f's + G1).""" plan = adopter.Plan.load(edited(world, old, new)) source = plan.open_source(str(world["source"]), world["tmp"] / "read") findings, _, _ = adopter._entry_findings(plan, source) value = new.split(": ", 1)[1].strip('"') about = [f for f in findings if value in f] assert len(about) == 1, findings + assert about[0].startswith(FINDING), findings # --- a validator that cannot be asked --------------------------------------- diff --git a/tests/test_adopt_plan_yaml_quoting.py b/tests/test_adopt_plan_yaml_quoting.py index 1af3c29f..b47a7ea0 100644 --- a/tests/test_adopt_plan_yaml_quoting.py +++ b/tests/test_adopt_plan_yaml_quoting.py @@ -40,10 +40,17 @@ read back as something else, by the same question `y` asks (`loader_reading`), put into each template line that carries the value; and the end-to-end test clones and bootstraps what is still accepted. + +THE PROJECT'S OWN VALIDATOR IS ASKED FIRST (#261, rv181f's G1). A value it +refuses as the loader reads it -- `id: true`, `elected_on: 20260902`, and at +`plan` a leg path read as no value -- is refused in its words alone, as on +main. The misread finding is for what the validator accepts and `execute` +writes in every mode, which is what its remediation says. """ from __future__ import annotations +import argparse import importlib.util import itertools import random @@ -352,8 +359,22 @@ def test_a_value_is_asked_about_in_each_place_it_is_written( #: How `check` begins #261's line about a value the project's own #: validate-manifest.py refuses. INVALID_FINDING = "FINDING plan-manifest-invalid: " -#: `true` as text, as a plan edited by hand quotes it. +#: How #181's remediation begins, which no other remediation does. +MANIFEST_MISREAD_REMEDIATION_START = ( + "Remediation: choose another value for each one named. `execute` writes") +#: `true` as text, as a plan edited by hand quotes it, and `007` and +#: `null` too. TRUE_TEXT = '"true"' +QUOTED_007 = '"007"' +QUOTED_NULL = '"null"' +#: How `plan`'s and `check`'s codes for #181's rule end, and the +#: validator's code for an election date. +MISREAD_CODE = "manifest-misread" +ELECTED_ON_CODE = "manifest-elected-on" +#: The plan key of the assembly's name, which an id nobody set comes from. +ASSEMBLY_KEY = "legs.assembly" +#: The election date a plan below is written with. +ELECTION_DATE = "2026-09-02" #: The plan keys the rows below name, and the flag `plan` takes each as. SPEC_PATH_KEY = "legs.spec_path" TRACKING_KEY = "tracking_branch" @@ -390,45 +411,78 @@ def test_a_value_is_asked_about_in_each_place_it_is_written( QUOTED_DOMAIN_USER = f"'\"{DOMAIN_USER}\"'" #: `(flag, value, plan key, what the project's loader reads)`. At 76a6153 -#: each of the first fifteen went through `execute` to `adoption verified` -#: and then to a bootstrap that failed, or that put the legs on another branch -#: than the one they were pushed on (lane 2's review, and this fix's own repro -#: of it); `2026`, `1.5` and `~` did so on `main` too (#187's review). `007` -#: is a branch bootstrap would read as `7`. No value `project.yaml` writes -#: between its quotes is here: `render` spells those with `double_quoted` -#: since #284, and `BETWEEN_THE_QUOTES` below holds the ones that misread -#: before it. +#: each went through `execute` to `adoption verified` and then to a +#: bootstrap that failed, or that put the legs on another branch than the +#: one they were pushed on (lane 2's review, and this fix's own repro of +#: it). `007` is a branch bootstrap would read as `7`. The project's own +#: validator makes no finding about what the loader reads of any of them, +#: so each is #181's, at `plan` and in a plan. No value `project.yaml` +#: writes between its quotes is here: `render` spells those with +#: `double_quoted` since #284, and `BETWEEN_THE_QUOTES` below holds the ones +#: that misread before it. REFUSED = [ - (SPEC_FLAG, "null", SPEC_PATH_KEY, NO_VALUE), - (SPEC_FLAG, "Null", SPEC_PATH_KEY, NO_VALUE), - (SPEC_FLAG, "NULL", SPEC_PATH_KEY, NO_VALUE), - (SPEC_FLAG, "~", SPEC_PATH_KEY, NO_VALUE), - (SPEC_FLAG, "true", SPEC_PATH_KEY, TRUE_READ), - (SPEC_FLAG, FALSE_TITLE, SPEC_PATH_KEY, "the boolean false"), - (SPEC_FLAG, "2026", SPEC_PATH_KEY, "the number 2026"), - (SPEC_FLAG, "1.5", SPEC_PATH_KEY, "the number 1.5"), - ("--code-path", "null", "legs.code_path", NO_VALUE), (TRACKING_FLAG, "true", TRACKING_KEY, TRUE_READ), (TRACKING_FLAG, "null", TRACKING_KEY, NO_VALUE), - ("--id", "true", "id", TRUE_READ), - ("--id", "null", "id", NO_VALUE), (PIN_FLAG, "null", PINS_KEY, NO_VALUE), (PIN_FLAG, "True", PINS_KEY, TRUE_READ), (TRACKING_FLAG, "007", TRACKING_KEY, SEVEN_READ), ] -#: `(plan key, value, what the loader reads)` that only an edited plan can +#: How the validator's finding about a leg's mount path begins. +LEG_PATH_SAID = "manifest-leg-path: leg 'spec': path is " +#: `(flag, value, plan key, what the loader reads, the validator's words for +#: what it reads)`: leg paths that went the same way at 76a6153, and `2026`, +#: `1.5` and `~` on `main` too (#187's review). `plan` asks the validator +#: first, which refuses each (`manifest-leg-path`), so `plan` refuses it in +#: the validator's words alone, as main does (rv181f's G1). A PLAN carrying +#: one is #181's: `check` leaves the validator's leg-path codes to +#: `_leg_findings` (`LEG_PATH_CODES`), which passes the text, and `execute` +#: writes it and ends `adoption verified`, as on main. +LEG_PATHS = [ + (SPEC_FLAG, "null", SPEC_PATH_KEY, NO_VALUE, f"{LEG_PATH_SAID}None"), + (SPEC_FLAG, "Null", SPEC_PATH_KEY, NO_VALUE, f"{LEG_PATH_SAID}None"), + (SPEC_FLAG, "NULL", SPEC_PATH_KEY, NO_VALUE, f"{LEG_PATH_SAID}None"), + (SPEC_FLAG, "~", SPEC_PATH_KEY, NO_VALUE, f"{LEG_PATH_SAID}None"), + (SPEC_FLAG, "true", SPEC_PATH_KEY, TRUE_READ, f"{LEG_PATH_SAID}True"), + (SPEC_FLAG, FALSE_TITLE, SPEC_PATH_KEY, "the boolean false", + f"{LEG_PATH_SAID}False"), + (SPEC_FLAG, "2026", SPEC_PATH_KEY, "the number 2026", + f"{LEG_PATH_SAID}2026"), + (SPEC_FLAG, "1.5", SPEC_PATH_KEY, "the number 1.5", f"{LEG_PATH_SAID}1.5"), + ("--code-path", "null", "legs.code_path", NO_VALUE, + "manifest-leg-path: leg 'code': path is None"), +] +#: How the validator's finding about an id begins. +ID_SAID = "manifest-id: id is " +#: `(flag, value, plan key, the validator's words for what the loader +#: reads)`: an id the loader reads as a boolean or as no value. The +#: validator refuses it (`manifest-id`), and is asked first, so it is the +#: validator's finding alone, at `plan` and in a plan, as on main (rv181f's +#: G1): a misread finding there said `execute` writes it and the run ends +#: `adoption verified`, and `execute` refuses it before anything exists. +VALIDATORS = [ + ("--id", "true", "id", f"{ID_SAID}True; it must match "), + ("--id", "null", "id", f"{ID_SAID}None; it must match "), +] +#: `(plan key, value, the validator's words)` that only an edited plan can #: carry here: `plan` refuses `--elected-on 20260902` as #: `election-date-malformed` on every Python since #261, which holds the date -#: to the validator's own `YYYY-MM-DD`. The validator refuses it too -#: (`manifest-elected-on`), and `check` names it once, as the misread it is -#: (`JUDGED_ONCE`). No visibility is here: `plan` takes one from a fixed -#: choice, and any other is the validator's (`JUDGED_ONCE`). +#: to the validator's own `YYYY-MM-DD`. The loader reads it as a number, and +#: the validator refuses that (`manifest-elected-on`): its finding alone. +#: No visibility is here: `plan` takes one from a fixed choice, and none +#: other is #181's (`_execute_gets_to_write`, `NEVER_WRITTEN`). EDITED_ONLY = [ - (ELECTED_ON_KEY, DATE_DIGITS, f"the number {DATE_DIGITS}"), + (ELECTED_ON_KEY, DATE_DIGITS, f"{ELECTED_ON_CODE}: elected_on is " + f"{DATE_DIGITS}, expected an ISO date YYYY-MM-DD"), ] -#: REFUSED as `plan` is given it, and as a plan carries it. +#: #181's, as `plan` is given it, and as a plan carries it. PLANNED = [(flag, value, reading) for flag, value, _, reading in REFUSED] CARRIED = [(key, value, reading) for _, value, key, reading in REFUSED] \ + + [(key, value, reading) for _, value, key, reading, _ in LEG_PATHS] +#: The validator's alone, as `plan` is given it, and as a plan carries it. +PLANNED_INVALID = [ + (flag, value, said) for flag, value, _, _, said in LEG_PATHS] + [ + (flag, value, said) for flag, value, _, said in VALIDATORS] +CARRIED_INVALID = [(key, value, said) for _, value, key, said in VALIDATORS] \ + EDITED_ONLY @@ -562,20 +616,67 @@ def test_plan_refuses_a_value_the_projects_loader_would_misread( assert not plan.exists() -@pytest.mark.parametrize("project, value, reading", [ - ("True", "true", TRUE_READ), ("Null", "null", NO_VALUE)], +#: How a refusal or a finding in the validator's words goes on, once it has +#: named the value. +VALIDATOR_REFUSES = "which the project's own validate-manifest.py refuses: " + + +@pytest.mark.parametrize("flag, value, said", PLANNED_INVALID, + ids=ids_of(PLANNED_INVALID, 0, 1)) +def test_plan_refuses_a_value_the_validator_refuses_in_its_words_alone( + adopter, answered, tmp_path, flag, value, said): + """The project's own validator is asked first. A value it refuses as + the loader reads it was refused as a misread, whose remediation says + `execute` writes it and the run would still end `adoption verified`: + neither is true of a value the validator refuses (rv181f's G1). It is + refused in the validator's words alone, as on main.""" + source, _ = answered + plan = tmp_path / PLAN_FILE + result = write_plan(source, plan, extra=(flag, value)) + assert result.returncode == 2, result.stdout + result.stderr + assert result.stderr.startswith("REFUSED adopt-manifest-invalid: "), ( + result.stderr) + assert said in result.stderr, result.stderr + assert MISREAD_CODE not in result.stderr, result.stderr + assert adopter.MANIFEST_MISREAD_REMEDIATION not in result.stderr + assert "No plan was written." in result.stderr + assert not plan.exists() + + +@pytest.mark.parametrize("project, value, said", [ + ("True", "true", f"{ID_SAID}True; "), ("Null", "null", f"{ID_SAID}None; ")], ids=["True", "Null"]) -def test_plan_names_an_id_it_derived_from_the_project( - answered, tmp_path, project, value, reading): +def test_plan_leaves_an_id_it_derived_from_the_project_to_the_validator( + answered, tmp_path, project, value, said): """`--project True` passes no `--id`, and `plan` lowercases the project - into one: the refusal says where the value came from, so the person - knows the flag that chooses another.""" + into one, which the loader reads as a boolean and the validator then + refuses: the validator's refusal, as on main, and no misread.""" source, _ = answered result = write_plan(source, tmp_path / PLAN_FILE, project=project) assert result.returncode == 2, result.stdout + result.stderr - who, what = misread("--id (not given, so --project lowercased)", value, - reading) - assert who in result.stderr and what in result.stderr, result.stderr + assert result.stderr.startswith( + f"REFUSED adopt-manifest-invalid: --id is {value!r}, " + f"{VALIDATOR_REFUSES}{said}"), result.stderr + assert MISREAD_CODE not in result.stderr, result.stderr + + +def test_the_misread_rule_names_an_id_plan_derived_by_where_it_came_from( + adopter): + """Should #181 be the rule that reports an id `plan` derived, its + sentence says where the value came from, so the person knows the flag + that chooses another. The validator refuses every id the loader + misreads today, and is asked first, so this is the rule's own answer, + asked of it directly.""" + args = argparse.Namespace( + id="true", tracking_branch="main", visibility="private", + elected_by="Test Human", elected_on=ELECTION_DATE, reference="r", + spec_path="spec", code_path="code") + who, what = misread("--id (not given, so --project lowercased)", "true", + TRUE_READ) + said = adopter.manifest_misreads(adopter._flag_fields(args, set(), [])) + assert [sentence for sentence in said + if sentence.startswith(who) + and sentence.endswith(what.rstrip("."))], said def test_plan_takes_an_elector_from_git_the_manifests_quotes_read_back( @@ -589,7 +690,7 @@ def test_plan_takes_an_elector_from_git_the_manifests_quotes_read_back( plan = tmp_path / PLAN_FILE result = run_script( ADOPT, "plan", "--source", str(source), "--project", PROJECT_NAME, - "--org", "testorg", "--elected-on", "2026-09-02", "--out", str(plan), + "--org", "testorg", "--elected-on", ELECTION_DATE, "--out", str(plan), env={"GIT_CONFIG_COUNT": "1", "GIT_CONFIG_KEY_0": "user.name", "GIT_CONFIG_VALUE_0": DOMAIN_USER}) assert result.returncode == 0, result.stdout + result.stderr @@ -598,7 +699,7 @@ def test_plan_takes_an_elector_from_git_the_manifests_quotes_read_back( resolve(plan, path, leg) passed = checked(plan) assert passed.returncode == 0, passed.stdout + passed.stderr - assert "manifest-misread" not in passed.stderr + assert MISREAD_CODE not in passed.stderr @pytest.mark.parametrize("key, value, reading", CARRIED, @@ -607,8 +708,10 @@ def test_check_reports_a_plan_carrying_one(adopter, answered, tmp_path, key, value, reading): """A plan carrying the value -- spelled by the plan writer, so the plan itself reads it back -- is a FINDING, with the remediation once after. - It is #181's alone: the project's own validator is not asked about it - again (#261), though it refuses `id: "true"` and the date too.""" + It is #181's alone: the project's own validator, asked first, makes no + finding about what the loader reads of it (a leg path's are + `_leg_findings`' to judge, which passes the text), and `execute` writes + it in every mode.""" _, text = answered plan = tmp_path / PLAN_FILE carrying(plan, text, key, adopter.y(value)) @@ -622,6 +725,32 @@ def test_check_reports_a_plan_carrying_one(adopter, answered, tmp_path, key, assert "plan ok" not in result.stdout +def judged_lines(stderr: str) -> list[str]: + """The lines of #181's rule and the validator's, in the order printed.""" + return [line for line in stderr.splitlines() + if line.startswith((MISREAD_FINDING, INVALID_FINDING))] + + +@pytest.mark.parametrize("key, value, said", CARRIED_INVALID, + ids=ids_of(CARRIED_INVALID, 0, 1)) +def test_check_leaves_a_value_the_validator_refuses_to_it( + adopter, answered, tmp_path, key, value, said): + """`id: "true"` was #181's finding, with a remediation saying `execute` + writes it and the run would still end `adoption verified`; `execute` + refuses it before anything exists, as the validator's (rv181f's G1). + It is the validator's finding alone, as on main, and no misread.""" + _, text = answered + plan = tmp_path / PLAN_FILE + carrying(plan, text, key, adopter.y(value)) + result = checked(plan) + assert result.returncode == 1, result.stdout + result.stderr + (line,) = judged_lines(result.stderr) + assert line.startswith( + f"{INVALID_FINDING}{key} is {value!r}, {VALIDATOR_REFUSES}{said}"), ( + result.stderr) + assert adopter.MANIFEST_MISREAD_REMEDIATION not in result.stderr + + #: How a plan sets no id, each beside how it spells the assembly the id is #: then derived from. The first pair is what `main`'s #: `plan --project True --id null` writes: `id: null`, and a bare `True` @@ -635,26 +764,50 @@ def test_check_reports_a_plan_carrying_one(adopter, answered, tmp_path, key, ] +def without_an_id(text: str, id_line: str) -> str: + """The answered plan's text with its `id:` line put as `id_line`.""" + edited = re.sub(r"^id: .*\n", id_line, text, count=1, flags=re.MULTILINE) + assert edited != text, "no `id:` line in the plan" + return edited + + @pytest.mark.parametrize("id_line, assembly", NO_ID) -def test_check_names_an_id_it_derived_from_the_assembly( +def test_check_leaves_an_id_it_derived_from_the_assembly_to_the_validator( answered, tmp_path, id_line, assembly): """A plan that sets no id gets one from `execute`, which lowercases - `legs.assembly`: the finding says so, because the line to change is not - the one with the value. It says "not set in the plan" and not "not in - the plan": an `id: null` is in the plan and sets nothing (review181's - N3), and `Plan.get` reads it exactly as a missing key.""" + `legs.assembly`: `true`, which the loader reads as a boolean and the + validator refuses. The validator's finding alone, as on main.""" _, text = answered - without_id = re.sub(r"^id: .*\n", id_line, text, count=1, - flags=re.MULTILINE) - assert without_id != text, "no `id:` line in the plan" plan = tmp_path / PLAN_FILE - carrying(plan, without_id, "legs.assembly", assembly) + carrying(plan, without_an_id(text, id_line), ASSEMBLY_KEY, assembly) result = checked(plan) assert result.returncode == 1, result.stdout + result.stderr + (line,) = judged_lines(result.stderr) + assert line.startswith( + f"{INVALID_FINDING}id is 'true', {VALIDATOR_REFUSES}{ID_SAID}True; " + ), result.stderr + + +@pytest.mark.parametrize("id_line, assembly", NO_ID) +def test_the_misread_rule_names_an_id_it_derived_from_the_assembly( + adopter, answered, tmp_path, id_line, assembly): + """Should #181 be the rule that reports an id `execute` derives from + `legs.assembly`, its sentence says so, because the line to change is not + the one with the value. It says "not set in the plan" and not "not in + the plan": an `id: null` is in the plan and sets nothing (review181's + N3), and `Plan.get` reads it exactly as a missing key. The validator + refuses this id today, and is asked first, so this is the rule's own + answer, asked of it directly.""" + _, text = answered + plan = tmp_path / PLAN_FILE + carrying(plan, without_an_id(text, id_line), ASSEMBLY_KEY, assembly) who, what = misread("id (not set in the plan, so legs.assembly " "lowercased)", "true", TRUE_READ) - assert f"{MISREAD_FINDING}{who}" in result.stderr - assert what in result.stderr, result.stderr + said = adopter.manifest_misreads([adopter._id_field( + adopter.Plan.load(plan))]) + assert len(said) == 1, said + assert said[0].startswith(who) and said[0].endswith(what.rstrip(".")), ( + said) #: `(plan key, as a hand-edited plan spells it, what the plan's loader reads @@ -662,20 +815,15 @@ def test_check_names_an_id_it_derived_from_the_assembly( #: something that is not text, so the plan does not say what `str` of it #: says: `007` is read as 7, and `TRUE` as true. The sentence names what was #: READ, with its kind, and then the text `execute` writes (review181's N2). -#: The project's loader reads that text back as the same kind again. The -#: list and the mapping are an `elected_on`'s, which `execute` writes as it -#: stands: as a `tracking_branch` it refuses either as `unsafe-value`, and -#: as a `visibility` `gh` takes neither, before anything is written -#: (`NEVER_WRITTEN`). +#: The project's loader reads that text back as the same kind again, and the +#: project's own validator makes no finding about it: it does not judge a +#: branch, and a pin only as a name it classifies. An id, a date or a +#: visibility read as not text is the validator's (`NOT_TEXT_INVALID`). READ_AS_NOT_TEXT = [ (TRACKING_KEY, "007", SEVEN_READ, "7"), (TRACKING_KEY, "TRUE", TRUE_READ, "True"), (TRACKING_KEY, "1.50", "the number 1.5", "1.5"), - (ELECTED_ON_KEY, "[a]", "a list", "['a']"), - (ELECTED_ON_KEY, "{a: b}", "a mapping", "{'a': 'b'}"), - ("id", "007", SEVEN_READ, "7"), (PINS_KEY, "TRUE", TRUE_READ, "True"), - (ELECTED_ON_KEY, DATE_DIGITS, f"the number {DATE_DIGITS}", DATE_DIGITS), ] @@ -708,19 +856,34 @@ def test_check_names_a_plan_value_that_is_not_text_by_what_was_read( #: written and the run ends `adoption verified`: for these neither is true #: (rv181f's F2, F2b). Since #261 the visibility is the project's own #: validator's finding instead (`JUDGED_ONCE`). +#: +#: So is every other visibility outside the three choices, in gh mode, +#: `execute`'s default: `gh repo create` is handed `--null`, `--` or +#: `--'private'` before any leg exists, and `check` is not told the mode. +#: The texts the loader reads as no value, or as one of the three, are ones +#: the validator accepts as read, so nothing names them, as on main: with +#: `--local-remote-dir` the run ends `adoption verified`, the field read as +#: that (rv181f's G3, a residual this change shares with main). NEVER_WRITTEN = [ pytest.param(TRACKING_KEY, QUOTED_DOMAIN_USER, id=TRACKING_KEY), pytest.param(VISIBILITY_KEY, TRUE_TEXT, id=VISIBILITY_KEY), + *[pytest.param(VISIBILITY_KEY, spelled, id=f"visibility-{name}") + for spelled, name in [ + ('""', "empty"), ("''", "empty-single"), (QUOTED_NULL, "null"), + ('"Null"', "Null"), ('"NULL"', "NULL"), ('"~"', "null-tilde"), + ('"#"', "hash"), ('"#x"', "hash-x"), ('" "', "space"), + ('" "', "spaces"), ("|-", "block"), ("\"'private'\"", "private"), + ("'\"public\"'", "public"), ("\"'internal'\"", "internal")]], ] @pytest.mark.parametrize("key, spelled", NEVER_WRITTEN) def test_check_says_nothing_misread_of_a_value_execute_never_writes( adopter, answered, tmp_path, key, spelled): - """`check` asked the loader about every value, and named these two as + """`check` asked the loader about every value, and named these as misread, with a remediation saying the run would still end `adoption - verified`. It asks only about a value `execute` gets as far as - writing.""" + verified`. It asks only about a value `execute` gets as far as writing + in every mode.""" _, text = answered plan = tmp_path / PLAN_FILE carrying(plan, text, key, spelled) @@ -804,17 +967,30 @@ def test_execute_refuses_a_date_no_file_can_hold_as_unwritable_not_misread( #: `(plan key, as a plan edited by hand spells it, the one finding `check` #: prints about it)`: a value of each kind #181's gate and #261's validator -#: (`plan-manifest-invalid`) divide between them. Text the loader misreads is -#: #181's, though the validator refuses it too; text it reads back is the -#: validator's; a value read as not text is #181's where #181 asks about it, -#: and the validator's where it does not, as for a visibility outside the -#: three choices, text or not (`_execute_gets_to_write`). +#: (`plan-manifest-invalid`) divide between them. The validator is asked +#: first, and a value it refuses as the loader reads it is its alone, text +#: the loader misreads included (rv181f's G1); so is any value of a manifest +#: its reader cannot read at all, and that one finding names no value twice +#: (rv181f's G2). What the loader misreads and the validator accepts is +#: #181's: a branch, a pin, a leg path (`check` leaves its codes to +#: `_leg_findings`), and a date read back as other text that is a date. JUDGED_ONCE = [ - pytest.param(ELECTED_ON_KEY, f'"{DATE_DIGITS}"', MISREAD_FINDING, + pytest.param(ELECTED_ON_KEY, f'"{DATE_DIGITS}"', INVALID_FINDING, id="date-text-misread"), - pytest.param("id", '"007"', MISREAD_FINDING, id="id-text-misread"), - pytest.param(ELECTED_ON_KEY, DATE_DIGITS, MISREAD_FINDING, + pytest.param("id", QUOTED_007, INVALID_FINDING, id="id-text-misread"), + pytest.param(ELECTED_ON_KEY, DATE_DIGITS, INVALID_FINDING, id="date-read-as-a-number"), + pytest.param(ELECTED_ON_KEY, '"!"', INVALID_FINDING, + id="date-the-reader-refuses"), + pytest.param(PINS_KEY, '"? x"', INVALID_FINDING, + id="pin-the-reader-refuses"), + pytest.param(TRACKING_KEY, QUOTED_007, MISREAD_FINDING, + id="branch-text-misread"), + pytest.param(PINS_KEY, QUOTED_NULL, MISREAD_FINDING, id="pin-text-misread"), + pytest.param(SPEC_PATH_KEY, QUOTED_NULL, MISREAD_FINDING, + id="leg-path-text-misread"), + pytest.param(ELECTED_ON_KEY, "'\"2026-09-02\"'", MISREAD_FINDING, + id="date-read-as-another-date"), pytest.param(ELECTED_BY_KEY, '" "', INVALID_FINDING, id="elector-blank"), pytest.param(VISIBILITY_KEY, "secret", INVALID_FINDING, id="visibility-secret"), @@ -832,42 +1008,117 @@ def test_check_judges_each_value_once_by_the_rule_true_of_it( answered, tmp_path, key, spelled, finding): """At the merge of main dd8d0ea, `id: "007"` and `elected_on: "20260902"` were two findings each, #181's and the validator's, and a - visibility that is not text was none: #181 does not judge it, and the - validator left every value read as not text to #181. Each is one.""" + visibility that is not text was none. At 7ac2358 each was one, but + #181's, whose remediation said `execute` writes the value and the run + ends `adoption verified` where `execute` refuses it as the validator's + (rv181f's G1), and `elected_on: "!"` was still two (G2). Each is one, + in the words of the rule true of it.""" _, text = answered plan = tmp_path / PLAN_FILE carrying(plan, text, key, spelled) result = checked(plan) assert result.returncode == 1, result.stdout + result.stderr - judged = [line for line in result.stderr.splitlines() - if line.startswith((MISREAD_FINDING, INVALID_FINDING))] - assert len(judged) == 1, result.stderr - assert judged[0].startswith(finding), result.stderr - - -#: `(a visibility as a plan edited by hand spells it, how the validator's -#: sentence names it)`: read as not text, it is named by what was read, as -#: #181 names such a value, before the text `execute` would write for it. -NOT_TEXT_VISIBILITY = [ - pytest.param("true", "visibility, read out of the plan as the boolean " - "true, is 'True', which", id="boolean"), - pytest.param("[secret]", "visibility, read out of the plan as a list, " - "is \"['secret']\", which", id="list"), + (line,) = judged_lines(result.stderr) + assert line.startswith(finding), result.stderr + remediation = (MANIFEST_MISREAD_REMEDIATION_START + if finding == MISREAD_FINDING else + "Remediation: choose a value the project's own " + "validate-manifest.py accepts") + assert result.stderr.count("Remediation: ") == 1, result.stderr + assert remediation in result.stderr, result.stderr + + +#: `(plan key, as a plan edited by hand spells it, whether the leg-path +#: codes `check` leaves to `_leg_findings` are left out, what +#: `manifest_judged` answers)`. +JUDGED_BY_THE_VALIDATOR = [ + pytest.param("id", QUOTED_007, False, frozenset({"PROJECT_ID"}), + id="its-finding-names-the-value"), + pytest.param(TRACKING_KEY, QUOTED_007, False, frozenset(), + id="a-value-it-does-not-judge"), + pytest.param(ELECTED_ON_KEY, '"!"', False, None, + id="its-reader-refuses-the-file"), + pytest.param(SPEC_PATH_KEY, QUOTED_NULL, False, None, + id="a-finding-about-no-one-value"), + pytest.param(SPEC_PATH_KEY, QUOTED_NULL, True, frozenset(), + id="a-code-check-leaves-to-its-own-rule"), +] + + +@pytest.mark.parametrize("key, spelled, as_check, judged", + JUDGED_BY_THE_VALIDATOR) +def test_the_validator_says_which_values_it_judges( + adopter, answered, tmp_path, key, spelled, as_check, judged): + """`manifest_judged`: the placeholders whose value the validator makes + a finding about, which #181 leaves to it; None where no value can be + said to pass it -- its reader refuses the file, or it makes a finding + about no one value -- and #181 then leaves every value to it (rv181f's + G1, G2).""" + _, text = answered + plan = tmp_path / PLAN_FILE + carrying(plan, text, key, spelled) + naming = adopter.NamingPolicy.load(adopter.NAMING_POLICY) + values = adopter._manifest_values(adopter.Plan.load(plan), naming) + ignore = adopter.LEG_PATH_CODES if as_check else () + assert adopter.manifest_judged(values, naming, ignore=ignore) == judged + + +def test_no_value_passes_a_validator_that_is_not_asked( + adopter, answered, tmp_path): + """Names the naming policy does not classify are `check`'s finding in + their own words, and no manifest is rendered from them, so the + validator is asked nothing: no value can be said to pass it, and #181 + names none until it can be asked.""" + _, text = answered + plan = tmp_path / PLAN_FILE + carrying(plan, text, ASSEMBLY_KEY, "north_wind") + carrying(plan, text_of(plan), TRACKING_KEY, QUOTED_007) + loaded = adopter.Plan.load(plan) + assert adopter._validator_judged(loaded) is None + assert adopter._misread_findings(loaded) == [] + + +#: `(plan key, as a plan edited by hand spells it, how the validator's +#: sentence names it, the validator's code)`: read as not text, each is +#: named by what was read, as #181 names such a value, before the text +#: `execute` would write for it. The validator refuses what the loader +#: reads of each, so each is its finding alone (rv181f's G1); the id and +#: the dates were #181's at 7ac2358. +NOT_TEXT_INVALID = [ + pytest.param(VISIBILITY_KEY, "true", "visibility, read out of the plan " + "as the boolean true, is 'True',", + "manifest-visibility", id="visibility-boolean"), + pytest.param(VISIBILITY_KEY, "[secret]", "visibility, read out of the " + "plan as a list, is \"['secret']\",", + "manifest-visibility", id="visibility-list"), + pytest.param("id", "007", "id, read out of the plan as the number 7, is " + "'7',", "manifest-id", id="id-number"), + pytest.param(ELECTED_ON_KEY, DATE_DIGITS, "elected_on, read out of the " + f"plan as the number {DATE_DIGITS}, is '{DATE_DIGITS}',", + ELECTED_ON_CODE, id="date-number"), + pytest.param(ELECTED_ON_KEY, "[a]", "elected_on, read out of the plan " + "as a list, is \"['a']\",", ELECTED_ON_CODE, + id="date-list"), + pytest.param(ELECTED_ON_KEY, "{a: b}", "elected_on, read out of the " + "plan as a mapping, is \"{'a': 'b'}\",", + ELECTED_ON_CODE, id="date-mapping"), ] -@pytest.mark.parametrize("spelled, named", NOT_TEXT_VISIBILITY) +@pytest.mark.parametrize("key, spelled, named, code", NOT_TEXT_INVALID) def test_the_validator_names_a_value_read_as_not_text_by_what_was_read( - answered, tmp_path, spelled, named): + answered, tmp_path, key, spelled, named, code): """Its sentence quotes `str` of the value, the text `execute` would write: `visibility is 'True'` alone, for a plan that says `true`, would name text the plan never said (review181's N2).""" _, text = answered plan = tmp_path / PLAN_FILE - carrying(plan, text, VISIBILITY_KEY, spelled) + carrying(plan, text, key, spelled) result = checked(plan) - assert (f"{INVALID_FINDING}{named} the project's own validate-manifest.py " - "refuses: manifest-visibility: ") in result.stderr, result.stderr + assert result.returncode == 1, result.stdout + result.stderr + (line,) = judged_lines(result.stderr) + assert line.startswith(f"{INVALID_FINDING}{named} {VALIDATOR_REFUSES}" + f"{code}: "), result.stderr @needs_filter_repo @@ -1038,6 +1289,28 @@ def test_execute_refuses_a_plan_carrying_one_before_any_leg_exists( assert SPLIT_BRANCH not in branches_of(source) +@needs_filter_repo +@pytest.mark.parametrize("key, value, said", CARRIED_INVALID, + ids=ids_of(CARRIED_INVALID, 0, 1)) +def test_execute_refuses_a_value_the_validator_refuses_as_the_validators( + adopter, answered_copy, tmp_path, key, value, said): + """`execute` refuses such a plan under the validator's code, before it + creates a leg or touches the source, so no sentence it prints says the + value is written or that the run gets further (rv181f's G1).""" + source, text = answered_copy + plan = tmp_path / PLAN_FILE + carrying(plan, text, key, adopter.y(value)) + result = executed(plan, tmp_path) + assert result.returncode == 2, result.stdout + result.stderr + assert result.stderr.startswith( + "REFUSED plan-manifest-invalid: "), result.stderr + assert said in result.stderr, result.stderr + assert MISREAD_FINDING not in result.stderr, result.stderr + assert adopter.MANIFEST_MISREAD_REMEDIATION not in result.stderr + assert not (tmp_path / REMOTES).exists() + assert SPLIT_BRANCH not in branches_of(source) + + #: What the gate still lets through, each spelled like a keyword somewhere: #: a leg path with `null` as a segment, a branch the PLAN quotes for its #: leading digit and `project.yaml` writes bare, an id that is a YAML 1.1 @@ -1222,7 +1495,7 @@ def test_the_plan_writer_quotes_a_pin_whatever_refuses_it_later( assert adopter.main([ "plan", "--source", str(source), "--project", PROJECT_NAME, "--org", "testorg", ELECTED_BY_FLAG, "Test Human", - "--elected-on", "2026-09-02", PIN_FLAG, pin, + "--elected-on", ELECTION_DATE, PIN_FLAG, pin, "--work-dir", str(tmp_path / "work"), "--out", str(plan)]) == 0 assert f'\npins: ["{pin}"]\n' in text_of(plan) assert load_yaml(plan)[PINS_KEY] == [pin] From c8715971d8526be4cf0365295f5df165cb403b2b Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Mon, 12 Oct 2026 23:12:03 +0000 Subject: [PATCH 15/24] tests/test_adopt_plan_yaml_quoting.py: the misread rule's sentence about a set id read as not text is asked of it directly, so the naming `_id_field` gives `id: 007` (what was read, not `Plan.project_id`) keeps a test now that the validator, asked first, judges every such id (#181) At f38d19a the only test of that naming was READ_AS_NOT_TEXT's `id='007'` row, which is the validator's finding now (NOT_TEXT_INVALID), so the NIT driver's N2b row (`_field("id", ..., project_id)`) would have failed nothing. The rule's own answer is asked of `manifest_misreads` and `_id_field`, as the derived id's is. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- tests/test_adopt_plan_yaml_quoting.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/test_adopt_plan_yaml_quoting.py b/tests/test_adopt_plan_yaml_quoting.py index b47a7ea0..2cb79b17 100644 --- a/tests/test_adopt_plan_yaml_quoting.py +++ b/tests/test_adopt_plan_yaml_quoting.py @@ -849,6 +849,25 @@ def test_check_names_a_plan_value_that_is_not_text_by_what_was_read( assert INVALID_FINDING not in result.stderr, result.stderr +def test_the_misread_rule_names_a_set_id_by_what_was_read( + adopter, answered, tmp_path): + """`id: 007` is the number 7 to the plan's reader. Should #181 be the + rule that reports it, its sentence names what was READ, as the plan's + `id:` holds it, and never `str` of what `Plan.project_id` makes of it + (review181's N2). The validator refuses it today, and is asked first + (`NOT_TEXT_INVALID`), so this is the rule's own answer, asked of it + directly.""" + _, text = answered + plan = tmp_path / PLAN_FILE + carrying(plan, text, "id", "007") + (said,) = adopter.manifest_misreads([adopter._id_field( + adopter.Plan.load(plan))]) + assert said.startswith(f"id was read out of the plan as {SEVEN_READ}, " + "which `execute` writes into "), said + assert said.endswith(f"reads it back as {SEVEN_READ} and not as the " + "text `7`: choose another value"), said + + #: `(plan key, as a plan edited by hand spells it)`: values the project's #: loader would misread, which `execute` never writes. It refuses the branch #: as `unsafe-value` (`_checked_plan_values`), and `gh repo create` takes no From 576ca64ccf107540c5594a482388f998d0672677 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Mon, 12 Oct 2026 23:19:36 +0000 Subject: [PATCH 16/24] tests/test_adopt_plan_yaml_quoting.py: `plan`'s refusal of an id it derived is asked through `_checked_plan_inputs` with the validator's refusal taken out, so the line that tells a derived flag from a passed one (`given`) keeps a test now that the validator refuses every such id first (#181) At c871597 the test handed `_flag_fields` an empty `given` itself, so reverting `_checked_plan_inputs`' `given` (the fix driver's R11) failed nothing. It now asks `_checked_plan_inputs` for `--project True`, with `_refuse_unholdable_values` monkeypatched to pass, and reads the `adopt-manifest-misread` it raises: R11 fails it. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- tests/test_adopt_plan_yaml_quoting.py | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/tests/test_adopt_plan_yaml_quoting.py b/tests/test_adopt_plan_yaml_quoting.py index 2cb79b17..14498107 100644 --- a/tests/test_adopt_plan_yaml_quoting.py +++ b/tests/test_adopt_plan_yaml_quoting.py @@ -661,22 +661,27 @@ def test_plan_leaves_an_id_it_derived_from_the_project_to_the_validator( def test_the_misread_rule_names_an_id_plan_derived_by_where_it_came_from( - adopter): - """Should #181 be the rule that reports an id `plan` derived, its + adopter, monkeypatch): + """Should #181 be the rule that refuses an id `plan` derived, its sentence says where the value came from, so the person knows the flag that chooses another. The validator refuses every id the loader - misreads today, and is asked first, so this is the rule's own answer, - asked of it directly.""" + misreads today, and is asked first, so `plan` is asked here with the + validator's refusal taken out: what is left is the rule's own answer.""" + monkeypatch.setattr(adopter, "_refuse_unholdable_values", + lambda *_args: None) args = argparse.Namespace( - id="true", tracking_branch="main", visibility="private", - elected_by="Test Human", elected_on=ELECTION_DATE, reference="r", - spec_path="spec", code_path="code") + project="True", org="testorg", id=None, visibility="private", + elected_by="Test Human", elected_on=ELECTION_DATE, reference=None, + tracking_branch="main", spec_path="spec", code_path="code", pin=[]) + naming = adopter.NamingPolicy.load(adopter.NAMING_POLICY) + with pytest.raises(adopter.Refusal) as refused: + adopter._checked_plan_inputs( + args, argparse.Namespace(repository=None), naming) + assert refused.value.code == "adopt-manifest-misread", refused.value who, what = misread("--id (not given, so --project lowercased)", "true", TRUE_READ) - said = adopter.manifest_misreads(adopter._flag_fields(args, set(), [])) - assert [sentence for sentence in said - if sentence.startswith(who) - and sentence.endswith(what.rstrip("."))], said + assert refused.value.detail.startswith(who), refused.value.detail + assert refused.value.detail.endswith(what), refused.value.detail def test_plan_takes_an_elector_from_git_the_manifests_quotes_read_back( From 6a3a6b3ff9b0da2b18371c6c42eb17335ea24dad Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 13 Oct 2026 01:18:57 +0000 Subject: [PATCH 17/24] adopt-project.py: a tracking branch the loader misreads that git will not create, `~`, is left out of #181's gate, as a branch `checked_value` refuses is, because `execute` dies at `git init -b ~` before it writes anything, so no misread remediation says it writes the branch and ends `adoption verified` (#181) rv181f's H1 at edf9163, under the coordinator's ruling (direction (a)): - `_execute_gets_to_write` (check, execute) and `_flag_fields` (plan) ask `_a_branch_git_creates`: a tracking branch the project's loader misreads is asked of #283's `branch_problem`, and one git will not create is not this gate's. `~` then behaves as on main: `plan` records it, `check` says `plan ok`, and `execute` dies at `git init -b ~`, a residual this change shares with main. - Git is asked ONLY about a branch the loader misreads, so a plan whose values all read back starts no process (`test_the_question_runs_no_process_at_all`). - Tests: `tracking_branch: "~"` in NEVER_WRITTEN; `plan --tracking-branch ~` records it with no misread refusal; git is asked about `~` and `true` and not about `develop`. - NIT-2 in the UNWRITABLE comment: the validator lists the manifest beside a line break; a pin holding a tab is #255's finding alone. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- adopt-project.py | 39 +++++++++++++++++--- tests/test_adopt_plan_yaml_quoting.py | 51 +++++++++++++++++++++++++-- 2 files changed, 82 insertions(+), 8 deletions(-) diff --git a/adopt-project.py b/adopt-project.py index 31f822c2..39ae417a 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -173,6 +173,9 @@ # 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. from shape_materialize import unwritable_reason # noqa: E402 +# Whether git creates a tracking branch as it is written (#283), asked only +# of one the project's loader misreads (`_a_branch_git_creates`, #181). +from shape_materialize import branch_problem # noqa: E402 #: The naming policy this tool classifies leg names against. One constant, #: because three spellings of the same path is how the second one goes stale. @@ -2043,7 +2046,11 @@ def manifest_misreads(fields) -> list[str]: def _flag_fields(args, given: set, pins: list[str]) -> list[tuple]: """`plan`'s values as it is about to record them, each named by its flag, - and by where `plan` took it from when `given` says it was not passed.""" + and by where `plan` took it from when `given` says it was not passed. + + Less a tracking branch the loader misreads that git will not create + (`_a_branch_git_creates`): `plan` records it, as on main, and `execute` + dies at `git init -b`, before it writes anything.""" def flag(key: str) -> str: spelled = "--" + key.replace("_", "-") if key in FLAG_DEFAULTS and key not in given: @@ -2052,7 +2059,9 @@ def flag(key: str) -> str: fields = [_field(flag("id"), ID_PLACEHOLDER, args.id)] fields += [_field(flag(key), placeholder, getattr(args, key)) - for key, placeholder in MANIFEST_FIELDS.items()] + for key, placeholder in MANIFEST_FIELDS.items() + if key != TRACKING_KEY + or _a_branch_git_creates(flag(key), getattr(args, key))] fields += [_field(f"--{role}-path", placeholder, getattr(args, f"{role}_path")) for role, placeholder in LEG_PATH_PLACEHOLDERS.items()] @@ -2104,7 +2113,10 @@ def _execute_gets_to_write(key: str, value) -> bool: Nor for any other value no file can hold (`unwritable_reason`): `check` names it as `plan-unwritable-value`, and `execute` refuses a plan holding it before any leg exists (#255), so one wrong value is one - finding, and none says the run gets further.""" + finding, and none says the run gets further. + + Nor for a tracking branch the loader misreads that git will not create + (`_a_branch_git_creates`).""" if key == VISIBILITY_KEY: return str(value) in VISIBILITY_CHOICES if key not in GIT_ARGUMENT_KEYS: @@ -2113,7 +2125,24 @@ def _execute_gets_to_write(key: str, value) -> bool: checked_value(key, value) except Refusal: return False - return True + return _a_branch_git_creates(key, value) + + +def _a_branch_git_creates(what: str, value) -> bool: + """False for a tracking branch the project's loader misreads that git + will not create (`branch_problem`, #283): `~`, which the loader reads as + no value. `execute` hands the branch to `git init -b` when it creates + the leg repositories, and dies there, before it writes anything, as on + main; so a sentence saying it writes the branch, and that the run would + still end `adoption verified`, would be false (rv181f's H1). Main's + `execute` asks git nothing about the branch before that, which is not + this rule's to change. + + Git is asked ONLY about a branch the loader misreads. One it reads back + is no concern of this rule's, and a plan whose values all read back + starts no process.""" + field = _field(what, MANIFEST_FIELDS[TRACKING_KEY], value) + return not manifest_misreads([field]) or branch_problem(what, value) is None def _plan_fields(plan: Plan) -> list[tuple[str, tuple]]: @@ -2126,7 +2155,7 @@ def _plan_fields(plan: Plan) -> list[tuple[str, tuple]]: default of this tool's own (`main`, `private`, today's date), and none of those is misread. So is a value `execute` never gets as far as writing (`_execute_gets_to_write`): it refuses it first, as `unsafe-value` or as - #255's `plan-unwritable-value`, or `gh` does. A leg path is asked about + #255's `plan-unwritable-value`, or `gh` or `git` does. A leg path is asked about only when it is text with no `plan-bad-leg-path` of its own, so one wrong value is one finding. An id `Plan.project_id` refuses, or a refused leg name it is derived from, is refused on its own account by diff --git a/tests/test_adopt_plan_yaml_quoting.py b/tests/test_adopt_plan_yaml_quoting.py index 14498107..be61a6a6 100644 --- a/tests/test_adopt_plan_yaml_quoting.py +++ b/tests/test_adopt_plan_yaml_quoting.py @@ -888,8 +888,13 @@ def test_the_misread_rule_names_a_set_id_by_what_was_read( #: the validator accepts as read, so nothing names them, as on main: with #: `--local-remote-dir` the run ends `adoption verified`, the field read as #: that (rv181f's G3, a residual this change shares with main). +#: +#: So is a branch the loader misreads that git will not create, `~`, read +#: as no value: `execute` dies at `git init -b ~` before it writes anything, +#: as on main (rv181f's H1, a residual this change shares with main too). NEVER_WRITTEN = [ pytest.param(TRACKING_KEY, QUOTED_DOMAIN_USER, id=TRACKING_KEY), + pytest.param(TRACKING_KEY, '"~"', id="tracking_branch-tilde"), pytest.param(VISIBILITY_KEY, TRUE_TEXT, id=VISIBILITY_KEY), *[pytest.param(VISIBILITY_KEY, spelled, id=f"visibility-{name}") for spelled, name in [ @@ -917,6 +922,45 @@ def test_check_says_nothing_misread_of_a_value_execute_never_writes( assert adopter.MANIFEST_MISREAD_REMEDIATION not in result.stderr +#: A tracking branch the loader reads as no value, and git will not create. +BRANCH_GIT_REFUSES = "~" + + +def test_plan_records_a_branch_git_will_not_create_as_on_main( + answered, tmp_path): + """`plan` refused `--tracking-branch ~` as misread, with a remediation + saying `execute` writes it and the run would still end `adoption + verified`. `execute` dies at `git init -b ~` before it writes anything, + so `plan` says nothing of it, as on main (rv181f's H1).""" + source, _ = answered + plan = tmp_path / PLAN_FILE + result = write_plan(source, plan, + extra=(TRACKING_FLAG, BRANCH_GIT_REFUSES)) + assert result.returncode == 0, result.stdout + result.stderr + assert "adopt-manifest-misread" not in result.stderr, result.stderr + + +def test_git_is_asked_only_about_a_branch_the_loader_misreads( + adopter, monkeypatch): + """`branch_problem` starts git. It is asked about `~` and `true`, which + the loader misreads, and not about `develop`, which it reads back, so a + plan whose values all read back starts no process. Git creates `true`, + so that one is still the misread rule's (`REFUSED`).""" + asked = [] + real = adopter.branch_problem + + def recorded(what, value): + asked.append(value) + return real(what, value) + + monkeypatch.setattr(adopter, "branch_problem", recorded) + assert adopter._execute_gets_to_write(TRACKING_KEY, "develop") + assert not adopter._execute_gets_to_write(TRACKING_KEY, + BRANCH_GIT_REFUSES) + assert adopter._execute_gets_to_write(TRACKING_KEY, "true") + assert asked == [BRANCH_GIT_REFUSES, "true"] + + @needs_filter_repo def test_execute_refuses_that_branch_as_unsafe_before_any_leg_exists( answered_copy, tmp_path): @@ -942,9 +986,10 @@ def test_execute_refuses_that_branch_as_unsafe_before_any_leg_exists( #: a line break, in a value read back as one line, and a control character #: such as a tab. The date and the pins are written bare, where the #: project's loader would read each as something else too; the visibility -#: is not one #181 judges (`NEVER_WRITTEN`). The project's own validator -#: lists the manifest such a value makes beside #255's finding, as main has -#: it (#255 before #261, `_entry_findings`). +#: is not one #181 judges (`NEVER_WRITTEN`). Beside a line break, the +#: project's own validator lists the manifest such a value makes, as main +#: has it (#255 before #261, `_entry_findings`); a pin holding a tab is +#: #255's finding alone. UNWRITABLE = [ pytest.param(ELECTED_ON_KEY, UNWRITABLE_DATE, id=ELECTED_ON_KEY), pytest.param(VISIBILITY_KEY, '"private\\nx"', id=VISIBILITY_KEY), From 4ffd46094a39fa676049943cdac151e7983fbc17 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 13 Oct 2026 03:33:37 +0000 Subject: [PATCH 18/24] tests: a hand-edited `elected_on:` that `election_date` refuses is #294's one finding, `election-date-malformed`, with no misread or validator finding beside it, which the merge of main 965420b makes of every date these tests carried (#181) 965420b's `_plan_field_findings` asks `election_date` about a plan's `elected_on:`, and `_manifest_values` renders no manifest for a date it refuses, so the validator is asked nothing and #181 names no value (`_validator_judged` is None). The dates this branch's tests carried as the validator's or #181's are now #294's: - tests/test_adopt_plan_yaml_quoting.py: `LATE_DATES` (`"20260902"`, `20260902`, `"!"`, `'"2026-09-02"'`, `[a]`, `{a: b}`) are one `election-date-malformed` finding at `check`, with no misread finding or remediation, and `_misread_findings` is `[]`. They leave JUDGED_ONCE (four rows), NOT_TEXT_INVALID (three) and CARRIED_INVALID (EDITED_ONLY, with ELECTED_ON_CODE, now unused). `manifest_judged`'s case of a manifest its reader refuses carries the pin `? x` in place of `elected_on: "!"`. - tests/test_adopt_manifest_values.py: this branch's three tests of the elected-on rows expect main's `said_of` for a date, as main's own do. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- tests/test_adopt_manifest_values.py | 19 +++++-- tests/test_adopt_plan_yaml_quoting.py | 81 +++++++++++++++------------ 2 files changed, 58 insertions(+), 42 deletions(-) diff --git a/tests/test_adopt_manifest_values.py b/tests/test_adopt_manifest_values.py index 3f608f11..1010ed30 100644 --- a/tests/test_adopt_manifest_values.py +++ b/tests/test_adopt_manifest_values.py @@ -668,16 +668,19 @@ def test_a_value_the_reader_reads_as_another_kind_is_named_as_read( world, old, new): """`id: 007` is the number 7 and `elected_on: 20260902` another number: what the template would write is `str` of what was read, which the plan - never said. The validator refuses what is read, so the finding is its + never said. The validator refuses the id as read, so the finding is its alone, and names the value by what was read before the text `execute` would write (tests/test_adopt_plan_yaml_quoting.py, `NOT_TEXT_INVALID`); - #181's misread rule leaves to it every value it refuses (rv181f's G1).""" + #181's misread rule leaves to it every value it refuses (rv181f's G1). + The date is #294's one finding (`said_of`).""" plan = edited(world, old, new) result = run_script(ADOPT, "check", "--plan", str(plan)) (finding,) = [line for line in result.stderr.splitlines() - if line.startswith(FINDING)] + if line.startswith("FINDING ")] assert "1 finding(s)" in result.stderr, result.stderr - assert ", read out of the plan as the number " in finding, finding + assert finding.startswith(said_of(new)[0]), finding + if said_of(new)[0] == FINDING: + assert ", read out of the plan as the number " in finding, finding # --- a text value the reader misreads: judged once, not twice --------------- @@ -760,7 +763,11 @@ def test_where_the_validator_judges_it_the_misread_rule_says_nothing( validator's before anything exists (rv181f's G1).""" plan = adopter.Plan.load(edited(world, old, new)) assert adopter._misread_findings(plan) == [] - (finding,), _ = adopter._manifest_findings(plan) + findings, _ = adopter._manifest_findings(plan) + if said_of(new)[0] != FINDING: + assert findings == [] + findings, _ = adopter._plan_field_findings(plan) + (finding,) = findings assert named in finding @@ -801,7 +808,7 @@ def test_a_text_value_the_reader_misreads_is_one_finding_in_the_list_check_print value = new.split(": ", 1)[1].strip('"') about = [f for f in findings if value in f] assert len(about) == 1, findings - assert about[0].startswith(FINDING), findings + assert about[0].startswith(said_of(new)[0]), findings # --- a validator that cannot be asked --------------------------------------- diff --git a/tests/test_adopt_plan_yaml_quoting.py b/tests/test_adopt_plan_yaml_quoting.py index be61a6a6..8212047b 100644 --- a/tests/test_adopt_plan_yaml_quoting.py +++ b/tests/test_adopt_plan_yaml_quoting.py @@ -367,10 +367,8 @@ def test_a_value_is_asked_about_in_each_place_it_is_written( TRUE_TEXT = '"true"' QUOTED_007 = '"007"' QUOTED_NULL = '"null"' -#: How `plan`'s and `check`'s codes for #181's rule end, and the -#: validator's code for an election date. +#: How `plan`'s and `check`'s codes for #181's rule end. MISREAD_CODE = "manifest-misread" -ELECTED_ON_CODE = "manifest-elected-on" #: The plan key of the assembly's name, which an id nobody set comes from. ASSEMBLY_KEY = "legs.assembly" #: The election date a plan below is written with. @@ -463,17 +461,6 @@ def test_a_value_is_asked_about_in_each_place_it_is_written( ("--id", "true", "id", f"{ID_SAID}True; it must match "), ("--id", "null", "id", f"{ID_SAID}None; it must match "), ] -#: `(plan key, value, the validator's words)` that only an edited plan can -#: carry here: `plan` refuses `--elected-on 20260902` as -#: `election-date-malformed` on every Python since #261, which holds the date -#: to the validator's own `YYYY-MM-DD`. The loader reads it as a number, and -#: the validator refuses that (`manifest-elected-on`): its finding alone. -#: No visibility is here: `plan` takes one from a fixed choice, and none -#: other is #181's (`_execute_gets_to_write`, `NEVER_WRITTEN`). -EDITED_ONLY = [ - (ELECTED_ON_KEY, DATE_DIGITS, f"{ELECTED_ON_CODE}: elected_on is " - f"{DATE_DIGITS}, expected an ISO date YYYY-MM-DD"), -] #: #181's, as `plan` is given it, and as a plan carries it. PLANNED = [(flag, value, reading) for flag, value, _, reading in REFUSED] CARRIED = [(key, value, reading) for _, value, key, reading in REFUSED] \ @@ -482,8 +469,10 @@ def test_a_value_is_asked_about_in_each_place_it_is_written( PLANNED_INVALID = [ (flag, value, said) for flag, value, _, _, said in LEG_PATHS] + [ (flag, value, said) for flag, value, _, said in VALIDATORS] -CARRIED_INVALID = [(key, value, said) for _, value, key, said in VALIDATORS] \ - + EDITED_ONLY +#: No date is here: an `elected_on:` `election_date` refuses is #294's +#: finding alone (`LATE_DATES`). No visibility is either: `plan` takes one +#: from a fixed choice, and none other is #181's (`NEVER_WRITTEN`). +CARRIED_INVALID = [(key, value, said) for _, value, key, said in VALIDATORS] def ids_of(rows, key: int, value: int) -> list[str]: @@ -1044,13 +1033,7 @@ def test_execute_refuses_a_date_no_file_can_hold_as_unwritable_not_misread( #: #181's: a branch, a pin, a leg path (`check` leaves its codes to #: `_leg_findings`), and a date read back as other text that is a date. JUDGED_ONCE = [ - pytest.param(ELECTED_ON_KEY, f'"{DATE_DIGITS}"', INVALID_FINDING, - id="date-text-misread"), pytest.param("id", QUOTED_007, INVALID_FINDING, id="id-text-misread"), - pytest.param(ELECTED_ON_KEY, DATE_DIGITS, INVALID_FINDING, - id="date-read-as-a-number"), - pytest.param(ELECTED_ON_KEY, '"!"', INVALID_FINDING, - id="date-the-reader-refuses"), pytest.param(PINS_KEY, '"? x"', INVALID_FINDING, id="pin-the-reader-refuses"), pytest.param(TRACKING_KEY, QUOTED_007, MISREAD_FINDING, @@ -1058,8 +1041,6 @@ def test_execute_refuses_a_date_no_file_can_hold_as_unwritable_not_misread( pytest.param(PINS_KEY, QUOTED_NULL, MISREAD_FINDING, id="pin-text-misread"), pytest.param(SPEC_PATH_KEY, QUOTED_NULL, MISREAD_FINDING, id="leg-path-text-misread"), - pytest.param(ELECTED_ON_KEY, "'\"2026-09-02\"'", MISREAD_FINDING, - id="date-read-as-another-date"), pytest.param(ELECTED_BY_KEY, '" "', INVALID_FINDING, id="elector-blank"), pytest.param(VISIBILITY_KEY, "secret", INVALID_FINDING, id="visibility-secret"), @@ -1105,7 +1086,7 @@ def test_check_judges_each_value_once_by_the_rule_true_of_it( id="its-finding-names-the-value"), pytest.param(TRACKING_KEY, QUOTED_007, False, frozenset(), id="a-value-it-does-not-judge"), - pytest.param(ELECTED_ON_KEY, '"!"', False, None, + pytest.param(PINS_KEY, '"? x"', False, None, id="its-reader-refuses-the-file"), pytest.param(SPEC_PATH_KEY, QUOTED_NULL, False, None, id="a-finding-about-no-one-value"), @@ -1147,12 +1128,49 @@ def test_no_value_passes_a_validator_that_is_not_asked( assert adopter._misread_findings(loaded) == [] +#: How a plan edited by hand spells an `elected_on:` that `election_date` +#: refuses. #294's rule (`_plan_field_findings`) names it as +#: `election-date-malformed`, in the plan key's own words, and `execute` +#: refuses it before any leg exists. `_manifest_values` then renders no +#: manifest, so the validator is asked nothing and #181 names no value +#: (`_validator_judged`): the date is one finding. Before the merge of main +#: 965420b, the first three and the last two were the validator's, and the +#: fourth, a date in its own quotes, #181's. +LATE_DATES = [ + pytest.param(f'"{DATE_DIGITS}"', id="text-misread"), + pytest.param(DATE_DIGITS, id="read-as-a-number"), + pytest.param('"!"', id="the-reader-refuses"), + pytest.param("'\"2026-09-02\"'", id="read-as-another-date"), + pytest.param("[a]", id="list"), + pytest.param("{a: b}", id="mapping"), +] + + +@pytest.mark.parametrize("spelled", LATE_DATES) +def test_check_leaves_a_date_election_date_refuses_to_that_rule( + adopter, answered, tmp_path, spelled): + """One finding, #294's, and no misread finding or remediation beside it: + #181's would say `execute` writes the date and the run would still end + `adoption verified`, where `execute` refuses it before any leg exists.""" + _, text = answered + plan = tmp_path / PLAN_FILE + carrying(plan, text, ELECTED_ON_KEY, spelled) + result = checked(plan) + assert result.returncode == 1, result.stdout + result.stderr + findings = [line for line in result.stderr.splitlines() + if line.startswith("FINDING ")] + assert len(findings) == 1, result.stderr + assert findings[0].startswith("FINDING election-date-malformed: "), ( + result.stderr) + assert adopter.MANIFEST_MISREAD_REMEDIATION not in result.stderr + assert adopter._misread_findings(adopter.Plan.load(plan)) == [] + #: `(plan key, as a plan edited by hand spells it, how the validator's #: sentence names it, the validator's code)`: read as not text, each is #: named by what was read, as #181 names such a value, before the text #: `execute` would write for it. The validator refuses what the loader -#: reads of each, so each is its finding alone (rv181f's G1); the id and -#: the dates were #181's at 7ac2358. +#: reads of each, so each is its finding alone (rv181f's G1); the id was +#: #181's at 7ac2358. A date read as not text is #294's (`LATE_DATES`). NOT_TEXT_INVALID = [ pytest.param(VISIBILITY_KEY, "true", "visibility, read out of the plan " "as the boolean true, is 'True',", @@ -1162,15 +1180,6 @@ def test_no_value_passes_a_validator_that_is_not_asked( "manifest-visibility", id="visibility-list"), pytest.param("id", "007", "id, read out of the plan as the number 7, is " "'7',", "manifest-id", id="id-number"), - pytest.param(ELECTED_ON_KEY, DATE_DIGITS, "elected_on, read out of the " - f"plan as the number {DATE_DIGITS}, is '{DATE_DIGITS}',", - ELECTED_ON_CODE, id="date-number"), - pytest.param(ELECTED_ON_KEY, "[a]", "elected_on, read out of the plan " - "as a list, is \"['a']\",", ELECTED_ON_CODE, - id="date-list"), - pytest.param(ELECTED_ON_KEY, "{a: b}", "elected_on, read out of the " - "plan as a mapping, is \"{'a': 'b'}\",", - ELECTED_ON_CODE, id="date-mapping"), ] From f911d81d63d4e6c77f84cf635573f089e3fe8f09 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 13 Oct 2026 03:34:22 +0000 Subject: [PATCH 19/24] adopt-project.py: `_misread_findings` and `_manifest_findings` no longer give an `elected_on:` as the validator's example, since the merge of main 965420b makes such a date #294's `election-date-malformed`, about which neither rule is asked (#181) Docstrings only. `_misread_findings` names a pin `? x` as the manifest the validator's reader refuses, and says this rule names no value where `_manifest_values` renders no manifest. `_manifest_findings` drops `elected_on: 20260902` from the values it judges. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- adopt-project.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/adopt-project.py b/adopt-project.py index c4b0fef5..9c1b0ca7 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -2197,9 +2197,12 @@ def _misread_findings(plan: Plan) -> list[str]: THE VALIDATOR IS ASKED FIRST, as `check` asks it (`_manifest_findings`, the same `ignore`), and a value it makes a finding about is its alone: `id: "007"` is `manifest-id: id is 7`, as on main, and not also this. - Where it refuses the whole file (`elected_on: "!"`, which its reader - cannot read) or makes a finding about no one value, no value can be - said to pass it, so every value is its, as on main (rv181f's G1, G2). + Where it refuses the whole file (a pin `? x`, which its reader cannot + read) or makes a finding about no one value, no value can be said to + pass it, so every value is its, as on main (rv181f's G1, G2); and where + it is asked about no manifest (`_manifest_values` is None, as for an + `elected_on:` `election_date` refuses, #294's finding), this rule names + no value either. That is what makes this rule's remediation true of every value it follows: `execute` writes it as it stands, and the run would still end `adoption verified`. A leg path the loader reads as no value is still @@ -2457,8 +2460,9 @@ def _manifest_findings(plan: Plan) -> tuple[list[str], list[str]]: and one #181's misread rule would also call misread: a value this rule refuses is this rule's alone, and the misread rule leaves it here (`_misread_findings`, rv181f's G1). So `id: "007"` is `manifest-id: id - is 7`, as on main, `elected_on: 20260902` and `visibility: true` are - this rule's too, and one wrong value is one finding. A value read as not + is 7`, as on main, `visibility: true` is this rule's too, and one wrong + value is one finding. (`elected_on: 20260902` is #294's, and this rule + is not asked about it: `_manifest_values`.) A value read as not text is named by what was read (`_named_as_read`), because the sentence goes on to quote `str` of it, the text `execute` would write and not what the plan says. From 69a743ede593834612d7fd2d18a6adcb4b797d85 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 13 Oct 2026 12:07:50 +0000 Subject: [PATCH 20/24] adopt-project.py: #181's misread gate names a tracking branch only where `execute` pushes the legs to it in every mode, and a pin by the product `execute` writes and only where its `pins:` has no finding of #273's, so a branch the source lacks or only `origin` has is no longer called written, and a `product@<40 hex>` pin is asked about at all (#181) The merge of main cc8f2da brought #479 (56713e2) and #273 (aa4e1f5) under #181's gate, which names a value the project's loader misreads under a remediation saying `execute` writes it as it stands and the run still ends `adoption verified`. The tracking branch (#479). `check` now finds a tracking branch that is no branch of the source as `plan-tracking-branch-missing`, and `execute` refuses it before any leg exists; under `--local-remote-dir` `execute` also refuses one that is not the source's own, one only its `origin` has among them. On the bare merge, `check` named `true` there twice, #479's finding and the misread one, and `plan --tracking-branch true` was refused under the misread remediation, though `execute` never writes it. Now `_a_branch_execute_writes` asks, of a branch the loader misreads only, git's question (#283, H1) and `_pushed_in_every_mode`: a branch of the source as #479 reads it (`_tracking_branch_problem`), and one of the source's own, listed as `execute --local-remote-dir` lists it (`_branches_at`). `plan` (`_flag_fields`) leaves out such a branch and records it, with #479's note where the source lacks it; `check` and `execute` (`_entry_findings`) pass its placeholder to `_misread_findings` as one it leaves to the caller (`unwritten`). A branch that reads back starts no process. A defaulted branch is named as #479 names it, `--tracking-branch (not given, so the source's default branch)` (`FLAG_DEFAULTS`). The pins (#273). A pin is a mapping since #273, and `_plan_fields` read each `pins:` item as a name. It now asks about the product of each pin `_read_pins` accepts, once each, and about none where `pins:` has a finding of #273's: `execute` refuses such a plan before any leg exists. `plan` names one as `the product of a --pin`, `check` as `the product of a pin in pins`. A product holds no whitespace since #273, so `_pin_field`'s comment says so. `_checked_plan_inputs` reached Sonar's cognitive complexity of 16 with #479's two conditionals and #181's refusal; that refusal is now `_refuse_misread_values`, and the function is at main's 15. Tests (tests/test_adopt_plan_yaml_quoting.py): - Every tracking-branch row is asked of a copy of the source that has the branch (`holding`), one branch per copy: `true` and `True` are one file on a disk that ignores case. The accepted end-to-end run's source has `2026-q4`. - A `--pin` is `@<40 hex>` with `--pin-source` (`glass`, `flag_args`), and a carried pin is whole (`whole_pin`); the writer tests read `_pin_lines`' mappings. The trailing-whitespace pin rows are #273's `plan-pin-malformed` alone now, and a product read as not text too. - New: a misread branch the source lacks is #479's finding alone, at `plan` and `check`; one only `origin` has is named by neither, and main's `execute --local-remote-dir` refuses it before any leg exists; a default branch `true` is refused naming where it came from; the source is asked only about a misread branch; a pin beside a #273 finding is not named misread. - The derived-id unit test passes `--pin-owner` and `--pin-source`, which #273's `_checked_plan_inputs` reads. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- adopt-project.py | 153 ++++++++--- tests/test_adopt_plan_yaml_quoting.py | 366 ++++++++++++++++++++++---- 2 files changed, 430 insertions(+), 89 deletions(-) diff --git a/adopt-project.py b/adopt-project.py index 8ce5c593..745a7a09 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -2153,18 +2153,24 @@ def _kept_collision_notes(args, entries: list, tree: list) -> list[str]: ELECTED_ON_KEY: "ELECTED_ON", REFERENCE_KEY: "REFERENCE"} LEG_PATH_PLACEHOLDERS = {"spec": "SPEC_PATH", "code": "CODE_PATH"} #: How `plan` fills a flag nobody passed, where the value it fills in can be -#: one the loader misreads: a sentence about it names where it came from. +#: one the loader misreads: a sentence about it names where it came from, +#: as `checked_value` names the same default (the tracking branch, #479). #: Not the elector it takes from `git config user.name`: the manifest writes #: that between double quotes, which `render` spells with `double_quoted` #: (#284), and no elector is misread there. -FLAG_DEFAULTS = {"id": "--project lowercased"} +FLAG_DEFAULTS = {"id": "--project lowercased", + TRACKING_KEY: "the source's default branch"} #: How a sentence names the id `execute` derives when the plan sets none: #: when it has no `id:`, or an `id:` with no value (`null`, `~` or nothing, #: as `main`'s `plan --id null` wrote it), which `Plan.get` reads alike. DERIVED_ID = "id (not set in the plan, so legs.assembly lowercased)" -#: How a sentence names one of a plan's `pins:`: the key holds a list, and a -#: sentence about what one item was read as is about that item. -PIN_IN_PLAN = "a pin in pins" +#: How a sentence names one of a plan's `pins:`: the key holds a list of +#: pins, each a mapping since #273, and what `execute` writes into the +#: manifest's list for one is its product. +PIN_IN_PLAN = "the product of a pin in pins" +#: And how `plan` names the same value: the product of a `--pin` it read +#: (`product@<40 hex>`), which is not the whole of what was typed. +PIN_FLAG_PRODUCT = "the product of a --pin" #: What a sentence calls a character a terminal shows as something else, or #: not at all, which `_worded` names instead of printing. CHARACTER_NAMES = {"\t": "a tab", "\n": "a line break", @@ -2231,9 +2237,10 @@ def _field(what: str, placeholder: str, value) -> tuple[str, object, list]: def _pin_field(what: str, pin) -> tuple[str, object, list]: - # Only the item's indent goes: `execute` writes the pin as it stands, - # whitespace after it included, and the sentence names that line. The - # loader drops the whitespace, which is the misreading it reports. + # Only the item's indent goes: `execute` writes the pin's product as it + # stands, and the sentence names that line. Since #273 the product is + # `parse_pin`'s, or a `pins:` entry `_read_pins` accepts, which both + # hold to `PIN_ARG_RE`'s alphabet, so it holds no whitespace. return what, pin, [(f"{MANIFEST_YAML}'s neutral_product_pins", _pin_item(str(pin)).lstrip())] @@ -2329,13 +2336,19 @@ def manifest_misreads(fields) -> list[str]: return problems -def _flag_fields(args, given: set, pins: list[str]) -> list[tuple]: +def _flag_fields(args, given: set, pins: dict, + source: Source) -> list[tuple]: """`plan`'s values as it is about to record them, each named by its flag, and by where `plan` took it from when `given` says it was not passed. - - Less a tracking branch the loader misreads that git will not create - (`_a_branch_git_creates`): `plan` records it, as on main, and `execute` - dies at `git init -b`, before it writes anything.""" + `pins` is `declared_pin_values`' `{product: PIN_* values}`: the product + is what `execute` writes into the manifest's list (#273). + + Less a tracking branch the loader misreads that `execute` never writes + in some mode (`_a_branch_execute_writes`): git will not create it, and + `execute` dies at `git init -b`, as on main; or `execute` does not push + the legs to it in every mode, and refuses it before any leg exists + (#479). `plan` records either, and notes one that is no branch of the + source.""" def flag(key: str) -> str: spelled = "--" + key.replace("_", "-") if key in FLAG_DEFAULTS and key not in given: @@ -2346,11 +2359,13 @@ def flag(key: str) -> str: fields += [_field(flag(key), placeholder, getattr(args, key)) for key, placeholder in MANIFEST_FIELDS.items() if key != TRACKING_KEY - or _a_branch_git_creates(flag(key), getattr(args, key))] + or _a_branch_execute_writes(source, flag(key), + getattr(args, key))] fields += [_field(f"--{role}-path", placeholder, getattr(args, f"{role}_path")) for role, placeholder in LEG_PATH_PLACEHOLDERS.items()] - return fields + [_pin_field("--pin", pin) for pin in pins] + return fields + [_pin_field(PIN_FLAG_PRODUCT, product) + for product in pins] def _id_field(plan: Plan) -> tuple: @@ -2430,6 +2445,49 @@ def _a_branch_git_creates(what: str, value) -> bool: return not manifest_misreads([field]) or branch_problem(what, value) is None +def _a_branch_execute_writes(source: Source, what: str, value) -> bool: + """`_a_branch_git_creates`, asked where the source is in hand (`plan`, + and `check` and `execute` through `_entry_findings`): False also for a + tracking branch the loader misreads that `execute` does not push the + legs to in every mode (`_pushed_in_every_mode`, #479), and refuses + before any leg exists. As git is, the source is asked ONLY about a + branch the loader misreads, so one that reads back starts no process.""" + field = _field(what, MANIFEST_FIELDS[TRACKING_KEY], value) + return not manifest_misreads([field]) or ( + branch_problem(what, value) is None + and _pushed_in_every_mode(source, str(value))) + + +def _pushed_in_every_mode(source: Source, tracking: str) -> bool: + """Does `execute` push the legs to `tracking`, and write it into the + project, in EVERY mode it runs in, as the source knows its branches? + `check` is not told which mode it will be (#479). + + In gh mode, `execute`'s default, the split goes to the repository the + source is a clone of. `check` reads that repository's branches from the + clone (`_tracking_branch_problem`) and finds a branch it lacks as + `plan-tracking-branch-missing`, which `execute` refuses before any leg + exists. Under `--local-remote-dir` the split is pushed back into the + source itself, and `execute` refuses a branch that is not one of the + source's OWN before any leg exists, one only its remote `origin` has + among them (`_refuse_a_tracking_branch_the_remote_lacks`). So a sentence + saying `execute` writes the branch, and that the run would still end + `adoption verified`, is true only of a branch both modes have. + + What GitHub has NOW is `execute`'s to ask (`git ls-remote`, before any + leg): `check` asks nothing of the network, as #479 has it, and reads + what the clone remembers. The source's own branches are asked as + `execute --local-remote-dir` asks them, of the source as a remote + (`_branches_at`); one that cannot be listed is no branch `execute` + pushes to there.""" + if _tracking_branch_problem(source, TRACKING_KEY, tracking) is not None: + return False + try: + return tracking in _branches_at(str(source.path), source.path) + except (CommandFailed, OSError): + return False + + def _plan_fields(plan: Plan) -> list[tuple[str, tuple]]: """A plan's values as its loader read them, each named by its key in the plan, with the lines `_template_values` would write them into: `(the @@ -2459,19 +2517,27 @@ def _plan_fields(plan: Plan) -> list[tuple[str, tuple]]: LEG_PATH_PLACEHOLDERS.values()): if isinstance(value, str) and leg_path_problem(what, value) is None: fields.append((placeholder, _field(what, placeholder, value))) - # Each pin as the loader read it, which `Plan.pins` has already made text - # (`TRUE` would be named as `True`); the same items, in the same order, - # less one no file can hold, which is #255's finding alone. - return fields + [(PINS_PLACEHOLDER, _pin_field(PIN_IN_PLAN, pin)) - for pin in (plan.data.get("pins") or []) - if pin and _execute_gets_to_write("pins", pin)] - - -def _misread_findings(plan: Plan) -> list[str]: + # Each pin `execute` writes, by the product it lists in the manifest + # (`_template_table`), once each, less one no file can hold, which is + # #255's finding alone. None at all where the plan's `pins:` has a + # finding of its own (`_read_pins`, #273): `execute` refuses such a plan + # before any leg exists, and one wrong pin is one finding. + recorded, pin_findings = _read_pins(plan) + products = () if pin_findings else dict.fromkeys( + product for _, product, _ in recorded) + return fields + [(PINS_PLACEHOLDER, _pin_field(PIN_IN_PLAN, product)) + for product in products + if _execute_gets_to_write("pins", product)] + + +def _misread_findings(plan: Plan, unwritten=()) -> list[str]: """`check`'s FINDING for each plan value the project's loader would misread (#181) and the project's own validator would NOT refuse as the loader reads it, of the values `execute` writes in every mode - (`_execute_gets_to_write`). + (`_execute_gets_to_write`). `unwritten` names the placeholders of values + the caller knows `execute` does not write in every mode, by a question + only it can ask, of the source: the tracking branch + (`_a_branch_execute_writes`, #479). THE VALIDATOR IS ASKED FIRST, as `check` asks it (`_manifest_findings`, the same `ignore`), and a value it makes a finding about is its alone: @@ -2490,9 +2556,10 @@ def _misread_findings(plan: Plan) -> list[str]: judged = _validator_judged(plan) if judged is None: return [] + left = judged | frozenset(unwritten) return [f"FINDING plan-manifest-misread: {problem}." for placeholder, field in _plan_fields(plan) - if placeholder not in judged + if placeholder not in left for problem in manifest_misreads([field])] @@ -2804,12 +2871,23 @@ def _checked_plan_inputs(args, source: Source, # every value that refusal names is one `execute` would write and end # `adoption verified` with, as its remediation says. _refuse_unholdable_values(args, names, pins, naming) - misread = manifest_misreads(_flag_fields(args, flags_given, pins)) + _refuse_misread_values(args, flags_given, pins, source) + return names, pins + + +def _refuse_misread_values(args, given: set, pins: dict, + source: Source) -> None: + """`plan`'s refusal, before the plan exists, of each value it is about + to record that the project's loader would read back as something else + (#181), named as `_flag_fields` names it. Asked after the project's own + validator (`_refuse_unholdable_values`), whose refusal is the one a + value it refuses gets. Apart from `_checked_plan_inputs`, which #479's + default made as complex as Sonar allows.""" + misread = manifest_misreads(_flag_fields(args, given, pins, source)) if misread: raise Refusal("adopt-manifest-misread", " ".join(f"{problem}." for problem in misread), f"{MANIFEST_MISREAD_REMEDIATION} No plan was written.") - return names, pins def _refuse_an_unrecordable_pin_source(source: Path, products) -> None: @@ -4940,6 +5018,11 @@ def _entry_findings(plan: Plan, source: Source, as for an `elected_on:` `election_date` refuses, #181 names no value either (`_validator_judged`): the date is one finding, `_plan_field_findings`'s (#293, #294). + In the same way a tracking branch that is no branch of the source is + `plan-tracking-branch-missing` alone (#479), and one only the source's + remote `origin` has, which `execute --local-remote-dir` refuses, is + named by neither (`_a_branch_execute_writes`); and a pin is named only + where the plan's `pins:` has no finding of #273's (`_plan_fields`). """ tree = source.tree() unsafe, unsafe_remediation = _source_path_findings(plan, tree) @@ -4955,9 +5038,17 @@ def _entry_findings(plan: Plan, source: Source, field_findings, field_remediation = _plan_field_findings(plan) tracking_findings, tracking_remediation = _tracking_branch_findings( plan, source) - # Asked after the validator, whose judgement it follows; listed where - # it always was, after the mount paths. - misread = _misread_findings(plan) + # Asked after the validator, whose judgement it follows, and of the + # tracking branch only where `execute` pushes the legs to it in every + # mode (`_a_branch_execute_writes`, #479), so that a branch the source + # lacks is `plan-tracking-branch-missing` alone; listed where it always + # was, after the mount paths. A plan with no `tracking_branch:` gets + # `main`, which is never misread. + tracking = plan.get(TRACKING_KEY) + misread = _misread_findings( + plan, () if tracking is None + or _a_branch_execute_writes(source, TRACKING_KEY, str(tracking)) + else (MANIFEST_FIELDS[TRACKING_KEY],)) return (findings + misread + split + unwritable_findings + value_findings + tracking_findings + moved + field_findings, unsafe_remediation diff --git a/tests/test_adopt_plan_yaml_quoting.py b/tests/test_adopt_plan_yaml_quoting.py index 8212047b..2329d1f5 100644 --- a/tests/test_adopt_plan_yaml_quoting.py +++ b/tests/test_adopt_plan_yaml_quoting.py @@ -387,8 +387,24 @@ def test_a_value_is_asked_about_in_each_place_it_is_written( ELECTED_BY_FLAG = "--elected-by" MANIFEST = "project.yaml" #: How a sentence names a plan key, where that is not the key itself: one of -#: `pins:` is an item of a list, and what it was read as is the item's. -NAMED = {PINS_KEY: "a pin in pins"} +#: `pins:` is an item of a list, a mapping since #273, and what `execute` +#: writes into the manifest for it is its product. +NAMED = {PINS_KEY: "the product of a pin in pins"} +#: And how `plan` names a flag's value, where that is not the flag itself: +#: `--pin` is `product@<40 hex>`, and the product is what is written. +FLAG_NAMED = {PIN_FLAG: "the product of a --pin"} +#: What `plan` records of a pin besides its product (#273), as a plan edited +#: by hand would carry it: a commit and a digest `check` reads no network +#: for, and how the digest was read. +PIN_COMMIT = "1" * 40 +PIN_DIGEST = "2" * 64 +PIN_DIGEST_SOURCE = "gh-api-tree-recursive" +#: The owner `plan` qualifies an unqualified `--pin` with. +PIN_OWNER = "opensoft" +#: The flag that names the stand-in clone a `--pin`'s digest is read from. +PIN_SOURCE_FLAG = "--pin-source" +#: The first line of the pin `plan` records for `openGlass`. +GLASS_ITEM = " - product: openGlass" #: The `_template_values` placeholders the tests below fill. TRACKING_PLACEHOLDER = "TRACKING_BRANCH" ELECTED_BY_PLACEHOLDER = "ELECTED_BY" @@ -417,7 +433,10 @@ def test_a_value_is_asked_about_in_each_place_it_is_written( #: so each is #181's, at `plan` and in a plan. No value `project.yaml` #: writes between its quotes is here: `render` spells those with #: `double_quoted` since #284, and `BETWEEN_THE_QUOTES` below holds the ones -#: that misread before it. +#: that misread before it. Since main's #479 each branch is asked of a +#: source that has it (`holding`), and 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), @@ -570,12 +589,67 @@ def answered_copy(adopter, answered, tmp_path) -> tuple[Path, str]: lambda _match: f" local_path: {adopter.y(str(copy))}", text, count=1) +@pytest.fixture +def glass(tmp_path) -> tuple[Path, str]: + """A stand-in clone of a neutral product, `(path, commit)`, for `plan + --pin @ --pin-source ` to read a digest from with + no network (#273). The product's name is the `--pin`'s, not the clone's.""" + path = tmp_path / "pin-source" / OPEN_GLASS + path.mkdir(parents=True) + git("init", "-q", "-b", "main", cwd=path) + (path / "glass.txt").write_bytes(b"openGlass\n") + git("add", "-A", cwd=path) + git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "one", + cwd=path) + return path, git("rev-parse", "HEAD", cwd=path).stdout.strip() + + +def holding(source: Path, key: str, branch) -> None: + """Since #479 a tracking branch that is no branch of the source is + `plan-tracking-branch-missing` alone, so a row about the tracking branch + is asked of a source that has it as its own branch: `branch`, as the + plan's loader reads it, spelled as `execute` takes it (`str`). One + branch per copy of the source, because `true` and `True` are one file + on a disk that ignores case.""" + if key in (TRACKING_KEY, TRACKING_FLAG): + git("branch", str(branch), cwd=source) + + +def flag_args(source: Path, glass: tuple[Path, str], flag: str, + value: str) -> tuple[str, ...]: + """`plan`'s arguments for one row of a flag's: a `--pin` is + `@<40 hex>` read from the stand-in clone (#273), and a + `--tracking-branch` names a branch the source has (#479).""" + holding(source, flag, value) + if flag != PIN_FLAG: + return flag, value + path, commit = glass + return PIN_FLAG, f"{value}@{commit}", PIN_SOURCE_FLAG, str(path) + + +def whole_pin(spelled: str) -> str: + """`pins:` holding one pin as `plan` records it since #273, its + `product:` spelled as given: product, repository, commit, digest and + how the digest was read. The repository is named after the product as + the plan's loader reads it, as `plan` names it, so that `_read_pins` + asks about the product alone.""" + read = parse_yaml(f"product: {spelled}\n")["product"] + return ("pins:\n" + f" - product: {spelled}\n" + " repository: " + f"{repo_shape.double_quoted(f'{PIN_OWNER}/{read}')}\n" + f' commit: "{PIN_COMMIT}"\n' + f' tree_sha256: "{PIN_DIGEST}"\n' + f" digest_source: {PIN_DIGEST_SOURCE}\n") + + def carrying(plan: Path, text: str, key: str, spelled: str) -> None: """Write `text`, an answered plan, to `plan` with `key` set to `spelled` exactly as given: a plan edited by hand, or written by a `plan` older - than this check. LF, as `plan` writes it.""" + than this check. LF, as `plan` writes it. A pin is a whole one, whose + product is `spelled` (`whole_pin`).""" if key == PINS_KEY: - edited = text.replace("\npins: []\n", f"\npins: [{spelled}]\n", 1) + edited = text.replace("\npins: []\n", "\n" + whole_pin(spelled), 1) else: leaf = key.split(".")[-1] indent = " " if key.startswith("legs.") else "" @@ -591,15 +665,16 @@ def carrying(plan: Path, text: str, key: str, spelled: str) -> None: @pytest.mark.parametrize("flag, value, reading", PLANNED, ids=ids_of(PLANNED, 0, 1)) def test_plan_refuses_a_value_the_projects_loader_would_misread( - answered, tmp_path, flag, value, reading): + answered_copy, glass, tmp_path, flag, value, reading): """Before the plan is written: the flag, the value, what the project's loader would read, and the fix.""" - source, _ = answered + source, _ = answered_copy plan = tmp_path / PLAN_FILE - result = write_plan(source, plan, extra=(flag, value)) + result = write_plan(source, plan, + extra=flag_args(source, glass, flag, value)) assert result.returncode == 2, result.stdout + result.stderr assert result.stderr.startswith("REFUSED adopt-manifest-misread: ") - who, what = misread(flag, value, reading) + who, what = misread(FLAG_NAMED.get(flag, flag), value, reading) assert who in result.stderr and what in result.stderr, result.stderr assert "No plan was written." in result.stderr assert not plan.exists() @@ -661,7 +736,8 @@ def test_the_misread_rule_names_an_id_plan_derived_by_where_it_came_from( args = argparse.Namespace( project="True", org="testorg", id=None, visibility="private", elected_by="Test Human", elected_on=ELECTION_DATE, reference=None, - tracking_branch="main", spec_path="spec", code_path="code", pin=[]) + tracking_branch="main", spec_path="spec", code_path="code", pin=[], + pin_owner=PIN_OWNER, pin_source=None) naming = adopter.NamingPolicy.load(adopter.NAMING_POLICY) with pytest.raises(adopter.Refusal) as refused: adopter._checked_plan_inputs( @@ -698,15 +774,16 @@ def test_plan_takes_an_elector_from_git_the_manifests_quotes_read_back( @pytest.mark.parametrize("key, value, reading", CARRIED, ids=ids_of(CARRIED, 0, 1)) -def test_check_reports_a_plan_carrying_one(adopter, answered, tmp_path, key, - value, reading): +def test_check_reports_a_plan_carrying_one(adopter, answered_copy, tmp_path, + key, value, reading): """A plan carrying the value -- spelled by the plan writer, so the plan itself reads it back -- is a FINDING, with the remediation once after. It is #181's alone: the project's own validator, asked first, makes no finding about what the loader reads of it (a leg path's are `_leg_findings`' to judge, which passes the text), and `execute` writes it in every mode.""" - _, text = answered + source, text = answered_copy + holding(source, key, value) plan = tmp_path / PLAN_FILE carrying(plan, text, key, adopter.y(value)) result = checked(plan) @@ -811,25 +888,28 @@ def test_the_misread_rule_names_an_id_it_derived_from_the_assembly( #: READ, with its kind, and then the text `execute` writes (review181's N2). #: The project's loader reads that text back as the same kind again, and the #: project's own validator makes no finding about it: it does not judge a -#: branch, and a pin only as a name it classifies. An id, a date or a -#: visibility read as not text is the validator's (`NOT_TEXT_INVALID`). +#: branch. An id, a date or a visibility read as not text is the +#: validator's (`NOT_TEXT_INVALID`), and a pin's product #273's +#: (`test_a_pin_whose_product_is_not_text_is_273s_finding_alone`). The +#: source has the branch as `execute` takes it, `str` of what was read +#: (#479). READ_AS_NOT_TEXT = [ (TRACKING_KEY, "007", SEVEN_READ, "7"), (TRACKING_KEY, "TRUE", TRUE_READ, "True"), (TRACKING_KEY, "1.50", "the number 1.5", "1.5"), - (PINS_KEY, "TRUE", TRUE_READ, "True"), ] @pytest.mark.parametrize("key, spelled, read_as, written", READ_AS_NOT_TEXT, ids=ids_of(READ_AS_NOT_TEXT, 0, 1)) def test_check_names_a_plan_value_that_is_not_text_by_what_was_read( - answered, tmp_path, key, spelled, read_as, written): + answered_copy, tmp_path, key, spelled, read_as, written): """The finding said `tracking_branch is '7'` for a plan that says `007`, and `'True'` for `TRUE`: `str` of what was read, which the plan never said. It says what the plan's loader read, and the text `execute` writes for it, which is `str` of that and is named as such.""" - _, text = answered + source, text = answered_copy + holding(source, key, written) plan = tmp_path / PLAN_FILE carrying(plan, text, key, spelled) result = checked(plan) @@ -968,6 +1048,162 @@ def test_execute_refuses_that_branch_as_unsafe_before_any_leg_exists( assert SPLIT_BRANCH not in branches_of(source) +# --- the tracking branch is a branch of the source (main's #479) ------------ + +#: How #479's finding, and the note `plan` prints for the same branch, begin. +BRANCH_MISSING = "FINDING plan-tracking-branch-missing: `tracking_branch` is " +BRANCH_NOTED = "NOTE the tracking branch is `true`, which is no branch of " + + +def test_a_misread_branch_the_source_lacks_is_479s_finding_alone( + adopter, answered, tmp_path): + """`execute` refuses a tracking branch that is no branch of the source + before any leg exists (#479), so it never writes it. At the merge of + main cc8f2da `check` named `true` there twice, #479's finding and a + misread one saying `execute` writes it and the run ends `adoption + verified`, and `plan` refused it under that remediation. `plan` records + it with #479's note, as main does, and `check` says #479's finding + alone.""" + source, _ = answered + plan = tmp_path / PLAN_FILE + written = write_plan(source, plan, extra=(TRACKING_FLAG, "true")) + assert written.returncode == 0, written.stdout + written.stderr + assert BRANCH_NOTED in written.stdout, written.stdout + assert MISREAD_CODE not in written.stdout + written.stderr + for path, leg in ANSWERS: + resolve(plan, path, leg) + result = checked(plan) + assert result.returncode == 1, result.stdout + result.stderr + assert f"{BRANCH_MISSING}`true`, which is no branch of the source " in ( + result.stderr), result.stderr + assert MISREAD_FINDING not in result.stderr, result.stderr + assert adopter.MANIFEST_MISREAD_REMEDIATION not in result.stderr + + +def origin_only(tmp_path: Path) -> tuple[Path, Path]: + """`(source, plan)`: a source whose remote `origin` has the branch + `true`, fetched, and which has no branch of its own by that name, + planned and answered with `--tracking-branch true`. `plan` notes + nothing: `origin` has it.""" + seed = make_source_repo(tmp_path / "seed") + holding(seed, TRACKING_KEY, "true") + source = make_source_repo(tmp_path / SOURCE_NAME) + git("remote", "add", "origin", str(seed), cwd=source) + git("fetch", "-q", "origin", cwd=source) + assert "true" not in branches_of(source).split() + plan = tmp_path / PLAN_FILE + written = write_plan(source, plan, extra=(TRACKING_FLAG, "true")) + assert written.returncode == 0, written.stdout + written.stderr + assert "NOTE the tracking branch" not in written.stdout, written.stdout + assert MISREAD_CODE not in written.stdout + written.stderr + for path, leg in ANSWERS: + resolve(plan, path, leg) + return source, plan + + +def test_a_misread_branch_only_origin_has_is_named_by_neither( + adopter, tmp_path): + """In gh mode the split goes to `origin`, which has the branch, and + `execute` would write it; under `--local-remote-dir` it is pushed back + into the source, which does not, and `execute` refuses it before any + leg exists. `check` is not told the mode, so a sentence saying + `execute` writes it would be false in one of them: `check` passes the + plan, as main does (`_pushed_in_every_mode`).""" + _, plan = origin_only(tmp_path) + result = checked(plan) + assert result.returncode == 0, result.stdout + result.stderr + assert "plan ok" in result.stdout, result.stdout + assert MISREAD_FINDING not in result.stderr, result.stderr + assert adopter.MANIFEST_MISREAD_REMEDIATION not in result.stderr + + +@needs_filter_repo +def test_execute_refuses_a_branch_only_origin_has_under_a_local_remote( + tmp_path): + """What makes the misread sentence false there: main's `execute` asks + the source itself, the remote under `--local-remote-dir`, and refuses + the branch before any leg exists (#479).""" + source, plan = origin_only(tmp_path) + result = executed(plan, tmp_path) + assert result.returncode == 2, result.stdout + result.stderr + assert result.stderr.startswith( + "REFUSED plan-tracking-branch-missing: `tracking_branch` is `true`, " + ), result.stderr + assert not (tmp_path / REMOTES).exists() + assert SPLIT_BRANCH not in branches_of(source) + + +def test_plan_names_a_tracking_branch_it_took_from_the_source(tmp_path): + """Since #479 `plan` records the source's default branch when no + `--tracking-branch` is given. A source whose default branch is `true` + is refused before the plan exists, naming where the value came from, as + `checked_value` names it, and not as a flag nobody passed.""" + source = make_source_repo(tmp_path / SOURCE_NAME, branch="true") + plan = tmp_path / PLAN_FILE + result = write_plan(source, plan) + assert result.returncode == 2, result.stdout + result.stderr + who, what = misread( + "--tracking-branch (not given, so the source's default branch)", + "true", TRUE_READ) + assert result.stderr.startswith( + f"REFUSED adopt-manifest-misread: {who}"), result.stderr + assert what in result.stderr, result.stderr + assert not plan.exists() + + +def test_the_source_is_asked_only_about_a_branch_the_loader_misreads( + adopter, monkeypatch): + """`_pushed_in_every_mode` starts git. It is asked about `true`, which + the loader misreads, and not about `develop`, which it reads back, as + git's own question is asked (`_a_branch_git_creates`).""" + asked = [] + + def recorded(_source, branch): + asked.append(branch) + return False + + monkeypatch.setattr(adopter, "_pushed_in_every_mode", recorded) + assert adopter._a_branch_execute_writes(None, TRACKING_KEY, "develop") + assert not adopter._a_branch_execute_writes(None, TRACKING_KEY, "true") + assert asked == ["true"] + + +# --- a pin is `product@<40 hex>`, recorded whole (main's #273) -------------- + +def test_a_pin_beside_a_273_finding_is_not_named_misread( + answered, tmp_path): + """`execute` refuses a plan whose `pins:` has a finding of #273's + before any leg exists, so it writes none of its pins: a misread + sentence about the whole pin beside it would say otherwise. One wrong + `pins:` is #273's findings alone.""" + _, text = answered + plan = tmp_path / PLAN_FILE + carrying(plan, text, PINS_KEY, QUOTED_NULL) + edited = text_of(plan).replace("\npins:\n", "\npins:\n - openX\n", 1) + with plan.open("w", encoding=UTF8, newline="\n") as handle: + handle.write(edited) + result = checked(plan) + assert result.returncode == 1, result.stdout + result.stderr + assert "FINDING plan-pin-unrecorded: `pins:` entry 1, 'openX'" in ( + result.stderr), result.stderr + assert MISREAD_FINDING not in result.stderr, result.stderr + + +def test_a_pin_whose_product_is_not_text_is_273s_finding_alone( + answered, tmp_path): + """`product: TRUE` is read as a boolean, which `_read_pins` finds + `plan-pin-malformed`, and `execute` refuses before any leg exists. Before + #273 a pin was a name, and #181 named this one by what was read.""" + _, text = answered + plan = tmp_path / PLAN_FILE + carrying(plan, text, PINS_KEY, "TRUE") + result = checked(plan) + assert result.returncode == 1, result.stdout + result.stderr + assert "FINDING plan-pin-malformed: `pins:` entry 1 records " in ( + result.stderr), result.stderr + assert MISREAD_FINDING not in result.stderr, result.stderr + + #: A date with a line break in it, as a plan edited by hand spells it. UNWRITABLE_DATE = '"2026-09-02\\nx"' #: `(plan key, as a plan edited by hand spells it)`: values no file @@ -1055,7 +1291,7 @@ def test_execute_refuses_a_date_no_file_can_hold_as_unwritable_not_misread( @pytest.mark.parametrize("key, spelled, finding", JUDGED_ONCE) def test_check_judges_each_value_once_by_the_rule_true_of_it( - answered, tmp_path, key, spelled, finding): + answered_copy, tmp_path, key, spelled, finding): """At the merge of main dd8d0ea, `id: "007"` and `elected_on: "20260902"` were two findings each, #181's and the validator's, and a visibility that is not text was none. At 7ac2358 each was one, but @@ -1063,7 +1299,8 @@ def test_check_judges_each_value_once_by_the_rule_true_of_it( ends `adoption verified` where `execute` refuses it as the validator's (rv181f's G1), and `elected_on: "!"` was still two (G2). Each is one, in the words of the rule true of it.""" - _, text = answered + source, text = answered_copy + holding(source, key, parse_yaml(f"{key}: {spelled}\n")[key]) plan = tmp_path / PLAN_FILE carrying(plan, text, key, spelled) result = checked(plan) @@ -1259,36 +1496,32 @@ def test_text_the_loader_reads_as_other_text_is_shown_as_typed_and_read( f"{reading} and not as that text: choose another value"] -#: `(a hand-edited plan's pin, the item `execute` writes for it, as a -#: sentence shows that line)`. `execute` writes a pin into its ` - ` item as -#: it stands, so the whitespace after it stays in the line, where the loader -#: drops it. A space shows between the backticks; a no-break space makes the -#: line one a terminal would not show as it is, so Python spells it. Not a -#: tab since #255: no file `execute` writes holds a control character, and a -#: pin holding one is #255's finding alone (`UNWRITABLE`). +#: Hand-edited pins whose product ends in whitespace, which the loader drops +#: from the manifest's ` - ` item. Before #273 a pin was a name, and #181 +#: named such a pin in the line `execute` writes, whitespace and all +#: (rv181f's F1). Since #273 a pin's product is held to `PIN_ARG_RE`'s +#: alphabet, which has no whitespace (`_pin_row_problem`). PIN_WITH_A_TRAIL = [ - pytest.param('"openGlass "', "`- openGlass `", id="space"), - pytest.param('"openGlass\\xa0"', "'- openGlass\\xa0'", - id="no-break-space"), + pytest.param('"openGlass "', id="space"), + pytest.param('"openGlass\\xa0"', id="no-break-space"), ] -@pytest.mark.parametrize("spelled, shown", PIN_WITH_A_TRAIL) -def test_a_pin_is_shown_in_the_line_execute_writes_whitespace_and_all( - answered, tmp_path, spelled, shown): - """The finding said `execute` writes `- openGlass` for a pin - `"openGlass "`: the line was stripped after the pin was put in it, so - the space `execute` keeps was gone from the line the sentence named - (rv181f's F1). The loader drops that space, which is why the pin is - refused, and the line named is the one `execute` writes.""" +@pytest.mark.parametrize("trailed", PIN_WITH_A_TRAIL) +def test_a_pin_ending_in_whitespace_is_273s_finding_alone( + adopter, answered, tmp_path, trailed): + """`check` finds the pin `plan-pin-malformed`, and `execute` refuses it + before any leg exists, so it is never written: one finding, #273's, and + no misread sentence saying `execute` writes it.""" _, text = answered plan = tmp_path / PLAN_FILE - carrying(plan, text, PINS_KEY, spelled) + carrying(plan, text, PINS_KEY, trailed) result = checked(plan) assert result.returncode == 1, result.stdout + result.stderr - assert (f"{MANIFEST}'s neutral_product_pins as {shown}, where the " - "project's own YAML loader reads it back as the text `openGlass` " - "and not as that text") in result.stderr, result.stderr + assert "FINDING plan-pin-malformed: `pins:` entry 1 " in result.stderr, ( + result.stderr) + assert MISREAD_FINDING not in result.stderr, result.stderr + assert adopter.MANIFEST_MISREAD_REMEDIATION not in result.stderr #: Values the manifest's own double quotes misread while `render` pasted @@ -1353,6 +1586,7 @@ def test_execute_refuses_a_plan_carrying_one_before_any_leg_exists( #168's one list of `check`'s findings, before it creates a leg or touches the source.""" source, text = answered_copy + holding(source, key, value) plan = tmp_path / PLAN_FILE carrying(plan, text, key, adopter.y(value)) result = executed(plan, tmp_path) @@ -1405,6 +1639,9 @@ def test_what_is_still_accepted_adopts_clones_and_bootstraps(tmp_path): each leg on the branch `execute` pushed it on, with that branch as its upstream, and read back every value the plan was given.""" source = make_source_repo(tmp_path / SOURCE_NAME) + # Since #479 the tracking branch is a branch of the source: `execute` + # pushes the split back into it under `--local-remote-dir`. + holding(source, TRACKING_KEY, Q4_BRANCH) plan = tmp_path / PLAN_FILE written = write_plan(source, plan, extra=ACCEPTED) assert written.returncode == 0, written.stderr + written.stdout @@ -1561,37 +1798,50 @@ def test_an_explicit_null_leg_path_is_a_finding_not_the_default( def test_the_plan_writer_quotes_a_pin_whatever_refuses_it_later( - adopter, answered, tmp_path, monkeypatch): + adopter, answered, glass, tmp_path, monkeypatch): """`pins: [` was the one list of strings the plan wrote without `y`, so - `--pin null` was `pins: [null]`: a null `Plan.load` drops. `plan` now - refuses such a pin before writing, so the writer is asked with the - gate taken away: the pin is a string like any other in the plan.""" + `--pin null` was `pins: [null]`: a null `Plan.load` drops. Since #273 + `plan` writes each pin as a mapping through `y` (`_pin_lines`), and + refuses such a product before writing, so the writer is asked with the + gate taken away: the product is a string like any other in the plan.""" source, _ = answered + path, commit = glass monkeypatch.setattr(adopter, "manifest_misreads", lambda _fields: []) for pin in ("null", "Null", "NULL", "true", FALSE_TITLE): plan = tmp_path / f"{pin}.yaml" assert adopter.main([ "plan", "--source", str(source), "--project", PROJECT_NAME, "--org", "testorg", ELECTED_BY_FLAG, "Test Human", - "--elected-on", ELECTION_DATE, PIN_FLAG, pin, + "--elected-on", ELECTION_DATE, PIN_FLAG, f"{pin}@{commit}", + PIN_SOURCE_FLAG, str(path), "--work-dir", str(tmp_path / "work"), "--out", str(plan)]) == 0 - assert f'\npins: ["{pin}"]\n' in text_of(plan) - assert load_yaml(plan)[PINS_KEY] == [pin] + assert f'\n - product: "{pin}"\n' in text_of(plan) + assert load_yaml(plan)[PINS_KEY][0]["product"] == pin assert adopter.Plan.load(plan).pins == [pin] @pytest.mark.parametrize("pins, written", [ - ((OPEN_GLASS,), "pins: [openGlass]"), - (("opensoft/openGlass",), "pins: [opensoft/openGlass]"), - ((OPEN_GLASS, "opensoft/openCode"), "pins: [openGlass, opensoft/openCode]"), - ((), "pins: []"), + ((OPEN_GLASS,), (GLASS_ITEM, " repository: opensoft/openGlass")), + (("opensoft/openGlass",), (GLASS_ITEM, + " repository: opensoft/openGlass")), + ((OPEN_GLASS, "opensoft/openCode"), + (GLASS_ITEM, " - product: openCode", + " repository: opensoft/openCode")), + ((), ("pins: []",)), ], ids=["one", "owned", "two", "none"]) -def test_an_ordinary_pin_is_written_as_it_always_was(tmp_path, pins, written): - """`openGlass` and `opensoft/openGlass` are the pins that exist; they stay - bare, singly and together, and no pin is still `pins: []`.""" - extra = tuple(part for pin in pins for part in (PIN_FLAG, pin)) +def test_an_ordinary_pin_is_written_as_it_always_was(glass, tmp_path, pins, + written): + """`openGlass` and `opensoft/openGlass` are the pins that exist; their + product and repository stay bare, singly and together, and no pin is + still `pins: []`.""" + path, commit = glass + extra = tuple(part for pin in pins + for part in (PIN_FLAG, f"{pin}@{commit}")) + if pins: + extra += (PIN_SOURCE_FLAG, str(path)) text = text_of(planned(tmp_path, *extra)) - assert f"\n{written}\n" in text + for line in written: + assert f"\n{line}\n" in text, line def test_a_source_file_named_like_a_keyword_is_one_entry_with_that_path( From 94242a20e9335f03c797024064c7f90409931e6e Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 13 Oct 2026 12:14:39 +0000 Subject: [PATCH 21/24] tests: a misread tracking branch the source has as its own and its fetched `origin` lacks is #479's `plan-tracking-branch-missing` alone, which holds `_pushed_in_every_mode` to #479's question as well as its own; `_plan_fields` asks about each recorded pin with no dedupe nothing reaches (#181) `_pushed_in_every_mode` asks two questions of a misread tracking branch: #479's (`_tracking_branch_problem`, the branches of `origin` once fetched, the repository the pull request goes to) and the source's own branches (`execute --local-remote-dir`). No test told them apart: in a source with no remote the two lists are one, so dropping #479's question failed nothing. `test_a_misread_branch_origin_lacks_is_479s_finding_alone` plans `--tracking-branch true` on a source whose fetched `origin` lacks `true` and which has it as its own branch: `plan` notes it, and `check` finds it `plan-tracking-branch-missing` alone. `with_origin` builds that source and `origin_only`'s. `_plan_fields` asked each product once through `dict.fromkeys`. A product given twice is #273's `plan-pin-collides`, and then no pin is asked about, so the dedupe was never reached; it is gone, and the comment says why. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- adopt-project.py | 13 ++++---- tests/test_adopt_plan_yaml_quoting.py | 43 +++++++++++++++++++++++---- 2 files changed, 44 insertions(+), 12 deletions(-) diff --git a/adopt-project.py b/adopt-project.py index 745a7a09..5acdc29d 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -2518,15 +2518,14 @@ def _plan_fields(plan: Plan) -> list[tuple[str, tuple]]: if isinstance(value, str) and leg_path_problem(what, value) is None: fields.append((placeholder, _field(what, placeholder, value))) # Each pin `execute` writes, by the product it lists in the manifest - # (`_template_table`), once each, less one no file can hold, which is - # #255's finding alone. None at all where the plan's `pins:` has a - # finding of its own (`_read_pins`, #273): `execute` refuses such a plan - # before any leg exists, and one wrong pin is one finding. + # (`_template_table`), less one no file can hold, which is #255's + # finding alone. None at all where the plan's `pins:` has a finding of + # its own (`_read_pins`, #273): `execute` refuses such a plan before any + # leg exists, and one wrong pin is one finding. A product given twice is + # one such finding (`plan-pin-collides`), so each is asked about once. recorded, pin_findings = _read_pins(plan) - products = () if pin_findings else dict.fromkeys( - product for _, product, _ in recorded) return fields + [(PINS_PLACEHOLDER, _pin_field(PIN_IN_PLAN, product)) - for product in products + for _, product, _ in ([] if pin_findings else recorded) if _execute_gets_to_write("pins", product)] diff --git a/tests/test_adopt_plan_yaml_quoting.py b/tests/test_adopt_plan_yaml_quoting.py index 2329d1f5..ef298347 100644 --- a/tests/test_adopt_plan_yaml_quoting.py +++ b/tests/test_adopt_plan_yaml_quoting.py @@ -1080,16 +1080,49 @@ def test_a_misread_branch_the_source_lacks_is_479s_finding_alone( assert adopter.MANIFEST_MISREAD_REMEDIATION not in result.stderr +def with_origin(tmp_path: Path, branch: str | None) -> Path: + """A source whose remote `origin` has been fetched, `origin` holding + `branch` beside `main` where one is given, and the source no branch of + its own but `main`.""" + seed = make_source_repo(tmp_path / "seed") + if branch: + holding(seed, TRACKING_KEY, branch) + source = make_source_repo(tmp_path / SOURCE_NAME) + git("remote", "add", "origin", str(seed), cwd=source) + git("fetch", "-q", "origin", cwd=source) + return source + + +def test_a_misread_branch_origin_lacks_is_479s_finding_alone( + adopter, tmp_path): + """The source has `true` as its own branch, and its fetched `origin`, + the repository the split's pull request goes to, does not. `check` + reads `origin`'s branches and finds it `plan-tracking-branch-missing`, + which `execute` refuses before any leg exists in either mode, so it is + #479's finding alone, as at `plan`, though the source itself has it.""" + source = with_origin(tmp_path, None) + holding(source, TRACKING_KEY, "true") + plan = tmp_path / PLAN_FILE + written = write_plan(source, plan, extra=(TRACKING_FLAG, "true")) + assert written.returncode == 0, written.stdout + written.stderr + assert BRANCH_NOTED in written.stdout, written.stdout + assert MISREAD_CODE not in written.stdout + written.stderr + for path, leg in ANSWERS: + resolve(plan, path, leg) + result = checked(plan) + assert result.returncode == 1, result.stdout + result.stderr + assert (f"{BRANCH_MISSING}`true`, which is no branch of the source's " + "remote `origin` ") in result.stderr, result.stderr + assert MISREAD_FINDING not in result.stderr, result.stderr + assert adopter.MANIFEST_MISREAD_REMEDIATION not in result.stderr + + def origin_only(tmp_path: Path) -> tuple[Path, Path]: """`(source, plan)`: a source whose remote `origin` has the branch `true`, fetched, and which has no branch of its own by that name, planned and answered with `--tracking-branch true`. `plan` notes nothing: `origin` has it.""" - seed = make_source_repo(tmp_path / "seed") - holding(seed, TRACKING_KEY, "true") - source = make_source_repo(tmp_path / SOURCE_NAME) - git("remote", "add", "origin", str(seed), cwd=source) - git("fetch", "-q", "origin", cwd=source) + source = with_origin(tmp_path, "true") assert "true" not in branches_of(source).split() plan = tmp_path / PLAN_FILE written = write_plan(source, plan, extra=(TRACKING_FLAG, "true")) From 180902f3e5142734327a1b7f9af6c051fa3de741 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 13 Oct 2026 14:09:53 +0000 Subject: [PATCH 22/24] adopt-project.py: #181's misread gate asks a pin's product of the table `execute` writes the manifest's list from, and its tests follow #259's quoted templates, so a value the template quotes now is planned, checked and written as given, and lane 2's #514 will silence the gate on pins with no edit here (#181) Main's 47b1e06 (#259), merged in 5c04ca2, writes `id`, `elected_on`, `visibility`, `tracking_branch`, both leg `path`s and the leg pins' `submodule_path` between double quotes, where `render` spells a value with `double_quoted` and the loader reads it back as typed. The gate asks the line `execute` writes, so on those lines it finds nothing. That is the gate working: what `execute` still writes bare is each pin's product, an item of the manifest's `neutral_product_pins:` list, and the gate refuses that until #514 quotes it. The gate stays whole. On the merge, 76 tests failed (75 of the quoting file's 491 and one of this branch's in tests/test_adopt_manifest_values.py); each is made true here. Code: - `_pin_field` asks `_template_table`, the table `execute`'s `_template_values` renders the manifest from: the table `_manifest_values` builds from the plan with that one pin, put into the template's line by `render` (`_written`). `_pin_item`, the one copy of how an item is spelled, which `_template_table` and the gate shared, is gone, and `_template_table`'s `NEUTRAL_PRODUCT_PINS` line is main's text again: #514 changes it on main with no conflict here, and the gate asks the quoted line with no edit. - `plan` asks that question of the plan it is about to write, built once: `_refuse_unholdable_values` returns the plan it builds (`_flag_plan`), and `_refuse_misread_values` and `_flag_fields` take it. Main's `test_plan_asks_what_check_asks_of_the_same_keys` holds `plan` to one `_flag_plan`. - `MANIFEST_MISREAD_REMEDIATION` says `execute` writes the values "into the project's own YAML files", not "into `project.yaml` and leg pins": a pin's product is in the manifest and the product's own pin file, not a leg pin. - Sentences made true at the merge: the module docstring, the section comment (the templates quote those lines now; the follow-up it named is #259), `_execute_gets_to_write` (a visibility outside the choices is the validator's in either mode now), `_a_branch_git_creates` and `_flag_fields` (a branch git will not create is #479's `plan-tracking-branch-missing`, refused before any leg), `_misread_findings`, `_manifest_findings` and `_entry_findings` (no `id: "007"` example), the `_checked_plan_inputs` comment and the `#261` section comment (no `--id true` or `--tracking-branch 007` example), and a parenthetical of main's in the leg-path help's docstring (`null` is mounted at `null/`). - scripts/shape_materialize.py: `leg_path_problem`'s comment says why the sentence does not say "quote it" now; #266's `_read_as` takes None's words from `NOT_TEXT_KINDS`, which this branch gives them, the same sentence with "no value at all" spelled twice, not three times (S1192). - Nothing of the gate is removed: no piece is dead even to a direct call. Tests (tests/test_adopt_plan_yaml_quoting.py): - `REFUSED` holds the pins alone, `null`, `True` and `007` (#514's own example); the branches move to `READ_BACK`. - `READ_BACK`, 17 rows: `--tracking-branch` `true`, `null`, `007`; the eight `--spec-path` values and `--code-path null`; `--id true` and `null`; `--project True` and `Null`; and a source whose default branch is `true`. `test_a_value_the_template_quotes_is_planned_and_checked_as_given` and `test_execute_writes_a_value_the_template_quotes_as_given` ask that each reads back exactly through `plan`, `check` and `execute --local-remote-dir`, with no finding. - `bare_template`: the templates the gate reads, #259's lines bare again. The gate's branches that only a direct test reaches now are asked there: the derived id (`DERIVED_ID`, `FLAG_DEFAULTS`), a set id and a branch read as not text, git's and the source's questions, the default branch's name, the escapes `UNESCAPED` names, the validator asked about no manifest, and the values `execute` never writes. - Changed in kind: `test_check_passes_an_id_it_derived_from_the_assembly_as_the_text_it_is` (was `..._to_the_validator`), `test_check_passes_a_plan_value_read_as_not_text_as_main_does` with `test_the_misread_rule_names_a_plan_value_that_is_not_text_by_what_was_read` (was one test), `test_plan_names_a_tracking_branch_it_took_from_the_source` (in this process, over `bare_template`), the loader-learns-a-keyword and template-line tests (a pin's line is the bare one), the agreement test's pin case (rendered from the same table), and `manifest_judged`'s cases (`visibility: secret`, a leg path `../x`). - New: `test_the_misread_rule_asks_nothing_of_a_value_execute_never_writes`, `test_the_gate_asks_the_table_execute_writes_the_pins_from` (a table quoting each item, as #514 means to, and the gate passes `null`), and `test_a_leg_path_written_bare_is_266s_finding_alone`. - Deleted, each asked by another test now: `test_plan_refuses_a_value_the_validator_refuses_in_its_words_alone`, `test_check_leaves_a_value_the_validator_refuses_to_it` and `test_execute_refuses_a_value_the_validator_refuses_as_the_validators` (their values are `READ_BACK`'s `--spec-path`, `--code-path` and `--id` rows); `test_plan_leaves_an_id_it_derived_from_the_project_to_the_validator` (`READ_BACK`'s `--project` rows); and `test_the_misread_rule_names_a_set_id_by_what_was_read` (`test_the_misread_rule_names_a_plan_value_that_is_not_text_by_what_was_read[id='007']`). `JUDGED_ONCE` loses `id: "007"`, `tracking_branch: "007"` and `legs.spec_path: "null"` (main's `TEXT_THE_TEMPLATE_NOW_HOLDS` and `READ_BACK`), and `NOT_TEXT_INVALID` loses `id: 007` (`READ_AS_NOT_TEXT`). Other test files: - tests/test_adopt_manifest_values.py: this branch's `test_a_value_the_reader_reads_as_another_kind_is_named_as_read` is main's `..._is_not_this_questions` again, less main's visibility row: `id: 007` is no finding since #259, as on main. - tests/test_adopt_leg_paths.py: a docstring. - tests/test_template_text_placeholders.py (main's): its comment said `plan` writes `true` and `null` bare, which it does not here. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- adopt-project.py | 207 +++--- scripts/shape_materialize.py | 17 +- tests/test_adopt_leg_paths.py | 9 +- tests/test_adopt_manifest_values.py | 22 +- tests/test_adopt_plan_yaml_quoting.py | 802 ++++++++++++++--------- tests/test_template_text_placeholders.py | 10 +- 6 files changed, 652 insertions(+), 415 deletions(-) diff --git a/adopt-project.py b/adopt-project.py index 5acdc29d..6ce53585 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -50,7 +50,8 @@ any case), every value `execute` writes into the project's YAML (`id`, the branch, the leg paths, the pins, the election record) one its loader reads back as that same text, which `null`, `true` - and `2026` are not where a template writes them bare (#181), and + and `2026` are not where a template writes them bare, as + `project.yaml` still writes each pin's product (#181), and the source's own submodules kept on one leg with their `.gitmodules`, registered there and never in the root, each relative url naming the same repository from the leg as from the @@ -2098,7 +2099,7 @@ def _kept_collision_notes(args, entries: list, tree: list) -> list[str]: # # `y` makes the PLAN say what was typed. `execute` then puts the same values # into the assembly root's templates, and `project.yaml` and the leg pins -# carry most of them bare: `id: {{PROJECT_ID}}`, `path: {{SPEC_PATH}}`, +# carried most of them bare: `id: {{PROJECT_ID}}`, `path: {{SPEC_PATH}}`, # `tracking_branch: {{TRACKING_BRANCH}}`. So once the plan kept `null` as the # text it was, `--spec-path null` ended `adoption verified` and the first # bootstrap failed on `path is None`, `--id true` on `id is True`, and @@ -2106,13 +2107,18 @@ def _kept_collision_notes(args, entries: list, tree: list) -> list[str]: # on a local `True` with no upstream. `2026`, `1.5` and `~` already did the # same on `main` (#187's review). # -# THE FIX IS TO REFUSE. `plan` (before the plan is written), `check` (a -# FINDING) and `execute` (through `_entry_findings`, before any leg exists) -# ask the ONE question below and refuse a value the project's loader would -# read back as anything but that same text. Quoting the bare placeholders is -# left to a follow-up rather than done here: the same templates render every -# scaffolded project, and `scaffold-project.py` writes these values bare -# with no such gate, so quoting them changes both tools at once. +# THE TEMPLATES QUOTE THOSE LINES NOW (#259), and `render` spells a value +# between a YAML template's double quotes with `double_quoted`, which reads +# back as typed: on them the question below finds nothing. What `execute` +# still writes bare is each pin's product, an item of the manifest's +# `neutral_product_pins:` list (lane 2's #514 quotes it). +# +# SO THE GATE STAYS, AND REFUSES. `plan` (before the plan is written), +# `check` (a FINDING) and `execute` (through `_entry_findings`, before any +# leg exists) ask the ONE question below and refuse a value the project's +# loader would read back as anything but that same text, on whatever line +# `execute` writes it into: today a pin's product, and any line a template +# writes bare tomorrow, with no edit here. # # THE LOADER DECIDES, AND THE TEMPLATE SAYS WHERE. Each value is put into # every line of a templated YAML file that carries its placeholder, exactly @@ -2120,8 +2126,9 @@ def _kept_collision_notes(args, entries: list, tree: list) -> list[str]: # asks -- reads that line back. No list of words, and no list of which # placeholders are quoted: `elected_by: "{{ELECTED_BY}}"` is asked as the # line `render` writes, the value spelled by `double_quoted` (#284), so -# `Null` and `CORP\tom` both read back there, and a bare `Null` after -# `tracking_branch:` does not. +# `Null` and `CORP\tom` both read back there, as they do after +# `tracking_branch:` since #259, and a bare `- Null` in the pins' list does +# not. #: The assembly root's templated files that are YAML: what the project's own #: loader reads back out of the split commit. The materializer's list, so a @@ -2180,20 +2187,14 @@ def _kept_collision_notes(args, entries: list, tree: list) -> list[str]: MANIFEST_MISREAD_REMEDIATION = ( "Remediation: choose another value for each one named. `execute` writes " - "these values into the project's `project.yaml` and leg pins as they " - "stand, and the loader the project's own `bootstrap.py` and validators " - "read those files with would take each one named here as something else: " + "these values into the project's own YAML files as they stand, and the " + "loader the project's own `bootstrap.py` and validators read those files " + "with would take each one named here as something else: " "the run would still end `adoption verified`, and the project would then " "be bootstrapped and validated against what was read instead. Quoting it " "in the plan does not help, because the plan is not what is misread.") -def _pin_item(pin: str) -> str: - """One pin as `_template_values` writes it under `neutral_product_pins:`, - and as `manifest_misreads` asks about it.""" - return f" - {pin}" - - def _written(placeholder: str, value: str) -> list[tuple[str, str]]: """`(file, line)` for each line of a templated YAML file that carries `{{placeholder}}`: with `value` in its place, as `execute` writes it, and @@ -2236,13 +2237,35 @@ def _field(what: str, placeholder: str, value) -> tuple[str, object, list]: return what, value, _written(placeholder, str(value)) -def _pin_field(what: str, pin) -> tuple[str, object, list]: - # Only the item's indent goes: `execute` writes the pin's product as it - # stands, and the sentence names that line. Since #273 the product is - # `parse_pin`'s, or a `pins:` entry `_read_pins` accepts, which both - # hold to `PIN_ARG_RE`'s alphabet, so it holds no whitespace. - return what, pin, [(f"{MANIFEST_YAML}'s neutral_product_pins", - _pin_item(str(pin)).lstrip())] +def _pin_field(what: str, plan: "Plan", product: str, + naming: NamingPolicy) -> tuple[str, object, list]: + """`(what, product, written)` for one pin `execute` writes: `written` + holds the line it writes for the pin's product in the manifest's + `neutral_product_pins:` list, without the item's indent. + + ASKED OF `_template_table`, THE TABLE `execute` WRITES THAT LIST FROM, + and never of a copy of how it spells an item: the table + `_manifest_values` builds from `plan` with this one pin (`execute`'s + `_template_values` is the same table), put into the template's line by + `render` (`_written`). The list is written bare, and quoting its items + is lane 2's #514: the day `_template_table` quotes one, this asks the + quoted line, and the gate passes it with no edit here. No line where the + table cannot be built (`_manifest_values` is None), which `plan` and + `check` report in their own words. Since #273 a product is `parse_pin`'s, + or a `pins:` entry `_read_pins` accepts, which both hold to + `PIN_ARG_RE`'s alphabet: it holds no whitespace, and the line loses only + its indent.""" + # The pin by its product alone, as an old plan's entry names it: + # `_manifest_values` reads only the products (`Plan.pins`). + alone = Plan(plan.path, {**plan.data, "pins": [product]}) + table = _manifest_values(alone, naming) + if table is None: + return what, product, [] + return what, product, [ + (f"{rel}'s neutral_product_pins", item.strip()) + for rel, written in _written(PINS_PLACEHOLDER, + table[PINS_PLACEHOLDER]) + for item in written.splitlines()[1:]] def _the_loader_made(reading) -> str: @@ -2336,17 +2359,19 @@ def manifest_misreads(fields) -> list[str]: return problems -def _flag_fields(args, given: set, pins: dict, - source: Source) -> list[tuple]: +def _flag_fields(args, given: set, pins: dict, source: Source, + planned: "Plan", naming: NamingPolicy) -> list[tuple]: """`plan`'s values as it is about to record them, each named by its flag, and by where `plan` took it from when `given` says it was not passed. `pins` is `declared_pin_values`' `{product: PIN_* values}`: the product - is what `execute` writes into the manifest's list (#273). + is what `execute` writes into the manifest's list (#273), asked of the + table `execute` writes it from, built from `planned`, the plan `plan` is + about to write (`_flag_plan`, `_pin_field`). Less a tracking branch the loader misreads that `execute` never writes - in some mode (`_a_branch_execute_writes`): git will not create it, and - `execute` dies at `git init -b`, as on main; or `execute` does not push - the legs to it in every mode, and refuses it before any leg exists + in some mode (`_a_branch_execute_writes`): one git will not create, which + is then no branch of the source, or one `execute` does not push the + legs to in every mode; `execute` refuses each before any leg exists (#479). `plan` records either, and notes one that is no branch of the source.""" def flag(key: str) -> str: @@ -2364,7 +2389,7 @@ def flag(key: str) -> str: fields += [_field(f"--{role}-path", placeholder, getattr(args, f"{role}_path")) for role, placeholder in LEG_PATH_PLACEHOLDERS.items()] - return fields + [_pin_field(PIN_FLAG_PRODUCT, product) + return fields + [_pin_field(PIN_FLAG_PRODUCT, planned, product, naming) for product in pins] @@ -2399,16 +2424,14 @@ def _execute_gets_to_write(key: str, value) -> bool: not. In gh mode, `execute`'s default, `_create_leg_remotes` hands it to `gh repo create` as `--` before anything is written, and gh creates a repository with no other (rv181f's F2b), so such a sentence - would be false there. Where the project's own validator refuses what - the loader reads (`secret`, `"true"`, `[secret]`), `check` names it in - the validator's words (`manifest-visibility`, #261, `_manifest_findings`) - and `execute` refuses the plan before anything exists, in either mode. - Where it accepts what is read -- a text the loader reads as no value - (`"null"`, `""`, `"~"`), or as one of the three (`"'private'"`) -- - nothing names it, as on main: with `--local-remote-dir` the run ends - `adoption verified` with the field read as that, and in gh mode it stops - at the first `gh repo create`, before any leg exists. None of the three - choices is misread. + would be false there. The project's own validator refuses each such + visibility as the loader reads it (`secret`, `"true"`, `true`, + `[secret]`), and since #259 quotes the template's `visibility:`, a text + the loader read there as no value (`"null"`, `""`, `"~"`) or as one of + the three (`"'private'"`) is that text, which it refuses too: `check` + names each in the validator's words (`manifest-visibility`, #261, + `_manifest_findings`), and `execute` refuses the plan before anything + exists, in either mode. None of the three choices is misread. Nor for any other value no file can hold (`unwritable_reason`): `check` names it as `plan-unwritable-value`, and `execute` refuses a plan @@ -2431,12 +2454,13 @@ def _execute_gets_to_write(key: str, value) -> bool: def _a_branch_git_creates(what: str, value) -> bool: """False for a tracking branch the project's loader misreads that git will not create (`branch_problem`, #283): `~`, which the loader reads as - no value. `execute` hands the branch to `git init -b` when it creates - the leg repositories, and dies there, before it writes anything, as on - main; so a sentence saying it writes the branch, and that the run would - still end `adoption verified`, would be false (rv181f's H1). Main's - `execute` asks git nothing about the branch before that, which is not - this rule's to change. + no value where it is written bare. No source has such a branch, so + `check` finds it `plan-tracking-branch-missing` and `execute` refuses it + before any leg exists (#479); before #479 `execute` handed it to `git + init -b` and died there. Either way a sentence saying `execute` writes + the branch, and that the run would still end `adoption verified`, would + be false (rv181f's H1). Since #259 the template quotes the branch, which + reads `~` back, so on the templates shipped this asks git nothing. Git is asked ONLY about a branch the loader misreads. One it reads back is no concern of this rule's, and a plan whose values all read back @@ -2524,7 +2548,9 @@ def _plan_fields(plan: Plan) -> list[tuple[str, tuple]]: # leg exists, and one wrong pin is one finding. A product given twice is # one such finding (`plan-pin-collides`), so each is asked about once. recorded, pin_findings = _read_pins(plan) - return fields + [(PINS_PLACEHOLDER, _pin_field(PIN_IN_PLAN, product)) + naming = NamingPolicy.load(NAMING_POLICY) + return fields + [(PINS_PLACEHOLDER, + _pin_field(PIN_IN_PLAN, plan, product, naming)) for _, product, _ in ([] if pin_findings else recorded) if _execute_gets_to_write("pins", product)] @@ -2539,19 +2565,21 @@ def _misread_findings(plan: Plan, unwritten=()) -> list[str]: (`_a_branch_execute_writes`, #479). THE VALIDATOR IS ASKED FIRST, as `check` asks it (`_manifest_findings`, - the same `ignore`), and a value it makes a finding about is its alone: - `id: "007"` is `manifest-id: id is 7`, as on main, and not also this. - Where it refuses the whole file (a pin `? x`, which its reader cannot - read) or makes a finding about no one value, no value can be said to + the same `ignore`), and a value it makes a finding about is its alone, + as on main, and not also this (while the template wrote `id:` bare, + `id: "007"` was `manifest-id: id is 7` alone). Where it refuses the + whole file (a pin `? x`, which its reader cannot read) or makes a + finding about no one value, no value can be said to pass it, so every value is its, as on main (rv181f's G1, G2); and where it is asked about no manifest (`_manifest_values` is None, as for an `elected_on:` `election_date` refuses, #294's finding), this rule names no value either. That is what makes this rule's remediation true of every value it follows: `execute` writes it as it stands, and the run would still end - `adoption verified`. A leg path the loader reads as no value is still - this rule's: `check` leaves the validator's leg-path codes to - `_leg_findings` (`LEG_PATH_CODES`), and `execute` writes it.""" + `adoption verified`. A leg path is this rule's wherever a template + writes it bare (none has since #259): `check` leaves the validator's + leg-path codes to `_leg_findings` (`LEG_PATH_CODES`), and `execute` + writes it.""" judged = _validator_judged(plan) if judged is None: return [] @@ -2864,25 +2892,26 @@ def _checked_plan_inputs(args, source: Source, # LAST here, over every value as the plan will record it: `execute` # writes them into the project's own YAML. The project's own validator # is asked FIRST (#261): a value it refuses is refused in its words - # alone, as on main, `--id true` and `--spec-path null` among them - # (rv181f's G1). Then one its loader would read as something else that - # the validator accepts is refused before the plan exists (#181), so - # every value that refusal names is one `execute` would write and end - # `adoption verified` with, as its remediation says. - _refuse_unholdable_values(args, names, pins, naming) - _refuse_misread_values(args, flags_given, pins, source) + # alone, as on main (rv181f's G1). Then one its loader would read as + # something else that the validator accepts, since #259 a pin's product + # alone (`--pin null@`), is refused before the plan exists + # (#181), so every value that refusal names is one `execute` would + # write and end `adoption verified` with, as its remediation says. + planned = _refuse_unholdable_values(args, names, pins, naming) + _refuse_misread_values(args, flags_given, pins, source, planned, naming) return names, pins -def _refuse_misread_values(args, given: set, pins: dict, - source: Source) -> None: +def _refuse_misread_values(args, given: set, pins: dict, source: Source, + planned: "Plan", naming: NamingPolicy) -> None: """`plan`'s refusal, before the plan exists, of each value it is about to record that the project's loader would read back as something else (#181), named as `_flag_fields` names it. Asked after the project's own validator (`_refuse_unholdable_values`), whose refusal is the one a value it refuses gets. Apart from `_checked_plan_inputs`, which #479's default made as complex as Sonar allows.""" - misread = manifest_misreads(_flag_fields(args, given, pins, source)) + misread = manifest_misreads( + _flag_fields(args, given, pins, source, planned, naming)) if misread: raise Refusal("adopt-manifest-misread", " ".join(f"{problem}." for problem in misread), @@ -2947,10 +2976,11 @@ def _refuse_an_unrecordable_pin_source(source: Path, products) -> None: # loads that validator rather than copying a rule of it. The table is built by # `_template_table`, which `execute`'s `_template_values` also calls, so it is # the one `execute` renders from. Only the validator's question is asked here, -# and it is asked FIRST: a value it refuses is its alone, `--id true` among -# them. Whether the project's YAML reader reads back as the text it is a value -# the validator accepts -- `--tracking-branch 007` -- is #181's, asked after -# (`_misread_findings`), and the scaffold asks both. +# and it is asked FIRST: a value it refuses is its alone. Whether the +# project's YAML reader reads back as the text it is a value the validator +# accepts -- since #259 quoted the other lines, a pin's product alone, +# `--pin null@` -- is #181's, asked after (`_misread_findings`), and +# the scaffold asks both. #: How `check` and `execute` name each value the manifest records: its key #: in the plan. The id's placeholder and `MANIFEST_FIELDS` are #181's, so @@ -3074,13 +3104,13 @@ def _manifest_findings(plan: Plan) -> tuple[list[str], list[str]]: EVERY VALUE IS ASKED, ONE THE PLAN'S READER READ AS NOT TEXT INCLUDED, and one #181's misread rule would also call misread: a value this rule refuses is this rule's alone, and the misread rule leaves it here - (`_misread_findings`, rv181f's G1). So `id: "007"` is `manifest-id: id - is 7`, as on main, `visibility: true` is this rule's too, and one wrong - value is one finding. (`elected_on: 20260902` is #294's, and this rule - is not asked about it: `_manifest_values`.) A value read as not - text is named by what was read (`_named_as_read`), because the sentence - goes on to quote `str` of it, the text `execute` would write and not - what the plan says. + (`_misread_findings`, rv181f's G1). So `visibility: true` is this + rule's, as `visibility: secret` is, and one wrong value is one + finding. (`elected_on: 20260902` is #294's, and this rule is not asked + about it: `_manifest_values`.) A value read as not text is named by + what was read (`_named_as_read`), because the sentence goes on to + quote `str` of it, the text `execute` would write and not what the + plan says. """ naming = NamingPolicy.load(NAMING_POLICY) values = _manifest_values(plan, naming) @@ -3127,18 +3157,21 @@ def _named_as_read(plan: Plan) -> dict[str, str]: def _refuse_unholdable_values(args, names: dict, pins: dict, - naming: NamingPolicy) -> None: + naming: NamingPolicy) -> "Plan": """`plan` refuses, before it writes a file, what `check` would find in the plan it is about to write (#261): the three repository names it derives, held to `checked_value` as `check` holds `legs:` (a `--project` of 251 to 255 characters planned, then `check` refused `legs.spec`), and the - manifest's values, through the one question `check` asks of them.""" + manifest's values, through the one question `check` asks of them. It + returns that plan, built once (`_flag_plan`), for #181's question to ask + of the same values (`_refuse_misread_values`).""" plan = _flag_plan(args, names, pins) values = _manifest_values(plan, naming) if values is not None: refuse_manifest_values( "adopt", values, naming, MANIFEST_FLAGS, "No plan was written.", misreads=False) + return plan def _print_triad_advisory(path: Path, commit: str | None = None) -> None: @@ -5007,10 +5040,9 @@ def _entry_findings(plan: Plan, source: Source, and #181's `plan-manifest-misread` names a value only where the loader misreads it, the validator makes no finding about what the loader reads, and `execute` gets to write it in every mode - (`_execute_gets_to_write`, `_misread_findings`): `id: "007"` is - `manifest-id: id is 7` alone, as on main. A manifest the validator's - own reader refuses, or a finding of its about no one value, leaves - every value to it. So the two never both name one value, and every + (`_execute_gets_to_write`, `_misread_findings`). A manifest the + validator's own reader refuses, or a finding of its about no one value, + leaves every value to it. So the two never both name one value, and every value #181 names is one `execute` writes as it stands, with the run still ending `adoption verified`, as its remediation says. Where `_manifest_values` asks the validator about no manifest at all, @@ -7394,7 +7426,7 @@ def _template_table(plan: Plan, names, repositories, urls, spec_path, "SPEC_TREE_SHA256": leg_digests.get("spec", ""), "CODE_TREE_SHA256": leg_digests.get("code", ""), "NEUTRAL_PRODUCT_PINS": ("[]" if not pins else - "\n" + "\n".join(_pin_item(p) + "\n" + "\n".join(f" - {p}" for p in sorted(pins))), "ASSEMBLY_NAMING": naming_block(naming, names["assembly"], "assembly", pins), @@ -8105,7 +8137,8 @@ def _leg_path_help(leg: str, default: str) -> str: them: `plan` writes a plan for it, or refuses it under a clause or a code another change adds, and this text is true either way. So it says nothing of what `plan` ACCEPTS, because `plan` takes some values and does not - mount them where they were given (`null` is mounted at the default, #181). + mount them where they were given (before #181, `null` was mounted at + the default). """ return ( f"where the {leg} leg is mounted, as a path in the assembly root " diff --git a/scripts/shape_materialize.py b/scripts/shape_materialize.py index 052334f6..165ffb81 100644 --- a/scripts/shape_materialize.py +++ b/scripts/shape_materialize.py @@ -2900,9 +2900,9 @@ def _listed(names: list[str]) -> str: def _read_as(value) -> str: - """What the YAML reader made of a value, in words: `the number 1.5`.""" - if value is None: - return "no value at all" + """What the YAML reader made of a value, in words: `the number 1.5`. + None is `NOT_TEXT_KINDS`' "no value at all", as for a leg path read out + of a plan as no value (#181).""" if isinstance(value, bool): return f"the boolean {str(value).lower()}" if isinstance(value, (int, float)): @@ -2995,11 +2995,12 @@ def leg_path_problem(what: str, value) -> str | None: """ if not isinstance(value, str): # NOT "SO QUOTE IT" (#181). Quoted, the plan reads the same spelling - # back as text, and `adopt-project.py execute` writes that text into - # `project.yaml` and the leg's pin as it stands, where its - # `manifest_misreads` refuses every spelling the loader reads as one - # of these kinds. Only a value read out of a plan file gets here: - # `plan`'s flags and the scaffold's are always text. + # back as text, and since #259 `adopt-project.py execute` mounts the + # leg at a directory of that very name (`2026/`, `true/`, `null/`), + # which is the path meant only if it was meant. So the sentence asks + # for the path meant, or for no key at all, and says nothing of + # quotes. Only a value read out of a plan file gets here: `plan`'s + # flags and the scaffold's are always text. kind = NOT_TEXT_KINDS.get(type(value), "something that is not text") return (f"{what} is not text: its value was read as {kind}, and a " "leg path is text: write the path you mean, or remove the key " diff --git a/tests/test_adopt_leg_paths.py b/tests/test_adopt_leg_paths.py index 419d87ac..4fec29c6 100644 --- a/tests/test_adopt_leg_paths.py +++ b/tests/test_adopt_leg_paths.py @@ -261,10 +261,11 @@ def test_a_non_canonical_path_is_named_with_its_canonical_spelling( @pytest.mark.parametrize("value, kind", NOT_TEXT) def test_a_value_that_is_not_text_is_a_problem_that_does_not_echo_it( adopter, value, kind): - """And it does not say "quote it" (#181): quoted, every one of these - spellings is text `execute` writes bare into `project.yaml`, where the - loader reads the same kind again and `plan-manifest-misread` refuses - it.""" + """And it does not say "quote it" (#181): quoted, each of these + spellings is text, and since #259 `execute` mounts the leg at a + directory of that very name, which is the path meant only if it was + meant. So the sentence asks for the path meant, or for no key at + all.""" problem = adopter.leg_path_problem("legs.spec_path", value) assert problem.startswith("legs.spec_path is not text: ") assert problem.endswith( diff --git a/tests/test_adopt_manifest_values.py b/tests/test_adopt_manifest_values.py index 8cf0f4dc..724fbfdc 100644 --- a/tests/test_adopt_manifest_values.py +++ b/tests/test_adopt_manifest_values.py @@ -674,23 +674,21 @@ def test_a_bad_leg_path_is_one_finding_in_the_words_of_its_own_rule(world, ('elected_on: "2026-09-02"', "elected_on: 20260902"), ("id: northwind", "id: 007"), ]) -def test_a_value_the_reader_reads_as_another_kind_is_named_as_read( +def test_a_value_the_reader_reads_as_another_kind_is_not_this_questions( world, old, new): """`id: 007` is the number 7 and `elected_on: 20260902` another number: what the template would write is `str` of what was read, which the plan - never said. The validator refuses the id as read, so the finding is its - alone, and names the value by what was read before the text `execute` - would write (tests/test_adopt_plan_yaml_quoting.py, `NOT_TEXT_INVALID`); - #181's misread rule leaves to it every value it refuses (rv181f's G1). - The date is #294's one finding (`said_of`).""" + never said. The date is #294's one finding (`said_of`), and the + validator is not asked about it. The id is no finding at all: since + #259 the template writes it `id: "7"`, which the validator accepts, and + #181's misread rule, which asks that line, has nothing to say of it, so + `execute` writes `7` for a plan that says `007`, as on main. A + visibility read as a list is the validator's finding, named as read + (tests/test_adopt_plan_yaml_quoting.py, `NOT_TEXT_INVALID`).""" plan = edited(world, old, new) result = run_script(ADOPT, "check", "--plan", str(plan)) - (finding,) = [line for line in result.stderr.splitlines() - if line.startswith("FINDING ")] - assert "1 finding(s)" in result.stderr, result.stderr - assert finding.startswith(said_of(new)[0]), finding - if said_of(new)[0] == FINDING: - assert ", read out of the plan as the number " in finding, finding + assert FINDING not in result.stderr, result.stderr + assert "plan-manifest-misread" not in result.stderr, result.stderr # --- a text value the template once wrote bare: judged once, not twice ------ diff --git a/tests/test_adopt_plan_yaml_quoting.py b/tests/test_adopt_plan_yaml_quoting.py index ef298347..9eccf88b 100644 --- a/tests/test_adopt_plan_yaml_quoting.py +++ b/tests/test_adopt_plan_yaml_quoting.py @@ -41,10 +41,20 @@ (`loader_reading`), put into each template line that carries the value; and the end-to-end test clones and bootstraps what is still accepted. +SINCE #259 THE TEMPLATES QUOTE THOSE LINES (main's 47b1e06): the id, the +branch, the leg paths, the election date and the visibility are written +between double quotes, which read back as typed, so each value of theirs +the gate refused is planned, checked and written as given (`READ_BACK`). +What `execute` still writes bare is each pin's product, in the manifest's +`neutral_product_pins:` list, and the gate refuses that (`REFUSED`) until +lane 2's #514 quotes it. The gate is asked of a line written bare once more +where a test means its own answer (`bare_template`): how it names a value, +and which values it asks about at all. + THE PROJECT'S OWN VALIDATOR IS ASKED FIRST (#261, rv181f's G1). A value it -refuses as the loader reads it -- `id: true`, `elected_on: 20260902`, and at -`plan` a leg path read as no value -- is refused in its words alone, as on -main. The misread finding is for what the validator accepts and `execute` +refuses as the loader reads it -- `visibility: secret`, and `visibility: +true`, a boolean main does not ask it about -- is refused in its words +alone. The misread finding is for what the validator accepts and `execute` writes in every mode, which is what its remediation says. """ @@ -403,6 +413,19 @@ def test_a_value_is_asked_about_in_each_place_it_is_written( PIN_OWNER = "opensoft" #: The flag that names the stand-in clone a `--pin`'s digest is read from. PIN_SOURCE_FLAG = "--pin-source" +#: The organisation every plan here is written for. +TEST_ORG = "testorg" +#: How every finding `check` prints begins. +ANY_FINDING = "FINDING" +#: The other flags the tools here are given, spelled once. +PLAN_OPTION = "--plan" +WORK_DIR_FLAG = "--work-dir" +#: What `check` says of a plan it passes. +PLAN_OK = "plan ok" +#: The key a leg's pin records its mount path under. +SUBMODULE_PATH = "submodule_path" +#: The parameters of the tests that carry a value spelled by hand. +KEY_SPELLED = "key, spelled" #: The first line of the pin `plan` records for `openGlass`. GLASS_ITEM = " - product: openGlass" #: The `_template_values` placeholders the tests below fill. @@ -424,74 +447,55 @@ def test_a_value_is_asked_about_in_each_place_it_is_written( #: carries it: `'"CORP\tom"'`. QUOTED_DOMAIN_USER = f"'\"{DOMAIN_USER}\"'" -#: `(flag, value, plan key, what the project's loader reads)`. At 76a6153 -#: each went through `execute` to `adoption verified` and then to a -#: bootstrap that failed, or that put the legs on another branch than the -#: one they were pushed on (lane 2's review, and this fix's own repro of -#: it). `007` is a branch bootstrap would read as `7`. The project's own -#: validator makes no finding about what the loader reads of any of them, -#: so each is #181's, at `plan` and in a plan. No value `project.yaml` -#: writes between its quotes is here: `render` spells those with -#: `double_quoted` since #284, and `BETWEEN_THE_QUOTES` below holds the ones -#: that misread before it. Since main's #479 each branch is asked of a -#: source that has it (`holding`), and since #273 a `--pin` is +#: `(flag, value, plan key, what the project's loader reads)`: a pin's +#: product, which `execute` writes bare, as an item of the manifest's +#: `neutral_product_pins:` list. At 76a6153 `--pin null` went through +#: `execute` to `adoption verified` with the pin gone, and on main `--pin +#: 007@` still ends `adoption verified` over a root whose own +#: `validate-pins.py` refuses `entry 7` (lane 2's #514). The project's own +#: validator makes no finding about what the loader reads of any of them, 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. +#: `whole_pin`): the value is the pin's product. Once #514 quotes the list's +#: items, each reads back as typed, and these rows are `READ_BACK`'s kind. REFUSED = [ - (TRACKING_FLAG, "true", TRACKING_KEY, TRUE_READ), - (TRACKING_FLAG, "null", TRACKING_KEY, NO_VALUE), (PIN_FLAG, "null", PINS_KEY, NO_VALUE), (PIN_FLAG, "True", PINS_KEY, TRUE_READ), - (TRACKING_FLAG, "007", TRACKING_KEY, SEVEN_READ), -] -#: How the validator's finding about a leg's mount path begins. -LEG_PATH_SAID = "manifest-leg-path: leg 'spec': path is " -#: `(flag, value, plan key, what the loader reads, the validator's words for -#: what it reads)`: leg paths that went the same way at 76a6153, and `2026`, -#: `1.5` and `~` on `main` too (#187's review). `plan` asks the validator -#: first, which refuses each (`manifest-leg-path`), so `plan` refuses it in -#: the validator's words alone, as main does (rv181f's G1). A PLAN carrying -#: one is #181's: `check` leaves the validator's leg-path codes to -#: `_leg_findings` (`LEG_PATH_CODES`), which passes the text, and `execute` -#: writes it and ends `adoption verified`, as on main. -LEG_PATHS = [ - (SPEC_FLAG, "null", SPEC_PATH_KEY, NO_VALUE, f"{LEG_PATH_SAID}None"), - (SPEC_FLAG, "Null", SPEC_PATH_KEY, NO_VALUE, f"{LEG_PATH_SAID}None"), - (SPEC_FLAG, "NULL", SPEC_PATH_KEY, NO_VALUE, f"{LEG_PATH_SAID}None"), - (SPEC_FLAG, "~", SPEC_PATH_KEY, NO_VALUE, f"{LEG_PATH_SAID}None"), - (SPEC_FLAG, "true", SPEC_PATH_KEY, TRUE_READ, f"{LEG_PATH_SAID}True"), - (SPEC_FLAG, FALSE_TITLE, SPEC_PATH_KEY, "the boolean false", - f"{LEG_PATH_SAID}False"), - (SPEC_FLAG, "2026", SPEC_PATH_KEY, "the number 2026", - f"{LEG_PATH_SAID}2026"), - (SPEC_FLAG, "1.5", SPEC_PATH_KEY, "the number 1.5", f"{LEG_PATH_SAID}1.5"), - ("--code-path", "null", "legs.code_path", NO_VALUE, - "manifest-leg-path: leg 'code': path is None"), -] -#: How the validator's finding about an id begins. -ID_SAID = "manifest-id: id is " -#: `(flag, value, plan key, the validator's words for what the loader -#: reads)`: an id the loader reads as a boolean or as no value. The -#: validator refuses it (`manifest-id`), and is asked first, so it is the -#: validator's finding alone, at `plan` and in a plan, as on main (rv181f's -#: G1): a misread finding there said `execute` writes it and the run ends -#: `adoption verified`, and `execute` refuses it before anything exists. -VALIDATORS = [ - ("--id", "true", "id", f"{ID_SAID}True; it must match "), - ("--id", "null", "id", f"{ID_SAID}None; it must match "), + (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] -CARRIED = [(key, value, reading) for _, value, key, reading in REFUSED] \ - + [(key, value, reading) for _, value, key, reading, _ in LEG_PATHS] -#: The validator's alone, as `plan` is given it, and as a plan carries it. -PLANNED_INVALID = [ - (flag, value, said) for flag, value, _, _, said in LEG_PATHS] + [ - (flag, value, said) for flag, value, _, said in VALIDATORS] -#: No date is here: an `elected_on:` `election_date` refuses is #294's -#: finding alone (`LATE_DATES`). No visibility is either: `plan` takes one -#: from a fixed choice, and none other is #181's (`NEVER_WRITTEN`). -CARRIED_INVALID = [(key, value, said) for _, value, key, said in VALIDATORS] +CARRIED = [(key, value, reading) for _, value, key, reading in REFUSED] + +#: How a `READ_BACK` row names the value `plan` takes from the source when +#: no `--tracking-branch` is given: its default branch (#479). +DEFAULT_BRANCH = "default-branch" +#: The flags of the leg paths, and of the project an id is derived from. +CODE_FLAG = "--code-path" +PROJECT_FLAG = "--project" +#: `(flag, value)`: values the project's loader read as something else +#: while the templates wrote them bare. `plan` refused the branches as a +#: misread, and the leg paths and the ids, given or derived from +#: `--project`, as the project's own validator's (`manifest-leg-path`, +#: `manifest-id`); at 76a6153 each went through `execute` to `adoption +#: verified` and a bootstrap that failed or put the legs on another branch. +#: Since #259 (main's 47b1e06) each line is between double quotes and reads +#: back as typed, so each is planned, checked and written as given, with no +#: finding of #181's or the validator's. A branch is asked of a source that +#: has it (#479), and one row takes it from the source's default branch. +READ_BACK = [ + *[(TRACKING_FLAG, value) for value in ("true", "null", "007")], + *[(SPEC_FLAG, value) for value in ("null", "Null", "NULL", "~", "true", + FALSE_TITLE, "2026", "1.5")], + (CODE_FLAG, "null"), + ("--id", "true"), ("--id", "null"), + (PROJECT_FLAG, "True"), (PROJECT_FLAG, "Null"), + (DEFAULT_BRANCH, "true"), +] +#: The placeholders #259 put between double quotes in the assembly root's +#: templated YAML files, which `bare_template` writes bare again. +QUOTED_BY_259 = ("PROJECT_ID", "ELECTED_ON", "VISIBILITY", "TRACKING_BRANCH", + "SPEC_PATH", "CODE_PATH") def ids_of(rows, key: int, value: int) -> list[str]: @@ -499,7 +503,7 @@ def ids_of(rows, key: int, value: int) -> list[str]: def checked(plan: Path): - return run_script(ADOPT, "check", "--plan", str(plan)) + return run_script(ADOPT, "check", PLAN_OPTION, str(plan)) #: The directory `executed` names for the local remotes, which a run that is @@ -508,9 +512,9 @@ def checked(plan: Path): def executed(plan: Path, tmp_path: Path): - return run_script(ADOPT, "execute", "--plan", str(plan), "--yes", + return run_script(ADOPT, "execute", PLAN_OPTION, str(plan), "--yes", "--local-remote-dir", str(tmp_path / REMOTES), - "--work-dir", str(tmp_path / "work")) + WORK_DIR_FLAG, str(tmp_path / "work")) def branches_of(source: Path) -> str: @@ -526,6 +530,16 @@ def planned(tmp_path: Path, *extra: str, tree: dict | None = None) -> Path: return plan +def plan_argv(source: Path, out: Path, tmp_path: Path, *extra: str) -> list: + """`plan`'s arguments for a run in this process (`adopter.main`): as + `conftest.write_plan` gives them, with a work directory of the test's + own.""" + return ["plan", "--source", str(source), PROJECT_FLAG, PROJECT_NAME, + "--org", TEST_ORG, ELECTED_BY_FLAG, "Test Human", "--elected-on", + ELECTION_DATE, WORK_DIR_FLAG, str(tmp_path / "work"), "--out", + str(out), *extra] + + def answer(plan: Path, path: str, leg: str) -> None: """`resolve` for an entry whose path the writer had to QUOTE, which `conftest.resolve` cannot find because it looks for the bare spelling.""" @@ -685,59 +699,147 @@ def test_plan_refuses_a_value_the_projects_loader_would_misread( VALIDATOR_REFUSES = "which the project's own validate-manifest.py refuses: " -@pytest.mark.parametrize("flag, value, said", PLANNED_INVALID, - ids=ids_of(PLANNED_INVALID, 0, 1)) -def test_plan_refuses_a_value_the_validator_refuses_in_its_words_alone( - adopter, answered, tmp_path, flag, value, said): - """The project's own validator is asked first. A value it refuses as - the loader reads it was refused as a misread, whose remediation says - `execute` writes it and the run would still end `adoption verified`: - neither is true of a value the validator refuses (rv181f's G1). It is - refused in the validator's words alone, as on main.""" - source, _ = answered +@pytest.fixture +def bare_template(adopter, tmp_path, monkeypatch) -> Path: + """The templates #181's gate reads (`ASSEMBLY_TEMPLATE`), each line #259 + quoted written bare again, as it was before #259. + + The gate asks the line `execute` writes, and since #259 every line it + asks about but the pins' list is quoted, where no value is misread. What + it says of a line written bare -- how it names a value, and which values + it asks about at all -- is still the rule's own answer, and a test of + that is asked here, of the gate directly or of `plan` in this process. + No `check` run as a process of its own sees this copy, nor the leg-path + rule (`leg_path_problem`, #266) unless a test points it here too, nor + the validator, and `execute` never does. The copy is a templates + directory's `assembly-root`, as the shape's own is.""" + copy = tmp_path / "bare-templates" / "assembly-root" + shipped = "" + for rel in adopter.TEMPLATED_YAML: + text = (adopter.ASSEMBLY_TEMPLATE / rel).read_text(encoding=UTF8) + shipped += text + for placeholder in QUOTED_BY_259: + text = text.replace(f'"{{{{{placeholder}}}}}"', + f"{{{{{placeholder}}}}}") + target = copy / rel + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(text, encoding=UTF8) + assert all(f'"{{{{{placeholder}}}}}"' in shipped + for placeholder in QUOTED_BY_259), "#259's quotes moved" + monkeypatch.setattr(adopter, "ASSEMBLY_TEMPLATE", copy) + return copy + + +def planned_as_given(tmp_path: Path, flag: str, value: str) -> tuple[Path, + Path]: + """`(source, plan)` for a `READ_BACK` row: `plan` given the flag, or the + project an id is derived from, or a source whose default branch is the + value, then answered. A branch is one the source has (#479).""" + default = value if flag == DEFAULT_BRANCH else "main" + source = make_source_repo(tmp_path / SOURCE_NAME, branch=default) + holding(source, flag, value) plan = tmp_path / PLAN_FILE - result = write_plan(source, plan, extra=(flag, value)) - assert result.returncode == 2, result.stdout + result.stderr - assert result.stderr.startswith("REFUSED adopt-manifest-invalid: "), ( - result.stderr) - assert said in result.stderr, result.stderr - assert MISREAD_CODE not in result.stderr, result.stderr - assert adopter.MANIFEST_MISREAD_REMEDIATION not in result.stderr - assert "No plan was written." in result.stderr - assert not plan.exists() + written = write_plan( + source, plan, project=value if flag == PROJECT_FLAG else PROJECT_NAME, + extra=() if flag in (DEFAULT_BRANCH, PROJECT_FLAG) else (flag, value)) + assert written.returncode == 0, written.stdout + written.stderr + assert "REFUSED" not in written.stderr, written.stderr + for path, leg in ANSWERS: + resolve(plan, path, leg) + return source, plan -@pytest.mark.parametrize("project, value, said", [ - ("True", "true", f"{ID_SAID}True; "), ("Null", "null", f"{ID_SAID}None; ")], - ids=["True", "Null"]) -def test_plan_leaves_an_id_it_derived_from_the_project_to_the_validator( - answered, tmp_path, project, value, said): - """`--project True` passes no `--id`, and `plan` lowercases the project - into one, which the loader reads as a boolean and the validator then - refuses: the validator's refusal, as on main, and no misread.""" - source, _ = answered - result = write_plan(source, tmp_path / PLAN_FILE, project=project) - assert result.returncode == 2, result.stdout + result.stderr - assert result.stderr.startswith( - f"REFUSED adopt-manifest-invalid: --id is {value!r}, " - f"{VALIDATOR_REFUSES}{said}"), result.stderr - assert MISREAD_CODE not in result.stderr, result.stderr +def as_given(flag: str, value: str) -> str: + """What the row's value must read back as: an id `plan` derives from + `--project` is the project lowercased, and everything else as typed.""" + return value.lower() if flag == PROJECT_FLAG else value + + +def where_it_lands(flag: str) -> tuple[tuple, tuple]: + """`(in the plan, in project.yaml)`: where the row's value lands in + each, as the steps into what its loader reads. A leg is the second or + third of the manifest's `legs:`; the assembly is the first.""" + if flag in (TRACKING_FLAG, DEFAULT_BRANCH): + return (TRACKING_KEY,), (TRACKING_KEY,) + if flag in (SPEC_FLAG, CODE_FLAG): + role = flag[2:6] + return (("legs", f"{role}_path"), + ("legs", 1 if role == "spec" else 2, "path")) + return ("id",), ("id",) + + +def read_at(loaded, steps: tuple): + """What a loader read at `steps` into what it loaded.""" + for step in steps: + loaded = loaded[step] + return loaded + + +@pytest.mark.parametrize("flag, value", READ_BACK, + ids=ids_of(READ_BACK, 0, 1)) +def test_a_value_the_template_quotes_is_planned_and_checked_as_given( + tmp_path, flag, value): + """`plan` refused each of these, as #181's misread or in the project's + own validator's words (`manifest-leg-path`, `manifest-id`), while the + template wrote its line bare. Since #259 the line is quoted and reads + back as typed: `plan` writes the plan, which reads the value back as + given, and `check` passes it with no finding at all.""" + _, plan = planned_as_given(tmp_path, flag, value) + in_plan, _ = where_it_lands(flag) + assert read_at(load_yaml(plan), in_plan) == as_given(flag, value) + result = checked(plan) + assert result.returncode == 0, result.stdout + result.stderr + assert PLAN_OK in result.stdout, result.stdout + assert ANY_FINDING not in result.stderr, result.stderr + + +@needs_filter_repo +@pytest.mark.parametrize("flag, value", READ_BACK, + ids=ids_of(READ_BACK, 0, 1)) +def test_execute_writes_a_value_the_template_quotes_as_given( + tmp_path, flag, value): + """`execute` refused each of these before any leg existed, as #181's + misread or as the validator's. Since #259 it writes each between double + quotes, ends `adoption verified`, and the split's `project.yaml` reads + the value back as given; a leg path reads back from its pin's + `submodule_path:` too, and the legs are pushed on the branch.""" + source, plan = planned_as_given(tmp_path, flag, value) + result = executed(plan, tmp_path) + assert result.returncode == 0, result.stdout + result.stderr + assert "adoption verified" in result.stdout, result.stdout + assert MISREAD_CODE not in result.stdout + result.stderr + + def shown(rel: str) -> dict: + return parse_yaml(git("show", f"{SPLIT_BRANCH}:{rel}", + cwd=source).stdout) + + _, in_manifest = where_it_lands(flag) + assert read_at(shown(MANIFEST), in_manifest) == as_given(flag, value) + if flag in (SPEC_FLAG, CODE_FLAG): + pin = shown(f"contracts/{flag[2:6]}-pin.yaml") + assert pin[SUBMODULE_PATH] == value + if flag in (TRACKING_FLAG, DEFAULT_BRANCH): + for leg in ("spec", "code"): + heads = git("for-each-ref", "--format=%(refname:short)", + "refs/heads", + cwd=tmp_path / REMOTES / f"{PROJECT_NAME}-{leg}.git") + assert heads.stdout.split() == [value], leg def test_the_misread_rule_names_an_id_plan_derived_by_where_it_came_from( - adopter, monkeypatch): - """Should #181 be the rule that refuses an id `plan` derived, its - sentence says where the value came from, so the person knows the flag - that chooses another. The validator refuses every id the loader - misreads today, and is asked first, so `plan` is asked here with the - validator's refusal taken out: what is left is the rule's own answer.""" - monkeypatch.setattr(adopter, "_refuse_unholdable_values", - lambda *_args: None) + adopter, bare_template, tmp_path): + """Where #181 refuses an id `plan` derived, its sentence says where the + value came from, so the person knows the flag that chooses another. + Since #259 the template quotes `id:`, and `true` reads back there + (`READ_BACK`), so `plan` is asked here of a bare line + (`bare_template`), in this process: what is left is the rule's own + answer. The validator, which reads the shipped template, accepts the + id `true`, as it does on main.""" args = argparse.Namespace( - project="True", org="testorg", id=None, visibility="private", + project="True", org=TEST_ORG, id=None, visibility="private", elected_by="Test Human", elected_on=ELECTION_DATE, reference=None, tracking_branch="main", spec_path="spec", code_path="code", pin=[], - pin_owner=PIN_OWNER, pin_source=None) + pin_owner=PIN_OWNER, pin_source=None, out=str(tmp_path / PLAN_FILE)) naming = adopter.NamingPolicy.load(adopter.NAMING_POLICY) with pytest.raises(adopter.Refusal) as refused: adopter._checked_plan_inputs( @@ -759,8 +861,8 @@ def test_plan_takes_an_elector_from_git_the_manifests_quotes_read_back( source, _ = answered plan = tmp_path / PLAN_FILE result = run_script( - ADOPT, "plan", "--source", str(source), "--project", PROJECT_NAME, - "--org", "testorg", "--elected-on", ELECTION_DATE, "--out", str(plan), + ADOPT, "plan", "--source", str(source), PROJECT_FLAG, PROJECT_NAME, + "--org", TEST_ORG, "--elected-on", ELECTION_DATE, "--out", str(plan), env={"GIT_CONFIG_COUNT": "1", "GIT_CONFIG_KEY_0": "user.name", "GIT_CONFIG_VALUE_0": DOMAIN_USER}) assert result.returncode == 0, result.stdout + result.stderr @@ -779,9 +881,8 @@ def test_check_reports_a_plan_carrying_one(adopter, answered_copy, tmp_path, """A plan carrying the value -- spelled by the plan writer, so the plan itself reads it back -- is a FINDING, with the remediation once after. It is #181's alone: the project's own validator, asked first, makes no - finding about what the loader reads of it (a leg path's are - `_leg_findings`' to judge, which passes the text), and `execute` writes - it in every mode.""" + finding about what the loader reads of it, and `execute` writes it in + every mode.""" source, text = answered_copy holding(source, key, value) plan = tmp_path / PLAN_FILE @@ -793,7 +894,7 @@ def test_check_reports_a_plan_carrying_one(adopter, answered_copy, tmp_path, assert what in result.stderr, result.stderr assert result.stderr.count(adopter.MANIFEST_MISREAD_REMEDIATION) == 1 assert INVALID_FINDING not in result.stderr, result.stderr - assert "plan ok" not in result.stdout + assert PLAN_OK not in result.stdout def judged_lines(stderr: str) -> list[str]: @@ -802,26 +903,6 @@ def judged_lines(stderr: str) -> list[str]: if line.startswith((MISREAD_FINDING, INVALID_FINDING))] -@pytest.mark.parametrize("key, value, said", CARRIED_INVALID, - ids=ids_of(CARRIED_INVALID, 0, 1)) -def test_check_leaves_a_value_the_validator_refuses_to_it( - adopter, answered, tmp_path, key, value, said): - """`id: "true"` was #181's finding, with a remediation saying `execute` - writes it and the run would still end `adoption verified`; `execute` - refuses it before anything exists, as the validator's (rv181f's G1). - It is the validator's finding alone, as on main, and no misread.""" - _, text = answered - plan = tmp_path / PLAN_FILE - carrying(plan, text, key, adopter.y(value)) - result = checked(plan) - assert result.returncode == 1, result.stdout + result.stderr - (line,) = judged_lines(result.stderr) - assert line.startswith( - f"{INVALID_FINDING}{key} is {value!r}, {VALIDATOR_REFUSES}{said}"), ( - result.stderr) - assert adopter.MANIFEST_MISREAD_REMEDIATION not in result.stderr - - #: How a plan sets no id, each beside how it spells the assembly the id is #: then derived from. The first pair is what `main`'s #: `plan --project True --id null` writes: `id: null`, and a bare `True` @@ -843,32 +924,33 @@ def without_an_id(text: str, id_line: str) -> str: @pytest.mark.parametrize("id_line, assembly", NO_ID) -def test_check_leaves_an_id_it_derived_from_the_assembly_to_the_validator( +def test_check_passes_an_id_it_derived_from_the_assembly_as_the_text_it_is( answered, tmp_path, id_line, assembly): """A plan that sets no id gets one from `execute`, which lowercases - `legs.assembly`: `true`, which the loader reads as a boolean and the - validator refuses. The validator's finding alone, as on main.""" + `legs.assembly`: `true`. While the template wrote `id:` bare the loader + read that as a boolean, and the validator refused it as that (`id is + True`). Since #259 the line is quoted, `true` reads back as the id it + is, which the validator accepts, and `check` passes the plan with no + finding.""" _, text = answered plan = tmp_path / PLAN_FILE carrying(plan, without_an_id(text, id_line), ASSEMBLY_KEY, assembly) result = checked(plan) - assert result.returncode == 1, result.stdout + result.stderr - (line,) = judged_lines(result.stderr) - assert line.startswith( - f"{INVALID_FINDING}id is 'true', {VALIDATOR_REFUSES}{ID_SAID}True; " - ), result.stderr + assert result.returncode == 0, result.stdout + result.stderr + assert PLAN_OK in result.stdout, result.stdout + assert ANY_FINDING not in result.stderr, result.stderr @pytest.mark.parametrize("id_line, assembly", NO_ID) def test_the_misread_rule_names_an_id_it_derived_from_the_assembly( - adopter, answered, tmp_path, id_line, assembly): - """Should #181 be the rule that reports an id `execute` derives from - `legs.assembly`, its sentence says so, because the line to change is not - the one with the value. It says "not set in the plan" and not "not in - the plan": an `id: null` is in the plan and sets nothing (review181's - N3), and `Plan.get` reads it exactly as a missing key. The validator - refuses this id today, and is asked first, so this is the rule's own - answer, asked of it directly.""" + adopter, answered, bare_template, tmp_path, id_line, assembly): + """Where #181 reports an id `execute` derives from `legs.assembly`, its + sentence says so, because the line to change is not the one with the + value. It says "not set in the plan" and not "not in the plan": an `id: + null` is in the plan and sets nothing (review181's N3), and `Plan.get` + reads it exactly as a missing key. Since #259 the template quotes `id:`, + where `true` reads back, so this is the rule's own answer for a bare + line (`bare_template`), asked of it directly.""" _, text = answered plan = tmp_path / PLAN_FILE carrying(plan, without_an_id(text, id_line), ASSEMBLY_KEY, assembly) @@ -882,14 +964,17 @@ def test_the_misread_rule_names_an_id_it_derived_from_the_assembly( #: `(plan key, as a hand-edited plan spells it, what the plan's loader reads -#: it as, the text `execute` writes for it)`. The loader reads each one as -#: something that is not text, so the plan does not say what `str` of it -#: says: `007` is read as 7, and `TRUE` as true. The sentence names what was -#: READ, with its kind, and then the text `execute` writes (review181's N2). -#: The project's loader reads that text back as the same kind again, and the -#: project's own validator makes no finding about it: it does not judge a -#: branch. An id, a date or a visibility read as not text is the -#: validator's (`NOT_TEXT_INVALID`), and a pin's product #273's +#: it as, the text `execute` writes for it)`. The plan's loader reads each +#: one as something that is not text, so the plan does not say what `str` +#: of it says: `007` is read as 7, and `TRUE` as true, and `execute` writes +#: `str` of what was read. While the template wrote these lines bare, the +#: project's loader read that text back as the same kind again, and #181 +#: named the branch by what was READ, with its kind, and then the text +#: `execute` writes (review181's N2); the validator refused the id as `id is +#: 7`. Since #259 the line is quoted and `7` reads back as the text `7`, so +#: nothing names any of them, as on main: `execute` writes `7` for a plan +#: that says `007`. A date or a visibility read as not text is still +#: refused (`LATE_DATES`, `NOT_TEXT_INVALID`), and a pin's product is #273's #: (`test_a_pin_whose_product_is_not_text_is_273s_finding_alone`). The #: source has the branch as `execute` takes it, `str` of what was read #: (#479). @@ -897,70 +982,83 @@ def test_the_misread_rule_names_an_id_it_derived_from_the_assembly( (TRACKING_KEY, "007", SEVEN_READ, "7"), (TRACKING_KEY, "TRUE", TRUE_READ, "True"), (TRACKING_KEY, "1.50", "the number 1.5", "1.5"), + ("id", "007", SEVEN_READ, "7"), ] @pytest.mark.parametrize("key, spelled, read_as, written", READ_AS_NOT_TEXT, ids=ids_of(READ_AS_NOT_TEXT, 0, 1)) -def test_check_names_a_plan_value_that_is_not_text_by_what_was_read( +def test_check_passes_a_plan_value_read_as_not_text_as_main_does( answered_copy, tmp_path, key, spelled, read_as, written): - """The finding said `tracking_branch is '7'` for a plan that says `007`, - and `'True'` for `TRUE`: `str` of what was read, which the plan never - said. It says what the plan's loader read, and the text `execute` - writes for it, which is `str` of that and is named as such.""" + """`check` named each of these, the branch as #181's misread and the id + in the validator's words, while the template wrote its line bare. Since + #259 the line is quoted, the text `execute` writes reads back, and + neither rule has a finding: `check` passes the plan, as main does, and + `execute` writes `str` of what was read, `7` for `007` (a residual this + change shares with main, in its description). `read_as` is the + misread rule's answer for a bare line, asked in the test below.""" source, text = answered_copy holding(source, key, written) plan = tmp_path / PLAN_FILE carrying(plan, text, key, spelled) result = checked(plan) - assert result.returncode == 1, result.stdout + result.stderr - named = NAMED.get(key, key) - assert (f"{MISREAD_FINDING}{named} was read out of the plan as {read_as}, " - "which `execute` writes into ") in result.stderr, result.stderr - assert (f"reads it back as {read_as} and not as the text `{written}`: " - "choose another value.") in result.stderr, result.stderr - assert f"{named} is " not in result.stderr, "`str` of it was echoed" - assert INVALID_FINDING not in result.stderr, result.stderr + assert result.returncode == 0, result.stdout + result.stderr + assert PLAN_OK in result.stdout, result.stdout + assert ANY_FINDING not in result.stderr, result.stderr -def test_the_misread_rule_names_a_set_id_by_what_was_read( - adopter, answered, tmp_path): - """`id: 007` is the number 7 to the plan's reader. Should #181 be the - rule that reports it, its sentence names what was READ, as the plan's - `id:` holds it, and never `str` of what `Plan.project_id` makes of it - (review181's N2). The validator refuses it today, and is asked first - (`NOT_TEXT_INVALID`), so this is the rule's own answer, asked of it - directly.""" +@pytest.mark.parametrize("key, spelled, read_as, written", READ_AS_NOT_TEXT, + ids=ids_of(READ_AS_NOT_TEXT, 0, 1)) +def test_the_misread_rule_names_a_plan_value_that_is_not_text_by_what_was_read( + adopter, answered, bare_template, tmp_path, key, spelled, read_as, + written): + """The finding said `tracking_branch is '7'` for a plan that says `007`, + and `'True'` for `TRUE`: `str` of what was read, which the plan never + said. It says what the plan's loader read, and the text `execute` + writes for it, which is `str` of that and is named as such; an id too, + never `str` of what `Plan.project_id` makes of it. Since #259 the + template quotes these lines, so this is the rule's answer for a bare one + (`bare_template`), asked of `_misread_findings` directly.""" _, text = answered plan = tmp_path / PLAN_FILE - carrying(plan, text, "id", "007") - (said,) = adopter.manifest_misreads([adopter._id_field( - adopter.Plan.load(plan))]) - assert said.startswith(f"id was read out of the plan as {SEVEN_READ}, " - "which `execute` writes into "), said - assert said.endswith(f"reads it back as {SEVEN_READ} and not as the " - "text `7`: choose another value"), said + carrying(plan, text, key, spelled) + (said,) = adopter._misread_findings(adopter.Plan.load(plan)) + named = NAMED.get(key, key) + assert said.startswith(f"{MISREAD_FINDING}{named} was read out of the " + f"plan as {read_as}, which `execute` writes " + "into "), said + assert said.endswith(f"reads it back as {read_as} and not as the text " + f"`{written}`: choose another value."), said + assert f"{named} is " not in said, "`str` of it was echoed" #: `(plan key, as a plan edited by hand spells it)`: values the project's -#: loader would misread, which `execute` never writes. It refuses the branch -#: as `unsafe-value` (`_checked_plan_values`), and `gh repo create` takes no -#: `--true`, before anything is written. A finding would say the value is -#: written and the run ends `adoption verified`: for these neither is true -#: (rv181f's F2, F2b). Since #261 the visibility is the project's own -#: validator's finding instead (`JUDGED_ONCE`). +#: loader misread where a template wrote them bare, which `execute` never +#: writes. It refuses the branch as `unsafe-value` (`_checked_plan_values`), +#: and `gh repo create` takes no `--true`, before anything is written. A +#: finding would say the value is written and the run ends `adoption +#: verified`: for these neither is true (rv181f's F2, F2b). Since #261 the +#: visibility is the project's own validator's finding instead +#: (`JUDGED_ONCE`). #: #: So is every other visibility outside the three choices, in gh mode, -#: `execute`'s default: `gh repo create` is handed `--null`, `--` or +#: `execute`'s default: `gh repo create` would be handed `--null`, `--` or #: `--'private'` before any leg exists, and `check` is not told the mode. -#: The texts the loader reads as no value, or as one of the three, are ones -#: the validator accepts as read, so nothing names them, as on main: with -#: `--local-remote-dir` the run ends `adoption verified`, the field read as -#: that (rv181f's G3, a residual this change shares with main). +#: The texts the loader read as no value, or as one of the three, while the +#: template wrote `visibility:` bare were ones the validator accepted as +#: read, and nothing named them (rv181f's G3). Since #259 the line is quoted, +#: each is the text it is, and the validator refuses it, in either mode, +#: before anything exists. #: #: So is a branch the loader misreads that git will not create, `~`, read -#: as no value: `execute` dies at `git init -b ~` before it writes anything, -#: as on main (rv181f's H1, a residual this change shares with main too). +#: as no value where it is bare. No source has it, so it is #479's +#: `plan-tracking-branch-missing`, and `execute` refuses it before any leg +#: exists (rv181f's H1). +#: +#: On the templates shipped since #259 each of these lines is quoted and +#: reads back, and `check` names none of them misread whatever the rule +#: asks; `test_the_misread_rule_asks_nothing_of_a_value_execute_never_writes` +#: asks the rule of a bare line. NEVER_WRITTEN = [ pytest.param(TRACKING_KEY, QUOTED_DOMAIN_USER, id=TRACKING_KEY), pytest.param(TRACKING_KEY, '"~"', id="tracking_branch-tilde"), @@ -975,7 +1073,7 @@ def test_the_misread_rule_names_a_set_id_by_what_was_read( ] -@pytest.mark.parametrize("key, spelled", NEVER_WRITTEN) +@pytest.mark.parametrize(KEY_SPELLED, NEVER_WRITTEN) def test_check_says_nothing_misread_of_a_value_execute_never_writes( adopter, answered, tmp_path, key, spelled): """`check` asked the loader about every value, and named these as @@ -991,6 +1089,20 @@ def test_check_says_nothing_misread_of_a_value_execute_never_writes( assert adopter.MANIFEST_MISREAD_REMEDIATION not in result.stderr +@pytest.mark.parametrize(KEY_SPELLED, NEVER_WRITTEN) +def test_the_misread_rule_asks_nothing_of_a_value_execute_never_writes( + adopter, answered, bare_template, tmp_path, key, spelled): + """Asked of a bare line (`bare_template`), the rule still names none of + these: `_execute_gets_to_write` leaves out the branch `execute` refuses + as `unsafe-value`, the one git will not create and every visibility + outside the three choices, and the validator, asked first, judges each + such visibility as well. Asked of `_misread_findings` directly.""" + _, text = answered + plan = tmp_path / PLAN_FILE + carrying(plan, text, key, spelled) + assert adopter._misread_findings(adopter.Plan.load(plan)) == [] + + #: A tracking branch the loader reads as no value, and git will not create. BRANCH_GIT_REFUSES = "~" @@ -999,8 +1111,10 @@ def test_plan_records_a_branch_git_will_not_create_as_on_main( answered, tmp_path): """`plan` refused `--tracking-branch ~` as misread, with a remediation saying `execute` writes it and the run would still end `adoption - verified`. `execute` dies at `git init -b ~` before it writes anything, - so `plan` says nothing of it, as on main (rv181f's H1).""" + verified`. Git will not create `~`, so no source has it: `check` finds + it `plan-tracking-branch-missing` and `execute` refuses it before any + leg exists (#479). `plan` writes the plan, with #479's note and no + misread refusal, as on main (rv181f's H1).""" source, _ = answered plan = tmp_path / PLAN_FILE result = write_plan(source, plan, @@ -1010,11 +1124,13 @@ def test_plan_records_a_branch_git_will_not_create_as_on_main( def test_git_is_asked_only_about_a_branch_the_loader_misreads( - adopter, monkeypatch): + adopter, bare_template, monkeypatch): """`branch_problem` starts git. It is asked about `~` and `true`, which - the loader misreads, and not about `develop`, which it reads back, so a - plan whose values all read back starts no process. Git creates `true`, - so that one is still the misread rule's (`REFUSED`).""" + the loader misreads on a bare line, and not about `develop`, which it + reads back, so a plan whose values all read back starts no process. Git + creates `true`, so that one is the misread rule's. Since #259 the + template quotes the branch, where no branch is misread and git is asked + nothing, so this is asked of a bare line (`bare_template`).""" asked = [] real = adopter.branch_problem @@ -1145,7 +1261,7 @@ def test_a_misread_branch_only_origin_has_is_named_by_neither( _, plan = origin_only(tmp_path) result = checked(plan) assert result.returncode == 0, result.stdout + result.stderr - assert "plan ok" in result.stdout, result.stdout + assert PLAN_OK in result.stdout, result.stdout assert MISREAD_FINDING not in result.stderr, result.stderr assert adopter.MANIFEST_MISREAD_REMEDIATION not in result.stderr @@ -1166,29 +1282,35 @@ def test_execute_refuses_a_branch_only_origin_has_under_a_local_remote( assert SPLIT_BRANCH not in branches_of(source) -def test_plan_names_a_tracking_branch_it_took_from_the_source(tmp_path): +def test_plan_names_a_tracking_branch_it_took_from_the_source( + adopter, bare_template, tmp_path, capsys): """Since #479 `plan` records the source's default branch when no - `--tracking-branch` is given. A source whose default branch is `true` - is refused before the plan exists, naming where the value came from, as - `checked_value` names it, and not as a flag nobody passed.""" + `--tracking-branch` is given. Where the line `execute` writes it into + is bare, a source whose default branch is `true` is refused before the + plan exists, naming where the value came from, as `checked_value` names + it, and not as a flag nobody passed. Since #259 that line is quoted, + and such a source is planned and adopted as given (`READ_BACK`), so + `plan` is asked of a bare line (`bare_template`), in this process.""" source = make_source_repo(tmp_path / SOURCE_NAME, branch="true") plan = tmp_path / PLAN_FILE - result = write_plan(source, plan) - assert result.returncode == 2, result.stdout + result.stderr + code = adopter.main(plan_argv(source, plan, tmp_path)) + stderr = capsys.readouterr().err + assert code == 2, stderr who, what = misread( "--tracking-branch (not given, so the source's default branch)", "true", TRUE_READ) - assert result.stderr.startswith( - f"REFUSED adopt-manifest-misread: {who}"), result.stderr - assert what in result.stderr, result.stderr + assert stderr.startswith(f"REFUSED adopt-manifest-misread: {who}"), ( + stderr) + assert what in stderr, stderr assert not plan.exists() def test_the_source_is_asked_only_about_a_branch_the_loader_misreads( - adopter, monkeypatch): + adopter, bare_template, monkeypatch): """`_pushed_in_every_mode` starts git. It is asked about `true`, which - the loader misreads, and not about `develop`, which it reads back, as - git's own question is asked (`_a_branch_git_creates`).""" + the loader misreads on a bare line (`bare_template`), and not about + `develop`, which it reads back, as git's own question is asked + (`_a_branch_git_creates`).""" asked = [] def recorded(_source, branch): @@ -1256,7 +1378,7 @@ def test_a_pin_whose_product_is_not_text_is_273s_finding_alone( ] -@pytest.mark.parametrize("key, spelled", UNWRITABLE) +@pytest.mark.parametrize(KEY_SPELLED, UNWRITABLE) def test_check_names_a_value_no_file_can_hold_once_as_unwritable( adopter, answered, tmp_path, key, spelled): """At the merge of #255 the gate also called the date and the pin @@ -1299,17 +1421,14 @@ def test_execute_refuses_a_date_no_file_can_hold_as_unwritable_not_misread( #: the loader misreads included (rv181f's G1); so is any value of a manifest #: its reader cannot read at all, and that one finding names no value twice #: (rv181f's G2). What the loader misreads and the validator accepts is -#: #181's: a branch, a pin, a leg path (`check` leaves its codes to -#: `_leg_findings`), and a date read back as other text that is a date. +#: #181's: since #259 a pin's product alone. `id: "007"`, `tracking_branch: +#: "007"` and `legs.spec_path: "null"` were a row each here, the first the +#: validator's and the others #181's; since #259 each reads back and is no +#: finding (main's `TEXT_THE_TEMPLATE_NOW_HOLDS`, and `READ_BACK`). JUDGED_ONCE = [ - pytest.param("id", QUOTED_007, INVALID_FINDING, id="id-text-misread"), pytest.param(PINS_KEY, '"? x"', INVALID_FINDING, id="pin-the-reader-refuses"), - pytest.param(TRACKING_KEY, QUOTED_007, MISREAD_FINDING, - id="branch-text-misread"), pytest.param(PINS_KEY, QUOTED_NULL, MISREAD_FINDING, id="pin-text-misread"), - pytest.param(SPEC_PATH_KEY, QUOTED_NULL, MISREAD_FINDING, - id="leg-path-text-misread"), pytest.param(ELECTED_BY_KEY, '" "', INVALID_FINDING, id="elector-blank"), pytest.param(VISIBILITY_KEY, "secret", INVALID_FINDING, id="visibility-secret"), @@ -1348,19 +1467,24 @@ def test_check_judges_each_value_once_by_the_rule_true_of_it( assert remediation in result.stderr, result.stderr +#: A leg path the validator refuses as text, which its finding about the +#: leg (`manifest-leg-path`) names by no one value of the plan's. +OUTSIDE_THE_ROOT = '"../x"' #: `(plan key, as a plan edited by hand spells it, whether the leg-path #: codes `check` leaves to `_leg_findings` are left out, what -#: `manifest_judged` answers)`. +#: `manifest_judged` answers)`. The first and the last two were `id: "007"` +#: and `legs.spec_path: "null"` while the template wrote those lines bare; +#: since #259 each reads back, and the validator accepts it. JUDGED_BY_THE_VALIDATOR = [ - pytest.param("id", QUOTED_007, False, frozenset({"PROJECT_ID"}), + pytest.param(VISIBILITY_KEY, "secret", False, frozenset({"VISIBILITY"}), id="its-finding-names-the-value"), pytest.param(TRACKING_KEY, QUOTED_007, False, frozenset(), id="a-value-it-does-not-judge"), pytest.param(PINS_KEY, '"? x"', False, None, id="its-reader-refuses-the-file"), - pytest.param(SPEC_PATH_KEY, QUOTED_NULL, False, None, + pytest.param(SPEC_PATH_KEY, OUTSIDE_THE_ROOT, False, None, id="a-finding-about-no-one-value"), - pytest.param(SPEC_PATH_KEY, QUOTED_NULL, True, frozenset(), + pytest.param(SPEC_PATH_KEY, OUTSIDE_THE_ROOT, True, frozenset(), id="a-code-check-leaves-to-its-own-rule"), ] @@ -1384,11 +1508,12 @@ def test_the_validator_says_which_values_it_judges( def test_no_value_passes_a_validator_that_is_not_asked( - adopter, answered, tmp_path): + adopter, answered, bare_template, tmp_path): """Names the naming policy does not classify are `check`'s finding in their own words, and no manifest is rendered from them, so the validator is asked nothing: no value can be said to pass it, and #181 - names none until it can be asked.""" + names none until it can be asked. The branch `"007"` is one #181 names + on a bare line (`bare_template`); since #259 the template quotes it.""" _, text = answered plan = tmp_path / PLAN_FILE carrying(plan, text, ASSEMBLY_KEY, "north_wind") @@ -1439,8 +1564,11 @@ def test_check_leaves_a_date_election_date_refuses_to_that_rule( #: sentence names it, the validator's code)`: read as not text, each is #: named by what was read, as #181 names such a value, before the text #: `execute` would write for it. The validator refuses what the loader -#: reads of each, so each is its finding alone (rv181f's G1); the id was -#: #181's at 7ac2358. A date read as not text is #294's (`LATE_DATES`). +#: reads of each, so each is its finding alone (rv181f's G1). A date read +#: as not text is #294's (`LATE_DATES`). `id: 007` was a row here, the +#: validator's `id is 7`, while the template wrote `id:` bare; since #259 +#: `execute` writes it `"7"`, which the validator accepts +#: (`READ_AS_NOT_TEXT`). NOT_TEXT_INVALID = [ pytest.param(VISIBILITY_KEY, "true", "visibility, read out of the plan " "as the boolean true, is 'True',", @@ -1448,8 +1576,6 @@ def test_check_leaves_a_date_election_date_refuses_to_that_rule( pytest.param(VISIBILITY_KEY, "[secret]", "visibility, read out of the " "plan as a list, is \"['secret']\",", "manifest-visibility", id="visibility-list"), - pytest.param("id", "007", "id, read out of the plan as the number 7, is " - "'7',", "manifest-id", id="id-number"), ] @@ -1488,7 +1614,9 @@ def test_execute_refuses_a_visibility_it_never_writes_as_the_validators( #: `(placeholder, value, the manifest line `execute` writes, what the loader #: reads it back as)`: values the loader reads back as OTHER TEXT, each -#: through a placeholder the template writes bare. The double quotes in the +#: through a placeholder written bare, as `elected_on:` was before #259 +#: (`bare_template`); since #259 the template quotes it, where `render` +#: spells each value with `double_quoted`. The double quotes in the #: first six are the VALUE's own, as a hand-edited plan's #: `elected_on: '"CORP\tom"'` carries them: the loader takes them for YAML's #: and resolves the escape between them. The manifest's OWN quotes no longer @@ -1517,7 +1645,7 @@ def test_execute_refuses_a_visibility_it_never_writes_as_the_validators( @pytest.mark.parametrize("placeholder, value, line, reading", UNESCAPED, ids=ids_of(UNESCAPED, 0, 1)) def test_text_the_loader_reads_as_other_text_is_shown_as_typed_and_read( - adopter, placeholder, value, line, reading): + adopter, bare_template, placeholder, value, line, reading): """`repr` showed `CORP\\tom` with its backslash doubled, and the tab the loader makes of it as `\\t`, so each looked like the other (review181's N1). The value is shown as it is typed, and a character a terminal shows @@ -1634,33 +1762,13 @@ def test_execute_refuses_a_plan_carrying_one_before_any_leg_exists( assert SPLIT_BRANCH not in branches_of(source) -@needs_filter_repo -@pytest.mark.parametrize("key, value, said", CARRIED_INVALID, - ids=ids_of(CARRIED_INVALID, 0, 1)) -def test_execute_refuses_a_value_the_validator_refuses_as_the_validators( - adopter, answered_copy, tmp_path, key, value, said): - """`execute` refuses such a plan under the validator's code, before it - creates a leg or touches the source, so no sentence it prints says the - value is written or that the run gets further (rv181f's G1).""" - source, text = answered_copy - plan = tmp_path / PLAN_FILE - carrying(plan, text, key, adopter.y(value)) - result = executed(plan, tmp_path) - assert result.returncode == 2, result.stdout + result.stderr - assert result.stderr.startswith( - "REFUSED plan-manifest-invalid: "), result.stderr - assert said in result.stderr, result.stderr - assert MISREAD_FINDING not in result.stderr, result.stderr - assert adopter.MANIFEST_MISREAD_REMEDIATION not in result.stderr - assert not (tmp_path / REMOTES).exists() - assert SPLIT_BRANCH not in branches_of(source) - - -#: What the gate still lets through, each spelled like a keyword somewhere: -#: a leg path with `null` as a segment, a branch the PLAN quotes for its -#: leading digit and `project.yaml` writes bare, an id that is a YAML 1.1 -#: boolean this loader reads as text, and a person whose name is a null -#: anywhere `project.yaml` does not quote it (`elected_by:` it does). +#: What the gate let through while the templates wrote these lines bare, +#: each spelled like a keyword somewhere: a leg path with `null` as a +#: segment, a branch the PLAN quotes for its leading digit, an id that is a +#: YAML 1.1 boolean this loader reads as text, and a person whose name is a +#: null, which `elected_by:` has always quoted. Since #259 `project.yaml` +#: quotes the id, the branch and the leg path too, and the run reads each +#: back as given all the same. ACCEPTED = (SPEC_FLAG, LEGS_NULL, TRACKING_FLAG, Q4_BRANCH, "--id", "yes", ELECTED_BY_FLAG, "Null") @@ -1704,7 +1812,7 @@ def test_what_is_still_accepted_adopts_clones_and_bootstraps(tmp_path): manifest[ELECTED_BY_KEY]) == ("yes", Q4_BRANCH, "Null") assert [leg["path"] for leg in manifest["legs"]] == [".", LEGS_NULL, "code"] - assert load_yaml(clone / "contracts/spec-pin.yaml")["submodule_path"] \ + assert load_yaml(clone / "contracts/spec-pin.yaml")[SUBMODULE_PATH] \ == LEGS_NULL for mount in (LEGS_NULL, "code"): branches = [git("rev-parse", "--abbrev-ref", ref, @@ -1715,33 +1823,95 @@ def test_what_is_still_accepted_adopts_clones_and_bootstraps(tmp_path): # --- the gate asks the loader, where the template writes the value ---------- +def loaded_plan(adopter, answered, tmp_path: Path): + """The answered plan, as `check` loads it.""" + _, text = answered + plan = tmp_path / PLAN_FILE + with plan.open("w", encoding=UTF8, newline="\n") as handle: + handle.write(text) + return adopter.Plan.load(plan) + + +def pin_field(adopter, plan, product: str) -> tuple: + """`_pin_field` for one pin of `plan`, as `check` asks about it.""" + naming = adopter.NamingPolicy.load(adopter.NAMING_POLICY) + return adopter._pin_field(PINS_KEY, plan, product, naming) + + +def one_pin_table(adopter, plan, product: str) -> dict | None: + """The table `execute` writes the manifest from (`_template_table`, as + `_manifest_values` builds it) for `plan` with one pin, `product`.""" + naming = adopter.NamingPolicy.load(adopter.NAMING_POLICY) + alone = adopter.Plan(plan.path, {**plan.data, + PINS_KEY: [product]}) + return adopter._manifest_values(alone, naming) + + def test_the_gate_follows_the_loader_when_the_loader_learns_a_keyword( - adopter, monkeypatch): + adopter, answered, tmp_path, monkeypatch): """ONE QUESTION, `loader_reading`, for the plan writer and the gate. Teach the real reader that `yes` is a boolean and both change with no edit: - `y` quotes it, and a branch or an id called `yes` is refused.""" + `y` quotes it, and a pin called `yes`, which `execute` writes bare in + the manifest's list, is refused. A branch or an id called `yes` is + not: since #259 `execute` writes each between double quotes, which the + reader reads back as the text whatever it has learnt.""" reads = repo_shape._scalar fields = [adopter._field(TRACKING_KEY, TRACKING_PLACEHOLDER, "yes"), - adopter._field("id", "PROJECT_ID", "yes")] + adopter._field("id", "PROJECT_ID", "yes"), + pin_field(adopter, loaded_plan(adopter, answered, tmp_path), + "yes")] assert adopter.manifest_misreads(fields) == [] monkeypatch.setattr(repo_shape, SCALAR_READER, lambda raw: True if raw.strip() == "yes" else reads(raw)) assert adopter.y("yes") == QUOTED_YES assert [problem.split(" is ")[0] for problem - in adopter.manifest_misreads(fields)] == [TRACKING_KEY, "id"] + in adopter.manifest_misreads(fields)] == [PINS_KEY] -def test_the_template_line_says_whether_a_value_is_quoted(adopter): +def test_the_template_line_says_whether_a_value_is_quoted( + adopter, answered, tmp_path): """No list in `adopt-project.py` says which placeholders the templates - quote: the line is read. `Null` is a person's name between the quotes of - `elected_by:`, and no branch at all after `tracking_branch:`.""" + quote: the line is read. `Null` is a person's name between the quotes + of `elected_by:`, and a branch between those of `tracking_branch:` + since #259; as an item of the pins' list, which `execute` writes bare, + it is no product at all.""" assert adopter._written(ELECTED_BY_PLACEHOLDER, "Null") == [ (MANIFEST, 'elected_by: "Null"')] - assert adopter.manifest_misreads( - [adopter._field(ELECTED_BY_KEY, ELECTED_BY_PLACEHOLDER, "Null")]) == [] - assert adopter.manifest_misreads( - [adopter._field(TRACKING_KEY, TRACKING_PLACEHOLDER, "Null")]) != [] + assert adopter._written(TRACKING_PLACEHOLDER, "Null") == [ + (MANIFEST, 'tracking_branch: "Null"')] + assert adopter.manifest_misreads([ + adopter._field(ELECTED_BY_KEY, ELECTED_BY_PLACEHOLDER, "Null"), + adopter._field(TRACKING_KEY, TRACKING_PLACEHOLDER, "Null")]) == [] + field = pin_field(adopter, loaded_plan(adopter, answered, tmp_path), + "Null") + assert field[2] == [(f"{MANIFEST}'s neutral_product_pins", "- Null")] + assert adopter.manifest_misreads([field]) != [] + + +def test_the_gate_asks_the_table_execute_writes_the_pins_from( + adopter, answered, tmp_path, monkeypatch): + """`_pin_field` asks `_template_table`, the table `execute`'s + `_template_values` writes the manifest from, and never a copy of how it + spells an item. So the day that table quotes each product, as lane 2's + #514 means to, the gate asks the quoted line and passes `null`, with no + edit of its own. Here the table is made to quote each item so.""" + plan = loaded_plan(adopter, answered, tmp_path) + assert adopter.manifest_misreads([pin_field(adopter, plan, "null")]) + bare = adopter._template_table + + def quoting(*args, **kwargs): + table = bare(*args, **kwargs) + first, *items = table[PINS_PLACEHOLDER].split("\n") + table[PINS_PLACEHOLDER] = "\n".join( + [first] + [f" - {repo_shape.double_quoted(item[4:])}" + for item in items]) + return table + + monkeypatch.setattr(adopter, "_template_table", quoting) + field = pin_field(adopter, plan, "null") + assert field[2] == [(f"{MANIFEST}'s neutral_product_pins", '- "null"')] + assert adopter.manifest_misreads([field]) == [] #: Where each placeholder lands in the RENDERED files, as their loader reads @@ -1752,24 +1922,21 @@ def test_the_template_line_says_whether_a_value_is_quoted(adopter): ELECTED_BY_PLACEHOLDER: [(MANIFEST, (ELECTED_BY_KEY,))], REFERENCE_PLACEHOLDER: [(MANIFEST, (REFERENCE_KEY,))], "SPEC_PATH": [(MANIFEST, ("legs", 1, "path")), - ("contracts/spec-pin.yaml", ("submodule_path",))], + ("contracts/spec-pin.yaml", (SUBMODULE_PATH,))], PINS_PLACEHOLDER: [(MANIFEST, ("neutral_product_pins", 0))], } -def rendered_readings(adopter, placeholder: str, value: str) -> list: +def rendered_readings(placeholder: str, value: str) -> list: """What the project's loader reads where `value` lands, in the three templated YAML files rendered WHOLE by the materializer's own `render`, - every other placeholder filled with plain text.""" + every other placeholder filled with plain text. For the pins, `value` + is the list as `_template_table` makes it.""" from shape_materialize import PLACEHOLDER_RE, render template = REPO / "templates" / "assembly-root" # A naming block is lines of its own, and an empty one is no lines. values = {PINS_PLACEHOLDER: "[]", "ASSEMBLY_NAMING": "", - "SPEC_NAMING": "", "CODE_NAMING": ""} - if placeholder == PINS_PLACEHOLDER: - values[placeholder] = "\n" + adopter._pin_item(value) - else: - values[placeholder] = value + "SPEC_NAMING": "", "CODE_NAMING": "", placeholder: value} readings = [] for name, path in LANDS[placeholder]: text = (template / name).read_text(encoding=UTF8) @@ -1787,19 +1954,57 @@ def rendered_readings(adopter, placeholder: str, value: str) -> list: @pytest.mark.parametrize("placeholder", sorted(LANDS)) def test_the_gate_agrees_with_the_rendered_files_on_every_candidate( - adopter, placeholder): + adopter, answered, tmp_path, placeholder): """The gate asks one template line at a time. Rendered whole, as `execute` renders them, the files must agree on every candidate this module generates: a value the gate passes comes back out of each place - it lands, and one it refuses does not.""" + it lands, and one it refuses does not. A pin is asked as `check` asks + it, of the answered plan with that one pin, and rendered from the same + table; the empty candidate is no pin (`Plan` keeps no empty product).""" + plan = loaded_plan(adopter, answered, tmp_path) + asked = 0 for value in CANDIDATES: if placeholder == PINS_PLACEHOLDER: - field = adopter._pin_field(PINS_KEY, value) + if not value: + continue + field = pin_field(adopter, plan, value) + written = one_pin_table(adopter, plan, value)[PINS_PLACEHOLDER] else: field = adopter._field("x", placeholder, value) + written = value refused = bool(adopter.manifest_misreads([field])) - lands = rendered_readings(adopter, placeholder, value) + lands = rendered_readings(placeholder, written) assert refused == any(got != value for got in lands), (value, lands) + asked += 1 + assert asked >= len(CANDIDATES) - 1 + + +# --- a leg path the project's loader misreads is #266's (main's 191105a) ---- + +def test_a_leg_path_written_bare_is_266s_finding_alone( + adopter, answered, bare_template, tmp_path, monkeypatch, capsys): + """Where every template line that holds a leg path is bare, as before + #259, the leg-path rule both tools ask (`leg_path_problem`, #266) + refuses a leg path the project's loader misreads, and #181's gate asks + its question of a leg path only where that rule has no answer + (`_plan_fields`): one wrong value is one finding, #266's. Since #259 + those lines are quoted, and neither rule refuses `null` there + (`READ_BACK`). Asked of a bare copy of the shape's templates, given to + both rules, in this process.""" + materialize = sys.modules["shape_materialize"] + monkeypatch.setattr(materialize, "LEG_PATH_TEMPLATES", + bare_template.parent) + said = adopter.leg_path_problem(SPEC_PATH_KEY, "null") + assert said.startswith(f"{SPEC_PATH_KEY} is 'null', which the project " + "would not read back as text: "), said + _, text = answered + plan = tmp_path / PLAN_FILE + carrying(plan, text, SPEC_PATH_KEY, QUOTED_NULL) + assert adopter._misread_findings(adopter.Plan.load(plan)) == [] + assert adopter.main(["check", PLAN_OPTION, str(plan)]) == 1 + findings = [line for line in capsys.readouterr().err.splitlines() + if line.startswith("FINDING ")] + assert findings == [f"FINDING plan-bad-leg-path: {said}"], findings # --- an explicit null leg path, the reviewer's question on 76a6153 ---------- @@ -1842,12 +2047,9 @@ def test_the_plan_writer_quotes_a_pin_whatever_refuses_it_later( monkeypatch.setattr(adopter, "manifest_misreads", lambda _fields: []) for pin in ("null", "Null", "NULL", "true", FALSE_TITLE): plan = tmp_path / f"{pin}.yaml" - assert adopter.main([ - "plan", "--source", str(source), "--project", PROJECT_NAME, - "--org", "testorg", ELECTED_BY_FLAG, "Test Human", - "--elected-on", ELECTION_DATE, PIN_FLAG, f"{pin}@{commit}", - PIN_SOURCE_FLAG, str(path), - "--work-dir", str(tmp_path / "work"), "--out", str(plan)]) == 0 + assert adopter.main(plan_argv( + source, plan, tmp_path, PIN_FLAG, f"{pin}@{commit}", + PIN_SOURCE_FLAG, str(path))) == 0 assert f'\n - product: "{pin}"\n' in text_of(plan) assert load_yaml(plan)[PINS_KEY][0]["product"] == pin assert adopter.Plan.load(plan).pins == [pin] @@ -1893,7 +2095,7 @@ def test_a_source_file_named_like_a_keyword_is_one_entry_with_that_path( answer(plan, path, "root") result = checked(plan) assert result.returncode == 0, result.stdout + result.stderr - assert "FINDING" not in result.stdout + result.stderr + assert ANY_FINDING not in result.stdout + result.stderr def test_the_doctors_placement_plan_quotes_a_path_the_same_way(adopter): diff --git a/tests/test_template_text_placeholders.py b/tests/test_template_text_placeholders.py index 4a68103b..11596c84 100644 --- a/tests/test_template_text_placeholders.py +++ b/tests/test_template_text_placeholders.py @@ -242,10 +242,12 @@ def test_a_scaffold_reads_back_every_value_it_was_given(tmp_path, project_id, # # `plan` writes a value the plan's own reader would misread between double # quotes when it starts with a digit, so `2026`, `007`, `1.5` and `20261011` -# go through the flags. It writes `true` and `null` bare, and the plan's -# reader then takes them as a boolean and as no value: that is the PLAN's -# writer, #181's to fix. Those two rows quote the value in the plan by hand, -# as #181's writer will, and ask `check` and `execute` the rest. +# go through the flags. It wrote `true` and `null` bare, and the plan's +# reader then took them as a boolean and as no value: that was the PLAN's +# writer, which #181 fixes, and `plan` quotes them too now +# (tests/test_adopt_plan_yaml_quoting.py drives them through the flags). +# These two rows quote the value in the plan by hand, as a plan edited by +# hand does, and ask `check` and `execute` the rest. ADOPTIONS = [ # --id, --tracking-branch, --spec-path, --code-path, by hand From 9efe95a98150833d3cf27912a05fb062eb12e9c3 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 13 Oct 2026 23:02:05 +0000 Subject: [PATCH 23/24] adopt-project.py: with #514 and #516 merged, `--pin null@`, `True@` and `007@` are planned, checked and adopted as given, and the misread gate's refusals, #479's branch questions and the validator-first order at `plan` are asked of lines written bare again, so no test of the gate passes with its change reverted (#181) Main's e6fbd748 (#514) quotes an item of the manifest's `neutral_product_pins:` list the project's reader would misread, and 89c6a6c (#516) quotes the pin file's `product:`. The gate asks the table `execute` writes the list from (`_pin_field`), so it asks the quoted line with no edit, and on the shipped templates it now finds nothing. The 14 tests that asked the bare line become true: - `--pin null@`, `True@` and `007@` move from `REFUSED` to `READ_BACK`: `plan` writes `product: "null"`, `check` passes it, and `execute` writes `- "null"` into the manifest and `product: "null"` into `contracts/null-pin.yaml`, each read back as the text given. - `test_plan_refuses_a_value_the_projects_loader_would_misread`, `test_check_reports_a_plan_carrying_one` and `test_execute_refuses_a_plan_carrying_one_before_any_leg_exists` run in this process over a line written bare: `REFUSED` is the branches `true`, `null` and `007` over `bare_template` and the pins `null`, `True` and `007` over the new `bare_pins`, the pins' list written as before #514. The check test also asks that the finding is the only one judged and the remediation is printed once. - `JUDGED_ONCE` loses `pin-text-misread` (its successor is the check test above) and `pin-the-reader-refuses`, and `JUDGED_BY_THE_VALIDATOR` loses `its-reader-refuses-the-file`. No value of any key the manifest is rendered from makes a manifest the validator's reader refuses on the shipped templates (probed, 3384 plans), so G2 is asked of the same pin `? x` over `bare_pins`, in `test_a_manifest_its_reader_refuses_is_the_validators_alone`. - The template-line test expects `- "Null"` and no finding. The table test drops its first assertion. Its stub that quoted each item would now quote an item twice, so it asks the real table (`- "null"`, passed) and then the list written bare (`- null`, refused). - The keyword-learning test fixed the pin's line before teaching the reader, so it refused a line `execute` no longer writes. It now asks the pin after teaching (`- "yes"`, passed) and a branch over `bare_template` (refused). - The two #273 pin tests that K7 and K8 revert run in this process over `bare_pins`, where a pin asked about would be named. rv181g's B1 at 180902f: since #259 no test held #479's composition or the validator-first order at `plan`. Added: `test_a_branch_execute_does_not_push_in_every_mode_is_never_misread` (the source lacks `true`, its `origin` lacks it, only `origin` has it; `plan` and `check` in this process over `bare_template`), `test_the_source_says_whether_execute_pushes_the_legs_to_a_branch` (`_pushed_in_every_mode` for four sources) and `test_plan_asks_the_validator_before_the_misread_rule` (`--id true`, with the validator given the same bare copy). The shipped #479 tests say they pass there because the line is quoted. #514's docstring on `_neutral_product_pins` said keywords `y` leaves bare reach it "only from a plan edited by hand, because `check` refuses the bare `product: true` that `plan` writes". With this branch's `y` that is false: `plan` writes `"true"` and `"null"` and `check` passes them. It says so now. That docstring is the only adopt-project.py change since the merges. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- adopt-project.py | 17 +- tests/test_adopt_plan_yaml_quoting.py | 549 ++++++++++++++++++-------- 2 files changed, 396 insertions(+), 170 deletions(-) diff --git a/adopt-project.py b/adopt-project.py index f2e580b9..c293c6da 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -7614,15 +7614,16 @@ def _neutral_product_pins(pins) -> str: EACH ENTRY IS SPELLED AS `y` SPELLS THE PLAN'S `product:`. Of the names `--pin` takes, `y` writes bare only one that begins with a letter, `_` - or `.`, so `openGlass` is the line it always was and `007` is `"007"`. - Where the reader would not read that bare spelling back as the same - text, which is `true`, `null` and the other keywords `y` leaves bare - (#181), the entry is double-quoted all the same. The reader is asked, as + or `.` and that the reader reads back as the same text, after `key: ` + and as a `- ` item alike (#181), so `openGlass` is the line it always + was, `007` is `"007"`, and `true` and `null` are `"true"` and `"null"`. + The reader is asked once more of the entry as this list holds it, as `family.py` asks it of a member's id (#259), rather than a list of its - words. Such a name reaches here only from a plan edited by hand, because - `check` refuses the bare `product: true` that `plan` writes. The list is - a block placeholder, so `render` writes it as it stands, and nothing - else quotes it. + words, and an entry it would not read back as the same text is + double-quoted all the same. Such a name reaches here from `plan` as + well as from a plan edited by hand: `plan` records it quoted, and + `check` passes it. The list is a block placeholder, so `render` writes + it as it stands, and nothing else quotes it. """ if not pins: return "[]" diff --git a/tests/test_adopt_plan_yaml_quoting.py b/tests/test_adopt_plan_yaml_quoting.py index 9eccf88b..2ef3274b 100644 --- a/tests/test_adopt_plan_yaml_quoting.py +++ b/tests/test_adopt_plan_yaml_quoting.py @@ -43,13 +43,15 @@ SINCE #259 THE TEMPLATES QUOTE THOSE LINES (main's 47b1e06): the id, the branch, the leg paths, the election date and the visibility are written -between double quotes, which read back as typed, so each value of theirs -the gate refused is planned, checked and written as given (`READ_BACK`). -What `execute` still writes bare is each pin's product, in the manifest's -`neutral_product_pins:` list, and the gate refuses that (`REFUSED`) until -lane 2's #514 quotes it. The gate is asked of a line written bare once more -where a test means its own answer (`bare_template`): how it names a value, -and which values it asks about at all. +between double quotes, which read back as typed. Since #514 (main's +e6fbd748) `execute` quotes an item of the manifest's `neutral_product_pins:` +list the reader would misread, so a pin's product reads back too. Each value +the gate refused is planned, checked and written as given (`READ_BACK`), and +on the shipped templates the gate finds nothing. It is asked of a line +written bare once more where a test means its own answer (`bare_template`, +`bare_pins`, in this process): how it names a value, which values it asks +about at all, and what `plan`, `check` and `execute` do with its answer +(`REFUSED`). THE PROJECT'S OWN VALIDATOR IS ASKED FIRST (#261, rv181f's G1). A value it refuses as the loader reads it -- `visibility: secret`, and `visibility: @@ -61,6 +63,7 @@ from __future__ import annotations import argparse +import functools import importlib.util import itertools import random @@ -77,6 +80,7 @@ sys.path.insert(0, str(REPO / "scripts")) import repo_shape # noqa: E402 from repo_shape import load_yaml, parse_yaml # noqa: E402 +from shape_materialize import neutral_pin_path # noqa: E402 DOCTOR = REPO / "shape-doctor.py" @@ -369,9 +373,10 @@ def test_a_value_is_asked_about_in_each_place_it_is_written( #: How `check` begins #261's line about a value the project's own #: validate-manifest.py refuses. INVALID_FINDING = "FINDING plan-manifest-invalid: " -#: How #181's remediation begins, which no other remediation does. -MANIFEST_MISREAD_REMEDIATION_START = ( - "Remediation: choose another value for each one named. `execute` writes") +#: How the remediation after the project's own validator's findings begins. +VALIDATOR_REMEDIATION_START = ( + "Remediation: choose a value the project's own validate-manifest.py " + "accepts") #: `true` as text, as a plan edited by hand quotes it, and `007` and #: `null` too. TRUE_TEXT = '"true"' @@ -447,18 +452,26 @@ def test_a_value_is_asked_about_in_each_place_it_is_written( #: carries it: `'"CORP\tom"'`. QUOTED_DOMAIN_USER = f"'\"{DOMAIN_USER}\"'" -#: `(flag, value, plan key, what the project's loader reads)`: a pin's -#: product, which `execute` writes bare, as an item of the manifest's -#: `neutral_product_pins:` list. At 76a6153 `--pin null` went through -#: `execute` to `adoption verified` with the pin gone, and on main `--pin -#: 007@` still ends `adoption verified` over a root whose own -#: `validate-pins.py` refuses `entry 7` (lane 2's #514). The project's own -#: validator makes no finding about what the loader reads of any of them, 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. Once #514 quotes the list's -#: items, each reads back as typed, and these rows are `READ_BACK`'s kind. +#: `(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. 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), @@ -483,6 +496,9 @@ def test_a_value_is_asked_about_in_each_place_it_is_written( #: back as typed, so each is planned, checked and written as given, with no #: finding of #181's or the validator's. A branch is asked of a source that #: has it (#479), and one row takes it from the source's default branch. +#: The pins were `REFUSED`'s rows while the manifest's list wrote its items +#: bare; since #514 (main's e6fbd748) `execute` writes each as `- "null"`, +#: `- "True"` and `- "007"`, which read back as typed too. READ_BACK = [ *[(TRACKING_FLAG, value) for value in ("true", "null", "007")], *[(SPEC_FLAG, value) for value in ("null", "Null", "NULL", "~", "true", @@ -491,6 +507,7 @@ def test_a_value_is_asked_about_in_each_place_it_is_written( ("--id", "true"), ("--id", "null"), (PROJECT_FLAG, "True"), (PROJECT_FLAG, "Null"), (DEFAULT_BRANCH, "true"), + *[(PIN_FLAG, value) for value in ("null", "True", "007")], ] #: The placeholders #259 put between double quotes in the assembly root's #: templated YAML files, which `bare_template` writes bare again. @@ -511,10 +528,23 @@ def checked(plan: Path): REMOTES = "remotes" +def execute_argv(plan: Path, tmp_path: Path) -> list[str]: + """`execute`'s arguments for a run under local remotes.""" + return ["execute", PLAN_OPTION, str(plan), "--yes", + "--local-remote-dir", str(tmp_path / REMOTES), + WORK_DIR_FLAG, str(tmp_path / "work")] + + def executed(plan: Path, tmp_path: Path): - return run_script(ADOPT, "execute", PLAN_OPTION, str(plan), "--yes", - "--local-remote-dir", str(tmp_path / REMOTES), - WORK_DIR_FLAG, str(tmp_path / "work")) + return run_script(ADOPT, *execute_argv(plan, tmp_path)) + + +def run_here(adopter, capsys, *argv: str) -> tuple[int, str, str]: + """`adopt-project.py ` run in this process, where `bare_template` + and `bare_pins` reach it: `(exit, stdout, stderr)`.""" + code = adopter.main(list(argv)) + said = capsys.readouterr() + return code, said.out, said.err def branches_of(source: Path) -> str: @@ -679,18 +709,20 @@ def carrying(plan: Path, text: str, key: str, spelled: str) -> None: @pytest.mark.parametrize("flag, value, reading", PLANNED, ids=ids_of(PLANNED, 0, 1)) def test_plan_refuses_a_value_the_projects_loader_would_misread( - answered_copy, glass, tmp_path, flag, value, reading): + adopter, answered_copy, glass, bare_template, bare_pins, tmp_path, + capsys, flag, value, reading): """Before the plan is written: the flag, the value, what the project's - loader would read, and the fix.""" + loader would read, and the fix. Asked where the line is bare + (`bare_template`, `bare_pins`), in this process.""" source, _ = answered_copy plan = tmp_path / PLAN_FILE - result = write_plan(source, plan, - extra=flag_args(source, glass, flag, value)) - assert result.returncode == 2, result.stdout + result.stderr - assert result.stderr.startswith("REFUSED adopt-manifest-misread: ") + code, _, stderr = run_here(adopter, capsys, *plan_argv( + source, plan, tmp_path, *flag_args(source, glass, flag, value))) + assert code == 2, stderr + assert stderr.startswith("REFUSED adopt-manifest-misread: "), stderr who, what = misread(FLAG_NAMED.get(flag, flag), value, reading) - assert who in result.stderr and what in result.stderr, result.stderr - assert "No plan was written." in result.stderr + assert who in stderr and what in stderr, stderr + assert "No plan was written." in stderr assert not plan.exists() @@ -705,13 +737,15 @@ def bare_template(adopter, tmp_path, monkeypatch) -> Path: quoted written bare again, as it was before #259. The gate asks the line `execute` writes, and since #259 every line it - asks about but the pins' list is quoted, where no value is misread. What - it says of a line written bare -- how it names a value, and which values - it asks about at all -- is still the rule's own answer, and a test of - that is asked here, of the gate directly or of `plan` in this process. - No `check` run as a process of its own sees this copy, nor the leg-path - rule (`leg_path_problem`, #266) unless a test points it here too, nor - the validator, and `execute` never does. The copy is a templates + asks about but the pins' list is quoted, where no value is misread (the + pins' list since #514: `bare_pins`). What it says of a line written bare + -- how it names a value, which values it asks about at all, and what + `plan`, `check` and `execute` do with that answer -- is still the rule's + own answer, and a test of that is asked here, of the gate directly or of + `plan`, `check` or `execute` in this process (`run_here`). No run as a + process of its own sees this copy, nor the leg-path rule + (`leg_path_problem`, #266) unless a test points it here too, nor the + validator, which reads the shipped templates. The copy is a templates directory's `assembly-root`, as the shape's own is.""" copy = tmp_path / "bare-templates" / "assembly-root" shipped = "" @@ -730,18 +764,44 @@ def bare_template(adopter, tmp_path, monkeypatch) -> Path: return copy -def planned_as_given(tmp_path: Path, flag: str, value: str) -> tuple[Path, - Path]: +def pins_written_bare(pins) -> str: + """The manifest's `neutral_product_pins:` list as `_template_table` + wrote it before #514: each product bare, as it stands.""" + if not pins: + return "[]" + return "\n" + "\n".join(f" - {product}" for product in sorted(pins)) + + +@pytest.fixture +def bare_pins(adopter, monkeypatch) -> None: + """The manifest's pins' list written bare again, as it was before #514 + (main's e6fbd748), whose `_neutral_product_pins` quotes an item the + project's reader would misread and which `_template_table` takes the + list from. The gate asks that table (`_pin_field`), so on the shipped + code it finds no pin misread; what it says of a bare item -- how it + names a pin's product, at `plan` and in a plan, and which pins it asks + about at all -- is still the rule's own answer, asked here in this + process. The validator is asked of the same table, so a manifest its + reader refuses is the validator's here too.""" + assert adopter._neutral_product_pins(["007"]) == '\n - "007"', ( + "#514's quotes moved") + monkeypatch.setattr(adopter, "_neutral_product_pins", pins_written_bare) + + +def planned_as_given(tmp_path: Path, glass: tuple[Path, str], flag: str, + value: str) -> tuple[Path, Path]: """`(source, plan)` for a `READ_BACK` row: `plan` given the flag, or the project an id is derived from, or a source whose default branch is the - value, then answered. A branch is one the source has (#479).""" + value, then answered. A branch is one the source has (#479), and a pin's + digest is read from the stand-in clone (`flag_args`).""" default = value if flag == DEFAULT_BRANCH else "main" source = make_source_repo(tmp_path / SOURCE_NAME, branch=default) - holding(source, flag, value) + extra = (() if flag in (DEFAULT_BRANCH, PROJECT_FLAG) + else flag_args(source, glass, flag, value)) plan = tmp_path / PLAN_FILE written = write_plan( source, plan, project=value if flag == PROJECT_FLAG else PROJECT_NAME, - extra=() if flag in (DEFAULT_BRANCH, PROJECT_FLAG) else (flag, value)) + extra=extra) assert written.returncode == 0, written.stdout + written.stderr assert "REFUSED" not in written.stderr, written.stderr for path, leg in ANSWERS: @@ -758,9 +818,13 @@ def as_given(flag: str, value: str) -> str: def where_it_lands(flag: str) -> tuple[tuple, tuple]: """`(in the plan, in project.yaml)`: where the row's value lands in each, as the steps into what its loader reads. A leg is the second or - third of the manifest's `legs:`; the assembly is the first.""" + third of the manifest's `legs:`; the assembly is the first. A pin is + the plan's one pin, by its product, and the manifest's one item of + `neutral_product_pins:`.""" if flag in (TRACKING_FLAG, DEFAULT_BRANCH): return (TRACKING_KEY,), (TRACKING_KEY,) + if flag == PIN_FLAG: + return (PINS_KEY, 0, "product"), ("neutral_product_pins", 0) if flag in (SPEC_FLAG, CODE_FLAG): role = flag[2:6] return (("legs", f"{role}_path"), @@ -778,13 +842,15 @@ def read_at(loaded, steps: tuple): @pytest.mark.parametrize("flag, value", READ_BACK, ids=ids_of(READ_BACK, 0, 1)) def test_a_value_the_template_quotes_is_planned_and_checked_as_given( - tmp_path, flag, value): + glass, tmp_path, flag, value): """`plan` refused each of these, as #181's misread or in the project's own validator's words (`manifest-leg-path`, `manifest-id`), while the - template wrote its line bare. Since #259 the line is quoted and reads - back as typed: `plan` writes the plan, which reads the value back as - given, and `check` passes it with no finding at all.""" - _, plan = planned_as_given(tmp_path, flag, value) + template wrote its line bare, and a pin's product while the manifest's + list did. Since #259 the line is quoted, and since #514 the list's item + where the reader would misread it, and each reads back as typed: `plan` + writes the plan, which reads the value back as given, and `check` + passes it with no finding at all.""" + _, plan = planned_as_given(tmp_path, glass, flag, value) in_plan, _ = where_it_lands(flag) assert read_at(load_yaml(plan), in_plan) == as_given(flag, value) result = checked(plan) @@ -797,13 +863,16 @@ def test_a_value_the_template_quotes_is_planned_and_checked_as_given( @pytest.mark.parametrize("flag, value", READ_BACK, ids=ids_of(READ_BACK, 0, 1)) def test_execute_writes_a_value_the_template_quotes_as_given( - tmp_path, flag, value): + glass, tmp_path, flag, value): """`execute` refused each of these before any leg existed, as #181's misread or as the validator's. Since #259 it writes each between double - quotes, ends `adoption verified`, and the split's `project.yaml` reads - the value back as given; a leg path reads back from its pin's - `submodule_path:` too, and the legs are pushed on the branch.""" - source, plan = planned_as_given(tmp_path, flag, value) + quotes, and since #514 a pin's product as a quoted item of the + manifest's list (`- "007"`); it ends `adoption verified`, and the + split's `project.yaml` reads the value back as given; a leg path reads + back from its pin's `submodule_path:` too, a pin's product from its own + `contracts/-pin.yaml` (`product: "007"`, since #516), and the + legs are pushed on the branch.""" + source, plan = planned_as_given(tmp_path, glass, flag, value) result = executed(plan, tmp_path) assert result.returncode == 0, result.stdout + result.stderr assert "adoption verified" in result.stdout, result.stdout @@ -815,6 +884,10 @@ def shown(rel: str) -> dict: _, in_manifest = where_it_lands(flag) assert read_at(shown(MANIFEST), in_manifest) == as_given(flag, value) + if flag == PIN_FLAG: + manifest = git("show", f"{SPLIT_BRANCH}:{MANIFEST}", cwd=source) + assert f'\n - "{value}"\n' in manifest.stdout, manifest.stdout + assert shown(neutral_pin_path(value))["product"] == value if flag in (SPEC_FLAG, CODE_FLAG): pin = shown(f"contracts/{flag[2:6]}-pin.yaml") assert pin[SUBMODULE_PATH] == value @@ -876,25 +949,29 @@ def test_plan_takes_an_elector_from_git_the_manifests_quotes_read_back( @pytest.mark.parametrize("key, value, reading", CARRIED, ids=ids_of(CARRIED, 0, 1)) -def test_check_reports_a_plan_carrying_one(adopter, answered_copy, tmp_path, - key, value, reading): +def test_check_reports_a_plan_carrying_one( + adopter, answered_copy, bare_template, bare_pins, tmp_path, capsys, + key, value, reading): """A plan carrying the value -- spelled by the plan writer, so the plan itself reads it back -- is a FINDING, with the remediation once after. - It is #181's alone: the project's own validator, asked first, makes no - finding about what the loader reads of it, and `execute` writes it in - every mode.""" + It is #181's alone, one finding: the project's own validator, asked + first, makes no finding about what the loader reads of it, and `execute` + writes it in every mode. Asked where the line is bare (`bare_template`, + `bare_pins`), in this process.""" source, text = answered_copy holding(source, key, value) plan = tmp_path / PLAN_FILE carrying(plan, text, key, adopter.y(value)) - result = checked(plan) - assert result.returncode == 1, result.stdout + result.stderr + code, stdout, stderr = run_here(adopter, capsys, "check", PLAN_OPTION, + str(plan)) + assert code == 1, stdout + stderr who, what = misread(NAMED.get(key, key), value, reading) - assert f"{MISREAD_FINDING}{who}" in result.stderr - assert what in result.stderr, result.stderr - assert result.stderr.count(adopter.MANIFEST_MISREAD_REMEDIATION) == 1 - assert INVALID_FINDING not in result.stderr, result.stderr - assert PLAN_OK not in result.stdout + (line,) = judged_lines(stderr) + assert line.startswith(f"{MISREAD_FINDING}{who}"), stderr + assert what in stderr, stderr + assert stderr.count("Remediation: ") == 1, stderr + assert stderr.count(adopter.MANIFEST_MISREAD_REMEDIATION) == 1 + assert PLAN_OK not in stdout def judged_lines(stderr: str) -> list[str]: @@ -1179,7 +1256,10 @@ def test_a_misread_branch_the_source_lacks_is_479s_finding_alone( misread one saying `execute` writes it and the run ends `adoption verified`, and `plan` refused it under that remediation. `plan` records it with #479's note, as main does, and `check` says #479's finding - alone.""" + alone. Since #259 the shipped template quotes the branch, where `true` + reads back, so this passes there with the misread rule asking nothing; + `test_a_branch_execute_does_not_push_in_every_mode_is_never_misread` + asks the rule where the line is bare.""" source, _ = answered plan = tmp_path / PLAN_FILE written = write_plan(source, plan, extra=(TRACKING_FLAG, "true")) @@ -1215,7 +1295,11 @@ def test_a_misread_branch_origin_lacks_is_479s_finding_alone( the repository the split's pull request goes to, does not. `check` reads `origin`'s branches and finds it `plan-tracking-branch-missing`, which `execute` refuses before any leg exists in either mode, so it is - #479's finding alone, as at `plan`, though the source itself has it.""" + #479's finding alone, as at `plan`, though the source itself has it. + On the shipped templates, which quote the branch since #259, this + passes with the misread rule asking nothing; the rule is asked where + the line is bare in + `test_a_branch_execute_does_not_push_in_every_mode_is_never_misread`.""" source = with_origin(tmp_path, None) holding(source, TRACKING_KEY, "true") plan = tmp_path / PLAN_FILE @@ -1257,7 +1341,10 @@ def test_a_misread_branch_only_origin_has_is_named_by_neither( into the source, which does not, and `execute` refuses it before any leg exists. `check` is not told the mode, so a sentence saying `execute` writes it would be false in one of them: `check` passes the - plan, as main does (`_pushed_in_every_mode`).""" + plan, as main does. On the shipped templates, which quote the branch + since #259, it passes because `true` reads back there; where the line + is bare, `_pushed_in_every_mode` is what keeps the rule silent + (`test_a_branch_execute_does_not_push_in_every_mode_is_never_misread`).""" _, plan = origin_only(tmp_path) result = checked(plan) assert result.returncode == 0, result.stdout + result.stderr @@ -1282,6 +1369,115 @@ def test_execute_refuses_a_branch_only_origin_has_under_a_local_remote( assert SPLIT_BRANCH not in branches_of(source) +def branch_source(tmp_path: Path, own: bool, origin: bool | None) -> Path: + """A source for the branch `true`: one of its own where `own`; a + fetched remote `origin` that has it where `origin` is True, lacks it + where False, and no `origin` at all where None.""" + if origin is None: + source = make_source_repo(tmp_path / SOURCE_NAME) + else: + source = with_origin(tmp_path, "true" if origin else None) + if own: + holding(source, TRACKING_KEY, "true") + return source + + +#: `(the source has `true`, its fetched `origin` has it, or None for no +#: `origin`, how `check`'s one finding begins, or None where it passes the +#: plan)`: #479's three tracking branches `execute` does not push the legs +#: to in every mode, and refuses before any leg exists (69a743e, 94242a2). +BRANCH_CASES = [ + pytest.param(False, None, + f"{BRANCH_MISSING}`true`, which is no branch of the source ", + id="the-source-lacks-it"), + pytest.param(True, False, + f"{BRANCH_MISSING}`true`, which is no branch of the " + "source's remote `origin` ", id="origin-lacks-it"), + pytest.param(False, True, None, id="only-origin-has-it"), +] + + +@pytest.mark.parametrize("own, origin, finding", BRANCH_CASES) +def test_a_branch_execute_does_not_push_in_every_mode_is_never_misread( + adopter, bare_template, tmp_path, capsys, own, origin, finding): + """Where `tracking_branch:` is written bare, as before #259 + (`bare_template`), the loader misreads `true`, so the misread rule asks + git and then the source whether `execute` pushes the legs to it in + every mode (`_a_branch_execute_writes`, `_pushed_in_every_mode`). In + each of these it does not, and refuses it before any leg exists. So + `plan` records it, noting it where it is no branch of the source or of + its `origin`, and refuses nothing; `check` finds it #479's + `plan-tracking-branch-missing` alone, or passes the plan where only + `origin` has it, as main does; and neither says it is misread. In this + process.""" + source = branch_source(tmp_path, own, origin) + plan = tmp_path / PLAN_FILE + code, stdout, stderr = run_here(adopter, capsys, *plan_argv( + source, plan, tmp_path, TRACKING_FLAG, "true")) + assert code == 0, stdout + stderr + assert (BRANCH_NOTED in stdout) == (finding is not None), stdout + assert MISREAD_CODE not in stdout + stderr + for path, leg in ANSWERS: + resolve(plan, path, leg) + code, stdout, stderr = run_here(adopter, capsys, "check", PLAN_OPTION, + str(plan)) + assert MISREAD_FINDING not in stderr, stderr + assert adopter.MANIFEST_MISREAD_REMEDIATION not in stderr + if finding is None: + assert code == 0, stdout + stderr + assert PLAN_OK in stdout, stdout + else: + assert code == 1, stdout + stderr + assert finding in stderr, stderr + + +@pytest.mark.parametrize("own, origin, pushed", [ + pytest.param(False, None, False, id="the-source-lacks-it"), + pytest.param(True, False, False, id="origin-lacks-it"), + pytest.param(False, True, False, id="only-origin-has-it"), + pytest.param(True, True, True, id="both-have-it"), +]) +def test_the_source_says_whether_execute_pushes_the_legs_to_a_branch( + adopter, tmp_path, own, origin, pushed): + """`_pushed_in_every_mode`: `execute` pushes the legs to a tracking + branch in every mode only where #479's question finds it -- among the + branches of the source's `origin` as the clone remembers them, or the + source's own where it has no `origin` (gh mode) -- and the source + itself has it (`--local-remote-dir`).""" + opened = adopter.Source.open(str(branch_source(tmp_path, own, origin)), + tmp_path / "work") + assert adopter._pushed_in_every_mode(opened, "true") is pushed + + +def test_plan_asks_the_validator_before_the_misread_rule( + adopter, answered, bare_template, tmp_path, monkeypatch, capsys): + """rv181f's G1 at `plan`: the project's own validator is asked first, + and a value it refuses is refused in its words alone. On a bare `id:` + line, as before #259, `--id true` is read as a boolean: the validator + refuses it (`manifest-id`), and the misread rule would name it too. So + the validator is given the same bare copy (`bare_template`, with the + template's own `scripts/` beside it, asked through + `manifest_value_problems`), and `plan` refuses the id as + `adopt-manifest-invalid`, says nothing of a misread, and writes no plan. + On the shipped templates `true` reads back and is `READ_BACK`'s. In + this process.""" + shutil.copytree(REPO / "templates" / "assembly-root" / "scripts", + bare_template / "scripts") + materialize = sys.modules["shape_materialize"] + monkeypatch.setattr(materialize, "manifest_value_problems", + functools.partial(materialize.manifest_value_problems, + template_dir=bare_template)) + source, _ = answered + plan = tmp_path / PLAN_FILE + code, _, stderr = run_here(adopter, capsys, *plan_argv( + source, plan, tmp_path, "--id", "true")) + assert code == 2, stderr + assert stderr.startswith("REFUSED adopt-manifest-invalid: "), stderr + assert "manifest-id" in stderr, stderr + assert MISREAD_CODE not in stderr, stderr + assert not plan.exists() + + def test_plan_names_a_tracking_branch_it_took_from_the_source( adopter, bare_template, tmp_path, capsys): """Since #479 `plan` records the source's default branch when no @@ -1326,22 +1522,25 @@ def recorded(_source, branch): # --- a pin is `product@<40 hex>`, recorded whole (main's #273) -------------- def test_a_pin_beside_a_273_finding_is_not_named_misread( - answered, tmp_path): + adopter, answered, bare_pins, tmp_path, capsys): """`execute` refuses a plan whose `pins:` has a finding of #273's before any leg exists, so it writes none of its pins: a misread sentence about the whole pin beside it would say otherwise. One wrong - `pins:` is #273's findings alone.""" + `pins:` is #273's findings alone. Since #514 the pin `"null"` reads back + from the manifest's list, so it is asked where the list is written + bare (`bare_pins`), in this process.""" _, text = answered plan = tmp_path / PLAN_FILE carrying(plan, text, PINS_KEY, QUOTED_NULL) edited = text_of(plan).replace("\npins:\n", "\npins:\n - openX\n", 1) with plan.open("w", encoding=UTF8, newline="\n") as handle: handle.write(edited) - result = checked(plan) - assert result.returncode == 1, result.stdout + result.stderr + code, stdout, stderr = run_here(adopter, capsys, "check", PLAN_OPTION, + str(plan)) + assert code == 1, stdout + stderr assert "FINDING plan-pin-unrecorded: `pins:` entry 1, 'openX'" in ( - result.stderr), result.stderr - assert MISREAD_FINDING not in result.stderr, result.stderr + stderr), stderr + assert MISREAD_FINDING not in stderr, stderr def test_a_pin_whose_product_is_not_text_is_273s_finding_alone( @@ -1364,7 +1563,7 @@ def test_a_pin_whose_product_is_not_text_is_273s_finding_alone( #: `(plan key, as a plan edited by hand spells it)`: values no file #: `execute` writes can hold, which #255 names as `plan-unwritable-value`: #: a line break, in a value read back as one line, and a control character -#: such as a tab. The date and the pins are written bare, where the +#: such as a tab. Where the date and the pins were written bare, the #: project's loader would read each as something else too; the visibility #: is not one #181 judges (`NEVER_WRITTEN`). Beside a line break, the #: project's own validator lists the manifest such a value makes, as main @@ -1414,43 +1613,37 @@ def test_execute_refuses_a_date_no_file_can_hold_as_unwritable_not_misread( assert SPLIT_BRANCH not in branches_of(source) -#: `(plan key, as a plan edited by hand spells it, the one finding `check` -#: prints about it)`: a value of each kind #181's gate and #261's validator -#: (`plan-manifest-invalid`) divide between them. The validator is asked -#: first, and a value it refuses as the loader reads it is its alone, text -#: the loader misreads included (rv181f's G1); so is any value of a manifest -#: its reader cannot read at all, and that one finding names no value twice -#: (rv181f's G2). What the loader misreads and the validator accepts is -#: #181's: since #259 a pin's product alone. `id: "007"`, `tracking_branch: +#: `(plan key, as a plan edited by hand spells it)`: values #261's +#: validator (`plan-manifest-invalid`) refuses as the loader reads them. The +#: validator is asked first, and a value it refuses is its alone, text the +#: loader misreads included (rv181f's G1). `id: "007"`, `tracking_branch: #: "007"` and `legs.spec_path: "null"` were a row each here, the first the #: validator's and the others #181's; since #259 each reads back and is no -#: finding (main's `TEXT_THE_TEMPLATE_NOW_HOLDS`, and `READ_BACK`). +#: finding (main's `TEXT_THE_TEMPLATE_NOW_HOLDS`, and `READ_BACK`). So were +#: a pin `"null"`, #181's, and a pin `? x`, whose bare item made a manifest +#: the validator's reader refuses (rv181f's G2); since #514 each item reads +#: back. What is left of #181's is asked where a line is bare +#: (`test_check_reports_a_plan_carrying_one`), and so is G2 +#: (`test_a_manifest_its_reader_refuses_is_the_validators_alone`). JUDGED_ONCE = [ - pytest.param(PINS_KEY, '"? x"', INVALID_FINDING, - id="pin-the-reader-refuses"), - pytest.param(PINS_KEY, QUOTED_NULL, MISREAD_FINDING, id="pin-text-misread"), - pytest.param(ELECTED_BY_KEY, '" "', INVALID_FINDING, id="elector-blank"), - pytest.param(VISIBILITY_KEY, "secret", INVALID_FINDING, - id="visibility-secret"), - pytest.param(VISIBILITY_KEY, TRUE_TEXT, INVALID_FINDING, - id="visibility-text-true"), - pytest.param(VISIBILITY_KEY, "true", INVALID_FINDING, - id="visibility-boolean"), - pytest.param(VISIBILITY_KEY, "[secret]", INVALID_FINDING, - id="visibility-list"), + pytest.param(ELECTED_BY_KEY, '" "', id="elector-blank"), + pytest.param(VISIBILITY_KEY, "secret", id="visibility-secret"), + pytest.param(VISIBILITY_KEY, TRUE_TEXT, id="visibility-text-true"), + pytest.param(VISIBILITY_KEY, "true", id="visibility-boolean"), + pytest.param(VISIBILITY_KEY, "[secret]", id="visibility-list"), ] -@pytest.mark.parametrize("key, spelled, finding", JUDGED_ONCE) +@pytest.mark.parametrize(KEY_SPELLED, JUDGED_ONCE) def test_check_judges_each_value_once_by_the_rule_true_of_it( - answered_copy, tmp_path, key, spelled, finding): + answered_copy, tmp_path, key, spelled): """At the merge of main dd8d0ea, `id: "007"` and `elected_on: "20260902"` were two findings each, #181's and the validator's, and a visibility that is not text was none. At 7ac2358 each was one, but #181's, whose remediation said `execute` writes the value and the run ends `adoption verified` where `execute` refuses it as the validator's (rv181f's G1), and `elected_on: "!"` was still two (G2). Each is one, - in the words of the rule true of it.""" + in the validator's words.""" source, text = answered_copy holding(source, key, parse_yaml(f"{key}: {spelled}\n")[key]) plan = tmp_path / PLAN_FILE @@ -1458,13 +1651,40 @@ def test_check_judges_each_value_once_by_the_rule_true_of_it( result = checked(plan) assert result.returncode == 1, result.stdout + result.stderr (line,) = judged_lines(result.stderr) - assert line.startswith(finding), result.stderr - remediation = (MANIFEST_MISREAD_REMEDIATION_START - if finding == MISREAD_FINDING else - "Remediation: choose a value the project's own " - "validate-manifest.py accepts") + assert line.startswith(INVALID_FINDING), result.stderr assert result.stderr.count("Remediation: ") == 1, result.stderr - assert remediation in result.stderr, result.stderr + assert VALIDATOR_REMEDIATION_START in result.stderr, result.stderr + + +def test_a_manifest_its_reader_refuses_is_the_validators_alone( + adopter, answered, bare_pins, tmp_path, capsys): + """rv181f's G2. Where the project's own reader refuses the whole + manifest a plan makes, no value of it can be said to pass the validator + (`manifest_judged` is None), so #181 leaves every value to it, and + `check` prints one finding of the two rules', the validator's, with its + remediation once. A pin `? x` made such a manifest while the pins' list + was written bare (`- ? x`, an explicit key). Since #514 `execute` quotes + it, and no plan value was found to make such a manifest of the shipped + templates (probed: each key the manifest is rendered from, with every + candidate here), so it is asked with the list written bare again + (`bare_pins`), in this process. The pin is #273's `plan-pin-malformed` + as well, as it was.""" + _, text = answered + plan = tmp_path / PLAN_FILE + carrying(plan, text, PINS_KEY, '"? x"') + naming = adopter.NamingPolicy.load(adopter.NAMING_POLICY) + values = adopter._manifest_values(adopter.Plan.load(plan), naming) + assert adopter.manifest_judged(values, naming, + ignore=adopter.LEG_PATH_CODES) is None + code, _, stderr = run_here(adopter, capsys, "check", PLAN_OPTION, + str(plan)) + assert code == 1, stderr + (line,) = judged_lines(stderr) + assert line.startswith(f"{INVALID_FINDING}the manifest this run would " + "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 #: A leg path the validator refuses as text, which its finding about the @@ -1474,14 +1694,15 @@ def test_check_judges_each_value_once_by_the_rule_true_of_it( #: codes `check` leaves to `_leg_findings` are left out, what #: `manifest_judged` answers)`. The first and the last two were `id: "007"` #: and `legs.spec_path: "null"` while the template wrote those lines bare; -#: since #259 each reads back, and the validator accepts it. +#: since #259 each reads back, and the validator accepts it. A pin `? x`, +#: whose manifest its reader refused while the pins' list was written bare, +#: was a row here; since #514 it is asked with the list bare again +#: (`test_a_manifest_its_reader_refuses_is_the_validators_alone`). JUDGED_BY_THE_VALIDATOR = [ pytest.param(VISIBILITY_KEY, "secret", False, frozenset({"VISIBILITY"}), id="its-finding-names-the-value"), pytest.param(TRACKING_KEY, QUOTED_007, False, frozenset(), id="a-value-it-does-not-judge"), - pytest.param(PINS_KEY, '"? x"', False, None, - id="its-reader-refuses-the-file"), pytest.param(SPEC_PATH_KEY, OUTSIDE_THE_ROOT, False, None, id="a-finding-about-no-one-value"), pytest.param(SPEC_PATH_KEY, OUTSIDE_THE_ROOT, True, frozenset(), @@ -1670,19 +1891,21 @@ def test_text_the_loader_reads_as_other_text_is_shown_as_typed_and_read( @pytest.mark.parametrize("trailed", PIN_WITH_A_TRAIL) def test_a_pin_ending_in_whitespace_is_273s_finding_alone( - adopter, answered, tmp_path, trailed): + adopter, answered, bare_pins, tmp_path, capsys, trailed): """`check` finds the pin `plan-pin-malformed`, and `execute` refuses it before any leg exists, so it is never written: one finding, #273's, and - no misread sentence saying `execute` writes it.""" + no misread sentence saying `execute` writes it. Since #514 the manifest's + list quotes such an item, so it is asked where the list is written bare + (`bare_pins`), in this process.""" _, text = answered plan = tmp_path / PLAN_FILE carrying(plan, text, PINS_KEY, trailed) - result = checked(plan) - assert result.returncode == 1, result.stdout + result.stderr - assert "FINDING plan-pin-malformed: `pins:` entry 1 " in result.stderr, ( - result.stderr) - assert MISREAD_FINDING not in result.stderr, result.stderr - assert adopter.MANIFEST_MISREAD_REMEDIATION not in result.stderr + code, stdout, stderr = run_here(adopter, capsys, "check", PLAN_OPTION, + str(plan)) + assert code == 1, stdout + stderr + assert "FINDING plan-pin-malformed: `pins:` entry 1 " in stderr, stderr + assert MISREAD_FINDING not in stderr, stderr + assert adopter.MANIFEST_MISREAD_REMEDIATION not in stderr #: Values the manifest's own double quotes misread while `render` pasted @@ -1742,22 +1965,25 @@ def test_a_value_holding_a_placeholder_is_asked_as_render_puts_it_in_once( @pytest.mark.parametrize("key, value, reading", CARRIED, ids=ids_of(CARRIED, 0, 1)) def test_execute_refuses_a_plan_carrying_one_before_any_leg_exists( - adopter, answered_copy, tmp_path, key, value, reading): + adopter, answered_copy, bare_template, bare_pins, tmp_path, capsys, + key, value, reading): """The refused half of the end-to-end run: `execute` says no through #168's one list of `check`'s findings, before it creates a leg or - touches the source.""" + touches the source. Asked where the line is bare (`bare_template`, + `bare_pins`), in this process.""" source, text = answered_copy holding(source, key, value) plan = tmp_path / PLAN_FILE carrying(plan, text, key, adopter.y(value)) - result = executed(plan, tmp_path) - assert result.returncode == 2, result.stdout + result.stderr - assert result.stderr.startswith("REFUSED plan-manifest-misread: ") + code, stdout, stderr = run_here(adopter, capsys, + *execute_argv(plan, tmp_path)) + assert code == 2, stdout + stderr + assert stderr.startswith("REFUSED plan-manifest-misread: "), stderr who, what = misread(NAMED.get(key, key), value, reading) - assert f"{MISREAD_FINDING}{who}" in result.stderr - assert what in result.stderr - assert adopter.MANIFEST_MISREAD_REMEDIATION in result.stderr - assert INVALID_FINDING not in result.stderr, result.stderr + assert f"{MISREAD_FINDING}{who}" in stderr + assert what in stderr + assert adopter.MANIFEST_MISREAD_REMEDIATION in stderr + assert INVALID_FINDING not in stderr, stderr assert not (tmp_path / REMOTES).exists() assert SPLIT_BRANCH not in branches_of(source) @@ -1848,25 +2074,29 @@ def one_pin_table(adopter, plan, product: str) -> dict | None: def test_the_gate_follows_the_loader_when_the_loader_learns_a_keyword( - adopter, answered, tmp_path, monkeypatch): - """ONE QUESTION, `loader_reading`, for the plan writer and the gate. Teach - the real reader that `yes` is a boolean and both change with no edit: - `y` quotes it, and a pin called `yes`, which `execute` writes bare in - the manifest's list, is refused. A branch or an id called `yes` is - not: since #259 `execute` writes each between double quotes, which the - reader reads back as the text whatever it has learnt.""" + adopter, answered, bare_template, tmp_path, monkeypatch): + """ONE QUESTION, `loader_reading`, for the plan writer, the pins' list + and the gate. Teach the real reader that `yes` is a boolean and all + three change with no edit: `y` quotes it; `execute` writes a pin called + `yes` as `- "yes"`, because #514 asks the same reader, and the gate + passes that line; and a branch called `yes`, on a line written bare as + before #259 (`bare_template`), is refused. On the shipped templates a + branch is written between double quotes, which the reader reads back as + the text whatever it has learnt (`READ_BACK`).""" reads = repo_shape._scalar - fields = [adopter._field(TRACKING_KEY, TRACKING_PLACEHOLDER, "yes"), - adopter._field("id", "PROJECT_ID", "yes"), - pin_field(adopter, loaded_plan(adopter, answered, tmp_path), - "yes")] - assert adopter.manifest_misreads(fields) == [] + plan = loaded_plan(adopter, answered, tmp_path) + branch = adopter._field(TRACKING_KEY, TRACKING_PLACEHOLDER, "yes") + assert branch[2] == [(MANIFEST, "tracking_branch: yes")] + assert adopter.manifest_misreads( + [branch, pin_field(adopter, plan, "yes")]) == [] monkeypatch.setattr(repo_shape, SCALAR_READER, lambda raw: True if raw.strip() == "yes" else reads(raw)) assert adopter.y("yes") == QUOTED_YES + pin = pin_field(adopter, plan, "yes") + assert pin[2] == [(f"{MANIFEST}'s neutral_product_pins", '- "yes"')] assert [problem.split(" is ")[0] for problem - in adopter.manifest_misreads(fields)] == [PINS_KEY] + in adopter.manifest_misreads([branch, pin])] == [TRACKING_KEY] def test_the_template_line_says_whether_a_value_is_quoted( @@ -1874,8 +2104,8 @@ def test_the_template_line_says_whether_a_value_is_quoted( """No list in `adopt-project.py` says which placeholders the templates quote: the line is read. `Null` is a person's name between the quotes of `elected_by:`, and a branch between those of `tracking_branch:` - since #259; as an item of the pins' list, which `execute` writes bare, - it is no product at all.""" + since #259; as an item of the pins' list, which `execute` writes + `- "Null"` since #514, it is a product too.""" assert adopter._written(ELECTED_BY_PLACEHOLDER, "Null") == [ (MANIFEST, 'elected_by: "Null"')] assert adopter._written(TRACKING_PLACEHOLDER, "Null") == [ @@ -1885,33 +2115,27 @@ def test_the_template_line_says_whether_a_value_is_quoted( adopter._field(TRACKING_KEY, TRACKING_PLACEHOLDER, "Null")]) == [] field = pin_field(adopter, loaded_plan(adopter, answered, tmp_path), "Null") - assert field[2] == [(f"{MANIFEST}'s neutral_product_pins", "- Null")] - assert adopter.manifest_misreads([field]) != [] + assert field[2] == [(f"{MANIFEST}'s neutral_product_pins", '- "Null"')] + assert adopter.manifest_misreads([field]) == [] def test_the_gate_asks_the_table_execute_writes_the_pins_from( adopter, answered, tmp_path, monkeypatch): """`_pin_field` asks `_template_table`, the table `execute`'s `_template_values` writes the manifest from, and never a copy of how it - spells an item. So the day that table quotes each product, as lane 2's - #514 means to, the gate asks the quoted line and passes `null`, with no - edit of its own. Here the table is made to quote each item so.""" + spells an item. Since #514 (main's e6fbd748) that table quotes a product + the project's reader would misread, and the gate asks the quoted line + and passes `null`, with no edit of its own. Made to write the item bare + again, as before #514 (`pins_written_bare`), the table's bare line is + the one the gate asks, and refuses.""" plan = loaded_plan(adopter, answered, tmp_path) - assert adopter.manifest_misreads([pin_field(adopter, plan, "null")]) - bare = adopter._template_table - - def quoting(*args, **kwargs): - table = bare(*args, **kwargs) - first, *items = table[PINS_PLACEHOLDER].split("\n") - table[PINS_PLACEHOLDER] = "\n".join( - [first] + [f" - {repo_shape.double_quoted(item[4:])}" - for item in items]) - return table - - monkeypatch.setattr(adopter, "_template_table", quoting) field = pin_field(adopter, plan, "null") assert field[2] == [(f"{MANIFEST}'s neutral_product_pins", '- "null"')] assert adopter.manifest_misreads([field]) == [] + monkeypatch.setattr(adopter, "_neutral_product_pins", pins_written_bare) + field = pin_field(adopter, plan, "null") + assert field[2] == [(f"{MANIFEST}'s neutral_product_pins", "- null")] + assert adopter.manifest_misreads([field]) #: Where each placeholder lands in the RENDERED files, as their loader reads @@ -2039,9 +2263,10 @@ def test_the_plan_writer_quotes_a_pin_whatever_refuses_it_later( adopter, answered, glass, tmp_path, monkeypatch): """`pins: [` was the one list of strings the plan wrote without `y`, so `--pin null` was `pins: [null]`: a null `Plan.load` drops. Since #273 - `plan` writes each pin as a mapping through `y` (`_pin_lines`), and - refuses such a product before writing, so the writer is asked with the - gate taken away: the product is a string like any other in the plan.""" + `plan` writes each pin as a mapping through `y` (`_pin_lines`). The + writer is asked with the gate taken away, so the answer is the writer's + alone, whatever refuses the product later: it is a string like any + other in the plan.""" source, _ = answered path, commit = glass monkeypatch.setattr(adopter, "manifest_misreads", lambda _fields: []) From 6c9e585ed6ee778cb4ab99d316f479a0d77df9f5 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Wed, 14 Oct 2026 03:22:46 +0000 Subject: [PATCH 24/24] tests/test_adopt_plan_yaml_quoting.py: with #513 merged, `with_origin` puts the source's `origin` under `testorg/Northwind`, so the five tests that plan from it are no longer refused as `adopt-not-the-source`, and adopt-project.py's comments no longer say a pin's product is written bare (#181) M1 (rv181g, K5). #513 reads a path `origin`'s last two directories as the source's repository and refuses a plan for another. `with_origin` made `origin` a directory named `seed`, so `plan --org testorg --project Northwind` was refused as `adopt-not-the-source` before #181 asked anything, and five tests failed at the merge: test_a_misread_branch_origin_lacks_is_479s_finding_alone, test_a_misread_branch_only_origin_has_is_named_by_neither, test_execute_refuses_a_branch_only_origin_has_under_a_local_remote and test_a_branch_execute_does_not_push_in_every_mode_is_never_misread [origin-lacks-it] and [only-origin-has-it]. `origin` is now `/testorg/Northwind`, as main's cf118ff did for tests/test_adopt_tracking_branch.py. The text is rv181g's scratch commit 163fef9's, blob for blob. The stale comments (rv181g's N-1 to N-6 and N-8; N-7 is in the merge). Each said a pin's product is still written bare, or that a pin `? x` makes a manifest its reader refuses. Since #514 neither is true of the shipped code. Each old text occurred exactly once. None is printed: only the module docstring's first line feeds `--help`, and `render-cli-reference.py --check` is ok. With docstrings blanked, the module's AST is unchanged. - N-1, the module docstring: "where a line is written bare (since #259 and #514 no line `execute` writes them into is)". - N-2, N-3 and N-4, the section comment above the gate. - N-5, `_pin_field`'s docstring. - N-6, `_misread_findings`' docstring. - N-8, the #261 section comment. Sonar's new smells on 9efe95a. - S9073: seven composite asserts in the quoting file are split into plain asserts with the same meaning. They are in `assert_reads_back`, test_a_float_a_bool_an_int_and_none_are_still_typed_values (two), test_the_writer_follows_the_loader_when_the_loader_learns_a_keyword, test_plan_refuses_a_value_the_projects_loader_would_misread, test_the_misread_rule_names_an_id_it_derived_from_the_assembly and test_a_source_file_named_like_a_keyword_is_one_entry_with_that_path. `assert_reads_back` builds its message once, and both asserts give it. - S5778: the `pytest.raises` block in test_the_misread_rule_names_an_id_plan_derived_by_where_it_came_from held two calls. The `argparse.Namespace` source is now built before the block. - S1192: the third `"project.yaml"` was this branch's own line, `MANIFEST_YAML = "project.yaml"`. Nothing has read it since 180902f took the pin's line from `_template_table`, so it is removed and the module is back to main's two. Pointing it at `SHAPE_MANIFEST` instead would fail at import, because that constant is bound more than 1,600 lines later. Windows. A pytest plugin outside the repository made text-mode writes in the test process write `\r\n`, as on Windows: `io.open` and `open` with no `newline`, so `Path.write_text` too, and text-mode `input=` to a process. At PR #504's 9e8c672 it fails exactly the 17 tests of tests/test_adopt_root_symlinks.py the Windows job failed, with its message, and nothing else in that file. Over this branch's four changed test files, in both modes, it fails nothing. No `os.name` branch, `chmod`, text-mode stdin or path split is among their added lines. So nothing here changes for Windows. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- adopt-project.py | 34 ++++++++++++------------ tests/test_adopt_plan_yaml_quoting.py | 37 +++++++++++++++++---------- 2 files changed, 40 insertions(+), 31 deletions(-) diff --git a/adopt-project.py b/adopt-project.py index aa3b74cc..790284c5 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -56,8 +56,8 @@ any case), every value `execute` writes into the project's YAML (`id`, the branch, the leg paths, the pins, the election record) one its loader reads back as that same text, which `null`, `true` - and `2026` are not where a template writes them bare, as - `project.yaml` still writes each pin's product (#181), and + and `2026` are not where a line is written bare (since #259 and + #514 no line `execute` writes them into is) (#181), and the source's own submodules kept on one leg with their `.gitmodules`, registered there and never in the root, each relative url naming the same repository from the leg as from the @@ -2115,15 +2115,16 @@ def _kept_collision_notes(args, entries: list, tree: list) -> list[str]: # # THE TEMPLATES QUOTE THOSE LINES NOW (#259), and `render` spells a value # between a YAML template's double quotes with `double_quoted`, which reads -# back as typed: on them the question below finds nothing. What `execute` -# still writes bare is each pin's product, an item of the manifest's -# `neutral_product_pins:` list (lane 2's #514 quotes it). +# back as typed: on them the question below finds nothing. Since #514 +# (main's e6fbd748) `_neutral_product_pins` quotes an item of the manifest's +# `neutral_product_pins:` list the reader would misread, so a pin's product +# reads back too, and on the shipped templates nothing is left to refuse. # # SO THE GATE STAYS, AND REFUSES. `plan` (before the plan is written), # `check` (a FINDING) and `execute` (through `_entry_findings`, before any # leg exists) ask the ONE question below and refuse a value the project's # loader would read back as anything but that same text, on whatever line -# `execute` writes it into: today a pin's product, and any line a template +# `execute` writes it into: any line a template, or `_template_table`, # writes bare tomorrow, with no edit here. # # THE LOADER DECIDES, AND THE TEMPLATE SAYS WHERE. Each value is put into @@ -2133,16 +2134,14 @@ def _kept_collision_notes(args, entries: list, tree: list) -> list[str]: # placeholders are quoted: `elected_by: "{{ELECTED_BY}}"` is asked as the # line `render` writes, the value spelled by `double_quoted` (#284), so # `Null` and `CORP\tom` both read back there, as they do after -# `tracking_branch:` since #259, and a bare `- Null` in the pins' list does -# not. +# `tracking_branch:` since #259, and as `- "Null"` in the pins' list since +# #514; a bare `- Null` there would not. #: The assembly root's templated files that are YAML: what the project's own #: loader reads back out of the split commit. The materializer's list, so a #: templated YAML file added there is asked about here without an edit. ASSEMBLY_TEMPLATE = SHAPE_ROOT / "templates" / "assembly-root" TEMPLATED_YAML = tuple(rel for rel in TEMPLATED if rel.endswith(".yaml")) -#: The manifest, which holds the one list `_template_values` writes itself. -MANIFEST_YAML = "project.yaml" #: The plan's keys for the election record, as `_template_values` reads them #: out of the plan and as the tables below ask about them: one spelling. ELECTED_BY_KEY = "elected_by" @@ -2253,9 +2252,10 @@ def _pin_field(what: str, plan: "Plan", product: str, and never of a copy of how it spells an item: the table `_manifest_values` builds from `plan` with this one pin (`execute`'s `_template_values` is the same table), put into the template's line by - `render` (`_written`). The list is written bare, and quoting its items - is lane 2's #514: the day `_template_table` quotes one, this asks the - quoted line, and the gate passes it with no edit here. No line where the + `render` (`_written`). Since #514 (main's e6fbd748) `_template_table` + quotes an item the reader would misread, so this asks the quoted line + and the gate passes it, with no edit here; written bare again, the line + is refused. No line where the table cannot be built (`_manifest_values` is None), which `plan` and `check` report in their own words. Since #273 a product is `parse_pin`'s, or a `pins:` entry `_read_pins` accepts, which both hold to @@ -2574,7 +2574,8 @@ def _misread_findings(plan: Plan, unwritten=()) -> list[str]: the same `ignore`), and a value it makes a finding about is its alone, as on main, and not also this (while the template wrote `id:` bare, `id: "007"` was `manifest-id: id is 7` alone). Where it refuses the - whole file (a pin `? x`, which its reader cannot read) or makes a + whole file (as a pin `? x` did while the pins' list was written bare, + before #514) or makes a finding about no one value, no value can be said to pass it, so every value is its, as on main (rv181f's G1, G2); and where it is asked about no manifest (`_manifest_values` is None, as for an @@ -2988,9 +2989,8 @@ def _refuse_an_unrecordable_pin_source(source: Path, products) -> None: # the one `execute` renders from. Only the validator's question is asked here, # and it is asked FIRST: a value it refuses is its alone. Whether the # project's YAML reader reads back as the text it is a value the validator -# accepts -- since #259 quoted the other lines, a pin's product alone, -# `--pin null@` -- is #181's, asked after (`_misread_findings`), and -# the scaffold asks both. +# accepts -- since #259 and #514, only one on a line written bare -- is +# #181's, asked after (`_misread_findings`), and the scaffold asks both. #: How `check` and `execute` name each value the manifest records: its key #: in the plan. The id's placeholder and `MANIFEST_FIELDS` are #181's, so diff --git a/tests/test_adopt_plan_yaml_quoting.py b/tests/test_adopt_plan_yaml_quoting.py index 2ef3274b..0511c0c0 100644 --- a/tests/test_adopt_plan_yaml_quoting.py +++ b/tests/test_adopt_plan_yaml_quoting.py @@ -196,9 +196,10 @@ def readings(adopter, value: str) -> list: def assert_reads_back(adopter, value: str) -> None: for shape, got in zip(SHAPES_OF_A_STRING, readings(adopter, value)): - assert isinstance(got, str) and got == value, ( - f"{value!r} was written as {adopter.y(value)} and read back " - f"as {got!r} ({type(got).__name__}) as {shape}") + said = (f"{value!r} was written as {adopter.y(value)} and read back " + f"as {got!r} ({type(got).__name__}) as {shape}") + assert isinstance(got, str), said + assert got == value, said # --- the round trip, through the real loader ------------------------------- @@ -271,8 +272,10 @@ def test_a_float_a_bool_an_int_and_none_are_still_typed_values(adopter): numbers and booleans on purpose, and `None` is how `leg: null` says an entry is unresolved.""" assert adopter.y(None) == "null" - assert adopter.y(True) == "true" and adopter.y(False) == FALSE_WORD - assert adopter.y(0) == "0" and adopter.y(1234) == "1234" + assert adopter.y(True) == "true" + assert adopter.y(False) == FALSE_WORD + assert adopter.y(0) == "0" + assert adopter.y(1234) == "1234" assert adopter.y(0.5) == "0.5000" @@ -316,7 +319,8 @@ def test_the_writer_follows_the_loader_when_the_loader_learns_a_keyword( def learned(raw): return True if raw.strip() in ("yes", "on") else reads(raw) - assert adopter.y("yes") == "yes" and adopter.y("on") == "on" + assert adopter.y("yes") == "yes" + assert adopter.y("on") == "on" monkeypatch.setattr(repo_shape, SCALAR_READER, learned) assert adopter.y("yes") == QUOTED_YES assert adopter.y("on") == '"on"' @@ -721,7 +725,8 @@ def test_plan_refuses_a_value_the_projects_loader_would_misread( assert code == 2, stderr assert stderr.startswith("REFUSED adopt-manifest-misread: "), stderr who, what = misread(FLAG_NAMED.get(flag, flag), value, reading) - assert who in stderr and what in stderr, stderr + assert who in stderr, stderr + assert what in stderr, stderr assert "No plan was written." in stderr assert not plan.exists() @@ -914,9 +919,9 @@ def test_the_misread_rule_names_an_id_plan_derived_by_where_it_came_from( tracking_branch="main", spec_path="spec", code_path="code", pin=[], pin_owner=PIN_OWNER, pin_source=None, out=str(tmp_path / PLAN_FILE)) naming = adopter.NamingPolicy.load(adopter.NAMING_POLICY) + source = argparse.Namespace(repository=None) with pytest.raises(adopter.Refusal) as refused: - adopter._checked_plan_inputs( - args, argparse.Namespace(repository=None), naming) + adopter._checked_plan_inputs(args, source, naming) assert refused.value.code == "adopt-manifest-misread", refused.value who, what = misread("--id (not given, so --project lowercased)", "true", TRUE_READ) @@ -1036,8 +1041,8 @@ def test_the_misread_rule_names_an_id_it_derived_from_the_assembly( said = adopter.manifest_misreads([adopter._id_field( adopter.Plan.load(plan))]) assert len(said) == 1, said - assert said[0].startswith(who) and said[0].endswith(what.rstrip(".")), ( - said) + assert said[0].startswith(who), said + assert said[0].endswith(what.rstrip(".")), said #: `(plan key, as a hand-edited plan spells it, what the plan's loader reads @@ -1279,8 +1284,11 @@ def test_a_misread_branch_the_source_lacks_is_479s_finding_alone( def with_origin(tmp_path: Path, branch: str | None) -> Path: """A source whose remote `origin` has been fetched, `origin` holding `branch` beside `main` where one is given, and the source no branch of - its own but `main`.""" - seed = make_source_repo(tmp_path / "seed") + its own but `main`. `origin` is under `testorg/Northwind`, the + repository `plan` is given, because `plan` reads the source's + repository from its `origin`'s last two directories and refuses a plan + for another (#499).""" + seed = make_source_repo(tmp_path / TEST_ORG / PROJECT_NAME) if branch: holding(seed, TRACKING_KEY, branch) source = make_source_repo(tmp_path / SOURCE_NAME) @@ -2314,7 +2322,8 @@ def test_a_source_file_named_like_a_keyword_is_one_entry_with_that_path( "src/app/main.py": "VALUE = 1\n", "specs/001/spec.md": "# s\n"} plan = planned(tmp_path, tree=tree) paths = [e["path"] for e in load_yaml(plan)[PATHS]] - assert "null" in paths and "True" in paths, paths + assert "null" in paths, paths + assert "True" in paths, paths assert all(isinstance(path, str) for path in paths), paths for path in ("null", "True"): answer(plan, path, "root")