Skip to content

scripts/repo_shape.py: a block scalar's record carries its key and its body as a pair instead of NUL-joined text, so "a\x00b": | no longer reads as the key a with the body b<NUL>text, and a line that begins with a raw NUL and BLOCK no longer reads as a block scalar nobody wrote (#248) - #265

Merged
brettheap merged 1 commit into
mainfrom
fix/yaml-block-key-nul
Oct 11, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Lane: openreposhape-5 (openRepoShape-5)
Claim: #248 (comment)

What

The YAML reader kept a block scalar (key: |, key: >) as one _Rec whose text was "\x00BLOCK\x00" + key + "\x00" + body. _parse_mapping took it apart with split("\x00", 3), and both _parse_mapping and _parse_sequence recognised it by that text prefix. Now:

  • _Rec has a block slot holding (key, body). It is set only on the record a block scalar's header makes, and that record's text is empty.
  • _tokenise builds the record with the pair. _parse_mapping and _parse_sequence test rec.block, not a text prefix.
  • _BLOCK_SENTINEL is gone. Its comment, which named the collision as a known limit, becomes a _Rec docstring saying why nothing can collide now.

No other function in the file changes, and no writer changes.

Why

Since #199 the reader resolves \x00, \u0000 and \U00000000 in a double-quoted key, which is valid YAML. A key holding a NUL was cut short at the NUL, and the rest of it read into the body. A raw NUL did the same before #199. The marker was also only a text prefix, so an ordinary line beginning with a raw NUL + BLOCK + NUL was taken for a block record:

document main (150c41d) this branch
"a\x00b": | {'a': 'b\x00text\n'} {'a\x00b': 'text\n'} (PyYAML agrees)
"a\x00b": text (control) {'a\x00b': 'text'} same
<NUL>BLOCK<NUL>k<NUL>v: 1 {'k': 'v: 1'} {'\x00BLOCK\x00k\x00v': 1}
- <NUL>BLOCK<NUL>x bare ValueError out of load_yaml ['\x00BLOCK\x00x']
k:\n <NUL>BLOCK<NUL>x<NUL>y {'k': {'x': 'y'}} YamlError, as k:\n xyz is

A structure that is not a string has no character to collide with, so this removes the class and not just \x00. A different separator only moves the collision, and refusing a NUL in a block key leaves the prefix misreads in place (a \x01 separator and a NUL refusal were each tried as mutants, and both are caught by the new tests).

A NUL in a key is read, not refused. YAML allows it escaped. PyYAML 6.0.3 reads every escaped row the way this branch does. The reader already read the same key over a plain value, and double_quoted writes a NUL as \x00. A raw NUL, which YAML forbids, is read the way the reader reads a raw NUL in any other key (a<NUL>b: text reads as {'a\x00b': 'text'} on main). Refusing raw control characters is a reader-wide decision and is left out (see below).

Issue row 2 ("a\^@b": >) does not reproduce, not even on a02240a: \^@ is no escape the reader (or YAML) resolves, so the key keeps its backslash on main and here. It is pinned as a control.

Evidence

  • tests/test_yaml_block_scalar_keys.py (new, 51 tests). On 150c41d: 41 fail and 10 pass, and the 10 that pass are controls. On this branch: 51 pass.

  • Targeted runs on 9be6287:

    • the new file plus test_yaml_flow_quotes.py, test_load_yaml_refusals.py, test_adopt_plan_escapes.py, test_manifest_validator.py, test_pin_validator.py, test_neutral_pin_validator.py, test_repo_hygiene.py, test_cli_reference.py and test_adopt_plan.py: 326 passed, 1 skipped (SHAPE_MEDXEHR_CLONE unset);
    • test_shape_doctor.py: 181 passed.
  • Mutation: each fix hunk reverted on its own (_tokenise, _parse_sequence, _parse_mapping, _Rec) fails 46, 4, 48 and 51 tests. Of the narrow alternatives, refusing a NUL in a block key fails 41, including every marker-prefixed-line test. A \x01 separator fails the every-character round-trip test. Dropping the sequence check fails the placement test, and taking the pair reversed fails 46.

  • Every code point in a written block key, double_quoted("a" + chr(c) + "b") under | and >: all 2,228,224 documents read back, and PyYAML agrees on every one. Main misreads exactly 2 of them, both NUL.

  • A base-against-head fuzz of 200,000 documents built around block scalars finds 0 differences outside the class. All 23,298 documents with no NUL in any spelling read the same. Over the same corpus, main lets 4,250 bare ValueErrors out of parse_yaml, and this branch none.

  • Reach: all 14 tracked YAML files read the same on main and here, rendered templates too. Block scalars in tests.yml, path-classification.yaml and both validate.yml templates read unchanged. No writer in this repository emits the construct:

    • no shipped Python writes a block-scalar header;
    • adopt-project.py's keys are literals, and its one variable key comes from summary_of's fixed names;
    • y never writes |, > or a raw NUL plain;
    • the doctor's placement plan uses y/emit, and family.py, update-shape.py and bump-leg.py write literal keys.

    So the defect shows only on a file a person edits or another tool writes. Every YAML file every tool reads goes through this reader.

