badness 0.9.0

A language server, formatter, and linter for LaTeX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
# Badness TODO

A LaTeX formatter, linter, and language server on a lossless rowan CST,
following **rust-analyzer's** architecture. See `AGENTS.md` for load-bearing
design decisions and invariants.

Single-crate package (not a workspace). Parser and formatter are **intentionally interleaved**: the formatter is the primary tool for stress-testing the parser.

Status: `[ ]` todo · `[~]` in progress · `[x]` done

--------------------------------------------------------------------------------

## Parser

## Formatter

- [x] **Math operator spacing.** A single space around each binary/relation atom
  (`a+2*1^5` -> `a + 2 * 1^5`, `x=-b` -> `x = -b`); unary signs and `^`/`_` scripts
  stay tight; command operators (`\cdot`, `\leq`) join via `math_atom_role`.
  Group bodies normalized (`x^{a+b}` -> `x^{a + b}`). Scientific notation (`1e-5`)
  is a known non-special-cased limitation.
- [ ] **Opaque-group layout non-determinism.** The content-kind taxonomy has
  landed: `ArgSpec` now carries a `ContentKind` enum (`Opaque`/`Prose`/
  `TokenList`) the formatter dispatches whitespace and break policy on
  (`DocumentBody` stays an environment-body concept via
  `EnvironmentSig::no_indent`; add it when a command-arg case appears). What
  remains is the non-determinism fix: `spans_multiple_lines` decides
  block-vs-inline from incidental source newlines, sidestepped for the
  `TokenList` kind but still governing every `Opaque` multi-line group. Give
  `Opaque` groups a deterministic layout policy that does not depend on
  incidental whitespace.
- [ ] **Long collapsed cite list overflow.** A `collapse` arg folds to one line
  even when the key list exceeds the width; it never breaks *at commas* (one
  key per line) as a fallback. Needs the token-list content kind to break on
  its own separators rather than the paragraph fill.
- [ ] Widen the prose-argument table (CWL ingest could feed it); consider gluing
  a prose arg onto its command line when a source break separates them.
- [ ] **Hanging continuation indent for wrapped statements (B', deferred ---
  blocked on structure).** A wrapped brace-body line ideally hangs its continuation
  one step in (`\node[…] at (2,3)`/`····{…};`) to read as a continuation rather
  than a sibling. This **cannot be idempotent** under the generic CST: the wrap
  becomes a real source newline, and on re-parse the continuation is just a line at
  the body indent (no marker says "continuation"), so the next pass flushes it ---
  `fmt(fmt(x)) != fmt(x)`. Flush-B sidesteps this precisely because there is no
  indent delta. The real fix needs a node that *owns the whole statement*, so layout
  derives from structure (source newlines insignificant). For the motivating case
  (`\node[…] at (2,3) {…};`) that node is a **TikZ path statement**: `at` keyword,
  `(coord)`, `;` terminator, `{label}`—none of which are TeX-surface facts
  (`;`/`at`/`()` carry no special catcode in plain TeX), so grouping them is
  package-specific grammar, out of scope for the generic parser (decisions #1, #2;
  non-goals). Belongs in a future sanctioned **TikZ-aware mode** (its own grammar,
  corpus, and AGENTS.md amendment), not a formatter patch.
- [x] Column-spec-aware L/C/R cell alignment and `\multicolumn` for the table
  environments. The `{lcr}` spec is parsed (`formatter::colspec`, layout-only,
  bails to all-left on any unrecognized token; `p`/`m`/`b` read as left, `*{n}{}`
  expands, `>{}`/`<{}`/`@{}`/`!{}` and rules produce no column) and threaded into the
  grid renderer: each cell aligns L/C/R, a right/center last column pads on the left
  only (no trailing whitespace), and a `\multicolumn{n}{spec}{…}` spans `n` columns
  (excluded from single-column widths, aligned within its span by its own spec, wide
  markup overflows rather than ballooning the data columns). Also handled:
  `\cmidrule(lr){2-3}` paren trim specs (the `(lr)` `WORD` and detached `{2-3}` group
  are consumed as part of the rule line) and the same-line `\\ \hline` form (a rule
  sharing a physical line with the preceding `\\` is normalized onto its own
  passthrough line).

## Linter

## Semantic layer & signatures

- [ ] How much of `\newcommand`/`xparse` to model for the signature DB. *(open
  decision)*

## Language server

### Feature status vs texlab

A deliberate diff against **texlab** (5.25.1) as the mature reference LSP. The
comparison is asymmetric, and the framing matters when triaging the items below.

- **Badness leads:** range formatting, on-type formatting, code-action quick-fixes
  (texlab's `codeAction` handler returns an empty array), a native linter (25
  LaTeX + 9 bib rules vs texlab's 4 built-ins plus a `chktex` shell-out),
  comment-run folding, the deterministic rule-based formatter, and a first-class
  bib formatter with entry sorting. texlab also ships **no** semantic tokens,
  signature help, selection ranges, or code lens—so those are not texlab gaps.
- **Badness trails (tracked below):** completion breadth (`### Completion`) and
  matching-pair highlight (`### Navigation & structure`). (Macro references/
  rename, package hover, and the change-environment refactor have since shipped;
  see the `[x]` items below.)
- **Deliberately not matched:** the typeset-adjacent features under
  `## Editor integration` (build, clean-aux, chktex passthrough, CSL rendering).

### Configuration & sync

- [x] config over LSP—the LSP now discovers `badness.toml` per document
  (`GlobalState::resolve_settings`, cached by anchor dir, cleared on
  `didChangeConfiguration`). A discovered config wins outright
  (file-wins); editor settings are the fallback. Both `[format]` (`line-width`,
  `indent-width`, `wrap`) and `[lint]` (`select`/`ignore`, applied via
  `RuleSelection` in the analyze/diagnostic/code-action paths) are honored. Two
  follow-ups remain:
  - Deliberately *not* done: plumbing `wrap` (or other knobs) through
    `EditorSettings` itself. A discovered config's `wrap` flows via `FormatConfig`,
    so no new editor knob was needed; `EditorSettings` stays `line_width`/`indent_width`.
- [ ] `workspace/diagnostic` (the workspace-wide pull)—deferred: it is a
  streaming/long-poll protocol (held-open request, per-uri result ids, partial
  results) that fits the one-shot id-bound read-job model poorly. Advertise
  `workspace_diagnostics: true` and add it once that plumbing exists; editors
  drive interactive diagnostics through `textDocument/diagnostic` meanwhile.

### Formatting

### Navigation & structure

- [x] Selection ranges (`textDocument/selectionRange`)—expand-selection from
  the CST's node hierarchy (group → argument → command → environment). Subsumes
  texlab's `findEnvironments` command: the enclosing-environment stack falls out
  of the CST-hierarchy expansion. (`lsp/selection_range.rs`: leaf token +
  `parent_ancestors` up to `ROOT`, consecutive-equal ranges collapsed; single-file
  read-pool job mirroring folding.)

### Completion

Badness offers command, environment, label, cite-key, bib field/type, and file
completion (`src/completion.rs`, `src/bib/completion.rs`). texlab's completion
breadth is its biggest lead (`crates/completion/providers/`); the specialized
sources below are missing.

- [x] **Color name + model, TikZ/PGF library completion**—small static datasets
  (`data/colors.json`, `data/tikz_libraries.json`) for
  `\color`/`\textcolor`/`\definecolor` and `\usetikzlibrary`/`\usepgflibrary`.
  Color-name completion also merges document `\definecolor`/`\colorlet` names.
  (Model completion is brace-arg only; the optional-arg form `\color[rgb]{…}` is
  not yet classified.)
- [x] **Argument-value enum completion** for fixed enumerated argument choices
  (`\pagestyle`, `\pagenumbering`, `\bibliographystyle`, `\theoremstyle`,
  `\mathversion`, `\fontshape`/`\fontseries`). A curated side dataset
  (`data/arg_enums.json`) keyed by command name then *brace*-group index, consumed
  by completion only (`semantic::signature::arg_enum_values`)—it is *not* a field
  on the formatter's `ArgSpec` (cold completion data stays out of the hot `Copy`
  struct), mirroring the color/TikZ datasets. Static built-ins only; merging
  document-defined values (e.g. `\fancypagestyle` names into `\pagestyle`,
  `\newtheoremstyle` into `\theoremstyle`) the way colors merge `\definecolor` is a
  clean follow-up on top of the table.
- [ ] *(Design decision)* **Package-scoped command completion.** texlab suggests
  only commands provided by the loaded packages (a package→command component
  model). Badness's signature DB is flat (curated + CWL + scanned); scoping
  completion to `\usepackage`-loaded packages needs package→command attribution.
  Open question, not a mechanical add.

