|
| 1 | +# Merging bd-ky14a (PR #235, hash-based FileIds) onto current `main` |
| 2 | + |
| 3 | +**Status:** context-gathering / awaiting go-ahead — do NOT start the merge yet |
| 4 | +**Braid:** bd-bu5y34fp (`discovered-from` bd-ky14a) |
| 5 | +**Implementation strand:** bd-ky14a |
| 6 | +**PR:** [#235](https://github.com/quarto-dev/q2/pull/235) |
| 7 | +**Branch:** `feature/bd-ky14a-pampa-hash-fileids` (this branch) |
| 8 | +**Author of this note:** investigation on 2026-06-15 |
| 9 | + |
| 10 | +## Why this document exists |
| 11 | + |
| 12 | +PR #235 has been open since before #231 was superseded. #231 → #260 |
| 13 | +(`feat(provenance): targeted paragraph/header inline editing`) is now |
| 14 | +**merged** (2026-06-10), so #235 is unblocked in the scheduling sense. |
| 15 | +But it is **not** a clean fast-forward. cscheid asked for a written |
| 16 | +context dump before any merge work begins, because the merge turned out |
| 17 | +to be genuinely complex — the work that landed in #260 re-introduced, in |
| 18 | +a new form, exactly the pattern bd-ky14a set out to remove. |
| 19 | + |
| 20 | +**This note describes the situation. It is not a plan to execute yet.** |
| 21 | + |
| 22 | +## What bd-ky14a does (recap) |
| 23 | + |
| 24 | +pampa's `ASTContext` historically gave the primary document `FileId(0)` |
| 25 | +(sequential), while `quarto_yaml` keyed files by `hash(filename)`. The |
| 26 | +two schemes collide: the same `FileId` means different files depending |
| 27 | +on which parser produced it. bd-ky14a makes pampa adopt |
| 28 | +`quarto_yaml::file_id_for_filename(name)` so a `FileId` is globally |
| 29 | +meaningful, and **removes** the `quarto_ast_reconcile::remap_file_ids` |
| 30 | +workarounds in `include_expansion.rs` and `engine_execution.rs` plus the |
| 31 | +parallel-`SourceContext` lockstep. |
| 32 | + |
| 33 | +The PR is a **single commit** (`eb726334`) on top of merge-base |
| 34 | +`65f13faf`. The user-visible effect: FileIds in the JSON wire format's |
| 35 | +`sourceInfoPool.d` go from `0,1,2…` to 64-bit hashes. |
| 36 | + |
| 37 | +## Branch topology |
| 38 | + |
| 39 | +``` |
| 40 | +65f13faf (merge-base: "bd-ky14a: clarify TDD intent in test strategy") |
| 41 | + │ |
| 42 | + ├─ eb726334 feature/bd-ky14a-pampa-hash-fileids ← PR #235 (1 commit) |
| 43 | + │ |
| 44 | + └─ …235 commits… origin/main (af21ccfc at time of writing) |
| 45 | +``` |
| 46 | + |
| 47 | +`origin/main` has moved **235 commits** past the merge-base. A trial |
| 48 | +`git merge origin/main` into the feature branch produces: |
| 49 | + |
| 50 | +- **1 real source-code conflict:** `crates/quarto-core/src/stage/stages/engine_execution.rs` |
| 51 | +- **61 snapshot conflicts:** `crates/pampa/snapshots/json/*.snap` |
| 52 | +- Several files auto-merged but **need semantic review** (see §"Auto-merged but suspect"). |
| 53 | + |
| 54 | +`gh pr view 235` reports `mergeable: CONFLICTING`. |
| 55 | + |
| 56 | +## The three things that make this complex |
| 57 | + |
| 58 | +### 1. `engine_execution.rs` — architectural conflict (the real one) |
| 59 | + |
| 60 | +This is the crux. When bd-ky14a was written, `EngineExecutionStage` |
| 61 | +ran a **single** engine pass over a `doc_ast` and used hash-based |
| 62 | +FileIds to register the engine's intermediate `<stem>.<engine>.rmarkdown` |
| 63 | +file directly — no remap, because the intermediate's filename hashes to |
| 64 | +its own `FileId` natively. |
| 65 | + |
| 66 | +Since then, the provenance / multi-engine work (bd-5yff4, et al.) on |
| 67 | +`main` **rewrote the stage into a multi-engine loop**: |
| 68 | + |
| 69 | +```rust |
| 70 | +// origin/main |
| 71 | +let mut merged_context = ast_context; |
| 72 | +… |
| 73 | +for (run_index, (engine, engine_config)) in to_run.into_iter().enumerate() { |
| 74 | + … |
| 75 | + // Register this engine's intermediate as a NEW SEQUENTIAL slot: |
| 76 | + let new_slot = quarto_source_map::FileId(merged_context.filenames.len()); |
| 77 | + … |
| 78 | + // Remap the executed AST's FileId(0) up into this engine's slot: |
| 79 | + quarto_ast_reconcile::remap_file_ids(&mut executed_ast, |
| 80 | + &|id| quarto_source_map::FileId(id.0 + new_slot.0)); |
| 81 | + let (reconciled_ast, plan) = quarto_ast_reconcile::reconcile(ast, executed_ast); |
| 82 | + ast = reconciled_ast; |
| 83 | + … |
| 84 | +} |
| 85 | +``` |
| 86 | + |
| 87 | +That is **the same `FileId(0) + remap` workaround bd-ky14a deletes**, now |
| 88 | +generalized to one sequential slot per engine and tracking a parallel |
| 89 | +`merged_context.filenames: Vec<…>` whose `.len()` is the next FileId. |
| 90 | + |
| 91 | +**The merge cannot keep both sides.** The hash-based design has to be |
| 92 | +re-expressed inside the new multi-engine loop. The good news is that the |
| 93 | +hash scheme makes the loop *simpler*, not harder: |
| 94 | + |
| 95 | +- Each engine's intermediate name already includes the engine |
| 96 | + (`<stem>.<engine>.rmarkdown`), so `file_id_for_filename(intermediate_name)` |
| 97 | + is distinct per engine **natively** — no `new_slot` counter, no |
| 98 | + `remap_file_ids`, no `filenames.len()` bookkeeping. |
| 99 | +- pampa on this branch reads the intermediate with `file_id: None`, which |
| 100 | + now derives `FileId(hash(intermediate_name))` automatically, so |
| 101 | + `executed_ast` already carries the right FileId before `reconcile`. |
| 102 | +- `reconcile` already tolerates mixed-provenance FileIds (its keep/replace |
| 103 | + decision is by content), so dropping the remap is safe. |
| 104 | + |
| 105 | +But this is a **re-implementation of a conflicted hunk against a changed |
| 106 | +control-flow shape**, not a textual merge. It needs care and its own |
| 107 | +tests (the existing bd-ky14a contract tests assume the single-engine |
| 108 | +shape — see §"Tests"). |
| 109 | + |
| 110 | +**Open design question:** does anything else still depend on |
| 111 | +`merged_context.filenames` as a dense, sequential, slot-indexed `Vec` |
| 112 | +(e.g. a downstream consumer that indexes `filenames[file_id.0]`)? Under |
| 113 | +hash FileIds, `file_id.0` is a 64-bit hash, not a dense index, so any |
| 114 | +such consumer breaks. This needs a grep audit before re-implementing. |
| 115 | + |
| 116 | +### 2. Semantic entanglement with #260 — `apply_node_edit.rs` |
| 117 | + |
| 118 | +#260 added `crates/pampa/src/apply_node_edit.rs` (the targeted |
| 119 | +inline-edit path used by q2-preview / hub-client). It contains: |
| 120 | + |
| 121 | +```rust |
| 122 | +// v1 assumption: single-file document → FileId(0). |
| 123 | +let Some(path) = lookup_block(&a_u, &target_si, FileId(0)) else { … }; |
| 124 | +``` |
| 125 | + |
| 126 | +A literal `FileId(0)` as "the primary file" is exactly the invariant |
| 127 | +bd-ky14a removes. **However**, the risk is smaller than it first looks: |
| 128 | + |
| 129 | +- `lookup_block(ast, target, _file_id)` **ignores** the file-id argument |
| 130 | + (it's `_file_id`). It matches on full `block.source_info() == target` |
| 131 | + equality, where `target_si` comes from the frontend-serialized |
| 132 | + `SourceInfo` (the resolved value, including its own `d`/file_id). So the |
| 133 | + `FileId(0)` literal is effectively dead — it does not constrain the match. |
| 134 | +- Correctness therefore depends on the **frontend and the AST agreeing on |
| 135 | + the FileId scheme**, not on the literal. After the merge both sides |
| 136 | + produce hash FileIds, so exact-match still holds. |
| 137 | + |
| 138 | +Two things still need an explicit audit before declaring this safe: |
| 139 | + |
| 140 | +- **`decode_compact_source_info`** reads `d` as |
| 141 | + `data.as_u64().unwrap_or(0) as usize`. `FileId(pub usize)` is **32-bit |
| 142 | + on `wasm32`** (the hub-client target) and 64-bit natively. |
| 143 | + `file_id_for_filename` likewise does `hasher.finish() as usize`, so the |
| 144 | + **same** truncation happens on both producer and consumer *within* wasm |
| 145 | + — consistent there. The danger is any path where a FileId computed |
| 146 | + **natively** (64-bit) is compared against one computed on **wasm** |
| 147 | + (32-bit truncation of the same hash). Confirm no such cross-target |
| 148 | + comparison exists (snapshots are native-only, so snapshot values will |
| 149 | + show the full 64-bit hash — expected). |
| 150 | +- The dead `FileId(0)` argument and its "single-file document" comment |
| 151 | + should be cleaned up (or made real) so the next reader isn't misled. |
| 152 | + |
| 153 | +### 3. The 61 snapshot conflicts are NOT mechanical |
| 154 | + |
| 155 | +The PR description frames the snapshot churn as a pure `"d": 0 → "d": <hash>` |
| 156 | +substitution. That **was** true against the merge-base, but `main` has |
| 157 | +since changed the JSON writer's *format*, so the feature branch's |
| 158 | +snapshots are now **doubly stale**: |
| 159 | + |
| 160 | +Comparing the two conflict sides in e.g. `math-with-attr.snap`: |
| 161 | + |
| 162 | +| Aspect | feature branch (HEAD) | origin/main | |
| 163 | +|---|---|---| |
| 164 | +| pool key | `"sourceInfoPool"` | `"p"` (renamed/compacted) | |
| 165 | +| attribute key | `"attrS"` | `"a"` (renamed) | |
| 166 | +| pool layout | one ordering | different dedup/ordering, different `id`/`s` indices | |
| 167 | +| `source:` header | `crates/pampa/tests/test.rs` | `crates/pampa/tests/integration/test.rs` (bd-xvdop test move) | |
| 168 | +| `d` value | `0` | `0` | |
| 169 | + |
| 170 | +So you **cannot** take `main`'s snapshot and swap in the hash, and you |
| 171 | +cannot take the feature branch's snapshot at all (wrong keys, wrong |
| 172 | +layout, wrong source path). The only correct path is: |
| 173 | + |
| 174 | +> Get the code merged and correct first, then **regenerate** all JSON |
| 175 | +> snapshots with `cargo insta` and review the diff to confirm the only |
| 176 | +> semantic change is `d: <small int> → d: <hash>` on top of `main`'s |
| 177 | +> current format. |
| 178 | +
|
| 179 | +This also means the "62 snapshots reviewed" checkbox in the PR body is |
| 180 | +**stale** and must be re-done after regeneration. |
| 181 | + |
| 182 | +## Auto-merged but suspect (review before trusting) |
| 183 | + |
| 184 | +These merged without conflict markers, but `main` changed them |
| 185 | +substantially, so the three-way merge may be *clean-but-wrong*: |
| 186 | + |
| 187 | +- **`crates/pampa/src/readers/json.rs`** — `main` rewrote this **+1041 |
| 188 | + lines** (the provenance JSON reader/writer, incl. the `sourceInfoPool`→`p` |
| 189 | + and `attrS`→`a` renames). The feature branch's 24-line change touches |
| 190 | + how `d` (file_id) is read/written. Auto-merge may have silently dropped |
| 191 | + or mis-placed the feature change. **Read the merged result by hand.** |
| 192 | +- **`crates/quarto-core/src/stage/stages/include_expansion.rs`** — `main` |
| 193 | + changed it (+7/−7) since base; the feature branch removes the |
| 194 | + remap/parallel-SourceContext workaround here. Confirm the removal still |
| 195 | + lands correctly on `main`'s version. |
| 196 | +- **`crates/wasm-quarto-hub-client/src/lib.rs`** — feature branch +17; |
| 197 | + verify against `main`'s current shape (this is the WASM entry surface |
| 198 | + q2-preview/hub-client consume). |
| 199 | +- **`crates/pampa/src/readers/qmd.rs`** — both sides changed (`main` +17, |
| 200 | + feature +11). Verify the `file_id` parameter plumbing survived. |
| 201 | + |
| 202 | +### Files the feature branch changes that `main` did NOT touch (should apply cleanly) |
| 203 | + |
| 204 | +`git diff --stat 65f13faf..origin/main` shows `main` did **not** modify |
| 205 | +these, so the feature branch's edits should graft on without conflict — |
| 206 | +but still build-check them: |
| 207 | + |
| 208 | +- `crates/pampa/src/pandoc/ast_context.rs` (+127, the core of the change) |
| 209 | +- `crates/quarto-source-map/src/context.rs` (+34, new |
| 210 | + `add_file_with_id` / `add_file_with_id_and_info`) |
| 211 | +- `crates/quarto-core/src/transforms/attribution_render.rs` (+147/−…) |
| 212 | +- `crates/quarto-lsp-core/src/document.rs`, `parse_document.rs`, |
| 213 | + `pipeline.rs`, `perf-harness` |
| 214 | + |
| 215 | +## Tests |
| 216 | + |
| 217 | +bd-ky14a shipped 5 contract tests (TDD anchors): |
| 218 | + |
| 219 | +1. `pampa::pandoc::ast_context::tests::bd_ky14a_with_filename_uses_quarto_yaml_file_id` |
| 220 | +2. `pampa::pandoc::ast_context::tests::bd_ky14a_source_context_indexed_by_hash_file_id` |
| 221 | +3. `quarto_core::bd_ky14a_file_id_contract::bd_ky14a_pampa_qmd_read_uses_quarto_yaml_file_id` |
| 222 | +4. `quarto_core::bd_ky14a_file_id_contract::bd_ky14a_sub_document_file_id_is_hash_based` |
| 223 | +5. `quarto_core::bd_ky14a_file_id_contract::bd_ky14a_fresh_source_context_renders_pampa_source_info` |
| 224 | + |
| 225 | +Note that test #3–#5 live in `crates/quarto-core/tests/bd_ky14a_file_id_contract.rs` |
| 226 | +as a **top-level `tests/*.rs` file**, which now violates the |
| 227 | +integration-test-layout rule (`.claude/rules/integration-tests.md`, |
| 228 | +landed via bd-xvdop while this branch was open). On merge it should move |
| 229 | +to `crates/quarto-core/tests/integration/bd_ky14a_file_id_contract.rs` |
| 230 | +and be registered in `main.rs`. |
| 231 | + |
| 232 | +**A new test is needed** for the multi-engine FileId behavior (§1): two |
| 233 | +engines on one document must each get a distinct hash FileId with no |
| 234 | +collision and no remap. The existing contract tests predate the |
| 235 | +multi-engine loop and won't exercise it. |
| 236 | + |
| 237 | +**A new test is wanted** for the §2 interaction: an `apply_node_edit` |
| 238 | +round-trip on a document whose primary FileId is a hash (not 0), proving |
| 239 | +the targeted-edit path still locates the destination block. |
| 240 | + |
| 241 | +## Suggested merge strategy (for when the go-ahead comes) |
| 242 | + |
| 243 | +1. Branch a working branch off `feature/bd-ky14a-pampa-hash-fileids` |
| 244 | + (keep the PR branch pristine until the merge is proven). |
| 245 | +2. `git merge origin/main`. Expect the conflicts above. |
| 246 | +3. Resolve `engine_execution.rs` by **re-implementing** the hash-based |
| 247 | + intermediate registration inside `main`'s multi-engine loop (drop |
| 248 | + `new_slot`/`remap_file_ids`/`filenames.len()` slot allocation). Do the |
| 249 | + `merged_context.filenames` consumer-audit grep first (§1 open question). |
| 250 | +4. For the 61 snapshot conflicts: `git checkout --theirs` is **also |
| 251 | + wrong** (wrong `d`). Instead resolve them to *anything that compiles*, |
| 252 | + then **regenerate** with `cargo insta accept` after the code is correct, |
| 253 | + and review the diff (§3). |
| 254 | +5. Hand-review the four "auto-merged but suspect" files (§"Auto-merged"). |
| 255 | +6. Address the `apply_node_edit` audit (§2) and clean up the dead |
| 256 | + `FileId(0)` arg. |
| 257 | +7. Move the contract test file under `tests/integration/` (§Tests). |
| 258 | +8. Add the two new tests (multi-engine FileId; apply_node_edit hash |
| 259 | + round-trip). |
| 260 | +9. Full gate: `cargo build --workspace` → `cargo nextest run --workspace` |
| 261 | + → `cargo xtask verify` (full, **not** `--skip-hub-build` — this touches |
| 262 | + `quarto-core`/`pampa`/`quarto-source-map` which the WASM leg depends on). |
| 263 | +10. End-to-end: `cargo run --bin q2 -- render <fixture>.qmd` and inspect |
| 264 | + the JSON pool; exercise a q2-preview targeted edit against the hash |
| 265 | + FileId (the §2 path) in a real browser session. |
| 266 | + |
| 267 | +## Decisions needed from cscheid before starting |
| 268 | + |
| 269 | +1. **Merge vs. rebase.** A single-commit branch could be `git rebase |
| 270 | + origin/main`'d instead of merged, giving a linear history and one |
| 271 | + clean commit to review. The conflict content is identical either way; |
| 272 | + the question is the history shape you want on the PR. |
| 273 | +2. **Scope of the `apply_node_edit` cleanup** (§2): minimal audit + comment |
| 274 | + fix, or fold the "primary file id" into a real helper now? |
| 275 | +3. **Whether to also tackle the lingering `FileId(0)` fallbacks** in |
| 276 | + `pampa/src/pandoc/location.rs`, `treesitter_utils/*`, |
| 277 | + `comrak-to-pandoc` (these are internal "no known file" sentinels, not |
| 278 | + primary-file assumptions — probably out of scope, but worth a yes/no). |
| 279 | + |
| 280 | +## Appendix: reproduction |
| 281 | + |
| 282 | +```bash |
| 283 | +git fetch origin |
| 284 | +git checkout -B trial origin/feature/bd-ky14a-pampa-hash-fileids |
| 285 | +git merge --no-commit --no-ff origin/main # observe conflicts |
| 286 | +git diff --name-only --diff-filter=U # 1 code + 61 snapshots |
| 287 | +git merge --abort |
| 288 | +``` |
0 commit comments