From e21c8c05c20580211d2297be00f0e2464b06de93 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Wed, 21 Oct 2026 21:31:29 +0000 Subject: [PATCH 01/18] shape-doctor.py and shape_advisory.py: a `.git` or an agent file in the root that is a link into a folder this user cannot search is said once, `: cannot be read ()` with the chmod cure, never absent, and no way in is offered over it (#1110) Slice A of #1110: the eight sites in `shape-doctor.py` and `scripts/shape_advisory.py` that asked `exists()` or `is_file()` of a name directly in the root now ask `is_a` / `_is_a`, which raise a refused `stat`. - `what is here` names each name it cannot read in validate-pins.py's words, with this doctor again as the NEXT and the gate's advice as the WHY: the one row that carries the cure. - `the way in` offers neither command while the `.git` cannot be read, and points at `what is here`; `the other way in` and the Triad row are silent. - `naming` classifies the folder's own name and says origin's was not read. - `origin_org`, `repo_local_origin_name` and `repository_names` raise the refused `stat` (`carries_git`, `_is_a`) where they answered as for a folder with no repository. - `shape_advisory.read()` answers `not-answered` (silent) with the `.git`'s line in `why`, where 3.14 said `not-a-repository`; no new state. - `agent files` says the line with the cure: a `note` for a rendered file nothing pins, a FINDING for the pinned `AGENTS-shape.md`. The eight entries leave `KNOWN_OPEN`; the three family sites stay, slice B. New rows: tests/test_doctor_unsearchable_links.py. Part of #1110 Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- scripts/shape_advisory.py | 30 +- shape-doctor.py | 177 +++++++++- tests/test_doctor_unsearchable_links.py | 434 ++++++++++++++++++++++++ tests/test_path_guard_hygiene.py | 25 -- 4 files changed, 622 insertions(+), 44 deletions(-) create mode 100644 tests/test_doctor_unsearchable_links.py diff --git a/scripts/shape_advisory.py b/scripts/shape_advisory.py index 1d6254fd..2c72a3b6 100644 --- a/scripts/shape_advisory.py +++ b/scripts/shape_advisory.py @@ -137,7 +137,13 @@ #: construction is the one thing in a run that must never be the reason it #: crashed. So whatever `read()` meets that it did not expect (a record nested #: past the interpreter's recursion limit, a NUL in a configured path) ends -#: here: silent, with the exception's NAME in `why` for `--json`. +#: here: silent, with the exception's NAME in `why` for `--json`. So does a +#: `.git` this user cannot reach, the one such case `_read` asks for itself +#: (#1110), with its line in `validate-pins.py`'s words, `.git: cannot be read +#: ()`, in `why`. It is no new state: whether this is a repository is +#: not known, which is "could not be read here", and every reader already +#: answers this one with silence -- the doctor's `Triad` row and `leg_clone`, +#: and `adopt-project.py`'s `triad` lines. NOT_ANSWERED = "not-answered" STATES = (ADVISE, TRIAD_ROOT, FAMILY_HOLDER, LEG_CLONE, WORKSPACE, RECORDED_SINGLE, NOT_A_REPOSITORY, NOT_ANSWERED) @@ -490,9 +496,16 @@ def repository_names(root: Path) -> list[str]: `naming` row and the `Triad` row read one identity. The directory's own name, the leg's `AGENTS.md` and the person's `workspace.yaml` still answer. + + A `.git` THIS USER CANNOT REACH RAISES (`_is_a`, #1110). It is a link + into a folder this user cannot search, and `exists()` follows it: up to + Python 3.13 that raised too, and from 3.14 it answered False, so the + origin's name was dropped (`['Atlas']` for `['Atlas', 'Orbit']`) as for + a folder with no repository. `_read` asks the `.git` first and answers + for it by name; a caller asking this alone gets the `OSError`. """ names = [root.name] - if (root / ".git").exists(): + if _is_a(root / ".git", lambda _mode: True): try: url = git_out(["remote", "get-url", "origin"], cwd=root) except (Refusal, OSError, UnicodeDecodeError): @@ -776,7 +789,18 @@ def _read(root: Path, shape: Path, env, commit: str | None) -> Reading: return Reading(FAMILY_HOLDER, f"{FAMILY_MANIFEST} declares a family " "holder, which pins projects and is " "not one") - if not (root / ".git").exists(): + try: + repository = _is_a(root / ".git", lambda _mode: True) + except OSError as exc: + # A `.git` THIS USER CANNOT REACH (#1110): not `NOT_A_REPOSITORY`, + # which is what 3.14's `exists()` made of it, and no new state + # either -- see `NOT_ANSWERED`. + return Reading(NOT_ANSWERED, f".git: cannot be read " + f"({exc.strerror or type(exc).__name__})," + " so whether this is a repository is not " + "known here, and the advisory says " + "nothing") + if not repository: return Reading(NOT_A_REPOSITORY, "there is no repository here to " "advise about") policy = _naming_policy(shape) diff --git a/shape-doctor.py b/shape-doctor.py index 118558ed..1587d1b1 100755 --- a/shape-doctor.py +++ b/shape-doctor.py @@ -9892,14 +9892,52 @@ def confirm_digest(ctx: Context) -> str: def check_agent_files(ctx: Context) -> Row: - present = {name: (ctx.root / name).is_file() for name in AGENT_FILES} + """`AGENTS-shape.md`, `AGENTS.md` and `CLAUDE.md` at the root: each + there, or the command that puts it back. + + A FILE THIS USER CANNOT REACH IS THERE, AND SAID SO (#1110). Each is + asked with `is_a`, which raises a `stat` the operating system refused: an + `AGENTS.md` that is a link into a folder this user cannot search ended + this row in `could not be run` up to Python 3.13, which `run_checks` made + a FINDING, and from 3.14, where `is_file()` answers False to any + `OSError`, it was `absent: AGENTS.md` with `scaffold-project.py --help` + over a file that is there. It is now `: cannot be read ()`, + in `validate-pins.py`'s words, with this doctor again as the NEXT and the + gate's advice for the cause as the WHY (`cannot_read_why`), on every + Python. `files` keeps `true` for it, since it is there, and + `cannot_be_read` lists the lines. + + A FINDING ONLY WHERE THE PIN NAMES IT, as for a file that is absent: the + project's own `validate-pins.py` refuses a pinned copy it cannot read by + name, and this row agrees with it; a rendered file nothing pins is a + `note`, and moves no verdict. Beside a file that IS absent, that file's + command stays the NEXT, as the `legs` row keeps its checkout line beside + a pin it cannot read, and the gate's advice is added to its `why`. + """ + present: dict[str, bool] = {} + refused: dict[str, OSError] = {} + for name in AGENT_FILES: + try: + present[name] = is_a(ctx.root / name, stat.S_ISREG) + except OSError as exc: + present[name] = True + refused[name] = exc absent = [name for name, there in present.items() if not there] detail = {"files": present} - if not absent: + if not absent and not refused: return Row("agent-files", LABEL_AGENT_FILES, OK, ", ".join(f"{name} present" for name in AGENT_FILES), None, detail) pinned = pinned_paths(ctx.root) + unread = [cannot_be_read(name, exc) for name, exc in refused.items()] + advice = cannot_read_why(list(refused.values())) if refused else None + if refused: + detail["cannot_be_read"] = unread + unread_pinned = any(name in pinned for name in refused) + if not absent: + return Row("agent-files", LABEL_AGENT_FILES, + FINDING if unread_pinned else NOTE, ", ".join(unread), + doctor_line(ctx), detail, why=advice) detail["pinned"] = sorted(name for name in absent if name in pinned) # WHICH FIX DEPENDS ON WHETHER THE PIN ALREADY NAMES IT, and getting that # backwards hands somebody a command that refuses. A path the pin has a @@ -9937,9 +9975,13 @@ def check_agent_files(ctx: Context) -> Row: # without one is a root nothing in this standard says anything about, and # failing it here would be the `leg shape files` mistake again, one file # along. - status = FINDING if detail["pinned"] else NOTE - return Row("agent-files", LABEL_AGENT_FILES, status, - "absent: " + ", ".join(absent), fix, detail, why=why) + status = FINDING if detail["pinned"] or unread_pinned else NOTE + reason = "absent: " + ", ".join(absent) + if refused: + reason += "; " + ", ".join(unread) + why += ". " + advice + return Row("agent-files", LABEL_AGENT_FILES, status, reason, fix, detail, + why=why) def detached(path: Path, git_dir: Path | None = None) -> bool | None: @@ -10960,6 +11002,24 @@ def origin_name(root: Path) -> str | None: return name[:-4] if name.endswith(".git") else name or None +def carries_git(root: Path) -> bool: + """Whether `root` ITSELF carries a `.git` -- a directory, a gitdir file, + or a link to either -- asked with `is_a`, which RAISES the `OSError` of a + `stat` the operating system refused, for the caller to name (#1110). + + NOT `(root / ".git").exists()`, which follows a link. A `.git` that is a + link into a folder this user cannot search raised `PermissionError` out of + every reader up to Python 3.13, and from 3.14, where `exists()` answers + False to any `OSError`, it was no repository at all: `the way in` sent + the person to `setup.sh` to scaffold three new repositories over the one + in front of them, and `origin_org` and `repo_local_origin_name` gave the + answer for a folder that is not a repository. Nothing there, or a part of + the path that is not a directory, is False as it always was (`is_a`, + #430's set). + """ + return is_a(root / ".git", lambda _mode: True) + + def repo_local_origin_name(root: Path) -> str | None: """`origin`'s name, but only when `root` ITSELF carries the `.git` it belongs to. @@ -10973,8 +11033,13 @@ def repo_local_origin_name(root: Path) -> str | None: OWN identity -- `naming` and `the way in` -- share this guard rather than each re-deriving "is this directory itself a repository" on its own. + + A `.git` THIS USER CANNOT REACH RAISES (`carries_git`, #1110): None is + the answer for a folder with no repository of its own, and that one may + have one. `naming` catches it and says `origin`'s name was not read, and + `the way in` asks the `.git` itself before it asks this. """ - return origin_name(root) if (root / ".git").exists() else None + return origin_name(root) if carries_git(root) else None def origin_org(root: Path) -> str | None: @@ -11003,9 +11068,12 @@ def origin_org(root: Path) -> str | None: ONLY WHEN `root` CARRIES ITS OWN `.git`, for `repo_local_origin_name`'s reason: `git remote get-url origin` walks UP to the nearest ancestor - repository, and an ancestor's organisation is not this directory's. + repository, and an ancestor's organisation is not this directory's. A + `.git` this user cannot reach RAISES (`carries_git`, #1110), where it was + None, the answer for a folder that is no repository: `the way in` asks + the `.git` first, and offers no way in over it. """ - if not (root / ".git").exists(): + if not carries_git(root): return None try: url = git_out(["remote", "get-url", "origin"], cwd=root) @@ -11026,10 +11094,19 @@ def check_not_a_root_naming(ctx: Context) -> Row: so a loose directory nested inside somebody else's checkout is classified on its own name alone, the same fact `the way in` row now reads it by (Copilot, PR #110). + + A `.git` THIS USER CANNOT REACH (#1110) leaves the directory's own name + to classify, and the row says that `origin`'s was not read, rather than + treat the folder as one with no repository. The line and its cure are + the `what is here` row's, which reads every name in the directory, so + they are said once. `detail.cannot_be_read` carries the line. """ script = ctx.shape / NAMING_SCRIPT names = [ctx.root.name] - remote = repo_local_origin_name(ctx.root) + try: + remote, unread = repo_local_origin_name(ctx.root), None + except OSError as exc: + remote, unread = None, cannot_be_read(".git", exc) if remote and remote not in names: names.append(remote) # `--` BEFORE THE NAMES. Without it a directory literally named `--help` @@ -11047,6 +11124,11 @@ def check_not_a_root_naming(ctx: Context) -> Row: ctx, script, ["--explain", "--", *names]) detail = {"names": names, "origin": remote, "exit": code, "explain": ascii_text(whole).strip().splitlines()[:40]} + if unread is not None: + detail["cannot_be_read"] = unread + unread_said = ("" if unread is None else + "; origin's name is not read while .git cannot be read " + "(see `what is here`)") spelled = " ".join(quote_arg(name) for name in names) command = f"{PYTHON} {quote_arg(script)} --explain -- {spelled}" # A CHECKOUT WHOSE OWN POLICY DOES NOT LOAD SAID NOTHING ABOUT THE NAME @@ -11065,19 +11147,39 @@ def check_not_a_root_naming(ctx: Context) -> Row: return naming_crashed_row(code, crash, command, detail, names) if code >= 2: return Row("naming", "naming", FINDING, - f"{', '.join(names)}: {naming_refuses_words(code, quoted)}", - command, detail) + f"{', '.join(names)}: {naming_refuses_words(code, quoted)}" + f"{unread_said}", command, detail) reason = f"{', '.join(names)}: " + ( "classifies under the naming policy" if code == 0 - else (quoted or "matches no family in the policy")) + else (quoted or "matches no family in the policy")) + unread_said return Row("naming", "naming", OK if code == 0 else FINDING, reason, None if code == 0 else command, detail) def check_what_is_here(ctx: Context) -> Row: - """What IS in this directory, said plainly, so the reader can place it.""" + """What IS in this directory, said plainly, so the reader can place it. + + A NAME THIS USER CANNOT REACH IS THERE, AND SAID SO HERE, ONCE (#1110). + Each name is asked with `is_a`, which raises a `stat` the operating + system refused. A `.git` or an `AGENTS.md` that is a link into a folder + this user cannot search ended this row in `could not be run` up to Python + 3.13, which `run_checks` made a FINDING in each row that asked it, four + times for a `.git`; from 3.14, where `exists()` answers False to any + `OSError`, it was not there at all, and the row called the directory + `an ordinary directory`. Now the row says `: cannot be read + ()`, in `validate-pins.py`'s words, before what it did find, with + this doctor again as the NEXT and the gate's advice for the cause as the + WHY (`cannot_read_why`), on every Python. It is the one row that reads + every name here, so the cure is said in it and nowhere else: `the way + in` offers no way in while the `.git` cannot be read, and points here. + Still `n/a`: nothing asserts a name in a directory that is no shape root, + and its verdict reads no row. The name's entry in `detail` is `true`, + since it is there, and `cannot_be_read` lists the lines. + """ root = ctx.root facts: list[str] = [] + unread: list[str] = [] + refused: list[OSError] = [] detail = {} for name, note in ((".git", "a git repository"), (".gitmodules", "a .gitmodules"), @@ -11092,7 +11194,13 @@ def check_what_is_here(ctx: Context) -> Row: # whose row says what it found in it. (shape_advisory.RECORD_FILE, f"a {shape_advisory.RECORD_FILE}")): - there = (root / name).exists() + try: + there = is_a(root / name, lambda _mode: True) + except OSError as exc: + detail[name] = True + unread.append(cannot_be_read(name, exc)) + refused.append(exc) + continue detail[name] = there if there: facts.append(note) @@ -11105,6 +11213,12 @@ def check_what_is_here(ctx: Context) -> Row: # never here, because its filename makes the root that kind (#205). facts.append("a manifest file that declares neither `kind: " "project-manifest` nor `kind: family-manifest`") + if unread: + detail["cannot_be_read"] = unread + return Row("contents", "what is here", NA, + "; ".join([", ".join(unread)] + + ([", ".join(facts)] if facts else [])), + doctor_line(ctx), detail, why=cannot_read_why(refused)) return Row("contents", "what is here", NA, ", ".join(facts) if facts else "no git repository, no manifest, no contracts/ -- an ordinary " @@ -11471,9 +11585,30 @@ def check_the_way_in(ctx: Context) -> Row: environment row naming the checkout's file, in a plain directory and in a repository, which is also told of `the other way in` below. A leg clone, which is offered no command, is the row it always was. + + A `.git` THIS USER CANNOT REACH OFFERS NO WAY IN (#1110). A `.git` that + is a link into a folder this user cannot search raised `PermissionError` + up to Python 3.13, which `run_checks` made a FINDING here and in three + other rows; from 3.14, where `exists()` answers False to any `OSError`, + this row said `there is no repository here to adopt` and named + `setup.sh`, three new repositories, over the one in front of the person. + Whether it is a repository to adopt is not known until the `.git` can be + read, so this row offers neither command and asks nothing of `origin` + (`carries_git`, asked first). It is `n/a`, as every answer here is, and + points at `what is here`, which names the `.git` with the cure once. """ root = ctx.root - is_repo = (root / ".git").exists() + try: + is_repo = carries_git(root) + except OSError as exc: + line = cannot_be_read(".git", exc) + return Row("way-in", "the way in", NA, + "its .git cannot be read, so it is not known whether this " + "is a git repository to adopt, and neither way in is " + "offered until it can be: `what is here` says how", None, + {"git_repository": None, "suggested_project": None, + "origin_org": None, "cannot_be_read": line, + "path": ".git"}) project, refusal = way_in_project(ctx) org = origin_org(root) if is_repo and leg_clone(ctx): @@ -11595,8 +11730,18 @@ def check_the_other_way_in(ctx: Context) -> Row | None: sent to its assembly root (`leg_clone`, asked as `check_the_way_in` asks it). A row that repeated the scaffold there would be this report printing the same line twice, or contradicting the Triad row above it. + + SILENT TOO WHERE THE `.git` CANNOT BE READ (#1110): whether this is a + repository is not known, so `the way in` offers neither command and this + row offers no second one. On Python 3.14 `exists()` answered that `.git` + False, and the row was left out as for a folder with no repository; up + to 3.13 it raised, and `run_checks` named the `.git` here a fourth time. """ - if not (ctx.root / ".git").exists() or leg_clone(ctx): + try: + repository = carries_git(ctx.root) + except OSError: + return None + if not repository or leg_clone(ctx): return None project, refusal = way_in_project(ctx) if refusal is not None: diff --git a/tests/test_doctor_unsearchable_links.py b/tests/test_doctor_unsearchable_links.py new file mode 100644 index 00000000..34fab624 --- /dev/null +++ b/tests/test_doctor_unsearchable_links.py @@ -0,0 +1,434 @@ +# SPDX-License-Identifier: Apache-2.0 +"""A name in the root that is a link into a folder this user cannot search is +named by the doctor and by the Triad advisory, never taken for nothing there: +#1110's slice A (`shape-doctor.py` and `scripts/shape_advisory.py`). + +THE DEFECT. #430's rule is to `stat` a path and read only ENOENT, ENOTDIR, +EBADF and ELOOP as nothing there. Eight sites still asked `exists()` or +`is_file()` of a name directly in the root, and those follow a link: a `.git` +or an `AGENTS.md` that is a link into a folder at mode 000 raised +`PermissionError` up to Python 3.13, and from 3.14 answered False. Measured on +main `038dafba`, Python 3.12.3 and a 3.14-like shim (a `sitecustomize` whose +`is_file`, `is_dir` and `exists` answer False to any `OSError`), over a folder +`Atlas` whose `.git` links into such a folder, origin `acme/Orbit`: + + * 3.12: the `naming`, `what is here`, `the way in` and `the other way in` + rows each `FINDING .git: cannot be read (Permission denied)`, with the + same NEXT and the same `chmod` advice, four times; no Triad row; + * the shim: `what is here` said `an ordinary directory`, and `the way in` + said `there is no repository here to adopt` and sent the person to + `setup.sh`, three new repositories; `naming` classified `Atlas` alone; + `origin_org` and `repo_local_origin_name` gave None and + `shape_advisory.repository_names` `['Atlas']`, the answers for a folder + that is no repository, and `read()` said `not-a-repository`; + * an `AGENTS.md` linked so: `absent: AGENTS.md` under the shim, with + `scaffold-project.py --help` over a file that is there, in the `agent + files` row of a project and in `what is here` elsewhere; on 3.12 a + FINDING made of the raise, which put `agent-files` in the verdict for a + file nothing pins. + +NOW, ON EVERY PYTHON: `what is here`, the one row that reads every name in a +directory that is no shape root, says `: cannot be read ()`, in +`validate-pins.py`'s words, with this doctor again as the NEXT and the gate's +advice as the WHY, once. `the way in` offers neither command while the `.git` +cannot be read, `the other way in` and the Triad row say nothing, and +`naming` classifies the folder's own name and says `origin`'s was not read. +The `agent files` row of a project says the same line, a `note` for a file +nothing pins and a FINDING for one the pin names, as for a file that is +absent. The advisory answers `not-answered`, silent, with the line in `why`. + +TWO KINDS OF CASE, as `tests/test_doctor_unreadable_paths.py` has them. The +MODE cases make a real link into a real folder at 000 and run the doctor as a +person does; they skip on Windows and as root (`CANNOT_RUN_AS_ROOT`), and put +the mode back in a `finally` (`mode_of`). The STAND-IN cases refuse the +`stat` in process (`refused_stat`, which also answers `exists()` as 3.14 +does, and `refusing` for another reason), so they run everywhere. +""" + +from __future__ import annotations + +import errno +import json +import os +import sys +from pathlib import Path + +import pytest + +from conftest import REPO, git +#: #827's stand-in for a `stat` the operating system refuses, answering +#: `exists()` and `is_file()` as Python 3.14 does, from the file that owns it. +from test_bump_leg_unopenable import refused_stat +#: 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, IO_ERROR, 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 + doctor, doctor_module, project, rows_of, 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 + +#: The repository behind the link: a checkout of `Orbit`, in a folder that is +#: called something else, as a clone into another name is. +ORIGIN = "git@github.com:acme/Orbit.git" +GIT_LINE = cannot(".git") +AGENTS_LINE = cannot("AGENTS.md") +#: What `the way in` says while the `.git` cannot be read. +NO_WAY_IN = ("its .git cannot be read, so it is not known whether this is a " + "git repository to adopt, and neither way in is offered until " + "it can be: `what is here` says how") +#: What `naming` adds while the `.git` cannot be read. +ORIGIN_UNREAD = ("; origin's name is not read while .git cannot be read " + "(see `what is here`)") +#: What the advisory says of it, in `--json`'s `why`. +ADVISORY_WHY = (f"{GIT_LINE}, so whether this is a repository is not known " + "here, and the advisory says nothing") +#: The words no row may say of a name that IS there, and the lines that send +#: a person to adopt or scaffold (the `machine` row's `--preflight` is neither). +SAYS_NOTHING_THERE = ("absent", "no repository", "an ordinary directory", + "SCAFFOLDED") +OFFERS_A_WAY_IN = (" --org ", "adopt-project.py") + + +def symlink(at: Path, to: Path) -> None: + """`at`, a link to the relative path `to`; a skip where none can be + made.""" + try: + at.symlink_to(to) + except OSError as exc: + pytest.skip(f"no symlink here: {exc}") + + +def repository_here(root: Path) -> Path: + """`root`, a git repository of its own whose `origin` is `ORIGIN`.""" + root.mkdir() + git("init", "-q", "-b", "main", ".", cwd=root) + git("remote", "add", "origin", ORIGIN, cwd=root) + return root + + +def linked_repository(tmp_path: Path) -> Path: + """`/Atlas`, whose `.git` is a link to the git directory of the + repository at `/locked/repo`, whose `origin` is `ORIGIN`.""" + (tmp_path / "locked").mkdir() + repository_here(tmp_path / "locked" / "repo") + root = tmp_path / "Atlas" + root.mkdir() + symlink(root / ".git", Path("..", "locked", "repo", ".git")) + (root / "README.md").write_text("# Atlas\n", encoding="utf-8") + return root + + +def told_once(rows: dict, line: str) -> None: + """`line` is the reason of ONE row, which alone carries this doctor + again and the gate's advice; no other row says the line or the advice.""" + saying = [key for key, row in rows.items() if line in row["reason"]] + advising = [key for key, row in rows.items() + if PERMISSION_WHY in (row["why"] or "") + or OTHER_WHY in (row["why"] or "")] + assert len(saying) == 1, saying + assert advising == saying, (saying, advising) + + +def no_way_in_over_it(rows: dict) -> None: + """No row says nothing is there, and none sends the person to adopt or + scaffold. The `machine` row is about the workstation, not the folder.""" + for key, row in rows.items(): + if key == "machine": + continue + for words in SAYS_NOTHING_THERE: + assert words not in row["reason"], row + for words in OFFERS_A_WAY_IN: + assert words not in (row["next"] or ""), row + + +def by_id(rows) -> dict: + """`{id: row}` as `--json` has them, for rows made in process.""" + return {row.id: {"status": row.status, "reason": row.reason, + "next": row.next_command, "why": row.why, + "detail": row.detail} + for row in rows if row is not None} + + +def not_a_root_rows(module, ctx) -> dict: + """The rows a directory with no manifest gets, but `machine`, in + process.""" + return by_id([module.check_not_a_root_naming(ctx), + module.check_what_is_here(ctx), + module.check_the_way_in(ctx), + module.check_the_other_way_in(ctx), + module.check_triad_advisory(ctx)]) + + +# --- the mode cases: a real link, the doctor run as a person runs it -------- + +@CANNOT_RUN_AS_ROOT +def test_a_git_linked_into_a_folder_nobody_may_search_offers_no_way_in( + standard, tmp_path): + """The issue's worst case. Readable, the folder is the repository it + links to, adopted as `Orbit`. At 000 it was four FINDINGs of one line on + 3.12, and on 3.14 an ordinary directory sent to `setup.sh`. Now `what is + here` names the `.git` once with the cure, `the way in` offers nothing, + `the other way in` and the Triad row are silent, and nothing raises.""" + root = linked_repository(tmp_path) + readable, _verdict = doctored(standard, root) + assert "adopt-project.py" in readable["way-in"]["next"], readable + assert "--project Orbit" in readable["way-in"]["next"], readable + assert readable["naming"]["detail"]["names"] == ["Atlas", "Orbit"] + denied = os.strerror(errno.EACCES) + line = cannot(".git", denied) + with mode_of(tmp_path, "locked", 0): + result = doctor(standard, root, "--json") + assert "Traceback" not in result.stderr, result.stderr + assert result.returncode == 2, result.stdout + result.stderr + assert json.loads(result.stdout)["verdict"] == "NOT A SHAPE ROOT" + rows = rows_of(result) + assert set(rows) == {"naming", "contents", "way-in", "machine"}, rows + contents, way_in, naming = rows["contents"], rows["way-in"], rows["naming"] + assert contents["status"] == "n/a", contents + assert contents["reason"] == line + assert contents["next"] == doctor_again(standard, root) + assert contents["why"] == PERMISSION_WHY + assert contents["detail"][".git"] is True + assert contents["detail"]["cannot_be_read"] == [line] + assert way_in["status"] == "n/a", way_in + assert way_in["reason"] == NO_WAY_IN + assert way_in["next"] is None, way_in + assert way_in["detail"]["git_repository"] is None, way_in + assert way_in["detail"]["cannot_be_read"] == line + assert naming["reason"] == ("Atlas: classifies under the naming policy" + + ORIGIN_UNREAD) + assert naming["detail"]["names"] == ["Atlas"] + assert naming["detail"]["cannot_be_read"] == line + told_once(rows, line) + no_way_in_over_it(rows) + + +@CANNOT_RUN_AS_ROOT +def test_an_agents_md_linked_into_a_folder_nobody_may_search_is_there( + standard, tmp_path): + """A directory that is no shape root, with an `AGENTS.md` linked into a + folder at 000: `what is here` names it with the cure, where 3.14 called + the directory ordinary and 3.12 made a FINDING of the raise. With no + `.git` here, the way in is still the scaffold.""" + locked, root = tmp_path / "locked", tmp_path / "Atlas" + locked.mkdir() + root.mkdir() + (locked / "AGENTS.md").write_text("# agents\n", encoding="utf-8") + symlink(root / "AGENTS.md", Path("..", "locked", "AGENTS.md")) + readable, _verdict = doctored(standard, root) + assert readable["contents"]["reason"] == "an AGENTS.md", readable + line = cannot("AGENTS.md", os.strerror(errno.EACCES)) + with mode_of(tmp_path, "locked", 0): + rows, verdict = doctored(standard, root) + assert verdict == "NOT A SHAPE ROOT" + contents = rows["contents"] + assert contents["status"] == "n/a", contents + assert contents["reason"] == line + assert contents["next"] == doctor_again(standard, root) + assert contents["why"] == PERMISSION_WHY + assert contents["detail"]["AGENTS.md"] is True + assert rows["way-in"]["detail"]["git_repository"] is False + told_once(rows, line) + + +@CANNOT_RUN_AS_ROOT +@pytest.mark.parametrize("name", ["AGENTS.md", "CLAUDE.md"]) +def test_a_projects_agent_file_linked_out_of_reach_is_a_note( + standard, project, tmp_path, name): + """A project's rendered agent file, a link into a folder at 000: the + `agent files` row says the gate's line, with the cure, as a `note`, and + the verdict is the project's own. 3.12 made a FINDING of the raise and + `INVALID (agent-files)` of a COMPLIANT project; 3.14 said `absent` with + `scaffold-project.py --help` over a file that is there.""" + locked = tmp_path / "locked" + locked.mkdir() + (project / name).rename(locked / name) + symlink(project / name, Path("..", "locked", name)) + _rows, readable = doctored(standard, project) + line = cannot(name, os.strerror(errno.EACCES)) + with mode_of(tmp_path, "locked", 0): + rows, verdict = doctored(standard, project) + files = rows["agent-files"] + assert files["status"] == "note", files + assert files["reason"] == line + assert files["next"] == doctor_again(standard, project) + assert files["why"] == PERMISSION_WHY + assert files["detail"]["files"][name] is True + assert files["detail"]["cannot_be_read"] == [line] + assert verdict == readable + told_once(rows, line) + + +# --- the stand-in cases: in process, on every platform and as root ---------- + +@pytest.mark.parametrize("ask", ["carries_git", "origin_org", + "repo_local_origin_name"]) +def test_the_git_questions_raise_a_refused_stat(standard, tmp_path, + monkeypatch, ask): + """Each question the doctor asks of the root's own `.git` raises a + refused `stat` for its caller to name, where 3.14's `exists()` answered + as for a folder with no repository: None from `origin_org` and from + `repo_local_origin_name`.""" + module = doctor_module(standard) + question = getattr(module, ask) + root = repository_here(tmp_path / "Atlas") + answered = {"carries_git": True, "origin_org": "acme", + "repo_local_origin_name": "Orbit"} + assert question(root) == answered[ask] + # The controls: no `.git`, or a name under a file, is no repository. + plain = tmp_path / "Plain" + plain.mkdir() + (tmp_path / "file").write_text("x\n", encoding="utf-8") + for nothing in (plain, tmp_path / "file"): + assert question(nothing) in (False, None) + refused_stat(monkeypatch, ".git") + with pytest.raises(PermissionError): + question(root) + + +@pytest.mark.parametrize("error, line, why", [ + pytest.param(PermissionError(errno.EACCES, DENIED), GIT_LINE, + PERMISSION_WHY, id="permission"), + pytest.param(OSError(errno.EIO, IO_ERROR), cannot(".git", IO_ERROR), + OTHER_WHY, id="io-error"), +]) +def test_a_git_this_user_cannot_reach_is_named_once_on_every_python( + standard, tmp_path, monkeypatch, error, line, why): + """The rows of a directory with no manifest, in process, the `.git`'s + `stat` refused: `what is here` names it with the advice for the cause, + `the way in` offers nothing, `the other way in` and the Triad row are + silent, and `naming` says `origin` was not read. An I/O error is told + the gate's other advice, not `chmod`.""" + module = doctor_module(standard) + root = repository_here(tmp_path / "Atlas") + ctx = module.Context(root.resolve()) + if isinstance(error, PermissionError): + refused_stat(monkeypatch, ".git") + else: + refusing(monkeypatch, "stat", ".git", error) + rows = not_a_root_rows(module, ctx) + assert set(rows) == {"naming", "contents", "way-in"}, rows + contents = rows["contents"] + assert contents["reason"] == line + assert contents["next"] == module.doctor_line(ctx) + assert contents["why"] == why + assert contents["status"] == module.NA + assert rows["way-in"]["reason"] == NO_WAY_IN + assert rows["way-in"]["next"] is None + assert rows["way-in"]["detail"] == { + "git_repository": None, "suggested_project": None, + "origin_org": None, "cannot_be_read": line, "path": ".git"} + assert rows["naming"]["reason"].endswith(ORIGIN_UNREAD), rows["naming"] + told_once(rows, line) + no_way_in_over_it(rows) + + +def test_beside_what_it_can_read_the_row_names_what_it_cannot_first( + standard, tmp_path, monkeypatch): + """`what is here` with a `Makefile` it can read and a `.git` it cannot: + the line first, then what it found, and never `a git repository`.""" + module = doctor_module(standard) + root = repository_here(tmp_path / "Atlas") + (root / "Makefile").write_text("all:\n", encoding="utf-8") + ctx = module.Context(root.resolve()) + refused_stat(monkeypatch, ".git") + row = module.check_what_is_here(ctx) + assert row.reason == f"{GIT_LINE}; a Makefile" + assert row.detail[".git"] is True + assert row.detail["Makefile"] is True + + +def test_the_advisory_names_a_git_it_cannot_reach_and_says_nothing( + tmp_path, monkeypatch): + """`shape_advisory`: `repository_names` raises the refused `stat` where + 3.14 dropped `origin`'s name, and `read()` answers `not-answered`, which + every reader keeps silent, naming the `.git` in `why`, where 3.14 said + `not-a-repository`.""" + root = repository_here(tmp_path / "Atlas") + quiet = tmp_path / "agent-protocol" + quiet.mkdir() + env = {"AGENT_PROTOCOL_ROOT": str(quiet)} + assert shape_advisory.repository_names(root) == ["Atlas", "Orbit"] + assert shape_advisory.read(root, env=env).state == shape_advisory.ADVISE + refused_stat(monkeypatch, ".git") + with pytest.raises(PermissionError): + shape_advisory.repository_names(root) + reading = shape_advisory.read(root, env=env) + assert reading.state == shape_advisory.NOT_ANSWERED + assert reading.why == ADVISORY_WHY + assert reading.advise is False + + +def test_the_doctor_reads_the_advisory_as_silent(standard, tmp_path, + monkeypatch): + """The doctor's two readers of the advisory over that `.git`: no Triad + row, and not a leg clone.""" + module = doctor_module(standard) + root = repository_here(tmp_path / "Atlas") + ctx = module.Context(root.resolve()) + refused_stat(monkeypatch, ".git") + assert module.shape_advisory.read(ctx.root, ctx.shape).why == ADVISORY_WHY + assert module.check_triad_advisory(ctx) is None + assert module.leg_clone(ctx) is False + + +@pytest.mark.parametrize("refuse, status, line, why", [ + pytest.param(lambda patch: refused_stat(patch, "AGENTS.md"), "note", + AGENTS_LINE, PERMISSION_WHY, id="rendered"), + pytest.param(lambda patch: refusing(patch, "stat", "AGENTS.md", + OSError(errno.EIO, IO_ERROR)), + "note", cannot("AGENTS.md", IO_ERROR), OTHER_WHY, + id="io-error"), + pytest.param(lambda patch: refused_stat(patch, "AGENTS-shape.md"), + "FINDING", cannot("AGENTS-shape.md"), PERMISSION_WHY, + id="pinned"), +]) +def test_an_agent_file_this_user_cannot_reach_is_named_on_every_python( + standard, project, monkeypatch, refuse, status, line, why): + """The `agent files` row with one file's `stat` refused: the gate's + line, this doctor again and the advice for the cause, never `absent`. A + `note` for a file nothing pins, and a FINDING for the copy the pin + names, which the project's own gate refuses by name.""" + module, ctx = in_process(standard, project) + pinned = module.pinned_paths(ctx.root) + assert "AGENTS-shape.md" in pinned and "AGENTS.md" not in pinned, pinned + refuse(monkeypatch) + row = module.check_agent_files(ctx) + assert row.status == status + assert row.reason == line + assert row.next_command == doctor_again(standard, project) + assert row.why == why + assert all(row.detail["files"].values()), row.detail + assert row.detail["cannot_be_read"] == [line] + assert "absent" not in row.reason + + +def test_beside_an_absent_agent_file_its_command_stays_the_next( + standard, project, monkeypatch): + """`CLAUDE.md` gone and `AGENTS.md` out of reach: the absent file's + command is the NEXT, said first, and the advice for the other follows + its `why`, as the `legs` row keeps its checkout line beside a pin it + cannot read.""" + module, ctx = in_process(standard, project) + (project / "CLAUDE.md").unlink() + alone = module.check_agent_files(ctx) + refused_stat(monkeypatch, "AGENTS.md") + row = module.check_agent_files(ctx) + assert row.reason == f"absent: CLAUDE.md; {AGENTS_LINE}" + assert row.next_command == alone.next_command + assert row.why == f"{alone.why}. {PERMISSION_WHY}" + assert row.status == alone.status == module.NOTE + assert row.detail["files"] == {"AGENTS-shape.md": True, "AGENTS.md": True, + "CLAUDE.md": False} diff --git a/tests/test_path_guard_hygiene.py b/tests/test_path_guard_hygiene.py index 070f5597..0fb4f7c8 100644 --- a/tests/test_path_guard_hygiene.py +++ b/tests/test_path_guard_hygiene.py @@ -620,31 +620,6 @@ def _entries(table: dict) -> dict: (f"{FR}/siblings.py", "submodule_urls", "path.is_file()"): ( FOLLOW_UP, "the same, in the pinned `siblings.py`: no url read, " "so a member is cloned from its row's default"), - (SD, "check_agent_files", "(ctx.root / name).is_file()"): ( - FOLLOW_UP, "an agent file that is a link into a folder this user " - "cannot search reads `absent` in the `agent files` note"), - (SD, "check_what_is_here", "(root / name).exists()"): ( - FOLLOW_UP, "the same for the NOT A SHAPE ROOT row's facts"), - # r1111's M1: the root's `.git`, asked by a `stat` that follows a link. - (SD, "check_the_way_in", '(root / ".git").exists()'): ( - FOLLOW_UP, "a `.git` that is a link into a folder this user cannot " - "search: the `way-in` row says there is no repository to adopt and " - "sends the person to `setup.sh` to scaffold three new ones"), - (SD, "check_the_other_way_in", '(ctx.root / ".git").exists()'): ( - FOLLOW_UP, "the same `.git`: the `way-in-scaffold` row is left out"), - (SD, "origin_org", '(root / ".git").exists()'): ( - FOLLOW_UP, "the same `.git`: None, the answer for a folder that is " - "no repository"), - (SD, "repo_local_origin_name", '(root / ".git").exists()'): ( - FOLLOW_UP, "the same `.git`: None, so the `naming` row classifies " - "the folder's name alone and drops the origin's"), - ("scripts/shape_advisory.py", "_read", '(root / ".git").exists()'): ( - FOLLOW_UP, "the same `.git`: `not-a-repository`, so the Triad " - "advisory row is left out"), - ("scripts/shape_advisory.py", "repository_names", - '(root / ".git").exists()'): ( - FOLLOW_UP, "the same `.git`: the origin's name is not read for the " - "Triad advice"), }) From eb3f098ceb1b1ef64c866660f74a047dbd8a1399 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Wed, 21 Oct 2026 21:37:58 +0000 Subject: [PATCH 02/18] tests/test_doctor_unsearchable_links.py: a `.git` that is a gitdir file is still a repository to the doctor's `.git` questions, and the advisory names a `.git` refused for another reason than permission (#1110) Two rows the first round of mutants showed missing: `carries_git` asked for a directory only (D8) and `_read` catching only `PermissionError` (A2) both passed the new rows. The `.git` questions now also answer for a `--separate-git-dir` checkout, whose `.git` is a file, and the advisory row runs for an I/O error as well as a permission. Part of #1110 Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- tests/test_doctor_unsearchable_links.py | 32 +++++++++++++++++++------ 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/tests/test_doctor_unsearchable_links.py b/tests/test_doctor_unsearchable_links.py index 34fab624..1334d8c4 100644 --- a/tests/test_doctor_unsearchable_links.py +++ b/tests/test_doctor_unsearchable_links.py @@ -287,7 +287,16 @@ def test_the_git_questions_raise_a_refused_stat(standard, tmp_path, answered = {"carries_git": True, "origin_org": "acme", "repo_local_origin_name": "Orbit"} assert question(root) == answered[ask] - # The controls: no `.git`, or a name under a file, is no repository. + # The controls: a `.git` that is a gitdir FILE, as `--separate-git-dir`, + # a linked worktree or a submodule's checkout writes it, is a repository; + # no `.git`, or a name under a file, is none. + separate = tmp_path / "Separate" + separate.mkdir() + git("init", "-q", "-b", "main", f"--separate-git-dir={tmp_path / 'kept'}", + ".", cwd=separate) + git("remote", "add", "origin", ORIGIN, cwd=separate) + assert (separate / ".git").is_file() + assert question(separate) == answered[ask] plain = tmp_path / "Plain" plain.mkdir() (tmp_path / "file").write_text("x\n", encoding="utf-8") @@ -350,24 +359,33 @@ def test_beside_what_it_can_read_the_row_names_what_it_cannot_first( assert row.detail["Makefile"] is True +@pytest.mark.parametrize("error, why", [ + pytest.param(PermissionError(errno.EACCES, DENIED), ADVISORY_WHY, + id="permission"), + pytest.param(OSError(errno.EIO, IO_ERROR), + ADVISORY_WHY.replace(DENIED, IO_ERROR), id="io-error"), +]) def test_the_advisory_names_a_git_it_cannot_reach_and_says_nothing( - tmp_path, monkeypatch): + tmp_path, monkeypatch, error, why): """`shape_advisory`: `repository_names` raises the refused `stat` where 3.14 dropped `origin`'s name, and `read()` answers `not-answered`, which - every reader keeps silent, naming the `.git` in `why`, where 3.14 said - `not-a-repository`.""" + every reader keeps silent, naming the `.git` and the system's reason in + `why`, where 3.14 said `not-a-repository`.""" root = repository_here(tmp_path / "Atlas") quiet = tmp_path / "agent-protocol" quiet.mkdir() env = {"AGENT_PROTOCOL_ROOT": str(quiet)} assert shape_advisory.repository_names(root) == ["Atlas", "Orbit"] assert shape_advisory.read(root, env=env).state == shape_advisory.ADVISE - refused_stat(monkeypatch, ".git") - with pytest.raises(PermissionError): + if isinstance(error, PermissionError): + refused_stat(monkeypatch, ".git") + else: + refusing(monkeypatch, "stat", ".git", error) + with pytest.raises(type(error)): shape_advisory.repository_names(root) reading = shape_advisory.read(root, env=env) assert reading.state == shape_advisory.NOT_ANSWERED - assert reading.why == ADVISORY_WHY + assert reading.why == why assert reading.advise is False From 85e314dbf0a61f341d8f6f62fed4aabd44877b0b Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 22 Oct 2026 02:18:26 +0000 Subject: [PATCH 03/18] tests/test_doctor_unsearchable_links.py: rows for the seven mutants the review found alive: a `.git` that loops or dangles is no repository, a pinned agent file beside an absent one is a FINDING, `naming` says origin was not read on every exit, and the advisory reads a gitdir file as a repository (#1110) The review of #1144 (r1144, READY on condition) ran 29 mutants of the new code against the 17 rows; R01, R07, R13, R14, R15, R27 and R28 survived. Eight rows and one control kill them, and all 47 mutants (the writer's 18 and the review's 29) are now killed. - `.git -> .git` (ELOOP) and `.git -> ../nowhere/.git` (ENOENT): controls that hold on main (`the way in` is the scaffold, `what is here` an ordinary directory, no `origin` asked, the advisory `not-a-repository`), and `carries_git` False (R01, and the hygiene scan's R29 as well). - A pinned `AGENTS-shape.md` it cannot read beside an absent `CLAUDE.md`: a FINDING, the verdict `INVALID (agent-files)` (R07). - `naming` with the `.git` refused, for a name that classifies, one the policy does not classify (exit 1) and a naming script that refuses (exit 2): the status is the policy's, `ok` for the first, and the clause that origin's name was not read is on every branch (R13, R14, R15). - The advisory row gets the `--separate-git-dir` control: `_read` and `repository_names` take a gitdir file for a repository (R27, R28). - Line 98 is cut to the file's 79 columns. Part of #1110 Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Sonnet 5.5 --- tests/test_doctor_unsearchable_links.py | 161 ++++++++++++++++++++++-- 1 file changed, 154 insertions(+), 7 deletions(-) diff --git a/tests/test_doctor_unsearchable_links.py b/tests/test_doctor_unsearchable_links.py index 1334d8c4..c94a6642 100644 --- a/tests/test_doctor_unsearchable_links.py +++ b/tests/test_doctor_unsearchable_links.py @@ -37,6 +37,10 @@ nothing pins and a FINDING for one the pin names, as for a file that is absent. The advisory answers `not-answered`, silent, with the line in `why`. +THE CONTROLS, true on main as well, hold the guard to #430's other answers: a +`.git` that is a link to itself or past a folder that is not there is no +repository, and a `.git` that is a gitdir file is one. + TWO KINDS OF CASE, as `tests/test_doctor_unreadable_paths.py` has them. The MODE cases make a real link into a real folder at 000 and run the doctor as a person does; they skip on Windows and as root (`CANNOT_RUN_AS_ROOT`), and put @@ -95,10 +99,17 @@ ADVISORY_WHY = (f"{GIT_LINE}, so whether this is a repository is not known " "here, and the advisory says nothing") #: The words no row may say of a name that IS there, and the lines that send -#: a person to adopt or scaffold (the `machine` row's `--preflight` is neither). +#: a person to adopt or scaffold (`machine`'s `--preflight` is neither). SAYS_NOTHING_THERE = ("absent", "no repository", "an ordinary directory", "SCAFFOLDED") OFFERS_A_WAY_IN = (" --org ", "adopt-project.py") +#: A `.git` that leads nowhere, for #430's rule that a link to itself (ELOOP) +#: and a link past a folder that is not there (ENOENT) are nothing there. +GIT_LINKS = {"loop": Path(".git"), "dangling": Path("..", "nowhere", ".git")} +#: A folder name the naming policy does not classify (exit 1), and what a +#: naming script that refuses for another reason than the policy says (exit 2). +UNCLASSIFIED = "my-service" +SCRIPT_REFUSES = "REFUSED naming-no-target: no names given." def symlink(at: Path, to: Path) -> None: @@ -118,6 +129,44 @@ def repository_here(root: Path) -> Path: return root +def repository_apart(root: Path, kept: Path) -> Path: + """`root`, a repository whose git directory is KEPT at `kept`, so that + its `.git` is a gitdir FILE, as `--separate-git-dir`, a linked worktree or + a submodule's checkout writes it; its `origin` is `ORIGIN`.""" + root.mkdir() + git("init", "-q", "-b", "main", f"--separate-git-dir={kept}", ".", + cwd=root) + git("remote", "add", "origin", ORIGIN, cwd=root) + assert (root / ".git").is_file() + return root + + +def git_link(tmp_path: Path, kind: str) -> Path: + """`/Atlas`, whose `.git` is a link that leads nowhere: to itself + (`loop`) or past a folder that is not there (`dangling`).""" + root = tmp_path / "Atlas" + root.mkdir() + symlink(root / ".git", GIT_LINKS[kind]) + (root / "README.md").write_text("# Atlas\n", encoding="utf-8") + return root + + +def refusing_standard(tmp_path: Path, said: str) -> Path: + """A stand-in for the standard: this checkout's naming policy and a + naming script that says `said` on stderr and exits 2, as + `tests/test_doctor_validator_verdicts.py` makes one in a copy of the whole + standard, here without the copy.""" + here = tmp_path / "standard" + (here / "contracts").mkdir(parents=True) + (here / "scripts").mkdir() + (here / "contracts" / "repository-naming.yaml").write_bytes( + (REPO / "contracts" / "repository-naming.yaml").read_bytes()) + (here / "scripts" / "validate-repository-naming.py").write_text( + f"import sys\nprint({said!r}, file=sys.stderr)\nsys.exit(2)\n", + encoding="utf-8") + return here + + def linked_repository(tmp_path: Path) -> Path: """`/Atlas`, whose `.git` is a link to the git directory of the repository at `/locked/repo`, whose `origin` is `ORIGIN`.""" @@ -290,12 +339,7 @@ def test_the_git_questions_raise_a_refused_stat(standard, tmp_path, # The controls: a `.git` that is a gitdir FILE, as `--separate-git-dir`, # a linked worktree or a submodule's checkout writes it, is a repository; # no `.git`, or a name under a file, is none. - separate = tmp_path / "Separate" - separate.mkdir() - git("init", "-q", "-b", "main", f"--separate-git-dir={tmp_path / 'kept'}", - ".", cwd=separate) - git("remote", "add", "origin", ORIGIN, cwd=separate) - assert (separate / ".git").is_file() + separate = repository_apart(tmp_path / "Separate", tmp_path / "kept") assert question(separate) == answered[ask] plain = tmp_path / "Plain" plain.mkdir() @@ -307,6 +351,53 @@ def test_the_git_questions_raise_a_refused_stat(standard, tmp_path, question(root) +@pytest.mark.parametrize("kind", sorted(GIT_LINKS)) +def test_a_git_link_that_leads_nowhere_is_still_no_repository( + standard, tmp_path, kind): + """CONTROLS, true on main `038dafba` and on this change: a `.git` that is + a link to itself (ELOOP) or past a folder that is not there (ENOENT) is + nothing there, as `exists()` has always said. `the way in` is the + scaffold and `what is here` an ordinary directory, with no cure and no + `cannot be read`; no `origin` is asked. They hold the guard that reads a + `stat` the system REFUSED as `cannot be read` to #430's other answers.""" + module = doctor_module(standard) + root = git_link(tmp_path, kind) + ctx = module.Context(root.resolve()) + rows = not_a_root_rows(module, ctx) + assert set(rows) == {"naming", "contents", "way-in"}, rows + for row in rows.values(): + assert "cannot be read" not in row["reason"], row + assert "cannot_be_read" not in row["detail"], row + assert not row["why"] or PERMISSION_WHY not in row["why"], row + assert rows["naming"]["reason"] == ("Atlas: classifies under the " + "naming policy") + assert rows["naming"]["detail"]["names"] == ["Atlas"] + assert "an ordinary directory" in rows["contents"]["reason"], rows + way_in = rows["way-in"] + assert way_in["detail"]["git_repository"] is False, way_in + assert "SCAFFOLDED" in way_in["reason"], way_in + assert "setup.sh" in way_in["next"], way_in + assert "--project Atlas" in way_in["next"], way_in + assert "adopt-project.py" not in way_in["next"], way_in + assert module.origin_org(root) is None + assert module.repo_local_origin_name(root) is None + quiet = tmp_path / "agent-protocol" + quiet.mkdir() + env = {"AGENT_PROTOCOL_ROOT": str(quiet)} + assert shape_advisory.repository_names(root) == ["Atlas"] + assert (shape_advisory.read(root, env=env).state + == shape_advisory.NOT_A_REPOSITORY) + + +@pytest.mark.parametrize("kind", sorted(GIT_LINKS)) +def test_carries_git_says_no_to_a_link_that_leads_nowhere( + standard, tmp_path, kind): + """`carries_git`, asked of the same two links: False, not a repository + and not a refusal. A new helper, so this fails on main by its absence.""" + module = doctor_module(standard) + assert module.carries_git(git_link(tmp_path, kind)) is False + + @pytest.mark.parametrize("error, line, why", [ pytest.param(PermissionError(errno.EACCES, DENIED), GIT_LINE, PERMISSION_WHY, id="permission"), @@ -344,6 +435,35 @@ def test_a_git_this_user_cannot_reach_is_named_once_on_every_python( no_way_in_over_it(rows) +@pytest.mark.parametrize("name, code, status", [ + pytest.param("Atlas", 0, "ok", id="classifies"), + pytest.param(UNCLASSIFIED, 1, "FINDING", id="does-not-classify"), + pytest.param("Atlas", 2, "FINDING", id="script-refuses"), +]) +def test_naming_says_origin_was_not_read_whatever_the_name_comes_to( + standard, tmp_path, monkeypatch, name, code, status): + """The `naming` row with the `.git`'s `stat` refused, for each exit the + script gives: the folder's own name classified or not, and the same + clause that `origin`'s name was not read, on every branch. The status is + the policy's own, `ok` for a name that classifies: an unread `origin` is + said, and is no finding. A name the policy refuses is a FINDING beside + it, the clause still there, which `Atlas` alone never reaches.""" + module = doctor_module(standard) + root = repository_here(tmp_path / name) + ctx = module.Context(root.resolve()) + if code == 2: + ctx.shape = refusing_standard(tmp_path, SCRIPT_REFUSES) + refused_stat(monkeypatch, ".git") + row = module.check_not_a_root_naming(ctx) + assert row.status == status, row + assert row.detail["exit"] == code, row.detail + assert row.detail["names"] == [name], row.detail + assert row.reason.startswith(f"{name}: "), row + assert row.reason.endswith(ORIGIN_UNREAD), row + assert row.detail["cannot_be_read"] == GIT_LINE, row.detail + assert (row.next_command is None) == (code == 0), row + + def test_beside_what_it_can_read_the_row_names_what_it_cannot_first( standard, tmp_path, monkeypatch): """`what is here` with a `Makefile` it can read and a `.git` it cannot: @@ -377,6 +497,11 @@ def test_the_advisory_names_a_git_it_cannot_reach_and_says_nothing( env = {"AGENT_PROTOCOL_ROOT": str(quiet)} assert shape_advisory.repository_names(root) == ["Atlas", "Orbit"] assert shape_advisory.read(root, env=env).state == shape_advisory.ADVISE + # The control: a `.git` that is a gitdir FILE is a repository to both + # questions, its `origin`'s name kept and the advice given. + apart = repository_apart(tmp_path / "Apart", tmp_path / "kept") + assert shape_advisory.repository_names(apart) == ["Apart", "Orbit"] + assert shape_advisory.read(apart, env=env).state == shape_advisory.ADVISE if isinstance(error, PermissionError): refused_stat(monkeypatch, ".git") else: @@ -450,3 +575,25 @@ def test_beside_an_absent_agent_file_its_command_stays_the_next( assert row.status == alone.status == module.NOTE assert row.detail["files"] == {"AGENTS-shape.md": True, "AGENTS.md": True, "CLAUDE.md": False} + + +def test_an_unreadable_pinned_agent_file_beside_an_absent_one_is_a_finding( + standard, project, monkeypatch): + """`CLAUDE.md` gone, which nothing pins, and the PINNED `AGENTS-shape.md` + out of reach: a FINDING, as for a pinned file that is absent, with the + absent file's command still the NEXT, and the verdict names `agent-files`. + The status must not follow the absent file alone (a `note`): the project's + own `validate-pins.py` refuses the unreadable copy by name.""" + module, ctx = in_process(standard, project) + (project / "CLAUDE.md").unlink() + alone = module.check_agent_files(ctx) + assert alone.status == module.NOTE + assert alone.detail["pinned"] == [] + refused_stat(monkeypatch, "AGENTS-shape.md") + row = module.check_agent_files(ctx) + assert row.reason == f"absent: CLAUDE.md; {cannot('AGENTS-shape.md')}" + assert row.status == module.FINDING + assert row.next_command == alone.next_command + assert row.why == f"{alone.why}. {PERMISSION_WHY}" + assert row.detail["pinned"] == [] + assert module.verdict_for(ctx, [row]) == ("INVALID (agent-files)", 1) From 89486f699f9a0b451ddbf1d4cfe41d5969391be3 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 22 Oct 2026 02:34:26 +0000 Subject: [PATCH 04/18] tests/test_doctor_unsearchable_links.py: the two composite asserts are split, one per condition, so SonarCloud's python:S9073 has nothing to say of the new rows (#1110) One is a new row's `not row["why"] or PERMISSION_WHY not in row["why"]`, now `PERMISSION_WHY not in (row["why"] or "")`; the other is the first round's `"AGENTS-shape.md" in pinned and "AGENTS.md" not in pinned`, now two asserts. No behaviour changes: the 25 rows and the 47 mutants give the same results. Part of #1110 Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Sonnet 5.5 --- tests/test_doctor_unsearchable_links.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_doctor_unsearchable_links.py b/tests/test_doctor_unsearchable_links.py index c94a6642..3e249ccd 100644 --- a/tests/test_doctor_unsearchable_links.py +++ b/tests/test_doctor_unsearchable_links.py @@ -368,7 +368,7 @@ def test_a_git_link_that_leads_nowhere_is_still_no_repository( for row in rows.values(): assert "cannot be read" not in row["reason"], row assert "cannot_be_read" not in row["detail"], row - assert not row["why"] or PERMISSION_WHY not in row["why"], row + assert PERMISSION_WHY not in (row["why"] or ""), row assert rows["naming"]["reason"] == ("Atlas: classifies under the " "naming policy") assert rows["naming"]["detail"]["names"] == ["Atlas"] @@ -546,7 +546,8 @@ def test_an_agent_file_this_user_cannot_reach_is_named_on_every_python( names, which the project's own gate refuses by name.""" module, ctx = in_process(standard, project) pinned = module.pinned_paths(ctx.root) - assert "AGENTS-shape.md" in pinned and "AGENTS.md" not in pinned, pinned + assert "AGENTS-shape.md" in pinned, pinned + assert "AGENTS.md" not in pinned, pinned refuse(monkeypatch) row = module.check_agent_files(ctx) assert row.status == status From 0f9aff05bba97c80e2ba2142566d224732781b9e Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 22 Oct 2026 12:34:39 +0000 Subject: [PATCH 05/18] shape_advisory.py and shape-doctor.py: a leg's `AGENTS.md` this user cannot read leaves whether the folder is a leg unknown, never no leg's file, so `the way in` offers neither command and points at `what is here`, which names it, and `the other way in` and the Triad row are silent (#1110) #1110's twelfth site, found by r1144: `shape_advisory._leg_by_agents_md` read any exception as "no leg's file" (`except Exception: return None`). Over a clone of acme/Orbit whose AGENTS.md is the spec or the code leg's, linked into a folder at 000 or itself at 000, #1144's head offered `adopt-project.py plan ... --project Orbit` in `the way in`, `setup.sh` in `the other way in`, and the Triad advice, on 3.12 and on a 3.14-like shim. - `_leg_by_agents_md` raises a refused `stat` or read (#430's rule: only ENOENT, ENOTDIR, EBADF and ELOOP, and Windows' 21, 123 and 1921, are nothing there). Bytes that are not UTF-8 still declare no leg; anything else ends in `read()`'s net. - `_read` answers `not-answered`, with `AGENTS.md: cannot be read ()` in `why` and the OSError in the new `Reading.refused`, unless the folder's name is a leg's, which still answers. The `.git` case carries `refused` too. - `leg_clone` raises `refused`; `the way in` (`way_in_unread_row`, shared with the `.git` row) offers neither command, and `the other way in` is silent. - `what is here` reads AGENTS.md through (`read_through`), so a file at 000 is named there with the cure. tests/test_doctor_unsearchable_links.py: 20 new rows (3 mode, 17 in process), and `leg_clone` over a refused `.git` now raises in #1144's row. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- scripts/shape_advisory.py | 102 +++++-- shape-doctor.py | 96 +++++- tests/test_doctor_unsearchable_links.py | 370 +++++++++++++++++++++++- 3 files changed, 535 insertions(+), 33 deletions(-) diff --git a/scripts/shape_advisory.py b/scripts/shape_advisory.py index 2c72a3b6..8278ccc1 100644 --- a/scripts/shape_advisory.py +++ b/scripts/shape_advisory.py @@ -137,13 +137,16 @@ #: construction is the one thing in a run that must never be the reason it #: crashed. So whatever `read()` meets that it did not expect (a record nested #: past the interpreter's recursion limit, a NUL in a configured path) ends -#: here: silent, with the exception's NAME in `why` for `--json`. So does a -#: `.git` this user cannot reach, the one such case `_read` asks for itself -#: (#1110), with its line in `validate-pins.py`'s words, `.git: cannot be read -#: ()`, in `why`. It is no new state: whether this is a repository is -#: not known, which is "could not be read here", and every reader already -#: answers this one with silence -- the doctor's `Triad` row and `leg_clone`, -#: and `adopt-project.py`'s `triad` lines. +#: here: silent, with the exception's NAME in `why` for `--json`. So do the +#: two reads `_read` asks for itself that this user may be refused (#1110): a +#: `.git` it cannot reach, and a leg's own `AGENTS.md` it cannot read where no +#: leg's name answers instead, each with its line in `validate-pins.py`'s +#: words, `: cannot be read ()`, in `why`, and the `OSError` in +#: `Reading.refused`. It is no new state: whether this is a repository, or a +#: leg, is not known, which is "could not be read here", and every reader +#: answers it with silence -- the doctor's `Triad` row, and `adopt-project.py`'s +#: `triad` lines -- or, where silence is not enough, by naming `refused`: the +#: doctor's `leg_clone` raises it, so `the way in` offers no way in over it. NOT_ANSWERED = "not-answered" STATES = (ADVISE, TRIAD_ROOT, FAMILY_HOLDER, LEG_CLONE, WORKSPACE, RECORDED_SINGLE, NOT_A_REPOSITORY, NOT_ANSWERED) @@ -310,16 +313,24 @@ class Reading: `single-repository.yaml` silences nothing, reported beside the advisory; `field_problems` are what a record that DOES silence leaves out. `leg` is set for a leg clone and says which leg and how it was known. + + `refused` is the `OSError` of a read the operating system REFUSED that + left the question unanswered -- a `.git` or a leg's `AGENTS.md` this + user cannot reach (#1110) -- set with `NOT_ANSWERED` and nowhere else, + for a caller that must name it rather than only stay silent. It is not + in `as_dict`: an exception is no JSON value, and its line is in `why`. """ def __init__(self, state: str, why: str, record_problems=(), - field_problems=(), leg: dict | None = None): + field_problems=(), leg: dict | None = None, + refused: OSError | None = None): assert state in STATES, state self.state = state self.why = why self.record_problems = tuple(record_problems) self.field_problems = tuple(field_problems) self.leg = leg + self.refused = refused @property def advise(self) -> bool: @@ -346,6 +357,21 @@ def as_dict(self) -> dict: _ABSENT_WINERRORS = (21, 123, 1921) +def _nothing_there(exc: OSError) -> bool: + """Whether `exc`, raised by a `stat` or a read, says NOTHING IS THERE + (`_ABSENT_ERRNOS`, `_ABSENT_WINERRORS`), where any other `OSError` is + one the operating system refused (#430's rule).""" + return (exc.errno in _ABSENT_ERRNOS + or getattr(exc, "winerror", None) in _ABSENT_WINERRORS) + + +def _cannot_be_read(named: str, exc: OSError) -> str: + """`: cannot be read ()`, the line + `validate-pins.py` refuses a file it cannot reach or open with, and + `shape-doctor.py`'s `cannot_be_read` (#1110).""" + return f"{named}: cannot be read ({exc.strerror or type(exc).__name__})" + + 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 @@ -360,8 +386,7 @@ def _is_a(path: Path, kind) -> bool: try: mode = path.stat().st_mode except OSError as exc: - if (exc.errno in _ABSENT_ERRNOS - or getattr(exc, "winerror", None) in _ABSENT_WINERRORS): + if _nothing_there(exc): return False raise except ValueError: @@ -386,8 +411,10 @@ class Tree: `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. 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. + raises it (`_is_a`, #430), and so does `text()` a read it refuses, and + every reader takes the file as there and unreadable, on every Python: the + record's as a reason it silences nothing, a leg's `AGENTS.md`'s as a + question this directory cannot answer (`_leg_by_agents_md`, #1110). """ def __init__(self, root: Path, commit: str | None = None): @@ -559,11 +586,36 @@ def _leg_by_agents_md(tree: Tree) -> dict | None: leg `templates/spec-root/` and `templates/code-root/` seeds carries an `AGENTS.md` that opens by saying which leg it is and names the assembly root it belongs to. + + A FILE THIS USER CANNOT READ RAISES (#1110). `Tree.text` asks the `stat` + (`_is_a`) and then reads the bytes, and #430's rule reads only ENOENT, + ENOTDIR, EBADF and ELOOP (and Windows' 21, 123 and 1921) from either as + nothing there, None as before. Any other `OSError` is raised for `_read` + to answer: whether this is a leg is not known while its own `AGENTS.md` + cannot be read. This was `except Exception: return None`, "an unreadable + file says nothing", and it said "no leg": a leg's `AGENTS.md` linked into + a folder this user cannot search, or at mode 000, was read as no leg's + file, so the doctor offered to adopt the leg as a project of its own and + to scaffold three new repositories over it, and the Triad row called it + a single repository. + + A FILE THAT IS READ AND IS NOT UTF-8 DECLARES NO LEG, as before. A leg's + `AGENTS.md` is its template rendered as UTF-8 text, and bytes that do not + decode are not that text: none of them is salvaged, the rule + `repository_names` keeps for an origin URL (#385) and `_read_mapping` for + a manifest. Nothing was refused, so nothing is unknown, and a person's + `AGENTS.md` in another encoding is not made a reason to offer no way in. + Anything else (a git that cannot give the commit's blob) is not expected + here, and ends in `read()`'s net: `NOT_ANSWERED`, silent. """ try: text = tree.text(AGENTS_MD) - except Exception: # noqa: BLE001 - an unreadable file says nothing + except UnicodeDecodeError: return None + except OSError as exc: + if _nothing_there(exc): + return None + raise if text is None: return None text = text.lstrip(BOM) @@ -795,11 +847,10 @@ def _read(root: Path, shape: Path, env, commit: str | None) -> Reading: # A `.git` THIS USER CANNOT REACH (#1110): not `NOT_A_REPOSITORY`, # which is what 3.14's `exists()` made of it, and no new state # either -- see `NOT_ANSWERED`. - return Reading(NOT_ANSWERED, f".git: cannot be read " - f"({exc.strerror or type(exc).__name__})," - " so whether this is a repository is not " + return Reading(NOT_ANSWERED, f"{_cannot_be_read('.git', exc)}, so " + "whether this is a repository is not " "known here, and the advisory says " - "nothing") + "nothing", refused=exc) if not repository: return Reading(NOT_A_REPOSITORY, "there is no repository here to " "advise about") @@ -807,7 +858,22 @@ def _read(root: Path, shape: Path, env, commit: str | None) -> Reading: names = repository_names(root) # THE LEG'S OWN `AGENTS.md` FIRST: it is the route design D4 of the # ratified change names, and the one that can name the assembly root. - leg = _leg_by_agents_md(tree) or _leg_by_name(names, policy) + try: + leg = _leg_by_agents_md(tree) + except OSError as exc: + # ITS OWN `AGENTS.md` THIS USER CANNOT READ (#1110). A leg's NAME is a + # declared fact without it, so a name that is one still answers, as + # it did before; with none, whether this is a leg is not known -- + # neither the advice nor the leg's instruction -- so `NOT_ANSWERED`, + # as for the `.git` above. + leg = _leg_by_name(names, policy) + if leg is None: + return Reading(NOT_ANSWERED, f"{_cannot_be_read(AGENTS_MD, exc)}, " + "so whether this is a leg clone is " + "not known here, and the advisory " + "says nothing", refused=exc) + else: + leg = leg or _leg_by_name(names, policy) if leg is not None: return Reading(LEG_CLONE, leg["how"], leg=leg) workspace = _workspace(root, names, policy, env) diff --git a/shape-doctor.py b/shape-doctor.py index 1587d1b1..e7c4902a 100755 --- a/shape-doctor.py +++ b/shape-doctor.py @@ -11156,6 +11156,24 @@ def check_not_a_root_naming(ctx: Context) -> Row: None if code == 0 else command, detail) +def read_through(path: Path) -> None: + """Read `path` to its end where it is a regular file, raising the + `OSError` of a `stat` or a read the operating system REFUSED (#1110). + + The read `shape_advisory` makes of a leg's `AGENTS.md` (`Tree.text`), so + `what is here` names a file the advisory cannot read: one at mode 000, + whose `stat` succeeds, as well as a link into a folder this user cannot + search. Nothing there, no regular file, or a read that finds nothing + there (`ABSENT_ERRNOS`, as the advisory reads it) raises nothing.""" + if not is_a(path, stat.S_ISREG): + return + try: + path.read_bytes() + except OSError as exc: + if refused_read(exc) is not None: + raise + + def check_what_is_here(ctx: Context) -> Row: """What IS in this directory, said plainly, so the reader can place it. @@ -11175,6 +11193,14 @@ def check_what_is_here(ctx: Context) -> Row: Still `n/a`: nothing asserts a name in a directory that is no shape root, and its verdict reads no row. The name's entry in `detail` is `true`, since it is there, and `cannot_be_read` lists the lines. + + `AGENTS.md` IS READ THROUGH, NOT ONLY ASKED (#1110), because it is the + one name here whose CONTENT a reader of this directory reads: the Triad + advisory reads it to tell a leg, and `the way in` offers no way in while + it cannot, pointing here. A file this user may not open, at mode 000, + has a `stat` that succeeds, so asking alone would leave that pointer + with nothing here to point at; `read_through` reads it as the advisory + does and raises the refusal, which this row names in the same line. """ root = ctx.root facts: list[str] = [] @@ -11196,6 +11222,8 @@ def check_what_is_here(ctx: Context) -> Row: f"a {shape_advisory.RECORD_FILE}")): try: there = is_a(root / name, lambda _mode: True) + if there and name == AGENTS_MD: + read_through(root / name) except OSError as exc: detail[name] = True unread.append(cannot_be_read(name, exc)) @@ -11523,12 +11551,23 @@ def leg_clone(ctx: Context) -> bool: way in` and the `Triad` row cannot disagree about one directory. False when the advisory cannot answer at all -- `the way in` then says what it always said. + + A READ THE ADVISORY WAS REFUSED RAISES, for the caller to name (#1110). + A leg's own `AGENTS.md` this user cannot read leaves whether this is a + leg unknown, and was read as no leg's file: `the way in` offered to adopt + the leg, and `the other way in` to scaffold three new repositories over + it. The reading carries the `OSError` (`refused`, as for a `.git` it + cannot reach), and this raises it, as `carries_git` raises a `.git`'s: + both callers ask `carries_git` first, then this, and offer no way in + over either. A leg known by its NAME is still one (`shape_advisory`). """ try: - return shape_advisory.read(ctx.root, ctx.shape).state \ - == shape_advisory.LEG_CLONE + reading = shape_advisory.read(ctx.root, ctx.shape) except Exception: # noqa: BLE001 - the advisory never fails the report return False + if reading.refused is not None: + raise reading.refused + return reading.state == shape_advisory.LEG_CLONE def check_the_way_in(ctx: Context) -> Row: @@ -11596,22 +11635,32 @@ def check_the_way_in(ctx: Context) -> Row: read, so this row offers neither command and asks nothing of `origin` (`carries_git`, asked first). It is `n/a`, as every answer here is, and points at `what is here`, which names the `.git` with the cure once. + + NOR DOES A LEG'S `AGENTS.md` THIS USER CANNOT READ (#1110). In a + repository `leg_clone` is asked next, and it raises the refused read of + the file that says whether this is a leg: an `AGENTS.md` linked into a + folder this user cannot search, or one at mode 000. It was read as no + leg's file on every Python, and this row offered to adopt the leg as a + project of its own. A leg is neither adopted nor scaffolded, and whether + this is one is not known until the file can be read, so the row offers + neither command and points at `what is here`, as for the `.git` + (`way_in_unread_row`). A leg known by its name is still the leg's row. """ root = ctx.root try: is_repo = carries_git(root) except OSError as exc: - line = cannot_be_read(".git", exc) - return Row("way-in", "the way in", NA, - "its .git cannot be read, so it is not known whether this " - "is a git repository to adopt, and neither way in is " - "offered until it can be: `what is here` says how", None, - {"git_repository": None, "suggested_project": None, - "origin_org": None, "cannot_be_read": line, - "path": ".git"}) + return way_in_unread_row(".git", exc, None, + "this is a git repository to adopt") + try: + leg = is_repo and leg_clone(ctx) + except OSError as exc: + return way_in_unread_row(AGENTS_MD, exc, True, + "this is a clone of a Triad's leg, which is " + "not adopted or scaffolded on its own") project, refusal = way_in_project(ctx) org = origin_org(root) - if is_repo and leg_clone(ctx): + if leg: # A LEG IS NOT ADOPTED. Splitting a spec or code leg into two more # legs is the one adoption nobody wants, and offering it two rows # above a `Triad` row that says "work from its assembly root" would @@ -11651,6 +11700,25 @@ def check_the_way_in(ctx: Context) -> Row: why=why) +def way_in_unread_row(named: str, exc: OSError, is_repo: bool | None, + unknown: str) -> Row: + """`the way in` while `named` cannot be read, so that `unknown` is not + known (#1110): `n/a` with no NEXT, since neither way in is offered, and a + pointer at `what is here`, which names the file with the cure, once. + `detail` keeps the row's keys: `git_repository` is `is_repo` (None for a + `.git` that cannot be read), `suggested_project` and `origin_org` are + None because no command is offered with them, and `cannot_be_read` and + `path` name the file.""" + return Row("way-in", "the way in", NA, + f"its {named} cannot be read, so it is not known whether " + f"{unknown}, and neither way in is offered until it can be: " + "`what is here` says how", None, + {"git_repository": is_repo, "suggested_project": None, + "origin_org": None, + "cannot_be_read": cannot_be_read(named, exc), + "path": named}) + + def way_in_project(ctx: Context) -> tuple: """`(project, None)`: the `--project` both ways in are offered, from the one identity `check_the_way_in` reads -- `origin`'s repository name where @@ -11736,12 +11804,16 @@ def check_the_other_way_in(ctx: Context) -> Row | None: row offers no second one. On Python 3.14 `exists()` answered that `.git` False, and the row was left out as for a folder with no repository; up to 3.13 it raised, and `run_checks` named the `.git` here a fourth time. + And silent where a leg's `AGENTS.md` cannot be read (`leg_clone` raises + it, #1110): whether this is a leg is not known, and a leg is offered no + scaffold. This row offered it over a leg's file read as no leg's. """ try: repository = carries_git(ctx.root) + leg = repository and leg_clone(ctx) except OSError: return None - if not repository or leg_clone(ctx): + if not repository or leg: return None project, refusal = way_in_project(ctx) if refusal is not None: diff --git a/tests/test_doctor_unsearchable_links.py b/tests/test_doctor_unsearchable_links.py index 3e249ccd..70716309 100644 --- a/tests/test_doctor_unsearchable_links.py +++ b/tests/test_doctor_unsearchable_links.py @@ -41,6 +41,22 @@ `.git` that is a link to itself or past a folder that is not there is no repository, and a `.git` that is a gitdir file is one. +#1110'S TWELFTH SITE, `shape_advisory._leg_by_agents_md` (`except Exception: +return None`, "an unreadable file says nothing"). A leg's own `AGENTS.md` +that this user cannot read -- linked into a folder at 000, or itself at 000 +-- was read as no leg's file on every Python. Measured on #1144's head +`89486f69`, over a clone of `acme/Orbit` whose `AGENTS.md` is the spec or +the code leg's: `the way in` offered `adopt-project.py plan ... --project +Orbit`, `the other way in` `setup.sh`, and the Triad row called it a single +repository, where the readable file is the leg, with no command at all. Now +`read()` answers `not-answered` with the line and the `OSError` it carries, +`leg_clone` raises it, `the way in` offers neither command and points at +`what is here`, which names the file with the cure (and reads it through, for +a file at 000), and `the other way in` and the Triad row are silent. A leg +known by its NAME is still one; a file that is not there, or is read and is +not UTF-8, is still no leg's file (controls); and `adopt-project.py`, which +reads the commit it plans, never reads the disk's file at all. + TWO KINDS OF CASE, as `tests/test_doctor_unreadable_paths.py` has them. The MODE cases make a real link into a real folder at 000 and run the doctor as a person does; they skip on Windows and as root (`CANNOT_RUN_AS_ROOT`), and put @@ -71,6 +87,9 @@ DENIED, IO_ERROR, OTHER_WHY, PERMISSION_WHY, cannot, doctored, in_process, refusing, ) +#: A leg's `AGENTS.md`, rendered from its template as the scaffold renders +#: it, from the file that owns it. +from test_shape_advisory import rendered_leg_agents #: The doctor's fixtures, from the module that owns them. from test_shape_doctor import ( # noqa: F401 doctor, doctor_module, project, rows_of, scaffolded_here, standard, @@ -110,6 +129,31 @@ #: naming script that refuses for another reason than the policy says (exit 2). UNCLASSIFIED = "my-service" SCRIPT_REFUSES = "REFUSED naming-no-target: no names given." +#: What `the way in` says while a leg's `AGENTS.md` cannot be read. +NO_LEG_WAY_IN = ("its AGENTS.md cannot be read, so it is not known whether " + "this is a clone of a Triad's leg, which is not adopted or " + "scaffolded on its own, and neither way in is offered until " + "it can be: `what is here` says how") +#: An `AGENTS.md` that leads nowhere, as `GIT_LINKS` above. +AGENTS_LINKS = {"loop": Path("AGENTS.md"), + "dangling": Path("..", "nowhere", "AGENTS.md")} +#: The reads of a leg's `AGENTS.md` this user may be refused, in process: +#: its `stat`, as for a link into a folder this user cannot search, and its +#: read, as for a file at mode 000, whose `stat` succeeds; for a permission +#: and for an I/O error. `(refuse, the system's reason, the error's type)`. +AGENTS_REFUSALS = [ + pytest.param(lambda patch: refused_stat(patch, "AGENTS.md"), DENIED, + PermissionError, id="stat-permission"), + pytest.param(lambda patch: refusing(patch, "stat", "AGENTS.md", + OSError(errno.EIO, IO_ERROR)), + IO_ERROR, OSError, id="stat-io-error"), + pytest.param(lambda patch: refusing(patch, "read_bytes", "AGENTS.md", + PermissionError(errno.EACCES, DENIED)), + DENIED, PermissionError, id="read-permission"), + pytest.param(lambda patch: refusing(patch, "read_bytes", "AGENTS.md", + OSError(errno.EIO, IO_ERROR)), + IO_ERROR, OSError, id="read-io-error"), +] def symlink(at: Path, to: Path) -> None: @@ -220,6 +264,78 @@ def not_a_root_rows(module, ctx) -> dict: module.check_triad_advisory(ctx)]) +def no_workspace(tmp_path: Path) -> dict: + """An environment whose `AGENT_PROTOCOL_ROOT` holds no `workspace.yaml`, + so no person's own configuration can change an answer.""" + quiet = tmp_path / "agent-protocol" + quiet.mkdir() + return {"AGENT_PROTOCOL_ROOT": str(quiet)} + + +def leg_clone_here(root: Path, role: str = "spec") -> Path: + """`root`, a repository of its own whose `origin` is `ORIGIN` and whose + `AGENTS.md` is the `role` leg's, as its template renders it: a leg + cloned into a folder called something else.""" + repository_here(root) + (root / "AGENTS.md").write_text(rendered_leg_agents(role), + encoding="utf-8") + return root + + +def leg_agents_why(reason: str = DENIED) -> str: + """The advisory's `why` for a leg's `AGENTS.md` it cannot read.""" + return (f"{cannot('AGENTS.md', reason)}, so whether this is a leg clone " + "is not known here, and the advisory says nothing") + + +def is_the_leg(rows: dict, role: str) -> None: + """A clone of the `role` leg whose `AGENTS.md` this user can read: `the + way in` is the leg's row, with no command, no scaffold is offered, and + the Triad row sends it to its assembly root.""" + assert "AS A LEG" in rows["way-in"]["reason"], rows["way-in"] + assert rows["way-in"]["next"] is None, rows["way-in"] + assert "way-in-scaffold" not in rows, sorted(rows) + assert (f"this is a clone of the {role} leg" + in rows["triad-advisory"]["reason"]), rows["triad-advisory"] + + +def no_leg_way_in(rows: dict, line: str, again: str, why: str) -> None: + """The same clone, its `AGENTS.md` refused (`line`): `what is here` names + it with this doctor `again` and the advice for the cause, `the way in` + offers neither command and points there, and `the other way in` and the + Triad row are silent. `machine` is about the workstation.""" + assert set(rows) - {"machine"} == {"naming", "contents", "way-in"}, rows + contents, way_in = rows["contents"], rows["way-in"] + assert contents["status"] == "n/a", contents + assert contents["reason"] == f"{line}; a git repository" + assert contents["next"] == again + assert contents["why"] == why + assert contents["detail"]["AGENTS.md"] is True + assert contents["detail"]["cannot_be_read"] == [line] + assert way_in["status"] == "n/a", way_in + assert way_in["reason"] == NO_LEG_WAY_IN + assert way_in["next"] is None, way_in + assert way_in["detail"] == { + "git_repository": True, "suggested_project": None, + "origin_org": None, "cannot_be_read": line, "path": "AGENTS.md"} + told_once(rows, line) + no_way_in_over_it(rows) + + +def offered_both_ways_in(module, ctx) -> None: + """A clone the advisory reads as no leg, with nothing it cannot read: + advised, offered the adoption as `Orbit` and the scaffold, and no row + says `cannot be read`.""" + rows = not_a_root_rows(module, ctx) + assert set(rows) == {"naming", "contents", "way-in", "way-in-scaffold", + "triad-advisory"}, rows + for row in rows.values(): + assert "cannot be read" not in row["reason"], row + assert "adopt-project.py" in rows["way-in"]["next"], rows["way-in"] + assert "--project Orbit" in rows["way-in"]["next"], rows["way-in"] + assert rows["triad-advisory"]["status"] == module.ADVISORY + + # --- the mode cases: a real link, the doctor run as a person runs it -------- @CANNOT_RUN_AS_ROOT @@ -320,6 +436,54 @@ def test_a_projects_agent_file_linked_out_of_reach_is_a_note( told_once(rows, line) +@CANNOT_RUN_AS_ROOT +@pytest.mark.parametrize("role", ["spec", "code"]) +def test_a_leg_agents_md_linked_into_a_folder_nobody_may_search_offers_no_way_in( + standard, tmp_path, role): + """#1110's twelfth site, the case r1144 measured. Readable, a clone of + `acme/Orbit` whose `AGENTS.md` is a leg's is that leg: no command, and + the Triad row sends it to its root. Linked into a folder at 000 it was + no leg's file on every Python: `the way in` offered the adoption as + `Orbit`, `the other way in` the scaffold, and the Triad row called it a + single repository. Now `what is here` names it once with the cure, `the + way in` offers neither command and points there, `the other way in` and + the Triad row are silent, and the verdict and exit are unchanged.""" + locked = tmp_path / "locked" + locked.mkdir() + (locked / "AGENTS.md").write_text(rendered_leg_agents(role), + encoding="utf-8") + root = repository_here(tmp_path / "Atlas") + symlink(root / "AGENTS.md", Path("..", "locked", "AGENTS.md")) + readable, _verdict = doctored(standard, root) + is_the_leg(readable, role) + with mode_of(tmp_path, "locked", 0): + result = doctor(standard, root, "--json") + assert "Traceback" not in result.stderr, result.stderr + assert result.returncode == 2, result.stdout + result.stderr + assert json.loads(result.stdout)["verdict"] == "NOT A SHAPE ROOT" + no_leg_way_in(rows_of(result), + cannot("AGENTS.md", os.strerror(errno.EACCES)), + doctor_again(standard, root), PERMISSION_WHY) + + +@CANNOT_RUN_AS_ROOT +def test_a_leg_agents_md_this_user_may_not_open_offers_no_way_in( + standard, tmp_path): + """The same leg's `AGENTS.md` as a regular file at mode 000: its `stat` + succeeds and its read is refused. `what is here` said `an AGENTS.md`, + and the rest as for the link. `what is here` reads it through now, so it + names the file the advisory cannot read, and `the way in`'s pointer has + a line to point at.""" + root = leg_clone_here(tmp_path / "Atlas", "code") + readable, _verdict = doctored(standard, root) + is_the_leg(readable, "code") + with mode_of(root, "AGENTS.md", 0): + rows, verdict = doctored(standard, root) + assert verdict == "NOT A SHAPE ROOT" + no_leg_way_in(rows, cannot("AGENTS.md", os.strerror(errno.EACCES)), + doctor_again(standard, root), PERMISSION_WHY) + + # --- the stand-in cases: in process, on every platform and as root ---------- @pytest.mark.parametrize("ask", ["carries_git", "origin_org", @@ -512,19 +676,26 @@ def test_the_advisory_names_a_git_it_cannot_reach_and_says_nothing( assert reading.state == shape_advisory.NOT_ANSWERED assert reading.why == why assert reading.advise is False + assert type(reading.refused) is type(error) def test_the_doctor_reads_the_advisory_as_silent(standard, tmp_path, monkeypatch): """The doctor's two readers of the advisory over that `.git`: no Triad - row, and not a leg clone.""" + row, and `leg_clone` raises the refused `stat` the reading carries + (`refused`), as it raises a leg's `AGENTS.md` it cannot read (#1110's + twelfth site), for its caller to name. `the way in` and `the other way + in` ask `carries_git` first, so neither meets it here.""" module = doctor_module(standard) root = repository_here(tmp_path / "Atlas") ctx = module.Context(root.resolve()) refused_stat(monkeypatch, ".git") - assert module.shape_advisory.read(ctx.root, ctx.shape).why == ADVISORY_WHY + reading = module.shape_advisory.read(ctx.root, ctx.shape) + assert reading.why == ADVISORY_WHY + assert type(reading.refused) is PermissionError assert module.check_triad_advisory(ctx) is None - assert module.leg_clone(ctx) is False + with pytest.raises(PermissionError): + module.leg_clone(ctx) @pytest.mark.parametrize("refuse, status, line, why", [ @@ -598,3 +769,196 @@ def test_an_unreadable_pinned_agent_file_beside_an_absent_one_is_a_finding( assert row.why == f"{alone.why}. {PERMISSION_WHY}" assert row.detail["pinned"] == [] assert module.verdict_for(ctx, [row]) == ("INVALID (agent-files)", 1) + + +# --- #1110's twelfth site: a leg's `AGENTS.md` this user cannot read -------- + +@pytest.mark.parametrize("refuse, reason, error", AGENTS_REFUSALS) +def test_the_advisory_says_nothing_of_a_leg_agents_md_it_cannot_read( + tmp_path, monkeypatch, refuse, reason, error): + """`shape_advisory`: `_leg_by_agents_md` read ANY exception as "no leg's + file", so a leg's `AGENTS.md` this user cannot read was no leg, and + `read()` advised a single repository. It raises the refused `stat` or + read now, and `read()` answers `not-answered`, silent, with the line in + `why` and the `OSError` in `refused`, for the doctor to name.""" + root = leg_clone_here(tmp_path / "Atlas") + env = no_workspace(tmp_path) + assert shape_advisory.read(root, env=env).state == shape_advisory.LEG_CLONE + refuse(monkeypatch) + with pytest.raises(error): + shape_advisory._leg_by_agents_md(shape_advisory.Tree(root)) + reading = shape_advisory.read(root, env=env) + assert reading.state == shape_advisory.NOT_ANSWERED + assert reading.why == leg_agents_why(reason) + assert reading.advise is False + assert reading.leg is None + assert type(reading.refused) is error + + +@pytest.mark.parametrize("refuse, reason, error", AGENTS_REFUSALS) +def test_the_doctor_offers_no_way_in_over_a_leg_agents_md_it_cannot_read( + standard, tmp_path, monkeypatch, refuse, reason, error): + """The rows of a directory with no manifest, in process, over a clone of + a leg whose `AGENTS.md` this user cannot read: `what is here` names it + with the advice for the cause, `the way in` offers neither command and + points there, `the other way in` and the Triad row are silent, and + `leg_clone` raises the refusal. On #1144's head the same rows offered + the adoption and the scaffold, and gave the advice. The read cases are + a file at mode 000, which `what is here` names by reading it through.""" + module = doctor_module(standard) + root = leg_clone_here(tmp_path / "Atlas") + ctx = module.Context(root.resolve()) + is_the_leg(not_a_root_rows(module, ctx), "spec") + refuse(monkeypatch) + no_leg_way_in(not_a_root_rows(module, ctx), cannot("AGENTS.md", reason), + module.doctor_line(ctx), + PERMISSION_WHY if error is PermissionError else OTHER_WHY) + with pytest.raises(error): + module.leg_clone(ctx) + + +def test_an_agents_md_nobody_may_open_is_named_where_there_is_no_git( + standard, tmp_path, monkeypatch): + """`what is here` reads `AGENTS.md` through wherever it lists it, so a + file at mode 000 in a directory that is no repository is named with the + cure, as #1144 names one linked into a folder at 000 there + (`test_an_agents_md_linked_into_a_folder_nobody_may_search_is_there`). + Nothing reads it to choose a way in there, so the way in is still the + scaffold, and no Triad row is given.""" + module = doctor_module(standard) + root = tmp_path / "Atlas" + root.mkdir() + (root / "AGENTS.md").write_text(rendered_leg_agents("spec"), + encoding="utf-8") + ctx = module.Context(root.resolve()) + refusing(monkeypatch, "read_bytes", "AGENTS.md", + PermissionError(errno.EACCES, DENIED)) + rows = not_a_root_rows(module, ctx) + assert set(rows) == {"naming", "contents", "way-in"}, rows + assert rows["contents"]["reason"] == AGENTS_LINE + assert rows["contents"]["why"] == PERMISSION_WHY + assert rows["way-in"]["detail"]["git_repository"] is False + assert "setup.sh" in rows["way-in"]["next"], rows["way-in"] + told_once(rows, AGENTS_LINE) + + +def test_a_leg_by_its_name_is_one_while_its_agents_md_cannot_be_read( + standard, tmp_path, monkeypatch): + """CONTROL, true on #1144's head `89486f69` as here: a folder named + `Orbit-spec` is a leg by its NAME, a declared fact that needs no + `AGENTS.md`, so a leg's `AGENTS.md` this user cannot read leaves it the + leg its name says -- `named as`, the hedge for a name -- and `the way in` + is the leg's row, with no command and no scaffold. `what is here` still + names the file with the cure. It holds `_read`'s turn to the name when + the file is refused, rather than `not-answered`.""" + module = doctor_module(standard) + root = leg_clone_here(tmp_path / "Orbit-spec") + ctx = module.Context(root.resolve()) + refused_stat(monkeypatch, "AGENTS.md") + reading = shape_advisory.read(root, env=no_workspace(tmp_path)) + assert reading.state == shape_advisory.LEG_CLONE, reading.as_dict() + assert reading.leg["by"] == "name", reading.leg + rows = not_a_root_rows(module, ctx) + assert set(rows) == {"naming", "contents", "way-in", "triad-advisory"}, \ + rows + assert "AS A LEG" in rows["way-in"]["reason"], rows["way-in"] + assert rows["way-in"]["next"] is None, rows["way-in"] + assert ("`Orbit-spec` is named as the spec leg of `Orbit`" + in rows["triad-advisory"]["reason"]), rows["triad-advisory"] + assert module.leg_clone(ctx) is True + told_once(rows, AGENTS_LINE) + + +@pytest.mark.parametrize("kind", ["dangling", "gone-before-it-is-read", + "loop"]) +def test_an_agents_md_that_is_not_there_is_no_legs_file( + standard, tmp_path, monkeypatch, kind): + """CONTROLS, true on #1144's head as here, holding the reader that now + raises a refused read to #430's other answers: an `AGENTS.md` that is a + link past a folder that is not there (ENOENT) or to itself (ELOOP), or a + file whose read finds nothing there (ENOENT, gone after its `stat`), is + nothing there. No leg's file and no `cannot be read` anywhere: the clone + is advised and offered both ways in, as one with no `AGENTS.md` is.""" + module = doctor_module(standard) + root = repository_here(tmp_path / "Atlas") + if kind in AGENTS_LINKS: + symlink(root / "AGENTS.md", AGENTS_LINKS[kind]) + else: + (root / "AGENTS.md").write_text(rendered_leg_agents("spec"), + encoding="utf-8") + refusing(monkeypatch, "read_bytes", "AGENTS.md", + FileNotFoundError(errno.ENOENT, os.strerror(errno.ENOENT))) + assert shape_advisory._leg_by_agents_md(shape_advisory.Tree(root)) is None + reading = shape_advisory.read(root, env=no_workspace(tmp_path)) + assert reading.state == shape_advisory.ADVISE, reading.as_dict() + offered_both_ways_in(module, module.Context(root.resolve())) + + +@pytest.mark.parametrize("encode", [ + pytest.param(lambda text: text.encode("utf-8") + b"caf" + bytes([0xE9]) + + b"\n", id="a-legacy-byte-after-the-opening"), + pytest.param(lambda text: text.encode("utf-16"), id="utf-16"), +]) +def test_a_leg_agents_md_that_is_not_utf8_declares_no_leg(standard, tmp_path, + encode): + """THE DECISION FOR A FILE THAT IS READ AND IS NOT UTF-8, true on #1144's + head as here: no leg's file. A leg's `AGENTS.md` is its template rendered + as UTF-8, and bytes that do not decode are not that text; none of them is + salvaged, as no part of an origin URL that is not UTF-8 is (#385). It is + not #1110's case: nothing was refused, `what is here` reads the file, and + the clone is advised and offered both ways in. A leg's opening kept intact + ahead of a byte in a legacy code page is read so too: the PR's known + limit.""" + module = doctor_module(standard) + root = repository_here(tmp_path / "Atlas") + (root / "AGENTS.md").write_bytes(encode(rendered_leg_agents("spec"))) + assert shape_advisory._leg_by_agents_md(shape_advisory.Tree(root)) is None + reading = shape_advisory.read(root, env=no_workspace(tmp_path)) + assert reading.state == shape_advisory.ADVISE, reading.as_dict() + offered_both_ways_in(module, module.Context(root.resolve())) + + +def test_a_failure_the_leg_reader_does_not_expect_is_silence_not_advice( + tmp_path, monkeypatch): + """Anything `Tree.text` raises that is neither an `OSError` nor bytes + that are not UTF-8 -- a git that cannot give the commit's blob, which + `adopt-project.py`'s reading of a commit can meet -- is not expected by + `_leg_by_agents_md`, and ends in `read()`'s net: `not-answered`, silent, + with the exception's name. On #1144's head it was "no leg's file", and + the advice. No `OSError` was refused, so `refused` is None.""" + root = leg_clone_here(tmp_path / "Atlas") + real = shape_advisory.Tree.text + + def text(self, rel): + if rel == "AGENTS.md": + raise shape_advisory.Refusal("git-failed", + "git could not give the blob") + return real(self, rel) + + monkeypatch.setattr(shape_advisory.Tree, "text", text) + reading = shape_advisory.read(root, env=no_workspace(tmp_path)) + assert reading.state == shape_advisory.NOT_ANSWERED, reading.as_dict() + assert reading.why == ("the advisory could not be read here (Refusal), " + "so it says nothing") + assert reading.refused is None + + +def test_adopts_reader_reads_the_commits_agents_md_and_never_the_disks( + tmp_path, monkeypatch): + """`adopt-project.py` asks the advisory of the commit its plan is written + against (`commit=`), and reads a leg's `AGENTS.md` out of that commit's + objects: refused on the disk, the committed file is still the leg's, so + `plan` and `check` print the leg's NOTE and offer nothing over the + disk's file, on #1144's head as here. The disk, which the doctor reads, + is `not-answered`.""" + root = leg_clone_here(tmp_path / "Atlas") + git("add", "-A", "--", ".", cwd=root) + git("commit", "-q", "-m", "Initial import", cwd=root) + commit = git("rev-parse", "HEAD", cwd=root).stdout.strip() + env = no_workspace(tmp_path) + refused_stat(monkeypatch, "AGENTS.md") + reading = shape_advisory.read(root, env=env, commit=commit) + assert reading.state == shape_advisory.LEG_CLONE, reading.as_dict() + assert reading.leg["by"] == "agents", reading.leg + assert (shape_advisory.read(root, env=env).state + == shape_advisory.NOT_ANSWERED) From d65db6a606c24adb498e7c63ef026742d9ee4219 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 22 Oct 2026 19:16:34 +0000 Subject: [PATCH 06/18] tests/test_doctor_unsearchable_links.py: control rows hold `what is here` to reading through a regular `AGENTS.md` alone: a directory or a named pipe of that name is listed and not read, the pipe's doctor run under a timeout, and a refused `Makefile` is listed like any other name (#1110) The review of #1159 (r1) found two mutants of `shape-doctor.py` alive against all 45 rows: - M3, `read_through` reading any kind of file (`is_a(path, lambda _m: True)`): over a repository whose `AGENTS.md` is a directory, `what is here` said `AGENTS.md: cannot be read (Is a directory)`, and over one whose `AGENTS.md` is a named pipe the doctor never returned (SHOULD-2); - M14, `what is here` reading through every name it lists (NIT-4). New rows, each a control that holds on #1144's head as here: - `test_an_agents_md_that_is_no_regular_file_is_listed_and_not_read` [directory, named-pipe]: the doctor run as a person runs it, given `DOCTOR_SECONDS` (120) by `doctor_within`, so a run that opens the pipe fails the row instead of hanging the suite. `what is here` says `a git repository, an AGENTS.md`, nothing says `cannot be read`, and the clone is offered both ways in. The pipe row skips where `os.mkfifo` is absent, and nothing opens the pipe in the test's own process. - `test_what_is_here_reads_through_agents_md_alone`: a leg's clone whose `Makefile` read is refused, in process: `what is here` says `a git repository, an AGENTS.md, a Makefile`, and no row says `cannot be read`. `offered_both_ways_in` now reads its rows through `offers_both_ways_in`, which a whole run's rows (with `machine`) can be given too. Part of #1110 Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- tests/test_doctor_unsearchable_links.py | 98 ++++++++++++++++++++++++- 1 file changed, 95 insertions(+), 3 deletions(-) diff --git a/tests/test_doctor_unsearchable_links.py b/tests/test_doctor_unsearchable_links.py index 70716309..8a3e1e5d 100644 --- a/tests/test_doctor_unsearchable_links.py +++ b/tests/test_doctor_unsearchable_links.py @@ -55,7 +55,10 @@ a file at 000), and `the other way in` and the Triad row are silent. A leg known by its NAME is still one; a file that is not there, or is read and is not UTF-8, is still no leg's file (controls); and `adopt-project.py`, which -reads the commit it plans, never reads the disk's file at all. +reads the commit it plans, never reads the disk's file at all. `what is here` +reads through `AGENTS.md` alone, and only a regular one: a directory or a +named pipe of that name is listed and not read, and the pipe's row runs the +doctor under a timeout, since opening it would block for ever (controls). TWO KINDS OF CASE, as `tests/test_doctor_unreadable_paths.py` has them. The MODE cases make a real link into a real folder at 000 and run the doctor as a @@ -70,6 +73,7 @@ import errno import json import os +import subprocess import sys from pathlib import Path @@ -134,6 +138,10 @@ "this is a clone of a Triad's leg, which is not adopted or " "scaffolded on its own, and neither way in is offered until " "it can be: `what is here` says how") +#: How long one run of the doctor may take where an `AGENTS.md` it opened +#: would block it for ever (a named pipe nobody writes to): a run that takes +#: longer FAILS its row, rather than hanging the suite. +DOCTOR_SECONDS = 120 #: An `AGENTS.md` that leads nowhere, as `GIT_LINKS` above. AGENTS_LINKS = {"loop": Path("AGENTS.md"), "dangling": Path("..", "nowhere", "AGENTS.md")} @@ -323,10 +331,16 @@ def no_leg_way_in(rows: dict, line: str, again: str, why: str) -> None: def offered_both_ways_in(module, ctx) -> None: + """`offers_both_ways_in`, of the rows made in process.""" + offers_both_ways_in(module, not_a_root_rows(module, ctx)) + + +def offers_both_ways_in(module, rows: dict) -> None: """A clone the advisory reads as no leg, with nothing it cannot read: advised, offered the adoption as `Orbit` and the scaffold, and no row - says `cannot be read`.""" - rows = not_a_root_rows(module, ctx) + says `cannot be read`. `machine`, which only a whole run gives, is about + the workstation.""" + rows = {key: row for key, row in rows.items() if key != "machine"} assert set(rows) == {"naming", "contents", "way-in", "way-in-scaffold", "triad-advisory"}, rows for row in rows.values(): @@ -336,6 +350,32 @@ def offered_both_ways_in(module, ctx) -> None: assert rows["triad-advisory"]["status"] == module.ADVISORY +def doctor_within(standard: Path, root: Path) -> dict: + """`{id: row}` from `doctor(standard, root, "--json")`, the run a person + makes, given `DOCTOR_SECONDS` to end: `subprocess.run` is handed the + timeout for this one call, so the environment is `run_script`'s own. A + run that would block for ever on an `AGENTS.md` it opened fails the row + by name, and the doctor is killed. It printed no traceback, and its exit + is NOT A SHAPE ROOT's.""" + real = subprocess.run + + def bounded(*args, **kwargs): + return real(*args, timeout=DOCTOR_SECONDS, **kwargs) + + with pytest.MonkeyPatch.context() as patch: + patch.setattr(subprocess, "run", bounded) + try: + result = doctor(standard, root, "--json") + except subprocess.TimeoutExpired: + pytest.fail(f"the doctor did not end within {DOCTOR_SECONDS} " + f"seconds over {root}: something opened its " + "AGENTS.md") + assert "Traceback" not in result.stderr, result.stderr + assert result.returncode == 2, result.stdout + result.stderr + assert json.loads(result.stdout)["verdict"] == "NOT A SHAPE ROOT" + return rows_of(result) + + # --- the mode cases: a real link, the doctor run as a person runs it -------- @CANNOT_RUN_AS_ROOT @@ -894,6 +934,58 @@ def test_an_agents_md_that_is_not_there_is_no_legs_file( offered_both_ways_in(module, module.Context(root.resolve())) +@pytest.mark.parametrize("kind", ["directory", "named-pipe"]) +def test_an_agents_md_that_is_no_regular_file_is_listed_and_not_read( + standard, tmp_path, kind): + """CONTROLS, true on #1144's head as here, for `read_through`'s one + guard: it reads a REGULAR file. An `AGENTS.md` that is a directory or a + named pipe is there and is no file, so `what is here` lists it and reads + nothing of it, and the advisory, whose `Tree.text` reads a file alone, + finds no leg's file: the clone is advised and offered both ways in. A + read of the directory is refused (`Is a directory`), which the row + would name as a file this user cannot read, and the open of a pipe + nobody writes to blocks for ever, so the doctor runs as a person runs + it, and a run past `DOCTOR_SECONDS` fails the row. Nothing here opens + the pipe in this process.""" + module = doctor_module(standard) + root = repository_here(tmp_path / "Atlas") + if kind == "directory": + (root / "AGENTS.md").mkdir() + elif hasattr(os, "mkfifo"): + os.mkfifo(root / "AGENTS.md") + else: + pytest.skip("this platform makes no named pipe") + rows = doctor_within(standard, root) + contents = rows["contents"] + assert contents["reason"] == "a git repository, an AGENTS.md", contents + assert contents["detail"]["AGENTS.md"] is True + assert "cannot_be_read" not in contents["detail"], contents + offers_both_ways_in(module, rows) + + +def test_what_is_here_reads_through_agents_md_alone(standard, tmp_path, + monkeypatch): + """CONTROL, true on #1144's head as here: `AGENTS.md` is the one name + here whose CONTENT a reader of this directory reads, so it is the one + `what is here` reads through. Every other name is asked, as it always + was: in a leg's clone, a `Makefile` whose read is refused is listed like + any other name, and no row says it cannot be read.""" + module = doctor_module(standard) + root = leg_clone_here(tmp_path / "Atlas") + (root / "Makefile").write_text("all:\n", encoding="utf-8") + ctx = module.Context(root.resolve()) + refusing(monkeypatch, "read_bytes", "Makefile", + PermissionError(errno.EACCES, DENIED)) + rows = not_a_root_rows(module, ctx) + contents = rows["contents"] + assert contents["reason"] == ("a git repository, an AGENTS.md, " + "a Makefile"), contents + assert contents["detail"]["Makefile"] is True + for row in rows.values(): + assert "cannot be read" not in row["reason"], row + is_the_leg(rows, "spec") + + @pytest.mark.parametrize("encode", [ pytest.param(lambda text: text.encode("utf-8") + b"caf" + bytes([0xE9]) + b"\n", id="a-legacy-byte-after-the-opening"), From 34ed0a95ae7ffca5c9a1f0eeae6c3ab71ac57c63 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 22 Oct 2026 19:17:51 +0000 Subject: [PATCH 07/18] shape_advisory.py: where git cannot give a commit's `AGENTS.md`, a folder named as a leg is still that leg, as it was before #1159 and as it is where the disk's file is refused, so `adopt-project.py plan` and `check` print the leg's note over it again (#1110) The review of #1159 (r1, NIT-2) measured that a `Refusal` raised while reading a commit's `AGENTS.md` (`Tree.text`, `cat-file blob`) no longer fell back to the leg's name. #1144's head read it as no leg's file and then asked the name, so a folder named `

-spec` was `leg-clone`; #1159's head sent the `Refusal` straight to `read()`'s net, `not-answered`, and `adopt-project.py` stopped printing the leg's note there. `_read` now catches a `Refusal` from `_leg_by_agents_md` as it catches a refused `OSError`: the folder's name answers where it is a leg's, and with none the `Refusal` goes on to `read()`'s net, `not-answered`, as on #1159's head. This is the review's mutant M17. `refused` stays None: no `OSError` was refused. New row, a control that holds on #1144's head as here: `test_a_leg_by_its_name_is_one_while_git_cannot_give_its_agents_md`, a committed clone named `Orbit-spec` whose `cat-file blob` of `AGENTS.md` raises `Refusal`: `leg-clone`, by name, the spec leg. On #1159's head `0f9aff05` it fails with `not-answered`. Part of #1110 Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- scripts/shape_advisory.py | 14 +++++++-- tests/test_doctor_unsearchable_links.py | 38 +++++++++++++++++++++++-- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/scripts/shape_advisory.py b/scripts/shape_advisory.py index 8278ccc1..8de0f3f8 100644 --- a/scripts/shape_advisory.py +++ b/scripts/shape_advisory.py @@ -605,8 +605,10 @@ def _leg_by_agents_md(tree: Tree) -> dict | None: `repository_names` keeps for an origin URL (#385) and `_read_mapping` for a manifest. Nothing was refused, so nothing is unknown, and a person's `AGENTS.md` in another encoding is not made a reason to offer no way in. - Anything else (a git that cannot give the commit's blob) is not expected - here, and ends in `read()`'s net: `NOT_ANSWERED`, silent. + Anything else is raised too. A git that cannot give the commit's blob (a + `Refusal`) is answered by `_read` as a refused file is: by the folder's + name where it is a leg's, as before, and otherwise in `read()`'s net, + `NOT_ANSWERED`, silent. """ try: text = tree.text(AGENTS_MD) @@ -860,6 +862,14 @@ def _read(root: Path, shape: Path, env, commit: str | None) -> Reading: # ratified change names, and the one that can name the assembly root. try: leg = _leg_by_agents_md(tree) + except Refusal: + # A COMMIT'S `AGENTS.md` GIT COULD NOT GIVE (`cat-file blob`, in + # `adopt-project.py`'s reading of a commit). The NAME still answers, + # as it did before; with none, `read()`'s net: `NOT_ANSWERED`, with + # the exception's name in `why`. + leg = _leg_by_name(names, policy) + if leg is None: + raise except OSError as exc: # ITS OWN `AGENTS.md` THIS USER CANNOT READ (#1110). A leg's NAME is a # declared fact without it, so a name that is one still answers, as diff --git a/tests/test_doctor_unsearchable_links.py b/tests/test_doctor_unsearchable_links.py index 8a3e1e5d..74ff12a1 100644 --- a/tests/test_doctor_unsearchable_links.py +++ b/tests/test_doctor_unsearchable_links.py @@ -1016,8 +1016,9 @@ def test_a_failure_the_leg_reader_does_not_expect_is_silence_not_advice( that are not UTF-8 -- a git that cannot give the commit's blob, which `adopt-project.py`'s reading of a commit can meet -- is not expected by `_leg_by_agents_md`, and ends in `read()`'s net: `not-answered`, silent, - with the exception's name. On #1144's head it was "no leg's file", and - the advice. No `OSError` was refused, so `refused` is None.""" + with the exception's name, where no name says it is a leg (the row + after). On #1144's head it was "no leg's file", and the advice. No + `OSError` was refused, so `refused` is None.""" root = leg_clone_here(tmp_path / "Atlas") real = shape_advisory.Tree.text @@ -1035,6 +1036,39 @@ def text(self, rel): assert reading.refused is None +def test_a_leg_by_its_name_is_one_while_git_cannot_give_its_agents_md( + tmp_path, monkeypatch): + """CONTROL, true on #1144's head as here: `adopt-project.py` reads a + leg's `AGENTS.md` out of the commit its plan is written against, and + where git cannot give that blob (a `Refusal` from `cat-file blob`), a + folder NAMED as a leg is still that leg, as it is where the disk's file + is refused (the row `..._by_its_name_is_one_while_its_agents_md_cannot_ + be_read`), so `plan` and `check` print the leg's note. With no such + name, the reading is `not-answered` (the row above).""" + root = leg_clone_here(tmp_path / "Orbit-spec") + git("add", "-A", "--", ".", cwd=root) + git("commit", "-q", "-m", "Initial import", cwd=root) + commit = git("rev-parse", "HEAD", cwd=root).stdout.strip() + env = no_workspace(tmp_path) + committed = shape_advisory.read(root, env=env, commit=commit) + assert committed.leg["by"] == "agents", committed.as_dict() + real = shape_advisory.git_out + + def git_out(args, *rest, **kwargs): + if args[:2] == ["cat-file", "blob"] and args[-1].endswith( + ":AGENTS.md"): + raise shape_advisory.Refusal("git-failed", + "git could not give the blob") + return real(args, *rest, **kwargs) + + monkeypatch.setattr(shape_advisory, "git_out", git_out) + reading = shape_advisory.read(root, env=env, commit=commit) + assert reading.state == shape_advisory.LEG_CLONE, reading.as_dict() + assert reading.leg["by"] == "name", reading.leg + assert reading.leg["role"] == "spec", reading.leg + assert reading.refused is None + + def test_adopts_reader_reads_the_commits_agents_md_and_never_the_disks( tmp_path, monkeypatch): """`adopt-project.py` asks the advisory of the commit its plan is written From e356013bb9b38361117fc0d0db9378a773dd5841 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 22 Oct 2026 19:18:39 +0000 Subject: [PATCH 08/18] shape-doctor.py: `the way in` over a leg's `AGENTS.md` this user cannot read keeps `origin_org` in its `--json` detail, as the leg's own row and the policy row keep it with no command, where it said None while `origin` was read (#1110) The review of #1159 (r1, NIT-5) found that the `AGENTS.md` row gave `origin_org: null` while the `naming` row of the same run read `origin`, and that the docstring's reason, "None because no command is offered with them", did not hold: the leg's own row keeps `origin_org` with no command, and so does the policy row (`way_in_policy_row`). `way_in_unread_row` now takes `org`: None for a `.git` that cannot be read, whose `origin` is not read either (unchanged), and `origin_org(root)` for the `AGENTS.md` row, read only where `carries_git` answered and `leg_clone` raised. `suggested_project` stays None, since no `--project` is offered, and the docstring says so. `no_leg_way_in` now expects `origin_org: "acme"` (ORIGIN is `acme/Orbit`), so the 7 rows that use it pin the value: with the doctor of `34ed0a95` they fail, 7 failed and 42 passed. Part of #1110 Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- shape-doctor.py | 19 +++++++++++-------- tests/test_doctor_unsearchable_links.py | 2 +- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/shape-doctor.py b/shape-doctor.py index e7c4902a..41e8ce2b 100755 --- a/shape-doctor.py +++ b/shape-doctor.py @@ -11650,12 +11650,12 @@ def check_the_way_in(ctx: Context) -> Row: try: is_repo = carries_git(root) except OSError as exc: - return way_in_unread_row(".git", exc, None, + return way_in_unread_row(".git", exc, None, None, "this is a git repository to adopt") try: leg = is_repo and leg_clone(ctx) except OSError as exc: - return way_in_unread_row(AGENTS_MD, exc, True, + return way_in_unread_row(AGENTS_MD, exc, True, origin_org(root), "this is a clone of a Triad's leg, which is " "not adopted or scaffolded on its own") project, refusal = way_in_project(ctx) @@ -11701,20 +11701,23 @@ def check_the_way_in(ctx: Context) -> Row: def way_in_unread_row(named: str, exc: OSError, is_repo: bool | None, - unknown: str) -> Row: + org: str | None, unknown: str) -> Row: """`the way in` while `named` cannot be read, so that `unknown` is not known (#1110): `n/a` with no NEXT, since neither way in is offered, and a pointer at `what is here`, which names the file with the cure, once. - `detail` keeps the row's keys: `git_repository` is `is_repo` (None for a - `.git` that cannot be read), `suggested_project` and `origin_org` are - None because no command is offered with them, and `cannot_be_read` and - `path` name the file.""" + `detail` keeps the row's keys. `git_repository` is `is_repo` and + `origin_org` is `org`, each what was read: both None for a `.git` that + cannot be read, whose `origin` is not read either, and for an `AGENTS.md` + the repository and its `origin`'s organisation, kept as the leg's row + and the policy row keep `origin_org` with no command. `suggested_project` + is None, since no `--project` is offered, and `cannot_be_read` and `path` + name the file.""" return Row("way-in", "the way in", NA, f"its {named} cannot be read, so it is not known whether " f"{unknown}, and neither way in is offered until it can be: " "`what is here` says how", None, {"git_repository": is_repo, "suggested_project": None, - "origin_org": None, + "origin_org": org, "cannot_be_read": cannot_be_read(named, exc), "path": named}) diff --git a/tests/test_doctor_unsearchable_links.py b/tests/test_doctor_unsearchable_links.py index 74ff12a1..fb729650 100644 --- a/tests/test_doctor_unsearchable_links.py +++ b/tests/test_doctor_unsearchable_links.py @@ -325,7 +325,7 @@ def no_leg_way_in(rows: dict, line: str, again: str, why: str) -> None: assert way_in["next"] is None, way_in assert way_in["detail"] == { "git_repository": True, "suggested_project": None, - "origin_org": None, "cannot_be_read": line, "path": "AGENTS.md"} + "origin_org": "acme", "cannot_be_read": line, "path": "AGENTS.md"} told_once(rows, line) no_way_in_over_it(rows) From 0ca336110b67195ea670a2aa00a0c6915a3394be Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 22 Oct 2026 19:34:02 +0000 Subject: [PATCH 09/18] tests/test_doctor_unsearchable_links.py: the row for a leg known by its name where git cannot give its `AGENTS.md` no longer asks `Reading.refused`, which #1144's `Reading` does not have, so it is the control on #1144's head its docstring says it is (#1110) Measured with the file laid over #1144's head `89486f69`, the row's state, `by` and role checks pass there, and only `assert reading.refused is None` failed, with `'Reading' object has no attribute 'refused'`. `refused` is set only together with `not-answered`, so the line asked nothing of a `leg-clone` reading. Without it the row passes on `89486f69` and on this head, and still fails on `0f9aff05`, whose `Refusal` skips the name (`not-answered`, at the state check). Part of #1110 Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- tests/test_doctor_unsearchable_links.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_doctor_unsearchable_links.py b/tests/test_doctor_unsearchable_links.py index fb729650..e1abb9b3 100644 --- a/tests/test_doctor_unsearchable_links.py +++ b/tests/test_doctor_unsearchable_links.py @@ -1066,7 +1066,6 @@ def git_out(args, *rest, **kwargs): assert reading.state == shape_advisory.LEG_CLONE, reading.as_dict() assert reading.leg["by"] == "name", reading.leg assert reading.leg["role"] == "spec", reading.leg - assert reading.refused is None def test_adopts_reader_reads_the_commits_agents_md_and_never_the_disks( From 66e68f948d8612c5e7f5720b3f917f625c8bd38a Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 22 Oct 2026 21:03:24 +0000 Subject: [PATCH 10/18] tests/test_doctor_unsearchable_links.py: the docstring of the control row names its sibling row on one line, so a grep for the row's name finds the reference (#1110) Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Sonnet 5.5 --- tests/test_doctor_unsearchable_links.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/test_doctor_unsearchable_links.py b/tests/test_doctor_unsearchable_links.py index e1abb9b3..10a15eb5 100644 --- a/tests/test_doctor_unsearchable_links.py +++ b/tests/test_doctor_unsearchable_links.py @@ -1042,9 +1042,10 @@ def test_a_leg_by_its_name_is_one_while_git_cannot_give_its_agents_md( leg's `AGENTS.md` out of the commit its plan is written against, and where git cannot give that blob (a `Refusal` from `cat-file blob`), a folder NAMED as a leg is still that leg, as it is where the disk's file - is refused (the row `..._by_its_name_is_one_while_its_agents_md_cannot_ - be_read`), so `plan` and `check` print the leg's note. With no such - name, the reading is `not-answered` (the row above).""" + is refused (the row + `..._by_its_name_is_one_while_its_agents_md_cannot_be_read`), so `plan` + and `check` print the leg's note. With no such name, the reading is + `not-answered` (the row above).""" root = leg_clone_here(tmp_path / "Orbit-spec") git("add", "-A", "--", ".", cwd=root) git("commit", "-q", "-m", "Initial import", cwd=root) From 60c4152874de5f8dfac198d9e7f2deca7446687c Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 8 Nov 2026 16:12:47 +0000 Subject: [PATCH 11/18] shape_advisory.py: a leg's `AGENTS.md` committed as a symlink is read through it inside the commit `adopt-project.py plan` and `check` read, and one that leads out of the commit leaves whether it is a leg unknown, so neither calls the leg a single repository (#1163) `Tree.text(rel, follow=True)` follows a symlink at `rel` inside the commit as git follows one there (`git cat-file --batch-check --follow-symlinks`): to a file, which is read out of the commit's objects; to nothing that is a file (a link that dangles, loops or runs through a file, or leads to a directory or a submodule), which is no leg's file, as on the disk. A link that leads out of the commit raises `OutOfTheCommit`, and `_read` answers it as a file this user cannot read (#1110): by the leg's name, or `not-answered`, silent. Only `_leg_by_agents_md` asks it; the doctor reads the disk, which the operating system follows either way. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- scripts/shape_advisory.py | 89 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 87 insertions(+), 2 deletions(-) diff --git a/scripts/shape_advisory.py b/scripts/shape_advisory.py index ae535edc..48c72608 100644 --- a/scripts/shape_advisory.py +++ b/scripts/shape_advisory.py @@ -93,6 +93,7 @@ import os import re import stat +import subprocess import sys from pathlib import Path @@ -147,6 +148,10 @@ #: answers it with silence -- the doctor's `Triad` row, and `adopt-project.py`'s #: `triad` lines -- or, where silence is not enough, by naming `refused`: the #: doctor's `leg_clone` raises it, so `the way in` offers no way in over it. +#: So does a leg's `AGENTS.md` committed as a symlink that leads out of the +#: commit `adopt-project.py` reads (#1163), where no leg's name answers +#: instead: no commit holds what it leads to, so whether this is a leg is not +#: known there either, and `refused` is None, since nothing was refused. NOT_ANSWERED = "not-answered" STATES = (ADVISE, TRIAD_ROOT, FAMILY_HOLDER, LEG_CLONE, WORKSPACE, RECORDED_SINGLE, NOT_A_REPOSITORY, NOT_ANSWERED) @@ -397,6 +402,19 @@ def _is_a(path: Path, kind) -> bool: return kind(mode) +#: The mode `git ls-tree` gives an entry that is a symlink: its blob holds the +#: PATH it leads to, not a file's text. +LINK_MODE = "120000" + + +class OutOfTheCommit(Exception): + """A symlink in the commit being read that leads OUT of it (#1163): to + an absolute path, or past the root, which `git cat-file + --follow-symlinks` answers `symlink`. What it reaches is on this + machine's disk, or nowhere, and in no commit, so the commit cannot say + what is there; `_read` answers it as a file this user cannot read.""" + + class Tree: """The files of one repository's root, read from the disk or a commit. @@ -418,6 +436,11 @@ class Tree: every reader takes the file as there and unreadable, on every Python: the record's as a reason it silences nothing, a leg's `AGENTS.md`'s as a question this directory cannot answer (`_leg_by_agents_md`, #1110). + + `text(rel, follow=True)` reads a symlink at `rel` in a commit as the + disk's is read, through it (#1163): see `_followed`. On the disk the + operating system follows it either way, and without `follow` a link in a + commit is read as the blob it is, the PATH it holds, as before. """ def __init__(self, root: Path, commit: str | None = None): @@ -438,7 +461,9 @@ def kind(self, rel: str) -> str | None: # A tree, or a gitlink (`commit`), is there and is not a file. return "file" if found == "blob" else "dir" - def text(self, rel: str) -> str | None: + def text(self, rel: str, follow: bool = False) -> str | None: + if follow and self.commit is not None and self._link(rel): + return self._followed(rel) if self.kind(rel) != "file": return None if self.commit is None: @@ -448,6 +473,43 @@ def text(self, rel: str) -> str | None: cwd=self.root, binary=True) return raw.decode("utf-8") + def _link(self, rel: str) -> bool: + """Whether `rel` is a symlink in the commit: its `git ls-tree` entry + has `LINK_MODE`. A git that cannot say makes it no link, and `text` + reads `rel` as it did before #1163.""" + try: + entry = git_out(["ls-tree", "--full-tree", self.commit, "--", + rel], cwd=self.root) + except (Refusal, OSError): + return False + return entry.startswith(f"{LINK_MODE} ") + + def _followed(self, rel: str) -> str | None: + """The text of the file the symlink at `rel` leads to INSIDE the + commit, followed as git follows it there (`git cat-file + --batch-check --follow-symlinks`): from the directory each link is + in, through every link on the way, as a checkout's `open()` does. + + NOTHING THAT IS A FILE IS NONE, as on the disk: a link that dangles, + loops or runs through a file (#430's ENOENT, ELOOP and ENOTDIR), and + one that leads to a directory or a submodule. A link that leads OUT + of the commit raises `OutOfTheCommit`. A git that cannot follow it + is a `Refusal`, as one that cannot give a blob is.""" + asked = ["cat-file", "--batch-check", "--follow-symlinks"] + found = subprocess.run(["git", *asked], cwd=str(self.root), + input=f"{self.commit}:{rel}\n".encode("utf-8"), + capture_output=True, check=False) + if found.returncode != 0: + raise Refusal("git-failed", f"`git {' '.join(asked)}` exited " + f"{found.returncode}") + header = found.stdout.split(b"\n", 1)[0].split() + if header[:1] == [b"symlink"]: + raise OutOfTheCommit(f"{rel} leads out of the commit") + if header[1:2] != [b"blob"]: + return None + return git_out(["cat-file", "blob", header[0].decode("ascii")], + cwd=self.root, binary=True).decode("utf-8") + def _read_mapping(tree: Tree, rel: str): """`rel` as a YAML mapping, or None -- never a refusal, never a crash. @@ -612,9 +674,21 @@ def _leg_by_agents_md(tree: Tree) -> dict | None: `Refusal`) is answered by `_read` as a refused file is: by the folder's name where it is a leg's, as before, and otherwise in `read()`'s net, `NOT_ANSWERED`, silent. + + A SYMLINK IS READ THROUGH IN A COMMIT, AS ON THE DISK (#1163). The + operating system reads the disk's `AGENTS.md` through its link, and that + is what the doctor reads. A commit's link is a blob holding the PATH it + leads to, and read as text that was no leg's file, so `adopt-project.py + plan` and `check` called a leg whose `AGENTS.md` is a link a single + repository, where the doctor called the same checkout the leg. Now + `Tree.text` follows it inside the commit (`follow`): to a file, which is + read, out of the commit's objects as any file there is; to nothing that + is a file, which is no leg's file, as on the disk. A link that leads out + of the commit raises `OutOfTheCommit`, for `_read` to answer as a file + this user cannot read: no commit says what is there. """ try: - text = tree.text(AGENTS_MD) + text = tree.text(AGENTS_MD, follow=True) except UnicodeDecodeError: return None except OSError as exc: @@ -885,6 +959,17 @@ def _read(root: Path, shape: Path, env, commit: str | None) -> Reading: "so whether this is a leg clone is " "not known here, and the advisory " "says nothing", refused=exc) + except OutOfTheCommit: + # A COMMITTED `AGENTS.md` THAT IS A SYMLINK OUT OF THE COMMIT (#1163): + # what it reaches is in no commit, so the commit cannot say whether + # this is a leg. The NAME still answers, as for a file this user + # cannot read; with none, `NOT_ANSWERED` -- silent, never the advice. + leg = _leg_by_name(names, policy) + if leg is None: + return Reading(NOT_ANSWERED, f"{AGENTS_MD}: a symlink out of the " + "commit, so whether this is a leg " + "clone is not known here, and the " + "advisory says nothing") else: leg = leg or _leg_by_name(names, policy) if leg is not None: From b4b5b1e2bf0e1faafc6926a5a9582555e15d9378 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 8 Nov 2026 16:16:59 +0000 Subject: [PATCH 12/18] tests/test_advisory_agents_md_symlink.py: rows for a leg's `AGENTS.md` committed as a symlink, each failing on #1159's head `5370b018`: to its own file, beside it or through a linked folder, at 000 in or beside the repository, and out of the commit; with controls for a link to nothing, a regular leg and a single repository (#1163) Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- tests/test_advisory_agents_md_symlink.py | 378 +++++++++++++++++++++++ 1 file changed, 378 insertions(+) create mode 100644 tests/test_advisory_agents_md_symlink.py diff --git a/tests/test_advisory_agents_md_symlink.py b/tests/test_advisory_agents_md_symlink.py new file mode 100644 index 00000000..31eba61a --- /dev/null +++ b/tests/test_advisory_agents_md_symlink.py @@ -0,0 +1,378 @@ +# SPDX-License-Identifier: Apache-2.0 +"""A leg's `AGENTS.md` committed as a symlink is read through it by the Triad +advisory's reading of a commit, as the disk's is read: #1163, in +`scripts/shape_advisory.py`, beside #1110's twelfth site +(`tests/test_doctor_unsearchable_links.py`). + +THE DEFECT. `adopt-project.py plan` and `check` ask the advisory of the +commit the plan is written against, and a symlink in a commit is a blob that +holds the PATH it leads to. `_leg_by_agents_md` read that path as the file's +text, found no leg's opening in it, and both commands said "This is a single +repository" over a leg whose `AGENTS.md` is a link, whether what it leads to +was readable or not. The doctor reads the disk, where the operating system +follows the link, and called the same checkout the leg. Measured on #1159's +head `5370b018`, Python 3.12.3 and git 2.43.0, over a clone of `acme/Orbit` +in a folder `Atlas`, each link committed: + + * `AGENTS.md -> docs/AGENTS.md`, the spec leg's file: `plan` and `check` + printed the `triad` advisory, and `read()` of the commit said `advise`; + the doctor's Triad row said "this is a clone of the spec leg"; + * the same with `docs/` or the file at mode 000: the advisory again, where + the doctor offers no way in (#1110's twelfth site); + * `AGENTS.md -> ../locked/AGENTS.md`, or its absolute path, beside the + repository: the advisory, where the doctor read the leg (readable) or + offered no way in (`locked/` at 000). + +THE DECISION, option (b) of the brief: the commit's link is followed INSIDE +the commit, as git follows one there (`git cat-file --follow-symlinks`), and +what it leads to is read out of the commit's own objects, as any committed +file is: a leg's file is the leg, whatever the disk's modes. Nothing there +that is a file -- a link that dangles in the commit, loops, runs through a +file, or leads to a directory -- is no leg's file, as on the disk (#430's +rule), so the clone is advised there by both readers. A link that leads OUT +of the commit, an absolute path or one past the root, reaches what no commit +holds: whether this is a leg is not known, the way #1159 treats a file this +user cannot read -- a leg's NAME still answers, and otherwise the reading is +`not-answered`, which `plan` and `check` keep silent. + +TWO KINDS OF CASE, as `tests/test_doctor_unsearchable_links.py` has them: +the MODE cases make a real folder at 000 and run the tools as a person does, +and skip on Windows and as root (`CANNOT_RUN_AS_ROOT`), putting the mode +back in a `finally` (`mode_of`); the rest run everywhere a symlink can be +made, and skip where none can (`symlink`). +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import pytest + +from conftest import ADOPT, REPO, git, run_script +#: #827's stand-in for a `stat` the operating system refuses, from the file +#: that owns it. +from test_bump_leg_unopenable import refused_stat +#: The way in's words for a leg's `AGENTS.md` this user cannot read, the +#: environment with no `workspace.yaml`, and the link maker that skips where +#: none can be made, from #1159's file. +from test_doctor_unsearchable_links import NO_LEG_WAY_IN, no_workspace, symlink +#: A leg's `AGENTS.md` as the scaffold renders it, `adopt-project.py plan` +#: as the advisory's own rows run it, the doctor's `--json` rows, and a +#: commit of the whole working tree, from the file that owns them. +from test_shape_advisory import ( + adopt_plan, commit_all, doctor_json, rendered_leg_agents, +) +#: 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 + +AGENTS = shape_advisory.AGENTS_MD +#: What `plan` and `check` print over a single repository, and over a leg +#: known by its `AGENTS.md`. +SINGLE = "This is a single repository." +LEG_NOTE = "NOTE this is a clone of the {} leg of a Triad" +#: The advisory's `why` for a committed `AGENTS.md` that leads out of the +#: commit, where no leg's name answers. +OUT_WHY = (f"{AGENTS}: a symlink out of the commit, so whether this is a leg " + "clone is not known here, and the advisory says nothing") +#: The `AGENTS.md` link to `docs/AGENTS.md`, the shape of every case here +#: that leads inside the repository. +DOCS_LEG = Path("docs", AGENTS) + + +def linked_clone(tmp_path: Path, target: Path, files: dict | None = None, + links: dict | None = None, + name: str = "renamed-clone") -> Path: + """`/`, a repository with no `origin` whose `AGENTS.md` is a + symlink to `target`, beside `files` (`{relative path: text}`) and + `links` (`{relative path: target}`), all of it committed: a clone whose + own name and files alone say what it is.""" + root = tmp_path / name + root.mkdir() + git("init", "-q", "-b", "main", ".", cwd=root) + (root / "README.md").write_text("# here\n", encoding="utf-8") + for rel, text in (files or {}).items(): + (root / rel).parent.mkdir(parents=True, exist_ok=True) + (root / rel).write_text(text, encoding="utf-8") + for rel, to in (links or {}).items(): + symlink(root / rel, to) + symlink(root / AGENTS, target) + commit_all(root, "Initial import") + return root + + +def locked_leg(tmp_path: Path, role: str = "spec") -> Path: + """`/locked/AGENTS.md`, the `role` leg's file, BESIDE the + repository rather than in it.""" + (tmp_path / "locked").mkdir() + leg = tmp_path / "locked" / AGENTS + leg.write_text(rendered_leg_agents(role), encoding="utf-8") + return leg + + +def committed(root: Path, env: dict) -> shape_advisory.Reading: + """The advisory as `adopt-project.py` asks it: of HEAD's commit.""" + commit = git("rev-parse", "HEAD", cwd=root).stdout.strip() + return shape_advisory.read(root, env=env, commit=commit) + + +def adopt_says(root: Path, out: Path, env: dict) -> list[str]: + """`[plan's stdout, check's stdout]`, `check` run on the plan `plan` + wrote to `out`: the two places a person meets the advisory before + `execute`. Neither printed a traceback, and `plan` exited 0, as it does + whatever the advisory says.""" + planned = adopt_plan(root, out, env) + assert planned.returncode == 0, planned.stdout + planned.stderr + checked = run_script(ADOPT, "check", "--plan", str(out), env=env) + for result in (planned, checked): + assert "Traceback" not in result.stdout + result.stderr, \ + result.stdout + result.stderr + return [planned.stdout, checked.stdout] + + +def told_the_leg(said: list[str], role: str) -> None: + """Each of `plan` and `check` printed the leg's NOTE once, and no + advisory.""" + for stdout in said: + assert stdout.count(LEG_NOTE.format(role)) == 1, stdout + assert SINGLE not in stdout, stdout + + +def told_nothing(said: list[str]) -> None: + """Neither `plan` nor `check` printed the advisory, a `triad` line or a + leg's NOTE: the silence of `not-answered`.""" + for stdout in said: + assert SINGLE not in stdout, stdout + assert "\ntriad " not in f"\n{stdout}", stdout + assert "leg of a Triad" not in stdout, stdout + assert "is named as the" not in stdout, stdout + + +# --- a link to the leg's own file, in the commit ----------------------------- + +@pytest.mark.parametrize("role, leg_at, links", [ + pytest.param("spec", "docs/AGENTS.md", {}, id="beside"), + pytest.param("code", ".agents/AGENTS.md", {"docs": Path(".agents")}, + id="through-a-linked-folder"), +]) +def test_a_leg_agents_md_linked_to_its_own_file_is_the_leg_in_the_commit( + tmp_path, role, leg_at, links): + """The issue's case. The doctor reads the disk's `AGENTS.md` through its + link and calls the clone the leg. On the base the commit's link was read + as the path it holds, so `plan` and `check` printed the advisory over + the leg. Now the link is followed inside the commit -- through a linked + folder too (`docs -> .agents`), as git follows it -- and both print the + leg's NOTE, as over a leg whose `AGENTS.md` is a regular file.""" + root = linked_clone(tmp_path, DOCS_LEG, + {leg_at: rendered_leg_agents(role)}, links) + env = no_workspace(tmp_path) + reading = committed(root, env) + assert reading.state == shape_advisory.LEG_CLONE, reading.as_dict() + assert reading.leg["by"] == "agents", reading.leg + assert reading.leg["role"] == role, reading.leg + assert reading.leg["assembly"] == "Northwind/Atlas", reading.leg + told_the_leg(adopt_says(root, tmp_path / "plan.yaml", env), role) + _code, rows, _payload = doctor_json(root, env) + triad = rows["triad-advisory"] + assert triad["status"] == "n/a", triad + assert f"this is a clone of the {role} leg" in triad["reason"], triad + + +@CANNOT_RUN_AS_ROOT +@pytest.mark.parametrize("where", ["in-the-repository", "beside-it"]) +def test_a_leg_agents_md_linked_into_a_folder_nobody_may_search( + tmp_path, where): + """MODE CASES: the link leads into a folder at 000, in the repository + (`docs/`) or beside it (`../locked/`). The doctor reads the disk and + cannot read the file: no Triad row, and `the way in` offers neither + command (#1110's twelfth site). On the base `plan` and `check` printed + the advisory over both. Now the commit answers for the folder in the + repository out of its own objects, which no mode refuses, so both print + the leg's NOTE; and for the folder beside it, which no commit holds, + they print nothing at all.""" + if where == "in-the-repository": + root = linked_clone(tmp_path, DOCS_LEG, + {"docs/AGENTS.md": rendered_leg_agents("spec")}) + parent, locked = root, "docs" + else: + locked_leg(tmp_path) + root = linked_clone(tmp_path, Path("..", "locked", AGENTS)) + parent, locked = tmp_path, "locked" + env = no_workspace(tmp_path) + with mode_of(parent, locked, 0): + said = adopt_says(root, tmp_path / "plan.yaml", env) + _code, rows, _payload = doctor_json(root, env) + reading = committed(root, env) + assert "triad-advisory" not in rows, rows["triad-advisory"] + assert rows["way-in"]["reason"] == NO_LEG_WAY_IN, rows["way-in"] + assert rows["way-in"]["next"] is None, rows["way-in"] + if where == "in-the-repository": + assert reading.state == shape_advisory.LEG_CLONE, reading.as_dict() + told_the_leg(said, "spec") + else: + assert reading.state == shape_advisory.NOT_ANSWERED, \ + reading.as_dict() + told_nothing(said) + + +def test_the_commits_linked_leg_file_is_read_when_the_disks_is_refused( + tmp_path, monkeypatch): + """The stand-in for the mode case, in process, on every platform and as + root: every `stat` of a name `AGENTS.md` refused, the link's and its + target's. The disk, which the doctor reads, is `not-answered`; the + commit, which `adopt-project.py` reads, is the leg, where the base read + the link's path and advised. #1159's + `test_adopts_reader_reads_the_commits_agents_md_and_never_the_disks` + holds the same for a regular file.""" + root = linked_clone(tmp_path, DOCS_LEG, + {"docs/AGENTS.md": rendered_leg_agents("code")}) + env = no_workspace(tmp_path) + refused_stat(monkeypatch, AGENTS) + reading = committed(root, env) + assert reading.state == shape_advisory.LEG_CLONE, reading.as_dict() + assert reading.leg["role"] == "code", reading.leg + assert (shape_advisory.read(root, env=env).state + == shape_advisory.NOT_ANSWERED) + + +# --- a link out of the commit ------------------------------------------------ + +@pytest.mark.parametrize("how", ["relative", "absolute", "dangling"]) +def test_a_leg_agents_md_linked_out_of_the_commit_is_not_answered(tmp_path, + how): + """A link that leads out of the commit: `../locked/AGENTS.md`, the same + file by its absolute path, or `../nowhere/AGENTS.md`. No commit holds + what it reaches, so whether this is a leg is not known in the commit: + `not-answered`, with its line in `why` and nothing `refused`, and `plan` + and `check` print neither the advisory nor a leg's NOTE. On the base + each printed the advisory. The doctor reads the disk, which the commit + cannot: the leg where the link reaches its file, and the advisory where + it reaches nothing, as for any dangling link -- silence in the commit is + the one answer that is false of neither.""" + leg = locked_leg(tmp_path) + target = {"relative": Path("..", "locked", AGENTS), "absolute": leg, + "dangling": Path("..", "nowhere", AGENTS)}[how] + root = linked_clone(tmp_path, target) + env = no_workspace(tmp_path) + reading = committed(root, env) + assert reading.state == shape_advisory.NOT_ANSWERED, reading.as_dict() + assert reading.why == OUT_WHY + assert reading.advise is False + assert reading.leg is None + assert reading.refused is None + told_nothing(adopt_says(root, tmp_path / "plan.yaml", env)) + _code, rows, _payload = doctor_json(root, env) + triad = rows["triad-advisory"] + if how == "dangling": + assert triad["status"] == "advisory", triad + else: + assert "this is a clone of the spec leg" in triad["reason"], triad + + +def test_a_leg_by_its_name_is_one_while_its_agents_md_links_out_of_the_commit( + tmp_path): + """CONTROL, true on the base as here: a folder named `Orbit-spec` is a + leg by its NAME, a declared fact no `AGENTS.md` is needed for, so its + `AGENTS.md` leading out of the commit leaves it the leg its name says, + as a file this user cannot read does + (`..._by_its_name_is_one_while_its_agents_md_cannot_be_read`): `plan` + and `check` print the hedged NOTE for a name.""" + locked_leg(tmp_path) + root = linked_clone(tmp_path, Path("..", "locked", AGENTS), + name="Orbit-spec") + env = no_workspace(tmp_path) + reading = committed(root, env) + assert reading.state == shape_advisory.LEG_CLONE, reading.as_dict() + assert reading.leg["by"] == "name", reading.leg + for stdout in adopt_says(root, tmp_path / "plan.yaml", env): + assert "NOTE `Orbit-spec` is named as the spec leg" in stdout, stdout + assert SINGLE not in stdout, stdout + + +def test_a_git_that_cannot_follow_the_link_is_silence_not_advice( + tmp_path, monkeypatch): + """A git that cannot follow the committed link (`cat-file + --follow-symlinks` exits 128) is a `Refusal`, as one that cannot give + the commit's blob is (#1159): a folder named as a leg is still that + leg, and one that is not is `not-answered`, in `read()`'s net. On the + base the link's path was read as the file, and the folder that is no + leg's name was advised.""" + leg = {"docs/AGENTS.md": rendered_leg_agents("spec")} + plain = linked_clone(tmp_path, DOCS_LEG, leg) + named = linked_clone(tmp_path, DOCS_LEG, leg, name="Orbit-spec") + real = subprocess.run + + def run(args, *rest, **kwargs): + if "--follow-symlinks" in args: + return subprocess.CompletedProcess(args, 128, b"", b"fatal: no") + return real(args, *rest, **kwargs) + + monkeypatch.setattr(subprocess, "run", run) + env = no_workspace(tmp_path) + reading = committed(plain, env) + assert reading.state == shape_advisory.NOT_ANSWERED, reading.as_dict() + assert reading.why == ("the advisory could not be read here (Refusal), " + "so it says nothing") + assert reading.refused is None + reading = committed(named, env) + assert reading.state == shape_advisory.LEG_CLONE, reading.as_dict() + assert reading.leg["by"] == "name", reading.leg + + +# --- the controls: nothing that is a leg's file, and the plain cases ---------- + +@pytest.mark.parametrize("target, files", [ + pytest.param(DOCS_LEG, {"docs/notes.md": "# notes\n"}, id="dangling"), + pytest.param(Path(AGENTS), {}, id="loop"), + pytest.param(Path("docs"), {"docs/notes.md": "# notes\n"}, + id="a-directory"), + pytest.param(Path("README.md", AGENTS), {}, id="through-a-file"), + pytest.param(DOCS_LEG, {"docs/AGENTS.md": "# agents\n"}, + id="another-file"), +]) +def test_an_agents_md_linked_to_no_legs_file_is_advised_by_both_readers( + tmp_path, target, files): + """CONTROLS, true on the base as here: a link that leads, inside the + commit, to nothing (it dangles), to itself, to a directory, through a + file, or to a file that is no leg's, is no leg's file, on the disk + (#430's rule) and in the commit alike, and both readers advise. They + hold the follow to the disk's answers, rather than to "a link is not + known", which only a link out of the commit is.""" + root = linked_clone(tmp_path, target, files) + env = no_workspace(tmp_path) + assert committed(root, env).state == shape_advisory.ADVISE + assert shape_advisory.read(root, env=env).state == shape_advisory.ADVISE + + +def test_a_regular_leg_agents_md_and_a_single_repository_are_as_before( + tmp_path): + """CONTROLS, true on the base as here: a leg whose `AGENTS.md` is a + regular file is the leg in the commit, and `plan` and `check` print its + NOTE; a single repository with no `AGENTS.md` is advised, once by each. + Neither meets a link, and neither reading changes.""" + env = no_workspace(tmp_path) + for name, files in (("renamed-clone", {AGENTS: rendered_leg_agents( + "code")}), ("Thing", {})): + root = tmp_path / name + root.mkdir() + git("init", "-q", "-b", "main", ".", cwd=root) + (root / "README.md").write_text("# here\n", encoding="utf-8") + for rel, text in files.items(): + (root / rel).write_text(text, encoding="utf-8") + commit_all(root, "Initial import") + leg = committed(tmp_path / "renamed-clone", env) + assert leg.state == shape_advisory.LEG_CLONE, leg.as_dict() + assert leg.leg["by"] == "agents", leg.leg + told_the_leg(adopt_says(tmp_path / "renamed-clone", + tmp_path / "leg-plan.yaml", env), "code") + assert committed(tmp_path / "Thing", env).state == shape_advisory.ADVISE + for stdout in adopt_says(tmp_path / "Thing", tmp_path / "plan.yaml", + env): + assert stdout.count(SINGLE) == 1, stdout + assert "leg of a Triad" not in stdout, stdout From 2abd81765fd1b773f18b8cd5fbe9aa6fbded15f3 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 8 Nov 2026 16:25:30 +0000 Subject: [PATCH 13/18] shape_advisory.py: `_leg_by_agents_md` asks `Tree.is_link` and reads a committed link through `Tree.text_through`, and `Tree.text` keeps its one argument, so #1159's row that stands in for `Tree.text` with a refusal reads `Refusal` again, not `TypeError` (#1163) `text(rel, follow=True)` made `_leg_by_agents_md` pass a keyword that the stand-in in `tests/test_doctor_unsearchable_links.py`'s `test_a_failure_the_leg_reader_does_not_expect_is_silence_not_advice` (`def text(self, rel)`) does not take, so `read()`'s net named a `TypeError` where the row asks for `Refusal`. The decision is unchanged. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- scripts/shape_advisory.py | 43 +++++++++++++++++++++------------------ 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/scripts/shape_advisory.py b/scripts/shape_advisory.py index 48c72608..a0b4ab7c 100644 --- a/scripts/shape_advisory.py +++ b/scripts/shape_advisory.py @@ -403,7 +403,7 @@ def _is_a(path: Path, kind) -> bool: #: The mode `git ls-tree` gives an entry that is a symlink: its blob holds the -#: PATH it leads to, not a file's text. +#: PATH it leads to, not a file's text (`Tree.is_link`). LINK_MODE = "120000" @@ -437,10 +437,10 @@ class Tree: record's as a reason it silences nothing, a leg's `AGENTS.md`'s as a question this directory cannot answer (`_leg_by_agents_md`, #1110). - `text(rel, follow=True)` reads a symlink at `rel` in a commit as the - disk's is read, through it (#1163): see `_followed`. On the disk the - operating system follows it either way, and without `follow` a link in a - commit is read as the blob it is, the PATH it holds, as before. + `text()` reads a symlink in a commit as the blob it is, the PATH it + holds, as before. `text_through()` reads it as the disk's is read, + through it (#1163), where `is_link()` says it is one; only a leg's + `AGENTS.md` is read so (`_leg_by_agents_md`). """ def __init__(self, root: Path, commit: str | None = None): @@ -461,9 +461,7 @@ def kind(self, rel: str) -> str | None: # A tree, or a gitlink (`commit`), is there and is not a file. return "file" if found == "blob" else "dir" - def text(self, rel: str, follow: bool = False) -> str | None: - if follow and self.commit is not None and self._link(rel): - return self._followed(rel) + def text(self, rel: str) -> str | None: if self.kind(rel) != "file": return None if self.commit is None: @@ -473,10 +471,13 @@ def text(self, rel: str, follow: bool = False) -> str | None: cwd=self.root, binary=True) return raw.decode("utf-8") - def _link(self, rel: str) -> bool: - """Whether `rel` is a symlink in the commit: its `git ls-tree` entry - has `LINK_MODE`. A git that cannot say makes it no link, and `text` - reads `rel` as it did before #1163.""" + def is_link(self, rel: str) -> bool: + """Whether `rel` is a symlink IN THE COMMIT: its `git ls-tree` entry + has `LINK_MODE` (#1163). Never on the disk, where the operating + system reads through a link whoever asks. A git that cannot say + makes it no link, and `rel` is read as it was before.""" + if self.commit is None: + return False try: entry = git_out(["ls-tree", "--full-tree", self.commit, "--", rel], cwd=self.root) @@ -484,7 +485,7 @@ def _link(self, rel: str) -> bool: return False return entry.startswith(f"{LINK_MODE} ") - def _followed(self, rel: str) -> str | None: + def text_through(self, rel: str) -> str | None: """The text of the file the symlink at `rel` leads to INSIDE the commit, followed as git follows it there (`git cat-file --batch-check --follow-symlinks`): from the directory each link is @@ -680,15 +681,17 @@ def _leg_by_agents_md(tree: Tree) -> dict | None: is what the doctor reads. A commit's link is a blob holding the PATH it leads to, and read as text that was no leg's file, so `adopt-project.py plan` and `check` called a leg whose `AGENTS.md` is a link a single - repository, where the doctor called the same checkout the leg. Now - `Tree.text` follows it inside the commit (`follow`): to a file, which is - read, out of the commit's objects as any file there is; to nothing that - is a file, which is no leg's file, as on the disk. A link that leads out - of the commit raises `OutOfTheCommit`, for `_read` to answer as a file - this user cannot read: no commit says what is there. + repository, where the doctor called the same checkout the leg. Now a + link in the commit (`Tree.is_link`) is followed inside it + (`Tree.text_through`): to a file, which is read, out of the commit's + objects as any file there is; to nothing that is a file, which is no + leg's file, as on the disk. A link that leads out of the commit raises + `OutOfTheCommit`, for `_read` to answer as a file this user cannot read: + no commit says what is there. """ try: - text = tree.text(AGENTS_MD, follow=True) + text = (tree.text_through(AGENTS_MD) if tree.is_link(AGENTS_MD) + else tree.text(AGENTS_MD)) except UnicodeDecodeError: return None except OSError as exc: From 7121e2228a3ec013b4f4d524d4ebcf0e6c49bf3a Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 8 Nov 2026 17:25:02 +0000 Subject: [PATCH 14/18] shape_advisory.py: a leg's `AGENTS.md` committed as a link spelled with a `.` segment (`./docs/AGENTS.md`, `docs/./AGENTS.md`, `.//docs/AGENTS.md`), which git 2.43's `--follow-symlinks` answers `dangling`, is asked again with those segments dropped, so `plan` and `check` name the leg there too (#1163) r1163's SHOULD-1. git 2.43 looks a `.` segment up as a name no tree holds, so the follow answered `dangling` and the commit reading advised over a leg the doctor names. Where git says `dangling`, `Tree.text_through` now reads the link's own target (`cat-file blob :AGENTS.md`), and one that holds a `.` segment before its last is asked again with those dropped (`_dot_segments_dropped`). A last `.` or `/` is kept, so a link through a file stays `notdir`. A target that then begins with `..` is out of the commit, as git answers `../x`; git is never asked a name beginning `../`, which it reads from the working directory and refuses (exit 128, measured). A target with a line break, a carriage return or a NUL is not asked again, and only a line that begins with an object's name is a blob to read, since git's line for a name it did not find begins with that name. `text_through`'s docstring no longer says git follows as `open()` does. Rows: `..._is_the_leg_in_the_commit[dot-slash]`, `[dot-in-the-middle]`, `[dot-and-a-double-slash]` and `..._out_of_the_commit_is_not_answered [relative-after-a-dot]` fail on 2abd8176; four controls hold the second ask to the disk's answer. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- scripts/shape_advisory.py | 80 +++++++++++++++--- tests/test_advisory_agents_md_symlink.py | 101 +++++++++++++++++------ 2 files changed, 146 insertions(+), 35 deletions(-) diff --git a/scripts/shape_advisory.py b/scripts/shape_advisory.py index a0b4ab7c..8edad0c8 100644 --- a/scripts/shape_advisory.py +++ b/scripts/shape_advisory.py @@ -406,6 +406,36 @@ def _is_a(path: Path, kind) -> bool: #: PATH it leads to, not a file's text (`Tree.is_link`). LINK_MODE = "120000" +#: A full object name, SHA-1 or SHA-256, as `git cat-file --batch-check` +#: begins the line for an object it found. Only such a line names a blob to +#: read: the line for a name it did not find begins with the name it was +#: asked, which a link's target spells (`Tree.text_through`). +OBJECT_NAME_RE = re.compile(rb"[0-9a-f]{40}(?:[0-9a-f]{24})?") + + +def _dot_segments_dropped(target: bytes) -> bytes | None: + """A committed link's own `target` with every `.` segment before its + last one dropped, or None where there is none to drop (#1163). + + git 2.43's `cat-file --follow-symlinks` looks a `.` segment up as a + name, which no tree holds, so it answers `dangling` for + `./docs/AGENTS.md`, `docs/./AGENTS.md` and `.//docs/AGENTS.md`, where + the operating system reads the file. A `.` names the folder it is in, so + the path without it is the one the operating system follows. A LAST `.` + OR `/` IS KEPT: `docs/AGENTS.md/.` and `docs/AGENTS.md/` run through a + file, ENOTDIR on the disk and `notdir` in git, and must not become the + file. None, too, for an absolute target, which git answers `symlink` + before it reads a segment, and for one with a line break, a carriage + return or a NUL in it, which git's batch input would cut into another + name.""" + if target.startswith(b"/") or any(ch in target + for ch in (b"\n", b"\r", b"\0")): + return None + *inner, last = target.split(b"/") + if b"." not in inner: + return None + return b"/".join([part for part in inner if part != b"."] + [last]) + class OutOfTheCommit(Exception): """A symlink in the commit being read that leads OUT of it (#1163): to @@ -485,28 +515,56 @@ def is_link(self, rel: str) -> bool: return False return entry.startswith(f"{LINK_MODE} ") + def _follow(self, name: bytes) -> list[bytes]: + """The first line of `git cat-file --batch-check --follow-symlinks`'s + answer for `:`, split at its spaces. A git that cannot + answer is a `Refusal`, as one that cannot give a blob is.""" + asked = ["cat-file", "--batch-check", "--follow-symlinks"] + found = subprocess.run(["git", *asked], cwd=str(self.root), + input=(self.commit.encode("utf-8") + b":" + + name + b"\n"), + capture_output=True, check=False) + if found.returncode != 0: + raise Refusal("git-failed", f"`git {' '.join(asked)}` exited " + f"{found.returncode}") + return found.stdout.split(b"\n", 1)[0].split() + def text_through(self, rel: str) -> str | None: """The text of the file the symlink at `rel` leads to INSIDE the commit, followed as git follows it there (`git cat-file - --batch-check --follow-symlinks`): from the directory each link is - in, through every link on the way, as a checkout's `open()` does. + --batch-check --follow-symlinks`): each link from the directory it is + in, through a linked folder too, up to 40 links. `rel` is a name at + the root, as `AGENTS.md` is. + + GIT 2.43 FOLLOWS NO `.` SEGMENT (#1163), so it is not quite a + checkout's `open()`: it answers `dangling` for a link spelled + `./docs/AGENTS.md`. Where it says `dangling`, the link's own target + is read, and one that holds a `.` segment before its last is asked + again with those dropped (`_dot_segments_dropped`). One that then + begins with `..` climbs past the root, out of the commit, as git + answers `../x`; git is never asked a name that begins `../`, which + it takes as relative to the working directory, or refuses. Only the + link at `rel` is read so: a `.` segment in a link further on is still + `dangling`. NOTHING THAT IS A FILE IS NONE, as on the disk: a link that dangles, loops or runs through a file (#430's ENOENT, ELOOP and ENOTDIR), and one that leads to a directory or a submodule. A link that leads OUT of the commit raises `OutOfTheCommit`. A git that cannot follow it is a `Refusal`, as one that cannot give a blob is.""" - asked = ["cat-file", "--batch-check", "--follow-symlinks"] - found = subprocess.run(["git", *asked], cwd=str(self.root), - input=f"{self.commit}:{rel}\n".encode("utf-8"), - capture_output=True, check=False) - if found.returncode != 0: - raise Refusal("git-failed", f"`git {' '.join(asked)}` exited " - f"{found.returncode}") - header = found.stdout.split(b"\n", 1)[0].split() + header = self._follow(rel.encode("utf-8")) + if header[:1] == [b"dangling"]: + target = _dot_segments_dropped(git_out( + ["cat-file", "blob", f"{self.commit}:{rel}"], cwd=self.root, + binary=True)) + if target is not None and target.split(b"/", 1)[0] == b"..": + raise OutOfTheCommit(f"{rel} leads out of the commit") + if target is not None: + header = self._follow(target) if header[:1] == [b"symlink"]: raise OutOfTheCommit(f"{rel} leads out of the commit") - if header[1:2] != [b"blob"]: + if (len(header) != 3 or header[1] != b"blob" + or not OBJECT_NAME_RE.fullmatch(header[0])): return None return git_out(["cat-file", "blob", header[0].decode("ascii")], cwd=self.root, binary=True).decode("utf-8") diff --git a/tests/test_advisory_agents_md_symlink.py b/tests/test_advisory_agents_md_symlink.py index 31eba61a..90dbc3f7 100644 --- a/tests/test_advisory_agents_md_symlink.py +++ b/tests/test_advisory_agents_md_symlink.py @@ -44,6 +44,7 @@ from __future__ import annotations +import os import subprocess import sys from pathlib import Path @@ -84,25 +85,37 @@ #: The `AGENTS.md` link to `docs/AGENTS.md`, the shape of every case here #: that leads inside the repository. DOCS_LEG = Path("docs", AGENTS) +#: The spec leg's `AGENTS.md`, for the controls whose link must NOT reach it. +SPEC_LEG = rendered_leg_agents("spec") -def linked_clone(tmp_path: Path, target: Path, files: dict | None = None, - links: dict | None = None, +def linked_clone(tmp_path: Path, target: Path | str, + files: dict | None = None, links: dict | None = None, name: str = "renamed-clone") -> Path: """`/`, a repository with no `origin` whose `AGENTS.md` is a - symlink to `target`, beside `files` (`{relative path: text}`) and - `links` (`{relative path: target}`), all of it committed: a clone whose - own name and files alone say what it is.""" + symlink to `target`, beside `files` (`{relative path: text or bytes}`) + and `links` (`{relative path: target}`), all of it committed: a clone + whose own name and files alone say what it is. + + A `str` target is linked AS SPELLED, which the link is checked to hold: + a `Path` would fold `./docs/AGENTS.md`, `docs/./AGENTS.md` and + `.//docs/AGENTS.md` into `docs/AGENTS.md`, and the row would test + nothing.""" root = tmp_path / name root.mkdir() git("init", "-q", "-b", "main", ".", cwd=root) (root / "README.md").write_text("# here\n", encoding="utf-8") for rel, text in (files or {}).items(): (root / rel).parent.mkdir(parents=True, exist_ok=True) - (root / rel).write_text(text, encoding="utf-8") + if isinstance(text, bytes): + (root / rel).write_bytes(text) + else: + (root / rel).write_text(text, encoding="utf-8") for rel, to in (links or {}).items(): symlink(root / rel, to) symlink(root / AGENTS, target) + if isinstance(target, str): + assert os.readlink(root / AGENTS) == target commit_all(root, "Initial import") return root @@ -156,20 +169,32 @@ def told_nothing(said: list[str]) -> None: # --- a link to the leg's own file, in the commit ----------------------------- -@pytest.mark.parametrize("role, leg_at, links", [ - pytest.param("spec", "docs/AGENTS.md", {}, id="beside"), - pytest.param("code", ".agents/AGENTS.md", {"docs": Path(".agents")}, - id="through-a-linked-folder"), +@pytest.mark.parametrize("role, target, leg_at, links", [ + pytest.param("spec", DOCS_LEG, "docs/AGENTS.md", {}, id="beside"), + pytest.param("spec", "./docs/AGENTS.md", "docs/AGENTS.md", {}, + id="dot-slash"), + pytest.param("spec", "docs/./AGENTS.md", "docs/AGENTS.md", {}, + id="dot-in-the-middle"), + pytest.param("spec", ".//docs/AGENTS.md", "docs/AGENTS.md", {}, + id="dot-and-a-double-slash"), + pytest.param("code", DOCS_LEG, ".agents/AGENTS.md", + {"docs": Path(".agents")}, id="through-a-linked-folder"), ]) def test_a_leg_agents_md_linked_to_its_own_file_is_the_leg_in_the_commit( - tmp_path, role, leg_at, links): + tmp_path, role, target, leg_at, links): """The issue's case. The doctor reads the disk's `AGENTS.md` through its link and calls the clone the leg. On the base the commit's link was read as the path it holds, so `plan` and `check` printed the advisory over the leg. Now the link is followed inside the commit -- through a linked folder too (`docs -> .agents`), as git follows it -- and both print the - leg's NOTE, as over a leg whose `AGENTS.md` is a regular file.""" - root = linked_clone(tmp_path, DOCS_LEG, + leg's NOTE, as over a leg whose `AGENTS.md` is a regular file. + + SPELLED WITH A `.` SEGMENT (`./docs/AGENTS.md`, `docs/./AGENTS.md`, + `.//docs/AGENTS.md`), git 2.43 answers the follow `dangling`, and the + first cut of this fix advised as on the base. The link's own target is + now asked again with its `.` segments dropped, and the commit names + the leg the disk does.""" + root = linked_clone(tmp_path, target, {leg_at: rendered_leg_agents(role)}, links) env = no_workspace(tmp_path) reading = committed(root, env) @@ -243,20 +268,30 @@ def test_the_commits_linked_leg_file_is_read_when_the_disks_is_refused( # --- a link out of the commit ------------------------------------------------ -@pytest.mark.parametrize("how", ["relative", "absolute", "dangling"]) +@pytest.mark.parametrize("how", ["relative", "relative-after-a-dot", + "absolute", "dangling"]) def test_a_leg_agents_md_linked_out_of_the_commit_is_not_answered(tmp_path, how): """A link that leads out of the commit: `../locked/AGENTS.md`, the same - file by its absolute path, or `../nowhere/AGENTS.md`. No commit holds - what it reaches, so whether this is a leg is not known in the commit: - `not-answered`, with its line in `why` and nothing `refused`, and `plan` - and `check` print neither the advisory nor a leg's NOTE. On the base - each printed the advisory. The doctor reads the disk, which the commit - cannot: the leg where the link reaches its file, and the advisory where - it reaches nothing, as for any dangling link -- silence in the commit is - the one answer that is false of neither.""" + spelled `./../locked/AGENTS.md`, the same file by its absolute path, or + `../nowhere/AGENTS.md`. No commit holds what it reaches, so whether this + is a leg is not known in the commit: `not-answered`, with its line in + `why` and nothing `refused`, and `plan` and `check` print neither the + advisory nor a leg's NOTE. On the base each printed the advisory. The + doctor reads the disk, which the commit cannot: the leg where the link + reaches its file, and the advisory where it reaches nothing, as for any + dangling link -- silence in the commit is the one answer that is false + of neither. + + git 2.43 answers `./../locked/AGENTS.md` `dangling`, for its `.` + segment. Without it the target begins with `..`, past the root, which + git answers `symlink`; and git is never asked `../locked/AGENTS.md` + itself, which it reads from the working directory, and refuses as + outside the repository: its `Refusal` would name a `why` of its own.""" leg = locked_leg(tmp_path) - target = {"relative": Path("..", "locked", AGENTS), "absolute": leg, + target = {"relative": Path("..", "locked", AGENTS), + "relative-after-a-dot": "./../locked/AGENTS.md", + "absolute": leg, "dangling": Path("..", "nowhere", AGENTS)}[how] root = linked_clone(tmp_path, target) env = no_workspace(tmp_path) @@ -335,6 +370,14 @@ def run(args, *rest, **kwargs): pytest.param(Path("README.md", AGENTS), {}, id="through-a-file"), pytest.param(DOCS_LEG, {"docs/AGENTS.md": "# agents\n"}, id="another-file"), + pytest.param("./docs/AGENTS.md/.", {"docs/AGENTS.md": SPEC_LEG}, + id="dot-then-through-the-legs-file"), + pytest.param("./docs/AGENTS.md/", {"docs/AGENTS.md": SPEC_LEG}, + id="dot-then-a-slash-after-the-legs-file"), + pytest.param("./docs/AGENTS.md\nx", {"docs/AGENTS.md": SPEC_LEG}, + id="dot-and-a-line-break"), + pytest.param("./docs/x blob", {"docs/x": SPEC_LEG}, + id="dot-to-nothing-spelled-like-an-answer"), ]) def test_an_agents_md_linked_to_no_legs_file_is_advised_by_both_readers( tmp_path, target, files): @@ -343,7 +386,17 @@ def test_an_agents_md_linked_to_no_legs_file_is_advised_by_both_readers( file, or to a file that is no leg's, is no leg's file, on the disk (#430's rule) and in the commit alike, and both readers advise. They hold the follow to the disk's answers, rather than to "a link is not - known", which only a link out of the commit is.""" + known", which only a link out of the commit is. + + THE `.` CONTROLS hold the second ask of a target spelled with a `.` + segment to the disk's answer where the leg's file is near. A LAST `.` + or `/` is kept, so `./docs/AGENTS.md/.` and `./docs/AGENTS.md/` still + run through the file (ENOTDIR), and never become it. A target with a + line break is not asked again: git's batch input would take its first + line, `docs/AGENTS.md`, for the name. And `docs/x blob` is not there: + git's line for it begins with the name it was asked, `:docs/x`, + then `blob`, and only a line that begins with an object's name is a + blob to read.""" root = linked_clone(tmp_path, target, files) env = no_workspace(tmp_path) assert committed(root, env).state == shape_advisory.ADVISE From 0f8dae6ec271b57766f82c79337c09af02a6ae6e Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 8 Nov 2026 17:26:11 +0000 Subject: [PATCH 15/18] tests/test_advisory_agents_md_symlink.py: a control `[not-utf-8]`, a link to the leg's own text and one byte 0xff, advised by both readers, so the followed blob is held to "a file that is read and is not UTF-8 declares no leg", and decoding it with `errors="replace"` fails a row (#1163) r1163's SHOULD-2. Its mutant R6 (`.decode("utf-8", "replace")` on the blob `Tree.text_through` reads) passed every row, yet it named the leg in the commit where the disk advises. The control writes `docs/AGENTS.md` as bytes (`linked_clone` takes bytes since the previous commit) and asks both readers: each advises, on 2abd8176 as here. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- tests/test_advisory_agents_md_symlink.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/test_advisory_agents_md_symlink.py b/tests/test_advisory_agents_md_symlink.py index 90dbc3f7..9d25152a 100644 --- a/tests/test_advisory_agents_md_symlink.py +++ b/tests/test_advisory_agents_md_symlink.py @@ -370,6 +370,9 @@ def run(args, *rest, **kwargs): pytest.param(Path("README.md", AGENTS), {}, id="through-a-file"), pytest.param(DOCS_LEG, {"docs/AGENTS.md": "# agents\n"}, id="another-file"), + pytest.param(DOCS_LEG, + {"docs/AGENTS.md": SPEC_LEG.encode("utf-8") + b"\xff"}, + id="not-utf-8"), pytest.param("./docs/AGENTS.md/.", {"docs/AGENTS.md": SPEC_LEG}, id="dot-then-through-the-legs-file"), pytest.param("./docs/AGENTS.md/", {"docs/AGENTS.md": SPEC_LEG}, @@ -388,6 +391,12 @@ def test_an_agents_md_linked_to_no_legs_file_is_advised_by_both_readers( hold the follow to the disk's answers, rather than to "a link is not known", which only a link out of the commit is. + `[not-utf-8]` leads to the leg's own text and one byte 0xff after it. A + file that is read and is not UTF-8 declares no leg + (`_leg_by_agents_md`), and the followed blob is held to that rule as + the disk's bytes are: decoded with `errors="replace"`, the commit + would name the leg the disk does not. + THE `.` CONTROLS hold the second ask of a target spelled with a `.` segment to the disk's answer where the leg's file is near. A LAST `.` or `/` is kept, so `./docs/AGENTS.md/.` and `./docs/AGENTS.md/` still From 8e54c32de0b901d379efeb4ad569e24c0eb0fd96 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 8 Nov 2026 18:23:32 +0000 Subject: [PATCH 16/18] tests/test_advisory_agents_md_symlink.py and shape_advisory.py: a link out of the commit spelled `.//../locked/AGENTS.md` and a leg link that ends in a carriage return get a row each, and `_dot_segments_dropped` says what git 2.43 does with an LF, a CR and a NUL in a target (#1163) r1163b's re-review of #1270: one SHOULD and the first NIT. SHOULD-1: no row pinned a cleaned target that leaves the commit on the second ask, where git answers `symlink`. `[relative-after-a-dot]` is caught by the `..` check before git is asked again. The row `[relative-after-a-dot-and-a-double-slash]` (`.//../locked/AGENTS.md`, cleaned to `/../locked/AGENTS.md`) is. It fails on the base module (`a0b4ab7c`, `advise`), and it kills r1163b's K5 (the second answer's `symlink` read as nothing there) and K12 (the second answer kept only when it is a blob). NIT-1: the control `[dot-and-a-carriage-return]` (`./docs/AGENTS.md\r`, advised by both readers) kills K1, the CR check dropped: git removes a trailing CR from the name it is asked, so it would answer for `docs/AGENTS.md` where the disk has no file of that name. The docstring of `_dot_segments_dropped` now says what git 2.43 does: an LF splits the batch input into two requests, git removes a trailing CR, and cuts the name at a NUL. No code changes; `cat-file -z` is not used. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Sonnet 5.5 --- scripts/shape_advisory.py | 8 +++-- tests/test_advisory_agents_md_symlink.py | 41 ++++++++++++++++-------- 2 files changed, 34 insertions(+), 15 deletions(-) diff --git a/scripts/shape_advisory.py b/scripts/shape_advisory.py index 8edad0c8..9122f59b 100644 --- a/scripts/shape_advisory.py +++ b/scripts/shape_advisory.py @@ -426,8 +426,12 @@ def _dot_segments_dropped(target: bytes) -> bytes | None: file, ENOTDIR on the disk and `notdir` in git, and must not become the file. None, too, for an absolute target, which git answers `symlink` before it reads a segment, and for one with a line break, a carriage - return or a NUL in it, which git's batch input would cut into another - name.""" + return or a NUL in it, which git 2.43's batch input does not take as the + one name asked: an LF splits it into two requests, git removes a + trailing CR (it keeps an inner one, which this check leaves out too), + and git cuts the name at a NUL. Such a target is left as git first + answered it, so a leg's file named so is advised while the disk reads + the leg, a known limit of #1163.""" if target.startswith(b"/") or any(ch in target for ch in (b"\n", b"\r", b"\0")): return None diff --git a/tests/test_advisory_agents_md_symlink.py b/tests/test_advisory_agents_md_symlink.py index 9d25152a..62bf0a22 100644 --- a/tests/test_advisory_agents_md_symlink.py +++ b/tests/test_advisory_agents_md_symlink.py @@ -268,29 +268,39 @@ def test_the_commits_linked_leg_file_is_read_when_the_disks_is_refused( # --- a link out of the commit ------------------------------------------------ -@pytest.mark.parametrize("how", ["relative", "relative-after-a-dot", - "absolute", "dangling"]) +@pytest.mark.parametrize("how", [ + "relative", "relative-after-a-dot", + "relative-after-a-dot-and-a-double-slash", "absolute", "dangling"]) def test_a_leg_agents_md_linked_out_of_the_commit_is_not_answered(tmp_path, how): """A link that leads out of the commit: `../locked/AGENTS.md`, the same - spelled `./../locked/AGENTS.md`, the same file by its absolute path, or - `../nowhere/AGENTS.md`. No commit holds what it reaches, so whether this - is a leg is not known in the commit: `not-answered`, with its line in - `why` and nothing `refused`, and `plan` and `check` print neither the - advisory nor a leg's NOTE. On the base each printed the advisory. The - doctor reads the disk, which the commit cannot: the leg where the link - reaches its file, and the advisory where it reaches nothing, as for any - dangling link -- silence in the commit is the one answer that is false - of neither. + spelled `./../locked/AGENTS.md` or `.//../locked/AGENTS.md`, the same + file by its absolute path, or `../nowhere/AGENTS.md`. No commit holds + what it reaches, so whether this is a leg is not known in the commit: + `not-answered`, with its line in `why` and nothing `refused`, and `plan` + and `check` print neither the advisory nor a leg's NOTE. On the base + each printed the advisory. The doctor reads the disk, which the commit + cannot: the leg where the link reaches its file, and the advisory where + it reaches nothing, as for any dangling link -- silence in the commit + is the one answer that is false of neither. git 2.43 answers `./../locked/AGENTS.md` `dangling`, for its `.` segment. Without it the target begins with `..`, past the root, which git answers `symlink`; and git is never asked `../locked/AGENTS.md` itself, which it reads from the working directory, and refuses as - outside the repository: its `Refusal` would name a `why` of its own.""" + outside the repository: its `Refusal` would name a `why` of its own. + + `.//../locked/AGENTS.md` is the second kind of case. With its `.` + dropped it is `/../locked/AGENTS.md`, which does not begin with `..`, + so git IS asked it, and answers `symlink`: that second answer is what + leaves the commit. Taken as a file's absence, or kept only when it is a + blob, it leaves the first answer, `dangling`, and the clone is advised + over a leg the doctor reads.""" leg = locked_leg(tmp_path) target = {"relative": Path("..", "locked", AGENTS), "relative-after-a-dot": "./../locked/AGENTS.md", + "relative-after-a-dot-and-a-double-slash": + ".//../locked/AGENTS.md", "absolute": leg, "dangling": Path("..", "nowhere", AGENTS)}[how] root = linked_clone(tmp_path, target) @@ -379,6 +389,8 @@ def run(args, *rest, **kwargs): id="dot-then-a-slash-after-the-legs-file"), pytest.param("./docs/AGENTS.md\nx", {"docs/AGENTS.md": SPEC_LEG}, id="dot-and-a-line-break"), + pytest.param("./docs/AGENTS.md\r", {"docs/AGENTS.md": SPEC_LEG}, + id="dot-and-a-carriage-return"), pytest.param("./docs/x blob", {"docs/x": SPEC_LEG}, id="dot-to-nothing-spelled-like-an-answer"), ]) @@ -402,7 +414,10 @@ def test_an_agents_md_linked_to_no_legs_file_is_advised_by_both_readers( or `/` is kept, so `./docs/AGENTS.md/.` and `./docs/AGENTS.md/` still run through the file (ENOTDIR), and never become it. A target with a line break is not asked again: git's batch input would take its first - line, `docs/AGENTS.md`, for the name. And `docs/x blob` is not there: + line, `docs/AGENTS.md`, for the name, and the rest for another request. + Nor is one that ends in a carriage return: git removes a trailing CR + from the name, so it would answer for `docs/AGENTS.md`, where the disk + has no file called `docs/AGENTS.md`. And `docs/x blob` is not there: git's line for it begins with the name it was asked, `:docs/x`, then `blob`, and only a line that begins with an object's name is a blob to read.""" From 12616551452d17a2f5ff5b601739cc43c55a161a Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 10 Nov 2026 01:37:09 +0000 Subject: [PATCH 17/18] shape_advisory.py: a `project.yaml` or `family.yaml` committed as a symlink is read through it inside the commit, and a linked `single-repository.yaml` is named as a link it does not follow, not `could not be read (YamlError)` (#1272) `adopt-project.py plan` and `check` read the Triad advisory from the commit the plan is written against, where a symlink is a blob holding the path it leads to. `_read_mapping` and `read_record` read that path as YAML, so an assembly root or a family holder whose manifest is a link was advised, and a linked record got the false NOTE `it could not be read (YamlError)`. The doctor reads the disk, through the link. `_read_mapping` now follows a manifest's link inside the commit, through #1270's `Tree.is_link` and `Tree.text_through`. A link to nothing that is a file in the commit, or one out of it, reads as no manifest. `read_record` names a linked record as a symlink it does not follow, and names no exception. Following it would give a linked record the record's question and its default `drop` in `plan` (#528), which waits on Brett Heap's ruling. So `ask_about_a_record_only` and the doctor's leg audit read it as before, but for a link whose path parsed as a record, which `plan` no longer takes for one. tests/test_advisory_record_symlink.py: 35 ids, 18 of them failing on #1270's head `8e54c32de`. Stacked on #1270. Part of #1272 Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 --- scripts/shape_advisory.py | 57 +++- tests/test_advisory_record_symlink.py | 460 ++++++++++++++++++++++++++ 2 files changed, 513 insertions(+), 4 deletions(-) create mode 100644 tests/test_advisory_record_symlink.py diff --git a/scripts/shape_advisory.py b/scripts/shape_advisory.py index 9122f59b..129a4c86 100644 --- a/scripts/shape_advisory.py +++ b/scripts/shape_advisory.py @@ -473,8 +473,10 @@ class Tree: `text()` reads a symlink in a commit as the blob it is, the PATH it holds, as before. `text_through()` reads it as the disk's is read, - through it (#1163), where `is_link()` says it is one; only a leg's - `AGENTS.md` is read so (`_leg_by_agents_md`). + through it (#1163), where `is_link()` says it is one. A leg's + `AGENTS.md` is read so (`_leg_by_agents_md`), and so are `project.yaml` + and `family.yaml` (`_read_mapping`, #1272). `single-repository.yaml` is + not: a link there is named as one and never followed (`read_record`). """ def __init__(self, root: Path, commit: str | None = None): @@ -538,7 +540,7 @@ def text_through(self, rel: str) -> str | None: commit, followed as git follows it there (`git cat-file --batch-check --follow-symlinks`): each link from the directory it is in, through a linked folder too, up to 40 links. `rel` is a name at - the root, as `AGENTS.md` is. + the root, as `AGENTS.md` and the two manifests are. GIT 2.43 FOLLOWS NO `.` SEGMENT (#1163), so it is not quite a checkout's `open()`: it answers `dangling` for a link spelled @@ -583,9 +585,26 @@ def _read_mapping(tree: Tree, rel: str): three this file expects: a manifest nested past the recursion limit is as unreadable as one that does not parse, and the ratified rule is that the advisory never changes an exit status. + + A SYMLINK IN THE COMMIT IS READ THROUGH, AS THE DISK'S IS (#1272). In a + commit a link is a blob that holds the PATH it leads to, and that path + read as YAML was no manifest: `adopt-project.py plan` and `check` advised + an assembly root whose `project.yaml` is a link, or a family holder whose + `family.yaml` is one, while the doctor, which reads the disk through the + link, named the root or the holder. So a link in the commit + (`Tree.is_link`) is followed inside it (`Tree.text_through`), as a leg's + `AGENTS.md` is (#1163). NOTHING THAT IS A FILE INSIDE THE COMMIT IS A + MANIFEST THIS READING SEES, by the rule above: a link that dangles, + loops, runs through a file or leads to a directory gives None, and one + that leads OUT of the commit raises `OutOfTheCommit`, also None here. + The plan is written against the commit, and a manifest the commit does + not hold declares nothing in it; the link's own path is never read as + the manifest. A regular file, and every reading of the disk, is read as + before. """ try: - text = tree.text(rel) + text = (tree.text_through(rel) if tree.is_link(rel) + else tree.text(rel)) data = parse_yaml(text.lstrip(BOM)) if text is not None else None except Exception: # noqa: BLE001 - see the docstring return None @@ -838,6 +857,18 @@ def _workspace(root: Path, names: list[str], policy: NamingPolicy | None, RECORD_SILENCES = "silences" RECORD_SILENCES_NOTHING = "silences-nothing" +#: Why a `single-repository.yaml` the commit holds as a SYMLINK silences +#: nothing, whatever it leads to (#1272). Its blob is the PATH the link +#: holds, not the record's text: read as YAML, it said `it could not be read +#: (YamlError)` of a record that can be read, and a path spelled as a +#: record's `kind:` line silenced the advisory. A later ruling to follow a +#: link inside the commit narrows "a symlink in the commit" here to the +#: links it still does not follow. +RECORD_LINK_NOT_FOLLOWED = ("it is a symlink in the commit, which is not " + "followed for this record, so whether it " + "records that this project stays a single " + "repository is not known here") + def record_schema(shape: Path = SHAPE_ROOT) -> dict: """The record's schema, as data. A refusal when it cannot be read: it @@ -917,6 +948,22 @@ def read_record(root, shape: Path = SHAPE_ROOT, `root` is a path, or a `Tree` -- which is how `adopt-project.py` asks this of the commit it plans rather than of the working tree. + + A SYMLINK IN THE COMMIT IS NOT FOLLOWED, AND IS NAMED AS ONE (#1272). Its + blob is the PATH it leads to, and read as YAML that said `it could not + be read (YamlError)` of a record that can be read, where the disk, read + through the link, says `recorded-single`. Following the link inside the + commit, as `_read_mapping` follows a manifest's, would also change + `adopt-project.py`'s record question (`ask_about_a_record_only`, #528): a + linked record would take the record's rule and its default `drop`, a + ruling not yet made. So every link in the commit (`Tree.is_link`) + silences nothing with `RECORD_LINK_NOT_FOLLOWED`, a sentence that names + no exception, and `ask_about_a_record_only` asks the general question of + it, as it did of every link whose path did not parse as a record. + `is_link` is never True of the disk, which the doctor reads, nor of a + leg's INDEX (`Tree(mount, "")`, the doctor's leg audit), where `git + ls-tree` has no commit to list and fails, which `is_link` takes for no + link: both read as before. """ tree = root if isinstance(root, Tree) else Tree(Path(root), commit) try: @@ -925,6 +972,8 @@ def read_record(root, shape: Path = SHAPE_ROOT, return RECORD_ABSENT, [], [] if kind != "file": return RECORD_SILENCES_NOTHING, ["it is not a file"], [] + if tree.is_link(RECORD_FILE): + return RECORD_SILENCES_NOTHING, [RECORD_LINK_NOT_FOLLOWED], [] data = parse_yaml(tree.text(RECORD_FILE).lstrip(BOM)) except Exception as exc: # noqa: BLE001 - see the docstring return RECORD_SILENCES_NOTHING, [f"it could not be read " diff --git a/tests/test_advisory_record_symlink.py b/tests/test_advisory_record_symlink.py new file mode 100644 index 00000000..963f0b13 --- /dev/null +++ b/tests/test_advisory_record_symlink.py @@ -0,0 +1,460 @@ +# SPDX-License-Identifier: Apache-2.0 +"""`project.yaml`, `family.yaml` and `single-repository.yaml` committed as +symlinks, as the Triad advisory reads them in a commit: #1272, in +`scripts/shape_advisory.py`, beside #1163's `AGENTS.md` +(`tests/test_advisory_agents_md_symlink.py`). + +THE DEFECT. `adopt-project.py plan` and `check` ask the advisory of the +commit the plan is written against, and in a commit a symlink is a blob that +holds the PATH it leads to. Three readers took that path as YAML. The doctor +reads the disk, where the operating system follows the link. Measured on +#1270's head `8e54c32de`, Python 3.12.3 and git 2.43.0: + + * `project.yaml -> docs/project.yaml`, a Triad manifest: the commit read + `advise`, and `plan` and `check` printed the `triad` lines, where the + disk read `triad-root`. Beside a leg's `AGENTS.md` the commit read + `leg-clone`, and both printed the leg's NOTE. `family.yaml -> + docs/family.yaml`, a holder's manifest: `advise`, where the disk read + `family-holder`; + * `single-repository.yaml -> docs/record.yaml`, a record: `advise` with + the NOTE `it could not be read (YamlError)`, false of a file that can be + read, where the disk read `recorded-single`. Every other link to the + record got the same NOTE, and one whose path is spelled as a record's + `kind:` line silenced the advisory. + +THE CHANGE, in two parts. + + * `_read_mapping` FOLLOWS a manifest's link inside the commit, through + #1270's `Tree.is_link` and `Tree.text_through`, so `project.yaml` and + `family.yaml` read as they do on the disk. Nothing that is a file inside + the commit is a manifest it sees: a link that dangles, loops, runs + through a file, leads to a directory, or leads OUT of the commit reads + as no manifest, as `_read_mapping` already reads a manifest it cannot + read. + * `read_record` DOES NOT FOLLOW the record's link, and says so. Every link + in the commit silences nothing, with a sentence that calls it a symlink + and names no exception. Following it would give a linked record the + record's question in `plan`, with its default `drop` (#528), and that + ruling is Brett Heap's to make. So `plan` still asks the general question + of it, `check` still exits 1, and the doctor's audit of a leg's index + reads it as before. + +Every case here makes a symlink, and skips where none can be made +(`symlink`, #1159's). +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +import pytest + +from conftest import ADOPT, REPO, findings, git, load_without_bytecode +#: The two questions a `single-repository.yaml` can be asked, the plan's +#: entry for a path, and the doctor's audit of a code leg's index +#: (`audit_leg`, #691), from #528's file. +from test_adopt_single_repository_record import ( + GENERAL, RULE, entry_of, leg_audited, +) +#: The reading of HEAD's commit as `adopt-project.py` asks it, `plan` and +#: `check` as a person meets them, their silence, and the advisory's first +#: words, from #1270's file. +from test_advisory_agents_md_symlink import ( + SINGLE, adopt_says, committed, told_nothing, +) +#: The environment with no `workspace.yaml`, and the link maker that skips +#: where none can be made, from #1159's file. +from test_doctor_unsearchable_links import no_workspace, symlink +#: A commit of the whole working tree, a one-commit repository, the +#: doctor's `--json` rows, the schema's own example record and a leg's +#: `AGENTS.md`, from the file that owns them. +from test_shape_advisory import ( + commit_all, doctor_json, record_text, rendered_leg_agents, repository, +) + +sys.path.insert(0, str(REPO / "scripts")) +import shape_advisory # noqa: E402 +from repo_shape import Refusal # noqa: E402 + +PROJECT = shape_advisory.PROJECT_MANIFEST +FAMILY = shape_advisory.FAMILY_MANIFEST +RECORD = shape_advisory.RECORD_FILE +AGENTS = shape_advisory.AGENTS_MD +ADVISE = shape_advisory.ADVISE +TRIAD_ROOT = shape_advisory.TRIAD_ROOT +FAMILY_HOLDER = shape_advisory.FAMILY_HOLDER +RECORDED_SINGLE = shape_advisory.RECORDED_SINGLE + +#: A Triad assembly root's manifest, as small as the detector allows: the +#: schema, and an assembly, a spec and a code leg. +TRIAD = ("schema_version: 1\nkind: project-manifest\n" + "schema: project-repo-schema\nlegs:\n" + " - role: assembly\n path: \".\"\n" + " - role: spec\n path: spec\n" + " - role: code\n path: code\n") +#: A family holder's manifest. +HOLDER = "schema_version: 1\nkind: family-manifest\nid: inkrouter\nmembers: []\n" +#: A file of another kind, linked as a record. +OTHER_KIND = "schema_version: 1\nkind: release-config\n" +#: Why a linked record silences nothing, SPELLED OUT, as `read()` gives it: +#: a symlink, not followed, and no exception's name. +LINK_REASON = ("it is a symlink in the commit, which is not followed for " + "this record, so whether it records that this project stays " + "a single repository is not known here") +#: The same, as `plan` and `check` print it. +LINK_NOTE = (f"NOTE {RECORD} does not silence this: {LINK_REASON} (its schema " + "is openRepoShape's contracts/single-repository-record.yaml); " + "nothing fails on its account.") +#: `check`'s findings: an entry nobody answered, and a link the plan keeps +#: in the root. +UNRESOLVED = "plan-unresolved" +ROOT_LINK = "plan-root-symlink" + + +@pytest.fixture(scope="module") +def adopter(): + """`adopt-project.py`, as the doctor loads it.""" + return load_without_bytecode(ADOPT, "adopt_project_record_symlink") + + +@pytest.fixture(scope="module") +def doctor(): + return load_without_bytecode(REPO / "shape-doctor.py", + "shape_doctor_record_symlink") + + +def linked_here(tmp_path: Path, name: str, target: Path | str, + files: dict | None = None, + links: dict | None = None) -> Path: + """`/Atlas`, a repository with no `origin` whose `name` is a symlink + to `target`, beside `files` (`{relative path: text or bytes}`) and + `links` (`{relative path: target}`), all of it committed. + + #1270's `linked_clone` links `AGENTS.md`, which the reading asks before + the record, so its answer would come first; this links `name` and + nothing else. A `str` target is linked as spelled, which the link is + checked to hold, as there.""" + root = tmp_path / "Atlas" + root.mkdir() + git("init", "-q", "-b", "main", ".", cwd=root) + (root / "README.md").write_text("# here\n", encoding="utf-8") + for rel, text in (files or {}).items(): + (root / rel).parent.mkdir(parents=True, exist_ok=True) + if isinstance(text, bytes): + (root / rel).write_bytes(text) + else: + (root / rel).write_text(text, encoding="utf-8") + for rel, to in {**(links or {}), name: target}.items(): + symlink(root / rel, to) + if isinstance(target, str): + assert os.readlink(root / name) == target + commit_all(root, "Initial import") + return root + + +# --- the manifests: followed inside the commit ------------------------------- + +@pytest.mark.parametrize("name, target, files, links, state, codes", [ + pytest.param(PROJECT, Path("docs", PROJECT), {"docs/project.yaml": TRIAD}, + {}, TRIAD_ROOT, [UNRESOLVED, ROOT_LINK], id="project-yaml"), + pytest.param(PROJECT, Path("docs", PROJECT), + {"docs/project.yaml": TRIAD, + AGENTS: rendered_leg_agents("spec")}, + {}, TRIAD_ROOT, [UNRESOLVED, ROOT_LINK], + id="project-yaml-beside-a-leg-agents-md"), + pytest.param(FAMILY, Path("docs", FAMILY), {"docs/family.yaml": HOLDER}, + {}, FAMILY_HOLDER, [UNRESOLVED], id="family-yaml"), + pytest.param(PROJECT, "./docs/project.yaml", {"docs/project.yaml": TRIAD}, + {}, TRIAD_ROOT, [UNRESOLVED, ROOT_LINK], + id="project-yaml-dot-slash"), + pytest.param(FAMILY, Path("conf", FAMILY), {"docs/family.yaml": HOLDER}, + {"conf": Path("docs")}, FAMILY_HOLDER, + [UNRESOLVED, UNRESOLVED], + id="family-yaml-through-a-linked-folder"), +]) +def test_a_manifest_linked_inside_the_commit_is_read_through_it( + tmp_path, name, target, files, links, state, codes): + """#1272's rows 2a, 2b and 3. On #1270's head the commit's link was read + as the path it holds, which is no manifest, so `plan` and `check` + printed the `triad` lines over an assembly root and a family holder, and + the leg's NOTE where a regular leg `AGENTS.md` sits beside the root's + manifest (`project.yaml` is read first on the disk, and was not in the + commit). Now the link is followed inside the commit, as the disk's is, + spelled with a `.` segment or through a linked folder too, and neither + prints a Triad line. `check` exits 1 here as over a regular manifest of + the repository's own, an open question; only the advisory differs.""" + root = linked_here(tmp_path, name, target, files, links) + env = no_workspace(tmp_path) + reading = committed(root, env) + assert reading.state == state, reading.as_dict() + assert shape_advisory.read(root, env=env).state == state + plan = tmp_path / "plan.yaml" + told_nothing(adopt_says(root, plan, env)) + assert [code for code, _ in findings(plan)] == codes + + +@pytest.mark.parametrize("how", [ + "dangling", "loop", "a-directory", "through-a-file", "not-utf-8", + "spelled-as-a-manifest", "dangling-in-the-commit-there-on-the-disk", + "out-of-the-commit", "out-of-the-commit-by-its-absolute-path"]) +def test_a_manifest_link_to_no_file_in_the_commit_is_no_manifest(tmp_path, + how): + """THE CHOICE: nothing that is a file inside the commit is a manifest + the commit reading sees, as `_read_mapping` reads every manifest it + cannot read. The plan is written against the commit, and a manifest the + commit does not hold declares nothing in it, so the clone is advised. + + The disk agrees where the link reaches nothing: one that dangles, loops, + runs through a file or leads to a directory, and one to a file that is + not UTF-8: a Triad manifest with a byte 0xff in a value of its own, + which the disk reads as no manifest (`_read_mapping`), and which the + followed blob decoded with `errors="replace"` would make a Triad root + (r1163's R6). A link spelled `kind: family-manifest` dangles too: on + #1270's head its PATH parsed as a holder's manifest, and the commit + said `family-holder` where the disk advised. + + The disk does not agree where the link reaches a manifest the commit + does not hold: one committed dangling and written on the disk after, + and one that leads out of the commit, relative or absolute. The doctor + reads `triad-root` there, and `plan` and `check` print the advisory, + as on #1270's head: the commit reading never reads the disk.""" + beside = tmp_path / "elsewhere" / PROJECT + name, target, files = { + "dangling": (PROJECT, Path("docs", PROJECT), + {"docs/notes.md": "# notes\n"}), + "loop": (PROJECT, Path(PROJECT), {}), + "a-directory": (PROJECT, Path("docs"), {"docs/project.yaml": TRIAD}), + "through-a-file": (PROJECT, Path("README.md", PROJECT), {}), + "not-utf-8": (PROJECT, Path("docs", PROJECT), + {"docs/project.yaml": (TRIAD.encode("utf-8") + + b"name: Atlas\xff\n")}), + "spelled-as-a-manifest": (FAMILY, "kind: family-manifest", {}), + "dangling-in-the-commit-there-on-the-disk": ( + PROJECT, Path("docs", PROJECT), {"docs/notes.md": "# notes\n"}), + "out-of-the-commit": (PROJECT, Path("..", "elsewhere", PROJECT), {}), + "out-of-the-commit-by-its-absolute-path": (PROJECT, beside, {}), + }[how] + if how.startswith("out-of-the-commit"): + beside.parent.mkdir() + beside.write_text(TRIAD, encoding="utf-8") + root = linked_here(tmp_path, name, target, files) + if how == "dangling-in-the-commit-there-on-the-disk": + (root / "docs" / PROJECT).write_text(TRIAD, encoding="utf-8") + env = no_workspace(tmp_path) + reading = committed(root, env) + assert reading.state == ADVISE, reading.as_dict() + assert reading.record_problems == () + disk = shape_advisory.read(root, env=env).state + if how.startswith(("out-of-the-commit", "dangling-in-the-commit")): + assert disk == TRIAD_ROOT + for stdout in adopt_says(root, tmp_path / "plan.yaml", env): + assert stdout.count(SINGLE) == 1, stdout + else: + assert disk == ADVISE + + +# --- the record: named as a link, and never followed --------------------------- + +def test_a_record_linked_to_a_record_in_the_commit_is_named_as_a_link( + tmp_path): + """#1272's row 1. On #1270's head the commit's link was read as the path + it holds, which does not parse, and `plan` and `check` printed the + `triad` lines with `NOTE single-repository.yaml does not silence this: + it could not be read (YamlError)`, false of a file that can be read. + + Now the NOTE says what is true: it is a symlink in the commit, which is + not followed for the record. The rest is as on #1270's head, ON PURPOSE: + the link is not followed until Brett Heap rules whether a linked record + takes the record's question and its default `drop` (#528), so the entry + is still the general question with `leg: null`, `check` still exits 1 on + it, and the advisory is still given where the doctor, which reads the + disk through the link, says nothing (`recorded-single`).""" + root = linked_here(tmp_path, RECORD, Path("docs", "record.yaml"), + {"docs/record.yaml": record_text()}) + env = no_workspace(tmp_path) + reading = committed(root, env) + assert reading.state == ADVISE, reading.as_dict() + assert reading.record_problems == (LINK_REASON,) + assert shape_advisory.read(root, env=env).state == RECORDED_SINGLE + plan = tmp_path / "plan.yaml" + for stdout in adopt_says(root, plan, env): + assert stdout.count(SINGLE) == 1, stdout + assert stdout.splitlines().count(LINK_NOTE) == 1, stdout + assert "could not be read" not in stdout, stdout + entry = entry_of(plan, RECORD) + assert (entry["leg"], entry["rule"]) == (None, GENERAL), entry + [(code, _detail)] = findings(plan) + assert code == UNRESOLVED + exit_code, rows, _payload = doctor_json(root, env) + assert exit_code == 2 + assert "triad-advisory" not in rows, rows["triad-advisory"] + + +@pytest.mark.parametrize("how, disk", [ + pytest.param(how, disk, id=how) for how, disk in ( + ("dot-slash", RECORDED_SINGLE), + ("through-a-linked-folder", RECORDED_SINGLE), + ("another-kind", ADVISE), + ("dangling", ADVISE), + ("loop", ADVISE), + ("a-directory", ADVISE), + ("through-a-file", ADVISE), + ("not-utf-8", ADVISE), + ("out-of-the-commit", RECORDED_SINGLE), + ("out-of-the-commit-by-its-absolute-path", RECORDED_SINGLE))]) +def test_every_link_to_the_record_is_named_as_one_and_never_followed( + tmp_path, how, disk): + """#1272's Verify list, for a record that is not followed: whatever the + link leads to -- a record (spelled with a `.` segment, or through a + linked folder), a file of another kind, nothing (it dangles, loops, + runs through a file or leads to a directory), a file that is not UTF-8, + or a record out of the commit, relative or absolute -- the commit says + it is a symlink, which is not followed, and names no exception. On + #1270's head each said `it could not be read (YamlError)`. The disk + reads through the link, as the doctor does, and is unchanged.""" + record = record_text() + beside = tmp_path / "elsewhere" / "record.yaml" + target, files, links = { + "dot-slash": ("./docs/record.yaml", {"docs/record.yaml": record}, {}), + "through-a-linked-folder": (Path("conf", "record.yaml"), + {"docs/record.yaml": record}, + {"conf": Path("docs")}), + "another-kind": (Path("docs", "other.yaml"), + {"docs/other.yaml": OTHER_KIND}, {}), + "dangling": (Path("docs", "record.yaml"), + {"docs/notes.md": "# notes\n"}, {}), + "loop": (Path(RECORD), {}, {}), + "a-directory": (Path("docs"), {"docs/notes.md": "# notes\n"}, {}), + "through-a-file": (Path("README.md", RECORD), {}, {}), + "not-utf-8": (Path("docs", "record.yaml"), + {"docs/record.yaml": record.encode("utf-8") + b"\xff"}, + {}), + "out-of-the-commit": (Path("..", "elsewhere", "record.yaml"), {}, {}), + "out-of-the-commit-by-its-absolute-path": (beside, {}, {}), + }[how] + if how.startswith("out-of-the-commit"): + beside.parent.mkdir() + beside.write_text(record, encoding="utf-8") + root = linked_here(tmp_path, RECORD, target, files, links) + env = no_workspace(tmp_path) + reading = committed(root, env) + assert reading.state == ADVISE, reading.as_dict() + assert reading.record_problems == (LINK_REASON,) + assert shape_advisory.read(root, env=env).state == disk + + +def test_a_record_link_whose_path_is_spelled_as_a_record_is_no_record( + tmp_path): + """THE ONE CASE WHERE `plan`'S RECORD QUESTION MOVES. A link whose PATH + is `kind: single-repository-record` dangles, and the disk advises. On + #1270's head its path parsed as a record: the commit said + `recorded-single`, `plan` printed no advisory and wrote `leg: drop` + under the record's rule, and `check` exited 0. A path is not the + record, so it is named as a link like every other: the advisory with + the link's NOTE, the general question, and `check` exits 1.""" + root = linked_here(tmp_path, RECORD, "kind: single-repository-record") + env = no_workspace(tmp_path) + reading = committed(root, env) + assert reading.state == ADVISE, reading.as_dict() + assert reading.record_problems == (LINK_REASON,) + assert shape_advisory.read(root, env=env).state == ADVISE + plan = tmp_path / "plan.yaml" + for stdout in adopt_says(root, plan, env): + assert stdout.splitlines().count(LINK_NOTE) == 1, stdout + entry = entry_of(plan, RECORD) + assert (entry["leg"], entry["rule"]) == (None, GENERAL), entry + [(code, _detail)] = findings(plan) + assert code == UNRESOLVED + + +@pytest.mark.parametrize("target, rule", [ + pytest.param(Path("docs", "record.yaml"), GENERAL, + id="to-a-record-in-the-leg"), + pytest.param("kind: single-repository-record", RULE, + id="spelled-as-a-record"), +]) +def test_the_doctors_audit_of_a_legs_index_reads_a_record_link_as_before( + adopter, doctor, tmp_path, target, rule): + """CONTROLS, true on #1270's head as here: the doctor's placement row + asks `ask_about_a_record_only` of a leg's INDEX (`Source(mount, None, + "", "")`, #691), and reads a link there as its blob, never followed. + `git ls-tree` has no commit to list there, so `Tree.is_link` says no + link and the record is read as before: a link to a record in the leg is + no record, the general question, as `plan` asks it of a source's link. + + `[spelled-as-a-record]` is the case `plan` now reads otherwise: the + leg's index still reads the PATH as a record, the record's question, + where `plan` names the link and asks the general one. Left as it is, as + the doctor documents it, until the ruling on following a linked record + settles both readers.""" + def link(leg) -> None: + (leg / "docs").mkdir() + (leg / "docs" / "record.yaml").write_text(record_text(), + encoding="utf-8") + (leg / RECORD).unlink() + symlink(leg / RECORD, target) + git("add", "-A", "--", ".", cwd=leg) + + _, review = leg_audited(adopter, doctor, tmp_path, record_text(), link) + [row] = [row for row in review if row["path"] == f"code/{RECORD}"] + assert (row["rule"], row["classified_as"]) == (rule, None), row + + +# --- the controls: the three files as regular files ---------------------------- + +@pytest.mark.parametrize("files, state, leg, rule, codes", [ + pytest.param({PROJECT: TRIAD}, TRIAD_ROOT, None, + "ambiguous-project-yaml", [UNRESOLVED], id="project-yaml"), + pytest.param({PROJECT: TRIAD, AGENTS: rendered_leg_agents("spec")}, + TRIAD_ROOT, None, "ambiguous-project-yaml", [UNRESOLVED], + id="project-yaml-beside-a-leg-agents-md"), + pytest.param({FAMILY: HOLDER}, FAMILY_HOLDER, None, GENERAL, + [UNRESOLVED], id="family-yaml"), + pytest.param({RECORD: record_text()}, RECORDED_SINGLE, "drop", RULE, [], + id="single-repository-yaml"), +]) +def test_the_three_files_committed_as_regular_files_read_as_before( + tmp_path, files, state, leg, rule, codes): + """CONTROLS, true on #1270's head as here: #1272's rows 1c, 2a-c, 2b-c + and 3c. Each file regular, the commit and the disk agree, neither `plan` + nor `check` prints a Triad line, and the entry and `check`'s findings + are what they were: a record is the record's question with its default + `drop`, and `check` passes it.""" + root = repository(tmp_path / "Atlas", {"README.md": "# here\n", **files}) + env = no_workspace(tmp_path) + reading = committed(root, env) + assert reading.state == state, reading.as_dict() + assert shape_advisory.read(root, env=env).state == state + plan = tmp_path / "plan.yaml" + told_nothing(adopt_says(root, plan, env)) + name, = (name for name in files if name != AGENTS) + entry = entry_of(plan, name) + assert (entry["leg"], entry["rule"]) == (leg, rule), entry + assert [code for code, _ in findings(plan)] == codes + + +@pytest.mark.parametrize("files, state", [ + pytest.param({PROJECT: TRIAD}, TRIAD_ROOT, id="project-yaml"), + pytest.param({FAMILY: HOLDER}, FAMILY_HOLDER, id="family-yaml"), + pytest.param({RECORD: record_text()}, RECORDED_SINGLE, + id="single-repository-yaml"), +]) +def test_a_git_that_cannot_list_the_commit_reads_a_regular_file_as_before( + tmp_path, monkeypatch, files, state): + """CONTROLS, true on #1270's head as here: a git whose `ls-tree` fails + leaves `Tree.is_link` saying no link, so each regular file is read as + it was, and a record is not named as a link it is not.""" + real = shape_advisory.git_out + + def git_out(args, *rest, **kwargs): + if args[:1] == ["ls-tree"]: + raise Refusal("git-failed", "`git ls-tree` exited 128") + return real(args, *rest, **kwargs) + + root = repository(tmp_path / "Atlas", files) + env = no_workspace(tmp_path) + monkeypatch.setattr(shape_advisory, "git_out", git_out) + reading = committed(root, env) + assert reading.state == state, reading.as_dict() From 0c3999d5ae51c24e3cc185c4423ebd75bf269123 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 10 Nov 2026 03:58:04 +0000 Subject: [PATCH 18/18] tests/test_advisory_record_symlink.py: the docstring is true of the controls, and new rows pin a leg's index reading a linked record as before with the link committed too, and a regular manifest under a git that cannot follow a link (#1272) rv1295's review of #1295 found no MUST or SHOULD and four NITs. This commit takes the three that touch the file: NIT 1: the module docstring said every case makes a symlink. The controls of regular files and of a git that cannot list the commit or follow a link make none, and run everywhere. NIT 3 (mutant R5, `Tree.is_link` asking HEAD where the Tree reads the index): the doctor's audit of a leg's index reads a linked record as before, which rests on git refusing `ls-tree ""`. The row had the link only staged, with a regular record in HEAD. Two ids commit the link in the leg too, and `[spelled-as-a-record-committed-too]` fails under R5. NIT 4 (mutant R3, `_read_mapping` following every manifest, link or not): a stand-in git whose `cat-file --follow-symlinks` exits 128, as #1270's `test_a_git_that_cannot_follow_the_link_is_silence_not_advice` has, for a regular `project.yaml` and `family.yaml`. They are read as the blob they are, so the commit still says `triad-root` and `family-holder`. Two ids, both failing under R3. Test only: `scripts/shape_advisory.py` is unchanged. The four new ids are controls: they pass on #1270's head as here. Stacked on #1270. Part of #1272 Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Sonnet 5.5 --- tests/test_advisory_record_symlink.py | 60 +++++++++++++++++++++++---- 1 file changed, 52 insertions(+), 8 deletions(-) diff --git a/tests/test_advisory_record_symlink.py b/tests/test_advisory_record_symlink.py index 963f0b13..49630a40 100644 --- a/tests/test_advisory_record_symlink.py +++ b/tests/test_advisory_record_symlink.py @@ -39,13 +39,15 @@ of it, `check` still exits 1, and the doctor's audit of a leg's index reads it as before. -Every case here makes a symlink, and skips where none can be made -(`symlink`, #1159's). +Every case that needs a symlink makes one, and skips where none can be made +(`symlink`, #1159's). The controls of regular files, and of a git that +cannot list the commit or follow a link, need none and run everywhere. """ from __future__ import annotations import os +import subprocess import sys from pathlib import Path @@ -369,14 +371,18 @@ def test_a_record_link_whose_path_is_spelled_as_a_record_is_no_record( assert code == UNRESOLVED -@pytest.mark.parametrize("target, rule", [ - pytest.param(Path("docs", "record.yaml"), GENERAL, +@pytest.mark.parametrize("target, rule, in_head", [ + pytest.param(Path("docs", "record.yaml"), GENERAL, False, id="to-a-record-in-the-leg"), - pytest.param("kind: single-repository-record", RULE, + pytest.param("kind: single-repository-record", RULE, False, id="spelled-as-a-record"), + pytest.param(Path("docs", "record.yaml"), GENERAL, True, + id="to-a-record-in-the-leg-committed-too"), + pytest.param("kind: single-repository-record", RULE, True, + id="spelled-as-a-record-committed-too"), ]) def test_the_doctors_audit_of_a_legs_index_reads_a_record_link_as_before( - adopter, doctor, tmp_path, target, rule): + adopter, doctor, tmp_path, target, rule, in_head): """CONTROLS, true on #1270's head as here: the doctor's placement row asks `ask_about_a_record_only` of a leg's INDEX (`Source(mount, None, "", "")`, #691), and reads a link there as its blob, never followed. @@ -388,14 +394,23 @@ def test_the_doctors_audit_of_a_legs_index_reads_a_record_link_as_before( leg's index still reads the PATH as a record, the record's question, where `plan` names the link and asks the general one. Left as it is, as the doctor documents it, until the ruling on following a linked record - settles both readers.""" + settles both readers. + + `[...-committed-too]` commits the link in the leg as well as staging it, + so HEAD holds the link where the index does. The index is still what is + read: `Tree.is_link` must not ask HEAD for the listing it cannot make of + the index. Were it to, the spelled link would silence nothing and the + audit would ask the general question, where it asks the record's.""" def link(leg) -> None: (leg / "docs").mkdir() (leg / "docs" / "record.yaml").write_text(record_text(), encoding="utf-8") (leg / RECORD).unlink() symlink(leg / RECORD, target) - git("add", "-A", "--", ".", cwd=leg) + if in_head: + commit_all(leg, "Link the record") + else: + git("add", "-A", "--", ".", cwd=leg) _, review = leg_audited(adopter, doctor, tmp_path, record_text(), link) [row] = [row for row in review if row["path"] == f"code/{RECORD}"] @@ -458,3 +473,32 @@ def git_out(args, *rest, **kwargs): monkeypatch.setattr(shape_advisory, "git_out", git_out) reading = committed(root, env) assert reading.state == state, reading.as_dict() + + +@pytest.mark.parametrize("files, state", [ + pytest.param({PROJECT: TRIAD}, TRIAD_ROOT, id="project-yaml"), + pytest.param({FAMILY: HOLDER}, FAMILY_HOLDER, id="family-yaml"), +]) +def test_a_git_that_cannot_follow_a_link_reads_a_regular_manifest_as_before( + tmp_path, monkeypatch, files, state): + """CONTROLS, true on #1270's head as here, and the stand-in of #1270's + `test_a_git_that_cannot_follow_the_link_is_silence_not_advice` for a + REGULAR manifest: a git whose `cat-file --follow-symlinks` exits 128 + cannot follow a link, and a manifest that is no link is not asked to. + `_read_mapping` reads it as the blob it is (`Tree.text`), so the commit + still says `triad-root` and `family-holder`. A reading that sent every + manifest through the follow would read it as no manifest (the `Refusal` + is swallowed) and advise the clone, which a git that can follow never + shows: the two differ only here.""" + root = repository(tmp_path / "Atlas", files) + real = subprocess.run + + def run(args, *rest, **kwargs): + if "--follow-symlinks" in args: + return subprocess.CompletedProcess(args, 128, b"", b"fatal: no") + return real(args, *rest, **kwargs) + + env = no_workspace(tmp_path) + monkeypatch.setattr(subprocess, "run", run) + reading = committed(root, env) + assert reading.state == state, reading.as_dict()