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
14 changes: 8 additions & 6 deletions adopt-project.py
Original file line number Diff line number Diff line change
Expand Up @@ -6236,17 +6236,19 @@ def _failed_loudly(step: str, exc: BaseException) -> str:
"""What failed during `step`, as the REFUSED block `CommandFailed` prints.

`CommandFailed` says it itself. A `Refusal` keeps its code and detail,
and its own remediation unless it is the validators' generic one, which
`git_out` hands every refusal it raises and which is about pins: here it
would contradict the notice printed under it. Anything else is named by
its type, and is a REFUSAL only if it is one of `LATE_FAILURES`; the rest
STOPPED the run, and their traceback follows.
and its own remediation if it has one and it is not the validators'
generic one, which is about pins: here it would contradict the notice
printed under it. `git_out` handed every refusal it raised that one, and
hands them none since #370, which is no line at all rather than an empty
one. Anything else is named by its type, and is a REFUSAL only if it is
one of `LATE_FAILURES`; the rest STOPPED the run, and their traceback
follows.
"""
if isinstance(exc, CommandFailed):
return exc.loudly(step)
if isinstance(exc, Refusal):
said = f"REFUSED {step}. {exc.code}: {exc.detail}"
return said if exc.remediation == REMEDIATION \
return said if exc.remediation in ("", REMEDIATION) \
else f"{said}\n{exc.remediation}"
what = f"{type(exc).__name__}: {exc}" if str(exc) else type(exc).__name__
if isinstance(exc, LATE_FAILURES):
Expand Down
45 changes: 38 additions & 7 deletions scripts/family.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,20 @@
#: -- named once so the two commits cannot spell the file differently.
GITMODULES = ".gitmodules"
FILE_PROTOCOL = ["-c", "protocol.file.allow=always"]
#: What `add` says when git cannot read the holder's `.gitmodules` (#370), in
#: the words the holder's own `validate-family.py` says it about the same
#: file, under the same code, `gitmodules-unreadable` (#300): both read it as
#: git does, so one fault has one name and one remedy. `repo_shape`'s
#: `git-failed` was all `add` said before, under the lockstep remedy of an
#: assembly root's legs. A SECOND SPELLING, not an import: the validator is
#: the holder's copy. `tests/test_refusal_default_remediation.py` runs both
#: over one file and fails when they differ.
GITMODULES_UNREADABLE = (
"Remediation: git cannot read it either, so none of this holder's "
"submodules can be cloned from it as it stands. Mend the file by hand "
f"(`git config -f {GITMODULES} --list` says where it breaks), then run "
"this again.")

#: What `git symbolic-ref HEAD` prints before a branch's name: the one
#: spelling `_holder_branch` and `_member_head` strip to read a HEAD back as
#: the branch it is on, named once so the two cannot differ.
Expand Down Expand Up @@ -1854,7 +1868,7 @@
return entries


def _submodule_paths(

Check failure on line 1871 in scripts/family.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 20 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=opensoft_openRepoShape&issues=AaEyS6ZB5AOFwQ9qDBq4&open=AaEyS6ZB5AOFwQ9qDBq4&pullRequest=494
root: Path) -> dict[str, tuple[str, str | None, tuple[str, ...]]]:
"""`{name: (path, url, keys)}` of every submodule the holder's
`.gitmodules` names, read from where `git submodule add` reads it: the
Expand All @@ -1880,6 +1894,7 @@
read it through `git config --list`, never through a parser of this
tool's.
"""
spec = None
if os.path.lexists(root / GITMODULES):
source = ["--file", GITMODULES]
else:
Expand All @@ -1892,8 +1907,23 @@
continue
else:
return {}
listed = git_out(["config", "-z", *source, "--list"], cwd=root,
binary=True)
try:
listed = git_out(["config", "-z", *source, "--list"], cwd=root,
binary=True)
except Refusal as exc:
remedy = GITMODULES_UNREADABLE
if spec is not None:
# NONE IN THE WORKING TREE: git read the index's copy, or HEAD's,
# and that copy, put back, is the file to mend.
index = spec.startswith(":")
remedy += (f" With no {GITMODULES} in the working tree, git read "
f"{'the index' if index else 'HEAD'}'s copy: put it "
f"back first, `git checkout {'' if index else 'HEAD '}"
f"-- {GITMODULES}`.")
raise Refusal(
"gitmodules-unreadable",
f"{GITMODULES} could not be read as git reads it: {exc.detail}",
remedy) from exc
found: dict[str, dict[str, str]] = {}
for record in listed.split(b"\0"):
key, _, value = record.decode("utf-8",
Expand Down Expand Up @@ -3898,12 +3928,13 @@
as bytes for the reason `_git_input` gives; a failure is a
`CommandFailed`.

WHAT THE DRY RUNS ASK GIT WITH (#326). `git_out`'s failure is a
`Refusal` that carries `repo_shape`'s default remediation, which is a
WHAT THE DRY RUNS ASK GIT WITH (#326). `git_out`'s failure was a
`Refusal` that carried `repo_shape`'s default remediation, which was a
LEG's (run `validate-pins.py`, advance `contracts/<leg>-pin.yaml`), and
a holder has neither. A `CommandFailed` is printed as every failed git
command in this file is: the exact command, where, and git's own words,
exit 2, which is how the real run reports the same failure.
a holder has neither; since #370 it carries none. A `CommandFailed` is
printed as every failed git command in this file is: the exact command,
where, and git's own words, exit 2, which is how the real run reports the
same failure.
"""
command = ["git", *args]
check_program(command)
Expand Down
28 changes: 23 additions & 5 deletions scripts/repo_shape.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,11 @@
- `same_repository` and the remote-url arithmetic under it — the ONE
definition of whether two remote spellings name one repository, which
`shape-doctor.py` reports by and `scripts/siblings.py` refuses by.
- `Refusal` — the fail-closed exception every validator raises, carrying a
remediation string, because a refusal that names what is wrong without
naming what to run puts the exit in tribal memory instead of the message.
- `Refusal` — the fail-closed exception every validator raises, carrying
the remediation its caller names, because a refusal that names what is
wrong without naming what to run puts the exit in tribal memory instead
of the message. Where the caller names none it carries none (#370):
a wrong remedy is worse than the `REFUSED` line alone.
- `spelled` and `escape_unencodable_stdout` — how both roots'
`bootstrap.py` print a person's text: what a terminal must not be handed
raw is escaped, and a stdout that cannot encode it never raises (#391).
Expand Down Expand Up @@ -130,6 +132,14 @@
#: happened to run.
PYTHON = "python" if os.name == "nt" else "python3"

#: THE ASSEMBLY ROOT'S LOCKSTEP REMEDY, and no longer every refusal's (#370).
#: It names `validate-pins.py`, a leg's gitlink and `contracts/<leg>-pin.yaml`,
#: so it is right where an assembly root's legs are the question, and that
#: is `validate-pins.py`, which prints it under each refusal of its own that
#: names no other. As `Refusal`'s default it reached every tool: a family
#: holder, which has no legs and no `validate-pins.py`, was told to run that
#: file and move a leg pin, and so was everyone refused over an adoption
#: plan, a shape pin or a naming policy.
REMEDIATION = (
"Remediation: run `git submodule update --init --recursive`, then "
f"`{PYTHON} scripts/validate-pins.py`. If the PIN itself is stale, advance "
Expand All @@ -144,16 +154,24 @@ class Refusal(Exception):
`code` is machine-readable and `detail` is for humans; `str(exc)` renders
code, detail and the remediation trailer together, so a caller that prints
the exception cannot accidentally drop the remedy.

NO REMEDY IS ASSUMED (#370). A refusal built without one prints its
`REFUSED` line alone, which already says what failed and where, because
this module cannot know which tool, or which kind of root, it refuses
for: a default here was one tool's remedy printed under all of them. A
caller with a remedy to name passes it; `validate-pins.py` gives its own
refusals the lockstep one, `REMEDIATION`.
"""

def __init__(self, code: str, detail: str, remediation: str = REMEDIATION):
def __init__(self, code: str, detail: str, remediation: str = ""):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restore NEXT commands for remedy-free doctor findings

When a doctor check propagates a shared Refusal that does not explicitly supply remediation (for example, a git_out failure), this empty default flows through shape-doctor.py::run_checks as exc.remediation or None; the row remains a FINDING but has no NEXT command, and the new test explicitly locks in that result. Supply a doctor-specific fallback or contextual remediation rather than omitting NEXT, because the doctor contract requires every finding to carry the exact next command.

AGENTS.md reference: AGENTS.md:L226-L228

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(lane openRepoShape-3, 2026-10-12T23:47Z) An independent review checked this and does not take it, for four reasons:

  1. Main's NEXT here was not a command. It was the assembly root's prose (run git submodule update --init --recursive, then validate-pins.py), and the catch-all runs for every kind of root. On a family holder or a non-root that script does not exist, and on an assembly root obeying it detaches the legs for an unrelated fault.
  2. The doctor's own rulings prefer no NEXT to a wrong one (shape-doctor.py: the legs row's NEXT (and the members row's) runs <root>/scripts/bootstrap.py without asking whether the root has it, so a root that lost it pastes python3: can't open file, exit 2, and for an empty leg mount the legs NEXT would not cure the row even with the file there (bootstrap.py only prints Run git submodule update --init spec) #276, shape-doctor.py: validator_row reads a validator's exit 1 as "reports a finding" whatever caused it, so a validator that CRASHED (a : in the standard's path splits the PYTHONPATH the template fallback runs under, and validate-manifest.py dies on import repo_shape) is a FINDING row quoting ModuleNotFoundError, and a root whose manifest is valid goes from COMPLIANT, SHAPE BEHIND to INVALID (manifest) #278), and the open shape-doctor.py: a NEXT is only the command, and the sentence that ended twenty-five of them after # is the row's why, on a WHY line of its own and "why" in --json, so a NEXT pasted into an interactive zsh -- which reads that # as a word -- no longer hands the comment to the command as arguments, runs what follows its ;, or runs the make bootstrap in its backticks, and the template validator's line is env PYTHONPATH=... python3 ... (#277) #402 requires NEXT to be a bare command.
  3. Twelve ordinary breakages of a scaffolded root (leg dir or .git removed, unparsable .gitmodules or pins, broken index, HEAD or objects in the root and the leg) never reach the catch-all, on main or on this head. Every row's NEXT is the same.
  4. The catch-all only catches refusals nobody anticipated, so any generic fallback would be a made-up command.

Keeping exc.remediation or None.

super().__init__(code, detail)
self.code = code
self.detail = detail
self.remediation = remediation

def __str__(self) -> str: # pragma: no cover - trivial
return f"REFUSED {self.code}: {self.detail}\n{self.remediation}"
line = f"REFUSED {self.code}: {self.detail}"
return f"{line}\n{self.remediation}" if self.remediation else line


class YamlError(Exception):
Expand Down
23 changes: 14 additions & 9 deletions shape-doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -1549,9 +1549,13 @@ def refusal_line(refusal: Refusal) -> str:
the text up to its first newline: `detail` opens with the path the
loader was given, and a root whose directory name holds a newline would
be cut after its first line and quoted short -- a different report from
the one this row has always printed, with no error to say so.
the one this row has always printed, with no error to say so. A refusal
with no remedy has no trailer (#370), and its `str()` is the line whole.
"""
return str(refusal).removesuffix("\n" + refusal.remediation)
line = str(refusal)
if not refusal.remediation:
return line
return line.removesuffix("\n" + refusal.remediation)


def unloaded_manifest_row(ctx: Context, check_id: str, label: str, own: str,
Expand All @@ -1568,9 +1572,10 @@ def unloaded_manifest_row(ctx: Context, check_id: str, label: str, own: str,
validator line is cut at 200 characters and this one ends with the line
number, after a path of any length.

NOT THE REFUSAL'S REMEDIATION. `yaml-unparsable` carries `repo_shape`'s
NOT THE REFUSAL'S REMEDIATION. `yaml-unparsable` carried `repo_shape`'s
default one, which is about submodules and the lockstep invariant: it
would send a person resolving a merge conflict to `git submodule update`.
sent a person resolving a merge conflict to `git submodule update`. It
carries none since #370, and the row names the gate either way.

THE NEXT COMMAND IS THE GATE THAT OWNS THE FILE, picked by
`validator_for` exactly as the row would have run it, AND IT NAMES THE
Expand Down Expand Up @@ -3123,11 +3128,11 @@ def unreadable_pin_row(ctx: Context, exc: Refusal) -> Row:
is what `read_pin` raised, in the spelling the project's own validator
prints for the same file, and it names the line or the field at fault.
Its `Remediation:` trailer is left off on purpose: `yaml-unparsable`
carries `repo_shape`'s default one, about submodules and the lockstep
invariant, which would send a person resolving a merge conflict to `git
submodule update`. This is the wording #232's `unloaded_manifest_row`
gives a `project.yaml` that does not load, so the two files are answered
alike.
carried `repo_shape`'s default one, about submodules and the lockstep
invariant, which sent a person resolving a merge conflict to `git
submodule update` (it carries none since #370). This is the wording
#232's `unloaded_manifest_row` gives a `project.yaml` that does not
load, so the two files are answered alike.

THE NEXT PUTS BACK THE PIN A TOOL WROTE, NEVER A FETCH. `pin_restore`
names the git command from the pin's own state, and run as printed it
Expand Down
13 changes: 12 additions & 1 deletion templates/assembly-root/scripts/validate-pins.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@
pin_source_env_collisions, pin_source_env_name, pin_source_env_shared,
recorded_gitlink, repo_basename, tree_digest, tree_digest_from_gh,
)
#: The lockstep remedy, which is this validator's own (#370): see `main`.
from repo_shape import REMEDIATION # noqa: E402

# `PIN_SOURCE_ENV_PREFIX` / `pin_source_env_name` live in `repo_shape` because
# the SAME checkout answers two questions about a neutral product: the digest
Expand Down Expand Up @@ -1171,7 +1173,16 @@ def main(argv: list[str] | None = None) -> int:
root = find_repo_root(args.root or Path(__file__).resolve().parents[1])
_run_all_checks(root, report, args.pin_source)
except Refusal as exc:
print(str(exc), file=sys.stderr)
# THE LOCKSTEP REMEDY IS THIS VALIDATOR'S (#370). `repo_shape` gives a
# refusal built without a remedy none, because it cannot know whose
# it is; here it is the assembly root's, and every refusal this
# prints that names no remedy of its own -- its own, and those
# `load_yaml`, `git_out` and `find_repo_root` raise for it -- gets
# `REMEDIATION`, exactly as it always did. Only that name is
# imported, which every `repo_shape.py` a root has ever been given
# defines, and one that still defaults to it prints it the same way.
print(exc if exc.remediation else f"{exc}\n{REMEDIATION}",
file=sys.stderr)
return 2

return _emit_report(report, args.quiet)
Expand Down
Loading
Loading