fix(replace-links): share candidate scan semantics

Why:

The final architecture review found that AI ambiguity scanning and link replacement could diverge on scoped base-directory matching and raw Linear URL protection. The replacement renderer also still carried its own candidate traversal loop, which made that drift possible.

What:

Pass baseDir into ambiguity scanning, share raw URL protection across markdown segmentation and candidate scanning, and route replacement rendering through the scanner's single candidate-at-index matcher. Add regression coverage for baseDir scoped AI requests and Linear URL protection parity.
This commit is contained in:
Kodai Nakamura 2026-06-22 04:06:18 +09:00
parent da624f7cd1
commit 9a98513ea8
8 changed files with 412 additions and 500 deletions

View file

@ -497,6 +497,7 @@ export default class AutomaticLinkerPlugin extends Plugin {
this.trie,
this.settings,
normalizedActiveFilePath,
baseDir,
)
const resultBody = replaceLinks({

View file

@ -0,0 +1,2 @@
export const RAW_URL_SOURCE = "(?:https?:\\/\\/|linear:\\/\\/)[^\\s]+"
export const RAW_URL_AT_START_PATTERN = new RegExp(`^(${RAW_URL_SOURCE})`)

View file

@ -1,4 +1,5 @@
import { isMarkdownTableLine } from "./replace-links/candidate-scanner"
import { RAW_URL_SOURCE } from "./markdown-protection"
export type MarkdownSegmentKind = "prose" | "protected"
@ -138,7 +139,7 @@ const buildProtectedPattern = (protectUrls: boolean): RegExp => {
]
if (protectUrls) {
parts.push("(?:https?:\\/\\/|linear:\\/\\/)[^\\s]+")
parts.push(RAW_URL_SOURCE)
}
return new RegExp(`(${parts.join("|")})`, "g")

View file

@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest"
import { buildTrie, CandidateData } from "../../trie"
import { buildCandidateTrieForTest } from "./test-helpers"
import { replaceLinks } from "../replace-links"
import {
getOccurrenceContext,
scanCandidateOccurrences,
@ -174,6 +175,103 @@ describe("scanCandidateOccurrences", () => {
expect(occurrences).toEqual([])
})
it("matches replaceLinks scoped namespace behavior when baseDir is set", () => {
const settings = {
scoped: true,
baseDir: "pages",
ignoreCase: true,
proximityBasedLinking: true,
}
const { candidateMap, trie } = buildCandidateTrieForTest({
files: [
{ path: "pages/team-a/internal" },
{ path: "pages/team-a/archive/internal" },
],
settings,
})
const occurrences = scanCandidateOccurrences({
text: "internal",
filePath: "pages/team-a/today",
trie,
candidateMap,
settings,
})
const replaced = replaceLinks({
body: "internal",
linkResolverContext: {
filePath: "pages/team-a/today",
trie,
candidateMap,
},
settings,
})
expect(occurrences.map(o => ({
text: o.text,
candidates: o.candidateData.candidates.map(c => c.canonical),
}))).toEqual([
{
text: "internal",
candidates: [
"pages/team-a/internal",
"pages/team-a/archive/internal",
],
},
])
expect(replaced).toBe("[[team-a/archive/internal|internal]]")
})
it("matches replaceLinks by protecting raw Linear URLs", () => {
const settings = {
scoped: false,
baseDir: undefined,
ignoreCase: true,
proximityBasedLinking: true,
}
const { candidateMap, trie } = buildCandidateTrieForTest({
files: [
{ path: "work/linear" },
{ path: "private/linear" },
],
settings,
})
const body = "Open linear://issue/TEAM-123 then linear"
const expectedStart = body.lastIndexOf("linear")
const occurrences = scanCandidateOccurrences({
text: body,
filePath: "notes/today",
trie,
candidateMap,
settings,
})
const replaced = replaceLinks({
body,
linkResolverContext: {
filePath: "notes/today",
trie,
candidateMap,
},
settings,
})
expect(occurrences.map(o => ({
text: o.text,
start: o.start,
end: o.end,
}))).toEqual([
{
text: "linear",
start: expectedStart,
end: expectedStart + "linear".length,
},
])
expect(replaced).toBe(
"Open linear://issue/TEAM-123 then [[private/linear|linear]]",
)
})
it("skips fenced code blocks, callouts, and ignored headings", () => {
const { candidateMap, trie } = buildCandidateTrieForTest({
files: [{ path: "meeting" }],

View file

@ -1,4 +1,5 @@
import { CandidateData, getTopLevelDirectoryName, TrieNode } from "../trie"
import { RAW_URL_AT_START_PATTERN, RAW_URL_SOURCE } from "../markdown-protection"
import type { ReplaceLinksSettings } from "./replace-links"
export type CandidateOccurrenceKind = "unlinked" | "existing-wikilink"
@ -10,9 +11,15 @@ export interface CandidateOccurrence {
text: string
candidateKey: string
candidateData: CandidateData
replacementCandidateData?: CandidateData
isInTable: boolean
}
export type UnlinkedCandidateScanResult
= | { action: "match", occurrence: CandidateOccurrence }
| { action: "skip", end: number }
| null
export interface ScanCandidateOccurrencesOptions {
text: string
filePath: string
@ -22,14 +29,17 @@ export interface ScanCandidateOccurrencesOptions {
}
export const REGEX_PATTERNS = {
PROTECTED: /(```[\s\S]*?```|`[^`]*`|\[\[([^\]]+)\]\]|\[[^\]]+\]\([^)]+\)|\[[^\]]+\]|https?:\/\/[^\s]+)/g,
PROTECTED: new RegExp(
`(\`\`\`[\\s\\S]*?\`\`\`|\`[^\`]*\`|\\[\\[([^\\]]+)\\]\\]|\\[[^\\]]+\\]\\([^)]+\\)|\\[[^\\]]+\\]|${RAW_URL_SOURCE})`,
"g",
),
DATE_FORMAT: /^\d{4}-\d{2}-\d{2}$/,
MONTH_NOTE: /^[0-9]{1,2}$/,
CJK: /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u,
CJK_CANDIDATE: /^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}\s\d]+$/u,
KOREAN: /^[\p{Script=Hangul}]+$/u,
JAPANESE: /^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\s\d]+$/u,
URL: /^(https?:\/\/[^\s]+)/,
URL: RAW_URL_AT_START_PATTERN,
PROTECTED_LINK: /^\s*(\[\[[^\]]+\]\]|\[[^\]]+\]\([^)]+\))\s*$/,
KOREAN_SUFFIX: /^(이다\.?)/,
KOREAN_PARTICLES: /^(는|은)/,
@ -498,7 +508,7 @@ const collectFallbackOccurrence = ({
filePath: string
currentNamespace: string
settings: ReplaceLinksSettings
}): CandidateOccurrence | null => {
}): UnlinkedCandidateScanResult => {
const prevChar = text[startIndex - 1]
if (!isWordBoundary(prevChar)) {
return null
@ -579,10 +589,6 @@ const collectFallbackOccurrence = ({
return null
}
if (isSelfLink(candidateData, filePath, settings)) {
return null
}
const bestCandidateResult = filteredCandidates.length > 1
? findBestCandidateInSameNamespace(filteredCandidates, filePath, settings)
: filteredCandidates[0]
@ -590,14 +596,25 @@ const collectFallbackOccurrence = ({
return null
}
if (isSelfLink(bestCandidateResult[1], filePath, settings)) {
return {
action: "skip",
end: startIndex + longestMatch.length,
}
}
return {
kind: "unlinked",
start: startIndex,
end: startIndex + longestMatch.length,
text: longestMatch.word,
candidateKey: longestMatch.key,
candidateData,
isInTable: isIndexInsideMarkdownTable(text, startIndex),
action: "match",
occurrence: {
kind: "unlinked",
start: startIndex,
end: startIndex + longestMatch.length,
text: longestMatch.word,
candidateKey: longestMatch.key,
candidateData,
replacementCandidateData: bestCandidateResult[1],
isInTable: isIndexInsideMarkdownTable(text, startIndex),
},
}
}
@ -614,6 +631,152 @@ const shouldSkipKoreanTrieOccurrence = (
return REGEX_PATTERNS.KOREAN_PARTICLES.test(remaining)
}
export const scanUnlinkedCandidateAt = ({
text,
startIndex,
filePath,
trie,
candidateMap,
fallbackIndex,
currentNamespace,
settings,
}: {
text: string
startIndex: number
filePath: string
trie: TrieNode
candidateMap: Map<string, CandidateData>
fallbackIndex: Map<string, Array<[string, CandidateData]>>
currentNamespace: string
settings: ReplaceLinksSettings
}): UnlinkedCandidateScanResult => {
if (
(text[startIndex] === "h" && text.slice(startIndex, startIndex + 4) === "http")
|| (text[startIndex] === "l" && text.slice(startIndex, startIndex + 9) === "linear://")
) {
const urlMatch = text.slice(startIndex).match(REGEX_PATTERNS.URL)
if (urlMatch) {
return {
action: "skip",
end: startIndex + urlMatch[0].length,
}
}
}
let node = trie
let lastCandidate: { candidate: string, length: number } | null = null
let j = startIndex
let candidateBuilder = ""
while (j < text.length) {
const ch = text[j]
let chLower = settings.ignoreCase ? ch.toLowerCase() : ch
if (settings.matchSentenceCase && !settings.ignoreCase && j === startIndex && isSentenceStart(text, startIndex)) {
chLower = ch.toLowerCase()
}
candidateBuilder += ch
const child = node.children.get(chLower)
if (!child) break
node = child
if (node.candidate) {
const candidateIsCjk = isCjkCandidate(candidateBuilder)
if (candidateIsCjk || isWordBoundary(text[j + 1])) {
lastCandidate = {
candidate: node.candidate,
length: j - startIndex + 1,
}
}
}
j++
}
if (lastCandidate) {
const candidate = candidateBuilder.slice(0, lastCandidate.length)
if (shouldSkipCandidate(candidate, settings)) {
return {
action: "skip",
end: startIndex + lastCandidate.length,
}
}
const trieCandidateKey = lastCandidate.candidate
const candidateData = candidateMap.get(trieCandidateKey)
if (candidateData) {
if (isSelfLink(candidateData, filePath, settings)) {
return {
action: "skip",
end: startIndex + candidate.length,
}
}
if (shouldSkipKoreanTrieOccurrence(text, startIndex, candidate)) {
return {
action: "skip",
end: startIndex + 1,
}
}
const candidateIsCjk = isCjkCandidate(candidate)
if (!candidateIsCjk) {
const left = startIndex > 0 ? text[startIndex - 1] : undefined
const right = startIndex + candidate.length < text.length
? text[startIndex + candidate.length]
: undefined
if (!isWordBoundary(left) || !isWordBoundary(right)) {
return {
action: "skip",
end: startIndex + 1,
}
}
}
if (
settings.proximityBasedLinking
&& candidateData.candidates.length > 0
&& candidateData.candidates[0].scoped
&& candidateData.candidates[0].namespace !== currentNamespace
) {
return {
action: "skip",
end: startIndex + candidate.length,
}
}
return {
action: "match",
occurrence: {
kind: "unlinked",
start: startIndex,
end: startIndex + candidate.length,
text: candidate,
candidateKey: trieCandidateKey,
candidateData: dedupeCandidates(candidateData.candidates),
replacementCandidateData: candidateData,
isInTable: isIndexInsideMarkdownTable(text, startIndex),
},
}
}
}
if (settings.proximityBasedLinking) {
return collectFallbackOccurrence({
text,
startIndex,
fallbackIndex,
filePath,
currentNamespace,
settings,
})
}
return null
}
const collectUnlinkedOccurrences = ({
text,
filePath,
@ -635,117 +798,27 @@ const collectUnlinkedOccurrences = ({
}): void => {
let i = 0
outer: while (i < text.length) {
if (text[i] === "h" && text.slice(i, i + 4) === "http") {
const urlMatch = text.slice(i).match(REGEX_PATTERNS.URL)
if (urlMatch) {
i += urlMatch[0].length
continue
}
while (i < text.length) {
const result = scanUnlinkedCandidateAt({
text,
startIndex: i,
filePath,
trie,
candidateMap,
fallbackIndex,
currentNamespace,
settings,
})
if (result?.action === "match") {
occurrences.push(result.occurrence)
i = result.occurrence.end
continue
}
let node = trie
let lastCandidate: { candidate: string, length: number } | null = null
let j = i
let candidateBuilder = ""
while (j < text.length) {
const ch = text[j]
let chLower = settings.ignoreCase ? ch.toLowerCase() : ch
if (settings.matchSentenceCase && !settings.ignoreCase && j === i && isSentenceStart(text, i)) {
chLower = ch.toLowerCase()
}
candidateBuilder += ch
const child = node.children.get(chLower)
if (!child) break
node = child
if (node.candidate) {
const candidateIsCjk = isCjkCandidate(candidateBuilder)
if (candidateIsCjk || isWordBoundary(text[j + 1])) {
lastCandidate = {
candidate: node.candidate,
length: j - i + 1,
}
}
}
j++
}
if (lastCandidate) {
const candidate = candidateBuilder.slice(0, lastCandidate.length)
if (shouldSkipCandidate(candidate, settings)) {
i += lastCandidate.length
continue
}
const trieCandidateKey = lastCandidate.candidate
const candidateData = candidateMap.get(trieCandidateKey)
if (candidateData) {
if (isSelfLink(candidateData, filePath, settings)) {
i += candidate.length
continue
}
if (shouldSkipKoreanTrieOccurrence(text, i, candidate)) {
i += 1
continue
}
const candidateIsCjk = isCjkCandidate(candidate)
if (!candidateIsCjk) {
const left = i > 0 ? text[i - 1] : undefined
const right = i + candidate.length < text.length
? text[i + candidate.length]
: undefined
if (!isWordBoundary(left) || !isWordBoundary(right)) {
i++
continue
}
}
if (
settings.proximityBasedLinking
&& candidateData.candidates.length > 0
&& candidateData.candidates[0].scoped
&& candidateData.candidates[0].namespace !== currentNamespace
) {
i += candidate.length
continue
}
occurrences.push({
kind: "unlinked",
start: i,
end: i + candidate.length,
text: candidate,
candidateKey: trieCandidateKey,
candidateData: dedupeCandidates(candidateData.candidates),
isInTable: isIndexInsideMarkdownTable(text, i),
})
i += candidate.length
continue
}
}
if (settings.proximityBasedLinking) {
const fallbackOccurrence = collectFallbackOccurrence({
text,
startIndex: i,
fallbackIndex,
filePath,
currentNamespace,
settings,
})
if (fallbackOccurrence) {
occurrences.push(fallbackOccurrence)
i = fallbackOccurrence.end
continue outer
}
if (result?.action === "skip") {
i = result.end
continue
}
i++

View file

@ -3,20 +3,12 @@ import { mapMarkdownProse, segmentMarkdown } from "../markdown-segments"
import {
buildFallbackIndex,
extractLinkParts,
findBestCandidateInSameNamespace,
getCurrentNamespace,
isCjkCandidate,
isCjkText,
isIndexInsideMarkdownTable,
isKoreanText,
isMonthNote,
isProtectedLink,
isSelfLink,
isSentenceStart,
isWordBoundary,
normalizeCanonicalPath,
REGEX_PATTERNS,
shouldSkipCandidate,
scanUnlinkedCandidateAt,
} from "./candidate-scanner"
// Types for the replaceLinks function
@ -228,194 +220,6 @@ const processCjkText = (
)
}
// Fallback Search Processing
const processFallbackSearch = (
text: string,
startIndex: number,
fallbackIndex: Map<string, Array<[string, CandidateData]>>,
filePath: string,
currentNamespace: string,
linkGenerator: LinkGenerator,
settings: ReplaceLinksSettings,
forceIsInTable?: boolean,
): { result: string, newIndex: number } | null => {
// Early boundary check - if start isn't a word boundary, skip
const prevChar = text[startIndex - 1]
if (!isWordBoundary(prevChar)) {
return null
}
let longestMatch: {
word: string
length: number
key: string
candidateList: Array<[string, CandidateData]>
} | null = null
// Iterate through potential multi-word sequences starting from startIndex
const maxSearchLength = Math.min(text.length - startIndex, 100) // Limit search length for performance
let potentialMatch = ""
let searchWord = ""
for (let length = 1; length <= maxSearchLength; length++) {
const endIndex = startIndex + length
const currentChar = text[startIndex + length - 1]
potentialMatch += currentChar
if (settings.matchSentenceCase && !settings.ignoreCase && isSentenceStart(text, startIndex)) {
if (length === 1) {
searchWord = currentChar.toLowerCase()
}
else {
searchWord = searchWord + currentChar
}
}
else {
searchWord = settings.ignoreCase
? searchWord + currentChar.toLowerCase()
: potentialMatch
}
// Check if this potential match exists in fallback index
const candidateList = fallbackIndex.get(searchWord)
if (!candidateList) {
continue
}
// Basic boundary check: next char should be a boundary if not end of text
const nextChar = text[endIndex]
if (!isWordBoundary(nextChar)) {
continue // This isn't a valid match end
}
// Skip date formats and month notes
if (shouldSkipCandidate(potentialMatch, settings)) {
continue // Try longer match
}
// Found a valid candidate
longestMatch = {
word: potentialMatch,
length: length,
key: searchWord,
candidateList: candidateList,
}
// Continue checking for even longer matches
}
// Process the longest valid match found
if (!longestMatch) return null
// Filter candidates based on namespace restrictions
const filteredCandidates = longestMatch.candidateList.filter(
([, data]) => {
if (data.candidates.length === 0) return true
const candidate = data.candidates[0]
return !(candidate.scoped && candidate.namespace !== currentNamespace)
},
)
let bestCandidateData: CandidateData | null = null
if (filteredCandidates.length === 1) {
bestCandidateData = filteredCandidates[0][1]
}
else if (filteredCandidates.length > 1) {
const bestCandidateResult = findBestCandidateInSameNamespace(
filteredCandidates,
filePath,
settings,
)
if (bestCandidateResult) {
bestCandidateData = bestCandidateResult[1]
}
}
if (!bestCandidateData) return null
// Check if this is a self-link and should be prevented
if (isSelfLink(bestCandidateData, filePath, settings)) {
return {
result: longestMatch.word,
newIndex: startIndex + longestMatch.length,
}
}
// Create the link
const { linkPath, alias } = createLinkContent(
bestCandidateData,
longestMatch.word,
settings,
)
const isInTable = forceIsInTable ?? isIndexInsideMarkdownTable(text, startIndex)
const finalLink = linkGenerator({
linkPath,
sourcePath: filePath,
alias,
isInTable,
})
return {
result: finalLink,
newIndex: startIndex + longestMatch.length,
}
}
// Korean Language Processing
const handleKoreanSpecialCases = (
text: string,
i: number,
candidate: string,
candidateData: CandidateData,
filePath: string,
linkGenerator: LinkGenerator,
settings: ReplaceLinksSettings = {},
resolvedAmbiguities?: Map<string, string>,
forceIsInTable?: boolean,
): { result: string, newIndex: number } | null => {
const remaining = text.slice(i + candidate.length)
// Special handling when followed by "이다"
const suffixMatch = remaining.match(REGEX_PATTERNS.KOREAN_SUFFIX)
if (suffixMatch) {
// Check if this is a self-link and should be prevented
if (isSelfLink(candidateData, filePath, settings)) {
return {
result: candidate + suffixMatch[0],
newIndex: i + candidate.length + suffixMatch[0].length,
}
}
const { linkPath, alias } = resolveLinkContent(
candidateData,
candidate,
settings,
resolvedAmbiguities,
)
const finalLink = linkGenerator({
linkPath,
sourcePath: filePath,
alias,
isInTable: forceIsInTable ?? false,
})
return {
result: finalLink + suffixMatch[0],
newIndex: i + candidate.length + suffixMatch[0].length,
}
}
// Special handling when followed by particles like "는" or "은"
if (remaining.match(REGEX_PATTERNS.KOREAN_PARTICLES)) {
return {
result: text[i],
newIndex: i + 1,
}
}
return null
}
const processStandardText = (
text: string,
trie: TrieNode,
@ -431,190 +235,45 @@ const processStandardText = (
let result = ""
let i = 0
outer: while (i < text.length) {
// Check for URLs first - only if current character could start a URL
if (text[i] === "h" && text.slice(i, i + 4) === "http") {
const urlMatch = text.slice(i).match(REGEX_PATTERNS.URL)
if (urlMatch) {
result += urlMatch[0]
i += urlMatch[0].length
continue
}
while (i < text.length) {
const scanResult = scanUnlinkedCandidateAt({
text,
startIndex: i,
filePath,
trie,
candidateMap,
fallbackIndex,
currentNamespace,
settings,
})
if (scanResult?.action === "skip") {
result += text.slice(i, scanResult.end)
i = scanResult.end
continue
}
// Try to find a candidate using the trie
let node = trie
let lastCandidate: { candidate: string, length: number } | null = null
let j = i
let candidateBuilder = ""
while (j < text.length) {
const ch = text[j]
let chLower = settings.ignoreCase ? ch.toLowerCase() : ch
// At sentence start, lowercase only the first character to allow matching
if (settings.matchSentenceCase && !settings.ignoreCase && j === i && isSentenceStart(text, i)) {
chLower = ch.toLowerCase()
}
candidateBuilder += ch
const child = node.children.get(chLower)
if (!child) break
node = child
if (node.candidate) {
const candidateIsCjk = isCjkCandidate(candidateBuilder)
if (candidateIsCjk || isWordBoundary(text[j + 1])) {
lastCandidate = {
candidate: node.candidate,
length: j - i + 1,
}
}
}
j++
}
if (lastCandidate) {
const candidate = candidateBuilder.slice(0, lastCandidate.length)
// Skip if it's a date format
if (
settings.ignoreDateFormats
&& REGEX_PATTERNS.DATE_FORMAT.test(candidate)
) {
result += candidate
i += lastCandidate.length
continue outer
}
// Skip month notes
if (isMonthNote(candidate)) {
result += candidate
i += lastCandidate.length
continue
}
// Use the candidate found in the trie (lastCandidate.candidate) to look up in candidateMap
const trieCandidateKey = lastCandidate.candidate
// candidateMap lookup should always use the exact key from the trie result.
// Case comparison happened during trie traversal if ignoreCase is true.
const candidateData = candidateMap.get(trieCandidateKey)
if (candidateData) {
// Check if this is a self-link and should be prevented
if (isSelfLink(candidateData, filePath, settings)) {
result += candidate
i += candidate.length
continue outer
}
// Handle Korean special cases
const isKorean = isKoreanText(candidate)
if (isKorean) {
const koreanResult = handleKoreanSpecialCases(
text,
i,
candidate,
candidateData,
filePath,
linkGenerator,
settings,
resolvedAmbiguities,
forceIsInTable,
)
if (koreanResult) {
result += koreanResult.result
i = koreanResult.newIndex
continue outer
}
}
// Word boundary check for non-CJK text
const candidateIsCjk = isCjkCandidate(candidate)
if (!candidateIsCjk) {
const left = i > 0 ? text[i - 1] : undefined
const right = i + candidate.length < text.length
? text[i + candidate.length]
: undefined
if (!isWordBoundary(left) || !isWordBoundary(right)) {
result += text[i]
i++
continue outer
}
}
// Check for Korean particles (extended)
const isKoreanCandidate = isKoreanText(candidate)
if (isKoreanCandidate) {
const right = i + candidate.length < text.length
? text.slice(
i + candidate.length,
i + candidate.length + 10,
)
: ""
// Check for Korean particles (no action needed, just a check point)
if (right.match(REGEX_PATTERNS.KOREAN_PARTICLES_EXTENDED)) {
// Skip word boundary check for Korean particles
}
}
// Skip if namespace restriction applies
if (
settings.proximityBasedLinking
&& candidateData.candidates.length > 0
&& candidateData.candidates[0].scoped
&& candidateData.candidates[0].namespace !== currentNamespace
) {
result += candidate
i += candidate.length
continue outer
}
// Create the link
const { linkPath, alias } = resolveLinkContent(
candidateData,
candidate,
settings,
resolvedAmbiguities,
)
const isInTable = isIndexInsideMarkdownTable(text, i)
const finalLink = linkGenerator({
linkPath,
sourcePath: filePath,
alias,
isInTable: forceIsInTable ?? isInTable,
})
result += finalLink
i += candidate.length
continue outer
}
}
// Fallback: multi-word lookup using fallback index
if (settings.proximityBasedLinking) {
const fallbackResult = processFallbackSearch(
text,
i,
fallbackIndex,
filePath,
currentNamespace,
linkGenerator,
if (scanResult?.action === "match") {
const occurrence = scanResult.occurrence
const candidateData = occurrence.replacementCandidateData
?? occurrence.candidateData
const { linkPath, alias } = resolveLinkContent(
candidateData,
occurrence.text,
settings,
forceIsInTable,
resolvedAmbiguities,
)
if (fallbackResult) {
result += fallbackResult.result
i = fallbackResult.newIndex
continue outer
}
const finalLink = linkGenerator({
linkPath,
sourcePath: filePath,
alias,
isInTable: forceIsInTable ?? occurrence.isInTable,
})
result += finalLink
i = occurrence.end
continue
}
// If no rule applies, output the current character
result += text[i]
i++
}

View file

@ -189,4 +189,78 @@ meeting`
)
expect(result.size).toBe(0)
})
it("uses baseDir when filtering scoped candidates for AI requests", async () => {
const scopedCandidateMap = new Map<string, CandidateData>([
[
"internal",
{
candidates: [
{ canonical: "pages/team-a/internal", scoped: true, namespace: "team-a" },
{ canonical: "pages/team-a/archive/internal", scoped: true, namespace: "team-a" },
],
},
],
])
const scopedTrie: TrieNode = buildTrie(["internal"], true)
vi.mocked(aiClient.resolveAmbiguitiesBatch).mockClear()
vi.mocked(aiClient.resolveAmbiguitiesBatch).mockResolvedValue(new Map())
await resolveAmbiguities(
"internal",
scopedCandidateMap,
scopedTrie,
{
...mockSettings,
proximityBasedLinking: true,
},
"pages/team-a/today",
"pages",
)
const [settingsArg, requestsArg] = vi.mocked(aiClient.resolveAmbiguitiesBatch).mock.calls[0]
expect(settingsArg).toEqual(expect.objectContaining({
proximityBasedLinking: true,
}))
expect(settingsArg).not.toHaveProperty("baseDir")
expect(requestsArg).toEqual([
expect.objectContaining({
word: "internal",
candidates: [
"pages/team-a/internal",
"pages/team-a/archive/internal",
],
}),
])
})
it("does not request AI for candidates inside raw Linear URLs", async () => {
const linearCandidateMap = new Map<string, CandidateData>([
[
"linear",
{
candidates: [
{ canonical: "work/linear", scoped: false, namespace: "work" },
{ canonical: "private/linear", scoped: false, namespace: "private" },
],
},
],
])
const linearTrie: TrieNode = buildTrie(["linear"], true)
vi.mocked(aiClient.resolveAmbiguitiesBatch).mockClear()
vi.mocked(aiClient.resolveAmbiguitiesBatch).mockResolvedValue(new Map())
const result = await resolveAmbiguities(
"Open linear://issue/TEAM-123",
linearCandidateMap,
linearTrie,
mockSettings,
)
expect(aiClient.resolveAmbiguitiesBatch).toHaveBeenCalledWith(
mockSettings,
[],
)
expect(result.size).toBe(0)
})
})

View file

@ -12,13 +12,17 @@ export const resolveAmbiguities = async (
trie: TrieNode,
settings: AutomaticLinkerSettings,
filePath = "",
baseDir?: string,
): Promise<Map<string, string>> => {
const scannerSettings = baseDir === undefined
? settings
: { ...settings, baseDir }
const occurrences = scanCandidateOccurrences({
text,
filePath,
trie,
candidateMap,
settings,
settings: scannerSettings,
})
const requests: AIResolveRequest[] = occurrences