mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 13:28:57 +00:00
6114954236
An item whose LITERAL title starts with its own collection's slug plus a
slash — say "tasks/Setup" in collection "tasks" — stores an index row
byte-identical to a genuinely collection-qualified reference to an item
titled "Setup". Both read target_title = "tasks/Setup", collSlug =
"tasks". Renaming to "Renamed", the first must become [[Renamed]] and the
second [[tasks/Renamed]].
bracketRewriteAt inferred "this was qualified" from targetTitle merely
STARTING WITH collSlug + "/", which answers the second case for both. So
renaming the literal-titled item emitted [[tasks/Renamed]] — converting a
literal-title reference into a qualified one, resolved by a different
rule.
What that costs depends on what else the workspace holds, and both cases
are measured:
- With an item LITERALLY titled `tasks/Renamed` (slash included), the
link is STOLEN outright. resolveTitleTx tries an exact full-title
match before the qualified fallback, so that item wins and the
renamed item loses its backlink. Under the unfixed code the decoy
gains 1 backlink. Silent retarget, exactly as filed.
- With no such item, the emitted bracket still finds the renamed item,
because collSlug is that item's own collection and it now carries the
new title. The cost there is ambiguity wherever a same-titled sibling
exists, plus a later collection move breaking `[[tasks/X]]` where
`[[X]]` would have followed.
Repro delivered before the fix, as the filing required:
RewriteBracketAt("see [[tasks/Setup]] here", 4,
"tasks/Setup", "Renamed", "tasks")
=> "see [[tasks/Renamed]] here"
with the correct answer depending on information the function did not
have.
The discriminator is the renamed item's OLD title, and the cascade has
had it all along — cascadeTitleRename takes oldTitle and simply never
passed it down. It now rides on TitleEscaper (per-cascade, like
everything else there), and qualifiedFor decides by COMPARISON:
targetTitle == oldTitle -> literal
targetTitle == collSlug + "/" + oldTitle -> qualified
neither -> index drift, refuse
Literal wins when both could apply, and that is the correct precedence
rather than a convenient tiebreak: the renderer's stage 1 beats stage 2,
so a row pointing at this item resolved literally. resolveBrokenTitleLinks
already makes the same stage-1-over-stage-2 ruling for the same reason —
the discriminator existed in the codebase and was thrown away before
reaching the rewriter.
oldTitle is a required parameter of NewTitleEscaper rather than an
optional setter, so a caller that forgets it fails to compile instead of
silently getting the old behaviour back.
NO byte-length precondition guards the fold comparisons. strings.EqualFold
is Unicode simple case folding and case-equivalent strings can differ in
byte length — EqualFold("K", "K") (KELVIN SIGN) is true at 1 byte vs 3 —
so a length check is not a cheap pre-filter but a strictly narrower
predicate, and it made a qualified bracket whose title folds across
lengths read as index drift, leaving the link stale. The slug boundary is
still located by byte offset, which IS sound: collection slugs are
ASCII-lowercase by construction (store.slugify).
The frozen pre-refactor oracle is deliberately NOT updated — it is an
oracle, not live code. BUG-2830 is added to the named list of intentional
divergences from it, and the guarded corpus reaches the new function
through v0OldTitle, which states what the old implementation implicitly
assumed. Inputs where that assumption was WRONG cannot be produced by the
derivation and are pinned by name instead.
TestProjectRewrittenLen_IsLockstepWithTheRealPass grew a totalApplied
assertion: lockstep is trivially true when both sides refuse everything,
and this change makes the rewriter refuse more. It applies 5499 rewrites,
so it is measuring something. The codex-R2 overlap fixture was respelled
for the same reason — its two unrelated target titles are a shape a real
cascade cannot produce, so it would have decayed into two no-ops and lost
the regression; `[[A[[A]]]]` reproduces the no-op-then-overlapping-change
shape with reachable inputs and asserts it applies exactly one.
Negative-controlled four ways: reverting qualifiedFor to the prefix rule
kills the case-A regression in both its homes while the twin correctly
survives; making it accept drift kills the drift test (added because the
first mutation run showed that branch was unreachable by the whole
suite); the fold-length regression fails without the EqualFold fix; and
passing the WRONG oldTitle at the store call site kills five tests
including three pre-existing ones — the binding control, since the unit
tests pin qualifiedFor and only that shows the cascade hands it the right
value, which was the entire bug.
The severity above took two wrong turns before it was measured, and both
are recorded in the tests rather than quietly corrected. I first asserted
the retarget with a decoy that could not be stolen; then, finding that
decoy inert, concluded retargeting was impossible and wrote that into a
production doc comment. Neither conclusion came from reading
resolveTitleTx — both generalised one fixture's result. The two store
fixtures now split along exactly that line and each says which case it
pins.