Skip to content

pgn: Do not leak starting comments out of variations that contain no moves - #1219

Open
scks1 wants to merge 1 commit into
niklasf:masterfrom
scks1:fix-pgn-starting-comment-leak
Open

scks1 wants to merge 1 commit into
niklasf:masterfrom
scks1:fix-pgn-starting-comment-leak

Conversation

@scks1

@scks1 scks1 commented Oct 31, 2026

Copy link
Copy Markdown

Disclosure: this contribution was written by Árbol, an AI assistant working under the direction of the account holder, who set the acceptance criteria and verified the results before submission.

Problem

A comment that appears inside a variation before its first move is
collected by GameBuilder as a pending starting comment. If the
variation contains no move at all — an empty variation such as
({lonely}), or a variation whose moves are all skipped after an
illegal move — the pending comments are never consumed, and they leak
onto the next move parsed afterwards, wherever that is:

import io
import chess.pgn

pgn = "1. e4 e5 ({lonely}) 2. Nf3 Nc6 *"
game = chess.pgn.read_game(io.StringIO(pgn))

nf3 = game.variations[0].variations[0].variations[0]
nf3.starts_variation()    # False
nf3.starting_comments     # ['lonely']  <- leaked

That violates the documented contract of
GameNode.starting_comments ("Only nodes that actually start a
variation can have a starting comment"). It also breaks export round
trips: the exporter writes the starting comment before 2. Nf3, at
which position it re-parses as a post-move comment on e5, so the
comment is silently re-attached to a different node:

parse  -> Nf3.starting_comments == ['lonely'], e5.comments == []
export -> 1. e4 e5 { lonely } 2. Nf3 Nc6 *
parse  -> e5.comments == ['lonely'], Nf3.starting_comments == []

Nested variations are affected the same way
((2. Bc4 ({lonely}) Bc5 ...) lands lonely in
Bc5.starting_comments). Related symptoms of the same leak: a
comment in an empty variation in front of a sibling variation becomes
that sibling's starting comment, and a comment in an empty variation
at the end of a game is dropped entirely.

The defect is also present in the released 1.11.2 GameBuilder
(single-string comment API); this patch is against master.

Fix

Scope the pending starting-comment buffer to the variation it was
collected in:

  • begin_variation() saves the enclosing buffer and starts a fresh
    one (so nested variations cannot mix buffers).
  • end_variation() attaches any still-pending comments to the
    comments of the node the variation branched from — the position
    the annotation belongs to — and restores the saved buffer.

With this, the example above parses with e5.comments == ['lonely']
and Nf3.starting_comments == [], and export round trips are the
identity. Legitimate starting comments (variations that do contain
moves) are unaffected.

Tests

Added regression tests covering: the mainline leak, round-trip
identity, the nested empty variation, end-of-game preservation,
sibling non-contamination, nested buffer scoping, an invariant sweep
(no node carries starting comments without starting a variation),
and legitimate starting comments. Full test suite with this change: 297 tests, OK
(22 skipped, external engine/tablebase binaries unavailable).
Unpatched master runs 296 tests, OK, and the new regression test
fails there. mypy --strict chess passes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant