diff --git a/adopt-project.py b/adopt-project.py index c1766d1e..e4fa0451 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -53,8 +53,13 @@ a directory it writes into, `.github`, `contracts` or `scripts`, the source's own `project.yaml`, where it writes its manifest, or the source's own path at a copy it writes under `shape/`, in - any case), the source's own submodules kept on one leg with - their `.gitmodules`, registered there and never in the root, each + 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 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 source, no symlink kept in the root that the shape would write one of its files, or that file's copy under `shape/`, through @@ -144,6 +149,7 @@ import argparse import contextlib import datetime as _dt +import itertools import os import re import shlex @@ -165,21 +171,23 @@ tree_digest, ) from shape_materialize import ( # noqa: E402 - ADOPT_MAKEFILE_BLOCK, SHAPE_REPOSITORY, - SYMLINK_HOPS, TOO_MANY_SYMLINKS, 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, template_files, write_lf, + ADOPT_MAKEFILE_BLOCK, PLACEHOLDER_RE, SHAPE_REPOSITORY, SYMLINK_HOPS, + TEMPLATED, TOO_MANY_SYMLINKS, 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, + template_files, write_lf, write_paths_listing, ) # The leg-path rule (#179) is `scaffold-project.py`'s as much as adopt's, so it # is imported as one unit and not listed among the materializer's names above. -# `leg_path_problem` is not called here: `tests/test_adopt_leg_paths.py` asks -# it through this module, as it did when the rule was defined in it, which is -# what shows the move changed no behaviour. +# `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 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 - WINDOWS_ABSOLUTE_RE, leg_path_problem, leg_path_problems, + NOT_TEXT_KINDS, WINDOWS_ABSOLUTE_RE, leg_path_problem, leg_path_problems, refuse_bad_leg_paths as _refuse_bad_leg_paths, ) import shape_advisory # noqa: E402 @@ -199,12 +207,15 @@ # 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, - misread_placeholders, refuse_manifest_values, refuse_unstorable_names, + 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 # (#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 shape's bootstrap script, which its `make bootstrap` runs: where it # went beside one the source kept decides what NEXT can name (#480). from shape_materialize import BOOTSTRAP # noqa: E402 @@ -555,18 +566,62 @@ def _pin_findings(plan: "Plan", tree: list) -> list[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. +# 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). _PLAIN_RE = re.compile(r"^[A-Za-z_.][A-Za-z0-9_./@:+-]*\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`? + + 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. + """ + return all(loader_reading(f"{place}{text}") == text + for place in ("k: ", "- ")) + + def y(value) -> str: if value is None: return "null" @@ -577,7 +632,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 return double_quoted(text) @@ -1835,7 +1890,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 @@ -1846,8 +1905,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]: @@ -2063,6 +2122,504 @@ def _kept_collision_notes(args, entries: list, tree: list) -> list[str]: return notes +# --------------------------------------------------------------------------- +# 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 +# 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 +# `--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 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. 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: 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 +# 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 +# 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 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 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 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, 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` +#: 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_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 +#: 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", + 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 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", + "\r": "a carriage return"} +#: 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 " + "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 _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. + + 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 + "}}" + 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]: + """`(what, value, written)` for a value `_template_values` writes as + `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, 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`). 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 + `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: + """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: + return "no value at all" + if isinstance(reading, bool): + # 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): + 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, chars in itertools.groupby(text, str.isprintable): + if printable: + parts.append(f"`{''.join(chars)}`") + else: + parts += map(_character_name, chars) + 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 _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. 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) + + +def manifest_misreads(fields) -> list[str]: + """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 + the thing the person has in front of them. + """ + 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] != text] + if wrong: + where = AND.join(f"into {rel} as {_line_shown(line)}" + for rel, line, _ in wrong) + subject, expected = _named(what, value) + problems.append( + 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 + + +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), 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`): 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: + 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"), ID_PLACEHOLDER, args.id)] + fields += [_field(flag(key), placeholder, getattr(args, key)) + for key, placeholder in MANIFEST_FIELDS.items() + if key != TRACKING_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_FLAG_PRODUCT, planned, product, naming) + for product 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) + + +#: 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, 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. 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. 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 + holding it before any leg exists (#255), so one wrong value is one + 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: + return unwritable_reason(str(value)) is None + try: + checked_value(key, value) + except Refusal: + return False + 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 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 + starts no process.""" + field = _field(what, MANIFEST_FIELDS[TRACKING_KEY], value) + 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 + 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 + 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` 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 + `check` and `execute` alike. + """ + fields: list[tuple[str, tuple]] = [] + try: + fields.append((ID_PLACEHOLDER, _id_field(plan))) + except Refusal: + pass + 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((placeholder, _field(what, placeholder, value))) + # Each pin `execute` writes, by the product it lists in the manifest + # (`_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) + 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)] + + +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`). `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, + 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 (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 + `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 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 [] + left = judged | frozenset(unwritten) + return [f"FINDING plan-manifest-misread: {problem}." + for placeholder, field in _plan_fields(plan) + if placeholder not in left + for problem in manifest_misreads([field])] + + # --------------------------------------------------------------------------- # The tracking branch is a branch of the source (#479) # --------------------------------------------------------------------------- @@ -2273,6 +2830,10 @@ 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. + # Not `given`, which #479 binds below to whether `--tracking-branch` was. + flags_given = {key for key in FLAG_DEFAULTS if getattr(args, key)} args.project = checked_value("--project", args.project) # THE SOURCE'S OWN BRANCH, UNLESS ONE IS NAMED (#479): the branch `plan` # reads the source at is the one its pull request can be opened against. @@ -2362,9 +2923,35 @@ def _checked_plan_inputs(args, source: Source, names = _names(args.project) pins = declared_pin_values(args) _check_names(naming, names, set(pins), 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 (rv181f's G1). Then one its loader would read as + # something else that the validator accepts is refused before the plan + # exists (#181); since #259 and #514 that is only a value on a line + # written bare, 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, + 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, planned, naming)) + if misread: + raise Refusal("adopt-manifest-misread", + " ".join(f"{problem}." for problem in misread), + f"{MANIFEST_MISREAD_REMEDIATION} No plan was written.") + + def _refuse_an_unrecordable_pin_source(source: Path, products) -> None: """`--pin-source` is a path `plan` can record, or this refuses it as `adopt-unwritable-value`, before any digest is read and before a plan @@ -2422,32 +3009,32 @@ def _refuse_an_unrecordable_pin_source(source: Path, products) -> None: # 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. - -#: 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", +# 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 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 +#: 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: dict) -> "Plan": """The plan `plan` is about to write, as `Plan` reads one: the values it @@ -2541,40 +3128,29 @@ 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 - ) -> 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, or its date, 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. + 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 `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) 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, - misreads=False, ignore=LEG_PATH_CODES, - not_text=not_text) + problems = manifest_value_problems(values, naming, _named_as_read(plan), + misreads=False, ignore=LEG_PATH_CODES) if not problems: return [], [] return ([f"FINDING plan-manifest-invalid: {sentence}" @@ -2585,19 +3161,50 @@ def _manifest_findings(plan: Plan, misreads_elsewhere: bool = False "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 + 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: 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: @@ -2700,8 +3307,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 @@ -4643,16 +5250,17 @@ def _entry_findings(plan: Plan, source: Source, """Every FINDING `check` prints about the plan's ENTRIES, in its order, what it prints once after them, and the NOTEs it prints beside them: each entry's path, then coverage, then each entry's `leg:`, the two - mount paths and what the root keeps where the shape writes, then the - source's own submodules, then each symlink the plan keeps in the root - that a shape file cannot be written through (#178, #183), then each - value `execute` writes into a file that no file can hold (#255), then - what the project's own `validate-manifest.py` would refuse of the - values `execute` writes into its manifest (#261), then a tracking - branch that is no branch of the source (#479), then each relative url - of a kept submodule that names another repository from its leg - (#190), and last each plan value `execute` reads late and - `_plan_field_findings` asks about (#293, #294). + mount paths and what the root keeps where the shape writes, 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, + then each symlink the plan keeps in the root that a shape file cannot + be written through (#178, #183), then each value `execute` writes into + a file that no file can hold (#255), then what the project's own + `validate-manifest.py` would refuse of the values `execute` writes into + its manifest (#261), then a tracking branch that is no branch of the + source (#479), then each relative url of a kept submodule that names + another repository from its leg (#190), and last each plan value + `execute` reads late and `_plan_field_findings` asks about (#293, #294). ONE LIST, read by `check` and by `execute`'s `_refuse_what_check_finds`. #168 and #166 each added findings to `check` and a refusal to `execute` @@ -4697,6 +5305,26 @@ def _entry_findings(plan: Plan, source: Source, plan values `execute` reads late (#293, #294): an `elected_on:` that `election_date` refuses is said there in the plan key's own words, and `_manifest_values` does not ask the validator about it as well. + + 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`). 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, + 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) @@ -4712,12 +5340,26 @@ def _entry_findings(plan: Plan, source: Source, field_findings, field_remediation = _plan_field_findings(plan) tracking_findings, tracking_remediation = _tracking_branch_findings( plan, source) - return (findings + split + _root_symlink_findings(plan, source, tree) + # 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 + + _root_symlink_findings(plan, source, tree) + unwritable_findings + value_findings + tracking_findings + moved + field_findings, - unsafe_remediation + split_remediation + unwritable_remediation - + value_remediation + tracking_remediation + moved_remediation - + field_remediation, notes) + unsafe_remediation + + ([MANIFEST_MISREAD_REMEDIATION] if misread else []) + + split_remediation + unwritable_remediation + value_remediation + + tracking_remediation + moved_remediation + field_remediation, + notes) # --------------------------------------------------------------------------- @@ -5098,11 +5740,15 @@ 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] + mounted = (f" at {_shown(mounts[0])}/ and {_shown(mounts[1])}/" + if all(isinstance(mount, str) for mount in mounts) else "") print(f" ONE split commit on branch " f"{_shown(str(plan.get('adopt_branch', ADOPT_BRANCH)))} " - f"of {names['assembly']}: `git rm -r` those paths, mount the legs at " - f"{_shown(str(_plan_leg_path(plan, 'spec')))}/ and " - f"{_shown(str(_plan_leg_path(plan, 'code')))}/") + f"of {names['assembly']}: `git rm -r` those paths, mount the " + f"legs{mounted}") print(f" {len(stays)} path(s) stay in the assembly root; " f"{len(drops)} dropped") # Each pin `execute` writes, as the plan records it (#273): a pin it @@ -6848,8 +7494,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 @@ -7057,9 +7702,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 " @@ -7698,10 +8343,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 = ( @@ -7810,15 +8455,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 "[]" @@ -7862,7 +8508,7 @@ def _template_table(plan: Plan, names, repositories, urls, spec_path, handed, running nothing: no `git`, no `gh`.""" 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 @@ -7879,9 +8525,9 @@ def _template_table(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, @@ -8905,7 +9551,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 a82c70cc..fe2c40e6 100644 --- a/scripts/shape_materialize.py +++ b/scripts/shape_materialize.py @@ -3270,11 +3270,13 @@ def copy_out(src: Path, dst: Path) -> None: 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 " @@ -3408,9 +3410,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)): @@ -3502,9 +3504,17 @@ 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 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 " - "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) @@ -3892,13 +3902,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) @@ -3906,9 +3927,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_leg_paths.py b/tests/test_adopt_leg_paths.py index 6856bec0..4fec29c6 100644 --- a/tests/test_adopt_leg_paths.py +++ b/tests/test_adopt_leg_paths.py @@ -243,25 +243,35 @@ 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, 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 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_manifest_values.py b/tests/test_adopt_manifest_values.py index cd45dbf9..724fbfdc 100644 --- a/tests/test_adopt_manifest_values.py +++ b/tests/test_adopt_manifest_values.py @@ -479,8 +479,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, misreads_elsewhere=True) == ( - [], []) + assert adopter._misread_findings(good) == [] (finding,), remediation = adopter._manifest_findings(refused) assert "visibility is 'secret'" in finding assert remediation @@ -672,21 +671,24 @@ 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. 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)) 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 ------ @@ -806,44 +808,44 @@ def test_a_text_value_the_validator_refuses_is_one_finding_on_this_rule_alone( def test_a_text_value_the_template_now_holds_is_no_finding_either_way( adopter, world, old, new): """The validator accepts it, and the reader reads it back, so it is no - finding on this rule alone, and none where a misread rule judges the rest - (`misreads_elsewhere`).""" + finding of the validator's; and #181's misread rule, asked after it, + names nothing either, because the line `execute` writes for it is quoted + (#259) and reads back as typed.""" plan = adopter.Plan.load(edited(world, old, new)) assert adopter._manifest_findings(plan) == ([], []) - assert adopter._manifest_findings(plan, misreads_elsewhere=True) == ( - [], []) + assert adopter._misread_findings(plan) == [] @pytest.mark.parametrize("old, new, named", TEXT_THE_VALIDATOR_REFUSES) def test_where_a_misread_rule_judges_the_rest_the_validator_still_answers( adopter, world, old, new, named): - """`misreads_elsewhere` leaves out only what the project's reader would - misread, for a list that holds a misread rule's finding about it (#181's - `plan-manifest-misread`). While the template wrote these bare that was - each of them, and the validator was not asked again. It quotes them now - (#259): the reader reads each back as typed, no misread rule refuses it, - and the validator's finding, in words about the text, is the one with or - without such a rule in the list. A date `election_date` refuses is #294's - one finding either way, and the validator is not asked about it.""" + """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 (rv181f's G1). + Since #259 the template quotes each of these, so the reader reads each + back as typed, and the misread rule has nothing to say of it anyway. The + validator's finding, in words about the text, is the one. A date + `election_date` refuses is #294's one finding, and the validator is not + asked about it.""" plan = adopter.Plan.load(edited(world, old, new)) - shared = adopter._manifest_findings(plan, misreads_elsewhere=True) - assert shared == adopter._manifest_findings(plan) + assert adopter._misread_findings(plan) == [] + findings, remediation = adopter._manifest_findings(plan) if said_of(new)[0] != FINDING: - assert shared == ([], []) - shared = adopter._plan_field_findings(plan) - (finding,), remediation = shared + assert (findings, remediation) == ([], []) + findings, remediation = adopter._plan_field_findings(plan) + (finding,) = findings assert finding.startswith(said_of(new)[0]) assert named in finding assert remediation -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 was a misread while the - template wrote `id:` bare; quoted (#259) it is the id `007`, and no finding - on either list.""" + """Every value is asked. `visibility: secret` is text to the reader and + the validator refuses it, and so does a blank `elected_by`. `id: "007"` + beside them was the number 7 to the reader while the template wrote + `id:` bare, and the validator refused it as that; quoted (#259) it is the + id `007`, which it accepts. #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: " "') @@ -851,32 +853,32 @@ 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, misreads_elsewhere=True) - assert len(alone) == 2, alone - assert shared == alone - 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 alone) + findings, _ = adopter._manifest_findings(plan) + assert len(findings) == 2, findings + assert any("visibility is 'secret'" in f for f in findings) + assert any("elected_by is ' '" in f for f in findings) + assert not any("manifest-id" in f for f in findings) + assert adopter._misread_findings(plan) == [] @pytest.mark.parametrize("old, new, named", TEXT_THE_VALIDATOR_REFUSES) def test_a_text_value_the_validator_refuses_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 (#294's, for a date `election_date` refuses, which the - validator is then not asked about). A misread rule in the list as well - (#181) reads the template, which writes these values quoted (#259), so it - has nothing to say of them; one that said something would be the pair two - rules each make of one value, and this test fails on a merge that has - it.""" + the validator's finding is the one (#294's, for a date `election_date` + refuses, which the validator is then not asked about). 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. Since #259 the template + quotes these values, so #181's rule, which asks the line `execute` + writes, has nothing to say of them either.""" 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(said_of(new)[0]), findings @pytest.mark.parametrize("old, new", TEXT_THE_TEMPLATE_NOW_HOLDS) diff --git a/tests/test_adopt_plan_yaml_quoting.py b/tests/test_adopt_plan_yaml_quoting.py new file mode 100644 index 00000000..0511c0c0 --- /dev/null +++ b/tests/test_adopt_plan_yaml_quoting.py @@ -0,0 +1,2348 @@ +# 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, 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 +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. + +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. 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: +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. +""" + +from __future__ import annotations + +import argparse +import functools +import importlib.util +import itertools +import random +import re +import shutil +import sys +from pathlib import Path + +import pytest + +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 +from repo_shape import load_yaml, parse_yaml # noqa: E402 +from shape_materialize import neutral_pin_path # 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" +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"), + ("release.yaml", "root")) + + +def text_of(plan: Path) -> str: + return plan.read_text(encoding=UTF8) + + +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", 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"] + +#: 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_TITLE, "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)): + 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 ------------------------------- + +@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" + assert adopter.y(False) == FALSE_WORD + assert adopter.y(0) == "0" + assert 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" + assert adopter.y("on") == "on" + monkeypatch.setattr(repo_shape, SCALAR_READER, 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_READER, 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" + + +# --- 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" +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: " +#: 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: " +#: 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"' +QUOTED_007 = '"007"' +QUOTED_NULL = '"null"' +#: How `plan`'s and `check`'s codes for #181's rule end. +MISREAD_CODE = "manifest-misread" +#: 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" +PINS_KEY = "pins" +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" +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, 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 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. +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. +LEGS_NULL = "legs/null" +Q4_BRANCH = "2026-q4" +#: 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`" +#: 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)`: 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), +] +#: #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] + +#: 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. +#: 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", + FALSE_TITLE, "2026", "1.5")], + (CODE_FLAG, "null"), + ("--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. +QUOTED_BY_259 = ("PROJECT_ID", "ELECTED_ON", "VISIBILITY", "TRACKING_BRANCH", + "SPEC_PATH", "CODE_PATH") + + +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_OPTION, 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 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_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: + """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 + written = write_plan(source, plan, extra=extra) + assert written.returncode == 0, written.stderr + written.stdout + 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.""" + 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=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. 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.") + + +@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. 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 + written = write_plan(source, plan) + assert written.returncode == 0, written.stderr + written.stdout + for path, leg in ANSWERS: + resolve(plan, path, leg) + assert checked(plan).returncode == 0 + 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) + + +@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. A pin is a whole one, whose + product is `spelled` (`whole_pin`).""" + if key == PINS_KEY: + edited = text.replace("\npins: []\n", "\n" + whole_pin(spelled), 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( + 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. Asked where the line is bare + (`bare_template`, `bare_pins`), in this process.""" + source, _ = answered_copy + plan = tmp_path / PLAN_FILE + 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 stderr, stderr + assert what in stderr, stderr + assert "No plan was written." in stderr + assert not plan.exists() + + +#: 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.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 (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 = "" + 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 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), 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) + 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=extra) + 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 + + +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. 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"), + ("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( + 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, 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) + 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( + 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, 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 + 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 == 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 + 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, 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=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, 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, source, naming) + assert refused.value.code == "adopt-manifest-misread", refused.value + who, what = misread("--id (not given, so --project lowercased)", "true", + TRUE_READ) + 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( + 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( + 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 + 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 MISREAD_CODE not in passed.stderr + + +@pytest.mark.parametrize("key, value, reading", CARRIED, + ids=ids_of(CARRIED, 0, 1)) +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, 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)) + 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) + (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]: + """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))] + + +#: 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"), +] + + +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_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`. 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 == 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, 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) + who, what = misread("id (not set in the plan, so legs.assembly " + "lowercased)", "true", TRUE_READ) + said = adopter.manifest_misreads([adopter._id_field( + adopter.Plan.load(plan))]) + assert len(said) == 1, 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 +#: 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). +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"), + ("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_passes_a_plan_value_read_as_not_text_as_main_does( + answered_copy, tmp_path, key, spelled, read_as, written): + """`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 == 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("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, 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 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` would be handed `--null`, `--` or +#: `--'private'` before any leg exists, and `check` is not told the mode. +#: 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 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"), + 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 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 + in every mode.""" + _, 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 + + +@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 = "~" + + +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`. 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, + 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, bare_template, monkeypatch): + """`branch_problem` starts git. It is asked about `~` and `true`, which + 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 + + 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): + """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_copy + 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 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. 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")) + 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 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`. `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) + 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. + 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 + 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.""" + 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")) + 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. 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 + 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 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 + `--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 + 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 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, bare_template, monkeypatch): + """`_pushed_in_every_mode` starts git. It is asked about `true`, which + 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): + 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( + 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. 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) + 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 ( + stderr), stderr + assert MISREAD_FINDING not in stderr, 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 +#: `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. 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 +#: 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), + 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, once, and never #181's.""" + _, 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_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_copy + 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) + + +#: `(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`). 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(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, JUDGED_ONCE) +def test_check_judges_each_value_once_by_the_rule_true_of_it( + 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 validator's words.""" + 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) + assert result.returncode == 1, result.stdout + result.stderr + (line,) = judged_lines(result.stderr) + assert line.startswith(INVALID_FINDING), result.stderr + assert result.stderr.count("Remediation: ") == 1, 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 +#: 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)`. 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. 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(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(), + 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, 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. 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") + 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) == [] + + +#: 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). 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',", + "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.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, 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, key, spelled) + result = checked(plan) + 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 +def test_execute_refuses_a_visibility_it_never_writes_as_the_validators( + 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_copy + 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 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 +#: 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 = [ + (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`"), + (ELECTED_ON_PLACEHOLDER, '"a\\\\b"', 'elected_on: "a\\\\b"', + "the text `a\\b`"), + (ELECTED_ON_PLACEHOLDER, '"a\\"b"', 'elected_on: "a\\"b"', + 'the text `a"b`'), + (ELECTED_ON_PLACEHOLDER, '"CORP\\ralph"', 'elected_on: "CORP\\ralph"', + "the text `CORP`, then a carriage return, then `alph`"), + (ELECTED_ON_PLACEHOLDER, '"docs\\x41.md"', 'elected_on: "docs\\x41.md"', + "the text `docsA.md`"), + (ELECTED_ON_PLACEHOLDER, "a #b", "elected_on: a #b", "the text `a`"), +] + + +@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, 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 + 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"] + + +#: 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 "', id="space"), + pytest.param('"openGlass\\xa0"', id="no-break-space"), +] + + +@pytest.mark.parametrize("trailed", PIN_WITH_A_TRAIL) +def test_a_pin_ending_in_whitespace_is_273s_finding_alone( + 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. 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) + 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 +#: 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)]) == [] + + +#: 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)) +def test_execute_refuses_a_plan_carrying_one_before_any_leg_exists( + 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. 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)) + 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 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) + + +#: 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") + + +@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 / 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 + 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) + 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 + + 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 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, 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 + 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([branch, pin])] == [TRACKING_KEY] + + +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 a branch between those of `tracking_branch:` + 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") == [ + (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. 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) + 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 +#: them: `(file, path into what it loads)`. The test's own oracle, written +#: from the templates, never from `adopt-project.py`. +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))], +} + + +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. 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": "", 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, 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. 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: + 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(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 ---------- + +@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, 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`). 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: []) + for pin in ("null", "Null", "NULL", "true", FALSE_TITLE): + plan = tmp_path / f"{pin}.yaml" + 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] + + +@pytest.mark.parametrize("pins, written", [ + ((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(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)) + 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( + 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, 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") + result = checked(plan) + assert result.returncode == 0, 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): + """`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" diff --git a/tests/test_template_text_placeholders.py b/tests/test_template_text_placeholders.py index 84c010cc..9a2f7ba2 100644 --- a/tests/test_template_text_placeholders.py +++ b/tests/test_template_text_placeholders.py @@ -249,10 +249,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