From 9a98513ea89b51ebfcc48af64c96a437ed0b3f04 Mon Sep 17 00:00:00 2001 From: Kodai Nakamura Date: Mon, 22 Jun 2026 04:06:18 +0900 Subject: [PATCH] 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. --- src/main.ts | 1 + src/markdown-protection.ts | 2 + src/markdown-segments.ts | 3 +- .../__tests__/candidate-scanner.test.ts | 98 +++++ src/replace-links/candidate-scanner.ts | 319 ++++++++------ src/replace-links/replace-links.ts | 409 ++---------------- .../__tests__/resolve-ambiguities.test.ts | 74 ++++ src/utils/resolve-ambiguities.ts | 6 +- 8 files changed, 412 insertions(+), 500 deletions(-) create mode 100644 src/markdown-protection.ts diff --git a/src/main.ts b/src/main.ts index 5e649cb..48c7a81 100644 --- a/src/main.ts +++ b/src/main.ts @@ -497,6 +497,7 @@ export default class AutomaticLinkerPlugin extends Plugin { this.trie, this.settings, normalizedActiveFilePath, + baseDir, ) const resultBody = replaceLinks({ diff --git a/src/markdown-protection.ts b/src/markdown-protection.ts new file mode 100644 index 0000000..8390241 --- /dev/null +++ b/src/markdown-protection.ts @@ -0,0 +1,2 @@ +export const RAW_URL_SOURCE = "(?:https?:\\/\\/|linear:\\/\\/)[^\\s]+" +export const RAW_URL_AT_START_PATTERN = new RegExp(`^(${RAW_URL_SOURCE})`) diff --git a/src/markdown-segments.ts b/src/markdown-segments.ts index 37e9a62..5e5166b 100644 --- a/src/markdown-segments.ts +++ b/src/markdown-segments.ts @@ -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") diff --git a/src/replace-links/__tests__/candidate-scanner.test.ts b/src/replace-links/__tests__/candidate-scanner.test.ts index f7d8640..13679d6 100644 --- a/src/replace-links/__tests__/candidate-scanner.test.ts +++ b/src/replace-links/__tests__/candidate-scanner.test.ts @@ -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" }], diff --git a/src/replace-links/candidate-scanner.ts b/src/replace-links/candidate-scanner.ts index 958b3e6..a312cec 100644 --- a/src/replace-links/candidate-scanner.ts +++ b/src/replace-links/candidate-scanner.ts @@ -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 + fallbackIndex: Map> + 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++ diff --git a/src/replace-links/replace-links.ts b/src/replace-links/replace-links.ts index b4ced09..f798f02 100644 --- a/src/replace-links/replace-links.ts +++ b/src/replace-links/replace-links.ts @@ -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>, - 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, - 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++ } diff --git a/src/utils/__tests__/resolve-ambiguities.test.ts b/src/utils/__tests__/resolve-ambiguities.test.ts index 9296c67..b3754f0 100644 --- a/src/utils/__tests__/resolve-ambiguities.test.ts +++ b/src/utils/__tests__/resolve-ambiguities.test.ts @@ -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([ + [ + "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([ + [ + "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) + }) }) diff --git a/src/utils/resolve-ambiguities.ts b/src/utils/resolve-ambiguities.ts index d75f222..10a17fc 100644 --- a/src/utils/resolve-ambiguities.ts +++ b/src/utils/resolve-ambiguities.ts @@ -12,13 +12,17 @@ export const resolveAmbiguities = async ( trie: TrieNode, settings: AutomaticLinkerSettings, filePath = "", + baseDir?: string, ): Promise> => { + const scannerSettings = baseDir === undefined + ? settings + : { ...settings, baseDir } const occurrences = scanCandidateOccurrences({ text, filePath, trie, candidateMap, - settings, + settings: scannerSettings, }) const requests: AIResolveRequest[] = occurrences