Skip to content

repo_shape.py: a flow collection with an unmatched ] or } is read, not refused, so k: {a"]:} reads {'a"]': None} since #284 (a quote toggle used to swallow the :}) and k: [a]] reads ['a]'], where PyYAML refuses both #301

Closed

Description

@brettheap

Lane: openreposhape-5 (openRepoShape-5)

Found by this lane's re-verification of PR #284 (fix/yaml-quote-boundaries at b72ab0f, merged as b30595c) as NIT-B, which the review ruled acceptable as a follow-up and not a should-fix. #284's own body lists the class under "Left out" ("A plain flow item followed by a nested flow, and unbalanced brackets"). The class is old, and #284 made part of it newly reachable. Every result below is on main b30595c, and the contrast is its parent c3a9645.

What is wrong. scripts/repo_shape.py _flow checks only that a flow collection opens with [ or { and ends with ] or }. _split_flow counts [ and { up and ] and } down and never asks whether the count went below zero. So a body that closes a bracket it never opened is split at its top-level commas and read, with the stray closer kept inside an item or a key. A plain item that holds a [ is the same defect one level in: _split_flow('x [a, b]') returns ['x [a, b]'], and _scalar reads that as text.

Until #284, one slice of this was refused only by accident. The quote toggle took a " in the middle of a plain item for the start of a quoted scalar and swallowed what followed, so k: {a"]:} ended in flow mapping entry without a key: 'a"]:'. #284 reads a quote only where a scalar begins, as YAML does, so nothing hides the ] any more, and k: {a"]:} reads as its quote-free twin k: {a]x:} already did.

cd <an openRepoShape checkout at b30595c>
python3 - <<'EOF'
import sys

sys.path.insert(0, "scripts")  # run from the repository root
from repo_shape import YamlError, parse_yaml

try:
    import yaml  # PyYAML, as the oracle only
except ImportError:
    yaml = None

DOCS = [
    "pins: [a, b]]",
    'k: {a"]:}',
    'k: {a]x:}',
    'k: {]:}',
    "k: [a]]",
    "k: [a}]",
    "k: [x [a, b]]",
    "k: [a[]",
    "k: {a:}#}: b}",
    'k: {}#":}',
]


def ours(doc):
    try:
        return repr(parse_yaml(doc + "\n"))
    except YamlError as e:
        return f"refused: {e}"


def pyyaml(doc):
    try:
        return repr(yaml.safe_load(doc + "\n"))
    except yaml.YAMLError as e:
        return f"refused: {type(e).__name__}"


for doc in DOCS:
    print(repr(doc))
    print("    repo_shape:", ours(doc))
    if yaml:
        print("    PyYAML:    ", pyyaml(doc))
EOF

Observed on b30595c (Python 3.12.3, PyYAML 6.0.3, Linux):

'pins: [a, b]]'
    repo_shape: {'pins': ['a', 'b]']}
    PyYAML:     refused: ParserError
'k: {a"]:}'
    repo_shape: {'k': {'a"]': None}}
    PyYAML:     refused: ParserError
'k: {a]x:}'
    repo_shape: {'k': {'a]x': None}}
    PyYAML:     refused: ParserError
'k: {]:}'
    repo_shape: {'k': {']': None}}
    PyYAML:     refused: ParserError
'k: [a]]'
    repo_shape: {'k': ['a]']}
    PyYAML:     refused: ParserError
'k: [a}]'
    repo_shape: {'k': ['a}']}
    PyYAML:     refused: ParserError
'k: [x [a, b]]'
    repo_shape: {'k': ['x [a, b]']}
    PyYAML:     refused: ParserError
'k: [a[]'
    repo_shape: {'k': ['a[']}
    PyYAML:     refused: ParserError
'k: {a:}#}: b}'
    repo_shape: {'k': {'a:}#}': 'b'}}
    PyYAML:     {'k': {'a': None}}
'k: {}#":}'
    repo_shape: {'k': {'}#"': None}}
    PyYAML:     {'k': {}}

On c3a9645, the commit before #284, the same script prints the same for eight of the ten documents. The two that differ are refused: 'k: {a"]:}' with flow mapping entry without a key: 'a"]:', and 'k: {}#":}' with flow mapping entry without a key: '}#":'.

PyYAML refuses eight of the ten. It reads k: {a:}#}: b} as {'k': {'a': None}} and k: {}#":} as {'k': {}}, only because it accepts a # with no space before it as a comment, which YAML forbids. This reader's reads of those two are neither. (#284's body says PyYAML refuses k: {a:}#}: b}. It reads it.)

On seed 2490250 (785,646 documents: every string of up to 4 characters drawn from a , : space " ' [ ] \ { } # -, plus 100,000 random strings of up to 14, each placed in six contexts: a flow sequence, a flow mapping, a value, an item, a key and a nested item; PyYAML 6.0.3 as the oracle only), b30595c reads 775 flow-mapping documents that PyYAML refuses and c3a9645 refused. It also reads 13 more that PyYAML reads and c3a9645 refused, like k: {}#":}. The review measured this against b72ab0f, and it was re-run for this issue against b30595c with the same counts. The sketch below refuses 617 of the 775. The other 158 are a different class and are not claimed here: 145 of them open the body, after any spaces, with :, , or # (k: {:":}, k: {,a":}, k: {#":}), which the review counted with a separate follow-up, a key that begins with an indicator character.

Why it matters. YamlError's docstring is the reader's contract: "Raising rather than guessing is the whole point: a validator that silently mis-reads its own contract file is worse than one that will not start." A manifest, pin, plan or family file with one stray ] or } typed into a flow list or mapping reads as data instead (pins: [a, b]] reads {'pins': ['a', 'b]']}), where PyYAML refuses the file. The project's validators and a stock YAML parser then disagree about what the file says.

