Skip to content

repo_shape.py: load_yaml's three refusals and policy-unreadable name the file as shown_path does, and validate-pins.py, family.py and shape-doctor.py read either spelling (#1168) - #1274

Draft
brettheap wants to merge 1 commit into
mainfrom
fix/1168-load-yaml-shown
Draft

brettheap wants to merge 1 commit into
mainfrom
fix/1168-load-yaml-shown

Conversation

@brettheap

@brettheap brettheap commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

Goal

repo_shape.load_yaml names the file it refuses as shown_path names a path, in all three of its refusals (yaml-unreadable twice, yaml-unparsable), and so does policy-unreadable on the next line of NamingPolicy.load. A U+202E, an ESC or a line feed in a typed --plan, --path-policy, --policy, --project or --root path no longer reaches the terminal raw from any of the six tools that hand load_yaml such a path (eight runs: adopt-project.py reads two flags, validate-repository-naming.py two). A printable path prints byte for byte as on main. Every reader that finds the path at the start of a refusal's detail by its text keeps working, beside a repo_shape.py from before this change or after it.

What held on main (measured)

Probed at 51a9c5f10 with a stub gh first on PATH, GH_TOKEN=bogus, an empty GH_CONFIG_DIR, GIT_SSH_COMMAND=false and an empty global git config: a folder pl<U+202E>an holding a file that is not this reader's YAML (a: 1 then : b). Each run exits 2 with one line, the U+202E in it raw:

REFUSED yaml-unparsable: <D>/pl<U+202E>an/<file>: line 2: empty keys are not supported
  • adopt-project.py check --plan (and execute --plan, the same Plan.load);
  • adopt-project.py plan --path-policy;
  • validate-repository-naming.py --policy and --project;
  • bump-leg.py --root (<root>/project.yaml);
  • family.py bump --family-root (<root>/family.yaml);
  • update-shape.py check --root (<root>/contracts/shape-pin.yaml);
  • validate-manifest.py --root, as a root carries it.

On this branch the same eight runs say '<D>/pl\u202ean/<file>'. The rows below hold all eight, with U+202E, an ESC and a line feed in turn, and a printable name.

The readers that find the path in the detail by its text, on main: validate-pins.py's _load tests exc.detail.startswith(f"{path}: "); family.py's _read_member_project swaps str(manifest_path) for <repo> @ <commit>:project.yaml; repo_shape.link_declarations swaps str(path) for shown_path(path). Beyond the issue's list, a grep of every shipped .py for a detail read by its prefix found two more, both in shape-doctor.py: naming_script_refusal (detail.startswith(f"{shape / NAMING_COPY}: ")) and the manifest-kinds row (exc.detail.removeprefix(f"{path}: ")). Measured with only repo_shape.py changed: validate-pins.py named a root that is not printable by its whole spelled path in place of project.yaml; family.py named the deleted clone in place of the member when TMPDIR is not printable (probed with an ESC in the clone's folder); the doctor no longer knew its own policy's refusal, and its manifest-kinds row quoted the whole path again. Each is held below.

What changed

  • scripts/repo_shape.py, load_yaml: named = shown_path(path), used at the three sites; NamingPolicy.load's policy-unreadable: shown_path(path). Same module, no new import. The words after the path are unchanged.
  • templates/assembly-root/scripts/validate-pins.py, _load: the prefix is looked for raw and as the gate's own _shown spells it (_shown is written in the gate, not imported, under the skew rule), and either is named relative to the root.
  • scripts/family.py, _read_member_project: the swap to shown takes the spelled path, then the raw one.
  • shape-doctor.py: naming_script_refusal asks both prefixes (policy-wrong-kind still names the policy raw); the manifest-kinds row takes the path off with a new less_the_path, in either spelling.
  • repo_shape.link_declarations is unchanged: its replace(str(path), named) finds nothing to swap in a spelled detail and the why reads as it did (a row says so).
  • tests/test_load_yaml_refusals.py: the rows below, added to the file that already holds load_yaml's refusals (scripts/repo_shape.py load_yaml catches OSError and YamlError but not UnicodeDecodeError, so a plan or manifest holding a non-UTF-8 byte makes adopt-project.py check and execute exit 1 with a traceback instead of REFUSED yaml-unreadable #192), not a new file.

No refusal code, remediation or word after the path changes, and a printable path is named as before. README.md, docs/cli.md, the handbook and AGENTS.md hold none of cannot be read (, not UTF-8 at byte, yaml-unparsable, yaml-unreadable or policy-unreadable; the release notes of v1.0.0, v1.1.1 and v1.2.0 name the codes as the record of those releases, with printable paths, and stay as they are; the 46 files under tests/ that hold them are all in the gates below.

scripts/repo_shape.py and templates/assembly-root/scripts/validate-pins.py are shape copies. Every assembly root carries both, and every family holder carries repo_shape.py, so update-shape.py check shows each as upstream-changed in every project until apply re-copies it and re-pins, as #827 and #919 did. family.py and shape-doctor.py run from a checkout of the standard and are not copied.

The version mix

A root re-copies its pinned files one row at a time (update-shape.py apply --accept-local keeps a copy), so validate-pins.py and repo_shape.py may be of different ages. Rows, each a real run of the root's own gate over a root under a folder holding the character:

gate library the refusal names row
this branch this branch project.yaml test_the_gate_names_the_file_from_the_root_beside_this_library
this branch before #1168 (51a9c5f10, raw) project.yaml test_the_gate_names_the_file_from_the_root_beside_an_older_library
before #1168 this branch the whole path, spelled, nothing raw test_an_older_gate_beside_this_library_names_the_whole_path_spelled
before #1168 before #1168 project.yaml main

The third order is the one no change here can reach: that gate's own code asks the raw prefix alone. It still prints nothing raw, and it names project.yaml again once the gate is re-copied. A printable root is named project.yaml in all four. The two older copies are read with git show 51a9c5f10:..., a skip where a checkout lacks that history (CI checks out with fetch-depth: 0), so test_the_gates_load_takes_either_prefix also holds both halves in process with no history. family.py and the doctor always import their own checkout's repo_shape.py, so they cannot meet another age; they read both spellings all the same, so no reader of the prefix depends on which library it meets, and their rows run beside each.

Two styles in one tool

adopt-project.py spells the paths it prints itself with its own _shown (backticks, #1115, and #1152's PR #1177 for --plan), and load_yaml now spells with shown_path (repr, quotes). Measured, for one plan path:

path holds adopt-project.py _shown shown_path
U+202E `/d/pl\u202ean/plan.yaml` '/d/pl\u202ean/plan.yaml'
ESC `/d/x\x1b[31m/plan.yaml` '/d/x\x1b[31m/plan.yaml'
a line feed `/d/a\nb/plan.yaml` '/d/a\nb/plan.yaml'
a byte that is not UTF-8 `/d/caf\udce9/plan.yaml` '/d/caf\udce9/plan.yaml'
a quote and an ESC `/d/it's\x1b/plan.yaml` "/d/it's\x1b/plan.yaml"
a trailing space `/d/trail /plan.yaml ` /d/trail /plan.yaml
nothing unusual /d/plain/plan.yaml /d/plain/plan.yaml

Decision: shown_path in load_yaml, and the two styles stand. The escapes are the same; only the delimiters differ, and _shown's extra cases (a trailing space, backticks at both ends) are printable, so neither is a hole. A run never shows both for the plan: check and execute over an unparsable plan each print exactly one line, the load_yaml refusal, and stop (measured). shown_path is what the other five tools print for the same refusal, and what the three pinned validators' own _shown print (test_the_pinned_scripts_spell_a_path_as_the_library_does holds them equal). One spelling for adopt-project.py too would mean a spelling parameter on a function in a shape copy, or re-raising at Plan.load and PathPolicy.load, in a file #1177 is changing next to those lines and 24 other open PRs touch, for a difference of quote marks.

Rows, and what they do on main

109 rows in tests/test_load_yaml_refusals.py: 98 new, and the 11 that were there (#192). Run on main's code: this branch's rows in the detached tree at 51a9c5f10, with main's version of each changed file swapped in, each put back with a sha256 check.

rows on main's 4 files main's repo_shape.py only main's validate-pins.py only main's family.py only main's shape-doctor.py only
each refusal, U+202E / ESC / line feed x absent / not UTF-8 / unparsable (9) 9 fail 9 fail pass pass pass
policy-unreadable, 3 names (3) 3 fail 3 fail pass pass pass
each of the 8 tools x 3 names (24) 24 fail 24 fail pass pass pass
older gate beside this library, 3 names (3) 3 fail 3 fail pass pass pass
gate beside this library, 3 names, real run (3) and in process (3) pass pass 6 fail pass pass
family member manifest beside this library, 3 names (3) pass pass pass 3 fail pass
doctor's own policy in the naming script's output, 3 names x 2 refusals (6) 2 fail (line feed) 2 fail (line feed) pass pass 6 fail
doctor's manifest-kinds row beside this library, 3 names (3) pass pass pass pass 3 fail
total failing of 109 41 41 6 3 9

57 of the 98 new rows fail on main's code: the 41 above with main's four files, and 16 more that read the path back (the gate 6, family.py 3, the doctor 4 + 3) with main's reader beside this branch's library, the skew this change would otherwise open.

The other 41 new rows pass in every column, as do the 11 that were there. They are the controls the brief asks for: a printable path in each refusal (3), in policy-unreadable (1) and through each tool (8), which prints byte for byte as on main; the four readers beside a library older than #1168 (gate real run 4, gate in process 4, family.py 4, manifest-kinds 4) and beside this one for a printable path (6); link_declarations (4), which reads as it did; and the doctor finding policy-wrong-kind raw (3). Mutants M5, M8, M10, M12, M13 and M14 each fail some of them; the link_declarations rows guard only that its why did not move, which nothing in this change can move (M16). The doctor's two line-feed rows fail on main's four files too: a raw line feed splits the naming script's line before the doctor reads it.

Mutants

Each applied alone in the detached tree, the 109 rows run, the file put back and its sha256 checked against the branch's (all 17 matched). 15 killed, 2 survived.

mutant killed by
M1 cannot be read back to {path} the in-process absent rows (3)
M2 not UTF-8 at byte back to {path} the in-process not-utf-8 rows (3)
M3 yaml-unparsable back to {path} 31 rows: the in-process unparsable rows (3), the 24 tool rows that are not printable, the older gate's (3) and the doctor's line-feed load-yaml row
M4 policy-unreadable back to {path} the three policy-unreadable rows, and the doctor's line-feed row
M5 validate-pins.py: the raw half of the prefix test dropped gate beside an older library (3), _load with an older library (3)
M6 validate-pins.py: the spelled half dropped gate beside this library (3), _load with this library (3)
M7 family.py: the spelled swap dropped family with this library (3)
M8 family.py: the raw swap dropped family with an older library (3)
M9 doctor naming_script_refusal: the spelled prefix dropped its load-yaml and not-a-mapping rows that are not printable (6)
M10 doctor naming_script_refusal: the raw prefix dropped policy-wrong-kind named raw, U+202E and ESC (2)
M11 less_the_path: the spelled prefix dropped manifest-kinds with this library (3)
M12 less_the_path: the raw prefix dropped manifest-kinds with an older library (3)
M13 load_yaml's named always repr, a printable path too 24 rows: the printable rows of each refusal (3) and each tool (8), the four #192 rows that assert str(refusal).startswith(f"REFUSED yaml-unreadable: {path}"), and printable rows of the readers
M14 policy-unreadable always repr its printable row and the doctor's printable not-a-mapping row (2)
M15 validate-pins.py's spelled half as repr(str(path)) in place of _shown(str(path)) survived, equivalent: for a path that is not printable the two are the same text, and a printable one is caught by the raw half first
M16 link_declarations' replace(str(path), named) removed survived, equivalent: the detail already holds shown_path's spelling, so the swap finds nothing to swap; the row test_a_link_manifest_is_named_in_why_as_it_was is there to say the output did not move
M17 less_the_path returns the detail whole manifest-kinds, both libraries (6) and its printable rows (2)

Runs

Every run through the lane's runner: a stub gh first on PATH (and the suite's own gh guard ahead of it), GH_TOKEN=bogus, an empty GH_CONFIG_DIR, GIT_SSH_COMMAND=false, GIT_CONFIG_GLOBAL=/dev/null, a blank git identity, git-filter-repo 2.47.0 (CI's pin; --version says a40bce548d2c) on PATH, Python 3.12.3 (CI runs 3.14; no row asserts text that depends on the version).

The gate list, 53 files, 4591 tests: the new rows; the hygiene five (test_path_guard_hygiene, test_repo_hygiene, test_agents_md_codes, test_cli_reference, test_pinned_copy_text) and test_no_auto_maintenance; the 46 files under tests/ that hold cannot be read (, not UTF-8 at byte, yaml-unparsable or yaml-unreadable; every validate-pins file (test_pin_validator, test_validate_pins_lockstep_lines, test_validate_pins_without_git beside those the grep found); and for the doctor and family.py readers, test_shape_doctor, test_doctor_validator_verdicts, test_naming_policy, test_family_py_unreadable and test_validate_family_unreadable. One run of more than 100 tests at a time.

run git head result
the gate list 2.43.0 0e5f95db5 4579 passed, 12 skipped, 0 failed (40:38)
the gate list 2.47.1 e39b5d7e3 4586 passed, 5 skipped, 0 failed (46:43)
the new rows, the hygiene five, test_no_auto_maintenance 2.43.0 e39b5d7e3 546 passed, 2 skipped, 0 failed
tests/test_load_yaml_refusals.py 2.43.0 the change before the commit 109 passed
the same, on main's code (5 swaps) and under 17 mutants 2.43.0 51a9c5f10 + this change as in the tables above

e39b5d7e3 differs from 0e5f95db5 by one comment in tests/test_load_yaml_refusals.py (the commit was amended before it was pushed), so the 2.43.0 gate list stands for it, with the small run above on the final head. The 12 skips on 2.43.0: the reftable backend needs git 2.45 (7), the session's GIT_CONFIG_GLOBAL (2), a PowerShell line printed only on Windows (3). On 2.47.1, 5: the last two kinds.

Merges

git merge-tree --write-tree (git 2.47.1) of the head e39b5d7e3 with main and with the head of each open PR on a file this changes, main fetched and heads read with gh pr view N --json headRefOid at about 20:30Z:

with head result
main 77c56dde6 clean. Main moved since 51a9c5f10, in scripts/family.py too (#1117's remedies, not _read_member_project); the predicted merge, checked out alone, passes the new rows, test_pinned_copy_text and main's touched family.py tests (test_family_add_member_id, test_family_bump_to_checks, test_remove_add_remedies_family_root) with test_family_py_unreadable: 336 passed on git 2.43.0
#1266 (scripts/repo_shape.py) 09697e9c5 clean
#1171 (scripts/repo_shape.py, adopt-project.py) 8162b874e clean; it leaves shown_path as it is
#1249 (scripts/repo_shape.py) 3146c3ca5 clean
#1177 (--plan's _shown) 5208e874e clean
#1263, #1248, #1239, #1172 (validate-pins.py; #1248, #1172 also shape-doctor.py) 9ef8f0ab5, 1b747f7ab, fff421456, bbe5b414e clean
#1269 (shape-doctor.py) c792615f4 clean
#1201 (scripts/family.py) 24b6a0af6 clean
#1264 (scripts/family.py) 43d2e8b26 docs/handbook.html conflicts, which this does not touch; against main alone it conflicts there and in five more files
#1164 (scripts/family.py) 74096eb47 tests/test_path_guard_hygiene.py conflicts, which this does not touch; it conflicts there against main alone too

Limits

Closes #1168

Lane: openreposhape-5 (openRepoShape-5)

🤖 Generated with Claude Code

Summary by Sourcery

Sanitize YAML refusal paths and make all dependent readers compatible with both current and older path spellings.

Bug Fixes:

  • Sanitize YAML and policy refusal paths so control characters and other non-printable characters cannot reach terminal output raw.
  • Preserve path-based refusal handling across mixed versions of copied validators and libraries.

Enhancements:

  • Update family and shape-doctor refusal processing to recognize both raw and sanitized path spellings while retaining user-facing relative filenames.
  • Expand refusal coverage across affected tools, path types, version-skew combinations, and non-printable path names.

Tests:

  • Add comprehensive regression tests for sanitized refusal paths, policy errors, affected command-line tools, copied-script version skew, and downstream refusal readers.

…ame the file as `shown_path` does, so a U+202E, an ESC or a line feed in a typed path is spelled, and validate-pins.py, family.py and shape-doctor.py read either spelling (#1168)

`load_yaml` named the file with the bare `{path}` in `yaml-unreadable`
(twice) and `yaml-unparsable`, so a bidi override, an ESC or a line feed in
a `--plan`, `--path-policy`, `--policy`, `--project` or `--root` path
reached the terminal raw from `adopt-project.py check` and `plan`,
`validate-repository-naming.py`, `bump-leg.py`, `family.py bump`,
`update-shape.py check` and `validate-manifest.py`. Each now names it as
`shown_path` does: `repr` for a path that is not printable, byte for byte
for one that is. `policy-unreadable`, the next line of `NamingPolicy.load`,
does the same.

Every reader that finds the path at the start of the detail by its text
asks both the spelled and the raw prefix, so it reads a `repo_shape.py`
from before this change or after it: `validate-pins.py`'s `_load` (a root
re-copies its pinned files one row at a time), `family.py`'s member
manifest (the swap to `<repo> @ <commit>:project.yaml`), and in
`shape-doctor.py` `naming_script_refusal` and the `manifest-kinds` row's
`less_the_path`. `link_declarations`' own swap is unchanged and reads as
it did.

Rows: tests/test_load_yaml_refusals.py.

Closes #1168

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@brettheap brettheap added this to the v1.2.1 milestone Nov 8, 2026
@sourcery-ai

sourcery-ai Bot commented Nov 8, 2026

Copy link
Copy Markdown

Reviewer's Guide

API

Change load_yaml and policy-unreadable to spell unsafe paths with shown_path, documenting the refusal contract alongside the implementation.

  • scripts/repo_shape.py L1340-1351, L1356-1360 (+2 small edits)

Uses of the API

Teach each text-based refusal reader to recognize both the raw and spelled path prefixes, preserving behavior across mixed copy versions.

  • scripts/family.py L1127-1133
  • shape-doctor.py L2763-2774, L4257-4268, L4404
  • templates/assembly-root/scripts/validate-pins.py L653-661, L668-673

Tests

Add refusal, tool-output, compatibility, and reader tests covering unsafe and printable paths across current and older copies.

  • tests/test_load_yaml_refusals.py L20-31, L36-51, L126-557

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

@sonarqubecloud

sonarqubecloud Bot commented Nov 8, 2026

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

1 participant