Skip to content

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 gets a NOTE that says it is a symlink not followed, never could not be read (YamlError) (#1272) - #1295

Draft
brettheap wants to merge 30 commits into
mainfrom
fix/1272-record-symlink

Conversation

@brettheap

@brettheap brettheap commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

Was stacked on #1270 (lane 1's, for #1163), which landed on main as a squash, 3bf35b22e, at 06:24:35Z on 2026-11-10. This PR was restacked onto main by three merges, with no rebase (see The restack after #1270's squash), and its base is main. It was made at #1270's head 8e54c32de, which adds Tree.is_link and Tree.text_through to scripts/shape_advisory.py. Against main the diff is this PR's own change: 2 files, +557/-4, from two commits of its own, 126165514 and the round after rv1295's review (0c3999d5a, tests only). The merge commits c6d9a9ab4 and 129f4dce8 (the restack) change no line of them.

Part of #1272

Goal

adopt-project.py plan and check read a project.yaml or a family.yaml that the commit holds as a symlink the way the doctor reads it on the disk. They also say plainly that they do not follow a single-repository.yaml held as one, so no it could not be read (YamlError) NOTE is printed over a record that can be read. Whether the record's link should be followed is a ruling not yet made (see The ruling left open), so this PR does not follow it.

What held at #1270's head (measured)

Measured with git 2.43.0 and CPython 3.12.3, in the probe environment: a stub gh first on PATH, a bogus GH_TOKEN, an empty GH_CONFIG_DIR, GIT_CONFIG_GLOBAL=/dev/null and GIT_SSH_COMMAND=false, with no network. Each fixture is a one-commit repository in a folder Atlas whose origin is acme/Orbit on github.com, never contacted, as #1272's are. Each got plan --source <Atlas> --project Orbit --org acme ..., then check --plan, then shape_advisory.read() of the commit and of the disk, in process. The same 15 cases gave the same answers at 8e54c32de and at b24382a87, and the same answers at this change on both bases.

committed as the commit at #1270's head the commit here the disk (the doctor's)
single-repository.yaml -> docs/record.yaml, a record advise, NOTE it could not be read (YamlError) advise, the link's NOTE (below) recorded-single
the same, spelled ./docs/record.yaml the YamlError NOTE the link's NOTE recorded-single
a link to a file of another kind the YamlError NOTE the link's NOTE advise, NOTE it declares kind 'release-config', ...
a link that dangles, or loops the YamlError NOTE the link's NOTE advise, no NOTE
a link to the directory docs the YamlError NOTE the link's NOTE advise, NOTE it is not a file
-> ../elsewhere/record.yaml, a record out of the commit the YamlError NOTE the link's NOTE recorded-single
-> kind: single-repository-record (that is the PATH; it dangles) recorded-single; entry leg: drop, rule ambiguous-single-repository-record; check exits 0 advise, the link's NOTE; entry leg: null, rule ambiguous-top-level-yaml; check exits 1 advise, no NOTE
a regular record (control) recorded-single; leg: drop; check exits 0 the same recorded-single
project.yaml -> docs/project.yaml, a Triad manifest advise: the two triad lines triad-root: no Triad line triad-root
family.yaml -> docs/family.yaml, a holder's manifest advise: the two triad lines family-holder: no Triad line family-holder
family.yaml -> kind: family-manifest (the PATH; it dangles) family-holder advise advise
project.yaml -> ../elsewhere/project.yaml, a Triad manifest out of the commit advise advise triad-root
project.yaml -> docs/project.yaml, committed dangling and written on the disk after advise advise triad-root
a regular project.yaml (control) triad-root the same triad-root

What changed

Only scripts/shape_advisory.py. adopt-project.py and shape-doctor.py are untouched.

  • _read_mapping follows a manifest's link inside the commit, for project.yaml and family.yaml. It reads tree.text_through(rel) if tree.is_link(rel) else tree.text(rel), as shape_advisory.py: a leg's AGENTS.md committed as a symlink is read through it inside the commit, and one out of the commit leaves whether it is a leg unknown, so adopt-project.py plan and check stop calling the leg a single repository (#1163) #1270's _leg_by_agents_md reads a leg's AGENTS.md.
    • The choice, stated in its docstring and pinned by rows: nothing that is a file inside the commit is a manifest this reading sees. A link that dangles, loops, runs through a file or leads to a directory gives None from text_through. One that leads out of the commit raises OutOfTheCommit. _read_mapping already swallows both, as it swallows every manifest it cannot read, so each reads as no manifest. 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 reads as before, and so does every reading of the disk, where is_link is always False.
  • read_record does not follow the record's link, and names it. After kind() says "file", it asks tree.is_link(RECORD_FILE). A link silences nothing, with the new constant RECORD_LINK_NOT_FOLLOWED, for every link, whatever it leads to. plan and check print:
NOTE single-repository.yaml does not silence this: 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 (its schema is openRepoShape's contracts/single-repository-record.yaml); nothing fails on its account.
  • The sentence names no exception. It says "a symlink in the commit", so a later ruling to follow links inside the commit can narrow it to the links it still does not follow, such as one out of the commit (the constant's comment says so).
  • ask_about_a_record_only asks only whether the record silences, so a linked record is still "no record" there: the general question, leg: null, and check exits 1, as before. The one exception is the spelled link (see Known limits).
  • The docstrings of Tree and Tree.text_through say which readers follow a link now.
  • Cost: each read(commit=...) makes one git ls-tree per manifest, and one more where a record is a file.

Rows

tests/test_advisory_record_symlink.py is new, with 39 ids (35 at 126165514, 4 added in the round after rv1295's review) beside #1270's tests/test_advisory_agents_md_symlink.py. It imports what it needs from the files that own it:

It imports no fixture. Its one builder, linked_here, links the name under test where #1270's linked_clone links AGENTS.md. Every case that needs a symlink makes one and skips where none can be made. The 9 controls of regular files make none and run everywhere: 4 in test_the_three_files_committed_as_regular_files_read_as_before, 3 in test_a_git_that_cannot_list_the_commit_reads_a_regular_file_as_before and 2 in test_a_git_that_cannot_follow_a_link_reads_a_regular_manifest_as_before.

The new file was run at #1270's production code: its untracked copy, in a detached tree at 8e54c32de, gave 18 failed and 17 passed (11.34 s). Every failure is the defect: the YamlError reason where the link's is asserted (11 rows), advise or leg-clone where the disk's state is asserted (5), family-holder for the spelled manifest link, and recorded-single for the spelled record link. At b24382a87, #1270's head now, the same 18 fail and 17 pass (9.90 s). At this head all 35 pass. The round after rv1295's review added 4 ids, all controls. With b24382a87's production file swapped in (sha256 e0b0ef03) the 39 give the same 18 failed and 21 passed (12.05 s), the 4 among the passes, and at this head all 39 pass.

row #1270's head here
test_a_manifest_linked_inside_the_commit_is_read_through_it: [project-yaml], [project-yaml-beside-a-leg-agents-md], [family-yaml], [project-yaml-dot-slash], [family-yaml-through-a-linked-folder]. These are #1272's rows 2a, 2b and 3: both readings, no Triad line or leg NOTE in plan or check, and check's findings 5 failed 5 passed
test_a_manifest_link_to_no_file_in_the_commit_is_no_manifest[spelled-as-a-manifest] failed passed
THE CHOICE, same file: [dangling], [loop], [a-directory], [through-a-file], [not-utf-8], [dangling-in-the-commit-there-on-the-disk], [out-of-the-commit], [out-of-the-commit-by-its-absolute-path]. The commit reads advise; where the disk reads triad-root, plan and check print the advisory 8 passed 8 passed
test_a_record_linked_to_a_record_in_the_commit_is_named_as_a_link. This is #1272's row 1: the exact NOTE line once in each of plan and check, no could not be read, the entry leg: null under ambiguous-top-level-yaml, check's one finding plan-unresolved, and the doctor's exit 2 with no Triad row failed passed
test_every_link_to_the_record_is_named_as_one_and_never_followed: [dot-slash], [through-a-linked-folder], [another-kind], [dangling], [loop], [a-directory], [through-a-file], [not-utf-8], [out-of-the-commit], [out-of-the-commit-by-its-absolute-path] 10 failed 10 passed
test_a_record_link_whose_path_is_spelled_as_a_record_is_no_record failed passed
CONTROLS test_the_doctors_audit_of_a_legs_index_reads_a_record_link_as_before: [to-a-record-in-the-leg] (the general question) and [spelled-as-a-record] (the record's), the link staged in the leg's index only, and [to-a-record-in-the-leg-committed-too], [spelled-as-a-record-committed-too], the same with the link committed in the leg as well (R5) 4 passed 4 passed
CONTROLS test_the_three_files_committed_as_regular_files_read_as_before: [project-yaml], [project-yaml-beside-a-leg-agents-md], [family-yaml], [single-repository-yaml]. These are #1272's controls 2a-c, 2b-c, 3c and 1c: the entry, and check's findings (none for the record) 4 passed 4 passed
CONTROLS test_a_git_that_cannot_list_the_commit_reads_a_regular_file_as_before (stand-in: ls-tree refused): [project-yaml], [family-yaml], [single-repository-yaml] 3 passed 3 passed
CONTROLS test_a_git_that_cannot_follow_a_link_reads_a_regular_manifest_as_before (stand-in: cat-file --follow-symlinks exits 128): [project-yaml], [family-yaml] (R3) 2 passed 2 passed
total 18 failed, 21 passed 39 passed

Mutants

19 mutants, 19 killed: the 13 of the first round (M1-M10), and rv1295's own six (R1-R6), of which R3 and R5 survived rv1295's run of the 35 ids and are killed by the new ids. All 19 were run again against this round's file. Each was applied alone in a detached tree (16bee386d with this round's test file laid in, its blob equal to 0c3999d5a's), and the new file's 39 ids were run, one run each. After each, the file was written back from the bytes saved before it and proved equal by sha256: scripts/shape_advisory.py 40a766ca7adf, adopt-project.py 6e2402aaeec3, shape-doctor.py 140100bcaae9. The counts of M6b, M8 and M10 grew by the new ids that also fail them; every other count is the first round's.

id mutant result rows failed (first named)
M1 the link not followed in _read_mapping KILLED 6: ..._read_through_it[project-yaml] and the other four, [spelled-as-a-manifest]
M1b followed for project.yaml only, not family.yaml KILLED 3: ..._read_through_it[family-yaml]
M2 a dangling link read as a manifest: a follow that finds no file falls back to the link's own blob KILLED 1: ..._is_no_manifest[spelled-as-a-manifest]
M3 a link out of the commit read from the disk KILLED 2: ..._is_no_manifest[out-of-the-commit], [...-by-its-absolute-path]
M4 the record's link followed inside the commit (#1272's way A) KILLED 12: test_a_record_linked_to_a_record_in_the_commit_is_named_as_a_link, every test_every_link_..., the spelled record
M5 the NOTE carrying an exception name (... (OutOfTheCommit)) KILLED the same 12
M5b the NOTE as the old frame, it could not be read (Symlink) KILLED the same 12
M6a ask_about_a_record_only gives a linked record the record's rule KILLED 2: ..._named_as_a_link, the spelled record (leg: drop)
M6b ask_about_a_record_only handed the disk's tree, which follows a link KILLED 5: ..._named_as_a_link, the four leg-audit controls
M7 no link check in read_record (the link's path parsed, as before) KILLED the same 12 as M4
M8 the doctor's leg audit reads the leg's disk (Source(mount, None, "", None)), following where plan does not KILLED 4: the four leg-audit controls
M9 the followed blob decoded with errors="replace" (r1163's R6) KILLED 1: ..._is_no_manifest[not-utf-8]
M10 is_link says link where git cannot answer KILLED 3: the stand-in's [single-repository-yaml], the leg-audit [spelled-as-a-record] and [spelled-as-a-record-committed-too]
R1 rv1295's: _read_mapping lets OutOfTheCommit escape, #1270's silence for a manifest out of the commit KILLED 2: ..._is_no_manifest[out-of-the-commit], [...-by-its-absolute-path]
R2 rv1295's: a record's link read as no record at all, with no NOTE KILLED 12: ..._named_as_a_link, every test_every_link_..., the spelled record
R3 rv1295's: _read_mapping follows every manifest, link or not KILLED 2: test_a_git_that_cannot_follow_a_link_reads_a_regular_manifest_as_before[project-yaml] and [family-yaml], both new
R4 rv1295's: every committed link read from the disk KILLED 3: ..._is_no_manifest[dangling-in-the-commit-there-on-the-disk], [out-of-the-commit], [...-by-its-absolute-path]
R5 rv1295's: is_link asks HEAD where the Tree reads the index (commit "") KILLED 1: ..._reads_a_record_link_as_before[spelled-as-a-record-committed-too], new
R6 rv1295's: the record's NOTE only where the link's path does not parse KILLED 1: the spelled record
  • M9 survived the first round, run at 126165514's files on 8e54c32de, where 12 of the same 13 were killed. The [not-utf-8] manifest then put the byte 0xff after the manifest's last line. With errors="replace" that made a last line of its own, which does not parse, so the mutant read no manifest either. The row now puts the byte inside a value (name: Atlas<0xff>), and M9 fails it. All 13 were then run again at this head's files, with the table's result.

Runs

The gate ran at 0c3999d5a on git 2.43.0 and on git 2.47.1 (built from kernel.org's tarball), each leg in 8 calls of at most 78 ids (620 ids: the 616 of the first gate and the 4 added in the round after rv1295's review, collected at this head). It used CPython 3.12.3, pytest 9.1.1 and git-filter-repo 2.47.0 (CI's pin), in the probe environment, -rs alone, one basetemp per call, at most two calls at once, the tree clean. Each leg gave 618 passed and 2 skipped. The files are the new file, tests/test_advisory_agents_md_symlink.py, tests/test_shape_advisory.py, tests/test_adopt_single_repository_record.py, tests/test_doctor_recorded_answers.py, the hygiene five (test_agents_md_codes.py, test_cli_reference.py, test_path_guard_hygiene.py, test_pinned_copy_text.py, test_repo_hygiene.py) and tests/test_no_auto_maintenance.py.

call ids files (ids) 2.43.0 2.47.1
1 78 tests/test_advisory_record_symlink.py (39), tests/test_advisory_agents_md_symlink.py (27), tests/test_shape_advisory.py (12) 78 passed in 49.18 s 78 passed in 48.64 s
2 78 tests/test_shape_advisory.py (35), tests/test_adopt_single_repository_record.py (39), tests/test_doctor_recorded_answers.py (4) 78 passed in 39.15 s 78 passed in 39.00 s
3 78 tests/test_doctor_recorded_answers.py (25), tests/test_agents_md_codes.py (47), tests/test_cli_reference.py (6) 78 passed in 37.12 s 78 passed in 36.98 s
4 78 tests/test_cli_reference.py (17), tests/test_path_guard_hygiene.py (61) 78 passed in 24.43 s 78 passed in 24.39 s
5 78 tests/test_path_guard_hygiene.py (60), tests/test_pinned_copy_text.py (18) 78 passed in 8.60 s 78 passed in 8.62 s
6 78 tests/test_pinned_copy_text.py (69), tests/test_repo_hygiene.py (9) 78 passed in 4.10 s 78 passed in 3.96 s
7 78 tests/test_repo_hygiene.py (78) 78 passed in 1.02 s 78 passed in 0.98 s
8 74 tests/test_repo_hygiene.py (14), tests/test_no_auto_maintenance.py (60) 72 passed, 2 skipped in 5.61 s 72 passed, 2 skipped in 5.62 s
  • The 2 skips in call 8 are tests/test_no_auto_maintenance.py:222: "the session began with GIT_CONFIG_GLOBAL set, and its file includes that one". The probe environment sets GIT_CONFIG_GLOBAL=/dev/null.
  • Before this round the gate ran at 16bee386d (616 ids): 614 passed and 2 skipped on each git. rv1295 ran 815 ids there, which included 18 rows of tests/test_shape_doctor.py and the doctor's other rows that read shape_advisory: 813 passed and 2 skipped on both gits. scripts/shape_advisory.py and every other production file are unchanged in this round, so those were not run again.
  • adopt-project.py is unchanged, so tests/test_shape_doctor.py and tests/test_scaffold_leg_path_collisions.py were not run. The Python 3.14 shim was not run.

Merges

Read fresh after the restack and before the push (git fetch of main, and gh pr list, read only, 30 open PRs): main 3bf35b22e, which is #1270's squash, and #1270 MERGED at its final head 0c19a3c77. This head contains both, so a merge of either adds nothing. No other open PR touches scripts/shape_advisory.py, tests/test_advisory_record_symlink.py or tests/test_advisory_agents_md_symlink.py. #1278 and #1171 touch tests/test_pinned_copy_text.py, which the gate runs and this PR does not change.

with git merge-tree --write-tree
main 3bf35b22e clean: tree e3ca2459c, this head's own tree (main is an ancestor)
#1270's final head 0c19a3c77 clean: tree e3ca2459c, this head's own tree (an ancestor)
#1270's squash 3bf35b22e the same: it is main's tip

The 29 other open PRs: 19 of them touch adopt-project.py, shape-doctor.py or tests/conftest.py, which these rows run, and none touches this PR's two files. A change to check's findings for a root link would show in the rows that assert them.

The ruling left open

Whether a single-repository.yaml committed as a symlink to a file inside the commit should be followed. Followed, it would take the record rule ambiguous-single-repository-record, with the default drop that Brett Heap ruled on 2026-10-12 for a regular record (contracts/path-classification.yaml, adopt-project.py's ask_about_a_record_only, #528). This PR leaves every such link unfollowed, with a true NOTE. #1272's measurement 4 shows what following would do: recorded-single, leg: drop, and check exits 0, as for a regular record. What the ruling may weigh:

Not the record's ruling, but a choice for review: a manifest's link out of the commit reads as no manifest here, so plan advises where the doctor names the root. #1270 chose silence (not-answered) for a leg's AGENTS.md out of the commit. This PR reads such a manifest as _read_mapping reads any manifest it cannot read: as none. update-shape.py already refuses to re-pin a root whose manifest is a link (update-rewrite-symlink).

Known limits

The round after rv1295's review

rv1295 found this PR READY at 16bee386d, with no MUST or SHOULD and four NITs (#1295 issuecomment-6757105722). This round takes them as rv1295 measured them and changes nothing else: one commit, 0c3999d5a, in tests/test_advisory_record_symlink.py only. scripts/shape_advisory.py and every other production file are unchanged. #1270's head was read fresh and is still b24382a87, so no merge of it was needed.

The restack after #1270's squash

#1270 (lane 1's, for #1163) landed at 06:24:35Z on 2026-11-10 as the squash S70 = 3bf35b22e (parent de55beb03; #473 issuecomment-6760664498). Lane 1 had refreshed it first, so its final head H70 is 0c19a3c77 (b24382a87 plus a merge of main de55beb03), not the b24382a87 this PR's earlier text names. GitHub deleted #1270's branch and retargeted this PR to main, where it was CONFLICTING at 0c3999d5a. This round restacks it by three merges, by hand, with no rebase, and changes nothing else: no line of this PR's two files.

  • Merge 1: H70 0c19a3c77 gave c6d9a9ab4. It was clean: the 29 files it brought are main's (lane 1's refresh merged main), none of them this PR's.
  • Merge 2: S70 3bf35b22e gave 129f4dce8, with one conflict, in scripts/shape_advisory.py, the file shape_advisory.py: a leg's AGENTS.md committed as a symlink is read through it inside the commit, and one out of the commit leaves whether it is a leg unknown, so adopt-project.py plan and check stop calling the leg a single repository (#1163) #1270 and this PR both changed. It had two hunks, both in docstrings: the Tree class docstring (this PR's text says project.yaml and family.yaml are read through a link too, _read_mapping, shape_advisory.py: a single-repository.yaml committed as a symlink is read as its path, so plan and check advise with a false (YamlError) NOTE where the doctor says recorded-single (#1163) #1272, and that single-repository.yaml is not followed, read_record; the squash's text says only a leg's AGENTS.md is read so) and Tree.text_through's docstring ("as AGENTS.md and the two manifests are" against "as AGENTS.md is"). Both were resolved to this branch's side. The resolved blob 129a4c86b7ff equals 0c3999d5a's. S70's own copy of the file (9122f59bd22b) equals b24382a87's and H70's, so main changed nothing in it after shape_advisory.py: a leg's AGENTS.md committed as a symlink is read through it inside the commit, and one out of the commit leaves whether it is a leg unknown, so adopt-project.py plan and check stop calling the leg a single repository (#1163) #1270's head. tests/test_advisory_agents_md_symlink.py merged without a conflict: S70's blob 62bf0a2220d4 equals this branch's. The merge commit's tree equals merge 1's.
  • Merge 3: main 3bf35b22e, fetched fresh just before this step and again before the push, is S70 itself, and an ancestor of the head: Already up to date, no commit.
  • Verify, at 129f4dce8:
    • git diff origin/main HEAD touches exactly scripts/shape_advisory.py and tests/test_advisory_record_symlink.py: 2 files, +557/-4;
    • its -U0 changed lines (561 of them, sha256 85cc8b101b02) are identical to those of git diff b24382a87 0c3999d5a, the reviewed change, with no difference;
    • git merge-tree --write-tree origin/main HEAD gives e3ca2459c, the head's own tree;
    • both of this PR's files are byte-equal to 0c3999d5a's: scripts/shape_advisory.py 129a4c86b7ff, tests/test_advisory_record_symlink.py 49630a405c18.
  • The mutants were not run again. Both files are byte-equal to the ones they ran against. scripts/shape_advisory.py is still the Mutants section's sha256 40a766ca7adf, and so is shape-doctor.py, 140100bcaae9. Main changed adopt-project.py since (sha256 6e2402aaeec3 in the Mutants section, 2d5459ab2fee at this head, all main's), and M6a and M6b ran against the older file; the gate below runs every row against this head's.
  • The gate ran at 129f4dce8, the tree clean, on git 2.43.0 and on git 2.47.1 (built from kernel.org's tarball), each leg in 8 calls of at most 78 ids (620 ids, the same list as the Runs section's, collected again at this head: 0 ids differ). It used CPython 3.12.3, pytest 9.1.1 and git-filter-repo 2.47.0 (CI's pin), in the probe environment, -rs alone, one basetemp per call, at most two calls at once. Each leg gave 618 passed and 2 skipped, in the same files as the Runs section. The 2 skips are tests/test_no_auto_maintenance.py:222 in call 8, "the session began with GIT_CONFIG_GLOBAL set, and its file includes that one" (the probe environment sets GIT_CONFIG_GLOBAL=/dev/null). adopt-project.py is unchanged by this PR, so tests/test_shape_doctor.py and tests/test_scaffold_leg_path_collisions.py were not run; the Python 3.14 shim was not run.
call ids files (ids) 2.43.0 2.47.1
1 78 tests/test_advisory_record_symlink.py (39), tests/test_advisory_agents_md_symlink.py (27), tests/test_shape_advisory.py (12) 78 passed in 55.22 s 78 passed in 54.71 s
2 78 tests/test_shape_advisory.py (35), tests/test_adopt_single_repository_record.py (39), tests/test_doctor_recorded_answers.py (4) 78 passed in 44.52 s 78 passed in 44.73 s
3 78 tests/test_doctor_recorded_answers.py (25), tests/test_agents_md_codes.py (47), tests/test_cli_reference.py (6) 78 passed in 43.01 s 78 passed in 42.85 s
4 78 tests/test_cli_reference.py (17), tests/test_path_guard_hygiene.py (61) 78 passed in 28.31 s 78 passed in 28.16 s
5 78 tests/test_path_guard_hygiene.py (60), tests/test_pinned_copy_text.py (18) 78 passed in 10.96 s 78 passed in 10.87 s
6 78 tests/test_pinned_copy_text.py (69), tests/test_repo_hygiene.py (9) 78 passed in 4.81 s 78 passed in 4.84 s
7 78 tests/test_repo_hygiene.py (78) 78 passed in 1.31 s 78 passed in 1.20 s
8 74 tests/test_repo_hygiene.py (14), tests/test_no_auto_maintenance.py (60) 72 passed, 2 skipped in 7.42 s 72 passed, 2 skipped in 7.43 s

Lane: openreposhape-5 (openRepoShape-5)

🤖 Generated with Claude Code

Summary by Sourcery

Handle committed YAML symlinks accurately in shape advisories by following manifests and clearly reporting record links that are intentionally not followed.

Bug Fixes:

  • Read committed project.yaml and family.yaml symlinks through their targets when evaluating repository manifests.
  • Report committed single-repository.yaml symlinks explicitly as un followed instead of incorrectly reporting YAML read failures.

Enhancements:

  • Define consistent commit-based symlink handling for manifest and record advisory reads while preserving existing disk and leg-index behavior.

Tests:

  • Add comprehensive coverage for manifest and record symlinks, including dangling, looping, out-of-commit, malformed, and control cases.

Brett Heap and others added 26 commits October 21, 2026 21:31
…he root that is a link into a folder this user cannot search is said once, `<name>: cannot be read (<reason>)` 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 <noreply@anthropic.com>
…le 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 <noreply@anthropic.com>
…he 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 <noreply@anthropic.com>
…e 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 <noreply@anthropic.com>
…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
  (<reason>)` 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 <noreply@anthropic.com>
…ere` 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 <noreply@anthropic.com>
…lder 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 `<P>-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 <noreply@anthropic.com>
…ot 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 <noreply@anthropic.com>
…ts 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 <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
#1144's final head is update-branch's merge of main bd3e9c1 into
89486f6, the tree that squash-landed as b1f45fa. This is the first of
the three merges that restack #1159 after #1144 landed.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
b1f45fa is #1144's squash on main. Its own files (scripts/shape_advisory.py,
shape-doctor.py, tests/test_doctor_unsearchable_links.py and
tests/test_path_guard_hygiene.py) take the branch's side: S's tree is #1144's
final head 5b38c3a's tree, and merge 1 already brought that in. The merged
tree equals HEAD's tree, c5d0bc7. This is the second of the three merges that
restack #1159 after #1144 landed.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main 0d5704b is five commits past #1144's squash b1f45fa (#1169, #1223,
#1166, #1133, #1104). This is the third of the three merges that restack
#1159 after #1144 landed.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
…` 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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
9e4aab8 is #1159's final head: update-branch's merge of main 1212295 into
5370b01, the commit this branch was built on. This is the first of the three
merges that restack #1163 after #1159 landed as 46fe6ac.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
46fe6ac is #1159's squash on main. Its own files (scripts/shape_advisory.py,
shape-doctor.py and tests/test_doctor_unsearchable_links.py) take the branch's
side: S's tree is #1159's final head 9e4aab8's tree, 863f70c, and merge 1
already brought that in. The merged tree equals HEAD's tree. This is the
second of the three merges that restack #1163 after #1159 landed.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…th 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 <commit>: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 2abd817; four controls hold the second ask
to the disk's answer.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…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 2abd817 as here.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…k 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 <noreply@anthropic.com>
…ymlink 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 <noreply@anthropic.com>
…4069) into fix/1272-record-symlink

#1270 merged main after this branch was made at its head 8e54c32.

Part of #1272

Lane: openreposhape-5 (openRepoShape-5)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@brettheap brettheap added this to the v1.2.2 milestone Nov 10, 2026
@sourcery-ai

sourcery-ai Bot commented Nov 10, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

Implementation

Update manifest and record readers so committed project.yaml and family.yaml symlinks are followed within the commit, while single-repository.yaml symlinks are explicitly reported as not followed.

  • scripts/shape_advisory.py L588-607, L860-871, L951-966 (+3 small edits)

Tests

Add regression and control coverage for committed manifest and record symlinks, including invalid or out-of-commit targets and unchanged doctor index behavior.

  • tests/test_advisory_record_symlink.py L1-504

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@brettheap

Copy link
Copy Markdown
Contributor Author

Independent review of #1295 (#1272) for lane openRepoShape-5, by rv1295 (opus), at 16bee38: READY

There are no MUST or SHOULD findings, and four NITs. One rule breach is mine, not the PR's: one of my mutant runs had 109 ids, over the 100 cap (details under Mutants).

What I measured

1. The change against #1272's Goal and Verify list

2. The choice put up for review: the PR's reading is right

3. The rows

4. Mutants (25 runs: n1272's 13 and my 6, every file proved back at its sha256 after each)

  • n1272's 13: all killed in my reproduction (logs/mutants-m1.out). The failed-row counts match the body's table: M1 6, M1b 3, M2 1, M3 2, M4/M5/M5b/M7 12 each, M6a 2, M6b 3, M8 2, M9 1, M10 2.
  • My own six: four killed, two survived.
    • R1, _read_mapping lets OutOfTheCommit escape: killed by the 2 out-of-the-commit rows.
    • R2, a record link read as RECORD_ABSENT (no NOTE): killed by 12 rows.
    • R3, _read_mapping follows every manifest, link or not: survived (NIT 4).
    • R4, every committed link read from the disk: killed by 3 rows.
    • R5, is_link asks HEAD where the Tree reads the index: survived (NIT 3).
    • R6, the NOTE only where the link's path does not parse: killed by the spelled-record row.
  • My rule breach. My first table ran R1 as one pytest run of 109 ids (35 + 47 + 27), and had R5 set to 103.
    • I stopped R3's pytest by PID, then the mutant script, then R5's orphaned run.
    • I restored scripts/shape_advisory.py from my saved head copy and checked it with sha256sum -c (all OK, tree clean).
    • I re-ran R1, R3, R5 and R6 in groups of 82/27, 82/52, 64/39 and 35 ids (mutants2.py, logs/mutants-m2.out). R4 had already run compliantly on 35 ids.

5. Gates at 16bee386d

  • 11 calls per leg, 815 ids, each call at most 100 ids, -rs only.
  • Files:
    • the new file;
    • test_adopt_single_repository_record.py, test_advisory_agents_md_symlink.py, test_shape_advisory.py, test_doctor_recorded_answers.py;
    • the doctor's rows that read shape_advisory: test_bom_reads.py, test_doctor_validator_verdicts.py, test_doctor_unsearchable_links.py, test_doctor_unreadable_paths.py, and 18 rows of test_shape_doctor.py;
    • the hygiene five, and test_no_auto_maintenance.py.
  • git 2.43.0 (/usr/bin/git), git-filter-repo 2.47.0, CPython 3.12.3: 813 passed, 2 skipped (logs/gate-243-h1.out).
  • git 2.47.1 (L/tools/git247/bin/git), the same filter-repo and Python: 813 passed, 2 skipped (logs/gate-247-h1.out).
  • Skips: both legs skip tests/test_no_auto_maintenance.py:222 twice: "the session began with GIT_CONFIG_GLOBAL set, and its file includes that one".
  • CI on the head: SonarCloud, package and tests-git-bash pass. tests (Python 3.14, full suite) was still running at 02:17:38Z, after starting at 01:50:29Z.

6. Merge-trees

7. The body's claims and counts

NITs

  1. A false sentence in the test docstring and the body. tests/test_advisory_record_symlink.py lines 42-43, and the body's Rows section, say "Every case here makes a symlink, and skips where none can be made". The 7 control ids make none: the 4 in test_the_three_files_committed_as_regular_files_read_as_before and the 3 in test_a_git_that_cannot_list_the_commit_reads_a_regular_file_as_before. They need none and rightly run everywhere, so the sentence should say "every case that needs a symlink".
  2. A landing note. When shape_advisory.py: a leg's AGENTS.md committed as a symlink is read through it inside the commit, and one out of the commit leaves whether it is a leg unknown, so adopt-project.py plan and check stop calling the leg a single repository (#1163) #1270 lands as a squash, a plain merge of main into fix/1272-record-symlink conflicts in scripts/shape_advisory.py (section 6). Use the stacked-after-squash route.
  3. R5 survives. That the leg index "reads as before" rests on git refusing ls-tree "", and the leg-audit control stages the link only in the index, with a regular record in HEAD. Committing the link in the leg as well, or an explicit tree.commit guard in read_record, would pin it. Only the spelled link is affected. Optional.
  4. R3 survives. No row pins that a regular manifest is read with text() rather than through --follow-symlinks. The two differ only under a git that refuses the follow. A stand-in row like shape_advisory.py: a leg's AGENTS.md committed as a symlink is read through it inside the commit, and one out of the commit leaves whether it is a leg unknown, so adopt-project.py plan and check stop calling the leg a single repository (#1163) #1270's test_a_git_that_cannot_follow_the_link_is_silence_not_advice, for a regular project.yaml, would pin it. Optional.

Left behind: both trees are clean at 16bee386d; only pytest's ignored __pycache__ folders are there. No process of mine is running. Everything is in /workspace/projects/.lane-worktrees/openRepoShape-5/work/rv1295/: STATE.md, gate.sh, mutants.py, mutants2.py, probe1.py, probe2.py, the logs/ and probe/ folders, and mt-squash.out.

Lane: openreposhape-5 (openRepoShape-5)

…ontrols, 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 <noreply@anthropic.com>
@brettheap

Copy link
Copy Markdown
Contributor Author

Delta check of #1295 (#1272) for lane openRepoShape-5, by rv1295b (sonnet), 16bee38..0c3999d: READY

I found no MUST, no SHOULD and no NIT in the delta. There are two observations below. I wrote nothing to GitHub: reads only (gh pr view, gh pr list, gh api GET). Everything I produced is under /workspace/projects/.lane-worktrees/openRepoShape-5/work/rv1295b/ (STATE.md, logs/, probes/, gate.sh, mutants3.py, mutants3b.py, base.py, mt.sh). Both review trees are detached at 0c3999d5a and clean, and the production files are at 40a766ca7adf (scripts/shape_advisory.py), 6e2402aaeec3 (adopt-project.py) and 140100bcaae9 (shape-doctor.py).

1. Nothing else changed

2. NIT 1 (docstring and body sentence) is true

  • I read all 39 ids. Every case that makes a link goes through linked_here or symlink(), which skips by name.
  • Measurement: I loaded a plugin (probes/noslink_plugin.py) that makes Path.symlink_to and os.symlink raise OSError, and ran the file.
    • Result: 9 passed, 30 skipped (logs/noslink2.log). Every skip reads "no symlink here", from tests/test_doctor_unsearchable_links.py:173.
    • The 9 that pass are the controls: 4 of test_the_three_files_committed_as_regular_files_read_as_before, 3 of test_a_git_that_cannot_list_the_commit_reads_a_regular_file_as_before and 2 of test_a_git_that_cannot_follow_a_link_reads_a_regular_manifest_as_before.
  • The module docstring and the body's Rows section name exactly those 9, so both are true.
  • The 4 leg-audit controls (..._reads_a_record_link_as_before) are called "CONTROLS" in their docstring but need a link, so they skip. The module docstring does not count them among the controls that need none, so that is accurate.

3. R5, R3 and my rivals (the mut tree is rv1295-mut; each mutant was applied alone and restored from the saved bytes, with sha256 equal each time)

  • R5 (is_link asks HEAD where the Tree reads the index): KILLED. It fails only test_the_doctors_audit_of_a_legs_index_reads_a_record_link_as_before[spelled-as-a-record-committed-too].
    • Run as 68 ids (the new file plus tests/test_doctor_recorded_answers.py): 1 failed, 67 passed.
    • Run on tests/test_adopt_single_repository_record.py: 39 passed.
    • So no row fails that should not.
  • R3 (_read_mapping follows every manifest, link or not): KILLED. It fails exactly the two stand-in ids, test_a_git_that_cannot_follow_a_link_reads_a_regular_manifest_as_before[project-yaml] and [family-yaml].
    • Run as 86 ids (the new file plus tests/test_shape_advisory.py): 2 failed, 84 passed.
    • Run on tests/test_advisory_agents_md_symlink.py plus tests/test_bom_reads.py: 52 passed.
  • My rivals for the new rows, each run on the new file's 39 ids:
rival result
Q3c: family.yaml always read through the follow, link or not KILLED by [family-yaml] of the stand-in alone (1 failed, 38 passed)
Q3d: project.yaml always read through the follow KILLED by [project-yaml] of the stand-in alone (1 failed, 38 passed)
Q5: is_link falls back to HEAD when git refuses the listing KILLED by [spelled-as-a-record-committed-too] alone (1 failed, 38 passed)
Q5f: read_record asks HEAD whether the record is a link KILLED, 6 failed: 5 existing record rows and [spelled-as-a-record-committed-too]
Q5e: is_link says no link for an empty commit without asking git SURVIVED, and it is an equivalent mutant: git refuses ls-tree "", so it behaves the same
Q3e: try the follow first for any manifest, fall back to the blob on Refusal SURVIVED on 86 ids. For a regular manifest it behaves the same
  • Each stand-in id pins its own manifest, and the [spelled-as-a-record-committed-too] id is the one that catches the R5 family.
  • [to-a-record-in-the-leg-committed-too] kills nothing under R5, because the NOTE and the YamlError frame both give the general question. It is a pure control, and the docstring does not claim more.
  • My first Q3c, Q3d and Q3e broke the disk reading (commit is None), so I redesigned them with a tree.commit is not None guard and reran. The numbers above are from the corrected versions.
  • I also reran M6b, M8 and M10 on the new file (logs/mutants-m.out):
mutant rows failed what the body says
M6b 5 5
M8 4 4
M10 3 3

4. The 4 new ids are controls

  • scripts/shape_advisory.py from b24382a87 (sha256 e0b0ef03f278, which equals 8e54c32de's) was swapped into the mut tree. I restored the head file and proved it equal.
  • Result for the new file: 18 failed, 21 passed (logs/rv1295b-base1.log).
  • The 4 new ids pass: [to-a-record-in-the-leg-committed-too], [spelled-as-a-record-committed-too] and both stand-in ids.
  • The 18 failing ids are identical to L/work/n1272/base-failed.txt and to the failures in L/work/rv1295/logs/rv1295-base1.log.

5. Gates at 0c3999d5a

  • Files: the new file, tests/test_advisory_agents_md_symlink.py, tests/test_shape_advisory.py, tests/test_adopt_single_repository_record.py, tests/test_doctor_recorded_answers.py, the hygiene five (test_agents_md_codes.py, test_cli_reference.py, test_path_guard_hygiene.py, test_pinned_copy_text.py, test_repo_hygiene.py) and tests/test_no_auto_maintenance.py.
  • Ids: I collected 620 fresh. The list equals n1272b's gate-ids.txt (0 ids differ either way).
  • Calls: 8 per leg, at most 78 ids each, -rs alone, two runs at once.
  • git 2.43.0 (/usr/bin/git): 618 passed, 2 skipped (logs/gate-243-h1.out).
  • git 2.47.1 (L/tools/git247/bin/git): 618 passed, 2 skipped (logs/gate-247-h1.out).
  • Both legs used git-filter-repo 2.47.0, CPython 3.12.3 and pytest 9.1.1.
  • Skips: on both legs tests/test_no_auto_maintenance.py:222 skips twice: "the session began with GIT_CONFIG_GLOBAL set, and its file includes that one".
  • The per-call counts match the body's Runs table (78 ids per call, then 72 passed and 2 skipped in call 8).
  • CI on the head, read just now: tests-git-bash, package and SonarCloud pass. tests (Python 3.14, full suite) was still IN_PROGRESS at my read.

6. Merge-trees (object-only, on git 2.43.0 and 2.47.1, with the same results)

7. The record

Observations (not findings)

  1. The body names main e720dad45 and tree d128a0f2b. Both were true when it was written, and main has moved. The merge is still clean (section 6).
  2. Rival Q3e survives. A manifest link under a git that cannot follow would, by inspection, read as its own blob if that fallback existed. No row pins "a manifest link under such a git advises". I did not measure that case, and the stakes are low: --follow-symlinks exists in every git this repository supports.

I started the mutant script once by mistake without DRY=1. It ran real mutants and was cut off by a closed pipe, after the tree had been restored. I verified the restore by sha256 and reran what was missing. No run exceeded 100 ids (the largest was 86), and nothing of mine is still running.

Lane: openreposhape-5 (openRepoShape-5)

Base automatically changed from fix/1163-agents-md-symlink to main November 10, 2026 06:24
Brett Heap and others added 2 commits November 10, 2026 06:30
de55beb) into fix/1272-record-symlink

Step 1 of the three-merge restack of #1295 after #1270 landed as a squash. 0c19a3c is b24382a (the head this branch carried) plus lane 1's refresh with main; the merge is clean and changes no file this PR owns.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ape_advisory.py took the branch's side

Step 2 of the three-merge restack of #1295. #1270's final head was 0c19a3c (merged in the step before); its squash on main is 3bf35b2 (parent de55beb), which adds the same two files as that head.

One conflicted file, the one #1270 itself changed: scripts/shape_advisory.py, two hunks.
- the Tree class docstring: this PR's text says that project.yaml and family.yaml are read through a link too (_read_mapping, #1272) and that single-repository.yaml is not followed (read_record); the squash's older text says only a leg's AGENTS.md is read so. Resolved to the branch's side.
- Tree.text_through's docstring: "as AGENTS.md and the two manifests are" against "as AGENTS.md is". Resolved to the branch's side.
The resolved file's blob is 129a4c8, equal to 0c3999d's. The squash's own copy of the file (blob 9122f59) equals b24382a's and 0c19a3c's, so main changed nothing in it after #1270's head.

tests/test_advisory_agents_md_symlink.py merged without conflict: the squash's blob 62bf0a2 equals the branch's.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant