Files
pad/internal/links/extract_test.go
T
xarmian 905876af04 feat(backlinks): cross-workspace wiki-links + request-independent ACL (Phase 2b) (#622)
* feat(backlinks): cross-workspace wiki-links + request-independent ACL (Phase 2b)

Phase 2b of PLAN-1593 (TASK-1597). Completes the wiki-link reverse
index by indexing and surfacing `[[workspace::REF]]` cross-workspace
references. Builds on Phase 2a's title work (PR #621). Phase 3
(TASK-1596) owns the UI/MCP/CLI rendering changes.

What changed

- internal/store/backlinks_visibility.go (new): request-independent
  ACL helper `Store.ResolveBacklinksVisibility(userID, workspaceID,
  includeDeletedItems)`. Mirrors the role-determination + collection-
  merge logic from server.guestResourceFilterCore but doesn't depend
  on a request context, so cross-ws traversal can compute per-source-
  workspace ACLs without a `workspaceRole(r)` lookup. The Codex
  planning-round review caught the prior plan reusing the request-
  scoped helper as a hidden architectural cost; this is the resolution.

- internal/server/server.go: guestResourceFilterCore refactored to
  delegate to the new store helper. Keeps the request-scoped wrapper
  signature stable for all existing handler call sites; only the
  internals move.

- internal/links/extract.go: lift the Phase-2a workspace_ref emit
  gate. WikiLinkKindWorkspaceRef now flows through ExtractWikiLinks
  alongside ref and title kinds. parseBody recognition was already
  in place from earlier rounds.

- internal/store/wiki_links.go: WikiLinkKindWorkspaceRef branch in
  replaceWikiLinks stores (target_workspace_id, target_ref) verbatim,
  resolving the slug→ID via new resolveWorkspaceSlugTx (with per-call
  cache so repeated `[[ws::X]]` in one body don't re-query). Unknown
  slugs persist with target_workspace_id=NULL — broken-link
  semantics, identical to existing ref/title patterns.

- internal/store/wiki_links.go: new `Store.GetCrossWorkspaceBacklinks`
  enumerates accessible workspaces via Store.GetUserWorkspaces (which
  includes guest-only access — broader than membership query), then
  per-workspace computes visibility via ResolveBacklinksVisibility and
  runs the SQL backlinks query with the per-ws (FullCollectionIDs,
  GrantedItemIDs) predicate inline. Results sorted by updated_at DESC
  in Go, paginated globally. Per-workspace safety cap (offset+limit)
  prevents one workspace from dominating the global slice.

- internal/store/wiki_links.go: new `Store.CountBacklinks` for same-ws
  pagination boundary detection. Needed so the handler knows where
  the cross-ws tier begins for pages 2+.

- internal/models/backlink.go: new `SourceWorkspaceSlug string`
  (omitempty) field. Populated only by cross-ws rows; same-ws rows
  leave it empty so the existing wire shape is preserved.

- internal/server/handlers_backlinks.go: union pagination across
  same-ws and cross-ws tiers. Same-ws first (matches the renderer's
  UI mental model — your own workspace's links at the top of the
  panel). Count-based slice math handles pages 2+ correctly when
  same-ws is exhausted.

Tests

- internal/links/extract_test.go: workspace_ref forms emit correctly
  (bare, display alias, mixed case, invalid-slug fallback to title).
- internal/store/wiki_links_xws_test.go (new): six cross-ws scenarios
  plus a role-matrix test:
  - end-to-end cross-ws index + query
  - non-member sees nothing
  - guest with collection grant sees only that collection
  - guest with item grant sees only the granted item
  - unknown workspace slug → broken row, no query results
  - same-ws rows leave SourceWorkspaceSlug empty
  - ResolveBacklinksVisibility role matrix (admin/full member/guest
    with grants/non-member non-grant)

Out of scope (Phase 3 / TASK-1596)

UI rendering of cross-ws backlinks (workspace badge + workspace-
prefixed ref), MCP `pad_item.action: backlinks` cross-ws fields,
CLI display tweaks.

PLAN-1593 / TASK-1597.

* fix(backlinks): admin enumeration + cross-prefix ref fallback + unbounded perWsCap (Codex round 1)

Three P2 findings from Codex round 1 against PR #622:

Finding 1 — admin users miss cross-ws backlinks. `GetUserWorkspaces`
returns only memberships + grant-only guest workspaces, but
RequireWorkspaceAccess (middleware_auth.go:481) gives admins
implicit access to every workspace. An admin querying for backlinks
would silently miss links from workspaces they're not explicitly a
member of.

Fix: in GetCrossWorkspaceBacklinks, branch on user.Role:
  - admin → s.ListWorkspaces() (every non-deleted workspace)
  - non-admin → s.GetUserWorkspaces (memberships + grants)
Stale user IDs return empty result rather than erroring.

Finding 2 — cross-ws ref matching doesn't handle cross-prefix moves.
Same-ws is immune because target_item_id is resolved at parse time
and survives renames/moves; cross-ws resolves at query time, so a
`[[other-ws::OLD-42]]` row written before the target moved from
OLD→NEW collection wouldn't match a query under the NEW ref.

Fix: in queryCrossWorkspaceBacklinksForWorkspace, dual ref-match
clause: exact `LOWER(wl.target_ref) = LOWER(?)` OR
`LOWER(wl.target_ref) LIKE LOWER('%-N')` where N is the item_number
from the target ref. Pad prefixes are alphanumeric with no internal
`-`, so trailing `-N` uniquely identifies the number suffix — no
false positives like "TASK-142" matching "%-42" (LIKE anchors to
the trailing literal).

Finding 3 — per-workspace cap of 1000 silently broke pagination
beyond offset>=1000. The 1000 ceiling was defensive paranoia; the
correct math is offset+limit per workspace (worst case all rows
come from one workspace and the global slice still needs that
many).

Fix: drop the 1000 ceiling. perWsCap = offset+limit unconditionally.
For runaway offsets the per-workspace transfer cost is proportional;
documented as a known characteristic (callers shouldn't be paging
past offset=10000 anyway).

Regression tests:
- TestWikiLinks_CrossWorkspaceAdminSeesAllWorkspaces: admin sees
  cross-ws backlink without being a workspace member.
- TestWikiLinks_CrossWorkspaceRefNumberFallback: move target to new
  collection, query under new ref, old-ref-stored row still surfaces.

PLAN-1593 / TASK-1597.

* fix(backlinks): honor OAuth/MCP token workspace allow-list (Codex round 2)

Codex round 2 P1: cross-workspace backlinks bypassed the OAuth/MCP
token's workspace allow-list (TASK-952). A token consented for
workspace A but with the underlying user having access to B would
still surface source rows from B via the cross-ws query — leaking
data outside the token's consent scope.

Fix: thread `allowedWorkspaceSlugs []string` through
GetCrossWorkspaceBacklinks. Handler populates it from
TokenAllowedWorkspacesFromContext(r.Context()):

  - nil → no token gate (PAT or pre-TASK-952 token, allow all)
  - "*" wildcard → allow all
  - explicit list → strict slug membership

Workspace enumeration skips any source workspace whose slug isn't
in the allowlist. The same-ws path is unchanged because
RequireWorkspaceAccess already gated the target workspace against
the allow-list (so we only reach this handler when the target IS in
the list).

Regression test in wiki_links_xws_test.go covers four shapes: nil,
wildcard, target-only (blocks cross-ws), explicit source-workspace
(allows cross-ws).

PLAN-1593 / TASK-1597.

* fix(backlinks): normalize limit at handler boundary (Codex round 3)

Codex round 3 P2: the backlinks handler parsed ?limit=N but didn't
normalize it before computing the same-ws/cross-ws pagination
split. GetBacklinks and GetCrossWorkspaceBacklinks each clamp >300
internally, but the handler's 'remaining := limit - len(sameWs)'
used the original (potentially huge) value. With ?limit=301 and
more than 50 same-ws backlinks, the first page would mix cross-ws
in before same-ws was exhausted, violating the documented tier
order.

Fix: clamp 'limit' to <=300 at the handler boundary, before any
pagination math runs.

PLAN-1593 / TASK-1597.

* fix(backlinks): normalize same-workspace [[ws::REF]] to ref-kind (Codex round 4)

Codex round 4 P2: `[[<current-ws>::TASK-1]]` was being indexed as a
workspace_ref row with target_workspace_id = current workspace. But
the same-ws GetBacklinks query requires target_item_id (workspace_ref
rows leave it NULL), AND GetCrossWorkspaceBacklinks explicitly skips
the target workspace — so the link rendered and navigated correctly
in the UI but no backlink ever surfaced.

The renderer's L307 short-circuits same-workspace fully-qualified
form to behave identically to `[[REF]]`; the index must follow.

Fix: in replaceWikiLinks, normalize a workspace_ref link to ref-kind
when its slug resolves to the current workspace. The promotion
canonicalizes the ref (via new links.CanonicalizeRef exported alias)
so `[[ws::task-5]]` stores the same canonical shape as `[[TASK-5]]`.

Tests:
- TestWikiLinks_CrossWorkspaceSameWorkspaceQualifiedNormalized:
  same-ws fully-qualified `[[ws::REF]]` surfaces in same-ws backlinks
  and is absent from cross-ws backlinks.

PLAN-1593 / TASK-1597.

* fix(backlinks): same-ws qualified ref miss doesn't title-fallback (Codex round 5)

Codex round 5 P2: my round-4 normalization was too aggressive. It
promoted `[[<current-ws>::REF]]` to ref-kind and let the regular
ref branch handle it — including the title-fallback path that
runs on ref miss.

But the renderer's same-ws qualified branch (markdown.ts:472-481)
does NOT title-fallback: a ref miss in that path returns the
wiki-link verbatim (broken). Only the bare `[[REF]]` path
(markdown.ts:513) falls through to title lookup.

So my normalization could create ghost backlinks for source bodies
like `[[ws::ISO-9001]]` when an item titled "ISO-9001" exists but
no ISO collection — the renderer renders broken text, but the
index would point at the title-matching item.

Fix: handle same-ws qualified refs inline at the top of the loop,
BEFORE the switch dispatches. Insert as ref-kind row (resolved or
NULL) and `continue` past the switch. Bypasses the title-fallback
path entirely, mirroring the renderer's behavior.

Regression test in wiki_links_xws_test.go pairs same-ws qualified
miss (must NOT title-fallback) with bare ref miss (SHOULD
title-fallback) to lock the asymmetry in.

PLAN-1593 / TASK-1597.
2026-05-24 13:27:40 -04:00

902 lines
30 KiB
Go

package links
import (
"strings"
"testing"
)
// TestExtractWikiLinks_RefForm covers the Phase 1 happy path: ref-form
// links outside any code region are extracted with correct kind, ref,
// display, and position.
func TestExtractWikiLinks_RefForm(t *testing.T) {
tests := []struct {
name string
content string
want []WikiLinkRef
}{
{
name: "single bare ref",
content: "See [[TASK-5]] for context.",
want: []WikiLinkRef{
{Kind: WikiLinkKindRef, Ref: "TASK-5", Position: 4},
},
},
{
name: "ref with display alias",
content: "Per [[TASK-5|the auth fix]] we...",
want: []WikiLinkRef{
{Kind: WikiLinkKindRef, Ref: "TASK-5", Display: "the auth fix", Position: 4},
},
},
{
name: "multiple refs same content",
content: "[[TASK-1]] depends on [[BUG-2]] and [[IDEA-3]].",
want: []WikiLinkRef{
{Kind: WikiLinkKindRef, Ref: "TASK-1", Position: 0},
{Kind: WikiLinkKindRef, Ref: "BUG-2", Position: 22},
{Kind: WikiLinkKindRef, Ref: "IDEA-3", Position: 36},
},
},
{
name: "ref repeated in same body",
content: "[[FOO-1]] and again [[FOO-1]].",
want: []WikiLinkRef{
{Kind: WikiLinkKindRef, Ref: "FOO-1", Position: 0},
{Kind: WikiLinkKindRef, Ref: "FOO-1", Position: 20},
},
},
{
name: "long collection prefix",
content: "Linked to [[PLAYBOOK-12345]].",
want: []WikiLinkRef{
{Kind: WikiLinkKindRef, Ref: "PLAYBOOK-12345", Position: 10},
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := ExtractWikiLinks(tc.content)
assertLinks(t, got, tc.want)
})
}
}
// TestExtractWikiLinks_TitleForms covers Phase 2a title emission:
// plain `[[Title]]` and `[[collection/Title]]` are now returned with
// kind=title and the body stored verbatim. The collection-qualified
// form is stored AS-WRITTEN (e.g. "docs/Setup") so the resolver can
// try the renderer's order — full-key title match first, `/`-split
// only on miss — without losing information about how the link was
// typed. Codex finding #3 from the planning round.
func TestExtractWikiLinks_TitleForms(t *testing.T) {
t.Run("plain title", func(t *testing.T) {
got := ExtractWikiLinks("Click [[Some Title]] here.")
if len(got) != 1 {
t.Fatalf("expected 1 link, got %d: %+v", len(got), got)
}
if got[0].Kind != WikiLinkKindTitle {
t.Errorf("Kind: got %q, want title", got[0].Kind)
}
if got[0].Title != "Some Title" {
t.Errorf("Title: got %q, want %q", got[0].Title, "Some Title")
}
if got[0].Position != 6 {
t.Errorf("Position: got %d, want 6", got[0].Position)
}
})
t.Run("collection-qualified title stored verbatim", func(t *testing.T) {
got := ExtractWikiLinks("Look at [[docs/Setup]] for help.")
if len(got) != 1 {
t.Fatalf("expected 1 link, got %d", len(got))
}
if got[0].Kind != WikiLinkKindTitle {
t.Errorf("Kind: got %q, want title", got[0].Kind)
}
// Verbatim — DON'T pre-split. An item literally titled
// "docs/Setup" must resolve before the qualified-form
// fallback fires; storing the split here would lose that.
if got[0].Title != "docs/Setup" {
t.Errorf("Title: got %q, want %q", got[0].Title, "docs/Setup")
}
})
t.Run("title with display alias", func(t *testing.T) {
got := ExtractWikiLinks("See [[Some Title|the page]] for context.")
if len(got) != 1 {
t.Fatalf("expected 1 link, got %d", len(got))
}
if got[0].Kind != WikiLinkKindTitle {
t.Errorf("Kind: got %q, want title", got[0].Kind)
}
if got[0].Title != "Some Title" {
t.Errorf("Title: got %q, want %q", got[0].Title, "Some Title")
}
if got[0].Display != "the page" || !got[0].HasDisplay {
t.Errorf("Display: got %q (has=%v), want %q (true)",
got[0].Display, got[0].HasDisplay, "the page")
}
})
t.Run("multiple titles in one body", func(t *testing.T) {
got := ExtractWikiLinks("[[First]] and [[Second Title]] and [[third]].")
if len(got) != 3 {
t.Fatalf("expected 3 links, got %d", len(got))
}
wantTitles := []string{"First", "Second Title", "third"}
for i, w := range wantTitles {
if got[i].Kind != WikiLinkKindTitle {
t.Errorf("[%d] Kind: got %q, want title", i, got[i].Kind)
}
if got[i].Title != w {
t.Errorf("[%d] Title: got %q, want %q", i, got[i].Title, w)
}
}
})
}
// TestExtractWikiLinks_TitlePreservesWhitespace regresses Codex
// round 9 P2 against PR #621. The renderer doesn't trim before
// title matching (web/src/lib/utils/markdown.ts:541-543), so an
// item titled "Foo" doesn't match `[[ Foo ]]`. The extractor must
// preserve whitespace in the title kind too, or the index would
// surface backlinks the UI can't actually click on. Ref/workspace_ref
// shape detection still trims (the renderer does the same).
func TestExtractWikiLinks_TitlePreservesWhitespace(t *testing.T) {
t.Run("leading and trailing whitespace preserved in title", func(t *testing.T) {
got := ExtractWikiLinks("See [[ Foo ]] for details.")
if len(got) != 1 {
t.Fatalf("expected 1 link, got %d", len(got))
}
if got[0].Kind != WikiLinkKindTitle {
t.Errorf("Kind: got %q, want title", got[0].Kind)
}
if got[0].Title != " Foo " {
t.Errorf("Title should preserve whitespace, got %q want %q", got[0].Title, " Foo ")
}
})
t.Run("padded ref still parses as ref (whitespace forgiveness)", func(t *testing.T) {
got := ExtractWikiLinks("See [[ TASK-5 ]] please.")
if len(got) != 1 {
t.Fatalf("expected 1 link, got %d", len(got))
}
if got[0].Kind != WikiLinkKindRef {
t.Errorf("padded ref: Kind got %q want ref", got[0].Kind)
}
if got[0].Ref != "TASK-5" {
t.Errorf("padded ref: Ref got %q want TASK-5", got[0].Ref)
}
})
t.Run("internal whitespace preserved in title", func(t *testing.T) {
got := ExtractWikiLinks("See [[Project Goals]] (two spaces inside).")
if len(got) != 1 {
t.Fatalf("expected 1 link, got %d", len(got))
}
if got[0].Title != "Project Goals" {
t.Errorf("internal whitespace lost: got %q want %q", got[0].Title, "Project Goals")
}
})
}
// TestExtractWikiLinks_WorkspaceRefForms covers Phase 2b emission
// (TASK-1597). The gate that suppressed workspace_ref kinds in Phase
// 2a is lifted now that the cross-workspace query path and request-
// independent ACL helper are in place. Each `[[ws::REF]]` body
// produces a WikiLinkKindWorkspaceRef row with the parsed workspace
// slug + ref.
func TestExtractWikiLinks_WorkspaceRefForms(t *testing.T) {
t.Run("bare workspace ref", func(t *testing.T) {
got := ExtractWikiLinks("Cross [[other-ws::TASK-9]] over.")
if len(got) != 1 {
t.Fatalf("expected 1 link, got %d", len(got))
}
if got[0].Kind != WikiLinkKindWorkspaceRef {
t.Errorf("Kind: got %q, want workspace_ref", got[0].Kind)
}
if got[0].WorkspaceSlug != "other-ws" {
t.Errorf("WorkspaceSlug: got %q, want %q", got[0].WorkspaceSlug, "other-ws")
}
if got[0].Ref != "TASK-9" {
t.Errorf("Ref: got %q, want TASK-9", got[0].Ref)
}
})
t.Run("workspace ref with display alias", func(t *testing.T) {
got := ExtractWikiLinks("Cross [[other-ws::TASK-9|see this]] too.")
if len(got) != 1 {
t.Fatalf("expected 1 link, got %d", len(got))
}
if got[0].Kind != WikiLinkKindWorkspaceRef {
t.Errorf("Kind: got %q, want workspace_ref", got[0].Kind)
}
if got[0].Display != "see this" || !got[0].HasDisplay {
t.Errorf("Display: got %q (has=%v), want %q (true)",
got[0].Display, got[0].HasDisplay, "see this")
}
})
t.Run("mixed-case ref canonicalized in workspace_ref kind", func(t *testing.T) {
// parseBody validates the ref segment against refPattern
// case-insensitively but does NOT canonicalize for the
// workspace_ref kind — the cross-ws resolver compares against
// the foreign workspace's own canonical prefix at query time.
// What we store IS the verbatim ref the user wrote; matching
// happens via LOWER() at lookup. Document this so a future
// reviewer doesn't try to "fix" it.
got := ExtractWikiLinks("[[other-ws::task-9]]")
if len(got) != 1 || got[0].Kind != WikiLinkKindWorkspaceRef {
t.Fatalf("expected workspace_ref kind, got %+v", got)
}
// Verbatim preserved (lowercase here).
if got[0].Ref != "task-9" {
t.Errorf("Ref: got %q, want task-9 (verbatim)", got[0].Ref)
}
})
t.Run("invalid workspace slug falls through to title", func(t *testing.T) {
// `INVALID-SLUG::TASK-1` — uppercase slug doesn't match the
// workspace slug pattern. parseBody falls through to title
// kind, body stored verbatim.
got := ExtractWikiLinks("[[INVALID::TASK-1]]")
if len(got) != 1 || got[0].Kind != WikiLinkKindTitle {
t.Fatalf("expected title fallback for invalid slug, got %+v", got)
}
if got[0].Title != "INVALID::TASK-1" {
t.Errorf("Title: got %q, want %q", got[0].Title, "INVALID::TASK-1")
}
})
}
// TestExtractWikiLinks_TitleCodeBlockExclusion ensures Phase 2a
// titles are excluded from fenced / inline code just like refs were
// in Phase 1. The exclusion happens at the gate-independent
// outer scan (linkStart vs ranges), so it's worth a smoke test to
// catch any regression from lifting the gate.
func TestExtractWikiLinks_TitleCodeBlockExclusion(t *testing.T) {
content := "Real: [[Outside Title]]\n" +
"```\n" +
"Fake: [[Inside Title]]\n" +
"```\n" +
"Inline `[[Also Inside]]` then [[Last One]]."
got := ExtractWikiLinks(content)
if len(got) != 2 {
t.Fatalf("expected 2 titles, got %d: %+v", len(got), got)
}
if got[0].Title != "Outside Title" || got[1].Title != "Last One" {
t.Errorf("got titles %q / %q, want %q / %q",
got[0].Title, got[1].Title, "Outside Title", "Last One")
}
}
// TestExtractWikiLinks_CodeBlocksExcluded asserts the headline behavior
// decision from PLAN-1593: [[REF]] inside fenced or inline code is NOT
// a real link and must be skipped.
func TestExtractWikiLinks_CodeBlocksExcluded(t *testing.T) {
t.Run("fenced block excludes refs inside", func(t *testing.T) {
content := "Real: [[OUTSIDE-1]]\n" +
"```\n" +
"Example: [[INSIDE-1]] and [[INSIDE-2]]\n" +
"```\n" +
"After: [[OUTSIDE-2]]"
got := ExtractWikiLinks(content)
refs := refStrings(got)
want := []string{"OUTSIDE-1", "OUTSIDE-2"}
if !equalStringSlices(refs, want) {
t.Errorf("got refs %v, want %v", refs, want)
}
})
t.Run("fenced block with language tag", func(t *testing.T) {
content := "Before: [[A-1]]\n" +
"```bash\n" +
"echo [[B-1]]\n" +
"```\n" +
"After: [[C-1]]"
got := ExtractWikiLinks(content)
refs := refStrings(got)
want := []string{"A-1", "C-1"}
if !equalStringSlices(refs, want) {
t.Errorf("got refs %v, want %v", refs, want)
}
})
t.Run("inline code excludes ref inside", func(t *testing.T) {
content := "The `[[FAKE-1]]` syntax has shipped; see [[REAL-2]] for examples."
got := ExtractWikiLinks(content)
refs := refStrings(got)
want := []string{"REAL-2"}
if !equalStringSlices(refs, want) {
t.Errorf("got refs %v, want %v", refs, want)
}
})
t.Run("unclosed fence runs to EOF", func(t *testing.T) {
// A draft with an opened-but-never-closed fence should treat
// everything after the fence as code (matches markdown
// rendering of the same draft).
content := "Before: [[A-1]]\n" +
"```\n" +
"After: [[B-1]] // inside dangling fence — must be skipped"
got := ExtractWikiLinks(content)
refs := refStrings(got)
want := []string{"A-1"}
if !equalStringSlices(refs, want) {
t.Errorf("got refs %v, want %v", refs, want)
}
})
t.Run("inline code spans single newline (CommonMark §6.1)", func(t *testing.T) {
// Per CommonMark, an inline-code span can cross a single
// newline. The renderer treats `pre\n[[INSIDE-1]]\npost`
// as a single code span — the embedded link must NOT be
// indexed. Codex round-9 P1.
content := "Outside [[A-1]]\n" +
"start `pre\n[[INSIDE-1]]\npost` end\n" +
"Outside [[B-1]]"
got := ExtractWikiLinks(content)
refs := refStrings(got)
want := []string{"A-1", "B-1"}
if !equalStringSlices(refs, want) {
t.Errorf("got refs %v, want %v", refs, want)
}
})
t.Run("inline code breaks at blank line (paragraph boundary)", func(t *testing.T) {
// A blank line terminates the paragraph, so a still-open
// inline-code span must end at the blank-line break.
// Refs in subsequent paragraphs are NOT part of the span
// and must be indexed.
content := "Outside [[A-1]]\n" +
"open `unclosed-span\n" +
"\n" +
"new paragraph [[REAL-1]] more text\n" +
"Outside [[B-1]]"
got := ExtractWikiLinks(content)
refs := refStrings(got)
want := []string{"A-1", "REAL-1", "B-1"}
if !equalStringSlices(refs, want) {
t.Errorf("got refs %v, want %v", refs, want)
}
})
t.Run("inline code breaks at whitespace-only blank line", func(t *testing.T) {
// CommonMark treats a line containing only spaces/tabs as
// blank. Span must still terminate there.
content := "open `unclosed-span\n" +
" \t \n" +
"after-blank [[REAL-1]]"
got := ExtractWikiLinks(content)
refs := refStrings(got)
want := []string{"REAL-1"}
if !equalStringSlices(refs, want) {
t.Errorf("got refs %v, want %v", refs, want)
}
})
t.Run("inline code closer matches opener length", func(t *testing.T) {
// CommonMark §6.1: a span opened with N backticks closes
// only on a run of EXACTLY N backticks. Stray single
// backticks inside a ``...`` span are code text, not
// closers. Without matching-run semantics, the extractor
// would close on the first single backtick and leak the
// inner [[X]]. Codex round-7 finding #2.
content := "Before [[A-1]] ``code with ` inside and [[INSIDE-1]]`` after [[B-1]]"
got := ExtractWikiLinks(content)
refs := refStrings(got)
want := []string{"A-1", "B-1"}
if !equalStringSlices(refs, want) {
t.Errorf("got refs %v, want %v", refs, want)
}
})
t.Run("single-backtick span unaffected by adjacent multi-backtick run", func(t *testing.T) {
// `code` is a normal one-backtick span that closes on the
// next single backtick. A double-backtick run is NOT a
// valid closer for a single-backtick opener.
content := "Try `code [[INSIDE-1]] ``not-closer` then [[REAL-1]]"
got := ExtractWikiLinks(content)
refs := refStrings(got)
want := []string{"REAL-1"}
if !equalStringSlices(refs, want) {
t.Errorf("got refs %v, want %v", refs, want)
}
})
t.Run("multi-backtick inline code excludes ref", func(t *testing.T) {
// CommonMark inline-code spans support multi-backtick
// delimiters (`code` and ``code`` are both spans). Our
// permissive matcher treats any backtick run as an opener
// and closes on the next backtick run — which covers this
// case correctly: ``see [[X]]`` becomes a single [0, end)
// range, the embedded link is excluded.
content := "``see [[INSIDE-1]]`` and [[REAL-1]]"
got := ExtractWikiLinks(content)
refs := refStrings(got)
want := []string{"REAL-1"}
if !equalStringSlices(refs, want) {
t.Errorf("got refs %v, want %v", refs, want)
}
})
t.Run("tilde fence excludes refs inside", func(t *testing.T) {
// CommonMark / marked accept ~~~ fences alongside ```.
// Refs inside a tilde fence render as code in the UI and
// must NOT be indexed. Codex round-6 finding #1.
content := "Real: [[OUTSIDE-1]]\n" +
"~~~\n" +
"Example: [[INSIDE-1]]\n" +
"~~~\n" +
"After: [[OUTSIDE-2]]"
got := ExtractWikiLinks(content)
refs := refStrings(got)
want := []string{"OUTSIDE-1", "OUTSIDE-2"}
if !equalStringSlices(refs, want) {
t.Errorf("got refs %v, want %v", refs, want)
}
})
t.Run("tilde fence with language tag", func(t *testing.T) {
content := "Before [[A-1]]\n" +
"~~~python\n" +
"# echo [[B-1]]\n" +
"~~~\n" +
"After [[C-1]]"
got := ExtractWikiLinks(content)
refs := refStrings(got)
want := []string{"A-1", "C-1"}
if !equalStringSlices(refs, want) {
t.Errorf("got refs %v, want %v", refs, want)
}
})
t.Run("mixed fence types don't pair", func(t *testing.T) {
// A backtick opener must NOT be closed by a tilde line and
// vice versa. If the wrong char appears, the fence stays
// open until the right char (or EOF) is found.
content := "Before [[A-1]]\n" +
"```\n" +
"~~~ // not a closer for the ``` opener\n" +
"[[INSIDE-1]]\n" +
"```\n" +
"After [[B-1]]"
got := ExtractWikiLinks(content)
refs := refStrings(got)
want := []string{"A-1", "B-1"}
if !equalStringSlices(refs, want) {
t.Errorf("got refs %v, want %v", refs, want)
}
})
t.Run("closer-line strictness — backticks plus other text is not a closer", func(t *testing.T) {
// CommonMark §4.5: a closing fence line must contain only
// the fence + optional trailing spaces. A line like
// `\`\`\`not-closed` inside an open fence does NOT
// terminate the block — later refs in the same fence stay
// excluded. Codex round-6 finding #2.
content := "Real: [[OUTSIDE-1]]\n" +
"```\n" +
"```not-closed\n" +
"[[INSIDE-1]] still inside the fence\n" +
"```\n" +
"After: [[OUTSIDE-2]]"
got := ExtractWikiLinks(content)
refs := refStrings(got)
want := []string{"OUTSIDE-1", "OUTSIDE-2"}
if !equalStringSlices(refs, want) {
t.Errorf("got refs %v, want %v", refs, want)
}
})
t.Run("closer-line strictness — trailing spaces OK", func(t *testing.T) {
// A real closer may have trailing whitespace.
content := "Before [[A-1]]\n" +
"```\n" +
"[[INSIDE-1]]\n" +
"``` \n" +
"After [[B-1]]"
got := ExtractWikiLinks(content)
refs := refStrings(got)
want := []string{"A-1", "B-1"}
if !equalStringSlices(refs, want) {
t.Errorf("got refs %v, want %v", refs, want)
}
})
t.Run("indented fenced block (CommonMark 0-3 spaces)", func(t *testing.T) {
// CommonMark allows fenced code blocks indented 0-3 spaces;
// marked() (the renderer's markdown parser) implements this.
// Our extractor must mirror or we false-positive on every
// indented code example users write.
content := "Before [[A-1]]\n" +
" ```\n" +
" echo [[INSIDE-1]]\n" +
" ```\n" +
"After [[B-1]]"
got := ExtractWikiLinks(content)
refs := refStrings(got)
want := []string{"A-1", "B-1"}
if !equalStringSlices(refs, want) {
t.Errorf("got refs %v, want %v", refs, want)
}
})
t.Run("inline code adjacent to real link", func(t *testing.T) {
// `code-with-link` then [[REAL-1]] — closer of inline span
// must not accidentally include the real link.
content := "Try `[[A-1]]` and then [[REAL-1]]."
got := ExtractWikiLinks(content)
refs := refStrings(got)
want := []string{"REAL-1"}
if !equalStringSlices(refs, want) {
t.Errorf("got refs %v, want %v", refs, want)
}
})
t.Run("fenced block at start of content (no leading newline)", func(t *testing.T) {
content := "```\n[[INSIDE-1]]\n```\n[[OUTSIDE-1]]"
got := ExtractWikiLinks(content)
refs := refStrings(got)
want := []string{"OUTSIDE-1"}
if !equalStringSlices(refs, want) {
t.Errorf("got refs %v, want %v", refs, want)
}
})
}
// TestExtractWikiLinks_Edge covers malformed/weird inputs the renderer
// gracefully falls through on. Our extractor must do the same.
func TestExtractWikiLinks_Edge(t *testing.T) {
cases := []struct {
name string
content string
}{
{"empty content", ""},
{"no links", "Plain text with no wiki links at all."},
{"empty brackets", "Here is [[]] which shouldn't match."},
{"single bracket", "[A-1] should not match."},
{"nested brackets", "[[[A-1]]] shouldn't either."},
{"number-led not a ref", "[[5-task]] is not a ref shape."},
{"missing number", "[[TASK]] needs a number."},
{"bracket with newline body", "[[A-1\nB-2]] is malformed."},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := ExtractWikiLinks(c.content)
for _, g := range got {
// Phase 2a: emitted kinds are ref or title; cross-ws
// is still gated. Whatever the kind, the matching
// identifier field must be non-empty so downstream
// consumers can rely on it.
switch g.Kind {
case WikiLinkKindRef:
if g.Ref == "" {
t.Errorf("emitted ref-kind with empty Ref: %+v", g)
}
case WikiLinkKindTitle:
if g.Title == "" {
t.Errorf("emitted title-kind with empty Title: %+v", g)
}
default:
t.Errorf("emitted unexpected kind in Phase 2a: %+v", g)
}
}
})
}
}
// TestExtractWikiLinks_PositionIsByteOffset asserts that Position
// points at the opening `[[` in the ORIGINAL content — the handler
// uses this to extract a ~80-char snippet centered on the match.
func TestExtractWikiLinks_PositionIsByteOffset(t *testing.T) {
content := "Some prose. [[TASK-99]] more prose."
got := ExtractWikiLinks(content)
if len(got) != 1 {
t.Fatalf("want 1 link, got %d", len(got))
}
pos := got[0].Position
if got := content[pos : pos+2]; got != "[[" {
t.Errorf("Position should point at `[[`, got %q", got)
}
}
// TestExtractWikiLinks_RefVsTitleFallback exercises the parseBody
// decision tree. After Codex round-1 P2, the refPattern is
// case-insensitive — `[[Task-5]]` and `[[task-5]]` now parse as the
// ref kind (and normalize to "TASK-5" for storage), matching the
// renderer's behavior. Inputs that don't look like a ref at all
// still fall through to title.
func TestExtractWikiLinks_RefVsTitleFallback(t *testing.T) {
t.Run("uppercase ref-shaped body parses as ref", func(t *testing.T) {
got := ExtractWikiLinks("[[TASK-5]]")
if len(got) != 1 || got[0].Kind != WikiLinkKindRef {
t.Errorf("expected single ref kind, got %+v", got)
}
if got[0].Ref != "TASK-5" {
t.Errorf("Ref: got %q, want TASK-5", got[0].Ref)
}
})
t.Run("mixed-case body parses as ref, normalized to uppercase", func(t *testing.T) {
got := ExtractWikiLinks("[[Task-5]]")
if len(got) != 1 {
t.Fatalf("expected 1 ref-kind result (case-insensitive match), got %d: %+v", len(got), got)
}
if got[0].Kind != WikiLinkKindRef {
t.Errorf("Kind: got %q, want ref", got[0].Kind)
}
if got[0].Ref != "TASK-5" {
t.Errorf("Ref should be canonicalized to uppercase: got %q want TASK-5", got[0].Ref)
}
})
t.Run("lowercase body parses as ref, normalized to uppercase", func(t *testing.T) {
got := ExtractWikiLinks("[[task-5]]")
if len(got) != 1 {
t.Fatalf("expected 1 ref-kind result, got %d", len(got))
}
if got[0].Ref != "TASK-5" {
t.Errorf("Ref: got %q want TASK-5", got[0].Ref)
}
})
t.Run("non-ref body falls to title (Phase 2a emits)", func(t *testing.T) {
// "5-Task" (number-led) doesn't match REF_PATTERN even
// with the relaxed case rule; parseBody returns a
// title-kind ref; Phase 2a now emits title kinds, so the
// body is preserved verbatim as the Title field.
got := ExtractWikiLinks("[[5-Task]]")
if len(got) != 1 {
t.Fatalf("expected 1 title-kind row, got %d: %+v", len(got), got)
}
if got[0].Kind != WikiLinkKindTitle {
t.Errorf("Kind: got %q, want title", got[0].Kind)
}
if got[0].Title != "5-Task" {
t.Errorf("Title: got %q, want %q", got[0].Title, "5-Task")
}
})
}
// TestExtractWikiLinks_EscapedBodyChars regresses Codex rounds
// 4/7/10 P2: the editor's wikiLinksToMarkdown can produce bodies
// containing `\]`, `\|`, `\\` escapes (markdown.ts:461). The
// extractor must parse those — both the regex and the body parser —
// so the resulting link is indexed with the unescaped display text.
func TestExtractWikiLinks_EscapedBodyChars(t *testing.T) {
t.Run("escaped closing bracket in display", func(t *testing.T) {
// [[TASK-1|see \] bracket]] — display is "see ] bracket".
got := ExtractWikiLinks(`[[TASK-1|see \] bracket]]`)
if len(got) != 1 {
t.Fatalf("expected 1 ref, got %d: %+v", len(got), got)
}
if got[0].Ref != "TASK-1" {
t.Errorf("Ref: got %q want TASK-1", got[0].Ref)
}
if got[0].Display != "see ] bracket" {
t.Errorf("Display: got %q want %q", got[0].Display, "see ] bracket")
}
})
t.Run("escaped pipe in display", func(t *testing.T) {
// [[TASK-2|A \| B]] — display is "A | B"; the unescaped
// pipe doesn't split the body.
got := ExtractWikiLinks(`[[TASK-2|A \| B]]`)
if len(got) != 1 {
t.Fatalf("expected 1 ref, got %d", len(got))
}
if got[0].Display != "A | B" {
t.Errorf("Display: got %q want %q", got[0].Display, "A | B")
}
})
t.Run("escaped backslash in display", func(t *testing.T) {
// [[TASK-3|a \\ b]] — display is "a \ b".
got := ExtractWikiLinks(`[[TASK-3|a \\ b]]`)
if len(got) != 1 {
t.Fatalf("expected 1 ref, got %d", len(got))
}
if got[0].Display != `a \ b` {
t.Errorf("Display: got %q want %q", got[0].Display, `a \ b`)
}
})
t.Run("non-escape backslash passes through", func(t *testing.T) {
// `\n` (or any `\X` where X isn't ]|\) is left alone.
got := ExtractWikiLinks(`[[TASK-4|a \n b]]`)
if len(got) != 1 {
t.Fatalf("expected 1 ref, got %d", len(got))
}
if got[0].Display != `a \n b` {
t.Errorf("Display: got %q want %q", got[0].Display, `a \n b`)
}
})
t.Run("explicit empty display override is distinguished from no pipe", func(t *testing.T) {
// [[X|]] is distinct from [[X]] in the editor: the former
// has displayOverride="" (preserved by JS ?? coalescing),
// the latter has no override and falls back to title. We
// preserve that distinction via HasDisplay. Codex round-12 P3.
withPipe := ExtractWikiLinks(`[[TASK-7|]]`)
if len(withPipe) != 1 {
t.Fatalf("expected 1 ref, got %d", len(withPipe))
}
if !withPipe[0].HasDisplay {
t.Errorf("[[TASK-7|]] should have HasDisplay=true")
}
if withPipe[0].Display != "" {
t.Errorf("[[TASK-7|]] Display should be \"\", got %q", withPipe[0].Display)
}
noPipe := ExtractWikiLinks(`[[TASK-7]]`)
if len(noPipe) != 1 {
t.Fatalf("expected 1 ref, got %d", len(noPipe))
}
if noPipe[0].HasDisplay {
t.Errorf("[[TASK-7]] should have HasDisplay=false")
}
if noPipe[0].Display != "" {
t.Errorf("[[TASK-7]] Display should be \"\", got %q", noPipe[0].Display)
}
})
t.Run("display text preserved verbatim (no TrimSpace)", func(t *testing.T) {
// The renderer stores display text verbatim — leading and
// trailing whitespace are part of the display. Trimming
// in the extractor would silently differ from client
// behavior. Codex round-11 P3.
got := ExtractWikiLinks(`[[TASK-9| padded display ]]`)
if len(got) != 1 {
t.Fatalf("expected 1 ref, got %d", len(got))
}
if got[0].Display != " padded display " {
t.Errorf("Display should be verbatim, got %q", got[0].Display)
}
})
t.Run("position still points at opening [[", func(t *testing.T) {
// Escapes shouldn't shift Position — it's the byte offset
// in the ORIGINAL content, not the unescaped form.
content := "Prefix " + `[[TASK-5|see \] here]]` + " suffix"
got := ExtractWikiLinks(content)
if len(got) != 1 {
t.Fatalf("expected 1 ref, got %d", len(got))
}
if content[got[0].Position:got[0].Position+2] != "[[" {
t.Errorf("Position should point at `[[`, got %q",
content[got[0].Position:got[0].Position+2])
}
})
}
// TestSplitOnUnescapedPipe / TestUnescapeWikiBody — direct unit
// tests for the helpers. Round-trip safety against the editor's
// serializer (escapeWikiBody/unescapeWikiBody in markdown.ts:652-658)
// is the property we care about.
func TestSplitOnUnescapedPipe(t *testing.T) {
cases := []struct {
in string
wantKey string
wantSuffix string
wantFound bool
}{
{"ref-only", "ref-only", "", false},
{"key|display", "key", "display", true},
{`key\|with-pipe`, `key\|with-pipe`, "", false},
{`first\|second|third`, `first\|second`, "third", true},
{`\\|trailing`, `\\`, "trailing", true}, // \\ is escaped backslash, then |
}
for _, c := range cases {
k, s, ok := splitOnUnescapedPipe(c.in)
if k != c.wantKey || s != c.wantSuffix || ok != c.wantFound {
t.Errorf("splitOnUnescapedPipe(%q) = (%q, %q, %v), want (%q, %q, %v)",
c.in, k, s, ok, c.wantKey, c.wantSuffix, c.wantFound)
}
}
}
func TestUnescapeWikiBody(t *testing.T) {
cases := []struct {
in, want string
}{
{"plain", "plain"},
{`\]`, "]"},
{`\|`, "|"},
{`\\`, `\`},
{`a \] b \| c \\ d`, `a ] b | c \ d`},
{`\n stays literal`, `\n stays literal`},
{"", ""},
}
for _, c := range cases {
got := unescapeWikiBody(c.in)
if got != c.want {
t.Errorf("unescapeWikiBody(%q) = %q, want %q", c.in, got, c.want)
}
}
}
// TestCanonicalizeRef is the unit-level check on the helper. The
// integration coverage lives in TestWikiLinks_MixedCaseRefIndexed
// (store) — but the helper's edge cases (no-hyphen, all-uppercase
// already, multi-hyphen prefix) are easier to assert directly.
func TestCanonicalizeRef(t *testing.T) {
cases := []struct {
in, want string
}{
{"TASK-5", "TASK-5"},
{"task-5", "TASK-5"},
{"Task-5", "TASK-5"},
{"PLAYBOOK-12345", "PLAYBOOK-12345"},
{"playbook-12345", "PLAYBOOK-12345"},
}
for _, tc := range cases {
got := canonicalizeRef(tc.in)
if got != tc.want {
t.Errorf("canonicalizeRef(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
// assertLinks compares two WikiLinkRef slices for the fields Phase 1
// + Phase 2a care about. WorkspaceSlug is excluded because Phase 2b
// (TASK-1597) is the first to emit workspace_ref kinds; once that
// lands the helper grows another comparison line.
func assertLinks(t *testing.T, got, want []WikiLinkRef) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("got %d links, want %d: got=%+v want=%+v", len(got), len(want), got, want)
}
for i := range got {
if got[i].Kind != want[i].Kind {
t.Errorf("[%d] Kind: got %q, want %q", i, got[i].Kind, want[i].Kind)
}
if got[i].Ref != want[i].Ref {
t.Errorf("[%d] Ref: got %q, want %q", i, got[i].Ref, want[i].Ref)
}
if got[i].Title != want[i].Title {
t.Errorf("[%d] Title: got %q, want %q", i, got[i].Title, want[i].Title)
}
if got[i].Display != want[i].Display {
t.Errorf("[%d] Display: got %q, want %q", i, got[i].Display, want[i].Display)
}
if got[i].Position != want[i].Position {
t.Errorf("[%d] Position: got %d, want %d", i, got[i].Position, want[i].Position)
}
}
}
func refStrings(links []WikiLinkRef) []string {
out := make([]string, len(links))
for i, l := range links {
out[i] = l.Ref
}
return out
}
func equalStringSlices(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// Sanity check: position is BYTE offset, not rune offset. A leading
// multi-byte rune shifts the [[ to a position > its rune index.
func TestExtractWikiLinks_PositionByteVsRune(t *testing.T) {
// "héllo " has é = 2 bytes (UTF-8). The [[ that follows should
// be at byte offset 7 ("h"=1 + "é"=2 + "llo "=4 = 7), even
// though its rune offset is only 6.
content := "héllo [[TASK-1]]"
got := ExtractWikiLinks(content)
if len(got) != 1 {
t.Fatalf("expected 1 link, got %d", len(got))
}
if got[0].Position != strings.Index(content, "[[") {
t.Errorf("Position should be byte offset matching strings.Index, got %d want %d",
got[0].Position, strings.Index(content, "[["))
}
}