### IntelliSense (signature DB)

### Code actions

## Package & class infrastructure (`.sty`/`.cls`/`.dtx`/`.ins`)

The document-level tools are mature; the next frontier is the **package
ecosystem**—class and package sources, and the literate `.dtx` format they
ship in. This is a large, multi-area subproject (parser + formatter + semantic).
It stays inside the AGENTS.md non-goals: bounded, statically-recognizable
patterns only, signatures *extracted, never executed*, no docstrip run, no TeX
engine.

### Parsing

- [x] **expl3 full catcode model.** `~` is a literal space (catcode 10) and spaces/tabs
  are ignored (catcode 9) inside expl3 regions, so the formatter owns in-region layout
  regardless of `WrapMode`: one statement per source line, brace-group code blocks indent,
  inter-token whitespace collapses to a single space, and `~` is a breakable space. The
  formatter recomputes region membership read-only (`formatter::core::expl3_regions`, sharing
  the lexer's `expl_toggle` set); the CST/lexer are untouched, so losslessness holds and the
  layout is idempotent by construction (catcode-9 whitespace is insignificant, so it supersedes
  the flush-B deferral for expl3 — see "Hanging continuation indent" above). See the expl3
  code-formatting note in `AGENTS.md` decision #1.
- [ ] **expl3 implicit detection in toggle-less `.dtx` (deferred).** Real expl3
  package sources (e.g. `ltx-talk-structure.dtx`) carry no in-file `\ExplSyntaxOn`/
  `\ProvidesExpl*`; expl3 is declared in the parent `.dtx`/build, and `@@` is a
  docstrip module prefix (`%<@@=mod>`). Treat `macrocode` bodies as expl3 when the
  file carries a static expl3 signal (a `%<@@=mod>` guard or `\ProvidesExpl*`
  anywhere). Needs a file-level scan plus the `macrocode` save/restore interaction
  (mirror `at_letter`).

### Formatting

### Semantic and integration

## Performance & hardening

- [ ] Fuzzing (losslessness must hold on arbitrary input).
- [~] Large-doc benchmarks (`hyperfine`, criterion); flamegraph hot paths.
  Formatter speed bench vs `tex-fmt`/`latexindent` landed (`benches/compare_format.sh`,
  `task bench`, writes `benches/benchmark_results.json`, which feeds the docs
  benchmark page `docs/src/reference/benchmarks.md`). In-process parse/format micro-bench +
  flamegraph hot paths landed (`benches/formatting.rs`, `task bench:micro`/`bench:profile`;
  see the profiling item below). Still pending: bib + lint benchmarks.
- [ ] Intra-file incremental reparse (reuse green subtrees on contained edits).
- [ ] `wasm32` build for a web playground.

## Editor integration

texlab bundles PDF-workflow features. Only position mapping (no typesetting by
badness) is admissible; the rest are explicit non-goals recorded here so they are
not re-proposed.

- [ ] **Forward/inverse SyncTeX search (no typesetting).**
  `textDocument/forwardSearch` (a custom LSP method) locates a configured PDF and
  drives an external viewer; inverse search receives a viewer position over IPC
  and answers with `window/showDocument`. Badness never typesets—it only maps
  source↔PDF positions via SyncTeX and shells the viewer. texlab:
  `crates/commands/fwd_search` + the `ipc` crate.
- **Non-goals (do not re-propose):** build/latexmk orchestration
  (`textDocument/build`), clean-aux/artifacts, `chktex` passthrough (the native
  linter supersedes it), and citeproc/CSL bibliography rendering (badness shows a
  lightweight entry summary; full CSL is out of scope). These sit inside the
  AGENTS.md "We never typeset" non-goal. **Note (boundary split):** the
  kpsewhich/distro package DB is *no longer* a blanket non-goal—a **read-only TEXMF
  file index** feeding *LSP navigation only* now exists (`project::texmf`; see
  "TEXMF index" under Package & class infrastructure and the AGENTS.md "LSP
  environment awareness" section). What stays a non-goal is a distro query feeding
  the **formatter** (it would break formatting determinism).

## BibTeX/BibLaTeX

- [ ] Cross-file `undefined-string`: a `@string` defined in one `.bib` and used
  in another resolves only once a project-level `@string` union exists (today
  single-file-sound, same caveat as `unused-string`).
- [ ] `unused-entry`: a `.bib` entry never targeted by any `\cite`-family
  command, project-aware behind the same closed+rooted namespace gate as
  `unreferenced-label`/`undefined-ref` (the bib linter has `unused-string` but no
  `unused-entry`). Report-only. texlab: `UnusedEntry`.
- [x] Bib-aware LSP completion: `@string` macro names in value position, field
  names per entry type (type-scoped, hiding fields already present), and entry
  types after `@` (`src/bib/completion.rs`); plus `\cite` key completion on the
  `.tex` side, resolved cross-file via `ResolvedCitations` (`src/lsp.rs`
  `cite_completion_items`).
- [ ] Bib document-symbol outline completeness: `src/bib/outline.rs` surfaces
  regular entries only; consider `@string`/`@preamble`/`@comment` blocks (and a
  richer `SymbolKind`/detail).
- [x] `title-capitalization` refinement: a single mid-word capital now counts only
  when it is the *first* capital of a lowercase-initial word (the camelCase brand
  pattern, `iPhone`/`eBay`/`pH`). A later capital in a capital-initial word is a
  surname particle (`McDonald`, `DeForest`, `MacArthur`) or style token (`LaTeX`),
  so it is left alone---no curated name list needed, at the cost of an occasional
  miss (a capital-initial acronym like the cell line `HeLa`). `[A-Z]{2,}` runs
  (`DNA`) still flag regardless of word shape.
- [ ] Shared component-finder: `ResolvedCitations` duplicates the union-find +
  component assignment from `ResolvedLabels` (`project/citations.rs`); factor one
  helper when a third consumer appears.

--------------------------------------------------------------------------------

## rust-analyzer conformance audit

A structured audit of badness against **rust-analyzer** (its architectural
inspiration), across five layers: parser/event-stream, CST/AST/trivia, salsa
incrementality, the LSP server, and the diagnostics/linter model. A read-only
rust-analyzer checkout for triage lives at `.rust-analyzer-ref/` (git-ignored;
`git clone --depth 1 https://github.com/rust-lang/rust-analyzer` to recreate) so
line references stay stable while we work through the items.

**Verdict:** the overwhelming majority of divergences are deliberate,
AGENTS.md-sanctioned, or forced by the LaTeX/catcode domain, and are sound. The
green-node `no_eq` soundness argument, the byte-range error side channel, the
SubTok math split, the catcode-in-lexer modes, the recovery-anchor set, the
firewall-layered cross-file salsa queries, the read-snapshot/threadpool split,
cancellation-via-salsa, version-gated diagnostic publish, incremental document
sync, UTF-16/UTF-8 column math, and comment-suppression coverage were all checked
and found faithful (or better). One agent-reported concern was a **false
positive**: cross-file lint rules are *not* inert in the editor — `analyze_tex`
(`lsp.rs:3023`) and `compute_lint_findings` (`lsp.rs:3278`) thread full project
resolution; only the salsa cancellation/cache-miss fallbacks (`fallback_*`,
`lsp.rs:3186`/`3311`) pass `None`, by design.

The items below are the genuine divergences worth a separate look. None is a
known-live bug; they are latent gaps, hardening opportunities, and editor-UX
capabilities RA has that badness does not. Severity in brackets.

### Robustness / hardening

- [x] **[high] Worker thread panic guard** (`lsp.rs`, `Worker::handle_job_guarded`).
  A panic in the single write-phase worker (`seed_dir`, `apply_watched_change`,
  `project_members`, a poisoned-mutex `.expect`) used to unwind and kill the
  worker thread; the main loop kept running but every `job_tx.send` silently
  no-ops (`let _ = …`), so the server became a quiet zombie. Now the run loop
  routes every job through `handle_job_guarded`, which `catch_unwind`s the panic
  and logs it (mirroring the read pool's per-job isolation, `task_pool.rs:47`), so
  one bad job degrades to a single logged error instead of killing the server.
  (Follow-up still open: recovering from a *poisoned mutex* — see next item — so a
  read-pool panic while holding `files`/`query_log` can't leave the worker unable
  to touch the db.)
- [x] **[med] Mutex poisoning no longer cascades a read panic into worker death**
  (`incremental.rs`, `recover_poison`). The `files`/`query_log` locks now recover
  the inner guard on poison (`.lock().unwrap_or_else(recover_poison)`) instead of
  `.expect("… poisoned")`, so a read-pool job that panics while holding one can't
  cascade into the writer panicking on its next `.lock()`. Sound because each lock
  guards a plain map/vec mutated atomically per access, with no cross-call
  invariant a panic could leave half-updated. Guarded by
  `poisoned_files_lock_recovers`.
- [x] **[med] Global parser step/loop limiter** (`grammar.rs`,
  `Parser::step` + `PARSER_STEP_LIMIT`). The lookahead primitives (`kind`,
  `nth_kind`) now tick a peek budget that resets on every cursor advance (via
  `bump` *or* the math-split `pos += 1` fast path — keyed on `pos`, not `bump`),
  so the surviving count is the number of *consecutive* peeks with no token
  consumed. Exceeding the ceiling aborts loudly instead of hanging — the RA
  catch-all (`parser.rs`), converting "provably terminating by reading the code"
  into "cannot hang on adversarial or malformed input" (fuzzing, a corrupt corpus
  file). A **release-mode** guard (real `assert!`, not `debug_assert`); the async
  callers already recover from a parse panic, so a wedged parse degrades to a
  logged error. Measured overhead on the 95 KB parse bench: within run-to-run
  noise (~1-2%). Guarded by `step_guard_trips_when_wedged` and
  `step_budget_resets_on_cursor_progress`. (Complements the Fuzzing item under
  Performance & hardening.)
- [x] **[low] `--fix` post-application losslessness/parse guard**
  (`main.rs`, `debug_assert_fixes_preserved`). Before `fix_file` writes the
  fixpoint result back, a debug-only, kind-aware (LaTeX + bib) guard asserts the
  output (1) reconstructs losslessly and (2) carries no *new* parse errors vs. the
  original (`errors_before`), so a mis-built fix span that corrupts structure is
  caught before it reaches disk. Compiled out of release builds.
- [x] **[low] Debug open/close balance assertion**
  (`grammar.rs`, `debug_assert_balanced`). After `parse()` builds the event
  stream, a debug-only pass walks it (+1 `Start`, -1 `Finish`) and asserts it
  never goes negative and ends at zero — catching a leaked `open()` or an
  unbalanced `precede` splice at parse time (counting *all* start/finish events
  regardless of how emitted), rather than as an opaque rowan `finish_node` panic
  later. The cheap post-hoc analog of RA's per-`Marker` `DropBomb`; compiled out
  of release builds.

### Incrementality (salsa)

- [ ] **[med, latent] No input durability tier.** RA sets
  `Durability::HIGH/MEDIUM/LOW` per source root (`base-db/change.rs`); badness's
  setters (`incremental.rs:622`, `SourceFile::new`) never call
  `.with_durability(...)`, so every input is implicitly `LOW`. Harmless *today*
  because badness has zero rarely-changing salsa inputs (config, the built-in
  signature DB, and CWL/package/texmf/aux data all live in `LazyLock`/`OnceLock`
  or a plain `HashMap`, deliberately outside the db per the hermeticism tenet).
  But the moment config or package data is promoted into salsa — the natural
  direction for cross-file work — it must be `HIGH`/`MEDIUM` or every keystroke
  will invalidate it. Record the requirement now.
- [ ] **[low] `Project` re-interned from a fresh member `Vec` per request**
  (`incremental.rs:889`, `Analysis::resolve_project`/`scope_signatures`/…).
  Interning dedups by value, so an unchanged sorted membership yields the same id
  and the memo survives — correct *provided* member construction is always
  identically sorted and deduped. It is today (via `tracked_files`,
  `incremental.rs:672`); flag as fragile if member-list construction order ever
  drifts, since a silent id churn would recompute every cross-file query.

### CST / AST / trivia

- [ ] **[low, perf] `LineIndex` re-scans the buffer per call and requires the
  caller to pass `text` back in** (`text/line_index.rs`). Wide-char *correctness*
  is right (the common bug — verified: UTF-16/UTF-8 column math, astral chars,
  CRLF all handled). But RA's standalone `line-index` precomputes a per-line
  wide-char table at construction and owns no text, so queries are O(wide-chars)
  with no re-walk and no "hand the same buffer back" misuse hazard. Consider
  precomputing the table if line-index shows up in a profile.
- [ ] **[low, latent] No `SyntaxNodePtr`/`AstPtr`.** RA stashes stable node
  pointers in salsa data to re-resolve across reparses; badness sidesteps this by
  storing the `GreenNode` directly (decision #7) and carrying diagnostics as
  byte-ranges (decision #4), so the need has not arisen. Latent: a future feature
  that must stash a *stable node identity* in a salsa query (resolving a
  completion/hover target to a specific node across edits) has no primitive for
  it, and byte-ranges alone do not survive edits.

### Diagnostics / linter model (editor-UX capabilities RA has)

- [x] **[med] LSP diagnostic tags.** `lint_to_lsp` now sets `tags` via
  `lint_diagnostic_tags`, a presentational rule-id→tag map (kept out of the
  `Diagnostic` struct so the CLI renderer is untouched): `unreferenced-label`  `Unnecessary` (editor dim), and `deprecated-command`/`obsolete-environment`/
  `primitive-command``Deprecated` (strike-through). Extend the match as more
  rules earn a tag.
- [x] **[med] `related_information` / secondary spans.** `Diagnostic` now carries
  a `related: Vec<RelatedInfo>` (`{path, start, end, message}`), populated by
  `duplicate-label`: the intra-file branch points at the first definition's key
  range (precise, same file), the cross-file branch at each other definer. It
  renders as `DiagnosticRelatedInformation` in LSP (`lint_to_lsp`  `lint_related_to_lsp`) and as annotate-snippets context annotations in the CLI
  (`render_pretty`, cross-file loaded via `source_for`). Messages stay unchanged
  (additive), so concise output and `related`-blind clients are unaffected.
  **Cross-file secondaries are file-level (`0..0`, the document start), not the
  exact `\label` byte range:** `ResolvedLabels` / the `file_labels` firewall
  deliberately store names→paths only so a label move backdates and doesn't
  rebuild cross-file resolution (tenet #2). Precise cross-file carets would need a
  separate per-file name→range query resolved lazily at the render/LSP layer —
  a possible follow-up.
- [x] **[low] `code_description` (rule doc URL).** `lint_to_lsp` now sets
  `code_description.href` to `https://badness.dev/reference/linter-rules.html#<rule>`
  (the mdBook anchor equals the rule id), so editors deep-link the rule's docs.
  Gated by a `link_docs` flag threaded from the analyze/code-action sites: the
  LaTeX arms pass `true`, the bib arms `false` (bib rules aren't catalogued on that
  page yet — the `code` still carries the rule id, just without a link). Wire the
  bib rules in once they get a reference page.
- [ ] **[low, latent] Fix model can't express cross-file fixes.** `Fix` now
  carries a set of disjoint edits in the diagnostic's own file
  (`linter/diagnostic.rs`), applied atomically (`linter/fix.rs`: a fix lands
  with all its edits or none), so multi-location rewrites are expressible —
  `dollar-display-math` and `obsolete-environment` swap both delimiters/names
  in place with the body untouched. What remains of RA's `SourceChange` is the
  *per-file* dimension: a fix spanning files (e.g. rename a label and its
  `\ref`s across a multi-file project) has no representation, and the CLI's
  per-file fixpoint (`main.rs::fix_file`) has no way to apply one. Defer until
  a rule needs it; when it comes, key edit sets by path (the `RelatedInfo`
  pattern: real paths stamped at rule time) and teach both apply paths
  (`lint --fix`, LSP `WorkspaceEdit`) to honor them atomically.

### Maintainability (not a conformance gap, surfaced by the audit)

- [x] **[low] Factor the duplicated trivia/blank-line scanners.** The blank-line
  and comment-bind logic was re-implemented across ~five methods in `grammar.rs`
  (`peek_meaningful`, `at_paragraph_break`, `trivia_run_is_separator`,
  `binding_run`, `at_script`), each re-walking trivia with slightly different
  newline-counting and a near-identical `.dtx` margin/guard comment block. RA
  concentrates the equivalent in one `n_attached_trivias`. Now a single
  `scan_trivia(from, CommentMode)` helper returns a `TriviaScan` (`next`,
  `next_kind`, `saw_blank_line`, `comment_start`); all five methods are thin
  wrappers over it. `CommentMode::Stop` folds in `at_script`'s narrower regime (a
  comment ends the line and stops the scan) without a second walker.

--------------------------------------------------------------------------------

## Open decisions to revisit

- [ ] How much of `\newcommand`/`xparse` to model. *(Semantics)*
- [ ] Formatter opinionatedness: configurable vs. fixed. *(Formatter)*
- [ ] `.dtx` two-layer model: a preprocessor that splits doc/code layers, or a
  single lexer mode with margin-aware tokens? *(Package infrastructure)*