mirror of
https://github.com/kdnk/obsidian-automatic-linker.git
synced 2026-07-22 12:00:30 +00:00
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:
parent
21fd68c7b9
commit
a8afe1d847
5 changed files with 158 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue