diff --git a/README.md b/README.md index a772af99..fba86cc0 100644 --- a/README.md +++ b/README.md @@ -1210,7 +1210,7 @@ one verdict, and its exit code: | `NOT A SHAPE ROOT` | 2 | neither manifest is there — adopt, or scaffold | Exit 3 is usage or environment (a flag argparse rejects is exit 2) — nothing about -it is a statement about the tree you pointed at: no such path, a refused +it is a statement about the tree you pointed at: no such path, a path this user cannot search, a refused `--placement-plan`, a `shape-doctor.py` outside a checkout of the standard, or **this** checkout unable to answer (`CANNOT ANSWER`): a pin's commit it lacks, a partial clone missing what is compared, a read that fails, a validator of its diff --git a/docs/cli.md b/docs/cli.md index ab6258e3..0fc1cc84 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -182,11 +182,12 @@ over (default `.`) and prints one row per check and ONE verdict: `COMPLIANT`, `COMPLIANT, SHAPE BEHIND`, `DRIFTED`, `MISPLACED`, `INVALID`, `NOT A SHAPE ROOT` or `CANNOT ANSWER`. Every finding names the command that fixes it. Exit 0 compliant, 1 findings, 2 not a shape root, 3 when it cannot -answer (`CANNOT ANSWER`, no such , a `--placement-plan` it cannot -write); this command's own refusals exit 2. `--json` is the same report as -one JSON object. The doctor writes no file but the `--placement-plan ` -you name, at the path you give (a relative lands in the directory you -ran this from, even inside the repository), and moves nothing. +answer (`CANNOT ANSWER`, no such , a this user cannot search, a +`--placement-plan` it cannot write); this command's own refusals exit 2. +`--json` is the same report as one JSON object. The doctor writes no file but +the `--placement-plan ` you name, at the path you give (a relative + lands in the directory you ran this from, even inside the repository), +and moves nothing. Run beside a checkout of the standard, it uses that one. Run from anywhere else, it clones the standard into a temporary directory first and KEEPS it, diff --git a/docs/handbook.html b/docs/handbook.html index 0f2be244..d526011d 100644 --- a/docs/handbook.html +++ b/docs/handbook.html @@ -1548,7 +1548,7 @@

Bootstrap is COPIED into the project, not fetched

- +

Reference

Keeping a project’s shape current

@@ -1610,7 +1610,7 @@

Keeping a project’s shape current

- +

Reference

Is this repository compliant? openRepoShape --doctor

@@ -1671,7 +1671,7 @@

Is this repository compliant? openRepoShape --doctor

Exit 3 is usage or environment (a flag argparse rejects is exit 2) — nothing about - it is a statement about the tree you pointed at: no such path, a refused + it is a statement about the tree you pointed at: no such path, a path this user cannot search, a refused --placement-plan, a shape-doctor.py not in a checkout of the standard, or this checkout unable to answer (CANNOT ANSWER): a pin’s commit it lacks, a partial clone missing what is compared, a read that fails, a diff --git a/openRepoShape b/openRepoShape index 661796d2..f0d75740 100755 --- a/openRepoShape +++ b/openRepoShape @@ -145,11 +145,12 @@ over (default `.`) and prints one row per check and ONE verdict: `COMPLIANT`, `COMPLIANT, SHAPE BEHIND`, `DRIFTED`, `MISPLACED`, `INVALID`, `NOT A SHAPE ROOT` or `CANNOT ANSWER`. Every finding names the command that fixes it. Exit 0 compliant, 1 findings, 2 not a shape root, 3 when it cannot -answer (`CANNOT ANSWER`, no such , a `--placement-plan` it cannot -write); this command's own refusals exit 2. `--json` is the same report as -one JSON object. The doctor writes no file but the `--placement-plan ` -you name, at the path you give (a relative lands in the directory you -ran this from, even inside the repository), and moves nothing. +answer (`CANNOT ANSWER`, no such , a this user cannot search, a +`--placement-plan` it cannot write); this command's own refusals exit 2. +`--json` is the same report as one JSON object. The doctor writes no file but +the `--placement-plan ` you name, at the path you give (a relative + lands in the directory you ran this from, even inside the repository), +and moves nothing. Run beside a checkout of the standard, it uses that one. Run from anywhere else, it clones the standard into a temporary directory first and KEEPS it, diff --git a/scripts/shape_advisory.py b/scripts/shape_advisory.py index 56e365e9..1d6254fd 100644 --- a/scripts/shape_advisory.py +++ b/scripts/shape_advisory.py @@ -89,8 +89,10 @@ from __future__ import annotations import datetime as _dt +import errno import os import re +import stat import sys from pathlib import Path @@ -326,6 +328,41 @@ def as_dict(self) -> dict: "leg": self.leg} +#: The `OSError`s that mean NOTHING IS THERE, where any other is a `stat` the +#: operating system refused: no such file, a part of the path that is not a +#: directory, a bad descriptor, a symlink loop, and Windows' "not ready", +#: "invalid name" and "cannot resolve" -- the ones `Path.is_file()` answered +#: False to on Python 3.9 to 3.13. The same set as `validate-pins.py`'s +#: `_ABSENT_ERRNOS` and `shape-doctor.py`'s `ABSENT_ERRNOS` (#430's rule). +#: Kept here, not in `repo_shape.py`: that file is a shape copy every root +#: pins, and this one is not. +_ABSENT_ERRNOS = (errno.ENOENT, errno.ENOTDIR, errno.EBADF, errno.ELOOP) +_ABSENT_WINERRORS = (21, 123, 1921) + + +def _is_a(path: Path, kind) -> bool: + """Whether `path` is there and of `kind` (`stat.S_ISREG`, or any mode), + raising the `OSError` of a `stat` the operating system REFUSED, as + `validate-pins.py`'s `_is_a` asks it (#430). + + NOT `Path.is_file()` OR `Path.exists()`, whose answer depends on the + Python: up to 3.13 they raise a refused `stat`, and from 3.14 they answer + False to any `OSError`, so a `single-repository.yaml` in a folder this + user cannot search read as no record at all, and the advisory said + nothing of a file it could not read. A name no system takes is nothing + there, as `is_file()` answered it.""" + try: + mode = path.stat().st_mode + except OSError as exc: + if (exc.errno in _ABSENT_ERRNOS + or getattr(exc, "winerror", None) in _ABSENT_WINERRORS): + return False + raise + except ValueError: + return False + return kind(mode) + + class Tree: """The files of one repository's root, read from the disk or a commit. @@ -342,7 +379,9 @@ class Tree: file, or None when there is no file to read. An undecodable file RAISES `UnicodeDecodeError`, because "it is there and could not be read" is a different answer from "it is not there", and the record reports the - first. + first. So does a file whose `stat` the operating system REFUSES: `kind()` + raises it (`_is_a`, #430), and every reader's net reads the file as there + and unreadable, on every Python. """ def __init__(self, root: Path, commit: str | None = None): @@ -352,9 +391,9 @@ def __init__(self, root: Path, commit: str | None = None): def kind(self, rel: str) -> str | None: if self.commit is None: path = self.root / rel - if path.is_file(): - return "file" - return "dir" if path.exists() else None + if not _is_a(path, lambda _mode: True): + return None + return "file" if _is_a(path, stat.S_ISREG) else "dir" try: found = git_out(["cat-file", "-t", f"{self.commit}:{rel}"], cwd=self.root) diff --git a/shape-doctor.py b/shape-doctor.py index 5b76181a..5c7a6be2 100755 --- a/shape-doctor.py +++ b/shape-doctor.py @@ -319,8 +319,9 @@ file that is gone 2 NOT A SHAPE ROOT: neither `project.yaml` nor `family.yaml` is there 3 usage or environment -- nothing here is a statement about the tree - that was pointed at: no such root, this file is not sitting in a - checkout of the standard, or THIS CHECKOUT cannot answer (a pin + that was pointed at: no such root, a root this user cannot search + (#430), this file is not sitting in a checkout of the standard, or + THIS CHECKOUT cannot answer (a pin naming a commit it does not carry; a partial clone lacking what the comparison reads, #683; a validator of its own that crashed or cannot be given its `PYTHONPATH`, its template copy or its naming @@ -1995,9 +1996,21 @@ def validator_for(ctx: Context, own: str, template: str) -> tuple: it is returned here, from the one place that made the choice, instead of being re-derived from the path by a caller (#238). It is also the row's `detail.own_copy`. + + AN OWN COPY THIS USER CANNOT REACH IS NOT ONE THE ROOT LACKS (#430). It + was asked `own_path.is_file()`, which up to Python 3.13 raised a refused + `stat` out of the row (`could not be run: [Errno 13] ...`) and from 3.14 + answers False: a `scripts/` this user cannot search then ran the + standard's TEMPLATE in the project's place and printed `ok manifest the + standard's templates/assembly-root/scripts/validate-manifest.py passes`, + a pass of a gate the project does not have, over its own that nobody + ran. It is asked as `validate-pins.py` asks it (`is_a`), and a refused + `stat` is RAISED, on every Python: each caller names the copy + (`unreadable_gate_row`) or names no line (`gate_line`). The template + stands in only for a copy that is not there at all. """ own_path = ctx.root / own - if own_path.is_file(): + if is_a(own_path, stat.S_ISREG): return own_path, f"the project's own {own}", True return ctx.shape / template, f"the standard's {template}", False @@ -2138,6 +2151,25 @@ def lost_repo_shape_cure(ctx: Context, own: str, lost: Path, rerun: str, f"the commit that made it. Then {then}") +def unreadable_gate_row(ctx: Context, check_id: str, label: str, own: str, + exc: OSError, said: str = "") -> Row: + """The row of a validator whose OWN copy, `own`, this user cannot reach + (#430): a FINDING about the repository, in `validate-pins.py`'s words for + the file (`cannot_be_read`), with this doctor again as the NEXT and the + gate's advice for the cause as the WHY (`cannot_read_why`), as #847's + rows give them for a pin. NOTHING IS RUN: not the copy, which cannot be + opened, and not the standard's template in its place, which is not this + project's gate (`validator_for`). `said` is what the row says first, + where it has more to say (`unloaded_manifest_row`).""" + line = cannot_be_read(own, exc) + return Row(check_id, label, FINDING, + f"{said}{line}, so the project's own gate was not run, and the " + "standard's copy is not run in its place", doctor_line(ctx), + {"validator": (ctx.root / own).as_posix(), "own_copy": True, + "exit": None, "cannot_be_read": line}, + why=cannot_read_why([exc])) + + def validator_row(ctx: Context, check_id: str, label: str, own: str, template: str, args: list[str], *, output: list[str] | None = None) -> Row: @@ -2194,8 +2226,15 @@ def validator_row(ctx: Context, check_id: str, label: str, own: str, (`skipped`, `warnings`, `notes`, from `PASS_REPORTS`), quoted in the reason in the order printed (`passed_but`), and no NEXT -- each names its own remedies, and which declaration stays is the human's call. + + AN OWN COPY THIS USER CANNOT REACH IS NAMED AND NOTHING IS RUN (#430): + `validator_for` raises its refused `stat`, and the row is + `unreadable_gate_row`'s. """ - used, which, own_copy = validator_for(ctx, own, template) + try: + used, which, own_copy = validator_for(ctx, own, template) + except OSError as exc: + return unreadable_gate_row(ctx, check_id, label, own, exc) if not used.is_file(): return Row(check_id, label, FINDING, f"neither {own} nor {template} is readable, so the " @@ -2359,14 +2398,25 @@ def unloaded_manifest_row(ctx: Context, check_id: str, label: str, own: str, when pasted. Only the template copy can be refused that way: the root's own copy needs no `PYTHONPATH`, and `validator_for` names it whenever the root has one. + + AND AN OWN GATE THIS USER CANNOT REACH IS NAMED BESIDE THE REFUSAL + (#430), as `validator_row` names it, and no line runs it or the template + in its place: the person makes it readable, and the doctor names the + gate's line once it can. """ refusal = ctx.manifest_refusal name = ctx.manifest_path.name - used, which, own_copy = validator_for(ctx, own, template) reason = f"{name} does not load: {refusal_line(refusal)}" - detail = {"validator": used.as_posix(), "own_copy": own_copy, - "manifest": name, - "refusal": {"code": refusal.code, "detail": refusal.detail}} + refused = {"manifest": name, + "refusal": {"code": refusal.code, "detail": refusal.detail}} + try: + used, which, own_copy = validator_for(ctx, own, template) + except OSError as exc: + row = unreadable_gate_row(ctx, check_id, label, own, exc, + f"{reason}; ") + row.detail.update(refused) + return row + detail = {"validator": used.as_posix(), "own_copy": own_copy, **refused} if not used.is_file(): # Neither copy to name: `validator_row` already says so, for any # root, and its next command is the one this row then needs. @@ -3339,6 +3389,75 @@ def cannot_be_listed(named: str, exc: OSError) -> str: return f"{named}: cannot be listed ({os_reason(exc)})" +#: The key of a leg's or a member's `--json` entry that holds the gate's line +#: for a mount this user cannot reach (#430), beside a leg's `cannot_be_read`, +#: which is its PIN's (#847). +MOUNT_UNREAD = "mount_cannot_be_read" + + +def mount_read(root: Path, rel: str) -> tuple: + """`(populated, own_git, refused)` for the leg or member mounted at `rel`: + whether a directory with anything in it is there, whether it holds a + `.git` of its own, and None, or `(the gate's line, the OSError, whether it + was the listing)` where the operating system refused the mount's `stat`, + its listing or its `.git`'s `stat` (#430). + + ONE QUESTION FOR THE FOUR ROWS THAT READ A MOUNT -- `legs`, `leg shape + files`, `placement` and `members` -- each of which asked `mount.is_dir() + and any(mount.iterdir())`, and two of them `(mount / ".git").exists()`. + Up to Python 3.13 a mount this user cannot enter raised out of the row, + `could not be run: [Errno 13] ...`, and the verdict counted the empty + `legs` row as a leg off its pin; from 3.14 the same mount was empty, an + unbootstrapped submodule, or one with no `.git` of its own, and a checkout + was named over it. Asked here as `validate-pins.py` asks a path (`is_a`), + a refused read is said in the gate's words on every Python: `: + cannot be listed ()` for a mount it cannot list, `: cannot + be read ()` for one whose own `stat` is refused (a folder above + it this user cannot search), and `/.git: cannot be read ()` + for one it can list and not search, which is the line the gate refuses + that mount with (`pin-submodule-unreadable`). + + A MOUNT WITH NOTHING IN IT HAS NO `.git`: its listing says so, so `.git` + is asked only of a mount with something in it, which is the answer + `(mount / ".git").exists()` gave an empty, absent or non-folder mount. A + listing that fails because nothing is there any more is nothing there. + """ + mount = root / rel + try: + if not is_a(mount, stat.S_ISDIR): + return False, False, None + except OSError as exc: + return False, False, (cannot_be_read(rel, exc), exc, False) + try: + populated = any(mount.iterdir()) + except OSError as exc: + if refused_read(exc) is None: + return False, False, None + return False, False, (cannot_be_listed(rel, exc), exc, True) + if not populated: + return False, False, None + try: + return True, is_a(mount / ".git", lambda _mode: True), None + except OSError as exc: + return True, False, (cannot_be_read((Path(rel) / ".git").as_posix(), + exc), exc, False) + + +def mount_unread_said(line: str) -> str: + """What the `legs` and `members` rows say of a mount this user cannot + reach (#430), after the gate's line for it: no HEAD is read in it, so + nothing says whether it is at its pin.""" + return f"{line}, so no HEAD in it is compared with the pin" + + +def mount_refusals(refused: list) -> str: + """The gate's advice for the mounts `mount_read` was refused, each a + `(line, OSError, listed)` (#430): `cannot_read_why`'s, with a refused + listing told what a folder is told.""" + return cannot_read_why([exc for _, exc, listed in refused if not listed], + [exc for _, exc, listed in refused if listed]) + + #: What a refused PERMISSION is told, in `validate-pins.py`'s words for the #: same file (`_unreadable_file`, #712): the mode or the owner puts it right. PERMISSION_ADVICE = ( @@ -6272,10 +6391,19 @@ def own_bootstrap(root: Path, make_target: bool = False) -> bool: `make_target` also asks for the Makefile target `make bootstrap` runs, for the one sentence that sends a reader there. ONE DEFINITION, because the `legs` and `members` rows each asked it in their own words. + + A COPY THIS USER CANNOT REACH CANNOT RUN EITHER (#430), and that is the + answer on every Python: up to 3.13 `is_file()` raised the refused `stat` + out of the row, and from 3.14 it answered False. This chooses only which + line a row prints, and the `manifest` and `pins` rows name a `scripts/` + this user cannot search. """ scripts = root / "scripts" - if not all((scripts / name).is_file() - for name in ("bootstrap.py", "repo_shape.py")): + try: + if not all(is_a(scripts / name, stat.S_ISREG) + for name in ("bootstrap.py", "repo_shape.py")): + return False + except OSError: return False if not make_target: return True @@ -6296,8 +6424,15 @@ def gate_line(ctx: Context, gate: tuple) -> tuple[Path, bool, str | None]: `lockstep_next`, for the `legs` and `members` rows, and the `agent-files` row's confirm clause (#776). Both asked `validator_for` and `pythonpath_unnamable` in the same words. + + No line either where the root's own copy is one this user cannot reach + (#430): it runs nowhere until it is readable, the gate's own row names + it, and the template is not its stand-in (`validator_for`). """ - used, _, own_copy = validator_for(ctx, *gate) + try: + used, _, own_copy = validator_for(ctx, *gate) + except OSError: + return ctx.root / gate[0], True, None if (not used.is_file() or pythonpath_unnamable(ctx.shape, own_copy) is not None): return used, own_copy, None @@ -6699,6 +6834,11 @@ def kept_gitdir(root: Path, name: str) -> Path | None: (`os.fsdecode`), which `kept_head` and the keep line hand back to git unchanged. A strict decode of it found no repository for a section named `caf\\xe9`, and the commit on its detached HEAD got no keep line. + + ASKED AS `validate-pins.py` ASKS A PATH (`is_a`, #430): a `stat` the + operating system refuses there is raised, on every Python, and + `run_checks` names it, where Python 3.14's `is_dir()` called a kept + repository this user cannot reach gone. """ try: proc = subprocess.run(["git", "rev-parse", "--git-path", @@ -6712,7 +6852,7 @@ def kept_gitdir(root: Path, name: str) -> Path | None: path = Path(where) if not path.is_absolute(): path = root / path - return path if path.is_dir() else None + return path if is_a(path, stat.S_ISDIR) else None def kept_head(root: Path, git_dir: Path) -> tuple[bool | None, str | None]: @@ -6794,13 +6934,14 @@ def keeps_modules(mount: Path) -> bool: repository kept for it again and checks it out, and a commit made there on no branch is left in its reflog alone (measured). Asked through `rev-parse --absolute-git-dir` in the mount, which has a `.git` of its - own, so git does not walk up. Read only. + own, so git does not walk up. Read only. A refused `stat` of `modules/` + is raised, as `kept_gitdir` raises one (#430). """ try: where = git_out(["rev-parse", "--absolute-git-dir"], cwd=mount) except (Refusal, OSError, UnicodeDecodeError): return False - return (Path(where) / "modules").is_dir() + return is_a(Path(where) / "modules", stat.S_ISDIR) def nested_kind(carries: bool, keeps: bool) -> set: @@ -6869,7 +7010,7 @@ def checkout_reach(root: Path, rel: str, gitlink: str, populated: bool, if left_in_reflog(is_detached, kept_at, gitlink): keep = (git_dir, kept_at, None if mount.is_dir() else root) return (("relink" if populated else "checkout"), keep, - nested_kind(carries, (git_dir / "modules").is_dir())) + nested_kind(carries, is_a(git_dir / "modules", stat.S_ISDIR))) def kept_branch_line(kept: tuple, platform: str) -> str: @@ -7207,6 +7348,19 @@ def check_legs(ctx: Context) -> Row: `manifest kinds` and `shape currency` rows give them; beside the checkout line or the gate's line, the advice is said in its `why`, after what the line does. + + A MOUNT THIS USER CANNOT REACH IS NAMED, AND IS NO DRIFT EITHER (#430). + At mode 000 the mount's listing raised out of this row, `could not be + run: [Errno 13] ...`, and the verdict read the empty row as DRIFTED (1 + leg(s) not at the pin); at `r--`, Python 3.14 called it a mount with no + `.git` of its own and named a checkout over it. `mount_read` asks as the + gate asks, and the leg says the gate's line (`: cannot be listed + ()`, or `/.git: cannot be read ()`, which + `validate-pins.py` refuses it with), keeps it in `mount_cannot_be_read`, + which `drifted_reasons` reads, and is routed to no line: a person mends a + mode or an owner, so where nothing else has a line the NEXT is this + doctor again, and the advice is the `why`, or follows it, as for a pin. + Only a missing gitlink is said before it, as the lockstep fact it is. """ needs = ("the legs it declares are not known and none is checked against " "its pin") @@ -7229,8 +7383,11 @@ def check_legs(ctx: Context) -> Row: lockstep: list[str] = [] gone: list[str] = [] stuck: list[str] = [] - #: The `OSError` of each pin the operating system refused to read (#847). + #: The `OSError` of each pin the operating system refused to read (#847), + #: and of each mount whose `stat` it refused (#430). refused: list[OSError] = [] + #: The `OSError` of each mount it refused to list (#430). + unlisted: list[OSError] = [] rerun = False nested: set = set() for leg in legs: @@ -7244,8 +7401,7 @@ def check_legs(ctx: Context) -> Row: # beside an empty mount, and the verdict read the leg as drift. refusal = pin_read(ctx.root, pin)[1] if pinned is None else None gitlink = recorded_gitlink(ctx.root, rel) - populated = mount.is_dir() and any(mount.iterdir()) - own_git = (mount / ".git").exists() + populated, own_git, unread = mount_read(ctx.root, rel) head = head_of(mount) if populated and own_git else None unborn = (unborn_branch(mount) if populated and own_git and head is None else None) @@ -7256,6 +7412,9 @@ def check_legs(ctx: Context) -> Row: if refusal is not None: entry["cannot_be_read"] = cannot_be_read(pin, refusal) refused.append(refusal) + if unread is not None: + entry[MOUNT_UNREAD] = unread[0] + (unlisted if unread[2] else refused).append(unread[1]) problem = None cures = False #: Whether the problem is the pin's line, which no mount fact came @@ -7265,6 +7424,11 @@ def check_legs(ctx: Context) -> Row: problem = (f"{rel}: no gitlink recorded, so this leg is not " "mounted as a submodule at all") lockstep.append(rel) + if unread is not None: + problem += f", and {unread[0]}" + elif unread is not None: + # A PERSON'S, on a mode or an owner (#430): no line reaches it. + problem = mount_unread_said(unread[0]) elif not populated: problem = (f"{rel}: the gitlink is there and the directory is " "empty -- an unbootstrapped clone") @@ -7326,8 +7490,10 @@ def check_legs(ctx: Context) -> Row: problems.append(problem) detail = {"legs": rows} if problems: - rerun = rerun or bool(lockstep) or bool(stuck) or bool(refused) - said = [cannot_read_why(refused)] if refused else [] + rerun = (rerun or bool(lockstep) or bool(stuck) or bool(refused) + or bool(unlisted)) + said = ([cannot_read_why(refused, unlisted)] if refused or unlisted + else []) if checkout: # `make bootstrap` IS NAMED ONLY WHERE IT CAN RUN, and said for # what it does: it puts a leg on its tracking branch, and @@ -7359,8 +7525,9 @@ def check_legs(ctx: Context) -> Row: why += "".join(f". {sentence}" for sentence in [*said, *stuck]) elif said or stuck: why = ". ".join([*said, *stuck]) - elif refused: - # A PERSON'S, on a mode or an owner, and never a git line (#847). + elif refused or unlisted: + # A PERSON'S, on a mode or an owner, and never a git line (#847, + # #430). next_command, why = doctor_line(ctx), ". ".join([*said, *stuck]) else: # NO LINE CURES IT without choosing for the person (#414). @@ -7482,14 +7649,26 @@ def shape_file_state(here: Path, there: Path, name: str) -> str: Split out of `check_leg_shape_files` for #134: the chain answers one question about one file, and the loop that calls it answers a different one about a leg. + + A SIXTH ANSWER, `cannot be read ()`, for the leg's file whose + `stat` or read the operating system refuses (#430). It was `absent` on + Python 3.14, with a `cp` of the template over a file that is there, and + up to 3.13 the row `could not be run`. `there` is the standard's own copy. """ - if not here.is_file(): - return "absent" + try: + if not is_a(here, stat.S_ISREG): + return "absent" + except OSError as exc: + return f"cannot be read ({os_reason(exc)})" if name in LEG_RENDERED: return "rendered" if not there.is_file(): return "present (the template has none)" - if here.read_bytes() == there.read_bytes(): + try: + mine = here.read_bytes() + except OSError as exc: + return f"cannot be read ({os_reason(exc)})" + if mine == there.read_bytes(): return "identical" return "differs" @@ -7505,13 +7684,18 @@ def leg_shape_comparison(ctx: Context, role: str, rel: str) -> tuple: them is a fault of the repository's: a leg nobody has bootstrapped has no files yet, and a `templates/-root/` this checkout does not carry is the STANDARD being short rather than the project being wrong. Both say - which they are, in the `state` the row prints. + which they are, in the `state` the row prints. A leg this user cannot + reach is a third, and its `state` is the gate's line for it, kept under + `mount_cannot_be_read` as the `legs` row keeps it (`mount_read`, #430). Split out of `check_leg_shape_files` for #134. """ mount = ctx.root / rel template = ctx.shape / "templates" / f"{role}-root" - if not (mount.is_dir() and any(mount.iterdir())): + populated, _own_git, unread = mount_read(ctx.root, rel) + if unread is not None: + return {"role": role, "state": unread[0], MOUNT_UNREAD: unread[0]}, [] + if not populated: return {"role": role, "state": "not populated"}, [] if not template.is_dir(): return {"role": role, @@ -7534,6 +7718,9 @@ def leg_shape_summary(rel: str, entry: dict) -> str: Split out of `check_leg_shape_files` for #134. """ + if MOUNT_UNREAD in entry: + # The gate's line already starts with the leg's path (#430). + return entry[MOUNT_UNREAD] if entry["state"] != "compared": return f"{rel}: {entry['state']}" named = ", ".join(f"{name} {state}" @@ -7596,6 +7783,11 @@ def check_leg_shape_files(ctx: Context) -> Row: `leg_shape_summary`'s, so that this reads as the three sentences it is: compare every leg, say what was found, and offer a copy for the files a copy would fix. + + A LEG OR A FILE THIS USER CANNOT REACH KEEPS THE ROW OFF `ok` (#430): it + is a `note`, because what is missing is a read, the `legs` row asserts + the mount, and "compared" is not a claim to make about a file nobody + read. It offers no copy: the file is there. """ needs = "there are no legs to compare" waiting = (waiting_row(ctx, "leg-shape-files", LABEL_LEG_SHAPE_FILES, @@ -7632,8 +7824,12 @@ def check_leg_shape_files(ctx: Context) -> Row: fix, why = missing_shape_file_fix(ctx, missing) return Row("leg-shape-files", LABEL_LEG_SHAPE_FILES, NOTE, f"{summary} (missing: {named})", fix, detail, why=why) - return Row("leg-shape-files", LABEL_LEG_SHAPE_FILES, OK, summary, None, - detail) + unread = any(MOUNT_UNREAD in entry + or any(state.startswith("cannot be read") + for state in (entry.get("files") or {}).values()) + for entry in per_leg.values()) + return Row("leg-shape-files", LABEL_LEG_SHAPE_FILES, + NOTE if unread else OK, summary, None, detail) # --------------------------------------------------------------------------- @@ -8451,7 +8647,15 @@ def audit_leg(adopt, policy, patterns: list, role: str, rel: str, if escaped: entry["state"] = escaped return entry, [], [], [], [] - if not (mount.is_dir() and any(mount.iterdir())): + populated, _own_git, unread = mount_read(root, rel) + if unread is not None: + # A leg this user cannot reach, in the gate's words, as the `legs` + # row says it (#430): a READ that is missing, never a clean leg, and + # never `it is not a git repository`, which git said of a mount it + # could not enter. + entry["state"] = entry[MOUNT_UNREAD] = unread[0] + return entry, [], [], [], [] + if not populated: # The `legs` row already reports this, with `make bootstrap` beside # it. Here it is only why this leg has no answer. entry["state"] = "not populated" @@ -8567,6 +8771,9 @@ def leg_audit_summary(entry: dict) -> str: Split out of `check_placement` for #134. """ + if MOUNT_UNREAD in entry: + # The gate's line already starts with the leg's path (#430). + return entry[MOUNT_UNREAD] if entry.get("state") != "audited": return f"{entry['path']}: {entry.get('state')}" counted = (f"{entry['paths']} path(s) over {entry['classified']} of " @@ -9757,7 +9964,8 @@ def sibling_slot(root: Path, sibling: Path, row: dict, return ("not-a-clone" if unclonable(sibling) else "absent"), None -def member_state(ctx: Context, row: dict, members_dir: str) -> dict: +def member_state(ctx: Context, row: dict, members_dir: str, + refused: list) -> dict: """ONE member's `--json` entry: both copies of it, as they are found. NOTHING IS ASKED OF A MOUNT THAT IS NOT POPULATED. An unbootstrapped @@ -9781,6 +9989,12 @@ def member_state(ctx: Context, row: dict, members_dir: str) -> dict: WHAT STANDS AT `../` IS `sibling` (`sibling_slot`, #669), so a caller reads a word rather than parsing the row's sentence. + A MOUNT THIS USER CANNOT REACH IS NAMED (#430), by `mount_read`, as the + `legs` row names a leg's: the gate's line under `mount_cannot_be_read`, + and nothing asked of it. A `members/` at mode 000 was `could not be run` + up to Python 3.13 and an unbootstrapped submodule from 3.14. `refused` + collects `mount_read`'s `(line, OSError, listed)` for the row's advice. + Split out of `check_members` for #134. """ project = str(row.get("project") or "?") @@ -9789,21 +10003,24 @@ def member_state(ctx: Context, row: dict, members_dir: str) -> dict: pin = row.get("pin") if isinstance(row.get("pin"), dict) else {} pinned = str(pin.get("commit") or "").lower() pinned = pinned if COMMIT_RE.match(pinned) else None - populated = mount.is_dir() and any(mount.iterdir()) - own_git = (mount / ".git").exists() + populated, own_git, unread = mount_read(ctx.root, rel) head = head_of(mount) if populated and own_git else None unborn = (unborn_branch(mount) if populated and own_git and head is None else None) - loose = detached(mount) if populated else None + loose = detached(mount) if populated and unread is None else None sibling = ctx.root.parent / project remote = mounted_from(ctx.root, rel, str(row.get("repository") or "")) beside, elsewhere = sibling_slot(ctx.root, sibling, row, remote) clone = sibling.as_posix() if beside == "working-clone" else None - return {"project": project, "path": rel, "pin": pinned, - "populated": populated, "head": head, "own_git": own_git, - "unborn": unborn, "detached": loose, - "mounted_from": redacted(remote), "sibling": beside, - "working_clone": clone, "other_origin": elsewhere} + entry = {"project": project, "path": rel, "pin": pinned, + "populated": populated, "head": head, "own_git": own_git, + "unborn": unborn, "detached": loose, + "mounted_from": redacted(remote), "sibling": beside, + "working_clone": clone, "other_origin": elsewhere} + if unread is not None: + entry[MOUNT_UNREAD] = unread[0] + refused.append(unread) + return entry def member_problem(entry: dict) -> str | None: @@ -9824,9 +10041,14 @@ def member_problem(entry: dict) -> str | None: THE PATH, THE PROJECT AND THE BRANCH ARE SPELLED (#673): they are a manifest's and git's text, printed as `siblings.py` prints them. + A MOUNT THIS USER CANNOT REACH COMES FIRST (#430): nothing else could + be asked of it, and the gate's line says why. + Split out of `check_members` for #134. """ path = spelled(entry["path"]) + if entry.get(MOUNT_UNREAD): + return mount_unread_said(spelled(entry[MOUNT_UNREAD])) if not entry["populated"]: return (f"{path}: not populated -- the pinned copy is an " "unbootstrapped submodule") @@ -9933,7 +10155,7 @@ def beside_sentence(ctx: Context, entry: dict) -> str: def members_verdict(ctx: Context, detail: dict, members_dir: str, - problems: list) -> Row: + problems: list, refused: list = ()) -> Row: """What the members add up to: the one finding, or the two OK wordings. ONLY THE PINNED COPIES CAN MAKE THIS ROW RED. A missing working clone is @@ -9949,6 +10171,11 @@ def members_verdict(ctx: Context, detail: dict, members_dir: str, because a row that only said "no working clone" would leave a person staring at a directory of that exact name. + A MEMBER THIS USER CANNOT REACH IS ROUTED TO NO LINE (#430), as no such + leg is: `refused` holds `mount_read`'s `(line, OSError, listed)` for each, + the gate's advice for the cause is the `why`, or follows the line's, and + where no other member has a line the NEXT is this doctor again. + Split out of `check_members` for #134. """ rows = detail["members"] @@ -10018,7 +10245,9 @@ def members_verdict(ctx: Context, detail: dict, members_dir: str, and left_in_reflog(entry["detached"], entry["head"], gitlink)): moved.append(mount) - if member_problem(entry) is None: + if member_problem(entry) is None or MOUNT_UNREAD in entry: + # A member this user cannot reach is a person's, on a mode + # or an owner (#430): no line reaches it. continue own_git = entry.get("own_git", True) if gitlink is None: @@ -10053,7 +10282,8 @@ def members_verdict(ctx: Context, detail: dict, members_dir: str, # Its own lockstep fact (#414), as `check_legs` asks it. if entry["pin"] is None or gitlink != entry["pin"]: rerun = True - rerun = rerun or lockstep or bool(stuck) + rerun = rerun or lockstep or bool(stuck) or bool(refused) + said = [mount_refusals(list(refused))] if refused else [] # THE HOLDER'S OWN BOOTSTRAP STAYS THE NEXT WHEN IT CAN RUN: it does # what the plain git line does AND carries the credential rewrite a # PRIVATE member needs (`fetch_members`), so replacing it would @@ -10081,7 +10311,9 @@ def members_verdict(ctx: Context, detail: dict, members_dir: str, # recursive too, into every member that carries legs. carried = ({"tree"} if any((ctx.root / entry["path"] / ".gitmodules").is_file() - for entry in rows) else set()) + for entry in rows + if MOUNT_UNREAD not in entry) + else set()) why = ("what `make bootstrap` runs in a holder: it fetches every " "member at its pin and leaves it detached there" + checkout_notes( @@ -10089,6 +10321,7 @@ def members_verdict(ctx: Context, detail: dict, members_dir: str, nested | carried)) if rerun: why += rerun_sentence("member") + why += "".join(f". {sentence}" for sentence in said) elif checkout: if own_bootstrap(ctx.root): # Each member that stops the bootstrap is named, routed or @@ -10108,6 +10341,7 @@ def members_verdict(ctx: Context, detail: dict, members_dir: str, next_command, why = submodule_update_next( ctx.root, checkout, off_pin, "member", after, rerun, kept=kept, relinked=relinked, nested=nested) + why += "".join(f". {sentence}" for sentence in said) elif lockstep: # THE FAMILY GATE, one definition of it for this row and the # `family` row (`lockstep_next`), and, beside it, what no line @@ -10115,9 +10349,12 @@ def members_verdict(ctx: Context, detail: dict, members_dir: str, next_command, why = lockstep_next(ctx, FAMILY_GATE, "member") if why: why += "".join(gone_sentence(rel) for rel in gone) - why += "".join(f". {sentence}" for sentence in stuck) - elif stuck: - why = ". ".join(stuck) + why += "".join(f". {sentence}" for sentence in [*said, *stuck]) + elif said or stuck: + why = ". ".join([*said, *stuck]) + elif refused: + # A PERSON'S, on a mode or an owner, and never a git line (#430). + next_command, why = doctor_line(ctx), ". ".join([*said, *stuck]) else: # NO LINE CURES IT without choosing for the person (#414). next_command, why = None, ". ".join(stuck) @@ -10207,8 +10444,11 @@ def check_members(ctx: Context) -> Row: problems: list[str] = [] siblings_absent: list[str] = [] on_a_branch: list[str] = [] + #: `mount_read`'s `(line, OSError, listed)` for each member this user + #: cannot reach (#430). + refused: list[tuple] = [] for row in members: - entry = member_state(ctx, row, members_dir) + entry = member_state(ctx, row, members_dir, refused) rows.append(entry) problem = member_problem(entry) if problem: @@ -10229,7 +10469,7 @@ def check_members(ctx: Context) -> Row: siblings_absent.append(entry["project"]) detail = {"members": rows, "without_working_clone": siblings_absent, "on_a_branch": on_a_branch} - return members_verdict(ctx, detail, members_dir, problems) + return members_verdict(ctx, detail, members_dir, problems, refused) # --------------------------------------------------------------------------- @@ -11325,8 +11565,23 @@ def run_checks(ctx: Context) -> list[Row]: f"refused: {exc.detail}", None, {"error": exc.code}, why=remedy_as_why(exc))) except OSError as exc: - rows.append(Row(check.id, check.label, FINDING, - f"could not be run: {exc}", None, {})) + named = refused_in_root(ctx.root, exc) + if named is None: + rows.append(Row(check.id, check.label, FINDING, + f"could not be run: {exc}", None, {})) + continue + # A PATH IN THE ROOT THIS USER CANNOT REACH (#430), however the + # check came to ask it: the gate's line, this doctor again, and + # the gate's advice, as every row that asks its own path says + # it, where the row said `could not be run: [Errno 13] `. A statement about the repository, so no + # `environment`, and with no entry in it, so no drift either + # (`drifted_reasons`). + line = cannot_be_read(named, exc) + rows.append(Row(check.id, check.label, FINDING, line, + doctor_line(ctx), + {"cannot_be_read": line, "path": named}, + why=cannot_read_why([exc]))) return rows @@ -11402,6 +11657,19 @@ def drifted_reasons(by_id: dict, detail: dict) -> list: else, and INVALID names it; the run after the pin is mended says the drift. + A ROW THAT COULD NOT ASK IS NOT DRIFT EITHER (#430). The count is read + from the row's own entries, and a `legs` row with none -- what + `run_checks` makes of a check that raised, `could not be run` or a path + it names -- has no leg to be off its pin: with a leg mount at mode 000 + the verdict said `DRIFTED (1 leg(s) not at the pin)` beside the very + rows that said they could not be run. A leg whose MOUNT cannot be read + (`mount_cannot_be_read`) is as uncounted as one whose pin cannot be. The + `members` row is drift only where a member this user CAN reach has a + problem. Such a row goes on down the ladder, to the gate that refuses + the path (`invalid_verdict`) or to the catch-all, which names it: INVALID, + never DRIFTED, and never CANNOT ANSWER, because a path in the root this + user cannot reach is a statement about the repository. + Split out of `verdict_for` for #134. """ reasons: list[str] = [] @@ -11410,13 +11678,15 @@ def drifted_reasons(by_id: dict, detail: dict) -> list: legs = by_id.get("legs") if legs is not None and legs.status == FINDING: entries = legs.detail.get("legs") or [] - unread = [leg for leg in entries if "cannot_be_read" in leg] - off = [leg for leg in entries if "cannot_be_read" not in leg - and leg.get("head") != leg.get("pin")] - if off or not unread: + read = [leg for leg in entries + if "cannot_be_read" not in leg and MOUNT_UNREAD not in leg] + off = [leg for leg in read if leg.get("head") != leg.get("pin")] + if entries and (off or len(read) == len(entries)): reasons.append(f"{len(off) or 1} leg(s) not at the pin") members = by_id.get("members") - if members is not None and members.status == FINDING: + if members is not None and members.status == FINDING and any( + MOUNT_UNREAD not in entry and member_problem(entry) + for entry in members.detail.get("members") or []): reasons.append("a member is not at its pin") return reasons @@ -11746,6 +12016,35 @@ def lazy_fetch_off(): os.environ[NO_LAZY_FETCH] = before +#: What a `--root` this user may not search is told for a refused PERMISSION +#: (#430): `PERMISSION_ADVICE` for the root itself, which has no "inside the +#: root" above it. +ROOT_PERMISSION_ADVICE = ( + "Make the root searchable by this user: `chmod u+rx` on it (and on every " + "directory above it), or fix who owns it.") + + +def root_refusal(named: str, exc: OSError) -> str: + """The refusal of a `--root` whose `stat`, or whose search, the operating + system refused (#430): `shape-doctor-root-unreadable`, the code `main` + gives a root `resolve()` refuses, with the gate's line for the path and + its advice for the cause, then "Then run this doctor again". + + BEFORE ANY ROW, BECAUSE NO ROW CAN ASK. Every row reads a name in the + root, and a root this user cannot search answered each of them with its + own `[Errno 13]` up to Python 3.13 -- `Context` reading `project.yaml` + ended the run in a traceback -- and from 3.14 with "nothing is here", + `NOT A SHAPE ROOT`; a folder above it this user cannot search read as + `shape-doctor-root-missing`. EXIT 3, the documented "usage or + environment": nothing here is a statement about the tree, which no row + could read, and exit 2 is `NOT A SHAPE ROOT`, a verdict about it.""" + advice = (ROOT_PERMISSION_ADVICE if isinstance(exc, PermissionError) + else OTHER_REASON_FOLDER) + return (f"REFUSED shape-doctor-root-unreadable: --root {named}: cannot be " + f"read ({os_reason(exc)})\n Remediation: {advice} Then run this " + "doctor again") + + @lazy_fetch_off() def main(argv: list[str] | None = None) -> int: # FIRST, so that every `print` after it -- `--help` included -- escapes @@ -11765,7 +12064,19 @@ def main(argv: list[str] | None = None) -> int: print(f"REFUSED shape-doctor-root-unreadable: --root {args.root}: " f"{exc}", file=sys.stderr) return 3 - if not root.is_dir(): + try: + there = is_a(root, stat.S_ISDIR) + if there: + # SEARCH, AND NOT LISTING (#430): every row asks a name it knows, + # so a root this user can search and not list (mode 111) reads + # COMPLIANT, and is not refused. `/.` is asked through + # `os.path`, because a `Path` drops the `.` and would ask the + # root's own `stat`, which only its parent decides. + os.stat(os.path.join(root, os.curdir)) + except OSError as exc: + print(root_refusal(args.root, exc), file=sys.stderr) + return 3 + if not there: print(f"REFUSED shape-doctor-root-missing: --root {args.root} is not a " "directory on this machine.\n Remediation: pass a path to a " "checkout. This command reads a tree; it never clones one.", diff --git a/src/openreposhape/cli.py b/src/openreposhape/cli.py index b92e9cd9..7129f1d2 100644 --- a/src/openreposhape/cli.py +++ b/src/openreposhape/cli.py @@ -96,11 +96,12 @@ `COMPLIANT`, `COMPLIANT, SHAPE BEHIND`, `DRIFTED`, `MISPLACED`, `INVALID`, `NOT A SHAPE ROOT` or `CANNOT ANSWER`. Every finding names the command that fixes it. Exit 0 compliant, 1 findings, 2 not a shape root, 3 when it cannot -answer (`CANNOT ANSWER`, no such , a `--placement-plan` it cannot -write); this command's own refusals exit 2. `--json` is the same report as -one JSON object. The doctor writes no file but the `--placement-plan ` -you name, at the path you give (a relative lands in the directory you -ran this from, even inside the repository), and moves nothing. +answer (`CANNOT ANSWER`, no such , a this user cannot search, a +`--placement-plan` it cannot write); this command's own refusals exit 2. +`--json` is the same report as one JSON object. The doctor writes no file but +the `--placement-plan ` you name, at the path you give (a relative + lands in the directory you ran this from, even inside the repository), +and moves nothing. It runs from the package's clone of the standard, which is KEPT for the NEXT lines that name files in it; the report's last line names that clone (under diff --git a/tests/test_doctor_unreadable_paths.py b/tests/test_doctor_unreadable_paths.py new file mode 100644 index 00000000..9685c49f --- /dev/null +++ b/tests/test_doctor_unreadable_paths.py @@ -0,0 +1,587 @@ +# SPDX-License-Identifier: Apache-2.0 +"""A path in the root this user cannot reach is named by the doctor, and the +verdict follows the rows: #430's slice C (`shape-doctor.py` and +`scripts/shape_advisory.py`). + +THE DEFECT. Up to Python 3.13 `Path.is_file()`, `is_dir()` and `exists()` +raise a `stat` the operating system refuses; from 3.14 they answer False to +any `OSError`. The doctor asked them of the root's own paths, so one tree gave +two wrong answers (measured on main `50178305`, Python 3.12.3 and a 3.14-like +shim): + + * `scripts/` at 444: the `manifest` and `pins` rows `could not be run: + [Errno 13] ...` on 3.12; on 3.14 `validator_for` took the project's own + `scripts/validate-manifest.py` for a copy the root lacks and ran the + standard's TEMPLATE in its place: `ok manifest the standard's + templates/assembly-root/scripts/validate-manifest.py passes`; + * a leg mount at 000: the `legs`, `leg shape files` and `placement` rows + `could not be run`, and the verdict `DRIFTED (1 leg(s) not at the pin)` + beside them, on both, because `drifted_reasons` counted a red `legs` row + with no leg in it as one leg off its pin; at 444, on 3.14, `spec: + populated, but with no .git of its own` and every leg file `absent`; + * a holder's `members/` at 000: `DRIFTED (a member is not at its pin)` + with the `members` row `could not be run` on 3.12, and `members/IRRS: not + populated -- the pinned copy is an unbootstrapped submodule` on 3.14; + * the root at 000 or 444: a traceback on 3.12, `NOT A SHAPE ROOT` on 3.14; + a directory above it at 000: a traceback, or `REFUSED + shape-doctor-root-missing: ... is not a directory on this machine`. + +Each is now one answer on every Python: the path, in `validate-pins.py`'s +words (`: cannot be read ()`, or `cannot be listed` for a +folder), this doctor again as the NEXT and the gate's advice as the WHY; the +verdict is INVALID naming the red rows and never DRIFTED; and a root this user +cannot search is refused by name, exit 3, before any row. + +NOT HERE: the `shape currency` row's `copy-missing` under 3.14 for a `scripts/` +or a `.github/workflows/` this user cannot search comes from +`update-shape.py`'s comparison, which #430's slice B fixes. No test here +depends on it, so none asserts the verdict of those two cells. + +TWO KINDS OF CASE, as `tests/test_doctor_unreadable_pin.py` (#847) has them. +The MODE cases change a real mode and run the doctor as a person does; they +skip on Windows and as root (`CANNOT_RUN_AS_ROOT`), and restore the mode in a +`finally` (`mode_of`). The STAND-IN cases put the refusal in by hand, in +process, so they run everywhere: `refused_stat` (#383), which also answers +`is_file()` as Python 3.14 does, and `refusing` (#847) for a read or a +listing. Each fix is held by a case of each kind where both can reach it. + +NOTHING HERE REACHES A NETWORK: the doctor's own fixtures, a scaffold into +local bare remotes, and the session's `gh` guard for the `machine` row. +""" + +from __future__ import annotations + +import errno +import os +import sys +from pathlib import Path + +import pytest + +from conftest import REPO, git, load_without_bytecode +#: #827's stand-in for a `stat` the operating system refuses, from the file +#: that owns it (#383). +from test_bump_leg_unopenable import refused_stat +#: A private copy of the session's holder, from the file that owns the +#: `members` row's tests. +from test_doctor_legs_members_next import holder_copy +#: This doctor's own line for a root, as `unreadable_next` spells it. +from test_doctor_recorded_answers import doctor_again +#: #847's helpers and the gate's advice, word for word, from the file that +#: owns them. +from test_doctor_unreadable_pin import ( + DENIED, GATE, IO_ERROR, OTHER_FOLDER_WHY, OTHER_WHY, PERMISSION_WHY, + cannot, doctored, in_process, refusing, +) +#: The doctor's fixtures, from the module that owns them. +from test_shape_doctor import ( # noqa: F401 + FAMILY_MEMBER, doctor, holder, project, scaffolded_here, standard, +) +#: The one skip for a mode that root reads through and Windows ignores. +from test_validate_pins_unreadable_workflow import CANNOT_RUN_AS_ROOT +#: A mode for the block and the old one back after it. +from test_validate_pins_unreadable_workflows_dir import mode_of + +sys.path.insert(0, str(REPO / "scripts")) +import shape_advisory # noqa: E402 + +MANIFEST_GATE = "scripts/validate-manifest.py" +MANIFEST_TEMPLATE = "templates/assembly-root/scripts/validate-manifest.py" +PINS_GATE = "scripts/validate-pins.py" +MEMBER = f"members/{FAMILY_MEMBER}" +#: What every row said of a path it could not reach, and what none may say. +COULD_NOT_RUN = "could not be run" +#: The `members` row's words for an empty mount, which a member this user +#: cannot reach is not. +UNBOOTSTRAPPED = "not populated -- the pinned copy is an unbootstrapped" + + +def listed(named: str, reason: str = DENIED) -> str: + """The gate's line for a folder it cannot list (#434).""" + return f"{named}: cannot be listed ({reason})" + + +def unread_mount(line: str) -> str: + """The `legs` and `members` rows' words for a mount this user cannot + reach, after the gate's line for it.""" + return f"{line}, so no HEAD in it is compared with the pin" + + +def unread_gate(own: str, reason: str = DENIED) -> str: + """A validator row's words for an own copy this user cannot reach.""" + return (f"{cannot(own, reason)}, so the project's own gate was not run, " + "and the standard's copy is not run in its place") + + +def assert_nothing_could_not_run(rows: dict) -> None: + """No row says it could not be run.""" + for row in rows.values(): + assert COULD_NOT_RUN not in row["reason"], row + + +# --- the mode cases: a real mode, the doctor run as a person runs it --------- + +@CANNOT_RUN_AS_ROOT +def test_an_own_gate_this_user_cannot_reach_is_named_never_the_template( + standard, project): + """`scripts/` at 444: each validator row names the project's own copy it + cannot reach, and the standard's template is never run in its place, so + no row says `passes`. The verdict is not asserted: on 3.14 the `shape + currency` row's `copy-missing` is slice B's (`update-shape.py`).""" + denied = os.strerror(errno.EACCES) + again = doctor_again(standard, project) + with mode_of(project, "scripts", 0o444): + rows, verdict = doctored(standard, project) + for row_id, own in (("manifest", MANIFEST_GATE), ("pins", PINS_GATE)): + row = rows[row_id] + assert row["status"] == "FINDING", row + assert row["reason"] == unread_gate(own, denied) + assert row["next"] == again + assert row["why"] == PERMISSION_WHY + assert row["detail"]["own_copy"] is True, row + assert row["detail"]["cannot_be_read"] == cannot(own, denied) + assert not verdict.startswith("COMPLIANT"), verdict + assert_nothing_could_not_run(rows) + + +@CANNOT_RUN_AS_ROOT +@pytest.mark.parametrize("bits, line", [ + pytest.param(0, listed("spec", os.strerror(errno.EACCES)), id="spec-000"), + pytest.param(0o444, cannot("spec/.git", os.strerror(errno.EACCES)), + id="spec-r--"), +]) +def test_a_leg_mount_this_user_cannot_reach_is_named_and_no_drift( + standard, project, bits, line): + """The verdict bug: a leg mount at 000 made `legs`, `leg shape files` + and `placement` say `could not be run`, and the verdict DRIFTED. Each + row names the mount, the leg carries the line, and the verdict is the + gate's INVALID: `validate-pins.py` refuses `spec/.git` by name.""" + again = doctor_again(standard, project) + with mode_of(project, "spec", bits): + rows, verdict = doctored(standard, project) + legs = rows["legs"] + assert legs["status"] == "FINDING", legs + assert legs["reason"] == unread_mount(line) + assert legs["detail"]["legs"][0]["mount_cannot_be_read"] == line + assert legs["next"] == again + assert legs["why"] == PERMISSION_WHY + shape_files, placement = rows["leg-shape-files"], rows["placement"] + assert shape_files["status"] == "note", shape_files + assert shape_files["reason"].startswith(f"{line}; code: "), shape_files + assert shape_files["next"] is None, shape_files + assert placement["status"] == "note", placement + assert f": {line}; code: " in placement["reason"], placement + assert verdict == "INVALID (pins)" + assert_nothing_could_not_run(rows) + + +@CANNOT_RUN_AS_ROOT +@pytest.mark.parametrize("relative, bits, line", [ + pytest.param("members", 0, cannot(MEMBER, os.strerror(errno.EACCES)), + id="members-000"), + pytest.param(MEMBER, 0, listed(MEMBER, os.strerror(errno.EACCES)), + id="member-000"), + pytest.param(MEMBER, 0o444, + cannot(f"{MEMBER}/.git", os.strerror(errno.EACCES)), + id="member-r--"), +]) +def test_a_member_this_user_cannot_reach_is_named_and_no_drift( + standard, holder, tmp_path, relative, bits, line): + """A holder's `members/`, or a member's mount, this user cannot search: + the `members` row names the mount where 3.12 said `could not be run` and + 3.14 called it unbootstrapped, and the verdict is the `family` row's + INVALID, never a member off its pin.""" + root = holder_copy(holder, tmp_path) + with mode_of(root, relative, bits): + rows, verdict = doctored(standard, root) + members = rows["members"] + assert members["status"] == "FINDING", members + assert members["reason"] == unread_mount(line) + assert members["detail"]["members"][0]["mount_cannot_be_read"] == line + assert members["next"] == doctor_again(standard, root) + assert members["why"] == PERMISSION_WHY + assert UNBOOTSTRAPPED not in members["reason"] + assert verdict == "INVALID (family)" + assert_nothing_could_not_run(rows) + + +def root_refusal(root: Path, reason: str) -> str: + """The first line of the doctor's refusal of a root it cannot search.""" + return (f"REFUSED shape-doctor-root-unreadable: --root {root}: cannot be " + f"read ({reason})") + + +@CANNOT_RUN_AS_ROOT +@pytest.mark.parametrize("above, bits", [ + pytest.param(False, 0, id="root-000"), + pytest.param(False, 0o444, id="root-r--"), + pytest.param(True, 0, id="above-000"), +]) +def test_a_root_this_user_cannot_search_is_refused_by_name( + standard, project, above, bits): + """A root this user cannot search, or one under a directory it cannot + search: one refusal, exit 3, before any row, naming the root and the + cure. It was a traceback on 3.12, and on 3.14 `NOT A SHAPE ROOT` or + `shape-doctor-root-missing`.""" + target = project.parent if above else project + with mode_of(target, ".", bits): + result = doctor(standard, project, "--json") + assert result.returncode == 3, result.stdout + result.stderr + assert result.stdout == "", result.stdout + assert "Traceback" not in result.stderr, result.stderr + lines = result.stderr.splitlines() + assert lines[0] == root_refusal(project, os.strerror(errno.EACCES)) + assert "chmod u+rx" in lines[1], lines + assert lines[1].endswith("Then run this doctor again"), lines + + +@CANNOT_RUN_AS_ROOT +def test_a_root_this_user_can_search_and_not_list_is_not_refused( + standard, project): + """The control: only SEARCH is asked. A root at 111 is read through + names the doctor already knows, and it was COMPLIANT on both Pythons.""" + with mode_of(project, ".", 0o111): + result = doctor(standard, project, "--json") + assert result.returncode == 0, result.stdout + result.stderr + assert "shape-doctor-root" not in result.stderr, result.stderr + + +# --- the stand-in cases: in process, on every platform and as root ---------- + +def test_validator_for_raises_a_refused_stat_and_names_the_own_copy( + standard, project, monkeypatch): + """`validator_for` with the own copy's `stat` refused, as 3.14's + `is_file()` would answer it: it raises, and the `manifest` row names the + copy rather than running the standard's template in its place.""" + module, ctx = in_process(standard, project) + refused_stat(monkeypatch, "validate-manifest.py") + with pytest.raises(PermissionError): + module.validator_for(ctx, MANIFEST_GATE, MANIFEST_TEMPLATE) + row = module.check_manifest(ctx) + assert row.status == module.FINDING + assert row.reason == unread_gate(MANIFEST_GATE) + assert row.next_command == doctor_again(standard, project) + assert row.why == PERMISSION_WHY + assert row.detail["own_copy"] is True, row.detail + assert row.detail["exit"] is None, row.detail + + +def test_another_reason_for_the_own_copy_gets_the_other_advice( + standard, project, monkeypatch): + """A `stat` of the own copy refused for a reason that is no permission: + the system's reason, and the gate's other advice.""" + module, ctx = in_process(standard, project) + refusing(monkeypatch, "stat", "validate-pins.py", + OSError(errno.EIO, IO_ERROR)) + row = module.check_pins(ctx) + assert row.reason == unread_gate(PINS_GATE, IO_ERROR) + assert row.why == OTHER_WHY + + +def test_an_own_copy_that_is_not_there_still_falls_back_to_the_template( + standard, project): + """The control: a root that lacks its own copy is given the standard's + template, as before.""" + module, ctx = in_process(standard, project) + (project / MANIFEST_GATE).unlink() + used, which, own_copy = module.validator_for(ctx, MANIFEST_GATE, + MANIFEST_TEMPLATE) + assert own_copy is False + assert which == f"the standard's {MANIFEST_TEMPLATE}" + assert used == ctx.shape / MANIFEST_TEMPLATE + + +def test_no_lockstep_line_is_named_for_a_gate_this_user_cannot_reach( + standard, project, monkeypatch): + """`gate_line` names no line for an own copy it cannot reach, so the + `legs` row's lockstep NEXT and the `agent files` confirm clause say + nothing of a gate that cannot run, and no row ends in a traceback.""" + module, ctx = in_process(standard, project) + refused_stat(monkeypatch, "validate-pins.py") + used, own_copy, command = module.gate_line(ctx, module.PINS_GATE) + assert command is None + assert own_copy is True + assert used == ctx.root / PINS_GATE + assert module.confirm_digest(ctx) == "" + + +def test_an_unloaded_manifest_names_an_own_gate_it_cannot_reach( + standard, project, monkeypatch): + """A `project.yaml` that does not load beside an own gate this user + cannot reach: the row quotes the manifest's refusal and names the gate's + copy, and runs nothing.""" + module = in_process(standard, project)[0] + manifest = project / "project.yaml" + manifest.write_text(manifest.read_text(encoding="utf-8") + "\tbad: tab\n", + encoding="utf-8") + ctx = module.Context(project.resolve()) + assert ctx.manifest_refusal is not None + refused_stat(monkeypatch, "validate-manifest.py") + row = module.check_manifest(ctx) + assert row.status == module.FINDING + assert row.reason.startswith("project.yaml does not load: "), row.reason + assert row.reason.endswith("; " + unread_gate(MANIFEST_GATE)), row.reason + assert row.next_command == doctor_again(standard, project) + assert row.why == PERMISSION_WHY + + +@pytest.mark.parametrize("refuse, line, why", [ + pytest.param(lambda patch: refused_stat(patch, "spec"), + cannot("spec"), PERMISSION_WHY, id="mount-stat"), + pytest.param(lambda patch: refusing( + patch, "iterdir", "spec", PermissionError(errno.EACCES, DENIED)), + listed("spec"), PERMISSION_WHY, id="mount-listing"), + pytest.param(lambda patch: refusing( + patch, "iterdir", "spec", OSError(errno.EIO, IO_ERROR)), + listed("spec", IO_ERROR), OTHER_FOLDER_WHY, id="listing-io-error"), +]) +def test_a_leg_mount_refused_is_named_on_every_python( + standard, project, monkeypatch, refuse, line, why): + """The `legs` row for a mount whose `stat` or listing is refused: named + in the gate's words, no checkout and no lockstep line, the advice for + the cause, and no drift.""" + module, ctx = in_process(standard, project) + refuse(monkeypatch) + legs = module.check_legs(ctx) + assert legs.reason == unread_mount(line) + assert legs.detail["legs"][0]["mount_cannot_be_read"] == line + assert legs.next_command == doctor_again(standard, project) + assert legs.why == why + assert module.drifted_reasons({"legs": legs}, {}) == [] + + +def test_a_mount_whose_git_cannot_be_reached_is_not_one_without_a_git( + standard, project, monkeypatch): + """A mount this user can list and not search: its `.git`'s `stat` is + refused, which 3.14 answered as "no `.git` of its own", and the row sent + a checkout over it. Every row that reads the mount says the same line.""" + module, ctx = in_process(standard, project) + refused_stat(monkeypatch, ".git") + legs = module.check_legs(ctx) + shape_files = module.check_leg_shape_files(ctx) + placement = module.check_placement(ctx) + spec, code = cannot("spec/.git"), cannot("code/.git") + assert legs.reason == f"{unread_mount(spec)}; {unread_mount(code)}" + assert legs.next_command == doctor_again(standard, project) + assert shape_files.status == module.NOTE + assert shape_files.reason == f"{spec}; {code}" + assert placement.status == module.NA + assert placement.reason.endswith(f"{spec}; {code}"), placement.reason + + +def test_beside_a_checkout_line_the_unread_mount_gets_the_advice( + standard, project, monkeypatch): + """One leg's mount cannot be listed, and the other leg has a commit past + its pin while its gitlink is at the pin: the checkout line is the NEXT + for that leg, and its why ends on the advice for the mount.""" + module, ctx = in_process(standard, project) + git("commit", "-q", "--allow-empty", "-m", "past its pin", + cwd=project / "code") + refusing(monkeypatch, "iterdir", "spec", + PermissionError(errno.EACCES, DENIED)) + legs = module.check_legs(ctx) + assert legs.next_command.endswith(" -- code"), legs.next_command + assert legs.why.endswith(". " + PERMISSION_WHY), legs.why + + +def test_a_member_mount_refused_is_named_on_every_python( + standard, holder, tmp_path, monkeypatch): + """The `members` row for a member whose mount's `stat` is refused (a + `members/` this user cannot search): named, no bootstrap line, the + advice, and no drift.""" + root = holder_copy(holder, tmp_path) + module, ctx = in_process(standard, root) + refused_stat(monkeypatch, FAMILY_MEMBER) + members = module.check_members(ctx) + # First, and alone among the row's findings: the stand-in refuses the + # `stat` of `../IRRS` beside the holder too, which the row then says + # stands there, as "not what fails this row". + assert members.reason.split("; ")[0] == unread_mount(cannot(MEMBER)) + assert members.next_command == doctor_again(standard, root) + assert members.why == PERMISSION_WHY + assert module.drifted_reasons({"members": members}, {}) == [] + + +def test_a_leg_file_this_user_cannot_read_is_said_not_called_absent( + standard, project, monkeypatch): + """A leg's shape file whose `stat`, or whose read, is refused is `cannot + be read`, never `absent` with a copy over it: the row is a `note`.""" + module, ctx = in_process(standard, project) + refused_stat(monkeypatch, ".gitignore") + refusing(monkeypatch, "read_bytes", "CLAUDE.md", + PermissionError(errno.EACCES, DENIED)) + row = module.check_leg_shape_files(ctx) + files = row.detail["legs"]["spec"]["files"] + assert files[".gitignore"] == f"cannot be read ({DENIED})" + assert files["CLAUDE.md"] == f"cannot be read ({DENIED})" + assert row.status == module.NOTE + assert row.next_command is None + assert "absent" not in row.reason, row.reason + + +def test_a_bootstrap_this_user_cannot_reach_cannot_run_on_every_python( + standard, project, monkeypatch): + """`own_bootstrap` asks whether the root's own bootstrap can RUN: a + refused `stat` is "no", where 3.12 raised it out of the row.""" + module, ctx = in_process(standard, project) + assert module.own_bootstrap(ctx.root) is True + refusing(monkeypatch, "stat", "bootstrap.py", + PermissionError(errno.EACCES, DENIED)) + assert module.own_bootstrap(ctx.root) is False + + +def legs_over_a_kept_repository(module, ctx): + """The `legs` row over a `spec` with files and no `.git` of its own, + whose repository git kept: `checkout_reach` asks that repository for a + `modules/` of its own.""" + (ctx.root / "spec" / ".git").unlink() + return module.check_legs(ctx) + + +@pytest.mark.parametrize("names, ask", [ + pytest.param(("modules",), + lambda module, ctx: module.keeps_modules(ctx.root / "spec"), + id="keeps_modules"), + pytest.param(("spec",), + lambda module, ctx: module.kept_gitdir(ctx.root, "spec"), + id="kept_gitdir"), + pytest.param(("modules",), legs_over_a_kept_repository, + id="checkout_reach"), +]) +def test_a_git_directory_this_user_cannot_reach_is_raised( + standard, project, monkeypatch, names, ask): + """The reads of a repository's git directory raise a refused `stat`, + which `run_checks` names, where 3.14 called a kept repository this user + cannot reach gone, or one with no `modules/`.""" + module, ctx = in_process(standard, project) + refused_stat(monkeypatch, *names) + with pytest.raises(PermissionError): + ask(module, ctx) + + +def test_a_root_whose_stat_is_refused_is_refused_on_every_python( + standard, project, monkeypatch, capsys): + """The root's own `stat` refused, as under a folder this user cannot + search, in process: 3.14's `is_dir()` answered no directory, and the + doctor said `shape-doctor-root-missing`. It is the same refusal by name + as a root it cannot search, on every Python.""" + module = in_process(standard, project)[0] + refused_stat(monkeypatch, project.name) + code = module.main(["--root", str(project)]) + err = capsys.readouterr().err + assert code == 3 + assert err.splitlines()[0] == root_refusal(project, DENIED) + assert "shape-doctor-root-missing" not in err + + +def test_a_refused_read_out_of_a_check_is_named_by_run_checks( + standard, project): + """An `OSError` out of a check: a refused read of a path inside the root + is the row's FINDING in the gate's words; any other keeps `could not be + run`.""" + module, ctx = in_process(standard, project) + inside = PermissionError(errno.EACCES, DENIED, str(ctx.root / "spec")) + elsewhere = PermissionError(errno.EACCES, DENIED, "/elsewhere/x") + + def raising(error): + def run(_ctx): + raise error + return run + + module.CHECKS = ( + module.Check("legs", "legs", (module.PROJECT,), raising(inside)), + module.Check("members", "members", (module.PROJECT,), + raising(elsewhere))) + named, other = module.run_checks(ctx) + assert named.reason == cannot("spec") + assert named.next_command == doctor_again(standard, project) + assert named.why == PERMISSION_WHY + assert named.detail == {"cannot_be_read": cannot("spec"), "path": "spec"} + assert other.reason.startswith(f"{COULD_NOT_RUN}: "), other.reason + + +# --- the verdict ------------------------------------------------------------ + +def test_a_row_that_could_not_be_run_is_invalid_never_drift(standard, + project): + """A `legs` or `members` row with no entry in it, which is what + `run_checks` makes of a check that raised, is no leg off its pin: the + verdict is INVALID naming it. A leg that is off its pin still is.""" + module, ctx = in_process(standard, project) + for row_id in ("legs", "members"): + raised = module.Row(row_id, row_id, module.FINDING, + f"{COULD_NOT_RUN}: [Errno 13] {DENIED}", None, {}) + assert module.verdict_for(ctx, [raised]) == ( + f"INVALID ({row_id})", 1) + off = module.Row("legs", "legs", module.FINDING, "off", None, + {"legs": [{"head": "1" * 40, "pin": "2" * 40}]}) + assert module.verdict_for(ctx, [off]) == ( + "DRIFTED (1 leg(s) not at the pin)", 1) + + +def test_a_member_this_user_cannot_reach_is_no_drift_beside_one_that_is( + standard, holder, tmp_path, monkeypatch): + """The `members` row is drift for the members it could read and found + wrong, and for no other.""" + root = holder_copy(holder, tmp_path) + module, ctx = in_process(standard, root) + refused_stat(monkeypatch, FAMILY_MEMBER) + members = module.check_members(ctx) + entries = members.detail["members"] + assert module.drifted_reasons({"members": members}, {}) == [] + moved = dict(entries[0]) + del moved["mount_cannot_be_read"] + moved.update({"populated": True, "own_git": True, "head": "1" * 40, + "pin": "2" * 40}) + members.detail["members"] = [*entries, moved] + assert module.drifted_reasons({"members": members}, {}) == [ + "a member is not at its pin"] + + +# --- scripts/shape_advisory.py --------------------------------------------- + +def test_a_record_this_user_cannot_reach_silences_nothing_on_every_python( + tmp_path, monkeypatch): + """`Tree.kind` asks the operating system: a refused `stat` of + `single-repository.yaml` raises, and the record is "there and could not + be read", which silences nothing and says why, where 3.14 called it + absent and said nothing.""" + (tmp_path / shape_advisory.RECORD_FILE).write_text("kind: x\n") + refused_stat(monkeypatch, shape_advisory.RECORD_FILE) + with pytest.raises(PermissionError): + shape_advisory.Tree(tmp_path).kind(shape_advisory.RECORD_FILE) + state, why, _notes = shape_advisory.read_record(tmp_path) + assert state == shape_advisory.RECORD_SILENCES_NOTHING + assert why == ["it could not be read (PermissionError)"] + + +@CANNOT_RUN_AS_ROOT +def test_a_record_in_a_folder_this_user_cannot_search(tmp_path): + """The same, under a real mode: a folder this user can list and not + search.""" + folder = tmp_path / "single" + folder.mkdir() + (folder / shape_advisory.RECORD_FILE).write_text("kind: x\n") + with mode_of(tmp_path, "single", 0o444): + state, why, _notes = shape_advisory.read_record(folder) + assert state == shape_advisory.RECORD_SILENCES_NOTHING + assert why == ["it could not be read (PermissionError)"] + + +def test_a_record_that_is_not_there_is_still_absent(tmp_path): + """The control: no file, or a name under a file, is absent.""" + assert shape_advisory.Tree(tmp_path).kind("nothing.yaml") is None + (tmp_path / "plain").write_text("x\n") + assert shape_advisory.Tree(tmp_path).kind("plain/inside") is None + assert shape_advisory.Tree(tmp_path).kind("plain") == "file" + assert shape_advisory.Tree(REPO).kind("scripts") == "dir" + assert shape_advisory.read_record(tmp_path)[0] == \ + shape_advisory.RECORD_ABSENT + + +def test_the_advisory_calls_nothing_there_what_the_gate_calls_so(): + """`shape_advisory.py`'s `_ABSENT_ERRNOS` and `_ABSENT_WINERRORS` are + the gate's and the doctor's (#430's rule: one errno set).""" + gate = load_without_bytecode(GATE, "gate_unreadable_paths_parity") + assert (shape_advisory._ABSENT_ERRNOS, shape_advisory._ABSENT_WINERRORS) \ + == (gate._ABSENT_ERRNOS, gate._ABSENT_WINERRORS)