From ef7e233a477ed9efcd93dbf6f301ee34731c1bdf Mon Sep 17 00:00:00 2001 From: Kodai Nakamura Date: Mon, 22 Jun 2026 00:45:56 +0900 Subject: [PATCH 01/25] docs: add architecture deepening design Why: The architecture review identified five related deepening opportunities that should be implemented in a staged, testable sequence rather than as ad hoc refactors. What: Document the approved design for candidate scanning, formatting runs, settings catalog, Markdown protected segments, and URL formatting adapters, including scope, non-goals, testing strategy, migration order, risks, and success criteria. --- ...026-06-22-architecture-deepening-design.md | 277 ++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-22-architecture-deepening-design.md diff --git a/docs/superpowers/specs/2026-06-22-architecture-deepening-design.md b/docs/superpowers/specs/2026-06-22-architecture-deepening-design.md new file mode 100644 index 0000000..775f3a7 --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-architecture-deepening-design.md @@ -0,0 +1,277 @@ +# Architecture Deepening Design + +## Goal + +Deepen the architecture of Obsidian Automatic Linker without changing user-visible behavior. + +The work should reduce duplicated transformation logic, move Obsidian-specific code toward thin adapters, and concentrate link-formatting behavior in modules with higher locality and leverage. + +## Scope + +Implement all five architecture-review candidates in a staged sequence: + +1. Deepen candidate scanning. +2. Deepen the formatting run. +3. Deepen the settings catalog. +4. Deepen Markdown protected segments. +5. Deepen URL formatting adapters. + +Each stage must preserve the existing behavior covered by the current test suite. New tests should be added before production changes when a stage exposes behavior that is currently duplicated, underspecified, or hard to test through the existing interface. + +## Non-Goals + +- Do not add new user-facing features. +- Do not change existing settings names, defaults, command ids, or manifest metadata. +- Do not change Obsidian link output formats except where an existing test already expects that behavior. +- Do not replace the current test framework or build tooling. +- Do not introduce a Markdown parser dependency unless a later implementation step proves regex segmentation cannot preserve current behavior. + +## Architecture + +The current codebase has two large centers of gravity: + +- `src/main.ts` owns Obsidian lifecycle, command registration, settings access, indexing, URL title fetching, URL formatting, link replacement, and AI command orchestration. +- `src/replace-links/replace-links.ts` owns a deep but broad implementation for link candidate matching and content transformation. + +The target architecture keeps the transformation core pure and moves app integration to adapters: + +- Obsidian commands, editor reads/writes, notices, network requests, and plugin lifecycle remain in adapter code. +- Link candidate scanning, Markdown segment protection, URL formatting, settings projection, and formatting-run sequencing become testable modules. +- Existing modules are split only where the split concentrates complexity behind a smaller interface. + +## Candidate 1: Deepen Candidate Scanning + +### Problem + +`replaceLinks` and `resolveAmbiguities` both scan note text for possible link candidates. + +`replaceLinks` contains the full implementation: protected spans, trie traversal, CJK handling, sentence-case matching, namespace scope, table awareness, and existing-link correction. `resolveAmbiguities` contains a simplified scanner for AI requests. This weakens locality because changing candidate matching requires checking two implementations. + +### Design + +Create a candidate scanning module used by both link replacement and AI ambiguity resolution. + +The module should expose occurrences of linkable text and existing links in terms of the existing data model: + +- source text range +- matched text +- candidate key +- candidate data +- whether the occurrence is protected, replaceable, or an existing wikilink +- enough context for AI request construction + +`replaceLinks` remains responsible for rendering replacements. `resolveAmbiguities` becomes an adapter that asks the scanner for ambiguous occurrences and sends them to the AI client. + +### Testing + +Tests should prove that AI ambiguity detection respects the same candidate matching rules as replacement for: + +- protected inline code +- existing Markdown links +- case-insensitive candidates +- namespace-scoped candidates +- CJK or sentence-case behavior where currently covered by `replaceLinks` + +## Candidate 2: Deepen The Formatting Run + +### Problem + +`main.ts` currently sequences multiple pure transformations directly: + +- GitHub URL formatting +- Jira URL formatting +- Linear URL formatting +- URL title replacement +- frontmatter/body splitting +- link replacement +- settings projection for `replaceLinks` + +The file and selection commands duplicate the settings projection. The AI command passes a broader settings object into `replaceLinks`, which makes the transformation interface inconsistent. + +### Design + +Create a formatting-run module that owns content transformation sequencing. + +The Obsidian plugin adapter should supply: + +- current file path +- raw content or selected text +- frontmatter when available +- candidate index +- URL title map +- link generator +- projected runtime settings + +The formatting-run module should return transformed text and avoid direct Obsidian dependencies. + +`main.ts` keeps editor reads/writes, command registration, notices, vault traversal, URL title fetching, and plugin integration. + +### Testing + +Tests should exercise the formatting run without constructing `AutomaticLinkerPlugin` or mocking Obsidian. Existing `main.ts` tests that only verify pure transformation behavior should move toward the formatting-run module where practical. + +## Candidate 3: Deepen The Settings Catalog + +### Problem + +Adding a setting currently crosses several places: + +- `src/settings/settings-info.ts` +- `src/settings/settings.ts` +- `src/main.ts` +- `src/replace-links/replace-links.ts` + +The repo's development guide already warns about this. That warning is evidence that the settings module is shallow: the interface nearly matches the implementation, and adding a field lacks locality. + +### Design + +Create a settings catalog module that owns settings metadata and projections. + +The catalog should centralize: + +- default values +- setting groups and display metadata used by the settings tab +- whether changing a setting requires index refresh +- projection from `AutomaticLinkerSettings` to link replacement settings +- projection from `AutomaticLinkerSettings` to URL formatting settings, if needed + +The settings tab remains an Obsidian UI adapter that renders catalog entries into `Setting` controls. + +### Testing + +Tests should cover: + +- every default setting has catalog metadata or an explicit reason it is runtime-only +- link replacement projection includes all fields expected by `ReplaceLinksSettings` +- refresh-required settings match current behavior + +## Candidate 4: Deepen Markdown Protected Segments + +### Problem + +Markdown protection rules appear in multiple modules: + +- `replaceLinks` protects code blocks, inline code, existing wikilinks, Markdown links, headings, tables, and callouts. +- URL title replacement and URL discovery perform their own context checks. + +The URL title module also documents an incomplete fenced-code check. This is locality friction and an opportunity to put Markdown segmentation behind one module. + +### Design + +Create a Markdown segment module that divides text into transformable prose and protected segments. + +The module should preserve exact text and ordering. Adapters can then transform only prose segments: + +- link replacement adapter +- URL discovery adapter +- URL title replacement adapter + +This stage should be conservative. If extracting all segment rules at once is too risky, start with fenced code, inline code, Markdown links, and wikilinks, then fold headings, tables, and callouts into the same module after tests are in place. + +### Testing + +Tests should cover exact round-trip reconstruction and per-segment transformation for: + +- inline code +- fenced code blocks, including unclosed fences +- existing wikilinks +- Markdown links +- headings when ignored +- Markdown tables when ignored +- Obsidian callouts + +## Candidate 5: Deepen URL Formatting Adapters + +### Problem + +URL formatting has separate adapter modules for GitHub, Jira, and Linear, but orchestration is split: + +- `main.ts` chooses individual passes. +- `replaceURLs` runs a formatter over URL matches. +- `github.ts` imports sibling formatters through `formatURL`, creating adapter coupling. + +This is a shallow seam: there are already multiple adapters, but the module that should own ordering and one pass over URLs does not yet own them fully. + +### Design + +Create a URL formatting module that owns: + +- URL discovery in transformable prose +- adapter ordering +- selecting the first adapter that changes a URL +- preserving current output formats + +GitHub, Jira, and Linear modules become independent adapters. They should not import each other. + +### Testing + +Tests should prove: + +- all existing GitHub, Jira, and Linear outputs remain unchanged +- a text body can be processed in one URL formatting call +- adapter ordering is deterministic +- disabled settings prevent matching adapters from changing text + +## Data Flow + +The target formatting flow is: + +1. Obsidian adapter reads active file, selection, or vault file. +2. Obsidian adapter gathers frontmatter, candidate index, settings, URL title map, and link generator. +3. Formatting-run module splits frontmatter from body when needed. +4. Formatting-run module runs URL formatting, URL title replacement, and link replacement using pure modules. +5. Obsidian adapter writes the result back to the editor or vault. +6. Optional plugin integrations such as Prettier and Obsidian Linter remain in `main.ts`. + +## Error Handling + +Preserve current error behavior: + +- Obsidian command callbacks catch and log unexpected errors. +- URL title fetching failures are logged only when debug mode is enabled. +- AI API errors continue to surface through the AI command catch block and notice. +- Pure transformation modules should not create `Notice` instances or call Obsidian APIs. + +## Test Strategy + +Use TDD for each implementation stage. + +The expected test layers are: + +- pure module tests for candidate scanning, Markdown segmentation, formatting run, settings projection, and URL formatting orchestration +- existing integration-style tests around `AutomaticLinkerPlugin` only where Obsidian adapter behavior is the subject +- full regression suite after each stage + +The baseline regression command is: + +```bash +npm run test -- --reporter=dot +``` + +## Migration Strategy + +Each stage should be reversible and independently reviewable. + +1. Add tests for the behavior being centralized. +2. Add the new module with minimal implementation. +3. Route one existing caller through the new module. +4. Run focused tests. +5. Route the second caller through the same module. +6. Run the full test suite. +7. Commit the stage. + +## Risks + +- Candidate scanning is the highest-risk stage because it touches the most behavior in `replaceLinks`. +- Markdown segmentation can accidentally change exact output if placeholder restoration differs from current code. +- Settings catalog work can create broad churn in `settings.ts`; keep the UI adapter mechanical and avoid visual changes. +- URL formatting must preserve current output strings, including the existing link icon and Enterprise URL handling. + +## Success Criteria + +- All current tests pass after each stage. +- `main.ts` no longer owns pure transformation sequencing. +- `resolveAmbiguities` no longer has a separate simplified candidate scanner. +- Link replacement settings projection exists in one place. +- URL formatting can process all configured adapters through one module call. +- Git status shows only intentional source, test, and documentation changes for each stage. From dabd8d428dde07797efb5454b74dcac71a9590e8 Mon Sep 17 00:00:00 2001 From: Kodai Nakamura Date: Mon, 22 Jun 2026 00:56:07 +0900 Subject: [PATCH 02/25] docs: clarify architecture deepening design Why: - The architecture deepening work will span multiple core modules, so the design needs to remove vague implementation guidance before planning begins. - Clearer scope reduces the chance that future agents move the wrong tests or leave settings and Markdown segmentation decisions ambiguous. What: - Specify that pure transformation tests should move from main adapter tests into formatting-run tests. - Define the URL formatting settings projection in concrete terms. - Make the Markdown segment extraction order explicit for the implementation plan. --- .../specs/2026-06-22-architecture-deepening-design.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/specs/2026-06-22-architecture-deepening-design.md b/docs/superpowers/specs/2026-06-22-architecture-deepening-design.md index 775f3a7..7329fcd 100644 --- a/docs/superpowers/specs/2026-06-22-architecture-deepening-design.md +++ b/docs/superpowers/specs/2026-06-22-architecture-deepening-design.md @@ -108,7 +108,7 @@ The formatting-run module should return transformed text and avoid direct Obsidi ### Testing -Tests should exercise the formatting run without constructing `AutomaticLinkerPlugin` or mocking Obsidian. Existing `main.ts` tests that only verify pure transformation behavior should move toward the formatting-run module where practical. +Tests should exercise the formatting run without constructing `AutomaticLinkerPlugin` or mocking Obsidian. Move the existing `main.ts` tests that only verify pure transformation behavior to formatting-run tests; keep `main.ts` tests only for Obsidian adapter behavior such as link generator integration. ## Candidate 3: Deepen The Settings Catalog @@ -133,7 +133,7 @@ The catalog should centralize: - setting groups and display metadata used by the settings tab - whether changing a setting requires index refresh - projection from `AutomaticLinkerSettings` to link replacement settings -- projection from `AutomaticLinkerSettings` to URL formatting settings, if needed +- projection from `AutomaticLinkerSettings` to URL formatting adapter settings, including format flags and configured domains The settings tab remains an Obsidian UI adapter that renders catalog entries into `Setting` controls. @@ -166,7 +166,7 @@ The module should preserve exact text and ordering. Adapters can then transform - URL discovery adapter - URL title replacement adapter -This stage should be conservative. If extracting all segment rules at once is too risky, start with fenced code, inline code, Markdown links, and wikilinks, then fold headings, tables, and callouts into the same module after tests are in place. +This stage should be conservative. First extract fenced code, inline code, Markdown links, and wikilinks. Then add headings, tables, and callouts to the same module after exact round-trip segment tests are passing. ### Testing From 92cc5ac7e5286ce3d8438911a3cfb70cd0eae65c Mon Sep 17 00:00:00 2001 From: Kodai Nakamura Date: Mon, 22 Jun 2026 01:13:22 +0900 Subject: [PATCH 03/25] docs: add architecture deepening implementation plan Why: - The approved architecture design needs an executable task-by-task plan before implementation begins. - A committed plan gives subagents a stable requirements source and preserves recovery context across long-running work. What: - Add a staged TDD implementation plan for candidate scanning, formatting-run extraction, settings catalog, Markdown segmentation, and URL formatting orchestration. - Include exact files, interfaces, test commands, verification steps, and commit points for each stage. --- .../2026-06-22-architecture-deepening.md | 1750 +++++++++++++++++ 1 file changed, 1750 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-22-architecture-deepening.md diff --git a/docs/superpowers/plans/2026-06-22-architecture-deepening.md b/docs/superpowers/plans/2026-06-22-architecture-deepening.md new file mode 100644 index 0000000..df11142 --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-architecture-deepening.md @@ -0,0 +1,1750 @@ +# Architecture Deepening Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Deepen the architecture of Obsidian Automatic Linker without changing user-visible behavior. + +**Architecture:** Keep Obsidian lifecycle, editor writes, notices, network requests, and command registration in adapter code. Move pure transformation sequencing, candidate scanning, settings projection, Markdown segmentation, and URL formatter orchestration into modules with narrow interfaces and unit tests. Each stage preserves current output strings and leaves the repository in a releasable state. + +**Tech Stack:** TypeScript, Obsidian plugin APIs, Vitest, `npm run test -- --reporter=dot`, `npm run tsc`, `npm run lint`. + +## Global Constraints + +- Do not add new user-facing features. +- Do not change existing settings names, defaults, command ids, or manifest metadata. +- Do not change Obsidian link output formats except where an existing test already expects that behavior. +- Do not replace the current test framework or build tooling. +- Do not introduce a Markdown parser dependency unless a later implementation step proves regex segmentation cannot preserve current behavior. +- Use `git` for version-control operations in this repository. +- Do not access GitHub URLs directly. Use the `gh` CLI for GitHub operations. +- When committing, use English Conventional Commit messages and include a clear description of Why and What. + +--- + +## File Structure + +- Create `src/replace-links/candidate-scanner.ts`: owns candidate occurrence discovery shared by link replacement and AI ambiguity resolution. +- Create `src/replace-links/__tests__/candidate-scanner.test.ts`: tests candidate occurrence discovery without AI or rendering. +- Modify `src/replace-links/replace-links.ts`: imports scanner helpers and keeps rendering replacement output. +- Modify `src/utils/resolve-ambiguities.ts`: builds AI requests from candidate scanner output instead of its own simplified trie scan. +- Create `src/formatting-run.ts`: owns pure formatting sequencing for document bodies and whole Markdown documents. +- Create `src/__tests__/formatting-run.test.ts`: tests formatting sequencing without constructing `AutomaticLinkerPlugin`. +- Modify `src/main.ts`: uses formatting run for file, vault, selection, and AI content transformation. +- Create `src/settings/settings-catalog.ts`: owns settings defaults, metadata, refresh rules, and runtime projections. +- Create `src/settings/__tests__/settings-catalog.test.ts`: verifies catalog coverage and projections. +- Modify `src/settings/settings-info.ts`: re-exports settings type and defaults from the catalog. +- Modify `src/settings/settings.ts`: renders settings from catalog metadata while preserving visible labels and descriptions. +- Create `src/markdown-segments.ts`: owns Markdown prose/protected segment splitting and prose mapping. +- Create `src/__tests__/markdown-segments.test.ts`: verifies exact round-trip and prose-only transforms. +- Modify `src/replace-url-with-title/index.ts`: maps URL title replacement over Markdown prose segments. +- Modify `src/replace-url-with-title/utils/list-up-all-urls.ts`: collects URLs only from Markdown prose segments. +- Modify `src/replace-links/replace-links.ts`: uses Markdown segment module for protected regions. +- Create `src/replace-urls/url-formatting.ts`: owns URL formatter adapter ordering and one text pass. +- Create `src/replace-urls/__tests__/url-formatting.test.ts`: tests orchestration across GitHub, Jira, and Linear. +- Modify `src/replace-urls/github.ts`: remove sibling adapter imports from GitHub adapter. +- Modify `src/replace-urls/replace-urls.ts`: keep as compatibility shim that preserves the existing `replaceURLs` interface for current tests and callers. + +--- + +### Task 1: Candidate Scanning Module + +**Files:** +- Create: `src/replace-links/candidate-scanner.ts` +- Create: `src/replace-links/__tests__/candidate-scanner.test.ts` +- Modify: `src/replace-links/replace-links.ts` +- Modify: `src/utils/resolve-ambiguities.ts` +- Test: `src/replace-links/__tests__/candidate-scanner.test.ts` +- Test: `src/utils/__tests__/resolve-ambiguities.test.ts` +- Test: `src/replace-links/__tests__/ai-disambiguation.test.ts` + +**Interfaces:** +- Consumes: `CandidateData`, `TrieNode`, and `ReplaceLinksSettings`. +- Produces: + +```ts +export type CandidateOccurrenceKind = "unlinked" | "existing-wikilink" + +export interface CandidateOccurrence { + kind: CandidateOccurrenceKind + start: number + end: number + text: string + candidateKey: string + candidateData: CandidateData + isInTable: boolean +} + +export interface ScanCandidateOccurrencesOptions { + text: string + filePath: string + trie: TrieNode + candidateMap: Map + settings?: ReplaceLinksSettings +} + +export const scanCandidateOccurrences: ( + options: ScanCandidateOccurrencesOptions, +) => CandidateOccurrence[] + +export const getOccurrenceContext: ( + text: string, + occurrence: CandidateOccurrence, + maxContext: number, +) => string +``` + +- Later tasks may import `scanCandidateOccurrences` from `src/replace-links/candidate-scanner.ts`; no later task may recreate candidate matching logic. + +- [ ] **Step 1: Write the failing scanner tests** + +Create `src/replace-links/__tests__/candidate-scanner.test.ts`: + +```ts +import { describe, expect, it } from "vitest" +import { buildCandidateTrieForTest } from "./test-helpers" +import { + getOccurrenceContext, + scanCandidateOccurrences, +} from "../candidate-scanner" + +describe("scanCandidateOccurrences", () => { + it("reports ambiguous prose candidates and skips inline code", () => { + const { candidateMap, trie } = buildCandidateTrieForTest({ + files: [ + { path: "work/meeting" }, + { path: "private/meeting" }, + ], + settings: { + scoped: false, + baseDir: undefined, + ignoreCase: true, + }, + }) + + const occurrences = scanCandidateOccurrences({ + text: "`meeting` meeting", + filePath: "notes/today", + trie, + candidateMap, + settings: { ignoreCase: true, proximityBasedLinking: true }, + }) + + expect(occurrences.map(o => ({ + kind: o.kind, + text: o.text, + start: o.start, + end: o.end, + candidates: o.candidateData.candidates.map(c => c.canonical), + }))).toEqual([ + { + kind: "unlinked", + text: "meeting", + start: 10, + end: 17, + candidates: ["work/meeting", "private/meeting"], + }, + ]) + }) + + it("reports existing wikilinks by their display alias", () => { + const { candidateMap, trie } = buildCandidateTrieForTest({ + files: [ + { path: "work/meeting" }, + { path: "private/meeting" }, + ], + settings: { + scoped: false, + baseDir: undefined, + ignoreCase: true, + }, + }) + + const occurrences = scanCandidateOccurrences({ + text: "Check [[private/meeting|meeting]] notes.", + filePath: "notes/today", + trie, + candidateMap, + settings: { ignoreCase: true, proximityBasedLinking: true }, + }) + + expect(occurrences.map(o => ({ + kind: o.kind, + text: o.text, + candidateKey: o.candidateKey, + candidates: o.candidateData.candidates.map(c => c.canonical), + }))).toEqual([ + { + kind: "existing-wikilink", + text: "[[private/meeting|meeting]]", + candidateKey: "meeting", + candidates: ["work/meeting", "private/meeting"], + }, + ]) + }) + + it("filters scoped candidates outside the current namespace", () => { + const { candidateMap, trie } = buildCandidateTrieForTest({ + files: [ + { path: "pages/team-a/internal" }, + { path: "pages/team-b/internal" }, + ], + settings: { + scoped: true, + baseDir: "pages", + ignoreCase: true, + }, + }) + + const occurrences = scanCandidateOccurrences({ + text: "internal", + filePath: "pages/team-a/today", + trie, + candidateMap, + settings: { + baseDir: "pages", + ignoreCase: true, + proximityBasedLinking: true, + }, + }) + + expect(occurrences).toHaveLength(1) + expect(occurrences[0].candidateData.candidates.map(c => c.canonical)).toEqual([ + "pages/team-a/internal", + ]) + }) +}) + +describe("getOccurrenceContext", () => { + it("returns bounded surrounding text for AI requests", () => { + const occurrence = { + kind: "unlinked" as const, + start: 10, + end: 17, + text: "meeting", + candidateKey: "meeting", + candidateData: { candidates: [] }, + isInTable: false, + } + + expect(getOccurrenceContext("before -- meeting -- after", occurrence, 4)).toBe( + " -- meeting -- ", + ) + }) +}) +``` + +- [ ] **Step 2: Run scanner tests to verify RED** + +Run: + +```bash +npx vitest run src/replace-links/__tests__/candidate-scanner.test.ts +``` + +Expected: FAIL because `src/replace-links/candidate-scanner.ts` does not exist. + +- [ ] **Step 3: Implement candidate scanner** + +Create `src/replace-links/candidate-scanner.ts` with the exported interfaces from this task. Move these private helpers from `src/replace-links/replace-links.ts` into the new module, preserving behavior, then import them back into `replace-links.ts`: + +```ts +REGEX_PATTERNS +isWordBoundary +isMonthNote +isProtectedLink +isCjkText +isCjkCandidate +isKoreanText +isSentenceStart +buildFallbackIndex +getCurrentNamespace +normalizeCanonicalPath +extractLinkParts +escapeRegExp +extractFencedCodeBlocks +isSelfLink +shouldSkipCandidate +isMarkdownTableLine +isIndexInsideMarkdownTable +findBestCandidateInSameNamespace +``` + +Implement the exported scanner around those helpers: + +```ts +export const scanCandidateOccurrences = ({ + text, + filePath, + trie, + candidateMap, + settings = {}, +}: ScanCandidateOccurrencesOptions): CandidateOccurrence[] => { + const occurrences: CandidateOccurrence[] = [] + const normalizedText = text.normalize("NFC") + const fallbackIndex = buildFallbackIndex(candidateMap, settings.ignoreCase) + const currentNamespace = getCurrentNamespace(filePath, settings.baseDir) + + collectExistingWikilinks(normalizedText, candidateMap, occurrences) + collectUnlinkedOccurrences({ + text: normalizedText, + filePath, + trie, + candidateMap, + fallbackIndex, + currentNamespace, + settings, + occurrences, + }) + + return occurrences.sort((a, b) => a.start - b.start) +} + +export const getOccurrenceContext = ( + text: string, + occurrence: CandidateOccurrence, + maxContext: number, +): string => { + const start = Math.max(0, occurrence.start - maxContext) + const end = Math.min(text.length, occurrence.end + maxContext) + return text.slice(start, end) +} +``` + +`collectUnlinkedOccurrences` must use the same trie traversal, fallback search, word-boundary, CJK, sentence-case, namespace, date, month-note, and self-link checks that `processStandardText` currently uses. Keep the scanner pure; it must not call a `LinkGenerator` or produce final wiki link strings. + +Modify `src/replace-links/replace-links.ts` to import the moved helpers from `./candidate-scanner` and delete their local duplicate definitions. Keep `replaceLinks` rendering behavior unchanged. + +- [ ] **Step 4: Run scanner tests to verify GREEN** + +Run: + +```bash +npx vitest run src/replace-links/__tests__/candidate-scanner.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Route AI ambiguity detection through scanner** + +Modify `src/utils/resolve-ambiguities.ts` so it no longer scans the trie directly: + +```ts +import { CandidateData, TrieNode } from "../trie" +import { AutomaticLinkerSettings } from "../settings/settings-info" +import { resolveAmbiguitiesBatch, AIResolveRequest } from "./ai-client" +import { + getOccurrenceContext, + scanCandidateOccurrences, +} from "../replace-links/candidate-scanner" + +export const resolveAmbiguities = async ( + text: string, + candidateMap: Map, + trie: TrieNode, + settings: AutomaticLinkerSettings, +): Promise> => { + const occurrences = scanCandidateOccurrences({ + text, + filePath: "", + trie, + candidateMap, + settings, + }) + + const requests: AIResolveRequest[] = occurrences + .filter(occurrence => occurrence.candidateData.candidates.length > 1) + .map((occurrence) => ({ + word: occurrence.text, + text: getOccurrenceContext(text, occurrence, settings.aiMaxContext), + candidates: occurrence.candidateData.candidates.map(c => c.canonical), + })) + + const uniqueRequests = Array.from(new Map(requests.map(r => [r.word, r])).values()) + return await resolveAmbiguitiesBatch(settings, uniqueRequests) +} +``` + +For existing wikilinks, `occurrence.text` must be the full wikilink string, because `replaceLinks` already uses full wikilink text as the key for replacement. + +- [ ] **Step 6: Run focused AI and replacement tests** + +Run: + +```bash +npx vitest run src/utils/__tests__/resolve-ambiguities.test.ts src/replace-links/__tests__/ai-disambiguation.test.ts src/replace-links/__tests__/replace-links.basic.test.ts +``` + +Expected: PASS. + +- [ ] **Step 7: Run full verification for the stage** + +Run: + +```bash +npm run test -- --reporter=dot +npm run tsc +npm run lint +``` + +Expected: all commands exit 0. + +- [ ] **Step 8: Commit candidate scanning stage** + +Run: + +```bash +git add src/replace-links/candidate-scanner.ts src/replace-links/__tests__/candidate-scanner.test.ts src/replace-links/replace-links.ts src/utils/resolve-ambiguities.ts +MSG_FILE=$(mktemp) +cat > "$MSG_FILE" <<'EOF' +refactor(replace-links): centralize candidate scanning + +Why: +- Candidate detection was duplicated between link replacement and AI ambiguity resolution, which made matching behavior hard to reason about. +- A single scanner improves locality for namespace, protected-span, case, and CJK matching rules. + +What: +- Add a shared candidate scanner module for unlinked text and existing wikilinks. +- Route AI ambiguity request construction through the scanner. +- Keep link rendering behavior unchanged and covered by existing replacement tests. +EOF +git commit -F "$MSG_FILE" +rm -f "$MSG_FILE" +``` + +--- + +### Task 2: Formatting Run Module + +**Files:** +- Create: `src/formatting-run.ts` +- Create: `src/__tests__/formatting-run.test.ts` +- Modify: `src/main.ts` +- Modify: `src/__tests__/main-url-title-frontmatter.test.ts` +- Test: `src/__tests__/formatting-run.test.ts` +- Test: `src/__tests__/main-url-title-frontmatter.test.ts` +- Test: `src/__tests__/main-link-generator.test.ts` + +**Interfaces:** +- Consumes: `AutomaticLinkerSettings`, `ReplaceLinksSettings`, `TrieNode`, `CandidateData`, `LinkGenerator`, and URL title map. +- Produces: + +```ts +export interface CandidateIndex { + trie: TrieNode + candidateMap: Map +} + +export interface FormattingRunOptions { + content: string + filePath: string + contentStart?: number + frontmatter?: Record + settings: AutomaticLinkerSettings + baseDir?: string + candidateIndex?: CandidateIndex + urlTitleMap?: Map + linkGenerator?: LinkGenerator +} + +export const toReplaceLinksSettings: ( + settings: AutomaticLinkerSettings, + baseDir?: string, +) => ReplaceLinksSettings + +export const formatMarkdownDocument: (options: FormattingRunOptions) => string + +export const formatMarkdownBody: ( + options: Omit & { body: string }, +) => string +``` + +- `formatMarkdownDocument` handles frontmatter splitting. +- `formatMarkdownBody` transforms a body or selection without frontmatter splitting. + +- [ ] **Step 1: Write formatting-run tests** + +Create `src/__tests__/formatting-run.test.ts`: + +```ts +import { describe, expect, it } from "vitest" +import { formatMarkdownBody, formatMarkdownDocument, toReplaceLinksSettings } from "../formatting-run" +import { buildCandidateTrieForTest } from "../replace-links/__tests__/test-helpers" +import { DEFAULT_SETTINGS } from "../settings/settings-info" + +describe("toReplaceLinksSettings", () => { + it("projects only replacement settings and applies baseDir", () => { + expect(toReplaceLinksSettings({ + ...DEFAULT_SETTINGS, + proximityBasedLinking: false, + ignoreDateFormats: false, + ignoreCase: false, + matchSentenceCase: false, + preventSelfLinking: true, + removeAliasInDirs: ["archive"], + ignoreHeadings: true, + ignoreMarkdownTables: true, + }, "pages")).toEqual({ + proximityBasedLinking: false, + baseDir: "pages", + ignoreDateFormats: false, + ignoreCase: false, + matchSentenceCase: false, + preventSelfLinking: true, + removeAliasInDirs: ["archive"], + ignoreHeadings: true, + ignoreMarkdownTables: true, + }) + }) +}) + +describe("formatMarkdownDocument", () => { + it("preserves frontmatter and respects URL title opt-out", () => { + const result = formatMarkdownDocument({ + content: "---\nautomatic-linker-disable-url-title: true\n---\nhttps://example.com", + filePath: "current-file.md", + frontmatter: { "automatic-linker-disable-url-title": true }, + settings: { + ...DEFAULT_SETTINGS, + formatGitHubURLs: false, + formatJiraURLs: false, + formatLinearURLs: false, + replaceUrlWithTitle: true, + }, + urlTitleMap: new Map([["https://example.com", "Example Title"]]), + }) + + expect(result).toBe( + "---\nautomatic-linker-disable-url-title: true\n---\nhttps://example.com", + ) + }) + + it("runs URL formatting, URL titles, and link replacement in order", () => { + const { candidateMap, trie } = buildCandidateTrieForTest({ + files: [{ path: "notes/TypeScript" }], + settings: { + scoped: false, + baseDir: undefined, + ignoreCase: true, + }, + }) + + const result = formatMarkdownDocument({ + content: "Read TypeScript at https://example.com", + filePath: "current-file.md", + settings: { + ...DEFAULT_SETTINGS, + formatGitHubURLs: false, + formatJiraURLs: false, + formatLinearURLs: false, + replaceUrlWithTitle: true, + ignoreCase: true, + }, + candidateIndex: { candidateMap, trie }, + urlTitleMap: new Map([["https://example.com", "Example Title"]]), + }) + + expect(result).toBe("Read [[notes/TypeScript|TypeScript]] at [Example Title](https://example.com)") + }) +}) + +describe("formatMarkdownBody", () => { + it("formats selected body text without frontmatter splitting", () => { + const { candidateMap, trie } = buildCandidateTrieForTest({ + files: [{ path: "notes/TypeScript" }], + settings: { + scoped: false, + baseDir: undefined, + ignoreCase: true, + }, + }) + + const result = formatMarkdownBody({ + body: "TypeScript", + filePath: "current-file.md", + settings: { + ...DEFAULT_SETTINGS, + formatGitHubURLs: false, + formatJiraURLs: false, + formatLinearURLs: false, + replaceUrlWithTitle: false, + ignoreCase: true, + }, + candidateIndex: { candidateMap, trie }, + }) + + expect(result).toBe("[[notes/TypeScript|TypeScript]]") + }) +}) +``` + +- [ ] **Step 2: Run formatting-run tests to verify RED** + +Run: + +```bash +npx vitest run src/__tests__/formatting-run.test.ts +``` + +Expected: FAIL because `src/formatting-run.ts` does not exist. + +- [ ] **Step 3: Implement formatting-run module** + +Create `src/formatting-run.ts`: + +```ts +import { + isUrlTitleReplacementOff, +} from "./frontmatter-utils" +import { replaceLinks, LinkGenerator } from "./replace-links/replace-links" +import { replaceUrlWithTitle } from "./replace-url-with-title" +import { formatGitHubURL } from "./replace-urls/github" +import { formatJiraURL } from "./replace-urls/jira" +import { formatLinearURL } from "./replace-urls/linear" +import { replaceURLs } from "./replace-urls/replace-urls" +import { AutomaticLinkerSettings } from "./settings/settings-info" +import { CandidateData, TrieNode } from "./trie" +import { ReplaceLinksSettings } from "./replace-links/replace-links" + +export interface CandidateIndex { + trie: TrieNode + candidateMap: Map +} + +export interface FormattingRunOptions { + content: string + filePath: string + contentStart?: number + frontmatter?: Record + settings: AutomaticLinkerSettings + baseDir?: string + candidateIndex?: CandidateIndex + urlTitleMap?: Map + linkGenerator?: LinkGenerator +} + +export const toReplaceLinksSettings = ( + settings: AutomaticLinkerSettings, + baseDir?: string, +): ReplaceLinksSettings => ({ + proximityBasedLinking: settings.proximityBasedLinking, + baseDir, + ignoreDateFormats: settings.ignoreDateFormats, + ignoreCase: settings.ignoreCase, + matchSentenceCase: settings.matchSentenceCase, + preventSelfLinking: settings.preventSelfLinking, + removeAliasInDirs: settings.removeAliasInDirs, + ignoreHeadings: settings.ignoreHeadings, + ignoreMarkdownTables: settings.ignoreMarkdownTables, +}) + +export const formatMarkdownBody = ({ + body, + filePath, + frontmatter, + settings, + baseDir, + candidateIndex, + urlTitleMap = new Map(), + linkGenerator, +}: Omit & { body: string }): string => { + let updatedBody = body + + if (settings.formatGitHubURLs) { + updatedBody = replaceURLs(updatedBody, settings, formatGitHubURL) + } + if (settings.formatJiraURLs) { + updatedBody = replaceURLs(updatedBody, settings, formatJiraURL) + } + if (settings.formatLinearURLs) { + updatedBody = replaceURLs(updatedBody, settings, formatLinearURL) + } + if (settings.replaceUrlWithTitle && !isUrlTitleReplacementOff(frontmatter)) { + updatedBody = replaceUrlWithTitle({ body: updatedBody, urlTitleMap }) + } + if (candidateIndex) { + updatedBody = replaceLinks({ + body: updatedBody, + linkResolverContext: { + filePath: filePath.replace(/\.md$/, ""), + trie: candidateIndex.trie, + candidateMap: candidateIndex.candidateMap, + }, + settings: toReplaceLinksSettings(settings, baseDir), + linkGenerator, + }) + } + + return updatedBody +} + +const inferContentStart = (content: string): number => { + const frontmatter = content.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/) + return frontmatter?.[0].length ?? 0 +} + +export const formatMarkdownDocument = ({ + content, + contentStart = inferContentStart(content), + ...options +}: FormattingRunOptions): string => { + const frontmatterText = content.slice(0, contentStart) + const body = content.slice(contentStart) + return frontmatterText + formatMarkdownBody({ ...options, body }) +} +``` + +- [ ] **Step 4: Run formatting-run tests to verify GREEN** + +Run: + +```bash +npx vitest run src/__tests__/formatting-run.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Route `main.ts` through formatting-run** + +Modify `src/main.ts`: + +```ts +import { + formatMarkdownBody, + formatMarkdownDocument, + toReplaceLinksSettings, +} from "./formatting-run" +``` + +In `modifyLinks`, replace the URL and link transformation sequence with: + +```ts +const baseDir = this.settings.respectNewFileFolderPath ? this.app.vault.getConfig("newFileFolderPath") : undefined +const candidateIndex = this.trie && this.candidateMap + ? { trie: this.trie, candidateMap: this.candidateMap } + : undefined + +return formatMarkdownDocument({ + content: fileContent, + filePath, + contentStart: getFrontMatterInfo(fileContent).contentStart, + frontmatter, + settings: this.settings, + baseDir, + candidateIndex, + urlTitleMap: this.urlTitleMap, + linkGenerator: candidateIndex ? this.createLinkGenerator(filePath) : undefined, +}) +``` + +In `mofifyLinksSelection`, replace the direct `replaceLinks` call with: + +```ts +const updatedText = formatMarkdownBody({ + body: selectedText, + filePath: activeFile.path, + settings: this.settings, + baseDir, + candidateIndex: { + trie: this.trie, + candidateMap: this.candidateMap, + }, + linkGenerator, +}) +``` + +In the AI command, keep `resolvedAmbiguities` rendering in `replaceLinks` until Task 1 scanner output supports direct formatting-run AI rendering. Use `toReplaceLinksSettings(this.settings, baseDir)` rather than passing `this.settings` directly. + +- [ ] **Step 6: Move pure URL-title frontmatter test coverage** + +In `src/__tests__/main-url-title-frontmatter.test.ts`, keep only the test that verifies `buildUrlTitleMap` does not fetch when active-file frontmatter disables URL titles. Remove the `plugin.modifyLinks` pure transformation test because `src/__tests__/formatting-run.test.ts` now covers it without Obsidian mocks. + +- [ ] **Step 7: Run focused main and formatting tests** + +Run: + +```bash +npx vitest run src/__tests__/formatting-run.test.ts src/__tests__/main-url-title-frontmatter.test.ts src/__tests__/main-link-generator.test.ts +``` + +Expected: PASS. + +- [ ] **Step 8: Run full verification for the stage** + +Run: + +```bash +npm run test -- --reporter=dot +npm run tsc +npm run lint +``` + +Expected: all commands exit 0. + +- [ ] **Step 9: Commit formatting-run stage** + +Run: + +```bash +git add src/formatting-run.ts src/__tests__/formatting-run.test.ts src/main.ts src/__tests__/main-url-title-frontmatter.test.ts +MSG_FILE=$(mktemp) +cat > "$MSG_FILE" <<'EOF' +refactor: extract pure formatting run + +Why: +- main.ts mixed Obsidian adapter work with pure transformation sequencing, which made formatting behavior require plugin mocks to test. +- A pure formatting run increases locality for URL formatting, URL title replacement, and link replacement order. + +What: +- Add a formatting-run module with document and body formatting entry points. +- Route file and selection formatting through the new module. +- Move pure frontmatter URL-title coverage out of main adapter tests. +EOF +git commit -F "$MSG_FILE" +rm -f "$MSG_FILE" +``` + +--- + +### Task 3: Settings Catalog Module + +**Files:** +- Create: `src/settings/settings-catalog.ts` +- Create: `src/settings/__tests__/settings-catalog.test.ts` +- Modify: `src/settings/settings-info.ts` +- Modify: `src/settings/settings.ts` +- Modify: `src/formatting-run.ts` +- Test: `src/settings/__tests__/settings-catalog.test.ts` + +**Interfaces:** +- Produces: + +```ts +export type AutomaticLinkerSettings = { + formatOnSave: boolean + showNotice: boolean + respectNewFileFolderPath: boolean + includeAliases: boolean + proximityBasedLinking: boolean + ignoreDateFormats: boolean + ignoreHeadings: boolean + formatGitHubURLs: boolean + githubEnterpriseURLs: string[] + formatJiraURLs: boolean + jiraURLs: string[] + formatLinearURLs: boolean + debug: boolean + ignoreCase: boolean + matchSentenceCase: boolean + replaceUrlWithTitle: boolean + replaceUrlWithTitleIgnoreDomains: string[] + excludeDirsFromAutoLinking: string[] + preventSelfLinking: boolean + removeAliasInDirs: string[] + ignoreMarkdownTables: boolean + runLinterAfterFormatting: boolean + runPrettierAfterFormatting: boolean + formatDelayMs: number + aiEnabled: boolean + aiEndpoint: string + aiModel: string + aiMaxContext: number +} + +export type SettingControl = "toggle" | "text" | "textarea" + +export interface SettingCatalogEntry { + key: K + group: string + name: string + description: string + control: SettingControl + placeholder?: string + multiline?: boolean + refreshesIndex: boolean + runtimeOnly?: boolean +} + +export const DEFAULT_SETTINGS: AutomaticLinkerSettings +export const SETTINGS_CATALOG: readonly SettingCatalogEntry[] +export const settingRefreshesIndex: (key: keyof AutomaticLinkerSettings) => boolean +export const projectReplaceLinksSettings: ( + settings: AutomaticLinkerSettings, + baseDir?: string, +) => ReplaceLinksSettings +export const projectUrlFormattingSettings: ( + settings: AutomaticLinkerSettings, +) => Pick +``` + +- [ ] **Step 1: Write settings catalog tests** + +Create `src/settings/__tests__/settings-catalog.test.ts`: + +```ts +import { describe, expect, it } from "vitest" +import { + DEFAULT_SETTINGS, + SETTINGS_CATALOG, + projectReplaceLinksSettings, + projectUrlFormattingSettings, + settingRefreshesIndex, +} from "../settings-catalog" + +describe("SETTINGS_CATALOG", () => { + it("covers every default setting exactly once", () => { + const defaultKeys = Object.keys(DEFAULT_SETTINGS).sort() + const catalogKeys = SETTINGS_CATALOG.map(entry => entry.key).sort() + + expect(catalogKeys).toEqual(defaultKeys) + expect(new Set(catalogKeys).size).toBe(catalogKeys.length) + }) + + it("marks current index-refresh settings", () => { + expect(settingRefreshesIndex("respectNewFileFolderPath")).toBe(true) + expect(settingRefreshesIndex("includeAliases")).toBe(true) + expect(settingRefreshesIndex("proximityBasedLinking")).toBe(true) + expect(settingRefreshesIndex("ignoreDateFormats")).toBe(true) + expect(settingRefreshesIndex("ignoreCase")).toBe(true) + expect(settingRefreshesIndex("preventSelfLinking")).toBe(true) + expect(settingRefreshesIndex("excludeDirsFromAutoLinking")).toBe(true) + expect(settingRefreshesIndex("removeAliasInDirs")).toBe(true) + expect(settingRefreshesIndex("debug")).toBe(false) + }) +}) + +describe("settings projections", () => { + it("projects link replacement settings", () => { + expect(projectReplaceLinksSettings({ + ...DEFAULT_SETTINGS, + proximityBasedLinking: false, + ignoreDateFormats: false, + ignoreCase: false, + matchSentenceCase: false, + preventSelfLinking: true, + removeAliasInDirs: ["archive"], + ignoreHeadings: true, + ignoreMarkdownTables: true, + }, "pages")).toEqual({ + proximityBasedLinking: false, + baseDir: "pages", + ignoreDateFormats: false, + ignoreCase: false, + matchSentenceCase: false, + preventSelfLinking: true, + removeAliasInDirs: ["archive"], + ignoreHeadings: true, + ignoreMarkdownTables: true, + }) + }) + + it("projects URL formatting settings", () => { + expect(projectUrlFormattingSettings({ + ...DEFAULT_SETTINGS, + formatGitHubURLs: false, + githubEnterpriseURLs: ["github.enterprise.com"], + formatJiraURLs: false, + jiraURLs: ["jira.example.com"], + formatLinearURLs: true, + })).toEqual({ + formatGitHubURLs: false, + githubEnterpriseURLs: ["github.enterprise.com"], + formatJiraURLs: false, + jiraURLs: ["jira.example.com"], + formatLinearURLs: true, + }) + }) +}) +``` + +- [ ] **Step 2: Run catalog tests to verify RED** + +Run: + +```bash +npx vitest run src/settings/__tests__/settings-catalog.test.ts +``` + +Expected: FAIL because `src/settings/settings-catalog.ts` does not exist. + +- [ ] **Step 3: Implement settings catalog** + +Create `src/settings/settings-catalog.ts` by moving the `AutomaticLinkerSettings` type and `DEFAULT_SETTINGS` object from `src/settings/settings-info.ts` into the new file. Add `SETTINGS_CATALOG` entries for every setting. Preserve every current name, description, placeholder, default, and refresh behavior from `src/settings/settings.ts`. + +The projection functions must be: + +```ts +export const projectReplaceLinksSettings = ( + settings: AutomaticLinkerSettings, + baseDir?: string, +): ReplaceLinksSettings => ({ + proximityBasedLinking: settings.proximityBasedLinking, + baseDir, + ignoreDateFormats: settings.ignoreDateFormats, + ignoreCase: settings.ignoreCase, + matchSentenceCase: settings.matchSentenceCase, + preventSelfLinking: settings.preventSelfLinking, + removeAliasInDirs: settings.removeAliasInDirs, + ignoreHeadings: settings.ignoreHeadings, + ignoreMarkdownTables: settings.ignoreMarkdownTables, +}) + +export const projectUrlFormattingSettings = ( + settings: AutomaticLinkerSettings, +) => ({ + formatGitHubURLs: settings.formatGitHubURLs, + githubEnterpriseURLs: settings.githubEnterpriseURLs, + formatJiraURLs: settings.formatJiraURLs, + jiraURLs: settings.jiraURLs, + formatLinearURLs: settings.formatLinearURLs, +}) + +export const settingRefreshesIndex = ( + key: keyof AutomaticLinkerSettings, +): boolean => SETTINGS_CATALOG.find(entry => entry.key === key)?.refreshesIndex ?? false +``` + +Modify `src/settings/settings-info.ts` to preserve imports from existing callers: + +```ts +export type { AutomaticLinkerSettings } from "./settings-catalog" +export { DEFAULT_SETTINGS } from "./settings-catalog" +``` + +Modify `src/formatting-run.ts` to import `projectReplaceLinksSettings` and remove its local `toReplaceLinksSettings` implementation. Re-export the projection for existing imports: + +```ts +export { projectReplaceLinksSettings as toReplaceLinksSettings } from "./settings/settings-catalog" +``` + +- [ ] **Step 4: Run catalog tests to verify GREEN** + +Run: + +```bash +npx vitest run src/settings/__tests__/settings-catalog.test.ts src/__tests__/formatting-run.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Render settings tab from catalog metadata** + +Modify `src/settings/settings.ts` so the `display()` method iterates grouped catalog entries. Keep save behavior identical: + +```ts +import { App, PluginSettingTab, Setting } from "obsidian" +import AutomaticLinkerPlugin from "../main" +import { + AutomaticLinkerSettings, + SETTINGS_CATALOG, + SettingCatalogEntry, + settingRefreshesIndex, +} from "./settings-catalog" +``` + +Add private helpers to `AutomaticLinkerPluginSettingsTab`: + +```ts +private async setSettingValue( + key: K, + value: AutomaticLinkerSettings[K], +) { + this.plugin.settings[key] = value + await this.plugin.saveData(this.plugin.settings) + if (settingRefreshesIndex(key)) { + this.plugin.refreshFileDataAndTrie() + } +} + +private renderSetting(containerEl: HTMLElement, entry: SettingCatalogEntry) { + const setting = new Setting(containerEl) + .setName(entry.name) + .setDesc(entry.description) + + const value = this.plugin.settings[entry.key] + if (entry.control === "toggle") { + setting.addToggle((toggle) => { + toggle + .setValue(Boolean(value)) + .onChange(async nextValue => { + await this.setSettingValue(entry.key, nextValue as never) + }) + }) + } + if (entry.control === "text") { + setting.addText((text) => { + text.setPlaceholder(entry.placeholder ?? "") + .setValue(String(value)) + .onChange(async nextValue => { + const parsedValue = typeof value === "number" + ? parseInt(nextValue) + : nextValue + if (typeof value === "number" && (isNaN(parsedValue as number) || (parsedValue as number) < 0)) { + return + } + await this.setSettingValue(entry.key, parsedValue as never) + }) + }) + } + if (entry.control === "textarea") { + setting.addTextArea((text) => { + text.setPlaceholder(entry.placeholder ?? "") + .setValue(Array.isArray(value) ? value.join("\n") : String(value)) + .onChange(async nextValue => { + await this.setSettingValue( + entry.key, + nextValue.split("\n").map(item => item.trim()).filter(Boolean) as never, + ) + }) + text.inputEl.rows = 4 + text.inputEl.cols = 50 + }) + } +} +``` + +In `display()`, render groups in catalog order: + +```ts +const renderedGroups = new Set() +for (const entry of SETTINGS_CATALOG) { + if (!renderedGroups.has(entry.group)) { + new Setting(containerEl).setName(entry.group).setHeading() + renderedGroups.add(entry.group) + } + this.renderSetting(containerEl, entry) +} +``` + +Keep the current special side effects by encoding them in catalog-driven rendering: + +- `replaceUrlWithTitleIgnoreDomains` on change calls `await this.plugin.buildUrlTitleMap()` after saving. +- Numeric settings reject invalid values. `formatDelayMs` accepts `0`; `aiMaxContext` accepts values greater than `0`. + +- [ ] **Step 6: Run settings and full adapter tests** + +Run: + +```bash +npx vitest run src/settings/__tests__/settings-catalog.test.ts src/__tests__/main-link-generator.test.ts src/__tests__/main-url-title-frontmatter.test.ts +``` + +Expected: PASS. + +- [ ] **Step 7: Run full verification for the stage** + +Run: + +```bash +npm run test -- --reporter=dot +npm run tsc +npm run lint +``` + +Expected: all commands exit 0. + +- [ ] **Step 8: Commit settings catalog stage** + +Run: + +```bash +git add src/settings/settings-catalog.ts src/settings/__tests__/settings-catalog.test.ts src/settings/settings-info.ts src/settings/settings.ts src/formatting-run.ts +MSG_FILE=$(mktemp) +cat > "$MSG_FILE" <<'EOF' +refactor(settings): centralize settings catalog + +Why: +- Adding a setting required coordinated edits across defaults, UI, index refresh behavior, and runtime projections. +- A catalog gives settings changes one locality point while preserving current user-facing labels and defaults. + +What: +- Move settings defaults and metadata into a catalog module. +- Add projection helpers for link replacement and URL formatting. +- Render the settings tab from catalog entries without changing visible settings. +EOF +git commit -F "$MSG_FILE" +rm -f "$MSG_FILE" +``` + +--- + +### Task 4: Markdown Segment Module + +**Files:** +- Create: `src/markdown-segments.ts` +- Create: `src/__tests__/markdown-segments.test.ts` +- Modify: `src/replace-links/replace-links.ts` +- Modify: `src/replace-url-with-title/index.ts` +- Modify: `src/replace-url-with-title/utils/list-up-all-urls.ts` +- Test: `src/__tests__/markdown-segments.test.ts` +- Test: `src/replace-links/__tests__/replace-links.code.test.ts` +- Test: `src/replace-links/__tests__/replace-links.callout.test.ts` +- Test: `src/replace-url-with-title/utils/__tests__/list-up-all-urls.test.ts` + +**Interfaces:** +- Produces: + +```ts +export type MarkdownSegmentKind = "prose" | "protected" +export type MarkdownProtectedKind = + | "inline-code" + | "fenced-code" + | "wikilink" + | "markdown-link" + | "single-bracket" + | "url" + | "heading" + | "callout" + | "table-row" + +export interface MarkdownSegment { + kind: MarkdownSegmentKind + protectedKind?: MarkdownProtectedKind + start: number + end: number + text: string +} + +export interface SegmentMarkdownOptions { + protectHeadings?: boolean + protectCallouts?: boolean + protectTableRows?: boolean + protectUrls?: boolean +} + +export const segmentMarkdown: ( + text: string, + options?: SegmentMarkdownOptions, +) => MarkdownSegment[] + +export const mapMarkdownProse: ( + text: string, + transform: (segmentText: string, segment: MarkdownSegment) => string, + options?: SegmentMarkdownOptions, +) => string +``` + +- [ ] **Step 1: Write Markdown segment tests** + +Create `src/__tests__/markdown-segments.test.ts`: + +```ts +import { describe, expect, it } from "vitest" +import { mapMarkdownProse, segmentMarkdown } from "../markdown-segments" + +describe("segmentMarkdown", () => { + it("round-trips prose and protected inline code", () => { + const text = "Use `TypeScript` with TypeScript" + const segments = segmentMarkdown(text) + + expect(segments.map(segment => ({ + kind: segment.kind, + protectedKind: segment.protectedKind, + text: segment.text, + }))).toEqual([ + { kind: "prose", protectedKind: undefined, text: "Use " }, + { kind: "protected", protectedKind: "inline-code", text: "`TypeScript`" }, + { kind: "prose", protectedKind: undefined, text: " with TypeScript" }, + ]) + expect(segments.map(segment => segment.text).join("")).toBe(text) + }) + + it("protects fenced code blocks including unclosed blocks", () => { + const text = "before\n```ts\nTypeScript" + const segments = segmentMarkdown(text) + + expect(segments.map(segment => ({ + kind: segment.kind, + protectedKind: segment.protectedKind, + text: segment.text, + }))).toEqual([ + { kind: "prose", protectedKind: undefined, text: "before\n" }, + { kind: "protected", protectedKind: "fenced-code", text: "```ts\nTypeScript" }, + ]) + }) + + it("protects headings, tables, and callouts when requested", () => { + const text = "# TypeScript\n| TypeScript |\n> [!note]\n> TypeScript\nTypeScript" + const segments = segmentMarkdown(text, { + protectHeadings: true, + protectTableRows: true, + protectCallouts: true, + }) + + expect(segments.filter(segment => segment.kind === "protected").map(segment => segment.protectedKind)).toEqual([ + "heading", + "table-row", + "callout", + ]) + expect(segments.map(segment => segment.text).join("")).toBe(text) + }) +}) + +describe("mapMarkdownProse", () => { + it("transforms only prose segments", () => { + const result = mapMarkdownProse( + "TypeScript `TypeScript` [[TypeScript]]", + text => text.replaceAll("TypeScript", "TS"), + ) + + expect(result).toBe("TS `TypeScript` [[TypeScript]]") + }) +}) +``` + +- [ ] **Step 2: Run Markdown segment tests to verify RED** + +Run: + +```bash +npx vitest run src/__tests__/markdown-segments.test.ts +``` + +Expected: FAIL because `src/markdown-segments.ts` does not exist. + +- [ ] **Step 3: Implement Markdown segment module** + +Create `src/markdown-segments.ts`. Implement `segmentMarkdown` as a single ordered scanner: + +```ts +const PROTECTED_PATTERN = /(```[\s\S]*?(?:```|$)|~~~[\s\S]*?(?:~~~|$)|`[^`]*`|\[\[([^\]]+)\]\]|\[[^\]]+\]\([^)]+\)|\[[^\]]+\])/g +``` + +Before applying `PROTECTED_PATTERN`, collect optional block ranges for headings, callouts, and table rows. Merge overlapping protected ranges, sort by start index, and emit alternating prose/protected segments. `mapMarkdownProse` must join transformed prose with original protected text: + +```ts +export const mapMarkdownProse = ( + text: string, + transform: (segmentText: string, segment: MarkdownSegment) => string, + options: SegmentMarkdownOptions = {}, +): string => { + return segmentMarkdown(text, options) + .map(segment => segment.kind === "prose" ? transform(segment.text, segment) : segment.text) + .join("") +} +``` + +- [ ] **Step 4: Run Markdown segment tests to verify GREEN** + +Run: + +```bash +npx vitest run src/__tests__/markdown-segments.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Route URL title modules through Markdown prose mapping** + +Modify `src/replace-url-with-title/index.ts`: + +```ts +import { mapMarkdownProse } from "../markdown-segments" +``` + +Wrap the existing URL replacement loop so it only receives prose: + +```ts +return mapMarkdownProse(resultBody, replaceUrlsInProse) +``` + +Move the current loop body into: + +```ts +const replaceUrlsInProse = (prose: string): string => { + let resultBody = prose + // current sorted URL replacement loop + return resultBody +} +``` + +Modify `src/replace-url-with-title/utils/list-up-all-urls.ts` to iterate prose segments: + +```ts +import { segmentMarkdown } from "../../markdown-segments" + +for (const segment of segmentMarkdown(body)) { + if (segment.kind === "protected") continue + URL_REGEX.lastIndex = 0 + while ((match = URL_REGEX.exec(segment.text)) !== null) { + const url = match[0] + const matchIndex = segment.start + match.index + // preserve existing markdown-link, angle-bracket, inline-code, domain, and punctuation checks that still apply + } +} +``` + +- [ ] **Step 6: Route replace-links protected regions through Markdown segments** + +Modify `src/replace-links/replace-links.ts` so `replaceLinks` calls `mapMarkdownProse` instead of manually replacing code blocks, headings, callouts, protected links, and table rows with placeholders: + +```ts +import { mapMarkdownProse } from "../markdown-segments" +``` + +The final body processing should become: + +```ts +return mapMarkdownProse( + body, + processTableAwareTextSegment, + { + protectHeadings: settings.ignoreHeadings, + protectCallouts: true, + protectTableRows: settings.ignoreMarkdownTables, + protectUrls: true, + }, +) +``` + +Keep existing-link replacement behavior for `resolvedAmbiguities` by allowing wikilinks to be handled before protected mapping when `resolvedAmbiguities` has the full wikilink key. Preserve the current tests in `src/replace-links/__tests__/ai-disambiguation.test.ts`. + +- [ ] **Step 7: Run focused Markdown and transformation tests** + +Run: + +```bash +npx vitest run src/__tests__/markdown-segments.test.ts src/replace-links/__tests__/replace-links.code.test.ts src/replace-links/__tests__/replace-links.callout.test.ts src/replace-links/__tests__/replace-links.table.test.ts src/replace-url-with-title/utils/__tests__/list-up-all-urls.test.ts src/replace-url-with-title/__tests__/replace-url-with-title.test.ts +``` + +Expected: PASS. + +- [ ] **Step 8: Run full verification for the stage** + +Run: + +```bash +npm run test -- --reporter=dot +npm run tsc +npm run lint +``` + +Expected: all commands exit 0. + +- [ ] **Step 9: Commit Markdown segment stage** + +Run: + +```bash +git add src/markdown-segments.ts src/__tests__/markdown-segments.test.ts src/replace-links/replace-links.ts src/replace-url-with-title/index.ts src/replace-url-with-title/utils/list-up-all-urls.ts +MSG_FILE=$(mktemp) +cat > "$MSG_FILE" <<'EOF' +refactor(markdown): centralize protected segment handling + +Why: +- Link replacement and URL title code each carried their own Markdown context checks, which made protected text behavior hard to keep consistent. +- A shared Markdown segment module improves locality for prose-only transformations. + +What: +- Add a pure Markdown segment module with prose mapping. +- Route link replacement and URL title flows through shared protected segment handling. +- Preserve exact output for code blocks, links, tables, headings, and callouts. +EOF +git commit -F "$MSG_FILE" +rm -f "$MSG_FILE" +``` + +--- + +### Task 5: URL Formatting Module + +**Files:** +- Create: `src/replace-urls/url-formatting.ts` +- Create: `src/replace-urls/__tests__/url-formatting.test.ts` +- Modify: `src/replace-urls/github.ts` +- Modify: `src/replace-urls/replace-urls.ts` +- Modify: `src/formatting-run.ts` +- Test: `src/replace-urls/__tests__/url-formatting.test.ts` +- Test: `src/replace-urls/__tests__/replace-urls.github.test.ts` +- Test: `src/replace-urls/__tests__/replace-urls.jira.test.ts` +- Test: `src/replace-urls/__tests__/replace-urls.linear.test.ts` + +**Interfaces:** +- Produces: + +```ts +export type UrlFormatter = ( + url: string, + settings: AutomaticLinkerSettings, +) => string + +export interface FormatURLsInTextOptions { + text: string + settings: AutomaticLinkerSettings + formatters?: UrlFormatter[] +} + +export const DEFAULT_URL_FORMATTERS: readonly UrlFormatter[] +export const formatURLWithAdapters: ( + url: string, + settings: AutomaticLinkerSettings, + formatters?: readonly UrlFormatter[], +) => string +export const formatURLsInText: (options: FormatURLsInTextOptions) => string +``` + +- [ ] **Step 1: Write URL formatting orchestration tests** + +Create `src/replace-urls/__tests__/url-formatting.test.ts`: + +```ts +import { describe, expect, it } from "vitest" +import { DEFAULT_SETTINGS } from "../../settings/settings-info" +import { + formatURLsInText, + formatURLWithAdapters, + UrlFormatter, +} from "../url-formatting" + +describe("formatURLWithAdapters", () => { + it("uses the first adapter that changes the URL", () => { + const first: UrlFormatter = url => `${url}-first` + const second: UrlFormatter = url => `${url}-second` + + expect(formatURLWithAdapters("https://example.com", DEFAULT_SETTINGS, [first, second])).toBe( + "https://example.com-first", + ) + }) + + it("returns the original URL when no adapter changes it", () => { + const unchanged: UrlFormatter = url => url + + expect(formatURLWithAdapters("https://example.com", DEFAULT_SETTINGS, [unchanged])).toBe( + "https://example.com", + ) + }) +}) + +describe("formatURLsInText", () => { + it("formats GitHub, Jira, and Linear URLs in one text pass", () => { + const result = formatURLsInText({ + text: [ + "https://github.com/owner/repo/issues/123", + "https://jira.company.com/browse/ABC-456", + "https://linear.app/team/issue/BUG-789/title", + ].join("\n"), + settings: { + ...DEFAULT_SETTINGS, + githubEnterpriseURLs: [], + jiraURLs: ["jira.company.com"], + formatGitHubURLs: true, + formatJiraURLs: true, + formatLinearURLs: true, + }, + }) + + expect(result).toBe([ + "[[github/owner/repo/issues/123]] [🔗](https://github.com/owner/repo/issues/123)", + "[[company/jira/ABC/456]] [🔗](https://jira.company.com/browse/ABC-456)", + "[[linear/team/BUG-789]] [🔗](https://linear.app/team/issue/BUG-789)", + ].join("\n")) + }) + + it("leaves disabled adapter URLs unchanged", () => { + const result = formatURLsInText({ + text: "https://linear.app/team/issue/BUG-789/title", + settings: { + ...DEFAULT_SETTINGS, + formatLinearURLs: false, + }, + }) + + expect(result).toBe("https://linear.app/team/issue/BUG-789/title") + }) +}) +``` + +- [ ] **Step 2: Run URL formatting tests to verify RED** + +Run: + +```bash +npx vitest run src/replace-urls/__tests__/url-formatting.test.ts +``` + +Expected: FAIL because `src/replace-urls/url-formatting.ts` does not exist. + +- [ ] **Step 3: Implement URL formatting module** + +Create `src/replace-urls/url-formatting.ts`: + +```ts +import { AutomaticLinkerSettings } from "../settings/settings-info" +import { formatGitHubURL } from "./github" +import { formatJiraURL } from "./jira" +import { formatLinearURL } from "./linear" +import { mapMarkdownProse } from "../markdown-segments" + +export type UrlFormatter = ( + url: string, + settings: AutomaticLinkerSettings, +) => string + +export interface FormatURLsInTextOptions { + text: string + settings: AutomaticLinkerSettings + formatters?: readonly UrlFormatter[] +} + +export const DEFAULT_URL_FORMATTERS: readonly UrlFormatter[] = [ + formatGitHubURL, + formatJiraURL, + formatLinearURL, +] + +const URL_PATTERN = /(? { + for (const formatter of formatters) { + const formatted = formatter(url, settings) + if (formatted !== url) { + return formatted + } + } + return url +} + +export const formatURLsInText = ({ + text, + settings, + formatters = DEFAULT_URL_FORMATTERS, +}: FormatURLsInTextOptions): string => { + return mapMarkdownProse(text, prose => prose.replace(URL_PATTERN, match => { + return formatURLWithAdapters(match, settings, formatters) + })) +} +``` + +Modify `src/replace-urls/github.ts` to remove imports from `./jira` and `./linear`, and delete the exported `formatURL` function from that file. `formatGitHubURL` must remain unchanged. + +Modify `src/replace-urls/replace-urls.ts` to preserve the existing public helper for tests and compatibility: + +```ts +import { AutomaticLinkerSettings } from "../settings/settings-info" + +export const replaceURLs = ( + fileContent: string, + settings: AutomaticLinkerSettings, + formatter: (url: string, settings: AutomaticLinkerSettings) => string, +) => { + const urlPattern = /(? formatter(match, settings)) +} +``` + +- [ ] **Step 4: Run URL formatting tests to verify GREEN** + +Run: + +```bash +npx vitest run src/replace-urls/__tests__/url-formatting.test.ts src/replace-urls/__tests__/replace-urls.github.test.ts src/replace-urls/__tests__/replace-urls.jira.test.ts src/replace-urls/__tests__/replace-urls.linear.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Route formatting-run through one URL formatting call** + +Modify `src/formatting-run.ts` to import: + +```ts +import { formatURLsInText } from "./replace-urls/url-formatting" +``` + +Replace the three URL formatting passes with: + +```ts +updatedBody = formatURLsInText({ + text: updatedBody, + settings, +}) +``` + +Remove direct imports of `formatGitHubURL`, `formatJiraURL`, `formatLinearURL`, and `replaceURLs` from `src/formatting-run.ts`. + +- [ ] **Step 6: Run focused formatting tests** + +Run: + +```bash +npx vitest run src/__tests__/formatting-run.test.ts src/replace-urls/__tests__/url-formatting.test.ts src/replace-urls/__tests__/replace-urls.test.ts +``` + +Expected: PASS. + +- [ ] **Step 7: Run full verification for the stage** + +Run: + +```bash +npm run test -- --reporter=dot +npm run tsc +npm run lint +``` + +Expected: all commands exit 0. + +- [ ] **Step 8: Commit URL formatting stage** + +Run: + +```bash +git add src/replace-urls/url-formatting.ts src/replace-urls/__tests__/url-formatting.test.ts src/replace-urls/github.ts src/replace-urls/replace-urls.ts src/formatting-run.ts +MSG_FILE=$(mktemp) +cat > "$MSG_FILE" <<'EOF' +refactor(replace-urls): centralize URL formatter orchestration + +Why: +- URL formatter adapters existed, but ordering and text traversal were split across main formatting code and adapter modules. +- A central URL formatting module gives adapter ordering one locality point. + +What: +- Add one-pass URL formatting orchestration for GitHub, Jira, and Linear adapters. +- Remove sibling adapter coupling from the GitHub formatter module. +- Route formatting-run through the central URL formatting module. +EOF +git commit -F "$MSG_FILE" +rm -f "$MSG_FILE" +``` + +--- + +## Final Verification + +- [ ] **Step 1: Run the full regression suite** + +Run: + +```bash +npm run test -- --reporter=dot +npm run tsc +npm run lint +npm run build +``` + +Expected: all commands exit 0. + +- [ ] **Step 2: Inspect architecture-focused diffs** + +Run: + +```bash +git status --short +git log --oneline -5 +``` + +Expected: +- working tree is clean after all stage commits +- recent commits are the five architecture deepening stage commits + +- [ ] **Step 3: Confirm success criteria** + +Confirm these statements against the final source: + +- `src/main.ts` no longer owns pure transformation sequencing. +- `src/utils/resolve-ambiguities.ts` no longer has a direct trie scanning loop. +- `src/settings/settings-catalog.ts` owns settings defaults, metadata, refresh flags, and projections. +- `src/markdown-segments.ts` is used by link replacement and URL title modules. +- `src/replace-urls/url-formatting.ts` owns URL adapter ordering and text traversal. From 953688d96ecbb65a3f6bc0d150d1ed2707df7a0d Mon Sep 17 00:00:00 2001 From: Kodai Nakamura Date: Mon, 22 Jun 2026 01:22:38 +0900 Subject: [PATCH 04/25] refactor(replace-links): centralize candidate scanning Why: - Candidate detection was duplicated between link replacement and AI ambiguity resolution, which made matching behavior hard to reason about. - A single scanner improves locality for namespace, protected-span, case, and CJK matching rules. What: - Add a shared candidate scanner module for unlinked text and existing wikilinks. - Route AI ambiguity request construction through the scanner. - Keep link rendering behavior unchanged and covered by existing replacement tests. --- .../__tests__/candidate-scanner.test.ts | 131 ++++ src/replace-links/candidate-scanner.ts | 724 ++++++++++++++++++ src/replace-links/replace-links.ts | 359 +-------- src/utils/resolve-ambiguities.ts | 82 +- 4 files changed, 894 insertions(+), 402 deletions(-) create mode 100644 src/replace-links/__tests__/candidate-scanner.test.ts create mode 100644 src/replace-links/candidate-scanner.ts diff --git a/src/replace-links/__tests__/candidate-scanner.test.ts b/src/replace-links/__tests__/candidate-scanner.test.ts new file mode 100644 index 0000000..5aa386f --- /dev/null +++ b/src/replace-links/__tests__/candidate-scanner.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vitest" +import { buildCandidateTrieForTest } from "./test-helpers" +import { + getOccurrenceContext, + scanCandidateOccurrences, +} from "../candidate-scanner" + +describe("scanCandidateOccurrences", () => { + it("reports ambiguous prose candidates and skips inline code", () => { + const { candidateMap, trie } = buildCandidateTrieForTest({ + files: [ + { path: "work/meeting" }, + { path: "private/meeting" }, + ], + settings: { + scoped: false, + baseDir: undefined, + ignoreCase: true, + }, + }) + + const occurrences = scanCandidateOccurrences({ + text: "`meeting` meeting", + filePath: "notes/today", + trie, + candidateMap, + settings: { ignoreCase: true, proximityBasedLinking: true }, + }) + + expect(occurrences.map(o => ({ + kind: o.kind, + text: o.text, + start: o.start, + end: o.end, + candidates: o.candidateData.candidates.map(c => c.canonical), + }))).toEqual([ + { + kind: "unlinked", + text: "meeting", + start: 10, + end: 17, + candidates: ["work/meeting", "private/meeting"], + }, + ]) + }) + + it("reports existing wikilinks by their display alias", () => { + const { candidateMap, trie } = buildCandidateTrieForTest({ + files: [ + { path: "work/meeting" }, + { path: "private/meeting" }, + ], + settings: { + scoped: false, + baseDir: undefined, + ignoreCase: true, + }, + }) + + const occurrences = scanCandidateOccurrences({ + text: "Check [[private/meeting|meeting]] notes.", + filePath: "notes/today", + trie, + candidateMap, + settings: { ignoreCase: true, proximityBasedLinking: true }, + }) + + expect(occurrences.map(o => ({ + kind: o.kind, + text: o.text, + candidateKey: o.candidateKey, + candidates: o.candidateData.candidates.map(c => c.canonical), + }))).toEqual([ + { + kind: "existing-wikilink", + text: "[[private/meeting|meeting]]", + candidateKey: "meeting", + candidates: ["work/meeting", "private/meeting"], + }, + ]) + }) + + it("filters scoped candidates outside the current namespace", () => { + const { candidateMap, trie } = buildCandidateTrieForTest({ + files: [ + { path: "pages/team-a/internal" }, + { path: "pages/team-b/internal" }, + ], + settings: { + scoped: true, + baseDir: "pages", + ignoreCase: true, + }, + }) + + const occurrences = scanCandidateOccurrences({ + text: "internal", + filePath: "pages/team-a/today", + trie, + candidateMap, + settings: { + baseDir: "pages", + ignoreCase: true, + proximityBasedLinking: true, + }, + }) + + expect(occurrences).toHaveLength(1) + expect(occurrences[0].candidateData.candidates.map(c => c.canonical)).toEqual([ + "pages/team-a/internal", + ]) + }) +}) + +describe("getOccurrenceContext", () => { + it("returns bounded surrounding text for AI requests", () => { + const occurrence = { + kind: "unlinked" as const, + start: 10, + end: 17, + text: "meeting", + candidateKey: "meeting", + candidateData: { candidates: [] }, + isInTable: false, + } + + expect(getOccurrenceContext("before -- meeting -- after", occurrence, 4)).toBe( + " -- meeting -- ", + ) + }) +}) diff --git a/src/replace-links/candidate-scanner.ts b/src/replace-links/candidate-scanner.ts new file mode 100644 index 0000000..2da65a3 --- /dev/null +++ b/src/replace-links/candidate-scanner.ts @@ -0,0 +1,724 @@ +import { CandidateData, getTopLevelDirectoryName, TrieNode } from "../trie" +import type { ReplaceLinksSettings } from "./replace-links" + +export type CandidateOccurrenceKind = "unlinked" | "existing-wikilink" + +export interface CandidateOccurrence { + kind: CandidateOccurrenceKind + start: number + end: number + text: string + candidateKey: string + candidateData: CandidateData + isInTable: boolean +} + +export interface ScanCandidateOccurrencesOptions { + text: string + filePath: string + trie: TrieNode + candidateMap: Map + settings?: ReplaceLinksSettings +} + +export const REGEX_PATTERNS = { + PROTECTED: /(```[\s\S]*?```|`[^`]*`|\[\[([^\]]+)\]\]|\[[^\]]+\]\([^)]+\)|\[[^\]]+\]|https?:\/\/[^\s]+)/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]+)/, + PROTECTED_LINK: /^\s*(\[\[[^\]]+\]\]|\[[^\]]+\]\([^)]+\))\s*$/, + KOREAN_SUFFIX: /^(이다\.?)/, + KOREAN_PARTICLES: /^(는|은)/, + KOREAN_PARTICLES_EXTENDED: /^(가|는|을|에|서|와|로부터|까지|보다|로|의|나|도|또한)/, + TABLE_SEPARATOR: /^[|:\s-]+$/, + WORD_BOUNDARY: /[\p{L}\p{N}_/-]/u, + WHITESPACE: /[\t\n\r ]/, +} as const + +export const isWordBoundary = (char: string | undefined): boolean => { + if (char === undefined) return true + if (REGEX_PATTERNS.CJK.test(char)) return true + return (!REGEX_PATTERNS.WORD_BOUNDARY.test(char) || REGEX_PATTERNS.WHITESPACE.test(char)) +} + +export const isMonthNote = (candidate: string): boolean => + REGEX_PATTERNS.MONTH_NOTE.test(candidate) + && parseInt(candidate, 10) >= 1 + && parseInt(candidate, 10) <= 12 + +export const isProtectedLink = (body: string): boolean => REGEX_PATTERNS.PROTECTED_LINK.test(body) + +export const isCjkText = (text: string): boolean => REGEX_PATTERNS.CJK.test(text) + +export const isCjkCandidate = (candidate: string): boolean => REGEX_PATTERNS.CJK_CANDIDATE.test(candidate) + +export const isKoreanText = (text: string): boolean => REGEX_PATTERNS.KOREAN.test(text) + +export const isSentenceStart = (text: string, index: number): boolean => { + if (index === 0) return true + if (text[index - 1] === "\n") return true + if (index >= 3 && text[index - 1] === " " && text[index - 2] === ".") { + const charBeforePeriod = text[index - 3] + if (/[a-zA-Z]/.test(charBeforePeriod)) { + return true + } + } + return false +} + +const fallbackIndexCache = new WeakMap< + Map, + Map>> +>() + +export const buildFallbackIndex = ( + candidateMap: Map, + ignoreCase?: boolean, +): Map> => { + let cacheForMap = fallbackIndexCache.get(candidateMap) + if (!cacheForMap) { + cacheForMap = new Map() + fallbackIndexCache.set(candidateMap, cacheForMap) + } + + const cacheKey = ignoreCase ? "ignoreCase" : "normal" + const cached = cacheForMap.get(cacheKey) + if (cached) return cached + + const fallbackIndex = new Map>() + + for (const [key, data] of candidateMap.entries()) { + const slashIndex = key.lastIndexOf("/") + if (slashIndex === -1) continue + + const shorthand = key.slice(slashIndex + 1) + const indexKey = ignoreCase ? shorthand.toLowerCase() : shorthand + + let arr = fallbackIndex.get(indexKey) + if (!arr) { + arr = [] + fallbackIndex.set(indexKey, arr) + } + arr.push([key, data]) + } + + cacheForMap.set(cacheKey, fallbackIndex) + return fallbackIndex +} + +export const getCurrentNamespace = (filePath: string, baseDir?: string): string => { + if (baseDir) { + return getTopLevelDirectoryName(filePath, baseDir) + } + + const segments = filePath.split("/") + return segments[0] || "" +} + +export const normalizeCanonicalPath = (linkPath: string, baseDir?: string): string => { + if (baseDir && linkPath.startsWith(baseDir + "/")) { + return linkPath.slice((baseDir + "/").length) + } + return linkPath +} + +export const extractLinkParts = ( + canonicalPath: string, +): { linkPath: string, alias: string, hasAlias: boolean } => { + const pipeIndex = canonicalPath.indexOf("|") + const hasAlias = pipeIndex !== -1 + + if (hasAlias) { + const linkPath = canonicalPath.slice(0, pipeIndex) + const alias = canonicalPath.slice(pipeIndex + 1) + return { linkPath, alias, hasAlias } + } + + return { linkPath: canonicalPath, alias: "", hasAlias } +} + +export const escapeRegExp = (text: string): string => + text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + +export const extractFencedCodeBlocks = ( + body: string, +): { body: string, codeBlocks: Array<{ placeholder: string, content: string }> } => { + if (!body.includes("```") && !body.includes("~~~")) { + return { body, codeBlocks: [] } + } + + const codeBlocks: Array<{ placeholder: string, content: string }> = [] + let result = "" + let cursor = 0 + let codeBlockIndex = 0 + + const openingFencePattern = /^ {0,3}(`{3,}|~{3,})[^\r\n]*(?:\r?\n|$)/gm + let openingMatch: RegExpExecArray | null + + while ((openingMatch = openingFencePattern.exec(body)) !== null) { + const start = openingMatch.index + if (start < cursor) { + continue + } + + const openingFence = openingMatch[1] + const fenceChar = openingFence[0] + const fenceLength = openingFence.length + const closingFencePattern = new RegExp( + `^ {0,3}${escapeRegExp(fenceChar)}{${fenceLength},}[ \\t]*(?:\\r?\\n|$)`, + "gm", + ) + closingFencePattern.lastIndex = openingMatch.index + openingMatch[0].length + const closingMatch = closingFencePattern.exec(body) + const end = closingMatch + ? closingMatch.index + closingMatch[0].length + : body.length + + const placeholder = `__CODE_BLOCK_${codeBlockIndex}__` + codeBlocks.push({ + placeholder, + content: body.slice(start, end), + }) + result += body.slice(cursor, start) + placeholder + cursor = end + codeBlockIndex++ + openingFencePattern.lastIndex = end + } + + result += body.slice(cursor) + return { body: result, codeBlocks } +} + +export const isSelfLink = ( + candidateData: CandidateData, + currentFilePath: string, + settings: ReplaceLinksSettings = {}, +): boolean => { + if (!settings.preventSelfLinking || candidateData.candidates.length === 0) { + return false + } + + const { linkPath } = extractLinkParts(candidateData.candidates[0].canonical) + const normalizedLinkPath = normalizeCanonicalPath( + linkPath, + settings.baseDir, + ) + const normalizedCurrentPath = normalizeCanonicalPath( + currentFilePath, + settings.baseDir, + ) + + return normalizedLinkPath === normalizedCurrentPath +} + +export const shouldSkipCandidate = ( + candidate: string, + settings: ReplaceLinksSettings, +): boolean => { + if ( + settings.ignoreDateFormats + && REGEX_PATTERNS.DATE_FORMAT.test(candidate) + ) { + return true + } + return isMonthNote(candidate) +} + +export const isMarkdownTableLine = (line: string): boolean => { + const trimmedLine = line.trim() + if (!trimmedLine || !trimmedLine.includes("|")) { + return false + } + + if (trimmedLine.startsWith("|") + && trimmedLine.endsWith("|") + && REGEX_PATTERNS.TABLE_SEPARATOR.test(trimmedLine) + ) { + return true + } + + return trimmedLine.startsWith("|") && trimmedLine.endsWith("|") +} + +export const isIndexInsideMarkdownTable = (text: string, index: number): boolean => { + let lineStart = text.lastIndexOf("\n", index - 1) + 1 + if (lineStart === 0 && text[0] !== "\n") { + lineStart = 0 + } + + let lineEnd = text.indexOf("\n", index) + if (lineEnd === -1) { + lineEnd = text.length + } + + const line = text.slice(lineStart, lineEnd) + return isMarkdownTableLine(line) +} + +export const findBestCandidateInSameNamespace = ( + filteredCandidates: Array<[string, CandidateData]>, + filePath: string, + settings: ReplaceLinksSettings = {}, +): [string, CandidateData] | null => { + let bestCandidate: [string, CandidateData] | null = null + let bestScore = -1 + + const filePathDir = filePath.includes("/") + ? filePath.slice(0, filePath.lastIndexOf("/")) + : "" + const filePathSegments = filePathDir ? filePathDir.split("/") : [] + + for (const [key, data] of filteredCandidates) { + const slashIndex = key.lastIndexOf("/") + const candidateDir = key.slice(0, slashIndex) + const candidateSegments = candidateDir.split("/") + let score = 0 + + for ( + let idx = 0; + idx < Math.min(candidateSegments.length, filePathSegments.length); + idx++ + ) { + if (candidateSegments[idx] === filePathSegments[idx]) { + score++ + } + else { + break + } + } + + if (score > bestScore) { + bestScore = score + bestCandidate = [key, data] + } + else if (score === bestScore && bestCandidate !== null) { + if (filePathDir === "" && settings.baseDir) { + const basePrefix = settings.baseDir + "/" + const getRelativeDepth = (k: string): number => { + if (k.startsWith(basePrefix)) { + const relativeParts = k + .slice(basePrefix.length) + .split("/") + return relativeParts.length - 1 + } + return Infinity + } + + const candidateDepth = getRelativeDepth(key) + const bestCandidateDepth = getRelativeDepth(bestCandidate[0]) + + if (candidateDepth < bestCandidateDepth + || (candidateDepth === bestCandidateDepth && key.length < bestCandidate[0].length)) { + bestCandidate = [key, data] + } + } + else { + const currentBestDir = bestCandidate[0].slice(0, bestCandidate[0].lastIndexOf("/")) + const currentBestSegments = currentBestDir.split("/") + + if ( + candidateSegments.length < currentBestSegments.length + || (candidateSegments.length === currentBestSegments.length && key.length < bestCandidate[0].length) + ) { + bestCandidate = [key, data] + } + } + } + } + + return bestCandidate +} + +const dedupeCandidates = (candidates: CandidateData["candidates"]): CandidateData => { + const unique = new Map(candidates.map(candidate => [candidate.canonical, candidate])) + return { + candidates: Array.from(unique.values()).sort((a, b) => { + if (a.canonical.length !== b.canonical.length) { + return a.canonical.length - b.canonical.length + } + return a.canonical.localeCompare(b.canonical) + }), + } +} + +const filterCandidateDataForNamespace = ( + candidateData: CandidateData, + currentNamespace: string, + settings: ReplaceLinksSettings, +): CandidateData => { + if (!settings.proximityBasedLinking) { + return dedupeCandidates(candidateData.candidates) + } + + const filteredCandidates = candidateData.candidates.filter((candidate) => { + return !(candidate.scoped && candidate.namespace !== currentNamespace) + }) + + return dedupeCandidates(filteredCandidates) +} + +const collectExistingWikilinks = ( + text: string, + candidateMap: Map, + occurrences: CandidateOccurrence[], + settings: ReplaceLinksSettings, +): void => { + const existingLinkRegex = /\[\[([^|\]]+)(?:\|([^\]]+))?\]\]/g + let match: RegExpExecArray | null + + while ((match = existingLinkRegex.exec(text)) !== null) { + const fullMatch = match[0] + const path = match[1] + const alias = match[2] || path + const candidateKey = settings.ignoreCase ? alias.toLowerCase() : alias + const candidateData = candidateMap.get(candidateKey) ?? candidateMap.get(alias) + + if (!candidateData) { + continue + } + + occurrences.push({ + kind: "existing-wikilink", + start: match.index, + end: match.index + fullMatch.length, + text: fullMatch, + candidateKey, + candidateData: dedupeCandidates(candidateData.candidates), + isInTable: isIndexInsideMarkdownTable(text, match.index), + }) + } +} + +const collectFallbackOccurrence = ({ + text, + startIndex, + fallbackIndex, + filePath, + currentNamespace, + settings, +}: { + text: string + startIndex: number + fallbackIndex: Map> + filePath: string + currentNamespace: string + settings: ReplaceLinksSettings +}): CandidateOccurrence | null => { + const prevChar = text[startIndex - 1] + if (!isWordBoundary(prevChar)) { + return null + } + + let longestMatch: { + word: string + length: number + key: string + candidateList: Array<[string, CandidateData]> + } | null = null + + const maxSearchLength = Math.min(text.length - startIndex, 100) + + 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 += currentChar + } + } + else { + searchWord = settings.ignoreCase + ? searchWord + currentChar.toLowerCase() + : potentialMatch + } + + const candidateList = fallbackIndex.get(searchWord) + if (!candidateList) { + continue + } + + const nextChar = text[endIndex] + if (!isWordBoundary(nextChar)) { + continue + } + + if (shouldSkipCandidate(potentialMatch, settings)) { + continue + } + + longestMatch = { + word: potentialMatch, + length, + key: searchWord, + candidateList, + } + } + + if (!longestMatch) return null + + const filteredCandidates = longestMatch.candidateList.filter(([, data]) => { + if (data.candidates.length === 0 || !settings.proximityBasedLinking) { + return true + } + const candidate = data.candidates[0] + return !(candidate.scoped && candidate.namespace !== currentNamespace) + }) + + if (filteredCandidates.length === 0) { + return null + } + + const candidateData = dedupeCandidates( + filteredCandidates.flatMap(([, data]) => data.candidates), + ) + + if (candidateData.candidates.length === 0) { + return null + } + + if (isSelfLink(candidateData, filePath, settings)) { + return null + } + + const bestCandidateResult = filteredCandidates.length > 1 + ? findBestCandidateInSameNamespace(filteredCandidates, filePath, settings) + : filteredCandidates[0] + if (!bestCandidateResult) { + return null + } + + return { + kind: "unlinked", + start: startIndex, + end: startIndex + longestMatch.length, + text: longestMatch.word, + candidateKey: longestMatch.key, + candidateData, + isInTable: isIndexInsideMarkdownTable(text, startIndex), + } +} + +const collectUnlinkedOccurrences = ({ + text, + filePath, + trie, + candidateMap, + fallbackIndex, + currentNamespace, + settings, + occurrences, +}: { + text: string + filePath: string + trie: TrieNode + candidateMap: Map + fallbackIndex: Map> + currentNamespace: string + settings: ReplaceLinksSettings + occurrences: CandidateOccurrence[] +}): 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 + } + } + + 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) { + const filteredCandidateData = filterCandidateDataForNamespace( + candidateData, + currentNamespace, + settings, + ) + + if (filteredCandidateData.candidates.length === 0) { + i += candidate.length + continue + } + + if (isSelfLink(filteredCandidateData, filePath, settings)) { + i += candidate.length + 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 + } + } + + occurrences.push({ + kind: "unlinked", + start: i, + end: i + candidate.length, + text: candidate, + candidateKey: trieCandidateKey, + candidateData: filteredCandidateData, + 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 + } + } + + i++ + } +} + +export const scanCandidateOccurrences = ({ + text, + filePath, + trie, + candidateMap, + settings = {}, +}: ScanCandidateOccurrencesOptions): CandidateOccurrence[] => { + const occurrences: CandidateOccurrence[] = [] + const normalizedText = text.normalize("NFC") + const fallbackIndex = buildFallbackIndex(candidateMap, settings.ignoreCase) + const currentNamespace = getCurrentNamespace(filePath, settings.baseDir) + + collectExistingWikilinks(normalizedText, candidateMap, occurrences, settings) + + const protectedFreeOccurrences: CandidateOccurrence[] = [] + REGEX_PATTERNS.PROTECTED.lastIndex = 0 + let lastIndex = 0 + let match: RegExpExecArray | null + while ((match = REGEX_PATTERNS.PROTECTED.exec(normalizedText)) !== null) { + const segment = normalizedText.slice(lastIndex, match.index) + const segmentOccurrences: CandidateOccurrence[] = [] + collectUnlinkedOccurrences({ + text: segment, + filePath, + trie, + candidateMap, + fallbackIndex, + currentNamespace, + settings, + occurrences: segmentOccurrences, + }) + for (const occurrence of segmentOccurrences) { + protectedFreeOccurrences.push({ + ...occurrence, + start: occurrence.start + lastIndex, + end: occurrence.end + lastIndex, + }) + } + lastIndex = match.index + match[0].length + + if (match[0].length === 0) { + REGEX_PATTERNS.PROTECTED.lastIndex++ + } + } + + const tailOccurrences: CandidateOccurrence[] = [] + collectUnlinkedOccurrences({ + text: normalizedText.slice(lastIndex), + filePath, + trie, + candidateMap, + fallbackIndex, + currentNamespace, + settings, + occurrences: tailOccurrences, + }) + for (const occurrence of tailOccurrences) { + protectedFreeOccurrences.push({ + ...occurrence, + start: occurrence.start + lastIndex, + end: occurrence.end + lastIndex, + }) + } + + occurrences.push(...protectedFreeOccurrences) + + return occurrences.sort((a, b) => a.start - b.start) +} + +export const getOccurrenceContext = ( + text: string, + occurrence: CandidateOccurrence, + maxContext: number, +): string => { + const start = Math.max(0, occurrence.start - maxContext) + const end = Math.min(text.length, occurrence.end + maxContext) + return text.slice(start, end) +} diff --git a/src/replace-links/replace-links.ts b/src/replace-links/replace-links.ts index daf892b..afddcfc 100644 --- a/src/replace-links/replace-links.ts +++ b/src/replace-links/replace-links.ts @@ -1,4 +1,24 @@ -import { CandidateData, getTopLevelDirectoryName, TrieNode } from "../trie" +import { CandidateData, TrieNode } from "../trie" +import { + buildFallbackIndex, + extractFencedCodeBlocks, + extractLinkParts, + findBestCandidateInSameNamespace, + getCurrentNamespace, + isCjkCandidate, + isCjkText, + isIndexInsideMarkdownTable, + isKoreanText, + isMarkdownTableLine, + isMonthNote, + isProtectedLink, + isSelfLink, + isSentenceStart, + isWordBoundary, + normalizeCanonicalPath, + REGEX_PATTERNS, + shouldSkipCandidate, +} from "./candidate-scanner" // Types for the replaceLinks function export interface LinkResolverContext { @@ -36,214 +56,6 @@ export interface ReplaceLinksOptions { resolvedAmbiguities?: Map } -// Constants and Regular Expressions -const REGEX_PATTERNS = { - PROTECTED: /(```[\s\S]*?```|`[^`]*`|\[\[([^\]]+)\]\]|\[[^\]]+\]\([^)]+\)|\[[^\]]+\]|https?:\/\/[^\s]+)/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]+)/, - PROTECTED_LINK: /^\s*(\[\[[^\]]+\]\]|\[[^\]]+\]\([^)]+\))\s*$/, - KOREAN_SUFFIX: /^(이다\.?)/, - KOREAN_PARTICLES: /^(는|은)/, - KOREAN_PARTICLES_EXTENDED: /^(가|는|을|에|서|와|로부터|까지|보다|로|의|나|도|또한)/, - TABLE_SEPARATOR: /^[|:\s-]+$/, - WORD_BOUNDARY: /[\p{L}\p{N}_/-]/u, - WHITESPACE: /[\t\n\r ]/, -} as const - -// Text Analysis Utilities -const isWordBoundary = (char: string | undefined): boolean => { - if (char === undefined) return true - if (REGEX_PATTERNS.CJK.test(char)) return true - return (!REGEX_PATTERNS.WORD_BOUNDARY.test(char) || REGEX_PATTERNS.WHITESPACE.test(char)) -} - -const isMonthNote = (candidate: string): boolean => - REGEX_PATTERNS.MONTH_NOTE.test(candidate) - && parseInt(candidate, 10) >= 1 - && parseInt(candidate, 10) <= 12 - -const isProtectedLink = (body: string): boolean => REGEX_PATTERNS.PROTECTED_LINK.test(body) - -const isCjkText = (text: string): boolean => REGEX_PATTERNS.CJK.test(text) - -const isCjkCandidate = (candidate: string): boolean => REGEX_PATTERNS.CJK_CANDIDATE.test(candidate) - -const isKoreanText = (text: string): boolean => REGEX_PATTERNS.KOREAN.test(text) - -const isSentenceStart = (text: string, index: number): boolean => { - if (index === 0) return true - if (text[index - 1] === "\n") return true - // ". " pattern (after period + space) - // Avoid acronyms by checking the char before the period is a letter - if (index >= 3 && text[index - 1] === " " && text[index - 2] === ".") { - const charBeforePeriod = text[index - 3] - if (/[a-zA-Z]/.test(charBeforePeriod)) { - return true - } - } - return false -} - -// Cache for fallback index to avoid rebuilding -const fallbackIndexCache = new WeakMap< - Map, - Map>> ->() - -const buildFallbackIndex = ( - candidateMap: Map, - ignoreCase?: boolean, -): Map> => { - // Get or create cache for this candidateMap - let cacheForMap = fallbackIndexCache.get(candidateMap) - if (!cacheForMap) { - cacheForMap = new Map() - fallbackIndexCache.set(candidateMap, cacheForMap) - } - - // Check if we have cached result for this ignoreCase setting - const cacheKey = ignoreCase ? "ignoreCase" : "normal" - const cached = cacheForMap.get(cacheKey) - if (cached) return cached - - // Build new fallback index - const fallbackIndex = new Map>() - - for (const [key, data] of candidateMap.entries()) { - const slashIndex = key.lastIndexOf("/") - if (slashIndex === -1) continue - - const shorthand = key.slice(slashIndex + 1) - const indexKey = ignoreCase ? shorthand.toLowerCase() : shorthand - - let arr = fallbackIndex.get(indexKey) - if (!arr) { - arr = [] - fallbackIndex.set(indexKey, arr) - } - arr.push([key, data]) - } - - // Cache the result - cacheForMap.set(cacheKey, fallbackIndex) - return fallbackIndex -} - -const getCurrentNamespace = (filePath: string, baseDir?: string): string => { - if (baseDir) { - return getTopLevelDirectoryName(filePath, baseDir) - } - - const segments = filePath.split("/") - return segments[0] || "" -} - -const normalizeCanonicalPath = (linkPath: string, baseDir?: string): string => { - if (baseDir && linkPath.startsWith(baseDir + "/")) { - return linkPath.slice((baseDir + "/").length) - } - return linkPath -} - -const extractLinkParts = ( - canonicalPath: string, -): { linkPath: string, alias: string, hasAlias: boolean } => { - const pipeIndex = canonicalPath.indexOf("|") - const hasAlias = pipeIndex !== -1 - - if (hasAlias) { - const linkPath = canonicalPath.slice(0, pipeIndex) - const alias = canonicalPath.slice(pipeIndex + 1) - return { linkPath, alias, hasAlias } - } - - return { linkPath: canonicalPath, alias: "", hasAlias } -} - -const escapeRegExp = (text: string): string => - text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") - -const extractFencedCodeBlocks = ( - body: string, -): { body: string, codeBlocks: Array<{ placeholder: string, content: string }> } => { - if (!body.includes("```") && !body.includes("~~~")) { - return { body, codeBlocks: [] } - } - - const codeBlocks: Array<{ placeholder: string, content: string }> = [] - let result = "" - let cursor = 0 - let codeBlockIndex = 0 - - const openingFencePattern = /^ {0,3}(`{3,}|~{3,})[^\r\n]*(?:\r?\n|$)/gm - let openingMatch: RegExpExecArray | null - - while ((openingMatch = openingFencePattern.exec(body)) !== null) { - const start = openingMatch.index - if (start < cursor) { - continue - } - - const openingFence = openingMatch[1] - const fenceChar = openingFence[0] - const fenceLength = openingFence.length - const closingFencePattern = new RegExp( - `^ {0,3}${escapeRegExp(fenceChar)}{${fenceLength},}[ \\t]*(?:\\r?\\n|$)`, - "gm", - ) - closingFencePattern.lastIndex = openingMatch.index + openingMatch[0].length - const closingMatch = closingFencePattern.exec(body) - const end = closingMatch - ? closingMatch.index + closingMatch[0].length - : body.length - - const placeholder = `__CODE_BLOCK_${codeBlockIndex}__` - codeBlocks.push({ - placeholder, - content: body.slice(start, end), - }) - result += body.slice(cursor, start) + placeholder - cursor = end - codeBlockIndex++ - openingFencePattern.lastIndex = end - } - - result += body.slice(cursor) - return { body: result, codeBlocks } -} - -// Self-linking Prevention -const isSelfLink = ( - candidateData: CandidateData, - currentFilePath: string, - settings: ReplaceLinksSettings = {}, -): boolean => { - if (!settings.preventSelfLinking || candidateData.candidates.length === 0) { - return false - } - - // Extract the link path from the canonical path - const { linkPath } = extractLinkParts(candidateData.candidates[0].canonical) - - // Normalize paths for comparison - const normalizedLinkPath = normalizeCanonicalPath( - linkPath, - settings.baseDir, - ) - const normalizedCurrentPath = normalizeCanonicalPath( - currentFilePath, - settings.baseDir, - ) - - // Compare the paths - return normalizedLinkPath === normalizedCurrentPath -} - // Helper function to check if a path should have its alias removed const shouldRemoveAlias = ( normalizedPath: string, @@ -371,55 +183,6 @@ export const defaultLinkGenerator: LinkGenerator = ({ return escapeLinkForMarkdownTable(`[[${linkContent}]]`, isInTable) } -// Candidate Validation -const shouldSkipCandidate = ( - candidate: string, - settings: ReplaceLinksSettings, -): boolean => { - if ( - settings.ignoreDateFormats - && REGEX_PATTERNS.DATE_FORMAT.test(candidate) - ) { - return true - } - return isMonthNote(candidate) -} - -// Markdown Table Detection -const isMarkdownTableLine = (line: string): boolean => { - const trimmedLine = line.trim() - if (!trimmedLine || !trimmedLine.includes("|")) { - return false - } - - if (trimmedLine.startsWith("|") - && trimmedLine.endsWith("|") - && REGEX_PATTERNS.TABLE_SEPARATOR.test(trimmedLine) - ) { - return true - } - - return trimmedLine.startsWith("|") && trimmedLine.endsWith("|") -} - -const isIndexInsideMarkdownTable = (text: string, index: number): boolean => { - // Find the start of the line containing the index - let lineStart = text.lastIndexOf("\n", index - 1) + 1 - if (lineStart === 0 && text[0] !== "\n") { - lineStart = 0 - } - - // Find the end of the line containing the index - let lineEnd = text.indexOf("\n", index) - if (lineEnd === -1) { - lineEnd = text.length - } - - // Extract the line and check if it's a table line - const line = text.slice(lineStart, lineEnd) - return isMarkdownTableLine(line) -} - // Processing functions for different text types const processCjkText = ( text: string, @@ -630,86 +393,6 @@ const handleKoreanSpecialCases = ( return null } -const findBestCandidateInSameNamespace = ( - filteredCandidates: Array<[string, CandidateData]>, - filePath: string, - settings: ReplaceLinksSettings = {}, -): [string, CandidateData] | null => { - let bestCandidate: [string, CandidateData] | null = null - let bestScore = -1 - - // Get the directory portion of the current file (if any) - const filePathDir = filePath.includes("/") - ? filePath.slice(0, filePath.lastIndexOf("/")) - : "" - const filePathSegments = filePathDir ? filePathDir.split("/") : [] - - for (const [key, data] of filteredCandidates) { - const slashIndex = key.lastIndexOf("/") - const candidateDir = key.slice(0, slashIndex) - const candidateSegments = candidateDir.split("/") - let score = 0 - - // Calculate common prefix score - for ( - let idx = 0; - idx < Math.min(candidateSegments.length, filePathSegments.length); - idx++ - ) { - if (candidateSegments[idx] === filePathSegments[idx]) { - score++ - } - else { - break - } - } - - if (score > bestScore) { - bestScore = score - bestCandidate = [key, data] - } - else if (score === bestScore && bestCandidate !== null) { - if (filePathDir === "" && settings.baseDir) { - // When the current file is in the base directory, compare candidates by relative depth - const basePrefix = settings.baseDir + "/" - const getRelativeDepth = (k: string): number => { - if (k.startsWith(basePrefix)) { - // Remove the baseDir part and count the remaining segments - const relativeParts = k - .slice(basePrefix.length) - .split("/") - return relativeParts.length - 1 - } - return Infinity - } - - const candidateDepth = getRelativeDepth(key) - const bestCandidateDepth = getRelativeDepth(bestCandidate[0]) - - // Prefer the candidate with lower depth or shorter path - if (candidateDepth < bestCandidateDepth - || (candidateDepth === bestCandidateDepth && key.length < bestCandidate[0].length)) { - bestCandidate = [key, data] - } - } - else { - // Otherwise, choose the candidate with fewer directory segments - const currentBestDir = bestCandidate[0].slice(0, bestCandidate[0].lastIndexOf("/")) - const currentBestSegments = currentBestDir.split("/") - - if ( - candidateSegments.length < currentBestSegments.length - || (candidateSegments.length === currentBestSegments.length && key.length < bestCandidate[0].length) - ) { - bestCandidate = [key, data] - } - } - } - } - - return bestCandidate -} - const processStandardText = ( text: string, trie: TrieNode, diff --git a/src/utils/resolve-ambiguities.ts b/src/utils/resolve-ambiguities.ts index 831a947..68cae63 100644 --- a/src/utils/resolve-ambiguities.ts +++ b/src/utils/resolve-ambiguities.ts @@ -1,79 +1,33 @@ import { CandidateData, TrieNode } from "../trie" import { AutomaticLinkerSettings } from "../settings/settings-info" import { resolveAmbiguitiesBatch, AIResolveRequest } from "./ai-client" +import { + getOccurrenceContext, + scanCandidateOccurrences, +} from "../replace-links/candidate-scanner" -// A simplified scanner to find candidates that have multiple options or existing links to verify. export const resolveAmbiguities = async ( text: string, candidateMap: Map, trie: TrieNode, settings: AutomaticLinkerSettings, ): Promise> => { - const requests: AIResolveRequest[] = [] + const occurrences = scanCandidateOccurrences({ + text, + filePath: "", + trie, + candidateMap, + settings, + }) - // 1. Scan for existing links [[Path|Alias]] or [[Path]] - const existingLinkRegex = /\[\[([^|\]]+)(?:\|([^\]]+))?\]\]/g - let match: RegExpExecArray | null - while ((match = existingLinkRegex.exec(text)) !== null) { - const fullMatch = match[0] - const path = match[1] - const alias = match[2] || path + const requests: AIResolveRequest[] = occurrences + .filter(occurrence => occurrence.candidateData.candidates.length > 1) + .map(occurrence => ({ + word: occurrence.text, + text: getOccurrenceContext(text, occurrence, settings.aiMaxContext), + candidates: occurrence.candidateData.candidates.map(c => c.canonical), + })) - const candidateData = candidateMap.get(alias) - if (candidateData && candidateData.candidates.length > 1) { - const start = Math.max(0, match.index - settings.aiMaxContext) - const end = Math.min(text.length, match.index + fullMatch.length + settings.aiMaxContext) - - requests.push({ - word: fullMatch, // Using the full link as the "word" key for replacement - text: text.slice(start, end), - candidates: candidateData.candidates.map(c => c.canonical), - }) - } - } - - // 2. Scan for unlinked words with multiple candidates using the Trie - // (This is a simplified version of the trie traversal logic) - let i = 0 - while (i < text.length) { - let node = trie - let j = i - let lastMatch: { word: string, data: CandidateData, index: number } | null = null - - while (j < text.length && node.children.has(text[j].toLowerCase())) { - node = node.children.get(text[j].toLowerCase())! - if (node.candidate) { - const data = candidateMap.get(node.candidate) - if (data) { - lastMatch = { word: node.candidate, data, index: i } - } - } - j++ - } - - if (lastMatch && lastMatch.data.candidates.length > 1) { - // Check if it's already inside a link (simple check) - const isInsideLink = text.slice(Math.max(0, i - 2), i) === "[[" - || text.slice(i + lastMatch.word.length, i + lastMatch.word.length + 2) === "]]" - - if (!isInsideLink) { - const start = Math.max(0, i - settings.aiMaxContext) - const end = Math.min(text.length, i + lastMatch.word.length + settings.aiMaxContext) - - requests.push({ - word: lastMatch.word, - text: text.slice(start, end), - candidates: lastMatch.data.candidates.map(c => c.canonical), - }) - } - i += lastMatch.word.length - } - else { - i++ - } - } - - // Deduplicate requests by word to avoid redundant AI calls const uniqueRequests = Array.from(new Map(requests.map(r => [r.word, r])).values()) return await resolveAmbiguitiesBatch(settings, uniqueRequests) From c3151b3f416a8072a73977c29af76cbfe76ebf68 Mon Sep 17 00:00:00 2001 From: Kodai Nakamura Date: Mon, 22 Jun 2026 01:33:56 +0900 Subject: [PATCH 05/25] fix(replace-links): align candidate scanner semantics Why: - Task 1 scanner drifted from replaceLinks in two places that affect AI ambiguity review. - Trie-hit namespace handling was filtering candidate sets differently from replacement-time behavior. - Protected-region scanning was still walking fenced code, callouts, and ignored headings that replaceLinks already shields. What: - make trie-hit scanning use the original candidate set and first-candidate scoped checks, matching current replaceLinks behavior - skip fenced code blocks, callouts, and ignored headings before protected-regex scanning while preserving original occurrence offsets - add regressions for trie-hit namespace semantics and protected-region skipping, plus AI-side coverage and append the fix report --- .superpowers/sdd/task-1-report.md | 169 ++++++++++++ .../__tests__/candidate-scanner.test.ts | 84 +++++- src/replace-links/candidate-scanner.ts | 240 ++++++++++++------ .../__tests__/resolve-ambiguities.test.ts | 36 +++ 4 files changed, 450 insertions(+), 79 deletions(-) create mode 100644 .superpowers/sdd/task-1-report.md diff --git a/.superpowers/sdd/task-1-report.md b/.superpowers/sdd/task-1-report.md new file mode 100644 index 0000000..1cdf26d --- /dev/null +++ b/.superpowers/sdd/task-1-report.md @@ -0,0 +1,169 @@ +# Task 1 Report: Candidate Scanning Module + +## Scope + +Implemented Task 1 from `.superpowers/sdd/task-1-brief.md`: + +- Created `src/replace-links/candidate-scanner.ts` +- Created `src/replace-links/__tests__/candidate-scanner.test.ts` +- Updated `src/replace-links/replace-links.ts` +- Updated `src/utils/resolve-ambiguities.ts` + +No AGENTS.md changes were needed. + +## TDD Evidence + +### RED + +Command: + +```bash +npx vitest run src/replace-links/__tests__/candidate-scanner.test.ts +``` + +Result: + +- Failed as expected. +- Failure cause: `Cannot find module '../candidate-scanner'`. + +### GREEN + +Command: + +```bash +npx vitest run src/replace-links/__tests__/candidate-scanner.test.ts +``` + +Result: + +- Passed: `4/4` tests. + +## Implementation Summary + +- Extracted shared scanning helpers from `replace-links.ts` into `candidate-scanner.ts`. +- Added `scanCandidateOccurrences()` for: + - unlinked candidate occurrences + - existing wikilink occurrences +- Added `getOccurrenceContext()` for bounded AI request context. +- Preserved `replaceLinks()` rendering behavior by importing the moved helpers back into `replace-links.ts`. +- Routed `resolveAmbiguities()` through the shared scanner instead of maintaining separate trie scanning logic. + +## Focused Verification + +Command: + +```bash +npx vitest run src/utils/__tests__/resolve-ambiguities.test.ts src/replace-links/__tests__/ai-disambiguation.test.ts src/replace-links/__tests__/replace-links.basic.test.ts +``` + +Result: + +- Passed: `19/19` tests across `3/3` files. + +## Full Stage Verification + +Commands: + +```bash +npm run test -- --reporter=dot +npm run tsc +npm run lint +``` + +Results: + +- `npm run test -- --reporter=dot`: passed, `320/320` tests across `38/38` files +- `npm run tsc`: passed +- `npm run lint`: passed + +Note: + +- The first full-suite run hit a transient failure in the namespace-resolution performance test. +- Re-running the full stage verification passed cleanly, including that test (`Namespace resolution processed in 290.97ms`, below the `300ms` threshold). + +## Commit + +Created commit: + +- `953688d` `refactor(replace-links): centralize candidate scanning` + +## Files Changed + +- `src/replace-links/candidate-scanner.ts` +- `src/replace-links/__tests__/candidate-scanner.test.ts` +- `src/replace-links/replace-links.ts` +- `src/utils/resolve-ambiguities.ts` + +## Brief Compliance Check + +- Used the exact Task 1 file set from the brief. +- Followed RED then GREEN before production code claims. +- Ran the focused tests from the brief. +- Ran the full verification commands from the brief. +- Used the specified Conventional Commit message with `Why` and `What`. + +## Review Fix 1 + +### Scope + +Fixed the Task 1 review findings for candidate scanning drift: + +- aligned trie-hit namespace handling in `scanCandidateOccurrences()` with current `replaceLinks()` behavior +- aligned scanner protected-region handling with current `replaceLinks()` block-level shielding for fenced code blocks, callouts, and ignored headings +- added regression coverage for both drift cases + +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/utils/__tests__/resolve-ambiguities.test.ts +``` + +Results: + +- `candidate-scanner.test.ts` failed on the new trie-hit namespace semantics regression. +- `candidate-scanner.test.ts` failed on the new fenced-code/callout/ignored-heading regression. +- `resolve-ambiguities.test.ts` failed on the new protected-region regression. + +#### GREEN + +Commands: + +```bash +npx vitest run src/replace-links/__tests__/candidate-scanner.test.ts +npx vitest run src/utils/__tests__/resolve-ambiguities.test.ts src/replace-links/__tests__/ai-disambiguation.test.ts src/replace-links/__tests__/replace-links.callout.test.ts src/replace-links/__tests__/replace-links.headings.test.ts src/replace-links/__tests__/replace-links.code.test.ts +``` + +Results: + +- `candidate-scanner.test.ts`: passed `6/6` +- focused AI/replacement suite: passed `47/47` across `5/5` files + +### Implementation Summary + +- Reworked scanner block protection to skip the same fenced code blocks, callouts, and optional heading regions that `replaceLinks()` excludes before protected-regex scanning. +- Preserved absolute occurrence offsets while scanning only unprotected regions. +- Changed trie-hit scanner behavior to keep the original candidate set and use the first candidate for scoped/self-link checks, matching current `replaceLinks()` semantics. +- Updated scanner regressions to reflect current trie-hit behavior and added AI-side coverage to ensure protected regions are not scanned. + +### Full Verification + +Commands: + +```bash +npm run test -- --reporter=dot +npm run tsc +npm run lint +``` + +Results: + +- `npm run test -- --reporter=dot`: passed, `323/323` tests across `38/38` files +- `npm run tsc`: passed +- `npm run lint`: passed diff --git a/src/replace-links/__tests__/candidate-scanner.test.ts b/src/replace-links/__tests__/candidate-scanner.test.ts index 5aa386f..844afe8 100644 --- a/src/replace-links/__tests__/candidate-scanner.test.ts +++ b/src/replace-links/__tests__/candidate-scanner.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest" +import { buildTrie, CandidateData } from "../../trie" import { buildCandidateTrieForTest } from "./test-helpers" import { getOccurrenceContext, @@ -80,7 +81,7 @@ describe("scanCandidateOccurrences", () => { ]) }) - it("filters scoped candidates outside the current namespace", () => { + it("preserves trie-hit candidate sets for scoped candidates in the current namespace", () => { const { candidateMap, trie } = buildCandidateTrieForTest({ files: [ { path: "pages/team-a/internal" }, @@ -108,6 +109,87 @@ describe("scanCandidateOccurrences", () => { expect(occurrences).toHaveLength(1) expect(occurrences[0].candidateData.candidates.map(c => c.canonical)).toEqual([ "pages/team-a/internal", + "pages/team-b/internal", + ]) + }) + + it("matches replaceLinks trie-hit namespace semantics", () => { + const candidateMap = new Map([ + [ + "internal", + { + candidates: [ + { + canonical: "pages/team-b/internal", + scoped: true, + namespace: "team-b", + }, + { + canonical: "pages/team-a/internal", + scoped: true, + namespace: "team-a", + }, + ], + }, + ], + ]) + const trie = buildTrie(["internal"], true) + + const occurrences = scanCandidateOccurrences({ + text: "internal", + filePath: "pages/team-a/today", + trie, + candidateMap, + settings: { + baseDir: "pages", + ignoreCase: true, + proximityBasedLinking: true, + }, + }) + + expect(occurrences).toEqual([]) + }) + + it("skips fenced code blocks, callouts, and ignored headings", () => { + const { candidateMap, trie } = buildCandidateTrieForTest({ + files: [{ path: "meeting" }], + settings: { + scoped: false, + baseDir: undefined, + ignoreCase: true, + }, + }) + + const occurrences = scanCandidateOccurrences({ + text: `# meeting +> [!note] +> meeting + +~~~ts +meeting +~~~ + +meeting`, + filePath: "notes/today", + trie, + candidateMap, + settings: { + ignoreCase: true, + ignoreHeadings: true, + proximityBasedLinking: true, + }, + }) + + expect(occurrences.map(o => ({ + start: o.start, + end: o.end, + text: o.text, + }))).toEqual([ + { + start: 50, + end: 57, + text: "meeting", + }, ]) }) }) diff --git a/src/replace-links/candidate-scanner.ts b/src/replace-links/candidate-scanner.ts index 2da65a3..27c7b59 100644 --- a/src/replace-links/candidate-scanner.ts +++ b/src/replace-links/candidate-scanner.ts @@ -144,27 +144,18 @@ export const extractLinkParts = ( export const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") -export const extractFencedCodeBlocks = ( +const findFencedCodeBlockRanges = ( body: string, -): { body: string, codeBlocks: Array<{ placeholder: string, content: string }> } => { +): Array<{ start: number, end: number }> => { if (!body.includes("```") && !body.includes("~~~")) { - return { body, codeBlocks: [] } + return [] } - const codeBlocks: Array<{ placeholder: string, content: string }> = [] - let result = "" - let cursor = 0 - let codeBlockIndex = 0 - + const ranges: Array<{ start: number, end: number }> = [] const openingFencePattern = /^ {0,3}(`{3,}|~{3,})[^\r\n]*(?:\r?\n|$)/gm let openingMatch: RegExpExecArray | null while ((openingMatch = openingFencePattern.exec(body)) !== null) { - const start = openingMatch.index - if (start < cursor) { - continue - } - const openingFence = openingMatch[1] const fenceChar = openingFence[0] const fenceLength = openingFence.length @@ -178,15 +169,35 @@ export const extractFencedCodeBlocks = ( ? closingMatch.index + closingMatch[0].length : body.length + ranges.push({ + start: openingMatch.index, + end, + }) + openingFencePattern.lastIndex = end + } + + return ranges +} + +export const extractFencedCodeBlocks = ( + body: string, +): { body: string, codeBlocks: Array<{ placeholder: string, content: string }> } => { + if (!body.includes("```") && !body.includes("~~~")) { + return { body, codeBlocks: [] } + } + + const ranges = findFencedCodeBlockRanges(body) + const codeBlocks: Array<{ placeholder: string, content: string }> = [] + let result = "" + let cursor = 0 + for (const [codeBlockIndex, range] of ranges.entries()) { const placeholder = `__CODE_BLOCK_${codeBlockIndex}__` codeBlocks.push({ placeholder, - content: body.slice(start, end), + content: body.slice(range.start, range.end), }) - result += body.slice(cursor, start) + placeholder - cursor = end - codeBlockIndex++ - openingFencePattern.lastIndex = end + result += body.slice(cursor, range.start) + placeholder + cursor = range.end } result += body.slice(cursor) @@ -345,20 +356,57 @@ const dedupeCandidates = (candidates: CandidateData["candidates"]): CandidateDat } } -const filterCandidateDataForNamespace = ( - candidateData: CandidateData, - currentNamespace: string, +const findProtectedBlockRanges = ( + text: string, settings: ReplaceLinksSettings, -): CandidateData => { - if (!settings.proximityBasedLinking) { - return dedupeCandidates(candidateData.candidates) +): Array<{ start: number, end: number }> => { + const ranges: Array<{ start: number, end: number }> = [ + ...findFencedCodeBlockRanges(text), + ] + + if (settings.ignoreHeadings) { + const headingPattern = /^#{1,6}\s+.*$/gm + let headingMatch: RegExpExecArray | null + + while ((headingMatch = headingPattern.exec(text)) !== null) { + ranges.push({ + start: headingMatch.index, + end: headingMatch.index + headingMatch[0].length, + }) + } } - const filteredCandidates = candidateData.candidates.filter((candidate) => { - return !(candidate.scoped && candidate.namespace !== currentNamespace) - }) + const calloutPattern = /^>[ \t]*\[![\w-]+\].*?(\n>.*?)*(?=\n(?!>)|$)/gm + let calloutMatch: RegExpExecArray | null - return dedupeCandidates(filteredCandidates) + while ((calloutMatch = calloutPattern.exec(text)) !== null) { + ranges.push({ + start: calloutMatch.index, + end: calloutMatch.index + calloutMatch[0].length, + }) + } + + ranges.sort((a, b) => a.start - b.start) + + const merged: Array<{ start: number, end: number }> = [] + for (const range of ranges) { + 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 isIndexProtected = ( + index: number, + protectedRanges: Array<{ start: number, end: number }>, +): boolean => { + return protectedRanges.some(range => index >= range.start && index < range.end) } const collectExistingWikilinks = ( @@ -366,11 +414,16 @@ const collectExistingWikilinks = ( candidateMap: Map, occurrences: CandidateOccurrence[], settings: ReplaceLinksSettings, + protectedRanges: Array<{ start: number, end: number }>, ): void => { const existingLinkRegex = /\[\[([^|\]]+)(?:\|([^\]]+))?\]\]/g let match: RegExpExecArray | null while ((match = existingLinkRegex.exec(text)) !== null) { + if (isIndexProtected(match.index, protectedRanges)) { + continue + } + const fullMatch = match[0] const path = match[1] const alias = match[2] || path @@ -581,18 +634,7 @@ const collectUnlinkedOccurrences = ({ const candidateData = candidateMap.get(trieCandidateKey) if (candidateData) { - const filteredCandidateData = filterCandidateDataForNamespace( - candidateData, - currentNamespace, - settings, - ) - - if (filteredCandidateData.candidates.length === 0) { - i += candidate.length - continue - } - - if (isSelfLink(filteredCandidateData, filePath, settings)) { + if (isSelfLink(candidateData, filePath, settings)) { i += candidate.length continue } @@ -610,13 +652,23 @@ const collectUnlinkedOccurrences = ({ } } + 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: filteredCandidateData, + candidateData: dedupeCandidates(candidateData.candidates), isInTable: isIndexInsideMarkdownTable(text, i), }) i += candidate.length @@ -655,60 +707,92 @@ export const scanCandidateOccurrences = ({ const normalizedText = text.normalize("NFC") const fallbackIndex = buildFallbackIndex(candidateMap, settings.ignoreCase) const currentNamespace = getCurrentNamespace(filePath, settings.baseDir) + const protectedRanges = findProtectedBlockRanges(normalizedText, settings) - collectExistingWikilinks(normalizedText, candidateMap, occurrences, settings) + collectExistingWikilinks( + normalizedText, + candidateMap, + occurrences, + settings, + protectedRanges, + ) - const protectedFreeOccurrences: CandidateOccurrence[] = [] - REGEX_PATTERNS.PROTECTED.lastIndex = 0 - let lastIndex = 0 - let match: RegExpExecArray | null - while ((match = REGEX_PATTERNS.PROTECTED.exec(normalizedText)) !== null) { - const segment = normalizedText.slice(lastIndex, match.index) - const segmentOccurrences: CandidateOccurrence[] = [] + const scanUnprotectedSegment = ( + segment: string, + offset: number, + segmentOccurrences: CandidateOccurrence[], + ): void => { + REGEX_PATTERNS.PROTECTED.lastIndex = 0 + let lastProtectedIndex = 0 + let match: RegExpExecArray | null + + while ((match = REGEX_PATTERNS.PROTECTED.exec(segment)) !== null) { + const unprotectedSegment = segment.slice(lastProtectedIndex, match.index) + const nestedOccurrences: CandidateOccurrence[] = [] + collectUnlinkedOccurrences({ + text: unprotectedSegment, + filePath, + trie, + candidateMap, + fallbackIndex, + currentNamespace, + settings, + occurrences: nestedOccurrences, + }) + for (const occurrence of nestedOccurrences) { + segmentOccurrences.push({ + ...occurrence, + start: occurrence.start + offset + lastProtectedIndex, + end: occurrence.end + offset + lastProtectedIndex, + }) + } + lastProtectedIndex = match.index + match[0].length + + if (match[0].length === 0) { + REGEX_PATTERNS.PROTECTED.lastIndex++ + } + } + + const nestedOccurrences: CandidateOccurrence[] = [] collectUnlinkedOccurrences({ - text: segment, + text: segment.slice(lastProtectedIndex), filePath, trie, candidateMap, fallbackIndex, currentNamespace, settings, - occurrences: segmentOccurrences, + occurrences: nestedOccurrences, }) - for (const occurrence of segmentOccurrences) { - protectedFreeOccurrences.push({ + for (const occurrence of nestedOccurrences) { + segmentOccurrences.push({ ...occurrence, - start: occurrence.start + lastIndex, - end: occurrence.end + lastIndex, + start: occurrence.start + offset + lastProtectedIndex, + end: occurrence.end + offset + lastProtectedIndex, }) } - lastIndex = match.index + match[0].length + } - if (match[0].length === 0) { - REGEX_PATTERNS.PROTECTED.lastIndex++ - } + const protectedFreeOccurrences: CandidateOccurrence[] = [] + let lastIndex = 0 + for (const range of protectedRanges) { + const segmentOccurrences: CandidateOccurrence[] = [] + scanUnprotectedSegment( + normalizedText.slice(lastIndex, range.start), + lastIndex, + segmentOccurrences, + ) + protectedFreeOccurrences.push(...segmentOccurrences) + lastIndex = range.end } const tailOccurrences: CandidateOccurrence[] = [] - collectUnlinkedOccurrences({ - text: normalizedText.slice(lastIndex), - filePath, - trie, - candidateMap, - fallbackIndex, - currentNamespace, - settings, - occurrences: tailOccurrences, - }) - for (const occurrence of tailOccurrences) { - protectedFreeOccurrences.push({ - ...occurrence, - start: occurrence.start + lastIndex, - end: occurrence.end + lastIndex, - }) - } - - occurrences.push(...protectedFreeOccurrences) + scanUnprotectedSegment( + normalizedText.slice(lastIndex), + lastIndex, + tailOccurrences, + ) + occurrences.push(...protectedFreeOccurrences, ...tailOccurrences) return occurrences.sort((a, b) => a.start - b.start) } diff --git a/src/utils/__tests__/resolve-ambiguities.test.ts b/src/utils/__tests__/resolve-ambiguities.test.ts index 36e7196..5dcafc3 100644 --- a/src/utils/__tests__/resolve-ambiguities.test.ts +++ b/src/utils/__tests__/resolve-ambiguities.test.ts @@ -73,4 +73,40 @@ describe("resolveAmbiguities", () => { ) expect(result.get("[[private/meeting|meeting]]")).toBe("work/meeting") }) + + it("should skip fenced code blocks, callouts, and ignored headings", async () => { + const text = `# meeting +> [!note] +> meeting + +~~~ts +meeting +~~~ + +meeting` + vi.mocked(aiClient.resolveAmbiguitiesBatch).mockClear() + vi.mocked(aiClient.resolveAmbiguitiesBatch).mockResolvedValue( + new Map([["meeting", "work/meeting"]]), + ) + + await resolveAmbiguities(text, candidateMap, trie, { + ...mockSettings, + ignoreHeadings: true, + proximityBasedLinking: true, + }) + + expect(aiClient.resolveAmbiguitiesBatch).toHaveBeenCalledTimes(1) + expect(aiClient.resolveAmbiguitiesBatch).toHaveBeenCalledWith( + expect.objectContaining({ + ignoreHeadings: true, + proximityBasedLinking: true, + }), + [ + expect.objectContaining({ + word: "meeting", + candidates: ["work/meeting", "private/meeting"], + }), + ], + ) + }) }) From b264541faa638eff75688bd6aaf35daafd52b8eb Mon Sep 17 00:00:00 2001 From: Kodai Nakamura Date: Mon, 22 Jun 2026 01:41:26 +0900 Subject: [PATCH 06/25] fix(candidate-scanner): skip existing wikilinks in inline code Why: - Existing wikilinks inside inline code were still being collected as candidate occurrences, which could drive resolveAmbiguities() to prepare AI work for text that replaceLinks() intentionally leaves untouched. What: - Added inline-code protected ranges to existing wikilink collection so matches inside backticks are ignored alongside the current block-level exclusions. - Added regression coverage for scanCandidateOccurrences() and the resolveAmbiguities() request path. - Appended the focused and full verification results to the task 1 report. --- .superpowers/sdd/task-1-report.md | 41 +++++++++++++++++++ .../__tests__/candidate-scanner.test.ts | 24 +++++++++++ src/replace-links/candidate-scanner.ts | 24 ++++++++++- .../__tests__/resolve-ambiguities.test.ts | 14 +++++++ 4 files changed, 102 insertions(+), 1 deletion(-) diff --git a/.superpowers/sdd/task-1-report.md b/.superpowers/sdd/task-1-report.md index 1cdf26d..b1114d1 100644 --- a/.superpowers/sdd/task-1-report.md +++ b/.superpowers/sdd/task-1-report.md @@ -167,3 +167,44 @@ Results: - `npm run test -- --reporter=dot`: passed, `323/323` tests across `38/38` files - `npm run tsc`: passed - `npm run lint`: passed + +## Review Fix 2 + +### Scope + +Fixed the remaining Task 1 review finding: + +- `scanCandidateOccurrences()` now skips existing wikilinks that fall inside inline code spans, not just block-level protected ranges +- `resolveAmbiguities()` coverage now proves the protected inline wikilink produces no AI request payload + +No AGENTS.md changes were needed. + +### Focused Verification + +Commands: + +```bash +npx vitest run src/replace-links/__tests__/candidate-scanner.test.ts +npx vitest run src/utils/__tests__/resolve-ambiguities.test.ts +``` + +Results: + +- `src/replace-links/__tests__/candidate-scanner.test.ts`: passed `7/7` +- `src/utils/__tests__/resolve-ambiguities.test.ts`: passed `4/4` + +### Full Verification + +Commands: + +```bash +npm run test -- --reporter=dot +npm run tsc +npm run lint +``` + +Results: + +- `npm run test -- --reporter=dot`: passed, `325/325` tests across `38/38` files +- `npm run tsc`: passed +- `npm run lint`: passed diff --git a/src/replace-links/__tests__/candidate-scanner.test.ts b/src/replace-links/__tests__/candidate-scanner.test.ts index 844afe8..e1ea4da 100644 --- a/src/replace-links/__tests__/candidate-scanner.test.ts +++ b/src/replace-links/__tests__/candidate-scanner.test.ts @@ -81,6 +81,30 @@ describe("scanCandidateOccurrences", () => { ]) }) + it("skips existing wikilinks inside inline code", () => { + const { candidateMap, trie } = buildCandidateTrieForTest({ + files: [ + { path: "work/meeting" }, + { path: "private/meeting" }, + ], + settings: { + scoped: false, + baseDir: undefined, + ignoreCase: true, + }, + }) + + const occurrences = scanCandidateOccurrences({ + text: "`[[private/meeting|meeting]]`", + filePath: "notes/today", + trie, + candidateMap, + settings: { ignoreCase: true, proximityBasedLinking: true }, + }) + + expect(occurrences).toEqual([]) + }) + it("preserves trie-hit candidate sets for scoped candidates in the current namespace", () => { const { candidateMap, trie } = buildCandidateTrieForTest({ files: [ diff --git a/src/replace-links/candidate-scanner.ts b/src/replace-links/candidate-scanner.ts index 27c7b59..9ccc549 100644 --- a/src/replace-links/candidate-scanner.ts +++ b/src/replace-links/candidate-scanner.ts @@ -402,6 +402,25 @@ const findProtectedBlockRanges = ( return merged } +const findInlineCodeRanges = (text: string): Array<{ start: number, end: number }> => { + if (!text.includes("`")) { + return [] + } + + const ranges: Array<{ start: number, end: number }> = [] + const inlineCodePattern = /`[^`]*`/g + let inlineCodeMatch: RegExpExecArray | null + + while ((inlineCodeMatch = inlineCodePattern.exec(text)) !== null) { + ranges.push({ + start: inlineCodeMatch.index, + end: inlineCodeMatch.index + inlineCodeMatch[0].length, + }) + } + + return ranges +} + const isIndexProtected = ( index: number, protectedRanges: Array<{ start: number, end: number }>, @@ -707,7 +726,10 @@ export const scanCandidateOccurrences = ({ const normalizedText = text.normalize("NFC") const fallbackIndex = buildFallbackIndex(candidateMap, settings.ignoreCase) const currentNamespace = getCurrentNamespace(filePath, settings.baseDir) - const protectedRanges = findProtectedBlockRanges(normalizedText, settings) + const protectedRanges = [ + ...findProtectedBlockRanges(normalizedText, settings), + ...findInlineCodeRanges(normalizedText), + ] collectExistingWikilinks( normalizedText, diff --git a/src/utils/__tests__/resolve-ambiguities.test.ts b/src/utils/__tests__/resolve-ambiguities.test.ts index 5dcafc3..cd03d96 100644 --- a/src/utils/__tests__/resolve-ambiguities.test.ts +++ b/src/utils/__tests__/resolve-ambiguities.test.ts @@ -74,6 +74,20 @@ describe("resolveAmbiguities", () => { expect(result.get("[[private/meeting|meeting]]")).toBe("work/meeting") }) + it("should not request AI for existing links inside inline code", async () => { + const text = "Check the `[[private/meeting|meeting]]` notes." + vi.mocked(aiClient.resolveAmbiguitiesBatch).mockClear() + vi.mocked(aiClient.resolveAmbiguitiesBatch).mockResolvedValue(new Map()) + + const result = await resolveAmbiguities(text, candidateMap, trie, mockSettings) + + expect(aiClient.resolveAmbiguitiesBatch).toHaveBeenCalledWith( + mockSettings, + [], + ) + expect(result.size).toBe(0) + }) + it("should skip fenced code blocks, callouts, and ignored headings", async () => { const text = `# meeting > [!note] From 490e0c3277597d9b22c1dda9bc12f464e4623888 Mon Sep 17 00:00:00 2001 From: Kodai Nakamura Date: Mon, 22 Jun 2026 01:49:08 +0900 Subject: [PATCH 07/25] fix(replace-links): align Korean ambiguity handling Why: - Task 1 still had drift between candidate scanning and Korean replacement special cases. - The scanner could ask AI about Korean particle forms that replacement intentionally skips. - The Korean suffix replacement path ignored resolved ambiguities and could apply a different target than the AI chose. What: - skip Korean particle trie hits in the Task 1 candidate scanner and ambiguity request path - reuse a shared resolved-ambiguity link-content helper for the standard and Korean suffix replacement branches - add Task 1 regressions and append the Task 1 fix report with focused and full verification results --- .superpowers/sdd/task-1-report.md | 80 +++++++++++++++++++ .../__tests__/ai-disambiguation.test.ts | 26 ++++++ .../__tests__/candidate-scanner.test.ts | 34 ++++++++ src/replace-links/candidate-scanner.ts | 18 +++++ src/replace-links/replace-links.ts | 47 ++++++----- .../__tests__/resolve-ambiguities.test.ts | 30 +++++++ 6 files changed, 216 insertions(+), 19 deletions(-) diff --git a/.superpowers/sdd/task-1-report.md b/.superpowers/sdd/task-1-report.md index b1114d1..b4d90ad 100644 --- a/.superpowers/sdd/task-1-report.md +++ b/.superpowers/sdd/task-1-report.md @@ -208,3 +208,83 @@ Results: - `npm run test -- --reporter=dot`: passed, `325/325` tests across `38/38` files - `npm run tsc`: passed - `npm run lint`: passed + +## Review Fix 3 + +### Scope + +Fixed the remaining Task 1 Korean special-case scanner/replacement drift: + +- `scanCandidateOccurrences()` now skips Korean particle trie hits like `문서는` / `문서은`, matching the current non-linking `replaceLinks()` behavior for that branch +- `replaceLinks()` now honors `resolvedAmbiguities` for the Korean suffix branch like `문서이다`, so AI-resolved targets are applied consistently +- added regressions covering scanner omission, AI request omission, and Korean suffix replacement resolution + +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/utils/__tests__/resolve-ambiguities.test.ts +npx vitest run src/replace-links/__tests__/ai-disambiguation.test.ts +``` + +Results: + +- `candidate-scanner.test.ts`: failed because `scanCandidateOccurrences()` emitted `문서` for `문서는` and `문서은` +- `resolve-ambiguities.test.ts`: failed because the scanner still generated an AI ambiguity request for Korean particle forms +- `ai-disambiguation.test.ts`: failed because `replaceLinks()` ignored `resolvedAmbiguities` for the Korean `이다` suffix branch and kept the default candidate + +#### GREEN + +Commands: + +```bash +npx vitest run src/replace-links/__tests__/candidate-scanner.test.ts +npx vitest run src/utils/__tests__/resolve-ambiguities.test.ts +npx vitest run src/replace-links/__tests__/ai-disambiguation.test.ts +``` + +Results: + +- `src/replace-links/__tests__/candidate-scanner.test.ts`: passed `8/8` +- `src/utils/__tests__/resolve-ambiguities.test.ts`: passed `5/5` +- `src/replace-links/__tests__/ai-disambiguation.test.ts`: passed `4/4` + +### Implementation Summary + +- Added an internal scanner guard that skips Korean trie hits when the matched term is immediately followed by the `는` or `은` particle. +- Introduced a shared link-content resolver in `replace-links.ts` so the normal ambiguity-resolution path and the Korean `이다` suffix path use the same `resolvedAmbiguities` lookup. +- Kept behavior unchanged when `resolvedAmbiguities` is absent by falling back to the existing candidate-derived link generation. + +### Focused Verification + +Command: + +```bash +npx vitest run src/replace-links/__tests__/candidate-scanner.test.ts src/utils/__tests__/resolve-ambiguities.test.ts src/replace-links/__tests__/ai-disambiguation.test.ts src/replace-links/__tests__/replace-links.cjk.test.ts +``` + +Result: + +- Passed: `29/29` tests across `4/4` files + +### Full Verification + +Commands: + +```bash +npm run test -- --reporter=dot +npm run tsc +npm run lint +``` + +Results: + +- `npm run test -- --reporter=dot`: passed, `328/328` tests across `38/38` files +- `npm run tsc`: passed +- `npm run lint`: passed diff --git a/src/replace-links/__tests__/ai-disambiguation.test.ts b/src/replace-links/__tests__/ai-disambiguation.test.ts index 464f609..c8a7643 100644 --- a/src/replace-links/__tests__/ai-disambiguation.test.ts +++ b/src/replace-links/__tests__/ai-disambiguation.test.ts @@ -53,4 +53,30 @@ describe("replaceLinks with AI disambiguation", () => { expect(result).toBe("Check [[work/meeting|private/meeting]] notes.") }) + + it("should honor AI-resolved Korean suffix choices", () => { + const koreanFiles = [ + { path: "work/문서", scoped: false, aliases: [] }, + { path: "private/문서", scoped: false, aliases: [] }, + ] + const { candidateMap: koreanCandidateMap, trie: koreanTrie } = buildCandidateTrie( + koreanFiles, + undefined, + true, + ) + const body = "문서이다." + const resolvedAmbiguities = new Map([["문서", "private/문서"]]) + + const result = replaceLinks({ + body, + linkResolverContext: { + filePath: "test.md", + trie: koreanTrie, + candidateMap: koreanCandidateMap, + }, + resolvedAmbiguities, + }) + + expect(result).toBe("[[private/문서|문서]]이다.") + }) }) diff --git a/src/replace-links/__tests__/candidate-scanner.test.ts b/src/replace-links/__tests__/candidate-scanner.test.ts index e1ea4da..d1ec0ac 100644 --- a/src/replace-links/__tests__/candidate-scanner.test.ts +++ b/src/replace-links/__tests__/candidate-scanner.test.ts @@ -216,6 +216,40 @@ meeting`, }, ]) }) + + it("does not emit Korean particle forms that replaceLinks currently skips", () => { + const { candidateMap, trie } = buildCandidateTrieForTest({ + files: [ + { path: "work/문서" }, + { path: "private/문서" }, + ], + settings: { + scoped: false, + baseDir: undefined, + ignoreCase: true, + }, + }) + + const occurrences = scanCandidateOccurrences({ + text: "문서는 문서은 문서이다", + filePath: "notes/today", + trie, + candidateMap, + settings: { ignoreCase: true, proximityBasedLinking: true }, + }) + + expect(occurrences.map(o => ({ + text: o.text, + start: o.start, + end: o.end, + }))).toEqual([ + { + text: "문서", + start: 8, + end: 10, + }, + ]) + }) }) describe("getOccurrenceContext", () => { diff --git a/src/replace-links/candidate-scanner.ts b/src/replace-links/candidate-scanner.ts index 9ccc549..4f8ec1f 100644 --- a/src/replace-links/candidate-scanner.ts +++ b/src/replace-links/candidate-scanner.ts @@ -582,6 +582,19 @@ const collectFallbackOccurrence = ({ } } +const shouldSkipKoreanTrieOccurrence = ( + text: string, + startIndex: number, + candidate: string, +): boolean => { + if (!isKoreanText(candidate)) { + return false + } + + const remaining = text.slice(startIndex + candidate.length) + return REGEX_PATTERNS.KOREAN_PARTICLES.test(remaining) +} + const collectUnlinkedOccurrences = ({ text, filePath, @@ -658,6 +671,11 @@ const collectUnlinkedOccurrences = ({ continue } + if (shouldSkipKoreanTrieOccurrence(text, i, candidate)) { + i += candidate.length + continue + } + const candidateIsCjk = isCjkCandidate(candidate) if (!candidateIsCjk) { const left = i > 0 ? text[i - 1] : undefined diff --git a/src/replace-links/replace-links.ts b/src/replace-links/replace-links.ts index afddcfc..a008413 100644 --- a/src/replace-links/replace-links.ts +++ b/src/replace-links/replace-links.ts @@ -157,6 +157,24 @@ const createLinkContent = ( } } +const resolveLinkContent = ( + candidateData: CandidateData, + originalMatchedText: string, + settings: ReplaceLinksSettings, + resolvedAmbiguities?: Map, +): { linkPath: string, alias?: string } => { + if (resolvedAmbiguities?.has(originalMatchedText)) { + const resolvedPath = resolvedAmbiguities.get(originalMatchedText)! + const parts = extractLinkParts(resolvedPath) + return { + linkPath: parts.linkPath, + alias: parts.alias || originalMatchedText, + } + } + + return createLinkContent(candidateData, originalMatchedText, settings) +} + // Default link generator that creates standard Obsidian wikilinks export const escapeLinkForMarkdownTable = ( link: string, @@ -350,6 +368,7 @@ const handleKoreanSpecialCases = ( filePath: string, linkGenerator: LinkGenerator, settings: ReplaceLinksSettings = {}, + resolvedAmbiguities?: Map, ): { result: string, newIndex: number } | null => { const remaining = text.slice(i + candidate.length) @@ -364,10 +383,11 @@ const handleKoreanSpecialCases = ( } } - const { linkPath, alias } = createLinkContent( + const { linkPath, alias } = resolveLinkContent( candidateData, candidate, settings, + resolvedAmbiguities, ) const finalLink = linkGenerator({ linkPath, @@ -496,6 +516,7 @@ const processStandardText = ( filePath, linkGenerator, settings, + resolvedAmbiguities, ) if (koreanResult) { result += koreanResult.result @@ -548,24 +569,12 @@ const processStandardText = ( } // Create the link - let linkPath = "" - let alias: string | undefined - - if (resolvedAmbiguities?.has(candidate)) { - const resolvedPath = resolvedAmbiguities.get(candidate)! - const parts = extractLinkParts(resolvedPath) - linkPath = parts.linkPath - alias = parts.alias || candidate - } - else { - const content = createLinkContent( - candidateData, - candidate, - settings, - ) - linkPath = content.linkPath - alias = content.alias - } + const { linkPath, alias } = resolveLinkContent( + candidateData, + candidate, + settings, + resolvedAmbiguities, + ) const isInTable = isIndexInsideMarkdownTable(text, i) const finalLink = linkGenerator({ diff --git a/src/utils/__tests__/resolve-ambiguities.test.ts b/src/utils/__tests__/resolve-ambiguities.test.ts index cd03d96..dd751a4 100644 --- a/src/utils/__tests__/resolve-ambiguities.test.ts +++ b/src/utils/__tests__/resolve-ambiguities.test.ts @@ -123,4 +123,34 @@ meeting` ], ) }) + + it("should not request AI for Korean particle forms that replacement skips", async () => { + const koreanCandidateMap = new Map([ + [ + "문서", + { + candidates: [ + { canonical: "work/문서", scoped: false, namespace: "work" }, + { canonical: "private/문서", scoped: false, namespace: "private" }, + ], + }, + ], + ]) + const koreanTrie: TrieNode = buildTrie(["문서"], true) + vi.mocked(aiClient.resolveAmbiguitiesBatch).mockClear() + vi.mocked(aiClient.resolveAmbiguitiesBatch).mockResolvedValue(new Map()) + + const result = await resolveAmbiguities( + "문서는 문서은", + koreanCandidateMap, + koreanTrie, + mockSettings, + ) + + expect(aiClient.resolveAmbiguitiesBatch).toHaveBeenCalledWith( + mockSettings, + [], + ) + expect(result.size).toBe(0) + }) }) From 325ad7e9f49420b46b044304f0d878b78087d3f3 Mon Sep 17 00:00:00 2001 From: Kodai Nakamura Date: Mon, 22 Jun 2026 01:54:13 +0900 Subject: [PATCH 08/25] fix(replace-links): match Korean particle scanner cursor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: The scanner was skipping Korean particle hits by candidate length, which let it jump past overlapping follow-on candidates and diverge from replaceLinks cursor behavior.\n\nWhat: Advance the scanner by one codepoint after a Korean particle skip, add regressions for the isolated and overlapping 문서/서는 cases, and record the verification in the Task 1 report. --- .superpowers/sdd/task-1-report.md | 53 +++++++++++++++++++ .../__tests__/candidate-scanner.test.ts | 40 +++++++++++--- src/replace-links/candidate-scanner.ts | 2 +- 3 files changed, 86 insertions(+), 9 deletions(-) diff --git a/.superpowers/sdd/task-1-report.md b/.superpowers/sdd/task-1-report.md index b4d90ad..cdabc96 100644 --- a/.superpowers/sdd/task-1-report.md +++ b/.superpowers/sdd/task-1-report.md @@ -288,3 +288,56 @@ Results: - `npm run test -- --reporter=dot`: passed, `328/328` tests across `38/38` files - `npm run tsc`: passed - `npm run lint`: passed + +## Review Fix 4 + +### Scope + +Fixed the remaining Task 1 review finding for Korean particle cursor advancement: + +- `scanCandidateOccurrences()` now advances by one codepoint after skipping a Korean particle hit, instead of consuming the full matched candidate length +- added a regression proving an isolated `문서는` hit is still omitted while an overlapping follow-on candidate at index `1` is still discovered + +No AGENTS.md changes were needed. + +### TDD Evidence + +#### RED + +Command: + +```bash +npx vitest run src/replace-links/__tests__/candidate-scanner.test.ts +``` + +Result: + +- Failed as expected +- Failure: the scanner skipped `문서` in `문서는` by `candidate.length`, so it never reached the overlapping `서는` candidate at index `1` + +#### GREEN + +Commands: + +```bash +npx vitest run src/replace-links/__tests__/candidate-scanner.test.ts +npx vitest run src/replace-links/__tests__/candidate-scanner.test.ts src/replace-links/__tests__/ai-disambiguation.test.ts src/utils/__tests__/resolve-ambiguities.test.ts src/replace-links/__tests__/prev.test.ts +npm run test -- --reporter=dot +npm run tsc +npm run lint +``` + +Results: + +- `src/replace-links/__tests__/candidate-scanner.test.ts`: passed `8/8` +- focused scanner/AI/CJK suite: passed `60/60` across `4/4` files +- initial full-suite run: failed once on the namespace-resolution performance check at `320.1955ms` versus the `300ms` threshold +- rerun of `npm run test -- --reporter=dot`: passed, `328/328` tests across `38/38` files +- `npm run tsc`: passed +- `npm run lint`: passed + +### Implementation Summary + +- 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 diff --git a/src/replace-links/__tests__/candidate-scanner.test.ts b/src/replace-links/__tests__/candidate-scanner.test.ts index d1ec0ac..d341468 100644 --- a/src/replace-links/__tests__/candidate-scanner.test.ts +++ b/src/replace-links/__tests__/candidate-scanner.test.ts @@ -217,8 +217,8 @@ meeting`, ]) }) - it("does not emit Korean particle forms that replaceLinks currently skips", () => { - const { candidateMap, trie } = buildCandidateTrieForTest({ + it("skips Korean particle hits but still finds overlapping follow-on candidates", () => { + const isolatedFixture = buildCandidateTrieForTest({ files: [ { path: "work/문서" }, { path: "private/문서" }, @@ -229,24 +229,48 @@ meeting`, ignoreCase: true, }, }) + const isolatedOccurrences = scanCandidateOccurrences({ + text: "문서는", + filePath: "notes/today", + trie: isolatedFixture.trie, + candidateMap: isolatedFixture.candidateMap, + settings: { ignoreCase: true, proximityBasedLinking: true }, + }) - const occurrences = scanCandidateOccurrences({ - text: "문서는 문서은 문서이다", + expect(isolatedOccurrences).toEqual([]) + + const overlappingFixture = buildCandidateTrieForTest({ + files: [ + { path: "work/문서" }, + { path: "private/문서" }, + { path: "work/서는" }, + { path: "private/서는" }, + ], + settings: { + scoped: false, + baseDir: undefined, + ignoreCase: true, + }, + }) + const { candidateMap, trie } = overlappingFixture + + const overlappingOccurrences = scanCandidateOccurrences({ + text: "문서는", filePath: "notes/today", trie, candidateMap, settings: { ignoreCase: true, proximityBasedLinking: true }, }) - expect(occurrences.map(o => ({ + expect(overlappingOccurrences.map(o => ({ text: o.text, start: o.start, end: o.end, }))).toEqual([ { - text: "문서", - start: 8, - end: 10, + text: "서는", + start: 1, + end: 3, }, ]) }) diff --git a/src/replace-links/candidate-scanner.ts b/src/replace-links/candidate-scanner.ts index 4f8ec1f..f76a06e 100644 --- a/src/replace-links/candidate-scanner.ts +++ b/src/replace-links/candidate-scanner.ts @@ -672,7 +672,7 @@ const collectUnlinkedOccurrences = ({ } if (shouldSkipKoreanTrieOccurrence(text, i, candidate)) { - i += candidate.length + i += 1 continue } From 21fd68c7b92af86245e3655a5d5cba4a05ee713c Mon Sep 17 00:00:00 2001 From: Kodai Nakamura Date: Mon, 22 Jun 2026 01:59:23 +0900 Subject: [PATCH 09/25] fix(resolve-ambiguities): pass active file path into AI scan Why: AI ambiguity requests were scanned without the current file path, so preventSelfLinking could not filter self-link candidates on the AI path. What: - Thread an optional filePath through resolveAmbiguities() and pass it into the shared candidate scanner. - Pass the active markdown path without .md from the AI command in main.ts. - Add a regression proving self-link candidates are skipped before AI requests are built when the current file matches. --- .superpowers/sdd/task-1-report.md | 72 +++++++++++++++++++ src/main.ts | 1 + .../__tests__/resolve-ambiguities.test.ts | 36 ++++++++++ src/utils/resolve-ambiguities.ts | 3 +- 4 files changed, 111 insertions(+), 1 deletion(-) diff --git a/.superpowers/sdd/task-1-report.md b/.superpowers/sdd/task-1-report.md index cdabc96..36c5e2b 100644 --- a/.superpowers/sdd/task-1-report.md +++ b/.superpowers/sdd/task-1-report.md @@ -289,6 +289,78 @@ Results: - `npm run tsc`: passed - `npm run lint`: passed +## Review Fix 5 + +### Scope + +Fixed the remaining AI ambiguity-path review finding: + +- threaded the current file path into `resolveAmbiguities()` with a safe default so existing callers stay compatible +- passed the active markdown file path without `.md` from `src/main.ts` +- added a regression proving `resolveAmbiguities()` skips self-link candidates when `preventSelfLinking` is enabled and the current file matches the candidate + +No AGENTS.md changes were needed. + +### TDD Evidence + +#### RED + +Command: + +```bash +npx vitest run src/utils/__tests__/resolve-ambiguities.test.ts +``` + +Result: + +- Failed as expected on the new self-link regression +- The AI request still contained `meeting` because `resolveAmbiguities()` was scanning with `filePath: ""` + +#### GREEN + +Commands: + +```bash +npx vitest run src/utils/__tests__/resolve-ambiguities.test.ts +npx vitest run src/replace-links/__tests__/candidate-scanner.test.ts +npx vitest run src/replace-links/__tests__/ai-disambiguation.test.ts src/replace-links/__tests__/replace-links.prevent-self-linking.test.ts +``` + +Results: + +- `src/utils/__tests__/resolve-ambiguities.test.ts`: passed `6/6` +- `src/replace-links/__tests__/candidate-scanner.test.ts`: passed `8/8` +- `src/replace-links/__tests__/ai-disambiguation.test.ts` and `src/replace-links/__tests__/replace-links.prevent-self-linking.test.ts`: passed `13/13` + +### Full Verification + +Commands: + +```bash +npm run test -- --reporter=dot +npm run tsc +npm run lint +``` + +Results: + +- First `npm run test -- --reporter=dot` run failed once on the existing namespace-resolution performance threshold: `364.82662500000004ms` vs the `300ms` limit +- Reran `npm run test -- --reporter=dot` and it passed: `329/329` tests across `38/38` files +- `npm run tsc`: passed +- `npm run lint`: passed + +### Implementation Summary + +- Added an optional `filePath` parameter to `resolveAmbiguities()` and threaded it into `scanCandidateOccurrences()` +- Passed `activeFile.path.replace(/\.md$/, "")` from the AI command in `src/main.ts` +- Kept scanner public interfaces unchanged + +### Files Changed + +- `src/utils/resolve-ambiguities.ts` +- `src/main.ts` +- `src/utils/__tests__/resolve-ambiguities.test.ts` + ## Review Fix 4 ### Scope diff --git a/src/main.ts b/src/main.ts index 4df9a64..c5c050f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -531,6 +531,7 @@ export default class AutomaticLinkerPlugin extends Plugin { this.candidateMap, this.trie, this.settings, + activeFile.path.replace(/\.md$/, ""), ) const resultBody = replaceLinks({ diff --git a/src/utils/__tests__/resolve-ambiguities.test.ts b/src/utils/__tests__/resolve-ambiguities.test.ts index dd751a4..9296c67 100644 --- a/src/utils/__tests__/resolve-ambiguities.test.ts +++ b/src/utils/__tests__/resolve-ambiguities.test.ts @@ -153,4 +153,40 @@ meeting` ) expect(result.size).toBe(0) }) + + it("should not request AI for self-link candidates when the file path matches", async () => { + const selfLinkCandidateMap = new Map([ + [ + "meeting", + { + candidates: [ + { canonical: "work/meeting", scoped: false, namespace: "work" }, + { canonical: "private/meeting", scoped: false, namespace: "private" }, + ], + }, + ], + ]) + const selfLinkTrie: TrieNode = buildTrie(["meeting"], true) + vi.mocked(aiClient.resolveAmbiguitiesBatch).mockClear() + vi.mocked(aiClient.resolveAmbiguitiesBatch).mockResolvedValue(new Map()) + + const result = await resolveAmbiguities( + "meeting", + selfLinkCandidateMap, + selfLinkTrie, + { + ...mockSettings, + preventSelfLinking: true, + }, + "work/meeting", + ) + + expect(aiClient.resolveAmbiguitiesBatch).toHaveBeenCalledWith( + expect.objectContaining({ + preventSelfLinking: true, + }), + [], + ) + expect(result.size).toBe(0) + }) }) diff --git a/src/utils/resolve-ambiguities.ts b/src/utils/resolve-ambiguities.ts index 68cae63..d75f222 100644 --- a/src/utils/resolve-ambiguities.ts +++ b/src/utils/resolve-ambiguities.ts @@ -11,10 +11,11 @@ export const resolveAmbiguities = async ( candidateMap: Map, trie: TrieNode, settings: AutomaticLinkerSettings, + filePath = "", ): Promise> => { const occurrences = scanCandidateOccurrences({ text, - filePath: "", + filePath, trie, candidateMap, settings, From a8afe1d84752dcc06311a5225959ee82c565a7a3 Mon Sep 17 00:00:00 2001 From: Kodai Nakamura Date: Mon, 22 Jun 2026 02:07:14 +0900 Subject: [PATCH 10/25] 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, From 73014eb0123fb5ca6c845930ecb31f8ce80a3bec Mon Sep 17 00:00:00 2001 From: Kodai Nakamura Date: Mon, 22 Jun 2026 02:14:30 +0900 Subject: [PATCH 11/25] refactor: extract pure formatting run Why: - main.ts mixed Obsidian adapter work with pure transformation sequencing, which made formatting behavior require plugin mocks to test. - A pure formatting run increases locality for URL formatting, URL title replacement, and link replacement order. What: - Add a formatting-run module with document and body formatting entry points. - Route file and selection formatting through the new module. - Move pure frontmatter URL-title coverage out of main adapter tests. --- .superpowers/sdd/task-2-report.md | 85 +++++++++++++ src/__tests__/formatting-run.test.ts | 113 ++++++++++++++++++ .../main-url-title-frontmatter.test.ts | 25 ---- src/formatting-run.ts | 100 ++++++++++++++++ src/main.ts | 109 ++++++----------- 5 files changed, 336 insertions(+), 96 deletions(-) create mode 100644 .superpowers/sdd/task-2-report.md create mode 100644 src/__tests__/formatting-run.test.ts create mode 100644 src/formatting-run.ts diff --git a/.superpowers/sdd/task-2-report.md b/.superpowers/sdd/task-2-report.md new file mode 100644 index 0000000..6fbca3a --- /dev/null +++ b/.superpowers/sdd/task-2-report.md @@ -0,0 +1,85 @@ +# Task 2 Report: Formatting Run Module + +## Scope + +Implemented Task 2 only: + +- Added `src/formatting-run.ts` +- Added `src/__tests__/formatting-run.test.ts` +- Updated `src/main.ts` to route document and selection formatting through the new module +- Reduced duplicated adapter-level URL-title/frontmatter coverage in `src/__tests__/main-url-title-frontmatter.test.ts` + +Preserved Task 1 behavior: + +- `resolveAmbiguities()` still receives the normalized current file path +- AI command still renders via `replaceLinks(...)` +- AI command now uses `toReplaceLinksSettings(this.settings, baseDir)` +- Link replacement still normalizes source file paths by removing a trailing `.md` + +## TDD Evidence + +### RED + +Command: + +```bash +npx vitest run src/__tests__/formatting-run.test.ts +``` + +Result: + +- Failed as expected +- Error: `Cannot find module '../formatting-run'` + +### GREEN + +Command: + +```bash +npx vitest run src/__tests__/formatting-run.test.ts +``` + +Result: + +- Passed: `4` tests in `src/__tests__/formatting-run.test.ts` + +## Focused Verification + +Command: + +```bash +npx vitest run src/__tests__/formatting-run.test.ts src/__tests__/main-url-title-frontmatter.test.ts src/__tests__/main-link-generator.test.ts +``` + +Result: + +- Passed: `3` test files, `6` tests + +## Full Stage Verification + +Commands: + +```bash +npm run test -- --reporter=dot +npm run tsc +npm run lint +``` + +Results: + +- `npm run test -- --reporter=dot`: passed `39` test files, `334` tests +- `npm run tsc`: exit `0` +- `npm run lint`: exit `0` + +## Implementation Notes + +- `formatMarkdownDocument(...)` owns frontmatter splitting and delegates body formatting +- `formatMarkdownBody(...)` sequences GitHub/Jira/Linear URL formatting, URL-title replacement, then link replacement +- `toReplaceLinksSettings(...)` projects only the settings required by `replaceLinks(...)` and carries `baseDir` +- `modifyLinks(...)` now uses the pure formatter for both indexed and non-indexed runs +- `mofifyLinksSelection(...)` now uses `formatMarkdownBody(...)` +- Adapter-only URL-title fetch opt-out coverage remains in `main-url-title-frontmatter.test.ts` + +## Concerns + +None. diff --git a/src/__tests__/formatting-run.test.ts b/src/__tests__/formatting-run.test.ts new file mode 100644 index 0000000..323f1a6 --- /dev/null +++ b/src/__tests__/formatting-run.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest" +import { + formatMarkdownBody, + formatMarkdownDocument, + toReplaceLinksSettings, +} from "../formatting-run" +import { buildCandidateTrieForTest } from "../replace-links/__tests__/test-helpers" +import { DEFAULT_SETTINGS } from "../settings/settings-info" + +describe("toReplaceLinksSettings", () => { + it("projects only replacement settings and applies baseDir", () => { + expect(toReplaceLinksSettings({ + ...DEFAULT_SETTINGS, + proximityBasedLinking: false, + ignoreDateFormats: false, + ignoreCase: false, + matchSentenceCase: false, + preventSelfLinking: true, + removeAliasInDirs: ["archive"], + ignoreHeadings: true, + ignoreMarkdownTables: true, + }, "pages")).toEqual({ + proximityBasedLinking: false, + baseDir: "pages", + ignoreDateFormats: false, + ignoreCase: false, + matchSentenceCase: false, + preventSelfLinking: true, + removeAliasInDirs: ["archive"], + ignoreHeadings: true, + ignoreMarkdownTables: true, + }) + }) +}) + +describe("formatMarkdownDocument", () => { + it("preserves frontmatter and respects URL title opt-out", () => { + const result = formatMarkdownDocument({ + content: "---\nautomatic-linker-disable-url-title: true\n---\nhttps://example.com", + filePath: "current-file.md", + frontmatter: { "automatic-linker-disable-url-title": true }, + settings: { + ...DEFAULT_SETTINGS, + formatGitHubURLs: false, + formatJiraURLs: false, + formatLinearURLs: false, + replaceUrlWithTitle: true, + }, + urlTitleMap: new Map([["https://example.com", "Example Title"]]), + }) + + expect(result).toBe( + "---\nautomatic-linker-disable-url-title: true\n---\nhttps://example.com", + ) + }) + + it("runs URL formatting, URL titles, and link replacement in order", () => { + const { candidateMap, trie } = buildCandidateTrieForTest({ + files: [{ path: "notes/TypeScript" }], + settings: { + scoped: false, + baseDir: undefined, + ignoreCase: true, + }, + }) + + const result = formatMarkdownDocument({ + content: "Read TypeScript at https://example.com", + filePath: "current-file.md", + settings: { + ...DEFAULT_SETTINGS, + formatGitHubURLs: false, + formatJiraURLs: false, + formatLinearURLs: false, + replaceUrlWithTitle: true, + ignoreCase: true, + }, + candidateIndex: { candidateMap, trie }, + urlTitleMap: new Map([["https://example.com", "Example Title"]]), + }) + + expect(result).toBe("Read [[notes/TypeScript|TypeScript]] at [Example Title](https://example.com)") + }) +}) + +describe("formatMarkdownBody", () => { + it("formats selected body text without frontmatter splitting", () => { + const { candidateMap, trie } = buildCandidateTrieForTest({ + files: [{ path: "notes/TypeScript" }], + settings: { + scoped: false, + baseDir: undefined, + ignoreCase: true, + }, + }) + + const result = formatMarkdownBody({ + body: "TypeScript", + filePath: "current-file.md", + settings: { + ...DEFAULT_SETTINGS, + formatGitHubURLs: false, + formatJiraURLs: false, + formatLinearURLs: false, + replaceUrlWithTitle: false, + ignoreCase: true, + }, + candidateIndex: { candidateMap, trie }, + }) + + expect(result).toBe("[[notes/TypeScript|TypeScript]]") + }) +}) diff --git a/src/__tests__/main-url-title-frontmatter.test.ts b/src/__tests__/main-url-title-frontmatter.test.ts index 0a8b7e4..3804c00 100644 --- a/src/__tests__/main-url-title-frontmatter.test.ts +++ b/src/__tests__/main-url-title-frontmatter.test.ts @@ -46,31 +46,6 @@ describe("AutomaticLinkerPlugin URL title frontmatter opt-out", () => { requestMock.mockReset() }) - it("keeps raw URLs when URL title replacement is disabled in frontmatter", async () => { - const { default: AutomaticLinkerPlugin } = await import("../main") - const plugin = new AutomaticLinkerPlugin({} as never, {} as never) - plugin.settings = { - ...DEFAULT_SETTINGS, - formatGitHubURLs: false, - formatJiraURLs: false, - formatLinearURLs: false, - replaceUrlWithTitle: true, - } - ;(plugin as unknown as { urlTitleMap: Map }).urlTitleMap = new Map([ - ["https://example.com", "Example Title"], - ]) - - const result = plugin.modifyLinks( - "---\nautomatic-linker-disable-url-title: true\n---\nhttps://example.com", - "current-file.md", - { "automatic-linker-disable-url-title": true }, - ) - - expect(result).toBe( - "---\nautomatic-linker-disable-url-title: true\n---\nhttps://example.com", - ) - }) - it("does not fetch URL titles when disabled in active file frontmatter", async () => { const { default: AutomaticLinkerPlugin } = await import("../main") const activeFile = new MockTFile("current-file.md") diff --git a/src/formatting-run.ts b/src/formatting-run.ts new file mode 100644 index 0000000..e174277 --- /dev/null +++ b/src/formatting-run.ts @@ -0,0 +1,100 @@ +import { isUrlTitleReplacementOff } from "./frontmatter-utils" +import { + LinkGenerator, + ReplaceLinksSettings, + replaceLinks, +} from "./replace-links/replace-links" +import { replaceUrlWithTitle } from "./replace-url-with-title" +import { formatGitHubURL } from "./replace-urls/github" +import { formatJiraURL } from "./replace-urls/jira" +import { formatLinearURL } from "./replace-urls/linear" +import { replaceURLs } from "./replace-urls/replace-urls" +import { AutomaticLinkerSettings } from "./settings/settings-info" +import { CandidateData, TrieNode } from "./trie" + +export interface CandidateIndex { + trie: TrieNode + candidateMap: Map +} + +export interface FormattingRunOptions { + content: string + filePath: string + contentStart?: number + frontmatter?: Record + settings: AutomaticLinkerSettings + baseDir?: string + candidateIndex?: CandidateIndex + urlTitleMap?: Map + linkGenerator?: LinkGenerator +} + +export const toReplaceLinksSettings = ( + settings: AutomaticLinkerSettings, + baseDir?: string, +): ReplaceLinksSettings => ({ + proximityBasedLinking: settings.proximityBasedLinking, + baseDir, + ignoreDateFormats: settings.ignoreDateFormats, + ignoreCase: settings.ignoreCase, + matchSentenceCase: settings.matchSentenceCase, + preventSelfLinking: settings.preventSelfLinking, + removeAliasInDirs: settings.removeAliasInDirs, + ignoreHeadings: settings.ignoreHeadings, + ignoreMarkdownTables: settings.ignoreMarkdownTables, +}) + +export const formatMarkdownBody = ({ + body, + filePath, + frontmatter, + settings, + baseDir, + candidateIndex, + urlTitleMap = new Map(), + linkGenerator, +}: Omit & { body: string }): string => { + let updatedBody = body + + if (settings.formatGitHubURLs) { + updatedBody = replaceURLs(updatedBody, settings, formatGitHubURL) + } + if (settings.formatJiraURLs) { + updatedBody = replaceURLs(updatedBody, settings, formatJiraURL) + } + if (settings.formatLinearURLs) { + updatedBody = replaceURLs(updatedBody, settings, formatLinearURL) + } + if (settings.replaceUrlWithTitle && !isUrlTitleReplacementOff(frontmatter)) { + updatedBody = replaceUrlWithTitle({ body: updatedBody, urlTitleMap }) + } + if (candidateIndex) { + updatedBody = replaceLinks({ + body: updatedBody, + linkResolverContext: { + filePath: filePath.replace(/\.md$/, ""), + trie: candidateIndex.trie, + candidateMap: candidateIndex.candidateMap, + }, + settings: toReplaceLinksSettings(settings, baseDir), + linkGenerator, + }) + } + + return updatedBody +} + +const inferContentStart = (content: string): number => { + const frontmatter = content.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/) + return frontmatter?.[0].length ?? 0 +} + +export const formatMarkdownDocument = ({ + content, + contentStart = inferContentStart(content), + ...options +}: FormattingRunOptions): string => { + const frontmatterText = content.slice(0, contentStart) + const body = content.slice(contentStart) + return frontmatterText + formatMarkdownBody({ ...options, body }) +} diff --git a/src/main.ts b/src/main.ts index cf806d6..c26e6dc 100644 --- a/src/main.ts +++ b/src/main.ts @@ -11,6 +11,11 @@ import { TFile, } from "obsidian" import { excludeLinks } from "./exclude-links" +import { + formatMarkdownBody, + formatMarkdownDocument, + toReplaceLinksSettings, +} from "./formatting-run" import { isLinkingOff, isLinkingExcluded, @@ -26,13 +31,8 @@ import { LinkGeneratorParams, replaceLinks, } from "./replace-links/replace-links" -import { replaceUrlWithTitle } from "./replace-url-with-title" import { getTitleFromHtml } from "./replace-url-with-title/utils/get-title-from-html" import { listupAllUrls } from "./replace-url-with-title/utils/list-up-all-urls" -import { formatGitHubURL } from "./replace-urls/github" -import { formatJiraURL } from "./replace-urls/jira" -import { formatLinearURL } from "./replace-urls/linear" -import { replaceURLs } from "./replace-urls/replace-urls" import { AutomaticLinkerPluginSettingsTab } from "./settings/settings" import { AutomaticLinkerSettings, @@ -98,35 +98,15 @@ export default class AutomaticLinkerPlugin extends Plugin { filePath: string, frontmatter?: Record, ): string { - if (this.settings.formatGitHubURLs) { - fileContent = replaceURLs(fileContent, this.settings, formatGitHubURL) - } - - if (this.settings.formatJiraURLs) { - fileContent = replaceURLs(fileContent, this.settings, formatJiraURL) - } - - if (this.settings.formatLinearURLs) { - fileContent = replaceURLs( - fileContent, - this.settings, - formatLinearURL, - ) - } - - if ( - this.settings.replaceUrlWithTitle - && !isUrlTitleReplacementOff(frontmatter) - ) { - const { contentStart } = getFrontMatterInfo(fileContent) - const frontmatterText = fileContent.slice(0, contentStart) - const body = fileContent.slice(contentStart) - const updatedBody = replaceUrlWithTitle({ body, urlTitleMap: this.urlTitleMap }) - fileContent = frontmatterText + updatedBody - } - if (!this.trie || !this.candidateMap) { - return fileContent + return formatMarkdownDocument({ + content: fileContent, + filePath, + contentStart: getFrontMatterInfo(fileContent).contentStart, + frontmatter, + settings: this.settings, + urlTitleMap: this.urlTitleMap, + }) } if (this.settings.debug) { @@ -136,31 +116,21 @@ export default class AutomaticLinkerPlugin extends Plugin { new Notice(`Automatic Linker: ${new Date().toISOString()} modifyLinks started.`) } - const { contentStart } = getFrontMatterInfo(fileContent) - const frontmatterText = fileContent.slice(0, contentStart) - const linkGenerator = this.createLinkGenerator(filePath) const baseDir = this.settings.respectNewFileFolderPath ? this.app.vault.getConfig("newFileFolderPath") : undefined - const updatedBody = replaceLinks({ - body: fileContent.slice(contentStart), - linkResolverContext: { - filePath: filePath.replace(/\.md$/, ""), - trie: this.trie, - candidateMap: this.candidateMap, - }, - settings: { - proximityBasedLinking: this.settings.proximityBasedLinking, - baseDir, - ignoreDateFormats: this.settings.ignoreDateFormats, - ignoreCase: this.settings.ignoreCase, - matchSentenceCase: this.settings.matchSentenceCase, - preventSelfLinking: this.settings.preventSelfLinking, - removeAliasInDirs: this.settings.removeAliasInDirs, - ignoreHeadings: this.settings.ignoreHeadings, - ignoreMarkdownTables: this.settings.ignoreMarkdownTables, - }, - linkGenerator, + const candidateIndex = this.trie && this.candidateMap + ? { trie: this.trie, candidateMap: this.candidateMap } + : undefined + fileContent = formatMarkdownDocument({ + content: fileContent, + filePath, + contentStart: getFrontMatterInfo(fileContent).contentStart, + frontmatter, + settings: this.settings, + baseDir, + candidateIndex, + urlTitleMap: this.urlTitleMap, + linkGenerator: candidateIndex ? this.createLinkGenerator(filePath) : undefined, }) - fileContent = frontmatterText + updatedBody if (this.settings.debug) { console.log(new Date().toISOString(), "modifyLinks finished") @@ -263,24 +233,15 @@ export default class AutomaticLinkerPlugin extends Plugin { const linkGenerator = this.createLinkGenerator(activeFile.path) const baseDir = this.settings.respectNewFileFolderPath ? this.app.vault.getConfig("newFileFolderPath") : undefined - const updatedText = replaceLinks({ + const updatedText = formatMarkdownBody({ body: selectedText, - linkResolverContext: { - filePath: activeFile.path.replace(/\.md$/, ""), + filePath: activeFile.path, + settings: this.settings, + baseDir, + candidateIndex: { trie: this.trie, candidateMap: this.candidateMap, }, - settings: { - proximityBasedLinking: this.settings.proximityBasedLinking, - baseDir, - ignoreDateFormats: this.settings.ignoreDateFormats, - ignoreCase: this.settings.ignoreCase, - matchSentenceCase: this.settings.matchSentenceCase, - preventSelfLinking: this.settings.preventSelfLinking, - removeAliasInDirs: this.settings.removeAliasInDirs, - ignoreHeadings: this.settings.ignoreHeadings, - ignoreMarkdownTables: this.settings.ignoreMarkdownTables, - }, linkGenerator, }) cm.replaceSelection(updatedText) @@ -517,6 +478,9 @@ export default class AutomaticLinkerPlugin extends Plugin { const { contentStart } = getFrontMatterInfo(fileContent) const body = fileContent.slice(contentStart) const normalizedActiveFilePath = activeFile.path.replace(/\.md$/, "") + const baseDir = this.settings.respectNewFileFolderPath + ? this.app.vault.getConfig("newFileFolderPath") + : undefined if (!this.candidateMap || !this.trie) { this.refreshFileDataAndTrie() @@ -542,7 +506,10 @@ export default class AutomaticLinkerPlugin extends Plugin { trie: this.trie, candidateMap: this.candidateMap, }, - settings: this.settings, + settings: toReplaceLinksSettings( + this.settings, + baseDir, + ), resolvedAmbiguities: resolvedAmbiguitiesResult, }) From 6585b7393a11ef308e1ba8f52ddbf6cf14e2a0d2 Mon Sep 17 00:00:00 2001 From: Kodai Nakamura Date: Mon, 22 Jun 2026 02:20:28 +0900 Subject: [PATCH 12/25] fix(formatting): restore selection-only link replacement Why: - The selection command regressed by inheriting document-level URL formatting from the shared body formatter. - That changed user-visible output for selected text containing GitHub, Jira, or Linear URLs. What: - Added a selection-only formatter that projects link replacement settings and calls `replaceLinks(...)` directly. - Switched `mofifyLinksSelection()` to the new helper so document formatting stays unchanged. - Added regression coverage for selection-only behavior and documented the fix in the task report. --- .superpowers/sdd/task-2-report.md | 46 ++++++++++++++++++++ src/__tests__/formatting-run.test.ts | 32 ++++++++++++++ src/__tests__/main-link-generator.test.ts | 52 +++++++++++++++++++++++ src/formatting-run.ts | 24 +++++++++++ src/main.ts | 4 +- 5 files changed, 156 insertions(+), 2 deletions(-) diff --git a/.superpowers/sdd/task-2-report.md b/.superpowers/sdd/task-2-report.md index 6fbca3a..22b14f2 100644 --- a/.superpowers/sdd/task-2-report.md +++ b/.superpowers/sdd/task-2-report.md @@ -83,3 +83,49 @@ Results: ## Concerns None. + +## Fix: Selection Formatting Regression + +Selection formatting was accidentally routed through `formatMarkdownBody(...)`, which let URL-formatting behavior leak into the selection command. + +### RED + +Command: + +```bash +npx vitest run src/__tests__/main-link-generator.test.ts +``` + +Result: + +- Failed as expected +- Failure showed `mofifyLinksSelection()` was formatting a GitHub URL instead of leaving it unchanged: + `[[notes/TypeScript|TypeScript]] [[github/openai/openai/issues/1]] [🔗](https://github.com/openai/openai/issues/1)` + +### GREEN + +Changes: + +- Added `formatMarkdownSelection(...)` in `src/formatting-run.ts` +- Updated `mofifyLinksSelection()` in `src/main.ts` to call the selection-only helper +- Added a unit guard in `src/__tests__/formatting-run.test.ts` + +### Verification + +Commands: + +```bash +npx vitest run src/__tests__/formatting-run.test.ts src/__tests__/main-link-generator.test.ts +npm run tsc +npm run lint +``` + +Results: + +- `npx vitest run src/__tests__/formatting-run.test.ts src/__tests__/main-link-generator.test.ts`: passed `2` files, `7` tests +- `npm run tsc`: exit `0` +- `npm run lint`: exit `0` + +### Concerns + +- Selection formatting now has its own helper; any future selection-specific behavior should be added there, not to `formatMarkdownBody(...)`. diff --git a/src/__tests__/formatting-run.test.ts b/src/__tests__/formatting-run.test.ts index 323f1a6..21d87c2 100644 --- a/src/__tests__/formatting-run.test.ts +++ b/src/__tests__/formatting-run.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest" import { formatMarkdownBody, formatMarkdownDocument, + formatMarkdownSelection, toReplaceLinksSettings, } from "../formatting-run" import { buildCandidateTrieForTest } from "../replace-links/__tests__/test-helpers" @@ -111,3 +112,34 @@ describe("formatMarkdownBody", () => { expect(result).toBe("[[notes/TypeScript|TypeScript]]") }) }) + +describe("formatMarkdownSelection", () => { + it("keeps selection formatting to link replacement only", () => { + const { candidateMap, trie } = buildCandidateTrieForTest({ + files: [{ path: "notes/TypeScript" }], + settings: { + scoped: false, + baseDir: undefined, + ignoreCase: true, + }, + }) + + const result = formatMarkdownSelection({ + body: "TypeScript https://github.com/openai/openai/issues/1", + filePath: "current-file.md", + settings: { + ...DEFAULT_SETTINGS, + formatGitHubURLs: true, + formatJiraURLs: true, + formatLinearURLs: true, + replaceUrlWithTitle: true, + ignoreCase: true, + }, + candidateIndex: { candidateMap, trie }, + }) + + expect(result).toBe( + "[[notes/TypeScript|TypeScript]] https://github.com/openai/openai/issues/1", + ) + }) +}) diff --git a/src/__tests__/main-link-generator.test.ts b/src/__tests__/main-link-generator.test.ts index 807d9b6..2408145 100644 --- a/src/__tests__/main-link-generator.test.ts +++ b/src/__tests__/main-link-generator.test.ts @@ -76,4 +76,56 @@ describe("AutomaticLinkerPlugin link generator", () => { expect(result).toBe("| [[notes/foo\\|bar]] | x |\n| --- | --- |\n") }) + + it("keeps selection formatting to link replacement only", async () => { + const { default: AutomaticLinkerPlugin } = await import("../main") + const settings = { + scoped: false, + baseDir: undefined, + ignoreCase: true, + } + const { candidateMap, trie } = buildCandidateTrieForTest({ + files: [{ path: "notes/TypeScript" }], + settings, + }) + const replaceSelection = vi.fn() + const app = { + workspace: { + getActiveFile: vi.fn(() => ({ path: "current-file.md" })), + activeEditor: { + editor: { + getSelection: vi.fn(() => "TypeScript https://github.com/openai/openai/issues/1"), + replaceSelection, + }, + }, + }, + vault: { + getConfig: vi.fn(() => "pages"), + getAbstractFileByPath: vi.fn((path: string) => { + if (path === "notes/TypeScript.md") return { path: "notes/TypeScript.md" } + return null + }), + }, + fileManager: { + generateMarkdownLink: vi.fn(() => "[[notes/TypeScript|TypeScript]]"), + }, + } + const plugin = new AutomaticLinkerPlugin(app as never, {} as never) + plugin.settings = { + ...DEFAULT_SETTINGS, + formatGitHubURLs: true, + formatJiraURLs: true, + formatLinearURLs: true, + replaceUrlWithTitle: true, + respectNewFileFolderPath: true, + } + ;(plugin as unknown as { trie: typeof trie }).trie = trie + ;(plugin as unknown as { candidateMap: typeof candidateMap }).candidateMap = candidateMap + + await plugin.mofifyLinksSelection() + + expect(replaceSelection).toHaveBeenCalledWith( + "[[notes/TypeScript|TypeScript]] https://github.com/openai/openai/issues/1", + ) + }) }) diff --git a/src/formatting-run.ts b/src/formatting-run.ts index e174277..a3e9320 100644 --- a/src/formatting-run.ts +++ b/src/formatting-run.ts @@ -84,6 +84,30 @@ export const formatMarkdownBody = ({ return updatedBody } +export const formatMarkdownSelection = ({ + body, + filePath, + settings, + baseDir, + candidateIndex, + linkGenerator, +}: Omit & { body: string }): string => { + if (!candidateIndex) { + return body + } + + return replaceLinks({ + body, + linkResolverContext: { + filePath: filePath.replace(/\.md$/, ""), + trie: candidateIndex.trie, + candidateMap: candidateIndex.candidateMap, + }, + settings: toReplaceLinksSettings(settings, baseDir), + linkGenerator, + }) +} + const inferContentStart = (content: string): number => { const frontmatter = content.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/) return frontmatter?.[0].length ?? 0 diff --git a/src/main.ts b/src/main.ts index c26e6dc..5e649cb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -12,8 +12,8 @@ import { } from "obsidian" import { excludeLinks } from "./exclude-links" import { - formatMarkdownBody, formatMarkdownDocument, + formatMarkdownSelection, toReplaceLinksSettings, } from "./formatting-run" import { @@ -233,7 +233,7 @@ export default class AutomaticLinkerPlugin extends Plugin { const linkGenerator = this.createLinkGenerator(activeFile.path) const baseDir = this.settings.respectNewFileFolderPath ? this.app.vault.getConfig("newFileFolderPath") : undefined - const updatedText = formatMarkdownBody({ + const updatedText = formatMarkdownSelection({ body: selectedText, filePath: activeFile.path, settings: this.settings, From 9098a480538618f124272ffab8a15cc8b5f8dd94 Mon Sep 17 00:00:00 2001 From: Kodai Nakamura Date: Mon, 22 Jun 2026 02:26:52 +0900 Subject: [PATCH 13/25] fix: preserve frontmatter URL formatting Why: The Task 2 refactor split frontmatter before URL rewriting, which left GitHub/Jira/Linear URLs in YAML raw even though document formatting previously handled them. What: Add a regression test for frontmatter URL formatting with body-only title/link behavior, factor URL rewriting into a shared helper, and apply it to the original frontmatter slice before body-only formatting. --- src/__tests__/formatting-run.test.ts | 33 ++++++++++++++++++++++++++ src/formatting-run.ts | 35 +++++++++++++++++++--------- 2 files changed, 57 insertions(+), 11 deletions(-) diff --git a/src/__tests__/formatting-run.test.ts b/src/__tests__/formatting-run.test.ts index 21d87c2..bd89042 100644 --- a/src/__tests__/formatting-run.test.ts +++ b/src/__tests__/formatting-run.test.ts @@ -55,6 +55,39 @@ describe("formatMarkdownDocument", () => { ) }) + it("formats GitHub URLs in frontmatter without moving body-only formatting into frontmatter", () => { + const { candidateMap, trie } = buildCandidateTrieForTest({ + files: [{ path: "notes/TypeScript" }], + settings: { + scoped: false, + baseDir: undefined, + ignoreCase: true, + }, + }) + + const result = formatMarkdownDocument({ + content: + "---\nautomatic-linker-disable-url-title: true\nreference: https://github.com/openai/openai/issues/1\nterm: TypeScript\n---\nTypeScript https://example.com", + filePath: "current-file.md", + frontmatter: { + "automatic-linker-disable-url-title": true, + }, + settings: { + ...DEFAULT_SETTINGS, + formatGitHubURLs: true, + formatJiraURLs: false, + formatLinearURLs: false, + replaceUrlWithTitle: true, + ignoreCase: true, + }, + candidateIndex: { candidateMap, trie }, + }) + + expect(result).toBe( + "---\nautomatic-linker-disable-url-title: true\nreference: [[github/openai/openai/issues/1]] [🔗](https://github.com/openai/openai/issues/1)\nterm: TypeScript\n---\n[[notes/TypeScript|TypeScript]] https://example.com", + ) + }) + it("runs URL formatting, URL titles, and link replacement in order", () => { const { candidateMap, trie } = buildCandidateTrieForTest({ files: [{ path: "notes/TypeScript" }], diff --git a/src/formatting-run.ts b/src/formatting-run.ts index a3e9320..1b1a040 100644 --- a/src/formatting-run.ts +++ b/src/formatting-run.ts @@ -44,6 +44,25 @@ export const toReplaceLinksSettings = ( ignoreMarkdownTables: settings.ignoreMarkdownTables, }) +const formatMarkdownURLs = ( + text: string, + settings: AutomaticLinkerSettings, +): string => { + let updatedText = text + + if (settings.formatGitHubURLs) { + updatedText = replaceURLs(updatedText, settings, formatGitHubURL) + } + if (settings.formatJiraURLs) { + updatedText = replaceURLs(updatedText, settings, formatJiraURL) + } + if (settings.formatLinearURLs) { + updatedText = replaceURLs(updatedText, settings, formatLinearURL) + } + + return updatedText +} + export const formatMarkdownBody = ({ body, filePath, @@ -54,17 +73,8 @@ export const formatMarkdownBody = ({ urlTitleMap = new Map(), linkGenerator, }: Omit & { body: string }): string => { - let updatedBody = body + let updatedBody = formatMarkdownURLs(body, settings) - if (settings.formatGitHubURLs) { - updatedBody = replaceURLs(updatedBody, settings, formatGitHubURL) - } - if (settings.formatJiraURLs) { - updatedBody = replaceURLs(updatedBody, settings, formatJiraURL) - } - if (settings.formatLinearURLs) { - updatedBody = replaceURLs(updatedBody, settings, formatLinearURL) - } if (settings.replaceUrlWithTitle && !isUrlTitleReplacementOff(frontmatter)) { updatedBody = replaceUrlWithTitle({ body: updatedBody, urlTitleMap }) } @@ -118,7 +128,10 @@ export const formatMarkdownDocument = ({ contentStart = inferContentStart(content), ...options }: FormattingRunOptions): string => { - const frontmatterText = content.slice(0, contentStart) + const frontmatterText = formatMarkdownURLs( + content.slice(0, contentStart), + options.settings, + ) const body = content.slice(contentStart) return frontmatterText + formatMarkdownBody({ ...options, body }) } From c6babf82ad1f48401639ed90dbf276dcac0eca0d Mon Sep 17 00:00:00 2001 From: Kodai Nakamura Date: Mon, 22 Jun 2026 02:30:15 +0900 Subject: [PATCH 14/25] chore: keep sdd reports untracked Why: - SDD report files are local coordination artifacts and should not be part of the repository tree. What: - Remove the accidentally tracked Task 2 report while leaving ignored local SDD files available for this session. --- .superpowers/sdd/task-2-report.md | 131 ------------------------------ 1 file changed, 131 deletions(-) delete mode 100644 .superpowers/sdd/task-2-report.md diff --git a/.superpowers/sdd/task-2-report.md b/.superpowers/sdd/task-2-report.md deleted file mode 100644 index 22b14f2..0000000 --- a/.superpowers/sdd/task-2-report.md +++ /dev/null @@ -1,131 +0,0 @@ -# Task 2 Report: Formatting Run Module - -## Scope - -Implemented Task 2 only: - -- Added `src/formatting-run.ts` -- Added `src/__tests__/formatting-run.test.ts` -- Updated `src/main.ts` to route document and selection formatting through the new module -- Reduced duplicated adapter-level URL-title/frontmatter coverage in `src/__tests__/main-url-title-frontmatter.test.ts` - -Preserved Task 1 behavior: - -- `resolveAmbiguities()` still receives the normalized current file path -- AI command still renders via `replaceLinks(...)` -- AI command now uses `toReplaceLinksSettings(this.settings, baseDir)` -- Link replacement still normalizes source file paths by removing a trailing `.md` - -## TDD Evidence - -### RED - -Command: - -```bash -npx vitest run src/__tests__/formatting-run.test.ts -``` - -Result: - -- Failed as expected -- Error: `Cannot find module '../formatting-run'` - -### GREEN - -Command: - -```bash -npx vitest run src/__tests__/formatting-run.test.ts -``` - -Result: - -- Passed: `4` tests in `src/__tests__/formatting-run.test.ts` - -## Focused Verification - -Command: - -```bash -npx vitest run src/__tests__/formatting-run.test.ts src/__tests__/main-url-title-frontmatter.test.ts src/__tests__/main-link-generator.test.ts -``` - -Result: - -- Passed: `3` test files, `6` tests - -## Full Stage Verification - -Commands: - -```bash -npm run test -- --reporter=dot -npm run tsc -npm run lint -``` - -Results: - -- `npm run test -- --reporter=dot`: passed `39` test files, `334` tests -- `npm run tsc`: exit `0` -- `npm run lint`: exit `0` - -## Implementation Notes - -- `formatMarkdownDocument(...)` owns frontmatter splitting and delegates body formatting -- `formatMarkdownBody(...)` sequences GitHub/Jira/Linear URL formatting, URL-title replacement, then link replacement -- `toReplaceLinksSettings(...)` projects only the settings required by `replaceLinks(...)` and carries `baseDir` -- `modifyLinks(...)` now uses the pure formatter for both indexed and non-indexed runs -- `mofifyLinksSelection(...)` now uses `formatMarkdownBody(...)` -- Adapter-only URL-title fetch opt-out coverage remains in `main-url-title-frontmatter.test.ts` - -## Concerns - -None. - -## Fix: Selection Formatting Regression - -Selection formatting was accidentally routed through `formatMarkdownBody(...)`, which let URL-formatting behavior leak into the selection command. - -### RED - -Command: - -```bash -npx vitest run src/__tests__/main-link-generator.test.ts -``` - -Result: - -- Failed as expected -- Failure showed `mofifyLinksSelection()` was formatting a GitHub URL instead of leaving it unchanged: - `[[notes/TypeScript|TypeScript]] [[github/openai/openai/issues/1]] [🔗](https://github.com/openai/openai/issues/1)` - -### GREEN - -Changes: - -- Added `formatMarkdownSelection(...)` in `src/formatting-run.ts` -- Updated `mofifyLinksSelection()` in `src/main.ts` to call the selection-only helper -- Added a unit guard in `src/__tests__/formatting-run.test.ts` - -### Verification - -Commands: - -```bash -npx vitest run src/__tests__/formatting-run.test.ts src/__tests__/main-link-generator.test.ts -npm run tsc -npm run lint -``` - -Results: - -- `npx vitest run src/__tests__/formatting-run.test.ts src/__tests__/main-link-generator.test.ts`: passed `2` files, `7` tests -- `npm run tsc`: exit `0` -- `npm run lint`: exit `0` - -### Concerns - -- Selection formatting now has its own helper; any future selection-specific behavior should be added there, not to `formatMarkdownBody(...)`. From 36c6f451285fff65f8d7da556944d1649a35448c Mon Sep 17 00:00:00 2001 From: Kodai Nakamura Date: Mon, 22 Jun 2026 02:37:49 +0900 Subject: [PATCH 15/25] refactor(settings): centralize settings catalog Why: - Adding or changing a setting required parallel edits across defaults, projections, refresh behavior, and UI rendering. - Task 3 needs one canonical catalog while preserving the existing Task 2 formatting behavior and current settings side effects. What: - Add a settings catalog module with defaults, UI metadata, index-refresh flags, and projection helpers. - Re-export settings info compatibility imports and reuse the catalog projection from formatting-run. - Render the settings tab from catalog entries while preserving labels, order, validation, and URL-title refresh behavior. --- src/formatting-run.ts | 25 +- .../__tests__/settings-catalog.test.ts | 73 +++ src/settings/settings-catalog.ts | 380 +++++++++++ src/settings/settings-info.ts | 63 +- src/settings/settings.ts | 595 +++--------------- 5 files changed, 566 insertions(+), 570 deletions(-) create mode 100644 src/settings/__tests__/settings-catalog.test.ts create mode 100644 src/settings/settings-catalog.ts diff --git a/src/formatting-run.ts b/src/formatting-run.ts index 1b1a040..4bc7c5e 100644 --- a/src/formatting-run.ts +++ b/src/formatting-run.ts @@ -1,7 +1,6 @@ import { isUrlTitleReplacementOff } from "./frontmatter-utils" import { LinkGenerator, - ReplaceLinksSettings, replaceLinks, } from "./replace-links/replace-links" import { replaceUrlWithTitle } from "./replace-url-with-title" @@ -9,7 +8,10 @@ import { formatGitHubURL } from "./replace-urls/github" import { formatJiraURL } from "./replace-urls/jira" import { formatLinearURL } from "./replace-urls/linear" import { replaceURLs } from "./replace-urls/replace-urls" -import { AutomaticLinkerSettings } from "./settings/settings-info" +import { + AutomaticLinkerSettings, + projectReplaceLinksSettings, +} from "./settings/settings-catalog" import { CandidateData, TrieNode } from "./trie" export interface CandidateIndex { @@ -29,20 +31,7 @@ export interface FormattingRunOptions { linkGenerator?: LinkGenerator } -export const toReplaceLinksSettings = ( - settings: AutomaticLinkerSettings, - baseDir?: string, -): ReplaceLinksSettings => ({ - proximityBasedLinking: settings.proximityBasedLinking, - baseDir, - ignoreDateFormats: settings.ignoreDateFormats, - ignoreCase: settings.ignoreCase, - matchSentenceCase: settings.matchSentenceCase, - preventSelfLinking: settings.preventSelfLinking, - removeAliasInDirs: settings.removeAliasInDirs, - ignoreHeadings: settings.ignoreHeadings, - ignoreMarkdownTables: settings.ignoreMarkdownTables, -}) +export { projectReplaceLinksSettings as toReplaceLinksSettings } from "./settings/settings-catalog" const formatMarkdownURLs = ( text: string, @@ -86,7 +75,7 @@ export const formatMarkdownBody = ({ trie: candidateIndex.trie, candidateMap: candidateIndex.candidateMap, }, - settings: toReplaceLinksSettings(settings, baseDir), + settings: projectReplaceLinksSettings(settings, baseDir), linkGenerator, }) } @@ -113,7 +102,7 @@ export const formatMarkdownSelection = ({ trie: candidateIndex.trie, candidateMap: candidateIndex.candidateMap, }, - settings: toReplaceLinksSettings(settings, baseDir), + settings: projectReplaceLinksSettings(settings, baseDir), linkGenerator, }) } diff --git a/src/settings/__tests__/settings-catalog.test.ts b/src/settings/__tests__/settings-catalog.test.ts new file mode 100644 index 0000000..f6dd91e --- /dev/null +++ b/src/settings/__tests__/settings-catalog.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest" +import { + DEFAULT_SETTINGS, + SETTINGS_CATALOG, + projectReplaceLinksSettings, + projectUrlFormattingSettings, + settingRefreshesIndex, +} from "../settings-catalog" + +describe("SETTINGS_CATALOG", () => { + it("covers every default setting exactly once", () => { + const defaultKeys = Object.keys(DEFAULT_SETTINGS).sort() + const catalogKeys = SETTINGS_CATALOG.map(entry => entry.key).sort() + + expect(catalogKeys).toEqual(defaultKeys) + expect(new Set(catalogKeys).size).toBe(catalogKeys.length) + }) + + it("marks current index-refresh settings", () => { + expect(settingRefreshesIndex("respectNewFileFolderPath")).toBe(true) + expect(settingRefreshesIndex("includeAliases")).toBe(true) + expect(settingRefreshesIndex("proximityBasedLinking")).toBe(true) + expect(settingRefreshesIndex("ignoreDateFormats")).toBe(true) + expect(settingRefreshesIndex("ignoreCase")).toBe(true) + expect(settingRefreshesIndex("preventSelfLinking")).toBe(true) + expect(settingRefreshesIndex("excludeDirsFromAutoLinking")).toBe(true) + expect(settingRefreshesIndex("removeAliasInDirs")).toBe(true) + expect(settingRefreshesIndex("debug")).toBe(false) + }) +}) + +describe("settings projections", () => { + it("projects link replacement settings", () => { + expect(projectReplaceLinksSettings({ + ...DEFAULT_SETTINGS, + proximityBasedLinking: false, + ignoreDateFormats: false, + ignoreCase: false, + matchSentenceCase: false, + preventSelfLinking: true, + removeAliasInDirs: ["archive"], + ignoreHeadings: true, + ignoreMarkdownTables: true, + }, "pages")).toEqual({ + proximityBasedLinking: false, + baseDir: "pages", + ignoreDateFormats: false, + ignoreCase: false, + matchSentenceCase: false, + preventSelfLinking: true, + removeAliasInDirs: ["archive"], + ignoreHeadings: true, + ignoreMarkdownTables: true, + }) + }) + + it("projects URL formatting settings", () => { + expect(projectUrlFormattingSettings({ + ...DEFAULT_SETTINGS, + formatGitHubURLs: false, + githubEnterpriseURLs: ["github.enterprise.com"], + formatJiraURLs: false, + jiraURLs: ["jira.example.com"], + formatLinearURLs: true, + })).toEqual({ + formatGitHubURLs: false, + githubEnterpriseURLs: ["github.enterprise.com"], + formatJiraURLs: false, + jiraURLs: ["jira.example.com"], + formatLinearURLs: true, + }) + }) +}) diff --git a/src/settings/settings-catalog.ts b/src/settings/settings-catalog.ts new file mode 100644 index 0000000..d828502 --- /dev/null +++ b/src/settings/settings-catalog.ts @@ -0,0 +1,380 @@ +import { ReplaceLinksSettings } from "../replace-links/replace-links" + +export type AutomaticLinkerSettings = { + formatOnSave: boolean + showNotice: boolean + respectNewFileFolderPath: boolean + includeAliases: boolean + proximityBasedLinking: boolean + ignoreDateFormats: boolean + ignoreHeadings: boolean + formatGitHubURLs: boolean + githubEnterpriseURLs: string[] + formatJiraURLs: boolean + jiraURLs: string[] + formatLinearURLs: boolean + debug: boolean + ignoreCase: boolean + matchSentenceCase: boolean + replaceUrlWithTitle: boolean + replaceUrlWithTitleIgnoreDomains: string[] + excludeDirsFromAutoLinking: string[] + preventSelfLinking: boolean + removeAliasInDirs: string[] + ignoreMarkdownTables: boolean + runLinterAfterFormatting: boolean + runPrettierAfterFormatting: boolean + formatDelayMs: number + aiEnabled: boolean + aiEndpoint: string + aiModel: string + aiMaxContext: number +} + +export type SettingControl = "toggle" | "text" | "textarea" + +export interface SettingCatalogEntry { + key: K + group: string + name: string + description: string + control: SettingControl + placeholder?: string + multiline?: boolean + refreshesIndex: boolean + runtimeOnly?: boolean +} + +export const DEFAULT_SETTINGS: AutomaticLinkerSettings = { + formatOnSave: false, + showNotice: false, + respectNewFileFolderPath: true, + includeAliases: true, + proximityBasedLinking: true, + ignoreDateFormats: true, + ignoreHeadings: false, + formatGitHubURLs: true, + githubEnterpriseURLs: [], + formatJiraURLs: true, + jiraURLs: [], + formatLinearURLs: false, + debug: false, + ignoreCase: true, + matchSentenceCase: true, + replaceUrlWithTitle: true, + replaceUrlWithTitleIgnoreDomains: [], + excludeDirsFromAutoLinking: [], + preventSelfLinking: false, + removeAliasInDirs: [], + ignoreMarkdownTables: false, + runLinterAfterFormatting: false, + runPrettierAfterFormatting: false, + formatDelayMs: 1, + aiEnabled: false, + aiEndpoint: "http://localhost:1234/v1", + aiModel: "gemma-4-7b", + aiMaxContext: 500, +} + +export const SETTINGS_CATALOG = [ + { + key: "formatOnSave", + group: "Formatting", + name: "Format on save", + description: + "When enabled, the file will be automatically formatted (links replaced) when saving.", + control: "toggle", + refreshesIndex: false, + }, + { + key: "respectNewFileFolderPath", + group: "Formatting", + name: "Respect 'Folder to create new notes in' setting", + description: + "When enabled, the plugin will use Obsidian's 'Folder to create new notes in' setting as the base directory for omitting folder prefixes in links.", + control: "toggle", + refreshesIndex: true, + }, + { + key: "includeAliases", + group: "Formatting", + name: "Include aliases", + description: + "When enabled, aliases will be included when processing links. Note: A restart is required for changes to take effect.", + control: "toggle", + refreshesIndex: true, + }, + { + key: "proximityBasedLinking", + group: "Formatting", + name: "Proximity-based linking", + description: + "When enabled, the plugin will automatically resolve namespaces for shorthand links. If multiple candidates share the same shorthand, the candidate with the most common path segments relative to the current file will be selected.", + control: "toggle", + refreshesIndex: true, + }, + { + key: "ignoreDateFormats", + group: "Formatting", + name: "Ignore date formats", + description: + "When enabled, links that match date formats (e.g. 2025-02-10) will be ignored. This helps maintain compatibility with Obsidian Tasks.", + control: "toggle", + refreshesIndex: true, + }, + { + key: "ignoreHeadings", + group: "Formatting", + name: "Ignore headings", + description: + "When enabled, headings (lines starting with #) will not have links added to them.", + control: "toggle", + refreshesIndex: false, + }, + { + key: "ignoreMarkdownTables", + group: "Formatting", + name: "Ignore Markdown tables", + description: + "When enabled, Markdown table rows will not have links added to them.", + control: "toggle", + refreshesIndex: false, + }, + { + key: "ignoreCase", + group: "Formatting", + name: "Ignore case", + description: + "When enabled, link matching will be case-insensitive. The original case of the text will be preserved in the link.", + control: "toggle", + refreshesIndex: true, + }, + { + key: "matchSentenceCase", + group: "Formatting", + name: "Match sentence case", + description: + "When 'Ignore case' is OFF, match text that is capitalized at the start of a sentence. For example, 'My name' at a sentence start will match the file 'my name'. This setting is ignored when 'Ignore case' is ON.", + control: "toggle", + refreshesIndex: false, + }, + { + key: "preventSelfLinking", + group: "Formatting", + name: "Prevent self-linking", + description: + "When enabled, text will not be linked to its own file. For example, the word 'VPN' inside VPN.md will not be converted to a link.", + control: "toggle", + refreshesIndex: true, + }, + { + key: "excludeDirsFromAutoLinking", + group: "Formatting", + name: "Exclude directories from automatic linking", + description: + "Directories to be excluded from automatic linking, one per line (e.g. 'Templates')", + control: "textarea", + placeholder: "Templates\nArchive", + multiline: true, + refreshesIndex: true, + }, + { + key: "removeAliasInDirs", + group: "Formatting", + name: "Remove aliases in directories", + description: + "Directories where link aliases should be removed, one per line (e.g. 'dir' will convert [[dir/xxx|yyy]] to [[dir/xxx]]). This affects both auto-generated aliases and frontmatter aliases.", + control: "textarea", + placeholder: "dir1\ndir2/subdir", + multiline: true, + refreshesIndex: true, + }, + { + key: "runLinterAfterFormatting", + group: "Integrations", + name: "Run Obsidian Linter after formatting", + description: + "When enabled, Obsidian Linter will be executed after Automatic Linker formatting. This requires the Obsidian Linter plugin to be installed.", + control: "toggle", + refreshesIndex: false, + }, + { + key: "runPrettierAfterFormatting", + group: "Integrations", + name: "Run Prettier after formatting", + description: + "When enabled, Prettier will be executed after Automatic Linker formatting. This requires prettier-format plugin to be installed. https://github.com/dylanarmstrong/obsidian-prettier-plugin", + control: "toggle", + refreshesIndex: false, + }, + { + key: "formatDelayMs", + group: "Integrations", + name: "Format delay (ms)", + description: + "Delay in milliseconds before formatting. Increase this value if the linter/prettier runs before the file is fully saved.", + control: "text", + placeholder: "e.g. 100", + refreshesIndex: false, + }, + { + key: "replaceUrlWithTitle", + group: "URL Replacement with Title", + name: "Replace URL with title", + description: + "When enabled, raw URLs will be replaced with [Page Title](URL). Requires fetching the URL content.", + control: "toggle", + refreshesIndex: false, + }, + { + key: "replaceUrlWithTitleIgnoreDomains", + group: "URL Replacement with Title", + name: "Ignore domains", + description: + "Ignore domains for replacing URLs with titles, one per line (e.g. x.com)", + control: "textarea", + placeholder: "", + multiline: true, + refreshesIndex: false, + }, + { + key: "formatGitHubURLs", + group: "URL Formatting for GitHub", + name: "Format GitHub URLs on save", + description: + "When enabled, GitHub URLs will be formatted when saving the file.", + control: "toggle", + refreshesIndex: false, + }, + { + key: "githubEnterpriseURLs", + group: "URL Formatting for GitHub", + name: "GitHub Enterprise URLs", + description: + "Add your GitHub Enterprise URLs, one per line (e.g. github.enterprise.com)", + control: "textarea", + placeholder: "github.enterprise.com\ngithub.company.com", + multiline: true, + refreshesIndex: false, + }, + { + key: "formatJiraURLs", + group: "URL Formatting for Jira", + name: "Format JIRA URLs on save", + description: + "When enabled, JIRA URLs will be formatted when saving the file.", + control: "toggle", + refreshesIndex: false, + }, + { + key: "jiraURLs", + group: "URL Formatting for Jira", + name: "JIRA URLs", + description: + "Add your JIRA URLs, one per line (e.g. jira.enterprise.com)", + control: "textarea", + placeholder: "jira.enterprise.com\njira.company.com", + multiline: true, + refreshesIndex: false, + }, + { + key: "formatLinearURLs", + group: "URL Formatting for Linear", + name: "Format Linear URLs on save", + description: + "When enabled, Linear URLs will be formatted when saving the file.", + control: "toggle", + refreshesIndex: false, + }, + { + key: "showNotice", + group: "Debug", + name: "Show load notice", + description: "Display a notice when markdown files are loaded.", + control: "toggle", + refreshesIndex: false, + }, + { + key: "debug", + group: "Debug", + name: "Debug mode", + description: + "When enabled, debug information will be logged to the console.", + control: "toggle", + refreshesIndex: false, + }, + { + key: "aiEnabled", + group: "AI Link Enhancement (Beta)", + name: "Enable AI Link Enhancement", + description: + "When enabled, an AI-powered link enhancer command will be available. It uses a local LLM to resolve ambiguous links and correct existing ones.", + control: "toggle", + refreshesIndex: false, + }, + { + key: "aiEndpoint", + group: "AI Link Enhancement (Beta)", + name: "AI API Endpoint", + description: + "The URL of your OpenAI-compatible AI server (e.g. LM Studio, Ollama).", + control: "text", + placeholder: "http://localhost:1234/v1", + refreshesIndex: false, + }, + { + key: "aiModel", + group: "AI Link Enhancement (Beta)", + name: "AI Model", + description: "The name of the model to use (e.g. gemma-4-7b).", + control: "text", + placeholder: "gemma-4-7b", + refreshesIndex: false, + }, + { + key: "aiMaxContext", + group: "AI Link Enhancement (Beta)", + name: "Max Context Length", + description: + "Number of characters around the link to provide as context to the AI.", + control: "text", + placeholder: "500", + refreshesIndex: false, + }, +] as const satisfies readonly SettingCatalogEntry[] + +export const settingRefreshesIndex = ( + key: keyof AutomaticLinkerSettings, +): boolean => SETTINGS_CATALOG.find(entry => entry.key === key)?.refreshesIndex ?? false + +export const projectReplaceLinksSettings = ( + settings: AutomaticLinkerSettings, + baseDir?: string, +): ReplaceLinksSettings => ({ + proximityBasedLinking: settings.proximityBasedLinking, + baseDir, + ignoreDateFormats: settings.ignoreDateFormats, + ignoreCase: settings.ignoreCase, + matchSentenceCase: settings.matchSentenceCase, + preventSelfLinking: settings.preventSelfLinking, + removeAliasInDirs: settings.removeAliasInDirs, + ignoreHeadings: settings.ignoreHeadings, + ignoreMarkdownTables: settings.ignoreMarkdownTables, +}) + +export const projectUrlFormattingSettings = ( + settings: AutomaticLinkerSettings, +): Pick< + AutomaticLinkerSettings, + | "formatGitHubURLs" + | "githubEnterpriseURLs" + | "formatJiraURLs" + | "jiraURLs" + | "formatLinearURLs" +> => ({ + formatGitHubURLs: settings.formatGitHubURLs, + githubEnterpriseURLs: settings.githubEnterpriseURLs, + formatJiraURLs: settings.formatJiraURLs, + jiraURLs: settings.jiraURLs, + formatLinearURLs: settings.formatLinearURLs, +}) diff --git a/src/settings/settings-info.ts b/src/settings/settings-info.ts index dd119e3..9748b54 100644 --- a/src/settings/settings-info.ts +++ b/src/settings/settings-info.ts @@ -1,61 +1,2 @@ -export type AutomaticLinkerSettings = { - formatOnSave: boolean - showNotice: boolean - respectNewFileFolderPath: boolean // Respect Obsidian's "Folder to create new notes in" setting as base directory - includeAliases: boolean // Include aliases when linking - proximityBasedLinking: boolean // Automatically resolve namespaces for shorthand links - ignoreDateFormats: boolean // Ignore date formatted links (e.g. 2025-02-10) - ignoreHeadings: boolean // Ignore headings (lines starting with #) when adding links - formatGitHubURLs: boolean // Format GitHub URLs on save - githubEnterpriseURLs: string[] // List of GitHub Enterprise URLs - formatJiraURLs: boolean // Format Jira URLs on save - jiraURLs: string[] // List of Jira URLs (domains) - formatLinearURLs: boolean // Format Linear URLs on save - debug: boolean // Enable debug logging - ignoreCase: boolean // Ignore case when matching links - matchSentenceCase: boolean // Match sentence-start capitalized text when ignoreCase is off - replaceUrlWithTitle: boolean // Replace raw URLs with [Title](URL) - replaceUrlWithTitleIgnoreDomains: string[] // List of domains to ignore when replacing URLs with titles - excludeDirsFromAutoLinking: string[] // Optional: List of directories to exclude from auto-linking - preventSelfLinking: boolean // Prevent linking text to its own file - removeAliasInDirs: string[] // Remove aliases for links in specified directories - ignoreMarkdownTables: boolean // Ignore Markdown table rows when adding links - runLinterAfterFormatting: boolean // Run Obsidian Linter after automatic linker formatting - runPrettierAfterFormatting: boolean // Run Prettier after automatic linker formatting - formatDelayMs: number // Delay in milliseconds before running linter after formatting - aiEnabled: boolean // Enable AI link enhancement - aiEndpoint: string // API endpoint for AI (OpenAI compatible) - aiModel: string // Model name for AI - aiMaxContext: number // Maximum context length for AI -} - -export const DEFAULT_SETTINGS: AutomaticLinkerSettings = { - formatOnSave: false, - showNotice: false, - respectNewFileFolderPath: true, // Default: do not respect Obsidian's "Folder to create new notes in" setting - includeAliases: true, // Default: include aliases - proximityBasedLinking: true, // Default: enable automatic namespace resolution - ignoreDateFormats: true, // Default: ignore date formats (e.g. 2025-02-10) - ignoreHeadings: false, // Default: do not ignore headings - formatGitHubURLs: true, // Default: format GitHub URLs - githubEnterpriseURLs: [], // Default: empty list - formatJiraURLs: true, // Default: format Jira URLs - jiraURLs: [], // Default: empty list - formatLinearURLs: false, // Default: format Linear URLs - debug: false, // Default: disable debug logging - ignoreCase: true, // Default: case-sensitive matching - matchSentenceCase: true, // Default: match sentence-start capitalized text - replaceUrlWithTitle: true, // Default: enable replacing URLs with titles - replaceUrlWithTitleIgnoreDomains: [], - excludeDirsFromAutoLinking: [], // Default: no excluded directories - preventSelfLinking: false, // Default: allow self-linking (backward compatibility) - removeAliasInDirs: [], // Default: no directories for alias removal - ignoreMarkdownTables: false, // Default: add links inside Markdown tables - runLinterAfterFormatting: false, // Default: do not run linter after formatting - runPrettierAfterFormatting: false, // Run Prettier after automatic linker formatting - formatDelayMs: 1, // Default: 1ms delay before running linter - aiEnabled: false, // Default: disable AI enhancement - aiEndpoint: "http://localhost:1234/v1", // Default: LM Studio - aiModel: "gemma-4-7b", // Default: Gemma 4 - aiMaxContext: 500, // Default: 500 characters -} +export type { AutomaticLinkerSettings } from "./settings-catalog" +export { DEFAULT_SETTINGS } from "./settings-catalog" diff --git a/src/settings/settings.ts b/src/settings/settings.ts index d414c9b..0d3af98 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -1,5 +1,11 @@ import { App, PluginSettingTab, Setting } from "obsidian" import AutomaticLinkerPlugin from "../main" +import { + AutomaticLinkerSettings, + SETTINGS_CATALOG, + SettingCatalogEntry, + settingRefreshesIndex, +} from "./settings-catalog" export class AutomaticLinkerPluginSettingsTab extends PluginSettingTab { plugin: AutomaticLinkerPlugin @@ -8,500 +14,107 @@ export class AutomaticLinkerPluginSettingsTab extends PluginSettingTab { this.plugin = plugin } + private async setSettingValue( + key: K, + value: AutomaticLinkerSettings[K], + ) { + this.plugin.settings[key] = value + await this.plugin.saveData(this.plugin.settings) + if (key === "replaceUrlWithTitleIgnoreDomains") { + await this.plugin.buildUrlTitleMap() + } + if (settingRefreshesIndex(key)) { + this.plugin.refreshFileDataAndTrie() + } + } + + private parseTextValue( + key: K, + currentValue: AutomaticLinkerSettings[K], + nextValue: string, + ): AutomaticLinkerSettings[K] | null { + if (typeof currentValue !== "number") { + return nextValue as AutomaticLinkerSettings[K] + } + + const parsedValue = parseInt(nextValue) + if (isNaN(parsedValue)) { + return null + } + if (key === "formatDelayMs" && parsedValue < 0) { + return null + } + if (key === "aiMaxContext" && parsedValue <= 0) { + return null + } + + return parsedValue as AutomaticLinkerSettings[K] + } + + private renderSetting(containerEl: HTMLElement, entry: SettingCatalogEntry) { + const setting = new Setting(containerEl) + .setName(entry.name) + .setDesc(entry.description) + const value = this.plugin.settings[entry.key] + + if (entry.control === "toggle") { + setting.addToggle((toggle) => { + toggle + .setValue(Boolean(value)) + .onChange(async (nextValue) => { + await this.setSettingValue(entry.key, nextValue as never) + }) + }) + return + } + + if (entry.control === "text") { + setting.addText((text) => { + text.setPlaceholder(entry.placeholder ?? "") + .setValue(String(value)) + .onChange(async (nextValue) => { + const parsedValue = this.parseTextValue( + entry.key, + value, + nextValue, + ) + if (parsedValue === null) { + return + } + await this.setSettingValue(entry.key, parsedValue) + }) + }) + return + } + + setting.addTextArea((text) => { + text.setPlaceholder(entry.placeholder ?? "") + .setValue(Array.isArray(value) ? value.join("\n") : String(value)) + .onChange(async (nextValue) => { + await this.setSettingValue( + entry.key, + nextValue + .split("\n") + .map(item => item.trim()) + .filter(Boolean) as never, + ) + }) + text.inputEl.rows = 4 + text.inputEl.cols = 50 + }) + } + display(): void { const { containerEl } = this containerEl.empty() - new Setting(containerEl).setName("Formatting").setHeading() - - // Toggle for "Format on Save" setting. - new Setting(containerEl) - .setName("Format on save") - .setDesc( - "When enabled, the file will be automatically formatted (links replaced) when saving.", - ) - .addToggle((toggle) => { - toggle - .setValue(this.plugin.settings.formatOnSave) - .onChange(async (value) => { - this.plugin.settings.formatOnSave = value - await this.plugin.saveData(this.plugin.settings) - }) - }) - - // Toggle for respecting Obsidian's "Folder to create new notes in" setting - new Setting(containerEl) - .setName("Respect 'Folder to create new notes in' setting") - .setDesc( - "When enabled, the plugin will use Obsidian's 'Folder to create new notes in' setting as the base directory for omitting folder prefixes in links.", - ) - .addToggle((toggle) => { - toggle - .setValue(this.plugin.settings.respectNewFileFolderPath) - .onChange(async (value) => { - this.plugin.settings.respectNewFileFolderPath = value - await this.plugin.saveData(this.plugin.settings) - this.plugin.refreshFileDataAndTrie() - }) - }) - - // Toggle for including aliases. - new Setting(containerEl) - .setName("Include aliases") - .setDesc( - "When enabled, aliases will be included when processing links. Note: A restart is required for changes to take effect.", - ) - .addToggle((toggle) => { - toggle - .setValue(this.plugin.settings.includeAliases) - .onChange(async (value) => { - this.plugin.settings.includeAliases = value - await this.plugin.saveData(this.plugin.settings) - this.plugin.refreshFileDataAndTrie() - }) - }) - - // Toggle for proximity-based linking. - new Setting(containerEl) - .setName("Proximity-based linking") - .setDesc( - "When enabled, the plugin will automatically resolve namespaces for shorthand links. If multiple candidates share the same shorthand, the candidate with the most common path segments relative to the current file will be selected.", - ) - .addToggle((toggle) => { - toggle - .setValue(this.plugin.settings.proximityBasedLinking) - .onChange(async (value) => { - this.plugin.settings.proximityBasedLinking = value - await this.plugin.saveData(this.plugin.settings) - this.plugin.refreshFileDataAndTrie() - }) - }) - - // Toggle for ignoring date formats. - new Setting(containerEl) - .setName("Ignore date formats") - .setDesc( - "When enabled, links that match date formats (e.g. 2025-02-10) will be ignored. This helps maintain compatibility with Obsidian Tasks.", - ) - .addToggle((toggle) => { - toggle - .setValue(this.plugin.settings.ignoreDateFormats) - .onChange(async (value) => { - this.plugin.settings.ignoreDateFormats = value - await this.plugin.saveData(this.plugin.settings) - this.plugin.refreshFileDataAndTrie() - }) - }) - - // Toggle for ignoring headings - new Setting(containerEl) - .setName("Ignore headings") - .setDesc( - "When enabled, headings (lines starting with #) will not have links added to them.", - ) - .addToggle((toggle) => { - toggle - .setValue(this.plugin.settings.ignoreHeadings) - .onChange(async (value) => { - this.plugin.settings.ignoreHeadings = value - await this.plugin.saveData(this.plugin.settings) - }) - }) - - // Toggle for ignoring Markdown tables - new Setting(containerEl) - .setName("Ignore Markdown tables") - .setDesc( - "When enabled, Markdown table rows will not have links added to them.", - ) - .addToggle((toggle) => { - toggle - .setValue(this.plugin.settings.ignoreMarkdownTables) - .onChange(async (value) => { - this.plugin.settings.ignoreMarkdownTables = value - await this.plugin.saveData(this.plugin.settings) - }) - }) - - // Toggle for ignoring case in link matching - new Setting(containerEl) - .setName("Ignore case") - .setDesc( - "When enabled, link matching will be case-insensitive. The original case of the text will be preserved in the link.", - ) - .addToggle((toggle) => { - toggle - .setValue(this.plugin.settings.ignoreCase) - .onChange(async (value) => { - this.plugin.settings.ignoreCase = value - await this.plugin.saveData(this.plugin.settings) - this.plugin.refreshFileDataAndTrie() - }) - }) - - // Toggle for matching sentence-case text - new Setting(containerEl) - .setName("Match sentence case") - .setDesc( - "When 'Ignore case' is OFF, match text that is capitalized at the start of a sentence. For example, 'My name' at a sentence start will match the file 'my name'. This setting is ignored when 'Ignore case' is ON.", - ) - .addToggle((toggle) => { - toggle - .setValue(this.plugin.settings.matchSentenceCase) - .onChange(async (value) => { - this.plugin.settings.matchSentenceCase = value - await this.plugin.saveData(this.plugin.settings) - }) - }) - - // Toggle for preventing self-linking - new Setting(containerEl) - .setName("Prevent self-linking") - .setDesc( - "When enabled, text will not be linked to its own file. For example, the word 'VPN' inside VPN.md will not be converted to a link.", - ) - .addToggle((toggle) => { - toggle - .setValue(this.plugin.settings.preventSelfLinking) - .onChange(async (value) => { - this.plugin.settings.preventSelfLinking = value - await this.plugin.saveData(this.plugin.settings) - this.plugin.refreshFileDataAndTrie() - }) - }) - - // Add excluding dirs that you wish to exclude from the automatic linking - new Setting(containerEl) - .setName("Exclude directories from automatic linking") - .setDesc( - "Directories to be excluded from automatic linking, one per line (e.g. 'Templates')", - ) - .addTextArea((text) => { - text.setPlaceholder("Templates\nArchive") - .setValue( - this.plugin.settings.excludeDirsFromAutoLinking.join( - "\n", - ), - ) - .onChange(async (value) => { - // Split by newlines and filter out empty lines - const dirs = value.split("\n").map(dir => dir.trim()).filter(Boolean) - this.plugin.settings.excludeDirsFromAutoLinking = dirs - await this.plugin.saveData(this.plugin.settings) - this.plugin.refreshFileDataAndTrie() - }) - }) - - // Remove aliases for links in specified directories - new Setting(containerEl) - .setName("Remove aliases in directories") - .setDesc( - "Directories where link aliases should be removed, one per line (e.g. 'dir' will convert [[dir/xxx|yyy]] to [[dir/xxx]]). This affects both auto-generated aliases and frontmatter aliases.", - ) - .addTextArea((text) => { - text.setPlaceholder("dir1\ndir2/subdir") - .setValue(this.plugin.settings.removeAliasInDirs.join("\n")) - .onChange(async (value) => { - // Split by newlines and filter out empty lines - const dirs = value - .split("\n") - .map(dir => dir.trim()) - .filter(Boolean) - this.plugin.settings.removeAliasInDirs = dirs - await this.plugin.saveData(this.plugin.settings) - this.plugin.refreshFileDataAndTrie() - }) - }) - - new Setting(containerEl) - .setName("Integrations") - .setHeading() - - // Toggle for running linter after formatting - new Setting(containerEl) - .setName("Run Obsidian Linter after formatting") - .setDesc( - "When enabled, Obsidian Linter will be executed after Automatic Linker formatting. This requires the Obsidian Linter plugin to be installed.", - ) - .addToggle((toggle) => { - toggle - .setValue(this.plugin.settings.runLinterAfterFormatting) - .onChange(async (value) => { - this.plugin.settings.runLinterAfterFormatting = value - await this.plugin.saveData(this.plugin.settings) - }) - }) - - // Toggle for running prettier after formatting - new Setting(containerEl) - .setName("Run Prettier after formatting") - .setDesc( - "When enabled, Prettier will be executed after Automatic Linker formatting. This requires prettier-format plugin to be installed. https://github.com/dylanarmstrong/obsidian-prettier-plugin", - ) - .addToggle((toggle) => { - toggle - .setValue(this.plugin.settings.runPrettierAfterFormatting) - .onChange(async (value) => { - this.plugin.settings.runPrettierAfterFormatting = value - await this.plugin.saveData(this.plugin.settings) - }) - }) - - // Setting for linter delay - new Setting(containerEl) - .setName("Format delay (ms)") - .setDesc( - "Delay in milliseconds before formatting. Increase this value if the linter/prettier runs before the file is fully saved.", - ) - .addText((text) => { - text.setPlaceholder("e.g. 100") - .setValue(this.plugin.settings.formatDelayMs.toString()) - .onChange(async (value) => { - const parsedValue = parseInt(value) - if (!isNaN(parsedValue) && parsedValue >= 0) { - this.plugin.settings.formatDelayMs = parsedValue - await this.plugin.saveData(this.plugin.settings) - } - }) - }) - - new Setting(containerEl) - .setName("URL Replacement with Title") - .setHeading() - - // Toggle for replacing URLs with titles - new Setting(containerEl) - .setName("Replace URL with title") - .setDesc( - "When enabled, raw URLs will be replaced with [Page Title](URL). Requires fetching the URL content.", - ) - .addToggle((toggle) => { - toggle - .setValue(this.plugin.settings.replaceUrlWithTitle) - .onChange(async (value) => { - this.plugin.settings.replaceUrlWithTitle = value - await this.plugin.saveData(this.plugin.settings) - }) - }) - - new Setting(containerEl) - .setName("Ignore domains") - .setDesc( - "Ignore domains for replacing URLs with titles, one per line (e.g. x.com)", - ) - .addTextArea((text) => { - text.setPlaceholder("") - .setValue( - this.plugin.settings.replaceUrlWithTitleIgnoreDomains.join( - "\n", - ), - ) - .onChange(async (value) => { - // Split by newlines and filter out empty lines - const urls = value - .split("\n") - .map(url => url.trim()) - .filter(Boolean) - this.plugin.settings.replaceUrlWithTitleIgnoreDomains - = urls - await this.plugin.saveData(this.plugin.settings) - await this.plugin.buildUrlTitleMap() - }) - // Make the text area taller - text.inputEl.rows = 4 - text.inputEl.cols = 50 - }) - - new Setting(containerEl) - .setName("URL Formatting for GitHub") - .setHeading() - - // Toggle for formatting GitHub URLs on save - new Setting(containerEl) - .setName("Format GitHub URLs on save") - .setDesc( - "When enabled, GitHub URLs will be formatted when saving the file.", - ) - .addToggle((toggle) => { - toggle - .setValue(this.plugin.settings.formatGitHubURLs) - .onChange(async (value) => { - this.plugin.settings.formatGitHubURLs = value - await this.plugin.saveData(this.plugin.settings) - }) - }) - - // Add GitHub Enterprise URLs setting - new Setting(containerEl) - .setName("GitHub Enterprise URLs") - .setDesc( - "Add your GitHub Enterprise URLs, one per line (e.g. github.enterprise.com)", - ) - .addTextArea((text) => { - text.setPlaceholder("github.enterprise.com\ngithub.company.com") - .setValue( - this.plugin.settings.githubEnterpriseURLs.join("\n"), - ) - .onChange(async (value) => { - // Split by newlines and filter out empty lines - const urls = value - .split("\n") - .map(url => url.trim()) - .filter(Boolean) - this.plugin.settings.githubEnterpriseURLs = urls - await this.plugin.saveData(this.plugin.settings) - }) - // Make the text area taller - text.inputEl.rows = 4 - text.inputEl.cols = 50 - }) - - new Setting(containerEl) - .setName("URL Formatting for Jira") - .setHeading() - - // Toggle for formatting JIRA URLs on save - new Setting(containerEl) - .setName("Format JIRA URLs on save") - .setDesc( - "When enabled, JIRA URLs will be formatted when saving the file.", - ) - .addToggle((toggle) => { - toggle - .setValue(this.plugin.settings.formatJiraURLs) - .onChange(async (value) => { - this.plugin.settings.formatJiraURLs = value - await this.plugin.saveData(this.plugin.settings) - }) - }) - - // Add JIRA URLs setting - new Setting(containerEl) - .setName("JIRA URLs") - .setDesc( - "Add your JIRA URLs, one per line (e.g. jira.enterprise.com)", - ) - .addTextArea((text) => { - text.setPlaceholder("jira.enterprise.com\njira.company.com") - .setValue(this.plugin.settings.jiraURLs.join("\n")) - .onChange(async (value) => { - // Split by newlines and filter out empty lines - const urls = value - .split("\n") - .map(url => url.trim()) - .filter(Boolean) - this.plugin.settings.jiraURLs = urls - await this.plugin.saveData(this.plugin.settings) - }) - // Make the text area taller - text.inputEl.rows = 4 - text.inputEl.cols = 50 - }) - - new Setting(containerEl) - .setName("URL Formatting for Linear") - .setHeading() - - // Toggle for formatting Linear URLs on save - new Setting(containerEl) - .setName("Format Linear URLs on save") - .setDesc( - "When enabled, Linear URLs will be formatted when saving the file.", - ) - .addToggle((toggle) => { - toggle - .setValue(this.plugin.settings.formatLinearURLs) - .onChange(async (value) => { - this.plugin.settings.formatLinearURLs = value - await this.plugin.saveData(this.plugin.settings) - }) - }) - - new Setting(containerEl).setName("Debug").setHeading() - - // Toggle for showing the load notice. - new Setting(containerEl) - .setName("Show load notice") - .setDesc("Display a notice when markdown files are loaded.") - .addToggle((toggle) => { - toggle - .setValue(this.plugin.settings.showNotice) - .onChange(async (value) => { - this.plugin.settings.showNotice = value - await this.plugin.saveData(this.plugin.settings) - }) - }) - - // Toggle for debug logging - new Setting(containerEl) - .setName("Debug mode") - .setDesc( - "When enabled, debug information will be logged to the console.", - ) - .addToggle((toggle) => { - toggle - .setValue(this.plugin.settings.debug) - .onChange(async (value) => { - this.plugin.settings.debug = value - await this.plugin.saveData(this.plugin.settings) - }) - }) - - new Setting(containerEl) - .setName("AI Link Enhancement (Beta)") - .setHeading() - - new Setting(containerEl) - .setName("Enable AI Link Enhancement") - .setDesc( - "When enabled, an AI-powered link enhancer command will be available. It uses a local LLM to resolve ambiguous links and correct existing ones.", - ) - .addToggle((toggle) => { - toggle - .setValue(this.plugin.settings.aiEnabled) - .onChange(async (value) => { - this.plugin.settings.aiEnabled = value - await this.plugin.saveData(this.plugin.settings) - }) - }) - - new Setting(containerEl) - .setName("AI API Endpoint") - .setDesc("The URL of your OpenAI-compatible AI server (e.g. LM Studio, Ollama).") - .addText((text) => { - text.setPlaceholder("http://localhost:1234/v1") - .setValue(this.plugin.settings.aiEndpoint) - .onChange(async (value) => { - this.plugin.settings.aiEndpoint = value - await this.plugin.saveData(this.plugin.settings) - }) - }) - - new Setting(containerEl) - .setName("AI Model") - .setDesc("The name of the model to use (e.g. gemma-4-7b).") - .addText((text) => { - text.setPlaceholder("gemma-4-7b") - .setValue(this.plugin.settings.aiModel) - .onChange(async (value) => { - this.plugin.settings.aiModel = value - await this.plugin.saveData(this.plugin.settings) - }) - }) - - new Setting(containerEl) - .setName("Max Context Length") - .setDesc("Number of characters around the link to provide as context to the AI.") - .addText((text) => { - text.setPlaceholder("500") - .setValue(this.plugin.settings.aiMaxContext.toString()) - .onChange(async (value) => { - const parsedValue = parseInt(value) - if (!isNaN(parsedValue) && parsedValue > 0) { - this.plugin.settings.aiMaxContext = parsedValue - await this.plugin.saveData(this.plugin.settings) - } - }) - }) + const renderedGroups = new Set() + for (const entry of SETTINGS_CATALOG) { + if (!renderedGroups.has(entry.group)) { + new Setting(containerEl).setName(entry.group).setHeading() + renderedGroups.add(entry.group) + } + this.renderSetting(containerEl, entry) + } } } From f217ee9dad37999b050b6f846ceeb1dc6ef62d77 Mon Sep 17 00:00:00 2001 From: Kodai Nakamura Date: Mon, 22 Jun 2026 02:43:54 +0900 Subject: [PATCH 16/25] fix(settings): preserve textarea sizing metadata Why: Task 3 centralized settings rendering, but the renderer hard-coded textarea dimensions for every multiline control. That enlarged directory lists that previously used Obsidian's default textarea size. What: Add optional rows/cols metadata to catalog entries, mark only the three URL textarea settings with explicit 4x50 sizing, and apply textarea dimensions in the renderer only when the catalog requests them. Include a regression test that locks the sizing contract to the URL-related textarea settings only. --- src/settings/__tests__/settings-catalog.test.ts | 14 ++++++++++++++ src/settings/settings-catalog.ts | 8 ++++++++ src/settings/settings.ts | 8 ++++++-- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/settings/__tests__/settings-catalog.test.ts b/src/settings/__tests__/settings-catalog.test.ts index f6dd91e..cc4ae22 100644 --- a/src/settings/__tests__/settings-catalog.test.ts +++ b/src/settings/__tests__/settings-catalog.test.ts @@ -16,6 +16,20 @@ describe("SETTINGS_CATALOG", () => { expect(new Set(catalogKeys).size).toBe(catalogKeys.length) }) + it("marks only the URL textarea settings for explicit sizing", () => { + const sizedTextareaKeys = SETTINGS_CATALOG + .filter(entry => entry.control === "textarea") + .filter(entry => (entry as { rows?: number }).rows === 4) + .filter(entry => (entry as { cols?: number }).cols === 50) + .map(entry => entry.key) + + expect(sizedTextareaKeys).toEqual([ + "replaceUrlWithTitleIgnoreDomains", + "githubEnterpriseURLs", + "jiraURLs", + ]) + }) + it("marks current index-refresh settings", () => { expect(settingRefreshesIndex("respectNewFileFolderPath")).toBe(true) expect(settingRefreshesIndex("includeAliases")).toBe(true) diff --git a/src/settings/settings-catalog.ts b/src/settings/settings-catalog.ts index d828502..d8fe916 100644 --- a/src/settings/settings-catalog.ts +++ b/src/settings/settings-catalog.ts @@ -41,6 +41,8 @@ export interface SettingCatalogEntry Date: Mon, 22 Jun 2026 02:57:16 +0900 Subject: [PATCH 17/25] refactor(markdown): centralize protected segment handling Why: - Link replacement and URL title code each carried their own Markdown context checks, which made protected text behavior hard to keep consistent. - A shared Markdown segment module improves locality for prose-only transformations. - The refactor needed to preserve existing wikilink correction and table behavior without bringing back placeholder collisions. What: - Add a pure Markdown segment module with prose mapping and focused tests. - Route link replacement and URL title flows through shared protected segment handling. - Preserve existing-link correction, heading/callout/table protection, and add fast paths to keep performance within the existing thresholds. --- src/__tests__/markdown-segments.test.ts | 61 +++++ src/markdown-segments.ts | 253 ++++++++++++++++++ src/replace-links/replace-links.ts | 199 ++++++-------- src/replace-url-with-title/index.ts | 128 ++++----- .../utils/list-up-all-urls.ts | 192 ++++--------- 5 files changed, 503 insertions(+), 330 deletions(-) create mode 100644 src/__tests__/markdown-segments.test.ts create mode 100644 src/markdown-segments.ts diff --git a/src/__tests__/markdown-segments.test.ts b/src/__tests__/markdown-segments.test.ts new file mode 100644 index 0000000..3d9821f --- /dev/null +++ b/src/__tests__/markdown-segments.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest" +import { mapMarkdownProse, segmentMarkdown } from "../markdown-segments" + +describe("segmentMarkdown", () => { + it("round-trips prose and protected inline code", () => { + const text = "Use `TypeScript` with TypeScript" + const segments = segmentMarkdown(text) + + expect(segments.map(segment => ({ + kind: segment.kind, + protectedKind: segment.protectedKind, + text: segment.text, + }))).toEqual([ + { kind: "prose", protectedKind: undefined, text: "Use " }, + { kind: "protected", protectedKind: "inline-code", text: "`TypeScript`" }, + { kind: "prose", protectedKind: undefined, text: " with TypeScript" }, + ]) + expect(segments.map(segment => segment.text).join("")).toBe(text) + }) + + it("protects fenced code blocks including unclosed blocks", () => { + const text = "before\n```ts\nTypeScript" + const segments = segmentMarkdown(text) + + expect(segments.map(segment => ({ + kind: segment.kind, + protectedKind: segment.protectedKind, + text: segment.text, + }))).toEqual([ + { kind: "prose", protectedKind: undefined, text: "before\n" }, + { kind: "protected", protectedKind: "fenced-code", text: "```ts\nTypeScript" }, + ]) + }) + + it("protects headings, tables, and callouts when requested", () => { + const text = "# TypeScript\n| TypeScript |\n> [!note]\n> TypeScript\nTypeScript" + const segments = segmentMarkdown(text, { + protectHeadings: true, + protectTableRows: true, + protectCallouts: true, + }) + + expect(segments.filter(segment => segment.kind === "protected").map(segment => segment.protectedKind)).toEqual([ + "heading", + "table-row", + "callout", + ]) + expect(segments.map(segment => segment.text).join("")).toBe(text) + }) +}) + +describe("mapMarkdownProse", () => { + it("transforms only prose segments", () => { + const result = mapMarkdownProse( + "TypeScript `TypeScript` [[TypeScript]]", + text => text.replace(/TypeScript/g, "TS"), + ) + + expect(result).toBe("TS `TypeScript` [[TypeScript]]") + }) +}) diff --git a/src/markdown-segments.ts b/src/markdown-segments.ts new file mode 100644 index 0000000..bffa45d --- /dev/null +++ b/src/markdown-segments.ts @@ -0,0 +1,253 @@ +import { isMarkdownTableLine } from "./replace-links/candidate-scanner" + +export type MarkdownSegmentKind = "prose" | "protected" + +export type MarkdownProtectedKind = "inline-code" + | "fenced-code" + | "wikilink" + | "markdown-link" + | "single-bracket" + | "url" + | "heading" + | "callout" + | "table-row" + +export interface MarkdownSegment { + kind: MarkdownSegmentKind + protectedKind?: MarkdownProtectedKind + start: number + end: number + text: string +} + +export interface SegmentMarkdownOptions { + protectHeadings?: boolean + protectCallouts?: boolean + protectTableRows?: boolean + protectUrls?: boolean +} + +interface ProtectedRange { + start: number + end: number + protectedKind: MarkdownProtectedKind +} + +const collectHeadingRanges = (text: string): ProtectedRange[] => { + const ranges: ProtectedRange[] = [] + const headingPattern = /^#{1,6}\s+.*$/gm + let match: RegExpExecArray | null + + while ((match = headingPattern.exec(text)) !== null) { + ranges.push({ + start: match.index, + end: match.index + match[0].length, + protectedKind: "heading", + }) + } + + return ranges +} + +const collectCalloutRanges = (text: string): ProtectedRange[] => { + const ranges: ProtectedRange[] = [] + const calloutPattern = /^>[ \t]*\[![\w-]+\].*?(\n>.*?)*(?=\n(?!>)|$)/gm + let match: RegExpExecArray | null + + while ((match = calloutPattern.exec(text)) !== null) { + ranges.push({ + start: match.index, + end: match.index + match[0].length, + protectedKind: "callout", + }) + } + + return ranges +} + +const collectTableRowRanges = (text: string): ProtectedRange[] => { + const ranges: ProtectedRange[] = [] + const linePattern = /[^\n]*(?:\n|$)/g + let match: RegExpExecArray | null + + while ((match = linePattern.exec(text)) !== null) { + if (match[0] === "") { + break + } + + const lineText = match[0] + const lineContent = lineText.endsWith("\n") + ? lineText.slice(0, -1).replace(/\r$/, "") + : lineText.replace(/\r$/, "") + + if (isMarkdownTableLine(lineContent)) { + ranges.push({ + start: match.index, + end: match.index + lineText.length, + protectedKind: "table-row", + }) + } + } + + return ranges +} + +const buildProtectedPattern = (protectUrls: boolean): RegExp => { + const parts = [ + "```[\\s\\S]*?(?:```|$)", + "~~~[\\s\\S]*?(?:~~~|$)", + "`[^`]*`", + "\\[\\[[^\\]]+\\]\\]", + "\\[[^\\]]+\\]\\([^)]+\\)", + "\\[[^\\]]+\\]", + ] + + if (protectUrls) { + parts.push("https?:\\/\\/[^\\s]+") + } + + return new RegExp(`(${parts.join("|")})`, "g") +} + +const getProtectedKind = (text: string): MarkdownProtectedKind => { + if (text.startsWith("```") || text.startsWith("~~~")) { + return "fenced-code" + } + + if (text.startsWith("`")) { + return "inline-code" + } + + if (text.startsWith("[[")) { + return "wikilink" + } + + if (text.startsWith("[")) { + return text.includes("](") ? "markdown-link" : "single-bracket" + } + + return "url" +} + +const sortAndMergeRanges = (ranges: ProtectedRange[]): ProtectedRange[] => { + const sortedRanges = ranges + .slice() + .sort((a, b) => a.start - b.start || a.end - b.end) + const merged: ProtectedRange[] = [] + + 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 isInsideRanges = ( + index: number, + ranges: ProtectedRange[], +): boolean => { + return ranges.some(range => index >= range.start && index < range.end) +} + +export const segmentMarkdown = ( + text: string, + options: SegmentMarkdownOptions = {}, +): MarkdownSegment[] => { + const mayContainProtectedMarkdown = text.includes("`") + || text.includes("[") + || (options.protectHeadings && text.includes("#")) + || (options.protectCallouts && text.includes(">")) + || (options.protectTableRows && text.includes("|")) + || (options.protectUrls && text.includes("http")) + + if (!mayContainProtectedMarkdown) { + return [{ + kind: "prose", + start: 0, + end: text.length, + text, + }] + } + + const ranges: ProtectedRange[] = [] + + if (options.protectHeadings) { + ranges.push(...collectHeadingRanges(text)) + } + + if (options.protectCallouts) { + ranges.push(...collectCalloutRanges(text)) + } + + if (options.protectTableRows) { + ranges.push(...collectTableRowRanges(text)) + } + + const protectedPattern = buildProtectedPattern(options.protectUrls ?? false) + let match: RegExpExecArray | null + + while ((match = protectedPattern.exec(text)) !== null) { + if (isInsideRanges(match.index, ranges)) { + continue + } + + ranges.push({ + start: match.index, + end: match.index + match[0].length, + protectedKind: getProtectedKind(match[0]), + }) + } + + const mergedRanges = sortAndMergeRanges(ranges) + const segments: MarkdownSegment[] = [] + let cursor = 0 + + for (const range of mergedRanges) { + if (cursor < range.start) { + segments.push({ + kind: "prose", + start: cursor, + end: range.start, + text: text.slice(cursor, range.start), + }) + } + + segments.push({ + kind: "protected", + protectedKind: range.protectedKind, + start: range.start, + end: range.end, + text: text.slice(range.start, range.end), + }) + cursor = range.end + } + + if (cursor < text.length || segments.length === 0) { + segments.push({ + kind: "prose", + start: cursor, + end: text.length, + text: text.slice(cursor), + }) + } + + return segments +} + +export const mapMarkdownProse = ( + text: string, + transform: (segmentText: string, segment: MarkdownSegment) => string, + options: SegmentMarkdownOptions = {}, +): string => { + return segmentMarkdown(text, options) + .map(segment => segment.kind === "prose" + ? transform(segment.text, segment) + : segment.text) + .join("") +} diff --git a/src/replace-links/replace-links.ts b/src/replace-links/replace-links.ts index a008413..4800dbf 100644 --- a/src/replace-links/replace-links.ts +++ b/src/replace-links/replace-links.ts @@ -1,7 +1,7 @@ import { CandidateData, TrieNode } from "../trie" +import { mapMarkdownProse, segmentMarkdown } from "../markdown-segments" import { buildFallbackIndex, - extractFencedCodeBlocks, extractLinkParts, findBestCandidateInSameNamespace, getCurrentNamespace, @@ -9,7 +9,6 @@ import { isCjkText, isIndexInsideMarkdownTable, isKoreanText, - isMarkdownTableLine, isMonthNote, isProtectedLink, isSelfLink, @@ -211,6 +210,7 @@ const processCjkText = ( linkGenerator: LinkGenerator, settings: ReplaceLinksSettings = {}, resolvedAmbiguities?: Map, + forceIsInTable?: boolean, ): string => { // For CJK texts that might contain non-CJK terms like "taro-san", ensure we use a consistent approach // Pass the proper filePath to maintain correct namespace resolution @@ -224,6 +224,7 @@ const processCjkText = ( linkGenerator, settings, resolvedAmbiguities, + forceIsInTable, ) } @@ -236,6 +237,7 @@ const processFallbackSearch = ( 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] @@ -345,7 +347,7 @@ const processFallbackSearch = ( longestMatch.word, settings, ) - const isInTable = isIndexInsideMarkdownTable(text, startIndex) + const isInTable = forceIsInTable ?? isIndexInsideMarkdownTable(text, startIndex) const finalLink = linkGenerator({ linkPath, sourcePath: filePath, @@ -369,6 +371,7 @@ const handleKoreanSpecialCases = ( linkGenerator: LinkGenerator, settings: ReplaceLinksSettings = {}, resolvedAmbiguities?: Map, + forceIsInTable?: boolean, ): { result: string, newIndex: number } | null => { const remaining = text.slice(i + candidate.length) @@ -393,7 +396,7 @@ const handleKoreanSpecialCases = ( linkPath, sourcePath: filePath, alias, - isInTable: false, + isInTable: forceIsInTable ?? false, }) return { @@ -423,6 +426,7 @@ const processStandardText = ( linkGenerator: LinkGenerator, settings: ReplaceLinksSettings = {}, resolvedAmbiguities?: Map, + forceIsInTable?: boolean, ): string => { let result = "" let i = 0 @@ -517,6 +521,7 @@ const processStandardText = ( linkGenerator, settings, resolvedAmbiguities, + forceIsInTable, ) if (koreanResult) { result += koreanResult.result @@ -581,7 +586,7 @@ const processStandardText = ( linkPath, sourcePath: filePath, alias, - isInTable, + isInTable: forceIsInTable ?? isInTable, }) result += finalLink @@ -600,6 +605,7 @@ const processStandardText = ( currentNamespace, linkGenerator, settings, + forceIsInTable, ) if (fallbackResult) { result += fallbackResult.result @@ -642,8 +648,43 @@ export const replaceLinks = ({ // Get the current namespace const currentNamespace = getCurrentNamespace(filePath, settings.baseDir) + const markdownOptions = { + protectHeadings: settings.ignoreHeadings, + protectCallouts: true, + protectTableRows: settings.ignoreMarkdownTables, + protectUrls: true, + } + + const replaceResolvedWikilink = ( + wikilink: string, + start: number, + ): string => { + if (!resolvedAmbiguities?.has(wikilink)) { + return wikilink + } + + const resolvedPath = resolvedAmbiguities.get(wikilink)! + const { linkPath, alias: resolvedAlias } = extractLinkParts(resolvedPath) + const existingLinkRegex = /\[\[([^|\]]+)(?:\|([^\]]+))?\]\]/ + const linkMatch = wikilink.match(existingLinkRegex) + const existingPath = linkMatch ? linkMatch[1] : "" + const existingAlias = linkMatch ? linkMatch[2] : undefined + const finalAlias = resolvedAlias || existingAlias || (wikilink.includes("|") ? undefined : existingPath) + + return linkGenerator({ + linkPath, + sourcePath: filePath, + alias: finalAlias, + isInTable: !settings.ignoreMarkdownTables + && isIndexInsideMarkdownTable(body, start), + }) + } + // Process segments of text - const processTextSegment = (text: string): string => { + const processTextSegment = ( + text: string, + forceIsInTable?: boolean, + ): string => { // Check if the text contains CJK characters const hasCjkText = isCjkText(text) @@ -657,6 +698,7 @@ export const replaceLinks = ({ linkGenerator, settings, resolvedAmbiguities, + forceIsInTable, ) } else { @@ -670,138 +712,61 @@ export const replaceLinks = ({ linkGenerator, settings, resolvedAmbiguities, + forceIsInTable, ) } } - const processTableAwareTextSegment = (text: string): string => { - if (!settings.ignoreMarkdownTables) { - return processTextSegment(text) + const processTableAwareTextSegment = ( + text: string, + segment: { start: number }, + ): string => { + if (!text.includes("\n")) { + const isInTable = !settings.ignoreMarkdownTables + && isIndexInsideMarkdownTable(body, segment.start) + return processTextSegment(text, isInTable) } - return text.replace(/[^\n]*(?:\n|$)/g, (line) => { + return text.replace(/[^\n]*(?:\n|$)/g, (line, offset) => { if (line === "") { return line } const lineContent = line.endsWith("\n") - ? line.slice(0, -1) + ? line.slice(0, -1).replace(/\r$/, "") : line - if (isMarkdownTableLine(lineContent)) { + if (lineContent === "") { return line } - return processTextSegment(line) + const absoluteIndex = segment.start + offset + const isInTable = !settings.ignoreMarkdownTables + && isIndexInsideMarkdownTable(body, absoluteIndex) + + return processTextSegment(line, isInTable) }) } - // Extract and protect fenced code blocks before any other block-level rules. - const { body: bodyAfterCodeBlocks, codeBlocks } = extractFencedCodeBlocks(body) + let bodyWithResolvedWikilinks = body + if (resolvedAmbiguities) { + bodyWithResolvedWikilinks = segmentMarkdown(body, markdownOptions) + .map((segment) => { + if ( + segment.kind === "protected" + && segment.protectedKind === "wikilink" + ) { + return replaceResolvedWikilink(segment.text, segment.start) + } - // Extract and protect headings first - const headingPattern = /^#{1,6}\s+.*$/gm - const headings: Array<{ placeholder: string, content: string }> = [] - let headingIndex = 0 - - let bodyAfterHeadings = bodyAfterCodeBlocks - if (settings.ignoreHeadings) { - bodyAfterHeadings = bodyAfterCodeBlocks.replace(headingPattern, (match) => { - const placeholder = `__HEADING_${headingIndex}__` - headings.push({ placeholder, content: match }) - headingIndex++ - return placeholder - }) - } - - // Extract and protect callout blocks first - // Match callout blocks: starts with > [!type] and continues with lines starting with > - const calloutPattern = /^>[ \t]*\[![\w-]+\].*?(\n>.*?)*(?=\n(?!>)|$)/gm - const callouts: Array<{ placeholder: string, content: string }> = [] - let calloutIndex = 0 - - // Replace callouts with placeholders - const bodyWithPlaceholders = bodyAfterHeadings.replace(calloutPattern, (match) => { - const placeholder = `__CALLOUT_${calloutIndex}__` - callouts.push({ placeholder, content: match }) - calloutIndex++ - return placeholder - }) - - // Process the entire body while preserving protected segments - let resultBody = "" - let lastIndex = 0 - let match: RegExpExecArray | null - - // Reset the regex to start from the beginning - REGEX_PATTERNS.PROTECTED.lastIndex = 0 - - while ( - (match = REGEX_PATTERNS.PROTECTED.exec(bodyWithPlaceholders)) !== null - ) { - const mIndex = match.index - const segment = bodyWithPlaceholders.slice(lastIndex, mIndex) - resultBody += processTableAwareTextSegment(segment) - - const fullMatch = match[0] - if ( - settings.ignoreMarkdownTables - && isIndexInsideMarkdownTable(bodyWithPlaceholders, mIndex) - ) { - resultBody += fullMatch - } - else if (resolvedAmbiguities?.has(fullMatch)) { - // Existing link replacement - const resolvedPath = resolvedAmbiguities.get(fullMatch)! - const { linkPath, alias: resolvedAlias } = extractLinkParts(resolvedPath) - - // Try to extract existing alias from the matched link - const existingLinkRegex = /\[\[([^|\]]+)(?:\|([^\]]+))?\]\]/ - const linkMatch = fullMatch.match(existingLinkRegex) - const existingPath = linkMatch ? linkMatch[1] : "" - const existingAlias = linkMatch ? linkMatch[2] : undefined - - // Use resolved alias if present, otherwise use existing alias, - // otherwise use existing path (as alias if it was a simple link) - const finalAlias = resolvedAlias || existingAlias || (fullMatch.includes("|") ? undefined : existingPath) - - const isInTable = isIndexInsideMarkdownTable(bodyWithPlaceholders, mIndex) - resultBody += linkGenerator({ - linkPath, - sourcePath: filePath, - alias: finalAlias, - isInTable, + return segment.text }) - } - else { - // Append the protected segment unchanged - resultBody += fullMatch - } - lastIndex = mIndex + fullMatch.length - - // Prevent infinite loop on zero-length matches - if (fullMatch.length === 0) { - REGEX_PATTERNS.PROTECTED.lastIndex++ - } + .join("") } - // Process the remaining text - resultBody += processTableAwareTextSegment(bodyWithPlaceholders.slice(lastIndex)) - - // Restore callouts - for (const { placeholder, content } of callouts) { - resultBody = resultBody.replace(placeholder, content) - } - - // Restore headings - for (const { placeholder, content } of headings) { - resultBody = resultBody.replace(placeholder, content) - } - - // Restore fenced code blocks - for (const { placeholder, content } of codeBlocks) { - resultBody = resultBody.replace(placeholder, content) - } - - return resultBody + return mapMarkdownProse( + bodyWithResolvedWikilinks, + processTableAwareTextSegment, + markdownOptions, + ) } diff --git a/src/replace-url-with-title/index.ts b/src/replace-url-with-title/index.ts index 831f650..7eca4cc 100644 --- a/src/replace-url-with-title/index.ts +++ b/src/replace-url-with-title/index.ts @@ -1,3 +1,5 @@ +import { mapMarkdownProse } from "../markdown-segments" + type Url = string type Title = string interface ReplaceUrlWithTitleOptions { @@ -13,103 +15,73 @@ export const replaceUrlWithTitle = ({ return body } - let resultBody = body - // Sort URLs by length descending to replace longer URLs first // This helps prevent partial replacements (e.g., replacing 'example.com' before 'sub.example.com') const sortedUrls = Array.from(urlTitleMap.keys()).sort( (a, b) => b.length - a.length, ) - for (const url of sortedUrls) { - const title = urlTitleMap.get(url) - // Should not happen with Map iteration, but good practice - if (!title) continue + const replaceUrlsInProse = (prose: string): string => { + let resultBody = prose - // Escape backslashes and special characters in title for link text safety if needed - // For now, assume title is safe. - const markdownLink = `[${title}](${url})` - let currentIndex = 0 - const newBodyParts: string[] = [] + for (const url of sortedUrls) { + const title = urlTitleMap.get(url) + if (!title) continue - // Find all occurrences of the current URL in the resultBody - // resultBody is updated in each iteration of the outer loop - while (currentIndex < resultBody.length) { - // Find the next occurrence of the URL, case-sensitive. - const nextOccurrence = resultBody.indexOf(url, currentIndex) + const markdownLink = `[${title}](${url})` + let currentIndex = 0 + const newBodyParts: string[] = [] - if (nextOccurrence === -1) { - // No more occurrences found, add the rest of the string - newBodyParts.push(resultBody.substring(currentIndex)) - break - } + while (currentIndex < resultBody.length) { + const nextOccurrence = resultBody.indexOf(url, currentIndex) - // Add the text segment before the match - newBodyParts.push( - resultBody.substring(currentIndex, nextOccurrence), - ) + if (nextOccurrence === -1) { + newBodyParts.push(resultBody.substring(currentIndex)) + break + } - // --- Context Check --- - let shouldReplace = true + newBodyParts.push( + resultBody.substring(currentIndex, nextOccurrence), + ) - // 1. Check if already part of a Markdown link: [...](url) - // Look for `](` immediately before the URL and `)` immediately after. - const precedingChars = resultBody.substring( - nextOccurrence - 2, - nextOccurrence, - ) - const followingChar = resultBody[nextOccurrence + url.length] - if (precedingChars === "](" && followingChar === ")") { - shouldReplace = false - } + let shouldReplace = true - // 2. Check if inside inline code: `... url ...` - // Count non-escaped backticks before the match. Odd count means inside code. - if (shouldReplace) { - const segmentBefore = resultBody.substring(0, nextOccurrence) - // Count non-escaped backticks `(?() let match - // --- Pre-calculate Fenced Code Block Ranges --- - const codeBlockRanges: { start: number, end: number }[] = [] - // Regex to find fenced code blocks (handles different fence lengths and optional language specifiers) - // Matches from ``` or ~~~ at the start of a line to the next ``` or ~~~ at the start of a line - const codeBlockRegex - = /^(?:```|~~~)[^\r\n]*?\r?\n([\s\S]*?)\r?\n^(?:```|~~~)$/gm - let blockMatch - while ((blockMatch = codeBlockRegex.exec(body)) !== null) { - codeBlockRanges.push({ - start: blockMatch.index, - end: blockMatch.index + blockMatch[0].length, - }) - } - // Reset regex state if needed, though new exec calls should handle this - codeBlockRegex.lastIndex = 0 + for (const segment of segmentMarkdown(body)) { + if (segment.kind === "protected") { + continue + } - while ((match = URL_REGEX.exec(body)) !== null) { - const url = match[0] - const matchIndex = match.index + URL_REGEX.lastIndex = 0 + while ((match = URL_REGEX.exec(segment.text)) !== null) { + const url = match[0] + const matchIndex = segment.start + match.index - // --- Fenced Code Block Check --- - // Check if the match index falls within any calculated code block range - let isInCodeBlock = false - for (const range of codeBlockRanges) { - if (matchIndex >= range.start && matchIndex < range.end) { - isInCodeBlock = true - break + let isBareUrl = true + + if (matchIndex >= 2) { + const followingCharIndex = matchIndex + url.length + if (followingCharIndex < body.length && body[followingCharIndex] === ")") { + const precedingChars = body.substring(matchIndex - 2, matchIndex) + if (precedingChars === "](") { + isBareUrl = false + } + } } - } - if (isInCodeBlock) { - continue // Skip this URL if it's inside a fenced code block - } - // --- Context Check --- - let isBareUrl = true - - // 1. Check if already part of a Markdown link: [...](url) - if (matchIndex >= 2) { // Need space for "](" - // Check if the URL is potentially followed by ')' - const followingCharIndex = matchIndex + url.length - if (followingCharIndex < body.length && body[followingCharIndex] === ")") { - // If followed by ')', check if preceded by "](" - const precedingChars = body.substring(matchIndex - 2, matchIndex) - if (precedingChars === "](") { - // Only if both conditions are met, it's a Markdown link + if (isBareUrl && matchIndex >= 1) { + const precedingChar = body[matchIndex - 1] + const followingChar = body[matchIndex + url.length] + if (precedingChar === "<" && followingChar === ">") { isBareUrl = false } } - } - // 2. Check if enclosed in angle brackets: - if (isBareUrl && matchIndex >= 1) { - const precedingChar = body[matchIndex - 1] - const followingChar = body[matchIndex + url.length] - if (precedingChar === "<" && followingChar === ">") { - isBareUrl = false - } - } + if (isBareUrl) { + let finalUrl = url + let shouldAdd = true - // 3. Check if inside inline code: `... url ...` - // Count non-escaped backticks before the match. Odd count means inside code. - if (isBareUrl) { - const segmentBefore = body.substring(0, matchIndex) - // Count non-escaped backticks `(? 0) { - try { - const parsedUrl = new URL(url) - const hostname = parsedUrl.hostname - if ( - ignoredDomains.some( - domain => - hostname === domain - || hostname.endsWith(`.${domain}`), + if (ignoredDomains && ignoredDomains.length > 0) { + try { + const parsedUrl = new URL(url) + const hostname = parsedUrl.hostname + if ( + ignoredDomains.some( + domain => + hostname === domain + || hostname.endsWith(`.${domain}`), + ) + ) { + shouldAdd = false + } + } + catch (e) { + console.warn( + `Failed to parse URL for domain check: ${url}`, + e, ) - ) { shouldAdd = false } } - catch (e) { - // If URL parsing fails, it's likely not a valid URL to add anyway - console.warn( - `Failed to parse URL for domain check: ${url}`, - e, - ) - shouldAdd = false - } - } - // 6. Clean Trailing Punctuation (only if not ignored) - // We only clean punctuation if the URL wasn't already excluded by context checks. - if (shouldAdd) { - const cleanedUrl = url.replace(TRAILING_PUNCTUATION_REGEX, "") - // Ensure cleaning didn't make it invalid (e.g., just "http://") - // or remove essential parts if the regex was too broad. - if (cleanedUrl.includes("://")) { - finalUrl = cleanedUrl // Use the cleaned URL only if it's still valid-looking + if (shouldAdd) { + const cleanedUrl = url.replace(TRAILING_PUNCTUATION_REGEX, "") + if (cleanedUrl.includes("://")) { + finalUrl = cleanedUrl + } + else { + finalUrl = url + console.warn(`URL cleaning potentially broke the URL: ${url} -> ${cleanedUrl}`) + } } - else { - // If cleaning resulted in an invalid URL, maybe don't add it, - // or reconsider the TRAILING_PUNCTUATION_REGEX. - // For now, let's stick with the original URL if cleaning fails badly. - // This case shouldn't happen often with the current regex. - finalUrl = url // Revert to original if cleaning broke it - console.warn(`URL cleaning potentially broke the URL: ${url} -> ${cleanedUrl}`) - // Decide if we should still add the original potentially dirty url - // shouldAdd = false; // Option: Don't add if cleaning failed - } - } - // --- Add URL if context checks pass and not ignored --- - if (shouldAdd) { - urls.add(finalUrl) + if (shouldAdd) { + urls.add(finalUrl) + } } } - - // Reset regex lastIndex to avoid issues with overlapping matches or zero-length matches - // Although our URL regex shouldn't produce zero-length matches. - // If the regex finds a match at index `i`, the next search starts at `i + 1`. - // If the match was length `l`, `exec` updates `lastIndex` to `i + l`. - // We need to ensure progress even if `l` is 0, but `URL_REGEX` won't match empty. - // If `isBareUrl` logic modified the string or indices, care would be needed. } return urls From db05c2ee1fd669489399df995042c7ec0a3381f7 Mon Sep 17 00:00:00 2001 From: Kodai Nakamura Date: Mon, 22 Jun 2026 03:05:52 +0900 Subject: [PATCH 18/25] fix(markdown): protect variable-length fenced code blocks Why: Markdown fence handling regressed when the shared segmenter only recognized backticks and exact triple fences. That let link and URL rewriting leak into supported fenced code blocks. What: Add fenced-code range collection with opening/closing fence length matching, include tilde-only input in the fast path, and keep fenced blocks out of the generic protected matcher. Add regressions for tilde fences, wider backtick fences, and the URL/link consumers that rely on markdown segmentation. --- src/__tests__/markdown-segments.test.ts | 15 +++++++ src/markdown-segments.ts | 42 ++++++++++++++++++- .../__tests__/replace-links.code.test.ts | 42 +++++++++++++++++++ .../__tests__/replace-url-with-title.test.ts | 13 ++++++ .../utils/__tests__/list-up-all-urls.test.ts | 6 +++ 5 files changed, 116 insertions(+), 2 deletions(-) diff --git a/src/__tests__/markdown-segments.test.ts b/src/__tests__/markdown-segments.test.ts index 3d9821f..c9adb6f 100644 --- a/src/__tests__/markdown-segments.test.ts +++ b/src/__tests__/markdown-segments.test.ts @@ -32,6 +32,21 @@ describe("segmentMarkdown", () => { ]) }) + it("protects tilde fenced code blocks", () => { + const text = "before\n~~~ts\nTypeScript\n~~~\nafter" + const segments = segmentMarkdown(text) + + expect(segments.map(segment => ({ + kind: segment.kind, + protectedKind: segment.protectedKind, + text: segment.text, + }))).toEqual([ + { kind: "prose", protectedKind: undefined, text: "before\n" }, + { kind: "protected", protectedKind: "fenced-code", text: "~~~ts\nTypeScript\n~~~\n" }, + { kind: "prose", protectedKind: undefined, text: "after" }, + ]) + }) + it("protects headings, tables, and callouts when requested", () => { const text = "# TypeScript\n| TypeScript |\n> [!note]\n> TypeScript\nTypeScript" const segments = segmentMarkdown(text, { diff --git a/src/markdown-segments.ts b/src/markdown-segments.ts index bffa45d..f54cdea 100644 --- a/src/markdown-segments.ts +++ b/src/markdown-segments.ts @@ -92,10 +92,45 @@ const collectTableRowRanges = (text: string): ProtectedRange[] => { return ranges } +const escapeRegExp = (text: string): string => + text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + +const collectFencedCodeRanges = (text: string): ProtectedRange[] => { + if (!text.includes("```") && !text.includes("~~~")) { + return [] + } + + const ranges: ProtectedRange[] = [] + const openingFencePattern = /^ {0,3}(`{3,}|~{3,})[^\r\n]*(?:\r?\n|$)/gm + let openingMatch: RegExpExecArray | null + + while ((openingMatch = openingFencePattern.exec(text)) !== null) { + const openingFence = openingMatch[1] + const fenceChar = openingFence[0] + const fenceLength = openingFence.length + const closingFencePattern = new RegExp( + `^ {0,3}${escapeRegExp(fenceChar)}{${fenceLength},}[ \\t]*(?:\\r?\\n|$)`, + "gm", + ) + closingFencePattern.lastIndex = openingMatch.index + openingMatch[0].length + const closingMatch = closingFencePattern.exec(text) + const end = closingMatch + ? closingMatch.index + closingMatch[0].length + : text.length + + ranges.push({ + start: openingMatch.index, + end, + protectedKind: "fenced-code", + }) + openingFencePattern.lastIndex = end + } + + return ranges +} + const buildProtectedPattern = (protectUrls: boolean): RegExp => { const parts = [ - "```[\\s\\S]*?(?:```|$)", - "~~~[\\s\\S]*?(?:~~~|$)", "`[^`]*`", "\\[\\[[^\\]]+\\]\\]", "\\[[^\\]]+\\]\\([^)]+\\)", @@ -160,6 +195,7 @@ export const segmentMarkdown = ( options: SegmentMarkdownOptions = {}, ): MarkdownSegment[] => { const mayContainProtectedMarkdown = text.includes("`") + || text.includes("~") || text.includes("[") || (options.protectHeadings && text.includes("#")) || (options.protectCallouts && text.includes(">")) @@ -189,6 +225,8 @@ export const segmentMarkdown = ( ranges.push(...collectTableRowRanges(text)) } + ranges.push(...collectFencedCodeRanges(text)) + const protectedPattern = buildProtectedPattern(options.protectUrls ?? false) let match: RegExpExecArray | null diff --git a/src/replace-links/__tests__/replace-links.code.test.ts b/src/replace-links/__tests__/replace-links.code.test.ts index 2d6f0da..a16237b 100644 --- a/src/replace-links/__tests__/replace-links.code.test.ts +++ b/src/replace-links/__tests__/replace-links.code.test.ts @@ -45,6 +45,48 @@ describe("ignore code", () => { expect(result).toBe("```typescript\nexample\n```") }) + it("tilde code block", () => { + const settings = { + scoped: false, + baseDir: undefined, + } + const { candidateMap, trie } = buildCandidateTrieForTest({ + files: [{ path: "example" }, { path: "typescript" }], + settings, + }) + const result = replaceLinks({ + body: "~~~typescript\nexample\n~~~", + linkResolverContext: { + filePath: "journals/2022-01-01", + trie, + candidateMap, + }, + settings, + }) + expect(result).toBe("~~~typescript\nexample\n~~~") + }) + + it("wide code fence with embedded triple backticks", () => { + const settings = { + scoped: false, + baseDir: undefined, + } + const { candidateMap, trie } = buildCandidateTrieForTest({ + files: [{ path: "example" }, { path: "typescript" }], + settings, + }) + const result = replaceLinks({ + body: "````\nnot end ```\nexample\n````", + linkResolverContext: { + filePath: "journals/2022-01-01", + trie, + candidateMap, + }, + settings, + }) + expect(result).toBe("````\nnot end ```\nexample\n````") + }) + it("unclosed code block", () => { const settings = { scoped: false, diff --git a/src/replace-url-with-title/__tests__/replace-url-with-title.test.ts b/src/replace-url-with-title/__tests__/replace-url-with-title.test.ts index 7455cae..d8d488a 100644 --- a/src/replace-url-with-title/__tests__/replace-url-with-title.test.ts +++ b/src/replace-url-with-title/__tests__/replace-url-with-title.test.ts @@ -63,4 +63,17 @@ describe("replaceUrlWithTitle", () => { "Line 1: [Example Title](https://example.com)\nLine 2: [Another Title](https://another.com)", ) }) + + it("should ignore URLs inside tilde fenced code blocks", () => { + const body = "~~~\nhttps://example.com\n~~~" + const result = replaceUrlWithTitle({ + body, + urlTitleMap: new Map( + Object.entries({ + "https://example.com": "Example Title", + }), + ), + }) + expect(result).toBe("~~~\nhttps://example.com\n~~~") + }) }) diff --git a/src/replace-url-with-title/utils/__tests__/list-up-all-urls.test.ts b/src/replace-url-with-title/utils/__tests__/list-up-all-urls.test.ts index 327fa93..18be74a 100644 --- a/src/replace-url-with-title/utils/__tests__/list-up-all-urls.test.ts +++ b/src/replace-url-with-title/utils/__tests__/list-up-all-urls.test.ts @@ -32,6 +32,12 @@ describe("listupAllUrls", () => { expect(result).not.toContain("https://example.com") }) + it("should ignore URLs inside tilde fenced code blocks", () => { + const body = "~~~\nhttps://example.com\n~~~" + const result = listupAllUrls(body) + expect(result).not.toContain("https://example.com") + }) + it("ignore domains", () => { const body = "Check this link: https://example.com" const result = listupAllUrls(body, ["example.com"]) From 29582da57538a00b1b730bde82ec8b11ff69e802 Mon Sep 17 00:00:00 2001 From: Kodai Nakamura Date: Mon, 22 Jun 2026 03:13:34 +0900 Subject: [PATCH 19/25] fix(markdown): preserve table context after wikilink rewrites Why: resolved wikilink rewrites can shift segment offsets before the prose pass that decides whether a row is inside a markdown table, which breaks alias escaping in table links. Angle-bracket autolinks also need to stay untouched when URL titles are available. What: use the rewritten body when checking table context in the second prose-mapping pass, and skip URL-title replacement when a matched URL is wrapped in angle brackets. Add regression tests for both cases. --- .../__tests__/replace-links.table.test.ts | 29 +++++++++++++++++++ src/replace-links/replace-links.ts | 6 ++-- .../__tests__/replace-url-with-title.test.ts | 13 +++++++++ src/replace-url-with-title/index.ts | 6 +++- 4 files changed, 50 insertions(+), 4 deletions(-) diff --git a/src/replace-links/__tests__/replace-links.table.test.ts b/src/replace-links/__tests__/replace-links.table.test.ts index 4d98a83..9f6ae2d 100644 --- a/src/replace-links/__tests__/replace-links.table.test.ts +++ b/src/replace-links/__tests__/replace-links.table.test.ts @@ -108,6 +108,35 @@ note1 | Test Item | | | --- | --- | | [[note1]] | | +`) + }) + + it("escapes table aliases after earlier resolved wikilinks change segment offsets", () => { + const settings = { + scoped: false, + baseDir: undefined, + proximityBasedLinking: true, + } + const { candidateMap, trie } = buildCandidateTrieForTest({ + files: [{ path: "ns/note1" }], + settings, + }) + const result = replaceLinks({ + body: `[[note1]] +| note1 | | +`, + linkResolverContext: { + filePath: "journals/2022-01-01", + trie, + candidateMap, + }, + settings, + resolvedAmbiguities: new Map([ + ["[[note1]]", "very/long/path/note1|note1"], + ]), + }) + expect(result).toBe(`[[very/long/path/note1|note1]] +| [[ns/note1\\|note1]] | | `) }) }) diff --git a/src/replace-links/replace-links.ts b/src/replace-links/replace-links.ts index 4800dbf..b4ced09 100644 --- a/src/replace-links/replace-links.ts +++ b/src/replace-links/replace-links.ts @@ -647,6 +647,7 @@ export const replaceLinks = ({ // Get the current namespace const currentNamespace = getCurrentNamespace(filePath, settings.baseDir) + let bodyWithResolvedWikilinks = body const markdownOptions = { protectHeadings: settings.ignoreHeadings, @@ -723,7 +724,7 @@ export const replaceLinks = ({ ): string => { if (!text.includes("\n")) { const isInTable = !settings.ignoreMarkdownTables - && isIndexInsideMarkdownTable(body, segment.start) + && isIndexInsideMarkdownTable(bodyWithResolvedWikilinks, segment.start) return processTextSegment(text, isInTable) } @@ -742,13 +743,12 @@ export const replaceLinks = ({ const absoluteIndex = segment.start + offset const isInTable = !settings.ignoreMarkdownTables - && isIndexInsideMarkdownTable(body, absoluteIndex) + && isIndexInsideMarkdownTable(bodyWithResolvedWikilinks, absoluteIndex) return processTextSegment(line, isInTable) }) } - let bodyWithResolvedWikilinks = body if (resolvedAmbiguities) { bodyWithResolvedWikilinks = segmentMarkdown(body, markdownOptions) .map((segment) => { diff --git a/src/replace-url-with-title/__tests__/replace-url-with-title.test.ts b/src/replace-url-with-title/__tests__/replace-url-with-title.test.ts index d8d488a..85e3178 100644 --- a/src/replace-url-with-title/__tests__/replace-url-with-title.test.ts +++ b/src/replace-url-with-title/__tests__/replace-url-with-title.test.ts @@ -76,4 +76,17 @@ describe("replaceUrlWithTitle", () => { }) expect(result).toBe("~~~\nhttps://example.com\n~~~") }) + + it("should not replace angle-bracket autolinks", () => { + const body = "" + const result = replaceUrlWithTitle({ + body, + urlTitleMap: new Map( + Object.entries({ + "https://example.com": "Example Title", + }), + ), + }) + expect(result).toBe("") + }) }) diff --git a/src/replace-url-with-title/index.ts b/src/replace-url-with-title/index.ts index 7eca4cc..93d3ac0 100644 --- a/src/replace-url-with-title/index.ts +++ b/src/replace-url-with-title/index.ts @@ -50,8 +50,12 @@ export const replaceUrlWithTitle = ({ nextOccurrence - 2, nextOccurrence, ) + const precedingChar = resultBody[nextOccurrence - 1] const followingChar = resultBody[nextOccurrence + url.length] - if (precedingChars === "](" && followingChar === ")") { + if ( + (precedingChars === "](" && followingChar === ")") + || (precedingChar === "<" && followingChar === ">") + ) { shouldReplace = false } From 621b15a7a1a8e98a73544f12d012720f4f204359 Mon Sep 17 00:00:00 2001 From: Kodai Nakamura Date: Mon, 22 Jun 2026 03:22:28 +0900 Subject: [PATCH 20/25] refactor(replace-urls): centralize prose-aware URL formatting Why: - URL formatting orchestration was split across formatting-run and the GitHub adapter, which made adapter ordering harder to reason about. - Task 4 added prose segmentation, and URL formatting needed to use it so protected Markdown stays untouched while still supporting linear:// URLs. What: - add a dedicated url-formatting module that applies GitHub, Jira, and Linear adapters in one prose-aware pass - route formatting-run document/body URL formatting through the central formatter and remove cross-adapter orchestration from github.ts - extend markdown segmentation and compatibility tests to cover protected angle-bracket autolinks and linear:// matching --- src/__tests__/markdown-segments.test.ts | 9 ++ src/formatting-run.ts | 25 +---- src/markdown-segments.ts | 5 +- .../__tests__/replace-urls.test.ts | 18 +++ .../__tests__/url-formatting.test.ts | 104 ++++++++++++++++++ src/replace-urls/github.ts | 30 ----- src/replace-urls/replace-urls.ts | 15 +-- src/replace-urls/url-formatting.ts | 61 ++++++++++ 8 files changed, 210 insertions(+), 57 deletions(-) create mode 100644 src/replace-urls/__tests__/url-formatting.test.ts create mode 100644 src/replace-urls/url-formatting.ts diff --git a/src/__tests__/markdown-segments.test.ts b/src/__tests__/markdown-segments.test.ts index c9adb6f..4770b95 100644 --- a/src/__tests__/markdown-segments.test.ts +++ b/src/__tests__/markdown-segments.test.ts @@ -73,4 +73,13 @@ describe("mapMarkdownProse", () => { expect(result).toBe("TS `TypeScript` [[TypeScript]]") }) + + it("treats angle-bracket autolinks as protected segments", () => { + const result = mapMarkdownProse( + " https://example.com", + text => text.replace(/https:\/\/example\.com/g, "URL"), + ) + + expect(result).toBe(" URL") + }) }) diff --git a/src/formatting-run.ts b/src/formatting-run.ts index 4bc7c5e..244b976 100644 --- a/src/formatting-run.ts +++ b/src/formatting-run.ts @@ -4,10 +4,7 @@ import { replaceLinks, } from "./replace-links/replace-links" import { replaceUrlWithTitle } from "./replace-url-with-title" -import { formatGitHubURL } from "./replace-urls/github" -import { formatJiraURL } from "./replace-urls/jira" -import { formatLinearURL } from "./replace-urls/linear" -import { replaceURLs } from "./replace-urls/replace-urls" +import { formatURLsInText } from "./replace-urls/url-formatting" import { AutomaticLinkerSettings, projectReplaceLinksSettings, @@ -36,21 +33,11 @@ export { projectReplaceLinksSettings as toReplaceLinksSettings } from "./setting const formatMarkdownURLs = ( text: string, settings: AutomaticLinkerSettings, -): string => { - let updatedText = text - - if (settings.formatGitHubURLs) { - updatedText = replaceURLs(updatedText, settings, formatGitHubURL) - } - if (settings.formatJiraURLs) { - updatedText = replaceURLs(updatedText, settings, formatJiraURL) - } - if (settings.formatLinearURLs) { - updatedText = replaceURLs(updatedText, settings, formatLinearURL) - } - - return updatedText -} +): string => + formatURLsInText({ + text, + settings, + }) export const formatMarkdownBody = ({ body, diff --git a/src/markdown-segments.ts b/src/markdown-segments.ts index f54cdea..5eb0960 100644 --- a/src/markdown-segments.ts +++ b/src/markdown-segments.ts @@ -132,6 +132,7 @@ const collectFencedCodeRanges = (text: string): ProtectedRange[] => { const buildProtectedPattern = (protectUrls: boolean): RegExp => { const parts = [ "`[^`]*`", + "<(?:https?:\\/\\/|linear:\\/\\/)[^>]+>", "\\[\\[[^\\]]+\\]\\]", "\\[[^\\]]+\\]\\([^)]+\\)", "\\[[^\\]]+\\]", @@ -196,11 +197,13 @@ export const segmentMarkdown = ( ): MarkdownSegment[] => { const mayContainProtectedMarkdown = text.includes("`") || text.includes("~") + || text.includes("<") || text.includes("[") || (options.protectHeadings && text.includes("#")) || (options.protectCallouts && text.includes(">")) || (options.protectTableRows && text.includes("|")) - || (options.protectUrls && text.includes("http")) + || (options.protectUrls + && (text.includes("http") || text.includes("linear://"))) if (!mayContainProtectedMarkdown) { return [{ diff --git a/src/replace-urls/__tests__/replace-urls.test.ts b/src/replace-urls/__tests__/replace-urls.test.ts index d8a96c9..33f66f0 100644 --- a/src/replace-urls/__tests__/replace-urls.test.ts +++ b/src/replace-urls/__tests__/replace-urls.test.ts @@ -4,6 +4,7 @@ import { DEFAULT_SETTINGS, } from "../../settings/settings-info" import { formatGitHubURL } from "../github" +import { formatLinearURL } from "../linear" import { replaceURLs } from "../replace-urls" describe("replace-urls", () => { @@ -21,4 +22,21 @@ describe("replace-urls", () => { expected, ) }) + + it("should replace linear protocol URLs", () => { + const input = "linear://workspace/issue/ACME-123" + const expected + = "[[linear/workspace/ACME-123]] [🔗](https://linear.app/workspace/issue/ACME-123)" + + expect( + replaceURLs( + input, + { + ...baseSettings, + formatLinearURLs: true, + }, + formatLinearURL, + ), + ).toBe(expected) + }) }) diff --git a/src/replace-urls/__tests__/url-formatting.test.ts b/src/replace-urls/__tests__/url-formatting.test.ts new file mode 100644 index 0000000..349afce --- /dev/null +++ b/src/replace-urls/__tests__/url-formatting.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest" +import { DEFAULT_SETTINGS } from "../../settings/settings-info" +import { + formatURLsInText, + formatURLWithAdapters, + UrlFormatter, +} from "../url-formatting" + +describe("formatURLWithAdapters", () => { + it("uses the first adapter that changes the URL", () => { + const first: UrlFormatter = url => `${url}-first` + const second: UrlFormatter = url => `${url}-second` + + expect( + formatURLWithAdapters( + "https://example.com", + DEFAULT_SETTINGS, + [first, second], + ), + ).toBe("https://example.com-first") + }) + + it("returns the original URL when no adapter changes it", () => { + const unchanged: UrlFormatter = url => url + + expect( + formatURLWithAdapters( + "https://example.com", + DEFAULT_SETTINGS, + [unchanged], + ), + ).toBe("https://example.com") + }) +}) + +describe("formatURLsInText", () => { + it("formats GitHub, Jira, and Linear URLs in one text pass", () => { + const result = formatURLsInText({ + text: [ + "https://github.com/owner/repo/issues/123", + "https://jira.company.com/browse/ABC-456", + "https://linear.app/team/issue/BUG-789/title", + "linear://team/issue/BUG-790", + ].join("\n"), + settings: { + ...DEFAULT_SETTINGS, + githubEnterpriseURLs: [], + jiraURLs: ["jira.company.com"], + formatGitHubURLs: true, + formatJiraURLs: true, + formatLinearURLs: true, + }, + }) + + expect(result).toBe([ + "[[github/owner/repo/issues/123]] [🔗](https://github.com/owner/repo/issues/123)", + "[[company/jira/ABC/456]] [🔗](https://jira.company.com/browse/ABC-456)", + "[[linear/team/BUG-789]] [🔗](https://linear.app/team/issue/BUG-789)", + "[[linear/team/BUG-790]] [🔗](https://linear.app/team/issue/BUG-790)", + ].join("\n")) + }) + + it("leaves disabled adapter URLs unchanged", () => { + const result = formatURLsInText({ + text: "https://linear.app/team/issue/BUG-789/title", + settings: { + ...DEFAULT_SETTINGS, + formatLinearURLs: false, + }, + }) + + expect(result).toBe("https://linear.app/team/issue/BUG-789/title") + }) + + it("does not format URLs inside protected Markdown segments", () => { + const result = formatURLsInText({ + text: [ + "`https://github.com/owner/repo/issues/123`", + "```md", + "https://github.com/owner/repo/issues/123", + "```", + "[label](https://github.com/owner/repo/issues/123)", + "[[https://github.com/owner/repo/issues/123]]", + "", + "https://github.com/owner/repo/issues/123", + ].join("\n"), + settings: { + ...DEFAULT_SETTINGS, + formatGitHubURLs: true, + }, + }) + + expect(result).toBe([ + "`https://github.com/owner/repo/issues/123`", + "```md", + "https://github.com/owner/repo/issues/123", + "```", + "[label](https://github.com/owner/repo/issues/123)", + "[[https://github.com/owner/repo/issues/123]]", + "", + "[[github/owner/repo/issues/123]] [🔗](https://github.com/owner/repo/issues/123)", + ].join("\n")) + }) +}) diff --git a/src/replace-urls/github.ts b/src/replace-urls/github.ts index 015fc13..6f1484f 100644 --- a/src/replace-urls/github.ts +++ b/src/replace-urls/github.ts @@ -1,6 +1,4 @@ import { AutomaticLinkerSettings } from "../settings/settings-info" -import { formatJiraURL } from "./jira" -import { formatLinearURL } from "./linear" type GitHubURLInfo = { owner: string @@ -124,31 +122,3 @@ function isPullRequestOrIssueURL(url: URL): boolean { const path = url.pathname.toLowerCase() return path.includes("/pull/") || path.includes("/issues/") } - -export function formatURL( - url: string, - settings: AutomaticLinkerSettings, -): string { - if (settings.formatGitHubURLs) { - const formattedGitHubURL = formatGitHubURL(url, settings) - if (formattedGitHubURL !== url) { - return formattedGitHubURL - } - } - - if (settings.formatJiraURLs) { - const formattedJiraURL = formatJiraURL(url, settings) - if (formattedJiraURL !== url) { - return formattedJiraURL - } - } - - if (settings.formatLinearURLs) { - const formattedLinearURL = formatLinearURL(url, settings) - if (formattedLinearURL !== url) { - return formattedLinearURL - } - } - - return url -} diff --git a/src/replace-urls/replace-urls.ts b/src/replace-urls/replace-urls.ts index fabf103..51d706f 100644 --- a/src/replace-urls/replace-urls.ts +++ b/src/replace-urls/replace-urls.ts @@ -1,13 +1,14 @@ +import { mapMarkdownProse } from "../markdown-segments" import { AutomaticLinkerSettings } from "../settings/settings-info" +const URL_PATTERN = /(?:https?:\/\/|linear:\/\/)[^\s<>\]]+/g + export const replaceURLs = ( fileContent: string, settings: AutomaticLinkerSettings, formatter: (url: string, settings: AutomaticLinkerSettings) => string, -) => { - const githubUrlPattern = /(? { - return formatter(match, settings) - }) - return fileContent -} +) => + mapMarkdownProse( + fileContent, + prose => prose.replace(URL_PATTERN, match => formatter(match, settings)), + ) diff --git a/src/replace-urls/url-formatting.ts b/src/replace-urls/url-formatting.ts new file mode 100644 index 0000000..bf71856 --- /dev/null +++ b/src/replace-urls/url-formatting.ts @@ -0,0 +1,61 @@ +import { mapMarkdownProse } from "../markdown-segments" +import { AutomaticLinkerSettings } from "../settings/settings-info" +import { formatGitHubURL } from "./github" +import { formatJiraURL } from "./jira" +import { formatLinearURL } from "./linear" + +export type UrlFormatter = ( + url: string, + settings: AutomaticLinkerSettings, +) => string + +export interface FormatURLsInTextOptions { + text: string + settings: AutomaticLinkerSettings + formatters?: readonly UrlFormatter[] +} + +const URL_PATTERN = /(?:https?:\/\/|linear:\/\/)[^\s<>\]]+/g + +const formatGitHubURLIfEnabled: UrlFormatter = (url, settings) => + settings.formatGitHubURLs ? formatGitHubURL(url, settings) : url + +const formatJiraURLIfEnabled: UrlFormatter = (url, settings) => + settings.formatJiraURLs ? formatJiraURL(url, settings) : url + +const formatLinearURLIfEnabled: UrlFormatter = (url, settings) => + settings.formatLinearURLs ? formatLinearURL(url, settings) : url + +export const DEFAULT_URL_FORMATTERS: readonly UrlFormatter[] = [ + formatGitHubURLIfEnabled, + formatJiraURLIfEnabled, + formatLinearURLIfEnabled, +] + +export const formatURLWithAdapters = ( + url: string, + settings: AutomaticLinkerSettings, + formatters: readonly UrlFormatter[] = DEFAULT_URL_FORMATTERS, +): string => { + for (const formatter of formatters) { + const formatted = formatter(url, settings) + if (formatted !== url) { + return formatted + } + } + + return url +} + +export const formatURLsInText = ({ + text, + settings, + formatters = DEFAULT_URL_FORMATTERS, +}: FormatURLsInTextOptions): string => + mapMarkdownProse( + text, + prose => + prose.replace(URL_PATTERN, match => + formatURLWithAdapters(match, settings, formatters), + ), + ) From 3ddcf085389e15934e3e4bffc78bcc03bdd6cf3a Mon Sep 17 00:00:00 2001 From: Kodai Nakamura Date: Mon, 22 Jun 2026 03:33:12 +0900 Subject: [PATCH 21/25] fix: restore raw URL helper semantics Why: Task 5 centralized URL formatting but accidentally changed shared Markdown segmentation and the legacy replaceURLs compatibility path. What: remove angle-bracket autolink protection from shared segmentation, keep angle-bracket and trailing-punctuation handling inside formatURLsInText, and restore replaceURLs to a direct regex replace with linear:// support. --- src/__tests__/markdown-segments.test.ts | 4 +- src/markdown-segments.ts | 2 - .../__tests__/replace-urls.test.ts | 23 ++++++- .../__tests__/url-formatting.test.ts | 41 +++++++++++++ src/replace-urls/replace-urls.ts | 7 +-- src/replace-urls/url-formatting.ts | 61 +++++++++++++++++-- 6 files changed, 122 insertions(+), 16 deletions(-) diff --git a/src/__tests__/markdown-segments.test.ts b/src/__tests__/markdown-segments.test.ts index 4770b95..f64f4a0 100644 --- a/src/__tests__/markdown-segments.test.ts +++ b/src/__tests__/markdown-segments.test.ts @@ -74,12 +74,12 @@ describe("mapMarkdownProse", () => { expect(result).toBe("TS `TypeScript` [[TypeScript]]") }) - it("treats angle-bracket autolinks as protected segments", () => { + it("does not globally protect angle-bracket autolinks", () => { const result = mapMarkdownProse( " https://example.com", text => text.replace(/https:\/\/example\.com/g, "URL"), ) - expect(result).toBe(" URL") + expect(result).toBe(" URL") }) }) diff --git a/src/markdown-segments.ts b/src/markdown-segments.ts index 5eb0960..c0a37c9 100644 --- a/src/markdown-segments.ts +++ b/src/markdown-segments.ts @@ -132,7 +132,6 @@ const collectFencedCodeRanges = (text: string): ProtectedRange[] => { const buildProtectedPattern = (protectUrls: boolean): RegExp => { const parts = [ "`[^`]*`", - "<(?:https?:\\/\\/|linear:\\/\\/)[^>]+>", "\\[\\[[^\\]]+\\]\\]", "\\[[^\\]]+\\]\\([^)]+\\)", "\\[[^\\]]+\\]", @@ -197,7 +196,6 @@ export const segmentMarkdown = ( ): MarkdownSegment[] => { const mayContainProtectedMarkdown = text.includes("`") || text.includes("~") - || text.includes("<") || text.includes("[") || (options.protectHeadings && text.includes("#")) || (options.protectCallouts && text.includes(">")) diff --git a/src/replace-urls/__tests__/replace-urls.test.ts b/src/replace-urls/__tests__/replace-urls.test.ts index 33f66f0..fbf68d6 100644 --- a/src/replace-urls/__tests__/replace-urls.test.ts +++ b/src/replace-urls/__tests__/replace-urls.test.ts @@ -14,8 +14,7 @@ describe("replace-urls", () => { } it("should replace GitHub URLs", () => { - const input - = "[[github/kdnk/obsidian-automatic-linker]] [🔗](https://github.com/kdnk/obsidian-automatic-linker)" + const input = "https://github.com/kdnk/obsidian-automatic-linker" const expected = "[[github/kdnk/obsidian-automatic-linker]] [🔗](https://github.com/kdnk/obsidian-automatic-linker)" expect(replaceURLs(input, baseSettings, formatGitHubURL)).toBe( @@ -39,4 +38,24 @@ describe("replace-urls", () => { ), ).toBe(expected) }) + + it("keeps raw helper semantics inside Markdown links and angle autolinks", () => { + const input = [ + "[label](https://github.com/kdnk/obsidian-automatic-linker)", + "", + ].join(" ") + + const expected = [ + "[label](converted:https://github.com/kdnk/obsidian-automatic-linker)", + "", + ].join(" ") + + expect( + replaceURLs( + input, + baseSettings, + url => `converted:${url}`, + ), + ).toBe(expected) + }) }) diff --git a/src/replace-urls/__tests__/url-formatting.test.ts b/src/replace-urls/__tests__/url-formatting.test.ts index 349afce..f72ec0c 100644 --- a/src/replace-urls/__tests__/url-formatting.test.ts +++ b/src/replace-urls/__tests__/url-formatting.test.ts @@ -60,6 +60,18 @@ describe("formatURLsInText", () => { ].join("\n")) }) + it("does not format GitHub URLs wrapped in angle brackets", () => { + const result = formatURLsInText({ + text: "", + settings: { + ...DEFAULT_SETTINGS, + formatGitHubURLs: true, + }, + }) + + expect(result).toBe("") + }) + it("leaves disabled adapter URLs unchanged", () => { const result = formatURLsInText({ text: "https://linear.app/team/issue/BUG-789/title", @@ -72,6 +84,35 @@ describe("formatURLsInText", () => { expect(result).toBe("https://linear.app/team/issue/BUG-789/title") }) + it("leaves Jira URLs with query strings unchanged", () => { + const result = formatURLsInText({ + text: "https://jira.company.com/browse/ABC-456?focusedCommentId=12345", + settings: { + ...DEFAULT_SETTINGS, + jiraURLs: ["jira.company.com"], + formatJiraURLs: true, + }, + }) + + expect(result).toBe( + "https://jira.company.com/browse/ABC-456?focusedCommentId=12345", + ) + }) + + it("keeps closing parens and trailing punctuation outside formatted URLs", () => { + const result = formatURLsInText({ + text: "See (https://github.com/owner/repo/issues/123).", + settings: { + ...DEFAULT_SETTINGS, + formatGitHubURLs: true, + }, + }) + + expect(result).toBe( + "See ([[github/owner/repo/issues/123]] [🔗](https://github.com/owner/repo/issues/123)).", + ) + }) + it("does not format URLs inside protected Markdown segments", () => { const result = formatURLsInText({ text: [ diff --git a/src/replace-urls/replace-urls.ts b/src/replace-urls/replace-urls.ts index 51d706f..75f5c63 100644 --- a/src/replace-urls/replace-urls.ts +++ b/src/replace-urls/replace-urls.ts @@ -1,4 +1,3 @@ -import { mapMarkdownProse } from "../markdown-segments" import { AutomaticLinkerSettings } from "../settings/settings-info" const URL_PATTERN = /(?:https?:\/\/|linear:\/\/)[^\s<>\]]+/g @@ -7,8 +6,4 @@ export const replaceURLs = ( fileContent: string, settings: AutomaticLinkerSettings, formatter: (url: string, settings: AutomaticLinkerSettings) => string, -) => - mapMarkdownProse( - fileContent, - prose => prose.replace(URL_PATTERN, match => formatter(match, settings)), - ) +) => fileContent.replace(URL_PATTERN, match => formatter(match, settings)) diff --git a/src/replace-urls/url-formatting.ts b/src/replace-urls/url-formatting.ts index bf71856..e652547 100644 --- a/src/replace-urls/url-formatting.ts +++ b/src/replace-urls/url-formatting.ts @@ -26,6 +26,48 @@ const formatJiraURLIfEnabled: UrlFormatter = (url, settings) => const formatLinearURLIfEnabled: UrlFormatter = (url, settings) => settings.formatLinearURLs ? formatLinearURL(url, settings) : url +const TRAILING_PUNCTUATION = new Set([".", ",", ";", "!", "?", "]", "}"]) + +const countCharacter = (text: string, character: string): number => { + let count = 0 + + for (const currentCharacter of text) { + if (currentCharacter === character) { + count += 1 + } + } + + return count +} + +const splitTrailingBoundary = (match: string): { url: string, suffix: string } => { + let url = match + let suffix = "" + + while (url.length > 0) { + const lastCharacter = url[url.length - 1] + + if (TRAILING_PUNCTUATION.has(lastCharacter)) { + suffix = lastCharacter + suffix + url = url.slice(0, -1) + continue + } + + if ( + lastCharacter === ")" + && countCharacter(url, "(") < countCharacter(url, ")") + ) { + suffix = lastCharacter + suffix + url = url.slice(0, -1) + continue + } + + break + } + + return { url, suffix } +} + export const DEFAULT_URL_FORMATTERS: readonly UrlFormatter[] = [ formatGitHubURLIfEnabled, formatJiraURLIfEnabled, @@ -54,8 +96,19 @@ export const formatURLsInText = ({ }: FormatURLsInTextOptions): string => mapMarkdownProse( text, - prose => - prose.replace(URL_PATTERN, match => - formatURLWithAdapters(match, settings, formatters), - ), + (prose, segment) => + prose.replace(URL_PATTERN, (match, offset) => { + const precedingCharacter = offset > 0 + ? segment.text[offset - 1] + : undefined + const followingCharacter = segment.text[offset + match.length] + + if (precedingCharacter === "<" && followingCharacter === ">") { + return match + } + + const { url, suffix } = splitTrailingBoundary(match) + + return formatURLWithAdapters(url, settings, formatters) + suffix + }), ) From cf5562f0cb3885c544973879e1a1375f1ae72d8c Mon Sep 17 00:00:00 2001 From: Kodai Nakamura Date: Mon, 22 Jun 2026 03:41:26 +0900 Subject: [PATCH 22/25] fix: restore wikilink protection for URL replacement Why: Linear URLs were being rewritten inside existing wikilinks, and protected markdown segments were not treating linear:// URLs as URLs. That regressed helper compatibility and selection safety. What: - Skip raw URL replacement matches that fall inside wikilinks while keeping the helper direct and linear-aware. - Extend protected markdown URL segmentation to include linear://. - Add regression tests for raw replacement, segmentation, and selection formatting. --- src/__tests__/formatting-run.test.ts | 30 +++++++++++++++++ src/__tests__/markdown-segments.test.ts | 17 ++++++++++ src/markdown-segments.ts | 2 +- .../__tests__/replace-urls.test.ts | 23 +++++++++++++ src/replace-urls/replace-urls.ts | 32 ++++++++++++++++++- 5 files changed, 102 insertions(+), 2 deletions(-) diff --git a/src/__tests__/formatting-run.test.ts b/src/__tests__/formatting-run.test.ts index bd89042..ed43131 100644 --- a/src/__tests__/formatting-run.test.ts +++ b/src/__tests__/formatting-run.test.ts @@ -175,4 +175,34 @@ describe("formatMarkdownSelection", () => { "[[notes/TypeScript|TypeScript]] https://github.com/openai/openai/issues/1", ) }) + + it("leaves linear URLs unchanged when a linear note exists", () => { + const { candidateMap, trie } = buildCandidateTrieForTest({ + files: [ + { path: "notes/linear" }, + { path: "notes/TypeScript" }, + ], + settings: { + scoped: false, + baseDir: undefined, + ignoreCase: true, + }, + }) + + const result = formatMarkdownSelection({ + body: "linear://workspace/issue/ACME-123", + filePath: "current-file.md", + settings: { + ...DEFAULT_SETTINGS, + formatGitHubURLs: true, + formatJiraURLs: true, + formatLinearURLs: true, + replaceUrlWithTitle: true, + ignoreCase: true, + }, + candidateIndex: { candidateMap, trie }, + }) + + expect(result).toBe("linear://workspace/issue/ACME-123") + }) }) diff --git a/src/__tests__/markdown-segments.test.ts b/src/__tests__/markdown-segments.test.ts index f64f4a0..2a16760 100644 --- a/src/__tests__/markdown-segments.test.ts +++ b/src/__tests__/markdown-segments.test.ts @@ -62,6 +62,23 @@ describe("segmentMarkdown", () => { ]) expect(segments.map(segment => segment.text).join("")).toBe(text) }) + + it("protects linear URLs when requested", () => { + const text = "linear://workspace/issue/ACME-123" + const segments = segmentMarkdown(text, { + protectUrls: true, + }) + + expect(segments).toEqual([ + { + kind: "protected", + protectedKind: "url", + start: 0, + end: text.length, + text, + }, + ]) + }) }) describe("mapMarkdownProse", () => { diff --git a/src/markdown-segments.ts b/src/markdown-segments.ts index c0a37c9..37e9a62 100644 --- a/src/markdown-segments.ts +++ b/src/markdown-segments.ts @@ -138,7 +138,7 @@ const buildProtectedPattern = (protectUrls: boolean): RegExp => { ] if (protectUrls) { - parts.push("https?:\\/\\/[^\\s]+") + parts.push("(?:https?:\\/\\/|linear:\\/\\/)[^\\s]+") } return new RegExp(`(${parts.join("|")})`, "g") diff --git a/src/replace-urls/__tests__/replace-urls.test.ts b/src/replace-urls/__tests__/replace-urls.test.ts index fbf68d6..e6684b5 100644 --- a/src/replace-urls/__tests__/replace-urls.test.ts +++ b/src/replace-urls/__tests__/replace-urls.test.ts @@ -22,6 +22,14 @@ describe("replace-urls", () => { ) }) + it("does not rewrite https URLs inside wikilinks", () => { + const input = "[[https://github.com/kdnk/obsidian-automatic-linker]]" + + expect( + replaceURLs(input, baseSettings, formatGitHubURL), + ).toBe(input) + }) + it("should replace linear protocol URLs", () => { const input = "linear://workspace/issue/ACME-123" const expected @@ -39,6 +47,21 @@ describe("replace-urls", () => { ).toBe(expected) }) + it("does not rewrite linear protocol URLs inside wikilinks", () => { + const input = "[[linear://workspace/issue/ACME-123]]" + + expect( + replaceURLs( + input, + { + ...baseSettings, + formatLinearURLs: true, + }, + formatLinearURL, + ), + ).toBe(input) + }) + it("keeps raw helper semantics inside Markdown links and angle autolinks", () => { const input = [ "[label](https://github.com/kdnk/obsidian-automatic-linker)", diff --git a/src/replace-urls/replace-urls.ts b/src/replace-urls/replace-urls.ts index 75f5c63..5587d41 100644 --- a/src/replace-urls/replace-urls.ts +++ b/src/replace-urls/replace-urls.ts @@ -1,9 +1,39 @@ import { AutomaticLinkerSettings } from "../settings/settings-info" const URL_PATTERN = /(?:https?:\/\/|linear:\/\/)[^\s<>\]]+/g +const WIKILINK_PATTERN = /\[\[[\s\S]*?\]\]/g + +const collectWikilinkRanges = (text: string): Array<{ start: number, end: number }> => { + const ranges: Array<{ start: number, end: number }> = [] + let match: RegExpExecArray | null + + while ((match = WIKILINK_PATTERN.exec(text)) !== null) { + ranges.push({ + start: match.index, + end: match.index + match[0].length, + }) + } + + return ranges +} + +const isInsideRange = ( + index: number, + ranges: Array<{ start: number, end: number }>, +): boolean => ranges.some(range => index >= range.start && index < range.end) export const replaceURLs = ( fileContent: string, settings: AutomaticLinkerSettings, formatter: (url: string, settings: AutomaticLinkerSettings) => string, -) => fileContent.replace(URL_PATTERN, match => formatter(match, settings)) +) => { + const wikilinkRanges = collectWikilinkRanges(fileContent) + + return fileContent.replace(URL_PATTERN, (match, offset) => { + if (isInsideRange(offset, wikilinkRanges)) { + return match + } + + return formatter(match, settings) + }) +} From da624f7cd11b314482424881e897aaef09984c37 Mon Sep 17 00:00:00 2001 From: Kodai Nakamura Date: Mon, 22 Jun 2026 03:46:19 +0900 Subject: [PATCH 23/25] chore: remove tracked sdd task report Why: - SDD task reports are local coordination artifacts and should not be included in the repository tree. What: - Remove the accidentally tracked Task 1 report while leaving local ignored SDD artifacts available for this session. --- .superpowers/sdd/task-1-report.md | 477 ------------------------------ 1 file changed, 477 deletions(-) delete mode 100644 .superpowers/sdd/task-1-report.md diff --git a/.superpowers/sdd/task-1-report.md b/.superpowers/sdd/task-1-report.md deleted file mode 100644 index 92e7525..0000000 --- a/.superpowers/sdd/task-1-report.md +++ /dev/null @@ -1,477 +0,0 @@ -# Task 1 Report: Candidate Scanning Module - -## Scope - -Implemented Task 1 from `.superpowers/sdd/task-1-brief.md`: - -- Created `src/replace-links/candidate-scanner.ts` -- Created `src/replace-links/__tests__/candidate-scanner.test.ts` -- Updated `src/replace-links/replace-links.ts` -- Updated `src/utils/resolve-ambiguities.ts` - -No AGENTS.md changes were needed. - -## TDD Evidence - -### RED - -Command: - -```bash -npx vitest run src/replace-links/__tests__/candidate-scanner.test.ts -``` - -Result: - -- Failed as expected. -- Failure cause: `Cannot find module '../candidate-scanner'`. - -### GREEN - -Command: - -```bash -npx vitest run src/replace-links/__tests__/candidate-scanner.test.ts -``` - -Result: - -- Passed: `4/4` tests. - -## Implementation Summary - -- Extracted shared scanning helpers from `replace-links.ts` into `candidate-scanner.ts`. -- Added `scanCandidateOccurrences()` for: - - unlinked candidate occurrences - - existing wikilink occurrences -- Added `getOccurrenceContext()` for bounded AI request context. -- Preserved `replaceLinks()` rendering behavior by importing the moved helpers back into `replace-links.ts`. -- Routed `resolveAmbiguities()` through the shared scanner instead of maintaining separate trie scanning logic. - -## Focused Verification - -Command: - -```bash -npx vitest run src/utils/__tests__/resolve-ambiguities.test.ts src/replace-links/__tests__/ai-disambiguation.test.ts src/replace-links/__tests__/replace-links.basic.test.ts -``` - -Result: - -- Passed: `19/19` tests across `3/3` files. - -## Full Stage Verification - -Commands: - -```bash -npm run test -- --reporter=dot -npm run tsc -npm run lint -``` - -Results: - -- `npm run test -- --reporter=dot`: passed, `320/320` tests across `38/38` files -- `npm run tsc`: passed -- `npm run lint`: passed - -Note: - -- The first full-suite run hit a transient failure in the namespace-resolution performance test. -- Re-running the full stage verification passed cleanly, including that test (`Namespace resolution processed in 290.97ms`, below the `300ms` threshold). - -## Commit - -Created commit: - -- `953688d` `refactor(replace-links): centralize candidate scanning` - -## Files Changed - -- `src/replace-links/candidate-scanner.ts` -- `src/replace-links/__tests__/candidate-scanner.test.ts` -- `src/replace-links/replace-links.ts` -- `src/utils/resolve-ambiguities.ts` - -## Brief Compliance Check - -- Used the exact Task 1 file set from the brief. -- Followed RED then GREEN before production code claims. -- Ran the focused tests from the brief. -- Ran the full verification commands from the brief. -- Used the specified Conventional Commit message with `Why` and `What`. - -## Review Fix 1 - -### Scope - -Fixed the Task 1 review findings for candidate scanning drift: - -- aligned trie-hit namespace handling in `scanCandidateOccurrences()` with current `replaceLinks()` behavior -- aligned scanner protected-region handling with current `replaceLinks()` block-level shielding for fenced code blocks, callouts, and ignored headings -- added regression coverage for both drift cases - -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/utils/__tests__/resolve-ambiguities.test.ts -``` - -Results: - -- `candidate-scanner.test.ts` failed on the new trie-hit namespace semantics regression. -- `candidate-scanner.test.ts` failed on the new fenced-code/callout/ignored-heading regression. -- `resolve-ambiguities.test.ts` failed on the new protected-region regression. - -#### GREEN - -Commands: - -```bash -npx vitest run src/replace-links/__tests__/candidate-scanner.test.ts -npx vitest run src/utils/__tests__/resolve-ambiguities.test.ts src/replace-links/__tests__/ai-disambiguation.test.ts src/replace-links/__tests__/replace-links.callout.test.ts src/replace-links/__tests__/replace-links.headings.test.ts src/replace-links/__tests__/replace-links.code.test.ts -``` - -Results: - -- `candidate-scanner.test.ts`: passed `6/6` -- focused AI/replacement suite: passed `47/47` across `5/5` files - -### Implementation Summary - -- Reworked scanner block protection to skip the same fenced code blocks, callouts, and optional heading regions that `replaceLinks()` excludes before protected-regex scanning. -- Preserved absolute occurrence offsets while scanning only unprotected regions. -- Changed trie-hit scanner behavior to keep the original candidate set and use the first candidate for scoped/self-link checks, matching current `replaceLinks()` semantics. -- Updated scanner regressions to reflect current trie-hit behavior and added AI-side coverage to ensure protected regions are not scanned. - -### Full Verification - -Commands: - -```bash -npm run test -- --reporter=dot -npm run tsc -npm run lint -``` - -Results: - -- `npm run test -- --reporter=dot`: passed, `323/323` tests across `38/38` files -- `npm run tsc`: passed -- `npm run lint`: passed - -## Review Fix 2 - -### Scope - -Fixed the remaining Task 1 review finding: - -- `scanCandidateOccurrences()` now skips existing wikilinks that fall inside inline code spans, not just block-level protected ranges -- `resolveAmbiguities()` coverage now proves the protected inline wikilink produces no AI request payload - -No AGENTS.md changes were needed. - -### Focused Verification - -Commands: - -```bash -npx vitest run src/replace-links/__tests__/candidate-scanner.test.ts -npx vitest run src/utils/__tests__/resolve-ambiguities.test.ts -``` - -Results: - -- `src/replace-links/__tests__/candidate-scanner.test.ts`: passed `7/7` -- `src/utils/__tests__/resolve-ambiguities.test.ts`: passed `4/4` - -### Full Verification - -Commands: - -```bash -npm run test -- --reporter=dot -npm run tsc -npm run lint -``` - -Results: - -- `npm run test -- --reporter=dot`: passed, `325/325` tests across `38/38` files -- `npm run tsc`: passed -- `npm run lint`: passed - -## Review Fix 3 - -### Scope - -Fixed the remaining Task 1 Korean special-case scanner/replacement drift: - -- `scanCandidateOccurrences()` now skips Korean particle trie hits like `문서는` / `문서은`, matching the current non-linking `replaceLinks()` behavior for that branch -- `replaceLinks()` now honors `resolvedAmbiguities` for the Korean suffix branch like `문서이다`, so AI-resolved targets are applied consistently -- added regressions covering scanner omission, AI request omission, and Korean suffix replacement resolution - -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/utils/__tests__/resolve-ambiguities.test.ts -npx vitest run src/replace-links/__tests__/ai-disambiguation.test.ts -``` - -Results: - -- `candidate-scanner.test.ts`: failed because `scanCandidateOccurrences()` emitted `문서` for `문서는` and `문서은` -- `resolve-ambiguities.test.ts`: failed because the scanner still generated an AI ambiguity request for Korean particle forms -- `ai-disambiguation.test.ts`: failed because `replaceLinks()` ignored `resolvedAmbiguities` for the Korean `이다` suffix branch and kept the default candidate - -#### GREEN - -Commands: - -```bash -npx vitest run src/replace-links/__tests__/candidate-scanner.test.ts -npx vitest run src/utils/__tests__/resolve-ambiguities.test.ts -npx vitest run src/replace-links/__tests__/ai-disambiguation.test.ts -``` - -Results: - -- `src/replace-links/__tests__/candidate-scanner.test.ts`: passed `8/8` -- `src/utils/__tests__/resolve-ambiguities.test.ts`: passed `5/5` -- `src/replace-links/__tests__/ai-disambiguation.test.ts`: passed `4/4` - -### Implementation Summary - -- Added an internal scanner guard that skips Korean trie hits when the matched term is immediately followed by the `는` or `은` particle. -- Introduced a shared link-content resolver in `replace-links.ts` so the normal ambiguity-resolution path and the Korean `이다` suffix path use the same `resolvedAmbiguities` lookup. -- Kept behavior unchanged when `resolvedAmbiguities` is absent by falling back to the existing candidate-derived link generation. - -### Focused Verification - -Command: - -```bash -npx vitest run src/replace-links/__tests__/candidate-scanner.test.ts src/utils/__tests__/resolve-ambiguities.test.ts src/replace-links/__tests__/ai-disambiguation.test.ts src/replace-links/__tests__/replace-links.cjk.test.ts -``` - -Result: - -- Passed: `29/29` tests across `4/4` files - -### Full Verification - -Commands: - -```bash -npm run test -- --reporter=dot -npm run tsc -npm run lint -``` - -Results: - -- `npm run test -- --reporter=dot`: passed, `328/328` tests across `38/38` files -- `npm run tsc`: passed -- `npm run lint`: passed - -## Review Fix 5 - -### Scope - -Fixed the remaining AI ambiguity-path review finding: - -- threaded the current file path into `resolveAmbiguities()` with a safe default so existing callers stay compatible -- passed the active markdown file path without `.md` from `src/main.ts` -- added a regression proving `resolveAmbiguities()` skips self-link candidates when `preventSelfLinking` is enabled and the current file matches the candidate - -No AGENTS.md changes were needed. - -### TDD Evidence - -#### RED - -Command: - -```bash -npx vitest run src/utils/__tests__/resolve-ambiguities.test.ts -``` - -Result: - -- Failed as expected on the new self-link regression -- The AI request still contained `meeting` because `resolveAmbiguities()` was scanning with `filePath: ""` - -#### GREEN - -Commands: - -```bash -npx vitest run src/utils/__tests__/resolve-ambiguities.test.ts -npx vitest run src/replace-links/__tests__/candidate-scanner.test.ts -npx vitest run src/replace-links/__tests__/ai-disambiguation.test.ts src/replace-links/__tests__/replace-links.prevent-self-linking.test.ts -``` - -Results: - -- `src/utils/__tests__/resolve-ambiguities.test.ts`: passed `6/6` -- `src/replace-links/__tests__/candidate-scanner.test.ts`: passed `8/8` -- `src/replace-links/__tests__/ai-disambiguation.test.ts` and `src/replace-links/__tests__/replace-links.prevent-self-linking.test.ts`: passed `13/13` - -### Full Verification - -Commands: - -```bash -npm run test -- --reporter=dot -npm run tsc -npm run lint -``` - -Results: - -- First `npm run test -- --reporter=dot` run failed once on the existing namespace-resolution performance threshold: `364.82662500000004ms` vs the `300ms` limit -- Reran `npm run test -- --reporter=dot` and it passed: `329/329` tests across `38/38` files -- `npm run tsc`: passed -- `npm run lint`: passed - -### Implementation Summary - -- Added an optional `filePath` parameter to `resolveAmbiguities()` and threaded it into `scanCandidateOccurrences()` -- Passed `activeFile.path.replace(/\.md$/, "")` from the AI command in `src/main.ts` -- Kept scanner public interfaces unchanged - -### Files Changed - -- `src/utils/resolve-ambiguities.ts` -- `src/main.ts` -- `src/utils/__tests__/resolve-ambiguities.test.ts` - -## Review Fix 4 - -### Scope - -Fixed the remaining Task 1 review finding for Korean particle cursor advancement: - -- `scanCandidateOccurrences()` now advances by one codepoint after skipping a Korean particle hit, instead of consuming the full matched candidate length -- added a regression proving an isolated `문서는` hit is still omitted while an overlapping follow-on candidate at index `1` is still discovered - -No AGENTS.md changes were needed. - -### TDD Evidence - -#### RED - -Command: - -```bash -npx vitest run src/replace-links/__tests__/candidate-scanner.test.ts -``` - -Result: - -- Failed as expected -- Failure: the scanner skipped `문서` in `문서는` by `candidate.length`, so it never reached the overlapping `서는` candidate at index `1` - -#### GREEN - -Commands: - -```bash -npx vitest run src/replace-links/__tests__/candidate-scanner.test.ts -npx vitest run src/replace-links/__tests__/candidate-scanner.test.ts src/replace-links/__tests__/ai-disambiguation.test.ts src/utils/__tests__/resolve-ambiguities.test.ts src/replace-links/__tests__/prev.test.ts -npm run test -- --reporter=dot -npm run tsc -npm run lint -``` - -Results: - -- `src/replace-links/__tests__/candidate-scanner.test.ts`: passed `8/8` -- focused scanner/AI/CJK suite: passed `60/60` across `4/4` files -- initial full-suite run: failed once on the namespace-resolution performance check at `320.1955ms` versus the `300ms` threshold -- rerun of `npm run test -- --reporter=dot`: passed, `328/328` tests across `38/38` files -- `npm run tsc`: passed -- `npm run lint`: passed - -### Implementation Summary - -- 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 From 9a98513ea89b51ebfcc48af64c96a437ed0b3f04 Mon Sep 17 00:00:00 2001 From: Kodai Nakamura Date: Mon, 22 Jun 2026 04:06:18 +0900 Subject: [PATCH 24/25] 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 From f9d5291e3dcfae74a3adb2edc3805af3f2669b2a Mon Sep 17 00:00:00 2001 From: Kodai Nakamura Date: Mon, 22 Jun 2026 04:16:02 +0900 Subject: [PATCH 25/25] fix(candidate-scanner): honor ignored table rows Why: AI ambiguity scanning still inspected Markdown table rows when ignoreMarkdownTables was enabled, while replacement skipped those rows through centralized Markdown segmentation. That kept scanner and renderer semantics from fully matching. What: Route candidate occurrence scanning through segmentMarkdown protection, move table-row detection into the Markdown segment module, and add regressions for unlinked candidates, existing wikilinks, and AI requests inside ignored table rows. --- src/markdown-segments.ts | 18 +- .../__tests__/candidate-scanner.test.ts | 76 +++++++ src/replace-links/candidate-scanner.ts | 214 +++--------------- .../__tests__/resolve-ambiguities.test.ts | 25 ++ 4 files changed, 149 insertions(+), 184 deletions(-) diff --git a/src/markdown-segments.ts b/src/markdown-segments.ts index 5e5166b..7dda3d4 100644 --- a/src/markdown-segments.ts +++ b/src/markdown-segments.ts @@ -1,4 +1,3 @@ -import { isMarkdownTableLine } from "./replace-links/candidate-scanner" import { RAW_URL_SOURCE } from "./markdown-protection" export type MarkdownSegmentKind = "prose" | "protected" @@ -34,6 +33,23 @@ interface ProtectedRange { protectedKind: MarkdownProtectedKind } +export const isMarkdownTableLine = (line: string): boolean => { + const trimmedLine = line.trim() + if (!trimmedLine || !trimmedLine.includes("|")) { + return false + } + + if ( + trimmedLine.startsWith("|") + && trimmedLine.endsWith("|") + && /^[|:\s-]+$/.test(trimmedLine) + ) { + return true + } + + return trimmedLine.startsWith("|") && trimmedLine.endsWith("|") +} + const collectHeadingRanges = (text: string): ProtectedRange[] => { const ranges: ProtectedRange[] = [] const headingPattern = /^#{1,6}\s+.*$/gm diff --git a/src/replace-links/__tests__/candidate-scanner.test.ts b/src/replace-links/__tests__/candidate-scanner.test.ts index 13679d6..4d9c9a3 100644 --- a/src/replace-links/__tests__/candidate-scanner.test.ts +++ b/src/replace-links/__tests__/candidate-scanner.test.ts @@ -352,6 +352,82 @@ meeting`, ]) }) + it("skips ignored Markdown table rows but still finds prose outside the table", () => { + const { candidateMap, trie } = buildCandidateTrieForTest({ + files: [ + { path: "work/meeting" }, + { path: "private/meeting" }, + ], + settings: { + scoped: false, + baseDir: undefined, + ignoreCase: true, + }, + }) + + const text = `| Topic | +| --- | +| meeting | + +meeting` + + const occurrences = scanCandidateOccurrences({ + text, + filePath: "notes/today", + trie, + candidateMap, + settings: { + ignoreCase: true, + ignoreMarkdownTables: true, + proximityBasedLinking: true, + }, + }) + + expect(occurrences.map(o => ({ + kind: o.kind, + text: o.text, + start: o.start, + end: o.end, + }))).toEqual([ + { + kind: "unlinked", + text: "meeting", + start: text.lastIndexOf("meeting"), + end: text.lastIndexOf("meeting") + "meeting".length, + }, + ]) + }) + + it("skips existing wikilinks inside ignored Markdown table rows", () => { + const { candidateMap, trie } = buildCandidateTrieForTest({ + files: [ + { path: "work/meeting" }, + { path: "private/meeting" }, + ], + settings: { + scoped: false, + baseDir: undefined, + ignoreCase: true, + }, + }) + + const occurrences = scanCandidateOccurrences({ + text: `| Topic | +| --- | +| [[private/meeting|meeting]] |`, + filePath: "notes/today", + trie, + candidateMap, + settings: { + ignoreCase: true, + ignoreMarkdownTables: true, + proximityBasedLinking: true, + }, + }) + + expect(occurrences).toEqual([]) + }) + 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 a312cec..2752de2 100644 --- a/src/replace-links/candidate-scanner.ts +++ b/src/replace-links/candidate-scanner.ts @@ -1,5 +1,6 @@ import { CandidateData, getTopLevelDirectoryName, TrieNode } from "../trie" import { RAW_URL_AT_START_PATTERN, RAW_URL_SOURCE } from "../markdown-protection" +import { isMarkdownTableLine, segmentMarkdown } from "../markdown-segments" import type { ReplaceLinksSettings } from "./replace-links" export type CandidateOccurrenceKind = "unlinked" | "existing-wikilink" @@ -44,7 +45,6 @@ export const REGEX_PATTERNS = { KOREAN_SUFFIX: /^(이다\.?)/, KOREAN_PARTICLES: /^(는|은)/, KOREAN_PARTICLES_EXTENDED: /^(가|는|을|에|서|와|로부터|까지|보다|로|의|나|도|또한)/, - TABLE_SEPARATOR: /^[|:\s-]+$/, WORD_BOUNDARY: /[\p{L}\p{N}_/-]/u, WHITESPACE: /[\t\n\r ]/, } as const @@ -249,22 +249,6 @@ export const shouldSkipCandidate = ( return isMonthNote(candidate) } -export const isMarkdownTableLine = (line: string): boolean => { - const trimmedLine = line.trim() - if (!trimmedLine || !trimmedLine.includes("|")) { - return false - } - - if (trimmedLine.startsWith("|") - && trimmedLine.endsWith("|") - && REGEX_PATTERNS.TABLE_SEPARATOR.test(trimmedLine) - ) { - return true - } - - return trimmedLine.startsWith("|") && trimmedLine.endsWith("|") -} - export const isIndexInsideMarkdownTable = (text: string, index: number): boolean => { let lineStart = text.lastIndexOf("\n", index - 1) + 1 if (lineStart === 0 && text[0] !== "\n") { @@ -366,109 +350,22 @@ const dedupeCandidates = (candidates: CandidateData["candidates"]): CandidateDat } } -const findProtectedBlockRanges = ( - text: string, - settings: ReplaceLinksSettings, -): Array<{ start: number, end: number }> => { - const ranges: Array<{ start: number, end: number }> = [ - ...findFencedCodeBlockRanges(text), - ] - - if (settings.ignoreHeadings) { - const headingPattern = /^#{1,6}\s+.*$/gm - let headingMatch: RegExpExecArray | null - - while ((headingMatch = headingPattern.exec(text)) !== null) { - ranges.push({ - start: headingMatch.index, - end: headingMatch.index + headingMatch[0].length, - }) - } - } - - const calloutPattern = /^>[ \t]*\[![\w-]+\].*?(\n>.*?)*(?=\n(?!>)|$)/gm - let calloutMatch: RegExpExecArray | null - - while ((calloutMatch = calloutPattern.exec(text)) !== null) { - ranges.push({ - start: calloutMatch.index, - end: calloutMatch.index + calloutMatch[0].length, - }) - } - - ranges.sort((a, b) => a.start - b.start) - - const merged: Array<{ start: number, end: number }> = [] - for (const range of ranges) { - 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 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 [] - } - - const ranges: Array<{ start: number, end: number }> = [] - const inlineCodePattern = /`[^`]*`/g - let inlineCodeMatch: RegExpExecArray | null - - while ((inlineCodeMatch = inlineCodePattern.exec(text)) !== null) { - ranges.push({ - start: inlineCodeMatch.index, - end: inlineCodeMatch.index + inlineCodeMatch[0].length, - }) - } - - return ranges -} - -const isIndexProtected = ( - index: number, - protectedRanges: Array<{ start: number, end: number }>, -): boolean => { - return protectedRanges.some(range => index >= range.start && index < range.end) -} - const collectExistingWikilinks = ( text: string, + segments: ReturnType, candidateMap: Map, occurrences: CandidateOccurrence[], settings: ReplaceLinksSettings, - protectedRanges: Array<{ start: number, end: number }>, ): void => { - const existingLinkRegex = /\[\[([^|\]]+)(?:\|([^\]]+))?\]\]/g - let match: RegExpExecArray | null + const existingLinkRegex = /^\[\[([^|\]]+)(?:\|([^\]]+))?\]\]$/ - while ((match = existingLinkRegex.exec(text)) !== null) { - if (isIndexProtected(match.index, protectedRanges)) { + for (const segment of segments) { + if (segment.kind !== "protected" || segment.protectedKind !== "wikilink") { + continue + } + + const match = segment.text.match(existingLinkRegex) + if (!match) { continue } @@ -484,12 +381,12 @@ const collectExistingWikilinks = ( occurrences.push({ kind: "existing-wikilink", - start: match.index, - end: match.index + fullMatch.length, + start: segment.start, + end: segment.end, text: fullMatch, candidateKey, candidateData: dedupeCandidates(candidateData.candidates), - isInTable: isIndexInsideMarkdownTable(text, match.index), + isInTable: isIndexInsideMarkdownTable(text, segment.start), }) } } @@ -836,96 +733,47 @@ export const scanCandidateOccurrences = ({ const normalizedText = text.normalize("NFC") const fallbackIndex = buildFallbackIndex(candidateMap, settings.ignoreCase) const currentNamespace = getCurrentNamespace(filePath, settings.baseDir) - const protectedRanges = sortAndMergeRanges([ - ...findProtectedBlockRanges(normalizedText, settings), - ...findInlineCodeRanges(normalizedText), - ]) + const markdownSegments = segmentMarkdown(normalizedText, { + protectHeadings: settings.ignoreHeadings, + protectCallouts: true, + protectTableRows: settings.ignoreMarkdownTables, + protectUrls: true, + }) collectExistingWikilinks( normalizedText, + markdownSegments, candidateMap, occurrences, settings, - protectedRanges, ) - const scanUnprotectedSegment = ( - segment: string, - offset: number, - segmentOccurrences: CandidateOccurrence[], - ): void => { - REGEX_PATTERNS.PROTECTED.lastIndex = 0 - let lastProtectedIndex = 0 - let match: RegExpExecArray | null - - while ((match = REGEX_PATTERNS.PROTECTED.exec(segment)) !== null) { - const unprotectedSegment = segment.slice(lastProtectedIndex, match.index) - const nestedOccurrences: CandidateOccurrence[] = [] - collectUnlinkedOccurrences({ - text: unprotectedSegment, - filePath, - trie, - candidateMap, - fallbackIndex, - currentNamespace, - settings, - occurrences: nestedOccurrences, - }) - for (const occurrence of nestedOccurrences) { - segmentOccurrences.push({ - ...occurrence, - start: occurrence.start + offset + lastProtectedIndex, - end: occurrence.end + offset + lastProtectedIndex, - }) - } - lastProtectedIndex = match.index + match[0].length - - if (match[0].length === 0) { - REGEX_PATTERNS.PROTECTED.lastIndex++ - } + for (const segment of markdownSegments) { + if (segment.kind !== "prose") { + continue } - const nestedOccurrences: CandidateOccurrence[] = [] + const segmentOccurrences: CandidateOccurrence[] = [] collectUnlinkedOccurrences({ - text: segment.slice(lastProtectedIndex), + text: segment.text, filePath, trie, candidateMap, fallbackIndex, currentNamespace, settings, - occurrences: nestedOccurrences, + occurrences: segmentOccurrences, }) - for (const occurrence of nestedOccurrences) { - segmentOccurrences.push({ + + for (const occurrence of segmentOccurrences) { + occurrences.push({ ...occurrence, - start: occurrence.start + offset + lastProtectedIndex, - end: occurrence.end + offset + lastProtectedIndex, + start: occurrence.start + segment.start, + end: occurrence.end + segment.start, }) } } - const protectedFreeOccurrences: CandidateOccurrence[] = [] - let lastIndex = 0 - for (const range of protectedRanges) { - const segmentOccurrences: CandidateOccurrence[] = [] - scanUnprotectedSegment( - normalizedText.slice(lastIndex, range.start), - lastIndex, - segmentOccurrences, - ) - protectedFreeOccurrences.push(...segmentOccurrences) - lastIndex = range.end - } - - const tailOccurrences: CandidateOccurrence[] = [] - scanUnprotectedSegment( - normalizedText.slice(lastIndex), - lastIndex, - tailOccurrences, - ) - occurrences.push(...protectedFreeOccurrences, ...tailOccurrences) - return occurrences.sort((a, b) => a.start - b.start) } diff --git a/src/utils/__tests__/resolve-ambiguities.test.ts b/src/utils/__tests__/resolve-ambiguities.test.ts index b3754f0..53ac13a 100644 --- a/src/utils/__tests__/resolve-ambiguities.test.ts +++ b/src/utils/__tests__/resolve-ambiguities.test.ts @@ -263,4 +263,29 @@ meeting` ) expect(result.size).toBe(0) }) + + it("does not request AI for candidates inside ignored Markdown table rows", async () => { + vi.mocked(aiClient.resolveAmbiguitiesBatch).mockClear() + vi.mocked(aiClient.resolveAmbiguitiesBatch).mockResolvedValue(new Map()) + + const result = await resolveAmbiguities( + `| Topic | +| --- | +| meeting |`, + candidateMap, + trie, + { + ...mockSettings, + ignoreMarkdownTables: true, + }, + ) + + expect(aiClient.resolveAmbiguitiesBatch).toHaveBeenCalledWith( + expect.objectContaining({ + ignoreMarkdownTables: true, + }), + [], + ) + expect(result.size).toBe(0) + }) })