From a8afe1d84752dcc06311a5225959ee82c565a7a3 Mon Sep 17 00:00:00 2001 From: Kodai Nakamura Date: Mon, 22 Jun 2026 02:07:14 +0900 Subject: [PATCH] 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 --- .superpowers/sdd/task-1-report.md | 62 +++++++++++++++++++ src/main.ts | 5 +- .../__tests__/ai-disambiguation.test.ts | 35 +++++++++++ .../__tests__/candidate-scanner.test.ts | 37 +++++++++++ src/replace-links/candidate-scanner.ts | 23 ++++++- 5 files changed, 158 insertions(+), 4 deletions(-) diff --git a/.superpowers/sdd/task-1-report.md b/.superpowers/sdd/task-1-report.md index 36c5e2b..92e7525 100644 --- a/.superpowers/sdd/task-1-report.md +++ b/.superpowers/sdd/task-1-report.md @@ -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 diff --git a/src/main.ts b/src/main.ts index c5c050f..cf806d6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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, }, diff --git a/src/replace-links/__tests__/ai-disambiguation.test.ts b/src/replace-links/__tests__/ai-disambiguation.test.ts index c8a7643..d5b67f2 100644 --- a/src/replace-links/__tests__/ai-disambiguation.test.ts +++ b/src/replace-links/__tests__/ai-disambiguation.test.ts @@ -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") + }) }) diff --git a/src/replace-links/__tests__/candidate-scanner.test.ts b/src/replace-links/__tests__/candidate-scanner.test.ts index d341468..f7d8640 100644 --- a/src/replace-links/__tests__/candidate-scanner.test.ts +++ b/src/replace-links/__tests__/candidate-scanner.test.ts @@ -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: [ diff --git a/src/replace-links/candidate-scanner.ts b/src/replace-links/candidate-scanner.ts index f76a06e..958b3e6 100644 --- a/src/replace-links/candidate-scanner.ts +++ b/src/replace-links/candidate-scanner.ts @@ -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,