diff --git a/adopt-project.py b/adopt-project.py index ebf7e3f0..e77ce0bf 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -2820,11 +2820,16 @@ def __init__(self, path: Path, data: dict): "Remediation: re-run `plan`, which writes `mode: in-place`. " "A `new-root` mode is described in the tool's own docstring " "and is not implemented.") - self.entries = [e for e in (data.get("paths") or []) + # Each read takes only what `plan` writes, and `_plan_key_findings` + # names what it leaves out (#386). `legs:` is kept as it stands: the + # doctor's PLACEMENT plan, handed here by `tests/test_shape_doctor.py`, + # carries it as a list, and its readers ask its type themselves. + self.entries = [e for e in _plan_list(data, "paths") if isinstance(e, dict)] self.legs = data.get("legs") or {} # The products only: what the naming policy classifies by. Whether - # each entry is a pin `execute` can write is `_read_pins`'s (#273). + # each entry is a pin `execute` can write is `_read_pins`'s (#273); + # `_pin_products` reads a list only, as `_plan_list` does. self.pins = _pin_products(data.get("pins")) @classmethod @@ -2834,13 +2839,20 @@ def load(cls, path: Path) -> "Plan": raise Refusal("plan-unreadable", f"{path}: not a mapping") return cls(path, data) + @property + def source(self) -> dict: + """The plan's `source:` when it is a mapping, as `plan` writes it, and + `{}` for anything else, which `_plan_key_findings` names (#386).""" + source = self.data.get("source") + return source if isinstance(source, dict) else {} + @property def source_commit(self) -> str: - return str((self.data.get("source") or {}).get("commit") or "") + return str(self.source.get("commit") or "") def open_source(self, override: str | None, work_root: Path) -> Source: - spec = override or (self.data.get("source") or {}).get("local_path") \ - or (self.data.get("source") or {}).get("repository") + spec = override or self.source.get("local_path") \ + or self.source.get("repository") if not spec: raise Refusal("plan-no-source", f"{self.path}: `source:` names neither a local path " @@ -2853,8 +2865,14 @@ def names(self) -> dict[str, str]: They reach `gh repo create` and a push URL, so they are checked here rather than trusted because they came out of a file this tool wrote: the file is edited between `plan` and `execute`, on purpose. + + Only a `legs:` MAPPING holds them, as `_plan_leg_path` reads it: + anything else is read as no `legs:` at all, and never with `.get` on + a number or a list. `check` and `execute` name such a `legs:` first, + in `_plan_key_findings`, before this is asked (#386). """ - return {role: checked_value(f"legs.{role}", self.legs.get(role)) + legs = self.legs if isinstance(self.legs, dict) else {} + return {role: checked_value(f"legs.{role}", legs.get(role)) for role in ("assembly", "spec", "code")} @property @@ -2888,9 +2906,13 @@ def allowed_empty_legs(self) -> set[str]: """The legs this PLAN consents to having seeded. `execute` unions it with its own `--allow-empty-leg`, so consent can be given at either end — but it is never inferred from the entries being empty, which is - the state the consent exists to be deliberate about.""" - return {str(role) for role in (self.data.get("allow_empty_legs") or []) - if role} + the state the consent exists to be deliberate about. + + Each item of a LIST that is text, as `plan` writes it: text on the + key's own line is not read one character at a time, and what is left + out is named by `_plan_key_findings` (#386).""" + return {role for role in _plan_list(self.data, "allow_empty_legs") + if isinstance(role, str) and role} def seeding_record_disagreements(self, seeded: list[str]) -> list[str]: """Where the plan's `seeding:` record and its own entries disagree. @@ -4220,6 +4242,208 @@ def _plan_field_findings(plan: Plan) -> tuple[list[str], list[str]]: + ([FOLLOW_UPS_REMEDIATION] if followed else [])) +# --------------------------------------------------------------------------- +# The keys a plan is opened by are the types `plan` writes (#386) +# --------------------------------------------------------------------------- +# +# `Plan` read five keys of a plan file without asking their type. `plan` +# writes `source:` and `legs:` as mappings and `pins:`, `allow_empty_legs:` +# and `paths:` as lists, and the file is edited between `plan` and `execute` +# on purpose. `x or []` rescues only the falsy values: a number or a boolean +# ended `check` and `execute` in a `TypeError` or an `AttributeError`, exit 1, +# naming Python's error and not the key, and text, which is iterable, was read +# one character at a time. `pins: openGlass` passed `check`, adopted, and +# wrote eight one-letter neutral-product pins, `G`, `a`, `e`, `l`, `n`, `o`, +# `p` and `s`, which the project's own `validate-pins.py` then refused. +# +# So each is read through one reader that takes only what `plan` writes +# (`_plan_list`, `Plan.source`, `Plan.names`), and `_plan_key_findings` names +# what the reader left out in #293's words: a code per key, `plan-bad-` +# as `follow_ups:`' is `plan-bad-follow-ups`, `_read_as` for what the file +# holds, and each key's remediation said once. +# +# `pins:` IS #273'S. Since #273, `plan` writes it as a list of MAPPINGS, one +# per pin, recording `product:`, `repository:`, `commit:`, `tree_sha256:` +# and `digest_source:`. `_pin_products` reads the products out of a list +# only, and `_read_pins` judges each entry beside the entries: a name alone +# is `plan-pin-unrecorded`, anything else `plan` never writes there is +# `plan-pin-malformed`, each with the `plan --pin` that records the pin +# whole. So only a `pins:` that is no list at all is asked here, and its +# finding is `_read_pins`' own, so that it is said once and under one code. +# +# THEY ARE ASKED FIRST, right after `Plan.load`, and not in `_entry_findings` +# beside #293's and #294's. These keys are what the rest of the run is built +# from: the source is opened from `source:`, the names `execute` refuses on +# first come from `legs:`, and the constructor builds the entries and the +# pins. Every later finding is a statement about what they say, so a `paths:` +# that is not a list would be reported as every source path uncovered. +# `check` reports them, and stops, exit 1, with #293's and #294's findings +# beside them, so that every key of the plan that is not what `plan` writes +# is named in one run. `execute` refuses the same list, in +# `_refuse_what_check_finds`' shape, before the work directory, the source or +# a leg exists. + +#: `check`'s finding for each key below that is not what `plan` writes, and +#: the code `execute` refuses it under (#386). A `pins:` that is no list is +#: `_read_pins`' `plan-pin-malformed` (#273). +BAD_SOURCE = "plan-bad-source" +BAD_LEGS = "plan-bad-legs" +BAD_ALLOW_EMPTY_LEGS = "plan-bad-allow-empty-legs" +BAD_PATHS = "plan-bad-paths" + +#: Said once after the findings for each key. +SOURCE_REMEDIATION = ( + "Remediation: write `source:` in the plan file as `plan` writes it, a " + "mapping whose `local_path:` or `repository:` names the repository to " + "adopt and whose `commit:` is the one the plan was written against, or " + "re-run `plan`. `--source` says where to read that repository; it does " + "not stand in for the plan's record of it.") +LEGS_REMEDIATION = ( + "Remediation: write `legs:` in the plan file as `plan` writes it, a " + "mapping of the three repository names, `assembly:`, `spec:` and " + "`code:`, and the two mount paths, `spec_path:` and `code_path:`; or " + "re-run `plan`.") +PINS_REMEDIATION = ( + "Remediation: `pins:` is the list of pins `plan` wrote, one mapping per " + "pin of its `product:`, `repository:`, `commit:`, `tree_sha256:` and " + "`digest_source:`, or `pins: []` for none, which a value on the key's own " + "line replaced. Restore the block from the plan `plan` wrote, or re-run " + "`plan` with each `--pin @<40 hex commit>`: `execute` writes " + "each pin's file from what `plan` recorded, and a commit or a digest " + "typed by hand is one nobody read.") +ALLOW_EMPTY_LEGS_REMEDIATION = ( + "Remediation: write `allow_empty_legs:` in the plan file as `plan` " + "writes it, a list of the legs that may be seeded, " + "`allow_empty_legs: [code]`, or `allow_empty_legs: []` for none; or " + "re-run `plan` with `--allow-empty-leg code`, or pass that to `execute`. " + "Each item is `spec` or `code`, the two legs `execute` extracts: text on " + "the key's own line would be read one character per leg, and consents " + "to nothing.") +PATHS_REMEDIATION = ( + "Remediation: `paths:` is the list of entries `plan` wrote, a `- path:` " + "and its `leg:` for each path of the source, which a value on the key's " + "own line replaced. Restore the block from the plan `plan` wrote, or " + "re-run `plan` and answer its questions again.") + +#: Said first in `execute`'s refusal of these findings. +PLAN_KEY_REFUSAL = ( + "Remediation: no leg repository was created and nothing was pushed: the " + "plan was refused before the work directory was made or the source was " + "opened. Correct each key named above in the plan file and run `check` " + "until it prints `plan ok`, then run `execute` again.") + + +def _plan_list(data: dict, key: str) -> list: + """The plan's `key` when it is a LIST, as `plan` writes `paths:` and + `allow_empty_legs:`, and `[]` for anything else: THE ONE READ of the two, + so that neither iterates a number or reads text one character at a time. + What it leaves out is named by `_plan_key_findings` (#386). `pins:` is + read by `_pin_products` and `_read_pins`, which take a list only (#273).""" + value = data.get(key) + return value if isinstance(value, list) else [] + + +def _not_a(code: str, key: str, value, kind: str, why: str) -> str: + """The finding for a `key:` that holds `value` where `plan` writes a + `kind`, in `_follow_ups_findings`' words.""" + return (f"FINDING {code}: `{key}:` is {_read_as(value)}, not a {kind}: " + f"{why}") + + +def _plan_key_findings(plan: Plan) -> tuple[list[str], list[str]]: + """A FINDING for each of the five keys `Plan` reads to open the source and + name the repositories that is present and is not what `plan` writes, in + the order `plan` writes them, and for each item of `allow_empty_legs:` + that is not a leg; and what to print once after them (#386). + + A key that is absent or bare is not a finding here, whichever it is: it + is read as it always was. For `source:` that is `plan-no-source`, unless + `--source` names the repository. + + `pins:` is asked only whether it is a list, the one thing its readers + need before anything else is read: the naming policy classifies the + root by its products, and a `pins:` that is no list has none. The + finding is `_read_pins`' own `plan-pin-malformed`, and each entry of a + list, a mapping as `plan` writes it or not, is `_read_pins`' to judge + beside the entries (#273). + """ + data = plan.data + said: list[tuple[list[str], str]] = [] + + source, legs = data.get("source"), data.get("legs") + said.append(([] if source is None or isinstance(source, dict) else [ + _not_a(BAD_SOURCE, "source", source, "mapping", + "`check` and `execute` open the repository to adopt by its " + "`local_path:` or `repository:`, and hold it to its " + "`commit:`")], SOURCE_REMEDIATION)) + said.append(([] if legs is None or isinstance(legs, dict) else [ + _not_a(BAD_LEGS, "legs", legs, "mapping", + "the three repository names and the two mount paths are read " + "out of it by their keys")], LEGS_REMEDIATION)) + + pins = data.get("pins") + no_list = pins is not None and not isinstance(pins, list) + said.append((_read_pins(plan)[1] if no_list else [], PINS_REMEDIATION)) + + allowed = data.get("allow_empty_legs") + if allowed is None: + found = [] + elif not isinstance(allowed, list): + found = [_not_a(BAD_ALLOW_EMPTY_LEGS, "allow_empty_legs", allowed, + "list", "each item of a list names one leg `execute` " + "may seed from the shape's template")] + else: + found = [f"FINDING {BAD_ALLOW_EMPTY_LEGS}: item {number} of " + f"`allow_empty_legs:` is {_read_as(item)}, not a leg " + "`execute` can seed, `spec` or `code`" + for number, item in enumerate(allowed, 1) + if item not in EXTRACTED_LEGS] + said.append((found, ALLOW_EMPTY_LEGS_REMEDIATION)) + + paths = data.get("paths") + said.append(([] if paths is None or isinstance(paths, list) else [ + _not_a(BAD_PATHS, "paths", paths, "list", + "each item of a list is one entry, a path of the source and " + "the leg it goes to")], PATHS_REMEDIATION)) + + return ([finding for found, _ in said for finding in found], + [remediation for found, remediation in said if found]) + + +def _plan_type_findings(plan: Plan) -> tuple[list[str], list[str]]: + """What `check` reports, and `execute` refuses, before it opens anything: + `_plan_key_findings`, and, when there is one, `_plan_field_findings` + too, so that every key of the plan that is not what `plan` writes is + named in one run and not the first of them (#386). `([], [])` for a plan + whose five keys `Plan` reads are what `plan` writes; #293's and #294's + keys are then asked in `_entry_findings`, beside everything else.""" + findings, remediation = _plan_key_findings(plan) + if not findings: + return [], [] + fields, field_remediation = _plan_field_findings(plan) + return findings + fields, remediation + field_remediation + + +def _refuse_a_mistyped_plan(plan: Plan) -> None: + """Refuse what `_plan_type_findings` finds, in the shape + `_refuse_what_check_finds` refuses `check`'s other findings -- the first + finding's code, every finding as `check` prints it, capped at + `REFUSED_FINDINGS_SHOWN` -- but first thing, before `_work_root` makes the + work directory or the source is opened or cloned (#386).""" + findings, remediation = _plan_type_findings(plan) + if not findings: + return + # `FINDING : ` is the one shape every finding is written in. + code = findings[0].split()[1].rstrip(":") + lines = [f"{len(findings)} finding(s) in {plan.path}, as `check` prints " + "them:"] + lines += [f" {finding}" for finding in findings[:REFUSED_FINDINGS_SHOWN]] + if len(findings) > REFUSED_FINDINGS_SHOWN: + lines.append(f" and {len(findings) - REFUSED_FINDINGS_SHOWN} more") + raise Refusal(code, "\n".join(lines), + "\n".join([PLAN_KEY_REFUSAL, *remediation])) + + #: What a plan whose entry `entry_path_problem` refuses is told to do. Said by #: `check`, once after its findings, and by `execute`'s refusal. UNSAFE_PATH_REMEDIATION = ( @@ -4855,6 +5079,13 @@ def _print_what_will_happen(plan: Plan, source: Source, names: dict, def cmd_check(args) -> int: plan = Plan.load(Path(args.plan)) + # FIRST, before anything is opened: the keys the rest is read from (#386). + mistyped, mistyped_remediation = _plan_type_findings(plan) + if mistyped: + for line in [*mistyped, *mistyped_remediation]: + print(line, file=sys.stderr) + print(f"\n{len(mistyped)} finding(s) in {plan.path}", file=sys.stderr) + return 1 work_root = _work_root(args) source = plan.open_source(args.source, work_root) naming = NamingPolicy.load(NAMING_POLICY) @@ -6469,6 +6700,7 @@ def _commit_the_split(plan: Plan, source: Source, assembly: Path, def cmd_execute(args) -> int: plan = Plan.load(Path(args.plan)) + _refuse_a_mistyped_plan(plan) work_root = _work_root(args) source = plan.open_source(args.source, work_root) naming = NamingPolicy.load(NAMING_POLICY) diff --git a/tests/test_adopt_execute_refusals.py b/tests/test_adopt_execute_refusals.py index a1f5318a..3fd809e7 100644 --- a/tests/test_adopt_execute_refusals.py +++ b/tests/test_adopt_execute_refusals.py @@ -808,6 +808,417 @@ def test_both_findings_are_listed_and_the_code_is_the_first(adopter, world): "Remediation: write `follow_ups:`") == 1 +# --- the keys a plan is opened by (#386) ------------------------------------- + +#: The finding codes, one per key, as #293's is `plan-bad-follow-ups`. +BAD_SOURCE, BAD_LEGS = "plan-bad-source", "plan-bad-legs" +BAD_ALLOW, BAD_PATHS = "plan-bad-allow-empty-legs", "plan-bad-paths" +#: `pins:` is #273's: a `pins:` that is no list is `_read_pins`' own +#: `plan-pin-malformed`, asked here first, and each entry of a list is +#: `plan-pin-unrecorded` or `plan-pin-malformed` beside the entries. +PIN_MALFORMED, PIN_UNRECORDED = "plan-pin-malformed", "plan-pin-unrecorded" + +#: What a hand edit can leave a key `Plan` reads holding, by test id: the key, +#: the block written in place of its own, the code, and what the finding must +#: begin with (#386). On #356's head (5a5011d) the scalars ended `check` and +#: `execute` in `TypeError: ... is not iterable` (`pins:`, `paths:`, +#: `allow_empty_legs:`) or `AttributeError: ... has no attribute 'get'` +#: (`legs:`, `source:`), exit 1; `pins: openGlass` passed `check` and adopted +#: with eight one-letter pins, and `allow_empty_legs: code` consented for four +#: legs, `c`, `o`, `d` and `e`. Since #273 a pin is a mapping, so one pin +#: written as the mapping itself, with no list around it, is a row too. +BAD_KEYS = { + "pins-int": ("pins", "pins: 5\n", PIN_MALFORMED, + "`pins:` is 5, not a list of pins"), + "pins-true": ("pins", "pins: true\n", PIN_MALFORMED, + "`pins:` is True, not a list of pins"), + "pins-text": ("pins", "pins: openGlass\n", PIN_MALFORMED, + "`pins:` is 'openGlass', not a list of pins"), + "pins-mapping": ("pins", "pins: {product: openGlass}\n", PIN_MALFORMED, + "`pins:` is {'product': 'openGlass'}, not a list of " + "pins"), + "allow-int": ("allow_empty_legs", "allow_empty_legs: 5\n", BAD_ALLOW, + "`allow_empty_legs:` is the number 5, not a list"), + "allow-true": ("allow_empty_legs", "allow_empty_legs: true\n", BAD_ALLOW, + "`allow_empty_legs:` is the boolean true, not a list"), + "allow-text": ("allow_empty_legs", "allow_empty_legs: code\n", BAD_ALLOW, + "`allow_empty_legs:` is the text 'code', not a list"), + "allow-item-case": ("allow_empty_legs", "allow_empty_legs: [Code]\n", + BAD_ALLOW, "item 1 of `allow_empty_legs:` is the text " + "'Code', not a leg `execute` can seed"), + "allow-item-int": ("allow_empty_legs", "allow_empty_legs: [code, 5]\n", + BAD_ALLOW, "item 2 of `allow_empty_legs:` is the " + "number 5, not a leg `execute` can seed"), + "paths-int": ("paths", "paths: 5\n", BAD_PATHS, + "`paths:` is the number 5, not a list"), + "paths-true": ("paths", "paths: true\n", BAD_PATHS, + "`paths:` is the boolean true, not a list"), + "paths-text": ("paths", "paths: src/\n", BAD_PATHS, + "`paths:` is the text 'src/', not a list"), + "legs-int": ("legs", "legs: 5\n", BAD_LEGS, + "`legs:` is the number 5, not a mapping"), + "legs-list": ("legs", "legs: [spec, code]\n", BAD_LEGS, + "`legs:` is a list of 2 item(s), not a mapping"), + "source-int": ("source", "source: 5\n", BAD_SOURCE, + "`source:` is the number 5, not a mapping"), + "source-text": ("source", "source: Thing\n", BAD_SOURCE, + "`source:` is the text 'Thing', not a mapping"), +} +ONE_PER_BAD_KEY = pytest.mark.parametrize("edit", sorted(BAD_KEYS)) + +#: The line each key's remediation begins with, said once after its findings. +KEY_REMEDIATION = {key: f"Remediation: write `{key}:` in the plan file" + for key in ("source", "legs", "allow_empty_legs")} +KEY_REMEDIATION["paths"] = ("Remediation: `paths:` is the list of entries " + "`plan` wrote") +KEY_REMEDIATION["pins"] = ("Remediation: `pins:` is the list of pins `plan` " + "wrote, one mapping per pin") + +#: What `execute`'s refusal of them says first: #445's words for a refusal +#: before any leg exists (`NO_LEG_MADE`), and that it came earlier still. +NOTHING_MADE = (f"{NO_LEG_MADE}: the plan was refused before the work " + "directory was made or the source was opened") + + +def with_key(text: str, key: str, block: str) -> str: + """`text` with the top-level `key:` and the block under it replaced by + `block`, or deleted when `block` is empty, as a hand edit would.""" + edited, done = re.subn(rf"^{key}:.*\n(?:[ \t]+.*\n)*", lambda _: block, + text, count=1, flags=re.M) + assert done == 1, f"the plan has no `{key}:` to edit" + return edited + + +def bad_key_text(world: World, edit: str) -> str: + key, block, _, _ = BAD_KEYS[edit] + return with_key(world.answered, key, block) + + +@ONE_PER_BAD_KEY +def test_check_finds_a_key_plan_reads_that_is_not_what_plan_writes(world, + edit): + """#386 (a): a FINDING that names the key and what the reader gave it, + exit 1, no traceback and no `plan ok`; and nothing else is reported, + because every other finding is read from these keys. On 5a5011d each was + a traceback naming Python's error, or `plan ok`.""" + key, _, code, said = BAD_KEYS[edit] + result = run_check(write_edited(world, bad_key_text(world, edit))) + assert result.returncode == 1, result.stderr + result.stdout + assert_no_traceback(result) + assert "plan ok" not in result.stdout + found = findings_of(result) + assert len(found) == 1, found + assert found[0].startswith(f"FINDING {code}: {said}"), found + assert result.stderr.count(KEY_REMEDIATION[key]) == 1, result.stderr + assert "1 finding(s) in" in result.stderr + + +@ONE_PER_BAD_KEY +def test_execute_refuses_a_key_plan_reads_before_anything_is_made( + adopter, world, edit): + """#386 (c): the same finding, refused under its own code before the + work directory, the source's reading or a leg; on 5a5011d each ended in + the traceback `check` printed, or, for the text rows of `pins:` and the + text and item rows of `allow_empty_legs:`, adopted.""" + key, _, code, said = BAD_KEYS[edit] + world.plan.write_text(bad_key_text(world, edit), encoding=UTF8) + refusal = refused_by(adopter, execute_args(world.tmp, world.plan)) + assert refusal.code == code + assert f" FINDING {code}: {said}" in refusal.detail + assert refusal.detail.startswith(f"1 finding(s) in {world.plan}, as " + "`check` prints them:") + assert NOTHING_MADE in refusal.remediation + assert refusal.remediation.count(KEY_REMEDIATION[key]) == 1 + no_leg_was_created(world.tmp) + assert not (world.tmp / WORK).exists(), "not even the work directory" + assert source_branches(world.source) == ["main"] + + +def test_every_key_that_is_wrong_is_named_in_one_run(adopter, world): + """#386 (b): three of the five keys wrong at once, and #293's beside + them: one list, in the order `plan` writes the keys, then #293's, which + `check` would otherwise report only once these were corrected. `execute` + wears the first one's code and names every one, each remediation once.""" + text = with_key(world.answered, "pins", "pins: openGlass\n") + text = with_key(text, "legs", "legs: 5\n") + text = with_key(text, "paths", "paths: true\n") + text = with_follow_ups(text, "follow_ups: 5\n") + printed = check_findings(write_edited(world, text)) + assert [line.split(":", 1)[0] for line in printed] == [ + f"FINDING {BAD_LEGS}", f"FINDING {PIN_MALFORMED}", + f"FINDING {BAD_PATHS}", f"FINDING {BAD_FOLLOW_UPS_CODE}"], printed + world.plan.write_text(text, encoding=UTF8) + refusal = refused_by(adopter, execute_args(world.tmp, world.plan)) + assert refusal.code == BAD_LEGS + for finding in printed: + assert f" {finding}" in refusal.detail + for key in ("legs", "pins", "paths"): + assert refusal.remediation.count(KEY_REMEDIATION[key]) == 1 + assert refusal.remediation.count("Remediation: write `follow_ups:`") == 1 + no_leg_was_created(world.tmp) + + +def test_a_wrong_source_is_named_when_source_says_where_to_read_it(world): + """`--source` says where to read the repository, so the plan's `source:` + is not read to open it; it is still the plan's record of the repository, + and its `commit:` is what `check` holds the tree to. On 5a5011d this was + `AttributeError` from `source_commit`.""" + text = with_key(world.answered, "source", "source: 5\n") + result = run_script(ADOPT, "check", "--plan", + str(write_edited(world, text)), + "--source", str(world.source)) + assert result.returncode == 1, result.stderr + result.stdout + assert_no_traceback(result) + [found] = findings_of(result) + assert found.startswith( + f"FINDING {BAD_SOURCE}: `source:` is the number 5, not a mapping"), \ + found + + +def test_a_spec_only_plan_that_consents_in_text_is_not_four_legs(adopter, + tmp_path): + """#386's `allow_empty_legs: code`, where it matters: a source with no + code, whose plan `plan` wrote with `--allow-empty-leg code`. Text on the + key's own line was read as four legs, `c`, `o`, `d` and `e`, so on + 5a5011d `check` said `plan ok` while `execute` refused + `adopt-empty-leg-unconsented` for the leg the person had named; both now + name the key.""" + repo = spec_only_source(tmp_path) + plan_path = tmp_path / "plan.yaml" + written = write_plan(repo, plan_path, project=PROJECT, + extra=("--allow-empty-leg", CODE)) + assert written.returncode == 0, written.stderr + written.stdout + text = with_key(plan_path.read_text(encoding=UTF8), "allow_empty_legs", + "allow_empty_legs: code\n") + plan_path.write_text(text, encoding=UTF8) + found = check_findings(plan_path) + assert found == [f"FINDING {BAD_ALLOW}: `allow_empty_legs:` is the text " + "'code', not a list: each item of a list names one leg " + "`execute` may seed from the shape's template"], found + refusal = refused_by(adopter, execute_args(tmp_path, plan_path)) + assert refusal.code == BAD_ALLOW + no_leg_was_created(tmp_path) + + +def a_whole_pin(product: str) -> str: + """One `pins:` entry as `plan --pin @` writes it since + #273: a mapping of the five values `execute` writes the pin file from. + `check` and `execute` read no network for a pin, so the commit and the + digest need only be of the form `plan` records them in.""" + return (f" - product: {product}\n" + f" repository: opensoft/{product}\n" + f' commit: "{"a" * 40}"\n' + f' tree_sha256: "{"b" * 64}"\n' + f' digest_source: "local-clone ({product})"\n') + + +#: What `check` and `execute` still take, by test id: the key, the block +#: written in place of its own ("" deletes the key), and whether `check` +#: then passes. A `paths:` with no entries is valid as a TYPE and leaves every +#: source path uncovered, which is `plan-uncovered`'s to say, as it was. A +#: `pins:` is a list of mappings, as `plan` writes it since #273. +GOOD_KEYS = { + "pins-empty": ("pins", "pins: []\n", True), + "pins-bare": ("pins", "pins:\n", True), + "pins-absent": ("pins", "", True), + "pins-one": ("pins", "pins:\n" + a_whole_pin("openGlass"), True), + "pins-two": ("pins", "pins:\n" + a_whole_pin("openGlass") + + a_whole_pin("openPane"), True), + "allow-empty": ("allow_empty_legs", "allow_empty_legs: []\n", True), + "allow-bare": ("allow_empty_legs", "allow_empty_legs:\n", True), + "allow-absent": ("allow_empty_legs", "", True), + "allow-both": ("allow_empty_legs", "allow_empty_legs: [spec, code]\n", + True), + "paths-empty": ("paths", "paths: []\n", False), + "paths-bare": ("paths", "paths:\n", False), + "paths-absent": ("paths", "", False), +} + + +@pytest.mark.parametrize("edit", sorted(GOOD_KEYS)) +def test_a_list_an_empty_list_a_bare_key_and_no_key_are_not_findings( + adopter, world, edit): + """#386 (d): what `plan` writes, and the ways of writing none, are not + findings of this kind; a plan with no entries is `plan-uncovered`, as it + was, and never `plan-bad-paths`.""" + key, block, passes = GOOD_KEYS[edit] + text = with_key(world.answered, key, block) + checked = run_check(write_edited(world, text)) + assert_no_traceback(checked) + assert "FINDING plan-bad-" not in checked.stderr, checked.stderr + if passes: + assert checked.returncode == 0, checked.stderr + checked.stdout + plan, source = open_plan(adopter, world, text) + assert adopter._refuse_what_check_finds(plan, source) is None + else: + assert checked.returncode == 1, checked.stderr + checked.stdout + assert {line.split(":", 1)[0] for line in findings_of(checked)} == { + f"FINDING {UNCOVERED}"} + + +#: #386's rows for an ENTRY of `pins:`, by test id: the block, and each +#: finding `check` prints, `(code, what it begins with)`. On 5a5011d each +#: was adopted as written: the pins `openGlass` and `True`, `2026`, none, and +#: `{'a': 1}`. Since #273 every entry of a list is `_read_pins`' to judge, +#: beside the entries, in its own words: a name alone is `plan-pin- +#: unrecorded`, anything else `plan` never writes there `plan-pin-malformed`. +PIN_ENTRIES = { + "name-and-true": ("pins: [openGlass, true]\n", [ + (PIN_UNRECORDED, "`pins:` entry 1, 'openGlass', is a name with no " + "commit and no digest"), + (PIN_MALFORMED, "`pins:` entry 2 is True, not a pin")]), + "number": ("pins:\n - 2026\n", [ + (PIN_MALFORMED, "`pins:` entry 1 is 2026, not a pin")]), + "null": ("pins: [null]\n", [ + (PIN_MALFORMED, "`pins:` entry 1 is None, not a pin")]), + "mapping": ("pins: [{a: 1}]\n", [ + (PIN_UNRECORDED, "`pins:` entry 1 records no `commit:` and no " + "`tree_sha256:`")]), +} + + +@pytest.mark.parametrize("edit", sorted(PIN_ENTRIES)) +def test_an_entry_of_pins_is_judged_by_the_pin_reader_not_first(adopter, + world, edit): + """A `pins:` that IS a list is not this gate's: `_plan_key_findings` + says nothing of it, and each entry `plan` would not write is named by + `_read_pins` beside the entries, with no traceback and none of this + gate's remediation; `execute` refuses it under the first one's code + before any leg exists.""" + block, expected = PIN_ENTRIES[edit] + text = with_key(world.answered, "pins", block) + checked = run_check(write_edited(world, text)) + assert checked.returncode == 1, checked.stderr + checked.stdout + assert_no_traceback(checked) + found = findings_of(checked) + assert len(found) == len(expected), found + for line, (code, said) in zip(found, expected): + assert line.startswith(f"FINDING {code}: {said}"), line + assert KEY_REMEDIATION["pins"] not in checked.stderr, checked.stderr + assert adopter._plan_key_findings( + adopter.Plan.load(world.tmp / EDITED)) == ([], []) + world.plan.write_text(text, encoding=UTF8) + refusal = refused_by(adopter, execute_args(world.tmp, world.plan)) + assert refusal.code == expected[0][0] + no_leg_was_created(world.tmp) + + +def test_the_readers_ask_the_type_of_what_they_read(adopter, world): + """#386 (3): no `TypeError` or `AttributeError` from the reads themselves, + whoever constructs the plan: each takes only what `plan` writes, and of + `pins:` the products, out of the mappings `plan` writes since #273 or an + old plan's names. On 5a5011d the constructor raised on `pins: 5` and + `paths: 5`, `allowed_empty_legs` on `allow_empty_legs: 5`, `names` on + `legs: 5`, and `source_commit` and `open_source` on `source: 5`; text was + read one character at a time.""" + base = {"kind": adopter.PLAN_KIND, "mode": "in-place"} + for value in (5, True, 1.5, "openGlass", {"a": 1}): + plan = adopter.Plan(Path("p.yaml"), { + **base, "pins": value, "paths": value, + "allow_empty_legs": value, "source": value}) + assert plan.pins == [] and plan.entries == [] + assert plan.allowed_empty_legs() == set() + assert plan.source_commit == "" + with pytest.raises(adopter.Refusal) as caught: + plan.open_source(None, world.tmp / "read") + assert caught.value.code == "plan-no-source" + assert plan.open_source(str(world.source), + world.tmp / "read").commit + plan = adopter.Plan(Path("p.yaml"), { + **base, "pins": ["openGlass", True, None, "", 5, + {"product": "openPane"}, {"product": 5}, {"a": 1}], + "allow_empty_legs": ["code", None, 5, "Spec"]}) + assert plan.pins == ["openGlass", "openPane"] + assert plan.allowed_empty_legs() == {"code", "Spec"} + no_legs = adopter.Plan(Path("p.yaml"), dict(base)).names() + for legs in (5, "Northwind", [{"role": "spec"}]): + plan = adopter.Plan(Path("p.yaml"), {**base, "legs": legs}) + assert plan.legs == legs, "the doctor's list is kept as it stands" + assert plan.names() == no_legs, "read as no `legs:` at all" + + +def test_a_placement_plans_list_of_legs_still_constructs(adopter): + """#386's constraint: `shape-doctor.py --placement-plan` writes `legs:` as + a LIST and `tests/test_shape_doctor.py` hands it to `Plan` and + `_leg_findings`. The constructor refuses no `legs:`; only `check` and + `execute`, reading a plan FILE, name one that is not a mapping.""" + plan = adopter.Plan(Path("placement-plan.yaml"), { + "kind": adopter.PLAN_KIND, "mode": "in-place", + "legs": [{"role": "spec", "path": "spec"}], + "paths": [{"path": "spec/tool.py", "leg": "spec"}]}) + assert [entry["path"] for entry in plan.entries] == ["spec/tool.py"] + assert plan.legs == [{"role": "spec", "path": "spec"}] + assert adopter._leg_findings(plan, None) == [] + + +def test_the_refusal_comes_before_anyone_is_asked_to_say_yes_for_a_key( + adopter, world, monkeypatch): + """WITHOUT `--yes`, on a stream that is not a terminal: the refusal is + still the key's own, and not `adopt-unconfirmed`.""" + monkeypatch.setattr(sys, "stdin", io.StringIO("")) + world.plan.write_text(bad_key_text(world, "pins-text"), encoding=UTF8) + refusal = refused_by(adopter, + execute_args(world.tmp, world.plan, yes=False)) + assert refusal.code == PIN_MALFORMED + no_leg_was_created(world.tmp) + + +#: Products `plan --pin @` takes (`PIN_ARG_RE`) that the +#: plan's reader would take for a number written bare, `2026`, `7` and +#: `1.5`, and `openGlass`, the control. +PIN_NAMES_PLAN_WRITES = ("2026", "007", "1.5", "openGlass") + + +def stand_in_product(path: Path) -> str: + """A clone of a neutral product on this machine, with one commit, which + `plan --pin @ --pin-source ` reads the digest + from with no network (#273); its commit. The file is written as bytes, + so the tree is the same on every platform.""" + path.mkdir(parents=True) + git("init", "-q", "-b", "main", cwd=path) + (path / "README.md").write_bytes(b"# a neutral product\n") + git("add", "-A", cwd=path) + git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", + "README.md", cwd=path) + return git("rev-parse", "HEAD", cwd=path).stdout.strip() + + +def write_pinned_plan(source: Path, plan: Path, product: str, base: Path): + """`plan` for `source` with `--pin @`, read from a + stand-in under `base`, as `plan` writes such a plan since #273.""" + clone = base / "pin-source" + commit = stand_in_product(clone) + return write_plan(source, plan, project=PROJECT, + extra=("--pin", f"{product}@{commit}", + "--pin-source", str(clone))) + + +@pytest.mark.parametrize("name", PIN_NAMES_PLAN_WRITES) +def test_the_pins_plan_writes_are_what_the_gate_takes(adopter, tmp_path, + name): + """`plan --pin` writes `pins:` as a list of MAPPINGS since #273, and + this gate takes exactly that: no finding of this kind and none of + `_read_pins`', `check` passes the plan `plan` wrote, and the reader + reads back the product it was given. With #386's item check as it was, + which took only text, every plan `plan --pin` wrote was `plan-bad-pins` + in its own `check`.""" + source = make_source_repo(tmp_path / "Thing") + plan = tmp_path / "adoption-plan.yaml" + written = write_pinned_plan(source, plan, name, tmp_path) + assert written.returncode == 0, written.stderr + written.stdout + for path, leg in ANSWERS: + resolve(plan, path, leg) + [pin] = adopter.load_yaml(plan)["pins"] + assert isinstance(pin, dict) and pin["product"] == name, pin + loaded = adopter.Plan.load(plan) + assert adopter._plan_key_findings(loaded) == ([], []) + assert adopter._read_pins(loaded)[1] == [] + checked = run_check(plan) + assert checked.returncode == 0, checked.stderr + checked.stdout + assert "plan ok" in checked.stdout + assert loaded.pins == [name] + + # --- a plan that is not UTF-8 (#192) ---------------------------------------- #: The byte `0xff` is in no UTF-8 text, so wherever it lands the file cannot be @@ -975,3 +1386,43 @@ def test_an_elected_on_it_cannot_read_creates_no_leg_and_the_corrected_plan_runs manifest = adopter.load_yaml(work / PROJECT / "project.yaml") assert manifest["reference"] == adopter.default_reference("2026-09-02") assert str(manifest["elected_on"]) == "2026-09-02" + + +@needs_filter_repo +def test_pins_written_as_text_creates_no_leg_and_the_corrected_plan_runs( + adopter, world): + """#386 (e) as a person met it, through `main`: `pins: openGlass` is exit 2 + and a REFUSED line, with no work directory, no leg and no split branch, + and no notice of what the run left, which #445 prints only once a leg + exists. On 5a5011d it adopted, said `adoption verified`, and wrote eight + pins, `G`, `a`, `e`, `l`, `n`, `o`, `p` and `s`. The corrected plan, with + the `pins:` block `plan --pin openGlass@` writes, adopts with the + one pin and writes its file (#273).""" + remotes, work = world.tmp / REMOTES, world.tmp / WORK + flags = ("--plan", str(world.plan), "--yes", "--local-remote-dir", + str(remotes), "--work-dir", str(work)) + world.plan.write_text(bad_key_text(world, "pins-text"), encoding=UTF8) + refused = run_script(ADOPT, "execute", *flags) + assert refused.returncode == 2, refused.stderr + refused.stdout + assert f"REFUSED {PIN_MALFORMED}: " in refused.stderr + assert "`pins:` is 'openGlass', not a list of pins" in refused.stderr + assert_no_traceback(refused) + assert_refused_before_any_leg(refused) + assert "adoption verified" not in refused.stdout + assert not remotes.exists(), "not even the directory the legs go in" + assert not work.exists(), "and no work directory" + assert source_branches(world.source) == ["main"] + + pinned = world.tmp / "pinned-plan.yaml" + written = write_pinned_plan(world.source, pinned, "openGlass", world.tmp) + assert written.returncode == 0, written.stderr + written.stdout + block = re.search(r"^pins:.*\n(?:[ \t]+.*\n)*", + pinned.read_text(encoding=UTF8), re.M).group(0) + world.plan.write_text(with_key(world.answered, "pins", block), + encoding=UTF8) + corrected = run_script(ADOPT, "execute", *flags) + assert corrected.returncode == 0, corrected.stderr + corrected.stdout + assert "adoption verified" in corrected.stdout + manifest = adopter.load_yaml(work / PROJECT / "project.yaml") + assert manifest["neutral_product_pins"] == ["openGlass"] + assert (work / PROJECT / "contracts" / "openglass-pin.yaml").is_file()