fix(replace-links): align AI self-link and scanner protection

Why:
- the AI enhancement command normalized the active note path for ambiguity resolution but then passed the raw .md path into replacement, which could still create self-links when preventSelfLinking was enabled
- the scanner merged block-level protected ranges before appending inline-code ranges, which let mixed ordering move scanning backward into protected callout content

What:
- normalize the AI command file path once and reuse it for both resolveAmbiguities() and replaceLinks()
- sort and merge the final protected range list after adding inline-code ranges
- add focused regressions for the AI self-link path handoff and the inline-code-before-callout scanner case
This commit is contained in:
Kodai Nakamura 2026-06-22 02:07:14 +09:00
parent 21fd68c7b9
commit a8afe1d847
5 changed files with 158 additions and 4 deletions

View file

@ -413,3 +413,65 @@ Results:
- Changed the Korean particle skip branch in `scanCandidateOccurrences()` to advance by one codepoint, matching the existing cursor behavior in `replaceLinks()`
- Kept the isolated particle omission intact for `문서는` when no overlapping follow-on candidate is present
- Preserved public interfaces unchanged
## Review Fix 5
### Scope
Fixed the two remaining Task 1 review findings:
- normalized the AI command `linkResolverContext.filePath` once and reused the same no-`.md` path for both `resolveAmbiguities()` and the subsequent `replaceLinks()` call
- normalized scanner protected ranges after inline-code ranges are added so mixed ordering cannot move scanning backward into protected callout content
- added focused regressions for the AI self-link path handoff and the inline-code-before-callout protected-range ordering case
No AGENTS.md changes were needed.
### TDD Evidence
#### RED
Commands:
```bash
npx vitest run src/replace-links/__tests__/candidate-scanner.test.ts
npx vitest run src/replace-links/__tests__/ai-disambiguation.test.ts
```
Results:
- `candidate-scanner.test.ts`: failed on the new mixed-order protected-range regression because the scanner emitted a `meeting` occurrence from inside the callout and duplicated the final prose occurrence
- `ai-disambiguation.test.ts`: failed on the new AI self-link path regression because the `.md`-suffixed replacement path still allowed `[[work/meeting|meeting]]` with `preventSelfLinking: true`
#### GREEN
Command:
```bash
npx vitest run src/replace-links/__tests__/candidate-scanner.test.ts src/replace-links/__tests__/ai-disambiguation.test.ts src/replace-links/__tests__/replace-links.prevent-linking.test.ts src/utils/__tests__/resolve-ambiguities.test.ts
```
Result:
- Passed: `25/25` tests across `4/4` files
### Implementation Summary
- Reused one `normalizedActiveFilePath` in the AI command path so ambiguity resolution and replacement observe the same self-linking semantics.
- Added an internal scanner `sortAndMergeRanges()` pass over the combined block and inline-code protected ranges before iterating unprotected segments.
- Added a focused AI regression that models a `.md` active file path flowing through normalized AI disambiguation and replacement, and a focused scanner regression for inline code preceding a protected callout.
### Full Verification
Commands:
```bash
npm run test -- --reporter=dot
npm run tsc
npm run lint
```
Results:
- `npm run test -- --reporter=dot`: passed, `331/331` tests across `38/38` files
- `npm run tsc`: passed
- `npm run lint`: passed

View file

@ -516,6 +516,7 @@ export default class AutomaticLinkerPlugin extends Plugin {
const fileContent = await this.app.vault.read(activeFile)
const { contentStart } = getFrontMatterInfo(fileContent)
const body = fileContent.slice(contentStart)
const normalizedActiveFilePath = activeFile.path.replace(/\.md$/, "")
if (!this.candidateMap || !this.trie) {
this.refreshFileDataAndTrie()
@ -531,13 +532,13 @@ export default class AutomaticLinkerPlugin extends Plugin {
this.candidateMap,
this.trie,
this.settings,
activeFile.path.replace(/\.md$/, ""),
normalizedActiveFilePath,
)
const resultBody = replaceLinks({
body,
linkResolverContext: {
filePath: activeFile.path,
filePath: normalizedActiveFilePath,
trie: this.trie,
candidateMap: this.candidateMap,
},

View file

@ -1,6 +1,8 @@
import { describe, it, expect } from "vitest"
import { replaceLinks } from "../replace-links"
import { buildCandidateTrie } from "../../trie"
import { resolveAmbiguities } from "../../utils/resolve-ambiguities"
import { DEFAULT_SETTINGS } from "../../settings/settings-info"
describe("replaceLinks with AI disambiguation", () => {
const allFiles = [
@ -79,4 +81,37 @@ describe("replaceLinks with AI disambiguation", () => {
expect(result).toBe("[[private/문서|문서]]이다.")
})
it("should keep AI command self-link prevention when the active file path includes .md", async () => {
const body = "meeting"
const activeFilePath = "work/meeting.md"
const normalizedFilePath = activeFilePath.replace(/\.md$/, "")
const settings = {
...DEFAULT_SETTINGS,
aiEnabled: true,
preventSelfLinking: true,
}
const resolvedAmbiguities = await resolveAmbiguities(
body,
candidateMap,
trie,
settings,
normalizedFilePath,
)
const result = replaceLinks({
body,
linkResolverContext: {
filePath: normalizedFilePath,
trie,
candidateMap,
},
settings,
resolvedAmbiguities,
})
expect(resolvedAmbiguities.size).toBe(0)
expect(result).toBe("meeting")
})
})

View file

@ -217,6 +217,43 @@ meeting`,
])
})
it("does not re-enter a later callout when inline code appears earlier", () => {
const { candidateMap, trie } = buildCandidateTrieForTest({
files: [
{ path: "work/meeting" },
{ path: "private/meeting" },
],
settings: {
scoped: false,
baseDir: undefined,
ignoreCase: true,
},
})
const occurrences = scanCandidateOccurrences({
text: "`meeting`\n\n> [!note]\n> meeting\n\nmeeting",
filePath: "notes/today",
trie,
candidateMap,
settings: {
ignoreCase: true,
proximityBasedLinking: true,
},
})
expect(occurrences.map(o => ({
start: o.start,
end: o.end,
text: o.text,
}))).toEqual([
{
start: 32,
end: 39,
text: "meeting",
},
])
})
it("skips Korean particle hits but still finds overlapping follow-on candidates", () => {
const isolatedFixture = buildCandidateTrieForTest({
files: [

View file

@ -402,6 +402,25 @@ const findProtectedBlockRanges = (
return merged
}
const sortAndMergeRanges = (
ranges: Array<{ start: number, end: number }>,
): Array<{ start: number, end: number }> => {
const sortedRanges = ranges.slice().sort((a, b) => a.start - b.start)
const merged: Array<{ start: number, end: number }> = []
for (const range of sortedRanges) {
const lastRange = merged[merged.length - 1]
if (!lastRange || range.start > lastRange.end) {
merged.push({ ...range })
continue
}
lastRange.end = Math.max(lastRange.end, range.end)
}
return merged
}
const findInlineCodeRanges = (text: string): Array<{ start: number, end: number }> => {
if (!text.includes("`")) {
return []
@ -744,10 +763,10 @@ export const scanCandidateOccurrences = ({
const normalizedText = text.normalize("NFC")
const fallbackIndex = buildFallbackIndex(candidateMap, settings.ignoreCase)
const currentNamespace = getCurrentNamespace(filePath, settings.baseDir)
const protectedRanges = [
const protectedRanges = sortAndMergeRanges([
...findProtectedBlockRanges(normalizedText, settings),
...findInlineCodeRanges(normalizedText),
]
])
collectExistingWikilinks(
normalizedText,