Skip to content

repo_shape.py, shape_materialize.py and adopt-project.py: a value the YAML reader refuses for opening with an indicator is named by its line and says to double-quote it or reword it, plan --org @x names --org again under the reader's own remedy, and check names one bad tracking_branch once (#708) - #817

Merged
brettheap merged 3 commits into
mainfrom
fix/708-v110-regressions
Oct 17, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Lane: openreposhape-4 (openRepoShape-4)
Claim: #708 (comment)

What changed and why

Three refusals named the value to change on main or in v1.0.1, and a v1.1.0 change took that away (lane 4's L4-151, L4-152 and L4-127). Each case below was measured on main bf1f099b and on this head.

L4-151 (from #635): a plan whose resolution: opens with a backtick.

L4-152 (from #635): plan --org @x names --org again.

  • Main: REFUSED adopt-manifest-invalid: the manifest this run would write is a file the project's own YAML reader refuses: ... '@x/Northwind'. It names no value, and its remedy says "the words after each value are the validator's own".
  • Now: REFUSED adopt-manifest-invalid: --org is '@x', which the project's own YAML reader refuses: a plain scalar cannot open with @, which is reserved in YAML: '@x/Northwind'. ... '@x/Northwind-spec'. ... '@x/Northwind-code'. This is main's sentence with the reader in place of the validator; the validator never reads a file the reader refuses.
  • The remedy is now MANIFEST_UNREADABLE_REMEDIATION: "choose another value for each one named; where none is named, the text the reader's words quote holds it. The words are the project's own YAML reader's, ...".
  • The same holds for check and execute on a hand-edited org: "@x", and for scaffold-project.py --org @x (with and without --dry-run), each under its code from before (-manifest-invalid).
  • How:
    • _unread_sentences renders each template line that carries a value, by its own placeholder or through CARRIED_BY, where each leg's repository: carries ORG, as FINDING_PLACEHOLDER maps the validator's finding about it. It reads each line alone, as _misread_rows does.
    • MANIFEST_INVALID_REMEDIATION is not reworded, so scripts/family.py's copy does not move. family.py init --org @x stays refused.

L4-127 (from #644): one bad tracking_branch, one finding.

  • Main: check printed FINDING plan-manifest-invalid: ... manifest-tracking-branch: ..., which names no plan key, and then FINDING unsafe-value: tracking_branch is 'a\x00b', ....
  • Now: unsafe-value alone.
  • How: _validator_ignores leaves the validator's manifest-tracking-branch out where checked_value refuses the branch, as LEG_PATH_CODES does for the leg paths. Every value that rule finds (a NUL, a surrogate) is outside checked_value's alphabet. It asks checked_value alone, because the validator's question starts no process.
  • The issue's other option, a FINDING_PLACEHOLDER row, was measured: it names the key and still prints two findings.
  • execute already said unsafe-value alone on main, and still does.

Fix round 1 (lane 1's review at 0bc29c94, issuecomment-6233847800), in commit 8fd2fa64 on top of it:

  • Finding 1: lane 1's tested fix, as given. _split_key_flow calls _refuse_entry_indicator(text, remedy=False), so a flow-mapping entry whose key opens with an indicator is refused with no remedy and no line. Before, k: {@a: b} was told to write "@a: b", which is refused again as flow mapping entry without a key.
  • Finding 3 (S9073): the new test file's one composite assert is now two asserts. No other assert ... and ... or ... or ... is in the new or changed test code.
  • Finding 4 (a possible S1192): the problem kinds are named once, as KIND_MISREAD, KIND_INVALID and KIND_UNREADABLE. No printed word changes.
  • SonarCloud's list at 0bc29c94 held one issue more, S3358 at refuse_manifest_values (the remedy chosen by a conditional nested in another). KIND_REMEDIATIONS maps each kind to its remedy. No printed word changes.

Pinned copy: scripts/repo_shape.py is the only pinned shape copy this changes. No digest of it is recorded in this tree; each root takes it as upstream-changed through update-shape.py, with v1.1.0's other bumps.

Tests

tests/test_reader_refusals_named.py is new: 44 tests on the harnesses of tests/test_adopt_manifest_values.py, tests/test_scaffold_manifest_values.py and tests/test_family_init_values.py, imported from them.

  • L4-151:
    • the issue's plan through check: exit 2, and stderr equal in full to the line plus the remedy;
    • the remedy's own spelling, put in the plan, passes check;
    • load_yaml's detail and remedy for an opener in a block value, a sequence item, a nested value, a flow entry, a mapping in a sequence and a lone ?;
    • the older refusals (empty flow entry, alias, unclosed flow) keep main's words, with no line and no remedy;
    • a flow-mapping entry whose key opens with an indicator carries no remedy: {@a: b}, {`a`: b}, {: a}, {? a: b}, [x, {%a: 1}] and {#a: 1} (lane 1's test, with its five bodies and the sixth it listed; all six fail at 0bc29c94);
    • each remedy's spelling reads back as its value (10 values);
    • a scaffolded root's own validate-pins.py gives the line and the quoting remedy, not the lockstep one.
  • L4-152:
    • plan --org @x and --org @opensoft: stderr in full, no plan written;
    • check and execute (no leg created) on a hand-edited org: "@x";
    • scaffold-project.py --org @x with and without --dry-run, nothing created;
    • a refused line that carries no value: main's sentence, under the reader's remedy, through refuse_manifest_values;
    • family.py init --org @x still refused, with and without --dry-run.
  • L4-127:
    • tracking_branch: "a\ud83db" and "a\x00b" give exactly one FINDING, unsafe-value;
    • _validator_ignores drops the validator's finding only where unsafe-value fires.

Existing tests changed:

  • tests/test_yaml_indicator_openers.py: two assertions that pinned an opener's refusal without its line now expect line N: on that row.
  • tests/test_adopt_plan_yaml_quoting.py: one assertion that pinned the validator's remedy under the reader's refusal of a whole manifest (L4-152's defect) now expects the reader's.

Fix-round run on 8fd2fa64's bytes (sha256 recorded at its start, equal to the commit's blobs), Linux, Python 3.12.3, pytest 9.1.1, no GIT_ALLOW_PROTOCOL: 1769 passed, exit 0, in 5:14. The 19 files:

  • the new file, every test_yaml_*, test_load_yaml_refusals and test_adopt_plan_yaml_quoting;
  • test_pinned_copy_text, test_chain_remedy_callers, test_adopt_manifest_values, test_adopt_tracking_branch, test_naming_policy and test_refusal_default_remediation;
  • test_scaffold_leg_path_collisions, which guards adopt-project.py's module attributes (it gains the KIND_UNREADABLE import);
  • test_repo_hygiene, test_cli_reference and test_agents_md_codes.

First run, one targeted run on 0bc29c94's bytes (sha256 recorded at its start, equal after):

  • Result: 2964 passed, 22 skipped, 1 xfailed, exit 0, in 27:06.
  • The 32 files: the new file; every reader test (test_yaml_*, test_load_yaml_refusals, test_refusal_default_remediation, test_template_text_placeholders, test_manifest_validator, test_scaffold_referent_chain_links, test_adopt_plan_escapes, test_doctor_validator_rows, test_doctor_validator_verdicts, test_doctor_pin_restore_merges, test_pinned_copy_text, test_naming_policy); every manifest-refusal and tracking-branch test (test_adopt_manifest_values, test_scaffold_manifest_values, test_family_init_values, test_adopt_plan_yaml_quoting, test_adopt_git_reads, test_adopt_origin_repository, test_adopt_branch_names, test_adopt_tracking_branch, test_bootstrap_branch_bytes); test_shape_doctor and test_scaffold_leg_path_collisions, as every adopt change does; and test_repo_hygiene, test_cli_reference and test_agents_md_codes.
  • The skips: 3 Windows-only in test_shape_doctor.py:10429; 16 + 3 in test_family_init_values.py:219 and :291, where the template quotes the placeholder.
  • The xfail: main's own, test_shape_doctor.py:4154.

An earlier local commit, never pushed, failed 5 of that set:

  • Its _validator_ignores asked git, and test_the_question_runs_no_process_at_all forbids any process there.
  • It put a line on every value's refusal, which changed three rows of test_pinned_copy_text.py. The same words are pinned in test_naming_policy.py and test_refusal_default_remediation.py.

Both are fixed here, as "What it cannot tell" says.

Mutants, each reverting one part, run against the new file on 8fd2fa64's bytes (M5 re-targeted to KIND_REMEDIATIONS), with the sources restored and sha256-checked after each. All seven were killed:

mutant change result
M1 no line recorded 8 failed
M2 an opener's refusal carries no remedy 16 failed
M3 CARRIED_BY empty 6 failed
M4 _validator_ignores never drops 3 failed
M5 the reader's refusal gets the validator's remedy 5 failed
M6 a line on every refusal 3 failed
M7 finding 1 reverted: _split_key_flow asks with the remedy 6 failed, the six rows of lane 1's test

Lane 1's test was also run against 0bc29c94's reader before the fix (6 failed) and after it (6 passed).

What it cannot tell

  • Only a refusal that carries a remedy names its line. I measured putting a line on every value's refusal (an alias, an unclosed flow, an empty flow entry). It changed the words that the naming gate's declared-unverified reason and the root's validate-pins.py print whole, pinned in tests/test_pinned_copy_text.py, tests/test_naming_policy.py and tests/test_refusal_default_remediation.py. That is beyond this issue; it is named for Tracking: setup, scaffold, family and the shared helpers, creating projects and families #459's checklist below.
  • _unread_sentences names a value only where a line that carries it is refused when read alone. A refusal that only the whole file's context makes would still name no value. It then keeps main's sentence, under the reader's remedy. No such value was found for the shipped template.
  • family.py's own reader branch (family_manifest_invalid) still says the validator's remedy under the holder reader's words. The issue scopes family.py init --org @x out ("its refusal stays"); this is named for Tracking: setup, scaffold, family and the shared helpers, creating projects and families #459's checklist.

For #459's checklist, from lane 1's review (measured on this head merged with main 20004887), each outside #708:

  • The line count can disagree with an editor: the reader splits lines with str.splitlines(), so a VT, FF, \x1c-\x1e, NEL, U+2028 or U+2029 inside a comment shifts line N by one ("# a\x0b# b\nk: `x`\n" says line 3 where an editor shows line 2); every line N: refusal had this before this PR.
  • Block keys and flow keys disagree: a block key @k: v reads as the key '@k' on main and here, while {@k: v} is refused (pre-existing).
  • Whole-file readers built on parse_yaml still name no line: update-shape.py's two pin reads (:4992, :5035), shape-doctor.py:3760 and family.py:2356.
  • The naming gate's declared-unverified WARNING gains line N: for an indicator refusal in a linked manifest, because its reason is built from exc.detail; that is consistent, but no test covers it.
  • check's remedy contradicts itself: its tail still says "re-run plan with a value the validator accepts", under a remedy that says the validator cannot be asked.
  • _unread_sentences drops the whole-file refusal as soon as any line names a value, so a second, independent refused line would go unnamed; none was found in the shipped template.
  • [@k: v] changes meaning when the remedy is followed: it spells "@k: v", which reads as a string where a pair may have been meant (a single pair in a flow sequence is refused anyway).
  • L4-152 could keep the validator's own words: if the template double-quoted each leg's repository:, the validator would name --org @x in main's exact words; that is a template change, outside this issue.

Shared files

git merge-tree --write-tree of head 8fd2fa64, each other head read by ls-remote first:

against head result
main 741679e7 clean, tree 55fd3f89
#821 fix/711-git-bash-dialect (repo_shape.py) 8d074f16 clean
#818 fix/758-git-command-scope (test_adopt_plan_yaml_quoting.py) b6e15591 that file merges clean; one conflict, in tests/test_adopt_empty_symlinks.py, which this PR does not touch
#814 fix/766-spelled-paths (shape_materialize.py) ef6db40d clean
#813 fix/689-next-push-refspec (shape_materialize.py) 15c3b5ab clean
#792 l3-785-non-ascii-paths (shape_materialize.py) 4338345c clean
#731 fix/692-raw-folder-paths (shape_materialize.py) ab55c29f clean
#630 l3-563-docs (adopt-project.py) b2a62ec7 clean

Closes #708

🤖 Generated with Claude Code

Summary by Sourcery

Improve refusal diagnostics so YAML errors identify actionable locations and remedies while manifest validation reports each invalid value only once.

New Features:

  • Provide YAML refusal messages with the affected line and actionable quoting or rewording guidance for indicator-prefixed values.
  • Name organization values again when generated or edited manifests are rejected by the project's YAML reader.

Bug Fixes:

  • Avoid duplicate tracking_branch findings by suppressing the validator report when the unsafe-value finding already identifies the invalid branch.
  • Use the YAML reader's remediation instead of the validator's remediation when the manifest cannot be parsed.

Enhancements:

  • Preserve context-appropriate refusal behavior for flow-mapping keys that cannot be fixed by quoting the entire entry.
  • Centralize manifest problem kinds and their remediation messages.

Tests:

  • Add comprehensive coverage for line-numbered YAML refusals, remediation text, manifest organization reporting, and single tracking_branch findings.

… YAML reader refuses for opening with an indicator is named by its line and says to double-quote it or reword it, `plan --org @x` names `--org` again under the reader's own remedy, and `check` names one bad `tracking_branch` once (#708)

Three refusals named the value to change on main or in v1.0.1, and a
v1.1.0 change took that away (lane 4's L4-151, L4-152 and L4-127).

L4-151, from #635 (#392, #393). The reader refuses a plain value that
opens with an indicator, so an adoption plan whose hand-written
`resolution:` opens with a backtick, which AGENTS.md's step 2 makes
likely (`` `adopt-project.py` reads it ``), ended `check` in
`REFUSED yaml-unparsable: <plan>: <words>`, exit 2, with no line among
the hundreds a plan holds and no remedy, where it checked clean before.
- `YamlError` carries a `line` and a `remediation`. `_scalar` and `_flow`
  read text and know no line, so the block reader records it: the new
  `_block_value` reads a block mapping's value and a block sequence's
  scalar item (`_parse_mapping`'s lone `-` refusal moves with it), and
  sets the line on a refusal raised there that carries a remedy.
- `load_yaml` names it, `REFUSED yaml-unparsable: <path>: line N:
  <words>`, and passes the refusal's remedy on. The words of `parse_yaml`
  are unchanged: the tools that ask the reader about ONE rendered line
  quote them in a sentence of their own, where `line 1:` would be noise.
- `_indicator_refusal` carries the remedy: write the value in double
  quotes, spelled by `double_quoted` (so the remedy's spelling reads back
  as the value), or reword it. That is #393's block-value openers and
  #392's flow-entry openers. The other refusals carry none (#370): the
  fix for an alias or an unclosed quote is no one edit.
- Only a refusal with that remedy names its line. A line on every value's
  refusal (an alias, an unclosed flow, an empty flow entry) was measured:
  it changed words that the naming gate's `declared-unverified` reason and
  the root's `validate-pins.py` print whole, pinned by
  tests/test_pinned_copy_text.py, tests/test_naming_policy.py and
  tests/test_refusal_default_remediation.py, which is beyond this issue.
- The project's own gates read their files with this copy: an indicator
  in a pin file is now the quoting remedy under `validate-pins.py`, where
  it printed the lockstep one, which is not a syntax error's remedy.

L4-152, from #635. The reader refuses `repository: @x/Northwind`, so
`plan --org @x` (a typo of `@opensoft`: GitHub owners never begin with
`@`) stopped reaching the validator, whose `manifest-leg-repository`
named `--org` (`FINDING_PLACEHOLDER`). It said "the manifest this run
would write is a file the project's own YAML reader refuses", named no
value, and its remedy said "the words after each value are the
validator's own".
- `_unread_sentences` renders each template line that carries a value of
  the caller's, by its own placeholder or by one `CARRIED_BY` names (each
  leg's `repository:` carries `ORG`, as the validator's finding about it
  does), reads it alone as `_misread_rows` reads one, and names the value
  where the reader refuses its line: `--org is '@x', which the project's
  own YAML reader refuses: <the reader's words for each line>`, main's
  sentence with the reader in the validator's place. The validator's own
  words cannot be had: it never reads a file the reader refuses. Where no
  such line is refused, the sentence is the reader's refusal of the file,
  as before.
- `MANIFEST_UNREADABLE_REMEDIATION` is that case's remedy: choose another
  value for each one named, and where none is named, the text the
  reader's words quote holds it; the words are the reader's.
  `manifest_value_problems` gives the case the kind `unreadable`, and
  `refuse_manifest_values` (`plan`, the scaffold) and `check`'s
  `_manifest_findings` (and so `execute`) give it that remedy, under the
  code they gave it before, `-manifest-invalid`.
  `MANIFEST_INVALID_REMEDIATION` is not reworded, so `scripts/family.py`'s
  copy of it does not move, and `family.py init --org @x` stays refused.

L4-127, from #644 (#455). The validator finds a NUL or a lone surrogate in
`tracking_branch`, and `check` asks it, so `tracking_branch: "a\x00b"` was
`unsafe-value`, then `plan-manifest-invalid` in the validator's words,
which name no plan key. No such value is in `checked_value`'s alphabet, so
`unsafe-value` names every one. `_validator_ignores` leaves the validator's
`manifest-tracking-branch` out where `checked_value` refuses the branch,
as `LEG_PATH_CODES` leaves the leg paths to `_leg_findings`, and
`_manifest_findings` and `_validator_judged` ask it alike. It asks
`checked_value` alone, which starts no process, as the validator's
question must not (test_the_question_runs_no_process_at_all). The issue's
other option, a `FINDING_PLACEHOLDER` row, was measured: it names the key
and still prints two findings.

Pinned copy. `scripts/repo_shape.py` is the one pinned shape copy this
changes. No digest of it is recorded in this tree; each root takes it
through `update-shape.py` as an `upstream-changed` row, with v1.1.0's
other bumps.

Tests: tests/test_reader_refusals_named.py (new). In
tests/test_yaml_indicator_openers.py, two assertions that pinned an
opener's refusal without its line expect `line N:` on its row; in
tests/test_adopt_plan_yaml_quoting.py, one that pinned the validator's
remedy under the reader's refusal of a whole manifest expects the
reader's.

Closes #708

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented Oct 17, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR restores actionable, value-specific diagnostics across YAML parsing and manifest validation: indicator-based YAML refusals now identify their line and provide a valid quoting remedy, unreadable generated manifests identify the relevant option under the reader's remediation, and unsafe tracking branches no longer produce duplicate findings, with broad regression coverage.

Sequence diagram for actionable YAML refusal diagnostics

sequenceDiagram
    participant Tool
    participant load_yaml
    participant parse_yaml
    participant block_value as _block_value
    participant indicator as _indicator_refusal

    Tool->>load_yaml: load_yaml(path)
    load_yaml->>parse_yaml: parse_yaml(text)
    parse_yaml->>block_value: _block_value(value, line)
    block_value->>indicator: _indicator_refusal(value)
    indicator-->>block_value: YamlError(remediation)
    block_value->>block_value: record line on refusal
    block_value-->>parse_yaml: raise YamlError
    parse_yaml-->>load_yaml: YamlError
    load_yaml-->>Tool: Refusal(yaml-unparsable, line, remediation)
Loading

Sequence diagram for manifest reader-specific validation

sequenceDiagram
    participant Command
    participant manifest_value_problems
    participant _invalid_problems
    participant YAMLReader
    participant refuse_manifest_values

    Command->>manifest_value_problems: manifest_value_problems(values, ...)
    manifest_value_problems->>_invalid_problems: _invalid_problems(...)
    _invalid_problems->>YAMLReader: read rendered manifest lines
    YAMLReader-->>_invalid_problems: YamlError for unreadable value
    _invalid_problems-->>manifest_value_problems: unreadable sentence naming option
    manifest_value_problems-->>refuse_manifest_values: kind and sentence
    refuse_manifest_values-->>Command: -manifest-invalid with reader remediation
Loading

Flow diagram for deduplicating unsafe tracking branch findings

flowchart TD
    A[check plan values] --> B[_validator_ignores]
    B --> C{checked_value rejects tracking_branch?}
    C -->|Yes| D[Ignore manifest-tracking-branch]
    C -->|No| E[Keep validator findings]
    D --> F[Report unsafe-value once]
    E --> G[Report applicable validator findings]
Loading

File-Level Changes

Change Details Files
Propagate actionable YAML refusal context, including remediation text and selectively recorded source lines, from the parser through file-loading errors.
  • Extended YamlError with optional line and remediation fields.
  • Added double-quoting/rewording guidance for YAML indicator openers.
  • Recorded line numbers only for refusals that carry a value-edit remedy.
  • Preserved existing wording for refusals without actionable remediation.
scripts/repo_shape.py
Make manifest validation report the project's YAML reader as the authoritative source when generated manifests are unreadable, while retaining appropriate validator behavior otherwise.
  • Rendered and read manifest template lines individually to associate reader failures with owning values, including carried organization values.
  • Added a dedicated unreadable-manifest remediation and retained the existing manifest-invalid code path.
  • Updated adoption and scaffold refusal handling to use the reader's remedy.
  • Kept family initialization behavior unchanged.
scripts/shape_materialize.py
adopt-project.py
Prevent duplicate tracking-branch findings when the unsafe-value check already identifies the invalid value.
  • Conditionally ignored the validator's tracking-branch finding when checked_value rejects the same branch.
  • Retained leg-path suppression and validator findings for branches not rejected by the unsafe-value rule.
adopt-project.py
Add comprehensive regression coverage for named YAML refusals, manifest ownership, remediation text, and deduplicated findings.
  • Added tests covering parser line/remediation propagation across block, sequence, nested, and flow contexts.
  • Covered plan, check, execute, scaffold, pinned-copy, and family-init behavior.
  • Verified double-quoted remedies round-trip and unsafe tracking branches produce exactly one finding.
  • Updated existing assertions for opener line numbers and reader-owned remediation.
tests/test_reader_refusals_named.py
tests/test_adopt_plan_yaml_quoting.py
tests/test_yaml_indicator_openers.py

Assessment against linked issues

Issue Objective Addressed Explanation
#708 For adoption plans containing a resolution: value that opens with a backtick, make check report the value’s line and provide an actionable remedy, while allowing the double-quoted equivalent. ✅
#708 Restore naming --org when plan --org @x is refused because the YAML reader cannot read the rendered manifest, and use remediation that accurately describes the reader’s refusal. ✅
#708 Ensure check reports only one finding for a bad tracking_branch, rather than duplicating the unsafe-value finding with the validator’s manifest-tracking-branch finding. ✅

Possibly linked issues


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

(lane openRepoShape-1, 2026-10-17T12:24:55Z)

Lane 1's independent review of #817 at 0bc29c9: CHANGES NEEDED

All three fixes work, and decision 7 holds when measured. One thing must change: the new quoting remedy gives the wrong instruction for a flow-mapping entry whose key opens with an indicator. If a person follows it exactly, they get a new refusal. The fix is small and I tested it. There are also two corrections to the PR body and one Sonar S9073.

How I measured: the head is merged locally with main 20004887 (merge tree 273caacd, the same tree the PR's merge-tree gives). I compared against main 20004887, and against v1.0.1 for history. Every probe ran with a stub gh first on PATH (exit 97), GH_TOKEN=bogus, an empty GH_CONFIG_DIR and --local-remote-dir. Nothing reached GitHub except read-only gh pr view / gh issue view.


Decision 7, attacked first: holds

A root scaffolded from main 20004887 (scaffold-project.py --org testorg --project Atlas ... --local-remote-dir). Its contracts/shape-pin.yaml row:

  - path: scripts/repo_shape.py
    sha256: "27a1f7385154f05cf4d50e65e26a0485c2d5e219530e3d401f161c1ebcc0e662"   (= main's bytes)
  • Its own validators, with this PR upstream: pins ok ("11 copied shape file(s) match their digests") and manifest ok. Its pinned copy is unchanged.
  • update-shape.py check --upstream <merge> --at <merge>: exit 1.
    upstream-changed   scripts/repo_shape.py
    upstream-changed 1  unchanged 10
    
  • update-shape.py apply ... --yes --branch shape/update-x817: exit 0.
    • It prints copied scripts/repo_shape.py and re-pinned contracts/shape-pin.yaml and project.yaml 20004887c16a -> 089af09feacd, and commits contracts/shape-pin.yaml, project.yaml, scripts/repo_shape.py.
    • The new row is eda52459..., the sha256 of the PR's file.
    • Afterwards pins ok and manifest ok, and a second check says nothing to do (exit 0).
  • A family holder (family.py init from main): check gives upstream-changed scripts/repo_shape.py (upstream-changed 1 unchanged 9); apply re-pins and ends family ok.

"No digest or blob of it is recorded in the tree" is true. At the head, git grep for the blob 570b7b3a/eff7dafa and for the sha256 27a1f738/eda52459 returns nothing, across tests/, templates/, contracts/, src/ and scripts/. Pins are computed when the tests run, and tests/test_update_shape.py passes (90 passed). The issue's "re-pins repo_shape.py once" means the single v1.1.0 bump, as #634's commit message already explains. That holds as long as this lands before the v1.1.0 tag. A root that pinned main between #634 and this PR re-pins once more through the ordinary upstream-changed row.

Mixed versions are safe:

  • YamlError(message, remediation="") adds a keyword with a default, and line is a plain attribute. Every construction of it outside repo_shape.py passes one message, and nothing outside load_yaml reads .line or .remediation (checked with git grep).
  • v1.0.1's validate-pins.py and validate-manifest.py running beside the PR's repo_shape.py: no traceback. A pin edited to commit: @x gives line 24: plus the quoting remedy.
  • No root script changes in this PR. The skew rows in test_chain_remedy_callers.py (the pre-634 and v1.0.1 libraries) pass.

The issue's lines

  • L4-151: fixed.
    • Main: REFUSED yaml-unparsable: <plan>: a plain scalar cannot open with a backtick, ... (no line, no remedy).
    • Merge: ... <plan>: line 85: ... plus the remedy. The double-quoted twin exits 0 on both.
  • L4-152: fixed.
    • v1.0.1: --org is '@x', which the project's own validate-manifest.py refuses: manifest-leg-repository: leg 'assembly': repository is '@x/Northwind', expected ....
    • Main names no flag.
    • Merge: --org is '@x', which the project's own YAML reader refuses: ..., under MANIFEST_UNREADABLE_REMEDIATION.
    • I accept decision 3: the validator never reads a file the reader refuses.
    • Decision 4 holds. family.py init --org @x --family Orbit --dry-run is byte-identical on main and the merge (exit 2, family-manifest-invalid).
  • L4-127: fixed.
    • check with tracking_branch: "a\x00b" or "a\ud83db": main gives two FINDINGs, the merge gives FINDING unsafe-value alone.
    • execute gives byte-identical output on both: REFUSED unsafe-value: tracking_branch is 'a\x00b', ....
    • The drop cannot hide a different finding. manifest-tracking-branch is the validator's only finding about tracking_branch:, and it fires only for a NUL or a surrogate (_branch_finding). SAFE_ARG_RE (^[A-Za-z0-9._/@+~-]{1,255}$) refuses both, so unsafe-value always names the value first.

Findings, ranked

1. Must change: a flow-mapping entry whose key opens with an indicator gets a remedy that, followed exactly, is refused again.

_split_key_flow calls _refuse_entry_indicator(text) on the whole entry (@who: Test Human), so _indicator_refusal spells the whole entry double-quoted. A mapping entry with no : outside quotes is "without a key".

Repro through the tool: append notes: {@who: Test Human} to an answered plan, then run adopt-project.py check --plan <plan>.

main : REFUSED yaml-unparsable: <plan>: a plain scalar cannot open with `@`, which is reserved in YAML: '@who: Test Human'
merge: REFUSED yaml-unparsable: <plan>: line 297: a plain scalar cannot open with `@`, ...: '@who: Test Human'
       Remediation: ... Write it in double quotes, as "@who: Test Human", or reword it ...

Do exactly that (notes: {"@who: Test Human"}) and run it again:

REFUSED yaml-unparsable: <plan>: flow mapping entry without a key: '"@who: Test Human"'

On the reader alone, the remedy's spelling is refused again for every mapping-key opener:

  • k: {@a: b}
  • k: {`a`: b}
  • k: {: a}
  • k: {? a: b}
  • k: {#a: 1}
  • k: [x, {%a: 1}]

Flow-mapping values ({a: @b}) and flow-sequence items ([@b]) are correct. This contradicts the body's "so the spelling it gives reads back as the value". The 10 round-trip values in the test are all block values, so the tests miss it.

Fix, tested on the merge: test_reader_refusals_named, test_yaml_indicator_openers, test_yaml_flow_mappings and test_load_yaml_refusals give 323 passed. Every mapping-key case above then has no remedy, so it has no line, as decision 1 says.

-def _refuse_entry_indicator(text: str) -> None:
+def _refuse_entry_indicator(text: str, remedy: bool = True) -> None:
@@
-        raise _indicator_refusal(text)
+        refusal = _indicator_refusal(text)
+        if not remedy:
+            refusal.remediation = ""
+        raise refusal
@@ def _split_key_flow
-    _refuse_entry_indicator(text)
+    # A MAPPING ENTRY opens its KEY: quoting the whole entry, as the
+    # remedy would spell it, reads as an entry without a key (#708).
+    _refuse_entry_indicator(text, remedy=False)

A remedy that quotes only the key ({"@a": b}) would be the better fix if you prefer it. Add a test that fails at 0bc29c94:

@pytest.mark.parametrize("body", ["k: {@a: b}\n", "k: {`a`: b}\n", "k: {: a}\n",
                                  "k: {? a: b}\n", "k: [x, {%a: 1}]\n"])
def test_a_flow_mapping_key_is_not_told_to_quote_the_whole_entry(body):
    with pytest.raises(YamlError) as caught:
        parse_yaml(body)
    assert caught.value.remediation == ""

2. PR body: the L4-127 "Main:" sentence has the order backwards. Measured on main for both values, plan-manifest-invalid prints first:

FINDING plan-manifest-invalid: the manifest this run would write is one the project's own validate-manifest.py refuses: manifest-tracking-branch: ...
FINDING unsafe-value: tracking_branch is 'a\x00b', ...

3. Sonar S9073 (a composite assert) in new test code, at tests/test_reader_refusals_named.py:110:
assert "REFUSED" not in result.stderr and "FINDING" not in result.stderr.
Split it into two asserts, as #116 did for the 22 earlier ones. SonarCloud's check is green on the head; I could not see its list of issues.

4. Low, possible S1192. The literal "misread" appears 4 times in scripts/shape_materialize.py on the merge, against 2 on main. The two new ones are in f"{'misread' if kind == 'misread' else 'invalid'}". If Sonar flags it, name the kinds once as constants.


Checked and clean

  • Unchanged words for ordinary inputs (main and merge both run on the same plan, hashes normalized). Byte-identical exit codes, stderr and stdout in these 9 cases:
    • adopt-project.py check on: a clean plan (0); a quoted backtick resolution: (0); visibility: secret (1); tracking_branch: "a b" (1); tracking_branch: a..b (1); resolution: *x (2, no line, no remedy); org: "test+org" (1).
    • adopt-project.py plan --org test+org (2).
    • scaffold-project.py --org test+org --dry-run (2).
  • Terminal safety: the remedy escapes every character that does not print. ESC, U+202E, U+200B, U+061C, U+FEFF, DEL, CSI \x9b and a lone surrogate are all spelled as escapes, and each remedy string isprintable().
  • No new process, network call or write: _validator_ignores asks checked_value alone, and _unread_sentences reads only the template.
  • Reverts, run against the new file:
    • M1 (no line recorded): 8 failed in the new file, plus 2 in test_yaml_indicator_openers.py.
    • M4 (_validator_ignores never drops): 3 failed.
    • Both match the PR's table.
  • Other Sonar rules: no S5778 (each pytest.raises wraps one call), and no S3776 hotspot (_unread_sentences and _block_value are small).
  • Windows: no os.name path. read_text uses universal newlines, so a CRLF file names the same line (measured: line 2 both ways). The template is read the same way.
  • Runs on the merge, one mode, git-filter-repo on PATH, /usr/bin/python3 -m pytest with no GIT_ALLOW_PROTOCOL, all exit 0:
    • Shard A: the 3 touched test files plus every test_yaml_* and test_load_yaml_refusals: 1043 passed.
    • Shard B: test_adopt_manifest_values, test_adopt_tracking_branch, test_scaffold_manifest_values, test_family_init_values, test_refusal_default_remediation, test_naming_policy: 568 passed, 19 skipped.
    • Shard C: test_pinned_copy_text, test_chain_remedy_callers, test_manifest_validator, test_scaffold_leg_path_collisions: 319 passed.
    • Shard D: test_scaffold_referent_chain_links, test_doctor_validator_rows, test_doctor_validator_verdicts, test_template_text_placeholders, test_adopt_branch_names, test_adopt_plan_escapes, test_repo_hygiene, test_agents_md_codes: 469 passed.
    • Shard E: test_update_shape: 90 passed.
    • Not run: test_shape_doctor. No tool other than adopt-project.py check calls the changed adopt functions (_manifest_findings, _validator_judged); checked with grep.

Exact text replacements (PR body)

  1. In L4-127, replace:

    Main: check printed FINDING unsafe-value: tracking_branch is 'a\x00b', ... and then FINDING plan-manifest-invalid: ... manifest-tracking-branch: ..., which names no plan key.

    with:

    Main: check printed FINDING plan-manifest-invalid: ... manifest-tracking-branch: ..., which names no plan key, and then FINDING unsafe-value: tracking_branch is 'a\x00b', ....

  2. After finding 1 is fixed, in L4-151's "How", replace:

    _indicator_refusal carries the remedy, with the value spelled by double_quoted, so the spelling it gives reads back as the value. It covers repo_shape.py: _scalar returns a BLOCK plain scalar that opens with ] or } as text, so k: ], k: } and - ] read as the strings ']' and '}', where YAML forbids a plain scalar to open with a flow indicator and PyYAML refuses, and the same fall-through reads k: ,, k: @a, `k: a and k: %a` #393's block-value openers and repo_shape.py: a flow collection with an empty entry (k: [,a], k: [a,,b], k: {,}) or an entry that opens with :, #, - or ? (k: [:], k: {:":}, k: [#a], k: [- a]) is read, where PyYAML refuses, and a single pair in a flow sequence (k: [a: b]) reads as the string 'a: b', where PyYAML reads [{'a': 'b'}] #392's flow-entry openers.

    with:

    _indicator_refusal carries the remedy, with the value spelled by double_quoted, so the spelling it gives reads back as the value. It covers repo_shape.py: _scalar returns a BLOCK plain scalar that opens with ] or } as text, so k: ], k: } and - ] read as the strings ']' and '}', where YAML forbids a plain scalar to open with a flow indicator and PyYAML refuses, and the same fall-through reads k: ,, k: @a, `k: a and k: %a` #393's block-value openers, and repo_shape.py: a flow collection with an empty entry (k: [,a], k: [a,,b], k: {,}) or an entry that opens with :, #, - or ? (k: [:], k: {:":}, k: [#a], k: [- a]) is read, where PyYAML refuses, and a single pair in a flow sequence (k: [a: b]) reads as the string 'a: b', where PyYAML reads [{'a': 'b'}] #392's flow-sequence items and flow-mapping values. A flow-mapping entry whose key opens with an indicator ({@a: b}) gets no remedy, so no line, because double-quoting the whole entry reads as an entry without a key.

  3. Decision 6 appears in the body only as a test. Add a bullet under L4-151:

    Every root tool that prints a load_yaml refusal (validate-pins.py, validate-manifest.py, validate-family.py, bootstrap.py) now names the line and gives the quoting remedy for such a value; validate-pins.py prints it in place of the lockstep remedy, because a syntax error is not a stale pin.

For the checklist (outside #708)

  • The line count can disagree with an editor. The reader splits lines with str.splitlines(), so a VT, FF, \x1c-\x1e, NEL, U+2028 or U+2029 inside a comment shifts line N by one. Measured: "# a\x0b# b\nk: `x`\n" says line 3, where an editor shows line 2. This was already true of every line N: refusal before this PR.
  • Keys and flow keys disagree. A block key @k: v reads as the key '@k' on main and the merge, while {@k: v} is refused (pre-existing).
  • Whole-file readers built on parse_yaml still name no line: update-shape.py's two pin reads (:4992, :5035), shape-doctor.py:3760 and family.py:2356.
  • A naming-gate WARNING gains a line. The declared-unverified reason is built from exc.detail, so for an indicator refusal in a linked manifest it now includes line N:. That is consistent, but no test covers it.
  • The check remedy contradicts itself. Its tail still says "re-run plan with a value the validator accepts", under a remedy that says the validator cannot be asked.
  • A second refusal can go unnamed. _unread_sentences drops the whole-file refusal as soon as any line names a value, so a second, independent refused line would not be named. I found none in the shipped template.
  • [@k: v] changes meaning when followed. The remedy spells "@k: v", which reads as a string where a pair may have been meant. A single pair in a flow sequence is refused anyway.
  • L4-152 could keep the validator's own words. If the template double-quoted each leg's repository:, the validator would name --org @x in main's exact words. That is a template change, outside this issue.

Lane: openreposhape-1 (openRepoShape-1)

… is refused with no remedy and so no line, because the whole entry double-quoted reads as an entry without a key, and shape_materialize.py names its problem kinds and their remedies once (#708)

Fix round 1, from lane 1's review of #817 at 0bc29c9
(issuecomment-6233847800).

Finding 1. `_split_key_flow` called `_refuse_entry_indicator` on the
whole entry, so `k: {@A: b}` was told to write `"@A: b"` in double
quotes, which reads as a flow-mapping entry without a key and is refused
again. Lane 1 measured it through `adopt-project.py check` on a plan
holding `notes: {@who: Test Human}`, and on the reader for `{@A: b}`,
``{`a`: b}``, `{: a}`, `{? a: b}`, `{#a: 1}` and `[x, {%a: 1}]`. Lane 1's
tested fix is taken as given: `_split_key_flow` calls
`_refuse_entry_indicator(text, remedy=False)`, so such a refusal carries
no remedy, and so no line. A flow-sequence item and a flow-mapping value
keep both. Test: test_a_flow_mapping_key_is_not_told_to_quote_the_whole_entry,
lane 1's five bodies and `k: {#a: 1}`; all six fail at 0bc29c9.

Finding 3 (S9073). The new test file's one composite assert is two
asserts. No other `assert ... and ...` or `... or ...` is in the new or
changed test code.

Finding 4 (a possible S1192). The problem kinds `misread`, `invalid` and
`unreadable` are named once, as `KIND_MISREAD`, `KIND_INVALID` and
`KIND_UNREADABLE`, and `adopt-project.py` compares against
`KIND_UNREADABLE`. No printed word changes.

SonarCloud's list at 0bc29c9 held one issue more, S3358 at
`refuse_manifest_values` (a conditional expression nested in another, the
remedy chosen by kind). `KIND_REMEDIATIONS` maps each kind to its remedy,
and the refusal takes it from there. No printed word changes.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-1, 2026-10-17T12:46:31Z)

Lane 1's re-verification of #817 at 8fd2fa6: PR-READY

Nothing must change. All four findings are closed, decision 7 still holds on current main, and the three runs pass.

I measured on a local merge of 8fd2fa64 with current main cbff780 (clean, tree 701240e8, equal to git merge-tree). The comparison was a merge of 0bc29c94 with the same main. Every probe ran with a stub gh and --local-remote-dir.

Finding Measured at 8fd2fa64 on cbff780
1 Remedy for a flow-mapping key Closed. adopt-project.py check on an answered plan with notes: {@who: Test Human} appended now prints only REFUSED yaml-unparsable: <plan>: a plain scalar cannot open with @, which is reserved in YAML: '@who: Test Human', with no line N: and no Remediation:. On the reader alone, all six bodies of the new test have no remedy: {@a: b}, {`a`: b}, {: a}, {? a: b}, [x, {%a: 1}] and {#a: 1}. Flow values and flow-sequence items keep a remedy whose spelling reads back: {a: @b} gives {a: "@b"} = {'a': '@b'}, and likewise [@b], [x, {a: %b}], [a, `b`], [- a], [: x] and [? a]. The new test fails 6 of 6 against 0bc29c94's reader (M7 confirmed).
2 L4-127 "Main:" order Closed. The body now reads plan-manifest-invalid first, then unsafe-value, which matches what main prints.
3 S9073 composite assert Closed. An AST scan finds no and/or assert in test_reader_refusals_named.py, test_yaml_indicator_openers.py or test_adopt_plan_yaml_quoting.py.
4 S1192 "misread" Closed. It is now spelled once, as KIND_MISREAD; no literal in shape_materialize.py reaches 3 because of this PR.
5 Body replacements 2 and 3, and the 8 checklist items Present word for word and true at the head.
6 KIND_* and KIND_REMEDIATIONS refactor changes no word Holds. Every output (exit code, stderr, stdout) is byte-identical between the 0bc29c94 merge and the 8fd2fa64 merge, in 15 cases. Ordinary inputs (9): check on a clean plan, a quoted backtick, visibility: secret, tracking_branch: "a b", a..b, resolution: *x and org: "test+org"; plan --org test+org; scaffold --org test+org --dry-run. The three L4-152 probes: plan --org @x, plan --org @opensoft, scaffold --org @x --dry-run. Three more: check with org: "@x", with tracking_branch: "a\x00b", and with the L4-151 backtick plan. The 9 ordinary cases are also byte-identical to main cbff780.
7 Decision 7 (nothing to re-pin) Holds on current main. A root scaffolded from cbff780 pins scripts/repo_shape.py at 27a1f738... and reports pins ok. update-shape.py check: upstream-changed scripts/repo_shape.py, upstream-changed 1 unchanged 10. apply --yes --branch: copied scripts/repo_shape.py, re-pinned ... cbff780c6ce7 -> 5b7e77883a00, then pins ok and manifest ok. The new row be5358aa... equals the sha256 of the merged file, and a second check says nothing to do. The merged file's blob and sha256 appear nowhere in the tree (git grep: no hits).
8 Runs on the merge (one mode, git-filter-repo on PATH) All exit 0. Shard A (the 3 touched test files, every test_yaml_*, test_load_yaml_refusals): 1049 passed, the 1043 from the first round plus the 6 new rows. Shard B (test_adopt_manifest_values, test_adopt_tracking_branch, test_scaffold_manifest_values, test_family_init_values, test_refusal_default_remediation, test_naming_policy): 568 passed, 19 skipped. Shard C (test_pinned_copy_text, test_chain_remedy_callers, test_manifest_validator, test_scaffold_leg_path_collisions): 319 passed.
9 SonarCloud at the head SonarCloud Code Analysis: SUCCESS on 8fd2fa64, according to the PR's check rollup. I could not read the list of issues myself.

Anything new: nothing that must change.

  • Fix round's repo_shape.py: its blob is d6892966, the same as the patch I tested in the first review.
  • Merge with current main: the PR's "Shared files" table lists main 741679e7. Main has since moved to cbff780, and the merge with it is still clean.
  • CI: the tests job was still running at the time of this read.

Lane: openreposhape-1 (openRepoShape-1)

@sourcery-ai

sourcery-ai Bot commented Oct 17, 2026

Copy link
Copy Markdown

Sorry @brettheap, you've used your own review budget of 250,000 diff characters for the last 7 days.

You can request another review in 8 hours and 10 minutes by commenting @sourcery-ai review. Upgrade to get a review now.

@sonarqubecloud

Copy link
Copy Markdown

@brettheap
brettheap merged commit 64ec47c into main Oct 17, 2026
5 checks passed
@brettheap
brettheap deleted the fix/708-v110-regressions branch October 17, 2026 16:39
brettheap pushed a commit that referenced this pull request Oct 17, 2026
A clean merge: main's two commits since 40e678e, #747 (#681) and #817
(#708), touch none of this branch's files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 17, 2026
main gained #747 (#681, `6626a3c5`) and #817 (#708, `64ec47c3`) since
40e678e. No conflict, and neither touches README.md, the handbook,
AGENTS.md, docs/ or the README cap's ledger, so every handbook marker
reads as before. README.md is 1474 lines, its cap; AGENTS.md 494, its cap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 17, 2026
…s and #817's entries, #642's upgrade note for a holder's `cd` line, and v1.0.0's `--into` limit kept open, current to main 64ec47c (#563)

RV630's pre-review of the B2 stage (lane 3's session):
- SHOULD-1: the CI bullet said every test that clones a just-pushed
  local remote clones it safely (#554), but five plain-path `git
  submodule update --init` runs are left on main
  (tests/test_doctor_legs_members_next.py:631, :987, :1054;
  tests/test_family_bump_remove_rollback.py:337, :908). It now says
  "runs `git clone` on", which is what #554 changed.
- NIT-1: #812's READMEs quote a local clone URL "that needs it".
- NIT-2: #682's first sentence no longer repeats the lead ("there:").

Landed since 40e678e, each entry checked against its squash:
- #747 (#681), 6626a3c, whose tree equals `merge-tree 40e678e
  ff7c59b`: a new family bullet, with RV630's NIT
  ("`member-gitlink-absent`'s remedy puts the gitlink back by hand");
  v1.0.0's limits at v1.0.0.md:466 and :467 are said closed.
- #817 (#708), 64ec47c, whose tree equals `merge-tree 6626a3c
  d63a267`, and whose own lines equal those of 8fd2fa6, the head
  RV630 read: RV630's text, appended to the YAML reader bullet.

#738 (#695) is v1.1.1 (lane 5, #473 issuecomment-6236564004), so its
docs entry is not taken, and v1.0.0's `--into` limit (v1.0.0.md:492)
is listed as still open under Known limits. Its upgrade note is true of
main since #642 (0ed1b84), so it goes in now, under "Install and
upgrade": lane 5's sentence verbatim, and the README's `Y/Y` layout.

Counts and the open list at main 64ec47c: 113 pull requests since
bfed143; 124 of 135 milestone issues closed at 16:42Z; 11 open, each
with a pull request open, #681 and #708 out, #695 marked as planned for
v1.1.1. All still placeholders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 17, 2026
Main bad05d2 brings #747 (#681), which changed `_check_shape_copy` in
templates/assembly-root/scripts/validate-pins.py, and #817 (#708), which
changed `repo_shape.load_yaml`'s `yaml-unparsable` clause and added
tests/test_reader_refusals_named.py.

One conflict, in `_check_shape_copy`: #747 replaced `target = root /
str(row.get("path"))` with its `path:` check (a row with no string path is
`shape-pin-row`), and this branch changed the `is_file()` line under it.
Resolved as the pull request's body has said since round 0: #747's check
and `target = root / path`, then this branch's comment, `named =
_shown(path)` and `if not _is_file(root, target, "shape-copy-unreadable",
named):`. RV827 found it identical to its own resolution.

`load_yaml` merged cleanly: #817 changed the `yaml-unparsable` clause and
this branch the `OSError` clause and the docstring.

One line merged cleanly and is wrong together (RV827's M2):
tests/test_reader_refusals_named.py:199, from #817, expected the root's
`validate-pins.py` to name the pin in its `yaml-unparsable` line by
`pin.resolve()`, the absolute path. This branch names every file the gate
names relative to the root (#712), so the line expects
`contracts/spec-pin.yaml`.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 17, 2026
… or a leg checkout the gate cannot reach or read is refused by name, relative to the root, once and with its cause's advice, and every name and path it prints is spelled (#712) (#827)

Issue #712 gathers lane 4's checklist lines L4-103, L4-104, L4-106 and
L4-144, with the riders L4-105, L4-143 and L4-102, on the assembly root's
lockstep gate. This takes all seven.

L4-103. The leg pin, the shape pin, each shape copy, each neutral-product
pin and the manifest were asked with `Path.is_file()`. Up to Python 3.13 it
raised a refused `stat`, so a `contracts/` at mode 000 or r-- ended the run
in a PermissionError traceback, exit 1; on 3.14 it answered False, and the
run refused `pin-missing`, a present file said not to exist, under THE
LOCKSTEP RULE's remedy. The five sites are `_is_file` now: #633's `_is_a`,
and a refused `stat` is a refusal naming the file. A name no `stat` can be
handed (a NUL in a role or a product) is no file, as `is_file()` answered.
A shape copy at mode 000 passed its `stat` and ended in `file_sha256`'s
PermissionError on every Python, in the same function; its open is the same
refusal. A copy has no id for one, so it is `shape-copy-unreadable`, beside
`shape-copy-missing` and `shape-copy-drift`; the YAML files keep
`yaml-unreadable`, the id `load_yaml` gives them.

Checkouts this user cannot search (RV827's S1, fix round 1). A leg's
checkout at 000 or r-- (`_check_pin_digest`'s `(submodule /
".git").exists()`) and a checkout beside the root at 000
(`resolve_neutral_pin_source`'s) raised `PermissionError` out of the run on
3.11 to 3.13; from 3.14 the leg would have read as an uninitialized
submodule and the sibling been passed over for `gh api`. Both ask
`_is_there`, `_is_a` with any kind. The leg is `pin-submodule-unreadable`,
beside `pin-submodule-uninitialized`, naming the `.git` it could not reach
(`spec/.git: cannot be read (Permission denied)`) with the cause's advice;
the sibling is there, so it is the source, and the pin's
`neutral-pin-source-unreadable` finding says it could not answer.

L4-104. A pin this user cannot open was `load_yaml`'s line, `<absolute
path>: [Errno 13] Permission denied: '<absolute path>'`, with the lockstep
remedy, which moves a pin and says nothing of access. `_load` reads the
`OSError` the refusal is raised from, which `load_yaml` has chained since it
was written, and names the file relative to the root, POSIX, once:
`contracts/spec-pin.yaml: cannot be read (Permission denied)`, with a remedy
of its own, so `main` adds no other. Read off the cause and not off new
library words, so a root whose `scripts/repo_shape.py` is older than its
`validate-pins.py` names the file the same way (#370, #610). `load_yaml`
itself names the path once for every caller: `<path>: cannot be read
(<the system's reason>)`.

One rule for every file the gate names, on the coordinator's ruling: the
other refusals `load_yaml` raises here (not UTF-8, not parsable) keep their
id, words and remedy and name the file relative to the root, and so do
`not a mapping` for the four YAML files, `shape-pin-missing`,
`manifest-missing` and `manifest-no-legs`, which named the absolute path
raw.

L4-105. Every file this gate cannot open is told what its cause needs. A
permission keeps the words it had (a workflow) or gets bump-leg.py's (#383)
for a pin, the manifest or a copy: `chmod u+r` on a file, `u+rx` on each
directory above it. Any other cause, an I/O error or a name too long, gets
bump-leg.py's other sentence and is never told to `chmod`. `_Unreadable`
carries the cause, so one workflow refusal naming both kinds says both.

L4-106, L4-144 and every other print, on the coordinator's ruling (option
A). A workflow's name, a pin's path built from a role or a
`neutral_product_pins:` entry, a leg's path, role and repository, a pin's
`source_repository`, a declared product, a shape row's path and digest, the
shape pin's and the manifest's commits, a `--pin-source` value with no path
and the root in a printed `bump-leg.py` line each reached stderr or stdout
raw: a newline began a line, a bidi control reordered one, a C1 control
broke one, an ESC was the control it is. Each is printed as `_shown`
spells it now: `repr` where a character is not printable, the same bytes
where every one is. A word of a printed command is `shlex.quote`d as before
where it is printable, and `_shown`'s where it is not (`_quoted`): no
quoting makes a control character pasteable, and quoted it still reached
the terminal. The workflow-reference note's repository is not spelled: it
prints only a repository equal to a reference `WORKFLOW_REF_RE` matched,
which is printable ASCII.

L4-143. A `--pin-source` under a `neutral_product_pins:` that is not a list
was noted "project.yaml declares no neutral-product pin"; it says the field
is not a list and points at the `manifest-neutral-product-pins` finding.

L4-102. `PinSources` keys `--pin-source` products by `pin_product_key`, the
pin file's own fold, as #638 keys the naming gate's `LinkSources`:
`casefold()` took `openss=` for `openß`, two pin files.

Re-pin: `templates/assembly-root/scripts/validate-pins.py` and
`scripts/repo_shape.py` are pinned shape copies. As #634 says, no digest is
recorded in this repository; each root and holder reads the copies it
carries as `upstream-changed` in its `contracts/shape-pin.yaml` (both in an
assembly root, `scripts/repo_shape.py` in a family holder), which
`update-shape.py apply` re-copies and re-pins.

Tests: tests/test_validate_pins_named_and_spelled.py (new): the issue's
modes through the real gate, the five sites under a refused `stat` on every
Python, every file named relative to the root, each cause's advice, one row
for each print over a newline, a bidi control, a C1 control, an ESC and a
byte that is not UTF-8 (and a printable control), the issue's own pin entry
and workflow names, the L4-143 note and the L4-102 keys, a leg checkout
and a checkout beside the root this user cannot search, and a role or a
product spelled as an absolute path. Four existing
tests held the old absolute path: tests/test_refusal_default_remediation.py
(the `yaml-unparsable` line under the lockstep remedy),
tests/test_bump_leg_unreadable.py (the validator names the other leg's pin
POSIX now, on Windows too), tests/test_reader_refusals_named.py (#817's
`yaml-unparsable` line from the root's gate, which names
`contracts/spec-pin.yaml`) and a docstring in
tests/test_validate_pins_unreadable_workflow.py.

Closes #712

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 19, 2026
…st it would write, `family-manifest-invalid` names the value on the line it refuses, `--org is '@x'`, and gives the reader's remedy, not the validator's (#929) (#960)

The family half of #459's U119 (U151), which `64ec47c` (#817, #708) left
when it did the same for the scaffold and `adopt-project.py`. The reader
refuses `repository: @x/<Family>`, so `init --org @x --local-remote-dir
<dir>` (and `--dry-run`) said "the manifest this run would write is a file
the holder's own YAML reader refuses: ...", named no value, and its remedy
said "the words after each value are the validator's own", when the words
were the reader's and the validator was never asked.

- `shape_materialize._unread_sentences` takes the manifest's file name,
  the `CARRIED_BY` map and the words that name the reader, each the
  project's unless given, so its sentences and bytes for a project do not
  move.
- `family.py`'s `_manifest_problems` gives each sentence a kind, as
  `_invalid_problems` does for a project: on the reader's refusal it asks
  `_unread_sentences` of `family.yaml`, whose `repository:` carries
  `--org` (`CARRIED_BY`), with "the holder's own YAML reader".
  `family_manifest_invalid` returns its sentences without their kinds.
- `refuse_unholdable_manifest` refuses `family-manifest-invalid` still,
  with the remedy for the kind (`KIND_REMEDIATIONS`): the validator's for a
  finding, and the new `MANIFEST_UNREADABLE_REMEDIATION` for the reader's
  refusal. That opens with `UNREADABLE_CHOICE`, the first sentence of the
  project's reader remedy, now its own constant, and says the words are
  the holder's reader's.

Measured before, at 64464d3 (family.py as at 5017830): `init --org @x
--family Orbit --created-by 'Test Human' --dry-run --local-remote-dir <dir>
--work-dir <dir>` exits 2 with the unnamed sentence and the validator's
remedy. Now it says `--org is '@x', which the holder's own YAML reader
refuses: a plain scalar cannot open with `@`, which is reserved in YAML:
'@x/Orbit'.` under the reader's remedy, both runs, nothing created.

Tests: tests/test_family_init_yaml_words.py, which also holds that the
manifest that is not a mapping keeps the validator's kind and remedy.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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