Content provenance for decoded values - #582
Merged
Merged
Conversation
Contributor
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
A source range recorded against a YAML scalar was only correct while the
decoded value matched its source bytes one for one. It often doesn't: an
escape collapses two bytes to one, and a block scalar drops a newline
plus its indent on every line. Anything mapping a decoded offset back to
the file then landed short, and the error grew with each collapse.
ConfigValueKind::Scalar becomes a struct variant carrying
`content_source_info` next to `yaml`, so the decoded content carries its
own provenance instead of being assumed byte-identical to the source.
241 call sites updated for the new shape.
span_assert now resolves Concat and Substring{parent: Concat} spans
piecewise rather than composing them affinely over the enclosing hull,
which is what produced wrong spans for these values, and no longer
reports OutOfBounds when the start offset is past EOF.
Requires quarto-source-map 0.1.3 and quarto-yaml 0.1.3 for four
map_offset fixes, and quarto-error-reporting 0.2.2. A tripwire test pins
the Concat exclusive-end value across the bump.
Design notes for this and the two following commits are in
claude-notes/plans/2026-08-20-provenance-{1,2,3}-*.md.
…mxa44voa)
Carries content provenance out to the code that reads it, so positions
reported to users and tools point at the right bytes.
- Attribute values: an attribute's recorded range no longer includes its
surrounding quotes, and survives escapes in the value. Driven from the
markdown attribute decoder via ProvenanceBuilder.
- Link and image titles get their own bound spans.
- fig-cap caption content gets provenance through the cell-options path.
- Both YAML re-parse paths thread content provenance; quarto-config's
now-unused YAML converter is made private so the two cannot diverge.
- Config sources bind by root_file_id rather than resolve_byte_range.
- A YAML string scalar arriving with no content provenance now warns.
It is non-fatal and un-coded: it signals an internal inconsistency,
not something a user can act on, and the render still completes.
- A panic while rendering one diagnostic no longer takes down the run.
- Provenance is excluded from the incremental-rebuild hash, so recording
it does not defeat caching.
TypeScript reader: `resolveChain` composed a Substring's local offsets
onto its parent's *resolved* start, and derived a Concat's exclusive end
by mapping the last content character and adding one. Both hold only
while content matches source byte for byte. Concretely, `{tail="y\*"}`
resolved to `y\` instead of `y\*` — the +1 landed inside a trailing
two-byte escape — and every inline after a line break in a YAML block
scalar drifted by the bytes the indent collapse removed. Replaced with
`mapContentRange`, which walks a half-open content range down to source
coordinates. @quarto/annotated-qmd 0.1.1 -> 0.2.0, breaking.
SNAPSHOT CHANGES: 4 modified, 0 added, 0 removed.
crates/pampa/snapshots/json/{002,003}.snap,
horizontal-rules-vs-metadata.snap, table-caption-attr.snap
All four move the same way: meta scalars now emit a separate content
provenance pool entry, so the pool gains entries and every later `s`
index shifts by the number inserted ahead of it. No range value
changes meaning. 002/003 also drop a stale insta `assertion_line`
header. The 20 ts-packages/annotated-qmd/examples/*.json fixtures are
regenerated for the same reason.
…(bd-mxa44voa) Audits every site converting an offset to a location or composing ranges, and fixes the ones that were wrong. Findings and the 26-row classification are in claude-notes/research/2026-08-21-provenance-audit-findings.md. - comrak merges entity and escape runs into a single Text node, so a position-derived offset drifted by the collapsed bytes for every node after the first in that run. A lockstep walker now advances source and content positions together. - `empty_source_info` becomes Generated rather than Original(0, 0..0), so a missing location stops masquerading as byte 0 of the first file and pointing diagnostics at unrelated text. - `offset_to_location_bytes` is floored, closing the third and last offset-to-location rounding rule. - codeblock_shorthand's body search is bounded to between the fences, so it cannot match text belonging to a surrounding container. - `is_gapless` is narrowed to the sub-range actually queried instead of answering for the whole span. - The dead shortcode_string range is removed; q_2_28 and q_2_33 use the resolving accessor. - The per-diagnostic guard in render.rs is wrapped. Covered end to end by a regression test for the original crash, plus selective-replay coverage.
gordonwoodhull
force-pushed
the
feature/yaml-provenance
branch
2 times, most recently
from
August 23, 2026 22:40
f5591bb to
6f4a230
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
SourceInforanges recorded for decoded values were computed as if the decoded text were the source text. That holds only while a value's content matches its source byte for byte, which decoding routinely breaks:"a\*b"→a*b)Two independent bugs followed from the same bad assumption, both in
resolveChainon the TS side:+1lands mid-escape:{tail="y\*"}resolved to[49,51](y\) instead of[49,52](y\*). A value whose escape isn't last resolves correctly either way, which is why no existing test caught it.Substring's local offsets were composed onto its parent's resolved start — affine composition over the hull. Inside a YAML block scalar every inline after a line break drifts by the bytes the newline-plus-indent collapse removed: the second line'sStr "line"resolved to[24,28](li) instead of[26,30]. This reaches consumers throughinline-converter.ts's per-inlinegetAnnotatedParseSourceFields.Affine composition over a hull is the shared defect. It had already been found and fixed once, then reintroduced at three more sites.
Approach
Give decoded content its own provenance rather than deriving it arithmetically from the node.
ConfigValueKind::Scalarbecomes a struct variant carryingcontent_source_info: Option<SourceInfo>alongsideyaml. 241 call sites swept.AttrSourceInfo.attributes[i].1becomes aSourceInfoover the decoded value instead of aRangeover the node. The attribute path is driven from the markdown decoder viaProvenanceBuilder.resolveChain's two arms are replaced bymapContentRange(id, start, end), which walks a half-open content range down to source coordinates:Originalshifts into file space,Substringshifts into the parent's content space, andConcatunions the contributions of the pieces it overlaps. A piece whose declared content length equals its own extent contributed verbatim and can be indexed into; any other piece is a replacement, is opaque, and contributes its whole source span.span_assertresolvesConcatandSubstring{parent: Concat}piecewise, and stops reportingOutOfBoundswhen the start offset is past EOF.Requires quarto-source-map 0.1.3 and quarto-yaml 0.1.3 for four
map_offsetfixes; quarto-error-reporting 0.2.2.Audit
The third commit enumerates every offset→location conversion and range composition — by consumer, since a producer's range can only be judged against what reads it — and fixes what it found:
Textnode, so a position-derived offset drifts by the collapsed bytes for every node after the first in a run. Replaced with a walker that advances source and content positions in lockstep.empty_source_info→Generated, notOriginal(0, 0..0). An absent location was indistinguishable from byte 0 of the first file, so diagnostics anchored at unrelated text.offset_to_location_bytesfloored — the third and last offset→location rounding rule.codeblock_shorthandbody search bounded to between the fences, so it can't match text belonging to an enclosing container.is_gaplessnarrowed to the queried sub-range rather than answering for the whole span.shortcode_stringrange deleted;q_2_28/q_2_33moved to the resolving accessor.Breaking
@quarto/annotated-qmd0.1.1 → 0.2.0. An attribute value's range no longer includes its quotes — the range now describes decoded content, not the source node. Consumers trimming a character off each end should stop.Review notes
annotated-qmdexample fixtures regenerated. Uniform cause: meta scalars now emit a separate content-provenance pool entry, so the pool grows and latersindices shift by the count inserted ahead of them. Verified structurally —Original(type-0) entry counts unchanged in every file; only type-1 entries added.main's full-pathnamefield preserved.002/003also drop a stale instaassertion_line.mainchanged.test_div_kv_keys_have_tight_sourceasserted "Values keep their quotes: P1 includes a node's own delimiters." That is exactly what this PR changes. The key assertions in that test —main's tight-attribute-key fix (bd-1d6io) — are untouched and pass.content-provenance.test.tspins the new quote-trimmed ranges case by case, including both failure modes above.ConfigValueKind::Scalar(...)sites added onmainduring the rebase window were swept. They merge cleanly and then fail to compile, so they're invisible to a conflict count.Verification
cargo nextest run --workspace: 13130 passed, 199 skipped, 0 failed, against a livemainbaseline of 13045/198/0. The +85 is 86 added − 1 removed. The removal iscallout::tests::escaped_title_span_stays_inside_the_attribute, which pinned theattribute_value_sourcelength-arithmetic workaround this PR deletes.cargo xtask verify: all steps passed — custom lints + clippy, rustfmt, Rust build, tree-sitter tests, CRLF parity, ts-packages build, WASM build, hub-client build + tests, preview-renderer integration (51/51 files, 591 passed), trace-viewer, q2-preview-spa.The wasm32 test target is checked separately, since
cargo check --all-targetson a native host never compiles it:This caught a real defect in the first commit's sweep, now fixed:
wasm_lua.rsmatched onConfigValueKind::scalar(..), the constructor function, in a pattern position (error[E0164]). The blanketScalar(→scalar(rewrite is correct for construction but invalid in a match arm, and the file is#![cfg(target_arch = "wasm32")], so no native build sees it. Patterns now use the struct variant. Repo-wide scan confirms this was the only pattern-position occurrence.Design notes and the audit classification:
claude-notes/plans/2026-08-20-provenance-{1,2,3}-*.md,claude-notes/research/2026-08-21-provenance-audit-findings.md.