Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
76a6153
adopt-project.py: the plan writer quotes any string its own loader wo…
brettheap Oct 9, 2026
d8ed5b0
Merge origin/main (22c9fdd) into fix/adopt-plan-quote-yaml-keywords
brettheap Oct 9, 2026
c6d61d1
Merge origin/main (e4decfb) into fix/adopt-plan-quote-yaml-keywords
brettheap Oct 10, 2026
b26841b
adopt-project.py: `plan`, `check` and `execute` refuse a value `execu…
brettheap Oct 11, 2026
2e225fe
Merge origin/main (a5b09c3) into fix/adopt-plan-quote-yaml-keywords
brettheap Oct 11, 2026
a0ff15b
Merge origin/main (54aa69a) into fix/adopt-plan-quote-yaml-keywords
brettheap Oct 11, 2026
5aee63a
adopt-project.py: a `manifest-misread` sentence names each value the …
brettheap Oct 11, 2026
6b104cf
Merge origin/main (4835fb4) into fix/adopt-plan-quote-yaml-keywords
brettheap Oct 11, 2026
34d683f
adopt-project.py: this branch's `_shown(line)` is `_line_shown` now, …
brettheap Oct 11, 2026
2ad82cb
Merge origin/main (39ac59c) into fix/adopt-plan-quote-yaml-keywords
brettheap Oct 11, 2026
44c8c17
Merge origin/main (60d08a1) into fix/adopt-plan-quote-yaml-keywords
brettheap Oct 11, 2026
bd695e5
Merge origin/main (c3f4987) into fix/adopt-plan-quote-yaml-keywords
brettheap Oct 11, 2026
77218c8
Merge origin/main (60588d5) into fix/adopt-plan-quote-yaml-keywords
brettheap Oct 11, 2026
e4e5e44
Merge origin/main (d94d155) into fix/adopt-plan-quote-yaml-keywords
brettheap Oct 12, 2026
3352be4
Merge origin/main (be2790e) into fix/adopt-plan-quote-yaml-keywords
brettheap Oct 12, 2026
4bf2cc4
adopt-project.py: the misread gate asks `elected_by:` and `reference:…
brettheap Oct 12, 2026
e337f44
Merge origin/main (ede15ce) into fix/adopt-plan-quote-yaml-keywords
brettheap Oct 12, 2026
2405670
Merge origin/main (71b58a1) into fix/adopt-plan-quote-yaml-keywords
brettheap Oct 12, 2026
a230c82
adopt-project.py: a misread sentence names a boolean in the words `y`…
brettheap Oct 12, 2026
57103dd
adopt-project.py: a misread sentence about a pin names the item `exec…
brettheap Oct 12, 2026
cba6d3f
adopt-project.py: `_worded` names each stretch of one kind of charact…
brettheap Oct 12, 2026
bf6d656
Merge origin/main (a1b3a4b) into fix/adopt-plan-quote-yaml-keywords
brettheap Oct 12, 2026
d38e143
adopt-project.py: the misread gate asks the materializer's own `rende…
brettheap Oct 12, 2026
e391b18
adopt-project.py: the #181 gate asks only about a value `execute` get…
brettheap Oct 12, 2026
e8e0026
Merge main 1f90a0f into fix/adopt-plan-quote-yaml-keywords (#181)
brettheap Oct 12, 2026
dae165d
adopt-project.py: the #181 gate leaves a value no file can hold to #2…
brettheap Oct 12, 2026
37d7a71
Merge main 92e29d9 into fix/adopt-plan-quote-yaml-keywords (#181)
brettheap Oct 12, 2026
4c6e856
Merge main dd8d0ea into fix/adopt-plan-quote-yaml-keywords (#181)
Oct 12, 2026
7ac2358
adopt-project.py: #181's misread gate and #261's validator judge each…
Oct 12, 2026
3662784
Merge main 75052cb into fix/adopt-plan-quote-yaml-keywords (#181)
Oct 12, 2026
c87c45f
tests/test_adopt_plan_yaml_quoting.py: each test that runs `execute` …
Oct 12, 2026
f38d19a
adopt-project.py: the project's own validator is asked before #181's …
Oct 12, 2026
dc7b79d
Merge main 437a360 into fix/adopt-plan-quote-yaml-keywords (#181)
Oct 12, 2026
c871597
tests/test_adopt_plan_yaml_quoting.py: the misread rule's sentence ab…
Oct 12, 2026
576ca64
tests/test_adopt_plan_yaml_quoting.py: `plan`'s refusal of an id it d…
Oct 12, 2026
edf9163
Merge main 7a8b981 into fix/adopt-plan-quote-yaml-keywords (#181)
Oct 12, 2026
6a3a6b3
adopt-project.py: a tracking branch the loader misreads that git will…
Oct 13, 2026
ed9968f
Merge main 5a25387 into fix/adopt-plan-quote-yaml-keywords (#181)
Oct 13, 2026
cf6da3f
Merge main 965420b into fix/adopt-plan-quote-yaml-keywords (#181)
Oct 13, 2026
4ffd460
tests: a hand-edited `elected_on:` that `election_date` refuses is #2…
Oct 13, 2026
f911d81
adopt-project.py: `_misread_findings` and `_manifest_findings` no lon…
Oct 13, 2026
43b0f58
Merge main cc8f2da into fix/adopt-plan-quote-yaml-keywords (#181)
Oct 13, 2026
69a743e
adopt-project.py: #181's misread gate names a tracking branch only wh…
Oct 13, 2026
94242a2
tests: a misread tracking branch the source has as its own and its fe…
Oct 13, 2026
5c04ca2
Merge main 631f087 into fix/adopt-plan-quote-yaml-keywords (#181)
Oct 13, 2026
e3fe7cf
Merge main 191105a into fix/adopt-plan-quote-yaml-keywords (#181)
Oct 13, 2026
180902f
adopt-project.py: #181's misread gate asks a pin's product of the tab…
Oct 13, 2026
f54dce7
Merge main e6fbd748 into fix/adopt-plan-quote-yaml-keywords (#181)
Oct 13, 2026
8ce572b
Merge main 89c6a6c into fix/adopt-plan-quote-yaml-keywords (#181)
Oct 13, 2026
9efe95a
adopt-project.py: with #514 and #516 merged, `--pin null@<commit>`, `…
Oct 13, 2026
a22b7f9
Merge main 8beb3d92 into fix/adopt-plan-quote-yaml-keywords (#181)
Oct 14, 2026
6c9e585
tests/test_adopt_plan_yaml_quoting.py: with #513 merged, `with_origin…
Oct 14, 2026
b602c16
Merge main b0826e02 into fix/adopt-plan-quote-yaml-keywords (#181)
Oct 14, 2026
42362de
Merge main 31219459 into fix/adopt-plan-quote-yaml-keywords (#181)
Oct 14, 2026
7d6e13c
Merge main 5ed3e5de (#511's squash for #471) into fix/adopt-plan-quot…
Oct 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
861 changes: 754 additions & 107 deletions adopt-project.py

Large diffs are not rendered by default.

70 changes: 57 additions & 13 deletions scripts/shape_materialize.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "
Expand Down Expand Up @@ -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)):
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -3892,23 +3902,57 @@ 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)
except Exception as exc: # the validator's own defect, never a finding
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
Expand Down
20 changes: 15 additions & 5 deletions tests/test_adopt_leg_paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"


Expand Down
94 changes: 48 additions & 46 deletions tests/test_adopt_manifest_values.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ------
Expand Down Expand Up @@ -806,77 +808,77 @@ 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: " "')
path = world["tmp"] / "mixed.yaml"
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)
Expand Down
Loading
Loading