Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
45 changes: 41 additions & 4 deletions adopt-project.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,8 @@
# into its manifest, and whether a repository's name can be a directory's:
# asked of the plan's values by `plan`, `check` and `execute` (#261).
from shape_materialize import ( # noqa: E402
MANIFEST_INVALID_REMEDIATION, manifest_judged, manifest_value_problems,
KIND_UNREADABLE, MANIFEST_INVALID_REMEDIATION,
MANIFEST_UNREADABLE_REMEDIATION, manifest_judged, manifest_value_problems,
refuse_manifest_values, refuse_unstorable_names,
)
# Whether a value this tool writes into a file is text a file can hold
Expand Down Expand Up @@ -4220,12 +4221,18 @@ def _manifest_findings(plan: Plan) -> tuple[list[str], list[str]]:
if values is None:
return [], []
problems = manifest_value_problems(values, naming, _named_as_read(plan),
misreads=False, ignore=LEG_PATH_CODES)
misreads=False,
ignore=_validator_ignores(plan))
if not problems:
return [], []
# Where the project's own YAML reader refuses the manifest, the words
# are the reader's, and so is the remedy (#708).
remedy = (MANIFEST_UNREADABLE_REMEDIATION
if problems[0][0] == KIND_UNREADABLE
else MANIFEST_INVALID_REMEDIATION)
return ([f"FINDING plan-manifest-invalid: {sentence}"
for _, sentence in problems],
[f"{MANIFEST_INVALID_REMEDIATION} `execute` writes the plan's "
[f"{remedy} `execute` writes the plan's "
"values into the project's `project.yaml` as they stand, so "
"correct each one named here in the plan, or re-run `plan` with "
"a value the validator accepts."])
Expand All @@ -4241,7 +4248,37 @@ def _validator_judged(plan: Plan) -> frozenset[str] | None:
values = _manifest_values(plan, naming)
if values is None:
return None
return manifest_judged(values, naming, ignore=LEG_PATH_CODES)
return manifest_judged(values, naming, ignore=_validator_ignores(plan))


#: The validator's finding about `tracking_branch:` (#455), which `check`
#: leaves to `unsafe-value` wherever that names the branch (#708).
TRACKING_BRANCH_CODES = ("manifest-tracking-branch",)


def _validator_ignores(plan: Plan) -> tuple:
"""The codes of the validator's findings `check` leaves out, because a
rule of its own names the same value: a leg path's always
(`LEG_PATH_CODES`), and the tracking branch's where `checked_value`
refuses that branch, which `_branch_name_findings` reports as
`unsafe-value` (#708).

ONE BAD `tracking_branch` IS ONE FINDING. Since #644 (#455) the
validator finds a NUL or a lone surrogate in it, and `check` asks the
validator, so `tracking_branch: "a\\x00b"` was `unsafe-value`, and
then `plan-manifest-invalid` again in the validator's words, which
name no plan key. No such value is in `checked_value`'s alphabet, so
`unsafe-value`, `execute`'s own refusal of it, names every one, and
the validator's sentence is left out where it does. `checked_value`
alone is asked, the value `execute` reads (`_branch_name_refusal`'s),
because the validator's question starts no process (git's verdict on
a name is `_branch_name_findings`' own).
"""
try:
checked_value(TRACKING_KEY, plan.get(TRACKING_KEY, "main"))
except Refusal:
return LEG_PATH_CODES + TRACKING_BRANCH_CODES
return LEG_PATH_CODES


def _named_as_read(plan: Plan) -> dict[str, str]:
Expand Down
87 changes: 74 additions & 13 deletions scripts/repo_shape.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,8 +179,22 @@ class YamlError(Exception):

Raising rather than guessing is the whole point: a validator that silently
mis-reads its own contract file is worse than one that will not start.

`line` AND `remediation` ARE FOR `load_yaml`, WHICH NAMES A FILE (#708).
Where the fix is one edit of the value, the same in every file, the
refusal carries it (`_indicator_refusal`); every other refusal carries
none. Such a refusal is raised where the value is read, which knows no
line, so the block reader records the line it read the value on
(`_block_value`), and `load_yaml` names it. It stays out of the words:
a tool that asks the reader about ONE rendered line (`k: @a`) quotes
those words in a sentence of its own, where `line 1:` would be noise.
"""

def __init__(self, message: str, remediation: str = ""):
super().__init__(message)
self.line: int | None = None
self.remediation = remediation


# ---------------------------------------------------------------------------
# The YAML subset reader
Expand Down Expand Up @@ -501,13 +515,24 @@ def _quoted_scalar(text: str) -> str:
def _indicator_refusal(text: str) -> YamlError:
"""The refusal of the plain scalar `text`, which opens with an indicator
YAML does not let open one (#392, #393): the indicator, what it does, and
the scalar. One `?` opens an explicit key, refused in #252's words."""
the scalar. One `?` opens an explicit key, refused in #252's words.

IT SAYS WHAT TO DO (#708). The likeliest hand to meet it is an adoption
plan's `resolution:`, which AGENTS.md asks for in words, and a reason
such as `` `adopt-project.py` reads it `` read as text until #392 and
#393. Whatever the file, the value is kept by writing it between double
quotes, spelled by `double_quoted`, the writer this reader takes back
unchanged, or by rewording it, so the refusal carries that remedy."""
char = text[0]
remedy = ("Remediation: unquoted, YAML does not read this value as text, "
"so this reader refuses it rather than guess. Write it in "
f"double quotes, as {double_quoted(text)}, or reword it so that "
"it opens with another character, and run the command again.")
if char == "?":
return YamlError(f"explicit keys are not supported: {text!r}")
return YamlError(f"explicit keys are not supported: {text!r}", remedy)
shown = "a backtick" if char == "`" else f"`{char}`"
return YamlError(f"a plain scalar cannot open with {shown}, which "
f"{_INDICATOR_ROLES[char]} in YAML: {text!r}")
f"{_INDICATOR_ROLES[char]} in YAML: {text!r}", remedy)


def _scalar(raw: str) -> Any:
Expand Down Expand Up @@ -571,7 +596,7 @@ def _refuse_flow_indicator(text: str) -> None:
f"which ends it in YAML: {text!r}")


def _refuse_entry_indicator(text: str) -> None:
def _refuse_entry_indicator(text: str, remedy: bool = True) -> None:
"""Refuse a flow ENTRY -- an item of a flow sequence, or an entry of a
flow mapping, whose key it opens -- that opens with an indicator (#392).

Expand All @@ -592,7 +617,10 @@ def _refuse_entry_indicator(text: str) -> None:
first, after = text[:1], text[1:2]
if (first in ":#@`%" or first in "-?" and after in (" ", "\t")
or text == "?"):
raise _indicator_refusal(text)
refusal = _indicator_refusal(text)
if not remedy:
refusal.remediation = ""
raise refusal


def _flow_value(text: str, item: bool = False) -> Any:
Expand Down Expand Up @@ -679,7 +707,9 @@ def _split_key_flow(text: str) -> tuple[str, str] | None:
refuses both. `_refuse_flow_indicator` refuses them; a quoted key is
still text.
"""
_refuse_entry_indicator(text)
# A MAPPING ENTRY opens its KEY: quoting the whole entry, as the
# remedy would spell it, reads as an entry without a key (#708).
_refuse_entry_indicator(text, remedy=False)
kv = _split_key(text)
if kv is not None and not text.startswith(('"', "'")):
_refuse_flow_indicator(kv[0])
Expand Down Expand Up @@ -921,7 +951,7 @@ def _parse_sequence(recs: list[_Rec], i: int, indent: int) -> tuple[list[Any], i
if not rec.dash and rec.block is None \
and _split_key(rec.text) is None:
# A plain or flow SCALAR item (`- openAvatar`, `- [a, b]`).
value = _flow(rec.text) if rec.text[:1] in "[{" else _scalar(rec.text)
value = _block_value(rec.text, rec.line)
i += 1
else:
value, i = _parse_nodes(recs, i, rec.indent)
Expand Down Expand Up @@ -971,15 +1001,41 @@ def _parse_mapping(recs: list[_Rec], i: int, indent: int) -> tuple[dict, int]:
i += 1
if rest == "":
out[key], i = _nested_value(recs, i, indent)
elif rest[0] in "[{":
out[key] = _flow(rest)
elif rest == "-":
raise _indicator_refusal(rest)
else:
out[key] = _scalar(rest)
out[key] = _block_value(rest, rec.line)
return out, i


def _block_value(text: str, line: int) -> Any:
"""A block mapping's value or a block sequence's scalar item, read on
`line`: a flow collection where `text` opens one, and otherwise a
scalar. A lone `-` is `_parse_mapping`'s refusal, made here: no
sequence item is one, because `_peel_dashes` takes `- -` for two
entries.

THE ONE PLACE A VALUE'S REFUSAL LEARNS ITS LINE (#708). `_scalar` and
`_flow` read text and know no line, so their refusals named the file
and not where in it: an adoption plan whose `resolution:` opened with
a backtick was `REFUSED yaml-unparsable: <plan>: a plain scalar cannot
open with a backtick ...`, among the hundreds of lines a plan holds.
The line is recorded on the refusal (`YamlError.line`), never put in
its words, where the refusal carries a remedy (`_indicator_refusal`'s):
one that tells a person to edit a value says where it is. The others
keep the words they had, which the naming gate's `declared-unverified`
reason and the root's `validate-pins.py` print, and tests quote, whole.
"""
try:
if text[0] in "[{":
return _flow(text)
if text == "-":
raise _indicator_refusal(text)
return _scalar(text)
except YamlError as exc:
if exc.remediation:
exc.line = line
raise


def parse_yaml(text: str) -> Any:
"""Parse the supported YAML subset. Raises `YamlError` on anything else."""
recs = _tokenise(text)
Expand Down Expand Up @@ -1016,7 +1072,12 @@ def load_yaml(path: Path) -> Any:
try:
return parse_yaml(text)
except YamlError as exc:
raise Refusal("yaml-unparsable", f"{path}: {exc}") from exc
# The remedy a value's refusal carries, and the line it was read on
# (#708): with neither, a plan that read as text before #392 and
# #393 ended `check` with a refusal a person could not act on.
where = "" if exc.line is None else f"line {exc.line}: "
raise Refusal("yaml-unparsable", f"{path}: {where}{exc}",
exc.remediation) from exc


# ---------------------------------------------------------------------------
Expand Down
114 changes: 101 additions & 13 deletions scripts/shape_materialize.py
Original file line number Diff line number Diff line change
Expand Up @@ -4681,13 +4681,84 @@ def manifest_invalid(values: dict, policy: NamingPolicy, what: dict,
asked as the project asks it. `values` must be the complete table;
`policy` is the naming policy the project carries a copy of. A WARNING of
the validator (a chain link it cannot check offline) is a note and never
a refusal, exactly as the project's own exit code treats it.
a refusal, exactly as the project's own exit code treats it. Where the
project's own YAML reader refuses the file before the validator can be
asked, each sentence is the reader's, naming the value on the line it
refuses (`_unread_sentences`, #708).
"""
return [sentence for _, sentence in _invalid_problems(
values, policy, what, template_dir, ignore, not_text)]


#: The kinds of problem `manifest_value_problems` names, each spelled once
#: (#708): a value the project's reader reads back as something else, one
#: its validator refuses, and a manifest the reader refuses before the
#: validator can be asked, which `refuse_manifest_values` refuses under the
#: code `invalid` still.
KIND_MISREAD, KIND_INVALID, KIND_UNREADABLE = (
"misread", "invalid", "unreadable")


def _invalid_problems(values: dict, policy: NamingPolicy, what: dict,
template_dir: Path, ignore: tuple,
not_text: tuple) -> list[tuple[str, str]]:
"""`manifest_invalid`'s sentences, each with its kind: `invalid` for a
finding of the validator's, and `unreadable` where the project's own
YAML reader refuses the file the validator would read (#708), whose
words are the reader's and whose remedy is its own
(`MANIFEST_UNREADABLE_REMEDIATION`)."""
notes = _validator_notes(values, policy, template_dir, ignore)
if isinstance(notes, YamlError):
return [(KIND_UNREADABLE, sentence) for sentence
in _unread_sentences(values, what, template_dir, notes)]
return [(KIND_INVALID, sentence)
for sentence in _sentences_of(notes, values, what, not_text)]


#: The placeholders that carry a value of `what` into the manifest where no
#: line holds that value's own placeholder (#708): the organisation is the
#: owner half of each leg's `repository:`, so the reader's refusal of such a
#: line is `--org`'s, as the validator's finding about it is
#: (`FINDING_PLACEHOLDER`'s `manifest-leg-repository`).
CARRIED_BY = {"ASSEMBLY_REPOSITORY": "ORG", "SPEC_REPOSITORY": "ORG",
"CODE_REPOSITORY": "ORG"}


def _unread_sentences(values: dict, what: dict, template_dir: Path,
refusal: YamlError) -> list[str]:
"""A sentence for each value of `what` that a line of the manifest the
project's own reader refuses carries, in the reader's words, and the
reader's `refusal` of the whole file where no such line is found.

THE VALUE IS NAMED AGAIN (#708). Until #392 and #393 the reader took
`repository: @x/Atlas` for text, so `--org @x` reached the validator,
whose `manifest-leg-repository` names `--org`. Since then the reader
refuses the file first, and the sentence named nothing: `plan --org
@x` (a typo of `@opensoft`, where GitHub owners never begin with `@`)
said only that the manifest was a file the reader refuses. So each
template line that carries a value of `what` (by its own placeholder,
or by one `CARRIED_BY` names) is rendered and read alone, as
`_misread_rows` reads one, and a value is named where the reader
refuses one of its lines.
"""
said: dict[str, list[str]] = {}
template = (Path(template_dir) / MANIFEST_TEMPLATE).read_text(
encoding="utf-8")
for line in template.splitlines():
carried = {CARRIED_BY.get(key, key) for key in _placeholders_of(line)}
named = [key for key in what if key in carried and key in values]
if not named or line.lstrip().startswith("#"):
continue
reading = _reading_of(render(line, values, MANIFEST_TEMPLATE))
if isinstance(reading, YamlError):
for key in named:
said.setdefault(key, []).append(f"{reading}.")
if not said:
return ["the manifest this run would write is a file the project's "
f"own YAML reader refuses: {notes}."]
return _sentences_of(notes, values, what, not_text)
f"own YAML reader refuses: {refusal}."]
return [f"{what[key]} is {str(values[key])!r}, which the project's own "
f"YAML reader refuses: {' '.join(words)}"
for key, words in said.items()]


def _validator_notes(values: dict, policy: NamingPolicy, template_dir: Path,
Expand Down Expand Up @@ -4765,6 +4836,21 @@ def manifest_judged(values: dict, policy: NamingPolicy,
"Remediation: choose a value the project's own validate-manifest.py "
"accepts; the words after each value are the validator's own, and a "
"project it refuses is one whose first bootstrap would fail.")
#: Where the project's own YAML reader refuses the manifest before its
#: validator can be asked (#708), the words are the reader's, and a sentence
#: may name no value (`_unread_sentences`), so the remedy says neither that
#: they are the validator's nor that a value is always named.
MANIFEST_UNREADABLE_REMEDIATION = (
"Remediation: choose another value for each one named; where none is "
"named, the text the reader's words quote holds it. The words are the "
"project's own YAML reader's, which refuses the manifest this run would "
"write before its validate-manifest.py can be asked, and a project "
"whose manifest it refuses is one whose first bootstrap would fail.")
#: The remedy for each kind of problem, so a refusal names it by its kind
#: (#708), with no conditional nested in another.
KIND_REMEDIATIONS = {KIND_MISREAD: MANIFEST_MISREAD_REMEDIATION,
KIND_INVALID: MANIFEST_INVALID_REMEDIATION,
KIND_UNREADABLE: MANIFEST_UNREADABLE_REMEDIATION}


def manifest_value_problems(values: dict, policy: NamingPolicy, what: dict,
Expand All @@ -4773,7 +4859,8 @@ def manifest_value_problems(values: dict, policy: NamingPolicy, what: dict,
template_dir: Path = ASSEMBLY_TEMPLATE
) -> list[tuple[str, str]]:
"""`(kind, sentence)` for everything wrong with the values, `kind` being
`misread` or `invalid`; `[]` when the project would hold every one.
`misread`, `invalid` or `unreadable` (`_invalid_problems`); `[]` when
the project would hold every one.

A misread comes FIRST and alone: a value the reader turns into something
else makes the manifest invalid in ways that are its consequence (`id:
Expand All @@ -4784,10 +4871,9 @@ def manifest_value_problems(values: dict, policy: NamingPolicy, what: dict,
if misreads:
misread = manifest_misreads(values, what, template_dir)
if misread:
return [("misread", sentence) for sentence in misread]
return [("invalid", sentence) for sentence
in manifest_invalid(values, policy, what, template_dir, ignore,
not_text)]
return [(KIND_MISREAD, sentence) for sentence in misread]
return _invalid_problems(values, policy, what, template_dir, ignore,
not_text)


def refuse_manifest_values(prefix: str, values: dict, policy: NamingPolicy,
Expand All @@ -4796,16 +4882,18 @@ def refuse_manifest_values(prefix: str, values: dict, policy: NamingPolicy,
"""Raise `Refusal(<prefix>-manifest-misread)` or `<prefix>-manifest-
invalid`, naming every value, when the project could not hold them; the
caller's own `prefix` (`scaffold`, `adopt`) says which tool refused, and
its `tail` finishes the remediation with what only it knows."""
its `tail` finishes the remediation with what only it knows. A manifest
the project's reader refuses is `-invalid` still, the code a value it
refuses had when the validator was the one to refuse it, with the
reader's remedy (#708)."""
problems = manifest_value_problems(values, policy, what,
misreads=misreads)
if problems:
kind = problems[0][0]
remedy = (MANIFEST_MISREAD_REMEDIATION if kind == "misread"
else MANIFEST_INVALID_REMEDIATION)
raise Refusal(f"{prefix}-manifest-{kind}",
code = KIND_MISREAD if kind == KIND_MISREAD else KIND_INVALID
raise Refusal(f"{prefix}-manifest-{code}",
" ".join(sentence for _, sentence in problems),
f"{remedy} {tail}")
f"{KIND_REMEDIATIONS[kind]} {tail}")


# ---------------------------------------------------------------------------
Expand Down
4 changes: 3 additions & 1 deletion tests/test_adopt_plan_yaml_quoting.py
Original file line number Diff line number Diff line change
Expand Up @@ -1694,7 +1694,9 @@ def test_a_manifest_its_reader_refuses_is_the_validators_alone(
"write is a file the project's own YAML reader "
"refuses: "), stderr
assert stderr.count("Remediation: ") == 1, stderr
assert VALIDATOR_REMEDIATION_START in stderr, stderr
# The words are the reader's, and since #708 so is the remedy.
assert adopter.MANIFEST_UNREADABLE_REMEDIATION in stderr, stderr
assert VALIDATOR_REMEDIATION_START not in stderr, stderr


#: A leg path the validator refuses as text, which its finding about the
Expand Down
Loading
Loading