Composition

git merge-tree --write-tree from 9be6287:

Left out

  • Refusing the raw control characters YAML forbids (PyYAML: unacceptable character #x0000). The reader takes them everywhere today, and double_quoted's docstring says so. A refusal belongs to the whole reader, not to one path. The plain-twin tests here say those rows become refusals together if that changes. A follow-up issue is proposed.
  • Honouring + chomping and the indentation digit is documented as accepted, not honoured, and is unrelated.

Closes #248

🤖 Generated with Claude Code

Summary by Sourcery

Make block-scalar parsing preserve arbitrary keys and distinguish genuine block records from ordinary YAML lines.

Bug Fixes:

  • Preserve NUL-containing YAML keys in block scalars instead of splitting them into the key and body.
  • Prevent ordinary lines beginning with the former block-scalar marker from being misclassified as block scalars.

Enhancements:

  • Represent block-scalar records with structured key and body data, eliminating sentinel-based parsing collisions.

Tests:

  • Add comprehensive coverage for escaped and raw NUL keys, marker-prefixed lines, nested and sequence contexts, character round trips, and unchanged block bodies.

…s body as a pair instead of NUL-joined text, so `"a\x00b": |` no longer reads as the key `a` with the body `b<NUL>text`, and a line that begins with a raw NUL and `BLOCK` no longer reads as a block scalar nobody wrote (#248)

`_tokenise` kept a block scalar (`key: |`, `key: >`) as one `_Rec` whose
text was `"\x00BLOCK\x00" + key + "\x00" + body`, and `_parse_mapping`
took it apart with `split("\x00", 3)`. Since #199 the reader resolves
`\x00`, `\u0000` and `\U00000000` in a double-quoted key, so a key holding
a NUL was cut short at it and the rest of it read into the body; a raw NUL
did the same before #199. And the marker was a text prefix: an ordinary
line beginning with it was taken for a block record, read as a key and a
value nobody wrote (`\x00BLOCK\x00k\x00v: 1` as `{'k': 'v: 1'}`), or, as a
sequence item with one NUL too few, died with a bare `ValueError` that
`load_yaml` let through as a traceback.

`_Rec` gains a `block` slot holding `(key, body)`, set only on the record a
block scalar's header makes, and `_parse_mapping` and `_parse_sequence` test
that slot instead of a text prefix. `_BLOCK_SENTINEL` is gone. No character
a key can hold can collide with a structure that is not a string.

A NUL in a key is read, not refused: YAML allows it escaped and PyYAML
6.0.3 reads every escaped row here the same way, and the reader already
took the same key over a plain value. A raw NUL, which YAML forbids, is
read as the reader reads it in any other key; refusing raw control
characters is a reader-wide decision outside this path.

- `tests/test_yaml_block_scalar_keys.py` (new, 51 tests).

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-5, 2026-10-11T22:46Z) @codex review

@sourcery-ai

sourcery-ai Bot commented Oct 11, 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 23 hours and 56 minutes by commenting @sourcery-ai review. Upgrade to get a review now.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-11T23:08:07.259146Z 9be6287 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@sourcery-ai

sourcery-ai Bot commented Oct 11, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR eliminates the collision-prone NUL-delimited representation of block scalars by storing their key and body as a structured pair on _Rec, updates parsers to use that metadata, and adds comprehensive regression tests covering NUL keys, former sentinel-like lines, nesting, sequences, and body preservation.

Sequence diagram for parsing a YAML block scalar with a NUL key

sequenceDiagram
    participant Input as YAML input
    participant Tokenise as _tokenise
    participant Rec as _Rec
    participant Mapping as _parse_mapping

    Input->>Tokenise: _tokenise(text)
    Tokenise->>Tokenise: _opens_block_scalar(kv)
    Tokenise->>Tokenise: _block_scalar_body(raw_lines, i, indent)
    Tokenise->>Tokenise: _block_scalar_text(kv[1], body)
    Tokenise->>Rec: create with block=(kv[0], body)
    Note over Rec: text is empty, key and body remain separate
    Tokenise-->>Mapping: list[_Rec]
    Mapping->>Rec: read rec.block
    Rec-->>Mapping: key, body
    Mapping-->>Input: {key: body}
Loading

Flow diagram for avoiding block scalar marker collisions

flowchart LR
    A[YAML block scalar header] --> B[_tokenise]
    B --> C[_Rec.block = key, body]
    C --> D{rec.block is not None}
    D -->|yes| E[_parse_mapping uses key and body]
    D -->|no| F[Parse ordinary text normally]
    G[Key or line contains NUL or marker-like text] --> F
Loading

File-Level Changes

Change Details Files
Replace the NUL-delimited block-scalar encoding with explicit structured metadata on each token record.
  • Add an optional (key, body) block field to _Rec.
  • Have _tokenise store block-scalar keys and processed bodies as a pair while leaving record text empty.
  • Have mapping and sequence parsing detect block records through the structured field and unpack the pair directly.
  • Remove the _BLOCK_SENTINEL constant and document the collision-avoidance rationale on _Rec.
scripts/repo_shape.py
Add regression coverage for NUL-containing keys and lines that resemble the former block marker.
  • Verify escaped and raw NUL keys round-trip for literal and folded block scalars, including placement in sequences and nested mappings.
  • Verify marker-prefixed ordinary keys and sequence items are no longer interpreted as block records.
  • Check block bodies, chomping behavior, plain-value twins, broad character coverage, and file-based load_yaml reads.
tests/test_yaml_block_scalar_keys.py

Assessment against linked issues

Issue Objective Addressed Explanation
#248 Prevent block-scalar records from misparsing keys containing NUL characters, including escaped NULs introduced by #247, so that documents such as `"a\x00b": ` preserve the complete key and correct body. ✅
#248 Replace the NUL-delimited block-scalar sentinel representation with a collision-safe structured representation, and update mapping and sequence parsing accordingly. ✅
#248 Prevent ordinary input beginning with the former block-scalar sentinel from being mistaken for a block scalar, while adding regression coverage for the affected key, sequence, nesting, and marker-prefix cases. ✅

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

@sonarqubecloud

Copy link
Copy Markdown

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Nice work!

Reviewed commit: 9be6287e8c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@brettheap
brettheap merged commit c3f4987 into main Oct 11, 2026
5 checks passed
@brettheap
brettheap deleted the fix/yaml-block-key-nul branch October 11, 2026 23:11
brettheap added a commit that referenced this pull request Oct 12, 2026
…, #252 via #257, #195 via #226, #210 via #219, #211 via #220, #248 via #265, #212 via #223) into fix/adopt-record-submodule-placement

Brings in 06b5204 (#214), 60d08a1 (#217), 2727b08 (#209), 4b2add8 (#252),
aaa40eb (#195), d89fffa (#210), eb6df75 (#211), c3f4987 (#248) and
5751460 (#212) on top of 39ac59c. No rebase. 5751460 landed while the
merge of c3f4987 was being checked, and it changes adopt-project.py,
scripts/shape_materialize.py and tests/test_adopt_submodules.py, as this
branch does, so this one merge takes it too.

No conflicts. Both sides change adopt-project.py, scripts/repo_shape.py,
scripts/shape_materialize.py, shape-doctor.py, tests/test_adopt_submodules.py
and tests/test_shape_doctor.py. Every top-level definition only one side
changed is that side's text in the merge, byte for byte. The one thing
both changed is shape_materialize.py's imports, and both are kept: main's
`codecs` and this branch's `path_spelling_fault` and `PATH_*` codes.
Checked:

- adopt-project.py: main calls `escape_what_stdout_cannot_encode()` first
  in `main` (#214). It makes a `.gitmodules` git's submodule reader cannot
  read a finding where the tip holds no submodule and the history does, or
  where `ignore`, `update` or `branch` has no value (#195). And every path
  a finding names is now `_spelled` (#212). This branch's
  `submodule_record`, `_resolution_text` and the record's preamble are
  unchanged by the merge, and so are `_answered_legs`, `_kept_in_a_leg`,
  `_commit_the_split`, `_refuse_what_check_finds`, `cmd_execute` and `Plan`.
  The record and #195's finding read the kept submodules the same way,
  `_kept_in_a_leg(_answered_legs(...))` over the tip's gitlinks. `execute`
  refuses #195's finding in `_refuse_what_check_finds`, before any leg
  exists and before `_commit_the_split` writes the record. So a source
  #195 refuses gets no record, and so does a source whose tip holds no
  submodule.
- scripts/repo_shape.py: main refuses an explicit key in a flow mapping
  and reads an empty flow-mapping value as null (#252). A block scalar's
  record is now a (key, body) pair, and `_BLOCK_SENTINEL` is gone (#248);
  nothing on this branch names it. The record is a block sequence of
  double-quoted scalars, and it reads back through the merged reader with
  0 mismatches (`submodule_record`, then `parse_yaml`, path and
  resolution):
  - 4,525 names, plus 35 more;
  - 240 aimed at #252 and #248 (`? a`, `{a: }`, `|`, `a: |`, a NUL, the
    old sentinel).
- scripts/shape_materialize.py: main adds `escape_what_stdout_cannot_encode`,
  `run()` reading UTF-8 (#217), and `first_and_more(paths, spell)` (#212).
  This branch's `LEG_SEGMENT_REASONS` and `_spelling_reason` are untouched.
- shape-doctor.py: main adds the `manifest kinds` finding for a
  `family.yaml` beside `project.yaml` (#211), and the stdout escaping.
  This branch's placement row and `recorded_submodules` are untouched.
- tests: #212 respells the finding text that the earlier tests in
  test_adopt_submodules.py assert. None of this branch's tests quotes a
  finding.
- No top-level name is bound twice in adopt-project.py,
  scaffold-project.py, scripts/repo_shape.py, scripts/shape_materialize.py,
  shape-doctor.py, validate-manifest.py or update-shape.py. Each merged
  module's names are the base's, plus what either side added, minus what
  either side removed. No side added a name the other side also added,
  in the tests either.
- AGENTS.md is untouched by main: 472 lines, under the cap of 472.
- The branch's diff against main is still #189 alone: 12 files, +1670
  -156, file by file the same as against 39ac59c.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 12, 2026
…, #209, #252, #195, #210, #211, #248, #212, #251 and #206 merge with one hunk, the materializer import list, kept on both sides, and `_entry_findings` still hands `_leg_findings` the tree and ends with the root-symlink findings

origin/main moved 39ac59c -> 60588d5: 06b5204 (#214 via #246), 60d08a1
(#217 via #230), 2727b08 (#209 via #218), 4b2add8 (#252 via #257),
aaa40eb (#195 via #226), d89fffa (#210 via #219), eb6df75 (#211 via
#220), c3f4987 (#248 via #265), 5751460 (#212 via #223), 23e16ae (#251
via #271) and 60588d5 (#206; its subject names no pull request).

Conflict, resolved by keeping both sides:
- adopt-project.py, the `shape_materialize` import list. This branch
  adds BOOTSTRAP, SYMLINK_HOPS and template_files; #214 adds
  escape_what_stdout_cannot_encode. The resolution is the union.

Auto-merged: scripts/shape_materialize.py, where #214's escaping, #217's
UTF-8 `run()` and #212's `first_and_more(paths, spell=None)` sit apart
from this branch's #183 section, and adopt-project.py's other hunks.
#195 adds `import os` at the line where this branch added it, and the
merge keeps it once.

Checked on the merged tree:
- `_entry_findings` still calls `_leg_findings(plan, tree)` and ends
  with `_root_symlink_findings(plan, source, tree)`. `main` still calls
  `escape_what_stdout_cannot_encode()` first.
- No top-level name is bound twice in adopt-project.py,
  scaffold-project.py, scripts/repo_shape.py or
  scripts/shape_materialize.py, and none is loaded but bound nowhere
  (AST). `first_and_more` gives its new `spell` a default, so this
  branch's call is unchanged.
- Both sides are intact by +/- line sets: each side's change against the
  base reappears against the other side, apart from the import hunk and
  the shared `import os`.
- #212 says this branch's `plan-root-symlink` names its paths raw, and
  that whichever pull request lands second should spell them. That was
  04792d2. Since d11cf9f every path the finding names goes through
  `_spelled`.

One clash the textual merge does not show is fixed in the next commit:
#217's tests/test_git_output_decoding.py refuses a shipped text-mode
`subprocess` call that names no codec, and this branch's
`_checks_out_symlinks` probe names none.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 12, 2026
…e-notice

It merged with no conflict. Main brings nine commits since 39ac59c:

- 06b5204, six tools escape a name their stdout cannot encode (#214
  via #246), with tests/test_stdout_encoding.py;
- 60d08a1, `shape_materialize.run()` and 26 other git and gh calls
  read the child's output as UTF-8 (#217 via #230), with
  tests/test_git_output_decoding.py;
- 2727b08, update-shape.py's `gh pr create --base` (#209 via #218),
  with tests/test_update_shape_pr_base.py;
- 4b2add8, scripts/repo_shape.py's flow mappings (#252 via #257), with
  tests/test_yaml_flow_mappings.py;
- aaa40eb, adopt-project.py refuses a `.gitmodules` the history's
  gitlinks make unreadable (#195 via #226), with
  tests/test_adopt_history_gitlinks.py;
- d89fffa, family.py `bump` to the current pin (#210 via #219);
- eb6df75, shape-doctor.py's family.yaml beside a project.yaml (#211
  via #220);
- c3f4987, scripts/repo_shape.py's block-scalar records (#248 via
  #265), with tests/test_yaml_block_scalar_keys.py;
- 5751460, adopt-project.py spells every path a FINDING or a refusal
  names (#212 via #223), with tests/test_adopt_finding_spelling.py.

Three files changed on both sides: adopt-project.py,
tests/test_adopt_ignored_shape_files.py and tests/test_adopt_verify.py.
In each, `git diff -U0` of this merge against 5a8b649 equals main's
diff against 39ac59c, and this merge against 5751460 equals the
branch's diff against 39ac59c, apart from the hunk headers. Neither
side's lines are changed.

How main's changes meet this branch's failure handling:

- Every definition the late failures go through is byte-identical to
  5a8b649's: LATE_FAILURES, the notice (`_nothing_rolled_back`),
  `_failed_loudly`, `_report_late`, the three builders' handlers,
  `_verify` and `cmd_execute` among them. Three definitions differ, each
  by main's hunk alone.
- `main()` gains `escape_what_stdout_cannot_encode()` as its first
  statement (06b5204). The branch writes every REFUSED block and notice
  to stderr, which Python opens with `backslashreplace` whatever the
  locale.
- `run()` names its codec (60d08a1). Output that is not UTF-8 now
  reaches the branch's handlers as `CommandFailed`, not as
  `UnicodeDecodeError`. LATE_FAILURES keeps `UnicodeError`, because
  `git_out` in scripts/repo_shape.py still decodes strictly.
- `Source.registered_submodules` reads the history (aaa40eb). Its one
  caller in `execute` is `_refuse_what_check_finds`, before
  `_create_leg_remotes`, so the new refusal comes before any leg
  repository exists and needs no notice.
- 5751460 spells the paths of the verification's findings. The
  branch's footer and notice follow those findings unchanged.

No top-level name is bound twice in adopt-project.py,
scaffold-project.py, scripts/repo_shape.py or
scripts/shape_materialize.py, and a `symtable` check finds no name
they read that nothing binds. On the merged tree, ten test files give
439 passed and 1 skipped (Windows' own stdout default): the two shared
ones, main's four new ones that read adopt-project.py
(finding_spelling, history_gitlinks, stdout_encoding,
git_output_decoding), and test_adopt_late_failure,
test_adopt_filter_repo_failure, test_adopt_execute_refusals and
test_adopt_plan_escapes.

Lane: openreposhape-1 (openRepoShape-1)
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