The resolver only treated purely numeric references as work package ids,
so semantic identifiers fell through to a typeahead search and failed.
Route both numeric and semantic ids to the show endpoint instead.
Scope semantic-id resolution through `WorkPackage.visible` so a reference to a
work package outside the current user's visibility falls through to literal
text. Without this, semantic identifiers (guessable, project-prefixed) would
disclose existence: a hidden `#PROJ-1` would resolve and render a mention,
whereas a non-existent `#GHOST-1` would fall through — a discoverable oracle.
Spec pins the new contract; matches the in-app preload pipeline which already
uses `WorkPackage.visible`.
Drop the `to_i.to_s` round-trip on the numeric branch. `numeric_id?` already
verifies the matched string is canonical, so the round-trip is a no-op.
Tighten the alias spec to assert the rendered `data-text` carries the current
identifier, not just that the old one is absent. Trim the `render_link`
comment to the load-bearing claim (defence-in-depth escape).
In semantic mode, the PDF text-macro handler resolves `#PROJ-1`,
`##PROJ-1`, and `###PROJ-1` through `find_by_display_id` and emits a
`<mention>` whose `data-id` carries the display id straight through to
the PDF renderer. Cache misses (unknown identifier, deleted WP) fall
through to literal text. Historical aliases resolve to the current
identifier. Classic mode rejects semantic-shape input — `#PROJ-1` in
a classic-mode export stays literal.
Numeric input keeps its current path: the matcher emits the mention
from the matched id without a DB lookup.
`render_link` HTML-escapes both interpolated values. The matcher
regex (`ID_ROUTE_CONSTRAINT`) already constrains shape to
`\d+|[A-Z][A-Z0-9_]*-\d+`, so the escape is defence-in-depth: a
future caller bypassing the matcher cannot regress into HTML
attribute injection.
The fast-path filter `Links.applicable?` matches `/#[A-Z\d]/` so
semantic-only bodies reach the full regex.
Downstream `Exports::PDF::Common::Markdown#wp_mention_macro` is
updated to `find_by_display_id` in PR 23093. This commit must land
after that PR — otherwise its existing `id[/\d+/]; find_by(id:)`
extracts the trailing digits from `data-id="PROJ-7"` and resolves
to WP id 7.
Cuts comments that restate code mechanics, narrate the journey, or
pile on implementation detail. Genuine WHYs (perf rationale of the
to_i.to_s round-trip, the alias second-query reason, leading-zero
rejection, classic-mode preload skip) all stayed.
`Macros::Links` extends `ResourceLinksMatcher`, so the regex broadening
in the previous commit also matches `#PROJ-1` shapes inside markdown
that's about to be exported as PDF. Without an explicit guard, the PDF
custom handler would silently emit `<mention data-id="0">` (since
`"PROJ-1".to_i == 0`) — broken-looking output that's hard to attribute.
Tighten `WorkPackagesLinkHandler#applicable?` to reject semantic-shape
input. Override `call` so it doesn't fall through the parent's
cache-driven path (PDF rendering walks Markly nodes via a separate
pipeline that doesn't populate the per-render cache). Cite WP #74366 in
the comment as the follow-up that adds semantic-id support to the PDF
side via the Markly walk in `app/models/exports/pdf/common/macro.rb`.
New specs assert `#PROJ-1` falls through to literal text in PDF output
and never produces a `data-id="0"` mention, both alone and mixed with
numeric references.
The macro replacement iterates matches manually to substrings by index.
When multiple macros appeared in the same text segment (e.g.,
workPackageValue:"dueDate" workPackageValue:"startDate"), the first
replacement left later macros in the line unprocessed, so raw macro
markup ended up being in the PDF output.
Using gsub with the macro regexp ensures we replace every match in a
single pass even when multiple macros are used in a row