No valid YAML document is misread by this. Every document above is one PyYAML refuses, except the two it reads through its # leniency. No writer in this tree emits a stray closer, so only a hand edit reaches it, which is why the review ruled it a follow-up and not a blocker. What #284 changed is the reach: on seed 2490250, 775 more flow-mapping documents are read where main refused them, and 617 of those are this class.

Expected. Refuse a flow body that closes a bracket it never opened, and a plain flow item that holds a [ or {. In YAML a plain scalar inside a flow collection ends at any of [ ] { } ,. The review tested a two-line sketch for the first half, in _split_flow:

        elif ch in "]}":
            depth -= 1
            if depth < 0:
                raise YamlError(f"a flow collection closes a bracket it never opened: {body!r}")

On the ten documents above it refuses eight, all but k: [x [a, b]] and k: [a[]. As measured by the review on seed 2490250 against b72ab0f (the first four lines were re-run for this issue against b30595c with the same counts):

The sketch does not touch a plain item that holds an opener (k: [x [a, b]], k: [a[]). Five of the 158 documents it leaves read end their body one bracket level deep, an opener never closed (k: {t-'': x{} reads {"t-''": 'x{'}). That needs its own rule, and no fix for it was measured. scripts/repo_shape.py is the digest-pinned copy in every scaffolded root, so a fix reaches projects through update-shape.py. Add a row for each document above, with the two PyYAML reads refused by design as k: "a"#c is.

NIT-A, a test docstring. The module docstring of tests/test_yaml_quote_boundaries.py (lines 30-32) says every refused document is one PyYAML refuses, "except k: "a"#c". There are eight exceptions: that one, and seven BRACKET_IN_A_KEY rows (- a[b]: c, a[b]: c, k:\n - a{b}: c, - note "[1]": x and three more) that PyYAML reads and the subset refuses on purpose. Whoever fixes the class should correct that sentence.

Found by. handoffs/openRepoShape/attachments/lane-openRepoShape-5/reviewA-report.md in opensoft/brett-wip (NIT-A and NIT-B of its "Re-verification of b72ab0f"). The ten-document run on b30595c and c3a9645, the seed 2490250 counts for b30595c and the eight-disagreement count for NIT-A were re-run for this issue.

Not claimed.

🤖 Generated with Claude Code

Activity

  1. brettheap commented on Oct 12, 2026

    @brettheap
    ContributorAuthor

    CLAIMED — lane openRepoShape-5, session e62c2fd7-176d-429d-89fb-0653dd08ad5f@Eagle, 2026-10-12T11:02:25Z, for #301

    Logged in lanes/log/openRepoShape-5.md at 81b172c6b23a3667e712b6b9f80c86c9bd030f3d.

    The three reads (lane-collision-protocol Rule 1):

    1. existing `CLAIMED —` comments on opensoft/openRepoShape#301:
       none
    2. `gh pr list --repo opensoft/openRepoShape --state all --search "#301"`, kept where the row or its body names `#301`:
       none
    3. `git ls-remote --heads git@github.com:opensoft/openRepoShape` branches naming `301` as a whole token:
       none
    
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions