Skip to content

encode() silently drops empty-string keys with non-primitive values, promoting children into the parent scope #64

Description

@antrixy

Description

When an object contains an empty-string key ("") whose value is an object or array, encode() omits the "": header line entirely and emits the children at the parent's indentation level. No error or warning is raised. This is silent data loss, and depending on the surrounding structure it can also overwrite sibling keys or change the document's root type. Empty keys with primitive values encode correctly ("": 1), so the bug is specific to the non-primitive branch.

The decoder is not at fault: given correct wire text ('"":\n "a": 1'), decode() returns the right value. The reference TypeScript implementation (2.3.0) and toon-rust (0.5.0) both encode these inputs correctly, so Python-encoded output misdecodes in every implementation — including toon-python itself.

Reproduction Steps

from toon_format import encode, decode

encode({"": {"a": 1}})          # 'a: 1'  — empty-key level silently deleted
encode({"": {"a": 1}, "a": 2})  # 'a: 1\na: 2' — duplicate key; decodes to {"a": 2}
encode({"": [1, 2]})            # '[2]: 1,2' — root object became a root array
decode(encode({"": {"a": 1}}))  # {"a": 1} — round-trip silently loses structure
decode('"":\n  "a": 1')         # {"": {"a": 1}} — decoder handles correct wire fine

Expected Behavior

Per SPEC.md §7.3, keys not matching ^[A-Za-z_][A-Za-z0-9_.]*$ must be quoted (the empty string does not match), and per §8, a nested object is emitted as key: on its own line with fields at depth +1. So {"": {"a": 1}} should encode as:

"":
  a: 1

This is exactly what @toon-format/toon 2.3.0 and toon-rust 0.5.0 produce, and what toon-python's own decoder round-trips correctly.

Actual Behavior

The "": header is never emitted; children are promoted one level up. Observed consequences:

  1. Silent structural data loss on round-trip: {"": {"a": 1}}{"a": 1}
  2. Sibling-key collision where the last duplicate wins: {"": {"a": 1}, "a": 2} → wire a: 1\na: 2{"a": 2}
  3. Root-type change from object to array: {"": [1, 2]} → wire [2]: 1,2[1, 2]
  4. With an array value nested deeper, wire that decodes to garbage: {"a": {"": [1, 2]}}{"a": {"[2]": "1,2"}}

Environment

toon_format 0.9.0b1, Python 3.14, macOS. Cross-checked against @toon-format/toon 2.3.0 (Node 24) and toon-rust 0.5.0.

Additional Context

Found by differential fuzzing with toon-diff, which compares TS/Python/Rust round-trips against an independent oracle. Related: the TS implementation fixed an adjacent empty-key issue in array headers in toon#281, suggesting empty-string keys are a known-delicate area. Distinct from #63, which concerns empty-object values in tabular cells; this issue concerns empty-string keys in plain object nesting.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions