Files
pad/internal
xarmian 69a6ebfe69 fix(links,store): make the rename cascade escape-aware in both directions (BUG-2805) (#1225)
* fix(links,store): make the rename cascade escape-aware in both directions (BUG-2805)

Item titles containing `]`, `|` or `\` did not round-trip through a rename.
Repro through the real API on TASK-2826 (Rook) with hex receipts; this fixes
both directions plus a third half that repro did not name.

Direction 1 — MATCHING. The rewriter compared the RAW bracket body against the
unescaped title, so a link stored as `[[Weird \] Title]]` never matched. The
link was left naming the OLD title while the reparse flipped its index row to
broken: content and index disagreeing, with the staleness visible to the user.
Matching now runs on the UNESCAPED body, full-body first and then the
split-on-unescaped-pipe form — the same preference order the parser and the
renderer use, which is what keeps literal-pipe titles like "A|B" working.

Direction 2 — EMISSION. The rewriter emitted the new title by plain
concatenation. A title containing `]` produced a bracket the grammar cannot
parse, so the reparse found no link and DELETED the index row — permanent
damage from an ordinary rename, since a later rename has no row left to
cascade. A title containing `\]` produced valid syntax for a DIFFERENT title.
Emission now escapes, byte-for-byte the same rule as the editor's
escapeWikiBody (markdown.ts:748).

Third half, not in the filing or the repro — the close-scan. The rewriter found
its closing `]]` with strings.Index, which is not the grammar: in `[[A\]]]` it
stops at the `]` belonging to the `\]` escape. That was latent only while the
rewriter never emitted escapes; this fix makes escaped bodies routine, so each
rename would have corrupted what the last one wrote. scanBracketBody now
implements the parser's body production exactly.

Allocation discipline carried forward from BUG-2804 R5: escaping happens ONCE
per cascade via a TitleEscaper, not per source and not per bracket. A cascade
renames one item to one title, but the projection and rewrite run once per
source and the scan bound admits very many sources when their titles are short
— escaping inside those calls would multiply an unbounded title by an unbounded
source count, which is the R5 defect in a new costume. The projection path
measures with escapedWikiBodyLen and builds nothing.

The frozen V0 oracle is now scoped rather than blanket: it stays authoritative
for bodies with no escape characters, and every deliberate departure is listed
by name with its reason in TestRewriteBracketAt_IntentionalDivergencesFromV0.
One departure is outside BUG-2805's scope and called out there: an empty body
`[[]]` is not a link under the parser's `+` production, so the scanner refuses
it where V0 would have MINTED a link from a non-link on a drifted offset.

Two accidental compatibilities the repro identified as non-reproducing
directions are pinned so the fix cannot trade two silent successes for two
silent failures: the legacy full-body-with-pipe fallback, and unescapeWikiBody's
leniency toward a stray backslash.

Instruments: a 4000-iteration property that every emitted title parses back to
itself; a scanner/parser parity property; end-to-end cascade tests for both
directions including a PERMANENCE check, since a fix that merely delayed the
index-row destruction by one rename would otherwise pass.

Gates: gofmt, vet, go build ./... clean; go test ./... PASS on SQLite and
Postgres; go test -race PASS on internal/links and internal/store; zero
failures, zero data races.

* test(links): lock the scanner's delimiter edges + correct a stale renderer citation (BUG-2805)

Codex R1 named scanBracketBody's edges as a review priority. Probing them
against the parser as oracle found no disagreement — all ten shapes agree,
including trailing backslash at EOF, `]]]` runs, an escape consuming the
would-be close, and the two where BOTH refuse (`[[]]`, and a bare `]` mid-body).
Locked in one assertion per shape so a failure names WHICH edge broke rather
than printing a random string.

Separately, and more substantive: extract.go carried a stale cross-file claim.
It said renderMarkdown at markdown.ts:300 uses a simpler `[^\]]+` regex and
therefore REJECTS escaped bodies, making escaped links index-only and never
clickable. That is no longer true. markdown.ts:300 is inside a doc comment now,
and BOTH wiki-link regexes in that file — renderMarkdown at :326 and
wikiLinksToMarkdown at :625 — use this package's exact escape-aware production;
the renderer comment at :323-325 names BUG-1744 as the change that aligned them.

The staleness is load-bearing for BUG-2805 in the direction that makes the bug
WORSE: the stale link a rename left behind was a working, clickable link rather
than an invisible one. It had also been quoted forward into TASK-2826's repro
as a live constraint, which is how a stale comment turns into a shared wrong
premise.

Verified by reading both regexes rather than by re-citing the comment.

* fix(links): refuse empty-title emission; agree with the parser on backslash-LF (BUG-2805, codex R2)

Codex R2, triaged per finding with a receipt. The orchestrator did not
spot-check these, so each was confirmed or refuted here first.

P1 — CONFIRMED as a test weakness, REFUTED as irrecoverable. The added test
counted index rows, so it would have passed with the row present and
target_item_id cleared. Measured: after a rename to "" the content survives, the
row survives, target_item_id goes NULL, and renaming back RESTORES it. So the
broken state is correct rather than damage — no title-form link can resolve to
an item with no title, and NULL is exactly what a renderer would resolve — and
it is recoverable precisely because the CONTENT was never destroyed. The test
now asserts the full post-state plus the recovery leg, which is what
distinguishes "broken but honest" from "irrecoverable".

Separately and genuinely destructive, found while probing the new TitleEscaper's
zero value: an empty new title emitted `[[]]`, which is not a link under the
parser's `+` production, so the reparse DELETED the row. Reachable two ways — a
zero-value escaper, and UpdateItem accepting a rename to "" because the
empty-title guard lives only in handleCreateItem. The rewriter now refuses to
emit an empty title segment in both the build and the projection path, kept in
lockstep. The door-level validation gap is BUG-2833.

P2 backslash-LF — CONFIRMED and introduced by this diff. Go's regexp `.` does
not match a newline, so `\\.` cannot consume one and the parser rejects the
body; scanBracketBody treated backslash-ANY as a pair and accepted it. Now
excludes LF only, which is what the Go parser does. Measured both ways before
and after.

Establishing that surfaced a PRE-EXISTING divergence, filed as BUG-2834: the Go
and JS wiki-link regexes are byte-identical source text, but `.` excludes only
LF in Go and all four line terminators in JS (measured with node), so Go indexes
a backslash-CR link the renderer will not render.

P2 qualified-vs-literal — PRE-EXISTING. Byte-identical output between the frozen
V0 oracle and current for both slug variants, so this diff neither introduced
nor changed it. Matches BUG-2830's mechanism, already filed.

P2 cross-workspace parity — PRE-EXISTING. Two receipts: extract.go's diff
against main is comment-only, and no line of this diff touches cross-workspace
parsing.

Gates: gofmt, vet, go build ./... clean; go test ./... PASS on SQLite and
Postgres; go test -race PASS on internal/links and internal/store; zero
failures, zero data races.
2026-08-31 15:33:40 -04:00
..
2026-03-26 01:52:36 +00:00