Repository navigation
B6 P1: apply(text, ops), the pure operation function over an entry file's text - #732
Conversation
d1f9a60 to
6717593
Compare
…s outside the named spans (#732 round 1) The property: apply() returns a patch whose hunks fall inside the spans of the values the operations name; every other byte is identical. The module docstring states who owns which bytes. Comments after an inline value, on a key line or on a block scalar's header are the pair's: a set keeps them. The post-check now computes the allowed spans by its own rule (end_mark walked back over blank and comment lines), not with the edit code's _end, and compares the bytes outside them directly; it used to trim comment lines and split keys on ':' (a comment between keys could be deleted or rewritten, and a re-quoted "x:y" key passed). The test oracle (tests/ file_operations_oracle.py) is a third mechanism: an indentation scan; the old one was the module's scan copied (Andon #745). Fixed from the cold read: an unset of a dash-line pair deleting the comment below it; a block value set inline deleting the key-line comment; keys that load as bool, null, int or a date (one lookup rule, _key_matches, for nodes and values; true and 1 are duplicates as the loader sees them); the widen crashing on a top-level scalar; untyped crashes on malformed paths and ops (a fuzz test now holds every failure to OperationRefusedError); a key starting with '-'; |2- and |+ kept; an append after a trailing |+ scalar; U+2028 written bare; 1e20 written as 1e+20 (a string to YAML 1.1); a..b silently normalised; the misleading out-of-range message. Constructing values from the composed nodes mutates a merge (<<) out of node.value, so anchors are found before construction. One YAML instance per thread, one compose per operation, and a run of operations on different top-level keys is spliced and checked once: 500 sets on 500 keys went from 30 s to 0.4 s, a 2,000-item list set from 3.2 s to 0.1 s. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
21eebb2 to
8fb5eea
Compare
Fix round 1, pushed at
|
| Bytes | Owner | Status |
|---|---|---|
key text, : and the key line's indentation |
the pair (an unset) | guarded: the sweep, test_unset_finds_a_key_by_what_it_reads_as |
separator after : and an inline scalar, plus the spaces after it |
the value | guarded: the sweep, test_doc_example_one_field_edit_keeps_the_comment |
| comment after an inline value | the pair: a set keeps it, an unset removes it | guarded: test_a_shorter_value_keeps_the_comment_column, sweep (COMMENTED) |
| comment on a key line before a block value | the pair | fixed: test_a_block_value_set_inline_keeps_the_key_line_comment (×3), test_a_scalar_set_to_a_block_keeps_its_comment_on_the_key_line |
block scalar indicators (|2-, |+) |
the value | fixed: test_an_indentation_indicator_is_kept, test_keep_chomping_is_kept |
| comment on a block scalar's header line | the pair | fixed: test_a_block_scalar_header_comment_stays |
block scalar content lines; a |+ scalar's trailing blank lines |
the value | fixed: test_an_append_after_a_keep_scalar_keeps_its_blank_lines; trailing spaces on the last line are content: test_top_level_scalars_the_cold_read_crashed_on[block-scalar-trailing-spaces] |
| flow collection text | the value | guarded: the sweep, test_a_flow_list_append_copies_its_neighbours_quoting |
| block collection lines, end of key line to last content line | the value | guarded: the sweep |
| comment between items, inside a named list | the list (a set of the list may drop it); outside an item's unset | guarded: the sweep (COMMENTED: # between) |
| comment between a dash-line pair and its sibling | outside the pair's unset span | fixed: test_unset_of_a_dash_line_pair_keeps_the_comment_below_it |
| comment inside a nested map | the map (when named), else outside | guarded: the sweep (COMMENTED: # middle, # deep) |
| comment or blank lines between top-level keys | outside every span | fixed in the check: test_a_narrow_edit_touching_bytes_outside_its_span_is_never_returned (delete, rewrite, blank line), test_unset_of_the_first_key_keeps_the_comment_above_the_next |
| header lines before the first key; lines after the last key | outside (a new key goes after them) | guarded: the sweep (# head, # tail), test_unset_leaves_the_comment_above_the_key |
| delimiters, BOM | outside | guarded: check_contract, test_the_bom_survives_every_operation |
| body | ReplaceBody only |
guarded: check_contract, test_replace_body_* |
Every p6 "COMMENTS LOST" line is on a line the named value owns. Examples: - a # first under Set("tags", ["b"]) is the removed item's own line; # between under Set("tags", []) sits between items inside the set list; id: x # the id under Unset("id") is the pair's own line.
Evidence
- Red on round 0's module: 33 of the new tests fail (comments, keys, fuzz, limits, budget), and the sweep goes red once it has the comment-dense file.
tests/test_file_operations.py: 191 passed at-n 4.scripts/test-affected --run(-n 4): 353 passed.- The scratch sweep: 2,602 cases through the new oracle, 0 failures. The in-suite sweep covers 18 files (the 17 shapes plus
COMMENTED) × {LF, CRLF, BOM}, about 4 s per variant. - Mutations, each guard removed alone:
| Guard removed | Test that fails |
|---|---|
bytes-outside check in _try |
test_a_narrow_edit_touching_bytes_outside_its_span_is_never_returned[...] (the final check then refuses) |
_named_spans allows everything |
the same test |
| final check | test_the_final_check_catches_what_the_per_operation_check_missed |
| key lookup rule | test_unset_finds_a_key_by_what_it_reads_as[true] |
| dash-line pair keeps the comment below it | test_unset_of_a_dash_line_pair_keeps_the_comment_below_it |
| block-to-inline keeps the key-line comment | test_a_block_value_set_inline_keeps_the_key_line_comment[...] |
a |+ scalar owns its blank lines |
test_keep_chomping_is_kept |
| anchors found before construction | test_operation_contract[anchor-alias-43] |
| duplicate keys compared by loaded value | test_a_segment_naming_two_keys_is_refused |
- - x refusal |
test_limit_a_list_written_directly_in_a_list_item_is_refused |
float gets a . |
test_a_large_float_reads_as_a_float_under_yaml_11_too |
| bare emit refuses U+2028 | test_a_line_separator_is_escaped_not_written_bare |
| an empty path segment is refused | test_a_path_that_cannot_be_read_is_refused[a..b] |
| batching | test_budget_many_operations_and_a_long_list (ceiling 5 s; measured 0.4 s, about 9 s unbatched) |
- Performance: 500 sets on 500 keys went from 30 s to 0.4 s, and a 2,000-item list set from 3.2 s to 0.1 s.
Learned
- ruamel's
construct_documenton composed nodes mutates a merge: it removes the<<pair fromnode.value. Anchors are therefore found before construction. true:and1:are duplicate keys to the loader, becauseTrue == 1in Python.
Left
- Splitter: the frontmatter span is still a parameter. Repoint to
split_frontmatter(refactor: one frontmatter splitter, every reader uses it #744):FrontmatterSpan(start=yaml_start, end=close_start, body=close_end), the_frontmatter_ofcall in_final_check, and the test helperspan_of. - FEEDBACK: the hallway entry moved to
feedback/2026-10-03-b6-p1-operations.md(process: retro 2026-10-03 decisions (feedback/ per entry; A1 shared only when configured; quality theme) #742).
…ry file's text (ADR-0042 P1) Refs #730. Set, nested set, append, remove, unset, replace body and add-sub-key, each a splice at the span ruamel's YAML 1.2 composer gives (amendment A4). Untouched keys keep their bytes; a replaced scalar keeps its quote style and its trailing comment (its column when the new value fits); a string YAML 1.1 would misread is quoted when emitted; EOL and BOM are kept. The post-check (decision 2) parses the result through the reader's split after every operation; a narrow edit that fails it is widened item-wise, copying unchanged items by bytes, or refused. Anchors, aliases, duplicate keys and non-YAML frontmatter are refused with a reason. A no-op returns the input object. Pure, and not wired: P3 wires it. The frontmatter span is a parameter: no new splitter. The post-check calls _frontmatter_of read-only; the one-frontmatter-splitter theme repoints both. end_mark is not where a node's bytes end: a block collection's runs past the comment line after it, a block scalar's past the blank lines, and an alias's is its anchor's own node. _end computes the real end; the anchor refusal walks the subtree, which catches aliases. ruamel's TimeStamp loses its tzinfo under deepcopy, so _plain makes it a datetime. Tests: the 17 hand-made shapes of #730 crossed with the operations under an oracle independent of the module (the reader, a line scan, decision 3 written again), exact-byte cases, refusals, two injected-fault tests for the post-check and widen, and a control showing the oracle fails today's round trip. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…s outside the named spans (#732 round 1) The property: apply() returns a patch whose hunks fall inside the spans of the values the operations name; every other byte is identical. The module docstring states who owns which bytes. Comments after an inline value, on a key line or on a block scalar's header are the pair's: a set keeps them. The post-check now computes the allowed spans by its own rule (end_mark walked back over blank and comment lines), not with the edit code's _end, and compares the bytes outside them directly; it used to trim comment lines and split keys on ':' (a comment between keys could be deleted or rewritten, and a re-quoted "x:y" key passed). The test oracle (tests/ file_operations_oracle.py) is a third mechanism: an indentation scan; the old one was the module's scan copied (Andon #745). Fixed from the cold read: an unset of a dash-line pair deleting the comment below it; a block value set inline deleting the key-line comment; keys that load as bool, null, int or a date (one lookup rule, _key_matches, for nodes and values; true and 1 are duplicates as the loader sees them); the widen crashing on a top-level scalar; untyped crashes on malformed paths and ops (a fuzz test now holds every failure to OperationRefusedError); a key starting with '-'; |2- and |+ kept; an append after a trailing |+ scalar; U+2028 written bare; 1e20 written as 1e+20 (a string to YAML 1.1); a..b silently normalised; the misleading out-of-range message. Constructing values from the composed nodes mutates a merge (<<) out of node.value, so anchors are found before construction. One YAML instance per thread, one compose per operation, and a run of operations on different top-level keys is spliced and checked once: 500 sets on 500 keys went from 30 s to 0.4 s, a 2,000-item list set from 3.2 s to 0.1 s. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…round 2) Decision 1 says every existing item's bytes stay; decision 2 says a widen copies each unchanged item's bytes. Round 2's cold read found both broken: - _edit_map decided which keys were new by id(): only one-character strings are interned, so every longer kept key looked new, the narrow edit made a duplicate, the parse failed and it widened, dropping comments (Hugo params, links[0], p.q). Keys are now compared by value under the key rule. - the widen rebuilt pairs in the new value's order and dropped the lines between items. It now walks the file's order, copying kept pairs and items with the lines between them, and is checked against the whole top-level value it rebuilds. Also: a complex key (? [x, y]) anywhere is refused with a reason (it raised TypeError); a tagged value anywhere (!custom, !!set, !!binary) no longer blocks every edit; a second operation on the same key in one call is checked against the text it applied to, not the call's original (L1); a value shared within one call ([d, d]) no longer aliases in the model (L2); a new key spelled like a document marker is quoted (L5). L3 (a batch indents new lines by the conventions before it) and L6 (cosmetic dash) are stated and pinned. The final check keeps what is independent: values and body through the reader's split, compared without _same. Tests: every narrow-supported sweep edit asserts widened == (); map-to-map sets with multi-character keys and comments; whole-list sets keeping the comments between kept items; the widen in file order; complex keys and tags in the fuzz texts. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
8fb5eea to
de2672e
Compare
Fix round 2, pushed at
|
| Guard removed | Test that fails |
|---|---|
| keys compared by value (B1) | test_a_map_set_with_long_keys_stays_narrow, test_an_item_map_set_keeps_its_comments |
| widen keeps lines between pairs | test_the_widen_of_a_map_keeps_the_files_order_and_its_comments |
| widen keeps lines between items | test_the_widen_of_a_list_keeps_the_comments_between_kept_items |
| widen owns the top-level value | test_the_widen_of_a_nested_operation_rebuilds_its_top_level_value |
| complex key refused | test_a_complex_key_anywhere_is_refused_with_a_reason |
TaggedScalar in _plain |
test_a_tagged_value_elsewhere_does_not_block_an_edit[!custom 1] |
values_equal fallback |
the same test, [!!set] and [!!binary] |
_unshare |
test_a_shared_value_in_one_call_does_not_alias_in_the_model |
| marker key quoted | test_a_new_key_spelled_like_a_document_marker_is_quoted |
| final check by the reader | test_the_final_check_catches_what_the_per_operation_check_missed |
Removed: the final check's byte replay. It repeated _try's check exactly, so deleting it failed no test.
Left:
- Repoint to
split_frontmatter(refactor: one frontmatter splitter, every reader uses it #744): the span parameter,_final_check's_frontmatter_of, and the test helperspan_of.
|
Correction to the round-2 comment: |
Refs #730 (B6, phase P1: the operation function, pure, not wired). ADR-0042 decisions 1, 2, 3, 6 and amendment A4.
Riskiest assumption, tested first
Assumption: ruamel's composer (YAML 1.2, A4) gives spans you can splice at. Run on ruamel 0.19.1 with a CRLF, anchor, block scalar and nested-map document:
start_mark.index/end_mark.indexare character offsets into the composed text, CRLF included.'it''s the #1 hit' # cspans exactly the quoted scalar, and the comment sits outside it.flag: nocomposes astag:yaml.org,2002:str(1.2), andd: 2026-01-15as a timestamp.n: *A) composes to the same node object as its anchor, son's value span ism's bytes. A splice onneditsm. The anchor refusal therefore checks the touched key's whole subtree for any node with.anchorset. An alias shares that node, so the check catches both the anchor and its uses.end_marktakes in the comment line that follows it (tags: [- x, - y]spans# comment above c\n). An unset bounded byend_markwould delete the comment above the next key. So a node's own end is computed recursively: the last child's end. For a scalar it isend_mark.end_marktakes in the blank lines that follow it. This is spike 1's "unset of a folded block scalar ate the next key". It is trimmed back to the last content character.|+keep chomping is a named case.So the composer works, but
end_markmust not be read as "where this key's bytes end". That rule goes in a comment at the helper.Plan
Goal, as properties.
apply(text, ops, span) -> (new_text, Report)is pure:_frontmatter_of). If it fails, the touched top-level key is widened item-wise, copying unchanged items by bytes. If the widened result fails too, the call raisesOperationRefused(reason). No existing bytes go through a serializer. New values are emitted by a small emitter that writes new lines only.title: Field notes, second draft # working title, needs the gap). A new key goes last. EOL and BOM are preserved. An emitted string that YAML 1.1 would misread is quoted, using the existing_is_yaml11_ambiguousinpyrite/utils/yaml.py(one rule, reused). Untouchedyes/no/nullare left alone.+++, JSON{), and a flow-style root mapping.textitself (result is text).Set(path, value)(top level or nested:links[2].relation,params.deep.k),Append,Remove(first equal item),Unset,ReplaceBody, andAddSubkey(path, key, value), which inserts one line at the item's indent and refuses if the key is already there with a different value. The unset of a block scalar is a named test.Module:
pyrite/storage/file_operations.py. It holds operations on an entry file's text, not on the model; "operations" alone would collide with the many service-level "operations" in the codebase.Where the frontmatter ends: the caller passes it as a
FrontmatterSpan(start, end, body)parameter._frontmatter_ofcannot give offsets; it returns parsed values and a stripped body. I call it read-only in the post-check, so the result is verified through the same split that reads use. The tests' span helper is the one splitter-shaped piece of code; the splitter theme repoints both.Callers found by grep: none. Nothing is wired (P3). Footprint: 1 new module, 1 new test module and hand-written fixtures under
tests/fixtures/file_operations/. No existing file is touched.Tests:
Out of scope: wiring (P3), locks (P2), every file #708 owns, and the frontmatter splitter.
Open questions (none block this):
ReplaceBodykeeps the file's leading blank lines and trailing whitespace, and treats a body equal after.strip()(the reader's form) as unchanged.Evidence (pushed
6717593b)tests/test_file_operations.py: 142 tests pass.scripts/test-affected --runat-n 4: 304 passed (14 files).check_contract. 0 failures.ruff check pyrite/ tests/andruff format --check pyrite/ tests/are clean.ruff check .still fails on dev'sdeploy/*/create-user.pyandscripts/*appointee*.py; this branch touches neither.0 red · 142 import-only · 0 unexpected pass · 0 n/a. Why every red is import-only: the module is new and pure. No code exists without it whose behaviour a test could hit, so every test fails at import. The behavioural evidence is the mutation table below, plustest_the_oracle_catches_the_round_trip, which shows the oracle fails today's ruamel round trip (candidate A) on the hugo, yaml11-words and odd-spacing shapes.Mutation checks (each guard removed alone, then the file restored):
_verifyreturns None)test_a_narrow_edit_that_fails_the_post_check_is_widened_not_returned,test_a_narrow_edit_that_breaks_the_value_is_widenedtest_a_double_quoted_scalar_stays_double_quoted,test_a_shorter_value_keeps_the_comment_column,test_a_flow_list_append_copies_its_neighbours_quoting,test_a_date_string_set_keeps_the_date_plain_refuse_anchorsno-op)test_operation_contract[anchor-alias-43],[anchor-alias-44],test_the_anchor_refusal_names_the_reason(the other two anchor cases are then refused by the post-check)_is_yaml11_ambiguousskipped)test_a_string_yaml11_would_misread_is_quoted_when_emittedtest_operation_contract[block-scalars-32],[-33],test_unset_of_a_folded_block_scalar_keeps_the_next_key,test_set_of_a_literal_block_scalar_keeps_literal_styletest_the_duplicate_key_refusal_names_the_key(added after the first run showed the loader's own error masked it)test_a_shorter_value_keeps_the_comment_columntest_a_narrow_edit_that_breaks_the_value_is_widened(asserts the hand-written item's bytes)Covers (property 6, "the operations"; property 7, "every shape"):
test_every_shape_is_in_the_tableasserts all 17 are in the tabletest_doc_example_one_field_edit_keeps_the_commentlinks[0].relation,params.social.mastodon,metadata.source_id(missing parents created)test_doc_example_append_leaves_existing_items_alonetest_removing_the_last_item_of_a_block_list_leaves_an_empty_list), absent (unchanged)test_unset_leaves_the_comment_above_the_key,test_unset_of_a_list_does_not_take_the_comment_after_it; block scalar:test_unset_of_a_folded_block_scalar_keeps_the_next_key,..._literal_...test_add_subkey_inserts_one_line_at_the_items_indent,test_add_subkey_refuses_an_existing_key_with_another_valuetest_a_set_of_a_list_is_reduced_to_item_edits)Where the frontmatter ends: the span is a parameter (
FrontmatterSpan)._frontmatter_ofis called read-only in_verify. The one-frontmatter-splitter theme should repoint_verify, and the test helperspan_ofintests/test_file_operations.py.🤖 Generated with Claude Code
Worker report (pushed 6717593)
test_the_oracle_catches_the_round_trip.end_markis not where a node's bytes end: block collections run past the comment that follows, block scalars run past blank lines, and an alias shares its anchor's node.TimeStampdropstzinfounderdeepcopy._endand_scalar_editdocstrings, and named tests.FrontmatterSpan)._verifyand the test helperspan_ofcall_frontmatter_of, so the splitter theme should repoint them._widen_editsis reached only by injected-fault tests.|+scalars and scalars with a leading space fall back to literal or double-quoted style.Fix round 1 (pushed 8fb5eea):
apply()is a value-level patchtests/file_operations_oracle.py) compare those bytes directly. The copied_key_blocksoracle is deleted.OperationRefusedError.true/false/nullkeys, under one key-lookup rule;|+/|2-;1e20;a..b.split_frontmatter(refactor: one frontmatter splitter, every reader uses it #744).